From 1c8aa7e7727707901ee0f9d900e39f37607cf66c Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Mon, 12 Oct 2026 23:37:48 -0700 Subject: [PATCH 1/9] Prototype a committed snapshot of every corpus name's reading (#664) tools/differential/snapshot.py records one line per (name, order) across every corpus*.jsonl: the seven role fields, ambiguity kinds and initials() on the 2.0 surface, plus the facade's fields where they differ. tests/v2/test_snapshot.py fails on drift and prints the moves grouped by which fields changed; --since REF compares against the snapshot at a git ref, --tree PATH reads with another checkout. Measured 2026-10-12: 3,422 readings, 495 KB, ~1.1s test. Run before and after #655, it reports five moved names where that PR spent 241 lines on ledger and guard edits. Co-Authored-By: Claude Opus 5.5 --- tests/v2/test_snapshot.py | 31 + tools/differential/snapshot.jsonl | 3422 +++++++++++++++++++++++++++++ tools/differential/snapshot.py | 183 ++ 3 files changed, 3636 insertions(+) create mode 100644 tests/v2/test_snapshot.py create mode 100644 tools/differential/snapshot.jsonl create mode 100644 tools/differential/snapshot.py diff --git a/tests/v2/test_snapshot.py b/tests/v2/test_snapshot.py new file mode 100644 index 00000000..eff306f4 --- /dev/null +++ b/tests/v2/test_snapshot.py @@ -0,0 +1,31 @@ +"""The tree's readings of every corpus name match the committed snapshot +(tools/differential/snapshot.py). A failure lists the moved names +grouped by which fields changed; re-record if the moves are intended, +and the diff of snapshot.jsonl is then the review's list of moves.""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +_PATH = Path(__file__).resolve().parents[2] / "tools" / "differential" / "snapshot.py" +_spec = importlib.util.spec_from_file_location("_snapshot", _PATH) +assert _spec and _spec.loader +snapshot = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(snapshot) + + +def test_readings_match_the_snapshot() -> None: + recorded = snapshot.load(snapshot.SNAPSHOT.read_text(encoding="utf-8")) + current = {snapshot.key(r): r for r in snapshot.read(snapshot.corpus_entries())} + moves = snapshot.grouped(recorded, current) + assert not moves, ("\n".join(moves) + + f"\n\nif these moves are intended: {snapshot.RECORD_COMMAND}") + + +def test_the_snapshot_can_report_a_move() -> None: + # A clean run and a broken comparison print the same thing; prove + # this one can see a difference. + rows = snapshot.read([("John Smith", None)]) + moved = [{**rows[0], "family": "Smyth"}] + assert snapshot.grouped({snapshot.key(r): r for r in rows}, + {snapshot.key(r): r for r in moved}) diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl new file mode 100644 index 00000000..2234d57b --- /dev/null +++ b/tools/differential/snapshot.jsonl @@ -0,0 +1,3422 @@ +{"name": ""} +{"name": "\"Rick\" Edmonds", "family": "Edmonds", "nickname": "Rick", "initials": "E."} +{"name": "'Smitty' Dr. Jones", "title": "Dr.", "family": "Jones", "nickname": "Smitty", "initials": "J."} +{"name": "'Smitty' John Jones", "given": "John", "family": "Jones", "nickname": "Smitty", "initials": "J. J."} +{"name": "'Smitty' Jones", "family": "Jones", "nickname": "Smitty", "initials": "J."} +{"name": "'Smitty' Jones Jr.", "given": "Jones", "suffix": "Jr.", "nickname": "Smitty", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "'Smitty' Sir John", "title": "Sir", "given": "John", "nickname": "Smitty", "initials": "J."} +{"name": "(", "ambiguities": ["UNBALANCED_DELIMITER"]} +{"name": "()"} +{"name": "(김민준.) John Smith", "given": "김", "middle": "민준. John", "family": "Smith", "initials": "김. 민. J. S."} +{"name": ","} +{"name": ", John", "given": "John", "initials": "J."} +{"name": ".,"} +{"name": "1 & 2, 3 4 5, Mr.", "title": "Mr.", "given": "3", "middle": "4 5", "family": "1 & 2", "initials": "3. 4. 5. 1. 2."} +{"name": "A. D.", "given": "A.", "family": "D.", "initials": "A. D."} +{"name": "A. R., Sr. Maria García Rosa de la Cruz Jr.", "title": "Sr.", "given": "Maria", "middle": "García Rosa de la Cruz", "family": "A. R.", "suffix": "Jr.", "initials": "M. G. R. C. A. R."} +{"name": "A. R., Sr. de la Cruz Maria García Rosa Jr.", "order": "FAMILY_FIRST", "title": "Sr.", "family": "A. R. de la Cruz Maria García Rosa", "suffix": "Jr.", "initials": "A. R. C. M. G. R."} +{"name": "A.B. Vajpayee", "given": "A.B.", "family": "Vajpayee", "initials": "A. V."} +{"name": "AKIME Fuseena", "order": "FAMILY_FIRST", "given": "Fuseena", "family": "AKIME", "initials": "F. A."} +{"name": "ANH DO", "given": "ANH", "family": "DO", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. D."} +{"name": "Abd Berg", "given": "Abd", "family": "Berg", "initials": "A. B."} +{"name": "Abd Berg née Jones", "given": "Abd", "family": "Berg", "maiden": "Jones", "initials": "A. B."} +{"name": "Abd van der Berg", "given": "Abd", "family": "van der Berg", "initials": "A. B."} +{"name": "Abd van der Berg née Jones", "given": "Abd", "family": "van der Berg", "maiden": "Jones", "initials": "A. B."} +{"name": "Abdul Salam Ahmed Salem, MD", "given": "Abdul Salam", "middle": "Ahmed", "family": "Salem", "suffix": "MD", "initials": "A. S. A. S."} +{"name": "Abdul Salam Hassan, MD", "given": "Abdul Salam", "family": "Hassan", "suffix": "MD", "initials": "A. S. H."} +{"name": "Abdul Salam, MD", "given": "Abdul", "family": "Salam", "suffix": "MD", "initials": "A. S."} +{"name": "Abu Bakar", "given": "Abu", "family": "Bakar", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. B."} +{"name": "Abu Bakar Salim", "given": "Abu Bakar", "family": "Salim", "initials": "A. B. S."} +{"name": "Abu Bakr Al Baghdadi, MD", "given": "Abu Bakr", "family": "Al Baghdadi", "suffix": "MD", "initials": "A. B. B."} +{"name": "Abɖu-Tɔyib GOMINA MUSA", "given": "Abɖu-Tɔyib", "middle": "GOMINA", "family": "MUSA", "initials": "A. G. M."} +{"name": "Ada", "given": "Ada", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Ada", "order": "FAMILY_FIRST", "family": "Ada", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Ada Cornelia César Martín von Brühl M.D. Ph.D.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "M.D. Ph.D.", "initials": "A. C. C. M. B."} +{"name": "Ada Cornelia César Martín von Brühl MD DDS", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "MD DDS", "initials": "A. C. C. M. B."} +{"name": "Ada Cornelia Eva Sophia van. den. Wolf M.D. Ph.D.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van. den. Wolf", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W."} +{"name": "Ada Cornelia Eva Sophia von Wolf MD DDS", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "von Wolf", "suffix": "MD DDS", "initials": "A. C. E. S. W."} +{"name": "Ada Kornelia Yeva Sofiya van den Volf M.D. Ph.D.", "given": "Ada", "middle": "Kornelia Yeva Sofiya", "family": "van den Volf", "suffix": "M.D. Ph.D.", "initials": "A. K. Y. S. V."} +{"name": "Adebola Opeyemi Olumide Ajao Adekunle Asofin", "given": "Adebola", "middle": "Opeyemi Olumide Ajao Adekunle", "family": "Asofin", "initials": "A. O. O. A. A. A."} +{"name": "Adeboye Olabisi", "order": "FAMILY_FIRST", "given": "Olabisi", "family": "Adeboye", "initials": "O. A."} +{"name": "Adeboye, Olabisi", "given": "Olabisi", "family": "Adeboye", "initials": "O. A."} +{"name": "Adekunle Adebola Opeyemi Olumide Ajao Asofin", "order": "FAMILY_FIRST", "given": "Adebola", "middle": "Opeyemi Olumide Ajao Asofin", "family": "Adekunle", "initials": "A. O. O. A. A. A."} +{"name": "Adekunle, Adebola Opeyemi Olumide Ajao", "given": "Adebola", "middle": "Opeyemi Olumide Ajao", "family": "Adekunle", "initials": "A. O. O. A. A."} +{"name": "Adeolu Adegboyega Akintola", "given": "Adeolu", "middle": "Adegboyega", "family": "Akintola", "initials": "A. A. A."} +{"name": "Adinata, Dwi Putro bin", "given": "Dwi", "middle": "Putro", "family": "bin Adinata", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. P. A."} +{"name": "Adler Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Adler", "initials": "I. A."} +{"name": "Adler İren", "order": "FAMILY_FIRST", "given": "İren", "family": "Adler", "initials": "İ. A."} +{"name": "Adler, Irene", "given": "Irene", "family": "Adler", "initials": "I. A."} +{"name": "Adolph D", "given": "Adolph", "family": "D", "initials": "A. D."} +{"name": "Adrian Dragos Ionescu", "given": "Adrian", "middle": "Dragos", "family": "Ionescu", "initials": "A. D. I."} +{"name": "Aduritz, Irene", "given": "Irene", "family": "Aduritz", "initials": "I. A."} +{"name": "Aduritz, Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Aduritz", "initials": "I. A."} +{"name": "Adá Cornelia Ẹ́va Sophia ván den Wólf M.D. PhD.", "given": "Adá", "middle": "Cornelia Ẹ́va Sophia ván", "family": "den Wólf", "suffix": "M.D. PhD.", "initials": "A. C. Ẹ. S. v. W."} +{"name": "Adèle", "given": "Adèle", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Adèle", "order": "FAMILY_FIRST", "family": "Adèle", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Adélaïde Lemaître", "given": "Adélaïde", "family": "Lemaître", "initials": "A. L."} +{"name": "Adólfsdóttir Íris", "order": "FAMILY_FIRST", "given": "Íris", "family": "Adólfsdóttir", "initials": "Í. A."} +{"name": "Ahmad Albar bin Akbar", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "initials": "A. A. A."} +{"name": "Ahmad Jayadi, CHA", "given": "Ahmad", "family": "Jayadi", "suffix": "CHA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. J."} +{"name": "Ahmad ben Husain", "given": "Ahmad", "middle": "ben", "family": "Husain", "initials": "A. b. H."} +{"name": "Aina Kalniņa", "given": "Aina", "family": "Kalniņa", "initials": "A. K."} +{"name": "Aishwarya Rai", "given": "Aishwarya", "family": "Rai", "initials": "A. R."} +{"name": "Aishwarya Rai Bachchan", "given": "Aishwarya", "middle": "Rai", "family": "Bachchan", "initials": "A. R. B."} +{"name": "Aita Caduff", "given": "Aita", "family": "Caduff", "initials": "A. C."} +{"name": "Ajay Reddy B", "given": "Ajay", "middle": "Reddy", "family": "B", "initials": "A. R. B."} +{"name": "Akbar, Ahmad Albar bin", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. A. A."} +{"name": "Akbar, Ahmad Albar bin", "order": "FAMILY_FIRST", "given": "Ahmad", "middle": "Albar", "family": "bin Akbar", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. A. A."} +{"name": "Akintola Adeolu Adegboyega", "order": "FAMILY_FIRST", "given": "Adeolu", "middle": "Adegboyega", "family": "Akintola", "initials": "A. A. A."} +{"name": "Akintola, Adeolu Adegboyega", "given": "Adeolu", "middle": "Adegboyega", "family": "Akintola", "initials": "A. A. A."} +{"name": "Al Arnold Gore, Jr.", "given": "Al", "middle": "Arnold", "family": "Gore", "suffix": "Jr.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. A. G."} +{"name": "Alex Ben Johnson", "given": "Alex", "middle": "Ben", "family": "Johnson", "initials": "A. B. J."} +{"name": "Alex van Johnson", "given": "Alex", "family": "van Johnson", "initials": "A. J."} +{"name": "Alexandru Ioan Popa", "given": "Alexandru", "middle": "Ioan", "family": "Popa", "initials": "A. I. P."} +{"name": "Alfɔns MANƉELA Rolanɖ", "given": "Alfɔns", "middle": "MANƉELA", "family": "Rolanɖ", "initials": "A. M. R."} +{"name": "Ali Ahmad Vali oglu", "given": "Ali", "middle": "Ahmad Vali", "family": "oglu", "initials": "A. A. V. o."} +{"name": "Ali Ahmad Vali oglu Jr.", "given": "Ali", "middle": "Ahmad Vali", "family": "oglu", "suffix": "Jr.", "initials": "A. A. V. o."} +{"name": "Ali Ahmad oglu", "given": "Ali", "middle": "Ahmad", "family": "oglu", "initials": "A. A. o."} +{"name": "Ali Amina", "order": "FAMILY_FIRST", "given": "Amina", "family": "Ali", "initials": "A. A."} +{"name": "Ali Juma Hamisi", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} +{"name": "Ali, Amina", "given": "Amina", "family": "Ali", "initials": "A. A."} +{"name": "Alina", "given": "Alina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Alina", "order": "FAMILY_FIRST", "family": "Alina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Alisher Salimovich Qodirov Deputat", "given": "Alisher", "middle": "Salimovich Qodirov", "family": "Deputat", "initials": "A. S. Q. D."} +{"name": "Alisher Sattorov", "given": "Alisher", "family": "Sattorov", "initials": "A. S."} +{"name": "Aliyev Vusal Said oglu", "given": "Aliyev", "middle": "Vusal Said", "family": "oglu", "initials": "A. V. S. o."} +{"name": "Alois von und zu Liechtenstein", "given": "Alois", "family": "von und zu Liechtenstein", "initials": "A. L."} +{"name": "Alois von und zu und von Liechtenstein", "given": "Alois", "family": "von und zu und von Liechtenstein", "initials": "A. L."} +{"name": "Amadi John Chinedu", "order": "FAMILY_FIRST", "given": "John", "middle": "Chinedu", "family": "Amadi", "initials": "J. C. A."} +{"name": "Amadi Maazị Christopher Chukwudike Jr, MP", "order": "FAMILY_FIRST", "given": "Maazị", "middle": "Christopher Chukwudike", "family": "Amadi", "suffix": "Jr, MP", "initials": "M. C. C. A."} +{"name": "Amadi, Christopher Chukwudike", "given": "Christopher", "middle": "Chukwudike", "family": "Amadi", "initials": "C. C. A."} +{"name": "Amadi, John Chinedu", "given": "John", "middle": "Chinedu", "family": "Amadi", "initials": "J. C. A."} +{"name": "Amangeldi Saparow", "given": "Amangeldi", "family": "Saparow", "initials": "A. S."} +{"name": "Amarildo Lombardi", "given": "Amarildo", "family": "Lombardi", "initials": "A. L."} +{"name": "Amina Ali", "given": "Amina", "family": "Ali", "initials": "A. A."} +{"name": "Amy E Maid", "given": "Amy", "middle": "E", "family": "Maid", "initials": "A. E. M."} +{"name": "Amy E. Maid", "given": "Amy", "middle": "E.", "family": "Maid", "initials": "A. E. M."} +{"name": "An tOll. An Dr. Brigid Aoibhe Sophia de Bhulbh", "given": "An", "middle": "tOll. An Dr. Brigid Aoibhe Sophia", "family": "de Bhulbh", "initials": "A. t. A. D. B. A. S. B."} +{"name": "Ana Marija Vera Kovač", "given": "Ana", "middle": "Marija Vera", "family": "Kovač", "initials": "A. M. V. K."} +{"name": "Ana Torres", "given": "Ana", "family": "Torres", "initials": "A. T."} +{"name": "Andersen Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Andersen", "initials": "I. A."} +{"name": "Andersen, Irene", "given": "Irene", "family": "Andersen", "initials": "I. A."} +{"name": "Andersonさん", "given": "Anderson", "suffix": "さん", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Anderson선생님", "given": "Anderson", "suffix": "선생님", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Andersson Ingrid", "order": "FAMILY_FIRST", "given": "Ingrid", "family": "Andersson", "initials": "I. A."} +{"name": "Andersson, Ingrid", "order": "FAMILY_FIRST", "given": "Ingrid", "family": "Andersson", "initials": "I. A."} +{"name": "Andrew", "given": "Andrew", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Andrew (Andy) Perkins", "given": "Andrew", "family": "Perkins", "nickname": "Andy", "initials": "A. P."} +{"name": "Andrew Boris Petersen", "given": "Andrew", "middle": "Boris", "family": "Petersen", "initials": "A. B. P."} +{"name": "Andrew Ma", "given": "Andrew", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. M."} +{"name": "Andrew Perkins \"MBA\"", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} +{"name": "Andrew Perkins 'MBA'", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} +{"name": "Andrew Perkins (Andy)", "given": "Andrew", "family": "Perkins", "nickname": "Andy", "initials": "A. P."} +{"name": "Andrew Perkins (JD)", "given": "Andrew", "family": "Perkins", "nickname": "JD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "initials": "A. P."} +{"name": "Andrew Perkins (M.D)", "given": "Andrew", "family": "Perkins", "suffix": "M.D", "initials": "A. P."} +{"name": "Andrew Perkins (MBA)", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} +{"name": "Andrew Perkins (Mgr.)", "title": "Mgr.", "given": "Andrew", "family": "Perkins", "initials": "A. P."} +{"name": "Andrew Perkins (XYZ)", "given": "Andrew", "family": "Perkins", "nickname": "XYZ", "initials": "A. P."} +{"name": "Andrew Perkins, Jr., Col. (Ret)", "title": "Col.", "given": "Andrew", "family": "Perkins", "suffix": "Jr., Ret", "initials": "A. P."} +{"name": "Andrews, M.D.", "family": "Andrews", "suffix": "M.D.", "initials": "A."} +{"name": "Ane Miren Urrestaratzu", "given": "Ane", "middle": "Miren", "family": "Urrestaratzu", "initials": "A. M. U."} +{"name": "Anh", "given": "Anh", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Anh", "order": "FAMILY_FIRST", "family": "Anh", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Anh Do", "given": "Anh", "family": "Do", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. D."} +{"name": "Anh Nguyễn", "given": "Anh", "family": "Nguyễn", "initials": "A. N."} +{"name": "Anh Van Do", "given": "Anh", "family": "Van Do", "initials": "A. V. D."} +{"name": "Anh Văn Nguyễn", "given": "Anh", "middle": "Văn", "family": "Nguyễn", "initials": "A. V. N."} +{"name": "Anh do", "given": "Anh", "family": "do", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. d."} +{"name": "Anh van Do", "given": "Anh", "family": "van Do", "initials": "A. v. D."} +{"name": "Ann H", "given": "Ann", "family": "H", "initials": "A. H."} +{"name": "Anna () Smith", "given": "Anna", "family": "Smith", "initials": "A. S."} +{"name": "Anna Greve", "given": "Anna", "family": "Greve", "initials": "A. G."} +{"name": "Anna Liina Eva Sofia von Vall, MD, PhD", "given": "Anna", "middle": "Liina Eva Sofia", "family": "von Vall", "suffix": "MD, PhD", "initials": "A. L. E. S. V."} +{"name": "Anna Müller (geb. Schmidt)", "given": "Anna", "family": "Müller", "maiden": "Schmidt", "initials": "A. M."} +{"name": "Anna Müller geb. Schmidt", "given": "Anna", "family": "Müller", "maiden": "Schmidt", "initials": "A. M."} +{"name": "Anna Paula Bērziņa", "given": "Anna", "middle": "Paula", "family": "Bērziņa", "initials": "A. P. B."} +{"name": "Anna z (domu) Nowak", "given": "Anna", "middle": "z", "family": "Nowak", "nickname": "domu", "initials": "A. z. N."} +{"name": "Anna z Nowak", "given": "Anna", "middle": "z", "family": "Nowak", "initials": "A. z. N."} +{"name": "Anne Berit Bernhard Nilsen", "given": "Anne", "middle": "Berit Bernhard", "family": "Nilsen", "initials": "A. B. B. N."} +{"name": "Anne Sepp", "given": "Anne", "family": "Sepp", "initials": "A. S."} +{"name": "Anne Sofie Holm Hansen", "given": "Anne", "middle": "Sofie Holm", "family": "Hansen", "initials": "A. S. H. H."} +{"name": "Annelise Weber", "given": "Annelise", "family": "Weber", "initials": "A. W."} +{"name": "Annette Charlotte Freiherrin von und zu der Tann-Rathsamhausen", "given": "Annette", "middle": "Charlotte", "family": "Freiherrin von und zu der Tann-Rathsamhausen", "initials": "A. C. T."} +{"name": "Ardelean, Daniel Ionescu Dragoș Ioan", "given": "Daniel", "middle": "Ionescu Dragoș Ioan", "family": "Ardelean", "initials": "D. I. D. I. A."} +{"name": "Asmar", "given": "Asmar", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Asmar", "order": "FAMILY_FIRST", "family": "Asmar", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "A."} +{"name": "Assoc Dean", "title": "Assoc", "family": "Dean", "initials": "D."} +{"name": "Assoc Dean of Chemistry Robert Johns", "title": "Assoc", "given": "Dean of Chemistry", "middle": "Robert", "family": "Johns", "initials": "D. C. R. J."} +{"name": "Asst. Vice Chancellor John Smith", "title": "Asst. Vice Chancellor", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Attorney General of Minnesota", "title": "Attorney", "family": "General of Minnesota", "ambiguities": ["TITLE_OR_NAME"], "initials": "G. M."} +{"name": "Awa Isseu", "order": "FAMILY_FIRST", "given": "Isseu", "family": "Awa", "initials": "I. A."} +{"name": "Aydan Arif Aliyev İlvus Santak Deputat", "order": "FAMILY_FIRST", "given": "Arif", "middle": "Aliyev İlvus Santak Deputat", "family": "Aydan", "initials": "A. A. İ. S. D. A."} +{"name": "Aydan Cənab Arif Aliyev İlvus Santak Deputat", "order": "FAMILY_FIRST", "given": "Cənab", "middle": "Arif Aliyev İlvus Santak Deputat", "family": "Aydan", "initials": "C. A. A. İ. S. D. A."} +{"name": "Baba Ramdev", "title": "Baba", "given": "Ramdev", "initials": "R."} +{"name": "Babić, Marijana Tomislav", "given": "Marijana", "middle": "Tomislav", "family": "Babić", "initials": "M. T. B."} +{"name": "Babić, Marijana Tomislav", "order": "FAMILY_FIRST", "given": "Marijana", "middle": "Tomislav", "family": "Babić", "initials": "M. T. B."} +{"name": "Baker (Johnson), Jenny", "given": "Jenny", "family": "Baker", "nickname": "Johnson", "initials": "J. B."} +{"name": "Baker (Jr.), Jenny", "given": "Jenny", "family": "Baker", "suffix": "Jr.", "initials": "J. B."} +{"name": "Bakker Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Bakker", "initials": "I. B."} +{"name": "Bakker, Irene", "given": "Irene", "family": "Bakker", "initials": "I. B."} +{"name": "Balóch Karima", "order": "FAMILY_FIRST", "given": "Karima", "family": "Balóch", "initials": "K. B."} +{"name": "Balóch Karima Mehráb", "order": "FAMILY_FIRST", "given": "Karima", "middle": "Mehráb", "family": "Balóch", "initials": "K. M. B."} +{"name": "Balóch Wj. Monir Mómen Monir Mómen", "order": "FAMILY_FIRST", "given": "Wj.", "middle": "Monir Mómen Monir Mómen", "family": "Balóch", "initials": "W. M. M. M. M. B."} +{"name": "Bapak Adinata, Dwi Putro bin MP", "order": "FAMILY_FIRST", "given": "Dwi", "middle": "Putro", "family": "bin Bapak Adinata", "suffix": "MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. P. B. A."} +{"name": "Bapak Dwi Putro bin Adinata MP", "given": "Bapak", "middle": "Dwi Putro", "family": "bin Adinata", "suffix": "MP", "initials": "B. D. P. A."} +{"name": "Barbara Scharf", "given": "Barbara", "family": "Scharf", "initials": "B. S."} +{"name": "Barbara Scharf, CPPM", "given": "Barbara", "family": "Scharf", "suffix": "CPPM", "initials": "B. S."} +{"name": "Baron Davis", "title": "Baron", "family": "Davis", "initials": "D."} +{"name": "Beauchêne Jacqueline", "order": "FAMILY_FIRST", "given": "Jacqueline", "family": "Beauchêne", "initials": "J. B."} +{"name": "Beauchêne, Jacqueline", "given": "Jacqueline", "family": "Beauchêne", "initials": "J. B."} +{"name": "Beethoven Ludwig van", "given": "Beethoven", "middle": "Ludwig", "family": "van", "initials": "B. L. v."} +{"name": "Beethoven, Ludwig van", "given": "Ludwig", "family": "van Beethoven", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "L. B."} +{"name": "Ben Alex Johnson", "given": "Ben", "middle": "Alex", "family": "Johnson", "initials": "B. A. J."} +{"name": "Ben Johnson", "given": "Ben", "family": "Johnson", "initials": "B. J."} +{"name": "Benjamin \"Ben\" Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} +{"name": "Benjamin \"Big Ben\" Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Big Ben", "initials": "B. F."} +{"name": "Benjamin 'Ben' Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} +{"name": "Benjamin (Ben) Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} +{"name": "Benjamin (Big Ben) Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Big Ben", "initials": "B. F."} +{"name": "Benjamin [Ben] Franklin", "given": "Benjamin", "middle": "[Ben]", "family": "Franklin", "initials": "B. [. F."} +{"name": "Benno Hans Meyer", "given": "Benno", "middle": "Hans", "family": "Meyer", "initials": "B. H. M."} +{"name": "Berg Jan de Jr.", "given": "Berg", "middle": "Jan", "family": "de", "suffix": "Jr.", "initials": "B. J. d."} +{"name": "Berg, Cornelia Margarita Sofia Aletta van den", "given": "Cornelia", "middle": "Margarita Sofia Aletta", "family": "van den Berg", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "C. M. S. A. B."} +{"name": "Berg, Jan mc", "given": "Jan", "family": "mc Berg", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B."} +{"name": "Berg, Jan van der", "given": "Jan", "family": "van der Berg", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. B."} +{"name": "Berg, Jan vd", "given": "Jan", "family": "vd Berg", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B."} +{"name": "Berg, Jane van der nee Smith DO", "given": "Jane", "middle": "van der nee Smith", "family": "Berg", "suffix": "DO", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. B."} +{"name": "Berg, abd née Jones", "given": "abd", "middle": "née Jones", "family": "Berg", "initials": "a. n. J. B."} +{"name": "Berg, abdul MA", "given": "abdul MA", "family": "Berg", "initials": "a. M. B."} +{"name": "Berg, abdul Prof.", "title": "Prof.", "given": "abdul", "family": "Berg", "initials": "a. B."} +{"name": "Berg, abdul Prof. Ali", "given": "abdul Prof.", "middle": "Ali", "family": "Berg", "initials": "a. P. A. B."} +{"name": "Berg, abdul V", "given": "abdul V", "family": "Berg", "initials": "a. V. B."} +{"name": "Berg, abdul nee Jones MA", "given": "abdul", "middle": "nee Jones", "family": "Berg", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. n. J. B."} +{"name": "Berg, abdul van", "given": "abdul van", "family": "Berg", "initials": "a. v. B."} +{"name": "Berg, abdul vd", "given": "abdul", "family": "vd Berg", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. B."} +{"name": "Bertram Wilberforce Henry Robert Wooster", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "initials": "B. W. H. R. W."} +{"name": "Bertram Wilberforce Henry Robert Wooster AS", "given": "Bertram", "middle": "Wilberforce Henry Robert Wooster", "family": "AS", "initials": "B. W. H. R. W. A."} +{"name": "Bertram Wilberforce Henry Robert Wooster MP", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} +{"name": "Beth Edmunds", "given": "Beth", "family": "Edmunds", "initials": "B. E."} +{"name": "Beyoncé Knowles", "given": "Beyoncé", "family": "Knowles", "initials": "B. K."} +{"name": "Bharati Lalit", "order": "FAMILY_FIRST", "given": "Lalit", "family": "Bharati", "initials": "L. B."} +{"name": "Bharati Lalit Mohan", "order": "FAMILY_FIRST", "given": "Lalit", "middle": "Mohan", "family": "Bharati", "initials": "L. M. B."} +{"name": "Bharati Shree Lalit Kumaar Jr, MP", "order": "FAMILY_FIRST", "given": "Shree", "middle": "Lalit Kumaar", "family": "Bharati", "suffix": "Jr, MP", "initials": "S. L. K. B."} +{"name": "Bharati, Lalit", "given": "Lalit", "family": "Bharati", "initials": "L. B."} +{"name": "Bharati, Lalit Kumaar", "given": "Lalit", "middle": "Kumaar", "family": "Bharati", "initials": "L. K. B."} +{"name": "Bharati, Lalit Mohan", "given": "Lalit", "middle": "Mohan", "family": "Bharati", "initials": "L. M. B."} +{"name": "Blerim", "given": "Blerim", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Blerim", "order": "FAMILY_FIRST", "family": "Blerim", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Bob Dole", "given": "Bob", "family": "Dole", "initials": "B. D."} +{"name": "Bob Jones, author", "title": "author", "given": "Bob", "family": "Jones", "initials": "B. J."} +{"name": "Bob Jones, compositeur", "title": "compositeur", "given": "Bob", "family": "Jones", "initials": "B. J."} +{"name": "Bobur", "given": "Bobur", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Bobur", "order": "FAMILY_FIRST", "family": "Bobur", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Boluwatife", "given": "Boluwatife", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Boluwatife", "order": "FAMILY_FIRST", "family": "Boluwatife", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Bouchart, Jean-Nicolas Louis Marcel de", "given": "Jean-Nicolas", "middle": "Louis Marcel", "family": "de Bouchart", "initials": "J. L. M. B."} +{"name": "Brais Manoel Souto", "given": "Brais", "middle": "Manoel", "family": "Souto", "initials": "B. M. S."} +{"name": "Brian Andrew O'connor", "given": "Brian", "middle": "Andrew", "family": "O'connor", "initials": "B. A. O."} +{"name": "Brian O'connor", "given": "Brian", "family": "O'connor", "initials": "B. O."} +{"name": "Bridge (1.4)", "family": "Bridge", "nickname": "1.4", "initials": "B."} +{"name": "Bridge (A.B)", "family": "Bridge", "nickname": "A.B", "initials": "B."} +{"name": "Bruhel, Ada Cornelia Martina von", "given": "Ada", "middle": "Cornelia Martina", "family": "von Bruhel", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. M. B."} +{"name": "Bruhl, Ada Cornelia Cesar Martin von", "given": "Ada", "middle": "Cornelia Cesar Martin", "family": "von Bruhl", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. C. M. B."} +{"name": "Bruhl, Ada Cornelia Cesar Martín von", "given": "Ada", "middle": "Cornelia Cesar Martín", "family": "von Bruhl", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. C. M. B."} +{"name": "Brundridge, Contessa A", "given": "Contessa", "middle": "A", "family": "Brundridge", "initials": "C. A. B."} +{"name": "Brühl, Ada Cornelia César Martín von", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. C. M. B."} +{"name": "Brühl, Ada Cornelia César Martín von (Prof. Dr., M.D. DDS.)", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "M.D. DDS.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. C. M. B."} +{"name": "Brühl, Mary Lou Cesare Martín von", "given": "Mary", "middle": "Lou Cesare Martín", "family": "von Brühl", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "M. L. C. M. B."} +{"name": "Brühl, María Florencia Martina Cristina von", "given": "María", "middle": "Florencia Martina Cristina", "family": "von Brühl", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "M. F. M. C. B."} +{"name": "Brül Cabral de Melo, Erika Cornelia Anna Isabelle von", "given": "Erika", "middle": "Cornelia Anna Isabelle", "family": "von Brül Cabral de Melo", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "E. C. A. I. B. C. M."} +{"name": "Brül, Ada Kornelia Sezar Martin fon", "given": "Ada", "middle": "Kornelia Sezar Martin fon", "family": "Brül", "initials": "A. K. S. M. f. B."} +{"name": "Buca di Beppo", "given": "Buca", "family": "di Beppo", "initials": "B. B."} +{"name": "Budi", "given": "Budi", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Budi", "order": "FAMILY_FIRST", "family": "Budi", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "B."} +{"name": "Bukenya Zamara Imani", "order": "FAMILY_FIRST", "given": "Zamara", "middle": "Imani", "family": "Bukenya", "initials": "Z. I. B."} +{"name": "Bukenya, Zamara Imani", "given": "Zamara", "middle": "Imani", "family": "Bukenya", "initials": "Z. I. B."} +{"name": "Bw. Adam Juma Agape Shukurani Masalu MB", "title": "Bw.", "given": "Adam", "middle": "Juma Agape Shukurani Masalu", "family": "MB", "initials": "A. J. A. S. M. M."} +{"name": "Bērziņa Anna", "order": "FAMILY_FIRST", "given": "Anna", "family": "Bērziņa", "initials": "A. B."} +{"name": "Bērziņa Anna Paula", "order": "FAMILY_FIRST", "given": "Anna", "middle": "Paula", "family": "Bērziņa", "initials": "A. P. B."} +{"name": "Caduff, Aita", "given": "Aita", "family": "Caduff", "initials": "A. C."} +{"name": "Caduff, Aita", "order": "FAMILY_FIRST", "given": "Aita", "family": "Caduff", "initials": "A. C."} +{"name": "Caimbeul Dòmhnall Iain Tormod", "order": "FAMILY_FIRST", "given": "Dòmhnall", "middle": "Iain Tormod", "family": "Caimbeul", "initials": "D. I. T. C."} +{"name": "Caimbeul, Dòmhnall Iain Tormod", "given": "Dòmhnall", "middle": "Iain Tormod", "family": "Caimbeul", "initials": "D. I. T. C."} +{"name": "Calum", "given": "Calum", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "C."} +{"name": "Calum", "order": "FAMILY_FIRST", "family": "Calum", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "C."} +{"name": "Canth Ulrika Wilhelmina", "order": "FAMILY_FIRST", "given": "Ulrika", "middle": "Wilhelmina", "family": "Canth", "initials": "U. W. C."} +{"name": "Canth, Ulrika Wilhelmina", "given": "Ulrika", "middle": "Wilhelmina", "family": "Canth", "initials": "U. W. C."} +{"name": "Cardinal Secretary of State Hillary Clinton", "title": "Cardinal Secretary of State", "given": "Hillary", "family": "Clinton", "initials": "H. C."} +{"name": "Carlos Zabala", "given": "Carlos", "family": "Zabala", "initials": "C. Z."} +{"name": "Carod i", "given": "Carod", "suffix": "i", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "C."} +{"name": "Carod i Rovira", "given": "Carod", "middle": "i", "family": "Rovira", "initials": "C. i. R."} +{"name": "Carod i Rovira, Josep", "given": "Josep", "family": "Carod i Rovira", "initials": "J. C. R."} +{"name": "Carod y de Rovira i", "given": "Carod", "middle": "y", "family": "de Rovira", "suffix": "i", "initials": "C. y. R."} +{"name": "Carolina", "given": "Carolina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "C."} +{"name": "Carolina", "order": "FAMILY_FIRST", "family": "Carolina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "C."} +{"name": "Castro Sáez, Xosé Carlos Francisco Xavier de", "given": "Xosé", "middle": "Carlos Francisco Xavier", "family": "de Castro Sáez", "initials": "X. C. F. X. C. S."} +{"name": "Cecilia Angela Smith", "given": "Cecilia", "middle": "Angela", "family": "Smith", "initials": "C. A. S."} +{"name": "Cecilija Hemiš Šteber", "given": "Cecilija", "middle": "Hemiš", "family": "Šteber", "initials": "C. H. Š."} +{"name": "Chancellor Jane Smith", "title": "Chancellor", "given": "Jane", "family": "Smith", "initials": "J. S."} +{"name": "Chang, Andy C I", "given": "Andy", "middle": "C", "family": "Chang", "suffix": "I", "initials": "A. C. C."} +{"name": "Charles van der van der Berg", "given": "Charles", "family": "van der van der Berg", "initials": "C. B."} +{"name": "Chemistry Jane Smith", "given": "Chemistry", "middle": "Jane", "family": "Smith", "initials": "C. J. S."} +{"name": "Cherice J. (Johnson) Williams", "given": "Cherice", "middle": "J.", "family": "Williams", "nickname": "Johnson", "initials": "C. J. W."} +{"name": "Chief Judge J. Leon Holmes", "title": "Chief Judge", "given": "J.", "middle": "Leon", "family": "Holmes", "initials": "J. L. H."} +{"name": "Chief Judge Sharon Lovelace Blackburn", "title": "Chief Judge", "given": "Sharon", "middle": "Lovelace", "family": "Blackburn", "initials": "S. L. B."} +{"name": "Chief Keef", "title": "Chief", "family": "Keef", "initials": "K."} +{"name": "Christopher Hans Jacobsen", "given": "Christopher", "middle": "Hans", "family": "Jacobsen", "initials": "C. H. J."} +{"name": "Clarke, Kenneth, Q.C. M.P.", "given": "Kenneth", "family": "Clarke", "suffix": "Q.C. M.P.", "initials": "K. C."} +{"name": "Claus SchroederVP of", "given": "Claus", "family": "SchroederVP of", "initials": "C. S."} +{"name": "Co-Chairs Miller", "title": "Co-Chairs", "family": "Miller", "initials": "M."} +{"name": "Co-Chairs Muffy Miller", "title": "Co-Chairs", "given": "Muffy", "family": "Miller", "initials": "M. M."} +{"name": "Coach", "title": "Coach"} +{"name": "Col. (Ret.) Smith", "title": "Col. Ret.", "family": "Smith", "initials": "S."} +{"name": "Coleman Zendaya Marie", "order": "FAMILY_FIRST", "given": "Zendaya", "middle": "Marie", "family": "Coleman", "initials": "Z. M. C."} +{"name": "Coleman, Zendaya Marie", "given": "Zendaya", "middle": "Marie", "family": "Coleman", "initials": "Z. M. C."} +{"name": "Con Hamiş Vatson", "given": "Con", "middle": "Hamiş", "family": "Vatson", "initials": "C. H. V."} +{"name": "Condori Edwin Segundo", "order": "FAMILY_FIRST", "given": "Edwin", "middle": "Segundo", "family": "Condori", "initials": "E. S. C."} +{"name": "Condori, Edwin Segundo", "given": "Edwin", "middle": "Segundo", "family": "Condori", "initials": "E. S. C."} +{"name": "Conti Gian Carlo Maria", "order": "FAMILY_FIRST", "given": "Gian", "middle": "Carlo Maria", "family": "Conti", "initials": "G. C. M. C."} +{"name": "Conti, Gian Carlo Maria", "given": "Gian", "middle": "Carlo Maria", "family": "Conti", "initials": "G. C. M. C."} +{"name": "Count Basie", "title": "Count", "family": "Basie", "initials": "B."} +{"name": "Cruz, de la Alejandro Manuel César Mamani", "family": "Cruz de la Alejandro Manuel César Mamani", "initials": "C. A. M. C. M."} +{"name": "Cruz, de la Maria García Rosa", "family": "Cruz de la Maria García Rosa", "initials": "C. M. G. R."} +{"name": "Cənab Arif Aliyev İlvus Santak Aydan Deputat", "given": "Cənab", "middle": "Arif Aliyev İlvus Santak Aydan", "family": "Deputat", "initials": "C. A. A. İ. S. A. D."} +{"name": "DE MESNIL", "family": "DE MESNIL", "initials": "M."} +{"name": "DELA CRUZ", "given": "DELA", "family": "CRUZ", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. C."} +{"name": "DOE, JANE MA VD PHD", "given": "JANE", "family": "DOE", "suffix": "MA VD PHD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "DOE, JANE PHD VD", "given": "JANE", "family": "VD DOE", "suffix": "PHD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "DOE, JANE PHD VD MA", "given": "JANE", "family": "DOE", "suffix": "PHD VD MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "DOE, JOHN MA", "given": "JOHN", "family": "DOE", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "DOE, MARY JO MA", "given": "MARY", "middle": "JO", "family": "DOE", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. J. D."} +{"name": "DR DOE", "title": "DR", "family": "DOE", "initials": "D."} +{"name": "Dame Mary", "title": "Dame", "given": "Mary", "initials": "M."} +{"name": "David Ben Gurion", "given": "David", "middle": "Ben", "family": "Gurion", "initials": "D. B. G."} +{"name": "Davis Royce, Ed", "given": "Davis", "family": "Royce", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D. R."} +{"name": "De Groot", "family": "De Groot", "initials": "G."} +{"name": "De La Cruz, Ed", "given": "Ed", "family": "De La Cruz", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. C."} +{"name": "De La Cruz, M.J. K.L.", "given": "M.J.", "family": "De La Cruz", "suffix": "K.L.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. C."} +{"name": "De La Cruz, M.J. PhD", "given": "M.J.", "family": "De La Cruz", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. C."} +{"name": "Dean Koontz", "given": "Dean", "family": "Koontz", "initials": "D. K."} +{"name": "Dean Martin", "given": "Dean", "family": "Martin", "initials": "D. M."} +{"name": "Dean Ms Hon Solo", "given": "Dean", "middle": "Ms Hon", "family": "Solo", "initials": "D. M. H. S."} +{"name": "Dean Robert Johns", "given": "Dean", "middle": "Robert", "family": "Johns", "initials": "D. R. J."} +{"name": "Dean Smith", "given": "Dean", "family": "Smith", "initials": "D. S."} +{"name": "Dean of Chemistry", "given": "Dean of Chemistry", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "D. C."} +{"name": "Dean of Chemistry Robert Johns", "given": "Dean of Chemistry", "middle": "Robert", "family": "Johns", "initials": "D. C. R. J."} +{"name": "Dean. Robert Johns", "title": "Dean.", "given": "Robert", "family": "Johns", "initials": "R. J."} +{"name": "Del Toro", "given": "Del", "family": "Toro", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. T."} +{"name": "Del toro", "given": "Del", "family": "toro", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. t."} +{"name": "Della Reese", "given": "Della", "family": "Reese", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. R."} +{"name": "Deputy Secretary of State", "title": "Deputy", "family": "Secretary of State", "ambiguities": ["TITLE_OR_NAME"], "initials": "S. S."} +{"name": "Designated Judge David A. Ezra", "title": "Designated Judge", "given": "David", "middle": "A.", "family": "Ezra", "initials": "D. A. E."} +{"name": "Dhòmhnallach Oighrig", "order": "FAMILY_FIRST", "given": "Oighrig", "family": "Dhòmhnallach", "initials": "O. D."} +{"name": "Dhòmhnallach, Oighrig", "given": "Oighrig", "family": "Dhòmhnallach", "initials": "O. D."} +{"name": "Di Caprio", "given": "Di", "family": "Caprio", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. C."} +{"name": "Dl Dr. Daniel Ionescu Dragoș Ioan Ardelean Jr.", "given": "Dl", "middle": "Dr. Daniel Ionescu Dragoș Ioan", "family": "Ardelean", "suffix": "Jr.", "initials": "D. D. D. I. D. I. A."} +{"name": "Do Quang Minh", "given": "Do", "middle": "Quang", "family": "Minh", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. Q. M."} +{"name": "Doctor, Jane E.", "given": "Jane", "middle": "E.", "family": "Doctor", "initials": "J. E. D."} +{"name": "Doe nee Smith Jr. Prof., Jane", "given": "Jane", "family": "Doe Prof.", "suffix": "Jr.", "maiden": "Smith", "initials": "J. D. P."} +{"name": "Doe nee Smith, Jane", "given": "Jane", "family": "Doe", "maiden": "Smith", "initials": "J. D."} +{"name": "Doe, Dr. John", "title": "Dr.", "given": "John", "family": "Doe", "initials": "J. D."} +{"name": "Doe, Dr. John A.", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} +{"name": "Doe, Dr. John A. III", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} +{"name": "Doe, Dr. John A. Jr.", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} +{"name": "Doe, Dr. John A. Kenneth", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} +{"name": "Doe, Dr. John A. Kenneth III", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} +{"name": "Doe, Dr. John A. Kenneth Jr.", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} +{"name": "Doe, Dr. John III", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} +{"name": "Doe, Dr. John MA", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, Dr. John P., CLU, CFP, LUTC", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D."} +{"name": "Doe, Dr. John, Jr.", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} +{"name": "Doe, Dr. MA", "title": "Dr.", "family": "Doe", "suffix": "MA", "initials": "D."} +{"name": "Doe, Dr. MA Smith", "title": "Dr.", "given": "MA", "middle": "Smith", "family": "Doe", "initials": "M. S. D."} +{"name": "Doe, Dr. nee Smith MA", "title": "Dr.", "given": "nee", "middle": "Smith", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "n. S. D."} +{"name": "Doe, J. MA", "given": "J.", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, J. ba", "given": "J.", "family": "Doe", "suffix": "ba", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, J. nee MA ba", "given": "J.", "middle": "nee", "family": "Doe", "suffix": "MA ba", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. D."} +{"name": "Doe, Jane PhD MEng", "given": "Jane", "family": "Doe", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, Jane Q. nee Smith MA", "given": "Jane", "middle": "Q. nee Smith", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. Q. n. S. D."} +{"name": "Doe, Jane nee Puig i Soler", "given": "Jane", "middle": "nee Puig i Soler", "family": "Doe", "initials": "J. n. P. S. D."} +{"name": "Doe, Jane nee Smith", "given": "Jane", "middle": "nee Smith", "family": "Doe", "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith DO", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "DO", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith Do", "given": "Jane", "middle": "nee Smith", "family": "Do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith MA", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith MA Prof.", "title": "Prof.", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith MA do", "given": "Jane", "middle": "nee Smith MA", "family": "do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. n. S. M. D."} +{"name": "Doe, Jane nee Smith Ma", "given": "Jane", "middle": "nee Smith Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. M. D."} +{"name": "Doe, Jane nee Smith PhD MEng", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith do", "given": "Jane", "middle": "nee Smith", "family": "do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. n. S. D."} +{"name": "Doe, Jane nee Smith ma", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. n. S. D."} +{"name": "Doe, Jane, and Jr.", "given": "Jane", "family": "Doe", "suffix": "and Jr.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. D."} +{"name": "Doe, John", "given": "John", "family": "Doe", "initials": "J. D."} +{"name": "Doe, John A.", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} +{"name": "Doe, John A. III", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} +{"name": "Doe, John A. Kenneth", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} +{"name": "Doe, John A. Kenneth III", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} +{"name": "Doe, John A. Kenneth, Jr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} +{"name": "Doe, John A., III", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} +{"name": "Doe, John A., Jr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} +{"name": "Doe, John BA", "given": "John", "family": "Doe", "suffix": "BA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John DO", "given": "John", "family": "Doe", "suffix": "DO", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John DO Ed", "given": "John", "middle": "DO Ed", "family": "Doe", "initials": "J. E. D."} +{"name": "Doe, John Do", "given": "John", "family": "Do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. D."} +{"name": "Doe, John Ed", "given": "John", "middle": "Ed", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. E. D."} +{"name": "Doe, John III", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} +{"name": "Doe, John MA", "given": "John", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John MA JD", "given": "John", "family": "Doe", "suffix": "MA JD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John MA Jr", "given": "John", "family": "Doe", "suffix": "MA Jr", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John MA Ma", "given": "John", "middle": "MA Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M. M. D."} +{"name": "Doe, John MA PhD", "given": "John", "family": "Doe", "suffix": "MA PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John MA Smith", "given": "John", "middle": "MA Smith", "family": "Doe", "initials": "J. M. S. D."} +{"name": "Doe, John MA do", "given": "John", "middle": "MA", "family": "do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. M. D."} +{"name": "Doe, John MA y", "given": "John", "middle": "MA y", "family": "Doe", "initials": "J. M. D."} +{"name": "Doe, John Ma", "given": "John", "middle": "Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M. D."} +{"name": "Doe, John Msc.Ed.", "given": "John", "family": "Doe", "suffix": "Msc.Ed.", "initials": "J. D."} +{"name": "Doe, John PhD MA", "given": "John", "family": "Doe", "suffix": "PhD MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John Q. MA", "given": "John", "middle": "Q.", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. Q. D."} +{"name": "Doe, John X.Y.Z.", "given": "John", "family": "Doe", "suffix": "X.Y.Z.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Doe, John jr., MD", "given": "John", "family": "Doe", "suffix": "jr., MD", "initials": "J. D."} +{"name": "Doe, John van DO", "given": "John", "family": "van DO Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. D."} +{"name": "Doe, John van Ma", "given": "John", "middle": "van Ma", "family": "Doe", "initials": "J. M. D."} +{"name": "Doe, John, Jr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} +{"name": "Doe, John, MD PhD - FACS Fellow", "given": "John", "family": "Doe", "suffix": "MD PhD - FACS Fellow", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. D."} +{"name": "Doe, John,, Jr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} +{"name": "Doe, John,, Jr.,, III", "given": "John", "family": "Doe", "suffix": "Jr., III", "initials": "J. D."} +{"name": "Doe, John. A. Kenneth", "title": "John.", "given": "A.", "middle": "Kenneth", "family": "Doe", "initials": "A. K. D."} +{"name": "Doe, John. A. Kenneth III", "title": "John.", "given": "A.", "middle": "Kenneth", "family": "Doe", "suffix": "III", "initials": "A. K. D."} +{"name": "Doe, John. A. Kenneth, Jr.", "title": "John.", "given": "A.", "middle": "Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "A. K. D."} +{"name": "Doe, Lt. Gen. John A. Kenneth IV", "title": "Lt. Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "IV", "initials": "J. A. K. D."} +{"name": "Doe, Lt.Gov. John", "title": "Lt.Gov.", "given": "John", "family": "Doe", "initials": "J. D."} +{"name": "Doe, MA", "family": "Doe", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D."} +{"name": "Doe, MA PhD", "family": "Doe", "suffix": "MA PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D."} +{"name": "Doe, MA Smith", "given": "MA", "middle": "Smith", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. S. D."} +{"name": "Doe, Mary - Kate, RN", "given": "Mary", "middle": "- Kate", "family": "Doe", "suffix": "RN", "initials": "M. -. K. D."} +{"name": "Doe, Mary Jo Ma", "given": "Mary", "middle": "Jo Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. J. M. D."} +{"name": "Doe, Mr. MA PhD", "title": "Mr.", "family": "Doe", "suffix": "MA PhD", "initials": "D."} +{"name": "Doe, PhD Jones", "family": "Doe", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D."} +{"name": "Doe, Rev. John A. Jr.", "title": "Rev.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} +{"name": "Doe, Rev. John A., V, Jr.", "title": "Rev.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "V, Jr.", "initials": "J. A. D."} +{"name": "Doe, Rev. John V, Jr.", "title": "Rev.", "given": "John", "middle": "V", "family": "Doe", "suffix": "Jr.", "initials": "J. V. D."} +{"name": "Doe,, Jr.", "family": "Doe", "suffix": "Jr.", "initials": "D."} +{"name": "Doe-Ray, Dr. John P., CLU, CFP, LUTC", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D."} +{"name": "Doe-Ray, Hon. Barrington P. Jr.", "title": "Hon.", "given": "Barrington", "middle": "P.", "family": "Doe-Ray", "suffix": "Jr.", "initials": "B. P. D."} +{"name": "Doe-Ray, Hon. Barrington P. Jr., CFP, LUTC", "title": "Hon.", "given": "Barrington", "middle": "P.", "family": "Doe-Ray", "suffix": "Jr., CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "B. P. D."} +{"name": "Don Cheadle", "given": "Don", "family": "Cheadle", "initials": "D. C."} +{"name": "Donald mc", "given": "Donald", "suffix": "mc", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "D."} +{"name": "Donovan McNabb-Smith", "given": "Donovan", "family": "McNabb-Smith", "initials": "D. M."} +{"name": "Dr John Smith", "title": "Dr", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Dr Jr", "given": "Dr", "suffix": "Jr", "ambiguities": ["TITLE_OR_NAME"], "initials": "D."} +{"name": "Dr King Jr", "title": "Dr", "family": "King", "suffix": "Jr", "ambiguities": ["TITLE_OR_NAME"], "initials": "K."} +{"name": "Dr Martin Luther King, Jr.", "title": "Dr", "given": "Martin", "middle": "Luther", "family": "King", "suffix": "Jr.", "initials": "M. L. K."} +{"name": "Dr 田中さん, V.", "title": "Dr", "family": "田中", "suffix": "さん, V.", "initials": "田."} +{"name": "Dr 김민준, Jr.", "title": "Dr", "given": "민준", "family": "김", "suffix": "Jr.", "initials": "민. 김."} +{"name": "Dr 김민준, MA", "title": "Dr", "given": "민준", "family": "김", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "민. 김."} +{"name": "Dr 김민준씨, Jr.", "title": "Dr", "given": "민준", "family": "김", "suffix": "씨, Jr.", "initials": "민. 김."} +{"name": "Dr 김민준씨, V.", "title": "Dr", "given": "민준", "family": "김", "suffix": "씨, V.", "initials": "민. 김."} +{"name": "Dr.", "title": "Dr."} +{"name": "Dr. Abdul Salam Hassan, MD", "title": "Dr.", "given": "Abdul Salam", "family": "Hassan", "suffix": "MD", "initials": "A. S. H."} +{"name": "Dr. Ardelean Dl Daniel Ionescu Dragoș Ioan Jr.", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Dl", "middle": "Daniel Ionescu Dragoș Ioan", "family": "Ardelean", "suffix": "Jr.", "initials": "D. D. I. D. I. A."} +{"name": "Dr. Do Van Johnson, MD", "title": "Dr.", "given": "Do", "family": "Van Johnson", "suffix": "MD", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "D. J."} +{"name": "Dr. Jason", "title": "Dr.", "family": "Jason", "initials": "J."} +{"name": "Dr. Jason Ross Jay", "title": "Dr.", "given": "Jason", "middle": "Ross", "family": "Jay", "initials": "J. R. J."} +{"name": "Dr. John A. Doe", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} +{"name": "Dr. John A. Doe III", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} +{"name": "Dr. John A. Doe, Jr.", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} +{"name": "Dr. John A. Kenneth Doe", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} +{"name": "Dr. John A. Kenneth Doe III", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} +{"name": "Dr. John A. Kenneth Doe, Jr.", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} +{"name": "Dr. John Doe", "title": "Dr.", "given": "John", "family": "Doe", "initials": "J. D."} +{"name": "Dr. John Doe III", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} +{"name": "Dr. John Doe, Jr.", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} +{"name": "Dr. John P. Doe-Ray, CLU", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU", "initials": "J. P. D."} +{"name": "Dr. John P. Doe-Ray, CLU, CFP, LUTC", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D."} +{"name": "Dr. John Smith", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Dr. John Smith Prof.", "title": "Dr. Prof.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Dr. John van der Berg", "title": "Dr.", "given": "John", "family": "van der Berg", "initials": "J. B."} +{"name": "Dr. Juan Q. Velasquez y Garcia", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} +{"name": "Dr. Juan Q. Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} +{"name": "Dr. Juan Q. Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} +{"name": "Dr. Juan Q. Xavier Velasquez y Garcia", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} +{"name": "Dr. Juan Q. Xavier Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} +{"name": "Dr. Juan Q. Xavier Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} +{"name": "Dr. Juan Q. Xavier de la Vega", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} +{"name": "Dr. Juan Q. Xavier de la Vega III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "Dr. Juan Q. Xavier de la Vega, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} +{"name": "Dr. Juan Q. Xavier de la dos Vega III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la dos Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "Dr. Juan Q. Xavier de la dos Vega, III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la dos Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "Dr. Juan Q. de la Vega", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} +{"name": "Dr. Juan Q. de la Vega III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} +{"name": "Dr. Juan Q. de la Vega, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} +{"name": "Dr. Juan Velasquez y Garcia", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} +{"name": "Dr. Juan Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} +{"name": "Dr. Juan Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} +{"name": "Dr. Juan de la Vega", "title": "Dr.", "given": "Juan", "family": "de la Vega", "initials": "J. V."} +{"name": "Dr. Juan de la Vega III", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} +{"name": "Dr. Juan de la Vega, Jr.", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} +{"name": "Dr. King", "title": "Dr.", "family": "King", "ambiguities": ["TITLE_OR_NAME"], "initials": "K."} +{"name": "Dr. King MD", "title": "Dr.", "family": "King", "suffix": "MD", "ambiguities": ["TITLE_OR_NAME"], "initials": "K."} +{"name": "Dr. Martin Luther King Jr.", "title": "Dr.", "given": "Martin", "middle": "Luther", "family": "King", "suffix": "Jr.", "initials": "M. L. K."} +{"name": "Dr. Mazen Elrouqi", "title": "Dr.", "given": "Mazen", "family": "Elrouqi", "initials": "M. E."} +{"name": "Dr. Paul Vinzent von Fischer jr. MdB", "title": "Dr.", "given": "Paul", "middle": "Vinzent", "family": "von Fischer", "suffix": "jr. MdB", "initials": "P. V. F."} +{"name": "Dr. Sir John", "title": "Dr. Sir", "given": "John", "initials": "J."} +{"name": "Dr. Smith", "title": "Dr.", "family": "Smith", "initials": "S."} +{"name": "Dr. Smith Sir.", "title": "Dr. Sir.", "family": "Smith", "initials": "S."} +{"name": "Dr. Smith née Jones", "title": "Dr.", "family": "Smith", "maiden": "Jones", "initials": "S."} +{"name": "Dr. Smith, John", "given": "John", "family": "Dr. Smith", "initials": "J. D. S."} +{"name": "Dr. Van Johnson", "title": "Dr.", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. J."} +{"name": "Dr. Williams", "title": "Dr.", "family": "Williams", "initials": "W."} +{"name": "Dr. abdul salam", "title": "Dr.", "given": "abdul", "family": "salam", "initials": "a. s."} +{"name": "Dr. abdul salam ahmed salem", "title": "Dr.", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s."} +{"name": "Dr. juan garcia", "title": "Dr.", "given": "juan", "family": "garcia", "initials": "j. g."} +{"name": "Dr. med. univ.", "title": "Dr. med.", "family": "univ.", "initials": "u."} +{"name": "Dr. med. univ. Margit Popp", "title": "Dr. med. univ.", "given": "Margit", "family": "Popp", "initials": "M. P."} +{"name": "Dr. med. univ. Margit Popp, MSc", "title": "Dr. med. univ.", "given": "Margit", "family": "Popp", "suffix": "MSc", "initials": "M. P."} +{"name": "Dr. nee Smith PhD Prof.", "title": "Dr. Prof.", "given": "nee", "family": "Smith", "suffix": "PhD", "initials": "n. S."} +{"name": "Dr. 김민준씨, MA", "title": "Dr.", "given": "민준", "family": "김", "suffix": "씨, MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "민. 김."} +{"name": "Dr.philol. Laima Kristīne Eva Sofija van den Volfa Bērziņa-Kalniņa", "title": "Dr.philol.", "given": "Laima", "middle": "Kristīne Eva Sofija", "family": "van den Volfa Bērziņa-Kalniņa", "initials": "L. K. E. S. V. B."} +{"name": "Dra. Andréia da Silva", "title": "Dra.", "given": "Andréia", "family": "da Silva", "initials": "A. S."} +{"name": "Dra. Erika Cornelia Anna Isabelle von Brül Cabral de Melo II", "title": "Dra.", "given": "Erika", "middle": "Cornelia Anna Isabelle von Brül Cabral", "family": "de Melo", "suffix": "II", "initials": "E. C. A. I. B. C. M."} +{"name": "Duke Ellington", "given": "Duke", "family": "Ellington", "initials": "D. E."} +{"name": "Duke Snider", "given": "Duke", "family": "Snider", "initials": "D. S."} +{"name": "Duke of Edinburgh", "given": "Duke of Edinburgh", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "D. E."} +{"name": "Duke of Wellington", "given": "Duke of Wellington", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "D. W."} +{"name": "Dut.ra Prof.ra Maria Giusepa Juana Teresa von Brühl González Domingo", "title": "Dut.ra Prof.ra", "given": "Maria", "middle": "Giusepa Juana Teresa", "family": "von Brühl González Domingo", "initials": "M. G. J. T. B. G. D."} +{"name": "Dòmhnall Iain Tormod Caimbeul", "given": "Dòmhnall", "middle": "Iain Tormod", "family": "Caimbeul", "initials": "D. I. T. C."} +{"name": "Dóra Mátrai", "given": "Dóra", "family": "Mátrai", "initials": "D. M."} +{"name": "E Anne D", "given": "E", "middle": "Anne", "family": "D", "initials": "E. A. D."} +{"name": "E Anne D,Leonardo", "given": "Leonardo", "family": "E Anne D", "initials": "L. E. A. D."} +{"name": "E Jones", "given": "E", "family": "Jones", "initials": "E. J."} +{"name": "E Maria", "given": "E", "family": "Maria", "initials": "E. M."} +{"name": "E.T. Smith", "given": "E.T.", "family": "Smith", "initials": "E. S."} +{"name": "E.T. Smith, II", "given": "E.T.", "family": "Smith", "suffix": "II", "initials": "E. S."} +{"name": "Earl Campbell", "given": "Earl", "family": "Campbell", "initials": "E. C."} +{"name": "Ed, Royce", "given": "Royce", "family": "Ed", "initials": "R. E."} +{"name": "Eddie Chang Seng Dee Adecco", "given": "Eddie", "middle": "Chang Seng Dee", "family": "Adecco", "initials": "E. C. S. D. A."} +{"name": "Edwin Segundo Condori", "given": "Edwin", "middle": "Segundo", "family": "Condori", "initials": "E. S. C."} +{"name": "Eemil", "given": "Eemil", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "E."} +{"name": "Eemil", "order": "FAMILY_FIRST", "family": "Eemil", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "E."} +{"name": "El Dine", "given": "El", "family": "Dine", "initials": "E. D."} +{"name": "Elder Soares", "given": "Elder", "family": "Soares", "initials": "E. S."} +{"name": "Elizabeth Windsor Maria Teresa bin Stromae MP", "given": "Elizabeth", "middle": "Windsor Maria Teresa", "family": "bin Stromae", "suffix": "MP", "initials": "E. W. M. T. S."} +{"name": "Emomoto Yoshi", "order": "FAMILY_FIRST", "given": "Yoshi", "family": "Emomoto", "initials": "Y. E."} +{"name": "Emomoto, Yoshi", "given": "Yoshi", "family": "Emomoto", "initials": "Y. E."} +{"name": "Eric H. Holder Jr. Attorney General", "title": "Attorney General", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr.", "initials": "E. H. H."} +{"name": "Eric H. Holder, Jr., Attorney General", "title": "Attorney General", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr.", "initials": "E. H. H."} +{"name": "Eric H. Holder, Jr., Secretary of State", "title": "Secretary of State", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "E. H. H."} +{"name": "Esq Jane Smith", "given": "Esq", "middle": "Jane", "family": "Smith", "initials": "E. J. S."} +{"name": "Esq Smith", "given": "Esq", "family": "Smith", "initials": "E. S."} +{"name": "Esq. Smith", "title": "Esq.", "family": "Smith", "initials": "S."} +{"name": "Esq. van Gogh", "title": "Esq.", "given": "van", "family": "Gogh", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. G."} +{"name": "Eunsoo Johanna Jeong", "given": "Eunsoo", "middle": "Johanna", "family": "Jeong", "initials": "E. J. J."} +{"name": "Ewa Nowak", "given": "Ewa", "family": "Nowak", "initials": "E. N."} +{"name": "Excm. Sr. Josep Antoni Carles Joan Lloret Palol", "title": "Excm. Sr.", "given": "Josep", "middle": "Antoni Carles Joan Lloret", "family": "Palol", "initials": "J. A. C. J. L. P."} +{"name": "Excm. Sr. Lloret Palol II, Josep Antoni Carles Joan", "order": "FAMILY_FIRST", "given": "Josep", "middle": "Antoni Carles Joan", "family": "Excm. Lloret Palol", "suffix": "Sr., II", "initials": "J. A. C. J. E. L. P."} +{"name": "Excma. Dra. Maria da Graça César Martín van Dalen Hoogeveen", "title": "Excma. Dra.", "given": "Maria", "middle": "da Graça César Martín", "family": "van Dalen Hoogeveen", "initials": "M. G. C. M. D. H."} +{"name": "Excma. Dra. Van Dalen Hoogeveen II, Maria da Graça César Martín", "order": "FAMILY_FIRST", "given": "Maria", "middle": "da Graça César Martín", "family": "Excma. Dra. Van Dalen Hoogeveen", "suffix": "II", "initials": "M. G. C. M. E. D. D. H."} +{"name": "Fabbri Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Fabbri", "initials": "I. F."} +{"name": "Fabbri, Irene", "given": "Irene", "family": "Fabbri", "initials": "I. F."} +{"name": "Falke, Iris", "given": "Iris", "family": "Falke", "initials": "I. F."} +{"name": "Falke, Iris", "order": "FAMILY_FIRST", "given": "Iris", "family": "Falke", "initials": "I. F."} +{"name": "Farf. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D.", "title": "Farf. Dr.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van den Wolf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W. B. S."} +{"name": "Fatima", "given": "Fatima", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "F."} +{"name": "Fatima", "order": "FAMILY_FIRST", "family": "Fatima", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "F."} +{"name": "Fatima Amina Umar", "given": "Fatima", "middle": "Amina", "family": "Umar", "initials": "F. A. U."} +{"name": "Fernandes Maria Helena", "order": "FAMILY_FIRST", "given": "Maria", "middle": "Helena", "family": "Fernandes", "initials": "M. H. F."} +{"name": "Fernandes, Maria Helena", "given": "Maria", "middle": "Helena", "family": "Fernandes", "initials": "M. H. F."} +{"name": "Ferrer Rosa Maria Ferran", "order": "FAMILY_FIRST", "given": "Rosa", "middle": "Maria Ferran", "family": "Ferrer", "initials": "R. M. F. F."} +{"name": "Ferrer, Rosa Maria Ferran", "given": "Rosa", "middle": "Maria Ferran", "family": "Ferrer", "initials": "R. M. F. F."} +{"name": "Fischer, Paul Vinzent von", "given": "Paul", "middle": "Vinzent", "family": "von Fischer", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "P. V. F."} +{"name": "Fleksnes, Gregers Werle Marve Almar von", "given": "Gregers", "middle": "Werle Marve Almar", "family": "von Fleksnes", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "G. W. M. A. F."} +{"name": "Flores Marco Antonio", "order": "FAMILY_FIRST", "given": "Marco", "middle": "Antonio", "family": "Flores", "initials": "M. A. F."} +{"name": "Flores, Marco Antonio", "given": "Marco", "middle": "Antonio", "family": "Flores", "initials": "M. A. F."} +{"name": "Flávio Juvenal da Silva Jr", "given": "Flávio", "middle": "Juvenal", "family": "da Silva", "suffix": "Jr", "initials": "F. J. S."} +{"name": "Flávio Juvenal da Silva Junior", "given": "Flávio", "middle": "Juvenal", "family": "da Silva", "suffix": "Junior", "initials": "F. J. S."} +{"name": "Foffa, Gian Carlo Ramun", "given": "Gian", "middle": "Carlo Ramun", "family": "Foffa", "initials": "G. C. R. F."} +{"name": "Foffa, Gian Carlo Ramun", "order": "FAMILY_FIRST", "given": "Gian", "middle": "Carlo Ramun", "family": "Foffa", "initials": "G. C. R. F."} +{"name": "Fonda, Menada", "given": "Menada", "family": "Fonda", "initials": "M. F."} +{"name": "Fonda, Menada", "order": "FAMILY_FIRST", "given": "Menada", "family": "Fonda", "initials": "M. F."} +{"name": "Foo. John Smith", "title": "Foo.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Foo. Xyz. John Smith", "title": "Foo. Xyz.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Franklin Washington, Jr. MD", "given": "Franklin", "family": "Washington", "suffix": "Jr. MD", "initials": "F. W."} +{"name": "Franklin, Benjamin (Ben)", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} +{"name": "Franklin, Benjamin (Ben), Jr.", "given": "Benjamin", "family": "Franklin", "suffix": "Jr.", "nickname": "Ben", "initials": "B. F."} +{"name": "Frantziscu", "given": "Frantziscu", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "F."} +{"name": "Frantziscu", "order": "FAMILY_FIRST", "family": "Frantziscu", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "F."} +{"name": "Frau Anna Müller", "title": "Frau", "given": "Anna", "family": "Müller", "initials": "A. M."} +{"name": "Freiherr St John Smith", "title": "Freiherr St", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Freiherr von Berg MA", "title": "Freiherr", "family": "von Berg", "suffix": "MA", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "initials": "B."} +{"name": "Freiherr von Berg, Ed", "given": "Ed", "family": "Freiherr von Berg", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. B."} +{"name": "Freiherr von Richthofen", "title": "Freiherr", "family": "von Richthofen", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "R."} +{"name": "Freiherr von Richthofen V", "title": "Freiherr", "family": "von Richthofen", "suffix": "V", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "initials": "R."} +{"name": "Freire Xiana", "order": "FAMILY_FIRST", "given": "Xiana", "family": "Freire", "initials": "X. F."} +{"name": "Freire, Xiana", "given": "Xiana", "family": "Freire", "initials": "X. F."} +{"name": "Fritz Freiherr und von Bar", "given": "Fritz", "family": "Freiherr und von Bar", "initials": "F. u."} +{"name": "Frøken Jensen", "title": "Frøken", "family": "Jensen", "initials": "J."} +{"name": "Fuseena AKIME", "given": "Fuseena", "family": "AKIME", "initials": "F. A."} +{"name": "G. J. M.", "given": "G.", "middle": "J.", "family": "M.", "initials": "G. J. M."} +{"name": "G. M.", "given": "G.", "family": "M.", "initials": "G. M."} +{"name": "G. V. M.", "given": "G.", "middle": "V.", "family": "M.", "initials": "G. V. M."} +{"name": "GEORGE SHANAHAN IV", "given": "GEORGE", "family": "SHANAHAN", "suffix": "IV", "initials": "G. S."} +{"name": "GOMINA MUSA Abɖu-Tɔyib", "order": "FAMILY_FIRST", "given": "MUSA", "middle": "Abɖu-Tɔyib", "family": "GOMINA", "initials": "M. A. G."} +{"name": "GREGORY HOUSE M.D.", "given": "GREGORY", "family": "HOUSE", "suffix": "M.D.", "initials": "G. H."} +{"name": "Gal·la Roig", "given": "Gal·la", "family": "Roig", "initials": "G. R."} +{"name": "Gal·la Serra", "given": "Gal·la", "family": "Serra", "initials": "G. S."} +{"name": "Gaonkar Shanti", "order": "FAMILY_FIRST", "given": "Shanti", "family": "Gaonkar", "initials": "S. G."} +{"name": "Gaonkar, Shanti", "given": "Shanti", "family": "Gaonkar", "initials": "S. G."} +{"name": "Garcia", "given": "Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "G."} +{"name": "Garcia Juan Carlos", "given": "Garcia", "middle": "Juan", "family": "Carlos", "initials": "G. J. C."} +{"name": "Garcia de Jalon", "given": "Garcia", "family": "de Jalon", "initials": "G. J."} +{"name": "Garcia y Lopez", "given": "Garcia", "middle": "y", "family": "Lopez", "initials": "G. y. L."} +{"name": "García Márquez, Ed G.J.", "given": "Ed", "family": "García Márquez", "suffix": "G.J.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. G. M."} +{"name": "García Márquez, G.J", "given": "G.J", "family": "García Márquez", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "G. G. M."} +{"name": "García Márquez, G.J.", "given": "G.J.", "family": "García Márquez", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "G. G. M."} +{"name": "García Márquez, G.J.R.", "given": "García", "family": "Márquez", "suffix": "G.J.R.", "initials": "G. M."} +{"name": "García Márquez, MJ", "given": "MJ", "family": "García Márquez", "initials": "M. G. M."} +{"name": "García Márquez, MJ JK", "given": "García", "family": "Márquez", "suffix": "MJ JK", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "G. M."} +{"name": "García Márquez, MJ PhD", "given": "MJ", "family": "García Márquez", "suffix": "PhD", "initials": "M. G. M."} +{"name": "García Márquez, Ms G.J.", "title": "Ms", "given": "G.J.", "family": "García Márquez", "initials": "G. G. M."} +{"name": "García Márquez, PhD G.J.", "given": "García", "family": "Márquez", "suffix": "PhD G.J.", "initials": "G. M."} +{"name": "Gary Strawsburg, PMP, CSCP, CLSSBB", "given": "Gary", "family": "Strawsburg", "suffix": "PMP, CSCP, CLSSBB", "ambiguities": ["COMMA_STRUCTURE"], "initials": "G. S."} +{"name": "Gebhart GF", "given": "Gebhart", "family": "GF", "initials": "G. G."} +{"name": "Geir Jón Magnús Brynjar Skeggjason", "order": "FAMILY_FIRST", "given": "Jón", "middle": "Magnús Brynjar Skeggjason", "family": "Geir", "initials": "J. M. B. S. G."} +{"name": "Geir Jón Magnús Brynjar Skeggjason, þingmaður", "given": "þingmaður", "family": "Geir Jón Magnús Brynjar Skeggjason", "initials": "þ. G. J. M. B. S."} +{"name": "General of Minnesota", "title": "General of Minnesota"} +{"name": "Gian Carlo Maria Conti", "given": "Gian", "middle": "Carlo Maria", "family": "Conti", "initials": "G. C. M. C."} +{"name": "Gian Carlo Ramun Foffa", "given": "Gian", "middle": "Carlo Ramun", "family": "Foffa", "initials": "G. C. R. F."} +{"name": "Gilot Marie-Agnès Suzanne", "order": "FAMILY_FIRST", "given": "Marie-Agnès", "middle": "Suzanne", "family": "Gilot", "initials": "M. S. G."} +{"name": "Gilot, Marie-Agnès Suzanne", "given": "Marie-Agnès", "middle": "Suzanne", "family": "Gilot", "initials": "M. S. G."} +{"name": "Ginevra Molly Weasley", "given": "Ginevra", "middle": "Molly", "family": "Weasley", "initials": "G. M. W."} +{"name": "Giuanne Marcu Piras", "given": "Giuanne", "middle": "Marcu", "family": "Piras", "initials": "G. M. P."} +{"name": "Give me a private config", "given": "Give", "middle": "me a private", "family": "config", "initials": "G. m. a. p. c."} +{"name": "Giáo sư, Tiến sĩ Ada Cornelia César Martín von Brühl Jr, MD DDS", "given": "Tiến", "middle": "sĩ Ada Cornelia César Martín von Brühl", "family": "Giáo sư", "suffix": "Jr, MD DDS", "initials": "T. s. A. C. C. M. B. G. s."} +{"name": "Grand Rounds", "title": "Grand", "family": "Rounds", "initials": "R."} +{"name": "Granger Hermione", "order": "FAMILY_FIRST", "given": "Hermione", "family": "Granger", "initials": "H. G."} +{"name": "Granger, Hermione", "given": "Hermione", "family": "Granger", "initials": "H. G."} +{"name": "Greve Anna", "title": "Greve", "family": "Anna", "initials": "A."} +{"name": "Greve, Anna", "given": "Anna", "family": "Greve", "initials": "A. G."} +{"name": "Gunny de Mesnil", "given": "Gunny", "family": "de Mesnil", "initials": "G. M."} +{"name": "Gurban", "given": "Gurban", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "G."} +{"name": "Gurban", "order": "FAMILY_FIRST", "family": "Gurban", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "G."} +{"name": "Gurbanow Meretgeldi Durdyýewiç", "order": "FAMILY_FIRST", "given": "Meretgeldi", "middle": "Durdyýewiç", "family": "Gurbanow", "initials": "M. D. G."} +{"name": "Gurbanow, Meretgeldi Durdyýewiç", "given": "Meretgeldi", "middle": "Durdyýewiç", "family": "Gurbanow", "initials": "M. D. G."} +{"name": "Gurdow Atajan Saparowiç, magistr, ylymlaryň kandidaty", "order": "FAMILY_FIRST", "given": "magistr", "family": "Gurdow Atajan Saparowiç", "suffix": "ylymlaryň kandidaty", "ambiguities": ["COMMA_STRUCTURE"], "initials": "m. G. A. S."} +{"name": "Gurdow, Atajan Saparowiç", "given": "Atajan", "middle": "Saparowiç", "family": "Gurdow", "initials": "A. S. G."} +{"name": "Guru Gobind Singh", "title": "Guru", "given": "Gobind", "family": "Singh", "initials": "G. S."} +{"name": "Guru Nanak", "title": "Guru", "given": "Nanak", "initials": "N."} +{"name": "Hamisi Ali Juma", "order": "FAMILY_FIRST", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} +{"name": "Hamisi, Ali Juma", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} +{"name": "Hamisi, Salima Farida mwana", "given": "Salima", "middle": "Farida mwana", "family": "Hamisi", "initials": "S. F. m. H."} +{"name": "Hana Kulina", "given": "Hana", "family": "Kulina", "initials": "H. K."} +{"name": "Hanka Šołćina", "given": "Hanka", "family": "Šołćina", "initials": "H. Š."} +{"name": "Hans „Erster“ und “Zweiter” Müller", "family": "Hans und Müller", "nickname": "Erster Zweiter", "initials": "H. M."} +{"name": "Hans „Hansi“ Müller", "given": "Hans", "family": "Müller", "nickname": "Hansi", "initials": "H. M."} +{"name": "Hansen, Anne Sofie Holm", "given": "Anne", "middle": "Sofie Holm", "family": "Hansen", "initials": "A. S. H. H."} +{"name": "Hansen, Anne Sofie Holm", "order": "FAMILY_FIRST", "given": "Anne", "middle": "Sofie Holm", "family": "Hansen", "initials": "A. S. H. H."} +{"name": "Harald", "given": "Harald", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "H."} +{"name": "Harald", "order": "FAMILY_FIRST", "family": "Harald", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "H."} +{"name": "Hardie Leannon DVM", "given": "Hardie", "family": "Leannon", "suffix": "DVM", "initials": "H. L."} +{"name": "Hardie Leannon V", "given": "Hardie", "family": "Leannon", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "H. L."} +{"name": "Harietta Keopuolani Nahi'ena'ena", "given": "Harietta", "middle": "Keopuolani", "family": "Nahi'ena'ena", "initials": "H. K. N."} +{"name": "Harrieta Keōpūolani Nāhiʻenaʻena", "given": "Harrieta", "middle": "Keōpūolani", "family": "Nāhiʻenaʻena", "initials": "H. K. N."} +{"name": "Hasani Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Hasani", "initials": "M. H."} +{"name": "Hasani, Maria", "given": "Maria", "family": "Hasani", "initials": "M. H."} +{"name": "Hassan", "given": "Hassan", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "H."} +{"name": "Hassan", "order": "FAMILY_FIRST", "family": "Hassan", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "H."} +{"name": "Hassan Mohamad Ali", "given": "Hassan", "middle": "Mohamad", "family": "Ali", "initials": "H. M. A."} +{"name": "Hassan, Mohamad Ahmad Ali", "given": "Mohamad", "middle": "Ahmad Ali", "family": "Hassan", "initials": "M. A. A. H."} +{"name": "Heidi Johannes Willemse", "given": "Heidi", "middle": "Johannes", "family": "Willemse", "initials": "H. J. W."} +{"name": "Her Majesty Queen Elizabeth", "title": "Her Majesty Queen", "given": "Elizabeth", "initials": "E."} +{"name": "Her Royal Highness Princess Anne", "title": "Her Royal Highness Princess", "given": "Anne", "initials": "A."} +{"name": "Hermansson Lars-Åke Karl", "order": "FAMILY_FIRST", "given": "Lars-Åke", "middle": "Karl", "family": "Hermansson", "initials": "L. K. H."} +{"name": "Hermansson, Herr Lars-Åke Karl, Riksdagsledamot", "order": "FAMILY_FIRST", "title": "Herr", "given": "Lars-Åke", "middle": "Karl", "family": "Hermansson", "suffix": "Riksdagsledamot", "ambiguities": ["COMMA_STRUCTURE"], "initials": "L. K. H."} +{"name": "Hermione Granger", "given": "Hermione", "family": "Granger", "initials": "H. G."} +{"name": "Herr Klaus Schmidt", "title": "Herr", "given": "Klaus", "family": "Schmidt", "initials": "K. S."} +{"name": "Herr Schmidt", "title": "Herr", "family": "Schmidt", "initials": "S."} +{"name": "His Excellency Lord Duncan", "title": "His Excellency Lord", "family": "Duncan", "initials": "D."} +{"name": "His Holiness", "title": "His", "family": "Holiness", "ambiguities": ["TITLE_OR_NAME"], "initials": "H."} +{"name": "His Holiness the Dalai Lama", "title": "His Holiness the Dalai", "family": "Lama", "ambiguities": ["TITLE_OR_NAME"], "initials": "L."} +{"name": "Hisham Ibrahim, CPA, CFA", "given": "Hisham", "family": "Ibrahim", "suffix": "CPA, CFA", "initials": "H. I."} +{"name": "Hon Solo", "title": "Hon", "family": "Solo", "initials": "S."} +{"name": "Hon. Barrington P. Doe-Ray, Jr.", "title": "Hon.", "given": "Barrington", "middle": "P.", "family": "Doe-Ray", "suffix": "Jr.", "initials": "B. P. D."} +{"name": "Hon. Charles J. Siragusa", "title": "Hon.", "given": "Charles", "middle": "J.", "family": "Siragusa", "initials": "C. J. S."} +{"name": "Hon. Marian W. Payson", "title": "Hon.", "given": "Marian", "middle": "W.", "family": "Payson", "initials": "M. W. P."} +{"name": "Honorable Judge Susan Russ Walker", "title": "Honorable Judge", "given": "Susan", "middle": "Russ", "family": "Walker", "initials": "S. R. W."} +{"name": "Honorable Judge Terry F. Moorer", "title": "Honorable Judge", "given": "Terry", "middle": "F.", "family": "Moorer", "initials": "T. F. M."} +{"name": "Honorable Judge W. Harold Albritton, III", "title": "Honorable Judge", "given": "W.", "middle": "Harold", "family": "Albritton", "suffix": "III", "initials": "W. H. A."} +{"name": "Honorable Terry F. Moorer", "title": "Honorable", "given": "Terry", "middle": "F.", "family": "Moorer", "initials": "T. F. M."} +{"name": "Honorable W. Harold Albritton, III", "title": "Honorable", "given": "W.", "middle": "Harold", "family": "Albritton", "suffix": "III", "initials": "W. H. A."} +{"name": "Horvat, Irena", "given": "Irena", "family": "Horvat", "initials": "I. H."} +{"name": "Horvat, Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Horvat", "initials": "I. H."} +{"name": "Howard G. Buffett", "given": "Howard", "middle": "G.", "family": "Buffett", "initials": "H. G. B."} +{"name": "Hozier", "given": "Hozier", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "H."} +{"name": "Hozier", "order": "FAMILY_FIRST", "family": "Hozier", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "H."} +{"name": "Hussein Mariam Geedi Hamish", "order": "FAMILY_FIRST", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} +{"name": "Hussein, Mariam Geedi Hamish", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} +{"name": "I don", "given": "I", "family": "don", "initials": "I. d."} +{"name": "II Van Johnson", "given": "II", "family": "Van Johnson", "initials": "I. J."} +{"name": "III van Gogh", "given": "III", "family": "van Gogh", "initials": "I. G."} +{"name": "Iiɖa KANSULO", "given": "Iiɖa", "family": "KANSULO", "initials": "I. K."} +{"name": "Ilir Rustemi", "given": "Ilir", "family": "Rustemi", "initials": "I. R."} +{"name": "Ilmarinen Seppo", "order": "FAMILY_FIRST", "given": "Seppo", "family": "Ilmarinen", "initials": "S. I."} +{"name": "Ilmarinen, Seppo", "given": "Seppo", "family": "Ilmarinen", "initials": "S. I."} +{"name": "Imam Khomeini", "title": "Imam", "family": "Khomeini", "initials": "K."} +{"name": "Ingrid Andersson", "given": "Ingrid", "family": "Andersson", "initials": "I. A."} +{"name": "Ionescu Adrian Dragos", "order": "FAMILY_FIRST", "given": "Adrian", "middle": "Dragos", "family": "Ionescu", "initials": "A. D. I."} +{"name": "Ionescu, Adrian Dragos", "given": "Adrian", "middle": "Dragos", "family": "Ionescu", "initials": "A. D. I."} +{"name": "Irena Horvat", "given": "Irena", "family": "Horvat", "initials": "I. H."} +{"name": "Irena Kazlauskienė", "given": "Irena", "family": "Kazlauskienė", "initials": "I. K."} +{"name": "Irena Marković", "given": "Irena", "family": "Marković", "initials": "I. M."} +{"name": "Irena Orel", "given": "Irena", "family": "Orel", "initials": "I. O."} +{"name": "Irene Adler", "given": "Irene", "family": "Adler", "initials": "I. A."} +{"name": "Irene Aduritz", "given": "Irene", "family": "Aduritz", "initials": "I. A."} +{"name": "Irene Andersen", "given": "Irene", "family": "Andersen", "initials": "I. A."} +{"name": "Irene Bakker", "given": "Irene", "family": "Bakker", "initials": "I. B."} +{"name": "Irene Fabbri", "given": "Irene", "family": "Fabbri", "initials": "I. F."} +{"name": "Irene Møller", "given": "Irene", "family": "Møller", "initials": "I. M."} +{"name": "Irene Obidike", "given": "Irene", "family": "Obidike", "initials": "I. O."} +{"name": "Irene Rossouw", "given": "Irene", "family": "Rossouw", "initials": "I. R."} +{"name": "Iris Falke", "given": "Iris", "family": "Falke", "initials": "I. F."} +{"name": "Irmak Yılmaz", "given": "Irmak", "family": "Yılmaz", "initials": "I. Y."} +{"name": "Ismail, Syed Abd Aziz Syed A", "given": "Syed", "middle": "Aziz Syed A", "family": "Ismail", "suffix": "Abd", "initials": "S. A. S. A. I."} +{"name": "Isseu Awa", "given": "Isseu", "family": "Awa", "initials": "I. A."} +{"name": "Ivan", "given": "Ivan", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "I."} +{"name": "Ivan", "order": "FAMILY_FIRST", "family": "Ivan", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "I."} +{"name": "Ivan Ivanov", "given": "Ivan", "family": "Ivanov", "initials": "I. I."} +{"name": "Ivan Tham Jun Hoe", "given": "Ivan", "middle": "Tham Jun", "family": "Hoe", "initials": "I. T. J. H."} +{"name": "Ivanov Ivan", "given": "Ivanov", "family": "Ivan", "initials": "I. I."} +{"name": "Ivanov Ivan Ivanovich", "given": "Ivanov", "middle": "Ivan", "family": "Ivanovich", "initials": "I. I. I."} +{"name": "Iñaki", "given": "Iñaki", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "I."} +{"name": "Iñaki", "order": "FAMILY_FIRST", "family": "Iñaki", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "I."} +{"name": "J A D", "given": "J", "middle": "A", "family": "D", "initials": "J. A. D."} +{"name": "J-E-P DUPONT", "given": "J-E-P", "family": "DUPONT", "initials": "J. D."} +{"name": "J. A. D.", "given": "J.", "middle": "A.", "family": "D.", "initials": "J. A. D."} +{"name": "J. None D.", "given": "J.", "middle": "None", "family": "D.", "initials": "J. N. D."} +{"name": "J. S. V.", "given": "J.", "middle": "S.", "family": "V.", "initials": "J. S. V."} +{"name": "J. Smith", "given": "J.", "family": "Smith", "initials": "J. S."} +{"name": "J. Smith Jr.", "given": "J.", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "J. Smith Sr.", "given": "J.", "family": "Smith", "suffix": "Sr.", "initials": "J. S."} +{"name": "J. Smith, MD", "given": "J.", "family": "Smith", "suffix": "MD", "initials": "J. S."} +{"name": "J. Smith, PhD", "given": "J.", "family": "Smith", "suffix": "PhD", "initials": "J. S."} +{"name": "J. V.", "given": "J.", "family": "V.", "initials": "J. V."} +{"name": "J. née Jones Smith V", "given": "J.", "maiden": "Jones Smith V", "initials": "J."} +{"name": "J. Ó. Pérez", "given": "J.", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} +{"name": "J.A. K.D.", "given": "J.A.", "family": "K.D.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. K."} +{"name": "J.R. Smith", "given": "J.R.", "family": "Smith", "initials": "J. S."} +{"name": "J.R.R. Tolkien", "given": "J.R.R.", "family": "Tolkien", "initials": "J. T."} +{"name": "J.씨", "given": "J.", "suffix": "씨", "initials": "J."} +{"name": "JA KD", "given": "JA", "family": "KD", "initials": "J. K."} +{"name": "JACK MA", "given": "JACK", "family": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "JANE DOE NEE SMITH MA", "given": "JANE", "family": "DOE", "suffix": "MA", "maiden": "SMITH", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "JANE DOE NEE YO-YO MA", "given": "JANE", "family": "DOE", "suffix": "MA", "maiden": "YO-YO", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "JANE YO-YO MA", "given": "JANE", "family": "YO-YO", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. Y."} +{"name": "JEFFREY (JD) BRICKEN", "given": "JEFFREY", "family": "BRICKEN", "nickname": "JD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "initials": "J. B."} +{"name": "JOHN DOE", "given": "JOHN", "family": "DOE", "initials": "J. D."} +{"name": "JOHN DOE PHD", "given": "JOHN", "family": "DOE", "suffix": "PHD", "initials": "J. D."} +{"name": "JOHN DOE PHD MD", "given": "JOHN", "family": "DOE", "suffix": "PHD MD", "initials": "J. D."} +{"name": "JOHN E SMITH", "given": "JOHN", "middle": "E", "family": "SMITH", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "J. E. S."} +{"name": "JOHN MA PHD", "given": "JOHN", "family": "MA", "suffix": "PHD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "JOHN NEE JONES SMITH MA PHD", "family": "JOHN", "suffix": "PHD", "maiden": "JONES SMITH MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J."} +{"name": "JOHN PROF. MA", "title": "PROF.", "given": "JOHN", "family": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "JOHN QUINCY SMITH I", "given": "JOHN", "middle": "QUINCY", "family": "SMITH", "suffix": "I", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. Q. S."} +{"name": "JOHN SMITH", "given": "JOHN", "family": "SMITH", "initials": "J. S."} +{"name": "JOHN SMITH MA", "given": "JOHN", "family": "SMITH", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "JOHN SMITH PH.D.", "given": "JOHN", "family": "SMITH", "suffix": "PH.D.", "initials": "J. S."} +{"name": "JOHN SMITH, MA", "given": "JOHN", "family": "SMITH", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "JOHN SMITH, VD MA", "given": "JOHN", "family": "SMITH", "suffix": "VD MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "JOSE E MARIA SANTOS", "given": "JOSE", "middle": "E MARIA", "family": "SANTOS", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "J. E. M. S."} +{"name": "JOSE ORTEGA-Y-GASSET", "given": "JOSE", "family": "ORTEGA-Y-GASSET", "initials": "J. O."} +{"name": "JOSEP CAROD I ROVIRA", "given": "JOSEP", "middle": "CAROD I", "family": "ROVIRA", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "J. C. I. R."} +{"name": "JOSEP LLUIS CAROD I ROVIRA", "given": "JOSEP", "middle": "LLUIS CAROD I", "family": "ROVIRA", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "J. L. C. I. R."} +{"name": "JOSÉ GARCÍA", "given": "JOSÉ", "family": "GARCÍA", "initials": "J. G."} +{"name": "JOSÉ GARCÍA", "given": "JOSÉ", "family": "GARCÍA", "initials": "J. G."} +{"name": "JUAN DELA CRUZ", "given": "JUAN", "family": "DELA CRUZ", "initials": "J. C."} +{"name": "JUAN GARCIA Jr.", "given": "JUAN", "family": "GARCIA", "suffix": "Jr.", "initials": "J. G."} +{"name": "JUAN GARCIA Y LOPEZ", "given": "JUAN", "family": "GARCIA Y LOPEZ", "initials": "J. G. L."} +{"name": "JUAN Y GARCIA", "given": "JUAN", "middle": "Y", "family": "GARCIA", "initials": "J. Y. G."} +{"name": "Jaak Eerik Kask", "given": "Jaak", "middle": "Eerik", "family": "Kask", "initials": "J. E. K."} +{"name": "Jack", "given": "Jack", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jack", "order": "FAMILY_FIRST", "family": "Jack", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jack M.A.", "given": "Jack", "suffix": "M.A.", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jack MA", "given": "Jack", "suffix": "MA", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "initials": "J."} +{"name": "Jack MA.", "given": "Jack", "suffix": "MA.", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "initials": "J."} +{"name": "Jack Ma", "given": "Jack", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "Jack Ma Jr", "given": "Jack", "family": "Ma", "suffix": "Jr", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "Jack Ma.", "given": "Jack", "family": "Ma.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "Jack Wei Ma", "given": "Jack", "middle": "Wei", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. W. M."} +{"name": "Jack X.Y.I.", "given": "Jack", "family": "X.Y.I.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. X."} +{"name": "Jack X.Y.Z.", "given": "Jack", "family": "X.Y.Z.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. X."} +{"name": "Jacobsen Christopher Hans", "order": "FAMILY_FIRST", "given": "Christopher", "middle": "Hans", "family": "Jacobsen", "initials": "C. H. J."} +{"name": "Jacobsen, Christopher Hans", "given": "Christopher", "middle": "Hans", "family": "Jacobsen", "initials": "C. H. J."} +{"name": "Jacqueline Beauchêne", "given": "Jacqueline", "family": "Beauchêne", "initials": "J. B."} +{"name": "Jan Beno Krawc", "given": "Jan", "middle": "Beno", "family": "Krawc", "initials": "J. B. K."} +{"name": "Jan Kees de Jager", "given": "Jan", "middle": "Kees", "family": "de Jager", "initials": "J. K. J."} +{"name": "Jan Koos Van der Merwe", "given": "Jan", "middle": "Koos", "family": "Van der Merwe", "initials": "J. K. M."} +{"name": "Jan Měto Šejc", "given": "Jan", "middle": "Měto", "family": "Šejc", "initials": "J. M. Š."} +{"name": "Jana Kováčová", "given": "Jana", "family": "Kováčová", "initials": "J. K."} +{"name": "Jana Nováková", "given": "Jana", "family": "Nováková", "initials": "J. N."} +{"name": "Jane (Janey, Jo) Smith", "given": "Jane", "family": "Smith", "nickname": "Janey, Jo", "initials": "J. S."} +{"name": "Jane (née , Jones) Smith", "given": "Jane", "family": "Smith", "nickname": "née , Jones", "initials": "J. S."} +{"name": "Jane (née ,Jones) Smith", "given": "Jane", "family": "Smith", "nickname": "née ,Jones", "initials": "J. S."} +{"name": "Jane (née Jones) Smith", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} +{"name": "Jane Doctor", "given": "Jane", "family": "Doctor", "initials": "J. D."} +{"name": "Jane Doe", "given": "Jane", "family": "Doe", "initials": "J. D."} +{"name": "Jane Doe (nee Smith MA)", "given": "Jane", "family": "Doe", "maiden": "Smith MA", "initials": "J. D."} +{"name": "Jane Doe (nee Smith Ma)", "given": "Jane", "family": "Doe", "maiden": "Smith Ma", "initials": "J. D."} +{"name": "Jane Doe (nee Smith Prof.)", "title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith", "initials": "J. D."} +{"name": "Jane Doe (nee Smith) MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe Jr. nee Smith", "given": "Jane", "family": "Doe", "suffix": "Jr. nee Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe Jr. nee Smith Ma", "given": "Jane", "family": "Doe", "suffix": "Jr. nee Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe PhD nee Smith", "given": "Jane", "family": "Doe", "suffix": "PhD nee Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe geb. Smith MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee King.", "given": "Jane", "family": "Doe", "maiden": "King.", "initials": "J. D."} +{"name": "Jane Doe nee MA", "given": "Jane", "family": "Doe", "maiden": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee MA PhD", "given": "Jane", "family": "Doe", "suffix": "PhD", "maiden": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee MA Smith", "given": "Jane", "family": "Doe", "maiden": "MA Smith", "initials": "J. D."} +{"name": "Jane Doe nee Prof. Dr.", "title": "Dr.", "given": "Jane", "family": "Doe", "maiden": "Prof.", "initials": "J. D."} +{"name": "Jane Doe nee Puig i", "given": "Jane", "family": "Doe", "suffix": "i", "maiden": "Puig", "initials": "J. D."} +{"name": "Jane Doe nee Puig i III", "given": "Jane", "family": "Doe", "suffix": "i III", "maiden": "Puig", "initials": "J. D."} +{"name": "Jane Doe nee Puig i Soler", "given": "Jane", "family": "Doe", "maiden": "Puig i Soler", "initials": "J. D."} +{"name": "Jane Doe nee Smith 2", "given": "Jane", "family": "Doe", "suffix": "2", "maiden": "Smith", "initials": "J. D."} +{"name": "Jane Doe nee Smith DO DO", "given": "Jane", "family": "Doe", "suffix": "DO DO", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith MA JD", "given": "Jane", "family": "Doe", "suffix": "MA JD", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith MA PhD", "given": "Jane", "family": "Doe", "suffix": "MA PhD", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith MA Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith MA y", "given": "Jane", "family": "Doe", "maiden": "Smith MA y", "initials": "J. D."} +{"name": "Jane Doe nee Smith Ma", "given": "Jane", "family": "Doe", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith Ma JD", "given": "Jane", "family": "Doe", "suffix": "JD", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith Ma Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith PhD MA", "given": "Jane", "family": "Doe", "suffix": "PhD MA", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith PhD MEng", "given": "Jane", "family": "Doe", "suffix": "PhD MEng", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith PhD Smith", "given": "Jane", "family": "Doe", "suffix": "PhD Smith", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith", "initials": "J. D."} +{"name": "Jane Doe nee Smith Prof. MA", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith V", "given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith V MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith V Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith VI Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "VI", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith X.Y.Z.", "given": "Jane", "family": "Doe", "suffix": "X.Y.Z.", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Smith XYZ", "given": "Jane", "family": "Doe", "maiden": "Smith XYZ", "initials": "J. D."} +{"name": "Jane Doe nee Smith do", "given": "Jane", "family": "Doe", "suffix": "do", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe nee Yo-Yo Ma", "given": "Jane", "family": "Doe", "maiden": "Yo-Yo Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Doe, MS LAc", "given": "Jane", "family": "Doe", "suffix": "MS LAc", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "Jane Mac Beth", "given": "Jane", "family": "Mac Beth", "initials": "J. B."} +{"name": "Jane Smith", "given": "Jane", "family": "Smith", "initials": "J. S."} +{"name": "Jane Smith (Jones)", "given": "Jane", "family": "Smith", "nickname": "Jones", "initials": "J. S."} +{"name": "Jane Smith (Nee)", "given": "Jane", "family": "Smith", "nickname": "Nee", "initials": "J. S."} +{"name": "Jane Smith (Nee) (Jones)", "given": "Jane", "family": "Smith", "nickname": "Nee Jones", "initials": "J. S."} +{"name": "Jane Smith (née Jones)", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} +{"name": "Jane Smith (née Jr.)", "given": "Jane", "middle": "Smith", "family": "née", "suffix": "Jr.", "initials": "J. S. n."} +{"name": "Jane Smith (née V)", "given": "Jane", "family": "Smith", "maiden": "V", "initials": "J. S."} +{"name": "Jane Smith (née)", "given": "Jane", "family": "Smith", "nickname": "née", "initials": "J. S."} +{"name": "Jane Smith born Jones", "given": "Jane", "middle": "Smith born", "family": "Jones", "initials": "J. S. b. J."} +{"name": "Jane Smith née Jones", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} +{"name": "Jane Smith née Jones PhD", "given": "Jane", "family": "Smith", "suffix": "PhD", "maiden": "Jones", "initials": "J. S."} +{"name": "Jane Smith née V", "given": "Jane", "family": "Smith", "maiden": "V", "initials": "J. S."} +{"name": "Jane Smith, née Jones", "given": "née", "middle": "Jones", "family": "Jane Smith", "initials": "n. J. J. S."} +{"name": "Jane and née Jones", "given": "Jane and née", "family": "Jones", "initials": "J. n. J."} +{"name": "Jane de la née Jones", "given": "Jane", "family": "de la", "maiden": "Jones", "initials": "J. d. l."} +{"name": "Jane née Jones Smith", "family": "Jane", "maiden": "Jones Smith", "initials": "J."} +{"name": "Jane née Jr y Jones", "given": "Jane", "middle": "née", "family": "Jr y Jones", "initials": "J. n. J. J."} +{"name": "Jane née and Jones Smith", "family": "Jane", "maiden": "and Jones Smith", "initials": "J."} +{"name": "Jane van der Berg nee Prof. King. MA", "title": "King.", "given": "Jane", "family": "van der Berg", "suffix": "MA", "maiden": "Prof.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B."} +{"name": "Jane van der Berg nee Smith Prof.", "title": "Prof.", "given": "Jane", "family": "van der Berg", "maiden": "Smith", "initials": "J. B."} +{"name": "Jane van der Berg née", "given": "Jane", "family": "van der Berg née", "initials": "J. B. n."} +{"name": "Jane van der Berg née Jones", "given": "Jane", "family": "van der Berg", "maiden": "Jones", "initials": "J. B."} +{"name": "Jane van der Berg née Jr Jones", "given": "Jane", "family": "van der Berg née", "suffix": "Jr Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B. n."} +{"name": "Jane van der Berg née PhD", "given": "Jane", "family": "van der Berg née", "suffix": "PhD", "initials": "J. B. n."} +{"name": "Jane van der Berg née y Jones", "given": "Jane", "family": "van der Berg", "maiden": "y Jones", "initials": "J. B."} +{"name": "Jane van der Berg 旧姓 Jones", "given": "Jane", "family": "van der Berg", "maiden": "Jones", "initials": "J. B."} +{"name": "Jane „JD Smith", "given": "Jane", "middle": "„JD", "family": "Smith", "ambiguities": ["UNBALANCED_DELIMITER"], "initials": "J. „. S."} +{"name": "Janey née Jones", "family": "Janey", "maiden": "Jones", "initials": "J."} +{"name": "Jean 'JD' Smith", "given": "Jean", "family": "Smith", "nickname": "JD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "initials": "J. S."} +{"name": "Jean DUPONT", "given": "Jean", "family": "DUPONT", "initials": "J. D."} +{"name": "Jean Pierre DUPONT", "given": "Jean", "middle": "Pierre", "family": "DUPONT", "initials": "J. P. D."} +{"name": "Jean St Clair", "given": "Jean", "family": "St Clair", "initials": "J. C."} +{"name": "Jean de Mesnil", "given": "Jean", "family": "de Mesnil", "initials": "J. M."} +{"name": "Jean «Petit» Dupont", "given": "Jean", "family": "Dupont", "nickname": "Petit", "initials": "J. D."} +{"name": "Jenny \"JJ\" Baker (Johnson)", "given": "Jenny", "family": "Baker", "nickname": "JJ Johnson", "initials": "J. B."} +{"name": "Jenny (Johnson) Baker", "given": "Jenny", "family": "Baker", "nickname": "Johnson", "initials": "J. B."} +{"name": "Jenny Baker", "given": "Jenny", "family": "Baker", "initials": "J. B."} +{"name": "Jenny Baker (Johnson)", "given": "Jenny", "family": "Baker", "nickname": "Johnson", "initials": "J. B."} +{"name": "Jill St. John", "given": "Jill", "family": "St. John", "initials": "J. J."} +{"name": "Jiří", "given": "Jiří", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jiří", "order": "FAMILY_FIRST", "family": "Jiří", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jo Ann", "given": "Jo", "family": "Ann", "initials": "J. A."} +{"name": "Joao da Silva Do Amaral de Souza", "given": "Joao", "middle": "da Silva Do Amaral", "family": "de Souza", "initials": "J. S. A. S."} +{"name": "Joao da Silva do Amaral de Souza", "given": "Joao", "middle": "da Silva do Amaral", "family": "de Souza", "initials": "J. S. A. S."} +{"name": "Joe Dentist D.D.S.", "given": "Joe", "family": "Dentist", "suffix": "D.D.S.", "initials": "J. D."} +{"name": "Joe E. Smith", "given": "Joe", "middle": "E.", "family": "Smith", "initials": "J. E. S."} +{"name": "Joe Franklin Jr", "given": "Joe", "family": "Franklin", "suffix": "Jr", "initials": "J. F."} +{"name": "Joe Smith", "given": "Joe", "family": "Smith", "initials": "J. S."} +{"name": "Johan Harald Wallin", "given": "Johan", "middle": "Harald", "family": "Wallin", "initials": "J. H. W."} +{"name": "John", "given": "John", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "John", "order": "FAMILY_FIRST", "family": "John", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "John Doe ☁️", "given": "John", "middle": "Doe", "family": "️", "initials": "J. D. ️."} +{"name": "John Q Doe", "given": "John", "middle": "Q", "family": "Doe", "initials": "J. Q. D."} +{"name": "John \"Jack\" Kennedy", "given": "John", "family": "Kennedy", "nickname": "Jack", "initials": "J. K."} +{"name": "John & Jane", "given": "John & Jane", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J. J."} +{"name": "John . Smith", "given": "John", "middle": ".", "family": "Smith", "initials": "J. .. S."} +{"name": "John A. Doe", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} +{"name": "John A. Doe III", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} +{"name": "John A. Doe, Jr", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr", "initials": "J. A. D."} +{"name": "John A. Doe, Jr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} +{"name": "John A. Kenneth Doe", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} +{"name": "John A. Kenneth Doe III", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} +{"name": "John A. Kenneth Doe, Jr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} +{"name": "John An Smith", "given": "John", "middle": "An", "family": "Smith", "initials": "J. A. S."} +{"name": "John Chinedu Amadi", "given": "John", "middle": "Chinedu", "family": "Amadi", "initials": "J. C. A."} +{"name": "John Doctor Smith", "given": "John", "middle": "Doctor", "family": "Smith", "initials": "J. D. S."} +{"name": "John Doe", "given": "John", "family": "Doe", "initials": "J. D."} +{"name": "John Doe III", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} +{"name": "John Doe JD.CPA", "given": "John", "family": "Doe", "suffix": "JD.CPA", "initials": "J. D."} +{"name": "John Doe MD PhD", "given": "John", "family": "Doe", "suffix": "MD PhD", "initials": "J. D."} +{"name": "John Doe Msc.Ed.", "given": "John", "family": "Doe", "suffix": "Msc.Ed.", "initials": "J. D."} +{"name": "John Doe do", "given": "John", "family": "Doe", "suffix": "do", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. D."} +{"name": "John Doe jr., MD", "given": "John", "family": "Doe", "suffix": "jr., MD", "initials": "J. D."} +{"name": "John Doe, CLU, CFP, LUTC", "given": "John", "family": "Doe", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. D."} +{"name": "John Doe, Jr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} +{"name": "John Doe, Jr.,,", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} +{"name": "John Doe, MD - PhD - FACS", "title": "MD", "given": "-", "family": "John Doe", "suffix": "PhD - FACS", "initials": "-. J. D."} +{"name": "John Doe, MD - PhD, FACS", "title": "MD", "given": "-", "family": "John Doe", "suffix": "PhD, FACS", "initials": "-. J. D."} +{"name": "John Doe, MD, PhD", "given": "John", "family": "Doe", "suffix": "MD, PhD", "initials": "J. D."} +{"name": "John Doe, MD-PhD-", "given": "MD-PhD-", "family": "John Doe", "initials": "M. J. D."} +{"name": "John Doe, Msc.Ed.", "given": "John", "family": "Doe", "suffix": "Msc.Ed.", "initials": "J. D."} +{"name": "John Doe, Phd, Jr", "given": "John", "family": "Doe", "suffix": "Phd, Jr", "initials": "J. D."} +{"name": "John E Smith", "given": "John", "middle": "E", "family": "Smith", "initials": "J. E. S."} +{"name": "John Edgar Casey Williams III", "given": "John", "middle": "Edgar Casey", "family": "Williams", "suffix": "III", "initials": "J. E. C. W."} +{"name": "John F Kennedy", "given": "John", "middle": "F", "family": "Kennedy", "initials": "J. F. K."} +{"name": "John Hamish Watson", "given": "John", "middle": "Hamish", "family": "Watson", "initials": "J. H. W."} +{"name": "John Hámish Watson", "given": "John", "middle": "Hámish", "family": "Watson", "initials": "J. H. W."} +{"name": "John I Smith", "given": "John", "middle": "I", "family": "Smith", "initials": "J. I. S."} +{"name": "John Jack Andrew Kennedy", "given": "John", "middle": "Jack Andrew", "family": "Kennedy", "initials": "J. J. A. K."} +{"name": "John Jones (Google Docs)", "given": "John", "family": "Jones", "nickname": "Google Docs", "initials": "J. J."} +{"name": "John Jones (Google Docs), Jr. (Unknown)", "given": "John", "family": "Jones", "suffix": "Jr.", "nickname": "Google Docs Unknown", "initials": "J. J."} +{"name": "John Jones (Unknown)", "given": "John", "family": "Jones", "nickname": "Unknown", "initials": "J. J."} +{"name": "John King", "given": "John", "family": "King", "initials": "J. K."} +{"name": "John Leed", "given": "John", "family": "Leed", "initials": "J. L."} +{"name": "John Ma", "given": "John", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "John Major. Smith", "given": "John", "middle": "Major.", "family": "Smith", "initials": "J. M. S."} +{"name": "John Mkwawa", "given": "John", "family": "Mkwawa", "initials": "J. M."} +{"name": "John P. Doe, CLU, CFP, LUTC", "given": "John", "middle": "P.", "family": "Doe", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D."} +{"name": "John PhD Smith", "given": "John", "middle": "PhD", "family": "Smith", "initials": "J. P. S."} +{"name": "John Prof. MA", "title": "Prof.", "family": "John", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J."} +{"name": "John Q. Smith", "given": "John", "middle": "Q.", "family": "Smith", "initials": "J. Q. S."} +{"name": "John Quincy Smith i", "given": "John", "middle": "Quincy", "family": "Smith", "suffix": "i", "initials": "J. Q. S."} +{"name": "John Smith", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith 1.4", "given": "John", "middle": "Smith", "family": "1.4", "initials": "J. S. 1."} +{"name": "John Smith B.Tech.", "given": "John", "family": "Smith", "suffix": "B.Tech.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith C.H.A.", "given": "John", "family": "Smith", "suffix": "C.H.A.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith Dr.", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith E.S.Q.", "given": "John", "family": "Smith", "suffix": "E.S.Q.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith Esq", "given": "John", "family": "Smith", "suffix": "Esq", "initials": "J. S."} +{"name": "John Smith Esq.", "given": "John", "family": "Smith", "suffix": "Esq.", "initials": "J. S."} +{"name": "John Smith Esquire", "given": "John", "family": "Smith", "suffix": "Esquire", "initials": "J. S."} +{"name": "John Smith I", "given": "John", "family": "Smith", "suffix": "I", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith J.u.n.i.o.r.", "given": "John", "family": "Smith", "suffix": "J.u.n.i.o.r.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith Jr", "given": "John", "family": "Smith", "suffix": "Jr", "initials": "J. S."} +{"name": "John Smith Jr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "John Smith Jr. Prof.", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "John Smith Jr., PhD", "given": "John", "family": "Smith", "suffix": "Jr., PhD", "initials": "J. S."} +{"name": "John Smith M.A.", "given": "John", "family": "Smith", "suffix": "M.A.", "initials": "J. S."} +{"name": "John Smith MA", "given": "John", "family": "Smith", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith MC", "given": "John", "family": "Smith", "suffix": "MC", "initials": "J. S."} +{"name": "John Smith MD PhD", "given": "John", "family": "Smith", "suffix": "MD PhD", "initials": "J. S."} +{"name": "John Smith MEng PhD", "given": "John", "middle": "Smith", "family": "MEng", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S. M."} +{"name": "John Smith Ma", "given": "John", "middle": "Smith", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S. M."} +{"name": "John Smith Mc V", "given": "John", "family": "Smith", "suffix": "Mc V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith Mr.", "title": "Mr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith Msc.Ed.", "given": "John", "family": "Smith", "suffix": "Msc.Ed.", "initials": "J. S."} +{"name": "John Smith Ph.", "given": "John", "middle": "Smith", "family": "Ph.", "initials": "J. S. P."} +{"name": "John Smith Ph. D.", "given": "John", "family": "Smith", "suffix": "Ph. D.", "initials": "J. S."} +{"name": "John Smith PhD", "given": "John", "family": "Smith", "suffix": "PhD", "initials": "J. S."} +{"name": "John Smith PhD Ed Ma", "given": "John", "family": "Smith", "suffix": "PhD Ed Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith PhD Jones", "given": "John", "family": "Smith", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith PhD MEng", "given": "John", "family": "Smith", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith PhD Ma", "given": "John", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith PhD Prof. Ma", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith Prof.", "title": "Prof.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith Prof. Dr.", "title": "Prof. Dr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith Prof. Jr.", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "John Smith Q.W.E.R.T.", "given": "John", "family": "Smith", "suffix": "Q.W.E.R.T.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith R.A.I.", "given": "John", "family": "Smith", "suffix": "R.A.I.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith RAI", "given": "John", "middle": "Smith", "family": "RAI", "initials": "J. S. R."} +{"name": "John Smith Rev.", "title": "Rev.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith Sir", "given": "John", "middle": "Smith", "family": "Sir", "initials": "J. S. S."} +{"name": "John Smith VI", "given": "John", "family": "Smith", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith X.Y.Z.", "given": "John", "family": "Smith", "suffix": "X.Y.Z.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith XYZ", "given": "John", "middle": "Smith", "family": "XYZ", "initials": "J. S. X."} +{"name": "John Smith Xyz.", "given": "John", "middle": "Smith", "family": "Xyz.", "initials": "J. S. X."} +{"name": "John Smith nee Jones R.A.I.", "given": "John", "family": "Smith", "suffix": "R.A.I.", "maiden": "Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith 田.中.", "given": "John", "middle": "Smith", "family": "田.中.", "initials": "J. S. 田."} +{"name": "John Smith, 1.4", "given": "1.4", "family": "John Smith", "initials": "1. J. S."} +{"name": "John Smith, A.B.", "given": "A.B.", "family": "John Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. J. S."} +{"name": "John Smith, A.B. Ph.D.", "given": "A.B.", "family": "John Smith", "suffix": "Ph.D.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. J. S."} +{"name": "John Smith, Dr.", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith, Dr.", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Smith", "family": "John", "initials": "S. J."} +{"name": "John Smith, Dr. J.씨", "title": "Dr.", "given": "John", "family": "Smith", "suffix": "J.씨", "initials": "J. S."} +{"name": "John Smith, Ed", "given": "John", "family": "Smith", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Ed Ma", "given": "John", "family": "Smith", "suffix": "Ed Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Jones", "given": "Jones", "family": "John Smith", "initials": "J. J. S."} +{"name": "John Smith, Jr do", "given": "John", "family": "Smith", "suffix": "Jr do", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Jr vd", "given": "John", "family": "Smith", "suffix": "Jr vd", "initials": "J. S."} +{"name": "John Smith, Jr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "John Smith, Jr. Ph. D.", "given": "John", "family": "Smith", "suffix": "Jr. Ph. D.", "initials": "J. S."} +{"name": "John Smith, Jr., Lt.Gov.", "title": "Lt.Gov.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "John Smith, Jr., abdul Dr. Smith", "title": "Dr.", "given": "John", "family": "Smith", "suffix": "Jr., abdul Smith", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "John Smith, Jr., van der Berg Prof.", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr., van der Berg", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "John Smith, LEED AP", "given": "John", "family": "Smith", "suffix": "LEED AP", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, MA", "given": "John", "family": "Smith", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, MD Jones", "title": "MD", "given": "Jones", "family": "John Smith", "initials": "J. J. S."} +{"name": "John Smith, MD MEng", "given": "John", "family": "Smith", "suffix": "MD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, MD, Bart", "given": "John", "family": "Smith", "suffix": "MD, Bart", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "John Smith, MD, Ma", "given": "John", "family": "Smith", "suffix": "MD, Ma", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "John Smith, MD, Ms", "given": "John", "family": "Smith", "suffix": "MD, Ms", "initials": "J. S."} +{"name": "John Smith, MD, R.A.I.", "given": "John", "family": "Smith", "suffix": "MD, R.A.I.", "initials": "J. S."} +{"name": "John Smith, MD, XYZ", "given": "John", "family": "Smith", "suffix": "MD, XYZ", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "John Smith, MD,, Jr.", "given": "John", "family": "Smith", "suffix": "MD, Jr.", "initials": "J. S."} +{"name": "John Smith, MEng PhD", "given": "John", "family": "Smith", "suffix": "MEng PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Ma", "given": "John", "family": "Smith", "suffix": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Mr.", "title": "Mr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith, Mr. Jr.", "title": "Mr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "John Smith, Mr. and Mrs.", "title": "Mr. and Mrs.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "John Smith, Ms Ma", "given": "John", "family": "Smith", "suffix": "Ms Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Ph. D.", "given": "John", "family": "Smith", "suffix": "Ph. D.", "initials": "J. S."} +{"name": "John Smith, Ph. D. Jones", "given": "John", "family": "Smith", "suffix": "Ph. D. Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, Ph.D.", "given": "John", "family": "Smith", "suffix": "Ph.D.", "initials": "J. S."} +{"name": "John Smith, PhD", "given": "John", "family": "Smith", "suffix": "PhD", "initials": "J. S."} +{"name": "John Smith, PhD DO DO", "given": "John", "family": "Smith", "suffix": "PhD DO DO", "initials": "J. S."} +{"name": "John Smith, PhD Jones", "given": "John", "family": "Smith", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, PhD MEng", "given": "John", "family": "Smith", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, PhD Ma", "given": "John", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, PhD X.Y.", "given": "John", "family": "Smith", "suffix": "PhD X.Y.", "initials": "J. S."} +{"name": "John Smith, PhD XYZ", "given": "John", "family": "Smith", "suffix": "PhD XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, PhD vd DO", "given": "John", "family": "Smith", "suffix": "PhD vd DO", "initials": "J. S."} +{"name": "John Smith, RAI", "given": "John", "family": "Smith", "suffix": "RAI", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, V Jr.", "given": "John", "family": "Smith", "suffix": "V Jr.", "initials": "J. S."} +{"name": "John Smith, V MD", "given": "John", "family": "Smith", "suffix": "V MD", "initials": "J. S."} +{"name": "John Smith, V.", "given": "John", "family": "Smith", "suffix": "V.", "initials": "J. S."} +{"name": "John Smith, X.Y. P.Q.", "given": "John", "family": "Smith", "suffix": "X.Y. P.Q.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, X.Y.Z.", "given": "John", "family": "Smith", "suffix": "X.Y.Z.", "initials": "J. S."} +{"name": "John Smith, X.Y.Z. MA", "given": "John", "family": "Smith", "suffix": "X.Y.Z. MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, XYZ", "given": "John", "family": "Smith", "suffix": "XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John Smith, vd Ma", "given": "John", "family": "Smith", "suffix": "vd Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "John V", "given": "John", "suffix": "V", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "initials": "J."} +{"name": "John V. Smith", "given": "John", "middle": "V.", "family": "Smith", "initials": "J. V. S."} +{"name": "John W. Ingram", "given": "John", "middle": "W.", "family": "Ingram", "initials": "J. W. I."} +{"name": "John W. Ingram V", "given": "John", "middle": "W.", "family": "Ingram", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. W. I."} +{"name": "John W. Ingram, V", "given": "John", "middle": "W.", "family": "Ingram", "suffix": "V", "initials": "J. W. I."} +{"name": "John W. Smith, I", "given": "John", "middle": "W.", "family": "Smith", "suffix": "I", "initials": "J. W. S."} +{"name": "John Williams", "given": "John", "family": "Williams", "initials": "J. W."} +{"name": "John and Jane Aznar y Lopez", "given": "John and Jane", "family": "Aznar y Lopez", "initials": "J. J. A. L."} +{"name": "John and Jane Smith", "given": "John and Jane", "family": "Smith", "initials": "J. J. S."} +{"name": "John de Ma", "given": "John", "family": "de Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M."} +{"name": "John e Smith", "given": "John", "middle": "e", "family": "Smith", "initials": "J. e. S."} +{"name": "John e Smith III", "given": "John", "middle": "e", "family": "Smith", "suffix": "III", "initials": "J. e. S."} +{"name": "John e Smith, III", "given": "John", "middle": "e", "family": "Smith", "suffix": "III", "initials": "J. e. S."} +{"name": "John nee Prof. ba MA", "family": "John", "suffix": "MA", "maiden": "Prof. ba", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J."} +{"name": "John née Jones Smith Ma", "family": "John", "maiden": "Jones Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J."} +{"name": "John née Jones Smith V", "family": "John", "suffix": "V", "maiden": "Jones Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J."} +{"name": "John of the Doe", "given": "John of the Doe", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J. D."} +{"name": "John van Buren, Ed", "given": "John", "family": "van Buren", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B."} +{"name": "John van Mc", "given": "John", "family": "van Mc", "initials": "J. v. M."} +{"name": "John van der Berg", "given": "John", "family": "van der Berg", "initials": "J. B."} +{"name": "John van der Berg Ma", "given": "John", "family": "van der Berg Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B. M."} +{"name": "John van der Berg PhD", "given": "John", "family": "van der Berg", "suffix": "PhD", "initials": "J. B."} +{"name": "John van der Berg Prof.", "title": "Prof.", "given": "John", "family": "van der Berg", "initials": "J. B."} +{"name": "John van der Berg Smith", "given": "John", "family": "van der Berg Smith", "initials": "J. B. S."} +{"name": "John van der Berg V", "given": "John", "family": "van der Berg", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. B."} +{"name": "John van der Berg née Jones", "given": "John", "family": "van der Berg", "maiden": "Jones", "initials": "J. B."} +{"name": "John van der J. V", "given": "John", "family": "van der J. V", "initials": "J. J. V."} +{"name": "John von PhD Jones", "given": "John", "family": "von", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. v."} +{"name": "John y Jane", "given": "John", "middle": "y", "family": "Jane", "initials": "J. y. J."} +{"name": "John “Jack” Kennedy", "given": "John", "family": "Kennedy", "nickname": "Jack", "initials": "J. K."} +{"name": "John 王", "given": "John", "family": "王", "initials": "J. 王."} +{"name": "John 田中さん, XYZ", "given": "John", "family": "田中", "suffix": "さん, XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. 田."} +{"name": "John 「Jack」 Smith", "given": "John", "family": "Smith", "nickname": "Jack", "initials": "J. S."} +{"name": "John, Smith, Dr.", "title": "Dr.", "given": "Smith", "family": "John", "initials": "S. J."} +{"name": "Johnson Mary", "order": "FAMILY_FIRST", "given": "Mary", "family": "Johnson", "initials": "M. J."} +{"name": "Johnson, Mary", "given": "Mary", "family": "Johnson", "initials": "M. J."} +{"name": "John😀Smith", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Jon Andoni Urrestaratzu", "given": "Jon", "middle": "Andoni", "family": "Urrestaratzu", "initials": "J. A. U."} +{"name": "Jon Dough and", "given": "Jon", "family": "Dough and", "initials": "J. D."} +{"name": "Jon Dough and of", "given": "Jon", "family": "Dough and of", "initials": "J. D."} +{"name": "Jonas Lukas Petrauskas", "given": "Jonas", "middle": "Lukas", "family": "Petrauskas", "initials": "J. L. P."} +{"name": "Jones née", "given": "Jones", "family": "née", "initials": "J. n."} +{"name": "Jong Anke de", "given": "Jong", "middle": "Anke", "family": "de", "initials": "J. A. d."} +{"name": "Jong van der", "given": "Jong", "family": "van der", "initials": "J. v. d."} +{"name": "Jong, Anke de", "given": "Anke", "family": "de Jong", "initials": "A. J."} +{"name": "Jong, Bertus Wevers Harry Robert de", "given": "Bertus", "middle": "Wevers Harry Robert", "family": "de Jong", "initials": "B. W. H. R. J."} +{"name": "Jong, Piet de", "given": "Piet", "family": "de Jong", "initials": "P. J."} +{"name": "Jong, van der", "given": "van", "middle": "der", "family": "Jong", "initials": "v. d. J."} +{"name": "Jose Aznar y Lopez", "given": "Jose", "family": "Aznar y Lopez", "initials": "J. A. L."} +{"name": "Jose E Maria Santos", "given": "Jose", "middle": "E Maria", "family": "Santos", "initials": "J. E. M. S."} +{"name": "Jose E. Maria Santos", "given": "Jose", "middle": "E. Maria", "family": "Santos", "initials": "J. E. M. S."} +{"name": "Jose Ortega-Y-Gasset", "given": "Jose", "family": "Ortega-Y-Gasset", "initials": "J. O."} +{"name": "Jose e Maria", "given": "Jose", "middle": "e", "family": "Maria", "initials": "J. e. M."} +{"name": "Jose e Maria Santos", "given": "Jose e Maria", "family": "Santos", "initials": "J. M. S."} +{"name": "Josef Václav Svoboda", "given": "Josef", "middle": "Václav", "family": "Svoboda", "initials": "J. V. S."} +{"name": "Josep Carod I Rovira", "given": "Josep", "middle": "Carod I", "family": "Rovira", "initials": "J. C. I. R."} +{"name": "Josep Carod i", "given": "Josep", "family": "Carod", "suffix": "i", "initials": "J. C."} +{"name": "Josep Carod i Rovira", "given": "Josep", "family": "Carod i Rovira", "initials": "J. C. R."} +{"name": "Josep Carod i Rovira III", "given": "Josep", "family": "Carod i Rovira", "suffix": "III", "initials": "J. C. R."} +{"name": "Josep Carod i de Rovira", "given": "Josep", "middle": "Carod i de", "family": "Rovira", "initials": "J. C. R."} +{"name": "Josep Lluis Carod i III", "given": "Josep", "middle": "Lluis", "family": "Carod", "suffix": "i III", "initials": "J. L. C."} +{"name": "Josep Lluis Carod i Rovira", "given": "Josep", "middle": "Lluis", "family": "Carod i Rovira", "initials": "J. L. C. R."} +{"name": "Josep Lluis Carod i V", "given": "Josep", "middle": "Lluis", "family": "Carod", "suffix": "i V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. L. C."} +{"name": "Josep i Rovira", "given": "Josep", "middle": "i", "family": "Rovira", "initials": "J. i. R."} +{"name": "Jovan Dragan Popović", "given": "Jovan", "middle": "Dragan", "family": "Popović", "initials": "J. D. P."} +{"name": "Jozef", "given": "Jozef", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jozef", "order": "FAMILY_FIRST", "family": "Jozef", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "João Pedro Silva", "given": "João", "middle": "Pedro", "family": "Silva", "initials": "J. P. S."} +{"name": "Jo김민준씨, DO", "family": "Jo김민준", "suffix": "씨, DO", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J."} +{"name": "Jr.", "title": "Jr."} +{"name": "Jr. Ph. D.", "title": "Jr.", "suffix": "Ph. D."} +{"name": "Jr. Van Johnson", "title": "Jr.", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. J."} +{"name": "Jr., PhD", "family": "Jr.", "suffix": "PhD", "initials": "J."} +{"name": "Juan & Garcia", "given": "Juan & Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J. G."} +{"name": "Juan Garcia Lopez y", "given": "Juan", "middle": "Garcia", "family": "Lopez y", "initials": "J. G. L."} +{"name": "Juan Garcia Y Lopez", "given": "Juan", "middle": "Garcia Y", "family": "Lopez", "initials": "J. G. Y. L."} +{"name": "Juan Garcia y Lopez", "given": "Juan", "family": "Garcia y Lopez", "initials": "J. G. L."} +{"name": "Juan Garcia y Lopez y", "given": "Juan", "family": "Garcia y Lopez y", "initials": "J. G. L."} +{"name": "Juan McDonald", "given": "Juan", "family": "McDonald", "initials": "J. M."} +{"name": "Juan Q. Velasquez y Garcia", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} +{"name": "Juan Q. Velasquez y Garcia III", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} +{"name": "Juan Q. Velasquez y Garcia, Jr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} +{"name": "Juan Q. Xavier Velasquez y Garcia", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} +{"name": "Juan Q. Xavier Velasquez y Garcia III", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} +{"name": "Juan Q. Xavier Velasquez y Garcia, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} +{"name": "Juan Q. Xavier de la Vega", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} +{"name": "Juan Q. Xavier de la Vega III", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "Juan Q. Xavier de la Vega, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} +{"name": "Juan Q. de la Vega", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} +{"name": "Juan Q. de la Vega III", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} +{"name": "Juan Q. de la Vega, Jr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} +{"name": "Juan Velasquez y Garcia", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} +{"name": "Juan Velasquez y Garcia III", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} +{"name": "Juan Velasquez y Garcia, Jr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} +{"name": "Juan and Garcia", "given": "Juan and Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J. G."} +{"name": "Juan de", "given": "Juan", "family": "de", "initials": "J. d."} +{"name": "Juan de la Vega", "given": "Juan", "family": "de la Vega", "initials": "J. V."} +{"name": "Juan de la Vega III", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} +{"name": "Juan de la Vega, Jr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} +{"name": "Juan de la de la Vega", "given": "Juan", "family": "de la de la Vega", "initials": "J. V."} +{"name": "Juan de la de la Vega Jr.", "given": "Juan", "family": "de la de la Vega", "suffix": "Jr.", "initials": "J. V."} +{"name": "Juan de la de la de la Vega", "given": "Juan", "family": "de la de la de la Vega", "initials": "J. V."} +{"name": "Juan de y", "given": "Juan", "family": "de y", "initials": "J. y."} +{"name": "Juan del Sur", "given": "Juan", "family": "del Sur", "initials": "J. S."} +{"name": "Juan garcia III", "given": "Juan", "family": "garcia", "suffix": "III", "initials": "J. g."} +{"name": "Juan van der", "given": "Juan", "family": "van der", "initials": "J. v. d."} +{"name": "Juan y Eva Garcia", "given": "Juan y Eva", "family": "Garcia", "initials": "J. E. G."} +{"name": "Juan y Garcia", "given": "Juan", "middle": "y", "family": "Garcia", "initials": "J. y. G."} +{"name": "Juan y Garcia née Jones", "given": "Juan", "middle": "y", "family": "Garcia", "maiden": "Jones", "initials": "J. y. G."} +{"name": "Juan Ó Pérez", "given": "Juan", "family": "Ó Pérez", "initials": "J. P."} +{"name": "Juan Ó. Pérez", "given": "Juan", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} +{"name": "Judge C Lynwood Smith, Jr", "title": "Judge", "given": "C", "middle": "Lynwood", "family": "Smith", "suffix": "Jr", "initials": "C. L. S."} +{"name": "Judge G. Thomas Eisele", "title": "Judge", "given": "G.", "middle": "Thomas", "family": "Eisele", "initials": "G. T. E."} +{"name": "Judge James M. Moody", "title": "Judge", "given": "James", "middle": "M.", "family": "Moody", "initials": "J. M. M."} +{"name": "Julia Roberts", "given": "Julia", "family": "Roberts", "initials": "J. R."} +{"name": "Jun Hoe", "given": "Jun", "family": "Hoe", "initials": "J. H."} +{"name": "Jurgis", "given": "Jurgis", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jurgis", "order": "FAMILY_FIRST", "family": "Jurgis", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jurij", "given": "Jurij", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jurij", "order": "FAMILY_FIRST", "family": "Jurij", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Juro", "given": "Juro", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Juro", "order": "FAMILY_FIRST", "family": "Juro", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Ján Matej Slovák", "given": "Ján", "middle": "Matej", "family": "Slovák", "initials": "J. M. S."} +{"name": "Jón Hannes Valdimarsson", "given": "Jón", "middle": "Hannes", "family": "Valdimarsson", "initials": "J. H. V."} +{"name": "Jón Hannes Valdimarsson", "order": "FAMILY_FIRST", "given": "Hannes", "middle": "Valdimarsson", "family": "Jón", "initials": "H. V. J."} +{"name": "Jānis", "given": "Jānis", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "Jānis", "order": "FAMILY_FIRST", "family": "Jānis", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "J."} +{"name": "KALAM Alɛ Mustafa", "order": "FAMILY_FIRST", "given": "Alɛ", "middle": "Mustafa", "family": "KALAM", "initials": "A. M. K."} +{"name": "KANSULO Iiɖa", "order": "FAMILY_FIRST", "given": "Iiɖa", "family": "KANSULO", "initials": "I. K."} +{"name": "Kajal Patel Hiring Globally", "given": "Kajal", "middle": "Patel Hiring", "family": "Globally", "initials": "K. P. H. G."} +{"name": "Kalniņa Aina", "order": "FAMILY_FIRST", "given": "Aina", "family": "Kalniņa", "initials": "A. K."} +{"name": "Kant, Anna Karolína Eva Kristína von (Mgr. Ing., CSc.)", "given": "Anna", "middle": "Karolína Eva Kristína von Mgr. Ing.", "family": "Kant", "suffix": "CSc.", "initials": "A. K. E. K. M. I. K."} +{"name": "Karima", "given": "Karima", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "K."} +{"name": "Karima", "order": "FAMILY_FIRST", "family": "Karima", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "K."} +{"name": "Karima Balóch", "given": "Karima", "family": "Balóch", "initials": "K. B."} +{"name": "Karima Mehráb Balóch", "given": "Karima", "middle": "Mehráb", "family": "Balóch", "initials": "K. M. B."} +{"name": "Kask Jaak Eerik", "order": "FAMILY_FIRST", "given": "Jaak", "middle": "Eerik", "family": "Kask", "initials": "J. E. K."} +{"name": "Kask, Jaak Eerik", "given": "Jaak", "middle": "Eerik", "family": "Kask", "initials": "J. E. K."} +{"name": "Kate Miller", "given": "Kate", "family": "Miller", "initials": "K. M."} +{"name": "Kathe Muller", "given": "Kathe", "family": "Muller", "initials": "K. M."} +{"name": "Kathi Muller", "given": "Kathi", "family": "Muller", "initials": "K. M."} +{"name": "Kathy Mueller", "given": "Kathy", "family": "Mueller", "initials": "K. M."} +{"name": "Katie Stefan", "given": "Katie", "family": "Stefan", "initials": "K. S."} +{"name": "Katrine", "given": "Katrine", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "K."} +{"name": "Katrine", "order": "FAMILY_FIRST", "family": "Katrine", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "K."} +{"name": "Katryn Müller", "given": "Katryn", "family": "Müller", "initials": "K. M."} +{"name": "Katy Mané", "given": "Katy", "family": "Mané", "initials": "K. M."} +{"name": "Kaya Yağız Can", "order": "FAMILY_FIRST", "given": "Yağız", "middle": "Can", "family": "Kaya", "initials": "Y. C. K."} +{"name": "Kaya, Yağız Can", "given": "Yağız", "middle": "Can", "family": "Kaya", "initials": "Y. C. K."} +{"name": "Kazlauskienė Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Kazlauskienė", "initials": "I. K."} +{"name": "Kazlauskienė, Irena", "given": "Irena", "family": "Kazlauskienė", "initials": "I. K."} +{"name": "Keita Millere", "given": "Keita", "family": "Millere", "initials": "K. M."} +{"name": "Kennedy, John (Jack)", "given": "John", "family": "Kennedy", "nickname": "Jack", "initials": "J. K."} +{"name": "Kenneth Clarke Q.C.", "given": "Kenneth", "family": "Clarke", "suffix": "Q.C.", "initials": "K. C."} +{"name": "Kenneth Clarke Q.C., M.P.", "given": "Kenneth", "family": "Clarke", "suffix": "Q.C., M.P.", "initials": "K. C."} +{"name": "Kenneth Clarke QC MP", "given": "Kenneth", "family": "Clarke", "suffix": "QC MP", "initials": "K. C."} +{"name": "Kete Miler", "given": "Kete", "family": "Miler", "initials": "K. M."} +{"name": "Kete Myuller", "given": "Kete", "family": "Myuller", "initials": "K. M."} +{"name": "Kete Müller", "given": "Kete", "family": "Müller", "initials": "K. M."} +{"name": "Kevin", "given": "Kevin", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "K."} +{"name": "Kevin", "order": "FAMILY_FIRST", "family": "Kevin", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "K."} +{"name": "Kim, Jr.", "family": "Kim", "suffix": "Jr.", "initials": "K."} +{"name": "Kim김민준씨, MA", "family": "Kim김민준", "suffix": "씨, MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "K."} +{"name": "King Curtis", "title": "King", "given": "Curtis", "initials": "C."} +{"name": "King Henry", "title": "King", "given": "Henry", "initials": "H."} +{"name": "King John Alexander V", "title": "King", "given": "John", "family": "Alexander", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. A."} +{"name": "King John V.", "title": "King", "given": "John", "family": "V.", "initials": "J. V."} +{"name": "Kjetil Bjørn Løseth", "given": "Kjetil", "middle": "Bjørn", "family": "Løseth", "initials": "K. B. L."} +{"name": "Kotryna Miliūtė", "given": "Kotryna", "family": "Miliūtė", "initials": "K. M."} +{"name": "Kovač Ana Marija Vera", "order": "FAMILY_FIRST", "given": "Ana", "middle": "Marija Vera", "family": "Kovač", "initials": "A. M. V. K."} +{"name": "Kovač, Ana Marija Vera", "given": "Ana", "middle": "Marija Vera", "family": "Kovač", "initials": "A. M. V. K."} +{"name": "Kováčová Jana", "order": "FAMILY_FIRST", "given": "Jana", "family": "Kováčová", "initials": "J. K."} +{"name": "Kováčová, Jana", "given": "Jana", "family": "Kováčová", "initials": "J. K."} +{"name": "Kowalska Maria Kamila", "order": "FAMILY_FIRST", "given": "Maria", "middle": "Kamila", "family": "Kowalska", "initials": "M. K. K."} +{"name": "Kraja, Majlinda", "given": "Majlinda", "family": "Kraja", "initials": "M. K."} +{"name": "Kraja, Majlinda", "order": "FAMILY_FIRST", "given": "Majlinda", "family": "Kraja", "initials": "M. K."} +{"name": "Kranjčević Silvije Strahimir", "order": "FAMILY_FIRST", "given": "Silvije", "middle": "Strahimir", "family": "Kranjčević", "initials": "S. S. K."} +{"name": "Krawc, Jan Beno", "given": "Jan", "middle": "Beno", "family": "Krawc", "initials": "J. B. K."} +{"name": "Krawc, Jan Beno", "order": "FAMILY_FIRST", "given": "Jan", "middle": "Beno", "family": "Krawc", "initials": "J. B. K."} +{"name": "Kulina, Hana", "given": "Hana", "family": "Kulina", "initials": "H. K."} +{"name": "Kulina, Hana", "order": "FAMILY_FIRST", "given": "Hana", "family": "Kulina", "initials": "H. K."} +{"name": "Käthe Müller", "given": "Käthe", "family": "Müller", "initials": "K. M."} +{"name": "Käthe Müller", "order": "FAMILY_FIRST", "given": "Müller", "family": "Käthe", "initials": "M. K."} +{"name": "LEED AP", "given": "LEED", "family": "AP", "initials": "L. A."} +{"name": "LT. GEN. JOHN A. KENNETH DOE IV", "title": "LT. GEN.", "given": "JOHN", "middle": "A. KENNETH", "family": "DOE", "suffix": "IV", "initials": "J. A. K. D."} +{"name": "La Shawn K. Ford", "given": "La", "middle": "Shawn K.", "family": "Ford", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "L. S. K. F."} +{"name": "La'tanya O'connor", "given": "La'tanya", "family": "O'connor", "initials": "L. O."} +{"name": "Lala Lajpat Rai", "given": "Lala", "middle": "Lajpat", "family": "Rai", "initials": "L. L. R."} +{"name": "Lalit", "given": "Lalit", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Lalit", "order": "FAMILY_FIRST", "family": "Lalit", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Lalit Bharati", "given": "Lalit", "family": "Bharati", "initials": "L. B."} +{"name": "Lalit Mohan Bharati", "given": "Lalit", "middle": "Mohan", "family": "Bharati", "initials": "L. M. B."} +{"name": "Lama Zopa", "title": "Lama", "given": "Zopa", "initials": "Z."} +{"name": "Lamha Yahya Al Mawali", "given": "Lamha", "middle": "Yahya", "family": "Al Mawali", "initials": "L. Y. M."} +{"name": "Larry James Johnson I", "given": "Larry", "middle": "James", "family": "Johnson", "suffix": "I", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "L. J. J."} +{"name": "Larry V I", "given": "Larry", "middle": "V", "family": "I", "initials": "L. V. I."} +{"name": "Lastname, Firstname", "given": "Firstname", "family": "Lastname", "initials": "F. L."} +{"name": "Lehtola-Aalto Marja-Liisa Katariina, kansanedustaja", "order": "FAMILY_FIRST", "given": "kansanedustaja", "family": "Lehtola-Aalto Marja-Liisa Katariina", "initials": "k. L. M. K."} +{"name": "Lehtola-Aalto, Marja-Liisa Katariina", "given": "Marja-Liisa", "middle": "Katariina", "family": "Lehtola-Aalto", "initials": "M. K. L."} +{"name": "Lejnikowa, Hejba-Marja Hana", "given": "Hejba-Marja", "middle": "Hana", "family": "Lejnikowa", "initials": "H. H. L."} +{"name": "Lejnikowa, dr. Hejba-Marja Hana jr. phd", "order": "FAMILY_FIRST", "title": "dr.", "given": "Hejba-Marja", "middle": "Hana", "family": "Lejnikowa", "suffix": "jr. phd", "initials": "H. H. L."} +{"name": "Lemaître, Adélaïde", "given": "Adélaïde", "family": "Lemaître", "initials": "A. L."} +{"name": "Lemaître, Adélaïde", "order": "FAMILY_FIRST", "given": "Adélaïde", "family": "Lemaître", "initials": "A. L."} +{"name": "Lena", "given": "Lena", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Lena", "order": "FAMILY_FIRST", "family": "Lena", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Lianora Sanna", "given": "Lianora", "family": "Sanna", "initials": "L. S."} +{"name": "Lisa Schmidt, MBA", "given": "Lisa", "family": "Schmidt", "suffix": "MBA", "initials": "L. S."} +{"name": "Lloret Palol, Josep Antoni Carles Joan", "given": "Josep", "middle": "Antoni Carles Joan", "family": "Lloret Palol", "initials": "J. A. C. J. L. P."} +{"name": "Lluis Carod i", "given": "Lluis", "family": "Carod", "suffix": "i", "initials": "L. C."} +{"name": "Lola", "given": "Lola", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Lola", "order": "FAMILY_FIRST", "family": "Lola", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Lombardi, Amarildo", "given": "Amarildo", "family": "Lombardi", "initials": "A. L."} +{"name": "Lombardi, Amarildo A.", "order": "FAMILY_FIRST", "given": "Amarildo", "middle": "A.", "family": "Lombardi", "initials": "A. A. L."} +{"name": "Lon (Jr.) Williams", "given": "Lon", "middle": "Jr.", "family": "Williams", "initials": "L. J. W."} +{"name": "Lord Chancellor", "title": "Lord", "family": "Chancellor", "ambiguities": ["TITLE_OR_NAME"], "initials": "C."} +{"name": "Lord God Almighty", "title": "Lord", "given": "God", "family": "Almighty", "initials": "G. A."} +{"name": "Lord and of the Universe", "title": "Lord and of the Universe"} +{"name": "Lord of the Universe", "title": "Lord of the Universe"} +{"name": "Lord of the Universe and Associate Supreme Queen of the World Lisa Simpson", "title": "Lord of the Universe and Associate Supreme Queen of the World", "given": "Lisa", "family": "Simpson", "initials": "L. S."} +{"name": "Lord of the Universe and Supreme King of the World Lisa Simpson", "title": "Lord of the Universe and Supreme King of the World", "given": "Lisa", "family": "Simpson", "initials": "L. S."} +{"name": "Louhi", "given": "Louhi", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Louhi", "order": "FAMILY_FIRST", "family": "Louhi", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "L."} +{"name": "Louise Péricourt", "given": "Louise", "family": "Péricourt", "initials": "L. P."} +{"name": "Lt. Gen. John A. Kenneth Doe IV", "title": "Lt. Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "IV", "initials": "J. A. K. D."} +{"name": "Lt. Gen. John A. Kenneth Doe, Jr.", "title": "Lt. Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} +{"name": "Lt.Gen. John A. Kenneth Doe IV", "title": "Lt.Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "IV", "initials": "J. A. K. D."} +{"name": "Lt.Gov. John Doe", "title": "Lt.Gov.", "given": "John", "family": "Doe", "initials": "J. D."} +{"name": "Lt.Gov. juan e garcia", "title": "Lt.Gov.", "given": "juan", "middle": "e", "family": "garcia", "initials": "j. e. g."} +{"name": "Luca Sarolta Éva Erzsébet von Kovács M.D. Ph.D.", "given": "Luca", "middle": "Sarolta Éva Erzsébet", "family": "von Kovács", "suffix": "M.D. Ph.D.", "initials": "L. S. É. E. K."} +{"name": "Lucía Pérez", "given": "Lucía", "family": "Pérez", "initials": "L. P."} +{"name": "Ludwig van Beethoven", "given": "Ludwig", "family": "van Beethoven", "initials": "L. B."} +{"name": "Løseth, Kjetil Bjørn", "given": "Kjetil", "middle": "Bjørn", "family": "Løseth", "initials": "K. B. L."} +{"name": "Løseth, Kjetil Bjørn", "order": "FAMILY_FIRST", "given": "Kjetil", "middle": "Bjørn", "family": "Løseth", "initials": "K. B. L."} +{"name": "M. Jean-Nicolas Louis Marcel de Bouchart fils", "given": "M.", "middle": "Jean-Nicolas Louis Marcel", "family": "de Bouchart fils", "initials": "M. J. L. M. B. f."} +{"name": "M. de Bouchart Jean-Nicolas Louis Marcel fils", "order": "FAMILY_FIRST", "given": "de Bouchart Jean-Nicolas Louis Marcel fils", "family": "M.", "initials": "d. B. J. L. M. f. M."} +{"name": "MANƉELA Rolanɖ Alfɔns", "order": "FAMILY_FIRST", "given": "Rolanɖ", "middle": "Alfɔns", "family": "MANƉELA", "initials": "R. A. M."} +{"name": "MD - DO - DDS", "title": "MD", "given": "-", "family": "DO -", "suffix": "DDS", "initials": "-. -."} +{"name": "MD Chirurgie dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans Jr.", "order": "FAMILY_FIRST", "title": "MD", "given": "dentară", "middle": "von der Ruhr Prof. dr. Eva-Eleonora Karl Hans", "family": "Chirurgie", "suffix": "Jr.", "initials": "d. R. P. d. E. K. H. C."} +{"name": "MD DDS", "title": "MD", "family": "DDS", "initials": "D."} +{"name": "MD DDS Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa", "order": "FAMILY_FIRST", "title": "MD", "given": "Dut.ra", "middle": "Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa", "family": "DDS", "initials": "D. P. B. G. D. M. G. J. T. D."} +{"name": "MD DDS von Brühl César Martín Ada Cornelia", "order": "FAMILY_FIRST", "title": "MD", "given": "von Brühl César Martín Ada Cornelia", "family": "DDS", "initials": "v. B. C. M. A. C. D."} +{"name": "MD, DO, DDS", "given": "DO", "family": "MD", "suffix": "DDS", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D. M."} +{"name": "MD, PHD", "family": "MD", "suffix": "PHD", "initials": "M."} +{"name": "MP Pēteris Kārlis Ozoliņš", "given": "MP", "middle": "Pēteris Kārlis", "family": "Ozoliņš", "initials": "M. P. K. O."} +{"name": "MSc Dr. med. univ.", "given": "MSc", "middle": "Dr. med.", "family": "univ.", "initials": "M. D. m. u."} +{"name": "Ma III, Jack Jr", "given": "Jack", "family": "Ma", "suffix": "III, Jr", "initials": "J. M."} +{"name": "Ma, Andrew", "given": "Andrew", "family": "Ma", "initials": "A. M."} +{"name": "Ma, Jack", "given": "Jack", "family": "Ma", "initials": "J. M."} +{"name": "Maazị Christopher Chukwudike Amadi MP", "given": "Maazị", "middle": "Christopher Chukwudike", "family": "Amadi", "suffix": "MP", "initials": "M. C. C. A."} +{"name": "Mac Miller", "given": "Mac", "family": "Miller", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "M. M."} +{"name": "Mac a’ Ghobhainn Chorùna, Mgr. Dòmhnall Iain Bàn am post", "order": "FAMILY_FIRST", "title": "Mgr.", "given": "Dòmhnall", "middle": "Iain Bàn", "family": "Mac a’ Ghobhainn Chorùna", "suffix": "am post", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D. I. B. a. G. C."} +{"name": "Mac a’ Ghobhainn, Dòmhnall Iain Bàn", "given": "Dòmhnall", "middle": "Iain Bàn", "family": "Mac a’ Ghobhainn", "initials": "D. I. B. a. G."} +{"name": "Machová, Alexandra Zuzana (paní, Ph.D.)", "given": "Alexandra", "middle": "Zuzana paní", "family": "Machová", "suffix": "Ph.D.", "initials": "A. Z. p. M."} +{"name": "Macáková, Alexandra Zuzana (pani, PhD)", "given": "Alexandra", "middle": "Zuzana", "family": "Macáková", "nickname": "pani, PhD", "initials": "A. Z. M."} +{"name": "Mag-Judge Harwell G Davis, III", "title": "Mag-Judge", "given": "Harwell", "middle": "G", "family": "Davis", "suffix": "III", "initials": "H. G. D."} +{"name": "Mag. Judge Byron G. Cudmore", "title": "Mag. Judge", "given": "Byron", "middle": "G.", "family": "Cudmore", "initials": "B. G. C."} +{"name": "Magistrate Judge John F. Forster, Jr", "title": "Magistrate Judge", "given": "John", "middle": "F.", "family": "Forster", "suffix": "Jr", "initials": "J. F. F."} +{"name": "Magistrate-Judge Elizabeth Todd Campbell", "title": "Magistrate-Judge", "given": "Elizabeth", "middle": "Todd", "family": "Campbell", "initials": "E. T. C."} +{"name": "Maheen Farooqi, ACA", "given": "Maheen", "family": "Farooqi", "suffix": "ACA", "initials": "M. F."} +{"name": "Mai Le née Nguyen", "given": "Mai", "family": "Le", "maiden": "Nguyen", "initials": "M. L."} +{"name": "Maid Marion", "title": "Maid", "given": "Marion", "initials": "M."} +{"name": "Maider", "given": "Maider", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Maider", "order": "FAMILY_FIRST", "family": "Maider", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Maier, Amy I, Jr.", "given": "Amy", "middle": "I", "family": "Maier", "suffix": "Jr.", "initials": "A. I. M."} +{"name": "Maier, Amy Lauren I", "given": "Amy", "middle": "Lauren", "family": "Maier", "suffix": "I", "initials": "A. L. M."} +{"name": "Majlinda Kraja", "given": "Majlinda", "family": "Kraja", "initials": "M. K."} +{"name": "Major Harris", "given": "Major", "family": "Harris", "initials": "M. H."} +{"name": "Major. Dona Smith", "title": "Major.", "given": "Dona", "family": "Smith", "initials": "D. S."} +{"name": "Major. John Smith, Jr.", "title": "Major.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "Malam Shehu Bello Modibbo MP", "given": "Malam", "middle": "Shehu Bello", "family": "Modibbo", "suffix": "MP", "initials": "M. S. B. M."} +{"name": "Mané Katy", "order": "FAMILY_FIRST", "given": "Katy", "family": "Mané", "initials": "K. M."} +{"name": "Marco Antonio Flores", "given": "Marco", "middle": "Antonio", "family": "Flores", "initials": "M. A. F."} +{"name": "Mari", "given": "Mari", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mari", "order": "FAMILY_FIRST", "family": "Mari", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mari shu Halimatu Waly", "given": "Mari", "middle": "shu Halimatu", "family": "Waly", "initials": "M. s. H. W."} +{"name": "Mari' Aube'", "given": "Mari'", "family": "Aube'", "initials": "M. A."} +{"name": "Mari-Ann Henri Roobert Tammik, parlamendiliige", "given": "parlamendiliige", "family": "Mari-Ann Henri Roobert Tammik", "initials": "p. M. H. R. T."} +{"name": "Maria", "given": "Maria", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Maria", "order": "FAMILY_FIRST", "family": "Maria", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Maria Hasani", "given": "Maria", "family": "Hasani", "initials": "M. H."} +{"name": "Maria Helena Fernandes", "given": "Maria", "middle": "Helena", "family": "Fernandes", "initials": "M. H. F."} +{"name": "Maria Kowalska", "given": "Maria", "family": "Kowalska", "initials": "M. K."} +{"name": "Maria Kowalska (z domu Nowak)", "given": "Maria", "family": "Kowalska", "maiden": "Nowak", "initials": "M. K."} +{"name": "Maria Kowalska (z domu)", "given": "Maria", "family": "Kowalska", "nickname": "z domu", "initials": "M. K."} +{"name": "Maria Kowalska (z, domu Nowak)", "given": "Maria", "family": "Kowalska", "nickname": "z, domu Nowak", "initials": "M. K."} +{"name": "Maria Kowalska z domu Nowak", "given": "Maria", "family": "Kowalska", "maiden": "Nowak", "initials": "M. K."} +{"name": "Maria Kowalska z domu Nowak MA", "given": "Maria", "family": "Kowalska", "suffix": "MA", "maiden": "Nowak", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. K."} +{"name": "Maria Luisa y de la Cruz", "given": "Maria", "middle": "Luisa y de", "family": "la Cruz", "initials": "M. L. C."} +{"name": "Maria López née García Pérez", "given": "Maria", "family": "López", "maiden": "García Pérez", "initials": "M. L."} +{"name": "Maria Popescu", "given": "Maria", "family": "Popescu", "initials": "M. P."} +{"name": "Maria Silva", "given": "Maria", "family": "Silva", "initials": "M. S."} +{"name": "Maria Wijaya", "given": "Maria", "family": "Wijaya", "initials": "M. W."} +{"name": "Mariam Geedi Hamish Hussein", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} +{"name": "Marie-Agnès Suzanne Gilot", "given": "Marie-Agnès", "middle": "Suzanne", "family": "Gilot", "initials": "M. S. G."} +{"name": "Marijana Tomislav Babić", "given": "Marijana", "middle": "Tomislav", "family": "Babić", "initials": "M. T. B."} +{"name": "Mario", "given": "Mario", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mario", "order": "FAMILY_FIRST", "family": "Mario", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Marić, Branko Hrvoje", "given": "Branko", "middle": "Hrvoje", "family": "Marić", "initials": "B. H. M."} +{"name": "Marić, dr. sc. Branko Hrvoje mlađi, prof.", "order": "FAMILY_FIRST", "title": "dr. sc. prof.", "given": "Branko", "middle": "Hrvoje mlađi", "family": "Marić", "initials": "B. H. m. M."} +{"name": "Marja-Liisa Katariina Lehtola-Aalto kansanedustaja", "given": "Marja-Liisa", "middle": "Katariina Lehtola-Aalto", "family": "kansanedustaja", "initials": "M. K. L. k."} +{"name": "Mark", "given": "Mark", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mark", "order": "FAMILY_FIRST", "family": "Mark", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Marković Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Marković", "initials": "I. M."} +{"name": "Marković, Irena", "given": "Irena", "family": "Marković", "initials": "I. M."} +{"name": "Marquess of Bath", "title": "Marquess of Bath"} +{"name": "Martine Sodersen", "given": "Martine", "family": "Sodersen", "initials": "M. S."} +{"name": "Martini, Fabio Massimo Carlo Alberto", "given": "Fabio", "middle": "Massimo Carlo Alberto", "family": "Martini", "initials": "F. M. C. A. M."} +{"name": "Mary Ann H", "given": "Mary", "middle": "Ann", "family": "H", "initials": "M. A. H."} +{"name": "Mary Ann H Luther", "given": "Mary", "middle": "Ann H", "family": "Luther", "initials": "M. A. H. L."} +{"name": "Mary Ann H,Luther", "given": "Luther", "family": "Mary Ann H", "initials": "L. M. A. H."} +{"name": "Mary Beth Smith", "given": "Mary", "middle": "Beth", "family": "Smith", "initials": "M. B. S."} +{"name": "Mary Jane King", "given": "Mary", "middle": "Jane", "family": "King", "initials": "M. J. K."} +{"name": "Mary Jane King Smith", "given": "Mary", "middle": "Jane King", "family": "Smith", "initials": "M. J. K. S."} +{"name": "Mary Jane King.", "title": "King.", "given": "Mary", "family": "Jane", "initials": "M. J."} +{"name": "Mary Johnson", "given": "Mary", "family": "Johnson", "initials": "M. J."} +{"name": "Mary Nicet", "given": "Mary", "family": "Nicet", "initials": "M. N."} +{"name": "Mary Smith née Jones Prof.", "title": "Prof.", "given": "Mary", "family": "Smith", "maiden": "Jones", "initials": "M. S."} +{"name": "Mary Sue Hamish Watson", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} +{"name": "Masalu Adam Juma Agape Shukurani MB", "order": "FAMILY_FIRST", "given": "Adam", "middle": "Juma Agape Shukurani MB", "family": "Masalu", "initials": "A. J. A. S. M. M."} +{"name": "Masalu, Adam Juma Agape Shukurani", "given": "Adam", "middle": "Juma Agape Shukurani", "family": "Masalu", "initials": "A. J. A. S. M."} +{"name": "Matthew Matthew", "given": "Matthew", "family": "Matthew", "initials": "M. M."} +{"name": "Maura Cellario-Pingor, PHR", "given": "Maura", "family": "Cellario-Pingor", "suffix": "PHR", "initials": "M. C."} +{"name": "Max Ben Mustermann", "given": "Max", "middle": "Ben", "family": "Mustermann", "initials": "M. B. M."} +{"name": "Max Born", "given": "Max", "family": "Born", "initials": "M. B."} +{"name": "Mc Donald", "family": "Mc Donald", "initials": "D."} +{"name": "Md Abdul Karim", "title": "Md", "given": "Abdul", "family": "Karim", "initials": "A. K."} +{"name": "Md,, Phd", "family": "Md", "suffix": "Phd", "initials": "M."} +{"name": "Md. Abdul Karim", "title": "Md.", "given": "Abdul", "family": "Karim", "initials": "A. K."} +{"name": "Menada Fonda", "given": "Menada", "family": "Fonda", "initials": "M. F."} +{"name": "Meretgeldi Durdyýewiç Gurbanow", "given": "Meretgeldi", "middle": "Durdyýewiç", "family": "Gurbanow", "initials": "M. D. G."} +{"name": "Mesnil Garcia Juan", "given": "Mesnil", "middle": "Garcia", "family": "Juan", "initials": "M. G. J."} +{"name": "Mesnil Garcia de", "given": "Mesnil", "middle": "Garcia", "family": "de", "initials": "M. G. d."} +{"name": "Mesnil Garcia van", "given": "Mesnil", "middle": "Garcia", "family": "van", "initials": "M. G. v."} +{"name": "Mesnil de", "given": "Mesnil", "family": "de", "initials": "M. d."} +{"name": "Mevrouw Anna de Vries", "title": "Mevrouw", "given": "Anna", "family": "de Vries", "initials": "A. V."} +{"name": "Meyer Benno Hans", "order": "FAMILY_FIRST", "given": "Benno", "middle": "Hans", "family": "Meyer", "initials": "B. H. M."} +{"name": "Meyer, Benno Hans", "given": "Benno", "middle": "Hans", "family": "Meyer", "initials": "B. H. M."} +{"name": "Mgr. Dòmhnall Iain Bàn Mac a’ Ghobhainn Chorùna am post", "title": "Mgr.", "given": "Dòmhnall", "middle": "Iain Bàn", "family": "Mac a’ Ghobhainn Chorùna", "suffix": "am post", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "D. I. B. a. G. C."} +{"name": "Mgr. Ing. Anna Karolína Eva Kristína von Kant Nováková Predná, CSc.", "title": "Mgr. Ing.", "given": "Anna", "middle": "Karolína Eva Kristína", "family": "von Kant Nováková Predná", "suffix": "CSc.", "initials": "A. K. E. K. K. N. P."} +{"name": "Mgr. Ing. von Kant Nováková Predná Anna Karolína Eva Kristína, CSc.", "order": "FAMILY_FIRST", "title": "Mgr. Ing.", "given": "Kant", "middle": "Nováková Predná Anna Karolína Eva Kristína", "family": "von", "suffix": "CSc.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "K. N. P. A. K. E. K. v."} +{"name": "Mike van der Velt", "given": "Mike", "family": "van der Velt", "initials": "M. V."} +{"name": "Milenković dr Slađana Vera mlađi, dipl. inž.", "order": "FAMILY_FIRST", "title": "dipl. inž.", "given": "dr", "middle": "Slađana Vera mlađi", "family": "Milenković", "initials": "d. S. V. m. M."} +{"name": "Milenković, Slađana Vera", "given": "Slađana", "middle": "Vera", "family": "Milenković", "initials": "S. V. M."} +{"name": "Miler Kete", "order": "FAMILY_FIRST", "given": "Kete", "family": "Miler", "initials": "K. M."} +{"name": "Miler, Kete", "given": "Kete", "family": "Miler", "initials": "K. M."} +{"name": "Miliūtė Kotryna", "order": "FAMILY_FIRST", "given": "Kotryna", "family": "Miliūtė", "initials": "K. M."} +{"name": "Miliūtė, Kotryna", "given": "Kotryna", "family": "Miliūtė", "initials": "K. M."} +{"name": "Miller Kate", "order": "FAMILY_FIRST", "given": "Kate", "family": "Miller", "initials": "K. M."} +{"name": "Miller, Kate", "given": "Kate", "family": "Miller", "initials": "K. M."} +{"name": "Millere Keita", "order": "FAMILY_FIRST", "given": "Keita", "family": "Millere", "initials": "K. M."} +{"name": "Millers Pīters", "order": "FAMILY_FIRST", "given": "Pīters", "family": "Millers", "initials": "P. M."} +{"name": "Millers Pīters Džims", "order": "FAMILY_FIRST", "given": "Pīters", "middle": "Džims", "family": "Millers", "initials": "P. D. M."} +{"name": "Mina", "given": "Mina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mina", "order": "FAMILY_FIRST", "family": "Mina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Minjun KIM", "given": "Minjun", "family": "KIM", "initials": "M. K."} +{"name": "Miret", "given": "Miret", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Miret", "order": "FAMILY_FIRST", "family": "Miret", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mkwawa John", "order": "FAMILY_FIRST", "given": "John", "family": "Mkwawa", "initials": "J. M."} +{"name": "Mkwawa, John", "given": "John", "family": "Mkwawa", "initials": "J. M."} +{"name": "Modibbo Malam Shehu Bello MP", "order": "FAMILY_FIRST", "given": "Malam", "middle": "Shehu Bello", "family": "Modibbo", "suffix": "MP", "initials": "M. S. B. M."} +{"name": "Modibbo, Shehu Bello", "title": "Shehu", "given": "Bello", "family": "Modibbo", "initials": "B. M."} +{"name": "Mohamad Ahmad Ali Hassan", "given": "Mohamad", "middle": "Ahmad Ali", "family": "Hassan", "initials": "M. A. A. H."} +{"name": "Mohamad Ali", "given": "Mohamad", "family": "Ali", "initials": "M. A."} +{"name": "Mohamad Ali Khalil", "given": "Mohamad", "middle": "Ali", "family": "Khalil", "initials": "M. A. K."} +{"name": "Mohamad X", "given": "Mohamad", "suffix": "X", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "initials": "M."} +{"name": "Mohamad X Surname", "given": "Mohamad", "middle": "X", "family": "Surname", "initials": "M. X. S."} +{"name": "Mohamad Zein El Dine", "given": "Mohamad", "middle": "Zein El", "family": "Dine", "initials": "M. Z. E. D."} +{"name": "Mohamad Zeineddine", "given": "Mohamad", "family": "Zeineddine", "initials": "M. Z."} +{"name": "Mohamed Ali Abd Allah", "given": "Mohamed", "middle": "Ali Abd", "family": "Allah", "initials": "M. A. A. A."} +{"name": "Monsieur Jean Dupont", "title": "Monsieur", "given": "Jean", "family": "Dupont", "initials": "J. D."} +{"name": "Morse, Det. Insp. Jane", "title": "Det. Insp.", "given": "Jane", "family": "Morse", "initials": "J. M."} +{"name": "Mozart Wolfgang Amadeus", "order": "FAMILY_FIRST", "given": "Wolfgang", "middle": "Amadeus", "family": "Mozart", "initials": "W. A. M."} +{"name": "Mozart, Wolfgang Amadeus", "given": "Wolfgang", "middle": "Amadeus", "family": "Mozart", "initials": "W. A. M."} +{"name": "Mr MA", "title": "Mr", "family": "MA", "initials": "M."} +{"name": "Mr XXX", "title": "Mr", "family": "XXX", "initials": "X."} +{"name": "Mr. & Mrs. John Smith", "title": "Mr. & Mrs.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Mr. Bertram Wilberforce Henry Robert Wooster Jr, MP", "title": "Mr.", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "Jr, MP", "initials": "B. W. H. R. W."} +{"name": "Mr. Bertram Wilberforce Henry Robert Wooster MP", "title": "Mr.", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} +{"name": "Mr. Francisco Xavier Joao Anton de Souza e Menezes Dk, MP", "title": "Mr.", "given": "Francisco", "middle": "Xavier Joao Anton", "family": "de Souza e Menezes Dk", "suffix": "MP", "initials": "F. X. J. A. S. M. D."} +{"name": "Mr. Jack Jill", "title": "Mr.", "given": "Jack", "family": "Jill", "initials": "J. J."} +{"name": "Mr. Jack and Jill", "title": "Mr.", "family": "Jack and Jill", "initials": "J. J."} +{"name": "Mr. Johnson", "title": "Mr.", "family": "Johnson", "initials": "J."} +{"name": "Mr. Van Nguyen", "title": "Mr.", "given": "Van", "family": "Nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. N."} +{"name": "Mr. and Mrs. John Smith", "title": "Mr. and Mrs.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Mr. and Mrs. John and Jane Smith", "title": "Mr. and Mrs.", "given": "John and Jane", "family": "Smith", "initials": "J. J. S."} +{"name": "Mrs. Garcia", "title": "Mrs.", "family": "Garcia", "initials": "G."} +{"name": "Ms A.J. Smith", "title": "Ms", "given": "A.J.", "family": "Smith", "initials": "A. S."} +{"name": "Ms Hon Solo", "title": "Ms Hon", "family": "Solo", "initials": "S."} +{"name": "Ms. Rebecca Teichman", "title": "Ms.", "given": "Rebecca", "family": "Teichman", "initials": "R. T."} +{"name": "Mudane Bertram Wilberforce Henry Robert Wooster MP", "given": "Mudane", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "M. B. W. H. R. W."} +{"name": "Mueller Kathy", "order": "FAMILY_FIRST", "given": "Kathy", "family": "Mueller", "initials": "K. M."} +{"name": "Mueller, Kathy", "given": "Kathy", "family": "Mueller", "initials": "K. M."} +{"name": "Muhammad Yusuf Qodirov", "given": "Muhammad", "middle": "Yusuf", "family": "Qodirov", "initials": "M. Y. Q."} +{"name": "Muhammed Shehu", "order": "FAMILY_FIRST", "given": "Shehu", "family": "Muhammed", "initials": "S. M."} +{"name": "Muhammed, Shehu", "title": "Shehu", "family": "Muhammed", "initials": "M."} +{"name": "Mujanović Razija", "order": "FAMILY_FIRST", "given": "Razija", "family": "Mujanović", "initials": "R. M."} +{"name": "Muller Kathe", "order": "FAMILY_FIRST", "given": "Kathe", "family": "Muller", "initials": "K. M."} +{"name": "Muller Kathi", "order": "FAMILY_FIRST", "given": "Kathi", "family": "Muller", "initials": "K. M."} +{"name": "Muller, Kathe", "given": "Kathe", "family": "Muller", "initials": "K. M."} +{"name": "Muller, Kathe", "order": "FAMILY_FIRST", "given": "Kathe", "family": "Muller", "initials": "K. M."} +{"name": "Muller, Kathi", "given": "Kathi", "family": "Muller", "initials": "K. M."} +{"name": "Muniiru", "given": "Muniiru", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Muniiru", "order": "FAMILY_FIRST", "family": "Muniiru", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "M."} +{"name": "Mustermann, Max Ben", "given": "Max", "middle": "Ben", "family": "Mustermann", "initials": "M. B. M."} +{"name": "Mustermann, Max Ben", "order": "FAMILY_FIRST", "given": "Max", "middle": "Ben", "family": "Mustermann", "initials": "M. B. M."} +{"name": "Mx A.J. Smith", "title": "Mx", "given": "A.J.", "family": "Smith", "initials": "A. S."} +{"name": "Myuller Kete", "order": "FAMILY_FIRST", "given": "Kete", "family": "Myuller", "initials": "K. M."} +{"name": "Myuller, Kete", "given": "Kete", "family": "Myuller", "initials": "K. M."} +{"name": "MÜLLER WEIß, HANS", "given": "HANS", "family": "MÜLLER WEIß", "initials": "H. M. W."} +{"name": "Mátrai Dóra", "order": "FAMILY_FIRST", "given": "Dóra", "family": "Mátrai", "initials": "D. M."} +{"name": "Mátrai, Dóra", "given": "Dóra", "family": "Mátrai", "initials": "D. M."} +{"name": "Máté Bendegúz Szabados", "given": "Máté", "middle": "Bendegúz", "family": "Szabados", "initials": "M. B. S."} +{"name": "Ménil Christophe de", "given": "Ménil", "middle": "Christophe", "family": "de", "initials": "M. C. d."} +{"name": "Ménil de", "given": "Ménil", "family": "de", "initials": "M. d."} +{"name": "Møller, Hans Henrik Sven Åge", "given": "Hans", "middle": "Henrik Sven Åge", "family": "Møller", "initials": "H. H. S. Å. M."} +{"name": "Møller, Irene", "given": "Irene", "family": "Møller", "initials": "I. M."} +{"name": "Møller, Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Møller", "initials": "I. M."} +{"name": "Møller, hr. Hans Henrik Sven Åge, MF", "order": "FAMILY_FIRST", "title": "hr.", "given": "Hans", "middle": "Henrik Sven Åge", "family": "Møller", "suffix": "MF", "ambiguities": ["COMMA_STRUCTURE"], "initials": "H. H. S. Å. M."} +{"name": "Müller Katryn", "order": "FAMILY_FIRST", "given": "Katryn", "family": "Müller", "initials": "K. M."} +{"name": "Müller Kete", "order": "FAMILY_FIRST", "given": "Kete", "family": "Müller", "initials": "K. M."} +{"name": "Müller Käthe", "order": "FAMILY_FIRST", "given": "Käthe", "family": "Müller", "initials": "K. M."} +{"name": "Müller geb.", "given": "Müller", "family": "geb.", "initials": "M. g."} +{"name": "Müller, Katryn", "given": "Katryn", "family": "Müller", "initials": "K. M."} +{"name": "Müller, Kete", "given": "Kete", "family": "Müller", "initials": "K. M."} +{"name": "Müller, Käthe", "given": "Käthe", "family": "Müller", "initials": "K. M."} +{"name": "Müller, Käthe", "order": "FAMILY_FIRST", "given": "Käthe", "family": "Müller", "initials": "K. M."} +{"name": "NASCIMENTO, EDSON ARANTES DO", "given": "EDSON", "middle": "ARANTES", "family": "DO NASCIMENTO", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "E. A. N."} +{"name": "Naomi Wambui Ng'ang'a", "given": "Naomi", "middle": "Wambui", "family": "Ng'ang'a", "initials": "N. W. N."} +{"name": "Nascimento, Edson Arantes do", "given": "Edson", "middle": "Arantes", "family": "do Nascimento", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "E. A. N."} +{"name": "Nguyen Thi Van", "given": "Nguyen", "middle": "Thi", "family": "Van", "initials": "N. T. V."} +{"name": "Nguyen Van Lac", "given": "Nguyen", "family": "Van Lac", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "N. L."} +{"name": "Nguyen, Thi Van", "given": "Thi", "family": "Van Nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "T. N."} +{"name": "Nguyen, Van", "given": "Van", "family": "Nguyen", "initials": "V. N."} +{"name": "Nguyen, Van Le", "given": "Van", "middle": "Le", "family": "Nguyen", "initials": "V. L. N."} +{"name": "Nguyễn Anh", "order": "FAMILY_FIRST", "given": "Anh", "family": "Nguyễn", "initials": "A. N."} +{"name": "Nguyễn Thị Minh Khai", "given": "Nguyễn", "middle": "Thị Minh", "family": "Khai", "initials": "N. T. M. K."} +{"name": "Nguyễn Văn Anh", "order": "FAMILY_FIRST", "given": "Văn", "middle": "Anh", "family": "Nguyễn", "initials": "V. A. N."} +{"name": "Nguyễn, Thị Vân", "given": "Thị", "middle": "Vân", "family": "Nguyễn", "initials": "T. V. N."} +{"name": "Nilsen Anne Berit Bernhard", "order": "FAMILY_FIRST", "given": "Anne", "middle": "Berit Bernhard", "family": "Nilsen", "initials": "A. B. B. N."} +{"name": "Nilsen, Anne Berit Bernhard", "given": "Anne", "middle": "Berit Bernhard", "family": "Nilsen", "initials": "A. B. B. N."} +{"name": "No1. John Smith", "given": "No1.", "middle": "John", "family": "Smith", "initials": "N. J. S."} +{"name": "None John None Doe None (None)", "given": "None", "middle": "John None Doe", "family": "None", "nickname": "None", "initials": "N. J. N. D. N."} +{"name": "None Smith", "given": "None", "family": "Smith", "initials": "N. S."} +{"name": "Nonez Smith", "given": "Nonez", "family": "Smith", "initials": "N. S."} +{"name": "Novak, Janez Peter", "given": "Janez", "middle": "Peter", "family": "Novak", "initials": "J. P. N."} +{"name": "Nováková Jana", "order": "FAMILY_FIRST", "given": "Jana", "family": "Nováková", "initials": "J. N."} +{"name": "Nováková, Jana", "given": "Jana", "family": "Nováková", "initials": "J. N."} +{"name": "Nowak Ewa", "order": "FAMILY_FIRST", "given": "Ewa", "family": "Nowak", "initials": "E. N."} +{"name": "Nowak Tomasz Jan", "order": "FAMILY_FIRST", "given": "Tomasz", "middle": "Jan", "family": "Nowak", "initials": "T. J. N."} +{"name": "Noémie Katerina Yannaki", "given": "Noémie", "middle": "Katerina", "family": "Yannaki", "initials": "N. K. Y."} +{"name": "O'B. John Smith", "given": "O'B.", "middle": "John", "family": "Smith", "initials": "O. J. S."} +{"name": "Obidike Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Obidike", "initials": "I. O."} +{"name": "Obidike, Irene", "given": "Irene", "family": "Obidike", "initials": "I. O."} +{"name": "Oighrig Dhòmhnallach", "given": "Oighrig", "family": "Dhòmhnallach", "initials": "O. D."} +{"name": "Okeke Tunde Seun", "order": "FAMILY_FIRST", "given": "Tunde", "middle": "Seun", "family": "Okeke", "initials": "T. S. O."} +{"name": "Okeke, Tunde Seun", "given": "Tunde", "middle": "Seun", "family": "Okeke", "initials": "T. S. O."} +{"name": "Olabisi Adeboye", "given": "Olabisi", "family": "Adeboye", "initials": "O. A."} +{"name": "Orel Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Orel", "initials": "I. O."} +{"name": "Orel, Irena", "given": "Irena", "family": "Orel", "initials": "I. O."} +{"name": "Oren ben Horin", "given": "Oren", "middle": "ben", "family": "Horin", "initials": "O. b. H."} +{"name": "Ortega y Gasset, Dr.", "title": "Dr.", "given": "Ortega", "middle": "y", "family": "Gasset", "initials": "O. y. G."} +{"name": "Ortega y Gasset, Ed", "given": "Ortega", "middle": "y", "family": "Gasset", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "O. y. G."} +{"name": "Owura Bertram Wilberforce Henry Robert Wooster MP", "given": "Owura", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "O. B. W. H. R. W."} +{"name": "Ozoliņš Pēteris", "order": "FAMILY_FIRST", "given": "Pēteris", "family": "Ozoliņš", "initials": "P. O."} +{"name": "Ozoliņš Pēteris Kārlis", "order": "FAMILY_FIRST", "given": "Pēteris", "middle": "Kārlis", "family": "Ozoliņš", "initials": "P. K. O."} +{"name": "P. V.", "given": "P.", "family": "V.", "initials": "P. V."} +{"name": "Pablo Pérez Miguel Ángel Juan Antonio II", "order": "FAMILY_FIRST", "given": "Pérez", "middle": "Miguel Ángel Juan Antonio", "family": "Pablo", "suffix": "II", "initials": "P. M. Á. J. A. P."} +{"name": "Pablo Pérez, Sr. Miguel Ángel Juan Antonio", "title": "Sr.", "given": "Miguel", "middle": "Ángel Juan Antonio", "family": "Pablo Pérez", "initials": "M. Á. J. A. P. P."} +{"name": "Pan Nowak Tomasz Jan", "order": "FAMILY_FIRST", "given": "Nowak", "middle": "Tomasz Jan", "family": "Pan", "initials": "N. T. J. P."} +{"name": "Pan Tomasz Nowak", "given": "Pan", "middle": "Tomasz", "family": "Nowak", "initials": "P. T. N."} +{"name": "Pedro", "given": "Pedro", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Pedro", "order": "FAMILY_FIRST", "family": "Pedro", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Penkauskienė Sofija Rita", "order": "FAMILY_FIRST", "given": "Sofija", "middle": "Rita", "family": "Penkauskienė", "initials": "S. R. P."} +{"name": "Penkauskienė, Sofija Rita", "given": "Sofija", "middle": "Rita", "family": "Penkauskienė", "initials": "S. R. P."} +{"name": "Peter", "given": "Peter", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Peter", "order": "FAMILY_FIRST", "family": "Peter", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Petraitytė, Gabrielė Aldona", "given": "Gabrielė", "middle": "Aldona", "family": "Petraitytė", "initials": "G. A. P."} +{"name": "Petrauskas Jonas Lukas", "order": "FAMILY_FIRST", "given": "Jonas", "middle": "Lukas", "family": "Petrauskas", "initials": "J. L. P."} +{"name": "Petrauskas, Jonas Lukas", "given": "Jonas", "middle": "Lukas", "family": "Petrauskas", "initials": "J. L. P."} +{"name": "Ph. D.", "title": "Ph.", "family": "D.", "initials": "D."} +{"name": "Ph. D. John Smith", "title": "Ph.", "given": "D.", "middle": "John", "family": "Smith", "initials": "D. J. S."} +{"name": "Ph. D. Van Johnson", "title": "Ph.", "given": "D.", "family": "Van Johnson", "initials": "D. J."} +{"name": "Ph. D., John", "given": "John", "family": "Ph. D.", "initials": "J. P. D.", "facade": {"initials": "J. P D."}} +{"name": "Ph. D., Jr.", "family": "Ph. D.", "suffix": "Jr.", "initials": "P. D.", "facade": {"initials": "P D."}} +{"name": "Ph. D., Prof. Dr. Alejandro Manuel César Mamani de la Cruz Jr.", "title": "Prof. Dr.", "given": "Alejandro", "middle": "Manuel César Mamani de la Cruz", "family": "Ph. D.", "suffix": "Jr.", "initials": "A. M. C. M. C. P. D.", "facade": {"initials": "A. M. C. M. C. P D."}} +{"name": "Ph. D., Prof. Dr. de la Cruz Alejandro Manuel César Mamani Jr.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "family": "Ph. D. de la Cruz Alejandro Manuel César Mamani", "suffix": "Jr.", "initials": "P. D. C. A. M. C. M."} +{"name": "Piet", "given": "Piet", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Piet", "order": "FAMILY_FIRST", "family": "Piet", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Piras Giuanne Marcu", "order": "FAMILY_FIRST", "given": "Giuanne", "middle": "Marcu", "family": "Piras", "initials": "G. M. P."} +{"name": "Piras, Giuanne Marcu", "given": "Giuanne", "middle": "Marcu", "family": "Piras", "initials": "G. M. P."} +{"name": "Planta, Margaritta Marionna Maria de", "given": "Margaritta", "middle": "Marionna Maria", "family": "de Planta", "initials": "M. M. M. P."} +{"name": "Popa Alexandru Ioan", "order": "FAMILY_FIRST", "given": "Alexandru", "middle": "Ioan", "family": "Popa", "initials": "A. I. P."} +{"name": "Popa, Alexandru Ioan", "given": "Alexandru", "middle": "Ioan", "family": "Popa", "initials": "A. I. P."} +{"name": "Pope Francis", "title": "Pope", "given": "Francis", "initials": "F."} +{"name": "Popescu Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Popescu", "initials": "M. P."} +{"name": "Popescu, Maria", "given": "Maria", "family": "Popescu", "initials": "M. P."} +{"name": "Popović Jovan Dragan", "order": "FAMILY_FIRST", "given": "Jovan", "middle": "Dragan", "family": "Popović", "initials": "J. D. P."} +{"name": "Popović, Jovan Dragan", "given": "Jovan", "middle": "Dragan", "family": "Popović", "initials": "J. D. P."} +{"name": "Potential Gotcha", "given": "Potential", "family": "Gotcha", "initials": "P. G."} +{"name": "Prema", "given": "Prema", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Prema", "order": "FAMILY_FIRST", "family": "Prema", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "P."} +{"name": "Prince Charles", "title": "Prince", "given": "Charles", "initials": "C."} +{"name": "Prince Fielder", "title": "Prince", "given": "Fielder", "initials": "F."} +{"name": "Prince of Wales Jr", "title": "Prince of Wales", "family": "Jr", "initials": "J."} +{"name": "Prof. Dk. Salima Farida mwana Hamisi Musa M.D. Ph.D.", "title": "Prof. Dk.", "given": "Salima", "middle": "Farida mwana Hamisi", "family": "Musa", "suffix": "M.D. Ph.D.", "initials": "S. F. m. H. M."} +{"name": "Prof. Dr. Ada Cornelia Cesar Martin von Bruhl Jr, MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Cesar Martin", "family": "von Bruhl", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B."} +{"name": "Prof. Dr. Ada Cornelia Cesar Martín von Bruhl Jr, M.D., Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Cesar Martín", "family": "von Bruhl", "suffix": "Jr, M.D., Ph.D.", "initials": "A. C. C. M. B."} +{"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo Jr, MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B. G. D."} +{"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "M.D. Ph.D.", "initials": "A. C. C. M. B. G. D."} +{"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} +{"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo, M.D. DDS.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "M.D. DDS.", "initials": "A. C. C. M. B. G. D."} +{"name": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr MD DDS", "initials": "A. C. C. M. B."} +{"name": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B."} +{"name": "Prof. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van den Wolf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W. B. S."} +{"name": "Prof. Dr. Ada Cornelia Martina von Bruhel Domingo MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Martina", "family": "von Bruhel Domingo", "suffix": "MD DDS", "initials": "A. C. M. B. D."} +{"name": "Prof. Dr. Ada Kornelia Sezar Martin van den Volf Becker Schmidt M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Kornelia Sezar Martin", "family": "van den Volf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. K. S. M. V. B. S."} +{"name": "Prof. Dr. Adama Coulibaly Camara Massamba won Baar Dominique Goudiaby MD DDS", "title": "Prof. Dr.", "given": "Adama", "middle": "Coulibaly Camara Massamba won Baar Dominique", "family": "Goudiaby", "suffix": "MD DDS", "initials": "A. C. C. M. w. B. D. G."} +{"name": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf jr. M.D. Ph.D.", "title": "Prof. Dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. M.D. Ph.D.", "initials": "A. C. E. S. W."} +{"name": "Prof. Dr. MD DDS Ada Cornelia César Martín von Brühl Jr", "title": "Prof. Dr. MD", "given": "DDS", "middle": "Ada Cornelia César Martín", "family": "von Brühl", "suffix": "Jr", "initials": "D. A. C. C. M. B."} +{"name": "Prof. Dr. Mary Lou Cesare Martín von Brühl Jr., Dr. Ph.D", "title": "Prof. Dr. Dr.", "given": "Mary", "middle": "Lou Cesare Martín", "family": "von Brühl", "suffix": "Jr., Ph.D", "initials": "M. L. C. M. B."} +{"name": "Prof. Dr. Mary Sue Marie von Miller II, MD DDS", "title": "Prof. Dr.", "given": "Mary", "middle": "Sue Marie", "family": "von Miller", "suffix": "II, MD DDS", "initials": "M. S. M. M."} +{"name": "Prof. Dr. María Florencia Martina Cristina von Brühl Jr., MD DDS", "title": "Prof. Dr.", "given": "María", "middle": "Florencia Martina Cristina", "family": "von Brühl", "suffix": "Jr., MD DDS", "initials": "M. F. M. C. B."} +{"name": "Prof. Dr. von Bruhl, Ada Cornelia Cesar Martín Jr, M.D., Ph.D.", "order": "FAMILY_FIRST", "given": "Ada", "middle": "Cornelia Cesar Martín", "family": "Prof. Dr. von Bruhl", "suffix": "Jr, M.D., Ph.D.", "initials": "A. C. C. M. P. D. B."} +{"name": "Prof. Dr. von Brühl Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. A. C. C. M. v."} +{"name": "Prof. Dr. von Brühl González Domingo Ada Cornelia César Martín, M.D. DDS.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "González Domingo Ada Cornelia César Martín", "family": "von", "suffix": "M.D. DDS.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. G. D. A. C. C. M. v."} +{"name": "Prof. Dr. von Brühl María Florencia Martina Cristina Jr., MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "María Florencia Martina Cristina", "family": "von", "suffix": "Jr., MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. M. F. M. C. v."} +{"name": "Prof. Dr. von Brühl Plácido Domingo Ada Cornelia César Martín, MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "Plácido Domingo Ada Cornelia César Martín", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. P. D. A. C. C. M. v."} +{"name": "Prof. dr. Cornelia Margarita Sofia Aletta van den Berg jnr., MBChB PhD", "given": "MBChB", "family": "Prof. dr. Cornelia Margarita Sofia Aletta van den Berg", "suffix": "jnr., PhD", "initials": "M. P. d. C. M. S. A. B."} +{"name": "Prof. dr. MD Chirurgie dentară Eva-Eleonora Karl Hans von der Ruhr Jr.", "title": "Prof. dr. MD", "given": "Chirurgie", "middle": "dentară Eva-Eleonora Karl Hans", "family": "von der Ruhr", "suffix": "Jr.", "initials": "C. d. E. K. H. R."} +{"name": "Prof. Ɖr. MƉ Samʊwɛl fan ƉAM Fostɛɛŋ", "title": "Prof. Ɖr.", "given": "MƉ", "middle": "Samʊwɛl fan ƉAM", "family": "Fostɛɛŋ", "initials": "M. S. f. Ɖ. F."} +{"name": "Prof. Ɖr. MƉ fan ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl", "order": "FAMILY_FIRST", "title": "Prof. Ɖr.", "given": "fan", "middle": "ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl", "family": "MƉ", "initials": "f. Ɖ. Ɖ. F. S. M."} +{"name": "Prop. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS", "title": "Prop. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} +{"name": "Pérez Lucía", "order": "FAMILY_FIRST", "given": "Lucía", "family": "Pérez", "initials": "L. P."} +{"name": "Pérez, Juan Ó.", "given": "Juan", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} +{"name": "Pérez, Lucía", "given": "Lucía", "family": "Pérez", "initials": "L. P."} +{"name": "Péricourt Louise", "order": "FAMILY_FIRST", "given": "Louise", "family": "Péricourt", "initials": "L. P."} +{"name": "Péricourt, Louise", "given": "Louise", "family": "Péricourt", "initials": "L. P."} +{"name": "Pīters Džims Millers", "given": "Pīters", "middle": "Džims", "family": "Millers", "initials": "P. D. M."} +{"name": "Q R", "given": "Q", "family": "R", "initials": "Q. R."} +{"name": "QC MP", "given": "QC", "suffix": "MP", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "Q."} +{"name": "Qodirov Alisher Salimovich Deputat", "order": "FAMILY_FIRST", "given": "Alisher", "middle": "Salimovich Deputat", "family": "Qodirov", "initials": "A. S. D. Q."} +{"name": "Qodirov Muhammad Yusuf", "order": "FAMILY_FIRST", "given": "Muhammad", "middle": "Yusuf", "family": "Qodirov", "initials": "M. Y. Q."} +{"name": "Qodirov, Alisher Salimovich", "given": "Alisher", "middle": "Salimovich", "family": "Qodirov", "initials": "A. S. Q."} +{"name": "Qodirov, Muhammad Yusuf", "given": "Muhammad", "middle": "Yusuf", "family": "Qodirov", "initials": "M. Y. Q."} +{"name": "Queen Elizabeth", "title": "Queen", "given": "Elizabeth", "initials": "E."} +{"name": "Quincy Adams", "given": "Quincy", "family": "Adams", "initials": "Q. A."} +{"name": "RONALD MACDONALD", "given": "RONALD", "family": "MACDONALD", "initials": "R. M."} +{"name": "RONALD MCDONALD", "given": "RONALD", "family": "MCDONALD", "initials": "R. M."} +{"name": "Rabbi Cohen", "title": "Rabbi", "family": "Cohen", "initials": "C."} +{"name": "Rafael Sousa dos Anjos", "given": "Rafael", "middle": "Sousa", "family": "dos Anjos", "initials": "R. S. A."} +{"name": "Razija Mujanović", "given": "Razija", "family": "Mujanović", "initials": "R. M."} +{"name": "Reem Al-Bahrani, CPA", "given": "Reem", "family": "Al-Bahrani", "suffix": "CPA", "initials": "R. A."} +{"name": "Rev Andrews", "title": "Rev", "family": "Andrews", "initials": "A."} +{"name": "Rev John A. Kenneth Doe", "title": "Rev", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} +{"name": "Rev John A. Kenneth Doe III (Kenny)", "title": "Rev", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "nickname": "Kenny", "initials": "J. A. K. D."} +{"name": "Rev. John A. Kenneth Doe", "title": "Rev.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} +{"name": "Rev. John Smith", "title": "Rev.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Riksdagsledamot Lars-Åke Karl Hermansson", "given": "Riksdagsledamot", "middle": "Lars-Åke Karl", "family": "Hermansson", "initials": "R. L. K. H."} +{"name": "Rinpoche", "given": "Rinpoche", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "R."} +{"name": "Rob And Beth Edmunds", "given": "Rob And Beth", "family": "Edmunds", "initials": "R. B. E."} +{"name": "Rob Edmunds", "given": "Rob", "family": "Edmunds", "initials": "R. E."} +{"name": "Robert Prince", "given": "Robert", "family": "Prince", "initials": "R. P."} +{"name": "Roberts Julia", "order": "FAMILY_FIRST", "given": "Julia", "family": "Roberts", "initials": "J. R."} +{"name": "Roberts, Julia", "given": "Julia", "family": "Roberts", "initials": "J. R."} +{"name": "Roig Gal·la", "order": "FAMILY_FIRST", "given": "Gal·la", "family": "Roig", "initials": "G. R."} +{"name": "Roig, Gal·la", "given": "Gal·la", "family": "Roig", "initials": "G. R."} +{"name": "Rokas", "given": "Rokas", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "R."} +{"name": "Rokas", "order": "FAMILY_FIRST", "family": "Rokas", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "R."} +{"name": "Rosa Maria Ferran Ferrer", "given": "Rosa", "middle": "Maria Ferran", "family": "Ferrer", "initials": "R. M. F. F."} +{"name": "Rosa María Ruiz", "given": "Rosa", "middle": "María", "family": "Ruiz", "initials": "R. M. R."} +{"name": "Rossouw Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Rossouw", "initials": "I. R."} +{"name": "Rossouw, Irene", "given": "Irene", "family": "Rossouw", "initials": "I. R."} +{"name": "Rovira, Josep Carod i Jr.", "given": "Josep", "middle": "Carod", "family": "Rovira", "suffix": "i Jr.", "initials": "J. C. R."} +{"name": "Royce, Ed", "given": "Ed", "family": "Royce", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. R."} +{"name": "Rt. Hon. Paul E. Mary", "title": "Rt. Hon.", "given": "Paul", "middle": "E.", "family": "Mary", "initials": "P. E. M."} +{"name": "Ruhr, Eva-Eleonora Karl Hans von der", "given": "Eva-Eleonora", "middle": "Karl Hans", "family": "von der Ruhr", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "E. K. H. R."} +{"name": "Ruiz Rosa María", "order": "FAMILY_FIRST", "given": "Rosa", "middle": "María", "family": "Ruiz", "initials": "R. M. R."} +{"name": "Ruiz, Rosa María", "given": "Rosa", "middle": "María", "family": "Ruiz", "initials": "R. M. R."} +{"name": "Rustemi, Ilir", "given": "Ilir", "family": "Rustemi", "initials": "I. R."} +{"name": "Rustemi, Ilir I.", "order": "FAMILY_FIRST", "given": "Ilir", "middle": "I.", "family": "Rustemi", "initials": "I. I. R."} +{"name": "Rüdiger", "given": "Rüdiger", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "R."} +{"name": "Rüdiger", "order": "FAMILY_FIRST", "family": "Rüdiger", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "R."} +{"name": "S. V.", "given": "S.", "family": "V.", "initials": "S. V."} +{"name": "S. V. D.", "given": "S.", "middle": "V.", "family": "D.", "initials": "S. V. D."} +{"name": "SEÁN Ó MURCHÚ", "given": "SEÁN", "family": "Ó MURCHÚ", "initials": "S. M."} +{"name": "SHIRLEY MACLAINE", "given": "SHIRLEY", "family": "MACLAINE", "initials": "S. M."} +{"name": "SMITH VD MA, JOHN", "given": "JOHN", "family": "SMITH VD MA", "initials": "J. S. M."} +{"name": "SMITH VD, JOHN", "given": "JOHN", "family": "SMITH", "suffix": "VD", "initials": "J. S."} +{"name": "SMITH, JOHN DO", "given": "JOHN", "family": "DO SMITH", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. S."} +{"name": "SMITH, VD DE LA MA", "family": "SMITH VD DE LA MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S. M."} +{"name": "SMITH, VD MA", "family": "SMITH VD MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S. M."} +{"name": "STEVEN HARDMAN, MD, DO, DDS", "given": "STEVEN", "family": "HARDMAN", "suffix": "MD, DO, DDS", "ambiguities": ["COMMA_STRUCTURE"], "initials": "S. H."} +{"name": "Saar Samira Ahmet", "order": "FAMILY_FIRST", "given": "Samira", "middle": "Ahmet", "family": "Saar", "initials": "S. A. S."} +{"name": "Said oglu", "given": "Said", "family": "oglu", "initials": "S. o."} +{"name": "Salam, abd Allah", "given": "abd Allah", "family": "Salam", "initials": "a. A. S."} +{"name": "Salem, Abdul Rahman Ahmed", "given": "Abdul Rahman", "middle": "Ahmed", "family": "Salem", "initials": "A. R. A. S."} +{"name": "Salimah Sulaiman", "given": "Salimah", "family": "Sulaiman", "initials": "S. S."} +{"name": "Sam Smith 😊", "given": "Sam", "family": "Smith", "initials": "S. S."} +{"name": "Samba", "given": "Samba", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Samba", "order": "FAMILY_FIRST", "family": "Samba", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Samira Ahmet Saar", "given": "Samira", "middle": "Ahmet", "family": "Saar", "initials": "S. A. S."} +{"name": "San San Chiou", "given": "San", "family": "San Chiou", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "S. C."} +{"name": "Sander van", "given": "Sander", "family": "van", "initials": "S. v."} +{"name": "Sanna Lianora", "order": "FAMILY_FIRST", "given": "Lianora", "family": "Sanna", "initials": "L. S."} +{"name": "Sanna, Lianora", "given": "Lianora", "family": "Sanna", "initials": "L. S."} +{"name": "Saparow Amangeldi", "order": "FAMILY_FIRST", "given": "Amangeldi", "family": "Saparow", "initials": "A. S."} +{"name": "Saparow, Amangeldi", "given": "Amangeldi", "family": "Saparow", "initials": "A. S."} +{"name": "Sarel", "given": "Sarel", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sarel", "order": "FAMILY_FIRST", "family": "Sarel", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sasilia Hamis Stober", "given": "Sasilia", "middle": "Hamis", "family": "Stober", "initials": "S. H. S."} +{"name": "Sattorov Alisher", "order": "FAMILY_FIRST", "given": "Alisher", "family": "Sattorov", "initials": "A. S."} +{"name": "Sattorov, Alisher", "given": "Alisher", "family": "Sattorov", "initials": "A. S."} +{"name": "Sañsé Señ Baba Wagué Amdy Raas MP", "order": "FAMILY_FIRST", "given": "Señ", "middle": "Baba Wagué Amdy Raas", "family": "Sañsé", "suffix": "MP", "initials": "S. B. W. A. R. S."} +{"name": "Sean O'Connor", "given": "Sean", "family": "O'Connor", "initials": "S. O."} +{"name": "Secretary of State Hillary Clinton", "title": "Secretary of State", "given": "Hillary", "family": "Clinton", "initials": "H. C."} +{"name": "Sejal Chaturvedi, CSM", "given": "Sejal", "family": "Chaturvedi", "suffix": "CSM", "initials": "S. C."} +{"name": "Senator \"Rick\" Edmonds", "title": "Senator", "family": "Edmonds", "nickname": "Rick", "initials": "E."} +{"name": "Senior Judge Charles R. Butler, Jr", "title": "Senior Judge", "given": "Charles", "middle": "R.", "family": "Butler", "suffix": "Jr", "initials": "C. R. B."} +{"name": "Senior Judge Harold D. Vietor", "title": "Senior Judge", "given": "Harold", "middle": "D.", "family": "Vietor", "initials": "H. D. V."} +{"name": "Senior Judge Virgil Pittman", "title": "Senior Judge", "given": "Virgil", "family": "Pittman", "initials": "V. P."} +{"name": "Sepp Anne", "order": "FAMILY_FIRST", "given": "Anne", "family": "Sepp", "initials": "A. S."} +{"name": "Sepp, Anne", "given": "Anne", "family": "Sepp", "initials": "A. S."} +{"name": "Seppo Ilmarinen", "given": "Seppo", "family": "Ilmarinen", "initials": "S. I."} +{"name": "Seynabu", "given": "Seynabu", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Seynabu", "order": "FAMILY_FIRST", "family": "Seynabu", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Seán Seamas Watson", "given": "Seán", "middle": "Seamas", "family": "Watson", "initials": "S. S. W."} +{"name": "Señ Baba Wagué Amdy Raas Sañsé MP", "given": "Señ", "middle": "Baba Wagué Amdy Raas", "family": "Sañsé", "suffix": "MP", "initials": "S. B. W. A. R. S."} +{"name": "Señor Carlos García", "title": "Señor", "given": "Carlos", "family": "García", "initials": "C. G."} +{"name": "Señora María García", "title": "Señora", "given": "María", "family": "García", "initials": "M. G."} +{"name": "Shanti Gaonkar", "given": "Shanti", "family": "Gaonkar", "initials": "S. G."} +{"name": "Shehu Muhammed", "title": "Shehu", "family": "Muhammed", "initials": "M."} +{"name": "Sheik Abu Bakar", "title": "Sheik", "given": "Abu Bakar", "initials": "A. B."} +{"name": "Sheik abdul Jr Smith", "title": "Sheik", "given": "abdul", "middle": "Jr", "family": "Smith", "initials": "a. J. S."} +{"name": "Sheik abdul salam", "title": "Sheik", "given": "abdul salam", "initials": "a. s."} +{"name": "Sheik abdul salam Jr", "title": "Sheik", "given": "abdul salam", "suffix": "Jr", "initials": "a. s."} +{"name": "Shirley Maclaine", "given": "Shirley", "family": "Maclaine", "initials": "S. M."} +{"name": "Shree Lalit Kumaar Bharati Jr, MP", "given": "Shree", "middle": "Lalit Kumaar", "family": "Bharati", "suffix": "Jr, MP", "initials": "S. L. K. B."} +{"name": "Shtober Zaziliya Hemish", "order": "FAMILY_FIRST", "given": "Zaziliya", "middle": "Hemish", "family": "Shtober", "initials": "Z. H. S."} +{"name": "Shtober, Zaziliya Hemish", "given": "Zaziliya", "middle": "Hemish", "family": "Shtober", "initials": "Z. H. S."} +{"name": "Sidorov Ivan Petrovich Jr.", "given": "Sidorov", "middle": "Ivan", "family": "Petrovich", "suffix": "Jr.", "initials": "S. I. P."} +{"name": "Sig. Fabio Massimo Carlo Alberto Martini II, AD", "given": "AD", "family": "Sig. Fabio Massimo Carlo Alberto Martini", "suffix": "II", "initials": "A. S. F. M. C. A. M."} +{"name": "Sig. Martini Fabio Massimo Carlo Alberto II, AD", "order": "FAMILY_FIRST", "given": "AD", "family": "Sig. Martini Fabio Massimo Carlo Alberto", "suffix": "II", "initials": "A. S. M. F. M. C. A."} +{"name": "Signor Marco Rossi", "title": "Signor", "given": "Marco", "family": "Rossi", "initials": "M. R."} +{"name": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir", "order": "FAMILY_FIRST", "given": "Jóna", "middle": "Valgerður Fanndís von Bjarnadóttir", "family": "Sigríður", "initials": "J. V. F. B. S."} +{"name": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir, M.A. í bókmenntafræði", "given": "Sigríður", "middle": "Jóna Valgerður Fanndís", "family": "von Bjarnadóttir", "suffix": "M.A. í bókmenntafræði", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S. J. V. F. B."} +{"name": "Sigurður", "given": "Sigurður", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sigurður", "order": "FAMILY_FIRST", "family": "Sigurður", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Silva João Pedro", "order": "FAMILY_FIRST", "given": "João", "middle": "Pedro", "family": "Silva", "initials": "J. P. S."} +{"name": "Silva Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Silva", "initials": "M. S."} +{"name": "Silva, José Pedro Eduardo da", "given": "José", "middle": "Pedro Eduardo", "family": "da Silva", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. P. E. S."} +{"name": "Silva, João Pedro", "given": "João", "middle": "Pedro", "family": "Silva", "initials": "J. P. S."} +{"name": "Silva, Maria", "given": "Maria", "family": "Silva", "initials": "M. S."} +{"name": "Silvije Strahimir Kranjčević", "given": "Silvije", "middle": "Strahimir", "family": "Kranjčević", "initials": "S. S. K."} +{"name": "Sinbad", "given": "Sinbad", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sinbad", "order": "FAMILY_FIRST", "family": "Sinbad", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sinbads", "given": "Sinbads", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sinbads", "order": "FAMILY_FIRST", "family": "Sinbads", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sindbad", "given": "Sindbad", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sindbad", "order": "FAMILY_FIRST", "family": "Sindbad", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sir Bob Andrew Dole", "title": "Sir", "given": "Bob", "middle": "Andrew", "family": "Dole", "initials": "B. A. D."} +{"name": "Sir Gerald", "title": "Sir", "given": "Gerald", "initials": "G."} +{"name": "Sir John", "title": "Sir", "given": "John", "initials": "J."} +{"name": "Sir John Prof.", "title": "Sir Prof.", "given": "John", "initials": "J."} +{"name": "Sir Ph. D. Van Johnson", "title": "Sir", "given": "Van Johnson", "suffix": "Ph. D.", "initials": "V. J."} +{"name": "Sir Van Johnson", "title": "Sir", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. J."} +{"name": "Sir abdul van der Berg", "title": "Sir", "given": "abdul", "family": "van der Berg", "initials": "a. B."} +{"name": "Sir de Mesnil", "title": "Sir", "family": "de Mesnil", "initials": "M."} +{"name": "Sister Mary", "title": "Sister", "given": "Mary", "initials": "M."} +{"name": "Sister Souljah", "title": "Sister", "given": "Souljah", "initials": "S."} +{"name": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður", "order": "FAMILY_FIRST", "given": "þingmaður", "family": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri", "initials": "þ. S. H. G. J. M. B. y."} +{"name": "Slovák Ján Matej", "order": "FAMILY_FIRST", "given": "Ján", "middle": "Matej", "family": "Slovák", "initials": "J. M. S."} +{"name": "Slovák, Ján Matej", "given": "Ján", "middle": "Matej", "family": "Slovák", "initials": "J. M. S."} +{"name": "Smith", "given": "Smith", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Smith (Jones)", "family": "Smith", "nickname": "Jones", "initials": "S."} +{"name": "Smith (née Jones)", "family": "Smith", "maiden": "Jones", "initials": "S."} +{"name": "Smith , John", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith Cecilia Angela", "order": "FAMILY_FIRST", "given": "Cecilia", "middle": "Angela", "family": "Smith", "initials": "C. A. S."} +{"name": "Smith Dr", "given": "Smith", "family": "Dr", "initials": "S. D."} +{"name": "Smith Jones, Ph. D. Jr.", "given": "Smith", "family": "Jones", "suffix": "Ph. D. Jr.", "initials": "S. J."} +{"name": "Smith Jr.", "given": "Smith", "suffix": "Jr.", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Smith Jr., A.B.", "given": "A.B.", "family": "Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. S."} +{"name": "Smith Jr., John", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "Smith Jr., MA", "family": "Smith", "suffix": "Jr., MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S."} +{"name": "Smith Jr., Ma", "given": "Ma", "family": "Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. S."} +{"name": "Smith Jr., Mr.", "title": "Mr.", "family": "Smith", "suffix": "Jr.", "initials": "S."} +{"name": "Smith Juan Vega", "given": "Smith", "middle": "Juan", "family": "Vega", "initials": "S. J. V."} +{"name": "Smith Prof.", "title": "Prof.", "family": "Smith", "initials": "S."} +{"name": "Smith Sir.", "title": "Sir.", "given": "Smith", "initials": "S."} +{"name": "Smith born", "given": "Smith", "family": "born", "initials": "S. b."} +{"name": "Smith nee Jones, Jane MA", "given": "Jane", "family": "Smith", "suffix": "MA", "maiden": "Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "Smith née", "given": "Smith", "family": "née", "initials": "S. n."} +{"name": "Smith née Jones", "family": "Smith", "maiden": "Jones", "initials": "S."} +{"name": "Smith née Jones PhD", "family": "Smith", "suffix": "PhD", "maiden": "Jones", "initials": "S."} +{"name": "Smith van der", "given": "Smith", "family": "van der", "initials": "S. v. d."} +{"name": "Smith 김민준씨, XYZ", "given": "Smith", "middle": "김", "family": "민준", "suffix": "씨, XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S. 김. 민."} +{"name": "Smith, A.B.", "given": "A.B.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. S."} +{"name": "Smith, A.B.C.", "family": "Smith", "suffix": "A.B.C.", "initials": "S."} +{"name": "Smith, A.P.", "given": "A.P.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "A. S."} +{"name": "Smith, Abd", "family": "Smith", "suffix": "Abd", "initials": "S."} +{"name": "Smith, Cecilia Angela", "given": "Cecilia", "middle": "Angela", "family": "Smith", "initials": "C. A. S."} +{"name": "Smith, Chancellor of the Exchequer MP", "title": "Chancellor of the Exchequer", "family": "Smith", "suffix": "MP", "initials": "S."} +{"name": "Smith, Dr.", "title": "Dr.", "family": "Smith", "initials": "S."} +{"name": "Smith, Dr. John", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith, Dr. Jr.", "title": "Dr.", "family": "Smith", "suffix": "Jr.", "initials": "S."} +{"name": "Smith, E.S.Q.", "given": "E.S.Q.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. S."} +{"name": "Smith, E.T., Jr.", "given": "E.T.", "family": "Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. S."} +{"name": "Smith, Esq.", "family": "Smith", "suffix": "Esq.", "initials": "S."} +{"name": "Smith, I.", "given": "I.", "family": "Smith", "initials": "I. S."} +{"name": "Smith, J. R.", "given": "J.", "middle": "R.", "family": "Smith", "initials": "J. R. S."} +{"name": "Smith, J.R.", "given": "J.R.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "Smith, Jan van Prof.", "title": "Prof.", "given": "Jan", "family": "van Smith", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. S."} +{"name": "Smith, John", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith, John ,", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith, John E, III, Jr", "given": "John", "middle": "E", "family": "Smith", "suffix": "III, Jr", "initials": "J. E. S."} +{"name": "Smith, John I", "given": "John", "family": "Smith", "suffix": "I", "initials": "J. S."} +{"name": "Smith, John Jr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} +{"name": "Smith, John MEng", "given": "John", "middle": "MEng", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. M. S."} +{"name": "Smith, John PhD I.", "given": "John", "family": "Smith", "suffix": "PhD I.", "initials": "J. S."} +{"name": "Smith, John PhD Jones", "given": "John", "family": "Smith", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "J. S."} +{"name": "Smith, John PhD de Jr.", "given": "John", "family": "de Smith", "suffix": "PhD, Jr.", "initials": "J. S."} +{"name": "Smith, John Prof.", "title": "Prof.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith, John V", "given": "John", "family": "Smith", "suffix": "V", "initials": "J. S."} +{"name": "Smith, John V, Jr.", "given": "John", "middle": "V", "family": "Smith", "suffix": "Jr.", "initials": "J. V. S."} +{"name": "Smith, John V.", "given": "John", "middle": "V.", "family": "Smith", "initials": "J. V. S."} +{"name": "Smith, John e, III, Jr", "given": "John", "middle": "e", "family": "Smith", "suffix": "III, Jr", "initials": "J. e. S."} +{"name": "Smith, John van der Berg Prof.", "title": "Prof.", "given": "John", "middle": "van der Berg", "family": "Smith", "initials": "J. B. S."} +{"name": "Smith, John, Extra, Jr.", "given": "John", "family": "Smith", "suffix": "Extra, Jr.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, John, MD - née Jones Smith", "given": "John", "family": "Smith", "suffix": "MD - née Jones Smith", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, John, PhD - and MD", "given": "John", "family": "Smith", "suffix": "PhD - and MD", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, John, PhD - i Soler", "given": "John", "family": "Smith", "suffix": "PhD - i Soler", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, John, PhD née Jones", "given": "John", "family": "Smith", "suffix": "PhD née Jones", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, John, Prof.", "title": "Prof.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith, John, Puig - i Soler", "given": "John", "family": "Smith", "suffix": "Puig - i Soler", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, John, and", "given": "John", "family": "Smith", "suffix": "and", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} +{"name": "Smith, Jr.", "family": "Smith", "suffix": "Jr.", "initials": "S."} +{"name": "Smith, Jr., PhD", "family": "Smith", "suffix": "Jr., PhD", "initials": "S."} +{"name": "Smith, LEED AP", "given": "LEED", "middle": "AP", "family": "Smith", "initials": "L. A. S."} +{"name": "Smith, MA", "family": "Smith", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S."} +{"name": "Smith, MD - PhD - FACS", "title": "MD", "given": "-", "family": "Smith", "suffix": "PhD - FACS", "initials": "-. S."} +{"name": "Smith, MD PhD", "family": "Smith", "suffix": "MD PhD", "initials": "S."} +{"name": "Smith, MD PhD Ma", "title": "MD", "given": "PhD", "middle": "Ma", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "P. M. S."} +{"name": "Smith, MEng", "given": "MEng", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. S."} +{"name": "Smith, Ma", "given": "Ma", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. S."} +{"name": "Smith, Major. John", "title": "Major.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Smith, Ms Jane", "title": "Ms", "given": "Jane", "family": "Smith", "initials": "J. S."} +{"name": "Smith, Ms MD Ma", "title": "Ms MD", "given": "Ma", "family": "Smith", "initials": "M. S."} +{"name": "Smith, Ms Ma", "title": "Ms", "given": "Ma", "family": "Smith", "initials": "M. S."} +{"name": "Smith, Ms.", "family": "Smith", "suffix": "Ms.", "initials": "S."} +{"name": "Smith, Ms. Jane", "title": "Ms.", "given": "Jane", "family": "Smith", "initials": "J. S."} +{"name": "Smith, PSM I", "family": "Smith", "suffix": "PSM I", "initials": "S."} +{"name": "Smith, PSM I.", "family": "Smith", "suffix": "PSM I.", "initials": "S."} +{"name": "Smith, Ph. D.", "family": "Smith", "suffix": "Ph. D.", "initials": "S."} +{"name": "Smith, Ph. D. III", "family": "Smith", "suffix": "Ph. D. III", "initials": "S."} +{"name": "Smith, Ph. D. Jr.", "family": "Smith", "suffix": "Ph. D. Jr.", "initials": "S."} +{"name": "Smith, Ph. D. Jr. MD", "family": "Smith", "suffix": "Ph. D. Jr. MD", "initials": "S."} +{"name": "Smith, Ph. D. MD", "family": "Smith", "suffix": "Ph. D. MD", "initials": "S."} +{"name": "Smith, Ph.D. Jr.", "family": "Smith", "suffix": "Ph.D. Jr.", "initials": "S."} +{"name": "Smith, PhD", "family": "Smith", "suffix": "PhD", "initials": "S."} +{"name": "Smith, PhD Jr.", "family": "Smith", "suffix": "PhD Jr.", "initials": "S."} +{"name": "Smith, PhD MEng", "family": "Smith", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S."} +{"name": "Smith, PhD Ma", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "S."} +{"name": "Smith, Prof.", "title": "Prof.", "family": "Smith", "initials": "S."} +{"name": "Smith, RN - CRNA", "family": "Smith", "suffix": "RN - CRNA", "initials": "S."} +{"name": "Smith, Sr.", "family": "Smith", "suffix": "Sr.", "initials": "S."} +{"name": "Smith, XYZ", "given": "XYZ", "family": "Smith", "initials": "X. S."} +{"name": "Smith, de Mesnil Jean", "family": "Smith de Mesnil Jean", "initials": "S. M. J."} +{"name": "Smith, meng", "given": "meng", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "m. S."} +{"name": "Smith. John", "title": "Smith.", "family": "John", "initials": "J."} +{"name": "Smith,John", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Sn. Dr. Fatma Su Yıldırım", "title": "Sn. Dr.", "given": "Fatma", "middle": "Su", "family": "Yıldırım", "initials": "F. S. Y."} +{"name": "Sn. Yıldırım Kaya Fatma Su, Dr.", "order": "FAMILY_FIRST", "title": "Sn. Dr.", "given": "Kaya", "middle": "Fatma Su", "family": "Yıldırım", "initials": "K. F. S. Y."} +{"name": "Sodersen Martine", "order": "FAMILY_FIRST", "given": "Martine", "family": "Sodersen", "initials": "M. S."} +{"name": "Sodersen, Martine", "given": "Martine", "family": "Sodersen", "initials": "M. S."} +{"name": "Sofija Rita Penkauskienė", "given": "Sofija", "middle": "Rita", "family": "Penkauskienė", "initials": "S. R. P."} +{"name": "Souto Brais Manoel", "order": "FAMILY_FIRST", "given": "Brais", "middle": "Manoel", "family": "Souto", "initials": "B. M. S."} +{"name": "Souto, Brais Manoel", "given": "Brais", "middle": "Manoel", "family": "Souto", "initials": "B. M. S."} +{"name": "Souza, Francisco Xavier Joao Anton de", "given": "Francisco", "middle": "Xavier Joao Anton", "family": "de Souza", "initials": "F. X. J. A. S."} +{"name": "Sr US District Judge Richard G Kopf", "title": "Sr US District Judge", "given": "Richard", "middle": "G", "family": "Kopf", "initials": "R. G. K."} +{"name": "Sr. Antoni Baìngiu Torchitòriu Comita de Lacon Gunale", "title": "Sr.", "given": "Antoni", "middle": "Baìngiu Torchitòriu Comita", "family": "de Lacon Gunale", "initials": "A. B. T. C. L. G."} +{"name": "Sr. José Pedro Eduardo da Silva Jr., MP", "title": "Sr.", "given": "José", "middle": "Pedro Eduardo", "family": "da Silva", "suffix": "Jr., MP", "initials": "J. P. E. S."} +{"name": "Sr. Miguel Ángel Juan Antonio Pablo II", "title": "Sr.", "given": "Miguel", "middle": "Ángel Juan Antonio", "family": "Pablo", "suffix": "II", "initials": "M. Á. J. A. P."} +{"name": "Sr. Xosé Carlos Francisco Xavier de Castro Sáez", "title": "Sr.", "given": "Xosé", "middle": "Carlos Francisco Xavier", "family": "de Castro Sáez", "initials": "X. C. F. X. C. S."} +{"name": "Srta. Andréia da Silva", "title": "Srta.", "given": "Andréia", "family": "da Silva", "initials": "A. S."} +{"name": "St Clair", "title": "St", "family": "Clair", "initials": "C."} +{"name": "Stander Zelda Herman", "order": "FAMILY_FIRST", "given": "Zelda", "middle": "Herman", "family": "Stander", "initials": "Z. H. S."} +{"name": "Stander, Zelda Herman", "given": "Zelda", "middle": "Herman", "family": "Stander", "initials": "Z. H. S."} +{"name": "Ste Marie", "family": "Ste Marie", "initials": "M."} +{"name": "Stefan", "given": "Stefan", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Stefan", "order": "FAMILY_FIRST", "family": "Stefan", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Stefan Katie", "order": "FAMILY_FIRST", "given": "Katie", "family": "Stefan", "initials": "K. S."} +{"name": "Stefan, Katie", "given": "Katie", "family": "Stefan", "initials": "K. S."} +{"name": "Steven Hardman", "given": "Steven", "family": "Hardman", "initials": "S. H."} +{"name": "Steven Hardman, MD - DO - DDS", "title": "MD", "given": "-", "middle": "DO -", "family": "Steven Hardman", "suffix": "DDS", "initials": "-. -. S. H."} +{"name": "Steven Hardman, MD, DO, DDS", "given": "Steven", "family": "Hardman", "suffix": "MD, DO, DDS", "initials": "S. H."} +{"name": "Steven Hardman, RN - CRNA", "given": "Steven", "family": "Hardman", "suffix": "RN - CRNA", "initials": "S. H."} +{"name": "Stober Sasilia Hamis", "order": "FAMILY_FIRST", "given": "Sasilia", "middle": "Hamis", "family": "Stober", "initials": "S. H. S."} +{"name": "Stober Zazilia Hamish", "order": "FAMILY_FIRST", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} +{"name": "Stober, Sasilia Hamis", "given": "Sasilia", "middle": "Hamis", "family": "Stober", "initials": "S. H. S."} +{"name": "Stober, Zazilia Hamish", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} +{"name": "Stober, Zazilia Hamish", "order": "FAMILY_FIRST", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} +{"name": "Stöber Hamish Zäzilia", "order": "FAMILY_FIRST", "given": "Hamish", "middle": "Zäzilia", "family": "Stöber", "initials": "H. Z. S."} +{"name": "Stöber Zezilia Hemiş", "order": "FAMILY_FIRST", "given": "Zezilia", "middle": "Hemiş", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Stöber Zäzilia Hamish", "order": "FAMILY_FIRST", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Stöber, Zezilia Hemiş", "given": "Zezilia", "middle": "Hemiş", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Stöber, Zäzilia Gültan", "given": "Zäzilia", "middle": "Gültan", "family": "Stöber", "initials": "Z. G. S."} +{"name": "Stöber, Zäzilia Gültan", "order": "FAMILY_FIRST", "given": "Zäzilia", "middle": "Gültan", "family": "Stöber", "initials": "Z. G. S."} +{"name": "Stöber, Zäzilia Hamish", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Stöber, Zäzilia Hamish", "order": "FAMILY_FIRST", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Støver Zelda Hamish", "order": "FAMILY_FIRST", "given": "Zelda", "middle": "Hamish", "family": "Støver", "initials": "Z. H. S."} +{"name": "Støver, Zelda Hamish", "given": "Zelda", "middle": "Hamish", "family": "Støver", "initials": "Z. H. S."} +{"name": "Sulaiman", "given": "Sulaiman", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sulaiman", "order": "FAMILY_FIRST", "family": "Sulaiman", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sulaiman Salimah", "order": "FAMILY_FIRST", "given": "Salimah", "family": "Sulaiman", "initials": "S. S."} +{"name": "Sulaiman, Salimah", "given": "Salimah", "family": "Sulaiman", "initials": "S. S."} +{"name": "Sule", "given": "Sule", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Sule", "order": "FAMILY_FIRST", "family": "Sule", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Svarun", "given": "Svarun", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Svarun", "order": "FAMILY_FIRST", "family": "Svarun", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "S."} +{"name": "Svoboda Josef Václav", "order": "FAMILY_FIRST", "given": "Josef", "middle": "Václav", "family": "Svoboda", "initials": "J. V. S."} +{"name": "Svoboda, Josef Václav", "given": "Josef", "middle": "Václav", "family": "Svoboda", "initials": "J. V. S."} +{"name": "Swami Vivekananda", "title": "Swami", "given": "Vivekananda", "initials": "V."} +{"name": "Swami Vivekananda Saraswati", "title": "Swami", "given": "Vivekananda", "family": "Saraswati", "initials": "V. S."} +{"name": "Syed A Ismail Syed Abd Aziz MP", "order": "FAMILY_FIRST", "given": "A", "middle": "Ismail Syed Abd Aziz", "family": "Syed", "suffix": "MP", "initials": "A. I. S. A. A. S."} +{"name": "Syed Hassan Syed Sadiq", "order": "FAMILY_FIRST", "given": "Hassan", "middle": "Syed Sadiq", "family": "Syed", "initials": "H. S. S. S."} +{"name": "Syed Hassan, Syed Sadiq", "given": "Syed", "middle": "Sadiq", "family": "Syed Hassan", "initials": "S. S. S. H."} +{"name": "Syed Sadiq Syed Hassan", "given": "Syed", "middle": "Sadiq Syed", "family": "Hassan", "initials": "S. S. S. H."} +{"name": "Syukri @ Shu Qi Mohd Nor", "given": "Syukri", "middle": "@ Shu Qi Mohd", "family": "Nor", "initials": "S. @. S. Q. M. N."} +{"name": "Szabados Máté Bendegúz", "order": "FAMILY_FIRST", "given": "Máté", "middle": "Bendegúz", "family": "Szabados", "initials": "M. B. S."} +{"name": "Szabados, Máté Bendegúz", "given": "Máté", "middle": "Bendegúz", "family": "Szabados", "initials": "M. B. S."} +{"name": "Tammik hr Mari-Ann Henri Roobert parlamendiliige", "order": "FAMILY_FIRST", "given": "hr", "middle": "Mari-Ann Henri Roobert parlamendiliige", "family": "Tammik", "initials": "h. M. H. R. p. T."} +{"name": "Tammik, Mari-Ann Henri Roobert, parlamendiliige", "given": "Mari-Ann", "middle": "Henri Roobert", "family": "Tammik", "suffix": "parlamendiliige", "ambiguities": ["COMMA_STRUCTURE"], "initials": "M. H. R. T."} +{"name": "Te Awanui-a-Rangi Black", "given": "Te", "middle": "Awanui-a-Rangi", "family": "Black", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "T. A. B."} +{"name": "Tham Jun", "given": "Tham", "family": "Jun", "initials": "T. J."} +{"name": "Tham Jun Hoe", "given": "Tham", "middle": "Jun", "family": "Hoe", "initials": "T. J. H."} +{"name": "Tham Junhoe", "given": "Tham", "family": "Junhoe", "initials": "T. J."} +{"name": "The Hon", "given": "The", "family": "Hon", "initials": "T. H."} +{"name": "The Lord of the Universe", "title": "The Lord of the Universe"} +{"name": "The Right Hon. the President of the Queen's Bench Division", "title": "The Right Hon. the President of the Queen's Bench", "family": "Division", "ambiguities": ["TITLE_OR_NAME"], "initials": "D."} +{"name": "The Rt Hon", "title": "The Rt", "family": "Hon", "ambiguities": ["TITLE_OR_NAME"], "initials": "H."} +{"name": "The Rt Hon John Jones", "title": "The Rt Hon", "given": "John", "family": "Jones", "initials": "J. J."} +{"name": "The Rt Hon Kenneth Clarke QC MP, HMG", "title": "The Rt Hon", "given": "Kenneth", "family": "Clarke", "suffix": "QC MP, HMG", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "K. C."} +{"name": "Title First Middle Middle Last, Jr.", "given": "Title", "middle": "First Middle Middle", "family": "Last", "suffix": "Jr.", "initials": "T. F. M. M. L."} +{"name": "Tn. Syed Abd Aziz Syed A Ismail Petra MP", "title": "Tn.", "given": "Syed", "middle": "Abd Aziz Syed A Ismail", "family": "Petra", "suffix": "MP", "initials": "S. A. A. S. A. I. P."} +{"name": "Tomás", "given": "Tomás", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "T."} +{"name": "Tomás", "order": "FAMILY_FIRST", "family": "Tomás", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "T."} +{"name": "Torres Ana", "order": "FAMILY_FIRST", "given": "Ana", "family": "Torres", "initials": "A. T."} +{"name": "Torres, Ana", "given": "Ana", "family": "Torres", "initials": "A. T."} +{"name": "Tunde Seun Okeke", "given": "Tunde", "middle": "Seun", "family": "Okeke", "initials": "T. S. O."} +{"name": "U. S. Grant", "given": "U.", "middle": "S.", "family": "Grant", "initials": "U. S. G."} +{"name": "U.S. District Judge Marc Thomas Treadwell", "given": "U.S.", "middle": "District Judge Marc Thomas", "family": "Treadwell", "initials": "U. D. J. M. T. T."} +{"name": "UNILEVER LTD", "given": "UNILEVER", "family": "LTD", "initials": "U. L."} +{"name": "US Magistrate Judge T Michael Putnam", "title": "US Magistrate Judge", "given": "T", "middle": "Michael", "family": "Putnam", "initials": "T. M. P."} +{"name": "US Magistrate-Judge Elizabeth E Campbell", "title": "US Magistrate-Judge", "given": "Elizabeth", "middle": "E", "family": "Campbell", "initials": "E. E. C."} +{"name": "Ulrika Wilhelmina Canth", "given": "Ulrika", "middle": "Wilhelmina", "family": "Canth", "initials": "U. W. C."} +{"name": "Umar Fatima Amina", "order": "FAMILY_FIRST", "given": "Fatima", "middle": "Amina", "family": "Umar", "initials": "F. A. U."} +{"name": "Umar, Fatima Amina", "given": "Fatima", "middle": "Amina", "family": "Umar", "initials": "F. A. U."} +{"name": "Unilever Ltd", "given": "Unilever", "family": "Ltd", "initials": "U. L."} +{"name": "Urrestaratzu, Ane Miren", "given": "Ane", "middle": "Miren", "family": "Urrestaratzu", "initials": "A. M. U."} +{"name": "Urrestaratzu, Ane Miren", "order": "FAMILY_FIRST", "given": "Ane", "middle": "Miren", "family": "Urrestaratzu", "initials": "A. M. U."} +{"name": "Urrestaratzu, Jon Andoni", "given": "Jon", "middle": "Andoni", "family": "Urrestaratzu", "initials": "J. A. U."} +{"name": "Urrestaratzu, Jon Andoni", "order": "FAMILY_FIRST", "given": "Jon", "middle": "Andoni", "family": "Urrestaratzu", "initials": "J. A. U."} +{"name": "Ursina", "given": "Ursina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "U."} +{"name": "Ursina", "order": "FAMILY_FIRST", "family": "Ursina", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "U."} +{"name": "Ursula von der Leyen (geb. Albrecht)", "given": "Ursula", "family": "von der Leyen", "maiden": "Albrecht", "initials": "U. L."} +{"name": "Ursula von der Leyen geb. Albrecht", "given": "Ursula", "family": "von der Leyen", "maiden": "Albrecht", "initials": "U. L."} +{"name": "Uxía", "given": "Uxía", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "U."} +{"name": "Uxía", "order": "FAMILY_FIRST", "family": "Uxía", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "U."} +{"name": "VINCENT VAN GOGH", "given": "VINCENT", "family": "VAN GOGH", "initials": "V. G."} +{"name": "Va'apu'u Vitale", "given": "Va'apu'u", "family": "Vitale", "initials": "V. V."} +{"name": "Valdimarsson Jón Hannes", "order": "FAMILY_FIRST", "given": "Jón", "middle": "Hannes", "family": "Valdimarsson", "initials": "J. H. V."} +{"name": "Van Buren, Ed", "given": "Ed", "family": "Van Buren", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "E. B."} +{"name": "Van Dalen Hoogeveen, Maria da Graça César Martín", "given": "Maria", "middle": "da Graça César Martín", "family": "Van Dalen Hoogeveen", "initials": "M. G. C. M. D. H."} +{"name": "Van Jeremy Johnson", "given": "Van", "middle": "Jeremy", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. J. J."} +{"name": "Van Johnson", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. J."} +{"name": "Van Johnson Smith", "given": "Van", "middle": "Johnson", "family": "Smith", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. J. S."} +{"name": "Van Maximilian Carlson", "given": "Van", "middle": "Maximilian", "family": "Carlson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. M. C."} +{"name": "Van Nguyen", "given": "Van", "family": "Nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. N."} +{"name": "Van der Merwe Jan Koos", "order": "FAMILY_FIRST", "given": "der Merwe Jan Koos", "family": "Van", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. M. J. K. V."} +{"name": "Van der Merwe, Jan Koos", "given": "Jan", "middle": "Koos", "family": "Van der Merwe", "initials": "J. K. M."} +{"name": "Vatson Con Hamiş", "order": "FAMILY_FIRST", "given": "Con", "middle": "Hamiş", "family": "Vatson", "initials": "C. H. V."} +{"name": "Vega Smith", "given": "Vega", "family": "Smith", "initials": "V. S."} +{"name": "Vega, Juan de la", "given": "Juan", "family": "de la Vega", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "J. V."} +{"name": "Velasquez y Garcia, Dr. Juan", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan III", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan Q.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan Q. III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan Q. Xavier", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan Q. Xavier III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan Q. Xavier, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan Q., Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} +{"name": "Velasquez y Garcia, Dr. Juan, Jr.", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} +{"name": "Velasquez y Garcia, Juan", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} +{"name": "Velasquez y Garcia, Juan III", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} +{"name": "Velasquez y Garcia, Juan Q.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} +{"name": "Velasquez y Garcia, Juan Q. III", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} +{"name": "Velasquez y Garcia, Juan Q. Xavier", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} +{"name": "Velasquez y Garcia, Juan Q. Xavier III", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} +{"name": "Velasquez y Garcia, Juan Q. Xavier, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} +{"name": "Velasquez y Garcia, Juan Q., Jr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} +{"name": "Velasquez y Garcia, Juan, Jr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} +{"name": "Venerable Ananda", "title": "Venerable", "family": "Ananda", "initials": "A."} +{"name": "Vilkas, Ada Kornelija Eva Sofija von", "given": "Ada", "middle": "Kornelija Eva Sofija", "family": "von Vilkas", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. K. E. S. V."} +{"name": "Vincent van Gogh", "given": "Vincent", "family": "van Gogh", "initials": "V. G."} +{"name": "Vincent van Gogh van Beethoven", "given": "Vincent", "middle": "van Gogh", "family": "van Beethoven", "initials": "V. G. B."} +{"name": "Virginia G. Essandoh, J.D.", "given": "Virginia", "middle": "G.", "family": "Essandoh", "suffix": "J.D.", "initials": "V. G. E."} +{"name": "Volf, Ada Kornelia Yeva Sofiya van den", "given": "Ada", "middle": "Kornelia Yeva Sofiya", "family": "van den Volf", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. K. Y. S. V."} +{"name": "Volf, Aleksandra Eva Sara van den", "given": "Aleksandra", "middle": "Eva Sara", "family": "van den Volf", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. E. S. V."} +{"name": "Vuk", "given": "Vuk", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "V."} +{"name": "Vuk", "order": "FAMILY_FIRST", "family": "Vuk", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "V."} +{"name": "WOOT ALFRED DOUGHERTY III", "given": "WOOT", "middle": "ALFRED", "family": "DOUGHERTY", "suffix": "III", "initials": "W. A. D."} +{"name": "Wallin Johan Harald", "order": "FAMILY_FIRST", "given": "Johan", "middle": "Harald", "family": "Wallin", "initials": "J. H. W."} +{"name": "Wallin, Johan Harald", "order": "FAMILY_FIRST", "given": "Johan", "middle": "Harald", "family": "Wallin", "initials": "J. H. W."} +{"name": "Waly Mari shu Halimatu", "order": "FAMILY_FIRST", "given": "Mari", "middle": "shu Halimatu", "family": "Waly", "initials": "M. s. H. W."} +{"name": "Wang M.Eng.", "given": "Wang", "suffix": "M.Eng.", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "W."} +{"name": "Wang Ma PhD", "given": "Wang", "family": "Ma", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "W. M."} +{"name": "Washington Jr. MD, Franklin", "given": "Franklin", "family": "Washington", "suffix": "Jr. MD", "initials": "F. W."} +{"name": "Watson John Hamish", "order": "FAMILY_FIRST", "given": "John", "middle": "Hamish", "family": "Watson", "initials": "J. H. W."} +{"name": "Watson John Hámish", "order": "FAMILY_FIRST", "given": "John", "middle": "Hámish", "family": "Watson", "initials": "J. H. W."} +{"name": "Watson Mary Sue Hamish", "order": "FAMILY_FIRST", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} +{"name": "Watson Seán Seamas", "order": "FAMILY_FIRST", "given": "Seán", "middle": "Seamas", "family": "Watson", "initials": "S. S. W."} +{"name": "Watson, John Hamish", "given": "John", "middle": "Hamish", "family": "Watson", "initials": "J. H. W."} +{"name": "Watson, John Hámish", "given": "John", "middle": "Hámish", "family": "Watson", "initials": "J. H. W."} +{"name": "Watson, Mary Sue Hamish", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} +{"name": "Weasley Ginevra Molly", "order": "FAMILY_FIRST", "given": "Ginevra", "middle": "Molly", "family": "Weasley", "initials": "G. M. W."} +{"name": "Weasley, Ginevra Molly", "given": "Ginevra", "middle": "Molly", "family": "Weasley", "initials": "G. M. W."} +{"name": "Weber Annelise", "order": "FAMILY_FIRST", "given": "Annelise", "family": "Weber", "initials": "A. W."} +{"name": "Weber, Annelise", "given": "Annelise", "family": "Weber", "initials": "A. W."} +{"name": "Wijaya, Maria", "given": "Maria", "family": "Wijaya", "initials": "M. W."} +{"name": "Wijaya, Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Wijaya", "initials": "M. W."} +{"name": "Will De Groot", "given": "Will", "family": "De Groot", "initials": "W. G."} +{"name": "Willemse Bertus Wilbers Henri Retief LP", "order": "FAMILY_FIRST", "given": "Bertus", "middle": "Wilbers Henri Retief", "family": "Willemse", "suffix": "LP", "initials": "B. W. H. R. W."} +{"name": "Willemse Heidi Johannes", "order": "FAMILY_FIRST", "given": "Heidi", "middle": "Johannes", "family": "Willemse", "initials": "H. J. W."} +{"name": "Willemse, Bertus Wilbers Henri Retief", "given": "Bertus", "middle": "Wilbers Henri Retief", "family": "Willemse", "initials": "B. W. H. R. W."} +{"name": "Willemse, Heidi Johannes", "given": "Heidi", "middle": "Johannes", "family": "Willemse", "initials": "H. J. W."} +{"name": "Wićazowa, Jěwa-Marja Hańža", "given": "Jěwa-Marja", "middle": "Hańža", "family": "Wićazowa", "initials": "J. H. W."} +{"name": "Wićazowa, dr. Jěwa-Marja Hańža jr. phd", "order": "FAMILY_FIRST", "title": "dr.", "given": "Jěwa-Marja", "middle": "Hańža", "family": "Wićazowa", "suffix": "jr. phd", "initials": "J. H. W."} +{"name": "Wj. Monir Mómen Monir Mómen Balóch", "title": "Wj.", "given": "Monir", "middle": "Mómen Monir Mómen", "family": "Balóch", "initials": "M. M. M. M. B."} +{"name": "Wolf, Ada Cornelia Eva Sophia van den", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. E. S. W."} +{"name": "Wolf, Ada Cornelia Eva Sophia von", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "von Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. E. S. W."} +{"name": "Wolf, Anna Cornelia Eva Sophia van den", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "A. C. E. S. W."} +{"name": "Wolf, Inger Marie Adele Synnøve von", "given": "Inger", "middle": "Marie Adele Synnøve", "family": "von Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "I. M. A. S. W."} +{"name": "Wolfgang Amadeus Mozart", "given": "Wolfgang", "middle": "Amadeus", "family": "Mozart", "initials": "W. A. M."} +{"name": "Wooster Bertram Wilberforce Henry Robert", "order": "FAMILY_FIRST", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "initials": "B. W. H. R. W."} +{"name": "Wooster Bertram Wilberforce Henry Robert AS", "order": "FAMILY_FIRST", "given": "Bertram", "middle": "Wilberforce Henry Robert AS", "family": "Wooster", "initials": "B. W. H. R. A. W."} +{"name": "Wooster Bertram Wilberforce Henry Robert MP", "order": "FAMILY_FIRST", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} +{"name": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP", "order": "FAMILY_FIRST", "given": "Mr.", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "Jr, MP", "initials": "M. B. W. H. R. W."} +{"name": "Wooster Mudane Bertram Wilberforce Henry Robert Jr, MP", "order": "FAMILY_FIRST", "given": "Mudane", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "Jr, MP", "initials": "M. B. W. H. R. W."} +{"name": "Wooster Owura Bertram Wilberforce Henry Robert MP", "order": "FAMILY_FIRST", "given": "Owura", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "O. B. W. H. R. W."} +{"name": "Wooster, Bertram Wilberforce Henry Robert", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "initials": "B. W. H. R. W."} +{"name": "Wooster, Bertram Wilberforce Henry Robert MP", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} +{"name": "X.Y.Z. Smith", "given": "X.Y.Z.", "family": "Smith", "initials": "X. S."} +{"name": "Xiana Freire", "given": "Xiana", "family": "Freire", "initials": "X. F."} +{"name": "Xyz. (Bud) Smith", "title": "Xyz.", "family": "Smith", "nickname": "Bud", "initials": "S."} +{"name": "Xyz. John Smith", "title": "Xyz.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "Xyz. Smith, John", "given": "John", "family": "Xyz. Smith", "initials": "J. X. S."} +{"name": "Xyz. van Johnson", "title": "Xyz.", "given": "van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. J."} +{"name": "Y. L.", "given": "Y.", "family": "L.", "initials": "Y. L."} +{"name": "Yannaki Noémie Katerina", "order": "FAMILY_FIRST", "given": "Noémie", "middle": "Katerina", "family": "Yannaki", "initials": "N. K. Y."} +{"name": "Yannaki, Noémie Katerina", "given": "Noémie", "middle": "Katerina", "family": "Yannaki", "initials": "N. K. Y."} +{"name": "Yağız Can Kaya", "given": "Yağız", "middle": "Can", "family": "Kaya", "initials": "Y. C. K."} +{"name": "Yin Le", "given": "Yin", "family": "Le", "initials": "Y. L."} +{"name": "Yin a Le", "given": "Yin", "middle": "a", "family": "Le", "initials": "Y. a. L."} +{"name": "Yoshi Emomoto", "given": "Yoshi", "family": "Emomoto", "initials": "Y. E."} +{"name": "Yıldırım, Fatma Su", "given": "Fatma", "middle": "Su", "family": "Yıldırım", "initials": "F. S. Y."} +{"name": "Yılmaz Irmak", "order": "FAMILY_FIRST", "given": "Irmak", "family": "Yılmaz", "initials": "I. Y."} +{"name": "Yılmaz, Irmak", "given": "Irmak", "family": "Yılmaz", "initials": "I. Y."} +{"name": "Z. Dara, Gavril", "given": "Gavril", "family": "Z. Dara", "initials": "G. Z. D."} +{"name": "Z. Dara, Gavril A.", "order": "FAMILY_FIRST", "given": "Gavril", "middle": "A.", "family": "Z. Dara", "initials": "G. A. Z. D."} +{"name": "Z. Gavril Dara", "given": "Z.", "middle": "Gavril", "family": "Dara", "initials": "Z. G. D."} +{"name": "Zabala Carlos", "order": "FAMILY_FIRST", "given": "Carlos", "family": "Zabala", "initials": "C. Z."} +{"name": "Zabala, Carlos", "given": "Carlos", "family": "Zabala", "initials": "C. Z."} +{"name": "Zamara Imani Bukenya", "given": "Zamara", "middle": "Imani", "family": "Bukenya", "initials": "Z. I. B."} +{"name": "Zazilia Hamish Stober", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} +{"name": "Zazilia Hamiş Ştöber", "given": "Zazilia", "middle": "Hamiş", "family": "Ştöber", "initials": "Z. H. Ş."} +{"name": "Zaziliya Hemish Shtober", "given": "Zaziliya", "middle": "Hemish", "family": "Shtober", "initials": "Z. H. S."} +{"name": "Zein El Dine", "given": "Zein", "middle": "El", "family": "Dine", "initials": "Z. E. D."} +{"name": "Zelda Hamish Støver", "given": "Zelda", "middle": "Hamish", "family": "Støver", "initials": "Z. H. S."} +{"name": "Zelda Herman Stander", "given": "Zelda", "middle": "Herman", "family": "Stander", "initials": "Z. H. S."} +{"name": "Zendaya", "given": "Zendaya", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Z."} +{"name": "Zendaya", "order": "FAMILY_FIRST", "family": "Zendaya", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Z."} +{"name": "Zendaya Marie Coleman", "given": "Zendaya", "middle": "Marie", "family": "Coleman", "initials": "Z. M. C."} +{"name": "Zephyrmark Jane Smith", "given": "Zephyrmark", "middle": "Jane", "family": "Smith", "initials": "Z. J. S."} +{"name": "Zeynep", "given": "Zeynep", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Z."} +{"name": "Zeynep", "order": "FAMILY_FIRST", "family": "Zeynep", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Z."} +{"name": "Zezilia Hemiş Stöber", "given": "Zezilia", "middle": "Hemiş", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Zrinka", "given": "Zrinka", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Z."} +{"name": "Zrinka", "order": "FAMILY_FIRST", "family": "Zrinka", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Z."} +{"name": "Zäzilia Gültan Stöber", "given": "Zäzilia", "middle": "Gültan", "family": "Stöber", "initials": "Z. G. S."} +{"name": "Zäzilia Hamish Stöber", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} +{"name": "Zäzilia Hamish Stöber", "order": "FAMILY_FIRST", "given": "Hamish", "middle": "Stöber", "family": "Zäzilia", "initials": "H. S. Z."} +{"name": "Zäzilia Stöber", "given": "Zäzilia", "family": "Stöber", "initials": "Z. S."} +{"name": "abd Allah", "given": "abd", "family": "Allah", "initials": "a. A."} +{"name": "abd Allah Smith", "given": "abd Allah", "family": "Smith", "initials": "a. A. S."} +{"name": "abd Allah Smith née Jones", "given": "abd Allah", "family": "Smith", "maiden": "Jones", "initials": "a. A. S."} +{"name": "abd Berg née Jones", "given": "abd", "family": "Berg", "maiden": "Jones", "initials": "a. B."} +{"name": "abd née Jones", "given": "abd", "maiden": "Jones", "initials": "a."} +{"name": "abdul", "given": "abdul", "initials": "a."} +{"name": "abdul Jr Smith Berg", "given": "abdul", "middle": "Jr Smith", "family": "Berg", "initials": "a. J. S. B."} +{"name": "abdul Ma Smith", "given": "abdul Ma", "family": "Smith", "initials": "a. M. S."} +{"name": "abdul Ph. D. Smith Berg", "given": "abdul", "middle": "Smith", "family": "Berg", "suffix": "Ph. D.", "initials": "a. S. B."} +{"name": "abdul Salam, Ed", "given": "abdul", "family": "Salam", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. S."} +{"name": "abdul Sir Smith Berg", "given": "abdul Sir", "middle": "Smith", "family": "Berg", "initials": "a. S. S. B."} +{"name": "abdul Smith Berg Ma", "given": "abdul Smith", "middle": "Berg", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. S. B. M."} +{"name": "abdul Smith Jr Ma", "given": "abdul", "family": "Smith", "suffix": "Jr Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. S."} +{"name": "abdul Smith Jr V", "given": "abdul", "family": "Smith", "suffix": "Jr V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. S."} +{"name": "abdul Smith Ma", "given": "abdul Smith", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. S. M."} +{"name": "abdul Smith V", "given": "abdul", "family": "Smith", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "a. S."} +{"name": "abdul V Smith", "given": "abdul V", "family": "Smith", "initials": "a. V. S."} +{"name": "abdul salam", "given": "abdul", "family": "salam", "initials": "a. s."} +{"name": "abdul salam ahmed salem", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s."} +{"name": "abdul salam ahmed salem jr", "given": "abdul salam", "middle": "ahmed", "family": "salem", "suffix": "jr", "initials": "a. s. a. s."} +{"name": "abdul salam jr", "given": "abdul", "family": "salam", "suffix": "jr", "initials": "a. s."} +{"name": "abdul salam salem", "given": "abdul salam", "family": "salem", "initials": "a. s. s."} +{"name": "abdulsalam ahmed salem", "given": "abdulsalam", "middle": "ahmed", "family": "salem", "initials": "a. a. s."} +{"name": "abu bakr al baghdadi", "given": "abu bakr", "family": "al baghdadi", "initials": "a. b. b."} +{"name": "ahmed abu bakr", "given": "ahmed", "family": "abu bakr", "initials": "a. b."} +{"name": "and Jon Dough", "given": "and Jon", "family": "Dough", "initials": "J. D."} +{"name": "and van Buren", "given": "and van", "family": "Buren", "initials": "a. v. B."} +{"name": "anh van do", "given": "anh", "family": "van do", "initials": "a. v. d."} +{"name": "anh van mc", "given": "anh", "family": "van mc", "initials": "a. v. m."} +{"name": "bin Stromae Elizabeth Windsor Maria Teresa MP", "order": "FAMILY_FIRST", "given": "Stromae", "middle": "Elizabeth Windsor Maria Teresa", "family": "bin", "suffix": "MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "S. E. W. M. T. b."} +{"name": "bin Stromae, Elizabeth Windsor Maria Teresa MP", "given": "Elizabeth", "middle": "Windsor Maria Teresa", "family": "bin Stromae", "suffix": "MP", "initials": "E. W. M. T. S."} +{"name": "bob v. de la macdole-eisenhower phd", "given": "bob", "middle": "v.", "family": "de la macdole-eisenhower", "suffix": "phd", "initials": "b. v. m."} +{"name": "da Silva Sr. José Pedro Eduardo Jr., MP", "order": "FAMILY_FIRST", "given": "Silva", "middle": "Sr. José Pedro Eduardo", "family": "da", "suffix": "Jr., MP", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "S. S. J. P. E. d."} +{"name": "de", "given": "de", "initials": "d."} +{"name": "de Anke van y", "family": "de Anke van y", "initials": "A."} +{"name": "de Bhulbh An tOll. An Dr. Brigid Aoibhe Sophia", "order": "FAMILY_FIRST", "given": "An", "middle": "tOll. An Dr. Brigid Aoibhe Sophia", "family": "de Bhulbh", "initials": "A. t. A. D. B. A. S. B."} +{"name": "de Bhulbh Brigid Aoibhe Sophia", "order": "FAMILY_FIRST", "given": "Brigid", "middle": "Aoibhe Sophia", "family": "de Bhulbh", "initials": "B. A. S. B."} +{"name": "de Castro Xosé Carlos Francisco Xavier", "order": "FAMILY_FIRST", "given": "Xosé", "middle": "Carlos Francisco Xavier", "family": "de Castro", "initials": "X. C. F. X. C."} +{"name": "de Jong dhr. Bertus Wevers Harry Robert jr., mp", "order": "FAMILY_FIRST", "given": "dhr.", "middle": "Bertus Wevers Harry Robert", "family": "de Jong", "suffix": "jr., mp", "initials": "d. B. W. H. R. J."} +{"name": "de Lacon, Antoni Baìngiu Torchitòriu Comita", "given": "Antoni", "middle": "Baìngiu Torchitòriu Comita", "family": "de Lacon", "initials": "A. B. T. C. L."} +{"name": "de Mesnil", "family": "de Mesnil", "initials": "M."} +{"name": "de Mesnil Garcia", "family": "de Mesnil Garcia", "initials": "M. G."} +{"name": "de Mesnil Jean", "family": "de Mesnil Jean", "initials": "M. J."} +{"name": "de Mesnil Jean, Dr.", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Jean", "family": "de Mesnil", "initials": "J. M."} +{"name": "de Mesnil Jr.", "family": "de Mesnil", "suffix": "Jr.", "initials": "M."} +{"name": "de Planta, dr. Margaritta Marionna Maria jr. CF", "order": "FAMILY_FIRST", "title": "dr.", "given": "Margaritta", "middle": "Marionna Maria", "family": "de Planta", "suffix": "jr. CF", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. M. M. P."} +{"name": "de Souza e Menezes Mr. Francisco Xavier Joao Anton Dk, MP", "order": "FAMILY_FIRST", "given": "Mr.", "middle": "Francisco Xavier Joao Anton Dk", "family": "de Souza e Menezes", "suffix": "MP", "initials": "M. F. X. J. A. D. S. M."} +{"name": "de la Cruz Juan Carlos", "family": "de la Cruz Juan Carlos", "initials": "C. J. C."} +{"name": "de la Cruz Juan Carlos", "order": "FAMILY_FIRST", "given": "Juan", "middle": "Carlos", "family": "de la Cruz", "initials": "J. C. C."} +{"name": "de la Cruz Juan Carlos", "order": "FAMILY_FIRST_GIVEN_LAST", "given": "Carlos", "middle": "Juan", "family": "de la Cruz", "initials": "C. J. C."} +{"name": "de la Cruz Juan Carlos, Dr.", "order": "FAMILY_FIRST_GIVEN_LAST", "title": "Dr.", "given": "Carlos", "middle": "Juan", "family": "de la Cruz", "initials": "C. J. C."} +{"name": "de la Cruz née Vega", "order": "FAMILY_FIRST", "family": "de la Cruz", "maiden": "Vega", "initials": "C."} +{"name": "de la Cruz née Vega", "order": "FAMILY_FIRST_GIVEN_LAST", "family": "de la Cruz", "maiden": "Vega", "initials": "C."} +{"name": "de la Cruz, Ma", "given": "Ma", "family": "de la Cruz", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. C."} +{"name": "de la Torre Vega", "family": "de la Torre Vega", "initials": "T. V."} +{"name": "de la Vega", "family": "de la Vega", "initials": "V."} +{"name": "de la Vega y Santos Juan", "family": "de la Vega y Santos Juan", "initials": "V. S. J."} +{"name": "de la Vega, Dr. Juan", "title": "Dr.", "given": "Juan", "family": "de la Vega", "initials": "J. V."} +{"name": "de la Vega, Dr. Juan III", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} +{"name": "de la Vega, Dr. Juan Q.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} +{"name": "de la Vega, Dr. Juan Q. III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} +{"name": "de la Vega, Dr. Juan Q. Xavier", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} +{"name": "de la Vega, Dr. Juan Q. Xavier III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "de la Vega, Dr. Juan Q. Xavier, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} +{"name": "de la Vega, Dr. Juan Q., Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} +{"name": "de la Vega, Dr. Juan, Jr.", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} +{"name": "de la Vega, Juan", "given": "Juan", "family": "de la Vega", "initials": "J. V."} +{"name": "de la Vega, Juan III", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} +{"name": "de la Vega, Juan Q.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} +{"name": "de la Vega, Juan Q. III", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} +{"name": "de la Vega, Juan Q. Xavier", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} +{"name": "de la Vega, Juan Q. Xavier III", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "de la Vega, Juan Q. Xavier, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} +{"name": "de la Vega, Juan Q., Jr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} +{"name": "de la Vega, Juan, Jr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} +{"name": "de la Véña, Jüan", "given": "Jüan", "family": "de la Véña", "initials": "J. V."} +{"name": "de la dos Vega, Dr. Juan Q. Xavier III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la dos Vega", "suffix": "III", "initials": "J. Q. X. V."} +{"name": "de la vega, dr. juan Q. xavier III", "title": "dr.", "given": "juan", "middle": "Q. xavier", "family": "de la vega", "suffix": "III", "initials": "j. Q. x. v."} +{"name": "de los Santos", "family": "de los Santos", "initials": "S."} +{"name": "der, y van", "given": "y", "family": "van der", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "y. v. d."} +{"name": "dhr. Bertus Wevers Harry Robert de Jong jr. mp", "title": "dhr.", "given": "Bertus", "middle": "Wevers Harry Robert", "family": "de Jong", "suffix": "jr. mp", "initials": "B. W. H. R. J."} +{"name": "doe, jane phd vd ma", "given": "jane", "family": "doe", "suffix": "phd vd ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. d."} +{"name": "doe, jane v phd do", "given": "jane", "family": "doe", "suffix": "v phd do", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. d."} +{"name": "doe, john ma", "given": "john", "family": "doe", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. d."} +{"name": "donovan mcnabb-smith", "given": "donovan", "family": "mcnabb-smith", "initials": "d. m."} +{"name": "dos Santos", "family": "dos Santos", "initials": "S."} +{"name": "dr Slađana Vera Milenković dipl. inž.", "title": "dr", "given": "Slađana", "middle": "Vera Milenković dipl.", "family": "inž.", "initials": "S. V. M. d. i."} +{"name": "dr Vincent James van Gogh dr", "title": "dr", "given": "Vincent", "middle": "James", "family": "van Gogh dr", "initials": "V. J. G. d."} +{"name": "dr Vincent van Gogh dr", "title": "dr", "given": "Vincent", "family": "van Gogh dr", "initials": "V. G. d."} +{"name": "dr Vincent van der Gogh dr", "title": "dr", "given": "Vincent", "family": "van der Gogh dr", "initials": "V. G. d."} +{"name": "dr. Anastasia Eleni César Martín von Brühl, cand.odont.", "title": "dr.", "given": "Anastasia", "middle": "Eleni César Martín", "family": "von Brühl", "suffix": "cand.odont.", "initials": "A. E. C. M. B."} +{"name": "dr. Hejba-Marja Hana Lejnikowa jr. phd", "title": "dr.", "given": "Hejba-Marja", "middle": "Hana", "family": "Lejnikowa", "suffix": "jr. phd", "initials": "H. H. L."} +{"name": "dr. Jěwa-Marja Hańža Wićazowa jr. phd", "title": "dr.", "given": "Jěwa-Marja", "middle": "Hańža", "family": "Wićazowa", "suffix": "jr. phd", "initials": "J. H. W."} +{"name": "dr. Margaritta Marionna Maria de Planta jr. CF", "title": "dr.", "given": "Margaritta", "middle": "Marionna Maria", "family": "de Planta", "suffix": "jr. CF", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. M. M. P."} +{"name": "dr. ben alex johnson III", "title": "dr.", "given": "ben", "middle": "alex", "family": "johnson", "suffix": "III", "initials": "b. a. j."} +{"name": "dr. john p. doe-Ray, CLU, CFP, LUTC", "title": "dr.", "given": "john", "middle": "p.", "family": "doe-Ray", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "j. p. d."} +{"name": "dr. juan de la vega jr.", "title": "dr.", "given": "juan", "family": "de la vega", "suffix": "jr.", "initials": "j. v."} +{"name": "dr. juan garcia III", "title": "dr.", "given": "juan", "family": "garcia", "suffix": "III", "initials": "j. g."} +{"name": "dr. p. Gabrielė Aldona Petraitytė", "title": "dr.", "given": "p.", "middle": "Gabrielė Aldona", "family": "Petraitytė", "initials": "p. G. A. P."} +{"name": "dr. sc. Branko Hrvoje Marić mlađi, prof.", "title": "dr. sc. prof.", "given": "Branko", "middle": "Hrvoje Marić", "family": "mlađi", "initials": "B. H. M. m."} +{"name": "e and e", "given": "e and e", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "e. e."} +{"name": "e j smith", "given": "e", "middle": "j", "family": "smith", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "e. j. s."} +{"name": "émile zola", "given": "émile", "family": "zola", "initials": "é. z."} +{"name": "fan ƉAM Fostɛɛŋ Samʊwɛl", "order": "FAMILY_FIRST", "given": "ƉAM", "middle": "Fostɛɛŋ Samʊwɛl", "family": "fan", "initials": "Ɖ. F. S. f."} +{"name": "fon Brül Ada Kornelia Sezar Martin, hirurg-stomatolog lukman", "order": "FAMILY_FIRST", "given": "hirurg-stomatolog", "middle": "lukman", "family": "fon Brül Ada Kornelia Sezar Martin", "initials": "h. l. f. B. A. K. S. M."} +{"name": "g. Adnan Mirza Hodžić zastupnik u parlamentu", "given": "g.", "middle": "Adnan Mirza Hodžić zastupnik u", "family": "parlamentu", "initials": "g. A. M. H. z. u. p."} +{"name": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "given": "zastupnik", "middle": "u parlamentu", "family": "g. Hodžić Adnan Mirza", "initials": "z. u. p. g. H. A. M."} +{"name": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "order": "FAMILY_FIRST", "given": "zastupnik", "middle": "u parlamentu", "family": "g. Hodžić Adnan Mirza", "initials": "z. u. p. g. H. A. M."} +{"name": "g. Janez Peter Novak ml., univ. dipl. inž.", "title": "univ. dipl. inž.", "given": "g.", "middle": "Janez Peter Novak", "family": "ml.", "initials": "g. J. P. N. m."} +{"name": "g. Novak Janez Peter, univ. dipl. inž.", "order": "FAMILY_FIRST", "title": "univ. dipl. inž.", "given": "Novak", "middle": "Janez Peter", "family": "g.", "initials": "N. J. P. g."} +{"name": "hr. Hans Henrik Sven Åge Møller, MF", "given": "MF", "family": "hr. Hans Henrik Sven Åge Møller", "initials": "M. h. H. H. S. Å. M."} +{"name": "ibn Awf abdul Rahman", "family": "ibn Awf abdul Rahman", "initials": "A. a. R."} +{"name": "ina binti navalamar", "given": "ina", "family": "binti navalamar", "initials": "i. n."} +{"name": "j.-e.-p. dupont", "given": "j.-e.-p.", "family": "dupont", "initials": "j. d."} +{"name": "jack ma", "given": "jack", "family": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. m."} +{"name": "jane doe nee smith ma", "given": "jane", "family": "doe", "suffix": "ma", "maiden": "smith", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. d."} +{"name": "jenap Atajan Saparowiç Gurdow magistr, ylymlaryň kandidaty", "given": "ylymlaryň", "middle": "kandidaty", "family": "jenap Atajan Saparowiç Gurdow magistr", "initials": "y. k. j. A. S. G. m."} +{"name": "joao da silva do amaral de souza", "given": "joao", "middle": "da silva do amaral", "family": "de souza", "initials": "j. s. a. s."} +{"name": "john doe", "given": "john", "family": "doe", "initials": "j. d."} +{"name": "john doe q.c.", "given": "john", "family": "doe", "suffix": "q.c.", "initials": "j. d."} +{"name": "john e jones", "given": "john", "middle": "e", "family": "jones", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "j. e. j."} +{"name": "john e jones, III", "given": "john", "middle": "e", "family": "jones", "suffix": "III", "initials": "j. e. j."} +{"name": "john e smith", "given": "john", "middle": "e", "family": "smith", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "j. e. s."} +{"name": "john e. smith", "given": "john", "middle": "e.", "family": "smith", "initials": "j. e. s."} +{"name": "john smith", "given": "john", "family": "smith", "initials": "j. s."} +{"name": "john smith EdD", "given": "john", "family": "smith", "suffix": "EdD", "initials": "j. s."} +{"name": "john smith MA", "given": "john", "family": "smith", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith MEng", "given": "john", "middle": "smith", "family": "MEng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s. M."} +{"name": "john smith bsc", "given": "john", "family": "smith", "suffix": "bsc", "initials": "j. s."} +{"name": "john smith ed", "given": "john", "family": "smith", "suffix": "ed", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith iii", "given": "john", "family": "smith", "suffix": "iii", "initials": "j. s."} +{"name": "john smith iii.", "given": "john", "family": "smith", "suffix": "iii.", "initials": "j. s."} +{"name": "john smith ix", "given": "john", "family": "smith", "suffix": "ix", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith jr", "given": "john", "family": "smith", "suffix": "jr", "initials": "j. s."} +{"name": "john smith m.d.", "given": "john", "family": "smith", "suffix": "m.d.", "initials": "j. s."} +{"name": "john smith mba", "given": "john", "family": "smith", "suffix": "mba", "initials": "j. s."} +{"name": "john smith mcse", "given": "john", "family": "smith", "suffix": "mcse", "initials": "j. s."} +{"name": "john smith md", "given": "john", "family": "smith", "suffix": "md", "initials": "j. s."} +{"name": "john smith meng", "given": "john", "family": "smith", "suffix": "meng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith p.h.d.", "given": "john", "family": "smith", "suffix": "p.h.d.", "initials": "j. s."} +{"name": "john smith ph. d.", "given": "john", "family": "smith", "suffix": "ph. d.", "initials": "j. s."} +{"name": "john smith ph.d.", "given": "john", "family": "smith", "suffix": "ph.d.", "initials": "j. s."} +{"name": "john smith phd", "given": "john", "family": "smith", "suffix": "phd", "initials": "j. s."} +{"name": "john smith psyd", "given": "john", "family": "smith", "suffix": "psyd", "initials": "j. s."} +{"name": "john smith vi", "given": "john", "family": "smith", "suffix": "vi", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith x.y.z.", "given": "john", "family": "smith", "suffix": "x.y.z.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith xi", "given": "john", "middle": "smith", "family": "xi", "initials": "j. s. x."} +{"name": "john smith, ma", "given": "john", "family": "smith", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith, md ma", "given": "john", "family": "smith", "suffix": "md ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith, phd meng", "given": "john", "family": "smith", "suffix": "phd meng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. s."} +{"name": "john smith, v", "given": "john", "family": "smith", "suffix": "v", "initials": "j. s."} +{"name": "john smith, vi", "given": "vi", "family": "john smith", "initials": "v. j. s."} +{"name": "john van der berg ma", "given": "john", "family": "van der berg", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "j. b."} +{"name": "johnny y", "given": "johnny", "family": "y", "initials": "j. y."} +{"name": "jones, john e", "given": "john", "middle": "e", "family": "jones", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "j. e. j."} +{"name": "jose e maria santos", "given": "jose", "middle": "e maria", "family": "santos", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "j. e. m. s."} +{"name": "jose ortega-y-gasset", "given": "jose", "family": "ortega-y-gasset", "initials": "j. o."} +{"name": "josep carod i rovira", "given": "josep", "middle": "carod i", "family": "rovira", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "j. c. i. r."} +{"name": "josé garcía", "given": "josé", "family": "garcía", "initials": "j. g."} +{"name": "juan de la vega", "given": "juan", "family": "de la vega", "initials": "j. v."} +{"name": "juan e-f smith", "given": "juan", "middle": "e-f", "family": "smith", "initials": "j. e. s."} +{"name": "juan garcia III", "given": "juan", "family": "garcia", "suffix": "III", "initials": "j. g."} +{"name": "juan garcia Iii", "given": "juan", "family": "garcia", "suffix": "Iii", "initials": "j. g."} +{"name": "juan garcia PhD", "given": "juan", "family": "garcia", "suffix": "PhD", "initials": "j. g."} +{"name": "juan garcia y lopez", "given": "juan", "family": "garcia y lopez", "initials": "j. g. l."} +{"name": "juan mcdonald", "given": "juan", "family": "mcdonald", "initials": "j. m."} +{"name": "juan q. xavier velasquez y garcia iii", "given": "juan", "middle": "q. xavier", "family": "velasquez y garcia", "suffix": "iii", "initials": "j. q. x. v. g."} +{"name": "juan y garcia", "given": "juan", "middle": "y", "family": "garcia", "initials": "j. y. g."} +{"name": "juan y-garcia", "given": "juan", "family": "y-garcia", "initials": "j. y."} +{"name": "juan y-garcia-lopez", "given": "juan", "family": "y-garcia-lopez", "initials": "j. y."} +{"name": "larry james edward johnson v", "given": "larry", "middle": "james edward", "family": "johnson", "suffix": "v", "initials": "l. j. e. j."} +{"name": "lt. gen. john a. kenneth doe iv", "title": "lt. gen.", "given": "john", "middle": "a. kenneth", "family": "doe", "suffix": "iv", "initials": "j. a. k. d."} +{"name": "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr.", "given": "m.d", "middle": "ph.d. Inger Marie Adele Synnøve", "family": "von Wolf", "suffix": "jr.", "initials": "m. p. I. M. A. S. W."} +{"name": "m.d ph.d. von Wolf Inger Marie Adele Synnøve", "order": "FAMILY_FIRST", "given": "ph.d.", "middle": "von Wolf Inger Marie Adele Synnøve", "family": "m.d", "initials": "p. W. I. M. A. S. m."} +{"name": "mack johnson", "given": "mack", "family": "johnson", "initials": "m. j."} +{"name": "maria da-silva", "given": "maria", "family": "da-silva", "initials": "m. d."} +{"name": "maria silva-e-sousa", "given": "maria", "family": "silva-e-sousa", "initials": "m. s."} +{"name": "matthëus schmidt", "given": "matthëus", "family": "schmidt", "initials": "m. s."} +{"name": "med. dr. fil. dr. jur. dr. Ada Cornelia César Martín von Brühl González Domingo", "title": "med. dr. fil. dr. jur. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "initials": "A. C. C. M. B. G. D."} +{"name": "med. m. dr. prof. dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas", "title": "med.", "given": "m.", "middle": "dr. prof. dr. Ada Kornelija Eva Sofija", "family": "von Vilkas Jankauskas-Baranauskas", "initials": "m. d. p. d. A. K. E. S. V. J."} +{"name": "meng li", "given": "meng", "family": "li", "initials": "m. l."} +{"name": "mevrouw Ada Cornelia César Martín von Brühl jr. PhD", "title": "mevrouw", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "jr. PhD", "initials": "A. C. C. M. B."} +{"name": "mnr. Bertus Wilbers Henri Retief Willemse jnr., LP", "title": "mnr.", "given": "Bertus", "middle": "Wilbers Henri Retief", "family": "Willemse", "suffix": "jnr., LP", "initials": "B. W. H. R. W."} +{"name": "mohamad ali smith", "given": "mohamad", "middle": "ali", "family": "smith", "initials": "m. a. s."} +{"name": "mwana Hamisi Salima Farida M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "Hamisi", "middle": "Salima Farida", "family": "mwana", "suffix": "M.D. Ph.D.", "initials": "H. S. F. m."} +{"name": "nguyen van lac", "given": "nguyen", "family": "van", "suffix": "lac", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "n. v."} +{"name": "née Jones", "given": "née", "family": "Jones", "initials": "n. J."} +{"name": "onorèvole Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita", "order": "FAMILY_FIRST", "given": "Sr.", "middle": "de Lacon Gunale Antoni Baìngiu Torchitòriu Comita", "family": "onorèvole", "initials": "S. L. G. A. B. T. C. o."} +{"name": "p. Petraitytė Gabrielė Aldona", "order": "FAMILY_FIRST", "given": "Petraitytė", "middle": "Gabrielė Aldona", "family": "p.", "initials": "P. G. A. p."} +{"name": "pani Alexandra Zuzana Macáková PhD", "given": "pani", "middle": "Alexandra Zuzana", "family": "Macáková", "suffix": "PhD", "initials": "p. A. Z. M."} +{"name": "pani Macáková Alexandra Zuzana, PhD", "order": "FAMILY_FIRST", "given": "Macáková", "middle": "Alexandra Zuzana", "family": "pani", "suffix": "PhD", "initials": "M. A. Z. p."} +{"name": "paní Alexandra Zuzana Machová Ondřejová, Ph.D.", "title": "paní", "given": "Alexandra", "middle": "Zuzana Machová", "family": "Ondřejová", "suffix": "Ph.D.", "initials": "A. Z. M. O."} +{"name": "paní Machová Ondřejová Alexandra Zuzana, Ph.D.", "order": "FAMILY_FIRST", "title": "paní", "given": "Ondřejová", "middle": "Alexandra Zuzana", "family": "Machová", "suffix": "Ph.D.", "initials": "O. A. Z. M."} +{"name": "part1 of The part2 of the part3 and part4", "given": "part1 of The part2 of the part3 and part4", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "p. p. p. p."} +{"name": "part1 of and The part2 of the part3 And part4", "given": "part1 of and The part2 of the part3 And part4", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "p. p. p. p."} +{"name": "pennie von bergen wessels", "given": "pennie", "family": "von bergen wessels", "initials": "p. b. w."} +{"name": "pennie von bergen wessels III", "given": "pennie", "family": "von bergen wessels", "suffix": "III", "initials": "p. b. w."} +{"name": "pennie von bergen wessels M.D.", "given": "pennie", "family": "von bergen wessels", "suffix": "M.D.", "initials": "p. b. w."} +{"name": "pennie von bergen wessels MD, III", "given": "pennie", "family": "von bergen wessels", "suffix": "MD, III", "initials": "p. b. w."} +{"name": "pennie von bergen wessels, III", "given": "pennie", "family": "von bergen wessels", "suffix": "III", "initials": "p. b. w."} +{"name": "prof. dr Ada Cornelia von Brühl", "title": "prof. dr", "given": "Ada", "middle": "Cornelia", "family": "von Brühl", "initials": "A. C. B."} +{"name": "prof. dr Aleksandra Eva Sara van den Volf Petrović Jugović dipl. inž.", "title": "prof. dr", "given": "Aleksandra", "middle": "Eva Sara", "family": "van den Volf Petrović Jugović dipl. inž.", "initials": "A. E. S. V. P. J. d. i."} +{"name": "prof. dr von Brühl Ada Cornelia César Martín", "order": "FAMILY_FIRST", "title": "prof. dr", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. A. C. C. M. v."} +{"name": "prof. dr. Ada Cornelia César Martín von Brühl Jr, univ. bacc", "title": "univ.", "given": "bacc", "family": "prof. dr. Ada Cornelia César Martín von Brühl", "suffix": "Jr", "initials": "b. p. d. A. C. C. M. B."} +{"name": "prof. dr. Ada Cornelia César Martín von Brühl ml., dr. med., dr. dent. med.", "title": "prof. dr. dr. med. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl ml.", "suffix": "dent. med.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. C. C. M. B. m."} +{"name": "prof. dr. Anna Cornelia Eva Sophia van den Wolf jr. m.d. ph.d.", "title": "prof. dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. m.d. ph.d.", "initials": "A. C. E. S. W."} +{"name": "prof. dr. Gentiana van den Popa, dr. shk. mjek.", "title": "prof. dr. dr. shk. mjek.", "given": "Gentiana", "family": "van den Popa", "initials": "G. P."} +{"name": "prof. dr. Izeta Amra von Selimović Hodžić, dipl. stom.", "title": "prof. dr. dipl. stom.", "given": "Izeta", "middle": "Amra", "family": "von Selimović Hodžić", "initials": "I. A. S. H."} +{"name": "prof. dr. Popa, Gentiana van den", "given": "Gentiana", "family": "van den prof. dr. Popa", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "G. p. d. P."} +{"name": "prof. dr. van den Popa, Gentiana M., dr. shk. mjek.", "order": "FAMILY_FIRST", "title": "dr.", "given": "Gentiana", "middle": "M.", "family": "prof. dr. van den Popa", "suffix": "shk. mjek.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "G. M. p. d. P."} +{"name": "prof. dr. von Brühl Ada Cornelia César Martín, dr. med., dr. dent. med.", "order": "FAMILY_FIRST", "title": "prof. dr. dr. med. dr.", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "suffix": "dent. med.", "ambiguities": ["COMMA_STRUCTURE", "PARTICLE_OR_GIVEN"], "initials": "B. A. C. C. M. v."} +{"name": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom.", "given": "Izeta", "middle": "Amra", "family": "prof. dr. von Selimović Hodžić", "suffix": "dipl. stom.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "I. A. p. d. S. H."} +{"name": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom.", "order": "FAMILY_FIRST", "given": "Izeta", "middle": "Amra", "family": "prof. dr. von Selimović Hodžić", "suffix": "dipl. stom.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "I. A. p. d. S. H."} +{"name": "prof. dr. von Vilkas Jankauskas-Baranauskas, Ada Kornelija Eva Sofija", "order": "FAMILY_FIRST", "given": "Ada", "middle": "Kornelija Eva Sofija", "family": "prof. dr. von Vilkas Jankauskas-Baranauskas", "initials": "A. K. E. S. p. d. V. J."} +{"name": "professori Ana Christina Lëja von Wright González Domingo, MD DDS", "given": "professori", "middle": "Ana Christina Lëja", "family": "von Wright González Domingo", "suffix": "MD DDS", "initials": "p. A. C. L. W. G. D."} +{"name": "qc mp", "given": "qc", "suffix": "mp", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "q."} +{"name": "salem, abdul", "given": "abdul", "family": "salem", "initials": "a. s."} +{"name": "salem, abdul salam", "given": "abdul salam", "family": "salem", "initials": "a. s. s."} +{"name": "salem, abdul salam ahmed", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s."} +{"name": "scott e. werner", "given": "scott", "middle": "e.", "family": "werner", "initials": "s. e. w."} +{"name": "scott l. werner", "given": "scott", "middle": "l.", "family": "werner", "initials": "s. l. w."} +{"name": "señora María García", "title": "señora", "given": "María", "family": "García", "initials": "M. G."} +{"name": "smith, de ma", "family": "smith de ma", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "s. m."} +{"name": "st.repr. Gregers Werle Marve Almar von Fleksnes jr.", "title": "st.repr.", "given": "Gregers", "middle": "Werle Marve Almar", "family": "von Fleksnes", "suffix": "jr.", "initials": "G. W. M. A. F."} +{"name": "st.repr. von Fleksnes Gregers Werle Marve Almar", "order": "FAMILY_FIRST", "title": "st.repr.", "given": "Fleksnes", "middle": "Gregers Werle Marve Almar", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "F. G. W. M. A. v."} +{"name": "test", "given": "test", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "t."} +{"name": "the and Jon Dough", "given": "the and Jon", "family": "Dough", "initials": "J. D."} +{"name": "tran lac", "given": "tran", "family": "lac", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "t. l."} +{"name": "u-Adier u-Irene", "order": "FAMILY_FIRST", "given": "u-Irene", "family": "u-Adier", "initials": "u. u."} +{"name": "u-Adier, u-Irene", "given": "u-Irene", "family": "u-Adier", "initials": "u. u."} +{"name": "u-Irene u-Adier", "given": "u-Irene", "family": "u-Adier", "initials": "u. u."} +{"name": "u-John u-Hamish u-Watson", "given": "u-John", "middle": "u-Hamish", "family": "u-Watson", "initials": "u. u. u."} +{"name": "u-Prof. Dr. u-Ada Cornelia u-Eva Sophia u-van den u-Wold u-Becker Schmidt M.D. Ph.D.", "given": "u-Prof.", "middle": "Dr. u-Ada Cornelia u-Eva Sophia u-van", "family": "den u-Wold u-Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "u. D. u. C. u. S. u. u. u. S."} +{"name": "u-Watson u-John u-Hamish", "order": "FAMILY_FIRST", "given": "u-John", "middle": "u-Hamish", "family": "u-Watson", "initials": "u. u. u."} +{"name": "u-Watson, u-John u-Hamish", "given": "u-John", "middle": "u-Hamish", "family": "u-Watson", "initials": "u. u. u."} +{"name": "u-Wold, u-Ada Cornelia u-Eva Sophia u-van den", "given": "u-Ada", "middle": "Cornelia u-Eva Sophia u-van", "family": "den u-Wold", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "u. C. u. S. u. u."} +{"name": "u-van den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane, M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane", "family": "u-van", "suffix": "M.D. Ph.D.", "initials": "d. u. u. D. u. C. u. S. i. u."} +{"name": "vai la", "given": "vai", "family": "la", "initials": "v. l."} +{"name": "van Berg Jan de", "given": "van", "middle": "Berg Jan", "family": "de", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. B. J. d."} +{"name": "van Gogh", "given": "van", "family": "Gogh", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. G."} +{"name": "van den Berg Cornelia Margarita Sofia Aletta MBChB PhD", "order": "FAMILY_FIRST", "given": "den Berg Cornelia Margarita Sofia Aletta MBChB", "family": "van", "suffix": "PhD", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. B. C. M. S. A. M. v."} +{"name": "van den Volf Ada Kornelia Sezar Martin M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Volf Ada Kornelia Sezar Martin", "family": "van", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. V. A. K. S. M. v."} +{"name": "van den Volf Ada Kornelia Yeva Sofiya M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Volf Ada Kornelia Yeva Sofiya", "family": "van", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. V. A. K. Y. S. v."} +{"name": "van den Volf Becker Schmidt, Prof. Dr. Ada Kornelia Sezar Martin M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Kornelia Sezar Martin", "family": "van den Volf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. K. S. M. V. B. S."} +{"name": "van den Volf prof. dr Aleksandra Eva Sara mlađi, dipl. inž.", "order": "FAMILY_FIRST", "title": "dipl. inž.", "given": "den Volf prof. dr Aleksandra Eva Sara mlađi", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. V. p. d. A. E. S. m. v."} +{"name": "van den Volfa Laima Kristīne", "order": "FAMILY_FIRST", "given": "den Volfa Laima Kristīne", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. V. L. K. v."} +{"name": "van den Volfa Laima Kristīne Eva Sofija", "order": "FAMILY_FIRST", "given": "den Volfa Laima Kristīne Eva Sofija", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. V. L. K. E. S. v."} +{"name": "van den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia", "family": "van", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. W. B. S. F. D. A. C. E. S. v."} +{"name": "van den Wolf Prof. Dr. Ada Cornelia Eva Sophia Jr, M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Wolf Prof. Dr. Ada Cornelia Eva Sophia", "family": "van", "suffix": "Jr, M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "d. W. P. D. A. C. E. S. v."} +{"name": "van den Wolf, Prof. Dr. Anna Cornelia Eva Sophia jr. M.D. Ph.D.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. M.D. Ph.D.", "initials": "A. C. E. S. W."} +{"name": "van den Wolf, prof. dr. Anna Cornelia Eva Sophia jr. m.d. ph.d.", "order": "FAMILY_FIRST", "title": "prof. dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. m.d. ph.d.", "initials": "A. C. E. S. W."} +{"name": "van der Berg, MA", "family": "van der Berg", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "B."} +{"name": "van der Berg, PhD", "family": "van der Berg", "suffix": "PhD", "initials": "B."} +{"name": "van ma van", "given": "van", "middle": "ma", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. m. v."} +{"name": "van nguyen", "given": "van", "family": "nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. n."} +{"name": "van. den. Wolf Ada Cornelia Eva Sophia M.D. Ph.D.", "order": "FAMILY_FIRST", "title": "van. den.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "Wolf", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W."} +{"name": "van. den. Wolf, Ada Cornelia Eva Sophia M.D. Ph.D.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van. den. Wolf", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W."} +{"name": "von Bjarnadóttir dr. Sigríður Jóna Valgerður Fanndís yngri, M.A. í bókmenntafræði", "order": "FAMILY_FIRST", "given": "Bjarnadóttir", "middle": "dr. Sigríður Jóna Valgerður Fanndís yngri", "family": "von", "suffix": "M.A. í bókmenntafræði", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "initials": "B. d. S. J. V. F. y. v."} +{"name": "von Braun", "given": "von", "family": "Braun", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "v. B."} +{"name": "von Bruhel Ada Cornelia Martina MD DDS", "order": "FAMILY_FIRST", "given": "Bruhel", "middle": "Ada Cornelia Martina", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. A. C. M. v."} +{"name": "von Bruhl Prof. Dr. Ada Cornelia Cesar Martin Jr, MD DDS", "order": "FAMILY_FIRST", "given": "Bruhl", "middle": "Prof. Dr. Ada Cornelia Cesar Martin", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. P. D. A. C. C. M. v."} +{"name": "von Brühl Ada Cornelia César Martín", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. A. C. C. M. v."} +{"name": "von Brühl Ada Cornelia César Martín M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. A. C. C. M. v."} +{"name": "von Brühl César Martín Ada Cornelia", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "César Martín Ada Cornelia", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. C. M. A. C. v."} +{"name": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "González Domingo Prof. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. G. D. P. D. A. C. C. M. v."} +{"name": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "González Domingo Prof. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. G. D. P. D. A. C. C. M. v."} +{"name": "von Brühl González Domingo Prop. Dr. Ada Cornelia César Martín MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "González Domingo Prop. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. G. D. P. D. A. C. C. M. v."} +{"name": "von Brühl González Domingo, Ada Cornelia César Martín", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "initials": "A. C. C. M. B. G. D."} +{"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B. G. D."} +{"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "M.D. Ph.D.", "initials": "A. C. C. M. B. G. D."} +{"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} +{"name": "von Brühl González Domingo, Prop. Dr. Ada Cornelia César Martín MD DDS", "title": "Prop. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} +{"name": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "Prof. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. P. D. A. C. C. M. v."} +{"name": "von Brühl Prof. Dr. Mary Lou Cesare Martín Jr., Dr. Ph.D", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Brühl", "middle": "Prof. Dr. Mary Lou Cesare Martín", "family": "von", "suffix": "Jr., Ph.D", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. P. D. M. L. C. M. v."} +{"name": "von Brühl mevrouw Ada Cornelia César Martín jr., PhD", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "mevrouw Ada Cornelia César Martín", "family": "von", "suffix": "jr., PhD", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. m. A. C. C. M. v."} +{"name": "von Brühl, Ada Cornelia César Martín", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "initials": "A. C. C. M. B."} +{"name": "von Brühl, Ada Cornelia César Martín MD DDS", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "MD DDS", "initials": "A. C. C. M. B."} +{"name": "von Brühl, Ada Cornelia César Martín MD DDS", "order": "FAMILY_FIRST", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "MD DDS", "initials": "A. C. C. M. B."} +{"name": "von Brühl, Anastasia Eleni César Martín", "given": "Anastasia", "middle": "Eleni César Martín", "family": "von Brühl", "initials": "A. E. C. M. B."} +{"name": "von Brühl, Maria Giusepa Juana Teresa", "given": "Maria", "middle": "Giusepa Juana Teresa", "family": "von Brühl", "initials": "M. G. J. T. B."} +{"name": "von Brühl, Prof. Dr. Ada Cornelia César Martín Jr MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr MD DDS", "initials": "A. C. C. M. B."} +{"name": "von Brühl, Prof. dr. Ada Cornelia César Martín, med. dr. fil. dr. jur. dr.", "order": "FAMILY_FIRST", "title": "Prof. dr. dr. dr. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "med. fil. jur.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. C. C. M. B."} +{"name": "von Brühl, dr. Anastasia Eleni César Martín, cand.odont.", "order": "FAMILY_FIRST", "title": "dr.", "given": "Anastasia", "middle": "Eleni César Martín", "family": "von Brühl", "suffix": "cand.odont.", "initials": "A. E. C. M. B."} +{"name": "von Brühl, prof. dr. Ada Cornelia César Martín Jr, univ. bacc", "order": "FAMILY_FIRST", "title": "prof. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr, univ. bacc", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. C. C. M. B."} +{"name": "von Brül Erika Cornelia Anna Isabelle II", "order": "FAMILY_FIRST", "given": "Brül", "middle": "Erika Cornelia Anna Isabelle", "family": "von", "suffix": "II", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "B. E. C. A. I. v."} +{"name": "von Fischer, Dr. Paul Vinzent jr. MdB", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Paul", "middle": "Vinzent", "family": "von Fischer", "suffix": "jr. MdB", "initials": "P. V. F."} +{"name": "von Kovács Luca Sarolta Éva Erzsébet M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "Kovács", "middle": "Luca Sarolta Éva Erzsébet", "family": "von", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "K. L. S. É. E. v."} +{"name": "von Kovács, Luca Sarolta Éva Erzsébet M.D. Ph.D.", "given": "Luca", "middle": "Sarolta Éva Erzsébet", "family": "von Kovács", "suffix": "M.D. Ph.D.", "initials": "L. S. É. E. K."} +{"name": "von Miller Jones Mary Sue Marie II, MD DDS", "order": "FAMILY_FIRST", "given": "Miller", "middle": "Jones Mary Sue Marie", "family": "von", "suffix": "II, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "M. J. M. S. M. v."} +{"name": "von Miller Jones, Prof. Dr. Mary Sue Marie", "title": "Prof. Dr.", "given": "Mary", "middle": "Sue Marie", "family": "von Miller Jones", "initials": "M. S. M. M. J."} +{"name": "von Vall Heinmann Laas Prof Anna Liina Eva Sofia MD, PhD", "order": "FAMILY_FIRST", "given": "Vall", "middle": "Heinmann Laas Prof Anna Liina Eva Sofia", "family": "von", "suffix": "MD, PhD", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "V. H. L. P. A. L. E. S. v."} +{"name": "von Vall, Anna Liina Eva Sofia, MD, PhD", "given": "Anna", "middle": "Liina Eva Sofia", "family": "von Vall", "suffix": "MD, PhD", "initials": "A. L. E. S. V."} +{"name": "von Wolf Ada Cornelia Eva Sophia MD DDS", "order": "FAMILY_FIRST", "given": "Wolf", "middle": "Ada Cornelia Eva Sophia", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "initials": "W. A. C. E. S. v."} +{"name": "von Wright González Domingo Ana Christina Lëja, professori MD DDS", "order": "FAMILY_FIRST", "given": "professori", "family": "von Wright González Domingo Ana Christina Lëja", "suffix": "MD DDS", "initials": "p. W. G. D. A. C. L."} +{"name": "von Wright, Ana Christina Lëja", "given": "Ana", "middle": "Christina Lëja", "family": "von Wright", "initials": "A. C. L. W."} +{"name": "von bergen wessels MD, pennie", "given": "pennie", "family": "von bergen wessels", "suffix": "MD", "initials": "p. b. w."} +{"name": "von bergen wessels MD, pennie III", "given": "pennie", "family": "von bergen wessels", "suffix": "MD, III", "initials": "p. b. w."} +{"name": "von bergen wessels, pennie III", "given": "pennie", "family": "von bergen wessels", "suffix": "III", "initials": "p. b. w."} +{"name": "von bergen wessels, pennie MD", "given": "pennie", "family": "von bergen wessels", "suffix": "MD", "initials": "p. b. w."} +{"name": "ván den Wólf Adá Cornelia Ẹ́va Sophia M.D. PhD.", "order": "FAMILY_FIRST", "given": "den Wólf Adá Cornelia Ẹ́va Sophia", "family": "ván", "suffix": "M.D. PhD.", "initials": "d. W. A. C. Ẹ. S. v."} +{"name": "ván den Wólf, Adá Cornelia Ẹ́va Sophia M.D. PhD.", "given": "Adá", "middle": "Cornelia Ẹ́va Sophia", "family": "ván den Wólf", "suffix": "M.D. PhD.", "initials": "A. C. Ẹ. S. v. W."} +{"name": "wang meng", "given": "wang", "family": "meng", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "w. m."} +{"name": "won Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba MD DDS", "order": "FAMILY_FIRST", "given": "Baar", "middle": "Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba", "family": "won", "suffix": "MD DDS", "initials": "B. D. G. P. D. A. C. C. M. w."} +{"name": "won Baar Dominique Goudiaby, Prof. Dr. Adama Coulibaly Camara Massamba MD DDS", "title": "Prof. Dr.", "given": "Adama", "middle": "Coulibaly Camara Massamba", "family": "won Baar Dominique Goudiaby", "suffix": "MD DDS", "initials": "A. C. C. M. w. B. D. G."} +{"name": "xyz. John Smith", "title": "xyz.", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "ylymlaryň doktory, professor Ada Kornelia Sezar Martin fon Brül Gonzales Domingo, hirurg-stomatolog lukman", "title": "professor", "given": "Ada", "middle": "Kornelia Sezar Martin fon Brül Gonzales Domingo", "family": "ylymlaryň doktory", "suffix": "hirurg-stomatolog lukman", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. K. S. M. f. B. G. D. y. d."} +{"name": "Íris Adólfsdóttir", "given": "Íris", "family": "Adólfsdóttir", "initials": "Í. A."} +{"name": "Íris Adólfsdóttir", "order": "FAMILY_FIRST", "given": "Adólfsdóttir", "family": "Íris", "initials": "A. Í."} +{"name": "Ó. Pérez", "given": "Ó.", "family": "Pérez", "initials": "Ó. P."} +{"name": "Ông Anh Văn Nguyễn ĐBQH", "given": "Ông", "middle": "Anh Văn Nguyễn", "family": "ĐBQH", "initials": "Ô. A. V. N. Đ."} +{"name": "ĐBQH Nguyễn Văn Anh", "order": "FAMILY_FIRST", "given": "Nguyễn", "middle": "Văn Anh", "family": "ĐBQH", "initials": "N. V. A. Đ."} +{"name": "İren Adler", "given": "İren", "family": "Adler", "initials": "İ. A."} +{"name": "Ştöber Zazilia Hamiş", "order": "FAMILY_FIRST", "given": "Zazilia", "middle": "Hamiş", "family": "Ştöber", "initials": "Z. H. Ş."} +{"name": "Šejc, Jan Měto", "given": "Jan", "middle": "Měto", "family": "Šejc", "initials": "J. M. Š."} +{"name": "Šejc, Jan Měto", "order": "FAMILY_FIRST", "given": "Jan", "middle": "Měto", "family": "Šejc", "initials": "J. M. Š."} +{"name": "Šołćina, Hanka", "given": "Hanka", "family": "Šołćina", "initials": "H. Š."} +{"name": "Šołćina, Hanka", "order": "FAMILY_FIRST", "given": "Hanka", "family": "Šołćina", "initials": "H. Š."} +{"name": "Šteber Cecilija Hemiš", "order": "FAMILY_FIRST", "given": "Cecilija", "middle": "Hemiš", "family": "Šteber", "initials": "C. H. Š."} +{"name": "Šteber, Cecilija Hemiš", "given": "Cecilija", "middle": "Hemiš", "family": "Šteber", "initials": "C. H. Š."} +{"name": "ʊŋono Alaaji KALAM Alɛ Mustafa", "order": "FAMILY_FIRST", "given": "Alaaji", "middle": "KALAM Alɛ Mustafa", "family": "ʊŋono", "initials": "A. K. A. M. ʊ."} +{"name": "ʊŋono Alaaji Mustafa KALAM Alɛ", "given": "ʊŋono", "middle": "Alaaji Mustafa KALAM", "family": "Alɛ", "initials": "ʊ. A. M. K. A."} +{"name": "Δημήτρης", "given": "Δημήτρης", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Δ."} +{"name": "Δημήτρης", "order": "FAMILY_FIRST", "family": "Δημήτρης", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Δ."} +{"name": "Ειρήνη Πολίτη", "given": "Ειρήνη", "family": "Πολίτη", "initials": "Ε. Π."} +{"name": "Ζεζίλια Χάμις Στέμπερ", "given": "Ζεζίλια", "middle": "Χάμις", "family": "Στέμπερ", "initials": "Ζ. Χ. Σ."} +{"name": "Κάτε Μίλερ", "given": "Κάτε", "family": "Μίλερ", "initials": "Κ. Μ."} +{"name": "Καθ. δρ. Άντα Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι. Δρ.Χ.Ο", "title": "Καθ. δρ.", "given": "Άντα", "middle": "Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι.", "family": "Δρ.Χ.Ο", "initials": "Ά. Κ. Σ. Μ. φ. Μ. Γ. Δ. Δ. Δ."} +{"name": "Καρατζής Πέτρος Νικόλαος", "order": "FAMILY_FIRST", "given": "Πέτρος", "middle": "Νικόλαος", "family": "Καρατζής", "initials": "Π. Ν. Κ."} +{"name": "Καρατζής, Πέτρος Νικόλαος", "given": "Πέτρος", "middle": "Νικόλαος", "family": "Καρατζής", "initials": "Π. Ν. Κ."} +{"name": "Κος Νικόλαος Καρατζής Κωνσταντίνος Μάριος Χατζής", "title": "Κος", "given": "Νικόλαος", "middle": "Καρατζής Κωνσταντίνος Μάριος", "family": "Χατζής", "initials": "Ν. Κ. Κ. Μ. Χ."} +{"name": "Μίλερ Κάτε", "order": "FAMILY_FIRST", "given": "Κάτε", "family": "Μίλερ", "initials": "Κ. Μ."} +{"name": "Μίλερ, Κάτε", "given": "Κάτε", "family": "Μίλερ", "initials": "Κ. Μ."} +{"name": "Μπριλ, φον Άντα Κορνέλια Σέσαρ Μαρτίν", "given": "φον", "middle": "Άντα Κορνέλια Σέσαρ Μαρτίν", "family": "Μπριλ", "initials": "φ. Ά. Κ. Σ. Μ. Μ."} +{"name": "Πέτρος Νικόλαος Καρατζής", "given": "Πέτρος", "middle": "Νικόλαος", "family": "Καρατζής", "initials": "Π. Ν. Κ."} +{"name": "Πολίτη Ειρήνη", "order": "FAMILY_FIRST", "given": "Ειρήνη", "family": "Πολίτη", "initials": "Ε. Π."} +{"name": "Πολίτη, Ειρήνη", "given": "Ειρήνη", "family": "Πολίτη", "initials": "Ε. Π."} +{"name": "Σινμπάντ", "given": "Σινμπάντ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Σ."} +{"name": "Σινμπάντ", "order": "FAMILY_FIRST", "family": "Σινμπάντ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Σ."} +{"name": "Στέμπερ Ζεζίλια Χάμις", "order": "FAMILY_FIRST", "given": "Ζεζίλια", "middle": "Χάμις", "family": "Στέμπερ", "initials": "Ζ. Χ. Σ."} +{"name": "Στέμπερ, Ζεζίλια Χάμις", "given": "Ζεζίλια", "middle": "Χάμις", "family": "Στέμπερ", "initials": "Ζ. Χ. Σ."} +{"name": "Χατζής Νικόλαος Καρατζής Κωνσταντίνος Μάριος", "order": "FAMILY_FIRST", "given": "Νικόλαος", "middle": "Καρατζής Κωνσταντίνος Μάριος", "family": "Χατζής", "initials": "Ν. Κ. Κ. Μ. Χ."} +{"name": "Χατζής, Νικόλαος Καρατζής Κωνσταντίνος Μάριος", "given": "Νικόλαος", "middle": "Καρατζής Κωνσταντίνος Μάριος", "family": "Χατζής", "initials": "Ν. Κ. Κ. Μ. Χ."} +{"name": "φον Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο", "order": "FAMILY_FIRST", "given": "Μπριλ", "middle": "Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο", "family": "φον", "initials": "Μ. Ά. Κ. Σ. Μ. Δ. Δ. φ."} +{"name": "Єва Марія Шевченко", "given": "Єва", "middle": "Марія", "family": "Шевченко", "initials": "Є. М. Ш."} +{"name": "Ірына Алейнік", "given": "Ірына", "family": "Алейнік", "initials": "І. А."} +{"name": "Јован Драган Поповић", "given": "Јован", "middle": "Драган", "family": "Поповић", "initials": "Ј. Д. П."} +{"name": "Ада Карнэлія Сесар Марцін фон Бруль д-р", "given": "Ада", "middle": "Карнэлія Сесар Марцін фон Бруль", "family": "д-р", "initials": "А. К. С. М. ф. Б. д."} +{"name": "Ада Корнелия фон Брюль Джуниор, тіс дәрігері", "given": "тіс", "middle": "дәрігері", "family": "Ада Корнелия фон Брюль Джуниор", "initials": "т. д. А. К. ф. Б. Д."} +{"name": "Адлер Ирене", "order": "FAMILY_FIRST", "given": "Ирене", "family": "Адлер", "initials": "И. А."} +{"name": "Азамат Салауат улы Фаттахов", "given": "Азамат", "middle": "Салауат улы", "family": "Фаттахов", "initials": "А. С. у. Ф."} +{"name": "Айдар Қасым", "given": "Айдар", "family": "Қасым", "initials": "А. Қ."} +{"name": "Алейнік Ірына", "order": "FAMILY_FIRST", "given": "Ірына", "family": "Алейнік", "initials": "І. А."} +{"name": "Алейнік, Ірына", "given": "Ірына", "family": "Алейнік", "initials": "І. А."} +{"name": "Алымбекова Каныкей", "order": "FAMILY_FIRST", "given": "Каныкей", "family": "Алымбекова", "initials": "К. А."} +{"name": "Амосова Ирина", "order": "FAMILY_FIRST", "given": "Ирина", "family": "Амосова", "initials": "И. А."} +{"name": "Амосова, Ирина", "given": "Ирина", "family": "Амосова", "initials": "И. А."} +{"name": "Ана Иванова Димитрова", "given": "Ана", "middle": "Иванова", "family": "Димитрова", "initials": "А. И. Д."} +{"name": "Анна Мельник", "given": "Анна", "family": "Мельник", "initials": "А. М."} +{"name": "Аталан", "given": "Аталан", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "А."} +{"name": "Аталан", "order": "FAMILY_FIRST", "family": "Аталан", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "А."} +{"name": "Ашмарин Николай Иванович", "order": "FAMILY_FIRST", "given": "Николай", "middle": "Иванович", "family": "Ашмарин", "initials": "Н. И. А."} +{"name": "Ашмарин, Николай Иванович", "given": "Николай", "middle": "Иванович", "family": "Ашмарин", "initials": "Н. И. А."} +{"name": "Бат", "given": "Бат", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Б."} +{"name": "Бат", "order": "FAMILY_FIRST", "family": "Бат", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Б."} +{"name": "Бертрам Вилберфорс Ҳенру Роберт Вустер", "given": "Бертрам", "middle": "Вилберфорс Ҳенру Роберт", "family": "Вустер", "initials": "Б. В. Ҳ. Р. В."} +{"name": "Болд Дулмаа", "order": "FAMILY_FIRST", "given": "Дулмаа", "family": "Болд", "initials": "Д. Б."} +{"name": "Болд, Дулмаа", "given": "Дулмаа", "family": "Болд", "initials": "Д. Б."} +{"name": "Бруль, Ада Карнэлія Сесар Марцін фон", "given": "Ада", "middle": "Карнэлія Сесар Марцін фон", "family": "Бруль", "initials": "А. К. С. М. ф. Б."} +{"name": "Брюл, Ада Корнелия Цезар Мартин фон", "given": "Ада", "middle": "Корнелия Цезар Мартин фон", "family": "Брюл", "initials": "А. К. Ц. М. ф. Б."} +{"name": "Брюль, Ада Корнелия Сезар Мартин фон", "given": "Ада", "middle": "Корнелия Сезар Мартин фон", "family": "Брюль", "initials": "А. К. С. М. ф. Б."} +{"name": "Брюхл, Ада Корнелиа Эва Софиа вон", "given": "Ада", "middle": "Корнелиа Эва Софиа вон", "family": "Брюхл", "initials": "А. К. Э. С. в. Б."} +{"name": "Васильев Дмитрий Петрович", "order": "FAMILY_FIRST", "given": "Дмитрий", "middle": "Петрович", "family": "Васильев", "initials": "Д. П. В."} +{"name": "Васильев, Дмитрий Петрович", "given": "Дмитрий", "middle": "Петрович", "family": "Васильев", "initials": "Д. П. В."} +{"name": "Васіль", "given": "Васіль", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "В."} +{"name": "Васіль", "order": "FAMILY_FIRST", "family": "Васіль", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "В."} +{"name": "Ватсон Мэри Сю Хамиш", "order": "FAMILY_FIRST", "given": "Мэри", "middle": "Сю Хамиш", "family": "Ватсон", "initials": "М. С. Х. В."} +{"name": "Вернер, Иван Асен фон", "given": "Иван", "middle": "Асен фон", "family": "Вернер", "initials": "И. А. ф. В."} +{"name": "Волф, Александра Ева Сара ван ден", "given": "Александра", "middle": "Ева Сара ван ден", "family": "Волф", "initials": "А. Е. С. в. д. В."} +{"name": "Волф, Ана Марија Цезар Мартин ван ден", "given": "Ана", "middle": "Марија Цезар Мартин ван ден", "family": "Волф", "initials": "А. М. Ц. М. в. д. В."} +{"name": "Вольга Караліна Равенская", "given": "Вольга", "middle": "Караліна", "family": "Равенская", "initials": "В. К. Р."} +{"name": "Вустер Бертрам Вилберфорс Ҳенру Роберт", "order": "FAMILY_FIRST", "given": "Бертрам", "middle": "Вилберфорс Ҳенру Роберт", "family": "Вустер", "initials": "Б. В. Ҳ. Р. В."} +{"name": "Джон", "given": "Джон", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Д."} +{"name": "Джон", "order": "FAMILY_FIRST", "family": "Джон", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Д."} +{"name": "Джоунс, Зара Мери", "given": "Зара", "middle": "Мери", "family": "Джоунс", "initials": "З. М. Д."} +{"name": "Джоунс, Зара Мери", "order": "FAMILY_FIRST", "given": "Зара", "middle": "Мери", "family": "Джоунс", "initials": "З. М. Д."} +{"name": "Димитрова, Ана Иванова", "given": "Ана", "middle": "Иванова", "family": "Димитрова", "initials": "А. И. Д."} +{"name": "Димитрова, Ана Иванова", "order": "FAMILY_FIRST", "given": "Ана", "middle": "Иванова", "family": "Димитрова", "initials": "А. И. Д."} +{"name": "Дмитрий Петрович Васильев", "given": "Дмитрий", "middle": "Петрович", "family": "Васильев", "initials": "Д. П. В."} +{"name": "Дорж Цэцэг", "order": "FAMILY_FIRST", "given": "Цэцэг", "family": "Дорж", "initials": "Ц. Д."} +{"name": "Дорж, Цэцэг", "given": "Цэцэг", "family": "Дорж", "initials": "Ц. Д."} +{"name": "Дулмаа Болд", "given": "Дулмаа", "family": "Болд", "initials": "Д. Б."} +{"name": "Зазилиа Хамиш Стобер", "given": "Зазилиа", "middle": "Хамиш", "family": "Стобер", "initials": "З. Х. С."} +{"name": "Зазилия Хамиш Штобер", "given": "Зазилия", "middle": "Хамиш", "family": "Штобер", "initials": "З. Х. Ш."} +{"name": "Зазилия Ҳамиш Стобер", "given": "Зазилия", "middle": "Ҳамиш", "family": "Стобер", "initials": "З. Ҳ. С."} +{"name": "Зазилія Хеміш Стобер", "given": "Зазилія", "middle": "Хеміш", "family": "Стобер", "initials": "З. Х. С."} +{"name": "Зазілія Хэміш Шцёбер", "given": "Зазілія", "middle": "Хэміш", "family": "Шцёбер", "initials": "З. Х. Ш."} +{"name": "Зара Мери Джоунс", "given": "Зара", "middle": "Мери", "family": "Джоунс", "initials": "З. М. Д."} +{"name": "Заңғар Тәуекел", "given": "Заңғар", "family": "Тәуекел", "initials": "З. Т."} +{"name": "Зендая", "given": "Зендая", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "З."} +{"name": "Зендая", "order": "FAMILY_FIRST", "family": "Зендая", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "З."} +{"name": "Иван", "given": "Иван", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "И."} +{"name": "Иван", "order": "FAMILY_FIRST", "family": "Иван", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "И."} +{"name": "Иван Васильевич Купцов", "given": "Иван", "middle": "Васильевич", "family": "Купцов", "initials": "И. В. К."} +{"name": "Иван Петрович Абрамович", "given": "Иван", "middle": "Петрович", "family": "Абрамович", "initials": "И. П. А."} +{"name": "Иванов Константин Васильевич", "order": "FAMILY_FIRST", "given": "Константин", "middle": "Васильевич", "family": "Иванов", "initials": "К. В. И."} +{"name": "Иванов, Константин Васильевич", "given": "Константин", "middle": "Васильевич", "family": "Иванов", "initials": "К. В. И."} +{"name": "Илемпи Корнилова", "given": "Илемпи", "family": "Корнилова", "initials": "И. К."} +{"name": "Ирена Марковић", "given": "Ирена", "family": "Марковић", "initials": "И. М."} +{"name": "Ирене Адлер", "given": "Ирене", "family": "Адлер", "initials": "И. А."} +{"name": "Ирина Амосова", "given": "Ирина", "family": "Амосова", "initials": "И. А."} +{"name": "Ирина Яҡупова", "given": "Ирина", "family": "Яҡупова", "initials": "И. Я."} +{"name": "Кавалёў Мікалай Аляксандр", "order": "FAMILY_FIRST", "given": "Мікалай", "middle": "Аляксандр", "family": "Кавалёў", "initials": "М. А. К."} +{"name": "Кавалёў, Мікалай Аляксандр", "given": "Мікалай", "middle": "Аляксандр", "family": "Кавалёў", "initials": "М. А. К."} +{"name": "Каныкей Алымбекова", "given": "Каныкей", "family": "Алымбекова", "initials": "К. А."} +{"name": "Касым Жоомарт Токаев", "given": "Касым", "middle": "Жоомарт", "family": "Токаев", "initials": "К. Ж. Т."} +{"name": "Кате Мюллер", "given": "Кате", "family": "Мюллер", "initials": "К. М."} +{"name": "Кати Мюлер", "given": "Кати", "family": "Мюлер", "initials": "К. М."} +{"name": "Кейт Мюллер", "given": "Кейт", "family": "Мюллер", "initials": "К. М."} +{"name": "Кете Милер", "given": "Кете", "family": "Милер", "initials": "К. М."} +{"name": "Кете Мюллер", "given": "Кете", "family": "Мюллер", "initials": "К. М."} +{"name": "Кетэ Мюлер", "given": "Кетэ", "family": "Мюлер", "initials": "К. М."} +{"name": "Константин Васильевич Иванов", "given": "Константин", "middle": "Васильевич", "family": "Иванов", "initials": "К. В. И."} +{"name": "Корнилова Илемпи", "order": "FAMILY_FIRST", "given": "Илемпи", "family": "Корнилова", "initials": "И. К."} +{"name": "Корнилова, Илемпи", "given": "Илемпи", "family": "Корнилова", "initials": "И. К."} +{"name": "Крсте", "given": "Крсте", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "К."} +{"name": "Крсте", "order": "FAMILY_FIRST", "family": "Крсте", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "К."} +{"name": "Крсте Мисирков", "given": "Крсте", "family": "Мисирков", "initials": "К. М."} +{"name": "Крсте Петков Мисирков", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} +{"name": "Купцов Иван Васильевич", "order": "FAMILY_FIRST", "given": "Иван", "middle": "Васильевич", "family": "Купцов", "initials": "И. В. К."} +{"name": "Купцов, Иван Васильевич", "given": "Иван", "middle": "Васильевич", "family": "Купцов", "initials": "И. В. К."} +{"name": "Кэйт Мюллер", "given": "Кэйт", "family": "Мюллер", "initials": "К. М."} +{"name": "Кэти Мюлер", "given": "Кэти", "family": "Мюлер", "initials": "К. М."} +{"name": "Мария Петрова", "given": "Мария", "family": "Петрова", "initials": "М. П."} +{"name": "Марковић Ирена", "order": "FAMILY_FIRST", "given": "Ирена", "family": "Марковић", "initials": "И. М."} +{"name": "Марковић, Ирена", "given": "Ирена", "family": "Марковић", "initials": "И. М."} +{"name": "Мельник Анна", "order": "FAMILY_FIRST", "given": "Анна", "family": "Мельник", "initials": "А. М."} +{"name": "Миленковић др Слађана Вера млађи, дипл. инж.", "order": "FAMILY_FIRST", "title": "дипл. инж.", "given": "др", "middle": "Слађана Вера млађи", "family": "Миленковић", "initials": "д. С. В. м. М."} +{"name": "Миленковић, Слађана Вера", "given": "Слађана", "middle": "Вера", "family": "Миленковић", "initials": "С. В. М."} +{"name": "Милер Кете", "order": "FAMILY_FIRST", "given": "Кете", "family": "Милер", "initials": "К. М."} +{"name": "Милер, Кете", "given": "Кете", "family": "Милер", "initials": "К. М."} +{"name": "Мисирков Крсте", "order": "FAMILY_FIRST", "given": "Крсте", "family": "Мисирков", "initials": "К. М."} +{"name": "Мисирков Крсте Петков", "order": "FAMILY_FIRST", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} +{"name": "Мисирков, Крсте", "given": "Крсте", "family": "Мисирков", "initials": "К. М."} +{"name": "Мисирков, Крсте Петков", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} +{"name": "Мырза Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент мүчөсү", "given": "Мырза", "middle": "Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент", "family": "мүчөсү", "initials": "М. Ч. А. И. Р. у. Т. М. П. м."} +{"name": "Мэри Сю Хамиш Ватсон", "given": "Мэри", "middle": "Сю Хамиш", "family": "Ватсон", "initials": "М. С. Х. В."} +{"name": "Мюлер Кетэ", "order": "FAMILY_FIRST", "given": "Кетэ", "family": "Мюлер", "initials": "К. М."} +{"name": "Мюлер Кэти", "order": "FAMILY_FIRST", "given": "Кэти", "family": "Мюлер", "initials": "К. М."} +{"name": "Мюлер, Кати", "given": "Кати", "family": "Мюлер", "initials": "К. М."} +{"name": "Мюлер, Кати", "order": "FAMILY_FIRST", "given": "Кати", "family": "Мюлер", "initials": "К. М."} +{"name": "Мюлер, Кетэ", "given": "Кетэ", "family": "Мюлер", "initials": "К. М."} +{"name": "Мюллер Кате", "order": "FAMILY_FIRST", "given": "Кате", "family": "Мюллер", "initials": "К. М."} +{"name": "Мюллер Кейт", "order": "FAMILY_FIRST", "given": "Кейт", "family": "Мюллер", "initials": "К. М."} +{"name": "Мюллер Кете", "order": "FAMILY_FIRST", "given": "Кете", "family": "Мюллер", "initials": "К. М."} +{"name": "Мюллер Кэйт", "order": "FAMILY_FIRST", "given": "Кэйт", "family": "Мюллер", "initials": "К. М."} +{"name": "Мюллер, Кате", "given": "Кате", "family": "Мюллер", "initials": "К. М."} +{"name": "Мюллер, Кейт", "given": "Кейт", "family": "Мюллер", "initials": "К. М."} +{"name": "Мюллер, Кете", "given": "Кете", "family": "Мюллер", "initials": "К. М."} +{"name": "Мікалай Аляксандр Кавалёў", "given": "Мікалай", "middle": "Аляксандр", "family": "Кавалёў", "initials": "М. А. К."} +{"name": "Мұхамеджан Әсем", "order": "FAMILY_FIRST", "given": "Әсем", "family": "Мұхамеджан", "initials": "Ә. М."} +{"name": "Мұхамеджан, Әсем", "given": "Әсем", "family": "Мұхамеджан", "initials": "Ә. М."} +{"name": "Николай Иванович Ашмарин", "given": "Николай", "middle": "Иванович", "family": "Ашмарин", "initials": "Н. И. А."} +{"name": "Ноён Азжаргал Төмөр", "given": "Ноён", "middle": "Азжаргал", "family": "Төмөр", "initials": "Н. А. Т."} +{"name": "Нұрлан", "given": "Нұрлан", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Н."} +{"name": "Нұрлан", "order": "FAMILY_FIRST", "family": "Нұрлан", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Н."} +{"name": "Петренко Анна Марія Богдан-Зиновій, доц., проф.", "title": "доц. проф.", "given": "Петренко", "middle": "Анна Марія", "family": "Богдан-Зиновій", "initials": "П. А. М. Б."} +{"name": "Петрова, Мария", "given": "Мария", "family": "Петрова", "initials": "М. П."} +{"name": "Петрова, Мария", "order": "FAMILY_FIRST", "given": "Мария", "family": "Петрова", "initials": "М. П."} +{"name": "Поповић Јован Драган", "order": "FAMILY_FIRST", "given": "Јован", "middle": "Драган", "family": "Поповић", "initials": "Ј. Д. П."} +{"name": "Поповић, Јован Драган", "given": "Јован", "middle": "Драган", "family": "Поповић", "initials": "Ј. Д. П."} +{"name": "Проф. Док. Ада Корнелиа Сезар Мартин вон Брухи Гонзалес Доминго", "title": "Проф. Док.", "given": "Ада", "middle": "Корнелиа Сезар Мартин вон Брухи Гонзалес", "family": "Доминго", "initials": "А. К. С. М. в. Б. Г. Д."} +{"name": "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин кандидаты", "title": "Проф. Доктор.", "given": "Алтынай", "middle": "Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин", "family": "кандидаты", "initials": "А. С. А. С. в. д. В. Б. Ш. И. к."} +{"name": "Проф. Др. Ада Корнелиа Эва Софиа вон Брюхл Жр, Анагаах ухааны доктор", "given": "Анагаах", "middle": "ухааны доктор", "family": "Проф. Др. Ада Корнелиа Эва Софиа вон Брюхл Жр", "initials": "А. у. д. П. Д. А. К. Э. С. в. Б. Ж."} +{"name": "Равенская Вольга Караліна", "order": "FAMILY_FIRST", "given": "Вольга", "middle": "Караліна", "family": "Равенская", "initials": "В. К. Р."} +{"name": "Равенская, Вольга Караліна", "given": "Вольга", "middle": "Караліна", "family": "Равенская", "initials": "В. К. Р."} +{"name": "Сергей", "given": "Сергей", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Сергей", "order": "FAMILY_FIRST", "family": "Сергей", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Сесилия Штобер", "given": "Сесилия", "family": "Штобер", "initials": "С. Ш."} +{"name": "Сидоров Иван Петрович", "given": "Сидоров", "middle": "Иван", "family": "Петрович", "initials": "С. И. П."} +{"name": "Синбад", "given": "Синбад", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Синбад", "order": "FAMILY_FIRST", "family": "Синбад", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Синдбад", "given": "Синдбад", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Синдбад", "order": "FAMILY_FIRST", "family": "Синдбад", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Стобер Зазилиа Хамиш", "order": "FAMILY_FIRST", "given": "Зазилиа", "middle": "Хамиш", "family": "Стобер", "initials": "З. Х. С."} +{"name": "Стобер Зазилия Ҳамиш", "order": "FAMILY_FIRST", "given": "Зазилия", "middle": "Ҳамиш", "family": "Стобер", "initials": "З. Ҳ. С."} +{"name": "Стобер Зазилія Хеміш", "order": "FAMILY_FIRST", "given": "Зазилія", "middle": "Хеміш", "family": "Стобер", "initials": "З. Х. С."} +{"name": "Стобер, Зазилиа Хамиш", "given": "Зазилиа", "middle": "Хамиш", "family": "Стобер", "initials": "З. Х. С."} +{"name": "Сыймык", "given": "Сыймык", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Сыймык", "order": "FAMILY_FIRST", "family": "Сыймык", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Сіндбад", "given": "Сіндбад", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Сіндбад", "order": "FAMILY_FIRST", "family": "Сіндбад", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "С."} +{"name": "Токаев Касым Жоомарт", "order": "FAMILY_FIRST", "given": "Касым", "middle": "Жоомарт", "family": "Токаев", "initials": "К. Ж. Т."} +{"name": "Тәуекел Заңғар", "order": "FAMILY_FIRST", "given": "Заңғар", "family": "Тәуекел", "initials": "З. Т."} +{"name": "Тәуекел, Заңғар Ермекұлы", "given": "Заңғар", "middle": "Ермекұлы", "family": "Тәуекел", "initials": "З. Е. Т."} +{"name": "Төмөр Ноён Азжаргал", "order": "FAMILY_FIRST", "given": "Ноён", "middle": "Азжаргал", "family": "Төмөр", "initials": "Н. А. Т."} +{"name": "Төмөр, Азжаргал", "given": "Азжаргал", "family": "Төмөр", "initials": "А. Т."} +{"name": "Урал", "given": "Урал", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "У."} +{"name": "Урал", "order": "FAMILY_FIRST", "family": "Урал", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "У."} +{"name": "Фаттахов Азамат Салауат улы", "order": "FAMILY_FIRST", "given": "Азамат", "middle": "Салауат улы", "family": "Фаттахов", "initials": "А. С. у. Ф."} +{"name": "Фаттахов, Азамат Салауат улы", "given": "Азамат", "middle": "Салауат улы", "family": "Фаттахов", "initials": "А. С. у. Ф."} +{"name": "ХОСЕ И МАРИЯ САНТОС", "given": "ХОСЕ И МАРИЯ", "family": "САНТОС", "initials": "Х. М. С."} +{"name": "Хосе И Мария Сантос", "given": "Хосе И Мария", "family": "Сантос", "initials": "Х. М. С."} +{"name": "Цецилия Хэмиш Штёбер", "given": "Цецилия", "middle": "Хэмиш", "family": "Штёбер", "initials": "Ц. Х. Ш."} +{"name": "Цецилија Хамиш Штебер", "given": "Цецилија", "middle": "Хамиш", "family": "Штебер", "initials": "Ц. Х. Ш."} +{"name": "Цецилија Хемиш Штебер", "given": "Цецилија", "middle": "Хемиш", "family": "Штебер", "initials": "Ц. Х. Ш."} +{"name": "Цэцэг Дорж", "given": "Цэцэг", "family": "Дорж", "initials": "Ц. Д."} +{"name": "Шевченко Єва Марія", "order": "FAMILY_FIRST", "given": "Єва", "middle": "Марія", "family": "Шевченко", "initials": "Є. М. Ш."} +{"name": "Штебер Цецилија Хамиш", "order": "FAMILY_FIRST", "given": "Цецилија", "middle": "Хамиш", "family": "Штебер", "initials": "Ц. Х. Ш."} +{"name": "Штебер Цецилија Хемиш", "order": "FAMILY_FIRST", "given": "Цецилија", "middle": "Хемиш", "family": "Штебер", "initials": "Ц. Х. Ш."} +{"name": "Штебер, Цецилија Хамиш", "given": "Цецилија", "middle": "Хамиш", "family": "Штебер", "initials": "Ц. Х. Ш."} +{"name": "Штебер, Цецилија Хемиш", "given": "Цецилија", "middle": "Хемиш", "family": "Штебер", "initials": "Ц. Х. Ш."} +{"name": "Штобер Зазилия Хамиш", "order": "FAMILY_FIRST", "given": "Зазилия", "middle": "Хамиш", "family": "Штобер", "initials": "З. Х. Ш."} +{"name": "Штобер Сесилия", "order": "FAMILY_FIRST", "given": "Сесилия", "family": "Штобер", "initials": "С. Ш."} +{"name": "Штобер, Сесилия Хэмиш", "given": "Сесилия", "middle": "Хэмиш", "family": "Штобер", "initials": "С. Х. Ш."} +{"name": "Штёбер Цецилия Хэмиш", "order": "FAMILY_FIRST", "given": "Цецилия", "middle": "Хэмиш", "family": "Штёбер", "initials": "Ц. Х. Ш."} +{"name": "Штёбер, Цецилия Хэмиш", "given": "Цецилия", "middle": "Хэмиш", "family": "Штёбер", "initials": "Ц. Х. Ш."} +{"name": "Шцёбер Зазілія Хэміш", "order": "FAMILY_FIRST", "given": "Зазілія", "middle": "Хэміш", "family": "Шцёбер", "initials": "З. Х. Ш."} +{"name": "Шцёбер, Зазілія Хэміш", "given": "Зазілія", "middle": "Хэміш", "family": "Шцёбер", "initials": "З. Х. Ш."} +{"name": "Юрій", "given": "Юрій", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ю."} +{"name": "Юрій", "order": "FAMILY_FIRST", "family": "Юрій", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ю."} +{"name": "Яҡупова Ирина", "order": "FAMILY_FIRST", "given": "Ирина", "family": "Яҡупова", "initials": "И. Я."} +{"name": "Яҡупова, Ирина", "given": "Ирина", "family": "Яҡупова", "initials": "И. Я."} +{"name": "ван ден Волф проф. др Александра Ева Сара млађи, дипл. инж.", "order": "FAMILY_FIRST", "title": "дипл. инж.", "given": "ден", "middle": "Волф проф. др Александра Ева Сара млађи", "family": "ван", "initials": "д. В. п. д. А. Е. С. м. в."} +{"name": "ван ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "order": "FAMILY_FIRST", "given": "ден", "middle": "Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "family": "ван", "initials": "д. В. А. С. А. С. И. к. в."} +{"name": "ван ден Вольф Бекер Шмидт, Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "title": "Проф. Доктор.", "given": "Алтынай", "middle": "Сулайманова Асель Сартбаева Илимдин кандидаты", "family": "ван ден Вольф Бекер Шмидт", "initials": "А. С. А. С. И. к. в. д. В. Б. Ш."} +{"name": "вон Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин", "order": "FAMILY_FIRST", "given": "Брухи", "middle": "Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин", "family": "вон", "initials": "Б. Г. Д. П. Д. А. К. С. М. в."} +{"name": "вон Брухи Гонзалес Доминго, Проф. Док. Ада Корнелиа Сезар Мартин", "title": "Проф. Док.", "given": "Ада", "middle": "Корнелиа Сезар Мартин", "family": "вон Брухи Гонзалес Доминго", "initials": "А. К. С. М. в. Б. Г. Д."} +{"name": "вон Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор", "order": "FAMILY_FIRST", "given": "Брюхл", "middle": "Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор", "family": "вон", "initials": "Б. Г. Д. П. Д. А. К. Э. С. А. у. д. в."} +{"name": "г-н Вернер, Иван Асен, проф. д-р", "order": "FAMILY_FIRST", "title": "проф. д-р", "given": "Иван", "middle": "Асен", "family": "г-н Вернер", "initials": "И. А. г. В."} +{"name": "г-н Иван Асен Вернер, проф. д-р", "title": "г-н проф. д-р", "given": "Иван", "middle": "Асен", "family": "Вернер", "initials": "И. А. В."} +{"name": "г-н Иван Петров", "title": "г-н", "given": "Иван", "family": "Петров", "initials": "И. П."} +{"name": "др Слађана Вера Миленковић дипл. инж.", "given": "др", "middle": "Слађана Вера Миленковић дипл.", "family": "инж.", "initials": "д. С. В. М. д. и."} +{"name": "проф. Анастасія Єва Софія фон Вольф Гонзалес Домінго, д-р", "title": "проф. д-р", "given": "Анастасія", "middle": "Єва Софія фон Вольф Гонзалес", "family": "Домінго", "initials": "А. Є. С. ф. В. Г. Д."} +{"name": "проф. Анна Марія Богдан-Зиновій Петренко доц.", "title": "проф.", "given": "Анна", "middle": "Марія Богдан-Зиновій Петренко", "family": "доц.", "initials": "А. М. Б. П. д."} +{"name": "проф. Петренко Анна Марія Богдан-Зиновій доц.", "order": "FAMILY_FIRST", "title": "проф.", "given": "Анна", "middle": "Марія Богдан-Зиновій доц.", "family": "Петренко", "initials": "А. М. Б. д. П."} +{"name": "проф. д-р Ада Корнелия Цезар Мартин Брюл, доктор", "given": "доктор", "family": "проф. д-р Ада Корнелия Цезар Мартин Брюл", "initials": "д. п. д. А. К. Ц. М. Б."} +{"name": "проф. д-р Ана Марија Цезар Мартин ван ден Волф", "title": "проф. д-р", "given": "Ана", "middle": "Марија Цезар Мартин ван ден", "family": "Волф", "initials": "А. М. Ц. М. в. д. В."} +{"name": "проф. д-р Брюл, Ада Корнелия Цезар Мартин, доктор", "order": "FAMILY_FIRST", "given": "Ада", "middle": "Корнелия Цезар Мартин", "family": "проф. д-р Брюл", "suffix": "доктор", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. Ц. М. п. д. Б."} +{"name": "проф. д-р Крсте Петков Мисирков", "title": "проф. д-р", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} +{"name": "проф. д-р Мисирков Крсте Петков", "order": "FAMILY_FIRST", "title": "проф. д-р", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} +{"name": "проф. д-р ван ден Волф Ана Марија Цезар Мартин", "order": "FAMILY_FIRST", "title": "проф. д-р", "given": "ден", "middle": "Волф Ана Марија Цезар Мартин", "family": "ван", "initials": "д. В. А. М. Ц. М. в."} +{"name": "проф. др Александра Ева Сара ван ден Волф Петровић Југовић дипл. инж.", "title": "проф.", "given": "др", "middle": "Александра Ева Сара ван ден Волф Петровић Југовић дипл.", "family": "инж.", "initials": "д. А. Е. С. в. д. В. П. Ј. д. и."} +{"name": "проф. фон Вольф Гонзалес Домінго Анастасія Єва Софія д-р", "order": "FAMILY_FIRST", "title": "проф.", "given": "Вольф", "middle": "Гонзалес Домінго Анастасія Єва Софія д-р", "family": "фон", "initials": "В. Г. Д. А. Є. С. д. ф."} +{"name": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль кесе, м. ф. д.", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин фон Брюль кесе", "family": "проф.", "suffix": "м. ф. д.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. С. М. ф. Б. к. п."} +{"name": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к. м. н.", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин фон Брюль — мл.", "family": "проф.", "suffix": "к. м. н.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. С. М. ф. Б. —. м. п."} +{"name": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к.м.н.", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин фон Брюль — мл.", "family": "проф.", "suffix": "к.м.н.", "initials": "А. К. С. М. ф. Б. —. м. п."} +{"name": "уулу Төрөкул Махмудов, Мырза Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "given": "Мырза", "middle": "Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "family": "уулу Төрөкул Махмудов", "initials": "М. Ч. А. И. Р. П. м. у. Т. М."} +{"name": "уулу Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "order": "FAMILY_FIRST", "given": "Төрөкул", "middle": "Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "family": "уулу", "initials": "Т. Ч. А. И. Р. П. м. у."} +{"name": "фон Бруль Ада Карнэлія Сесар Марцін д-р", "order": "FAMILY_FIRST", "given": "Бруль", "middle": "Ада Карнэлія Сесар Марцін д-р", "family": "фон", "initials": "Б. А. К. С. М. д. ф."} +{"name": "фон Брюль Ада Корнелия", "order": "FAMILY_FIRST", "given": "Брюль", "middle": "Ада Корнелия", "family": "фон", "initials": "Б. А. К. ф."} +{"name": "фон Брюль Ада Корнелия Сезар Мартин, к. м. н.", "order": "FAMILY_FIRST", "given": "к.", "middle": "м. н.", "family": "фон Брюль Ада Корнелия Сезар Мартин", "initials": "к. м. н. ф. Б. А. К. С. М."} +{"name": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин к.м.н.", "order": "FAMILY_FIRST", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин", "family": "фон Брюль проф.", "suffix": "к.м.н.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "А. К. С. М. ф. Б. п."} +{"name": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин кесе, м. ф. д.", "order": "FAMILY_FIRST", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин кесе", "family": "фон Брюль проф.", "suffix": "м. ф. д.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. С. М. к. ф. Б. п."} +{"name": "фон Вольф Гонзалес Домінго Анастасія Єва Софія, д-р, проф.", "title": "д-р проф.", "given": "фон", "middle": "Вольф Гонзалес Домінго Анастасія Єва", "family": "Софія", "initials": "ф. В. Г. Д. А. Є. С."} +{"name": "хосе и мария сантос", "given": "хосе и мария", "family": "сантос", "initials": "х. м. с."} +{"name": "Қасым Айдар", "order": "FAMILY_FIRST", "given": "Айдар", "family": "Қасым", "initials": "А. Қ."} +{"name": "Қасым, Айдар", "given": "Айдар", "family": "Қасым", "initials": "А. Қ."} +{"name": "Әсем Мұхамеджан", "given": "Әсем", "family": "Мұхамеджан", "initials": "Ә. М."} +{"name": "Ադամյան Հեղինե", "order": "FAMILY_FIRST", "given": "Հեղինե", "family": "Ադամյան", "initials": "Հ. Ա."} +{"name": "Արթուր", "given": "Արթուր", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ա."} +{"name": "Արթուր", "order": "FAMILY_FIRST", "family": "Արթուր", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ա."} +{"name": "Դարբինյան Հովհաննես", "order": "FAMILY_FIRST", "given": "Հովհաննես", "family": "Դարբինյան", "initials": "Հ. Դ."} +{"name": "Դարբինյան Հովհաննես Արմենի", "order": "FAMILY_FIRST", "given": "Հովհաննես", "middle": "Արմենի", "family": "Դարբինյան", "initials": "Հ. Ա. Դ."} +{"name": "Հեղինե Ադամյան", "given": "Հեղինե", "family": "Ադամյան", "initials": "Հ. Ա."} +{"name": "Հովհաննես Արմենի Դարբինյան", "given": "Հովհաննես", "middle": "Արմենի", "family": "Դարբինյան", "initials": "Հ. Ա. Դ."} +{"name": "Մյուլեր Քեյթ", "order": "FAMILY_FIRST", "given": "Քեյթ", "family": "Մյուլեր", "initials": "Ք. Մ."} +{"name": "Շտյոբեր Ցեցիլիա", "order": "FAMILY_FIRST", "given": "Ցեցիլիա", "family": "Շտյոբեր", "initials": "Ց. Շ."} +{"name": "Շտյոբեր Ցեցիլիա Համիշ", "order": "FAMILY_FIRST", "given": "Ցեցիլիա", "middle": "Համիշ", "family": "Շտյոբեր", "initials": "Ց. Հ. Շ."} +{"name": "Սինբադ", "given": "Սինբադ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ս."} +{"name": "Սինբադ", "order": "FAMILY_FIRST", "family": "Սինբադ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ս."} +{"name": "Վոլֆ Ադա Կոռնելիա", "order": "FAMILY_FIRST", "given": "Ադա", "middle": "Կոռնելիա", "family": "Վոլֆ", "initials": "Ա. Կ. Վ."} +{"name": "Տեր-Պողոսյան Հայկ", "order": "FAMILY_FIRST", "given": "Հայկ", "family": "Տեր-Պողոսյան", "initials": "Հ. Տ."} +{"name": "Ցեցիլիա Համիշ Շտյոբեր", "given": "Ցեցիլիա", "middle": "Համիշ", "family": "Շտյոբեր", "initials": "Ց. Հ. Շ."} +{"name": "Քեյթ Մյուլեր", "given": "Քեյթ", "family": "Մյուլեր", "initials": "Ք. Մ."} +{"name": "բ․գ․դ․ Տեր-Պողոսյան Հայկ Պարգևի", "order": "FAMILY_FIRST", "given": "Տեր-Պողոսյան", "middle": "Հայկ Պարգևի", "family": "բ․գ․դ․", "initials": "Տ. Հ. Պ. բ."} +{"name": "բ․գ․դ․ դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն Վոլֆ", "given": "բ․գ․դ․", "middle": "դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն", "family": "Վոլֆ", "initials": "բ. դ. Ա. Կ. Ս. Մ. վ. դ. Վ."} +{"name": "բ․գ․դ․ պրն Հայկ Պարգևի Տեր-Պողոսյան", "given": "բ․գ․դ․", "middle": "պրն Հայկ Պարգևի", "family": "Տեր-Պողոսյան", "initials": "բ. պ. Հ. Պ. Տ."} +{"name": "բ․գ․դ․ վան դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին", "order": "FAMILY_FIRST", "given": "վան", "middle": "դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին", "family": "բ․գ․դ․", "initials": "վ. դ. Վ. Ա. Կ. Ս. Մ. բ."} +{"name": "אבן אבן-גבירול מר בנימין משה ח״כ", "order": "FAMILY_FIRST", "given": "אבן-גבירול", "middle": "מר בנימין משה ח״כ", "family": "אבן", "initials": "א. מ. ב. מ. ח. א."} +{"name": "דנית לוי", "given": "דנית", "family": "לוי", "initials": "ד. ל."} +{"name": "זזיליה חמיש סטובר", "given": "זזיליה", "middle": "חמיש", "family": "סטובר", "initials": "ז. ח. ס."} +{"name": "יונתן", "given": "יונתן", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "י."} +{"name": "יונתן", "order": "FAMILY_FIRST", "family": "יונתן", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "י."} +{"name": "יונתן חיים כהן", "given": "יונתן", "middle": "חיים", "family": "כהן", "initials": "י. ח. כ."} +{"name": "כהן יונתן חיים", "order": "FAMILY_FIRST", "given": "יונתן", "middle": "חיים", "family": "כהן", "initials": "י. ח. כ."} +{"name": "לוי דנית", "order": "FAMILY_FIRST", "given": "דנית", "family": "לוי", "initials": "ד. ל."} +{"name": "מולר קתה", "order": "FAMILY_FIRST", "given": "קתה", "family": "מולר", "initials": "ק. מ."} +{"name": "מר בנימין משה אבן אבן-גבירול ח״כ", "title": "מר", "given": "בנימין", "middle": "משה אבן אבן-גבירול", "family": "ח״כ", "initials": "ב. מ. א. א. ח."} +{"name": "סטובר זזיליה חמיש", "order": "FAMILY_FIRST", "given": "זזיליה", "middle": "חמיש", "family": "סטובר", "initials": "ז. ח. ס."} +{"name": "סינבד", "given": "סינבד", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ס."} +{"name": "סינבד", "order": "FAMILY_FIRST", "family": "סינבד", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ס."} +{"name": "פון ברוהל גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "order": "FAMILY_FIRST", "given": "ברוהל", "middle": "גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "family": "פון", "initials": "ב. ג. ד. פ. ע. ק. ס. מ. ד. פ."} +{"name": "פון ברוהל גונזלס דומינגו, פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "title": "פרופ׳", "given": "עדה", "middle": "קורנליה סזאר מרטין ד״ר", "family": "פון ברוהל גונזלס דומינגו", "initials": "ע. ק. ס. מ. ד. פ. ב. ג. ד."} +{"name": "פרופ׳ עדה קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו ד״ר", "title": "פרופ׳", "given": "עדה", "middle": "קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו", "family": "ד״ר", "initials": "ע. ק. ס. מ. פ. ב. ג. ד. ד."} +{"name": "קתה מולר", "given": "קתה", "family": "מולר", "initials": "ק. מ."} +{"name": "آئرين ائڊلر", "given": "آئرين", "family": "ائڊلر", "initials": "آ. ا."} +{"name": "آقای امیر هوشنگ رستگار دکتر", "given": "آقای", "middle": "امیر هوشنگ رستگار", "family": "دکتر", "initials": "آ. ا. ه. ر. د."} +{"name": "آیتین عدلو", "given": "آیتین", "family": "عدلو", "initials": "آ. ع."} +{"name": "أبو الأغا، أحمد رامي نجيب محفوظ", "order": "FAMILY_FIRST", "given": "أحمد", "middle": "رامي نجيب محفوظ", "family": "أبو الأغا", "initials": "أ. ر. ن. م. ا."} +{"name": "ائڊلر آئرين", "order": "FAMILY_FIRST", "given": "آئرين", "family": "ائڊلر", "initials": "آ. ا."} +{"name": "احمد", "given": "احمد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ا."} +{"name": "احمد", "order": "FAMILY_FIRST", "family": "احمد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ا."} +{"name": "ادا كورنەليا فون بريۋل جۋنيور، ءتىس دارىگەرى", "given": "ءتىس", "middle": "دارىگەرى", "family": "ادا كورنەليا فون بريۋل جۋنيور", "initials": "ء. د. ا. ك. ف. ب. ج."} +{"name": "ارین اډلر", "given": "ارین", "family": "اډلر", "initials": "ا. ا."} +{"name": "استوبر، زازیلیا هایمیش", "order": "FAMILY_FIRST", "given": "زازیلیا", "middle": "هایمیش", "family": "استوبر", "initials": "ز. ه. ا."} +{"name": "اسٽوبر زيزيليا ھئمش", "order": "FAMILY_FIRST", "given": "زيزيليا", "middle": "ھئمش", "family": "اسٽوبر", "initials": "ز. ھ. ا."} +{"name": "اسەم", "given": "اسەم", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ا."} +{"name": "اسەم", "order": "FAMILY_FIRST", "family": "اسەم", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ا."} +{"name": "اصغر شازیہ", "order": "FAMILY_FIRST", "given": "شازیہ", "family": "اصغر", "initials": "ش. ا."} +{"name": "اصغر، شازیہ", "order": "FAMILY_FIRST", "given": "شازیہ", "family": "اصغر", "initials": "ش. ا."} +{"name": "الدكتور عايدة كورنيليا سيزار مارتن فون برول الإبن، طبيب وجراح", "given": "طبيب", "middle": "وجراح", "family": "الدكتور عايدة كورنيليا سيزار مارتن فون برول الإبن", "initials": "ط. و. ا. ع. ك. س. م. ف. ب. ا."} +{"name": "السيد أحمد رامي نجيب محفوظ أبو الأغا الابن، النائب", "given": "النائب", "family": "السيد أحمد رامي نجيب محفوظ أبو الأغا الابن", "initials": "ا. ا. أ. ر. ن. م. ا. ا."} +{"name": "العامر، كمال مجدي", "order": "FAMILY_FIRST", "given": "كمال", "middle": "مجدي", "family": "العامر", "initials": "ك. م. ا."} +{"name": "النجار، سميرة", "order": "FAMILY_FIRST", "given": "سميرة", "family": "النجار", "initials": "س. ا."} +{"name": "امید رضا ملایری", "given": "امید", "middle": "رضا", "family": "ملایری", "initials": "ا. ر. م."} +{"name": "اډلر ارین", "order": "FAMILY_FIRST", "given": "ارین", "family": "اډلر", "initials": "ا. ا."} +{"name": "بالقىباي ماقسات", "order": "FAMILY_FIRST", "given": "ماقسات", "family": "بالقىباي", "initials": "م. ب."} +{"name": "بالقىباي، ماقسات توقماحامبەتۇلى", "order": "FAMILY_FIRST", "given": "ماقسات", "middle": "توقماحامبەتۇلى", "family": "بالقىباي", "initials": "م. ت. ب."} +{"name": "بروهل، آدا کورنلیا سزار مارتین وان", "order": "FAMILY_FIRST", "given": "آدا", "middle": "کورنلیا سزار مارتین وان", "family": "بروهل", "initials": "آ. ک. س. م. و. ب."} +{"name": "بريۋل، ادا كورنەليا سەزار مارتين فون", "order": "FAMILY_FIRST", "given": "ادا", "middle": "كورنەليا سەزار مارتين فون", "family": "بريۋل", "initials": "ا. ك. س. م. ف. ب."} +{"name": "جان همیش واټسن", "given": "جان", "middle": "همیش", "family": "واټسن", "initials": "ج. ه. و."} +{"name": "حاتم منصور", "given": "حاتم", "family": "منصور", "initials": "ح. م."} +{"name": "خان مریم نواز", "order": "FAMILY_FIRST", "given": "مریم", "middle": "نواز", "family": "خان", "initials": "م. ن. خ."} +{"name": "خان، مریم نواز", "order": "FAMILY_FIRST", "given": "مریم", "middle": "نواز", "family": "خان", "initials": "م. ن. خ."} +{"name": "رستگار، آقای امیر هوشنگ دکتر", "order": "FAMILY_FIRST", "given": "آقای", "middle": "امیر هوشنگ دکتر", "family": "رستگار", "initials": "آ. ا. ه. د. ر."} +{"name": "رستگار، امیر هوشنگ", "order": "FAMILY_FIRST", "given": "امیر", "middle": "هوشنگ", "family": "رستگار", "initials": "ا. ه. ر."} +{"name": "زازیلیا هایمیش استوبر", "given": "زازیلیا", "middle": "هایمیش", "family": "استوبر", "initials": "ز. ه. ا."} +{"name": "زيزيليا ھئمش اسٽوبر", "given": "زيزيليا", "middle": "ھئمش", "family": "اسٽوبر", "initials": "ز. ھ. ا."} +{"name": "زيزينيا هاميش ستوبر", "given": "زيزينيا", "middle": "هاميش", "family": "ستوبر", "initials": "ز. ه. س."} +{"name": "زیزیلیا همیش سټوبر", "given": "زیزیلیا", "middle": "همیش", "family": "سټوبر", "initials": "ز. ه. س."} +{"name": "سامانتا", "given": "سامانتا", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سامانتا", "order": "FAMILY_FIRST", "family": "سامانتا", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "ستوبر، زيزينيا هاميش", "order": "FAMILY_FIRST", "given": "زيزينيا", "middle": "هاميش", "family": "ستوبر", "initials": "ز. ه. س."} +{"name": "سلمان, محمد", "given": "محمد", "family": "سلمان", "initials": "م. س."} +{"name": "سلمان،", "given": "سلمان", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سلمان، محمد", "given": "محمد", "family": "سلمان", "initials": "م. س."} +{"name": "سميرة النجار", "given": "سميرة", "family": "النجار", "initials": "س. ا."} +{"name": "سنباد", "given": "سنباد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سنباد", "order": "FAMILY_FIRST", "family": "سنباد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سندباد", "given": "سندباد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سندباد", "order": "FAMILY_FIRST", "family": "سندباد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سينباد", "given": "سينباد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سينباد", "order": "FAMILY_FIRST", "family": "سينباد", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "س."} +{"name": "سټوبر زیزیلیا همیش", "order": "FAMILY_FIRST", "given": "زیزیلیا", "middle": "همیش", "family": "سټوبر", "initials": "ز. ه. س."} +{"name": "سید عبد الکریم عبد الحمید ایم پی", "order": "FAMILY_FIRST", "given": "عبد", "middle": "الکریم عبد الحمید ایم پی", "family": "سید", "initials": "ع. ا. ع. ا. ا. پ. س."} +{"name": "سید، عبد الکریم عبد الحمید ایم پی", "order": "FAMILY_FIRST", "given": "عبد الکریم", "middle": "عبد الحمید ایم پی", "family": "سید", "initials": "ع. ا. ع. ا. ا. پ. س."} +{"name": "سەسيليا شتوبەر", "given": "سەسيليا", "family": "شتوبەر", "initials": "س. ش."} +{"name": "شازیہ اصغر", "given": "شازیہ", "family": "اصغر", "initials": "ش. ا."} +{"name": "شتوبەر سەسيليا", "order": "FAMILY_FIRST", "given": "سەسيليا", "family": "شتوبەر", "initials": "س. ش."} +{"name": "شتوبەر، سەسيليا حەمىش", "order": "FAMILY_FIRST", "given": "سەسيليا", "middle": "حەمىش", "family": "شتوبەر", "initials": "س. ح. ش."} +{"name": "صابری محمد ہاشم", "order": "FAMILY_FIRST", "given": "محمد", "middle": "ہاشم", "family": "صابری", "initials": "م. ہ. ص."} +{"name": "صابری، محمد ہاشم", "order": "FAMILY_FIRST", "given": "محمد", "middle": "ہاشم", "family": "صابری", "initials": "م. ہ. ص."} +{"name": "عبد الکریم عبد الحمید سید ایم پی", "given": "عبد الکریم", "middle": "عبد الحمید سید ایم", "family": "پی", "initials": "ع. ا. ع. ا. س. ا. پ."} +{"name": "عدلو، آیتین", "order": "FAMILY_FIRST", "given": "آیتین", "family": "عدلو", "initials": "آ. ع."} +{"name": "فون برول، عايدة كورنيليا سيزار مارتن", "order": "FAMILY_FIRST", "given": "عايدة", "middle": "كورنيليا سيزار مارتن", "family": "فون برول", "initials": "ع. ك. س. م. ف. ب."} +{"name": "فون بريۋل ادا كورنەليا", "order": "FAMILY_FIRST", "given": "بريۋل", "middle": "ادا كورنەليا", "family": "فون", "initials": "ب. ا. ك. ف."} +{"name": "قالاجان قايرات", "given": "قالاجان", "family": "قايرات", "initials": "ق. ق."} +{"name": "قايرات قالاجان", "order": "FAMILY_FIRST", "given": "قالاجان", "family": "قايرات", "initials": "ق. ق."} +{"name": "قايرات، قالاجان", "order": "FAMILY_FIRST", "given": "قالاجان", "family": "قايرات", "initials": "ق. ق."} +{"name": "كاثرين مولر", "given": "كاثرين", "family": "مولر", "initials": "ك. م."} +{"name": "كارىباي مۇرات", "order": "FAMILY_FIRST", "given": "مۇرات", "family": "كارىباي", "initials": "م. ك."} +{"name": "كارىباي، مۇرات", "order": "FAMILY_FIRST", "given": "مۇرات", "family": "كارىباي", "initials": "م. ك."} +{"name": "كمال مجدي العامر", "given": "كمال", "middle": "مجدي", "family": "العامر", "initials": "ك. م. ا."} +{"name": "كەيت مۇللەر", "given": "كەيت", "family": "مۇللەر", "initials": "ك. م."} +{"name": "ماقسات بالقىباي", "given": "ماقسات", "family": "بالقىباي", "initials": "م. ب."} +{"name": "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم پي", "given": "محترم", "middle": "برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم", "family": "پي", "initials": "م. ب. و. ھ. ر. و. ا. پ."} +{"name": "محمد بن سلمان", "given": "محمد", "family": "بن سلمان", "initials": "م. س."} +{"name": "محمد و علي", "given": "محمد", "middle": "و", "family": "علي", "initials": "م. و. ع."} +{"name": "محمد ہاشم صابری", "given": "محمد", "middle": "ہاشم", "family": "صابری", "initials": "م. ہ. ص."} +{"name": "مریم نواز خان", "given": "مریم", "middle": "نواز", "family": "خان", "initials": "م. ن. خ."} +{"name": "ملایری، امید رضا", "order": "FAMILY_FIRST", "given": "امید", "middle": "رضا", "family": "ملایری", "initials": "ا. ر. م."} +{"name": "ملر ڪئٿي", "order": "FAMILY_FIRST", "given": "ڪئٿي", "family": "ملر", "initials": "ڪ. م."} +{"name": "منصور حاتم", "order": "FAMILY_FIRST", "given": "حاتم", "family": "منصور", "initials": "ح. م."} +{"name": "منصور، حاتم", "order": "FAMILY_FIRST", "given": "حاتم", "family": "منصور", "initials": "ح. م."} +{"name": "منير", "given": "منير", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "م."} +{"name": "منير", "order": "FAMILY_FIRST", "family": "منير", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "م."} +{"name": "مولر کیتی", "order": "FAMILY_FIRST", "given": "کیتی", "family": "مولر", "initials": "ک. م."} +{"name": "مولر، كاثرين", "order": "FAMILY_FIRST", "given": "كاثرين", "family": "مولر", "initials": "ك. م."} +{"name": "مولر، کاتیا", "order": "FAMILY_FIRST", "given": "کاتیا", "family": "مولر", "initials": "ک. م."} +{"name": "ميري سيو ھامش واٽسن", "given": "ميري", "middle": "سيو ھامش", "family": "واٽسن", "initials": "م. س. ھ. و."} +{"name": "مۇرات كارىباي", "given": "مۇرات", "family": "كارىباي", "initials": "م. ك."} +{"name": "مۇللەر كەيت", "order": "FAMILY_FIRST", "given": "كەيت", "family": "مۇللەر", "initials": "ك. م."} +{"name": "مۇللەر، كەيت", "order": "FAMILY_FIRST", "given": "كەيت", "family": "مۇللەر", "initials": "ك. م."} +{"name": "ندیم رضا یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "order": "FAMILY_FIRST", "given": "رضا", "middle": "یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "family": "ندیم", "initials": "ر. ی. ر. س. م. ا. ڈ. ڈ. ڈ. ا. ن."} +{"name": "ندیم رضا، یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "order": "FAMILY_FIRST", "given": "یاسمین", "middle": "راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "family": "ندیم رضا", "initials": "ی. ر. س. م. ا. ڈ. ڈ. ڈ. ا. ن. ر."} +{"name": "وان برل گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "order": "FAMILY_FIRST", "given": "برل", "middle": "گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "family": "وان", "initials": "ب. گ. ڊ. پ. ڊ. ا. ڪ. س. م. ا. ڊ. ڊ. ڊ. ا. و."} +{"name": "وان برل گونزالس ڊومنگو, پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "given": "پروفيسر", "middle": "ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "family": "وان برل گونزالس ڊومنگو", "initials": "پ. ڊ. ا. ڪ. س. م. ا. ڊ. ڊ. ڊ. ا. و. ب. گ. ڊ."} +{"name": "وان بروهل، پروفسور آدا کورنلیا سزار مارتین جونیور، دکترای دندانپزشکی", "order": "FAMILY_FIRST", "given": "پروفسور", "middle": "آدا کورنلیا سزار مارتین جونیور", "family": "وان بروهل", "suffix": "دکترای دندانپزشکی", "ambiguities": ["COMMA_STRUCTURE"], "initials": "پ. آ. ک. س. م. ج. و. ب."} +{"name": "واټسن جان همیش", "order": "FAMILY_FIRST", "given": "جان", "middle": "همیش", "family": "واټسن", "initials": "ج. ه. و."} +{"name": "واٽسن ميري سيو ھامش", "order": "FAMILY_FIRST", "given": "ميري", "middle": "سيو ھامش", "family": "واٽسن", "initials": "م. س. ھ. و."} +{"name": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی", "order": "FAMILY_FIRST", "given": "ښاغلی.", "middle": "برټرام ولبرفورس هنري رابرټ ایم پی", "family": "ووسټر", "initials": "ښ. ب. و. ه. ر. ا. پ. و."} +{"name": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي", "order": "FAMILY_FIRST", "given": "محترم", "middle": "برٽرم ولبرفورس ھينري رابرٽ ايم پي", "family": "ووسٽر", "initials": "م. ب. و. ھ. ر. ا. پ. و."} +{"name": "وون لیوه بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "order": "FAMILY_FIRST", "given": "لیوه", "middle": "بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "family": "وون", "initials": "ل. ب. ش. پ. ډ. ا. ک. ا. س. ا. ‌. ډ. ډ. ا. و."} +{"name": "وون لیوه بیکر شمیت, پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "given": "پروفیسور", "middle": "ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "family": "وون لیوه بیکر شمیت", "initials": "پ. ډ. ا. ک. ا. س. ا. ‌. ډ. ډ. ا. و. ل. ب. ش."} +{"name": "پروفسور آدا کورنلیا سزار مارتین وان بروهل جونیور، دکترای دندانپزشکی", "given": "دکترای", "middle": "دندانپزشکی", "family": "پروفسور آدا کورنلیا سزار مارتین وان بروهل جونیور", "initials": "د. د. پ. آ. ک. س. م. و. ب. ج."} +{"name": "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي ايس", "given": "پروفيسر", "middle": "ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي", "family": "ايس", "initials": "پ. ڊ. ا. ڪ. س. م. و. ب. گ. ڊ. ا. ڊ. ڊ. ڊ. ا."} +{"name": "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی ایس", "given": "پروفیسور", "middle": "ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی", "family": "ایس", "initials": "پ. ډ. ا. ک. ا. س. و. ل. ب. ش. ا. ‌. ډ. ډ. ا."} +{"name": "ژیلا", "given": "ژیلا", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ژ."} +{"name": "ژیلا", "order": "FAMILY_FIRST", "family": "ژیلا", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ژ."} +{"name": "ښاغلی. برټرام ولبرفورس هنري رابرټ ووسټر ایم پی", "title": "ښاغلی.", "given": "برټرام", "middle": "ولبرفورس هنري رابرټ ووسټر ایم", "family": "پی", "initials": "ب. و. ه. ر. و. ا. پ."} +{"name": "کاتیا مولر", "given": "کاتیا", "family": "مولر", "initials": "ک. م."} +{"name": "کیتی مولر", "given": "کیتی", "family": "مولر", "initials": "ک. م."} +{"name": "ڪئٿي ملر", "given": "ڪئٿي", "family": "ملر", "initials": "ڪ. م."} +{"name": "یاسمین راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی ایس", "given": "یاسمین", "middle": "راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی", "family": "ایس", "initials": "ی. ر. س. م. ن. ر. ا. ڈ. ڈ. ڈ. ا."} +{"name": "आलिया हमीश हेमंत", "given": "आलिया", "middle": "हमीश", "family": "हेमंत", "initials": "आ. ह. ह."} +{"name": "एडा कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी डिडिएस", "given": "एडा", "middle": "कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी", "family": "डिडिएस", "initials": "ए. क. स. म. भ. ब. ए. ड."} +{"name": "काठे मुलर", "given": "काठे", "family": "मुलर", "initials": "क. म."} +{"name": "काथा बाउमान", "given": "काथा", "family": "बाउमान", "initials": "क. ब."} +{"name": "कीथ ब्राउन", "given": "कीथ", "family": "ब्राउन", "initials": "क. ब."} +{"name": "क्याथी मुलर", "given": "क्याथी", "family": "मुलर", "initials": "क. म."} +{"name": "गांवकर शांति", "order": "FAMILY_FIRST", "given": "शांति", "family": "गांवकर", "initials": "श. ग."} +{"name": "गांवकर, शांति", "given": "शांति", "family": "गांवकर", "initials": "श. ग."} +{"name": "जाजिला हामिश स्टोबा", "given": "जाजिला", "middle": "हामिश", "family": "स्टोबा", "initials": "ज. ह. स."} +{"name": "जाजिला हार्निस स्टोबर", "given": "जाजिला", "middle": "हार्निस", "family": "स्टोबर", "initials": "ज. ह. स."} +{"name": "जोनस मैरी सिलविया", "order": "FAMILY_FIRST", "given": "मैरी", "middle": "सिलविया", "family": "जोनस", "initials": "म. स. ज."} +{"name": "जोनस, मैरी सिलविया", "given": "मैरी", "middle": "सिलविया", "family": "जोनस", "initials": "म. स. ज."} +{"name": "दिशा", "given": "दिशा", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "द."} +{"name": "दिशा", "order": "FAMILY_FIRST", "family": "दिशा", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "द."} +{"name": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा., एमपी", "order": "FAMILY_FIRST", "given": "एमपी", "family": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा.", "initials": "ए. द. स. ए. म. श. फ. श. ज. आ. ध."} +{"name": "देशपांडे धिरज", "order": "FAMILY_FIRST", "given": "धिरज", "family": "देशपांडे", "initials": "ध. द."} +{"name": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क., एम. पी.", "order": "FAMILY_FIRST", "title": "एम.", "given": "पी.", "family": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क.", "initials": "प. द. श. ध. द. व. द. क."} +{"name": "देशपांडे, धिरज", "given": "धिरज", "family": "देशपांडे", "initials": "ध. द."} +{"name": "देशपांडे, धिरज देशपांडे विजय देशपांडे", "given": "धिरज", "middle": "देशपांडे विजय देशपांडे", "family": "देशपांडे", "initials": "ध. द. व. द. द."} +{"name": "धिरज देशपांडे", "given": "धिरज", "family": "देशपांडे", "initials": "ध. द."} +{"name": "पोख्रेल रमिता", "order": "FAMILY_FIRST", "given": "रमिता", "family": "पोख्रेल", "initials": "र. प."} +{"name": "पोख्रेल, रमिता", "given": "रमिता", "family": "पोख्रेल", "initials": "र. प."} +{"name": "प्रेमप्रसाद हिमलाल वास्तोला", "given": "प्रेमप्रसाद", "middle": "हिमलाल", "family": "वास्तोला", "initials": "प. ह. व."} +{"name": "प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर, बी.ए. एल.एल.बी", "given": "बी.ए.", "middle": "एल.एल.बी", "family": "प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर", "initials": "ब. ए. प. ड. आ. क. स. म. व. ब. ग. ड. ज."} +{"name": "प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु., एम. डी. डी. डी. एस.", "title": "एम.", "given": "डी.", "middle": "डी. डी. एस.", "family": "प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु.", "initials": "ड. ड. ड. ए. प. अ. श. स. म. व. ब. ज."} +{"name": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर, एम॰डी॰, पी॰एच॰डी॰", "given": "एम॰डी॰", "family": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर", "suffix": "पी॰एच॰डी॰", "ambiguities": ["COMMA_STRUCTURE"], "initials": "ए. प. ड. म. ज. व. व. च."} +{"name": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन, एम॰डी॰, पी॰एच॰डी॰", "order": "FAMILY_FIRST", "given": "एम॰डी॰", "family": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन", "suffix": "पी॰एच॰डी॰", "ambiguities": ["COMMA_STRUCTURE"], "initials": "ए. प. ड. व. व. म. ज."} +{"name": "फडके रिधान समीर", "order": "FAMILY_FIRST", "given": "रिधान", "middle": "समीर", "family": "फडके", "initials": "र. स. फ."} +{"name": "फडके, रिधान समीर", "given": "रिधान", "middle": "समीर", "family": "फडके", "initials": "र. स. फ."} +{"name": "फर्नॅन्डेज मारिया हेलेना", "order": "FAMILY_FIRST", "given": "मारिया", "middle": "हेलेना", "family": "फर्नॅन्डेज", "initials": "म. ह. फ."} +{"name": "फर्नॅन्डेज, मारिया हेलेना", "given": "मारिया", "middle": "हेलेना", "family": "फर्नॅन्डेज", "initials": "म. ह. फ."} +{"name": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट वुस्टर एमपी", "given": "बर्ट्राम", "middle": "विलबरफोर्स हेनरी रोबर्ट वुस्टर", "family": "एमपी", "initials": "ब. व. ह. र. व. ए."} +{"name": "बाउमान काथा", "order": "FAMILY_FIRST", "given": "काथा", "family": "बाउमान", "initials": "क. ब."} +{"name": "बाउमान, काथा", "given": "काथा", "family": "बाउमान", "initials": "क. ब."} +{"name": "ब्राउन कीथ", "order": "FAMILY_FIRST", "given": "कीथ", "family": "ब्राउन", "initials": "क. ब."} +{"name": "ब्राउन, कीथ", "given": "कीथ", "family": "ब्राउन", "initials": "क. ब."} +{"name": "ब्रुहल, एडा कर्नेलिया सिजर मार्टिन भोन", "given": "एडा", "middle": "कर्नेलिया सिजर मार्टिन भोन", "family": "ब्रुहल", "initials": "ए. क. स. म. भ. ब."} +{"name": "ब्रूह्ल, आडा कोर्नेलिया सेजार मार्टिन वॉन", "given": "आडा", "middle": "कोर्नेलिया सेजार मार्टिन वॉन", "family": "ब्रूह्ल", "initials": "आ. क. स. म. व. ब."} +{"name": "ब्र्यूल, अदा शर्मा सीझर मार्टिन व्हॉन", "given": "अदा", "middle": "शर्मा सीझर मार्टिन व्हॉन", "family": "ब्र्यूल", "initials": "अ. श. स. म. व. ब."} +{"name": "भोन ब्रुहल एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस", "order": "FAMILY_FIRST", "given": "ब्रुहल", "middle": "एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस", "family": "भोन", "initials": "ब. ए. क. स. म. ए. ड. भ."} +{"name": "माधुरी", "given": "माधुरी", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "म."} +{"name": "माधुरी", "order": "FAMILY_FIRST", "family": "माधुरी", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "म."} +{"name": "मारिया हेलेना फर्नॅन्डेज", "given": "मारिया", "middle": "हेलेना", "family": "फर्नॅन्डेज", "initials": "म. ह. फ."} +{"name": "मुलर काठे", "order": "FAMILY_FIRST", "given": "काठे", "family": "मुलर", "initials": "क. म."} +{"name": "मुलर क्याथी", "order": "FAMILY_FIRST", "given": "क्याथी", "family": "मुलर", "initials": "क. म."} +{"name": "मुलर, काठे", "given": "काठे", "family": "मुलर", "initials": "क. म."} +{"name": "मुलर, क्याथी", "given": "क्याथी", "family": "मुलर", "initials": "क. म."} +{"name": "मैरी सिलविया जोनस", "given": "मैरी", "middle": "सिलविया", "family": "जोनस", "initials": "म. स. ज."} +{"name": "रमिता पोख्रेल", "given": "रमिता", "family": "पोख्रेल", "initials": "र. प."} +{"name": "रिधान समीर फडके", "given": "रिधान", "middle": "समीर", "family": "फडके", "initials": "र. स. फ."} +{"name": "रॉबर्ट", "given": "रॉबर्ट", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "र."} +{"name": "रॉबर्ट", "order": "FAMILY_FIRST", "family": "रॉबर्ट", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "र."} +{"name": "ललित", "given": "ललित", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ल."} +{"name": "ललित", "order": "FAMILY_FIRST", "family": "ललित", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ल."} +{"name": "ललित मोहन शर्मा", "given": "ललित", "middle": "मोहन", "family": "शर्मा", "initials": "ल. म. श."} +{"name": "ललित शर्मा", "given": "ललित", "family": "शर्मा", "initials": "ल. श."} +{"name": "वास्तोला प्रेमप्रसाद हिमलाल", "order": "FAMILY_FIRST", "given": "प्रेमप्रसाद", "middle": "हिमलाल", "family": "वास्तोला", "initials": "प. ह. व."} +{"name": "वास्तोला, प्रेमप्रसाद हिमलाल", "given": "प्रेमप्रसाद", "middle": "हिमलाल", "family": "वास्तोला", "initials": "प. ह. व."} +{"name": "वुस्टर बर्ट्राम विलबरफोर्स हेनरी रोबर्ट एमपी", "order": "FAMILY_FIRST", "given": "बर्ट्राम", "middle": "विलबरफोर्स हेनरी रोबर्ट एमपी", "family": "वुस्टर", "initials": "ब. व. ह. र. ए. व."} +{"name": "वुस्टर, बर्ट्राम विलबरफोर्स हेनरी रोबर्ट", "given": "बर्ट्राम", "middle": "विलबरफोर्स हेनरी रोबर्ट", "family": "वुस्टर", "initials": "ब. व. ह. र. व."} +{"name": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर, बी.ए. एल.एल.बी", "order": "FAMILY_FIRST", "given": "बी.ए.", "middle": "एल.एल.बी", "family": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर", "initials": "ब. ए. व. ब. ग. ड. प. ड. आ. क. स. म. ज."} +{"name": "वॉन वॉटसन, माइकल जॉन एम॰डी॰, पी॰एच॰डी॰", "given": "माइकल", "middle": "जॉन एम॰डी॰", "family": "वॉन वॉटसन", "suffix": "पी॰एच॰डी॰", "ambiguities": ["COMMA_STRUCTURE"], "initials": "म. ज. ए. व. व."} +{"name": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु., एम. डी. डी. डी. एस.", "order": "FAMILY_FIRST", "title": "एम.", "given": "डी.", "middle": "डी. डी. एस.", "family": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु.", "initials": "ड. ड. ड. ए. व. ब. प. अ. श. स. म. ज."} +{"name": "शर्मा ललित", "order": "FAMILY_FIRST", "given": "ललित", "family": "शर्मा", "initials": "ल. श."} +{"name": "शर्मा ललित मोहन", "order": "FAMILY_FIRST", "given": "ललित", "middle": "मोहन", "family": "शर्मा", "initials": "ल. म. श."} +{"name": "शर्मा, ललित", "given": "ललित", "family": "शर्मा", "initials": "ल. श."} +{"name": "शर्मा, ललित कुमार एम॰डी॰", "given": "ललित", "middle": "कुमार एम॰डी॰", "family": "शर्मा", "initials": "ल. क. ए. श."} +{"name": "शर्मा, ललित मोहन", "given": "ललित", "middle": "मोहन", "family": "शर्मा", "initials": "ल. म. श."} +{"name": "शांति गांवकर", "given": "शांति", "family": "गांवकर", "initials": "श. ग."} +{"name": "श्री फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा., एमपी", "given": "एमपी", "family": "श्री फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा.", "initials": "ए. श. फ. श. ज. आ. द. स. ए. म. ध."} +{"name": "श्री ललित कुमार शर्मा एम॰डी॰", "title": "श्री", "given": "ललित", "middle": "कुमार शर्मा", "family": "एम॰डी॰", "initials": "ल. क. श. ए."} +{"name": "श्री शर्मा ललित कुमार, एम॰डी॰", "order": "FAMILY_FIRST", "given": "एम॰डी॰", "family": "श्री शर्मा ललित कुमार", "initials": "ए. श. श. ल. क."} +{"name": "श्री. धिरज देशपांडे विजय देशपांडे देशपांडे क., एम. पी.", "title": "एम.", "given": "पी.", "family": "श्री. धिरज देशपांडे विजय देशपांडे देशपांडे क.", "initials": "प. श. ध. द. व. द. द. क."} +{"name": "सिंदबाद", "given": "सिंदबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "स."} +{"name": "सिंदबाद", "order": "FAMILY_FIRST", "family": "सिंदबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "स."} +{"name": "सिनबाद", "given": "सिनबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "स."} +{"name": "सिनबाद", "order": "FAMILY_FIRST", "family": "सिनबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "स."} +{"name": "सुन्दर", "given": "सुन्दर", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "स."} +{"name": "सुन्दर", "order": "FAMILY_FIRST", "family": "सुन्दर", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "स."} +{"name": "सोजा, फ्रांसिस शावियेर जुआंव आंतोन दे", "given": "फ्रांसिस", "middle": "शावियेर जुआंव आंतोन दे", "family": "सोजा", "initials": "फ. श. ज. आ. द. स."} +{"name": "स्टोबर जाजिला हार्निस", "order": "FAMILY_FIRST", "given": "जाजिला", "middle": "हार्निस", "family": "स्टोबर", "initials": "ज. ह. स."} +{"name": "स्टोबर, जाजिला हार्निस", "given": "जाजिला", "middle": "हार्निस", "family": "स्टोबर", "initials": "ज. ह. स."} +{"name": "स्टोबा जाजिला हामिश", "order": "FAMILY_FIRST", "given": "जाजिला", "middle": "हामिश", "family": "स्टोबा", "initials": "ज. ह. स."} +{"name": "स्टोबा, जाजिला हामिश", "given": "जाजिला", "middle": "हामिश", "family": "स्टोबा", "initials": "ज. ह. स."} +{"name": "हेमंत आलिया हमीश", "order": "FAMILY_FIRST", "given": "आलिया", "middle": "हमीश", "family": "हेमंत", "initials": "आ. ह. ह."} +{"name": "हेमंत, आलिया हमीश", "given": "आलिया", "middle": "हमीश", "family": "हेमंत", "initials": "आ. ह. ह."} +{"name": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি. এছ", "given": "আডা", "middle": "কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি.", "family": "এছ", "initials": "আ. ক. ই. চ. ভ. ড. ক. এ. ড. ড. ড. এ."} +{"name": "আহমেদ কিরণ", "order": "FAMILY_FIRST", "given": "কিরণ", "family": "আহমেদ", "initials": "ক. আ."} +{"name": "কাকতি নৰেন কলিতা ৰুমী বৰুৱা এম. পি.", "order": "FAMILY_FIRST", "given": "নৰেন", "middle": "কলিতা ৰুমী বৰুৱা এম. পি.", "family": "কাকতি", "initials": "ন. ক. ৰ. ব. এ. প. ক."} +{"name": "কাকতি, নৰেন কলিতা ৰুমী বৰুৱা", "given": "নৰেন", "middle": "কলিতা ৰুমী বৰুৱা", "family": "কাকতি", "initials": "ন. ক. ৰ. ব. ক."} +{"name": "কিরণ আহমেদ", "given": "কিরণ", "family": "আহমেদ", "initials": "ক. আ."} +{"name": "কিশোৰ", "given": "কিশোৰ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ক."} +{"name": "কিশোৰ", "order": "FAMILY_FIRST", "family": "কিশোৰ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ক."} +{"name": "কুকুৰনেচীয়া, আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন", "given": "আডা", "middle": "কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন", "family": "কুকুৰনেচীয়া", "initials": "আ. ক. ই. চ. ভ. ড. ক."} +{"name": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি", "order": "FAMILY_FIRST", "given": "জনাব", "middle": "এমদাদ আলি অনিল কুমার এমপি", "family": "গাঙ্গুলী", "initials": "জ. এ. আ. অ. ক. এ. গ."} +{"name": "চৌধুরী শ্রেয়া", "order": "FAMILY_FIRST", "given": "শ্রেয়া", "family": "চৌধুরী", "initials": "শ. চ."} +{"name": "জনাব এমদাদ আলি অনিল কুমার গাঙ্গুলী এমপি", "title": "জনাব", "given": "এমদাদ", "middle": "আলি অনিল কুমার গাঙ্গুলী", "family": "এমপি", "initials": "এ. আ. অ. ক. গ. এ."} +{"name": "জাজিলিয়া হামিশ মুন্সি", "given": "জাজিলিয়া", "middle": "হামিশ", "family": "মুন্সি", "initials": "জ. হ. ম."} +{"name": "জেনদায়া", "given": "জেনদায়া", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "জ."} +{"name": "জেনদায়া", "order": "FAMILY_FIRST", "family": "জেনদায়া", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "জ."} +{"name": "নগেন বৰা", "given": "নগেন", "family": "বৰা", "initials": "ন. ব."} +{"name": "নমিতা বৰুৱা", "given": "নমিতা", "family": "বৰুৱা", "initials": "ন. ব."} +{"name": "নৰেন কলিতা ৰুমী বৰুৱা কাকতি এম. পি.", "given": "নৰেন", "middle": "কলিতা ৰুমী বৰুৱা কাকতি এম.", "family": "পি.", "initials": "ন. ক. ৰ. ব. ক. এ. প."} +{"name": "প্রঃ ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার এম.ডি.পিএইচ.ডি.", "given": "প্রঃ", "middle": "ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার", "family": "এম.ডি.পিএইচ.ডি.", "initials": "প. ড. আ. ই. স. র. আ. ত. এ."} +{"name": "প্রকৌশলী. Sen", "given": "প্রকৌশলী.", "family": "Sen", "initials": "প. S."} +{"name": "বৰা নগেন", "order": "FAMILY_FIRST", "given": "নগেন", "family": "বৰা", "initials": "ন. ব."} +{"name": "বৰা, নগেন", "given": "নগেন", "family": "বৰা", "initials": "ন. ব."} +{"name": "বৰুৱা নমিতা", "order": "FAMILY_FIRST", "given": "নমিতা", "family": "বৰুৱা", "initials": "ন. ব."} +{"name": "বৰুৱা মণিমালা অজিত", "order": "FAMILY_FIRST", "given": "মণিমালা", "middle": "অজিত", "family": "বৰুৱা", "initials": "ম. অ. ব."} +{"name": "বৰুৱা, নমিতা", "given": "নমিতা", "family": "বৰুৱা", "initials": "ন. ব."} +{"name": "বৰুৱা, মণিমালা অজিত", "given": "মণিমালা", "middle": "অজিত", "family": "বৰুৱা", "initials": "ম. অ. ব."} +{"name": "ভেন ডেন কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ", "order": "FAMILY_FIRST", "given": "ডেন", "middle": "কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ", "family": "ভেন", "initials": "ড. ক. আ. ক. ই. চ. এ. ড. ড. ড. এ. ভ."} +{"name": "মণিমালা অজিত বৰুৱা", "given": "মণিমালা", "middle": "অজিত", "family": "বৰুৱা", "initials": "ম. অ. ব."} +{"name": "মুন্সি জাজিলিয়া হামিশ", "order": "FAMILY_FIRST", "given": "জাজিলিয়া", "middle": "হামিশ", "family": "মুন্সি", "initials": "জ. হ. ম."} +{"name": "মেরি সু হামিশ রহমান", "given": "মেরি", "middle": "সু হামিশ", "family": "রহমান", "initials": "ম. স. হ. র."} +{"name": "রহমান আলি তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "order": "FAMILY_FIRST", "given": "আলি", "middle": "তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "family": "রহমান", "initials": "আ. ত. প. ড. আ. ই. স. এ. র."} +{"name": "রহমান আলি তালুকদার, প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "given": "প্রঃ", "middle": "ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "family": "রহমান আলি তালুকদার", "initials": "প. ড. আ. ই. স. এ. র. আ. ত."} +{"name": "রহমান মেরি সু হামিশ", "order": "FAMILY_FIRST", "given": "মেরি", "middle": "সু হামিশ", "family": "রহমান", "initials": "ম. স. হ. র."} +{"name": "শ্রেয়া চৌধুরী", "given": "শ্রেয়া", "family": "চৌধুরী", "initials": "শ. চ."} +{"name": "শৰ্মা ৰুলা কলিতা যতীন", "order": "FAMILY_FIRST", "given": "ৰুলা", "middle": "কলিতা যতীন", "family": "শৰ্মা", "initials": "ৰ. ক. য. শ."} +{"name": "শৰ্মা, ৰুলা কলিতা যতীন", "given": "ৰুলা", "middle": "কলিতা যতীন", "family": "শৰ্মা", "initials": "ৰ. ক. য. শ."} +{"name": "সত্যৰাম", "given": "সত্যৰাম", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "স."} +{"name": "সত্যৰাম", "order": "FAMILY_FIRST", "family": "সত্যৰাম", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "স."} +{"name": "সিনবাদ", "given": "সিনবাদ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "স."} +{"name": "সিনবাদ", "order": "FAMILY_FIRST", "family": "সিনবাদ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "স."} +{"name": "ৰুলা কলিতা যতীন শৰ্মা", "given": "ৰুলা", "middle": "কলিতা যতীন", "family": "শৰ্মা", "initials": "ৰ. ক. য. শ."} +{"name": "ਆਰੀਨ ਐਡਲਰ", "given": "ਆਰੀਨ", "family": "ਐਡਲਰ", "initials": "ਆ. ਐ."} +{"name": "ਐਡਲਰ ਆਰੀਨ", "order": "FAMILY_FIRST", "given": "ਆਰੀਨ", "family": "ਐਡਲਰ", "initials": "ਆ. ਐ."} +{"name": "ਐਡਲਰ, ਆਰੀਨ", "given": "ਆਰੀਨ", "family": "ਐਡਲਰ", "initials": "ਆ. ਐ."} +{"name": "ਕੈਥੀ ਮੂਲਰ", "given": "ਕੈਥੀ", "family": "ਮੂਲਰ", "initials": "ਕ. ਮ."} +{"name": "ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼ ਸਟੋਬਰ", "given": "ਜ਼ਜ਼ਿਲੀਆ", "middle": "ਹੇਮਿਸ਼", "family": "ਸਟੋਬਰ", "initials": "ਜ. ਹ. ਸ."} +{"name": "ਜ਼ੈਨਡਿਆ", "given": "ਜ਼ੈਨਡਿਆ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ਜ."} +{"name": "ਜ਼ੈਨਡਿਆ", "order": "FAMILY_FIRST", "family": "ਜ਼ੈਨਡਿਆ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ਜ."} +{"name": "ਜੌਨ ਹਾਮਿਸ਼ ਵਾਟਸਨ", "given": "ਜੌਨ", "middle": "ਹਾਮਿਸ਼", "family": "ਵਾਟਸਨ", "initials": "ਜ. ਹ. ਵ."} +{"name": "ਪ੍ਰੋ. ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "given": "ਪ੍ਰੋ.", "middle": "ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ.", "family": "ਡੀ.ਡੀ.ਐੱਸ.", "initials": "ਪ. ਡ. ਐ. ਕ. ਈ. ਸ. ਵ. ਡ. ਵ. ਬ. ਸ. ਐ. ਡ."} +{"name": "ਮੂਲਰ ਕੈਥੀ", "order": "FAMILY_FIRST", "given": "ਕੈਥੀ", "family": "ਮੂਲਰ", "initials": "ਕ. ਮ."} +{"name": "ਮੂਲਰ, ਕੈਥੀ", "given": "ਕੈਥੀ", "family": "ਮੂਲਰ", "initials": "ਕ. ਮ."} +{"name": "ਵਾਟਸਨ ਜੌਨ ਹਾਮਿਸ਼", "order": "FAMILY_FIRST", "given": "ਜੌਨ", "middle": "ਹਾਮਿਸ਼", "family": "ਵਾਟਸਨ", "initials": "ਜ. ਹ. ਵ."} +{"name": "ਵਾਟਸਨ, ਜੌਨ ਹਾਮਿਸ਼", "given": "ਜੌਨ", "middle": "ਹਾਮਿਸ਼", "family": "ਵਾਟਸਨ", "initials": "ਜ. ਹ. ਵ."} +{"name": "ਵੂਸਟਰ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ.", "order": "FAMILY_FIRST", "given": "ਬਰਟਰਮ", "middle": "ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ.", "family": "ਵੂਸਟਰ", "initials": "ਬ. ਵ. ਹ. ਰ. ਐ. ਵ."} +{"name": "ਵੂਸਟਰ, ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ", "given": "ਬਰਟਰਮ", "middle": "ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ", "family": "ਵੂਸਟਰ", "initials": "ਬ. ਵ. ਹ. ਰ. ਵ."} +{"name": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "order": "FAMILY_FIRST", "given": "ਡੇਨ", "middle": "ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "family": "ਵੈਨ", "initials": "ਡ. ਵ. ਐ. ਕ. ਈ. ਸ. ਐ. ਡ. ਵ."} +{"name": "ਵੌਲਫ਼, ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ", "given": "ਐਡਾ", "middle": "ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ", "family": "ਵੌਲਫ਼", "initials": "ਐ. ਕ. ਈ. ਸ. ਵ. ਡ. ਵ."} +{"name": "ਸਟੋਬਰ ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼", "order": "FAMILY_FIRST", "given": "ਜ਼ਜ਼ਿਲੀਆ", "middle": "ਹੇਮਿਸ਼", "family": "ਸਟੋਬਰ", "initials": "ਜ. ਹ. ਸ."} +{"name": "ਸਟੋਬਰ, ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼", "given": "ਜ਼ਜ਼ਿਲੀਆ", "middle": "ਹੇਮਿਸ਼", "family": "ਸਟੋਬਰ", "initials": "ਜ. ਹ. ਸ."} +{"name": "ਸ਼੍ਰੀ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ ਐਮ.ਪੀ.", "given": "ਸ਼੍ਰੀ", "middle": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ", "family": "ਐਮ.ਪੀ.", "initials": "ਸ. ਬ. ਵ. ਹ. ਰ. ਵ. ਐ."} +{"name": "ਸਿੰਨਬੈਡ", "given": "ਸਿੰਨਬੈਡ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ਸ."} +{"name": "ਸਿੰਨਬੈਡ", "order": "FAMILY_FIRST", "family": "ਸਿੰਨਬੈਡ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ਸ."} +{"name": "અદાણી આઇરીન", "order": "FAMILY_FIRST", "given": "આઇરીન", "family": "અદાણી", "initials": "આ. અ."} +{"name": "અદાણી, આઇરીન", "given": "આઇરીન", "family": "અદાણી", "initials": "આ. અ."} +{"name": "આઇરીન અદાણી", "given": "આઇરીન", "family": "અદાણી", "initials": "આ. અ."} +{"name": "કિરણ", "given": "કિરણ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ક."} +{"name": "કિરણ", "order": "FAMILY_FIRST", "family": "કિરણ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ક."} +{"name": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ જૂનિયર, એમપી", "order": "FAMILY_FIRST", "given": "એમપી", "family": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ જૂનિયર", "initials": "એ. ક. શ. શ. પ. ખ. ક. જ."} +{"name": "કૅથિ મુલર", "given": "કૅથિ", "family": "મુલર", "initials": "ક. મ."} +{"name": "ખુશી પટેલ હેતલ દવે", "given": "ખુશી", "middle": "પટેલ હેતલ", "family": "દવે", "initials": "ખ. પ. હ. દ."} +{"name": "જઝિલીયા હૅમીશ સ્ટૂબર", "given": "જઝિલીયા", "middle": "હૅમીશ", "family": "સ્ટૂબર", "initials": "જ. હ. સ."} +{"name": "દવે ખુશી પટેલ હેતલ", "order": "FAMILY_FIRST", "given": "ખુશી", "middle": "પટેલ હેતલ", "family": "દવે", "initials": "ખ. પ. હ. દ."} +{"name": "દવે, ખુશી પટેલ હેતલ", "given": "ખુશી", "middle": "પટેલ હેતલ", "family": "દવે", "initials": "ખ. પ. હ. દ."} +{"name": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્ જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "given": "એમ.ડી.", "middle": "ડી.ડી.એસ.", "family": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્ જૂનિયર", "initials": "એ. ડ. પ. ડ. એ. ક. સ. મ. ફ. બ. જ."} +{"name": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "order": "FAMILY_FIRST", "given": "એમ.ડી.", "middle": "ડી.ડી.એસ.", "family": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન જૂનિયર", "initials": "એ. ડ. ફ. બ. પ. ડ. એ. ક. સ. મ. જ."} +{"name": "બ્રાઑલ્, એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્", "given": "એડા", "middle": "કૉર્નેલિયા સિઝર માર્ટિન ફૉન્", "family": "બ્રાઑલ્", "initials": "એ. ક. સ. મ. ફ. બ."} +{"name": "મુલર કૅથિ", "order": "FAMILY_FIRST", "given": "કૅથિ", "family": "મુલર", "initials": "ક. મ."} +{"name": "મુલર, કૅથિ", "given": "કૅથિ", "family": "મુલર", "initials": "ક. મ."} +{"name": "શાહ, પટેલ ખુશી કિંજલ કુમાર", "given": "પટેલ", "middle": "ખુશી કિંજલ કુમાર", "family": "શાહ", "initials": "પ. ખ. ક. ક. શ."} +{"name": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ જૂનિયર, એમપી", "given": "એમપી", "family": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ જૂનિયર", "initials": "એ. શ. પ. ખ. ક. ક. શ. જ."} +{"name": "સિનબાદ", "given": "સિનબાદ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "સ."} +{"name": "સિનબાદ", "order": "FAMILY_FIRST", "family": "સિનબાદ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "સ."} +{"name": "સ્ટૂબર જઝિલીયા હૅમીશ", "order": "FAMILY_FIRST", "given": "જઝિલીયા", "middle": "હૅમીશ", "family": "સ્ટૂબર", "initials": "જ. હ. સ."} +{"name": "સ્ટૂબર, જઝિલીયા હૅમીશ", "given": "જઝિલીયા", "middle": "હૅમીશ", "family": "સ્ટૂબર", "initials": "જ. હ. સ."} +{"name": "ଆଇରେନ୍ ଆଡଲର୍", "given": "ଆଇରେନ୍", "family": "ଆଡଲର୍", "initials": "ଆ. ଆ."} +{"name": "ଆଡଲର୍ ଆଇରେନ୍", "order": "FAMILY_FIRST", "given": "ଆଇରେନ୍", "family": "ଆଡଲର୍", "initials": "ଆ. ଆ."} +{"name": "ଆଡଲର୍, ଆଇରେନ୍", "given": "ଆଇରେନ୍", "family": "ଆଡଲର୍", "initials": "ଆ. ଆ."} +{"name": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଏମ.ଡି.ପିଏଚ.ଡି", "given": "ଆଡା", "middle": "କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ", "family": "ଏମ.ଡି.ପିଏଚ.ଡି", "initials": "ଆ. କ. ଇ. ସ. ୱ. ଡ. ୱ. ଏ."} +{"name": "କାଥେ ମୁଲେର୍", "given": "କାଥେ", "family": "ମୁଲେର୍", "initials": "କ. ମ."} +{"name": "ଜନ୍ ହାମିଶ ୱାଟସନ୍", "given": "ଜନ୍", "middle": "ହାମିଶ", "family": "ୱାଟସନ୍", "initials": "ଜ. ହ. ୱ."} +{"name": "ଜେଆର୍ ହାମିଶ ଷ୍ଟୋବର", "given": "ଜେଆର୍", "middle": "ହାମିଶ", "family": "ଷ୍ଟୋବର", "initials": "ଜ. ହ. ଷ."} +{"name": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର ଏମପି", "given": "ବର୍ଟ୍ରାମ", "middle": "ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର", "family": "ଏମପି", "initials": "ବ. ୱ. ହ. ର. ୱ. ଏ."} +{"name": "ମୁଲେର୍ କାଥେ", "order": "FAMILY_FIRST", "given": "କାଥେ", "family": "ମୁଲେର୍", "initials": "କ. ମ."} +{"name": "ମୁଲେର୍, କାଥେ", "given": "କାଥେ", "family": "ମୁଲେର୍", "initials": "କ. ମ."} +{"name": "ଷ୍ଟୋବର ଜେଆର୍ ହାମିଶ", "order": "FAMILY_FIRST", "given": "ଜେଆର୍", "middle": "ହାମିଶ", "family": "ଷ୍ଟୋବର", "initials": "ଜ. ହ. ଷ."} +{"name": "ଷ୍ଟୋବର, ଜେଆର୍ ହାମିଶ", "given": "ଜେଆର୍", "middle": "ହାମିଶ", "family": "ଷ୍ଟୋବର", "initials": "ଜ. ହ. ଷ."} +{"name": "ସିନବାଦ", "given": "ସିନବାଦ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ସ."} +{"name": "ସିନବାଦ", "order": "FAMILY_FIRST", "family": "ସିନବାଦ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ସ."} +{"name": "ସିନବାଦ୍", "given": "ସିନବାଦ୍", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ସ."} +{"name": "ସିନବାଦ୍", "order": "FAMILY_FIRST", "family": "ସିନବାଦ୍", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ସ."} +{"name": "ୱାଟସନ୍ ଜନ୍ ହାମିଶ", "order": "FAMILY_FIRST", "given": "ଜନ୍", "middle": "ହାମିଶ", "family": "ୱାଟସନ୍", "initials": "ଜ. ହ. ୱ."} +{"name": "ୱାଟସନ୍, ଜନ୍ ହାମିଶ", "given": "ଜନ୍", "middle": "ହାମିଶ", "family": "ୱାଟସନ୍", "initials": "ଜ. ହ. ୱ."} +{"name": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି", "order": "FAMILY_FIRST", "given": "ଡେନ୍", "middle": "ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି", "family": "ୱାନ୍", "initials": "ଡ. ୱ. ଆ. କ. ଇ. ସ. ଏ. ୱ."} +{"name": "ୱୋଲ୍ଫ, ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍", "given": "ଆଡା", "middle": "କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍", "family": "ୱୋଲ୍ଫ", "initials": "ଆ. କ. ଇ. ସ. ୱ. ଡ. ୱ."} +{"name": "ୱୋଷ୍ଟର ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି", "order": "FAMILY_FIRST", "given": "ବର୍ଟ୍ରାମ", "middle": "ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି", "family": "ୱୋଷ୍ଟର", "initials": "ବ. ୱ. ହ. ର. ଏ. ୱ."} +{"name": "ୱୋଷ୍ଟର, ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ", "given": "ବର୍ଟ୍ରାମ", "middle": "ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ", "family": "ୱୋଷ୍ଟର", "initials": "ବ. ୱ. ହ. ର. ୱ."} +{"name": "அல்போன்சோ", "given": "அல்போன்சோ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "அ."} +{"name": "அல்போன்சோ", "order": "FAMILY_FIRST", "family": "அல்போன்சோ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "அ."} +{"name": "அல்போன்சோ எர்னஸ்டோ வான் ஹெர்னாண்டஸ் பிஎச்.டி", "given": "அல்போன்சோ", "middle": "எர்னஸ்டோ வான் ஹெர்னாண்டஸ்", "family": "பிஎச்.டி", "initials": "அ. எ. வ. ஹ. ப."} +{"name": "அல்போன்சோ எர்னஸ்டோ ஹெர்னாண்டஸ்", "given": "அல்போன்சோ", "middle": "எர்னஸ்டோ", "family": "ஹெர்னாண்டஸ்", "initials": "அ. எ. ஹ."} +{"name": "அல்போன்சோ எர்னஸ்டோ, வான் ஹெர்னாண்டஸ்", "given": "வான்", "middle": "ஹெர்னாண்டஸ்", "family": "அல்போன்சோ எர்னஸ்டோ", "initials": "வ. ஹ. அ. எ."} +{"name": "அல்போன்சோ எர்னஸ்டோ, ஹெர்னாண்டஸ்", "given": "ஹெர்னாண்டஸ்", "family": "அல்போன்சோ எர்னஸ்டோ", "initials": "ஹ. அ. எ."} +{"name": "அல்போன்சோ ஹெர்னாண்டஸ்", "given": "அல்போன்சோ", "family": "ஹெர்னாண்டஸ்", "initials": "அ. ஹ."} +{"name": "அல்போன்சோ ஹெர்னாண்டஸ்", "order": "FAMILY_FIRST", "given": "ஹெர்னாண்டஸ்", "family": "அல்போன்சோ", "initials": "ஹ. அ."} +{"name": "சோழன் ராஜ ராஜ", "order": "FAMILY_FIRST", "given": "ராஜ", "middle": "ராஜ", "family": "சோழன்", "initials": "ர. ர. ச."} +{"name": "சோழன் ராஜேந்திர", "order": "FAMILY_FIRST", "given": "ராஜேந்திர", "family": "சோழன்", "initials": "ர. ச."} +{"name": "திரு. சோழன் ராஜ ராஜன் ராஜன் எம்பி", "order": "FAMILY_FIRST", "given": "சோழன்", "middle": "ராஜ ராஜன் ராஜன் எம்பி", "family": "திரு.", "initials": "ச. ர. ர. ர. எ. த."} +{"name": "பேராசிரியர் வான் ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி", "order": "FAMILY_FIRST", "given": "வான்", "middle": "ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி", "family": "பேராசிரியர்", "initials": "வ. ஹ. அ. எ. ப. ப."} +{"name": "ராஜ ராஜ சோழன்", "given": "ராஜ", "middle": "ராஜ", "family": "சோழன்", "initials": "ர. ர. ச."} +{"name": "ராஜ ராஜ, சோழன்", "given": "சோழன்", "family": "ராஜ ராஜ", "initials": "ச. ர. ர."} +{"name": "ராஜ ராஜன் ராஜன் சோழன் எம்பி", "given": "ராஜ", "middle": "ராஜன் ராஜன் சோழன்", "family": "எம்பி", "initials": "ர. ர. ர. ச. எ."} +{"name": "ராஜ ராஜன் ராஜன், சோழன்", "given": "சோழன்", "family": "ராஜ ராஜன் ராஜன்", "initials": "ச. ர. ர. ர."} +{"name": "ராஜேந்திர சோழன்", "given": "ராஜேந்திர", "family": "சோழன்", "initials": "ர. ச."} +{"name": "ராஜேந்திர சோழன்", "order": "FAMILY_FIRST", "given": "சோழன்", "family": "ராஜேந்திர", "initials": "ச. ர."} +{"name": "ராஜேந்திரன்", "given": "ராஜேந்திரன்", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ர."} +{"name": "ராஜேந்திரன்", "order": "FAMILY_FIRST", "family": "ராஜேந்திரன்", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ர."} +{"name": "ஹெர்னாண்டஸ் அல்போன்சோ", "order": "FAMILY_FIRST", "given": "அல்போன்சோ", "family": "ஹெர்னாண்டஸ்", "initials": "அ. ஹ."} +{"name": "ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ", "order": "FAMILY_FIRST", "given": "அல்போன்சோ", "middle": "எர்னஸ்டோ", "family": "ஹெர்னாண்டஸ்", "initials": "அ. எ. ஹ."} +{"name": "ఏలేటి కుమార్ చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి.", "order": "FAMILY_FIRST", "given": "కుమార్", "middle": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి.", "family": "ఏలేటి", "initials": "క. చ. శ. అ. ఆ. ఎ. ప. ఏ."} +{"name": "ఏలేటి కుమార్, చంద్ర శేఖర్ అబ్దుల్ ఆజాద్", "given": "చంద్ర", "middle": "శేఖర్ అబ్దుల్ ఆజాద్", "family": "ఏలేటి కుమార్", "initials": "చ. శ. అ. ఆ. ఏ. క."} +{"name": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం. పి.", "given": "చంద్ర", "middle": "శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం.", "family": "పి.", "initials": "చ. శ. అ. ఆ. ఏ. క. ఎ. ప."} +{"name": "చౌదరి, సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ", "given": "సూర్య", "middle": "ప్రతాప్ లాస్య ప్రియ శర్మ", "family": "చౌదరి", "initials": "స. ప. ల. ప. శ. చ."} +{"name": "ప్రసాద్ శంకర్ నరేష్", "order": "FAMILY_FIRST", "given": "శంకర్", "middle": "నరేష్", "family": "ప్రసాద్", "initials": "శ. న. ప."} +{"name": "ప్రసాద్, శంకర్ నరేష్", "given": "శంకర్", "middle": "నరేష్", "family": "ప్రసాద్", "initials": "శ. న. ప."} +{"name": "భరత్ రఘు", "order": "FAMILY_FIRST", "given": "రఘు", "family": "భరత్", "initials": "ర. భ."} +{"name": "భరత్, రఘు", "given": "రఘు", "family": "భరత్", "initials": "ర. భ."} +{"name": "రఘు భరత్", "given": "రఘు", "family": "భరత్", "initials": "ర. భ."} +{"name": "రవి", "given": "రవి", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ర."} +{"name": "రవి", "order": "FAMILY_FIRST", "family": "రవి", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ర."} +{"name": "రాజశేఖర్", "given": "రాజశేఖర్", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ర."} +{"name": "రాజశేఖర్", "order": "FAMILY_FIRST", "family": "రాజశేఖర్", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ర."} +{"name": "రాజశేఖర్ రెడ్డి", "given": "రాజశేఖర్", "family": "రెడ్డి", "initials": "ర. ర."} +{"name": "రెడ్డి రాజశేఖర్", "order": "FAMILY_FIRST", "given": "రాజశేఖర్", "family": "రెడ్డి", "initials": "ర. ర."} +{"name": "రెడ్డి, రాజశేఖర్", "given": "రాజశేఖర్", "family": "రెడ్డి", "initials": "ర. ర."} +{"name": "వర్మ సూర్య ప్రతాప్", "order": "FAMILY_FIRST", "given": "సూర్య", "middle": "ప్రతాప్", "family": "వర్మ", "initials": "స. ప. వ."} +{"name": "వర్మ, సూర్య ప్రతాప్", "given": "సూర్య", "middle": "ప్రతాప్", "family": "వర్మ", "initials": "స. ప. వ."} +{"name": "శంకర్ నరేష్ ప్రసాద్", "given": "శంకర్", "middle": "నరేష్", "family": "ప్రసాద్", "initials": "శ. న. ప."} +{"name": "శర్మ చౌదరి సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి.", "order": "FAMILY_FIRST", "given": "చౌదరి", "middle": "సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి.", "family": "శర్మ", "initials": "చ. స. ప. ల. ప. ఎ. డ. ప. డ. శ."} +{"name": "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్. డి.", "given": "సూర్య", "middle": "ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్.", "family": "డి.", "initials": "స. ప. ల. ప. శ. చ. ఎ. డ. ప. డ."} +{"name": "సూర్య ప్రతాప్ వర్మ", "given": "సూర్య", "middle": "ప్రతాప్", "family": "వర్మ", "initials": "స. ప. వ."} +{"name": "ಕ್ಯಾಥಿ ಮುಲ್ಲರ್", "given": "ಕ್ಯಾಥಿ", "family": "ಮುಲ್ಲರ್", "initials": "ಕ. ಮ."} +{"name": "ಝಝಿಲಿಯಾ ಹಮಿಶ್ ಸ್ಟೋಬರ್", "given": "ಝಝಿಲಿಯಾ", "middle": "ಹಮಿಶ್", "family": "ಸ್ಟೋಬರ್", "initials": "ಝ. ಹ. ಸ."} +{"name": "ಝೆಂಡಾಯಾ", "given": "ಝೆಂಡಾಯಾ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ಝ."} +{"name": "ಝೆಂಡಾಯಾ", "order": "FAMILY_FIRST", "family": "ಝೆಂಡಾಯಾ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ಝ."} +{"name": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ, ಎಮ್‌ಪಿ", "order": "FAMILY_FIRST", "given": "ಎಮ್‌ಪಿ", "family": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ", "initials": "ಎ. ಪ. ಶ. ನ. ಕ. ಶ. ರ. ಕ."} +{"name": "ಪಾಟೀಲ್, ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ", "given": "ನಾರಯಣ", "middle": "ಕುಮಾರ್ ಶಿವ ರಾಮ", "family": "ಪಾಟೀಲ್", "initials": "ನ. ಕ. ಶ. ರ. ಪ."} +{"name": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "given": "ಎಂ.ಡಿ", "middle": "ಡಿ.ಡಿ.ಎಸ್", "family": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್ ಜೂನಿಯರ್", "initials": "ಎ. ಡ. ಪ. ಡ. ಅ. ಕ. ಸ. ಮ. ವ. ಬ. ಜ."} +{"name": "ಬ್ರಾಲ್, ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್", "given": "ಅಡಾ", "middle": "ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್", "family": "ಬ್ರಾಲ್", "initials": "ಅ. ಕ. ಸ. ಮ. ವ. ಬ."} +{"name": "ಮುಲ್ಲರ್ ಕ್ಯಾಥಿ", "order": "FAMILY_FIRST", "given": "ಕ್ಯಾಥಿ", "family": "ಮುಲ್ಲರ್", "initials": "ಕ. ಮ."} +{"name": "ಮುಲ್ಲರ್, ಕ್ಯಾಥಿ", "given": "ಕ್ಯಾಥಿ", "family": "ಮುಲ್ಲರ್", "initials": "ಕ. ಮ."} +{"name": "ಯಶ್ ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "order": "FAMILY_FIRST", "given": "ವಿಜಯ", "middle": "ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "family": "ಯಶ್", "initials": "ವ. ಲ. ಹ. ಯ."} +{"name": "ಯಶ್, ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "given": "ವಿಜಯ", "middle": "ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "family": "ಯಶ್", "initials": "ವ. ಲ. ಹ. ಯ."} +{"name": "ರಶ್ಮಿಕಾ ಶೆಟ್ಟಿ", "given": "ರಶ್ಮಿಕಾ", "family": "ಶೆಟ್ಟಿ", "initials": "ರ. ಶ."} +{"name": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "order": "FAMILY_FIRST", "given": "ಎಂ.ಡಿ", "middle": "ಡಿ.ಡಿ.ಎಸ್", "family": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ಜೂನಿಯರ್", "initials": "ಎ. ಡ. ವ. ಬ. ಪ. ಡ. ಅ. ಕ. ಸ. ಮ. ಜ."} +{"name": "ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್ ಯಶ್", "given": "ವಿಜಯ", "middle": "ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "family": "ಯಶ್", "initials": "ವ. ಲ. ಹ. ಯ."} +{"name": "ಶೆಟ್ಟಿ ರಶ್ಮಿಕಾ", "order": "FAMILY_FIRST", "given": "ರಶ್ಮಿಕಾ", "family": "ಶೆಟ್ಟಿ", "initials": "ರ. ಶ."} +{"name": "ಶೆಟ್ಟಿ, ರಶ್ಮಿಕಾ", "given": "ರಶ್ಮಿಕಾ", "family": "ಶೆಟ್ಟಿ", "initials": "ರ. ಶ."} +{"name": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ, ಎಮ್‌ಪಿ", "given": "ಎಮ್‌ಪಿ", "family": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ", "initials": "ಎ. ಶ. ನ. ಕ. ಶ. ರ. ಪ. ಕ."} +{"name": "ಸಿನ್‍ಬಾದ್", "given": "ಸಿನ್‍ಬಾದ್", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ಸ."} +{"name": "ಸಿನ್‍ಬಾದ್", "order": "FAMILY_FIRST", "family": "ಸಿನ್‍ಬಾದ್", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ಸ."} +{"name": "ಸ್ಟೋಬರ್ ಝಝಿಲಿಯಾ ಹಮಿಶ್", "order": "FAMILY_FIRST", "given": "ಝಝಿಲಿಯಾ", "middle": "ಹಮಿಶ್", "family": "ಸ್ಟೋಬರ್", "initials": "ಝ. ಹ. ಸ."} +{"name": "ಸ್ಟೋಬರ್, ಝಝಿಲಿಯಾ ಹಮಿಶ್", "given": "ಝಝಿಲಿಯಾ", "middle": "ಹಮಿಶ್", "family": "ಸ್ಟೋಬರ್", "initials": "ಝ. ಹ. ಸ."} +{"name": "അശ്വതി നായർ", "given": "അശ്വതി", "family": "നായർ", "initials": "അ. ന."} +{"name": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി. ഡി.ഡി.എസ്", "given": "എയ്‌ഡ", "middle": "കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി.", "family": "ഡി.ഡി.എസ്", "initials": "എ. ക. സ. മ. വ. ബ. എ. ഡ."} +{"name": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി ജൂനിയർ, എംപി", "order": "FAMILY_FIRST", "given": "എംപി", "family": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി ജൂനിയർ", "initials": "എ. ക. ശ. ത. ക. അ. ജ."} +{"name": "കറുവാപ്പറമ്പിൽ, തോമസ് കുരുവിള അന്തോണി", "given": "തോമസ്", "middle": "കുരുവിള അന്തോണി", "family": "കറുവാപ്പറമ്പിൽ", "initials": "ത. ക. അ. ക."} +{"name": "കാത്തി മുള്ളർ", "given": "കാത്തി", "family": "മുള്ളർ", "initials": "ക. മ."} +{"name": "ജോൺ ചാണ്ടി പടവീട്ടിൽ", "given": "ജോൺ", "middle": "ചാണ്ടി", "family": "പടവീട്ടിൽ", "initials": "ജ. ച. പ."} +{"name": "തോമസ് കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ എംപി", "given": "തോമസ്", "middle": "കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ", "family": "എംപി", "initials": "ത. ക. അ. ക. എ."} +{"name": "ദീപ", "given": "ദീപ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ദ."} +{"name": "ദീപ", "order": "FAMILY_FIRST", "family": "ദീപ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ദ."} +{"name": "നായർ അശ്വതി", "order": "FAMILY_FIRST", "given": "അശ്വതി", "family": "നായർ", "initials": "അ. ന."} +{"name": "നായർ, അശ്വതി", "given": "അശ്വതി", "family": "നായർ", "initials": "അ. ന."} +{"name": "പടവീട്ടിൽ ജോൺ ചാണ്ടി", "order": "FAMILY_FIRST", "given": "ജോൺ", "middle": "ചാണ്ടി", "family": "പടവീട്ടിൽ", "initials": "ജ. ച. പ."} +{"name": "പടവീട്ടിൽ, ജോൺ ചാണ്ടി", "given": "ജോൺ", "middle": "ചാണ്ടി", "family": "പടവീട്ടിൽ", "initials": "ജ. ച. പ."} +{"name": "ബ്ര്യൂൾ, എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ", "given": "എയ്‌ഡ", "middle": "കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ", "family": "ബ്ര്യൂൾ", "initials": "എ. ക. സ. മ. വ. ബ."} +{"name": "മുള്ളർ കാത്തി", "order": "FAMILY_FIRST", "given": "കാത്തി", "family": "മുള്ളർ", "initials": "ക. മ."} +{"name": "മുള്ളർ, കാത്തി", "given": "കാത്തി", "family": "മുള്ളർ", "initials": "ക. മ."} +{"name": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ ജൂനിയർ, എം.ഡി. ഡി.ഡി.എസ്", "order": "FAMILY_FIRST", "given": "എം.ഡി.", "middle": "ഡി.ഡി.എസ്", "family": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ ജൂനിയർ", "initials": "എ. ഡ. വ. ബ. പ. ഡ. എ. ക. സ. മ. ജ."} +{"name": "സസിലിയ ഹാമിഷ് സ്‌റ്റോബർ", "given": "സസിലിയ", "middle": "ഹാമിഷ്", "family": "സ്‌റ്റോബർ", "initials": "സ. ഹ. സ."} +{"name": "സിൻബാദ്", "given": "സിൻബാദ്", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "സ."} +{"name": "സിൻബാദ്", "order": "FAMILY_FIRST", "family": "സിൻബാദ്", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "സ."} +{"name": "സ്‌റ്റോബർ സസിലിയ ഹാമിഷ്", "order": "FAMILY_FIRST", "given": "സസിലിയ", "middle": "ഹാമിഷ്", "family": "സ്‌റ്റോബർ", "initials": "സ. ഹ. സ."} +{"name": "സ്‌റ്റോബർ, സസിലിയ ഹാമിഷ്", "given": "സസിലിയ", "middle": "ഹാമിഷ്", "family": "സ്‌റ്റോബർ", "initials": "സ. ഹ. സ."} +{"name": "අයිරින් ඇඩ්ලර්", "given": "අයිරින්", "family": "ඇඩ්ලර්", "initials": "අ. ඇ."} +{"name": "අයිරින් ඇඩ්ලර්", "order": "FAMILY_FIRST", "given": "ඇඩ්ලර්", "family": "අයිරින්", "initials": "ඇ. අ."} +{"name": "ඇඩ්ලර් අයිරින්", "order": "FAMILY_FIRST", "given": "අයිරින්", "family": "ඇඩ්ලර්", "initials": "අ. ඇ."} +{"name": "ඇඩා කොර්නේලියා සීසර් මාටින්, වොන් බෲල්", "given": "වොන්", "middle": "බෲල්", "family": "ඇඩා කොර්නේලියා සීසර් මාටින්", "initials": "ව. බ. ඇ. ක. ස. ම."} +{"name": "කේතේ මුලර්", "given": "කේතේ", "family": "මුලර්", "initials": "ක. ම."} +{"name": "කේතේ මුලර්", "order": "FAMILY_FIRST", "given": "මුලර්", "family": "කේතේ", "initials": "ම. ක."} +{"name": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්, වූස්ටර්", "given": "වූස්ටර්", "family": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්", "initials": "ව. බ. ව. හ. ර."} +{"name": "මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර් පා.ම.", "given": "මහතා", "middle": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර්", "family": "පා.ම.", "initials": "ම. බ. ව. හ. ර. ව. ප."} +{"name": "මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී. පී.එච්.ඩී.", "given": "මහාචාර්ය.", "middle": "ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී.", "family": "පී.එච්.ඩී.", "initials": "ම. ආ. ඇ. ක. ස. ම. ව. බ. ග. ඩ. එ. ප."} +{"name": "මුලර් කේතේ", "order": "FAMILY_FIRST", "given": "කේතේ", "family": "මුලර්", "initials": "ක. ම."} +{"name": "මේරි සූ හමිෂ් වොට්සන්", "given": "මේරි", "middle": "සූ හමිෂ්", "family": "වොට්සන්", "initials": "ම. ස. හ. ව."} +{"name": "මේරි සූ හමිෂ්, වොට්සන්", "given": "වොට්සන්", "family": "මේරි සූ හමිෂ්", "initials": "ව. ම. ස. හ."} +{"name": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල, පා.ම.", "order": "FAMILY_FIRST", "given": "පා.ම.", "family": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල", "initials": "ප. ව. ම. බ. ව. හ. ර. බ."} +{"name": "වොට්සන් මේරි සූ හමිෂ්", "order": "FAMILY_FIRST", "given": "මේරි", "middle": "සූ හමිෂ්", "family": "වොට්සන්", "initials": "ම. ස. හ. ව."} +{"name": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල, එම්.ඩී. පී.එච්.ඩී.", "order": "FAMILY_FIRST", "given": "එම්.ඩී.", "middle": "පී.එච්.ඩී.", "family": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල", "initials": "එ. ප. ව. බ. ම. ආ. ඇ. ක. ස. ම. බ."} +{"name": "ස්ටෝබර් සාසිලියා හමිෂ්", "order": "FAMILY_FIRST", "given": "සාසිලියා", "middle": "හමිෂ්", "family": "ස්ටෝබර්", "initials": "ස. හ. ස."} +{"name": "සාසිලියා හමිෂ් ස්ටෝබර්", "given": "සාසිලියා", "middle": "හමිෂ්", "family": "ස්ටෝබර්", "initials": "ස. හ. ස."} +{"name": "සාසිලියා හමිෂ්, ස්ටෝබර්", "given": "ස්ටෝබර්", "family": "සාසිලියා හමිෂ්", "initials": "ස. ස. හ."} +{"name": "සින්බෑඩ්", "given": "සින්බෑඩ්", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ස."} +{"name": "සින්බෑඩ්", "order": "FAMILY_FIRST", "family": "සින්බෑඩ්", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ස."} +{"name": "සෙන්ඩයා", "given": "සෙන්ඩයා", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ස."} +{"name": "සෙන්ඩයා", "order": "FAMILY_FIRST", "family": "සෙන්ඩයා", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ස."} +{"name": "กล้าหาญ ไอริณ", "order": "FAMILY_FIRST", "given": "ไอริณ", "family": "กล้าหาญ", "initials": "ไ. ก."} +{"name": "กล้าหาญ, ไอริณ", "given": "ไอริณ", "family": "กล้าหาญ", "initials": "ไ. ก."} +{"name": "คุณ ปรีชา กล้าหาญ แสงระวี", "given": "คุณ", "middle": "ปรีชา กล้าหาญ", "family": "แสงระวี", "initials": "ค. ป. ก. แ."} +{"name": "ซาซิเลีย ฮามิช สโตเบอร์", "given": "ซาซิเลีย", "middle": "ฮามิช", "family": "สโตเบอร์", "initials": "ซ. ฮ. ส."} +{"name": "ซินแบด", "given": "ซินแบด", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ซ."} +{"name": "ซินแบด", "order": "FAMILY_FIRST", "family": "ซินแบด", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ซ."} +{"name": "ธนา", "given": "ธนา", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ธ."} +{"name": "ธนา", "order": "FAMILY_FIRST", "family": "ธนา", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ธ."} +{"name": "บรืล, เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน", "given": "เอดา", "middle": "คอร์เนเลีย เซซาร์ มาร์ติน วอน", "family": "บรืล", "initials": "เ. ค. เ. ม. ว. บ."} +{"name": "พิชิตชัย มานี ชัยยศ", "order": "FAMILY_FIRST", "given": "มานี", "middle": "ชัยยศ", "family": "พิชิตชัย", "initials": "ม. ช. พ."} +{"name": "พิชิตชัย, มานี ชัยยศ", "given": "มานี", "middle": "ชัยยศ", "family": "พิชิตชัย", "initials": "ม. ช. พ."} +{"name": "มานี ชัยยศ พิชิตชัย", "given": "มานี", "middle": "ชัยยศ", "family": "พิชิตชัย", "initials": "ม. ช. พ."} +{"name": "มึลเลอร์ เคเทอ", "order": "FAMILY_FIRST", "given": "เคเทอ", "family": "มึลเลอร์", "initials": "เ. ม."} +{"name": "มึลเลอร์, เคเทอ", "given": "เคเทอ", "family": "มึลเลอร์", "initials": "เ. ม."} +{"name": "วอน บรืล กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน พ.บ. ท.บ.", "order": "FAMILY_FIRST", "given": "บรืล", "middle": "กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน", "family": "วอน", "suffix": "พ.บ. ท.บ.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "บ. ก. โ. ศ. เ. ค. เ. ม. ว."} +{"name": "ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน บรืล พ.บ. ท.บ.", "given": "ศ.ดร.", "middle": "เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน", "family": "บรืล", "suffix": "พ.บ. ท.บ.", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "ศ. เ. ค. เ. ม. ว. บ."} +{"name": "สโตเบอร์ ซาซิเลีย ฮามิช", "order": "FAMILY_FIRST", "given": "ซาซิเลีย", "middle": "ฮามิช", "family": "สโตเบอร์", "initials": "ซ. ฮ. ส."} +{"name": "สโตเบอร์, ซาซิเลีย ฮามิช", "given": "ซาซิเลีย", "middle": "ฮามิช", "family": "สโตเบอร์", "initials": "ซ. ฮ. ส."} +{"name": "เคเทอ มึลเลอร์", "given": "เคเทอ", "family": "มึลเลอร์", "initials": "เ. ม."} +{"name": "แสงระวี คุณ ปรีชา กล้าหาญ", "order": "FAMILY_FIRST", "given": "คุณ", "middle": "ปรีชา กล้าหาญ", "family": "แสงระวี", "initials": "ค. ป. ก. แ."} +{"name": "แสงระวี, ปรีชา กล้าหาญ", "given": "ปรีชา", "middle": "กล้าหาญ", "family": "แสงระวี", "initials": "ป. ก. แ."} +{"name": "ไอริณ กล้าหาญ", "given": "ไอริณ", "family": "กล้าหาญ", "initials": "ไ. ก."} +{"name": "ຄຳຫຼ້າ", "given": "ຄຳຫຼ້າ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ຄ."} +{"name": "ຄຳຫຼ້າ", "order": "FAMILY_FIRST", "family": "ຄຳຫຼ້າ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ຄ."} +{"name": "ຊາຊິເລຍ ຮາມີສ ສໂຕເບີ", "given": "ຊາຊິເລຍ", "middle": "ຮາມີສ", "family": "ສໂຕເບີ", "initials": "ຊ. ຮ. ສ."} +{"name": "ຊິນແບດ", "given": "ຊິນແບດ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ຊ."} +{"name": "ຊິນແບດ", "order": "FAMILY_FIRST", "family": "ຊິນແບດ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ຊ."} +{"name": "ດຣ. ປອ. ວອນ ບຣູລ ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ", "order": "FAMILY_FIRST", "title": "ດຣ. ປອ.", "given": "ບຣູລ", "middle": "ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ", "family": "ວອນ", "initials": "ບ. ສ. ປ. ເ. ຄ. ເ. ມ. ຮ. ວ."} +{"name": "ດຣ. ປອ. ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ ຮຸ່ນລູກ", "title": "ດຣ. ປອ. ສຈ. ປອ.", "given": "ເອດາ", "middle": "ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ", "family": "ຮຸ່ນລູກ", "initials": "ເ. ຄ. ເ. ມ. ວ. ບ. ຮ."} +{"name": "ນາລິນ ເອກະພົນ", "given": "ນາລິນ", "family": "ເອກະພົນ", "initials": "ນ. ເ."} +{"name": "ບຣູລ, ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ", "given": "ເອດາ", "middle": "ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ", "family": "ບຣູລ", "initials": "ເ. ຄ. ເ. ມ. ວ. ບ."} +{"name": "ມະນີ ມະນີຈັນ ວັດທະນາ", "given": "ມະນີ", "middle": "ມະນີຈັນ", "family": "ວັດທະນາ", "initials": "ມ. ມ. ວ."} +{"name": "ມູລເລີ ເຄທີ", "order": "FAMILY_FIRST", "given": "ເຄທີ", "family": "ມູລເລີ", "initials": "ເ. ມ."} +{"name": "ມູລເລີ, ເຄທີ", "given": "ເຄທີ", "family": "ມູລເລີ", "initials": "ເ. ມ."} +{"name": "ວັດທະນາ ມະນີ ມະນີຈັນ", "order": "FAMILY_FIRST", "given": "ມະນີ", "middle": "ມະນີຈັນ", "family": "ວັດທະນາ", "initials": "ມ. ມ. ວ."} +{"name": "ວັດທະນາ, ມະນີ ມະນີຈັນ", "given": "ມະນີ", "middle": "ມະນີຈັນ", "family": "ວັດທະນາ", "initials": "ມ. ມ. ວ."} +{"name": "ວູສເຕີ, ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ", "given": "ເບີແທຣັມ", "middle": "ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ", "family": "ວູສເຕີ", "initials": "ເ. ວ. ເ. ໂ. ວ."} +{"name": "ສ.ສ. ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ ຮຸ່ນລູກ", "given": "ສ.ສ.", "middle": "ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ", "family": "ຮຸ່ນລູກ", "initials": "ສ. ທ. ເ. ວ. ເ. ໂ. ວ. ຮ."} +{"name": "ສ.ສ. ວູສເຕີ ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ", "order": "FAMILY_FIRST", "given": "ວູສເຕີ", "middle": "ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ", "family": "ສ.ສ.", "initials": "ວ. ທ. ເ. ວ. ເ. ໂ. ຮ. ສ."} +{"name": "ສໂຕເບີ ຊາຊິເລຍ ຮາມີສ", "order": "FAMILY_FIRST", "given": "ຊາຊິເລຍ", "middle": "ຮາມີສ", "family": "ສໂຕເບີ", "initials": "ຊ. ຮ. ສ."} +{"name": "ສໂຕເບີ, ຊາຊິເລຍ ຮາມີສ", "given": "ຊາຊິເລຍ", "middle": "ຮາມີສ", "family": "ສໂຕເບີ", "initials": "ຊ. ຮ. ສ."} +{"name": "ເຄທີ ມູລເລີ", "given": "ເຄທີ", "family": "ມູລເລີ", "initials": "ເ. ມ."} +{"name": "ເອກະພົນ ນາລິນ", "order": "FAMILY_FIRST", "given": "ນາລິນ", "family": "ເອກະພົນ", "initials": "ນ. ເ."} +{"name": "ເອກະພົນ, ນາລິນ", "given": "ນາລິນ", "family": "ເອກະພົນ", "initials": "ນ. ເ."} +{"name": "ကက်သီ မူလာ", "given": "ကက်သီ", "family": "မူလာ", "initials": "က. မ."} +{"name": "စတိုဘာ ဇာဇီလာ ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "ဇာဇီလာ", "middle": "ဟာမစ်ရှ်", "family": "စတိုဘာ", "initials": "ဇ. ဟ. စ."} +{"name": "စတိုဘာ၊ ဇာဇီလာ ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "ဇာဇီလာ", "middle": "ဟာမစ်ရှ်", "family": "စတိုဘာ၊", "initials": "ဇ. ဟ. စ."} +{"name": "ဆင်းဘတ်", "given": "ဆင်းဘတ်", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ဆ."} +{"name": "ဆင်းဘတ်", "order": "FAMILY_FIRST", "family": "ဆင်းဘတ်", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ဆ."} +{"name": "ဇင်ဒါယာ", "given": "ဇင်ဒါယာ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ဇ."} +{"name": "ဇင်ဒါယာ", "order": "FAMILY_FIRST", "family": "ဇင်ဒါယာ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ဇ."} +{"name": "ဇာဇီလာ ဟာမစ်ရှ် စတိုဘာ", "given": "ဇာဇီလာ", "middle": "ဟာမစ်ရှ်", "family": "စတိုဘာ", "initials": "ဇ. ဟ. စ."} +{"name": "ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊ ဒေါက်တာ", "given": "ပရော်ဖက်ဆာ", "middle": "ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊", "family": "ဒေါက်တာ", "initials": "ပ. ဒ. အ. က. ဆ. မ. ဝ. ဗ. ဝ. ဘ. ဂ. ဒ."} +{"name": "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊ ဒေါက်တာ", "given": "မစ္စတာ", "middle": "ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊", "family": "ဒေါက်တာ", "initials": "မ. ဘ. ဝ. ဟ. ရ. ဝ. ဂ. ဒ."} +{"name": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ မေႃယႃ", "given": "မိတ်ႇသတႃႇ", "middle": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ", "family": "မေႃယႃ", "initials": "မ. ပ. ဝ. ႁ. ရ. ဝ. မ."} +{"name": "မူလာ ကက်သီ", "order": "FAMILY_FIRST", "given": "ကက်သီ", "family": "မူလာ", "initials": "က. မ."} +{"name": "မူလာ၊ ကက်သီ", "order": "FAMILY_FIRST", "given": "ကက်သီ", "family": "မူလာ၊", "initials": "က. မ."} +{"name": "မူးလႃႇ ၶေးတႃႇ", "order": "FAMILY_FIRST", "given": "ၶေးတႃႇ", "family": "မူးလႃႇ", "initials": "ၶ. မ."} +{"name": "မေရီ စူး ဟာမစ်ရှ် ဝပ်ဆင်", "given": "မေရီ", "middle": "စူး ဟာမစ်ရှ်", "family": "ဝပ်ဆင်", "initials": "မ. စ. ဟ. ဝ."} +{"name": "မေႇရီႇသူး ႁေးမိသျ် ဝတ်ႉသၼ်ႇ", "given": "မေႇရီႇသူး", "middle": "ႁေးမိသျ်", "family": "ဝတ်ႉသၼ်ႇ", "initials": "မ. ႁ. ဝ."} +{"name": "ဝတ်ႉသၼ်ႇ မေႇရီႇသူး ႁေးမိသျ်", "order": "FAMILY_FIRST", "given": "မေႇရီႇသူး", "middle": "ႁေးမိသျ်", "family": "ဝတ်ႉသၼ်ႇ", "initials": "မ. ႁ. ဝ."} +{"name": "ဝပ်ဆင် မေရီ စူး ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "မေရီ", "middle": "စူး ဟာမစ်ရှ်", "family": "ဝပ်ဆင်", "initials": "မ. စ. ဟ. ဝ."} +{"name": "ဝပ်ဆင်၊ မေရီ စူး ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "မေရီ", "middle": "စူး ဟာမစ်ရှ်", "family": "ဝပ်ဆင်၊", "initials": "မ. စ. ဟ. ဝ."} +{"name": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "order": "FAMILY_FIRST", "given": "မိတ်ႇသတႃႇ", "middle": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "family": "ဝုတ်ႉသ်တႃႇ", "initials": "မ. ပ. ဝ. ႁ. ရ. မ. ဝ."} +{"name": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ", "order": "FAMILY_FIRST", "given": "ဗွန်", "middle": "ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ", "family": "ဝူစ်တာ", "initials": "ဗ. ဝ. ဘ. ပ. ဒ. အ. က. ဆ. မ. ဂ. ဒ. ဝ."} +{"name": "ဝူစ်တာ ဘရူ၊ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်", "order": "FAMILY_FIRST", "given": "ဘရူ၊", "middle": "အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်", "family": "ဝူစ်တာ", "initials": "ဘ. အ. က. ဆ. မ. ဝ. ဗ. ဝ."} +{"name": "ဝူစ်တာ မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ", "order": "FAMILY_FIRST", "given": "မစ္စတာ", "middle": "ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ", "family": "ဝူစ်တာ", "initials": "မ. ဘ. ဝ. ဟ. ရ. ဂ. ဒ. ဝ."} +{"name": "ဝူစ်တာ၊ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "order": "FAMILY_FIRST", "given": "ဘာထရမ်", "middle": "ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "family": "ဝူစ်တာ၊", "initials": "ဘ. ဝ. ဟ. ရ. ဝ."} +{"name": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "order": "FAMILY_FIRST", "given": "ပရူဝ်ႇလ်", "middle": "ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "family": "ဝွၼ်း", "initials": "ပ. ၵ. တ. ၶ. ဢ. ၶ. သ. မ. မ. ဝ."} +{"name": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်, ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "given": "ၶူးမေႃယႃ", "middle": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "family": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "initials": "ၶ. ဢ. ၶ. သ. မ. မ. ဝ. ပ. ၵ. တ."} +{"name": "သတူႇပႃႇ ၸၸီးလီႇယႃႇ ႁေးမိသျ်", "order": "FAMILY_FIRST", "given": "ၸၸီးလီႇယႃႇ", "middle": "ႁေးမိသျ်", "family": "သတူႇပႃႇ", "initials": "ၸ. ႁ. သ."} +{"name": "သိၼ်းပၢတ်ႉ", "given": "သိၼ်းပၢတ်ႉ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "သ."} +{"name": "သိၼ်းပၢတ်ႉ", "order": "FAMILY_FIRST", "family": "သိၼ်းပၢတ်ႉ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "သ."} +{"name": "အက်ဒ်လာ အိုင်ရင်း", "order": "FAMILY_FIRST", "given": "အိုင်ရင်း", "family": "အက်ဒ်လာ", "initials": "အ. အ."} +{"name": "အက်ဒ်လာ၊ အိုင်ရင်း", "order": "FAMILY_FIRST", "given": "အိုင်ရင်း", "family": "အက်ဒ်လာ၊", "initials": "အ. အ."} +{"name": "အိုင်ရင်း အက်ဒ်လာ", "given": "အိုင်ရင်း", "family": "အက်ဒ်လာ", "initials": "အ. အ."} +{"name": "ဢၢႆႇရိၼ်း ဢႅတ်ႉလႃႇ", "given": "ဢၢႆႇရိၼ်း", "family": "ဢႅတ်ႉလႃႇ", "initials": "ဢ. ဢ."} +{"name": "ဢႅတ်ႉလႃႇ ဢၢႆႇရိၼ်း", "order": "FAMILY_FIRST", "given": "ဢၢႆႇရိၼ်း", "family": "ဢႅတ်ႉလႃႇ", "initials": "ဢ. ဢ."} +{"name": "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် မေႃယႃ", "given": "ၶူးမေႃယႃ", "middle": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "family": "မေႃယႃ", "initials": "ၶ. ဢ. ၶ. သ. မ. ဝ. ပ. ၵ. တ. မ."} +{"name": "ၶေးတႃႇ မူးလႃႇ", "given": "ၶေးတႃႇ", "family": "မူးလႃႇ", "initials": "ၶ. မ."} +{"name": "ၸၸီးလီႇယႃႇ ႁေးမိသျ် သတူႇပႃႇ", "given": "ၸၸီးလီႇယႃႇ", "middle": "ႁေးမိသျ်", "family": "သတူႇပႃႇ", "initials": "ၸ. ႁ. သ."} +{"name": "ၸႅၼ်ႇတေႇယႃႇ", "given": "ၸႅၼ်ႇတေႇယႃႇ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ၸ."} +{"name": "ၸႅၼ်ႇတေႇယႃႇ", "order": "FAMILY_FIRST", "family": "ၸႅၼ်ႇတေႇယႃႇ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ၸ."} +{"name": "აბაშიძე მარიამ მართა", "order": "FAMILY_FIRST", "given": "მარიამ", "middle": "მართა", "family": "აბაშიძე", "initials": "მ. მ. ა."} +{"name": "ბელუჩი მონიკა", "order": "FAMILY_FIRST", "given": "მონიკა", "family": "ბელუჩი", "initials": "მ. ბ."} +{"name": "გიორგი თავაძე", "given": "გიორგი", "family": "თავაძე", "initials": "გ. თ."} +{"name": "თავაძე გიორგი", "order": "FAMILY_FIRST", "given": "გიორგი", "family": "თავაძე", "initials": "გ. თ."} +{"name": "თეკლა მარიამ ნაკაშიძე", "given": "თეკლა", "middle": "მარიამ", "family": "ნაკაშიძე", "initials": "თ. მ. ნ."} +{"name": "მარიამ მართა აბაშიძე", "given": "მარიამ", "middle": "მართა", "family": "აბაშიძე", "initials": "მ. მ. ა."} +{"name": "მონიკა ბელუჩი", "given": "მონიკა", "family": "ბელუჩი", "initials": "მ. ბ."} +{"name": "ნაკაშიძე თეკლა მარიამ", "order": "FAMILY_FIRST", "given": "თეკლა", "middle": "მარიამ", "family": "ნაკაშიძე", "initials": "თ. მ. ნ."} +{"name": "ნინო", "given": "ნინო", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ნ."} +{"name": "ნინო", "order": "FAMILY_FIRST", "family": "ნინო", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ნ."} +{"name": "პროფ. დოქტ. ადა კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ. დოქტ.", "title": "პროფ. დოქტ.", "given": "ადა", "middle": "კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ.", "family": "დოქტ.", "initials": "ა. კ. ს. მ. ფ. ბ. გ. დ. მ. დ."} +{"name": "სინდბად", "given": "სინდბად", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ს."} +{"name": "სინდბად", "order": "FAMILY_FIRST", "family": "სინდბად", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ს."} +{"name": "ფონ ბრიული გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "order": "FAMILY_FIRST", "given": "ბრიული", "middle": "გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "family": "ფონ", "initials": "ბ. გ. დ. პ. დ. ა. კ. ს. მ. მ. დ. ფ."} +{"name": "ფონ ბრიული გონსალეს დომინგო, პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "title": "პროფ. დოქტ.", "given": "ადა", "middle": "კორნელია სეზარ მარტინ მეცნ. დოქტ.", "family": "ფონ ბრიული გონსალეს დომინგო", "initials": "ა. კ. ს. მ. მ. დ. ფ. ბ. გ. დ."} +{"name": "შტიობერი ცეცილია ჰეიმიშ", "order": "FAMILY_FIRST", "given": "ცეცილია", "middle": "ჰეიმიშ", "family": "შტიობერი", "initials": "ც. ჰ. შ."} +{"name": "ცეცილია ჰეიმიშ შტიობერი", "given": "ცეცილია", "middle": "ჰეიმიშ", "family": "შტიობერი", "initials": "ც. ჰ. შ."} +{"name": "ሙለር ካቲ", "order": "FAMILY_FIRST", "given": "ካቲ", "family": "ሙለር", "initials": "ካ. ሙ."} +{"name": "ሙለር፣ ካቲ", "order": "FAMILY_FIRST", "given": "ካቲ", "family": "ሙለር፣", "initials": "ካ. ሙ."} +{"name": "ሜሪ ሱ ሃሚሽ ዋትሰን", "given": "ሜሪ", "middle": "ሱ ሃሚሽ", "family": "ዋትሰን", "initials": "ሜ. ሱ. ሃ. ዋ."} +{"name": "ሲንባድ", "given": "ሲንባድ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ሲ."} +{"name": "ሲንባድ", "order": "FAMILY_FIRST", "family": "ሲንባድ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ሲ."} +{"name": "ስቶበር ዛዚሊያ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ዛዚሊያ", "middle": "ሃሚሽ", "family": "ስቶበር", "initials": "ዛ. ሃ. ስ."} +{"name": "ስቶበር፣ ዛዚሊያ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ዛዚሊያ", "middle": "ሃሚሽ", "family": "ስቶበር፣", "initials": "ዛ. ሃ. ስ."} +{"name": "ብሩሂ፣ ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን", "order": "FAMILY_FIRST", "given": "ኣዳ", "middle": "ኮርኒሊያ ሲዘር ማርቲን ቮን", "family": "ብሩሂ፣", "initials": "ኣ. ኮ. ሲ. ማ. ቮ. ብ."} +{"name": "ቮን ብሩሂ ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "order": "FAMILY_FIRST", "given": "ብሩሂ", "middle": "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "family": "ቮን", "initials": "ብ. ፕ. ዶ. ኣ. ኮ. ሲ. ማ. ማ. ሓ. መ. ስ. ቮ."} +{"name": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "order": "FAMILY_FIRST", "given": "ብሩህል", "middle": "ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "family": "ቮን", "initials": "ብ. ጎ. ዶ. ፕ. ዶ. አ. ኮ. ሴ. ማ. ዶ. የ. ዶ. ቮ."} +{"name": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ, ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "given": "ፕ/ር", "middle": "ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "family": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ", "initials": "ፕ. ዶ. አ. ኮ. ሴ. ማ. ዶ. የ. ዶ. ቮ. ብ. ጎ. ዶ."} +{"name": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ አባል", "given": "አቶ", "middle": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ", "family": "አባል", "initials": "አ. በ. ዊ. ሄ. ሮ. ዉ. የ. አ."} +{"name": "አይሪን አድለር", "given": "አይሪን", "family": "አድለር", "initials": "አ. አ."} +{"name": "አድለር አይሪን", "order": "FAMILY_FIRST", "given": "አይሪን", "family": "አድለር", "initials": "አ. አ."} +{"name": "ኣይሪን ኣድለር", "given": "ኣይሪን", "family": "ኣድለር", "initials": "ኣ. ኣ."} +{"name": "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል ባይቶ", "given": "ኣይተ", "middle": "በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል", "family": "ባይቶ", "initials": "ኣ. በ. ዊ. ሄ. ሮ. ዉ. ማ. ኣ. ባ."} +{"name": "ኣድለር ኣይሪን", "order": "FAMILY_FIRST", "given": "ኣይሪን", "family": "ኣድለር", "initials": "ኣ. ኣ."} +{"name": "ኣድለር፣ ኣይሪን", "order": "FAMILY_FIRST", "given": "ኣይሪን", "family": "ኣድለር፣", "initials": "ኣ. ኣ."} +{"name": "ካቲ ሙለር", "given": "ካቲ", "family": "ሙለር", "initials": "ካ. ሙ."} +{"name": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "order": "FAMILY_FIRST", "given": "አቶ", "middle": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "family": "ዉስተር", "initials": "አ. በ. ዊ. ሄ. ሮ. የ. አ. ዉ."} +{"name": "ዉስተር ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ", "order": "FAMILY_FIRST", "given": "ኣይተ", "middle": "በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ", "family": "ዉስተር", "initials": "ኣ. በ. ዊ. ሄ. ሮ. ማ. ኣ. ባ. ዉ."} +{"name": "ዉስተር፣ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት", "order": "FAMILY_FIRST", "given": "በርትረም", "middle": "ዊልበርፎርስ ሄንሪ ሮበርት", "family": "ዉስተር፣", "initials": "በ. ዊ. ሄ. ሮ. ዉ."} +{"name": "ዋትሰን ሜሪ ሱ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ሜሪ", "middle": "ሱ ሃሚሽ", "family": "ዋትሰን", "initials": "ሜ. ሱ. ሃ. ዋ."} +{"name": "ዋትሰን፣ ሜሪ ሱ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ሜሪ", "middle": "ሱ ሃሚሽ", "family": "ዋትሰን፣", "initials": "ሜ. ሱ. ሃ. ዋ."} +{"name": "ዘንዳያ", "given": "ዘንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ዘ."} +{"name": "ዘንዳያ", "order": "FAMILY_FIRST", "family": "ዘንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ዘ."} +{"name": "ዛዚሊያ ሃሚሽ ስቶበር", "given": "ዛዚሊያ", "middle": "ሃሚሽ", "family": "ስቶበር", "initials": "ዛ. ሃ. ስ."} +{"name": "ዜንዳያ", "given": "ዜንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ዜ."} +{"name": "ዜንዳያ", "order": "FAMILY_FIRST", "family": "ዜንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ዜ."} +{"name": "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ ዶ/ር", "given": "ፕ/ር", "middle": "ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ", "family": "ዶ/ር", "initials": "ፕ. ዶ. አ. ኮ. ሴ. ማ. ቮ. ብ. ጎ. ዶ. ዶ. የ. ዶ."} +{"name": "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "title": "ፕሮ. ዶር.", "given": "ኣዳ", "middle": "ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ", "family": "ስኒ", "initials": "ኣ. ኮ. ሲ. ማ. ቮ. ብ. ማ. ሓ. መ. ስ."} +{"name": "ᎠᏕᎸ ᎠᏪᏁ", "order": "FAMILY_FIRST", "given": "ᎠᏪᏁ", "family": "ᎠᏕᎸ", "initials": "Ꭰ. Ꭰ."} +{"name": "ᎠᏕᎸ, ᎠᏪᏁ", "given": "ᎠᏪᏁ", "family": "ᎠᏕᎸ", "initials": "Ꭰ. Ꭰ."} +{"name": "ᎠᏪᏁ ᎠᏕᎸ", "given": "ᎠᏪᏁ", "family": "ᎠᏕᎸ", "initials": "Ꭰ. Ꭰ."} +{"name": "ᎧᏖ ᎽᎴ", "given": "ᎧᏖ", "family": "ᎽᎴ", "initials": "Ꭷ. Ꮍ."} +{"name": "ᎨᏓᏯ", "given": "ᎨᏓᏯ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ꭸ."} +{"name": "ᎨᏓᏯ", "order": "FAMILY_FIRST", "family": "ᎨᏓᏯ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ꭸ."} +{"name": "ᎺᎵ ᏑᎢ ᎭᎻᏏ ᏩᏔᏒᎢ", "given": "ᎺᎵ", "middle": "ᏑᎢ ᎭᎻᏏ", "family": "ᏩᏔᏒᎢ", "initials": "Ꮊ. Ꮡ. Ꭽ. Ꮹ."} +{"name": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ, ᎷᏢ", "given": "ᎷᏢ", "family": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ", "initials": "Ꮇ. Ꮋ. Ꮗ. Ꮻ. Ꭾ. Ꮖ. Ꮼ. Ꮤ."} +{"name": "ᎽᎴ ᎧᏖ", "order": "FAMILY_FIRST", "given": "ᎧᏖ", "family": "ᎽᎴ", "initials": "Ꭷ. Ꮍ."} +{"name": "ᎽᎴ, ᎧᏖ", "given": "ᎧᏖ", "family": "ᎽᎴ", "initials": "Ꭷ. Ꮍ."} +{"name": "ᏆᏏᎵ ᎭᎻᏍᎯ ᏍᏙᏇᎢ", "given": "ᏆᏏᎵ", "middle": "ᎭᎻᏍᎯ", "family": "ᏍᏙᏇᎢ", "initials": "Ꮖ. Ꭽ. Ꮝ."} +{"name": "ᏇᎯ, ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ", "given": "ᎠᏓ", "middle": "ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ", "family": "ᏇᎯ", "initials": "Ꭰ. Ꭺ. Ꮟ. Ꮉ. Ꮙ. Ꮗ."} +{"name": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "given": "ᎷᎠ", "middle": "ᎠᎠᏚ", "family": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ", "initials": "Ꮇ. Ꭰ. Ꮙ. Ꮕ. Ꭰ. Ꭺ. Ꮟ. Ꮉ. Ꮙ. Ꮗ. Ꮤ."} +{"name": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "order": "FAMILY_FIRST", "given": "ᎷᎠ", "middle": "ᎠᎠᏚ", "family": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ", "initials": "Ꮇ. Ꭰ. Ꮙ. Ꮗ. Ꮙ. Ꮕ. Ꭰ. Ꭺ. Ꮟ. Ꮉ. Ꮤ."} +{"name": "ᏍᏙᏇᎢ ᏆᏏᎵ ᎭᎻᏍᎯ", "order": "FAMILY_FIRST", "given": "ᏆᏏᎵ", "middle": "ᎭᎻᏍᎯ", "family": "ᏍᏙᏇᎢ", "initials": "Ꮖ. Ꭽ. Ꮝ."} +{"name": "ᏍᏙᏇᎢ, ᏆᏏᎵ ᎭᎻᏍᎯ", "given": "ᏆᏏᎵ", "middle": "ᎭᎻᏍᎯ", "family": "ᏍᏙᏇᎢ", "initials": "Ꮖ. Ꭽ. Ꮝ."} +{"name": "ᏏᎾᏆᏗ", "given": "ᏏᎾᏆᏗ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ꮟ."} +{"name": "ᏏᎾᏆᏗ", "order": "FAMILY_FIRST", "family": "ᏏᎾᏆᏗ", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "Ꮟ."} +{"name": "ᏩᏔᏒᎢ ᎺᎵ ᏑᎢ ᎭᎻᏏ", "order": "FAMILY_FIRST", "given": "ᎺᎵ", "middle": "ᏑᎢ ᎭᎻᏏ", "family": "ᏩᏔᏒᎢ", "initials": "Ꮊ. Ꮡ. Ꭽ. Ꮹ."} +{"name": "ᏩᏔᏒᎢ, ᎺᎵ ᏑᎢ ᎭᎻᏏ", "given": "ᎺᎵ", "middle": "ᏑᎢ ᎭᎻᏏ", "family": "ᏩᏔᏒᎢ", "initials": "Ꮊ. Ꮡ. Ꭽ. Ꮹ."} +{"name": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ, ᎷᏢ", "order": "FAMILY_FIRST", "given": "ᎷᏢ", "family": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ", "initials": "Ꮇ. Ꮼ. Ꮋ. Ꮗ. Ꮻ. Ꭾ. Ꮖ. Ꮤ."} +{"name": "ᏬᏍᏖᎢ, ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ", "given": "ᏇᏔᎻ", "middle": "ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ", "family": "ᏬᏍᏖᎢ", "initials": "Ꮗ. Ꮻ. Ꭾ. Ꮖ. Ꮼ."} +{"name": "កាតេ មូលឡើ", "given": "កាតេ", "family": "មូលឡើ", "initials": "ក. ម."} +{"name": "ប្រ៊ូល, អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់", "given": "អាដា", "middle": "ខរនែលៀ ឆេសារ ម៉ាធីន វន់", "family": "ប្រ៊ូល", "initials": "អ. ខ. ឆ. ម. វ. ប."} +{"name": "មូលឡើ កាតេ", "order": "FAMILY_FIRST", "given": "កាតេ", "family": "មូលឡើ", "initials": "ក. ម."} +{"name": "មូលឡើ, កាតេ", "given": "កាតេ", "family": "មូលឡើ", "initials": "ក. ម."} +{"name": "រិទ្ធី មករា វ៉េង", "given": "រិទ្ធី", "middle": "មករា", "family": "វ៉េង", "initials": "រ. ម. វ."} +{"name": "លោក សុវណ្ណថេត សុធារ៉ា វេហា សមាជិកសភា", "given": "លោក", "middle": "សុវណ្ណថេត សុធារ៉ា វេហា", "family": "សមាជិកសភា", "initials": "ល. ស. ស. វ. ស."} +{"name": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ, ទន្ដបណ្ឌិត", "order": "FAMILY_FIRST", "given": "ទន្ដបណ្ឌិត", "family": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ", "initials": "ទ. វ. ប. ស. អ. ខ. ឆ. ម. ជ."} +{"name": "វេហា លោក សុវណ្ណថេត សុធារ៉ា សមាជិកសភា", "order": "FAMILY_FIRST", "given": "លោក", "middle": "សុវណ្ណថេត សុធារ៉ា សមាជិកសភា", "family": "វេហា", "initials": "ល. ស. ស. ស. វ."} +{"name": "វេហា, សុវណ្ណថេត សុធារ៉ា", "given": "សុវណ្ណថេត", "middle": "សុធារ៉ា", "family": "វេហា", "initials": "ស. ស. វ."} +{"name": "វ៉េង រិទ្ធី មករា", "order": "FAMILY_FIRST", "given": "រិទ្ធី", "middle": "មករា", "family": "វ៉េង", "initials": "រ. ម. វ."} +{"name": "វ៉េង, រិទ្ធី មករា", "given": "រិទ្ធី", "middle": "មករា", "family": "វ៉េង", "initials": "រ. ម. វ."} +{"name": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ, ទន្ដបណ្ឌិត", "given": "ទន្ដបណ្ឌិត", "family": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ", "initials": "ទ. ស. អ. ខ. ឆ. ម. វ. ប. ជ."} +{"name": "សុខា", "given": "សុខា", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ស."} +{"name": "សុខា", "order": "FAMILY_FIRST", "family": "សុខា", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ស."} +{"name": "សុង សុជាតិ", "order": "FAMILY_FIRST", "given": "សុជាតិ", "family": "សុង", "initials": "ស. ស."} +{"name": "សុង, សុជាតិ", "given": "សុជាតិ", "family": "សុង", "initials": "ស. ស."} +{"name": "សុជាតិ សុង", "given": "សុជាតិ", "family": "សុង", "initials": "ស. ស."} +{"name": "ស៊ីនប៊ែត", "given": "ស៊ីនប៊ែត", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ស."} +{"name": "ស៊ីនប៊ែត", "order": "FAMILY_FIRST", "family": "ស៊ីនប៊ែត", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "ស."} +{"name": "ស្តូបើ ហ្សាហ្សីលៀ ហេមីស", "order": "FAMILY_FIRST", "given": "ហ្សាហ្សីលៀ", "middle": "ហេមីស", "family": "ស្តូបើ", "initials": "ហ. ហ. ស."} +{"name": "ស្តូបើ, ហ្សាហ្សីលៀ ហេមីស", "given": "ហ្សាហ្សីលៀ", "middle": "ហេមីស", "family": "ស្តូបើ", "initials": "ហ. ហ. ស."} +{"name": "ហ្សាហ្សីលៀ ហេមីស ស្តូបើ", "given": "ហ្សាហ្សីលៀ", "middle": "ហេមីស", "family": "ស្តូបើ", "initials": "ហ. ហ. ស."} +{"name": "‏John‏ Smith", "given": "John", "family": "Smith", "initials": "J. S."} +{"name": "‏محمد بن سلمان‏", "given": "محمد", "family": "بن سلمان", "initials": "م. س."} +{"name": "∫≜⩕ Smith 😊", "given": "∫≜⩕", "family": "Smith", "initials": "∫. S."} +{"name": "∫≜⩕ Smith😊", "given": "∫≜⩕", "family": "Smith", "initials": "∫. S."} +{"name": "〆木 ひろ", "given": "ひろ", "family": "〆木", "initials": "ひ. 〆."} +{"name": "〆木 太郎", "given": "太郎", "family": "〆木", "initials": "太. 〆."} +{"name": "〆木太郎", "family": "〆木太郎", "initials": "〆."} +{"name": "あ゙い たろう", "given": "たろう", "family": "あ゙い", "initials": "た. あ."} +{"name": "さん", "family": "さん", "initials": "さ."} +{"name": "みなみ", "family": "みなみ", "initials": "み."} +{"name": "ア゙イ タロウ", "given": "ア゙イ", "family": "タロウ", "initials": "ア. タ."} +{"name": "ア゛イ タロウ", "given": "ア゛イ", "family": "タロウ", "initials": "ア. タ."} +{"name": "アインシュタイン・アルベルト", "order": "FAMILY_FIRST", "given": "アルベルト", "family": "アインシュタイン", "initials": "ア. ア."} +{"name": "アルベルト・アインシュタイン", "given": "アルベルト", "family": "アインシュタイン", "initials": "ア. ア."} +{"name": "ジェニファー・ソフィア・フォン・スミス・ジュニア博士", "given": "ソフィア", "middle": "フォン スミス ジュニア博士", "family": "ジェニファー", "initials": "ソ. フ. ス. ジ. ジ."} +{"name": "セシリア・ローズ・ブラウン", "given": "セシリア", "middle": "ローズ", "family": "ブラウン", "initials": "セ. ロ. ブ."} +{"name": "フォン・スミス・ソフィア・ジェニファー", "order": "FAMILY_FIRST", "given": "スミス", "middle": "ソフィア ジェニファー", "family": "フォン", "initials": "ス. ソ. ジ. フ."} +{"name": "フォン・スミス・ソフィア・ジェニファー博士", "order": "FAMILY_FIRST", "given": "スミス", "middle": "ソフィア ジェニファー博士", "family": "フォン", "initials": "ス. ソ. ジ. フ."} +{"name": "ブラウン・ローズ・セシリア", "order": "FAMILY_FIRST", "given": "ローズ", "middle": "セシリア", "family": "ブラウン", "initials": "ロ. セ. ブ."} +{"name": "マイケル", "given": "マイケル", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "マ."} +{"name": "マイケル", "order": "FAMILY_FIRST", "family": "マイケル", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "マ."} +{"name": "マイケル ア゙イ", "given": "マイケル", "family": "ア゙イ", "initials": "マ. ア."} +{"name": "マイケル ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} +{"name": "マイケル ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} +{"name": "マイケル.", "given": "マイケル.", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "マ."} +{"name": "マイケル·ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} +{"name": "マイケル・ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} +{"name": "一郎安藤", "family": "一郎安藤", "initials": "一."} +{"name": "佐々木 太郎", "given": "太郎", "family": "佐々木", "initials": "太. 佐."} +{"name": "佐藤 花子 \"旧姓・田中\" (旧姓 鈴木)", "given": "花子", "family": "佐藤", "nickname": "旧姓 田中", "maiden": "鈴木", "initials": "花. 佐."} +{"name": "佐藤グレース恵子", "order": "FAMILY_FIRST", "family": "佐藤グレース恵子", "initials": "佐."} +{"name": "佐藤グレース恵子さん", "order": "FAMILY_FIRST", "family": "佐藤グレース恵子", "suffix": "さん", "initials": "佐."} +{"name": "俊年杰思陈", "family": "俊年杰思陈", "initials": "俊."} +{"name": "先生子媚惠仪陈小,太平绅士", "given": "太平绅士", "family": "先生子媚惠仪陈小", "initials": "太. 先."} +{"name": "先生子媚惠儀陳小,太平紳士", "given": "太平紳士", "family": "先生子媚惠儀陳小", "initials": "太. 先."} +{"name": "克特·米勒", "given": "克特", "family": "米勒", "initials": "克. 米."} +{"name": "凯晴", "family": "凯晴", "initials": "凯."} +{"name": "凯晴", "order": "FAMILY_FIRST", "family": "凯晴", "initials": "凯."} +{"name": "凱晴", "family": "凱晴", "initials": "凱."} +{"name": "凱晴", "order": "FAMILY_FIRST", "family": "凱晴", "initials": "凱."} +{"name": "司马相如", "family": "司马相如", "initials": "司."} +{"name": "吳·永強·德安", "order": "FAMILY_FIRST", "given": "永強", "middle": "德安", "family": "吳", "initials": "永. 德. 吳."} +{"name": "吴·永强·德安", "order": "FAMILY_FIRST", "given": "永强", "middle": "德安", "family": "吴", "initials": "永. 德. 吴."} +{"name": "塞西莉亚·哈米什·施托贝尔", "given": "塞西莉亚", "middle": "哈米什", "family": "施托贝尔", "initials": "塞. 哈. 施."} +{"name": "夏侯惇", "family": "夏侯惇", "initials": "夏."} +{"name": "太郎トーマス山田", "family": "太郎トーマス山田", "initials": "太."} +{"name": "威廉·莎士比亚", "given": "威廉", "family": "莎士比亚", "initials": "威. 莎."} +{"name": "威廉·莎士比亚, PhD", "given": "威廉", "family": "莎士比亚", "suffix": "PhD", "initials": "威. 莎."} +{"name": "威廉·莎士比亚さん", "given": "威廉", "family": "莎士比亚", "suffix": "さん", "initials": "威. 莎."} +{"name": "威廉・莎士比亚", "given": "莎士比亚", "family": "威廉", "initials": "莎. 威."} +{"name": "宇瀚", "family": "宇瀚", "initials": "宇."} +{"name": "宇瀚", "order": "FAMILY_FIRST", "family": "宇瀚", "initials": "宇."} +{"name": "安东尼·陈志明", "given": "安东尼", "family": "陈志明", "initials": "安. 陈."} +{"name": "安怡张", "family": "安怡张", "initials": "安."} +{"name": "安藤一郎", "order": "FAMILY_FIRST", "family": "安藤一郎", "initials": "安."} +{"name": "家豪明德林", "family": "家豪明德林", "initials": "家."} +{"name": "小冯·布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·博士教授", "order": "FAMILY_FIRST", "given": "布鲁赫", "middle": "艾达 科妮莉亚 塞萨尔 马丁", "family": "小冯", "suffix": "博士 教授", "initials": "布. 艾. 科. 塞. 马. 小."} +{"name": "小彭德威东升议员先生", "order": "FAMILY_FIRST", "family": "小彭德威东升议员", "suffix": "先生", "initials": "小."} +{"name": "小德威东升彭议员先生", "family": "小德威东升彭议员", "suffix": "先生", "initials": "小."} +{"name": "小艾达·科妮莉亚·塞萨尔·马丁·冯·布鲁赫博士教授", "given": "小艾达", "middle": "科妮莉亚 塞萨尔 马丁 冯", "family": "布鲁赫博士", "suffix": "教授", "initials": "小. 科. 塞. 马. 冯. 布."} +{"name": "山田 ア゙イ", "given": "ア゙イ", "family": "山田", "initials": "ア. 山."} +{"name": "山田 エミ", "given": "エミ", "family": "山田", "initials": "エ. 山."} +{"name": "山田 太郎", "given": "太郎", "family": "山田", "initials": "太. 山."} +{"name": "山田 太郎 (マイケル・ジャクソン)", "given": "太郎", "family": "山田", "nickname": "マイケル ジャクソン", "initials": "太. 山."} +{"name": "山田 花子 旧姓 佐藤", "given": "花子", "family": "山田", "maiden": "佐藤", "initials": "花. 山."} +{"name": "山田 花子 旧姓:佐藤", "given": "山田", "middle": "花子", "family": "旧姓:佐藤", "initials": "山. 花. 旧."} +{"name": "山田 花子(旧姓 佐藤)", "given": "花子", "family": "山田", "maiden": "佐藤", "initials": "花. 山."} +{"name": "山田 「タロー」 タロウ", "given": "タロウ", "family": "山田", "nickname": "タロー", "initials": "タ. 山."} +{"name": "山田 タロウ", "given": "タロウ", "family": "山田", "initials": "タ. 山."} +{"name": "山田 ダイスケ", "given": "ダイスケ", "family": "山田", "initials": "タ. 山."} +{"name": "山田 ペーター", "given": "ペーター", "family": "山田", "initials": "ヘ. 山."} +{"name": "山田「タロ」太郎", "given": "太郎", "family": "山田", "nickname": "タロ", "initials": "太. 山."} +{"name": "山田トーマス太郎", "order": "FAMILY_FIRST", "family": "山田トーマス太郎", "initials": "山."} +{"name": "山田太郎様", "family": "山田太郎", "suffix": "様", "initials": "山."} +{"name": "山田花子 旧姓 佐藤", "family": "山田花子", "maiden": "佐藤", "initials": "山."} +{"name": "山田「タロー」太郎", "given": "太郎", "family": "山田", "nickname": "タロー", "initials": "太. 山."} +{"name": "山田タロウ", "family": "山田タロウ", "initials": "山."} +{"name": "布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·冯", "order": "FAMILY_FIRST", "given": "艾达", "middle": "科妮莉亚 塞萨尔 马丁 冯", "family": "布鲁赫", "initials": "艾. 科. 塞. 马. 冯. 布."} +{"name": "张伟", "family": "张伟", "initials": "张."} +{"name": "张安怡", "order": "FAMILY_FIRST", "family": "张安怡", "initials": "张."} +{"name": "张雅婷", "order": "FAMILY_FIRST", "family": "张雅婷", "initials": "张."} +{"name": "張雅婷", "order": "FAMILY_FIRST", "family": "張雅婷", "initials": "張."} +{"name": "彭德威东升", "order": "FAMILY_FIRST", "family": "彭德威东升", "initials": "彭."} +{"name": "怡君達印馮·陳二世博士,醫學博士", "given": "醫學博士", "family": "怡君達印馮 陳二世博士", "initials": "醫. 怡. 陳."} +{"name": "恵子グレース佐藤ジュニアさん", "family": "恵子グレース佐藤ジュニア", "suffix": "さん", "initials": "恵."} +{"name": "慎太郎", "family": "慎太郎", "initials": "慎."} +{"name": "慎太郎", "order": "FAMILY_FIRST", "family": "慎太郎", "initials": "慎."} +{"name": "教授·怡君·达印·陈·陈·小,博士", "given": "教授", "middle": "怡君 达印 陈 陈", "family": "小", "suffix": "博士", "initials": "教. 怡. 达. 陈. 陈. 小."} +{"name": "教授·怡君·達印·陳·陳·小,博士", "given": "教授", "middle": "怡君 達印 陳 陳", "family": "小", "suffix": "博士", "initials": "教. 怡. 達. 陳. 陳. 小."} +{"name": "文傑", "family": "文傑", "initials": "文."} +{"name": "文傑", "order": "FAMILY_FIRST", "family": "文傑", "initials": "文."} +{"name": "文杰", "family": "文杰", "initials": "文."} +{"name": "文杰", "order": "FAMILY_FIRST", "family": "文杰", "initials": "文."} +{"name": "施托贝尔·塞西莉亚·哈米什", "order": "FAMILY_FIRST", "given": "塞西莉亚", "middle": "哈米什", "family": "施托贝尔", "initials": "塞. 哈. 施."} +{"name": "林家豪明德", "order": "FAMILY_FIRST", "family": "林家豪明德", "initials": "林."} +{"name": "毛 泽东", "given": "泽东", "family": "毛", "initials": "泽. 毛."} +{"name": "毛 泽东 Dr.", "title": "Dr.", "given": "泽东", "family": "毛", "initials": "泽. 毛."} +{"name": "毛 김", "given": "毛", "family": "김", "initials": "毛. 김."} +{"name": "毛·泽东", "given": "毛", "family": "泽东", "initials": "毛. 泽."} +{"name": "毛泽东", "family": "毛泽东", "initials": "毛."} +{"name": "毛泽东 MA", "given": "毛泽东", "family": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "毛. M."} +{"name": "毛泽东, MA", "given": "MA", "family": "毛泽东", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. 毛."} +{"name": "永強·德安·吳", "given": "永強", "middle": "德安", "family": "吳", "initials": "永. 德. 吳."} +{"name": "永强·德安·吴", "given": "永强", "middle": "德安", "family": "吴", "initials": "永. 德. 吴."} +{"name": "永翘·黄", "given": "永翘", "family": "黄", "initials": "永. 黄."} +{"name": "永翹·黃", "given": "永翹", "family": "黃", "initials": "永. 黃."} +{"name": "王·Smith", "given": "王·Smith", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "王."} +{"name": "王·雅婷", "order": "FAMILY_FIRST", "given": "雅婷", "family": "王", "initials": "雅. 王."} +{"name": "王·雅婷·婷婷", "order": "FAMILY_FIRST", "given": "雅婷", "middle": "婷婷", "family": "王", "initials": "雅. 婷. 王."} +{"name": "王先生", "family": "王", "suffix": "先生", "initials": "王."} +{"name": "王先生, V.", "given": "V.", "family": "王", "suffix": "先生", "initials": "V. 王."} +{"name": "王君", "family": "王君", "initials": "王."} +{"name": "王小明 先生", "family": "王小明", "suffix": "先生", "initials": "王."} +{"name": "王小明先生", "family": "王小明", "suffix": "先生", "initials": "王."} +{"name": "王氏", "family": "王氏", "initials": "王."} +{"name": "王雅婷婷婷", "order": "FAMILY_FIRST", "family": "王雅婷婷婷", "initials": "王."} +{"name": "王雅婷婷婷二世議員", "order": "FAMILY_FIRST", "family": "王雅婷婷婷二世議員", "initials": "王."} +{"name": "田中 さん", "family": "田中", "suffix": "さん", "initials": "田."} +{"name": "田中 太郎 旧姓 佐藤 MA", "given": "太郎", "family": "田中", "suffix": "MA", "maiden": "佐藤", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "太. 田."} +{"name": "田中 太郎 様", "given": "太郎", "family": "田中", "suffix": "様", "initials": "太. 田."} +{"name": "田中 太郎, MA", "given": "太郎", "family": "田中", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "太. 田."} +{"name": "田中 殿", "family": "田中", "suffix": "殿", "initials": "田."} +{"name": "田中, 太郎 MA", "given": "太郎", "family": "田中", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "太. 田."} +{"name": "田中, 太郎さん", "given": "太郎", "family": "田中", "suffix": "さん", "initials": "太. 田."} +{"name": "田中.", "family": "田中.", "initials": "田."} +{"name": "田中. 太郎", "given": "太郎", "family": "田中.", "initials": "太. 田."} +{"name": "田中、太郎", "given": "田中、太郎", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "田."} +{"name": "田中『ハナ』花子", "given": "花子", "family": "田中", "nickname": "ハナ", "initials": "花. 田."} +{"name": "田中さん", "family": "田中", "suffix": "さん", "initials": "田."} +{"name": "田中さん II", "family": "田中", "suffix": "さん II", "initials": "田."} +{"name": "田中さん V.", "given": "田中さん", "family": "V.", "initials": "田. V."} +{"name": "田中さん 様.", "family": "田中", "suffix": "さん 様.", "initials": "田."} +{"name": "田中さん, Dr.", "title": "Dr.", "family": "田中さん", "initials": "田."} +{"name": "田中さん, Ph. D.", "family": "田中", "suffix": "さん, Ph. D.", "initials": "田."} +{"name": "田中さん, PhD", "family": "田中", "suffix": "さん, PhD", "initials": "田."} +{"name": "田中さん, V.", "given": "V.", "family": "田中", "suffix": "さん", "initials": "V. 田."} +{"name": "田中さん, 太郎", "given": "太郎", "family": "田中さん", "initials": "太. 田."} +{"name": "田中さん, 様.", "family": "田中", "suffix": "さん, 様.", "initials": "田."} +{"name": "田中さん.", "family": "田中", "suffix": "さん.", "initials": "田."} +{"name": "田中さん., V.", "given": "V.", "family": "田中", "suffix": "さん.", "initials": "V. 田."} +{"name": "田中博士", "family": "田中博士", "initials": "田."} +{"name": "米勒·克特", "order": "FAMILY_FIRST", "given": "克特", "family": "米勒", "initials": "克. 米."} +{"name": "諸葛亮", "family": "諸葛亮", "initials": "諸."} +{"name": "辛巴达", "family": "辛巴达", "initials": "辛."} +{"name": "辛巴达", "order": "FAMILY_FIRST", "family": "辛巴达", "initials": "辛."} +{"name": "阿明", "family": "阿明", "initials": "阿."} +{"name": "陈·怡君·达印", "order": "FAMILY_FIRST", "given": "怡君", "middle": "达印", "family": "陈", "initials": "怡. 达. 陈."} +{"name": "陈·陈·怡君·教授,小·达印·博士", "order": "FAMILY_FIRST", "given": "小", "middle": "达印", "family": "陈 陈 怡君", "suffix": "教授, 博士", "initials": "小. 达. 陈. 陈. 怡."} +{"name": "陈俊年杰思", "order": "FAMILY_FIRST", "family": "陈俊年杰思", "initials": "陈."} +{"name": "陈子媚先生,小惠仪太平绅士", "order": "FAMILY_FIRST", "given": "小惠仪太平绅士", "family": "陈子媚先生", "initials": "小. 陈."} +{"name": "陈子媚惠仪", "order": "FAMILY_FIRST", "family": "陈子媚惠仪", "initials": "陈."} +{"name": "陳·怡君·達印", "order": "FAMILY_FIRST", "given": "怡君", "middle": "達印", "family": "陳", "initials": "怡. 達. 陳."} +{"name": "陳·陳·怡君·教授,小·達印·博士", "order": "FAMILY_FIRST", "given": "小", "middle": "達印", "family": "陳 陳 怡君", "suffix": "教授, 博士", "initials": "小. 達. 陳. 陳. 怡."} +{"name": "陳子媚先生,小惠儀太平紳士", "order": "FAMILY_FIRST", "given": "小惠儀太平紳士", "family": "陳子媚先生", "initials": "小. 陳."} +{"name": "陳子媚惠儀", "order": "FAMILY_FIRST", "family": "陳子媚惠儀", "initials": "陳."} +{"name": "雅婷", "family": "雅婷", "initials": "雅."} +{"name": "雅婷", "order": "FAMILY_FIRST", "family": "雅婷", "initials": "雅."} +{"name": "雅婷婷婷王", "family": "雅婷婷婷王", "initials": "雅."} +{"name": "雅婷婷婷王二世先生,議員", "given": "議員", "family": "雅婷婷婷王二世先生", "initials": "議. 雅."} +{"name": "雅婷张", "family": "雅婷张", "initials": "雅."} +{"name": "雅婷張", "family": "雅婷張", "initials": "雅."} +{"name": "雅婷王", "family": "雅婷王", "initials": "雅."} +{"name": "馮·陳·怡君·達印", "order": "FAMILY_FIRST", "given": "陳", "middle": "怡君 達印", "family": "馮", "initials": "陳. 怡. 達. 馮."} +{"name": "馮·陳·怡君·達印·二世醫學博士", "order": "FAMILY_FIRST", "given": "陳", "middle": "怡君 達印 二世醫學博士", "family": "馮", "initials": "陳. 怡. 達. 二. 馮."} +{"name": "马丁·路德·金", "given": "马丁", "middle": "路德", "family": "金", "initials": "马. 路. 金."} +{"name": "马丁·路德·金씨", "given": "马丁", "middle": "路德", "family": "金", "suffix": "씨", "initials": "马. 路. 金."} +{"name": "高橋 みなみ", "given": "みなみ", "family": "高橋", "initials": "み. 高."} +{"name": "高橋みなみ", "family": "高橋みなみ", "initials": "高."} +{"name": "高橋・一郎", "given": "一郎", "family": "高橋", "initials": "一. 高."} +{"name": "高橋一郎", "family": "高橋一郎", "initials": "高."} +{"name": "鵜殿", "family": "鵜殿", "initials": "鵜."} +{"name": "黃·永翹", "order": "FAMILY_FIRST", "given": "永翹", "family": "黃", "initials": "永. 黃."} +{"name": "黄·永翘", "order": "FAMILY_FIRST", "given": "永翘", "family": "黄", "initials": "永. 黄."} +{"name": "강유미", "order": "FAMILY_FIRST", "given": "유미", "family": "강", "initials": "유. 강."} +{"name": "김 민준", "given": "민준", "family": "김", "initials": "민. 김."} +{"name": "김, 민준 MA", "given": "민준", "family": "김", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "민. 김."} +{"name": "김, 민준씨", "given": "민준", "family": "김", "suffix": "씨", "initials": "민. 김."} +{"name": "김, 민준씨 (Jimmy)", "given": "민준", "family": "김", "suffix": "씨", "nickname": "Jimmy", "initials": "민. 김."} +{"name": "김. 민준", "given": "민준", "family": "김.", "initials": "민. 김."} +{"name": "김゙민준", "given": "김゙민준", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "김."} +{"name": "김민규헨리", "order": "FAMILY_FIRST", "given": "민규헨리", "family": "김", "initials": "민. 김."} +{"name": "김민준", "given": "민준", "family": "김", "initials": "민. 김."} +{"name": "김민준 님", "given": "민준", "family": "김", "suffix": "님", "initials": "민. 김."} +{"name": "김민준 박, MA Jones", "given": "MA", "middle": "Jones", "family": "김민준 박", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "M. J. 김. 박."} +{"name": "김민준 박, X.Y.Z.", "given": "민준", "middle": "박", "family": "김", "suffix": "X.Y.Z.", "initials": "민. 박. 김."} +{"name": "김민준 박사", "given": "민준", "family": "김", "suffix": "박사", "initials": "민. 김."} +{"name": "김민준 박사 씨", "given": "민준", "family": "김", "suffix": "박사 씨", "initials": "민. 김."} +{"name": "김민준 박사님", "given": "민준", "family": "김", "suffix": "박사님", "initials": "민. 김."} +{"name": "김민준 씨", "given": "민준", "family": "김", "suffix": "씨", "initials": "민. 김."} +{"name": "김민준 씨.", "given": "민준", "family": "김", "suffix": "씨.", "initials": "민. 김."} +{"name": "김민준 씨。", "given": "민준", "family": "김", "suffix": "씨。", "initials": "민. 김."} +{"name": "김민준 씨.", "given": "민준", "family": "김", "suffix": "씨.", "initials": "민. 김."} +{"name": "김민준 씨。", "given": "민준", "family": "김", "suffix": "씨。", "initials": "민. 김."} +{"name": "김민준 양", "given": "민준", "family": "김", "suffix": "양", "initials": "민. 김."} +{"name": "김민준 양.", "given": "민준", "family": "김", "suffix": "양.", "initials": "민. 김."} +{"name": "김민준, 씨", "family": "김민준", "suffix": "씨", "initials": "김."} +{"name": "김민준, 씨.", "family": "김민준", "suffix": "씨.", "initials": "김."} +{"name": "김민준.", "given": "민준.", "family": "김", "initials": "민. 김."} +{"name": "김민준. 지훈", "given": "민준.", "middle": "지훈", "family": "김", "initials": "민. 지. 김."} +{"name": "김민준님", "given": "민준", "family": "김", "suffix": "님", "initials": "민. 김."} +{"name": "김민준박사님", "given": "민준", "family": "김", "suffix": "박사님", "initials": "민. 김."} +{"name": "김민준씨", "given": "민준", "family": "김", "suffix": "씨", "initials": "민. 김."} +{"name": "김민준씨 (Jimmy)", "given": "민준", "family": "김", "suffix": "씨", "nickname": "Jimmy", "initials": "민. 김."} +{"name": "김민준씨 Jr.", "given": "민준", "family": "김", "suffix": "씨 Jr.", "initials": "민. 김."} +{"name": "김민준씨, J.씨", "family": "김민준", "suffix": "씨, J.씨", "initials": "김."} +{"name": "김민준씨, MA PhD", "family": "김민준", "suffix": "씨, MA PhD", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "김."} +{"name": "김민준씨, V.", "given": "V.", "family": "김민준", "suffix": "씨", "initials": "V. 김."} +{"name": "김민준씨.", "given": "민준", "family": "김", "suffix": "씨.", "initials": "민. 김."} +{"name": "김민준씨., J.씨", "family": "김민준", "suffix": "씨., J.씨", "initials": "김."} +{"name": "김선생님", "family": "김", "suffix": "선생님", "initials": "김."} +{"name": "김씨", "family": "김", "suffix": "씨", "initials": "김."} +{"name": "김지양", "given": "지양", "family": "김", "initials": "지. 김."} +{"name": "남궁", "family": "남궁", "initials": "남."} +{"name": "남궁민수", "given": "민수", "family": "남궁", "ambiguities": ["SEGMENTATION"], "initials": "민. 남."} +{"name": "남궁민수 지훈", "given": "민수", "middle": "지훈", "family": "남궁", "ambiguities": ["SEGMENTATION"], "initials": "민. 지. 남."} +{"name": "남궁민수, 지훈", "given": "지훈", "family": "남궁민수", "initials": "지. 남."} +{"name": "마이클·잭슨", "given": "마이클", "family": "잭슨", "initials": "마. 잭."} +{"name": "마틴 킹, MA", "given": "틴", "middle": "킹", "family": "마", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "initials": "틴. 킹. 마."} +{"name": "뮐러 케테", "order": "FAMILY_FIRST", "given": "케테", "family": "뮐러", "initials": "케. 뮐."} +{"name": "민규헨리김", "given": "규헨리김", "family": "민", "initials": "규. 민."} +{"name": "박소현", "order": "FAMILY_FIRST", "given": "소현", "family": "박", "initials": "소. 박."} +{"name": "선생님", "family": "선생님", "initials": "선."} +{"name": "선생님, J.씨", "given": "J.", "family": "선생님", "suffix": "씨", "initials": "J. 선."} +{"name": "소현박", "given": "현박", "family": "소", "initials": "현. 소."} +{"name": "슈퇴버 제질리아 해미쉬", "order": "FAMILY_FIRST", "given": "제질리아", "middle": "해미쉬", "family": "슈퇴버", "initials": "제. 해. 슈."} +{"name": "신밧드", "given": "밧드", "family": "신", "initials": "밧. 신."} +{"name": "신밧드", "order": "FAMILY_FIRST", "given": "밧드", "family": "신", "initials": "밧. 신."} +{"name": "씨", "family": "씨", "initials": "씨."} +{"name": "양 미선", "given": "미선", "family": "양", "initials": "미. 양."} +{"name": "양 지훈", "given": "지훈", "family": "양", "initials": "지. 양."} +{"name": "양 지훈.", "given": "지훈.", "family": "양", "initials": "지. 양."} +{"name": "양. 지훈", "given": "지훈", "family": "양.", "initials": "지. 양."} +{"name": "에이다 코넬리아 세사르 마르틴 폰 볼프 박사", "given": "코넬리아", "middle": "세사르 마르틴 폰 볼프", "family": "에이다", "suffix": "박사", "initials": "코. 세. 마. 폰. 볼. 에."} +{"name": "유미", "given": "미", "family": "유", "initials": "미. 유."} +{"name": "유미", "order": "FAMILY_FIRST", "given": "미", "family": "유", "initials": "미. 유."} +{"name": "유미강", "given": "미강", "family": "유", "initials": "미. 유."} +{"name": "이, J.씨", "given": "J.", "family": "이", "suffix": "씨", "initials": "J. 이."} +{"name": "이, J.씨.", "given": "J.", "family": "이", "suffix": "씨.", "initials": "J. 이."} +{"name": "제질리아 해미쉬 슈퇴버", "given": "질리아", "middle": "해미쉬 슈퇴버", "family": "제", "initials": "질. 해. 슈. 제."} +{"name": "지훈, 남궁민수", "given": "남궁민수", "family": "지훈", "initials": "남. 지."} +{"name": "케테 뮐러", "given": "뮐러", "family": "케테", "initials": "뮐. 케."} +{"name": "폰 볼프 에이다 코넬리아 세사르 마르틴", "order": "FAMILY_FIRST", "given": "볼프", "middle": "에이다 코넬리아 세사르 마르틴", "family": "폰", "initials": "볼. 에. 코. 세. 마. 폰."} +{"name": "폰 볼프 에이다 코넬리아 세사르 마르틴 박사", "order": "FAMILY_FIRST", "given": "볼프", "middle": "에이다 코넬리아 세사르 마르틴", "family": "폰", "suffix": "박사", "initials": "볼. 에. 코. 세. 마. 폰."} +{"name": "タナカ. John", "given": "タナカ.", "family": "John", "initials": "タ. J."} +{"name": "タロウ·ヤマダ", "given": "タロウ", "family": "ヤマダ", "initials": "タ. ヤ."} +{"name": "マイケル.", "given": "マイケル.", "ambiguities": ["GIVEN_OR_FAMILY"], "initials": "マ."} +{"name": "ヤマダ タロウ", "given": "ヤマダ", "family": "タロウ", "initials": "ヤ. タ."} diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py new file mode 100644 index 00000000..93194929 --- /dev/null +++ b/tools/differential/snapshot.py @@ -0,0 +1,183 @@ +"""Every corpus name's current reading, committed -- a snapshot. + +snapshot.jsonl holds one line per (name, order) across every +corpus*.jsonl beside this script: the seven role fields, the reported +ambiguity kinds and initials() on the 2.0 surface, plus the facade's +v1 fields wherever they say something the 2.0 row does not. +tests/v2/test_snapshot.py fails when the tree's readings stop matching +it. A change that moves readings re-records, and the PR diff of +snapshot.jsonl is the list of names it moved -- one line per name, so +the review reads it directly and nothing has to be classified. + + uv run python tools/differential/snapshot.py # check, grouped + uv run python tools/differential/snapshot.py --record # re-record + uv run python tools/differential/snapshot.py --since v2.5.0 # vs a ref + +--since REF compares the tree against the snapshot as committed at a +git ref, which is how a release asks what moved since the last tag. +--tree PATH reads with the nameparser package of another checkout +(the corpus is always this one's), for comparing two trees. +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import subprocess +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +SNAPSHOT = HERE / "snapshot.jsonl" +RECORD_COMMAND = "uv run python tools/differential/snapshot.py --record" +V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", + "maiden") +#: The facade's v1 field each 2.0 field corresponds to. +V1_FOR = dict(zip(V2_FIELDS, ("title", "first", "middle", "last", "suffix", + "nickname", "maiden"))) + + +def _shapes() -> dict[int, Any]: + spec = importlib.util.spec_from_file_location("_shapes", HERE / "shapes.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.SHAPES # type: ignore[no-any-return] + + +def corpus_entries() -> list[tuple[str, str | None]]: + """Every distinct (name, order) in the corpora, sorted.""" + orders = {k: s.order for k, s in _shapes().items()} + found: set[tuple[str, str | None]] = set() + for path in sorted(HERE.glob("corpus*.jsonl")): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + entry = json.loads(line) + if isinstance(entry, str): + found.add((entry, None)) + else: + found.add((entry["name"], orders.get(entry.get("shape")))) + return sorted(found, key=lambda e: (e[0], e[1] or "")) + + +def read(entries: list[tuple[str, str | None]]) -> list[dict[str, Any]]: + import nameparser + from nameparser import HumanName, Parser, Policy + + parsers: dict[str | None, Any] = {None: Parser()} + rows = [] + for name, order in entries: + if order not in parsers: + parsers[order] = Parser(policy=Policy(name_order=getattr(nameparser, order))) + p = parsers[order].parse(name) + row: dict[str, Any] = {"name": name} + if order: + row["order"] = order + row.update({f: v for f in V2_FIELDS if (v := getattr(p, f) or "")}) + if kinds := sorted({a.kind.name for a in p.ambiguities}): + row["ambiguities"] = kinds + if initials := p.initials() or "": + row["initials"] = initials + if order is None: + # The facade is a translation of the same parse; record it + # only where it reads the name differently. + hn = HumanName(name) + v1 = hn.as_dict() + facade = {f: v1[V1_FOR[f]] or "" for f in V2_FIELDS + if (v1[V1_FOR[f]] or "") != (getattr(p, f) or "")} + if (hn.initials() or "") != initials: + facade["initials"] = hn.initials() or "" + if facade: + row["facade"] = facade + rows.append(row) + return rows + + +def key(row: dict[str, Any]) -> tuple[str, str]: + return row["name"], row.get("order", "") + + +def dump(rows: list[dict[str, Any]]) -> str: + return "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows) + + +def load(text: str) -> dict[tuple[str, str], dict[str, Any]]: + return {key(r): r for r in map(json.loads, text.splitlines()) if r} + + +def _flat(row: dict[str, Any]) -> dict[str, str]: + out = {k: ("|".join(v) if isinstance(v, list) else v) + for k, v in row.items() if k not in ("name", "order", "facade")} + out.update({f"facade.{k}": v for k, v in row.get("facade", {}).items()}) + return out + + +def grouped(old: dict[tuple[str, str], dict[str, Any]], + new: dict[tuple[str, str], dict[str, Any]]) -> list[str]: + """The moves, grouped by which fields changed, so a large move reads + as a few kinds of change rather than a wall of names.""" + groups: dict[tuple[str, ...], list[str]] = defaultdict(list) + for k in sorted(set(old) | set(new)): + label = k[0] + (f" [{k[1]}]" if k[1] else "") + if k not in new: + groups[("(removed from corpus)",)].append(label) + continue + if k not in old: + groups[("(new to corpus)",)].append(label) + continue + a, b = _flat(old[k]), _flat(new[k]) + fields = tuple(sorted(f for f in set(a) | set(b) if a.get(f) != b.get(f))) + if fields: + groups[fields].append(f"{label}\n " + "; ".join( + f"{f}: {a.get(f, '')!r} -> {b.get(f, '')!r}" for f in fields)) + out = [] + for fields, names in sorted(groups.items(), key=lambda g: -len(g[1])): + out.append(f"{', '.join(fields)}: {len(names)}") + out.extend(f" {n}" for n in names) + return out + + +def _use_tree(path: str) -> None: + root = Path(path).resolve() + sys.path.insert(0, str(root)) + import nameparser + got = Path(nameparser.__file__).resolve() + if root not in got.parents: + sys.exit(f"--tree {root}: imported nameparser from {got} instead") + + +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("--out", type=Path, default=SNAPSHOT, + help="where --record writes (default: snapshot.jsonl)") + args = ap.parse_args(argv) + if args.tree: + _use_tree(args.tree) + rows = read(corpus_entries()) + if args.record: + args.out.write_text(dump(rows), encoding="utf-8") + print(f"recorded {len(rows)} readings to {args.out}") + return 0 + if args.since: + rel = SNAPSHOT.relative_to(Path(subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], cwd=HERE, text=True).strip())) + text = subprocess.check_output(["git", "show", f"{args.since}:{rel}"], + cwd=HERE, text=True) + else: + text = SNAPSHOT.read_text(encoding="utf-8") + moves = grouped(load(text), {key(r): r for r in rows}) + print("\n".join(moves) or "matches the snapshot") + if moves and not args.since: + print(f"\nif these moves are intended: {RECORD_COMMAND}") + return 1 if moves else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 2217903166b2fdbd8bc9a5adcdf275a4bad9a242 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:01:33 -0700 Subject: [PATCH 2/9] Re-record the snapshot for #663's new corpus name Co-Authored-By: Claude Opus 5.5 --- tools/differential/snapshot.jsonl | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index 2234d57b..21c86923 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -467,6 +467,7 @@ {"name": "Dr. Williams", "title": "Dr.", "family": "Williams", "initials": "W."} {"name": "Dr. abdul salam", "title": "Dr.", "given": "abdul", "family": "salam", "initials": "a. s."} {"name": "Dr. abdul salam ahmed salem", "title": "Dr.", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s."} +{"name": "Dr. inž. Janez Novak", "title": "Dr. inž.", "given": "Janez", "family": "Novak", "initials": "J. N."} {"name": "Dr. juan garcia", "title": "Dr.", "given": "juan", "family": "garcia", "initials": "j. g."} {"name": "Dr. med. univ.", "title": "Dr. med.", "family": "univ.", "initials": "u."} {"name": "Dr. med. univ. Margit Popp", "title": "Dr. med. univ.", "given": "Margit", "family": "Popp", "initials": "M. P."} From 496e59a689ec04428ec8bd3a60a798c99bfef864 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:15:03 -0700 Subject: [PATCH 3/9] Replace the differential ledgers with the committed snapshot (#664) Deletes the six expected_since_*.toml ledgers and tests/v2/test_ledger_guards.py. The snapshot (tools/differential/ snapshot.py and tests/v2/test_snapshot.py) is now how a change shows which corpus names it moved: the PR diff of snapshot.jsonl. compare.py keeps the released-wheel runner and its checks against a comparison of one tree with itself (version tell, path checks, stripped PYTHONPATH), and prints the moves grouped by changed fields through snapshot.py's row format and grouping. It classifies nothing and exits 0. Corpus-line validation moved into snapshot.py, so both tools read the corpora through one loader. With the contract/radar tiers gone, the CJK corpus is one file again (corpus_cjk_tolerated.jsonl folded into corpus_cjk.jsonl, same 153 names) and build_rules_corpus.py stops skipping tolerated rules' examples (8 W3 texts the CJK corpus already held); the snapshot is unchanged by both. The corpus-generation pins move to tests/v2/test_corpora.py, the phrase-entry storage test to test_lexicon.py; test_doc_citations.py stops sweeping ledger comments and tests the lead forms only the ledgers exemplified directly against the grammar. Co-Authored-By: Claude Opus 5.5 --- nameparser/config/_invariants.py | 5 +- tests/test_initials.py | 19 +- tests/v2/_differential_fixtures.py | 113 +- tests/v2/cases.py | 116 +- tests/v2/test_config_aliases.py | 3 +- tests/v2/test_corpora.py | 126 + tests/v2/test_differential.py | 4839 +-------- tests/v2/test_doc_citations.py | 28 +- tests/v2/test_ledger_guards.py | 9001 ----------------- tests/v2/test_lexicon.py | 23 + tests/v2/test_regex_sync.py | 5 - tests/v2/test_render.py | 3 +- tests/v2/test_rules_doc_grammar.py | 8 +- tests/v2/test_snapshot.py | 21 +- tools/differential/build_cjk_corpus.py | 102 +- tools/differential/build_cldr_corpus.py | 5 +- tools/differential/build_rules_corpus.py | 45 +- tools/differential/build_shapes_corpus.py | 23 +- tools/differential/compare.py | 3497 +------ tools/differential/corpus_cjk.jsonl | 69 + tools/differential/corpus_cjk_tolerated.jsonl | 69 - tools/differential/corpus_rules.jsonl | 8 + tools/differential/expected_since_1.4.0.toml | 4981 --------- tools/differential/expected_since_2.0.0.toml | 4101 -------- tools/differential/expected_since_2.1.0.toml | 4045 -------- tools/differential/expected_since_2.2.0.toml | 2534 ----- tools/differential/expected_since_2.3.0.toml | 1815 ---- tools/differential/expected_since_2.4.0.toml | 63 - tools/differential/shapes.py | 33 +- tools/differential/snapshot.py | 161 +- 30 files changed, 762 insertions(+), 35099 deletions(-) create mode 100644 tests/v2/test_corpora.py delete mode 100644 tests/v2/test_ledger_guards.py delete mode 100644 tools/differential/corpus_cjk_tolerated.jsonl delete mode 100644 tools/differential/expected_since_1.4.0.toml delete mode 100644 tools/differential/expected_since_2.0.0.toml delete mode 100644 tools/differential/expected_since_2.1.0.toml delete mode 100644 tools/differential/expected_since_2.2.0.toml delete mode 100644 tools/differential/expected_since_2.3.0.toml delete mode 100644 tools/differential/expected_since_2.4.0.toml diff --git a/nameparser/config/_invariants.py b/nameparser/config/_invariants.py index ab08ae23..eb3f94d4 100644 --- a/nameparser/config/_invariants.py +++ b/nameparser/config/_invariants.py @@ -30,9 +30,8 @@ hygiene depend on the parser, and the question worth asking is not "is this entry pre-folded" but "does ``Lexicon`` store it unchanged". That is one assertion over ``_PHRASE_FIELDS`` in -``tests/v2/test_ledger_guards.py``, the suite that already imports the -parser's fold for exactly this purpose, and it is where an entry -written ``'z. domu'`` is caught. +``tests/v2/test_lexicon.py``, and it is where an entry written +``'z. domu'`` is caught. """ from __future__ import annotations diff --git a/tests/test_initials.py b/tests/test_initials.py index 59c1bea8..b8e324e4 100644 --- a/tests/test_initials.py +++ b/tests/test_initials.py @@ -292,22 +292,9 @@ def test_initials_follow_the_one_case_fork_on_both_letters(self) -> None: # 'y' outside the marked set is still the connective. hn = HumanName("maria y lopez") self.m(hn.initials(), "m. y. l.", hn) - # These two are among #528's four movers, and the #528 ledger - # rule's own name_regex (a literal alternation, in - # tools/differential/expected_since_1.4.0.toml) covers all four - # names by itself -- but only "john e jones" also has a row in - # _CROSS_RULE_WINNERS (tests/v2/test_ledger_guards.py) and - # _RECORDED_DIFFS (tools/differential/compare.py): those two - # Python dicts, keyed by the LEDGER FILENAME rather than - # anything stored in the TOML, record that - # "fix(initials-per-word) a connective run initials each word" - # also reaches "john e jones" through its lowercase ' e ' and - # contests the name with #528's rule. "jones, john e" has no - # row in either: that connective-run rule's operative half is a - # `(?=\s)` lookahead requiring whitespace AFTER the letter, and - # a letter at the end of the string can never satisfy it -- so - # the comma form is a distinct shape from the space-written - # form even though both give the same initials() answer. + # These two are among #528's four movers, written once with + # the letter mid-name and once at the end after a comma: both + # give the same initials() answer by different routes. hn = HumanName("john e jones") self.m(hn.initials(), "j. e. j.", hn) hn = HumanName("jones, john e") diff --git a/tests/v2/_differential_fixtures.py b/tests/v2/_differential_fixtures.py index 115bfb03..eb70a6be 100644 --- a/tests/v2/_differential_fixtures.py +++ b/tests/v2/_differential_fixtures.py @@ -1,70 +1,38 @@ -"""Shared handles on the differential harness's own data. +"""Shared handles on tools/differential/: its corpora and its modules. -Imported by tests/v2/test_ledger_guards.py and tests/v2/test_differential.py, -which both need to reach `tools/differential/` -- the ledgers, the -corpora, and compare.py itself. Neither is importable the ordinary way: -`tools/` is outside `testpaths` and is not a package, so compare.py is -loaded by path, and the ledgers and corpora are data files a Python -module could not import if it wanted to. +Neither is importable the ordinary way: `tools/` is outside +`testpaths` and is not a package, so its modules are loaded by path, +and the corpora are data files. Not a conftest: these are constants and plain helpers, not fixtures in -pytest's sense, and two modules want them by name. Not a test module -either -- the guards that keep this data honest live next to the tests -that rely on it, in test_ledger_guards.py. +pytest's sense, and several modules want them by name. """ import importlib.util import json -import re -import tomllib from pathlib import Path from types import ModuleType -from nameparser import _policy - _TOOLS = Path(__file__).parents[2] / "tools" / "differential" -#: Every baseline's ledger, swept rather than named. #332 added a second -#: file whose four hand copies went unchecked because the pins in -#: test_ledger_guards.py named the 1.4 one by filename, and the count grows by one per -#: release -- see AGENTS.md's release step 8. -_LEDGERS = sorted(_TOOLS.glob("expected_since_*.toml")) - def _entry_name(raw: object) -> str: - """A corpus line's name, whichever format the line uses. Mirrors - compare.py's _load_entries: a line is a bare JSON string or an - object carrying "name".""" + """A corpus line's name, whichever format the line uses: a bare + JSON string or an object carrying "name".""" if isinstance(raw, str): return raw assert isinstance(raw, dict) and isinstance(raw.get("name"), str), raw return raw["name"] -#: Corpus files the harness compares but whose names this population -#: leaves out. corpus_cldr.jsonl (2026-10-12): about 1,900 fictional -#: CLDR renderings whose ground truth the agreement ratchet in -#: test_cldr_agreement.py enforces. Counted here they moved the -#: recorded reach of fourteen 1.4.0-ledger rules at once (the comma -#: rules 461 -> 771 names, feat(#269) 6 -> 196), and the decision was -#: to keep the recorded claims about the names the ledgers were written -#: for. The harness still classifies CLDR diffs against the ledger on -#: the radar tier; only this population does not measure them. compare.py -#: holds the same set as its own _UNMEASURED_CORPORA, which keeps these -#: names out of contest adjudication too; test_differential.py holds the -#: two equal. +#: Corpus files _CORPUS_NAMES leaves out. corpus_cldr.jsonl +#: (2026-10-12): about 1,900 fictional CLDR renderings whose ground +#: truth the agreement ratchet in test_cldr_agreement.py enforces. The +#: tests reading _CORPUS_NAMES carry counts measured over the names the +#: corpora held before it arrived. _UNMEASURED_CORPORA = frozenset({"corpus_cldr.jsonl"}) -#: Every name the harness classifies, deduplicated, except the files in -#: _UNMEASURED_CORPORA. The ledgers exist to explain diffs on THESE -#: strings, so "what does this rule claim?" is answerable here without -#: parsing anything -- a plain regex search, no baseline wheel, no -#: network. -#: -#: This is what test_ledger_guards.py's corpus checks read, and why -#: they hold where four rounds of syntactic ones did not. Depth-0 pipes, nesting -#: levels and probe strings are all proxies for the question that -#: actually matters; a rule cannot widen its corpus reach and still -#: answer this one the same way, however it is spelled. +#: Every corpus name, deduplicated, except the files in +#: _UNMEASURED_CORPORA. _CORPUS_NAMES = sorted({ _entry_name(json.loads(line)) for path in sorted(_TOOLS.glob("corpus*.jsonl")) @@ -72,62 +40,13 @@ def _entry_name(raw: object) -> str: for line in path.read_text(encoding="utf-8").splitlines() if line.strip()}) -def _claimed(name_regex: str) -> list[str]: - """Corpus names a rule's regex matches.""" - return [name for name in _CORPUS_NAMES if re.search(name_regex, name)] - - -def _unclassified_names() -> list[str]: - """Corpus names carrying no codepoint _SCRIPT_RANGES classifies.""" - has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES) - return [name for name in _CORPUS_NAMES if not has_classified(name)] - - -#: Built once. The expression this replaced sat inside a -#: comprehension's condition, so it rebuilt the script matcher AND -#: rescanned all 751 names per candidate name rather than per rule -- -#: measured around 400x a frozenset lookup, machine-dependent. The -#: rescan was the cost; the rebuild alone is minor. -_UNCLASSIFIED_NAMES = frozenset(_unclassified_names()) - - -def _rules(ledger: Path) -> list[dict]: - """The [[change]] table of one ledger.""" - # .get, matching compare.py. The open cycle's ledger is created at - # release with no `change` key at all -- an empty [[change]] array - # cannot be appended to in TOML -- so an absent key IS the empty - # ledger here, not a malformed file. What stops that leniency from - # hiding a typo'd table header lives in tests/v2/test_differential.py: - # every other ledger must be non-empty, and the open one may define - # nothing but `change`. - return tomllib.loads( - ledger.read_text(encoding="utf-8")).get("change", []) - - -def _exclusions(ledger: Path) -> list[dict]: - """The [[never]] table of one ledger. - - Same .get default as _rules and for the same reason: a ledger with - no exclusions is the normal case, not a malformed file. - """ - return tomllib.loads( - ledger.read_text(encoding="utf-8")).get("never", []) - - def load_tool(stem: str) -> ModuleType: """A module from tools/differential/, loaded by path. `tools/` is outside testpaths, and adding it would run --doctest-modules over the corpus builders, so these are imported - this way rather than made importable. Two callers need it and - wrote the same six lines each: test_differential.py for compare.py, - test_ledger_guards.py for build_cjk_corpus.py. - - None of them has import-time side effects -- compare.py's main() is - behind a __name__ guard, and build_cjk_corpus.py only defines - functions -- so importing them to read a constant or call one - function is safe. - """ + this way rather than made importable. None has import-time side + effects: each main() is behind a __name__ guard.""" spec = importlib.util.spec_from_file_location( f"differential_{stem}", _TOOLS / f"{stem}.py") assert spec is not None and spec.loader is not None, stem diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 78c8bdfe..e1904a43 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -45,7 +45,7 @@ _order_repr, _script_matcher) #: mirrors tools/differential/shapes.py's SHAPES keys; -#: test_case_shape_ids_exist_in_the_inventory (test_ledger_guards.py) +#: test_case_shape_ids_exist_in_the_inventory (test_corpora.py) #: holds the two equal, since this file cannot import tools/. _SHAPE_IDS = frozenset({1, 2, 3, 4, 5, 6, 7}) @@ -110,24 +110,15 @@ class Case: ambiguities: tuple[str, ...] = () # expected AmbiguityKind values notes: str = "" #: input-shape id from tools/differential/shapes.py (#468/#469). - #: Tagging a row admits its text to the differential's CONTRACT - #: corpus (see tools/differential/shapes.py, projected into - #: corpus_shapes.jsonl by build_shapes_corpus.py beside it) under - #: the shape's name_order. For shapes 6/7 that admission is - #: NOMINAL: a pure CJK text is already in corpus_cjk.jsonl, also - #: contract, and compare.py's (name, order) dedup collapses the - #: two -- the tag buys the coverage answer, not a comparison. - #: Optional: a row - #: exercising a policy fork rather than an input shape stays - #: untagged. + #: Tagging a row puts its text in corpus_shapes.jsonl (projected by + #: build_shapes_corpus.py), read under the shape's name_order, so + #: the snapshot watches it there. Optional: a row exercising a + #: policy fork rather than an input shape stays untagged. shape: int | None = None #: Marks a row's text as TOLERATED input (2026-09-01 CJK demotion): - #: read best-effort, exempt from the DIFFERENTIAL's contract tier - #: -- not from this table, whose expectations are asserted by the - #: suite either way, and a behavior change on a tolerated row still - #: edits its classification. The opposite of a shape - #: tag -- mutually exclusive with `shape`, since a shape ADMITS a - #: text to the contract and tolerated deliberately does not. Every + #: read best-effort, rules.md#W3. This table still asserts its + #: expectation, and a behavior change on a tolerated row still edits + #: its classification. Mutually exclusive with `shape`. Every #: composed/wrapped CJK form (a comma listing, a Latin title or #: credential around a CJK name, since 2026-09-05 a trailing ASCII #: period on an honorific, and since #323 an edge full stop of any @@ -136,10 +127,8 @@ class Case: #: it exists to demote composed/wrapped CJK forms specifically, and #: a Latin row asking for it is a smell until some future arc #: argues otherwise. build_cjk_corpus.py reads the flag: a - #: tolerated row's text goes to the radar-tier - #: corpus_cjk_tolerated.jsonl instead of the contract - #: corpus_cjk.jsonl, and clearing the flag promotes it back at the - #: next regeneration. Mark every row of a text, or none: the + #: tolerated row's text goes to corpus_cjk_tolerated.jsonl instead + #: of corpus_cjk.jsonl. Mark every row of a text, or none: the #: generator reads the flag per TEXT (a corpus line is a name #: string) and HARD-ERRORS on a split declaration, which #: __post_init__ cannot catch from inside one row. @@ -160,8 +149,8 @@ def __post_init__(self) -> None: else: self._check_latin_shape_order() # tolerated is the opposite of a shape tag: a reviewed act - # admitting a composed/wrapped CJK form to the radar corpus - # rather than the contract one. Checked regardless of which + # marking a composed/wrapped CJK form as best-effort input + # rather than a supported shape. Checked regardless of which # branch above ran (or whether shape was tagged at all), so a # row cannot smuggle both declarations onto one text. if self.tolerated: @@ -508,10 +497,7 @@ def _check_cjk_shape_purity(self) -> None: "against the surname, so 2.0.0 through 2.2.0 read " "suffix 'Rai' with NO family name at all. 1.4.0 read " "family 'Rai' and this restores that reading, which is " - "why it classifies parity rather than fix, and why " - "the 1.4.0 ledger replaces a NOT WANTED rule where the " - "three 2.x ledgers gain a new one, and its heading " - "explains four names where theirs explain five. Named " + "why it classifies parity rather than fix. Named " "in the release note"), Case("removed_credential_loses_its_suffix_reading", "John Smith RAI", {"given": "John", "middle": "Smith", "family": "RAI"}, @@ -603,8 +589,8 @@ def _check_cjk_shape_purity(self) -> None: # slot the suffix vocabulary claims, the last word of a name. No rule # moves: these rows are rules.md#S2's forks read through two more # members, written all-lower so the count decides unless the - # writing says otherwise, and shape-tagged so the differential's - # contract tier sees them -- no corpus name had either word. + # writing says otherwise, and shape-tagged so the snapshot sees + # them -- no corpus name had either word. Case("bare_meng_is_a_surname", "wang meng", {"given": "wang", "family": "meng"}, ambiguities=("suffix-or-name",), @@ -1544,10 +1530,9 @@ def _check_cjk_shape_purity(self) -> None: classification="fix(suffix-routing)", ambiguities=("given-or-family",), notes="#454, dispositioned by design with this bundle. " - "1.4.0 read last 'Mc'; the 1.4.0 ledger's " - "fix(suffix-routing) rule -- a two-token name ending " - "in a credential acronym keeps it in `suffix` -- is " - "what explains the divergence. This is the one " + "1.4.0 read last 'Mc'; fix(suffix-routing) -- a " + "two-token name ending in a credential acronym keeps " + "it in `suffix` -- is the divergence. This is the one " "row whose point is that it must NOT change. 'mc' is " "SUFFIX_ACRONYMS and PARTICLES both, and it is not a " "borne name: it has no vowel, and the Scottish prefix " @@ -2190,11 +2175,10 @@ def _check_cjk_shape_purity(self) -> None: # ground. # # All 17 rows are shape-tagged so build_shapes_corpus.py projects - # them into the contract corpus -- measured 2026-09-13, no corpus + # them into corpus_shapes.jsonl -- measured 2026-09-13, no corpus # file held a uniform-case name with a single-letter connective # beyond the eight already there, and none held a standalone و at - # all, so without these the fork is invisible to every future gate - # run. + # all, so without these the fork is invisible to the snapshot. Case("one_case_lower_e_reads_as_an_initial", "jose e maria santos", {"given": "jose", "middle": "e maria", "family": "santos"}, classification="fix(#479)", @@ -3214,11 +3198,11 @@ def _check_cjk_shape_purity(self) -> None: # Korean names are why the trailing slots stay off # (mechanisms.md#VOCABULARY-EXERCISES-FORKS -- each pair pins the # setting, not the words). A row that sets - # the non-default policy carries no `shape=` tag: the contract - # corpus (build_shapes_corpus.py) keys only on (shape, text), with - # no policy of its own, so admitting one of these texts under a - # shape id would have compare.py diff it against the released - # wheels under the DEFAULT policy -- the wrong question for a row + # the non-default policy carries no `shape=` tag: corpus_shapes.jsonl + # (build_shapes_corpus.py) keys only on (shape, text), with no + # policy of its own, so admitting one of these texts under a shape + # id would have the snapshot read it under the DEFAULT policy -- + # the wrong question for a row # whose point IS the non-default policy. Case("caps_surname_is_a_family_name_by_default", "Jean DUPONT", {"given": "Jean", "family": "DUPONT"}, @@ -3516,9 +3500,9 @@ def _check_cjk_shape_purity(self) -> None: # their DEFAULT-policy siblings above (`caps_ambiguous_leans_ # credential`, `the_lean_reaches_the_post_comma_slot`) and carry # no `shape=` tag for the same reason every other policy-on row - # here does not: the contract corpus keys on (shape, text) with no - # policy of its own, so tagging one would have the gate diff it - # against the released wheels under the DEFAULT policy -- the + # here does not: corpus_shapes.jsonl keys on (shape, text) with no + # policy of its own, so tagging one would have the snapshot read it + # under the DEFAULT policy -- the # wrong question for a row whose point IS the non-default policy # (`Case.shape`'s own docstring). Case("caps_switch_does_not_silence_the_listed_lean", "Jack MA", @@ -4095,16 +4079,16 @@ def _check_cjk_shape_purity(self) -> None: {"given": "Carod", "suffix": "i"}, classification="fix(suffix-routing)", ambiguities=("given-or-family",), - notes="an ACCEPTED COST, pinned so the ledger rule that " - "records it has a witness in this table. Two pieces " + notes="an ACCEPTED COST, pinned so it has a witness in " + "this table. Two pieces " "never reach the join loop at all, which needs three, " "so the name reads as a name plus a trailing " "generation and the family is empty. 1.4.0 read last " - "'i' -- the same two-token divergence the 1.4.0 " - "ledger's fix(suffix-routing) rule describes for a " + "'i' -- the same two-token divergence " + "fix(suffix-routing) describes for a " "trailing roman numeral, and the same one 'Donald Mc' " "pins above for the acronym half. That reading also " - "has a NOT-WANTED rule at 1.4.0 whose standing this " + "had a NOT-WANTED rule at 1.4.0 whose standing this " "bundle converts from 'unresolved' to 'decided': the " "cause is settled and the reading is kept", shape=1), @@ -4150,7 +4134,7 @@ def _check_cjk_shape_purity(self) -> None: "of Edinburgh' already has, which is why the report is " "the lone-name-word one. UNTAGGED on purpose: a " "second-order consequence is not a shape this table " - "admits to the contract corpus", + "admits to the shapes corpus", ), Case("a_trailing_y_is_not_in_the_both_sides_class", "Juan Garcia Lopez y", @@ -4266,9 +4250,8 @@ def _check_cjk_shape_purity(self) -> None: "disagreed at 2.3.0 and agree now. NO SHAPE TAG on " "any of these four: decisions.md#P3's one-case " "paragraph says of them that none is a corpus name, " - "and admitting them to the differential's contract " - "corpus is a ledger change of its own rather than " - "this row's business. The cross-version readings " + "and admitting them to a corpus is a change of its " + "own rather than this row's business. The cross-version readings " "above are what the corpus would have bought, " "measured on the wheels instead"), Case("a_one_case_link_before_a_generation_is_a_name_word", @@ -6225,7 +6208,7 @@ def _check_cjk_shape_purity(self) -> None: "script, so nothing leans, so the positional reading " "applies -- and at this slot the positional reading IS " "the credential. 1.4.0 parity, measured. A composed " - "CJK comma form, so tolerated rather than contract " + "CJK comma form, so tolerated rather than shape-tagged " "(rules.md#W3's 2026-09-01 demotion)", tolerated=True), Case("a_hangul_family_takes_the_trailing_credential_too", @@ -6737,13 +6720,7 @@ def _check_cjk_shape_purity(self) -> None: "ambiguity, so fields alone do not catch it. Measured: " "1.4.0 gave suffix 'Ph. D., Jr.' -- fix_phd EXTRACTED " "the pair pre-parse and re-appended it, reordering the " - "tail, where 2.0 renders it as written. Same words, " - "same roles, different order, and the harness cannot " - "currently see it: expected_since_1.4.0.toml states that a " - "diffing trailing 'Ph. D.' must fail the run, but this " - "input is absorbed by fix(comma-family), whose " - "name_regex is a bare comma -- measured on a probe " - "corpus, unexplained 0. See the note there"), + "tail, where 2.0 renders it as written"), Case("tail_segment_entry_space_joined", "John Smith, V MD", {"given": "John", "family": "Smith", "suffix": "V MD"}, notes="v1 renders each tail comma segment as ONE suffix " @@ -8882,9 +8859,7 @@ def _check_cjk_shape_purity(self) -> None: classification="parity", notes="a bare credential yields a family name, which is " "surprising and is 1.4.0's reading exactly. Pinned " - "because nothing else in the suite says so -- the " - "ledger reaches it, and the ledger is an out-of-band " - "tool run"), + "because nothing else in the suite says so"), Case("phd_split_leading", "Ph. D. John Smith", {"title": "Ph.", "given": "D.", "middle": "John", "family": "Smith"}, @@ -8984,8 +8959,7 @@ def _check_cjk_shape_purity(self) -> None: Case("renunciate_title_keeps_the_given_name", "Swami Vivekananda", {"title": "Swami", "given": "Vivekananda"}, classification="fix(#346)", - notes="pins the #346 membership change the ledger " - "classifies: 'swami' moved from the TITLES-only block " + notes="pins the #346 membership change: 'swami' moved from the TITLES-only block " "into GIVEN_NAME_TITLES, so H1's fold fires and the " "family is empty where 1.4.0 through 2.2.0 read family " "'Vivekananda'. The fold itself is already pinned by " @@ -9018,11 +8992,8 @@ def _check_cjk_shape_purity(self) -> None: "Swami Vivekananda Saraswati", {"title": "Swami", "given": "Vivekananda", "family": "Saraswati"}, - notes="H1's fold requires exactly one name word. The ledger " - "carries this name as a _MUST_NOT_MATCH probe, which " - "pins the regex and not the parse -- and it is " - "radar-tier in corpus_issues.jsonl, so the gate cannot " - "fail on it either. The release note advertises it"), + notes="H1's fold requires exactly one name word. The " + "release note advertises it"), Case("renunciate_title_swallows_a_real_given_name", "Guru Dutt", {"title": "Guru", "given": "Dutt"}, classification="fix(#346)", @@ -10432,8 +10403,7 @@ def _check_cjk_shape_purity(self) -> None: "the W3 demotion: the rules corpus stopped harvesting " "W3's examples, and this text lived in no other " "corpus, so without a row it would have left the " - "differential harness entirely rather than moving to " - "the radar tier. Classification measured for this row " + "corpora entirely. Classification measured for this row " "rather than copied from the row above: 1.4.0 gives " "first 남궁민수 / last 지훈, field for field what 2.3 " "gives, so parity", diff --git a/tests/v2/test_config_aliases.py b/tests/v2/test_config_aliases.py index 44eb641f..496f2d8f 100644 --- a/tests/v2/test_config_aliases.py +++ b/tests/v2/test_config_aliases.py @@ -25,8 +25,7 @@ class _Alias(NamedTuple): Four same-typed strings whose order carries all the meaning, which is what makes a bare tuple the wrong shape here: ``for m, n, _, _ in ALIASES`` appeared six times below and reads correctly only if you - remember which end is which. Named after ``_LatinCopy`` in - ``test_ledger_guards.py`` (#356). + remember which end is which (#356). """ old_module: str old_name: str diff --git a/tests/v2/test_corpora.py b/tests/v2/test_corpora.py new file mode 100644 index 00000000..1e24c932 --- /dev/null +++ b/tests/v2/test_corpora.py @@ -0,0 +1,126 @@ +"""The corpora under tools/differential/ that are GENERATED stay equal to +what their generators would write, and every corpus line is one the +snapshot can read. + +A generated corpus that falls behind its source silently narrows the +names snapshot.jsonl watches: a case row or a rules.md example added +without regenerating is then read by nothing but its own test, which +moves with the change it pins. These pins make a stale corpus fail +instead. +""" +import json +from pathlib import Path + +import pytest + +from ._differential_fixtures import _TOOLS, load_tool + +snapshot = load_tool("snapshot") + + +def _checked_in(name: str) -> list[object]: + return [json.loads(line) for line in + (_TOOLS / name).read_text(encoding="utf-8").splitlines() + if line.strip()] + + +def test_cjk_corpus_matches_the_case_table() -> None: + """corpus_cjk.jsonl is every distinct case-table text bearing a + codepoint the script table classifies (#295; build_cjk_corpus.py + says why no other corpus can carry these names).""" + module = load_tool("build_cjk_corpus") + assert _checked_in("corpus_cjk.jsonl") == module.selected_names(), ( + "corpus_cjk.jsonl is stale: regenerate with " + "`uv run python tools/differential/build_cjk_corpus.py`") + + +def test_shapes_corpus_matches_the_case_table() -> None: + """corpus_shapes.jsonl is generated from the shape-tagged case rows.""" + module = load_tool("build_shapes_corpus") + assert _checked_in("corpus_shapes.jsonl") == module.selected(), ( + "corpus_shapes.jsonl is stale: regenerate with " + "`uv run python tools/differential/build_shapes_corpus.py`") + + +def test_case_shape_ids_exist_in_the_inventory() -> None: + """cases.py cannot import tools/, so Case.__post_init__ validates + every shape tag against its own hand copy of the inventory's ids + (cases._SHAPE_IDS). The equality holds that copy honest; the subset + check catches a row tagged under a stale copy.""" + from tests.v2 import cases + shapes = load_tool("shapes") + tagged = {c.shape for c in cases.CASES if c.shape is not None} + assert tagged <= set(shapes.SHAPES), ( + f"tests/v2/cases.py tags shape id(s) " + f"{sorted(tagged - set(shapes.SHAPES))} that are not in " + f"tools/differential/shapes.py's SHAPES; add the shape there " + f"or fix the tag in cases.py") + assert cases._SHAPE_IDS == set(shapes.SHAPES), ( + f"cases._SHAPE_IDS {sorted(cases._SHAPE_IDS)} != shapes.py's " + f"{sorted(shapes.SHAPES)}; they are hand copies of one " + f"inventory -- update whichever side is stale") + + +def test_rules_corpus_matches_the_rules_doc() -> None: + """corpus_rules.jsonl is generated from docs/design/rules.md's own + examples (#414), through the doc's parser. test_rules_doc executes + every example against an expectation stored beside it, so a + deliberate behavior change edits both and stays green; the snapshot + is what shows the moved name in the diff.""" + module = load_tool("build_rules_corpus") + assert _checked_in("corpus_rules.jsonl") == module.selected_names(), ( + "corpus_rules.jsonl is stale: regenerate with " + "`uv run python tools/differential/build_rules_corpus.py`") + + # The equality cannot see a generator that silently selects LESS: + # regenerating then agrees with the degraded selection. A rule + # whose ID line stops matching, or that gains a heading mid-block, + # loses its examples to the previous rule or to nothing. + from .rules_doc import parse_rules_doc + rules = parse_rules_doc( + (_TOOLS.parents[1] / "docs" / "design" / "rules.md") + .read_text(encoding="utf-8")) + assert len(rules) >= 38, ( + f"rules.md parsed to {len(rules)} rules, fewer than the 38 this " + f"corpus was built from. A rule block that stops parsing hands " + f"its examples to its neighbour and shrinks the corpus " + f"silently. Ratchet this floor up when rules are added") + empty = [r.rule_id for r in rules + if not any(e.text for e in r.examples)] + assert empty == ["D1", "D2"], ( + f"rules with no example NAME: {empty}. D1 and D2 are the " + f"expected two -- their examples name a policy rather than a " + f"name string. Any other rule contributing nothing means its " + f"examples stopped being recognized as examples") + + +_ORDERS = {k: s.order for k, s in load_tool("shapes").SHAPES.items()} + + +@pytest.mark.parametrize("line,expected", [ + ('"John Smith"', ("John Smith", None)), + ('{"name": "John Smith", "tests": ["t"]}', ("John Smith", None)), + ('{"name": "Ménil Christophe du", "shape": 4}', + ("Ménil Christophe du", "FAMILY_FIRST")), +]) +def test_a_corpus_line_reads_as_a_name_and_order( + line: str, expected: tuple[str, str | None]) -> None: + assert snapshot._corpus_line(Path("corpus_x.jsonl"), line, _ORDERS) == expected + + +@pytest.mark.parametrize("line,match", [ + # A misspelled key would drop the shape the line declares and read + # the name under the default order with nothing saying so. + ('{"name": "John Smith", "shpae": 4}', r"corpus_x\.jsonl.*'shpae'"), + ('{"name": "John Smith", "order": "FAMILY_FIRST"}', "'order'"), + ('{"name": "John Smith", "tests": "t"}', "'tests' must be a list"), + # bool is an int subclass and hash(True) == hash(1): unrefused, it + # would read the name under shape 1's order. + ('{"name": "John Smith", "shape": true}', "'shape' must name"), + ('{"name": "John Smith", "shape": 99}', "'shape' must name"), + ('{"nmae": "John Smith"}', "neither a JSON string"), +]) +def test_a_malformed_corpus_line_is_a_hard_error(line: str, match: str) -> None: + with pytest.raises(SystemExit, match=match): + snapshot._corpus_line(Path("corpus_x.jsonl"), line, _ORDERS) + diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 581b4506..0d9b6327 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -1,24 +1,30 @@ -"""Unit tests for the differential gate's decision logic. +"""Unit tests for compare.py, the released-wheel comparison. `tools/` is outside `testpaths`, and adding it would run `--doctest-modules` over the corpus builders, so `compare.py` is -imported by path -- through `_differential_fixtures.load_compare`, -shared with `test_ledger_guards.py` so the loader exists once. +imported by path through `_differential_fixtures.load_tool`. -Only pure logic is covered: nothing here spawns `uv` or the network. -What is tested is what produces FALSE CONFIDENCE when it silently -misbehaves -- which surfaces get compared, which ledger gets consulted, -and above all whether a version tell is believed. +Nothing here spawns `uv` or the network. What is tested is what +produces FALSE CONFIDENCE when it silently misbehaves: a version tell +believed when the wrong library answered, a tree side that is not the +checkout, a worker that reads a name differently from the tree, and a +comparison that cannot report a difference at all. """ +import contextlib +import io +import json +import sys +from collections.abc import Callable from pathlib import Path +from typing import Any import pytest -from ._differential_fixtures import ( - _LEDGERS, _TOOLS, _UNMEASURED_CORPORA, _claimed, _rules, load_tool) +from ._differential_fixtures import _TOOLS, load_tool compare = load_tool("compare") shapes = load_tool("shapes") +snapshot = load_tool("snapshot") def test_parse_version_pads_a_short_release_to_three_parts() -> None: @@ -58,45 +64,6 @@ def test_surfaces_are_derived_from_the_baseline( assert compare._surfaces_for(version) == expected -def test_allowlist_path_is_named_for_its_baseline() -> None: - assert compare._allowlist_for("1.4.0").name == "expected_since_1.4.0.toml" - - -def test_allowlist_for_a_baseline_with_no_ledger_is_a_hard_error() -> None: - """Not an empty rule set. An empty set classifies nothing, so every - diff reports as unexplained -- which reads as a catastrophic - regression rather than as a missing file, and sends the reader - hunting the parser instead of the ledger.""" - with pytest.raises(SystemExit, match="no allowlist for baseline"): - compare._allowlist_for("9.9.9") - - -def test_name_regex_rules_sort_ahead_of_fields_only_rules() -> None: - """Most-specific-first BETWEEN tiers. Within a tier the stable - sort leaves file order deciding, which the 2.0 ledger relies on.""" - rules = [{"issue": "broad", "fields": ["first"]}, - {"issue": "specific", "name_regex": "Smith"}] - assert [r["issue"] for r in compare._sorted_rules(rules)] \ - == ["specific", "broad"] - - -def test_rule_sort_is_stable_within_a_tier() -> None: - rules = [{"issue": "a", "name_regex": "A"}, - {"issue": "b", "name_regex": "B"}] - assert [r["issue"] for r in compare._sorted_rules(rules)] == ["a", "b"] - - -def test_classify_returns_none_when_no_rule_matches() -> None: - rules = [{"issue": "x", "name_regex": "Zzz"}] - assert compare.classify("John Smith", {"first"}, rules) is None - - -def test_classify_takes_the_first_matching_rule() -> None: - rules = [{"issue": "specific", "name_regex": "Smith"}, - {"issue": "broad", "fields": ["first"]}] - assert compare.classify("John Smith", {"first"}, rules) == "specific" - - def test_worker_source_carries_the_requested_pin() -> None: src = compare._worker_source("2.0.0", want_v2=True) assert 'dependencies = ["nameparser==2.0.0"]' in src @@ -106,3725 +73,152 @@ def test_worker_source_always_emits_a_version_tell() -> None: """The tell is the whole defence against a worker that silently resolved to the checkout, so it is not conditional on anything.""" for want_v2 in (True, False): - src = compare._worker_source("1.4.0", want_v2=want_v2) - assert "__version__" in src and "__file__" in src - - -def test_worker_source_gates_the_v2_import_on_the_baseline() -> None: - """1.4 has no nameparser.parse to import; asking for it would make - the worker die on import rather than report a clean facade diff.""" - assert "WANT_V2 = False" in compare._worker_source("1.4.0", want_v2=False) - assert "WANT_V2 = True" in compare._worker_source("2.0.0", want_v2=True) - - -def test_worker_source_emits_initials_on_both_surfaces() -> None: - """#484: initials() is a derived view the seven-field diff cannot - see. The facade has had initials() since 1.x, so the facade row - carries it at EVERY baseline; the v2 row carries it wherever the - v2 surface is compared at all. - - A text check, and the two asserts do not buy the same thing. - `_v2_row` is defined unconditionally in the template body, which - is version-independent -- so its assert at want_v2=False pins - template TEXT, not behavior. The facade line is the one whose - BEHAVIOR depends on the installed wheel -- 1.4.0's - `HumanName.initials()` has to exist and answer -- which no text - check can see, and is why the sibling below RUNS the template - instead of reading it: - test_the_worker_reads_a_default_order_line_as_the_tree_does.""" - for version, want_v2 in (("1.4.0", False), ("2.0.0", True)): - src = compare._worker_source(version, want_v2=want_v2) - assert 'row["facade"]["_initials"] = hn.initials() or ""' in src - assert 'row["_initials"] = p.initials() or ""' in src - - -def test_every_shape_orders_resolve_and_bound_sanely() -> None: - """The inventory's two contracts: an `order` is a public constant - name on the installed tree that Policy actually accepts as a - name_order -- hasattr alone would admit "HumanName" or any other - real attribute, failing only at runtime inside the worker -- and a - shape with an order cannot claim a pre-2.0 baseline -- Policy - shipped in 2.0.0, so an earlier min_baseline would send an order - to a worker with no Policy to apply it. - - `_parse_version(shape.min_baseline)` is called for EVERY shape, - not just ordered ones, so a typo'd min on shapes 1-3 (whose order - is None and so skips the >= (2, 0, 0) check) is still caught -- - an unparsable string raises SystemExit on its own. - """ - import nameparser - from nameparser import Policy - for sid, shape in shapes.SHAPES.items(): - parsed_min = compare._parse_version(shape.min_baseline) - if shape.order is not None: - assert hasattr(nameparser, shape.order), (sid, shape.order) - Policy(name_order=getattr(nameparser, shape.order)) - assert parsed_min >= (2, 0, 0), sid - - -def test_worker_reads_entry_objects_and_applies_an_order() -> None: - """The template must parse {"name","order"} lines and build the - order's parser; rendering is checked textually the way - test_worker_source_carries_the_requested_pin does.""" - src = compare._worker_source("2.2.0", want_v2=True) - assert '"order"' in src and "getattr(nameparser, order)" in src - - -def test_entries_below_their_shapes_min_baseline_are_skipped( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A 1.4 worker must never see an order it cannot honor; the - skip is printed so a shrunken comparison is never silent.""" - import contextlib - import io - import json as _json - import sys - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": "Ménil Christophe du", "shape": 4}, - ensure_ascii=False) + "\n" - + _json.dumps("John Smith") + "\n", encoding="utf-8") - (tmp_path / "expected_since_1.4.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - sent: dict = {} - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - sent["entries"] = list(entries) - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"facade": {"title": "", "first": "John", "middle": "", - "last": "Smith", "suffix": "", "nickname": "", - "maiden": "", - # the tree's own initials for this name. - # This test is about the skip decision, so - # the fake baseline agrees by construction - # on the #484 pseudo-field too. - "_initials": "J. S."}}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "1.4.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - assert code == 0 - assert [e["name"] for e in sent["entries"]] == ["John Smith"] - out = buf.getvalue() - assert ("skipped 1 name tagged shape(s) [4]: baseline 1.4.0 predates " - "their minimum (2.0.0)") in out - assert "corpus_x.jsonl (2, 1 skipped)" in out - - -def test_an_order_none_shapes_later_minimum_does_not_skip_the_entry( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The companion to test_entries_below_their_shapes_min_baseline_ - are_skipped, pinning the other half of the same branch: shape 6's - min_baseline (2.1.0) is DOCUMENTARY, not a skip trigger, because - `order` is None -- the default policy already exists at 1.4.0, so - there is no order for that baseline's worker to fail to honor. - Without the `shapes_by_id[shape].order is not None` gate in - compare.py's skip loop, this entry would be silently dropped the - same way an order-bearing one correctly is above -- this proves - the gate actually distinguishes the two rather than reverting to - shape-blind or, worse, always-skip behavior.""" - import contextlib - import io - import json as _json - import sys - from nameparser import HumanName - name = "김민준" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 6}, ensure_ascii=False) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_1.4.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - # The tree's own facade reading, used as the "1.4.0" side too -- - # this test is about the skip decision, not about what the parse - # produces, so an old/new facade that agree by construction keeps - # a real diff from muddying the assertion. - old_facade = {k: (v or "") for k, v in HumanName(name).as_dict().items()} - old_facade["_initials"] = HumanName(name).initials() or "" - sent: dict = {} - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - sent["entries"] = list(entries) - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"facade": old_facade}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "1.4.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - assert code == 0 - assert [e["name"] for e in sent["entries"]] == [name] - out = buf.getvalue() - assert "skipped" not in out - assert "corpus_x.jsonl (1)" in out - - -def _tree_v2_row(name: str, order: str | None) -> dict: - """The tree's own v2 reading of `name` under `order`, built the - same way main()'s tree side and the worker template's _v2_row both - build it. Used to fabricate a baseline row that agrees (or, with a - field mutated, disagrees) with the tree, without needing a real - baseline wheel. - - `order` is None for the DEFAULT order -- the comparison that - declares no name_order at all, so it reads through `parse()` - rather than a Policy-bearing Parser. One builder covering both - branches rather than two hand copies, for the reason the three - row-building copies in compare.py carry a comment about: a - duplicate drifts the moment a field is added, and #484 added one. - """ - if order is None: - from nameparser import parse - p = parse(name) - else: - from nameparser import Parser, Policy - import nameparser as _np - p = Parser(policy=Policy(name_order=getattr(_np, order))).parse(name) - row = {f: (getattr(p, f, "") or "") for f in compare.V2_FIELDS} - row["_ambiguities"] = sorted( - {a.kind.name for a in getattr(p, "ambiguities", ())}) - row["_initials"] = p.initials() or "" - return row - - -def test_order_bearing_entry_reaches_the_worker_and_compares_on_v2_alone( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Mutating the shape resolution to always leave e["order"] = None - must not leave this suite green: a shape-4 entry must reach the - worker with order == "FAMILY_FIRST" (not None), and a baseline row - that carries no "facade" key at all -- exactly what an - order-bearing worker row looks like -- must diff against nothing - but the v2 fields, proving the branch that skips the facade - comparison for these rows actually runs rather than crashing or - silently defaulting to the facade path.""" - import contextlib - import io - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - v2_row = _tree_v2_row(name, "FAMILY_FIRST") - sent: dict = {} - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - sent["entries"] = list(entries) - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"v2": v2_row}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - assert sent["entries"][0]["order"] == "FAMILY_FIRST" - assert code == 0 - assert "UNEXPLAINED" not in buf.getvalue() - - -@pytest.mark.parametrize("tier,header,want_code", [ - ("contract", "UNEXPLAINED", 1), - # radar tier: same order tag, but never fatal (#468) -- exit 0 - ("radar", "UNCLASSIFIED (radar)", 0), -]) -def test_order_bearing_diff_row_tags_its_order_and_hides_v2_only( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - tier: str, header: str, want_code: int) -> None: - """Both the UNEXPLAINED and UNCLASSIFIED (radar) headers must say - which order produced a diff -- a family-first regression and a - default-order regression on the same name are otherwise - indistinguishable in the report. Both draw the tag from the same - _order_tag helper, so this parametrization pins BOTH call sites: - a test covering one header alone says nothing about the other. - And "[v2 surface only]" - means "the facade was compared and agreed", which is false for an - order-bearing row: its facade was never consulted, so the tag must - not appear on either header.""" - import contextlib - import io - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, tier) - monkeypatch.setattr(compare, "HERE", tmp_path) - v2_row = _tree_v2_row(name, "FAMILY_FIRST") - v2_row["family"] = v2_row["family"] + "X" # force a diff on `family` - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"v2": v2_row}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - assert code == want_code - out = buf.getvalue() - assert f"{header} {name!r}" in out and "[order: FAMILY_FIRST]" in out - assert "[v2 surface only]" not in out - - -def test_classified_order_bearing_diff_tags_its_order( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The `## issue` block carries the order tag too. Without it, one - string compared under two orders lists twice under the same issue - with nothing telling the two lines apart -- and the release note - written from that block would claim a default-order change the - run never made.""" - import contextlib - import io - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text( - '[[change]]\nissue = "claimed"\nname_regex = "Ménil"\n' - 'fields = ["family"]\n', encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - v2_row = _tree_v2_row(name, "FAMILY_FIRST") - v2_row["family"] = v2_row["family"] + "X" # force a diff on `family` - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"v2": v2_row}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - out = buf.getvalue() - assert code == 0 - assert "## claimed (1)" in out - assert f" {name!r} [order: FAMILY_FIRST]" in out - - -def _order_bearing_run( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - ledger_body: str) -> tuple[int, str]: - """One shape-4 (FAMILY_FIRST) entry whose v2 reading disagrees on - `family`, run against `ledger_body`. The order-bearing sibling of - _run_main, which can only build default-order comparisons.""" - import contextlib - import io - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text( - ledger_body, encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - v2_row = _tree_v2_row(name, "FAMILY_FIRST") - v2_row["family"] = v2_row["family"] + "X" # force a diff on `family` - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"v2": v2_row}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - return code, buf.getvalue() - - -def _order_bearing_initials_run( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - ledger_body: str) -> tuple[int, str]: - """_order_bearing_run's `_initials` twin: the same shape-4 entry, - with the CORE's initials moved and every role left alone. - - A second helper rather than a parameter on the first, because the - two differ in what they are about: that one builds a role diff, and - this one builds the diff main() only ever forms when no role moved - at all.""" - import contextlib - import io - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text( - ledger_body, encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - v2_row = _tree_v2_row(name, "FAMILY_FIRST") - v2_row["_initials"] = "M. X." # the view moved; the roles did not - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"v2": v2_row}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - return code, buf.getvalue() - - -def test_an_order_bearing_initials_only_diff_reports_without_a_surface_tag( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """An order-bearing entry never consults the facade -- main() - leaves `new` empty for it -- so the facade pair is ('', ''), - `facade_moved` is False, and the v2 `_initials` line falls to the - ORDER-AWARE branch, which suppresses the tag. A "[v2 surface only]" - here would be a lie: no facade reading was compared to be `only` - different from. - - This is also the one path on which an `_initials` diff can reach - the report at all under a declared order, which is why every - `_initials` ledger rule carries orders = ["DEFAULT"]: such a diff - is the CORE's, and no rule whose prose says "facade" may absorb - it.""" - code, out = _order_bearing_initials_run( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n') - assert code == 1 - assert "UNEXPLAINED 'Ménil Christophe du' [order: FAMILY_FIRST]" in out - # 'C. M.' and not 'M. C. d.': the tree side is read under - # FAMILY_FIRST, where 'Ménil' is the family and 'du' its particle - assert "_initials: 'M. X.' -> 'C. M.'" in out - assert "[v2 surface" not in out - - -def test_an_order_blind_rule_absorbing_an_order_bearing_diff_is_reported( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The leak this notice makes visible runs the OTHER way from the - one `orders` was added for: a legacy rule carries no `orders`, so - it claims a family-first diff its author never considered, and an - order-only regression there reports as an intentional change. The - notice is informational -- order-blind rules stay legal, and a - ledger full of them is what every baseline before shape tags - has -- so it must NOT move the exit code.""" - code, out = _order_bearing_run( - tmp_path, monkeypatch, - '[[change]]\nissue = "blind"\nname_regex = "Ménil"\n' - 'fields = ["family"]\n') - assert code == 0 - assert "ORDER-BLIND" in out - assert "'blind'" in out and "FAMILY_FIRST" in out - - -def test_one_string_tagged_with_two_shapes_is_two_comparisons( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The dedup key is (name, order), not name. Regressing it to the - name alone drops the second reading silently: the run compares one - of the two orders, prints a corpus count one smaller, and exits 0 - -- and nothing above this pins it, because every other order test - uses a single-shape corpus where the two keys agree.""" - import contextlib - import io - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text("".join( - _json.dumps({"name": name, "shape": s}, ensure_ascii=False) + "\n" - for s in (4, 5)), encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - sent: dict = {} - - def _fake(v: str, w: bool, - entries: list[dict[str, object]]) -> tuple[dict, list[dict]]: - sent["entries"] = list(entries) - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"v2": _tree_v2_row(name, str(e["order"]))} - for e in entries]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - assert code == 0 - assert [e["name"] for e in sent["entries"]] == [name, name] - assert [e["order"] for e in sent["entries"]] == [ - "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST"] - assert "corpus: 2 names" in buf.getvalue() - - -def test_every_order_scoped_rule_declines_the_default_order() -> None: - """Swept over the shipped ledgers rather than pinned per rule: a - rule whose `orders` omits "DEFAULT" is saying its diffs come from - declared orders alone, and the whole value of that statement is - that the default-order reading of the same string stays somebody - else's business -- unclaimed, and so still able to report - UNEXPLAINED. - - Asked over the names the rule's own regex reaches, so it is the - rule's real population and not a fixture's. Both directions, since - a narrowing that declined everything would pass the first half.""" - checked = 0 - for ledger in _LEDGERS: - for rule in _rules(ledger): - orders = rule.get("orders") - if not isinstance(orders, list) or "DEFAULT" in orders: - continue - fields = set(rule["fields"]) - examples = _claimed(rule["name_regex"]) - assert examples, ( - f"{ledger.name}: {rule['issue']!r} is order-scoped but its " - f"regex reaches no corpus name, so this sweep would check " - f"it vacuously") - for example in examples: - assert not compare._entry_matches( - rule, example, fields, None), ( - f"{ledger.name}: {rule['issue']!r} scopes to {orders} " - f"yet claims the DEFAULT-order diff on {example!r}") - assert any(compare._entry_matches(rule, example, fields, o) - for o in orders), ( - f"{ledger.name}: {rule['issue']!r} claims nothing under " - f"any order it lists, on {example!r}") - checked += 1 - assert checked >= 4, ( - f"only {checked} order-scoped rules were swept; the shipped 2.x " - f"ledgers carry four each, so this pin is passing vacuously") - - -def test_the_worker_reads_an_order_bearing_line_as_the_tree_does() -> None: - """The generated worker's order branch, RUN rather than compiled. - test_worker_source_compiles proves it parses and - test_run_worker_sends_the_name_and_resolved_order_on_the_wire - proves what goes in; between them the branch that resolves an order - constant, builds a Parser for it and emits a v2-only row was - covered by nothing that executes it. - - exec'd in-process on purpose: the template's `import nameparser` - then resolves to this checkout, so the row it emits is the tree's - own reading and can be compared against the tree's own Parser. - That is exactly the equality a real run depends on -- the worker - and main()'s tree side must build the same row from the same parse - -- and it is what makes a diff mean a behavior change rather than - a protocol one.""" - import contextlib - import io - import json as _json - import sys - import nameparser - name = "Ménil Christophe du" - source = compare._worker_source(nameparser.__version__, want_v2=True) - stdin = io.StringIO(_json.dumps( - {"name": name, "order": "FAMILY_FIRST"}, ensure_ascii=False) + "\n") - real_stdin, buf = sys.stdin, io.StringIO() - try: - sys.stdin = stdin - with contextlib.redirect_stdout(buf): - exec(compile(source, "baseline_worker.py", "exec"), {}) - finally: - sys.stdin = real_stdin - lines = buf.getvalue().splitlines() - # the version tell is the first line, always -- a reader that - # forgets it compares the tell against a parse and sees nothing - tell, row = (_json.loads(line) for line in lines) - assert tell["__version__"] == nameparser.__version__ - assert row == {"v2": _tree_v2_row(name, "FAMILY_FIRST")} - # the facade is never consulted for an order-bearing entry, so the - # key must be absent rather than empty: main() branches on it - assert "facade" not in row - - -def test_the_worker_reads_a_default_order_line_as_the_tree_does() -> None: - """The worker's FACADE row, RUN rather than compiled -- the third - copy of the row-building code, pinned behaviorally. - - The other two copies (main()'s tree side, the template's _v2_row) - are exercised by every main() test; this one crosses a process - boundary in a real run and so is reachable only by executing the - template. test_worker_source_emits_initials_on_both_surfaces - pins its TEXT, which cannot see a row that builds the right keys - from the wrong parse -- and #484 added a key to exactly this row. - - The name is chosen to tell the two SURFACES apart. `Ph. D., John` - is the one corpus name whose facade and core initials differ today - -- 'J. P D.' against 'J. P. D.', the phd-merge element grouped one - way by HumanName and another by parse() -- so the facade assertion - below can no longer pass by reading the core's value into the - facade row. Under a name the two surfaces agree on (this test used - `Ménil Christophe du`, 'M. C. d.' both ways) that swap is - invisible. - - exec'd in-process for the same reason as its order-bearing - sibling: `import nameparser` then resolves to this checkout, so - the row is the tree's own reading and can be compared against the - tree's own HumanName and parse().""" - import contextlib - import io - import json as _json - import sys - import nameparser - from nameparser import HumanName - name = "Ph. D., John" - source = compare._worker_source(nameparser.__version__, want_v2=True) - stdin = io.StringIO(_json.dumps( - {"name": name, "order": None}, ensure_ascii=False) + "\n") - real_stdin, buf = sys.stdin, io.StringIO() - try: - sys.stdin = stdin - with contextlib.redirect_stdout(buf): - exec(compile(source, "baseline_worker.py", "exec"), {}) - finally: - sys.stdin = real_stdin - tell, row = (_json.loads(line) for line in buf.getvalue().splitlines()) - assert tell["__version__"] == nameparser.__version__ - assert row["facade"] == { - **{k: v or "" for k, v in HumanName(name).as_dict().items() - if k in compare.FIELDS}, - "_initials": HumanName(name).initials() or ""} - assert row["v2"] == _tree_v2_row(name, None) - - -def test_dormancy_diagnoses_a_reverted_scoped_rule_as_reverted() -> None: - """An order-scoped rule that stops explaining anything has had its - behavior reverted, and must say so even when another rule explains - the SAME NAME under the default order. Read order-blind, the scoped - rule matches that default-order diff, sees the other rule win it - and reports `shadowed` -- which sends someone to delete a rule that - is not redundant, while the family-first behavior it described - stays gone.""" - rules = [{"issue": "fix(default)", "name_regex": "^de la Cruz$", - "fields": ["family"], "orders": ["DEFAULT"]}, - {"issue": "feat(scoped)", "name_regex": "^de la Cruz$", - "fields": ["family"], "orders": ["FAMILY_FIRST"]}] - # only the default-order comparison diffs: the family-first one the - # scoped rule describes has been reverted - report = compare.dormant_rules( - rules, {"fix(default)"}, [("de la Cruz", {"family"}, None)]) - assert [d.issue for d in report.undeclared] == ["feat(scoped)"] - assert report.undeclared[0].kind == "reverted" - assert report.undeclared[0].detail == "" - - -def test_a_scoped_rule_absorbing_its_own_order_is_not_reported( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Scoping is the fix the notice asks for, so a scoped rule must - print nothing -- otherwise the block is noise on every ledger that - took the advice, and a reader stops reading it.""" - code, out = _order_bearing_run( - tmp_path, monkeypatch, - '[[change]]\nissue = "scoped"\nname_regex = "Ménil"\n' - 'fields = ["family"]\norders = ["FAMILY_FIRST"]\n') - assert code == 0 - assert "## scoped (1)" in out - assert "ORDER-BLIND" not in out - - -_WHEEL = "/Users/x/.cache/uv/environments-v2/w/lib/python3.11/" \ - "site-packages/nameparser/__init__.py" - - -def test_tell_accepts_a_matching_wheel() -> None: - compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0.0") - - -def test_tell_accepts_an_equivalent_short_release() -> None: - compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0") - - -def test_tell_rejects_a_version_mismatch() -> None: - with pytest.raises(SystemExit, match="not the requested"): - compare._check_tell( - {"__version__": "2.1.0", "__file__": _WHEEL}, "2.0.0") - - -def test_tell_rejects_a_module_loaded_from_the_checkout() -> None: - """The failure the whole design exists to make impossible. An - editable install reports the TREE's version, so when the tree and - the baseline share a version the version half of the tell agrees - and only the path gives it away. - """ - checkout = _TOOLS.parents[1] / "nameparser" / "__init__.py" - with pytest.raises(SystemExit, match="CHECKOUT"): - compare._check_tell( - {"__version__": "2.0.0", "__file__": str(checkout)}, "2.0.0") - - -def test_tell_rejects_an_empty_tell() -> None: - with pytest.raises(SystemExit): - compare._check_tell({}, "2.0.0") - - -def test_facade_field_names_canonicalize_to_role_vocabulary() -> None: - """Both surfaces name the same seven roles with different words, - and Role's names win -- AGENTS.md already makes Role's declaration - order canonical "defined once and derived everywhere", and the - facade's vocabulary expires at 3.0.""" - assert compare._canonical_field("first") == "given" - assert compare._canonical_field("last") == "family" - assert compare._canonical_field("middle") == "middle" - assert compare._canonical_field("_ambiguities") == "_ambiguities" - - -def test_canonical_field_is_idempotent_on_role_names() -> None: - """Both surfaces' diffs pass through it, and the v2 surface's names - are already canonical, so applying it must be a no-op there.""" - for role in compare.V2_FIELDS: - assert compare._canonical_field(role) == role - - -def test_every_ledger_rule_names_roles_canonically() -> None: - """A rule written in facade vocabulary parses, and validate_rules - now rejects it at startup ("not roles"). Before that guard it - validated and then silently never matched -- the ledger growing an - entry that did nothing. This keeps a sharper message than the - generic role check, and sweeps every ledger, so a new baseline's - file is covered the day it is added.""" - import tomllib - ledgers = sorted(_TOOLS.glob("expected_since_*.toml")) - assert ledgers, "no ledgers found; this test would pass vacuously" - for ledger in ledgers: - rules = tomllib.loads( - ledger.read_text(encoding="utf-8")).get("change", []) - for rule in rules: - for field in rule.get("fields", []): - assert field == compare._canonical_field(field), ( - f"{ledger.name}: rule {rule['issue']!r} names " - f"{field!r}; use " - f"{compare._canonical_field(field)!r}") - - -@pytest.mark.parametrize("name,latin", [ - ("John Smith", True), - ("Anna Müller", True), - ("Jane Smith (née Jones)", True), - ("田中さん", False), - ("김민준", False), - ("Хосе Сантос", False), - ("威廉·莎士比亚", False), -]) -def test_latin_only_partition(name: str, latin: bool) -> None: - assert compare._is_latin_only(name) is latin - - -def test_malformed_rule_error_names_the_ledger_it_came_from() -> None: - """There is one ledger per baseline now, so a hardcoded filename - sends the reader to edit a rule that is not the broken one.""" - bad = [{"issue": "x"}] # neither name_regex nor fields - with pytest.raises(SystemExit, match="expected_since_2.0.0.toml"): - compare.validate_rules(bad, "expected_since_2.0.0.toml") - with pytest.raises(SystemExit, match="expected_since_1.4.0.toml"): - compare.validate_rules([{}], "expected_since_1.4.0.toml") - - -def test_a_rule_with_fields_and_no_regex_is_rejected() -> None: - """The shape #451 retired, closed so it cannot return. - - A rule with no `name_regex` claims every name whose diff fits its - `fields`, and _CORPUS_CLAIMS records its reach as the whole corpus - -- already at maximum, so arrivals never move it. The one rule with - this shape grew from 4 explained names to 14 across six behavior - families with every guard green. - """ - with pytest.raises(SystemExit, match="no 'name_regex'"): - compare.validate_rules( - [{"issue": "fix(x) a rule with no name narrowing", - "fields": ["given", "family", "suffix"]}], - "test_ledger.toml") - - -def test_dormant_does_not_buy_an_exemption_from_the_ban() -> None: - """`dormant` says a rule explains nothing; it does not say the rule - may be unbounded. - - The two are independent, and conflating them is the plausible - future edit: "it is declared idle, so its reach cannot matter." - It can. `dormant` is a claim about TODAY's corpus, and #372's - lesson is that a rule's reach is what it will claim tomorrow -- - the retired catch-all explained four names when it was written. - A regexless rule declared dormant would sit at the whole corpus - the moment one diffing name arrived, and its `dormant` reason - would then be false as well as its bound missing. - - Pinned because check ORDER is what makes this hold: the dormancy - check runs before the #451 one, so a malformed `dormant` still - reports its own message, and a well-formed one falls through to - the ban. Nothing else asserts that a valid `dormant` does not - short-circuit it (#453 review). - """ - with pytest.raises(SystemExit, match="no 'name_regex'"): - compare.validate_rules( - [{"issue": "fix(x) idle and unbounded", - "fields": ["given", "family"], - "dormant": "a reason nobody could fault"}], - "test_ledger.toml") - # the other order still holds: a malformed `dormant` reports its - # own defect rather than the ban's, because it is checked first - with pytest.raises(SystemExit, match="'dormant' that is not a"): - compare.validate_rules( - [{"issue": "fix(x) idle and unbounded", - "fields": ["given", "family"], "dormant": ""}], - "test_ledger.toml") - - -def test_a_rule_with_a_regex_and_no_fields_is_rejected() -> None: - """#451's ban in mirror image (#456). - - A rule with no `fields` narrows by name and by nothing else, so on - any name its regex reaches it claims every diff shape there is -- - measured, every one of the 256 shapes the seven roles, - `_ambiguities` and the standalone `_initials` allow at a 2.x - baseline. #452 made that worse than it looks by giving - the shape a second job: over_declared_rules skips a rule with no - `fields`, correctly, since one declaring no roles cannot - over-declare them. So deleting the `fields` line is the response to - an OVER-DECLARED failure that takes the least thought, and it both - silences the check and makes the rule maximally permissive. - - Free to enforce, on the same terms as #451's: no rule in any - shipped ledger has the shape, so the ban costs no migration. - - This assertion is an INVERSION. Its other half pinned the - regex-only shape as LEGAL when #451 landed -- "the neighbouring - shapes #451 did NOT retire" -- and #456 retired it. Inverted rather - than deleted, so the change of status is visible to a `git log -L` - on the assertion rather than vanishing with the test. - """ - with pytest.raises(SystemExit, match="no 'fields'"): - compare.validate_rules( - [{"issue": "fix(x) a rule with no role narrowing", - "name_regex": "Smith"}], - "test_ledger.toml") - # `dormant` buys no exemption here either, for the reason it buys - # none from #451's ban: it is a claim about today's corpus, not a - # bound on reach, and a regex-only rule sits at every diff shape - # the moment one matching name arrives -- at which point its - # dormancy claim is false too. Pinned because disabling the check - # otherwise fails exactly one test (#457 review). - with pytest.raises(SystemExit, match="no 'fields'"): - compare.validate_rules( - [{"issue": "fix(x) idle and unbounded", - "name_regex": "Smith", "dormant": "a reason nobody faults"}], - "test_ledger.toml") - - -def test_a_rule_carrying_both_keys_is_the_only_legal_shape() -> None: - """What is left after the three bans, and there is exactly one. - - `validate_rules` rejects neither key (it would match every diff), - `fields` without `name_regex` (#451, no name narrowing), and - `name_regex` without `fields` (#456, no role narrowing). One - shape survives all three, and this is it. - """ - compare.validate_rules( - [{"issue": "x", "name_regex": "Smith", "fields": ["given"]}], - "test_ledger.toml") - - -def test_classify_declines_a_diff_touching_a_field_the_rule_omits() -> None: - """The subset check is the tightness mechanism of every `fields` - rule -- a rule claims a diff only when EVERY changed field is one it - listed. Nothing else pinned it: a rule and a diff that name the same - single field satisfy `<=`, `>=`, `==` and `&` alike, so the existing - tests pass with the comparison flipped, and every deliberate field - omission in both ledgers would quietly stop meaning anything.""" - rules = [{"issue": "given-only", "fields": ["given"]}] - assert compare.classify("x", {"given"}, rules) == "given-only" - assert compare.classify("x", {"given", "suffix"}, rules) is None - - -def test_classify_scopes_a_rule_to_the_orders_it_declares() -> None: - """`orders` is the third narrowing, and the one a name compared - twice needs: the SAME string and the SAME moved roles mean - different things under a declared family-first order (the #395 - fold, intended) and under the default order (that fold leaking - where it must not). Order-blind, one rule claims both and the - leak reports as an intentional change. - - Both directions are pinned. A rule with `orders` must decline the - default-order diff -- comparison order None is never a member, - since the members are constant NAMES -- and must still claim the - diff under each order it lists. A rule without the key stays - order-blind, which is what every rule written before shape-tagged - entries existed relies on.""" - scoped = [{"issue": "family-first only", "name_regex": "Cruz", - "fields": ["family"], - "orders": ["FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST"]}] - assert compare.classify("de la Cruz", {"family"}, scoped) is None - assert compare.classify("de la Cruz", {"family"}, scoped, - order="FAMILY_FIRST") == "family-first only" - assert compare.classify( - "de la Cruz", {"family"}, scoped, - order="FAMILY_FIRST_GIVEN_LAST") == "family-first only" - blind = [{"issue": "any order", "name_regex": "Cruz", - "fields": ["family"]}] - assert compare.classify("de la Cruz", {"family"}, blind) == "any order" - assert compare.classify("de la Cruz", {"family"}, blind, - order="FAMILY_FIRST") == "any order" - - -def test_the_default_order_sentinel_scopes_a_rule_to_the_default_order( - ) -> None: - """"DEFAULT" is a sentinel and not an order constant: no shape - declares it, because it names the absence of a declared order. It - exists because TOML has no null inside an array, so a rule that - explains only default-order diffs had no way to say so and had to - stay order-blind -- which is the leak running the other way from - the one `orders` was added for.""" - scoped = [{"issue": "default only", "name_regex": "Cruz", - "fields": ["family"], "orders": ["DEFAULT"]}] - assert compare.classify("de la Cruz", {"family"}, - scoped) == "default only" - assert compare.classify("de la Cruz", {"family"}, scoped, - order="FAMILY_FIRST") is None - both = [{"issue": "default and one order", "name_regex": "Cruz", - "fields": ["family"], "orders": ["DEFAULT", "FAMILY_FIRST"]}] - assert compare.classify("de la Cruz", {"family"}, - both) == "default and one order" - assert compare.classify("de la Cruz", {"family"}, both, - order="FAMILY_FIRST") == "default and one order" - assert compare.classify("de la Cruz", {"family"}, both, - order="FAMILY_FIRST_GIVEN_LAST") is None - - -@pytest.mark.parametrize("orders,message", [ - (["NO_SUCH_ORDER"], "shapes.py declares for no shape"), - ([], "empty 'orders'"), - ("FAMILY_FIRST", "not a list of strings"), - ([1], "not a list of strings"), -]) -def test_validate_rules_rejects_a_bad_orders_narrowing( - orders: object, message: str) -> None: - """Each way an `orders` can stop meaning what its author wrote. - The two type failures are the dangerous direction -- _entry_matches - ignores a non-list, so a mistyped key silently returns the rule to - claiming every order, which is the scoping it was added to undo -- - and the other two can only ever match nothing, which is loud but - reads as a dormant rule rather than as a typo.""" - with pytest.raises(SystemExit, match=message): - compare.validate_rules( - [{"issue": "fix(x) scoped", "name_regex": "Smith", - "fields": ["given"], "orders": orders}], - "test_ledger.toml") - - -def test_order_contests_reads_the_orders_narrowing_classify_reads() -> None: - """`orders` is the third narrowing, so the contest predicate has to - read it too: two rules scoped to DISJOINT orders can never claim - the same comparison, whatever their `fields` and regexes say, so - file order decides nothing between them and there is no hazard to - justify. - - The first pair below is the demonstration. fix(b)'s `fields` are a - strict subset of fix(a)'s and both regexes reach 'John Smith', so - the fields-and-reach half of the predicate holds -- and classify() - still routes each order to its own rule, because neither rule is - reachable under the order the other declares. Reporting it would - demand a written exemption for a contest that cannot occur, which - is the detector-disagrees-with-the-predicate failure - docs/design/AGENTS.md axis 2 is about. - - An order-blind rule keeps contesting everything, which is the - second pair: omitting `orders` means claiming every order, so it - overlaps whatever the other rule declares. That is the direction - that must NOT be quietly narrowed away -- every rule in every - shipped ledger is order-blind today, and a skip that swallowed - those would empty the roster while looking like a fix. - """ - names = ["John Smith"] - disjoint = [ - {"issue": "fix(a) x", "name_regex": "Smith", - "fields": ["given", "family"], "orders": ["DEFAULT"]}, - {"issue": "fix(b) y", "name_regex": "Smith", - "fields": ["family"], "orders": ["FAMILY_FIRST"]}] - assert compare.order_contests(disjoint, names) == [] - assert compare.classify("John Smith", {"family"}, disjoint) == "fix(a) x" - assert compare.classify("John Smith", {"family"}, disjoint, - order="FAMILY_FIRST") == "fix(b) y" - - # ... and the same pair overlapping in one order IS a contest, - # which keeps the skip above from passing for the wrong reason - overlapping = [dict(disjoint[0]), - {**disjoint[1], "orders": ["DEFAULT", "FAMILY_FIRST"]}] - assert [c.earlier for c in compare.order_contests(overlapping, names)] \ - == ["fix(a) x"] - - blind = [dict(disjoint[0]), {k: v for k, v in disjoint[1].items() - if k != "orders"}] - assert [(c.earlier, c.later, c.names) - for c in compare.order_contests(blind, names)] \ - == [("fix(a) x", "fix(b) y", ("John Smith",))] - - -def test_validate_rules_takes_the_order_names_from_the_shape_inventory( - ) -> None: - """The legal set is BORROWED, not hand-copied: every order any - shape declares is legal and nothing else is, so an order added to - shapes.py is usable in a rule the same day, and one removed stops - validating without anyone remembering a second list. An order no - shape declares is an order no comparison runs under, so a rule - naming it could only ever be dormant. - - "DEFAULT" is the one member shapes.py does not supply, and cannot: - it names the absence of a declared order, which is not a shape.""" - assert compare._legal_orders() == { - shape.order for shape in shapes.SHAPES.values() - if shape.order is not None} | {"DEFAULT"} - for order in compare._legal_orders(): - compare.validate_rules( - [{"issue": "fix(x) scoped", "name_regex": "Smith", - "fields": ["given"], "orders": [order]}], - "test_ledger.toml") - - -def test_v2_fields_matches_the_Role_enum() -> None: - """AGENTS.md: the seven roles are 'defined once and derived - everywhere'. compare.py cannot import Role into the WORKER (that - runs under the old wheel), but this copy reads the working tree's - ParsedName and must track Role. If a role were added and this tuple - not updated, getattr never asks for it and every change in that role - is invisible on the v2 surface -- silent under-coverage, exit 0.""" - from nameparser import Role - assert compare.V2_FIELDS == tuple(str(r) for r in Role) - - -# The malformed-rule family. Most rows are a way a rule can silently -# match MORE than its author meant, which is how a real regression -# becomes a classified diff and a green run. Three rows are the -# opposite -- an empty `fields`, a non-role name, a facade name -- and -# make a rule that can never match; those fail loudly (the diff -# surfaces as UNEXPLAINED) so their rows buy a precise message rather -# than safety. Parametrized rather than written one-by-one because a -# guard added to one member of this family belongs on all of it. -@pytest.mark.parametrize("rule,expect", [ - ({}, "no string 'issue'"), - ({"issue": ""}, "no string 'issue'"), - ({"issue": "x"}, "neither 'name_regex' nor 'fields'"), - # a misspelled key is not ignored -- it deletes that half of the - # narrowing and the rule matches on the other half alone - ({"issue": "x", "name_regex": ",", "field": ["given"]}, "unknown key"), - # wrong types: classify skips them, so the rule silently widens - ({"issue": "x", "name_regex": ["a"], "fields": ["given"]}, - "non-string 'name_regex'"), - ({"issue": "x", "name_regex": "a", "fields": "given"}, - "not a list of strings"), - # an empty pattern matches every name, and name_regex rules sort - # FIRST, so it would shadow the whole ledger - ({"issue": "x", "name_regex": ""}, "matches every one of"), - ({"issue": "x", "name_regex": "(?:)"}, "matches every one of"), - # the shapes the empty-string probe let through: each declines "" - # and still matches every name in every corpus - ({"issue": "x", "name_regex": "."}, "matches every one of"), - ({"issue": "x", "name_regex": ".+"}, "matches every one of"), - ({"issue": "x", "name_regex": r"\b"}, "matches every one of"), - ({"issue": "x", "name_regex": r"[\s\S]"}, "matches every one of"), - # all seven roles: the roles are the only names that ever co-occur - # in one diff -- _ambiguities cannot appear below baseline 2.0 and - # _initials only ever appears alone -- so listing all seven is - # already the widest a rule can be, and it claims every role diff. - # name_regex is along for the ride so this pins the roles check, - # not the #451 one. - ({"issue": "x", "name_regex": "Smith", - "fields": ["title", "given", "middle", "family", - "suffix", "nickname", "maiden"]}, - "all seven roles"), - # uncompilable: without this it raises mid-run, after the worker - ({"issue": "x", "name_regex": "Smith("}, "invalid 'name_regex'"), - ({"issue": "x", "name_regex": "Smith", "fields": []}, "empty 'fields'"), - # a repeated name is a copy-paste slip the set-based subset test - # would swallow; refused so it cannot masquerade as a narrowing - ({"issue": "x", "name_regex": "Smith", "fields": ["family", "family"]}, - "repeats"), - ({"issue": "x", "name_regex": "Smith", - "fields": ["_initials", "_initials"]}, "repeats"), - ({"issue": "x", "name_regex": "Smith", "fields": ["famly"]}, - "not roles"), - # facade vocabulary is not role vocabulary; it would never match - ({"issue": "x", "name_regex": "Smith", "fields": ["first"]}, - "not roles"), - ({"issue": "x", "name_regex": "Smith", - "fields": ["title", "given", "middle", "family", - "suffix", "nickname", "maiden", - "_ambiguities"]}, "all seven roles"), - ({"issue": "x", "fields": ["given"], "dormant": ""}, "not a non-empty"), - ({"issue": "x", "fields": ["given"], "dormant": True}, "not a non-empty"), - # widening _RULE_KEYS is exactly the edit that could let a near-miss - # through, and a silently-ignored `dormnat` would mean the rule is - # checked for dormancy while its author believes it is exempt - ({"issue": "x", "fields": ["given"], "dormnat": "typo"}, "unknown key"), - # a dormant declaration is not a pass for the rest of the checks - ({"issue": "x", "dormant": "reason"}, "neither 'name_regex' nor 'fields'"), - # #382's shape failures. The key is an array-of-tables, so every - # other shape is a rule that reads as an exemption and declares - # none: [] says nothing, a bare table is the single-bracket - # [change.precedes_narrower] slip, and a list of strings is the - # reason written where the table belongs. - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": []}, "not a non-empty list of tables"), - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": {"issue": "y", "why": "r"}}, - "not a non-empty list of tables"), - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": ["y"]}, "not a non-empty list of tables"), - # an entry naming no rule exempts nothing, and 'entry with' is - # load-bearing in the match: a bare "no string 'issue'" would also - # match the RULE-level message and pin nothing here - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": [{"why": "r"}]}, "entry with no string 'issue'"), - # the reason is the whole safeguard, absent as well as blank - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": [{"issue": "y"}]}, "with no 'why'"), - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": [{"issue": "y", "why": 3}]}, "with no 'why'"), - # a rule cannot outrank itself; reported as itself rather than as - # the backwards-pointing case, which would tell the reader the rule - # sits earlier in the file than itself - ({"issue": "x", "name_regex": "Smith", "fields": ["given"], - "precedes_narrower": [{"issue": "x", "why": "r"}]}, - "precedence over ITSELF"), -]) -def test_validate_rules_rejects_a_rule_that_would_silently_widen( - rule: dict, expect: str) -> None: - with pytest.raises(SystemExit, match=expect): - compare.validate_rules([rule], "expected_since_2.0.0.toml") - - -def test_default_baseline_has_a_ledger_and_nothing_else_in_it() -> None: - """Two facts about the OPEN cycle's ledger, which the carve-out - below leans on and neither of us should have to derive. - - It must exist: _allowlist_for treats a missing ledger as a hard - error, so a DEFAULT_BASELINE with no file makes a bare compare.py - run abort -- and it would also make the carve-out inert, since it - would name a file nothing iterates over. - - And it must define nothing at the top level except `change` and - `never`, the two keys anything reads. This is the one ledger allowed - to be empty, so a mistyped table header -- `[[changes]]`, `[[rules]]`, - `[[nevr]]` -- reads as a legitimately empty open cycle everywhere - instead of as a broken file: every sweep gets zero rules and passes, - while the author believes they shipped a rule. The other ledgers are - protected by having to be non-empty; this one needs saying out loud. - """ - import tomllib - open_cycle = _TOOLS / f"expected_since_{compare.DEFAULT_BASELINE}.toml" - assert open_cycle.exists(), ( - f"DEFAULT_BASELINE is {compare.DEFAULT_BASELINE!r} but " - f"{open_cycle.name} does not exist; a bare compare.py run would " - f"hard-error, and the empty-ledger carve-out would be inert") - keys = set(tomllib.loads(open_cycle.read_text(encoding="utf-8"))) - assert keys <= {"change", "never"}, ( - f"{open_cycle.name} defines {sorted(keys - {'change', 'never'})} at " - f"the top level. Only `change` and `never` are read, so anything " - f"else is a typo that would read as an empty ledger rather than as " - f"a broken one") - - -def test_validate_rules_accepts_the_shipped_ledgers() -> None: - """The guards above must not be so strict they reject real rules. - - Every ledger but one must carry rules. The exception is the OPEN - cycle's -- the one DEFAULT_BASELINE names -- which is created the day - its baseline is released and is legitimately empty until that - cycle's first behavior change lands. An older ledger is history, and - history is not empty, so emptying one is still a mistake this - catches. - """ - import tomllib - open_cycle = f"expected_since_{compare.DEFAULT_BASELINE}.toml" - ledgers = sorted(_TOOLS.glob("expected_since_*.toml")) - assert ledgers, "no ledgers found; this test would pass vacuously" - for ledger in ledgers: - rules = tomllib.loads( - ledger.read_text(encoding="utf-8")).get("change", []) - assert rules or ledger.name == open_cycle, ( - f"{ledger.name} has no [[change]] rules, and it is not the " - f"open cycle's ledger ({open_cycle})") - compare.validate_rules(rules, ledger.name) - - -def test_ambiguities_is_a_legal_field_name() -> None: - """A SEGMENTATION-only diff is facade-identical by construction, so - this pseudo-field is the only name that can classify it -- and the - 2.0 ledger's first rule depends on it.""" - compare.validate_rules( - [{"issue": "x", "name_regex": "Smith", "fields": ["_ambiguities"]}], - "ledger.toml") - - -def test_initials_is_a_legal_field_name_alone() -> None: - """#484: the derived-view pseudo-field, legal by itself.""" - compare.validate_rules( - [{"issue": "x", "name_regex": "Smith", "fields": ["_initials"]}], - "ledger.toml") - compare.validate_exclusions( - [{"why": "x", "name_regex": "Smith", "examples": ["John Smith"], - "fields": ["_initials"]}], "ledger.toml") - - -@pytest.mark.parametrize("fields", [ - ["_initials", "family"], - ["family", "_initials"], - ["_initials", "_ambiguities"], -]) -def test_initials_mixed_with_another_field_is_rejected( - fields: list[str]) -> None: - """main() adds `_initials` to a diff only when nothing else moved, - so a rule declaring it beside a role can never match on it: the - half is dead, and dead rule text is the shape every other check in - validate_rules exists to refuse.""" - with pytest.raises(SystemExit, match="silently dead"): - compare.validate_rules( - [{"issue": "x", "name_regex": "Smith", "fields": fields}], - "ledger.toml") - with pytest.raises(SystemExit, match="silently dead"): - compare.validate_exclusions( - [{"why": "x", "name_regex": "Smith", "examples": ["John Smith"], - "fields": fields}], "ledger.toml") - - -def _rule(issue: str, **extra: object) -> dict[str, object]: - """A minimal well-formed ledger rule, for the checks below.""" - return {"issue": issue, "name_regex": "x", "fields": ["given"], **extra} - - -def test_an_exemption_naming_an_unknown_rule_is_rejected() -> None: - with pytest.raises(SystemExit, match="names no rule in this ledger"): - compare.validate_rules( - [_rule("fix(a) first", precedes_narrower=[ - {"issue": "fix(ghost) not in this file", "why": "because"}])], - "test_ledger.toml") - - -def test_an_exemption_pointing_backwards_is_rejected() -> None: - """An exemption names the rule it OUTRANKS, which sits later. - - Pointing at an earlier rule describes a pair where the declaring - rule is already the loser, so it protects nothing -- and the - likeliest way to write one is a copy-paste of the wrong issue - string, which would otherwise sit in the file reading as a - justification. - """ - with pytest.raises(SystemExit, match="sits EARLIER"): - compare.validate_rules( - [_rule("fix(a) first"), - _rule("fix(b) second", precedes_narrower=[ - {"issue": "fix(a) first", "why": "because"}])], - "test_ledger.toml") - - -def test_an_exemption_without_a_reason_is_rejected() -> None: - """`dormant`'s precedent: the reason is the whole safeguard.""" - with pytest.raises(SystemExit, match="'why'"): - compare.validate_rules( - [_rule("fix(a) first", precedes_narrower=[ - {"issue": "fix(b) second", "why": " "}]), - _rule("fix(b) second")], - "test_ledger.toml") - - -def test_a_rule_key_misplaced_into_an_exemption_is_rejected() -> None: - """The trap the TOML shape carries. - - `precedes_narrower` is a nested array-of-tables, so once its block - opens EVERY later bare `key = value` in the rule binds to the - exemption instead of the rule. An author appending `orders` after - an exemption silently deletes that rule's order narrowing -- and - the unknown-key check on the rule cannot see it, because the key - never lands in the rule dict at all. This is the same quiet - widening #451 and #456 close, arriving through new syntax rather - than through a misspelling. - """ - with pytest.raises(SystemExit, match="belongs to the RULE"): - compare.validate_rules( - [_rule("fix(a) first", precedes_narrower=[ - {"issue": "fix(b) second", "why": "because", - "orders": ["FAMILY_FIRST"]}]), - _rule("fix(b) second")], - "test_ledger.toml") - - -def test_the_same_rule_exempted_twice_is_rejected() -> None: - """Two reasons for one pair, and no way to tell which is stale. - - A row in the table above cannot carry this: a repeat only reaches - the check once both entries name a rule that exists, which takes a - second rule in the ledger. - """ - with pytest.raises(SystemExit, match="more than once"): - compare.validate_rules( - [_rule("fix(a) first", precedes_narrower=[ - {"issue": "fix(b) second", "why": "a is the compound rule"}, - {"issue": "fix(b) second", "why": "b was reverted"}]), - _rule("fix(b) second")], - "test_ledger.toml") - - -def test_a_well_formed_exemption_is_accepted() -> None: - """The positive control: the four refusals above must not be - refusing every exemption for some unrelated reason.""" - compare.validate_rules( - [_rule("fix(a) first", precedes_narrower=[ - {"issue": "fix(b) second", "why": "a is the compound rule"}]), - _rule("fix(b) second")], - "test_ledger.toml") - - -#: A wide-first TRIO: one regex, and TWO later rules whose `fields` -#: are each a strict subset of the first's, so file order alone decides -#: which of them classify() hands a matching diff to (#382). -#: -#: Three rather than two on purpose, and the two narrow sets are -#: deliberately disjoint from one another -- neither nests in the other, -#: so they contest nothing between themselves and the fixture holds -#: exactly two contests, both owned by `fix(wide)`. That is what makes -#: it possible to declare ONE of them and see the other still reported. -#: On a two-rule fixture, "this rule declares that rule" and "this rule -#: declares something" are the same sentence, and every assertion below -#: would pass against a reader that treated any declaration as a -#: blanket opt-out over every narrower rule -- which is precisely the -#: widening validate_rules' blank-'issue' refusal exists to refuse. The -#: shipped 1.4 ledger already has the shape: two of its rules are the -#: earlier side of two contests each. -_CONTESTED: list[dict[str, object]] = [ - {"issue": "fix(wide) the compound behavior", - "name_regex": "Smith", "fields": ["given", "family", "suffix"]}, - {"issue": "fix(narrow) one half of it", - "name_regex": "Smith", "fields": ["given", "family"]}, - {"issue": "fix(narrow-b) the other half of it", - "name_regex": "Smith", "fields": ["given", "suffix"]}, -] - - -def test_a_wide_first_pair_is_reported_until_it_is_declared() -> None: - """What `precedes_narrower` buys, and only what it buys. - - The declaration is read off the EARLIER rule and names the later - one, so a pair stays on the roster until the rule that wins it - says in writing that it means to. Reading it off the wrong rule - would exempt pairs nobody declared. - - A declaration retires the ONE pair it names and no other, which is - the middle assertion and the reason the fixture carries two - narrower rules: with `fix(narrow)` declared and `fix(narrow-b)` not, - exactly the second pair must still be reported. Read the rule's - declarations as a blanket opt-out -- `if not _declared_over(...)` - in place of the `c.later not in ...` membership test -- and the - reported list goes empty here while every other test in this file - keeps passing. - - The malformed shapes at the end pin CRASH-SAFETY, and that is all - they pin. `_declared_over` reads whatever validate_rules already - accepted plus whatever a test hands it, and a bare string, an entry - with no `issue` at all, and a list of strings must each leave it - returning a set rather than raising. - - They deliberately do not pin the LENIENCE. A stricter reader -- - one demanding a non-blank `why`, say -- passes all three of these, - and could not hide a contest if it wanted to, since declaring less - can only report more. Leniency here is a convenience for callers, - not the safe direction, so there is nothing about it worth pinning. - """ - names = ["Smith, Jr."] - both = [("fix(wide) the compound behavior", "fix(narrow) one half of it"), - ("fix(wide) the compound behavior", - "fix(narrow-b) the other half of it")] - assert [(c.earlier, c.later) for c - in compare.undeclared_contests(_CONTESTED, names)] == both - - # ONE of the two declared: the other must survive. - half = [dict(_CONTESTED[0], precedes_narrower=[ - {"issue": "fix(narrow) one half of it", "why": "wide describes both"}]), - _CONTESTED[1], _CONTESTED[2]] - assert [(c.earlier, c.later) for c - in compare.undeclared_contests(half, names)] == [both[1]] - - declared = [dict(_CONTESTED[0], precedes_narrower=[ - {"issue": "fix(narrow) one half of it", "why": "wide describes both"}, - {"issue": "fix(narrow-b) the other half of it", - "why": "and the other half"}]), - _CONTESTED[1], _CONTESTED[2]] - assert compare.undeclared_contests(declared, names) == [] - - for malformed in ("fix(narrow) one half of it", - [{"why": "a reason, and no rule it is a reason for"}], - ["fix(narrow) one half of it"]): - broken = [dict(_CONTESTED[0], precedes_narrower=malformed), - _CONTESTED[1], _CONTESTED[2]] - assert len(compare.undeclared_contests(broken, names)) == 2 - - -def test_a_pair_whose_regexes_share_no_name_is_no_contest() -> None: - """The shared-name test carries the whole check. - - Drop the shared-name test and the same scan reports 657 wide-first - pairs across the shipped ledgers, against the 11 the full predicate - finds. That gap is the argument and it does not rest on the digits: - an exemption roster in the hundreds, where the real one is eleven, - is a roster nobody writes and nobody reads -- so `fields`-subset is - not a usable predicate on its own. - - To recompute, reimplement order_contests' loop over `_rules(ledger)` - and `_CORPUS_NAMES` for every ledger in `_LEDGERS`, dropping one - condition at a time -- the function itself has no knobs to turn - them off, and a script importing `tests.v2._differential_fixtures` - needs `PYTHONPATH=.`. Mind the basis: 657 is the count with the - `orders` test still in place, and `fields`-subset ALONE -- both - conditions gone, nesting counted in either direction -- reports - 1350, of which the `orders` test removes 2 and none of the 657. - Measured 2026-09-02.""" - apart = [dict(_CONTESTED[0], name_regex="Smith"), - dict(_CONTESTED[1], name_regex="Jones")] - assert compare.order_contests(apart, ["Smith, Jr.", "Jones, Jr."]) == [] - - # The control, inline rather than a pointer at a neighbouring test - # that a rename would silently break: the same fixture with every - # regex reaching one name IS contested, so the empty list above is - # the shared-name test doing work and not the scan having gone - # quiet. Two, because _CONTESTED's wide rule strictly contains both - # of the narrower ones. - assert len(compare.order_contests(_CONTESTED, ["Smith, Jr."])) == 2 - - -def test_an_exemption_for_a_pair_that_is_no_contest_is_vacant() -> None: - """The `dormant`-awake precedent: a narrowing that ends a contest - must not leave a permission nobody re-earned. - - The last block is the one that pins WHICH declaration went vacant. - One rule carrying two declarations, one live and one stale, is the - only arrangement that can tell the real reader from a rule-level - one -- ask `does this rule contest anything` instead of `is THIS - pair contested` and the stale declaration disappears from the - report, silently, on a ledger where two of the shipped rules - already carry two declarations each. - """ - apart = [dict(_CONTESTED[0], name_regex="Smith", precedes_narrower=[ - {"issue": "fix(narrow) one half of it", "why": "stale"}]), - dict(_CONTESTED[1], name_regex="Jones")] - vacancies = compare.vacant_exemptions(apart, ["Smith, Jr.", "Jones, Jr."]) - assert vacancies == [ - ("fix(wide) the compound behavior", "fix(narrow) one half of it")] - # NamedTuple equality is by value, so the assertion above passes - # against a bare 2-tuple and would keep passing if _Vacancy were - # deleted. The field names are what the caller's message reads. - assert vacancies[0].earlier == "fix(wide) the compound behavior" - assert vacancies[0].later == "fix(narrow) one half of it" - - # ... and the live pair, which is what makes the assertion above a - # measurement: a function that simply listed every declaration - # would read identically on the vacant pair alone. - live = [dict(apart[0]), dict(apart[1], name_regex="Smith")] - assert compare.vacant_exemptions(live, ["Smith, Jr.", "Jones, Jr."]) == [] - - # One rule, two declarations, one of each: only the stale one is - # reported. `fix(narrow)` still contests over 'Smith, Jr.', so the - # rule contests SOMETHING -- and `fix(narrow-b)`, pulled away onto - # 'Jones', no longer does. - mixed = [dict(_CONTESTED[0], precedes_narrower=[ - {"issue": "fix(narrow) one half of it", "why": "live"}, - {"issue": "fix(narrow-b) the other half of it", "why": "stale"}]), - _CONTESTED[1], dict(_CONTESTED[2], name_regex="Jones")] - assert compare.vacant_exemptions(mixed, ["Smith, Jr.", "Jones, Jr."]) == [ - ("fix(wide) the compound behavior", - "fix(narrow-b) the other half of it")] - - -#: A NON-NESTED pair over one name: `fix(a)`'s {family, given} and -#: `fix(b)`'s {family, suffix} intersect in {family} and neither -#: contains the other, so order_contests looks past the pair entirely -#: and only file order decides which rule classify() hands a {family} -#: diff to. The #498 class, in two rules' worth of TOML. -_OVERLAPPING: list[dict[str, object]] = [ - {"issue": "fix(a) the winner", "name_regex": "Smith", - "fields": ["family", "given"]}, - {"issue": "fix(b) the loser", "name_regex": "Smith", - "fields": ["family", "suffix"]}, -] - -#: One row of main()'s `diffing`: (name, diff fields, order). Both -#: rules above admit it, since {family} is a subset of each. -_ONE_DIFF: list[tuple[str, set[str], str | None]] = [ - ("John Smith", {"family"}, None)] - -#: main()'s `tier_of` for that one name. -_ONE_TIER: dict[str, str] = {"John Smith": "contract"} - - -def test_an_overlapping_pair_with_no_pin_is_an_unowned_contest() -> None: - """The #498 predicate's positive case, and every field of the row. - - NamedTuple equality is by value, so asserting the tuple alone - would pass against a bare 7-tuple; the field reads below are what - main()'s message prints, so each is named. - """ - found = compare.unowned_contests( - _ONE_DIFF, _OVERLAPPING, [], set(), _ONE_TIER) - assert len(found) == 1, found - row = found[0] - assert row.name == "John Smith" - assert row.order is None - assert row.diff == ("family",) - assert row.winner == "fix(a) the winner" - assert row.loser == "fix(b) the loser" - assert row.kind == "overlap" - assert row.tier == "contract" - - -def test_an_equal_fields_pair_with_no_pin_is_an_unowned_contest() -> None: - """The class the docs name _CROSS_RULE_WINNERS the instrument for. - - Equal `fields` is the special case of "neither contains the - other", and seven of #498's fourteen are in it (measured - 2026-09-05) -- so a predicate that reported only the - strict-intersection case would miss half the class it exists for. - """ - equal = [dict(_OVERLAPPING[0], fields=["family"]), - dict(_OVERLAPPING[1], fields=["family"])] - found = compare.unowned_contests(_ONE_DIFF, equal, [], set(), _ONE_TIER) - assert [(r.loser, r.kind) for r in found] == [ - ("fix(b) the loser", "equal")] - - -def test_a_narrow_first_pair_is_justified_without_a_pin() -> None: - """#382's declaration-free default, unchanged by this check. - - The winner's `fields` are a STRICT subset of the loser's, which is - the arrangement narrow-first says needs no declaration -- and the - wider predicate that would demand a pin here anyway was declined - (decisions.md, the #498 completeness check). - """ - narrow_first = [dict(_OVERLAPPING[0], fields=["family"]), - dict(_OVERLAPPING[1], fields=["family", "given"])] - assert compare.unowned_contests( - _ONE_DIFF, narrow_first, [], set(), _ONE_TIER) == [] - # ... and the same pair with the nesting SWAPPED reports, so the - # silence above is narrow-first and not the fixture gone quiet. - assert len(compare.unowned_contests( - _ONE_DIFF, narrow_first[::-1], [], set(), _ONE_TIER)) == 1 - - -def test_a_declared_wide_first_pair_is_justified_without_a_pin() -> None: - """A `precedes_narrower` block covers the pair it names. - - The winner is the WIDER rule, which is the arrangement #382 says - needs a declaration, and the declaration names the loser. - """ - declared = [dict(_OVERLAPPING[0], fields=["family", "given"], - precedes_narrower=[{"issue": "fix(b) the loser", - "why": "a describes both halves"}]), - dict(_OVERLAPPING[1], fields=["family"])] - assert compare.unowned_contests( - _ONE_DIFF, declared, [], set(), _ONE_TIER) == [] - # ... and the same pair with the declaration dropped reports, so - # the silence above is the `precedes_narrower` block. - assert len(compare.unowned_contests( - _ONE_DIFF, [dict(declared[0], precedes_narrower=[]), declared[1]], - [], set(), _ONE_TIER)) == 1 - - -def test_an_undeclared_wide_first_pair_is_classified_not_assumed_away( -) -> None: - """The wide-first pair carrying NO declaration is REPORTED. - - This shape cannot reach a ledger main() runs: undeclared_contests - refuses it pre-worker, so on any validated ledger the branch is - dead. It is classified anyway rather than assumed unreachable - because this function is handed rule lists nothing validated -- - every fixture in this file is one -- and a check that presumes - another check's guarantee reports nothing when that guarantee is - what broke. `wide-undeclared` is a TOKEN; _UNOWNED_WHY carries the - wording the caller prints. - """ - undeclared = [dict(_OVERLAPPING[0], fields=["family", "given"]), - dict(_OVERLAPPING[1], fields=["family"])] - found = compare.unowned_contests( - _ONE_DIFF, undeclared, [], set(), _ONE_TIER) - assert [r.kind for r in found] == ["wide-undeclared"], found - assert set(compare._UNOWNED_WHY) == {"equal", "overlap", - "wide-undeclared"} - - -def test_a_pinned_name_is_justified_whatever_the_kind() -> None: - """`pinned` is the set the caller passes and nothing else. - - main() passes set(_RECORDED_DIFFS[ledger]) -- the keys of the - roster whose rows carry a winner. The function reads no dict of - its own, so this test says exactly what "pinned" means and the - main()-level tests say which dict supplies it. - """ - assert compare.unowned_contests( - _ONE_DIFF, _OVERLAPPING, [], {"John Smith"}, _ONE_TIER) == [] - # ... and the same call with an empty set reports, so the silence - # above is the pin and not the fixture having gone quiet. - assert len(compare.unowned_contests( - _ONE_DIFF, _OVERLAPPING, [], set(), _ONE_TIER)) == 1 - - -def test_an_excluded_name_is_not_in_the_population() -> None: - """Exclusions are honored the way classify() honors them. - - A name a [[never]] entry refuses was classified to NOTHING, so it - has no winner and cannot have a contest -- demanding a pin for it - would demand an argument about a rule that did not win. Read - without `fields`, as classify() reads an exclusion. - """ - never: list[dict[str, object]] = [ - {"why": "nothing here may be explained", - "examples": ["John Smith"], "name_regex": "Smith"}] - assert compare.unowned_contests( - _ONE_DIFF, _OVERLAPPING, never, set(), _ONE_TIER) == [] - # ... and the same call with the exclusion dropped reports, so the - # silence above is the [[never]] entry and not the fixture. - assert len(compare.unowned_contests( - _ONE_DIFF, _OVERLAPPING, [], set(), _ONE_TIER)) == 1 - - -def test_one_admitting_rule_is_no_contest() -> None: - """The control: with the second rule's regex pulled off the name, - the same fixture reports nothing -- so every assertion above is - the pair being seen, not the loop running twice.""" - apart = [_OVERLAPPING[0], dict(_OVERLAPPING[1], name_regex="Jones")] - assert compare.unowned_contests( - _ONE_DIFF, apart, [], set(), _ONE_TIER) == [] - # ... and putting a SECOND admitter back on the name reports, so - # the silence above is the count of admitters and nothing else. - assert len(compare.unowned_contests( - _ONE_DIFF, apart + [_OVERLAPPING[1]], [], set(), _ONE_TIER)) == 1 - - -def test_a_rule_scoped_to_another_order_does_not_admit_the_diff() -> None: - """`orders` narrows the admitting set here as it does in classify(). - - A rule scoped to FAMILY_FIRST is not in the running for a - default-order comparison at all, so the pair is not a contest -- - the losing rule could not have taken the diff whatever the file - order. The winner stays order-blind, which is what a rule written - before shape-tagged entries existed is, so the only thing moving - between the two calls is the comparison's own order. - """ - scoped = [_OVERLAPPING[0], - dict(_OVERLAPPING[1], orders=["FAMILY_FIRST"])] - assert compare.unowned_contests( - _ONE_DIFF, scoped, [], set(), _ONE_TIER) == [] - family_first: list[tuple[str, set[str], str | None]] = [ - ("John Smith", {"family"}, "FAMILY_FIRST")] - found = compare.unowned_contests( - family_first, scoped, [], set(), _ONE_TIER) - assert [(r.loser, r.order) for r in found] == [ - ("fix(b) the loser", "FAMILY_FIRST")] - - -def test_the_reported_diff_is_every_role_sorted() -> None: - """A two-role diff rides the row whole and in a stable order. - - `diff` is what main() prints beside the name, and the reader - compares it against a _RECORDED_DIFFS shape, which is stored - sorted -- so a row carrying one role of three, or the set's - iteration order, would read as a shape mismatch that is really a - formatting bug. Three roles rather than two because a set of - strings iterates in an order PYTHONHASHSEED decides: dropping the - `sorted` leaves this assertion right one time in six rather than - one in two, and right always at the single-role diff every other - test here uses. Both rules admit all three roles, which is what - makes the pair a contest over the whole diff. - """ - wide = [dict(_OVERLAPPING[0], - fields=["family", "given", "middle", "nickname"]), - dict(_OVERLAPPING[1], - fields=["family", "given", "middle", "suffix"])] - three_role: list[tuple[str, set[str], str | None]] = [ - ("John Smith", {"middle", "given", "family"}, None)] - found = compare.unowned_contests(three_role, wide, [], set(), _ONE_TIER) - assert len(found) == 1, found - assert found[0].diff == ("family", "given", "middle") - assert found[0].kind == "overlap" - - -def test_one_name_contesting_two_losers_reports_both() -> None: - """'田中さん, Dr.' is this shape on the shipped ledger: one name, - two admitting rules behind the winner, and each pair owed its own - row because each is a separate boundary somebody has to argue.""" - trio = _OVERLAPPING + [ - {"issue": "fix(c) the other loser", "name_regex": "Smith", - "fields": ["family", "nickname"]}] - found = compare.unowned_contests(_ONE_DIFF, trio, [], set(), _ONE_TIER) - assert [(r.winner, r.loser) for r in found] == [ - ("fix(a) the winner", "fix(b) the loser"), - ("fix(a) the winner", "fix(c) the other loser")] - - -def test_the_rule_list_is_sorted_here_the_way_classify_sorts_it() -> None: - """The winner is the rule that would actually TAKE the diff. - - `_sorted_rules` puts a name_regex rule ahead of a fields-only one - whatever the file order, so a caller handing this function a list - in the raw order would be told the fields-only rule won a diff - classify() gives to the other. Sorted internally, as dormant_rules - sorts for the same reason: the shadower it names has to be the - rule that really won. The fixture is in the WRONG order on - purpose, and classify() over the same list is the control. - """ - fields_only: dict[str, object] = { - "issue": "fix(fields-only) no regex, so it sorts second", - "fields": ["family", "suffix"]} - wrong_order = [fields_only, _OVERLAPPING[0]] - found = compare.unowned_contests( - _ONE_DIFF, wrong_order, [], set(), _ONE_TIER) - assert [(r.winner, r.loser) for r in found] == [ - ("fix(a) the winner", "fix(fields-only) no regex, so it sorts " - "second")], found - # the control, in main()'s own arrangement: classify() sorts - # NOTHING -- main() hands it `_sorted_rules`' output -- so the - # winner above is that same tier ordering and not this function's - # own opinion, while the raw list would have answered the other way - assert compare.classify("John Smith", {"family"}, - compare._sorted_rules(wrong_order), [], - None) == found[0].winner - assert compare.classify( - "John Smith", {"family"}, wrong_order, [], None) == found[0].loser - - -def test_a_name_missing_from_the_tier_map_raises() -> None: - """`tier_of` is main()'s own map and a miss is a BUG, not a row. - - Every compared name has a key there, so a KeyError here is the - fail-closed reading main()'s other tier lookups take: a row - printed with a guessed tier would say the wrong thing about - whether the run may fail on it. - """ - with pytest.raises(KeyError): - compare.unowned_contests(_ONE_DIFF, _OVERLAPPING, [], set(), {}) - - -def test_the_row_carries_the_order_its_comparison_ran_under() -> None: - """The stated limit, made visible in the data. - - A _RECORDED_DIFFS row is a DEFAULT-order shape, so a contest - measured under a declared order is reported here and could not be - absorbed by a row. None exists on any shipped ledger today -- all - fourteen of #498's are order-None -- and the row carries the order - so main() can say the limit only when it applies. - """ - ordered: list[tuple[str, set[str], str | None]] = [ - ("John Smith", {"family"}, "FAMILY_FIRST")] - found = compare.unowned_contests( - ordered, _OVERLAPPING, [], set(), _ONE_TIER) - assert [r.order for r in found] == ["FAMILY_FIRST"] - - -def test_a_pin_cannot_absorb_a_declared_order_contest() -> None: - """The stated limit in the GATE and not only in the row. - - `pinned` is a set of _RECORDED_DIFFS keys and a row there records - a DEFAULT-order shape, so it cannot say which rule wins a contest - measured under FAMILY_FIRST. A name-only gate would let the row - absorb it silently, which is the one thing the limit says cannot - happen; the same pin over the same name's DEFAULT-order comparison - does absorb it, which is what makes the difference the order and - not the pin. - """ - ordered: list[tuple[str, set[str], str | None]] = [ - ("John Smith", {"family"}, "FAMILY_FIRST")] - found = compare.unowned_contests( - ordered, _OVERLAPPING, [], {"John Smith"}, _ONE_TIER) - assert [r.order for r in found] == ["FAMILY_FIRST"], found - assert compare.unowned_contests( - _ONE_DIFF, _OVERLAPPING, [], {"John Smith"}, _ONE_TIER) == [] - - -#: What _run_worker was asked for, so a test can prove main forwarded -#: the baseline and the corpus rather than defaults of its own. -_WORKER_CALL: dict = {} - - -def _run_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ledger_body: str, - baseline_facade: dict, - extra: list[tuple[str, dict]] | None = None, - baseline: str = "1.4.0", - baseline_v2: dict | None = None, - floor: int | None = 1, - tier: str | None = "contract", - corpus_flag: bool = True, - names_every_corpus: bool = False) -> tuple[int, str]: - """Drive main() end to end with a faked baseline worker. - - No uv, no network. The helper exists because every unit test above - proves a helper WORKS while none proves main() calls it -- and in a - gate, the composition is the part that can go silently permissive. - - `baseline` defaults to 1.4.0 (facade only). Pass 2.0.0 with - `baseline_v2` to exercise the v2 surface, including the - ambiguity-only diff that is the stated reason to compare it. - - `extra` appends more (name, baseline_facade) pairs to the corpus, - in order, alongside the fixture's own 'John Smith'. It exists so a - test can mix a diffing and a non-diffing name -- the single-name - corpus below is structurally incapable of that. - - `corpus_flag=False` drops `--corpus` from argv, so main() globs its - corpora the way a FULL gate run does. It is the only way to reach - main()'s `if not args.corpus` branches from a test, and there are - two of them -- the missing-floor roster and, since #382, the - vacant-exemption refusal, which a partial run must only NOTE. The - fixture corpus is the only file in the patched HERE, so the floor - roster is replaced wholesale rather than added to: left intact it - would name every real corpus as missing. - - `names_every_corpus=True` keeps `--corpus` on argv but replaces the - floor roster wholesale anyway, so the run NAMES every corpus the - roster knows about. That is the case `--corpus` cannot be read as - "a narrowing": the flag is `action="append"`, so a run listing all - six corpora is the full gate wearing a flag, and every check that - softens under narrowing must stay hard here. Meaningless without - `corpus_flag`, which is why the two are separate parameters rather - than one tri-state. - """ - import json - import sys - corpus = tmp_path / "corpus_x.jsonl" - names = ["John Smith"] + [n for n, _ in (extra or ())] - corpus.write_text( - "\n".join(json.dumps(n) for n in names) + "\n", encoding="utf-8") - (tmp_path / f"expected_since_{baseline}.toml").write_text( - ledger_body, encoding="utf-8") - # Copies, not the caller's dicts: the `_initials` default below - # writes into every row, and the fixtures are module-level - # constants (_SAME_FACADE, _DIFFERS, _SAME_V2). Mutating them in - # place would leak one test's baseline into the next. - rows: list[dict] = [{"facade": dict(baseline_facade)}] - if baseline_v2 is not None: - rows[0]["v2"] = dict(baseline_v2) - for _, facade in (extra or ()): - rows.append({"facade": dict(facade)}) - # #484: the tree emits `_initials` on every row and compares it - # whenever the roles agree. A fixture row that omitted the key - # would read as an initials diff against the tree's own initials, - # so an omitted key means "same as the tree"; a test that wants an - # initials diff writes the key explicitly. - # - # Which makes a MISSPELLED key the dangerous one, and the reason - # for the refusal below: `{**_SAME_FACADE, "_initals": "J. X."}` - # is a row main() never reads the stray key from, so the real - # `_initials` defaults to the tree's own answer, the surfaces - # agree, and the test passes while pinning nothing. Refuse the row - # instead. Checked BEFORE the setdefault so the message can name - # the key the caller wrote rather than the one the helper added. - _facade_keys = set(compare.FIELDS) | {"_initials"} - _v2_keys = set(compare.V2_FIELDS) | {"_ambiguities", "_initials"} - for n, row in zip(names, rows): - for which, legal in (("facade", _facade_keys), ("v2", _v2_keys)): - if which not in row: - continue - stray = sorted(set(row[which]) - legal) - if stray: - raise AssertionError( - f"fixture row for {n!r} ({which}) carries " - f"{stray}, which main() never reads. The row would " - f"silently agree with the tree on every field it " - f"does read -- a misspelled '_initials' defaults to " - f"the tree's own initials -- so the test would pass " - f"having pinned nothing. Expected keys: " - f"{sorted(legal)}") - from nameparser import HumanName as _HN - for n, row in zip(names, rows): - row["facade"].setdefault("_initials", _HN(n).initials() or "") - if "v2" in row: - from nameparser import parse as _parse - row["v2"].setdefault("_initials", _parse(n).initials() or "") - _WORKER_CALL.clear() - - def _fake(v: str, w: bool, n: list[dict]) -> tuple[dict, list[dict]]: - _WORKER_CALL.update(version=v, want_v2=w, names=[e["name"] for e in n]) - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, rows) - - # The fixture corpus needs a floor like any other. `floor=None` - # leaves it unregistered, for the test that pins what happens when - # a corpus arrives without one. - if floor is not None: - if corpus_flag and not names_every_corpus: - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, floor) - else: - monkeypatch.setattr( - compare, "_CORPUS_FLOORS", {corpus.name: floor}) - # The fixture corpus needs a tier like any other. `tier=None` - # leaves it unregistered, for the test that pins the fail-closed - # roster. - if tier is not None: - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, tier) - monkeypatch.setattr(compare, "HERE", tmp_path) - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", - ["compare.py", "--baseline", baseline] - + (["--corpus", str(corpus)] if corpus_flag else [])) - import io - import contextlib - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - return code, buf.getvalue() - - -#: 'John Smith' with the family name altered, so the tree disagrees on -#: exactly one role. The facade calls it `last`; the report and any rule -#: must call it `family`. -_DIFFERS = {"title": "", "first": "John", "middle": "", "last": "SMYTHE", - "suffix": "", "nickname": "", "maiden": ""} - - -def test_main_exits_1_and_reports_an_unclassified_diff( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The gate's entire verdict. Nothing else pins it: mutating the - return to a bare 0 leaves every other test in this file passing, - and the harness would report unexplained diffs on stdout while - exiting 0 forever -- read by exit code, that is silence.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\nfields = ["family"]\n', _DIFFERS) - assert code == 1 - assert "UNEXPLAINED 'John Smith'" in out - - -def test_main_reports_the_unexplained_field_under_its_role_name( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The block exists to be copy-pasted into a ledger rule, so the - label it prints must be the label a rule needs. The facade calls - this role `last`; a rule saying `last` never matches.""" - _, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\nfields = ["family"]\n', _DIFFERS) - assert "family:" in out and "last:" not in out - - -def test_main_exits_0_when_every_diff_is_claimed( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "claimed"\nname_regex = "Smith"\n' - 'fields = ["family"]\n', _DIFFERS) - assert code == 0 - assert "UNEXPLAINED" not in out - assert "## claimed (1)" in out - - -def test_main_checks_contests_against_the_names_it_loaded() -> None: - """The run must refuse the ledger BEFORE the worker pass. - - The unit guard is what catches a new rule at pytest speed; this is - the belt for a run over a --corpus the guard never sees, and it has - to fire early -- a refusal raised after the worker pass has already - installed the pinned wheel and parsed the whole corpus for a - comparison that will never be made, and it prints below the run's - own published `baseline:` header. The ordering is the argument; the - "multi-minute" cost this docstring used to give for it was - withdrawn as unmeasured (#497). - """ - src = (compare.HERE / "compare.py").read_text(encoding="utf-8") - body = src[src.index("def main("):] - assert "undeclared_contests(" in body and "vacant_exemptions(" in body, ( - "main() does not consult the contest checks") - assert body.index("undeclared_contests(") < body.index("_run_worker("), ( - "main() must refuse an undeclared contest before spawning the " - "worker, not after") - - -#: A wide-first pair over the fixture corpus's own 'John Smith', for -#: the two main() refusals below (#382). Written as ledger text rather -#: than reusing _CONTESTED, because _run_main takes a TOML body. -_CONTESTED_LEDGER = ( - '[[change]]\nissue = "fix(wide) the compound behavior"\n' - 'name_regex = "Smith"\nfields = ["given", "family"]\n' - '\n' - '[[change]]\nissue = "fix(narrow) one half of it"\n' - 'name_regex = "Smith"\nfields = ["family"]\n') - - -def test_main_refuses_an_undeclared_contest_without_running_the_worker( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The source-order assertion above says the call SITES are in the - right order; this says the refusal actually fires, and fires early. - - `_WORKER_CALL` is the instrument for "early": _run_main clears it - and then monkeypatches _run_worker to record into it, so an empty - dict after the SystemExit means the worker was never asked -- which - a source-text index cannot establish, since a call site can sit - ahead of the worker and still be guarded into never running. - """ - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _CONTESTED_LEDGER, _DIFFERS) - message = str(exc.value) - assert "fix(wide) the compound behavior" in message - assert "fix(narrow) one half of it" in message - # the message must send the reader to the declaration, not to the - # reorder that would move which rule classifies a name - assert "precedes_narrower" in message and "do NOT reorder" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the ledger") - - -def test_a_full_run_refuses_an_undeclared_contest_too( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The same refusal on the run that matters: `corpus_flag=False`. - - `_run_main` defaults to `--corpus`, so every other undeclared-contest - test above reaches main() through the narrowed path -- and `vacant` - right below it genuinely behaves differently there. Making the - undeclared refusal conditional on `args.corpus` the same way, so - that a FULL gate run never refuses a contest nobody declared, - survives the whole suite without this test. It is the mode the CI - gate actually runs in, and it is the one that was untested. - """ - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _CONTESTED_LEDGER, _DIFFERS, - corpus_flag=False) - message = str(exc.value) - assert "fix(wide) the compound behavior" in message - assert "fix(narrow) one half of it" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the ledger") - - -def test_the_contest_check_reads_names_this_baseline_will_not_compare( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """LOADED, not KEPT: the choice main()'s comment defends, measured. - - main() runs the contest checks over every entry it read, ahead of - the baseline-minimum shape skip -- so an order-bearing name that a - 1.4.0 run will not actually compare still counts toward whether a - ledger is acceptable. The point is that the ledger gets the same - verdict at every baseline; move the block after `kept`, or filter - shape-tagged entries out of `corpus_names`, and it stops. - - Neither mutation is visible to - test_main_checks_contests_against_the_names_it_loaded, which reads - source-text indices and both mutations preserve. Here the ONLY name - the contested pair reaches is a shape-4 (FAMILY_FIRST, minimum - 2.0.0) entry the 1.4.0 run drops, so either mutation leaves the - undeclared contest unreported and the run proceeds to the worker. - """ - import json as _json - import sys - name = "Ménil Christophe du" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n" - + _json.dumps("John Smith") + "\n", encoding="utf-8") - (tmp_path / "expected_since_1.4.0.toml").write_text( - '[[change]]\nissue = "fix(wide) the compound behavior"\n' - 'name_regex = "Ménil"\nfields = ["given", "family", "suffix"]\n' - '\n' - '[[change]]\nissue = "fix(narrow) one half of it"\n' - 'name_regex = "Ménil"\nfields = ["given", "family"]\n', - encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - _WORKER_CALL.clear() - - def _fake(v: str, w: bool, n: list[dict]) -> tuple[dict, list[dict]]: - _WORKER_CALL.update(version=v, want_v2=w) - raise AssertionError("the worker must not run") - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "1.4.0", - "--corpus", str(corpus)]) - with pytest.raises(SystemExit) as exc: - compare.main() - message = str(exc.value) - assert "fix(wide) the compound behavior" in message - assert "fix(narrow) one half of it" in message - assert not _WORKER_CALL - - # The control: the same run at a baseline that DOES compare the - # name refuses identically. Without it the assertion above could - # read as "the check fires", where what it pins is "the check fires - # at a baseline whose comparison never sees this name". - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - (tmp_path / "expected_since_2.0.0.toml").write_text( - (tmp_path / "expected_since_1.4.0.toml").read_text(encoding="utf-8"), - encoding="utf-8") - with pytest.raises(SystemExit) as exc: - compare.main() - assert "fix(wide) the compound behavior" in str(exc.value) - - -def test_the_refusal_asks_for_the_reason_the_ledger_guard_asks_for( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A contributor who follows this message verbatim must not hit a - second refusal. `precedes_narrower` needs an `issue` AND a - non-blank `why` -- validate_rules refuses the entry without one -- - so a message that says only "name the later rule" sends the reader - into a ledger that will not load. The guard message in - tests/v2/test_ledger_guards.py already asks for both halves; the - two are read by the same contributor and must say the same thing. - """ - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _CONTESTED_LEDGER, _DIFFERS) - message = str(exc.value) - assert "what it describes that the later one does not" in message - - -#: The same pair with the exemption declared and the two regexes pulled -#: apart, so the declaration has nothing left to permit (#382). -_VACANT_LEDGER = _CONTESTED_LEDGER.replace( - 'fields = ["given", "family"]\n', - 'fields = ["given", "family"]\n' - '[[change.precedes_narrower]]\n' - 'issue = "fix(narrow) one half of it"\nwhy = "stale"\n' -).replace('name_regex = "Smith"\nfields = ["family"]', - 'name_regex = "Jones"\nfields = ["family"]') - - -def test_main_refuses_a_vacant_exemption_without_running_the_worker( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The other half, on a FULL run -- `corpus_flag=False`, because - that is the only run whose name set can tell a stale declaration - from one this run simply did not reach.""" - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _VACANT_LEDGER, _DIFFERS, - corpus_flag=False) - message = str(exc.value) - assert "fix(narrow) one half of it" in message - assert "Delete the exemption" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the ledger") - - -def test_main_only_notes_a_vacant_exemption_under_corpus( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The same ledger under `--corpus` must NOTE, never refuse. - - Narrowing the corpus removes contests, and the two checks read that - in opposite directions: fewer contests is fewer things to declare - (fail-closed for `undeclared`), but a live declaration whose names - are outside this run reads as vacant. A refusal here tells a - contributor to delete an exemption the full gate still needs. - """ - code, out = _run_main(tmp_path, monkeypatch, _VACANT_LEDGER, _DIFFERS) - assert "--corpus" in out and "not evidence" in out - assert "Delete the exemption" not in out - # A single value, not `in (0, 1)`: main() has exactly one return - # for the run outcome, so a two-member set is an assertion that - # cannot fail. 1 for reasons orthogonal to the NOTE -- _VACANT_LEDGER - # pulls 'fix(narrow)' onto a regex the fixture corpus has no name - # for (EXPLAINED NOTHING) and leaves 'fix(wide)' declaring a role no - # diff moves (OVER-DECLARED). What this pins is that the vacancy - # RETURNED a verdict rather than raising, and 0 would mean the - # ledger's own defects had gone quiet. - assert code == 1, out - - -def test_naming_every_corpus_refuses_a_vacant_exemption( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """`--corpus` is not the question; the NAME SET is. - - The flag is `action="append"`, so a run that lists every corpus - explicitly compares exactly what the flagless gate compares -- and - a check keyed on `args.corpus` softens for it anyway. Written as - the inversion of the NOTE test right above: same ledger, same - flag, and the only difference is that here the named corpus is the - whole roster. Keying the downgrade on the flag rather than on the - name set leaves a genuinely stale exemption printing a NOTE and - exiting 0. - """ - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _VACANT_LEDGER, _DIFFERS, - names_every_corpus=True) - message = str(exc.value) - assert "Delete the exemption" in message - assert "fix(narrow) one half of it" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the ledger") - - -def test_the_vacancy_refusal_names_both_of_its_causes( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A vacancy has two reachable causes and the message must not - offer repair advice for only one. The rule may have been narrowed - -- delete the exemption -- or a corpus NAME may have left while the - corpus stayed above its floor, in which case the exemption was - right and the corpus is what regressed. The ledger guard's message - in tests/v2/test_ledger_guards.py already says both; a contributor - reads whichever fires first. - """ - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _VACANT_LEDGER, _DIFFERS, - corpus_flag=False) - assert "or a corpus name left" in str(exc.value) - - -def test_a_corpus_narrowing_does_not_refuse_the_shipped_1_4_ledger( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The regression itself, on the file it was measured against. - - Every one of the six real corpora, run alone against - expected_since_1.4.0.toml, reported vacancies -- 11 of the 11 - exemptions for three of them -- so `--corpus` refused every - narrowing the README documents. A fixture ledger cannot show that: - it would keep passing if the shipped exemptions were deleted, which - is exactly the repair the refusal wrongly asked for. - """ - ledger = (_TOOLS / "expected_since_1.4.0.toml").read_text( - encoding="utf-8") - code, out = _run_main(tmp_path, monkeypatch, ledger, _DIFFERS) - assert "Delete the exemption" not in out - assert "not evidence" in out - # See the neighbouring NOTE test on why this is one value and not - # two. 1 here because the shipped ledger is written for six real - # corpora and this run compares one fixture name, so nearly every - # rule in it reports EXPLAINED NOTHING -- orthogonal to the NOTE, - # and 0 would mean the run had stopped reporting that. - assert code == 1 - - -#: A ledger that explains the fixture's own family-role diff, so a run -#: over _DIFFERS is quiet except for whatever the test under it puts -#: into _RECORDED_DIFFS. -_CLAIMS_FAMILY = ('[[change]]\nissue = "claimed"\nname_regex = "Smith"\n' - 'fields = ["family"]\n') - - -def test_main_reports_a_recorded_shape_the_run_contradicts( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The roster's shapes are checked where they are measured (#497). - - _CROSS_RULE_WINNERS feeds a recorded shape into classify() as an - INPUT, so a guessed one agrees with itself forever; the shape is - only falsifiable against a run. recorded_diff_mismatches has its - own unit tests above -- this pins that main() calls it, which is - the half that can go silently permissive. - - CONTRACT tier, _run_main's default. A contest row is fatal on a - radar name too, and test_a_contest_shape_on_a_radar_name_still_fails - pins that half; the watched-roster tests beside it pin the tier - the other roster's rows DO follow. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) - assert "MOVED SHAPE" in out - assert "John Smith" in out - # both sides, because a report naming only one leaves the reader - # unable to see which way the shape moved - assert "nickname" in out and "family" in out - # a FINDING, not a number to update: the winner recorded beside the - # shape was recorded for the OLD shape - assert "FINDING" in out - assert "_CROSS_RULE_WINNERS" in out - # The FINDING instruction leads the block once rather than riding - # every row, and the two-cause disclaimer beside it is conditional: - # every row here carries a measured shape, so advice for the - # unmeasured case would be advice for something that did not - # happen. Its sibling below pins the other branch. - assert out.count("FINDING") == 1 - assert "TWO" not in out - # The verdict, not just the print. A block that reports and exits 0 - # is read by CI as silence -- the same trap - # test_main_exits_1_and_reports_an_unclassified_diff exists for. - # Ledger and fixture are otherwise clean, so 1 here is this check's - # alone. - assert code == 1, out - - -def test_a_moved_shape_does_not_truncate_the_report( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A stale roster row must not hide an unexplained diff. - - This check runs after the worker, so unlike the two pre-worker - refusals it cannot raise: a SystemExit here lands MID-report and - takes dormancy, OVER-DECLARED, UNEXPLAINED and the radar block down - with it -- measured on the shipped tree, a `--corpus corpus.jsonl` - run at 1.4.0 prints 62 EXPLAINED NOTHING lines and 0 of them with - one _RECORDED_DIFFS row corrupted. So it prints and feeds the exit - code, like over_declared_rules. Pinned with all three in one run, - because a mismatch alone cannot show what a raise would have eaten. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",)}) - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', _DIFFERS) - assert "MOVED SHAPE" in out - assert "EXPLAINED NOTHING 'unrelated'" in out - assert "UNEXPLAINED 'John Smith'" in out - assert code == 1, out - - -def test_the_shape_report_names_every_contradicted_row( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The multi-row case the block's own layout is argued from. - - The header leads the block once, rather than the instruction riding - every line, BECAUSE a real parser move lands on many of these rows - at once -- and every other fixture here, unit and main(), carries - exactly one row, so that case was the one nothing exercised. Three - mutations lived in it: `out[:] = [...]` for the append in - recorded_diff_mismatches, a literal 1 for `len(shape_bad)` in the - header, and `shape_bad[:1]` for the row loop. Same harm as - test_a_moved_shape_does_not_truncate_the_report, one level in: a - truncated block hides a finding while reporting one. - - Two names, one rule explaining both diffs, and both recorded at a - shape neither produces. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",), - "Alice Jones": ("nickname",)}) - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "claimed"\nname_regex = "Smith|Jones"\n' - 'fields = ["family"]\n', _DIFFERS, - extra=[("Alice Jones", - {"title": "", "first": "Alice", "middle": "", - "last": "JONESY", "suffix": "", "nickname": "", - "maiden": ""})]) - # the COUNT, which a literal 1 would still print for two rows - assert "MOVED SHAPE expected_since_1.4.0.toml: 2 recorded" in out, out - # ... and both rows, which a truncated loop would not - assert "John Smith" in out and "Alice Jones" in out, out - assert out.count("_RECORDED_DIFFS records") == 2, out - # the header still leads ONCE, which is what the count is for - assert out.count("FINDING") == 1, out - assert code == 1, out - - -def test_the_shape_report_over_an_unmeasured_name_claims_no_cause( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """`measured` None has two reachable causes and the report must - not assert one of them. - - The parser may have stopped moving a name recorded as moving, or - the name may be compared under a declared order ALONE, so no - default-order comparison of it exists to have a shape. The two hand - recorded_diff_mismatches byte-identical arguments -- main() appends - to `diffing` only where a comparison DIFFED -- so this check cannot - tell them apart, and a line saying the name "stopped diffing" - would send half its readers to the parser over a corpus entry. - Written in the shape of test_the_vacancy_refusal_names_both_of_its_causes. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("family",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, - _SAME_FACADE) - assert "no default-order diff" in out - assert "declared order" in out - # ... and once, not once per row: the disclaimer is a property of - # the check, so it leads the block rather than riding every line. - assert out.count("TWO reach it") == 1 - # Every line of the block, not just the one carrying the shape: the - # header sentence is the one that used to assert the parser had - # changed, which is false for the second cause. - assert "stopped diffing" not in out - assert "the parser changed" not in out - assert code == 1, out - - -def test_the_shape_check_reads_the_section_for_the_LEDGER_it_ran( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """_RECORDED_DIFFS is keyed per ledger, and nothing pinned the key. - - Replacing the lookup with a hardcoded - `_RECORDED_DIFFS['expected_since_1.4.0.toml']` survived the whole - suite: every other main() test here runs at the default 1.4.0 - baseline, so the right answer and the hardcoded one are the same - dict. That was harmless only while the 2.x sections stayed empty, - and since #501 filled them it is not: 'MD, PHD' is keyed in three - sections at two different shapes, so the hardcoded lookup would now - read measurements of a different comparison and say the parser - moved. - - A correct row in the 2.0.0 section and a DECOY in the 1.4.0 one: - dispatched right, the run is quiet; dispatched to 1.4.0, it reports - a moved shape on the same name. Set wholesale rather than per key, - so the shipped 45 rows cannot supply the answer either way. - """ - monkeypatch.setattr( - compare, "_RECORDED_DIFFS", - {"expected_since_2.0.0.toml": {"John Smith": ("family",)}, - "expected_since_1.4.0.toml": {"John Smith": ("nickname",)}}) - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "claimed"\nname_regex = "Smith"\n' - 'fields = ["family"]\n', - {**_SAME_FACADE, "last": "SMYTHE"}, baseline="2.0.0", - baseline_v2={**_SAME_V2, "family": "SMYTHE"}) - assert "MOVED SHAPE" not in out, out - assert "nickname" not in out, out - assert code == 0, out - - -def test_a_full_run_refuses_a_recorded_name_no_corpus_holds( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The caller's half of the skip (#497 addendum). - - recorded_diff_mismatches SKIPS a recorded name it did not compare, - which is right under `--corpus` and forgiving forever under the - full gate: a roster row naming a name no corpus holds is a row that - agrees with itself because nothing measures it, which is the very - defect this arc is about. Same asymmetry as the vacancy check's - caller (#382), and `corpus_flag=False` is the only run whose name - set can tell a departed name from one this run did not reach. - - Pre-worker, like both refusals it sits beside: it reads the ledger - and the loaded names and nothing the comparison produces, and a - refusal raised afterwards prints below the run's own published - `baseline:` header, for a comparison it will never report -- not - "a comparison it is about to disown", since nothing of the - comparison prints until the `corpus: ... intentional diffs:` line - below it. It is the ORDER that earns the placement, which is the - wording compare.py carries at all three of its own placement - comments. Its measured half cannot refuse at all, and prints - instead. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Here, Esq.": ("family",)}) - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - corpus_flag=False) - message = str(exc.value) - assert "Nobody Here, Esq." in message - # both repairs, because the row may be stale OR the corpus may be - # what regressed, and they are not interchangeable - assert "restore" in message and "delete" in message - assert "_CROSS_RULE_WINNERS" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the roster row") - - -def test_a_corpus_run_is_silent_about_a_recorded_name_outside_it( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The inversion, and the reason the half above is not in the - function: `--corpus` narrows the name set on purpose, so absence is - a fact about the run. Refusing here would tell a contributor to - delete a roster row over a name the full gate compares -- the - earlier-draft `vacant` bug, one check over. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Here, Esq.": ("family",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) - assert "Nobody Here, Esq." not in out - assert code == 0, out - - -def test_naming_every_corpus_refuses_a_recorded_name_no_corpus_holds( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """`--corpus` is not the question; the NAME SET is. - - The flag is `action="append"`, so a run listing every corpus - compares exactly what the flagless gate compares. Keying the - silence on `args.corpus` rather than on `full_corpus` would let a - genuinely departed roster name pass by naming the roster. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Here, Esq.": ("family",)}) - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - names_every_corpus=True) - assert "Nobody Here, Esq." in str(exc.value) - assert not _WORKER_CALL - - -def test_a_name_this_baseline_skipped_is_not_a_name_the_corpus_lost( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The two name sets the check reads are opposite, and this is the - only instrument that can tell which one it read. - - Swap them and this full run refuses, telling a contributor to - delete a roster row for a name sitting in the corpus it just read. - The two questions, and the three names the lists differ by at - 1.4.0, are recorded once in recorded_diff_mismatches' docstring; - the shipped rosters cannot show the swap, because none of those - three carries a row yet. - - IT PINS "DOES NOT REFUSE", which is what it was written for, and it - used to pin "says nothing" as a side effect of asserting the name - was absent from stdout. Those are different decisions and only the - first was ever argued: a row on a skipped name is checked by - NEITHER half -- recorded_diff_mismatches drops it as uncompared and - the `gone` refusal passes it as still-in-a-file -- so silence made - a wrong shape on one of these three names a silent pass at the one - baseline whose section carries rows. Measured before the NOT - CHECKED note: a wrong shape on 'de la Cruz née Vega' over the full - corpus at 1.4.0 exited 0 in 375 lines naming it in none of them. So - the name is now REPORTED and the run still does not refuse, and - both halves are asserted below. - - Hand-rolled rather than via _run_main, which writes bare-string - corpus lines and so cannot produce a skipped entry at all; same - construction as - test_the_contest_check_reads_names_this_baseline_will_not_compare. - Its control is the sibling above: a name in NO corpus is refused by - this same run shape. - - One skipped name per roster, because the note reads the UNION: a - watched row on a skipped entry falls between the two halves for - exactly the reason a contest row does, and a note reading one dict - would leave the other's row silent on the same run. - """ - import json as _json - import sys - name = "Ménil Christophe du" - watched_name = "Vega Carlos de la" - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text( - _json.dumps({"name": name, "shape": 4}, ensure_ascii=False) + "\n" - + _json.dumps({"name": watched_name, "shape": 4}) + "\n" - + _json.dumps("John Smith") + "\n", encoding="utf-8") - (tmp_path / "expected_since_1.4.0.toml").write_text( - _CLAIMS_FAMILY, encoding="utf-8") - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", {name: ("family",)}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {watched_name: ("family",)}) - # wholesale, so the flagless glob below sees a full corpus roster - monkeypatch.setattr(compare, "_CORPUS_FLOORS", {corpus.name: 1}) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - _WORKER_CALL.clear() - - def _fake(v: str, w: bool, n: list[dict]) -> tuple[dict, list[dict]]: - _WORKER_CALL.update(version=v, names=[e["name"] for e in n]) - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"facade": dict(_DIFFERS)}]) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "1.4.0"]) - import contextlib - import io - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - out = buf.getvalue() - assert _WORKER_CALL["names"] == ["John Smith"], out - # does not REFUSE: the `gone` refusal's own sentence, which the - # sibling above asserts IS raised for a name no corpus holds - assert "no corpus holds any more" not in out, out - assert code == 0, out - # ... and does not stay silent either. The note names the row, says - # it went unchecked, and says the row is not the thing at fault -- - # the three things a reader needs to not delete it. - assert "NOT CHECKED" in out, out - assert name in out, out - assert "do not delete" in out, out - # ... and the watched row too, under the same note: the count is - # asserted so a note reading one roster cannot pass by naming the - # other's row in some later line - assert "NOT CHECKED expected_since_1.4.0.toml: 2 recorded" in out, out - assert watched_name in out, out - - -def test_a_watched_shape_on_a_radar_name_prints_and_does_not_fail( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The severity rule's whole point: a row that is only a snapshot - follows its name's tier, and on a radar name that is "shown, never - blocking" (#468). The VERDICT is the pin -- a block that printed - and exited 1 would be the contest roster's behavior on a name that - carries no argument. - - Radar tier, and the ledger claims the family diff, so the run is - clean apart from the row: 0 here is this check's alone, exactly as - the contest twin's 1 is its own. - """ - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - tier="radar") - assert "MOVED SHAPE (radar)" in out, out - # the dict the row lives in, so the reader edits the right one - assert "_WATCHED_DIFFS records" in out, out - # both shapes, for the reason the contest twin gives - assert "nickname" in out and "family" in out, out - # no row here has a partner pin, so the block must not send a - # reader to a roster that holds nothing for it - assert "_CROSS_RULE_WINNERS" not in out, out - # the ledger explained the diff, so the only radar block is this one - assert "UNCLASSIFIED" not in out, out - assert code == 0, out - - -def test_a_watched_shape_on_a_contract_name_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The same row, the other tier: a contract name's watched shape - fails the run as an unexplained diff on it would. The block is - MOVED SHAPE without the `(radar)` tag, and its count word is - `watched` so the two rosters' blocks read apart on one run.""" - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - tier="contract") - assert "MOVED SHAPE expected_since_1.4.0.toml: 1 watched" in out, out - assert "(radar)" not in out, out - assert "_WATCHED_DIFFS records" in out, out - assert "_CROSS_RULE_WINNERS" not in out, out - assert code == 1, out - - -def test_a_contest_shape_on_a_radar_name_still_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A contest row carries an argument, so the tier does not soften - it: the winner pinned beside the shape was recorded for the OLD - shape whichever file the name sits in. The contract-tier twin is - test_main_reports_a_recorded_shape_the_run_contradicts.""" - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - tier="radar") - assert "MOVED SHAPE expected_since_1.4.0.toml: 1 recorded" in out, out - assert "(radar)" not in out, out - assert "_CROSS_RULE_WINNERS" in out, out - assert code == 1, out - - -def test_a_contest_row_and_a_watched_radar_row_print_apart( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Both kinds moved on one run: two blocks, contest first, each - naming its own dict, and the exit code reading only the fatal one. - The radar-only sibling above exits 0, so 1 here is the contest - row's -- which is what "reads only the fatal list" means when both - are present.""" - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("nickname",)}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"Alice Jones": ("nickname",)}) - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "claimed"\nname_regex = "Smith|Jones"\n' - 'fields = ["family"]\n', _DIFFERS, - extra=[("Alice Jones", - {"title": "", "first": "Alice", "middle": "", - "last": "JONESY", "suffix": "", "nickname": "", - "maiden": ""})], - tier="radar") - contest = out.index("MOVED SHAPE expected_since_1.4.0.toml: 1 recorded") - radar = out.index("MOVED SHAPE (radar) expected_since_1.4.0.toml: 1 watched") - assert contest < radar, out - assert out.count("_RECORDED_DIFFS records") == 1, out - assert out.count("_WATCHED_DIFFS records") == 1, out - assert code == 1, out - - -#: A NON-NESTED pair over the fixture corpus, as ledger text: neither -#: rule's `fields` contains the other's, so the pre-worker contest -#: refusal looks past the pair and the #498 check is the only thing -#: that sees it. Two properties keep the fixture's verdict this -#: check's alone. The WINNER reaches both corpus names, so its -#: declared `fields` equal the union of the diffs it explains and -#: OVER-DECLARED stays quiet. The LOSER declares `dormant`, because -#: it explains nothing -- the winner is written first -- and -#: EXPLAINED NOTHING would otherwise fail the run for a reason that -#: is not this one. -_UNOWNED_LEDGER = ( - '[[change]]\nissue = "fix(a) the winner"\n' - 'name_regex = "Smith|Jones"\nfields = ["family", "given"]\n' - '\n' - '[[change]]\nissue = "fix(b) the loser"\n' - 'name_regex = "Smith"\nfields = ["family", "suffix"]\n' - 'dormant = "shadowed by fix(a) on the only name it reaches"\n') - -#: 'Alice Jones' with the GIVEN name altered, so the winner rule -#: explains a {given} diff too and its `fields` are exactly the union -#: of what it explains. -_JONES_GIVEN = {"title": "", "first": "ALICIA", "middle": "", - "last": "Jones", "suffix": "", "nickname": "", - "maiden": ""} - -#: The clause main() renders for an `overlap` row, from -#: compare._UNOWNED_WHY. Written out rather than read off the mapping, -#: as every other block's wording is asserted in this file: a test -#: that quoted the dict would pass whatever the dict said. -_OVERLAP_WHY = "`fields` overlap without nesting" - - -def test_main_reports_an_unowned_contest_and_exits_1( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The gate's verdict on a contest nobody owns (#498). - - unowned_contests has its own unit tests above; this pins that - main() calls it, names both rules and the kind, and FAILS the - run -- the half that can go silently permissive, as - test_main_exits_1_and_reports_an_unclassified_diff is for the - primary output. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", {}) - code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, - extra=[("Alice Jones", _JONES_GIVEN)]) - # PAIR(S), not diff(s): the count is rows, one per (name, losing - # rule), and the noun has to say so or a reader counts names by it - assert ("UNPINNED CONTEST expected_since_1.4.0.toml: 1 contested " - "pair(s)" in out), out - # the LEAD as main() renders it, not the two issues asserted - # apart: `in out` on each passes with the (winner, loser) grouping - # key INVERTED, which is the half of this message that says which - # rule the tree hands the name to. - assert ("'fix(a) the winner'\n outranks 'fix(b) the loser' on:" - in out), out - # the row, name and shape together: the shape prints in the - # `list(row.diff)` form every MOVED SHAPE row uses, so the bare - # tuple a dropped `list()` would render fails here too. - assert "'John Smith' [contract] ['family']" in out, out - assert _OVERLAP_WHY in out and out.count(_OVERLAP_WHY) == 1, out - # the repair names BOTH halves of a pin, since a shape without a - # winner is the other roster's row and would not close this - assert "_RECORDED_DIFFS" in out and "_CROSS_RULE_WINNERS" in out, out - # the disclaimer: a contest is not a defect, an unowned one is - assert "not a defect" in out, out - # no declared order in the fixture, so the order limit must not - # print -- it is named only when it applies, as NOT CHECKED is. - # Scoped to the BLOCK: the word appears in other parts of a - # report (the role legend, another check's note), so asserting - # over the whole of `out` would be a claim about the rest of - # main() rather than about this message. The block runs from its - # header to the blank line the printer ends it with. - block = out[out.index("UNPINNED CONTEST"):] - block = block[:block.index("\n\n")] - assert "`orders`" not in block, block - assert code == 1, out - - -def test_a_recorded_row_owns_a_contest_and_the_run_goes_quiet( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The same ledger with the name pinned: silent, and exit 0. - - The shape recorded is the shape the run measures, so no MOVED - SHAPE prints either -- 0 here is the pin having closed the - finding and not some other check having gone quiet. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("family",)}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", {}) - code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, - extra=[("Alice Jones", _JONES_GIVEN)]) - assert "UNPINNED CONTEST" not in out, out - assert "MOVED SHAPE" not in out, out - assert code == 0, out - - -def test_a_watched_row_does_not_own_a_contest( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A shape with no winner cannot answer "which rule wins". - - _WATCHED_DIFFS records a shape and pins nobody, so a row there - leaves the contest unowned -- the sentence the check's docstring - rests on, asserted at the level where the two dicts are actually - read apart. The shape recorded AGREES with the run, so no MOVED - SHAPE prints and the 1 is this check's alone. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("family",)}) - code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, - extra=[("Alice Jones", _JONES_GIVEN)]) - assert "UNPINNED CONTEST" in out, out - assert "MOVED SHAPE" not in out, out - assert code == 1, out - - -def test_an_unowned_contest_on_a_radar_name_still_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Fatal on both tiers, exactly as a contest ROW is. - - A contest row is fatal on either tier because it carries an - argument; an owed row is fatal because it carries none yet. - Scoping this to contract tier was declined for the roster it - completes (decisions.md, 2026-09-02), and the reason -- the - roster already pins radar names -- still holds: 32 of the 45 - rows at 1.4.0 sit on radar-tier names. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", {}) - code, out = _run_main(tmp_path, monkeypatch, _UNOWNED_LEDGER, _DIFFERS, - extra=[("Alice Jones", _JONES_GIVEN)], - tier="radar") - assert "UNPINNED CONTEST" in out, out - assert "[radar]" in out, out - assert code == 1, out - - -#: _UNOWNED_LEDGER plus a rule that is dormant IN FACT and does not -#: say so, which is what makes an EXPLAINED NOTHING block print: its -#: regex reaches no corpus name at all, so the diagnosis is -#: "reverted". Its `fields` are the winner's, so nothing about it can -#: reach the contest above. -_UNOWNED_LEDGER_WITH_A_DEAD_RULE = _UNOWNED_LEDGER + ( - '\n' - '[[change]]\nissue = "fix(c) the inert one"\n' - 'name_regex = "Nobody Here"\nfields = ["family", "given"]\n') - -#: 'Zed Quux' with a nickname the tree does not produce, so its diff -#: is {nickname} and NO rule in the ledger above reaches the name -- -#: an UNEXPLAINED row, which is the only thing that makes main() print -#: the Role-vocabulary legend the ordering assertion needs. -_QUUX_NICKNAME = {"title": "", "first": "Zed", "middle": "", - "last": "Quux", "suffix": "", "nickname": "ZZ", - "maiden": ""} - - -def test_the_unowned_block_prints_after_the_moved_shape_blocks( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Print order, which the blocks' readers differ on. - - MOVED SHAPE is about a row that already exists and has gone - false; UNPINNED CONTEST is about a row that does not exist yet. - A reader repairing the first should not have to scroll past the - second. Below it sit EXPLAINED NOTHING, which is about a RULE - rather than a name, and the Role-vocabulary legend that the - UNEXPLAINED and radar blocks share -- so the whole run of four is - asserted rather than the one boundary, since a block moving past - either neighbour is the same defect. - - The moved row is on 'Alice Jones' rather than 'John Smith': a - _RECORDED_DIFFS row on the contested name would OWN the contest - and there would be nothing to order. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"Alice Jones": ("nickname",)}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", {}) - code, out = _run_main(tmp_path, monkeypatch, - _UNOWNED_LEDGER_WITH_A_DEAD_RULE, _DIFFERS, - extra=[("Alice Jones", _JONES_GIVEN), - ("Zed Quux", _QUUX_NICKNAME)]) - moved = out.index("MOVED SHAPE expected_since_1.4.0.toml: 1 recorded") - unowned = out.index("UNPINNED CONTEST expected_since_1.4.0.toml") - nothing = out.index("EXPLAINED NOTHING 'fix(c) the inert one'") - legend = out.index("Field names below are Role's") - assert moved < unowned < nothing < legend, out - assert code == 1, out - - -#: The two-name variant of _UNOWNED_LEDGER: the LOSER's regex reaches -#: both contested names and both diff {family}, which is inside the -#: pair's intersection, so ONE (winner, loser) heading carries two -#: rows. 'Carol Brown' is the winner's alone and diffs {given}, which -#: is what keeps the winner's `fields` equal to the union it explains -#: and OVER-DECLARED quiet. -_TWO_NAME_UNOWNED_LEDGER = ( - '[[change]]\nissue = "fix(a) the winner"\n' - 'name_regex = "Smith|Jones|Brown"\nfields = ["family", "given"]\n' - '\n' - '[[change]]\nissue = "fix(b) the loser"\n' - 'name_regex = "Smith|Jones"\nfields = ["family", "suffix"]\n' - 'dormant = "shadowed by fix(a) on both names it reaches"\n') - -#: 'Alice Jones' with the FAMILY altered, so her diff is {family} -- -#: the same shape as 'John Smith' under _DIFFERS, and inside the -#: contested pair's intersection. -_JONES_FAMILY = {"title": "", "first": "Alice", "middle": "", - "last": "JONESY", "suffix": "", "nickname": "", - "maiden": ""} - -#: 'Carol Brown' with the GIVEN altered: the winner's third name, -#: uncontested, and the only reason its `given` is not over-declared. -_BROWN_GIVEN = {"title": "", "first": "CAROLINE", "middle": "", - "last": "Brown", "suffix": "", "nickname": "", - "maiden": ""} - - -def test_two_names_contesting_the_same_pair_print_under_one_heading( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The grouping, at the level where a reader sees it. - - `by_pair` exists so somebody editing one rule meets its whole cost - at once rather than the same pair once per name -- the shape - `fix(#271/#272/#298)` has at 1.4.0, heading three pairs over five - names. An ungrouped printer passes every assertion the one-row - tests make, so this is where that goes wrong: the heading once, - both names under it, in corpus order. - """ - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", {}) - code, out = _run_main(tmp_path, monkeypatch, _TWO_NAME_UNOWNED_LEDGER, - _DIFFERS, - extra=[("Alice Jones", _JONES_FAMILY), - ("Carol Brown", _BROWN_GIVEN)]) - assert "2 contested pair(s)" in out, out - heading = "'fix(a) the winner'\n outranks 'fix(b) the loser' on:" - assert out.count(heading) == 1, out - rows = out[out.index(heading) + len(heading):] - rows = rows[:rows.index("\n\n")] - assert rows.splitlines()[1:] == [ - " 'John Smith' [contract] ['family'] " - f"({_OVERLAP_WHY}, so neither is the narrower)", - " 'Alice Jones' [contract] ['family'] " - f"({_OVERLAP_WHY}, so neither is the narrower)"], rows - # 'Carol Brown' is the winner's alone, so it is not a contest and - # must not appear -- the control on the block's population. - # Scoped to the BLOCK: the name is in the classified listing above - # like every other explained name, so asserting over the whole of - # `out` would be a claim about that listing instead. - block = out[out.index("UNPINNED CONTEST"):] - assert "Carol Brown" not in block[:block.index("\n\n")], block - assert code == 1, out - - -def test_a_corpus_run_is_silent_about_a_watched_name_outside_it( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The contest roster's inversion, for the watched one: under - `--corpus` absence is a fact about the run, and - recorded_diff_mismatches is called on this dict with the same - `compared` it skips by.""" - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Here, Esq.": ("family",)}) - code, out = _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) - assert "Nobody Here, Esq." not in out, out - assert "MOVED SHAPE" not in out, out - assert code == 0, out - - -def test_a_full_run_refuses_a_watched_name_no_corpus_holds( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The departed-name refusal reads the union, and its repair text - is per roster: a watched row has no partner in _CROSS_RULE_WINNERS, - so the message must not send a reader to delete one. The contest - sibling asserts the partner IS named for its row. - - The contest section is EMPTIED first: a flagless run globs only - the fixture corpus, so every shipped 1.4.0 contest row is departed - on this run too, and the message would name the partner for those - -- correctly -- and the assertion below would be reading the wrong - rows.""" - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Here, Esq.": ("family",)}) - with pytest.raises(SystemExit) as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - corpus_flag=False) - message = str(exc.value) - assert "Nobody Here, Esq." in message - assert "restore" in message and "delete" in message - assert "_WATCHED_DIFFS" in message - assert "_CROSS_RULE_WINNERS" not in message, message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the roster row") - - -def test_a_name_in_both_rosters_is_refused_pre_worker( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A row is one kind or the other, and the run cannot pick: the two - kinds carry different severities and different repairs. Refused - before the worker, like the other roster refusals, and under - `--corpus` too -- the overlap is a fact about the dicts, not about - which names this run read.""" - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("family",)}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"John Smith": ("family",)}) - with pytest.raises(SystemExit, match="sit in both") as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) - message = str(exc.value) - assert "John Smith" in message - assert "_RECORDED_DIFFS" in message and "_WATCHED_DIFFS" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the overlap") - - -@pytest.mark.parametrize("dict_name", ["_RECORDED_DIFFS", "_WATCHED_DIFFS"]) -def test_a_ledger_missing_from_a_shape_roster_is_refused_pre_worker( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - dict_name: str) -> None: - """An empty section is a statement and a missing one is nobody - having looked, and a `.get(..., {})` read the two alike: with the - 1.4.0 key deleted from _WATCHED_DIFFS, a full run at that baseline - checked 36 rows fewer, printed the same 375 lines and exited 0 - (re-measured 2026-09-05, #498 having moved five rows out of that - section; compare.py's FAIL-CLOSED paragraph carries the control). - So the run refuses, pre-worker, naming the dict and the ledger -- - the guards in test_ledger_guards.py hold the same key equality at - pytest speed, and the tool may not assume they ran.""" - monkeypatch.delitem(getattr(compare, dict_name), - "expected_since_1.4.0.toml") - with pytest.raises(SystemExit, match="carry no section") as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS) - message = str(exc.value) - assert dict_name in message - assert "expected_since_1.4.0.toml" in message - assert "test_ledger_guards.py" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the missing section") - - -def test_the_departed_name_refusal_names_the_partner_per_roster( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Both rosters departed on one run: one refusal, both names, and - the _CROSS_RULE_WINNERS sentence once -- under the contest name - and not under the watched one. The two single-roster siblings - above each see one list and cannot tell a per-list sentence from - one that rides the whole message.""" - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Contested, Esq.": ("family",)}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_1.4.0.toml", - {"Nobody Watched, Esq.": ("family",)}) - with pytest.raises(SystemExit, match="no corpus holds any more") as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - corpus_flag=False) - message = str(exc.value) - assert "2 recorded diff shape(s)" in message, message - assert message.count("_CROSS_RULE_WINNERS") == 1, message - contest_at = message.index("Nobody Contested, Esq.") - watched_at = message.index("Nobody Watched, Esq.") - partner_at = message.index("_CROSS_RULE_WINNERS") - watched_lead = message.index("in _WATCHED_DIFFS, pinning no winner") - assert partner_at < contest_at < watched_lead < watched_at, message - assert not _WORKER_CALL - - -def _run_main_over( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - corpora: list[tuple[str, str, list[object]]], - rows: list[dict], - ledger_body: str, - baseline: str = "2.0.0") -> tuple[int, str]: - """_run_main's multi-corpus, order-bearing sibling. - - _run_main writes ONE corpus of bare-string lines, so it cannot - build a name compared under a declared order, nor one string held - by two files of different tiers -- and the watched-row tier rule - is stated over exactly those two shapes. `corpora` is - (filename, tier, lines) per file, the lines in either corpus - format; `rows` are the worker's rows in the order main() will send - the entries, which is contract files first and then by filename, - each file in line order. A row that agrees with the tree on every - field is _tree_v2_row(name, order); the facade half is omitted for - an order-bearing entry, as the worker omits it. - """ - import contextlib - import io - import json - import sys - argv = ["compare.py", "--baseline", baseline] - for filename, tier, lines in corpora: - path = tmp_path / filename - path.write_text("\n".join(json.dumps(x) for x in lines) + "\n", - encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, filename, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, filename, tier) - argv += ["--corpus", str(path)] - (tmp_path / f"expected_since_{baseline}.toml").write_text( - ledger_body, encoding="utf-8") - monkeypatch.setattr(compare, "HERE", tmp_path) - - def _fake(v: str, w: bool, n: list[dict]) -> tuple[dict, list[dict]]: - return ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, rows) - - monkeypatch.setattr(compare, "_run_worker", _fake) - monkeypatch.setattr(sys, "argv", argv) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - return code, buf.getvalue() - - -def _default_order_diff(name: str, family: str) -> dict: - """A baseline row for `name` under the default order whose facade - disagrees with the tree on the family alone, and whose v2 half - agrees -- so the run's only diff on the name is {family}.""" - from nameparser import HumanName - given = name.split()[0] - return {"facade": {"title": "", "first": given, "middle": "", - "last": family, "suffix": "", "nickname": "", - "maiden": "", - "_initials": HumanName(name).initials() or ""}, - "v2": _tree_v2_row(name, None)} - - -def test_a_watched_row_reads_the_default_order_entrys_tier( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The 'John Smith, Dr.' shape: one string, contract in one file - only under a declared order and radar in another under the - default. The shape was measured on the default-order comparison, - so that entry's tier decides -- radar, printed, exit 0 -- although - the contract entry loaded FIRST. A rule reading the first-loaded - entry always would fail this run; one reading order-None entries - only would pass it, and the declared-order-only sibling below is - what refuses that one.""" - monkeypatch.setitem(compare._WATCHED_DIFFS, "expected_since_2.0.0.toml", - {"John Smith": ("nickname",)}) - code, out = _run_main_over( - tmp_path, monkeypatch, - [("corpus.jsonl", "radar", ["John Smith"]), - ("corpus_rules.jsonl", "contract", - [{"name": "John Smith", "shape": 4}])], - # contract file first, so its FAMILY_FIRST entry is sent first - [{"v2": _tree_v2_row("John Smith", "FAMILY_FIRST")}, - _default_order_diff("John Smith", "SMYTHE")], - _CLAIMS_FAMILY) - assert "MOVED SHAPE (radar) expected_since_2.0.0.toml: 1 watched" in out, out - assert "This fails the run" not in out, out - assert code == 0, out - - -@pytest.mark.parametrize(("tier", "lead", "code"), [ - ("contract", "MOVED SHAPE expected_since_2.0.0.toml: 1 watched", 1), - ("radar", "MOVED SHAPE (radar) expected_since_2.0.0.toml: 1 watched", 0), -]) -def test_a_watched_row_on_a_declared_order_only_name_reads_its_first_entry( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - tier: str, lead: str, code: int) -> None: - """A name compared under a declared order ALONE has no - default-order entry, so the first-loaded one decides -- and it - must decide something: the row reports with `measured` None - (nothing compared the name under the default order), which is a - mismatch owed a tier like any other. A tier map built over - order-None entries only has no key for the name and dies - mid-report on it. - - 'Jane Smith' keeps the ledger rule awake: with no default-order - diff at all the rule would be EXPLAINED NOTHING, exit 1 for a - reason that is not the row's, and the contract case could not - be told from it.""" - monkeypatch.setitem(compare._WATCHED_DIFFS, "expected_since_2.0.0.toml", - {"John Smith": ("family",)}) - got, out = _run_main_over( - tmp_path, monkeypatch, - [("corpus_x.jsonl", tier, - [{"name": "John Smith", "shape": 4}, "Jane Smith"])], - [{"v2": _tree_v2_row("John Smith", "FAMILY_FIRST")}, - _default_order_diff("Jane Smith", "SMYTHE")], - _CLAIMS_FAMILY) - assert lead in out, out - assert "measured no default-order diff" in out, out - # the two-cause disclaimer: the check cannot say whether the - # parser stopped moving the name or the name is compared only - # under a declared order, and here it is the second - assert "TWO reach it" in out, out - assert "EXPLAINED NOTHING" not in out, out - assert got == code, out - - -def _order_bearing_unowned_ledger(loser_fields: str) -> str: - """A 2.0.0 ledger whose two rules both admit `'John Smith'`'s - FAMILY_FIRST `{family}` diff, the loser declaring `loser_fields`. - - `'Alice Jones'` diffs `{given}` under the default order and the - winner alone explains it, which is what keeps the winner's - `fields` equal to the union it explains at both widths below and - OVER-DECLARED quiet. The loser declares `dormant` because it - explains nothing -- the winner is written first -- so EXPLAINED - NOTHING does not fail the run for a reason that is not the - block's. - """ - return ('[[change]]\nissue = "fix(a) the winner"\n' - 'name_regex = "Smith|Jones"\nfields = ["family", "given"]\n' - '\n' - '[[change]]\nissue = "fix(b) the loser"\n' - f'name_regex = "Smith"\nfields = {loser_fields}\n' - 'dormant = "shadowed by fix(a) on the only name it reaches"\n') - - -def _order_bearing_unowned_run( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - loser_fields: str) -> tuple[int, str]: - """That ledger driven over one FAMILY_FIRST entry and one - default-order entry, with both shape rosters emptied for 2.0.0 -- - emptied rather than left alone so nothing on disk can own the - contest, and keyed rather than deleted because `main()` refuses a - ledger either dict has no section for.""" - from nameparser import HumanName - monkeypatch.setitem(compare._RECORDED_DIFFS, - "expected_since_2.0.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, - "expected_since_2.0.0.toml", {}) - smith = _tree_v2_row("John Smith", "FAMILY_FIRST") - smith["family"] = "SMYTHE" - return _run_main_over( - tmp_path, monkeypatch, - [("corpus_x.jsonl", "contract", - [{"name": "John Smith", "shape": 4}, "Alice Jones"])], - [{"v2": smith}, - {"facade": {"title": "", "first": "ALICIA", "middle": "", - "last": "Jones", "suffix": "", "nickname": "", - "maiden": "", - "_initials": HumanName("Alice Jones").initials() or ""}, - "v2": _tree_v2_row("Alice Jones", None)}], - _order_bearing_unowned_ledger(loser_fields)) - - -def test_an_unowned_contest_under_a_declared_order_names_the_limit( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The stated limit, printed only where it applies. - - A _RECORDED_DIFFS row is a DEFAULT-order shape, so no row can - absorb a contest measured under FAMILY_FIRST -- the repair the - lead offers is unavailable and the block has to say so, the way - NOT CHECKED names its own window. The order rides the ROW as well - as the note, since a reader given the note alone cannot tell which - of several rows it is about. - """ - code, out = _order_bearing_unowned_run( - tmp_path, monkeypatch, '["family", "suffix"]') - assert ("UNPINNED CONTEST expected_since_2.0.0.toml: 1 contested " - "pair(s)" in out), out - block = out[out.index("UNPINNED CONTEST"):] - block = block[:block.index("\n\n")] - assert ("NOTE: a row below carries a declared order, and a " - "_RECORDED_DIFFS row is a DEFAULT-order shape, so no row " - "can absorb it: narrow the pair, or scope one of the two " - "rules with `orders`." in block), block - assert ("'John Smith' [order: FAMILY_FIRST] [contract] ['family']" - in block), block - assert code == 1, out - - -#: The clause main() renders for an `equal` row, from -#: compare._UNOWNED_WHY, truncated as _OVERLAP_WHY is: a test quoting -#: the whole mapping entry would pass whatever the mapping said. -_EQUAL_WHY = "equal `fields`, file order" - - -def test_main_renders_the_equal_fields_clause( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The kind the docs call _CROSS_RULE_WINNERS the instrument for. - - Seven of #498's fourteen sit in an EQUAL-`fields` pair, so a - printer that rendered only the `overlap` clause would be wrong - about half the class. Same fixture as the sibling above with the - loser's `fields` widened to equal the winner's, so the kind is the - only thing that moved. - """ - code, out = _order_bearing_unowned_run( - tmp_path, monkeypatch, '["family", "given"]') - block = out[out.index("UNPINNED CONTEST"):] - block = block[:block.index("\n\n")] - assert _EQUAL_WHY in block, block - assert _OVERLAP_WHY not in block, block - assert code == 1, out - - -def test_a_tier_outside_the_two_literals_is_refused_at_load( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """_CORPUS_TIERS' value is validated where its key's presence is, - naming the file and the value, before any worker runs. Not a - partition test: main()'s `!= "radar"` split would put a misspelled - tier on the fatal side, but that would be the side the `!=` - happens to fall on, and no such value reaches the split now.""" - with pytest.raises(SystemExit, match="neither 'contract' nor 'radar'") as exc: - _run_main(tmp_path, monkeypatch, _CLAIMS_FAMILY, _DIFFERS, - tier="bogus") - message = str(exc.value) - assert "corpus_x.jsonl" in message - assert "'bogus'" in message - assert not _WORKER_CALL, ( - "main() spawned the worker before refusing the tier value") - - -def test_radar_diff_with_no_rule_exits_0_and_is_reported( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The tier split's entire point (#468): a harvested name's diff - is shown, not owed a ledger rule. The heading is pinned because it - is what a release reader greps for. - - An empty ledger, not the usual 'unrelated'/ZZZ decoy rule: that - decoy matches no diffing name in a single-name corpus and so is - itself EXPLAINED NOTHING (dormant_rules' "reverted" case) -- - orthogonal to the tier split and would fail this run for a reason - that has nothing to do with what it is pinning.""" - code, out = _run_main(tmp_path, monkeypatch, "", _DIFFERS, tier="radar") - assert code == 0 - assert "UNCLASSIFIED (radar) 'John Smith'" in out - assert "family:" in out - assert "UNEXPLAINED" not in out - - -def test_radar_initials_only_diff_prints_its_pseudo_field_line( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The radar block forwards `initials_only` too. Every other - `_initials` print test goes through the UNEXPLAINED block, and the - two blocks call _print_field_diffs from separate call sites -- so - passing `initials_only=False` at the radar one leaves a - `UNCLASSIFIED (radar)` header with NO field lines under it, which - is a report nobody can act on and which no other test here sees. - - An empty ledger for the same reason as the sibling above: a - ZZZ decoy rule would be dormant in a one-name corpus and exit 1 - for a reason that has nothing to do with the pseudo-field.""" - code, out = _run_main( - tmp_path, monkeypatch, "", _INITIALS_MOVED, tier="radar") - assert code == 0 - assert "UNCLASSIFIED (radar) 'John Smith'" in out - assert "_initials: 'J. X.' -> 'J. S.'" in out - - -def test_radar_diff_matching_a_rule_still_classifies( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Radar names keep feeding the release-note grouping, and a rule - explaining only radar diffs is NOT dormant -- exit 0 with no - EXPLAINED NOTHING block is the pin for both at once.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "claimed"\nname_regex = "Smith"\nfields = ["family"]\n', - _DIFFERS, tier="radar") - assert code == 0 - assert "## claimed (1)" in out - assert "EXPLAINED NOTHING" not in out - - -def test_contract_diff_still_fails_under_the_tier_roster( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The split must not loosen the tier that keeps the old promise.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\nfields = ["family"]\n', - _DIFFERS, tier="contract") - assert code == 1 - assert "UNEXPLAINED 'John Smith'" in out - - -def test_radar_name_refused_by_an_exclusion_still_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A [[never]] entry is chosen -- someone wrote its `why` and its - `examples` -- so it belongs to the contract even when the name it - refuses sits in a radar file. classify() returns None for both - 'no rule matched' and 'an exclusion refused this', and only the - first is the tier split's business: an excluded shape must stay - UNEXPLAINED and exit 1 on every tier, matching what - validate_exclusions' docstring and every 1.4.0 `why` promise.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[never]]\nwhy = "test exclusion"\nname_regex = "Smith"\n' - 'examples = ["John Smith"]\n', - _DIFFERS, tier="radar") - assert code == 1 - assert "UNEXPLAINED 'John Smith'" in out - assert "UNCLASSIFIED" not in out - - -def test_a_corpus_without_a_tier_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Fail-closed like _CORPUS_FLOORS: a new corpus must choose.""" - with pytest.raises(SystemExit, match="_CORPUS_TIERS"): - _run_main(tmp_path, monkeypatch, "", _DIFFERS, tier=None) - - -def test_object_corpus_lines_are_read_and_labels_printed( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A corpus line may be {"name": ..., "tests": [...]} -- the - label-bearing format corpus.jsonl ships in (both shapes are legal - on any corpus file). The name is compared and the labels ride into - the radar report, which is what they are for.""" - import json as _json - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps( - {"name": "John Smith", "tests": ["test_two_word_name"]}) + "\n", - encoding="utf-8") - (tmp_path / "expected_since_1.4.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "radar") - monkeypatch.setattr(compare, "HERE", tmp_path) - monkeypatch.setattr( - compare, "_run_worker", - lambda v, w, entries: ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"facade": _DIFFERS}])) - import contextlib - import io - import sys - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "1.4.0", - "--corpus", str(corpus)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - assert code == 0 - assert "test_two_word_name" in buf.getvalue() - - -def test_a_malformed_tests_label_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Caught at load time, not report time: a 'tests' label is read - only when printing the radar block, at the end of the run and well - past the worker pass, so a bad one left unchecked would crash there - instead -- the failure mode validate_rules' compile-at-startup - paragraph exists to avoid.""" - import json as _json - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps( - {"name": "John Smith", "tests": "not_a_list"}) + "\n", - encoding="utf-8") - with pytest.raises(SystemExit, match="'tests' must be a list"): - compare._load_entries(corpus) - - -def test_a_misspelled_corpus_key_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """'shpae' is not 'shape', and an ignored key is a narrowing that - silently did not happen: the line compares under the default order - while its author believes they declared a family-first one. The - message names the FILE, like every other loader error here, because - one run reads every corpus in the directory and a message without a - filename says nothing about which one to open.""" - import json as _json - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps( - {"name": "John Smith", "shpae": 4}) + "\n", encoding="utf-8") - with pytest.raises(SystemExit, match=r"corpus_x\.jsonl.*'shpae'"): - compare._load_entries(corpus) - - -def test_a_corpus_line_writing_a_computed_key_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """'order' is the key the WIRE protocol documents, so it is the one - a corpus author is likeliest to write by hand -- and main() computes - it from `shape` and overwrites whatever the line said. Rejected - rather than obeyed: honoring it would let a corpus line name an - order no shape declares, which is the check `orders` rules get.""" - import json as _json - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps( - {"name": "John Smith", "order": "FAMILY_FIRST"}) + "\n", - encoding="utf-8") - with pytest.raises(SystemExit, match=r"corpus_x\.jsonl.*'order'"): - compare._load_entries(corpus) - - -@pytest.mark.parametrize("shape", [True, "4"]) -def test_a_malformed_shape_id_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - shape: object) -> None: - """`true` is the dangerous one: bool is an int subclass and - hash(True) == hash(1), so an unchecked {"shape": true} resolves - against shapes.py's entry 1 and the line is compared under THAT - shape's order -- a wrong comparison that reports as a passing one. - The string spelling is the honest typo beside it.""" - import json as _json - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps( - {"name": "John Smith", "shape": shape}) + "\n", encoding="utf-8") - with pytest.raises(SystemExit, match="'shape' must be an int"): - compare._load_entries(corpus) + src = compare._worker_source("1.4.0", want_v2=want_v2) + assert "__version__" in src and "__file__" in src -def test_a_corpus_line_that_is_neither_shape_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A bare number, or an object with no string 'name', carries no - name to compare. Skipping it would shrink the comparison by one - name and print a summary that reads exactly like a full run.""" - import json as _json - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps({"nmae": "John Smith"}) + "\n", - encoding="utf-8") - with pytest.raises(SystemExit, match="neither a JSON string"): - compare._load_entries(corpus) +def test_worker_source_gates_the_v2_import_on_the_baseline() -> None: + """1.4 has no nameparser.parse to import; asking for it would make + the worker die on import rather than report a clean facade diff.""" + assert "WANT_V2 = False" in compare._worker_source("1.4.0", want_v2=False) + assert "WANT_V2 = True" in compare._worker_source("2.0.0", want_v2=True) -def test_a_shape_id_shapes_py_does_not_define_is_a_hard_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """_load_entries checks the TYPE; only main() has shapes.py loaded, - so resolvability is its check. Unchecked, the entry would compare - under whatever `.get` returned rather than the order it declared.""" - import json as _json - import sys - corpus = tmp_path / "corpus_x.jsonl" - corpus.write_text(_json.dumps( - {"name": "John Smith", "shape": 9999}) + "\n", encoding="utf-8") - (tmp_path / "expected_since_2.0.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, corpus.name, 1) - monkeypatch.setitem(compare._CORPUS_TIERS, corpus.name, "contract") - monkeypatch.setattr(compare, "HERE", tmp_path) - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "2.0.0", - "--corpus", str(corpus)]) - with pytest.raises(SystemExit, match="shapes.py does not define"): - compare.main() +@pytest.mark.parametrize("want_v2", [True, False]) +def test_worker_source_compiles(want_v2: bool) -> None: + """A syntax error in the rendered template surfaces only as the + opaque 'worker exited 1', and by then the temp dir holding the + rendered source is gone; this catches it at test time, for both + renderings (WANT_V2 gates a def-inside-if that is easy to + misindent).""" + compile(compare._worker_source("2.2.0", want_v2=want_v2), + "", "exec") -def test_cross_tier_dedup_keeps_the_contract_reading( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """'John Smith' really does sit in both corpus.jsonl (radar) and - corpus_rules.jsonl (contract). Nothing above the dedup itself - would catch a regression here, so this is the one guard that pins - contract-first loading rather than just describing it: an - unmatched diff on the shared name must still be UNEXPLAINED and - fail the run, whichever way the sort key is written.""" - import json as _json - radar_file = tmp_path / "corpus.jsonl" - contract_file = tmp_path / "corpus_rules.jsonl" - radar_file.write_text(_json.dumps("John Smith") + "\n", encoding="utf-8") - contract_file.write_text( - _json.dumps("John Smith") + "\n", encoding="utf-8") - (tmp_path / "expected_since_1.4.0.toml").write_text("", encoding="utf-8") - monkeypatch.setitem(compare._CORPUS_FLOORS, radar_file.name, 1) - monkeypatch.setitem(compare._CORPUS_FLOORS, contract_file.name, 1) - monkeypatch.setattr(compare, "HERE", tmp_path) - monkeypatch.setattr( - compare, "_run_worker", - lambda v, w, entries: ({"__version__": v, - "__file__": "/wheel/nameparser/__init__.py"}, - [{"facade": _DIFFERS}])) - import contextlib - import io - import sys - monkeypatch.setattr(sys, "argv", ["compare.py", "--baseline", "1.4.0", - "--corpus", str(radar_file), - "--corpus", str(contract_file)]) - buf = io.StringIO() - with contextlib.redirect_stdout(buf): - code = compare.main() - out = buf.getvalue() - assert code == 1 - assert "UNEXPLAINED 'John Smith'" in out +def test_every_shape_orders_resolve_and_bound_sanely() -> None: + """An `order` is a public constant name that Policy accepts as a + name_order -- hasattr alone would admit any real attribute, failing + only inside the worker -- and a shape with an order cannot claim a + pre-2.0 baseline, since Policy shipped in 2.0.0. Every shape's + min_baseline must parse, ordered or not.""" + import nameparser + from nameparser import Policy + for sid, shape in shapes.SHAPES.items(): + parsed_min = compare._parse_version(shape.min_baseline) + if shape.order is not None: + assert hasattr(nameparser, shape.order), (sid, shape.order) + Policy(name_order=getattr(nameparser, shape.order)) + assert parsed_min >= (2, 0, 0), sid -def test_main_validates_the_ledger_before_running_anything( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """validate_rules has its own tests; this pins that main CALLS it. - Deleting the call leaves those tests passing while a match-anything - rule shadows the ledger.""" - with pytest.raises(SystemExit, match="matches every one of"): - _run_main(tmp_path, monkeypatch, - '[[change]]\nissue = "wide"\nname_regex = ""\nfields = ["family"]\n', _DIFFERS) +def test_v2_fields_matches_the_Role_enum() -> None: + """The seven roles are 'defined once and derived everywhere'. The + snapshot cannot import Role into the WORKER (that runs under the + old wheel), but its copy reads the tree's ParsedName and must track + Role: a role missing here is invisible to every comparison.""" + from nameparser import Role + assert snapshot.V2_FIELDS == tuple(str(r) for r in Role) -def test_main_rejects_a_broad_fields_only_rule_before_running_anything( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """This used to pin main's _sorted_rules call: a broad fields-only - rule written FIRST could not claim a diff a specific name_regex - rule below it owned, because _sorted_rules puts every name_regex - rule ahead of every fields-only one. That scenario is retired by - #451 -- a fields-only rule is rejected at validate_rules, so main() - never reaches _sorted_rules with one. What is left to pin here is - that main() still validates before running, for this shape too, - the same composition fact test_main_validates_the_ledger_before_ - running_anything covers for a different malformed shape. The - tier-sort mechanism itself stays pinned directly by - test_name_regex_rules_sort_ahead_of_fields_only_rules and - test_rule_sort_is_stable_within_a_tier, which call _sorted_rules - without going through validate_rules. - """ - with pytest.raises(SystemExit, match="no 'name_regex'"): - _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "broad"\nfields = ["family"]\n' - '[[change]]\nissue = "specific"\nname_regex = "Smith"\nfields = ["family"]\n', - _DIFFERS) +def _run_template(line: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """The generated worker, exec'd in-process on one input line. Its + `import nameparser` then resolves to this checkout, so what it + emits is the tree's own reading, comparable with snapshot.read's.""" + import nameparser + source = compare._worker_source(nameparser.__version__, want_v2=True) + real_stdin, buf = sys.stdin, io.StringIO() + try: + sys.stdin = io.StringIO(json.dumps(line, ensure_ascii=False) + "\n") + with contextlib.redirect_stdout(buf): + exec(compile(source, "baseline_worker.py", "exec"), {}) + finally: + sys.stdin = real_stdin + tell, row = (json.loads(x) for x in buf.getvalue().splitlines()) + assert tell["__version__"] == nameparser.__version__ + return tell, row -def test_main_exits_1_and_names_a_rule_that_explained_nothing( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The #372 gate. `idle` matches no diffing name, so it explains - nothing and is not declared dormant -- the run must say so and fail, - even though every diff here IS explained. +@pytest.mark.parametrize("name,order", [ + # The one corpus name whose facade and core initials differ + # ('J. P D.' against 'J. P. D.'), so a row that read the core's + # value into the facade's slot cannot pass. + ("Ph. D., John", None), + ("Ménil Christophe du", "FAMILY_FIRST"), +]) +def test_the_worker_reads_a_line_as_the_tree_does( + name: str, order: str | None) -> None: + """The worker's row and snapshot.read's are separate code on two + sides of a process boundary; a comparison means a behavior change + only while both turn the same parse into the same snapshot row.""" + _, raw = _run_template({"name": name, "order": order}) + if order is not None: + # the facade is never consulted for an order-bearing entry + assert "facade" not in raw + assert snapshot.row(name, order, raw.get("v2"), raw.get("facade")) \ + == snapshot.read([(name, order)])[0] - Nothing else pins this: dropping the dormancy terms from main's - return, or deleting the report loop, leaves every other test green. - """ - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "explains-it"\nname_regex = "Smith"\n' - 'fields = ["family"]\n' - '[[change]]\nissue = "idle"\nname_regex = "ZZNOSUCHNAME"\n' - 'fields = ["family"]\n', _DIFFERS) - assert code == 1 - assert "EXPLAINED NOTHING 'idle'" in out - assert "may have been reverted" in out - # the diff itself was explained; this failure is only about the rule - assert "unexplained: 0" in out +_WHEEL = "/Users/x/.cache/uv/environments-v2/w/lib/python3.11/" \ + "site-packages/nameparser/__init__.py" -def test_main_exits_1_and_names_an_over_declared_rule( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The #452 gate. `wide` explains the only diff here and declares - two roles it never moves -- the run must say so and fail, even - though every diff IS explained and no rule is idle. - Nothing else pins this, which is the same gap _run_main's own - docstring names for the dormancy check: five unit tests prove - over_declared_rules WORKS and none proved main() calls it. - Measured on mutants built outside the repo -- deleting the - `roles_by_issue` accumulator, deleting this report loop, or - dropping `or overwide` from main's return each leaves the whole - suite green (#455 review). - """ - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "wide"\nname_regex = "Smith"\n' - 'fields = ["family", "given", "suffix"]\n', _DIFFERS) - assert code == 1 - # the ledger is named before the issue: this rule's correct - # `fields` differ per baseline, so the file is part of the finding - assert "OVER-DECLARED" in out and "'wide'" in out - assert out.count(".toml: 'wide'") == 1 - assert "['given', 'suffix']" in out # the roles nothing moves - assert "['family']" in out # the repair - # the diff itself was explained; this failure is only about the rule - assert "unexplained: 0" in out +def test_tell_accepts_a_matching_wheel() -> None: + compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0.0") -def test_main_accepts_a_rule_declaring_exactly_what_it_moves( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The other direction, so the #452 check cannot pass by being - unconditional. `exact` declares the one role the one diff moves, - and the run is silent and exits 0. +def test_tell_accepts_an_equivalent_short_release() -> None: + compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0") - Its partner above would still pass if over_declared_rules reported - every rule; this is what makes that impossible. - """ - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "exact"\nname_regex = "Smith"\n' - 'fields = ["family"]\n', _DIFFERS) - assert code == 0 - assert "OVER-DECLARED" not in out +def test_tell_rejects_a_version_mismatch() -> None: + with pytest.raises(SystemExit, match="not the requested"): + compare._check_tell( + {"__version__": "2.1.0", "__file__": _WHEEL}, "2.0.0") -def test_main_only_feeds_diffing_names_to_the_dormancy_check( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """If a non-diffing name reached `diffing`, a rule matching only - that name would appear to match a name in the diff set -- and - since it is the only rule that matches it, dormant_rules would - classify it via that same rule and diagnose it as its own - shadower ("shadowed by 'idle'"), instead of the correct - 'reverted' diagnosis for a rule that matches no diffing name. - 'Alice Jones' is added via `extra` with a baseline facade equal to - what the tree parses it as -- it does not diff -- alongside the - fixture's own diffing 'John Smith'. `idle`'s regex matches only - 'Alice Jones', so it must be reported reverted, never shadowed. - """ - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "explains-it"\nname_regex = "Smith"\n' - 'fields = ["family"]\n' - '[[change]]\nissue = "idle"\nname_regex = "Jones"\n' - 'fields = ["family"]\n', _DIFFERS, - extra=[("Alice Jones", - {"title": "", "first": "Alice", "middle": "", - "last": "Jones", "suffix": "", "nickname": "", - "maiden": ""})]) - assert code == 1 - assert "EXPLAINED NOTHING 'idle'" in out - assert "may have been reverted" in out - assert "shadowed by 'idle'" not in out - # the diffing name's diff was explained; this failure is only - # about the rule that matched no diffing name - assert "unexplained: 0" in out +def test_tell_rejects_a_module_loaded_from_the_checkout() -> None: + """The failure the whole design exists to make impossible. An + editable install reports the TREE's version, so when the tree and + the baseline share a version the version half of the tell agrees + and only the path gives it away.""" + checkout = _TOOLS.parents[1] / "nameparser" / "__init__.py" + with pytest.raises(SystemExit, match="CHECKOUT"): + compare._check_tell( + {"__version__": "2.0.0", "__file__": str(checkout)}, "2.0.0") -def test_main_exits_1_when_a_declared_dormant_rule_wakes_up( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The other direction. A `dormant` reason that stopped being true is - a false statement in the ledger, so it fails the run too.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "awake"\nname_regex = "Smith"\n' - 'fields = ["family"]\n' - 'dormant = "claims to be idle, but explains the only diff here"\n', - _DIFFERS) - assert code == 1 - assert "NO LONGER DORMANT 'awake'" in out +def test_tell_rejects_an_empty_tell() -> None: + with pytest.raises(SystemExit): + compare._check_tell({}, "2.0.0") def test_check_tree_accepts_the_checkout_and_rejects_anything_else( tmp_path: Path) -> None: - """The tree side is the half that had no proof at all: the baseline - gets a pinned wheel, a temp dir and a version tell, while the tree - was a bare import trusted on sight.""" inside = _TOOLS.parents[1] / "nameparser" / "__init__.py" assert compare._check_tree(str(inside)) == inside.resolve() with pytest.raises(SystemExit, match="not from this checkout's source"): compare._check_tree(str(tmp_path / "nameparser" / "__init__.py")) -def test_main_aborts_when_the_tree_side_is_not_the_checkout( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Pins that main CALLS the tree check, not merely that the check - works. Measured 2026-08-05: with a released 2.0.0 on PYTHONPATH, - compare.py imported THAT and reported `intentional diffs: 0`, - exit 0 -- both halves of the baseline tell passing. Run as a - script, sys.path[0] is tools/differential/, which holds no - nameparser, so PYTHONPATH outranks the editable install. +@pytest.mark.parametrize("rel", [ + ".venv/lib/python3.11/site-packages/nameparser/__init__.py", + "build/lib/nameparser/__init__.py", + "dist/unpacked/nameparser/__init__.py", +]) +def test_check_tree_rejects_a_wheel_sitting_inside_the_checkout( + rel: str) -> None: + """The repo contains .venv/, build/ and dist/, any of which can hold + a released wheel, so "under the repo" is not "the source package".""" + with pytest.raises(SystemExit, match="not from this checkout's source"): + compare._check_tree(str(compare.REPO_ROOT / rel)) - REPO_ROOT is moved rather than the module, because relocating the - import is what the trap does and this reproduces its EFFECT: the - tree's nameparser is no longer under the root it must be under. - """ - monkeypatch.setattr(compare, "REPO_ROOT", tmp_path) + +def test_check_tree_resolves_before_comparing() -> None: + """Without .resolve(), a path escaping via .. reads as inside.""" + escaped = compare.REPO_ROOT / "nameparser" / ".." / ".." / "x" \ + / "nameparser" / "__init__.py" with pytest.raises(SystemExit, match="not from this checkout's source"): - _run_main(tmp_path, monkeypatch, - '[[change]]\nissue = "x"\nname_regex = "ZZZ"\nfields = ["family"]\n', _DIFFERS) + compare._check_tree(str(escaped)) def test_worker_env_strips_the_import_path_overrides( @@ -3857,11 +251,9 @@ def communicate(self, payload: str) -> tuple[str, str]: return _FakePopen.out, "" -def _fake_popen(monkeypatch: pytest.MonkeyPatch, out: str, - rc: int = 0) -> type[_FakePopen]: +def _fake_popen(monkeypatch: pytest.MonkeyPatch, out: str, rc: int = 0) -> None: _FakePopen.out, _FakePopen.rc = out, rc monkeypatch.setattr(compare.subprocess, "Popen", _FakePopen) - return _FakePopen _TELL = ('{"__version__": "1.4.0", ' @@ -3871,10 +263,7 @@ def _fake_popen(monkeypatch: pytest.MonkeyPatch, out: str, def test_run_worker_strips_the_import_path_overrides_from_the_child( monkeypatch: pytest.MonkeyPatch) -> None: - """_worker_env has its own test; this pins that _run_worker USES - it. Deleting `env=_worker_env()` left all 61 tests green -- the - same shape as the bug the previous review found, a proved helper - with an unproved call site.""" + """_worker_env has its own test; this pins that _run_worker USES it.""" monkeypatch.setenv("PYTHONPATH", "/shadow") _fake_popen(monkeypatch, f"{_TELL}\n{_ROW}\n") compare._run_worker("1.4.0", False, [{"name": "John Smith"}]) @@ -3898,8 +287,7 @@ def test_run_worker_aborts_on_empty_output( def test_run_worker_aborts_when_fewer_results_than_names( monkeypatch: pytest.MonkeyPatch) -> None: - """The guard behind main's zip(), which truncates silently. This is - the comparing-fewer-names-than-you-think failure.""" + """The guard behind compare()'s zip(), which truncates silently.""" _fake_popen(monkeypatch, f"{_TELL}\n{_ROW}\n") with pytest.raises(SystemExit, match="1 results for 2 corpus entries"): compare._run_worker("1.4.0", False, @@ -3915,14 +303,10 @@ def test_run_worker_checks_the_tell_before_returning_results( compare._run_worker("1.4.0", False, [{"name": "John Smith"}]) -def test_run_worker_sends_the_name_and_resolved_order_on_the_wire( +def test_run_worker_sends_the_name_and_order_on_the_wire( monkeypatch: pytest.MonkeyPatch) -> None: """The wire format is the whole contract between compare.py and the - generated worker; nothing else pins it, so a resolution bug -- e.g. - forgetting to set e["order"] before calling this -- would leave - every other test in this file green while the worker silently - received the wrong order for every name. _FakePopen.communicate - already records the payload it was given; this reads it back.""" + generated worker.""" _fake_popen(monkeypatch, f"{_TELL}\n{_ROW}\n{_ROW}\n") compare._run_worker( "1.4.0", False, @@ -3934,893 +318,92 @@ def test_run_worker_sends_the_name_and_resolved_order_on_the_wire( '{"name": "Ménil Christophe du", "order": "FAMILY_FIRST"}' -@pytest.mark.parametrize("want_v2", [True, False]) -def test_worker_source_compiles(want_v2: bool) -> None: - """A syntax error in the rendered template surfaces only as the - opaque 'worker exited 1', and by the time _run_worker raises it the - TemporaryDirectory holding the rendered source is already gone, so - there is nothing left to open; this catches it at test time - instead, for both renderings (WANT_V2 gates a def-inside-if that is - easy to misindent). The install this used to call "multi-minute" is - sub-second even on a cold uv cache -- withdrawn as unmeasured by - #497; the opacity is the reason, not the wait.""" - compile(compare._worker_source("2.2.0", want_v2=want_v2), - "", "exec") +_ENTRIES = [("John Smith", None), ("Ménil Christophe du", "FAMILY_FIRST")] + + +def _tree_results(entries: list[tuple[str, str | None]], want_v2: bool + ) -> list[dict[str, Any]]: + """What a worker would emit if the baseline read every entry as + the tree does -- built by running the template on each line.""" + out = [] + for name, order in entries: + _, raw = _run_template({"name": name, "order": order}) + if not want_v2: + raw.pop("v2", None) + out.append(raw) + return out + + +def _fake_compare(monkeypatch: pytest.MonkeyPatch, baseline: str, + alter: "Callable[[list[dict[str, Any]]], None] | None" = None + ) -> tuple[tuple[list[str], int, int], + list[dict[str, Any]]]: + """compare() over _ENTRIES with the worker replaced by the tree's + own readings, optionally altered by `alter(results)`; also returns + the entries the worker was sent.""" + monkeypatch.setattr(snapshot, "corpus_entries", lambda: list(_ENTRIES)) + monkeypatch.setattr(compare, "_load_snapshot", lambda: snapshot) + sent: list[list[dict[str, Any]]] = [] + + def fake_run(version: str, want_v2: bool, entries: list[dict[str, Any]] + ) -> tuple[dict[str, str], list[dict[str, Any]]]: + sent.append(entries) + results = _tree_results([(e["name"], e["order"]) for e in entries], want_v2) + if alter: + alter(results) + return {}, results + monkeypatch.setattr(compare, "_run_worker", fake_run) + return compare.compare(baseline), sent[0] + + +def test_compare_reports_no_move_when_the_baseline_reads_like_the_tree( + monkeypatch: pytest.MonkeyPatch) -> None: + assert _fake_compare(monkeypatch, "2.4.0")[0] == ([], 0, 0) -@pytest.mark.parametrize("rel", [ - ".venv/lib/python3.11/site-packages/nameparser/__init__.py", - "build/lib/nameparser/__init__.py", - "dist/unpacked/nameparser/__init__.py", -]) -def test_check_tree_rejects_a_wheel_sitting_inside_the_checkout( - rel: str) -> None: - """The hole in the first version of this guard. It asked "is this - under the repo", but the repo contains .venv/, build/ and dist/, - any of which can hold a released wheel -- so - PYTHONPATH=/build/lib was the same trap one directory to the - left, and uv never touches build/ to self-heal it.""" - with pytest.raises(SystemExit, match="not from this checkout's source"): - compare._check_tree(str(compare.REPO_ROOT / rel)) +def test_compare_reports_a_move(monkeypatch: pytest.MonkeyPatch) -> None: + """A clean run and a broken comparison print the same thing; this + is the control that the comparison can see a difference.""" + def alter(results: list[dict[str, Any]]) -> None: + results[0]["v2"]["family"] = "Smyth" + results[0]["facade"]["last"] = "Smyth" + (moves, moved, latin), _ = _fake_compare(monkeypatch, "2.4.0", alter) + assert (moved, latin) == (1, 1) + assert moves[0] == "family: 1" + assert "'Smyth' -> 'Smith'" in moves[1] -def test_check_tree_resolves_before_comparing() -> None: - """Without .resolve(), a path escaping via .. reads as inside.""" - escaped = compare.REPO_ROOT / "nameparser" / ".." / ".." / "x" \ - / "nameparser" / "__init__.py" +def test_compare_reads_the_facade_alone_below_2_0( + monkeypatch: pytest.MonkeyPatch) -> None: + """1.4 has no 2.0 surface, so the tree's facade reading is what it + is compared with: a facade move shows, and nothing order-bearing is + sent, since no order existed before 2.0.""" + def alter(results: list[dict[str, Any]]) -> None: + results[0]["facade"]["last"] = "Smyth" + (moves, moved, _), sent = _fake_compare(monkeypatch, "1.4.0", alter) + assert moved == 1 and moves[0] == "family: 1" + assert [e["order"] for e in sent] == [None] + + +def test_compare_checks_that_the_tree_side_is_the_checkout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Pins that compare() CALLS the tree check: run as a script, + sys.path[0] is tools/differential/, so a PYTHONPATH wheel outranks + the editable install and two wheels would agree on everything. + REPO_ROOT is moved, which reproduces the trap's effect.""" + monkeypatch.setattr(compare, "REPO_ROOT", tmp_path) with pytest.raises(SystemExit, match="not from this checkout's source"): - compare._check_tree(str(escaped)) - - -#: The tree's own reading of the fixture name, on both surfaces. A fake -#: baseline row built from these differs from the tree in exactly the -#: one field a test chooses to alter. -_SAME_FACADE = {"title": "", "first": "John", "middle": "", "last": "Smith", - "suffix": "", "nickname": "", "maiden": ""} -_SAME_V2 = {"title": "", "given": "John", "middle": "", "family": "Smith", - "suffix": "", "nickname": "", "maiden": "", "_ambiguities": []} - -#: 'John Smith' with every role identical and only the facade's -#: initials moved: the render-layer drift #484 exists to see. -_INITIALS_MOVED = {**_SAME_FACADE, "_initials": "J. X."} - - -def test_main_reports_an_initials_only_diff_under_the_pseudo_field( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """#484: a name whose seven roles agree on both surfaces and whose - initials do not is a diff, reported under `_initials` so the block - can be pasted into a rule.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', _INITIALS_MOVED) - assert code == 1 - assert "UNEXPLAINED 'John Smith'" in out - assert "_initials: 'J. X.' -> 'J. S.'" in out - - -def test_main_classifies_an_initials_only_diff_by_an_initials_rule( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "render-drift"\nname_regex = "Smith"\n' - 'fields = ["_initials"]\n', _INITIALS_MOVED) - assert code == 0 - assert "## render-drift (1)" in out - - -def test_main_drops_initials_from_a_diff_where_a_role_moved( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The roles-identical guard, pinned as the exact condition. The - rule below declares `family` only; if `_initials` entered the diff - beside the moved role, the subset test would decline it and the - run would exit 1. Delete `not diff and` from the guard and this - is the test that fails.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "role-only"\nname_regex = "Smith"\n' - 'fields = ["family"]\n', {**_DIFFERS, "_initials": "J. X."}) - assert code == 0 - assert "## role-only (1)" in out - assert "_initials" not in out - - -def test_main_does_not_print_initials_beside_a_moved_role( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The print half of the guard. An UNEXPLAINED block exists to be - pasted into a rule, and a rule listing `_initials` beside a role - is refused by validate_rules -- so the block must never show the - pair. Pass `initials_only=True` unconditionally (or delete the - `if initials_only:`) in _print_field_diffs and this fails while - the classification test above still passes.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', {**_DIFFERS, "_initials": "J. X."}) - assert code == 1 - assert "family: 'SMYTHE' -> 'Smith'" in out - assert "_initials" not in out - - -def test_main_reports_a_v2_only_initials_diff_with_the_surface_tag( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The v2 half of the pseudo-field: the facade agrees and the core - view moved, which is the shape #408 had for a whole minor.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', - _SAME_FACADE, baseline="2.0.0", - baseline_v2={**_SAME_V2, "_initials": "J. X."}) - assert code == 1 - assert "_initials: 'J. X.' -> 'J. S.' [v2 surface only]" in out - - -def test_main_prints_both_initials_lines_when_the_surfaces_moved_differently( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The facade's and the core's initials() are independent - implementations, so unlike a role they can move to different - strings; a block that showed only the facade's movement would - have a rule written for it in the belief the core agreed.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', - {**_SAME_FACADE, "_initials": "J. X."}, baseline="2.0.0", - baseline_v2={**_SAME_V2, "_initials": "J. Y."}) - assert code == 1 - assert "_initials: 'J. X.' -> 'J. S.'" in out - assert "_initials: 'J. Y.' -> 'J. S.' [v2 surface]" in out - assert out.count("_initials:") == 2 - - -def test_main_prints_one_initials_line_when_both_surfaces_moved_alike( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The print-once convention, kept for the case it was written - for: two surfaces moving to the SAME pair are one movement and one - rule, so the facade line stands for both and carries no surface - tag. - - The mutant this kills is replacing the WHOLE - `(not facade_moved or v2_pair != facade_pair)` guard with a bare - `if v2_moved:` -- then the v2 line prints here too, while the - sibling above still sees its two lines and passes. Deleting only - the pair comparison is the SIBLING's mutant, not this one: it - leaves `v2_moved and not facade_moved`, which is False here, so - this case still prints once and this test cannot see it.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', - {**_SAME_FACADE, "_initials": "J. X."}, baseline="2.0.0", - baseline_v2={**_SAME_V2, "_initials": "J. X."}) - assert code == 1 - assert "_initials: 'J. X.' -> 'J. S.'" in out - assert out.count("_initials:") == 1 - assert "[v2 surface" not in out + _fake_compare(monkeypatch, "2.4.0") -def test_main_keeps_initials_out_of_a_diff_a_V2_role_moved( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The v2 half of the roles-identical guard. Its facade half is - pinned above (test_main_drops_initials_from_a_diff_where_a_role_ - moved); this one moves the role on the CORE surface only, where the - guard reads a different dict. The rule declares `family` alone, so - an `_initials` that leaked into the diff beside it would fail the - subset test and exit 1.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "role-only"\nname_regex = "Smith"\n' - 'fields = ["family"]\n', - _SAME_FACADE, baseline="2.0.0", - baseline_v2={**_SAME_V2, "family": "SMYTHE", "_initials": "J. X."}) - assert code == 0 - assert "## role-only (1)" in out - assert "_initials" not in out - - -def test_main_prints_one_initials_line_when_only_the_facade_moved( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The facade moved and the core, compared, AGREED -- the reading - the docstring of _print_field_diffs warns a bare facade line does - NOT license, and the case that makes the warning necessary. Both - surfaces were consulted here; the v2 line is suppressed because - the core did not move, not because it was never asked. - - Distinct from test_main_reports_an_initials_only_diff_under_the_ - pseudo_field, which runs at 1.4.0 where there is no core surface - to agree.""" - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', - {**_SAME_FACADE, "_initials": "J. X."}, baseline="2.0.0", - baseline_v2=dict(_SAME_V2)) - assert code == 1 - assert "_initials: 'J. X.' -> 'J. S.'" in out - assert out.count("_initials:") == 1 - assert "[v2 surface" not in out - - -def test_run_main_refuses_a_fixture_row_with_an_unknown_key( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The fixture's own guard, and the reason it exists: main() reads - `_initials` off the row and the helper defaults a MISSING one to - the tree's own answer, so a misspelled key is not a loud failure - but a silent agreement -- the test passes having compared the tree - against itself. Refuse the row instead of letting it pass.""" - with pytest.raises(AssertionError, match="_initals"): - _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["family"]\n', - {**_SAME_FACADE, "_initals": "J. X."}) - - -def test_main_compares_the_v2_surface_from_baseline_2_0( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A SEGMENTATION-only diff is facade-identical by construction, so - it is invisible unless main actually unions the v2 surface into the - diff set. That diff shape is the whole stated reason _surfaces_for - compares v2 from 2.0 on -- and every mutation that disabled it - (want_v2 forced False, the v2 union deleted, `|=` changed to `=`) - passed the suite before this test existed. - """ - v2 = {**_SAME_V2, "_ambiguities": ["SEGMENTATION"]} - code, out = _run_main( - tmp_path, monkeypatch, - # `_ambiguities`, not `family`: this test's diff IS the - # ambiguity-only one, and a `family` declaration refuses that - # shape -- which left the rule inert even with name narrowing - # disabled, costing this test the mutation it was written to - # catch (#457 review). Declare the role the diff moves. - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\n' - 'fields = ["_ambiguities"]\n', - _SAME_FACADE, baseline="2.0.0", baseline_v2=v2) - assert code == 1, "an ambiguity-only regression must not exit 0" - assert "UNEXPLAINED 'John Smith'" in out - assert "_ambiguities:" in out - assert "[v2 surface only]" in out, ( - "the tag distinguishes an ambiguity-kind change from a field " - "change; without it the row reads as a field diff") - - -def test_main_claims_an_ambiguity_only_diff_when_a_rule_names_it( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - v2 = {**_SAME_V2, "_ambiguities": ["SEGMENTATION"]} - code, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "seg"\nname_regex = "Smith"\n' - 'fields = ["_ambiguities"]\n', - _SAME_FACADE, baseline="2.0.0", baseline_v2=v2) - assert code == 0 and "## seg (1)" in out - - -def test_main_reports_a_role_once_when_both_surfaces_moved( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The `seen` set. Both surfaces name the same role, so a family - change shows on each; printing it twice would read as two findings.""" - _, out = _run_main( - tmp_path, monkeypatch, - '[[change]]\nissue = "unrelated"\nname_regex = "ZZZ"\nfields = ["family"]\n', - {**_SAME_FACADE, "last": "SMYTHE"}, baseline="2.0.0", - baseline_v2={**_SAME_V2, "family": "SMYTHE"}) - assert out.count("family:") == 1 - - -def test_main_forwards_the_baseline_and_corpus_to_the_worker( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Otherwise main could read the 2.0 ledger while comparing against - 1.4, or compare a truncated corpus, and every other test would pass.""" - _run_main(tmp_path, monkeypatch, - '[[change]]\nissue = "x"\nname_regex = "ZZZ"\nfields = ["family"]\n', - _SAME_FACADE, baseline="2.0.0", baseline_v2=_SAME_V2) - assert _WORKER_CALL == {"version": "2.0.0", "want_v2": True, - "names": ["John Smith"]} - - -def test_main_asks_for_the_facade_alone_below_2_0( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - _run_main(tmp_path, monkeypatch, - '[[change]]\nissue = "x"\nname_regex = "ZZZ"\nfields = ["family"]\n', _SAME_FACADE) - assert _WORKER_CALL["want_v2"] is False - - -def test_every_shipped_corpus_has_a_floor_and_clears_it() -> None: - """Two bindings, so neither half can rot alone: every corpus file - on disk must have a floor, and must be at or above it. - - The floor exists because the empty-file guard only catches a corpus - that lost EVERY name. One truncated to a handful passes that guard, - and the run exits 0 having compared a fraction of what its summary - line reports -- the harness's own stated nightmare, reached by a - file that is merely short rather than absent. - """ - import json - corpora = sorted(_TOOLS.glob("corpus*.jsonl")) - assert corpora, "no corpora found; this test would pass vacuously" - for path in corpora: - names = [json.loads(line) for line - in path.read_text(encoding="utf-8").splitlines() - if line.strip()] - floor = compare._CORPUS_FLOORS.get(path.name) - assert floor is not None, ( - f"{path.name} has no _CORPUS_FLOORS entry; add one a little " - f"under its {len(names)} names") - assert len(names) >= floor, ( - f"{path.name} holds {len(names)}, below its floor {floor}") - - -def test_a_floor_names_a_corpus_that_exists() -> None: - """The other direction: a floor for a file nobody ships is a guard - that can never fire, and reads as coverage that is not there.""" - on_disk = {p.name for p in _TOOLS.glob("corpus*.jsonl")} - assert set(compare._CORPUS_FLOORS) <= on_disk - - -def test_every_corpus_with_a_floor_also_has_a_tier() -> None: - """The two rosters are meant to name the same files. A corpus in - one but not the other reopens the vanished-file hole the floors - were added to close: main() only checks _CORPUS_FLOORS' keys - against the files on disk (see the 'missing' check above the - loading loop), so a file present in _CORPUS_TIERS alone, or in - _CORPUS_FLOORS alone, would not be caught there.""" - assert set(compare._CORPUS_TIERS) == set(compare._CORPUS_FLOORS) - - -def test_main_aborts_on_a_truncated_corpus( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A corpus below its floor must stop the run, not shrink it.""" - with pytest.raises(SystemExit, match="below its floor"): - _run_main(tmp_path, monkeypatch, - '[[change]]\nissue = "x"\nname_regex = "ZZZ"\nfields = ["family"]\n', - _SAME_FACADE, floor=50) - - -def test_main_aborts_on_a_corpus_with_no_floor( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Adding a corpus without a floor must be a decision, not a - silent default -- the same force-a-decision shape the Script - tables use.""" - with pytest.raises(SystemExit, match="no entry in _CORPUS_FLOORS"): - _run_main(tmp_path, monkeypatch, - '[[change]]\nissue = "x"\nname_regex = "ZZZ"\nfields = ["family"]\n', - _SAME_FACADE, floor=None) - - -# The exclusion grammar. Every row is a way a [[never]] entry can look -# correct and protect nothing -- the same failure the rules' own -# validator exists for, pointed at the section that DISABLES -# classification instead of the one that performs it. -@pytest.mark.parametrize("entry,expect", [ - ({}, "no string 'why'"), - ({"why": ""}, "no string 'why'"), - ({"why": "x"}, "no string 'name_regex'"), - ({"why": "x", "name_regex": "Smith("}, "invalid 'name_regex'"), - ({"why": "x", "name_regex": "a"}, "no 'examples'"), - ({"why": "x", "name_regex": "a", "examples": []}, "no 'examples'"), - ({"why": "x", "name_regex": "a", "examples": "b"}, "not a list of strings"), - ({"why": "x", "name_regex": "a", "examples": ["a", 1]}, - "not a list of strings"), - # an example the entry does not actually protect - ({"why": "x", "name_regex": "zzz", "examples": ["John Smith"]}, - "does not match its own"), - # a misspelled key deletes half the declaration, exactly as for rules - ({"why": "x", "name_regex": "a", "examples": ["a"], "reason": "b"}, - "unknown key"), - # would silence the entire ledger - ({"why": "x", "name_regex": ".", "examples": ["a"]}, - "matches every one of"), -]) -def test_validate_exclusions_rejects_an_entry_that_protects_nothing( - entry: dict, expect: str) -> None: - with pytest.raises(SystemExit, match=expect): - compare.validate_exclusions([entry], "expected_since_1.4.0.toml") - - -def test_validate_exclusions_accepts_the_shipped_entries() -> None: - """The guards above must not be so strict they reject real entries.""" - import tomllib - for ledger in sorted(_TOOLS.glob("expected_since_*.toml")): - parsed = tomllib.loads(ledger.read_text(encoding="utf-8")) - compare.validate_exclusions(parsed.get("never", []), ledger.name) - - -def test_classify_refuses_an_excluded_shape() -> None: - """The whole point: an excluded name reports UNEXPLAINED however - many rules would otherwise claim it. Two did, for the shape this - was built for -- fix(comma-family) on file order, and the - fields-only fix(suffix-routing) which had no name_regex at all and - so reached every name. #451 deleted that second one, and no ledger - has a fields-only rule now; the fixture below keeps one because - the behaviour it pins is compare.classify's, not any ledger's.""" - rules = [{"issue": "broad", "name_regex": ","}, - {"issue": "broader", "fields": ["given", "suffix"]}] - never = [{"why": "parity", "name_regex": r"(?i)\bph\.\s*d\.\s*$", - "examples": ["John Smith, Ph. D."]}] - assert compare.classify("John Smith, Ph. D.", {"suffix"}, rules) == "broad" - assert compare.classify( - "John Smith, Ph. D.", {"suffix"}, rules, never) is None - # a name the exclusion does not cover is unaffected - assert compare.classify("Smith, Dr.", {"suffix"}, rules, never) == "broad" - - -@pytest.mark.parametrize("entry,expect", [ - ({"why": "x", "name_regex": "a", "examples": ["a"], "fields": "given"}, - "not a list of strings"), - ({"why": "x", "name_regex": "a", "examples": ["a"], "fields": []}, - "empty 'fields'"), - ({"why": "x", "name_regex": "a", "examples": ["a"], - "fields": ["_initials", "_initials"]}, "repeats"), - ({"why": "x", "name_regex": "a", "examples": ["a"], "fields": ["nope"]}, - "not roles"), - # the facade's vocabulary is not the role vocabulary - ({"why": "x", "name_regex": "a", "examples": ["a"], "fields": ["first"]}, - "not roles"), - # all seven means "any diff", which is what omitting the key does - ({"why": "x", "name_regex": "a", "examples": ["a"], - "fields": ["title", "given", "middle", "family", "suffix", - "nickname", "maiden"]}, "omit 'fields'"), -]) -def test_validate_exclusions_rejects_a_bad_fields_narrowing( - entry: dict, expect: str) -> None: - with pytest.raises(SystemExit, match=expect): - compare.validate_exclusions([entry], "expected_since_1.4.0.toml") - - -def test_an_excluded_shape_stays_classifiable_on_other_roles() -> None: - """The reason `fields` exists. ASCII parens mark nicknames, maiden - names, suffixes and credentials alike, so an exclusion that names - the nickname reading must not silence a suffix diff on the same - name. Such a diff would not be hidden -- an excluded name reports - UNEXPLAINED and exits non-zero -- but it could never be classified - as intended either, leaving an area under active development - permanently unexplainable.""" - rules = [{"issue": "catch-all", "fields": ["given", "suffix", - "nickname", "middle"]}] - never = [{"why": "ascii pairs were already handled in 1.4", - "name_regex": r"\w\s+\([^)]+\)\s+\w", - "fields": ["nickname", "middle"], - "examples": ["John (Jack) Kennedy"]}] - name = "Lon (Jr.) Williams" - # the reading the exclusion names is refused - assert compare.classify(name, {"nickname"}, rules, never) is None - assert compare.classify(name, {"middle"}, rules, never) is None - # a different reading of the same name is still classifiable - assert compare.classify(name, {"suffix"}, rules, never) == "catch-all" - # and a mixed diff is not a subset of the exclusion, so it survives - assert compare.classify( - name, {"nickname", "suffix"}, rules, never) == "catch-all" - - -def test_entry_matches_is_the_question_classify_asks() -> None: - """classify() and the dormancy check must agree on what a rule - would claim. They share this predicate so they cannot drift.""" - rule = {"issue": "x", "name_regex": "Smith", "fields": ["given"]} - assert compare._entry_matches(rule, "John Smith", {"given"}) - # regex misses - assert not compare._entry_matches(rule, "John Jones", {"given"}) - # fields is a SUBSET test, not an intersection - assert not compare._entry_matches(rule, "John Smith", {"given", "family"}) - # a rule with neither key admits everything validate_rules lets exist - assert compare._entry_matches({"issue": "x"}, "anyone", {"suffix"}) - # the ignore-don't-reject contract the docstring rests on: validate_* - # already rejected these at startup, so re-judging them here would put - # the two in a position to disagree - assert compare._entry_matches({"issue": "x", "name_regex": 5}, "anyone", {"suffix"}) - assert compare._entry_matches({"issue": "x", "fields": "given"}, "anyone", {"given"}) - # an exclusion entry narrows on the same two keys and carries no `issue` - assert compare._entry_matches( - {"why": "x", "name_regex": "Smith", "examples": ["John Smith"]}, - "John Smith", {"given"}) - - -def test_validate_rules_accepts_a_declared_dormant_rule() -> None: - """`dormant` is a legal key, so a rule that declares one is not - rejected as a misspelling.""" - compare.validate_rules( - [{"issue": "x", "name_regex": "ZZNOSUCHNAME", "fields": ["given"], - "dormant": "no corpus name"}], - "expected_since_1.4.0.toml") - - -def test_dormant_rules_reports_a_rule_whose_behavior_vanished() -> None: - """The #372 case: a rule matching no diffing name at all. Its fix - was probably reverted, and today the run exits 0 regardless.""" - rules = [{"issue": "fix(a)", "name_regex": "Smith", "fields": ["given"]}, - {"issue": "fix(b)", "name_regex": "Jones", "fields": ["given"]}] - report = compare.dormant_rules( - rules, {"fix(a)"}, [("John Smith", {"given"}, None)]) - assert report.awake == () - assert [d.issue for d in report.undeclared] == ["fix(b)"] - assert report.undeclared[0].kind == "reverted" - - -def test_dormant_rules_names_the_rule_that_shadows_one() -> None: - """A rule can explain nothing because a broader rule written ahead - of it in the same tier claimed every diff it would have claimed. - That is a different diagnosis with a different fix, so it gets - different words.""" - rules = [{"issue": "fix(broad)", "name_regex": "Smith", - "fields": ["given", "family"]}, - {"issue": "fix(narrow)", "name_regex": "John Smith", - "fields": ["given"]}] - report = compare.dormant_rules( - rules, {"fix(broad)"}, [("John Smith", {"given"}, None)]) - assert [d.issue for d in report.undeclared] == ["fix(narrow)"] - assert report.undeclared[0].kind == "shadowed" - assert report.undeclared[0].detail == "fix(broad)" - - -def test_dormant_rules_distinguishes_an_excluded_shape() -> None: - """Third diagnosis: the rule matches a diffing name, but a [[never]] - entry refuses it, so no rule claims it. Reporting that as `reverted` - would send someone hunting for a fix that was never undone.""" - rules = [{"issue": "fix(a)", "name_regex": "Smith", "fields": ["given"]}] - never = [{"why": "protected", "name_regex": "Smith"}] - report = compare.dormant_rules( - rules, set(), [("John Smith", {"given"}, None)], never) - assert [d.issue for d in report.undeclared] == ["fix(a)"] - assert report.undeclared[0].kind == "excluded" - - -def test_dormant_rules_is_silent_about_a_declared_rule() -> None: - rules = [{"issue": "fix(a)", "name_regex": "Smith", "fields": ["given"], - "dormant": "no corpus name reaches it"}] - report = compare.dormant_rules(rules, set(), []) - assert report.undeclared == () and report.awake == () - - -def test_dormant_rules_reports_a_declared_rule_that_woke_up() -> None: - """The other direction. A `dormant` declaration that stopped being - true is a false statement in the ledger, and the roster pattern in - this tree checks both directions or it checks nothing.""" - rules = [{"issue": "fix(a)", "fields": ["given"], "dormant": "was idle"}] - report = compare.dormant_rules( - rules, {"fix(a)"}, [("John Smith", {"given"}, None)]) - assert report.awake == ("fix(a)",) - assert report.undeclared == () - - -def test_dormant_rules_names_the_shadower_that_does_the_shadowing() -> None: - """Which rule to go and look at. Picking the alphabetically first - claimant would send someone to the rule that took one name while - another took the rest.""" - rules = [{"issue": "fix(a)", "name_regex": "Alpha", "fields": ["given"]}, - {"issue": "fix(z)", "name_regex": "Zeta", "fields": ["given"]}, - {"issue": "fix(idle)", "name_regex": "Alpha|Zeta", - "fields": ["given"]}] - report = compare.dormant_rules( - rules, {"fix(a)", "fix(z)"}, - [("Alpha One", {"given"}, None), ("Zeta One", {"given"}, None), - ("Zeta Two", {"given"}, None), ("Zeta Three", {"given"}, None)]) - assert [d.issue for d in report.undeclared] == ["fix(idle)"] - assert report.undeclared[0].detail == "fix(z)" - - -def test_dormant_rules_sorts_before_diagnosing() -> None: - """classify() must be asked in the order main() asks it. These rules - are written broad-first, but _sorted_rules puts the name_regex rule - ahead of the fields-only one, so 'specific' is what actually claims - 'John Smith' -- which makes 'broad' the dormant one, shadowed by it. - - Without the internal sort this returns the diagnosis backwards, - naming 'specific' as dormant and shadowed by 'broad'. Nothing else - pins that line: main() always pre-sorts before calling this. - """ - rules = [{"issue": "broad", "fields": ["given"]}, - {"issue": "specific", "name_regex": "Smith", - "fields": ["given"]}] - report = compare.dormant_rules( - rules, {"specific"}, [("John Smith", {"given"}, None)]) - assert [d.issue for d in report.undeclared] == ["broad"] - assert report.undeclared[0].detail == "specific" - - -def test_over_declared_rules_flags_a_role_nothing_explains() -> None: - """The #452 shape: `fields` wider than every diff beneath it. - - classify() admits a rule when the diff is a SUBSET of `fields`, so - the excess is not inert -- it lets the rule keep claiming a name - whose diff shrank out of the declared role, which is what #410 - found on fix(#424) with no run saying so. - """ - rules = [{"issue": "fix(x) wide", "name_regex": "Smith", - "fields": ["given", "family", "suffix"]}] - found = compare.over_declared_rules( - rules, {"fix(x) wide": {"family", "suffix"}}) - assert len(found) == 1 - assert found[0].issue == "fix(x) wide" - assert found[0].unused == ("given",) - assert found[0].observed == ("family", "suffix") - - -def test_over_declared_rules_accepts_exactly_exercised_fields() -> None: - """Equality, not superset: `fields` may name every role its diffs - move and no more.""" - rules = [{"issue": "fix(x) exact", "name_regex": "Smith", - "fields": ["family", "suffix"]}] - assert compare.over_declared_rules( - rules, {"fix(x) exact": {"family", "suffix"}}) == () - - -def test_over_declared_rules_skips_a_dormant_rule() -> None: - """A `dormant` rule is this check's business never, even when it - HAS explained something. - - The input matters, and the obvious one is vacuous: passing an empty - `roles_by_issue` is caught by the `if not moved` guard whether or - not the dormant clause exists, so the test would pass on its - deletion -- mutation-tested, and the reason this row is written the - way it is (#452 review). The separating input is a dormant rule - that DID explain a diff, which is also the only interesting one: it - is exactly the state dormant_rules reports as NO LONGER DORMANT. - That is one defect with one remedy -- remove the `dormant` key -- - and reporting it here as well would demand a second, contradictory - one: narrow `fields` on a rule whose real problem is that its - dormancy claim went false. - """ - rules = [{"issue": "fix(x) idle", "name_regex": "Smith", - "fields": ["given", "family"], "dormant": "no corpus name"}] - assert compare.over_declared_rules( - rules, {"fix(x) idle": {"family"}}) == () - - -def test_over_declared_rules_skips_a_rule_with_no_fields() -> None: - """A regex-only rule declares no roles, so it has nothing to - over-declare. (`fields` with no name_regex cannot exist since - #451.)""" - rules = [{"issue": "fix(x) regex only", "name_regex": "Smith"}] - assert compare.over_declared_rules( - rules, {"fix(x) regex only": {"family"}}) == () - - -def test_over_declared_rules_skips_a_rule_that_explained_nothing() -> None: - """Explaining nothing is dormancy's finding, not this one. Reporting - it here too would make one defect fail twice with two different - remedies.""" - rules = [{"issue": "fix(x) silent", "name_regex": "Smith", - "fields": ["given", "family"]}] - assert compare.over_declared_rules(rules, {}) == () - - -def test_validate_rules_rejects_two_rules_sharing_an_issue() -> None: - """The dormancy check identifies a rule by its `issue`, so a - duplicate lets one rule hide behind the other -- it can explain - nothing and never be reported. Measured before this check existed: - dormant_rules returned undeclared=() awake=() for a rule that - genuinely explained nothing.""" - with pytest.raises(SystemExit, match="sharing the issue"): - compare.validate_rules( - [{"issue": "dup", "name_regex": "Smith", "fields": ["given"]}, - {"issue": "dup", "name_regex": "Jones", "fields": ["given"]}], - "expected_since_1.4.0.toml") - - -def test_a_recorded_shape_matching_the_run_is_no_mismatch() -> None: - diffing = [("Smith, Jr.", {"family", "suffix"}, None)] - assert compare.recorded_diff_mismatches( - {"Smith, Jr.": ("family", "suffix")}, diffing, - {"Smith, Jr."}) == [] - - # ... and the recorded side is a hand-written tuple, so both sides - # are sorted before they are compared: a row spelled in any other - # order is the same shape, not a mismatch. - assert compare.recorded_diff_mismatches( - {"Smith, Jr.": ("suffix", "family")}, diffing, - {"Smith, Jr."}) == [] - - # ... and so is the measured side, which arrives as a SET: its - # iteration order is this interpreter's hash seed, so an unsorted - # reading would agree with the roster on some runs and report a - # mismatch on others. All seven roles rather than the two above - # because an unsorted reading cannot be refuted outright -- a set - # CAN iterate alphabetically, and the two-role set above does under - # 85 of PYTHONHASHSEED 0..199. The seven-role set THE ASSERTION - # BELOW BUILDS does under none of them, so the size is what makes - # this row an instrument rather than a coin flip. Measured - # 2026-09-03; recompute either count with - # for s in $(seq 0 199); do PYTHONHASHSEED=$s python3 -c \ - # 'S={"family","suffix"};print(list(S)==sorted(S))' - # done | grep -c True - # substituting, for the seven-role count, - # S=set(("title","given","middle","family","suffix", - # "nickname","maiden")) - # -- compare.V2_FIELDS in ITS OWN order, which is the set the - # assertion passes. INSERTION ORDER is part of the measurement and - # not decoration: a bare set literal spelled alphabetically instead - # iterates sorted under seed 56, so a recipe retyped that way reads - # 1 of 200 where the assertion's own set reads 0. - assert compare.recorded_diff_mismatches( - {"Smith, Jr.": tuple(sorted(compare.V2_FIELDS))}, - [("Smith, Jr.", set(compare.V2_FIELDS), None)], - {"Smith, Jr."}) == [] - - -def test_a_recorded_shape_the_run_contradicts_is_reported() -> None: - """The check the roster could not do for itself. - - _CROSS_RULE_WINNERS feeds its recorded shape into classify() and - asserts the winner, so a guessed shape agrees with itself forever -- - which is how '田中さん II' sat recorded as {given, suffix} when the - measured diff is {family, given, suffix}. - """ - diffing = [("田中さん II", {"family", "given", "suffix"}, None)] - got = compare.recorded_diff_mismatches( - {"田中さん II": ("given", "suffix")}, diffing, {"田中さん II"}) - assert [(m.name, m.recorded, m.measured) for m in got] == [ - ("田中さん II", ("given", "suffix"), ("family", "given", "suffix"))] - - -def test_every_contradicted_row_is_returned_not_just_one() -> None: - """`out.append`, not `out[:] = [...]`. - - Every other fixture for this function carries one recorded row, and - over a one-row roster an assignment and an append are the same - function. The case that separates them is the one the report's - layout is argued from -- a parser move landing on many rows at once - -- so it is pinned at both levels; - test_the_shape_report_names_every_contradicted_row is main()'s. - """ - diffing = [("Smith, Jr.", {"family"}, None), - ("Kim, Jr.", {"family"}, None)] - got = compare.recorded_diff_mismatches( - {"Smith, Jr.": ("family", "suffix"), "Kim, Jr.": ("given",)}, - diffing, {"Smith, Jr.", "Kim, Jr."}) - assert [(m.name, m.recorded, m.measured) for m in got] == [ - ("Smith, Jr.", ("family", "suffix"), ("family",)), - ("Kim, Jr.", ("given",), ("family",))] - - -def test_a_recorded_name_that_stops_diffing_is_reported() -> None: - """Recorded means it diffed. If it no longer does, that is a finding - about the parser, not a row to delete.""" - got = compare.recorded_diff_mismatches( - {"Smith, Jr.": ("family", "suffix")}, [], {"Smith, Jr."}) - assert [(m.name, m.measured) for m in got] == [("Smith, Jr.", None)] - - -def test_a_recorded_name_outside_this_run_is_skipped() -> None: - """--corpus narrows the name set, so absence is a fact about the run. - - Only the SUBSET half of the asymmetry the vacancy check's caller - reads (#382): that caller REFUSES under a full run, and this - function is silent in both cases. Refusing a recorded name no - corpus holds any more is the caller's half, and it asks - `set(recorded) - set(corpus_names)` -- the PRE-skip list, since the - baseline-minimum skip takes a name out of the RUN and out of no - file. Nothing here does either; - test_a_full_run_refuses_a_recorded_name_no_corpus_holds pins the - caller's side of it, and - test_a_name_this_baseline_skipped_is_not_a_name_the_corpus_lost - pins that it reads the other list. - """ - assert compare.recorded_diff_mismatches( - {"Smith, Jr.": ("family", "suffix")}, [], set()) == [] - - -def test_only_the_order_none_comparison_is_read() -> None: - """The roster calls classify() with no order, so the shape it records - is the default-order comparison's. An order-bearing comparison of the - same string is a different question. - - Two fixtures, pinning the two ways a reading of every row survives, - since either one alone leaves the other's mutant alive. Both are - reachable: `diffing` is appended in corpus file order, so a name's - two entries can arrive either way round. Neither is a measurement -- - the shapes here are illustrative, chosen to separate the readings, - where _CROSS_RULE_WINNERS records 'Kim, Jr.' on four roles. - - FIRST: the order-bearing row written LAST, which kills a reading - where the last row wins. SECOND: the order-bearing row ALONE, which - kills a reading where the first row wins -- there the shape is - recorded, the name is compared, and no order-None comparison of it - exists, so the honest answer is `measured` None. - """ - diffing = [("Kim, Jr.", {"family", "suffix"}, None), - ("Kim, Jr.", {"family"}, "FAMILY_FIRST")] - assert compare.recorded_diff_mismatches( - {"Kim, Jr.": ("family", "suffix")}, diffing, {"Kim, Jr."}) == [] - - got = compare.recorded_diff_mismatches( - {"Kim, Jr.": ("family",)}, - [("Kim, Jr.", {"family"}, "FAMILY_FIRST")], {"Kim, Jr."}) - assert [(m.name, m.measured) for m in got] == [("Kim, Jr.", None)] - - -harvester = load_tool("build_issues_corpus") - - -def test_a_backticked_name_is_harvested() -> None: - """This tracker writes names in backticks, because issues are - markdown. Matching only quoted names left the corpus whose whole - purpose is "what users reported" blind to how this project reports - (#413). - """ - assert harvester._harvest( - "the tussenvoegsel in `Beethoven, Ludwig van` is filed behind " - "the given name") == {"Beethoven, Ludwig van"} - - -def test_a_call_inside_backticks_yields_the_name_not_the_call() -> None: - """The reason backticks are a SECOND PASS rather than a third - alternative in one pattern. - - A reporter writes the failing input as a call inside a code span. - Regex scanning takes the leftmost match, so a backtick alternative - starts one character before the HumanName branch can and swallows - the whole span -- harvesting the call and losing the name inside - it. This is the bug the two-pass structure exists to prevent, and - it is reachable from a single string. - """ - got = harvester._harvest('reported as `HumanName("John Q Doe")`') - assert got == {"John Q Doe"} - - # ... and a call anywhere in the span, not only at its start - assert not any("HumanName(" in n for n in - harvester._harvest('see `Foo HumanName("Jane Roe")`')) - - -def test_a_sentence_is_not_a_name() -> None: - """Prose is quoted and backticked as often as names are, and a - capitalized phrase is well-formed enough that the character screen - cannot see it. - """ - for text in ("`What this gate does not cover` is the section", - "the note said `Takes everything` about it", - "`C is CONSTANTS` in that example", - "wrote `NO Latin twins on purpose` there", - "raised `TypeError: a bytes-like object is required`"): - assert harvester._harvest(text) == set(), text - - -def test_the_sentence_screen_keeps_its_hands_off_names() -> None: - """The screen matches a function word only where it is written - LOWERCASE, which is what keeps it off names: a name capitalizes its - words and a sentence's function words do not. - - Every name here contains a word that is in the stop list, and every - one is a real naming convention -- Burmese, Norwegian, Estonian, - Cantonese, Luhya, Dutch, Lakota. Without the case test the screen - drops all of them, silently, from the one corpus meant to catch - what users report. - """ - for name in ("U Than Shwe", "Sven Are Olsen", "Kertu Must", - "Chan On Kei", "Wafula Were", "John Does", - "Marie Takes War Bonnet", "Rob And Beth Edmunds", - "Duke of Wellington", "Anh Do", "Will De Groot"): - assert harvester._harvest(f"reported `{name}` today") == {name}, name - - -@pytest.mark.parametrize(("filename", "adjudicated"), [ - # A name held only by an unmeasured corpus is classified but not - # adjudicated: CLDR's fictional renderings would otherwise demand - # pins (2026-10-12: three at 1.4.0) for rule pairs nobody wrote - # them to decide. The control is the same name in any other file. - ("corpus_cldr.jsonl", False), - ("corpus_x.jsonl", True), +@pytest.mark.parametrize("name,latin", [ + ("John Smith", True), + ("Anna Müller", True), + ("Jane Smith (née Jones)", True), + ("田中さん", False), + ("김민준", False), + ("Хосе Сантос", False), + ("威廉·莎士比亚", False), ]) -def test_an_unmeasured_corpus_name_is_not_adjudicated( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, - filename: str, adjudicated: bool) -> None: - monkeypatch.setitem(compare._RECORDED_DIFFS, "expected_since_2.0.0.toml", {}) - monkeypatch.setitem(compare._WATCHED_DIFFS, "expected_since_2.0.0.toml", {}) - ledger = ('[[change]]\nissue = "fix(a) the winner"\n' - 'name_regex = "Smith"\nfields = ["family"]\n\n' - '[[change]]\nissue = "fix(b) the loser"\n' - 'name_regex = "Smith"\nfields = ["family"]\n' - 'dormant = "shadowed by fix(a) on the only name it reaches"\n') - code, out = _run_main_over( - tmp_path, monkeypatch, [(filename, "radar", ["John Smith"])], - [_default_order_diff("John Smith", "SMYTHE")], ledger) - assert ("UNPINNED CONTEST" in out) is adjudicated, out - assert (code != 0) is adjudicated, out - - -def test_the_guards_and_the_gate_leave_out_the_same_corpora() -> None: - """The ledger guards' population (_CORPUS_NAMES) and the gate's - contest adjudication leave out one set of files, held in two places - because tests/ cannot import tools/ at module scope. Two copies that - drift would measure a rule's reach over one population and - adjudicate its contests over another.""" - assert _UNMEASURED_CORPORA == compare._UNMEASURED_CORPORA +def test_latin_only_partition(name: str, latin: bool) -> None: + assert compare._is_latin_only(name) is latin diff --git a/tests/v2/test_doc_citations.py b/tests/v2/test_doc_citations.py index d18fd05b..39da99cb 100644 --- a/tests/v2/test_doc_citations.py +++ b/tests/v2/test_doc_citations.py @@ -1,7 +1,7 @@ """Referential integrity for docs/design/ citations. -Checks: every reference to a design doc, in code and in the -differential ledgers' comments, names a rule, section, mechanism or +Checks: every reference to a design doc in code names a rule, +section, mechanism or decisions entry that exists; every quote a citation carries -- in any of the shapes _LEAD_RE accepts, chained quotes included -- is a normalized verbatim excerpt of what it cites ("[...]" eliding, in @@ -60,11 +60,6 @@ # verbatim, non-empty, and in order. _ELISION = "[...]" DEC_DOC = REPO / "docs" / "design" / "decisions.md" -# Ledger comments cite rules under the same discipline as code -# (AGENTS.md, "Release-log claims"); they are swept with the code but -# are not "citing modules" -- rules.md's implemented: names parser -# modules, never ledgers. -_LEDGERS = REPO / "tools" / "differential" class Citation(NamedTuple): @@ -173,7 +168,7 @@ def _swept_files() -> list[Path]: # this module's own comments show citation shapes, citing nothing files = [p for d in SWEEP_DIRS for p in sorted((REPO / d).rglob("*.py")) if p != Path(__file__).resolve()] - return files + sorted(_LEDGERS.glob("*.toml")) + return files def _quotes(text: str, at: int) -> tuple[str, ...]: @@ -256,18 +251,19 @@ def test_the_sweep_reaches_every_citation_shape() -> None: # grammar, an elision, the decisions routing -- and one matching # nothing would let the excerpt test pass on what is left. cites = _citations() - ledgers = {c.path.name for c in cites if c.excerpts - and c.path.suffix == ".toml"} - assert {"expected_since_1.4.0.toml", - "expected_since_2.3.0.toml"} <= ledgers leads = [c.lead for c in cites if c.excerpts and not c.colon] - assert any(ld.startswith("'s") for ld in leads) + # The possessive lead (`rules.md#X's Accepted clause ("...")`), the + # dash lead (`rules.md#X -- "..."`) and the "[...]" elision had + # their only witnesses in the differential ledgers' comments, + # deleted with them (#664). The grammar still reads both leads, and + # test_an_elided_excerpt_must_keep_its_order holds the elision. + for lead in ("'s Accepted clause (", " -- "): + m = _LEAD_RE.match(lead + '"') + assert m is not None and m.group() == lead assert any("(" in ld for ld in leads) - assert any(ld.strip().startswith("--") for ld in leads) assert any(re.fullmatch(r"(?:\s+[A-Za-z]+)+\s*", ld) for ld in leads) assert any(c.excerpts and c.doc == "decisions" for c in cites) assert any(len(c.excerpts) > 1 for c in cites) - assert any(_ELISION in e for c in cites for e in c.excerpts) def test_an_elided_excerpt_must_keep_its_order() -> None: @@ -385,7 +381,7 @@ def test_no_legacy_citations() -> None: problems = [] for d in SWEEP_DIRS: for path in sorted((REPO / d).rglob("*")): - if path.suffix not in (".py", ".toml"): + if path.suffix != ".py": continue if path.name in self_files: continue diff --git a/tests/v2/test_ledger_guards.py b/tests/v2/test_ledger_guards.py deleted file mode 100644 index 834a124e..00000000 --- a/tests/v2/test_ledger_guards.py +++ /dev/null @@ -1,9001 +0,0 @@ -"""Pins the differential harness's hand copies to their sources, and -bounds what each ledger rule may claim. - -tools/differential/expected_since_*.toml classifies a v1-vs-v2 diff as -an INTENDED change. A rule matching too widely absorbs a real -regression and the run exits 0, so these files carry hand copies of -nameparser constants -- script ranges, honorific and maiden vocabulary --- that a TOML file could not import if it wanted to. Nothing else -checks them. - -Most of what is here reads a rule's SYNTAX -- which spans a character -class declares, which members an alternation offers. Those are exact -where they apply and blind where they do not: five review rounds each -found a widening spelled just outside whichever one had been added -last (#333, #350). The rest read the CORPORA, asking what a rule -actually claims of the names the harness will ever be asked about; -those cannot be dodged by notation, because they do not read notation --- though a recorded claim can be re-recorded, which is why the syntax -guards stay. - -Three tests are neither kind. They keep this file's own inputs honest: -the ledger glob, the roster-versus-filesystem staleness check, and the -pin holding a GENERATED corpus equal to what its generator would -write. - -Split from test_regex_sync.py, which shares none of this (#352). -""" -import hashlib -import itertools -import json -import re -from pathlib import Path -from types import ModuleType -from typing import NamedTuple - -import pytest - -from nameparser import DEFAULT_NICKNAME_DELIMITERS, _policy -from nameparser._policy import Script -# The parser's own fold, imported rather than reimplemented: a -# hand-written one here stripped commas, parens, brackets and quotes, -# five character classes neither the lexicon's fold nor config's -# assert_normalized touches -- looser in the dangerous direction, and a -# hand copy of a constant with a source of truth, inside the module -# written to forbid exactly that. -from nameparser._lexicon import FULL_STOPS, _PHRASE_FIELDS, _normalize -from nameparser.config.bound_given_names import BOUND_GIVEN_NAMES -from nameparser.config.conjunctions import CONJUNCTIONS -from nameparser.config.maiden_markers import MAIDEN_MARKERS -from nameparser.config.particles import PARTICLES -from nameparser.config.titles import GIVEN_NAME_TITLES, TITLES -from nameparser.config.suffixes import ( - GLUED_HONORIFICS, SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, - SUFFIX_WORDS) - -from ._differential_fixtures import ( - _CORPUS_NAMES, _LEDGERS, _TOOLS, _UNCLASSIFIED_NAMES, _claimed, - _entry_name, _exclusions, _rules, _unclassified_names, load_tool) - - -# Sanctioned divergences between the differential rules' character -# classes and _SCRIPT_RANGES: a span a rule must cover although the -# table does not classify it. Empty since #594. Its one member was the -# halfwidth middle dot U+FF65, which separates tokens without having -# been classified while halfwidth kana stayed out of the table; #594 -# classified the whole halfwidth kana block, U+FF65 with it, and it -# moved into the table, as the membership guard below requires. -# U+00B7 is deliberately NOT here -- its flank guard means every name -# it can change matches through a classified flanking character -# already. Single-sourced: read by the span sweep below, and by the -# membership guard that keeps "sanctioned" meaning something -- an -# extra that becomes classified belongs in the table, not in this list. -_SANCTIONED_EXTRAS: frozenset[tuple[int, int]] = frozenset() - - -@pytest.mark.parametrize("field", _PHRASE_FIELDS) -def test_a_shipped_phrase_entry_is_stored_as_written(field: str) -> None: - """The half of a phrase entry's storage rule config's own - assert_normalized cannot see. - - It checks the single-spaced lowercase form; the per-WORD period - strip is _lexicon._title_key's, and a data module asserting with - the parser's fold would make a constant's hygiene depend on the - parser. The question that actually matters is asked here instead, - and it is stronger than the fold: does Lexicon store the shipped - constant UNCHANGED? An entry written 'z. domu' passes import-time - hygiene and is silently rewritten to 'z domu' -- inert as a lookup - key nobody wrote, and invisible everywhere until now. - - Over _PHRASE_FIELDS rather than over maiden_markers, so the - given_name_titles half is covered by the same assertion and a third - phrase field arrives already pinned. Nothing else in the suite pins - this equality for any field. - """ - from nameparser import Lexicon - shipped = {"maiden_markers": MAIDEN_MARKERS, - "given_name_titles": GIVEN_NAME_TITLES}[field] - assert getattr(Lexicon.default(), field) == frozenset(shipped), ( - f"{field}: Lexicon rewrote the shipped constant, so the entries " - f"it stores are not the ones the data module wrote -- " - f"{sorted(frozenset(shipped) - getattr(Lexicon.default(), field))} " - f"were folded away or changed") - - -def test_the_corpus_population_is_not_degenerate() -> None: - """The floors themselves live in compare.py and are asserted by - tests/v2/test_differential.py, which already checks every shipped - corpus clears one and that every floor names a file that exists. - Restating that here was a second, independently-drifting copy of a - guarantee the harness owns. - - What is local to THIS module is the population the guards actually - measure, which is not the same thing: _CORPUS_NAMES is - deduplicated, so a corpus rewritten as 486 copies of one line - clears its floor while the set collapses. And guard A is inert if - nothing in that set is unclassified. - """ - assert len(_CORPUS_NAMES) > 700, ( - f"_CORPUS_NAMES holds {len(_CORPUS_NAMES)} distinct names; the " - f"corpora clear their floors in compare.py but deduplicate to far " - f"fewer than usual, so every guard here is measuring a smaller " - f"population than it appears to") - assert _unclassified_names(), ( - "no corpus name lacks a classified codepoint, so the span rules' " - "unclassified-reach check has nothing to test against") - - -def test_ledger_glob_is_not_empty() -> None: - """A parametrize over an empty list generates a single silent SKIP - rather than a failure -- the exact silence this module exists to - break. The swept pins cannot assert this for themselves, since a - test that is never generated cannot complain, so it lives here.""" - assert _LEDGERS, f"no expected_since_*.toml under {_TOOLS}" - - -def test_span_bearing_roster_names_exactly_the_ledgers_on_disk() -> None: - """The roster's per-ledger sweep asserts every ledger has an entry, - but it runs over _LEDGERS, so it can never visit an entry naming a - file that is gone. A deleted or renamed ledger would leave its key - behind indefinitely -- the same staleness the roster's tag-level - check exists to prevent, one level up.""" - assert set(_SPAN_BEARING_RULES) == {ledger.name for ledger in _LEDGERS}, ( - f"_SPAN_BEARING_RULES names ledgers that do not exist: " - f"{sorted(set(_SPAN_BEARING_RULES) - {L.name for L in _LEDGERS})}; " - f"and is missing: " - f"{sorted({L.name for L in _LEDGERS} - set(_SPAN_BEARING_RULES))}") - - -_SPAN = re.compile(r"\\u([0-9A-Fa-f]{4})-\\u([0-9A-Fa-f]{4})") - - -def _declared_spans(name_regex: str) -> set[tuple[int, int]]: - """The \\uXXXX-\\uXXXX span pairs a rule's character class declares.""" - return {(int(lo, 16), int(hi, 16)) - for lo, hi in _SPAN.findall(name_regex)} - - -def _unrecognized_class_content(name_regex: str) -> list[str]: - """Whatever a span-declaring character class holds BESIDES spans. - - _declared_spans reads one notation and is blind to every other, so - set equality against the table only pins what is written as an - escaped span. Anything appended in another notation -- a literal - range, a bare character, a leading "^" negating the whole class -- - is invisible to it and rides along unchecked. That is not - hypothetical: the ledgers' own convention mixes both spellings (see - the interpunct note in expected_since_2.0.0.toml), and their - non-span classes are written literally. - - The consequence is worst in the widening direction the equality is - supposed to cover. Appending "a-z" to a pinned CJK class passes both - that equality and compare.validate_rules' sentinel probe, and lets - the rule claim every Latin diff in the corpus as intended -- exactly - the regression-absorbing failure the harness exists to prevent. - - So: a class that declares any span must declare NOTHING else. - Classes carrying no spans (the delimiter sets) are a different - decision surface and are out of scope here -- as is widening spelled - OUTSIDE the brackets, which _no_top_level_alternation covers. - - Two known false positives, both deliberate: a single non-range - escape and a trailing literal "-" are legal regex and would be - rejected. Write them as a one-codepoint span instead. A class - metacharacter like \\s has no span spelling at all, so a rule - genuinely needing one has to move it out of the span-bearing class. - - The bracket scan is a simple findall, not a regex parser: an escaped - "\\]" inside a class truncates the body early and a nested "[" reads - as content. Both surface as unrecognized content rather than as - silence, which is the safe direction, and no ledger rule uses either. - """ - return [rest - for body in re.findall(r"\[([^\]]*)\]", name_regex) - if _SPAN.search(body) - for rest in [_SPAN.sub("", body)] - if rest] - - -def _top_level_alternation(name_regex: str) -> bool: - """Whether the rule has a "|" at nesting depth 0. - - The sibling of the hole above, three characters to the right of the - "]". Appending "|[A-Za-z]" to a span-bearing rule widens it exactly - as appending "a-z" inside the class would, and passes BOTH layers - that are supposed to stop that: the span equality sees an unchanged - class, and compare.validate_rules' sentinel probe clears it because - "Хосе Сантос" fails to match, breaking the matches-everything - conjunction. A CJK-scoped rule would then claim a Latin name's diff - as intended. - - No ledger rule has one today, and a rule that genuinely needs an - alternation can wrap it in "(?:...)", so requiring depth-0 purity - costs nothing and closes the hatch. - """ - depth = in_class = 0 - i = 0 - while i < len(name_regex): - char = name_regex[i] - if char == "\\": - i += 2 - continue - if in_class: - in_class = char != "]" - elif char == "[": - in_class = 1 - elif char == "(": - depth += 1 - elif char == ")": - depth -= 1 - elif char == "|" and depth == 0: - return True - i += 1 - return False - - -#: Codepoints the script table classifies that a ledger rule may still -#: spell literally inside a character class. The nakaguro separators -#: sit in the katakana block by Unicode block assignment while -#: functioning as punctuation, which is exactly why the delimiter rules -#: write them as themselves alongside the corner brackets. -_LITERAL_IN_CLASS_OK = frozenset("・・") - -_SPAN_TOKEN = re.compile(r"\\u[0-9A-Fa-f]{4}") - - -def _literally_spelled_script_chars(name_regex: str) -> list[str]: - """Classified codepoints a class spells as themselves, not as spans. - - This is the escape hatch every masking attack on the roster below - actually uses. Respell a rule's class in literal characters and the - regex means the same thing while the rule vanishes from discovery -- - its hand copy is then unpinned, and _SPAN_BEARING_RULES cannot tell - a rule that LEFT from a rule that was never there, because a set of - names cannot see that two different rules answer to one name (the - 1.4 ledger already has two rules tagged feat(#269)). - - Naming rules could never fix that on its own. Closing the hatch can: - a classified codepoint written literally in a class is refused, so a - class covering script content has to stay in the notation discovery - reads. - """ - return sorted({char - for body in re.findall(r"\[([^\]]*)\]", name_regex) - for char in _SPAN_TOKEN.sub("", body) - if char not in _LITERAL_IN_CLASS_OK - and _policy._script_matcher(*_policy._SCRIPT_RANGES)(char)}) - - -def _expected_bmp_spans() -> set[tuple[int, int]]: - """What a full CJK character class in the toml must declare: the - table's BMP spans plus the sanctioned extras. - - Han's astral block is the single table entry out of scope, on both - sides: the ledger rules omit it deliberately because no corpus name - reaches it -- see the comment on the canonical rule in - expected_since_1.4.0.toml, which is the only place that reasoning is - written down -- so the comparisons run over the BMP spans only - rather than failing forever on a difference everyone agreed to. - """ - return {span - for spans in _policy._SCRIPT_RANGES.values() - for span in spans - if span[1] <= 0xFFFF} | set(_SANCTIONED_EXTRAS) - - -def test_script_ranges_membership_is_decided() -> None: - """The two guards that belong to the script TABLE rather than to any - one ledger's copy of it. - - Every table entry is in scope for the differential rules. The - canonical rule covered HAN and HANGUL alone while the kana members - existed only for classification, but #272 gave HIRAGANA a default - order entry and made the kana blocks part of the same - first/middle/last diff shape, so scoping by issue no longer draws a - real line. Comparing against the whole table is the stronger - promise: a script added to _SCRIPT_RANGES for ANY reason fails here - until someone decides, in writing, whether the rules should cover - it. - - The second assert is what makes _SANCTIONED_EXTRAS mean something. - That set is the ledgers' licence to be WIDER than the table -- see - its definition above for why U+FF65 left it and U+00B7 was never in it -- - and a licence nobody audits is just a hole. An extra that becomes - classified belongs in the table, not in the exception list, and - fails here until it moves. - - There is deliberately no canonical-rule selector here any more. It - picked rules by the literal '#271'/'#272' substrings and asserted - uniqueness, which #332 broke: expected_since_2.0.0.toml has two such - rules. Its equality check was in any case fully subsumed by - test_every_span_bearing_rule_matches_the_script_ranges, since the - canonical rule is itself span-bearing. Splitting the two guarantees - is the point -- the sweep owns "every hand copy equals the table", - this test owns "the table did not change shape without a decision" - -- so a selector break can no longer take the decision gate out as - collateral. Rule authors are correspondingly free to put #271 or - #272 in a compound slug. - """ - assert set(_policy._SCRIPT_RANGES) == { - Script.HAN, Script.HANGUL, Script.HIRAGANA, Script.KATAKANA}, ( - "a Script joined _SCRIPT_RANGES: decide whether the differential " - "rules in tools/differential/expected_since_*.toml should cover " - "it, then update this assertion") - for xlo, xhi in _SANCTIONED_EXTRAS: - assert not any(lo <= xhi and xlo <= hi - for spans in _policy._SCRIPT_RANGES.values() - for lo, hi in spans), ( - f"U+{xlo:04X}-U+{xhi:04X} is classified now; drop it " - "from _SANCTIONED_EXTRAS") - - -#: Which rules each ledger is known to carry a script-span copy in, -#: named by the leading fix(...)/feat(...) tag of their `issue`. -#: -#: Declared rather than counted. A count is identity-free, so one copy -#: could leave discovery -- rewritten as literal characters, say -- while -#: an unrelated span-bearing rule was added, and the total would hold -#: steady while a hand copy went unpinned (measured). Naming them also -#: buys the staleness direction the count never had, and that this -#: module's other two rosters already have: a tag here that matches no -#: rule fails, so a renamed or deleted rule cannot leave its entry -#: behind. -#: -#: Membership is the forcing function: a new baseline's ledger fails as -#: unrecorded until someone writes its rules down. An empty set is fine -#: and correct for a release that changed nothing CJK. -_SPAN_BEARING_RULES: dict[str, frozenset[str]] = { - "expected_since_1.4.0.toml": frozenset({ - "fix(#271/#272/#298)", # the canonical class - "fix(cjk-delimited-nickname)", # the four compounds, whose - "fix(cjk-fullwidth-paren-nickname)", # lookaheads each carry - "fix(cjk-comma-compound)", # their own copy - "fix(cjk-comma-honorific-peel)", - }), - "expected_since_2.0.0.toml": frozenset({ - "fix(#271/#272/#298)", # the canonical class - "fix(#298)", # the 间隔号 lookahead - }), - "expected_since_2.1.0.toml": frozenset(), # 2.2 cycle: no span-bearing rule - "expected_since_2.2.0.toml": frozenset(), # 2.3 cycle: no span-bearing rule - # The 2.4 cycle's rules (#383/#479, 2026-09-13) are literal - # names or alternations of names, and copy no script range. - "expected_since_2.3.0.toml": frozenset(), - "expected_since_2.4.0.toml": frozenset(), # open cycle, no rules yet -} - -#: The leading `fix(...)`/`feat(...)` tag of a rule's `issue`, which is -#: what _SPAN_BEARING_RULES names rules by. Unique within each ledger -#: among span-bearing rules (asserted below), and stable across the -#: prose that follows it. -_ISSUE_TAG = re.compile(r"^[a-z]+\([^)]*\)") - - -def _tag(issue: str) -> str: - """The leading tag, required only of rules the roster has to name. - - Nothing obliges a ledger rule to carry a tag in general, and one in - the 1.4 file does not ("ambiguous-surname-acronym data change: - ..."). That is fine while it declares no script span. If such a rule - ever gains one it lands here, and the fix is to give it a tag rather - than to loosen the roster. - """ - match = _ISSUE_TAG.match(issue) - assert match, ( - f"a span-bearing rule's issue must open with a fix(...) or " - f"feat(...) tag so _SPAN_BEARING_RULES can name it; this one " - f"does not: {issue!r}") - return match.group(0) - - -@pytest.mark.parametrize("ledger", _LEDGERS, ids=lambda p: p.name) -def test_every_span_bearing_rule_matches_the_script_ranges( - ledger: Path) -> None: - """Auto-discovered pin for every hand copy of the script spans in - every ledger: any rule whose character class declares spans - intersecting _SCRIPT_RANGES must declare the whole expected class - (table BMP spans + sanctioned extras). - - A TOML file cannot import _policy._SCRIPT_RANGES, so these are the - copies with no possible alternative -- and the ones whose divergence - is quietest, because the harness is run by hand rather than in CI. - - Both failure directions matter, which is why this compares sets - rather than checking coverage. A span MISSING from a class turns an - intended change into an UNEXPLAINED diff (a release blocker for the - wrong reason); a span that should not be there silently classifies a - real regression as intended, which is the failure the whole harness - exists to prevent. - - The compound rules' require-a-classified-codepoint lookaheads exist - so their trigger sets alone (delimiters; a comma) cannot claim a - Latin name's regression -- and each such lookahead is a copy nothing - else checks. Discovery, not enrollment, is what subjects a rule to - the equality above: a new compound rule's copy is checked because it - exists, not because an author remembered it. _SPAN_BEARING_RULES - then holds discovery itself to account, since a copy rewritten in a - notation discovery cannot see would otherwise just vanish from the - sweep. Rules whose spans touch OTHER scripts (Cyrillic, say) are out - of scope and skipped by the intersection test. - """ - assert ledger.name in _SPAN_BEARING_RULES, ( - f"{ledger.name} is a new ledger whose span-bearing rules are not " - f"recorded; add it to _SPAN_BEARING_RULES (an empty set is a " - f"legal answer for a release that changed nothing CJK)") - table_spans = _expected_bmp_spans() - tags = [] - for rule in _rules(ledger): - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - declared = _declared_spans(regex) - if not declared & table_spans: - continue - tags.append(_tag(rule["issue"])) - assert declared == table_spans, ( - f"{ledger.name}: {rule['issue']!r} declares " - f"{sorted(declared)}; expected {sorted(table_spans)}") - extra = _unrecognized_class_content(regex) - assert not extra, ( - f"{ledger.name}: {rule['issue']!r} has a span-declaring " - f"character class holding {extra!r} besides its spans. The " - f"span equality above cannot see that, so it would widen the " - f"rule unchecked -- write it as an escaped span or not at all") - assert not _top_level_alternation(regex), ( - f"{ledger.name}: {rule['issue']!r} has a '|' at depth 0, so " - f"the whole rule is an alternation and the pinned class " - f"governs only one branch. Wrap it in '(?:...)'") - # The property the syntactic check above is only a proxy for. - # A rule scoped to classified scripts must not reach a name - # written in none of them -- and unlike a depth test, this does - # not care how the widening is spelled. "(?:CJK|[A-Za-z])" - # hides the pipe at depth 1 where the check above stops - # looking, and its Latin half claims all but a handful of - # _UNCLASSIFIED_NAMES -- the whole population this assertion is - # over, whatever that population's size that day; this sees it. - # Both are kept: the depth test gives the clearer message - # for the naive spelling, and catches a widening toward a - # script the corpora happen not to contain. - unclassified = _UNCLASSIFIED_NAMES.intersection(_claimed(regex)) - assert not unclassified, ( - f"{ledger.name}: {rule['issue']!r} declares the script table's " - f"spans but claims {len(unclassified)} corpus names carrying " - f"no classified codepoint at all, e.g. {sorted(unclassified)[:3]}. A rule scoped " - f"to these scripts cannot explain a diff on those names, so it " - f"would absorb one instead") - # Every rule, not just the discovered ones: this is what stops a - # class being respelled out of discovery in the first place, and it - # has to reach the rules that are NOT currently span-bearing to do - # that job. - for rule in _rules(ledger): - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - literal = _literally_spelled_script_chars(regex) - assert not literal, ( - f"{ledger.name}: {rule['issue']!r} spells the classified " - f"codepoints {literal} literally inside a character class. " - f"Write them as \\uXXXX-\\uXXXX spans, or the rule drops out " - f"of the sweep above while meaning the same thing") - # two rules sharing a tag would collapse into one set member and - # read as a disappearance below, which is a confusing way to learn - # that the naming scheme broke - assert len(tags) == len(set(tags)), ( - f"{ledger.name}: two span-bearing rules share an issue tag " - f"({sorted(tags)}); _SPAN_BEARING_RULES cannot name them apart") - found = set(tags) - assert found == _SPAN_BEARING_RULES[ledger.name], ( - f"{ledger.name}'s span-bearing rules are not the recorded set. " - f"Left discovery (a hand copy is now unpinned): " - f"{sorted(_SPAN_BEARING_RULES[ledger.name] - found)}. " - f"Newly discovered (pinned now, but record it): " - f"{sorted(found - _SPAN_BEARING_RULES[ledger.name])}") - - -#: The delimiter compound's trigger set is its own decision surface, -#: separate from the script spans: these are the characters whose mere -#: presence lets the rule claim a diff. Written once here rather than -#: inline: the check this replaces tested the regex against this set -#: spelled as unicode escapes OR against it spelled as the characters -#: themselves, which in a non-raw literal are the same str -- an `or` -#: whose two operands could never disagree. One spelling, named once. -_NICKNAME_DELIMITERS = "[「」『』・・]" - - -def test_nickname_delimiter_sets_are_deliberate() -> None: - """Swept rather than pinned to one file and one rule. The 1.4 ledger - has exactly one cjk-delimited-nickname rule and the 2.0 ledger has - none, so a per-file `== 1` is already false and a global one holds - only by accident of there being a single rule today. What is - actually invariant is that EVERY such rule carries the sanctioned - trigger set, and that at least one exists somewhere or this is - checking nothing. - - Note the scope this does NOT have: it is a decision surface, not a - sync pin. _NICKNAME_DELIMITERS is a literal because the rule's class - is not Policy.nickname_delimiters and is not meant to be -- it is - the two CJK corner-bracket pairs, plus the nakaguro separators, - which delimit nothing, and minus the nine other pairs the config - ships. Deriving it would mean deciding all of that in code rather - than writing it down. - - So a delimiter pair removed from the config does not fail here; it - fails tests/v2/test_cases.py, on the cjk_white_corner_bracket_ - nickname row (measured -- tests/v2/pipeline/ stays green, which is - why the pointer is worth being exact about).""" - found = [] - for ledger in _LEDGERS: - for rule in _rules(ledger): - if "cjk-delimited-nickname" not in rule["issue"]: - continue - found.append(f"{ledger.name}: {rule['issue']}") - # .get rather than [] on purpose: a rule that dropped its - # name_regex outright should land on the assertion below, - # not raise KeyError out of the sweep - assert _NICKNAME_DELIMITERS in rule.get("name_regex", ""), ( - f"{ledger.name}: the compound rule's delimiter set " - f"changed, or the rule lost its name_regex; decide " - f"deliberately, then update _NICKNAME_DELIMITERS") - assert found, ( - "no cjk-delimited-nickname rule in any ledger; this check is " - "passing vacuously") - - -def test_the_emoji_boundary_rule_copies_the_dividing_ranges() -> None: - """The emoji rule's character class is a HAND COPY of the ranges - the tokenizer actually divides on, and this is what keeps the two - in step. - - The rule promises "an emoji inside a token divides it". Only - _EMOJI_RANGES decides that, so a class wider than those ranges - makes the promise false for every codepoint in the gap -- and the - rule would then claim a {given, family} diff whose cause is - something else entirely. The first draft had exactly that bug: one - \\U0001F300-\\U0001FAFF span, covering U+1F650-U+1F67F and - U+1F700-U+1FAFF, where the parser leaves the token whole. - - Only the ASTRAL half is copied. The BMP half (U+2600-U+26FF, - U+2700-U+27BF) is out because no corpus name reaches it through - the rule's \\S...\\S anchor, and a rule should be no wider than the - diffs it must explain -- so this asserts a SUBSET relationship in - that direction, not equality. What it refuses is the other - direction: a class reaching a codepoint the tokenizer does not - divide on. - """ - from nameparser._pipeline._tokenize import _EMOJI_RANGES - - divides = {c for lo, hi in _EMOJI_RANGES for c in range(lo, hi + 1)} - found = 0 - for ledger in _LEDGERS: - for rule in _rules(ledger): - if "emoji-boundary" not in rule["issue"]: - continue - found += 1 - pattern = rule["name_regex"] - # EVERY codepoint, not just the astral plane. Scanned - # 0x1F000-0x20000 until the #453 review measured what that - # missed: a bare '-' or a \u2B00-\u2BFF span appended to - # the class left this guard GREEN while the rule stood - # ready to explain a {given, family} regression on 22 - # hyphenated corpus names. The docstring promises to - # refuse a class reaching a codepoint the tokenizer does - # not divide on; a scan narrower than that promise is the - # #451 shape one level down. Costs 0.3s. - claimed = {c for c in range(0x20, 0x110000) - if re.search(pattern, f"a{chr(c)}b")} - stray = sorted(claimed - divides) - assert not stray, ( - f"{ledger.name}: {rule['issue']!r} claims " - f"{len(stray)} codepoint(s) the tokenizer does not " - f"divide on, e.g. {[hex(c) for c in stray[:3]]}. Its " - f"prose says an emoji inside a token divides it, which " - f"is false for those -- so a diff with another cause " - f"would classify here as intended. Copy from " - f"_EMOJI_RANGES rather than widening the span.") - assert claimed, ( - f"{ledger.name}: {rule['issue']!r} claims no codepoint " - f"at all; the class or the anchor is broken and the " - f"rule can explain nothing") - assert found, ( - "no emoji-boundary rule in any ledger; this pin is passing " - "vacuously") - - -def test_cjk_corpus_matches_the_case_table() -> None: - """corpus_cjk.jsonl is GENERATED, not curated (#295): every - distinct case-table text bearing a codepoint the script table - classifies -- minus the rows that declare `tolerated` (the - 2026-09-01 demotion; they are the twin test below) -- sorted. - See build_cjk_corpus.py for why the other two corpora cannot - carry these names. The checked-in file must equal what the - generator would write, so a CJK case row added without - regenerating fails HERE instead of silently narrowing the - differential gate back toward the blind spot #295 closed. Same - promise as the toml pin above, aimed at a generated artifact - instead of a hand copy. - """ - module = load_tool("build_cjk_corpus") - checked_in = [json.loads(line) for line in - (_TOOLS / "corpus_cjk.jsonl") - .read_text(encoding="utf-8").splitlines()] - assert checked_in == module.selected_names(), ( - "corpus_cjk.jsonl is stale: regenerate with " - "`uv run python tools/differential/build_cjk_corpus.py`") - - -def test_tolerated_cjk_corpus_matches_the_case_table() -> None: - """The radar half of the same generated projection: the texts - whose case rows declare `tolerated`. Pinned for the same reason - as the contract half one function up -- one command writes both - files, so a row marked without regenerating leaves the demoted - name in NEITHER file and its diffs invisible at every baseline, - which is the failure the radar tier exists to prevent.""" - module = load_tool("build_cjk_corpus") - checked_in = [json.loads(line) for line in - (_TOOLS / "corpus_cjk_tolerated.jsonl") - .read_text(encoding="utf-8").splitlines()] - assert checked_in == module.tolerated_names(), ( - "corpus_cjk_tolerated.jsonl is stale: regenerate with " - "`uv run python tools/differential/build_cjk_corpus.py`") - - -def test_a_text_tolerated_on_one_row_only_is_a_hard_error( - monkeypatch: pytest.MonkeyPatch) -> None: - """The two pins above compare files to a selection; neither can - see the one input that has no right answer. A corpus carries name - STRINGS, so a text on two rows -- a default row and a - policy/locale fork of it, which several CJK texts have -- is one - line in one file, and marking one of those rows and not the other - declares both tiers for it. - - What makes the raise load-bearing rather than tidy is the - consequence with it removed, which the negative control below - measures instead of asserting in prose: the two halves select - `flags == {False}` and `flags == {True}`, so a split text matches - NEITHER and would be dropped from both files -- gone from the - harness, watched at no baseline, and invisible to every guard - here (the pins would agree with the degraded selection, and the - floors bound the loss to a few names). The message must name the - offending text for the same reason: an author who has to go - hunting for which row is split is an author who marks the other - one at random. - """ - from tests.v2.cases import Case - module = load_tool("build_cjk_corpus") - split = [ - Case(id="split_a", text="田中さん, PhD", expect={"family": "田中"}, - tolerated=True), - Case(id="split_b", text="田中さん, PhD", expect={"family": "田中"}), - Case(id="pure", text="김민준", expect={"family": "김"}), - ] - monkeypatch.setattr(module, "CASES", split) - with pytest.raises(SystemExit, match=r"'田中さん, PhD' on \['split_a', " - r"'split_b'\]"): - module._partition() - - # The negative control: the same selections the generator runs, - # with the raise conceptually deleted. The split text is in - # neither half -- which is the dropped-name failure the docstring - # above describes, reproduced rather than asserted from reading. - by_text: dict[str, set[bool]] = {} - for case in split: - by_text.setdefault(case.text, set()).add(case.tolerated) - contract = {t for t, f in by_text.items() if f == {False}} - tolerated = {t for t, f in by_text.items() if f == {True}} - assert "田中さん, PhD" not in contract | tolerated - assert contract | tolerated == {"김민준"} - - -def test_shapes_corpus_matches_the_case_table() -> None: - """corpus_shapes.jsonl is GENERATED from the shape-tagged case - rows -- the same promise test_cjk_corpus_matches_the_case_table - makes above, for the tag predicate instead of the - codepoint one: a row tagged without regenerating fails HERE - instead of silently keeping the contract tier narrower than the - table says it is.""" - module = load_tool("build_shapes_corpus") - checked_in = [json.loads(line) for line in - (_TOOLS / "corpus_shapes.jsonl") - .read_text(encoding="utf-8").splitlines() if line.strip()] - assert checked_in == module.selected(), ( - "corpus_shapes.jsonl is stale: regenerate with " - "`uv run python tools/differential/build_shapes_corpus.py`") - - -def test_case_shape_ids_exist_in_the_inventory() -> None: - """cases.py cannot import tools/, so Case.__post_init__ validates - every shape tag against its own hand copy of the inventory's id - set (cases._SHAPE_IDS). This is the cross-file half, in two parts. - The equality holds the copy itself honest: a shape added to or - removed from shapes.py without updating cases._SHAPE_IDS fails - here, whichever side changed. The subset check catches a - different drift -- a row tagged under a stale _SHAPE_IDS before - this test last ran -- so it stays even though __post_init__ would - refuse the same tag today.""" - from tests.v2 import cases - shapes = load_tool("shapes") - tagged = {c.shape for c in cases.CASES if c.shape is not None} - assert tagged <= set(shapes.SHAPES), ( - f"tests/v2/cases.py tags shape id(s) " - f"{sorted(tagged - set(shapes.SHAPES))} that are not in " - f"tools/differential/shapes.py's SHAPES; add the shape there " - f"or fix the tag in cases.py") - assert cases._SHAPE_IDS == set(shapes.SHAPES), ( - f"cases._SHAPE_IDS {sorted(cases._SHAPE_IDS)} != shapes.py's " - f"{sorted(shapes.SHAPES)}; they are hand copies of one " - f"inventory -- update whichever side is stale") - - -#: Names each rule MUST NOT match, keyed by a substring of its `issue`. -#: -#: A wall, not a change detector, and that is the point. _CORPUS_CLAIMS -#: catches a widening that changes corpus reach or roles -- but a rule -#: whose regex is literal-anchored and claims exactly ONE corpus name -#: can be widened with the count unmoved, because the names it newly -#: reaches are not in the corpora. Such widenings were demonstrated on -#: this file's own rules with the whole suite green: `^mc\s+\S+$` -#: to `^mc` (every leading Mc*), the vd rule to a bare `\bvd\b`, -#: `^sir\s+de\b` without its anchor, and the nakaguro rule to `·.*씨`. -#: Each of those then stood ready to explain exactly what its own -#: comment promises will arrive UNEXPLAINED. The list is the evidence; -#: a count here said six over four items and was the only part anyone -#: could get wrong, since the widenings themselves are named. -#: -#: The probes are taken from the names those comments already argue -#: about, so this roster records an answer someone already wrote in -#: prose. Being a wall rather than a snapshot, it is wrong at recording -#: time too -- a probe that matches when it is added fails immediately -#: rather than being blessed as the new normal. -#: -#: A probe must be a name the rule has no business claiming, NOT merely -#: one that does not move today: rules deliberately claim some static -#: names (fix(#399) claims 'Jane van der Berg nee PhD', fix(#400) -#: claims 'abd Allah'), and those are recorded in the rules' comments -#: instead. -_MUST_NOT_MATCH: dict[str, tuple[str, ...]] = { - # Keyed on the full issue text, not "fix(#380)": since #531 that - # substring matches three rules -- the vd one below, and the `mc` - # and `mc or do` ones that carry the same decision over the other - # two collision words -- and 'Berg, Jan mc' is the vd rule's - # boundary and the others' claim. The same rekeying fix(#360)'s - # pair already records, for the same reason. - "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal": - ("vd Berg, Jan", "Jan vd Berg", "Smith vd", "Berg, Jan mc"), - # #531's five trailing-particle names at 1.4.0. 'Doe, John DO' is - # the spelling the capitals take (decisions.md#S2), so this rule - # must not reach it however the other four are spelled; 'Berg, Jan - # vd' is the vd rule's own name; and the two Mc* probes are the - # widening the comment refuses -- a regex over the trailing word - # rather than the literal list. - "fix(#380) a trailing mc or do after a family comma": - ("Doe, John DO", "Berg, Jan vd", "McDonald, Ronald", - "Mcintyre Smith Jr."), - # The same boundary at 2.0.0/2.1.0, where only 'mc' diffs. - "fix(#380) a trailing mc after a family comma": - ("Doe, John DO", "Berg, Jan vd", "McDonald, Ronald", - "Mcintyre Smith Jr."), - # #531's trailing slot. The probes are the three boundaries the - # rule's own comment argues: a member the WRITING declines - # ('Doe, John Ma', 'DOE, JOHN MA' being one-case and so a mover, - # not a probe), a member with a NAME WORD behind it, which ends - # the run ('Doe, John MA Smith'), the third-comma-part shape #144's - # restriction still owns ('Doe, John V, PhD'), the `do` spelling - # P6 keeps ('Doe, John do'), and the comma-less spelling, which is - # #289's and has read the credential since 2.0 ('John Doe MA'). - "fix(#531) a credential ending the given part": - ("Doe, John Ma", "Doe, John MA Smith", "Doe, John V, PhD", - "Doe, John do", "John Doe MA"), - # The declining half. Its probes are the movers: a rule that - # reached one would claim a ROLE regression as a report-only - # diff, which is the direction `fields = ["_ambiguities"]` alone - # cannot catch. - "fix(#531) a member the writing declines": - ("Doe, John MA", "DOE, JOHN MA", "Doe, John X.Y.Z.", - "Doe, John MA Smith"), - # The `do` carve-out, whose probes ARE the pairing - # decisions.md#S2 records: every other spelling attaches to the - # family and reports as P6's fork, and each is measured - # byte-identical across #531. - "fix(#531) capitals take the do collision": - ("Doe, John do", "Doe, John Do", "DOE, JOHN DO", "doe, john do", - "SMITH, JOHN DO", "Nascimento, Edson Arantes do", - "Doe, John van DO"), - # The caseless pair. Their probes are the CJK comma names whose - # member sits in the FIRST post-comma piece, which is #289's slot: - # a rule reaching one would stand ready to explain a regression in - # the half of the comma reading #531 did not touch. - "fix(#531) the trailing slot's positional reading reaches a caseless script": - ("田中 太郎, MA", "毛泽东, MA", "마틴 킹, MA", "田中, 太郎"), - # #531's dotted half at 1.4.0. The probes are the dotted shapes - # the VOCABULARY answers for, which fix(#516)'s rule holds out for - # the same reason, plus the acronym spelling of the same slot, - # which reaches 1.4.0 parity and diffs nowhere. - "fix(#531) a dotted credential ending the given part": - ("John Smith X.Y.Z.", "Doe, John Msc.Ed.", "Smith, A.B.C.", - "Doe, John MA"), - # The interior-credential control at 1.4.0. Its probes are the - # trailing-slot names on either side of it: a rule reaching one - # would absorb #531's own diff under a 2.0-era label. - "fix(comma-family) an interior credential acronym": - ("Doe, John MA", "Doe, John Ma", "John Smith MA", - "Doe, John MA Smith Jr."), - # The 1.4.0 rule #531 re-pointed. 'Doe, John MA' is the name it - # LOST -- the tree now agrees with v1 there, so a rule reaching it - # would explain a diff that no longer exists and stand ready for - # its regression; the other three are the neighbouring readings. - "fix(given-part-trailing-slot)": - ("Doe, John MA", "Doe, John MA Smith", "John Smith Ma", - "Doe, John X.Y.Z."), - "fix(#399)": ("Jane van der Berg née", "Jane van der Berg née y Jones", - "van der Berg, abdul née Jones", "Jane Smith née Jones"), - # Keyed on the full issue text, not "fix(#360)": that substring now - # matches two rules, and each one's boundary is the other's claim. - "fix(#360) mc moved into the never-given particles, so it folds into the family": - ("McDonald, Ronald", "Mcintyre Smith Jr.", "Ste Marie", - "Los Santos"), - "fix(#360) ste moved into the never-given particles with mc": - ("Steve Marie", "Ste", "Mc Donald", "Los Santos", - "Ste Marie Jones", "Ste. Marie Dupont"), - "fix(#400)": ("Jane Smith ABD", "Jane Smith A.B.D.", "Abdul Salam"), - "fix(#400/#274)": ("abd Berg née Jones", "abd Allah Smith", - "Jane Smith née Jones"), - # The literal-anchored rules #413 added. Each claims exactly one - # corpus name -- _CORPUS_CLAIMS carries that reach for both, so the - # number is pinned there rather than asserted here -- and a reach - # that small is one _CORPUS_CLAIMS cannot use: a widening reaching - # only names the corpora lack leaves it unmoved. These probes are - # the only wall. - "fix(#399) a maiden marker bounds the particle chain: the geb. spelling": - ("Berg, Ursula von der geb. Albrecht",), - "fix(credential-pair-order) a split credential and a suffix render in written order": - ("田中さん, Jr. Ph. D.", "Smith, Jr. Ph. D. MD"), - "fix(#411)": ("abdul née Jones", "Smith, abdul Rahman", - "Jane Smith ABD", "van der Berg, abd née Jones"), - "fix(#367) a title no longer displaces a leading never-given particle": - ("John Sir de Mesnil", "Smith, Sir de Vaux", "Sir Smith"), - "fix(#272/#308)": ("田中·太郎 김씨", "A·B 씨", "山田·花子"), - "fix(nickname-typographic-pairs)": ("Hans „Hansi“ Müller", - "John “Jack” Kennedy"), - "fix(#274)": ("Jones née", "née Jones", "Jane van der Berg née", - "Jane Smith (Nee)"), - # #399's and #274's names: a marker followed by a NAME word was - # never the connective join's to reach. A connective BEFORE the - # marker ('Jane Smith and née Jones') IS the rule's shape, reached - # from the other side, and is deliberately not a probe: the regex - # is scoped to marker-then-connective because that is what the - # corpora carry -- a snapshot, not a boundary. - "fix(#412)": ("Jane van der Berg née Jones", "Jane Smith née Jones", - "Jane van der Berg née", "Jane née Andrews Smith"), - # the carve-out is a THREE-word rule: four name words join with or - # without the clause, a name with no clause has nothing to lose, - # and the #412 pair is the other rule's - "fix(#418)": ("Juan y Eva Garcia née Jones", "Juan y Garcia", - "Jane van der Berg née y Jones", "Juan y Garcia née"), - # literal-anchored to its one example: the suffix-then-connective - # class has no other corpus name, and a name with either half - # missing is fix(#399)'s or fix(#412)'s - "fix(#418) accepted": ("Jane née Jr Jones", "Jane née y Jones", - "Jane van der Berg née Jr Jones"), - # decisions.md#H1's own nickname-plus-title-plus-name shape, kept - # out on purpose: this rule is anchored to a trailing suffix word, - # not a title, and a two-token name (with or without the suffix) - # has no third token for the regex to require. - "fix(N3) a nickname-led name with a trailing suffix keeps the suffix in `suffix`": - ("'Smitty' Jones", "Jones Jr.", "'Smitty' Dr. Jones"), - # #451's remaining literal-anchored rule, now stating a DECIDED - # boundary rather than an open enhancement: a three-token name - # with a rootname before or after is a different diff shape - # ('Carod i Rovira' and 'Josep Carod i Rovira' now join and have - # fix(#397) rules of their own), and 'Lluis Carod i' is the same - # both-sides boundary one word longer, diffing nothing. The probe - # is unchanged: none of the three may be claimed by a regex - # anchored to the two-word shape. - # - # KEYED on the retitled issue rather than on the bare 'fix(#397)' - # it carried until 2026-09-20. The keys are `issue` SUBSTRINGS and - # there are three fix(#397) rules now; the bare key would apply - # this list to the join rule below, which claims 'Josep Carod i - # Rovira' on purpose. - "fix(#397) accepted": - ("Carod i Rovira", "Josep Carod i Rovira", "Lluis Carod i"), - # The join rule's own wall, and every probe is a name the join - # must NOT reach for a different reason: 'Josep i Rovira' is three - # words and under rules.md#P3's carve-out; 'Carod i' and 'John - # Quincy Smith i' have no name word to the link's right, the - # accepted cost the rule above states; 'Josep Lluis Carod i III' - # and 'Josep Lluis Carod i V' end in a generation run and are - # claimed by fix(#436/#437) and the suffix-routing rule beside it. - # A rule keyed on the SHAPE -- a single letter between two name - # words -- reaches all five. - "fix(#397) the Catalan/Polish link joins": - ("Josep i Rovira", "Carod i", "John Quincy Smith i", - "Josep Lluis Carod i III", "Josep Lluis Carod i V", - "Lluis Carod i"), - # The one-case report rule: mixed-case spellings are where the - # writing decides the letter, and the join moved their ROLES - # instead, so a regex reaching one would absorb the join's own - # regression. 'JUAN GARCIA Y LOPEZ' is the unmarked letter in the - # same one-case position, which emits no report at all. - "fix(#397) the Catalan link reports": - ("Josep Carod i Rovira", "Carod i Rovira, Josep", - "Josep Lluis Carod i Rovira", "JUAN GARCIA Y LOPEZ"), - # #461's two halves, each probed with the other half's names and - # with the boundary the rule is keyed on. The criterion is the - # PART and never a word count: 'Jon Dough and' has base 'Dough - # and' and keeps 'J. D.', 'Juan Velasquez y Garcia' keeps 'J. V. - # G.', and 'Juan Garcia Lopez y' is the trailing spelling of the - # same -- none of the three moves, measured 2026-09-20 against the - # parent 46651750, so no #461 rule may claim any of them. - "fix(#461) a connective holding its part alone": - ("Jon Dough and", "Juan Velasquez y Garcia", - "Juan Garcia Lopez y", "John and Jane Smith", - "Duke of Edinburgh"), - "fix(#461) a connective with a name word beside it": - ("Jon Dough and", "Juan Velasquez y Garcia", - "Juan Garcia Lopez y", "Juan de y", "johnny y"), - # #342's rule is a literal alternation of five names, so a - # widening reaching only names the corpora lack would leave - # _CORPUS_CLAIMS unmoved. These probes are the wall, and every one - # of them is a name MEASURED not to move under the removal (which - # is the roster's actual condition -- a name the rule has no - # business claiming, not merely one outside the corpus). Three - # test the right anchor, where a third word or a re-ordered comma - # leaves 'Rai' with a family name beside it and nothing to take; - # 'Ahmad Jayadi', 'Ahmad Jayadi,', 'John Smith' and 'John Smith,' - # test the ALTERNATIVES' trailing words, each being a mover with - # its last word dropped -- with the comma and without it, so an - # alternative TRUNCATED to its leading tokens is caught wherever - # the truncation falls. No probe - # tests the left anchor and none can: dropping the '^' widens the - # rule to names ENDING in an alternative, and every such name is - # itself a mover -- 'Mr Aishwarya Rai' moves -- so no name that - # widening reaches can satisfy the roster's condition. - # 'Sejal Chaturvedi, CSM' is the wall against a SHAPE widening - # instead: the trailing-acronym shape "^.+\s[A-Z]{2,}$" reaches - # three of the five above and would look like a faithful - # generalization, but it also reaches this corpus name - # (corpus_issues.jsonl), which parses identically with and without - # 'rai'/'cha' in SUFFIX_ACRONYMS -- so the widening is caught. - "fix(#342)": ("Aishwarya Rai Bachchan", "Rai, Aishwarya", - "Jane Rai Smith", "Ahmad Jayadi", "Ahmad Jayadi,", - "John Smith", "John Smith,", - "Sejal Chaturvedi, CSM"), - # #451's four replacements for the fields-only catch-all. Each is - # anchored to a two-token name, so the probes are a third token and - # each other's vocabulary: the four exist BECAUSE one rule could not - # carry all six names, and a rule that quietly grew to reach a - # sibling's name would put the split back where it started. - # - # 'Carod i' and '田中さん II' are deliberately NOT probes for the - # numeral rule: its regex really does reach both, and rules above it - # win them -- 'Carod i' on file order, '田中さん II' on the subset - # test, its diff moving {family, given, suffix} where the numeral - # rule declares {family, suffix} and so cannot admit the `given` - # move. _CROSS_RULE_WINNERS pins both instead; - # this roster tests the regex, not classify(). - "fix(suffix-routing) a two-token name ending in a roman numeral keeps it in `suffix`": - ("Mohamad X Surname", "Smith Jr.", "Donald mc", "Aishwarya Rai"), - "fix(suffix-routing) a two-token name ending in the suffix word jr keeps it in `suffix`": - ("John Smith Jr.", "Smith Jr. PhD", "John V", "Jack Ma"), - # 'Mc Donald' is the leading-particle shape fix(#360) claims and - # 'Berg, Jan vd' the comma shape P6 gives to fix(#380); this rule is - # the third corner, trailing and comma-less, and must not reach - # either of the others. - # 2026-09-18 (#289): 'Jack MA' is a probe here NOW, where it was - # not before. This rule's own sentence -- a two-token name ending - # in a credential acronym that keeps it in `suffix` -- became TRUE - # of 'Jack MA' the day the case contrast started deciding it, and - # the only thing keeping the two apart is this regex's anchoring - # to the mc/mp pair. `fix(#289) a written case contrast decides a - # bare ambiguous acronym` is the rule that owns the name. - "fix(suffix-routing) a two-token name ending in a credential acronym keeps it in `suffix`": - ("Mc Donald", "Berg, Jan vd", "John Smith MP", "Jack Ma", - "Jack MA"), - # decisions.md#ma-do turns on the BARE spelling keeping its surname - # while the dotted one reads as a credential, so both spellings of - # the bare one are probes here -- and 'Jack Ma' is a probe for the - # acronym rule above as well, since 'ma' is acronym vocabulary too. - # 2026-09-18 (#289): 'Jack MA' no longer keeps its surname -- the - # written case contrast reads it as a credential with nothing to - # spare -- so the sentence above is now true of 'Jack Ma' alone. - # It stays a probe here for a different reason than it arrived: - # the name is claimed by `fix(#289) a written case contrast - # decides a bare ambiguous acronym`, and this rule, anchored to - # the DOTTED spelling, must still never reach it. - # 2026-09-27 (#544): 'Wang M.Eng.' joined the 1.4.0 rule, anchored on - # 'wang' for the reason 'John Smith M.A.' is a probe: the three-token - # 'John Smith M.Eng.' does not diff there. 'Wang Ma.' is the single - # trailing period the gate still does not count. Relabelled the same - # day for the shape both names share, from 'the dotted M.A. - # spelling reads as a credential (ma-do)'. - "fix(suffix-routing) a two-token name ending in a dotted credential spelling keeps it in `suffix`": - ("Jack Ma", "Jack MA", "John Smith M.A.", "John Smith M.Eng.", - "Wang Ma.", "Dr. Wang M.Eng."), - # #484: the connective rule is case-sensitive on the single letters - # so that the #462 shapes -- a capital or dotted E that is an - # INITIAL the facade drops -- are never claimed as the per-word - # grouping change. Since the facade fix these names AGREE with - # 1.4.0 and diff only against the 2.x baselines, where fix(#462) - # claims them, so a #462 REGRESSION is the only way they can diff - # at this baseline again -- and it must surface as UNEXPLAINED - # rather than be absorbed as per-word grouping. That is what the - # case-sensitivity buys, and it is why this roster keeps probing - # for it after the bug is gone. - # - # Re-read 2026-09-13 against #383/#479 and then again after #528, - # and left exactly as it is both times. The case-sensitivity - # argument is about the #462 shapes, which are MIXED case, and - # #383/#479 touched only one-case names. The sentence above still - # holds literally for both uppercase probes: measured at 1.4.0 - # that day, neither 'JOSE E MARIA SANTOS' nor 'Jose E Maria - # Santos' diffs at all, the facade's initials being what this - # baseline compares and #383/#479 having moved only the core's -- - # a clause #528 retired the same day by making the facade follow - # the tags too, without moving either probe (re-measured after it; - # an all-upper and a capital-E mixed-case spelling, and neither - # surface moves on either). What #528 added is a claimant: the - # four names whose facade view it moved now go to - # `fix(#528) the facade's initials follow the parse's connective - # tags`, written ahead of this rule. - "a connective run initials": ("Jose E Maria Santos", - "JOSE E MARIA SANTOS", - "Scott E. Werner", "Amy E Maid"), - # fix(#462)'s boundary: lowercase bare e/y is the connective; - # 'E.T.' is a run of initials the rule has no view on; a bare I - # is not conjunction vocabulary at all. - # - # 2026-09-13: 'john e smith' stopped being a name that does not - # diff. #383/#479 makes it report conjunction-or-initial at every - # 2.x baseline and moves the CORE's initials as well, so this - # probe now guards a LIVE diff rather than a dormant shape -- and - # that is a stronger reason to keep it, not a reason to re-read - # it as stale. If this rule ever lost its lowercase exclusion the - # regex would reach the name, and the only thing left refusing the - # claim would be the field narrowing (`_initials` against a - # measured `_ambiguities`), which is a thinner wall than the - # regex and would vanish the moment the report stopped moving. - # - # 2026-09-13, after #528: the FACADE's initials move on this name - # too ('j. s.' -> 'j. e. s.'), so both surfaces do. At the three - # 2.x baselines that changes nothing here -- the measured - # `_ambiguities` diff still keeps `_initials` out of the name's - # diff entirely -- and the probe's job is unchanged: this rule's - # lowercase exclusion is what refuses the claim, and it must. - "fix(#462)": ("john e smith", "maria y lopez", "E.T. Smith", - "Maier, Amy I, Jr."), - # #436/#437's rules are literal-anchored, so _CORPUS_CLAIMS' - # reach cannot move under a widening that only reaches names the - # corpora lack. These probes are the wall. Each rule's boundary - # is a WRITING of the same name: the comma forms and the - # delimiter forms render exactly as they did and must arrive - # UNEXPLAINED if they ever stop doing so. - "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": - ("Smith, MD PhD", "John Smith, MD PhD", "Smith, MD, PhD", - "Smith, MD - PhD - FACS", "John Doe, MD - PhD - FACS"), - # #397's maiden-clause rules, at every baseline. The key is a - # substring of BOTH spellings -- the bare 'fix(#397)' one at the - # 2.x baselines and the 'fix(#274/#397)' comma name at 1.4.0 -- - # and every probe must miss both. The wall the comments argue - # for: the link with nothing to its right, the link before a - # generation and before a credential (each the generation it also - # spells, and the clause ends at it as it always did), the two - # spellings where the letter is an INITIAL and the clause keeps - # everything, the delimited form the writer settled, and the 'y' - # twin, which never had either reading at any baseline. - "a link inside a maiden clause stays in the birth name": - ("Jane Doe nee Puig i", "Jane Doe nee Puig i III", - "Jane Doe nee Puig i MA", "Jane Doe nee Puig I Soler", - "JANE DOE NEE PUIG I SOLER", "Jane Doe (nee Puig i Soler)", - "Jane Doe nee Puig y Soler"), - # The 1.4.0 rule for the control, whose diff there carries the - # marker's consumption and the run's rendering and not this - # change at all. It must not reach the names the link fix DOES - # move, nor the older generation run whose rendering the - # fix(#436/#437) rule at the head of that file owns. - "a clause the generational link ended": - ("Jane Doe nee Puig i Soler", "Doe, Jane nee Puig i Soler", - "Jane Doe nee Puig i", "Josep Lluis Carod i III"), - "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run": - ("田中さん V.", "田中さん 様.", "田中さん, Dr."), - "fix(#436/#437) the glued honorific and the polite address are one post-nominal run": - ("田中さん II", "田中さん, 様.", "田中さん 様"), - "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run": - ("김민준 박사", "김민준 박사님", "김민준 씨"), - "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": - ("김민준씨", "Dr 김민준씨, Jr.", "김민준씨 (Jimmy)"), - "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve": - ("abdul Smith V", "abdul Smith Jr Ma", "abdul Smith Jr", - "Smith Jr V"), - # #346's rule is a literal alternation of four names, so a - # widening reaching only names the corpora lack would leave - # _CORPUS_CLAIMS unmoved. These probes are the wall, and each is a - # name that must not move: two name words after the title (H1 does - # not fold), a surname-retaining title, and the bare renunciate - # title standing alone -- and 'Mr Guru Nanak', which the anchored - # regex rejects but an alternation that lost its left anchor would - # claim (the guard uses re.search). - "fix(#346) a renunciate title and one name word leave the name a given name": - ("Swami Vivekananda Saraswati", "Guru Gobind Singh", - "Rabbi Cohen", "Swami", "Mr Guru Nanak"), - # #519's rule is a literal alternation, so a widening reaching - # only names the corpora lack would leave _CORPUS_CLAIMS unmoved. - # These probes are the wall: two name words behind the title (H1 - # does not fold), the joined run that addresses by its last word - # `wales`, the word standing last as an ordinary surname, a - # surname-addressing title that stayed out, the bare title, and - # 'Mr Prince Charles', which the anchored regex rejects but an - # alternation that lost its left anchor would claim. - "fix(#519) prince and princess join the given-name titles": - ("Prince Harry Windsor", "Prince of Wales Jr", "Robert Prince", - "Lord Byron", "Prince", "Mr Prince Charles"), - # #449's six rules. The five CJK ones are literal-anchored on one - # corpus name each, so _CORPUS_CLAIMS cannot move under a widening - # that reaches only names the corpora lack; these probes are the - # wall. Each rule's boundary is the same name with something - # DECIDING it -- a title, a second name word, a comma -- because - # that is the widening the rule invites. A SUFFIX beside the word - # is deliberately not such a probe: it decides nothing, and - # 'Smith Jr.' is a member of the alternation rather than a wall - # against it. - "feat(#449) a lone name word reports given-or-family": - ("Dr. Andrew", "Andrew Smith", "Smith, Andrew", "abdul", "de", - "Dr. Smith Jr.", "J", "Dr. Wang M.Eng."), - "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it": - ("マイケル ジャクソン", "マイケル・ジャクソン", "Dr. マイケル"), - "feat(#449) an interpunct transcription declines the script order, so the convention decides it": - ("王·Smith Jones", "王 Smith", "Dr. 王·Smith"), - "feat(#449) an ideographic comma leaves one name word, and the convention decides it": - ("田中、太郎、次郎", "田中 太郎", "Dr. 田中、太郎"), - "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order": - ("Anderson Smithさん", "Dr. Andersonさん", "Anderson, さん"), - "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order": - ("Anderson Smith선생님", "Dr. Anderson선생님", "Anderson, 선생님"), - "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it": - ("김민준", "김\u3099민준 지훈", "Dr. 김\u3099민준"), - # #491's two rules. Each boundary is a name the vocabulary reaches - # and the SHAPE does not: a title with an ordinary surname behind - # it, a credential with a name word beside it, and the lone title - # word the peel takes whole, leaving nothing to read as a name. - "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name": - ("Dr. Smith", "Rabbi Cohen", "Mrs. Garcia", "Dr.", "King, Dr Jr", - "Lord Chancellor Smith", "The Rt Hon Jane Smith"), - "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name": - ("John Smith QC MP", "Lama Zopa Rinpoche", "Smith Jr.", "Jr.", - "Rinpoche Tenzin"), - # The join clause's own rule, added by the #518 review round when - # hoisting it out from under O5's field-deciding clauses reached - # two corpus names. Literal-anchored on the two, so the probes are - # the wall: each member with a name word behind it (the join stops - # being the only unit), the join LED by a title word that H3 - # chains into a title run instead, and a join whose members are no - # title vocabulary at all, which is O5's report and not this one. - "feat(#491) a joined unit carrying title vocabulary reports title-or-name": - ("Attorney General of Minnesota Smith", - "Deputy Secretary of State Jones", "Prince of Wales", - "Duke of Edinburgh"), - # The 2.3 title-run bundle's four rules, all literal-anchored, so - # these probes are the wall _CORPUS_CLAIMS cannot be: a reach this - # small can be widened into names the corpora lack without moving - # a claim count. - # - # The run rule's boundary is the vocabulary the SHAPE needs: a - # one-word run addresses nobody by given name, and a run whose - # last word is title vocabulary that is NOT given-name vocabulary - # keeps its family name. 'Her Royal Highness Princess Anne' was - # that shape when this wall was built and stopped being it on - # 2026-09-10, when #519 made `princess` given-name vocabulary: it - # now MOVES, under fix(#519)'s rule and not this one, and it stays - # here because #489's regex must still not reach it. - "fix(#489) a title run addresses by its last title": - ("Sir John Smith", "His Excellency Lord Duncan", - "Her Royal Highness Princess Anne", "Dr. Smith"), - # The floor's boundary is the word it gives back having to be a - # NAME candidate: 'MD DDS' and 'Jr. Ph. D.' are the two all-suffix - # rests it declines, 'Marquess of Bath' the all-title input with - # no rest to read at all, and 'Dr Smith Jr' the ordinary titled - # name where the piece behind the run was never a suffix. - "fix(#489) the title peel leaves a name word a suffix cannot be": - ("Dr Smith Jr", "MD DDS", "Marquess of Bath", "Prince of Wales Jr"), - # The trailing rule's boundaries are the three readings that did - # NOT move -- an unlisted abbreviation, a bare title word, a - # post-nominal -- plus the comma shape whose segment holds no name - # word and which segment_suffix_reading has always routed by a - # different gate. - "fix(#316) a trailing period-marked title word reads as a title": - ("John Smith Xyz.", "John Smith Sir", "Mary Jane King", - "John Smith Esq.", "Smith, Prof."), - # The CJK member of the same argument is its own literal rule -- - # an UNDECLARED alternation holding a script-classified member - # belongs to the honorific pin, which until #322/#323 was every - # such alternation -- so a reach of 1 is one _CORPUS_CLAIMS cannot - # police on its own, and these are the wall. All three are - # tolerated CJK corpus names the rule must not take: a Latin - # post-nominal behind a comma, the comma spelling of this very - # shape (routed by the segment gate), and a spaced Latin - # post-nominal that is not title vocabulary. - "fix(#316) a trailing Latin title on a native-script name is a title": - ("王先生, V.", "田中さん, Dr.", "田中さん II"), - # The ph boundary: the merged split spelling in both positions, and - # the single-token spellings the `phd` entry carries. - "change(suffix-acronym-collisions) ph leaves the acronym set": - ("John Smith Ph. D.", "Smith, Ph. D.", "john smith phd", - "John Smith Ph.D."), - # #540's rules, keyed on the full issue since all of them carry - # `fix(#540)` -- eight when written, six since #544 (2026-09-27) - # read the comma run and the chunked dotted spelling again. - # Each wall is the other rules' names plus the - # spellings a case-blind widening would reach: 'meng li' leads - # with the word and nothing moved; the one-case 'john smith meng' - # and 'JOHN SMITH MENG' keep the credential, so the cost rule has - # no business with either. Its third probe, 'John Smith MENG', is - # not one-case either -- MENG's own capitals keep the credential - # on the capitals lean, in a name otherwise written Title-case. - # The report rule must not reach the names whose ROLES move. The - # lone-word comma rule must not reach - # its own capitals-lean probe or the two-word comma rule's name. - # Every #540 rule carries one superstring probe too, after a - # mutant dropping the ^...$ anchors passed every other guard here. - "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": - ("meng li", "Meng Li", "john smith meng", "john smith MEng", - "Dr. wang meng"), - "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - ("john smith meng", "JOHN SMITH MENG", "John Smith MENG", - "Dr. john smith MEng"), - "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": - ("wang meng", "tran lac", "john smith MEng", "meng li", - "Dr. john smith meng"), - "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - ("John Smith, MEng", "Smith, John meng", "Dr. Smith, John MEng"), - "fix(#540) a lone meng or MEng after a family comma reads as the given name": - ("Smith, MENG", "Smith, John MEng", "Dr. Smith, meng"), - "fix(#540) a Title-case Lac behind a particle is the family name": - ("nguyen van lac", "Dr. Nguyen Van Lac"), - # #544's rules (2026-09-27), which retired #540's comma-run and - # chunked-dotted pair, keyed the same way. The chunked-dotted name, - # 'Wang M.Eng.', is feat(#449)'s at the 2.x baselines and - # fix(suffix-routing)'s at 1.4.0, #544 returning the reading every - # release had, so no #544 rule claims it. Each wall is the - # boundary its rule's comment argues -- the capitals-settled run - # that reports nothing new, one name word before the comma, the - # dual opening the given part, the credential BEHIND the member -- - # plus one superstring probe per literal rule, for the - # anchor-dropping widening the #540 block above records. The - # anchor rule's key drops its tag: it is fix(#436/#437/#544) at - # 2.0.0 through 2.2.0 and fix(#544) at 2.3.0. - "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports": - ("John Smith, PhD MA", "Smith, PhD MEng", - "Dr. John Smith, PhD MEng"), - "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count": - ("Smith, Ed Ma", "Smith, Ms Ma", "Dr. John Smith, Ed Ma", - "Dr. John Smith, X.Y.Z. MA"), - "#544) an unambiguous credential in front anchors the member behind it": - ("Wang Ma PhD", "John Smith MEng PhD", "Dr. John Smith PhD MEng"), - "fix(#544) a degree in front outranks P6's attachment for a particle member": - ("doe, jane do", "NASCIMENTO, EDSON ARANTES DO", - "Dr. doe, jane v phd do"), - # 2026-09-28: the one-word-family-comma rule is fix(#544) at 2.2.0 - # and 2.3.0 and fix(#296/#544) at 2.0.0 and 2.1.0, so its key drops - # the tag as the anchor rule's does; the dual-title rules are - # fix(#296) at 1.4.0 and fix(#296/#531) at 2.0.0 and 2.1.0, keyed by - # the words they share. Walls: the capitals-settled member, the - # dual in the leading title run, and a superstring each. - "#544) a credential in front anchors a member after a one-word family comma, and the pick reports": - ("Smith, PhD MA", "Smith, MD PhD Ma", "Dr. Smith, PhD Ma"), - "phd is not a prenominal, so behind a dual title it is the given name": - ("Smith, MD PhD MA", "Smith, Ms MD Ma", "Dr. Smith, MD PhD Ma"), - # The esq boundary is every spelling SUFFIX_WORDS still carries, - # in each of the three positions the corpora write it in. - "change(suffix-acronym-collisions) esq leaves the acronym set": - ("John Smith Esq", "John Smith Esq.", "Esq. Smith", "Smith, Esq.", - "Esq. van Gogh"), - # The #322/#323 rule is a literal alternation of seventeen names at - # most, so _CORPUS_CLAIMS cannot see a widening into names the - # corpora lack and these probes are the wall. Each is a reading - # the bundle deliberately left where it was: the ASCII spelling - # that reached the suffix entry before #322 ever ran, a stop on a - # SEPARATE post-nominal word (rules.md#W3's unchanged case), the - # comma listing form routed by the segment gate, and the Latin H2 - # abbreviation the initialless-script veto never touches. - "fix(#322/#323)": ("김민준 씨.", "田中さん 様.", "김민준, 씨.", - "Smith. John"), - # #383/#479's three rules are literal-anchored alternations, so - # _CORPUS_CLAIMS cannot see a widening that reaches only names the - # corpora lack -- these probes are the wall, and they are keyed by - # the FULL issue string because "fix(#383/#479)" matches three - # rules and each one's boundary is another's claim. - # - # Almost every probe is a MIXED-CASE spelling of a name the rule - # does claim. Mixed case is where the writing itself decides the - # letter (rules.md#P3), nothing moved there at all, and a rule that - # ever reached one would be absorbing a regression in the half of - # P3 this change did not touch. The rest are one-case names in the - # same population whose ROLES this change leaves alone -- 'JUAN - # Y GARCIA' keeps given, middle and family while its tag and its - # initials both move, which is why it probes the role rule. - "fix(#383/#479) a single-letter connective joins only on case evidence": - ("Jose e Maria Santos", "Jose E Maria Santos", - "Juan Garcia y Lopez", "Juan Garcia Y Lopez", "John e Smith", - "juan garcia y lopez", "JUAN Y GARCIA", - # a title counts toward the case class the same as any other - # of the name's own words (unlike a clause) -- "Dr." beside - # an all-upper name makes the whole name mixed case, so 'Y' - # is evidence-backed and stays an initial; the literal rule - # must never claim this - "Dr. JUAN GARCIA Y LOPEZ"), - # 'john e jones, III' is the probe worth understanding: the - # trailing uppercase III makes the whole name mixed-case, so it - # keeps today's reading and no rule of this change may ever claim - # it. 'johnny y' and 'der, y van' are one-case names carrying an - # unmarked letter, which reports nothing. 'e and e' is NOT a probe: - # this rule claims it at 2.3.0 and leaves it to feat(#449) at the - # three older baselines. - "fix(#383/#479) a marked connective letter in a one-case name is reported": - ("John e Smith", "John E Smith", "john e. smith", - "john e jones, III", "johnny y", "der, y van"), - # `fix(#383/#479) a bare capital connective in an all-upper name - # stops initialing` carried a probe tuple here until 2026-09-20. - # The rule is DELETED from expected_since_2.3.0.toml -- #461 gave - # 'JUAN Y GARCIA' its letter back and the name stopped diffing - # there -- and a key matching no rule empties its own pin - # silently, which the roster's own assertion refuses. The - # boundaries it pinned are now the #461 rules' below. - # #528's rule is a literal alternation of four names, so - # _CORPUS_CLAIMS cannot see a widening that reaches only names the - # corpora lack -- these probes are the wall, and the key is the - # FULL issue string for the same reason the #383/#479 keys are. - # - # The first five are MIXED-CASE spellings: mixed case is where the - # writing decides the letter (rules.md#P3), neither surface moved - # there, and a rule reaching one would be absorbing a regression - # in the half of P3 that change did not touch -- 'Scott E. Werner' - # most of all, being the name fix(#462) exists for. 'maria y lopez' - # and 'juan y garcia' are the one-case controls whose lowercase - # 'y' was the connective before and after. 'Ph. D., John' is the - # one corpus name whose two initials views still disagree, for the - # unrelated reason fix(initials-per-word) the Ph. D. merge names; - # this rule must never claim it. 'john e jones, III' is the probe - # worth understanding: the trailing uppercase III makes the whole - # name mixed-case, so its 'e' never entered the one-case fork and - # its initials do not move on either surface. - "fix(#528) the facade's initials follow the parse's connective tags": - ("John E Smith", "Juan Y. Garcia", "Juan y Garcia", - "Scott E. Werner", "Jose E Maria Santos", "maria y lopez", - "Ph. D., John", "john e jones, III", "juan y garcia"), - # The third feat(#269) rule, the only one keyed on a derived view, - # carried a probe tuple here until 2026-09-20. It is DELETED from - # expected_since_1.4.0.toml -- #461 gave 'محمد و علي' its 'و' back - # and the name gives 1.4.0's own answer again -- and a key matching - # no rule empties its own pin silently. The name still diffs at the - # four 2.x baselines, where the #461 readmission rule carries it - # and _MUST_NOT_MATCH pins that rule's boundary instead. - # #289/#516's three rules are literal-anchored alternations, so - # _CORPUS_CLAIMS cannot see a widening that reaches only names the - # corpora lack -- these probes are the wall, and the keys are the - # FULL issue strings for the same reason #383/#479's are. - # - # #289's rule is anchored to the names a MIXED-case spelling - # moves, so every probe here is a name whose writing carries no - # contrast at all -- one case, lower case, or a caseless script -- - # plus the two dotted shapes the vocabulary settles on its own. - # A rule reaching one would be absorbing a regression in the half - # of rules.md#S2 this change did not touch. '毛泽东, MA' is the - # sharpest of them: it DOES gain the fork's report, having one - # pre-comma word and no case to read, and it must still never be - # claimed by a rule about written case. - "fix(#289) a written case contrast decides a bare ambiguous acronym": - ("JOHN SMITH MA", "ANH DO", "anh van do", "jack ma", "Jack Ma", - "Jack Ma.", "毛泽东, MA", "Smith, A.B.C.", "Jack M.A."), - # #575: two name words before the comma, a given name and a - # particle surname, keep the credential reading. - "fix(#575) a particle surname before a comma is one name word": - ("John van Buren, Ed", "John van der Berg, PhD"), - # #573: each rule's boundary -- nothing behind the particle, and a - # particle that can be a given name. - "fix(#573) a particle-suffix word before a family comma stays in the family": - ("SMITH VD, JOHN",), - "fix(#573) a particle-suffix word between credentials after a family comma stays in the run": - ("DOE, JANE PHD VD",), - "fix(#573) a never-given particle opening the given part takes the member behind it": - ("SMITH, VAN MA",), - # #564: one name word, a lone two-letter word, and no comma. - "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default": - ("Smith, XYZ", "García Márquez, MJ", "John Smith XYZ"), - # #516's dotted rule must never reach the shapes the vocabulary - # SETTLES (a whole-token match, or a surviving chunk claim), the - # leading dotted runs rules.md#S2 excludes by position, the single - # trailing period AGENTS.md's trailing-abbreviation reading owns, - # or a digit chunk, which the alphabetic gate refuses. - "fix(#516) an unlisted dotted acronym is read by position": - ("Jack M.A.", "John Smith M.A.", "Smith, A.B.C.", - "Doe, John Msc.Ed.", "John Doe Msc.Ed.", "X.Y.Z. Smith", - "J.R.R. Tolkien", "A.B. Vajpayee", "E.T. Smith", - "John Smith Xyz.", "John Smith 1.4", "Bridge (1.4)", - # 2026-09-18 review round: the three gates on the shape - # verdict that had no probe of their own, each a DIFFERENT - # conjunct. The initialless-script gate ('田.中.' -- a script - # that writes no abbreviations has nothing for interior - # periods to abbreviate); the `token.role is None` gate - # ('Bridge (A.B)' -- delimited content the escape kept as a - # nickname, where 'Bridge (1.4)' above declines one gate - # earlier and so cannot exercise this one); and the - # alphabetic-chunk gate at a COMMA ('John Smith, 1.4', the - # comma twin of the bare 'John Smith 1.4' already here). - "John Smith 田.中.", "Bridge (A.B)", "John Smith, 1.4", - # 2026-09-30, #563: paired initials after a comma are the - # paired-initials rule's, never this one's - "John Smith, A.B.", "García Márquez, G.J."), - # #563: the shapes the exception does not reach. One word before - # the comma never flips (#516's reading, reported there); three - # letters run together read by the count (#516's rule claims them); - # a pair after a suffix-bearing family keeps the given by the - # name-word count alone; spaced initials are separate words. - "fix(#563) paired initials after a comma read as the given name unless a word speaks for them": - ("Smith, A.B.", "Tolkien, J.R.R.", "John Smith, X.Y.Z.", - "García Márquez, G.J.R.", "Smith Jr., A.B.", "Smith, J. R. R.", - "John Smith X.Y.", "García Márquez, Ms G.J.", - "García Márquez, Ed G.J."), - # #562: the shapes the particle chain does not reach. One particle - # beside no other is left to its capitals and stays settled; one - # name word before the comma keeps the listing form, chain or not. - "fix(#562) a comma credential run holding a particle chain reads by the name-word count": - ("John Smith, PhD DO", "Smith, PhD DO DO", "John Smith, PhD MA"), - # The caps shape's trailing slots are behind CapsSuffixes.EVERYWHERE, - # not the default, so their population is a probe here: the corpora - # run at the default, and a rule of this arc reaching one of these - # names would mean the default reached a trailing slot. 'John Smith, - # LEED AP' moves at the default since #564, but at the comma and - # under fix(#564), which is why THIS rule must still not reach it. - # 'Mr XXX' is the boundary from the other side -- - # one piece behind a title never reaches the peel's two-piece - # floor, switch or no switch. - "fix(#289/#516) the ambiguous credential class reports at slots " - "that were silent": - ("Jean DUPONT", "Jean Pierre DUPONT", "Minjun KIM", - "John Smith XYZ", "Smith, LEED AP", "John Smith, LEED AP", - "Mr XXX"), - # #533's rules, and the probes are the boundaries each rule's own - # comment argues for. The class is a SLOT, so every one of these - # is a name carrying the same vocabulary in a position the rule - # does not reach -- which is what a regex written over the WORDS - # rather than over the names would claim. - # - # The movers must not reach the declining half ('Jane Doe nee - # Smith Ma', 'Doe, Jane nee Smith MA do'), the boundaries where - # no fork was consulted at all ('Jane Doe nee MA Smith', a name - # word behind the member; 'Jane Doe nee Smith MA Prof.', a - # trailing title breaking the peel; 'Smith, John, Jr nee Jones - # MA', past a second comma), the numeral shape another rule owns - # ('Jane Doe nee Smith V'), or the CLAUSE-LESS controls whose - # readings pre-date this change ('Doe, John MA' is #531's, - # 'John Smith MA' is S2's). - "fix(#533) a credential ending a maiden clause reads as a credential": - ("Jane Doe nee Smith Ma", "Doe, Jane nee Smith MA do", - "Jane Doe nee MA Smith", "Jane Doe nee Smith MA Prof.", - "Smith, John, Jr nee Jones MA", "Jane Doe nee Smith V", - "Doe, John MA", "John Smith MA", "Berg, abdul MA", - "Jane Doe nee Smith XYZ"), - # The declining half must not reach the movers, and the same - # boundaries apply from this side. - "fix(#533) the maiden clause reports the credential it keeps": - ("Jane Doe nee Smith MA", "Doe, Jane nee Smith MA", - "Jane Doe nee MA Smith", "Jane Doe nee Smith MA Prof.", - "Smith, John, Jr nee Jones MA", "Doe, John Ma", - "John Smith Ma", "Jane Doe nee Smith V"), - # The by-shape rule must stay on the one spelling the switch - # makes credential-SHAPED: not the listed member beside it, not - # the comma-less control, and not the same acronym one period - # short. - "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause": - ("Jane Doe nee Smith MA", "John Doe X.Y.Z.", - "Doe, John X.Y.Z.", "Jane Doe nee Smith X.Y.Z", - "Jane Doe nee Smith XYZ"), - # The names whose clause KEEPS the member because no reader or a - # join would have taken it must not reach their own clause-less - # controls -- which is what the rule's argument rests on: 'Berg, - # abdul MA' really does read given 'abdul MA', and that is the - # reading the decline refuses to hand a clause word to. - "fix(#274/#533) the clause keeps the credential a join or a missing reader would have taken": - ("Berg, abdul MA", "Doe, Dr. MA", "Berg, Jane van der DO", - "Doe, Jane MA do", "Jane Doe nee Smith MA"), - # The delimited rule must not reach the undelimited spellings, - # which is the whole of what it says: the boundary is the writer's. - "fix(#335/#533) a marker-led bracketed clause is the maiden name whatever its last word is": - ("Jane Doe nee Smith MA", "Jane Doe nee Smith Ma", - "Jane Smith (née Jones)"), - # The restoration is one name and one spelling: not the same - # acronym without the clause, and not a spelling the dotted gate - # reads differently. - "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name": - ("John Smith R.A.I.", "John Smith nee Jones RAI", - "John Smith nee Jones R.A.I", "Jane Doe nee Smith R.A.I."), - # 1.4.0's pre-existing half must not reach the names whose clause - # this change made give the member up. - "fix(#274/#424) accepted: a maiden clause keeps a trailing credential": - ("Jane Doe nee Smith MA", "Doe, Jane nee Smith MA", - "Jane Doe nee MA Smith", "Jane Doe nee Smith V", - "John Smith MA", "Jane Doe Jr. Ma", - "Dr. Jane Doe Jr. nee Smith Ma"), - # ...and the name whose clause an UNAMBIGUOUS credential ended - # must not reach the ambiguous ones, in either word order. - "fix(#274/#436/#437) a clause the unambiguous credential ended": - ("Jane Doe nee Smith MA PhD", "Jane Doe nee Smith MA", - "Jane Doe nee Smith PhD", "John Doe PhD MA", - "Dr. Jane Doe nee Smith PhD MEng"), - # 1.4.0's #533 half must not reach the pre-existing one, and the - # capitals are the evidence: 'Jane Doe nee Smith Ma JD' is in the - # rule and 'Jane Doe nee Smith Ma' is not. - "fix(#533) the clause gives the credential up, and the run it joins renders as the writer spaced it": - ("Jane Doe nee Smith PhD MA", "Jane Doe nee Smith MA", - "Jane Doe nee Smith Ma", "Doe, J. nee MA", "Doe, J. ba"), - # The compound rule is case-SENSITIVE by construction, so it - # takes the other spellings of its own words as probes. - "fix(#445/#533) a one-case clause keeps the credential, and the one name word it leaves is the family": - ("John nee Jones Smith MA PHD", "John née Jones Smith Ma", - "JOHN NEE JONES SMITH MA", "John née Jones Smith MA"), - # The phrase rule must not reach the bare phrase name the - # fix(#434) rules own, nor the n[ée]e spellings. - "fix(#434/#533) a marker PHRASE takes the maiden name": - ("Maria Kowalska z domu Nowak", "Maria Kowalska z domu Nowak Ma", - "Jane Doe nee Smith MA", "Maria Kowalska nee Nowak MA"), - # The native-script rule is one literal, and the widening it - # invites is a 旧姓 regex: the marker's other corpus names do not - # end in a member and must not be claimed, nor must the Latin - # spelling of the same reading. - "fix(#533) the maiden clause ends at the credential in a native-script name too": - ("田中 太郎 旧姓 佐藤", "Jane van der Berg 旧姓 Jones", - "田中 太郎 旧姓 佐藤 Ma", "Jane Doe nee Smith MA"), - # The numeral rule is #424's and must not reach the acronym - # shapes #533 moved, nor the two numeral names that already have - # rules of their own. - "fix(#424) the maiden walk stops before the trailing numeral, with the family name left standing": - ("Jane Doe nee Smith MA", "John née Jones Smith V", - "Jane Smith née V", "J. née Jones Smith V"), - # 2026-09-19, #533: the narrowed anchor's boundary is the - # spelling it no longer claims. 'John née Jones Smith MA' reads - # the opposite way and has fix(#445/#533); 'John nee Jones Smith - # MA PHD' is the mixed-case spelling of the name that joined, - # and reads the opposite way too. - "fix(#424/#445) accepted: the maiden walk keeps a bare acronym": - ("John née Jones Smith MA", "John nee Jones Smith MA PHD", - "JOHN NEE JONES SMITH MA"), - # #535's title-stop rule is a literal list over the SLOT, for the - # same reason fix(#533)'s rules give: a superstring with a name - # word standing ahead of the marker's release, or behind the - # title, must not match. Used at 2.0.0-2.3.0, where the rule is - # #535-only for the names it still carries. - "fix(#535) a trailing title ends the maiden clause": - ("Dr. Jane Doe nee Smith Prof.", "Jane Doe nee Smith Prof. Dr."), - # 1.4.0's copy: every name here differs from that baseline for - # #274's reason too (no maiden reading at all), so the issue is - # relabeled -- same probes, same reason. - "fix(#274/#535) a trailing title ends the maiden clause": - ("Dr. Jane Doe nee Smith Prof.", "Jane Doe nee Smith Prof. Dr."), - # The 1.4.0-only Dr.-postnominal rule's own probe: a name word - # standing ahead of the marker or behind the title must not match. - "fix(#274/#296/#535) a trailing title after a dropped postnominal Dr., with no maiden reading either": - ("Dr. Jane Doe nee Prof. Dr.", "Jane Doe nee Prof. Dr. Jr."), - # The dropped-Dr.-postnominal rule's own probe, at 2.0.0/2.1.0. - "fix(#296/#535) a trailing title after a dropped postnominal Dr.": - ("Dr. Jane Doe nee Prof. Dr.", "Jane Doe nee Prof. Dr. Jr."), - # The title-in-front-of-the-credential rule's own probe, at - # 2.0.0-2.3.0. - "fix(#533/#535) a title in front of the credential the clause gives up": - ("Dr. Jane Doe nee Smith Prof. MA", "Jane Doe nee Smith Prof. MA Jr."), - # (Nine probes left on 2026-10-04 with the rules they pinned, which - # #601 deleted: the lone-marker, numeral-stop, #399-sibling, - # given-slot-numeral, bound-given-after-a-comma and #411/S2 rules.) -} - - -def test_no_rule_matches_a_name_it_has_no_business_claiming() -> None: - """The wall _CORPUS_CLAIMS cannot be. - - A claim count moves only when corpus reach moves, so a rule that - claims one corpus name can be widened arbitrarily as long as the - names it newly reaches are outside the corpora -- and every rule - added by #414 is literal-anchored and claims exactly one. This - asserts the boundaries those rules' comments argue for, against - names chosen to sit just outside them. - """ - checked = 0 - hits_by_key: dict[str, int] = {k: 0 for k in _MUST_NOT_MATCH} - for ledger in _LEDGERS: - for rule in _rules(ledger): - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - for key, probes in _MUST_NOT_MATCH.items(): - if key not in rule["issue"]: - continue - hits_by_key[key] += 1 - for probe in probes: - checked += 1 - assert not re.search(regex, probe), ( - f"{ledger.name}: {rule['issue']!r} matches " - f"{probe!r}, a name its own comment says should " - f"arrive UNEXPLAINED. Either the rule widened " - f"or the boundary moved -- if the latter, the " - f"comment and this roster both need editing") - # Per KEY, not in aggregate. The old spelling summed one counter - # over every key, so renaming a single key to a string no rule - # contains left the suite green -- which is exactly the edit the - # message below warns about. - empty = sorted(k for k, n in hits_by_key.items() if not n) - assert not empty, ( - f"_MUST_NOT_MATCH keys matching no rule: {empty}. The keys are " - f"`issue` substrings, so a renamed or deleted rule silently " - f"empties its pin while every other key keeps this test green") - assert checked - - -def test_rules_corpus_matches_the_rules_doc() -> None: - """corpus_rules.jsonl is GENERATED from docs/design/rules.md's own - examples (#414), through the doc's parser rather than a second - regex. Same promise as the CJK pin above: an example added to a - rule without regenerating fails HERE, instead of leaving the - normative names outside the differential gate. - - Why the doc's examples need the gate at all, when test_rules_doc - already executes every one of them: those pin against an - expectation stored beside them, so a deliberate behaviour change - edits both in one commit and the test stays green. The corpus - compares against a RELEASED baseline no commit can edit, which is - what forces a moved name to be classified in writing. - """ - module = load_tool("build_rules_corpus") - checked_in = [json.loads(line) for line in - (_TOOLS / "corpus_rules.jsonl") - .read_text(encoding="utf-8").splitlines()] - assert checked_in == module.selected_names(), ( - "corpus_rules.jsonl is stale: regenerate with " - "`uv run python tools/differential/build_rules_corpus.py`") - - # The equality above cannot see a generator that silently selects - # LESS: someone regenerates, the file agrees with the degraded - # selection, and both are wrong together. _CORPUS_FLOORS bounds - # that to a few names; these two bound the shapes it cannot. - # - # A rule whose ID line stops matching, or that gains a heading - # mid-block, loses its examples to the previous rule or to - # nothing. Both are loud today only by luck of a neighbouring - # invariant -- every rule carries `implemented:` and is cited from - # code, so test_doc_citations fails on the unknown ID. That is a - # different file's guarantee and could stop holding. - from .rules_doc import parse_rules_doc - rules = parse_rules_doc( - (_TOOLS.parents[1] / "docs" / "design" / "rules.md") - .read_text(encoding="utf-8")) - assert len(rules) >= 38, ( - f"rules.md parsed to {len(rules)} rules, fewer than the 38 this " - f"corpus was built from. A rule block that stops parsing hands " - f"its examples to its neighbour and shrinks the corpus " - f"silently. Ratchet this floor up when rules are added") - empty = [r.rule_id for r in rules - if not any(e.text for e in r.examples)] - assert empty == ["D1", "D2"], ( - f"rules with no example NAME: {empty}. D1 and D2 are the " - f"expected two -- their examples name a policy rather than a " - f"name string. Any other rule contributing nothing means its " - f"examples stopped being recognized as examples, which the " - f"file-equality check above cannot distinguish from a rule " - f"that never had any") - - -def _tolerated_only_texts(rules: list) -> set[str]: - """Example texts a `tolerated:` rule carries and no normative rule - does -- the subtraction the demotion guards below both scope by. - - Shared rather than written twice because the two guards must agree - on WHICH texts a demotion is about: one asserts they left the - contract corpus, the other that they are still watched somewhere, - and a pair disagreeing about the set would leave a text neither - enforced nor watched while both stayed green. An example some - normative rule also carries is in the contract on that rule's - account -- demoting one rule cannot take another's name away. - """ - normative = {e.text for r in rules if r.tolerated is None - for e in r.examples if e.text} - return {e.text for r in rules if r.tolerated is not None - for e in r.examples if e.text} - normative - - -def test_a_tolerated_rule_puts_no_name_in_the_rules_corpus() -> None: - """A rule marked `tolerated:` in rules.md contributes no example - to the CONTRACT corpus (2026-09-01, the CJK comma demotion). - - The equality above would pass with the skip deleted -- regenerate - and the file agrees with whatever the generator now selects. This - reads the doc's marker directly and asks the file. Scoped to the - marked rule's OWN texts: an example some normative rule also - carries is in the corpus on that rule's account, and demoting one - rule cannot take another's name away, so the check subtracts them - rather than failing on them. - - What it does NOT check: that the demoted names are still watched - somewhere. That is the opposite promise and it has its own guard, - the next one down -- this pair is a floor and a ceiling on the - same set of texts, and each is vacuous without the other. Deleting - an example satisfies this one; adding an unwatched one satisfies - that one; only together do they say what a demotion is. - """ - from .rules_doc import parse_rules_doc - rules = parse_rules_doc( - (_TOOLS.parents[1] / "docs" / "design" / "rules.md") - .read_text(encoding="utf-8")) - tolerated = [r for r in rules if r.tolerated is not None] - assert tolerated, ( - "no rule in rules.md carries a `tolerated:` marker; W3 has " - "carried one since 2026-09-01. If a demotion was reversed, " - "delete this guard in that commit rather than leaving it " - "asserting nothing") - in_corpus = {json.loads(line) for line in - (_TOOLS / "corpus_rules.jsonl") - .read_text(encoding="utf-8").splitlines()} - leaked = sorted(_tolerated_only_texts(rules) & in_corpus) - assert not leaked, ( - f"corpus_rules.jsonl carries example texts belonging only to " - f"tolerated rules {[r.rule_id for r in tolerated]}: {leaked}. " - f"A tolerated rule's examples illustrate current behavior; " - f"enforcing them against released baselines is the promise " - f"the marker withdrew") - - -def test_a_tolerated_rules_examples_are_still_watched_somewhere() -> None: - """Every text a tolerated rule alone carries is in SOME corpus the - harness loads, so it is still compared at every released baseline - -- the half of the demotion the marker did not withdraw. - - The failure this closes, verified by injection rather than - reasoned about (the negative control below runs it): a sixth comma - example added to W3 with no `tolerated` row in tests/v2/cases.py. - Nothing in the suite notices. The doc parses, test_rules_doc.py - executes the example and it passes, build_rules_corpus.py skips - the whole rule so the contract pin above stays equal, and - build_cjk_corpus.py projects the CASE TABLE -- it never sees a - name nobody wrote a row for. The text is executed at HEAD and - compared at no baseline, with every suite green. That is exactly - the state the demotion was written to avoid: "we still watch it, - we no longer enforce it" costs the watching, or it costs nothing - and means nothing. - - Membership is asked of the UNION of the corpora rather than of - corpus_cjk_tolerated.jsonl by name, because the union is what this - can honestly promise: the requirement is that the name reaches the - comparison, and which file carries it is the projection's business - (a future tolerated rule outside the CJK sections would have no - business in a CJK file at all). Today every one of them arrives - through corpus_cjk_tolerated.jsonl -- W3's four comma texts and, - since 2026-09-05, the trailing-period one W2 handed over, its - remaining example being W1's normative one and subtracted here -- - and - that file's equality with the case table is pinned separately by - test_tolerated_cjk_corpus_matches_the_case_table. - """ - from .rules_doc import parse_rules_doc - doc = (_TOOLS.parents[1] / "docs" / "design" / "rules.md").read_text( - encoding="utf-8") - rules = parse_rules_doc(doc) - demoted = _tolerated_only_texts(rules) - # Anti-vacuity, the same shape the guard above carries: with no - # marked rule, or with the subtraction eating everything, the - # assertion below is a truth about the empty set. - assert demoted, ( - "no rule in rules.md carries a `tolerated:` marker with an " - "example text of its own; W3 has carried at least four since " - "2026-09-01. If a demotion was reversed, delete this guard in " - "that commit rather than leaving it asserting nothing") - unwatched = sorted(demoted - set(_CORPUS_NAMES)) - assert not unwatched, ( - f"tolerated rules in rules.md carry example texts no corpus " - f"holds: {unwatched}. Each is executed at HEAD and compared " - f"at no baseline. Give it a `tolerated=True` row in " - f"tests/v2/cases.py and regenerate " - f"(`uv run python tools/differential/build_cjk_corpus.py`), or " - f"take the example out of the doc") - - # The negative control, run rather than described: the doc with - # one extra example on the tolerated rule -- a text no case row - # produces -- and the same question asked again. It must find it. - # Without this, a subtraction that quietly emptied `demoted` would - # leave the assertion above green forever. - probe = "조, 은우" - assert probe not in _CORPUS_NAMES, ( - f"{probe!r} was chosen because no corpus holds it; a row was " - f"added for it, so pick another string for the injection") - lines = doc.splitlines() - # Anchored on the marker line, not on one of the rule's example - # texts: W3's first example is a text W1 also carries, so a search - # by text lands in W1's block and injects a NORMATIVE example -- - # which the subtraction then removes, and the control passes by - # doing nothing. The marker is in the tolerated rule's block by - # definition. - at = next(i for i, line in enumerate(lines) - if re.match(r"^\s*tolerated:", line)) - lines.insert(at, f' "{probe}" → family="조"') - injected = _tolerated_only_texts(parse_rules_doc("\n".join(lines))) - assert sorted(injected - set(_CORPUS_NAMES)) == [probe] - - -def _corpus_texts(filename: str) -> set[str]: - """One corpus file's names, in whichever line format it uses.""" - return {_entry_name(json.loads(line)) for line in - (_TOOLS / filename).read_text(encoding="utf-8").splitlines() - if line.strip()} - - -def test_the_tolerated_corpus_is_disjoint_from_the_contract_ones() -> None: - """No text is in corpus_cjk_tolerated.jsonl and in a CONTRACT - corpus at the same time. - - Overlap does not error anywhere -- it reads as a demotion that did - not happen. compare.py's dedup loads contract files first and - keeps the contract reading, so a text both tiers hold is still - enforced: an unmatched diff on it fails the run, exactly as before - the flag was set. Meanwhile everything that describes it says - otherwise -- the case row says `tolerated`, README's tier table - says radar, the release notes group it as watched-not-enforced. - The name is enforced and documented as demoted, which is the one - combination nobody is reading for. - - _CORPUS_TIERS's own comment states the rule this asserts: a file - entry demotes the file, and a text some contract corpus also holds - reads contract no matter what the entry says. Five texts did on - the day the file was created; the same day's rules.md edits took - all five out of corpus_rules.jsonl. This keeps that true instead - of leaving it a fact about one afternoon. - - Either resolution is deliberate and neither is this test's to - pick: clear the `tolerated` flag (the name was never demoted), or - take the text out of the contract corpus that still holds it (it - was). The tier list is read from compare.py rather than copied, - so a new contract corpus is covered by existing here, not by - someone remembering to add it. - """ - tiers = load_tool("compare")._CORPUS_TIERS - contract = sorted(name for name, tier in tiers.items() - if tier == "contract") - assert len(contract) >= 3, ( - f"_CORPUS_TIERS lists {contract} as contract; corpus_cjk, " - f"corpus_rules and corpus_shapes have been contract since the " - f"#468 tier split. A shorter list means this guard is asking " - f"about fewer files than it reads as") - demoted = _corpus_texts("corpus_cjk_tolerated.jsonl") - assert demoted, "corpus_cjk_tolerated.jsonl is empty" - for filename in contract: - overlap = sorted(demoted & _corpus_texts(filename)) - assert not overlap, ( - f"{filename} (contract) and corpus_cjk_tolerated.jsonl " - f"(radar) both hold {overlap}. compare.py's dedup keeps " - f"the contract reading, so these names are still enforced " - f"at released baselines while their case rows, the README " - f"tier table and the release notes all call them demoted. " - f"Clear the `tolerated` flag, or take the text out of " - f"{filename}") - - -#: Which vocabulary constant each ledger rule's alternation is a hand -#: copy of. A roster rather than an inference: GLUED_HONORIFICS is a -#: SUBSET of SUFFIX_WORDS (asserted at the bottom of -#: nameparser/config/suffixes.py), so "equals one of the two known sets" -#: would let a spaced rule that silently narrowed to exactly the glued -#: set pass by matching the other member -- a subset check wearing a -#: disguise, and the subset direction is precisely the one that removal -#: drift travels in. -#: -#: Keys are matched as substrings of a rule's `issue` and must select -#: exactly one entry. The full issue lists are the keys, not a bare -#: '#308': both 2.0 rules cite #308 while copying different constants. -_HONORIFIC_SOURCES: dict[str, frozenset[str]] = { - "cjk-honorific-suffix": SUFFIX_WORDS, # 1.4, spaced - "cjk-glued-honorific-peel": GLUED_HONORIFICS, # 1.4, glued (#372) - "#307/#308/#320": SUFFIX_WORDS, # 2.0, spaced - "#308/#312/#319/#320": GLUED_HONORIFICS, # 2.0, glued -} - -#: An alternation group with two or more members and no nested -#: parentheses, capturing or not. Together with the classified-member -#: filter in _cjk_alternations it selects exactly the three honorific -#: alternations across both ledgers; on its own it also matches -#: "(?:^| )" from the 1.4 rule and the two Latin maiden/acronym -#: alternations, all of which the filter drops. -#: -#: The "(?:" alternative is spelled out and the plain "(" is guarded by -#: (?!\?) so that lookarounds -- "(?=$|[ ,])", "(?<=[^\s,])" -- do not -#: parse as alternations of their own syntax. Matching plain capturing -#: groups matters: a rule written "(씨|님|先生)" instead of -#: "(?:씨|님|先生)" would otherwise carry a hand copy this pin cannot -#: see, which is the silent-unpinning this module exists to prevent. -#: -#: Shapes it still cannot read correctly. All of them fail, but by two -#: different mechanisms, and the message you get depends on which -#: (every row measured): -#: -#: STALE half of the roster check -- the group stops matching, so the -#: rule's key goes unused: -#: a nested group whose inner members are UNclassified; -#: a paren inside a character class; -#: a single-member "group", which is not an alternation at all; -#: the named and inline-flag forms "(?Pa|b)" and "(?i:a|b)". -#: -#: Declared-vs-expected equality -- the group still matches, but its -#: members are MISread, since the split is a plain str.split("|"): -#: "|" inside a character class: "[a|b]" yields "[a" and "b]"; -#: a nested group whose inner members ARE classified: the inner -#: group is extracted and shows up as a member of the outer one. -_ALTERNATION = re.compile(r"\((?:\?:|(?!\?))((?:[^()|]+\|)+[^()|]+)\)") - - -def _alternations(name_regex: str) -> list[set[str]]: - """Every alternation group's members.""" - return [set(body.split("|")) for body in _ALTERNATION.findall(name_regex)] - - -def _cjk_alternations(name_regex: str) -> list[set[str]]: - """Every alternation in a rule with a script-classified member.""" - has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES) - return [members for members in _alternations(name_regex) - if any(has_classified(m) for m in members)] - - -def _copies_honorific_vocabulary(members: set[str], - sources: frozenset[str]) -> bool: - """Whether a classified alternation is a hand copy of `sources`: - some member, read as the regex it IS, FOLDS into the vocabulary -- - it fullmatches a classified entry, or that entry wearing exactly - one full stop. - - The stop is half the criterion because #323 is what taught the - parser to read through one: a hand copy spelled '(?:씨\\.|님\\.)' - drifts from the config exactly as a bare one does, and only the - period distinguishes them. Classified entries only, the same - script derivation the pin's expected set makes -- over the whole - of SUFFIX_WORDS a Latin member would fold on 'i' or 'v' and every - list of names would read as a copy. - """ - has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES) - entries = [entry for entry in sources if has_classified(entry)] - for member in members: - try: - pattern = re.compile(member) - except re.error as exc: - raise AssertionError( - f"CJK alternation member {member!r} is not a valid regex " - f"({exc}). A mis-split alternation can produce this -- " - f"see _ALTERNATION's notes") from None - if any(pattern.fullmatch(entry) - or any(pattern.fullmatch(entry + stop) for stop in FULL_STOPS) - for entry in entries): - return True - return False - - -def test_differential_honorific_rules_match_their_vocabulary() -> None: - """The honorific rules' alternations are hand copies of the CJK - entries of SUFFIX_WORDS (#307) and of GLUED_HONORIFICS (#308) -- - a toml cannot import them. Each expected set is DERIVED from the - config by script membership (a classified codepoint anywhere in the - entry), so adding a CJK honorific without widening the rule, or - widening a rule with something the vocabulary does not ship, fails - here. - - Swept over every ledger and every alternation, because the three - copies are anchored three different ways -- a leading '(?:^| )' in - 1.4, a character class and a lookbehind in 2.0 -- so the old - startswith/endswith parser could not read two of them. Discovery - plus a declared source is what replaces it. - - The span-bearing pins skip these rules on purpose: their trigger is - the alternation, not a character class. Note GLUED_HONORIFICS had no - pinned copy anywhere in the tree before this. - - Two completeness directions, both required. An alternation matching - no roster key fails as UNDECLARED, so a new rule's copy is pinned by - existing rather than by an author remembering to enroll it. A roster - key matching no rule fails as STALE, catching an entry left behind - after a rule was renamed or deleted. - - Since #322/#323 a classified member no longer implies a hand copy: - an alternation may be a list of CJK NAMES. Which alternations this - pin owns is therefore DERIVED, not declared -- a copy is one whose - members FOLD into the vocabulary (_copies_honorific_vocabulary), - and a rule folding nowhere is skipped. Skipping is not silence: an - alternation that folds and names no source still fails as - undeclared, and _NOT_A_VOCABULARY_COPY remains the escape hatch - for the residual case the fold cannot tell apart. - - The `assert found` below is non-vacuity over ALL the ledgers at - once, not per ledger: a baseline whose every CJK alternation folds - nowhere contributes nothing and the pin still passes on the - others. Today the count rests entirely on the 1.4.0 and 2.0.0 - copies -- two rules each -- the 2.1.0 and 2.2.0 ledgers carrying - only #322/#323's list of names. Delete those four and this pin - goes quiet without failing, which is what the global scope buys - and what it costs. - """ - has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES) - used: set[str] = set() - found = 0 - for ledger in _LEDGERS: - for rule in _rules(ledger): - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - for declared in _cjk_alternations(regex): - # The escape hatch the Latin twin has had since #350, - # shared with this pin: the alternation of NAMES whose - # members the fold cannot tell from a copy. Consulted - # before the fold, and empty of CJK sets today. - if frozenset(declared) in _NOT_A_VOCABULARY_COPY: - continue - keys = [k for k in _HONORIFIC_SOURCES if k in rule["issue"]] - # A rule naming no key is asked about the union: an - # undeclared copy has to be CAUGHT here and fail the - # assertion below, not skipped for want of a source. - sources = frozenset().union(*( - _HONORIFIC_SOURCES[k] for k in keys or _HONORIFIC_SOURCES)) - if not _copies_honorific_vocabulary(declared, sources): - continue # a list of CJK names (#322/#323) - assert len(keys) == 1, ( - f"{ledger.name}: rule {rule['issue']!r} carries a CJK " - f"alternation that folds into honorific vocabulary and " - f"matches {len(keys)} roster keys ({keys}); every such " - f"hand copy must name exactly one source in " - f"_HONORIFIC_SOURCES") - used.add(keys[0]) - found += 1 - expected = {entry for entry in _HONORIFIC_SOURCES[keys[0]] - if has_classified(entry)} - assert declared == expected, ( - f"{ledger.name}: {rule['issue']!r} declares " - f"{sorted(declared)}; the config's CJK entries for " - f"{keys[0]!r} are {sorted(expected)}") - assert found, ( - "no CJK honorific alternation found in any ledger; this pin is " - "passing vacuously") - assert used == set(_HONORIFIC_SOURCES), ( - f"_HONORIFIC_SOURCES keys matching no rule: " - f"{sorted(set(_HONORIFIC_SOURCES) - used)}. Either a renamed or " - f"deleted rule left its entry behind -- drop it -- or that " - f"rule's alternation stopped matching _ALTERNATION (a nested " - f"group, a paren inside a character class, or a lone member), " - f"which is how a still-present hand copy silently leaves this " - f"pin.") - - -def test_a_period_suffixed_hand_copy_still_reads_as_a_copy() -> None: - """The criterion above, driven from both sides: the adversary it - exists to catch and the rule it must not catch. - - The adversary is built FROM the config rather than spelled out, so - it cannot go stale the way a hand-written '(?:씨\\.|님\\.)' would - -- entries wearing a period are the shape a member-shape test - ("space or period means it is a name") would have waved through, - and the whole reason the criterion reads the fold instead.""" - glued = _HONORIFIC_SOURCES["cjk-glued-honorific-peel"] - has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES) - entries = sorted(entry for entry in glued if has_classified(entry))[:3] - assert len(entries) == 3 - assert _copies_honorific_vocabulary({rf"{e}\." for e in entries}, glued) - - # and the bundle's own rule, a list of names carrying periods and - # spaces of its own, folds nowhere - ledger, = (p for p in _LEDGERS if p.name == "expected_since_2.2.0.toml") - rules = [r for r in _rules(ledger) - if r["issue"].startswith("fix(#322/#323)")] - assert len(rules) == 1 - alternations = _cjk_alternations(rules[0]["name_regex"]) - assert len(alternations) == 1 - sources = frozenset().union(*_HONORIFIC_SOURCES.values()) - assert not _copies_honorific_vocabulary(alternations[0], sources) - - -@pytest.mark.parametrize(("ledger_name", "issue"), [ - ("expected_since_1.4.0.toml", - "fix(cjk-glued-honorific-peel) glued honorific peels into suffix"), - ("expected_since_2.0.0.toml", - "fix(#308/#312/#319/#320) glued CJK honorific peeled off the name " - "into suffix"), -]) -@pytest.mark.parametrize(("name", "glued"), [ - ("김민준씨", True), - ("김민준 박사님", False), - ("선생님", False), -]) -def test_the_glued_peel_rules_decline_an_interior_honorific( - ledger_name: str, issue: str, name: str, glued: bool) -> None: - """The 2026-09-05 narrowing, stated as the property rather than as - a regex both ledgers happen to spell the same way. - - A rule titled "glued honorific peeled off the name" may claim a - name whose honorific is GLUED and no other. 박사님, 선생님 and 교수님 - are the only listed entries containing another listed one, so a - match on the interior 님 was the one way a spaced form could reach - these rules -- and it reached two corpus names, '김민준 박사님' where - the honorific stands as its own whole word (rules.md#W2's second - sentence, witnessed there by suffix="박사님") and '선생님' where no - `suffix` moves at all. The three negative lookbehinds refuse the - interior position; '김민준씨' is the glued form they must leave - alone, and is here so a narrowing that went too far fails rather - than passing as a tightening. - - Hermetic on purpose: the shipped ledgers are read off disk and - _entry_matches is asked directly, so this holds at pytest speed - with no baseline wheel. _CORPUS_CLAIMS records the reach the same - narrowing produced (37 -> 35 in both), and the gate is what checks - where the name that changed hands landed. - """ - compare = load_tool("compare") - ledger = next(led for led in _LEDGERS if led.name == ledger_name) - rule = next(r for r in _rules(ledger) if r["issue"] == issue) - # the roles the rule declares, so `fields` cannot be what decides - # the answer: the question here is the name_regex alone - shape = set(rule["fields"]) - assert compare._entry_matches(rule, name, shape, None) is glued, ( - f"{ledger_name}: {issue!r} " - f"{'no longer claims' if glued else 'claims'} {name!r}. The " - f"rule's title says a GLUED honorific was peeled off the name; " - f"a spaced one reaching it is a false label, and a glued one " - f"escaping it is a narrowing that overshot") - - -class _LatinCopy(NamedTuple): - """A ledger alternation that hand-copies a Latin-script vocabulary. - - Two set-shaped fields that are emphatically not the same kind of - thing, which is why they are named rather than positional: - `vocabulary` is the source of truth, `covers` an audited snapshot of - which of its entries the rule's members reach. - """ - vocabulary: frozenset[str] - covers: frozenset[str] - - -#: Ledger rules whose alternation hand-copies a LATIN vocabulary, keyed -#: by a substring of the rule's `issue`. Kept apart from -#: _HONORIFIC_SOURCES because the relationship is not set equality: -#: these members are regex FRAGMENTS, not entries -- "n[ée]e" covers two -#: markers at once and "geb\.?" one -- so there is no set to compare -#: against. -#: -#: `covers` is recorded rather than equated to the whole vocabulary. -#: Equality would force a rule to grow alternatives for markers it has -#: no reason to claim -- see the note on fix(#274) in the 1.4 ledger for -#: why 旧姓 in particular is not one of them. Recording still catches -#: removal: drop an entry a member covers and the snapshot shrinks. -#: -#: Three nearby counts differ and are easy to conflate, all for -#: fix(#274) specifically, and every one of them moved in 2.2 -- twice, -#: so recount rather than adjust them: MAIDEN_MARKERS ships 17 entries -#: (roz left the vocabulary, z domu joined it); that rule's members -#: reach 3 of them; the corpora contain 5 markers in total (geb, nee, -#: née, z domu, 旧姓 -- nee arrived with #414's rules corpus and z domu -#: with #434's examples), 3 of which it covers. Counting z domu at all -#: needs the RUN reading _carries uses: it is two tokens, and a -#: per-token count cannot see it. -_LATIN_ALTERNATION_SOURCES: dict[str, _LatinCopy] = { - "fix(#274)": _LatinCopy( - vocabulary=MAIDEN_MARKERS, - covers=frozenset({"geb", "nee", "née"})), - "ambiguous-surname-acronym": _LatinCopy( - vocabulary=SUFFIX_ACRONYMS_AMBIGUOUS, - covers=frozenset({"do", "ma"})), - # fix(#412) copies CONJUNCTIONS partly on purpose: the two - # connectives the corpus names carry beside a marker. The rule - # names the join-swallow shape, not the vocabulary. - "fix(#412)": _LatinCopy( - vocabulary=CONJUNCTIONS, - covers=frozenset({"and", "y"})), - # The tussenvoegsel rule copies PARTICLES, and copies it PARTLY on - # purpose: it needs the words that actually end a Dutch, Iberian or - # German listing, not all 70. A wider copy would claim comma names - # whose trailing word is a particle nobody files a name with, which - # is the reach this roster exists to keep honest. - "fix(#379)": _LatinCopy( - vocabulary=PARTICLES, - covers=frozenset({"de", "del", "den", "der", "di", "do", "dos", - "du", "la", "le", "los", "mc", "van", "vd", - "von", "zu"})), - # The same partial copy as fix(#379), and partial for the same - # reason -- these are the words that actually open a chain in a - # Latin name. The two rules ask different questions of it (#379 - # wants the word that ENDS a comma listing, #399 the word that - # opens a chain running into a maiden marker), so the shared - # snapshot is agreement rather than duplication: a divergence - # would be a fact about one of the rules. - # The title half of the #367 rule, widened when three more titled - # names arrived (#413). Partial on purpose: these are the spellings - # that appear before `van` in the corpora, not every title that - # could -- the other titles before a particle in the corpora either - # have rules of their own or do not diff. 'jr' was a member until #296's audit took - # it out of TITLES (a postnominal only); 'Jr. Van Johnson' reads the - # same by the period-abbreviation inference and has its literal. - "fix(#367) a title no longer displaces a leading particle out of the leading position": - _LatinCopy(vocabulary=TITLES, - covers=frozenset({"mr", "dr", "sir"})), - "fix(#399)": _LatinCopy( - vocabulary=PARTICLES, - covers=frozenset({"de", "del", "den", "der", "di", "do", "dos", - "du", "la", "le", "los", "mc", "van", "vd", - "von", "zu"})), - "fix(N3) a nickname-led name with a trailing suffix keeps the suffix in `suffix`": - _LatinCopy(vocabulary=SUFFIX_WORDS, - covers=frozenset({"jr", "sr", "ii", "iii", "iv", "v"})), - # #451's credential-acronym rule, one of the four that replaced the - # fields-only fix(suffix-routing) catch-all. Two members, because - # 'Donald mc' and 'QC MP' are the two corpus names in that shape; - # partial for fix(#379)'s reason, SUFFIX_ACRONYMS running to - # hundreds of entries that nobody writes after a bare name word. - # - # This is the roster entry behind one of #451's three splits, and - # it forces that split only GIVEN THE ROSTER AS WRITTEN: the - # sibling rule's `jr` is a SUFFIX_WORDS entry, _LatinCopy carries - # exactly one `vocabulary`, and the test below asserts exactly one - # roster key per alternation -- so an alternation spanning both - # vocabularies cannot be pinned against either AS DECLARED HERE. - # `vocabulary` is a hand-supplied frozenset, though: declaring - # SUFFIX_WORDS | SUFFIX_ACRONYMS would let `(jr\.?|v|mp|mc)` pass - # the member checks, measured. Declining to write a union is a - # judgement -- an alternation should name one wordlist a reader can - # go and check -- and decisions.md records it as one rather than as - # a constraint. An earlier version of this comment said FORCED - # flatly and was walked back (#452 review). - "fix(suffix-routing) a two-token name ending in a credential acronym keeps it in `suffix`": - _LatinCopy(vocabulary=SUFFIX_ACRONYMS, - covers=frozenset({"mc", "mp"})), - # #484's per-word rules copy one vocabulary EACH, which is why the - # 98 names take three rules: the roster keys a rule to a single - # source. B1 is case-sensitive on purpose (its comment says why), - # so both spellings of `and` are members and cover one entry. - "a connective run initials": _LatinCopy( - vocabulary=CONJUNCTIONS, - covers=frozenset({"y", "e", "&", "and", "of", "the"})), - "a bound-given run initials": _LatinCopy( - vocabulary=BOUND_GIVEN_NAMES, - covers=frozenset({"abdul", "abu"})), - # Three entries, not the five the chain names: this roster keys ONE - # vocabulary per rule, and the rule's first draft carried a second - # alternation (`(?:\s+(?:der|la))?`) that this test would have had - # to pin against the same entry. It could not: `covers` is one set, - # and the two alternations cover disjoint halves of it. The draft - # group was inert -- same 107 names, same digest, with and without - # it -- so the ledger dropped it rather than the roster growing a - # second shape. The rule's comment records the measurement. - "a particle chain inside": _LatinCopy( - vocabulary=PARTICLES, - covers=frozenset({"van", "von", "de"})), -} - -#: Alternations that copy no vocabulary, so discovery must not demand a -#: source for them. Declared rather than inferred, on the principle -#: test_regex_sync.py's _SOURCES sets with its None entries: an undeclared alternation is a -#: question someone answers in writing, not something to skip past. -_NOT_A_VOCABULARY_COPY = frozenset({ - frozenset({"^", " "}), # the honorific rule's leading anchor - # the 2.0.0/2.1.0 C1-example rule's tail, widened by #613 to - # 'John Smith, Mr. and Mrs.': two spellings of one rule's names - frozenset({"\\s+and\\s+mrs\\.?", "\\s+jr\\.?"}), - # ... and the #296 'Dr.' rule there, widened by #613 to rules.md#C1's - # 'Ortega y Gasset, Dr.' example - frozenset({"john,?\\s+smith", "ortega\\s+y\\s+gasset"}), - # #603's two rules (2026-10-04), one alternative per corpus name: - # what selects them is the SHAPE -- a credential opening the part - # after the comma, a title word past the second -- and no wordlist. - frozenset({"Doe, PhD Jones", "John Smith, PhD Jones", - "John Smith, Ph\\. D\\. Jones", "Smith, RN - CRNA", - "Steven Hardman, RN - CRNA"}), - # ... and the title rule, without 'John, Smith, Dr.' where the - # 2.0.0 and 2.1.0 ledgers' #296 rule already claims that name - frozenset({"1 & 2, 3 4 5, Mr\\.", - "Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29", - "Eric H\\. Holder, Jr\\., Attorney General", - "Eric H\\. Holder, Jr\\., Secretary of State", - "John Smith, Jr\\., Lt\\.Gov\\.", - "Smith, John, Prof\\."}), - frozenset({"1 & 2, 3 4 5, Mr\\.", - "Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29", - "Eric H\\. Holder, Jr\\., Attorney General", - "Eric H\\. Holder, Jr\\., Secretary of State", - "John Smith, Jr\\., Lt\\.Gov\\.", - "John, Smith, Dr\\.", "Smith, John, Prof\\."}), - # The #533 review's two literal-anchored rules, one alternative - # per corpus name. Lists of names, not copies of any wordlist: - # what selects them is the SHAPE the clause's decline turns on -- - # no reader ahead of the member, or a join below the marker pass - # that would take it -- and no vocabulary decides that. The - # delimited trio is the same, keyed on the writer's brackets. - frozenset({"Jane Doe \\(nee Smith MA\\)", "Jane Doe \\(nee Smith Ma\\)", - "Jane Doe \\(nee Smith\\) MA"}), - # rules.md#M2's two delimiter examples (added with #538, read by - # the comma reading since #549), - # and rules.md#C1's Accepted example of a marker beside one (#549), - # one alternative per corpus name. A list of names, not a copy of - # any wordlist: what selects them is the doc's own choice of - # examples for a policy this corpus does not configure, so there - # is no vocabulary here for the alternation to drift from. - # fix(#604)'s three movers: rules.md#P7's bare boundary and the two - # rules.md#R4 case-repair examples, one alternative per corpus - # name. Names the doc chose, not a copy of the particle wordlist, - # so there is nothing for the alternation to drift from. - frozenset({"Juan Ó Pérez", "SEÁN Ó MURCHÚ", "ina binti navalamar"}), - # fix(#400)'s two openings: start-of-name or just after a family - # comma. `abd` joins forward on the given side wherever that side - # begins, and the alternation is over ANCHORS, not over words -- - # there is no vocabulary here to drift from. - frozenset({"^", ",\\s*"}), - # fix(#371)'s alternation holds the TAILS of the four corpus names - # that carry a leading 'Ph. D.' -- three tails plus the bare stem - # on the `?`. A list of names, not a copy of any wordlist. And the - # pair the rule turns on is not vocabulary at all: _vocab.PH and - # _vocab.D are fixed regexes, so there is no wordlist here for the - # alternation to drift from. - frozenset({" John Smith", " Van Johnson", ", Jr\\."}), - # #535's title-stop rule, one alternative per corpus name -- a - # list of names, not a copy of any wordlist: what selects them is - # the SHAPE the release check turns on (a name word left standing - # for the title chain to end behind, no join below the marker - # pass absorbing the released span), and no vocabulary decides - # that. Three sets, one per baseline's remaining membership after - # the review that split out every name whose diff from that - # baseline has an EARLIER cause too (each such name got its own - # joint-labelled rule instead, verified against the parent tree - # d9d80492): 1.4.0 is additionally relabelled fix(#274/#535), - # since v1 has no maiden reading at all and so differs from every - # remaining name for #274's reason as well as #535's, and loses - # 'Jane Doe nee Prof. Dr.' too (#274/#296, v1's missing maiden - # reading plus dr still being suffix vocabulary there); 2.0.0/2.1.0 - # lose 'Jane van der Berg nee Smith Prof.' (#399, a two-trailing- - # word marker clause that baseline's own particle-chain rule - # cannot yet reach), 'Jane Doe nee Prof. Dr.' (#296 alone there, - # dr leaving the postnominal vocabulary) and 'Jane Doe nee Smith - # Prof. MA' (#533, the credential the clause gives up); 2.2.0/2.3.0 - # lose only the last of those three, #296 and #399 both predating - # those baselines. 2.3.0 alone also carries 'Doe nee Smith ba - # Prof.', a rules.md#M2 Accepted example: at every earlier - # baseline its diff has #342's cause too ('ba' was a plain suffix - # word there) and it sits in a joint-labelled rule instead. - # #436/#437's Latin movers, one corpus name per alternative -- a - # list of names, not a copy of any wordlist, so there is no - # vocabulary for it to drift from. The rule's subject is the - # SEPARATOR between two post-nominals rather than which words are - # post-nominals, so the vocabularies it would otherwise be - # suspected of copying (SUFFIX_WORDS, SUFFIX_ACRONYMS) are not - # what selects these names: 'John Smith Mc V' is here on a - # particle that is also suffix vocabulary and 'Kenneth Clarke QC - # MP' on two acronyms, and a member matching either set would - # reach names that do not move. TWO sets since 2026-09-19 (#533): - # 'Jane Doe nee Smith PhD MA' joined the THREE 2.x spellings of - # that rule and not the 1.4.0 one, so the four ledgers no longer - # carry one identical set. The name is one more corpus name whose - # run those baselines write with a comma and the tree writes with - # a space; the SEPARATOR is still the subject, and the maiden - # clause in front of it decides nothing there -- #533 moves no - # role on it, measured against 2f57ff21. At 1.4.0 it does move - # roles, under a rule of that ledger's own. - frozenset({"JOHN DOE PHD MD", "John Doe MD PhD", - "John Smith MD PhD", "John Smith Mc V", - "Kenneth Clarke QC MP", "Smith, John PhD I\\.", - "The Rt Hon Kenneth Clarke QC MP, HMG", - "Washington Jr\\. MD, Franklin", "abdul Smith Jr Ma", - "abdul Smith Jr V"}), - frozenset({"JOHN DOE PHD MD", "Jane Doe nee Smith PhD MA", - "John Doe MD PhD", - "John Smith MD PhD", "John Smith Mc V", - "Kenneth Clarke QC MP", "Smith, John PhD I\\.", - "The Rt Hon Kenneth Clarke QC MP, HMG", - "Washington Jr\\. MD, Franklin", "abdul Smith Jr Ma", - "abdul Smith Jr V"}), - # #346's movers, one corpus name per alternative -- a list of - # names, not a copy of any wordlist, so there is no vocabulary for - # it to drift from. The rule's subject is a SHAPE the vocabulary - # participates in (a renunciate title plus exactly one name word), - # and a member copying GIVEN_NAME_TITLES would reach 'Swami - # Vivekananda Saraswati' and 'Guru Gobind Singh', which do not - # move. One set, identical in all four ledgers. - frozenset({"Baba Ramdev", "Guru Nanak", "Lama Zopa", - "Swami Vivekananda"}), - # #519's movers, one corpus name per alternative -- a list of - # names, not a copy of any wordlist. The rule's subject is a - # SHAPE the vocabulary participates in (a royal title plus - # exactly one name word), and a member copying GIVEN_NAME_TITLES - # would reach 'Prince Harry Windsor', which does not move. One - # set, identical in all four ledgers. - frozenset({"Her Royal Highness Princess Anne", "Prince Charles", - "Prince Fielder"}), - # #342's movers, one corpus name per alternative -- a list of - # names, not a copy of any wordlist, so there is no vocabulary - # for it to drift from. The rule's subject is what two REMOVED - # entries stop doing, and the vocabulary it would be suspected of - # copying (SUFFIX_ACRONYMS) no longer holds either word, so a - # member drawn from it could not name these names at all. A - # member spelled as the shape -- a trailing all-caps token -- - # would reach 'John Smith XYZ' and every future acronym - # regression. One set, identical in all four ledgers. - frozenset({"Ahmad Jayadi, CHA", "Aishwarya Rai", "John Smith RAI", - "John Smith, RAI", "Lala Lajpat Rai"}), - # #289/#516's movers, one corpus name per alternative -- lists of - # names, not copies of any wordlist, so there is no vocabulary for - # them to drift from. Five sets rather than one, because two of - # the three rules hold a name back at one baseline where an older - # rule already owns its diff (each ledger's own comment says which - # and why), and the report-only rule gains a name from 2.2.0 on. - # - # What selects every one of them is a SHAPE the vocabulary - # participates in only at one end: a trailing or post-comma member - # of SUFFIX_ACRONYMS_AMBIGUOUS read by WRITTEN CASE (#289), or a - # multi-chunk dotted token NO vocabulary claims at all (#516). A - # member copying SUFFIX_ACRONYMS_AMBIGUOUS would reach 'Jack Ma', - # 'JOHN SMITH MA', 'ANH DO' and 'anh van do', every one of which - # stays exactly as it read -- the case contrast is the whole - # subject, and a wordlist cannot spell it. A member spelled as the - # dotted shape would reach 'Jack M.A.', 'John Smith Ph.D.' and - # 'Doe, John Msc.Ed.', which the vocabulary answers for and this - # change does not touch. _MUST_NOT_MATCH carries both rosters. - frozenset({"Davis Royce, Ed", "Freiherr von Berg MA", "JOHN SMITH, MA", - "Jack MA", r"Jack MA\.", "Jack Wei Ma", "John Smith Ma", - "John Smith, Ed", "John Smith, MA", "John Smith, Ma", - "John de Ma", "John van der Berg Ma", r"Smith Jr\., MA", - r"Smith Jr\., Ma", "Smith, MA", "abdul Smith Berg Ma", - "abdul Smith Ma", "john smith, ma"}), - # 2026-10-01, #575: the 2.x copy gains eight C1 examples, comma - # names whose post-comma word is a listed member -- no set copied. - frozenset({"Davis Royce, Ed", "De La Cruz, Ed", r"Doe, Dr\. MA", - "Doe, MA", "Doe, MA PhD", r"Doe, Mr\. MA PhD", - "Freiherr von Berg MA", "Freiherr von Berg, Ed", - "JOHN SMITH, MA", "Jack MA", - r"Jack MA\.", "Jack Wei Ma", r"John Prof\. MA", - "John Smith Ma", "John Smith, Ed", "John Smith, MA", - "John Smith, Ma", "John de Ma", "John van Buren, Ed", - "John van der Berg Ma", "Ortega y Gasset, Ed", - r"Smith Jr\., MA", r"Smith Jr\., Ma", "Smith, MA", - "Van Buren, Ed", "abdul Salam, Ed", "abdul Smith Berg Ma", - "abdul Smith Ma", "de la Cruz, Ma", "john smith, ma", - "van der Berg, MA"}), - frozenset({r"García Márquez, G\.J\.R\.", r"Jack X\.Y\.I\.", - r"John Smith B\.Tech\.", r"John Smith C\.H\.A\.", - r"John Smith E\.S\.Q\.", r"John Smith Q\.W\.E\.R\.T\.", - r"John Smith X\.Y\.Z\.", r"John Smith, X\.Y\.Z\.", - r"Smith, E\.S\.Q\.", r"john smith x\.y\.z\."}), - # #563's paired-initials rule, literal-anchored the same way and - # for the same reason: what selects its names is two dotted single - # letters and what stands around them, which no wordlist spells. - # The 1.4.0 ledger lists the three role movers alone. - frozenset({r"De La Cruz, M\.J\. K\.L\.", r"García Márquez, PhD G\.J\.", - r"John Smith, PhD X\.Y\.", r"John Smith, X\.Y\. P\.Q\."}), - frozenset({r"De La Cruz, M\.J\. K\.L\.", - r"De La Cruz, M\.J\. PhD", r"García Márquez, G\.J", - r"García Márquez, G\.J\.", r"García Márquez, PhD G\.J\.", - r"John Smith, A\.B\.", r"John Smith, A\.B\. Ph\.D\.", - r"John Smith, PhD X\.Y\.", r"John Smith, X\.Y\. P\.Q\."}), - # #562's particle-chain rule, literal-anchored to its two movers: - # what selects them is two particles side by side in a comma - # credential run, which no wordlist spells. - frozenset({"John Smith, PhD DO DO", "John Smith, PhD vd DO"}), - frozenset({"Doe, MA Smith", r"J\.A\. K\.D\.", r"Jack X\.Y\.Z\.", - r"John Smith J\.u\.n\.i\.o\.r\.", r"John Smith R\.A\.I\.", - "Royce, Ed", r"Smith Jr\., A\.B\.", r"Smith, A\.B\.", - "Smith, Ma"}), - frozenset({"Doe, MA Smith", r"J\.A\. K\.D\.", r"Jack X\.Y\.Z\.", - r"John Smith J\.u\.n\.i\.o\.r\.", r"John Smith R\.A\.I\.", - "Royce, Ed", r"Smith Jr\., A\.B\.", r"Smith, A\.B\.", - "Smith, Ma", "Steven Hardman, MD, DO, DDS"}), - # #531's movers, one corpus name per alternative -- lists of - # names, not copies of any wordlist. Three sets: the thirteen the - # trailing slot moves, the four the WRITING declines (one set, and - # it is shared by two rules -- the 2.x report-only rule and the - # 1.4.0 fix(given-part-trailing-slot) rule, which hold the same - # four names for opposite halves of the same reading), and the - # five trailing-particle names the fix(#380) decision covers. - # - # What selects the first two is a SLOT the vocabulary participates - # in only at one end: a member of the ambiguous credential class - # ENDING the given part of a family-comma listing. A member - # copying SUFFIX_ACRONYMS_AMBIGUOUS would reach 'Doe, John MA - # Smith', where a name word behind the member ends the run, and - # the whole `do` family, which rules.md#P6 keeps -- every one of - # them measured not to move. _MUST_NOT_MATCH carries the probes. - # 2026-09-19, #533: two names joined that first set and it is - # fifteen. 'Doe, J. ba' and 'Smith nee Jones, Jane MA' are the - # same slot read the same way, and both read so at 2f57ff21 -- - # the corpus grew, the rule did not. Neither is a step toward a - # wordlist: 'ba' is already in the class the first thirteen draw - # on, and what selects the second is still the SLOT. - frozenset({"DOE, JOHN MA", "DOE, MARY JO MA", r"Doe, Dr\. John MA", - r"Doe, J\. MA", r"Doe, J\. ba", "Doe, John BA", - "Doe, John MA", "Doe, John MA JD", "Doe, John MA Jr", - "Doe, John MA PhD", "Doe, John PhD MA", - r"Doe, John Q\. MA", r"Doe, John X\.Y\.Z\.", - r"García Márquez, Ed G\.J\.", - "Smith nee Jones, Jane MA", "doe, john ma"}), - # 2026-09-30, #563: 'García Márquez, Ed G.J.' joins that set in - # every 2.x ledger -- the same slot, a by-shape member ending the - # given part, reached because a class member in front of a pair - # no longer flips the comma -- and the 1.4.0 ledger's - # single-name dotted rule becomes this two-name alternation. - frozenset({r"Doe, John X\.Y\.Z\.", r"García Márquez, Ed G\.J\."}), - frozenset({"Doe, John Ed", "Doe, John MA Ma", "Doe, John Ma", - "Doe, Mary Jo Ma"}), - # 2026-09-28: the 2.2.0 and 2.3.0 copies of the declining rule - # gain 'Smith, MD PhD Ma', the member ending a given part that a - # dual title opens; 1.4.0's fix(given-part-trailing-slot) keeps - # the four above. - frozenset({"Doe, John Ed", "Doe, John MA Ma", "Doe, John Ma", - "Doe, Mary Jo Ma", "Smith, MD PhD Ma"}), - # The third set. What selects these five is a WORD that is both - # particle and credential vocabulary standing last after a family - # comma -- `mc` and `do` -- and a member copying PARTICLES would - # reach every Dutch and Portuguese comma listing in the corpora, - # which is exactly the reach fix(#379)'s shape regex has and this - # rule deliberately does not. - frozenset({"Berg, Jan mc", "Doe, John Do", - "NASCIMENTO, EDSON ARANTES DO", - "Nascimento, Edson Arantes do", "SMITH, JOHN DO"}), - # A fourth set, from #531's fix round: the three 1.4.0 names whose - # member grouping JOINED into a multi-token piece before the slot - # could ask about it. A list of names, not a copy of any wordlist, - # and the vocabularies they touch could not select them anyway -- - # the piece is what the rule turns on, and a piece is not in the - # string. A member copying CONJUNCTIONS or PARTICLES would reach - # every comma listing carrying one, and a member copying - # SUFFIX_ACRONYMS_AMBIGUOUS would reach 'Doe, John MA', which the - # slot takes. _MUST_NOT_MATCH carries both probes. - frozenset({"Doe, John DO Ed", "Doe, John MA y", - "Doe, John van Ma"}), - # #449's movers, one corpus name per alternative -- a list of - # names, not a copy of any wordlist, so there is no vocabulary for - # it to drift from. What selects these names is a SHAPE the - # vocabulary only participates in negatively (a lone name word NO - # vocabulary claimed), and a member copying any wordlist would - # reach names that report nothing: 'abdul' is bound-given - # vocabulary and 'de' a particle, and neither moves. Eight members - # carry a trailing suffix -- 'Smith Jr.', "'Smitty' Jones Jr.", - # 'John V', 'Jack M.A.', 'Carod i', 'Donald mc', 'Mohamad X', and - # 'Wang M.Eng.' (joined 2026-09-27, #544) -- - # where the peel took the suffix and the convention placed the ONE - # name word left, so a suffix wordlist is not what this list copies - # either. One set, identical in all three 2.x ledgers. - frozenset({r"'Smitty' Jones Jr\.", "Andrew", "Carod i", - "Dean of Chemistry", "Donald mc", "Duke of Edinburgh", - "Duke of Wellington", "Garcia", r"Jack M\.A\.", - "John & Jane", "John V", "John of the Doe", - "Juan & Garcia", "Juan and Garcia", "Mohamad X", - "Smith", r"Smith Jr\.", r"Wang M\.Eng\.", "e and e", - "part1 of The part2 of the part3 and part4", - "part1 of and The part2 of the part3 And part4", - "test", "سلمان،"}), - # #491's title movers, one corpus name per alternative. A list of - # names, not a copy of TITLES: the rule's subject is a SHAPE the - # vocabulary participates in (the title peel leaves one name word - # and that word is in it), and a member copying the wordlist - # would reach 'Dr. Smith', 'Rabbi Cohen' and 'Mrs. Garcia', which - # do not move. One set, identical in all three 2.x ledgers. - frozenset({"Dr\\. King", "His Holiness", "His Holiness the Dalai Lama", - "Lord Chancellor", - "The Right Hon\\. the President of the Queen's Bench Division", - "The Rt Hon"}), - # #491's suffix movers. A list of names, not a copy of SUFFIX_WORDS - # or SUFFIX_ACRONYMS: a member copying either would reach every - # credential-bearing name in the corpus, and what selects these two - # is that no name word stood beside the credential. - frozenset({"QC MP", "Rinpoche"}), - # #491's join movers, reached by the #518 review round's hoist. A - # list of two names, not a copy of TITLES: a member copying the - # wordlist would reach 'Prince of Wales', which H3 chains into a - # title run, and every title-plus-surname name besides. What - # selects these two is a SHAPE -- a peeled title, then a joined - # unit standing last with title vocabulary inside it. - frozenset({"Attorney General of Minnesota", - "Deputy Secretary of State"}), - # fix(#445)'s movers, one corpus name per alternative -- a list of - # names, not a copy of any wordlist, so there is no vocabulary for - # it to drift from. Two sets because the ledgers group the nine - # movers differently. At 1.4.0 seven of them move the same four - # fields and share the first set; the eighth ('John née Jones - # Smith Ma') moves those plus `suffix`, and the ninth ('Smith - # (née Jones)') keeps the rule it already had. At 2.0.0/2.1.0 the - # second set holds the ones that move two fields: the first set - # less the two compounds it drops -- with #412 and with #424, each - # with a rule of its own -- plus that acronym name, whose acronym - # is no part of the diff at those baselines. Read the composition - # off the two literals below rather than off a count here. - # - # Spelled out rather than written as the shape -- one word, then a - # marker -- because that shape also matches rules.md#M4's two - # carve-outs, 'abd née Jones' and 'J. née Jones Smith V'. A rule - # covering those two while declaring `given` would stand ready to - # explain the very given -> family move they exist to prevent, - # which is the absorption these rosters exist to catch. - frozenset({"Janey n[ée]e Jones", "Jane n[ée]e Jones J\\. V", - "Jane n[ée]e Jones Smith", "Jane n[ée]e and Jones Smith", - "John n[ée]e Jones Smith V", "Smith n[ée]e Jones", - "Smith n[ée]e Jones PhD"}), - frozenset({"Janey n[ée]e Jones", "Jane n[ée]e Jones J\\. V", - "Jane n[ée]e Jones Smith", "John n[ée]e Jones Smith Ma", - "Smith n[ée]e Jones", "Smith n[ée]e Jones PhD"}), - # #451's roman-numeral rule copies _ROMAN -- the pattern in - # nameparser/_pipeline/_vocab.py that _vocab.is_trailing_numeral_suffix - # matches a final piece against -- and NOT a wordlist. There is no - # vocabulary here to drift from: 'x' is in none of SUFFIX_WORDS, - # SUFFIX_ACRONYMS or their ambiguous subsets, and 'Mohamad X' moves - # to `suffix` all the same, which is the whole reason the rule - # cannot be an entry in _LATIN_ALTERNATION_SOURCES. - # - # The members are _ROMAN's body with the EMPTY alternative removed - # (I{0,3} -> I{1,3}, plus the bare V that would otherwise be lost). - # Measured over every string of length 1..4 from IVXivx, the two - # accept the same strings apart from the empty one. Nothing pins - # the ledger's copy against _ROMAN: test_regex_sync pins _ROMAN - # against _config.REGEXES["roman_numeral"], which is the parser's - # two copies of it, and a hand copy in a toml is outside that pair. - # What holds this one is _CORPUS_CLAIMS' reach and digest plus the - # _MUST_NOT_MATCH probes, the same wall every other literal rule - # here stands behind. This roster records only that a wordlist is - # not what is copied. - frozenset({"X", "IX", "IV", "V?I{1,3}", "V"}), - # #484: the anchor groups of the `_initials` rules -- start-of-name - # or whitespace, start-of-name or whitespace-or-comma. Anchors, not - # words; the same reason as fix(#400)'s pair above. - frozenset({"^", "\\s"}), - frozenset({"^", "[\\s,]"}), - # fix(#385/#402)'s spellings: the corpus names whose - # all-particle part moved, listed because "a part of nothing but - # particles" is not a property a regex over the raw string can - # state. A list of names, the fix(#445) precedent -- that rule and - # fix(#410) and fix(#335) are the 1.4.0 ledger's literal lists. - # 'anh van mc' joined 2026-09-18 with its case row, and 'nguyen - # van lac' 2026-09-25 with #540's. - frozenset({"anh do", "smith van der", "yin le", "yin a le", "vai la", - "jong van der", "jong, van der", "juan van der", - "mesnil garcia de", "mesnil garcia van", "mesnil de", - "sander van", "van ma van", "anh van do", "anh van mc", - "beethoven ludwig van", "berg jan de jr\\.", "john van mc", - "jong anke de", "juan de", "ménil christophe de", - "ménil de", "nguyen thi van", "nguyen van lac", - "nguyen, van le", "van berg jan de"}), - # #540's two lists, one alternative per corpus name: the names - # whose family comes back and the names that keep the credential - # and gain a report. Lists of names, not copies of - # SUFFIX_ACRONYMS_AMBIGUOUS -- the rules' subject is which SLOT - # the member stands in, which no vocabulary decides, and a member - # spelled as the bare word would reach 'meng li' and - # 'john smith MEng'. - frozenset({"tran lac", "wang meng"}), - frozenset({"john smith meng", "nguyen van lac"}), - # #540's lone-word comma rule shares a literal prefix ('Smith, '), - # so its alternation is over the word's two spellings alone (the - # word reads as the given name here), not a full name pair like - # the two lists above -- 'meng'/'MEng' is not a copy of - # SUFFIX_ACRONYMS_AMBIGUOUS's bare 'meng' either, the same reason - # as the two lists above. - frozenset({"MEng", "meng"}), - # #540's second pair: the widened cost rule (a full name pair, - # the credential run behind it kept as a separate word) and the - # credential-run rule (a full name pair sharing no literal - # prefix, so its alternation IS over full names like the two - # lists above). Neither copies SUFFIX_ACRONYMS_AMBIGUOUS -- same - # reason as the rest of this roster. - frozenset({"John Smith MEng PhD", "john smith MEng"}), - # fix(#462)'s letter shape: a bare capital E/Y or a dotted E./Y. - # It is the initial SHAPE (v1's `initial` regex, _render._INITIAL) - # intersected with the single-letter conjunctions, not a copy of - # CONJUNCTIONS -- "e." is no entry, and matching it against the - # vocabulary would be a false claim of correspondence. - frozenset({"[EY]", "[EeYy]\\."}), - # #489's run movers, one corpus name per alternative -- a list of - # names, not a copy of GIVEN_NAME_TITLES. The rule's subject is a - # SHAPE the vocabulary participates in (a multi-word title run - # whose LAST word is a given-name title), and a member copying the - # wordlist would reach 'Sir John Smith' and 'Dr. Smith', which do - # not move -- the run has to be multi-word before the last word is - # asked about at all. One set, identical in all four ledgers. - frozenset({r"Dr\. Sir John", "Her Majesty Queen Elizabeth"}), - # #489's peel-floor movers, one corpus name per alternative -- a - # list of names, and NOT a copy of either wordlist the floor - # consults. A member copying TITLES would reach 'Marquess of Bath' - # and every titled name besides; a member copying SUFFIX_WORDS - # would reach 'MD DDS' and 'Jr. Ph. D.', which are the inputs the - # floor DECLINES. What selects these four is the shape: a title - # run with nothing but post-nominal pieces behind it, whose own - # last word is not one of those. One set, identical in all four - # ledgers. - frozenset({"Dr Jr", "Dr King Jr", r"Dr\. King MD", "Sir Jr"}), - # #316's trailing-title movers, one corpus name per alternative -- - # a list of names, not a copy of TITLES. The rule's subject is a - # SHAPE the vocabulary participates in (a trailing run of - # period-marked LISTED title words), and a member copying the - # wordlist would reach the BARE 'Mary Jane King' and 'John Smith - # Sir', which do not move, while a member spelled as the shape -- - # a trailing dotted word -- would reach 'John Smith Xyz.', the - # unlisted abbreviation this slot deliberately does not infer - # from. 'Mary Jane King\.' and 'Smith Sir\.' joined in the - # 2026-09-09 review of the docs commit, as rules.md examples: the - # first is the period-marked spelling of the very name the - # wordlist test above uses, which is the point of it -- the bare - # word is protected and the abbreviated one is not, and the member - # says so by carrying the period. 'Sir John Prof\.' and - # 'Dr\. Smith Sir\.' joined in the SECOND review round the same - # day, as the rules.md examples of H1's leading-run clause: both - # move `title` -- the walk takes the trailing word out of the name - # -- which is what puts them here rather than on the #489 run - # rule, whose `fields` reach no title role at all. 'Andrew Perkins \x28Mgr\.\x29' - # carries the ledger's \x28/\x29 spelling of the parentheses, - # without which this whole alternation would be invisible to - # _alternations above. One set, identical in all four ledgers. - frozenset({r"Andrew Perkins \x28Mgr\.\x29", r"Dr\. John Smith Prof\.", - r"Dr\. Smith Sir\.", r"JOHN PROF\. MA", r"John Prof\. MA", - r"John Smith Dr\.", - r"John Smith Jr\. Prof\.", r"John Smith Mr\.", - r"John Smith Prof\.", r"John Smith Prof\. Dr\.", - r"John Smith Prof\. Jr\.", r"John Smith Rev\.", - r"Mary Jane King\.", r"Sir John Prof\.", r"Smith Prof\.", - r"Smith Sir\.", r"Smith, John Prof\."}), - # NO CJK SET HERE TODAY, and the hatch is kept open for one - # residual: an alternation of NAMES in which some member happens - # to BE a shipped word -- a bare '양\.' would fold, 양 being - # SUFFIX_WORDS, where #322/#323's '양 지훈\.' and '양\. 지훈' do - # not. Nothing in the tree needs that today; which classified - # alternations the honorific pin owns is DERIVED instead, by - # _copies_honorific_vocabulary. - # - # WHY NOT A MEMBER-SHAPE TEST, which is what the derived criterion - # replaced an enumeration with. The obvious cheaper spelling is "a - # member carrying a space or a period is a literal name, so let it - # through" -- and it would equally have exempted - # '(?:씨\.|님\.|선생\.)', a period-suffixed hand copy of - # GLUED_HONORIFICS, from the pin that exists to keep such a copy - # in step with the config. That is the silent unpinning this - # module is written to prevent, and a shape test cannot tell the - # two apart, because the period is exactly what the bundle taught - # the parser to read THROUGH. The fold test can, which is what - # makes it stronger than either: the period-suffixed copy folds - # into the vocabulary and is caught (two of that adversary's three - # members fold, 선생 being no entry), while every member of - # #322/#323's own lists folds nowhere -- 0 of 11 and 0 of 17, - # measured 2026-09-10. - # - # WHAT A SKIP FORGOES, so nobody reads it as free, derived or - # declared. Both pins pass over the alternation, so its rule is - # checked by neither _unjustified_reach nor _top_level_alternation - # -- the #350 precedent, where the Latin twin's escape was - # introduced on the same terms. What stands in for them is - # per-rule and stronger for a list of names than a reach bound - # would be: _CORPUS_CLAIMS pins the exact set of corpus names the - # whole rule claims, with a digest, so a widened regex fails on - # the count or the digest, and _MUST_NOT_MATCH names the readings - # the bundle deliberately left alone. - # - # #383/#479's movers, one corpus name per alternative -- lists of - # names, not copies of CONJUNCTIONS. The rule's subject is a SHAPE - # the vocabulary participates in twice over: the name must be - # written wholly in one case, and the letter must be (or not be) a - # `conjunctions_ambiguous` member. A member drawn from either - # wordlist would reach every corpus name carrying a connective at - # all -- 'Juan y Eva Garcia', 'juan garcia y lopez', 'Rob And Beth - # Edmunds' -- and seventeen names are one-case with a cased - # single-letter connective while only ten of them move. Three sets - # because the ledgers group the movers differently: the role pair - # is the same in all five, and the report rule claims 'e and e' at - # 2.3.0 alone, feat(#449) having it at the three older baselines. - frozenset({"jose e maria santos", "JUAN GARCIA Y LOPEZ"}), - frozenset({"JOSE E MARIA SANTOS", "JOHN E SMITH", "john e smith", - "john e jones", "jones, john e", "e j smith"}), - frozenset({"JOSE E MARIA SANTOS", "JOHN E SMITH", "john e smith", - "john e jones", "jones, john e", "e j smith", - "e and e"}), - # #528's movers, one corpus name per alternative -- a list of - # names, not a copy of CONJUNCTIONS. The rule's subject is not - # expressible as a shape over the raw string at all: what moved is - # which READING the parse gave a letter, and the same four strings - # would be claimed by a connective-shaped member together with - # every other corpus name carrying a connective ('Juan y Eva - # Garcia', 'juan garcia y lopez', 'Rob And Beth Edmunds'). - # Measured 2026-09-13 over the corpus glob, driving the facade - # before and after #528: six one-case names' HumanName.initials() - # move for the TAG reason, and these are the four of them whose - # roles hold still. The other two, 'jose e maria santos' and - # 'JUAN GARCIA Y LOPEZ', move a role against 1.4.0 as well, so - # `_initials` never enters their diff and no `_initials` rule can - # list them. Scoped to that reason: facade initials that move - # against 1.4.0 for OTHER reasons -- 'e and e' and 'juan garcia y - # lopez', on the 2.0.0 per-word grouping -- are no part of the six. - # - # 2026-09-20, #461: 'JUAN Y GARCIA' LEFT the alternation and the - # set is three. Its 'Y' holds the middle part alone, so it - # initials again on both surfaces and gives the 1.4.0 wheel's - # string; the name has no diff left at that baseline for this - # rule to explain. Nothing about the other three moved, and the - # set is still a list of names for the reason above. - frozenset({"john e smith", "john e jones", "jones, john e"}), - # #533's rules, one corpus name per alternative -- lists of names, - # not copies of any wordlist. What selects every one of them is a - # SLOT the vocabulary participates in only at one end: a member of - # the ambiguous credential class ending a maiden marker's clause. - # A member copying SUFFIX_ACRONYMS_AMBIGUOUS would reach 'Jane Doe - # nee MA Smith', where a name word behind the member ends the - # clause, and 'Jane Doe nee Smith MA Prof.', where a title breaks - # the peel before it -- both measured not to move. - # _MUST_NOT_MATCH carries those probes and the rest. - # - # Six sets, and the pairs differ by BASELINE rather than by - # reading: what a rule claims is what its baseline can see - # (#452), so 'Doe, J. nee MA ba' is a report at 2.0.0 and a mover - # at 2.2.0, and 'John née Jones Smith MA' needs a compound rule of - # its own below 2.2.0 where #445's move is still in the diff. - # - # The movers, at 2.2.0, sixteen since #535 dropped 'Jane Doe nee - # Smith Prof. MA' -- the title chain now ends the clause before - # this rule's credential reading is asked, so the name moved to - # its own fix(#535) rule: - # and at 2.3.0, seventeen for the same reason plus one: 'Jane Doe - # nee King. ba' joins here too (the review's own floor boundary, - # this baseline never having split its credential at all). - # and at 2.0.0 and 2.1.0, thirteen for the same reason. - # The names the clause KEEPS and now reports, at 2.3.0 (eight), - # plus 'Doe nee Smith Prof. ba' (2026-09-26), rules.md#M2's - # Accepted example of a title in front of a kept credential, - # which #535 moves nothing on, - # and at 2.2.0 (nine): 'Jane Doe nee King. ba' joins here (the - # review's own floor boundary; unlike at 2.3.0, this baseline - # already split the credential, so only the report is new). - # and at 2.0.0 and 2.1.0 (nine): the review added 'Jane Doe nee - # King. ba' beside 'Doe, J. nee MA ba', the same FLOOR boundary. - # The two names where the released member lands somewhere other - # than the trailing peel. One set, shared by the 1.4.0 rule and - # the 2.2.0/2.3.0 one; at 2.0.0 and 2.1.0 the rule holds one name - # and has no alternation to declare. - # The 1.4.0 pair and the 1.4.0 halves. The clause KEEPS the member - # in seven of them ('Jane Doe Jr. nee Smith Ma' joined 2026-09-27, - # #544), which at that baseline is not a report but the - # `suffix` v1 read emptying; it gives the member up in four, whose - # runs v1 also wrote with commas. The two-member set is the - # narrowed fix(#424/#445) anchor, which holds the two spellings - # whose reading that rule describes and no longer reaches the - # third by (?i). - frozenset({"JOHN NEE JONES SMITH MA PHD", - "John n[ée]e Jones Smith Ma"}), - # 2026-09-20, #397: the #436/#437 rule's set grows at both the - # 1.4.0 ledger and the three 2.x ones, and the two stay separate - # for the reason the block above gives -- one comma-written - # generation run enters at 1.4.0 ('Josep Lluis Carod i III') and - # three at the 2.x baselines, 'Josep Lluis Carod i V' and 'Rovira, - # Josep Carod i Jr.' moving ROLES at 1.4.0 and so belonging to - # other rules there. Still a list of names and still about the - # SEPARATOR: the new members carry the roman numerals i/III/V and - # a jr, so a member copying SUFFIX_WORDS would reach every - # generation-bearing name in the corpora, most of which do not - # move. - frozenset({"Doe, Jane PhD MEng", "JOHN DOE PHD MD", r"Jane Doe Jr\. Ma", - "John Doe MD PhD", "John Smith MD PhD", "John Smith Mc V", - "John Smith PhD Ed Ma", "John Smith PhD MEng", - "John Smith PhD Ma", "Josep Lluis Carod i III", - "Kenneth Clarke QC MP", r"Smith, John PhD I\.", - "The Rt Hon Kenneth Clarke QC MP, HMG", - r"Washington Jr\. MD, Franklin", "abdul Smith Jr Ma", - "abdul Smith Jr V", "doe, jane v phd do"}), - # 2026-09-20, #397 review: one more name joins the 2.x set and not - # the 1.4.0 one, for the reason 'Jane Doe nee Smith PhD MA' did -- - # 'Jane Doe nee Puig i III' is a comma-written generation run - # those baselines share with the tree's spaced one, while at 1.4.0 - # its ROLES move too and a rule of that ledger's own owns it. - frozenset({"JOHN DOE PHD MD", r"Jane Doe Jr\. Ma", - "Jane Doe nee Puig i III", "Jane Doe nee Smith PhD MA", - "John Doe MD PhD", "John Smith MD PhD", "John Smith Mc V", - "John Smith PhD Ed Ma", "John Smith PhD Ma", - "Josep Lluis Carod i III", "Josep Lluis Carod i V", - "Kenneth Clarke QC MP", r"Rovira, Josep Carod i Jr\.", - r"Smith, John PhD I\.", "The Rt Hon Kenneth Clarke QC MP, HMG", - r"Washington Jr\. MD, Franklin", "abdul Smith Jr Ma", - "abdul Smith Jr V"}), - # #397's maiden-clause rule, one corpus name per alternative -- a - # list of names, not a copy of any wordlist. What selects the - # first two is the PLACEMENT of the link inside a clause, which no - # vocabulary decides; a member spelled as the shape (a bare letter - # after a maiden marker) would reach every clause name in the - # corpora and pre-excuse the readings the walk must refuse. The - # last two are rules.md#M2's two delimiter examples (added with #538), - # which the corpus parses at the DEFAULT policy and so for this - # rule's own sentence rather than for #538's. - # #397's join and its one-case report, one corpus name per - # alternative -- lists of names, not copies of any wordlist. The - # join's subject is a SHAPE the vocabulary participates in at one - # end only: a connective standing between two name words, where - # the connective is also generational vocabulary. A member copying - # CONJUNCTIONS or CONJUNCTIONS_AMBIGUOUS would reach every - # connective-bearing name in the corpora, and most of them -- - # 'Josep i Rovira' under the three-word carve-out, 'Carod i' and - # 'John Quincy Smith i' with nothing to the link's right -- do not - # move, which is the whole of #397's accepted cost. The report - # rule's three are the one-case spellings of the same names, and - # what selects THEM is the writing rather than any word. - # _MUST_NOT_MATCH carries both walls. The join set is identical in - # all five ledgers; the report set in the four 2.x ones, there - # being no `_ambiguities` to diff at 1.4.0. - frozenset({"Carod i Rovira, Josep", "Josep Carod i Rovira", - "Josep Carod i Rovira III", "Josep Carod i de Rovira", - "Josep Lluis Carod i Rovira"}), - frozenset({"JOSEP CAROD I ROVIRA", "JOSEP LLUIS CAROD I ROVIRA", - "josep carod i rovira"}), - # #461's two halves, one corpus name per alternative -- lists of - # names, not copies of any wordlist. Four sets, because each half - # holds a different population at different baselines and each - # ledger's own comment says which: the readmission half gains - # 'محمد و علي' from 2.0.0 (1.4.0 parity is RESTORED there, so it - # has no 1.4.0 member) and 'Juan y Garcia née Jones' from 2.2.0 - # (below that the maiden clause moves its roles), and the joining - # half gains nine names at 2.3.0 that the #449 report masks at - # every earlier baseline. - # - # What selects every member is a PART -- whether the connective - # has a name word beside it to join -- which no wordlist can - # spell and no regex over the raw string can state. A member - # copying CONJUNCTIONS would reach both halves at once, they - # being the same vocabulary read in two positions, and would also - # reach 'Jon Dough and', 'Juan Velasquez y Garcia' and 'Juan - # Garcia Lopez y', which do not move at all. The non-Latin - # members are spelled literally rather than as script spans for - # the same reason: the sentence is about a part, in any script, - # so a span would declare a scope the rule does not have - # (_SPAN_BEARING_RULES records that neither rule declares one). - frozenset({"Carod y de Rovira i", "Fritz Freiherr und von Bar", - "Garcia y Lopez", "John e Smith", "John e Smith III", - "John e Smith, III", "John y Jane", "Jose e Maria", - "Juan de y", "Juan y Garcia", "Lt\\.Gov\\. juan e garcia", - "Smith, John e, III, Jr", "john e jones, III", - "johnny y", "juan y garcia"}), - frozenset({"Carod y de Rovira i", "Fritz Freiherr und von Bar", - "Garcia y Lopez", "John e Smith", "John e Smith III", - "John e Smith, III", "John y Jane", "Jose e Maria", - "Juan de y", "Juan y Garcia", "Lt\\.Gov\\. juan e garcia", - "Smith, John e, III, Jr", "john e jones, III", - "johnny y", "juan y garcia", "محمد و علي"}), - frozenset({"Carod y de Rovira i", "Fritz Freiherr und von Bar", - "Garcia y Lopez", "John e Smith", "John e Smith III", - "John e Smith, III", "John y Jane", "Jose e Maria", - "Juan de y", "Juan y Garcia", "Juan y Garcia née Jones", - "Lt\\.Gov\\. juan e garcia", - "Smith, John e, III, Jr", "john e jones, III", - "johnny y", "juan y garcia", "محمد و علي"}), - frozenset({"Assoc Dean of Chemistry Robert Johns", - "Dean of Chemistry Robert Johns", - "John and Jane Aznar y Lopez", "John and Jane Smith", - "Jose e Maria Santos", "Juan y Eva Garcia", - "Mr\\. and Mrs\\. John and Jane Smith", - "Rob And Beth Edmunds", "and Jon Dough", - "the and Jon Dough", "ХОСЕ И МАРИЯ САНТОС", - "Хосе И Мария Сантос", "хосе и мария сантос"}), - frozenset({"Assoc Dean of Chemistry Robert Johns", - "Dean of Chemistry", "Dean of Chemistry Robert Johns", - "Duke of Edinburgh", "Duke of Wellington", - "John & Jane", "John and Jane Aznar y Lopez", - "John and Jane Smith", "John of the Doe", - "Jose e Maria Santos", "Juan & Garcia", - "Juan and Garcia", "Juan y Eva Garcia", - "Mr\\. and Mrs\\. John and Jane Smith", - "Rob And Beth Edmunds", "and Jon Dough", - "part1 of The part2 of the part3 and part4", - "part1 of and The part2 of the part3 And part4", - "the and Jon Dough", "ХОСЕ И МАРИЯ САНТОС", - "Хосе И Мария Сантос", "хосе и мария сантос"}), - # #544 (2026-09-27): its literal rules, one alternative per - # corpus name -- #544's own case rows and rules.md examples, - # selected by the SHAPE each rule reads (a comma run, a - # credential in front of a member, a clause boundary), which - # no wordlist decides. Two of the sets are 1.4.0 rules #544's - # names joined rather than #544's own, #544 moving no role on - # them against that baseline: the clause an unambiguous credential - # ended (fix(#274/#436/#437)), and the dotted spelling of a - # two-token name (fix(suffix-routing)), whose members are - # anchored on the name word and so copy no vocabulary. - frozenset({r"jack\s+m\.a\.", r"wang\s+m\.eng\."}), - # 2026-10-01, #564: the comma caps rule, literal names that copy no - # set, at 1.4.0 and in the 2.x copy. - frozenset({"García Márquez, MJ JK", "John Smith, LEED AP", - "John Smith, PhD XYZ", "John Smith, XYZ", - "The Rt Hon Kenneth Clarke QC MP, HMG"}), - frozenset({"Ahmad Jayadi, CHA", "García Márquez, MJ JK", - "John Smith, LEED AP", "John Smith, PhD XYZ", - "John Smith, RAI", "John Smith, XYZ", - "The Rt Hon Kenneth Clarke QC MP, HMG"}), - # 2026-10-01, #575: rules.md#C1's particle-surname examples at - # 1.4.0, literal names that copy no set. - frozenset({"De La Cruz, Ed", "Freiherr von Berg, Ed", "Van Buren, Ed", - "de la Cruz, Ma"}), - # 2026-10-02, #573: rules.md#P6 and S2 examples, one name in two - # casings each -- literal names that copy no set. - frozenset({"DOE, JANE MA VD PHD", "DOE, JANE PHD VD MA", - "doe, jane phd vd ma"}), - frozenset({"SMITH, VD DE LA MA", "SMITH, VD MA", "smith, de ma"}), - # 2026-10-01, #554: the run rule gains rules.md#C1's particle-and- - # suffix examples, a vocabulary word in front of a member, which - # copies no set either. - frozenset({"JOHN SMITH, VD MA", "John Smith, Ed Ma", - "John Smith, Jr do", "John Smith, Ms Ma", - "John Smith, PhD Ma", - r"John Smith, X\.Y\.Z\. MA", "John Smith, vd Ma", - "john smith, md ma"}), - frozenset({"Doe, Jane PhD MEng", "Doe, Jane nee Smith PhD MEng", - "Jane Doe nee Smith PhD MEng", "John Smith PhD MEng"}), - # 2026-09-28: the anchor rule at 2.2.0 and 2.3.0 gains 'Smith, PhD - # MEng', whose roles those baselines read; at 2.0.0 and 2.1.0 the - # name moves roles and is the one-word-family-comma pair's. - frozenset({"Doe, Jane PhD MEng", "Doe, Jane nee Smith PhD MEng", - "Jane Doe nee Smith PhD MEng", "John Smith PhD MEng", - "Smith, PhD MEng"}), - frozenset({"Smith, PhD MEng", "Smith, PhD Ma"}), - frozenset({"Jane Doe, MS LAc", "John Smith, MD MEng", - "John Smith, MEng PhD", "John Smith, PhD MEng", - "john smith, phd meng"}), - # #601/#602 (2026-10-04): the literal-anchored rules those two - # changes added or narrowed, one alternative per corpus name -- - # lists of names, not copies of any wordlist. - frozenset({'Berg, Jane van der nee Smith DO', 'Berg, abd née Jones', - 'Berg, abdul nee Jones MA', 'Doe, Dr\\. nee Smith MA', - 'Doe, J\\. nee MA ba', 'Doe, Jane Q\\. nee Smith MA', - 'Doe, Jane nee Puig i Soler', 'Doe, Jane nee Smith', - 'Doe, Jane nee Smith DO', 'Doe, Jane nee Smith Do', - 'Doe, Jane nee Smith MA', 'Doe, Jane nee Smith MA Prof\\.', - 'Doe, Jane nee Smith MA do', 'Doe, Jane nee Smith Ma', - 'Doe, Jane nee Smith PhD MEng', 'Doe, Jane nee Smith do', - 'Doe, Jane nee Smith ma'}), - frozenset({'Doe nee Smith Prof\\. ba', 'JOHN NEE JONES SMITH MA PHD', - 'Jane Doe nee MA', 'Jane Doe nee MA PhD', - 'Jane Doe nee Smith Ma', 'Jane Doe nee Yo-Yo Ma', - 'John née Jones Smith Ma'}), - frozenset({'Doe nee Smith ba Prof\\.', - 'Jane Doe \\x28nee Smith Prof\\.\\x29', - 'Jane Doe nee Prof\\. Dr\\.', 'Jane Doe nee Smith King\\.', - 'Jane Doe nee Smith MA Prof\\.', - 'Jane Doe nee Smith Ma Prof\\.', 'Jane Doe nee Smith Prof\\.', - 'Jane Doe nee Smith V Prof\\.', 'Mary Smith née Jones Prof\\.'}), - frozenset({'Doe, Jane nee Puig i Soler', 'Doe, Jane nee Smith Do', - 'Doe, Jane nee Smith MA Prof\\.', 'Doe, Jane nee Smith MA do', - 'Doe, Jane nee Smith Ma', 'Doe, Jane nee Smith do'}), - frozenset({'Dr\\. nee Smith PhD Prof\\.', 'Jane Doe Jr\\. nee Smith', - 'Jane Doe Jr\\. nee Smith Ma', 'Jane Doe PhD nee Smith'}), - frozenset({'Dr\\. nee Smith PhD Prof\\.', 'Jane Doe Jr\\. nee Smith', - 'Jane Doe Jr\\. nee Smith Ma', 'Jane Doe PhD nee Smith', - 'Jane and née Jones'}), - frozenset({'Eric H\\. Holder Jr\\. Attorney General', - 'Jane van der Berg née Jr Jones', - 'John Smith PhD Jones', 'John Smith PhD Prof\\. Ma', - 'Smith, John PhD Jones'}), - frozenset({'Eric H\\. Holder Jr\\. Attorney General', - 'Jane van der Berg née Jr Jones', - 'John Smith PhD Jones', 'Smith, John PhD Jones'}), - frozenset({'JANE DOE NEE SMITH MA', 'JANE DOE NEE YO-YO MA', - 'Jane Doe geb\\. Smith MA', 'Jane Doe nee King\\. ba', - 'Jane Doe nee Smith MA', 'Jane Doe nee Smith MA JD', - 'Jane Doe nee Smith MA PhD', 'Jane Doe nee Smith Ma JD', - 'Jane Doe nee Smith V MA', 'Jane Doe nee Smith do', - 'John née Jones Smith MA', 'Maria Kowalska z domu Nowak MA', - 'jane doe nee smith ma'}), - frozenset({'JANE DOE NEE SMITH MA', 'JANE DOE NEE YO-YO MA', - 'Jane Doe geb\\. Smith MA', 'Jane Doe nee Smith MA', - 'Jane Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', - 'Jane Doe nee Smith Ma JD', 'Jane Doe nee Smith V MA', - 'Jane Doe nee Smith do', 'John née Jones Smith MA', - 'Maria Kowalska z domu Nowak MA', 'jane doe nee smith ma'}), - frozenset({'JANE DOE NEE SMITH MA', 'JANE DOE NEE YO-YO MA', - 'Jane Doe geb\\. Smith MA', 'Jane Doe nee Smith MA', - 'Jane Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', - 'Jane Doe nee Smith Ma JD', 'Jane Doe nee Smith V MA', - 'Jane Doe nee Smith do', 'jane doe nee smith ma'}), - frozenset({'JOHN NEE JONES SMITH MA PHD', 'Jane Doe nee King\\. ba', - 'Jane Doe nee MA', 'Jane Doe nee MA PhD', - 'Jane Doe nee Smith Ma', 'Jane Doe nee Yo-Yo Ma', - 'John née Jones Smith Ma'}), - frozenset({'Jane Doe \\x28nee Smith Prof\\.\\x29', - 'Jane Doe nee Prof\\. Dr\\.', 'Jane Doe nee Smith King\\.', - 'Jane Doe nee Smith MA Prof\\.', - 'Jane Doe nee Smith Ma Prof\\.', 'Jane Doe nee Smith Prof\\.', - 'Jane Doe nee Smith V Prof\\.', 'Mary Smith née Jones Prof\\.'}), - frozenset({'Jane Doe \\x28nee Smith Prof\\.\\x29', - 'Jane Doe nee Smith King\\.', 'Jane Doe nee Smith MA Prof\\.', - 'Jane Doe nee Smith Ma Prof\\.', 'Jane Doe nee Smith Prof\\.', - 'Jane Doe nee Smith Prof\\. MA', 'Jane Doe nee Smith V Prof\\.', - 'Mary Smith née Jones Prof\\.'}), - frozenset({'Jane Doe \\x28nee Smith Prof\\.\\x29', - 'Jane Doe nee Smith King\\.', 'Jane Doe nee Smith MA Prof\\.', - 'Jane Doe nee Smith Ma Prof\\.', 'Jane Doe nee Smith Prof\\.', - 'Jane Doe nee Smith V Prof\\.', 'Mary Smith née Jones Prof\\.'}), - frozenset({'Jane Doe nee King\\. ba', 'Jane Doe nee MA', - 'Jane Doe nee MA PhD', 'Jane Doe nee Smith Ma', - 'Jane Doe nee Yo-Yo Ma'}), - frozenset({'Jane Doe nee MA', 'Jane Doe nee Smith Ma', - 'Jane Doe nee Yo-Yo Ma'}), - frozenset({'Jane Doe nee Puig i Soler', - 'Smith, John, PhD née Puig - i Soler', - 'Smith, John, PhD née Puig Mr\\. - i Soler'}), - frozenset({'Jane Doe nee Smith DO DO', 'Jane Doe nee Smith VI Prof\\.', - 'Jane van der Berg nee Prof\\. King\\. MA', - 'Jane van der Berg nee Smith Prof\\.', 'John nee Prof\\. ba MA'}), - frozenset({'Jane Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', - 'Jane Doe nee Smith Ma JD'}), - frozenset({'Jane Doe nee Smith PhD MA', 'Jane Doe nee Smith PhD MEng'}), - frozenset({'Smith, John, MD - née Jones Smith', - 'Smith, John, PhD née Jones'}), -}) - -def _unjustified_reach(name_regex: str, members: set[str]) -> list[str]: - """Corpus names the whole rule claims that none of its own members - reach. - - The member checks bound what each ALTERNATIVE matches. This bounds - the rule built around them, which is a different question and the - one four rounds of syntactic guards kept failing to ask. Nesting a - rule's own alternation one level down and adding a branch -- - "(?:[(\"'](m\\.?a\\.?|d\\.?o\\.?)[)\"']|[A-Za-z]{4,})" -- leaves every - member innocent, hides the pipe below the depth test, and hides the - outer group from _ALTERNATION, which refuses nested parens. It - claimed 622 of the corpus while every check passed. - - Keyed on the roster rather than on `fields`, which is what makes it - reach that rule: the acronym copy claims `suffix`/`nickname`, so - the field-keyed guard below never looks at it. - - A rule claiming nothing scores zero, which is the strongest answer - rather than a vacuous one -- so the non-vacuity assertion is over - the union of all rostered rules, not per rule. - """ - reachable = [re.compile(member, re.IGNORECASE) for member in members] - return [name for name in _claimed(name_regex) - if not any(pattern.search(name) for pattern in reachable)] - - -def _reaches_non_vocabulary(member: str, vocabulary: frozenset[str]) -> list[str]: - """Corpus text this member matches that is NOT a vocabulary entry. - - fullmatch against the vocabulary bounds what a member matches - WITHIN those entries and says nothing about what it matches in a - NAME. That gap was first filled with a tuple of eight hand-picked - probe strings, and the tuple WAS DEFEATED by a wider rule than the - one it was added to stop: every entry fix(#274) needs is three - characters (`geb`, `nee`, `née`), so a member must accept some - 3-character string and is unconstrained everywhere else -- - "[acdf-uw-z]{3,}" covers `roz`, dodged all eight probes, and as a - fourth alternative reached most of the corpus, far past what the - rule carrying it claimed. The figures that stood here were taken - against a corpus of 751 names and are not restated: the corpus has - since grown by half, the eight-string tuple this replaced is gone, - and the finding is the SHAPE -- a member unconstrained outside one - 3-character window walks past every probe anyone hand-picks -- - which is what the paragraph below acts on and which no count - strengthens. If a number is wanted, drive the member over - _CORPUS_NAMES with the IGNORECASE this function applies; the - case-sensitive figure is smaller and is not what runs. Counts here - and below are against _CORPUS_NAMES, which deduplicates the corpus - lines rather than counting them. - - Eight strings could never be more than a spot check. The corpus is - the whole population the rule will ever be asked about, so ask it - instead: every fragment a member matches must BE an entry. - - Searched unanchored, which is stricter than the rule's own \\b - anchoring and so cannot produce a false negative from it. - Normalized before comparison because the corpus writes "geb." where - the config stores "geb" -- without that, the shipped member fails. - Exempting only THIS vocabulary, not the union of all of them, keeps - a maiden member from being excused by an acronym entry. - """ - matches = set() - for name in _CORPUS_NAMES: - for found in re.finditer(member, name, re.IGNORECASE): - if found.group() and _normalize(found.group()) not in vocabulary: - matches.add(found.group()) - return sorted(matches) - - -def test_latin_alternations_mean_something_the_vocabulary_ships() -> None: - """The Latin twin of the honorific pin, shaped by what a regex - alternation over a vocabulary can honestly promise. - - Discovery-first, like its twin: every alternation in every ledger - must be a declared vocabulary copy or a declared non-copy. Keying - off the roster and skipping everything else would mean a future rule - that hand-copies a vocabulary under a new tag is pinned only if its - author remembers to enroll it -- the failure this module exists to - prevent, not a shape it should adopt. - - Members are matched against entries as regexes rather than compared - as strings, because the members ARE regex syntax: "geb\\.?" stands - for the entry "geb", which the config stores normalized (lowercase, - no trailing period). - - Two ways a MEMBER can be wrong, checked per member: it can match - nothing in the vocabulary -- then it cannot describe a real change - and can only claim other names' diffs, which is how "born" survived - from the harness's first commit to #350 -- or it can reach corpus - text that is not vocabulary. A third assertion is about the RULE - around them, which can widen at depth 0 and leave the pinned - alternation governing one branch of an unchecked whole. An invalid - member raises rather than asserts, since nothing else can proceed. - """ - has_classified = _policy._script_matcher(*_policy._SCRIPT_RANGES) - used: set[str] = set() - found = 0 - reach_checked = 0 - for ledger in _LEDGERS: - for rule in _rules(ledger): - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - for members in _alternations(regex): - if any(has_classified(m) for m in members): - # An UNDECLARED classified alternation is the - # honorific pin's; a DECLARED one is skipped by - # both pins, and the roster is where its answer is - # written. Either way it is not this pin's, so the - # order of these two tests does not matter. - continue - if frozenset(members) in _NOT_A_VOCABULARY_COPY: - continue - found += 1 - keys = [k for k in _LATIN_ALTERNATION_SOURCES - if k in rule["issue"]] - assert len(keys) == 1, ( - f"{ledger.name}: {rule['issue']!r} carries the Latin " - f"alternation {sorted(members)}, which matches " - f"{len(keys)} roster keys ({keys}). Declare the " - f"vocabulary it copies in _LATIN_ALTERNATION_SOURCES, " - f"or add it to _NOT_A_VOCABULARY_COPY if it copies " - f"nothing") - used.add(keys[0]) - source = _LATIN_ALTERNATION_SOURCES[keys[0]] - unjustified = _unjustified_reach(regex, members) - assert not unjustified, ( - f"{ledger.name}: {rule['issue']!r} claims " - f"{len(unjustified)} corpus names that none of its own " - f"vocabulary members reach, e.g. {unjustified[:3]}. The " - f"members are innocent and the rule around them is not " - f"-- whatever it matches beyond them, it claims") - reach_checked += len(_claimed(regex)) - assert not _top_level_alternation(regex), ( - f"{ledger.name}: {rule['issue']!r} has a '|' at depth " - f"0, so the pinned alternation governs only one branch " - f"and the rest of the rule is unchecked. Wrap it in " - f"'(?:...)'") - covered = set() - for member in members: - try: - pattern = re.compile(member, re.IGNORECASE) - except re.error as exc: - raise AssertionError( - f"{ledger.name}: {rule['issue']!r} member " - f"{member!r} is not a valid regex ({exc}). A " - f"mis-split alternation can produce this -- see " - f"_ALTERNATION's notes") from None - matched = {entry for entry in source.vocabulary - if pattern.fullmatch(entry)} - loose = _reaches_non_vocabulary(member, source.vocabulary) - assert not loose, ( - f"{ledger.name}: {rule['issue']!r} member " - f"{member!r} matches corpus text that is not " - f"vocabulary: {loose[:6]}. Spell it literally -- a " - f"member broad enough to reach a real name lets the " - f"rule claim that name's diff as intended") - assert matched, ( - f"{ledger.name}: {rule['issue']!r} offers the " - f"alternative {member!r}, which matches no entry in " - f"the vocabulary it copies. It cannot correspond to " - f"a real change, so it can only claim other names' " - f"diffs as intended -- drop it, or ship it as " - f"vocabulary first") - covered |= matched - assert covered == source.covers, ( - f"{ledger.name}: {rule['issue']!r} covers " - f"{sorted(covered)}; recorded {sorted(source.covers)}. " - f"Lost: {sorted(source.covers - covered)} (an entry the " - f"rule relied on left the vocabulary). Gained: " - f"{sorted(covered - source.covers)} (record it)") - assert found, ( - "no Latin vocabulary alternation found in any ledger; this pin is " - "passing vacuously") - assert reach_checked, ( - "no rostered rule claims any corpus name, so the reach check above " - "measured nothing -- verify the corpora loaded") - assert used == set(_LATIN_ALTERNATION_SOURCES), ( - f"_LATIN_ALTERNATION_SOURCES keys matching no rule: " - f"{sorted(set(_LATIN_ALTERNATION_SOURCES) - used)}") - - -#: A role a rule can claim, mapped to the vocabulary a name must carry -#: for that claim to be possible. Keyed on `fields` rather than on the -#: regex, which is the point: the two pins above discover hand copies -#: by their SYNTAX, so a copy that is not written as an alternation or -#: a character class is not undeclared, it is unseen. A brand-new rule -#: whose name_regex is "(?i)\b[a-z]{3}\b" with maiden fields passed -#: every check in this module while claiming 308 corpus names. -#: -#: `fields` cannot be dodged the same way. A rule that does not claim -#: `maiden` cannot be labelled a maiden change at all, so keying here -#: reaches every spelling -- including fix(cjk-maiden-marker), whose -#: regex is the bare literal "旧姓" and which no roster in this module -#: could see. -#: -#: Only `maiden` today, because only its vocabulary is small and -#: mandatory enough for the implication to hold: a maiden diff needs a -#: marker in the name. `suffix` and `title` are not like that -- most -#: of their diffs come from routing, not from a vocabulary word being -#: present -- so adding them would be false rather than strict. -_FIELD_VOCABULARIES: dict[str, frozenset[str]] = { - "maiden": MAIDEN_MARKERS, -} - - -def _carries(name: str, vocabulary: frozenset[str]) -> bool: - """Whether a name contains a vocabulary entry. - - Whole-token for ASCII entries, substring for the rest, because a - marker like 旧姓 is written against the name it marks rather than - spaced off it. - - Note what the isascii() split actually covers: EVERY non-ASCII - entry, not only the CJK one -- the great majority of - MAIDEN_MARKERS, and the predicate below is the enumeration, so - read it there rather than from a count here, which said 12 of 16 - while the vocabulary shipped 17. `né` is two characters, so the - substring branch reads `René` as carrying a marker. Every - over-match here SHRINKS the set of unexplained names and so - weakens the guard -- the direction this module exists to close -- - but only two corpus names DEPEND on that branch today, meaning the - token test below says no and the substring test says yes, and both - are 旧姓 ones: the fullwidth-bracketed clause and the - fullwidth-colon spelling. Eight depended on it before the - delimiter strip below arrived (2026-08-26); that strip moved the - parenthesized née names onto the token branch, where the answer - does not rest on a substring. Both figures quantify over the - corpus and go stale on any row added to it, so recount rather than - adjust: - The body must sit flush left: `python -c` compiles it as a module, - so an indented first line raises IndentationError on paste. - -uv run python -c " -import glob, json -from nameparser import DEFAULT_NICKNAME_DELIMITERS as D -from nameparser.config.maiden_markers import MAIDEN_MARKERS as V -from nameparser._lexicon import _normalize -def _n(x): return x if isinstance(x, str) else x['name'] -names = {_n(json.loads(l)) for f in glob.glob('tools/differential/corpus*.jsonl') if not f.endswith('corpus_cldr.jsonl') for l in open(f, encoding='utf-8') if l.strip()} -strip = ''.join({c for p in D for c in p}) -sub = lambda n: any(e in n for e in V if not e.isascii()) -print(sum(not {_normalize(t.strip(strip)) for t in n.split()} & V and sub(n) for n in names), - sum(not {_normalize(t) for t in n.split()} & V and sub(n) for n in names))" - Tighten this before admitting a vocabulary whose short non-ASCII - entries occur inside ordinary names. - - Delimiter characters come off the token before the membership - test, because a marker glued to a bracket is still a marker to the - parser: rules.md#M3 reads '(geb. Schmidt)' as a maiden clause on - the strength of that very word, and _normalize strips the - abbreviating period but not the paren, so 'Anna Müller (geb. - Schmidt)' read as carrying no maiden vocabulary at all. This - direction is the safe one -- an unstripped token cannot be a - vocabulary entry, so the strip only ever finds markers that are - really there, and the names it rescues are exactly the ones a - maiden rule may legitimately claim. - """ - delimiters = "".join({ch for pair in DEFAULT_NICKNAME_DELIMITERS - for ch in pair}) - tokens = [_normalize(token.strip(delimiters)) for token in name.split()] - # A vocabulary may hold PHRASE entries ('z domu'), which no set of - # single tokens can contain: 'Maria Kowalska z domu Nowak' carries - # a marker and not one of its words is one. So the membership test - # runs over every consecutive token RUN up to the longest entry -- - # which is the single tokens themselves when no entry is a phrase, - # leaving every count above unchanged. Still textual and still - # wider than the parser (no longest-first, no fold-away rule): - # tools/differential/README.md says that is this helper's job. - longest = max((entry.count(" ") + 1 for entry in vocabulary), default=1) - runs = {" ".join(tokens[i:i + n]) - for n in range(1, longest + 1) - for i in range(len(tokens) - n + 1)} - return bool(runs & vocabulary) or any( - entry in name for entry in vocabulary if not entry.isascii()) - - -def test_rules_claiming_a_vocabulary_role_need_the_vocabulary_present() -> None: - """The guard that does not care how a rule is spelled. - - Every pin above starts from regex syntax -- an alternation, a - character class, a span. Each closed the hole it was built for and - left the next spelling open, four rounds running. This one starts - from what the rule CLAIMS: if a rule says a diff is a maiden-marker - change, then every corpus name it claims must actually carry a - maiden marker. A rule cannot escape that by changing notation, - because it is not reading the notation. - - Deliberately narrow. It does not say the rule is correct, only that - it cannot be explaining a marker on a name that has none. That - covers the maiden widenings review found; the ones claiming - `nickname` and `suffix` fall outside it, and _CORPUS_CLAIMS is - what catches those. - """ - checked = 0 - for ledger in _LEDGERS: - for rule in _rules(ledger): - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - for field, vocabulary in _FIELD_VOCABULARIES.items(): - if field not in (rule.get("fields") or []): - continue - checked += 1 - bare = [name for name in _claimed(regex) - if not _carries(name, vocabulary)] - assert not bare, ( - f"{ledger.name}: {rule['issue']!r} claims {field!r} " - f"diffs on {len(bare)} corpus names carrying no {field} " - f"vocabulary, e.g. {bare[:3]}. It cannot be explaining a " - f"marker that is not there, so on those names it would " - f"absorb a regression instead") - assert checked, ( - "no rule claims a role in _FIELD_VOCABULARIES; this pin is passing " - "vacuously") - - -class _Claim(NamedTuple): - """What a rule claims, in the three dimensions classify() uses. - - A count alone is identity-free -- the weakness this module rejects - at _SPAN_BEARING_RULES -- and review proved it here twice. Swapping - feat(#273)'s delimiter class for a single accented letter holds the - count at 6 while claiming six entirely different names. And - classify() narrows on `fields` as well as `name_regex`, so widening - a rule's roles moves nothing a regex-only count can see: the - comma rule kept its `,` regex and its 236 names while going from - explaining 6 of the corpus to 242. - """ - #: corpus names the name_regex reaches. The whole corpus when a - #: rule has none -- a shape validate_rules REJECTED in #451, so no - #: ledger can carry one now; the fallback stays because this - #: module reads ledgers without validating them first - names: int - #: the roles it narrows by, sorted; () when it narrows by regex alone - roles: tuple[str, ...] - #: sha256[:12] of the claimed names, so a swap that holds the count - #: still fails. Unreadable by design -- the count above is what a - #: reviewer reads, and the failure message prints what moved. - digest: str - #: the comparison orders it narrows by, sorted; None when it carries - #: no `orders` key at all. The three above are all blind to the - #: third narrowing: deleting `orders` from a shipped rule widens it - #: to every order while its regex reaches the same names, narrows by - #: the same roles, and digests identically. No default, so a new - #: entry has to state which one it is. - orders: tuple[str, ...] | None - - -def _claim(rule: dict) -> _Claim: - regex = rule.get("name_regex") - names = (_claimed(regex) if isinstance(regex, str) else list(_CORPUS_NAMES)) - fields = rule.get("fields") - orders = rule.get("orders") - return _Claim( - names=len(names), - roles=tuple(sorted(fields)) if isinstance(fields, list) else (), - digest=hashlib.sha256( - "\n".join(names).encode("utf-8")).hexdigest()[:12], - orders=tuple(sorted(orders)) if isinstance(orders, list) else None) - - -#: How many corpus names each rule's name_regex matches, per ledger. -#: -#: The backstop the other guards each failed to be. Every one of them -#: is scoped to a CATEGORY -- a span class, an alternation's members, a -#: `maiden` field, a member's reach -- and five review rounds each found -#: a rule outside whichever category the last fix had covered. The -#: categories are the test's, not the ledger's. What every rule shares -#: is what it claims: how much corpus its regex reaches, which roles -#: it narrows by, which comparison ORDERS it admits, and WHICH names -#: those are. The orders entry closes a widening none of the other -#: three can see: deleting `orders` from a shipped rule returns it to -#: claiming every order while its reach, its roles and its digest all -#: stand still. Scoped to the corpus -- -#: and only there -- a widening cannot change what a rule explains -#: without moving one of the four. Restoring 'born' moves none of -#: them, because no corpus name contains it; the member guards catch -#: that, which is why this does not replace them. -#: -#: So this is deliberately dumb: it knows nothing about vocabularies, -#: scripts or roles, and it cannot say whether a number is RIGHT. It -#: says only that it moved, which is the question a widening cannot -#: dodge. The specific guards above stay because they explain WHY a -#: rule may claim what it claims, and their messages are the ones worth -#: reading; this one just refuses to let the number change quietly. -#: -#: Keyed by the full `issue` rather than by tag: the 1.4 ledger has two -#: rules tagged feat(#269), and a tag-keyed roster cannot tell them -#: apart -- the same identity-free weakness recorded at -#: _SPAN_BEARING_RULES. -#: -#: These numbers move when the CORPORA move, not only when a rule does. -#: That is the intended cost: a corpus name added under an existing -#: rule is a real change in what that rule explains, and it should be -#: read once rather than absorbed silently. -#: Seventeen of these moved at once when corpus_rules.jsonl landed -#: (#414) and fourteen more were added, which is a lot of re-recording -#: to review. The figure read "Twenty" until #452 measured it: parse -#: this roster out of `7a10689^` and `7a10689` with ast and diff the -#: per-entry dumps -- 30 entries before, 44 after, 17 changed. The same -#: three ways (that commit, the whole of PR #415, the backtick-harvest -#: commit after it) all give 17. The jumps are all -#: one cause -- 113 names arriving -- and the ones worth naming are -#: the broad rules: the fields-only catch-alls grew to the whole -#: corpus (751 -> 864), the comma rules 215 -> 236, fix(#274) 4 -> 11 -#: and fix(#379) 2 -> 8. What made each jump reviewable was not this -#: record but the two guards beside it: the vocabulary-presence guard -#: rejected fix(#274)'s first grown reach outright, and the -#: member-reach guard rejected the acronym rule's. A jump that passes -#: both is growth into names the rule genuinely describes. -_CORPUS_CLAIMS: dict[str, dict[str, _Claim]] = { - "expected_since_1.4.0.toml": { - # The ph removal (#459/#521): one literal name, the cases.py - # row written to carry the bare trailing `Ph.` shape, since no - # corpus name had it -- which is why the removal measured as - # zero role movers at review time. - "change(suffix-acronym-collisions) ph leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's rules, four at landing (2026-09-25) and three since - # #544 (2026-09-27) retired the comma-run one, the only #540 - # rules this baseline needs - # (every one of them either an accepted cost or the gain that - # is its mirror): the mixed-case credential read as the name - # even with words to spare, no-comma (now also the credential - # run behind it, widened in) and after a family comma; the - # credential run behind a suffix comma re-reading it as a - # family comma; and the Title-case Lac gain. Roles without - # `_ambiguities`, which 1.4.0 does not compare. - "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(2, ('family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - _Claim(1, ('middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'John Smith, PhD MEng', 'john smith, phd meng'. - "fix(#540) a Title-case Lac behind a particle is the family name": - _Claim(1, ('family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), - # #436/#437's Latin alternation, first in every ledger. - # Ten corpus names, `suffix` alone: the rule moves the - # SEPARATOR and no role, so a widening that took a role would - # change the row here before it reached the gate. - # 2026-09-20, #397: 10 -> 11, one new comma-written - # generation run ('Josep Lluis Carod i III'), which #397 - # leaves byte-identical -- measured against the parent - # 46651750 -- and which arrived with that change's own case - # rows. Roles unmoved, `suffix` alone as before. - # 2026-09-27, #544: 11 -> 17; gains 'Doe, Jane PhD MEng', - # 'Jane Doe Jr. Ma', 'John Smith PhD Ed Ma', 'John Smith PhD - # MEng' and 2 more. - # 2026-10-04, #601/#602: 17 -> 16; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": - _Claim(16, ('suffix',), "b171e45499c3", None), - # #346's alternation. Four corpus names, `family` and - # `given` together: the fold moves both roles at once, so a - # widening taking one alone would change the roles here - # before it reached the gate. - "fix(#346) a renunciate title and one name word leave the name a given name": - _Claim(4, ('family', 'given'), "a3399ee7b21e", None), - # #519's alternation. Three corpus names, `family` and `given` - # together: the fold moves both roles at once, so a widening - # taking one alone would change the roles here before it - # reached the gate. - "fix(#519) prince and princess join the given-name titles": - _Claim(3, ('family', 'given'), "ca37331f9803", None), - # #342's alternation. Five corpus names and four roles: the - # comma forms move `given` where the bare forms move `middle`, - # so a widening taking one shape alone would change the roles - # here before it reached the gate. Reach, not explanation -- - # at this baseline 'Aishwarya Rai' reaches the rule and is - # explained by parity instead, so the gate heading reads 4. - "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word": - # 2026-10-01, #564: `given` leaves the roles -- the comma pair - # reads suffix again by its capitals, so no name here moves it. - _Claim(5, ('family', 'middle', 'suffix'), "c3d76812da97", None), - "fix(A2) content-free input names nobody, so every role empties": - _Claim(5, ('given',), "1af8d718688b", None), - "fix(#335) a marker-led clause leaves the one name word its bare reading": - _Claim(1, ('maiden', 'nickname'), "c09cc7dba88b", None), - "fix(#434) a multi-word maiden marker takes the maiden name": - _Claim(1, ('family', 'maiden', 'middle'), "c428798fc6ef", None), - "fix(#434) a multi-word marker leads a bracketed clause to the maiden name": - _Claim(1, ('maiden', 'nickname'), "0b3ef183f283", None), - "fix(#335) a marker-led bracketed clause reads as the maiden name whatever pair encloses it": - _Claim(5, ('maiden', 'nickname'), "a419f74143e3", None), - "fix(#410) a title and one name word name the family, whatever annotation stands beside it": - _Claim(3, ('family', 'given'), "24d6223e472f", None), - "fix(#410) the maiden flavor, where 1.4.0 read the marker as a middle name": - _Claim(1, ('family', 'given', 'maiden', 'middle'), "309e39fc2475", None), - "fix(#432) a dotted numeral behind a name is a middle initial, not the generation": - _Claim(1, ('middle', 'suffix'), "e9f282da0d0f", None), - # 2026-09-18: 126 -> 131. Five corpus names arrived with - # #289/#516's own case rows -- 'J.씨', 'John Smith 田.中.', - # '毛泽东, MA', '田中 太郎, MA', '마틴 킹, MA' -- all of them - # names this rule already described. Growth into new - # corpus, not a widened regex. - # 2026-09-18, #531: 135 -> 137. The two CJK comma forms that - # round's case rows added, '田中, 太郎 MA' and '김, 민준 MA', - # both tolerated rows and so both in the radar corpus. Growth - # into new corpus again; no role joined the list. - # 2026-09-19, #533: 137 -> 138. One new corpus name, - # '田中 太郎 旧姓 佐藤 MA' -- the tolerated row this change - # added, so radar corpus. Growth into new corpus again; no - # role joined the list. - # 2026-10-02, #585: 138 -> 139, one new corpus name and not a - # wider rule: the decomposed katakana R3 row 'マイケル ジャクソン' - # (NFD) lies in its script span, and fix(#585) explains it. - # 2026-10-03, #594: 139 -> 147, and this time the regex DID - # widen: its class copies the halfwidth kana block U+FF65-U+FF9F - # whole, where it held U+FF65 alone. What it reaches beyond that - # is exactly the eight halfwidth case rows #594 added -- no - # corpus line held halfwidth kana before them. It explains the - # five that move only name fields; the two title movers also - # move `title`, outside its fields, and go to the #594 rule; - # 'ヤマダ タロウ' is parity, as 'マイケル ジャクソン' already was - # inside the same class. - # 2026-10-03, #596: 147 -> 153, six new corpus names and not a - # wider rule: the five kana voicing-mark case rows and the - # tolerated hangul one ('김\u3099민준') lie in its script span. - # It explains 'あ\u3099い たろう' and '山田 ア\u3099イ', which move - # here as every native-script name does; the three fix(#596) - # movers are parity at this baseline, and the hangul row is - # parity too (v1 reads it first, as the tree does). - # 2026-10-03, #597: 153 -> 155, two new corpus names and not a - # wider rule: '山田 「タロー」 タロウ' and '山田「タロー」太郎' lie in its script - # span ('John 「Jack」 Smith' does not); fix(#597), which declares - # `nickname` beside the name fields, explains all three. - # 2026-10-06, #613's PR review: 155 -> 161; its reach absorbed the tolerated CJK case rows the review added. - # 2026-10-06, #613 /simplify: 161 -> 162; its reach absorbed the tolerated J.씨 case row the /simplify pass added. - "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots": - # 2026-10-12, #653: 162 -> 163, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', the case row that - # took over the #329 drop-pass witness. Reach, not - # explanation: its diff here is fix(#335/#272)'s. - _Claim(163, ('family', 'given', 'middle'), "329f9a9c49f1", None), - # 2026-09-19, #533: 33 -> 68. The count grew with the CORPUS - # rather than with the rule -- this change added 35 - # maiden-clause names as rules.md example lines and - # shape-tagged case rows, and this regex reaches every name - # carrying a marker. Read name by name against the regex; no - # role joined the list. - # 2026-09-22, #397 follow-up: 77 -> 78, the one new corpus - # name rules.md#M2's `deviates: #538` example adds, - # 'Smith, John, PhD née Puig Mr. - i Soler'. Reach again -- - # it carries a marker -- and verified name by name; no role - # joined the list. - # 2026-09-26, #538: 78 -> 79, rules.md#M2's second live - # example, 'Smith, John, PhD née Puig - i Soler', added to the - # corpus. Reach again -- it carries a marker -- and verified - # name by name; no role joined the list. - # 2026-09-26, #535: 79 -> 89, the corpus growth from the - # title-stop rebuild ('Jane Doe nee Smith Prof.' and its - # siblings) -- every one carries a marker and this regex - # reaches it; no role joined the list. - # 2026-09-26, #535 review: 89 -> 91, the two rules.md#M2 - # examples the floor/tail-follows fix added ('Jane Doe nee - # King. ba', 'Doe, Jane nee Smith V, PhD'). Reach again -- - # both carry a marker -- and verified name by name; no role - # joined the list. - # 2026-09-27, #544: 105 -> 108; gains 'Doe, Jane nee Smith PhD - # MEng', 'Jane Doe Jr. nee Smith Ma', 'Jane Doe nee Smith PhD - # MEng'. - # 2026-10-03, #549: 108 -> 109, 'Smith, John, MD - née Jones - # Smith', rules.md#C1's Accepted example. Reach -- it carries a - # marker. - # 2026-10-04, #601/#602: 109 -> 98; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - # 2026-10-04, #610: +1, 'Jane Doe nee Smith VI Prof.', rules.md#M2's - # numeral example behind a title. Reach. - "fix(#274) maiden markers consumed": - _Claim(99, ('family', 'maiden', 'middle'), "b9259620e118", None), - # 2026-09-19, #533: 5 -> 6, the same one new corpus name - # '田中 太郎 旧姓 佐藤 MA' as the CJK rule above. - "fix(cjk-maiden-marker) maiden marker consumed, compounding with the CJK order flip": - # 2026-10-12, #653: 6 -> 7, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', the case row that - # took over the #329 drop-pass witness. Reach, not - # explanation: its diff here is fix(#335/#272)'s. - _Claim(7, ('family', 'given', 'maiden', 'middle'), "9879a6808907", None), - "fix(#335/#272) a marker-led paren clause beside a quoted one, compounding with the CJK order flip": - _Claim(1, ('family', 'given', 'maiden', 'nickname'), "247d488eb9b7", None), - # 2026-09-18, #531: 13 -> 19. Six corpus names whose trailing - # word is particle vocabulary this regex already lists -- - # 'Doe, John DO', 'Doe, John Do', 'SMITH, JOHN DO', - # 'Nascimento, Edson Arantes do', 'NASCIMENTO, EDSON ARANTES - # DO' and 'Berg, Jan mc'. Five of the six read exactly what - # this rule describes; the sixth, 'Doe, John DO', is the one - # spelling where the capitals take the credential instead, - # and it files here because the ROLES it moves are this - # rule's. Growth into new corpus, not a widened regex. - # 2026-09-19, #531 review round: 19 -> 20. One new corpus - # name, 'Doe, John van DO' -- the control row for the member - # grouping merges INTO a particle piece, whose trailing word - # is particle vocabulary this regex already lists. Growth into - # new corpus again; no role joined the list. - # 2026-09-19, #531 fix round: 20 -> 21. One new corpus name, - # 'Doe, John MA do', which this rule's shape regex reaches on - # its trailing tussenvoegsel. REACH, not explanation: the name - # diffs {family, middle, suffix} and this rule declares two of - # the three, so it is ineligible and the fix(#380) rule the - # round added explains it. Verified to be that name and no - # other. - # 2026-09-19, #533: 21 -> 24. Three new corpus names whose - # trailing word is particle vocabulary this regex already - # lists -- 'Doe, Jane nee Smith DO', 'Doe, Jane nee Smith MA - # do' and 'Doe, Jane nee Smith do', the do pair this change - # added. Reach, not explanation: all three are the contest - # fix(#274) is now declared to outrank. - # 2026-09-27, #544: 26 -> 27; gains 'doe, jane v phd do'. - # 2026-10-01, #562: 27 -> 29, 'John Smith, PhD DO DO' - # and 'John Smith, PhD vd DO', #562's rules.md#C1 examples - # and case rows. Reach, verified name by name. - # 2026-10-01, #554: 29 -> 31, 'John Smith, Jr vd' and 'John - # Smith, Jr do', #554's rules.md#C1 examples. Reach, verified - # name by name. - # 2026-10-02, #573: 31 -> 32, 'DOE, JANE PHD VD', P6's - # boundary, which attaches as this rule reads. Reach, verified. - "fix(#379) a tussenvoegsel after a family comma attaches to the family": - _Claim(32, ('family', 'middle'), "3e3ff4af0060", None), - "fix(#379) a tussenvoegsel behind a post-nominal after a family comma attaches": - _Claim(1, ('family', 'middle'), "f24e5eee3cc1", None), - # 2026-10-01, #554: 2 -> 3, 'John Smith, Jr vd', #554's - # rules.md#C1 example. Reach, verified by name. - # 2026-10-02, #573: 3 -> 4, 'DOE, JANE PHD VD', P6's - # boundary, which attaches as this rule reads. Reach, verified. - "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal": - _Claim(4, ('family', 'suffix'), "25798c4e2a22", None), - # 279 -> 280 with #371, and the growth is corpus, not behavior: - # that PR added `Ph. D., John` as a rules.md example, so the - # regex matches one more corpus name. The name does not diff at - # this baseline (v1 and HEAD both read first 'John', last - # 'Ph. D.'), so nothing was absorbed. - # - # 284 -> 286 with the shape 1-3 variation matrix (#486), and - # the growth is corpus again: 'Smith, John Jr.' and - # 'Smith, John, Extra, Jr.' were case-table rows in no corpus - # until their shape tags put them in corpus_shapes.jsonl. Both - # are parity rows, so neither diffs at this baseline and this - # rule absorbs nothing new -- the same reach-not-behavior - # growth the paragraph above records, from the other source. - # It moves this rule and fix(comma-precomma-family) below by - # the same two names, both matching on the bare comma. 286 -> - # 288 in the commit after it, for the two comma-bearing rows - # #486 had to AUTHOR -- 'John Smith Jr., PhD' and - # 'Kennedy, John (Jack)' -- and neither diffs at this baseline - # either, so all four names are reach without absorption. - # 288 -> 289 on 2026-09-08: 'Smith, John Prof.' joined the - # rules corpus with the 2.3 title-run bundle and matches on - # the bare comma, like the 288 before it. It does not diff - # on this rule's roles at any baseline -- {title, middle} -- - # so this is reach without absorption, as the paragraph - # above records for the four names before it. - # 2026-09-18: 289 -> 302. Thirteen comma-bearing corpus names - # arrived with #289/#516's case rows, every one inside - # this rule's Latin comma range. Growth into new corpus, - # not a widened regex. - # 2026-09-18, second round: 302 -> 304. Two more comma names, - # 'John Smith, Ma' and 'Smith Jr., Ma' -- rules.md#C1's own - # witnesses that the NAME-word count is asked before the - # written case is. - # 2026-09-18, #531: 304 -> 310 was that round; 310 -> 334 is - # this one. Twenty-four comma-bearing corpus names arrived - # with #531's case rows and rules.md example lines, every one - # inside this rule's Latin comma range -- the whole 26 new - # names less the two CJK forms. The count grew with the - # CORPUS and not with the rule; no role joined the list. - # 2026-09-19, #531 review round: 334 -> 336. The two control - # rows the round added, 'Doe, Dr. MA' and 'Doe, John van DO', - # both comma-bearing and both inside this rule's Latin comma - # range. Verified to be those two names and no others. - # 2026-09-19, #531 fix round: 336 -> 343. Seven more shape-2 - # rows, and it is the WHOLE of that round's new corpus text -- - # every one of 'Doe, Dr. MA Smith', 'Doe, John DO Ed', - # 'Doe, John MA do', 'Doe, John MA y', 'Doe, John van Ma', - # 'Doe, MA Smith' and 'Doe, Mr. MA PhD' is comma-bearing and - # Latin. Matched name by name against the regex rather than - # inferred from the arithmetic. Reach again, not explanation: - # four of the seven diff in fields this rule cannot admit and - # are explained further down the file. - # 2026-09-19, #533: 343 -> 356, the thirteen new corpus names - # carrying a comma. Reach, not explanation: the regex is a - # comma and every family-comma row this change added matches - # it. - # 2026-09-20, #397: 358 -> 359, the one new corpus name - # carrying a comma, 'Carod i Rovira, Josep'. Reach, not - # explanation, and verified name by name: the claimed set - # gained that name and lost none. - # 2026-09-20, #397 review: 359 -> 360, the one new comma name - # the review's rows add, 'Rovira, Josep Carod i Jr.' -- the - # comma form of the swallowed generation. Reach again, and - # verified name by name. - # 2026-09-22, #397 follow-up: 363 -> 364, the one new comma - # name the M2 deviation example adds. Reach again, verified - # name by name. - # 2026-09-23, #459: 364 -> 366, the two comma-written numeral - # lines rules.md#R4 gained ('john smith, v', 'john smith, vi'); - # its forced 'Dr. med. univ. Margit Popp, MSc' line was already - # in corpus_issues.jsonl. Reach, verified name by name. - # 2026-09-25, #540: 366 -> 367, 'Smith, John MEng', the new - # comma-cost row's shape landing in this rule's reach too. - # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, - # MEng', the lone-word comma rows' shape landing here too. - # 2026-09-25, #540: 369 -> 371, 'John Smith, PhD MEng' and - # 'john smith, phd meng', the credential-run comma rows' - # shape landing here too. - # 2026-09-26, #538: 371 -> 372, 'Smith, John, PhD née Puig - i - # Soler', rules.md#M2's second live example, its comma landing - # in this rule's reach too. Reach again, verified name by - # name. - # 2026-09-26, #538 (frozen-loop half): 372 -> 373, 'Smith, - # John, PhD - i Soler', rules.md#R1's new example, its comma - # landing in this rule's reach too. Reach again, verified name - # by name. - # 2026-09-26, #535: 373 -> 376, three comma-fronted names the - # title-stop rebuild added ('Berg, abdul nee Smith V' and two - # of its siblings), each a lone post-comma piece this rule - # already reaches. Reach again, verified name by name. - # 2026-09-26, #535 review: 376 -> 377, 'Doe, Jane nee Smith V, - # PhD', rules.md#M2's third-comma-part example, its comma - # landing in this rule's reach too. Reach again, verified name - # by name. - # 2026-09-27, #544: 380 -> 392; gains 'Doe, Jane PhD MEng', - # 'Doe, Jane nee Smith PhD MEng', 'Jane Doe, MS LAc', 'John - # Smith, Ed Ma' and 8 more. - # 2026-09-28, #544: 392 -> 396; gains 'John Smith, PhD Ma', - # 'Smith, MD PhD Ma', 'Smith, Ms MD Ma', 'Smith, PhD Ma'. - # Reach, verified name by name. - # 2026-09-30, #563: 396 -> 408, the twelve names #563's rules.md#C1 - # examples and case rows add, every one written with a comma. - # Reach, verified name by name. - # 2026-10-01, #562: 408 -> 410, 'John Smith, PhD DO DO' - # and 'John Smith, PhD vd DO', #562's rules.md#C1 examples - # and case rows. Reach, verified name by name. - # 2026-10-01, #554: 410 -> 414, 'John Smith, vd Ma', - # 'JOHN SMITH, VD MA', 'John Smith, Jr vd' and 'John Smith, - # Jr do', #554's rules.md#C1 examples. Reach, verified name by - # name. - # 2026-10-01, #575: 414 -> 423, its nine rules.md#C1 and - # case-row names, every one a comma name. Reach, verified - # name by name. - # 2026-10-02, #573: 430 -> 439, its nine rules.md#P2, - # P6 and S2 comma examples. Reach, verified name by name. - # 2026-10-04, #601/#602: 443 -> 438; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - # 2026-10-04, #601/#602 review: 438 -> 439, rules.md#S2's new - # example 'Smith, John PhD de Jr.'. Reach, verified by name. - # 2026-10-04, #603: 441 -> 452; the rules.md#C1 and #C2 examples #603 added entered the corpus. - # 2026-10-06, #613: 452 -> 454; its reach absorbed both C1 examples #613 added to the corpus, 'John Smith, Mr. and Mrs.' and 'Ortega y Gasset, Dr.'. - # 2026-10-06, #617: 454 -> 455; its reach absorbed the C1 example 'Smith, Chancellor of the Exchequer MP' #617 added to rules.md. - # 2026-10-10, #623: 455 -> 457; its reach absorbed the two rules.md#H5 examples #623 added, 'Smith, John van der Berg Prof.' and 'Smith, Jan van Prof.', both explained by fix(#623). - # 2026-10-11, #636: 457 -> 458; its reach absorbed the rules.md#C2 example 'John Smith, Jr., van der Berg Prof.' #636 added, which at 1.4.0 it explains (suffix and title), as fix(#636) does at 2.x. - # 2026-10-11, #637/#640: 458 -> 461; its reach absorbed the three rules.md#P5 examples #637/#640 added, 'Berg, abdul Prof.', 'Berg, abdul Prof. Ali' and 'John Smith, Jr., abdul Dr. Smith', the first and last of which it explains at 1.4.0, as fix(#637) and fix(#640) do at 2.x. - "fix(comma-family) lone post-comma piece routes to suffix/title, not first": - # 2026-10-01, #564: 423 -> 430, 'John Smith, XYZ', 'Smith, - # XYZ', 'García Márquez, MJ', 'García Márquez, MJ PhD', - # 'García Márquez, MJ JK', 'John Smith, PhD XYZ' and 'MÜLLER - # WEIß, HANS', #564's rules.md#C1 examples. Reach, verified - # name by name. - # 2026-10-03, #549: 439 -> 442, 'Smith, John, Puig - i Soler', - # 'Smith, John, PhD - and MD' and 'Smith, John, MD - née Jones - # Smith', #549's rules.md#C1 examples. Reach -- each is a - # comma name. - # 2026-10-04, #602: 442 -> 443, 'Smith, John PhD Jones', the - # rules.md#S2 example of the given part's credential run. - # Reach -- a comma name. - # 2026-10-04, #604: 442 -> 443, 'Pérez, Juan Ó.', #604's - # rules.md#P7 comma example. Reach. - # 2026-10-04, merging #604 into the #601/#602 branch: 440, - # recomputed over the merged corpus -- #604's P7 example and - # #602's two S2 examples in, the marker names #601 moved to - # its own rules out. Reach. - # 2026-10-04, #606: 443 -> 444, 'Greve, Anna', #606's - # rules.md#H1 comma example. Reach. - # 2026-10-04, merging #606 in as well: 441 over the merged - # corpus. Reach. - # 2026-10-12, #653: 461 -> 465, 'Jane (Janey, Jo) Smith', - # 'Maria Kowalska (z, domu Nowak)', 'Jane (née , Jones) Smith' - # and 'Jane (née ,Jones) Smith', #653's rules.md#N1, #M1 and #M3 - # examples. Reach -- each is a comma name, read at 1.4.0's - # reading here, so there is no diff to absorb. - _Claim(465, ('given', 'suffix', 'title'), "ae1d223b1aa2", None), - "fix(comma-family) a comma followed only by titles keeps the given/family split": - _Claim(2, ('family', 'given'), "5bd9c6d96c38", None), - "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example": - _Claim(2, ('family', 'given', 'suffix'), "a3cfff4e78f4", None), - "fix(#296) a dropped prenominal takes the name position it occupies": - _Claim(5, ('given', 'middle', 'title'), "e5aa72ebb9bf", None), - # `middle` left the ROLES in the same edit, at the gate's - # own OVER-DECLARED insistence: with 'John Smith Dr.' gone, - # no name the rule still explains moves a middle name. - # 11 -> 12 on 2026-09-08: the 2.3 title-run bundle put - # 'John Smith Prof. Dr.' in the rules corpus, and the - # trailing-`dr` regex reaches any name ending in " Dr.". - # Reach, not explanation -- the rule explains NEITHER of - # the two 'Dr.' names now, both having moved to the #316 - # rule at the end of the ledger, and the comment there - # records the handover. - # 2026-09-26, #535: 13 -> 14, 'Jane Doe nee Prof. Dr.' joined - # the corpus and ends in " Dr." like the rest this rule reaches. - # 2026-10-06, #613: 14 -> 15; its reach absorbed 'Ortega y Gasset, Dr.', the C1 example #613 added, which ends in 'Dr.'. - "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word": - _Claim(15, ('family', 'suffix'), "0c6062c4dbd7", None), - "fix(#296) a credential-only comma string reads a name and its postnominal": - _Claim(2, ('family', 'given', 'suffix', 'title'), "3f983ff71dee", None), - # 2026-09-18: 18 -> 20. Two new corpus names, 'Smith, MA' and - # 'Smith, Ma' -- the pair #289's lean reads in opposite - # directions, both of them lone post-comma credentials - # by this rule's own description. - # 2026-09-18, #531: 20 -> 21. One new corpus name, 'Doe, MA' -- - # the family-word twin of the 'Smith, MA' above, and a lone - # post-comma credential by this rule's own description. - # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, - # meng' and 'Smith, MEng' -- reached by the same post-comma - # shape this rule matches, but read the opposite way and - # explained by neither: 1.4.0 already reads the given name - # for both, so there is no diff here to explain. - "fix(#296) a lone post-comma credential is a suffix": - # 2026-10-01, #564: 23 -> 24, 'Smith, XYZ'. Reach. - # 2026-10-04, #606: 24 -> 25, 'Greve, Anna'. Reach. - _Claim(25, ('family', 'given', 'suffix', 'title'), "b2bc2b830168", None), - # 2026-09-27, #544: 6 -> 7; gains 'Smith, PhD MEng'. - # 2026-09-28, #544: 7 -> 8; gains 'Smith, PhD Ma'. - "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": - _Claim(8, ('given', 'suffix', 'title'), "81e29a5745e7", None), - "fix(#325) a credential run across a second comma reads as suffixes": - _Claim(1, ('suffix', 'title'), "f025c5f70a4e", None), - "fix(#367) an inferred title no longer displaces a leading particle either": - _Claim(1, ('family', 'given'), "d8ee9cd5da5f", None), - # 288 -> 289 with fix(comma-family) above and for the same - # one name, both rules matching on the bare comma. - # 2026-09-18: 289 -> 302, the same thirteen new comma names as - # the lone-post-comma rule above and for the same reason. - # 2026-09-18, second round: 302 -> 304, the same two new comma - # names as the rule above. - # 2026-09-18, #531: 310 -> 334, the same twenty-four new comma - # names as the rule above and for the same reason. - # 2026-09-19, #531 review round: 334 -> 336, the same two new - # comma names as the rule above. - # 2026-09-19, #531 fix round: 336 -> 343, the same seven new - # comma names as the rule above and for the same reason. - # 2026-09-19, #533: 343 -> 356, the same thirteen new comma - # names as the rule above and for the same reason. - # 2026-09-20, #397: 358 -> 359, the same one new comma name as - # the rule above and for the same reason. - # 2026-09-20, #397 review: 359 -> 360, the same one new comma - # name as the rule above and for the same reason. - # 2026-09-22, #397 follow-up: 363 -> 364, the same one new - # comma name as the rule above and for the same reason. - # 2026-09-23, #459: 364 -> 366, the two comma-written numeral - # lines rules.md#R4 gained ('john smith, v', 'john smith, vi'); - # its forced 'Dr. med. univ. Margit Popp, MSc' line was already - # in corpus_issues.jsonl. Reach, verified name by name. - # 2026-09-25, #540: 366 -> 367, 'Smith, John MEng', the new - # comma-cost row's shape landing in this rule's reach too. - # 2026-09-25, #540: 367 -> 369, 'Smith, meng' and 'Smith, - # MEng', the lone-word comma rows' shape landing here too. - # 2026-09-25, #540: 369 -> 371, 'John Smith, PhD MEng' and - # 'john smith, phd meng', the credential-run comma rows' - # shape landing here too. - # 2026-09-26, #538: 371 -> 372, the same one new comma name as - # the rule above and for the same reason. - # 2026-09-26, #538 (frozen-loop half): 372 -> 373, the same - # one new comma name as the rule above and for the same - # reason. - # 2026-09-26, #535: 373 -> 376, the same three comma-fronted - # names as the rule above and for the same reason. - # 2026-09-26, #535 review: 376 -> 377, the same one new comma - # name as the rule above and for the same reason. - # 2026-09-27, #544: 380 -> 392; gains 'Doe, Jane PhD MEng', - # 'Doe, Jane nee Smith PhD MEng', 'Jane Doe, MS LAc', 'John - # Smith, Ed Ma' and 8 more. - # 2026-09-28, #544: 392 -> 396; the same four comma names. - # 2026-09-30, #563: 396 -> 408, the twelve names #563's rules.md#C1 - # examples and case rows add, every one written with a comma. - # Reach, verified name by name. - # 2026-10-01, #562: 408 -> 410, 'John Smith, PhD DO DO' - # and 'John Smith, PhD vd DO', #562's rules.md#C1 examples - # and case rows. Reach, verified name by name. - # 2026-10-01, #554: 410 -> 414, 'John Smith, vd Ma', - # 'JOHN SMITH, VD MA', 'John Smith, Jr vd' and 'John Smith, - # Jr do', #554's rules.md#C1 examples. Reach, verified name by - # name. - # 2026-10-01, #575: 414 -> 423, its nine rules.md#C1 and - # case-row names, every one a comma name. Reach, verified - # name by name. - # 2026-10-02, #573: 430 -> 439, its nine rules.md#P2, - # P6 and S2 comma examples. Reach, verified name by name. - # 2026-10-04, #601/#602: 443 -> 438; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - # 2026-10-04, #601/#602 review: 438 -> 439, rules.md#S2's new - # example 'Smith, John PhD de Jr.'. Reach, verified by name. - # 2026-10-04, #603: 441 -> 452; the rules.md#C1 and #C2 examples #603 added entered the corpus. - # 2026-10-06, #613: 452 -> 454; its reach absorbed both C1 examples #613 added to the corpus, 'John Smith, Mr. and Mrs.' and 'Ortega y Gasset, Dr.'. - # 2026-10-06, #617: 454 -> 455; its reach absorbed the C1 example 'Smith, Chancellor of the Exchequer MP' #617 added to rules.md. - # 2026-10-10, #623: 455 -> 457; its reach absorbed the two rules.md#H5 examples #623 added, 'Smith, John van der Berg Prof.' and 'Smith, Jan van Prof.', both explained by fix(#623). - # 2026-10-11, #636: 457 -> 458; its reach absorbed the rules.md#C2 example 'John Smith, Jr., van der Berg Prof.' #636 added. Reach only: it moves title, outside this rule's fields; the lone post-comma rule explains it at 1.4.0. - # 2026-10-11, #637/#640: 458 -> 461; its reach absorbed the three rules.md#P5 examples #637/#640 added, 'Berg, abdul Prof.', 'Berg, abdul Prof. Ali' and 'John Smith, Jr., abdul Dr. Smith'. Reach only: the moved two move title, outside this rule's fields; the lone post-comma rule explains them at 1.4.0. - "fix(comma-precomma-family) pre-comma run reads as family, not given": - # 2026-10-01, #564: 423 -> 430, 'John Smith, XYZ', 'Smith, - # XYZ', 'García Márquez, MJ', 'García Márquez, MJ PhD', - # 'García Márquez, MJ JK', 'John Smith, PhD XYZ' and 'MÜLLER - # WEIß, HANS', #564's rules.md#C1 examples. Reach, verified - # name by name. - # 2026-10-03, #549: 439 -> 442, the same three #549 examples. - # Reach. - # 2026-10-04, #602: 442 -> 443, 'Smith, John PhD Jones'. Reach. - # 2026-10-04, #604: 442 -> 443, 'Pérez, Juan Ó.'. Reach. - # 2026-10-04, merging #604 into the #601/#602 branch: 440, - # recomputed over the merged corpus -- #604's P7 example and - # #602's two S2 examples in, the marker names #601 moved to - # its own rules out. Reach. - # 2026-10-04, #606: 443 -> 444, 'Greve, Anna'. Reach. - # 2026-10-04, merging #606 in as well: 441 over the merged - # corpus. Reach. - # 2026-10-12, #653: 461 -> 465, 'Jane (Janey, Jo) Smith', - # 'Maria Kowalska (z, domu Nowak)', 'Jane (née , Jones) Smith' - # and 'Jane (née ,Jones) Smith', #653's rules.md#N1, #M1 and #M3 - # examples. Reach -- each is a comma name, read at 1.4.0's - # reading here, so there is no diff to absorb. - _Claim(465, ('family', 'given'), "ae1d223b1aa2", None), - # 2026-10-01, #575: new, 4; 'De La Cruz, Ed', 'Freiherr von - # Berg, Ed', 'Van Buren, Ed', 'de la Cruz, Ma'. - "fix(#575) a particle surname before a comma is one name word": - _Claim(4, ('family', 'given', 'suffix', 'title'), "30163564e03d", None), - # 2026-10-01, #564: new, 5; 'García Márquez, MJ JK', 'John - # Smith, LEED AP', 'John Smith, PhD XYZ', 'John Smith, XYZ', - # 'The Rt Hon Kenneth Clarke QC MP, HMG'. - "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default": - _Claim(5, ('family', 'given', 'middle', 'suffix', 'title'), "0de364bee3e3", ('DEFAULT',)), - # 2026-09-20, #397: retitled in place, reach and digest - # unchanged -- the rule keeps 'Carod i', which the landing - # leaves byte-identical. - "fix(#397) accepted: a two-word Catalan link has no name word to its right and stays the generation": - _Claim(1, ('family', 'suffix'), "498602f3cfd0", None), - "fix(suffix-delimiter-rendering) no-space delimiter core token kept whole": - _Claim(0, ('suffix',), "e3b0c44298fc", None), - "ambiguous-surname-acronym data change: parenthesized (MA)/(DO) now stays nickname": - _Claim(0, ('nickname', 'suffix'), "e3b0c44298fc", None), - "feat(#269) Arabic بن prefix chains onto family (non-Latin new-recognition)": - _Claim(2, ('family', 'middle'), "3e2b5c6d1f4d", None), - "feat(#273) typographic nickname delimiters recognized by default": - _Claim(8, ('middle', 'nickname'), "968bd4162257", None), - "fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip": - # 2026-10-12, #653: 6 -> 7, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', the case row that - # took over the #329 drop-pass witness. Reach, not - # explanation: its diff here is fix(#335/#272)'s. - _Claim(7, ('family', 'given', 'nickname'), "7817294588bc", None), - "fix(cjk-fullwidth-paren-nickname) fullwidth-parenthesis recognition compounds with the CJK order flip": - _Claim(1, ('family', 'given', 'middle', 'nickname'), "cf370e856ae7", None), - # 2026-09-18: 26 -> 29. The three CJK comma forms #289's own - # case rows added ('毛泽东, MA', '田中 太郎, MA', - # '마틴 킹, MA'). Growth into new corpus. - # 2026-09-18, #531: 32 -> 34, the two CJK comma forms that - # round added ('田中, 太郎 MA' and '김, 민준 MA'). Both - # regexes here ask only for a comma beside a CJK codepoint, - # so every CJK comma name in the corpora reaches them. - # 2026-10-06, #613's PR review: 34 -> 40; its reach absorbed the tolerated CJK case rows the review added. - # 2026-10-06, #613 /simplify: 40 -> 41; its reach absorbed the tolerated J.씨 case row the /simplify pass added. - "fix(cjk-comma-honorific-peel) glued honorific peels off a post-comma given name": - _Claim(41, ('given', 'suffix'), "66211a448772", None), - # 2026-09-18: 26 -> 29, the same three new CJK comma forms. - # 2026-09-18, #531: 32 -> 34, the same two as the rule above. - # 2026-10-06, #613's PR review: 34 -> 40; its reach absorbed the tolerated CJK case rows the review added. - # 2026-10-06, #613 /simplify: 40 -> 41; its reach absorbed the tolerated J.씨 case row the /simplify pass added. - "fix(cjk-comma-compound) comma routing compounds with the CJK order flip": - _Claim(41, ('family', 'given', 'suffix', 'title'), "66211a448772", None), - # 37 -> 35 with the 2026-09-05 narrowing, which is a rule - # NARROWING and not corpus movement: the three negative - # lookbehinds stop the regex matching a listed honorific - # interior to a longer one, so '김민준 박사님' and '선생님' left - # the reach and nothing else did. The 2.0 twin below carries - # the identical regex and moves by the same two names, to the - # same digest. Only '김민준 박사님' changed hands -- it goes to - # fix(cjk-honorific-suffix) here -- '선생님' having been the - # order rule's all along. - # 40 since #323, which is 35 + 2 + 3: '김민준씨.' and '田中さん.' - # entered the corpus, and the review round after them added the - # three stop-bearing FAMILY_COMMA rows ('田中さん., V.', - # '김민준씨., J.씨', '이, J.씨.'). The regex already carried the - # optional stop, so all five move the REACH on membership, not - # on the fix. None of the five is a diff this rule explains: - # the first two carry a title role its fields do not admit, so - # the #322/#323 rule at the foot of the ledger explains them - # instead, and the three comma rows produce no diff at this - # baseline at all. `radar unclassified` stays 0. - # 2026-09-18: 40 -> 41. One new corpus name, 'J.씨'. - # 2026-10-06, #613's PR review: 44 -> 47; its reach absorbed the tolerated CJK case rows the review added. - # 2026-10-06, #613 /simplify: 47 -> 48; its reach absorbed the tolerated J.씨 case row the /simplify pass added. - "fix(cjk-glued-honorific-peel) glued honorific peels into suffix": - _Claim(48, ('family', 'given', 'suffix'), "aad99618f822", None), - "fix(cjk-honorific-suffix) postnominal honorifics recognized, compounding with the CJK order flip": - _Claim(19, ('family', 'given', 'middle', 'suffix'), "aa475ddd4745", None), - # 4 -> 6 on 2026-09-13: 'ХОСЕ И МАРИЯ САНТОС' and 'хосе и - # мария сантос' entered corpus_shapes.jsonl with #383/#479's - # case rows, as the one-case control for a script whose - # connective the vocabulary has decided since 2.0. Both move - # ROLES against this baseline, which read 'И' as a middle - # word, so the pair is this rule's rather than #383/#479's -- - # the fork RUNS on them (one-case, and 'И'/'и' is a cased - # single letter) and takes the non-member branch, so the tag - # is the one the old path produced and no report is emitted, - # and nothing about their reading moved in 2.4. Reach and - # explanation both, unusually: the run classifies six here. - "feat(#269) non-Latin titles/conjunctions recognized": - _Claim(6, ('given', 'middle', 'title'), "20cde5535c9a", None), - "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle": - _Claim(1, ('family', 'given'), "ca7b37af6cf8", None), - "fix(#367) a title no longer displaces a leading particle out of the leading position": - _Claim(3, ('family', 'given'), "724967a4a117", None), - "fix(#400) abd joins the word after it as one given name": - _Claim(11, ('given', 'middle'), "1eaed91fc574", None), - "fix(#272/#308) nakaguro division and a glued hangul honorific in one name": - _Claim(1, ('family', 'given', 'middle', 'suffix'), "2fbf1a94f122", None), - "fix(emoji-boundary) an emoji inside a token divides it": - _Claim(1, ('family', 'given'), "efa60ca42d4a", None), - "fix(nickname-typographic-pairs) two typographic quote spans read as one nickname set": - _Claim(1, ('family', 'given', 'middle', 'nickname'), "3cf566c78800", None), - # 2026-09-19, #533: 1 -> 2. One new corpus name, 'Berg, - # abdul nee Jones MA' -- this change's record that P5's - # lenient post-comma join takes the released member before - # assign can read it, so the clause form agrees with the - # bare 'Berg, abdul MA'. Reach, not explanation. - # 2026-09-26, #535: 2 -> 3, 'Berg, abdul nee Smith V' joining - # the corpus -- the same bound-given/maiden shape. Reach, not - # explanation. - # 2026-10-04, #601/#602: 3 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#411) the bound-given reserve stops counting words the maiden name takes": - _Claim(1, ('given', 'middle'), "431d3dd24c14", None), - "fix(#400/#274) bound-given join and maiden consumption in one name": - _Claim(1, ('family', 'given', 'maiden', 'middle'), "6bed6d349342", None), - "fix(#418) the connective carve-out counts the name the maiden clause leaves behind": - _Claim(1, ('family', 'given', 'maiden', 'middle'), "7923e6d3c5a7", None), - "fix(credential-pair-order) a split credential and a suffix render in written order": - _Claim(1, ('suffix',), "6f6eef764248", None), - "fix(#369) a given-name title licenses the bound given-name join with one word to spare": - _Claim(3, ('family', 'given'), "724be3e6b926", None), - "fix(#401) the bound-given reserve counts the trailing numeral assign reads as the suffix": - _Claim(2, ('family', 'given', 'suffix'), "5713d4c0bd68", None), - "fix(#421) the bound-given join never absorbs a suffix piece": - _Claim(1, ('given', 'middle'), "9523e518e6ec", None), - "fix(#421) the bound-given join never absorbs a split credential": - _Claim(1, ('given', 'middle'), "228abe0f32ef", None), - "fix(#425) accepted: a bare ambiguous acronym the peel does not take joins as a name word": - _Claim(1, ('given', 'middle'), "2010cc79a34d", None), - "fix(#424) the particle chain stops before the trailing numeral": - _Claim(1, ('family', 'suffix'), "2c99162bc9cf", None), - # 2026-09-19, #533: 1 -> 2, and the two are not the two an - # earlier draft of this note predicted. The rule was an (?i) - # alternation carrying 'John n[ée]e Jones Smith Ma', so the - # example line this change added, 'John née Jones Smith MA', - # matched it case-insensitively -- and that spelling now - # reads the OPPOSITE way, the clause giving the MA up. The - # anchor is narrowed to the two spellings whose reading this - # rule describes ('John née Jones Smith Ma' and 'JOHN NEE - # JONES SMITH MA PHD'), the caps-MA spelling is carried by - # fix(#445/#533) below, and the digest moves although the - # count does not -- which is the one kind of growth a names - # count alone cannot see. - "fix(#424/#445) accepted: the maiden walk keeps a bare acronym, and the lone name word is the family": - _Claim(2, ('family', 'given', 'maiden', 'middle', 'suffix'), "348bf7114c9c", None), - "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle, the P4 example": - _Claim(1, ('family', 'given'), "42b69cf1b320", None), - "fix(#424) accepted: the maiden walk keeps the numeral an initial before the marker vetoes": - _Claim(1, ('family', 'maiden', 'middle', 'suffix'), "08c0158c8d3f", None), - # 2026-09-18 (#289): the reach is unchanged at one name and the - # ROLES grew by `suffix` -- 'Freiherr von Berg MA' now - # moves three roles where it moved two, which is the - # `fields` re-scope this ledger's own comment argues. - "fix(#424) accepted: the chain keeps an acronym assign will not peel behind a title-and-particle word": - _Claim(1, ('family', 'given', 'suffix'), "faa4bedda537", None), - "fix(#424) a title-led chain before the numeral is the one name piece": - _Claim(1, ('family', 'suffix'), "5b3a743f9e35", None), - "fix(#424) accepted: a particle of the suffix vocabulary opening the trailing run is a suffix piece": - _Claim(1, ('family', 'middle', 'suffix'), "a564b97f7162", None), - "fix(#360) ste moved into the never-given particles with mc": - _Claim(1, ('family', 'given'), "e62caedec864", None), - "fix(#360) mc moved into the never-given particles, so it folds into the family": - _Claim(1, ('family', 'given'), "ee4339908f4d", None), - "fix(#367) a title no longer displaces a leading never-given particle": - _Claim(1, ('family', 'given'), "db724fb9c779", None), - # 2026-10-04, #601/#602: 7 -> 6; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#445) a maiden marker makes the lone name word the family": - _Claim(6, ('family', 'given', 'maiden', 'middle'), "3a1de67a470f", None), - "fix(N3) a nickname-led name with a trailing suffix keeps the suffix in `suffix`": - _Claim(1, ('family', 'suffix'), "570f265a2f46", None), - # #451's four replacements for the fields-only catch-all, whose - # own entry recorded the whole 1090-name corpus -- a rule with no - # name_regex reaches everything, so its reach could never move and - # this roster could not see names arrive on it. The numeral and jr - # rules reach more names than they explain, and rules above them - # take the surplus, which is why the four are last in the file; - # the acronym and M.A. rules reach exactly what they explain. - "fix(suffix-routing) a two-token name ending in a roman numeral keeps it in `suffix`": - _Claim(4, ('family', 'suffix'), "fc52089dfa8e", None), - # 5 -> 7 on 2026-09-08: 'Dr Jr' and 'Sir Jr' joined the - # rules corpus with the 2.3 title-run bundle. Both are - # two-token names ending in `jr`, so the regex reaches - # them; neither diff fits {family, suffix} -- each moves - # `title` and `given` too -- so both fall through to the - # peel-floor rule at the end of the ledger, and the surplus - # is the one the paragraph above says these four carry. - # 7 -> 6 on 2026-09-09, in the /simplify round: `Sir Jr` left - # rules.md and so left the corpus; `Dr Jr` reads the same way - # and pins the shape alone. - "fix(suffix-routing) a two-token name ending in the suffix word jr keeps it in `suffix`": - _Claim(6, ('family', 'suffix'), "dd3fc23d90a1", None), - # 2026-09-23, #459: 2 -> 3, 'qc mp', rules.md#R4's name-role - # boundary line. Reach, verified name by name. - "fix(suffix-routing) a two-token name ending in a credential acronym keeps it in `suffix`": - _Claim(3, ('family', 'suffix'), "07c5470c399e", None), - # 2026-09-27, #544: 1 -> 2; gains 'Wang M.Eng.', the 2.0 - # reading #544 returns to, and relabelled for the shape the two - # names share (it was 'the dotted M.A. spelling reads as a - # credential (ma-do)'). - "fix(suffix-routing) a two-token name ending in a dotted credential spelling keeps it in `suffix`": - _Claim(2, ('family', 'suffix'), "5d450ee3cadb", None), - # #484's six `_initials` rules. Four of them reach far more - # than they explain, and the gap is the pseudo-field's own - # doing rather than a widening: `_initials` enters a diff ONLY - # when the seven roles and the ambiguity kinds all agree, so a - # name whose regex the rule matches contributes nothing unless - # its parse is otherwise identical across the two surfaces. - # Reach against explained, measured 2026-09-02 at baseline - # 1.4.0 -- a snapshot of that run, not a standing count: - # 27/27, 1/1, 96/66, 41/19, 107/11, 18/2 -- the particle chain's - # reach is 108 since 2026-09-07, when rules.md#P1's `de la Torre - # Vega` example joined the contract corpus (#471). The reach half is - # what this roster holds; the explained half moves with the - # corpus and is re-read from the gate. The phd rule's 18 is the widest - # gap and the most literal regex -- `\bph\. d\.` matches every - # spelling of the fragment the corpora carry, and the trailing - # ones are protected by the [[never]] entry above, which is - # what _EXCLUSION_EFFECT's grown `absorbed_by` records. - # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', which #540's - # case row admitted and which this rule explains here: its - # family 'van' is a one-particle part, this rule's reason and - # not #540's. - # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac' -- reached, not - # explained, by this rule: its role move is explained by the - # Title-case Lac rule instead, so `_initials` never enters its - # diff here. - "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), - "fix(#360) los joined the particles, so it no longer initials": - _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), - # #269's derived-view rule, added 2026-09-13. One corpus name, - # `_initials` alone: 'محمد و علي' entered the corpora with - # #383/#479's case rows and brought a 2.0-era view change with - # it -- a recognized connective contributes no initial -- which - # nothing had classified because no corpus name had exercised - # it. Literal and caseless, so a second name here means the - # alternation grew. - # `feat(#269) a recognized non-Latin connective contributes - # no initial` had a row here until 2026-09-20 (reach 1, - # 'محمد و علي'). #461 gave that name its 'و' back on both - # surfaces, which is 1.4.0's own answer, so the rule stopped - # explaining anything and is deleted from the ledger. - # #528's literal name list, added 2026-09-13 and sitting ahead - # of the three vocabulary rules below because it shares their - # `_initials` field, and because the first of them -- the - # connective rule -- reaches two of its corpus names. - # Four corpus names, `_initials` alone: the roles move on none, - # which is what leaves the derived view as the whole diff. A - # fifth name here means the alternation grew. - # 2026-09-20, #461: 4 -> 3. 'JUAN Y GARCIA' left the - # alternation -- its 'Y' holds its part alone and initials - # again on both surfaces, giving the 1.4.0 wheel's own string - # -- so the rule has nothing left to say about it. The other - # three are unmoved. - "fix(#528) the facade's initials follow the parse's connective tags": - _Claim(3, ('_initials',), "3b5e8a0e39bb", ('DEFAULT',)), - # 96 -> 97 on 2026-09-08: 'Prince of Wales Jr' joined the - # rules corpus with the 2.3 title-run bundle -- a parity - # row, kept as the boundary the peel floor declines -- and - # `of` is a connective. Reach, not explanation. - # 97 -> 101 on 2026-09-13: 'john e smith', 'jose e maria - # santos', 'John e Smith' and 'juan y garcia' arrived with - # #383/#479's case rows and rules.md#P3 examples, each - # carrying a lowercase connective this regex reaches. Reach, - # not explanation again: the facade's view did not move for - # any of them at this baseline, which is why #383/#479 had no - # `_initials` rule here. #528 gave it one on 2026-09-13 -- - # `fix(#528) the facade's initials follow the parse's - # connective tags`, written ahead of this rule because they - # share a field and two corpus names -- and this rule's REACH - # is unmoved by it: the regex did not change, and reach is - # what this number counts. - # 2026-09-20, #397: 101 -> 102. One new corpus name holding a - # connective run, 'Juan Garcia y Lopez y' -- the negative - # control for the both-sides condition's class half, which is - # in the corpus because its row carries a shape tag. Reach - # again: the regex is unchanged and the claimed set gained - # that name alone. - # 2026-09-20, #397 review: 102 -> 103. One new corpus name - # holding a connective run, 'Carod y de Rovira i' -- the row - # pinning that a trailing link does not count itself for an - # unrelated 'y', which is in the corpus because its row - # carries a shape tag. Reach again, verified name by name. - # 2026-10-01, #575: 104 -> 105, 'Ortega y Gasset, Ed'. Reach. - # 2026-10-03, #549: 105 -> 106, 'Smith, John, PhD - and MD'. - # Reach. - # 2026-10-04, #601/#602: 106 -> 107; 'Jane and née Jones', a new rules.md#M2 example, is in reach -- a marker behind a connective is a word in the run since #601. - # 2026-10-04, #603: 107 -> 108; the rules.md#C1 and #C2 examples #603 added entered the corpus. - # 2026-10-06, #613: 108 -> 110; its reach absorbed both C1 examples #613 added, each holding a connective ('and', 'y'). - # 2026-10-06, #617: 110 -> 111; its reach absorbed the C1 example 'Smith, Chancellor of the Exchequer MP' #617 added to rules.md. - "fix(initials-per-word) a connective run initials each word (facade, since 2.0.0)": - _Claim(111, ('_initials',), "42cf3ec43544", ('DEFAULT',)), - # 2026-09-19, #533: 41 -> 43. Two new corpus names opening - # with a bound-given word, 'Berg, abdul MA' and 'Berg, abdul - # nee Jones MA' -- the P5 pair this change added to record - # that the clause form now agrees with the bare one. - # 2026-09-26, #535: 43 -> 44, 'Berg, abdul nee Smith V' -- - # another opening bound-given word. Reach again. - # 2026-10-01, #575: 44 -> 45, 'abdul Salam, Ed'. Reach. - # 2026-10-04, #601/#602: 45 -> 43; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - # 2026-10-11, #637/#640: 43 -> 46, the three rules.md#P5 examples #637/#640 added ('Berg, abdul Prof.', 'Berg, abdul Prof. Ali', 'John Smith, Jr., abdul Dr. Smith'). Reach: where their roles move, `_initials` is not compared. - "fix(initials-per-word) a bound-given run initials each word (facade, since 2.0.0)": - _Claim(46, ('_initials',), "a91bd719adc6", ('DEFAULT',)), - # 2026-09-18: 109 -> 110. One new corpus name, - # 'john van der berg ma' -- rules.md#P2's one-case contrast, - # and a particle chain like every other member. - # 2026-09-20, #397 review: 111 -> 112. The same one new name - # as the connective-run rule above, 'Carod y de Rovira i', - # whose 'de Rovira' is a particle chain; two rules reach one - # name and neither widened. Verified name by name. - # 2026-09-26, #535: 112 -> 113, 'Jane van der Berg nee Smith - # Prof.' -- another particle chain, 'van der Berg'. Reach again. - # 2026-09-30, #563: 115 -> 117, 'De La Cruz, M.J. PhD' and - # 'De La Cruz, M.J. K.L.' -- a particle chain, 'De La Cruz'. - # Reach, verified name by name. - # 2026-10-01, #575: 117 -> 124, its seven particle-surname - # names ('De La Cruz, Ed', 'de la Cruz, Ma', 'van der Berg, - # MA', 'Van Buren, Ed', 'van der Berg, PhD', 'John van Buren, - # Ed', 'Freiherr von Berg, Ed'). Reach, verified name by name. - # 2026-10-02, #573: 124 -> 125, 'SMITH, VD DE LA MA' -- a - # particle chain, 'VD DE LA'. Reach, verified name by name. - # 2026-10-04, #601/#602: 125 -> 123; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - # 2026-10-10, #627: 123 -> 124, 'John von PhD Jones', the - # rules.md#S2 example #627 added -- a lone particle inside the - # name. Reach: its roles move, so `_initials` is not compared. - # 2026-10-10, #623: 124 -> 125, 'Smith, John van der Berg - # Prof.', the rules.md#H5 example #623 added. Reach: its roles - # move, so `_initials` is not compared. - # 2026-10-11, #636: 125 -> 126, 'John Smith, Jr., van der Berg - # Prof.', the rules.md#C2 example #636 added. Reach: its roles - # move, so `_initials` is not compared. - "fix(initials-per-word) a particle chain inside a name part initials each word (facade, since 2.0.0)": - _Claim(126, ('_initials',), "c756ff1fea5c", ('DEFAULT',)), - # 2026-09-23, #459: 18 -> 19, 'john smith ph. d.', rules.md#R4's - # two-token line. Reach, verified name by name. - # 2026-10-04, #603: 19 -> 20; the rules.md#C1 and #C2 examples #603 added entered the corpus. - "fix(initials-per-word) the Ph. D. merge initials each word (facade, since 2.0.0)": - _Claim(20, ('_initials',), "d89231d36185", ('DEFAULT',)), - # The 2.3 title-run bundle's five rules, last in every - # ledger. All five are anchored on NAMES, so the reach IS the - # mover list: 2 names for the run keying, 1 for the esq drop, - # 3 for the peel floor, 16 for the trailing title and 1 for - # the trailing title on a native-script name, which is the - # same argument on the one name a script-classified - # alternation may not hold. Twenty-three in all, and every one - # of them is explained by the rule that names it -- these are - # the rare rows where reach and explanation coincide, which is - # what an anchored name list buys. A widening past those names - # moves the digest here before it can reach the gate. - # 24 -> 23 on 2026-09-09, in the /simplify round: `Sir Jr` - # left rules.md, where it pinned nothing `Dr Jr` does not, - # and so left the rules corpus and the peel floor's reach. - "fix(#489) a title run addresses by its last title": - _Claim(2, ('family', 'given'), "e14159a4d48f", None), - "change(suffix-acronym-collisions) esq leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), "ef9c8cfc56d8", None), - # `_ambiguities` is in the ROLES at the three 2.x baselines and - # not at 1.4.0, which has no ambiguity surface to compare - # (compare.py's _RULE_FIELDS). Same regex, so the reach and the - # digest are identical in all four ledgers and only the roles - # move. - "fix(#489) the title peel leaves a name word a suffix cannot be": - _Claim(3, ('family', 'given', 'suffix', 'title'), "07b02286cd81", None), - # 2026-09-18 (#289/#516): 16 -> 17. One new corpus name, - # 'JOHN PROF. MA', added to the alternation deliberately -- - # the ledger comment argues it. No role joined the five. - "fix(#316) a trailing period-marked title word reads as a title": - _Claim(17, ('family', 'given', 'middle', 'suffix', 'title'), - "0dc3551e57da", None), - # The CJK member of the same argument, its own literal rule - # (an alternation holding a script-classified member belongs - # to the honorific pin above -- until #322/#323 that was every - # such alternation, and what the pin owns is derived from the - # fold now, not declared). - # ONE corpus name, and the roles are the one thing that - # differs by ledger here: this baseline read the Latin word as - # a post-nominal and the Han words given-first, so all four - # move. - "fix(#316) a trailing Latin title on a native-script name is a title": - _Claim(1, ('family', 'given', 'suffix', 'title'), - "567f09dc9b45", None), - # #322/#323, last in every ledger. ELEVEN corpus names here - # and at 2.0.0, SEVENTEEN at the two 2.x ledgers below (nine - # CJK movers, the katakana 'マイケル.', and the bracketed '(김민준.) - # John Smith', the last two added 2026-09-10 in the - # whole-branch and follow-up review rounds): '김. 민준', '양 지훈.' - # and '양. 지훈' move within {given, middle, family} at this - # baseline -- {family, given}, as it happens -- which - # fix(#271/#272/#298) above declares, and the three - # stop-bearing FAMILY_COMMA rows the same later review round - # added produce no diff at this baseline at all -- so none of - # the six are members of this rule's regex here, and the reach - # is smaller by six. Five roles, the union over the eleven: a - # widening that took a sixth would change the row here before - # it reached the gate. - "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": - _Claim(11, ('family', 'given', 'middle', 'suffix', 'title'), - "671c6c89cf61", None), - # #383/#479's role rule, last in this ledger as in the file. - # TWO corpus names, `family`, `given` and `middle` together -- - # and the two names move DISJOINT pairs of those three, which - # is why the declaration is their union and why a widening - # taking a fourth role would change this row before it reached - # the gate. The regex is a literal alternation of the two, so - # a THIRD name appearing here means the alternation grew: - # seventeen corpus names sit in the class it describes and - # fifteen of them do not move a role. - "fix(#383/#479) a single-letter connective joins only on case evidence": - _Claim(2, ('family', 'given', 'middle'), "ec00806a06f0", - ('DEFAULT',)), - # #289's alternation, appended with #516's and the report - # rule's below it. Literal-anchored to the movers a written - # case contrast decides, `orders` DEFAULT: the class is a - # SHAPE and not a wordlist, so a member spelled as the class - # would claim the one-case spellings that do not move. - # _MUST_NOT_MATCH carries those, and at a reach this small it - # is the wall this row cannot be. - # 2026-09-18, second round: two more names, 'John Smith, Ma' - # and 'Smith Jr., Ma', the Title-case halves of two minimal - # pairs rules.md#C1 now states. No role joined the list. - # 2026-09-27, #544: 19 -> 18; loses 'abdul Smith Jr Ma'. - "fix(#289) a written case contrast decides a bare ambiguous acronym": - # 2026-09-18, review round: 'John de Ma' joins, the - # one-particle spelling of 'John van der Berg Ma'. Its diff - # is the restored chain report at the 2.x baselines and the - # role move at 1.4.0 and 2.2/2.3; no role joined the list. - _Claim(18, ('family', 'given', 'middle', 'suffix'), "b1b2b5419b22", ('DEFAULT',)), - # #516's alternation. Literal-anchored to the by-shape movers, - # `orders` DEFAULT. Same reasoning as the rule above: the - # class is a shape the vocabulary does not spell, so the - # probes and not this number are what bound it. - # 2026-09-30, #563: 9 -> 10, a different set. 'John Smith, A.B.' - # leaves for the #563 rule (paired initials, rules.md#C1), and - # 'John Smith, X.Y.Z.' and 'García Márquez, G.J.R.' join, the - # comma form's by-count movers. Literal-anchored, so the reach - # IS the mover list. - "fix(#516) an unlisted dotted acronym is read by position": - _Claim(10, ('family', 'given', 'middle', 'suffix'), "9efed4efb90b", ('DEFAULT',)), - # 2026-09-30, #563: new. Literal-anchored to its movers -- - # the four role movers; a report-only diff is invisible at this baseline. - "fix(#563) paired initials after a comma read as the given name unless a word speaks for them": - _Claim(4, ('family', 'given', 'middle', 'suffix', 'title'), "ff266bb1798d", ('DEFAULT',)), - # 2026-09-18, verification round. ONE name, and a rule this PR - # did not earn: 'Doe, John MA' has read middle since 2.0.0 and - # reads suffix at 1.4.0, so the diff exists at this baseline - # alone. It entered the corpus as a rules.md#S2 boundary - # example line, which is what forced the classification. - # Literal-anchored, `orders` DEFAULT: the class is a SLOT, and - # a member spelled as the slot would claim every family-comma - # name whose given part ends in an ambiguous acronym -- - # including the ones v1 read the same way. - "fix(given-part-trailing-slot) a credential acronym ending the given part of a family-comma listing reads as a middle name": - # 2026-09-19, #531: 1 -> 4, and the name it HELD is not among - # the four. 'Doe, John MA' reached 1.4.0 parity and left; the - # four that replaced it -- 'Doe, John Ma', 'Doe, John Ed', - # 'Doe, Mary Jo Ma', 'Doe, John MA Ma' -- entered the corpora - # with #531's case rows carrying the SAME reading this rule - # describes, declined by the writing rather than by the slot's - # silence. Verified name by name against the gate's - # UNEXPLAINED block at this baseline. A rule re-pointed, not - # widened: the count grew and the digest moved together. - _Claim(4, ('middle', 'suffix'), "c6d26d145ee1", ('DEFAULT',)), - # 2026-09-19, #531. Three rules the round added at this - # baseline, each literal-anchored and each claiming exactly - # the names its ledger comment names -- verified name by name - # against the gate's UNEXPLAINED block here. #531's dotted - # half (one name, the one v1 parity BREAK the change accepts); - # the interior-credential control, whose member is not at the - # trailing slot at all; and the fix(#380) decision read over - # the two other collision words, five names this PR measured - # byte-identical at cc78c960. - # 2026-09-30, #563: 1 -> 2, 'García Márquez, Ed G.J.' -- a - # class member in front of a pair keeps the family comma, and - # the pair then ends the given part. Literal-anchored. - "fix(#531) a dotted credential ending the given part leaves 1.4's middle name": - _Claim(2, ('middle', 'suffix'), "0d423d266341", ('DEFAULT',)), - "fix(comma-family) an interior credential acronym stays a middle-name word": - _Claim(1, ('middle', 'suffix'), "4831097f5067", ('DEFAULT',)), - # 2026-09-19, #531 fix round: two rules land, each literal- - # anchored to names that arrived in the same commit, so both - # claims are born at exactly what their regex spells -- three - # names and one. Neither reading moved in that commit: all - # four parse byte-identically at cc78c960 (measured). - "fix(comma-family) a credential acronym grouping joined into one piece stays middle-name text": - _Claim(3, ('middle', 'suffix'), "4095bb985318", ('DEFAULT',)), - "fix(#380) the do attachment leaves the credential in front of it in the middle name": - _Claim(1, ('family', 'middle', 'suffix'), "8080ca73a18a", - ('DEFAULT',)), - "fix(#380) a trailing mc or do after a family comma is the tussenvoegsel, not a post-nominal": - _Claim(5, ('family', 'suffix'), "13fad71c96d0", ('DEFAULT',)), - # #533's rows, and the rule is not #533's: six corpus names - # whose reading this change leaves alone, carrying the - # `suffix` v1 read beside the three fields fix(#274) declares. - # A number that grows here without a corpus row growing with - # it is this rule reaching a name whose clause DOES give the - # word up, which is the rule below. - # 2026-09-27, #544: 6 -> 7; gains 'Jane Doe Jr. nee Smith Ma', - # a case row #544 added whose roles every 2.x release reads. - # 2026-10-04, #601/#602: 7 -> 3; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#274/#424) accepted: a maiden clause keeps a trailing credential v1 read as a post-nominal": - _Claim(3, ('family', 'maiden', 'middle', 'suffix'), "bd025e92d67b", None), - # One corpus name, four roles. The rule is a compound of two - # released changes and #533 moves nothing on it, so a growth - # here is this rule reaching a name whose clause the - # ambiguous member ended instead. - # 2026-09-27, #544: 1 -> 3; gains 'Doe, Jane nee Smith PhD - # MEng' and 'Jane Doe nee Smith PhD MEng', case rows #544 added - # whose roles every 2.x release reads -- the same two changes. - # 2026-10-04, #601/#602: 3 -> 2; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#274/#436/#437) a clause the unambiguous credential ended, and the run it left renders with spaces": - _Claim(2, ('family', 'maiden', 'middle', 'suffix'), "45b66dfef88c", None), - # Four corpus names, four roles. The `suffix` is where the - # released member lands and `maiden` is what it left, so a - # widening that took one without the other would change the - # roles here before it reached the gate. - # 2026-10-04, #601/#602: 4 -> 3; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) the clause gives the credential up, and the run it joins renders as the writer spaced it": - _Claim(3, ('family', 'maiden', 'middle', 'suffix'), "19d1d3c15d86", None), - # One corpus name. The by-shape member has no lean to read, - # so a growth here is the rule reaching a LISTED member -- - # a different reading under this rule's sentence. - "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause leaves v1's family name": - _Claim(1, ('family', 'maiden', 'middle', 'suffix'), "0cc7bd35a9b3", None), - # One corpus name, three roles and no `suffix`: v1 read the - # trailing MA as a post-nominal and so does the tree. A - # `suffix` appearing here would mean the phrase rule had - # reached a name whose member moved. - "fix(#434/#533) a marker PHRASE takes the maiden name, and its clause ends at the credential": - _Claim(1, ('family', 'maiden', 'middle'), "bf8359f65c8a", None), - # 2026-10-04, #601/#602: 5 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#274/#533) the clause keeps the credential a join or a missing reader would have taken": - _Claim(1, ('family', 'maiden', 'middle', 'suffix'), "13f391350f24", None), - "fix(#335/#533) a marker-led bracketed clause is the maiden name whatever its last word is": - _Claim(3, ('maiden', 'nickname'), 'cc1045ecdc05', None), - # 2026-09-20, #397 and #461's three new rules at this - # baseline, all literal alternations of exactly the names each - # explains -- so a count that moves means the alternation - # grew, and _MUST_NOT_MATCH carries the walls a reach this - # small cannot be. - # - # The join reaches 5 and declares the union of its two diff - # shapes: {family, suffix} for the comma spelling, whose link - # was a GENERATION in v1's `suffix`, and {family, middle} for - # the four written straight. The suffix-routing rule reaches - # its 1 and declares the same three roles for a diff #397 does - # not cause at all -- a trailing two-word generation run that - # v2 has peeled whole since 2.0. #461's readmission rule - # reaches 15 on `_initials` alone: fifteen names whose - # connective holds its part alone, every one of which agreed - # with the 1.4.0 wheel before this bundle. It has no joining - # twin here, the joining half having diffed at this baseline - # since 2.0 on the per-word grouping rule. - "fix(#397) the Catalan/Polish link joins two surnames": - _Claim(5, ('family', 'middle', 'suffix'), "0c31a48b1867", - ('DEFAULT',)), - "fix(suffix-routing) a two-word trailing generation run peels whole, and the name word before it is the family": - _Claim(1, ('family', 'middle', 'suffix'), "5ecc6ba1109e", - ('DEFAULT',)), - "fix(#461) a connective holding its part alone contributes an initial": - _Claim(15, ('_initials',), "4d436d1ebeca", ('DEFAULT',)), - "fix(#274/#436/#437) a clause the generational link ended, and the run it left renders with spaces": - _Claim(1, ('family', 'maiden', 'middle', 'suffix'), - '7d64445a7252', None), - # New rule (#274/#296/#535): one corpus name, 'Jane Doe nee - # Prof. Dr.' -- moved out of the nine-name list below (review): - # this baseline still has 'dr' in the suffix vocabulary - # (#296 had not run yet), so it reads suffix 'Dr.' rather than - # keeping the word in the maiden clause; #274 (no maiden - # reading at all) is why it reads middle 'Doe nee' and last - # 'Prof.' where the tree reads family 'Doe' and maiden 'Prof.'. - # Verified against the parent tree - # d9d80492: before #535, it already reads maiden 'Prof. Dr.' - # (dr having left suffix vocabulary by #296), differing from - # this baseline on {family, maiden, middle, suffix}; #535 then - # moves the word on into `title`. - "fix(#274/#296/#535) a trailing title after a dropped postnominal Dr., with no maiden reading either": - _Claim(1, ('family', 'maiden', 'middle', 'suffix', 'title'), - '193350645868', None), - # New rule (#274/#535): eight corpus names -- the title-stop - # literal list, minus 'Dr. nee Jones Smith Prof.' and 'Jane - # Doe nee Prof. Dr.' (each its own rule above). Relabelled - # from a bare '#535' rule (review): this baseline has no - # maiden/title reading at all, so every name here differs from - # it for #274's reason too, verified against the parent tree - # d9d80492 -- before #535 ran, seven of these eight already - # differed from this baseline on exactly {family, maiden, - # middle}, and the eighth (the family-comma one, 'Doe, Jane - # nee Smith MA Prof.') on exactly {maiden, middle, suffix} - # instead, `family` there already matching. #535 additionally - # moves `title` (and `suffix` for the credential-bearing ones - # among the seven; 1.4.0 is compared on the facade, where - # `_ambiguities` does not exist). - # 2026-10-04, #601/#602: 9 -> 7; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#274/#535) a trailing title ends the maiden clause": - _Claim(7, ('family', 'maiden', 'middle', 'suffix', 'title'), "5ffb52f287a9", None), - # 2026-09-27, #544: new, 1; gains 'John Smith, X.Y.Z. MA'. - "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count": - _Claim(1, ('family', 'given', 'suffix'), "23c792cefe18", ('DEFAULT',)), - # 2026-09-28, #544: new, 1; 'Smith, MD PhD Ma'. - "fix(#296) phd is not a prenominal, so behind a dual title it is the given name": - _Claim(1, ('given', 'middle', 'title'), "8e9913f5df8b", None), - # 2026-10-02, #573: new, 1; 'SMITH VD MA, JOHN'. - "fix(#573) a particle-suffix word before a family comma stays in the family": - _Claim(1, ('family', 'suffix'), "638c43d95171", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'SMITH, VD DE LA MA', - # 'SMITH, VD MA', 'smith, de ma'. - "fix(#573) a never-given particle opening the given part takes the member behind it": - _Claim(3, ('family', 'suffix'), "54ce0dda7114", ('DEFAULT',)), - "fix(#585) a decomposed initial keeps its whole first letter": - _Claim(2, ('_initials',), "c4f045134f0f", None), - "fix(#594) a period-marked halfwidth katakana word is not a title": - _Claim(2, ('given', 'title'), "8cdafcf56c45", None), - "fix(#597) halfwidth corner brackets enclose a nickname": - _Claim(3, ('family', 'given', 'middle', 'nickname'), "d1b9b0addff3", None), - # 2026-10-04, #601/#602: new rules. - "fix(#601) a marker in the given part after a family comma is an ordinary word": - _Claim(6, ('family', 'middle', 'suffix', 'title'), "007582298dfc", None), - "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word": - _Claim(4, ('family', 'middle', 'suffix', 'title'), "456e8a63ed75", None), - # 2026-10-04, #610: +1, 'Jane Doe nee Smith VI Prof.', rules.md#M2's - # numeral example behind a title. Reach. - "fix(#274/#601) the clause ends at the clause-free name's trailing run, which the take consumes": - _Claim(5, ('family', 'given', 'maiden', 'middle', 'suffix', 'title'), "7d2d5efa8eb5", None), - "fix(#274/#601) the first word after the marker is the maiden name": - _Claim(1, ('family', 'maiden', 'middle', 'suffix'), "aaf53040b071", None), - "fix(#602) a credential after the name core starts a run to the end of its part": - _Claim(5, ('family', 'middle', 'suffix', 'title'), "3129cd9609b9", None), - "fix(#627) a lone particle with the run right behind it is the surname": - _Claim(1, ('family', 'middle', 'suffix'), "33096566ba7f", None), - "fix(#624) of two title-particles at the head the second leads the chain": - _Claim(1, ('family', 'given', 'title'), "c5b6da915be2", None), - "fix(#623) after a family comma the chain stops before a trailing title": - _Claim(2, ('family', 'middle', 'title'), "bbab0baa7826", None), - "fix(#274/#601/#602) a credential in the clause starts a run the take consumes": - _Claim(1, ('family', 'maiden', 'middle', 'suffix'), "acdcc81cb17c", None), - # 2026-10-04, #604: new, 3; the rules.md#P7 boundary and the - # two rules.md#R4 case-repair examples. - "fix(#604) the Irish and Malay patronymic particles": - _Claim(3, ('family', 'middle'), "cfcfd91f9d58", None), - # 2026-10-04, #606: new, 1; 'Greve Anna', rules.md#H1's - # Accepted example for a surname-borne title. - "fix(#606) salutations and titles in other languages": - _Claim(1, ('given', 'title'), "0c78dbac8a6b", None), - # 2026-10-04, #603: new, 5; rules.md#C1's opened-part examples and the delimiter rows it moves. - "fix(#603) a credential opening the part after the comma makes it the postnominal part": - _Claim(5, ('family', 'given', 'middle', 'suffix', 'title'), "a4ebde5b8cd7", None), - # 2026-10-04, #603: new, 7; rules.md#C2's tail-title examples and the radar names it moves. - "fix(#603) a title word in a part past the second comma is a title": - _Claim(7, ('suffix', 'title'), "8cbad6f13c6b", None), - # 2026-10-07, #614: new, 1; rules.md#H5's 'John van der Berg Prof.' example. - "fix(#614) a trailing title is read before the particle chain": - _Claim(1, ('family', 'title'), "4536c33ed2a2", None), - "fix(#652) a decomposed accented abbreviation is an abbreviation": - _Claim(1, ('given', 'middle', 'title'), "df11091e2fc1", None), - }, - "expected_since_2.0.0.toml": { - # The ph removal (#459/#521): one literal name, the cases.py - # row written to carry the bare trailing `Ph.` shape, since no - # corpus name had it -- which is why the removal measured as - # zero role movers at review time. - "change(suffix-acronym-collisions) ph leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's rules, eight at landing (2026-09-25) -- five literal - # alternations and three literal names -- and six since #544 - # (2026-09-27) retired two, every name one of #540's own case - # rows. The - # report rule's `_ambiguities`-only roles are its point: a - # widening that took a role would change them here first. - "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), - "fix(#540) a lone meng or MEng after a family comma reads as the given name": - _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), - "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": - _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'John Smith, PhD MEng', 'john smith, phd meng'. - "fix(#540) a Title-case Lac behind a particle is the family name": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'Wang M.Eng.'. - # #436/#437's Latin alternation, first in every ledger. - # Ten corpus names, `suffix` alone: the rule moves the - # SEPARATOR and no role, so a widening that took a role would - # change the row here before it reached the gate. - # 2026-09-20, #397: 11 -> 14, three new comma-written - # generation runs ('Josep Lluis Carod i III', 'Josep Lluis - # Carod i V', 'Rovira, Josep Carod i Jr.'), each of which #397 - # leaves byte-identical -- measured against the parent - # 46651750 -- and each of which arrived with that change's own - # case rows. Roles unmoved, `suffix` alone as before. - # 2026-09-27, #544: 15 -> 18; gains 'Jane Doe Jr. Ma', 'John - # Smith PhD Ed Ma', 'John Smith PhD Ma'. - # 2026-10-04, #601/#602: 18 -> 17; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": - _Claim(17, ('suffix',), "8f6fa4cee3c3", None), - # #449's six rules, second in every 2.x ledger. The - # alternation reached twenty-two corpus names at landing - # (twenty-three since #544, 2026-09-27) and - # `_ambiguities` alone: no role moves anywhere in this change, - # so a widening that took a role would change the row here - # before it reached the gate, and a member reaching a name - # something DECIDED ('abdul', 'de', 'Dr. Smith') would move - # the count. The five CJK rules are literal-anchored on one - # corpus name each, a reach _CORPUS_CLAIMS cannot police on - # its own -- a widening into names the corpora lack leaves it - # unmoved -- so _MUST_NOT_MATCH carries the boundary probes - # beside them. The digests are the same in all three 2.x - # ledgers because the regexes are the same strings and the - # corpora are one set; the ROLES are not, and the two glued - # honorific rules are why -- only at this baseline is #308's - # peel still in the diff beside the report, and the gate - # refuses a rule declaring a role no diff it explains moves. - # 2026-09-27, #544: 22 -> 23; gains 'Wang M.Eng.', whose diff - # here is the one-word name's report alone. - "feat(#449) a lone name word reports given-or-family": - _Claim(23, ('_ambiguities',), "ea61ec446eef", None), - "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it": - _Claim(1, ('_ambiguities',), "80777383a11a", None), - "feat(#449) an interpunct transcription declines the script order, so the convention decides it": - _Claim(1, ('_ambiguities',), "ba9a4258c864", None), - "feat(#449) an ideographic comma leaves one name word, and the convention decides it": - _Claim(1, ('_ambiguities',), "4b2858642238", None), - "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order": - _Claim(1, ('_ambiguities', 'given', 'suffix'), "528858346d14", - None), - "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order": - _Claim(1, ('_ambiguities', 'given', 'suffix'), "30ec95e25f05", - None), - "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it": - _Claim(1, ('_ambiguities',), "63f797a141db", None), - # #491's two rules, beside #449's for the same reason: both - # classify on `_ambiguities` alone, and no role moves anywhere - # in this change, so a widening that took a role would change - # the row here before it reached the gate. Six title names and - # two suffix names, one alternative each; a member reaching a - # name the SHAPE does not select ('Dr. Smith', 'Rabbi Cohen', - # 'Smith Jr.', the lone 'Dr.' the peel takes whole) would move - # the count, and _MUST_NOT_MATCH carries those probes. Same - # digests in all three 2.x ledgers: the regexes are the same - # strings and the corpora are one set. - "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name": - _Claim(6, ('_ambiguities',), "9ee2a07d96c8", None), - # 2026-09-23, #459: 2 -> 3, 'qc mp', rules.md#R4's name-role - # boundary line. Same digest in all three 2.x ledgers. - "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name": - _Claim(3, ('_ambiguities',), "6d5341766f53", None), - # The join clause's own rule, added by the #518 review round: - # hoisting the clause out from under O5's field-deciding - # guards reached two corpus names, both a peeled title in - # front of a joined unit standing last. Literal-anchored on - # the two, so _MUST_NOT_MATCH carries the boundary probes. - "feat(#491) a joined unit carrying title vocabulary reports title-or-name": - _Claim(2, ('_ambiguities',), "7af3fec03ccc", None), - # #346's alternation. Four corpus names, `family` and - # `given` together: the fold moves both roles at once, so a - # widening taking one alone would change the roles here - # before it reached the gate. - "fix(#346) a renunciate title and one name word leave the name a given name": - _Claim(4, ('family', 'given'), "a3399ee7b21e", None), - # #519's alternation. Three corpus names, `family` and `given` - # together: the fold moves both roles at once, so a widening - # taking one alone would change the roles here before it - # reached the gate. - "fix(#519) prince and princess join the given-name titles": - _Claim(3, ('family', 'given'), "ca37331f9803", None), - # #342's alternation. Five corpus names and four roles: the - # comma forms move `given` where the bare forms move `middle`, - # so a widening taking one shape alone would change the roles - # here before it reached the gate. - "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word": - # 2026-10-01, #564: `given` leaves the roles -- the comma pair - # reads suffix again by its capitals, so no name here moves it. - _Claim(5, ('family', 'middle', 'suffix'), "c3d76812da97", None), - # The compound rule, at the two baselines where 'abdul Smith - # Jr V' already diffs {family, given} under fix(#401) and the - # widened diff leaves that rule's `fields`. Three roles here - # because the diff it explains carries all three. - "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve": - _Claim(1, ('family', 'given', 'suffix'), "8152bc33f8da", None), - "fix(#335) a marker-led clause leaves the one name word its bare reading": - _Claim(1, ('maiden', 'nickname'), "c09cc7dba88b", None), - "fix(#434) a multi-word maiden marker takes the maiden name": - _Claim(1, ('family', 'maiden', 'middle'), "c428798fc6ef", None), - "fix(#434) a multi-word marker leads a bracketed clause to the maiden name": - _Claim(1, ('maiden', 'nickname'), "0b3ef183f283", None), - "fix(#335) a marker-led bracketed clause reads as the maiden name whatever pair encloses it": - _Claim(5, ('maiden', 'nickname'), "a419f74143e3", None), - "fix(#335) a marker-led bracketed clause reads as the maiden name, compounding with the CJK order flip": - # 2026-10-12, #653: 1 -> 2, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', - # the drop-pass witness row, added to the regex by literal: - # its paren clause is the marker-led one this rule names. - _Claim(2, ('family', 'given', 'maiden', 'nickname'), "3a39bc53290f", None), - "fix(#410) a title and one name word name the family, whatever annotation stands beside it": - _Claim(4, ('family', 'given'), "da1dd1473145", None), - "fix(#430) a credential run does not end at the roman numeral describing it": - _Claim(2, ('given', 'suffix'), "3c8fa6bc827a", None), - "fix(#432) a dotted numeral behind a name is a middle initial, not the generation": - _Claim(1, ('middle', 'suffix'), "e9f282da0d0f", None), - "fix(#429) a wholly-credential segment after a one-word family renders as one entry": - _Claim(1, ('suffix', 'title'), "9e0b9e8d5cbe", None), - # 2026-09-18, #531: 13 -> 19, the same six particle-trailing - # comma names as the 1.4 twin, whose entry carries the roster. - # 2026-09-19, #531 review round: 19 -> 20, the same one new - # name as that twin ('Doe, John van DO'). - # 2026-09-27, #544: 26 -> 27; gains 'doe, jane v phd do'. - # 2026-10-02, #573: 31 -> 32, 'DOE, JANE PHD VD', P6's - # boundary, which attaches as this rule reads. Reach, verified. - "fix(#379) a tussenvoegsel after a family comma attaches to the family": - # 2026-09-19, #531 fix round: 20 -> 21, the same one new - # corpus name as the 1.4.0 copy ('Doe, John MA do'), reached - # on the trailing tussenvoegsel and explained by neither. - # 2026-09-19, #533: 21 -> 24, the same three new corpus - # names as the 1.4.0 copy -- the do pair this change added - # after a family comma. - # 2026-10-01, #562: 27 -> 29, 'John Smith, PhD DO DO' - # and 'John Smith, PhD vd DO', #562's rules.md#C1 examples - # and case rows. Reach, verified name by name. - # 2026-10-01, #554: 29 -> 31, 'John Smith, Jr vd' and 'John - # Smith, Jr do', #554's rules.md#C1 examples. Reach, verified - # name by name. - _Claim(32, ('_ambiguities', 'family', 'middle'), "3e3ff4af0060", None), - "fix(#379) a tussenvoegsel behind a post-nominal after a family comma attaches": - _Claim(1, ('family', 'middle'), "f24e5eee3cc1", None), - # 2026-09-18: 126 -> 131. Five corpus names arrived with - # #289/#516's own case rows -- 'J.씨', 'John Smith 田.中.', - # '毛泽东, MA', '田中 太郎, MA', '마틴 킹, MA' -- all of them - # names this rule already described. Growth into new - # corpus, not a widened regex. - # 2026-09-18, #531: 135 -> 137, the same two CJK comma forms - # as the 1.4 twin. - # 2026-09-19, #533: 137 -> 138, the same one new tolerated - # corpus name as the 1.4.0 copy, '田中 太郎 旧姓 佐藤 MA'. - # 2026-10-02, #585: 138 -> 139, one new corpus name and not a - # wider rule: the decomposed katakana R3 row 'マイケル ジャクソン' - # (NFD) lies in its script span, and fix(#585) explains it. - # 2026-10-03, #594: 139 -> 147, and this time the regex DID - # widen: its class copies the halfwidth kana block U+FF65-U+FF9F - # whole, where it held U+FF65 alone. What it reaches beyond that - # is exactly the eight halfwidth case rows #594 added -- no - # corpus line held halfwidth kana before them. It explains the - # five that move only name fields; the two title movers also - # move `title`, outside its fields, and go to the #594 rule; - # 'ヤマダ タロウ' is parity, as 'マイケル ジャクソン' already was - # inside the same class. - # 2026-10-03, #596: 147 -> 153, six new corpus names and not a - # wider rule: the five kana voicing-mark case rows and the - # tolerated hangul one ('김\u3099민준') lie in its script span. - # It explains 'あ\u3099い たろう' and '山田 ア\u3099イ', which move - # here as every native-script name does; the three fix(#596) - # movers are parity at this baseline, and the hangul row is - # claimed first by its own #449 rule, on its report. - # 2026-10-03, #597: 153 -> 155, two new corpus names and not a - # wider rule: '山田 「タロー」 タロウ' and '山田「タロー」太郎' lie in its script - # span ('John 「Jack」 Smith' does not); fix(#597), which declares - # `nickname` beside the name fields, explains all three. - # 2026-10-06, #613 PR review: 155 -> 161; its reach absorbed the tolerated CJK case rows the review added. - # 2026-10-06, #613 /simplify: 161 -> 162; its reach absorbed the tolerated J.씨 case row the /simplify pass added. - "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots": - # 2026-10-12, #653: 162 -> 163, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', the case row that - # took over the #329 drop-pass witness. Reach, not - # explanation: its diff here is the fix(#335) CJK-order rule's. - _Claim(163, ('_ambiguities', 'family', 'given', 'middle'), "329f9a9c49f1", None), - # 37 -> 35 with the same 2026-09-05 narrowing as the 1.4 twin, - # whose entry carries the reason. Here the one name that - # changed hands, '김민준 박사님', goes to the spaced rule - # fix(#307/#308/#320) -- the label its title states. - # 35 -> 40 since #323 by the same five names, the 1.4 twin's - # entry carrying the arithmetic. - # 2026-09-18: 40 -> 41. One new corpus name, 'J.씨'. - # 2026-10-06, #613 PR review: 44 -> 47; its reach absorbed the tolerated CJK case rows the review added. - # 2026-10-06, #613 /simplify: 47 -> 48; its reach absorbed the tolerated J.씨 case row the /simplify pass added. - "fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix": - _Claim(48, ('family', 'given', 'suffix'), "aad99618f822", None), - "fix(#307/#308/#320) spaced CJK postnominal honorific routed to suffix": - _Claim(16, ('family', 'given', 'middle', 'suffix'), "6d390e518bd2", None), - # 2026-09-19, #533: 5 -> 6, the same one new tolerated - # corpus name as the CJK rule above. - "fix(#309) 旧姓 maiden marker consumed, compounding with the CJK order flip": - # 2026-10-12, #653: 6 -> 7, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', the case row that - # took over the #329 drop-pass witness. Reach, not - # explanation: its diff here is the fix(#335) CJK-order rule's. - _Claim(7, ('family', 'given', 'maiden', 'middle'), "9879a6808907", None), - "fix(#272) nakaguro inside delimited content renders as a space, compounding with the CJK order flip": - # 2026-10-12, #653: 1 -> 2, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', the case row that - # took over the #329 drop-pass witness. Reach, not - # explanation: its diff here is the fix(#335) CJK-order rule's. - _Claim(2, ('family', 'given', 'nickname'), "33c47dfd85eb", None), - "fix(#298) 间隔号 division changes the comma reading, sending the credential from title to suffix": - _Claim(1, ('family', 'given', 'suffix', 'title'), "1d45596e6fdb", None), - "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle": - _Claim(1, ('_ambiguities', 'family', 'given'), "ca7b37af6cf8", None), - "fix(#367) a title no longer displaces a leading particle out of the leading position": - _Claim(3, ('family', 'given'), "724967a4a117", None), - # 2026-10-01, #554: 2 -> 3, 'John Smith, Jr vd', #554's - # rules.md#C1 example. Reach, verified by name. - # 2026-10-02, #573: 3 -> 4, 'DOE, JANE PHD VD', P6's - # boundary, which attaches as this rule reads. Reach, verified. - "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal": - _Claim(4, ('_ambiguities', 'family', 'suffix'), "25798c4e2a22", None), - # 2026-10-04, #601/#602: 6 -> 7; 'Mai Le née Nguyen', a new rules.md#M2 example, is in reach -- at this baseline the particle chain swallowed its marker, the #399 shape. - "fix(#399) a maiden marker bounds the particle chain that swallowed it": - _Claim(7, ('family', 'maiden'), "0a306405fb9a", ('DEFAULT',)), - "fix(#360) mc moved into the never-given particles, so it folds into the family": - _Claim(1, ('_ambiguities', 'family', 'given'), "ee4339908f4d", None), - "fix(#400) abd joins the word after it as one given name": - _Claim(11, ('given', 'middle'), "1eaed91fc574", None), - "fix(#367) a title no longer displaces a leading never-given particle": - _Claim(1, ('family', 'given'), "db724fb9c779", None), - "fix(#272/#308) nakaguro division and a glued hangul honorific in one name": - _Claim(1, ('family', 'given', 'middle', 'suffix'), "2fbf1a94f122", None), - # 2026-09-19, #533: 1 -> 2. One new corpus name, 'Berg, - # abdul nee Jones MA' -- this change's record that P5's - # lenient post-comma join takes the released member before - # assign can read it, so the clause form agrees with the - # bare 'Berg, abdul MA'. Reach, not explanation. - # 2026-09-26, #535: 2 -> 3, 'Berg, abdul nee Smith V' joining - # the corpus -- the same bound-given/maiden shape. Reach, not - # explanation. - # 2026-10-04, #601/#602: 3 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#411) the bound-given reserve stops counting words the maiden name takes": - _Claim(1, ('given', 'maiden', 'middle'), "431d3dd24c14", None), - "fix(#412) a connective join no longer absorbs the maiden marker beside it": - _Claim(2, ('family', 'maiden'), "51c0eb36b5c5", None), - "fix(#418) the connective carve-out counts the name the maiden clause leaves behind": - _Claim(1, ('family', 'given', 'middle'), "7923e6d3c5a7", None), - "fix(#418) accepted: a suffix word inside the maiden name ends it, connective or not": - _Claim(1, ('family', 'maiden', 'middle'), "bedc18423d2a", None), - "fix(#369) a given-name title licenses the bound given-name join with one word to spare": - _Claim(3, ('family', 'given'), "724be3e6b926", None), - "fix(#401) the bound-given reserve counts the trailing numeral assign reads as the suffix": - _Claim(2, ('family', 'given'), "5713d4c0bd68", None), - "fix(#421) the bound-given join never absorbs a suffix piece": - _Claim(1, ('given', 'middle'), "9523e518e6ec", None), - "fix(#421) the bound-given join never absorbs a split credential": - _Claim(1, ('given', 'middle', 'suffix'), "228abe0f32ef", None), - # 2026-09-18 (#289): the reach is unchanged at two names and the - # ROLES moved -- `given` out, `middle` in. 'abdul Smith Ma' - # stops diffing at this baseline altogether and - # 'abdul Smith Jr Ma' now reads middle 'Jr'; the ledger's - # own dated paragraph carries the argument. - # 2026-09-27, #544: 2 -> 2; roles ['family', 'middle', - # 'suffix'] -> ['family', 'given', 'suffix'], and relabelled - # fix(#425/#436/#437), the `suffix` being R1's spacing alone. - "fix(#425/#436/#437) the bound-given reserve runs assign's peel over the joined view": - _Claim(2, ('family', 'given', 'suffix'), "ef1ab03b617e", None), - "fix(#424) the particle chain stops before the trailing numeral": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "2c99162bc9cf", None), - # 2026-09-18 (#289): 1 -> 2. 'john van der berg ma' entered - # the corpus as rules.md#P2's one-case contrast, and it is the - # spelling this rule still describes -- see the ledger's own - # dated paragraph, which replaced a `dormant` key with it. - "fix(#424) the particle chain stops before a bare acronym with words to spare": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "18fbd95cd89e", None), - "fix(#424) a title-led chain before the numeral is the one name piece": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "5b3a743f9e35", None), - "fix(comma-family) a comma followed only by titles keeps the given/family split": - _Claim(2, ('family', 'given'), "5bd9c6d96c38", None), - # 2026-10-06, #613: 2 -> 3; absorbed 'John Smith, Mr. and Mrs.', the C1 example #613 added; the regex was widened to take it. - "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example": - _Claim(3, ('family', 'given'), "e87f5944b870", None), - "fix(#296) a dropped prenominal takes the name position it occupies": - _Claim(5, ('_ambiguities', 'given', 'middle', 'title'), "e5aa72ebb9bf", None), - # `middle` left the ROLES in the same edit, and the reach - # grew with the rules corpus; the 1.4.0 roster above carries - # both, and says the same at the other two baselines. - # 2026-09-26, #535: 13 -> 14, 'Jane Doe nee Prof. Dr.' joined - # the corpus and ends in " Dr." like the rest this rule reaches. - # 2026-10-06, #613: 14 -> 15; its reach absorbed 'Ortega y Gasset, Dr.', the C1 example #613 added, which ends in 'Dr.'. - "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word": - _Claim(15, ('family', 'suffix'), "0c6062c4dbd7", None), - "fix(#296) a credential-only comma string reads a name and its postnominal": - _Claim(2, ('suffix', 'title'), "3f983ff71dee", None), - # 2026-09-18: 18 -> 20. Two new corpus names, 'Smith, MA' and - # 'Smith, Ma' -- the pair #289's lean reads in opposite - # directions, both of them lone post-comma credentials - # by this rule's own description. - # 2026-09-18, #531: 20 -> 21, the same one new name - # ('Doe, MA') as the 1.4 twin. - # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, - # meng' and 'Smith, MEng' -- reached by the same post-comma - # shape this rule matches, but read the opposite way and - # explained by neither: at this baseline the diff moves - # `given`, a field outside this rule's own ('suffix', 'title'). - "fix(#296) a lone post-comma credential is a suffix": - # 2026-10-01, #564: 23 -> 24, 'Smith, XYZ'. Reach. - # 2026-10-04, #606: 24 -> 25, 'Greve, Anna'. Reach. - _Claim(25, ('suffix', 'title'), "b2bc2b830168", None), - # 2026-09-27, #544: 6 -> 7; gains 'Smith, PhD MEng'. - # 2026-09-28, #544: 7 -> 8; gains 'Smith, PhD Ma'. - "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": - _Claim(8, ('given', 'suffix', 'title'), "81e29a5745e7", None), - "fix(#325) a credential run across a second comma reads as suffixes": - _Claim(1, ('suffix', 'title'), "f025c5f70a4e", None), - "fix(#296) a glued honorific before a lone credential: the credential is the postnominal": - _Claim(1, ('family', 'suffix', 'title'), "01bf2bd3f895", None), - "fix(#296) do is a name, so it no longer stops the leading-particle scan as a title": - _Claim(1, ('family', 'given'), "faa2c70fc49e", None), - # 2026-10-04, #603: roles lost `_ambiguities`; a title word past - # the second comma is a title now, so 'John, Smith, Dr.' raises - # no comma-structure report and the rule's fields narrowed. - # 2026-10-06, #613: 2 -> 3; absorbed 'Ortega y Gasset, Dr.', the C1 example #613 added; the regex was widened to take it. - "fix(#296) dr is not postnominal vocabulary, so 'John Smith, Dr.' keeps its split and its title": - _Claim(3, ('suffix', 'title'), "714ad68cf4cc", ('DEFAULT', 'FAMILY_FIRST')), - # 2026-09-18: 1 -> 2. One new corpus name, 'Jack MA.', the - # all-caps spelling #289's case contrast now reads as a - # credential; the rule's own 'Jack Ma.' is untouched. - "fix(#296) an ambiguous acronym counts as a suffix only when written with its periods": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "29b08f75e6ff", None), - "fix(#367) an inferred title no longer displaces a leading particle either": - _Claim(1, ('family', 'given'), "d8ee9cd5da5f", None), - "fix(#424) accepted: a particle of the suffix vocabulary opening the trailing run is a suffix piece": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "a564b97f7162", None), - "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle, the P4 example": - _Claim(1, ('_ambiguities', 'family', 'given'), "42b69cf1b320", None), - "fix(#424/#445) the maiden walk stops before the trailing numeral, and the lone name word is the family": - _Claim(1, ('_ambiguities', 'family', 'given', 'maiden', 'suffix'), "cbe5bdd97317", None), - # 2026-09-19, #533: 6 -> 7, and as at 1.4.0 the new name - # looks like an accident and is not: this rule is an (?i) - # alternation carrying 'John n[ée]e Jones Smith Ma', which - # the example line 'John née Jones Smith MA' matches - # case-insensitively and correctly, the two being one - # given->family shape written two ways. Reach, not - # explanation. - # 2026-10-04, #601/#602: 7 -> 5; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#445) a maiden marker makes the lone name word the family": - _Claim(5, ('_ambiguities', 'family', 'given'), "2648aa85b6b4", None), - "fix(#445) the lone name word beside a marker a connective join no longer absorbs": - _Claim(1, ('family', 'given', 'maiden', 'middle'), "52544a41dd62", None), - "fix(#369) the bound given-name join takes a particle-and-bound word, so no fork is reported": - _Claim(1, ('_ambiguities',), "81cf02ffdb33", None), - "fix(#360) ste moved into the never-given particles with mc": - _Claim(1, ('_ambiguities', 'family', 'given'), "e62caedec864", None), - "fix(#399) a maiden marker bounds the particle chain: the geb. spelling": - _Claim(1, ('family', 'maiden'), "2150936a8c55", None), - "fix(#371) a suffix never begins a name: the Ph./D. merge declines at the head": - _Claim(4, ('family', 'given', 'middle', 'suffix', 'title'), "1425d85a2d86", None), - "feat(#395) a leading never-given particle bounds the family under a declared family-first order": - _Claim(1, ('family', 'given', 'middle'), "47dcff268731", ('FAMILY_FIRST', 'FAMILY_FIRST_GIVEN_LAST')), - "fix(#399)/feat(#395) a consumed maiden marker leaves the family-first fold no given name": - _Claim(1, ('family', 'given', 'maiden'), "504eb466e347", ('FAMILY_FIRST', 'FAMILY_FIRST_GIVEN_LAST')), - "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold": - _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "fc6bc9e605e1", ('FAMILY_FIRST',)), - "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling": - _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "3e43a2be022e", ('FAMILY_FIRST_GIVEN_LAST',)), - # #484's two `_initials` rules. Both are literal name lists; - # reach 31 and 1, explaining 29 and 1 (2026-09-25), the gap - # being 'nguyen van lac' and 'Nguyen Van Lac' below -- and - # both digests match the 1.4.0 ledger's, which is the point - # of copying the list verbatim rather than restating it. - # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and - # NOT explained at this baseline -- its report moves here, - # which keeps `_initials` out of its diff (#484). - # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac' -- the - # case-insensitive regex matches it too, and its role move is - # explained by the Title-case Lac rule below, so #484 keeps - # `_initials` out of its diff the same way. - "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), - "fix(#360) los joined the particles, so it no longer initials": - _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), - # fix(#462) reaches more than it explains -- the reach is the - # _Claim below, and the gap is the names named here. Its regex - # is a letter SHAPE rather than a name list, and the extra -- - # 'E Anne D', 'E Jones', 'E Maria', 'Y. L.' -- carry the E/Y in - # the GIVEN group, which has always initialed every word it - # holds whatever the vocabulary says, so the fix does not move - # them. The discriminator is the GROUP and not the position: - # 'E Anne D,Leonardo' also leads with the E, but its comma - # re-roles the whole run into the FAMILY group, and it moves. - # The digest is the same in all three 2.x ledgers because the - # regex is the same string in each. - # 18 -> 22 on 2026-09-13: four #383/#479 names entered the - # corpora carrying a bare capital E or Y this regex reaches. - # expected_since_2.0.0.toml's copy of this rule says which - # four, and why only one of them diffs. - "fix(#462) the facade keeps an initial-shaped conjunction letter": - _Claim(22, ('_initials',), "7386690d2928", ('DEFAULT',)), - # The 2.3 title-run bundle's five rules, last in every - # ledger, and the same reach at all four: the 1.4.0 roster - # above carries the argument. - "fix(#489) a title run addresses by its last title": - _Claim(2, ('family', 'given'), "e14159a4d48f", None), - "change(suffix-acronym-collisions) esq leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), "ef9c8cfc56d8", None), - # `_ambiguities` is in the ROLES at the three 2.x baselines and - # not at 1.4.0, which has no ambiguity surface to compare - # (compare.py's _RULE_FIELDS). Same regex, so the reach and the - # digest are identical in all four ledgers and only the roles - # move. - "fix(#489) the title peel leaves a name word a suffix cannot be": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "07b02286cd81", None), - # 2026-09-18 (#289/#516): 16 -> 17. One new corpus name, - # 'JOHN PROF. MA', added to the alternation deliberately -- - # the ledger comment argues it. No role joined the five. - "fix(#316) a trailing period-marked title word reads as a title": - _Claim(17, ('family', 'given', 'middle', 'suffix', 'title'), - "0dc3551e57da", None), - # The CJK member of the same argument, its own literal rule - # (an UNDECLARED alternation holding a script-classified - # member belongs to the honorific pin above -- see the 1.4.0 - # mapping's note on what #322/#323 changed there). ONE corpus - # name; this baseline reads as 1.4.0 does, so the roles are - # the same four. - "fix(#316) a trailing Latin title on a native-script name is a title": - _Claim(1, ('family', 'given', 'suffix', 'title'), - "567f09dc9b45", None), - # #322/#323. Reach and digest as in the 1.4.0 mapping, the - # same eleven-member regex over the same corpus; that entry - # carries why six of the bundle's seventeen movers are absent - # here (all seventeen are members at 2.1.0/2.2.0). - # `_ambiguities` joins the roles here and not at 1.4.0: the - # katakana mover carries a GIVEN_OR_FAMILY ambiguity that only - # a v2-surface comparison sees. - "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": - _Claim(11, ('_ambiguities', 'family', 'given', 'middle', - 'suffix', 'title'), "671c6c89cf61", None), - # #383/#479's two rules, last in this ledger as in the file. - # The role rule reaches TWO corpus names and declares four - # fields, which is the union of two disjoint diffs plus the - # report ONE of them carries -- 'jose e maria santos' moves - # `given` and `middle` and reports, 'JUAN GARCIA Y LOPEZ' - # moves `middle` and `family` and reports nothing. Its digest - # is the 1.4.0 rule's, the same literal alternation over the - # same corpora; its roles are not, the report being a v2 - # surface. The report rule reaches SIX, `_ambiguities` alone: - # every role is identical on all six and only the CALL is new. - # Both regexes are literal alternations, so a new name here - # means one of them grew -- seventeen corpus names sit in the - # class they describe and only ten of those move at all. - "fix(#383/#479) a single-letter connective joins only on case evidence": - _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), - "ec00806a06f0", ('DEFAULT',)), - "fix(#383/#479) a marked connective letter in a one-case name is reported": - _Claim(6, ('_ambiguities',), "03de2830707e", ('DEFAULT',)), - # #289's alternation, appended with #516's and the report - # rule's below it. Literal-anchored to the movers a written - # case contrast decides, `orders` DEFAULT: the class is a - # SHAPE and not a wordlist, so a member spelled as the class - # would claim the one-case spellings that do not move. - # _MUST_NOT_MATCH carries those, and at a reach this small it - # is the wall this row cannot be. - # 2026-09-18, second round: two more names, 'John Smith, Ma' - # and 'Smith Jr., Ma', the Title-case halves of two minimal - # pairs rules.md#C1 now states. No role joined the list. - # 2026-09-27, #544: 24 -> 23; loses 'abdul Smith Jr Ma'. - "fix(#289) a written case contrast decides a bare ambiguous acronym": - # 2026-09-18, review round: 'John de Ma' joins, the - # one-particle spelling of 'John van der Berg Ma'. Its diff - # is the restored chain report at the 2.x baselines and the - # role move at 1.4.0 and 2.2/2.3; no role joined the list. - # 2026-09-19, #531: 20 -> 23. Three corpus names, 'Doe, MA', - # 'Doe, MA PhD' and 'Doe, Dr. MA', the lone post-comma - # credentials #531's control rows brought in. Verified to be - # those three and no others; no role joined the list, and the - # 1.4.0 copy does not carry them (its ledger comment says - # which rule already owns their diff there). - # 2026-09-19, #531 fix round: 23 -> 24. One corpus name, - # 'Doe, Mr. MA PhD' -- 'Doe, Dr. MA' with a credential run - # behind the member. Verified to be that name and no other; - # no role joined the list. - # 2026-10-01, #575: 23 -> 31, the eight C1 examples the - # alternation gained. Reach, verified name by name. - _Claim(31, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "997497ae83e5", ('DEFAULT',)), - # 2026-10-01, #575: new, 1; 'van der Berg, PhD'. - "fix(#575) a particle surname before a comma is one name word": - _Claim(1, ('_ambiguities', 'family', 'given'), "d449a9b43779", ('DEFAULT',)), - # 2026-10-01, #564: new, 7; 'Ahmad Jayadi, CHA', 'García - # Márquez, MJ JK', 'John Smith, LEED AP', 'John Smith, PhD XYZ', - # 'John Smith, RAI', 'John Smith, XYZ', 'The Rt Hon Kenneth - # Clarke QC MP, HMG'. - "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default": - _Claim(7, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "66be5f479c54", ('DEFAULT',)), - # #516's alternation. Literal-anchored to the by-shape movers, - # `orders` DEFAULT. Same reasoning as the rule above: the - # class is a shape the vocabulary does not spell, so the - # probes and not this number are what bound it. - # 2026-09-30, #563: 9 -> 10, a different set. 'John Smith, A.B.' - # leaves for the #563 rule (paired initials, rules.md#C1), and - # 'John Smith, X.Y.Z.' and 'García Márquez, G.J.R.' join, the - # comma form's by-count movers. Literal-anchored, so the reach - # IS the mover list. - "fix(#516) an unlisted dotted acronym is read by position": - _Claim(10, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "9efed4efb90b", ('DEFAULT',)), - # 2026-09-30, #563: new. Literal-anchored to its movers -- - # four role movers and five names whose only diff is the report. - "fix(#563) paired initials after a comma read as the given name unless a word speaks for them": - _Claim(9, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "3550ea3b5804", ('DEFAULT',)), - # 2026-10-06, #613: roles lost `_ambiguities`; read before any - # join, the pair is settled by its capitals and no longer - # reported, so the rule's fields narrowed. - "fix(#562) a comma credential run holding a particle chain reads by the name-word count": - _Claim(2, ('family', 'given', 'suffix', 'title'), "29c98be5c6c7", ('DEFAULT',)), - # The report-only rule. `_ambiguities` alone, so a widening that - # took a ROLE would change the roles here before it reached - # the gate -- which is the one thing this row can say about a - # rule whose whole subject is a report. - "fix(#289/#516) the ambiguous credential class reports at slots that were silent": - # 2026-09-19, #531 fix round: one corpus name joins, - # 'Doe, MA Smith' -- the post-comma given slot reporting where - # no role moves at any baseline. Verified to be that name and - # no other. - _Claim(9, ('_ambiguities',), "879e77ab5baa", ('DEFAULT',)), - # The 2026-09-18 review round's honorific-peel rule: three - # composed CJK forms, radar tier, classified because the arc - # intended the move (the ledger comment carries the argument). - "fix(#289/#516) the glued CJK honorific peel reads the case lean": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "4fb6dbdbccd4", ('DEFAULT',)), - # 2026-09-19, #531. Five rules, every one - # literal-anchored and claiming exactly the names its ledger - # comment names -- verified name by name against the gate's - # UNEXPLAINED and UNCLASSIFIED (radar) blocks at this - # baseline. The caseless pair is radar-tier and classified all - # the same, on fix(#289/#516)'s stated ground: a radar diff an - # arc intended is one a release note may be written from. - # `Doe, John DO` moves {middle, suffix} here and - # {family, suffix} from 2.2.0 on, which is P6's own shipping - # date showing through. - # 2026-09-30, #563: 15 -> 16, 'García Márquez, Ed G.J.' -- a - # class member in front of a pair keeps the family comma, and - # the pair then ends the given part. Literal-anchored. - "fix(#531) a credential ending the given part of a family-comma listing reads as a credential": - _Claim(16, ('_ambiguities', 'middle', 'suffix'), "2da482a17f0e", ('DEFAULT',)), - "fix(#531) a member the writing declines keeps its name reading and reports the fork": - _Claim(4, ('_ambiguities',), "c6d26d145ee1", ('DEFAULT',)), - "fix(#531) capitals take the do collision from the family-comma particle attachment": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "8ad64f404621", ('DEFAULT',)), - "fix(#531) the trailing slot's positional reading reaches a caseless script": - _Claim(2, ('_ambiguities', 'middle', 'suffix'), "8419a6f53c3e", ('DEFAULT',)), - # The `mc` collision, at the two baselines where it diffs: - # `mc` joined the never-given particles in 2.2 and P6's - # family-comma attachment shipped in 2.3, so 2.2.0 and 2.3.0 - # already read family 'mc Berg' and carry no rule for it. - "fix(#380) a trailing mc after a family comma is the tussenvoegsel, not a post-nominal": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "105229644206", ('DEFAULT',)), - # The change's own movers: seventeen corpus names at 2.2.0 - # and 2.3.0, fourteen at 2.0.0 and 2.1.0 where three of them - # move roles those baselines cannot see and have rules of - # their own. `suffix`, `maiden` and `_ambiguities` together: - # the member lands in one, leaves the other and is reported, - # so a widening taking any of the three alone would change - # the roles here before it reached the gate. - # 2026-09-26, #535: 15 -> 14, 'Jane Doe nee Smith Prof. MA' - # LEAVING this rule's alternation -- the title chain now ends - # the clause before the credential is asked, so the name has - # its own fix(#535) rule instead. - # 2026-10-04, #601/#602: 14 -> 10; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) a credential ending a maiden clause reads as a credential": - _Claim(10, ('_ambiguities', 'maiden', 'suffix'), "e05c5164668c", None), - # The declining half: fourteen corpus names at 2.2.0, eight - # at 2.0.0 and 2.1.0. `_ambiguities` alone, so a role - # appearing here is this rule reaching a name whose clause - # gave the member up. - # 2026-09-26: 8 -> 9, 'Jane Doe nee King. ba' -- this - # baseline already splits suffix 'ba' from maiden 'King.', so - # the whole diff is #533's; #535 moves no role here either, - # only the report. - # 2026-09-27, #544: 9 -> 10; gains 'Jane Doe Jr. nee Smith - # Ma'. - # 2026-10-04, #601/#602: 10 -> 4; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) the maiden clause reports the credential it keeps": - _Claim(4, ('_ambiguities',), "c57e8eb30830", None), - # One corpus name. The by-shape member has no lean to read, - # so a growth here is the rule reaching a LISTED member -- - # a different reading under this rule's sentence. - "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "0cc7bd35a9b3", None), - # One corpus name, `_ambiguities` alone at every 2.x - # baseline: the roles are the ones these releases already - # read, which is what makes the row a restoration. A role - # appearing here would mean the restoration had stopped - # being one. - "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name": - _Claim(1, ('_ambiguities',), "0a7295a0cb19", None), - # One corpus name, at 2.0.0 and 2.1.0 only. Case-sensitive by - # construction, so a growth here is the anchor having picked - # up the mixed-case spelling, which reads the other way. - "fix(#445/#533) a one-case clause keeps the credential, and the one name word it leaves is the family": - _Claim(1, ('_ambiguities', 'family', 'given'), "a1816f915523", None), - # One corpus name, three roles and no `suffix`: v1 read the - # trailing MA as a post-nominal and so does the tree. A - # `suffix` appearing here would mean the phrase rule had - # reached a name whose member moved. - "fix(#434/#533) a marker PHRASE takes the maiden name, and its clause ends at the credential": - _Claim(1, ('family', 'maiden', 'middle'), "bf8359f65c8a", None), - # One corpus name, and the rule is #424's rather than #533's: - # the reading is unchanged on this tree since 2f57ff21. A - # growth here is the numeral rule reaching an acronym name. - "fix(#424) the maiden walk stops before the trailing numeral, with the family name left standing": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "170a53c37765", None), - "fix(#335/#533) a marker-led bracketed clause is the maiden name whatever its last word is": - _Claim(3, ('_ambiguities', 'maiden', 'nickname'), 'cc1045ecdc05', None), - # 2026-09-20, #397 and #461's four new rules, all literal - # alternations of exactly the names each explains -- a count - # that moves means the alternation grew, and _MUST_NOT_MATCH - # carries the walls a reach this small cannot be. - # - # The join reaches 5 and declares the union of its two diff - # shapes; the one-case report rule reaches 3 on - # `_ambiguities` alone, no role moving on any of them. #461's - # two halves are 16 and 13 on `_initials` alone, and they - # are two rules and not one because a rule listing `_initials` - # may list nothing else, so the two readings of one sentence - # cannot share a row either. - "fix(#397) the Catalan/Polish link joins two surnames": - _Claim(5, ('family', 'middle', 'suffix'), "0c31a48b1867", - ('DEFAULT',)), - "fix(#397) the Catalan link reports a connective-or-initial in a one-case name": - _Claim(3, ('_ambiguities',), "e43f9595ac02", ('DEFAULT',)), - "fix(#461) a connective holding its part alone contributes an initial": - _Claim(16, ('_initials',), "075dc34f9e95", ('DEFAULT',)), - "fix(#461) a connective with a name word beside it stops contributing an initial": - _Claim(13, ('_initials',), "3cc41f4bfc21", ('DEFAULT',)), - # 2026-09-22, #397 follow-up: 2 -> 3, the one new corpus name - # rules.md#M2's `deviates: #538` example adds, which this - # rule's own alternation now names. Verified name by name. - # 2026-09-26, #538: 3 -> 4, rules.md#M2's second live example, - # 'Smith, John, PhD née Puig - i Soler', joining the same - # alternation for the same reason. Verified name by name. - # 2026-10-04, #601/#602: 4 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#397) a link inside a maiden clause stays in the birth name": - _Claim(1, ('family', 'maiden', 'middle'), "4e62e18718a4", ('DEFAULT',)), - # New rule (#296/#535): one corpus name, 'Jane Doe nee Prof. - # Dr.'. This baseline still has 'dr' in the suffix vocabulary - # (#296 had not run yet), so it reads suffix 'Dr.'. The parent - # tree d9d80492, before #535, already reads maiden 'Prof. - # Dr.' (dr having left the suffix vocabulary by #296); #535 - # then moves that word on into `title`, returning maiden to - # 'Prof.' -- the same value this baseline reads there, so - # `maiden` does not move net baseline-to-HEAD. - "fix(#296/#535) a trailing title after a dropped postnominal Dr.": - _Claim(1, ('suffix', 'title'), '193350645868', None), - # New rule (#533/#535): one corpus name, 'Jane Doe nee Smith - # Prof. MA'. #533 already gives 'MA' up to the credential - # reading and reports the fork (`suffix`, `maiden`, - # `_ambiguities`) before #535 exists, verified against the - # parent tree d9d80492; #535 then reads the title chain - # through the credential, so the title standing in front of - # 'MA' moves `title` and `maiden` further. - "fix(#533/#535) a title in front of the credential the clause gives up": - _Claim(1, ('_ambiguities', 'maiden', 'suffix', 'title'), - 'cb85a2cbf9f2', None), - # New rule (#535): seven corpus names -- the title-stop - # literal list, minus the three names above whose diff from - # this baseline has an earlier cause too. `title`, `maiden` - # and `suffix` move for the credential-bearing ones, and - # `_ambiguities` for the declined-member one. - # 2026-10-04, #601/#602: 8 -> 6; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#535) a trailing title ends the maiden clause": - _Claim(6, ('_ambiguities', 'maiden', 'suffix', 'title'), "3e41b335baa4", None), - # 2026-09-27, #544: new, 5; gains 'Jane Doe, MS LAc', 'John - # Smith, MD MEng', 'John Smith, MEng PhD', 'John Smith, PhD - # MEng' and 1 more. - "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports": - _Claim(5, ('_ambiguities',), "bac52af569e4", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'John Smith, Ed Ma', 'John - # Smith, Ms Ma', 'John Smith, X.Y.Z. MA', 'john smith, md ma'. - # 2026-09-28, #544: 4 -> 5; gains 'John Smith, PhD Ma'. - # 2026-10-01, #554: 5 -> 8; gains 'John Smith, vd Ma', - # 'JOHN SMITH, VD MA' and 'John Smith, Jr do'. - "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count": - _Claim(8, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "ac1125df8691", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'Doe, Jane PhD MEng', 'Doe, - # Jane nee Smith PhD MEng', 'Jane Doe nee Smith PhD MEng', - # 'John Smith PhD MEng'. Relabelled the same day - # fix(#436/#437/#544), the `suffix` it claims being R1's - # spacing alone; reach, roles and digest unchanged. - "fix(#436/#437/#544) an unambiguous credential in front anchors the member behind it": - _Claim(4, ('_ambiguities', 'suffix'), "c9d1d53dee20", ('DEFAULT',)), - # 2026-09-27, #544: new, 1; gains 'doe, jane v phd do'. - "fix(#544) a degree in front outranks P6's attachment for a particle member": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "a2b8cea490a7", ('DEFAULT',)), - # 2026-09-28, #544: new, 2; 'Smith, PhD MEng', 'Smith, PhD Ma'. - "fix(#296/#544) a credential in front anchors a member after a one-word family comma, and the pick reports": - _Claim(2, ('_ambiguities', 'given', 'suffix', 'title'), "9fe346e1f150", ('DEFAULT',)), - # 2026-09-28, #544: new, 1; 'Smith, MD PhD Ma'. - "fix(#296/#531) phd is not a prenominal, so behind a dual title it is the given name and the member ending the part reports": - _Claim(1, ('_ambiguities', 'given', 'middle', 'title'), "8e9913f5df8b", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'DOE, JANE MA VD PHD', - # 'DOE, JANE PHD VD MA', 'doe, jane phd vd ma'. - "fix(#573) a particle-suffix word between credentials after a family comma stays in the run": - _Claim(3, ('_ambiguities', 'middle', 'suffix'), "03e448f4776b", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'SMITH, VD DE LA MA', - # 'SMITH, VD MA', 'smith, de ma'. - "fix(#573) a never-given particle opening the given part takes the member behind it": - _Claim(3, ('_ambiguities',), "54ce0dda7114", ('DEFAULT',)), - "fix(#585) a decomposed initial keeps its whole first letter": - _Claim(2, ('_initials',), "c4f045134f0f", None), - "fix(#594) a period-marked halfwidth katakana word is not a title": - _Claim(2, ('_ambiguities', 'given', 'title'), "8cdafcf56c45", None), - "fix(#597) halfwidth corner brackets enclose a nickname": - _Claim(3, ('family', 'given', 'middle', 'nickname'), "d1b9b0addff3", None), - # 2026-10-04, #601/#602: new rules. - "fix(#601) a marker in the given part after a family comma is an ordinary word": - _Claim(17, ('_ambiguities', 'family', 'given', 'maiden', 'middle', 'suffix', 'title'), "c7ed65a885ac", None), - "fix(#601) a marker in a part after a suffix comma is an ordinary word": - _Claim(2, ('maiden', 'suffix'), "59dfc0c40e36", None), - "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word": - _Claim(5, ('_ambiguities', 'family', 'given', 'maiden', 'suffix', 'title'), "1c9432e08a21", None), - # 2026-10-04, #610: +1, 'Jane Doe nee Smith VI Prof.', rules.md#M2's - # numeral example behind a title. Reach. - "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes": - _Claim(5, ('_ambiguities', 'family', 'given', 'maiden', 'suffix', 'title'), "7d2d5efa8eb5", None), - "fix(#602) a credential after the name core starts a run to the end of its part": - _Claim(5, ('_ambiguities', 'family', 'middle', 'suffix', 'title'), "3129cd9609b9", None), - "fix(#627) a lone particle with the run right behind it is the surname": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "33096566ba7f", None), - "fix(#624) of two title-particles at the head the second leads the chain": - _Claim(1, ('_ambiguities', 'family', 'given', 'title'), "c5b6da915be2", None), - "fix(#623) after a family comma the chain stops before a trailing title": - _Claim(2, ('_ambiguities', 'family', 'middle', 'title'), "bbab0baa7826", None), - "fix(#636) a part past the second comma makes no particle chain": - _Claim(1, ('suffix', 'title'), "f409b04e4c16", None), - "fix(#637) the bound join declines a trailing title after a family comma": - _Claim(1, ('middle', 'title'), "feff01f1e769", None), - "fix(#640) the bound join declines a title word past the second comma": - _Claim(1, ('suffix', 'title'), "bd68517718e5", None), - "fix(#601/#602) a credential in the clause starts a run the take consumes": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "acdcc81cb17c", None), - "fix(#601) a marker behind a connective joins the connective run, and the run's initials follow": - _Claim(1, ('_initials',), "b3a89ea7087c", None), - # 2026-10-04, #604: new, 3; the rules.md#P7 boundary and the - # two rules.md#R4 case-repair examples. - "fix(#604) the Irish and Malay patronymic particles": - _Claim(3, ('family', 'middle'), "cfcfd91f9d58", None), - # 2026-10-04, #606: new, 1; 'Greve Anna', rules.md#H1's - # Accepted example for a surname-borne title. - "fix(#606) salutations and titles in other languages": - _Claim(1, ('given', 'title'), "0c78dbac8a6b", None), - # 2026-10-04, #603: new, 5; rules.md#C1's opened-part examples and the delimiter rows it moves. - "fix(#603) a credential opening the part after the comma makes it the postnominal part": - _Claim(5, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "a4ebde5b8cd7", None), - # 2026-10-04, #603: new, 6; rules.md#C2's tail-title examples and the radar names it moves. - "fix(#603) a title word in a part past the second comma is a title": - _Claim(6, ('_ambiguities', 'suffix', 'title'), "eeb11557d7f0", None), - # 2026-10-07, #614: new, 1; rules.md#H5's 'John van der Berg Prof.' example. - "fix(#614) a trailing title is read before the particle chain": - _Claim(1, ('family', 'title'), "4536c33ed2a2", None), - "fix(#653) a delimited clause keeps its comma": - # 2 -> 4 the same day: the two rules.md#M3 comma examples - # move only their nickname at this pre-M3 baseline. - _Claim(4, ('nickname',), "d7a0d8c919c8", None), - "fix(#652) a decomposed accented abbreviation is an abbreviation": - _Claim(1, ('given', 'middle', 'title'), "df11091e2fc1", None), - }, - # The 2.3 cycle's first rule, and a facade-only render fix: every - # role is identical, so `_initials` alone. Reach and digest as in - # the 2.0.0 mapping above, the same regex classifying the same - # names. - "expected_since_2.2.0.toml": { - # The ph removal (#459/#521): one literal name, the cases.py - # row written to carry the bare trailing `Ph.` shape, since no - # corpus name had it -- which is why the removal measured as - # zero role movers at review time. - "change(suffix-acronym-collisions) ph leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's rules, eight at landing (2026-09-25) -- five literal - # alternations and three literal names -- and six since #544 - # (2026-09-27) retired two, every name one of #540's own case - # rows. The - # report rule's `_ambiguities`-only roles are its point: a - # widening that took a role would change them here first. - "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), - "fix(#540) a lone meng or MEng after a family comma reads as the given name": - _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), - "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": - _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'John Smith, PhD MEng', 'john smith, phd meng'. - "fix(#540) a Title-case Lac behind a particle is the family name": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'Wang M.Eng.'. - # #436/#437's Latin alternation, first in every ledger. - # Ten corpus names, `suffix` alone: the rule moves the - # SEPARATOR and no role, so a widening that took a role would - # change the row here before it reached the gate. - # 2026-09-20, #397: 11 -> 14, three new comma-written - # generation runs ('Josep Lluis Carod i III', 'Josep Lluis - # Carod i V', 'Rovira, Josep Carod i Jr.'), each of which #397 - # leaves byte-identical -- measured against the parent - # 46651750 -- and each of which arrived with that change's own - # case rows. Roles unmoved, `suffix` alone as before. - # 2026-09-27, #544: 15 -> 18; gains 'Jane Doe Jr. Ma', 'John - # Smith PhD Ed Ma', 'John Smith PhD Ma'. - # 2026-10-04, #601/#602: 18 -> 17; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": - _Claim(17, ('suffix',), "8f6fa4cee3c3", None), - # #449's six rules, second in every 2.x ledger. The - # alternation reached twenty-two corpus names at landing - # (twenty-three since #544, 2026-09-27) and - # `_ambiguities` alone: no role moves anywhere in this change, - # so a widening that took a role would change the row here - # before it reached the gate, and a member reaching a name - # something DECIDED ('abdul', 'de', 'Dr. Smith') would move - # the count. The five CJK rules are literal-anchored on one - # corpus name each, a reach _CORPUS_CLAIMS cannot police on - # its own -- a widening into names the corpora lack leaves it - # unmoved -- so _MUST_NOT_MATCH carries the boundary probes - # beside them. The digests are the same in all three 2.x - # ledgers because the regexes are the same strings and the - # corpora are one set. - # 2026-09-27, #544: 22 -> 23; gains 'Wang M.Eng.', whose diff - # here is the one-word name's report alone. - "feat(#449) a lone name word reports given-or-family": - _Claim(23, ('_ambiguities',), "ea61ec446eef", None), - "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it": - _Claim(1, ('_ambiguities',), "80777383a11a", None), - "feat(#449) an interpunct transcription declines the script order, so the convention decides it": - _Claim(1, ('_ambiguities',), "ba9a4258c864", None), - "feat(#449) an ideographic comma leaves one name word, and the convention decides it": - _Claim(1, ('_ambiguities',), "4b2858642238", None), - "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order": - _Claim(1, ('_ambiguities',), "528858346d14", None), - "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order": - _Claim(1, ('_ambiguities',), "30ec95e25f05", None), - "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it": - _Claim(1, ('_ambiguities',), "63f797a141db", None), - # #491's two rules, beside #449's for the same reason: both - # classify on `_ambiguities` alone, and no role moves anywhere - # in this change, so a widening that took a role would change - # the row here before it reached the gate. Six title names and - # two suffix names, one alternative each; a member reaching a - # name the SHAPE does not select ('Dr. Smith', 'Rabbi Cohen', - # 'Smith Jr.', the lone 'Dr.' the peel takes whole) would move - # the count, and _MUST_NOT_MATCH carries those probes. Same - # digests in all three 2.x ledgers: the regexes are the same - # strings and the corpora are one set. - "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name": - _Claim(6, ('_ambiguities',), "9ee2a07d96c8", None), - # 2026-09-23, #459: 2 -> 3, 'qc mp', rules.md#R4's name-role - # boundary line. Same digest in all three 2.x ledgers. - "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name": - _Claim(3, ('_ambiguities',), "6d5341766f53", None), - # The join clause's own rule, added by the #518 review round: - # hoisting the clause out from under O5's field-deciding - # guards reached two corpus names, both a peeled title in - # front of a joined unit standing last. Literal-anchored on - # the two, so _MUST_NOT_MATCH carries the boundary probes. - "feat(#491) a joined unit carrying title vocabulary reports title-or-name": - _Claim(2, ('_ambiguities',), "7af3fec03ccc", None), - # #346's alternation. Four corpus names, `family` and - # `given` together: the fold moves both roles at once, so a - # widening taking one alone would change the roles here - # before it reached the gate. - "fix(#346) a renunciate title and one name word leave the name a given name": - _Claim(4, ('family', 'given'), "a3399ee7b21e", None), - # #519's alternation. Three corpus names, `family` and `given` - # together: the fold moves both roles at once, so a widening - # taking one alone would change the roles here before it - # reached the gate. - "fix(#519) prince and princess join the given-name titles": - _Claim(3, ('family', 'given'), "ca37331f9803", None), - # #342's alternation. Five corpus names and four roles: the - # comma forms move `given` where the bare forms move `middle`, - # so a widening taking one shape alone would change the roles - # here before it reached the gate. - "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word": - # 2026-10-01, #564: `given` leaves the roles -- the comma pair - # reads suffix again by its capitals, so no name here moves it. - _Claim(5, ('family', 'middle', 'suffix'), "c3d76812da97", None), - # The four one-name CJK rules, literal-anchored, at the two - # baselines where the render is the whole of what moved. A - # reach of 1 is one _CORPUS_CLAIMS cannot police on its own -- - # a widening into names the corpora lack leaves it unmoved -- - # so _MUST_NOT_MATCH carries the boundary probes beside it. - "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run": - _Claim(1, ('suffix',), "7afc1a7f6b7b", None), - "fix(#436/#437) the glued honorific and the polite address are one post-nominal run": - _Claim(1, ('suffix',), "4fc52f5a60f2", None), - "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run": - _Claim(1, ('suffix',), "6edfa4394c33", None), - "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": - _Claim(1, ('suffix',), "1b67339cf744", None), - # 18 -> 22 on 2026-09-13: four #383/#479 names entered the - # corpora carrying a bare capital E or Y this regex reaches. - # expected_since_2.0.0.toml's copy of this rule says which - # four, and why only one of them diffs. - "fix(#462) the facade keeps an initial-shaped conjunction letter": - _Claim(22, ('_initials',), "7386690d2928", ('DEFAULT',)), - # The 2.3 title-run bundle's five rules, last in every - # ledger, and the same reach at all four: the 1.4.0 roster - # above carries the argument. - "fix(#489) a title run addresses by its last title": - _Claim(2, ('family', 'given'), "e14159a4d48f", None), - "change(suffix-acronym-collisions) esq leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), "ef9c8cfc56d8", None), - # `_ambiguities` is in the ROLES at the three 2.x baselines and - # not at 1.4.0, which has no ambiguity surface to compare - # (compare.py's _RULE_FIELDS). Same regex, so the reach and the - # digest are identical in all four ledgers and only the roles - # move. - "fix(#489) the title peel leaves a name word a suffix cannot be": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "07b02286cd81", None), - # 2026-09-18 (#289/#516): 16 -> 17. One new corpus name, - # 'JOHN PROF. MA', added to the alternation deliberately -- - # the ledger comment argues it. No role joined the five. - "fix(#316) a trailing period-marked title word reads as a title": - _Claim(17, ('family', 'given', 'middle', 'suffix', 'title'), - "0dc3551e57da", None), - # The CJK member of the same argument, its own literal rule - # (an UNDECLARED alternation holding a script-classified - # member belongs to the honorific pin above -- see the 1.4.0 - # mapping's note on what #322/#323 changed there). ONE corpus - # name; 2.2.0 took `dr` out of the post-nominal vocabulary, so - # the Latin word was a name word there and `suffix` is empty - # on both sides while `middle` moves instead. - "fix(#316) a trailing Latin title on a native-script name is a title": - _Claim(1, ('family', 'given', 'middle', 'title'), - "567f09dc9b45", None), - # #322/#323, and SEVENTEEN names rather than the eleven the - # 1.4.0 and 2.0.0 mappings record: 2.1.0 shipped the hangul - # segmentation and the script order rule, so from that - # baseline on '김. 민준', '양 지훈.' and '양. 지훈' are this bundle's - # diffs and no older rule declares them, and the three - # stop-bearing FAMILY_COMMA rows the review round added diff - # from 2.1.0 on too -- six more members, not three. - # `_ambiguities` joins the roles here as it does at 2.0.0, for - # the same katakana mover. - "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": - _Claim(17, ('_ambiguities', 'family', 'given', 'middle', - 'suffix', 'title'), "ef4a7afe791a", None), - # #383/#479's two rules, last in this ledger as in the file. - # The role rule reaches TWO corpus names and declares four - # fields, which is the union of two disjoint diffs plus the - # report ONE of them carries -- 'jose e maria santos' moves - # `given` and `middle` and reports, 'JUAN GARCIA Y LOPEZ' - # moves `middle` and `family` and reports nothing. Its digest - # is the 1.4.0 rule's, the same literal alternation over the - # same corpora; its roles are not, the report being a v2 - # surface. The report rule reaches SIX, `_ambiguities` alone: - # every role is identical on all six and only the CALL is new. - # Both regexes are literal alternations, so a new name here - # means one of them grew -- seventeen corpus names sit in the - # class they describe and only ten of those move at all. - "fix(#383/#479) a single-letter connective joins only on case evidence": - _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), - "ec00806a06f0", ('DEFAULT',)), - "fix(#383/#479) a marked connective letter in a one-case name is reported": - _Claim(6, ('_ambiguities',), "03de2830707e", ('DEFAULT',)), - # #289's alternation, appended with #516's and the report - # rule's below it. Literal-anchored to the movers a written - # case contrast decides, `orders` DEFAULT: the class is a - # SHAPE and not a wordlist, so a member spelled as the class - # would claim the one-case spellings that do not move. - # _MUST_NOT_MATCH carries those, and at a reach this small it - # is the wall this row cannot be. - # 2026-09-18, second round: two more names, 'John Smith, Ma' - # and 'Smith Jr., Ma', the Title-case halves of two minimal - # pairs rules.md#C1 now states. No role joined the list. - # 2026-09-27, #544: 24 -> 23; loses 'abdul Smith Jr Ma'. - "fix(#289) a written case contrast decides a bare ambiguous acronym": - # 2026-09-18, review round: 'John de Ma' joins, the - # one-particle spelling of 'John van der Berg Ma'. Its diff - # is the restored chain report at the 2.x baselines and the - # role move at 1.4.0 and 2.2/2.3; no role joined the list. - # 2026-09-19, #531: 20 -> 23. Three corpus names, 'Doe, MA', - # 'Doe, MA PhD' and 'Doe, Dr. MA', the lone post-comma - # credentials #531's control rows brought in. Verified to be - # those three and no others; no role joined the list, and the - # 1.4.0 copy does not carry them (its ledger comment says - # which rule already owns their diff there). - # 2026-09-19, #531 fix round: 23 -> 24. One corpus name, - # 'Doe, Mr. MA PhD' -- 'Doe, Dr. MA' with a credential run - # behind the member. Verified to be that name and no other; - # no role joined the list. - # 2026-10-01, #575: 23 -> 31, the eight C1 examples the - # alternation gained. Reach, verified name by name. - _Claim(31, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "997497ae83e5", ('DEFAULT',)), - # 2026-10-01, #575: new, 1; 'van der Berg, PhD'. - "fix(#575) a particle surname before a comma is one name word": - _Claim(1, ('_ambiguities', 'family', 'given'), "d449a9b43779", ('DEFAULT',)), - # 2026-10-01, #564: new, 7; 'Ahmad Jayadi, CHA', 'García - # Márquez, MJ JK', 'John Smith, LEED AP', 'John Smith, PhD XYZ', - # 'John Smith, RAI', 'John Smith, XYZ', 'The Rt Hon Kenneth - # Clarke QC MP, HMG'. - "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default": - _Claim(7, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "66be5f479c54", ('DEFAULT',)), - # #516's alternation. Literal-anchored to the by-shape movers, - # `orders` DEFAULT. Same reasoning as the rule above: the - # class is a shape the vocabulary does not spell, so the - # probes and not this number are what bound it. - # 2026-09-30, #563: 9 -> 10, a different set. 'John Smith, A.B.' - # leaves for the #563 rule (paired initials, rules.md#C1), and - # 'John Smith, X.Y.Z.' and 'García Márquez, G.J.R.' join, the - # comma form's by-count movers. Literal-anchored, so the reach - # IS the mover list. - "fix(#516) an unlisted dotted acronym is read by position": - _Claim(10, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "9efed4efb90b", ('DEFAULT',)), - # 2026-09-30, #563: new. Literal-anchored to its movers -- - # four role movers and five names whose only diff is the report. - "fix(#563) paired initials after a comma read as the given name unless a word speaks for them": - _Claim(9, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "3550ea3b5804", ('DEFAULT',)), - "fix(#562) a comma credential run holding a particle chain reads by the name-word count": - _Claim(2, ('_ambiguities', 'family', 'given', 'suffix'), "29c98be5c6c7", ('DEFAULT',)), - # The report-only rule. `_ambiguities` alone, so a widening that - # took a ROLE would change the roles here before it reached - # the gate -- which is the one thing this row can say about a - # rule whose whole subject is a report. - "fix(#289/#516) the ambiguous credential class reports at slots that were silent": - # 2026-09-19, #531 fix round: one corpus name joins, - # 'Doe, MA Smith' -- the post-comma given slot reporting where - # no role moves at any baseline. Verified to be that name and - # no other. - _Claim(10, ('_ambiguities',), "5d427b25c1c7", ('DEFAULT',)), - # The 2026-09-18 review round's honorific-peel rule: three - # composed CJK forms, radar tier, classified because the arc - # intended the move (the ledger comment carries the argument). - "fix(#289/#516) the glued CJK honorific peel reads the case lean": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "4fb6dbdbccd4", ('DEFAULT',)), - # 2026-09-19, #531. Four rules, every one - # literal-anchored and claiming exactly the names its ledger - # comment names -- verified name by name against the gate's - # UNEXPLAINED and UNCLASSIFIED (radar) blocks at this - # baseline. The caseless pair is radar-tier and classified all - # the same, on fix(#289/#516)'s stated ground: a radar diff an - # arc intended is one a release note may be written from. - # `Doe, John DO` moves {family, suffix} here, P6 having - # shipped in 2.3; at 2.0.0 and 2.1.0 the same name moves - # {middle, suffix} instead. - # 2026-09-30, #563: 15 -> 16, 'García Márquez, Ed G.J.' -- a - # class member in front of a pair keeps the family comma, and - # the pair then ends the given part. Literal-anchored. - "fix(#531) a credential ending the given part of a family-comma listing reads as a credential": - _Claim(16, ('_ambiguities', 'middle', 'suffix'), "2da482a17f0e", ('DEFAULT',)), - # 2026-09-28, #544: 4 -> 5; gains 'Smith, MD PhD Ma'. - "fix(#531) a member the writing declines keeps its name reading and reports the fork": - _Claim(5, ('_ambiguities',), "f73bc2fc5408", ('DEFAULT',)), - "fix(#531) capitals take the do collision from the family-comma particle attachment": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "8ad64f404621", ('DEFAULT',)), - "fix(#531) the trailing slot's positional reading reaches a caseless script": - _Claim(2, ('_ambiguities', 'middle', 'suffix'), "8419a6f53c3e", ('DEFAULT',)), - # The change's own movers: seventeen corpus names at 2.2.0 - # and 2.3.0, fourteen at 2.0.0 and 2.1.0 where three of them - # move roles those baselines cannot see and have rules of - # their own. `suffix`, `maiden` and `_ambiguities` together: - # the member lands in one, leaves the other and is reported, - # so a widening taking any of the three alone would change - # the roles here before it reached the gate. - # 2026-09-26, #535: 18 -> 17, 'Jane Doe nee Smith Prof. MA' - # LEAVING this rule's alternation -- the title chain now ends - # the clause before the credential is asked, so the name has - # its own fix(#535) rule instead. - # 2026-10-04, #601/#602: 17 -> 11; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) a credential ending a maiden clause reads as a credential": - _Claim(11, ('_ambiguities', 'maiden', 'suffix'), "9f217cc8fda4", None), - # The declining half: fourteen corpus names at this baseline, - # eight at 2.0.0 and 2.1.0. `_ambiguities` alone, so a role - # appearing here is this rule reaching a name whose clause - # gave the member up. - # 2026-09-26: 14 -> 15, 'Jane Doe nee King. ba' -- this - # baseline already splits suffix 'ba' from maiden 'King.', so - # the whole diff is #533's; #535 moves no role here either, - # only the report. - # 2026-09-27, #544: 15 -> 16; gains 'Jane Doe Jr. nee Smith - # Ma'. - # 2026-10-04, #601/#602: 16 -> 6; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) the maiden clause reports the credential it keeps": - _Claim(6, ('_ambiguities',), "fe166ed5ead1", None), - # One corpus name. The by-shape member has no lean to read, - # so a growth here is the rule reaching a LISTED member -- - # a different reading under this rule's sentence. - "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "0cc7bd35a9b3", None), -# One corpus name, `_ambiguities` alone at every 2.x - # baseline: the roles are the ones these releases already - # read, which is what makes the row a restoration. A role - # appearing here would mean the restoration had stopped - # being one. - "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name": - _Claim(1, ('_ambiguities',), "0a7295a0cb19", None), - # One corpus name, on the radar tier. A growth here is this - # rule reaching a second CJK-marker name, which would be a - # corpus row to look at rather than a widening to wave - # through. - "fix(#533) the maiden clause ends at the credential in a native-script name too": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "6bab87214ddf", None), - # 2026-09-20, #397 and #461's four new rules, all literal - # alternations of exactly the names each explains -- a count - # that moves means the alternation grew, and _MUST_NOT_MATCH - # carries the walls a reach this small cannot be. - # - # The join reaches 5 and declares the union of its two diff - # shapes; the one-case report rule reaches 3 on - # `_ambiguities` alone, no role moving on any of them. #461's - # two halves are 17 and 13 on `_initials` alone, and they - # are two rules and not one because a rule listing `_initials` - # may list nothing else, so the two readings of one sentence - # cannot share a row either. - # - # The readmission half reaches one more name here than at - # 2.0.0/2.1.0 -- 'Juan y Garcia née Jones', whose roles move - # against the older wheels and so keep `_initials` out of its - # diff there. - "fix(#397) the Catalan/Polish link joins two surnames": - _Claim(5, ('family', 'middle', 'suffix'), "0c31a48b1867", - ('DEFAULT',)), - "fix(#397) the Catalan link reports a connective-or-initial in a one-case name": - _Claim(3, ('_ambiguities',), "e43f9595ac02", ('DEFAULT',)), - "fix(#461) a connective holding its part alone contributes an initial": - _Claim(17, ('_initials',), "797473971e75", ('DEFAULT',)), - "fix(#461) a connective with a name word beside it stops contributing an initial": - _Claim(13, ('_initials',), "3cc41f4bfc21", ('DEFAULT',)), - # 2026-09-22, #397 follow-up: 2 -> 3, the one new corpus name - # rules.md#M2's `deviates: #538` example adds, which this - # rule's own alternation now names. Verified name by name. - # 2026-09-26, #538: 3 -> 4, rules.md#M2's second live example, - # 'Smith, John, PhD née Puig - i Soler', joining the same - # alternation for the same reason. Verified name by name. - # 2026-10-04, #601/#602: 4 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#397) a link inside a maiden clause stays in the birth name": - _Claim(1, ('family', 'maiden', 'middle'), "4e62e18718a4", ('DEFAULT',)), - # New rule (#533/#535): one corpus name, 'Jane Doe nee Smith - # Prof. MA'. #533 already gives 'MA' up to the credential - # reading and reports the fork (`suffix`, `maiden`, - # `_ambiguities`) before #535 exists, verified against the - # parent tree d9d80492 (#296 and #399, the other two-cause - # names at 2.0.0/2.1.0, both predate this baseline and so move - # nothing extra here); #535 then reads the title chain through - # the credential, so the title standing in front of 'MA' - # moves `title` and `maiden` further. - "fix(#533/#535) a title in front of the credential the clause gives up": - _Claim(1, ('_ambiguities', 'maiden', 'suffix', 'title'), - 'cb85a2cbf9f2', None), - # New rule (#535): eight corpus names -- the title-stop - # literal list, minus the one name above whose diff from this - # baseline has an earlier cause too. `title`, `maiden`, - # `suffix` and `_ambiguities` move between them. - # 2026-10-04, #601/#602: 9 -> 7; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#535) a trailing title ends the maiden clause": - _Claim(7, ('_ambiguities', 'maiden', 'suffix', 'title'), "0155334273c4", None), - # 2026-09-27, #544: new, 5; gains 'Jane Doe, MS LAc', 'John - # Smith, MD MEng', 'John Smith, MEng PhD', 'John Smith, PhD - # MEng' and 1 more. - "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports": - _Claim(5, ('_ambiguities',), "bac52af569e4", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'John Smith, Ed Ma', 'John - # Smith, Ms Ma', 'John Smith, X.Y.Z. MA', 'john smith, md ma'. - # 2026-09-28, #544: 4 -> 5; gains 'John Smith, PhD Ma'. - # 2026-10-01, #554: 5 -> 8; gains 'John Smith, vd Ma', - # 'JOHN SMITH, VD MA' and 'John Smith, Jr do'. - "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count": - _Claim(8, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "ac1125df8691", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'Doe, Jane PhD MEng', 'Doe, - # Jane nee Smith PhD MEng', 'Jane Doe nee Smith PhD MEng', - # 'John Smith PhD MEng'. Relabelled the same day - # fix(#436/#437/#544), the `suffix` it claims being R1's - # spacing alone; reach, roles and digest unchanged. - # 2026-09-28, #544: 4 -> 5; gains 'Smith, PhD MEng'. - "fix(#436/#437/#544) an unambiguous credential in front anchors the member behind it": - _Claim(5, ('_ambiguities', 'suffix'), "5807b060ae87", ('DEFAULT',)), - # 2026-09-27, #544: new, 1; gains 'doe, jane v phd do'. - "fix(#544) a degree in front outranks P6's attachment for a particle member": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "a2b8cea490a7", ('DEFAULT',)), - # 2026-09-28, #544: new, 1; 'Smith, PhD Ma'. - "fix(#544) a credential in front anchors a member after a one-word family comma, and the pick reports": - _Claim(1, ('_ambiguities', 'given', 'middle', 'suffix'), "b2b939a7b814", ('DEFAULT',)), - # 2026-10-02, #573: new, 1; 'SMITH VD MA, JOHN'. - "fix(#573) a particle-suffix word before a family comma stays in the family": - _Claim(1, ('family', 'suffix'), "638c43d95171", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'DOE, JANE MA VD PHD', - # 'DOE, JANE PHD VD MA', 'doe, jane phd vd ma'. - "fix(#573) a particle-suffix word between credentials after a family comma stays in the run": - _Claim(3, ('_ambiguities', 'family', 'middle', 'suffix'), "03e448f4776b", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'SMITH, VD DE LA MA', - # 'SMITH, VD MA', 'smith, de ma'. - "fix(#573) a never-given particle opening the given part takes the member behind it": - _Claim(3, ('_ambiguities',), "54ce0dda7114", ('DEFAULT',)), - "fix(#585) a decomposed initial keeps its whole first letter": - _Claim(2, ('_initials',), "c4f045134f0f", None), - "fix(#594) halfwidth katakana takes the kana license and the 间隔号": - _Claim(5, ('family', 'given'), "86805db7d55e", None), - "fix(#594) a period-marked halfwidth katakana word is not a title": - _Claim(2, ('_ambiguities', 'given', 'title'), "8cdafcf56c45", None), - "fix(#596) a voicing mark does not make a katakana word kana-licensed": - _Claim(3, ('family', 'given'), "d822332b50f9", None), - "fix(#597) halfwidth corner brackets enclose a nickname": - _Claim(3, ('family', 'given', 'middle', 'nickname'), "d1b9b0addff3", None), - # 2026-10-04, #601/#602: new rules. - "fix(#601) a marker in the given part after a family comma is an ordinary word": - _Claim(17, ('_ambiguities', 'family', 'given', 'maiden', 'middle', 'suffix', 'title'), "c7ed65a885ac", None), - "fix(#601) a marker in a part after a suffix comma is an ordinary word": - _Claim(2, ('maiden', 'suffix'), "59dfc0c40e36", None), - "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word": - _Claim(5, ('_ambiguities', 'family', 'given', 'maiden', 'suffix', 'title'), "1c9432e08a21", None), - # 2026-10-04, #610: +1, 'Jane Doe nee Smith VI Prof.', rules.md#M2's - # numeral example behind a title. Reach. - "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes": - _Claim(5, ('_ambiguities', 'maiden', 'suffix', 'title'), "7d2d5efa8eb5", None), - "fix(#601) the first word after the marker is the maiden name": - _Claim(1, ('_ambiguities', 'family', 'maiden', 'middle', 'suffix'), "aaf53040b071", None), - "fix(#602) a credential after the name core starts a run to the end of its part": - _Claim(5, ('_ambiguities', 'family', 'middle', 'suffix', 'title'), "3129cd9609b9", None), - "fix(#627) a lone particle with the run right behind it is the surname": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "33096566ba7f", None), - "fix(#624) of two title-particles at the head the second leads the chain": - _Claim(1, ('_ambiguities', 'family', 'given', 'title'), "c5b6da915be2", None), - "fix(#623) after a family comma the chain stops before a trailing title": - _Claim(2, ('_ambiguities', 'family', 'middle', 'title'), "bbab0baa7826", None), - "fix(#636) a part past the second comma makes no particle chain": - _Claim(1, ('suffix', 'title'), "f409b04e4c16", None), - "fix(#637) the bound join declines a trailing title after a family comma": - _Claim(1, ('given', 'title'), "feff01f1e769", None), - "fix(#640) the bound join declines a title word past the second comma": - _Claim(1, ('suffix', 'title'), "bd68517718e5", None), - "fix(#601/#602) a credential in the clause starts a run the take consumes": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "acdcc81cb17c", None), - # 2026-10-04, #604: new, 3; the rules.md#P7 boundary and the - # two rules.md#R4 case-repair examples. - "fix(#604) the Irish and Malay patronymic particles": - _Claim(3, ('family', 'middle'), "cfcfd91f9d58", None), - # 2026-10-04, #606: new, 1; 'Greve Anna', rules.md#H1's - # Accepted example for a surname-borne title. - "fix(#606) salutations and titles in other languages": - _Claim(1, ('given', 'title'), "0c78dbac8a6b", None), - # 2026-10-04, #603: new, 5; rules.md#C1's opened-part examples and the delimiter rows it moves. - "fix(#603) a credential opening the part after the comma makes it the postnominal part": - _Claim(5, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "a4ebde5b8cd7", None), - # 2026-10-04, #603: new, 7; rules.md#C2's tail-title examples and the radar names it moves. - "fix(#603) a title word in a part past the second comma is a title": - _Claim(7, ('_ambiguities', 'suffix', 'title'), "8cbad6f13c6b", None), - # 2026-10-06, #613: new, 1; rules.md#C1's 'Ortega y Gasset, Dr.' example. - "fix(#613) a connective surname before the comma counts its words": - _Claim(1, ('family', 'given', 'middle'), "a7a939e6843d", None), - # 2026-10-07, #614: new, 1; rules.md#H5's 'John van der Berg Prof.' example. - "fix(#614) a trailing title is read before the particle chain": - _Claim(1, ('family', 'title'), "4536c33ed2a2", None), - "fix(#653) a delimited clause keeps its comma": - _Claim(2, ('nickname',), "fad1fd2c06ad", None), - "fix(#653) a comma after a marker unmarks it": - _Claim(2, ('maiden', 'nickname'), "9ff9af68de6d", None), - "fix(#652) a decomposed accented abbreviation is an abbreviation": - _Claim(1, ('given', 'middle', 'title'), "df11091e2fc1", None), - }, - "expected_since_2.1.0.toml": { - # The ph removal (#459/#521): one literal name, the cases.py - # row written to carry the bare trailing `Ph.` shape, since no - # corpus name had it -- which is why the removal measured as - # zero role movers at review time. - "change(suffix-acronym-collisions) ph leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's rules, eight at landing (2026-09-25) -- five literal - # alternations and three literal names -- and six since #544 - # (2026-09-27) retired two, every name one of #540's own case - # rows. The - # report rule's `_ambiguities`-only roles are its point: a - # widening that took a role would change them here first. - "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), - "fix(#540) a lone meng or MEng after a family comma reads as the given name": - _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), - "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": - _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'John Smith, PhD MEng', 'john smith, phd meng'. - "fix(#540) a Title-case Lac behind a particle is the family name": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'Wang M.Eng.'. - # #436/#437's Latin alternation, first in every ledger. - # Ten corpus names, `suffix` alone: the rule moves the - # SEPARATOR and no role, so a widening that took a role would - # change the row here before it reached the gate. - # 2026-09-20, #397: 11 -> 14, three new comma-written - # generation runs ('Josep Lluis Carod i III', 'Josep Lluis - # Carod i V', 'Rovira, Josep Carod i Jr.'), each of which #397 - # leaves byte-identical -- measured against the parent - # 46651750 -- and each of which arrived with that change's own - # case rows. Roles unmoved, `suffix` alone as before. - # 2026-09-27, #544: 15 -> 18; gains 'Jane Doe Jr. Ma', 'John - # Smith PhD Ed Ma', 'John Smith PhD Ma'. - # 2026-10-04, #601/#602: 18 -> 17; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas": - _Claim(17, ('suffix',), "8f6fa4cee3c3", None), - # #449's six rules, second in every 2.x ledger. The - # alternation reached twenty-two corpus names at landing - # (twenty-three since #544, 2026-09-27) and - # `_ambiguities` alone: no role moves anywhere in this change, - # so a widening that took a role would change the row here - # before it reached the gate, and a member reaching a name - # something DECIDED ('abdul', 'de', 'Dr. Smith') would move - # the count. The five CJK rules are literal-anchored on one - # corpus name each, a reach _CORPUS_CLAIMS cannot police on - # its own -- a widening into names the corpora lack leaves it - # unmoved -- so _MUST_NOT_MATCH carries the boundary probes - # beside them. The digests are the same in all three 2.x - # ledgers because the regexes are the same strings and the - # corpora are one set. - # 2026-09-27, #544: 22 -> 23; gains 'Wang M.Eng.', whose diff - # here is the one-word name's report alone. - "feat(#449) a lone name word reports given-or-family": - _Claim(23, ('_ambiguities',), "ea61ec446eef", None), - "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it": - _Claim(1, ('_ambiguities',), "80777383a11a", None), - "feat(#449) an interpunct transcription declines the script order, so the convention decides it": - _Claim(1, ('_ambiguities',), "ba9a4258c864", None), - "feat(#449) an ideographic comma leaves one name word, and the convention decides it": - _Claim(1, ('_ambiguities',), "4b2858642238", None), - "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order": - _Claim(1, ('_ambiguities',), "528858346d14", None), - "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order": - _Claim(1, ('_ambiguities',), "30ec95e25f05", None), - "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it": - _Claim(1, ('_ambiguities',), "63f797a141db", None), - # #491's two rules, beside #449's for the same reason: both - # classify on `_ambiguities` alone, and no role moves anywhere - # in this change, so a widening that took a role would change - # the row here before it reached the gate. Six title names and - # two suffix names, one alternative each; a member reaching a - # name the SHAPE does not select ('Dr. Smith', 'Rabbi Cohen', - # 'Smith Jr.', the lone 'Dr.' the peel takes whole) would move - # the count, and _MUST_NOT_MATCH carries those probes. Same - # digests in all three 2.x ledgers: the regexes are the same - # strings and the corpora are one set. - "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name": - _Claim(6, ('_ambiguities',), "9ee2a07d96c8", None), - # 2026-09-23, #459: 2 -> 3, 'qc mp', rules.md#R4's name-role - # boundary line. Same digest in all three 2.x ledgers. - "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name": - _Claim(3, ('_ambiguities',), "6d5341766f53", None), - # The join clause's own rule, added by the #518 review round: - # hoisting the clause out from under O5's field-deciding - # guards reached two corpus names, both a peeled title in - # front of a joined unit standing last. Literal-anchored on - # the two, so _MUST_NOT_MATCH carries the boundary probes. - "feat(#491) a joined unit carrying title vocabulary reports title-or-name": - _Claim(2, ('_ambiguities',), "7af3fec03ccc", None), - # #346's alternation. Four corpus names, `family` and - # `given` together: the fold moves both roles at once, so a - # widening taking one alone would change the roles here - # before it reached the gate. - "fix(#346) a renunciate title and one name word leave the name a given name": - _Claim(4, ('family', 'given'), "a3399ee7b21e", None), - # #519's alternation. Three corpus names, `family` and `given` - # together: the fold moves both roles at once, so a widening - # taking one alone would change the roles here before it - # reached the gate. - "fix(#519) prince and princess join the given-name titles": - _Claim(3, ('family', 'given'), "ca37331f9803", None), - # #342's alternation. Five corpus names and four roles: the - # comma forms move `given` where the bare forms move `middle`, - # so a widening taking one shape alone would change the roles - # here before it reached the gate. - "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word": - # 2026-10-01, #564: `given` leaves the roles -- the comma pair - # reads suffix again by its capitals, so no name here moves it. - _Claim(5, ('family', 'middle', 'suffix'), "c3d76812da97", None), - # The four one-name CJK rules, literal-anchored, at the two - # baselines where the render is the whole of what moved. A - # reach of 1 is one _CORPUS_CLAIMS cannot police on its own -- - # a widening into names the corpora lack leaves it unmoved -- - # so _MUST_NOT_MATCH carries the boundary probes beside it. - "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run": - _Claim(1, ('suffix',), "7afc1a7f6b7b", None), - "fix(#436/#437) the glued honorific and the polite address are one post-nominal run": - _Claim(1, ('suffix',), "4fc52f5a60f2", None), - "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run": - _Claim(1, ('suffix',), "6edfa4394c33", None), - "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run": - _Claim(1, ('suffix',), "1b67339cf744", None), - # The compound rule, at the two baselines where 'abdul Smith - # Jr V' already diffs {family, given} under fix(#401) and the - # widened diff leaves that rule's `fields`. Three roles here - # because the diff it explains carries all three. - "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve": - _Claim(1, ('family', 'given', 'suffix'), "8152bc33f8da", None), - "fix(#371) a suffix never begins a name: the Ph./D. merge declines at the head": - _Claim(4, ('family', 'given', 'middle', 'suffix', 'title'), "1425d85a2d86", None), - "fix(#335) a marker-led clause leaves the one name word its bare reading": - _Claim(1, ('maiden', 'nickname'), "c09cc7dba88b", None), - "fix(#434) a multi-word maiden marker takes the maiden name": - _Claim(1, ('family', 'maiden', 'middle'), "c428798fc6ef", None), - "fix(#434) a multi-word marker leads a bracketed clause to the maiden name": - _Claim(1, ('maiden', 'nickname'), "0b3ef183f283", None), - "fix(#335) a marker-led bracketed clause reads as the maiden name whatever pair encloses it": - # 2026-10-12, #653: 6 -> 7, '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', - # the drop-pass witness row, added to the regex by literal: - # its paren clause is the marker-led one this rule names. - _Claim(7, ('maiden', 'nickname'), "89cde2208e99", None), - "fix(#410) a title and one name word name the family, whatever annotation stands beside it": - _Claim(4, ('family', 'given'), "da1dd1473145", None), - "fix(#430) a credential run does not end at the roman numeral describing it": - _Claim(2, ('given', 'suffix'), "3c8fa6bc827a", None), - "fix(#432) a dotted numeral behind a name is a middle initial, not the generation": - _Claim(1, ('middle', 'suffix'), "e9f282da0d0f", None), - "fix(#429) a wholly-credential segment after a one-word family renders as one entry": - _Claim(1, ('suffix', 'title'), "9e0b9e8d5cbe", None), - # 2026-09-18, #531: 13 -> 19, the same six particle-trailing - # comma names as the 1.4 twin, whose entry carries the roster. - # 2026-09-19, #531 review round: 19 -> 20, the same one new - # name as that twin ('Doe, John van DO'). - # 2026-09-27, #544: 26 -> 27; gains 'doe, jane v phd do'. - # 2026-10-02, #573: 31 -> 32, 'DOE, JANE PHD VD', P6's - # boundary, which attaches as this rule reads. Reach, verified. - "fix(#379) a tussenvoegsel after a family comma attaches to the family": - # 2026-09-19, #531 fix round: 20 -> 21, the same one new - # corpus name as the 1.4.0 copy ('Doe, John MA do'), reached - # on the trailing tussenvoegsel and explained by neither. - # 2026-09-19, #533: 21 -> 24, the same three new corpus - # names as the 1.4.0 copy -- the do pair this change added - # after a family comma. - # 2026-10-01, #562: 27 -> 29, 'John Smith, PhD DO DO' - # and 'John Smith, PhD vd DO', #562's rules.md#C1 examples - # and case rows. Reach, verified name by name. - # 2026-10-01, #554: 29 -> 31, 'John Smith, Jr vd' and 'John - # Smith, Jr do', #554's rules.md#C1 examples. Reach, verified - # name by name. - _Claim(32, ('_ambiguities', 'family', 'middle'), "3e3ff4af0060", None), - "fix(#379) a tussenvoegsel behind a post-nominal after a family comma attaches": - _Claim(1, ('family', 'middle'), "f24e5eee3cc1", None), - "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle": - _Claim(1, ('_ambiguities', 'family', 'given'), "ca7b37af6cf8", None), - "fix(#367) a title no longer displaces a leading particle out of the leading position": - _Claim(3, ('family', 'given'), "724967a4a117", None), - # 2026-10-01, #554: 2 -> 3, 'John Smith, Jr vd', #554's - # rules.md#C1 example. Reach, verified by name. - # 2026-10-02, #573: 3 -> 4, 'DOE, JANE PHD VD', P6's - # boundary, which attaches as this rule reads. Reach, verified. - "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal": - _Claim(4, ('_ambiguities', 'family', 'suffix'), "25798c4e2a22", None), - # 2026-10-04, #601/#602: 6 -> 7; 'Mai Le née Nguyen', a new rules.md#M2 example, is in reach -- at this baseline the particle chain swallowed its marker, the #399 shape. - "fix(#399) a maiden marker bounds the particle chain that swallowed it": - _Claim(7, ('family', 'maiden'), "0a306405fb9a", ('DEFAULT',)), - "fix(#360) mc moved into the never-given particles, so it folds into the family": - _Claim(1, ('_ambiguities', 'family', 'given'), "ee4339908f4d", None), - "fix(#400) abd joins the word after it as one given name": - _Claim(11, ('given', 'middle'), "1eaed91fc574", None), - "fix(#367) a title no longer displaces a leading never-given particle": - _Claim(1, ('family', 'given'), "db724fb9c779", None), - # 2026-09-19, #533: 1 -> 2. One new corpus name, 'Berg, - # abdul nee Jones MA' -- this change's record that P5's - # lenient post-comma join takes the released member before - # assign can read it, so the clause form agrees with the - # bare 'Berg, abdul MA'. Reach, not explanation. - # 2026-09-26, #535: 2 -> 3, 'Berg, abdul nee Smith V' joining - # the corpus -- the same bound-given/maiden shape. Reach, not - # explanation. - # 2026-10-04, #601/#602: 3 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#411) the bound-given reserve stops counting words the maiden name takes": - _Claim(1, ('given', 'maiden', 'middle'), "431d3dd24c14", None), - "fix(#412) a connective join no longer absorbs the maiden marker beside it": - _Claim(2, ('family', 'maiden'), "51c0eb36b5c5", None), - "fix(#418) the connective carve-out counts the name the maiden clause leaves behind": - _Claim(1, ('family', 'given', 'middle'), "7923e6d3c5a7", None), - "fix(#418) accepted: a suffix word inside the maiden name ends it, connective or not": - _Claim(1, ('family', 'maiden', 'middle'), "bedc18423d2a", None), - "fix(#369) a given-name title licenses the bound given-name join with one word to spare": - _Claim(3, ('family', 'given'), "724be3e6b926", None), - "fix(#401) the bound-given reserve counts the trailing numeral assign reads as the suffix": - _Claim(2, ('family', 'given'), "5713d4c0bd68", None), - "fix(#421) the bound-given join never absorbs a suffix piece": - _Claim(1, ('given', 'middle'), "9523e518e6ec", None), - "fix(#421) the bound-given join never absorbs a split credential": - _Claim(1, ('given', 'middle', 'suffix'), "228abe0f32ef", None), - # 2026-09-18 (#289): the reach is unchanged at two names and the - # ROLES moved -- `given` out, `middle` in. 'abdul Smith Ma' - # stops diffing at this baseline altogether and - # 'abdul Smith Jr Ma' now reads middle 'Jr'; the ledger's - # own dated paragraph carries the argument. - # 2026-09-27, #544: 2 -> 2; roles ['family', 'middle', - # 'suffix'] -> ['family', 'given', 'suffix'], and relabelled - # fix(#425/#436/#437), the `suffix` being R1's spacing alone. - "fix(#425/#436/#437) the bound-given reserve runs assign's peel over the joined view": - _Claim(2, ('family', 'given', 'suffix'), "ef1ab03b617e", None), - "fix(#424) the particle chain stops before the trailing numeral": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "2c99162bc9cf", None), - # 2026-09-18 (#289): 1 -> 2. 'john van der berg ma' entered - # the corpus as rules.md#P2's one-case contrast, and it is the - # spelling this rule still describes -- see the ledger's own - # dated paragraph, which replaced a `dormant` key with it. - "fix(#424) the particle chain stops before a bare acronym with words to spare": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "18fbd95cd89e", None), - "fix(#424) a title-led chain before the numeral is the one name piece": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "5b3a743f9e35", None), - "fix(comma-family) a comma followed only by titles keeps the given/family split": - _Claim(2, ('family', 'given'), "5bd9c6d96c38", None), - # 2026-10-06, #613: 2 -> 3; absorbed 'John Smith, Mr. and Mrs.', the C1 example #613 added; the regex was widened to take it. - "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example": - _Claim(3, ('family', 'given'), "e87f5944b870", None), - "fix(#296) a dropped prenominal takes the name position it occupies": - _Claim(5, ('_ambiguities', 'given', 'middle', 'title'), "e5aa72ebb9bf", None), - # `middle` left the ROLES in the same edit, and the reach - # grew with the rules corpus; the 1.4.0 roster above carries - # both, and says the same at the other two baselines. - # 2026-09-26, #535: 13 -> 14, 'Jane Doe nee Prof. Dr.' joined - # the corpus and ends in " Dr." like the rest this rule reaches. - # 2026-10-06, #613: 14 -> 15; its reach absorbed 'Ortega y Gasset, Dr.', the C1 example #613 added, which ends in 'Dr.'. - "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word": - _Claim(15, ('family', 'suffix'), "0c6062c4dbd7", None), - "fix(#296) a credential-only comma string reads a name and its postnominal": - _Claim(2, ('suffix', 'title'), "3f983ff71dee", None), - # 2026-09-18: 18 -> 20. Two new corpus names, 'Smith, MA' and - # 'Smith, Ma' -- the pair #289's lean reads in opposite - # directions, both of them lone post-comma credentials - # by this rule's own description. - # 2026-09-18, #531: 20 -> 21, the same one new name - # ('Doe, MA') as the 1.4 twin. - # 2026-09-25, #540: 21 -> 23. Two new corpus names, 'Smith, - # meng' and 'Smith, MEng' -- reached by the same post-comma - # shape this rule matches, but read the opposite way and - # explained by neither: at this baseline the diff moves - # `given`, a field outside this rule's own ('suffix', 'title'). - "fix(#296) a lone post-comma credential is a suffix": - # 2026-10-01, #564: 23 -> 24, 'Smith, XYZ'. Reach. - # 2026-10-04, #606: 24 -> 25, 'Greve, Anna'. Reach. - _Claim(25, ('suffix', 'title'), "b2bc2b830168", None), - # 2026-09-27, #544: 6 -> 7; gains 'Smith, PhD MEng'. - # 2026-09-28, #544: 7 -> 8; gains 'Smith, PhD Ma'. - "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes": - _Claim(8, ('given', 'suffix', 'title'), "81e29a5745e7", None), - "fix(#325) a credential run across a second comma reads as suffixes": - _Claim(1, ('suffix', 'title'), "f025c5f70a4e", None), - "fix(#296) a glued honorific before a lone credential: the credential is the postnominal": - _Claim(1, ('suffix', 'title'), "01bf2bd3f895", None), - "fix(#296) do is a name, so it no longer stops the leading-particle scan as a title": - _Claim(1, ('family', 'given'), "faa2c70fc49e", None), - # 2026-10-04, #603: roles lost `_ambiguities`; a title word past - # the second comma is a title now, so 'John, Smith, Dr.' raises - # no comma-structure report and the rule's fields narrowed. - # 2026-10-06, #613: 2 -> 3; absorbed 'Ortega y Gasset, Dr.', the C1 example #613 added; the regex was widened to take it. - "fix(#296) dr is not postnominal vocabulary, so 'John Smith, Dr.' keeps its split and its title": - _Claim(3, ('suffix', 'title'), "714ad68cf4cc", ('DEFAULT', 'FAMILY_FIRST')), - # 2026-09-18: 1 -> 2. One new corpus name, 'Jack MA.', the - # all-caps spelling #289's case contrast now reads as a - # credential; the rule's own 'Jack Ma.' is untouched. - "fix(#296) an ambiguous acronym counts as a suffix only when written with its periods": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "29b08f75e6ff", None), - "fix(#367) an inferred title no longer displaces a leading particle either": - _Claim(1, ('family', 'given'), "d8ee9cd5da5f", None), - "fix(#424) accepted: a particle of the suffix vocabulary opening the trailing run is a suffix piece": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "a564b97f7162", None), - "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle, the P4 example": - _Claim(1, ('_ambiguities', 'family', 'given'), "42b69cf1b320", None), - "fix(#424/#445) the maiden walk stops before the trailing numeral, and the lone name word is the family": - _Claim(1, ('_ambiguities', 'family', 'given', 'maiden', 'suffix'), "cbe5bdd97317", None), - # 2026-09-19, #533: 6 -> 7, and as at 1.4.0 the new name - # looks like an accident and is not: this rule is an (?i) - # alternation carrying 'John n[ée]e Jones Smith Ma', which - # the example line 'John née Jones Smith MA' matches - # case-insensitively and correctly, the two being one - # given->family shape written two ways. Reach, not - # explanation. - # 2026-10-04, #601/#602: 7 -> 5; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#445) a maiden marker makes the lone name word the family": - _Claim(5, ('_ambiguities', 'family', 'given'), "2648aa85b6b4", None), - "fix(#445) the lone name word beside a marker a connective join no longer absorbs": - _Claim(1, ('family', 'given', 'maiden', 'middle'), "52544a41dd62", None), - "fix(#369) the bound given-name join takes a particle-and-bound word, so no fork is reported": - _Claim(1, ('_ambiguities',), "81cf02ffdb33", None), - "fix(#360) ste moved into the never-given particles with mc": - _Claim(1, ('_ambiguities', 'family', 'given'), "e62caedec864", None), - "fix(#399) a maiden marker bounds the particle chain: the geb. spelling": - _Claim(1, ('family', 'maiden'), "2150936a8c55", None), - "fix(#399) a maiden marker bounds the particle chain: a native-script marker": - _Claim(1, ('family', 'maiden'), "f016cc61ca43", None), - "feat(#395) a leading never-given particle bounds the family under a declared family-first order": - _Claim(1, ('family', 'given', 'middle'), "47dcff268731", ('FAMILY_FIRST', 'FAMILY_FIRST_GIVEN_LAST')), - "fix(#399)/feat(#395) a consumed maiden marker leaves the family-first fold no given name": - _Claim(1, ('family', 'given', 'maiden'), "504eb466e347", ('FAMILY_FIRST', 'FAMILY_FIRST_GIVEN_LAST')), - "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold": - _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "fc6bc9e605e1", ('FAMILY_FIRST',)), - "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling": - _Claim(1, ('family', 'given', 'middle', 'suffix', 'title'), "3e43a2be022e", ('FAMILY_FIRST_GIVEN_LAST',)), - # #484's two, the same pair as the 2.0.0 ledger's: the change - # shipped in 2.2.0, so it is equally visible from either 2.x - # baseline, and the reaches and digests agree because the two - # files carry the same literal list. - # 2026-09-25, #540: 29 -> 30, 'nguyen van lac', reached and - # not explained here, as in the 2.0.0 mapping. - # 2026-09-25, #540: 30 -> 31, 'Nguyen Van Lac', reached and - # not explained here, as in the 2.0.0 mapping. - "fix(#385/#402) an all-particle name part initials its words (R2)": - _Claim(31, ('_initials',), "001fb4be2f76", ('DEFAULT',)), - "fix(#360) los joined the particles, so it no longer initials": - _Claim(1, ('_initials',), "cd721215f463", ('DEFAULT',)), - # fix(#462), reach and digest as in the 2.0.0 mapping: the same - # regex over the same corpora, and the facade bug it fixes is - # in every 2.x wheel, so the baseline makes no difference. - # 18 -> 22 on 2026-09-13: four #383/#479 names entered the - # corpora carrying a bare capital E or Y this regex reaches. - # expected_since_2.0.0.toml's copy of this rule says which - # four, and why only one of them diffs. - "fix(#462) the facade keeps an initial-shaped conjunction letter": - _Claim(22, ('_initials',), "7386690d2928", ('DEFAULT',)), - # The 2.3 title-run bundle's five rules, last in every - # ledger, and the same reach at all four: the 1.4.0 roster - # above carries the argument. - "fix(#489) a title run addresses by its last title": - _Claim(2, ('family', 'given'), "e14159a4d48f", None), - "change(suffix-acronym-collisions) esq leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), "ef9c8cfc56d8", None), - # `_ambiguities` is in the ROLES at the three 2.x baselines and - # not at 1.4.0, which has no ambiguity surface to compare - # (compare.py's _RULE_FIELDS). Same regex, so the reach and the - # digest are identical in all four ledgers and only the roles - # move. - "fix(#489) the title peel leaves a name word a suffix cannot be": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "07b02286cd81", None), - # 2026-09-18 (#289/#516): 16 -> 17. One new corpus name, - # 'JOHN PROF. MA', added to the alternation deliberately -- - # the ledger comment argues it. No role joined the five. - "fix(#316) a trailing period-marked title word reads as a title": - _Claim(17, ('family', 'given', 'middle', 'suffix', 'title'), - "0dc3551e57da", None), - # The CJK member of the same argument, its own literal rule - # (an UNDECLARED alternation holding a script-classified - # member belongs to the honorific pin above -- see the 1.4.0 - # mapping's note on what #322/#323 changed there). ONE corpus - # name, and the FEWEST roles of any ledger: 2.1.0 shipped the - # Han family-first order, so only the Latin word moves, out of - # `suffix` and into `title`. - "fix(#316) a trailing Latin title on a native-script name is a title": - _Claim(1, ('suffix', 'title'), - "567f09dc9b45", None), - # #322/#323. Reach, roles and digest as in the 2.2.0 mapping - # above -- the same seventeen-member regex, nothing between - # 2.1.0 and 2.2.0 having touched any of these readings. - "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token": - _Claim(17, ('_ambiguities', 'family', 'given', 'middle', - 'suffix', 'title'), "ef4a7afe791a", None), - # #383/#479's two rules, last in this ledger as in the file. - # The role rule reaches TWO corpus names and declares four - # fields, which is the union of two disjoint diffs plus the - # report ONE of them carries -- 'jose e maria santos' moves - # `given` and `middle` and reports, 'JUAN GARCIA Y LOPEZ' - # moves `middle` and `family` and reports nothing. Its digest - # is the 1.4.0 rule's, the same literal alternation over the - # same corpora; its roles are not, the report being a v2 - # surface. The report rule reaches SIX, `_ambiguities` alone: - # every role is identical on all six and only the CALL is new. - # Both regexes are literal alternations, so a new name here - # means one of them grew -- seventeen corpus names sit in the - # class they describe and only ten of those move at all. - "fix(#383/#479) a single-letter connective joins only on case evidence": - _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), - "ec00806a06f0", ('DEFAULT',)), - "fix(#383/#479) a marked connective letter in a one-case name is reported": - _Claim(6, ('_ambiguities',), "03de2830707e", ('DEFAULT',)), - # #289's alternation, appended with #516's and the report - # rule's below it. Literal-anchored to the movers a written - # case contrast decides, `orders` DEFAULT: the class is a - # SHAPE and not a wordlist, so a member spelled as the class - # would claim the one-case spellings that do not move. - # _MUST_NOT_MATCH carries those, and at a reach this small it - # is the wall this row cannot be. - # 2026-09-18, second round: two more names, 'John Smith, Ma' - # and 'Smith Jr., Ma', the Title-case halves of two minimal - # pairs rules.md#C1 now states. No role joined the list. - # 2026-09-27, #544: 24 -> 23; loses 'abdul Smith Jr Ma'. - "fix(#289) a written case contrast decides a bare ambiguous acronym": - # 2026-09-18, review round: 'John de Ma' joins, the - # one-particle spelling of 'John van der Berg Ma'. Its diff - # is the restored chain report at the 2.x baselines and the - # role move at 1.4.0 and 2.2/2.3; no role joined the list. - # 2026-09-19, #531: 20 -> 23. Three corpus names, 'Doe, MA', - # 'Doe, MA PhD' and 'Doe, Dr. MA', the lone post-comma - # credentials #531's control rows brought in. Verified to be - # those three and no others; no role joined the list, and the - # 1.4.0 copy does not carry them (its ledger comment says - # which rule already owns their diff there). - # 2026-09-19, #531 fix round: 23 -> 24. One corpus name, - # 'Doe, Mr. MA PhD' -- 'Doe, Dr. MA' with a credential run - # behind the member. Verified to be that name and no other; - # no role joined the list. - # 2026-10-01, #575: 23 -> 31, the eight C1 examples the - # alternation gained. Reach, verified name by name. - _Claim(31, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "997497ae83e5", ('DEFAULT',)), - # 2026-10-01, #575: new, 1; 'van der Berg, PhD'. - "fix(#575) a particle surname before a comma is one name word": - _Claim(1, ('_ambiguities', 'family', 'given'), "d449a9b43779", ('DEFAULT',)), - # 2026-10-01, #564: new, 7; 'Ahmad Jayadi, CHA', 'García - # Márquez, MJ JK', 'John Smith, LEED AP', 'John Smith, PhD XYZ', - # 'John Smith, RAI', 'John Smith, XYZ', 'The Rt Hon Kenneth - # Clarke QC MP, HMG'. - "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default": - _Claim(7, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "66be5f479c54", ('DEFAULT',)), - # #516's alternation. Literal-anchored to the by-shape movers, - # `orders` DEFAULT. Same reasoning as the rule above: the - # class is a shape the vocabulary does not spell, so the - # probes and not this number are what bound it. - # 2026-09-30, #563: 9 -> 10, a different set. 'John Smith, A.B.' - # leaves for the #563 rule (paired initials, rules.md#C1), and - # 'John Smith, X.Y.Z.' and 'García Márquez, G.J.R.' join, the - # comma form's by-count movers. Literal-anchored, so the reach - # IS the mover list. - "fix(#516) an unlisted dotted acronym is read by position": - _Claim(10, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "9efed4efb90b", ('DEFAULT',)), - # 2026-09-30, #563: new. Literal-anchored to its movers -- - # four role movers and five names whose only diff is the report. - "fix(#563) paired initials after a comma read as the given name unless a word speaks for them": - _Claim(9, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "3550ea3b5804", ('DEFAULT',)), - # 2026-10-06, #613: roles lost `_ambiguities`; read before any - # join, the pair is settled by its capitals and no longer - # reported, so the rule's fields narrowed. - "fix(#562) a comma credential run holding a particle chain reads by the name-word count": - _Claim(2, ('family', 'given', 'suffix', 'title'), "29c98be5c6c7", ('DEFAULT',)), - # The report-only rule. `_ambiguities` alone, so a widening that - # took a ROLE would change the roles here before it reached - # the gate -- which is the one thing this row can say about a - # rule whose whole subject is a report. - "fix(#289/#516) the ambiguous credential class reports at slots that were silent": - # 2026-09-19, #531 fix round: one corpus name joins, - # 'Doe, MA Smith' -- the post-comma given slot reporting where - # no role moves at any baseline. Verified to be that name and - # no other. - _Claim(9, ('_ambiguities',), "879e77ab5baa", ('DEFAULT',)), - # The 2026-09-18 review round's honorific-peel rule: three - # composed CJK forms, radar tier, classified because the arc - # intended the move (the ledger comment carries the argument). - "fix(#289/#516) the glued CJK honorific peel reads the case lean": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "4fb6dbdbccd4", ('DEFAULT',)), - # 2026-09-19, #531. Five rules, every one - # literal-anchored and claiming exactly the names its ledger - # comment names -- verified name by name against the gate's - # UNEXPLAINED and UNCLASSIFIED (radar) blocks at this - # baseline. The caseless pair is radar-tier and classified all - # the same, on fix(#289/#516)'s stated ground: a radar diff an - # arc intended is one a release note may be written from. - # `Doe, John DO` moves {middle, suffix} here and - # {family, suffix} from 2.2.0 on, which is P6's own shipping - # date showing through. - # 2026-09-30, #563: 15 -> 16, 'García Márquez, Ed G.J.' -- a - # class member in front of a pair keeps the family comma, and - # the pair then ends the given part. Literal-anchored. - "fix(#531) a credential ending the given part of a family-comma listing reads as a credential": - _Claim(16, ('_ambiguities', 'middle', 'suffix'), "2da482a17f0e", ('DEFAULT',)), - "fix(#531) a member the writing declines keeps its name reading and reports the fork": - _Claim(4, ('_ambiguities',), "c6d26d145ee1", ('DEFAULT',)), - "fix(#531) capitals take the do collision from the family-comma particle attachment": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "8ad64f404621", ('DEFAULT',)), - "fix(#531) the trailing slot's positional reading reaches a caseless script": - _Claim(2, ('_ambiguities', 'middle', 'suffix'), "8419a6f53c3e", ('DEFAULT',)), - # The `mc` collision, at the two baselines where it diffs: - # `mc` joined the never-given particles in 2.2 and P6's - # family-comma attachment shipped in 2.3, so 2.2.0 and 2.3.0 - # already read family 'mc Berg' and carry no rule for it. - "fix(#380) a trailing mc after a family comma is the tussenvoegsel, not a post-nominal": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "105229644206", ('DEFAULT',)), - # The change's own movers: seventeen corpus names at 2.2.0 - # and 2.3.0, fourteen at 2.0.0 and 2.1.0 where three of them - # move roles those baselines cannot see and have rules of - # their own. `suffix`, `maiden` and `_ambiguities` together: - # the member lands in one, leaves the other and is reported, - # so a widening taking any of the three alone would change - # the roles here before it reached the gate. - # 2026-09-26, #535: 15 -> 14, 'Jane Doe nee Smith Prof. MA' - # LEAVING this rule's alternation -- the title chain now ends - # the clause before the credential is asked, so the name has - # its own fix(#535) rule instead. - # 2026-10-04, #601/#602: 14 -> 10; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) a credential ending a maiden clause reads as a credential": - _Claim(10, ('_ambiguities', 'maiden', 'suffix'), "e05c5164668c", None), - # The declining half: fourteen corpus names at 2.2.0, eight - # at 2.0.0 and 2.1.0. `_ambiguities` alone, so a role - # appearing here is this rule reaching a name whose clause - # gave the member up. - # 2026-09-26: 8 -> 9, 'Jane Doe nee King. ba' -- this - # baseline already splits suffix 'ba' from maiden 'King.', so - # the whole diff is #533's; #535 moves no role here either, - # only the report. - # 2026-09-27, #544: 9 -> 10; gains 'Jane Doe Jr. nee Smith - # Ma'. - # 2026-10-04, #601/#602: 10 -> 4; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) the maiden clause reports the credential it keeps": - _Claim(4, ('_ambiguities',), "c57e8eb30830", None), - # One corpus name. The by-shape member has no lean to read, - # so a growth here is the rule reaching a LISTED member -- - # a different reading under this rule's sentence. - "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "0cc7bd35a9b3", None), -# One corpus name, `_ambiguities` alone at every 2.x - # baseline: the roles are the ones these releases already - # read, which is what makes the row a restoration. A role - # appearing here would mean the restoration had stopped - # being one. - "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name": - _Claim(1, ('_ambiguities',), "0a7295a0cb19", None), - # One corpus name, at 2.0.0 and 2.1.0 only. Case-sensitive by - # construction, so a growth here is the anchor having picked - # up the mixed-case spelling, which reads the other way. - "fix(#445/#533) a one-case clause keeps the credential, and the one name word it leaves is the family": - _Claim(1, ('_ambiguities', 'family', 'given'), "a1816f915523", None), - # One corpus name, three roles and no `suffix`: v1 read the - # trailing MA as a post-nominal and so does the tree. A - # `suffix` appearing here would mean the phrase rule had - # reached a name whose member moved. - "fix(#434/#533) a marker PHRASE takes the maiden name, and its clause ends at the credential": - _Claim(1, ('family', 'maiden', 'middle'), "bf8359f65c8a", None), - # One corpus name, and the rule is #424's rather than #533's: - # the reading is unchanged on this tree since 2f57ff21. A - # growth here is the numeral rule reaching an acronym name. - "fix(#424) the maiden walk stops before the trailing numeral, with the family name left standing": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "170a53c37765", None), - # One corpus name, on the radar tier. A growth here is this - # rule reaching a second CJK-marker name, which would be a - # corpus row to look at rather than a widening to wave - # through. - "fix(#533) the maiden clause ends at the credential in a native-script name too": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "6bab87214ddf", None), - "fix(#335/#533) a marker-led bracketed clause is the maiden name whatever its last word is": - _Claim(3, ('_ambiguities', 'maiden', 'nickname'), 'cc1045ecdc05', None), - # 2026-09-20, #397 and #461's four new rules, all literal - # alternations of exactly the names each explains -- a count - # that moves means the alternation grew, and _MUST_NOT_MATCH - # carries the walls a reach this small cannot be. - # - # The join reaches 5 and declares the union of its two diff - # shapes; the one-case report rule reaches 3 on - # `_ambiguities` alone, no role moving on any of them. #461's - # two halves are 16 and 13 on `_initials` alone, and they - # are two rules and not one because a rule listing `_initials` - # may list nothing else, so the two readings of one sentence - # cannot share a row either. - "fix(#397) the Catalan/Polish link joins two surnames": - _Claim(5, ('family', 'middle', 'suffix'), "0c31a48b1867", - ('DEFAULT',)), - "fix(#397) the Catalan link reports a connective-or-initial in a one-case name": - _Claim(3, ('_ambiguities',), "e43f9595ac02", ('DEFAULT',)), - "fix(#461) a connective holding its part alone contributes an initial": - _Claim(16, ('_initials',), "075dc34f9e95", ('DEFAULT',)), - "fix(#461) a connective with a name word beside it stops contributing an initial": - _Claim(13, ('_initials',), "3cc41f4bfc21", ('DEFAULT',)), - # 2026-09-22, #397 follow-up: 2 -> 3, the one new corpus name - # rules.md#M2's `deviates: #538` example adds, which this - # rule's own alternation now names. Verified name by name. - # 2026-09-26, #538: 3 -> 4, rules.md#M2's second live example, - # 'Smith, John, PhD née Puig - i Soler', joining the same - # alternation for the same reason. Verified name by name. - # 2026-10-04, #601/#602: 4 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#397) a link inside a maiden clause stays in the birth name": - _Claim(1, ('family', 'maiden', 'middle'), "4e62e18718a4", ('DEFAULT',)), - # New rule (#296/#535): one corpus name, 'Jane Doe nee Prof. - # Dr.'. This baseline still has 'dr' in the suffix vocabulary - # (#296 had not run yet), so it reads suffix 'Dr.'. The parent - # tree d9d80492, before #535, already reads maiden 'Prof. - # Dr.' (dr having left the suffix vocabulary by #296); #535 - # then moves that word on into `title`, returning maiden to - # 'Prof.' -- the same value this baseline reads there, so - # `maiden` does not move net baseline-to-HEAD. - "fix(#296/#535) a trailing title after a dropped postnominal Dr.": - _Claim(1, ('suffix', 'title'), '193350645868', None), - # New rule (#533/#535): one corpus name, 'Jane Doe nee Smith - # Prof. MA'. #533 already gives 'MA' up to the credential - # reading and reports the fork (`suffix`, `maiden`, - # `_ambiguities`) before #535 exists, verified against the - # parent tree d9d80492; #535 then reads the title chain - # through the credential, so the title standing in front of - # 'MA' moves `title` and `maiden` further. - "fix(#533/#535) a title in front of the credential the clause gives up": - _Claim(1, ('_ambiguities', 'maiden', 'suffix', 'title'), - 'cb85a2cbf9f2', None), - # New rule (#535): seven corpus names -- the title-stop - # literal list, minus the three names above whose diff from - # this baseline has an earlier cause too. `title`, `maiden` - # and `suffix` move for the credential-bearing ones, and - # `_ambiguities` for the declined-member one. - # 2026-10-04, #601/#602: 8 -> 6; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#535) a trailing title ends the maiden clause": - _Claim(6, ('_ambiguities', 'maiden', 'suffix', 'title'), "3e41b335baa4", None), - # 2026-09-27, #544: new, 5; gains 'Jane Doe, MS LAc', 'John - # Smith, MD MEng', 'John Smith, MEng PhD', 'John Smith, PhD - # MEng' and 1 more. - "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports": - _Claim(5, ('_ambiguities',), "bac52af569e4", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'John Smith, Ed Ma', 'John - # Smith, Ms Ma', 'John Smith, X.Y.Z. MA', 'john smith, md ma'. - # 2026-09-28, #544: 4 -> 5; gains 'John Smith, PhD Ma'. - # 2026-10-01, #554: 5 -> 8; gains 'John Smith, vd Ma', - # 'JOHN SMITH, VD MA' and 'John Smith, Jr do'. - "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count": - _Claim(8, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "ac1125df8691", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'Doe, Jane PhD MEng', 'Doe, - # Jane nee Smith PhD MEng', 'Jane Doe nee Smith PhD MEng', - # 'John Smith PhD MEng'. Relabelled the same day - # fix(#436/#437/#544), the `suffix` it claims being R1's - # spacing alone; reach, roles and digest unchanged. - "fix(#436/#437/#544) an unambiguous credential in front anchors the member behind it": - _Claim(4, ('_ambiguities', 'suffix'), "c9d1d53dee20", ('DEFAULT',)), - # 2026-09-27, #544: new, 1; gains 'doe, jane v phd do'. - "fix(#544) a degree in front outranks P6's attachment for a particle member": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "a2b8cea490a7", ('DEFAULT',)), - # 2026-09-28, #544: new, 2; 'Smith, PhD MEng', 'Smith, PhD Ma'. - "fix(#296/#544) a credential in front anchors a member after a one-word family comma, and the pick reports": - _Claim(2, ('_ambiguities', 'given', 'suffix', 'title'), "9fe346e1f150", ('DEFAULT',)), - # 2026-09-28, #544: new, 1; 'Smith, MD PhD Ma'. - "fix(#296/#531) phd is not a prenominal, so behind a dual title it is the given name and the member ending the part reports": - _Claim(1, ('_ambiguities', 'given', 'middle', 'title'), "8e9913f5df8b", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'DOE, JANE MA VD PHD', - # 'DOE, JANE PHD VD MA', 'doe, jane phd vd ma'. - "fix(#573) a particle-suffix word between credentials after a family comma stays in the run": - _Claim(3, ('_ambiguities', 'middle', 'suffix'), "03e448f4776b", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'SMITH, VD DE LA MA', - # 'SMITH, VD MA', 'smith, de ma'. - "fix(#573) a never-given particle opening the given part takes the member behind it": - _Claim(3, ('_ambiguities',), "54ce0dda7114", ('DEFAULT',)), - "fix(#585) a decomposed initial keeps its whole first letter": - _Claim(2, ('_initials',), "c4f045134f0f", None), - "fix(#594) halfwidth katakana takes the kana license and the 间隔号": - _Claim(5, ('family', 'given'), "86805db7d55e", None), - "fix(#594) a period-marked halfwidth katakana word is not a title": - _Claim(2, ('_ambiguities', 'given', 'title'), "8cdafcf56c45", None), - "fix(#596) a voicing mark does not make a katakana word kana-licensed": - _Claim(3, ('family', 'given'), "d822332b50f9", None), - "fix(#597) halfwidth corner brackets enclose a nickname": - _Claim(3, ('family', 'given', 'middle', 'nickname'), "d1b9b0addff3", None), - # 2026-10-04, #601/#602: new rules. - "fix(#601) a marker in the given part after a family comma is an ordinary word": - _Claim(17, ('_ambiguities', 'family', 'given', 'maiden', 'middle', 'suffix', 'title'), "c7ed65a885ac", None), - "fix(#601) a marker in a part after a suffix comma is an ordinary word": - _Claim(2, ('maiden', 'suffix'), "59dfc0c40e36", None), - "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word": - _Claim(5, ('_ambiguities', 'family', 'given', 'maiden', 'suffix', 'title'), "1c9432e08a21", None), - # 2026-10-04, #610: +1, 'Jane Doe nee Smith VI Prof.', rules.md#M2's - # numeral example behind a title. Reach. - "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes": - _Claim(5, ('_ambiguities', 'family', 'given', 'maiden', 'suffix', 'title'), "7d2d5efa8eb5", None), - "fix(#602) a credential after the name core starts a run to the end of its part": - _Claim(5, ('_ambiguities', 'family', 'middle', 'suffix', 'title'), "3129cd9609b9", None), - "fix(#627) a lone particle with the run right behind it is the surname": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "33096566ba7f", None), - "fix(#624) of two title-particles at the head the second leads the chain": - _Claim(1, ('_ambiguities', 'family', 'given', 'title'), "c5b6da915be2", None), - "fix(#623) after a family comma the chain stops before a trailing title": - _Claim(2, ('_ambiguities', 'family', 'middle', 'title'), "bbab0baa7826", None), - "fix(#636) a part past the second comma makes no particle chain": - _Claim(1, ('suffix', 'title'), "f409b04e4c16", None), - "fix(#637) the bound join declines a trailing title after a family comma": - _Claim(1, ('middle', 'title'), "feff01f1e769", None), - "fix(#640) the bound join declines a title word past the second comma": - _Claim(1, ('suffix', 'title'), "bd68517718e5", None), - "fix(#601/#602) a credential in the clause starts a run the take consumes": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "acdcc81cb17c", None), - "fix(#601) a marker behind a connective joins the connective run, and the run's initials follow": - _Claim(1, ('_initials',), "b3a89ea7087c", None), - # 2026-10-04, #604: new, 3; the rules.md#P7 boundary and the - # two rules.md#R4 case-repair examples. - "fix(#604) the Irish and Malay patronymic particles": - _Claim(3, ('family', 'middle'), "cfcfd91f9d58", None), - # 2026-10-04, #606: new, 1; 'Greve Anna', rules.md#H1's - # Accepted example for a surname-borne title. - "fix(#606) salutations and titles in other languages": - _Claim(1, ('given', 'title'), "0c78dbac8a6b", None), - # 2026-10-04, #603: new, 5; rules.md#C1's opened-part examples and the delimiter rows it moves. - "fix(#603) a credential opening the part after the comma makes it the postnominal part": - _Claim(5, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "a4ebde5b8cd7", None), - # 2026-10-04, #603: new, 6; rules.md#C2's tail-title examples and the radar names it moves. - "fix(#603) a title word in a part past the second comma is a title": - _Claim(6, ('_ambiguities', 'suffix', 'title'), "eeb11557d7f0", None), - # 2026-10-07, #614: new, 1; rules.md#H5's 'John van der Berg Prof.' example. - "fix(#614) a trailing title is read before the particle chain": - _Claim(1, ('family', 'title'), "4536c33ed2a2", None), - "fix(#653) a delimited clause keeps its comma": - # 2 -> 4 the same day: the two rules.md#M3 comma examples - # move only their nickname at this pre-M3 baseline. - _Claim(4, ('nickname',), "d7a0d8c919c8", None), - "fix(#652) a decomposed accented abbreviation is an abbreviation": - _Claim(1, ('given', 'middle', 'title'), "df11091e2fc1", None), - }, - "expected_since_2.3.0.toml": { - # The ph removal (#459/#521): one literal name, the cases.py - # row written to carry the bare trailing `Ph.` shape, since no - # corpus name had it -- which is why the removal measured as - # zero role movers at review time. - "change(suffix-acronym-collisions) ph leaves the acronym set": - _Claim(1, ('family', 'middle', 'suffix'), '8a2e1dbb972d', None), - # #540's rules, eight at landing (2026-09-25) -- five literal - # alternations and three literal names -- and six since #544 - # (2026-09-27) retired two, every name one of #540's own case - # rows. The - # report rule's `_ambiguities`-only roles are its point: a - # widening that took a role would change them here first. - "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name": - _Claim(2, ('_ambiguities', 'family', 'suffix'), "34801c7cd448", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name": - _Claim(2, ('_ambiguities', 'family', 'middle', 'suffix'), "28537bf159a3", ('DEFAULT',)), - "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name": - _Claim(1, ('_ambiguities', 'middle', 'suffix'), "84dcae6d2ff5", ('DEFAULT',)), - "fix(#540) a lone meng or MEng after a family comma reads as the given name": - _Claim(2, ('_ambiguities', 'given', 'suffix'), "96bf15fae64b", ('DEFAULT',)), - "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork": - _Claim(2, ('_ambiguities',), "4a72b3bde603", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'John Smith, PhD MEng', 'john smith, phd meng'. - "fix(#540) a Title-case Lac behind a particle is the family name": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "828c38e0abaa", ('DEFAULT',)), - # 2026-09-27, #544: the rule recorded here is retired, #544 - # reading its names as the rules that now claim them say; its - # names were 'Wang M.Eng.'. - # #383/#479's three rules, the first this ledger carries. The - # role rule is the 2.x shape of the 1.4.0 rule of the same - # name -- two corpus names, the union of two disjoint role - # diffs plus the report one of them carries -- and its digest - # is that rule's, the same literal alternation over the same - # corpora. The report rule reaches SEVEN here where it reaches - # six at the older baselines: 'e and e' is in the alternation - # only in this ledger, feat(#449) claiming it at 2.0.0-2.2.0, - # so the count and the digest both differ by that one name. - # The view rule reaches ONE and exists only here, fix(#462) - # explaining the same name's `_initials` move at the three - # older baselines where the facade moves too. All three are - # literal alternations, so a new name in any of them means the - # alternation grew; seventeen corpus names sit in the class - # they describe and ten of those move. - "fix(#383/#479) a single-letter connective joins only on case evidence": - _Claim(2, ('_ambiguities', 'family', 'given', 'middle'), - "ec00806a06f0", ('DEFAULT',)), - "fix(#383/#479) a marked connective letter in a one-case name is reported": - _Claim(7, ('_ambiguities',), "2eb6eff33836", ('DEFAULT',)), - # `fix(#383/#479) a bare capital connective in an all-upper - # name stops initialing` had a row here until 2026-09-20 - # (reach 1, 'JUAN Y GARCIA'). #461 gave that name its 'Y' - # back on both surfaces, which is what 2.3.0 itself gives, so - # the rule stopped explaining anything and is deleted from the - # ledger. - # #289's alternation, appended with #516's and the report - # rule's below it. Literal-anchored to the movers a written - # case contrast decides, `orders` DEFAULT: the class is a - # SHAPE and not a wordlist, so a member spelled as the class - # would claim the one-case spellings that do not move. - # _MUST_NOT_MATCH carries those, and at a reach this small it - # is the wall this row cannot be. - # 2026-09-18, second round: two more names, 'John Smith, Ma' - # and 'Smith Jr., Ma', the Title-case halves of two minimal - # pairs rules.md#C1 now states. No role joined the list. - # 2026-09-27, #544: 24 -> 23; loses 'abdul Smith Jr Ma'. - "fix(#289) a written case contrast decides a bare ambiguous acronym": - # 2026-09-18, review round: 'John de Ma' joins, the - # one-particle spelling of 'John van der Berg Ma'. Its diff - # is the restored chain report at the 2.x baselines and the - # role move at 1.4.0 and 2.2/2.3; no role joined the list. - # 2026-09-19, #531: 20 -> 23. Three corpus names, 'Doe, MA', - # 'Doe, MA PhD' and 'Doe, Dr. MA', the lone post-comma - # credentials #531's control rows brought in. Verified to be - # those three and no others; no role joined the list, and the - # 1.4.0 copy does not carry them (its ledger comment says - # which rule already owns their diff there). - # 2026-09-19, #531 fix round: 23 -> 24. One corpus name, - # 'Doe, Mr. MA PhD' -- 'Doe, Dr. MA' with a credential run - # behind the member. Verified to be that name and no other; - # no role joined the list. - # 2026-10-01, #575: 23 -> 31, the eight C1 examples the - # alternation gained. Reach, verified name by name. - _Claim(31, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "997497ae83e5", ('DEFAULT',)), - # 2026-10-01, #575: new, 1; 'van der Berg, PhD'. - "fix(#575) a particle surname before a comma is one name word": - _Claim(1, ('_ambiguities', 'family', 'given'), "d449a9b43779", ('DEFAULT',)), - # 2026-10-01, #564: new, 7; 'Ahmad Jayadi, CHA', 'García - # Márquez, MJ JK', 'John Smith, LEED AP', 'John Smith, PhD XYZ', - # 'John Smith, RAI', 'John Smith, XYZ', 'The Rt Hon Kenneth - # Clarke QC MP, HMG'. - "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default": - _Claim(7, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "66be5f479c54", ('DEFAULT',)), - # #516's alternation. Literal-anchored to the by-shape movers, - # `orders` DEFAULT. Same reasoning as the rule above: the - # class is a shape the vocabulary does not spell, so the - # probes and not this number are what bound it. - # 2026-09-30, #563: 9 -> 10, a different set. 'John Smith, A.B.' - # leaves for the #563 rule (paired initials, rules.md#C1), and - # 'John Smith, X.Y.Z.' and 'García Márquez, G.J.R.' join, the - # comma form's by-count movers. Literal-anchored, so the reach - # IS the mover list. - "fix(#516) an unlisted dotted acronym is read by position": - _Claim(10, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "9efed4efb90b", ('DEFAULT',)), - # 2026-09-30, #563: new. Literal-anchored to its movers -- - # four role movers and five names whose only diff is the report. - "fix(#563) paired initials after a comma read as the given name unless a word speaks for them": - _Claim(9, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "3550ea3b5804", ('DEFAULT',)), - "fix(#562) a comma credential run holding a particle chain reads by the name-word count": - _Claim(2, ('_ambiguities', 'family', 'given', 'suffix'), "29c98be5c6c7", ('DEFAULT',)), - # The report-only rule. `_ambiguities` alone, so a widening that - # took a ROLE would change the roles here before it reached - # the gate -- which is the one thing this row can say about a - # rule whose whole subject is a report. - "fix(#289/#516) the ambiguous credential class reports at slots that were silent": - # 2026-09-19, #531 fix round: one corpus name joins, - # 'Doe, MA Smith' -- the post-comma given slot reporting where - # no role moves at any baseline. Verified to be that name and - # no other. - _Claim(10, ('_ambiguities',), "5d427b25c1c7", ('DEFAULT',)), - # The 2026-09-18 review round's honorific-peel rule: three - # composed CJK forms, radar tier, classified because the arc - # intended the move (the ledger comment carries the argument). - "fix(#289/#516) the glued CJK honorific peel reads the case lean": - _Claim(3, ('_ambiguities', 'family', 'given', 'suffix', 'title'), "4fb6dbdbccd4", ('DEFAULT',)), - # 2026-09-19, #531. Four rules, every one - # literal-anchored and claiming exactly the names its ledger - # comment names -- verified name by name against the gate's - # UNEXPLAINED and UNCLASSIFIED (radar) blocks at this - # baseline. The caseless pair is radar-tier and classified all - # the same, on fix(#289/#516)'s stated ground: a radar diff an - # arc intended is one a release note may be written from. - # `Doe, John DO` moves {family, suffix} here, P6 having - # shipped in 2.3; at 2.0.0 and 2.1.0 the same name moves - # {middle, suffix} instead. - # 2026-09-30, #563: 15 -> 16, 'García Márquez, Ed G.J.' -- a - # class member in front of a pair keeps the family comma, and - # the pair then ends the given part. Literal-anchored. - "fix(#531) a credential ending the given part of a family-comma listing reads as a credential": - _Claim(16, ('_ambiguities', 'middle', 'suffix'), "2da482a17f0e", ('DEFAULT',)), - # 2026-09-28, #544: 4 -> 5; gains 'Smith, MD PhD Ma'. - "fix(#531) a member the writing declines keeps its name reading and reports the fork": - _Claim(5, ('_ambiguities',), "f73bc2fc5408", ('DEFAULT',)), - "fix(#531) capitals take the do collision from the family-comma particle attachment": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "8ad64f404621", ('DEFAULT',)), - "fix(#531) the trailing slot's positional reading reaches a caseless script": - _Claim(2, ('_ambiguities', 'middle', 'suffix'), "8419a6f53c3e", ('DEFAULT',)), - # The change's own movers: seventeen corpus names at 2.2.0 - # and 2.3.0, fourteen at 2.0.0 and 2.1.0 where three of them - # move roles those baselines cannot see and have rules of - # their own. `suffix`, `maiden` and `_ambiguities` together: - # the member lands in one, leaves the other and is reported, - # so a widening taking any of the three alone would change - # the roles here before it reached the gate. - # 2026-09-26, #535: 18 -> 17, 'Jane Doe nee Smith Prof. MA' - # LEAVING this rule's alternation -- the title chain now ends - # the clause before the credential is asked, so the name has - # its own fix(#535) rule instead. - # 2026-09-26: 17 -> 18, 'Jane Doe nee King. ba' -- unlike at - # 2.2.0, this baseline never splits the credential at all, so - # it joins the movers here instead of the declining half - # below; the whole diff is #533's and #535 moves nothing. - # 2026-10-04, #601/#602: 18 -> 11; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) a credential ending a maiden clause reads as a credential": - _Claim(11, ('_ambiguities', 'maiden', 'suffix'), "9f217cc8fda4", None), - # The declining half: fourteen corpus names at this baseline, - # eight at 2.0.0 and 2.1.0. `_ambiguities` alone, so a role - # appearing here is this rule reaching a name whose clause - # gave the member up. - # 2026-09-27, #544: 15 -> 16; gains 'Jane Doe Jr. nee Smith - # Ma'. - # 2026-10-04, #601/#602: 16 -> 6; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#533) the maiden clause reports the credential it keeps": - _Claim(6, ('_ambiguities',), "fe166ed5ead1", None), - # One corpus name. The by-shape member has no lean to read, - # so a growth here is the rule reaching a LISTED member -- - # a different reading under this rule's sentence. - "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "0cc7bd35a9b3", None), -# One corpus name, `_ambiguities` alone at every 2.x - # baseline: the roles are the ones these releases already - # read, which is what makes the row a restoration. A role - # appearing here would mean the restoration had stopped - # being one. - "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name": - _Claim(1, ('_ambiguities',), "0a7295a0cb19", None), - # One corpus name, on the radar tier. A growth here is this - # rule reaching a second CJK-marker name, which would be a - # corpus row to look at rather than a widening to wave - # through. - "fix(#533) the maiden clause ends at the credential in a native-script name too": - _Claim(1, ('_ambiguities', 'maiden', 'suffix'), "6bab87214ddf", None), - # 2026-09-20, #397 and #461's four new rules, all literal - # alternations of exactly the names each explains -- a count - # that moves means the alternation grew, and _MUST_NOT_MATCH - # carries the walls a reach this small cannot be. - # - # The join reaches 5 and declares the union of its two diff - # shapes; the one-case report rule reaches 3 on - # `_ambiguities` alone, no role moving on any of them. #461's - # two halves are 17 and 22 on `_initials` alone, and they - # are two rules and not one because a rule listing `_initials` - # may list nothing else, so the two readings of one sentence - # cannot share a row either. - # - # Both halves are at their widest here: the readmission - # half holds 'Juan y Garcia née Jones' as at 2.2.0, and the - # joining half holds nine names the #449 report masks at every - # earlier baseline (compare.py's roles-identical guard, #484). - "fix(#397) the Catalan/Polish link joins two surnames": - _Claim(5, ('family', 'middle', 'suffix'), "0c31a48b1867", - ('DEFAULT',)), - "fix(#397) the Catalan link reports a connective-or-initial in a one-case name": - _Claim(3, ('_ambiguities',), "e43f9595ac02", ('DEFAULT',)), - "fix(#461) a connective holding its part alone contributes an initial": - _Claim(17, ('_initials',), "797473971e75", ('DEFAULT',)), - "fix(#461) a connective with a name word beside it stops contributing an initial": - _Claim(22, ('_initials',), "e73827447b4e", ('DEFAULT',)), - # 2026-09-22, #397 follow-up: 2 -> 3, the one new corpus name - # rules.md#M2's `deviates: #538` example adds, which this - # rule's own alternation now names. Verified name by name. - # 2026-09-26, #538: 3 -> 4, rules.md#M2's second live example, - # 'Smith, John, PhD née Puig - i Soler', joining the same - # alternation for the same reason. Verified name by name. - # 2026-10-04, #601/#602: 4 -> 1; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#397) a link inside a maiden clause stays in the birth name": - _Claim(1, ('family', 'maiden', 'middle'), "4e62e18718a4", ('DEFAULT',)), - # New rule (#533/#535): one corpus name, 'Jane Doe nee Smith - # Prof. MA'. #533 already gives 'MA' up to the credential - # reading and reports the fork (`suffix`, `maiden`, - # `_ambiguities`) before #535 exists, verified against the - # parent tree d9d80492 (#296 and #399, the other two-cause - # names at 2.0.0/2.1.0, both predate this baseline and so move - # nothing extra here); #535 then reads the title chain through - # the credential, so the title standing in front of 'MA' - # moves `title` and `maiden` further. - "fix(#533/#535) a title in front of the credential the clause gives up": - _Claim(1, ('_ambiguities', 'maiden', 'suffix', 'title'), - 'cb85a2cbf9f2', None), - # New rule (#535): eight corpus names -- the title-stop - # literal list, minus the one name above whose diff from this - # baseline has an earlier cause too. `title`, `maiden`, - # `suffix` and `_ambiguities` move between them. - # 2026-10-04, #601/#602: 10 -> 7; the rules.md#M2 examples #601 retired left the corpus, and the names #601 moved left this rule's regex. - "fix(#535) a trailing title ends the maiden clause": - _Claim(7, ('_ambiguities', 'maiden', 'suffix', 'title'), "0155334273c4", None), - # 2026-09-27, #544: new, 5; gains 'Jane Doe, MS LAc', 'John - # Smith, MD MEng', 'John Smith, MEng PhD', 'John Smith, PhD - # MEng' and 1 more. - "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports": - _Claim(5, ('_ambiguities',), "bac52af569e4", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'John Smith, Ed Ma', 'John - # Smith, Ms Ma', 'John Smith, X.Y.Z. MA', 'john smith, md ma'. - # 2026-09-28, #544: 4 -> 5; gains 'John Smith, PhD Ma'. - # 2026-10-01, #554: 5 -> 8; gains 'John Smith, vd Ma', - # 'JOHN SMITH, VD MA' and 'John Smith, Jr do'. - "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count": - _Claim(8, ('_ambiguities', 'family', 'given', 'middle', 'suffix', 'title'), "ac1125df8691", ('DEFAULT',)), - # 2026-09-27, #544: new, 4; gains 'Doe, Jane PhD MEng', 'Doe, - # Jane nee Smith PhD MEng', 'Jane Doe nee Smith PhD MEng', - # 'John Smith PhD MEng'. - # 2026-09-28, #544: 4 -> 5; gains 'Smith, PhD MEng'. - "fix(#544) an unambiguous credential in front anchors the member behind it": - _Claim(5, ('_ambiguities',), "5807b060ae87", ('DEFAULT',)), - # 2026-09-27, #544: new, 1; gains 'doe, jane v phd do'. - "fix(#544) a degree in front outranks P6's attachment for a particle member": - _Claim(1, ('_ambiguities', 'family', 'suffix'), "a2b8cea490a7", ('DEFAULT',)), - # 2026-09-28, #544: new, 1; 'Smith, PhD Ma'. - "fix(#544) a credential in front anchors a member after a one-word family comma, and the pick reports": - _Claim(1, ('_ambiguities', 'given', 'middle', 'suffix'), "b2b939a7b814", ('DEFAULT',)), - # 2026-10-02, #573: new, 1; 'SMITH VD MA, JOHN'. - "fix(#573) a particle-suffix word before a family comma stays in the family": - _Claim(1, ('family', 'suffix'), "638c43d95171", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'DOE, JANE MA VD PHD', - # 'DOE, JANE PHD VD MA', 'doe, jane phd vd ma'. - "fix(#573) a particle-suffix word between credentials after a family comma stays in the run": - _Claim(3, ('_ambiguities', 'family', 'middle', 'suffix'), "03e448f4776b", ('DEFAULT',)), - # 2026-10-02, #573: new, 3; 'SMITH, VD DE LA MA', - # 'SMITH, VD MA', 'smith, de ma'. - "fix(#573) a never-given particle opening the given part takes the member behind it": - _Claim(3, ('_ambiguities',), "54ce0dda7114", ('DEFAULT',)), - "fix(#585) a decomposed initial keeps its whole first letter": - _Claim(2, ('_initials',), "c4f045134f0f", None), - "fix(#594) halfwidth katakana takes the kana license and the 间隔号": - _Claim(5, ('_ambiguities', 'family', 'given'), "86805db7d55e", None), - "fix(#594) a period-marked halfwidth katakana word is not a title": - _Claim(2, ('_ambiguities', 'given', 'title'), "8cdafcf56c45", None), - "fix(#596) a voicing mark does not make a katakana word kana-licensed": - _Claim(3, ('family', 'given'), "d822332b50f9", None), - "fix(#597) halfwidth corner brackets enclose a nickname": - _Claim(3, ('_ambiguities', 'family', 'given', 'middle', 'nickname'), "d1b9b0addff3", None), - # 2026-10-04, #601/#602: new rules. - "fix(#601) a marker in the given part after a family comma is an ordinary word": - _Claim(17, ('_ambiguities', 'family', 'given', 'maiden', 'middle', 'suffix', 'title'), "c7ed65a885ac", None), - "fix(#601) a marker in a part after a suffix comma is an ordinary word": - _Claim(2, ('maiden', 'suffix'), "59dfc0c40e36", None), - "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word": - _Claim(5, ('_ambiguities', 'family', 'given', 'maiden', 'suffix'), "1c9432e08a21", None), - # 2026-10-04, #610: +1, 'Jane Doe nee Smith VI Prof.', rules.md#M2's - # numeral example behind a title. Reach. - "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes": - _Claim(5, ('_ambiguities', 'maiden', 'suffix', 'title'), "7d2d5efa8eb5", None), - "fix(#601) the first word after the marker is the maiden name": - _Claim(1, ('_ambiguities', 'family', 'maiden', 'middle', 'suffix'), "aaf53040b071", None), - "fix(#602) a credential after the name core starts a run to the end of its part": - _Claim(4, ('_ambiguities', 'family', 'middle', 'suffix', 'title'), "e638a392a3a4", None), - "fix(#627) a lone particle with the run right behind it is the surname": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "33096566ba7f", None), - "fix(#624) of two title-particles at the head the second leads the chain": - _Claim(1, ('_ambiguities', 'family', 'given', 'title'), "c5b6da915be2", None), - "fix(#623) after a family comma the chain stops before a trailing title": - _Claim(2, ('_ambiguities', 'family', 'middle', 'title'), "bbab0baa7826", None), - "fix(#636) a part past the second comma makes no particle chain": - _Claim(1, ('suffix', 'title'), "f409b04e4c16", None), - "fix(#637) the bound join declines a trailing title after a family comma": - _Claim(1, ('given', 'title'), "feff01f1e769", None), - "fix(#640) the bound join declines a title word past the second comma": - _Claim(1, ('suffix', 'title'), "bd68517718e5", None), - "fix(#601/#602) a credential in the clause starts a run the take consumes": - _Claim(1, ('_ambiguities', 'family', 'middle', 'suffix'), "acdcc81cb17c", None), - # 2026-10-04, #604: new, 3; the rules.md#P7 boundary and the - # two rules.md#R4 case-repair examples. - "fix(#604) the Irish and Malay patronymic particles": - _Claim(3, ('family', 'middle'), "cfcfd91f9d58", None), - # 2026-10-04, #606: new, 1; 'Greve Anna', rules.md#H1's - # Accepted example for a surname-borne title. - "fix(#606) salutations and titles in other languages": - _Claim(1, ('given', 'title'), "0c78dbac8a6b", None), - # 2026-10-04, #603: new, 5; rules.md#C1's opened-part examples and the delimiter rows it moves. - "fix(#603) a credential opening the part after the comma makes it the postnominal part": - _Claim(5, ('_ambiguities', 'family', 'given', 'middle', 'suffix'), "a4ebde5b8cd7", None), - # 2026-10-04, #603: new, 7; rules.md#C2's tail-title examples and the radar names it moves. - "fix(#603) a title word in a part past the second comma is a title": - _Claim(7, ('_ambiguities', 'suffix', 'title'), "8cbad6f13c6b", None), - # 2026-10-06, #613: new, 1; rules.md#C1's 'Ortega y Gasset, Dr.' example. - "fix(#613) a connective surname before the comma counts its words": - _Claim(1, ('family', 'given', 'middle'), "a7a939e6843d", None), - # 2026-10-07, #614: new, 1; rules.md#H5's 'John van der Berg Prof.' example. - "fix(#614) a trailing title is read before the particle chain": - _Claim(1, ('family', 'title'), "4536c33ed2a2", None), - "fix(#653) a delimited clause keeps its comma": - _Claim(2, ('nickname',), "fad1fd2c06ad", None), - "fix(#653) a comma after a marker unmarks it": - _Claim(2, ('maiden', 'nickname'), "9ff9af68de6d", None), - "fix(#652) a decomposed accented abbreviation is an abbreviation": - _Claim(1, ('given', 'middle', 'title'), "df11091e2fc1", None), - }, - "expected_since_2.4.0.toml": { - "fix(#653) a delimited clause keeps its comma": - _Claim(2, ('nickname',), "fad1fd2c06ad", None), - "fix(#653) a comma after a marker unmarks it": - _Claim(2, ('maiden', 'nickname'), "9ff9af68de6d", None), - "fix(#652) a decomposed accented abbreviation is an abbreviation": - _Claim(1, ('given', 'middle', 'title'), "df11091e2fc1", None), - }, -} - - -def test_every_rule_claims_the_recorded_share_of_the_corpus() -> None: - """The guard that knows nothing, and therefore cannot be dodged. - - Five rounds of review defeated five guards, each by moving to a - rule the last fix did not cover: a span rule, then an alternation's - members, then a non-maiden role, then a rule whose narrowing lived - outside its alternation entirely. Every one of those attacks - changed what the rule claimed -- 6 to 668 names, 0 to 193, and the - comma rule from 3 roles to 6 while its regex and its 236 names - stood still -- because that is what widening a rule means WITHIN - the corpus. A widening reaching only names the corpora lack moves - nothing here; the guards above are what see those. - - Note which layer is which, because it is the opposite of what it - looks like. This is a change DETECTOR, not an enforcer: it is - inert for a brand-new rule, whose author simply records whatever - number it produces, and its own failure message invites the - remedy that defeats it -- re-record and the attack lands. The - member and vocabulary guards above are the walls, because they - judge a rule wrong at any time INCLUDING at recording time. This - catches the widenings none of them is scoped to see, and holds - them still long enough for someone to look. - """ - for ledger in _LEDGERS: - assert ledger.name in _CORPUS_CLAIMS, ( - f"{ledger.name} is a new ledger with no recorded corpus claims; " - f"add it to _CORPUS_CLAIMS (an empty mapping if it has no rules)") - recorded = _CORPUS_CLAIMS[ledger.name] - # Keyed on `issue`, so a duplicate silently collapses to the - # last rule written and the other goes unmeasured -- coverage - # by file order, which is no coverage. Nothing else asserts - # this: validate_rules only requires a non-empty string, and - # the tag-uniqueness check above covers span-bearing rules - # alone. _LATIN_ALTERNATION_SOURCES and _HONORIFIC_SOURCES key - # on issue SUBSTRINGS, so they lean on it too. - issues = [rule["issue"] for rule in _rules(ledger)] - assert len(set(issues)) == len(issues), ( - f"{ledger.name} has rules sharing an `issue`: " - f"{sorted({i for i in issues if issues.count(i) > 1})}. Every " - f"roster here keys on it, so one of them would go unmeasured") - # A rule with no name_regex narrows by `fields` alone and so - # reaches EVERY name. #451 made that shape a startup error, so - # no ledger reaching this line has one -- but _rules() does not - # validate, so counting it as the whole corpus stays correct - # rather than becoming dead. It is what such a rule claims. - actual = {rule["issue"]: _claim(rule) - for rule in _rules(ledger)} - moved = {issue: (recorded.get(issue), count) - for issue, count in actual.items() - if recorded.get(issue) != count} - assert actual == recorded, ( - f"{ledger.name}: the corpus each rule claims is not what is " - f"recorded. Moved (recorded, now): {moved}. Gone: " - f"{sorted(set(recorded) - set(actual))}. New: " - f"{sorted(set(actual) - set(recorded))}. A number that GREW " - f"means the rule claims more of the corpus than it did -- " - f"check it is not absorbing a regression, then record it") - assert set(_CORPUS_CLAIMS) == {led.name for led in _LEDGERS}, ( - f"_CORPUS_CLAIMS names ledgers that do not exist: " - f"{sorted(set(_CORPUS_CLAIMS) - {L.name for L in _LEDGERS})}") - - -#: Which rule classify() actually picks, for names several rules could -#: claim. Keyed by name; the diff shape each one produces against that -#: baseline is compare._RECORDED_DIFFS' half (#497). -#: -#: Every other guard here measures a rule ALONE: _CORPUS_CLAIMS records -#: what a regex reaches, and the gate's total counts names. Neither can -#: see which rule wins a contest, and that is the property #372 is -#: about -- seven of these names spent months on fix(comma-family), -#: whose prose describes none of them, with every guard green and the -#: gate reporting 108/0 throughout. -#: -#: The comma family is recorded because it is where that went wrong. -#: Hundreds of corpus names are reachable by two or more rules in the -#: same tier -- 301 in the 1.4 tier as of the rules-doc corpus (#414), -#: up from 42, which is why the count is described rather than pinned: -#: it moves with every corpus addition and pinning it would only ever -#: be re-recorded. The rows below are the ones whose boundaries -#: somebody has ARGUED -- in the comment beside the row, or in a -#: ledger's own comment on the rule, which #501 established counts the -#: same and why (the 2.x sections' comment) -- and pinning the argument -#: is cheaper than re-deriving it. Note -#: 'Andrews, M.D.' diffs on the SAME {given, suffix} shape as the seven -#: peels -- only the CJK lookahead separates them, so it is the row -#: that fails if that lookahead is ever dropped. -#: -#: The shapes these rows are classified with used to sit in the keys -#: here and moved to compare._RECORDED_DIFFS (#497), because a shape is -#: something only a RUN can measure: fed to classify() as an input and -#: checked by nothing, a guessed one agreed with itself forever. That -#: comment carries what this one used to say about them -- how each was -#: measured and where the claim did not hold, that a shifted shape is a -#: finding rather than a number to update, and that they are -#: default-order only, the blind spot `orders` (#468) opened. One copy -#: of each fact, since two means one of them goes quietly stale. -_CROSS_RULE_WINNERS: dict[str, dict[str, str]] = { - "expected_since_2.2.0.toml": {}, - # The 2.4 cycle carried #383/#479's three rules from 2026-09-13 - # and no contest: literal names or alternations of names, reaching - # no corpus name in common, so no diff is claimed twice. - "expected_since_2.3.0.toml": {}, - # open cycle: no rules, so no contest - "expected_since_2.4.0.toml": {}, - "expected_since_1.4.0.toml": { - # Spelled out since #508: the bare `fix(comma-family)` this row - # carried is a prefix of THREE rules in this ledger (measured - # 2026-09-05 with tomllib over the [[change]] issues), and the - # guard now refuses a pin that names anything other than - # exactly one. The - # lone-post-comma rule is the one that wins the name and the - # only one that admits its recorded {given, suffix} shape. - "Andrews, M.D.": - "fix(comma-family) lone post-comma piece routes to " - "suffix/title, not first", - # #613's PR review: the hangul rule is the one that reads the - # native-script name; the comma routing adds nothing there - "Dr 김민준, MA": "fix(#271/#272/#298)", - "田中, 太郎さん": "fix(cjk-comma-honorific-peel)", - "김, 민준씨": "fix(cjk-comma-honorific-peel)", - "김, 민준씨 (Jimmy)": "fix(cjk-comma-honorific-peel)", - "김민준, 씨": "fix(cjk-comma-honorific-peel)", - "김민준, 씨.": "fix(cjk-comma-honorific-peel)", - "선생님, J.씨": "fix(cjk-comma-honorific-peel)", - "이, J.씨": "fix(cjk-comma-honorific-peel)", - # the union rows: they move `family`, so the peel rule's fields - # exclude them and they must stay on the compound rule - "Dr 김민준씨, V.": "fix(cjk-comma-compound)", - # since #296's audit the title moves too (PhD is the postnominal) - "田中さん, PhD": "fix(cjk-comma-compound)", - "田中さん, V.": "fix(cjk-comma-compound)", - # #372's suffix-routing split. 'Bob Jones, author' moves NO - # suffix, which is what disqualifies it from the routing rule; - # 'Smith Jr.' is the Latin shape that rule is named for; the two - # glued honorifics are the majority it also legitimately takes, - # and cannot be given a rule of their own -- '김민준씨' and the - # given name '김지양' are the same string shape. - # since the all-titles repair (#296's bundle) the pre-comma name - # keeps its split here, and the rule written for that shape is - # ahead of the precomma merge in the file; 'MD, PHD' has one - # pre-comma piece, no split to keep, and stays merged - "Bob Jones, author": - "fix(comma-family) a comma followed only by titles keeps " - "the given/family split", - # since #296's audit 'PHD' is a postnominal here and the string - # is credentials only; the rule written for that shape is ahead - # of the precomma merge in the file - "MD, PHD": - "fix(#296) a credential-only comma string reads a name and " - "its postnominal", - "Smith Jr.": - "fix(suffix-routing) a two-token name ending in the suffix " - "word jr keeps it in `suffix`", - # #451's two contests with the numeral rule, whose regex reaches - # both of these names from the very end of the file. Only the - # first is decided by order: 'Carod i' diffs {family, suffix}, - # which both rules declare, so nothing but _sorted_rules' - # stability inside the name_regex tier keeps it with fix(#397). - # '田中さん II' diffs {family, given, suffix} -- 1.4 read it - # 'first 田中さん / last II', 2.x reads 'last 田中 / suffix - # さん, II' -- and `given` is the field the numeral rule's - # `fields` cannot admit at any position. Both are recorded - # because a later edit that moves either rule, or widens the - # numeral rule's `fields`, would take one silently -- exactly the - # absorption #451 exists to end. - # 2026-09-20, #397: the rule kept the name and was RETITLED in - # place -- its standing moved from NOT WANTED and unresolved - # to the decided boundary of a landed rule -- so this pin - # follows the new issue string. 'Carod i' itself is unchanged - # by that landing, measured against the parent 46651750. - "Carod i": - "fix(#397) accepted: a two-word Catalan link has no name " - "word to its right and stays the generation", - # #289/#516's one. '田中 太郎, MA' is this arc's own case row - # (radar tier): two whitespace tokens before the comma make it - # two NAME words by rules.md#C1's count, so the suffix reading - # is taken and the Japanese surname split then runs over the - # pre-comma part. The native-script order rule WINS it -- what - # moves `family` and `given` here is the family-first ordering - # of '田中 太郎', which the comma-compound rule describes only - # half of -- and it wins by file order alone, the two rules' - # `fields` overlapping without nesting. - "田中 太郎, MA": - "fix(#271/#272/#298) native-script CJK: family-first " - "order, hangul segmentation, the kana license and the dots", - # #289's one contract-tier contest here. The lone-post-comma - # routing rule WINS 'Smith, MA', and it is the right winner at - # THIS baseline: what the diff shows against v1 is a lone piece - # after the comma routing to `suffix` instead of to `first`, - # which is that rule's own sentence. The caps lean is why the - # sentence became true for this name; it is not what the - # baseline-relative diff is about. - "Smith, MA": - "fix(comma-family) lone post-comma piece routes to " - "suffix/title, not first", - "田中さん II": - "fix(cjk-glued-honorific-peel) glued honorific peels into " - "suffix", - # #484's three `_initials` contests with a literal rule on one - # side. fix(initials-per-word)'s particle-chain rule reaches all - # three, declares the same lone `_initials` field, and loses - # only because both literal rules are written ahead of it -- - # file order, which _sorted_rules leaves untouched now that - # every rule carries a name_regex. Each of the three really does - # diff on initials, so narrowing or deleting a literal rule - # would hand its name to the per-word rule rather than surface - # it: the handover this row exists to catch. - "de los Santos": - "fix(#360) los joined the particles, so it no longer initials", - "van Berg Jan de": - "fix(#385/#402) an all-particle name part initials its words (R2)", - "van ma van": - "fix(#385/#402) an all-particle name part initials its words (R2)", - # The equal-`fields` contest #528 opened: both rules carry - # `fields = ["_initials"]`, so neither is narrower and - # `precedes_narrower` has nothing to declare -- classify() - # hands the name to whichever is written first, and this is - # where that answer is pinned. The connective rule's regex - # reaches both names through their lowercase ' e ' (measured - # 2026-09-13) and describes the 2.0.0 per-word GROUPING change, - # which is not what moved: what moved is the reading of the - # letter, and #528's rule says so. The other two of #528's four - # are not contested, each kept out by a different half of that - # regex (measured 2026-09-13): 'jones, john e' ends on its 'e', - # and the alternation wants whitespace AFTER the letter, so a - # letter at end of string never matches; 'JUAN Y GARCIA' has - # the whitespace but a capital 'Y', and the alternation is - # CASE-SENSITIVE, carrying only the lowercase letter. Neither - # has a row. - "john e smith": - "fix(#528) the facade's initials follow the parse's " - "connective tags", - "john e jones": - "fix(#528) the facade's initials follow the parse's " - "connective tags", - # The eleven equal-`fields` contests #461 opened on - # 2026-09-20, the same shape as #528's pair above and against - # the same rule: both carry `fields = ["_initials"]`, so - # neither is narrower, `precedes_narrower` has nothing to - # declare, and classify() hands the name to whichever is - # written first. The #461 rule is written first and WINS each - # of them, because 1.4.0 rendered every one of these with the - # granularity the tree does -- the per-word grouping moved - # nothing on them, the letter COUNT did (measured 2026-09-20 - # from the 1.4.0 wheel and from the parent 46651750: all - # eleven agreed with 1.4.0 before this bundle). 'Carod y de - # Rovira i' loses to the #461 rule on the particle-chain rule - # as well, and one row pins the name once. - # - # The other four names the #461 rule explains are NOT - # contested, each kept out by a different half of the - # connective alternation: 'Juan de y' and 'johnny y' end on - # their letter and the alternation wants whitespace after it; - # 'Smith, John e, III, Jr' has a comma there instead; and - # 'Fritz Freiherr und von Bar' carries 'und', which is in no - # alternation. None has a row. - "Carod y de Rovira i": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "Garcia y Lopez": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "John e Smith": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "John e Smith III": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "John e Smith, III": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "John y Jane": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "Jose e Maria": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "Juan y Garcia": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "Lt.Gov. juan e garcia": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "john e jones, III": - "fix(#461) a connective holding its part alone contributes " - "an initial", - "juan y garcia": - "fix(#461) a connective holding its part alone contributes " - "an initial", - # the glued/spaced boundary. 'Andersonさん' and '김민준씨' left - # suffix-routing for a rule that names them; '김민준 씨.' is - # spaced and stays on the spaced rule, which #372 taught to - # tolerate the trailing period. - # - # '김지양' is why the glued rule copies GLUED_HONORIFICS and not - # SUFFIX_WORDS: 양 is absent from the glued set, so no rule - # NAMED for honorifics can claim a suffix diff on a given name - # that merely ends in one. The fields-only fix(suffix-routing) - # still would -- measured -- which was unchanged by #372 and was - # the residual cost of having a last-resort tier at all. Being - # absorbed by the catch-all is recoverable; being labelled - # 'recognized honorific' by a specific rule is not. #451 deleted - # that catch-all, so there is no last-resort tier left in any - # ledger and the residual cost went with it: all four rules that - # replaced it carry a name_regex. - "Andersonさん": "fix(cjk-glued-honorific-peel)", - "김민준씨": "fix(cjk-glued-honorific-peel)", - "김민준 씨.": "fix(cjk-honorific-suffix)", - # '.,' moved off `fix(comma-family) lone post-comma piece - # routes to suffix/title, not first`, whose Latin-range comma - # regex reaches it, onto the A2 rule that describes it (#451). - # Recorded because only file order separates the two rules -- - # they are in the same tier and both reach the name -- and a - # later edit that moves either one silently hands it back. - ".,": "fix(A2) content-free input names nobody, so every role empties", - # The one exception the cjk-comma-compound rule's `middle` - # argument turns on, added by #452's review. That comment says - # ten of the eleven names it explains have a single-token - # post-comma tail and this is the eleventh, escaping `middle` - # only because v1's fix_phd merges the split credential before - # parsing. Pinned because the count itself is not: _claim - # measures regex REACH (23 here), which does not move when a - # name inside it changes hands -- so if this name ever went to - # another rule, the argument would go false with nothing - # saying so. Shape measured against the 1.4.0 tag: v1 reads - # first '田中さん', suffix 'Ph. D.'; the tree reads family - # '田中', suffix 'さん, Ph. D.'. - "田中さん, Ph. D.": - "fix(cjk-comma-compound) comma routing compounds with the CJK order flip", - # The jr rule's surplus, added by the #453 review. Its regex - # reaches these three and does not explain them; `fields` is - # what makes it ineligible -- none of the shapes below is a - # subset of its {family, suffix} -- and file order is only the - # second line. Recorded because handing one over takes TWO - # edits (widen `fields`, move the rule up) and nothing else - # here would report the pair: reach is per-rule, and the jr - # rule's own _CORPUS_CLAIMS count of 5 does not move when a - # name inside it changes hands. Shapes measured against the - # 1.4.0 wheel, not guessed. 'Doe,, Jr.' is the fourth name the - # regex reaches and has no row: it does not diff at this - # baseline, so there is no winner to pin. - "Kim, Jr.": - "fix(#296) a lone post-comma credential is a suffix", - "Smith, Jr.": - "fix(#296) a lone post-comma credential is a suffix", - "김민준씨 Jr.": - "fix(cjk-glued-honorific-peel) glued honorific peels into suffix", - # #484's per-word rules. Measured over the corpus, exactly ONE - # name is reached by two of them AND diffs on `_initials`: - # 'de la Vega y Santos Juan' carries both a connective and a - # particle chain, and the connective rule wins it on file - # order alone -- the two declare the same single field, so - # `fields` cannot separate them and only position does. Five - # more names are reached by two ('Jane van der Berg née y - # Jones', 'Maria Luisa y de la Cruz', 'Sir abdul van der - # Berg', 'abdul Ph. D. Smith Berg', 'van der Berg, abdul née - # Jones') and none of them has an `_initials` diff to pin: - # three move ROLES, so the pseudo-field never enters, and two - # do not diff at all. - "de la Vega y Santos Juan": - "fix(initials-per-word) a connective run", - # Not contested today -- the bound-given rule is the only one - # of the four whose regex reaches these three -- and recorded - # anyway, because the bound rule sits BEHIND the connective - # one in file order and the two vocabularies are one widened - # alternation apart. A connective rule grown to reach 'Abu' - # or a particle rule grown to reach a trailing 'van' takes - # these names silently: reach is per-rule and the gate's total - # is per-corpus, so nothing else here would say so. - "Abu Bakr Al Baghdadi, MD": - "fix(initials-per-word) a bound-given run", - "abu bakr al baghdadi": - "fix(initials-per-word) a bound-given run", - "Berg, abdul van": "fix(initials-per-word) a bound-given run", - # #498's fourteen, adjudicated 2026-09-05. Every one is a - # contest the static predicates are silent on by construction: - # the two rules' `fields` are EQUAL or merely INTERSECT, so - # neither is narrower -- `order_contests` asks for nesting and - # finds none in either arrangement, and `precedes_narrower` - # has no narrower rule to name. What a row here adds is the - # ARGUMENT, which rule should explain the name and why. - # Grouped by (winner, loser) pair, so a reader editing one - # rule sees its whole cost. - # - # The "moves N of the 352" figures below were measured - # 2026-09-05, not reasoned. RECOMPUTE: drive compare.main() at - # --baseline 1.4.0 with classify() spied, move the loser above - # the winner in a COPY of the run's own sorted rule list, and - # re-classify the run's own 352 classified calls against that - # copy; N is how many change label. Nothing on disk moves. The - # `fields` readings below are the same recipe with one role - # dropped from the winner instead of a rule moved. - # - # PAIR A, six names, EQUAL `fields`. `fix(#325)` and - # `fix(comma-family) lone post-comma piece routes to - # suffix/title, not first` declare the same roles, so `fields` - # separates them not at all and file order is the whole - # decision. The distinguishing clause is a property of the - # STRING and the #325 rule names it: a credential run after a - # one-word family comma, followed by ANOTHER suffix, collapsing - # whole into `suffix` -- measured at the 1.4.0 wheel, whatever - # the wheel held in `title` or `given` empties into it - # ('Smith, Ph. D. Jr.': title 'Jr.' -> '', suffix 'Ph. D.' -> - # 'Ph. D. Jr.'). The lone-post-comma rule's prose describes a - # SINGLE post-comma piece, which none of the six is. Five hold - # the split credential the winner is named for ('Ph. D.', - # 'Ph.D.'); 'Smith, PhD Jr.' is a REACH rather than a fit -- - # 'PhD' is not split -- and the winning rule's own comment - # names it separately as one "which the regex reaches too". A - # documented reach whose alternative describes nothing is not - # the regex accident #372 refuses. Measured 2026-09-05, that - # name reads title 'PhD' -> '', given 'Jr.' -> '', suffix '' -> - # 'PhD Jr.', and TWO halves of the clause that names it in the - # winning rule's own comment disagree with that: it renders - # the new suffix 'PhD, Jr.', with a comma no run produces, - # and it says "title 'PhD' at every baseline", where the 1.4.0 - # title MOVES, 'PhD' -> '', the whole run going to `suffix`. - # The ledger's prose was a separate concern and was not - # touched by #498; this row is where the discrepancy went on - # the record. HALF of it is repaired since 2026-09-06: #436/ - # #437 had to touch that comment anyway, and corrected the - # 'PhD, Jr.' half to 'PhD Jr.' in all three ledgers carrying - # it, the line naming the date and saying it had been stale - # since #429 made the run one entry. The "title 'PhD' at every - # baseline" half stands as written and is still live -- - # re-measured 2026-09-06, the tree reads 'Smith, PhD Jr.' as - # family 'Smith', suffix 'PhD Jr.', title '' -- so this row - # goes on carrying that half. - # 'Smith, Ph. D. Jr.' is contract tier; the other five are - # radar and are pinned on the same terms, the argument - # being the row's subject and not the tier. Moving the loser - # ahead hands all six to it and moves 7 of the 352 - # classifications -- the six, plus 'Smith, Jr., PhD' as - # collateral. - "Smith, Ph. D. III": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - "Smith, Ph. D. Jr.": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - "Smith, Ph. D. Jr. MD": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - "Smith, Ph. D. MD": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - "Smith, Ph.D. Jr.": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - "Smith, PhD Jr.": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - # #544, 2026-09-27: 'Smith, PhD MEng' is 'Smith, PhD Jr.' with - # an anchored member in the second slot -- a credential run - # after a one-word family comma that collapses whole into - # `suffix` (1.4.0 title 'PhD', given 'MEng'), reached by the - # same regex and won by the same file order, so the argument - # above carries over whole. - "Smith, PhD MEng": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - # 2026-09-28: 'Smith, PhD Ma' likewise -- 1.4.0 title 'PhD', - # first 'Ma', collapsing whole into `suffix` -- the same - # contest, won by the same file order on the same argument. - "Smith, PhD Ma": - "fix(#325) a split credential followed by another suffix " - "after a one-word family comma reads as suffixes", - # PAIR B, three names, OVERLAPPING `fields`. This is the pair - # #498 was filed on: `fix(#271/#272/#298)` declares - # {family, given, middle} and `fix(cjk-delimited-nickname)` - # {family, given, nickname}, intersecting in {family, given} - # with neither containing the other. All three names diff - # exactly {family, given} -- the 2.1 family-first order flip on - # a nakaguro-delimited native-script string, given - # 'マイケル・ジャクソン' -> 'マイケル' with family '' -> - # 'ジャクソン' -- and `nickname` is EMPTY on both sides of all - # three, so the nickname rule's prose, whose subject is a name - # doing two things at once, describes none of this diff while - # the order rule describes the whole of it and names one of the - # three among its own examples. All three are contract tier. - # Swapping the two rules reattributes all three and nothing - # else (3 of the 352, no collateral), and the pair nests in - # neither arrangement, so `order_contests` has nothing to - # report about it and `precedes_narrower` has no narrower rule - # to name -- the blind spot this row's argument fills. - "マイケル・ジャクソン": - "fix(#271/#272/#298) native-script CJK: family-first " - "order, hangul segmentation, the kana license and the dots", - "威廉・莎士比亚": - "fix(#271/#272/#298) native-script CJK: family-first " - "order, hangul segmentation, the kana license and the dots", - "高橋・一郎": - "fix(#271/#272/#298) native-script CJK: family-first " - "order, hangul segmentation, the kana license and the dots", - # PAIR C and PAIR D, two names, OVERLAPPING `fields`. Both diff - # {family, given} alone, the order flip and nothing else: - # `title` and `suffix` are byte-identical on both sides of both - # names, so no comma routing moved a role, and '田中さん, Dr.' - # keeps its glued さん whole (family '' -> '田中さん' entire, - # `suffix` empty on both sides), so nothing was peeled. So - # `fix(cjk-comma-compound)` and `fix(cjk-glued-honorific-peel)` - # -- which reach the strings through the comma and the glued - # さん respectively -- each describe a mechanism that did not - # fire, and the order rule describes what did; the peel rule's - # stated scope, a name with "no comma anywhere", excludes the - # string outright. 'Dr 김민준, Jr.' contests the comma rule - # alone, and its whole diff is the hangul segmentation the - # order rule names: given '김민준' -> '민준', family '' -> '김'. - # '田中さん, Dr.' contests BOTH, and is one name carrying two - # unowned pairs. The comma rule's own disposition table -- the - # four `shape -> rule` rows in `fix(cjk-comma-compound)`'s - # comment -- does not concede this shape to the winner BY - # NAME: its one row naming '田中さん, Dr.' hands the name to - # `fix(#296)` under {middle, suffix}, a different shape - # from the {family, given} measured here, and the row that - # does concede to the winner names no name -- it is - # `{given, middle, family} all 23`, which carries `middle`. - # The measurement is what settles the pair: the winner - # describes the whole of a {family, given} diff, and the comma - # rule's own `title` and `suffix` are byte-identical on both - # sides, so its comma routing moved nothing. Moving the - # comma rule ahead moves 9 of the 352 and the peel rule ahead - # 15, the widest of the seven; and for the peel pair a `fields` - # narrowing does NOT hand the name over, since - # `fix(cjk-comma-compound)` takes it instead, so a reorder is - # the only single edit that does. - "Dr 김민준, Jr.": - "fix(#271/#272/#298) native-script CJK: family-first " - "order, hangul segmentation, the kana license and the dots", - "田中さん, Dr.": - "fix(#271/#272/#298) native-script CJK: family-first " - "order, hangul segmentation, the kana license and the dots", - # PAIR E, one name, OVERLAPPING `fields`. '田中さん, 様.' moves - # {family, given, suffix} through TWO mechanisms at once, both - # legible in the measured values: the glued さん peels off - # (family '田中さん' -> '田中') while the post-comma 様. leaves - # `given` for `suffix`, the two arriving there together as - # suffix '' -> 'さん, 様.'. The winner is named for the comma - # half. The peel half belongs to the name's OTHER admitter, - # `fix(cjk-glued-honorific-peel)`, which is nested and DECLARED - # over by a `precedes_narrower` block, so that half of the - # contest is justified where it stands and only the - # honorific-suffix pair is owed a row. That loser disclaims the - # glued diffs outright and is written for a SPACED honorific on - # a name that also takes an order flip -- and measured, no - # `middle` moves and no flip occurs, `given` going to empty - # rather than gaining a name. The honorific-suffix rule's - # `fields` and the comma rule's intersect without nesting, so - # nothing else can see it. Moving the loser ahead moves 2 of - # the 352; a `fields` narrowing does not buy this pair either, - # since dropping any of the three shared roles sends the name - # to the glued rule instead. (The 2.0.0 section below pins this - # same string to the GLUED rule: a different ledger, where that - # pair nests narrow-first.) - "田中さん, 様.": - "fix(cjk-comma-compound) comma routing compounds with the " - "CJK order flip", - # PAIR F, one name, EQUAL `fields`. The all-titles rule and - # fix(comma-precomma-family) both declare {given, family} and - # both reach the string; the - # all-titles rule is the one whose prose is true of it, to the - # value -- measured, given 'Bob Jones' -> 'Bob', family '' -> - # 'Jones', with `title` 'compositeur' unmoved on both sides, - # which is what that rule says happens -- where the - # precomma-merge rule's prose says the pre-comma RUN reads as - # family, and the run does not move whole. That rule's own - # comment already concedes the sibling 'Bob Jones, author' to - # the winner on this reasoning, and the winner's regex reaches - # 'compositeur' by the same literal. Moving the loser ahead - # moves 3 of the 352 -- this name, 'Bob Jones, author' and - # 'John Smith, Mr.'. The pin is the winner's FULL issue string, - # and since #508 that is what the guard compares EXACTLY: three - # rules in this ledger begin `fix(comma-family)` (measured - # 2026-09-05 with tomllib over the ledger's `[[change]]` - # issues), and this winner's issue is itself a strict PREFIX of - # `... keeps the given/family split, the C1 example`, so no - # prefix comparison could tell those two apart -- _pinned_rule - # resolves a pin that IS an issue string to that rule and to no - # other. The C1 rule cannot reach this string in any case: its - # name_regex is (?i)^john\s+smith,\s*mr\.?(\s+jr\.?)?$ whole, - # anchored at both ends on a literal this name shares no prefix - # with (measured 2026-09-05). - "Bob Jones, compositeur": - "fix(comma-family) a comma followed only by titles keeps " - "the given/family split", - # PAIR G, one name, OVERLAPPING `fields`. 'MD, DO, DDS' holds - # no name at all -- every piece is a credential -- and the - # dropped-prenominal rule names the string and states its diff - # verbatim, title 'DO' -> given 'DO', which is the whole of - # what moved: `family` 'MD' and `suffix` 'DDS' are identical on - # both sides. The lone-post-comma rule's prose is about a - # one-word NAME followed by a comma and a lone credential; this - # string has neither, and the rule routes a piece OUT of - # `given` where this name routes one IN, so it is true of no - # part of the diff. Neither single edit that would hand the - # name over is free. A reorder ahead of the winner necessarily - # passes `fix(#325)` as well and drags all six of PAIR A with - # it: 8 of the 352 in all, this name plus PAIR A's six plus - # PAIR A's own collateral 'Smith, Jr., PhD'. The `fields` - # narrowing is smaller and NOT surgical -- dropping `given` or - # `title` from the winner moves 2, handing this name over and - # sending 'Do Quang Minh', a radar-tier corpus_issues.jsonl - # name the winner is the sole explainer of, to no rule at all, - # so radar unclassified goes 0 -> 1. The pin costs neither. - "MD, DO, DDS": - "fix(#296) a dropped prenominal takes the name position it " - "occupies", - }, - # The two 2.x ledgers had NO section here until #452, and the - # coverage assertion below was `<=`, so their absence read as "no - # contest to pin" rather than "nobody looked". #452 gave each two - # rows -- 'Nguyen, Van' and 'Jane née and Jones Smith', the - # handovers its narrowings caused -- and #497 deleted all four - # rather than correct them. Each recorded a diff shape a run - # contradicts (compare._RECORDED_DIFFS' provenance note has the - # measurements and the recompute, in one copy, including why the one - # correctable shape was not corrected), and at the shape each name - # really produces, exactly one rule admits it. That is the defect - # under the wrong shapes rather than beside them: a row pinning a - # race with one runner is never exercised as a contest, so its shape - # only ever had to agree with itself. Those four are not coming - # back, and the six rows below are not them: every one is a shape a - # run measured, carried in from compare._WATCHED_DIFFS where a run - # had been re-measuring it since 2026-09-03. - # - # WHAT #501 DECIDED, 2026-09-05. The sections stood empty on a - # position, not an omission: 5 of the 247 diffs at 2.0.0 and 1 of - # the 155 at 2.1.0 were measured to move a shape two or more rules - # admit, file order picking the winner ('MD, PHD' is the one both - # baselines have), and the position was that a row is owed when - # somebody ARGUES a boundary, not when one is merely measured. - # That 5 is the PRE-NARROWING figure (measured 2026-09-03, - # re-measured 2026-09-05 before the narrowing landed). The same - # recipe over THIS tree gives 3 at 2.0.0 and the same 1 at 2.1.0 - # (measured 2026-09-05). 2.1.0 does not move because the narrowing - # reaches no name it counts; 2.0.0 falls by two because the - # narrowing DISSOLVED two of the six contests -- '김민준 박사님' and - # '선생님' have one admitter each now, which the paragraph below - # says as well. Both figures are the same recipe on different - # trees, not two recipes. RECOMPUTE (either one): drive - # compare.main() at the baseline, capture its `diffing` by - # wrapping dormant_rules, and count the names for which more than - # one rule satisfies compare._entry_matches at the measured shape. - # - # What the position got wrong was WHERE an argument counts. Three - # of the six boundaries were already argued -- in the 2.0.0 - # ledger's own comment on fix(#308/#312/#319/#320), which named - # '田中さん 様.', '田中さん, 様.' and '김민준 박사님' one by one and - # said for each why the glued rule should win it. "Argued in THIS - # file" is the wrong criterion, and the reason is not tidiness: a - # ledger comment's claim about which rule wins a name is checked by - # nothing at all, and this roster is the only thing in the tree - # that CAN check it. The proof is the third of those three. Its - # argument was FALSE -- '김민준 박사님' has no glued honorific, - # matching that rule on the 님 interior to a spaced 박사님 -- and it - # sat in the ledger for a month with every guard green, because no - # row here made classify() answer for it. The narrowing that closed - # it is the 2026-09-05 narrowing of the glued-peel `name_regex` - # (both ledgers); the winner pinned for that name below is the - # SPACED rule, which only wins after it. - # - # So the criterion is ARGUED SOMEWHERE. A boundary argued in a - # ledger comment earns a row here exactly as one argued in this - # file does, and the row is what turns the argument into something - # a run can contradict. - # - # Nor is "only one rule admits it" grounds on its own to delete a - # row: 13 of the 45 at 1.4.0 are in that position too, and two of - # the six below joined them with the narrowing -- '김민준 박사님' - # and '선생님' have one admitter each now, the glued rule's regex - # having stopped reaching them. None of #498's fourteen joins the - # 13: every one has two or more admitters at its recorded shape by - # the definition of the sweep that found it, so the numerator held - # while the denominator grew (measured 2026-09-05). SIX of the 13 - # say so where they sit -- the jr rule's surplus and the - # bound-given trio, three rows each -- and the other seven do not, - # so take the count from the recompute rather than from the - # comments. RECOMPUTE (measured 2026-09-05): count the rows of - # compare._RECORDED_DIFFS['expected_since_1.4.0.toml'] for which - # exactly one rule of compare._sorted_rules over this ledger - # satisfies compare._entry_matches at the recorded shape. They - # stay because the shapes they pin are shapes runs actually make, - # so widening a `fields` or moving a rule hands the name over and - # this test says so -- and 11 of the 13 route to a DIFFERENT rule - # under some other shape, so the assertion is doing work on them. - # mechanisms.md#RECORDED-ROSTERS carries that measurement and the - # reason counting admitters is the wrong instrument for the - # question. The four deleted rows could not do that work at any - # edit. - "expected_since_2.0.0.toml": { - # The '様.' pair, and the one boundary of the six that is a - # live two-runner contest decided by nothing but file order. - # Both names move {family, given, suffix} through TWO - # mechanisms at once: the glued さん peels off 田中さん (#308, - # and #320 is what stops the trailing ASCII period vetoing it) - # while the spaced 様 routes to `suffix`. One rule per half, - # and the two sit in the same name_regex tier with fields that - # DO nest: the glued rule's {given, family, suffix} is a - # STRICT SUBSET of the spaced rule's {given, middle, family, - # suffix}, so the glued rule is the narrower of the two -- and - # it is the one written first. The pair is therefore nested - # NARROW-FIRST, #382's declaration-free default, and no - # `precedes_narrower` block is owed for the plain reason that - # the narrower rule already wins: that block sits on the - # earlier rule only where the earlier rule is the wider one. - # Measured 2026-09-05 over the corpus*.jsonl union, - # order_contests reports nothing for this ledger as it stands - # and reports this pair -- both names -- only with the two - # rules swapped, where undeclared_contests refuses the ledger - # too. The check can see the arrangement that would be wrong - # and not the one shipped. - # - # The prose test lands on the glued rule and the file order - # already does: #312/#319/#320 are the glued rule's clauses, - # they name both strings outright, and no clause of the spaced - # rule mentions either. It is a PARTIAL label in #382's - # accepted sense -- the glued rule describes the peel and not - # the routing -- and what makes the pair unusual is that - # narrow-first's JUSTIFICATION does not apply to it. That - # justification is that the narrower rule describes the name - # more precisely; here both mechanisms fire at once and each - # rule describes one half, so neither describes the whole and - # the label is partial whichever rule wins. The default picks - # the right rule for a reason that is not available, and this - # row is where an unjustified-but-correct default is pinned. - # - # What the pin buys, measured 2026-09-05: swapping the two - # rules in memory hands BOTH names to the spaced rule and - # touches nothing else -- 2 of the 247 classifications move, - # `unexplained` stays 0, so the gate's summary line does not - # move, and neither rule's _CORPUS_CLAIMS reach can see a - # reorder at all. - "田中さん 様.": - "fix(#308/#312/#319/#320) glued CJK honorific peeled off " - "the name into suffix", - "田中さん, 様.": - "fix(#308/#312/#319/#320) glued CJK honorific peeled off " - "the name into suffix", - # The name the glued rule should never have had. '김민준 박사님' - # is SPACED: 박사님 stands as its own word and nothing is peeled - # off the name at all, which rules.md#W2's second sentence - # states and its witness line for this very string shows - # (suffix="박사님", the honorific whole). The glued rule reached - # it on the 님 interior to 박사님 and, sitting first, took it -- - # a false label on a contract-tier name, green everywhere. The - # 2026-09-05 narrowing (`(? str: - """The one rule a _CROSS_RULE_WINNERS pin names, or a refusal. - - A pin is a rule's issue string, or a prefix of exactly one. Exact - wins where a rule's issue IS the pin, because a full issue string - can be a strict prefix of a sibling's -- measured 2026-09-05 in - expected_since_1.4.0.toml, `fix(comma-family) a comma followed only - by titles keeps the given/family split` is a prefix of - `... split, the C1 example` -- and a prefix comparison alone could - not tell those two apart. The exact branch cannot itself be - ambiguous: a ledger holding two rules with the same `issue` string - is already refused by - test_every_rule_claims_the_recorded_share_of_the_corpus, which is - where that uniqueness is stated. A prefix that reaches no rule, or - more than one, is refused here rather than left for classify() to - answer: a pin that names a FAMILY of rules pins nothing, since the - roster's whole job is to say which rule wins (mechanisms.md - #CROSS-RULE-OUTCOME-PINS), and the #498 completeness check reads a - _RECORDED_DIFFS key as "a winner is pinned" on the strength of it. - - `issues` are the ledger's [[change]] issue strings -- file order - from `_rules`, classify()'s sorted order from the winner guard, - and the answer is the same either way, being exact-or-unique. - """ - # An empty pin is a prefix of every rule, so on a one-rule ledger - # -- expected_since_2.2.0.toml today -- it would resolve uniquely - # and pin nothing. Refused before either branch sees it. - assert pin, f"{where}: an empty pin names every rule" - if pin in issues: - return pin - hits = [issue for issue in issues if issue.startswith(pin)] - assert hits, ( - f"{where}: the pin {pin!r} names 0 rules. It reaches no rule in " - f"this ledger, so the rule it was written for has been reworded " - f"or removed. Re-pin the row to the current issue string of the " - f"rule that wins this name, or drop the row -- together with " - f"its shape in compare._RECORDED_DIFFS -- if the contest is " - f"gone with it.") - assert len(hits) == 1, ( - f"{where}: the pin {pin!r} names {len(hits)} rules: {hits}. A " - f"pin must name exactly one rule -- spell it out to the issue " - f"string of the rule that wins the name. Three rules in " - f"expected_since_1.4.0.toml begin `fix(comma-family)` (measured " - f"2026-09-05), which is the shape this refuses.") - return hits[0] - - -def test_every_pinned_winner_names_exactly_one_rule() -> None: - """A pin resolves to one rule before any shape is asked (#508). - - test_the_recorded_rule_still_wins_each_contested_name resolves the - pin through the same _pinned_rule, before it asks classify() - anything, so it would refuse an ambiguous pin identically -- but - only after loading compare and walking as far as that row. This - guard refuses it from the roster and the ledger's issue strings - alone, with compare never loaded, at pytest speed. - """ - by_name = {led.name: led for led in _LEDGERS} - resolved = 0 - for ledger_name, winners in _CROSS_RULE_WINNERS.items(): - issues = [r["issue"] for r in _rules(by_name[ledger_name])] - for name, pin in winners.items(): - _pinned_rule(pin, issues, f"{ledger_name}: {name!r}") - resolved += 1 - assert resolved, "no pin was resolved, so this guard is vacuous" - - -def test_a_pin_resolves_exactly_or_by_unique_prefix_or_is_refused() -> None: - """The whole of _pinned_rule's table, on hand-built issues. - - Both halves: the two that resolve -- an exact issue string wins - over the sibling it is a prefix of, and a family prefix reaching - exactly one rule resolves to that rule -- and the two that refuse, - a prefix reaching several and a prefix reaching none. Matching is - literal, so case is part of the pin and a miscased pin reaches - nothing rather than the rule it was aimed at. - """ - issues = [ - "fix(x) the first", - "fix(x) the first, extended", - "fix(x) the second", - "fix(y) alone", - ] - # a family prefix reaching one rule resolves to it - assert _pinned_rule("fix(y)", issues, "t") == "fix(y) alone" - # an exact issue that is also a prefix of a sibling resolves exactly - assert _pinned_rule("fix(x) the first", issues, "t") == "fix(x) the first" - # a prefix reaching several is refused, naming them - with pytest.raises(AssertionError) as several: - _pinned_rule("fix(x)", issues, "t") - assert "names 3 rules" in str(several.value) - assert "fix(x) the second" in str(several.value) - # a prefix reaching none is refused - with pytest.raises(AssertionError) as none: - _pinned_rule("fix(z)", issues, "t") - assert "names 0 rules" in str(none.value) - # a pin quotes the issue string literally, so case is not folded - with pytest.raises(AssertionError, match="names 0 rule"): - _pinned_rule("FIX(y)", issues, "t") - - -def test_the_winner_guard_refuses_a_longer_sibling_of_the_pin( - monkeypatch: pytest.MonkeyPatch) -> None: - """The regression #508 was opened for, run rather than described. - - 'Bob Jones, compositeur' is pinned to the all-titles rule, whose - full issue string is a strict PREFIX of the C1 rule's. Under the - `startswith` comparison the winner guard used before #508, a run in - which the C1 rule answered for that name satisfied the pin, and the - handover passed unseen; here classify() is made to answer exactly - that, and the guard must refuse it. Measured 2026-09-05: with `==` - put back to `startswith`, this test does not raise and so fails. - """ - compare = load_tool("compare") - ledger = {led.name: led for led in _LEDGERS}["expected_since_1.4.0.toml"] - sibling = ("fix(comma-family) a comma followed only by titles keeps " - "the given/family split, the C1 example") - assert sibling in [rule["issue"] for rule in _rules(ledger)], ( - f"{sibling!r} is no longer a rule in expected_since_1.4.0.toml, " - f"so this test impersonates an answer no run could produce. " - f"Re-pick a rule whose issue string extends another rule's") - - def _answer_the_longer_sibling( - name: str, diff_fields: set[str], - rules: list[dict[str, object]], - exclusions: list[dict[str, object]] | None = None, - order: str | None = None) -> str | None: - got: str | None = compare_classify( - name, diff_fields, rules, exclusions, order) - if name == "Bob Jones, compositeur" and got is not None: - return got + ", the C1 example" - return got - - compare_classify = compare.classify - monkeypatch.setattr(compare, "classify", _answer_the_longer_sibling) - # load_tool builds a FRESH module every call, so the guard under - # test would otherwise load an unpatched compare; hand it this one. - # It loads no other tool, so answering every stem with it is safe. - monkeypatch.setitem(globals(), "load_tool", lambda stem: compare) - with pytest.raises(AssertionError, match="is now explained by"): - test_the_recorded_rule_still_wins_each_contested_name() - - -def test_the_recorded_rule_still_wins_each_contested_name() -> None: - """Who explains what, which nothing else here asks. - - Measured when this was written: narrowing fix(cjk-comma-compound)'s - script class sent three of these names to fix(suffix-routing) -- a - fields-only catch-all whose prose was about two-token Latin names -- - and the gate still reported 108 intentional / 0 unexplained. Reach - is per-rule, the total is per-corpus; neither notices a name - changing hands. #451 has since deleted that catch-all, so the same - narrowing would now send those three to UNEXPLAINED and the gate - would say so -- but only because a fields-only rule happened to be - what absorbed them. File order inside the name_regex tier hides a - handover just as completely, which is what the #451 rows below pin. - - A failure here is not necessarily a regression: it can equally mean - a rule was narrowed correctly and its names found a better home. It - means someone has to look, which is the point. - - Since #508 the pin is resolved to one rule and compared exactly, - not by prefix: `startswith` was satisfied by any rule whose issue - extended the pin, and for three rows at 1.4.0 (measured - 2026-09-05) that was more than one rule, so the row pinned a family - where it claimed to pin a winner. - """ - compare = load_tool("compare") - by_name = {led.name: led for led in _LEDGERS} - checked = 0 - for ledger_name, winners in _CROSS_RULE_WINNERS.items(): - ledger = by_name[ledger_name] - rules = compare._sorted_rules(_rules(ledger)) - issues = [r["issue"] for r in rules] - never = _exclusions(ledger) - shapes = compare._RECORDED_DIFFS[ledger_name] - for name, expected in winners.items(): - # explicit, because a bare KeyError here names a dict and a - # string and nothing else. The readable half-recorded-pin - # message lives in test_every_pinned_winner_has_a_recorded_shape, - # which a `-k` selection or a first failure may not have run. - assert name in shapes, ( - f"{ledger_name}: {name!r} is pinned a winner in " - f"_CROSS_RULE_WINNERS with no shape in " - f"compare._RECORDED_DIFFS, so there is nothing to ask " - f"classify() about. Record the shape a run measures for " - f"it, or drop the pin -- " - f"test_every_pinned_winner_has_a_recorded_shape is the " - f"guard that states this as its own subject") - # Resolved before classify() is asked, so a pin naming a - # family of rules fails as a PIN defect with the family - # listed (test_every_pinned_winner_names_exactly_one_rule - # states that as its own subject); and compared EXACTLY, - # because a full issue string can be a strict prefix of a - # sibling's and `startswith` could not tell them apart - # (#508; the PAIR F rows above are the case). - pinned = _pinned_rule(expected, issues, f"{ledger_name}: {name!r}") - fields = shapes[name] - got = compare.classify(name, set(fields), rules, never) - assert got == pinned, ( - f"{ledger_name}: {name!r} diffing {list(fields)} is now " - f"explained by {got!r}, not {pinned!r}" - # a prefix pin: the roster holds `expected`, not the - # resolved issue string, so name the text to edit. - + (f" (pinned as {expected!r})" if pinned != expected else "") - + ". Check the new " - "rule's prose actually describes this name before " - "recording it -- a rule claiming a diff it does not " - "describe is #372, and it stays green everywhere else") - checked += 1 - assert checked, "no contested name was checked, so this pin is vacuous" - assert set(_CROSS_RULE_WINNERS) == {led.name for led in _LEDGERS}, ( - f"_CROSS_RULE_WINNERS must name every ledger on disk, with an " - f"explicit empty mapping for one that genuinely has no contest. " - f"Missing: {sorted({L.name for L in _LEDGERS} - set(_CROSS_RULE_WINNERS))}; " - f"unknown: {sorted(set(_CROSS_RULE_WINNERS) - {L.name for L in _LEDGERS})}. " - f"This was `<=` until #452, which made a ledger with no rows " - f"indistinguishable from one needing none -- and the #452 " - f"narrowings moved shapes between rules in the two 2.x ledgers " - f"that had no section at all. Both sibling rosters " - f"(_CORPUS_CLAIMS, _SPAN_BEARING_RULES) use equality; this one " - f"was the odd one out.") - - -def _check_shape_rows(compare: ModuleType, roster: str, ledger_name: str, - shapes: dict[str, tuple[str, ...]]) -> None: - """The per-row checks both shape rosters are held to. A contest - row is fed to classify() as the same kind of input a rule's - `fields` is; a watched row is fed to nothing -- the run compares - it against a diff classify() explained -- and the two are held to - one vocabulary for that reason, since a shape spelled outside it - is one no run can produce and so one no run can contradict. A - departed name agrees with itself forever in either, so the checks - are one function applied twice rather than two copies that drift. - `roster` is the dict's name, for the messages. - test_every_pinned_winner_has_a_recorded_shape's docstring carries - why each assertion is here rather than left to the gate; - test_the_shape_row_checks_refuse_each_illegal_row pins that each - one fires. - """ - # For the message below: which live rows carry '_ambiguities', - # read from the dicts rather than named, so the example cannot - # go stale as rows move. - ambiguity_rows = sorted( - (ledger, name) - for roster_dict in (compare._RECORDED_DIFFS, compare._WATCHED_DIFFS) - for ledger, section in roster_dict.items() - for name, row in section.items() if "_ambiguities" in row) - # `gone`'s question, at pytest speed and against every ledger - # rather than only the one a run was pointed at. The gate asks - # it over the corpora a full run loaded; _CORPUS_NAMES is every - # name in every corpus*.jsonl EXCEPT _UNMEASURED_CORPORA - # (corpus_cldr.jsonl, 2026-10-12), so the two populations differ by - # that file: a recorded or watched row on a CLDR-only name reads as - # departed here. Keep such rows off CLDR names, or move the name - # into a measured corpus first. - departed = sorted(set(shapes) - set(_CORPUS_NAMES)) - assert not departed, ( - f"{ledger_name}: {departed} carry a recorded shape in {roster} " - f"and sit in no corpus. Nothing measures such a row, so it " - f"agrees with itself forever. Settle whether the name left " - f"DELIBERATELY before editing either roster -- " - f"compare.py's own refusal over the full corpus carries the " - f"question and both repairs") - for name, shape in shapes.items(): - where = f"{ledger_name}: the {roster} shape for {name!r}" - bad = sorted(set(shape) - compare._RULE_FIELDS) - assert not bad, ( - f"{where} names {bad}, which are not roles; expected " - f"from {sorted(compare._RULE_FIELDS)}. classify() is " - f"asked about the shape as a SET, so a misspelled role " - f"is simply a role the diff does not carry: it never " - f"reports as a typo, only as a rule handover or a " - f"MOVED SHAPE finding blaming the parser. Checked " - f"against _RULE_FIELDS -- the set validate_rules checks " - f"a rule's 'fields' against, since a shape is the same " - f"kind of input -- and not against V2_FIELDS, because " - f"'_ambiguities' is legal in a 2.x row (it cannot " - f"appear below 2.0), and {len(ambiguity_rows)} live " - f"row(s) carry it today" - + (f", e.g. {ambiguity_rows[0][1]!r} at " - f"{ambiguity_rows[0][0]}" if ambiguity_rows else "")) - dups = sorted({f for f in shape if shape.count(f) > 1}) - assert not dups, ( - f"{where} repeats {dups}. It is read as a set, so the " - f"repeat changes nothing classify() matches -- it is a " - f"copy-paste slip that would otherwise pass every check " - f"here silently, and the '_initials' check below would " - f"read ('_initials', '_initials') as '_initials' alone") - others = sorted(set(shape) - {"_initials"}) - assert not ("_initials" in shape and others), ( - f"{where} lists '_initials' beside {others}. " - f"'_initials' enters a diff only when every role and " - f"the ambiguity kinds agree (#484), so no diff can " - f"carry it with another field: this shape is one no run " - f"can measure, and the row is unfalsifiable rather than " - f"merely wrong") - assert shape, ( - f"{where} is empty. main() appends to `diffing` only " - f"where a comparison DIFFED, so no run produces an " - f"empty shape and the row can never be contradicted") - - -def test_every_pinned_winner_has_a_recorded_shape() -> None: - """The roster names a contested name; _RECORDED_DIFFS says what it - diffs. A name in one and not the other is a half-recorded pin -- - the winner cannot be checked without the shape, and a shape nothing - pins a winner for is unverified by the run for no purpose. - - EQUALITY, in both directions, and the second is the one to defend - now that a shape CAN legally stand without a winner. It stands in - compare._WATCHED_DIFFS, not here: a shape with no winner is not an - orphan this roster tolerates but a row in the wrong dict, since - the two dicts carry different contracts (a shape beside a winner - adjudicates a contest; a shape alone watches a name no winner is - pinned for) and compare.py reads the severity and the repair text off - which dict a row sits in. So a shape here with no winner is a - deleted-winner slip -- the pin went and the shape was forgotten -- - and relaxing this to `<=` would make that slip indistinguishable - from a deliberate watched row. The watched roster's own guard is - test_the_watched_roster_is_disjoint_and_names_every_ledger. - - The row VALUES are checked here too, against the three illegal - states compare.validate_rules already refuses for a rule's `fields` - (a name outside the role vocabulary, '_initials' beside a role, a - repeated role) plus the empty one. A recorded shape is fed to - classify() as the same kind of input a rule's `fields` is, so the - wording mirrors those refusals deliberately -- the two should - read as one check written in two places. _check_shape_rows holds - them, and the watched roster's guard applies the same function. - - WHY HERE. What each illegal row does WITHOUT these assertions, - measured 2026-09-03 by injecting one into the shipped 1.4.0 section - and running this file: a repeated role and an empty shape pass the - whole suite silently, and surface only as a MOVED SHAPE finding - from a gate run -- whose message says the winner beside the shape - "was recorded for the OLD shape", telling a contributor with a - copy-paste slip that the parser moved. A misspelled role and an - '_initials' beside a role happened to fail the sibling test above - as well, because on the rows they were injected into the corrupted - shape changed which rule classify() picks -- but that is a property - of those rows, not of the defect: a shape a second rule admits at - both spellings changes no winner and says nothing. Either way the - message names a rule handover rather than the typo, and the loop - that does catch these needs a baseline wheel and a full corpus - pass, so the row ships green through CI. At pytest speed the answer - is a sentence naming the typo. The `departed` assertion asks - compare.py's `gone` question the same way and for the same reason. - """ - compare = load_tool("compare") - for ledger_name, winners in _CROSS_RULE_WINNERS.items(): - shapes = compare._RECORDED_DIFFS.get(ledger_name, {}) - assert set(winners) == set(shapes), ( - f"{ledger_name}: winners without a recorded shape " - f"{sorted(set(winners) - set(shapes))}; shapes with no " - f"pinned winner {sorted(set(shapes) - set(winners))} -- a " - f"shape that is meant to stand without a winner belongs in " - f"compare._WATCHED_DIFFS, not here") - _check_shape_rows(compare, "_RECORDED_DIFFS", ledger_name, shapes) - # Per-ledger equality above iterates the ROSTER's keys, so a - # _RECORDED_DIFFS section for a ledger the roster does not name is - # invisible to it -- and to the run, which reads shapes per ledger - # too. The sibling test asserts the ROSTER's keys are exactly the - # ledgers on disk; this carries that to _RECORDED_DIFFS, which has - # no such check of its own. - assert set(compare._RECORDED_DIFFS) == set(_CROSS_RULE_WINNERS), ( - f"_RECORDED_DIFFS and _CROSS_RULE_WINNERS name different " - f"ledgers: only in _RECORDED_DIFFS " - f"{sorted(set(compare._RECORDED_DIFFS) - set(_CROSS_RULE_WINNERS))}; " - f"only in _CROSS_RULE_WINNERS " - f"{sorted(set(_CROSS_RULE_WINNERS) - set(compare._RECORDED_DIFFS))}") - - -def test_the_watched_roster_is_disjoint_and_names_every_ledger() -> None: - """compare._WATCHED_DIFFS holds the shape of a name no winner is - pinned for -- every row sole-watched again since #501 took the - five contested ones to _RECORDED_DIFFS, which is a fact about - today's rows and not a second contract the checks below enforce. - Three things are checked here, at pytest speed, because - the gate checks them only for the one ledger a run was pointed - at: the first is the sibling rosters' key convention, applied to - this dict; the second and third are what the dict's header - promises. - - Every ledger on disk is a key -- the same equality the other two - rosters use, and for the same reason: an empty section is a - statement (this ledger has no watched name) where a missing one is - nobody having looked; compare.py's main() refuses a ledger missing - from either shape roster pre-worker on the same ground, so the - guard and the run agree. Per ledger, no name sits in both shape - rosters, since a row is one kind or the other and compare.py reads - a row's severity and its repair text off the dict it is in. And, - stated on its own rather than left to follow from the first two: - no watched row has a winner in _CROSS_RULE_WINNERS. It DOES follow - -- every winner has a _RECORDED_DIFFS row, and no watched name has - one -- but "no watched row has a winner" is the sentence the dict - exists on, and a reader should find it as its own assertion rather - than derive it. - - The row values are held to _check_shape_rows, as the contest - roster's are: a watched shape is fed to classify() by nothing - today, but the gate compares it against a run's diff, and a shape - no run can produce is a row that can never be contradicted. - - And the dict must hold SOME row, the sibling `assert checked` - idiom: every check above is per row, so an all-empty roster passes - all of them vacuously, and a ledger with no watched name is an - empty section beside filled ones, not an all-empty dict. - """ - compare = load_tool("compare") - on_disk = {led.name for led in _LEDGERS} - assert set(compare._WATCHED_DIFFS) == on_disk, ( - f"_WATCHED_DIFFS must name every ledger on disk, with an " - f"explicit empty mapping for one that has no watched name. " - f"Missing: {sorted(on_disk - set(compare._WATCHED_DIFFS))}; " - f"unknown: {sorted(set(compare._WATCHED_DIFFS) - on_disk)}") - for ledger_name, watched in compare._WATCHED_DIFFS.items(): - # `.get`, both here and for the winners below: a ledger this - # dict names that either sibling lacks is the every-ledger - # equality's finding, in its own words, not a bare KeyError. - recorded = compare._RECORDED_DIFFS.get(ledger_name, {}) - both = sorted(set(watched) & set(recorded)) - assert not both, ( - f"{ledger_name}: {both} sit in both _RECORDED_DIFFS and " - f"_WATCHED_DIFFS. A row is one kind or the other: a name " - f"with an argument behind it belongs in _RECORDED_DIFFS " - f"alone, since the argument is the stronger claim -- " - f"delete the _WATCHED_DIFFS row") - pinned = sorted(set(watched) - & set(_CROSS_RULE_WINNERS.get(ledger_name, {}))) - assert not pinned, ( - f"{ledger_name}: {pinned} carry a watched shape AND a " - f"pinned winner. A winner pin asserts an argument, and the " - f"day one is argued the row moves to _RECORDED_DIFFS -- it " - f"does not keep a _WATCHED_DIFFS row beside the pin") - _check_shape_rows(compare, "_WATCHED_DIFFS", ledger_name, watched) - assert any(compare._WATCHED_DIFFS.values()), ( - "every section of _WATCHED_DIFFS is empty, so every per-row " - "check above was vacuous. A ledger with no watched name is an " - "empty section beside filled ones; an all-empty dict is the " - "roster deleted, and its header's population says which rows " - "belong") - - -@pytest.mark.parametrize(("row", "sentence"), [ - ({"John Smith": ("famly",)}, "are not roles"), - ({"John Smith": ("family", "family")}, "repeats"), - ({"John Smith": ("_initials", "family")}, "beside"), - ({"John Smith": ()}, "is empty"), - ({"Nobody Here, Esq.": ("family",)}, "sit in no corpus"), -]) -def test_the_shape_row_checks_refuse_each_illegal_row( - row: dict[str, tuple[str, ...]], sentence: str) -> None: - """The negative control for _check_shape_rows, one case per - illegal row kind, under the _WATCHED_DIFFS label. Its sensitivity - was previously a hand measurement cited in - test_every_pinned_winner_has_a_recorded_shape's docstring -- an - injected row and a suite run -- which nothing re-ran; a helper - that stopped firing on one of these would have left both rosters' - guards green and said nothing.""" - compare = load_tool("compare") - with pytest.raises(AssertionError, match=sentence) as exc: - _check_shape_rows(compare, "_WATCHED_DIFFS", - "expected_since_1.4.0.toml", row) - assert "_WATCHED_DIFFS" in str(exc.value) - - -def test_the_family_first_fold_is_not_explained_under_the_default_order( - ) -> None: - """The hazard the `orders` key exists for, pinned against the - shipped 2.x ledgers rather than a fixture. - - 'de la Cruz Juan Carlos' is compared three times: twice from - corpus_shapes.jsonl under the two family-first orders, where #395's - fold is the intended reading, and once from corpus_rules.jsonl - under the DEFAULT order, where rules.md#P1 says the whole string is - the family. If the fold ever leaked into the default order it would - move {family, given, middle} -- the same three roles, on the same - string -- so an order-blind rule would claim the leak, label it - feat(#395), and exit 0. That is #372's failure mode aimed at the - most plausible regression of the very change the rule describes. - - Both directions again: declining the default-order diff is the - point, but a rule that declined its OWN orders too would be - silently dormant and this pin would pass on a broken ledger. - - The default-order assertion runs on EVERY ledger, before the - scoped-rule search decides whether there is anything else to check. - Guarding it behind that search is what made an earlier draft weak: - dropping `orders` from one 2.x ledger takes that ledger through the - `continue` while the other keeps `checked` above zero, and the - suite stays green with one ledger absorbing the leak. On a ledger - whose rules do not reach this name at all -- 1.4.0, the open cycle - -- the assertion is trivially true, which costs nothing. - """ - compare = load_tool("compare") - name, moved = "de la Cruz Juan Carlos", {"family", "given", "middle"} - checked = 0 - for ledger in _LEDGERS: - rules = compare._sorted_rules(_rules(ledger)) - never = _exclusions(ledger) - assert compare.classify(name, moved, rules, never) is None, ( - f"{ledger.name}: a DEFAULT-order diff moving {sorted(moved)} " - f"on {name!r} is explained by " - f"{compare.classify(name, moved, rules, never)!r}. That is " - f"the family-first fold firing where P1 forbids it, and the " - f"rule describing the fold must not absorb it -- scope it " - f"with `orders`") - scoped = [r for r in rules - if isinstance(r.get("orders"), list) - and re.search(str(r["name_regex"]), name) - and moved <= set(r["fields"])] - for rule in scoped: - for order in rule["orders"]: - assert compare.classify( - name, moved, rules, never, order) == rule["issue"], ( - f"{ledger.name}: {rule['issue']!r} declares order " - f"{order} and does not explain its own diff under " - f"it; the rule is dormant and the pin above is " - f"passing for the wrong reason") - checked += 1 - assert checked, ("no orders-scoped rule reaches this name in any " - "ledger, so this pin is vacuous") - - -class _Excluded(NamedTuple): - """What a [[never]] entry silences, in the two dimensions that can - change under it.""" - #: corpus names its name_regex captures - captures: int - #: sha256[:12] of those names, so a regex swap holding the count - #: still fails -- the identity-free lesson _CORPUS_CLAIMS records - digest: str - #: rules that WOULD claim a protected reading of its examples, with - #: exclusions switched OFF. This is the #328 event: a rule widened - #: to reach a protected shape joins this tuple. - absorbed_by: tuple[str, ...] - - -#: What each exclusion silences today, keyed by its name_regex. -#: -#: The first version of this guard asked whether a rule claims a -#: protected shape WITH exclusions active. It could not fail: classify() -#: consults exclusions first and returns None, and the guard only asked -#: about subsets the exclusion covers, so the answer was None by -#: construction. Measured: prepending a catch-all rule, and deleting -#: every rule in the ledger, both left it green across all 387 subsets. -#: It was the tenth inert measurement recorded in this tree. -#: -#: So ask with exclusions OFF, and record the answer. Then a rule -#: widened to reach a protected shape changes `absorbed_by` and demands -#: a decision -- which is the event #328 is about, and the one the -#: harness cannot report because the exclusion (correctly) hides it. -#: -#: `captures` and `digest` cover the opposite direction, which nothing -#: else watched: an exclusion widened by name_regex silences real -#: classifications, and BEFORE this record CI stayed green while the -#: release gate broke. Measured then: dropping the Ph. D. entry's -#: Latin anchor left the whole suite passing and took a bare run to -#: unexplained: 1. Measured now: the same edit fails this pin -- the -#: record is keyed by name_regex, so editing one is exactly what it -#: catches -- while the gate still goes to unexplained: 1. -#: -#: One limit worth naming: `absorbed_by` records only the FIRST rule -#: matching each subset, so a rule appended behind one that already -#: answers those subsets is invisible here. A rule reaching a -#: protected READING is caught; a rule shadowed by an existing one on -#: every subset it claims is not. -#: -#: A second, newer limit: `absorbed_by` is recomputed at comparison -#: order None, so an absorption that happens only under a declared -#: order is invisible to it -- true today because no exclusion -#: protects a shape that reads differently under one, and something to -#: revisit the day one does. -_EXCLUSION_EFFECT: dict[str, _Excluded] = { - "(?i)^(?!\\s*ph\\.)(?![^\\s,]+\\s*,\\s*ph\\.\\s*d\\.\\s*$)(?![\\u0000-\\u024f]*\\b(?:jr|sr|ii|iii|iv)\\.?\\s+ph\\.\\s*d\\.\\s*$)[\\u0000-\\u024f]*\\bph\\.\\s+d\\.\\s*$": - _Excluded(3, "0691d45b4505", - # 2026-10-12: 7 -> 3 captures. The entry was NARROWED to - # the split pair its `why` is about (`ph\\.\\s+d\\.`): - # the unsplit forms 'JOHN SMITH PH.D.', 'John Smith, - # A.B. Ph.D.', 'John Smith, Ph.D.' and 'john smith - # ph.d.' left its reach, as corpus_cldr.jsonl's 17 - # 'M.D. Ph.D.' names (suffix-separator diffs only) did. - # absorbed_by unchanged. - # 2026-09-30, #563: 6 -> 7 captures, 'John Smith, - # A.B. Ph.D.', a rules.md#C1 boundary line whose - # trailing 'Ph.D.' the end anchor reaches. It reads - # as 1.4.0 read it, so nothing is silenced; - # absorbed_by unchanged. - # 2026-09-23, #459: 3 -> 6 captures, rules.md#R4's - # three new trailing Ph. D. spellings - # ('john smith ph.d.', 'JOHN SMITH PH.D.', - # 'john smith ph. d.'); absorbed_by unchanged. - # fix(comma-precomma-family) JOINED this tuple in #372, - # it did not replace anything: it claims the {given, - # family} readings, which it legitimately describes for a - # Latin comma name, while fix(suffix-routing) claimed the - # readings outside its two fields. The exclusion refuses - # the name before either is reached; this records what - # would happen without it. - # - # fix(suffix-routing) LEFT the tuple in #451, which - # deleted the fields-only catch-all of that name: the - # four rules that replaced it are two-token literals and - # none of them reaches a trailing 'Ph. D.'. A tuple that - # SHRANK is the safe direction -- one fewer rule stands - # ready to claim a protected reading -- which is why this - # roster's message warns only about growth. - # FULL issue strings, not `fix(tag)` prefixes. The - # #453 review measured what the truncation cost: this - # ledger carries three rules beginning "fix(comma-family)" - # and, since #451, four beginning "fix(suffix-routing)", - # so a tag-keyed tuple sits at its maximum for that tag - # -- any of the siblings could widen onto a protected - # reading and this roster would stay green. The same - # identity-free weakness _SPAN_BEARING_RULES records. - # - # #484's phd-merge initials rule JOINED the tuple, - # and it is the first entrant that reaches a - # protected shape through the `_initials` - # pseudo-field rather than through a role: its - # `fields` is exactly ["_initials"], so the only - # subset it can claim is the singleton, and - # _protectable_fields builds that subset because it - # reads compare._RULE_FIELDS rather than Role. - # - # Decided rather than absorbed. The rule's regex is - # the bare fragment `\bph\. d\.`, which reaches all - # 19 corpus spellings (18 until #459's 'john smith - # ph. d.' line, 2026-09-23) while explaining the two - # LEADING ones it is named for; a trailing 'Ph. D.' - # is what this entry protects, and the exclusion - # refuses those names before any rule is consulted, - # so the rule claims nothing there in a real run. - # What the growth records is that it now STANDS - # READY to -- delete this entry and the initials - # reading of a trailing 'Ph. D.' would classify - # under a rule whose prose is about the merge at - # the front of a name. Narrowing the regex to the - # leading spelling would empty this tuple again and - # is the alternative on the table if the exclusion - # is ever retired. - ("fix(comma-family) lone post-comma piece routes to " - "suffix/title, not first", - "fix(comma-precomma-family) pre-comma run reads as " - "family, not given", - "fix(initials-per-word) the Ph. D. merge initials " - "each word (facade, since 2.0.0)")), - '(^|[\\w.]\\s+)[("\'][^)"\']+[)"\'](\\s+\\w|\\s*$)': - # 51 -> 54 as rules.md gained the bracketed Polish examples - # (#434): 'Maria Kowalska (z domu Nowak)', 'Maria Kowalska - # (z domu)', and the boundary 'Anna z (domu) Nowak' M2 gained - # when the clause-straddling defect was fixed. 54 -> 55 for - # M4's markerless clause example, 'Smith (Jones)' (#445), - # which the exclusion costs nothing: under the default facade - # the pair is a nickname one, so the name reads family - # 'Smith', nickname 'Jones' exactly as 1.4.0 read it and has - # no diff to silence. Growth in the corpus, not in the - # exclusion -- its regex is untouched -- and `absorbed_by` - # stayed empty, so no rule reaches the protected shape. - # 55 -> 56 for the shape-1 variation matrix (#486): tagging - # 'John "Jack" Kennedy' put this entry's own FIRST `examples` - # string into a corpus for the first time. It was in none - # before -- the radar corpora hold the smart-quote spelling - # 'John “Jack” Kennedy' and not this one, which is why the - # two read differently at 1.4.0 (feat(#273) classifies the - # typographic pair; the ASCII pair is what this entry promises - # was already recognized). It costs the entry nothing either -- - # 1.4.0 reads the quoted clause as a nickname exactly as the - # tree does, so there is no diff to silence. 56 -> 57 for the - # shape-2 slot the same matrix opened, 'Kennedy, John (Jack)', - # which is the paren spelling of that clause after a family - # comma and costs the entry nothing for the same reason. - # 57 -> 58 on 2026-09-10 for the tolerated - # '(김민준.) John Smith', whose bracketed clause this shape - # matches -- and which costs the entry nothing again, the - # clause reading as a CREDENTIAL rather than a nickname at - # every baseline (rules.md#S1's escape unwraps it), so the - # #322/#323 rule carries the name's diff and this exclusion - # silences none of it. - # 58 -> 59 on 2026-09-18 for 'Bridge (A.B)', added as the - # #516 review round's control for classify's `token.role is - # None` guard -- its bracketed clause matches this shape and - # costs the entry nothing: 1.4.0 reads the parenthesized pair - # as a nickname exactly as the tree does, so there is no diff - # to silence. - # 59 -> 62 on 2026-09-19 for the #533 review's three delimited - # maiden names ('Jane Doe (nee Smith MA)', '... Ma)', '(nee - # Smith) MA'), whose parentheses match this shape. It silences - # none of their diff either: the fix(#335/#533) rule carries - # it at every baseline that has one, and `absorbed_by` stays - # empty, which is the half of this record that matters. - # 62 -> 63 for #535's delimited title example, 'Jane Doe (nee - # Smith Prof.)', whose parentheses match this shape too. Same - # non-effect: the fix(#535) title-stop rule carries the name's - # diff at every baseline that has one, and `absorbed_by` stays - # empty. - # 63 -> 67 on 2026-10-12 for #653's rules.md#N1, #M1 and #M3 - # examples, 'Jane (Janey, Jo) Smith', 'Maria Kowalska (z, - # domu Nowak)', 'Jane (née , Jones) Smith' and 'Jane (née - # ,Jones) Smith', whose clauses match this shape. They cost the - # entry nothing: #653 restored 1.4.0's comma inside the clause, - # so the tree reads all four nicknames as 1.4.0 does and there - # is no diff to silence. (The CLDR corpus name the issue reports, - # 'Macáková, Alexandra Zuzana (pani, PhD)', tripped this entry - # through the bug; that corpus lands separately.) - _Excluded(67, "3dd4efe0d13a", ()), -} - - -def _protectable_fields(compare: ModuleType) -> tuple[str, ...]: - """The universe both exclusion pins quantify over: every field a - rule's `fields` may name, which is what an exclusion's may name too. - - Not `Role` alone. validate_exclusions accepts `_ambiguities` -- a - SEGMENTATION-only diff is facade-identical, so it is the one name - that can classify one -- and a universe of the seven roles never - builds a subset containing it. Measured: a rule with - `fields = ["_ambiguities"]` and no `name_regex` claims the - ambiguity-only reading of every protected shape, which is the #328 - event in that dimension, and only the wider universe grows - `absorbed_by` and fails. - - It does NOT close the matching hole on the exclusion side: an entry - narrowing itself to `fields = ["_ambiguities"]` protects nothing - anyone would notice and passes both pins under either universe, - because `absorbed_by` is legitimately empty for the honest entry - too. Taking the universe from compare's own set at least keeps the - two from drifting apart as `_RULE_FIELDS` grows. - """ - return tuple(sorted(compare._RULE_FIELDS)) - - -def test_every_exclusion_silences_what_is_recorded() -> None: - """Both directions an exclusion can drift, recorded rather than - derived -- because a derivation from the same data always agrees - with itself, which is how the first version of this guard came to - be tautological. - - `absorbed_by` is asked with exclusions OFF. That is the only way to - see the #328 event at all: once an entry is in place the harness - reports nothing, correctly, so a rule widened to reach a protected - shape is invisible everywhere else. When this tuple grows, someone - has to decide whether the new rule is legitimate and the exclusion - is now doing real work, or whether the rule reached too far. - - `captures`/`digest` are the opposite drift. An over-wide exclusion - silences classifications a rule should make -- loud at release, - silent in CI, which is the wrong way round for something a push - can introduce. - """ - compare = load_tool("compare") - roles = _protectable_fields(compare) - actual: dict[str, _Excluded] = {} - for ledger in _LEDGERS: - rules = compare._sorted_rules(_rules(ledger)) - for entry in _exclusions(ledger): - captured = sorted(name for name in _CORPUS_NAMES - if re.search(entry["name_regex"], name)) - covered = entry.get("fields") - absorbed = set() - for example in entry["examples"]: - for size in range(1, len(roles) + 1): - for combo in itertools.combinations(roles, size): - diff = set(combo) - if covered is not None and not diff <= set(covered): - continue - claimed = compare.classify(example, diff, rules) - if claimed: - absorbed.add(claimed) - actual[entry["name_regex"]] = _Excluded( - len(captured), - hashlib.sha256( - "\n".join(captured).encode("utf-8")).hexdigest()[:12], - tuple(sorted(absorbed))) - assert actual == _EXCLUSION_EFFECT, ( - f"what an exclusion silences has moved. Recorded " - f"{_EXCLUSION_EFFECT}, now {actual}. A grown `absorbed_by` means " - f"a rule now reaches a protected shape -- decide whether that " - f"rule is right before recording it. A changed captures/digest " - f"means the exclusion itself moved, which is loud at release " - f"and silent here until this fails.") - assert actual, ( - "no ledger declares a [[never]] entry, so this pin is passing " - "vacuously") - - -def test_a_fields_narrowing_actually_narrows_something() -> None: - """The other direction, which nothing else watches. - - test_every_exclusion_silences_what_is_recorded pins an entry's - reach by NAME -- how much corpus it captures, and which rules would - claim its examples. It says nothing about whether the `fields` - narrowing on top of that reach still leaves anything behind, and - the two failures are not symmetric: an over-wide `fields` silences - diffs a rule should explain, on names that are not examples and so - are looked at nowhere else. - - Note where this fails when `fields` is DELETED outright: on the - vacuity assert at the end, not on the per-entry assert below, since - a deleted key drops the entry from the loop entirely. That works - only while one entry carries `fields`. A second one would leave the - deletion green here -- caught instead by `absorbed_by` in the - recorded pin, which is then asked about every reading rather than - the three the key covers, and sees rules claim them. Measured: - deleting this entry's `fields` grows its `absorbed_by` from () to - ('fix(comma-family)', 'fix(comma-precomma-family)'). Re-measured - for #451, and the figure it replaces was wrong in both halves: it - read ('fix(suffix-routing)',) where the answer was a three-tuple - with those two comma rules in it, and #451's deletion of the - fields-only fix(suffix-routing) catch-all then took the third - entry away. The point the sentence is making is unchanged -- the - hypothetical is still caught, by more rules than it named. - - Measured: deleting `fields = ["nickname", "middle"]` from the - ASCII-pairs entry passes every other check in this tree. The entry - then refuses ANY diff on every corpus name it captures -- the - count is _EXCLUSION_EFFECT's `captures` for that pattern, above, - which is where it is checked and where it stays current -- - including 'Jenny (Johnson) Baker' and 'Lon (Jr.) Williams', whose - parens are a maiden name and a suffix, both under active - development. Nothing failed, because none of those names diffs - today and none of them is an example. - - So: an entry that bothers to name `fields` must leave something - behind. If no captured corpus name is still classifiable on a - reading outside them, the narrowing is not narrowing -- either it - was deleted, or it grew to cover everything the entry reaches. - """ - compare = load_tool("compare") - roles = _protectable_fields(compare) - checked = 0 - for ledger in _LEDGERS: - rules = compare._sorted_rules(_rules(ledger)) - never = _exclusions(ledger) - for entry in never: - covered = entry.get("fields") - if covered is None: - continue - checked += 1 - captured = [name for name in _CORPUS_NAMES - if re.search(entry["name_regex"], name)] - survives = [ - (name, sorted(combo)) - for name in captured - for size in (1, 2) - for combo in itertools.combinations(roles, size) - if not set(combo) <= set(covered) - and compare.classify(name, set(combo), rules, never)] - assert survives, ( - f"{ledger.name}: the entry for {entry['name_regex']!r} names " - f"fields={covered}, but no corpus name it captures is still " - f"classifiable on any reading outside them. It captures " - f"{len(captured)} names, so the narrowing has stopped " - f"narrowing -- check it was not deleted or widened to cover " - f"the whole entry.") - assert checked, ( - "no exclusion declares `fields`, so this pin is passing vacuously") - - -def test_a_rule_reaching_no_corpus_name_says_why_it_is_kept() -> None: - """The cheap half of #372, asked on every push. - - A rule whose regex reaches nothing explains nothing, and no other - guard here can tell that from a rule that is merely narrow: - _CORPUS_CLAIMS records the reach it HAS, whatever that is, so a - reach of zero is recorded as contentedly as any other number. - - This is deliberately about REACH, not about diffs. Asking whether a - rule explained a diff needs a baseline wheel and belongs in the - harness; asking whether it could reach any name at all needs only - the corpus, so it runs here, on every push, for free. - """ - silent = [] - checked = 0 - for ledger in _LEDGERS: - for rule in _rules(ledger): - if "dormant" in rule: - continue - # Every rule has a name_regex since #451, so this skip is - # unreachable for a ledger that loads; kept because _rules() - # does not validate. A fields-only rule would reach every - # name by construction and so could never be statically - # silent -- see _claim(), which counts one as the whole - # corpus for the same reason - regex = rule.get("name_regex") - if not isinstance(regex, str): - continue - checked += 1 - if not _claimed(regex): - silent.append(f"{ledger.name}: {rule['issue']}") - assert not silent, ( - f"these rules reach no corpus name, so they explain nothing and " - f"nothing else would say so: {silent}. Either declare `dormant` " - f"with the reason the rule is worth keeping, or delete it.") - assert checked, ( - "no rule was examined, so this guard is passing vacuously -- " - "every rule declares `dormant`, or (impossible since #451) " - "narrows by `fields` alone") - - -#: Every order-decided contest in every shipped ledger, measured with -#: exemptions IGNORED: the earlier rule's issue, the later rule's, and -#: how many corpus names their regexes both reach. -#: -#: The recorded negative control for the guard below (the -#: _EXCLUSION_EFFECT shape AGENTS.md asks of every guard): it is the -#: answer with the mechanism switched off, stored as data. Without it, -#: `precedes_narrower` could be deleted from every rule and the live -#: guard would keep passing if the predicate had quietly stopped -#: finding anything. -#: -#: 11 pairs, all in the 1.4 ledger. They divide by the tier of the -#: names they are contested over -- some reach contract-tier names, -#: the rest only radar -- and #495 argues from that division, which -#: survives a name changing tier even though its two counts there do -#: not. Read a name's tier off `_CORPUS_TIERS` and not off any one -#: demotion: the five radar-only pairs get there by two different -#: warrants, #488's `corpus_cjk_tolerated.jsonl` demotion for most of -#: the CJK names and #468's tier split for every `corpus_issues.jsonl` -#: one -- which is both 'Jr., PhD'/'MD, PHD' pairs whole, and one name -#: of the seventeen the compound/peel pair is contested over. Measured -#: 2026-09-02. A row that MOVES is a finding, not a number to update: -#: re-measure before editing it. -_ORDER_EXEMPTION_EFFECT: dict[str, list[tuple[str, str, int]]] = { - "expected_since_1.4.0.toml": [ - # NEW on 2026-09-19 (#533): the three names carrying a maiden - # marker AND a trailing tussenvoegsel after a family comma - # ('Doe, Jane nee Smith do', its capital spelling, and 'Doe, - # Jane nee Smith MA do') are the first corpus names inside - # both regexes, so file order alone arbitrates the pair. The - # earlier rule now carries the [[change.precedes_narrower]] - # block saying why it wins; this control records the contest - # whether or not it is declared away. - # 3 → 5 on 2026-09-19: the #533 review's two `do`-bearing - # corpus rows ('Berg, Jane van der nee Smith DO', 'Doe, Jane - # nee Smith Do') land inside both regexes. Shared REACH, not - # a widening -- the declared pair is unchanged. - ("fix(#274) maiden markers consumed", - "fix(#379) a tussenvoegsel after a family comma attaches to the family", 5), - # NEW on 2026-10-12 (#653): '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', - # the drop-pass witness row, is inside both regexes and the - # later rule's fields are a strict subset; the earlier rule - # carries the [[change.precedes_narrower]] block. - ("fix(#335/#272) a marker-led paren clause beside a quoted one, compounding with the CJK order flip", - "fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip", 1), - # The #533 review's own rule is wide-first against three - # narrower ones, each declared on it with a - # [[change.precedes_narrower]] block in the 1.4.0 ledger. - # Recorded here for the reason this whole roster exists: the - # declarations say the contests are INTENDED, and these rows - # say how much corpus they cover, so a rule quietly growing - # into a fourth pair fails here. - # The three #274/#533 pairs left on 2026-10-04 (#601): the four - # given-part names they were about lost their clause, and the - # rule its three exemptions. - ("fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example", - "fix(comma-precomma-family) pre-comma run reads as family, not given", 2), - ("fix(#296) a credential-only comma string reads a name and its postnominal", - "fix(comma-family) lone post-comma piece routes to suffix/title, not first", 2), - ("fix(#296) a credential-only comma string reads a name and its postnominal", - "fix(comma-precomma-family) pre-comma run reads as family, not given", 2), - # 2026-10-01, #575: both declared on the #575 rule, over its - # four names; it stands ahead of the two comma rules because - # each describes the opposite or the other half of the move. - ("fix(#575) a particle surname before a comma is one name word", - "fix(comma-family) lone post-comma piece routes to suffix/title, not first", 4), - ("fix(#575) a particle surname before a comma is one name word", - "fix(comma-precomma-family) pre-comma run reads as family, not given", 4), - # 2026-10-04, #603: both declared on the #603 opened-part rule, - # over its five names, ahead of the two comma rules for #575's - # reason -- the move is a whole part's, not one piece's. - ("fix(#603) a credential opening the part after the comma makes it the postnominal part", - "fix(comma-family) lone post-comma piece routes to suffix/title, not first", 5), - ("fix(#603) a credential opening the part after the comma makes it the postnominal part", - "fix(comma-precomma-family) pre-comma run reads as family, not given", 5), - ("fix(#296) a lone post-comma credential is a suffix", - "fix(suffix-routing) a two-token name ending in the suffix word jr keeps it in `suffix`", 2), - ("fix(#400/#274) bound-given join and maiden consumption in one name", - "fix(#400) abd joins the word after it as one given name", 1), - # ('fix(#411/S2) ...' against fix(#400) left with its rule on - # 2026-10-04, #601: 'Berg, abd née Jones' has no clause now.) - ("fix(#272/#308) nakaguro division and a glued hangul honorific in one name", - "fix(cjk-glued-honorific-peel) glued honorific peels into suffix", 1), - ("fix(nickname-typographic-pairs) two typographic quote spans read as one nickname set", - "feat(#273) typographic nickname delimiters recognized by default", 1), - # 20 → 23 on 2026-09-18: the review round's three - # `tolerated=True` rows for the honorific peel behind a - # leaning credential ('Kim김민준씨, MA', 'Jo김민준씨, DO', - # 'Dr. 김민준씨, MA') put three more corpus names inside BOTH - # regexes. The pair is unchanged and no rule was widened -- - # this counter is the shared corpus REACH, so it moves with - # every corpus addition that lands in the overlap. - # 2026-10-06, #613's PR review: 23 -> 26, the tolerated CJK - # rows it added ('김민준씨, MA PhD', 'Smith 김민준씨, XYZ', - # 'John 田中さん, XYZ') landing in both regexes; 26 -> 27 with - # the /simplify pass's 'John Smith, Dr. J.씨' - ("fix(cjk-comma-compound) comma routing compounds with the CJK order flip", - "fix(cjk-glued-honorific-peel) glued honorific peels into suffix", 27), - ("fix(cjk-glued-honorific-peel) glued honorific peels into suffix", - "fix(suffix-routing) a two-token name ending in a roman numeral keeps it in `suffix`", 1), - ("fix(cjk-glued-honorific-peel) glued honorific peels into suffix", - "fix(suffix-routing) a two-token name ending in the suffix word jr keeps it in `suffix`", 1), - ], - # The 2.0.0 list was EMPTY until 2026-09-08, and what put a row in - # it was a narrowing rather than a widening: #316 took 'John Smith - # Dr.' off the dr rule, the gate's OVER-DECLARED check then took - # `middle` out of that rule's `fields`, and {family, suffix} is a - # strict subset of the glued-honorific rule's three roles where - # {middle, family, suffix} was not. The one contested name, - # '田中さん, Dr.', produces no diff at this baseline at all, so the - # pair is latent; the ledger's [[change.precedes_narrower]] block - # carries the argument. - "expected_since_2.0.0.toml": [ - ("fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix", - "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word", 1), - ], - "expected_since_2.1.0.toml": [], - "expected_since_2.2.0.toml": [], - # The 2.4 cycle's three rules (#383/#479, 2026-09-13) nest with - # nothing: no two of them share a corpus name, so no pair is a - # contest at all, let alone a wide-first one. - "expected_since_2.3.0.toml": [], - "expected_since_2.4.0.toml": [], # open cycle, no rules yet -} - - -def test_the_recorded_order_contests_are_what_the_ledgers_hold() -> None: - """The negative control: every contest, exemptions ignored. - - A contest is two same-tier rules that overlap on all three of the - keys classify() narrows by: the LATER one's `fields` are a strict - subset of the earlier one's, both regexes reach a common corpus - name, and neither scopes itself to `orders` the other excludes. - There are then diffs both rules admit, and file order alone picks - the winner. - - This roster is deliberately blind to `precedes_narrower`: it - records the hazard, not whether it has been declared away. - """ - compare = load_tool("compare") - assert any(_ORDER_EXEMPTION_EFFECT.values()), ( - "every ledger's contest list is empty, so this control measures " - "nothing: a predicate that had stopped finding anything at all " - "would read exactly the same. That is the inert-measurement " - "class mechanisms.md#RECORDED-ROSTERS is written against. If " - "the last contest genuinely went away, delete this guard and " - "its roster together -- do not leave four empty lists standing " - "in for a measurement.") - assert set(_ORDER_EXEMPTION_EFFECT) == {led.name for led in _LEDGERS}, ( - f"_ORDER_EXEMPTION_EFFECT must name every ledger on disk, with " - f"an explicit empty list for one that genuinely has no contest. " - f"Missing: {sorted({L.name for L in _LEDGERS} - set(_ORDER_EXEMPTION_EFFECT))}; " - f"unknown: {sorted(set(_ORDER_EXEMPTION_EFFECT) - {L.name for L in _LEDGERS})}") - for ledger in _LEDGERS: - rules = _rules(ledger) - # order_contests' shape leniency (see _rule_reach) is a - # BORROWED guarantee: it trusts validate_rules to have run, and - # `_rules()` does not run it. Calling it here turns the borrowed - # guarantee into a local one, so this guard is not measuring a - # ledger nothing has validated. It costs nothing on the shipped - # files -- they already validate -- and fires first on a - # hand-edit that would otherwise be scanned as if well-formed. - compare.validate_rules(rules, ledger.name) - found = [(c.earlier, c.later, len(c.names)) - for c in compare.order_contests(rules, _CORPUS_NAMES)] - assert found == _ORDER_EXEMPTION_EFFECT[ledger.name], ( - f"{ledger.name}: the order-decided contests are no longer " - f"what this roster records.\n found: {found}\n" - f" recorded: {_ORDER_EXEMPTION_EFFECT[ledger.name]}\n" - f"A contest that appeared is a new rule pair whose winner " - f"file order is deciding; one that vanished means a rule " - f"was narrowed or a corpus name left. Re-measure and read " - f"the pair before editing this roster.") - - -def test_every_order_decided_contest_is_declared() -> None: - """Narrow-first is the declaration-free default; wide-first says why. - - Where the later rule's `fields` are a strict subset of the earlier - one's and both regexes reach a common corpus name, file order alone - decides who classifies the diff. That is legal -- a wider rule can - genuinely be the better description, and `马丁·路德·金씨` is the - worked case: it divides on the nakaguro AND peels its honorific, so - `fix(#272/#308)` describes it and `fix(cjk-glued-honorific-peel)` - describes half of it. What is not legal is leaving it unsaid, - because nothing else in the suite can see it: _CORPUS_CLAIMS - measures each rule alone, the gate total is per-corpus, and - _CROSS_RULE_WINNERS pins only names somebody hand-added. - - Do NOT satisfy this by reordering rules -- that moves which rule - classifies a name and breaks _CROSS_RULE_WINNERS. Declare it. - """ - compare = load_tool("compare") - for ledger in _LEDGERS: - rules = _rules(ledger) - # Both scanners below read `precedes_narrower` through - # _declared_over, whose docstring says outright that its shape - # guarantee is BORROWED from validate_rules -- and `_rules()` - # does not validate. Without this call a whitespace-only `why` - # on a real exemption passes here, because _declared_over reads - # the entry as a perfectly good declaration and retires the - # pair. One line converts the borrowed guarantee into a local - # one; the shipped ledgers already validate, so it costs - # nothing. - compare.validate_rules(rules, ledger.name) - undeclared = compare.undeclared_contests(rules, _CORPUS_NAMES) - assert not undeclared, "\n".join( - [f"{ledger.name}: {len(undeclared)} order-decided contest(s) " - f"nobody declared. The EARLIER rule must carry a " - f"[[change.precedes_narrower]] block naming the later one " - f"and saying what it describes that the later one does not:"] - + [f" {c.earlier!r}\n outranks {c.later!r}\n" - f" on {len(c.names)} name(s), e.g. {list(c.names[:3])}" - for c in undeclared]) - vacant = compare.vacant_exemptions(rules, _CORPUS_NAMES) - assert not vacant, ( - f"{ledger.name}: {vacant} declare precedence over a pair " - f"that is no longer contested. A rule was narrowed or a " - f"corpus name left; delete the exemption rather than " - f"leaving a justification for a hazard that is gone") diff --git a/tests/v2/test_lexicon.py b/tests/v2/test_lexicon.py index 77c84872..04992577 100644 --- a/tests/v2/test_lexicon.py +++ b/tests/v2/test_lexicon.py @@ -973,3 +973,26 @@ def test_removing_a_honorific_tail_is_the_peels_off_switch() -> None: # the peel being broken outright on = Parser().parse("田中さん") assert (on.family, on.suffix) == ("田中", "さん") + + +@pytest.mark.parametrize("field", _PHRASE_FIELDS) +def test_a_shipped_phrase_entry_is_stored_as_written(field: str) -> None: + """The half of a phrase entry's storage rule config's own + assert_normalized cannot see. It checks the single-spaced lowercase + form; the per-WORD period strip is _lexicon._title_key's. So the + question is asked here, and it is stronger than the fold: does + Lexicon store the shipped constant UNCHANGED? An entry written + 'z. domu' passes import-time hygiene and is silently rewritten to + 'z domu' -- inert as a lookup key nobody wrote. + + Over _PHRASE_FIELDS rather than over maiden_markers, so a third + phrase field arrives already pinned.""" + from nameparser.config.maiden_markers import MAIDEN_MARKERS + from nameparser.config.titles import GIVEN_NAME_TITLES + shipped = {"maiden_markers": MAIDEN_MARKERS, + "given_name_titles": GIVEN_NAME_TITLES}[field] + assert getattr(Lexicon.default(), field) == frozenset(shipped), ( + f"{field}: Lexicon rewrote the shipped constant, so the entries " + f"it stores are not the ones the data module wrote -- " + f"{sorted(frozenset(shipped) - getattr(Lexicon.default(), field))} " + f"were folded away or changed") diff --git a/tests/v2/test_regex_sync.py b/tests/v2/test_regex_sync.py index 48566028..0e55094c 100644 --- a/tests/v2/test_regex_sync.py +++ b/tests/v2/test_regex_sync.py @@ -12,11 +12,6 @@ Layering is the usual reason for a copy but not the only one, so the scope here is the PROMISE rather than that one pair of packages: the comma-set pin below reads _pipeline._state instead of config. - -The differential harness makes the same kind of copy by hand, into -files that could not import a Python constant if they wanted to. Those -are pinned in test_ledger_guards.py, which shares nothing with this -module but the idea (#352). """ import pathlib import re diff --git a/tests/v2/test_render.py b/tests/v2/test_render.py index be932e0d..80ffc87f 100644 --- a/tests/v2/test_render.py +++ b/tests/v2/test_render.py @@ -1630,8 +1630,7 @@ def test_facade_initials_follow_the_one_case_fork() -> None: # 'maria y lopez' is a ONE-CASE control, not a mixed-case one: written # wholly in lowercase, its 'y' is outside the marked set (rules.md#P3), # so it stays the CONNECTIVE on both surfaces rather than reading as - # an initial the way 'e' does (tests/v2/test_ledger_guards.py's - # "one-case controls" wording, around line 1118). What it no longer + # an initial the way 'e' does. What it no longer # witnesses is the DROP: #461 gave the letter its initial back here, # because it holds the middle part alone and so joins nothing # (decisions.md#R3). The joining one-case control below is where diff --git a/tests/v2/test_rules_doc_grammar.py b/tests/v2/test_rules_doc_grammar.py index cf16b53d..768e5947 100644 --- a/tests/v2/test_rules_doc_grammar.py +++ b/tests/v2/test_rules_doc_grammar.py @@ -89,12 +89,8 @@ def test_tolerated_marker_is_read_off_a_rule() -> None: A rule may declare its examples ILLUSTRATIVE with a ``tolerated: `` line. The grammar's whole job is to make the line visible: the examples still parse and are still executed - by test_rules_doc.py, and it is build_rules_corpus.py that acts - on the flag by harvesting nothing from a marked rule. The other - half of that promise -- that a tolerated rule really contributes - no name to corpus_rules.jsonl -- is - test_a_tolerated_rule_puts_no_name_in_the_rules_corpus in - tests/v2/test_ledger_guards.py, which can see the corpus file. + by test_rules_doc.py, and the marker tells a reader the rule + describes best-effort behavior rather than promising it. The fixture is synthetic, like DOC above, and stays local to this test so the shared fixture keeps describing a normative doc. diff --git a/tests/v2/test_snapshot.py b/tests/v2/test_snapshot.py index eff306f4..9199c2b0 100644 --- a/tests/v2/test_snapshot.py +++ b/tests/v2/test_snapshot.py @@ -1,17 +1,10 @@ """The tree's readings of every corpus name match the committed snapshot -(tools/differential/snapshot.py). A failure lists the moved names +(tools/differential/snapshot.py, #664). A failure lists the moved names grouped by which fields changed; re-record if the moves are intended, and the diff of snapshot.jsonl is then the review's list of moves.""" -from __future__ import annotations +from ._differential_fixtures import load_tool -import importlib.util -from pathlib import Path - -_PATH = Path(__file__).resolve().parents[2] / "tools" / "differential" / "snapshot.py" -_spec = importlib.util.spec_from_file_location("_snapshot", _PATH) -assert _spec and _spec.loader -snapshot = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(snapshot) +snapshot = load_tool("snapshot") def test_readings_match_the_snapshot() -> None: @@ -29,3 +22,11 @@ def test_the_snapshot_can_report_a_move() -> None: moved = [{**rows[0], "family": "Smyth"}] assert snapshot.grouped({snapshot.key(r): r for r in rows}, {snapshot.key(r): r for r in moved}) + + +def test_a_name_leaving_the_corpus_is_reported() -> None: + # A truncated corpus must not pass for a clean run. + rows = snapshot.read([("John Smith", None), ("Jane Doe", None)]) + assert snapshot.grouped({snapshot.key(r): r for r in rows}, + {snapshot.key(rows[0]): rows[0]}) \ + == ["(removed from corpus): 1", " Jane Doe"] diff --git a/tools/differential/build_cjk_corpus.py b/tools/differential/build_cjk_corpus.py index 8f589b0a..4dc6cb54 100644 --- a/tools/differential/build_cjk_corpus.py +++ b/tools/differential/build_cjk_corpus.py @@ -1,4 +1,4 @@ -"""Regenerate the two CJK corpora from the CJK rows of the case table. +"""Regenerate the CJK corpus from the CJK rows of the case table. The third corpus, with the third provenance (#295): corpus.jsonl regenerates from v1's test banks at an immutable ref and @@ -9,55 +9,25 @@ table instead: every distinct `text` in tests/v2/cases.py containing a character the script table classifies, regardless of the row's policy/locale context -- the corpus carries name STRINGS and the -differential run parses them with the default facade, so a name from -a zh-scoped row is simply one more CJK name to diff. +snapshot reads them under the default policy, so a name from a +zh-scoped row is simply one more CJK name to watch. Selection is by the shipped table (nameparser._policy._SCRIPT_RANGES, through the same _script_matcher the parser uses), so a script added to the table widens the harvest on the next regeneration, and a case row added for new CJK work enters the corpus by being written down in the table everyone already reviews (#298's 间隔号 forms arrived -exactly this way). - -That harvest is SPLIT in two by the row's `tolerated` flag (the -2026-09-01 CJK demotion): an unmarked text goes to -corpus_cjk.jsonl, which stays a CONTRACT corpus, and a text marked -tolerated on its rows goes to corpus_cjk_tolerated.jsonl, a RADAR one -(compare.py's _CORPUS_TIERS). Both files are written by one run over -one sweep, so the two halves cannot drift apart or double-count a -text. Nothing about the harvest predicate changed: a composed or -wrapped CJK form -- a comma listing, a Latin title or credential -around a CJK name, and since 2026-09-05 a trailing ASCII period on an -honorific -- is still compared at every baseline and still -classified against the ledger. What the flag moves is which of THESE -TWO FILES a text is written to, and nothing else: a text that another -CONTRACT corpus also holds keeps the contract tier until it leaves -there too, because compare.py loads contract files first and its -(name, order) dedup keeps the contract reading. That was live when -this split landed -- corpus_rules.jsonl carried five of these texts -as rules.md examples -- and the rules.md edits later the same day -removed all five: W3 marked tolerated, W2's two comma examples -swapped for pure ones, C1's two moved into W3. No text written here -is held contract anywhere today. The sentence stays because it is the -mechanism, not a note about those five: a demotion is complete only -when no contract corpus holds the text, and the next demotion has to -check that for itself. - -The flag is read PER TEXT, not per row: the corpora carry name -strings, so a text on two rows (a default row and a policy/locale -fork of it) is one line in one file. A text marked tolerated on one -of its rows and unmarked on another is therefore a hard error rather -than a silent choice: neither half would select it -- contract takes -the texts whose flags are all False, tolerated the ones all True -- -so the name would leave the harness entirely. +exactly this way). Rows marked `tolerated` (the 2026-09-01 CJK +demotion, rules.md#W3) are harvested like any other: the flag says +the reading is best-effort, not that nobody should see it move. Regenerate after editing CJK case rows: uv run python tools/differential/build_cjk_corpus.py -tests/v2/test_ledger_guards.py pins both checked-in files against -this module's selection, so a stale corpus fails the suite rather -than silently narrowing the differential gate. +tests/v2/test_corpora.py pins the checked-in file against this +module's selection, so a stale corpus fails the suite rather than +silently narrowing what the snapshot watches. """ from __future__ import annotations @@ -73,60 +43,22 @@ from tests.v2.cases import CASES # noqa: E402 OUT = HERE / "corpus_cjk.jsonl" -OUT_TOLERATED = HERE / "corpus_cjk_tolerated.jsonl" _has_cjk = _script_matcher(*_SCRIPT_RANGES) -def _partition() -> tuple[list[str], list[str]]: - """(contract, tolerated) texts, each sorted for a deterministic - file. A text marked tolerated on ANY row while unmarked on - another is a hard error, and note WHICH failure the raise - prevents: the two selections below are `flags == {False}` and - `flags == {True}`, so a split text matches NEITHER and would - vanish from both files -- dropped from the harness, unwatched at - every baseline, rather than landing on one tier or the other.""" - by_text: dict[str, set[bool]] = {} - ids_by_text: dict[str, list[str]] = {} - for case in CASES: - if _has_cjk(case.text): - by_text.setdefault(case.text, set()).add(case.tolerated) - ids_by_text.setdefault(case.text, []).append(case.id) - split = sorted(text for text, flags in by_text.items() if len(flags) > 1) - if split: - rows = "; ".join( - f"{text!r} on {sorted(ids_by_text[text])}" for text in split) - raise SystemExit( - f"texts marked tolerated on one row and not another: {rows}. " - f"A split text is selected by neither half, so it would be " - f"dropped from both corpora and watched at no baseline. " - f"Resolve it deliberately: every row of the text marked " - f"demotes it to the radar file, every row clear keeps it " - f"in the contract one") - contract = sorted(t for t, flags in by_text.items() if flags == {False}) - tolerated = sorted(t for t, flags in by_text.items() if flags == {True}) - return contract, tolerated - - def selected_names() -> list[str]: - """The contract half: every distinct case-table text bearing a - classified codepoint whose rows do not declare it tolerated.""" - return _partition()[0] - - -def tolerated_names() -> list[str]: - """The radar half: the same harvest, for texts whose rows declare - tolerated.""" - return _partition()[1] + """Every distinct case-table text bearing a classified codepoint, + sorted for a deterministic file.""" + return sorted({case.text for case in CASES if _has_cjk(case.text)}) def main() -> None: - contract, tolerated = _partition() - for path, names in ((OUT, contract), (OUT_TOLERATED, tolerated)): - with path.open("w", encoding="utf-8") as fh: - for name in names: - fh.write(json.dumps(name, ensure_ascii=False) + "\n") - print(f"wrote {len(names)} names to {path.name}") + names = selected_names() + with OUT.open("w", encoding="utf-8") as fh: + for name in names: + fh.write(json.dumps(name, ensure_ascii=False) + "\n") + print(f"wrote {len(names)} names to {OUT.name}") if __name__ == "__main__": diff --git a/tools/differential/build_cldr_corpus.py b/tools/differential/build_cldr_corpus.py index d1be2548..6d84a76f 100644 --- a/tools/differential/build_cldr_corpus.py +++ b/tools/differential/build_cldr_corpus.py @@ -15,9 +15,8 @@ Writes three files beside this script, always together: - corpus_cldr.jsonl -- one line per distinct (rendering, shape), in the - harness's existing line format. Radar tier (_CORPUS_TIERS): nobody - chose these names one by one, so the harness watches them and does - not answer for them. + existing corpus line format, read by snapshot.py like every other + corpus. - cldr_fields.jsonl -- one line per rendering, with CLDR's fields, read by cldr_agreement.py and its ratchet test. - cldr_LICENSE -- the Unicode License v3 notice, which must travel with diff --git a/tools/differential/build_rules_corpus.py b/tools/differential/build_rules_corpus.py index 2e4eaf6a..cd4c9a1a 100644 --- a/tools/differential/build_rules_corpus.py +++ b/tools/differential/build_rules_corpus.py @@ -16,10 +16,9 @@ says nothing. A doc example cannot warn about the change that edited it. -The differential run answers the other question. It parses these names -with a RELEASED baseline, which no commit can edit, so a moved name -arrives unexplained and has to be classified in a ledger in writing. -#409 is the worked example: it changed M2's +The snapshot answers the other question: a moved name shows as a line +in the diff of tools/differential/snapshot.jsonl, which a reviewer +reads beside the doc edit (#664). #409 is the worked example: it changed M2's "Jane de la née Jones" → family="de la née Jones" @@ -36,30 +35,12 @@ already gets reviewed. Policy annotations are deliberately ignored. The corpus carries name -STRINGS and the differential run parses them with the default facade, -so a family-first-scoped example is simply one more name to diff -- -build_cjk_corpus.py makes the same call for its zh-scoped rows. - -A TOLERATED rule is skipped whole. A rule carrying rules.md's -`tolerated: ` line has stepped out of the document's -normative reading: its statement describes what the parser does with -input the writing systems it speaks about do not produce, and it says -so on that line. A tolerated rule's examples ILLUSTRATE, they do not -promise -- so they stay in the doc, stay executed by -test_rules_doc.py, and stay pinned at HEAD, while this corpus (which -is what enforces a name against RELEASED baselines, in a contract -tier where an unclassified diff fails the run) stops carrying them. -Skipping the rule rather than filtering its examples is the point: -the unit the doc marks is a rule, so a tolerated rule gaining an -example does not quietly re-enter the contract. - -That is the whole of the demotion's mechanism here, and it leaves the -name watched: rules.md#W3's subjects are also tolerated rows in -tests/v2/cases.py, so build_cjk_corpus.py writes them to the -radar-tier corpus_cjk_tolerated.jsonl and every baseline still parses -and classifies them. A tolerated rule whose examples land in NO -corpus would be a name dropped from the harness, which is a different -act and not one this skip performs on its own. +STRINGS and the snapshot reads them under the default policy, so a +family-first-scoped example is simply one more name to watch -- +build_cjk_corpus.py makes the same call for its zh-scoped rows. A +rule marked `tolerated:` contributes its examples like any other: the +marker says the reading is best-effort, not that nobody should see it +move. The one example form that carries no name is skipped. A `[subject]` example names a policy or locale rather than a name string, so its @@ -80,9 +61,9 @@ uv run python tools/differential/build_rules_corpus.py -tests/v2/test_ledger_guards.py pins the checked-in file against this +tests/v2/test_corpora.py pins the checked-in file against this module's selection, so a stale corpus fails the suite rather than -silently narrowing the differential gate. +silently narrowing what the snapshot watches. """ from __future__ import annotations @@ -102,12 +83,10 @@ def selected_names() -> list[str]: """Every distinct example text in the rules doc, sorted for a - deterministic file. Rules marked `tolerated:` contribute nothing: - their examples illustrate, they do not promise.""" + deterministic file.""" rules = parse_rules_doc(RULES_DOC.read_text(encoding="utf-8")) return sorted({example.text for rule in rules - if rule.tolerated is None for example in rule.examples if example.text}) diff --git a/tools/differential/build_shapes_corpus.py b/tools/differential/build_shapes_corpus.py index 0e60a398..b3b0d9ba 100644 --- a/tools/differential/build_shapes_corpus.py +++ b/tools/differential/build_shapes_corpus.py @@ -1,17 +1,18 @@ """Regenerate corpus_shapes.jsonl from the shape-tagged rows of the case table. -The contract corpus of the v2.3 tier design (#468): every entry here -was CHOSEN -- a row someone wrote with reviewed expected values -- -and the shape id records, once, at admission, what the name is for. -The corpus_cjk pattern generalized: same source of truth (cases.py), -same pinned projection (tests/v2/test_ledger_guards.py holds the -checked-in file equal to this module's selection), different -predicate -- explicit tags instead of codepoint content. - -compare.py resolves each entry's shape to its name_order through -tools/differential/shapes.py and compares family-first entries under -that order, on the v2 surface, at baselines >= the shape's minimum. +Every entry here was CHOSEN (#468) -- a row someone wrote with +reviewed expected values -- and the shape id records, once, at +admission, what the name is for. The corpus_cjk pattern generalized: +same source of truth (cases.py), same pinned projection +(tests/v2/test_corpora.py holds the checked-in file equal to this +module's selection), different predicate -- explicit tags instead of +codepoint content. + +snapshot.py resolves each entry's shape to its name_order through +tools/differential/shapes.py and reads family-first entries under +that order, on the v2 surface; compare.py skips them below the +shape's minimum baseline. Regenerate after tagging or editing tagged case rows: diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 15480aa7..ef8f91a3 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1,63 +1,49 @@ -"""Differential harness: a released baseline vs -the working tree over the corpora. Every diff on a CONTRACT corpus -must classify against that baseline's ledger or the run fails; a -RADAR corpus reports its unmatched diffs instead of failing on them. -A `[[never]]` exclusion is fatal on EITHER tier -- it was chosen, the -same way a rule was, so it belongs to the contract regardless of -which corpus the name it refuses happens to sit in. +"""A released nameparser against the working tree, over every corpus. uv run python tools/differential/compare.py [--baseline VERSION] ---baseline 1.4.0 answers the v1 compat contract; the default answers -what changes for a user upgrading from the previous minor. - -Redirect to a file rather than piping: under zsh, `| tail` replaces the -exit code with tail's, so a failing run reads as a passing one. +Reads every corpus name with a pinned release installed fresh from +PyPI and with the working tree, and prints the names that read +differently, grouped by which fields changed -- the same report +snapshot.py prints. It classifies nothing and fails on nothing (#664): +it answers "how did VERSION read these names?" for a release older +than the committed snapshot, where `snapshot.py --since ` cannot. +For any release that has a snapshot, that command is the cheaper one. + +--baseline 1.4.0 compares the facade alone (1.4 has no 2.0 API); +from 2.0.0 on both surfaces are compared, ambiguity kinds included. +An order-bearing corpus name is skipped below the release its order +shipped in. + +What this file still guards is the trap that makes a comparison +report no differences when it compared one tree with itself: see the +invocation traps in tools/differential/README.md, and _check_tell and +_check_tree below. """ import argparse import importlib.util -import itertools import json import os import re import subprocess import tempfile -import tomllib -from collections import Counter from pathlib import Path -from typing import Literal, NamedTuple +from types import ModuleType HERE = Path(__file__).resolve().parent -FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", - "maiden") - DEFAULT_BASELINE = "2.4.0" REPO_ROOT = HERE.parents[1] -#: The v2 API's names for the same seven roles FIELDS names in v1 -#: vocabulary. Both are compared from baseline 2.0 on. -V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", - "maiden") -#: The two roles the FACADE names differently from Role. Diffs from -#: both surfaces canonicalize to Role's names before classification, so -#: a ledger rule names a role once -- and names it the way the codebase -#: already does everywhere else (AGENTS.md, "canonical field order"). -#: The facade's vocabulary is the one that expires, at 3.0. -_V1_TO_ROLE = {"first": "given", "last": "family"} -#: An unclassified diff, carrying BOTH surfaces' before/after: -#: (name, old_facade, new_facade, old_v2, new_v2, order, -#: initials_only). Both halves are kept because a diff can exist on -#: the v2 surface alone, and a report that named such a diff without -#: showing it would be unactionable. `order` rides along so the report -#: can say which order produced the diff -- None for the default -#: order. `initials_only` is main()'s own verdict on the diff set -#: (#484): it rides along so _print_field_diffs can be TOLD whether -#: the derived view belongs in the block rather than re-deriving it -#: from the rows, which is the shape two copies of one rule take just -#: before they drift. -_Unexplained = tuple[str, dict[str, str], dict[str, str], - dict[str, object], dict[str, object], str | None, - bool] + +def _load_snapshot() -> ModuleType: + """snapshot.py beside this script, loaded by path: tools/ is not a + package, and a test loads this module by path as well.""" + spec = importlib.util.spec_from_file_location( + "differential_snapshot", HERE / "snapshot.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module def _parse_version(text: str) -> tuple[int, int, int]: @@ -96,74 +82,6 @@ def _surfaces_for(version: str) -> frozenset[str]: return frozenset({"facade"}) -def _allowlist_for(version: str) -> Path: - """The ledger for a baseline, one file per baseline so each - release's classified changes stay as history. - - A missing file is a hard error rather than an empty rule set: an - empty set classifies nothing, so every diff reports UNEXPLAINED and - the run reads as a catastrophic regression instead of as a missing - file. - """ - path = HERE / f"expected_since_{version}.toml" - if not path.exists(): - raise SystemExit( - f"no allowlist for baseline {version!r}: expected {path}. " - f"Create it before running this baseline -- an absent " - f"ledger cannot classify anything, so every diff would " - f"report as unexplained.") - return path - - -_SHAPES_PATH = Path(__file__).resolve().parent / "shapes.py" - - -def _load_shapes() -> dict: - """SHAPES from shapes.py beside this script, loaded by path so it - works both run-as-script and under tests' load_tool. Resolved - against the script's own directory, not HERE: shapes.py is code, - not corpus data -- a --corpus override or a test's HERE patch must - not change which inventory loads.""" - spec = importlib.util.spec_from_file_location( - "differential_shapes", _SHAPES_PATH) - assert spec is not None and spec.loader is not None, _SHAPES_PATH - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.SHAPES - - -def _sorted_rules(rules: list[dict[str, object]]) -> list[dict[str, object]]: - """Most-specific-first: a name_regex rule outranks a fields-only - rule wherever both match, so file order does not decide BETWEEN THE - TIERS. - - Within a tier it still decides everything, because the sort is - stable. That is not a footnote: every rule in - expected_since_2.0.0.toml carries a name_regex, so they all sit in - one tier and the order they are written in settles every tie among - them -- two names match both honorific rules and are labelled by - whichever comes first (three until the 2026-09-05 narrowing of the - glued-peel name_regex took '김민준 박사님' off the glued rule; - measured 2026-09-05 over the corpus*.jsonl union, and the twin - sentence over that rule in the ledger itself says the same). - - SINCE #451 THAT IS EVERY LEDGER, and this function is the identity - on all of them. validate_rules now rejects a rule carrying `fields` - and no `name_regex`, which is the only shape that could occupy the - second tier -- so no ledger that loads can reach it, and file order - settles every tie there is. Verified against all three shipped - ledgers at the time of writing. - - Kept rather than deleted, deliberately. It is the defence that - makes the ban SAFE to state: a ledger read by anything that does - not call validate_rules first -- a future tool, a REPL session, a - test fixture -- still gets the tier ordering rather than silently - letting a regexless rule shadow the file. Deleting it would move - the guarantee from the code into a convention, which is the trade - #451 was filed to undo. - """ - return sorted(rules, key=lambda r: not isinstance(r.get("name_regex"), str)) - _WORKER_TEMPLATE = '''\ # /// script @@ -214,12 +132,12 @@ def _parser_for(order): def _v2_row(p): - # must stay identical to main()'s tree-side row (compare.py) -- + # must stay identical to snapshot.read()'s tree-side readings -- # duplicated rather than shared across the process boundary row = {f: (getattr(p, f, "") or "") for f in V2_FIELDS} row["_ambiguities"] = sorted( {a.kind.name for a in getattr(p, "ambiguities", ())}) - # #484: a derived view, compared only where the roles agree + # #484: the derived initials() view row["_initials"] = p.initials() or "" return row @@ -393,3339 +311,50 @@ def _run_worker(version: str, want_v2: bool, return tell, results -def _canonical_field(field: str) -> str: - """A role's canonical name: Role's, not the facade's. Applied to - diffs from BOTH surfaces, so it must be a no-op on names that are - already canonical.""" - return _V1_TO_ROLE.get(field, field) - - -def _order_tag(order: str | None) -> str: - """The ` [order: X]` header suffix, or "" for a default-order - entry. Shared by the UNEXPLAINED and UNCLASSIFIED (radar) headers - in main() -- one copy for the same reason _print_field_diffs is: - two copies can drift.""" - return f" [order: {order}]" if order is not None else "" - - -def _print_field_diffs(old_facade: dict[str, str], new: dict[str, str], - old_v2: dict[str, object], - new_v2: dict[str, object], - order: str | None = None, *, - initials_only: bool) -> None: - """Print each moved field under one name, Role's, whichever - surface(s) moved it. Shared by the UNEXPLAINED and UNCLASSIFIED - (radar) blocks in main() -- one copy rather than two that can - drift apart, the same reason _entry_matches is "one predicate - rather than three copies". - - Role's names, not the facade's: both report blocks exist to be - turned into a ledger rule, and a rule naming the facade's `first` - is rejected by validate_rules at startup. Both surfaces are - walked, and a ROLE is reported once even when both moved, since - one rule covers it. `_initials` is the exception, and the last - paragraph is why. - - `order` is None for a default-order entry, else the order both - surfaces were read under. The "[v2 surface only]" tag means "the - facade was compared and agreed" -- untrue for an order-bearing - entry, whose facade is never consulted at all (main() passes empty - dicts for it), so the tag is suppressed there rather than printed - with a meaning it does not have. - - `initials_only` is main()'s VERDICT on the diff set, not a hint: - the derived `_initials` view is printed only when it is what the - diff consists of. Taking the answer rather than re-deriving it - from the rows is the point -- main()'s roles-identical guard and - this print cannot drift apart, so a block always pastes into a - rule validate_rules accepts (`fields = ["family", "_initials"]` is - refused, and a block showing that pair would invite exactly it). - - The facade's initials() and the core's are INDEPENDENT - implementations -- the facade builds its string from - `_initials_lists`, not from `_render.initials` -- so unlike a role - the two can legitimately move to different strings, and the roles' - print-it-once convention would hide the core's movement behind the - facade's. So the v2 line is suppressed only when it would REPEAT - the facade's: it prints whenever the core moved to a different - (old, new) pair, tagged "[v2 surface]" to say both surfaces were - compared and moved differently. One rule still covers both, the - field name being the same -- but a block showing the facade's - movement alone would have that rule written in the belief the core - agreed. Read a bare facade line for what it says, then: the - absence of a v2 line means the core did not move to a DIFFERENT - pair -- it moved identically, or it did not move at all -- and - never that the core agreed. The tag needs no order check to stay - honest, because `facade_moved` can only be true where the facade - was CONSULTED: main() passes empty dicts for an order-bearing - entry, so the facade pair is ('', '') there, `facade_moved` is - False, and the v2 line falls to the order-aware branch, which - suppresses the tag. - """ - seen: set[str] = set() - for f in FIELDS: - if old_facade.get(f, "") != new.get(f, ""): - seen.add(_canonical_field(f)) - print(f" {_canonical_field(f)}: " - f"{old_facade.get(f, '')!r} -> {new.get(f, '')!r}") - for f in (*V2_FIELDS, "_ambiguities"): - if old_v2.get(f, "") != new_v2.get(f, "") \ - and _canonical_field(f) not in seen: - tag = "" if order is not None else " [v2 surface only]" - print(f" {_canonical_field(f)}: " - f"{old_v2.get(f, '')!r} -> {new_v2.get(f, '')!r}" - f"{tag}") - # #484: the derived view, printed exactly as main() compares it -- - # per surface, facade first, and twice when the two surfaces moved - # DIFFERENTLY, which a role cannot do (see the docstring). - if initials_only: - facade_pair = (old_facade.get("_initials", ""), - new.get("_initials", "")) - v2_pair = (old_v2.get("_initials", ""), new_v2.get("_initials", "")) - facade_moved = facade_pair[0] != facade_pair[1] - v2_moved = v2_pair[0] != v2_pair[1] - if facade_moved: - print(f" _initials: {facade_pair[0]!r} -> {facade_pair[1]!r}") - if v2_moved and (not facade_moved or v2_pair != facade_pair): - if facade_moved: - tag = " [v2 surface]" - else: - tag = "" if order is not None else " [v2 surface only]" - print(f" _initials: {v2_pair[0]!r} -> {v2_pair[1]!r}{tag}") - def _is_latin_only(name: str) -> bool: """Every character below U+0250 -- Latin, ASCII punctuation and - Latin-1 accents. - - The partition is by SCRIPT OF THE INPUT, not by which issue claimed - the diff, because the question it answers is whether a user parsing - Western names sees any change at all. That number goes into the - release notes. - """ + Latin-1 accents: whether a user parsing Western names sees a move.""" return all(ord(ch) < 0x250 for ch in name) -#: Every legal entry in a rule's `fields`: the seven roles under Role's -#: names, plus two pseudo-fields. `_ambiguities` carries reported -#: AmbiguityKinds -- a SEGMENTATION-only diff is facade-identical, so -#: this is the one name that can classify it -- and cannot enter a diff -#: below baseline 2.0. `_initials` (#484) carries the derived -#: initials() view and CAN enter one at 1.4.0, through the facade; it -#: enters only when every role and the ambiguity kinds agree (main()), -#: so a rule listing it lists nothing else (validate_rules). -_RULE_FIELDS = frozenset((*V2_FIELDS, "_ambiguities", "_initials")) -_RULE_KEYS = frozenset(( - "issue", "name_regex", "fields", "dormant", "orders", - "precedes_narrower")) - - -#: The `orders` member naming the DEFAULT order -- the comparison whose -#: `order` is None, run under no declared name_order at all. A sentinel -#: rather than a constant name because there is no constant to borrow: -#: the default order is the absence of one, no shape declares it, and -#: TOML has no null to put inside an array. Without it a rule that -#: explains only default-order diffs cannot say so and has to stay -#: order-blind, which is the leak running the OTHER way from the one -#: `orders` was added for: an order-blind rule sorted ahead of the -#: scoped ones absorbs a family-first regression on a name its regex -#: happens to reach. -_DEFAULT_ORDER = "DEFAULT" - - -def _legal_orders() -> frozenset[str]: - """The names a rule's `orders` may carry: every order shapes.py's - inventory declares, plus the "DEFAULT" sentinel. - - The constants are borrowed rather than hand-copied, the same way - build_cjk_corpus.py borrows the script table: an order no shape - declares is an order no comparison can run under, so a rule scoped - to it could only ever be dormant, and a typo in one would be a rule - that silently explains nothing. - - "DEFAULT" is the one member that cannot be borrowed, since it names - the absence of a declared order rather than a shape. - """ - return frozenset(shape.order for shape in _load_shapes().values() - if shape.order is not None) | {_DEFAULT_ORDER} - - -#: Probe names for the over-match check, chosen to share no script, no -#: vocabulary and no punctuation. A `name_regex` matching ALL of them is -#: not targeting a behavior family, it is matching everything -- and -#: since name_regex rules sort first, one such rule shadows the ledger. -#: Probing the empty string instead (the first attempt) tested the wrong -#: property: '.', '.+', r'\b' and '[\s\S]' all decline "" and still -#: match every name in every corpus. -_SENTINELS = ("John Smith", "田中さん", "Хосе Сантос", "x") - -#: Per-corpus size floors. The existing empty-file guard only catches a -#: corpus that lost EVERY name; one truncated to a handful sails past -#: it, and the run then exits 0 having compared a fraction of what its -#: own summary line reports -- green, and quietly meaningless. -#: -#: Floors, not counts, because corpus_issues.jsonl grows whenever it is -#: regenerated from the tracker and pinning it exactly would fail on -#: every legitimate harvest. Set a little under the real size, and -#: ratchet up only deliberately. A file with no entry here is a hard -#: error rather than an unguarded default: the point is to force a -#: decision when a corpus is added, the way the Script tables do. -_CORPUS_FLOORS = { - "corpus.jsonl": 480, # 486 today, from v1's banks at a pinned ref - "corpus_cldr.jsonl": 1830, # 1870 today (2026-10-12, `wc -l`), - # generated from CLDR release-48 test - # data by build_cldr_corpus.py. - "corpus_cjk.jsonl": 67, # 70 today (2026-09-10, `wc -l`), - # generated from the case table. - # LOWERED 95 -> 70 on 2026-09-01, - # deliberately: the CJK comma demotion - # moved 25 tolerated texts out of this - # file into corpus_cjk_tolerated.jsonl - # below. Nothing left the harness -- - # the names are compared and classified - # exactly as before, on the radar tier. - # LOWERED again 70 -> 67 on 2026-09-05, - # the same way and for the same class: - # the trailing-period honorifics - # ('田中さん 様.' and its two twins) are - # the listing artifact the first - # sweep's criterion could not see. - # UNMOVED by the #322/#323 full-stop - # bundle (2026-09-10): every row it - # added is tolerated, so all seventeen - # of its names landed in the file below - "corpus_cjk_tolerated.jsonl": 22, # 47 today (2026-09-10, `wc -l`), - # the tolerated half of the same - # generator: composed and wrapped CJK - # forms (comma listings, Latin titles - # and credentials, trailing ASCII - # periods) whose handling the contract - # stopped promising on 2026-09-01. 47 - # since 2026-09-10, the #322/#323 - # bundle's seventeen names, every one - # tolerated and so every one on the - # radar tier; floor left at 22, which - # guards against the file emptying, - # this half only growing as the - # contract narrows - "corpus_issues.jsonl": 370, # 381 today, harvested and append-only - "corpus_rules.jsonl": 150, # 287 today (2026-09-10, `wc -l`), - # generated from rules.md -- the "249" - # this line read was measured on - # 2026-09-05 and never re-measured as - # the document grew (286 the day - # before). The one row this bundle - # adds is 'Smith. John', H2's Latin - # witness for the #323 veto's fork; - # the CJK witness is W3's, and a - # tolerated rule's examples are not - # harvested here. The file tracks the - # document, so a demotion shrinks it -- - # a marked rule stops being harvested - # and its texts move to - # corpus_cjk_tolerated.jsonl above, - # still compared and classified; floor - # left at 150, which guards against the - # file emptying and is nowhere near any - # demotion this document has seen - "corpus_shapes.jsonl": 35, # 37 today, generated from shape-tagged - # case rows. Ratcheted 27 -> 35 on - # 2026-09-01 with the shape 6/7 - # exemplars (7 CJK names, already in - # corpus_cjk.jsonl and deduped against - # it by (name, order) -- the file grew, - # the comparison did not) -} - -#: Tier per corpus file, fail-closed like the floors above. CONTRACT -#: corpora hold names someone chose -- an unmatched diff on one is -#: UNEXPLAINED and fails the run, today's discipline. RADAR corpora -#: hold the names the contract does not answer for (#468) -- scraped -#: and harvested ones, and since 2026-09-01 the deliberately demoted -#: ones too: their diffs still classify against the ledger (release -#: notes want the grouping) but an unmatched one prints under -#: UNCLASSIFIED (radar) and cannot fail the run or demand a rule. -#: Promotion is a cases.py row plus a shape tag -- a name enters the -#: contract by being chosen -- or, for a demoted name that already -#: has rows, clearing `tolerated` on them (a shape tag is not -#: available to it: shapes 6/7 refuse composed and wrapped text, and -#: that refusal is why the name was demoted). -#: -#: A `[[never]]` exclusion is the same kind of choice, and stays fatal -#: on a name in a radar file for exactly that reason: someone wrote -#: the entry, its `why`, and its `examples`, so the shape it refuses -#: was chosen the same way a rule is -- unlike most of a radar file, -#: which nobody has looked at name by name. The tier split governs -#: what the contract answers for; a [[never]] entry declares a shape -#: nobody may explain away, so it outranks the tier the name happens -#: to sit in. -#: Corpora the harness compares and classifies but does not ADJUDICATE: -#: a name held only by one of these never raises an UNPINNED CONTEST. -#: corpus_cldr.jsonl (2026-10-12): CLDR's fictional renderings raised -#: three at 1.4.0 between rule pairs nobody wrote them to decide, and the -#: decision was to keep ledger accounting about the names the ledgers -#: were written for -- the run-time twin of the ledger guards' -#: population, which leaves the same file out (tests/v2/ -#: _differential_fixtures.py, held equal to this by a test). Their diffs -#: still classify, by file order. The cost is the guards': a rule that -#: over-reaches onto these names is not measured here either. -_UNMEASURED_CORPORA = frozenset({"corpus_cldr.jsonl"}) - - -_CORPUS_TIERS = { - "corpus.jsonl": "radar", - "corpus_cjk.jsonl": "contract", - # The one radar file whose names WERE looked at one by one: the - # 2026-09-01 demotion moved them here by a reviewed flag on their - # case rows, not by scraping. The tier still fits, and for the - # reason the flag was written -- these are composed and wrapped - # CJK forms (a comma listing, a Latin title or credential around a - # CJK name, and since 2026-09-05 a trailing ASCII period on an - # honorific) that native CJK writing does not contain, so the - # differential stops answering for them. Radar is what "we still - # watch it, we no longer enforce it" costs; the case rows still - # assert every one of these parses in the suite. - # - # This entry demotes the FILE, which is not the same as demoting - # every text in it: the dedup above loads contract files first and - # keeps the contract reading, so a text some contract corpus also - # holds reads contract no matter what this says. Five did on the - # day this file was created -- rules.md examples, so - # corpus_rules.jsonl held them too and they went on reading - # contract. The rules.md edits later the same day took those five - # out of corpus_rules.jsonl -- W3 marked tolerated, W2's two comma - # examples swapped for pure ones, C1's two moved into W3 -- so - # NONE do today and every text in this file reads radar. The rule - # is stated as a rule, not as a caveat about the five: whatever - # lands here next is demoted only once no contract corpus holds - # it. What landed next needed exactly that. '田中さん 様.' was one - # of the two pure texts the W2 swap brought IN, so the 2026-09-05 - # period-class demotion had to move that example line into the - # tolerated W3 in the same breath as marking its case row -- - # marking the row alone would have left the name enforced and - # documented as demoted. Still none today, and asked of every - # contract file on every run by - # test_the_tolerated_corpus_is_disjoint_from_the_contract_ones - # (tests/v2/test_ledger_guards.py) rather than left to a reader. - "corpus_cjk_tolerated.jsonl": "radar", - # Unicode CLDR's person-name formatting test data (build_cldr_corpus.py): - # fictional names nobody here chose one by one, so the harness - # watches them and does not answer for them. Its ground truth is - # enforced elsewhere -- by the agreement ratchet in - # tests/v2/test_cldr_agreement.py, not by this tier. - "corpus_cldr.jsonl": "radar", - "corpus_issues.jsonl": "radar", - "corpus_rules.jsonl": "contract", - "corpus_shapes.jsonl": "contract", -} - - -def validate_rules(rules: list[dict[str, object]], ledger: str) -> None: - """Reject malformed allowlist rules LOUDLY at startup. - - Every check below is a way a rule can silently stop meaning what - its author wrote. Most of them catch a rule matching MORE than - intended, which is the dangerous direction: it converts a real - regression into a classified diff and a green run. Two catch the - opposite -- an empty `fields`, or one naming something that is not - a role, makes a rule that can never match, and its diff then - surfaces as UNEXPLAINED. That failure is loud, so those two checks - buy a precise message rather than safety; they are here because the - family is easier to reason about whole than split by direction. - - Two checks belong to the dangerous direction and are the family's - sharpest examples, one per missing key -- #451 for a `fields` with - no `name_regex`, #456 for a `name_regex` with no `fields`. An - earlier version of this paragraph said the first was the only shape - that could widen invisibly; #456 falsified that, and worse, since - over_declared_rules skips a fieldless rule so nothing narrows it - either. #451 is the rule that lived - it -- no name narrowing, so it claimed every name whose diff fit - its `fields`, and _CORPUS_CLAIMS (the guard tracking each rule's - reach) recorded that reach as the whole corpus from the start, so - growth could never trip it. It grew from 4 explained names to 14 - across six unrelated behavior families before anyone noticed, with - every guard green throughout. A specificity FLOOR on role count was - proposed and declined as vacuous (#372/#373): that one rule named - 3 of 7 roles, and no floor rejecting it matches anything else. This - check rejects the SHAPE instead -- no name narrowing at all -- which - is a different proposal and costs no migration, since no rule in - any shipped ledger has it. - - The TYPE and VALUE checks are the quiet ones, not the presence - check: `classify` skips a `name_regex` that is not a str and a - `fields` that is not a list, so a mistyped or misspelled key does - not fail -- it deletes that half of the rule's narrowing and the - rule matches on the other half alone. - - Rules are also compiled here, not at match time. `classify` returns - on the first match, so an uncompilable pattern at position k only - raises once a diff gets past rules 1..k-1: a ledger can run green - for months and then explode mid-run, past the worker pass and deep - into the comparison, in a traceback naming neither the file nor - the rule. - - `ledger` is named rather than hardcoded because there is one per - baseline now: a message naming the wrong file sends the reader to - edit a rule that is not the broken one. - - The `dormant` check is the one exception to that framing: it is not - about a rule's matching semantics drifting, but about an opt-out - carrying a justification someone can review. - """ - # A dict rather than a set because `precedes_narrower` (#382) needs - # each rule's POSITION to check that an exemption points forward. - # The membership test is the same one the dedupe check was written - # with, and a rule's issue is unique by the time this loop ends, so - # the index it records is unambiguous. - positions: dict[str, int] = {} - for k, rule in enumerate(rules): - issue = rule.get("issue") - if not isinstance(issue, str): - continue # the per-rule loop below rejects it with a better message - if issue in positions: - raise SystemExit( - f"{ledger} has two rules sharing the issue {issue!r}. The " - f"dormancy check identifies a rule by its issue, so the " - f"second would hide behind the first: it could explain " - f"nothing and never be reported") - positions[issue] = k - for i, rule in enumerate(rules): - where = f"{ledger} rule #{i + 1}" - issue = rule.get("issue") - if not isinstance(issue, str) or not issue: - raise SystemExit( - f"{where} has no string 'issue': {rule!r}") - where = f"{where} ({issue!r})" - unknown = set(rule) - _RULE_KEYS - if unknown: - raise SystemExit( - f"{where} has unknown key(s) {sorted(unknown)}; expected " - f"only {sorted(_RULE_KEYS)}. A misspelled key is not " - f"ignored -- it drops that half of the rule's narrowing " - f"and the rule matches on the other half alone") - if "dormant" in rule: - reason = rule["dormant"] - if not isinstance(reason, str) or not reason: - raise SystemExit( - f"{where} has a 'dormant' that is not a non-empty " - f"string ({reason!r}). 'dormant' declares that a rule " - f"is expected to explain nothing, and the reason is " - f"the whole safeguard -- an exemption nobody can " - f"justify means the rule should be deleted instead") - if "orders" in rule: - orders = rule["orders"] - if not isinstance(orders, list) \ - or not all(isinstance(o, str) for o in orders): - raise SystemExit( - f"{where} has an 'orders' that is not a list of " - f"strings ({orders!r}); _entry_matches would ignore " - f"it and the rule would go back to claiming a diff " - f"under EVERY order, which is the scoping this key " - f"exists to undo") - if not orders: - raise SystemExit( - f"{where} has an empty 'orders', which no comparison " - f"can be run under -- a rule that can never match. " - f"Omit the key to stay order-blind") - legal = _legal_orders() - bad = sorted(set(orders) - legal) - if bad: - raise SystemExit( - f"{where} names {bad} in 'orders', which shapes.py " - f"declares for no shape and which is not the " - f"{_DEFAULT_ORDER!r} sentinel; expected from " - f"{sorted(legal)}. No comparison runs under an order " - f"no shape asks for, so the rule would explain " - f"nothing and report as dormant instead of saying " - f"the name is wrong") - if "precedes_narrower" in rule: - # #382. Where this rule deliberately outranks a NARROWER one - # it would otherwise lose nothing by yielding to. Legal, and - # never silent: `fields` cannot say that a wider rule - # describes a compound behavior its component rule does - # not, so the reason is the only place that fact can live. - # - # "Sits later in the file" means "loses to this rule" only - # because #451 and #456 force every rule to carry BOTH - # narrowing keys, which leaves one tier and makes - # _sorted_rules the identity on any ledger that validates. - # Relaxing either ban breaks the forward-only check below - # rather than merely widening it: a fields-only rule at - # position 1 could declare precedence over a name_regex rule - # at position 5 and be accepted here, while _sorted_rules - # puts the name_regex rule first and it is the one that - # actually wins. - declared = rule["precedes_narrower"] - if not isinstance(declared, list) or not declared \ - or not all(isinstance(e, dict) for e in declared): - raise SystemExit( - f"{where} has a 'precedes_narrower' that is not a " - f"non-empty list of tables ({declared!r}). Write it " - f"as [[change.precedes_narrower]] blocks under the " - f"rule -- single-bracket [change.precedes_narrower] " - f"makes ONE table rather than a list of them -- and " - f"delete the key rather than leaving an empty one, " - f"which declares nothing") - for entry in declared: - unknown = set(entry) - {"issue", "why"} - if unknown: - raise SystemExit( - f"{where} has {sorted(unknown)} inside a " - f"'precedes_narrower' entry, where only 'issue' " - f"and 'why' belong. If that key belongs to the " - f"RULE, move it ABOVE the " - f"[[change.precedes_narrower]] block: TOML binds " - f"every bare key after that header to the " - f"exemption, so a rule key written below it is " - f"silently dropped from the rule -- an 'orders' " - f"landing here deletes the rule's order " - f"narrowing and nothing else would notice") - target, why = entry.get("issue"), entry.get("why") - if not isinstance(target, str) or not target: - raise SystemExit( - f"{where} has a 'precedes_narrower' entry with " - f"no string 'issue': {entry!r}. An exemption " - f"names the ONE rule it outranks -- a blanket " - f"opt-out would be inherited by every narrower " - f"rule added later, which is the widening this " - f"check exists to refuse") - if not isinstance(why, str) or not why.strip(): - raise SystemExit( - f"{where} declares precedence over {target!r} " - f"with no 'why' ({why!r}). The reason is the " - f"whole safeguard, as it is for 'dormant': an " - f"exemption nobody had to justify is the one " - f"nobody reviews") - if target not in positions: - raise SystemExit( - f"{where} declares precedence over {target!r}, " - f"which names no rule in this ledger. A rule's " - f"issue string is its identity here; a renamed " - f"or deleted rule leaves an exemption that " - f"protects nothing") - if positions[target] == i: - raise SystemExit( - f"{where} declares precedence over ITSELF. An " - f"exemption names the OTHER rule this one " - f"outranks; no rule contests itself, so this is " - f"the declaring rule's own issue string copied " - f"where the narrower rule's belongs") - if positions[target] < i: - raise SystemExit( - f"{where} declares precedence over {target!r}, " - f"which sits EARLIER in the file (rule " - f"#{positions[target] + 1}). An exemption names " - f"the narrower rule this one outranks, and that " - f"rule is by definition the later one -- so this " - f"is a copy-paste of the wrong issue string, " - f"sitting in the file reading as a justification") - # The same copy-paste slip the `fields` duplicate check - # refuses, and worse here: the second entry exempts a pair - # already exempted, so it changes nothing -- but two reasons - # for one pair means one of them is stale, and a reviewer - # reading the ledger cannot tell which. - targets = [e["issue"] for e in declared] - dups = sorted({t for t in targets if targets.count(t) > 1}) - if dups: - raise SystemExit( - f"{where} declares precedence over {dups} more than " - f"once in 'precedes_narrower'. One pair takes one " - f"exemption, so the repeat exempts nothing new; keep " - f"the reason that is still true and delete the rest") - has_regex, has_fields = "name_regex" in rule, "fields" in rule - if not has_regex and not has_fields: - raise SystemExit( - f"{where} has neither 'name_regex' nor 'fields' -- it " - f"would match every diff and shadow every later rule") - if has_regex: - pattern = rule["name_regex"] - if not isinstance(pattern, str): - raise SystemExit( - f"{where} has a non-string 'name_regex' " - f"({pattern!r}); classify would skip it and the rule " - f"would match on 'fields' alone") - try: - compiled = re.compile(pattern) - except re.error as exc: - raise SystemExit( - f"{where} has an invalid 'name_regex' " - f"({pattern!r}): {exc}") from None - if all(compiled.search(s) for s in _SENTINELS): - raise SystemExit( - f"{where} has a 'name_regex' ({pattern!r}) that " - f"matches every one of {list(_SENTINELS)} -- names " - f"with no script, vocabulary or punctuation in " - f"common, so a rule targeting one behavior family " - f"cannot match them all. name_regex rules sort " - f"FIRST, so this one would shadow the whole ledger") - if has_fields: - fields = rule["fields"] - if not isinstance(fields, list) \ - or not all(isinstance(f, str) for f in fields): - raise SystemExit( - f"{where} has a 'fields' that is not a list of " - f"strings ({fields!r}); classify would skip it and " - f"the rule would match on 'name_regex' alone") - if not fields: - raise SystemExit( - f"{where} has an empty 'fields', which can never " - f"match any diff -- a rule that does nothing") - bad = sorted(set(fields) - _RULE_FIELDS) - if bad: - raise SystemExit( - f"{where} names {bad} in 'fields', which are not " - f"roles; expected from {sorted(_RULE_FIELDS)}. A " - f"name outside that set never matches, so the rule " - f"is silently dead") - # `others` rather than len(fields) > 1: fields = - # ["_initials", "_initials"] is longer than one and yet - # mixes nothing, so the length test fired and printed an - # empty list -- a refusal naming no second field, which is - # the one thing the message exists to name. Nothing here - # rejects a repeated field name, and this check is not the - # place to start: it is about the MIX. - dups = sorted({f for f in fields if fields.count(f) > 1}) - if dups: - raise SystemExit( - f"{where} repeats {dups} in 'fields'. classify() " - f"reads 'fields' as a set, so the repeat changes " - f"nothing it matches -- it is a copy-paste slip that " - f"would otherwise pass every check below silently, " - f"and the '_initials' check in particular would read " - f"['_initials', '_initials'] as '_initials' alone") - others = sorted(set(fields) - {"_initials"}) - if "_initials" in fields and others: - raise SystemExit( - f"{where} lists '_initials' beside " - f"{others} in 'fields'. " - f"'_initials' enters a diff only when every role and " - f"the ambiguity kinds agree (#484), so no diff can " - f"carry it with another field: the '_initials' half " - f"of this rule is silently dead. Give the " - f"initials-only shape its own rule with " - f"fields = ['_initials']") - if set(V2_FIELDS) <= set(fields): - raise SystemExit( - f"{where} lists all seven roles in 'fields', so the " - f"subset test admits every ROLE diff -- the " - f"narrowing is not narrowing anything. Checked " - f"against the seven roles rather than against every " - f"legal entry, because the roles are the only names " - f"that ever co-occur in one diff: '_ambiguities' " - f"cannot appear below baseline 2.0, and '_initials' " - f"only ever appears alone (#484's roles-identical " - f"guard in main()). So all seven is already the " - f"widest a rule can be, and that is the widening " - f"this check refuses") - # LAST of the family, deliberately. This rejects a - # WELL-FORMED `fields` that simply has no name beside it, so it - # must not pre-empt the three checks above, each of which buys a - # precise message for a `fields` that is malformed -- empty, not - # a list, not roles. Written directly under the neither-key - # check (where the shapes are cousins) it did exactly that: an - # empty `fields` reported #451 instead of "empty 'fields'", and - # the parametrized cases pinning those messages had to grow a - # name_regex to keep reaching them (#453 review). - if has_fields and not has_regex: - raise SystemExit( - f"{where} has 'fields' but no 'name_regex' (#451). With " - f"no name narrowing, the rule claims every name whose " - f"diff fits its 'fields' -- and no guard can see that " - f"grow: _CORPUS_CLAIMS records a regexless rule's reach " - f"as the WHOLE CORPUS, so it starts at its maximum and " - f"arrivals never move it. The one rule ever shaped this " - f"way grew from 4 explained names to 14, across six " - f"unrelated behavior families, with every guard green " - f"the whole time. Narrow by name instead, or split this " - f"into the rules the diffs actually need") - if has_regex and not has_fields: - raise SystemExit( - f"{where} has 'name_regex' but no 'fields' (#456). It " - f"narrows by name and by nothing else, so on any name " - f"its regex reaches it claims EVERY diff shape there is -- " - f"256 of them from baseline 2.0 on, where `_ambiguities` " - f"joins the seven roles and `_initials` adds the one " - f"shape that stands alone, and 128 below it. #452 makes " - f"that worse than it looks: " - f"over_declared_rules skips a rule with no 'fields', " - f"correctly, since one declaring no roles cannot " - f"over-declare them -- so deleting the line is the " - f"cheapest way to silence an OVER-DECLARED failure and " - f"the most permissive thing you can do to the rule at " - f"the same time. Name the roles the diffs actually " - f"move. Do NOT reach for the other shape instead: a " - f"'fields' with no 'name_regex' is banned by #451 for " - f"the mirror-image reason, and the two bans are each " - f"other's obvious wrong answer") - - -def validate_exclusions(entries: list[dict[str, object]], - ledger: str) -> None: - """Reject malformed [[never]] entries LOUDLY at startup. - - An exclusion is the absorption bug pointed the other way. A rule - that matches too widely turns a regression into a classified diff; - an exclusion that matches too widely turns a legitimate - classification into UNEXPLAINED, which reads as a catastrophic - regression rather than as a bad exclusion. So the checks mirror - validate_rules', with one addition: an entry whose `examples` do - not match its own `name_regex` protects nothing while looking - complete. Nothing else says so at startup, which is where a - silently-inert entry most needs saying. - - `fields` is optional and narrows WHICH READING is protected, the - same subset test the rules use. It exists because ASCII parens mark - nicknames, maiden names, suffixes and credentials alike -- a - name-only exclusion for the nickname promise would also silence - every diff on 'Jenny (Johnson) Baker' and 'Lon (Jr.) Williams', - whose parens are a maiden name and a suffix. That does not HIDE a - regression there -- an excluded name reports UNEXPLAINED, which - exits non-zero -- it makes those names permanently unexplainable, - so an intended change in an area under active development can - never be recorded and every release blocks on the same false - alarm. Typographic delimiters carry no such ambiguity, which is - why feat(#273)'s own rule can be a bare character class. - """ - allowed = {"why", "name_regex", "examples", "fields"} - for index, entry in enumerate(entries): - where = f"{ledger} exclusion #{index + 1}" - unknown = set(entry) - allowed - if unknown: - raise SystemExit( - f"{where} has unknown key(s) {sorted(unknown)}; expected " - f"{sorted(allowed)}. A misspelled key is silently ignored, " - f"which deletes whatever it was meant to declare.") - why = entry.get("why") - if not isinstance(why, str) or not why: - raise SystemExit( - f"{where} has no string 'why'. An exclusion nobody can " - f"justify is one nobody can safely delete.") - pattern = entry.get("name_regex") - if not isinstance(pattern, str) or not pattern: - raise SystemExit(f"{where} has no string 'name_regex'") - try: - compiled = re.compile(pattern) - except re.error as exc: - raise SystemExit( - f"{where} has an invalid 'name_regex' ({exc})") from None - if all(compiled.search(s) for s in _SENTINELS): - raise SystemExit( - f"{where}'s 'name_regex' matches every one of " - f"{list(_SENTINELS)} -- it would silence the whole ledger, " - f"reporting every diff as unexplained.") - examples = entry.get("examples") - if examples is None or examples == []: - raise SystemExit( - f"{where} has no 'examples'. They are the entry's test " - f"data: a protected shape need not be in any corpus, so " - f"nothing else can supply one.") - if (not isinstance(examples, list) - or not all(isinstance(e, str) for e in examples)): - raise SystemExit( - f"{where}'s 'examples' is not a list of strings") - stray = [e for e in examples if not compiled.search(e)] - if stray: - raise SystemExit( - f"{where} lists {stray} which does not match its own " - f"'name_regex' -- the entry would protect nothing while " - f"looking complete.") - if "fields" in entry: - fields = entry["fields"] - if not isinstance(fields, list) \ - or not all(isinstance(f, str) for f in fields): - raise SystemExit( - f"{where} has a 'fields' that is not a list of " - f"strings ({fields!r}); classify would ignore it and " - f"the entry would silence EVERY diff on a matching " - f"name, not the reading it names") - if not fields: - raise SystemExit( - f"{where} has an empty 'fields', which can never " - f"match any diff -- an exclusion that protects " - f"nothing") - bad = sorted(set(fields) - _RULE_FIELDS) - if bad: - raise SystemExit( - f"{where} names {bad} in 'fields', which are not " - f"roles; expected from {sorted(_RULE_FIELDS)}") - # `others` rather than len(fields) > 1, as in - # validate_rules: a repeated '_initials' is longer than - # one and mixes nothing, and printed an empty list. - dups = sorted({f for f in fields if fields.count(f) > 1}) - if dups: - raise SystemExit( - f"{where} repeats {dups} in 'fields'. classify() " - f"reads 'fields' as a set, so the repeat changes " - f"nothing it matches -- it is a copy-paste slip that " - f"would otherwise pass every check below silently, " - f"and the '_initials' check in particular would read " - f"['_initials', '_initials'] as '_initials' alone") - others = sorted(set(fields) - {"_initials"}) - if "_initials" in fields and others: - raise SystemExit( - f"{where} lists '_initials' beside " - f"{others} in 'fields'. " - f"'_initials' enters a diff only when every role and " - f"the ambiguity kinds agree (#484), so no diff can " - f"carry it with another field: the '_initials' half " - f"of this entry is silently dead. Give the " - f"initials-only shape its own exclusion with " - f"fields = ['_initials']") - if set(V2_FIELDS) <= set(fields): - raise SystemExit( - f"{where} lists all seven roles in 'fields', which " - f"is what omitting the key already means. omit " - f"'fields' to exclude any diff on a matching name") - - -class _Contest(NamedTuple): - """Two rules that file order alone separates (#382). - - `earlier` outranks `later` purely by position: there are diffs - both rules admit, so `classify()` returns the first one it reaches. - """ - #: issue of the earlier, WIDER rule -- the one that wins today - earlier: str - #: issue of the later, NARROWER rule - later: str - #: corpus names both `name_regex`es reach, sorted - names: tuple[str, ...] - - -class _Reach(NamedTuple): - """What one rule may claim, on each key _entry_matches narrows by.""" - issue: str - fields: frozenset[str] - #: corpus names its `name_regex` reaches - names: frozenset[str] - #: the orders it admits, or None when it declares none and so - #: admits every order. _legal_orders() IS the set of every order, - #: and substituting it here would still be wrong: _entry_matches - #: reads an absent `orders` off the key's ABSENCE rather than off a - #: member list, so on a rule list validate_rules never saw -- the - #: hand-built ones the tests pass in -- the two readings diverge. A - #: rule with `orders = ["MADE_UP"]` intersects _legal_orders() to - #: the empty set and would stop being a contest, where classify() - #: would happily run it against a real comparison and it IS one. - orders: frozenset[str] | None - - -def _rule_reach(rules: list[dict[str, object]], - names: list[str]) -> list[_Reach | None]: - """Per rule: its issue, and what it may claim on each narrowing key. - - None for a rule this check cannot reason about -- a missing or - mistyped `name_regex` or `fields`, or an empty `fields`. Every one - of those is already refused by validate_rules with a better - message; skipping rather than raising keeps this function usable on - the hand-built rule lists the tests pass it, and an empty `fields` - is skipped for a second reason: the empty set is a strict subset of - every other, so admitting it would report a contest against every - rule in the file. - - A mistyped `orders` is read as ABSENT rather than skipping the - rule, which is what _entry_matches does with one: it tests - `isinstance(orders, list)` and so returns a non-list rule to - claiming every order. validate_rules rejects that shape too, and - an empty `orders` besides -- so a frozenset here is never empty, - and "declares orders" always means a real restriction. - """ - out: list[_Reach | None] = [] - for rule in rules: - pattern, fields = rule.get("name_regex"), rule.get("fields") - if (not isinstance(pattern, str) or not isinstance(fields, list) - or not fields or not all(isinstance(f, str) for f in fields)): - out.append(None) - continue - orders = rule.get("orders") - matcher = re.compile(pattern) - out.append(_Reach( - str(rule.get("issue", "")), frozenset(fields), - frozenset(n for n in names if matcher.search(n)), - frozenset(orders) if isinstance(orders, list) else None)) - return out - - -def order_contests(rules: list[dict[str, object]], - names: list[str]) -> list[_Contest]: - """Every pair whose winner file order decides, exemptions IGNORED (#382). - - The predicate needs no diff shapes and that is what makes it cheap. - It asks the three questions _entry_matches asks, one per narrowing - key, and a pair is a contest only where all three overlap: - - `fields` -- where the later rule's are a STRICT subset of the - earlier one's, every diff D fitting the narrower set passes both - rules' subset test. The nesting supplies the contested shape's - EXISTENCE, which is why no diff has to be computed: doing that - properly would need the pinned-wheel worker pass, and could only - ever remove pairs from this list, never add one. - - `name_regex` -- some corpus name must reach both, which the corpus - supplies. - - `orders` -- some order must reach both. Two rules scoped to - disjoint orders never see the same comparison, so file order - decides nothing between them however nested their `fields` are, - and calling that a contest would demand a justification for a - hazard that cannot occur. A rule declaring no `orders` is - order-blind and overlaps every other, which is every rule in every - shipped ledger today. - - Equal `fields` are deliberately not a contest: neither rule is - narrower, so "narrow-first" says nothing about the pair and - _CROSS_RULE_WINNERS stays the instrument there. - - Read `precedes_narrower` through undeclared_contests, not here. - This function is what the recorded negative control measures, and a - control that consulted the mechanism it controls for measures - nothing. - """ - reach = _rule_reach(rules, names) - found: list[_Contest] = [] - for i, j in itertools.combinations(range(len(rules)), 2): - a, b = reach[i], reach[j] - if a is None or b is None: - continue - if not b.fields < a.fields: - continue - if a.orders is not None and b.orders is not None \ - and not a.orders & b.orders: - continue - shared = a.names & b.names - if shared: - found.append(_Contest(a.issue, b.issue, tuple(sorted(shared)))) - return found - - -def _declared_over(rule: dict[str, object]) -> frozenset[str]: - """Issues this rule declares precedence over (#382). - - Shape is validate_rules' business and this reader TRUSTS that it - ran: main() validates every ledger before reaching any of this, and - test_validate_rules_accepts_the_shipped_ledgers covers the files on - disk. The leniency exists so the function stays usable on the - hand-built rule lists the tests pass it. It is tempting to write - it up as a safety property; it is not one. Of the shapes - validate_rules refuses, four are refused toward REPORTING the - contest -- a non-list value, an EMPTY list, an entry that is not a - table, and an entry whose `issue` is not a string -- since each - leaves that entry out of the set and a smaller set declares less. - Two go the other way. An entry naming a real rule with a missing or - blank `why` reads here as a perfectly good declaration and retires - the pair; so does one whose `issue` is the EMPTY string, which - validate_rules refuses as a blanket opt-out but the - `isinstance(str)` test here admits -- and which retires the pair - against any rule whose own issue reads as "", the shape - `str(rule.get("issue", ""))` gives an issue-less hand-built rule. - The blank `why` is the likeliest hand-edit slip in a ledger, and - nothing in this function catches either of the two. - - So the guarantee is borrowed, not intrinsic. Reading strictly here - would be no less safe -- a stricter reader declares LESS and so can - only report MORE -- and the reason not to is convenience for - callers, which is a much smaller claim than "the safe direction". - """ - declared = rule.get("precedes_narrower") - if not isinstance(declared, list): - return frozenset() - return frozenset( - e["issue"] for e in declared - if isinstance(e, dict) and isinstance(e.get("issue"), str)) - - -def undeclared_contests(rules: list[dict[str, object]], - names: list[str]) -> list[_Contest]: - """Contests whose earlier rule does not declare the later one (#382). - - The declaration is read off the rule that WINS the pair, which is - the earlier one: an exemption is that rule saying it means to - outrank its narrower neighbour, so it is the only rule whose word - can retire the pair. - - `by_issue` is last-wins, so two rules sharing an issue string would - let a declaration on the second copy retire a contest the first - copy owns. validate_rules refuses duplicate issues, which is the - only reason that is unreachable -- the same borrowed guarantee - main()'s `rules_by_issue` leans on, and the same one _declared_over - leans on for shape. - """ - by_issue = {str(r.get("issue", "")): r for r in rules} - return [c for c in order_contests(rules, names) - if c.later not in _declared_over(by_issue.get(c.earlier, {}))] - - -class _Vacancy(NamedTuple): - """An exemption whose pair stopped being a contest (#382). - - Named rather than a bare pair for _Dormancy's reason: the caller - formats these into a message, and `v.earlier`/`v.later` says which - end is which where `v[0]`/`v[1]` would not. - """ - #: issue of the rule carrying the declaration - earlier: str - #: issue it declares precedence over - later: str - - -def vacant_exemptions(rules: list[dict[str, object]], - names: list[str]) -> list[_Vacancy]: - """Declared precedences over a pair that is NOT a contest (#382). - - A rule narrowed until it no longer overlaps its neighbour leaves - its exemption behind, and the file then carries a justification for - a hazard that is gone -- indistinguishable, to a reader, from one - that is live. Same shape as `dormant`'s awake check: the ledger - states a condition, and the harness refuses to let it go on - standing after the condition stops holding. - """ - live = {(c.earlier, c.later) for c in order_contests(rules, names)} - return [_Vacancy(str(rule.get("issue", "")), later) - for rule in rules - for later in sorted(_declared_over(rule)) - if (str(rule.get("issue", "")), later) not in live] - - -def _entry_matches(rule: dict[str, object], name: str, - diff_fields: set[str], order: str | None = None) -> bool: - """Does this entry's narrowing admit this diff? - - Called twice in classify() -- once for exclusions, once for rules -- - and again in dormant_rules(). All three narrow on the same keys, - and the dormancy diagnosis is only meaningful if it asks the - question classify asks, so there is one predicate rather than three - copies of it. - - `order` is the name_order the COMPARISON ran under (None = the - default order), and a rule carrying `orders` admits only the orders - it lists. A comparison under the default order is matched by the - "DEFAULT" sentinel, there being no constant to name and no null to - put in a TOML array. Without that narrowing a rule is order-blind, - which is what every rule written before shape-tagged entries - existed is: the key is optional and its absence is today's - behavior -- and the absence is not free, since an order-blind rule - reaching an order-bearing name absorbs that name's order-only - regressions (main() prints an ORDER-BLIND notice where it sees - that happen). It matters - because a name can be compared twice, once per order, and the two - diffs can have the SAME fields for opposite reasons -- the - feat(#395) fold moving {family, given, middle} under a declared - family-first order is intended, and the same three roles moving on - the same string under the DEFAULT order would be that fold leaking - where it must not, which an order-blind rule would absorb and call - intentional (#372's failure mode, on the most plausible regression - of the very change the rule describes). - - A non-str `name_regex`, non-list `fields` or non-list `orders` is - IGNORED rather than rejected here: validate_rules and - validate_exclusions reject them at startup, and duplicating that - judgement in the hot path would put the two in a position to - disagree. - """ - name_regex = rule.get("name_regex") - if isinstance(name_regex, str) and not re.search(name_regex, name): - return False - fields = rule.get("fields") - if isinstance(fields, list) and not diff_fields <= set(fields): - return False - orders = rule.get("orders") - if isinstance(orders, list) \ - and (_DEFAULT_ORDER if order is None else order) not in orders: - return False - return True - - -def classify(name: str, diff_fields: set[str], - rules: list[dict[str, object]], - exclusions: list[dict[str, object]] | None = None, - order: str | None = None) -> str | None: - """Which rule explains this diff, or None if nothing does. - - Exclusions are consulted FIRST and win outright. They are the - ledger's way of saying a shape must never be explained, which the - rule vocabulary cannot express: a rule says "this diff is intended - and here is why", and there is no rule meaning "whatever happens - here is a regression". Two comments in expected_since_1.4.0.toml - promised exactly that in prose and could not keep it (#328). - - Consulting them first also makes them MONOTONE -- an exclusion only - ever removes a name from classification, never moves it between - rules -- so a new entry's blast radius is exactly the set of names - it captures, independent of rule order. - - An exclusion narrows by `name_regex` and optionally by `fields`. - The optionality is the exclusion's alone since #456: a RULE must - now carry both. Without `fields` it refuses any diff on a - matching name; with them it refuses only the reading it names, so a - name whose parens mark a nickname to one rule and a suffix to - another stays classifiable on the reading the exclusion is not - about. - - `order` is the name_order this comparison ran under and narrows the - RULES alone: exclusions have no `orders` key (validate_exclusions - rejects one as unknown), so they stay order-blind, deliberately. - An over-wide exclusion is loud rather than silent -- refusal is - monotone, so the widest thing it can do is make a name report - UNEXPLAINED and fail the run -- and no exclusion on the books - protects a shape that reads differently under a declared order. The - key can be given to them the day one does. - """ - for entry in exclusions or (): - if _entry_matches(entry, name, diff_fields): - return None - for rule in rules: - if _entry_matches(rule, name, diff_fields, order): - return rule["issue"] # type: ignore[return-value] - return None - - -#: How each `_Dormant.kind` reads in the report. Here rather than in -#: dormant_rules so the computation never has to know the wording, and -#: rephrasing one is a change to output alone. -_DORMANT_WHY = { - "reverted": ("matched no diffing name -- the behavior it describes " - "may have been reverted"), - "shadowed": "shadowed by {by}", - "excluded": ("every diffing name it matches is refused by a " - "[[never]] exclusion"), -} - - -class _Dormant(NamedTuple): - """One rule that explained nothing, and why. - - `kind` rather than a sentence because the three have three different - fixes, and a caller that wants to tell them apart should not have to - parse prose to do it. The wording lives in main(), which is the only - place that renders it -- so rephrasing a diagnosis stays a change to - output alone, and the tests that pin the DISTINCTION keep working. - """ - issue: str - kind: Literal["reverted", "shadowed", "excluded"] - #: the issue that claimed it, when `kind` is "shadowed"; else "" - detail: str - - -class _Dormancy(NamedTuple): - """What a run found out about rules that explained nothing.""" - #: every rule that explained nothing and does not declare `dormant` - undeclared: tuple[_Dormant, ...] - #: issues declaring `dormant` that explained at least one diff - awake: tuple[str, ...] - - -def dormant_rules(rules: list[dict[str, object]], explained: set[str], - diffing: list[tuple[str, set[str], str | None]], - exclusions: list[dict[str, object]] | None = None, - ) -> _Dormancy: - """Which rules explained nothing, and which kind of nothing. - - A rule going inert is invisible to every other guard here. - _CORPUS_CLAIMS records what a rule's REGEX reaches, which is - parser-independent, so reverting the fix a rule describes leaves the - rule matching exactly as many names as before while it explains no - diff at all -- and the run exits 0 (#372). - - Pure on purpose: it needs only values main() already derives per - name, and no second baseline run. Wiring it in means threading the - diff-fields set through the existing loop, not adding new I/O. A - check reachable only through a full baseline run is a check nobody - mutates, and this tree has a long list of measurements that ran, - printed a plausible number, and measured nothing. - - Three diagnoses, because they have three different fixes: - reverted -- matches no diffing name; the behavior is likely gone - shadowed -- an earlier rule claimed every diff it would have - excluded -- a [[never]] entry refuses every name it matches - """ - # classify() must be asked in the order main() asked it, or the - # shadower named here is not the rule that actually won. Sorting - # internally makes that true whatever the caller passes; the sort is - # stable and idempotent, so doing it twice costs nothing. - ordered = _sorted_rules(rules) - undeclared: list[tuple[str, str]] = [] - awake: list[str] = [] - for rule in ordered: - issue = str(rule["issue"]) - declared = "dormant" in rule - if issue in explained: - if declared: - awake.append(issue) - continue - if declared: - continue - # each diff carries the ORDER its comparison ran under, so a - # rule scoped by `orders` is asked the question classify asks - # it: a rule that would claim a name only under FAMILY_FIRST is - # not "shadowed" by whatever explains that name's default-order - # diff - matched = [(n, d, o) for n, d, o in diffing - if _entry_matches(rule, n, d, o)] - winners = Counter( - c for c in (classify(n, d, ordered, exclusions, o) - for n, d, o in matched) if c is not None) - if not matched: - undeclared.append(_Dormant(issue, "reverted", "")) - elif winners: - undeclared.append( - _Dormant(issue, "shadowed", winners.most_common(1)[0][0])) - else: - undeclared.append(_Dormant(issue, "excluded", "")) - return _Dormancy(tuple(undeclared), tuple(awake)) - - -class _OverDeclared(NamedTuple): - """One rule declaring a role nothing it explains moves. - - `unused` is the defect. `observed` is the repair -- the union of the - diffs the rule explained, which is what `fields` should say. - """ - issue: str - #: declared roles no explained diff moves, sorted - unused: tuple[str, ...] - #: the union of the diffs it explains, sorted; the correct `fields` - observed: tuple[str, ...] - - -def over_declared_rules( - rules: list[dict[str, object]], - roles_by_issue: dict[str, set[str]]) -> tuple[_OverDeclared, ...]: - """Rules whose declared `fields` exceed every diff they explain. - - classify() takes the first rule whose `fields` are a SUPERSET of the - observed diff, so a rule keeps matching when the diff beneath it - SHRINKS -- and shrinking is the common direction, most parser fixes - moving fewer roles rather than more. #410 narrowed - 'Freiherr von Richthofen V' from three roles to two while the - fix(#424) rule declaring all three kept claiming it, and no run - named the movement (decisions.md#H1). That rule was narrowed by - hand; this is what would have said so. - - The statement is exact rather than heuristic. classify() REQUIRES - `declared >= union(explained diffs)` -- declaring less would stop - the rule matching a name it explains -- so the only possible error - is the other direction, and `declared == union` is the whole check. - The union is also the repair, and narrowing to it cannot orphan a - name: every name the rule explains contributed to it. - - Three rules are skipped, none as an exemption. A rule declaring - `dormant` is dormant_rules' finding in BOTH directions -- including - when it has explained a diff, which that check reports as NO LONGER - DORMANT: one defect with one remedy (remove the key), where - reporting it here too would demand a second, contradictory one. - (Do not say "a dormant rule has no union to compare against" -- it - can have one, and the test that pins this skip uses exactly that - input, because the empty-union input cannot discriminate.) A rule - with no `fields` declares no roles and so has nothing to - over-declare -- and cannot exist since #456, which banned that - shape precisely because this skip made deleting `fields` the - cheapest way to silence the check. One with `fields` and no - `name_regex` cannot exist since #451. And a rule that explained nothing is dormant_rules' - too, which is the third `continue` below. - - What this does NOT bound is a diff shape no single name produced. - A rule declaring {family, suffix} where one name moves `family` - and another moves `suffix` passes -- the union is both -- while - still standing ready to claim a name diffing the two together. The - union is a per-RULE bound, not a per-name one, and narrowing - further would orphan one of the two. The #452 hazard survives in - that reduced form by choice. - - One caveat for a partial run: under `--corpus` the union is - computed over the names actually compared, so a subset run can - report a rule that is correctly declared for the full gate, with a - `observed` repair that would orphan a name on the next full run. - The report says so. - - Pure, like dormant_rules: it needs only values main() already - derives, so it is testable without a corpus or a baseline worker. - """ - found: list[_OverDeclared] = [] - for rule in rules: - declared = rule.get("fields") - if "dormant" in rule or not isinstance(declared, list): - continue - moved = roles_by_issue.get(str(rule["issue"])) - if moved is None: - # explains nothing -- dormant_rules owns that finding. - # `is None` rather than falsy: an empty union cannot reach - # this dict today (main() skips a name with no diff), and if - # that ever changes an empty one should be REPORTED rather - # than silently skipped. - continue - unused = tuple(sorted(set(declared) - moved)) - if unused: - found.append(_OverDeclared( - str(rule["issue"]), unused, tuple(sorted(moved)))) - return tuple(found) - - -class _ShapeMismatch(NamedTuple): - """A recorded diff shape the run disagrees with (#497). - - Named rather than a bare triple for _Vacancy's reason: the caller - formats these into a message, and `m.recorded`/`m.measured` says - which side is which where `m[1]`/`m[2]` would not. - """ - name: str - #: the shape the roster records. Sorted HERE, at construction, like - #: `measured` beside it -- a property of the instance and NOT a - #: requirement on the literal row: a row spelled in any other order - #: sorts to the same tuple and compares equal, so a sortedness - #: guard over _RECORDED_DIFFS would refuse nothing and pin nothing. - #: test_a_recorded_shape_matching_the_run_is_no_mismatch pins both - #: sides of that. - recorded: tuple[str, ...] - #: what this run measured, sorted; None when the run produced no - #: default-order diff of the name at all -- TWO states reach that, - #: deliberately collapsed, and the docstring says what a message - #: over one may and may not claim - measured: tuple[str, ...] | None - - -#: The recorded diff shape of every name _CROSS_RULE_WINNERS pins -#: (tests/v2/test_ledger_guards.py), per ledger (#497). -#: -#: Baseline-relative by construction, which is why it is keyed per -#: ledger rather than once by name: a string moves a different set of -#: roles against different baselines, and decisions.md (2026-08-28 -#: #452) records `fix(#296) a lone post-comma credential is a suffix` -#: moving four roles at 1.4.0 and two at both 2.x baselines. Since #501 -#: the keying carries its own witness rather than resting on that -#: argument alone: 'MD, PHD' has a row in three sections, diffing -#: {family, given, suffix, title} at 1.4.0 and {suffix, title} at both -#: 2.x baselines, so one by-name keying could not hold what it does. -#: -#: A recorded shape that MOVES is a FINDING, not a number to update, -#: and it names no cause because it cannot: the parser may have changed -#: what that name does, or the row may have been wrong when it was -#: recorded, which is what the provenance below found four times. -#: Either way the winner pinned beside it in _CROSS_RULE_WINNERS was -#: recorded for the OLD shape, so both want reading before either is -#: edited. -#: -#: Every row here HAS such a winner, and this dict and -#: _CROSS_RULE_WINNERS are held to name the same strings. A shape -#: measured for a name nothing pins a winner for does not go here: -#: _WATCHED_DIFFS below is the roster for a shape with no argument -#: behind it, and this dict and that one are kept disjoint. -#: -#: HERE rather than beside the roster because this is where the -#: measurement happens. main() computes every name's real diff, so a -#: run can check these; the unit suite cannot, and deliberately -- it -#: spawns no uv and no network -- tests/v2/test_differential.py says -#: so in its own header, and the ones that need a baseline fake -#: _run_worker or fake Popen beneath it rather than installing a -#: wheel. Not "every test monkeypatches _run_worker": most never -#: mention it, and that loose paraphrase is #497's own subject -#: arriving inside #497's evidence. Same -#: direction as _CORPUS_FLOORS above, which lives here and is read from -#: there. -#: -#: Default-order shapes only, because the roster classifies with no -#: order. recorded_diff_mismatches below says what that leaves out. -#: -#: PROVENANCE. The 47 rows at 1.4.0 are measured against the 1.4.0 -#: wheel, as the roster always claimed, and all 47 still agree -- the -#: original 31 re-measured 2026-09-03, the fourteen #498 added measured -#: 2026-09-05 by the sweep that found them, #528's two measured -#: 2026-09-13 by the run that reported their contest, and every one of -#: the 47 checked by the same recompute: drive main() at all four -#: baselines and feed its `diffing` and its post-skip corpus to -#: recorded_diff_mismatches, wrapping _run_worker to -#: capture the post-skip entries and dormant_rules to capture `diffing`, -#: since both receive exactly what main() built. -#: -#: The 2.x sections were EMPTY until #501, which is the question the -#: position recorded there was waiting on: contests were measured to -#: exist and nobody had argued a winner for one. Six are argued now -#: (2026-09-05), five at 2.0.0 and one at 2.1.0, and their shapes moved -#: in from _WATCHED_DIFFS unchanged -- a watched row is measured by a -#: run in the first place, which is what makes the move a move rather -#: than a fresh recording. _CROSS_RULE_WINNERS carries the argument per -#: row. -#: -#: The four rows #452 put in these sections, two per 2.x ledger, are a -#: different story and stay deleted. #497 ran that recompute against -#: them and all four recorded a shape no run makes: -#: 'Nguyen, Van' produces no diff at ANY of the four baselines. It is -#: compared under the default order out of corpus_rules.jsonl every -#: time, and the tree agrees with all four wheels on it, so {family} -#: is a shape no run makes and no run ever asked classify() about the -#: name at all. That is the position 'Doe,, Jr.' is in, which the -#: roster gives no row precisely because it does not diff. -#: 'Jane née and Jones Smith' diffs {family, given, maiden, middle} at -#: 1.4.0, 2.0.0 and 2.1.0, and not at all at 2.2.0 -- never the -#: {family, maiden, middle} recorded. Its shape WAS correctable, and -#: the rows went anyway: the string is a malformed harvest from -#: corpus_issues.jsonl (radar), the tree reads it family 'Jane' / -#: maiden 'and Jones Smith', and nobody can state what it ought to -#: parse to, so a pin on it defends no boundary anyone would argue -#: for. In both 2.x ledgers fix(#445) is the only rule admitting it at -#: either shape, measured, so no pin moved either way. -#: Deleting a row removes a PIN, not a name: corpus_issues.jsonl is -#: append-only and both strings are still compared, and classified, on -#: every run. 'Nguyen, Van' is classified by nothing only because it -#: diffs from nothing. -_RECORDED_DIFFS: dict[str, dict[str, tuple[str, ...]]] = { - "expected_since_2.2.0.toml": {}, - # The 2.4 cycle carried #383/#479's three rules from 2026-09-13, - # and no contest among them: each is a literal name or - # alternation of names, and no two reach the same corpus name, - # so the run finds no contested diff to adjudicate. - "expected_since_2.3.0.toml": {}, - # open cycle: no rules, so nothing for a second one to contest - "expected_since_2.4.0.toml": {}, - "expected_since_1.4.0.toml": { - "Andrews, M.D.": ("given", "suffix"), - # #613's PR review (2026-10-06): a tolerated CJK case row, H1's - # title-and-one-word reading behind a comma; both CJK rules - # admit its {family, given} - "Dr 김민준, MA": ("family", "given"), - "田中, 太郎さん": ("given", "suffix"), - "김, 민준씨": ("given", "suffix"), - "김, 민준씨 (Jimmy)": ("given", "suffix"), - "김민준, 씨": ("given", "suffix"), - "김민준, 씨.": ("given", "suffix"), - "선생님, J.씨": ("given", "suffix"), - "이, J.씨": ("given", "suffix"), - "Dr 김민준씨, V.": ("family", "given", "suffix"), - "田中さん, PhD": ("family", "given", "suffix", "title"), - "田中さん, V.": ("family", "suffix"), - "Bob Jones, author": ("family", "given"), - "MD, PHD": ("family", "given", "suffix", "title"), - "Smith Jr.": ("family", "suffix"), - "Carod i": ("family", "suffix"), - "田中さん II": ("family", "given", "suffix"), - "de los Santos": ("_initials",), - "van Berg Jan de": ("_initials",), - "van ma van": ("_initials",), - "Andersonさん": ("given", "suffix"), - "김민준씨": ("family", "given", "suffix"), - "김민준 씨.": ("family", "given", "suffix"), - ".,": ("given",), - "田中さん, Ph. D.": ("family", "given", "suffix"), - "Kim, Jr.": ("family", "given", "suffix", "title"), - "Smith, Jr.": ("family", "given", "suffix", "title"), - "김민준씨 Jr.": ("family", "given", "suffix"), - "de la Vega y Santos Juan": ("_initials",), - "Abu Bakr Al Baghdadi, MD": ("_initials",), - "abu bakr al baghdadi": ("_initials",), - "Berg, abdul van": ("_initials",), - # #498's fourteen, adjudicated 2026-09-05: every 1.4.0 diff - # two or more rules admitted, where the winner beats a loser - # by neither narrow-first nesting nor a `precedes_narrower` - # declaration, and nothing pinned it. Shapes measured against - # the 1.4.0 wheel by the sweep that found them, not guessed; - # the five that were _WATCHED_DIFFS rows moved here with their - # shapes unchanged, which is what this dict's NO WINNER clause - # below says happens the day a winner is argued. - "マイケル・ジャクソン": ("family", "given"), - "威廉・莎士比亚": ("family", "given"), - "高橋・一郎": ("family", "given"), - "Dr 김민준, Jr.": ("family", "given"), - "田中さん, Dr.": ("family", "given"), - "田中さん, 様.": ("family", "given", "suffix"), - "Bob Jones, compositeur": ("family", "given"), - "MD, DO, DDS": ("given", "title"), - "Smith, Ph. D. III": ("given", "suffix"), - "Smith, Ph. D. Jr.": ("suffix", "title"), - "Smith, Ph. D. Jr. MD": ("given", "suffix", "title"), - "Smith, Ph. D. MD": ("suffix", "title"), - "Smith, Ph.D. Jr.": ("given", "suffix"), - "Smith, PhD Jr.": ("given", "suffix", "title"), - # #544, 2026-09-27: the same contest and the same argument as - # 'Smith, PhD Jr.' above -- the unambiguous PhD anchors the - # MEng behind it, so the run collapses whole into `suffix`. - "Smith, PhD MEng": ("given", "suffix", "title"), - # 2026-09-28: 'Smith, PhD Ma', the same run with the Title-case - # member of the older marking, on the same terms. - "Smith, PhD Ma": ("given", "suffix", "title"), - # #528's two, adjudicated 2026-09-13, and kept BELOW #498's - # block so the three cohorts read down the dict in the order - # the PROVENANCE note above tells them. Both are contested by - # `fix(#528) the facade's initials follow the parse's - # connective tags` against `fix(initials-per-word) a - # connective run initials each word`: equal `fields`, so - # neither is narrower, `precedes_narrower` has no narrower - # rule to name, and file order is the whole decision. The - # shapes are this run's, not guessed -- the gate reported the - # pair as an unpinned contest and these are the diffs it - # measured. The winners are pinned in _CROSS_RULE_WINNERS. - "john e smith": ("_initials",), - "john e jones": ("_initials",), - # #461's eleven, adjudicated 2026-09-20, below #528's pair and - # contested against the same rule for the same structural - # reason: `fix(#461) a connective holding its part alone - # contributes an initial` and `fix(initials-per-word) a - # connective run initials each word` both carry - # `fields = ["_initials"]`, so neither is narrower, - # `precedes_narrower` has no narrower rule to name, and file - # order is the whole decision. 'Carod y de Rovira i' carries - # TWO losers -- the particle-chain per-word rule reaches it - # through 'de Rovira' -- and one row covers both, as this - # dict's header says a row does. - # - # The ARGUMENT for the winner, and it is the same on all - # eleven: 1.4.0 rendered each of these names with the SAME - # granularity the tree does ('J. G.', never 'J G.'), so the - # per-word grouping moved nothing on them; what moved is the - # letter count, the connective holding its part alone - # contributing an initial again. Measured 2026-09-20 against - # the 1.4.0 wheel and against the parent 46651750: every one - # of the eleven AGREED with 1.4.0 before this bundle and - # diverges from it now, which no rule about a 2.0.0 rendering - # change can be describing. Deleting or narrowing the #461 - # rule hands them back to the grouping rule silently, which is - # the handover these rows exist to catch. - "Carod y de Rovira i": ("_initials",), - "Garcia y Lopez": ("_initials",), - "John e Smith": ("_initials",), - "John e Smith III": ("_initials",), - "John e Smith, III": ("_initials",), - "John y Jane": ("_initials",), - "Jose e Maria": ("_initials",), - "Juan y Garcia": ("_initials",), - "Lt.Gov. juan e garcia": ("_initials",), - "john e jones, III": ("_initials",), - "juan y garcia": ("_initials",), - # #289/#516's one, adjudicated 2026-09-18, below both cohorts - # above for the same provenance reason. '田中 太郎, MA' entered - # the corpus with this arc's own case row (radar tier, a - # composed CJK-plus-Latin comma form the writing system does - # not produce), and two rules admit its {family, given} diff: - # the compound CJK rule and the native-script order rule. Their - # `fields` OVERLAP without nesting, so neither is the narrower - # and `precedes_narrower` has nothing to say; file order is the - # whole decision. The shape is this run's, not guessed. - "田中 太郎, MA": ("family", "given"), - # #289's one contract-tier contest at this baseline. 'Smith, - # MA' moves {given, suffix} -- v1 read given 'MA', family - # 'Smith', and the caps lean now reads family 'Smith', suffix - # 'MA' -- and two rules admit it: the lone-post-comma routing - # rule, whose Latin comma regex reaches every such name, and - # this arc's own. `fields` overlap on {given, suffix} without - # nesting, so neither is the narrower and file order is the - # whole decision. The shape is this run's, not guessed. - "Smith, MA": ("given", "suffix"), - }, - # #501's six, moved here from _WATCHED_DIFFS with their shapes - # unchanged. The four CJK rows sit at 2.0.0 alone: the honorific - # and order rules that contest them are 2.1 behavior, so those - # names diff at 1.4.0 and 2.0.0 and not against the 2.1.0 wheel at - # all. 'MD, PHD' diffs at all three and now carries a row at each. - # - # FOUR of the six ROWS sit on RADAR-tier names, and they are THREE - # distinct names -- '田中さん 様.' and '田中さん, 様.' in - # corpus_cjk_tolerated.jsonl and 'MD, PHD' in corpus_issues.jsonl, - # the last carrying a row at each 2.x baseline and so counting - # twice among the rows and once among the names (measured - # 2026-09-05) -- so a moved shape on them is fatal where a watched - # row would only have printed. That is the SEVERITY rule - # below applied as written and accepted deliberately (#501): a - # contest row is fatal on either tier because it carries an - # argument, and a moved shape has made that argument's premise - # false. The precedent is the section above, where 32 of the 45 - # rows are radar-tier names on exactly those terms (measured - # 2026-09-05). - "expected_since_2.0.0.toml": { - "田中さん 様.": ("family", "given", "suffix"), - "田中さん, 様.": ("family", "given", "suffix"), - "김민준 박사님": ("family", "given", "suffix"), - "선생님": ("family", "given"), - "MD, PHD": ("suffix", "title"), - }, - "expected_since_2.1.0.toml": { - "MD, PHD": ("suffix", "title"), - }, -} - - -#: The recorded diff shape of a name NO WINNER IS PINNED FOR, per -#: ledger. A second roster under a different contract from the one -#: above, and the difference is the whole reason it is a second dict -#: rather than more rows in the first: a shape beside a winner -#: ADJUDICATES a contest -- _RECORDED_DIFFS records what a name diffs -#: so that _CROSS_RULE_WINNERS can ask classify() which rule wins it -#: -- where a shape alone WATCHES the name without adjudicating it. -#: "No winner pinned" is the property every row here has and two -#: checks enforce -- the disjointness guard in -#: tests/v2/test_ledger_guards.py and main()'s `both` refusal; it is -#: not "nothing else watches" -- though since #501 took the five -#: contested rows to _RECORDED_DIFFS, every row here is in fact -#: sole-watched, and the roster and the population the sweep drew -#: (POPULATION below) are the same set. That is a fact about today's -#: rows and NOT a second contract: a name whose parse a Case(...) row -#: already pins is welcome here the day it is measured and no winner -#: is argued for it, which is the position all five held from -#: 2026-09-03 until #501 argued one. What sole-watched means: a radar -#: name that no test names and no -#: contract corpus holds is watched only by the classification rule -#: that explains its diff, and a rule is a weak watcher, asserting -#: that a diff here is intended and not what the diff IS, so a diff -#: that changes shape while staying inside the rule's `fields` moves -#: silently. A row here says what the diff is, and the move becomes -#: a finding. -#: -#: Four things follow from the split, and each is checked rather than -#: stated: -#: DISJOINT from _RECORDED_DIFFS, per ledger. A name is one kind of -#: row or the other. A name with BOTH an argument and no other -#: watcher belongs above, since the argument is the stronger claim. -#: test_the_watched_roster_is_disjoint_and_names_every_ledger -#: (tests/v2/test_ledger_guards.py) refuses an overlap at pytest -#: speed, and main() refuses it again pre-worker, because this tool -#: may not assume the suite ran. -#: NO WINNER, ever. A winner pin asserts an argument about which rule -#: should explain a name, and no row here carries one -- which is -#: what lets a row here be recorded from a measurement where a winner -#: cannot be. The day someone argues a winner, the row MOVES to -#: _RECORDED_DIFFS and the pin goes beside it there; it does not gain -#: a partner here. -#: MEASURED, from a run, and re-measured by every run. Everything -#: in main() that reads _RECORDED_DIFFS reads this dict too (the two -#: winner guards in tests/v2/test_ledger_guards.py read that dict -#: alone, correctly): the missing-section refusal pre-worker asks -#: each dict for a section, the departed-name refusal beside it and -#: the NOT CHECKED note read the literal UNION of the two, and the -#: shape comparison calls recorded_diff_mismatches once PER dict, -#: because the two halves carry different severities and the exit -#: code reads only one of them. A row in either dict that no run -#: can contradict is the defect #497 is about, whichever dict it -#: sits in. -#: SEVERITY follows the row's kind first and the name's tier second. -#: A contest row is fatal on either tier: it carries an argument, and -#: a moved shape has made that argument's premise false. A watched -#: row on a CONTRACT-tier name fails the run, as an unexplained diff -#: on that name would; on a RADAR-tier name it prints under MOVED -#: SHAPE (radar) and feeds no exit code. Fatal-on-radar is reserved -#: for a per-name deliberate choice -- a [[never]] entry with its -#: `why` (_CORPUS_TIERS), or a contest row with its pinned winner -- -#: and a measured snapshot is neither: the -#: only repair a fired snapshot admits is to re-snapshot, and a gate -#: whose repair is "record whatever it does now" is a changelog entry -#: in a gate's clothing. The tier is the DEFAULT-ORDER entry's when -#: the name has one, and the first-loaded entry's (contract whenever -#: any is) only when it does not -- a shape here is measured on the -#: default-order comparison, so that comparison's entry is the one -#: whose tier says whether the shape was promised, and a -#: baseline-scoped tier is a property of the ENTRY compared, not of -#: the files the string sits in (decisions.md, the rule-order arc). -#: So a name that is contract only under a declared order and radar -#: under the default reads radar here, and the declared-order promise -#: is untouched. -#: -#: POPULATION, measured 2026-09-05. A name has a row here where it is -#: SOLE-WATCHED at that baseline: held by a radar corpus and by no -#: contract corpus; named by no string literal anywhere under tests/ -#: outside test_ledger_guards.py -- every string ast.Constant, by exact -#: equality, since substring matching would score 'A. D.' a watcher of -#: every longer name containing it; diffing under the DEFAULT order at -#: that baseline AND explained by a ledger rule there -- a diff no -#: rule explains is already printed by every run as unclassified, so -#: a rule is the only weak watcher a row here is needed for (a -#: definition clause, not a live count: `radar unclassified` is 0 at -#: every baseline today, so the two sets coincide); and not already -#: keyed in _RECORDED_DIFFS for that -#: ledger. The last clause is the one a scan of tests/ cannot supply, -#: because that scan cannot see this file, and it excludes nine names -#: that carry contest rows: 'Bob Jones, author', 'Bob Jones, -#: compositeur', 'Carod i', 'MD, DO, DDS', 'MD, PHD', 'Smith, Ph. D. -#: Jr. MD', 'Smith, Ph. D. MD', 'Smith, Ph.D. Jr.', 'van ma van'. -#: SEVEN of them return at a 2.x baseline where they have -#: no contest row -- the five #498 pinned at 1.4.0 among them, each -#: still sole-watched at 2.0.0 and 2.1.0. The other two have no row in -#: this dict at all: -#: 'Carod i' diffs under the default order at 1.4.0 only, where its -#: contest row stands, and 'MD, PHD' carries a contest row at every one -#: of the three baselines it diffs at since #501 pinned its 2.x pair. -#: That is why the population is 48 names where the tests/-only scan -#: says 50. -#: AMENDED 2026-09-18 (#289/#516): the parenthetical above is dated -#: 2026-09-05 and stays as written, but its live half has stopped -#: being true. `radar unclassified` is now 0 / 5 / 6 / 7 / 7 at -#: 1.4.0 / 2.0.0 / 2.1.0 / 2.2.0 / 2.3.0, so the two sets no longer -#: coincide: a diffing radar name can be watched by no rule at all. -#: The seven at 2.3.0 are this arc's own unclassified SUFFIX_OR_NAME -#: reports -- 'Smith, E.T., Jr.', 'Smith, J.R.', 'Smith, A.P.', -#: 'MD, DO, DDS', '毛泽东, MA', '田中 太郎, MA', '마틴 킹, MA' -- each -#: a fork the ambiguous credential class now calls out loud at a slot -#: that was silent, on a name the contract does not answer for. The -#: POPULATION CLAUSE itself still holds unchanged, because it asks -#: for a name EXPLAINED by a ledger rule and these are explained by -#: none: an unclassified radar diff is already printed by every run, -#: which is the weak-watcher case the clause was written to exclude. -#: What is retired is only the claim that the two sets coincide. -#: Recounted 2026-09-07 with #342, which moved three names across the -#: literal clause at once: 'Aishwarya Rai' gained a case row and left -#: the population, while 'Lala Lajpat Rai' and 'John Smith, RAI' are -#: named nowhere under tests/ outside test_ledger_guards.py and -#: entered it, so the scan went 52 -> 53 and the roster 50 -> 51. -#: Recounted 2026-09-08 with the title-run bundle: one name entered the -#: population, 'John Smith Rev.', the only one of that bundle's -#: movers no test literal names -- the others on radar carry a -#: cases.py row or a v1 test -- and it took a row at each of the four -#: baselines, its shape measured by the run and the same at all four. -#: Re-checked 2026-09-09 in that bundle's second -#: review round, when its mover list went from twenty-one to -#: twenty-four: none of the three arrivals joins the population, all -#: three being named as case-row literals in tests/v2/cases.py -#: ('Sir John Prof.', 'Dr. Smith Sir.', '毛 泽东 Dr.'), so the row -#: counts below are unmoved. The /simplify round the same day took -#: that list to twenty-three, 'Sir Jr' leaving rules.md and so the -#: rules corpus; it was a cases.py literal and never in this -#: population, so the row counts are unmoved by that too. -#: Recounted 2026-09-13 with #383/#479, which moved three names across -#: the literal clause the way 'Aishwarya Rai' moved across it in #342: -#: 'Jose e Maria Santos' and 'JOSE E MARIA SANTOS' gained cases.py -#: rows and entered the CONTRACT corpora with them (corpus_rules.jsonl -#: and corpus_shapes.jsonl), and 'Juan Garcia y Lopez' became a string -#: literal in tests/v2/test_render.py -- so all three fail the literal -#: clause, two of them the radar-corpus clause as well, and all three -#: left the population. FIVE rows were RETIRED with them: 'Jose e -#: Maria Santos' and 'Juan Garcia y Lopez' at 1.4.0, and 'JOSE E MARIA -#: SANTOS' at 2.0.0, 2.1.0 and 2.2.0 -- that last row having been -#: re-recorded ("_initials",) -> ("_ambiguities",) earlier the same -#: day, before the population question was put to it. Nothing goes -#: unwatched by the retirement, which is the Aishwarya precedent's -#: whole point: each name now carries a case row asserting its entire -#: parse, and the three #383/#479 rules classify every diff the five -#: rows recorded. -#: One row the recount does NOT retire is worth naming, because -#: without it the per-file line below reads as a partition it is not: -#: 'QC MP' sits in corpus_rules.jsonl, a CONTRACT corpus, as well as -#: in corpus_issues.jsonl, and has done since before this change. By -#: the population clause above it does not belong here. It is left -#: alone rather than retired blind -- retiring a row removes a guard, -#: and nothing in this change made this one wrong. -#: 'John Smith Rev.' is named NOWHERE under -#: tests/, so it -#: counts in both scans -- the every-file figures in the RECOMPUTE -#: paragraph below count it too. That commit moved the ROW counts and nothing -#: else: it did not re-derive the population clause above, so the -#: equality sentence that follows is dated 2026-09-07 and is not -#: restated for today. -#: The counts: 36 / 33 / 32 / 7 rows, 108 in all, over those 49 names -#: -- and as of 2026-09-07 the roster was exactly the population, the -#: five contest rows beyond it having gone to _RECORDED_DIFFS with #501 -#: and five more with #498, which left the population by gaining a -#: _RECORDED_DIFFS key rather than by ceasing to be watched anywhere. -#: 47 of the 49 sit in corpus_issues.jsonl, 3 in corpus.jsonl and 1 in -#: corpus_rules.jsonl, with 'dr Vincent van Gogh dr' and 'QC MP' each -#: in two of them, so the per-file counts overlap by two and are not a -#: partition. Every row is a default-order shape, -#: as the roster above's are, so no row here is a declared-order-only -#: diff for NOT CHECKED to name. -#: -#: RECOMPUTE: wrap classify() and drive main() unchanged at each -#: baseline, recording (name, order, diff, rule) per call; keep the -#: calls whose order is None and whose rule is not None; apply the -#: four clauses above with the literal set from ast.walk over -#: tests/**/*.py EXCLUDING test_ledger_guards.py, as the POPULATION -#: clause says -- run over every file it yields 24 / 23 / 22 / 5 rows -#: rather than 36 / 33 / 32 / 7, since _CROSS_RULE_WINNERS' keys and -#: a few guard literals then score as watchers, and #498's fourteen -#: keys are exactly that kind of literal -- as are #342's two -#: 2026-09-07 arrivals, both named in _NOT_A_VOCABULARY_COPY. The -#: every-file figures are the strict ones MINUS the roster names that -#: are named as an exact string literal in test_ledger_guards.py and -#: nowhere else under tests/ (12 / 10 / 10 / 2 today), which is a -#: derivation a reader can run in one pass over this dict and that -#: file -- no baseline wheel needed -- and it is how the pair was -#: recomputed on 2026-09-09. That recount RETRACTS the pair recorded -#: on 2026-09-05 (33 / 23 / 22 / 4): the every-file count can never -#: EXCEED the strict one, dropping a name from the population removes -#: rows and never adds them, and 33 stood against a strict 37. The -#: paragraph that carried it argued the every-file figures "stood -#: still" while eight guard-literal rows landed -- which is right -#: about the mechanism and is exactly why the gap between the two -#: pairs widens as #498's and #342's keys arrive, so the two cannot -#: both have stood at 33 and 37. The strict pair is unchanged in -#: method and re-measured here: it is len() over this dict, ledger by -#: ledger -- the tier sets from -#: _load_entries over corpus*.jsonl -#: through _CORPUS_TIERS, and that ledger's _RECORDED_DIFFS keys. Not -#: by replaying the corpus load by hand: the (name, order) dedup, the -#: baseline-minimum skip and the tier stamp all happen inside main(), -#: and a name's membership is a property of the entry that run -#: compared. -#: -#: Two limits. The population is a LOWER bound: a literal in a test -#: that is not about parsing -- 'John Smith' in a TypeError test -- -#: scores as a watcher, so a name a test merely mentions has no row -#: here although nothing checks its parse. And nothing notices a NEW -#: sole-watched name arriving with no row. A completeness guard has -#: to answer "what else watches this name" mechanically, and its -#: other half, "diffs at some baseline", only a run with the wheel -#: knows -- so that guard is a run-time NOTE in the NOT CHECKED family -#: by necessity, not a pytest-speed roster check, and it is not here. -_WATCHED_DIFFS: dict[str, dict[str, tuple[str, ...]]] = { - "expected_since_1.4.0.toml": { - # 2026-10-04 (#603): the shape moved from `_initials` to - # {suffix, title}. A title word past the second comma is a title - # now (rules.md#C2), so 'Mr.' leaves the suffix; 1.4.0 read it - # as suffix 'Mr.' here, and the initials-only difference this - # row watched is now inside a role diff. - "1 & 2, 3 4 5, Mr.": ("suffix", "title"), - "Anh do": ("_initials",), - "Anna Müller (geb. Schmidt)": ("maiden", "nickname"), - "Anna Müller geb. Schmidt": ("family", "maiden", "middle"), - "Attorney General of Minnesota": ("_initials",), - "Dean of Chemistry": ("_initials",), - "Dean of Chemistry Robert Johns": ("_initials",), - "Deputy Secretary of State": ("_initials",), - "Do Quang Minh": ("given", "middle", "title"), - "Donald mc": ("family", "suffix"), - "Dr 田中さん, V.": ("family", "given", "suffix"), - "Dr. Do Van Johnson, MD": ("family", "given"), - "Duke of Edinburgh": ("_initials",), - "Esq. van Gogh": ("family", "given"), - "Jack M.A.": ("family", "suffix"), - "Jane van der Berg 旧姓 Jones": ("family", "maiden"), - "Janey née Jones": ("family", "given", "maiden", "middle"), - "John Smith Rev.": ("family", "middle", "title"), - # 'John Smith, RAI' left 2026-10-01 (#564): an unlisted all-caps - # word after a comma behind two name words is a credential by - # default, so the name reads suffix 'RAI' as 1.4.0 did -- parity, - # and a watched row with no diff to watch. - "John V": ("family", "suffix"), - "John of the Doe": ("_initials",), - "Jong van der": ("_initials",), - "Jong, van der": ("_initials",), - "Lala Lajpat Rai": ("family", "middle", "suffix"), - "Mesnil Garcia van": ("_initials",), - "Mohamad X": ("family", "suffix"), - # 2026-10-06 (#613): {family, given} -> {family, given, title}. - # The comma is decided once after classify, and 'Jr.' after it - # is the postnominal part bound before group's Ph./D. merge can - # make the name before it a title; junk input either way. - "Ph. D., Jr.": ("family", "given", "title"), - "QC MP": ("family", "suffix"), - "Sander van": ("_initials",), - "Smith Jones, Ph. D. Jr.": ("suffix",), - "Smith, Ph. D.": ("family", "given"), - "Smith, Prof.": ("family", "given"), - "Ursula von der Leyen (geb. Albrecht)": ("maiden", "nickname"), - "dr Vincent James van Gogh dr": ("family", "suffix"), - "dr Vincent van Gogh dr": ("family", "suffix"), - "dr Vincent van der Gogh dr": ("family", "suffix"), - # 'Bob Jones, compositeur', 'MD, DO, DDS', 'Smith, Ph. D. Jr. - # MD', 'Smith, Ph. D. MD' and 'Smith, Ph.D. Jr.' are GONE from - # this section: #498 argued a winner for each on 2026-09-05 - # and the five rows moved to _RECORDED_DIFFS with their shapes - # unchanged. Each is still watched at 2.0.0 and 2.1.0, where - # exactly one rule admits it and there is no contest to own, - # so the DISTINCT-NAME count in the header above does not - # move -- only the 1.4.0 row count does. - }, - "expected_since_2.0.0.toml": { - "Anh do": ("_initials",), - "Anna Müller (geb. Schmidt)": ("maiden", "nickname"), - "Bob Jones, author": ("family", "given"), - "Bob Jones, compositeur": ("family", "given"), - "Do Quang Minh": ("_ambiguities", "given", "middle", "title"), - "Dr 田中さん, V.": ("family", "given", "suffix"), - "Dr. Do Van Johnson, MD": ("family", "given"), - "E Anne D,Leonardo": ("_initials",), - "Esq. van Gogh": ("_ambiguities", "family", "given"), - "Jane van der Berg 旧姓 Jones": ("family", "maiden"), - "Janey née Jones": ("family", "given"), - "Joe E. Smith": ("_initials",), - "John Smith Rev.": ("family", "middle", "title"), - # 2026-10-01 (#564): the default now reads 'RAI' as the - # credential again by its capitals, as this baseline read it - # by vocabulary, so only the comma's report differs. - "John Smith, RAI": ("_ambiguities",), - # 2026-10-04 (#603): the shape moved from `_ambiguities` to - # {suffix, title}. A title word past the second comma is a - # title now (rules.md#C2), so 'Dr.' reads title and the part is - # recognized: the comma-structure report this row watched is - # gone, and the role diff is the one the baseline's suffix 'Dr.' - # gives. - "John, Smith, Dr.": ("suffix", "title"), - "Jong van der": ("_initials",), - "Jong, van der": ("_initials",), - "Jose E. Maria Santos": ("_initials",), - "Lala Lajpat Rai": ("family", "middle", "suffix"), - # 2026-09-18 (#289): the shape GREW by `_ambiguities`. A - # bare listed member in the post-comma slot now reports - # `suffix-or-name` wherever the fork is consulted, and 'DO' - # here is one; the roles are untouched. Re-recorded in the - # commit that moved it, per this dict's own rule. - "MD, DO, DDS": ("_ambiguities", "given", "title"), - "Mesnil Garcia van": ("_initials",), - # 2026-10-06 (#613): {family, suffix, title} -> {family, - # suffix}: the comma decided once after classify reads 'Ph. - # D.' whole as the family where the merge had made 'Ph.' a - # title; junk input either way. - "Ph. D., Jr.": ("family", "suffix"), - "Sander van": ("_initials",), - "Smith Dr": ("family", "suffix"), - "Smith, John E, III, Jr": ("_initials",), - "Smith, Ph. D. Jr. MD": ("given", "suffix"), - "Smith, Ph. D. MD": ("given", "suffix"), - "Smith, Ph.D. Jr.": ("given", "suffix"), - "Ursula von der Leyen (geb. Albrecht)": ("maiden", "nickname"), - "dr Vincent James van Gogh dr": ("family", "suffix"), - "dr Vincent van Gogh dr": ("family", "suffix"), - "dr Vincent van der Gogh dr": ("family", "suffix"), - "van ma van": ("_initials",), - # The four #501 contests that closed this section, and 'MD, PHD' - # beside them, are GONE from it: #501 argued a winner for each - # on 2026-09-05 and the five rows moved to _RECORDED_DIFFS with - # their shapes unchanged, which is what this dict's NO WINNER - # clause says happens the day one is argued. Nothing about them - # is left here to go stale, the tier note included -- what a - # contest row costs is _RECORDED_DIFFS' subject now. - }, - "expected_since_2.1.0.toml": { - "Anh do": ("_initials",), - "Anna Müller (geb. Schmidt)": ("maiden", "nickname"), - "Bob Jones, author": ("family", "given"), - "Bob Jones, compositeur": ("family", "given"), - "Do Quang Minh": ("_ambiguities", "given", "middle", "title"), - "Dr. Do Van Johnson, MD": ("family", "given"), - "E Anne D,Leonardo": ("_initials",), - "Esq. van Gogh": ("_ambiguities", "family", "given"), - "Jane van der Berg 旧姓 Jones": ("family", "maiden"), - "Janey née Jones": ("family", "given"), - "Joe E. Smith": ("_initials",), - "John Smith Rev.": ("family", "middle", "title"), - # 2026-10-01 (#564): the default now reads 'RAI' as the - # credential again by its capitals, as this baseline read it - # by vocabulary, so only the comma's report differs. - "John Smith, RAI": ("_ambiguities",), - # 2026-10-04 (#603): the shape moved from `_ambiguities` to - # {suffix, title}. A title word past the second comma is a - # title now (rules.md#C2), so 'Dr.' reads title and the part is - # recognized: the comma-structure report this row watched is - # gone, and the role diff is the one the baseline's suffix 'Dr.' - # gives. - "John, Smith, Dr.": ("suffix", "title"), - "Jong van der": ("_initials",), - "Jong, van der": ("_initials",), - "Jose E. Maria Santos": ("_initials",), - "Lala Lajpat Rai": ("family", "middle", "suffix"), - # 2026-09-18 (#289): the shape GREW by `_ambiguities`. A - # bare listed member in the post-comma slot now reports - # `suffix-or-name` wherever the fork is consulted, and 'DO' - # here is one; the roles are untouched. Re-recorded in the - # commit that moved it, per this dict's own rule. - "MD, DO, DDS": ("_ambiguities", "given", "title"), - "Mesnil Garcia van": ("_initials",), - # 2026-10-06 (#613): {family, suffix, title} -> {family, - # suffix}: the comma decided once after classify reads 'Ph. - # D.' whole as the family where the merge had made 'Ph.' a - # title; junk input either way. - "Ph. D., Jr.": ("family", "suffix"), - "Sander van": ("_initials",), - "Smith Dr": ("family", "suffix"), - "Smith, John E, III, Jr": ("_initials",), - "Smith, Ph. D. Jr. MD": ("given", "suffix"), - "Smith, Ph. D. MD": ("given", "suffix"), - "Smith, Ph.D. Jr.": ("given", "suffix"), - "Ursula von der Leyen (geb. Albrecht)": ("maiden", "nickname"), - "dr Vincent James van Gogh dr": ("family", "suffix"), - "dr Vincent van Gogh dr": ("family", "suffix"), - "dr Vincent van der Gogh dr": ("family", "suffix"), - "van ma van": ("_initials",), - }, - "expected_since_2.2.0.toml": { - "E Anne D,Leonardo": ("_initials",), - "Joe E. Smith": ("_initials",), - "John Smith Rev.": ("family", "middle", "title"), - # 2026-10-01 (#564): reads suffix 'RAI' again by its capitals, - # as this baseline read it by vocabulary; the report differs. - "John Smith, RAI": ("_ambiguities",), - "Jose E. Maria Santos": ("_initials",), - "Lala Lajpat Rai": ("family", "middle", "suffix"), - "Smith, John E, III, Jr": ("_initials",), - }, - # Still empty on 2026-09-13, and now for a reason rather than for - # want of diffs: all TEN of the open cycle's movers are named by a - # string literal under tests/ outside test_ledger_guards.py -- a - # cases.py row, a render pin or a v1 bank -- so not one of them - # meets the population's literal clause and none takes a row here. - "expected_since_2.3.0.toml": {}, - "expected_since_2.4.0.toml": {}, # open cycle, no watched name yet -} - - -def recorded_diff_mismatches( - recorded: dict[str, tuple[str, ...]], - diffing: list[tuple[str, set[str], str | None]], - compared: set[str]) -> list[_ShapeMismatch]: - """Recorded shapes this run contradicts (#497). - - The roster in tests/v2/test_ledger_guards.py pins WHICH RULE wins a - contested name, and to ask that question it needs the diff shape -- - which it reads from _RECORDED_DIFFS above and feeds to classify() as - an input. Nothing checks the shape itself, so a guessed one agrees - with itself forever. This is the half only a run can do: main() has - already measured every name's real diff by the time it calls this. - `recorded` is whichever roster the caller hands over -- nothing - here knows a contest row from a watched one, and main() calls this - once per dict and applies the severity to what comes back, since - the two kinds differ in what a mismatch means and not in how one is - found. - - Only the order-None comparison is read. The roster calls classify() - with no order, so the shape it records is the default-order one; a - string also compared under a declared order is a different question - and its own row would be needed to ask it. - - `compared` is the names this run actually compared: the entry list - AFTER the baseline-minimum skip -- main()'s `corpus`, NOT the - `corpus_names` the contest checks read, which is built before that - skip runs. The two differ by exactly the entries an old baseline - cannot honor -- 7 of them at 1.4.0, where 1120 load and 1113 - compare; re-measure by running this file with `--baseline 1.4.0` - and reading its `skipped` and `corpus:` lines. Those are ENTRY - counts. The paragraph further down counts NAMES, which is why the - same skip reads as 7 here and as 3 there: an entry is a (name, - order) pair, so the 1120 deduped entries carry 1116 distinct - strings -- three strings are compared under more than one order - ('de la Cruz Juan Carlos' under three, 'John Smith, Dr.' and 'de - la Cruz née Vega' under two) -- and four of the seven skipped - entries are the whole of the three names named below, while the - other three have a same-name twin that survives the skip. - decisions.md, "the rule-order arc", records the same trap for the - contest checks, - which read the pre-skip list on purpose. Pass that list here and a - roster row whose only entry was skipped reports as a name that - stopped diffing, when it was never compared at all. - - A name absent from `compared` is SKIPPED rather than reported. Under - `--corpus` the name set is narrowed, and absence is then a fact - about the run rather than about the roster. That is only the SUBSET - half of what the vacancy check's caller does with the same - asymmetry (#382): under a FULL run that caller refuses a - declaration whose pair is gone, and nothing here refuses a recorded - name no corpus holds any more -- forgiving forever the very shape - #497 is about. That half belongs to the caller, the only side that - knows whether this run read every corpus; main() does it, under a - full run alone, asking `set(recorded) - set(corpus_names)`: the - PRE-skip list, which is the opposite of what `compared` takes in - the paragraph above, and deliberately so. - Two different questions. "Was this name compared?" is about the - RUN, and an entry the baseline skipped was not; "does any corpus - still hold this name?" is about the FILES, and the skip removes a - name from no file. Measured at --baseline 1.4.0, the two lists - differ by three names -- 'de Mesnil Jean, Dr.', 'de la Cruz Juan - Carlos, Dr.' and 'de la Cruz née Vega', each order-bearing with no - order-None twin to keep it in `compared`, and each sitting in - corpus_shapes.jsonl the whole time. Refusing off the post-skip list - would tell a contributor to delete a roster row for a name that is - right there, at the compat baseline. `full_corpus` does not screen - that: it is _CORPUS_FLOORS against the files on disk and knows - nothing about the skip. It is the earlier-draft `vacant` bug - recorded below, one baseline over. - Those three names are the WINDOW between the two halves, and not - refusing is not the same decision as saying nothing: a row on one - of them is checked by neither side, so main() prints a NOT CHECKED - note over `set(recorded) & set(corpus_names) - compared` -- naming - them, claiming nothing about them, and feeding no exit code. That - note is the caller's too, for the reason this whole paragraph is: - only the caller holds both lists. - - A name that IS compared and produces no default-order diff is - reported with `measured` None, and two states reach that: the - parser stopped diffing a name recorded as diffing, or the name is - compared under a declared order ALONE, so no default-order - comparison of it exists to have a shape. They are collapsed because - nothing in these arguments separates them. main() appends to - `diffing` only where a comparison DIFFED, so a name compared under - the default order that produces no diff leaves no row at all, and - the two states hand this function byte-identical `diffing` and - `compared` -- constructed and run, not reasoned about. The - distinction lives in the ORDERS each name was compared under, which - is main()'s `entries` and would be a fourth argument here. So a - message over these rows may say the run measured no default-order - diff, and must NOT say the name stopped diffing; and a caller must - not try to label them off `diffing`, which cannot answer it. - _Dormant's `kind` is the shape to copy if that fourth argument is - ever added and the two are told apart. - """ - measured = {name: tuple(sorted(diff)) - for name, diff, order in diffing if order is None} - out: list[_ShapeMismatch] = [] - for name, shape in recorded.items(): - if name not in compared: - continue - got = measured.get(name) - want = tuple(sorted(shape)) - if got != want: - out.append(_ShapeMismatch(name, want, got)) - return out - - -#: How a winner beat a loser, as a TOKEN rather than a sentence. -#: Hoisted out of _UnownedContest so the row's field, the local in -#: unowned_contests and _UNOWNED_WHY's key type are ONE declaration: -#: three hand copies of the same three-way Literal drift the moment a -#: fourth kind is added, and mypy would keep every one of them happy. -_UnownedKind = Literal["equal", "overlap", "wide-undeclared"] - - -class _UnownedContest(NamedTuple): - """A measured contest with no owner (#498). - - Named rather than a bare tuple for _Vacancy's reason: the caller - formats these into a message, and `c.winner`/`c.loser` says which - end is which where `c[3]`/`c[4]` would not. - """ - name: str - #: the name_order the comparison ran under; None = the default - order: str | None - #: the diff shape this run measured, sorted - diff: tuple[str, ...] - #: issue of the rule classify() gave the diff to - winner: str - #: issue of an admitting rule the winner beat - loser: str - #: how the winner beat it -- "equal" where the two declare the same - #: roles, "overlap" where neither `fields` set contains the other, - #: and "wide-undeclared" for a wide-first pair carrying no - #: `precedes_narrower`, which is REPORTED rather than assumed - #: unreachable. A TOKEN, like _Dormant.kind: the wording lives in - #: _UNOWNED_WHY below, which the caller renders. - kind: _UnownedKind - #: the name's tier, from main()'s `tier_of`; the caller passes the - #: run's own map and a miss RAISES, as main()'s other tier lookups - #: do -- every compared name has a key there - tier: str - - -#: How each `_UnownedContest.kind` reads in the report, beside -#: _DORMANT_WHY and for its reason: the computation never has to know -#: the wording, and rephrasing one is a change to output alone. -_UNOWNED_WHY: dict[_UnownedKind, str] = { - "equal": "equal `fields`, file order the whole decision", - "overlap": ("`fields` overlap without nesting, so neither is the " - "narrower"), - "wide-undeclared": ("the loser is NARROWER and undeclared -- " - "undeclared_contests refuses this on any ledger " - "main() loads, so seeing it here means a " - "hand-built rule list"), -} - - -def unowned_contests( - diffing: list[tuple[str, set[str], str | None]], - rules: list[dict[str, object]], - exclusions: list[dict[str, object]], - pinned: set[str], - tier_of: dict[str, str]) -> list[_UnownedContest]: - """Contested diffs no rule outranks and no row pins (#498). - - `undeclared_contests` above refuses a ledger where a WIDER rule - sits ahead of a NARROWER one it reaches a corpus name with, unless - the wider rule declares why. Nesting is SUFFICIENT for an - order-decided contest and not necessary: two rules whose `fields` - merely intersect -- or are EQUAL -- both admit any diff inside the - intersection, classify() returns the first, and that check cannot - see the pair. The docs say _CROSS_RULE_WINNERS "stays the - instrument" there. This is what checks that the instrument was - applied. Between the two, every measured contest has an owner. - - It is handed ALL of `diffing` rather than only the names - classify() gave a rule to, and the two populations are the same - one: classify() returns a rule exactly when no exclusion matched - it and at least one rule admits the diff, which are the exclusion - check and the admitting-rule check below. This walk needs TWO - admitters where classify() needs one, so it can only narrow that - population further, never widen it. Passing the whole list keeps - the caller from having to keep a second one in step with it. - - RUN-TIME by necessity, and that is not a cost this trades away. - A pin is a shape beside a winner, and the shape is what a run - measured; there is no static form of "every contested name is - pinned". Without a diff the population is the static reach of - every pair this check can report -- 71 of them, measured - 2026-09-05 over the four ledgers with `_rule_reach`: pairs sharing - a corpus name whose `orders` are not disjoint and whose `fields` - intersect without either containing the other (60) or are equal - (11 more; the loop is in decisions.md, the 2026-09-02 "nesting is - sufficient ... not necessary" bullet, whose own 60 is the - overlap-only count) -- and demanding a pin for each is the roster - nobody writes, which is the answer decisions.md already gives - about the 657 and 111 figures. WITH a diff the population was 14 - names over 15 rows at 1.4.0 (measured 2026-09-05; '田中さん, Dr.' - carries two losers) and 0 at all three 2.x baselines. - The static check covers the hazard that has a declaration site - from the moment a rule is written; this covers the hazard that - has none, from the moment a run measures it. The coverage limit - is real and accepted: one ledger per invocation, and only when - the gate runs. - - A _WATCHED_DIFFS row does NOT justify a contest, which is why - `pinned` takes one roster and not the union. That roster records - a shape with NO WINNER, and a contest's whole question is which - rule should win. What makes "a key in _RECORDED_DIFFS" mean "a - winner is pinned" is the `set(winners) == set(shapes)` equality - in tests/v2/test_ledger_guards.py, held in both directions -- so - this function's justification is borrowed from that guard, the - way _declared_over's shape guarantee is borrowed from - validate_rules, and it is stated rather than assumed for the same - reason. A THIRD borrow, and the same shape: every row here reads - `winner != loser` because validate_rules refuses DUPLICATE issues, - the guarantee undeclared_contests leans on for its `by_issue` -- - on a hand-built list carrying one issue twice, the loop below - compares a rule against its own issue string and can report a - contest a rule holds with itself. - - NO VACANCY HALF, unlike `vacant_exemptions` beside the static - check, and the asymmetry is deliberate. A declaration whose pair - stopped being a contest is a justification for a hazard that is - gone and reads exactly like a live one. A PIN whose contest - dissolves is not: it keeps saying what the name diffs to and - which rule takes it, which is what 13 of the 45 rows at 1.4.0 - already do with a single admitter apiece (measured 2026-09-05: - count the rows of _RECORDED_DIFFS['expected_since_1.4.0.toml'] - for which exactly one rule of _sorted_rules over that ledger - satisfies _entry_matches at the recorded shape) - (mechanisms.md#RECORDED-ROSTERS -- the test to apply to a recorded - input is not "is it checked" but "would the consumer answer - DIFFERENTLY if it were wrong"). So nothing here inverts under a subset - either: under `--corpus` the population is the entries the run - loaded, so this check UNDER-reports and never false-alarms, and - `full_corpus` is not read. - - STATED LIMIT. A _RECORDED_DIFFS row is a DEFAULT-order shape, so - a contest measured under a declared order would be reported here - and could not be absorbed by a row at all. The pin gate below is - ORDER-AWARE for exactly that reason: `pinned` skips a name only - on its default-order comparison, so a row cannot silently absorb - the declared-order contest it does not describe. None exists on - any shipped ledger today -- every one of the fourteen is - order-None -- and `order` rides each row so the caller can name - the limit only when it applies, the way the NOT CHECKED note does. - """ - # classify() must be asked in the order main() asked it, or the - # winner named here is not the rule that actually took the diff. - # Sorting internally makes that true whatever the caller passes; - # the sort is stable and idempotent, so doing it twice costs - # nothing. dormant_rules carries the same line for the same reason. - rules = _sorted_rules(rules) - - def fields_of(rule: dict[str, object]) -> frozenset[str]: - """A rule's declared roles. A missing or mistyped `fields` - reads as EMPTY, and that runs the OPPOSITE way from - _entry_matches' leniency over the same shape rather than being - it seen from the other side: that predicate IGNORES a non-list - `fields` and admits every diff, which is the WIDEST a rule can - be, while frozenset() here is the NARROWEST. So the two - readers disagree about the same rule, and _declared_over's - docstring is the model for saying which way each disagreement - falls ("a stricter reader declares LESS and so can only report - MORE" is its version). On the WINNER the effect is - UNDER-reporting: `won < lost` fires and the pair retires as - narrow-first, where a reader agreeing with _entry_matches - would have called the winner the wider one. On a LOSER it - falls the other way -- `lost < won` fires and the pair is - reported wide-undeclared where the wide reading would have - retired it as narrow-first. Neither is reachable on a ledger - main() loads: validate_rules refuses a `fields` that is not a - list of strings, and its #456 check refuses a rule carrying - `name_regex` without one. This exists for the hand-built rule - lists the tests pass in.""" - declared = rule.get("fields") - return (frozenset(f for f in declared if isinstance(f, str)) - if isinstance(declared, list) else frozenset()) - - out: list[_UnownedContest] = [] - for name, diff, order in diffing: - # Exclusions FIRST and outright, as classify() consults them: - # a name a [[never]] entry refused was classified to nothing, - # so it has no winner and is not in the population. - if any(_entry_matches(x, name, diff) for x in exclusions): - continue - # A pin justifies every pair on the name whatever their kind: - # the row says which rule wins, which is the question a - # contest asks, and it says it once for the name rather than - # once per losing rule. ORDER-AWARE, per the stated limit - # above: a _RECORDED_DIFFS row is a DEFAULT-order shape, so it - # cannot own a contest measured under a declared order, and - # `order is None` is what keeps a row from absorbing one it - # does not describe. - if order is None and name in pinned: - continue - admit = [r for r in rules if _entry_matches(r, name, diff, order)] - if len(admit) < 2: - continue - winner = admit[0] - won = fields_of(winner) - declared_over = _declared_over(winner) - for rule in admit[1:]: - lost = fields_of(rule) - issue = str(rule.get("issue", "")) - if won < lost: - continue # narrow-first: #382's declaration-free default - if lost < won and issue in declared_over: - continue # wide-first, and the winner says why - kind: _UnownedKind = ( - "equal" if won == lost - else "wide-undeclared" if lost < won - else "overlap") - out.append(_UnownedContest( - name=name, order=order, diff=tuple(sorted(diff)), - winner=str(winner.get("issue", "")), loser=issue, - kind=kind, tier=tier_of[name])) - return out - - -def _two_causes(rows: list[_ShapeMismatch]) -> str: - """The MOVED SHAPE blocks' disclaimer over a row with no measured - shape. One-time like the lead it follows, and conditional as well: - a block whose rows all carry a measured shape would otherwise print - repair advice for a case that did not occur, which is what - OVER-DECLARED's `--corpus` NOTE is conditional to avoid. Shared by - the three blocks because the two causes are a property of - recorded_diff_mismatches, not of the roster a row came from.""" - return ("\n A row reading 'measured no default-order diff' " - "names no cause because TWO reach it and this check " - "cannot separate them: the parser may have stopped " - "moving the name, or the name may be compared under a " - "declared order alone, leaving no default-order " - "comparison to have a shape. Read the corpus entry " - "before you read the parser." - if any(m.measured is None for m in rows) else "") - - -def _print_moved_rows(rows: list[_ShapeMismatch], roster: str) -> None: - """The rows under a MOVED SHAPE lead, naming the dict each lives in - so the reader edits the right one. Trailing blank line included: - the caller prints a lead only when it has rows, so the two are one - unit.""" - for m in rows: - measured = (f"measured {list(m.measured)}" - if m.measured is not None - else "measured no default-order diff of it") - print(f" {m.name!r}\n {roster} records " - f"{list(m.recorded)}; this run {measured}") - print() - - -def _load_entries(path: Path) -> list[dict[str, object]]: - """Corpus lines as entry dicts. A line is either a bare JSON - string (the original format) or an object with a "name" plus - optional metadata -- "tests" labels from build_corpus.py, and a - "shape" id from build_shapes_corpus.py (#469). Tolerating both - means compare.py itself never needs a flag day as corpora are - added or converted: corpus.jsonl and corpus_shapes.jsonl carry - object lines, the rest are still bare strings, and both shapes - stay legal everywhere a corpus line is read. Counting the files - here said five over six, and the object/string split three over - four, which is the "all five corpora" claim #497 swept out of - three other files -- it survived because that sweep never - reached compare.py. The glob is the count. - - A "tests" label is read only when the radar block prints, at the - very end of the run and well past the worker pass, so a malformed - one left unchecked would crash there rather than here -- exactly - what validate_rules' compile-at-startup paragraph exists to - prevent. - - "shape" is checked for a different hazard. main() resolves it - against shapes.py into the "order" the worker protocol sends, and - that loop runs BEFORE the worker, so a bad id is not a late crash: - it is a wrong comparison that reports as a passing one. `true` - passes isinstance(shape, int) and hash(True) == hash(1), so an - unchecked one resolves against shapes.py's entry 1 and the line is - compared under that shape's order, silently. Only the TYPE is - checked here; an unresolvable id is main()'s to catch, since only - it has shapes.py loaded. - - Unknown keys are rejected the way validate_rules rejects them, and - for the same reason: a misspelled key is not ignored, it drops the - narrowing the line meant to declare, and the line then compares - under the default order with nothing saying so. "order", "tier" and - "file" are rejected rather than obeyed -- the comparison computes - all three per entry and overwrites whatever a line said, so a line - writing one would be silently discarded. "order" in particular is - the key the WIRE protocol documents, which makes it the one a - corpus author is likeliest to reach for. - """ - allowed = {"name", "tests", "shape"} - entries: list[dict[str, object]] = [] - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - raw = json.loads(line) - if isinstance(raw, str): - entries.append({"name": raw}) - elif isinstance(raw, dict) and isinstance(raw.get("name"), str): - unknown = sorted(set(raw) - allowed) - if unknown: - raise SystemExit( - f"{path.name}: a corpus line has unknown key(s) " - f"{unknown}; expected only {sorted(allowed)}: " - f"{line!r}. A misspelled key is not ignored -- it " - f"drops the narrowing the line declares, and the " - f"name is then compared under the default order " - f"with nothing saying so. 'order', 'tier' and " - f"'file' are computed by the comparison itself, so " - f"a line writing one would be overwritten") - tests = raw.get("tests") - if tests is not None and ( - not isinstance(tests, list) - or not all(isinstance(t, str) for t in tests)): - raise SystemExit( - f"{path.name}: a corpus line's 'tests' must be a " - f"list of strings, not {tests!r}: {line!r}") - shape = raw.get("shape") - # bool is an int subclass in Python, and hash(True) == hash(1) - # -- unexcluded, {"shape": true} would pass isinstance(shape, - # int) and silently resolve to shape 1's order - if shape is not None and ( - not isinstance(shape, int) or isinstance(shape, bool)): - raise SystemExit( - f"{path.name}: a corpus line's 'shape' must be an " - f"int naming a shapes.py entry, not {shape!r}: " - f"{line!r}") - entries.append(dict(raw)) - else: - raise SystemExit( - f"{path.name}: corpus line is neither a JSON string " - f"nor an object with a string 'name': {line!r}") - return entries +def compare(baseline: str) -> tuple[list[str], int, int]: + """The grouped moves between `baseline` and the tree, with the + number of moved names and how many of them are Latin-only.""" + snapshot = _load_snapshot() + shapes = snapshot.load_shapes() + min_for = {s.order: s.min_baseline for s in shapes.values() if s.order} + entries = [(name, order) for name, order in snapshot.corpus_entries() + if order is None + or _parse_version(baseline) >= _parse_version(min_for[order])] + want_v2 = "v2" in _surfaces_for(baseline) + _, results = _run_worker( + baseline, want_v2, [{"name": n, "order": o} for n, o in entries]) + old = [snapshot.row(n, o, r.get("v2"), r.get("facade")) + for (n, o), r in zip(entries, results)] + new = snapshot.read(entries) + import nameparser + _check_tree(nameparser.__file__) + if not want_v2: + new = [snapshot.facade_view(r) for r in new] + before = {snapshot.key(r): r for r in old} + after = {snapshot.key(r): r for r in new} + moved = [k for k in after if before[k] != after[k]] + latin = sum(_is_latin_only(name) for name, _ in moved) + return snapshot.grouped(before, after), len(moved), latin def main() -> int: - ap = argparse.ArgumentParser() - # Every corpus by default: they have different blind spots (see - # build_issues_corpus.py), and one that has to be asked for by name - # is one that stops being run. Deliberately a glob rather than a - # list, so adding a corpus file is enough to put it in the gate. - ap.add_argument("--corpus", action="append", metavar="PATH", - help="corpus file; repeatable. Defaults to every " - "corpus*.jsonl beside this script.") + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--baseline", default=DEFAULT_BASELINE, metavar="VERSION", help=f"released version to compare the tree against " - f"(default {DEFAULT_BASELINE}). Use 1.4.0 for the " - f"v1 compat contract, the previous minor for a " - f"release's blast radius.") + f"(default {DEFAULT_BASELINE})") args = ap.parse_args() - baseline = args.baseline - surfaces = _surfaces_for(baseline) - paths = ([Path(p) for p in args.corpus] if args.corpus - else sorted(HERE.glob("corpus*.jsonl"))) - ledger = _allowlist_for(baseline) - parsed = tomllib.loads(ledger.read_text()) - rules = parsed.get("change", []) - validate_rules(rules, ledger.name) - rules = _sorted_rules(rules) - exclusions = parsed.get("never", []) - validate_exclusions(exclusions, ledger.name) - # A glob that matches nothing must not read as "everything passed". - # Comparing zero names would print 0 unexplained and exit 0 -- the - # harness's own stated nightmare (see validate_rules), and a - # regression from the single hard-coded path this replaced, which - # raised FileNotFoundError. - if not paths: - raise SystemExit( - f"no corpus files found in {HERE}: expected corpus*.jsonl") - # A file that DISAPPEARS is the same nightmare one step smaller: - # the glob simply finds fewer files, and the run compares less - # while printing a summary that reads exactly like a full one. The - # floors already name every corpus that is supposed to exist, so - # ask them. Skipped under --corpus, where narrowing is the point. - # - # The same question, asked of the names rather than of the flag, - # answers "is this run over the FULL corpus" for the two checks - # below whose verdict inverts under narrowing -- the vacancy check - # and the departed-name half of the recorded-shape check (#497). - # That is what both need, and it is not the same as "was --corpus - # omitted": the flag is `action="append"`, so naming every corpus - # explicitly narrows nothing. - missing = sorted(set(_CORPUS_FLOORS) - {p.name for p in paths}) - full_corpus = not missing - if not args.corpus: - if missing: - raise SystemExit( - f"corpus files named in _CORPUS_FLOORS are not on disk: " - f"{missing}. A corpus that vanishes shrinks the " - f"comparison silently -- restore it, or drop its floor " - f"if it is meant to be gone") - # Contract files load FIRST so the (name, order) dedup below keeps - # the contract reading of a string both tiers hold. - paths = sorted(paths, key=lambda p: ( - _CORPUS_TIERS.get(p.name) != "contract", p.name)) - per_file = {} - entries: list[dict[str, object]] = [] - for path in paths: - tier = _CORPUS_TIERS.get(path.name) - if tier is None: - raise SystemExit( - f"{path.name} has no entry in _CORPUS_TIERS. Every " - f"corpus must choose: 'contract' (an unmatched diff " - f"fails the run) or 'radar' (an unmatched diff is " - f"reported and cannot fail). A default here would let " - f"a new corpus pick one by accident") - # The VALUE too, not only the key's presence: every tier read - # downstream is a comparison against one of the two literals, - # and a misspelled value passes each of them on the side its - # `!=` happens to fall -- fatal in the comparison loop and in - # the watched-shape split, which is the safe direction there, - # but by accident rather than by choice, and nothing would - # ever name the misspelling. - if tier not in ("contract", "radar"): - raise SystemExit( - f"{path.name} has tier {tier!r} in _CORPUS_TIERS, which " - f"is neither 'contract' nor 'radar'. Every tier read " - f"below compares against those two literals, so a third " - f"value is not a third tier but whichever side of each " - f"comparison the misspelling falls on") - file_entries = _load_entries(path) - if not file_entries: - raise SystemExit(f"{path.name} is empty; comparison aborted") - floor = _CORPUS_FLOORS.get(path.name) - if floor is None: - raise SystemExit( - f"{path.name} has no entry in _CORPUS_FLOORS. Add one at " - f"a little under its size: without a floor a corpus can " - f"shrink to a handful of names and the run still exits " - f"0, having compared far less than it reports") - if len(file_entries) < floor: - raise SystemExit( - f"{path.name} holds {len(file_entries)} names, below its " - f"floor of {floor} -- it has shrunk or been truncated. " - f"The run would still exit 0 while comparing a fraction " - f"of what it claims. Restore the file, or lower the " - f"floor deliberately if names were removed on purpose") - per_file[path.name] = len(file_entries) - for e in file_entries: - e["tier"] = tier - e["file"] = path.name - entries.extend(file_entries) - # resolve each entry's optional "shape" to the "order" the worker - # protocol actually sends -- a public nameparser constant name, or - # None for the default order - shapes_by_id = _load_shapes() - for e in entries: - shape = e.get("shape") - if shape is None: - e["order"] = None - continue - if shape not in shapes_by_id: - raise SystemExit( - f"corpus entry {e['name']!r} declares shape {shape!r}, " - f"which shapes.py does not define") - e["order"] = shapes_by_id[shape].order - # dedupe on (name, order): the same string tagged with two shapes - # is two comparisons, not a duplicate -- each order is compared - # under its own reading. First-seen wins, and contract files were - # loaded first. The min-baseline skip below reads the SURVIVOR's - # shape, which is safe only while min_baseline is a function of - # order alone (true today, since every order-bearing shape's - # minimum is 2.0.0) -- a future order-None shape carrying a higher - # minimum than an order-bearing duplicate would make survival, and - # so the skip decision, depend on which file loaded first. That - # future is now real -- shapes 6/7 are order-None with a later - # minimum (2.1.0) than shapes 1-3's (1.4.0) -- but still - # unreachable as an actual duplicate: shapes 1-5's purity check - # refuses CJK text and shapes 6/7's requires it, so no string can - # ever carry two order-None shape tags with different minimums, - # whichever file loaded first. - # Names some MEASURED corpus holds, read before the dedup below - # can keep an unmeasured file's copy of a string another file also - # holds: such a name is adjudicated like any other. - measured_names = {e["name"] for e in entries - if e["file"] not in _UNMEASURED_CORPORA} - by_key: dict[tuple[str, str | None], dict[str, object]] = {} - for e in entries: - by_key.setdefault((e["name"], e.get("order")), e) - entries = list(by_key.values()) - # Order checks here rather than beside validate_rules, which runs - # before any corpus is read: whether two rules CONTEST a diff is a - # question about NAMES -- both regexes have to reach one -- and the - # names arrive at this line. Before the worker pass, deliberately: - # a ledger refused after the worker runs has already installed the - # pinned wheel and parsed the whole corpus for a comparison that - # will never be made, and it refuses below its own published - # `baseline:` header (#382). It is the ORDER of the two that earns - # this placement and not the wait -- the worker pass is a fraction - # of a second, and the "multi-minute" this comment used to argue - # from was withdrawn as unmeasured (#497, and decisions.md, "the - # rule-order arc", which carries the dated figures). - # - # The names are the LOADED entries, not the corpus*.jsonl glob the - # unit guard in tests/v2/test_ledger_guards.py reads: `--corpus` - # narrows what this run compares, and a run is judged on the names - # it read, while the guard judges every corpus on disk. - # - # LOADED, precisely -- this sits ahead of the baseline-minimum - # shape skip below, so `corpus_names` holds names an old baseline - # will not actually compare (1120 here against the 1113 the 1.4.0 - # run reports; the 7 are order-bearing shape-4/5 names). Kept ahead - # of it on purpose: the check then asks the same question at every - # baseline, as the unit guard does, and moving it after `kept` - # would make a ledger's acceptability depend on which release it is - # being compared against. - # - # THE TWO CHECKS READ A SMALLER NAME SET IN OPPOSITE DIRECTIONS, - # which is the whole reason only one of them refuses below. Dropping - # names can only remove contests. For `undeclared` that can only - # UNDER-REPORT, never false-alarm: fewer contests is fewer pairs - # anyone owes a declaration, so a partial run is strictly more - # lenient and can never invent a refusal. (Not "fail-closed" -- - # this file uses that term above for the _CORPUS_FLOORS and - # _CORPUS_TIERS rosters, which REFUSE on a missing entry, and a - # check that errs toward not refusing is the opposite of that.) - # For `vacant` it INVERTS -- a live declaration whose contested - # names are outside this run reads exactly like a stale one. Measured: every one of the six corpora, - # run alone against expected_since_1.4.0.toml, reports vacancies -- - # 11 of the 11 exemptions for corpus.jsonl, corpus_cjk.jsonl and - # corpus_shapes.jsonl, and 8, 7 and 5 for the other three. So a - # partial run NOTES that count and does not act on it. - # - # FOUR CHECKS READ THE NARROWING AT FOUR DIFFERENT STRENGTHS, and - # the differences are the point rather than an inconsistency to - # tidy. The corpus-floor roster above is SKIPPED entirely, because - # narrowing is what the flag is for. over_declared_rules still - # FAILS the run -- `overwide` feeds the exit code on every run -- - # and only appends a NOTE that the union it computed is over a - # subset, so its repair advice is not followed blindly. `vacant` - # does not fail, because its VERDICT inverts under narrowing rather - # than merely its evidence. `gone` below -- the roster rows naming - # a departed name (#497) -- inverts the same way and goes one step - # further, staying SILENT: `vacant` prints a count a reader might - # act on, and there recorded_diff_mismatches has already dropped - # those names without reporting one, so a NOTE would carry noise - # and no information. Its own comment measures how much. - # Do not fold the two branches below back into one - # shape, and do not level the four checks onto one strength: an - # earlier draft of `vacant` refused under `--corpus` and told the - # contributor to delete legitimate exemptions. The shape comparison - # after the worker -- MOVED SHAPE at either severity, and MOVED - # SHAPE (radar) -- reads the flag not at all: recorded_diff_mismatches - # skips a name outside `compared`, and that skip is the whole of - # its narrowing, in both modes. - # - # `full_corpus`, not `args.corpus`: the question the inversion - # turns on is whether this run read every corpus, and `--corpus` is - # `action="append"`, so a run naming all six of them narrows - # nothing and must refuse a stale exemption exactly as a flagless - # run does. A genuine subset still only NOTEs, which is the whole - # of the argument above. - # - # `rules` here is _sorted_rules' output, which is intentional and - # harmless: since #451 every rule carries a name_regex, so the sort - # is the identity on every ledger that loads and positions are - # unchanged. Verified against all four shipped ledgers -- element - # identity, not just equality -- at the time of writing. - corpus_names = [str(e["name"]) for e in entries] - undeclared = undeclared_contests(rules, corpus_names) - if undeclared: - raise SystemExit("\n".join( - [f"{ledger.name} has {len(undeclared)} order-decided " - f"contest(s) nobody declared. Where the later rule's " - f"'fields' are a strict subset of the earlier one's and " - f"both regexes reach one name, file order alone picks the " - f"winner. Declare it on the EARLIER rule with a " - f"[[change.precedes_narrower]] block naming the later one " - f"and saying what it describes that the later one does " - f"not -- do NOT reorder, which moves which rule classifies " - f"a name and breaks _CROSS_RULE_WINNERS:"] - + [f" {c.earlier!r}\n outranks {c.later!r}\n" - f" on {len(c.names)} name(s), e.g. {list(c.names[:3])}" - for c in undeclared])) - vacant = vacant_exemptions(rules, corpus_names) - if vacant and not full_corpus: - print(f"NOTE: this run used --corpus, and over that SUBSET " - f"{len(vacant)} exemption(s) in {ledger.name} declare " - f"precedence over a pair nothing here contests. That " - f"count is not evidence of a stale exemption -- narrowing " - f"removes contests, so a declaration the full gate needs " - f"reads the same way. Re-run without --corpus before " - f"touching any of them.\n") - elif vacant: - raise SystemExit("\n".join( - [f"{ledger.name} carries {len(vacant)} exemption(s) over a " - f"pair that is not contested over the full corpus. A rule " - f"was narrowed or a corpus name left. Delete the exemption " - f"-- a justification for a hazard that is gone reads " - f"exactly like one for a hazard that is live:"] - + [f" {v.earlier!r}\n declares precedence over {v.later!r}" - for v in vacant])) - # A recorded diff shape naming a string no corpus holds any more - # (#497). _RECORDED_DIFFS pins the shape _CROSS_RULE_WINNERS feeds - # to classify() as an input, and a row nothing measures agrees with - # itself forever -- the exact shape of the defect that roster's - # shapes were found in. recorded_diff_mismatches cannot ask it: it - # skips a name it did not compare, which is right under `--corpus` - # and forgiving forever under the full gate, so this half is the - # caller's -- the only side that knows whether this run read every - # corpus. - # - # HERE, beside `vacant`, for the reason the block above gives: it - # reads the ledger and the loaded names and nothing the worker - # produces, and a refusal raised after the worker has installed the - # pinned wheel and compared the whole corpus prints below the run's - # own published `baseline:` header, for a comparison that will - # never be reported (#382). Not "disowning a comparison it just - # published": measured print order is `baseline:` at the tell, - # then the comparison loop, which prints nothing, then `corpus: - # ... intentional diffs:`, so at that later point the header - # would have printed and no line of the comparison would have. It - # is the ORDER of the two that earns the placement, as it does at - # the twin comment above. HERE, nothing has printed at all. - # The measured half of this check is the opposite case and - # sits after the comparison, where the diff it reads exists. - # - # PRE-skip `corpus_names`, where the measured half takes the - # POST-skip `corpus`. The two questions, why they take opposite - # lists, the three names they differ by at 1.4.0 and the recompute - # are recorded ONCE, in recorded_diff_mismatches' docstring -- read - # it before touching either line. One thing is not there and lives - # here, because it is an argument about THIS placement and not - # about that function: none of those three names carries a roster - # row today, so the post-skip reading would refuse nothing YET. It - # would wait for the first row on a shape-tagged name and then tell - # a contributor to delete a row for a name the run had just read - # past, which is why the hazard is invisible to the gate and has to - # be argued rather than measured. - # - # `full_corpus`, not `args.corpus`, for the same reason `vacant` - # reads it. Under a narrowing this is SILENT rather than a NOTE, - # which is where the two checks part: `vacant` prints a count a - # reader might act on, and here recorded_diff_mismatches has - # already dropped those names without reporting one, so a NOTE - # would add noise and no information. Measured 2026-09-05, it would - # name 28 to 43 of the 45 rows depending on which corpus was asked - # for (43 for corpus_shapes.jsonl) against `vacant`'s 5 to 11 of - # 11 (measured 2026-09-03, and untouched by a roster row). - # Recompute, from the worktree root: - # uv run python -c "import sys;sys.path.insert(0,'tools/\ - # differential');import compare,pathlib;r=set(compare.\ - # _RECORDED_DIFFS['expected_since_1.4.0.toml']);[print(p.name,\ - # len(r-{str(e['name']) for e in compare._load_entries(p)}),\ - # 'of',len(r)) for p in sorted(pathlib.Path('tools/differential')\ - # .glob('corpus*.jsonl'))]" - # `ledger` is a Path, so the key is `ledger.name` -- as the line - # below has it, and as an earlier draft of this recipe did not. - # - # BOTH rosters, because a watched row (_WATCHED_DIFFS) is measured - # by nothing else either: the two differ in what a mismatch means, - # not in whether a departed name can be measured, so the union is - # the population every check here reads. - # - # FAIL-CLOSED on a missing section, and indexed rather than - # `.get(ledger.name, {})` below for that reason. An empty section - # is a statement -- this ledger has no row of that kind -- where a - # missing one is nobody having looked, and a `.get` default reads - # the two alike: with the 1.4.0 key deleted from _WATCHED_DIFFS, - # a full run at that baseline checks 36 rows fewer, prints the - # same 375 lines (differing only in the worker environment's path - # on the `baseline:` line, as any two runs do) and exits 0 - # (measured 2026-09-05). The - # pytest-speed guards hold - # each dict's keys equal to the ledgers on disk - # (test_the_watched_roster_is_disjoint_and_names_every_ledger, and - # test_every_pinned_winner_has_a_recorded_shape through - # _CROSS_RULE_WINNERS); this refuses again because the tool may - # not assume the suite ran -- _CORPUS_TIERS' stance, applied to - # the rosters. - unsectioned = [dict_name for dict_name, roster in ( - ("_RECORDED_DIFFS", _RECORDED_DIFFS), - ("_WATCHED_DIFFS", _WATCHED_DIFFS)) if ledger.name not in roster] - if unsectioned: - raise SystemExit( - f"tools/differential/compare.py: {' and '.join(unsectioned)} " - f"carry no section for {ledger.name!r}. A ledger needs an " - f"explicit section in each shape roster, mapped to {{}} " - f"while it has no row of that kind: an empty section is a " - f"statement, a missing one is nobody having looked, and a " - f"default would read the two alike and check nothing for " - f"this ledger. tests/v2/test_ledger_guards.py holds every " - f"ledger on disk to the same key equality at pytest speed") - recorded = _RECORDED_DIFFS[ledger.name] - watched = _WATCHED_DIFFS[ledger.name] - # A name in BOTH dicts has not chosen which kind of row it is, and - # the two kinds carry different severities and different repairs, - # so the run cannot pick one for it. The pytest-speed guard in - # tests/v2/test_ledger_guards.py refuses the same overlap; this - # refuses it again because the tool may not assume the suite ran - # -- _CORPUS_TIERS is fail-closed here for the same reason. - both = sorted(set(recorded) & set(watched)) - if both: - raise SystemExit("\n".join( - [f"tools/differential/compare.py: {len(both)} name(s) sit in " - f"both _RECORDED_DIFFS[{ledger.name!r}] and " - f"_WATCHED_DIFFS[{ledger.name!r}]. A row is one kind or " - f"the other: a shape beside a winner adjudicates a " - f"contest, a shape alone watches a name no winner is " - f"pinned for. A name with an argument behind it belongs in " - f"_RECORDED_DIFFS alone -- delete its _WATCHED_DIFFS row:"] - + [f" {n!r}" for n in both])) - gone = sorted((set(recorded) | set(watched)) - set(corpus_names)) - if gone and full_corpus: - # The repair differs by roster and the message says so per - # list rather than once: a contest row has a partner pin in - # _CROSS_RULE_WINNERS to delete with it, a watched row has no - # partner, and one sentence covering both would tell a - # watched-row reader to delete something that does not exist. - contest_gone = [n for n in gone if n in recorded] - watched_gone = [n for n in gone if n in watched] - raise SystemExit("\n".join( - [f"tools/differential/compare.py: {len(gone)} recorded diff " - f"shape(s) for {ledger.name!r} name a string no corpus " - f"holds any more, over the FULL corpus. Nothing measures " - f"such a row, so it agrees with itself forever -- the shape " - f"of the defect #497 is about. Settle the question that " - f"picks the repair first: did the name leave DELIBERATELY? " - f"`git log -S'' -- tools/differential/corpus*.jsonl` " - f"answers it. If it did, delete the row. If it did not, " - f"restore the name to a corpus -- the corpus is what " - f"regressed. Both can be true of one name, which is why " - f"the question comes before the edit:"] - + ([" in _RECORDED_DIFFS, each with a partner in " - "_CROSS_RULE_WINNERS (tests/v2/test_ledger_guards.py) " - "that pins a winner for a contest nothing raises now " - "-- a deletion takes both:"] - + [f" {n!r}" for n in contest_gone] - if contest_gone else []) - + ([" in _WATCHED_DIFFS, pinning no winner, so the row is " - "the whole of the deletion:"] - + [f" {n!r}" for n in watched_gone] - if watched_gone else []))) - # an ORDER-BEARING entry must never reach a worker whose baseline - # cannot honor it (no Policy below 2.0.0) -- skip it and say so, - # rather than shrink the comparison silently. An order-NONE - # shape's min_baseline is documentary, not a skip trigger: the - # default policy exists at every baseline, so a name tagged with - # such a shape compares just fine below its min_baseline -- the - # resulting diff (if any) is an ordinary classified one, not a gap - # the skip needs to hide. Only an unhonorable ORDER forces a skip - # (shapes.py's docstring states the same asymmetry against shapes - # 4/5). Skips are also counted PER FILE: per_file above records - # pre-skip counts, so a shapes corpus fully skipped at an old - # baseline would otherwise print at full size while contributing - # nothing. - kept = [] - dropped = 0 - dropped_by_file: dict[str, int] = {} - dropped_shape_ids: set[int] = set() - dropped_minimums: set[str] = set() - for e in entries: - shape = e.get("shape") - if (shape is not None - and shapes_by_id[shape].order is not None - and _parse_version(baseline) - < _parse_version(shapes_by_id[shape].min_baseline)): - dropped += 1 - dropped_by_file[e["file"]] = dropped_by_file.get(e["file"], 0) + 1 - dropped_shape_ids.add(shape) - dropped_minimums.add(shapes_by_id[shape].min_baseline) - continue - kept.append(e) - if dropped: - ids = ", ".join(str(i) for i in sorted(dropped_shape_ids)) - minimums = ", ".join(sorted(dropped_minimums, key=_parse_version)) - print(f"skipped {dropped} name{'s' if dropped > 1 else ''} " - f"tagged shape(s) [{ids}]: baseline {baseline} predates " - f"their minimum ({minimums})") - entries = kept - corpus = [e["name"] for e in entries] - # per-file counts, not just the total: a corpus that shrinks or - # vanishes is only visible if its own number is printed. A file - # with skips ALSO prints its skip count, not just its final size -- - # otherwise a shapes corpus skipped to zero reads as a corpus that - # was simply never that large. N is pre-dedup and K counts only - # the baseline-minimum skip, so N - K is NOT "how many from this - # file were compared" -- an entry the cross-file dedup dropped is - # in neither number. - print("corpora: " + ", ".join( - f"{name} ({n}, {dropped_by_file[name]} skipped)" - if dropped_by_file.get(name) else f"{name} ({n})" - for name, n in per_file.items())) - - # The tree is checked BEFORE the worker runs. It depends on nothing - # the worker produces, and validate_rules' own reasoning applies: a - # misconfiguration that aborts after the whole install-and-compare - # pass is one the run reports below its own published `baseline:` - # line, having installed the wheel and compared the whole corpus - # for a report it will never make -- not "disowning a comparison it - # published", since nothing of the comparison prints until the - # `corpus: ... intentional diffs:` line further down. It - # is the ORDER that earns the placement, not the clock. This - # comment used to say that pass - # "costs minutes" -- a magnitude nobody had measured, and wrong: - # every baseline runs in well under a second. Withdrawn by #497. - # The figures, both recompute recipes, and the trap in timing - # _run_worker directly are in decisions.md, "the rule-order arc", - # kept in that one place because they carry a date there and a - # second copy is the copy that does not get updated. - import nameparser # the working tree -- verified, not assumed - from nameparser import HumanName - tree_at = _check_tree(nameparser.__file__) - print(f"tree: nameparser {nameparser.__version__} ({tree_at})") - - want_v2 = "v2" in surfaces - if want_v2: - from nameparser import Parser, Policy, parse - tree_parsers: dict[str, object] = {} - - def _tree_parse(name: str, order: str | None) -> object: - if order is None: - return parse(name) - if order not in tree_parsers: - tree_parsers[order] = Parser(policy=Policy( - name_order=getattr(nameparser, order))) - return tree_parsers[order].parse(name) - - tell, old_rows = _run_worker(baseline, want_v2, entries) - print(f"baseline: nameparser {tell['__version__']} ({tell['__file__']})") - #: (name, order) pairs, not names: one string compared under two - #: orders is two entries, and the report renders the order tag from - #: the pair. Rendering at print time rather than storing the line - #: keeps the bare name available to the Latin-only stat below, - #: which would otherwise need a second list to drift out of step - #: with this one. - by_issue: dict[str, list[tuple[str, str | None]]] = {} - #: the union of the diffs each rule explained, for over_declared_rules. - #: Kept beside by_issue rather than inside it: the summary printout - #: reads by_issue as a list of pairs. - roles_by_issue: dict[str, set[str]] = {} - # BOTH surfaces' old/new are retained, not just the facade's. A diff - # can exist on the v2 surface alone -- an _ambiguities-only change is - # facade-identical by construction, and is the case _surfaces_for - # names as the whole reason to compare v2 -- and keeping only the - # facade dicts would print such a name under UNEXPLAINED with no - # field lines under it at all: a failure nobody can act on. - unexplained: list[_Unexplained] = [] - # radar-tier equivalent of unexplained: reported, never fatal (#468) - radar: list[tuple[dict, _Unexplained]] = [] - # every name that diffed, with its diff AND the order its - # comparison ran under, so dormant_rules can ask which rule WOULD - # have claimed one that no rule did -- the same question classify - # asks, order included - diffing: list[tuple[str, set[str], str | None]] = [] - #: (issue, name, order) for each diff an order-blind rule explained - #: under a declared order. Informational, never fatal. - order_blind: list[tuple[str, str, str]] = [] - #: classify() returns an issue; the notice needs the rule behind it. - #: Keyed by issue because validate_rules has already refused two - #: rules sharing one. - rules_by_issue = {str(r["issue"]): r for r in rules} - for entry, old in zip(entries, old_rows): - name = entry["name"] - order = entry.get("order") - if order is None: - hn = HumanName(name) - new = {k: v or "" for k, v in hn.as_dict().items() - if k in FIELDS} - # must stay identical to the worker template's facade row - new["_initials"] = hn.initials() or "" - # canonicalized on the way in: the ledger speaks Role's - # names, and the facade is the surface whose vocabulary - # differs - diff = {_canonical_field(f) for f in FIELDS - if old["facade"].get(f, "") != new.get(f, "")} - else: - # order-bearing entries are compared on the v2 surface - # alone -- the facade is the v1-compat surface, and a - # family-first name is not a v1 contract - new = {} - diff = set() - new_v2: dict[str, object] = {} - if want_v2: - p = _tree_parse(name, order) - # must stay identical to the worker template's _v2_row - # (_WORKER_TEMPLATE, this file) -- duplicated rather than - # shared across the process boundary - new_v2 = {f: (getattr(p, f, "") or "") for f in V2_FIELDS} - new_v2["_ambiguities"] = sorted( - {a.kind.name for a in getattr(p, "ambiguities", ())}) - new_v2["_initials"] = p.initials() or "" - diff |= {_canonical_field(f) - for f in (*V2_FIELDS, "_ambiguities") - if old.get("v2", {}).get(f, "") != new_v2.get(f, "")} - # #484: initials() is a DERIVED view. It enters the diff only - # when every role and the ambiguity kinds agree on every - # compared surface -- render-layer drift, the one shape the - # field comparison cannot see. When a role moved, the initials - # movement is that move's consequence, not drift: it is neither - # compared nor printed, and the rule that explains the role - # diff explains it (decisions.md, "the initials view"). Strict - # subset semantics were measured and rejected there: they would - # have put `_initials` onto a long tail of existing rules for - # no added discrimination. - if not diff and ( - old.get("facade", {}).get("_initials", "") - != new.get("_initials", "") - or old.get("v2", {}).get("_initials", "") - != new_v2.get("_initials", "")): - diff = {"_initials"} - if not diff: - continue - diffing.append((name, diff, order)) - issue = classify(name, diff, rules, exclusions, order) - if issue is None: - row = (name, old.get("facade", {}), new, old.get("v2", {}), - new_v2, order, diff == {"_initials"}) - # classify() returns None for two different reasons: no - # rule matched, or a [[never]] entry refused the name -- - # and only the first belongs to the tier split. An - # exclusion was chosen (see _CORPUS_TIERS), so it is fatal - # on a radar name exactly as it is on a contract one. - excluded = any(_entry_matches(x, name, diff) - for x in exclusions) - if entry["tier"] == "radar" and not excluded: - radar.append((entry, row)) - else: - unexplained.append(row) - else: - by_issue.setdefault(issue, []).append((name, order)) - roles_by_issue.setdefault(issue, set()).update(diff) - if order is not None and "orders" not in rules_by_issue[issue]: - order_blind.append((issue, name, order)) - - # the bare halves of by_issue's pairs: _is_latin_only reads the - # string as a name, so it must never see the rendered order tag. A - # string compared under two orders counts twice here, accepted. - changed = [n for pairs in by_issue.values() for n, _ in pairs] \ - + [row[0] for row in unexplained] \ - + [e["name"] for e, _ in radar] - latin = sum(1 for n in changed if _is_latin_only(n)) - print(f"corpus: {len(corpus)} names; " - f"intentional diffs: {sum(map(len, by_issue.values()))}; " - f"unexplained: {len(unexplained)}; " - f"radar unclassified: {len(radar)}; " - f"{latin} of {len(changed)} changed names are Latin-only\n") - for issue, names in sorted(by_issue.items()): - print(f"## {issue} ({len(names)})") - for n, o in names[:10]: - print(f" {n!r}{_order_tag(o)}") - print() - # Informational, and deliberately outside the exit code: an - # order-blind rule is legal, and every rule written before shape - # tags is one. What the block buys is that the absorption stops - # being invisible -- a rule sorted ahead of the scoped ones can - # reach an order-bearing name its author never considered, and an - # order-only regression on that name would then classify as an - # intentional change. - if order_blind: - print("ORDER-BLIND (informational, not in the exit code): a rule " - "carrying no `orders` key explained a diff from an " - "order-bearing comparison. Consider scoping it with " - "`orders` -- including the \"DEFAULT\" sentinel if it " - "explains default-order diffs too.\n") - for issue, name, tagged in order_blind: - print(f" {issue!r} explained {name!r}{_order_tag(tagged)}") - print() - # The measured half of the recorded-shape check (#497): the shape - # _CROSS_RULE_WINNERS pins against the diff this run actually made, - # and the shape _WATCHED_DIFFS records against the same diff. - # HERE because this is the only place both exist -- validate_rules - # runs before any corpus is read, and the unit suite spawns no - # worker, deliberately, so neither can ask it. Its absent-name half - # needs none of that and refuses up beside `vacant`, pre-worker. - # - # PRINTS and feeds the exit code rather than raising, like - # over_declared_rules below -- its structural sibling, the other - # post-worker check on recorded data. A raise lands MID-REPORT and - # takes the rest of the run's output with it: measured, a - # `--corpus corpus.jsonl` run at 1.4.0 prints 62 EXPLAINED NOTHING - # lines, and 0 of them with one _RECORDED_DIFFS row corrupted, - # because the raise preceded dormancy, OVER-DECLARED, UNEXPLAINED - # and the radar block. A stale roster row must not hide an - # unexplained diff, which is the gate's primary output. The two - # pre-worker refusals raise before anything has printed, which is - # why they may; this one cannot. - # - # `compared` is the POST-skip name set -- main()'s `corpus` -- - # because the question is "was this name compared?", and a shape - # cannot be read off a comparison that never ran. The absent-name - # half reads the PRE-skip list, for the opposite reason spelled out - # there, and `recorded` is the per-ledger row dict both halves read, - # bound up beside it. - compared = {str(n) for n in corpus} - # THE WINDOW BETWEEN THE TWO HALVES, and it is not empty. A name can - # sit in a corpus file this run READ and still be outside `compared`, - # because the baseline-minimum skip above drops an order-bearing - # entry the baseline cannot honor. Such a row falls between both - # checks: recorded_diff_mismatches skips a name outside `compared`, - # and the `gone` refusal passes it because the skip takes a name out - # of the RUN and out of no file. Measured 2026-09-03 by putting a - # deliberately wrong shape on 'de la Cruz née Vega' over the FULL - # corpus at 1.4.0, and BOTH sides of the note are stated because - # only the pair says what the note bought: before `9360919` that - # run exited 0 in 375 stdout lines naming the name in none of them, - # and it now exits 0 in 378, the three added lines being the NOT - # CHECKED note below naming it. Re-measure by corrupting the row in - # _RECORDED_DIFFS in memory around main(), which leaves the - # worktree alone. The window is co-located with the only populated - # section, since 1.4.0 is both where the roster's rows live and the - # only baseline where the skip fires (every order-bearing shape's - # min_baseline is 2.0.0). - # - # A NOTE. Not a refusal, not in the exit code, and the distinction - # is the whole of it: recorded_diff_mismatches' docstring argues - # that refusing off the POST-skip list would tell a contributor to - # delete a row for a name sitting in corpus_shapes.jsonl at the - # compat baseline, and that argument stands. It is an argument - # against REFUSING, not against SAYING, and only the first of those - # was ever made. - # - # Intersected with `corpus_names` rather than gated on - # `full_corpus`, which is where this parts from the two checks that - # do read that flag. Under `--corpus` the pre-skip list is already - # narrowed to what this run loaded, so the intersection names only - # rows this run READ and then dropped -- a fact about the run in - # both modes, and never the departed-name question the `gone` half - # owns. Nothing else stands between the two lists: `entries` is - # rebuilt exactly once between them, by that skip. - # - # The UNION of the two rosters, like `gone` above: a watched row on - # a skipped entry is checked by neither half for exactly the same - # reason a contest row is, and which dict it sits in changes what - # a mismatch would mean, not whether one could be measured. - unchecked = sorted(((set(recorded) | set(watched)) & set(corpus_names)) - - compared) - if unchecked: - print(f"NOT CHECKED {ledger.name}: {len(unchecked)} recorded " - f"diff shape(s) name an entry this baseline skipped, so " - f"this run measured no diff to check them against. " - f"Informational, outside the exit code, and NOT a stale " - f"row: each name is in a corpus this run read -- the " - f"skip takes an order-bearing entry out of the RUN and " - f"out of no file -- so do not delete one over this. The " - f"shape report below speaks for every other row and for " - f"none of these; re-run at a baseline that can honor " - f"their order to check them:") - for n in unchecked: - print(f" {n!r}") - print() - # The severity split for a WATCHED row reads the compared entry's - # tier, and the entry is the post-skip one -- the same list - # `compared` was built from, so every name in `compared` has a key - # here and the lookups below index rather than `.get`: a miss would - # mean `compared` and `entries` had parted, which nothing between - # them can do. Not the order-None entries alone: a name compared - # under a declared order ALONE is in `compared` with no - # default-order entry, and a watched row on it reports with - # `measured` None (recorded_diff_mismatches' second cause), which - # is a mismatch owed a tier like any other -- read off only its - # order-None entry it would fall out of both lists below and - # print nowhere. So an order-None entry decides when there is one, - # since the shape recorded is the default-order shape, and the - # first-loaded entry decides otherwise, which is a contract one - # whenever any is: the (name, order) dedup loaded contract files - # first, and at most one order-None entry survives per name (a - # declared-order-only name has none), so the tier is unambiguous - # in both cases. - tier_of: dict[str, str] = {} - for e in entries: - if e["order"] is None or str(e["name"]) not in tier_of: - tier_of[str(e["name"])] = str(e["tier"]) - contest_bad = recorded_diff_mismatches(recorded, diffing, compared) - watched_bad = recorded_diff_mismatches(watched, diffing, compared) - # `== "radar"` and its complement rather than `== "contract"`, the - # reading the comparison loop above uses: a tier that is neither - # fails, which is the fail-closed direction. Defense in depth - # only, since the corpus load refuses any value outside the two - # literals -- so every tier that reaches this line IS one of them, - # and the watched block's "because the name is contract tier" is - # true of every value that lands on its side. - watched_contract = [m for m in watched_bad - if tier_of[m.name] != "radar"] - watched_radar = [m for m in watched_bad if tier_of[m.name] == "radar"] - # What the exit code reads. A contest row is fatal on either tier - # because it carries an argument; a watched row follows its name's - # tier, and _WATCHED_DIFFS' header carries why the radar half does - # not fail. - shape_bad = contest_bad + watched_contract - if contest_bad: - # The instruction and the disclaimer are properties of the - # CHECK, not of a row, so they lead the block once instead of - # riding every line -- the shape the Role-vocabulary legend - # below uses for the two blocks that share it. A real parser - # move lands on many of these rows at once, and per-row - # repetition would bury the names under its own advice. - # (OVER-DECLARED repeats its shared text per row and is not - # the precedent for doing so here: measured 2026-09-03 its - # row-invariant text is three sentences and 47 words, against - # this lead's two and 39, so it is the LONGER of the two -- it - # gets away with the repetition because its rows come one per - # over-declared RULE, where these come one per name and a real - # parser move lands on many at once. An earlier draft of this - # parenthetical called that text one sentence and cited it as - # the model.) No cause is - # named for a row with no measured - # shape: the check cannot tell the two apart -- - # recorded_diff_mismatches' docstring says why they are - # collapsed -- and naming one would send half the readers to - # the wrong file. - print(f"MOVED SHAPE {ledger.name}: {len(contest_bad)} recorded " - f"diff shape(s) disagree with this run. Each is a " - f"FINDING, not a number to update: the winner pinned " - f"beside the shape in _CROSS_RULE_WINNERS " - f"(tests/v2/test_ledger_guards.py) was recorded for the " - f"OLD shape, so read both before editing either." - + _two_causes(contest_bad)) - _print_moved_rows(contest_bad, "_RECORDED_DIFFS") - # The two watched blocks share a lead and differ in the last - # sentence, which is the severity and its reason. Neither names - # _CROSS_RULE_WINNERS: no row in them has a partner there, and a - # reader sent to that roster would find nothing to read. - if watched_contract: - print(f"MOVED SHAPE {ledger.name}: {len(watched_contract)} watched " - f"diff shape(s) disagree with this run. No winner is " - f"pinned for these names, so each is a FINDING, not a " - f"number to update: if the move is intended, re-record the shape " - f"in _WATCHED_DIFFS in the commit that moved it and say " - f"why there. This fails the run because the name is " - f"contract tier (_CORPUS_TIERS)." - + _two_causes(watched_contract)) - _print_moved_rows(watched_contract, "_WATCHED_DIFFS") - if watched_radar: - print(f"MOVED SHAPE (radar) {ledger.name}: {len(watched_radar)} " - f"watched diff shape(s) disagree with this run. No winner " - f"is pinned for these names, so each is a FINDING, not a " - f"number to update: if the move is intended, re-record " - f"the shape in _WATCHED_DIFFS in the commit that moved it " - f"and say why there. This does not fail the run because " - f"the name is radar tier (_CORPUS_TIERS), which watches " - f"without promising." - + _two_causes(watched_radar)) - _print_moved_rows(watched_radar, "_WATCHED_DIFFS") - # The completeness half of the contest checks (#498). The - # pre-worker `undeclared_contests` refusal covers the pairs where - # nesting supplies the contested shape's existence; this covers - # the pairs where only a measured diff can, which is why it is - # HERE and not up there -- it needs `diffing`, and `diffing` needs - # the worker. `recorded` is the same per-ledger dict the shape - # check reads, and its KEYS are what "a winner is pinned" means. - # - # FATAL on both tiers, in the exit expression beside `shape_bad`. - # A contest row is fatal on either tier because it carries an - # argument; an owed row is fatal because it carries none yet. - unowned = unowned_contests( - [d for d in diffing if d[0] in measured_names], rules, exclusions, - set(recorded), tier_of) - if unowned: - # Grouped by (winner, loser), so a reader editing one rule - # sees its whole cost at once rather than meeting the same - # pair once per name. dict preserves insertion order, which - # is `diffing` order, which is corpus order. - by_pair: dict[tuple[str, str], list[_UnownedContest]] = {} - for row in unowned: - by_pair.setdefault((row.winner, row.loser), []).append(row) - # PAIR(S) and not diff(s): the rows are one per (name, losing - # rule), so one name contesting two losers is two of them -- - # '田中さん, Dr.' is that shape at 1.4.0 -- and a reader - # counting names off this number would be short. - print(f"UNPINNED CONTEST {ledger.name}: {len(unowned)} contested " - f"pair(s), one per name and losing rule, that no rule " - f"outranks by nesting or declaration and " - f"no row pins. Pin each name below in _RECORDED_DIFFS " - f"(tools/differential/compare.py) and _CROSS_RULE_WINNERS " - f"(tests/v2/test_ledger_guards.py), with the argument for " - f"the winner beside the row -- or narrow the accident " - f"away until the pair stops admitting the same diff. A " - f"contest is not a defect: file order decides one " - f"legitimately, and narrow-first and a declared " - f"`precedes_narrower` pair are both justified above. An " - f"UNOWNED contest is: nothing in the tree says which rule " - f"should win it, so a reorder or a widened `fields` hands " - f"the name over silently. A _WATCHED_DIFFS row does NOT " - f"pin -- it records a shape and no winner. The population " - f"is the entries THIS run loaded, so a `--corpus` subset " - f"can only UNDER-report: silence is no verdict on the " - f"names it did not compare." - + (" NOTE: a row below carries a declared order, and a " - "_RECORDED_DIFFS row is a DEFAULT-order shape, so no " - "row can absorb it: narrow the pair, or scope one of " - "the two rules with `orders`." - if any(r.order is not None for r in unowned) else "")) - for (winner, loser), rows in by_pair.items(): - print(f" {winner!r}\n outranks {loser!r} on:") - for row in rows: - print(f" {row.name!r}{_order_tag(row.order)} " - f"[{row.tier}] {list(row.diff)} " - f"({_UNOWNED_WHY[row.kind]})") - print() - dormancy = dormant_rules(rules, set(by_issue), diffing, exclusions) - for dormant in dormancy.undeclared: - print(f"EXPLAINED NOTHING {dormant.issue!r}\n " - f"{_DORMANT_WHY[dormant.kind].format(by=repr(dormant.detail))}") - for issue in dormancy.awake: - print(f"NO LONGER DORMANT {issue!r}\n it explained a diff in " - f"this run, so its `dormant` reason is now false -- remove " - f"the key") - if dormancy.undeclared or dormancy.awake: - print() - overwide = over_declared_rules(rules, roles_by_issue) - for wide in overwide: - # The ledger is NAMED, unlike the two blocks above, because this - # rule's correct `fields` differ per baseline -- fix(#296) is - # exactly exercised at 1.4.0 and over-declared at both 2.x -- so - # a message without the file sends the reader to edit a rule - # that is not the broken one, which is validate_rules' own - # stated reason for carrying `ledger`. - print(f"OVER-DECLARED {ledger.name}: {wide.issue!r}\n " - f"declares {list(wide.unused)}, which no diff it explains " - f"moves; every one fits {list(wide.observed)}. Narrow " - f"`fields` to that. classify() matches by SUBSET, so the " - f"excess is not inert -- it lets this rule keep claiming a " - f"name whose diff shrinks out of the extra role, with " - f"nothing to say so (#452)." - + (" NOTE: this run used --corpus, so the union above is " - "over a SUBSET and the repair may orphan a name the " - "full gate compares -- confirm before narrowing." - if args.corpus else "")) - if overwide: - print() - # the radar rows below print the same Role-named field lines, so - # the legend belongs to both blocks or the radar reader is told - # nothing about the vocabulary they are reading - if unexplained or radar: - print("Field names below are Role's, matching what a ledger " - "`fields` rule must say.\n") - for (name, old_facade, new, old_v2, new_v2, order, - initials_only) in unexplained: - # the order tag distinguishes a family-first regression from a - # default-order one on the same name -- otherwise indistinguishable - # in the report - print(f"UNEXPLAINED {name!r}{_order_tag(order)}") - _print_field_diffs(old_facade, new, old_v2, new_v2, order, - initials_only=initials_only) - if radar: - print("\nRadar tier (names the contract does not answer for, " - "#468): shown, never blocking. Promote a name that " - "matters via a cases.py row + shape tag, or -- for a " - "demoted one -- by clearing `tolerated` on its rows.\n") - for entry, (name, old_facade, new, old_v2, new_v2, order, - initials_only) in radar: - labels = entry.get("tests") - tag = f" [v1: {', '.join(labels)}]" if labels else "" - print(f"UNCLASSIFIED (radar) {name!r}{tag}{_order_tag(order)}") - _print_field_diffs(old_facade, new, old_v2, new_v2, order, - initials_only=initials_only) - # A rule explaining nothing is as much a broken contract as an - # unexplained diff: both mean the ledger no longer describes what - # the code does. A rule that has STOPPED explaining nothing is the - # same statement inverted -- its `dormant` reason is now false. A - # rule explaining LESS than it declares is a fourth way, and the - # quietest: it still matches, so nothing looks broken, but the - # `fields` it names are no longer what the code moves. A recorded - # shape the run contradicts is the fifth, and it reaches furthest: - # _CROSS_RULE_WINNERS feeds that shape to classify() as an input, so - # a wrong one takes the roster's verdict with it. A measured contest - # nobody has pinned is the SIXTH, and among the ROSTER terms -- - # `shape_bad`'s two rosters and this one -- it is the only one about - # a row that does not exist: those two read a row somebody wrote and - # ask whether the run still agrees, while this one asks for a row - # nobody has written yet. In that it joins `unexplained`, which is a - # diff no rule was written for. One exit code for all six terms - # below, so none of them is the one nobody noticed. - # `shape_bad` is the contest rows plus the watched rows on contract - # names; a watched row on a radar name printed above and is not in - # it, by the severity rule _WATCHED_DIFFS' header argues. - return 1 if unexplained or dormancy.undeclared or dormancy.awake \ - or overwide or shape_bad or unowned else 0 + moves, moved, latin = compare(args.baseline) + print("\n".join(moves) or "no differences") + print(f"\n{moved} names read differently from {args.baseline}, " + f"{latin} of them Latin-only") + return 0 if __name__ == "__main__": diff --git a/tools/differential/corpus_cjk.jsonl b/tools/differential/corpus_cjk.jsonl index 1c8048b1..8c2f1730 100644 --- a/tools/differential/corpus_cjk.jsonl +++ b/tools/differential/corpus_cjk.jsonl @@ -1,6 +1,19 @@ +"(김민준.) John Smith" "Andersonさん" "Anderson선생님" +"Dr 김민준, Jr." +"Dr 김민준, MA" +"Dr 김민준씨, Jr." +"Dr 김민준씨, V." +"Dr. 김민준씨, MA" +"J.씨" +"John Smith 田.中." +"John Smith, Dr. J.씨" "John 王" +"John 田中さん, XYZ" +"Jo김민준씨, DO" +"Kim김민준씨, MA" +"Smith 김민준씨, XYZ" "〆木 ひろ" "〆木 太郎" "〆木太郎" @@ -12,6 +25,7 @@ "マイケル" "マイケル ア゙イ" "マイケル ジャクソン" +"マイケル." "マイケル·ジャクソン" "マイケル・ジャクソン" "佐々木 太郎" @@ -19,6 +33,7 @@ "司马相如" "夏侯惇" "威廉·莎士比亚" +"威廉·莎士比亚, PhD" "威廉·莎士比亚さん" "威廉・莎士比亚" "山田 ア゙イ" @@ -37,19 +52,40 @@ "山田タロウ" "张伟" "毛 泽东" +"毛 泽东 Dr." "毛 김" "毛泽东" +"毛泽东 MA" +"毛泽东, MA" "王·Smith" "王先生" +"王先生, V." "王君" "王小明 先生" "王小明先生" "王氏" "田中 さん" +"田中 太郎 旧姓 佐藤 MA" "田中 太郎 様" +"田中 太郎, MA" "田中 殿" +"田中, 太郎 MA" +"田中, 太郎さん" +"田中." +"田中. 太郎" "田中『ハナ』花子" "田中さん" +"田中さん II" +"田中さん V." +"田中さん 様." +"田中さん, Dr." +"田中さん, Ph. D." +"田中さん, PhD" +"田中さん, V." +"田中さん, 太郎" +"田中さん, 様." +"田中さん." +"田中さん., V." "田中博士" "諸葛亮" "阿明" @@ -60,25 +96,58 @@ "高橋一郎" "鵜殿" "김 민준" +"김, 민준 MA" +"김, 민준씨" +"김, 민준씨 (Jimmy)" +"김. 민준" +"김゙민준" "김민준" "김민준 님" +"김민준 박, MA Jones" +"김민준 박, X.Y.Z." "김민준 박사" "김민준 박사 씨" "김민준 박사님" "김민준 씨" +"김민준 씨." +"김민준 씨。" +"김민준 씨." +"김민준 씨。" "김민준 양" +"김민준 양." +"김민준, 씨" +"김민준, 씨." +"김민준." +"김민준. 지훈" "김민준님" "김민준박사님" "김민준씨" +"김민준씨 (Jimmy)" +"김민준씨 Jr." +"김민준씨, J.씨" +"김민준씨, MA PhD" +"김민준씨, V." +"김민준씨." +"김민준씨., J.씨" "김선생님" "김씨" "김지양" "남궁" "남궁민수" +"남궁민수, 지훈" "마이클·잭슨" +"마틴 킹, MA" "선생님" +"선생님, J.씨" "씨" "양 미선" "양 지훈" +"양 지훈." +"양. 지훈" +"이, J.씨" +"이, J.씨." +"지훈, 남궁민수" +"タナカ. John" "タロウ·ヤマダ" +"マイケル." "ヤマダ タロウ" diff --git a/tools/differential/corpus_cjk_tolerated.jsonl b/tools/differential/corpus_cjk_tolerated.jsonl deleted file mode 100644 index 3935ba36..00000000 --- a/tools/differential/corpus_cjk_tolerated.jsonl +++ /dev/null @@ -1,69 +0,0 @@ -"(김민준.) John Smith" -"Dr 김민준, Jr." -"Dr 김민준, MA" -"Dr 김민준씨, Jr." -"Dr 김민준씨, V." -"Dr. 김민준씨, MA" -"J.씨" -"John Smith 田.中." -"John Smith, Dr. J.씨" -"John 田中さん, XYZ" -"Jo김민준씨, DO" -"Kim김민준씨, MA" -"Smith 김민준씨, XYZ" -"マイケル." -"威廉·莎士比亚, PhD" -"毛 泽东 Dr." -"毛泽东 MA" -"毛泽东, MA" -"王先生, V." -"田中 太郎 旧姓 佐藤 MA" -"田中 太郎, MA" -"田中, 太郎 MA" -"田中, 太郎さん" -"田中." -"田中. 太郎" -"田中さん II" -"田中さん V." -"田中さん 様." -"田中さん, Dr." -"田中さん, Ph. D." -"田中さん, PhD" -"田中さん, V." -"田中さん, 太郎" -"田中さん, 様." -"田中さん." -"田中さん., V." -"김, 민준 MA" -"김, 민준씨" -"김, 민준씨 (Jimmy)" -"김. 민준" -"김゙민준" -"김민준 박, MA Jones" -"김민준 박, X.Y.Z." -"김민준 씨." -"김민준 씨。" -"김민준 씨." -"김민준 씨。" -"김민준 양." -"김민준, 씨" -"김민준, 씨." -"김민준." -"김민준. 지훈" -"김민준씨 (Jimmy)" -"김민준씨 Jr." -"김민준씨, J.씨" -"김민준씨, MA PhD" -"김민준씨, V." -"김민준씨." -"김민준씨., J.씨" -"남궁민수, 지훈" -"마틴 킹, MA" -"선생님, J.씨" -"양 지훈." -"양. 지훈" -"이, J.씨" -"이, J.씨." -"지훈, 남궁민수" -"タナカ. John" -"マイケル." diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 95664983..c21fe8ac 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -293,6 +293,7 @@ "Juan y Garcia née Jones" "Juan Ó Pérez" "Juan Ó. Pérez" +"Kim김민준씨, MA" "Lord Chancellor" "MD DDS" "MSc Dr. med. univ." @@ -529,8 +530,12 @@ "毛·泽东" "毛泽东" "王君" +"田中. 太郎" "田中、太郎" "田中さん" +"田中さん 様." +"田中さん, Dr." +"田中さん, PhD" "马丁·路德·金씨" "高橋 みなみ" "高橋・一郎" @@ -539,8 +544,11 @@ "김민준" "김민준 박사님" "김민준씨" +"김민준씨." "김지양" "남궁민수" "남궁민수 지훈" +"남궁민수, 지훈" "선생님" +"지훈, 남궁민수" "ヤマダ タロウ" diff --git a/tools/differential/expected_since_1.4.0.toml b/tools/differential/expected_since_1.4.0.toml deleted file mode 100644 index e0a62cc8..00000000 --- a/tools/differential/expected_since_1.4.0.toml +++ /dev/null @@ -1,4981 +0,0 @@ -# Every rule needs all three of `issue`, `name_regex` and `fields`. -# `fields` stopped being optional on 2026-08-29: validate_rules had -# refused a rule carrying `fields` and no `name_regex` since #451, and -# #456 closed the mirror shape, so a half-declared rule is now a -# startup error whichever key is the missing one. An unexplained diff -# is a release blocker until classified -# (tools/differential/README.md). The citation here used to name a -# gitignored spec requirement, and passed the armed legacy-citation -# guard only because the line wrapped between the two words. -# Rules were seeded from the 2.0 rewrite's working notes -# and the `classification="fix(...)"` rows in tests/v2/cases.py; keep -# each entry's `name_regex`/`fields` as tight as the diff allows. -# FILE ORDER IS LOAD-BEARING: every rule carries a `name_regex`, so -# they all sit in one tier, _sorted_rules is stable, and file order -# decides every tie. Write the narrower rule first -- that is the -# DEFAULT and not a law: a wider rule that describes a compound -# behavior its component rule does not is the better classifier and -# stays where it is, saying so in a [[change.precedes_narrower]] block -# naming the rule it outranks (#382; eleven rules below carry one, and -# tools/differential/README.md's "Declaring a wide-first pair" owns -# the mechanics). Do NOT reorder to satisfy the check -- that moves -# which rule classifies a name and breaks _CROSS_RULE_WINNERS. See -# also the note above the four fix(suffix-routing) rules at the end of -# this file. -# -# RECOMPUTE, once for all eleven. Those `why` texts quote counts that -# drift -- "explains N names", the contract/radar splits inside them, -# the 17/15/9/6 breakdown on the compound/peel pair, and how many -# contract names go UNEXPLAINED if a rule is deleted. NONE is pinned -# by a test, so re-measure before editing any of them rather than -# copying the neighbouring digits (docs/design/AGENTS.md: a count -# that drifts carries its recompute; a stale one here has already -# reached a filed issue once). -# 1. `uv run python tools/differential/compare.py --baseline 1.4.0` -# prints `## (N)` per rule. N IS the explains-count; the -# roster under it truncates at ten names, the heading does not. -# 2. For a tier split, take each explained name's corpus file from -# the tools/differential/corpus*.jsonl glob and its tier from -# compare.py's _CORPUS_TIERS -- read that map, never rebuild it -# from a file's name or size. corpus.jsonl is the LARGEST corpus -# and it is RADAR; a name in both tiers reads contract, the -# dedup loading contract files first. -# 3. For "N names this pair is contested over", call -# order_contests(rules, names) with `names` the union of -# _load_entries over that same glob. -# 4. For "deleting the rule sends N contract names to UNEXPLAINED", -# delete the rule and re-run step 1. -# Do NOT substitute driving classify() over a rule's regex reach at -# its declared `fields`: that answers what the rule COULD claim, not -# what it explains. Measured 2026-09-02 over the rules named in the -# eleven exemptions, reach runs from equal to the explained count up -# to forty times it -- 288 against 8 for the lone post-comma routing -# rule, which is the rule a wrong count reached a filed issue on. - -# #436/#437: the suffix view's entry boundary became the comma the -# writer typed. rules.md#R1: "a run of post-nominals written with -# spaces renders with spaces, and one written with commas keeps -# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA -# path only since 2.3, which is the whole of what these names move. -# Every one goes from a comma-joined render to a space-joined one; -# none goes the other way, and none has a comma in the original -# between the two words. Roles do not move: this rule declares -# `suffix` alone, so a name whose ROLES move here arrives -# UNEXPLAINED, as it should. -# -# FIRST in the file, and that is narrow-first rather than a -# preference: `fields = ["suffix"]` is a strict subset of several -# rules below, and any of them sitting earlier while both regexes -# reach one corpus name is an undeclared order-decided contest the -# run refuses (#382). Measured 2026-09-06 by moving this rule to -# the end of a scratch copy of this file and re-running the gate, -# which raises 3 such contests here. -# -# An anchored alternation of the names rather than a shape, because -# the shape is "two post-nominals with a space between them" and -# that reaches every listing form in the corpora. The members are a -# list of NAMES and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 10 with its digest, and -# _MUST_NOT_MATCH pins the five comma and delimiter forms it must -# never claim. -[[change]] -issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" -# 2026-09-20, #397: one name joins the alternation and the rule does -# not change. 'Josep Lluis Carod i III' is a two-word generation run -# this baseline renders 'i, III' and the tree renders 'i III' -- the -# whole of its diff here. #397 moves NOTHING on it: measured against -# the parent 46651750 the parse is byte-identical before and after -# this bundle (given 'Josep', middle 'Lluis', family 'Carod', suffix -# 'i III'), the link having no name word to its right. What grew is -# the CORPUS -- the name arrived with #397's own case rows -- and the -# run rendering is what the diff is about. -# 2026-09-27, #544: Six names join the alternation, each diffing here on -# the rendering alone -- the roles are this baseline's, and #544 is what -# put them back ('Doe, Jane PhD MEng', 'Jane Doe Jr. Ma', 'John Smith PhD Ed Ma', 'John Smith PhD MEng', 'John Smith PhD Ma', 'doe, jane v phd do'). The tree before #544 read the Title-case -# member behind the credential as a name word, the case lean stopping -# the peel at it; the company clause (rules.md#S2) reads it as the -# credential, so the run is v1's again and only R1's spacing differs. -name_regex = "^(?:Doe, Jane PhD MEng|JOHN DOE PHD MD|Jane Doe Jr\\. Ma|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|John Smith PhD Ed Ma|John Smith PhD MEng|John Smith PhD Ma|Josep Lluis Carod i III|Kenneth Clarke QC MP|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V|doe, jane v phd do)$" -fields = ["suffix"] - -# #346: swami, guru, baba and lama moved from the TITLES-only block -# into GIVEN_NAME_TITLES on 2026-09-06. rules.md#H1's Accepted clause -# -- "a given-name title plus one name word leaves the family empty" -# -- now reaches them, so the one name word behind the title is the -# GIVEN name and the family is empty, where every baseline read it as -# the family. Both roles move together and `fields` declares both: a -# name whose TITLE moved would arrive UNEXPLAINED, as it should. -# -# Four corpus names, all from the radar-tier corpus_issues.jsonl and -# all moving the same way. The two- and three-word spellings are the -# boundary and are _MUST_NOT_MATCH probes: 'Swami Vivekananda -# Saraswati' and 'Guru Gobind Singh' keep their family names, because -# H1's fold requires exactly one name word, and a rule reaching them -# would stand ready to explain the very regression that boundary -# exists to catch. -# -# An anchored alternation of the four NAMES rather than a shape, -# under the ledger discipline expected_since_1.4.0.toml's header -# states for all four files: keep each entry's `name_regex` as tight -# as the diff allows. Four names moved, so a four-name list is the -# tightest expression of that diff; anything shape-shaped is wider -# than what was measured. It copies no wordlist either, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 4 with its digest. -[[change]] -issue = "fix(#346) a renunciate title and one name word leave the name a given name" -name_regex = "^(?:Baba Ramdev|Guru Nanak|Lama Zopa|Swami Vivekananda)$" -fields = ["family", "given"] - -# #519: prince and princess moved from the TITLES-only block into -# GIVEN_NAME_TITLES on 2026-09-10. rules.md#H1's fold now reaches -# them, so the one name word behind the title is the GIVEN name and -# the family is empty, where every baseline read it as the family. -# Both roles move together and `fields` declares both. -# -# Three corpus names: 'Prince Charles' and 'Prince Fielder' from the -# radar-tier corpus_issues.jsonl, and the contract-tier rules.md -# example 'Her Royal Highness Princess Anne' (corpus_rules.jsonl), all -# moving the same way. 'Prince Fielder' moves too and is wrong either -# way -- Prince is his given name, which is #348's collision and not -# this rule's subject. The boundary is pinned by _MUST_NOT_MATCH -# probes: two name words behind the title ('Prince Harry Windsor'), -# the chained run 'Prince of Wales Jr' (addresses by `wales`), the -# trailing 'Robert Prince', 'Lord Byron' (`lord` stays out) and the -# bare 'Prince'. -# -# An anchored alternation of the NAMES rather than a shape, under -# the ledger discipline expected_since_1.4.0.toml's header states: -# keep each entry's `name_regex` as tight as the diff allows. It -# copies no wordlist, which _NOT_A_VOCABULARY_COPY records; -# _CORPUS_CLAIMS pins the reach with its digest. -[[change]] -issue = "fix(#519) prince and princess join the given-name titles" -name_regex = "^(?:Her Royal Highness Princess Anne|Prince Charles|Prince Fielder)$" -fields = ["family", "given"] - -[[change]] -issue = "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots" -# '毛 泽东', '김민준': script_orders flips first/last for a name written -# wholly in Han or Hangul, and the Korean surnames that now ship as -# default vocabulary additionally split an unspaced hangul token into -# last + first. '高橋 みなみ', 'マイケル・ジャクソン': #272 extends the -# same order table to kana-bearing Japanese names and makes the -# katakana middle dot a token separator, which moves pieces between the -# same three fields. Seeded from the fix(#271) and fix(#272) rows in -# tests/v2/cases.py. -# -# One rule, not two, because it is one diff shape: both issues move -# name pieces between first/middle/last on native-script CJK input, and -# splitting the class by issue would need a rule per script with no -# gain in tightness (the fields list is already the narrow half). -# -# Scoped by script, which is exactly the scope of the behavior -- -# nothing here can touch a name with no CJK character in it. Han's -# astral block (U+20000-U+323AF) is deliberately left out of the class: -# no name in either corpus reaches it, and a rule should be no wider -# than the diffs it has to explain. Extend it -- and the sync pin in -# tests/v2/test_ledger_guards.py, which fails if the two disagree -- if -# one ever does. The kana spans are BMP-only for a different reason: -# supplementary-plane kana is hentaigana and other archaic forms no -# modern name uses, so _SCRIPT_RANGES does not list it either. -# -# The halfwidth kana span (U+FF65-U+FF9F) is the table's own since -# #594. Until then U+FF65 alone stood here, a span _SCRIPT_RANGES did -# not have: the halfwidth middle dot separated tokens like its -# fullwidth twin, so 'マイケル・ジャクソン' split where 1.4 left one token, -# while the rest of the halfwidth block was left out of the table as a -# separate normalization problem, and out of this class because a -# dotless halfwidth name was parity -- which it still is where it -# is wholly katakana ('ヤマダ タロウ' keeps the declared order, as -# 'マイケル ジャクソン' does). What #594 moved is the kana license and -# the 间隔号's flank guard reaching halfwidth kana: '山田 タロウ' reads -# family-first as '山田 エミ' does, and 'タロウ·ヤマダ' divides. Both are -# this rule's diffs, so the class now copies the block whole like -# every other span here. U+00B7, the context-sensitive -# 间隔号 (#298), deliberately gets NO span even though it too changes -# parses: its flank guard divides only between classified-script -# characters, so every name it can change already matches this class -# through a flanking character -- and a B7 span's only actual effect -# would be letting this rule claim first/middle/last diffs on -# punt-volat Latin names ('Gal·la Marcet'), pre-excusing a regression -# on exactly the input class the guard exists to protect. -# -# corpus_cjk.jsonl (#295) exists so this rule fires in every real -# run: build_cjk_corpus.py harvests every CJK-bearing text in -# tests/v2/cases.py -- the fix(#271)/fix(#272)/fix(shime-mark) rows -# AND the parity rows, whose value here is pinning NON-diffs (マイケル -# must not change against 1.4) -- closing the blind spot the other -# two corpora have by construction: v1's banks had no reason to test -# CJK, and build_issues_corpus.py requires an internal space, which -# unspaced names never have. -# -# Since 2026-09-01 that one harvest writes TWO files, not one: rows -# carrying `tolerated` go to corpus_cjk_tolerated.jsonl and the rest -# to corpus_cjk.jsonl. The never-dormant conclusion above survives -# the split unchanged, because both files load in every real run -- -# what the split changed is the TIER (corpus_cjk_tolerated.jsonl is -# radar, so an unmatched diff on one of its names reports instead of -# failing), and a rule fires on names it is given regardless of what -# an unmatched diff would then cost. -name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F]" -fields = ["given", "middle", "family"] - -[[change]] -issue = "fix(#274) maiden markers consumed" -# The alternation is a hand copy of MAIDEN_MARKERS, pinned by -# tests/v2/test_ledger_guards.py: every alternative must match at least -# one entry the config ships, and none may match ordinary name text. -# -# Its members reach 3 of the 17 entries -- geb, nee, née -- which is -# not the same set as the markers the corpora contain (geb, nee, née, -# z domu and 旧姓; the first three are covered here). Fourteen entries -# go unreached, and the carve-outs in that arithmetic are -# load-bearing: TWO of the fourteen do appear in the corpora and each -# is classified somewhere other than this alternation. 旧姓 is one, -# and it has fix(cjk-maiden-marker) of its own below rather than an -# alternative here. z domu is the other, and it cannot be an -# alternative here at all: this is a per-WORD regex over a name, and -# the words of a phrase entry are an ordinary Polish preposition and -# an ordinary noun -- an alternative matching either would claim names -# carrying no marker, which is the one thing the pin above forbids. -# Its two names are classified by the fix(#434) rules, keyed on the -# literal. Growing this rule toward the other TWELVE buys nothing: -# none of them appears as a token in any corpus, so there is no diff -# for them to classify. The roz alternative was -# dropped in 2.2 with the vocabulary entry it copied (see -# maiden_markers.py's Deliberately absent note): no corpus name -# contains the string in any case or position, so it classified -# nothing and its removal moved no name. -# -# 旧姓 is a different case, and it has been got wrong three times, so -# state it precisely. Widening this rule to cover it changes no corpus -# classification -- every 旧姓 name in the corpora leaves a wholly Han -# residual, which reads family-first (#271), so its diff includes -# `given`, and classify() requires the diff's fields to be a subset of -# the rule's. That is a fact about the CORPUS, not about the rules: a -# 旧姓 name with a Latin residual would diff without `given`, and since -# this rule sorts ahead of fix(cjk-maiden-marker) in the same tier it -# would take that name from it. Inert today, shadowing in principle. -# Widen deliberately, and record the new coverage in -# _LATIN_ALTERNATION_SOURCES. -# -# 'born' was an alternative here from the harness's first commit and -# was removed in #350. It is not in MAIDEN_MARKERS and never was, in -# v1 or v2, so it could not correspond to a real #274 change. What it -# could do is claim any name containing "born" -- with fields -# maiden/middle/family -- and absorb a genuine regression. No corpus -# name contains it, so removing it changed no classification. -# -# Supporting 'born' would mean shipping it as vocabulary first, and it -# is not free: 'Bertha Born Smith' loses its family name to the marker. -# Note the vocabulary is not missing English -- 'nee' is there, and -# tests/v2/cases.py calls it the spelling English writes most often. -# See maiden_markers.py's own "Deliberately absent" note for what has -# actually been considered and declined. -# -# The lookarounds and the two context tests are not decoration. corpus_rules.jsonl (#414) -# brought in 'Jane Smith (Nee)' and 'Jane Smith (Nee) (Jones)', which -# are M1's examples of a one-word clause keeping a word that may -# itself be a surname. Under the DEFAULT facade -- which is what the -# differential parses with -- no maiden delimiter is configured, so -# the parenthesized word is a NICKNAME and no marker is active at all. -# Without the lookarounds this rule matched the word anyway and stood -# ready to explain a `maiden` diff on a name that has no marker in it, -# which is absorption waiting to happen. The vocabulary-presence guard -# in test_ledger_guards.py is what caught it. -# -# The same corpus addition also put M2's BOUNDARY examples in reach -- -# 'Jones nee', 'nee Jones', 'Jane van der Berg nee', 'Muller geb.' -- -# names M2 states as unchanged, because a marker with nothing before -# or after it is just a word. Nothing guards that: those names really -# do carry marker vocabulary, so the presence guard is satisfied. -# Hence the two context tests, which say what this rule has always -# meant: a marker with a name word BEFORE it and one AFTER it. Reach -# 16 -> 11 when they were added, and every name it explained then was -# inside the eleven -- figures from that measurement rather than from -# today's corpus, which has grown since and keeps growing. For today's, -# read the `## fix(#274) maiden markers consumed (N)` heading off -# `compare.py --baseline 1.4.0`; a number written here instead would -# only be that heading's value on the day someone typed it. -# -# These claimed names still do not move, recorded rather than -# excluded: 'Jane Smith, née Jones' (post-comma, M2's remaining -# Accepted limit), 'Jane van der Berg née PhD' and 'Jane Smith née V' -# (only a suffix follows, so the consumer declines), and -# 'Jane née Jr y Jones' and 'Jane van der Berg née Jr Jones' (the -# connective and bound-given joins, #412). Each would need the regex -# to ask a vocabulary question -- suffix or connective membership -- -# a second alternation this rule cannot spend. A count stood at the -# head of that list and said four until a review round re-measured -# it: the corpus grew and the sentence did not, which is this file's -# standing hazard. The list is the count now, and it is self-checking -# where a digit was not. Measured 2026-08-27 against the 1.4.0 wheel, -# name by name over everything the regex claims rather than over a -# sample of it -- a walk whose length is whatever `_claimed()` returns -# that day. -# -# #445 landed 2026-08-27, and the prediction this note used to carry -# was wrong in a way worth keeping on the record. It reasoned that a -# "keep the family" fix would leave these diffs a subset of {maiden, -# middle, family}, so this rule would go on explaining them and would -# absorb the change in silence. The rule #445 actually chose MOVES -# the one name word rather than adding one: `given` empties as -# `family` fills, so every name that moved left this rule's fields -# and arrived UNEXPLAINED -- loud, at all three baselines. The note -# earned its keep all the same, and that is why it is corrected here -# rather than deleted: it named the right rule, the right field and -# the right eight names, and re-measuring its checklist is what -# showed its own reasoning wrong. -# -# Six of the eight moved: five by the fix(#445) rule at the end of -# this file, and 'John née Jones Smith Ma' by the fix(#424/#445) -# acronym rule above it, its diff carrying the suffix v1 read as -# well. The other two are rules.md#M4's own carve-outs, and their -# diffs are unchanged to the field, which is what makes them a -# boundary rather than an oversight. 'abd née Jones' -- whose word -# the bound-given vocabulary has claimed -- is still this rule's. -# 'J. née Jones Smith V' -- whose word is an initial -- is NOT, and -# never was: its 1.4.0 diff carries `suffix`, which these three -# fields cannot hold, so 'fix(#424) accepted: the maiden walk keeps -# the numeral an initial before the marker vetoes' is what explains -# it (asked of classify directly, 2026-08-27, rather than read off -# the regexes). The safety argument holds through that rule instead: -# it declares no `given` either, so a `given` move on either -# carve-out lands UNEXPLAINED rather than absorbed. -# -# The two other fix(#445) names, -# 'Smith née Jones' and 'Smith née Jones PhD', are corpus rows M4's -# own example lines added. -name_regex = "(?i)(? given 'Bob', family 'Jones', the title staying -# where it was (the post-comma word is a title on both sides). 2.0 had -# merged the pre-comma run into the family (the comma-precomma-family -# rule below, whose fields would absorb this move -- written ahead of -# it so the name files here and that rule's count tells). -name_regex = "(?i)^bob\\s+jones,\\s*[a-z]+$" -fields = ["given", "family"] - -[[change]] -issue = "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example" -# 'John Smith, Mr.' and 'John Smith, Mr. Jr.': the rules.md#C1 examples -# of the rule above (first 'John Smith' -> given 'John', family -# 'Smith'; the title stays where it was; with the postnominal, v1's -# first 'Jr.' is the suffix). Without this rule the second landed on -# the fields-only fix(suffix-routing) catch-all, back when that -# catch-all declared `given` as well; once #451 narrowed it to -# {family, suffix} a {given, family, suffix} diff stopped being a -# subset of it, and #451 has now deleted it outright. Measured both -# before and after that deletion: with this rule removed the diff is -# UNEXPLAINED. Its own literal rather than an alternation with 'Bob -# Jones', which would be no vocabulary. -name_regex = "(?i)^john\\s+smith,\\s*mr\\.?(\\s+jr\\.?)?$" -fields = ["given", "family", "suffix"] - -[[change.precedes_narrower]] -issue = "fix(comma-precomma-family) pre-comma run reads as family, not given" -why = """ -LIVE -- one of the two live pairs in this file, and the one where the -narrower rule would be actively WRONG rather than merely partial. -'John Smith, Mr.' diffs {given, family}, which the precomma rule -admits, so nothing but file order keeps it here. - -The discriminator is what becomes of the pre-comma run. Measured, 2.x -CREATES a split across it where 1.4 had none: 1.4 read first 'John -Smith' with no family at all, and the tree reads given 'John', family -'Smith'. So the precomma rule's stated behaviour -- the pre-comma run -reads as family -- is half true of this name and no more: 'Smith' -becomes the family, 'John' does not. This rule describes the split; -that one describes the whole run moving, which is not what happens -here. - -That claim is about THIS name and is deliberately not generalised to -the precomma rule's other names. 'Dr. Do Van Johnson, MD' is one of -its seven and its pre-comma run splits too, so "that rule only ever -explains whole-run moves" would be false -- which is the shape of -argument to distrust here, since nothing recomputes a claim about -another rule's names. - -'John Smith, Mr. Jr.' sits beside it latently: {given, family, -suffix} is outside the precomma rule's fields at any position. -_CROSS_RULE_WINNERS has no row for either name. It pins the sibling -decision instead -- ('Bob Jones, author', ('family','given')) to the -unsuffixed C1 rule above, on the note that "the rule written for that -shape is ahead of the precomma merge in the file" -- and this rule is -that rule's suffixed twin.""" - -[[change]] -issue = "fix(#296) a dropped prenominal takes the name position it occupies" -# 'Do Quang Minh': 'do' left TITLES -- a postnominal (D.O.) and a -# Vietnamese surname, never a prenominal -- so a leading 'Do' is no -# longer peeled as a title and falls through to the positional read: -# title 'Do', first 'Quang' -> given 'Do', middle 'Quang'. The -# 2026-07-30 audit table's disposition; 'phd', 'jr', 'junior' and -# 'se' left with it, and no corpus name leads with a bare one (the -# three that lead with 'Jr.' are read by the period inference and do -# not move). The rule's second name is 'MD, DO, DDS': title 'DO' -> -# given 'DO', the comma anchor's one corpus hit. `family` -# is deliberately OUT of the field list: the family name must not -# move on this rule, so a regression there stays loud. The anchors -# are the two openings a name has (start, or after a family comma). -name_regex = "(?i)(^|,\\s*)do\\b" -fields = ["title", "given", "middle"] - -[[change]] -issue = "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word" -# 'dr Vincent van Gogh dr' with its #100 siblings (and, at 2.x only, -# 'Smith Dr', which 1.4.0 already read as the family): 'dr' left -# SUFFIX_WORDS, where it was v1 residue -- 'Dr.' is not a postnominal -# in any tradition -- so a trailing bare 'Dr' is no longer suffix -# vocabulary and falls to the positional read, taking the family name -# with it. -# -# 2026-09-08: 'John Smith Dr.' has LEFT this rule, and the sentence -# that used to close this comment has been retracted with it. It read -# "NOT a new class: no trailing title-only word routes to title on -# the no-comma path ('John Smith Prof.' reads family 'Prof.' at every -# baseline) ... #316 is the open question for the class". #316 is -# answered: a trailing PERIOD-MARKED listed title word now routes to -# title, so 'John Smith Dr.' moves {title, suffix} against this -# baseline and {title, middle, family} against 2.2.0, neither of them -# a subset of the three roles below, and the bundle rule `fix(#316) a -# trailing period-marked title word reads as a title` at the end of -# this file claims it. What stays here is the BARE spelling, which -# #316(b) leaves open: 'Smith Dr' and the 'dr ... dr' names wear no -# abbreviation period and the trailing walk never sees them. This -# rule is narrower than that one and stands first, so the DIFF -# decides which of the two claims a name, not file order. -# -# `middle` left the field list in the same edit: it was there for -# 'John Smith Dr.', whose middle name moved, and the three names the -# rule still explains move `family` and `suffix` alone. The gate said -# so itself -- OVER-DECLARED, "classify() matches by SUBSET, so the -# excess is not inert" (#452). -name_regex = "(?i)\\sdr\\.?$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#296) a credential-only comma string reads a name and its postnominal" -# 'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials. -# v1 read the trailing one as a title and the leading one as a first -# name; with 'phd' out of TITLES the comma structure reads it as a -# one-word name plus a postnominal -- a reading of a string nobody -# writes as a name, and the honest one for it. -name_regex = "(?i)^[a-z]{2,3}\\.?,\\s*phd$" -fields = ["title", "given", "family", "suffix"] - -[[change.precedes_narrower]] -issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not first" -why = """ -LATENT. Both names move {title, given, family, suffix}, and the -routing rule declares no `family`, so it is ineligible for them -wherever it sits -- file order decides nothing here today. What the -declaration records is the hazard that wakes if that rule's `fields` -ever grow `family`, and its own comment disclaims precisely that -growth ("family/`last` is unchanged either way -- pre-comma is -definitionally family") while the family move is the whole of the -credential reading here: 'MD' stops being a first name and becomes the -one-word name. This rule is the routing rule PLUS the pre-comma merge -on a single string, so a widened routing rule would take the union and -report only half of it. _CROSS_RULE_WINNERS pins 'MD, PHD' here, -keyed by the name alone since #497, and the shape it is adjudicated -at -- ('family','given','suffix','title') -- is the one -compare._RECORDED_DIFFS records for the name. Both names sit in -corpus_issues.jsonl alone -- radar since #468's tier split, and -untouched by #488, which created corpus_cjk_tolerated.jsonl and holds -neither name -- so neither can demand a rule any more and nothing -fatal turns on this pair. The tier reaches the NAMES, not both rules: -measured, this rule explains two names and both are radar, which made -it a candidate for #495; the routing rule explains eight of which FIVE -are contract, and was not one. (Six shipped here in #499 and is what -tiering by FILE MEMBERSHIP gives, with a correct _CORPUS_TIERS: it -counts 'John Smith, Dr.' contract because corpus_shapes.jsonl holds a -copy. The dedup is on (name, order), and that copy carries shape 4, -whose FAMILY_FIRST order makes it a SECOND entry rather than the -dedup winner -- verified at baseline 2.0.0, where the run compares -both -- and shape 4's min_baseline of 2.0.0 puts it among the seven -this baseline skips. So the only entry a 1.4.0 run compares for that -string is the radar one from corpus_issues.jsonl. Tier here is a -property of the ENTRY the run compared, not of the files the string -sits in.) #495 is now closed as not planned and -the radar-only rules keep their place, so both halves of that -measurement are facts about REACH and no longer a question about -either rule's life -- docs/design/decisions.md#differential-ledger's -Declined entry on #495 has the population and why deleting one -mislabels rather than merely loses.""" - -[[change.precedes_narrower]] -issue = "fix(comma-precomma-family) pre-comma run reads as family, not given" -why = """ -LATENT by the same arithmetic and for a different reason: a four-field -diff cannot fit {given, family} at any position, so the precomma rule -is ineligible for these two wherever it sits. The hazard here is a -rule that is this one's PRE-COMMA HALF alone. 'MD' going given -> -family really is its shape, so a reader widening it would have a -plausible-looking case -- and it would then explain a credential-only -string as a name with a listing comma, losing the postnominal reading -that moves the title and the suffix in the same breath. A string that -is nothing but credentials is not a name plus a comma. Radar tier -since #468 on both names -- corpus_issues.jsonl, which #488 did not -touch -- so this pair is watched rather than enforced, and again only -the wider rule was ever #495's business. -Measured, the precomma rule explains seven names of which three are -contract ('Berg, abdul vd', 'Smith, Dr.', 'Smith, de Mesnil Jean'), so -it outlives the radar tier by itself. #495 is closed as not planned, -so the wider rule outlives it too: nothing radar-only is being -deleted, and this declaration turns on neither answer.""" - -[[change]] -issue = "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes" -# 'Smith, Ph. D. Jr.' and the #325 rows it leads ('Smith, Ph. D. MD', -# 'Smith, Ph. D. III', 'Smith, Ph. D. Jr. MD', 'Smith, Ph.D. Jr.', and -# 'Smith, PhD Jr.', which the regex reaches too -- title 'PhD' at -# every baseline, suffix 'PhD Jr.' now; this line read -# 'PhD, Jr.' and had been stale since #429 made the run one -# entry, corrected 2026-09-06): -# with one word before the comma the name is the listing form, and -# the space-split 'Ph. D.' followed by another suffix was no longer -# the LONE post-comma piece, so it fell through to the given name -- -# a 1.4.0 regression (v1 read suffix 'Ph. D.', title 'Jr.'). The -# whole run is suffixes now. Literal rather than the shape: a shape -# regex absorbed 'Smith, Dr. Jr.', which is the comma-family rule's -# (the comment review). -# 2026-09-27, #544: 'Smith, PhD MEng' is reached too, as 'Smith, PhD -# Jr.' is -- the unambiguous PhD anchors the MEng behind it (rules.md#S2's -# company clause), so the run after the one-word family comma collapses -# whole into `suffix` where the tree before #544 read given 'PhD', -# middle 'MEng'. -# 2026-09-28: 'Smith, PhD Ma' likewise, the same run with the Title-case -# member of the older marking; both are pinned in _RECORDED_DIFFS -# beside 'Smith, PhD Jr.'. -name_regex = "(?i)^smith,\\s*ph\\.?\\s?d\\.?\\s" -fields = ["title", "given", "suffix"] - -[[change]] -issue = "fix(#325) a credential run across a second comma reads as suffixes" -# 'Smith, Jr., PhD': title 'Jr.', suffix 'PhD' -> suffix 'Jr., PhD'; -# segments 2+ compose with the run. -name_regex = "(?i)^smith,\\s*jr\\.?,\\s*phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#575) a particle surname before a comma is one name word" -# rules.md#C1: "a particle run and the one name word it attaches to -# are one word" -- so the part before the comma is one -# surname and the word after it is the given name, as 'Royce, Ed' -# reads. 1.4.0 counted tokens: 'De La Cruz, Ed' and 'de la Cruz, Ma' -# read suffix 'Ed'/'Ma' with no given name, and 'Van Buren, Ed' read -# given 'Van', family 'Buren', suffix 'Ed'. -# -# Ahead of the routing rule below on purpose: that rule's alternation -# reaches the two 'De La Cruz' spellings, and it describes the -# opposite move (a lone post-comma piece leaving `first`), so letting -# it absorb a piece ARRIVING in `given` would explain the diff with a -# rule that contradicts it. -# -# Literal; the probe 'John van Buren, Ed' (two name words: the -# credential reading stays) is _MUST_NOT_MATCH. -# 'Freiherr von Berg, Ed' joins, rules.md#C1's Accepted example: the -# surname counts once and 'Freiherr' is no name word, so the listing -# form reads it and keeps the rank in the family -- since 1919 part of -# the legal German surname. 1.4.0 read title 'Freiherr', first 'von -# Berg', suffix 'Ed'. -name_regex = "^(?:De La Cruz, Ed|Freiherr von Berg, Ed|Van Buren, Ed|de la Cruz, Ma)$" -fields = ["title", "given", "family", "suffix"] - -[[change.precedes_narrower]] -issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not first" -why = """ -The later rule describes a lone post-comma piece LEAVING `first` for -`suffix` or `title`. These names move the other way: the word after -the comma ARRIVES in `given`, because the particle surname before it -is one name word (rules.md#C1, #575), and that is what this rule -names. -""" - -[[change.precedes_narrower]] -issue = "fix(comma-precomma-family) pre-comma run reads as family, not given" -why = """ -The later rule describes 1.4.0 handing a pre-comma word to `given` -that 2.x reads as family, which is half of these moves ('Van' leaving -`given` in 'Van Buren, Ed'). It says nothing about the word AFTER the -comma arriving in `given`, the other half, nor why the particle -surname is one name: that is rules.md#C1's count (#575), which this -rule names. -""" - -[[change]] -# #603's two rules stand ahead of the comma-family catch-all below -# on purpose: each names its corpus names one by one, the narrower -# claim, so the classified summary credits #603 rather than that -# broad rule, which admits the same diffs on any Latin comma name. -# rules.md#C1: "A credential opening the part makes it the postnominal -# part however it goes on" (#603, 2026-10-04): where the first suffix -# word after the comma starts S2's run, every word behind it reads as -# that run reads, so a full name before the comma keeps its positional -# read ('John Smith, PhD Jones') and a one-word name is the family -# ('Doe, PhD Jones'). The undeclared dash of the delimiter rows is one -# more word of the run; a title/suffix dual opens nothing. -issue = "fix(#603) a credential opening the part after the comma makes it the postnominal part" -name_regex = "^(?:Doe, PhD Jones|John Smith, Ph\\. D\\. Jones|John Smith, PhD Jones|Smith, RN - CRNA|Steven Hardman, RN - CRNA)$" -fields = ["family", "given", "middle", "suffix", "title"] - -[[change.precedes_narrower]] -issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not first" -why = """ -That rule describes ONE post-comma piece leaving `first` for `suffix` -or `title`. These names move a whole part: a credential opening it -takes every word behind it into the suffix (rules.md#C1, #603), name -words included, and a full name before the comma keeps its positional -read, so `family` and `middle` move with it. One-word names whose diff -fits that rule's fields ('Doe, PhD Jones', 'Smith, RN - CRNA') are -still this rule's: the cause is the opened part, not the lone piece. -""" - -[[change.precedes_narrower]] -issue = "fix(comma-precomma-family) pre-comma run reads as family, not given" -why = """ -That rule describes the pre-comma run reading as the family. Here the -pre-comma run does the opposite where it holds two name words -- the -comma has fixed nothing, so it keeps its positional read ('John Smith, -PhD Jones' gives given 'John', family 'Smith') -- and the move is the -opened part's (rules.md#C1, #603), not that rule's. -""" - -[[change]] -# rules.md#C2: "a word of the title vocabulary there that is not also -# suffix vocabulary is a title" (#603, 2026-10-04): a part past the -# second comma reads its title words as titles, and a part of title and -# suffix words is recognized rather than flagged ('Eric H. Holder, Jr., -# Attorney General'). 1.4.0, like 2.0 through 2.3, read them as -# suffixes. -issue = "fix(#603) a title word in a part past the second comma is a title" -name_regex = "^(?:1 & 2, 3 4 5, Mr\\.|Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29|Eric H\\. Holder, Jr\\., Attorney General|Eric H\\. Holder, Jr\\., Secretary of State|John Smith, Jr\\., Lt\\.Gov\\.|John, Smith, Dr\\.|Smith, John, Prof\\.)$" -fields = ["suffix", "title"] - -[[change]] -issue = "fix(comma-family) lone post-comma piece routes to suffix/title, not first" -# 'Smith, Dr.' / 'Andrews, M.D.': v1 put the lone strict-suffix-or-title -# post-comma piece in `first`; 2.0 routes it to `suffix`/`title` instead -# (family/`last` is unchanged either way -- "pre-comma is definitionally -# family"). -# -# LATIN-ANCHORED, and that is what keeps this rule describing what it -# claims. The regex was a bare comma until #372, which reaches every -# comma in every script -- so seven CJK glued-honorific names landed -# here, on a rule whose prose has nothing to do with CJK. They diff as -# {given, suffix} (민준씨->민준/씨, 太郎さん->太郎/さん), which is a -# subset of this rule's fields, and classify() takes the first rule -# whose regex matches and whose fields are a superset. Both this rule -# and fix(cjk-comma-compound) carry a name_regex, so they share a tier -# and file order broke the tie -- in favour of the one written first, -# which was this one. -# -# Measured at the narrowing: corpus reach 236 -> 215, and all seven -# CJK names moved to fix(cjk-comma-compound), which grew 10 -> 17 and -# is the rule named for that shape. The gate was unmoved at 108 -# intentional / 0 unexplained -- nothing became unexplained, so no name -# lost an explanation, it only changed which rule gave it. -# -# Those are that narrowing's figures and none of them is today's. What -# the narrowing pinned is the SCRIPT bound, not a count: this rule goes -# on claiming the Latin comma-names, and their number grows with the -# corpus. The sentence that stood here said the rule "now claims -# exactly one name, 'Andrews, M.D.'"; re-measured 2026-09-03 it -# explained eight, 'Andrews, M.D.' among them. For today's, read the -# `## fix(comma-family) lone post-comma piece routes to suffix/title, -# not first (N)` heading off `compare.py --baseline 1.4.0`. -# -# The cut is at U+0250, the threshold _is_latin_only uses in -# compare.py, matching the trailing-'Ph. D.' exclusion. Anchored at -# both ends because a name is Latin or it is not: an unanchored class -# would match the Latin part of a mixed name and take it back. -# -# One row nearby is not here and never was: '田中さん, 太郎' agrees -# with 1.4.0, so it never reaches classify() at all. It is the pinned -# guard for the case a naive #312 fix breaks (the peel site is 太郎, so -# nothing peels). -# -# '田中さん, V.' is NOT that case, though this comment said it was -# until #372 checked. It diffs {family, suffix} and lands on -# fix(cjk-comma-compound) below, before and after this change. It -# reads as parity only under Policy(lenient_comma_suffixes=False) -- -# the knob tests/v2/cases.py exercises in -# ja_honorific_glued_family_comma_strict_knob -- and the corpora run -# under the default policy, so here it is a real diff. -name_regex = "^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*$" -fields = ["given", "title", "suffix"] - -[[change]] -issue = "fix(comma-precomma-family) pre-comma run reads as family, not given" -# 'Smith, Dr.' / 'Smith, Prof.': the post-comma piece is a title on -# BOTH sides -- it does not move, and no suffix moves either. What -# changes is the pre-comma run: 1.4 read it as `first`, 2.x reads it -# as `last`, because pre-comma is definitionally family. Diff is -# exactly {given, family}. -# -# This comment opened on 'Bob Jones, author' / 'MD, PHD' until #382's -# sweep, and the rule explains neither any more. Since #296's audit -# 'Bob Jones, author' KEEPS its pre-comma split (first 'Bob Jones' -> -# 'Bob', last '' -> 'Jones') and goes to `fix(comma-family) a comma -# followed only by titles keeps the given/family split`, and 'MD, PHD' -# reads as a one-word name plus a postnominal -- a four-field diff -# this rule's fields cannot admit -- and goes to `fix(#296) a -# credential-only comma string reads a name and its postnominal`. Both -# handovers are pinned in _CROSS_RULE_WINNERS and both winners are -# written above this rule, so nothing about this rule changed; only -# the example did. Nothing recomputes a name in a comment, which is -# how this one stayed wrong through two audits. -# -# The seven it does explain are not all the pure shape, and `fields` -# is what the rule actually claims. 'Smith, Ph. D.' and 'Ph. D., Jr.' -# are the pure move; 'Dr. Do Van Johnson, MD' has its pre-comma run -# SPLIT into given/family rather than move whole; 'Berg, abdul vd' -# hands a trailing particle to the family; 'Smith, de Mesnil Jean' -# re-orders the family run with `first` empty on both sides. Measured, -# all seven move `family` and six move `given` with it; the seventh is -# 'Smith, de Mesnil Jean', which moves `family` ALONE -- `first` is -# empty on both sides, so there is no given name for it to move. No -# title and no suffix moves in any of the seven. So the fields below -# are the boundary and the title above names the majority reading. -# -# Its own rule because these have nothing to do with suffix routing: -# they were falling to the fields-only fix(suffix-routing) catch-all -# (deleted in #451), on a rule whose every other name moves a trailing -# token into `suffix`, while these move no suffix at all (#372). -# -# Distinct from fix(comma-family) above by fields, not by order: that -# rule is the lone post-comma piece moving into suffix/title and its -# fields exclude `family`, which every name here moves. Latin-anchored -# for the same reason it is -- a bare comma reaches every script. -name_regex = "^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#296) a lone post-comma credential is a suffix" -# 'Smith, Jr.' / 'Kim, Jr.' / 'Smith, PhD': rules.md#C1 -- the listing -# form holds with one word before the comma, and the word after it is -# the credential run. v1 put the family in `first` and the credential -# in `title`; 2.0 routed the pre-comma word to the family (the -# comma-family rule below) but the title peel still claimed the -# credential -- 'jr' and 'phd' were title vocabulary, and the -# period-abbreviation inference reads 'Jr.' as a title regardless -- -# so the postnominal position is read first now: a post-comma run -# that is nothing but suffix pieces is suffixes. A one-word name, a -# comma, one short word: the reach is pinned by the guards, and the -# rule sits BEHIND the two comma-family rules above so that a name -# whose only move is the pre-comma word going given -> family -# ('Smith, Dr.', 'Smith, Prof.') or a lone piece going given -> -# suffix ('Smith, Abd') keeps the rule written for it (the comment -# review found this rule absorbing all three). 'Smith, Ms.' is -# here too: the dual's postnominal reading, title at 1.4.0. -name_regex = "(?i)^[a-z]+,\\s*[a-z]{2,6}\\.?$" -fields = ["title", "given", "family", "suffix"] - -[[change.precedes_narrower]] -issue = "fix(suffix-routing) a two-token name ending in the suffix word jr keeps it in `suffix`" -why = """ -a REGEX ACCIDENT, not two descriptions competing. The jr rule asks for -a leading run of non-space characters, and that run swallows the -trailing comma of 'Smith,' -- which is the only way a comma name ever -reaches a rule whose prose scopes it to the comma-LESS two-token name. -Nothing that rule says is true of 'Smith, Jr.' or 'Kim, Jr.'. - -LATENT as well: both move {title, given, family, suffix} against the -jr rule's {family, suffix}. The jr rule's OWN comment already argues -this from the other side and names these two names -- "what keeps them -there is `fields`, not file order... Order is the second line of -defence, not the first". This entry is that second line written down: -a widening of the jr rule's `fields` would spend the fields argument -and leave nothing but the accident holding the names, and an accident -is a worse reason to hold a name than a description is. -_CROSS_RULE_WINNERS pins both names to this rule.""" - -[[change]] -issue = "fix(#400/#274) bound-given join and maiden consumption in one name" -# 'abd Allah Smith nee Jones': two behaviours meeting in one name, so -# neither single-cause rule claims it -- fix(#400) moves {given, -# middle} and fix(#274) moves {maiden, middle, family}, and this moves -# all four at once. The join takes 'abd Allah' as the given name AND -# the marker takes 'Jones' as the maiden name, which between them -# leave 'Smith' as the family where 1.4 read family 'Jones'. -# -# Requires TWO words between 'abd' and the marker, which is what makes -# it the combination shape: with one word the join declines (P5's -# reserve, once the maiden words stop counting -- see fix(#411)) and -# the diff is fix(#274)'s alone. 'abd Berg nee Jones' is that name and -# this rule must not claim it. -name_regex = "(?i)^abd(\\s+\\S+){2}\\s+n[eé]e\\s" -fields = ["given", "middle", "family", "maiden"] - -[[change.precedes_narrower]] -issue = "fix(#400) abd joins the word after it as one given name" -why = """ -the canonical compound-versus-component shape. fix(#400) describes the -join alone -- 'abd' plus the word after it, with a family name left -behind -- and says nothing about a maiden marker, which is where the -other two roles in this name come from. - -LATENT: the measured diff is {given, middle, family, maiden}, and -`family` and `maiden` are outside fix(#400)'s {given, middle} at any -position, so the join rule cannot take this name wherever it sits. -The declaration is therefore about a future widening rather than -about today: teach fix(#400) anything about markers and it would -quietly absorb a four-role change and label it a bound-given join. -No _CROSS_RULE_WINNERS row for this name; the reason rests on the two -rules' own prose, which is unambiguous here -- one names two -behaviours meeting, the other one behaviour.""" - -[[change]] -issue = "fix(#411) the bound-given reserve stops counting words the maiden name takes" -# 'van der Berg, abdul nee Jones': P5 reserves a name word so the join -# always leaves a family name behind, but the reserve was counted while -# the marker and the maiden name were still pieces -- group's marker -# pass runs later in the same stage. Counting words that were about to -# leave answered the question about a name that would not exist. -# -# The fields here are the SIDE EFFECT rather than the defect. #411's -# own shape is an emptied family ('abdul Berg nee Jones' read given -# 'abdul Berg', family ''), and no BASELINE shows it -- every released -# version already reads the corpus names carrying that shape the way -# the fix does, so none of them can produce a #411 diff to classify. -# ('abd Berg nee Jones' IS in corpus_rules.jsonl, and goes unexplained -# at all three baselines if the fix is reverted, which is the guard -# working.) -# -# What this corpus -# name shows is the other half: with the reserve counted correctly the -# join declines, so the marker is never merged into 'abdul nee' and -# M2's bound sees a lone marker piece for the first time. That closes -# one of the two join-swallows #412 tracks; P3's connective join is -# the one that survives. -# -# `abdul` is a LITERAL, not an alternation over BOUND_GIVEN_NAMES, and -# the words are NOT interchangeable here. 'abd' is the one shipped word -# in both the bound-given and the suffix vocabulary, so after a family -# comma a declining join leaves the S2 suffix reading instead: 'van der -# Berg, abd nee Jones' moves {given, middle, suffix, maiden}, and the -# extra field breaks the subset test, so it would arrive UNEXPLAINED -- -# correct, but not what an alternation over the vocabulary would lead -# an author to expect. 'abdel', 'abdal' and the Arabic spelling do -# behave like 'abdul'; only this name is in the corpora. -name_regex = "(?i),\\s*abdul\\s+n[eé]e\\b" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#400) abd joins the word after it as one given name" -# 'abd Allah Smith' and 'Salam, abd Allah'. `abd` joined -# BOUND_GIVEN_NAMES in 2.2 so the spellings that write the article as -# its own word join like `abdul`, `abdel` and `abdal` already did. -# v1 had no bound given-name join for it either, so the diff is the -# same shape at every baseline; the 2.0 and 2.1 ledgers carry the same -# rule. -# -# `abd` is also the postnominal ABD and stays in SUFFIX_ACRONYMS -- -# position tells the two readings apart -- so the anchors are what -# keeps a trailing 'Jane Smith ABD' out of this rule. -# -# A third corpus name is claimed and does NOT move: a bare 'abd Allah', -# which has no third word for the join to leave as a family name, so -# P5's reserve declines and it reads given 'abd', family 'Allah' at -# every baseline. Same behaviour family as the two that move, so the -# claim is defensible -- recorded so it is not mistaken for coverage. -name_regex = "(?i)(?:^|,\\s*)abd\\s+\\S+" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#272/#308) nakaguro division and a glued hangul honorific in one name" -# '马丁·路德·金씨': two behaviours meeting in one name, which is why the -# single-cause CJK rules do not claim it. The nakaguro divides the -# name into given/middle/family and the glued 씨 peels off into -# `suffix`, so all four roles move at once. Literals rather than an -# alternation over the honorific vocabulary, because the rule is about -# the COMBINATION rather than about the vocabulary; it claims exactly -# the one corpus name. -name_regex = "^\\S*\u00b7\\S*\uc528$" -fields = ["given", "middle", "family", "suffix"] - -[[change.precedes_narrower]] -issue = "fix(cjk-glued-honorific-peel) glued honorific peels into suffix" -why = """ -`middle` is the discriminator and the nakaguro is where it comes from. -This name divides into given/middle/family AND peels its glued \uc528, and -this rule is the one describing both; the peel rule describes the peel -and scopes itself to a name with no comma anywhere and no space before -the honorific, saying nothing about a division. Claiming the name -there would be a rule claiming a diff it does not describe (#372). - -LATENT: {given, middle, family, suffix} is outside the peel rule's -{family, given, suffix} at any position, so what is declared is the -hazard of that rule acquiring `middle` -- which is exactly the role -the CJK order flip hands it, so the widening is a plausible one. -Contract tier (corpus_rules.jsonl), and not in _CROSS_RULE_WINNERS: -the roster never had this name added.""" - -[[change]] -issue = "fix(emoji-boundary) an emoji inside a token divides it" -# 'John😀Smith': 1.4 dropped the emoji and read one token -# (first='JohnSmith'); 2.x treats it as a division, so the name reads -# given 'John', family 'Smith'. rules.md#T1 documents both this and the -# keep-emoji policy reading. -# -# The emoji must stand BETWEEN two non-space characters, which is the -# behavior: it divides a token. A bare character class was the first -# draft and is rejected -- measured, it reaches past 'John😀Smith' to -# corpus names carrying a spaced or trailing emoji, which are -# byte-identical on both sides today and would be pre-excused by a rule -# that claims them. How many such names the corpus holds is not the -# argument and grows with the corpus; that the draft admits the class -# at all is. Pre-claiming shapes it has never seen is the #372 failure -# mode the fix(#379) comment names. -# -# The class is the ASTRAL HALF of _tokenize.py's _EMOJI_RANGES, hand -# copied and pinned by test_the_emoji_boundary_rule_copies_the_dividing_ranges. -# A single \U0001F300-\U0001FAFF span was the first draft and is wrong -# in the dangerous direction: it covers U+1F650-U+1F67F and -# U+1F700-U+1FAFF, where the parser does NOT divide, so the rule's own -# prose is false for about a thousand codepoints it would claim. -# 'John\U0001FA70Smith' reads given 'John\U0001FA70Smith' with no family -- -# a future {given, family} diff on such a name, with some other cause, -# would classify here as intended. That is the same #372 shape this -# comment invokes against the bare class. -# -# The BMP half (U+2600-U+26FF, U+2700-U+27BF) is deliberately left out -# rather than copied: no corpus name reaches it once the token anchor -# is on, and a rule should be no wider than the diffs it must explain. -# A name that needs it will arrive unclassified and be read once. That -# omission also leaves the pattern free of \uXXXX-\uXXXX span text. -# That is a CONSEQUENCE, not a safeguard, and the #453 review was -# right to say so: _SPAN reads the 4-hex spelling only, so the class -# below rides past _unrecognized_class_content whatever it contains. -# What actually bounds it is the guard named above, which probes every -# codepoint in Unicode against _EMOJI_RANGES, plus _CORPUS_CLAIMS' -# reach and digest. -name_regex = "\\S[\\U0001F300-\\U0001F64F\\U0001F680-\\U0001F6FF]\\S" -fields = ["given", "family"] - -[[change]] -issue = "fix(nickname-typographic-pairs) two typographic quote spans read as one nickname set" -# 'Hans „Erster“ und “Zweiter” Müller': 2.0 extracts typographically -# quoted nicknames, which v1 did not, and this name carries TWO spans -# in different pairs joined by a conjunction. Both come out as -# `nickname` 'Erster Zweiter', and what is left rejoins as a name -- -# so `given`, `middle` and `family` all move as well. -# -# Deliberately narrower than "any typographic quote": the corpora hold -# four such names and the single-span ones ('Hans „Hansi“ Müller', -# 'John “Jack” Kennedy') are already accounted for elsewhere. The -# pattern asks for two spans of DIFFERENT pairs with a word between -# them, which is the shape that moves all four roles at once. A -# single-span regression must not be absorbed here. The fourth is -# 'Jane „JD Smith', an UNCLOSED span, which is neither category -- it -# does not diff at any baseline and no rule here claims it. -name_regex = "\u201e[^\u201c]+\u201c\\s+\\S+\\s+\u201c[^\u201d]+\u201d" -fields = ["given", "middle", "family", "nickname"] - -[[change.precedes_narrower]] -issue = "feat(#273) typographic nickname delimiters recognized by default" -why = """ -feat(#273) is single-span RECOGNITION: a smart-quoted nickname stops -leaking into `middle` as literal text, which is the {middle, nickname} -diff its own names carry ('Hans \u201eHansi\u201c M\u00fcller', 'John \u201cJack\u201d -Kennedy'). This name has two spans of different pairs, and the reason -it needs a rule of its own is the REMAINDER REJOINING as a name once -both come out -- that is what moves `given` and `family`, and #273's -prose has nothing to say about it. - -LATENT: {given, middle, family, nickname} is outside {middle, -nickname} at any position. The hazard declared is #273 being widened -toward the multi-span shape, which this rule's own comment already -guards from the other side ("A single-span regression must not be -absorbed here") -- this declaration is that guard read backwards. No -_CROSS_RULE_WINNERS row.""" - -[[change]] -issue = "fix(#379) a tussenvoegsel after a family comma attaches to the family" -# 'Vega, Juan de la': the Dutch alphabetized listing moves the -# tussenvoegsel behind the given name -- "Beethoven, Ludwig van" is how -# "Ludwig van Beethoven" is filed -- and 1.4 read the trailing particle -# run as a middle name. 2.2 attaches it to the family already named by -# the comma and renders it before the base, so `middle` empties and -# `family` grows. rules.md#P6 states it; the derived views move with it -# (family_particles 'de la', family_base 'Vega'). -# -# Fields are {middle, family}: the given name does not move. `suffix` -# is deliberately NOT here, though the #380 half of the change does -# move a token out of it ('Berg, Jan vd' was suffix 'vd' at every -# baseline and is now family 'vd Berg'). Adding `suffix` to claim it -# costs three shapes this rule has no business explaining -- -# {family,suffix} taken from fix(#380) below, and {middle,suffix} -# and {middle,family,suffix} taken from UNEXPLAINED, the last two -# being diffs in which `family` does not move at all. A rule that -# pre-claims shapes it has never seen is the #372 failure mode. -# The first name read fix(suffix-routing) until #451 deleted that -# catch-all, and it was already the wrong name by then: measured, a -# {family, suffix} diff on 'Berg, Jan vd' goes to fix(#380), which -# sits BELOW this rule, so claiming `suffix` here would take it from -# there. On a name this regex reaches that fix(#380)'s does not -- -# 'Vega, Juan de la' -- the same diff went to the catch-all and -# arrives UNEXPLAINED now. Either way the decision below stands; only -# the rule the shape would be taken FROM has changed. -# -# 'Berg, Jan vd' entered corpus_issues.jsonl and corpus_rules.jsonl -# after this was written, and it did NOT arrive unexplained -- the -# fields-only catch-all took it, and _CROSS_RULE_WINNERS recorded the -# absorption rather than closing it. It has fix(#380)'s own rule now -# (#451, below). The declining was right; what was missing was the -# rule beside it. -# -# Latin-anchored over the WHOLE name, not just the trailing word: with -# a leading .* this absorbed non-Latin comma names whose diff has -# another cause -- a Cyrillic 'Smith, de la' moves {family} because the -# particle's RENDERING ORDER inside the family changed, which no rule -# in this ledger names, and it classified here. (No such name is in -# any corpus, so this is what WOULD happen. It would now arrive -# UNEXPLAINED and block the gate until someone read it, which is the -# right outcome: this ledger has no fields-only rule to absorb it any -# more. The claim that it "falls to the fields-only catch-all" was -# true until #451 deleted that rule, and this file now says what its -# 2.0.0 and 2.1.0 copies have said since 8237b54.) The class is the -# neighbours' and compare.py's own: U+0000-U+024F, which stops at -# Latin Extended-B, so a diacritic-bearing 'Nguyen, Van de la' in NFD -# or Latin Extended Additional is outside it. -# -# What the rule still over-claims is vocabulary, not script: any Latin -# comma name ending in an alternation member is claimed whatever the -# naming tradition, so a future {middle,family} regression on -# 'Nguyen, Van' -- one of the two corpus names it reaches, and the one -# that does NOT move -- would classify as intended. -name_regex = "(?ai)^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*\\s(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)(\\s+(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu))*\\s*$" -fields = ["middle", "family"] - -[[change]] -issue = "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal" -# 'Berg, Jan vd': the tree reads it given 'Jan', family 'vd Berg', -# suffix '' -- the trailing 'vd' attaches to the family the comma -# already named, the same P6 precedence fix(#379) states above for the -# leading-particle shapes. Whether 1.4 itself read the trailing word -# as `suffix` is outside what this ledger's worker can check directly -# (tools/differential/README.md's warning against reading a v1 release -# from a cached environment); the diff this rule explains at this -# baseline moves exactly {family, suffix}, which is consistent with -# that reading and is what the fields below claim. -# -# decisions.md#vocabulary-collisions C-ii records the word itself: -# "vd is the live case: never-given particle AND credential acronym -# (the British Volunteer Decoration), neither of them a name. -# Decision: the Dutch van der reading, as the more common." #380 is -# named there as the trailing-orphan half that decision unblocks. -# -# The {family, suffix} companion to fix(#379) above, which claims -# {middle, family} and deliberately declined `suffix` -- that -# comment's own accounting of what claiming it would have cost stands -# as written; this rule is the piece it left out, not a widening of -# it. -# -# Ported verbatim from expected_since_2.0.0.toml, issue text and regex -# included, so the _MUST_NOT_MATCH "fix(#380)" key that already guards -# the 2.x copies guards this one too. Latin-anchored over the whole -# name for fix(#379)'s reason: with a leading .* it would absorb -# non-Latin comma names whose diff has another cause. -# -# The regex also reaches 'Berg, abdul vd', which fix(comma-precomma-family) -# above claims on {family, given} -- a different diff shape, so this -# rule's {family, suffix} fields never match it and it is not claimed -# here. That reach is real, so 'Berg, abdul vd' is NOT a -# _MUST_NOT_MATCH probe for this rule: that roster tests the regex, -# not classify(). -name_regex = "(?i)^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*\\svd\\s*$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word" -# 'rai' and 'cha' left SUFFIX_ACRONYMS on 2026-09-07 -# (nameparser/config/suffixes.py). Both arrived in the 2019-12-11 -# bulk wikipedia post-nominal import (af5bdab), neither was reviewed -# against a surname collision, and decisions.md#suffix-acronym-collisions -# states the criterion that removed them rather than marking them -# ambiguous: marking is for a short credential in wide use that is -# also a name, removal for a longer one that is tenuous and collides. -# -# Five corpus names, all from the radar-tier corpus_issues.jsonl, in -# THREE diff shapes with one cause -- the word stopped being -# credential vocabulary, so the positional read takes it: -# 'Aishwarya Rai' {family, suffix} suffix -> family -# (2.x only -- parity at 1.4.0) -# 'Lala Lajpat Rai' {middle, family, suffix} the family shifts right -# 'John Smith RAI' {middle, family, suffix} same -# 'John Smith, RAI' {given, family, suffix} an ordinary family comma (C1) -# 'Ahmad Jayadi, CHA' {given, family, suffix} same -# 2026-10-01, #564: the comma pair reads suffix again by its capitals -# (an unlisted all-caps word after a comma behind two name words), so -# it no longer moves {given, family, suffix} here; at 1.4.0 it reaches -# parity, at 2.x only the comma's report differs (the fix(#564) rule). -# `given` therefore leaves `fields`, which the OVER-DECLARED check -# requires: no name here moves it any more. -# The comma pair is why `fields` declares four roles rather than the -# two the pre-2.3 fix(#342) rule declared: with no credential after -# the comma, C1 reads the pre-comma run as the family and the -# post-comma word as the GIVEN name, so `given` moves where it did -# not before. The declaration is the exact union of the five, which -# is what the OVER-DECLARED check recomputes at the end of every run. -# -# 'Aishwarya Rai' is in this rule at 2.0.0/2.1.0/2.2.0 and NOT -# explained by it at 1.4.0, where the name now reaches PARITY: 1.4.0 -# read family 'Rai' too, which is what the deleted -# `fix(#342) NOT WANTED` rule existed to classify. The regex is the -# same in all four files anyway -- reach is not explanation, and a -# per-ledger regex would say a name left the corpus. -# -# An anchored alternation of the five NAMES rather than a shape. The -# shape is "a trailing word that used to be an acronym entry", which -# would be a copy of SUFFIX_ACRONYMS and would stand ready to explain -# every future trailing-acronym regression. The members are a list of -# names and copy no wordlist, which is what _NOT_A_VOCABULARY_COPY in -# tests/v2/test_ledger_guards.py records; _CORPUS_CLAIMS pins the -# reach at 5 with its digest, and _MUST_NOT_MATCH carries the -# boundaries the paragraph above argues. -name_regex = "^(?:Ahmad Jayadi, CHA|Aishwarya Rai|John Smith RAI|John Smith, RAI|Lala Lajpat Rai)$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#397) accepted: a two-word Catalan link has no name word to its right and stays the generation" -# 'Carod i': the tree reads given 'Carod', family '', suffix 'i'. 'i' -# is a bare entry in SUFFIX_WORDS (nameparser/config/suffixes.py -- -# the Roman-numeral generation markers i/ii/iii/iv/v, not -# SUFFIX_ACRONYMS), so a two-word name ending in it reads as a name -# plus a trailing generation rather than a two-word family link. -# -# DECIDED, not unresolved, and that is what changed on 2026-09-20: -# #397 landed, 'i' IS connective vocabulary now, and the Catalan link -# joins wherever a name word stands on each side of it -- 'Josep Carod -# i Rovira' and 'Carod i Rovira, Josep' both read family 'Carod i -# Rovira' and have a rule of their own below. This name is the -# boundary that rule states: a connective with nothing to its right is -# connecting nothing, and a word of that vocabulary ending a name is -# the generation it also spells. Two pieces do not reach the join loop -# at all, which needs three, so the reading is refused one gate -# earlier still. rules.md#P3 carries both sentences and this name is -# one of its example lines. decisions.md#P3 records the count decision -# and the both-sides scope behind them. -# -# Whether 1.4 itself read the trailing word as `family` is outside -# what this ledger's worker can check directly -# (tools/differential/README.md's warning against reading a v1 release -# from a cached environment); the diff this rule explains at this -# baseline moves exactly {family, suffix}, which is consistent with -# that reading and is what the fields below claim. -# -# Classified because the cause is known and RECORDED AS WANTED, which -# is a stronger standing than the fix(#342) rule above: that one is -# NOT WANTED and unresolved, this one is the stated boundary of a -# landed rule. A [[never]] exclusion is still wrong for it -- an -# excluded name reports UNEXPLAINED and exits non-zero. -# -# Literal-anchored to the one corpus name that reaches this shape, for -# fix(#342) above's reason: a rule keyed on "two-word name, trailing -# single letter" would pre-excuse every generation-marker regression, -# not just this one. The three longer names in the same family are -# each accounted for elsewhere now -- 'Carod i Rovira' and 'Josep -# Carod i Rovira' by the fix(#397) join rule below, and 'Lluis Carod -# i' by the same both-sides boundary as this name, one word longer and -# therefore diffing nothing at all. -name_regex = "(?i)^carod\\s+i$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#397) the Catalan/Polish link joins two surnames" -# 'i' is connective vocabulary since #397, and a connective counts as -# a name word toward rules.md#P3's three-word carve-out whatever else -# the vocabulary says it is -- which matters here because 'i' is also -# the roman numeral. So a four-word name reaches the threshold and the -# link joins. 1.4.0 read 'Josep Carod i Rovira' as middle 'Carod i' -# with family 'Rovira', and 'Carod i Rovira, Josep' -- where the link -# stands after a family comma -- as family 'Carod Rovira' with the -# link taken as a GENERATION in `suffix`, which is why the two move -# different field sets and are grouped here rather than split. The -# three longer spellings move with the first: 'Josep Carod i Rovira -# III' and 'Josep Lluis Carod i Rovira' the same {family, middle}, and -# 'Josep Carod i de Rovira' the same pair with the link joining PAST a -# particle (middle 'Carod i' -> 'Carod i de', family 'de Rovira' -> -# 'Rovira'). Five names, `fields` the union of their two shapes. -# -# All five entered this corpus with #397's own rules.md#P3 example -# lines and case rows; before that they were radar-tier names the -# contract did not answer for, which is why a rule appears at this -# baseline only now. decisions.md#P3. -# -# Literal-anchored to the five. The shape is "a single letter between -# two name words", which reaches every corpus name carrying a bare -# initial and would pre-excuse the opposite reading -- #397's whole -# accepted cost is that some such letters must NOT join. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the wall: -# 'Josep i Rovira' (three words, under the carve-out), 'Carod i' (the -# rule above), 'John Quincy Smith i' (the link with nothing to its -# right) and 'Josep Lluis Carod i III' (a generation run, the -# fix(#436/#437) rule at the head of this file). -name_regex = "^(?:Carod i Rovira, Josep|Josep Carod i Rovira|Josep Carod i Rovira III|Josep Carod i de Rovira|Josep Lluis Carod i Rovira)$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(suffix-routing) a two-word trailing generation run peels whole, and the name word before it is the family" -# 'Josep Lluis Carod i V': v1 read middle 'Lluis Carod', last 'i', -# suffix 'V' -- it peeled the LAST word of the trailing run and left -# the first to the family -- where 2.x peels the run whole and reads -# middle 'Lluis', family 'Carod', suffix 'i V'. The same shape -# 'John Smith Mc V' carries on the fix(#424) rule further down, whose -# sentence is about a PARTICLE opening the run; here both words are -# generation vocabulary, so it needs a rule rather than that one's -# widening. -# -# NOT this bundle's doing, and measured rather than argued: against -# the parent 46651750 the parse is byte-identical before and after -# #397 and #461 (given 'Josep', middle 'Lluis', family 'Carod', suffix -# 'i V'), the link having a generation marker and not a name word to -# its right. The name is new to the CORPUS -- it arrived with #397's -# case rows as exactly that boundary -- so a divergence v2 has carried -# since 2.0 is classified here for the first time. -# -# Literal, one name: the shape is "a trailing run of two suffix words" -# and would stand ready to explain every future peel regression. -name_regex = "^Josep Lluis Carod i V$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(suffix-delimiter-rendering) no-space delimiter core token kept whole" -# Only fires when a custom suffix delimiter is configured (Policy / -# Constants.suffix_delimiters); the corpus runs with default policy, so -# this rule is expected to match nothing here. Kept for documentation -# parity with tests/v2/cases.py's 'suffix_delimiter_no_space_core' row -# (anti-#100; mechanisms.md#SPANS). -dormant = "the no-space delimiter rendering fires only under a non-default Policy, and the corpora run under the default one, so no corpus name can reach this rule. Kept because the behavior family is real and documented in tests/v2/cases.py: the rule is ready the moment a matching string is added" -name_regex = "/" -fields = ["suffix"] - -[[change]] -issue = "ambiguous-surname-acronym data change: parenthesized (MA)/(DO) now stays nickname" -# suffix_acronyms_ambiguous gained 'ma'/'do' so bare 'Jack Ma' keeps its -# surname (v1 parity restored); side effect: parenthesized/quoted "MA" -# or "DO" no longer escape to suffix (v1 did, since v1 treated them as -# unambiguous there) -- they now fall through to nickname parsing. -# -# 2026-09-18 (#289): the first clause holds for the spelling it names -# and not for its all-caps twin. 'Jack Ma' still keeps its surname by -# the words-to-spare guard; 'Jack MA' is now read as a credential by -# the written case contrast, with nothing to spare -- the guard never -# reaches it. The parenthesized side effect above is untouched: a -# bracketed clause is decided before the class is asked at all, so -# 'Andrew Perkins (MA)' keeps its nickname whatever the case. -# Not expected to fire against this corpus (no such strings survived -# into the v1 banks); kept for documentation completeness. -# The members are the entries themselves, undotted. They carried -# optional periods until corpus_rules.jsonl (#414) brought in 'John -# Smith M.A.': the member `m\.?a\.?` reaches the fragment 'M.A.' in -# that name, and a fragment a member matches has to BE a vocabulary -# entry -- 'M.A.' is not one. The bracket class around the alternation -# meant the RULE never claimed that name, but the member-level guard -# is the one that holds here, because a rule can widen at depth 0 -# later and leave a broad member governing it. A parenthesized dotted -# '(M.A.)' should now arrive UNEXPLAINED and be read once, which is -# the same discipline the rest of this file states. -dormant = "no corpus contains a parenthesized (MA) or (DO), so this data change has no diff to explain here. Kept so the rule is ready the moment such a name is harvested" -name_regex = "(?i)[(\"'](ma|do)[)\"']" -fields = ["suffix", "nickname"] - -[[change]] -issue = "feat(#269) Arabic بن prefix chains onto family (non-Latin new-recognition)" -# '‏محمد بن سلمان‏': #269 adds the native-script Arabic -# patronymic particle بن ("bin"/"son of") to PARTICLES/ -# NON_GIVEN_NAME_PARTICLES. v1 had no such entry, so it left بن a plain -# middle-name token ('سلمان' alone as last); 2.0 now chains it onto the -# family the same way 'von'/'bin' (Latin) do, giving family 'بن سلمان'. -# This is new-recognition on non-Latin input -- the exact behavior -# #269 exists to add -- not a Latin-corpus regression, so it is -# classified rather than reverted. -# Word-bounded: a bare "بن" would also match the substring inside e.g. -# لبنان ("Lebanon") and silently absorb unrelated middle/last diffs. -name_regex = "\\bبن\\b" -fields = ["middle", "family"] - -[[change]] -issue = "feat(#273) typographic nickname delimiters recognized by default" -# 'John “Jack” Kennedy', 'Hans „Hansi“ Müller', 'Jean «Petit» Dupont': -# v1 knew only straight quotes and parentheses, so a smart-quoted or -# guillemet-wrapped nickname leaked into `middle` as literal text. -# Matches only when such a delimiter is actually present. A nickname -# diff on the ASCII pairs would be a regression; the ASCII-pairs -# [[never]] entry at the end of this file is what refuses it, not this -# rule's silence (#328), in any position the pair can take. See that -# entry for why the delimiter's position turned out not to be the -# thing worth narrowing on. -name_regex = "[“”„«»「」『』()]" -fields = ["middle", "nickname"] - -[[change]] -issue = "fix(cjk-delimited-nickname) delimiter recognition compounds with the CJK order flip" -# '山田「タロ」太郎', '山田 太郎 (マイケル・ジャクソン)': one name, two -# intended changes at once -- the corner-bracket/nakaguro handling -# (2.0's typographic delimiters; 2.1's dot separator rendering the -# nickname join with a space) changes `nickname`, while the same -# name's wholly-CJK remainder takes the 2.1 family-first flip in -# first/last. Neither single-change rule may claim the union (the -# delimiter rule's fields exclude first/last, the CJK rule's exclude -# nickname -- each on purpose, so a lone regression in the other's -# fields stays loud). Both lookaheads are required: the delimiters -# alone would match 'John 「Jack」 Kennedy' -- the brackets sit in CJK -# Symbols and Punctuation, OUTSIDE every classified span, so a Latin -# name can carry them -- and would then absorb a bare first/last -# regression on it. Requiring a classified codepoint too confines the -# rule to names the order flip can actually reach. The second -# lookahead's class is the same hand copy of _SCRIPT_RANGES the rule -# above carries, pinned by the same sync test. One absorption is -# inherent and accepted: a nickname-ONLY diff on a nakaguro name -# ('マイケル・ジャクソン') classifies here, because subset-matching -# cannot REQUIRE a field to have changed. Positional note: this rule -# overlaps the CJK rule above and the delimiter rule below; within -# the name_regex tier file order decides which label a diff reports -# under, so it sits after the tighter single-change rules on purpose. -# The slug is free-form. It used to avoid the literal #271/#272 -# strings, because a pin selected the canonical CJK rule by those -# substrings and asserted uniqueness; that pin is gone (#333) and the -# span sweep finds rules by their character class instead. -name_regex = "(?s)(?=.*[「」『』・・])(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F])" -fields = ["given", "family", "nickname"] - -[[change]] -issue = "fix(cjk-fullwidth-paren-nickname) fullwidth-parenthesis recognition compounds with the CJK order flip" -# '山田 花子(旧姓 佐藤)', which reached the corpus from the #329 case -# table via build_cjk_corpus.py. It is NOT a #329 diff: that change is -# gated on a non-empty Policy.maiden_delimiters and the harness runs -# the default policy, where () is a #273 NICKNAME delimiter. So the -# same two intended changes as the rule above, with a different -# delimiter pair -- 1.4 knew no fullwidth pair and read the brackets as -# name text (first 山田 / middle '花子(旧姓' / last '佐藤)'), while 2.0 -# extracts the clause to `nickname` and the wholly-Han remainder left -# behind takes the family-first flip. -# -# Its own rule rather than a widening of fix(cjk-delimited-nickname), -# on both halves. That rule's `fields` deliberately exclude `middle`, -# and this diff has one: the fullwidth clause is written flush against -# the token before it, so 1.4's tokenizer left '花子(旧姓' as a single -# middle-name word -- a spaced corner-bracket name never does that. -# Adding `middle` there would pre-excuse a bare middle regression on -# '山田「タロ」太郎'. Its delimiter set is separately pinned in -# tests/v2/test_ledger_guards.py as "its own decision surface", so -# widening it is a deliberate act with a test to update, and there is -# nothing to gain by making it here. -# -# Both lookaheads are required for the reason given on the rule above: -# () lives in Halfwidth and Fullwidth Forms, outside every classified -# span, so a Latin name can carry it ('John (Jack) Kennedy') and the -# delimiter alone would let this rule absorb a first/last regression on -# one. The span class is the same hand copy of _SCRIPT_RANGES the rules -# above carry, pinned by the same auto-discovering test. The slug is -# free-form; the #271/#272 substrings it avoids were a constraint of a -# selector retired in #333. -# -# DORMANT since 2.2, and the reason is a migration rather than a -# revert. Its one name moved to fix(cjk-maiden-marker) above: under -# rules.md#M3 the fullwidth clause is read on the strength of the 旧姓 -# inside it, so the clause lands in `maiden` and `nickname` no longer -# diffs at all -- 1.4 read the brackets as name text and had no -# nickname here either. The diff is now {given, middle, family, -# maiden}, which is a subset of that rule's fields and not of this -# one's, so classify() hands the name over. That is the right home: -# what the name demonstrates today IS a marker consumption, and the -# fullwidth pair is only what makes the marker a separate token. -# -# Kept rather than deleted because the reasoning below is about the -# `fields` boundary between this rule and fix(cjk-delimited-nickname), -# which nothing else records, and because the declaration is load -# bearing in reverse: if a fullwidth-bracketed CJK NICKNAME diff ever -# appears again, this rule wakes and the harness says the dormant -# reason has gone false. -dormant = "its one name, '山田 花子(旧姓 佐藤)', diffs in `maiden` rather than `nickname` since rules.md#M3 and is claimed by fix(cjk-maiden-marker); no other corpus name pairs a fullwidth bracket with a CJK script" -name_regex = "(?s)(?=.*[()])(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F])" -fields = ["given", "middle", "family", "nickname"] - -[[change]] -issue = "fix(cjk-comma-honorific-peel) glued honorific peels off a post-comma given name" -# '김, 민준씨' / '田中, 太郎さん': the comma already fixes family/given -# order on both sides, so NO order flip happens -- `family` is -# byte-identical. The only change is #312's peel: the honorific glued -# to the post-comma given name comes off into `suffix` (민준씨 -> -# 민준/씨, 太郎さん -> 太郎/さん). Diff is exactly {given, suffix} on -# all seven names this rule claims. -# -# Its own rule because neither neighbour describes it, and #372 is -# about rules explaining what they describe. fix(comma-family) above -# is the Latin lone-post-comma-piece routing and is now anchored to -# Latin, so it cannot reach these at all. fix(cjk-comma-compound) -# below is for the UNION -- comma routing AND the family-first flip -# together -- which is why its fields include `family`; these names -# move no family, so claiming them there would be the same field-shape -# coincidence #372 was filed over, relocated rather than fixed. -# -# `fields` separates the two for the UNION rows ONLY, and file order -# decides the rest -- do not read the first half as the whole. The -# union rows ('Dr 김민준씨, V.', '田中さん, PhD') diff with `family` in -# the mix, which is outside this rule's [given, suffix], so they -# cannot match here however early it sits and still fall through to -# the compound rule. But the seven names this rule EXPLAINS diff -# exactly {given, suffix}, which both rules' `fields` admit, and the -# two regexes are byte-identical -- so file order alone holds them -# here, and swapping the two rules reattributes all seven (measured -# 2026-09-02; docs/design/decisions.md#differential-ledger's Declined -# entry on #382 option 1 carries the measurement and why narrowing -# the regex cannot change it). This rule is narrow-first, the -# declaration-free default, so the pair is no `order_contests` -# contest and owes no [[change.precedes_narrower]] block -- which is -# not the same as order deciding nothing. What pins the seven is -# _CROSS_RULE_WINNERS, by name. -# -# The regex is the same comma-plus-classified-codepoint pair the -# compound rule carries, hand-copied from _SCRIPT_RANGES and pinned by -# the same sync test -- a comma alone matches every Latin 'Smith, Jr.' -# in the corpus. -name_regex = "(?s)(?=.*,)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F])" -fields = ["given", "suffix"] - -[[change]] -issue = "fix(cjk-comma-compound) comma routing compounds with the CJK order flip" -# '威廉·莎士比亚, PhD': one name, two intended changes at once -- the -# post-comma lone-piece routing (fix(comma-family): v1 read PhD as -# title; 2.x routes it to suffix) and the CJK source-order/family- -# first movement in first/middle/last on the pre-comma name. Neither -# single-change rule may claim the union: comma-family's fields -# exclude last, the CJK rule's exclude title/suffix -- each on -# purpose, so a lone regression in the other's fields stays loud. -# Both lookaheads are required, by the same reasoning as the -# delimiter compound above: a comma alone matches every Latin -# 'Smith, Jr.' in the corpus, and the classified-codepoint lookahead -# confines the rule to names the CJK behaviors can actually reach. -# The class is the same hand copy of _SCRIPT_RANGES the rules above -# carry, pinned by the same sync test. The slug is free-form; the -# #271/#272 substrings it avoids were a constraint of a selector -# retired in #333. -# -# #312 sends only the glued-honorific comma rows whose diff includes -# `family` here ('Dr 김민준씨, V.', '田中さん, PhD') -- the union this -# rule is for. The rows whose diff is only {given, suffix} peel -# without any order flip and belong to fix(cjk-comma-honorific-peel) -# above, which names that shape and whose fields exclude `family` so -# the two cannot compete. Until #372 they landed on fix(comma-family) -# instead, on nothing but file order. -# -# `middle` was in that fields list too, on the strength of the -# mechanism paragraph above -- the CJK order flip CAN move a middle -# name for a three-token pre-comma run. None of the 11 names this -# rule actually explains has one: ten have a single-token post-comma -# tail, and the eleventh, '田中さん, Ph. D.', has two ('Ph.' and -# 'D.') but fix_phd merges them into one suffix unit before v1's -# comma split can turn the second token into `middle` the way an -# untreated two-token tail does at 1.4.0 ('Smith, John Middle' reads -# first 'John', middle 'Middle'). So no diff this rule claims has -# ever moved `middle`, for that reason rather than pre-comma token -# count. -# -# classify() admits by subset, so leaving `middle` out is not -# passive: a future diff that grows to include it alongside `title` -# or `suffix` -- the two roles this rule's comma-routing half -# actually claims -- now falls out of this rule instead of being -# silently absorbed, and should arrive UNEXPLAINED and be read once -# rather than get a widened rule pre-built for it (#452). That -# promise does not cover every shape, and the exceptions are per -# SHAPE and per NAME rather than per role -- measured with classify() -# over the 23 names this regex reaches, not reasoned from which role -# grew: -# -# {given, middle, family} all 23 -> fix(#271/#272/#298), the order -# flip with no comma routing, whose -# bare CJK regex sits ahead of this -# rule and whose fields were never -# narrowed -# {middle, suffix} 4 of 23 -> '田中さん, Dr.' to fix(#296), and -# '田中さん, 様.', '김민준, 씨', -# '김민준, 씨.' to -# fix(cjk-honorific-suffix) -# {middle, title} none -> unclassified -# {middle, suffix, title} none -> unclassified -# -# So what #452 buys here is narrower than "middle alongside title or -# suffix": it is the shapes in the last two rows, plus {middle, suffix} -# on the nineteen names the honorific rules do not reach. An earlier -# draft of this paragraph claimed the whole `title`-or-`suffix` -# combination and was falsified by driving classify() over the reach -- -# the method decisions.md#differential-ledger names as the only one -# that answers this question. -name_regex = "(?s)(?=.*,)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F])" -fields = ["given", "family", "title", "suffix"] - -[[change.precedes_narrower]] -issue = "fix(cjk-glued-honorific-peel) glued honorific peels into suffix" -why = """ -LIVE, and the least comfortable of the declarations in this file. -Seventeen names reach both regexes and FIFTEEN have a diff the peel -rule's {family, given, suffix} admits -- but six of those fifteen are -taken by rules written above both, and so are not this pair's to -decide: '田中, 太郎さん', '김, 민준씨', '김, 민준씨 (Jimmy)', -'선생님, J.씨' and '이, J.씨' go to fix(cjk-comma-honorific-peel), and -'田中さん, Dr.' to fix(#271/#272/#298). That leaves NINE this rule -holds and the peel rule could take, and position alone is what keeps -those nine here. Of the two names outside the fifteen, '田中さん, PhD' -lands here as well but moves `title` too, so the peel rule is -ineligible for it wherever it sits, and '田中さん, 太郎' does not diff -at all. All three counts are classify() over the seventeen, recomputed -rather than carried over. - -The order stays because the peel rule scopes itself -to a name with NO COMMA ANYWHERE, and promoting it would put that -label on '田中さん, 様.' and '김민준씨, J.씨', which do route a -post-comma piece and are the comma compound this rule is named for. - -What must NOT be claimed is that all nine are compounds. Measured -against the wheel, '王先生, V.', '田中さん, V.' and '김민준씨, V.' show -no comma routing and no order flip at all: 'V.' is the given name on -both sides, the pre-comma family is one unsegmented run, and the whole -diff is the glued peel. They arrive here because this rule's criterion -is "the diff includes `family`", and `family` moves only because the -peel took the honorific off it -- so for those three the rule's own -label is wider than the name is. The rule that would describe them, -fix(cjk-comma-honorific-peel) above, covers this shape for a -POST-comma given name and has no family-side twin. Writing one was -weighed as #496 and DECLINED on one reason alone: the three are radar -(the paragraph below), so no gate can demand the rule, and a rule -nothing can demand is not one to add. #495 asked the converse -- -whether the radar-only rules ALREADY here still earn their place -- -and closed as not planned, so they stay. That answer leaves the #496 -decline exactly where it was: it turns on nothing being able to demand -a NEW rule, which is not what #495 settled. So holding them here is -the least wrong of the readings available, and this is the entry that -says so out loud rather than letting the rule name imply otherwise. - -All seventeen contested names are radar tier, by two warrants and -not one: sixteen sit in corpus_cjk_tolerated.jsonl, demoted there by -#488, and the seventeenth, 'Dr 田中さん, V.', is in corpus_issues.jsonl -alone -- harvested, append-only, and radar since #468. So nothing -fatal turns on who wins this particular contest: no rule here can be -demanded on their account, because an unmatched diff on a radar name -is reported rather than failed for want of a rule. Not never-fatal -unconditionally, though -- a [[never]] exclusion outranks the tier -(see _CORPUS_TIERS) and none refuses these. That is a fact about the -NAMES and it does not carry to the rules. Measured by deleting each -rule and re-classifying the corpus -- without fix(cjk-comma-compound) -the run still reports 0 unexplained, so it really is radar-only and it -was the candidate #495 weighed; without the peel rule TWELVE -contract-tier names go UNEXPLAINED ('Andersonさん', '王先生' and -'김민준씨' among them) and the run fails. TWELVE remains the figure -after the 2026-09-05 narrowing of the peel rule, re-measured: the one -name that narrowing moved goes to fix(cjk-honorific-suffix) with the -peel rule present and without it alike. Twelve of the SIXTEEN names the -peel rule explains are contract (measured 2026-09-05; #495 read -fourteen of seventeen, one of which the period-class demotion took to -radar and one of which the narrowing took to another rule), which is -why #495 recorded it as a rule to keep rather than a deletion -candidate. #495 closed as -not planned, so the compound rule keeps its place as well -- and the -reason is this contest read from the other side: measured, deleting -the compound rule does not leave its eleven diffs UNCLASSIFIED, it -hands NINE of them to the peel rule, whose comment is about a -different shape. Two are left unclassified.""" - -[[change]] -issue = "fix(cjk-glued-honorific-peel) glued honorific peels into suffix" -# '김민준씨' -> 김/민준/씨, '田中さん' -> 田中/さん, 'Andersonさん' -> -# Anderson/さん: #312's peel, on a name with no space before the -# honorific and no comma anywhere. 16 corpus names, which fell to the -# fields-only fix(suffix-routing) catch-all (deleted in #451) until -# #372 gave them a rule that names them. 17 until the 2026-09-05 -# narrowing below, which is not one of them leaving the corpus: the -# name it lost was never glued. -# -# The regex is this file's first hand copy of GLUED_HONORIFICS rather -# than SUFFIX_WORDS, and that narrower vocabulary is the whole reason -# this rule can exist. '김민준씨' and the given name '김지양' are the -# same string shape -- hangul ending in a character the spaced list -# calls an honorific -- so a rule keyed on SUFFIX_WORDS could not tell -# them apart, and unanchoring the spaced rule below to reach the glued -# forms would let a future suffix regression on '김지양' classify as a -# recognized honorific. GLUED_HONORIFICS settles it in the config -# instead: 양, 군, 氏, 殿 and 博士 are deliberately absent from it, -# because a glued match on those collides with real given names. Same -# copy, same pin (_HONORIFIC_SOURCES) as the 2.0 ledger's twin, whose -# regex this is verbatim. -# -# `\.?(?=$|[ ,])` rather than `$`: the honorific ends a TOKEN, not -# necessarily the string. '田中さん II' and '김민준씨 Jr.' carry a -# Latin suffix after it, '김민준씨 (Jimmy)' a nickname. -# -# The three negative lookbehinds are the 2026-09-05 narrowing, and the -# 2.0 twin's comment carries the argument for them in full. 박사님, -# 선생님 and 교수님 are the only entries of this alternation containing -# another entry of it, so a match on the interior 님 was the one way a -# SPACED honorific could reach a rule whose title says a glued one was -# peeled off the name. It reached two: '김민준 박사님', where nothing is -# peeled at all -- rules.md#W2's second sentence, whose witness line for -# this string is suffix="박사님", the honorific whole and its own word -- -# and '선생님', where `suffix` moves nowhere and the order rule claims -# the diff regardless. The lookbehinds refuse the interior position -# only, so a glued '김민준박사님' still matches on 박사님 itself. -# -# Measured consequence here, 2026-09-05: exactly one name changes hands -# and it is '김민준 박사님', which fix(cjk-honorific-suffix) below takes. -# Summary lines byte-identical, no pin in this file moves, -# order_contests unchanged, recorded reach 37 -> 35. -name_regex = "(?<=[^\\s,])(? -# given 'abdul', family 'Smith', suffix 'V'; at this baseline the V was -# not read as a suffix, so all three fields move here where the 2.x -# ledgers list two. Both are rules.md examples; no differential corpus -# name has the shape. The 'abdul Smith V Ph. D.' spelling stays out -# of the corpus: a trailing split credential is this ledger's -# [[never]] shape, and its v1 reading carries the same family loss -# that entry would then refuse to explain. -# -# A rule with fewer fields than this diff silently handed the name to -# the fields-only fix(suffix-routing) catch-all, which is how this fix -# was first recorded as "1.4.0 parity": the catch-all's printout was -# capped, and EXPLAINED NOTHING is not byte-identical. #451 deleted -# that catch-all, so a narrowed rule here now costs the name its -# explanation outright instead of hiding it -- louder, and the reason -# the lesson is worth keeping written down. -name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?v$" -fields = ["given", "family", "suffix"] - -[[change]] -issue = "fix(#421) the bound-given join never absorbs a suffix piece" -# 'abdul Jr Smith Berg': rules.md#P5 -- "the join never absorbs a -# marker standing as a word of its own -- a marker is not a name word -# (M2) -- nor a word of the unambiguous suffix vocabulary (S2), -# wherever position will then place it". given 'abdul Jr', middle -# 'Smith' -> given 'abdul', middle 'Jr Smith'. Parity with this -# baseline until now; a -# rules.md example, since no differential corpus name has the shape. -name_regex = "(?i)^abdul\\s+jr\\s+smith\\s+berg$" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#421) the bound-given join never absorbs a split credential" -# 'abdul Ph. D. Smith Berg': the rule above, on the split credential. -# At this baseline v1's fix_phd extracted the credential before -# parsing, so: first 'abdul Smith', suffix 'Ph. D.' -> given 'abdul', -# middle 'Smith', suffix 'Ph. D.' -- the suffix field unchanged here, -# the given deliberately not restored (decisions.md#P5), so this -# ledger lists two fields where the 2.x ledgers list three. A rules.md -# example; no differential corpus name has the shape. -name_regex = "(?i)^abdul\\s+ph\\.\\s+d\\.\\s+smith\\s+berg$" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#425) accepted: a bare ambiguous acronym the peel does not take joins as a name word" -# 'abdul Ma Smith': rules.md#P5 -- "a bare ambiguous acronym is a name -# word wherever the peel does not take it". first 'abdul', middle -# 'Ma' -> given 'abdul Ma': v1's join counted the acronym out as -# suffix vocabulary and declined; since 2.0 the join takes it, as -# assign reads it (a middle name, not a credential, with a family -# behind it). A 2.0 reading, byte-identical at 2.0.0 and 2.1.0, first -# witnessed here -- P5 records it as Accepted, and the example exists -# to carry the shape into this corpus. -name_regex = "(?i)^abdul\\s+ma\\s+smith$" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle, the P4 example" -# 'Xyz. van Johnson': the rules.md#P4 example of the 'Esq. van Gogh' -# rule of this change (beside the fix(#367) rules, further down), -# same reading and fields. Separate rather than an alternation, per -# the guards' one-alternation-per-rule roster. -name_regex = "(?i)^xyz\\.\\s+van\\s+johnson$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#424) accepted: the maiden walk keeps the numeral an initial before the marker vetoes" -# 'J. née Jones Smith V': rules.md#M2 -- "It takes them up to the -# trailing run of post-nominals and titles that the end of the name -# reads as if the clause were not written". 1.4.0 has no maiden support -# and read middle 'née Jones', last 'Smith', suffix 'V'; 2.x reads -# maiden 'Jones Smith V' with the J. before the marker vetoing the fork, -# as 'J. V' reads the V as a name. The fix(#274) rule's fields omit the -# suffix v1 read, so this accepted reading names its own. -name_regex = "(?i)^j\\.\\s+n[ée]e\\s+jones\\s+smith\\s+v$" -fields = ["middle", "family", "suffix", "maiden"] - -[[change]] -issue = "fix(#424) accepted: the chain keeps an acronym assign will not peel behind a title-and-particle word" -# 'Freiherr von Berg MA': P2 at #424 read the trailing suffix over the -# pieces the chain leaves; since #614 it is read first -- rules.md#P2: -# "a trailing suffix begins -- the trailing run S2 reads, once, before -# the chain is made" -- and the 2026-09-18 note below is the reading -# that ships. #367 stops the leading-particle scan at a -# title-and-particle word, so the chain takes 'von Berg', -# and the acronym the fork counted with three pieces meets assign -# with two; the chain asks the peel again and takes it. first 'von -# Berg', last 'MA' -> family 'von Berg MA' (master's reading since -# 2.0). A rules.md example. Without this rule the name landed on the -# fields-only fix(suffix-routing) catch-all -- the count was the tell. -# #451 deleted that catch-all, so this ledger has no fields-only rule -# left to absorb anything. -# -# 2026-09-18 (#289): a THIRD reading, and it is the one that ships. -# The comment above says the chain "asks the peel again and takes -# it"; with the case signal the peel takes the ACRONYM instead, so -# the name reads title 'Freiherr', family 'von Berg', suffix 'MA' -- -# neither 1.4.0's (first 'von Berg', last 'MA') nor 2.0-2.3's -# (family 'von Berg MA'). Accepted rather than repaired: the lean -# ends P2's chain with no words to spare where the count would not, -# and `fields` gains `suffix` because the diff moves it. Measured at -# this baseline: given 'von Berg' -> '', family 'MA' -> 'von Berg', -# suffix '' -> 'MA'. The rule kept explaining nothing between the -# case signal landing and this edit -- the diff had grown past the -# declaration, which is the under-declared half of #452. -name_regex = "(?i)^freiherr\\s+von\\s+berg\\s+ma$" -fields = ["given", "family", "suffix"] - -[[change]] -issue = "fix(#424) a title-led chain before the numeral is the one name piece" -# 'Freiherr von Richthofen V': rules.md#P2 boundary -- the chain stops -# before the numeral, and the chain is then the one name piece, which -# reads as 'Dr. Smith V' reads. v1 read last 'von Richthofen V'; #424 -# split the numeral off, and #410 then moved the one name piece behind -# the title out of `given`, so today it is family 'von Richthofen', -# suffix 'V' -- 'Dr. Smith V' moved the same way, so the comparison -# still holds. A rules.md example. -# -# The diff was {given, family, suffix} before #410 and is {family, -# suffix} after it. `fields` declared all three until this round, and -# that width is exactly why the movement never surfaced: the subset -# test kept matching as the diff shrank, so a real change of reading -# passed through a green run unnamed. The declaration is narrowed to -# what the diff actually is, which is what makes a future `given` -# regression on this name loud instead of absorbed. A `fields` list -# wider than the diff it explains is a silent absorber. -name_regex = "(?i)^freiherr\\s+von\\s+richthofen\\s+v$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#424) accepted: a particle of the suffix vocabulary opening the trailing run is a suffix piece" -# 'John Smith Mc V': rules.md#P2 Accepted -- "where it opens the -# trailing run the chain stops before it as before any suffix word, -# and the peel takes it". middle 'Smith', last 'Mc V' -> family -# 'Smith', suffix 'Mc, V'. A rules.md example; the class was found -# unrecorded by the second docs review of #424. -name_regex = "(?i)^john\\s+smith\\s+mc\\s+v$" -fields = ["middle", "family", "suffix"] - -[[change]] -issue = "fix(#424) the particle chain stops before the trailing numeral" -# 'John van der Berg V': rules.md#P2 -- "a trailing suffix begins -- the -# trailing run S2 reads, once, before the chain is made [...] a trailing -# roman numeral, or a bare acronym with words to spare, ends the chain -# as a suffix word does". last 'van der Berg V' -> family 'van der -# Berg', suffix 'V', as 'John Smith V' reads. Shipped since 1.x: the -# chain's stop asked with the suffix-piece test, whose initial veto does -# not see a bare V. A rules.md example; no differential corpus name has -# the shape. Without this rule the name landed on the fields-only -# fix(suffix-routing) catch-all -- the count was the tell. #451 -# deleted that catch-all, so this ledger has no fields-only rule left -# to absorb anything. -name_regex = "(?i)^john\\s+van\\s+der\\s+berg\\s+v$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#424/#445) accepted: the maiden walk keeps a bare acronym, and the lone name word is the family" -# 'John née Jones Smith Ma': rules.md#M2 -- "It takes them up to the -# trailing run of post-nominals and titles that the end of the name -# reads as if the clause were not written", and 'John Ma' reads the Ma -# as the family. middle 'née Jones', family 'Smith', suffix 'Ma' -> -# maiden 'Jones Smith Ma': v1 had no maiden support; 2.0 has read the -# name so since #274, and since #533 the walk asks the acronym fork -# rather than leaving it: what declines this word is the WRITING, Title -# case saying name where capitals would say credential, and the count -# such a reading needs is taken over the name the take would leave -# rather than over the words as they stand. The fix(#274) rule -# cannot carry it: its fields omit the suffix v1 read. A rules.md -# Accepted example, first witnessed here. -# -# Renamed for #445 (2026-08-27) rather than widened in place, because -# the diff now has two causes and one rule has to explain the whole -# of it. What the walk leaves is a single name word, and rules.md#M4 -# makes that word the family: given 'John' -> family 'John', so -# `given` joins the four fields above. Attributing that to #424 alone -# would have been false, and leaving this rule at four fields would -# have made it explain nothing while the name went UNEXPLAINED -- -# which is exactly how the gate reported it before this edit. The -# name is #445's only mover whose diff is not the shape the fix(#445) -# rule below carries, the suffix v1 read being the difference. -# -# 2026-09-19, #533: NARROWED from (?i) to the two spellings whose -# reading this rule describes, and widened to the second of them. -# The (?i) anchor reached 'John née Jones Smith MA' as well, and -# that name now does the OPPOSITE of what this rule says -- the -# clause gives the MA up and it reads suffix 'MA' -- so leaving -# the anchor alone would have this rule explain a diff under the -# reasoning of the reading it lost. It is carried below, by -# 'fix(#445/#533) the clause gives the credential up, and the one -# name word it leaves is the family'. 'JOHN NEE JONES SMITH MA -# PHD' joins instead: one case, so no lean, and a suffix word -# BEHIND the member ends the peel before it reaches the member -- -# the clause keeps the MA and the lone name word is the family, -# which is this rule's sentence exactly. Its mixed-case spelling -# does not read that way ('John nee Jones Smith MA PHD' reads -# suffix 'MA PHD', measured), which is why the anchor cannot go -# back to (?i) to pick it up. -name_regex = "^(?:JOHN NEE JONES SMITH MA PHD|John n[ée]e Jones Smith Ma)$" -fields = ["given", "middle", "family", "suffix", "maiden"] - -[[change]] -issue = "fix(#360) ste moved into the never-given particles with mc" -# 'Ste Marie': the second word of the vocabulary move, which the -# fix(#360) comment in the 2.x ledgers names as a name that should -# arrive UNEXPLAINED and be read once. It has. Without this rule it -# landed on the fields-only catch-all, whose prose described a trailing -# credential rather than a leading particle folding into the family; -# #451 deleted that catch-all, so removing this rule now costs the -# name its explanation outright (measured through classify()). -name_regex = "(?i)^ste\\s+\\S+$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#360) mc moved into the never-given particles, so it folds into the family" -# 'Mc Donald': mc became a never-given particle, so the leading token -# stops being read as the given name and folds into the family instead -# ('Mc' + 'Donald' -> family 'Mc Donald'). The twin of the ste rule -# above, and written beside it for that reason. -# -# In `(?i)^mc\s+\S+$` the `mc` is a LITERAL anchored at the start, -# followed by whitespace and then exactly one token to the string's -# end. `\S+$`, not a `\b`, is what closes it: the pattern carries no -# word boundary at all. 'McDonald, Ronald' and 'Mcintyre Smith Jr.' -# fail on the whitespace, having none after 'mc'; 'Ste Marie' and -# 'Los Santos' fail earlier still, opening with neither letter. None -# of the four is this rule's to claim, whatever else explains it -- -# and if one ever needs explaining, it needs a rule of its own. Do -# not widen this one to reach it: a rule claiming names its prose -# does not describe is what #451 is retiring. -# -# Ported verbatim from expected_since_2.0.0.toml, minus `_ambiguities`: -# below baseline 2.0 there is no v2 surface, so no diff can carry that -# pseudo-field and naming it would narrow by something unreachable. -# Until this port the name fell to the fields-only catch-all, which -# _CROSS_RULE_WINNERS recorded rather than closed (#451). -name_regex = "(?i)^mc\\s+\\S+$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#367) a title no longer displaces a leading never-given particle" -# 'Sir de Mesnil': the title stops pushing the particle out of the -# leading position, so the particle run reads as the family rather -# than the given name. The `de` sibling of the two #367 `van` rules -# below. -# -# `(?i)^sir\s+de\b` is anchored at the start, and its `\b` is real -# work rather than decoration: it is what keeps 'Sir Denis Smith' and -# 'Sir del Rio' out. The roster's own probes fail earlier -- 'John Sir -# de Mesnil' and 'Smith, Sir de Vaux' on the anchor, 'sir' opening -# neither string, and 'Sir Smith' on the literal 'de'. None of the -# three is this rule's to claim, whatever else explains it, and a name -# of that shape that ever needs explaining wants its own rule rather -# than a widening of this one (#451). -# -# Ported verbatim from expected_since_2.0.0.toml, whose copy already -# reads fields = ["given", "family"] and carries nothing to drop -- -# unlike the #360 rule above, this one never claimed `_ambiguities`. -name_regex = "(?i)^sir\\s+de\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle" -# 'Esq. van Gogh' (corpus_issues): rules.md#P4 -- "An unlisted -# abbreviation before the particle is as transparent as a listed -# title, since assign reads it as one (H2)". title 'Esq.', last 'van -# Gogh' -> given 'van', family 'Gogh', as 'Dr. van Gogh' reads since -# #367 (whose rule below records v1's titled-particle reading). #367 keyed the leading-particle exception on the -# first piece of the name by group's title test, which does not see -# H2's abbreviations; the scan asks assign's test now. Literal for the -# reason the #367 rule gives: a regex cannot ask whether a word is a -# title, and this one cannot ask whether it is unlisted. -# Without this rule the name landed on the fields-only -# fix(suffix-routing) catch-all -- the count was the tell. #451 -# deleted that catch-all, so this ledger has no fields-only rule left -# to absorb anything. -name_regex = "(?i)^esq\\.\\s+van\\s+gogh$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#367) a title no longer displaces a leading particle out of the leading position" -# 'Mr. Van Nguyen': a leading particle deliberately does not chain -- -# that is what makes 'Van Nguyen' read as given Van, family Nguyen. -# Through 2.1 the exception was keyed on piece index 0, so a TITLE -# displaced the particle and the chain fired, giving family -# 'Van Nguyen' (v1's reading, which is why this name is parity with -# 1.4.0 until now). 2.2 keys it on the first piece of the NAME, so a -# titled name reads as the same text reads untitled. -# -# The wider class is any title followed by a particle -- 'Dr. Van -# Johnson', 'Sir de Mesnil', 'Sheik Abu Bakar' all move (the last restored -# by #369) -- and this -# regex names one title and one particle because 'Mr. Van Nguyen' is -# the one name in the corpora that moves. Deliberately narrower than -# the class: a corpus name that joins it later should arrive -# UNEXPLAINED and be read once, not be absorbed silently. Widen the -# literals then, and record the new count. -# -# Three joined through #413's backtick harvest: 'Dr. Van Johnson', -# 'Sir Van Johnson', 'Jr. Van Johnson'. Widened here as well as in the -# 2.0 and 2.1 copies -- widening only those left these three landing on -# the fields-only fix(suffix-routing) catch-all (deleted in #451), -# which is the very absorption this comment describes two paragraphs -# up. None of them is a two-token name and none moves a suffix. -# -# The title slot copies TITLES: all four spellings are in it. `jr` is -# also suffix vocabulary and reads as a title here simply because it -# is in TITLES and leads -- 1.4.0 already gives title 'Jr.' for -# 'Jr. Van Johnson', so that reading predates #367 entirely. -# -# The title slot is a LITERAL for that reason and not by accident. -# Nothing in a regex over the raw string can ask whether a word is a -# title, so the `(?i)^\S+\.?\s+van\b` this rule shipped with matched -# ANY first word: it reached 11 corpus names and explained 1. The -# other ten include 'Vincent van Gogh', 'VINCENT VAN GOGH' and -# 'Alex van Johnson' -- the "Van Johnson" family AGENTS.md names as a -# standard regression canary. name_regex rules sort ahead of -# fields-only ones and `fields` matches by subset, so a future -# regression on any of those would have been labelled fix(#367) and -# exited 0 -- a strict loss at this baseline, whose ledger was empty -# before. (`\.?` was inert into the bargain: `\S+` is greedy, so it -# had already taken the period.) -# -# `fields` is what carries the rest of the tightness -- and it is -# narrower than the change rather than a description of it. The wider -# mechanism moves more than `given`/`family`: un-chaining reveals -# whatever was hidden behind the particle, so 'Dr. St John Smith' -# moves `title` (title 'Dr.', family 'St John Smith' -> title -# 'Dr. St', given 'John', family 'Smith'), 'Mr. Van Johnson nee Brown' -# moves `maiden` (family 'Van Johnson nee Brown' with no maiden -> -# given 'Van', family 'Johnson', maiden 'Brown'), and a third token -# after the particle moves `middle` the same way ('Mr. Van Johnson -# Middle': v1 gives it all to `last`; the un-chained reading gives -# given 'Van', middle 'Johnson', last 'Middle'). `suffix` and -# `nickname` are the two roles that genuinely never move. -# -# `fields` is what keeps those wider shapes out, NOT the regex -- -# measured, `^(mr|dr|sir)\.?\s+van\b` has no end anchor and does -# reach 'Mr. Van Johnson nee Brown' and 'Mr. Van Johnson Middle'; -# only 'Dr. St John Smith' is outside it. The three names this rule -# actually claims are 'Mr. Van Nguyen', 'Dr. Van Johnson' and -# 'Sir Van Johnson' -- NOT the trio two paragraphs up, which includes -# 'Jr. Van Johnson', the next rule's one name -- and each diffs -# {given, family} alone. `fields` names those two roles and no -# others, so `title`, `maiden` and `middle` are left out on purpose. -# classify() admits by subset, so leaving a role out is not passive: -# a diff that grows to include one of them falls out of THIS rule -# instead of being silently absorbed. Where it lands then is a -# separate question, and it depends on the SHAPE rather than the role. -# Measured on the nee name above: {family, maiden} goes to fix(#274), -# which is its right home, but {given, family, maiden} -- the shape -# this rule would actually grow into, since it already moves `given` -# -- is claimed by nothing, because fix(#274)'s own `fields` stop at -# maiden/middle/family and its comment says why. A `middle` or `title` -# growth is likewise claimed by nothing. So the honest statement is -# per shape, not per role: drive classify() rather than reasoning from -# which role moved (#452). -# -# This rule has to exist even though the run already exited 0 without -# it. The diff was being classified by fix(suffix-routing), a -# fields-only rule whose ["given", "family", "suffix"] was a superset -# of this diff's ["given", "family"] -- a rule about a trailing -# credential absorbing a leading-particle change, which is the -# mis-classification the README warns about. A name_regex rule -# outranks every fields-only one, so this claimed it back; #451 has -# since deleted the catch-all, and no ledger has a fields-only rule -# for anything to be claimed back from. -name_regex = "(?i)^(mr|dr|sir)\\.?\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#367) an inferred title no longer displaces a leading particle either" -# 'Jr. Van Johnson': the same change as the rule above, for a leading -# piece the period-abbreviation inference reads as a title (H2). It -# sat in that rule's alternation as 'jr' while 'jr' was title -# vocabulary; #296's audit took it out (a postnominal only), so the -# member would now reach a word the vocabulary does not ship, and the -# shape gets its literal. The reading is unchanged: 'Jr.' is still a -# title by shape, and Van is still the leading name piece. -# -# `middle` was declared here too, the same excess the 'Mr./Dr./Sir.' -# rule above drops and for the same reason: this rule's one name, -# 'Jr. Van Johnson', diffs {given, family} alone. classify() admits -# by subset, so leaving `middle` out means a diff that grows to -# include it falls out of this rule instead of being silently -# absorbed. Measured, nothing else claims a `middle` diff on this -# rule's name either, so it arrives unclassified and is read once -# rather than have a widened rule pre-built for it (#452). -name_regex = "(?i)^jr\\.\\s+van\\b" -fields = ["given", "family"] - -# Shapes that must never be explained. A [[change]] rule says "this -# diff is intended, and here is what changed"; there was no rule -# meaning "whatever happens here is a regression", so the two promises -# below could only be written as prose -- and both were false as -# written from the day they were written (the first shipped with the -# harness itself, the second three days later) until #328 found them. -# -# The two are not false alike, and the difference matters now that -# this file scopes the second promise to one reading. Both said -# "unclassified", unqualified, and both were claimed on SOME reading -# from day one. But only the Ph. D. promise was claimed on the reading -# it now protects; no rule has ever claimed the ASCII pairs' NICKNAME -# reading, then or today. That entry is a tripwire under no present -# tension, which is the point of setting it before the tension exists. -# -# classify() consults these before any rule, so a matching name -# reports UNEXPLAINED however many rules would claim it. -# -# `examples` is not decoration. A protected shape need not appear in -# any corpus -- 'John (Jack) Kennedy' does not -- so the entry carries -# its own test data, which tests/v2/test_ledger_guards.py runs against -# every non-empty subset of the fields a rule may name: the seven -# roles and the `_ambiguities` pseudo-field. An entry carrying -# `fields` is asked only about the subsets those cover -- 3 for the -# ASCII pairs, not 255 -- which is where the 387 in that file comes -# from. -# -# The illustration was 'John "Jack" Kennedy' until #486's shape 1-3 -# variation matrix gave that string's existing case row a shape tag -- -# the row (tests/v2/cases.py 'nickname_quotes') long predates that -# work; only the tag is new -- which put it in corpus_shapes.jsonl. -# The sentence is about the KEY, not about that name, so it moved to -# the sibling example still in no corpus at all; of the four, the -# remaining two ('"Rick" Edmonds', 'Franklin, Benjamin (Ben)') are -# corpus names in radar files. That an example can be promoted this -# way is the point of the key rather than a problem with it -- -# nothing about the entry depends on which side of the corpus its -# test data sits on. -# -# The replacement is one tag from the same fate, and whoever writes -# that tag should read this: 'John (Jack) Kennedy' is itself a case -# row's text ('nickname_parens', the medial-paren sibling of the row -# above), so tagging it with shape 1 puts it in corpus_shapes.jsonl -# and falsifies this sentence exactly as the last tag did. There is -# no example left that is not a corpus name or a case row, which is -# the honest state of it: repoint the sentence at whichever example -# is still outside, or restate it as "examples are not required to be -# corpus names" and stop naming one. - -[[never]] -why = "trailing 'Ph. D.' split-token healing is PARITY, not a 2.0 change: v1 healed the adjacent pair too, so a diff here is a regression" -# Anchored to the END, and that is the whole design. The promise names -# two forms -- 'John Ph. D.' and 'John Smith, Ph. D.' -- so an -# exclusion keyed on the comma protects half of it while looking -# complete. The anchor also keeps clear of 'Ph. D. John Smith', the -# LEADING shape. That shape had its own fix(leading-credential) rule -# until #371 restored v1 and it went dormant; the carve-out stays -# load-bearing anyway, since it is what lets 'Ph. D., Jr.' reach -# fix(comma-precomma-family). Dropping it because the rule it once -# protected is gone takes this gate red. No `fields`: any diff on this shape is a -# regression, which is what omitting the key means. -# -# One shape this deliberately over-reaches: 'John Smith, Jr. Ph. D.' -# still matches, and its ORDER diff (1.4's 'Ph. D., Jr.' against 2.x's -# 'Jr. Ph. D.') is an INTENDED change -- cases.py classifies it -# fix(credential-pair-order). No corpus contains it, so nothing is -# silenced today. Erring toward refusal is the safe direction: an -# UNEXPLAINED diff blocks a release for scrutiny, where a wrongly -# classified one passes silently. If a corpus ever gains a -# Ph. D. name, narrow this entry rather than widening a rule -# to claim it. -# -# One did, through #413's backtick harvest, and this is that -# narrowing. It is written as the ORIGINAL broad refusal minus three -# named exceptions, not as a positive description of the two promised -# shapes. The first attempt did the latter -- "a name then the -# credential, or two words, a comma, then the credential" -- and cut -# far more than the three: 'Smith, John Ph. D.', the ordinary -# family-first listing, and every multi-comma or mid-title form -# ('Smith, John, Ph. D.', 'Smith, Dr. John Ph. D.') stopped being -# refused. All of those are dead-parity today, so the gate stayed -# green while the tripwire was gone -- and a healing regression on -# them would then have classified under fix(comma-family) and exited -# 0, which is the outcome this entry's own `why` exists to prevent. -# -# The three exceptions, each diffing for a cause this ledger names -# elsewhere: a leading credential with no name before it (which had -# a fix(leading-credential) rule until #371 restored v1 and it went -# dormant -- see the note where it stood), one word before the comma -# (fix(comma-precomma-family)), and a suffix word between the name and -# the credential (fix(credential-pair-order), its own rule below). -# Refusing those was the entry over-reaching; refusing anything else -# is the entry doing its job. -# -# Latin-anchored: an unrestricted trailing anchor also caught -# '田中さん, Ph. D.', whose diff is a real fix(cjk-comma-compound) -# classification -- measured, it took the gate to unexplained: 1. The -# cut is at U+0250, the same threshold _is_latin_only uses in -# compare.py, so it covers Latin-1 and Latin Extended-A and -B rather -# than bare ASCII: the split-token healing is script-independent, and -# 'José Smith, Ph. D.' is as much the protected shape as 'Jose Smith, -# Ph. D.' is. An ASCII-only class protected one and not the other. -# -# NARROWED 2026-10-12, as the paragraph above asks: the last component -# was `ph\.\s*d\.`, which also matches the UNSPLIT `Ph.D.` -- a single -# token no healing ever touched. corpus_cldr.jsonl brought 27 names -# ending `M.D. Ph.D.` in either case. 11 carry a FAMILY_FIRST shape and -# are skipped at 1.4.0; the 16 compared differ only in the suffix -# separator ('M.D., Ph.D.' -> 'M.D. Ph.D.'). With `paní Alexandra -# Zuzana Machová Ondřejová, Ph.D.` -- an unsplit `Ph.D.` whose diff is -# a title move -- the entry refused 17. `\s+` keeps it to the split pair its `why` is about. -name_regex = "(?i)^(?!\\s*ph\\.)(?![^\\s,]+\\s*,\\s*ph\\.\\s*d\\.\\s*$)(?![\\u0000-\\u024f]*\\b(?:jr|sr|ii|iii|iv)\\.?\\s+ph\\.\\s*d\\.\\s*$)[\\u0000-\\u024f]*\\bph\\.\\s+d\\.\\s*$" -examples = ["John Ph. D.", "John Smith Ph. D.", "John Smith, Ph. D."] - -[[never]] -why = "feat(#273) recognizes TYPOGRAPHIC nickname delimiters; the ASCII pairs were already recognized in 1.4, so a NICKNAME diff on them is a regression" -# Narrowed by ROLE, not by shape, and the reason is measured. -# -# By shape this covers a delimited run anywhere: leading -# ('"Rick" Edmonds'), medial ('John (Jack) Kennedy') or trailing -# ('Franklin, Benjamin (Ben)'), with a word character or a period as -# the inner flank so a middle initial counts ('Cherice J. (Johnson) -# Williams'). HOW MANY corpus names that is has a single home, and it -# is not this line: tests/v2/test_ledger_guards.py pins it as this -# entry's `captures` -- grep _EXCLUSION_EFFECT for the pattern above -# and read the number there, where a change fails CI. This comment -# carried a copy of it for several rounds (34, then 46, then 51) and -# the copy was stale more often than not, since the figure moves -# whenever the CORPORA move and not only when the entry does -- it -# never caught up to 55, which the pin was already holding when the -# copy still read 51, and that lag is the whole argument for deleting -# the copy rather than correcting it a fourth time. -# -# It was medial-only for three rounds, on the theory that the trailing -# position is where credentials live and that widening would silence -# 21 legitimate classifications. Both halves were wrong, and 1.4.0 -# says so directly: -# -# 'Andrew Perkins (JD)' 1.4 nickname 'JD' 2.x nickname 'JD' -# 'Andrew Perkins (MBA)' 1.4 suffix 'MBA' 2.x suffix 'MBA' -# 'Franklin, Benjamin (Ben)' 1.4 nickname 'Ben' 2.x nickname 'Ben' -# -# 1.4 read a trailing '(JD)' as a NICKNAME, exactly as it read -# '(Ben)'. So the promise -- ASCII pairs were already recognized, a -# nickname diff on them is a regression -- covers the trailing -# credentials too, and refusing them is the promise being kept rather -# than over-reach. Where 1.4 did read the parens as a credential it -# put them in `suffix` ('MBA' is in the suffix vocabulary), so the -# diff lands outside this entry's `fields` and the exclusion never -# fires. The PARSER discriminates by vocabulary, which is the -# discrimination no delimiter regex can make. -# -# Cost of the widening, measured against the 1.4.0 wheel: of the 21 -# names it adds, only 3 diff at all, and none of those diffs is a -# subset of {nickname, middle} -- '山田 太郎 (マイケル・ジャクソン)' -# moves {first,last,nickname}, '김, 민준씨 (Jimmy)' {first,suffix}, -# '김민준씨 (Jimmy)' {first,last,suffix}. Zero classifications lost. -# -# A bare [("'] class is still wrong, for the reason the role -# narrowing cannot fix: it reaches names whose only match is an -# apostrophe that is part of the word ("Brian O'connor", -# "Harietta Keopuolani Nahi'ena'ena"), which is not a delimited run at -# all. Both figures moved twice in the #335 delta and are the class -# this file keeps getting wrong, so recount rather than adjust -- the -# body sits flush left because `python -c` compiles it as a module: -# -# uv run python -c " -# import glob, json, re -# def _n(x): return x if isinstance(x, str) else x['name'] -# names = {_n(json.loads(l)) for f in glob.glob('tools/differential/corpus*.jsonl') -# for l in open(f, encoding='utf-8') if l.strip()} -# hit = [n for n in names if re.search(r'[(\"\']', n)] -# only = [n for n in hit if not re.search(r'[(\"]', n) -# and not re.search(r'(^|\\s)\'', n) and re.search(r'\\w\'', n)] -# print(len(hit), len(only))" -# -# By role: ASCII parens mark nicknames, maiden names, suffixes and -# credentials alike, and no regex tells them apart. The shape reaches -# 'Jenny (Johnson) Baker' and 'Lon (Jr.) Williams', whose parens are a -# maiden name and a suffix -- both areas under active development -# (#335, suffix in parens). `fields` names the two roles a nickname -# regression moves a token BETWEEN, so those names stay classifiable -# on the five it does not name. It does not leave them wholly alone: -# 'Lon (Jr.) Williams' parses its suffix into `middle` today, so a -# middle-only diff on it is silenced. That is the price of protecting -# the reading at all, since a nickname regression is exactly a -# middle/nickname move. -# -# feat(#273)'s own rule needs no shape narrowing of this kind -- a -# typographic delimiter is not a credential marker -- which is why its -# regex is a bare character class. It does carry `fields`, for the -# separate reason recorded there. -name_regex = "(^|[\\w.]\\s+)[(\"'][^)\"']+[)\"'](\\s+\\w|\\s*$)" -fields = ["nickname", "middle"] -examples = ["John \"Jack\" Kennedy", "John (Jack) Kennedy", - "\"Rick\" Edmonds", "Franklin, Benjamin (Ben)"] - -[[change]] -issue = "fix(#418) the connective carve-out counts the name the maiden clause leaves behind" -# 'Juan y Garcia née Jones': rules.md#P3's three-word carve-out -# counted the marker and the maiden name, so 'y' joined and the -# family was empty once the clause left. fix(#274) above cannot -# classify this one: it moves `given` as well, since the joined -# 'Juan y Garcia' comes apart. `maiden` is in the fields because 1.4 -# has no maiden field at all, the same reason every marker name is a -# diff at this baseline. Checked against this baseline's own run; the -# expected_since_2.1.0.toml copy carries the fuller account. -name_regex = "(?i)^\\S+\\s+[ye]\\s+\\S+\\s+n[eé]e\\s+\\S+$" -fields = ["given", "middle", "family", "maiden"] - -[[change]] -issue = "fix(#432) a dotted numeral behind a name is a middle initial, not the generation" -# 'Smith, John V.': v1 read suffix 'V.'. Behind a NAME word the period -# is decisive -- it marks an abbreviation, and an abbreviation is name -# material -- so the numeral is the middle initial it looks like, as -# 'Smith, John B.' always was. The BARE form is the boundary and is -# v1 parity either way: 'Smith, John V' stays suffix 'V' (#144), and it -# is a corpus name, so the gate asserts that half rather than this -# entry's silence. -# -# The #430 half needs no entry at this baseline: 'Smith, PSM I' is -# already claimed by "fix(comma-family) lone post-comma piece routes to -# suffix/title, not first", whose fields cover the move. A second rule -# would be dormant, which the gate reports as EXPLAINED NOTHING. -name_regex = "(?i)^smith,\\s*john\\s+v\\.$" -fields = ["middle", "suffix"] - -[[change]] -issue = "fix(#410) a title and one name word name the family, whatever annotation stands beside it" -# rules.md#H1 reads a title plus exactly one name word as the family -# ('Mr. Johnson' -> family 'Johnson'). The guard implementing it also -# declined whenever ANY token carried a suffix, nickname or maiden -# role, so the very same name with an annotation beside it kept the -# word in `given` and reported no family at all. None of the three is -# a name word, so the term went and these names move `given` -> -# `family`, reading as their un-annotated twins already did. -# -# Keyed on the names by literal rather than by shape. The shape is "a -# title, one name word, and an annotation" -- a parse tree, not a -# string -- and a fields-only rule standing in for it would classify -# every future given/family swap in this corpus. -# -# Its own rule because these have nothing to do with suffix routing: -# they were falling to the fields-only fix(suffix-routing) catch-all -# (deleted in #451), on a rule whose every other name moves a trailing -# token into `suffix`, while these move no suffix at all. Same surgery -# #372 did on the same catch-all, whose heading returned to 14 once -# this rule took them. -# -# THREE names here, where the 2.0.0 and 2.1.0 ledgers classify four: -# at this baseline 'Dr. Smith née Jones' also moves `middle` and -# `maiden`, because 1.4.0 has no maiden support and read the marker as -# a middle name. It gets the separate rule below rather than widening -# `fields` here, which would let these three move a maiden name -# unnoticed. -# -# Four classified across the two rules, out of FIVE names that change -# reading. The fifth is 'Freiherr von Richthofen V' -- a title, one -# name word and a suffix, so this same shape -- and the fix(#424) rule -# further down has always claimed it. #410 only narrows its diff, from -# {given, family, suffix} to {family, suffix}, and that rule declares -# all three, so the gate stayed green and never named it. It is not -# moved here; the classification was right and the count was not. -name_regex = "(?i)^(?:Senator \"Rick\" Edmonds|Xyz\\. \\(Bud\\) Smith|'Smitty' Dr\\. Jones)$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#410) the maiden flavor, where 1.4.0 read the marker as a middle name" -# 'Dr. Smith née Jones': the same H1 widening as the rule above, but -# against a baseline with no maiden support, so the diff is four -# fields rather than two. 1.4.0 had nowhere to put a birth surname -- -# no default marker vocabulary routes to `maiden` -- so it read first -# 'Smith', middle 'née', last 'Jones'. 2.x extracts the clause -# (`middle` and `maiden`), and #410 is what moves 'Smith' from `given` -# to `family` on top of that. -# -# Separate from the rule above because `fields` is the union of two -# different changes here, and a single rule carrying all four would -# let the three names up there move a `middle` or a `maiden` without -# anything noticing. This is the one name in the corpus where the -# marker extraction and the H1 fix land together. -name_regex = "(?i)^Dr\\. Smith n[ée]e Jones$" -fields = ["given", "middle", "family", "maiden"] - -[[change]] -issue = "fix(#434) a multi-word maiden marker takes the maiden name" -# 'Maria Kowalska z domu Nowak': maiden_markers gained PHRASE entries -# and the Polish "z domu" is the first one any shipped set holds, so -# rules.md#M2's take fires on a marker that spans two tokens. Every -# earlier version read the marker as name text -- middle 'Kowalska z -# domu', family 'Nowak' -- because neither z nor domu is a marker -# alone and nothing looked at the pair. -# -# `fields` is the three the diff moves and no more. The marker leaves -# the name, so `middle` empties and `family` moves back one word, -# which is why this cannot share a rule with the bracketed name below: -# there the clause changes role and the name words around it do not -# move at all. One rule over both would have to declare the union and -# would pre-excuse a middle/family regression on the bracketed -# spelling -- the #444 failure the fix(#335) rules above spell out. -# -# Keyed on the literal, like those rules and for their reason: the -# shape is "a marker phrase inside a name", a question about the -# parse, and a fields-only rule standing in for it would pre-excuse -# every future middle/family/maiden confusion in the corpus. -name_regex = "(?i)^Maria Kowalska z domu Nowak$" -fields = ["middle", "family", "maiden"] - -[[change]] -issue = "fix(#434) a multi-word marker leads a bracketed clause to the maiden name" -# 'Maria Kowalska (z domu Nowak)': the same vocabulary change at the -# other site. rules.md#M3 reads a marker-led clause as the maiden name -# whatever pair encloses it, and #335 gave it that reach in 2.2 -- but -# the reach alone leaves this name a nickname, since M3 asks whether -# the content opens with a recognized marker and through 2.1 no -# vocabulary answered yes here. The phrase entry is what moves it, so -# it is #434's diff and not #335's, and it is deliberately NOT a -# seventh alternative in that rule's six literals. -# -# `fields` is the pair that moves. The clause changes role; the name -# words keep their readings, so a `given`, `middle` or `family` -# movement here is a regression and stays loud. -name_regex = "(?i)^Maria Kowalska \\(z domu Nowak\\)$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led bracketed clause reads as the maiden name whatever pair encloses it" -# 'Jane Smith (née Jones)', 'Jane (née Jones) Smith', 'Jane Smith -# (née V)', 'Anna Müller (geb. Schmidt)', 'Ursula von der Leyen -# (geb. Albrecht)': rules.md#M3 reads a bracketed clause whose content -# opens with a marker word and carries a word after it as the maiden -# name. 1.4 extracted the same five clauses as NICKNAMES -- v1 knew -# the parenthesis pair and had no maiden reading to give an -# undeclared one -- so the diff is the role and only the role. -# -# `fields` is exactly the pair that moves. Five names, two fields, -# measured; the union with what these names move against 2.0.0 would -# be wider, and declaring a field a diff does not move is what let a -# real given/family movement hide behind fix(#424) until PR #444. -# -# The CJK name is NOT here, unlike in the two 2.x ledgers. Against -# 1.4 it diffs in {given, middle, family, maiden}: v1 read the -# fullwidth brackets as name text, so there is no nickname on either -# side to change hands, and fix(cjk-maiden-marker) above already -# describes exactly that diff. See the dormancy note on -# fix(cjk-fullwidth-paren-nickname), which is the rule that used to -# hold it. -name_regex = "(?i)^(?:Jane Smith \\(n[ée]e Jones\\)|Jane \\(n[ée]e Jones\\) Smith|Jane Smith \\(n[ée]e V\\)|Anna Müller \\(geb\\. Schmidt\\)|Ursula von der Leyen \\(geb\\. Albrecht\\))$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led clause leaves the one name word its bare reading" -# 'Smith (née Jones)', which rules.md#N3 carries as an Accepted line. -# rules.md#N3: "A name that is only a nickname and one name word reads -# that word as the family name", and a marker-led clause is not a -# nickname clause, so N3 no longer reaches this shape: the word keeps -# the reading the bare "Smith née Jones" gives it. -# -# Its own rule rather than a sixth alternative in the fix(#335) rule -# above, for the reason that rule states: one rule holding both would -# have to declare the union of two different diffs and would -# pre-excuse whatever else landed inside it. -# -# The revisit note this block carried was answered on 2026-08-27, and -# the answer is the opposite of what it feared. It expected #445 to -# widen this diff and be absorbed here; #445 SHRANK it. The bare -# reading the word keeps is now `family` (rules.md#M4), and 1.4.0 -# read this name family 'Smith', nickname 'née Jones' -- so `given` -# and `family` agree with the baseline again and only the clause -# changes hands. The declaration is narrowed to the two fields that -# still move, which is what the note asked for: a `given` or `family` -# move on this name is UNEXPLAINED from here on rather than absorbed. -# Measured, not reasoned -- and the first pass got the 2.x half of -# that sentence wrong, saying those baselines read `given` when they -# read family 'Smith', nickname 'née Jones' like this one. Both are -# narrowed to two fields now (2026-08-27 review round). This name is -# the one shape in #445's class the gates do NOT report, which is -# why nothing but reading the ledger could catch it. -# -# What the narrowing buys was said here to differ by ledger, and it -# did not. The sentence claimed the fields-only fix(suffix-routing) -# rule that then stood below "still matches every name with {given, -# family, suffix} and would take it"; that stopped being true when the -# same #451 bundle narrowed the catch-all to {family, suffix}, since a -# {given, family} diff is not a subset of those two. Measured against the -# ledger as it stood then, this name's {given, family} diff was -# already UNEXPLAINED at THIS baseline, exactly as at 2.0.0 and -# 2.1.0. #451 has since deleted the catch-all outright, and the -# measurement is unchanged. -name_regex = "(?i)^Smith \\(n[ée]e Jones\\)$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#445) a maiden marker makes the lone name word the family" -# rules.md#M4 -- "a maiden name standing beside exactly one name word -# makes that word the family name". A maiden name is a FORMER family -# name, which only means something beside a current one, so the word -# left standing beside it is the surname the bearer uses now. Seven -# names, one field set: 'Smith née Jones' -# read first 'Smith', middle 'née', last 'Jones' on 1.4.0 and reads -# family 'Smith', maiden 'Jones' here. -# -# Three of the four fields are #274's marker consumption -- v1 had no -# maiden support, so the marker sat in the name as a middle word and -# the last word was the family -- and the fix(#274) rule above -# carried these names until #445 added `given` to their diffs and -# they left its declaration. Only `given` is new; the rule declares -# all four because a rule explains a name's whole diff. -# -# Anchored, and spelled out one name per alternative, for the reason -# M4 itself gives. The shape regex a reader would write first -- one -# word, then a marker -- also matches M4's two carve-outs -# ('abd née Jones', 'J. née Jones Smith V'), and a rule declaring -# `given` over those two would stand ready to explain the very -# given -> family move the carve-outs exist to prevent. They stay -# with fix(#274), whose fields do not include it. -# -# 'Smith née Jones' and 'Smith née Jones PhD' are corpus rows this -# change itself added, being M4's own example lines; the other five -# were already here, and were five of the eight the fix(#274) note -# above listed as the checklist to re-measure. -name_regex = "(?i)^(?:Janey n[ée]e Jones|Jane n[ée]e Jones J\\. V|Jane n[ée]e Jones Smith|Jane n[ée]e and Jones Smith|John n[ée]e Jones Smith V|Smith n[ée]e Jones|Smith n[ée]e Jones PhD)$" -fields = ["given", "middle", "family", "maiden"] - -[[change]] -issue = "fix(N3) a nickname-led name with a trailing suffix keeps the suffix in `suffix`" -# "'Smitty' Jones Jr.": the diff against 1.4.0 moves exactly {family, -# suffix} -- `given` is 'Jones' at both baselines, and it is `given` -# BECAUSE N3 declines: N3's one-piece count does not set a trailing -# suffix aside, so the suffix disqualifies the rule and the positional -# read applies. Set the suffix aside and N3 does fire -- -# "'Smitty' Jones" reads family 'Jones' (rules.md#N3's own example). -# Whether 1.4 itself read the trailing word as -# `family` is outside what this ledger's worker can check directly -# (tools/differential/README.md's warning against reading a v1 -# release from a cached environment); the diff shape is consistent -# with that reading, and 2.x reads this name given 'Jones', family -# '', suffix 'Jr.'. decisions.md#N3. -# -# Its own rule because it is three tokens -- a quoted nickname, one -# bare name word, one suffix word -- and #451 retired the fields-only -# fix(suffix-routing) catch-all by giving its names narrower -# rules; this name was one of that catch-all's nine when that work -# started. The two-token suffix rules #451 owed are now the four -# below, and this shape -- three tokens, a leading nickname -- fits -# none of them, which is why it kept a rule of its own. -# -# Note what is NOT settled here: decisions.md#H1 records that N3 and -# H1 count a trailing suffix in opposite directions. H1 IGNORES one -- -# rules.md#H1 makes the lone word the family "whatever suffix, -# nickname or maiden name stands beside it", so 'Dr. Smith Jr.' reads -# family 'Smith' (the declining term was there until #410 took it -# out, which is what that entry is recording). N3 counts one, and so -# declines: "N3 counts a suffix the other way (`'Smitty' Jones Jr.` -# reads given 'Jones', family '')" -- and H1 states plainly that -# "which of the two readings is right for a nickname-led name is not -# decided here". This rule classifies only the 1.4-to-2.x movement -# measured above; it does not endorse either reading. -name_regex = "(?i)^['\"‘“][^'\"’”]+['\"’”]\\s+\\S+\\s+(jr|sr|ii|iii|iv|v)\\.?$" -fields = ["family", "suffix"] - -# The four rules below are what is left of the fields-only -# fix(suffix-routing) catch-all, which stood between fix(#380) and -# fix(#342) above until #451 deleted it. It was the only rule in any -# ledger with no name_regex, so it sorted after every other rule and -# took whatever nothing narrower named -- 25 names when #372 first cut -# it back, six by the time this task began. Those six are the six the -# four rules below explain, one behaviour family each. The numeral and -# jr rules reach more corpus names than they explain -- the surplus is -# claimed by rules above, or does not diff at all, and each of those -# two comments names its own. _CORPUS_CLAIMS holds the reaches; do not -# restate them here, because nothing recomputes a number in a comment. -# -# They sit at the END of the file on purpose. The catch-all sorted last -# because it had no name_regex; with regexes these join the regex tier, -# where _sorted_rules is STABLE and FILE ORDER decides ties. Placing -# them last reproduces the catch-all's position exactly: every rule -# above keeps every contest it already won. The numeral rule's regex -# reaches 'Carod i' and '田中さん II', and _CROSS_RULE_WINNERS pins -# both -- but only the first is a real contest: measured by moving the -# numeral rule to the front of the file, 'Carod i' changes hands and -# '田中さん II' does not, because its diff moves {family, given, -# suffix} and these four declare {family, suffix}, so the `given` move -# is outside them wherever they sit. File order decides one, the -# subset test the other, and the roster does not care which. - -[[change]] -issue = "fix(suffix-routing) a two-token name ending in a roman numeral keeps it in `suffix`" -# 'John V' and 'Mohamad X': v1 routed a lone trailing suffix to -# family/first (no comma is present in either), and 2.x reads the -# trailing numeral as the suffix. Whether 1.4 itself read that token as -# `family` is outside what this ledger's worker can check directly -# (tools/differential/README.md's warning against reading a v1 release -# from a cached environment); the diff this rule explains at this -# baseline moves exactly {family, suffix}, which is consistent with -# that reading and is what the fields below claim. -# -# The numeral does not reach `suffix` through a wordlist. It reaches it -# through _vocab.is_trailing_numeral_suffix -- "assign's roman-numeral -# fork, shared with group's bound-given reserve (#401): a FINAL -# single-token piece that is a roman numeral reads as the suffix when -# the piece before it does not look like part of an initial run". That -# is a PATTERN test plus a layout test, and no vocabulary lookup at -# all: 'x' is in none of SUFFIX_WORDS, SUFFIX_ACRONYMS or their -# ambiguous subsets, and 'Mohamad X' moves anyway. Which is why this -# alternation is declared in _NOT_A_VOCABULARY_COPY rather than in -# _LATIN_ALTERNATION_SOURCES -- there is no vocabulary here to drift -# from. -# -# The alternation is _ROMAN's own body, r"^(X|IX|IV|V?I{0,3})$" in -# nameparser/_pipeline/_vocab.py, with the EMPTY alternative removed: -# I{0,3} becomes I{1,3}, and the bare V that change would lose is -# spelled out beside it. Measured over every string of length 0 to 4 -# drawn from IVXivx -- length 0 included on purpose, since the empty -# string is the whole difference -- the two accept exactly the same -# strings but that one, which _ROMAN takes and this does not. Dropping -# it is a latent widening removed: with the `^\S+\s+` prefix the empty -# alternative would match a name ending in whitespace, which no corpus -# name is today. Dropping it is deliberate: behind "^\S+\s+" and before "$" an -# empty alternative would match a name ending in whitespace. No corpus -# name does, so this is a latent widening removed rather than a bug -# fixed -- and it is the reason the two patterns are NOT byte-identical -# copies of one another, which a reader comparing them should know -# before "fixing" the difference. -# -# 'v' is in SUFFIX_WORDS as well, and the jr rule below deliberately -# does not spell it: the numeral fork is what actually moves it, and -# two rules claiming one token would leave file order to decide which -# explains it. -# -# The regex also reaches corpus names this rule does not explain, and -# the list is the count: -# 'Carod i' is fix(#397)'s NOT-WANTED rule above -- a real contest, -# same fields and both regexes matching, decided by nothing but file -# order -- and '田中さん II' is fix(cjk-glued-honorific-peel)'s, where -# the diff moves {family, given, suffix} and this rule's `fields` -# cannot admit the `given` move whatever the order. -# _CROSS_RULE_WINNERS pins both. That reach is -# real, so neither is a _MUST_NOT_MATCH probe here: that roster tests -# the regex, not classify(). -name_regex = "(?i)^\\S+\\s+(X|IX|IV|V?I{1,3}|V)$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(suffix-routing) a two-token name ending in the suffix word jr keeps it in `suffix`" -# 'Smith Jr.': v1 routed a lone trailing suffix to family/first (no -# comma present) and 2.x keeps a recognized suffix WORD in `suffix`. -# Whether 1.4 itself read that token as `family` is outside what this -# ledger's worker can check directly (tools/differential/README.md's -# warning against reading a v1 release from a cached environment); the -# diff this rule explains at this baseline moves exactly {family, -# suffix}, which is consistent with that reading and is what the fields -# below claim. -# -# 'jr' is an entry in SUFFIX_WORDS (nameparser/config/suffixes.py), so -# unlike the numeral above this one really is a vocabulary claim. It is -# written as a single LITERAL rather than as a one-member alternation, -# which is what keeps it off _LATIN_ALTERNATION_SOURCES: that roster is -# discovered from alternation syntax, and a rule with no alternation -# owes it nothing. A second suffix word needing this shape should -# become an alternation and be enrolled against SUFFIX_WORDS then, not -# be smuggled in as another literal. -# -# The regex also reaches corpus names this rule does not explain, and -# the list is the count. Three of them diff and belong to two other -# rules: 'Kim, Jr.' and 'Smith, Jr.' are fix(#296)'s lone post-comma -# credential and '김민준씨 Jr.' is fix(cjk-glued-honorific-peel)'s. -# What keeps them there is `fields`, not file order: measured, they -# diff {family, given, suffix, title} and -# {family, given, suffix}, and neither is a subset of the two roles -# below, so this rule is INELIGIBLE for all three wherever it sits. -# Order is the second line of defence, not the first -- a widening of -# `fields` alone would still lose to the rules above, and it would -# take a widening AND a move to hand any of them over. -# _CROSS_RULE_WINNERS pins all three against that pair of edits. -# 'Doe,, Jr.' is reached too and does not diff against this baseline -# at all, so it has no winner to lose and no row. That reach is real -# either way, so none of the reached names is a _MUST_NOT_MATCH probe -# here: that roster tests the regex, not classify(). -name_regex = "(?i)^\\S+\\s+jr\\.?$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(suffix-routing) a two-token name ending in a credential acronym keeps it in `suffix`" -# 'Donald mc' and 'QC MP': v1 read the trailing token as a name part -# and 2.x reads it as the postnominal it is -- 'QC MP' is two -# credentials and no name at all. Whether 1.4 itself read that token as -# `family` is outside what this ledger's worker can check directly -# (tools/differential/README.md's warning against reading a v1 release -# from a cached environment); the diff this rule explains at this -# baseline moves exactly {family, suffix}, which is consistent with -# that reading and is what the fields below claim. -# -# Both members are entries in SUFFIX_ACRONYMS, which is a DIFFERENT -# vocabulary from the jr rule's SUFFIX_WORDS above, and that is why -# these are two rules rather than one. _LatinCopy carries exactly one -# `vocabulary`, and -# test_latin_alternations_mean_something_the_vocabulary_ships asserts -# exactly one roster key per alternation, so an alternation spanning -# both sources could not be pinned against either. The split is forced -# by the guard, not chosen for taste. -# -# 'mc' deserves its own paragraph, because it is in SUFFIX_ACRONYMS and -# in PARTICLES both -- the collision decisions.md#vocabulary-collisions -# C-ii settled for 'vd' ("the Dutch van der reading, as the more -# common"), and which its own C-ii note records that 'do' and 'mc' -# inherit "without being weighed". That decision reaches a written name -# only through P6's attachment, which is scoped to the comma form -- -# decisions.md#P6: "the COMMA form only, deliberately. [...] Without -# the comma the written shape is not settled [...] Those keep their -# positional reading and are not tracked as deviations." 'Donald mc' -# has no comma, so P6 never reaches it. -# -# What stands instead is rules.md#S2: "an unambiguous suffix is consumed -# even when that leaves no family name at all". That is the -# authority here, and NOT P6's scope note, which does not support it: -# P6 promises the comma-less shapes keep their POSITIONAL reading, and -# for the words that are both particle and suffix vocabulary it does -# not hold. Measured, 'Jong de' and 'Smith do' keep theirs (family -# 'de', family 'do') while 'Donald mc' and 'Smith vd' lose the family -# entirely to `suffix`. decisions.md#P6's own arc entry recorded that -# as a rules.md defect rather than a ledger one, and #454 fixed it on -# 2026-09-07: P6's Accepted clause now names vd and mc as the two words -# the positional reading does not hold for, which is what this rule has -# been classifying all along. This rule is unchanged by that repair and -# still rests on S2, its Accepted clause as quoted above. -# -# The two neighbouring shapes both already have rules above, and the -# contrast is the point: 'Mc Donald' has the particle LEADING and folds -# into the family (fix(#360), #360), and 'Berg, Jan vd' has it trailing -# WITH a comma, so P6 attaches it to the family (fix(#380), #380). -# Trailing and comma-less is the third corner, and it is this one. -name_regex = "(?i)^\\S+\\s+(mc|mp)$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(suffix-routing) a two-token name ending in a dotted credential spelling keeps it in `suffix`" -# 'Jack M.A.' and 'Wang M.Eng.': v1 read the trailing token as a name -# part and 2.x reads it as a credential. Whether 1.4 itself read that -# token as `family` is outside what this ledger's worker can check -# directly (tools/differential/README.md's warning against reading a -# v1 release from a cached environment); the diff this rule explains -# at this baseline moves exactly {family, suffix}, which is consistent -# with that reading and is what the fields below claim. -# -# The label names the SHAPE the two share -- one name word and a -# dotted credential behind it -- because no single decision covers -# both. Until 2026-09-27 the rule held 'Jack M.A.' alone and was -# labelled for decisions.md#ma-do, which decides that name and not -# the other. -# -# 'Jack M.A.' is a DECIDED reading, unlike the fix(#342) and fix(#397) -# rules above, which classify a diff because its cause is known and -# not because the reading is wanted. decisions.md#ma-do decides it: -# "ma and do joined the ambiguous acronym set because both are common -# surnames; the two-word 'Jack Ma' is kept intact by S2's -# words-to-spare guard, while the periods gate governs the dotted -# spellings ('M.A.' counts unambiguously)". The bare and dotted -# spellings of one name therefore read differently ON PURPOSE, which -# is why both spellings of the bare one are _MUST_NOT_MATCH probes -# here. -# -# 2026-09-18 (#289): the quoted sentence holds for the spelling it -# names and not for its all-caps twin. 'Jack Ma' is still kept intact -# by the words-to-spare guard; 'Jack MA' is now read as a credential -# by the written case contrast, with nothing to spare -- the guard -# never reaches it, and the periods gate is not what decides it. Only -# 'Jack Ma' is still a probe here; 'Jack MA' moved to the fix(#289) -# rule's own roster at the bottom of this file, where the roster -# comment says why. decisions.md#ma-do carries the amendment. -# -# 'Wang M.Eng.' (joined 2026-09-27, #544) reads suffix 'M.Eng.' at -# 2.0.0 through 2.3.0 for a different reason: 'meng' was an -# unambiguous acronym there, and rules.md#S2 consumes an unambiguous -# suffix "even when that leaves no family name at all". #540 made -# 'meng' ambiguous in the 2.4 cycle, which read the family for the -# chunked spelling, and the tree reads the suffix again by S2's period -# gate, which counts an ambiguous acronym "written with its periods, -# one after each letter or one after each of two or more letter -# chunks" unambiguously. Against this baseline the diff is the same -# {family, suffix} either way, and 2.0 is where it arrived. -# -# Literal-anchored, one whole name per alternative, and the members -# could not be spellings alone. Measured: a member `m\.?a\.?` matches -# the fragment 'M.A.' in the corpus, and _normalize leaves that as -# 'm.a', which is not a SUFFIX_ACRONYMS entry -- so -# test_latin_alternations_mean_something_the_vocabulary_ships rejects -# it as an alternation member -- its helper _reaches_non_vocabulary -# puts the rule as "every fragment a member matches must BE an entry". -# The ambiguous-surname-acronym rule above records the same finding -# and answers it the same way: keep the periods out of the members. -# The alternation here is of two anchored NAMES, not vocabulary, so it -# is declared in _NOT_A_VOCABULARY_COPY and owes -# _LATIN_ALTERNATION_SOURCES nothing. -# -# Anchored on the name word as well as on the spelling: 'John Smith -# M.A.' and 'John Smith M.Eng.' are three tokens rather than two and -# do not diff against this baseline -- 'John Smith M.A.' is a corpus -# name (it arrived with corpus_rules.jsonl, #414) -- so a rule keyed on -# the dotted spelling alone would stand ready to explain a future -# regression on either. Both are _MUST_NOT_MATCH probes for that -# reason, with 'Wang Ma.' (the single trailing period S2's gate does -# not count) and the superstring 'Dr. Wang M.Eng.'. -name_regex = "(?i)^(?:jack\\s+m\\.a\\.|wang\\s+m\\.eng\\.)$" -fields = ["family", "suffix"] - -# --------------------------------------------------------------------- -# The `_initials` rules (#484). `_initials` is the differential's -# derived-view pseudo-field: it enters a name's diff ONLY when the -# seven roles and `_ambiguities` agree on every compared surface, so -# every rule below declares it alone (validate_rules refuses it -# beside a role). None of these classifies a role move; each -# classifies a change in what HumanName.initials() -- and from 2.0 -# on parse().initials() -- renders for a parse whose fields did not -# move. -# -# The order the file has them in: the four literal name LISTS first -# (fix(#385/#402), fix(#360), feat(#269), fix(#528) -- the last added -# 2026-09-13), then the three VOCABULARY rules -- connective, -# bound-given, particle chain -- then the Ph. D. merge. -# Only the bound-given/particle-chain pair is ordered by narrowness, -# and by measured reach rather than by reading: 41 corpus names -# against 107 (measured 2026-09-02 at this baseline, and recorded in -# _CORPUS_CLAIMS). The connective rule's position ahead of both is NOT -# a specificity claim -- its reach is 96, wider than the bound-given -# rule it precedes -- it is just where it was written. -# -# Every rule below carries orders = ["DEFAULT"]. Each describes a -# DEFAULT-order render, and an order-bearing entry never consults the -# facade at all -- main() passes empty dicts for it -- so an -# `_initials` diff under a declared order could only come from the -# CORE, and must not be absorbed by a rule whose prose says "facade". -# -# #383/#479 HAD no rule in this block, and #528 gave it one. Recorded -# as a pair because the absence was itself a measured finding and the -# reason it stopped holding is the whole of #528. The change moved -# what `parse(...).initials()` renders for 'john e smith', 'john e -# jones', 'jones, john e' and 'JUAN Y GARCIA' -- and on 2026-09-13 -# `HumanName.initials()` re-derived the connective decision from -# vocabulary and initial shape on the raw text instead of reading the -# parse's tags, so the FACADE's view did not move at all and the -# facade is the only surface compared below 2.0. Measured that day: -# not one of the four diffed here. #528 closed the split; the facade -# reads the tags now, the same four names move here, and -# `fix(#528) the facade's initials follow the parse's connective tags` -# below classifies them. -# -# THE FOUR ARE NOT THE CHANGE'S WHOLE POPULATION, and this is the -# one place in this file that says which names it leaves out. -# Measured 2026-09-13 over the corpus glob, driving the facade before -# and after #528: SIX one-case names' `HumanName.initials()` move for -# the tag reason, and the two that are missing above -# ('jose e maria santos', 'JUAN GARCIA Y LOPEZ') move their ROLES -# against this baseline as well, so `_initials` never enters their -# diff (compare.py's roles-identical guard) and the role rule at the -# end of this file explains what happened to them. Scoped to that -# reason deliberately: other corpus names' facade initials move -# against 1.4.0 for other reasons and are no part of the six -- -# 'e and e' ('e a e.' -> 'e. a. e.') and 'juan garcia y lopez' -# ('j. g l.' -> 'j. g. l.'), both measured from the wheel the same -# day, move on the 2.0.0 per-word GROUPING the `fix(initials-per-word)` -# rules below describe. decisions.md#P3 records the split and -# decisions.md#R3 its closing. -# -# THE #528 RULE SITS AHEAD OF THE VOCABULARY RULES, with the literal -# name lists, and the position is load-bearing rather than tidy: the -# connective rule below carries `fields = ["_initials"]` too, its -# regex reaches 'john e smith' and 'john e jones' (measured -# 2026-09-13), and equal `fields` puts the pair outside -# `precedes_narrower` -- which leaves file order deciding, with -# _CROSS_RULE_WINNERS in tests/v2/test_ledger_guards.py pinning the -# answer. Written first, the narrower rule wins, which is this file's -# convention. - -[[change]] -issue = "fix(#385/#402) an all-particle name part initials its words (R2)" -# 'Anh Do', 'Juan van der', 'Nguyen, Van Le': a name part whose every -# word is particle vocabulary is a part where none of them is doing a -# particle's work, so its words initial as ordinary name words -- -# rules.md#R2/#R3, shipped in 2.2.0 (release_log "Fix a family name -# made only of particle words reporting no base"), decisions.md#R2. -# 1.4.0 contributed nothing for such a part ('Anh Do' -> 'A.'; the -# tree gives 'A. D.'). The fields did not move, only the view, which -# is why no rule could classify this until #484 gave the gate an -# initials column. Reach exactly the names that move, listed -# literally rather than shaped (31 corpus names over 26 spellings on -# 2026-09-25; _CORPUS_CLAIMS holds the live reach) -- 'anh van mc' -# joined on 2026-09-18 with the case row that admitted it to the -# corpus, the unambiguous-vocabulary twin of the 'anh van do' already -# here, and 'nguyen van lac' on 2026-09-25 with #540's, its family -# 'van' a one-particle part whose initial 1.4.0 dropped ('n.' -> -# 'n. v.'), which is this rule's reason and not #540's -- the -# case-insensitive regex reaches the later Title-case 'Nguyen Van -# Lac' too, one spelling for two corpus names: -# the shape is "a part of nothing but particles", which no regex over -# the raw string can state, and the fix(#445) rule above set the -# precedent for a name list in THIS ledger (fix(#410) and fix(#335) -# are two more). 'de los Santos' is NOT here: its -# initials moved for a different reason (the rule below). -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#360) los joined the particles, so it no longer initials" -# 'de los Santos': #360 (2.2) added the Spanish/Portuguese articles to -# PARTICLES. The family was 'de los Santos' before and after; what -# changed is that 'los' is now a particle and contributes no initial -# ('l. S.' -> 'S.'). One name, literal. -name_regex = "(?i)^de los santos$" -fields = ["_initials"] -orders = ["DEFAULT"] - -# `feat(#269) a recognized non-Latin connective contributes no -# initial` STOOD HERE until 2026-09-20 and is DELETED, not declared -# dormant. It covered '^محمد و علي$' on `fields = ["_initials"]`, -# saying that `و` was recognized as a connective in 2.0 and so stopped -# contributing a letter: 'م. و. ع.' became 'م. ع.'. #461 asks the -# question of the PART instead, and `و` is the whole middle part with -# no name word beside it to join, so it initials again -- the tree -# gives 'م. و. ع.' from both surfaces, which is 1.4.0's own answer, -# measured from the wheel. The name stops diffing at this baseline -# and the rule reported EXPLAINED NOTHING. Its REASON is wrong now, -# not merely unreached, so a `dormant = "..."` declaration would -# assert something false; the ledger's own convention -# (tools/differential/README.md, the dormancy tiers) is deletion. -# It still diffs at the four 2.x baselines, where 2.0 through 2.3 all -# dropped the letter, and the fix(#461) readmission rule in each of -# those ledgers carries it. decisions.md#R3. - -[[change]] -issue = "fix(#528) the facade's initials follow the parse's connective tags" -# 'john e smith', 'john e jones', 'jones, john e' and 'JUAN Y GARCIA'. -# `HumanName.initials()` decided "is this word the connective" from -# the lexicon plus v1's initial SHAPE on the part's raw text, where -# the core's initials() reads the tag the parse recorded. #383/#479 -# gave rules.md#P3 a fork the shape cannot see -- in a name written -# wholly in one case a marked 'e' is an initial and an unmarked 'y' -# joins -- so from 2.4's #527 until #528 the two views of one parse -# disagreed about the same letter. They agree now, and the facade's -# answer is the core's. Name by name, with 1.4.0's values measured -# from the wheel on 2026-09-13: 'john e smith' goes 'j. s.' -> -# 'j. e. s.'; 'john e jones' and 'jones, john e' both go 'j. j.' -> -# 'j. e. j.', the two e-names whose family initial is a `j` and not -# an `s`; and 'JUAN Y GARCIA' goes 'J. Y. G.' -> 'J. G.', the -# capital one losing a letter where the other three gain one. No -# role moves on any of the four -- P3's three-word carve-out is untouched -- which -# is exactly why the derived view is the whole diff here. -# -# LITERAL, four names; which two of the change's movers they leave -# out, and why, is the block header above. -# A regex for the class -- "a one-case name whose single-letter -# connective the parse read against 1.4.0's reading" -- is not -# writable over the raw string at all, the reading being the parse's. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the -# probes: the mixed-case spellings, where the writing decides the -# letter and nothing moved on either surface; 'maria y lopez', whose -# lowercase 'y' was the connective before and after; and -# 'Ph. D., John', the one corpus name whose two initials views still -# disagree -- the facade merges the credential into one list element -# and renders 'P D' with no inner delimiter, which is -# fix(initials-per-word) the Ph. D. merge below and not this. -# -# Two of the four -- 'john e smith' and 'john e jones' -- were -# EXPLAINED BY THE CONNECTIVE RULE BELOW before this rule existed -# (measured 2026-09-13 at this baseline: both sat in its classified -# list). Why this rule takes them instead: the block header above. -# _CROSS_RULE_WINNERS pins it and _RECORDED_DIFFS carries the shapes. -# -# 2026-09-20, #461: 'JUAN Y GARCIA' LEAVES the alternation, and the -# name is the one of the four this rule can no longer say anything -# about. Its 'Y' holds the middle part alone, so under #461 it -# initials again on both surfaces: the tree gives 'J. Y. G.' from -# HumanName.initials() and 'J. Y. G.' from parse(...).initials(), -# which is word for word what the 1.4.0 wheel gives, so the name has -# no diff left at this baseline to explain (measured 2026-09-20 from -# the wheel and from the tree). Dropping it from the regex rather -# than leaving it there is the narrowing this file asks for -- a -# member reaching a name that cannot diff is a standing claim on -# whatever that name diffs NEXT. The other three still move for this -# rule's own reason, the facade reading the parse's tags, and are -# untouched. The name keeps a rule at 2.0.0/2.1.0/2.2.0, where -# fix(#462) explains it and its surface flipped; at 2.3.0 the rule -# written for it is deleted for this reason. decisions.md#R3. -name_regex = "^(?:john e smith|john e jones|jones, john e)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# #461: A CONNECTIVE CONTRIBUTES NOTHING WHERE IT IS JOINING, AND -# INITIALS LIKE ANY OTHER NAME WORD WHERE ITS PART HOLDS NOTHING -# ELSE FOR IT TO JOIN (rules.md#R3, decisions.md#R3). -# -# ONE rule at this baseline and two at every 2.x one, and the -# asymmetry is measured rather than tidy. The half that LOSES a -# letter -- a connective with name words beside it -- was already -# diffing here before this change, on the 2.0.0 per-word GROUPING -# the four fix(initials-per-word) rules below describe, so its names -# stay on those rules and the first of them carries a dated -# paragraph saying which half of its diff is now which. The half -# that GAINS one had no 1.4.0 diff at all: every name below agreed -# with the wheel before this bundle and diverges from it now, which -# is a parity break this file has to state in its own words rather -# than let an older rule absorb. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#461) a connective holding its part alone contributes an initial" -# Fifteen names, each one a part whose only word is the connective, -# so there is nothing there for it to join and it initials as any -# other name word does -- which is what `family_base` and -# `given_base` have said about the same token since 2.2 -# (rules.md#R2/#R3). 1.4.0 dropped the letter and so did every -# release since; the tree keeps it. Measured from the 1.4.0 wheel -# and from the tree on 2026-09-20, name by name: 'Juan de y' 'J.' -> -# 'J. y.'; 'johnny y' 'j.' -> 'j. y.'; 'Garcia y Lopez' 'G. L.' -> -# 'G. y. L.'; 'Juan y Garcia' 'J. G.' -> 'J. y. G.' and 'juan y -# garcia' the lowercase twin; 'John y Jane' 'J. J.' -> 'J. y. J.'; -# 'Carod y de Rovira i' 'C. R.' -> 'C. y. R.'; 'John e Smith' 'J. S.' -# -> 'J. e. S.', with 'John e Smith III', 'John e Smith, III' and -# 'Smith, John e, III, Jr' giving the same pair; 'john e jones, III' -# 'j. j.' -> 'j. e. j.'; 'Jose e Maria' 'J. M.' -> 'J. e. M.'; -# 'Lt.Gov. juan e garcia' 'j. g.' -> 'j. e. g.'; and 'Fritz Freiherr -# und von Bar' 'F.' -> 'F. u.', the German spelling of the same -# shape. -# -# NOT THE PER-WORD GROUPING BELOW, and that is the reason this rule -# exists at this baseline rather than the names being left to it. -# 1.4.0 rendered every one of these with the same granularity the -# tree does -- 'J. G.', not 'J G.' -- so nothing about the grouping -# moved; what moved is the LETTER COUNT. Each of the eleven whose -# spelling carries a lowercase ' y ' or ' e ' is reached by that -# rule's alternation all the same, and would be absorbed by it -# silently, which is what _RECORDED_DIFFS and _CROSS_RULE_WINNERS -# adjudicate: equal `fields`, so neither rule is the narrower and -# file order is the whole decision, and this rule is written first. -# -# `Fritz Freiherr und von Bar`, 'johnny y' and 'Smith, John e, III, -# Jr' reach no such contest -- the alternation below carries no -# 'und', and it wants whitespace AFTER the letter, which a letter at -# end of string and a letter before a comma never offer -- so the -# three have no pin. -# -# Literal-anchored to the fifteen. The shape is "a part holding one -# connective and nothing else", which no regex over the raw string -# can state -- the reading is the parse's -- and the nearest -# writable shape, "a name carrying a lone cased letter", reaches -# every bare-initial name in the corpora. _MUST_NOT_MATCH carries -# the wall: 'Jon Dough and' (base 'Dough and', a connective with a -# name word beside it, keeping 'J. D.'), 'Juan Velasquez y Garcia' -# (the joined run, keeping 'J. V. G.') and 'Juan Garcia Lopez y'. -name_regex = "^(?:Carod y de Rovira i|Fritz Freiherr und von Bar|Garcia y Lopez|John e Smith|John e Smith III|John e Smith, III|John y Jane|Jose e Maria|Juan de y|Juan y Garcia|Lt\\.Gov\\. juan e garcia|Smith, John e, III, Jr|john e jones, III|johnny y|juan y garcia)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(initials-per-word) a connective run initials each word (facade, since 2.0.0)" -# 'Juan y Eva Garcia', 'Dean of Chemistry Robert Johns', 'John & Jane': -# 1.4.0 built initials one group per *_list ELEMENT, and a run its -# parse joined into one element ('Velasquez y Garcia') gave one -# initial per word joined by the separator with NO delimiter -- -# 'V G.'. The 2.0 facade initials each WORD of an element, 'V. G.'. -# The parse is identical; this is the facade's rendering, changed in -# 2.0.0 and recorded first in 2.3.0's release log (decisions.md, -# "the initials view"). Strip periods and spaces and every name here -# agreed with 1.4.0 letter for letter until 2026-09-20. -# -# 2026-09-20, #461: THAT SENTENCE NO LONGER HOLDS FOR EVERY NAME, and -# the ones it stopped holding for have TWO causes in one diff. A -# connective with a name word beside it now contributes no initial at -# all, so the group it used to render per-word is simply gone: 'John -# and Jane Smith' goes 'J a J. S.' at the wheel to 'J. J. S.' in the -# tree, where the grouping half is 'J a J. S.' -> 'J. a. J. S.' and -# #461's half is the drop of the 'a'. Measured against the parent -# 46651750 on 2026-09-20, the names this rule explains where BOTH -# halves are now in the diff are: 'Assoc Dean of Chemistry Robert -# Johns', 'Dean of Chemistry', 'Dean of Chemistry Robert Johns', -# 'Duke of Edinburgh', 'Duke of Wellington', 'John & Jane', 'John and -# Jane Aznar y Lopez', 'John and Jane Smith', 'John of the Doe', 'Jose -# e Maria Santos', 'Juan & Garcia', 'Juan and Garcia', 'Juan y Eva -# Garcia', 'Mr. and Mrs. John and Jane Smith', 'Rob And Beth Edmunds', -# 'and Jon Dough', 'the and Jon Dough' and the two 'part1 of ...' -# strings. Every one of them ALREADY diffed here before this bundle, -# and on this rule's own sentence, which is why they stay on it rather -# than moving to the fix(#461) rule above -- that rule is for the -# names whose 1.4.0 diff this change CREATED. Both halves are stated -# rather than one absorbing the other, the treatment fix(#462) and -# fix(#383/#479) already get in these files. decisions.md#R3. -# -# CASE-SENSITIVE, the whole alternation, deliberately -- and it is the -# SINGLE LETTERS the case decides, because a capital `E`/`Y` is an -# initial and so is a dotted `E.`/`Y.`/`e.`/`y.`. The dotted spellings -# are kept out by the PERIOD rather than by the case: the alternation -# wants whitespace after the letter, so 'john e. smith' and -# 'scott e. werner' cannot match whatever their case. The bare capital -# is kept out by the case alone, and that is the half worth keeping. -# The #462 names ('Amy E Maid', 'Jose E Maria Santos') AGREE with -# 1.4.0 since the facade fix and diff only against the 2.x baselines, -# where fix(#462) claims them. What the two exclusions buy is -# unconditional and outlives that fix: any future facade initials -# change touching these shapes must report UNEXPLAINED here rather -# than be absorbed as per-word grouping. -# `and`/`And` because both spellings are corpus names and 'Rob And -# Beth Edmunds' carries no lowercase alternation word to be reached -# by. `of`/`the` have no capital variant because no name in this -# cluster needs one -- a corpus fact and not a shape claim: a capital -# `Of` occurs nowhere in the corpora, and the two cluster names -# carrying a capital `The` ('part1 of The part2 ...' and its `And` -# twin) are already reached through their own lowercase `of`/`the`. -name_regex = "(?:^|\\s)(?:y|e|&|and|And|of|the)(?=\\s)" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(initials-per-word) a bound-given run initials each word (facade, since 2.0.0)" -# 'Abdul Salam Hassan, MD', 'Abu Bakar Salim', 'Berg, abdul van': the -# bound-given join makes one list element of the pair, and 1.4.0 -# initialed the element as one group ('A S. H.'); the 2.0 facade -# initials each word ('A. S. H.'). Same change as the rule above, on -# the BOUND_GIVEN_NAMES words the corpora carry. Written before the -# particle rule below DEFENSIVELY, not to win a live contest: nothing -# is contested yet. Two corpus names carry both a bound word and a -# particle chain -- 'Sir abdul van der Berg', which does not diff at -# all, and 'van der Berg, abdul née Jones', whose diff is ROLES and so -# never reaches the pseudo-field -- and the one name this rule shares -# with the Ph. D. rule ('abdul Ph. D. Smith Berg') moves roles too. -# _CROSS_RULE_WINNERS' comment lists these three among the FIVE names -# that two per-word rules both reach, and says why none of them is -# pinned there (its rows under this rule are other names). So the -# position decides nothing today; it is the narrower reading -- -# measured, 41 corpus names against the particle chain's 107 -- -# which is this file's -# write-the-narrower-rule-first convention (the header above, and -# #382's open question about separating such a pair by a predicate -# instead), and it is what would decide a future contest. -name_regex = "(?i)(?:^|[\\s,])(?:abdul|abu)\\s" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(initials-per-word) a particle chain inside a name part initials each word (facade, since 2.0.0)" -# 'John van der Berg Smith', 'pennie von bergen wessels', 'de la Cruz -# Juan Carlos': a particle chain that lands INSIDE a part -- a family -# of 'van der Berg Smith' or of 'von bergen wessels' -- is one -# list element, and 1.4.0 initialed it as one group ('J. B S.'); the -# 2.0 facade gives 'J. B. S.'. The shape is a chain-opening particle -# followed by at least two more words, so the particle cannot be the -# family's own tussenvoegsel standing before a single family word -# (those do not move: the particles contribute nothing either way). -# The chain lands in a MIDDLE name only where something later bounds -# the part: one of the eleven names this rule explains does, 'Jane van -# der Berg née Jr Jones', whose maiden marker ends the middle at 'Jr'. -# -# The chain's SECOND word is not spelled out. It was, as an optional -# `(?:\\s+(?:der|la))?` before the two-word tail, and that group was -# provably inert: X(?:A)?Y accepts XY either way, and requiring 'der' -# there only asks for a THIRD word after the particle, which is a -# subset of what the tail already demands. Measured, both spellings -# reach the same 107 corpus names with the same digest. It came out -# because it also put a second alternation in the rule, and -# _LATIN_ALTERNATION_SOURCES keys one vocabulary per rule -- so the -# roster could pin `van|von|de` or `der|la`, never both. -name_regex = "(?i)(?:^|\\s)(?:van|von|de)\\s+\\S+\\s+\\S+" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(initials-per-word) the Ph. D. merge initials each word (facade, since 2.0.0)" -# 'Ph. D., John' and 'Sir Ph. D. Van Johnson': the merged credential -# is one list element ('Ph. D.'), and the two facades group it -# differently -- 1.4.0 'J. P. D.', the tree 'J. P D.'; and in the -# titled name the element 'Van Johnson' went from 'V J.' to 'V. J.'. -# Same per-word change, on the phd-merge shape (decisions.md#phd-merge). -# Literal, no alternation. 'Ph. D., Jr.' also matches, and is harmless -# in both directions: its initials do not move ('D.' at 1.4.0 and -# 'D.' here), and its diff is ROLES anyway -- fix(comma-precomma-family) -# claims it on `first`/`last` -- so `_initials` never enters it and -# this rule can neither explain nor absorb it. -name_regex = "(?i)\\bph\\. d\\." -fields = ["_initials"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# THE 2.3 TITLE-RUN BUNDLE: #489 (a title run addresses by its last -# title), #316 (a trailing period-marked title word reads as a -# title), the leading peel's name-word floor, and the `esq` acronym -# drop that shipped beside them. Five rules, four of them one per -# ARGUMENT rather -# than one per name, plus a fifth that is #316's argument again on -# the one name a script-classified alternation cannot hold. Over -# twenty-four corpus names. -# -# LAST in the file, and that is narrow-first rather than a -# preference: every rule already here that REACHES one of the -# twenty-four declares `fields` that are a strict subset of the bundle -# rule claiming that name, and a wide rule sitting AHEAD of a -# narrower one it shares a corpus name with is an order-decided -# contest the run refuses (#382). Measured 2026-09-08, all four -# ledgers: at 1.4.0 fix(#296)'s trailing `dr` reaches 'John Smith -# Dr.' and 'John Smith Prof. Dr.', the two comma routings reach -# 'Smith, John Prof.', and the two-token `jr` rule reaches 'Dr Jr' -# (and reached 'Sir Jr' until that row left corpus_rules.jsonl on -# 2026-09-09); at 2.0.0 and 2.1.0 only fix(#296) reaches any of -# them; at 2.2.0 none does. Not one of those rules ADMITS the diff -# it reaches -- every shape is outside its field list, measured -- -# so nothing below takes a name off a rule that was explaining it, -# and no [[change.precedes_narrower]] block is needed anywhere. -# -# `_initials` appears in none of the four field lists although the -# initials of several of the twenty-four do move. The derived view -# enters a diff only where every ROLE and every ambiguity kind -# agrees (compare.py's _RULE_FIELDS, main()'s roles-identical -# guard), and all twenty-four move roles -- so no run can produce a -# shape here carrying it, and validate_rules refuses it beside -# another field in any case. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#489) a title run addresses by its last title" -# 'Her Majesty Queen Elizabeth' and 'Dr. Sir John': the -# given_name_titles lookup was keyed on the WHOLE run, and 'her -# majesty queen' is in no vocabulary and never could be, so the one -# name word behind the run stayed the family. Both sites that ask -- -# rules.md#H1's retag and rules.md#P5's licence -- now test the whole -# run's key OR the last word's, and 'queen' and 'sir' are -# GIVEN_NAME_TITLES entries. The name word behind the run becomes the -# GIVEN name and the family empties, which is H1's Accepted outcome. -# Both roles move together and `fields` declares both. `title` is -# deliberately OUT: the run itself is unchanged, only what it -# addresses as, so a name whose TITLE moved arrives UNEXPLAINED, as -# it should. -# -# 'Reverend Mother Teresa', 'Mr Sir John', 'Xyz. Sir John' and 'Sir -# Sheikh abdul rahman' move the same way and are NOT members: none of -# them is in any corpus file, so a member for one would be a rule -# reaching nothing. -# -# An anchored alternation of the two NAMES rather than a shape. What -# selects them is a SHAPE -- a multi-word title run whose LAST word -# is a given-name title -- and a member copying GIVEN_NAME_TITLES -# would reach 'Sir John Smith' and 'Dr. Smith', which do not move. -# The members are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 2 with its digest, and -# _MUST_NOT_MATCH carries three boundaries: a ONE-word run ('Sir John -# Smith'), and two runs whose last word is title vocabulary that is -# not given-name vocabulary ('His Excellency Lord Duncan', 'Her Royal -# Highness Princess Anne' -- the open vocabulary question, #519). One -# set, identical in all four ledgers. -name_regex = "^(?:Dr\\. Sir John|Her Majesty Queen Elizabeth)$" -fields = ["family", "given"] - -[[change]] -issue = "change(suffix-acronym-collisions) esq leaves the acronym set" -# 'John Smith E.S.Q.': 'esq' left SUFFIX_ACRONYMS, where the 2019 bulk -# post-nominal import put it (af5bdab, #93) -- esquire is a -# contraction, not an initialism -- so the multi-dot spelling stops -# being credential vocabulary and falls to the positional read: given -# 'John', middle 'Smith', family 'E.S.Q.', where every release since -# 1.4.0 read family 'Smith', suffix 'E.S.Q.'. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: this is a deliberate parity break at all four baselines, and -# the entry that argues it is decisions.md#suffix-acronym-collisions -# -- there is no issue number to cite, and the release-log bullet -# carries none either. It is not NOT WANTED: that spelling is for a -# reading nobody wants, and this reading is the one the decision -# chose. -# -# 2026-09-18 (#516): this rule stopped explaining anything, and the -# reason is not a revert. The by-shape credential class reads a word -# of two or more period-separated chunks that no vocabulary claims by -# POSITION (rules.md#S3), so 'John Smith E.S.Q.' is a credential again -# with no wordlist entry behind it at all -- the reading this baseline -# has, recovered by a different route than the acronym entry gave it. -# The vocabulary decision the `change` tag records still stands and is -# still the history of that entry; what is gone is its diff AT THIS -# BASELINE, where `_ambiguities` is not a compared surface at all. -# Above 2.0 the name still diffs, on the `suffix-or-name` report the -# positional reading brings with it, and fix(#516) classifies that -- -# each 2.x copy's own `dormant` string says so. Declared -# `dormant` rather than deleted, so the harness says so out loud if a -# future change takes the positional reading away again. -# -# Literal, no alternation, and exactly as wide as the diff: 'esq' -# keeps its SUFFIX_WORDS membership, so the acronym entry's only -# unique coverage was this one spelling and this one corpus name. -# _MUST_NOT_MATCH carries the three spellings that did NOT move -- -# 'John Smith Esq' (the word list still reads it), 'Esq. Smith' -# (rules.md#H2's leading inference) and 'Smith, Esq.' (the lone -# post-comma credential). -dormant = "'John Smith E.S.Q.' no longer diffs at this baseline: #516's by-shape credential class reads the multi-dot spelling as a credential by position, with no wordlist entry, which is the reading this baseline already had. Kept because the vocabulary change it records is real history and the rule wakes if that reading is lost again" -name_regex = "^John Smith E\\.S\\.Q\\.$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#489) the title peel leaves a name word a suffix cannot be" -# 'Dr King Jr', 'Dr. King MD', 'Dr Jr' and 'Sir Jr': the leading title -# peel's only floor was "leave one piece", so it peeled 'Dr King' -# whole and left the post-nominal to be the name -- title 'Dr King', -# family 'Jr', no suffix at all. leading_titles now also refuses to -# leave a word that is nothing but suffix vocabulary standing as the -# name and gives the run's last word back (rules.md#H3, -# decisions.md#H3), so 'Dr King Jr' reads title 'Dr', family 'King', -# suffix 'Jr' -- what the comma spelling 'King, Dr Jr' always read. -# -# FOUR roles, and no single name moves all four. The two three-word -# names move `title`, `family` and `suffix`; 'Dr Jr' and 'Sir Jr' -# move `given` as well, or instead of `family`, the word given back -# standing alone with the credential peeled off it. At the 2.x -# baselines `_ambiguities` joins the list -- with the title given -# back there is no title left to make the reading H1's, so all four -# report title-or-name through rules.md#H4 -- and it is absent at -# 1.4.0, which has no ambiguity surface to compare (compare.py's -# _RULE_FIELDS). -# -# An anchored alternation of the four NAMES rather than a shape. What -# selects them is a SHAPE -- a title run with nothing but post-nominal -# pieces behind it, whose own last word is not one of those -- and a -# member copying TITLES or SUFFIX_WORDS would reach 'MD DDS', 'Jr. -# Ph. D.' and 'Marquess of Bath', every one of which the floor -# declines and every one of which is a _MUST_NOT_MATCH probe, with -# 'Dr Smith Jr' beside them for the ordinary titled name the floor -# never sees. The members are a list of names and copy no wordlist, -# which is what _NOT_A_VOCABULARY_COPY records. THREE of the four are -# corpus names, not four: 'Sir Jr' left corpus_rules.jsonl on -# 2026-09-09, when the rules.md example pinning it was dropped as a -# duplicate of 'Dr Jr' (decisions.md#H5), and it stays in the -# alternation because a name_regex names INPUTS rather than corpus -# rows. _CORPUS_CLAIMS pins the reach at 3 with its digest. One set, -# identical in all four ledgers; only the `fields` differ, as the -# paragraph above says. -name_regex = "^(?:Dr Jr|Dr King Jr|Dr\\. King MD|Sir Jr)$" -fields = ["family", "given", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing period-marked title word reads as a title" -# 'John Smith Prof.' and thirteen more: a trailing run of period-marked -# LISTED title words is peeled as a title, so the surname behind it -# stops being lost -- title 'Prof.', given 'John', family 'Smith', -# where every baseline read middle 'Smith', family 'Prof.'. A RUN, -# mirroring the leading peel: 'John Smith Prof. Dr.' gives title -# 'Prof. Dr.' and 'Dr. John Smith Prof.' gives 'Dr. Prof.' in input -# order. The family-comma segment takes the same walk, so 'Smith, -# John Prof.' agrees with the bare spelling at last. And the trailing -# title is TRANSPARENT to the post-nominal peel that follows it: -# 'John Smith Jr. Prof.' keeps suffix 'Jr.' rather than promoting the -# generational suffix to the family name, and 'John Prof. MA' reads -# family 'MA' by rules.md#S2's reserve. -# -# 2026-09-18 (#289): the last clause is retracted. S2's reserve is no -# longer what decides 'John Prof. MA': written capitals in a -# mixed-case name lean credential with nothing to spare, so the name -# reads title 'Prof.', family 'John', suffix 'MA' -- the transparency -# this rule is about is unchanged and now shows a DIFFERENT reading -# through it, which is the point of transparency. This rule keeps the -# name at every baseline where it already had it; the fix(#289) rule -# at the bottom of each file carries the names whose reading moved and -# that nothing else claims. -# -# Five roles, the union of what the sixteen move; no name moves all -# five (four is the most, 'John Smith Jr. Prof.'), and two move only -# two at every baseline -- 'Smith, John Prof.' {title, middle} and -# 'Smith Sir.' {title, family} -- with 'Mary Jane King.' moving three. -# A third joins them at 1.4.0, 2.0.0 and 2.1.0, 'John Smith Dr.' -# {title, suffix}, which moves {title, middle, family} at 2.2.0 -# instead: 'dr' was suffix vocabulary until 2.2, so the role it -# vacates differs by baseline (measured 2026-09-09 against all four -# wheels). `_ambiguities` is not among the five, and it is not -# because no name reports one: 'John Prof. MA' reports -# `suffix-or-name`, which is S2's bare-acronym reserve and not this -# rule. None of the sixteen reports `title-or-name` -- the walk -# leaves a title standing, so H4's title half never fires -- and the -# derived view cannot enter a diff here in any case, every one of the -# sixteen moving roles (compare.py's _RULE_FIELDS). -# -# TWO of the sixteen end in ' Dr.' and are also reached by -# fix(#296)'s trailing-`dr` rule above -- 'John Smith Dr.', which -# that rule used to explain, and 'John Smith Prof. Dr.'. That rule -# declares {middle, family, suffix} and neither diff fits it now, so -# both fall through to here; its comment records the handover, and -# narrow-first placement is what keeps the pair out of contest. The -# BARE trailing spelling stays with fix(#296): 'Smith Dr' and the 'dr -# ... dr' names wear no abbreviation period and the walk never sees -# them (#316(b), left open). -# -# The last two members arrived with the 2026-09-09 review of the -# docs commit, which added two rules.md examples: 'Mary Jane King.', -# the H5 Accepted clause's collision example (title 'King.', given -# 'Mary', family 'Jane', where every baseline read family 'King.' -# behind middle 'Jane'), and 'Smith Sir.', which is an H1 example. -# 'Smith Sir.' is HERE and not on the fix(#489) run rule above -# because the DIFF decides: it moves {title, family}, the walk -# taking 'Sir.' out of the name, while its given/family reading is -# what every baseline already gave ('Smith' stays the given name on -# both sides). The #489 rule declares {family, given} and does not -# reach a title role at all, so it could not claim the diff even -# placed ahead of this one. -# -# An anchored alternation of the sixteen NAMES rather than a shape. -# What selects them is a SHAPE -- period-marked, listed, trailing -- -# and a member copying TITLES would reach the BARE 'Mary Jane King' -# and 'John Smith Sir', which do not move; the period-marked -# 'Mary Jane King.' is a member precisely because it does, which is -# the line the period draws and the whole of what it draws. A member -# spelled as the shape (a trailing dotted word) would reach 'John -# Smith Xyz.', which is the boundary the leading slot's H2 inference -# is deliberately NOT given here. Those three and 'Smith, Prof.' -- -# the comma shape whose segment holds no name word and which reads -# by a different gate -- are the _MUST_NOT_MATCH probes. The members -# are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY records; _CORPUS_CLAIMS pins the reach at -# 14 with its digest. -# -# 'Andrew Perkins (Mgr.)' is spelled with \\x28 and \\x29 rather than -# the \\( and \\) the paren-bearing rules above use. Escaped or not, a -# literal parenthesis inside an alternation body hides the WHOLE -# group from test_ledger_guards._alternations -- its member pattern -# excludes '(' and ')' -- and a sixteen-member alternation should not -# be invisible to the discovery pass that demands every alternation -# declare what it copies. The two spellings match the same string. -# One set, identical in all four ledgers. -# -# 2026-09-18 (#289/#516): a SEVENTEENTH member, 'JOHN PROF. MA'. It -# arrived in the rules corpus as rules.md#H5's one-case witness that -# the suffix reading is taken over what STANDS once the title chain -# has run, not over what stood -- the reading the mixed-case spelling -# stopped demonstrating when the caps lean started deciding it. The -# name is wholly one case, so no lean fires and nothing about it is -# this arc's: what moves is exactly this rule's trailing-title chain -# ({title, family, suffix}, inside the five roles already declared), -# and it stops diffing at 2.3.0 for the same reason its sixteen -# siblings do. -name_regex = "^(?:Andrew Perkins \\x28Mgr\\.\\x29|Dr\\. John Smith Prof\\.|Dr\\. Smith Sir\\.|JOHN PROF\\. MA|John Prof\\. MA|John Smith Dr\\.|John Smith Jr\\. Prof\\.|John Smith Mr\\.|John Smith Prof\\.|John Smith Prof\\. Dr\\.|John Smith Prof\\. Jr\\.|John Smith Rev\\.|Mary Jane King\\.|Sir John Prof\\.|Smith Prof\\.|Smith Sir\\.|Smith, John Prof\\.)$" -fields = ["family", "given", "middle", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing Latin title on a native-script name is a title" -# '毛 泽东 Dr.': the rule above's walk, on the one corpus name it -# reaches that is not Latin-only. The walk runs BEFORE the positional -# read, so the pieces the script test sees are all Han and the -# family-first order stands -- this is '毛 泽东' plus a title, and -# it reads title 'Dr.', given '泽东', family '毛'. A Latin title at -# the back of a wholly-Han name is the one piece that would make the -# piece set look mixed-script, which is what the ordering of the two -# buys. -# -# ITS OWN rule rather than a seventeenth alternative above, and not by -# preference: an UNDECLARED alternation holding a script-classified -# member is claimed by the honorific pin in -# tests/v2/test_ledger_guards.py, which requires such an -# alternation's members to BE the config's CJK honorific entries -- -# and until #322/#323 taught that pin to ask whether the members FOLD -# into that vocabulary, that was every such alternation. So this -# stands alone, literal-anchored, -# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) -# with _MUST_NOT_MATCH probes for the boundary -- the same shape the -# four #436/#437 CJK rules above take, and for the same reason. -# -# Classified 2026-09-09, in the review round that fixed which title -# run H1 addresses by; the reading itself is untouched by that fix. -# The name is RADAR tier (corpus_cjk_tolerated.jsonl, tolerated since -# the 2026-09-01 CJK demotion) and it had been printing UNCLASSIFIED -# at all four gates since the trailing walk landed. Radar never -# blocks, so nothing failed -- and this bundle's convention is that -# every gate line reads `radar unclassified: 0`, which is the whole -# reason the row is here rather than left to print. -# -# FOUR roles here, and NOT the same four at every baseline, which is -# why this is the one bundle rule whose `fields` differ per ledger. -# This baseline read first '毛', last '泽东', suffix 'Dr.' -- the -# Latin word taken as a post-nominal and the two Han words read -# given-first -- so `title`, `given`, `family` and `suffix` all move. -# -# Literal and anchored, so the reach cannot widen into the other -# forty-six tolerated CJK names (twenty-nine until this bundle's -# seventeen rows joined the file). _MUST_NOT_MATCH carries three of -# them as probes: '王先生, V.' (a Latin post-nominal behind a -# comma, which the trailing walk never sees), '田中さん, Dr.' (the -# comma spelling of this very shape, routed by the segment gate) and -# '田中さん II' (a spaced Latin post-nominal that is not title -# vocabulary). -name_regex = "^毛 泽东 Dr\\.$" -fields = ["family", "given", "suffix", "title"] - -# #322/#323, LAST in the file. The parser knew ONE full stop -- -# the ASCII period -- and knew it at ONE site, the lexicon's edge -# strip. It now reads four: the ASCII period, the fullwidth '.', -# the ideographic '。' and its halfwidth '。'. A full stop glued -# AFTER a script-written word is punctuation and not part of the -# word -- invisible to the script reading and to the vocabulary, -# and left in the text on the word it arrived with -- because no -# East Asian script writes an initial or an abbreviation with a -# period. A leading stop folds away for the vocabulary lookup, -# which reads both edges; the classification fold that feeds the -# surname site reads the TRAILING edge only, so a word wearing a -# leading stop is given no script and never becomes a surname -# site: '.김민준' stays one whole word, given. The honorific peel -# is not gated by that fold -- the tail alone licenses it -- so a -# leading stop does not stop it: '.김민준씨' peels to '.김민준' -# and '씨' (rules.md's W Background, decisions.md#cjk-full-stops). -# -# The eleven names here are the ones whose diff carries a `title` -# or a `suffix` move -- nine CJK, the katakana 'マイケル.' (a #323 -# mover added 2026-09-10 in the whole-branch review, its diff being -# title-or-given), and the bracketed '(김민준.) John Smith', added -# in the review round after that, whose diff carries the `title` -# the unwrapped clause used to take. SIX MORE of the bundle's -# seventeen movers are absent on purpose, and for one reason in two -# shapes. '김. 민준', '양 지훈.' and '양. 지훈' move within {given, -# middle, family} at this baseline -- {family, given}, as it -# happens -- which the native-script CJK rule above already -# declares. The three stop-bearing FAMILY_COMMA rows -# the same review round added are the other shape: '田中さん., V.' and '이, J.씨.' -# are claimed here by the broad 'fix(cjk-comma-compound)' and -# 'fix(cjk-comma-honorific-peel)' rules above, and '김민준씨., J.씨' -# produces no diff at this baseline at all. Adding -# any of the six would only take a name off a rule that describes -# it, or claim a diff that does not exist. -# -# LAST is narrow-first (#382), not taste. Two earlier rules reach -# these names by regex: 'fix(#271/#272/#298) native-script CJK' -# reaches all eleven (its script-range scope catches the katakana -# mover, and the bracketed row's CJK clause) and 'fix(cjk-glued-honorific-peel)' reaches the two -# glued spellings ('田中さん.', '김민준씨.'). This rule's five -# roles are a STRICT SUPERSET of the three each of those declares, -# so both are the narrower rule and both belong ahead of it; -# order_contests sees no pair at all, a contest needing the LATER -# rule's fields to nest inside the earlier one's. -# -# An anchored alternation of the NAMES rather than a shape, and the -# two criteria a shape could be written on both reach names this -# rule must not claim. "An edge full stop on a word carrying a -# classified character" reaches 22 corpus names, measured -# 2026-09-10: the bundle's seventeen movers and FIVE that do not -# move ('田中さん 様.', '田中さん, 様.', '김민준 씨.', '김민준 양.', -# '김민준, 씨.'). Measured with the TOKENIZER and not a whitespace -# split -- two of the movers wear their stop before a comma -# ('김민준씨.,', '田中さん.,'), which a whitespace split does not -# see -- plus the bracketed '(김민준.) John Smith' by hand, its stop -# sitting inside the clause until rules.md#S1 unwraps it; an 18 -# recorded earlier the same day came from the split, over a corpus -# four names smaller. A character class of the four stops would be a hand -# copy of nameparser._lexicon.FULL_STOPS besides, and would reach -# the looser population of "an ASCII period anywhere in a CJK- -# bearing name" -- 20 distinct names on 21 corpus rows before this -# bundle ('Dr 김민준씨, Jr.' being the name on two of them), wider -# because it sweeps in periods sitting on LATIN tokens inside a CJK -# name ('毛 泽东 Dr.', '田中さん, V.'). None of the five or the -# twenty is moved BY THIS BUNDLE, so a shape spelling would -# reach names whose diffs belong to other rules (most of them -# do diff against the older baselines -- 19 of the twenty at -# 1.4.0) or to no rule at all; decisions.md#cjk-full-stops -# carries both counts and the recompute. The members copy no wordlist, -# which the honorific pin in tests/v2/test_ledger_guards.py DERIVES -# rather than taking on trust -- no member of this alternation folds -# into the CJK honorific vocabulary, bare or wearing a full stop; -# _CORPUS_CLAIMS pins the reach at 11 with its digest, and -# _MUST_NOT_MATCH pins the four readings the bundle left alone. -[[change]] -issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" -name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.)$" -fields = ["family", "given", "middle", "suffix", "title"] - -# --------------------------------------------------------------- -# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. -# One rule, and the count is the finding. Seventeen corpus names are -# written wholly in one case AND carry a cased single-letter -# connective -- the whole population this change can reach, measured -# 2026-09-13 over the 1174-name corpus by folding every one-letter -# token of every one-case name against CONJUNCTIONS -- and exactly -# two of them move a ROLE BECAUSE OF THIS CHANGE. Of the fifteen -# others, thirteen move no role at all and the Cyrillic pair move -# theirs for #269's reason -- vocabulary that has decided their 'и' -# since 2.0, nothing to do with the fork -- so four names in the -# population move a role against this baseline and only two of the -# four are this rule's. The other two are classified by -# `feat(#269) non-Latin titles/conjunctions recognized` above, whose -# reach the population grew by exactly those two. -# The report the change also emits is a v2 -# surface that does not exist below 2.0, while the derived view moved -# only in the CORE, which is not compared here either (the note in -# the `_initials` block above). So this is the whole of 2.4's -# #383/#479 blast radius at 1.4.0. -# -# LAST in the file: every diff it claims reported UNEXPLAINED on the -# run that preceded it (measured 2026-09-13), so no rule above -# already claims one, and nothing below can be shadowed by a literal -# alternation of two names. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#383/#479) a single-letter connective joins only on case evidence" -# 'jose e maria santos' and 'JUAN GARCIA Y LOPEZ', both rules.md#P3 -# examples. A name written wholly in one case carries no case -# evidence about any letter in it, so the reading comes from the -# vocabulary: 'e' is marked as reading both ways and reads as an -# initial, 'y' is not and joins. Both directions are v1 parity BREAKS -# and both are deliberate -- 1.4.0 gives 'jose e maria santos' first -# 'jose e maria' and 'JUAN GARCIA Y LOPEZ' middle 'GARCIA Y' -# (measured from the 1.4.0 wheel, 2026-09-13). See rules.md#P3 and -# the 2026-09-13 entry of decisions.md#P3. -# -# The two names move DISJOINT role pairs and the declaration is their -# union: 'jose e maria santos' moves `given` and `middle` (the joined -# run splits, and what is past the letter becomes the middle name), -# 'JUAN GARCIA Y LOPEZ' moves `middle` and `family` (the letter now -# joins, so the middle empties into the family). `#452`'s -# over-declaration check recomputes that union on every run, so a -# third role here would fail rather than stand as a claim on a future -# diff. -# -# Literal-anchored to the two names: the class is wide (every one-case -# name with a cased single-letter connective) but the corpora carry -# only these two at a role-moving shape, and a regex for the class -# would claim the fifteen OTHER names of that population (the section -# comment above counts them) whose roles this change does not move -# -- including 'juan garcia y lopez', which already joined, and -# 'JUAN Y GARCIA', whose three-word carve-out keeps the letter a name -# word in both directions. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the probes, -# every one of them a MIXED-case spelling: mixed case is where the -# writing decides, nothing moved there at all, and a rule reaching one -# would be absorbing a regression in the half of rules.md#P3 this -# change did not touch. -name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" -fields = ["given", "middle", "family"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289) a written case contrast decides a bare ambiguous acronym" -# A member of suffix_acronyms_ambiguous written in CAPITALS inside a -# mixed-case name is written the way a credential is written, and is -# taken as one with nothing to spare ('Jack MA' -> given 'Jack', -# suffix 'MA'); one written in any other cased form is written the way -# a surname is written and stays one even with words to spare ('John -# Smith Ma' -> middle 'Smith', family 'Ma'). The same lean reaches the -# post-comma given slot ('Smith, MA' -> family 'Smith', suffix 'MA'; -# 'Smith, Ma' keeps its given), and the comma form's words-to-spare -# test becomes a count of NAME words, which is case-blind -- so -# 'John Smith, MA', 'John Smith, Ed', 'JOHN SMITH, MA' and -# 'john smith, ma' all read the credential where the token count read -# a given name. See rules.md#S2, rules.md#C1 and rules.md#P2. -# -# A name written wholly in one case carries no contrast and does not -# move on the LEAN at all -- 'JOHN SMITH MA', 'ANH DO', 'anh van do' -# and 'Jack Ma' are _MUST_NOT_MATCH probes in -# tests/v2/test_ledger_guards.py. The one-case comma forms in the list -# above are here on the NAME-word count instead, which asks nothing -# about case; that is why they sit beside the lean's own names rather -# than in a rule of their own. -# -# Accepted costs, both recorded rather than repaired: the walk stops -# at a declined pick instead of continuing past it, so a genuine -# suffix standing in front of a name-leaning acronym is never reached -# ('abdul Smith Jr Ma' reads middle 'Jr'), and the lean ends P2's -# particle chain with no words to spare where the count would not -# ('Freiherr von Berg MA', whose own rule further up this file carries -# the reading at 1.4.0). -# -# The Title-case half is witnessed at the comma by two minimal pairs -# rather than argued: 'John Smith, MA'/'John Smith, Ma' both read the -# credential, because two NAME words before the comma decide before -# the case is looked at, and 'Smith Jr., MA'/'Smith Jr., Ma' split, -# because one name word leaves the case to decide and Title-case -# declines. 'Smith Jr., Ma' diffs here for a reading this change did -# NOT move -- the pre-comma restructure v2 has done since 2.0, at -# 1.4.0, and the fork's new report at the 2.x baselines -- while -# 'John Smith, Ma' moves on the COUNT, as its all-caps twin does, and -# has no 1.4.0 diff at all, v1 having read the credential there too. -# Both sit on this rule because the rule is what states which way -# each member of a pair goes. -# -# Literal-anchored to the measured movers. The four 2.x ledgers share -# ONE set; this copy is that set less FOUR names, for the reasons the -# paragraphs below give. "One set for all five" was written before -# the first carve-out existed and is corrected in all five copies -# (2026-09-18). The set is literal because the class is -# wide (every mixed-case name whose trailing piece is a listed -# ambiguous acronym, plus every comma form with two name words before -# the comma) and a regex for the class would claim the one-case -# spellings that do not move. A name here that does not diff at a -# given baseline is simply one the reading already agreed with there. -# `fields` is per-baseline, the union the run at THAT baseline -# measures (#452). -# -# One name is held OUT of the set here and only here: 'John Prof. MA' -# is claimed at this baseline by `fix(#316) a trailing period-marked -# title word reads as a title`, whose own comment carries the dated -# retraction of what the name used to demonstrate. That rule declares -# a `title` role this one does not, describes the whole of the diff -# rather than the acronym's half of it, and stands first -- so the -# name is narrowed out rather than left to be decided by file order, -# which the harness refuses to let go undeclared. -# -# THREE MORE are held out here and only here, added to the 2.x copies -# on 2026-09-19: 'Doe, MA', 'Doe, MA PhD' and 'Doe, Dr. MA', the lone -# post-comma credentials #531's control rows brought into the corpora. -# They diff at this baseline too -- v1 read first 'MA', last 'Doe' -- -# but that diff is already claimed here by `fix(comma-family) lone -# post-comma piece routes to suffix/title, not first`, which stands -# near the top of this file and whose {given, suffix, title} admits -# their {given, suffix}. Adding them to this rule would give one diff -# two rules and leave file order to arbitrate, which is the shape the -# harness refuses to let go undeclared; so the 2.x copies carry them -# and this one does not. -# 2026-09-27, #544: 'abdul Smith Jr Ma' leaves this alternation. The -# unambiguous 'Jr' in front of 'Ma' anchors it (rules.md#S2's company -# clause), the peel takes both, and P5's reserve declines the join, so -# the accepted cost this rule recorded for it -- middle 'Jr', family -# 'Ma' -- is gone; what the name still diffs on here is claimed by the -# rule that explains it, and a literal left in this one would stand -# ready to explain that cost coming back. -name_regex = "^(?:Davis Royce, Ed|Freiherr von Berg MA|JOHN SMITH, MA|Jack MA|Jack MA\\.|Jack Wei Ma|John Smith Ma|John Smith, Ed|John Smith, MA|John Smith, Ma|John de Ma|John van der Berg Ma|Smith Jr\\., MA|Smith Jr\\., Ma|Smith, MA|abdul Smith Berg Ma|abdul Smith Ma|john smith, ma)$" -fields = ["family", "given", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#516) an unlisted dotted acronym is read by position" -# A token of two or more period-separated chunks that no vocabulary -# claims joins the ambiguous credential class by SHAPE and is read by -# POSITION, exactly as a bare listed acronym is: a credential where -# the name has words to spare, a name word where it does not, either -# reading reported (rules.md#S3). Case says nothing here -- the -# periods are the evidence -- which is why 'john smith x.y.z.' moves -# with 'John Smith X.Y.Z.'. The same admission reaches the comma form, -# where the NAME-word count decides ('John Smith, X.Y.Z.' -> suffix -# 'X.Y.Z.'; 'Smith, A.B.' keeps its given and only reports). Paired -# initials ('John Smith, A.B.') are the exception to that count and -# have their own rule below (#563); 'García Márquez, G.J.R.' is the -# exception's accepted boundary, three letters reading by the count. -# -# Two names here restore a reading a PRIOR bundle's vocabulary change -# took away, by a different route than it lost it: 'John Smith E.S.Q.' -# and 'John Smith C.H.A.' fell to a bare positional read when 'esq' -# and 'cha' left SUFFIX_ACRONYMS -# (decisions.md#suffix-acronym-collisions), and the shape class reads -# them as the credentials they always were with no wordlist entry. -# At the baselines where -# that restoration lands on the baseline's own reading, only the new -# report is left and `fields` says so. -# -# The narrow half of the same change: a chunk match no longer makes a -# dotted token a suffix where EVERY matched chunk is a single ASCII -# character, which reaches only the roman numerals -- 'Jack X.Y.I.' -# moves to family for that reason and not for the shape one. -# -# The shapes the vocabulary settles are untouched and are -# _MUST_NOT_MATCH probes in tests/v2/test_ledger_guards.py: a -# whole-token match ('Jack M.A.', 'John Smith Ph.D.', 'Smith, -# A.B.C.'), a surviving chunk claim ('Doe, John Msc.Ed.'), a leading -# dotted run ('X.Y.Z. Smith', 'J.R.R. Tolkien', 'A.B. Vajpayee'), the -# single trailing period that is no shape at all ('John Smith Xyz.'), -# and a digit chunk, which the alphabetic gate refuses ('Bridge (1.4)'). -# -# Literal-anchored to the measured movers, one set for all five -# ledgers, for the reason the fix(#289) rule above gives: the class is -# a SHAPE, and a regex for the shape would claim every dotted token -# the vocabulary already answers for. -name_regex = "^(?:García Márquez, G\\.J\\.R\\.|Jack X\\.Y\\.I\\.|John Smith B\\.Tech\\.|John Smith C\\.H\\.A\\.|John Smith E\\.S\\.Q\\.|John Smith Q\\.W\\.E\\.R\\.T\\.|John Smith X\\.Y\\.Z\\.|John Smith, X\\.Y\\.Z\\.|Smith, E\\.S\\.Q\\.|john smith x\\.y\\.z\\.)$" -fields = ["family", "given", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#563) paired initials after a comma read as the given name unless a word speaks for them" -# rules.md#C1: "Paired initials are the exception to the count" -- -# two words before the comma may be one surname ('García Márquez, -# G.J.'), so two dotted single letters alone after it stay the given -# name and report the fork, where #516 had read them as a credential. -# "Only an unambiguous suffix word in front of them that is not also -# title vocabulary, or another word the class admits by its dotted shape -# standing in the same part, makes them the credential run": 'John -# Smith, PhD X.Y.' and 'John Smith, X.Y. P.Q.' move to the run, while -# 'De La Cruz, M.J. PhD' keeps its initials, the degree standing behind -# them, and so does 'García Márquez, Ms G.J.', a title in front ("an -# unambiguous suffix word in front of them that is not also title -# vocabulary"), which moves nothing at any baseline. The second period -# is optional ('García Márquez, G.J'). Two pairs speaking only for each -# other make the run and report it behind two name words ('John Smith, -# X.Y. P.Q.'). 2026-10-01, #575: 'De La Cruz, M.J. K.L.' is no longer -# that case -- 'De La Cruz' is one name word, so the count no longer -# reaches it and it reads as 'Cruz, M.J. K.L.' does, given 'M.J.', -# suffix 'K.L.', reporting both; a listed member in front speaks for -# nothing ('García Márquez, Ed G.J.' keeps the family comma, and the -# fix(#531) rule claims what its given part's trailing slot then reads). -# Against a 2.x baseline the names whose roles match the baseline's move -# only the report, which a 1.4.0 comparison cannot see; that ledger -# lists the three role movers alone. -name_regex = "^(?:De La Cruz, M\\.J\\. K\\.L\\.|García Márquez, PhD G\\.J\\.|John Smith, PhD X\\.Y\\.|John Smith, X\\.Y\\. P\\.Q\\.)$" -fields = ["family", "given", "middle", "suffix", "title"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(given-part-trailing-slot) a credential acronym ending the given part of a family-comma listing reads as a middle name" -# 2026-09-19, #531: THE WIDENING THIS RULE'S OWN COMMENT PREDICTED HAS -# LANDED, AND THE RULE NEITHER RETIRED NOR NARROWED -- IT CHANGED -# NAMES. What the rule SAYS is still true, of a different set, and for -# a different reason, so read the two paragraphs below as replacing -# the retirement paragraph rather than amending it. -# -# 'Doe, John MA' is GONE from the regex: the emitter now reaches the -# trailing slot of the given part, so the tree reads suffix 'MA' -# exactly as the 1.4.0 wheel does and the name has no diff at this -# baseline at all (the report it gains is a v2 surface that cannot -# enter a diff below baseline 2.0, which is why the 2.x ledgers carry -# it and this one does not). -# -# What is here instead is the four names where the reading is STILL -# the middle name v1 read as a post-nominal -- and the mechanism is no -# longer the slot's silence. It is the WRITING: a member written in a -# cased form that is neither capitals nor wholly lower, in a name -# written in more than one case, is written the way a name is written -# and stays one (rules.md#S2, decisions.md#S2). 'Doe, John Ma', -# 'Doe, John Ed' and 'Doe, Mary Jo Ma' are Title-cased members in -# mixed-case names, and 'Doe, John MA Ma' is a two-member run whose -# LAST member is Title-cased, so the walk declines there and the whole -# run stays middle text. v1 took all four as suffixes, and the four -# keep the 2.0-era reading against it. Each of them now REPORTS the -# fork it declined, which is #531's doing and is a v2 surface, so it -# is invisible here and carried by the 2.x ledgers' own rule. -# -# The four names entered the corpora with #531's case rows -# (2026-09-18); the reading they carry is a 2.0-era one this PR did -# not move, exactly as 'Doe, John MA's was when this rule was written. -# -# The dotted sibling is still NOT here and must not be folded in: it -# now LEAVES 1.4.0's middle for the credential reading, which is the -# opposite direction, and has a rule of its own at the bottom of this -# file. -# -# Literal-anchored for the same reason the fix(#289) rule above gives: -# the class is a slot, and a regex for the slot would claim every -# family-comma name whose given part ends in an ambiguous acronym, -# including 'Doe, John MA' itself, which v1 now agrees with. -# _MUST_NOT_MATCH carries that name as a probe, beside -# 'Doe, John MA Smith', whose member is not at the trailing slot at -# all and whose diff has the comma-family rule below. -name_regex = "^(?:Doe, John Ed|Doe, John MA Ma|Doe, John Ma|Doe, Mary Jo Ma)$" -fields = ["middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) a dotted credential ending the given part leaves 1.4's middle name" -# The dotted half of #531, and the one place the change accepts a v1 -# parity BREAK rather than restoring one. 'Doe, John X.Y.Z.' reads -# middle 'X.Y.Z.' on the 1.4.0 wheel (measured) and middle at 2.0.0 -# through 2.3.0 too; the tree reads suffix 'X.Y.Z.'. -# -# Deliberate, and the reason is agreement rather than history: a -# multi-chunk dotted token no vocabulary claims joins the ambiguous -# credential class BY SHAPE and is read by POSITION (rules.md#S3, -# fix(#516)'s rule above), the comma-less 'John Doe X.Y.Z.' reads it -# as the credential, and at this slot the comma has already named the -# family so the words-to-spare count is satisfied by construction. -# The acronym half of #531 restores v1; this half departs from it, and -# decisions.md#S2 records the pair as one decision with two -# directions. -# -# `listed_lean` returns None wherever the by-shape tag rides, so no -# case evidence is consulted here at all -- which is why this name -# needs a rule of its own rather than sitting with the acronyms above -# or with the four the writing declines. -# -# Literal, one name. A regex for the dotted SHAPE would claim every -# dotted token the vocabulary already answers for, which is the reach -# fix(#516)'s own comment refuses; _MUST_NOT_MATCH carries the probes -# it names. -name_regex = "^(?:Doe, John X\\.Y\\.Z\\.|García Márquez, Ed G\\.J\\.)$" -fields = ["middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(comma-family) an interior credential acronym stays a middle-name word" -# THIS PR DID NOT MOVE THIS READING, and the rule exists only because -# the name entered the corpus here -- the shape the rule above is -# bounded by, arriving as #531's recorded negative control -# (2026-09-18). 'Doe, John MA Smith' reads middle 'Smith', suffix 'MA' -# on the 1.4.0 wheel and middle 'MA Smith' at 2.0.0 and every baseline -# since, this tree included. Measured case-independent: 'DOE, JOHN MA -# SMITH' and 'doe, john ma smith' read the same way. -# -# It is NOT the trailing slot and must not be folded into either rule -# above: a NAME WORD stands behind the member, which ends the trailing -# run, so no fork is consulted, nothing reports, and the member is an -# ordinary middle-name word. What v1 did instead was read the -# post-comma part's suffix vocabulary by POSITION and lift 'MA' out of -# the middle of the given part -- a family-comma routing difference -# 2.0 settled, which is why this files under the comma-family group -# that tests/v2/cases.py gives the row. -# -# Literal, one name: the shape is "a family-comma name whose given -# part carries an ambiguous acronym with a name word behind it", and a -# regex for it would claim the trailing-slot names two rules above. -name_regex = "^Doe, John MA Smith$" -fields = ["middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(comma-family) a credential acronym grouping joined into one piece stays middle-name text" -# THIS PR DID NOT MOVE THESE READINGS EITHER, and the three names -# arrived for the same reason the one above did: #531's fix round -# needed the rows that PIN the trailing slot's piece test, so they -# entered the corpora (2026-09-19). All three read the middle name at -# 2.0.0 and at every baseline since, this tree included, and v1 read -# the trailing word as a post-nominal. -# -# The mechanism is a STAGE EARLIER than the slot, and it is the piece -# rather than the word: grouping merges each of these members into a -# multi-token piece before assign asks anything -- 'MA y' on the -# conjunction, 'van Ma' on the particle chain in front, 'DO Ed' on the -# chain the member itself heads -- and the slot reads a piece, so a -# piece of two tokens is not the lone member the branch asks about. -# What v1 did instead was read the post-comma part's suffix -# vocabulary by POSITION, which is the same family-comma routing -# difference 'Doe, John MA Smith' carries above; these differ from it -# only in what ends the reach. -# -# Literal, three names. The shape is "a family-comma name whose given -# part ends in a joined piece carrying a class member", and a regex -# for it would need the join, which is not in the string: 'Doe, John -# MA Smith' and 'Doe, John MA' are spelled alike and neither is here. -# _MUST_NOT_MATCH carries both as probes. -name_regex = "^(?:Doe, John DO Ed|Doe, John MA y|Doe, John van Ma)$" -fields = ["middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#380) the do attachment leaves the credential in front of it in the middle name" -# THE SAME DECISION AS THE fix(#380) RULE BELOW, on a name that moves -# one field more than any of its five. 'Doe, John MA do' entered the -# corpora with #531's fix round (2026-09-19) and is NOT this PR's -# doing -- byte-identical at cc78c960 and on the 2.3.0 wheel. -# -# v1 read the whole tail as post-nominals, suffix 'MA, do'. Here the -# lower-case 'do' leans nothing, so the trailing slot declines it to -# P6 exactly as 'Doe, John Do' does -- and a piece that walk refuses -# ENDS the run, so the all-caps 'MA' in front of it is never asked -# and stays middle-name text, capitals and all. Two readings on one -# string: {family, suffix} is the attachment the rule below names, -# and `middle` is the silence behind it, which is why the name needs -# a rule of its own rather than joining that literal set -- a -# three-field diff is no subset of its {family, suffix}. -# -# Literal, one name. tests/v2/cases.py's -# the_particle_carve_out_silences_the_member_in_front is the row. -name_regex = "^Doe, John MA do$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#380) a trailing mc or do after a family comma is the tussenvoegsel, not a post-nominal" -# THE SAME DECISION AS THE fix(#380) RULE ABOVE, over the two other -# words that carry the collision it names. Five names entered the -# corpora with #531's case rows and its `do` pairing (2026-09-18), and -# every one of them reads here what 'Berg, Jan vd' reads there: the -# trailing word attaches to the family the comma already named -# (rules.md#P6) where v1 read it as a post-nominal, so {family, -# suffix} moves and nothing else does. -# -# 'Berg, Jan mc' is the second word of decisions.md#vocabulary- -# collisions' live pair, and decisions.md#suffix-acronym-collisions -# is where 'mc' was left in the acronym set as no borne name at all. -# The other four are the `do` rows: 'Doe, John Do', 'SMITH, JOHN DO', -# 'NASCIMENTO, EDSON ARANTES DO' and 'Nascimento, Edson Arantes do'. -# -# THIS PR DID NOT MOVE ANY OF THE FIVE -- verified name by name -# against the tree at cc78c960, where all five parse byte-identically -# (2026-09-19). What #531 DID do is take the sixth spelling OUT of -# this set: 'Doe, John DO' is an all-caps member in a mixed-case name, -# the one writing that carries a positive credential lean, so the -# credential reading wins, P6 stands down, and the name reaches 1.4.0 -# parity and drops out of this ledger entirely. That is -# decisions.md#S2's `do` pairing, and 'Doe, John DO' is a -# _MUST_NOT_MATCH probe here because of it -- the one-case -# 'SMITH, JOHN DO' beside it in the regex is the accepted cost the -# same entry records. -# -# Literal-anchored, unlike the vd rule's shape regex: `do` is -# ordinary given-name and particle text in a way `vd` is not, so a -# regex over the trailing word would claim every Portuguese and -# Vietnamese comma listing in the corpora. The vd rule's own -# _MUST_NOT_MATCH key was rekeyed to its full issue text when this -# rule landed, for the reason fix(#360)'s pair already records: the -# bare tag now matches two rules, and each one's boundary is the -# other's claim. -name_regex = "^(?:Berg, Jan mc|Doe, John Do|NASCIMENTO, EDSON ARANTES DO|Nascimento, Edson Arantes do|SMITH, JOHN DO)$" -fields = ["family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#274/#424) accepted: a maiden clause keeps a trailing credential v1 read as a post-nominal" -# Six names at landing (seven from #544; three since #601, which moved -# the three comma names to fix(#601) and the Jr. name to fix(#601/#602), -# below) whose reading this change does not touch: each reads on this -# tree exactly as it read at 2f57ff21, measured name by name, and the -# whole of the 1.4.0 diff is v1 having no maiden field. The marker takes -# the words after it, the trailing member among them -- rules.md#M2's -# Accepted reading, which has shipped since #274 and which #533 leaves -# standing wherever the WRITING declines the word (a Title-cased member -# in a mixed-case name: 'Jane Doe nee Smith Ma', 'Jane Doe nee Yo-Yo -# Ma', 'Doe, Jane nee Smith Ma'), wherever the member is the only word -# the marker would leave ('Jane Doe nee MA'), and wherever P6's -# attachment claims the word instead ('Doe, Jane nee Smith do', 'Doe, -# Jane nee Smith MA do'). What #533 adds on these names is a REPORT, and -# 1.4.0 has no surface to compare one against -- so nothing of this -# change is visible from here, and the rule is named for the change that -# is. -# -# Its own rule rather than a widening of fix(#274) above, and the -# reason is that rule's `fields`: it stops at maiden/middle/family -# because a Latin marker moves only those, while these names also move -# the `suffix` v1 read. Folding them in would have fix(#274) stand -# ready to explain a suffix regression on every name carrying a -# marker -- the trade 'fix(#424/#445) accepted: the maiden walk keeps -# a bare acronym' already refused for the same reason and records. -# -# Literal-anchored: the subject is a SLOT, and a regex for it would -# claim the names whose writing gives the word UP as readily as -# these. -# -# 2026-09-27, #544: a seventh joins, 'Jane Doe Jr. nee Smith Ma' -- -# maiden 'Smith Ma', suffix 'Jr.', where v1 read middle 'Doe Jr. nee', -# last 'Smith', suffix 'Ma'. It read so at 2.0.0 through 2.3.0, at the -# tree before #544 and at the tree, the report aside, by M2's Accepted -# boundary as it then stood: a credential in front of the marker spoke -# for no word of the clause. #544 decided that boundary and moved no -# role on the name, so the 1.4.0 diff was this rule's, the Title-case -# member's writing declining it as it does for 'Jane Doe nee Smith Ma'. -# #601 (2026-10-04) moved it on: it reads suffix 'Jr. nee Smith -# Ma' -- rules.md#M2: "a marker behind a credential is an ordinary word, -# and the credential's run (S2) takes it in with everything after it" -- -# and fix(#601/#602) below claims it. -name_regex = "^(?:Jane Doe nee MA|Jane Doe nee Smith Ma|Jane Doe nee Yo-Yo Ma)$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -issue = "fix(#274/#436/#437) a clause the unambiguous credential ended, and the run it left renders with spaces" -# 'Jane Doe nee Smith PhD MA' (the two #544 names below read the same -# way), and #533 moves nothing on it either: -# the unambiguous PhD already ended the walk before this change, so -# the clause gave the MA up then as it does now -- maiden 'Smith', -# suffix 'PhD MA', the same reading as at 2f57ff21. Two changes meet -# in the 1.4.0 diff and one rule has to explain the whole of it: the -# marker leaving the name (#274) and R1's space-separated rendering -# of the run v1 wrote 'PhD, MA' (#436/#437). -# -# The name is the order-sensitivity #533 was filed about, seen from -# the side that never moved: 'Jane Doe nee Smith MA PhD' reads the -# same way now and did not before, and it is in the rule below. -# -# 2026-09-27, #544: 'Jane Doe nee Smith PhD MEng' and 'Doe, Jane nee -# Smith PhD MEng' join, the same two changes and nothing else: maiden -# 'Smith', suffix 'PhD MEng', the roles 2.0.0 through 2.3.0 read. The -# tree before #544 read the Title-case MEng as a name word, and #544's -# company clause (rules.md#S2) put the run back, so against this -# baseline #544 moves no role on either name. -name_regex = "^(?:Jane Doe nee Smith PhD MA|Jane Doe nee Smith PhD MEng)$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -issue = "fix(#533) the clause gives the credential up, and the run it joins renders as the writer spaced it" -# Four names where #533 changes which words the clause holds, and the -# 1.4.0 diff carries two more changes besides. What the marker takes -# now ends where a trailing credential begins (rules.md#M2, -# decisions.md#M2), so 'Jane Doe nee Smith MA PhD' reads suffix 'MA -# PhD' where the tree read maiden 'Smith MA' at 2f57ff21, and 'Jane -# Doe nee Smith MA JD' reads suffix 'MA JD' where it read maiden -# 'Smith MA JD'. 'Jane Doe nee Smith Ma JD' is the pair's other -# half: the Title-cased Ma stays in the clause and only the JD behind -# it leaves. 'Doe, J. nee MA ba' is the CLAMP -- a member that is the -# first word after the marker stays the maiden name, so maiden 'MA' -# is kept and suffix 'ba' is given up. -# -# The other two changes are the marker leaving the name (#274, which -# v1 has no field for) and R1's space-separated rendering of the run -# the member joins, which v1 wrote with commas: 'MA, PhD' -> 'MA -# PhD', 'MA, JD' -> 'MA JD', 'MA, ba' -> 'ba' (#436/#437). One rule -# explains the whole of each diff, which is why this is not three. -# -# NOT case-insensitive, and measured: 'Jane Doe nee Smith Ma JD' and -# 'Jane Doe nee Smith MA JD' read differently from each other -- the -# capitals are the evidence the lean reads -- so an (?i) anchor here -# would stand ready to explain either with the other's reasoning. -name_regex = "^(?:Jane Doe nee Smith MA JD|Jane Doe nee Smith MA PhD|Jane Doe nee Smith Ma JD)$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -issue = "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause leaves v1's family name" -# 'Jane Doe nee Smith X.Y.Z.': v1 read middle 'Doe nee Smith', last -# 'X.Y.Z.' and no suffix at all; the tree reads family 'Doe', suffix -# 'X.Y.Z.', maiden 'Smith'. A by-shape member carries no writing -# convention to read -- `listed_lean` returns None where the switch -# made the token credential-SHAPED and the vocabulary did not list it -# -- so the positional reading decides alone and the clause gives the -# word up. The same trade #531 recorded at the given part's slot and -# for the same reason: taken to agree with the comma-less 'John Doe -# X.Y.Z.' and rules.md#S3's shape rule, not to restore anything. -# -# Accepted, and the divergence from v1 is the whole of it: v1 made -# the dotted acronym the family name, and nothing here proposes to. -name_regex = "^Jane Doe nee Smith X\\.Y\\.Z\\.$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -issue = "fix(#274/#296/#535) a trailing title after a dropped postnominal Dr., with no maiden reading either" -# 'Jane Doe nee Prof. Dr.' moves at this baseline for two reasons v1 -# and 2.0.0/2.1.0 both still carry: v1 has no maiden reading at all -# (#274, so it reads middle 'Doe nee' and last 'Prof.' where the -# tree reads family 'Doe' and maiden 'Prof.') AND 'dr' is still suffix -# vocabulary there (#296 had not yet run) -- rules.md#S2: "A trailing -# word of the suffix vocabulary reads as a suffix" -- so this -# baseline reads suffix 'Dr.' where the parent tree d9d80492, before -# #535, already reads maiden 'Prof. Dr.' (dr having left the suffix -# vocabulary by #296, so the clause keeps it as a name word). #535 -# then moves that word on into `title`, returning maiden to 'Prof.'. -# `given` does NOT move (both read 'Jane'). Literal-anchored for the -# reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Prof\\. Dr\\.$" -fields = ["family", "maiden", "middle", "suffix", "title"] - -[[change]] -issue = "fix(#274/#535) a trailing title ends the maiden clause" -# v1 has no maiden reading at all, so every one of these names' diff -# from this baseline is #274's (maiden markers consumed) as well as -# #535's (the title stop) -- verified against the parent tree -# d9d80492: before #535, seven of these eight already differed from -# 1.4.0 on exactly {family, maiden, middle}, and the eighth (the -# family-comma one, 'Doe, Jane nee Smith MA Prof.') on exactly -# {maiden, middle, suffix} instead -- `family` there already matches, -# the comma having fixed it. #535 additionally moves `title` (and -# `suffix` for the credential-bearing ones among the -# seven). rules.md#M2: "It takes them up to the trailing run -# of post-nominals and titles that the end of the name reads as if -# the clause were not written" (#601; until #601 this cited the -# sentence it replaced, "a trailing title ends it too") -- the end of -# the name is read -# through the trailing title chain -- rules.md#H5: "successive -# single words that wear the abbreviation shape and are title -# vocabulary chain into the title from the end" -- so the title -# leaves the clause and the credential or numeral in front of it gets -# the stop it gets with the title absent; the pair 'Smith MA Prof.' / -# 'Smith Prof. MA' now agrees. Literal-anchored for the reason -# fix(#533)'s rules give: the subject is a SLOT, and a regex would -# claim the names whose view check keeps the title as readily as the -# ones that release it. -name_regex = "^(?:Jane Doe \\x28nee Smith Prof\\.\\x29|Jane Doe nee Smith King\\.|Jane Doe nee Smith MA Prof\\.|Jane Doe nee Smith Ma Prof\\.|Jane Doe nee Smith Prof\\.|Jane Doe nee Smith Prof\\. MA|Jane Doe nee Smith V Prof\\.|Mary Smith née Jones Prof\\.)$" -fields = ["middle", "family", "title", "suffix", "maiden"] - -[[change]] -issue = "fix(#434/#533) a marker PHRASE takes the maiden name, and its clause ends at the credential" -# 'Maria Kowalska z domu Nowak MA'. fix(#274)'s regex is the -# n[ée]e/geb alternation and cannot reach a phrase entry at all -- -# its own comment says why, at length -- and the fix(#434) rules -# below are anchored to the bare 'Maria Kowalska z domu Nowak'. -# Separate rather than a widening of that anchor: the diff here is -# the same three fields but the CAUSE is two changes, and a rule -# naming only #434 would attribute this change's stop to the -# vocabulary addition. -# -# What #533 does here is not in the field list: `maiden` moves for -# the phrase's sake either way, and what changed is the TEXT it -# holds -- 'Nowak MA' at 2f57ff21, 'Nowak' now, with the MA a suffix -# v1 also read as one. -name_regex = "^Maria Kowalska z domu Nowak MA$" -fields = ["family", "maiden", "middle"] - - -# #397 review: A LINK INSIDE A MAIDEN CLAUSE STAYS IN THE BIRTH NAME -# (2026-09-20; a third rule added 2026-09-22 with rules.md#M2's -# `deviates: #538` example -- a live example since #538, 2026-09-26). -# Separate rules rather than one, because -# at this baseline OTHER changes ride along in the same diffs and one -# rule has to explain the whole of each: the marker leaving the name -# (#274), R1's space-joined rendering of a suffix run v1 wrote with a -# comma (#436/#437), and -- for the name added last -- the suffix -# field v1 put a whole suffix-comma tail in. -# -# The third new name, 'Jane Doe nee Puig i Soler', needs no rule here: -# its diff at this baseline is {family, maiden, middle}, which the -# fix(#274) alternation above already explains -- v1 has no maiden -# support at all, so the clause leaving the name IS the whole diff and -# the link's placement inside it changes none of the three field -# NAMES. The fourth, 'Jane Doe nee Puig i', is explained there too. -# -# rules.md#M2 states the exception; decisions.md#M2's 2026-09-20 -# bullet carries the measurement. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#274/#436/#437) a clause the generational link ended, and the run it left renders with spaces" -# 'Jane Doe nee Puig i III'. Two changes meet in the v1 diff: the -# marker leaving the name (#274, maiden 'Puig' where v1 read last -# 'Puig' and middle 'Doe nee') and the two-word generation run 'i III' -# that v1 rendered 'i, III' (#436/#437). The link fix moves NOTHING on -# it and that is what the name is in the corpus for -- measured -# against the parent 46651750, the parse is byte-identical before and -# after this bundle, the link having a generation to its right rather -# than a name word. -# -# Separate from the fix(#274/#436/#437) rule above it rather than a -# widening of that anchor: that one is about a clause an unambiguous -# CREDENTIAL ended, and a rule naming only credentials would -# attribute this stop to the wrong reading. -name_regex = "^Jane Doe nee Puig i III$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -issue = "change(suffix-acronym-collisions) ph leaves the acronym set" -# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps -# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' -# token pass the acronym test on its first piece -- the merge itself -# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working -# without it -- and its only unique coverage was a bare trailing -# 'Ph.' with no 'D.' behind it, read as a credential by every release -# since 1.4.0. Out of the set that spelling falls to the positional -# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: a deliberate 2.x parity break at every baseline, argued in -# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's -# and carried by the 2.4.0 release-log bullet. No corpus name had the -# shape until the cases.py row was written to carry it, which is why -# the removal measured as 0 role movers at review time; 'John Smith -# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. -# -# Literal to the one corpus name that carries the shape. The CLASS -# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', -# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', -# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every -# one a bare ph with no D. behind it (the decisions block lists them, -# measured 2026-09-22). A row carrying one of those arrives -# unexplained here by design: widen this rule to it, do not file it. -name_regex = "^John Smith Ph\\.$" -fields = ["family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. Only some of -# the 2.x ledgers' #540 rules are needed here. 'wang meng' and 'tran -# lac' read the family at 1.4.0 as the tree does, 'Smith, meng' and -# 'Smith, MEng' read the given name the same way, and 'john smith -# meng' and 'nguyen van lac' keep 1.4.0's suffix -- their only new -# thing is a report, and `_ambiguities` is a v2 surface this baseline -# does not compare. ('nguyen van lac' does diff here, on `_initials`, -# for fix(#385/#402)'s reason, and that rule's list carries it; the -# Title-case 'Nguyen Van Lac' reaches that same rule too, -# case-insensitively, but its role move is explained by the -# Title-case Lac rule below instead.) What is left is the marking's -# cost, paid twice (once bare behind a full name, once after a family -# comma), and the Title-case Lac gain that is the cost's mirror. -# decisions.md#suffix-acronym-collisions records the decision. -# -# At landing (2026-09-25) there were four rules here: the fourth was -# the same cost arriving as a credential run behind a suffix comma, -# and 'Wang M.Eng.' had no rule, reading the family here as 1.4.0 did -# (by its two-piece rule, a lone word after the given name being the -# family, not by S2's gate). #544 (2026-09-27) reads the run whole -# again, so 'John Smith, PhD MEng' and 'john smith, phd meng' read as -# this baseline read them and that rule is retired; and it reads -# 'Wang M.Eng.' as the suffix 2.0.0 through 2.3.0 read, so the name -# diffs here once more and the fix(suffix-routing) rule for a -# two-token dotted credential spelling claims it beside 'Jack M.A.'. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" -# 'john smith MEng': given 'john', middle 'smith', family 'MEng', -# where 1.4.0 read suffix 'MEng'. In a name written in more than one -# case rules.md#S2 reads a member "written in any other cased form -# that is not wholly lower" as the name "even with words to spare", -# and MEng is conventionally written in exactly that form. The same -# rule as the 2.x ledgers' of this name, without `_ambiguities`. -# -# 'John Smith MEng PhD' pays the same cost and keeps going: the -# declined pick stops the walk (family 'MEng'), but the unambiguous -# credential behind it is still peeled (suffix 'PhD'), where 1.4.0 -# read suffix 'MEng, PhD'. -# -# Literal, two names; the one-case spellings are _MUST_NOT_MATCH -# probes. -name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" -# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', -# where 1.4.0 read suffix 'MEng'. The same S2 case reading the rule -# above pins, arriving after a family comma, where C1 has already -# settled the word count and the writing is the only evidence left. -# -# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's -# name-word count, and the all-lower 'Smith, John meng' keeps the -# credential too -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, John MEng$" -fields = ["middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count" -# 'John Smith, X.Y.Z. MA': given 'John', family 'Smith', suffix 'X.Y.Z. -# MA', where this baseline read the comma as a family comma -- first -# 'X.Y.Z.', last 'John Smith', suffix 'MA' -- and so did the tree before -# #544. The part after the comma is the credential run on the count of -# the two name words before it, rules.md#C1: "The same count reads a -# part of two or more words as the credential run when every word of it -# is a suffix word or a word of this class, at least one of them of this -# class". 'X.Y.Z.' is a member by shape, whose capitals lean nothing -- -# rules.md#S2 reads the writing of a LISTED member only -- so the run -# is not one the capitals settle. -# -# The one name of the 2.x ledgers' rule that diffs here: 'John Smith, -# Ed Ma', 'John Smith, Ms Ma' and 'john smith, md ma' read the run as a -# suffix at this baseline as well, rendered as the tree renders it, and -# do not diff. Literal; `fields` is the union this name moves. Probes: -# 'Smith, Ed Ma', 'Smith, Ms Ma' and the superstrings 'Dr. John Smith, -# Ed Ma' and 'Dr. John Smith, X.Y.Z. MA' are _MUST_NOT_MATCH. -name_regex = "^John Smith, X\\.Y\\.Z\\. MA$" -fields = ["given", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a Title-case Lac behind a particle is the family name" -# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', where 1.4.0 read -# family 'Van', suffix 'Lac'. The mixed-case name's Title-case member -# leans 'name' with words to spare (S2) -- the gain side of the -# marking's cost, for a Vietnamese name (Lac is the trailing word in -# native order, Nguyen Van Lac). -# -# Literal, one name; the all-lower 'nguyen van lac' keeps its 1.4.0 -# suffix and is a _MUST_NOT_MATCH probe, along with the superstring -# 'Dr. Nguyen Van Lac'. -name_regex = "^Nguyen Van Lac$" -fields = ["family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#296) phd is not a prenominal, so behind a dual title it is the given name" -# 'Smith, MD PhD Ma': title 'MD', given 'PhD', middle 'Ma', where v1 -# read title 'MD PhD', first 'Ma'. 'phd' left TITLES (#296), so the -# title run ends at the dual 'MD' and 'PhD' is the given name, with -# 'Ma' ending the given part as a middle name (#531's slot, which keeps -# a Title-case member a name). #544 leaves the reading where it was: -# nothing in a part whose leading title run holds a dual speaks for the -# member (rules.md#S2). -# -# Literal. Probes: 'Smith, MD PhD MA' (the capitals make the member a -# credential and the part reads whole), 'Smith, Ms MD Ma' (v1's -# reading, unchanged) and the superstring 'Dr. Smith, MD PhD Ma' are -# _MUST_NOT_MATCH. -name_regex = "^Smith, MD PhD Ma$" -fields = ["title", "given", "middle"] - -[[change]] -issue = "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default" -# rules.md#C1 and S2 (#564): Policy.unlisted_caps_suffixes defaults to -# CapsSuffixes.AFTER_COMMA, so the part after a comma with two or more -# name words before it reads an unlisted all-caps word as the -# credential, alone or in C1's run, where the name contrasts it, and -# reports the call; a lone two-letter word is declined as initials. The all-caps SURNAME convention never writes the -# capitals there. 'John Smith, XYZ' and 'John Smith, LEED AP' are the -# C1 examples (the latter closing the #291 deviation the doc carried); -# 'The Rt Hon Kenneth Clarke QC MP, HMG' is a radar corpus name. -# 'John Smith, PhD XYZ' is a caps word in C1's run beside a listed -# credential, and 'García Márquez, MJ JK' two words of two capitals, -# read as #563 reads two pairs (both C1 examples). -# -# Literal; the probes 'Smith, XYZ' (one name word), 'García Márquez, -# MJ' (a lone two-letter word) and 'John Smith XYZ' (no comma: the -# trailing position stays opt-in) are _MUST_NOT_MATCH. -name_regex = "^(?:García Márquez, MJ JK|John Smith, LEED AP|John Smith, PhD XYZ|John Smith, XYZ|The Rt Hon Kenneth Clarke QC MP, HMG)$" -fields = ["title", "given", "middle", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word before a family comma stays in the family" -# rules.md#P2: in the part before a family comma "a particle that is -# also suffix vocabulary heads the name word behind it, whatever case -# the name is written in". This baseline read family 'SMITH', suffix -# 'VD, MA', splitting the run at the particle. -# -# Literal; the probe 'SMITH VD, JOHN' (nothing behind the particle, so -# the suffix stands) is _MUST_NOT_MATCH. -name_regex = "^SMITH VD MA, JOHN$" -fields = ["family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a never-given particle opening the given part takes the member behind it" -# rules.md#S2: a never-given particle first after a family comma is no -# given name, and the member behind it "is the surname the particle -# heads, not a word to spare", a chained particle run included ('VD DE -# LA'). This baseline read the particle run in front of the base, -# family 'VD SMITH' and the like, with the member as suffix 'MA'/'ma'. -# -# Literal; the probe 'SMITH, VAN MA' (an ambiguous particle, which can -# be a given name, keeps it, and the member reads as the credential) -# is _MUST_NOT_MATCH. -name_regex = "^(?:SMITH, VD DE LA MA|SMITH, VD MA|smith, de ma)$" -fields = ["family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#585) a decomposed initial keeps its whole first letter" -# initials() took a token's first CODE POINT, which in a name typed -# decomposed (NFD) is a base letter without the combining mark that -# completes it: 'e' + U+0301 initialled 'e.', and the katakana 'ジ', -# 'シ' + the combining dakuten U+3099, initialled 'シ.'. The initial is -# now the shortest start of the word that composes to the composed -# word's first character, written in the form the writer used -# (rules.md#R3), so both surfaces give the composed spelling's initial -# kept decomposed. A composed name's initials do not move, and nor do -# those of an NFD name whose first letters do not decompose (#542's -# 'josé garcía' rows), which is why the rules corpus's two other NFD -# rows are the only names here. -# -# Roles do not move; the derived view alone does, so `_initials` is -# the one field. Literal names rather than a shape: what selects them -# is "a name typed decomposed whose initial letter decomposes", and no -# regex over the raw text says that without copying Unicode's -# decomposition data. -name_regex = "^(e\u0301mile zola|マイケル シ\u3099ャクソン)$" -fields = ["_initials"] - -# --------------------------------------------------------------- -# #594: HALFWIDTH KATAKANA IS KATAKANA. The script table classifies -# the halfwidth kana block U+FF65-U+FF9F as Script.KATAKANA, so what -# reaches full-width katakana reaches halfwidth now: the kana license -# (rules.md#W4) reads 山田 タロウ family-first as it reads 山田 エミ, the -# 间隔号 divides between two halfwidth kana (rules.md#T3), and H2's -# opening-abbreviation shape declines a period-marked katakana word -# because katakana has no initials (rules.md#H2's Accepted clause), -# closing the limit decisions.md#cjk-full-stops recorded. A WHOLLY -# halfwidth name keeps the declared order, as wholly full-width -# katakana does, so 'ヤマダ タロウ' is parity. -# -# At this baseline the five license and 间隔号 movers need no rule of -# their own: the canonical fix(#271/#272/#298) rule above explains -# them through its span class, which copies the halfwidth block since -# #594 (its class reaches the parity name too, as it reaches -# 'マイケル ジャクソン'). The two title movers move `title`, outside -# that rule's fields, so the one rule below takes them. Literal- -# anchored, and LAST in the file: both reported unclassified on the -# radar on the run before it existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#594) a period-marked halfwidth katakana word is not a title" -# 'タナカ. John' and 'マイケル.' read title at 1.4.0, as their full-width -# twins did until #cjk-full-stops (2.3); both read given now, as the -# twins do. -# Tolerated input (an edge full stop on a CJK word), so these are -# radar names and the rule classifies them for the release note rather -# than for the exit code. -name_regex = "^(?:タナカ\\. John|マイケル\\.)$" -fields = ["title", "given"] - -# --------------------------------------------------------------- -# #597: THE HALFWIDTH CORNER BRACKETS ARE A DEFAULT NICKNAME PAIR. -# 「」 (U+FF62/U+FF63) join DEFAULT_NICKNAME_DELIMITERS beside 「」, and -# the v1 facade's nickname_delimiters gains halfwidth_corner_brackets, -# so a clause they enclose is lifted out as the nickname (rules.md#N1) -# where every release left it in the name -- a middle where spaced, -# part of one given word where glued. With the -# unclassified bracket gone, '山田 「タロー」 タロウ' is kanji plus katakana and -# takes the kana license; the unspaced '山田「タロー」太郎' divides at the -# extracted clause; 'John 「Jack」 Smith' shows the pair is not -# script-gated. -# -# Literal-anchored to the names: no corpus line held a halfwidth -# bracket before this change, so the movers ARE the three case rows -# #597 added. LAST in the file: each diff reported unexplained on the -# run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#597) halfwidth corner brackets enclose a nickname" -# The clause moves to nickname in all three, out of middle in the two -# spaced names and out of the one given word in '山田「タロー」太郎'; the -# two Japanese names also take their script order, family 山田. -name_regex = "^(?:山田 「タロー」 タロウ|山田「タロー」太郎|John 「Jack」 Smith)$" -fields = ["given", "middle", "family", "nickname"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04) -- which is how 1.4.0, -# with no maiden reading at all, read it too, so `maiden` is not in -# this diff. What differs is the given part's own reading of the -# clause's words since 1.4.0 -- the link join (#397: 'Puig i Soler'), -# P6's attachment of a trailing particle to the family (#405: 'Do -# Doe'), the capitals lean (#289: 'Ma' a name), and the trailing title -# chain (H5: 'Prof.') -- each as the same text with the marker replaced -# by an ordinary word reads it. These six sat in the #274/#533, -# #274/#424, #274/#535 and #274/#397 maiden rules until #601 left them -# no clause. -issue = "fix(#601) a marker in the given part after a family comma is an ordinary word" -name_regex = "^(?:Doe, Jane nee Puig i Soler|Doe, Jane nee Smith Do|Doe, Jane nee Smith MA Prof\\.|Doe, Jane nee Smith MA do|Doe, Jane nee Smith Ma|Doe, Jane nee Smith do)$" -fields = ["family", "middle", "suffix", "title"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). Where a credential -# stands before the marker it also starts #602's run -- rules.md#S2: "A -# credential after the name core starts a run to the end of its part" -- -# which takes the marker and the words after it in. -issue = "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word" -name_regex = "^(?:Dr\\. nee Smith PhD Prof\\.|Jane Doe Jr\\. nee Smith|Jane Doe Jr\\. nee Smith Ma|Jane Doe PhD nee Smith)$" -fields = ["family", "middle", "suffix", "title"] - -[[change]] -# 'Jane Doe nee Smith VI Prof.': rules.md#M2's numeral example -# behind a title, which the take reaches since #610 shared its -# candidate words with the peel (2026-10-04). -# rules.md#M2: "It takes them up to the trailing run of post-nominals and -# titles that the end of the name reads as if the clause were not -# written" (#601, 2026-10-04), and "The run so found ends the name: its -# words are the suffixes and titles that reading makes them, and no join -# reaches into it". The old walk's release check kept these words in the -# clause where a join it modelled might take them; the run is bound now. -# Against 1.4.0, which had no maiden reading, `maiden` fills -# as well (#274). -issue = "fix(#274/#601) the clause ends at the clause-free name's trailing run, which the take consumes" -name_regex = "^(?:Jane Doe nee Smith DO DO|Jane Doe nee Smith VI Prof\\.|Jane van der Berg nee Prof\\. King\\. MA|Jane van der Berg nee Smith Prof\\.|John nee Prof\\. ba MA)$" -fields = ["family", "given", "maiden", "middle", "suffix", "title"] - -[[change]] -# rules.md#M2's take "always takes the first word after it, the marker -# having announced a name" (#601, 2026-10-04): a lone roman numeral is no -# suffix word, so it is the maiden name, where the old walk read the -# numeral fork from the marker and declined. Decided with #601 (decisions.md#M2). -# Against 1.4.0, which had no maiden reading, `maiden` fills -# as well (#274). -issue = "fix(#274/#601) the first word after the marker is the maiden name" -name_regex = "^Jane Smith née V$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -# rules.md#S2: "A credential after the name core starts a run to the end -# of its part" (#602, 2026-10-04): every later word of the part is a -# suffix except a title word, which reads as a title, and an absorbed -# name word is reported. -# 'Jane van der Berg née Jr Jones' (radar): the marker declines on -# the suffix word after it (rules.md#M2) and stays a word, so 'Jr' -# stands behind a name core and its run takes 'Jones' (2026-10-04). -issue = "fix(#602) a credential after the name core starts a run to the end of its part" -name_regex = "^(?:Eric H\\. Holder Jr\\. Attorney General|Jane van der Berg née Jr Jones|John Smith PhD Jones|John Smith PhD Prof\\. Ma|Smith, John PhD Jones)$" -fields = ["family", "middle", "suffix", "title"] - -[[change]] -# rules.md#S2: "leaving it nothing to join: then it counts as a name -# word of its own" -# (#627, 2026-10-10): a lone particle with the credential right behind -# it counts toward the run's two name words, so it is the family and -# the run takes the rest. -issue = "fix(#627) a lone particle with the run right behind it is the surname" -name_regex = "^John von PhD Jones$" -fields = ["family", "middle", "suffix"] - -[[change]] -# rules.md#P4: "of several such words the last does, so no particle -# chains inside the titles" (#624, 2026-10-10): the second -# title-particle is a title and the name starts behind it. -issue = "fix(#624) of two title-particles at the head the second leads the chain" -name_regex = "^Freiherr St John Smith$" -fields = ["family", "given", "title"] - -[[change]] -# rules.md#H5: "chain stops in front of the trailing run of suffixes and titles," -# (#623, 2026-10-10): after a family comma a trailing title is the -# title, and P6 attaches the particle in front of it. -issue = "fix(#623) after a family comma the chain stops before a trailing title" -name_regex = "^Smith, J[a-z]+ van( der Berg)? Prof\\.$" -fields = ["family", "middle", "title"] - -[[change]] -# rules.md#M2 reads the clause-free name, whose credential starts #602's -# run -- rules.md#S2: "A credential after the name core starts a run to -# the end of its part" -- and the take consumes that run: maiden 'Smith', -# suffix 'PhD Smith' (2026-10-04). -# Against 1.4.0, which had no maiden reading, `maiden` fills -# as well (#274). -issue = "fix(#274/#601/#602) a credential in the clause starts a run the take consumes" -name_regex = "^Jane Doe nee Smith PhD Smith$" -fields = ["family", "maiden", "middle", "suffix"] - -[[change]] -# rules.md#P6: "a particle ending the name attaches to that family name", -# and a post-nominal written behind the particle does not end the name -# for this purpose. rules.md#S2's example for the given-part run leaving -# the particle to P6 (#601/#602 review, 2026-10-04); the attachment -# itself landed in 2.2 (#379). -issue = "fix(#379) a tussenvoegsel behind a post-nominal after a family comma attaches" -name_regex = "^Smith, John PhD de Jr\\.$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #604: THE IRISH AND MALAY PATRONYMIC PARTICLES. -# 'ó' and 'ní' join the never-given particles, 'ua', 'binti' and -# 'binte' the ambiguous half, so each opens the surname behind it -# (rules.md#P1, P2) where every release read it as a middle name. -# 'Ó.' written WITH its period stays the initial (rules.md#P7), so of -# the five P7 examples only the bare boundary moves; the two R4 case -# repair examples move for the vocabulary itself. -# -# Literal-anchored to the names: the movers are the P7 boundary and -# the two R4 examples, each reported unexplained on the run before -# this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#604) the Irish and Malay patronymic particles" -# The particle leaves the middle and opens the family: 'Ó Pérez', -# 'Ó MURCHÚ', 'binti navalamar'. -name_regex = "^(?:Juan Ó Pérez|SEÁN Ó MURCHÚ|ina binti navalamar)$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #606: SALUTATIONS AND TITLES IN OTHER LANGUAGES. -# TITLES gains salutations and offices for languages it had none -# for, among them three that are also surnames written last -# wherever they are borne (graaf, greve, knight), shipped on the -# graf precedent (rules.md#H1). The one corpus mover is the -# H1 Accepted example for that cost: the surname-first form -# without a comma gives the surname-borne word to the title. -# -# Literal-anchored to the name: it was reported unexplained on the -# run before this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#606) salutations and titles in other languages" -# The given name becomes the title: 'Greve'. -name_regex = "^Greve Anna$" -fields = ["title", "given"] - -[[change]] -# rules.md#S2: "The run at a name's trailing slot is read ONCE, once -# the connectives have joined (P3) and before the particle chain (P2) -# and the bound given-name join (P5) are made" and "neither join -# reaches a word the run took" (#614, 2026-10-07), and rules.md#H5: "a -# particle chain (P2) stops in front of a trailing title rather than -# taking it into the family name". This baseline made the chain first -# and the title was a word of the family name. -issue = "fix(#614) a trailing title is read before the particle chain" -name_regex = "^John van der Berg Prof\\.$" -fields = ["family", "title"] - -[[change]] -# rules.md#H2: "An accented Latin, Greek or Cyrillic letter written as a -# base letter plus a combining mark (NFD) is the same letter as the -# composed one" (#652, 2026-10-12): the abbreviation shape ended its -# word at the base letter, so a decomposed `inž.` was no abbreviation -# and became the given name. The rules example is the one name here. -issue = "fix(#652) a decomposed accented abbreviation is an abbreviation" -name_regex = "^Dr\\. inz\u030c\\. Janez Novak$" -fields = ["title", "given", "middle"] diff --git a/tools/differential/expected_since_2.0.0.toml b/tools/differential/expected_since_2.0.0.toml deleted file mode 100644 index 521fbc71..00000000 --- a/tools/differential/expected_since_2.0.0.toml +++ /dev/null @@ -1,4101 +0,0 @@ -# Ledger for baseline 2.0.0 -- what changes for a user upgrading from -# the previous minor. Same rule grammar as expected_since_1.4.0.toml: -# `issue`, `name_regex` and `fields` are all mandatory, none of them -# optional any more. A rule may not narrow on one axis alone: -# validate_rules has refused `fields` without a `name_regex` since -# #451 and refuses `name_regex` without a `fields` since #456. Since -# every rule therefore carries a `name_regex`, file order is -# load-bearing; the note below says how it falls out here. -# -# `fields` names roles the way Role does, for BOTH compared surfaces, -# plus the pseudo-field `_ambiguities`. The roster is deliberately not -# restated here -- it is Role's members, validate_rules rejects -# anything outside them, and a copy in prose goes stale the day a role -# is added. A rule saying "first" or "last" is rejected at startup. -# -# Rules here are NOT a subset of the 1.4 ledger's. That one classifies -# everything 2.x changed since 1.4, including what 2.0 itself changed; -# this one classifies only what 2.1 changed on top of 2.0. A rule -# copied across without checking will either over-match (hiding a real -# 2.1 regression behind a 2.0-era label) or never fire. -# -# Every rule below carries a `name_regex`, so they all sit in one tier -# and file order breaks ties between them. Where that matters it is -# said on the rule; the ordering decision made twice here is that the -# GLUED honorific rule precedes the SPACED one, because a name can -# carry both shapes at once. - -# #436/#437: the suffix view's entry boundary became the comma the -# writer typed. rules.md#R1: "a run of post-nominals written with -# spaces renders with spaces, and one written with commas keeps -# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA -# path only since 2.3, which is the whole of what these names move. -# Every one goes from a comma-joined render to a space-joined one; -# none goes the other way, and none has a comma in the original -# between the two words. Roles do not move: this rule declares -# `suffix` alone, so a name whose ROLES move here arrives -# UNEXPLAINED, as it should. -# -# FIRST in the file, and that is narrow-first rather than a -# preference: `fields = ["suffix"]` is a strict subset of several -# rules below, and any of them sitting earlier while both regexes -# reach one corpus name is an undeclared order-decided contest the -# run refuses (#382). Measured 2026-09-06 by moving this rule to -# the end of a scratch copy of this file and re-running the gate, -# which raises 3 such contests here. -# -# An anchored alternation of the names rather than a shape, because -# the shape is "two post-nominals with a space between them" and -# that reaches every listing form in the corpora. The members are a -# list of NAMES and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 10 with its digest, and -# _MUST_NOT_MATCH pins the five comma and delimiter forms it must -# never claim. -[[change]] -issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" -# 2026-09-19, #533: one name joins the alternation and the rule -# does not change. 'Jane Doe nee Smith PhD MA' is a two-word -# credential run this baseline renders 'PhD, MA' and the tree -# renders 'PhD MA' -- the whole of its diff at this baseline, and -# the same diff at 2f57ff21, measured. #533 moves nothing on it: -# the unambiguous PhD already ended the walk, so the clause gave -# the MA up before this change as it does after it. What grew is -# the CORPUS, and the run rendering is what the diff is about. -# 2026-09-20, #397: three names join the alternation and the rule -# does not change. 'Josep Lluis Carod i III' ('i, III' -> 'i III'), -# 'Josep Lluis Carod i V' ('i, V' -> 'i V') and 'Rovira, Josep Carod -# i Jr.' ('i, Jr.' -> 'i Jr.') are two-word generation runs this -# baseline renders with a comma and the tree renders with a space -- -# the whole of each one's diff here. #397 moves NOTHING on any of -# them: measured against the parent 46651750 all three parse -# byte-identically before and after this bundle, the link having a -# generation marker and not a name word to its right. What grew is -# the CORPUS -- the three arrived with #397's own case rows -- and -# the run rendering is what the diffs are about. -# 2026-09-20, #397 review: one more name joins the alternation and -# the rule does not change. 'Jane Doe nee Puig i III' is the same -# two-word generation run inside a MAIDEN clause's tail: this -# baseline renders 'i, III' and the tree renders 'i III', which is -# the whole of its diff here. The link fix moves nothing on it -- -# measured against the parent 46651750, the parse is byte-identical -# before and after (given 'Jane', family 'Doe', suffix 'i III', -# maiden 'Puig'), the link having a generation and not a name word -# to its right. What grew is the CORPUS. -# 2026-09-27, #544: Three names join the alternation, each diffing here on -# the rendering alone -- the roles are this baseline's, and #544 is what -# put them back ('Jane Doe Jr. Ma', 'John Smith PhD Ed Ma', 'John Smith PhD Ma'). The tree before #544 read the Title-case -# member behind the credential as a name word, the case lean stopping -# the peel at it; the company clause (rules.md#S2) reads it as the -# credential, so the run is v1's again and only R1's spacing differs. -name_regex = "^(?:JOHN DOE PHD MD|Jane Doe Jr\\. Ma|Jane Doe nee Puig i III|Jane Doe nee Smith PhD MA|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|John Smith PhD Ed Ma|John Smith PhD Ma|Josep Lluis Carod i III|Josep Lluis Carod i V|Kenneth Clarke QC MP|Rovira, Josep Carod i Jr\\.|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" -fields = ["suffix"] - -# The seven #449 rules go SECOND, not first: the rule above -# declares its own first-in-file position with a measurement, -# and `fields = ["suffix"]` and `fields = ["_ambiguities"]` -# are disjoint, so nothing orders the two against each other. -# They DO sit ahead of every rule below whose `fields` is a -# strict superset of `_ambiguities`, which is the narrow-first -# default (#382). -[[change]] -issue = "feat(#449) a lone name word reports given-or-family" -# rules.md#O5's convention, now reported. Twenty-two corpus names at -# landing (twenty-three since #544, 2026-09-27), and -# the alternation is a list of NAMES rather than a copy of any -# wordlist -- what selects them is the SHAPE (one name word, and no -# title, maiden name, comma, script order or vocabulary claim deciding -# it), so it is declared in _NOT_A_VOCABULARY_COPY. A suffix beside -# the word does NOT decide it: 'Smith Jr.' and "'Smitty' Jones Jr." -# are the peel taking the suffix and the convention placing what is -# left. `_ambiguities` alone: no role moves anywhere in this change. -# The five CJK-bearing names in the same population ride five rules of -# their own below, because a CJK member in an alternation is claimed -# by the honorific pin, which would demand this be a copy of -# GLUED_HONORIFICS. #322/#323 opened the one escape from that pin -- -# a member set declared in _NOT_A_VOCABULARY_COPY -- and nothing here -# declares one, so the five rules stand. -# 2026-09-27, #544: 'Wang M.Eng.' joins the alternation. Its roles are -# this baseline's, suffix 'M.Eng.', and the whole of its diff here is -# the report this rule describes, as for 'Jack M.A.'. The two readings -# of the suffix have different grounds: this baseline read it because -# 'meng' was an unambiguous acronym, which rules.md#S2 consumes "even -# when that leaves no family name at all"; #540 made 'meng' ambiguous -# in the 2.4 cycle, which read the family, and the tree reads the -# suffix again because S2 counts an ambiguous acronym "written with its -# periods, one after each letter or one after each of two or more -# letter chunks" unambiguously. Neither change is what the name diffs -# on against this baseline. -name_regex = "(?i)^(?:'Smitty' Jones Jr\\.|Andrew|Carod i|Dean of Chemistry|Donald mc|Duke of Edinburgh|Duke of Wellington|Garcia|Jack M\\.A\\.|John & Jane|John V|John of the Doe|Juan & Garcia|Juan and Garcia|Mohamad X|Smith|Smith Jr\\.|Wang M\\.Eng\\.|e and e|part1 of The part2 of the part3 and part4|part1 of and The part2 of the part3 And part4|test|سلمان،)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it" -# 'マイケル'. rules.md#W4 gives katakana no order of its own -- a -# wholly-katakana name is usually a transcription and keeps whatever -# the caller declared -- so the script rule DECLINES and O5's -# convention is what is left. Literal-anchored on one corpus name, so -# _CORPUS_CLAIMS cannot see a widening and _MUST_NOT_MATCH carries the -# wall. -name_regex = "^マイケル$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) an interpunct transcription declines the script order, so the convention decides it" -# '王·Smith'. rules.md#T3: "The interpunct divides a name only between -# two characters of a classified East Asian script; anywhere else it is -# part of the word" -- 'Smith' is Latin, so the name is one word, and -# '王Smith' reads the same. rules.md#W4: "A name written wholly in one -# East Asian script, or in the kana-licensed Japanese repertoire, reads -# family-first whatever order the caller declared" -- a word mixing Han -# and Latin is neither, so no script order applies, name_order governs -# and the one name word is O5's. The issue names the interpunct, which -# is incidental to this name. Literal-anchored, one corpus name. -name_regex = "^王·Smith$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) an ideographic comma leaves one name word, and the convention decides it" -# '田中、太郎'. The comma is inside the token, so the text resolves to -# no single script and the order lookup declines; what is left is one -# name word and O5. A rules.md#W3 shape -- tolerated, read -# best-effort -- and the report says only what the parse actually -# called. Literal-anchored, one corpus name. -name_regex = "^田中、太郎$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order" -# 'Andersonさん'. rules.md#W2 peels the glued honorific to SUFFIX and -# leaves 'Anderson' -- Latin, so effective_script answers None and no -# script order is resolved at all. One name word, and O5's convention -# places it. Literal-anchored, one corpus name. -# -# `given` and `suffix` ride beside `_ambiguities` here and in this -# ledger ALONE: at 2.0.0 the name reads given 'Andersonさん' with an -# empty suffix, so #308's peel and #449's report arrive in one diff -# and one rule has to admit both. By 2.1.0 the peel is already in the -# baseline and `_ambiguities` is the whole diff, which is why the -# other two ledgers spell these two rules with the narrower `fields` -# -- the gate's OVER-DECLARED check refuses a rule declaring a role no -# diff it explains moves, so the three copies cannot be identical. -name_regex = "^Andersonさん$" -fields = ["_ambiguities", "given", "suffix"] - -[[change]] -issue = "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order" -# 'Anderson선생님'. The Japanese rule's argument, in Hangul: the -# honorific is peeled, the Latin stem resolves to no script, and the -# convention places the one name word left. Literal-anchored, one -# corpus name, and `fields` carries the peel for the reason given -# above. -name_regex = "^Anderson선생님$" -fields = ["_ambiguities", "given", "suffix"] - -[[change]] -issue = "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it" -# '김゙민준' (김 + U+3099), a tolerated case row #596 added (2026-10-03). -# A kana voicing mark after hangul voices no kana, so it keeps its -# hiragana block and the token is mixed hangul and hiragana: no -# script, no surname division, and O5's convention places the one name -# word left. The roles are this baseline's; the report is the whole -# diff. Literal-anchored, one corpus name, `_ambiguities` alone. -name_regex = "^김\\u3099민준$" -fields = ["_ambiguities"] - -# The two #491 rules sit beside #449's, same position rule: both -# declare `fields = ["_ambiguities"]`, so they are disjoint from the -# `suffix` rule that owns first place and ahead of every rule below -# whose fields are a strict superset of theirs (#382's narrow-first -# default). -[[change]] -issue = "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name" -# rules.md#H4. Six corpus names, one alternative each -- a list of -# NAMES, not a copy of TITLES: what selects them is the SHAPE (the -# title peel leaves exactly one name word, and that word is itself -# title vocabulary), and a member copying the wordlist would reach -# 'Dr. Smith', 'Rabbi Cohen' and every other title-plus-surname name, -# none of which moves. Declared in _NOT_A_VOCABULARY_COPY for that -# reason. `_ambiguities` alone: the reading each of these gets is -# unchanged, only the silence about it. -name_regex = "(?i)^(?:Dr\\. King|His Holiness|His Holiness the Dalai Lama|Lord Chancellor|The Right Hon\\. the President of the Queen's Bench Division|The Rt Hon)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name" -# rules.md#H4's suffix half, emitted at assign's bare-suffix -# carve-out. Two corpus names. A list of names, not a copy of -# SUFFIX_WORDS or SUFFIX_ACRONYMS: a member copying either would reach -# 'John Smith MD', 'Smith Jr.' and the rest of the corpus's credential -# names, none of which moves -- what selects these two is that nothing -# was left to be the name. -name_regex = "(?i)^(?:QC MP|Rinpoche)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#491) a joined unit carrying title vocabulary reports title-or-name" -# H4's join clause -- the one name unit is a join (P3) and one of the -# words it joins is title vocabulary, so the doubt is whether that -# word is a title rather than which field the unit takes. These two -# arrive because the #518 review round moved the clause out from under -# O5's field-deciding guards: both peel a title in front ('Attorney', -# 'Deputy') and leave the join standing as the last piece, which a -# title cannot be, so H3 never chains it. A list of two names, not a -# copy of TITLES: a member copying the wordlist would reach 'Prince of -# Wales' -- a join LED by a title word, which H3 does chain -- and -# every title-plus-surname name besides. `_ambiguities` alone: the -# reading is unchanged, only the silence about it. -name_regex = "(?i)^(?:Attorney General of Minnesota|Deputy Secretary of State)$" -fields = ["_ambiguities"] - -# #346: swami, guru, baba and lama moved from the TITLES-only block -# into GIVEN_NAME_TITLES on 2026-09-06. rules.md#H1's Accepted clause -# -- "a given-name title plus one name word leaves the family empty" -# -- now reaches them, so the one name word behind the title is the -# GIVEN name and the family is empty, where every baseline read it as -# the family. Both roles move together and `fields` declares both: a -# name whose TITLE moved would arrive UNEXPLAINED, as it should. -# -# Four corpus names, all from the radar-tier corpus_issues.jsonl and -# all moving the same way. The two- and three-word spellings are the -# boundary and are _MUST_NOT_MATCH probes: 'Swami Vivekananda -# Saraswati' and 'Guru Gobind Singh' keep their family names, because -# H1's fold requires exactly one name word, and a rule reaching them -# would stand ready to explain the very regression that boundary -# exists to catch. -# -# An anchored alternation of the four NAMES rather than a shape, -# under the ledger discipline expected_since_1.4.0.toml's header -# states for all four files: keep each entry's `name_regex` as tight -# as the diff allows. Four names moved, so a four-name list is the -# tightest expression of that diff; anything shape-shaped is wider -# than what was measured. It copies no wordlist either, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 4 with its digest. -[[change]] -issue = "fix(#346) a renunciate title and one name word leave the name a given name" -name_regex = "^(?:Baba Ramdev|Guru Nanak|Lama Zopa|Swami Vivekananda)$" -fields = ["family", "given"] - -# #519: prince and princess moved from the TITLES-only block into -# GIVEN_NAME_TITLES on 2026-09-10. rules.md#H1's fold now reaches -# them, so the one name word behind the title is the GIVEN name and -# the family is empty, where every baseline read it as the family. -# Both roles move together and `fields` declares both. -# -# Three corpus names: 'Prince Charles' and 'Prince Fielder' from the -# radar-tier corpus_issues.jsonl, and the contract-tier rules.md -# example 'Her Royal Highness Princess Anne' (corpus_rules.jsonl), all -# moving the same way. 'Prince Fielder' moves too and is wrong either -# way -- Prince is his given name, which is #348's collision and not -# this rule's subject. The boundary is pinned by _MUST_NOT_MATCH -# probes: two name words behind the title ('Prince Harry Windsor'), -# the chained run 'Prince of Wales Jr' (addresses by `wales`), the -# trailing 'Robert Prince', 'Lord Byron' (`lord` stays out) and the -# bare 'Prince'. -# -# An anchored alternation of the NAMES rather than a shape, under -# the ledger discipline expected_since_1.4.0.toml's header states: -# keep each entry's `name_regex` as tight as the diff allows. It -# copies no wordlist, which _NOT_A_VOCABULARY_COPY records; -# _CORPUS_CLAIMS pins the reach with its digest. -[[change]] -issue = "fix(#519) prince and princess join the given-name titles" -name_regex = "^(?:Her Royal Highness Princess Anne|Prince Charles|Prince Fielder)$" -fields = ["family", "given"] - -[[change]] -issue = "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word" -# 'rai' and 'cha' left SUFFIX_ACRONYMS on 2026-09-07 -# (nameparser/config/suffixes.py). Both arrived in the 2019-12-11 -# bulk wikipedia post-nominal import (af5bdab), neither was reviewed -# against a surname collision, and decisions.md#suffix-acronym-collisions -# states the criterion that removed them rather than marking them -# ambiguous: marking is for a short credential in wide use that is -# also a name, removal for a longer one that is tenuous and collides. -# -# Five corpus names, all from the radar-tier corpus_issues.jsonl, in -# THREE diff shapes with one cause -- the word stopped being -# credential vocabulary, so the positional read takes it: -# 'Aishwarya Rai' {family, suffix} suffix -> family -# (2.x only -- parity at 1.4.0) -# 'Lala Lajpat Rai' {middle, family, suffix} the family shifts right -# 'John Smith RAI' {middle, family, suffix} same -# 'John Smith, RAI' {given, family, suffix} an ordinary family comma (C1) -# 'Ahmad Jayadi, CHA' {given, family, suffix} same -# 2026-10-01, #564: the comma pair reads suffix again by its capitals -# (an unlisted all-caps word after a comma behind two name words), so -# it no longer moves {given, family, suffix} here; at 1.4.0 it reaches -# parity, at 2.x only the comma's report differs (the fix(#564) rule). -# `given` therefore leaves `fields`, which the OVER-DECLARED check -# requires: no name here moves it any more. -# The comma pair is why `fields` declares four roles rather than the -# two the pre-2.3 fix(#342) rule declared: with no credential after -# the comma, C1 reads the pre-comma run as the family and the -# post-comma word as the GIVEN name, so `given` moves where it did -# not before. The declaration is the exact union of the five, which -# is what the OVER-DECLARED check recomputes at the end of every run. -# -# 'Aishwarya Rai' is in this rule at 2.0.0/2.1.0/2.2.0 and NOT -# explained by it at 1.4.0, where the name now reaches PARITY: 1.4.0 -# read family 'Rai' too, which is what the deleted -# `fix(#342) NOT WANTED` rule existed to classify. The regex is the -# same in all four files anyway -- reach is not explanation, and a -# per-ledger regex would say a name left the corpus. -# -# An anchored alternation of the five NAMES rather than a shape. The -# shape is "a trailing word that used to be an acronym entry", which -# would be a copy of SUFFIX_ACRONYMS and would stand ready to explain -# every future trailing-acronym regression. The members are a list of -# names and copy no wordlist, which is what _NOT_A_VOCABULARY_COPY in -# tests/v2/test_ledger_guards.py records; _CORPUS_CLAIMS pins the -# reach at 5 with its digest, and _MUST_NOT_MATCH carries the -# boundaries the paragraph above argues. -name_regex = "^(?:Ahmad Jayadi, CHA|Aishwarya Rai|John Smith RAI|John Smith, RAI|Lala Lajpat Rai)$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#271/#272/#298) native-script CJK: family-first order, hangul segmentation, the kana license and the dots" -# The 2.1 East Asian defaults, in the fields they move name pieces -# between. '毛 泽东' and '毛泽东' read family-first through the new -# Policy.script_orders (#271); '김민준' and '남궁민수' additionally -# split, hangul segmentation being on by default (#271), which is also -# where the one _ambiguities diff in the run comes from -- 남궁민수 now -# reports SEGMENTATION, a kind 2.0 did not have. '高橋 みなみ' and -# '山田 エミ' take the same order rule under the kana license (#272), -# while a wholly-katakana transcription ('マイケル・ジャクソン') is -# excluded from the kana license and stays positional. A hangul -# transcription like '마이클·잭슨' is a different mechanism -- it -# divides on the 间隔号 below and its pieces then read positionally. The two dots divide tokens: the -# nakaguro ・/・ unconditionally (#272) and the 间隔号 · between -# classified-script characters (#298), so 'マイケル・ジャクソン', -# '威廉·莎士比亚' and '马丁·路德·金' come apart where 2.0 held one -# token. '〆木 太郎' rides in this class too: #303 counts the shime -# mark as Han, which is what puts the name in scope of the order rule -# at all. -# -# One rule, not five, for the reason the 1.4 ledger gives its twin: -# these are one diff shape -- pieces moving between given/middle/family -# on a native-script CJK name -- and splitting by issue would need a -# rule per script and per dot with no gain in tightness, the fields -# list being the narrow half. -# -# `suffix` is deliberately absent, which is what keeps this rule from -# swallowing the honorific work below; `maiden`, `nickname` and `title` -# are absent for the same reason against the compound rules further -# down. The class is copied verbatim from the same rule in -# expected_since_1.4.0.toml rather than rewritten in literal -# characters, so the two ledgers spell the same class the same way. -# -# tests/v2/test_ledger_guards.py pins this copy, and the honorific -# alternations below, by sweeping every expected_since_*.toml rather -# than naming one (#333). A span removed from the script table, or an -# entry removed from SUFFIX_WORDS or GLUED_HONORIFICS, now -# forces this file to narrow with it instead of leaving a wide twin -# behind to classify a real regression as intended. -name_regex = "[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F]" -fields = ["given", "middle", "family", "_ambiguities"] - -[[change]] -issue = "fix(#308/#312/#319/#320) glued CJK honorific peeled off the name into suffix" -# '田中さん', '김민준씨', '王小明先生' (and 'Andersonさん', which the -# feat(#449) glued-Japanese rule above claims first since 2026-09-08): -# #308 splits an -# honorific written against the name off the end of its token and -# routes it to `suffix`, where 2.0 left it inside the name. #312 lets -# the peel reach across a comma ('김, 민준씨', '田中, 太郎さん', -# '威廉·莎士比亚さん'), #319 makes it survive an all-credential -# post-comma run ('田中さん, V.', '田中さん, Ph. D.', '김민준씨, V.', -# '김민준씨, J.씨'), and #320 stops an ASCII period vetoing the -# honorific it finds there ('田中さん 様.', '田中さん, 様.'). Every one -# of them is the same diff shape, so they share a rule; the peeled name -# then goes through the ordinary machinery, which is why `given` and -# `family` move alongside `suffix` (김민준씨 -> family 김, given 민준, -# suffix 씨). -# -# Keyed on GLUED_HONORIFICS specifically -- the peelable subset -- and -# not on the wider spaced vocabulary, because that subset is exactly -# the vetting this rule needs. 양, 군, 氏, 博士, 殿 and 君 are excluded -# from it precisely because they can END a name (김지양 is a given -# name, 鵜殿 a surname), so keying on the wider set would let this rule -# claim a suffix regression on '김지양' or '鵜殿' -- names that are in -# this run for the order flip alone and must stay in the rule above. -# -# The positive lookbehind is the glued half: the honorific must sit -# against a preceding non-space, non-comma character, so a SPACED -# honorific never matches here and falls through to the rule below. -# The three NEGATIVE lookbehinds beside it are the other half of that -# same test. 박사님, 선생님 and 교수님 are the only entries of this -# alternation containing another entry of it -- read the members: every -# other one is a whole word no listed word sits inside -- so a match on -# the interior 님 is the one way a spaced honorific could reach this -# rule, and refusing that position is what shuts it. They refuse the -# INTERIOR position only: a glued '김민준박사님' still matches on 박사님 -# itself, whose own lookbehind reads the name text in front of it. -# The trailing -# `\.?(?=$|[ ,])` is the whole-token half, judged on the name STRING: -# without it any name merely CONTAINING one of these strings would -# match, and the optional period is #320's shape. `middle` is -# deliberately not in `fields` -- no glued diff in this run carries -# one, and admitting it would pre-excuse a middle regression that the -# spaced rule below legitimately has. -# -# Sits AHEAD of the spaced rule on purpose. Two names match BOTH -# rules -- '田中さん 様.' and '田中さん, 様.' -- and -# both rules are in the name_regex tier, so file order is the only -# thing deciding which label they report under (measured 2026-08-05, -# re-measured 2026-09-05 after the narrowing below took the third). -# The glued さん is the piece #308 moves, so this label is the true one, -# and _CROSS_RULE_WINNERS pins both names here. -# ('선생님, J.씨' is NOT one of them: it opens with a spaced honorific -# at index 0, which this file's spaced rule deliberately no longer -# matches, so only this rule claims it and no ordering is involved -- -# it reaches this rule on its trailing 씨, which the lookbehinds leave -# alone.) -# -# One spaced name used to land here, and the negative lookbehinds were -# added 2026-09-05 to stop it: the SPACED '김민준 박사님' matched on its -# 님, which sits glued to 사 inside 박사님 and ends the string. Nothing -# is peeled off the NAME there -- rules.md#W2's second sentence says a -# part that is not name text is never the name's end, and its witness -# line for this very string is suffix="박사님", the honorific whole and -# standing as its own word -- so this rule's title was false of a -# contract-tier name while every guard stayed green. What happens to it -# is the spaced rule's title, and that rule takes it now. '선생님' was -# reached the same way, on the 님 inside it, and there `suffix` moves -# nowhere at all: its diff is the order flip's {given, family}, which -# the rule above claims either way. -# -# Measured consequence, 2026-09-05: exactly one name changes hands in -# this ledger and it is '김민준 박사님'. The 1.4 ledger carries this -# regex verbatim and was narrowed with it, where the same one name -# moves to fix(cjk-honorific-suffix). Summary lines are byte-identical -# at all four baselines, no 1.4 pin moves, order_contests is unchanged, -# and this rule's recorded reach falls 37 -> 35, for the two names -# above and nothing else. -name_regex = "(?<=[^\\s,])(? given 太郎, -# family 田中, suffix 様; '김민준 박사 씨' likewise). -name_regex = "[ ,](?:박사님|선생님|교수님|박사|씨|님|군|양|先生|女士|小姐|博士|教授|様|氏|殿|さん|さま|くん|ちゃん)\\.?(?=$|[ ,])" -fields = ["given", "middle", "family", "suffix"] - -[[change]] -issue = "fix(#309) 旧姓 maiden marker consumed, compounding with the CJK order flip" -# '山田 花子 旧姓 佐藤', '山田花子 旧姓 佐藤': #309 adds the Japanese -# maiden-name marker to the default vocabulary, so the marker is -# consumed and the name behind it routes to `maiden`, where 2.0 left -# both as plain name text. `given`/`family` move in the same parse -# because the remainder is wholly Han and takes the family-first -# reading (#271), and `middle` empties because that is where 2.0 held -# the marker. -# -# The regex is the marker itself, so the rule can claim nothing that -# does not contain it. Note what is NOT here: '山田 花子(旧姓 佐藤)' -# is in this run as a bare {given, family} order flip, classified by -# the first rule. #329 is gated on a non-empty Policy.maiden_delimiters -# and the harness runs the default policy, where () is a #273 -# NICKNAME delimiter -- so no #329 diff can appear at all, and adding a -# rule for one would be an allowlist entry covering nothing. -name_regex = "旧姓" -fields = ["given", "middle", "family", "maiden"] - -[[change]] -issue = "fix(#272) nakaguro inside delimited content renders as a space, compounding with the CJK order flip" -# '山田 太郎 (マイケル・ジャクソン)': the nakaguro is a token separator -# now, and rendering follows it, so the extracted nickname comes back -# 'マイケル ジャクソン' where 2.0 echoed the dot -- while the wholly-Han -# remainder takes the family-first flip in given/family. Neither -# single-change rule may claim the union: the first rule's fields -# exclude `nickname` on purpose, so a lone nickname regression stays -# loud. -# -# Both lookaheads are required. A delimiter alone matches every -# parenthesized nickname in the Latin corpus and would let this rule -# absorb a bare given/family regression on one; the nakaguro alone is -# already in the first rule's territory. Requiring both confines the -# rule to the one shape it explains. No `middle`: this diff has none, -# and the 1.4 ledger's twin rule reserves that field for the fullwidth -# pair, which under the default policy cannot produce a 2.1 diff. -name_regex = "(?s)(?=.*[・・])(?=.*[((「『])" -fields = ["given", "family", "nickname"] - -[[change]] -issue = "fix(#298) 间隔号 division changes the comma reading, sending the credential from title to suffix" -# '威廉·莎士比亚, PhD': one name, two intended changes at once. #298 -# divides the transcription, so the pre-comma run is two words where -# 2.0 saw one, and the comma structure that follows from the word count -# re-reads the lone post-comma credential -- PhD moves out of `title` -# and into `suffix` -- while given/family take #298's division itself. -# Neither the first rule (whose fields exclude title/suffix, on -# purpose) nor the honorific rules (which key on vocabulary this name -# has none of) may claim the union. -# -# All three lookaheads are required. A comma alone matches every Latin -# 'Smith, Jr.' in the corpus; the interpunct alone is the Catalan punt -# volat too ('Gal·la Marcet'), which is exactly the input class #298's -# flank guard exists to protect; the classified-codepoint lookahead is -# what confines the rule to names #298 can actually reach, and its -# class is the same pinned copy of _SCRIPT_RANGES the first rule -# carries. -# -# The interpunct is written as an escape rather than as itself, alone among -# the punctuation spelled literally in this file: · (U+00B7), ・ -# (U+30FB) and ・ (U+FF65) are three different separators with three -# different rules in 2.1, and they are indistinguishable enough on -# screen that a literal here would be unreviewable. -name_regex = "(?s)(?=.*,)(?=.*\\u00B7)(?=.*[\\u3005-\\u3006\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFAFF\\uAC00-\\uD7A3\\uFF65-\\uFF9F])" -fields = ["given", "family", "title", "suffix"] - -[[change]] -issue = "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal" -# 'Berg, Jan vd'. The 2.1 ledger carries the same rule and the full -# reasoning; `vd` is in both the particle and suffix-acronym -# vocabularies, and 2.2 attaches it to the family the comma already -# named (rules.md#P6) where 2.0 read it as a post-nominal. Arrived via -# corpus_rules.jsonl (#414), which is the arrival fix(#379) below -# predicted in writing. -# -# `_ambiguities` joined the diff in #405. Checked against THIS ledger's -# own run rather than copied from the 2.1 one: the two names this rule -# explains here are the same pair -- 'Berg, Jan vd' and 'Berg, abdul -# vd' -- and each moves {family, suffix, _ambiguities}, gaining -# SUFFIX_OR_NAME. 2.0 read the trailing `vd` as a post-nominal exactly -# as 2.1 did, so the diff shape is identical at both baselines; that is -# a measurement here, not an inference from the copy above. -name_regex = "(?i)^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*\\svd\\s*$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#399) a maiden marker bounds the particle chain that swallowed it" -# 'Jane de la née Jones', 'Jane van der Berg née Jones', 'John van der -# Berg née Jones'. Same rule and same reasoning as the 2.1 ledger's -# copy: the particle chain swallowed the marker before the maiden -# consumer could see it, so the family kept both the marker and the -# maiden name. The particle requirement keeps this off 'Jane Smith née -# Jones', which is in the corpora and does not move. The anchors keep -# it off the trailing-marker shape, the connective join and the -# post-comma bound-given join -- M2's accepted limits. 'Jane van der -# Berg nee PhD' is still claimed and does not move: excluding a suffix -# tail needs a second vocabulary alternation, and the guards allow one -# per rule. See the 2.1 ledger's copy for the argument. -name_regex = "(?i)^(?![^\\n]*,)[^\\n]*\\b(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)\\b.*?\\sn[e\u00e9]e\\s+\\S+$" -fields = ["family", "maiden"] -# Scoped to the DEFAULT order: every diff this rule explains is a -# default-order one. The regex reaches 'de la Cruz n\u00e9e Vega', which -# corpus_shapes.jsonl tags under both family-first orders, and this -# rule sorts ahead of the two rules written for that name -- so -# order-blind it would absorb a family-first regression there and call -# it intentional. -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#360) mc moved into the never-given particles, so it folds into the family" -# 'Mc Donald'. A data change (6389f06), not a rule change: `mc` moved -# out of the ambiguous half of the particle vocabulary, so P1's fold -# takes it into the family and the particle-or-given fork it used to -# report stops being a fork. `_ambiguities` is claimed for that reason -# and is the v2 surface only. -name_regex = "(?i)^mc\\s+\\S+$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#411) the bound-given reserve stops counting words the maiden name takes" -dormant = "no corpus name since #601 (2026-10-04): the rules.md#M2 examples that carried this shape left the doc with the walk they illustrated. The reserve still counts only the words the clause leaves (the take runs before every join), so the rule is kept for the next name that shows it" -# 'van der Berg, abdul nee Jones': P5 reserves a name word so the join -# always leaves a family name behind, but the reserve was counted while -# the marker and the maiden name were still pieces -- group's marker -# pass runs later in the same stage. Counting words that were about to -# leave answered the question about a name that would not exist. -# -# The fields here are the SIDE EFFECT rather than the defect. #411's -# own shape is an emptied family ('abdul Berg nee Jones' read given -# 'abdul Berg', family ''), and no BASELINE shows it -- every released -# version already reads the corpus names carrying that shape the way -# the fix does, so none of them can produce a #411 diff to classify. -# ('abd Berg nee Jones' IS in corpus_rules.jsonl, and goes unexplained -# at all three baselines if the fix is reverted, which is the guard -# working.) -# -# What this corpus -# name shows is the other half: with the reserve counted correctly the -# join declines, so the marker is never merged into 'abdul nee' and -# M2's bound sees a lone marker piece for the first time. That closes -# one of the two join-swallows #412 tracks; P3's connective join is -# the one that survives. -# -# `abdul` is a LITERAL, not an alternation over BOUND_GIVEN_NAMES, and -# the words are NOT interchangeable here. 'abd' is the one shipped word -# in both the bound-given and the suffix vocabulary, so after a family -# comma a declining join leaves the S2 suffix reading instead: 'van der -# Berg, abd nee Jones' moves {given, middle, suffix, maiden}, and the -# extra field breaks the subset test, so it would arrive UNEXPLAINED -- -# correct, but not what an alternation over the vocabulary would lead -# an author to expect. 'abdel', 'abdal' and the Arabic spelling do -# behave like 'abdul'; only this name is in the corpora. -name_regex = "(?i),\\s*abdul\\s+n[eé]e\\b" -fields = ["given", "middle", "maiden"] - -[[change]] -issue = "fix(#400) abd joins the word after it as one given name" -# 'abd Allah Smith' and 'Salam, abd Allah'. `abd` joined -# BOUND_GIVEN_NAMES so the spellings writing the article separately -# join like `abdul` already did. Both openings move, hence the -# alternative anchors; `abd` is also the postnominal ABD, which the -# anchors keep out. -# -# A third corpus name is claimed and does NOT move: a bare 'abd Allah', -# which has no third word for the join to leave as a family name, so -# P5's reserve declines and it reads given 'abd', family 'Allah' at -# every baseline. Same behaviour family as the two that move, so the -# claim is defensible -- recorded so it is not mistaken for coverage. -name_regex = "(?i)(?:^|,\\s*)abd\\s+\\S+" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#367) a title no longer displaces a leading never-given particle" -# 'Sir de Mesnil'. The never-given half of the rule below, which named -# this very name as a future arrival. Separate rather than widened -# because the guards allow one vocabulary alternation per rule, and -# the fields differ for a real reason: a never-given particle folds -# into the family under P1, emptying the given side, where the -# ambiguous half leaves a family behind. -name_regex = "(?i)^sir\\s+de\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#272/#308) nakaguro division and a glued hangul honorific in one name" -# '马丁·路德·金씨': two 2.1 behaviours meeting in one name, which is why -# neither of the single-cause rules above claims it. The nakaguro -# divides the name into given/middle/family (#272/#298) AND the glued -# 씨 peels off into `suffix` (#308), so all four roles move at once -# where each rule alone accounts for a subset. -# -# Written as literals -- a nakaguro anywhere in a spaceless name that -# ends in 씨 -- rather than as an alternation over the honorific -# vocabulary, which would need a _HONORIFIC_SOURCES declaration for a -# rule that is about the COMBINATION rather than about the vocabulary. -# It claims exactly the one corpus name; another combination name -# should arrive UNEXPLAINED and be read once. -name_regex = "^\\S*\u00b7\\S*\uc528$" -fields = ["given", "middle", "family", "suffix"] - -[[change]] -issue = "fix(#369) a given-name title licenses the bound given-name join with one word to spare" -# 'Sheik abdul salam': rules.md#P5 -- "or after a given-name title, -# which asserts that a given name follows (H1): there the two words -# join and the name has no family". Given 'abdul', family 'salam' -# becomes given 'abdul salam', family '' -- the empty family H1 -# accepts for "Sir John". The 'Sheik abdul salam Jr' rules example -# is the same move with the suffix standing; 'Sheik abdul Jr Smith' -# is in reach and does not move -- the licence takes a name word, -# not a suffix. -# -# 'Sheik Abu Bakar', the #367 regression this issue was filed for, -# reads given 'Abu Bakar' again with every field byte-identical to -# this baseline, so no rule names its fields: restored, not moved. -name_regex = "(?i)^sheik\\s+abdul\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#401) the bound-given reserve counts the trailing numeral assign reads as the suffix" -# 'abdul Smith V', 'abdul Smith Jr V': rules.md#P5 -- "A trailing roman -# numeral, or a bare acronym the run takes, or a trailing title word the -# run takes, is no word to spare". given 'abdul Smith', family '' -> -# given 'abdul', family 'Smith'; the suffix was already read at this -# baseline, which is why the 1.4.0 rule lists three fields and this -# one two. Both are rules.md examples; no differential corpus name -# has the shape, and the 'abdul Smith V Ph. D.' spelling stays out of -# the corpus (the 1.4.0 ledger's [[never]] shape). -name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?v$" -fields = ["given", "family"] - -# #436/#437 compounding with the bound-given reserve, one name. -# 'abdul Smith Jr V' already diffs {family, given} at this baseline -# under fix(#401) above, whose `fields` are those two alone -- so -# once the render moves as well, the diff is {family, given, suffix} -# and no longer a subset of anything fix(#401) declares. Two -# mechanisms, one name: the reserve decides the ROLES and #436 -# decides the SEPARATOR, and the ledger's idiom for that is a rule -# naming both (the cjk-*-compound rules are the precedent). AFTER -# fix(#401) in file order, because its `fields` are a strict subset -# of these and the reverse arrangement is an undeclared contest -# (#382). fix(#401) keeps 'abdul Smith V', which still diffs on the -# two roles alone, so it neither goes dormant nor over-declares. -[[change]] -issue = "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve" -name_regex = "(?i)^abdul\\s+smith\\s+jr\\s+v$" -fields = ["given", "family", "suffix"] - -[[change]] -issue = "fix(#421) the bound-given join never absorbs a suffix piece" -# 'abdul Jr Smith Berg': rules.md#P5 -- "the join never absorbs a -# marker standing as a word of its own -- a marker is not a name word -# (M2) -- nor a word of the unambiguous suffix vocabulary (S2), -# wherever position will then place it". given 'abdul Jr', middle -# 'Smith' -> given 'abdul', middle 'Jr Smith'. Parity with this -# baseline until now; a -# rules.md example, since no differential corpus name has the shape. -name_regex = "(?i)^abdul\\s+jr\\s+smith\\s+berg$" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#421) the bound-given join never absorbs a split credential" -# 'abdul Ph. D. Smith Berg': the rule above, on the split credential -# -- a 2.0 regression: given 'Smith', suffix 'abdul Ph. D.' -> given -# 'abdul', middle 'Smith', suffix 'Ph. D.', the suffix field 1.4.0's -# again (that ledger carries the given's own story). A rules.md -# example; no differential corpus name has the shape. -name_regex = "(?i)^abdul\\s+ph\\.\\s+d\\.\\s+smith\\s+berg$" -fields = ["given", "middle", "suffix"] - -[[change]] -issue = "fix(#425/#436/#437) the bound-given reserve runs assign's peel over the joined view" -# 'abdul Smith Jr Ma', 'abdul Smith Ma': P5 as it landed tried the join -# on the pieces as it would leave them and read the trailing peel over -# that, a word the peel read as a suffix unjoined having to read so -# joined. Since #614 rules.md#P5 reads the run first -- "the trailing -# suffix run (S2) and the trailing title run (H5), each read over what -# the other leaves until neither takes anything more, are read once, -# before the join is tried" -- and the #289 note below records how -# 'abdul Smith Ma' has read since 2026-09-18. given 'abdul Smith', -# family '' -> given 'abdul', family 'Smith' (the acronym and the suffix -# peel as they do for 'John Smith Jr Ma'); given 'abdul Smith', family -# 'Ma' -> given 'abdul', family 'Smith', suffix 'Ma' (the join would -# have turned a credential into the family). Both 1.4.0 parity, so the -# 1.4.0 ledger has no twin. Rules.md examples; no differential corpus -# name has either shape. -# -# 2026-09-18 (#289): the sentence above -- "Both 1.4.0 parity, so the -# 1.4.0 ledger has no twin" -- stops being true, and this rule's own -# diff shrinks at the same time. The surname lean (rules.md#S2) never -# lets the peel take a Title-case 'Ma', so the acronym is a name word -# to spare and the join fires: 'abdul Smith Ma' reads given 'abdul -# Smith', family 'Ma' -- which is what THIS baseline reads, so the -# name stops diffing here altogether -- and 'abdul Smith Jr Ma' reads -# given 'abdul Smith', middle 'Jr', family 'Ma', where v1 read first -# 'abdul', last 'Smith' and the acronym as a suffix in both. The -# 1.4.0 ledger gains its twin, inside the fix(#289) rule at the -# bottom of that file. `Jr` as a middle name is an ACCEPTED cost: the -# surname lean breaks the peel at 'Ma', so the suffix behind it is -# never reached. `fields` is narrowed to the union the run measures -# here -- `given` no longer moves on the one name left diffing, and -# a role nothing moves is a standing claim (#452). -# 2026-09-27, #544: the paragraph above is true again, `given` included. -# The company clause (rules.md#S2) reads the 'Ma' behind 'Jr' as the -# credential, so the peel takes both and the reserve declines the join: -# 'abdul Smith Jr Ma' reads given 'abdul', family 'Smith', suffix 'Jr -# Ma', the diff here {given, family, suffix}, and `middle` moves on -# nothing any more. `fields` follows the run. -# Of that diff, {given, family} is #425's -- 2.2.0 and 2.3.0 read those -# roles too -- and `suffix` is R1's spacing alone, the 'Jr, Ma' this -# baseline wrote (#436/#437). #544 moves nothing on it against this -# baseline: it takes back a reading the 2.4 cycle had broken. One rule -# has to explain the whole of the diff, so the label is joint, as the -# 1.4.0 ledger's fix(#274/#436/#437) rules are. -name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?ma$" -fields = ["given", "family", "suffix"] - -[[change]] -issue = "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle, the P4 example" -# 'Xyz. van Johnson': the rules.md#P4 example of the 'Esq. van Gogh' -# rule of this change (beside the fix(#367) rules, further down), -# same reading and fields. Separate rather than an alternation, per -# the guards' one-alternation-per-rule roster. -name_regex = "(?i)^xyz\\.\\s+van\\s+johnson$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#424) a title-led chain before the numeral is the one name piece" -# 'Freiherr von Richthofen V': rules.md#P2 boundary -- the chain stops -# before the numeral, and the chain is then the one name piece, which -# reads as 'Dr. Smith V' reads. The baseline read family 'von -# Richthofen V'; #424 split the numeral off as the suffix, and #410 -# then moved the one name piece behind the title out of `given`, so -# the reading today is family 'von Richthofen', suffix 'V' -- and -# 'Dr. Smith V' moved with it, so the comparison still holds. A -# rules.md example. -# -# #410 narrowed this diff rather than adding one: {given, family, -# suffix} before it, {family, suffix} after. `fields` went on -# declaring the wider set, and because the match is a subset test the -# rule kept claiming the name as its diff shrank -- a real change of -# reading crossing a green gate with nothing to name it. The -# declaration now matches the diff, so `given` moving on this name -# again would be unexplained rather than absorbed. The general form: -# a `fields` list wider than the diff it explains silently absorbs -# whatever else lands inside it. -name_regex = "(?i)^freiherr\\s+von\\s+richthofen\\s+v$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424) accepted: a particle of the suffix vocabulary opening the trailing run is a suffix piece" -# 'John Smith Mc V': rules.md#P2 Accepted -- "where it opens the -# trailing run the chain stops before it as before any suffix word, -# and the peel takes it". middle 'Smith', family 'Mc V' -> family -# 'Smith', suffix 'Mc, V'. A rules.md example; the class was found -# unrecorded by the second docs review of #424. -name_regex = "(?i)^john\\s+smith\\s+mc\\s+v$" -fields = ["middle", "family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(comma-family) a comma followed only by titles keeps the given/family split" -# 'Bob Jones, author' / 'Bob Jones, compositeur': the comma is followed -# by nothing but a title word, which said nothing about where the -# family name ends, so the pre-comma name keeps its positional read -- -# family 'Bob Jones' -> given 'Bob', family 'Jones', the title staying -# where it was. The FAMILY_COMMA rule that segment 0 is wholly the -# family rests on the writer having marked where it ends; a comma -# followed only by titles marked nothing ('John Smith, Mr.' is 'Mr. -# John Smith' with the honorific moved). -name_regex = "(?i)^bob\\s+jones,\\s*[a-z]+$" -fields = ["given", "family"] - -[[change]] -issue = "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example" -# 'John Smith, Mr.' and 'John Smith, Mr. Jr.': the rules.md#C1 examples -# of the rule above (family 'John Smith' -> given 'John', family -# 'Smith'; title and suffix stay where they were). Its own literal rather than -# an alternation with 'Bob Jones', which would be no vocabulary. -name_regex = "(?i)^john\\s+smith,\\s*mr\\.?(\\s+jr\\.?|\\s+and\\s+mrs\\.?)?$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#296) a dropped prenominal takes the name position it occupies" -# 'Do Quang Minh': 'do' left TITLES -- a postnominal (D.O.) and a -# Vietnamese surname, never a prenominal -- so a leading 'Do' is no -# longer peeled as a title and falls through to the positional read: -# title 'Do', given 'Quang' -> given 'Do', middle 'Quang'. The -# 2026-07-30 audit table's disposition; 'phd', 'jr', 'junior' and -# 'se' left with it, and no corpus name leads with a bare one (the -# three that lead with 'Jr.' are read by the period inference and do -# not move). The rule's second name is 'MD, DO, DDS': title 'DO' -> -# given 'DO', the comma anchor's one corpus hit. `family` -# is deliberately OUT of the field list: the family name must not -# move on this rule, so a regression there stays loud. The anchors -# are the two openings a name has (start, or after a family comma). -name_regex = "(?i)(^|,\\s*)do\\b" -fields = ["title", "given", "middle", "_ambiguities"] - -[[change]] -issue = "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word" -# 'dr Vincent van Gogh dr' with its #100 siblings (and, at 2.x only, -# 'Smith Dr', which 1.4.0 already read as the family): 'dr' left -# SUFFIX_WORDS, where it was v1 residue -- 'Dr.' is not a postnominal -# in any tradition -- so a trailing bare 'Dr' is no longer suffix -# vocabulary and falls to the positional read, taking the family name -# with it. -# -# 2026-09-08: 'John Smith Dr.' has LEFT this rule, and the sentence -# that used to close this comment has been retracted with it. It read -# "NOT a new class: no trailing title-only word routes to title on -# the no-comma path ('John Smith Prof.' reads family 'Prof.' at every -# baseline) ... #316 is the open question for the class". #316 is -# answered: a trailing PERIOD-MARKED listed title word now routes to -# title, so 'John Smith Dr.' moves {title, suffix} against this -# baseline and {title, middle, family} against 2.2.0, neither of them -# a subset of the three roles below, and the bundle rule `fix(#316) a -# trailing period-marked title word reads as a title` at the end of -# this file claims it. What stays here is the BARE spelling, which -# #316(b) leaves open: 'Smith Dr' and the 'dr ... dr' names wear no -# abbreviation period and the trailing walk never sees them. This -# rule is narrower than that one and stands first, so the DIFF -# decides which of the two claims a name, not file order. -# -# `middle` left the field list in the same edit: it was there for -# 'John Smith Dr.', whose middle name moved, and the three names the -# rule still explains move `family` and `suffix` alone. The gate said -# so itself -- OVER-DECLARED, "classify() matches by SUBSET, so the -# excess is not inert" (#452). -name_regex = "(?i)\\sdr\\.?$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#296) a credential-only comma string reads a name and its postnominal" -# 'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials. -# v1 read the trailing one as a title and the leading one as a first -# name; with 'phd' out of TITLES the comma structure reads it as a -# one-word name plus a postnominal -- a reading of a string nobody -# writes as a name, and the honest one for it. -# -# `given` and `family` are OUT here: by 2.0.0 the comma-family rule -# has already moved the pre-comma word from `first` to `last`, so -# both names diff {title, suffix} alone at this baseline -- checked -# against the released 2.0.0 and the tree directly, 'Jr., PhD' reads -# last 'Jr.' on both sides and only title 'PhD' -> suffix 'PhD' moves. -# The 1.4.0 copy of this same rule keeps `given`/`family`: there the -# pre-comma word is still v1's `first`, so the same names diff all -# four roles at that baseline and the wider declaration is exercised, -# not excess. classify() admits by subset, so leaving the two roles -# out here is not passive -- a future diff on either name that grows -# to move `given` or `family` falls out of this rule instead of being -# silently absorbed; measured against the narrowed ledger, no other -# rule claims a {given, family, title, suffix} diff on these two names -# either, so it would arrive unclassified rather than pre-claimed -# (#452). -name_regex = "(?i)^[a-z]{2,3}\\.?,\\s*phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) a lone post-comma credential is a suffix" -# 'Smith, Jr.' / 'Kim, Jr.' / 'Smith, PhD': rules.md#C1 -- the listing -# form holds with one word before the comma, and the word after it is -# the credential run. v1 put the family in `first` and the credential -# in `title`; 2.0 routed the pre-comma word to the family (the -# comma-family rule below) but the title peel still claimed the -# credential -- 'jr' and 'phd' were title vocabulary, and the -# period-abbreviation inference reads 'Jr.' as a title regardless -- -# so the postnominal position is read first now: a post-comma run -# that is nothing but suffix pieces is suffixes. A one-word name, a -# comma, one short word: the reach is pinned by the guards. -# -# `given` and `family` are OUT here for the same reason as the -# credential-only rule above: the comma-family move already landed by -# 2.0.0, so 'Smith, Jr.' reads last 'Smith' on both the 2.0.0 release -# and the tree and diffs {title, suffix} alone -- checked directly. -# The 1.4.0 copy of this same rule keeps `given`/`family` because -# there the pre-comma word is still moving `first` -> `last`, the -# larger diff that baseline actually produces. classify() admits by -# subset, so a future diff here that grows to move `given` or `family` -# falls out of this rule instead of being silently absorbed; measured -# against the narrowed ledger, nothing else in this file claims a -# {given, family, title, suffix} diff on these names, so it would -# arrive unclassified (#452). -name_regex = "(?i)^[a-z]+,\\s*[a-z]{2,6}\\.?$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) a glued honorific before a lone credential: the credential is the postnominal" -# '田中さん, PhD': 'phd' left TITLES, so the lone post-comma credential -# is the suffix it is; the glued-honorific peel is #312's (2.1), so at -# this baseline both halves move: title 'PhD', family '田中さん' -> -# family '田中', suffix 'さん, PhD'. -name_regex = "(?i)^田中さん,\\s*phd$" -fields = ["title", "family", "suffix"] - -[[change]] -issue = "fix(#296) do is a name, so it no longer stops the leading-particle scan as a title" -# 'Dr. Do Van Johnson, MD': the fix(#367) title-and-particle rule that -# explained this name is gone with 'do' from TITLES -- a word in both -# the title and particle vocabularies stopped the transparency scan -# and stayed a title piece (master, after #367/#413, read title 'Dr. -# Do'); 'do' is a particle and a name now, and reads as the given -# name. At this baseline: given 'Do Van Johnson' -> given 'Do', -# family 'Van Johnson'; the title 'Dr.' is unchanged here. -name_regex = "(?i)^dr\\.?\\s+do\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#296) dr is not postnominal vocabulary, so 'John Smith, Dr.' keeps its split and its title" -# 'John Smith, Dr.' / 'John, Smith, Dr.': with 'dr' out of the suffix -# sets the post-comma 'Dr.' is a title only, so the comma is followed -# by nothing but titles and the pre-comma name keeps its split (the -# no-name-word repair): suffix 'Dr.' -> title 'Dr.' for the two-part -# spelling. The three-part spelling read suffix 'Dr.' (a third part -# was suffix by position) and moved only by gaining the -# COMMA_STRUCTURE report C2 gives a third part that is not suffix -# words, until #603 (2026-10-04) made a title word there a title: -# rules.md#C2, "a word of the title vocabulary there that is not also -# suffix vocabulary is a title". It now moves suffix 'Dr.' -> title -# 'Dr.' and no report, the same {title, suffix} as the two-part -# spelling, so `_ambiguities` left this rule's fields that day. -# -# The two-part spelling is ALSO compared under FAMILY_FIRST, as shape -# 4 of corpus_shapes.jsonl: the declared order applies to the -# pre-comma name on both sides, so the split does not move and the -# diff is the same {title, suffix} one from the same cause. That is -# why the name lists twice in this rule's block, once order-tagged -- -# and why this rule's `orders` names exactly those two: unlike the -# four rules at the foot of this file it genuinely explains the -# default-order diff too, and unlike an order-blind rule it stops -# there. FAMILY_FIRST_GIVEN_LAST is not listed because no shape tags -# this string under it, so a diff arriving there would be one nobody -# has looked at. -name_regex = "(?i)^(john,?\\s+smith|ortega\\s+y\\s+gasset),\\s*dr\\.?$" -fields = ["title", "suffix"] -orders = ["DEFAULT", "FAMILY_FIRST"] - -[[change]] -issue = "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes" -# 'Smith, Ph. D. Jr.' and the #325 rows it leads ('Smith, Ph. D. MD', -# 'Smith, Ph. D. III', 'Smith, Ph. D. Jr. MD', 'Smith, Ph.D. Jr.', and -# 'Smith, PhD Jr.', which the regex reaches too -- title 'PhD' at -# every baseline, suffix 'PhD Jr.' now; this line read -# 'PhD, Jr.' and had been stale since #429 made the run one -# entry, corrected 2026-09-06): -# with one word before the comma the name is the listing form, and -# the space-split 'Ph. D.' followed by another suffix was no longer -# the LONE post-comma piece, so it fell through to the given name -- -# a 1.4.0 regression (v1 read suffix 'Ph. D.', title 'Jr.'). The -# whole run is suffixes now. Literal rather than the shape: a shape -# regex absorbed 'Smith, Dr. Jr.', which is the comma-family rule's -# (the comment review). -# 2026-09-27, #544: 'Smith, PhD MEng' is reached too, as 'Smith, PhD -# Jr.' is -- the unambiguous PhD anchors the MEng behind it (rules.md#S2's -# company clause), so the run after the one-word family comma collapses -# whole into `suffix` where the tree before #544 read given 'PhD', -# middle 'MEng'. -# 2026-09-28: the regex still reaches 'Smith, PhD MEng', and now 'Smith, -# PhD Ma', but explains neither: each reports the pick its member is, -# and this rule declares no `_ambiguities`. Both are the -# fix(#296/#544) rule's at the foot of this file. -name_regex = "(?i)^smith,\\s*ph\\.?\\s?d\\.?\\s" -fields = ["title", "given", "suffix"] - -[[change]] -issue = "fix(#325) a credential run across a second comma reads as suffixes" -# 'Smith, Jr., PhD': title 'Jr.', suffix 'PhD' -> suffix 'Jr., PhD'; -# segments 2+ compose with the run. -name_regex = "(?i)^smith,\\s*jr\\.?,\\s*phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) an ambiguous acronym counts as a suffix only when written with its periods" -# 'Jack Ma.': rules.md#S2 -- "written with its periods, one after each -# letter"; a single trailing period is the abbreviation shape any -# word can wear. The gate was "any period" until #296's review, so -# suffix 'Ma.' -> family 'Ma.', which is how 1.4.0 read it. -name_regex = "(?i)^jack\\s+ma\\.$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424) the particle chain stops before the trailing numeral" -# 'John van der Berg V': rules.md#P2 -- "a trailing suffix begins -- the -# trailing run S2 reads, once, before the chain is made [...] a trailing -# roman numeral, or a bare acronym with words to spare, ends the chain -# as a suffix word does". last 'van der Berg V' -> family 'van der -# Berg', suffix 'V', as 'John Smith V' reads. Shipped since 1.x: the -# chain's stop asked with the suffix-piece test, whose initial veto does -# not see a bare V. A rules.md example; no differential corpus name has -# the shape. The fork's report arrives with the reading. -name_regex = "(?i)^john\\s+van\\s+der\\s+berg\\s+v$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424) the particle chain stops before a bare acronym with words to spare" -# 'John van der Berg Ma': the rule above, on S2's other fork. family -# 'van der Berg Ma' -> family 'van der Berg', suffix 'Ma', as 'John -# Smith Ma' reads -- and as 1.4.0 read it, so this rule has no 1.4.0 -# twin: a 2.0 regression. A rules.md example; no differential corpus -# name has the shape. -# -# 2026-09-18 (#289): the MIXED-CASE spelling stops diffing here, and -# the rule survives on the one-case one. The surname lean -# (rules.md#S2) never lets the peel take a Title-case 'Ma', so -# 'John van der Berg Ma' reads family 'van der Berg Ma' again -- -# which is exactly what this baseline reads. What the chain does when -# the acronym IS taken is unchanged, and the one-case spelling -# 'john van der berg ma' shows it: no case contrast, the count -# decides, the chain stops before the acronym and the name reads -# family 'van der berg', suffix 'ma'. That spelling entered the -# corpus with rules.md#P2's own contrast pair in 2.4, which is why -# this rule went briefly dormant and then woke in the same commit. -# The case-insensitive regex was already written to reach both. -name_regex = "(?i)^john\\s+van\\s+der\\s+berg\\s+ma$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424/#445) the maiden walk stops before the trailing numeral, and the lone name word is the family" -# 'John née Jones Smith V': rules.md#M2 -- "It takes them up to the -# trailing run of post-nominals and titles that the end of the name -# reads as if the clause were not written", and 'John V' reads the V as -# the suffix. maiden 'Jones Smith V' -> maiden 'Jones Smith', suffix -# 'V'. The walk's stop asked with the suffix-piece test too. No 1.4.0 -# twin: v1 had no maiden support, and the fix(#274) rule carries the -# name there. A rules.md example; no differential corpus name has the -# shape. -# -# Renamed for #445 (2026-08-27) rather than widened in place, because -# the diff has two causes now and one rule has to explain the whole -# of it. The walk leaves a single name word, and rules.md#M4 makes -# that word the family: given 'John' -> family 'John' joins the three -# fields above. Attributing that to #424 would be false, and leaving -# the declaration at three fields left this rule explaining nothing -# while the name went UNEXPLAINED -- which is how the gate reported -# it before this edit, at 2.0.0 and 2.1.0 alike. No 1.4.0 twin to -# rename: the fix(#445) rule there carries this name. -name_regex = "(?i)^john\\s+n[ée]e\\s+jones\\s+smith\\s+v$" -fields = ["given", "family", "suffix", "maiden", "_ambiguities"] - -[[change]] -issue = "fix(#369) the bound given-name join takes a particle-and-bound word, so no fork is reported" -# 'Sheik Abu Bakar': the fields are byte-identical to this baseline -# (the rule above); what moves is the REPORT. Through 2.1 the particle -# chain took 'Bakar' and reported PARTICLE_OR_GIVEN. The join that -# takes it now is P5's, licensed by the given-name title, and a bound -# word read as the bound word is not a fork. -name_regex = "(?i)^sheik\\s+abu\\b" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#360) ste moved into the never-given particles with mc" -# 'Ste Marie': the second word of the vocabulary move the fix(#360) -# rule explains for `mc`, and that rule named it -- "'Ste Marie', -# 'Los Santos' and 'Das Silva' all move the same way ... The others -# should arrive UNEXPLAINED and be read once." One has. A sibling -# rather than an alternation: the guards allow one vocabulary -# alternation per rule, and the other moved words are in no corpus. -name_regex = "(?i)^ste\\s+\\S+$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#399) a maiden marker bounds the particle chain: the geb. spelling" -# 'Ursula von der Leyen geb. Albrecht': #399's own headline name, which -# reached the corpus through the backtick harvest (#413) -- it sat in -# that issue's TITLE while #399 shipped noting the class was invisible -# to this harness. A sibling of the fix(#399) rule rather than a -# widened marker slot: making that a vocabulary alternation would be a -# second one, and the guards allow one per rule. The particle -# alternation is identical to that rule's, deliberately -- both resolve -# to the same roster key and must cover the same set. -name_regex = "(?i)^(?![^\\n]*,)[^\\n]*\\b(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)\\b.*?\\sgeb\\..?\\s+\\S+$" -fields = ["family", "maiden"] - -[[change]] -issue = "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle" -# 'Esq. van Gogh' (corpus_issues): rules.md#P4 -- "An unlisted -# abbreviation before the particle is as transparent as a listed -# title, since assign reads it as one (H2)". family 'van Gogh' -> -# given 'van', family 'Gogh', with the PARTICLE_OR_GIVEN report, as -# 'Dr. van Gogh' reads since #367. #367 keyed the leading-particle -# exception on the first piece of the name by group's title test, -# which does not see H2's abbreviations; the scan asks assign's test -# now. Literal for the reason the #367 rule gives. -name_regex = "(?i)^esq\\.\\s+van\\s+gogh$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#367) a title no longer displaces a leading particle out of the leading position" -# 'Mr. Van Nguyen': a leading particle deliberately does not chain -- -# that is what makes 'Van Nguyen' read as given Van, family Nguyen. -# Through 2.1 the exception was keyed on piece index 0, so a TITLE -# displaced the particle and the chain fired, giving family -# 'Van Nguyen' (v1's reading, which is why this name is parity with -# 1.4.0 until now). 2.2 keys it on the first piece of the NAME, so a -# titled name reads as the same text reads untitled. -# -# The wider class is any title followed by a particle -- 'Dr. Van -# Johnson', 'Sir de Mesnil', 'Sheik Abu Bakar' all move (the last restored -# by #369) -- and this -# regex names one title and one particle because 'Mr. Van Nguyen' is -# the one name in the corpora that moves. Deliberately narrower than -# the class: a corpus name that joins it later should arrive -# UNEXPLAINED and be read once, not be absorbed silently. Widen the -# literals then, and record the new count. -# -# Three joined it through #413's backtick harvest: 'Dr. Van Johnson', -# 'Sir Van Johnson', 'Jr. Van Johnson'. The title slot is an -# alternation over TITLES, which all four spellings copy; `jr` reads -# as a title because it is in TITLES and leads, which 1.4.0 already -# did. -# `\.?` earns its place now the literals are spelled out. -# -# The title slot is a LITERAL for that reason and not by accident. -# Nothing in a regex over the raw string can ask whether a word is a -# title, so the `(?i)^\S+\.?\s+van\b` this rule shipped with matched -# ANY first word: it reached 11 corpus names and explained 1. The -# other ten include 'Vincent van Gogh', 'VINCENT VAN GOGH' and -# 'Alex van Johnson' -- the "Van Johnson" family AGENTS.md names as a -# standard regression canary. name_regex rules sort ahead of -# fields-only ones and `fields` matches by subset, so a future -# regression on any of those would have been labelled fix(#367) and -# exited 0 -- a strict loss at this baseline, whose ledger was empty -# before. (`\.?` was inert into the bargain: `\S+` is greedy, so it -# had already taken the period.) -# -# `fields` is what carries the rest of the tightness -- and it is narrower -# than the change rather than a description of it. The change moves -# `title` as well: a word in BOTH vocabularies stops the transparency -# scan and stays a title piece instead of being chained onto the name, -# so 'Dr. St John Smith' goes from title 'Dr.', family 'St John Smith' -# to title 'Dr. St', given 'John', family 'Smith'. It moves `maiden` -# as well: un-chaining lets a marker standing behind the particle be -# seen at all, so 'Mr. Van Johnson nee Brown' goes from family -# 'Van Johnson nee Brown' with no maiden name to given 'Van', family -# 'Johnson', maiden 'Brown'. `suffix` and `nickname` are the two roles -# that genuinely never move. -# -# `fields` names the two this rule's diffs DO move and no others, so -# `title`, `maiden` and `middle` are all left out on purpose, for the -# reason the regex names only `van`. It is `fields` that keeps the -# wider shapes out rather than the regex: `^(mr|dr|sir)\.?\s+van\b` -# has no end anchor and does reach 'Mr. Van Johnson nee Brown'. A diff -# growing into one of the three left out falls out of this rule -- -# measured, {given, family, middle} on any of the three names it -# claims is claimed by nothing and arrives unclassified, to be read -# once rather than have a widened rule pre-built for it (#452). -# -# The 1.4 ledger carries the same rule, where it also has a second job: -# there the diff was already being absorbed by that file's fields-only -# fix(suffix-routing), a rule about a trailing credential whose -# ["given", "family", "suffix"] is a superset of this diff's fields. No -# fields-only rule exists here, so this file's copy is the plain -# classification. -# -# The paragraph above already argues `middle` out alongside `title` -# and `maiden`; what follows is the measurement behind it, kept -# because the names are worth naming: the diff on the three names this -# rule -# actually claims here -- 'Mr. Van Nguyen', 'Dr. Van Johnson', 'Sir Van -# Johnson' -- is {given, family} alone at this baseline, checked -# directly against the released 2.0.0 and the tree (none of the three -# has a third name piece, so `middle` reads empty on both sides). -# classify() admits by subset, so leaving it out is not passive: a -# diff that grows to move `middle` falls out of this rule instead of -# being silently absorbed. Measured against the narrowed ledger, -# nothing else in this file claims a {given, family, middle} diff on -# any of the three names either, so it would arrive unclassified and -# be read once rather than have a widened rule pre-built for it (#452). -name_regex = "(?i)^(mr|dr|sir)\\.?\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#367) an inferred title no longer displaces a leading particle either" -# 'Jr. Van Johnson': the same change as the rule above, for a leading -# piece the period-abbreviation inference reads as a title (H2). It -# sat in that rule's alternation as 'jr' while 'jr' was title -# vocabulary; #296's audit took it out (a postnominal only), so the -# member would now reach a word the vocabulary does not ship, and the -# shape gets its literal. The reading is unchanged: 'Jr.' is still a -# title by shape, and Van is still the leading name piece. -# -# `middle` was declared here too, the same excess the 'Mr./Dr./Sir' -# rule above drops and for the same reason: this rule's one name, -# 'Jr. Van Johnson', diffs {given, family} alone at this baseline, -# checked directly against the released 2.0.0 and the tree. classify() -# admits by subset, so leaving `middle` out means a diff that grows to -# include it falls out of this rule instead of being silently -# absorbed. Measured against the narrowed ledger, nothing else claims -# a {given, family, middle} diff on this rule's name either, so it -# arrives unclassified and is read once rather than have a widened -# rule pre-built for it (#452). -name_regex = "(?i)^jr\\.\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#379) a tussenvoegsel after a family comma attaches to the family" -# 'Vega, Juan de la': the Dutch alphabetized listing moves the -# tussenvoegsel behind the given name -- "Beethoven, Ludwig van" is how -# "Ludwig van Beethoven" is filed -- and 1.4 read the trailing particle -# run as a middle name. 2.2 attaches it to the family already named by -# the comma and renders it before the base, so `middle` empties and -# `family` grows. rules.md#P6 states it; the derived views move with it -# (family_particles 'de la', family_base 'Vega'). -# -# Fields are {middle, family}: the given name does not move. `suffix` -# is deliberately NOT here, though the #380 half of the change does -# move a token out of it ('Berg, Jan vd' was suffix 'vd' at every -# baseline and is now family 'vd Berg'). Adding `suffix` to claim it -# costs three shapes this rule has no business explaining -- -# {family,suffix} taken from fix(#380) above, and {middle,suffix} and -# {middle,family,suffix} taken from UNEXPLAINED, the last two being -# diffs in which `family` does not move at all. A rule that pre-claims -# shapes it has never seen is the #372 failure mode. -# -# 'Berg, Jan vd' is a corpus name, and it did not arrive unexplained -# here: fix(#380) above already claims {family, suffix} for exactly -# this shape, in this same ledger, so there was never a gap for a -# future rule to fill at this baseline the way there was at 1.4.0's. -# The two sentences this replaces -- "No corpus name exercises that -# shape" and "If a vd-shaped name ever enters the corpus it should -# arrive unexplained" -- were copied from the 1.4 ledger and were -# false here from the day fix(#380) was written above them (#451). -# -# Latin-anchored over the WHOLE name, not just the trailing word: with -# a leading .* this absorbed non-Latin comma names whose diff has -# another cause -- a Cyrillic 'Smith, de la' moves {family} because the -# particle's RENDERING ORDER inside the family changed, which no rule -# in this ledger names, and it classified here. (No such name is in -# any corpus, so this is what WOULD happen. It would now arrive -# UNEXPLAINED and block the gate until someone read it, which is the -# right outcome: this ledger has no fields-only rule to absorb it. -# The claim that it "falls to the fields-only catch-all" was copied -# from the 1.4 ledger, where one existed until #451 deleted it.) -# The class is the -# neighbours' and compare.py's own: U+0000-U+024F, which stops at -# Latin Extended-B, so a diacritic-bearing 'Nguyen, Van de la' in NFD -# or Latin Extended Additional is outside it. -# -# What the rule still over-claims is vocabulary, not script: any Latin -# comma name ending in an alternation member is claimed whatever the -# naming tradition, so a future {middle,family} regression on -# 'Nguyen, Van' -- one of the two corpus names it reaches, and the one -# that does NOT move -- would classify as intended. -# -# `_ambiguities` joined the diff in #405. Measured on THIS ledger's own -# run: the same seven names classify here as at 2.1, and they split the -# same way -- five move {middle, family, _ambiguities} ('Vega, Juan de -# la', 'Beethoven, Ludwig van', 'Berg, Jan van der', 'der, y van', -# 'Nguyen, Thi Van', each gaining PARTICLE_OR_GIVEN) and two move -# {middle, family} alone ('Jong, Anke de', 'Jong, Piet de', whose `de` -# declines no reading and so reports nothing). The identical split is -# what a run at this baseline shows, not what the 2.1 rule says: 2.0 -# read the trailing run as a middle name as 2.1 did, and no ambiguity -# surface changed between them. The union over the seven is the three -# fields declared. -name_regex = "(?ai)^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*\\s(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)(\\s+(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu))*\\s*$" -fields = ["middle", "family", "_ambiguities"] - -[[change]] -issue = "fix(#412) a connective join no longer absorbs the maiden marker beside it" -# 'Jane van der Berg née y Jones' and 'Jane née and Jones Smith': the -# last of the join-swallows. rules.md#M2 takes a marker standing as a -# word of its own, and rules.md#P3's connective join ran first in the -# grouping stage and merged the marker into a multi-word piece, so the -# bound never saw one. The marker pass now runs before every join -# (#418 is the same order defect in P3's word count), so the marker is -# taken and the words after it, connective included, are the maiden -# name: family 'van der Berg', maiden 'y Jones'; given 'Jane', maiden -# 'and Jones Smith'. -# -# Checked against this baseline's own run: 2.0.0 reads both names the -# way 2.1.0 does, so the same two arrive UNEXPLAINED here. The -# expected_since_2.1.0.toml copy carries the fuller account. Since -# #445 (2026-08-27) this rule explains only 'Jane van der Berg née y -# Jones'; the other name is left with one name word once the marker -# takes the rest, so its diff gained `given` and it has a rule of its -# own at the end of this file, as it does at 2.1.0. -# -# `middle` is OUT: checked directly against the released 2.0.0 and the -# tree, the one name this rule now explains diffs {family, maiden} -# alone -- `given` 'Jane' and `middle` (empty on both sides) are -# unmoved; only `last` shrinks from 'van der Berg née y Jones' to -# 'van der Berg' and `maiden` fills with 'y Jones'. classify() admits -# by subset, so declaring `middle` here was not inert: a diff that -# grows to move it falls out of this rule instead of being silently -# claimed. Measured against the narrowed ledger, nothing else in this -# file claims a {family, maiden, middle} diff on this name either, so -# it would arrive unclassified (#452). -name_regex = "(?i)\\bn[eé]e\\s+(y|and)\\b" -fields = ["family", "maiden"] - -[[change]] -issue = "fix(#418) the connective carve-out counts the name the maiden clause leaves behind" -# 'Juan y Garcia née Jones': rules.md#P3's three-word carve-out -# counted the marker and the maiden name, so the connective joined -# and the family was empty once the clause left. Checked against this -# baseline's own run -- 2.0.0 reads it as 2.1.0 did, given 'Juan y -# Garcia' with no family. The expected_since_2.1.0.toml copy carries -# the fuller account. -name_regex = "(?i)^\\S+\\s+[ye]\\s+\\S+\\s+n[eé]e\\s+\\S+$" -fields = ["given", "middle", "family"] - -[[change]] -issue = "fix(#418) accepted: a suffix word inside the maiden name ends it, connective or not" -# 'Jane née Jr y Jones': the accepted cost of the marker-pass reorder -# (rules.md#M2's Accepted row). Checked against this baseline's own -# run -- 2.0.0 reads it as 2.1.0 does, maiden 'Jr y Jones'. The -# expected_since_2.1.0.toml copy carries the fuller account. -name_regex = "(?i)^jane\\s+n[eé]e\\s+jr\\s+y\\s+jones$" -fields = ["middle", "family", "maiden"] - -[[change]] -issue = "fix(#429) a wholly-credential segment after a one-word family renders as one entry" -# 'Smith, MD PhD': one word before the comma and nothing but -# credentials after it. The baseline read the leading 'MD' as a title -# and left suffix 'PhD'; the segment is the credential run rules.md#C1 -# describes and now lands whole in `suffix`. -# -# The roles moved in #325; what #429 adds is the RENDER. #325 shipped -# them as 'MD, PhD' -- a comma the writer never typed -- because group -# decided "one comma segment is one suffix entry" by segment INDEX -# while assign decided the same segment by CONTENT. group asked -# assign's own predicate from #429, so the run renders as written -# and this path agrees with the full-name 'John Smith, MD PhD', -# which has rendered 'MD PhD' since 1.4.0. (2026-09-06, #436: the -# render is post_rules' now -- it reads the commas the writer typed -# rather than any predicate about segment content -- and 'MD PhD' -# here is unchanged by that move.) -# -# The name entered the corpus as the rules.md#C1 example that fix -# added, so this entry arrives with it rather than explaining a -# name that was already there. `family` and `given` are deliberately -# OUT of the field list: neither moves on this rule, so a regression -# in either stays loud. Literal-anchored -- the class has no other -# corpus name, and a harvested one should arrive UNEXPLAINED and be -# read once rather than absorbed here. -name_regex = "(?i)^smith,\\s*md\\s+phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#430) a credential run does not end at the roman numeral describing it" -# 'Smith, PSM I' and 'Smith, PSM I.': the baseline read given 'PSM', -# suffix 'I' -- the run ended at the numeral because rules.md#S2's -# initial veto keeps a numeral out of a suffix reading, so the segment -# did not look like a credential run and fell to the given-and-suffix -# walk. Behind a SUFFIX the numeral is describing that suffix ('PSM I' -# is Professional Scrum Master level I), so the run continues through -# it, period included -- an initial in that position is no shape anyone -# writes. The full-name 'John Smith, PSM I' has read it this way all -# along; this brings the one-word family into line. -# -# `family` is deliberately OUT of the field list: the family name does -# not move on this rule, so a regression there stays loud. Anchored to -# the two example spellings -- the class has no other corpus name, and -# a harvested one should arrive UNEXPLAINED and be read once. -name_regex = "(?i)^smith,\\s*psm\\s+i\\.?$" -fields = ["given", "suffix"] - -[[change]] -issue = "fix(#432) a dotted numeral behind a name is a middle initial, not the generation" -# 'Smith, John V.': the baseline read suffix 'V.'. Behind a NAME word -# the period is decisive -- it marks an abbreviation, and an -# abbreviation is name material -- so the numeral is the middle initial -# it looks like. 'Smith, John B.' has always read middle 'B.'; the only -# thing that made V. differ is that V is also suffix vocabulary. -# -# The BARE form is the boundary and does not move: 'Smith, John V' is -# still suffix 'V' (v1 parity, #144), and it is a corpus name, so that -# half is asserted by the gate rather than by this entry's silence. -# `given` and `family` are out of the field list; neither moves. -name_regex = "(?i)^smith,\\s*john\\s+v\\.$" -fields = ["middle", "suffix"] - -[[change]] -issue = "fix(#410) a title and one name word name the family, whatever annotation stands beside it" -# rules.md#H1 reads a title plus exactly one name word as the family -# ('Mr. Johnson' -> family 'Johnson'). The guard implementing it also -# declined whenever ANY token carried a suffix, nickname or maiden -# role, so the very same name with an annotation beside it kept the -# word in `given` and reported no family at all. None of the three is -# a name word, so the term went, and the four names below move -# `given` -> `family` to read as their un-annotated twins already did. -# -# Keyed on the four names by literal rather than by shape. The shape -# is "a title, one name word, and an annotation" -- a parse tree, not -# a string -- and a fields-only rule standing in for it would classify -# every future given/family swap in this corpus. -# -# `fields` is exactly the pair that moves: no annotation changes hands -# here, which is the whole point of the fix, so a diff in `nickname`, -# `maiden` or `suffix` on these names stays loud. -# -# Four names are classified here, out of five that move. The one left -# out is 'Freiherr von Richthofen V', which the fix(#424) rule below -# has claimed all along; #410 shrinks its diff (the name piece stops -# moving through `given`) without shrinking it out of that rule's -# fields, which is why no run ever mentioned it. Left where it is -# deliberately -- the classification is right, only the counting was -# wrong. -# -# These four are therefore the names of this shape that nothing else -# explains, not every name of this shape: two with a nickname, one with -# a maiden clause, one where the nickname leads. That fifth name is -# also the suffix flavor, so the corpus does carry one; it is the bare -# 'Dr. Smith PhD' spelling that appears nowhere, and -# tests/v2/cases.py::suffix_stays_suffix_title pins that. -# -# The change is in the 2.2 cycle, so 2.0.0 sees the same four diffs -# 2.1.0 does. The RULE is shared verbatim with that ledger -- -# identical name_regex and fields, identical _CORPUS_CLAIMS digest -- -# and was checked against this baseline's own run rather than assumed -# from it. These comments are not shared: each ledger says what its -# own baseline sees. -name_regex = "(?i)^(?:Senator \"Rick\" Edmonds|Xyz\\. \\(Bud\\) Smith|Dr\\. Smith n[ée]e Jones|'Smitty' Dr\\. Jones)$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#434) a multi-word maiden marker takes the maiden name" -# 'Maria Kowalska z domu Nowak': maiden_markers gained PHRASE entries -# and the Polish "z domu" is the first one any shipped set holds, so -# rules.md#M2's take fires on a marker that spans two tokens. Every -# earlier version read the marker as name text -- middle 'Kowalska z -# domu', family 'Nowak' -- because neither z nor domu is a marker -# alone and nothing looked at the pair. -# -# `fields` is the three the diff moves and no more. The marker leaves -# the name, so `middle` empties and `family` moves back one word, -# which is why this cannot share a rule with the bracketed name below: -# there the clause changes role and the name words around it do not -# move at all. One rule over both would have to declare the union and -# would pre-excuse a middle/family regression on the bracketed -# spelling -- the #444 failure the fix(#335) rules above spell out. -# -# Keyed on the literal, like those rules and for their reason: the -# shape is "a marker phrase inside a name", a question about the -# parse, and a fields-only rule standing in for it would pre-excuse -# every future middle/family/maiden confusion in the corpus. -name_regex = "(?i)^Maria Kowalska z domu Nowak$" -fields = ["middle", "family", "maiden"] - -[[change]] -issue = "fix(#434) a multi-word marker leads a bracketed clause to the maiden name" -# 'Maria Kowalska (z domu Nowak)': the same vocabulary change at the -# other site. rules.md#M3 reads a marker-led clause as the maiden name -# whatever pair encloses it, and #335 gave it that reach in 2.2 -- but -# the reach alone leaves this name a nickname, since M3 asks whether -# the content opens with a recognized marker and through 2.1 no -# vocabulary answered yes here. The phrase entry is what moves it, so -# it is #434's diff and not #335's, and it is deliberately NOT one -# more alternative among that rule's literals. -# -# `fields` is the pair that moves. The clause changes role; the name -# words keep their readings, so a `given`, `middle` or `family` -# movement here is a regression and stays loud. -name_regex = "(?i)^Maria Kowalska \\(z domu Nowak\\)$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led bracketed clause reads as the maiden name whatever pair encloses it" -# 'Jane Smith (née Jones)', 'Jane (née Jones) Smith', 'Jane Smith -# (née V)', 'Anna Müller (geb. Schmidt)', 'Ursula von der Leyen -# (geb. Albrecht)': rules.md#M3 reads a bracketed clause whose content -# opens with a marker word and carries a word after it as the maiden -# name, whichever bucket the enclosing pair sits in. 2.0 read the -# parenthesis pair as a nickname delimiter and looked no further, so -# the clause came back a nickname. -# -# `fields` is exactly the pair that moves. The five names here are -# Latin and the clause changes ROLE and nothing else in them, so a -# `given`, `middle` or `family` movement on any of them is a -# regression and stays loud. -name_regex = "(?i)^(?:Jane Smith \\(n[ée]e Jones\\)|Jane \\(n[ée]e Jones\\) Smith|Jane Smith \\(n[ée]e V\\)|Anna Müller \\(geb\\. Schmidt\\)|Ursula von der Leyen \\(geb\\. Albrecht\\))$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led bracketed clause reads as the maiden name, compounding with the CJK order flip" -# '山田 花子(旧姓 佐藤)', and its own rule rather than a sixth -# alternative in the one above, for the reason the CJK rules in this -# file keep restating: it moves FOUR fields where the five Latin names -# move two. 2.0 read the fullwidth clause as a nickname and the -# wholly-Han remainder given-first, so consuming the clause flips -# `given` and `family` (#271) on top of the role change. -# -# One rule holding all six would have to declare the union, and the -# union is a superset for five of them -- a given/family regression on -# any Latin name here would then pass unreported. That is the shape PR -# #444 found behind fix(#424) and had to narrow, and it is why this is -# split rather than widened. -# -# The literal is the whole regex: no shape test can separate "the -# fullwidth clause is a maiden clause now" from "the fullwidth clause -# is a nickname", which is the distinction the fields above turn on. -# -# '佐藤 花子 "旧姓・田中" (旧姓 鈴木)' joined on 2026-10-12 (#653): its -# paren clause is the marker-led one, and the wholly-Han remainder -# flips given/family the same way. Its quoted clause stays a nickname, -# moving inside that field too -- '旧姓・田中' to '旧姓 田中', #272's -# nakaguro -- which is a field this rule already declares. -name_regex = "^(?:山田 花子(旧姓 佐藤)|佐藤 花子 \"旧姓・田中\" \\(旧姓 鈴木\\))$" -fields = ["given", "family", "nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led clause leaves the one name word its bare reading" -# 'Smith (née Jones)', which rules.md#N3 carries as an Accepted line. -# rules.md#N3: "A name that is only a nickname and one name word reads -# that word as the family name", and a marker-led clause is not a -# nickname clause, so N3 no longer reaches this shape: the word keeps -# the reading the bare "Smith née Jones" gives it -- `given` when this -# rule was written, and `family` since #445 gave the bare spelling -# the same reading from the other side. -# -# Its own rule rather than a sixth alternative in the fix(#335) rule -# above: one rule holding both would have to declare the union of two -# different diffs and would pre-excuse whatever else landed inside -# it. (The two now move the same two fields, but for different -# reasons -- that rule's names keep a family name throughout, while -# this one's gained one back from #445 -- and merging them would -# re-declare `given` over both.) -# -# The revisit note this block carried was answered on 2026-08-27 and -# answered WRONG, which the review round caught and this text -# records. The first answer said the diff was unchanged at this -# baseline "because the baseline is what read `given`". It did not: -# 2.0.0 and 2.1.0 both read 'Smith (née Jones)' as family 'Smith', -# nickname 'née Jones' (measured on the wheels, 2026-08-27) -- only -# the BARE spelling ever read `given`, and this rule is the -# bracketed one. So `given` is empty on both sides, `family` agrees -# again since #445, and the diff here shrank to {nickname, maiden} -# exactly as it did at 1.4.0. -# -# Narrowed to those two fields, which is what the revisit note asked -# for. Left at four, this rule stood over a two-field diff and -# classify's subset test would have explained a `given`/`family` -# regression on this name in silence -- the absorption the note was -# written to prevent, surviving the round that answered it. Verified -# after narrowing: the name is still explained, and a {given, family} -# diff on it is now UNEXPLAINED. -name_regex = "(?i)^Smith \\(n[ée]e Jones\\)$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#445) a maiden marker makes the lone name word the family" -# rules.md#M4 -- "a maiden name standing beside exactly one name word -# makes that word the family name". A maiden name is a FORMER family -# name, which only means something beside a current one, so the word -# left standing beside it is the surname the bearer uses now: -# 'Smith née Jones' read given 'Smith' with an -# empty family on this baseline and reads family 'Smith' here. -# -# Two fields and no more. The marker consumption these names also -# carry is this baseline's reading already, so `given` and `family` -# are the whole diff -- which is what makes them one rule, and what -# keeps out the two names whose diffs are compounds (the connective -# join below, the trailing numeral above). -# -# Anchored, and spelled out one name per alternative, for the reason -# M4 itself gives. The shape regex a reader would write first -- one -# word, then a marker -- also matches M4's two carve-outs -# ('abd née Jones', 'J. née Jones Smith V', both corpus names and -# neither moving), and a rule declaring `given` over those two would -# stand ready to explain the very given -> family move the carve-outs -# exist to prevent. -# -# 'Smith née Jones' and 'Smith née Jones PhD' are corpus rows this -# change itself added, being M4's own example lines. -# 'John née Jones Smith Ma' sits here rather than with an acronym -# rule of its own: this baseline already reads it maiden 'Jones Smith -# Ma', so the acronym is no part of the diff. At 1.4.0, where v1 read -# suffix 'Ma', it is the one name of this class that needs one. -# 2026-09-19, #533: `_ambiguities` joins the two, and no name -# leaves or enters. The roles are unchanged by that change -- -# 'John née Jones Smith Ma' has its Title-cased member declined -# by its own writing, exactly as the count declined it before -- -# but the fork is now CONSULTED at the end of the clause and -# REPORTED, and the gate compares `_ambiguities` as a field. The -# other five names carry no member and contribute nothing to the -# union, which is why this widening is one field and not a rule. -name_regex = "(?i)^(?:Janey n[ée]e Jones|Jane n[ée]e Jones J\\. V|Jane n[ée]e Jones Smith|John n[ée]e Jones Smith Ma|Smith n[ée]e Jones|Smith n[ée]e Jones PhD)$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#445) the lone name word beside a marker a connective join no longer absorbs" -# 'Jane née and Jones Smith', whose diff is a compound of #412 and -# #445 and so cannot sit in either's plain rule. #412 stopped the -# connective join from swallowing the marker, so the marker is taken -# and 'and Jones Smith' is the maiden name; what that leaves is one -# name word, and rules.md#M4 makes it the family. middle -# 'née and Jones', family 'Smith' -> family 'Jane', maiden -# 'and Jones Smith'. -# -# Its own rule rather than a widening of the fix(#412) rule above, -# whose regex reaches this name too: the OTHER name it claims -- the -# one it explains, 'Jane van der Berg née y Jones' -- keeps a given -# name, and one rule holding both would have to declare `given` for -# both and pre-excuse its loss where losing it would be a regression. At 1.4.0 -# the fix(#445) rule carries this name with the same four fields, v1 -# having read the marker as a middle word. -name_regex = "(?i)^Jane n[ée]e and Jones Smith$" -fields = ["given", "middle", "family", "maiden"] - -[[change]] -issue = "fix(#371) a suffix never begins a name: the Ph./D. merge declines at the head" -# 'Ph. D. Van Johnson', 'Ph. D. John Smith', 'Ph. D.', 'Ph. D., Jr.': -# rules.md#S2 -- "A suffix never OPENS THE STRING: position outranks the -# vocabulary match". The v1 fix_phd merge joined the pair wherever it -# stood, which is what made a leading credential possible at all, and -# it emptied the family doing it: 'Ph. D. Van Johnson' read given -# 'Van Johnson' with no surname. Declining at the head of segment 0 -# leaves 'Ph.' to H2's abbreviation clause (title) and 'D.' to the -# positional read. -# -# RESTORES 1.4.0 on THREE of the four, exactly, which is why this -# ledger gains a rule while expected_since_1.4.0 loses three names: -# v1 matched the pair by regex mid-string and never merged it at the -# head either, so what shipped in 2.0 was a regression rather than a -# reading. Measured -# on the released 1.4.0 wheel: 'Ph. D. Van Johnson' -> title 'Ph.', -# first 'D.', last 'Van Johnson'; 'Ph. D. John Smith' -> title 'Ph.', -# first 'D.', middle 'John', last 'Smith'; 'Ph. D.' -> title 'Ph.', -# last 'D.'; 'Ph. D., Jr.' -> title 'Ph.', first 'D.', suffix 'Jr.' -# -- and that fourth name is NOT restored: v1 read first 'D.' where -# the pre-comma run now reads family 'D.', two roles apart. The 'D.' -# relocated rather than vanished, which is why the residual diff -# rides under `fix(comma-precomma-family)` (fields given+family) and -# not under this rule. That reading predates this change. -# -# `fields` is the union over the four names and nothing wider: title -# and suffix move on all four, given on two, family on three, middle -# on one. -name_regex = "(?i)^Ph\\. D\\.( John Smith| Van Johnson|, Jr\\.)?$" -fields = ["title", "given", "middle", "family", "suffix"] - -# -- the four rules below are the shape-tagged contract corpus's -# arrival (#468): corpus_shapes.jsonl compares each tagged name under -# its shape's declared order from baseline 2.0.0 up, so these are -# comparisons no run before it made, and every one of them reads -# [order: ...] in the report. The same four stand in -# expected_since_2.1.0.toml -- both baselines predate the 2.2 cycle -# where #395 and #399 landed, and a ledger's rules are its own -# baseline's history. - -[[change]] -issue = "feat(#395) a leading never-given particle bounds the family under a declared family-first order" -# 'de la Cruz Juan Carlos', compared under FAMILY_FIRST and under -# FAMILY_FIRST_GIVEN_LAST -- one rule for both, since the fold moves -# the same three roles either way. This baseline read family 'de', -# given 'la Cruz Juan Carlos': the leading chain took the particle -# alone and everything after it was one given name. #395 lets the -# chain reach 'Cruz' THROUGH ambiguous 'la' and stop there, because a -# declared family-first order says what follows the family is not more -# surname; the leftovers then distribute by the order -- given 'Juan' -# middle 'Carlos' under FAMILY_FIRST, the reverse under -# FAMILY_FIRST_GIVEN_LAST. The DEFAULT order is untouched, which is -# why the untagged sibling case row reads family 'de la Cruz Juan -# Carlos' at every baseline: with no order declared, 'Juan Carlos' -# really can be more surname. -name_regex = "^de la Cruz Juan Carlos$" -fields = ["family", "given", "middle"] -# Scoped, and this is the rule the key exists for: the same string is -# also compared under the DEFAULT order (corpus_rules.jsonl carries -# it), where the fold must NOT fire -- and if it leaked there it would -# move these same three roles. Order-blind, this rule would absorb -# that regression and call it intentional. -orders = ["FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST"] - -[[change]] -issue = "fix(#399)/feat(#395) a consumed maiden marker leaves the family-first fold no given name" -# 'de la Cruz née Vega' under both family-first orders. Two changes -# compound: #395 bounds the family at 'Cruz' (this baseline read -# family 'de'), and #399 consumes the marker before anything can place -# it, so 'Vega' is the maiden name rather than a leftover competing -# for the given slot. What is left is no given name at all, the right -# answer for family-plus-maiden input -- and the two orders AGREE -# after it, having nothing to distribute. -# -# Its own rule rather than a widening of `fix(#399) a maiden marker -# bounds the particle chain that swallowed it` above: that rule -# already reaches this name by regex and declines it on `fields` -# (family+maiden, no `given`), and widening it would pre-excuse a lost -# given name on the names it explains today. -name_regex = "^de la Cruz née Vega$" -fields = ["family", "given", "maiden"] -# both family-first orders diff identically here, the marker having -# left them nothing to distribute; the default-order reading of this -# string is another rule's business -orders = ["FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST"] - -[[change]] -issue = "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold" -# 'de Mesnil Jean, Dr.' under FAMILY_FIRST. Two changes compound, and -# the case row carries no tag of its own (cases.py's default), so the -# rule names them: with 'dr' out of the suffix vocabulary (#296) the -# comma is followed by nothing but titles, so the pre-comma run is -# read as a name under the declared order -- and #395's fold then -# takes it. suffix 'Dr.' -> title 'Dr.', family 'de' -> 'de Mesnil', -# given 'Mesnil' middle 'Jean' -> given 'Jean'. -# -# The default-order sibling 'John Smith, Dr.' is NOT here: no -# particle, so nothing folds, and its title/suffix move rides under -# `fix(#296) ... keeps its split and its title` above, whose block now -# lists that name twice -- once tagged [order: FAMILY_FIRST]. -name_regex = "^de Mesnil Jean, Dr\\.$" -fields = ["title", "given", "middle", "family", "suffix"] -orders = ["FAMILY_FIRST"] - -[[change]] -issue = "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling" -# 'de la Cruz Juan Carlos, Dr.' under FAMILY_FIRST_GIVEN_LAST: the -# rule above's story with two leftovers instead of one, which is the -# only shape in which the two family-first orders can disagree about -# the distribution. suffix 'Dr.' -> title 'Dr.', family 'de' -> 'de la -# Cruz', given 'la Cruz Juan Carlos' -> middle 'Juan' given 'Carlos'. -# -# A separate rule rather than an alternation with the one above: these -# are two chosen names, and a literal-anchored regex per name is what -# keeps a rule from reaching a string nobody wrote down. -name_regex = "^de la Cruz Juan Carlos, Dr\\.$" -fields = ["title", "given", "middle", "family", "suffix"] -orders = ["FAMILY_FIRST_GIVEN_LAST"] - -# --------------------------------------------------------------------- -# The `_initials` rules (#484) -- see the block of the same name at the -# end of expected_since_1.4.0.toml for what the pseudo-field is. Only -# view changes AFTER this baseline are visible from it: the per-word -# grouping predates it (2.0.0) and is classified at 1.4.0 alone. -# fix(#462), below, is the 2.3 facade fix, visible from every 2.x -# baseline. - -[[change]] -issue = "fix(#385/#402) an all-particle name part initials its words (R2)" -# 'Anh Do' -> 'A. D.', 'Juan van der' -> 'J. v. d.': rules.md#R2/#R3, -# shipped in 2.2.0, decisions.md#R2. Fields identical at this -# baseline; only the view moved. The same literal list as the 1.4.0 -# rule, for the same reason. 'nguyen van lac' is in it since -# 2026-09-25 and explains nothing here: its report moves at this -# baseline, and the derived view enters a diff only where every -# role and every ambiguity kind agree (#484), so the list stays one -# list without claiming anything new. -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#360) los joined the particles, so it no longer initials" -# 'de los Santos': 'l. S.' -> 'S.' once #360 (2.2) made 'los' a -# particle. One name, literal. -name_regex = "(?i)^de los santos$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" -# 'Scott E. Werner', 'Amy E Maid', 'Jose E. Maria Santos': -# HumanName.initials() dropped a dotted or bare-capital E/Y in the -# middle or family group because the 2.0 facade tested conjunction -# membership alone, where 1.4.0's is_conjunction also required "NOT -# is_an_initial". 'S. W.' -> 'S. E. W.', restoring 1.4.0; the core's -# initials() was already right. Fields identical -- the parse never -# read E as a conjunction, only the facade's render did -- so this is -# `_initials` alone (#484). The shape: a bare capital E or Y, or a -# dotted E./Y. of either case, standing as its own word. Lowercase -# bare e/y is the connective and does not move. The regex reaches 22 -# corpus names -- 18 until #383/#479's case rows and rules.md#P3 -# examples arrived on 2026-09-13 -- of which 14 diffed when the rule -# was written (2026-09-02); _CORPUS_CLAIMS in -# tests/v2/test_ledger_guards.py pins that reach as _Claim(22, ...) -# and fails if it moves, so the REACH digit is checked rather than -# remembered. The 14 is not: it is a -# dated snapshot of what diffed that day and nothing re-runs it. -# The names it reaches and does not move are -# what the gap IS, and the invariant behind it is a GROUP rather than -# a count: -# they carry the E/Y in the GIVEN group, which has always initialed -# every word it holds whatever the vocabulary says, so there was -# nothing for the fix to restore. -# -# 2026-09-13, #383/#479 then #528: 'JUAN Y GARCIA' diffs here on -# `_initials`, and it has had two different causes in one day. -# #383/#479 gave it two at once. The FACADE's half was this rule's -# own -- a bare capital 'Y' is initial-shaped, so the 2.3 fix kept it -# where this baseline dropped it, 'J. G.' -> 'J. Y. G.', measured -# from the wheel. The CORE's half was new: in a name written wholly -# in one case nothing marks the letter as an initial, 'y' is not -# vocabulary that reads both ways, so it is the connective and -# contributes no initial, 'J. Y. G.' -> 'J. G.' (rules.md#P3 for -# which letters are connectives and rules.md#R3 for what a connective -# contributes). #528 then made the facade read the parse's tags like -# the core, so the facade's half CLOSED: the WHEEL'S FACADE gives -# 'J. G.' and the tree's facade gives 'J. G.' again, agreeing with -# this baseline, and the core's half is the whole diff -- the wheel's -# CORE gives 'J. Y. G.', which is the half that still moves. Measured -# 2026-09-13 on the 2.0.0, 2.1.0 and 2.2.0 wheels and on the tree. -# One rule still takes it, for the reason two halves needed one: -# `_initials` is ONE field, and this rule's regex reaches the name. -# That is also why #383/#479's section at the end of this file writes -# no competing `_initials` rule -- an equal-`fields` contest decided -# by nothing but file order. At 2.3.0, where this rule does not exist -# because the facade fix has shipped, the core's half is classified -# on its own and BOTH surfaces move together since #528; that -# ledger's comment says so. -# This is the ONE full copy: the 2.1.0 and 2.2.0 ledgers and the -# three _CORPUS_CLAIMS entries point here rather than restating it. -# -# 2026-09-20, #461: THE SURFACE FLIPPED AND THE RULE DID NOT. At -# this baseline the wheel gives facade 'J. G.' and core 'J. Y. G.' -# for 'JUAN Y GARCIA' (measured 2026-09-20 from the 2.0.0 wheel). -# Before this change only the CORE moved, 'J. Y. G.' -> 'J. G.', a -# connective contributing no initial; after it only the FACADE moves, -# 'J. G.' -> 'J. Y. G.', and the core agrees with the baseline word -# for word -- the 'Y' holds its part alone, so it initials again on -# both surfaces and the tree gives 'J. Y. G.' from each. `_initials` -# is one pseudo-field, so this rule still explains the name; what -# changed is WHICH HALF of the disagreement it is explaining, which -# is why the paragraph above reads backwards without this one. -# rules.md#R3, decisions.md#R3. -# -# The name stops diffing at 1.4.0 and at 2.3.0 under the same move, -# and the rule written for it in each of those ledgers goes: the -# 2.3.0 `fix(#383/#479) a bare capital connective in an all-upper -# name stops initialing` is deleted outright, and 1.4.0's -# `fix(#528) ...` keeps its other three names and drops this one from -# its alternation. -name_regex = "(?:^|[\\s,])(?:[EY]|[EeYy]\\.)(?=[\\s,]|$)" -fields = ["_initials"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# THE 2.3 TITLE-RUN BUNDLE: #489 (a title run addresses by its last -# title), #316 (a trailing period-marked title word reads as a -# title), the leading peel's name-word floor, and the `esq` acronym -# drop that shipped beside them. Five rules, four of them one per -# ARGUMENT rather -# than one per name, plus a fifth that is #316's argument again on -# the one name a script-classified alternation cannot hold. Over -# twenty-four corpus names. -# -# LAST in the file, and that is narrow-first rather than a -# preference: every rule already here that REACHES one of the -# twenty-four declares `fields` that are a strict subset of the bundle -# rule claiming that name, and a wide rule sitting AHEAD of a -# narrower one it shares a corpus name with is an order-decided -# contest the run refuses (#382). Measured 2026-09-08, all four -# ledgers: at 1.4.0 fix(#296)'s trailing `dr` reaches 'John Smith -# Dr.' and 'John Smith Prof. Dr.', the two comma routings reach -# 'Smith, John Prof.', and the two-token `jr` rule reaches 'Dr Jr' -# and 'Sir Jr'; at 2.0.0 and 2.1.0 only fix(#296) reaches any of -# them; at 2.2.0 none does. Not one of those rules ADMITS the diff -# it reaches -- every shape is outside its field list, measured -- -# so nothing below takes a name off a rule that was explaining it, -# and no [[change.precedes_narrower]] block is needed anywhere. -# -# `_initials` appears in none of the four field lists although the -# initials of several of the twenty-four do move. The derived view -# enters a diff only where every ROLE and every ambiguity kind -# agrees (compare.py's _RULE_FIELDS, main()'s roles-identical -# guard), and all twenty-four move roles -- so no run can produce a -# shape here carrying it, and validate_rules refuses it beside -# another field in any case. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#489) a title run addresses by its last title" -# 'Her Majesty Queen Elizabeth' and 'Dr. Sir John': the -# given_name_titles lookup was keyed on the WHOLE run, and 'her -# majesty queen' is in no vocabulary and never could be, so the one -# name word behind the run stayed the family. Both sites that ask -- -# rules.md#H1's retag and rules.md#P5's licence -- now test the whole -# run's key OR the last word's, and 'queen' and 'sir' are -# GIVEN_NAME_TITLES entries. The name word behind the run becomes the -# GIVEN name and the family empties, which is H1's Accepted outcome. -# Both roles move together and `fields` declares both. `title` is -# deliberately OUT: the run itself is unchanged, only what it -# addresses as, so a name whose TITLE moved arrives UNEXPLAINED, as -# it should. -# -# 'Reverend Mother Teresa', 'Mr Sir John', 'Xyz. Sir John' and 'Sir -# Sheikh abdul rahman' move the same way and are NOT members: none of -# them is in any corpus file, so a member for one would be a rule -# reaching nothing. -# -# An anchored alternation of the two NAMES rather than a shape. What -# selects them is a SHAPE -- a multi-word title run whose LAST word -# is a given-name title -- and a member copying GIVEN_NAME_TITLES -# would reach 'Sir John Smith' and 'Dr. Smith', which do not move. -# The members are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 2 with its digest, and -# _MUST_NOT_MATCH carries three boundaries: a ONE-word run ('Sir John -# Smith'), and two runs whose last word is title vocabulary that is -# not given-name vocabulary ('His Excellency Lord Duncan', 'Her Royal -# Highness Princess Anne' -- the open vocabulary question, #519). One -# set, identical in all four ledgers. -name_regex = "^(?:Dr\\. Sir John|Her Majesty Queen Elizabeth)$" -fields = ["family", "given"] - -[[change]] -issue = "change(suffix-acronym-collisions) esq leaves the acronym set" -# 'John Smith E.S.Q.': 'esq' left SUFFIX_ACRONYMS, where the 2019 bulk -# post-nominal import put it (af5bdab, #93) -- esquire is a -# contraction, not an initialism -- so the multi-dot spelling stops -# being credential vocabulary and falls to the positional read: given -# 'John', middle 'Smith', family 'E.S.Q.', where every release since -# 1.4.0 read family 'Smith', suffix 'E.S.Q.'. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: this is a deliberate parity break at all four baselines, and -# the entry that argues it is decisions.md#suffix-acronym-collisions -# -- there is no issue number to cite, and the release-log bullet -# carries none either. It is not NOT WANTED: that spelling is for a -# reading nobody wants, and this reading is the one the decision -# chose. -# -# 2026-09-18 (#516): this rule stopped explaining anything, and the -# reason is not a revert. The by-shape credential class reads a word -# of two or more period-separated chunks that no vocabulary claims by -# POSITION (rules.md#S3), so 'John Smith E.S.Q.' is a credential again -# with no wordlist entry behind it at all -- the reading this baseline -# has, recovered by a different route than the acronym entry gave it. -# The vocabulary decision the `change` tag records still stands and is -# still the history of that entry; what is gone is its ROLE diff. The -# name still diffs here, on the `suffix-or-name` report the positional -# reading brings with it -- `_ambiguities` is a compared surface from -# 2.0 on -- and fix(#516) classifies that, which is why this rule is -# dormant rather than explaining anything: `dormant` means "explained -# no diff", not "the name stopped moving". Only the 1.4.0 copy can say -# the diff is gone outright, that baseline comparing no ambiguities. -# Declared -# `dormant` rather than deleted, so the harness says so out loud if a -# future change takes the positional reading away again. -# -# Literal, no alternation, and exactly as wide as the diff: 'esq' -# keeps its SUFFIX_WORDS membership, so the acronym entry's only -# unique coverage was this one spelling and this one corpus name. -# _MUST_NOT_MATCH carries the three spellings that did NOT move -- -# 'John Smith Esq' (the word list still reads it), 'Esq. Smith' -# (rules.md#H2's leading inference) and 'Smith, Esq.' (the lone -# post-comma credential). -dormant = "'John Smith E.S.Q.' no longer moves any role this rule declares: #516's by-shape credential class reads the multi-dot spelling as a credential by position, with no wordlist entry, which is the reading this baseline already had. Its remaining diff here is the `suffix-or-name` report, which fix(#516) classifies. Kept because the vocabulary change it records is real history and the rule wakes if that reading is lost again" -name_regex = "^John Smith E\\.S\\.Q\\.$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#489) the title peel leaves a name word a suffix cannot be" -# 'Dr King Jr', 'Dr. King MD', 'Dr Jr' and 'Sir Jr': the leading title -# peel's only floor was "leave one piece", so it peeled 'Dr King' -# whole and left the post-nominal to be the name -- title 'Dr King', -# family 'Jr', no suffix at all. leading_titles now also refuses to -# leave a word that is nothing but suffix vocabulary standing as the -# name and gives the run's last word back (rules.md#H3, -# decisions.md#H3), so 'Dr King Jr' reads title 'Dr', family 'King', -# suffix 'Jr' -- what the comma spelling 'King, Dr Jr' always read. -# -# FOUR roles, and no single name moves all four. The two three-word -# names move `title`, `family` and `suffix`; 'Dr Jr' and 'Sir Jr' -# move `given` as well, or instead of `family`, the word given back -# standing alone with the credential peeled off it. At the 2.x -# baselines `_ambiguities` joins the list -- with the title given -# back there is no title left to make the reading H1's, so all four -# report title-or-name through rules.md#H4 -- and it is absent at -# 1.4.0, which has no ambiguity surface to compare (compare.py's -# _RULE_FIELDS). -# -# An anchored alternation of the four NAMES rather than a shape. What -# selects them is a SHAPE -- a title run with nothing but post-nominal -# pieces behind it, whose own last word is not one of those -- and a -# member copying TITLES or SUFFIX_WORDS would reach 'MD DDS', 'Jr. -# Ph. D.' and 'Marquess of Bath', every one of which the floor -# declines and every one of which is a _MUST_NOT_MATCH probe, with -# 'Dr Smith Jr' beside them for the ordinary titled name the floor -# never sees. The members are a list of names and copy no wordlist, -# which is what _NOT_A_VOCABULARY_COPY records. THREE of the four are -# corpus names, not four: 'Sir Jr' left corpus_rules.jsonl on -# 2026-09-09, when the rules.md example pinning it was dropped as a -# duplicate of 'Dr Jr' (decisions.md#H5), and it stays in the -# alternation because a name_regex names INPUTS rather than corpus -# rows. _CORPUS_CLAIMS pins the reach at 3 with its digest. One set, -# identical in all four ledgers; only the `fields` differ, as the -# paragraph above says. -name_regex = "^(?:Dr Jr|Dr King Jr|Dr\\. King MD|Sir Jr)$" -fields = ["_ambiguities", "family", "given", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing period-marked title word reads as a title" -# 'John Smith Prof.' and thirteen more: a trailing run of period-marked -# LISTED title words is peeled as a title, so the surname behind it -# stops being lost -- title 'Prof.', given 'John', family 'Smith', -# where every baseline read middle 'Smith', family 'Prof.'. A RUN, -# mirroring the leading peel: 'John Smith Prof. Dr.' gives title -# 'Prof. Dr.' and 'Dr. John Smith Prof.' gives 'Dr. Prof.' in input -# order. The family-comma segment takes the same walk, so 'Smith, -# John Prof.' agrees with the bare spelling at last. And the trailing -# title is TRANSPARENT to the post-nominal peel that follows it: -# 'John Smith Jr. Prof.' keeps suffix 'Jr.' rather than promoting the -# generational suffix to the family name, and 'John Prof. MA' reads -# family 'MA' by rules.md#S2's reserve. -# -# 2026-09-18 (#289): the last clause is retracted. S2's reserve is no -# longer what decides 'John Prof. MA': written capitals in a -# mixed-case name lean credential with nothing to spare, so the name -# reads title 'Prof.', family 'John', suffix 'MA' -- the transparency -# this rule is about is unchanged and now shows a DIFFERENT reading -# through it, which is the point of transparency. This rule keeps the -# name at every baseline where it already had it; the fix(#289) rule -# at the bottom of each file carries the names whose reading moved and -# that nothing else claims. -# -# Five roles, the union of what the sixteen move; no name moves all -# five (four is the most, 'John Smith Jr. Prof.'), and two move only -# two at every baseline -- 'Smith, John Prof.' {title, middle} and -# 'Smith Sir.' {title, family} -- with 'Mary Jane King.' moving three. -# A third joins them at 1.4.0, 2.0.0 and 2.1.0, 'John Smith Dr.' -# {title, suffix}, which moves {title, middle, family} at 2.2.0 -# instead: 'dr' was suffix vocabulary until 2.2, so the role it -# vacates differs by baseline (measured 2026-09-09 against all four -# wheels). `_ambiguities` is not among the five, and it is not -# because no name reports one: 'John Prof. MA' reports -# `suffix-or-name`, which is S2's bare-acronym reserve and not this -# rule. None of the sixteen reports `title-or-name` -- the walk -# leaves a title standing, so H4's title half never fires -- and the -# derived view cannot enter a diff here in any case, every one of the -# sixteen moving roles (compare.py's _RULE_FIELDS). -# -# TWO of the sixteen end in ' Dr.' and are also reached by -# fix(#296)'s trailing-`dr` rule above -- 'John Smith Dr.', which -# that rule used to explain, and 'John Smith Prof. Dr.'. That rule -# declares {middle, family, suffix} and neither diff fits it now, so -# both fall through to here; its comment records the handover, and -# narrow-first placement is what keeps the pair out of contest. The -# BARE trailing spelling stays with fix(#296): 'Smith Dr' and the 'dr -# ... dr' names wear no abbreviation period and the walk never sees -# them (#316(b), left open). -# -# The last two members arrived with the 2026-09-09 review of the -# docs commit, which added two rules.md examples: 'Mary Jane King.', -# the H5 Accepted clause's collision example (title 'King.', given -# 'Mary', family 'Jane', where every baseline read family 'King.' -# behind middle 'Jane'), and 'Smith Sir.', which is an H1 example. -# 'Smith Sir.' is HERE and not on the fix(#489) run rule above -# because the DIFF decides: it moves {title, family}, the walk -# taking 'Sir.' out of the name, while its given/family reading is -# what every baseline already gave ('Smith' stays the given name on -# both sides). The #489 rule declares {family, given} and does not -# reach a title role at all, so it could not claim the diff even -# placed ahead of this one. -# -# An anchored alternation of the sixteen NAMES rather than a shape. -# What selects them is a SHAPE -- period-marked, listed, trailing -- -# and a member copying TITLES would reach the BARE 'Mary Jane King' -# and 'John Smith Sir', which do not move; the period-marked -# 'Mary Jane King.' is a member precisely because it does, which is -# the line the period draws and the whole of what it draws. A member -# spelled as the shape (a trailing dotted word) would reach 'John -# Smith Xyz.', which is the boundary the leading slot's H2 inference -# is deliberately NOT given here. Those three and 'Smith, Prof.' -- -# the comma shape whose segment holds no name word and which reads -# by a different gate -- are the _MUST_NOT_MATCH probes. The members -# are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY records; _CORPUS_CLAIMS pins the reach at -# 14 with its digest. -# -# 'Andrew Perkins (Mgr.)' is spelled with \\x28 and \\x29 rather than -# the \\( and \\) the paren-bearing rules above use. Escaped or not, a -# literal parenthesis inside an alternation body hides the WHOLE -# group from test_ledger_guards._alternations -- its member pattern -# excludes '(' and ')' -- and a sixteen-member alternation should not -# be invisible to the discovery pass that demands every alternation -# declare what it copies. The two spellings match the same string. -# One set, identical in all four ledgers. -# -# 2026-09-18 (#289/#516): a SEVENTEENTH member, 'JOHN PROF. MA'. It -# arrived in the rules corpus as rules.md#H5's one-case witness that -# the suffix reading is taken over what STANDS once the title chain -# has run, not over what stood -- the reading the mixed-case spelling -# stopped demonstrating when the caps lean started deciding it. The -# name is wholly one case, so no lean fires and nothing about it is -# this arc's: what moves is exactly this rule's trailing-title chain -# ({title, family, suffix}, inside the five roles already declared), -# and it stops diffing at 2.3.0 for the same reason its sixteen -# siblings do. -name_regex = "^(?:Andrew Perkins \\x28Mgr\\.\\x29|Dr\\. John Smith Prof\\.|Dr\\. Smith Sir\\.|JOHN PROF\\. MA|John Prof\\. MA|John Smith Dr\\.|John Smith Jr\\. Prof\\.|John Smith Mr\\.|John Smith Prof\\.|John Smith Prof\\. Dr\\.|John Smith Prof\\. Jr\\.|John Smith Rev\\.|Mary Jane King\\.|Sir John Prof\\.|Smith Prof\\.|Smith Sir\\.|Smith, John Prof\\.)$" -fields = ["family", "given", "middle", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing Latin title on a native-script name is a title" -# '毛 泽东 Dr.': the rule above's walk, on the one corpus name it -# reaches that is not Latin-only. The walk runs BEFORE the positional -# read, so the pieces the script test sees are all Han and the -# family-first order stands -- this is '毛 泽东' plus a title, and -# it reads title 'Dr.', given '泽东', family '毛'. A Latin title at -# the back of a wholly-Han name is the one piece that would make the -# piece set look mixed-script, which is what the ordering of the two -# buys. -# -# ITS OWN rule rather than a seventeenth alternative above, and not by -# preference: an UNDECLARED alternation holding a script-classified -# member is claimed by the honorific pin in -# tests/v2/test_ledger_guards.py, which requires such an -# alternation's members to BE the config's CJK honorific entries -- -# and until #322/#323 taught that pin to ask whether the members FOLD -# into that vocabulary, that was every such alternation. So this -# stands alone, literal-anchored, -# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) -# with _MUST_NOT_MATCH probes for the boundary -- the same shape the -# four #436/#437 CJK rules above take, and for the same reason. -# -# Classified 2026-09-09, in the review round that fixed which title -# run H1 addresses by; the reading itself is untouched by that fix. -# The name is RADAR tier (corpus_cjk_tolerated.jsonl, tolerated since -# the 2026-09-01 CJK demotion) and it had been printing UNCLASSIFIED -# at all four gates since the trailing walk landed. Radar never -# blocks, so nothing failed -- and this bundle's convention is that -# every gate line reads `radar unclassified: 0`, which is the whole -# reason the row is here rather than left to print. -# -# FOUR roles here, and NOT the same four at every baseline, which is -# why this is the one bundle rule whose `fields` differ per ledger. -# This baseline reads as 1.4.0 does -- first '毛', last '泽东', -# suffix 'Dr.' -- so `title`, `given`, `family` and `suffix` all move. -# -# Literal and anchored, so the reach cannot widen into the other -# forty-six tolerated CJK names (twenty-nine until this bundle's -# seventeen rows joined the file). _MUST_NOT_MATCH carries three of -# them as probes: '王先生, V.' (a Latin post-nominal behind a -# comma, which the trailing walk never sees), '田中さん, Dr.' (the -# comma spelling of this very shape, routed by the segment gate) and -# '田中さん II' (a spaced Latin post-nominal that is not title -# vocabulary). -name_regex = "^毛 泽东 Dr\\.$" -fields = ["family", "given", "suffix", "title"] - -# #322/#323, LAST in the file. The parser knew ONE full stop -- -# the ASCII period -- and knew it at ONE site, the lexicon's edge -# strip. It now reads four: the ASCII period, the fullwidth '.', -# the ideographic '。' and its halfwidth '。'. A full stop glued -# AFTER a script-written word is punctuation and not part of the -# word -- invisible to the script reading and to the vocabulary, -# and left in the text on the word it arrived with -- because no -# East Asian script writes an initial or an abbreviation with a -# period. A leading stop folds away for the vocabulary lookup, -# which reads both edges; the classification fold that feeds the -# surname site reads the TRAILING edge only, so a word wearing a -# leading stop is given no script and never becomes a surname -# site: '.김민준' stays one whole word, given. The honorific peel -# is not gated by that fold -- the tail alone licenses it -- so a -# leading stop does not stop it: '.김민준씨' peels to '.김민준' -# and '씨' (rules.md's W Background, decisions.md#cjk-full-stops). -# -# The eleven names here are the ones whose diff carries a `title` -# or a `suffix` move -- nine CJK, the katakana 'マイケル.' (a #323 -# mover added 2026-09-10 in the whole-branch review, its diff being -# title-or-given), and the bracketed '(김민준.) John Smith', added -# in the review round after that, whose diff carries the `title` -# the unwrapped clause used to take. SIX MORE of the bundle's -# seventeen movers are absent on purpose, and for one reason in two -# shapes. '김. 민준', '양 지훈.' and '양. 지훈' move within {given, -# middle, family} at this baseline -- {family, given}, as it -# happens -- which the native-script CJK rule above already -# declares. The three stop-bearing FAMILY_COMMA rows -# the same review round added are the other shape: not one of the three -# produces a diff at this baseline at all, so there is nothing here -# for any rule to explain. Adding -# any of the six would only take a name off a rule that describes -# it, or claim a diff that does not exist. -# -# LAST, and at THIS baseline the two earlier rules that reach these -# names sit differently from their 1.4.0 twins. 'fix(#271/#272/#298) -# native-script CJK' reaches all eleven (its script-range scope -# catches the katakana mover, and the bracketed row's CJK clause). -# Its fields -- {_ambiguities, family, given, middle} -- are a -# STRICT SUBSET of the six declared here -- {_ambiguities, family, -# given, middle, suffix, title} -- so it is the narrower rule and -# belongs ahead, which is where it already sits (#382). -# 'fix(#308/#312/#319/#320) glued CJK honorific' reaches the two -# glued spellings ('田中さん.', '김민준씨.') with {family, given, -# suffix}, a STRICT SUBSET of the six here -- so that pair is -# nested narrow-first (#382) exactly as it stands, and reversing the -# two would owe a `precedes_narrower` block. -# -# An anchored alternation of the NAMES rather than a shape, and the -# two criteria a shape could be written on both reach names this -# rule must not claim. "An edge full stop on a word carrying a -# classified character" reaches 22 corpus names, measured -# 2026-09-10: the bundle's seventeen movers and FIVE that do not -# move ('田中さん 様.', '田中さん, 様.', '김민준 씨.', '김민준 양.', -# '김민준, 씨.'). Measured with the TOKENIZER and not a whitespace -# split -- two of the movers wear their stop before a comma -# ('김민준씨.,', '田中さん.,'), which a whitespace split does not -# see -- plus the bracketed '(김민준.) John Smith' by hand, its stop -# sitting inside the clause until rules.md#S1 unwraps it; an 18 -# recorded earlier the same day came from the split, over a corpus -# four names smaller. A character class of the four stops would be a hand -# copy of nameparser._lexicon.FULL_STOPS besides, and would reach -# the looser population of "an ASCII period anywhere in a CJK- -# bearing name" -- 20 distinct names on 21 corpus rows before this -# bundle ('Dr 김민준씨, Jr.' being the name on two of them), wider -# because it sweeps in periods sitting on LATIN tokens inside a CJK -# name ('毛 泽东 Dr.', '田中さん, V.'). None of the five or the -# twenty is moved BY THIS BUNDLE, so a shape spelling would -# reach names whose diffs belong to other rules (most of them -# do diff against the older baselines -- 19 of the twenty at -# 1.4.0) or to no rule at all; decisions.md#cjk-full-stops -# carries both counts and the recompute. The members copy no wordlist, -# which the honorific pin in tests/v2/test_ledger_guards.py DERIVES -# rather than taking on trust -- no member of this alternation folds -# into the CJK honorific vocabulary, bare or wearing a full stop; -# _CORPUS_CLAIMS pins the reach at 11 with its digest, and -# _MUST_NOT_MATCH pins the four readings the bundle left alone. One -# regex, identical at 1.4.0 and here; expected_since_2.1.0.toml and -# expected_since_2.2.0.toml carry the seventeen-member spelling -# instead, 2.1.0 being the release that shipped the hangul -# segmentation three of those names move through -- and the three -# FAMILY_COMMA rows are the rest of the difference, diffing from -# 2.1.0 on and not here. -[[change]] -issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" -name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] - -# --------------------------------------------------------------- -# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. -# Two rules where 1.4.0 needs one, and the split is the pseudo-field -# rule (#484) rather than two behaviors: from 2.0 on the v2 surface -# is compared, so the new CONJUNCTION_OR_INITIAL report enters the -# diff -- on one of the two names whose roles move ('JUAN GARCIA Y -# LOPEZ' reports nothing, nothing about 'y' having been in doubt) -# and on six more whose roles do not -- and a role move and a -# report can co-occur while a role move and `_initials` cannot. -# -# The derived view is deliberately absent from both rules. The core's -# initials() DOES move for 'john e smith', 'john e jones' and -# 'jones, john e' ('j. j.' -> 'j. e. j.'), but `_initials` enters a -# diff only where every role AND every ambiguity kind agrees (#484, -# compare.py main()), and the report moved on each of them -- so at -# this baseline those names classify on the report alone. 'JUAN Y -# GARCIA' is the one name of the population whose view moves with no -# report beside it, and at this baseline it is already claimed: -# fix(#462) reaches it and admits `_initials`. That rule's comment -# records what moved and when: the FACADE's view moved there too on -# the morning #383/#479 landed, the 2.3 facade fix not having shipped -# at 2.0.0, and #528 closed the facade's half the same day -# (2026-09-13), leaving the core's as the whole diff. A competing -# rule here would be an equal-`fields` contest decided by file order, -# which is what _CROSS_RULE_WINNERS exists to stop rather than to -# create. It is classified on its own at 2.3.0, where BOTH surfaces -# now move, and the same way. -# -# LAST in the file: every diff these two claim reported UNEXPLAINED -# on the run that preceded them (measured 2026-09-13), so no rule -# above already claims one. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#383/#479) a single-letter connective joins only on case evidence" -# The 2.x view of the 1.4.0 rule of the same name: 'jose e maria -# santos' reads given 'jose', middle 'e maria' where 2.0.0 joined, -# and 'JUAN GARCIA Y LOPEZ' reads family 'GARCIA Y LOPEZ' where -# 2.0.0's bare-Latin-capital veto gave middle 'GARCIA Y'. A name -# written wholly in one case carries no case evidence about any -# letter in it, so the vocabulary decides: 'e' is marked as reading -# both ways and reads as an initial, 'y' is not and joins. Both are -# rules.md#P3 examples; see the 2026-09-13 entry of decisions.md#P3. -# -# `_ambiguities` is in the fields here and cannot be at 1.4.0 -- the -# report is a v2 surface -- and only ONE of the two names carries it: -# 'jose e maria santos' reports conjunction-or-initial, 'JUAN GARCIA -# Y LOPEZ' reports nothing, because nothing about 'y' was ever in -# doubt. The declaration is the union of the two diffs and #452's -# over-declaration check recomputes it every run. -# -# Literal-anchored to the two names, as at 1.4.0: the class is every -# one-case name carrying a cased single-letter connective, seventeen -# corpus names, and fifteen of them do not move a role. -# _MUST_NOT_MATCH carries the mixed-case probes. -name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" -fields = ["given", "middle", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" -# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e -# jones', 'jones, john e', 'e j smith': every role is identical -- -# the letter was already read as an initial, or rules.md#P3's -# three-word carve-out keeps it a name word either way -- and what is -# new is that the CALL is reported as conjunction-or-initial, because -# a one-case name gives the reader nothing to decide it by. -# -# The initials of 'john e smith' ('j. s.' -> 'j. e. s.'), 'john e -# jones' and 'jones, john e' (both 'j. j.' -> 'j. e. j.') move as -# well, and the view is deliberately not in the fields: see the -# section comment above for why no run can produce it here. -# -# 'e and e' is NOT in the alternation although it is in the same -# population and its report moves too (it LOSES given-or-family and -# gains conjunction-or-initial, both inside `_ambiguities`). At this -# baseline `feat(#449) a lone name word reports given-or-family` -# names it literally and admits the diff, so adding it here would -# create an equal-`fields` contest that only file order decides -- -# the class `precedes_narrower` cannot express and _CROSS_RULE_WINNERS -# has to pin. The name is radar tier, so nothing is hidden by leaving -# it where it is; 2.3.0's copy of this rule does claim it, that -# ledger carrying no #449 rule. -name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289) a written case contrast decides a bare ambiguous acronym" -# A member of suffix_acronyms_ambiguous written in CAPITALS inside a -# mixed-case name is written the way a credential is written, and is -# taken as one with nothing to spare ('Jack MA' -> given 'Jack', -# suffix 'MA'); one written in any other cased form is written the way -# a surname is written and stays one even with words to spare ('John -# Smith Ma' -> middle 'Smith', family 'Ma'). The same lean reaches the -# post-comma given slot ('Smith, MA' -> family 'Smith', suffix 'MA'; -# 'Smith, Ma' keeps its given), and the comma form's words-to-spare -# test becomes a count of NAME words, which is case-blind -- so -# 'John Smith, MA', 'John Smith, Ed', 'JOHN SMITH, MA' and -# 'john smith, ma' all read the credential where the token count read -# a given name. See rules.md#S2, rules.md#C1 and rules.md#P2. -# -# A name written wholly in one case carries no contrast and does not -# move on the LEAN at all -- 'JOHN SMITH MA', 'ANH DO', 'anh van do' -# and 'Jack Ma' are _MUST_NOT_MATCH probes in -# tests/v2/test_ledger_guards.py. The one-case comma forms in the list -# above are here on the NAME-word count instead, which asks nothing -# about case; that is why they sit beside the lean's own names rather -# than in a rule of their own. -# -# Accepted costs, both recorded rather than repaired: the walk stops -# at a declined pick instead of continuing past it, so a genuine -# suffix standing in front of a name-leaning acronym is never reached -# ('abdul Smith Jr Ma' reads middle 'Jr'), and the lean ends P2's -# particle chain with no words to spare where the count would not -# ('Freiherr von Berg MA', whose own rule further up this file carries -# the reading at 1.4.0). -# -# The Title-case half is witnessed at the comma by two minimal pairs -# rather than argued: 'John Smith, MA'/'John Smith, Ma' both read the -# credential, because two NAME words before the comma decide before -# the case is looked at, and 'Smith Jr., MA'/'Smith Jr., Ma' split, -# because one name word leaves the case to decide and Title-case -# declines. 'Smith Jr., Ma' diffs here for a reading this change did -# NOT move -- the pre-comma restructure v2 has done since 2.0, at -# 1.4.0, and the fork's new report at the 2.x baselines -- while -# 'John Smith, Ma' moves on the COUNT, as its all-caps twin does, and -# has no 1.4.0 diff at all, v1 having read the credential there too. -# Both sit on this rule because the rule is what states which way -# each member of a pair goes. -# -# 2026-09-19, #531: three names join, all of them lone post-comma -# credentials this rule's own second sentence already describes. -# 'Doe, MA' and 'Doe, MA PhD' are 'Smith, MA's family-word twins and -# 'Doe, Dr. MA' is the same slot behind a title; each reads family -# 'Doe' with the credential run in `suffix` -- 'MA', and 'MA PhD' -# on the one that has a run -- where the baseline read given 'MA'. -# They -# entered the corpora with #531's control rows and are NOT #531's -# doing -- verified byte-identical at cc78c960, the #530 merge -# (2026-09-19). No role joined the field list: the three move -# {given, suffix} and, on two of them, `_ambiguities`. -# -# 2026-09-19, #531 review round: 'Doe, Mr. MA PhD' joins them. It is -# 'Doe, Dr. MA' with a credential run behind the member -- the gate -# reads the segment whole, so the member joins the run rather than -# taking the given slot -- and it moves {given, suffix}, both already -# in the field list. It entered the corpora with that round's control -# rows and is NOT its doing: byte-identical at cc78c960 (measured -# 2026-09-19). -# -# Literal-anchored to the measured movers, and ONE SET FOR THE FOUR -# 2.x LEDGERS so the rule says the same thing at each of them. The -# 1.4.0 copy differs by FOUR members now. It holds 'John Prof. MA' -# out: 1.4.0 read a trailing period-marked title as a title itself, -# so that name's diff there belongs to fix(#316)'s rule and not to -# this one. "One set for all five" was written before that carve-out -# existed and was false in these four copies from the day it did -# (2026-09-18). And it holds the three names above out for a -# different reason: at 1.4.0 their diff is already claimed by -# `fix(comma-family) lone post-comma piece routes to suffix/title, -# not first`, which stands far earlier in that file, so adding them -# there would reach a name without explaining it and would put two -# rules on one diff with nothing to arbitrate between them -# (2026-09-19). The set is literal because the class is -# wide (every mixed-case name whose trailing piece is a listed -# ambiguous acronym, plus every comma form with two name words before -# the comma) and a regex for the class would claim the one-case -# spellings that do not move. A name here that does not diff at a -# given baseline is simply one the reading already agreed with there. -# `fields` is per-baseline, the union the run at THAT baseline -# measures (#452). -# 2026-09-27, #544: 'abdul Smith Jr Ma' leaves this alternation. The -# unambiguous 'Jr' in front of 'Ma' anchors it (rules.md#S2's company -# clause), the peel takes both, and P5's reserve declines the join, so -# the accepted cost this rule recorded for it -- middle 'Jr', family -# 'Ma' -- is gone; what the name still diffs on here is claimed by the -# rule that explains it, and a literal left in this one would stand -# ready to explain that cost coming back. -# 2026-10-01, #575: eight names join, the C1 examples of that change. -# Against these baselines none of them is #575's doing: each moves -# on this rule's own count and lean. 'John van Buren, Ed', 'Ortega y -# Gasset, Ed' and 'abdul Salam, Ed' are two name words before the -# comma and flip; 'De La Cruz, Ed', 'de la Cruz, Ma' and 'Van Buren, -# Ed' are one and gain only the comma's report; 'van der Berg, MA' -# is one, and its capitals make 'MA' the credential, as 'Smith, MA'. -# These baselines already read the particle surnames as one name; -# what #575 fixed is this cycle's count, which had not. -# 'Freiherr von Berg, Ed', rules.md#C1's Accepted example for #575, -# reads as these baselines read it and gains only the comma's report. -name_regex = "^(?:Davis Royce, Ed|De La Cruz, Ed|Doe, Dr\\. MA|Doe, MA|Doe, MA PhD|Doe, Mr\\. MA PhD|Freiherr von Berg MA|Freiherr von Berg, Ed|JOHN SMITH, MA|Jack MA|Jack MA\\.|Jack Wei Ma|John Prof\\. MA|John Smith Ma|John Smith, Ed|John Smith, MA|John Smith, Ma|John de Ma|John van Buren, Ed|John van der Berg Ma|Ortega y Gasset, Ed|Smith Jr\\., MA|Smith Jr\\., Ma|Smith, MA|Van Buren, Ed|abdul Salam, Ed|abdul Smith Berg Ma|abdul Smith Ma|de la Cruz, Ma|john smith, ma|van der Berg, MA)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#516) an unlisted dotted acronym is read by position" -# A token of two or more period-separated chunks that no vocabulary -# claims joins the ambiguous credential class by SHAPE and is read by -# POSITION, exactly as a bare listed acronym is: a credential where -# the name has words to spare, a name word where it does not, either -# reading reported (rules.md#S3). Case says nothing here -- the -# periods are the evidence -- which is why 'john smith x.y.z.' moves -# with 'John Smith X.Y.Z.'. The same admission reaches the comma form, -# where the NAME-word count decides ('John Smith, X.Y.Z.' -> suffix -# 'X.Y.Z.'; 'Smith, A.B.' keeps its given and only reports). Paired -# initials ('John Smith, A.B.') are the exception to that count and -# have their own rule below (#563); 'García Márquez, G.J.R.' is the -# exception's accepted boundary, three letters reading by the count. -# -# Two names here restore a reading a PRIOR bundle's vocabulary change -# took away, by a different route than it lost it: 'John Smith E.S.Q.' -# and 'John Smith C.H.A.' fell to a bare positional read when 'esq' -# and 'cha' left SUFFIX_ACRONYMS -# (decisions.md#suffix-acronym-collisions), and the shape class reads -# them as the credentials they always were with no wordlist entry. -# At the baselines where -# that restoration lands on the baseline's own reading, only the new -# report is left and `fields` says so. -# -# The narrow half of the same change: a chunk match no longer makes a -# dotted token a suffix where EVERY matched chunk is a single ASCII -# character, which reaches only the roman numerals -- 'Jack X.Y.I.' -# moves to family for that reason and not for the shape one. -# -# The shapes the vocabulary settles are untouched and are -# _MUST_NOT_MATCH probes in tests/v2/test_ledger_guards.py: a -# whole-token match ('Jack M.A.', 'John Smith Ph.D.', 'Smith, -# A.B.C.'), a surviving chunk claim ('Doe, John Msc.Ed.'), a leading -# dotted run ('X.Y.Z. Smith', 'J.R.R. Tolkien', 'A.B. Vajpayee'), the -# single trailing period that is no shape at all ('John Smith Xyz.'), -# and a digit chunk, which the alphabetic gate refuses ('Bridge (1.4)'). -# -# Literal-anchored to the measured movers, one set for all five -# ledgers, for the reason the fix(#289) rule above gives: the class is -# a SHAPE, and a regex for the shape would claim every dotted token -# the vocabulary already answers for. -name_regex = "^(?:García Márquez, G\\.J\\.R\\.|Jack X\\.Y\\.I\\.|John Smith B\\.Tech\\.|John Smith C\\.H\\.A\\.|John Smith E\\.S\\.Q\\.|John Smith Q\\.W\\.E\\.R\\.T\\.|John Smith X\\.Y\\.Z\\.|John Smith, X\\.Y\\.Z\\.|Smith, E\\.S\\.Q\\.|john smith x\\.y\\.z\\.)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#562) a comma credential run holding a particle chain reads by the name-word count" -# rules.md#C1: "A part read as postnominal [...] is read as its words -# stand, before any join, and that reading is final: [...] no particle -# run, connective join or bound given name reaches into it" (#613, -# 2026-10-06): no particle chain reaches it, so each member's -# capitals settle it and the run reads whole as the credential run -- -# silently since #613, where #562 (2026-10-01) had read the pair by the -# count and reported the flip. The family-comma path had read the part as name text: -# this baseline read title 'PhD', given 'DO DO', family 'John -# Smith'. 'John Smith, PhD vd DO' is the same chain with a particle -# that is not a member ('vd', particle and unambiguous suffix -# vocabulary). 1.4.0 read both as suffixes, so that ledger has no -# rule. -# -# Literal. Probes: 'John Smith, PhD DO' (one particle beside no other -# stays settled and silent) and 'Smith, PhD DO DO' (one name word -# before the comma keeps the listing form) are _MUST_NOT_MATCH. -name_regex = "^(?:John Smith, PhD DO DO|John Smith, PhD vd DO)$" -fields = ["title", "given", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#563) paired initials after a comma read as the given name unless a word speaks for them" -# rules.md#C1: "Paired initials are the exception to the count" -- -# two words before the comma may be one surname ('García Márquez, -# G.J.'), so two dotted single letters alone after it stay the given -# name and report the fork, where #516 had read them as a credential. -# "Only an unambiguous suffix word in front of them that is not also -# title vocabulary, or another word the class admits by its dotted shape -# standing in the same part, makes them the credential run": 'John -# Smith, PhD X.Y.' and 'John Smith, X.Y. P.Q.' move to the run, while -# 'De La Cruz, M.J. PhD' keeps its initials, the degree standing behind -# them, and so does 'García Márquez, Ms G.J.', a title in front ("an -# unambiguous suffix word in front of them that is not also title -# vocabulary"), which moves nothing at any baseline. The second period -# is optional ('García Márquez, G.J'). Two pairs speaking only for each -# other make the run and report it behind two name words ('John Smith, -# X.Y. P.Q.'). 2026-10-01, #575: 'De La Cruz, M.J. K.L.' is no longer -# that case -- 'De La Cruz' is one name word, so the count no longer -# reaches it and it reads as 'Cruz, M.J. K.L.' does, given 'M.J.', -# suffix 'K.L.', reporting both; a listed member in front speaks for -# nothing ('García Márquez, Ed G.J.' keeps the family comma, and the -# fix(#531) rule claims what its given part's trailing slot then reads). -# Against a 2.x baseline the names whose roles match the baseline's move -# only the report, which a 1.4.0 comparison cannot see; that ledger -# lists the three role movers alone. -name_regex = "^(?:De La Cruz, M\\.J\\. K\\.L\\.|De La Cruz, M\\.J\\. PhD|García Márquez, G\\.J|García Márquez, G\\.J\\.|García Márquez, PhD G\\.J\\.|John Smith, A\\.B\\.|John Smith, A\\.B\\. Ph\\.D\\.|John Smith, PhD X\\.Y\\.|John Smith, X\\.Y\\. P\\.Q\\.)$" -fields = ["family", "given", "middle", "suffix", "title", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the ambiguous credential class reports at slots that were silent" -# Every decision at the trailing suffix slot, the post-comma given -# slot and a tail segment now reports the EXISTING SUFFIX_OR_NAME -- -# no new kind, and the report tracks the FORK BEING CONSULTED rather -# than which way it went, as the trailing slot always has. These names -# gain it with no role moving at any baseline: the fork was there all -# along and was called silently. It is the first time the comma's -# OWN decision is reported (rules.md#C1); P6's attachment fork has -# reported on a family-comma path since 2.3 ('Berg, Jan vd'). C2's -# structural flag was already the first comma-path report of a shape. -# -# 'Steven Hardman, MD, DO, DDS' goes the other way and LOSES its -# comma-structure flag: its third segment now reads as the credential -# run it is, because 'DO' leans credential in a mixed-case name. That -# is the one place this arc quiets a report rather than adding one. -# -# 2026-09-19, #531 review round: 'Doe, MA Smith' joins, and it is the -# post-comma given slot with a NAME WORD behind the member: no role -# moves at any baseline -- the member is the given name here as it -# always was -- and what is new is only the report that emitter makes. -# Its silent twin 'Doe, Dr. MA Smith' puts a title in that first piece -# instead, which the emitter reads rather than the member, and agrees -# with every baseline on every field, so it is in no ledger at all. -# -# `_ambiguities` alone, which keeps `_initials` out of these rows as -# always (#484), and the rule cannot absorb a role diff on any of -# these names for the same reason. Literal-anchored, one set for all -# the 2.x ledgers; the 1.4.0 ledger has no twin, `_ambiguities` being -# a v2 surface that cannot enter a diff below baseline 2.0. -# -# 'Steven Hardman, MD, DO, DDS' is held OUT of the set at THIS -# baseline: `fix(#296) a dropped prenominal takes the name position it -# occupies` reaches it through its own comma anchor, declares the -# three roles this rule does not, and stands first -- so the name is -# narrowed out rather than left to file order. The comma-structure -# flag it loses is a 2.2-era report in any case, and the 2.2.0 and -# 2.3.0 ledgers are where the loss is recorded. -name_regex = "^(?:Doe, MA Smith|J\\.A\\. K\\.D\\.|Jack X\\.Y\\.Z\\.|John Smith J\\.u\\.n\\.i\\.o\\.r\\.|John Smith R\\.A\\.I\\.|Royce, Ed|Smith Jr\\., A\\.B\\.|Smith, A\\.B\\.|Smith, Ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the glued CJK honorific peel reads the case lean" -# The suffix-run predicate the peel uses to decline a post-comma run -# (rules.md#W3, asking C1's own vocabulary question) now reads the -# same written-case evidence rule S2 reads, because `one_case` is -# passed to it. Before, one name written with two credentials divided -# two ways: a Kim-prefixed 김민준씨 input peeled its honorific when the -# credential was 'PhD' and did not when it was 'MA', the only -# difference being that 'MA' is an ambiguous acronym whose case lean -# the predicate was not given. -# -# Every name here is a COMPOSED form -- a Latin wrapper and a family -# comma around a CJK name -- which no East Asian writing system -# produces, so all three are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion). They are classified all the same: -# a radar diff this arc INTENDED is one a release note may be written -# from, and an unclassified one is not. -# -# `title` is in `fields` because 'Dr. 김민준씨, MA' had the whole -# string for its family at these baselines, the title included; at -# 2.0.0 and 2.1.0 'Jo김민준씨, DO' moves `title` instead of `given`, -# and the union is what a per-baseline `fields` records (#452). -name_regex = "^(?:Dr\\. 김민준씨, MA|Jo김민준씨, DO|Kim김민준씨, MA)$" -fields = ["title", "given", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# #531: THE GIVEN PART'S TRAILING SLOT. Four rules -- the thirteen -# the credential reading takes, the four the writing declines and that -# gain a REPORT alone, the one `do` spelling the capitals take from -# P6, and the two caseless comma forms on the radar tier. The decision is -# decisions.md#S2's 2026-09-18 pair; rules.md#S2, #S3, #C1 and #P6 -# carry the statements. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#531) a credential ending the given part of a family-comma listing reads as a credential" -# After a family comma, a word of the ambiguous credential class -# ending the given part is now read by the same rule the comma-less -# spelling uses for a word ending the name. The comma has already -# named the family and the first word after it is the given name, so -# the words to spare are there by construction and the count says -# nothing: the word reads as the credential unless its WRITING says -# otherwise. rules.md#S2 states it, rules.md#S3 carries the by-shape -# spelling, and decisions.md#S2 records the decision. -# -# Thirteen names, and they are the class read from every side the -# corpora reach it: the bare member ('Doe, John MA', 'Doe, John BA'); -# its one-case spellings, where the lean is inert and the POSITIONAL -# reading is not ('DOE, JOHN MA', 'doe, john ma', 'DOE, MARY JO MA'); -# the slot behind a title ('Doe, Dr. John MA') and behind an initial -# ('Doe, J. MA'); a run of two members, where the credential the -# member joins was already a suffix so `suffix` GROWS rather than -# filling ('Doe, John MA JD', 'Doe, John MA PhD', 'Doe, John PhD MA', -# 'Doe, John MA Jr'); the slot reached past a middle initial, which -# keeps its own middle ('Doe, John Q. MA', 'DOE, MARY JO MA'); and the -# by-shape member, whose lean is always None so the positional reading -# takes it outright ('Doe, John X.Y.Z.'). -# -# Do not read "thirteen role movers" off that list at every baseline: -# `fields` is the union the run at THIS baseline measures (#452), and -# a name here whose roles the baseline already agreed with contributes -# only its report. 'Doe, John BA' is that name at 2.1.0 and 2.2.0, and -# 'Doe, John MA JD' is the other end of the range, gaining TWO reports -# for its two members. -# -# `_ambiguities` is in the fields because every one of the thirteen -# gains the existing SUFFIX_OR_NAME -- no new kind, and the report -# tracks the FORK CONSULTED rather than which way it went, which is -# the rule fix(#289/#516) above already states for the other slots. -# #452's over-declaration check recomputes the union on every run. -# -# Literal-anchored for the reason the two rules above give, and it is -# sharper here: the class is a SLOT, so a regex for it would claim -# every family-comma name whose given part ends in an ambiguous -# acronym -- including the four the WRITING declines, which keep their -# name reading and have the rule below, and 'Doe, John MA Smith', -# where a name word behind the member ends the run so nothing moves -# and nothing reports. _MUST_NOT_MATCH carries both, with the `do` -# spellings P6 keeps and the third-comma-part name #144's restriction -# still owns. -# 2026-09-19, #533: TWO names join the alternation and the rule does -# not change. 'Doe, J. ba' and 'Smith nee Jones, Jane MA' are this -# slot read exactly as the thirteen above are -- a lower-case member -# ending the given part after an initial, and the same member behind -# a maiden clause that sits in the FAMILY segment, where no trailing -# rule reads the clause's own tail and the given part's slot is -# reached as usual. Both read the same way at 2f57ff21, before #533, -# measured name by name: what grew is the CORPUS, not the rule, and -# a `fix(#533)` rule for them would attribute a #531 reading to the -# wrong change. -name_regex = "^(?:DOE, JOHN MA|DOE, MARY JO MA|Doe, Dr\\. John MA|Doe, J\\. MA|Doe, J\\. ba|Doe, John BA|Doe, John MA|Doe, John MA JD|Doe, John MA Jr|Doe, John MA PhD|Doe, John PhD MA|Doe, John Q\\. MA|Doe, John X\\.Y\\.Z\\.|García Márquez, Ed G\\.J\\.|Smith nee Jones, Jane MA|doe, john ma)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) a member the writing declines keeps its name reading and reports the fork" -# The other direction of the same fork, and the whole of what these -# four names do: no role moves at any 2.x baseline and the decision -# starts being REPORTED. 'Doe, John Ma', 'Doe, John Ed' and -# 'Doe, Mary Jo Ma' carry a Title-cased member in a mixed-case name, -# which is written the way a name is written, so the member stays a -# middle name; 'Doe, John MA Ma' is a two-member run whose last member -# is Title-cased, so the walk declines there and the run stays middle -# text whole. -# -# Reporting a DECLINED fork is #530's stated rule -- the report tracks -# the fork consulted, not the lean -- and AGENTS.md's "A kind is worth -# adding only if a reader would hesitate too" is the standing -# objection it answers. It is also the only part of #531 that adds a -# report without moving a field, which is why it is a rule of its own -# rather than a widening of the one above: `fields` is -# `_ambiguities` alone, so this rule cannot absorb a role diff on any -# of the four. -# -# At 1.4.0 these same four DO move roles -- v1 read all four members -# as post-nominals -- and that divergence is carried there by -# fix(given-part-trailing-slot), whose comment records that #531 -# changed which names it holds rather than retiring it. -# -# Literal-anchored: the class is the slot's declining half, and a -# regex for it would claim the thirteen movers above. -name_regex = "^(?:Doe, John Ed|Doe, John MA Ma|Doe, John Ma|Doe, Mary Jo Ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) capitals take the do collision from the family-comma particle attachment" -# 'Doe, John DO', alone, because the word is alone in the class: `do` -# is the one ambiguous credential that is also particle vocabulary, so -# this slot and P6's attachment (rules.md#P6) want the same word. -# Derek's decision, recorded at decisions.md#S2: "CAPITALS DECIDE FOR -# `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING". An all-caps -# member in a name written in more than one case carries a positive -# credential lean, so the credential reading wins and P6 stands down -# (and since #544 so does an unambiguous credential in front of it, -# 'doe, jane v phd do'); every other spelling attaches exactly as it -# did, with P6's own `particle-or-given` and no second report. -# -# THE PAIRING IS THE ARGUMENT and the accepted cost is half of it. In -# ONE CASE the rule cannot tell 'NASCIMENTO, EDSON ARANTES DO' from -# 'SMITH, JOHN DO' and reads both as the particle -- right about the -# Brazilian record, wrong about the osteopath. Those two, and -# 'Doe, John Do', 'DOE, JOHN DO', 'doe, john do' and -# 'Nascimento, Edson Arantes do', are _MUST_NOT_MATCH probes: this -# rule has no business claiming a single one of them, and each is a -# name #531 measured to be byte-identical before and after. -# -# `fields` is per-baseline and the two halves of this file's set -# differ by which role the particle came OUT of, which is P6's own -# history: {middle, suffix} at 2.0.0 and 2.1.0, where the -# baseline read middle 'DO' because P6's family-comma attachment had -# not shipped, and {family, suffix} at 2.2.0 and 2.3.0, where it had -# and the baseline reads family 'DO Doe'. -name_regex = "^Doe, John DO$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) the trailing slot's positional reading reaches a caseless script" -# '田中, 太郎 MA' and '김, 민준 MA': middle 'MA' -> suffix 'MA', one -# report each. A caseless script answers True to the one-case test, so -# the lean is None -- and at this slot a None lean is not inert, -# because the POSITIONAL reading the lean would have overridden is -# itself the credential. "Caseless is inert" holds for the LEAN and -# not for the outcome, which is the sentence tests/v2/cases.py's two -# rows carry. -# -# Both are COMPOSED forms -- a family comma between a CJK family and a -# CJK given name, which no East Asian writing system produces -- so -# both are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion), and their diffs print under -# UNCLASSIFIED (radar) rather than blocking. They are classified all -# the same, on fix(#289/#516)'s stated ground: a radar diff an arc -# INTENDED is one a release note may be written from, and an -# unclassified one is not. -# -# Literal-anchored, and the probes are the CJK comma names that do NOT -# move at this slot: '田中 太郎, MA', '毛泽东, MA' and '마틴 킹, MA' put -# their member in the FIRST post-comma piece, which is #289's slot and -# not this one. -name_regex = "^(?:田中, 太郎 MA|김, 민준 MA)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#380) a trailing mc after a family comma is the tussenvoegsel, not a post-nominal" -# THE SAME DECISION AS THE fix(#380) RULE ABOVE, over the other word -# that carries the collision it names. 'Berg, Jan mc' entered the -# corpora with #531's case rows (2026-09-18) as the negative control -# for the `do` carve-out -- 'mc' is unambiguous suffix vocabulary AND -# a never-given particle, so P6's run claims it whatever role assign -# gave it, and #531 left it byte-identical (verified against the tree -# at cc78c960, 2026-09-19). -# -# The diff is against these two baselines only: `mc` joined the -# never-given particles in 2.2 (decisions.md#suffix-acronym- -# collisions) and P6's family-comma attachment shipped in 2.3, so -# 2.2.0 and 2.3.0 already read family 'mc Berg' and carry no rule for -# it. `_ambiguities` is in the fields because the name also gains -# #530's report at these baselines, which is #289/#516's doing and -# not this rule's subject; the rule claims the union the run measures -# (#452) and the roles are P6's. -# -# Literal, one name. A regex over the trailing word would claim every -# Mc* surname in the corpora, which is the widening the vd rule's own -# _MUST_NOT_MATCH probes already refuse. -name_regex = "^Berg, Jan mc$" -fields = ["_ambiguities", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#533) a credential ending a maiden clause reads as a credential" -# The PR review added one: 'Jane Doe nee Smith V MA', where a -# trailing roman numeral stands between the clause and the member. -# Both stops read the TRAILING word, so the numeral is not the word -# either fork asks about -- the acronym fork stops at 'MA' and the -# 'V' stays maiden text behind it. -# The words a maiden marker takes now end where a trailing credential -# begins, where the rule that reads the name left standing reads the -# word as one -- both as the name is written and as the take would -# leave it (rules.md#M2, decisions.md#M2). `suffix` fills as `maiden` -# shortens, and the fork is reported: the end of a maiden clause was -# the last trailing position in the library where a member of the -# ambiguous credential class was read in silence. -# -# Fourteen names here, three fewer than the same rule carries at -# 2.2.0 and 2.3.0, and the three are a fact about THIS baseline -# rather than about the change. 'Maria Kowalska z domu Nowak MA' and -# 'John née Jones Smith MA' move roles this baseline has never read, -# so each needs a rule that explains the whole of its diff; 'Doe, J. -# nee MA ba' moves no role from here at all and is with the reports -# below. Width is baseline-relative, and so is membership. -# -# What is left is the class read from every side the corpora reach -# it: the bare member ('Jane Doe nee Smith MA', 'Doe, Jane nee Smith -# MA'); its one-case spellings, where the lean is inert and the -# POSITIONAL reading is not ('JANE DOE NEE SMITH MA', 'JANE DOE NEE -# YO-YO MA', 'jane doe nee smith ma', 'Doe, Jane nee Smith ma'); the -# German abbreviation ('Jane Doe geb. Smith MA'); a member beside -# another credential, where `suffix` GROWS rather than filling ('Jane -# Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', 'Jane Doe nee -# Smith Ma JD' -- whose Title-cased Ma stays in the clause while the -# JD behind it leaves); past a trailing title, which the peel steps -# over ('Jane Doe nee Smith Prof. MA'); the slot reached past a -# middle initial ('Doe, Jane Q. nee Smith MA'); and the `do` -# collision, where the capitals decide ('Doe, Jane nee Smith DO' -# beside 'Jane Doe nee Smith do'). -# -# Literal-anchored for the reason fix(#531)'s rules give: the subject -# is a SLOT, so a regex for it would claim the names whose WRITING -# declines the word as readily as the ones it takes. -name_regex = "^(?:JANE DOE NEE SMITH MA|JANE DOE NEE YO-YO MA|Jane Doe geb\\. Smith MA|Jane Doe nee Smith MA|Jane Doe nee Smith MA JD|Jane Doe nee Smith MA PhD|Jane Doe nee Smith Ma JD|Jane Doe nee Smith V MA|Jane Doe nee Smith do|jane doe nee smith ma)$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) the maiden clause reports the credential it keeps" -# The PR review added more, and they are the same declining half -# reached by four further routes -- each a shape where the clause -# CANNOT promise the word a credential reading, so rules.md#M2 keeps -# it. No name word is left AHEAD of the member for a given part's -# trailing slot to be the end of ('Doe, Dr. nee Smith MA'); a -# particle chain below the marker pass would absorb it into the -# family ('Berg, Jane van der nee Smith DO', and 'Jane Doe nee Smith -# DO DO' where the other released member is what would do the -# joining); P5's bound-given join would swallow it into the pair -# ('Berg, abdul nee Jones MA'); the FIRST-WORD FLOOR spares it and -# only an unambiguous credential behind it leaves ('Jane Doe nee MA -# PhD'); and the lean declines it after a comma ('Doe, Jane nee -# Smith Do'). Those first four READ as 2f57ff21 read them and gain -# only the report: an earlier round of this branch released all four -# and moved them in silence, which is what the review measured and -# reversed. -# The other direction of the same fork, and the whole of what these -# five names do at this baseline: no role moves and the decision -# starts being REPORTED. A Title-cased member in a mixed-case name is -# written the way a name is written, so the clause keeps it ('Jane -# Doe nee Smith Ma', 'Jane Doe nee Yo-Yo Ma', 'Doe, Jane nee Smith -# Ma'); a member that is the ONLY word after the marker stays the -# maiden name whatever its writing says ('Jane Doe nee MA'); and -# 'Doe, J. nee MA ba' and 'Jane Doe nee King. ba' are the FLOOR's own -# pair, whose roles this baseline already reads the way the tree does -# -- maiden 'MA'/'King.', suffix 'ba' -- so from here the floor is -# visible only as the report it raises. The floor keeps a chained -# first word in the re-peel's count, which the parent's walk (it had -# no chain at all) never removed it from in the first place; so #535 -# moves nothing here either, and the second name's whole diff is -# #533's, exactly as the first's. -# -# Reporting a DECLINED fork is #530's stated rule -- the report -# tracks the fork consulted, not the lean -- and it is why this is a -# rule of its own rather than a widening of the one above: `fields` -# is `_ambiguities` alone, so it cannot absorb a role diff on any of -# the six. -# 2026-09-27, #544: 'Jane Doe Jr. nee Smith Ma' joins, the declining -# half at rules.md#M2's new Accepted boundary: the credential in front -# of the marker speaks for no member of the clause, the Title-case 'Ma' -# is kept, and the clause reports it. -name_regex = "^(?:Jane Doe nee King\\. ba|Jane Doe nee MA|Jane Doe nee MA PhD|Jane Doe nee Smith Ma|Jane Doe nee Yo-Yo Ma)$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#335/#533) a marker-led bracketed clause is the maiden name whatever its last word is" -# 'Jane Doe (nee Smith MA)', 'Jane Doe (nee Smith Ma)', 'Jane Doe -# (nee Smith) MA'. The role move is fix(#335)'s, one rule above: this -# baseline read the parenthesis pair as a nickname delimiter and -# looked no further. These three are separate because the FIRST TWO -# also lose a report -- the clause came back a nickname ending in a -# credential-class word, so the nickname reading raised its own -# SUFFIX_OR_NICKNAME, and consuming the clause as a maiden name -# retires it. -# -# They are here rather than as three more alternatives up there for -# the reason that rule's own comment gives about declaring a field a -# diff does not move: adding `_ambiguities` to it would let a report -# change hide on the five Latin names it already holds. -# -# What #533 does to them is NOTHING, and that is the point of -# recording them: the delimiters outrank every reading inside the -# span, so the whole span is the maiden name whatever its last word -# is and no fork is called (decisions.md#M2, rules.md#M2). 'Jane Doe -# (nee Smith) MA' is the control -- the member the writer left -# OUTSIDE the span is an ordinary trailing credential and reports. -name_regex = "^(?:Jane Doe \\(nee Smith MA\\)|Jane Doe \\(nee Smith Ma\\)|Jane Doe \\(nee Smith\\) MA)$" -fields = ["_ambiguities", "maiden", "nickname"] - -[[change]] -issue = "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position" -# 'Jane Doe nee Smith X.Y.Z.'. A by-shape member carries no writing -# convention to read -- `listed_lean` returns None where the switch -# made the token credential-SHAPED and the vocabulary did not list it -# -- so the positional reading decides alone and the clause gives the -# word up. The same trade #516 recorded at its own slot and #531 at -# the given part's, taken to agree with the comma-less 'John Doe -# X.Y.Z.' and rules.md#S3's shape rule rather than to restore -# anything. -# -# Its own rule rather than one of the movers above, because the -# ARGUMENT is different: every name up there has a writing the lean -# could have read, and this one has none. -name_regex = "^Jane Doe nee Smith X\\.Y\\.Z\\.$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name" -# 'John Smith nee Jones R.A.I.', and it is a RESTORATION rather than -# a change: this baseline reads suffix 'R.A.I.' with maiden 'Jones', -# and so does the tree, so only the report diffs here. What moved in -# between was this unreleased 2.4 cycle. At 2.3.0 the token carried -# `vocab:suffix` and the maiden walk's suffix-piece test stopped at -# it; #516 retags it `shape:acronym` plus `vocab:suffix-ambiguous`, -# it stops being a suffix piece, and the walk took it -- maiden -# 'Jones R.A.I.' on this tree at 2f57ff21, measured. The name was in -# no corpus file, so no gate ever compared it and no 2.4.0 release -# bullet records the move. -name_regex = "^John Smith nee Jones R\\.A\\.I\\.$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#296/#535) a trailing title after a dropped postnominal Dr." -# 'Jane Doe nee Prof. Dr.' moves {suffix, title} at this baseline for -# two reasons together: this baseline still has 'dr' in the suffix -# vocabulary (#296 had not run yet), so it reads suffix 'Dr.' -- -# rules.md#S2: "A trailing word of the suffix vocabulary reads as a -# suffix" -- where the parent tree d9d80492, before #535, already -# reads maiden 'Prof. Dr.' (dr having left the suffix vocabulary by -# #296, so the clause keeps it as a name word). #535 then moves that -# word on into `title`, returning maiden to 'Prof.' -- the SAME value -# this baseline reads there, which is why `maiden` itself does not -# move net baseline-to-HEAD even though it moves in between. -# Literal-anchored for the reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Prof\\. Dr\\.$" -fields = ["suffix", "title"] - -[[change]] -issue = "fix(#533/#535) a title in front of the credential the clause gives up" -# 'Jane Doe nee Smith Prof. MA' moves at this baseline for two -# reasons together: #533 already gives 'MA' up to the credential -# reading and reports the fork (`suffix='MA'`, `maiden='Smith -# Prof.'`, SUFFIX_OR_NAME -- rules.md#S2's ambiguity flag: "either -# reading carries the ambiguity flag") before #535 exists -- verified -# against the parent tree d9d80492, which already differs from this -# baseline exactly that way. #535 then reads the trailing title chain -# through the credential, so the title standing in front of 'MA' -# leaves the maiden name too (`title='Prof.'`, `maiden='Smith'`). -# Literal-anchored for the reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Smith Prof\\. MA$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -issue = "fix(#535) a trailing title ends the maiden clause" -# rules.md#M2: "It takes them up to the trailing run -# of post-nominals and titles that the end of the name reads as if -# the clause were not written" (#601; until #601 this cited the -# sentence it replaced, "a trailing title ends it too") -- the end of -# the name is read through -# the trailing title chain -- rules.md#H5: "successive single words -# that wear the abbreviation shape and are title vocabulary chain -# into the title from the end" -- so the title leaves the clause and -# the credential or numeral in front of it gets the stop it gets with -# the title absent; the pair 'Smith MA Prof.' / 'Smith Prof. MA' now -# agrees. Literal-anchored for the reason fix(#533)'s rules give: the -# subject is a SLOT, and a regex would claim the names whose view -# check keeps the title as readily as the ones that release it. Three -# names moved to their own rules above ('Jane van der Berg nee Smith -# Prof.', 'Jane Doe nee Prof. Dr.', 'Jane Doe nee Smith Prof. MA'), -# each because the parent tree d9d80492 already differed from this -# baseline before #535 touched it; the seven names here are ones -# where #535 is the sole cause, verified the same way. -name_regex = "^(?:Jane Doe \\x28nee Smith Prof\\.\\x29|Jane Doe nee Smith King\\.|Jane Doe nee Smith MA Prof\\.|Jane Doe nee Smith Ma Prof\\.|Jane Doe nee Smith Prof\\.|Jane Doe nee Smith V Prof\\.|Mary Smith née Jones Prof\\.)$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -issue = "fix(#445/#533) a one-case clause keeps the credential, and the one name word it leaves is the family" -# 'JOHN NEE JONES SMITH MA PHD'. One case, so no lean; a suffix word -# BEHIND the member ends the peel before it can reach the member, so -# the clause KEEPS the MA and only the clause's own emitter speaks. -# The role move is #445's -- the lone name word the clause leaves -# becomes the family -- and this baseline has not read it, so the -# diff is that move plus the new report. -# -# Its own rule rather than an alternative in the fix(#445) rule -# above, and case-sensitivity is the reason: that rule is (?i), and -# the mixed-case spelling of these words reads the other way. 'John -# nee Jones Smith MA PHD' gives the MA up and reads suffix 'MA PHD', -# measured 2026-09-19, so an (?i) alternative here would stand ready -# to explain a regression on a name whose reading is the opposite of -# this one's. -name_regex = "^JOHN NEE JONES SMITH MA PHD$" -fields = ["_ambiguities", "family", "given"] - -[[change]] -issue = "fix(#434/#533) a marker PHRASE takes the maiden name, and its clause ends at the credential" -# 'Maria Kowalska z domu Nowak MA'. The marker PHRASE is what moves -# the roles from here -- this baseline had no `z domu` entry, so it -# read middle 'Kowalska z domu', family 'Nowak' -- and the tree reads -# given 'Maria', family 'Kowalska', maiden 'Nowak'. #533's own effect -# on the name is invisible at this baseline: the trailing MA is a -# suffix on both sides, and both sides report it. What #533 changed -# is which words the maiden name holds, and `maiden` is already in -# the diff for the phrase's sake. -# -# Named for both changes because the maiden TEXT is the compound: at -# 2f57ff21 the same three fields moved and `maiden` read 'Nowak MA'. -# A rule naming only #434 would attribute this change's stop to the -# vocabulary addition; one naming only #533 would attribute the -# phrase to it. -name_regex = "^Maria Kowalska z domu Nowak MA$" -fields = ["family", "maiden", "middle"] - -[[change]] -issue = "fix(#424) the maiden walk stops before the trailing numeral, with the family name left standing" -# 'Jane Doe nee Smith V', and #533 has nothing to do with it: the -# name reads the same on this tree as it did at 2f57ff21, measured. -# The walk stops before the trailing roman numeral assign reads as -# the suffix, asked over the pieces as they stand and again over the -# view the take would leave -- #424's reading, stated at rules.md#M2. -# maiden 'Smith V' -> maiden 'Smith', suffix 'V', with the report -# #516 added at that slot. -# -# A rule of its own rather than an alternative in 'fix(#424/#445) the -# maiden walk stops before the trailing numeral, and the lone name -# word is the family' above: that rule's second half is the #445 move -# and this name has none -- family 'Doe' stands either way. What grew -# is the CORPUS: this change's rows put the numeral's clause shape in -# front of the gate for the first time at a baseline that can see it. -name_regex = "^Jane Doe nee Smith V$" -fields = ["_ambiguities", "maiden", "suffix"] - -# --------------------------------------------------------------- -# #397 + #461: THE CATALAN LINK JOINS, AND A CONNECTIVE INITIALS -# WHERE IT JOINS NOTHING (2026-09-20). Four rules: the join's ROLES, -# the REPORT a one-case spelling makes instead of joining, and -# #461's two halves, which are one sentence read in its two -# directions and need two rules because a rule listing `_initials` -# may list nothing else (#484). -# -# LAST in the file, and that is narrow-first rather than a -# preference: the join rule declares {family, middle, suffix} and -# every rule above it that reaches one of its five names declares a -# strict subset, so an earlier position is an order-decided contest -# the run refuses (#382). -# -# rules.md#P3 states the join and its two boundaries; rules.md#R3 -# states what a connective contributes. decisions.md#P3 carries the -# count decision and the both-sides scope, decisions.md#R3 the -# criterion and its measured blast radius. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#397) the Catalan/Polish link joins two surnames" -# 'i' is connective vocabulary since #397, and a connective counts as -# a name word toward rules.md#P3's three-word carve-out whatever else -# the vocabulary says it is -- which matters here because 'i' is also -# the roman numeral. So a four-word name reaches the threshold and the -# link joins. This baseline read 'Josep Carod i Rovira' as middle -# 'Carod i' with family 'Rovira', and 'Carod i Rovira, Josep' -- where -# the link stands after a family comma -- as family 'Carod Rovira' -# with the link taken as a GENERATION in `suffix`, which is why the -# two move different field sets and are grouped here rather than -# split. The three longer spellings move with the first: 'Josep Carod -# i Rovira III' and 'Josep Lluis Carod i Rovira' the same -# {family, middle}, and 'Josep Carod i de Rovira' the same pair with -# the link joining PAST a particle (middle 'Carod i' -> 'Carod i de', -# family 'de Rovira' -> 'Rovira'). Five names, `fields` the union of -# their two shapes. -# -# All five entered this corpus with #397's own rules.md#P3 example -# lines and case rows; before that they were radar-tier names the -# contract did not answer for, which is why a rule appears at this -# baseline only now. -# -# Literal-anchored to the five. The shape is "a single letter between -# two name words", which reaches every corpus name carrying a bare -# initial and would pre-excuse the opposite reading -- #397's whole -# accepted cost is that some such letters must NOT join. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the wall: -# 'Josep i Rovira' (three words, under the carve-out), 'Carod i' and -# 'John Quincy Smith i' (the link with nothing to its right) and the -# two 'Josep Lluis Carod i ' spellings, whose trailing -# generation run is the fix(#436/#437) rule's at the head of this -# file. -name_regex = "^(?:Carod i Rovira, Josep|Josep Carod i Rovira|Josep Carod i Rovira III|Josep Carod i de Rovira|Josep Lluis Carod i Rovira)$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#397) the Catalan link reports a connective-or-initial in a one-case name" -# 'JOSEP CAROD I ROVIRA', 'JOSEP LLUIS CAROD I ROVIRA' and 'josep -# carod i rovira'. 'i' joined `conjunctions_ambiguous` with #397, so -# in a name written wholly in one case -- where the writing says -# nothing either way -- the letter reads as an INITIAL and the call is -# reported, exactly as 'e' has read since #383/#479. #527's mechanism, -# a new letter: rules.md#P3's marked subset. -# -# ROLES DO NOT MOVE on any of the three, and that is the point of the -# marked subset rather than an accident of these spellings: reading -# the letter as an initial leaves it the middle-name word this -# baseline already had, so the whole diff is the report. Measured -# against the parent 46651750 on 2026-09-20, the same three names' -# parses are byte-identical before and after the bundle and only -# `ambiguities` moved. The lower-case spellings where the letter WAS -# the generation before -- 'rovira, i', 'john smith i jr', 'maier, amy -# i, jr.', 'josep de carod i rovira' -- do move fields under the same -# marked-subset reading, and no corpus carries one of them, so this -# rule is about the report alone. -# -# No rule at 1.4.0 and that is structural: `_ambiguities` is a v2 -# field and cannot diff against a v1 worker at all. -# -# Literal, three names. _MUST_NOT_MATCH carries the mixed-case -# spellings, where the writing decides the letter and the JOIN is what -# moved, and 'JUAN GARCIA Y LOPEZ', the unmarked letter in the same -# one-case position, which reports nothing. -name_regex = "^(?:JOSEP CAROD I ROVIRA|JOSEP LLUIS CAROD I ROVIRA|josep carod i rovira)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective holding its part alone contributes an initial" -# 16 names, each one a part whose only word is the connective, -# so there is nothing there for it to join and it initials as any -# other name word does -- which is what `family_base` and `given_base` -# have said about the same token since 2.2 (rules.md#R2/#R3). This -# baseline drops the letter; the tree keeps it. Measured from the -# 2.0.0 wheel and from the tree on 2026-09-20: 'Juan de y' 'J.' -> -# 'J. y.'; 'johnny y' 'j.' -> 'j. y.'; 'Garcia y Lopez' 'G. L.' -> -# 'G. y. L.'; 'Juan y Garcia' 'J. G.' -> 'J. y. G.' and 'juan y -# garcia' the lowercase twin; 'John y Jane' 'J. J.' -> 'J. y. J.'; -# 'Carod y de Rovira i' 'C. R.' -> 'C. y. R.'; 'John e Smith' 'J. S.' -# -> 'J. e. S.', with 'John e Smith III', 'John e Smith, III' and -# 'Smith, John e, III, Jr' giving the same pair; 'john e jones, III' -# 'j. j.' -> 'j. e. j.'; 'Jose e Maria' 'J. M.' -> 'J. e. M.'; -# 'Lt.Gov. juan e garcia' 'j. g.' -> 'j. e. g.'; and 'Fritz Freiherr -# und von Bar' 'F.' -> 'F. u.', the German spelling of the same shape. -# BOTH SURFACES move together and to the same string on every one of -# them, the two views reading one mark (#528, decisions.md#R3). -# 'محمد و علي' is the readmission RESTORING 1.4.0 parity rather than -# breaking it: 'م. ع.' here, 'م. و. ع.' in the tree, which is the -# string the 1.4.0 wheel gives. The 1.4.0 ledger's -# `feat(#269) a recognized non-Latin connective contributes no -# initial` explained that name and is DELETED in the same commit as -# this rule, the name having stopped diffing there. -# -# ONE CAUSE, measured, not two. Every name here agreed with this -# baseline's wheel BEFORE the bundle -- the parent 46651750 gives the -# wheel's string on each -- so none of the diff belongs to #383/#479's -# one-case fork or to any older change; the whole of it is #461. -# -# Literal-anchored. The shape is "a part holding one connective and -# nothing else", which no regex over the raw string can state -- the -# reading is the parse's -- and the nearest writable shape, "a name -# carrying a lone cased letter", reaches every bare-initial name in -# the corpora. _MUST_NOT_MATCH carries the wall: 'Jon Dough and' (base -# 'Dough and', a connective with a name word beside it, keeping -# 'J. D.'), 'Juan Velasquez y Garcia' (the joined run, keeping -# 'J. V. G.') and 'Juan Garcia Lopez y'. -name_regex = "^(?:Carod y de Rovira i|Fritz Freiherr und von Bar|Garcia y Lopez|John e Smith|John e Smith III|John e Smith, III|John y Jane|Jose e Maria|Juan de y|Juan y Garcia|Lt\\.Gov\\. juan e garcia|Smith, John e, III, Jr|john e jones, III|johnny y|juan y garcia|محمد و علي)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective with a name word beside it stops contributing an initial" -# 13 names, the other direction of the same sentence: where the -# part holds a name word for the connective to join, the connective is -# doing a connective's work and contributes nothing. This baseline -# initialed it; the tree does not. 'John and Jane Smith' 'J. a. J. S.' -# -> 'J. J. S.'; 'Juan y Eva Garcia' 'J. y. E. G.' -> 'J. E. G.'; -# 'Jose e Maria Santos' 'J. e. M. S.' -> 'J. M. S.'; 'and Jon Dough' -# 'a. J. D.' -> 'J. D.' and 'the and Jon Dough' 't. a. J. D.' -> -# 'J. D.'; 'Rob And Beth Edmunds' 'R. A. B. E.' -> 'R. B. E.'; the two -# 'Dean of Chemistry Robert Johns' spellings 'D. o. C. R. J.' -> -# 'D. C. R. J.'; 'John and Jane Aznar y Lopez' 'J. a. J. A. L.' -> -# 'J. J. A. L.'; 'Mr. and Mrs. John and Jane Smith' 'J. a. J. S.' -> -# 'J. J. S.'; and the three Cyrillic spellings of 'Хосе И Мария -# Сантос' 'Х. И. М. С.' -> 'Х. М. С.', lower case and upper case -# alike. Both surfaces move together and to the same string. -# -# ONE CAUSE, measured. Each of these names agreed with this -# baseline's wheel before the bundle -- the parent 46651750 gives the -# wheel's string on every one, the Cyrillic three included, whose 'и' -# has been decided vocabulary since 2.0 and reaches no case fork. So -# none of the diff is #383/#479's and the whole of it is #461. At -# 1.4.0 these names diff too, but they diffed there BEFORE this -# bundle, on the 2.0.0 per-word GROUPING, and that ledger keeps them -# on `fix(initials-per-word) a connective run initials each word` -# with a dated paragraph naming both halves. -# -# CYRILLIC SPELLED OUT, not classed. A `\p{Cyrillic}`-style span -# would declare a script this rule is not about -- the sentence is -# about a PART, in any script -- and _SPAN_BEARING_RULES in -# tests/v2/test_ledger_guards.py records that this rule declares no -# span. -# -# Literal-anchored, for the GAIN rule's reason, and with the same -# wall: 'Jon Dough and', 'Juan Velasquez y Garcia' and 'Juan Garcia -# Lopez y' are _MUST_NOT_MATCH probes here too -- the criterion is the -# PART and never a word count, which is exactly what keeps those three -# fixed. -name_regex = "^(?:Assoc Dean of Chemistry Robert Johns|Dean of Chemistry Robert Johns|John and Jane Aznar y Lopez|John and Jane Smith|Jose e Maria Santos|Juan y Eva Garcia|Mr\\. and Mrs\\. John and Jane Smith|Rob And Beth Edmunds|and Jon Dough|the and Jon Dough|ХОСЕ И МАРИЯ САНТОС|Хосе И Мария Сантос|хосе и мария сантос)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -# #397 review: A LINK INSIDE A MAIDEN CLAUSE STAYS IN THE BIRTH NAME -# (2026-09-20). LAST in the file, narrow-first: this rule declares -# {family, maiden, middle, suffix} and every rule above it that could -# reach any of the three declares a strict subset, so an earlier -# position would be an order-decided contest the run refuses (#382). -# -# rules.md#M2 states the exception; decisions.md#M2's 2026-09-20 -# bullet carries the measurement and why it is fixed in #397's own PR -# rather than filed. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#397) a link inside a maiden clause stays in the birth name" -# Three names, one sentence, three shapes -- `fields` is the union. -# -# The maiden walk has always ended the birth name at the first suffix -# WORD after the marker, and the Catalan link is also the roman -# numeral, so this baseline read maiden 'Puig' and left 'i Soler' -# outside the clause: 'Jane Doe nee Puig i Soler' middle 'Doe i', -# family 'Soler'; 'Doe, Jane nee Puig i Soler' middle 'Soler', suffix -# 'i'. That truncation PREDATES this bundle. What #397 added is -# worse and is why the stop is fixed here: with the link joining, the -# leaked words merged into the CURRENT surname -- measured on the -# parent 46651750 and on this branch's 0fbcaa0b, family 'Doe i Soler' -# and given 'Jane i Soler' respectively -- a word of the birth name -# inside the current one. -# -# The 'y' spelling is the control and never moved at any baseline: -# 'Jane Doe nee Puig y Soler' reads maiden 'Puig y Soler' here and in -# the tree, 'y' being connective vocabulary and no generation. That -# twin is the property invariant in tests/v2/test_properties.py. -# -# The THIRD name arrives from rules.md#M2 rather than from a report: -# 'Smith, John, PhD née Puig Mr. - i Soler' is one of rules.md#M2's -# delimiter examples, added with #538 (under a configured ' - ' it -# reads maiden 'Puig Mr.', by the comma reading since #549). This corpus does not configure that delimiter, -# so the gate parses it with the DEFAULT facade, where the dash is an -# ordinary name word: the link has one on each side and the clause -# keeps 'i Soler' where this baseline left it in the suffix: suffix -# 'PhD i Soler' -> 'PhD', maiden 'Puig Mr. -' -> 'Puig Mr. - i Soler' -# (measured 2026-09-22 at all four 2.x baselines, identical at each). -# That is this rule's own sentence and no part of #538 or #549, whose -# readings need the delimiter declared; {maiden, suffix} is a subset of the -# union above. -# -# Literal-anchored to the four. The shape is "a one-letter connective -# inside a maiden clause", which would stand ready to excuse every -# future regression at this walk -- and the walk's whole design is -# that some such letters must NOT be taken. _MUST_NOT_MATCH carries -# the wall: the i-last control, the generation behind it, and the -# spellings where the letter is an initial. -# -# 'Smith, John, PhD née Puig - i Soler' joined the alternation with -# #538 (2026-09-26): it is rules.md#M2's second live example, and it -# reaches the DEFAULT facade the same way its neighbour does -- no -# delimiter configured here either, so the dash is an ordinary name -# word. Measured at this baseline: suffix 'PhD i Soler' -> 'PhD', -# maiden 'Puig -' -> 'Puig - i Soler' -- the same shape as the -# neighbour's own reading with 'Mr.' dropped from it, unchanged by -# the fix; only the corpus name is new. -name_regex = "^(?:Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler|Smith, John, PhD née Puig - i Soler)$" -fields = ["family", "maiden", "middle"] -orders = ["DEFAULT"] - -[[change]] -issue = "change(suffix-acronym-collisions) ph leaves the acronym set" -# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps -# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' -# token pass the acronym test on its first piece -- the merge itself -# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working -# without it -- and its only unique coverage was a bare trailing -# 'Ph.' with no 'D.' behind it, read as a credential by every release -# since 1.4.0. Out of the set that spelling falls to the positional -# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: a deliberate 2.x parity break at every baseline, argued in -# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's -# and carried by the 2.4.0 release-log bullet. No corpus name had the -# shape until the cases.py row was written to carry it, which is why -# the removal measured as 0 role movers at review time; 'John Smith -# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. -# -# Literal to the one corpus name that carries the shape. The CLASS -# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', -# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', -# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every -# one a bare ph with no D. behind it (the decisions block lists them, -# measured 2026-09-22). A row carrying one of those arrives -# unexplained here by design: widen this rule to it, do not file it. -name_regex = "^John Smith Ph\\.$" -fields = ["family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. The rules -# below: the two names whose family name comes back, the marking's -# cost behind a full name, the same cost after a family comma, the -# lone word after a family comma (given name back, cost for the -# conventional spelling), the Title-case Lac gain that is the cost's -# mirror, and the two that keep the credential and gain a report. No -# rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. -# -# Every name here is one of #540's own shape-tagged case rows. No -# corpus name written before the change carried a bare trailing -# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at -# 120033b5), so the gate saw nothing until the rows admitted it. The -# same rules stand in the four 2.x ledgers; the 1.4.0 ledger carries -# the two cost rules and the Title-case Lac rule, 1.4.0 having read -# the other names' roles as the tree does. -# -# At landing (2026-09-25) there were eight rules here, and two more -# costs among them: the credential run behind a suffix comma re-read -# as a family comma, and the chunked dotted 'Wang M.Eng.' read as the -# family. #544 (2026-09-27) reads both shapes again and retired both -# rules. The comma-run names are the fix(#544) rules' at the -# bottom of this file; 'Wang M.Eng.' reads the roles this baseline -# read, and its report is the feat(#449) lone-name-word rule's near -# the top. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" -# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and -# 'tran', 'lac') with a suffix-or-name report, where this baseline -# read suffix 'meng' and no family name at all -- reporting nothing -# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the -# one-word-name fork, which was the wrong fork. 1.4.0 read the -# family, which is why that ledger has no copy of this rule. -# -# Literal-anchored to the two names. The class is every name whose -# last word is a bare member with nothing to spare, and a regex for -# it would stand ready to explain a regression on the spellings that -# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py -# carries 'meng li', where the word LEADS and nothing moved. The -# members are names, not a copy of any wordlist, which is what -# _NOT_A_VOCABULARY_COPY records. -name_regex = "^(?:tran lac|wang meng)$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" -# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with -# a suffix-or-name report, where every release read suffix 'MEng'. -# The marking's stated cost. In a name written in more than one case -# rules.md#S2 reads a member "written in any other cased form that is -# not wholly lower" as the name "even with words to spare", and MEng -# and LAc are conventionally written in exactly that form -- the cost -# 'John Smith Ma' already carries. -# -# 'John Smith MEng PhD' pays the same cost and keeps going: the -# declined pick stops the walk (family 'MEng'), but the unambiguous -# credential behind it is still peeled (suffix 'PhD'), where every -# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). -# -# A rule of its own rather than a member of the one above, so the -# gate's summary and the release note name the cost under its own -# heading. Literal, two names: the one-case spellings 'john smith -# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding -# where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" -fields = ["family", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" -# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', -# with a suffix-or-name report, where every release read suffix -# 'MEng'. The same S2 case reading the rule above pins, arriving -# after a family comma, where C1 has already settled the word count -# and the writing is the only evidence left. -# -# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's -# name-word count, and the all-lower 'Smith, John meng' keeps the -# credential too -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, John MEng$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" -# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family -# 'Smith', with a suffix-or-name report, where this baseline read -# suffix and no given name at all -- the third comma path, S2 -# declining the post-comma credential when the word count leaves it -# a name and C1 reading it as the given name. 1.4.0 already read -# the given name here, so that ledger carries no copy of this rule: -# the all-lower spelling restores 1.4.0's reading, and the -# mixed-case spelling is the cost side of the same path. -# -# Literal, two names; 'Smith, MENG' keeps the credential on the -# capitals lean, and 'Smith, John MEng' belongs to the middle-name -# rule above -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, (?:MEng|meng)$" -fields = ["given", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" -# 'john smith meng' and 'nguyen van lac': every role is what this -# baseline read -- suffix 'meng', suffix 'lac' -- because a full name -# in front leaves the credential reading available, and what is new -# is the report rules.md#S2 makes at the trailing slot, where "either -# reading carries the ambiguity flag". `_ambiguities` alone, so this -# rule cannot absorb a ROLE diff on either name, which is the point -# of keeping it apart from the two above. -name_regex = "^(?:john smith meng|nguyen van lac)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports" -# Every role is what this baseline read -- the whole run after the -# comma a suffix -- and what is new is the report. rules.md#C1: "The -# same count reads a part of two or more words as the credential run -# when every word of it is a suffix word or a word of this class, at -# least one of them of this class", and "A decision either way at this -# comma is reported". 'John Smith, PhD MEng' and 'john smith, phd meng' -# come back here from #540's accepted cost, which had read the comma as -# a family comma; 'Jane Doe, MS LAc' and 'John Smith, MD MEng' open the -# part with a title-and-suffix word, which counts as the suffix word it -# is after a full name, where #540 had read it as a title. -# -# `_ambiguities` alone, so this rule cannot absorb a ROLE diff on any of -# them -- the names whose roles move are the rule below. Literal; the -# probes 'John Smith, PhD MA' (capitals in a mixed-case name already -# settle the run, so it reports nothing new) and the superstring 'Dr. -# John Smith, PhD MEng' are _MUST_NOT_MATCH. -name_regex = "^(?:Jane Doe, MS LAc|John Smith, MD MEng|John Smith, MEng PhD|John Smith, PhD MEng|john smith, phd meng)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count" -# The part after the comma is the credential run on C1's count of the -# two name words before it, where this baseline read it as the listing -# form ('John Smith, Ed Ma' read given 'Ed', middle 'Ma'). A -# title-and-suffix word opening the part counts as the suffix word it is -# after a full name ('John Smith, Ms Ma', the accepted cost, Derek -# 2026-09-27; 'john smith, md ma'), and 'X.Y.Z.' is a member by shape, -# whose capitals lean nothing -- rules.md#S2 reads the writing of a -# LISTED member only -- so its run flips too. 1.4.0 read the listed- -# member names as suffixes as well. -# -# 2026-09-28: 'John Smith, PhD Ma' joins, the same run with an -# unambiguous word in front of the member, which the count reads whole -# like the rest. -# -# 2026-10-01, #554: 'John Smith, vd Ma' and 'JOHN SMITH, VD MA' join, -# rules.md#C1's examples for a word of both the particle and the -# unambiguous suffix vocabulary: "A word's particle reading does not -# take it out of the run". This baseline read the whole name as the -# family ('John Smith vd Ma'). 'John Smith, Jr do' joins as the -# contrast to the silent 'John Smith, Jr vd': `do` is a word of the -# ambiguous class, so the count flips the part and reports, where this -# baseline read title 'Jr', given 'do'. -# -# Literal; `fields` is the union the names move ('title' is the duals'). -# Probes: 'Smith, Ed Ma' (one name word before the comma keeps the -# listing form), 'Smith, Ms Ma' (a dual opening the given part is a -# title) and the superstring 'Dr. John Smith, Ed Ma' are _MUST_NOT_MATCH. -name_regex = "^(?:JOHN SMITH, VD MA|John Smith, Ed Ma|John Smith, Jr do|John Smith, Ms Ma|John Smith, PhD Ma|John Smith, X\\.Y\\.Z\\. MA|John Smith, vd Ma|john smith, md ma)$" -fields = ["given", "middle", "family", "suffix", "title", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#436/#437/#544) an unambiguous credential in front anchors the member behind it" -# rules.md#S2's company clause: a member written behind an unambiguous -# credential in one run "reads as the credential whatever its writing -# and whatever the count", where the tree before #544 read the Title-case -# MEng as a name word ('John Smith PhD MEng' family 'MEng'; 'Doe, Jane -# PhD MEng' middle 'MEng'), in the maiden clause's reading of the name -# too. The diff against this baseline is what remains once the roles -# come back: the report of the member the peel picked, and -- through -# 2.2 -- the run 1.4.0 and 2.x wrote 'PhD, MEng', which rules.md#R1 -# writes as the writer spaced it (#436/#437), so one rule explains the -# whole of each diff. -# -# The label is joint for that reason, as the 1.4.0 ledger's -# fix(#274/#436/#437) rules are: the `suffix` this rule claims moves on -# the spacing alone, which is #436/#437's, and `_ambiguities` is the -# report #544 adds. The 2.3.0 ledger, whose baseline already spaces the -# run, carries the same rule as #544's alone. -# -# Literal. Probes: 'Wang Ma PhD' (the credential BEHIND the member speaks -# for nothing) and the superstring 'Dr. John Smith PhD MEng' are -# _MUST_NOT_MATCH. -name_regex = "^(?:Doe, Jane PhD MEng|Doe, Jane nee Smith PhD MEng|Jane Doe nee Smith PhD MEng|John Smith PhD MEng)$" -fields = ["suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a degree in front outranks P6's attachment for a particle member" -# 'doe, jane v phd do': the one-case 'do' leans nothing and is particle -# vocabulary, so P6's attachment would take it into the family, and the -# unambiguous 'phd' in front of it outranks that attachment as capitals -# do (rules.md#S2, decided 2026-09-27) -- suffix 'v phd do', reporting -# suffix-or-name where this baseline read the particle into a name part. -# 'NASCIMENTO, EDSON ARANTES DO', with nothing in front, keeps P6's -# reading. Literal; the superstring 'Dr. doe, jane v phd do' and 'doe, -# jane do' are _MUST_NOT_MATCH. -name_regex = "^doe, jane v phd do$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a Title-case Lac behind a particle is the family name" -# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a -# suffix-or-name report, where every release read family 'Van', suffix -# 'Lac'. The mixed-case name's Title-case member leans 'name' with -# words to spare (S2) -- the gain side of the marking's cost, for a -# Vietnamese name (Lac is the trailing word in native order, Nguyen -# Van Lac). -# -# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix -# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, -# along with the superstring 'Dr. Nguyen Van Lac'. -name_regex = "^Nguyen Van Lac$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#296/#544) a credential in front anchors a member after a one-word family comma, and the pick reports" -# 'Smith, PhD Ma' and 'Smith, PhD MEng': family 'Smith', suffix 'PhD -# Ma' / 'PhD MEng', each reporting its member, where this baseline read -# title 'PhD' and given 'Ma' (suffix 'MEng'). Two changes make the one -# diff, so the label is joint: 'phd' left TITLES (#296, the 2.2 audit), -# which is what lets the part after the comma read as a credential run -# at all, and rules.md#S2's company clause (#544), 'PhD' speaking for -# the member behind it and the pick reporting where it was made. The -# fix(#325) rule above reaches both names by its regex and explains -# neither any longer, declaring no `_ambiguities`. -# -# Literal. Probes: 'Smith, PhD MA' (the capitals decide the member), -# 'Smith, MD PhD Ma' (a dual in the leading title run: nothing in the -# part speaks) and the superstring 'Dr. Smith, PhD Ma' are -# _MUST_NOT_MATCH. -name_regex = "^(?:Smith, PhD MEng|Smith, PhD Ma)$" -fields = ["title", "given", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#296/#531) phd is not a prenominal, so behind a dual title it is the given name and the member ending the part reports" -# 'Smith, MD PhD Ma': title 'MD', given 'PhD', middle 'Ma', reporting -# 'Ma', where this baseline read title 'MD PhD', given 'Ma'. 'phd' left -# TITLES (#296), so the title run ends at the dual 'MD' and 'PhD' is -# the given name; the Title-case 'Ma' ending the given part is #531's -# slot, which keeps it a name and reports the fork. #544 leaves the -# reading where it was: nothing in a part whose leading title run holds -# a dual speaks for the member (rules.md#S2). -# -# Literal. Probes: 'Smith, MD PhD MA' (the capitals make the member a -# credential and the part reads whole), 'Smith, Ms MD Ma' (the title -# run leaves 'Ma' the given name) and the superstring 'Dr. Smith, MD -# PhD Ma' are _MUST_NOT_MATCH. -name_regex = "^Smith, MD PhD Ma$" -fields = ["title", "given", "middle", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#575) a particle surname before a comma is one name word" -# rules.md#C1: "a particle run and the one name word it attaches to -# are one word", so 'van der Berg, PhD' reads as 'Berg, PhD' -# does: family 'van der Berg', suffix 'PhD'. Every release counted -# tokens and split the chain, given 'van', family 'der Berg', with a -# particle-or-given report. -# -# Literal; the probe 'John van der Berg, PhD' (two name words) is -# _MUST_NOT_MATCH. -name_regex = "^van der Berg, PhD$" -fields = ["given", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default" -# rules.md#C1 and S2 (#564): Policy.unlisted_caps_suffixes defaults to -# CapsSuffixes.AFTER_COMMA, so the part after a comma with two or more -# name words before it reads an unlisted all-caps word as the -# credential, alone or in C1's run, where the name contrasts it, and -# reports the call; a lone two-letter word is declined as initials. The all-caps SURNAME convention never writes the -# capitals there. 'John Smith, XYZ' and 'John Smith, LEED AP' are the -# C1 examples (the latter closing the #291 deviation the doc carried); -# 'The Rt Hon Kenneth Clarke QC MP, HMG' is a radar corpus name. -# 'John Smith, PhD XYZ' is a caps word in C1's run beside a listed -# credential, and 'García Márquez, MJ JK' two words of two capitals, -# read as #563 reads two pairs (both C1 examples). 'John Smith, RAI' and 'Ahmad Jayadi, CHA' read -# suffix again, as this baseline read them by vocabulary before #342 -# removed both words, and differ only by the comma's report. -# -# Literal; the probes 'Smith, XYZ' (one name word), 'García Márquez, -# MJ' (a lone two-letter word) and 'John Smith XYZ' (no comma: the -# trailing position stays opt-in) are _MUST_NOT_MATCH. -name_regex = "^(?:Ahmad Jayadi, CHA|García Márquez, MJ JK|John Smith, LEED AP|John Smith, PhD XYZ|John Smith, RAI|John Smith, XYZ|The Rt Hon Kenneth Clarke QC MP, HMG)$" -fields = ["title", "given", "middle", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word between credentials after a family comma stays in the run" -# rules.md#P6's third exception: a lone such word with a credential -# read in front of it and another behind "does not end the name, and -# keeps the post-nominal reading in any case, reported as S2's -# credential fork" -- whatever made the credential in front, so 'DOE, -# JANE MA VD PHD' stays whole as 'DOE, JANE PHD VD MA' does. This -# baseline read middle 'VD MA'/'vd ma' or 'MA', with the rest as -# suffixes, and reported nothing. -# -# Literal; the probe 'DOE, JANE PHD VD' (nothing behind, so the -# attachment holds) is _MUST_NOT_MATCH. -name_regex = "^(?:DOE, JANE MA VD PHD|DOE, JANE PHD VD MA|doe, jane phd vd ma)$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a never-given particle opening the given part takes the member behind it" -# rules.md#S2: a never-given particle first after a family comma is no -# given name, and the member behind it "is the surname the particle -# heads, not a word to spare", a chained particle run included ('VD DE -# LA'). This baseline read the same family; the report is the fork the -# given slot now decides, which it did not consult then. -# -# Literal; the probe 'SMITH, VAN MA' (an ambiguous particle, which can -# be a given name, keeps it, and the member reads as the credential) -# is _MUST_NOT_MATCH. -name_regex = "^(?:SMITH, VD DE LA MA|SMITH, VD MA|smith, de ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#585) a decomposed initial keeps its whole first letter" -# initials() took a token's first CODE POINT, which in a name typed -# decomposed (NFD) is a base letter without the combining mark that -# completes it: 'e' + U+0301 initialled 'e.', and the katakana 'ジ', -# 'シ' + the combining dakuten U+3099, initialled 'シ.'. The initial is -# now the shortest start of the word that composes to the composed -# word's first character, written in the form the writer used -# (rules.md#R3), so both surfaces give the composed spelling's initial -# kept decomposed. A composed name's initials do not move, and nor do -# those of an NFD name whose first letters do not decompose (#542's -# 'josé garcía' rows), which is why the rules corpus's two other NFD -# rows are the only names here. -# -# Roles do not move; the derived view alone does, so `_initials` is -# the one field. Literal names rather than a shape: what selects them -# is "a name typed decomposed whose initial letter decomposes", and no -# regex over the raw text says that without copying Unicode's -# decomposition data. -name_regex = "^(e\u0301mile zola|マイケル シ\u3099ャクソン)$" -fields = ["_initials"] - -# --------------------------------------------------------------- -# #594: HALFWIDTH KATAKANA IS KATAKANA. The script table classifies -# the halfwidth kana block U+FF65-U+FF9F as Script.KATAKANA, so what -# reaches full-width katakana reaches halfwidth now: the kana license -# (rules.md#W4) reads 山田 タロウ family-first as it reads 山田 エミ, the -# 间隔号 divides between two halfwidth kana (rules.md#T3), and H2's -# opening-abbreviation shape declines a period-marked katakana word -# because katakana has no initials (rules.md#H2's Accepted clause), -# closing the limit decisions.md#cjk-full-stops recorded. A WHOLLY -# halfwidth name keeps the declared order, as wholly full-width -# katakana does, so 'ヤマダ タロウ' is parity. -# -# At this baseline the five license and 间隔号 movers need no rule of -# their own: the canonical fix(#271/#272/#298) rule above explains -# them through its span class, which copies the halfwidth block since -# #594 (its class reaches the parity name too, as it reaches -# 'マイケル ジャクソン'). The two title movers move `title`, outside -# that rule's fields, so the one rule below takes them. Literal- -# anchored, and LAST in the file: both reported unclassified on the -# radar on the run before it existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#594) a period-marked halfwidth katakana word is not a title" -# 'タナカ. John' and 'マイケル.' read title at 2.0.0, as their full-width -# twins did until #cjk-full-stops (2.3); both read given now, as the -# twins do. -# Tolerated input (an edge full stop on a CJK word), so these are -# radar names and the rule classifies them for the release note rather -# than for the exit code. -# `_ambiguities` is the v2 surface's: the lone 'マイケル.' reports -# given-or-family as 'マイケル.' does, where the title reported nothing. -name_regex = "^(?:タナカ\\. John|マイケル\\.)$" -fields = ["title", "given", "_ambiguities"] - -# --------------------------------------------------------------- -# #597: THE HALFWIDTH CORNER BRACKETS ARE A DEFAULT NICKNAME PAIR. -# 「」 (U+FF62/U+FF63) join DEFAULT_NICKNAME_DELIMITERS beside 「」, and -# the v1 facade's nickname_delimiters gains halfwidth_corner_brackets, -# so a clause they enclose is lifted out as the nickname (rules.md#N1) -# where every release left it in the name -- a middle where spaced, -# part of one given word where glued. With the -# unclassified bracket gone, '山田 「タロー」 タロウ' is kanji plus katakana and -# takes the kana license; the unspaced '山田「タロー」太郎' divides at the -# extracted clause; 'John 「Jack」 Smith' shows the pair is not -# script-gated. -# -# Literal-anchored to the names: no corpus line held a halfwidth -# bracket before this change, so the movers ARE the three case rows -# #597 added. LAST in the file: each diff reported unexplained on the -# run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#597) halfwidth corner brackets enclose a nickname" -# The clause moves to nickname in all three, out of middle in the two -# spaced names and out of the one given word in '山田「タロー」太郎'; the -# two Japanese names also take their script order, family 山田. -name_regex = "^(?:山田 「タロー」 タロウ|山田「タロー」太郎|John 「Jack」 Smith)$" -fields = ["given", "middle", "family", "nickname"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). The clause these names -# carried at this baseline is gone: `maiden` empties and its words read -# where the given part reads any word. What else differs is the given -# part's own reading of those words -- the spaced credential run -# (#436/#437), the link join (#397), P6's attachment of a trailing -# particle to the family (#405), the capitals lean (#289) and the -# trailing title chain (H5) -- each as the same text with the marker -# replaced by an ordinary word reads it. -issue = "fix(#601) a marker in the given part after a family comma is an ordinary word" -name_regex = "^(?:Berg, Jane van der nee Smith DO|Berg, abd née Jones|Berg, abdul nee Jones MA|Doe, Dr\\. nee Smith MA|Doe, J\\. nee MA ba|Doe, Jane Q\\. nee Smith MA|Doe, Jane nee Puig i Soler|Doe, Jane nee Smith|Doe, Jane nee Smith DO|Doe, Jane nee Smith Do|Doe, Jane nee Smith MA|Doe, Jane nee Smith MA Prof\\.|Doe, Jane nee Smith MA do|Doe, Jane nee Smith Ma|Doe, Jane nee Smith PhD MEng|Doe, Jane nee Smith do|Doe, Jane nee Smith ma)$" -fields = ["_ambiguities", "family", "given", "maiden", "middle", "suffix", "title"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04): a tail part is read -# wholly as suffixes, the marker and its words with it. -issue = "fix(#601) a marker in a part after a suffix comma is an ordinary word" -name_regex = "^(?:Smith, John, MD - née Jones Smith|Smith, John, PhD née Jones)$" -fields = ["maiden", "suffix"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). Where a credential -# stands before the marker it also starts #602's run -- rules.md#S2: "A -# credential after the name core starts a run to the end of its part" -- -# which takes the marker and the words after it in. -issue = "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word" -name_regex = "^(?:Dr\\. nee Smith PhD Prof\\.|Jane Doe Jr\\. nee Smith|Jane Doe Jr\\. nee Smith Ma|Jane Doe PhD nee Smith|Jane and née Jones)$" -fields = ["_ambiguities", "family", "given", "maiden", "suffix", "title"] - -[[change]] -# 'Jane Doe nee Smith VI Prof.': rules.md#M2's numeral example -# behind a title, which the take reaches since #610 shared its -# candidate words with the peel (2026-10-04). -# rules.md#M2: "It takes them up to the trailing run of post-nominals and -# titles that the end of the name reads as if the clause were not -# written" (#601, 2026-10-04), and "The run so found ends the name: its -# words are the suffixes and titles that reading makes them, and no join -# reaches into it". The old walk's release check kept these words in the -# clause where a join it modelled might take them; the run is bound now. -issue = "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes" -name_regex = "^(?:Jane Doe nee Smith DO DO|Jane Doe nee Smith VI Prof\\.|Jane van der Berg nee Prof\\. King\\. MA|Jane van der Berg nee Smith Prof\\.|John nee Prof\\. ba MA)$" -fields = ["_ambiguities", "family", "given", "maiden", "suffix", "title"] - -[[change]] -# rules.md#S2: "A credential after the name core starts a run to the end -# of its part" (#602, 2026-10-04): every later word of the part is a -# suffix except a title word, which reads as a title, and an absorbed -# name word is reported. -# 'Jane van der Berg née Jr Jones' (radar): the marker declines on -# the suffix word after it (rules.md#M2) and stays a word, so 'Jr' -# stands behind a name core and its run takes 'Jones' (2026-10-04). -issue = "fix(#602) a credential after the name core starts a run to the end of its part" -name_regex = "^(?:Eric H\\. Holder Jr\\. Attorney General|Jane van der Berg née Jr Jones|John Smith PhD Jones|John Smith PhD Prof\\. Ma|Smith, John PhD Jones)$" -fields = ["_ambiguities", "family", "middle", "suffix", "title"] - -[[change]] -# rules.md#S2: "leaving it nothing to join: then it counts as a name -# word of its own" -# (#627, 2026-10-10): a lone particle with the credential right behind -# it counts toward the run's two name words, so it is the family and -# the run takes the rest. -issue = "fix(#627) a lone particle with the run right behind it is the surname" -name_regex = "^John von PhD Jones$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -[[change]] -# rules.md#P4: "of several such words the last does, so no particle -# chains inside the titles" (#624, 2026-10-10): the second -# title-particle is a title and the name starts behind it. -issue = "fix(#624) of two title-particles at the head the second leads the chain" -name_regex = "^Freiherr St John Smith$" -fields = ["_ambiguities", "family", "given", "title"] - -[[change]] -# rules.md#H5: "chain stops in front of the trailing run of suffixes and titles," -# (#623, 2026-10-10): after a family comma a trailing title is the -# title, and P6 attaches the particle in front of it. -issue = "fix(#623) after a family comma the chain stops before a trailing title" -name_regex = "^Smith, J[a-z]+ van( der Berg)? Prof\\.$" -fields = ["_ambiguities", "family", "middle", "title"] - -[[change]] -# rules.md#C2: "a part past the second comma holds no name word" (#636, -# 2026-10-11): group makes no particle chain in such a part, so a title -# word behind a particle run there is a title. -issue = "fix(#636) a part past the second comma makes no particle chain" -name_regex = "^John Smith, Jr\\., van der Berg Prof\\.$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#637, -# 2026-10-11): after a family comma the bound join declines the -# trailing title word H5's run takes, so it is read as the title. -issue = "fix(#637) the bound join declines a trailing title after a family comma" -name_regex = "^Berg, abdul Prof\\.$" -fields = ["middle", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#640, -# 2026-10-11): in a part past the second comma the bound join declines -# a title word, which C2 reads as a title. -issue = "fix(#640) the bound join declines a title word past the second comma" -name_regex = "^John Smith, Jr\\., abdul Dr\\. Smith$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#M2 reads the clause-free name, whose credential starts #602's -# run -- rules.md#S2: "A credential after the name core starts a run to -# the end of its part" -- and the take consumes that run: maiden 'Smith', -# suffix 'PhD Smith' (2026-10-04). -issue = "fix(#601/#602) a credential in the clause starts a run the take consumes" -name_regex = "^Jane Doe nee Smith PhD Smith$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -[[change]] -# The initials half of 'Jane and née Jones' (#601, 2026-10-04): a -# marker behind a connective is an ordinary word -- rules.md#M2: "In -# the given part after a family comma, in a part after a suffix comma, -# and behind a title, a suffix word or a connective, the marker is an -# ordinary word" -- so it joins the connective run 'Jane and née', and -# the run initials each of its words but the connective (R3). The -# roles' half is fix(#601/#602)'s marker-behind-a-connective rule above. -issue = "fix(#601) a marker behind a connective joins the connective run, and the run's initials follow" -name_regex = "^Jane and née Jones$" -fields = ["_initials"] - -[[change]] -# rules.md#P6: "a particle ending the name attaches to that family name", -# and a post-nominal written behind the particle does not end the name -# for this purpose. rules.md#S2's example for the given-part run leaving -# the particle to P6 (#601/#602 review, 2026-10-04); the attachment -# itself landed in 2.2 (#379). -issue = "fix(#379) a tussenvoegsel behind a post-nominal after a family comma attaches" -name_regex = "^Smith, John PhD de Jr\\.$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #604: THE IRISH AND MALAY PATRONYMIC PARTICLES. -# 'ó' and 'ní' join the never-given particles, 'ua', 'binti' and -# 'binte' the ambiguous half, so each opens the surname behind it -# (rules.md#P1, P2) where every release read it as a middle name. -# 'Ó.' written WITH its period stays the initial (rules.md#P7), so of -# the five P7 examples only the bare boundary moves; the two R4 case -# repair examples move for the vocabulary itself. -# -# Literal-anchored to the names: the movers are the P7 boundary and -# the two R4 examples, each reported unexplained on the run before -# this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#604) the Irish and Malay patronymic particles" -# The particle leaves the middle and opens the family: 'Ó Pérez', -# 'Ó MURCHÚ', 'binti navalamar'. -name_regex = "^(?:Juan Ó Pérez|SEÁN Ó MURCHÚ|ina binti navalamar)$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #606: SALUTATIONS AND TITLES IN OTHER LANGUAGES. -# TITLES gains salutations and offices for languages it had none -# for, among them three that are also surnames written last -# wherever they are borne (graaf, greve, knight), shipped on the -# graf precedent (rules.md#H1). The one corpus mover is the -# H1 Accepted example for that cost: the surname-first form -# without a comma gives the surname-borne word to the title. -# -# Literal-anchored to the name: it was reported unexplained on the -# run before this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#606) salutations and titles in other languages" -# The given name becomes the title: 'Greve'. -name_regex = "^Greve Anna$" -fields = ["title", "given"] - -[[change]] -# rules.md#C1: "A credential opening the part makes it the postnominal -# part however it goes on" (#603, 2026-10-04): where the first suffix -# word after the comma starts S2's run, every word behind it reads as -# that run reads, so a full name before the comma keeps its positional -# read ('John Smith, PhD Jones') and a one-word name is the family -# ('Doe, PhD Jones'). The undeclared dash of the delimiter rows is one -# more word of the run; a title/suffix dual opens nothing. -issue = "fix(#603) a credential opening the part after the comma makes it the postnominal part" -name_regex = "^(?:Doe, PhD Jones|John Smith, Ph\\. D\\. Jones|John Smith, PhD Jones|Smith, RN - CRNA|Steven Hardman, RN - CRNA)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] - -[[change]] -# rules.md#C2: "a word of the title vocabulary there that is not also -# suffix vocabulary is a title" (#603, 2026-10-04): a part past the -# second comma reads its title words as titles, and a part of title and -# suffix words is recognized rather than flagged ('Eric H. Holder, Jr., -# Attorney General'). 1.4.0, like 2.0 through 2.3, read them as -# suffixes. -issue = "fix(#603) a title word in a part past the second comma is a title" -name_regex = "^(?:1 & 2, 3 4 5, Mr\\.|Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29|Eric H\\. Holder, Jr\\., Attorney General|Eric H\\. Holder, Jr\\., Secretary of State|John Smith, Jr\\., Lt\\.Gov\\.|Smith, John, Prof\\.)$" -fields = ["_ambiguities", "suffix", "title"] - -[[change]] -# rules.md#S2: "The run at a name's trailing slot is read ONCE, once -# the connectives have joined (P3) and before the particle chain (P2) -# and the bound given-name join (P5) are made" and "neither join -# reaches a word the run took" (#614, 2026-10-07), and rules.md#H5: "a -# particle chain (P2) stops in front of a trailing title rather than -# taking it into the family name". This baseline made the chain first -# and the title was a word of the family name. -issue = "fix(#614) a trailing title is read before the particle chain" -name_regex = "^John van der Berg Prof\\.$" -fields = ["family", "title"] - -[[change]] -# rules.md#N1: "a comma inside it kept as written — the comma divides -# nothing there" (#653, 2026-10-12): a comma inside a delimited -# clause is the clause's text, as 1.4.0 kept it. This baseline split it -# off as a separator, and the nickname rendered without it. -issue = "fix(#653) a delimited clause keeps its comma" -# The two rules.md#M3 comma examples move only this way here: this -# baseline predates M3, so their clauses were nicknames either way -# and the comma is the whole diff. -name_regex = "^(?:Jane \\(Janey, Jo\\) Smith|Maria Kowalska \\(z, domu Nowak\\)|Jane \\(née , Jones\\) Smith|Jane \\(née ,Jones\\) Smith)$" -fields = ["nickname"] - -[[change]] -# rules.md#H2: "An accented Latin, Greek or Cyrillic letter written as a -# base letter plus a combining mark (NFD) is the same letter as the -# composed one" (#652, 2026-10-12): the abbreviation shape ended its -# word at the base letter, so a decomposed `inž.` was no abbreviation -# and became the given name. The rules example is the one name here. -issue = "fix(#652) a decomposed accented abbreviation is an abbreviation" -name_regex = "^Dr\\. inz\u030c\\. Janez Novak$" -fields = ["title", "given", "middle"] diff --git a/tools/differential/expected_since_2.1.0.toml b/tools/differential/expected_since_2.1.0.toml deleted file mode 100644 index d9a6b196..00000000 --- a/tools/differential/expected_since_2.1.0.toml +++ /dev/null @@ -1,4045 +0,0 @@ -# Ledger for baseline 2.1.0 -- what changes for a user upgrading from -# the previous minor. Same rule grammar as the other ledgers: `issue`, -# `name_regex` and `fields` are every one of them required. Neither -# narrowing key may stand on its own -- #451 banned a rule with -# `fields` and no regex, #456 banned the reverse -- so a rule here -# narrows by name AND by role, never by just one. Every -# rule therefore sits in one tier, _sorted_rules is stable, and FILE -# ORDER decides every tie: write the narrower rule first. That is the -# declaration-free DEFAULT rather than a law -- a wider rule that -# describes a compound behavior its component rule does not may sit -# first and declare it in a [[change.precedes_narrower]] block naming -# the rule it outranks (#382). Never reorder to satisfy that check; -# see tools/differential/README.md, "Declaring a wide-first pair". -# -# This file was opened empty the day 2.1.0 shipped (AGENTS.md release -# step 8) and stayed that way through #354, #358 and #361, none of -# which moved the default-order corpus. #367 is the first 2.2 change -# that does, and its rule below is the first entry here. -# -# The ledger is opened before it has anything to say because -# DEFAULT_BASELINE and the ledger are coupled: _allowlist_for treats a -# missing file as a hard error, on the reasoning that an absent ledger -# classifies nothing and would make every diff report as unexplained. -# So the baseline could not advance to 2.1.0 without this file. -# -# Do not copy rules across from expected_since_2.0.0.toml: that ledger -# classifies what 2.1 changed on top of 2.0, and a rule copied without -# checking either over-matches -- hiding a real 2.2 regression behind a -# 2.1-era label -- or never fires at all. The #367 rule below IS shared -# with the other two ledgers, and that is not an exception to this: the -# change is in the 2.2 cycle, so every baseline before it sees the same -# diff, and each copy was checked against its own run rather than -# assumed. -# -# tests/v2/test_differential.py permits exactly one empty ledger, the -# one DEFAULT_BASELINE names -- a permission this file no longer needs. -# -# tests/v2/test_ledger_guards.py's _SPAN_BEARING_RULES records this -# file with an empty set: no rule here hand-copies _SCRIPT_RANGES, and -# the first one that does has to be recorded there or the sweep fails. -# #367's rule is Latin-only and declares no script span. -# -# There is deliberately no `change = []` line. TOML forbids appending a -# [[change]] table to a statically defined array, so that line would -# have blocked the entry below -- and every reader uses -# .get("change", []) -- four call sites across three files -- so the -# key's absence was already the empty ledger. -# tests/v2/test_differential.py checks that nothing else is defined at -# the top level here, since a mistyped table name would otherwise read -# as an empty ledger rather than as a broken one. - -# #436/#437: the suffix view's entry boundary became the comma the -# writer typed. rules.md#R1: "a run of post-nominals written with -# spaces renders with spaces, and one written with commas keeps -# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA -# path only since 2.3, which is the whole of what these names move. -# Every one goes from a comma-joined render to a space-joined one; -# none goes the other way, and none has a comma in the original -# between the two words. Roles do not move: this rule declares -# `suffix` alone, so a name whose ROLES move here arrives -# UNEXPLAINED, as it should. -# -# FIRST in the file, and that is narrow-first rather than a -# preference: `fields = ["suffix"]` is a strict subset of several -# rules below, and any of them sitting earlier while both regexes -# reach one corpus name is an undeclared order-decided contest the -# run refuses (#382). Measured 2026-09-06 by moving this rule to -# the end of a scratch copy of this file and re-running the gate, -# which raises 3 such contests here. -# -# An anchored alternation of the names rather than a shape, because -# the shape is "two post-nominals with a space between them" and -# that reaches every listing form in the corpora. The members are a -# list of NAMES and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 10 with its digest, and -# _MUST_NOT_MATCH pins the five comma and delimiter forms it must -# never claim. -[[change]] -issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" -# 2026-09-19, #533: one name joins the alternation and the rule -# does not change. 'Jane Doe nee Smith PhD MA' is a two-word -# credential run this baseline renders 'PhD, MA' and the tree -# renders 'PhD MA' -- the whole of its diff at this baseline, and -# the same diff at 2f57ff21, measured. #533 moves nothing on it: -# the unambiguous PhD already ended the walk, so the clause gave -# the MA up before this change as it does after it. What grew is -# the CORPUS, and the run rendering is what the diff is about. -# 2026-09-20, #397: three names join the alternation and the rule -# does not change. 'Josep Lluis Carod i III' ('i, III' -> 'i III'), -# 'Josep Lluis Carod i V' ('i, V' -> 'i V') and 'Rovira, Josep Carod -# i Jr.' ('i, Jr.' -> 'i Jr.') are two-word generation runs this -# baseline renders with a comma and the tree renders with a space -- -# the whole of each one's diff here. #397 moves NOTHING on any of -# them: measured against the parent 46651750 all three parse -# byte-identically before and after this bundle, the link having a -# generation marker and not a name word to its right. What grew is -# the CORPUS -- the three arrived with #397's own case rows -- and -# the run rendering is what the diffs are about. -# 2026-09-20, #397 review: one more name joins the alternation and -# the rule does not change. 'Jane Doe nee Puig i III' is the same -# two-word generation run inside a MAIDEN clause's tail: this -# baseline renders 'i, III' and the tree renders 'i III', which is -# the whole of its diff here. The link fix moves nothing on it -- -# measured against the parent 46651750, the parse is byte-identical -# before and after (given 'Jane', family 'Doe', suffix 'i III', -# maiden 'Puig'), the link having a generation and not a name word -# to its right. What grew is the CORPUS. -# 2026-09-27, #544: Three names join the alternation, each diffing here on -# the rendering alone -- the roles are this baseline's, and #544 is what -# put them back ('Jane Doe Jr. Ma', 'John Smith PhD Ed Ma', 'John Smith PhD Ma'). The tree before #544 read the Title-case -# member behind the credential as a name word, the case lean stopping -# the peel at it; the company clause (rules.md#S2) reads it as the -# credential, so the run is v1's again and only R1's spacing differs. -name_regex = "^(?:JOHN DOE PHD MD|Jane Doe Jr\\. Ma|Jane Doe nee Puig i III|Jane Doe nee Smith PhD MA|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|John Smith PhD Ed Ma|John Smith PhD Ma|Josep Lluis Carod i III|Josep Lluis Carod i V|Kenneth Clarke QC MP|Rovira, Josep Carod i Jr\\.|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" -fields = ["suffix"] - -# The seven #449 rules go SECOND, not first: the rule above -# declares its own first-in-file position with a measurement, -# and `fields = ["suffix"]` and `fields = ["_ambiguities"]` -# are disjoint, so nothing orders the two against each other. -# They DO sit ahead of every rule below whose `fields` is a -# strict superset of `_ambiguities`, which is the narrow-first -# default (#382). -[[change]] -issue = "feat(#449) a lone name word reports given-or-family" -# rules.md#O5's convention, now reported. Twenty-two corpus names at -# landing (twenty-three since #544, 2026-09-27), and -# the alternation is a list of NAMES rather than a copy of any -# wordlist -- what selects them is the SHAPE (one name word, and no -# title, maiden name, comma, script order or vocabulary claim deciding -# it), so it is declared in _NOT_A_VOCABULARY_COPY. A suffix beside -# the word does NOT decide it: 'Smith Jr.' and "'Smitty' Jones Jr." -# are the peel taking the suffix and the convention placing what is -# left. `_ambiguities` alone: no role moves anywhere in this change. -# The five CJK-bearing names in the same population ride five rules of -# their own below, because a CJK member in an alternation is claimed -# by the honorific pin, which would demand this be a copy of -# GLUED_HONORIFICS. #322/#323 opened the one escape from that pin -- -# a member set declared in _NOT_A_VOCABULARY_COPY -- and nothing here -# declares one, so the five rules stand. -# 2026-09-27, #544: 'Wang M.Eng.' joins the alternation. Its roles are -# this baseline's, suffix 'M.Eng.', and the whole of its diff here is -# the report this rule describes, as for 'Jack M.A.'. The two readings -# of the suffix have different grounds: this baseline read it because -# 'meng' was an unambiguous acronym, which rules.md#S2 consumes "even -# when that leaves no family name at all"; #540 made 'meng' ambiguous -# in the 2.4 cycle, which read the family, and the tree reads the -# suffix again because S2 counts an ambiguous acronym "written with its -# periods, one after each letter or one after each of two or more -# letter chunks" unambiguously. Neither change is what the name diffs -# on against this baseline. -name_regex = "(?i)^(?:'Smitty' Jones Jr\\.|Andrew|Carod i|Dean of Chemistry|Donald mc|Duke of Edinburgh|Duke of Wellington|Garcia|Jack M\\.A\\.|John & Jane|John V|John of the Doe|Juan & Garcia|Juan and Garcia|Mohamad X|Smith|Smith Jr\\.|Wang M\\.Eng\\.|e and e|part1 of The part2 of the part3 and part4|part1 of and The part2 of the part3 And part4|test|سلمان،)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it" -# 'マイケル'. rules.md#W4 gives katakana no order of its own -- a -# wholly-katakana name is usually a transcription and keeps whatever -# the caller declared -- so the script rule DECLINES and O5's -# convention is what is left. Literal-anchored on one corpus name, so -# _CORPUS_CLAIMS cannot see a widening and _MUST_NOT_MATCH carries the -# wall. -name_regex = "^マイケル$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) an interpunct transcription declines the script order, so the convention decides it" -# '王·Smith'. rules.md#T3: "The interpunct divides a name only between -# two characters of a classified East Asian script; anywhere else it is -# part of the word" -- 'Smith' is Latin, so the name is one word, and -# '王Smith' reads the same. rules.md#W4: "A name written wholly in one -# East Asian script, or in the kana-licensed Japanese repertoire, reads -# family-first whatever order the caller declared" -- a word mixing Han -# and Latin is neither, so no script order applies, name_order governs -# and the one name word is O5's. The issue names the interpunct, which -# is incidental to this name. Literal-anchored, one corpus name. -name_regex = "^王·Smith$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) an ideographic comma leaves one name word, and the convention decides it" -# '田中、太郎'. The comma is inside the token, so the text resolves to -# no single script and the order lookup declines; what is left is one -# name word and O5. A rules.md#W3 shape -- tolerated, read -# best-effort -- and the report says only what the parse actually -# called. Literal-anchored, one corpus name. -name_regex = "^田中、太郎$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order" -# 'Andersonさん'. rules.md#W2 peels the glued honorific to SUFFIX and -# leaves 'Anderson' -- Latin, so effective_script answers None and no -# script order is resolved at all. One name word, and O5's convention -# places it. Literal-anchored, one corpus name. -# -# `_ambiguities` alone here, where the 2.0.0 ledger's twin also carries -# `given` and `suffix`: #308's peel is already in THIS baseline, so -# the report is the whole diff. The gate's OVER-DECLARED check is what -# forces the two spellings apart -- a rule may not declare a role no -# diff it explains moves. -name_regex = "^Andersonさん$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order" -# 'Anderson선생님'. The Japanese rule's argument, in Hangul: the -# honorific is peeled, the Latin stem resolves to no script, and the -# convention places the one name word left. Literal-anchored, one -# corpus name, and `_ambiguities` alone for the reason given above. -name_regex = "^Anderson선생님$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it" -# '김゙민준' (김 + U+3099), a tolerated case row #596 added (2026-10-03). -# A kana -# voicing mark after hangul voices no kana, so it keeps its hiragana -# block and the token is mixed hangul and hiragana: no script, no -# surname division, and O5's convention places the one name word left. -# The roles are this baseline's; the report is the whole diff. -# Literal-anchored, one corpus name, `_ambiguities` alone. -name_regex = "^김\\u3099민준$" -fields = ["_ambiguities"] - -# The two #491 rules sit beside #449's, same position rule: both -# declare `fields = ["_ambiguities"]`, so they are disjoint from the -# `suffix` rule that owns first place and ahead of every rule below -# whose fields are a strict superset of theirs (#382's narrow-first -# default). -[[change]] -issue = "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name" -# rules.md#H4. Six corpus names, one alternative each -- a list of -# NAMES, not a copy of TITLES: what selects them is the SHAPE (the -# title peel leaves exactly one name word, and that word is itself -# title vocabulary), and a member copying the wordlist would reach -# 'Dr. Smith', 'Rabbi Cohen' and every other title-plus-surname name, -# none of which moves. Declared in _NOT_A_VOCABULARY_COPY for that -# reason. `_ambiguities` alone: the reading each of these gets is -# unchanged, only the silence about it. -name_regex = "(?i)^(?:Dr\\. King|His Holiness|His Holiness the Dalai Lama|Lord Chancellor|The Right Hon\\. the President of the Queen's Bench Division|The Rt Hon)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name" -# rules.md#H4's suffix half, emitted at assign's bare-suffix -# carve-out. Two corpus names. A list of names, not a copy of -# SUFFIX_WORDS or SUFFIX_ACRONYMS: a member copying either would reach -# 'John Smith MD', 'Smith Jr.' and the rest of the corpus's credential -# names, none of which moves -- what selects these two is that nothing -# was left to be the name. -name_regex = "(?i)^(?:QC MP|Rinpoche)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#491) a joined unit carrying title vocabulary reports title-or-name" -# H4's join clause -- the one name unit is a join (P3) and one of the -# words it joins is title vocabulary, so the doubt is whether that -# word is a title rather than which field the unit takes. These two -# arrive because the #518 review round moved the clause out from under -# O5's field-deciding guards: both peel a title in front ('Attorney', -# 'Deputy') and leave the join standing as the last piece, which a -# title cannot be, so H3 never chains it. A list of two names, not a -# copy of TITLES: a member copying the wordlist would reach 'Prince of -# Wales' -- a join LED by a title word, which H3 does chain -- and -# every title-plus-surname name besides. `_ambiguities` alone: the -# reading is unchanged, only the silence about it. -name_regex = "(?i)^(?:Attorney General of Minnesota|Deputy Secretary of State)$" -fields = ["_ambiguities"] - -# The four CJK names of #436/#437's class, one rule each. Not one -# alternation: an UNDECLARED alternation holding a script-classified -# member is claimed by the honorific pin in -# tests/v2/test_ledger_guards.py, which requires its members to BE -# the config's CJK honorific entries -- and until #322/#323 opened -# the _NOT_A_VOCABULARY_COPY escape that was every such alternation. -# These are names, so each stands alone, literal-anchored, -# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) -# with _MUST_NOT_MATCH probes for the boundary each one has. -# rules.md#R1: "a run of post-nominals written with spaces renders -# with spaces, and one written with commas keeps them" -- the peel -# puts the honorific in `suffix` (rules.md#W2) and the run it lands -# in is spaced, so the render spaces it. At 1.4.0 and 2.0.0 these -# names already diff on `suffix` under the honorific rules and are -# claimed there; only at 2.1.0 and 2.2.0 is the render the whole of -# what moved. -[[change]] -issue = "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run" -name_regex = "^田中さん II$" -fields = ["suffix"] - -[[change]] -issue = "fix(#436/#437) the glued honorific and the polite address are one post-nominal run" -name_regex = "^田中さん 様\\.$" -fields = ["suffix"] - -[[change]] -issue = "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run" -name_regex = "^김민준 박사 씨$" -fields = ["suffix"] - -[[change]] -issue = "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run" -name_regex = "^김민준씨 Jr\\.$" -fields = ["suffix"] - -# #346: swami, guru, baba and lama moved from the TITLES-only block -# into GIVEN_NAME_TITLES on 2026-09-06. rules.md#H1's Accepted clause -# -- "a given-name title plus one name word leaves the family empty" -# -- now reaches them, so the one name word behind the title is the -# GIVEN name and the family is empty, where every baseline read it as -# the family. Both roles move together and `fields` declares both: a -# name whose TITLE moved would arrive UNEXPLAINED, as it should. -# -# Four corpus names, all from the radar-tier corpus_issues.jsonl and -# all moving the same way. The two- and three-word spellings are the -# boundary and are _MUST_NOT_MATCH probes: 'Swami Vivekananda -# Saraswati' and 'Guru Gobind Singh' keep their family names, because -# H1's fold requires exactly one name word, and a rule reaching them -# would stand ready to explain the very regression that boundary -# exists to catch. -# -# An anchored alternation of the four NAMES rather than a shape, -# under the ledger discipline expected_since_1.4.0.toml's header -# states for all four files: keep each entry's `name_regex` as tight -# as the diff allows. Four names moved, so a four-name list is the -# tightest expression of that diff; anything shape-shaped is wider -# than what was measured. It copies no wordlist either, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 4 with its digest. -[[change]] -issue = "fix(#346) a renunciate title and one name word leave the name a given name" -name_regex = "^(?:Baba Ramdev|Guru Nanak|Lama Zopa|Swami Vivekananda)$" -fields = ["family", "given"] - -# #519: prince and princess moved from the TITLES-only block into -# GIVEN_NAME_TITLES on 2026-09-10. rules.md#H1's fold now reaches -# them, so the one name word behind the title is the GIVEN name and -# the family is empty, where every baseline read it as the family. -# Both roles move together and `fields` declares both. -# -# Three corpus names: 'Prince Charles' and 'Prince Fielder' from the -# radar-tier corpus_issues.jsonl, and the contract-tier rules.md -# example 'Her Royal Highness Princess Anne' (corpus_rules.jsonl), all -# moving the same way. 'Prince Fielder' moves too and is wrong either -# way -- Prince is his given name, which is #348's collision and not -# this rule's subject. The boundary is pinned by _MUST_NOT_MATCH -# probes: two name words behind the title ('Prince Harry Windsor'), -# the chained run 'Prince of Wales Jr' (addresses by `wales`), the -# trailing 'Robert Prince', 'Lord Byron' (`lord` stays out) and the -# bare 'Prince'. -# -# An anchored alternation of the NAMES rather than a shape, under -# the ledger discipline expected_since_1.4.0.toml's header states: -# keep each entry's `name_regex` as tight as the diff allows. It -# copies no wordlist, which _NOT_A_VOCABULARY_COPY records; -# _CORPUS_CLAIMS pins the reach with its digest. -[[change]] -issue = "fix(#519) prince and princess join the given-name titles" -name_regex = "^(?:Her Royal Highness Princess Anne|Prince Charles|Prince Fielder)$" -fields = ["family", "given"] - -[[change]] -issue = "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word" -# 'rai' and 'cha' left SUFFIX_ACRONYMS on 2026-09-07 -# (nameparser/config/suffixes.py). Both arrived in the 2019-12-11 -# bulk wikipedia post-nominal import (af5bdab), neither was reviewed -# against a surname collision, and decisions.md#suffix-acronym-collisions -# states the criterion that removed them rather than marking them -# ambiguous: marking is for a short credential in wide use that is -# also a name, removal for a longer one that is tenuous and collides. -# -# Five corpus names, all from the radar-tier corpus_issues.jsonl, in -# THREE diff shapes with one cause -- the word stopped being -# credential vocabulary, so the positional read takes it: -# 'Aishwarya Rai' {family, suffix} suffix -> family -# (2.x only -- parity at 1.4.0) -# 'Lala Lajpat Rai' {middle, family, suffix} the family shifts right -# 'John Smith RAI' {middle, family, suffix} same -# 'John Smith, RAI' {given, family, suffix} an ordinary family comma (C1) -# 'Ahmad Jayadi, CHA' {given, family, suffix} same -# 2026-10-01, #564: the comma pair reads suffix again by its capitals -# (an unlisted all-caps word after a comma behind two name words), so -# it no longer moves {given, family, suffix} here; at 1.4.0 it reaches -# parity, at 2.x only the comma's report differs (the fix(#564) rule). -# `given` therefore leaves `fields`, which the OVER-DECLARED check -# requires: no name here moves it any more. -# The comma pair is why `fields` declares four roles rather than the -# two the pre-2.3 fix(#342) rule declared: with no credential after -# the comma, C1 reads the pre-comma run as the family and the -# post-comma word as the GIVEN name, so `given` moves where it did -# not before. The declaration is the exact union of the five, which -# is what the OVER-DECLARED check recomputes at the end of every run. -# -# 'Aishwarya Rai' is in this rule at 2.0.0/2.1.0/2.2.0 and NOT -# explained by it at 1.4.0, where the name now reaches PARITY: 1.4.0 -# read family 'Rai' too, which is what the deleted -# `fix(#342) NOT WANTED` rule existed to classify. The regex is the -# same in all four files anyway -- reach is not explanation, and a -# per-ledger regex would say a name left the corpus. -# -# An anchored alternation of the five NAMES rather than a shape. The -# shape is "a trailing word that used to be an acronym entry", which -# would be a copy of SUFFIX_ACRONYMS and would stand ready to explain -# every future trailing-acronym regression. The members are a list of -# names and copy no wordlist, which is what _NOT_A_VOCABULARY_COPY in -# tests/v2/test_ledger_guards.py records; _CORPUS_CLAIMS pins the -# reach at 5 with its digest, and _MUST_NOT_MATCH carries the -# boundaries the paragraph above argues. -name_regex = "^(?:Ahmad Jayadi, CHA|Aishwarya Rai|John Smith RAI|John Smith, RAI|Lala Lajpat Rai)$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#369) a given-name title licenses the bound given-name join with one word to spare" -# 'Sheik abdul salam': rules.md#P5 -- "or after a given-name title, -# which asserts that a given name follows (H1): there the two words -# join and the name has no family". Given 'abdul', family 'salam' -# becomes given 'abdul salam', family '' -- the empty family H1 -# accepts for "Sir John". The 'Sheik abdul salam Jr' rules example -# is the same move with the suffix standing; 'Sheik abdul Jr Smith' -# is in reach and does not move -- the licence takes a name word, -# not a suffix. -# -# 'Sheik Abu Bakar', the #367 regression this issue was filed for, -# reads given 'Abu Bakar' again with every field byte-identical to -# this baseline, so no rule names its fields: restored, not moved. -name_regex = "(?i)^sheik\\s+abdul\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#401) the bound-given reserve counts the trailing numeral assign reads as the suffix" -# 'abdul Smith V', 'abdul Smith Jr V': rules.md#P5 -- "A trailing roman -# numeral, or a bare acronym the run takes, or a trailing title word the -# run takes, is no word to spare". given 'abdul Smith', family '' -> -# given 'abdul', family 'Smith'; the suffix was already read at this -# baseline, which is why the 1.4.0 rule lists three fields and this -# one two. Both are rules.md examples; no differential corpus name -# has the shape, and the 'abdul Smith V Ph. D.' spelling stays out of -# the corpus (the 1.4.0 ledger's [[never]] shape). -name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?v$" -fields = ["given", "family"] - -# #436/#437 compounding with the bound-given reserve, one name. -# 'abdul Smith Jr V' already diffs {family, given} at this baseline -# under fix(#401) above, whose `fields` are those two alone -- so -# once the render moves as well, the diff is {family, given, suffix} -# and no longer a subset of anything fix(#401) declares. Two -# mechanisms, one name: the reserve decides the ROLES and #436 -# decides the SEPARATOR, and the ledger's idiom for that is a rule -# naming both (the cjk-*-compound rules are the precedent). AFTER -# fix(#401) in file order, because its `fields` are a strict subset -# of these and the reverse arrangement is an undeclared contest -# (#382). fix(#401) keeps 'abdul Smith V', which still diffs on the -# two roles alone, so it neither goes dormant nor over-declares. -[[change]] -issue = "fix(#436/#437) the space-separated post-nominal run compounds with the bound-given reserve" -name_regex = "(?i)^abdul\\s+smith\\s+jr\\s+v$" -fields = ["given", "family", "suffix"] - -[[change]] -issue = "fix(#421) the bound-given join never absorbs a suffix piece" -# 'abdul Jr Smith Berg': rules.md#P5 -- "the join never absorbs a -# marker standing as a word of its own -- a marker is not a name word -# (M2) -- nor a word of the unambiguous suffix vocabulary (S2), -# wherever position will then place it". given 'abdul Jr', middle -# 'Smith' -> given 'abdul', middle 'Jr Smith'. Parity with this -# baseline until now; a -# rules.md example, since no differential corpus name has the shape. -name_regex = "(?i)^abdul\\s+jr\\s+smith\\s+berg$" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#421) the bound-given join never absorbs a split credential" -# 'abdul Ph. D. Smith Berg': the rule above, on the split credential -# -- a 2.0 regression: given 'Smith', suffix 'abdul Ph. D.' -> given -# 'abdul', middle 'Smith', suffix 'Ph. D.', the suffix field 1.4.0's -# again (that ledger carries the given's own story). A rules.md -# example; no differential corpus name has the shape. -name_regex = "(?i)^abdul\\s+ph\\.\\s+d\\.\\s+smith\\s+berg$" -fields = ["given", "middle", "suffix"] - -[[change]] -issue = "fix(#425/#436/#437) the bound-given reserve runs assign's peel over the joined view" -# 'abdul Smith Jr Ma', 'abdul Smith Ma': P5 as it landed tried the join -# on the pieces as it would leave them and read the trailing peel over -# that, a word the peel read as a suffix unjoined having to read so -# joined. Since #614 rules.md#P5 reads the run first -- "the trailing -# suffix run (S2) and the trailing title run (H5), each read over what -# the other leaves until neither takes anything more, are read once, -# before the join is tried" -- and the #289 note below records how -# 'abdul Smith Ma' has read since 2026-09-18. given 'abdul Smith', -# family '' -> given 'abdul', family 'Smith' (the acronym and the suffix -# peel as they do for 'John Smith Jr Ma'); given 'abdul Smith', family -# 'Ma' -> given 'abdul', family 'Smith', suffix 'Ma' (the join would -# have turned a credential into the family). Both 1.4.0 parity, so the -# 1.4.0 ledger has no twin. Rules.md examples; no differential corpus -# name has either shape. -# -# 2026-09-18 (#289): the sentence above -- "Both 1.4.0 parity, so the -# 1.4.0 ledger has no twin" -- stops being true, and this rule's own -# diff shrinks at the same time. The surname lean (rules.md#S2) never -# lets the peel take a Title-case 'Ma', so the acronym is a name word -# to spare and the join fires: 'abdul Smith Ma' reads given 'abdul -# Smith', family 'Ma' -- which is what THIS baseline reads, so the -# name stops diffing here altogether -- and 'abdul Smith Jr Ma' reads -# given 'abdul Smith', middle 'Jr', family 'Ma', where v1 read first -# 'abdul', last 'Smith' and the acronym as a suffix in both. The -# 1.4.0 ledger gains its twin, inside the fix(#289) rule at the -# bottom of that file. `Jr` as a middle name is an ACCEPTED cost: the -# surname lean breaks the peel at 'Ma', so the suffix behind it is -# never reached. `fields` is narrowed to the union the run measures -# here -- `given` no longer moves on the one name left diffing, and -# a role nothing moves is a standing claim (#452). -# 2026-09-27, #544: the paragraph above is true again, `given` included. -# The company clause (rules.md#S2) reads the 'Ma' behind 'Jr' as the -# credential, so the peel takes both and the reserve declines the join: -# 'abdul Smith Jr Ma' reads given 'abdul', family 'Smith', suffix 'Jr -# Ma', the diff here {given, family, suffix}, and `middle` moves on -# nothing any more. `fields` follows the run. -# Of that diff, {given, family} is #425's -- 2.2.0 and 2.3.0 read those -# roles too -- and `suffix` is R1's spacing alone, the 'Jr, Ma' this -# baseline wrote (#436/#437). #544 moves nothing on it against this -# baseline: it takes back a reading the 2.4 cycle had broken. One rule -# has to explain the whole of the diff, so the label is joint, as the -# 1.4.0 ledger's fix(#274/#436/#437) rules are. -name_regex = "(?i)^abdul\\s+smith\\s+(jr\\s+)?ma$" -fields = ["given", "family", "suffix"] - -[[change]] -issue = "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle, the P4 example" -# 'Xyz. van Johnson': the rules.md#P4 example of the 'Esq. van Gogh' -# rule of this change (beside the fix(#367) rules, further down), -# same reading and fields. Separate rather than an alternation, per -# the guards' one-alternation-per-rule roster. -name_regex = "(?i)^xyz\\.\\s+van\\s+johnson$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#424) a title-led chain before the numeral is the one name piece" -# 'Freiherr von Richthofen V': rules.md#P2 boundary -- the chain stops -# before the numeral, and the chain is then the one name piece, which -# reads as 'Dr. Smith V' reads. The baseline read family 'von -# Richthofen V'; #424 split the numeral off as the suffix, and #410 -# then moved the one name piece behind the title out of `given`, so -# the reading today is family 'von Richthofen', suffix 'V' -- and -# 'Dr. Smith V' moved with it, so the comparison still holds. A -# rules.md example. -# -# #410 narrowed this diff rather than adding one: {given, family, -# suffix} before it, {family, suffix} after. `fields` went on -# declaring the wider set, and because the match is a subset test the -# rule kept claiming the name as its diff shrank -- a real change of -# reading crossing a green gate with nothing to name it. The -# declaration now matches the diff, so `given` moving on this name -# again would be unexplained rather than absorbed. The general form: -# a `fields` list wider than the diff it explains silently absorbs -# whatever else lands inside it. -name_regex = "(?i)^freiherr\\s+von\\s+richthofen\\s+v$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424) accepted: a particle of the suffix vocabulary opening the trailing run is a suffix piece" -# 'John Smith Mc V': rules.md#P2 Accepted -- "where it opens the -# trailing run the chain stops before it as before any suffix word, -# and the peel takes it". middle 'Smith', family 'Mc V' -> family -# 'Smith', suffix 'Mc, V'. A rules.md example; the class was found -# unrecorded by the second docs review of #424. -name_regex = "(?i)^john\\s+smith\\s+mc\\s+v$" -fields = ["middle", "family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(comma-family) a comma followed only by titles keeps the given/family split" -# 'Bob Jones, author' / 'Bob Jones, compositeur': the comma is followed -# by nothing but a title word, which said nothing about where the -# family name ends, so the pre-comma name keeps its positional read -- -# family 'Bob Jones' -> given 'Bob', family 'Jones', the title staying -# where it was. The FAMILY_COMMA rule that segment 0 is wholly the -# family rests on the writer having marked where it ends; a comma -# followed only by titles marked nothing ('John Smith, Mr.' is 'Mr. -# John Smith' with the honorific moved). -name_regex = "(?i)^bob\\s+jones,\\s*[a-z]+$" -fields = ["given", "family"] - -[[change]] -issue = "fix(comma-family) a comma followed only by titles keeps the given/family split, the C1 example" -# 'John Smith, Mr.' and 'John Smith, Mr. Jr.': the rules.md#C1 examples -# of the rule above (family 'John Smith' -> given 'John', family -# 'Smith'; title and suffix stay where they were). Its own literal rather than -# an alternation with 'Bob Jones', which would be no vocabulary. -name_regex = "(?i)^john\\s+smith,\\s*mr\\.?(\\s+jr\\.?|\\s+and\\s+mrs\\.?)?$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#296) a dropped prenominal takes the name position it occupies" -# 'Do Quang Minh': 'do' left TITLES -- a postnominal (D.O.) and a -# Vietnamese surname, never a prenominal -- so a leading 'Do' is no -# longer peeled as a title and falls through to the positional read: -# title 'Do', given 'Quang' -> given 'Do', middle 'Quang'. The -# 2026-07-30 audit table's disposition; 'phd', 'jr', 'junior' and -# 'se' left with it, and no corpus name leads with a bare one (the -# three that lead with 'Jr.' are read by the period inference and do -# not move). The rule's second name is 'MD, DO, DDS': title 'DO' -> -# given 'DO', the comma anchor's one corpus hit. `family` -# is deliberately OUT of the field list: the family name must not -# move on this rule, so a regression there stays loud. The anchors -# are the two openings a name has (start, or after a family comma). -name_regex = "(?i)(^|,\\s*)do\\b" -fields = ["title", "given", "middle", "_ambiguities"] - -[[change]] -issue = "fix(#296) dr is not postnominal vocabulary, so a trailing Dr. is a name word" -# 'dr Vincent van Gogh dr' with its #100 siblings (and, at 2.x only, -# 'Smith Dr', which 1.4.0 already read as the family): 'dr' left -# SUFFIX_WORDS, where it was v1 residue -- 'Dr.' is not a postnominal -# in any tradition -- so a trailing bare 'Dr' is no longer suffix -# vocabulary and falls to the positional read, taking the family name -# with it. -# -# 2026-09-08: 'John Smith Dr.' has LEFT this rule, and the sentence -# that used to close this comment has been retracted with it. It read -# "NOT a new class: no trailing title-only word routes to title on -# the no-comma path ('John Smith Prof.' reads family 'Prof.' at every -# baseline) ... #316 is the open question for the class". #316 is -# answered: a trailing PERIOD-MARKED listed title word now routes to -# title, so 'John Smith Dr.' moves {title, suffix} against this -# baseline and {title, middle, family} against 2.2.0, neither of them -# a subset of the three roles below, and the bundle rule `fix(#316) a -# trailing period-marked title word reads as a title` at the end of -# this file claims it. What stays here is the BARE spelling, which -# #316(b) leaves open: 'Smith Dr' and the 'dr ... dr' names wear no -# abbreviation period and the trailing walk never sees them. This -# rule is narrower than that one and stands first, so the DIFF -# decides which of the two claims a name, not file order. -# -# `middle` left the field list in the same edit: it was there for -# 'John Smith Dr.', whose middle name moved, and the three names the -# rule still explains move `family` and `suffix` alone. The gate said -# so itself -- OVER-DECLARED, "classify() matches by SUBSET, so the -# excess is not inert" (#452). -name_regex = "(?i)\\sdr\\.?$" -fields = ["family", "suffix"] - -[[change]] -issue = "fix(#296) a credential-only comma string reads a name and its postnominal" -# 'Jr., PhD' / 'MD, PHD': a string that is nothing BUT credentials. -# v1 read the trailing one as a title and the leading one as a first -# name; with 'phd' out of TITLES the comma structure reads it as a -# one-word name plus a postnominal -- a reading of a string nobody -# writes as a name, and the honest one for it. -# -# `given` and `family` are OUT: by 2.1.0 the comma-family move has -# already landed, so both names diff {title, suffix} alone at this -# baseline -- checked directly, 'Jr., PhD' reads last 'Jr.' on both -# the 2.1.0 release and the tree and only title 'PhD' -> suffix 'PhD' -# moves. The 1.4.0 copy of this same rule keeps `given`/`family`: -# there the pre-comma word is still v1's `first`, so the same names -# diff all four roles at that baseline and the wider declaration is -# earned, not excess. classify() admits by subset, so leaving the two -# roles out here is not passive -- a diff on either name that grows to -# move `given` or `family` falls out of this rule instead of being -# silently absorbed; measured against the narrowed ledger, nothing -# else in this file claims a {given, family, title, suffix} diff on -# these names either, so it would arrive UNEXPLAINED (#452). -name_regex = "(?i)^[a-z]{2,3}\\.?,\\s*phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) a lone post-comma credential is a suffix" -# 'Smith, Jr.' / 'Kim, Jr.' / 'Smith, PhD': rules.md#C1 -- the listing -# form holds with one word before the comma, and the word after it is -# the credential run. v1 put the family in `first` and the credential -# in `title`; 2.0 routed the pre-comma word to the family (the -# comma-family rule below) but the title peel still claimed the -# credential -- 'jr' and 'phd' were title vocabulary, and the -# period-abbreviation inference reads 'Jr.' as a title regardless -- -# so the postnominal position is read first now: a post-comma run -# that is nothing but suffix pieces is suffixes. A one-word name, a -# comma, one short word: the reach is pinned by the guards. -# -# `given` and `family` are OUT for the same reason as the -# credential-only rule above: the comma-family move already landed by -# 2.1.0, so 'Smith, Jr.' reads last 'Smith' on both the 2.1.0 release -# and the tree and diffs {title, suffix} alone -- checked directly. -# The 1.4.0 copy of this same rule keeps `given`/`family` because -# there the pre-comma word is still moving `first` -> `last`, the -# larger diff that baseline actually produces. classify() admits by -# subset, so a diff here that grows to move `given` or `family` falls -# out of this rule instead of being silently absorbed; measured -# against the narrowed ledger, nothing else in this file claims a -# {given, family, title, suffix} diff on these names, so it would -# arrive UNEXPLAINED (#452). -name_regex = "(?i)^[a-z]+,\\s*[a-z]{2,6}\\.?$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) a glued honorific before a lone credential: the credential is the postnominal" -# '田中さん, PhD': 'phd' left TITLES, so the lone post-comma credential -# is the suffix it is, and the glued-honorific peel (#312) reads 田中 -# and さん as before. title 'PhD' -> suffix 'さん, PhD'. -# -# `family` is OUT: at this baseline the glued-honorific peel (#312) -# already ships, so both the 2.1.0 release and the tree read family -# '田中' with さん already split into the postnominal position -- checked -# directly, only title 'PhD' -> suffix 'さん, PhD' moves. The 2.0.0 copy -# of this rule keeps `family` because #312 postdates that release: at -# 2.0.0 the whole 'さん' honorific is still riding on the family, so -# family '田中さん' -> '田中' is a real part of that baseline's diff. No -# 1.4.0 copy exists to compare against. classify() admits by subset, -# so a diff here that grows to move `family` falls out of this rule -# instead of being silently absorbed; measured against the narrowed -# ledger, nothing else in this file claims a {family, title, suffix} -# diff on this name either, so it would arrive UNEXPLAINED (#452). -name_regex = "(?i)^田中さん,\\s*phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) do is a name, so it no longer stops the leading-particle scan as a title" -# 'Dr. Do Van Johnson, MD': the fix(#367) title-and-particle rule that -# explained this name is gone with 'do' from TITLES -- a word in both -# the title and particle vocabularies stopped the transparency scan -# and stayed a title piece (master, after #367/#413, read title 'Dr. -# Do'); 'do' is a particle and a name now, and reads as the given -# name. At this baseline: given 'Do Van Johnson' -> given 'Do', -# family 'Van Johnson'; the title 'Dr.' is unchanged here. -name_regex = "(?i)^dr\\.?\\s+do\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#296) dr is not postnominal vocabulary, so 'John Smith, Dr.' keeps its split and its title" -# 'John Smith, Dr.' / 'John, Smith, Dr.': with 'dr' out of the suffix -# sets the post-comma 'Dr.' is a title only, so the comma is followed -# by nothing but titles and the pre-comma name keeps its split (the -# no-name-word repair): suffix 'Dr.' -> title 'Dr.' for the two-part -# spelling. The three-part spelling read suffix 'Dr.' (a third part -# was suffix by position) and moved only by gaining the -# COMMA_STRUCTURE report C2 gives a third part that is not suffix -# words, until #603 (2026-10-04) made a title word there a title: -# rules.md#C2, "a word of the title vocabulary there that is not also -# suffix vocabulary is a title". It now moves suffix 'Dr.' -> title -# 'Dr.' and no report, the same {title, suffix} as the two-part -# spelling, so `_ambiguities` left this rule's fields that day. -# -# The two-part spelling is ALSO compared under FAMILY_FIRST, as shape -# 4 of corpus_shapes.jsonl: the declared order applies to the -# pre-comma name on both sides, so the split does not move and the -# diff is the same {title, suffix} one from the same cause. That is -# why the name lists twice in this rule's block, once order-tagged -- -# and why this rule's `orders` names exactly those two: unlike the -# four rules at the foot of this file it genuinely explains the -# default-order diff too, and unlike an order-blind rule it stops -# there. FAMILY_FIRST_GIVEN_LAST is not listed because no shape tags -# this string under it, so a diff arriving there would be one nobody -# has looked at. -name_regex = "(?i)^(john,?\\s+smith|ortega\\s+y\\s+gasset),\\s*dr\\.?$" -fields = ["title", "suffix"] -orders = ["DEFAULT", "FAMILY_FIRST"] - -[[change]] -issue = "fix(#325) a split credential followed by another suffix after a one-word family comma reads as suffixes" -# 'Smith, Ph. D. Jr.' and the #325 rows it leads ('Smith, Ph. D. MD', -# 'Smith, Ph. D. III', 'Smith, Ph. D. Jr. MD', 'Smith, Ph.D. Jr.', and -# 'Smith, PhD Jr.', which the regex reaches too -- title 'PhD' at -# every baseline, suffix 'PhD Jr.' now; this line read -# 'PhD, Jr.' and had been stale since #429 made the run one -# entry, corrected 2026-09-06): -# with one word before the comma the name is the listing form, and -# the space-split 'Ph. D.' followed by another suffix was no longer -# the LONE post-comma piece, so it fell through to the given name -- -# a 1.4.0 regression (v1 read suffix 'Ph. D.', title 'Jr.'). The -# whole run is suffixes now. Literal rather than the shape: a shape -# regex absorbed 'Smith, Dr. Jr.', which is the comma-family rule's -# (the comment review). -# 2026-09-27, #544: 'Smith, PhD MEng' is reached too, as 'Smith, PhD -# Jr.' is -- the unambiguous PhD anchors the MEng behind it (rules.md#S2's -# company clause), so the run after the one-word family comma collapses -# whole into `suffix` where the tree before #544 read given 'PhD', -# middle 'MEng'. -# 2026-09-28: the regex still reaches 'Smith, PhD MEng', and now 'Smith, -# PhD Ma', but explains neither: each reports the pick its member is, -# and this rule declares no `_ambiguities`. Both are the -# fix(#296/#544) rule's at the foot of this file. -name_regex = "(?i)^smith,\\s*ph\\.?\\s?d\\.?\\s" -fields = ["title", "given", "suffix"] - -[[change]] -issue = "fix(#325) a credential run across a second comma reads as suffixes" -# 'Smith, Jr., PhD': title 'Jr.', suffix 'PhD' -> suffix 'Jr., PhD'; -# segments 2+ compose with the run. -name_regex = "(?i)^smith,\\s*jr\\.?,\\s*phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#296) an ambiguous acronym counts as a suffix only when written with its periods" -# 'Jack Ma.': rules.md#S2 -- "written with its periods, one after each -# letter"; a single trailing period is the abbreviation shape any -# word can wear. The gate was "any period" until #296's review, so -# suffix 'Ma.' -> family 'Ma.', which is how 1.4.0 read it. -name_regex = "(?i)^jack\\s+ma\\.$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424) the particle chain stops before the trailing numeral" -# 'John van der Berg V': rules.md#P2 -- "a trailing suffix begins -- the -# trailing run S2 reads, once, before the chain is made [...] a trailing -# roman numeral, or a bare acronym with words to spare, ends the chain -# as a suffix word does". last 'van der Berg V' -> family 'van der -# Berg', suffix 'V', as 'John Smith V' reads. Shipped since 1.x: the -# chain's stop asked with the suffix-piece test, whose initial veto does -# not see a bare V. A rules.md example; no differential corpus name has -# the shape. The fork's report arrives with the reading. -name_regex = "(?i)^john\\s+van\\s+der\\s+berg\\s+v$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424) the particle chain stops before a bare acronym with words to spare" -# 'John van der Berg Ma': the rule above, on S2's other fork. family -# 'van der Berg Ma' -> family 'van der Berg', suffix 'Ma', as 'John -# Smith Ma' reads -- and as 1.4.0 read it, so this rule has no 1.4.0 -# twin: a 2.0 regression. A rules.md example; no differential corpus -# name has the shape. -# -# 2026-09-18 (#289): the MIXED-CASE spelling stops diffing here, and -# the rule survives on the one-case one. The surname lean -# (rules.md#S2) never lets the peel take a Title-case 'Ma', so -# 'John van der Berg Ma' reads family 'van der Berg Ma' again -- -# which is exactly what this baseline reads. What the chain does when -# the acronym IS taken is unchanged, and the one-case spelling -# 'john van der berg ma' shows it: no case contrast, the count -# decides, the chain stops before the acronym and the name reads -# family 'van der berg', suffix 'ma'. That spelling entered the -# corpus with rules.md#P2's own contrast pair in 2.4, which is why -# this rule went briefly dormant and then woke in the same commit. -# The case-insensitive regex was already written to reach both. -name_regex = "(?i)^john\\s+van\\s+der\\s+berg\\s+ma$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#424/#445) the maiden walk stops before the trailing numeral, and the lone name word is the family" -# 'John née Jones Smith V': rules.md#M2 -- "It takes them up to the -# trailing run of post-nominals and titles that the end of the name -# reads as if the clause were not written", and 'John V' reads the V as -# the suffix. maiden 'Jones Smith V' -> maiden 'Jones Smith', suffix -# 'V'. The walk's stop asked with the suffix-piece test too. No 1.4.0 -# twin: v1 had no maiden support, and the fix(#274) rule carries the -# name there. A rules.md example; no differential corpus name has the -# shape. -# -# Renamed for #445 (2026-08-27) rather than widened in place, because -# the diff has two causes now and one rule has to explain the whole -# of it. The walk leaves a single name word, and rules.md#M4 makes -# that word the family: given 'John' -> family 'John' joins the three -# fields above. Attributing that to #424 would be false, and leaving -# the declaration at three fields left this rule explaining nothing -# while the name went UNEXPLAINED -- which is how the gate reported -# it before this edit, at 2.0.0 and 2.1.0 alike. No 1.4.0 twin to -# rename: the fix(#445) rule there carries this name. -name_regex = "(?i)^john\\s+n[ée]e\\s+jones\\s+smith\\s+v$" -fields = ["given", "family", "suffix", "maiden", "_ambiguities"] - -[[change]] -issue = "fix(#369) the bound given-name join takes a particle-and-bound word, so no fork is reported" -# 'Sheik Abu Bakar': the fields are byte-identical to this baseline -# (the rule above); what moves is the REPORT. Through 2.1 the particle -# chain took 'Bakar' and reported PARTICLE_OR_GIVEN. The join that -# takes it now is P5's, licensed by the given-name title, and a bound -# word read as the bound word is not a fork. -name_regex = "(?i)^sheik\\s+abu\\b" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#360) ste moved into the never-given particles with mc" -# 'Ste Marie': the second word of the same vocabulary move the -# fix(#360) rule below explains for `mc`, and that rule named it -- -# "'Ste Marie', 'Los Santos' and 'Das Silva' all move the same way ... -# The others should arrive UNEXPLAINED and be read once." One has. -# -# A sibling rule rather than an alternation over the moved words: the -# guards allow one vocabulary alternation per rule, and an alternation -# here would copy PARTICLES for a two-member set whose other members -# ('los', 'las', 'das') are still not in any corpus. -name_regex = "(?i)^ste\\s+\\S+$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#399) a maiden marker bounds the particle chain: the geb. spelling" -# 'Ursula von der Leyen geb. Albrecht': #399's own headline name, -# which reached the corpus through the backtick harvest (#413) -- it -# sat in that issue's TITLE while #399 shipped noting the class was -# invisible to this harness. -# -# A sibling of the fix(#399) rule rather than a widened marker -# alternation: the marker slot there is a character class, and making -# it an alternation over MAIDEN_MARKERS would be a second vocabulary -# alternation, which the guards allow only one of per rule. The -# particle alternation is identical to that rule's, deliberately -- -# both resolve to the same _LATIN_ALTERNATION_SOURCES key and must -# cover the same set. -name_regex = "(?i)^(?![^\\n]*,)[^\\n]*\\b(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)\\b.*?\\sgeb\\..?\\s+\\S+$" -fields = ["family", "maiden"] - -[[change]] -issue = "fix(#399) a maiden marker bounds the particle chain: a native-script marker" -# 'Jane van der Berg 旧姓 Jones': the cross-script pair -- a Latin -# tussenvoegsel and a Latin maiden name either side of a native-script -# marker. The stop is one vocabulary lookup, so it reaches every marker -# spelling; this is the only corpus name that shows it. -# -# The marker is a literal rather than an alternation, so no CJK -# honorific declaration is needed: the rule is about the particle -# chain, not about the marker vocabulary. -name_regex = "(?i)\\b(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)\\b.*?\\s\u65e7\u59d3\\s+\\S+$" -fields = ["family", "maiden"] - -[[change]] -issue = "fix(#424) an unlisted abbreviation is as transparent as a listed title to the leading particle" -# 'Esq. van Gogh' (corpus_issues): rules.md#P4 -- "An unlisted -# abbreviation before the particle is as transparent as a listed -# title, since assign reads it as one (H2)". family 'van Gogh' -> -# given 'van', family 'Gogh', with the PARTICLE_OR_GIVEN report, as -# 'Dr. van Gogh' reads since #367. #367 keyed the leading-particle -# exception on the first piece of the name by group's title test, -# which does not see H2's abbreviations; the scan asks assign's test -# now. Literal for the reason the #367 rule gives. -name_regex = "(?i)^esq\\.\\s+van\\s+gogh$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#367) a title no longer displaces a leading particle out of the leading position" -# 'Mr. Van Nguyen': a leading particle deliberately does not chain -- -# that is what makes 'Van Nguyen' read as given Van, family Nguyen. -# Through 2.1 the exception was keyed on piece index 0, so a TITLE -# displaced the particle and the chain fired, giving family -# 'Van Nguyen' (v1's reading, which is why this name is parity with -# 1.4.0 until now). 2.2 keys it on the first piece of the NAME, so a -# titled name reads as the same text reads untitled. -# -# The wider class is any title followed by a particle -- 'Dr. Van -# Johnson', 'Sir de Mesnil', 'Sheik Abu Bakar' all move (the last restored -# by #369) -- and this -# regex names one title and one particle because 'Mr. Van Nguyen' is -# the one name in the corpora that moves. Deliberately narrower than -# the class: a corpus name that joins it later should arrive -# UNEXPLAINED and be read once, not be absorbed silently. Widen the -# literals then, and record the new count. -# -# Three joined it, through #413's backtick harvest: 'Dr. Van Johnson', -# 'Sir Van Johnson', 'Jr. Van Johnson'. The title slot is now an -# alternation over TITLES, which is what all four spellings copy -- -# `jr` is also suffix vocabulary and reads as a title here simply -# because it is in TITLES and leads -- 1.4.0 already gives title 'Jr.' -# for 'Jr. Van Johnson', so that reading predates #367. (Not the -# stops-the-scan mechanism described below: that one is about a word in -# both the title and PARTICLE vocabularies FOLLOWING a title.) `\.?` earns its place now that the literals -# are spelled out: 'Sir' and 'Jr.' differ in whether the period is -# there. `van` stays literal -- the particle half of the class is -# wider ('Sir de Mesnil' has its own rule) but only `van` names in -# this title set are in the corpora. -# -# The title slot is a LITERAL for that reason and not by accident. -# Nothing in a regex over the raw string can ask whether a word is a -# title, so the `(?i)^\S+\.?\s+van\b` this rule shipped with matched -# ANY first word: it reached 11 corpus names and explained 1. The -# other ten include 'Vincent van Gogh', 'VINCENT VAN GOGH' and -# 'Alex van Johnson' -- the "Van Johnson" family AGENTS.md names as a -# standard regression canary. name_regex rules sort ahead of -# fields-only ones and `fields` matches by subset, so a future -# regression on any of those would have been labelled fix(#367) and -# exited 0 -- a strict loss at this baseline, whose ledger was empty -# before. (`\.?` was inert into the bargain: `\S+` is greedy, so it -# had already taken the period.) -# -# `fields` is what carries the rest of the tightness -- and it is narrower -# than the change rather than a description of it. The change moves -# `title` as well: a word in BOTH vocabularies stops the transparency -# scan and stays a title piece instead of being chained onto the name, -# so 'Dr. St John Smith' goes from title 'Dr.', family 'St John Smith' -# to title 'Dr. St', given 'John', family 'Smith'. It moves `maiden` -# as well: un-chaining lets a marker standing behind the particle be -# seen at all, so 'Mr. Van Johnson nee Brown' goes from family -# 'Van Johnson nee Brown' with no maiden name to given 'Van', family -# 'Johnson', maiden 'Brown'. `suffix` and `nickname` are the two that -# genuinely never move. -# -# `fields` names the two this rule's diffs DO move and no others, so -# `title`, `maiden` and `middle` are all left out on purpose, for the -# reason the regex names only `van`. It is `fields` that keeps the -# wider shapes out rather than the regex: `^(mr|dr|sir)\.?\s+van\b` has -# no end anchor and does reach 'Mr. Van Johnson nee Brown' -- checked -# directly, that name diffs {given, family, maiden} at this baseline, -# outside `fields`, and classify() returns nothing for it here. A diff -# growing into `title` or `maiden` falls out of this rule the same way -# rather than being silently absorbed; it should arrive UNEXPLAINED -# and be read once (#452). -# -# The 1.4 ledger carries the same rule, where it also has a second job: -# there the diff was already being absorbed by that file's fields-only -# fix(suffix-routing), a rule about a trailing credential whose -# ["given", "family", "suffix"] is a superset of this diff's fields. No -# fields-only rule exists here, so this file's copy is the plain -# classification. -# -# `middle` is OUT too, the same excess the paragraphs above already -# argue for `title` and `maiden`: the diff on the three names this rule -# actually claims here -- 'Mr. Van Nguyen', 'Dr. Van Johnson', 'Sir Van -# Johnson' -- is {given, family} alone at this baseline, checked -# directly against the released 2.1.0 and the tree (none of the three -# has a third name piece, so `middle` reads empty on both sides). -# classify() admits by subset, so leaving it out is not passive: a -# diff that grows to move `middle` falls out of this rule instead of -# being silently absorbed. Measured against the narrowed ledger, -# nothing else in this file claims a {given, family, middle} diff on -# any of the three names either, so it would arrive UNEXPLAINED and be -# read once rather than have a widened rule pre-built for it (#452). -name_regex = "(?i)^(mr|dr|sir)\\.?\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#367) an inferred title no longer displaces a leading particle either" -# 'Jr. Van Johnson': the same change as the rule above, for a leading -# piece the period-abbreviation inference reads as a title (H2). It -# sat in that rule's alternation as 'jr' while 'jr' was title -# vocabulary; #296's audit took it out (a postnominal only), so the -# member would now reach a word the vocabulary does not ship, and the -# shape gets its literal. The reading is unchanged: 'Jr.' is still a -# title by shape, and Van is still the leading name piece. -# -# `middle` was declared here too, the same excess the 'Mr./Dr./Sir' -# rule above drops and for the same reason: this rule's one name, -# 'Jr. Van Johnson', diffs {given, family} alone at this baseline, -# checked directly against the released 2.1.0 and the tree (no third -# name piece, so `middle` reads empty on both sides). classify() admits -# by subset, so leaving `middle` out means a diff that grows to include -# it falls out of this rule instead of being silently absorbed. -# Measured against the narrowed ledger, nothing else claims a -# {given, family, middle} diff on this rule's name either, so it -# arrives UNEXPLAINED and is read once rather than have a widened rule -# pre-built for it (#452). -name_regex = "(?i)^jr\\.\\s+van\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#380) a trailing vd after a family comma is the tussenvoegsel, not a post-nominal" -# 'Berg, Jan vd': the #380 half of the tussenvoegsel change, and the -# fix(#379) rule below predicted this arrival in writing -- "No corpus -# name exercises that shape, and adding `suffix` to claim it costs -# three shapes this rule has no business explaining ... If a vd-shaped -# name ever enters the corpus it should arrive unexplained and be read -# once." It has, via corpus_rules.jsonl (#414), and this is the once. -# -# `vd` is in both the particle and the suffix-acronym vocabulary. 1.4.0 -# and 2.1 alike read the trailing token as a post-nominal; 2.2 attaches -# it to the family the comma already named, on the judgment that a -# trailing abbreviation after a family comma is the tussenvoegsel far -# more often than the decoration it collides with. rules.md#P6 states -# it. -# -# Fields are {family, suffix} -- deliberately NOT a superset of -# fix(#379)'s {middle, family}. The two halves move different fields -# because they start from different readings: #379's names had the -# particle in `middle`, this one had it in `suffix`. A rule spanning -# both would claim {middle, family, suffix}, which is the shape #379's -# comment refused for exactly this reason. -# -# `vd` is a LITERAL, not an alternation over the dual-membership words. -# The release log names `mc` as sweeping in on the same shape, and no -# corpus name exercises it -- so an mc-after-comma name should arrive -# UNEXPLAINED and be read once, the same discipline that produced this -# rule. -# -# `_ambiguities` joined the diff in #405, which made the attachment -# report the fork it decides. Measured on THIS ledger's run: both names -# this rule explains -- 'Berg, Jan vd' and 'Berg, abdul vd' -- move -# {family, suffix, _ambiguities}, each gaining SUFFIX_OR_NAME, the kind -# that names the post-nominal reading the attachment declined. Neither -# name moves a field it did not move before, so the widening admits one -# more role and no more names; the union over the two is exactly the -# three declared. A separate fix(#405) rule was tried first and cannot -# work: classify matches on `fields` being a SUPERSET of the whole -# diff, so an `_ambiguities`-only rule matches neither name and this -# rule would go dormant with both of them unexplained. -name_regex = "(?i)^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*\\svd\\s*$" -fields = ["family", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#399) a maiden marker bounds the particle chain that swallowed it" -# 'Jane de la née Jones', 'Jane van der Berg née Jones', 'John van der -# Berg née Jones': grouping's particle chain absorbed everything up to -# the next particle or suffix piece, and the maiden marker is consumed -# later in the SAME stage -- by then the chain had merged the marker -# away and there was no lone marker piece left to find, so the family -# kept both the marker and the maiden name. rules.md#M2 states the -# stop; decisions.md#M2 records it. -# -# Fields are {family, maiden}: the given name does not move. The -# `orders` key below says the rest of it -- this shape IS compared -# under a declared order, on a name this regex reaches, and the rule -# has nothing to say about that reading. -# -# The rule requires a PARTICLE ahead of the marker because that IS the -# defect -- a marker with no particle in front of it was always -# consumed correctly. 'Jane Smith née Jones' is in the corpora and does -# NOT move, so the particle requirement is what keeps this rule off it. -# -# The three anchors each keep the rule off a shape M2 states as -# unchanged, and they are worth naming because the first cut of this -# rule claimed all three. Requiring exactly one word after the marker -# keeps it off the trailing-marker shape ('Jane van der Berg nee', -# which M2 leaves as an ordinary name word) and off the connective -# join ('Jane van der Berg nee y Jones', M2's accepted limit, #412). -# The leading no-comma lookahead keeps it off the post-comma -# bound-given join ('van der Berg, abdul nee Jones', the other limit). -# -# One name is still claimed that does NOT move, recorded rather than -# fixed: 'Jane van der Berg nee PhD', where the only word after the -# marker is a SUFFIX, so the consumer declines and M2 leaves the -# marker in the family. Excluding it needs the regex to ask whether a -# word is suffix vocabulary, which would be a second vocabulary -# alternation -- and the guards allow one per rule, spent here on -# PARTICLES. A future {family, maiden} regression on that one name -# would classify as intended. Named for the same reason fix(#379) -# names 'Nguyen, Van': an over-claim someone can find is survivable, -# an unrecorded one is not. -# -# One marker spelling, spelled out rather than alternated over -# MAIDEN_MARKERS: the three names that move all use it, the guards -# allow one vocabulary alternation per rule and this rule spends its -# on PARTICLES, and a differently-spelled name of this shape should -# arrive UNEXPLAINED rather than be absorbed. -name_regex = "(?i)^(?![^\\n]*,)[^\\n]*\\b(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)\\b.*?\\sn[e\u00e9]e\\s+\\S+$" -fields = ["family", "maiden"] -# Scoped to the DEFAULT order: every diff this rule explains is a -# default-order one. The regex reaches 'de la Cruz n\u00e9e Vega', which -# corpus_shapes.jsonl tags under both family-first orders, and this -# rule sorts ahead of the two rules written for that name -- so -# order-blind it would absorb a family-first regression there and call -# it intentional. -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#360) mc moved into the never-given particles, so it folds into the family" -# 'Mc Donald': a DATA change rather than a rule change (6389f06). `mc` -# and `ste` moved out of the ambiguous half of the particle vocabulary -# into the never-given half, and `los`, `las` and `das` were added -# having been absent entirely. A never-given leading particle folds -# into the family under rules.md#P1, so 'Mc Donald' goes from given -# 'Mc', family 'Donald' to family 'Mc Donald' -- and the -# PARTICLE_OR_GIVEN ambiguity it used to report goes away, the fork -# having stopped being a fork once the word can never be a given name. -# -# Fields are {given, family, _ambiguities}. The ambiguity is claimed -# rather than left out because it genuinely moves and the comparator -# would otherwise leave the diff unexplained: once `mc` can never be a -# given name there is no particle-or-given fork left to report, so the -# report disappearing is part of this change rather than a separate -# one. `_ambiguities` is the v2 surface only. -# -# `mc` is a LITERAL and the rule is anchored at the start. The class is -# every word the commit moved -- 'Ste Marie', 'Los Santos' and 'Das -# Silva' all move the same way -- and only 'Mc Donald' is in the -# corpora. The others should arrive UNEXPLAINED and be read once. -name_regex = "(?i)^mc\\s+\\S+$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#411) the bound-given reserve stops counting words the maiden name takes" -dormant = "no corpus name since #601 (2026-10-04): the rules.md#M2 examples that carried this shape left the doc with the walk they illustrated. The reserve still counts only the words the clause leaves (the take runs before every join), so the rule is kept for the next name that shows it" -# 'van der Berg, abdul nee Jones': P5 reserves a name word so the join -# always leaves a family name behind, but the reserve was counted while -# the marker and the maiden name were still pieces -- group's marker -# pass runs later in the same stage. Counting words that were about to -# leave answered the question about a name that would not exist. -# -# The fields here are the SIDE EFFECT rather than the defect. #411's -# own shape is an emptied family ('abdul Berg nee Jones' read given -# 'abdul Berg', family ''), and no BASELINE shows it -- every released -# version already reads the corpus names carrying that shape the way -# the fix does, so none of them can produce a #411 diff to classify. -# ('abd Berg nee Jones' IS in corpus_rules.jsonl, and goes unexplained -# at all three baselines if the fix is reverted, which is the guard -# working.) -# -# What this corpus -# name shows is the other half: with the reserve counted correctly the -# join declines, so the marker is never merged into 'abdul nee' and -# M2's bound sees a lone marker piece for the first time. That closes -# one of the two join-swallows #412 tracks; P3's connective join is -# the one that survives. -# -# `abdul` is a LITERAL, not an alternation over BOUND_GIVEN_NAMES, and -# the words are NOT interchangeable here. 'abd' is the one shipped word -# in both the bound-given and the suffix vocabulary, so after a family -# comma a declining join leaves the S2 suffix reading instead: 'van der -# Berg, abd nee Jones' moves {given, middle, suffix, maiden}, and the -# extra field breaks the subset test, so it would arrive UNEXPLAINED -- -# correct, but not what an alternation over the vocabulary would lead -# an author to expect. 'abdel', 'abdal' and the Arabic spelling do -# behave like 'abdul'; only this name is in the corpora. -name_regex = "(?i),\\s*abdul\\s+n[eé]e\\b" -fields = ["given", "middle", "maiden"] - -[[change]] -issue = "fix(#400) abd joins the word after it as one given name" -# 'abd Allah Smith' and 'Salam, abd Allah': `abd` was added to -# BOUND_GIVEN_NAMES so the spellings that write the article as its own -# word join like `abdul`, `abdel` and `abdal` already did. Both the -# plain and the post-comma given side move, which is why the rule -# allows either opening. -# -# Fields are {given, middle}: the word joins forward within the given -# name, so nothing leaves the given side. The family name does not -# move -- 'abd Allah Smith' keeps family 'Smith'. -# -# `abd` is a LITERAL and word-bounded. It is also the postnominal ABD -# and stays in SUFFIX_ACRONYMS, where position tells the two apart, so -# the anchors matter: a trailing 'Jane Smith ABD' is a suffix and must -# not be claimed here. -# -# A third corpus name is claimed and does NOT move: a bare 'abd Allah', -# which has no third word for the join to leave as a family name, so -# P5's reserve declines and it reads given 'abd', family 'Allah' at -# every baseline. Same behaviour family as the two that move, so the -# claim is defensible -- recorded so it is not mistaken for coverage. -name_regex = "(?i)(?:^|,\\s*)abd\\s+\\S+" -fields = ["given", "middle"] - -[[change]] -issue = "fix(#367) a title no longer displaces a leading never-given particle" -# 'Sir de Mesnil': the same change as the fix(#367) rule below, on the -# other half of the particle vocabulary, and that rule predicted this -# arrival by name -- "The wider class is any title followed by a -# particle -- 'Dr. Van Johnson', 'Sir de Mesnil', 'Sheik Abu Bakar' all -# move ... a corpus name that joins it later should arrive UNEXPLAINED -# and be read once". It has, via corpus_rules.jsonl (#414). -# -# A SEPARATE rule rather than widened literals, which is what that -# comment asked for, because the guards allow one vocabulary -# alternation per rule: widening both the title and the particle slot -# needs two, and their `covers` sets cannot both be recorded under one -# key. Two rules with literal pairs say the same thing and stay -# checkable. -# -# The fields differ from the sibling's for a real reason, not just -# tightness. There the particle was ambiguous, so un-chaining moved it -# to `given` and left a family behind: {given, middle, family}. Here it -# is never-given, so P1's fold takes the whole name into the family and -# the given side empties: {given, family}. -name_regex = "(?i)^sir\\s+de\\b" -fields = ["given", "family"] - -[[change]] -issue = "fix(#379) a tussenvoegsel after a family comma attaches to the family" -# 'Vega, Juan de la': the Dutch alphabetized listing moves the -# tussenvoegsel behind the given name -- "Beethoven, Ludwig van" is how -# "Ludwig van Beethoven" is filed -- and 1.4 read the trailing particle -# run as a middle name. 2.2 attaches it to the family already named by -# the comma and renders it before the base, so `middle` empties and -# `family` grows. rules.md#P6 states it; the derived views move with it -# (family_particles 'de la', family_base 'Vega'). -# -# Fields are {middle, family}: the given name does not move. `suffix` -# is deliberately NOT here, though the #380 half of the change does -# move a token out of it ('Berg, Jan vd' was suffix 'vd' at every -# baseline and is now family 'vd Berg'). Adding `suffix` to claim it -# costs three shapes this rule has no business explaining -- -# {family,suffix} taken from fix(#380) above, and {middle,suffix} -# and {middle,family,suffix} taken from UNEXPLAINED, the last two -# being diffs in which `family` does not move at all. A rule that -# pre-claims shapes it has never seen is the #372 failure mode. -# -# 'Berg, Jan vd' is a corpus name, and it did not arrive unexplained -# here either: fix(#380) above already claims {family, suffix} for -# exactly this shape, in this same ledger, so there was never a gap -# for a future rule to fill at this baseline the way there was at -# 1.4.0's. -# -# Latin-anchored over the WHOLE name, not just the trailing word: with -# a leading .* this absorbed non-Latin comma names whose diff has -# another cause -- a Cyrillic 'Smith, de la' moves {family} because the -# particle's RENDERING ORDER inside the family changed, which no rule -# in this ledger names, and it classified here. (No such name is in -# any corpus, so this is what WOULD happen. It would now arrive -# UNEXPLAINED and block the gate until someone read it, which is the -# right outcome: this ledger has no fields-only rule to absorb it. -# The claim that it "falls to the fields-only catch-all" was copied -# from the 1.4 ledger, where one existed until #451 deleted it.) -# The class is the -# neighbours' and compare.py's own: U+0000-U+024F, which stops at -# Latin Extended-B, so a diacritic-bearing 'Nguyen, Van de la' in NFD -# or Latin Extended Additional is outside it. -# -# What the rule still over-claims is vocabulary, not script: any Latin -# comma name ending in an alternation member is claimed whatever the -# naming tradition, so a future {middle,family} regression on -# 'Nguyen, Van' -- one of the two corpus names it reaches, and the one -# that does NOT move -- would classify as intended. -# -# `_ambiguities` joined the diff in #405, which made the attachment -# report the fork it decides. Measured on THIS ledger's run, the seven -# names split: five move {middle, family, _ambiguities} -- 'Vega, Juan -# de la', 'Beethoven, Ludwig van', 'Berg, Jan van der', 'der, y van' -# and 'Nguyen, Thi Van', each gaining PARTICLE_OR_GIVEN, their particle -# being vocabulary that could have been a name word -- and two still -# move {middle, family} alone: 'Jong, Anke de' and 'Jong, Piet de', -# whose `de` can be no name in any reading, so nothing was declined and -# nothing is reported. The union over the seven is the three fields -# declared, which is what the over-declaration check asks for; narrowing -# to {middle, family} would orphan the five. A separate fix(#405) rule -# cannot take them instead: classify matches on `fields` being a -# SUPERSET of the whole diff, so an `_ambiguities`-only rule matches -# none of the five, and any rule wide enough to match them is this rule -# plus a role -- which then also claims the two silent names and leaves -# this one dormant. -name_regex = "(?ai)^[\\u0000-\\u024f]*,[\\u0000-\\u024f]*\\s(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu)(\\s+(de|del|den|der|di|do|dos|du|la|le|los|mc|van|vd|von|zu))*\\s*$" -fields = ["middle", "family", "_ambiguities"] - -[[change]] -issue = "fix(#412) a connective join no longer absorbs the maiden marker beside it" -# 'Jane van der Berg née y Jones' and 'Jane née and Jones Smith': the -# last of the join-swallows. rules.md#M2 takes a marker standing as a -# word of its own, and rules.md#P3's connective join ran first in the -# grouping stage and merged the marker into a multi-word piece, so the -# bound never saw one -- the family kept 'née y Jones', or the -# marker-headed join read as a middle name. The marker pass now runs -# before every join (#418 is the same order defect in P3's word -# count), so the marker is taken and the words after it, connective -# included, are the maiden name: family 'van der Berg', maiden -# 'y Jones'; given 'Jane', maiden 'and Jones Smith'. -# -# Checked against this baseline's own run, not copied: both names -# arrive UNEXPLAINED here and at 2.0.0 alike, and nothing else does. -# Since #445 (2026-08-27) this rule explains only the first of the -# two: the marker in 'Jane née and Jones Smith' takes the rest of the -# name and leaves one name word, so `given` empties into `family` and -# the diff outgrew these fields. It has a rule of its own at -# the end of this file. The names were split rather than the fields -# widened, because 'Jane van der Berg née y Jones' keeps a given name -# and adding `given` here would have pre-excused its loss. -# At 1.4.0 fix(#274) already classifies them -- v1 has no maiden -# field, so every marker-bearing name is a diff there. -# -# The regex asks for a marker IMMEDIATELY followed by a connective, -# which is the shape the corpora carry. The join reached the marker -# from the other side too -- 'Jane Smith and née Jones' read middle -# 'Smith and née' and now reads family 'Smith and', maiden 'Jones' -- -# but no corpus name has that shape, so the rule does not claim it; -# one arriving lands UNEXPLAINED and is read once, as a new name -# should be. A marker followed by a name word ('Jane van der Berg née -# Jones') is fix(#399)'s and does not move. -# -# `middle` is OUT: checked directly against the released 2.1.0 and the -# tree, the one name this rule now explains diffs {family, maiden} -# alone -- `given` 'Jane' and `middle` (empty on both sides) are -# unmoved; only `last` shrinks from 'van der Berg née y Jones' to -# 'van der Berg' and `maiden` fills with 'y Jones'. classify() admits -# by subset, so declaring `middle` here was not inert: a diff that -# grows to move it falls out of this rule instead of being silently -# claimed. Measured against the narrowed ledger, nothing else in this -# file claims a {family, maiden, middle} diff on this name either, so -# it would arrive UNEXPLAINED (#452). -name_regex = "(?i)\\bn[eé]e\\s+(y|and)\\b" -fields = ["family", "maiden"] - -[[change]] -issue = "fix(#418) the connective carve-out counts the name the maiden clause leaves behind" -# 'Juan y Garcia née Jones': rules.md#P3 keeps a single-letter -# connective a name word in a THREE-word name, and the count included -# the marker and the maiden name, which leave -- so this read as five -# words, 'y' joined, and once the clause left there was no family: -# given 'Juan y Garcia', family ''. The marker pass now runs before -# the count, and the name reads as 'Juan y Garcia' does plus a maiden -# name: given 'Juan', middle 'y', family 'Garcia', maiden 'Jones'. -# -# This is the example the fix added to P3, and it reached the corpus -# through the rules-corpus regeneration in the same change. The other -# eight corpus names with this defect show it only under an APPENDED -# clause ('John e Smith', 'Lt.Gov. juan e garcia', ...), which the -# gate cannot construct; the case table pins that half. -# -# Exactly three words, a single-letter connective second, then the -# marker: four name words ('Juan y Eva Garcia née Jones') join with or -# without the clause and do not move. -name_regex = "(?i)^\\S+\\s+[ye]\\s+\\S+\\s+n[eé]e\\s+\\S+$" -fields = ["given", "middle", "family"] - -[[change]] -issue = "fix(#418) accepted: a suffix word inside the maiden name ends it, connective or not" -# 'Jane née Jr y Jones': the one reading the marker-pass reorder costs, -# recorded as an Accepted row under rules.md#M2 and so in the rules -# corpus. The marker now reads the words as written, before any join, -# so 'Jr' ends the maiden name where the connective join used to bind -# 'Jr y Jones' into one name word first. The marker is then a word -# with nothing but a suffix after it (M2 declines), and the connective -# builds the family out of what is left: middle 'née', family 'Jr y -# Jones', no maiden name, against the baseline's maiden 'Jr y Jones'. -# -# Literal-anchored to the example: the class (a suffix homograph -# anywhere in the maiden name beside a connective) has no other corpus -# name, and a harvested one should arrive UNEXPLAINED and be read -# once rather than absorbed here. -name_regex = "(?i)^jane\\s+n[eé]e\\s+jr\\s+y\\s+jones$" -fields = ["middle", "family", "maiden"] - -[[change]] -issue = "fix(#429) a wholly-credential segment after a one-word family renders as one entry" -# 'Smith, MD PhD': one word before the comma and nothing but -# credentials after it. The baseline read the leading 'MD' as a title -# and left suffix 'PhD'; the segment is the credential run rules.md#C1 -# describes and now lands whole in `suffix`. -# -# The roles moved in #325; what #429 adds is the RENDER. #325 shipped -# them as 'MD, PhD' -- a comma the writer never typed -- because group -# decided "one comma segment is one suffix entry" by segment INDEX -# while assign decided the same segment by CONTENT. group asked -# assign's own predicate from #429, so the run renders as written -# and this path agrees with the full-name 'John Smith, MD PhD', -# which has rendered 'MD PhD' since 1.4.0. (2026-09-06, #436: the -# render is post_rules' now -- it reads the commas the writer typed -# rather than any predicate about segment content -- and 'MD PhD' -# here is unchanged by that move.) -# -# The name entered the corpus as the rules.md#C1 example that fix -# added, so this entry arrives with it rather than explaining a -# name that was already there. `family` and `given` are deliberately -# OUT of the field list: neither moves on this rule, so a regression -# in either stays loud. Literal-anchored -- the class has no other -# corpus name, and a harvested one should arrive UNEXPLAINED and be -# read once rather than absorbed here. -name_regex = "(?i)^smith,\\s*md\\s+phd$" -fields = ["title", "suffix"] - -[[change]] -issue = "fix(#430) a credential run does not end at the roman numeral describing it" -# 'Smith, PSM I' and 'Smith, PSM I.': the baseline read given 'PSM', -# suffix 'I' -- the run ended at the numeral because rules.md#S2's -# initial veto keeps a numeral out of a suffix reading, so the segment -# did not look like a credential run and fell to the given-and-suffix -# walk. Behind a SUFFIX the numeral is describing that suffix ('PSM I' -# is Professional Scrum Master level I), so the run continues through -# it, period included -- an initial in that position is no shape anyone -# writes. The full-name 'John Smith, PSM I' has read it this way all -# along; this brings the one-word family into line. -# -# `family` is deliberately OUT of the field list: the family name does -# not move on this rule, so a regression there stays loud. Anchored to -# the two example spellings -- the class has no other corpus name, and -# a harvested one should arrive UNEXPLAINED and be read once. -name_regex = "(?i)^smith,\\s*psm\\s+i\\.?$" -fields = ["given", "suffix"] - -[[change]] -issue = "fix(#432) a dotted numeral behind a name is a middle initial, not the generation" -# 'Smith, John V.': the baseline read suffix 'V.'. Behind a NAME word -# the period is decisive -- it marks an abbreviation, and an -# abbreviation is name material -- so the numeral is the middle initial -# it looks like. 'Smith, John B.' has always read middle 'B.'; the only -# thing that made V. differ is that V is also suffix vocabulary. -# -# The BARE form is the boundary and does not move: 'Smith, John V' is -# still suffix 'V' (v1 parity, #144), and it is a corpus name, so that -# half is asserted by the gate rather than by this entry's silence. -# `given` and `family` are out of the field list; neither moves. -name_regex = "(?i)^smith,\\s*john\\s+v\\.$" -fields = ["middle", "suffix"] - -[[change]] -issue = "fix(#410) a title and one name word name the family, whatever annotation stands beside it" -# rules.md#H1 reads a title plus exactly one name word as the family -# ('Mr. Johnson' -> family 'Johnson'). The guard implementing it also -# declined whenever ANY token carried a suffix, nickname or maiden -# role, so the very same name with an annotation beside it kept the -# word in `given` and reported no family at all. None of the three is -# a name word, so the term went, and the four names below move -# `given` -> `family` to read as their un-annotated twins already did. -# -# Keyed on the four names by literal rather than by shape. The shape -# is "a title, one name word, and an annotation" -- a parse tree, not -# a string -- and a fields-only rule standing in for it would classify -# every future given/family swap in this corpus. -# -# `fields` is exactly the pair that moves: no annotation changes hands -# here, which is the whole point of the fix, so a diff in `nickname`, -# `maiden` or `suffix` on these names stays loud. -# -# FIVE corpus names change reading under #410. These four are the ones -# that arrive as new diffs and so need classifying here. The fifth, -# 'Freiherr von Richthofen V', was already classified by the fix(#424) -# rule below and stays there: #410 narrows its diff from three fields -# to two, and that rule's declared fields are a superset either way, so -# nothing ever reported it. A real movement behind a green gate. -# -# So the claim is not "the only names of this shape" -- it is the only -# ones not already spoken for. Of the four, two carry a nickname, one a -# maiden clause, one a nickname leading the name. The suffix flavor is -# NOT absent from the corpus: that fifth name is a title, one name word -# and a suffix. What no corpus carries -# is the plain spelling 'Dr. Smith PhD', pinned by -# tests/v2/cases.py::suffix_stays_suffix_title instead. -name_regex = "(?i)^(?:Senator \"Rick\" Edmonds|Xyz\\. \\(Bud\\) Smith|Dr\\. Smith n[ée]e Jones|'Smitty' Dr\\. Jones)$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#434) a multi-word maiden marker takes the maiden name" -# 'Maria Kowalska z domu Nowak': maiden_markers gained PHRASE entries -# and the Polish "z domu" is the first one any shipped set holds, so -# rules.md#M2's take fires on a marker that spans two tokens. Every -# earlier version read the marker as name text -- middle 'Kowalska z -# domu', family 'Nowak' -- because neither z nor domu is a marker -# alone and nothing looked at the pair. -# -# `fields` is the three the diff moves and no more. The marker leaves -# the name, so `middle` empties and `family` moves back one word, -# which is why this cannot share a rule with the bracketed name below: -# there the clause changes role and the name words around it do not -# move at all. One rule over both would have to declare the union and -# would pre-excuse a middle/family regression on the bracketed -# spelling -- the #444 failure the fix(#335) rules above spell out. -# -# Keyed on the literal, like those rules and for their reason: the -# shape is "a marker phrase inside a name", a question about the -# parse, and a fields-only rule standing in for it would pre-excuse -# every future middle/family/maiden confusion in the corpus. -name_regex = "(?i)^Maria Kowalska z domu Nowak$" -fields = ["middle", "family", "maiden"] - -[[change]] -issue = "fix(#434) a multi-word marker leads a bracketed clause to the maiden name" -# 'Maria Kowalska (z domu Nowak)': the same vocabulary change at the -# other site. rules.md#M3 reads a marker-led clause as the maiden name -# whatever pair encloses it, and #335 gave it that reach in 2.2 -- but -# the reach alone leaves this name a nickname, since M3 asks whether -# the content opens with a recognized marker and through 2.1 no -# vocabulary answered yes here. The phrase entry is what moves it, so -# it is #434's diff and not #335's, and it is deliberately NOT one -# more alternative among that rule's literals. -# -# `fields` is the pair that moves. The clause changes role; the name -# words keep their readings, so a `given`, `middle` or `family` -# movement here is a regression and stays loud. -name_regex = "(?i)^Maria Kowalska \\(z domu Nowak\\)$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led bracketed clause reads as the maiden name whatever pair encloses it" -# 'Jane Smith (née Jones)', 'Jane (née Jones) Smith', 'Jane Smith -# (née V)', 'Anna Müller (geb. Schmidt)', 'Ursula von der Leyen -# (geb. Albrecht)', '山田 花子(旧姓 佐藤)': rules.md#M3 reads a bracketed -# clause whose content opens with a marker word and carries a word -# after it as the maiden name, whichever bucket the enclosing pair -# sits in. Through 2.1 the parenthesis pairs -- ASCII and fullwidth -# alike -- were NICKNAME delimiters by default and nothing looked -# inside them, so all six clauses came back as nicknames. -# -# `fields` is exactly the pair that moves and not one field more. The -# clause changes ROLE and nothing else here: the name words around it -# keep their readings, so a `given`, `middle` or `family` movement on -# any of these is a regression and stays loud. Worth stating -# because the convenient thing is to declare the union of what the -# same names move against OTHER baselines -- against 2.0.0 the -# CJK names also flip given/family -- and PR #444 is what that costs: -# fix(#424) there declared a field its diff had stopped moving, and a -# real movement was absorbed silently behind it until #444 narrowed -# the rule. -# -# '佐藤 花子 "旧姓・田中" (旧姓 鈴木)' joined on 2026-10-12 (#653), the -# case row that witnesses the #329 drop pass's nickname refusal: its -# paren clause is the marker-led one and moves exactly this pair, the -# quoted clause beside it staying a nickname at both ends. -# -# Keyed on the names by literal, like the #410 rule above. The -# shape is "a bracketed clause whose first word is a marker", which is -# a question about the parse rather than about the string, and a -# fields-only rule standing in for it would pre-excuse every future -# nickname/maiden confusion in the corpus. -name_regex = "(?i)^(?:Jane Smith \\(n[ée]e Jones\\)|Jane \\(n[ée]e Jones\\) Smith|Jane Smith \\(n[ée]e V\\)|Anna Müller \\(geb\\. Schmidt\\)|Ursula von der Leyen \\(geb\\. Albrecht\\)|山田 花子(旧姓 佐藤)|佐藤 花子 \"旧姓・田中\" \\(旧姓 鈴木\\))$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#335) a marker-led clause leaves the one name word its bare reading" -# 'Smith (née Jones)', which rules.md#N3 carries as an Accepted line. -# rules.md#N3: "A name that is only a nickname and one name word reads -# that word as the family name", and a marker-led clause is not a -# nickname clause, so N3 no longer reaches this shape: the word keeps -# the reading the bare "Smith née Jones" gives it -- `given` when this -# rule was written, and `family` since #445 gave the bare spelling -# the same reading from the other side. -# -# Its own rule rather than a seventh alternative in the fix(#335) -# rule above: one rule holding both would have to declare the union of -# two different diffs and would pre-excuse whatever else landed inside -# it. (The two now move the same two fields, but for different -# reasons -- that rule's names keep a family name throughout, while -# this one's gained one back from #445 -- and merging them would -# re-declare `given` over both.) -# -# The revisit note this block carried was answered on 2026-08-27 and -# answered WRONG, which the review round caught and this text -# records. The first answer said the diff was unchanged at this -# baseline "because the baseline is what read `given`". It did not: -# 2.0.0 and 2.1.0 both read 'Smith (née Jones)' as family 'Smith', -# nickname 'née Jones' (measured on the wheels, 2026-08-27) -- only -# the BARE spelling ever read `given`, and this rule is the -# bracketed one. So `given` is empty on both sides, `family` agrees -# again since #445, and the diff here shrank to {nickname, maiden} -# exactly as it did at 1.4.0. -# -# Narrowed to those two fields, which is what the revisit note asked -# for. Left at four, this rule stood over a two-field diff and -# classify's subset test would have explained a `given`/`family` -# regression on this name in silence -- the absorption the note was -# written to prevent, surviving the round that answered it. Verified -# after narrowing: the name is still explained, and a {given, family} -# diff on it is now UNEXPLAINED. -name_regex = "(?i)^Smith \\(n[ée]e Jones\\)$" -fields = ["nickname", "maiden"] - -[[change]] -issue = "fix(#445) a maiden marker makes the lone name word the family" -# rules.md#M4 -- "a maiden name standing beside exactly one name word -# makes that word the family name". A maiden name is a FORMER family -# name, which only means something beside a current one, so the word -# left standing beside it is the surname the bearer uses now: -# 'Smith née Jones' read given 'Smith' with an -# empty family on this baseline and reads family 'Smith' here. -# -# Two fields and no more. The marker consumption these names also -# carry is this baseline's reading already, so `given` and `family` -# are the whole diff -- which is what makes them one rule, and what -# keeps out the two names whose diffs are compounds (the connective -# join below, the trailing numeral above). -# -# Anchored, and spelled out one name per alternative, for the reason -# M4 itself gives. The shape regex a reader would write first -- one -# word, then a marker -- also matches M4's two carve-outs -# ('abd née Jones', 'J. née Jones Smith V', both corpus names and -# neither moving), and a rule declaring `given` over those two would -# stand ready to explain the very given -> family move the carve-outs -# exist to prevent. -# -# 'Smith née Jones' and 'Smith née Jones PhD' are corpus rows this -# change itself added, being M4's own example lines. -# 'John née Jones Smith Ma' sits here rather than with an acronym -# rule of its own: this baseline already reads it maiden 'Jones Smith -# Ma', so the acronym is no part of the diff. At 1.4.0, where v1 read -# suffix 'Ma', it is the one name of this class that needs one. -# 2026-09-19, #533: `_ambiguities` joins the two, and no name -# leaves or enters. The roles are unchanged by that change -- -# 'John née Jones Smith Ma' has its Title-cased member declined -# by its own writing, exactly as the count declined it before -- -# but the fork is now CONSULTED at the end of the clause and -# REPORTED, and the gate compares `_ambiguities` as a field. The -# other five names carry no member and contribute nothing to the -# union, which is why this widening is one field and not a rule. -name_regex = "(?i)^(?:Janey n[ée]e Jones|Jane n[ée]e Jones J\\. V|Jane n[ée]e Jones Smith|John n[ée]e Jones Smith Ma|Smith n[ée]e Jones|Smith n[ée]e Jones PhD)$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#445) the lone name word beside a marker a connective join no longer absorbs" -# 'Jane née and Jones Smith', whose diff is a compound of #412 and -# #445 and so cannot sit in either's plain rule. #412 stopped the -# connective join from swallowing the marker, so the marker is taken -# and 'and Jones Smith' is the maiden name; what that leaves is one -# name word, and rules.md#M4 makes it the family. middle -# 'née and Jones', family 'Smith' -> family 'Jane', maiden -# 'and Jones Smith'. -# -# Its own rule rather than a widening of the fix(#412) rule above, -# whose regex reaches this name too: the OTHER name it claims -- the -# one it explains, 'Jane van der Berg née y Jones' -- keeps a given -# name, and one rule holding both would have to declare `given` for -# both and pre-excuse its loss where losing it would be a regression. At 1.4.0 -# the fix(#445) rule carries this name with the same four fields, v1 -# having read the marker as a middle word. -name_regex = "(?i)^Jane n[ée]e and Jones Smith$" -fields = ["given", "middle", "family", "maiden"] - -[[change]] -issue = "fix(#371) a suffix never begins a name: the Ph./D. merge declines at the head" -# 'Ph. D. Van Johnson', 'Ph. D. John Smith', 'Ph. D.', 'Ph. D., Jr.': -# rules.md#S2 -- "A suffix never OPENS THE STRING: position outranks the -# vocabulary match". The v1 fix_phd merge joined the pair wherever it -# stood, which is what made a leading credential possible at all, and -# it emptied the family doing it: 'Ph. D. Van Johnson' read given -# 'Van Johnson' with no surname. Declining at the head of segment 0 -# leaves 'Ph.' to H2's abbreviation clause (title) and 'D.' to the -# positional read. -# -# RESTORES 1.4.0 on THREE of the four, exactly, which is why this -# ledger gains a rule while expected_since_1.4.0 loses three names: -# v1 matched the pair by regex mid-string and never merged it at the -# head either, so what shipped in 2.0 was a regression rather than a -# reading. Measured -# on the released 1.4.0 wheel: 'Ph. D. Van Johnson' -> title 'Ph.', -# first 'D.', last 'Van Johnson'; 'Ph. D. John Smith' -> title 'Ph.', -# first 'D.', middle 'John', last 'Smith'; 'Ph. D.' -> title 'Ph.', -# last 'D.'; 'Ph. D., Jr.' -> title 'Ph.', first 'D.', suffix 'Jr.' -# -- and that fourth name is NOT restored: v1 read first 'D.' where -# the pre-comma run now reads family 'D.', two roles apart. The 'D.' -# relocated rather than vanished, which is why the residual diff -# rides under `fix(comma-precomma-family)` (fields given+family) and -# not under this rule. That reading predates this change. -# -# `fields` is the union over the four names and nothing wider: title -# and suffix move on all four, given on two, family on three, middle -# on one. -name_regex = "(?i)^Ph\\. D\\.( John Smith| Van Johnson|, Jr\\.)?$" -fields = ["title", "given", "middle", "family", "suffix"] - -# -- the four rules below are the shape-tagged contract corpus's -# arrival (#468): corpus_shapes.jsonl compares each tagged name under -# its shape's declared order from baseline 2.0.0 up, so these are -# comparisons no run before it made, and every one of them reads -# [order: ...] in the report. The same four stand in -# expected_since_2.0.0.toml -- both baselines predate the 2.2 cycle -# where #395 and #399 landed, and a ledger's rules are its own -# baseline's history. - -[[change]] -issue = "feat(#395) a leading never-given particle bounds the family under a declared family-first order" -# 'de la Cruz Juan Carlos', compared under FAMILY_FIRST and under -# FAMILY_FIRST_GIVEN_LAST -- one rule for both, since the fold moves -# the same three roles either way. This baseline read family 'de', -# given 'la Cruz Juan Carlos': the leading chain took the particle -# alone and everything after it was one given name. #395 lets the -# chain reach 'Cruz' THROUGH ambiguous 'la' and stop there, because a -# declared family-first order says what follows the family is not more -# surname; the leftovers then distribute by the order -- given 'Juan' -# middle 'Carlos' under FAMILY_FIRST, the reverse under -# FAMILY_FIRST_GIVEN_LAST. The DEFAULT order is untouched, which is -# why the untagged sibling case row reads family 'de la Cruz Juan -# Carlos' at every baseline: with no order declared, 'Juan Carlos' -# really can be more surname. -name_regex = "^de la Cruz Juan Carlos$" -fields = ["family", "given", "middle"] -# Scoped, and this is the rule the key exists for: the same string is -# also compared under the DEFAULT order (corpus_rules.jsonl carries -# it), where the fold must NOT fire -- and if it leaked there it would -# move these same three roles. Order-blind, this rule would absorb -# that regression and call it intentional. -orders = ["FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST"] - -[[change]] -issue = "fix(#399)/feat(#395) a consumed maiden marker leaves the family-first fold no given name" -# 'de la Cruz née Vega' under both family-first orders. Two changes -# compound: #395 bounds the family at 'Cruz' (this baseline read -# family 'de'), and #399 consumes the marker before anything can place -# it, so 'Vega' is the maiden name rather than a leftover competing -# for the given slot. What is left is no given name at all, the right -# answer for family-plus-maiden input -- and the two orders AGREE -# after it, having nothing to distribute. -# -# Its own rule rather than a widening of `fix(#399) a maiden marker -# bounds the particle chain that swallowed it` above: that rule -# already reaches this name by regex and declines it on `fields` -# (family+maiden, no `given`), and widening it would pre-excuse a lost -# given name on the names it explains today. -name_regex = "^de la Cruz née Vega$" -fields = ["family", "given", "maiden"] -# both family-first orders diff identically here, the marker having -# left them nothing to distribute; the default-order reading of this -# string is another rule's business -orders = ["FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST"] - -[[change]] -issue = "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold" -# 'de Mesnil Jean, Dr.' under FAMILY_FIRST. Two changes compound, and -# the case row carries no tag of its own (cases.py's default), so the -# rule names them: with 'dr' out of the suffix vocabulary (#296) the -# comma is followed by nothing but titles, so the pre-comma run is -# read as a name under the declared order -- and #395's fold then -# takes it. suffix 'Dr.' -> title 'Dr.', family 'de' -> 'de Mesnil', -# given 'Mesnil' middle 'Jean' -> given 'Jean'. -# -# The default-order sibling 'John Smith, Dr.' is NOT here: no -# particle, so nothing folds, and its title/suffix move rides under -# `fix(#296) ... keeps its split and its title` above, whose block now -# lists that name twice -- once tagged [order: FAMILY_FIRST]. -name_regex = "^de Mesnil Jean, Dr\\.$" -fields = ["title", "given", "middle", "family", "suffix"] -orders = ["FAMILY_FIRST"] - -[[change]] -issue = "feat(#395)/fix(#296) a comma followed only by a title leaves the pre-comma name to the declared order's fold, the given-last spelling" -# 'de la Cruz Juan Carlos, Dr.' under FAMILY_FIRST_GIVEN_LAST: the -# rule above's story with two leftovers instead of one, which is the -# only shape in which the two family-first orders can disagree about -# the distribution. suffix 'Dr.' -> title 'Dr.', family 'de' -> 'de la -# Cruz', given 'la Cruz Juan Carlos' -> middle 'Juan' given 'Carlos'. -# -# A separate rule rather than an alternation with the one above: these -# are two chosen names, and a literal-anchored regex per name is what -# keeps a rule from reaching a string nobody wrote down. -name_regex = "^de la Cruz Juan Carlos, Dr\\.$" -fields = ["title", "given", "middle", "family", "suffix"] -orders = ["FAMILY_FIRST_GIVEN_LAST"] - -# --------------------------------------------------------------------- -# The `_initials` rules (#484) -- see the block of the same name at the -# end of expected_since_1.4.0.toml for what the pseudo-field is. Only -# view changes AFTER this baseline are visible from it: the per-word -# grouping predates it (2.0.0) and is classified at 1.4.0 alone. -# fix(#462), below, is the 2.3 facade fix, visible from every 2.x -# baseline. - -[[change]] -issue = "fix(#385/#402) an all-particle name part initials its words (R2)" -# 'Anh Do' -> 'A. D.', 'Juan van der' -> 'J. v. d.': rules.md#R2/#R3, -# shipped in 2.2.0, decisions.md#R2. Fields identical at this -# baseline; only the view moved. The same literal list as the 1.4.0 -# rule, for the same reason. 'nguyen van lac' is in it since -# 2026-09-25 and explains nothing here: its report moves at this -# baseline, and the derived view enters a diff only where every -# role and every ambiguity kind agree (#484), so the list stays one -# list without claiming anything new. -name_regex = "(?i)^(?:anh do|smith van der|yin le|yin a le|vai la|jong van der|jong, van der|juan van der|mesnil garcia de|mesnil garcia van|mesnil de|sander van|van ma van|anh van do|anh van mc|beethoven ludwig van|berg jan de jr\\.|john van mc|jong anke de|juan de|ménil christophe de|ménil de|nguyen thi van|nguyen van lac|nguyen, van le|van berg jan de)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#360) los joined the particles, so it no longer initials" -# 'de los Santos': 'l. S.' -> 'S.' once #360 (2.2) made 'los' a -# particle. One name, literal. -name_regex = "(?i)^de los santos$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" -# 'Scott E. Werner', 'Amy E Maid', 'Jose E. Maria Santos': -# HumanName.initials() dropped a dotted or bare-capital E/Y in the -# middle or family group because the 2.0 facade tested conjunction -# membership alone, where 1.4.0's is_conjunction also required "NOT -# is_an_initial". 'S. W.' -> 'S. E. W.', restoring 1.4.0; the core's -# initials() was already right. Fields identical -- the parse never -# read E as a conjunction, only the facade's render did -- so this is -# `_initials` alone (#484). The shape: a bare capital E or Y, or a -# dotted E./Y. of either case, standing as its own word. Lowercase -# bare e/y is the connective and does not move. The regex reaches 22 -# corpus names -- 18 until #383/#479's case rows and rules.md#P3 -# examples arrived on 2026-09-13 -- of which 14 diffed when the rule -# was written (2026-09-02); _CORPUS_CLAIMS in -# tests/v2/test_ledger_guards.py pins that reach as _Claim(22, ...) -# and fails if it moves, so the REACH digit is checked rather than -# remembered. The 14 is not: it is a -# dated snapshot of what diffed that day and nothing re-runs it. -# The names it reaches and does not move are -# what the gap IS, and the invariant behind it is a GROUP rather than -# a count: -# they carry the E/Y in the GIVEN group, which has always initialed -# every word it holds whatever the vocabulary says, so there was -# nothing for the fix to restore. -# -# 2026-09-13, #383/#479 then #528: 'JUAN Y GARCIA' diffed here for -# TWO reasons at once that morning -- the facade's, which is this -# rule's own, and the core's, which was new -- and #528 closed the -# facade's half the same day, the facade now reading the parse's tags -# and agreeing with this baseline's 'J. G.' again. The core's half -# remains and this rule still explains it, one `_initials` move. -# expected_since_2.0.0.toml's copy of this rule carries the account; -# it is the same at all three baselines. -# -# 2026-09-20, #461: THE SURFACE FLIPPED AND THE RULE DID NOT. At -# this baseline the wheel gives facade 'J. G.' and core 'J. Y. G.' -# for 'JUAN Y GARCIA' (measured 2026-09-20 from the 2.1.0 wheel). -# Before this change only the CORE moved, 'J. Y. G.' -> 'J. G.', a -# connective contributing no initial; after it only the FACADE moves, -# 'J. G.' -> 'J. Y. G.', and the core agrees with the baseline word -# for word -- the 'Y' holds its part alone, so it initials again on -# both surfaces and the tree gives 'J. Y. G.' from each. `_initials` -# is one pseudo-field, so this rule still explains the name; what -# changed is WHICH HALF of the disagreement it is explaining, which -# is why the paragraph above reads backwards without this one. -# rules.md#R3, decisions.md#R3. -# -# The name stops diffing at 1.4.0 and at 2.3.0 under the same move, -# and the rule written for it in each of those ledgers goes: the -# 2.3.0 `fix(#383/#479) a bare capital connective in an all-upper -# name stops initialing` is deleted outright, and 1.4.0's -# `fix(#528) ...` keeps its other three names and drops this one from -# its alternation. -name_regex = "(?:^|[\\s,])(?:[EY]|[EeYy]\\.)(?=[\\s,]|$)" -fields = ["_initials"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# THE 2.3 TITLE-RUN BUNDLE: #489 (a title run addresses by its last -# title), #316 (a trailing period-marked title word reads as a -# title), the leading peel's name-word floor, and the `esq` acronym -# drop that shipped beside them. Five rules, four of them one per -# ARGUMENT rather -# than one per name, plus a fifth that is #316's argument again on -# the one name a script-classified alternation cannot hold. Over -# twenty-four corpus names. -# -# LAST in the file, and that is narrow-first rather than a -# preference: every rule already here that REACHES one of the -# twenty-four declares `fields` that are a strict subset of the bundle -# rule claiming that name, and a wide rule sitting AHEAD of a -# narrower one it shares a corpus name with is an order-decided -# contest the run refuses (#382). Measured 2026-09-08, all four -# ledgers: at 1.4.0 fix(#296)'s trailing `dr` reaches 'John Smith -# Dr.' and 'John Smith Prof. Dr.', the two comma routings reach -# 'Smith, John Prof.', and the two-token `jr` rule reaches 'Dr Jr' -# and 'Sir Jr'; at 2.0.0 and 2.1.0 only fix(#296) reaches any of -# them; at 2.2.0 none does. Not one of those rules ADMITS the diff -# it reaches -- every shape is outside its field list, measured -- -# so nothing below takes a name off a rule that was explaining it, -# and no [[change.precedes_narrower]] block is needed anywhere. -# -# `_initials` appears in none of the four field lists although the -# initials of several of the twenty-four do move. The derived view -# enters a diff only where every ROLE and every ambiguity kind -# agrees (compare.py's _RULE_FIELDS, main()'s roles-identical -# guard), and all twenty-four move roles -- so no run can produce a -# shape here carrying it, and validate_rules refuses it beside -# another field in any case. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#489) a title run addresses by its last title" -# 'Her Majesty Queen Elizabeth' and 'Dr. Sir John': the -# given_name_titles lookup was keyed on the WHOLE run, and 'her -# majesty queen' is in no vocabulary and never could be, so the one -# name word behind the run stayed the family. Both sites that ask -- -# rules.md#H1's retag and rules.md#P5's licence -- now test the whole -# run's key OR the last word's, and 'queen' and 'sir' are -# GIVEN_NAME_TITLES entries. The name word behind the run becomes the -# GIVEN name and the family empties, which is H1's Accepted outcome. -# Both roles move together and `fields` declares both. `title` is -# deliberately OUT: the run itself is unchanged, only what it -# addresses as, so a name whose TITLE moved arrives UNEXPLAINED, as -# it should. -# -# 'Reverend Mother Teresa', 'Mr Sir John', 'Xyz. Sir John' and 'Sir -# Sheikh abdul rahman' move the same way and are NOT members: none of -# them is in any corpus file, so a member for one would be a rule -# reaching nothing. -# -# An anchored alternation of the two NAMES rather than a shape. What -# selects them is a SHAPE -- a multi-word title run whose LAST word -# is a given-name title -- and a member copying GIVEN_NAME_TITLES -# would reach 'Sir John Smith' and 'Dr. Smith', which do not move. -# The members are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 2 with its digest, and -# _MUST_NOT_MATCH carries three boundaries: a ONE-word run ('Sir John -# Smith'), and two runs whose last word is title vocabulary that is -# not given-name vocabulary ('His Excellency Lord Duncan', 'Her Royal -# Highness Princess Anne' -- the open vocabulary question, #519). One -# set, identical in all four ledgers. -name_regex = "^(?:Dr\\. Sir John|Her Majesty Queen Elizabeth)$" -fields = ["family", "given"] - -[[change]] -issue = "change(suffix-acronym-collisions) esq leaves the acronym set" -# 'John Smith E.S.Q.': 'esq' left SUFFIX_ACRONYMS, where the 2019 bulk -# post-nominal import put it (af5bdab, #93) -- esquire is a -# contraction, not an initialism -- so the multi-dot spelling stops -# being credential vocabulary and falls to the positional read: given -# 'John', middle 'Smith', family 'E.S.Q.', where every release since -# 1.4.0 read family 'Smith', suffix 'E.S.Q.'. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: this is a deliberate parity break at all four baselines, and -# the entry that argues it is decisions.md#suffix-acronym-collisions -# -- there is no issue number to cite, and the release-log bullet -# carries none either. It is not NOT WANTED: that spelling is for a -# reading nobody wants, and this reading is the one the decision -# chose. -# -# 2026-09-18 (#516): this rule stopped explaining anything, and the -# reason is not a revert. The by-shape credential class reads a word -# of two or more period-separated chunks that no vocabulary claims by -# POSITION (rules.md#S3), so 'John Smith E.S.Q.' is a credential again -# with no wordlist entry behind it at all -- the reading this baseline -# has, recovered by a different route than the acronym entry gave it. -# The vocabulary decision the `change` tag records still stands and is -# still the history of that entry; what is gone is its ROLE diff. The -# name still diffs here, on the `suffix-or-name` report the positional -# reading brings with it -- `_ambiguities` is a compared surface from -# 2.0 on -- and fix(#516) classifies that, which is why this rule is -# dormant rather than explaining anything: `dormant` means "explained -# no diff", not "the name stopped moving". Only the 1.4.0 copy can say -# the diff is gone outright, that baseline comparing no ambiguities. -# Declared -# `dormant` rather than deleted, so the harness says so out loud if a -# future change takes the positional reading away again. -# -# Literal, no alternation, and exactly as wide as the diff: 'esq' -# keeps its SUFFIX_WORDS membership, so the acronym entry's only -# unique coverage was this one spelling and this one corpus name. -# _MUST_NOT_MATCH carries the three spellings that did NOT move -- -# 'John Smith Esq' (the word list still reads it), 'Esq. Smith' -# (rules.md#H2's leading inference) and 'Smith, Esq.' (the lone -# post-comma credential). -dormant = "'John Smith E.S.Q.' no longer moves any role this rule declares: #516's by-shape credential class reads the multi-dot spelling as a credential by position, with no wordlist entry, which is the reading this baseline already had. Its remaining diff here is the `suffix-or-name` report, which fix(#516) classifies. Kept because the vocabulary change it records is real history and the rule wakes if that reading is lost again" -name_regex = "^John Smith E\\.S\\.Q\\.$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#489) the title peel leaves a name word a suffix cannot be" -# 'Dr King Jr', 'Dr. King MD', 'Dr Jr' and 'Sir Jr': the leading title -# peel's only floor was "leave one piece", so it peeled 'Dr King' -# whole and left the post-nominal to be the name -- title 'Dr King', -# family 'Jr', no suffix at all. leading_titles now also refuses to -# leave a word that is nothing but suffix vocabulary standing as the -# name and gives the run's last word back (rules.md#H3, -# decisions.md#H3), so 'Dr King Jr' reads title 'Dr', family 'King', -# suffix 'Jr' -- what the comma spelling 'King, Dr Jr' always read. -# -# FOUR roles, and no single name moves all four. The two three-word -# names move `title`, `family` and `suffix`; 'Dr Jr' and 'Sir Jr' -# move `given` as well, or instead of `family`, the word given back -# standing alone with the credential peeled off it. At the 2.x -# baselines `_ambiguities` joins the list -- with the title given -# back there is no title left to make the reading H1's, so all four -# report title-or-name through rules.md#H4 -- and it is absent at -# 1.4.0, which has no ambiguity surface to compare (compare.py's -# _RULE_FIELDS). -# -# An anchored alternation of the four NAMES rather than a shape. What -# selects them is a SHAPE -- a title run with nothing but post-nominal -# pieces behind it, whose own last word is not one of those -- and a -# member copying TITLES or SUFFIX_WORDS would reach 'MD DDS', 'Jr. -# Ph. D.' and 'Marquess of Bath', every one of which the floor -# declines and every one of which is a _MUST_NOT_MATCH probe, with -# 'Dr Smith Jr' beside them for the ordinary titled name the floor -# never sees. The members are a list of names and copy no wordlist, -# which is what _NOT_A_VOCABULARY_COPY records. THREE of the four are -# corpus names, not four: 'Sir Jr' left corpus_rules.jsonl on -# 2026-09-09, when the rules.md example pinning it was dropped as a -# duplicate of 'Dr Jr' (decisions.md#H5), and it stays in the -# alternation because a name_regex names INPUTS rather than corpus -# rows. _CORPUS_CLAIMS pins the reach at 3 with its digest. One set, -# identical in all four ledgers; only the `fields` differ, as the -# paragraph above says. -name_regex = "^(?:Dr Jr|Dr King Jr|Dr\\. King MD|Sir Jr)$" -fields = ["_ambiguities", "family", "given", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing period-marked title word reads as a title" -# 'John Smith Prof.' and thirteen more: a trailing run of period-marked -# LISTED title words is peeled as a title, so the surname behind it -# stops being lost -- title 'Prof.', given 'John', family 'Smith', -# where every baseline read middle 'Smith', family 'Prof.'. A RUN, -# mirroring the leading peel: 'John Smith Prof. Dr.' gives title -# 'Prof. Dr.' and 'Dr. John Smith Prof.' gives 'Dr. Prof.' in input -# order. The family-comma segment takes the same walk, so 'Smith, -# John Prof.' agrees with the bare spelling at last. And the trailing -# title is TRANSPARENT to the post-nominal peel that follows it: -# 'John Smith Jr. Prof.' keeps suffix 'Jr.' rather than promoting the -# generational suffix to the family name, and 'John Prof. MA' reads -# family 'MA' by rules.md#S2's reserve. -# -# 2026-09-18 (#289): the last clause is retracted. S2's reserve is no -# longer what decides 'John Prof. MA': written capitals in a -# mixed-case name lean credential with nothing to spare, so the name -# reads title 'Prof.', family 'John', suffix 'MA' -- the transparency -# this rule is about is unchanged and now shows a DIFFERENT reading -# through it, which is the point of transparency. This rule keeps the -# name at every baseline where it already had it; the fix(#289) rule -# at the bottom of each file carries the names whose reading moved and -# that nothing else claims. -# -# Five roles, the union of what the sixteen move; no name moves all -# five (four is the most, 'John Smith Jr. Prof.'), and two move only -# two at every baseline -- 'Smith, John Prof.' {title, middle} and -# 'Smith Sir.' {title, family} -- with 'Mary Jane King.' moving three. -# A third joins them at 1.4.0, 2.0.0 and 2.1.0, 'John Smith Dr.' -# {title, suffix}, which moves {title, middle, family} at 2.2.0 -# instead: 'dr' was suffix vocabulary until 2.2, so the role it -# vacates differs by baseline (measured 2026-09-09 against all four -# wheels). `_ambiguities` is not among the five, and it is not -# because no name reports one: 'John Prof. MA' reports -# `suffix-or-name`, which is S2's bare-acronym reserve and not this -# rule. None of the sixteen reports `title-or-name` -- the walk -# leaves a title standing, so H4's title half never fires -- and the -# derived view cannot enter a diff here in any case, every one of the -# sixteen moving roles (compare.py's _RULE_FIELDS). -# -# TWO of the sixteen end in ' Dr.' and are also reached by -# fix(#296)'s trailing-`dr` rule above -- 'John Smith Dr.', which -# that rule used to explain, and 'John Smith Prof. Dr.'. That rule -# declares {middle, family, suffix} and neither diff fits it now, so -# both fall through to here; its comment records the handover, and -# narrow-first placement is what keeps the pair out of contest. The -# BARE trailing spelling stays with fix(#296): 'Smith Dr' and the 'dr -# ... dr' names wear no abbreviation period and the walk never sees -# them (#316(b), left open). -# -# The last two members arrived with the 2026-09-09 review of the -# docs commit, which added two rules.md examples: 'Mary Jane King.', -# the H5 Accepted clause's collision example (title 'King.', given -# 'Mary', family 'Jane', where every baseline read family 'King.' -# behind middle 'Jane'), and 'Smith Sir.', which is an H1 example. -# 'Smith Sir.' is HERE and not on the fix(#489) run rule above -# because the DIFF decides: it moves {title, family}, the walk -# taking 'Sir.' out of the name, while its given/family reading is -# what every baseline already gave ('Smith' stays the given name on -# both sides). The #489 rule declares {family, given} and does not -# reach a title role at all, so it could not claim the diff even -# placed ahead of this one. -# -# An anchored alternation of the sixteen NAMES rather than a shape. -# What selects them is a SHAPE -- period-marked, listed, trailing -- -# and a member copying TITLES would reach the BARE 'Mary Jane King' -# and 'John Smith Sir', which do not move; the period-marked -# 'Mary Jane King.' is a member precisely because it does, which is -# the line the period draws and the whole of what it draws. A member -# spelled as the shape (a trailing dotted word) would reach 'John -# Smith Xyz.', which is the boundary the leading slot's H2 inference -# is deliberately NOT given here. Those three and 'Smith, Prof.' -- -# the comma shape whose segment holds no name word and which reads -# by a different gate -- are the _MUST_NOT_MATCH probes. The members -# are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY records; _CORPUS_CLAIMS pins the reach at -# 14 with its digest. -# -# 'Andrew Perkins (Mgr.)' is spelled with \\x28 and \\x29 rather than -# the \\( and \\) the paren-bearing rules above use. Escaped or not, a -# literal parenthesis inside an alternation body hides the WHOLE -# group from test_ledger_guards._alternations -- its member pattern -# excludes '(' and ')' -- and a sixteen-member alternation should not -# be invisible to the discovery pass that demands every alternation -# declare what it copies. The two spellings match the same string. -# One set, identical in all four ledgers. -# -# 2026-09-18 (#289/#516): a SEVENTEENTH member, 'JOHN PROF. MA'. It -# arrived in the rules corpus as rules.md#H5's one-case witness that -# the suffix reading is taken over what STANDS once the title chain -# has run, not over what stood -- the reading the mixed-case spelling -# stopped demonstrating when the caps lean started deciding it. The -# name is wholly one case, so no lean fires and nothing about it is -# this arc's: what moves is exactly this rule's trailing-title chain -# ({title, family, suffix}, inside the five roles already declared), -# and it stops diffing at 2.3.0 for the same reason its sixteen -# siblings do. -name_regex = "^(?:Andrew Perkins \\x28Mgr\\.\\x29|Dr\\. John Smith Prof\\.|Dr\\. Smith Sir\\.|JOHN PROF\\. MA|John Prof\\. MA|John Smith Dr\\.|John Smith Jr\\. Prof\\.|John Smith Mr\\.|John Smith Prof\\.|John Smith Prof\\. Dr\\.|John Smith Prof\\. Jr\\.|John Smith Rev\\.|Mary Jane King\\.|Sir John Prof\\.|Smith Prof\\.|Smith Sir\\.|Smith, John Prof\\.)$" -fields = ["family", "given", "middle", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing Latin title on a native-script name is a title" -# '毛 泽东 Dr.': the rule above's walk, on the one corpus name it -# reaches that is not Latin-only. The walk runs BEFORE the positional -# read, so the pieces the script test sees are all Han and the -# family-first order stands -- this is '毛 泽东' plus a title, and -# it reads title 'Dr.', given '泽东', family '毛'. A Latin title at -# the back of a wholly-Han name is the one piece that would make the -# piece set look mixed-script, which is what the ordering of the two -# buys. -# -# ITS OWN rule rather than a seventeenth alternative above, and not by -# preference: an UNDECLARED alternation holding a script-classified -# member is claimed by the honorific pin in -# tests/v2/test_ledger_guards.py, which requires such an -# alternation's members to BE the config's CJK honorific entries -- -# and until #322/#323 taught that pin to ask whether the members FOLD -# into that vocabulary, that was every such alternation. So this -# stands alone, literal-anchored, -# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) -# with _MUST_NOT_MATCH probes for the boundary -- the same shape the -# four #436/#437 CJK rules above take, and for the same reason. -# -# Classified 2026-09-09, in the review round that fixed which title -# run H1 addresses by; the reading itself is untouched by that fix. -# The name is RADAR tier (corpus_cjk_tolerated.jsonl, tolerated since -# the 2026-09-01 CJK demotion) and it had been printing UNCLASSIFIED -# at all four gates since the trailing walk landed. Radar never -# blocks, so nothing failed -- and this bundle's convention is that -# every gate line reads `radar unclassified: 0`, which is the whole -# reason the row is here rather than left to print. -# -# TWO roles here, the fewest at any baseline, and NOT the same set as -# elsewhere -- this is the one bundle rule whose `fields` differ per -# ledger. 2.1.0 shipped the Han family-first order, so it already read -# first '泽东', last '毛'; only the Latin word moves, out of -# `suffix` and into `title`. -# -# Literal and anchored, so the reach cannot widen into the other -# forty-six tolerated CJK names (twenty-nine until this bundle's -# seventeen rows joined the file). _MUST_NOT_MATCH carries three of -# them as probes: '王先生, V.' (a Latin post-nominal behind a -# comma, which the trailing walk never sees), '田中さん, Dr.' (the -# comma spelling of this very shape, routed by the segment gate) and -# '田中さん II' (a spaced Latin post-nominal that is not title -# vocabulary). -name_regex = "^毛 泽东 Dr\\.$" -fields = ["suffix", "title"] - -# #322/#323, LAST in the file. The parser knew ONE full stop -- -# the ASCII period -- and knew it at ONE site, the lexicon's edge -# strip. It now reads four: the ASCII period, the fullwidth '.', -# the ideographic '。' and its halfwidth '。'. A full stop glued -# AFTER a script-written word is punctuation and not part of the -# word -- invisible to the script reading and to the vocabulary, -# and left in the text on the word it arrived with -- because no -# East Asian script writes an initial or an abbreviation with a -# period. A leading stop folds away for the vocabulary lookup, -# which reads both edges; the classification fold that feeds the -# surname site reads the TRAILING edge only, so a word wearing a -# leading stop is given no script and never becomes a surname -# site: '.김민준' stays one whole word, given. The honorific peel -# is not gated by that fold -- the tail alone licenses it -- so a -# leading stop does not stop it: '.김민준씨' peels to '.김민준' -# and '씨' (rules.md's W Background, decisions.md#cjk-full-stops). -# -# SEVENTEEN members here where the 1.4.0 and 2.0.0 ledgers carry -# eleven -- twelve CJK, the katakana 'マイケル.' (a #323 mover added -# 2026-09-10 in the whole-branch review), and four the review round -# after that added: the three stop-bearing FAMILY_COMMA rows -# ('田中さん., V.', '김민준씨., J.씨', '이, J.씨.') and the bracketed -# '(김민준.) John Smith', which is the one of the four the older -# ledgers carry too. 2.1.0 is the release that shipped the hangul -# segmentation and the script order rule, so from here '김. 민준', -# '양 지훈.' and '양. 지훈' are diffs of this bundle rather than of -# that one: the older ledgers hand them to their native-script CJK -# rule, which does not exist at this baseline. The three comma rows -# are absent there for a different reason -- at 2.0.0 none of them -# diffs at all, and at 1.4.0 two are claimed by that ledger's broad -# comma rules. -# -# No earlier rule in this file reaches any of the seventeen, so LAST -# is uniformity with the other three ledgers rather than a -# narrow-first ordering (#382) against anything: order_contests -# reports no pair here in either arrangement. -# -# An anchored alternation of the NAMES rather than a shape, and the -# two criteria a shape could be written on both reach names this -# rule must not claim. "An edge full stop on a word carrying a -# classified character" reaches 22 corpus names, measured -# 2026-09-10: the bundle's seventeen movers and FIVE that do not -# move ('田中さん 様.', '田中さん, 様.', '김민준 씨.', '김민준 양.', -# '김민준, 씨.'). Measured with the TOKENIZER and not a whitespace -# split -- two of the movers wear their stop before a comma -# ('김민준씨.,', '田中さん.,'), which a whitespace split does not -# see -- plus the bracketed '(김민준.) John Smith' by hand, its stop -# sitting inside the clause until rules.md#S1 unwraps it; an 18 -# recorded earlier the same day came from the split, over a corpus -# four names smaller. A character class of the four stops would be a hand -# copy of nameparser._lexicon.FULL_STOPS besides, and would reach -# the looser population of "an ASCII period anywhere in a CJK- -# bearing name" -- 20 distinct names on 21 corpus rows before this -# bundle ('Dr 김민준씨, Jr.' being the name on two of them), wider -# because it sweeps in periods sitting on LATIN tokens inside a CJK -# name ('毛 泽东 Dr.', '田中さん, V.'). None of the five or the -# twenty is moved BY THIS BUNDLE, so a shape spelling would -# reach names whose diffs belong to other rules (most of them -# do diff against the older baselines -- 19 of the twenty at -# 1.4.0) or to no rule at all; decisions.md#cjk-full-stops -# carries both counts and the recompute. The members copy no wordlist, -# which the honorific pin in tests/v2/test_ledger_guards.py DERIVES -# rather than taking on trust -- no member of this alternation folds -# into the CJK honorific vocabulary, bare or wearing a full stop; -# _CORPUS_CLAIMS pins the reach at 17 with its digest, and -# _MUST_NOT_MATCH pins the four readings the bundle left alone. -[[change]] -issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" -name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|田中さん\\., V\\.|김\\. 민준|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.|김민준씨\\., J\\.씨|양 지훈\\.|양\\. 지훈|이, J\\.씨\\.)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] - -# --------------------------------------------------------------- -# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. -# Two rules where 1.4.0 needs one, and the split is the pseudo-field -# rule (#484) rather than two behaviors: from 2.0 on the v2 surface -# is compared, so the new CONJUNCTION_OR_INITIAL report enters the -# diff -- on one of the two names whose roles move ('JUAN GARCIA Y -# LOPEZ' reports nothing, nothing about 'y' having been in doubt) -# and on six more whose roles do not -- and a role move and a -# report can co-occur while a role move and `_initials` cannot. -# -# The derived view is deliberately absent from both rules. The core's -# initials() DOES move for 'john e smith', 'john e jones' and -# 'jones, john e' ('j. j.' -> 'j. e. j.'), but `_initials` enters a -# diff only where every role AND every ambiguity kind agrees (#484, -# compare.py main()), and the report moved on each of them -- so at -# this baseline those names classify on the report alone. 'JUAN Y -# GARCIA' is the one name of the population whose view moves with no -# report beside it, and at this baseline it is already claimed: -# fix(#462) reaches it and admits `_initials`. That rule's comment -# records what moved and when: the FACADE's view moved there too on -# the morning #383/#479 landed, the 2.3 facade fix not having shipped -# at 2.1.0, and #528 closed the facade's half the same day -# (2026-09-13), leaving the core's as the whole diff. A competing -# rule here would be an equal-`fields` contest decided by file order, -# which is what _CROSS_RULE_WINNERS exists to stop rather than to -# create. It is classified on its own at 2.3.0, where BOTH surfaces -# now move, and the same way. -# -# LAST in the file: every diff these two claim reported UNEXPLAINED -# on the run that preceded them (measured 2026-09-13), so no rule -# above already claims one. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#383/#479) a single-letter connective joins only on case evidence" -# The 2.x view of the 1.4.0 rule of the same name: 'jose e maria -# santos' reads given 'jose', middle 'e maria' where 2.1.0 joined, -# and 'JUAN GARCIA Y LOPEZ' reads family 'GARCIA Y LOPEZ' where -# 2.1.0's bare-Latin-capital veto gave middle 'GARCIA Y'. A name -# written wholly in one case carries no case evidence about any -# letter in it, so the vocabulary decides: 'e' is marked as reading -# both ways and reads as an initial, 'y' is not and joins. Both are -# rules.md#P3 examples; see the 2026-09-13 entry of decisions.md#P3. -# -# `_ambiguities` is in the fields here and cannot be at 1.4.0 -- the -# report is a v2 surface -- and only ONE of the two names carries it: -# 'jose e maria santos' reports conjunction-or-initial, 'JUAN GARCIA -# Y LOPEZ' reports nothing, because nothing about 'y' was ever in -# doubt. The declaration is the union of the two diffs and #452's -# over-declaration check recomputes it every run. -# -# Literal-anchored to the two names, as at 1.4.0: the class is every -# one-case name carrying a cased single-letter connective, seventeen -# corpus names, and fifteen of them do not move a role. -# _MUST_NOT_MATCH carries the mixed-case probes. -name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" -fields = ["given", "middle", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" -# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e -# jones', 'jones, john e', 'e j smith': every role is identical -- -# the letter was already read as an initial, or rules.md#P3's -# three-word carve-out keeps it a name word either way -- and what is -# new is that the CALL is reported as conjunction-or-initial, because -# a one-case name gives the reader nothing to decide it by. -# -# The initials of 'john e smith' ('j. s.' -> 'j. e. s.'), 'john e -# jones' and 'jones, john e' (both 'j. j.' -> 'j. e. j.') move as -# well, and the view is deliberately not in the fields: see the -# section comment above for why no run can produce it here. -# -# 'e and e' is NOT in the alternation although it is in the same -# population and its report moves too (it LOSES given-or-family and -# gains conjunction-or-initial, both inside `_ambiguities`). At this -# baseline `feat(#449) a lone name word reports given-or-family` -# names it literally and admits the diff, so adding it here would -# create an equal-`fields` contest that only file order decides -- -# the class `precedes_narrower` cannot express and _CROSS_RULE_WINNERS -# has to pin. The name is radar tier, so nothing is hidden by leaving -# it where it is; 2.3.0's copy of this rule does claim it, that -# ledger carrying no #449 rule. -name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289) a written case contrast decides a bare ambiguous acronym" -# A member of suffix_acronyms_ambiguous written in CAPITALS inside a -# mixed-case name is written the way a credential is written, and is -# taken as one with nothing to spare ('Jack MA' -> given 'Jack', -# suffix 'MA'); one written in any other cased form is written the way -# a surname is written and stays one even with words to spare ('John -# Smith Ma' -> middle 'Smith', family 'Ma'). The same lean reaches the -# post-comma given slot ('Smith, MA' -> family 'Smith', suffix 'MA'; -# 'Smith, Ma' keeps its given), and the comma form's words-to-spare -# test becomes a count of NAME words, which is case-blind -- so -# 'John Smith, MA', 'John Smith, Ed', 'JOHN SMITH, MA' and -# 'john smith, ma' all read the credential where the token count read -# a given name. See rules.md#S2, rules.md#C1 and rules.md#P2. -# -# A name written wholly in one case carries no contrast and does not -# move on the LEAN at all -- 'JOHN SMITH MA', 'ANH DO', 'anh van do' -# and 'Jack Ma' are _MUST_NOT_MATCH probes in -# tests/v2/test_ledger_guards.py. The one-case comma forms in the list -# above are here on the NAME-word count instead, which asks nothing -# about case; that is why they sit beside the lean's own names rather -# than in a rule of their own. -# -# Accepted costs, both recorded rather than repaired: the walk stops -# at a declined pick instead of continuing past it, so a genuine -# suffix standing in front of a name-leaning acronym is never reached -# ('abdul Smith Jr Ma' reads middle 'Jr'), and the lean ends P2's -# particle chain with no words to spare where the count would not -# ('Freiherr von Berg MA', whose own rule further up this file carries -# the reading at 1.4.0). -# -# The Title-case half is witnessed at the comma by two minimal pairs -# rather than argued: 'John Smith, MA'/'John Smith, Ma' both read the -# credential, because two NAME words before the comma decide before -# the case is looked at, and 'Smith Jr., MA'/'Smith Jr., Ma' split, -# because one name word leaves the case to decide and Title-case -# declines. 'Smith Jr., Ma' diffs here for a reading this change did -# NOT move -- the pre-comma restructure v2 has done since 2.0, at -# 1.4.0, and the fork's new report at the 2.x baselines -- while -# 'John Smith, Ma' moves on the COUNT, as its all-caps twin does, and -# has no 1.4.0 diff at all, v1 having read the credential there too. -# Both sit on this rule because the rule is what states which way -# each member of a pair goes. -# -# 2026-09-19, #531: three names join, all of them lone post-comma -# credentials this rule's own second sentence already describes. -# 'Doe, MA' and 'Doe, MA PhD' are 'Smith, MA's family-word twins and -# 'Doe, Dr. MA' is the same slot behind a title; each reads family -# 'Doe' with the credential run in `suffix` -- 'MA', and 'MA PhD' -# on the one that has a run -- where the baseline read given 'MA'. -# They -# entered the corpora with #531's control rows and are NOT #531's -# doing -- verified byte-identical at cc78c960, the #530 merge -# (2026-09-19). No role joined the field list: the three move -# {given, suffix} and, on two of them, `_ambiguities`. -# -# 2026-09-19, #531 review round: 'Doe, Mr. MA PhD' joins them. It is -# 'Doe, Dr. MA' with a credential run behind the member -- the gate -# reads the segment whole, so the member joins the run rather than -# taking the given slot -- and it moves {given, suffix}, both already -# in the field list. It entered the corpora with that round's control -# rows and is NOT its doing: byte-identical at cc78c960 (measured -# 2026-09-19). -# -# Literal-anchored to the measured movers, and ONE SET FOR THE FOUR -# 2.x LEDGERS so the rule says the same thing at each of them. The -# 1.4.0 copy differs by FOUR members now. It holds 'John Prof. MA' -# out: 1.4.0 read a trailing period-marked title as a title itself, -# so that name's diff there belongs to fix(#316)'s rule and not to -# this one. "One set for all five" was written before that carve-out -# existed and was false in these four copies from the day it did -# (2026-09-18). And it holds the three names above out for a -# different reason: at 1.4.0 their diff is already claimed by -# `fix(comma-family) lone post-comma piece routes to suffix/title, -# not first`, which stands far earlier in that file, so adding them -# there would reach a name without explaining it and would put two -# rules on one diff with nothing to arbitrate between them -# (2026-09-19). The set is literal because the class is -# wide (every mixed-case name whose trailing piece is a listed -# ambiguous acronym, plus every comma form with two name words before -# the comma) and a regex for the class would claim the one-case -# spellings that do not move. A name here that does not diff at a -# given baseline is simply one the reading already agreed with there. -# `fields` is per-baseline, the union the run at THAT baseline -# measures (#452). -# 2026-09-27, #544: 'abdul Smith Jr Ma' leaves this alternation. The -# unambiguous 'Jr' in front of 'Ma' anchors it (rules.md#S2's company -# clause), the peel takes both, and P5's reserve declines the join, so -# the accepted cost this rule recorded for it -- middle 'Jr', family -# 'Ma' -- is gone; what the name still diffs on here is claimed by the -# rule that explains it, and a literal left in this one would stand -# ready to explain that cost coming back. -# 2026-10-01, #575: eight names join, the C1 examples of that change. -# Against these baselines none of them is #575's doing: each moves -# on this rule's own count and lean. 'John van Buren, Ed', 'Ortega y -# Gasset, Ed' and 'abdul Salam, Ed' are two name words before the -# comma and flip; 'De La Cruz, Ed', 'de la Cruz, Ma' and 'Van Buren, -# Ed' are one and gain only the comma's report; 'van der Berg, MA' -# is one, and its capitals make 'MA' the credential, as 'Smith, MA'. -# These baselines already read the particle surnames as one name; -# what #575 fixed is this cycle's count, which had not. -# 'Freiherr von Berg, Ed', rules.md#C1's Accepted example for #575, -# reads as these baselines read it and gains only the comma's report. -name_regex = "^(?:Davis Royce, Ed|De La Cruz, Ed|Doe, Dr\\. MA|Doe, MA|Doe, MA PhD|Doe, Mr\\. MA PhD|Freiherr von Berg MA|Freiherr von Berg, Ed|JOHN SMITH, MA|Jack MA|Jack MA\\.|Jack Wei Ma|John Prof\\. MA|John Smith Ma|John Smith, Ed|John Smith, MA|John Smith, Ma|John de Ma|John van Buren, Ed|John van der Berg Ma|Ortega y Gasset, Ed|Smith Jr\\., MA|Smith Jr\\., Ma|Smith, MA|Van Buren, Ed|abdul Salam, Ed|abdul Smith Berg Ma|abdul Smith Ma|de la Cruz, Ma|john smith, ma|van der Berg, MA)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#516) an unlisted dotted acronym is read by position" -# A token of two or more period-separated chunks that no vocabulary -# claims joins the ambiguous credential class by SHAPE and is read by -# POSITION, exactly as a bare listed acronym is: a credential where -# the name has words to spare, a name word where it does not, either -# reading reported (rules.md#S3). Case says nothing here -- the -# periods are the evidence -- which is why 'john smith x.y.z.' moves -# with 'John Smith X.Y.Z.'. The same admission reaches the comma form, -# where the NAME-word count decides ('John Smith, X.Y.Z.' -> suffix -# 'X.Y.Z.'; 'Smith, A.B.' keeps its given and only reports). Paired -# initials ('John Smith, A.B.') are the exception to that count and -# have their own rule below (#563); 'García Márquez, G.J.R.' is the -# exception's accepted boundary, three letters reading by the count. -# -# Two names here restore a reading a PRIOR bundle's vocabulary change -# took away, by a different route than it lost it: 'John Smith E.S.Q.' -# and 'John Smith C.H.A.' fell to a bare positional read when 'esq' -# and 'cha' left SUFFIX_ACRONYMS -# (decisions.md#suffix-acronym-collisions), and the shape class reads -# them as the credentials they always were with no wordlist entry. -# At the baselines where -# that restoration lands on the baseline's own reading, only the new -# report is left and `fields` says so. -# -# The narrow half of the same change: a chunk match no longer makes a -# dotted token a suffix where EVERY matched chunk is a single ASCII -# character, which reaches only the roman numerals -- 'Jack X.Y.I.' -# moves to family for that reason and not for the shape one. -# -# The shapes the vocabulary settles are untouched and are -# _MUST_NOT_MATCH probes in tests/v2/test_ledger_guards.py: a -# whole-token match ('Jack M.A.', 'John Smith Ph.D.', 'Smith, -# A.B.C.'), a surviving chunk claim ('Doe, John Msc.Ed.'), a leading -# dotted run ('X.Y.Z. Smith', 'J.R.R. Tolkien', 'A.B. Vajpayee'), the -# single trailing period that is no shape at all ('John Smith Xyz.'), -# and a digit chunk, which the alphabetic gate refuses ('Bridge (1.4)'). -# -# Literal-anchored to the measured movers, one set for all five -# ledgers, for the reason the fix(#289) rule above gives: the class is -# a SHAPE, and a regex for the shape would claim every dotted token -# the vocabulary already answers for. -name_regex = "^(?:García Márquez, G\\.J\\.R\\.|Jack X\\.Y\\.I\\.|John Smith B\\.Tech\\.|John Smith C\\.H\\.A\\.|John Smith E\\.S\\.Q\\.|John Smith Q\\.W\\.E\\.R\\.T\\.|John Smith X\\.Y\\.Z\\.|John Smith, X\\.Y\\.Z\\.|Smith, E\\.S\\.Q\\.|john smith x\\.y\\.z\\.)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#562) a comma credential run holding a particle chain reads by the name-word count" -# rules.md#C1: "A part read as postnominal [...] is read as its words -# stand, before any join, and that reading is final: [...] no particle -# run, connective join or bound given name reaches into it" (#613, -# 2026-10-06): no particle chain reaches it, so each member's -# capitals settle it and the run reads whole as the credential run -- -# silently since #613, where #562 (2026-10-01) had read the pair by the -# count and reported the flip. The family-comma path had read the part as name text: -# this baseline read title 'PhD', given 'DO DO', family 'John -# Smith'. 'John Smith, PhD vd DO' is the same chain with a particle -# that is not a member ('vd', particle and unambiguous suffix -# vocabulary). 1.4.0 read both as suffixes, so that ledger has no -# rule. -# -# Literal. Probes: 'John Smith, PhD DO' (one particle beside no other -# stays settled and silent) and 'Smith, PhD DO DO' (one name word -# before the comma keeps the listing form) are _MUST_NOT_MATCH. -name_regex = "^(?:John Smith, PhD DO DO|John Smith, PhD vd DO)$" -fields = ["title", "given", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#563) paired initials after a comma read as the given name unless a word speaks for them" -# rules.md#C1: "Paired initials are the exception to the count" -- -# two words before the comma may be one surname ('García Márquez, -# G.J.'), so two dotted single letters alone after it stay the given -# name and report the fork, where #516 had read them as a credential. -# "Only an unambiguous suffix word in front of them that is not also -# title vocabulary, or another word the class admits by its dotted shape -# standing in the same part, makes them the credential run": 'John -# Smith, PhD X.Y.' and 'John Smith, X.Y. P.Q.' move to the run, while -# 'De La Cruz, M.J. PhD' keeps its initials, the degree standing behind -# them, and so does 'García Márquez, Ms G.J.', a title in front ("an -# unambiguous suffix word in front of them that is not also title -# vocabulary"), which moves nothing at any baseline. The second period -# is optional ('García Márquez, G.J'). Two pairs speaking only for each -# other make the run and report it behind two name words ('John Smith, -# X.Y. P.Q.'). 2026-10-01, #575: 'De La Cruz, M.J. K.L.' is no longer -# that case -- 'De La Cruz' is one name word, so the count no longer -# reaches it and it reads as 'Cruz, M.J. K.L.' does, given 'M.J.', -# suffix 'K.L.', reporting both; a listed member in front speaks for -# nothing ('García Márquez, Ed G.J.' keeps the family comma, and the -# fix(#531) rule claims what its given part's trailing slot then reads). -# Against a 2.x baseline the names whose roles match the baseline's move -# only the report, which a 1.4.0 comparison cannot see; that ledger -# lists the three role movers alone. -name_regex = "^(?:De La Cruz, M\\.J\\. K\\.L\\.|De La Cruz, M\\.J\\. PhD|García Márquez, G\\.J|García Márquez, G\\.J\\.|García Márquez, PhD G\\.J\\.|John Smith, A\\.B\\.|John Smith, A\\.B\\. Ph\\.D\\.|John Smith, PhD X\\.Y\\.|John Smith, X\\.Y\\. P\\.Q\\.)$" -fields = ["family", "given", "middle", "suffix", "title", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the ambiguous credential class reports at slots that were silent" -# Every decision at the trailing suffix slot, the post-comma given -# slot and a tail segment now reports the EXISTING SUFFIX_OR_NAME -- -# no new kind, and the report tracks the FORK BEING CONSULTED rather -# than which way it went, as the trailing slot always has. These names -# gain it with no role moving at any baseline: the fork was there all -# along and was called silently. It is the first time the comma's -# OWN decision is reported (rules.md#C1); P6's attachment fork has -# reported on a family-comma path since 2.3 ('Berg, Jan vd'). C2's -# structural flag was already the first comma-path report of a shape. -# -# 'Steven Hardman, MD, DO, DDS' goes the other way and LOSES its -# comma-structure flag: its third segment now reads as the credential -# run it is, because 'DO' leans credential in a mixed-case name. That -# is the one place this arc quiets a report rather than adding one. -# -# 2026-09-19, #531 review round: 'Doe, MA Smith' joins, and it is the -# post-comma given slot with a NAME WORD behind the member: no role -# moves at any baseline -- the member is the given name here as it -# always was -- and what is new is only the report that emitter makes. -# Its silent twin 'Doe, Dr. MA Smith' puts a title in that first piece -# instead, which the emitter reads rather than the member, and agrees -# with every baseline on every field, so it is in no ledger at all. -# -# `_ambiguities` alone, which keeps `_initials` out of these rows as -# always (#484), and the rule cannot absorb a role diff on any of -# these names for the same reason. Literal-anchored, one set for all -# the 2.x ledgers; the 1.4.0 ledger has no twin, `_ambiguities` being -# a v2 surface that cannot enter a diff below baseline 2.0. -# -# 'Steven Hardman, MD, DO, DDS' is held OUT of the set at THIS -# baseline: `fix(#296) a dropped prenominal takes the name position it -# occupies` reaches it through its own comma anchor, declares the -# three roles this rule does not, and stands first -- so the name is -# narrowed out rather than left to file order. The comma-structure -# flag it loses is a 2.2-era report in any case, and the 2.2.0 and -# 2.3.0 ledgers are where the loss is recorded. -name_regex = "^(?:Doe, MA Smith|J\\.A\\. K\\.D\\.|Jack X\\.Y\\.Z\\.|John Smith J\\.u\\.n\\.i\\.o\\.r\\.|John Smith R\\.A\\.I\\.|Royce, Ed|Smith Jr\\., A\\.B\\.|Smith, A\\.B\\.|Smith, Ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the glued CJK honorific peel reads the case lean" -# The suffix-run predicate the peel uses to decline a post-comma run -# (rules.md#W3, asking C1's own vocabulary question) now reads the -# same written-case evidence rule S2 reads, because `one_case` is -# passed to it. Before, one name written with two credentials divided -# two ways: a Kim-prefixed 김민준씨 input peeled its honorific when the -# credential was 'PhD' and did not when it was 'MA', the only -# difference being that 'MA' is an ambiguous acronym whose case lean -# the predicate was not given. -# -# Every name here is a COMPOSED form -- a Latin wrapper and a family -# comma around a CJK name -- which no East Asian writing system -# produces, so all three are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion). They are classified all the same: -# a radar diff this arc INTENDED is one a release note may be written -# from, and an unclassified one is not. -# -# `title` is in `fields` because 'Dr. 김민준씨, MA' had the whole -# string for its family at these baselines, the title included; at -# 2.0.0 and 2.1.0 'Jo김민준씨, DO' moves `title` instead of `given`, -# and the union is what a per-baseline `fields` records (#452). -name_regex = "^(?:Dr\\. 김민준씨, MA|Jo김민준씨, DO|Kim김민준씨, MA)$" -fields = ["title", "given", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# #531: THE GIVEN PART'S TRAILING SLOT. Four rules -- the thirteen -# the credential reading takes, the four the writing declines and that -# gain a REPORT alone, the one `do` spelling the capitals take from -# P6, and the two caseless comma forms on the radar tier. The decision is -# decisions.md#S2's 2026-09-18 pair; rules.md#S2, #S3, #C1 and #P6 -# carry the statements. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#531) a credential ending the given part of a family-comma listing reads as a credential" -# After a family comma, a word of the ambiguous credential class -# ending the given part is now read by the same rule the comma-less -# spelling uses for a word ending the name. The comma has already -# named the family and the first word after it is the given name, so -# the words to spare are there by construction and the count says -# nothing: the word reads as the credential unless its WRITING says -# otherwise. rules.md#S2 states it, rules.md#S3 carries the by-shape -# spelling, and decisions.md#S2 records the decision. -# -# Thirteen names, and they are the class read from every side the -# corpora reach it: the bare member ('Doe, John MA', 'Doe, John BA'); -# its one-case spellings, where the lean is inert and the POSITIONAL -# reading is not ('DOE, JOHN MA', 'doe, john ma', 'DOE, MARY JO MA'); -# the slot behind a title ('Doe, Dr. John MA') and behind an initial -# ('Doe, J. MA'); a run of two members, where the credential the -# member joins was already a suffix so `suffix` GROWS rather than -# filling ('Doe, John MA JD', 'Doe, John MA PhD', 'Doe, John PhD MA', -# 'Doe, John MA Jr'); the slot reached past a middle initial, which -# keeps its own middle ('Doe, John Q. MA', 'DOE, MARY JO MA'); and the -# by-shape member, whose lean is always None so the positional reading -# takes it outright ('Doe, John X.Y.Z.'). -# -# Do not read "thirteen role movers" off that list at every baseline: -# `fields` is the union the run at THIS baseline measures (#452), and -# a name here whose roles the baseline already agreed with contributes -# only its report. 'Doe, John BA' is that name at 2.1.0 and 2.2.0, and -# 'Doe, John MA JD' is the other end of the range, gaining TWO reports -# for its two members. -# -# `_ambiguities` is in the fields because every one of the thirteen -# gains the existing SUFFIX_OR_NAME -- no new kind, and the report -# tracks the FORK CONSULTED rather than which way it went, which is -# the rule fix(#289/#516) above already states for the other slots. -# #452's over-declaration check recomputes the union on every run. -# -# Literal-anchored for the reason the two rules above give, and it is -# sharper here: the class is a SLOT, so a regex for it would claim -# every family-comma name whose given part ends in an ambiguous -# acronym -- including the four the WRITING declines, which keep their -# name reading and have the rule below, and 'Doe, John MA Smith', -# where a name word behind the member ends the run so nothing moves -# and nothing reports. _MUST_NOT_MATCH carries both, with the `do` -# spellings P6 keeps and the third-comma-part name #144's restriction -# still owns. -# 2026-09-19, #533: TWO names join the alternation and the rule does -# not change. 'Doe, J. ba' and 'Smith nee Jones, Jane MA' are this -# slot read exactly as the thirteen above are -- a lower-case member -# ending the given part after an initial, and the same member behind -# a maiden clause that sits in the FAMILY segment, where no trailing -# rule reads the clause's own tail and the given part's slot is -# reached as usual. Both read the same way at 2f57ff21, before #533, -# measured name by name: what grew is the CORPUS, not the rule, and -# a `fix(#533)` rule for them would attribute a #531 reading to the -# wrong change. -name_regex = "^(?:DOE, JOHN MA|DOE, MARY JO MA|Doe, Dr\\. John MA|Doe, J\\. MA|Doe, J\\. ba|Doe, John BA|Doe, John MA|Doe, John MA JD|Doe, John MA Jr|Doe, John MA PhD|Doe, John PhD MA|Doe, John Q\\. MA|Doe, John X\\.Y\\.Z\\.|García Márquez, Ed G\\.J\\.|Smith nee Jones, Jane MA|doe, john ma)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) a member the writing declines keeps its name reading and reports the fork" -# The other direction of the same fork, and the whole of what these -# four names do: no role moves at any 2.x baseline and the decision -# starts being REPORTED. 'Doe, John Ma', 'Doe, John Ed' and -# 'Doe, Mary Jo Ma' carry a Title-cased member in a mixed-case name, -# which is written the way a name is written, so the member stays a -# middle name; 'Doe, John MA Ma' is a two-member run whose last member -# is Title-cased, so the walk declines there and the run stays middle -# text whole. -# -# Reporting a DECLINED fork is #530's stated rule -- the report tracks -# the fork consulted, not the lean -- and AGENTS.md's "A kind is worth -# adding only if a reader would hesitate too" is the standing -# objection it answers. It is also the only part of #531 that adds a -# report without moving a field, which is why it is a rule of its own -# rather than a widening of the one above: `fields` is -# `_ambiguities` alone, so this rule cannot absorb a role diff on any -# of the four. -# -# At 1.4.0 these same four DO move roles -- v1 read all four members -# as post-nominals -- and that divergence is carried there by -# fix(given-part-trailing-slot), whose comment records that #531 -# changed which names it holds rather than retiring it. -# -# Literal-anchored: the class is the slot's declining half, and a -# regex for it would claim the thirteen movers above. -name_regex = "^(?:Doe, John Ed|Doe, John MA Ma|Doe, John Ma|Doe, Mary Jo Ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) capitals take the do collision from the family-comma particle attachment" -# 'Doe, John DO', alone, because the word is alone in the class: `do` -# is the one ambiguous credential that is also particle vocabulary, so -# this slot and P6's attachment (rules.md#P6) want the same word. -# Derek's decision, recorded at decisions.md#S2: "CAPITALS DECIDE FOR -# `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING". An all-caps -# member in a name written in more than one case carries a positive -# credential lean, so the credential reading wins and P6 stands down -# (and since #544 so does an unambiguous credential in front of it, -# 'doe, jane v phd do'); every other spelling attaches exactly as it -# did, with P6's own `particle-or-given` and no second report. -# -# THE PAIRING IS THE ARGUMENT and the accepted cost is half of it. In -# ONE CASE the rule cannot tell 'NASCIMENTO, EDSON ARANTES DO' from -# 'SMITH, JOHN DO' and reads both as the particle -- right about the -# Brazilian record, wrong about the osteopath. Those two, and -# 'Doe, John Do', 'DOE, JOHN DO', 'doe, john do' and -# 'Nascimento, Edson Arantes do', are _MUST_NOT_MATCH probes: this -# rule has no business claiming a single one of them, and each is a -# name #531 measured to be byte-identical before and after. -# -# `fields` is per-baseline and the two halves of this file's set -# differ by which role the particle came OUT of, which is P6's own -# history: {middle, suffix} at 2.0.0 and 2.1.0, where the -# baseline read middle 'DO' because P6's family-comma attachment had -# not shipped, and {family, suffix} at 2.2.0 and 2.3.0, where it had -# and the baseline reads family 'DO Doe'. -name_regex = "^Doe, John DO$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) the trailing slot's positional reading reaches a caseless script" -# '田中, 太郎 MA' and '김, 민준 MA': middle 'MA' -> suffix 'MA', one -# report each. A caseless script answers True to the one-case test, so -# the lean is None -- and at this slot a None lean is not inert, -# because the POSITIONAL reading the lean would have overridden is -# itself the credential. "Caseless is inert" holds for the LEAN and -# not for the outcome, which is the sentence tests/v2/cases.py's two -# rows carry. -# -# Both are COMPOSED forms -- a family comma between a CJK family and a -# CJK given name, which no East Asian writing system produces -- so -# both are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion), and their diffs print under -# UNCLASSIFIED (radar) rather than blocking. They are classified all -# the same, on fix(#289/#516)'s stated ground: a radar diff an arc -# INTENDED is one a release note may be written from, and an -# unclassified one is not. -# -# Literal-anchored, and the probes are the CJK comma names that do NOT -# move at this slot: '田中 太郎, MA', '毛泽东, MA' and '마틴 킹, MA' put -# their member in the FIRST post-comma piece, which is #289's slot and -# not this one. -name_regex = "^(?:田中, 太郎 MA|김, 민준 MA)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#380) a trailing mc after a family comma is the tussenvoegsel, not a post-nominal" -# THE SAME DECISION AS THE fix(#380) RULE ABOVE, over the other word -# that carries the collision it names. 'Berg, Jan mc' entered the -# corpora with #531's case rows (2026-09-18) as the negative control -# for the `do` carve-out -- 'mc' is unambiguous suffix vocabulary AND -# a never-given particle, so P6's run claims it whatever role assign -# gave it, and #531 left it byte-identical (verified against the tree -# at cc78c960, 2026-09-19). -# -# The diff is against these two baselines only: `mc` joined the -# never-given particles in 2.2 (decisions.md#suffix-acronym- -# collisions) and P6's family-comma attachment shipped in 2.3, so -# 2.2.0 and 2.3.0 already read family 'mc Berg' and carry no rule for -# it. `_ambiguities` is in the fields because the name also gains -# #530's report at these baselines, which is #289/#516's doing and -# not this rule's subject; the rule claims the union the run measures -# (#452) and the roles are P6's. -# -# Literal, one name. A regex over the trailing word would claim every -# Mc* surname in the corpora, which is the widening the vd rule's own -# _MUST_NOT_MATCH probes already refuse. -name_regex = "^Berg, Jan mc$" -fields = ["_ambiguities", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#533) a credential ending a maiden clause reads as a credential" -# The PR review added one: 'Jane Doe nee Smith V MA', where a -# trailing roman numeral stands between the clause and the member. -# Both stops read the TRAILING word, so the numeral is not the word -# either fork asks about -- the acronym fork stops at 'MA' and the -# 'V' stays maiden text behind it. -# The words a maiden marker takes now end where a trailing credential -# begins, where the rule that reads the name left standing reads the -# word as one -- both as the name is written and as the take would -# leave it (rules.md#M2, decisions.md#M2). `suffix` fills as `maiden` -# shortens, and the fork is reported: the end of a maiden clause was -# the last trailing position in the library where a member of the -# ambiguous credential class was read in silence. -# -# Fourteen names here, three fewer than the same rule carries at -# 2.2.0 and 2.3.0, and the three are a fact about THIS baseline -# rather than about the change. 'Maria Kowalska z domu Nowak MA' and -# 'John née Jones Smith MA' move roles this baseline has never read, -# so each needs a rule that explains the whole of its diff; 'Doe, J. -# nee MA ba' moves no role from here at all and is with the reports -# below. Width is baseline-relative, and so is membership. -# -# What is left is the class read from every side the corpora reach -# it: the bare member ('Jane Doe nee Smith MA', 'Doe, Jane nee Smith -# MA'); its one-case spellings, where the lean is inert and the -# POSITIONAL reading is not ('JANE DOE NEE SMITH MA', 'JANE DOE NEE -# YO-YO MA', 'jane doe nee smith ma', 'Doe, Jane nee Smith ma'); the -# German abbreviation ('Jane Doe geb. Smith MA'); a member beside -# another credential, where `suffix` GROWS rather than filling ('Jane -# Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', 'Jane Doe nee -# Smith Ma JD' -- whose Title-cased Ma stays in the clause while the -# JD behind it leaves); past a trailing title, which the peel steps -# over ('Jane Doe nee Smith Prof. MA'); the slot reached past a -# middle initial ('Doe, Jane Q. nee Smith MA'); and the `do` -# collision, where the capitals decide ('Doe, Jane nee Smith DO' -# beside 'Jane Doe nee Smith do'). -# -# Literal-anchored for the reason fix(#531)'s rules give: the subject -# is a SLOT, so a regex for it would claim the names whose WRITING -# declines the word as readily as the ones it takes. -name_regex = "^(?:JANE DOE NEE SMITH MA|JANE DOE NEE YO-YO MA|Jane Doe geb\\. Smith MA|Jane Doe nee Smith MA|Jane Doe nee Smith MA JD|Jane Doe nee Smith MA PhD|Jane Doe nee Smith Ma JD|Jane Doe nee Smith V MA|Jane Doe nee Smith do|jane doe nee smith ma)$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) the maiden clause reports the credential it keeps" -# The PR review added more, and they are the same declining half -# reached by four further routes -- each a shape where the clause -# CANNOT promise the word a credential reading, so rules.md#M2 keeps -# it. No name word is left AHEAD of the member for a given part's -# trailing slot to be the end of ('Doe, Dr. nee Smith MA'); a -# particle chain below the marker pass would absorb it into the -# family ('Berg, Jane van der nee Smith DO', and 'Jane Doe nee Smith -# DO DO' where the other released member is what would do the -# joining); P5's bound-given join would swallow it into the pair -# ('Berg, abdul nee Jones MA'); the FIRST-WORD FLOOR spares it and -# only an unambiguous credential behind it leaves ('Jane Doe nee MA -# PhD'); and the lean declines it after a comma ('Doe, Jane nee -# Smith Do'). Those first four READ as 2f57ff21 read them and gain -# only the report: an earlier round of this branch released all four -# and moved them in silence, which is what the review measured and -# reversed. -# The other direction of the same fork, and the whole of what these -# five names do at this baseline: no role moves and the decision -# starts being REPORTED. A Title-cased member in a mixed-case name is -# written the way a name is written, so the clause keeps it ('Jane -# Doe nee Smith Ma', 'Jane Doe nee Yo-Yo Ma', 'Doe, Jane nee Smith -# Ma'); a member that is the ONLY word after the marker stays the -# maiden name whatever its writing says ('Jane Doe nee MA'); and -# 'Doe, J. nee MA ba' and 'Jane Doe nee King. ba' are the FLOOR's own -# pair, whose roles this baseline already reads the way the tree does -# -- maiden 'MA'/'King.', suffix 'ba' -- so from here the floor is -# visible only as the report it raises. The floor keeps a chained -# first word in the re-peel's count, which the parent's walk (it had -# no chain at all) never removed it from in the first place; so #535 -# moves nothing here either, and the second name's whole diff is -# #533's, exactly as the first's. -# -# Reporting a DECLINED fork is #530's stated rule -- the report -# tracks the fork consulted, not the lean -- and it is why this is a -# rule of its own rather than a widening of the one above: `fields` -# is `_ambiguities` alone, so it cannot absorb a role diff on any of -# the six. -# 2026-09-27, #544: 'Jane Doe Jr. nee Smith Ma' joins, the declining -# half at rules.md#M2's new Accepted boundary: the credential in front -# of the marker speaks for no member of the clause, the Title-case 'Ma' -# is kept, and the clause reports it. -name_regex = "^(?:Jane Doe nee King\\. ba|Jane Doe nee MA|Jane Doe nee MA PhD|Jane Doe nee Smith Ma|Jane Doe nee Yo-Yo Ma)$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#335/#533) a marker-led bracketed clause is the maiden name whatever its last word is" -# 'Jane Doe (nee Smith MA)', 'Jane Doe (nee Smith Ma)', 'Jane Doe -# (nee Smith) MA'. The role move is fix(#335)'s, one rule above: this -# baseline read the parenthesis pair as a nickname delimiter and -# looked no further. These three are separate because the FIRST TWO -# also lose a report -- the clause came back a nickname ending in a -# credential-class word, so the nickname reading raised its own -# SUFFIX_OR_NICKNAME, and consuming the clause as a maiden name -# retires it. -# -# They are here rather than as three more alternatives up there for -# the reason that rule's own comment gives about declaring a field a -# diff does not move: adding `_ambiguities` to it would let a report -# change hide on the five Latin names it already holds. -# -# What #533 does to them is NOTHING, and that is the point of -# recording them: the delimiters outrank every reading inside the -# span, so the whole span is the maiden name whatever its last word -# is and no fork is called (decisions.md#M2, rules.md#M2). 'Jane Doe -# (nee Smith) MA' is the control -- the member the writer left -# OUTSIDE the span is an ordinary trailing credential and reports. -name_regex = "^(?:Jane Doe \\(nee Smith MA\\)|Jane Doe \\(nee Smith Ma\\)|Jane Doe \\(nee Smith\\) MA)$" -fields = ["_ambiguities", "maiden", "nickname"] - -[[change]] -issue = "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position" -# 'Jane Doe nee Smith X.Y.Z.'. A by-shape member carries no writing -# convention to read -- `listed_lean` returns None where the switch -# made the token credential-SHAPED and the vocabulary did not list it -# -- so the positional reading decides alone and the clause gives the -# word up. The same trade #516 recorded at its own slot and #531 at -# the given part's, taken to agree with the comma-less 'John Doe -# X.Y.Z.' and rules.md#S3's shape rule rather than to restore -# anything. -# -# Its own rule rather than one of the movers above, because the -# ARGUMENT is different: every name up there has a writing the lean -# could have read, and this one has none. -name_regex = "^Jane Doe nee Smith X\\.Y\\.Z\\.$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name" -# 'John Smith nee Jones R.A.I.', and it is a RESTORATION rather than -# a change: this baseline reads suffix 'R.A.I.' with maiden 'Jones', -# and so does the tree, so only the report diffs here. What moved in -# between was this unreleased 2.4 cycle. At 2.3.0 the token carried -# `vocab:suffix` and the maiden walk's suffix-piece test stopped at -# it; #516 retags it `shape:acronym` plus `vocab:suffix-ambiguous`, -# it stops being a suffix piece, and the walk took it -- maiden -# 'Jones R.A.I.' on this tree at 2f57ff21, measured. The name was in -# no corpus file, so no gate ever compared it and no 2.4.0 release -# bullet records the move. -name_regex = "^John Smith nee Jones R\\.A\\.I\\.$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#445/#533) a one-case clause keeps the credential, and the one name word it leaves is the family" -# 'JOHN NEE JONES SMITH MA PHD'. One case, so no lean; a suffix word -# BEHIND the member ends the peel before it can reach the member, so -# the clause KEEPS the MA and only the clause's own emitter speaks. -# The role move is #445's -- the lone name word the clause leaves -# becomes the family -- and this baseline has not read it, so the -# diff is that move plus the new report. -# -# Its own rule rather than an alternative in the fix(#445) rule -# above, and case-sensitivity is the reason: that rule is (?i), and -# the mixed-case spelling of these words reads the other way. 'John -# nee Jones Smith MA PHD' gives the MA up and reads suffix 'MA PHD', -# measured 2026-09-19, so an (?i) alternative here would stand ready -# to explain a regression on a name whose reading is the opposite of -# this one's. -name_regex = "^JOHN NEE JONES SMITH MA PHD$" -fields = ["_ambiguities", "family", "given"] - -[[change]] -issue = "fix(#434/#533) a marker PHRASE takes the maiden name, and its clause ends at the credential" -# 'Maria Kowalska z domu Nowak MA'. The marker PHRASE is what moves -# the roles from here -- this baseline had no `z domu` entry, so it -# read middle 'Kowalska z domu', family 'Nowak' -- and the tree reads -# given 'Maria', family 'Kowalska', maiden 'Nowak'. #533's own effect -# on the name is invisible at this baseline: the trailing MA is a -# suffix on both sides, and both sides report it. What #533 changed -# is which words the maiden name holds, and `maiden` is already in -# the diff for the phrase's sake. -# -# Named for both changes because the maiden TEXT is the compound: at -# 2f57ff21 the same three fields moved and `maiden` read 'Nowak MA'. -# A rule naming only #434 would attribute this change's stop to the -# vocabulary addition; one naming only #533 would attribute the -# phrase to it. -name_regex = "^Maria Kowalska z domu Nowak MA$" -fields = ["family", "maiden", "middle"] - -[[change]] -issue = "fix(#424) the maiden walk stops before the trailing numeral, with the family name left standing" -# 'Jane Doe nee Smith V', and #533 has nothing to do with it: the -# name reads the same on this tree as it did at 2f57ff21, measured. -# The walk stops before the trailing roman numeral assign reads as -# the suffix, asked over the pieces as they stand and again over the -# view the take would leave -- #424's reading, stated at rules.md#M2. -# maiden 'Smith V' -> maiden 'Smith', suffix 'V', with the report -# #516 added at that slot. -# -# A rule of its own rather than an alternative in 'fix(#424/#445) the -# maiden walk stops before the trailing numeral, and the lone name -# word is the family' above: that rule's second half is the #445 move -# and this name has none -- family 'Doe' stands either way. What grew -# is the CORPUS: this change's rows put the numeral's clause shape in -# front of the gate for the first time at a baseline that can see it. -name_regex = "^Jane Doe nee Smith V$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) the maiden clause ends at the credential in a native-script name too" -# '田中 太郎 旧姓 佐藤 MA': maiden '佐藤 MA' -> maiden '佐藤', suffix -# 'MA', one report. The walk reads the same rule whatever script the -# clause is written in -- the marker is 旧姓, the member is Latin, and -# neither fact reaches the stop -- so this is the movers' rule above -# with a CJK marker in front of it. -# -# A COMPOSED form, so the name is a `tolerated` row on the radar tier -# (decisions.md#cjk-comma-demotion) and its diff prints under -# UNCLASSIFIED (radar) rather than blocking. It is classified all the -# same, on fix(#289/#516)'s stated ground and the one #531's caseless -# rule follows: a radar diff an arc INTENDED is one a release note -# may be written from, and an unclassified one is not. -# -# Its own rule rather than an alternative in the movers' regex: a CJK -# member in an alternation is claimed by the honorific pin, which -# would demand that alternation be a copy of GLUED_HONORIFICS. A lone -# literal has no alternation to pin. At 1.4.0 and 2.0.0 the marker's -# own rule carries this name instead, the 旧姓 consumption being the -# larger diff from those baselines. -name_regex = "^田中 太郎 旧姓 佐藤 MA$" -fields = ["_ambiguities", "maiden", "suffix"] - -# --------------------------------------------------------------- -# #397 + #461: THE CATALAN LINK JOINS, AND A CONNECTIVE INITIALS -# WHERE IT JOINS NOTHING (2026-09-20). Four rules: the join's ROLES, -# the REPORT a one-case spelling makes instead of joining, and -# #461's two halves, which are one sentence read in its two -# directions and need two rules because a rule listing `_initials` -# may list nothing else (#484). -# -# LAST in the file, and that is narrow-first rather than a -# preference: the join rule declares {family, middle, suffix} and -# every rule above it that reaches one of its five names declares a -# strict subset, so an earlier position is an order-decided contest -# the run refuses (#382). -# -# rules.md#P3 states the join and its two boundaries; rules.md#R3 -# states what a connective contributes. decisions.md#P3 carries the -# count decision and the both-sides scope, decisions.md#R3 the -# criterion and its measured blast radius. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#296/#535) a trailing title after a dropped postnominal Dr." -# 'Jane Doe nee Prof. Dr.' moves {suffix, title} at this baseline for -# two reasons together: this baseline still has 'dr' in the suffix -# vocabulary (#296 had not run yet), so it reads suffix 'Dr.' -- -# rules.md#S2: "A trailing word of the suffix vocabulary reads as a -# suffix" -- where the parent tree d9d80492, before #535, already -# reads maiden 'Prof. Dr.' (dr having left the suffix vocabulary by -# #296, so the clause keeps it as a name word). #535 then moves that -# word on into `title`, returning maiden to 'Prof.' -- the SAME value -# this baseline reads there, which is why `maiden` itself does not -# move net baseline-to-HEAD even though it moves in between. -# Literal-anchored for the reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Prof\\. Dr\\.$" -fields = ["suffix", "title"] - -[[change]] -issue = "fix(#533/#535) a title in front of the credential the clause gives up" -# 'Jane Doe nee Smith Prof. MA' moves at this baseline for two -# reasons together: #533 already gives 'MA' up to the credential -# reading and reports the fork (`suffix='MA'`, `maiden='Smith -# Prof.'`, SUFFIX_OR_NAME -- rules.md#S2's ambiguity flag: "either -# reading carries the ambiguity flag") before #535 exists -- verified -# against the parent tree d9d80492, which already differs from this -# baseline exactly that way. #535 then reads the trailing title chain -# through the credential, so the title standing in front of 'MA' -# leaves the maiden name too (`title='Prof.'`, `maiden='Smith'`). -# Literal-anchored for the reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Smith Prof\\. MA$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -issue = "fix(#535) a trailing title ends the maiden clause" -# rules.md#M2: "It takes them up to the trailing run -# of post-nominals and titles that the end of the name reads as if -# the clause were not written" (#601; until #601 this cited the -# sentence it replaced, "a trailing title ends it too") -- the end of -# the name is read through -# the trailing title chain -- rules.md#H5: "successive single words -# that wear the abbreviation shape and are title vocabulary chain -# into the title from the end" -- so the title leaves the clause and -# the credential or numeral in front of it gets the stop it gets with -# the title absent; the pair 'Smith MA Prof.' / 'Smith Prof. MA' now -# agrees. Literal-anchored for the reason fix(#533)'s rules give: the -# subject is a SLOT, and a regex would claim the names whose view -# check keeps the title as readily as the ones that release it. Three -# names moved to their own rules above ('Jane van der Berg nee Smith -# Prof.', 'Jane Doe nee Prof. Dr.', 'Jane Doe nee Smith Prof. MA'), -# each because the parent tree d9d80492 already differed from this -# baseline before #535 touched it; the seven names here are ones -# where #535 is the sole cause, verified the same way. -name_regex = "^(?:Jane Doe \\x28nee Smith Prof\\.\\x29|Jane Doe nee Smith King\\.|Jane Doe nee Smith MA Prof\\.|Jane Doe nee Smith Ma Prof\\.|Jane Doe nee Smith Prof\\.|Jane Doe nee Smith V Prof\\.|Mary Smith née Jones Prof\\.)$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -issue = "fix(#397) the Catalan/Polish link joins two surnames" -# 'i' is connective vocabulary since #397, and a connective counts as -# a name word toward rules.md#P3's three-word carve-out whatever else -# the vocabulary says it is -- which matters here because 'i' is also -# the roman numeral. So a four-word name reaches the threshold and the -# link joins. This baseline read 'Josep Carod i Rovira' as middle -# 'Carod i' with family 'Rovira', and 'Carod i Rovira, Josep' -- where -# the link stands after a family comma -- as family 'Carod Rovira' -# with the link taken as a GENERATION in `suffix`, which is why the -# two move different field sets and are grouped here rather than -# split. The three longer spellings move with the first: 'Josep Carod -# i Rovira III' and 'Josep Lluis Carod i Rovira' the same -# {family, middle}, and 'Josep Carod i de Rovira' the same pair with -# the link joining PAST a particle (middle 'Carod i' -> 'Carod i de', -# family 'de Rovira' -> 'Rovira'). Five names, `fields` the union of -# their two shapes. -# -# All five entered this corpus with #397's own rules.md#P3 example -# lines and case rows; before that they were radar-tier names the -# contract did not answer for, which is why a rule appears at this -# baseline only now. -# -# Literal-anchored to the five. The shape is "a single letter between -# two name words", which reaches every corpus name carrying a bare -# initial and would pre-excuse the opposite reading -- #397's whole -# accepted cost is that some such letters must NOT join. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the wall: -# 'Josep i Rovira' (three words, under the carve-out), 'Carod i' and -# 'John Quincy Smith i' (the link with nothing to its right) and the -# two 'Josep Lluis Carod i ' spellings, whose trailing -# generation run is the fix(#436/#437) rule's at the head of this -# file. -name_regex = "^(?:Carod i Rovira, Josep|Josep Carod i Rovira|Josep Carod i Rovira III|Josep Carod i de Rovira|Josep Lluis Carod i Rovira)$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#397) the Catalan link reports a connective-or-initial in a one-case name" -# 'JOSEP CAROD I ROVIRA', 'JOSEP LLUIS CAROD I ROVIRA' and 'josep -# carod i rovira'. 'i' joined `conjunctions_ambiguous` with #397, so -# in a name written wholly in one case -- where the writing says -# nothing either way -- the letter reads as an INITIAL and the call is -# reported, exactly as 'e' has read since #383/#479. #527's mechanism, -# a new letter: rules.md#P3's marked subset. -# -# ROLES DO NOT MOVE on any of the three, and that is the point of the -# marked subset rather than an accident of these spellings: reading -# the letter as an initial leaves it the middle-name word this -# baseline already had, so the whole diff is the report. Measured -# against the parent 46651750 on 2026-09-20, the same three names' -# parses are byte-identical before and after the bundle and only -# `ambiguities` moved. The lower-case spellings where the letter WAS -# the generation before -- 'rovira, i', 'john smith i jr', 'maier, amy -# i, jr.', 'josep de carod i rovira' -- do move fields under the same -# marked-subset reading, and no corpus carries one of them, so this -# rule is about the report alone. -# -# No rule at 1.4.0 and that is structural: `_ambiguities` is a v2 -# field and cannot diff against a v1 worker at all. -# -# Literal, three names. _MUST_NOT_MATCH carries the mixed-case -# spellings, where the writing decides the letter and the JOIN is what -# moved, and 'JUAN GARCIA Y LOPEZ', the unmarked letter in the same -# one-case position, which reports nothing. -name_regex = "^(?:JOSEP CAROD I ROVIRA|JOSEP LLUIS CAROD I ROVIRA|josep carod i rovira)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective holding its part alone contributes an initial" -# 16 names, each one a part whose only word is the connective, -# so there is nothing there for it to join and it initials as any -# other name word does -- which is what `family_base` and `given_base` -# have said about the same token since 2.2 (rules.md#R2/#R3). This -# baseline drops the letter; the tree keeps it. Measured from the -# 2.1.0 wheel and from the tree on 2026-09-20: 'Juan de y' 'J.' -> -# 'J. y.'; 'johnny y' 'j.' -> 'j. y.'; 'Garcia y Lopez' 'G. L.' -> -# 'G. y. L.'; 'Juan y Garcia' 'J. G.' -> 'J. y. G.' and 'juan y -# garcia' the lowercase twin; 'John y Jane' 'J. J.' -> 'J. y. J.'; -# 'Carod y de Rovira i' 'C. R.' -> 'C. y. R.'; 'John e Smith' 'J. S.' -# -> 'J. e. S.', with 'John e Smith III', 'John e Smith, III' and -# 'Smith, John e, III, Jr' giving the same pair; 'john e jones, III' -# 'j. j.' -> 'j. e. j.'; 'Jose e Maria' 'J. M.' -> 'J. e. M.'; -# 'Lt.Gov. juan e garcia' 'j. g.' -> 'j. e. g.'; and 'Fritz Freiherr -# und von Bar' 'F.' -> 'F. u.', the German spelling of the same shape. -# BOTH SURFACES move together and to the same string on every one of -# them, the two views reading one mark (#528, decisions.md#R3). -# 'محمد و علي' is the readmission RESTORING 1.4.0 parity rather than -# breaking it: 'م. ع.' here, 'م. و. ع.' in the tree, which is the -# string the 1.4.0 wheel gives. The 1.4.0 ledger's -# `feat(#269) a recognized non-Latin connective contributes no -# initial` explained that name and is DELETED in the same commit as -# this rule, the name having stopped diffing there. -# -# ONE CAUSE, measured, not two. Every name here agreed with this -# baseline's wheel BEFORE the bundle -- the parent 46651750 gives the -# wheel's string on each -- so none of the diff belongs to #383/#479's -# one-case fork or to any older change; the whole of it is #461. -# -# Literal-anchored. The shape is "a part holding one connective and -# nothing else", which no regex over the raw string can state -- the -# reading is the parse's -- and the nearest writable shape, "a name -# carrying a lone cased letter", reaches every bare-initial name in -# the corpora. _MUST_NOT_MATCH carries the wall: 'Jon Dough and' (base -# 'Dough and', a connective with a name word beside it, keeping -# 'J. D.'), 'Juan Velasquez y Garcia' (the joined run, keeping -# 'J. V. G.') and 'Juan Garcia Lopez y'. -name_regex = "^(?:Carod y de Rovira i|Fritz Freiherr und von Bar|Garcia y Lopez|John e Smith|John e Smith III|John e Smith, III|John y Jane|Jose e Maria|Juan de y|Juan y Garcia|Lt\\.Gov\\. juan e garcia|Smith, John e, III, Jr|john e jones, III|johnny y|juan y garcia|محمد و علي)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective with a name word beside it stops contributing an initial" -# 13 names, the other direction of the same sentence: where the -# part holds a name word for the connective to join, the connective is -# doing a connective's work and contributes nothing. This baseline -# initialed it; the tree does not. 'John and Jane Smith' 'J. a. J. S.' -# -> 'J. J. S.'; 'Juan y Eva Garcia' 'J. y. E. G.' -> 'J. E. G.'; -# 'Jose e Maria Santos' 'J. e. M. S.' -> 'J. M. S.'; 'and Jon Dough' -# 'a. J. D.' -> 'J. D.' and 'the and Jon Dough' 't. a. J. D.' -> -# 'J. D.'; 'Rob And Beth Edmunds' 'R. A. B. E.' -> 'R. B. E.'; the two -# 'Dean of Chemistry Robert Johns' spellings 'D. o. C. R. J.' -> -# 'D. C. R. J.'; 'John and Jane Aznar y Lopez' 'J. a. J. A. L.' -> -# 'J. J. A. L.'; 'Mr. and Mrs. John and Jane Smith' 'J. a. J. S.' -> -# 'J. J. S.'; and the three Cyrillic spellings of 'Хосе И Мария -# Сантос' 'Х. И. М. С.' -> 'Х. М. С.', lower case and upper case -# alike. Both surfaces move together and to the same string. -# -# ONE CAUSE, measured. Each of these names agreed with this -# baseline's wheel before the bundle -- the parent 46651750 gives the -# wheel's string on every one, the Cyrillic three included, whose 'и' -# has been decided vocabulary since 2.0 and reaches no case fork. So -# none of the diff is #383/#479's and the whole of it is #461. At -# 1.4.0 these names diff too, but they diffed there BEFORE this -# bundle, on the 2.0.0 per-word GROUPING, and that ledger keeps them -# on `fix(initials-per-word) a connective run initials each word` -# with a dated paragraph naming both halves. -# -# CYRILLIC SPELLED OUT, not classed. A `\p{Cyrillic}`-style span -# would declare a script this rule is not about -- the sentence is -# about a PART, in any script -- and _SPAN_BEARING_RULES in -# tests/v2/test_ledger_guards.py records that this rule declares no -# span. -# -# Literal-anchored, for the GAIN rule's reason, and with the same -# wall: 'Jon Dough and', 'Juan Velasquez y Garcia' and 'Juan Garcia -# Lopez y' are _MUST_NOT_MATCH probes here too -- the criterion is the -# PART and never a word count, which is exactly what keeps those three -# fixed. -name_regex = "^(?:Assoc Dean of Chemistry Robert Johns|Dean of Chemistry Robert Johns|John and Jane Aznar y Lopez|John and Jane Smith|Jose e Maria Santos|Juan y Eva Garcia|Mr\\. and Mrs\\. John and Jane Smith|Rob And Beth Edmunds|and Jon Dough|the and Jon Dough|ХОСЕ И МАРИЯ САНТОС|Хосе И Мария Сантос|хосе и мария сантос)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -# #397 review: A LINK INSIDE A MAIDEN CLAUSE STAYS IN THE BIRTH NAME -# (2026-09-20). LAST in the file, narrow-first: this rule declares -# {family, maiden, middle, suffix} and every rule above it that could -# reach any of the three declares a strict subset, so an earlier -# position would be an order-decided contest the run refuses (#382). -# -# rules.md#M2 states the exception; decisions.md#M2's 2026-09-20 -# bullet carries the measurement and why it is fixed in #397's own PR -# rather than filed. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#397) a link inside a maiden clause stays in the birth name" -# Three names, one sentence, three shapes -- `fields` is the union. -# -# The maiden walk has always ended the birth name at the first suffix -# WORD after the marker, and the Catalan link is also the roman -# numeral, so this baseline read maiden 'Puig' and left 'i Soler' -# outside the clause: 'Jane Doe nee Puig i Soler' middle 'Doe i', -# family 'Soler'; 'Doe, Jane nee Puig i Soler' middle 'Soler', suffix -# 'i'. That truncation PREDATES this bundle. What #397 added is -# worse and is why the stop is fixed here: with the link joining, the -# leaked words merged into the CURRENT surname -- measured on the -# parent 46651750 and on this branch's 0fbcaa0b, family 'Doe i Soler' -# and given 'Jane i Soler' respectively -- a word of the birth name -# inside the current one. -# -# The 'y' spelling is the control and never moved at any baseline: -# 'Jane Doe nee Puig y Soler' reads maiden 'Puig y Soler' here and in -# the tree, 'y' being connective vocabulary and no generation. That -# twin is the property invariant in tests/v2/test_properties.py. -# -# The THIRD name arrives from rules.md#M2 rather than from a report: -# 'Smith, John, PhD née Puig Mr. - i Soler' is one of rules.md#M2's -# delimiter examples, added with #538 (under a configured ' - ' it -# reads maiden 'Puig Mr.', by the comma reading since #549). This corpus does not configure that delimiter, -# so the gate parses it with the DEFAULT facade, where the dash is an -# ordinary name word: the link has one on each side and the clause -# keeps 'i Soler' where this baseline left it in the suffix: suffix -# 'PhD i Soler' -> 'PhD', maiden 'Puig Mr. -' -> 'Puig Mr. - i Soler' -# (measured 2026-09-22 at all four 2.x baselines, identical at each). -# That is this rule's own sentence and no part of #538 or #549, whose -# readings need the delimiter declared; {maiden, suffix} is a subset of the -# union above. -# -# Literal-anchored to the four. The shape is "a one-letter connective -# inside a maiden clause", which would stand ready to excuse every -# future regression at this walk -- and the walk's whole design is -# that some such letters must NOT be taken. _MUST_NOT_MATCH carries -# the wall: the i-last control, the generation behind it, and the -# spellings where the letter is an initial. -# -# 'Smith, John, PhD née Puig - i Soler' joined the alternation with -# #538 (2026-09-26): it is rules.md#M2's second live example, and it -# reaches the DEFAULT facade the same way its neighbour does -- no -# delimiter configured here either, so the dash is an ordinary name -# word. Measured at this baseline: suffix 'PhD i Soler' -> 'PhD', -# maiden 'Puig -' -> 'Puig - i Soler' -- the same shape as the -# neighbour's own reading with 'Mr.' dropped from it, unchanged by -# the fix; only the corpus name is new. -name_regex = "^(?:Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler|Smith, John, PhD née Puig - i Soler)$" -fields = ["family", "maiden", "middle"] -orders = ["DEFAULT"] - -[[change]] -issue = "change(suffix-acronym-collisions) ph leaves the acronym set" -# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps -# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' -# token pass the acronym test on its first piece -- the merge itself -# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working -# without it -- and its only unique coverage was a bare trailing -# 'Ph.' with no 'D.' behind it, read as a credential by every release -# since 1.4.0. Out of the set that spelling falls to the positional -# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: a deliberate 2.x parity break at every baseline, argued in -# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's -# and carried by the 2.4.0 release-log bullet. No corpus name had the -# shape until the cases.py row was written to carry it, which is why -# the removal measured as 0 role movers at review time; 'John Smith -# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. -# -# Literal to the one corpus name that carries the shape. The CLASS -# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', -# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', -# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every -# one a bare ph with no D. behind it (the decisions block lists them, -# measured 2026-09-22). A row carrying one of those arrives -# unexplained here by design: widen this rule to it, do not file it. -name_regex = "^John Smith Ph\\.$" -fields = ["family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. The rules -# below: the two names whose family name comes back, the marking's -# cost behind a full name, the same cost after a family comma, the -# lone word after a family comma (given name back, cost for the -# conventional spelling), the Title-case Lac gain that is the cost's -# mirror, and the two that keep the credential and gain a report. No -# rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. -# -# Every name here is one of #540's own shape-tagged case rows. No -# corpus name written before the change carried a bare trailing -# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at -# 120033b5), so the gate saw nothing until the rows admitted it. The -# same rules stand in the four 2.x ledgers; the 1.4.0 ledger carries -# the two cost rules and the Title-case Lac rule, 1.4.0 having read -# the other names' roles as the tree does. -# -# At landing (2026-09-25) there were eight rules here, and two more -# costs among them: the credential run behind a suffix comma re-read -# as a family comma, and the chunked dotted 'Wang M.Eng.' read as the -# family. #544 (2026-09-27) reads both shapes again and retired both -# rules. The comma-run names are the fix(#544) rules' at the -# bottom of this file; 'Wang M.Eng.' reads the roles this baseline -# read, and its report is the feat(#449) lone-name-word rule's near -# the top. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" -# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and -# 'tran', 'lac') with a suffix-or-name report, where this baseline -# read suffix 'meng' and no family name at all -- reporting nothing -# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the -# one-word-name fork, which was the wrong fork. 1.4.0 read the -# family, which is why that ledger has no copy of this rule. -# -# Literal-anchored to the two names. The class is every name whose -# last word is a bare member with nothing to spare, and a regex for -# it would stand ready to explain a regression on the spellings that -# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py -# carries 'meng li', where the word LEADS and nothing moved. The -# members are names, not a copy of any wordlist, which is what -# _NOT_A_VOCABULARY_COPY records. -name_regex = "^(?:tran lac|wang meng)$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" -# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with -# a suffix-or-name report, where every release read suffix 'MEng'. -# The marking's stated cost. In a name written in more than one case -# rules.md#S2 reads a member "written in any other cased form that is -# not wholly lower" as the name "even with words to spare", and MEng -# and LAc are conventionally written in exactly that form -- the cost -# 'John Smith Ma' already carries. -# -# 'John Smith MEng PhD' pays the same cost and keeps going: the -# declined pick stops the walk (family 'MEng'), but the unambiguous -# credential behind it is still peeled (suffix 'PhD'), where every -# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). -# -# A rule of its own rather than a member of the one above, so the -# gate's summary and the release note name the cost under its own -# heading. Literal, two names: the one-case spellings 'john smith -# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding -# where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" -fields = ["family", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" -# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', -# with a suffix-or-name report, where every release read suffix -# 'MEng'. The same S2 case reading the rule above pins, arriving -# after a family comma, where C1 has already settled the word count -# and the writing is the only evidence left. -# -# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's -# name-word count, and the all-lower 'Smith, John meng' keeps the -# credential too -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, John MEng$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" -# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family -# 'Smith', with a suffix-or-name report, where this baseline read -# suffix and no given name at all -- the third comma path, S2 -# declining the post-comma credential when the word count leaves it -# a name and C1 reading it as the given name. 1.4.0 already read -# the given name here, so that ledger carries no copy of this rule: -# the all-lower spelling restores 1.4.0's reading, and the -# mixed-case spelling is the cost side of the same path. -# -# Literal, two names; 'Smith, MENG' keeps the credential on the -# capitals lean, and 'Smith, John MEng' belongs to the middle-name -# rule above -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, (?:MEng|meng)$" -fields = ["given", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" -# 'john smith meng' and 'nguyen van lac': every role is what this -# baseline read -- suffix 'meng', suffix 'lac' -- because a full name -# in front leaves the credential reading available, and what is new -# is the report rules.md#S2 makes at the trailing slot, where "either -# reading carries the ambiguity flag". `_ambiguities` alone, so this -# rule cannot absorb a ROLE diff on either name, which is the point -# of keeping it apart from the two above. -name_regex = "^(?:john smith meng|nguyen van lac)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports" -# Every role is what this baseline read -- the whole run after the -# comma a suffix -- and what is new is the report. rules.md#C1: "The -# same count reads a part of two or more words as the credential run -# when every word of it is a suffix word or a word of this class, at -# least one of them of this class", and "A decision either way at this -# comma is reported". 'John Smith, PhD MEng' and 'john smith, phd meng' -# come back here from #540's accepted cost, which had read the comma as -# a family comma; 'Jane Doe, MS LAc' and 'John Smith, MD MEng' open the -# part with a title-and-suffix word, which counts as the suffix word it -# is after a full name, where #540 had read it as a title. -# -# `_ambiguities` alone, so this rule cannot absorb a ROLE diff on any of -# them -- the names whose roles move are the rule below. Literal; the -# probes 'John Smith, PhD MA' (capitals in a mixed-case name already -# settle the run, so it reports nothing new) and the superstring 'Dr. -# John Smith, PhD MEng' are _MUST_NOT_MATCH. -name_regex = "^(?:Jane Doe, MS LAc|John Smith, MD MEng|John Smith, MEng PhD|John Smith, PhD MEng|john smith, phd meng)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count" -# The part after the comma is the credential run on C1's count of the -# two name words before it, where this baseline read it as the listing -# form ('John Smith, Ed Ma' read given 'Ed', middle 'Ma'). A -# title-and-suffix word opening the part counts as the suffix word it is -# after a full name ('John Smith, Ms Ma', the accepted cost, Derek -# 2026-09-27; 'john smith, md ma'), and 'X.Y.Z.' is a member by shape, -# whose capitals lean nothing -- rules.md#S2 reads the writing of a -# LISTED member only -- so its run flips too. 1.4.0 read the listed- -# member names as suffixes as well. -# -# 2026-09-28: 'John Smith, PhD Ma' joins, the same run with an -# unambiguous word in front of the member, which the count reads whole -# like the rest. -# -# 2026-10-01, #554: 'John Smith, vd Ma' and 'JOHN SMITH, VD MA' join, -# rules.md#C1's examples for a word of both the particle and the -# unambiguous suffix vocabulary: "A word's particle reading does not -# take it out of the run". This baseline read the whole name as the -# family ('John Smith vd Ma'). 'John Smith, Jr do' joins as the -# contrast to the silent 'John Smith, Jr vd': `do` is a word of the -# ambiguous class, so the count flips the part and reports, where this -# baseline read title 'Jr', given 'do'. -# -# Literal; `fields` is the union the names move ('title' is the duals'). -# Probes: 'Smith, Ed Ma' (one name word before the comma keeps the -# listing form), 'Smith, Ms Ma' (a dual opening the given part is a -# title) and the superstring 'Dr. John Smith, Ed Ma' are _MUST_NOT_MATCH. -name_regex = "^(?:JOHN SMITH, VD MA|John Smith, Ed Ma|John Smith, Jr do|John Smith, Ms Ma|John Smith, PhD Ma|John Smith, X\\.Y\\.Z\\. MA|John Smith, vd Ma|john smith, md ma)$" -fields = ["given", "middle", "family", "suffix", "title", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#436/#437/#544) an unambiguous credential in front anchors the member behind it" -# rules.md#S2's company clause: a member written behind an unambiguous -# credential in one run "reads as the credential whatever its writing -# and whatever the count", where the tree before #544 read the Title-case -# MEng as a name word ('John Smith PhD MEng' family 'MEng'; 'Doe, Jane -# PhD MEng' middle 'MEng'), in the maiden clause's reading of the name -# too. The diff against this baseline is what remains once the roles -# come back: the report of the member the peel picked, and -- through -# 2.2 -- the run 1.4.0 and 2.x wrote 'PhD, MEng', which rules.md#R1 -# writes as the writer spaced it (#436/#437), so one rule explains the -# whole of each diff. -# -# The label is joint for that reason, as the 1.4.0 ledger's -# fix(#274/#436/#437) rules are: the `suffix` this rule claims moves on -# the spacing alone, which is #436/#437's, and `_ambiguities` is the -# report #544 adds. The 2.3.0 ledger, whose baseline already spaces the -# run, carries the same rule as #544's alone. -# -# Literal. Probes: 'Wang Ma PhD' (the credential BEHIND the member speaks -# for nothing) and the superstring 'Dr. John Smith PhD MEng' are -# _MUST_NOT_MATCH. -name_regex = "^(?:Doe, Jane PhD MEng|Doe, Jane nee Smith PhD MEng|Jane Doe nee Smith PhD MEng|John Smith PhD MEng)$" -fields = ["suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a degree in front outranks P6's attachment for a particle member" -# 'doe, jane v phd do': the one-case 'do' leans nothing and is particle -# vocabulary, so P6's attachment would take it into the family, and the -# unambiguous 'phd' in front of it outranks that attachment as capitals -# do (rules.md#S2, decided 2026-09-27) -- suffix 'v phd do', reporting -# suffix-or-name where this baseline read the particle into a name part. -# 'NASCIMENTO, EDSON ARANTES DO', with nothing in front, keeps P6's -# reading. Literal; the superstring 'Dr. doe, jane v phd do' and 'doe, -# jane do' are _MUST_NOT_MATCH. -name_regex = "^doe, jane v phd do$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a Title-case Lac behind a particle is the family name" -# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a -# suffix-or-name report, where every release read family 'Van', suffix -# 'Lac'. The mixed-case name's Title-case member leans 'name' with -# words to spare (S2) -- the gain side of the marking's cost, for a -# Vietnamese name (Lac is the trailing word in native order, Nguyen -# Van Lac). -# -# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix -# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, -# along with the superstring 'Dr. Nguyen Van Lac'. -name_regex = "^Nguyen Van Lac$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#296/#544) a credential in front anchors a member after a one-word family comma, and the pick reports" -# 'Smith, PhD Ma' and 'Smith, PhD MEng': family 'Smith', suffix 'PhD -# Ma' / 'PhD MEng', each reporting its member, where this baseline read -# title 'PhD' and given 'Ma' (suffix 'MEng'). Two changes make the one -# diff, so the label is joint: 'phd' left TITLES (#296, the 2.2 audit), -# which is what lets the part after the comma read as a credential run -# at all, and rules.md#S2's company clause (#544), 'PhD' speaking for -# the member behind it and the pick reporting where it was made. The -# fix(#325) rule above reaches both names by its regex and explains -# neither any longer, declaring no `_ambiguities`. -# -# Literal. Probes: 'Smith, PhD MA' (the capitals decide the member), -# 'Smith, MD PhD Ma' (a dual in the leading title run: nothing in the -# part speaks) and the superstring 'Dr. Smith, PhD Ma' are -# _MUST_NOT_MATCH. -name_regex = "^(?:Smith, PhD MEng|Smith, PhD Ma)$" -fields = ["title", "given", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#296/#531) phd is not a prenominal, so behind a dual title it is the given name and the member ending the part reports" -# 'Smith, MD PhD Ma': title 'MD', given 'PhD', middle 'Ma', reporting -# 'Ma', where this baseline read title 'MD PhD', given 'Ma'. 'phd' left -# TITLES (#296), so the title run ends at the dual 'MD' and 'PhD' is -# the given name; the Title-case 'Ma' ending the given part is #531's -# slot, which keeps it a name and reports the fork. #544 leaves the -# reading where it was: nothing in a part whose leading title run holds -# a dual speaks for the member (rules.md#S2). -# -# Literal. Probes: 'Smith, MD PhD MA' (the capitals make the member a -# credential and the part reads whole), 'Smith, Ms MD Ma' (the title -# run leaves 'Ma' the given name) and the superstring 'Dr. Smith, MD -# PhD Ma' are _MUST_NOT_MATCH. -name_regex = "^Smith, MD PhD Ma$" -fields = ["title", "given", "middle", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#575) a particle surname before a comma is one name word" -# rules.md#C1: "a particle run and the one name word it attaches to -# are one word", so 'van der Berg, PhD' reads as 'Berg, PhD' -# does: family 'van der Berg', suffix 'PhD'. Every release counted -# tokens and split the chain, given 'van', family 'der Berg', with a -# particle-or-given report. -# -# Literal; the probe 'John van der Berg, PhD' (two name words) is -# _MUST_NOT_MATCH. -name_regex = "^van der Berg, PhD$" -fields = ["given", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default" -# rules.md#C1 and S2 (#564): Policy.unlisted_caps_suffixes defaults to -# CapsSuffixes.AFTER_COMMA, so the part after a comma with two or more -# name words before it reads an unlisted all-caps word as the -# credential, alone or in C1's run, where the name contrasts it, and -# reports the call; a lone two-letter word is declined as initials. The all-caps SURNAME convention never writes the -# capitals there. 'John Smith, XYZ' and 'John Smith, LEED AP' are the -# C1 examples (the latter closing the #291 deviation the doc carried); -# 'The Rt Hon Kenneth Clarke QC MP, HMG' is a radar corpus name. -# 'John Smith, PhD XYZ' is a caps word in C1's run beside a listed -# credential, and 'García Márquez, MJ JK' two words of two capitals, -# read as #563 reads two pairs (both C1 examples). 'John Smith, RAI' and 'Ahmad Jayadi, CHA' read -# suffix again, as this baseline read them by vocabulary before #342 -# removed both words, and differ only by the comma's report. -# -# Literal; the probes 'Smith, XYZ' (one name word), 'García Márquez, -# MJ' (a lone two-letter word) and 'John Smith XYZ' (no comma: the -# trailing position stays opt-in) are _MUST_NOT_MATCH. -name_regex = "^(?:Ahmad Jayadi, CHA|García Márquez, MJ JK|John Smith, LEED AP|John Smith, PhD XYZ|John Smith, RAI|John Smith, XYZ|The Rt Hon Kenneth Clarke QC MP, HMG)$" -fields = ["title", "given", "middle", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word between credentials after a family comma stays in the run" -# rules.md#P6's third exception: a lone such word with a credential -# read in front of it and another behind "does not end the name, and -# keeps the post-nominal reading in any case, reported as S2's -# credential fork" -- whatever made the credential in front, so 'DOE, -# JANE MA VD PHD' stays whole as 'DOE, JANE PHD VD MA' does. This -# baseline read middle 'VD MA'/'vd ma' or 'MA', with the rest as -# suffixes, and reported nothing. -# -# Literal; the probe 'DOE, JANE PHD VD' (nothing behind, so the -# attachment holds) is _MUST_NOT_MATCH. -name_regex = "^(?:DOE, JANE MA VD PHD|DOE, JANE PHD VD MA|doe, jane phd vd ma)$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a never-given particle opening the given part takes the member behind it" -# rules.md#S2: a never-given particle first after a family comma is no -# given name, and the member behind it "is the surname the particle -# heads, not a word to spare", a chained particle run included ('VD DE -# LA'). This baseline read the same family; the report is the fork the -# given slot now decides, which it did not consult then. -# -# Literal; the probe 'SMITH, VAN MA' (an ambiguous particle, which can -# be a given name, keeps it, and the member reads as the credential) -# is _MUST_NOT_MATCH. -name_regex = "^(?:SMITH, VD DE LA MA|SMITH, VD MA|smith, de ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#585) a decomposed initial keeps its whole first letter" -# initials() took a token's first CODE POINT, which in a name typed -# decomposed (NFD) is a base letter without the combining mark that -# completes it: 'e' + U+0301 initialled 'e.', and the katakana 'ジ', -# 'シ' + the combining dakuten U+3099, initialled 'シ.'. The initial is -# now the shortest start of the word that composes to the composed -# word's first character, written in the form the writer used -# (rules.md#R3), so both surfaces give the composed spelling's initial -# kept decomposed. A composed name's initials do not move, and nor do -# those of an NFD name whose first letters do not decompose (#542's -# 'josé garcía' rows), which is why the rules corpus's two other NFD -# rows are the only names here. -# -# Roles do not move; the derived view alone does, so `_initials` is -# the one field. Literal names rather than a shape: what selects them -# is "a name typed decomposed whose initial letter decomposes", and no -# regex over the raw text says that without copying Unicode's -# decomposition data. -name_regex = "^(e\u0301mile zola|マイケル シ\u3099ャクソン)$" -fields = ["_initials"] - -# --------------------------------------------------------------- -# #594: HALFWIDTH KATAKANA IS KATAKANA. The script table classifies -# the halfwidth kana block U+FF65-U+FF9F as Script.KATAKANA, so what -# reaches full-width katakana reaches halfwidth now: the kana license -# (rules.md#W4) reads 山田 タロウ family-first as it reads 山田 エミ, the -# 间隔号 divides between two halfwidth kana (rules.md#T3), and H2's -# opening-abbreviation shape declines a period-marked katakana word -# because katakana has no initials (rules.md#H2's Accepted clause), -# closing the limit decisions.md#cjk-full-stops recorded. A WHOLLY -# halfwidth name keeps the declared order, as wholly full-width -# katakana does, so 'ヤマダ タロウ' is parity. -# -# Literal-anchored to the names: no corpus line held a halfwidth kana -# before this change, so the movers ARE the eight case rows #594 added, -# less the parity one -- five here, two in the title rule. A shape -# spelling -- any halfwidth kana -- would be a hand copy of the span -# the canonical CJK rule already carries. LAST in the file: each diff -# reported unexplained, or unclassified on the radar, on the run -# before these rules existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#594) halfwidth katakana takes the kana license and the 间隔号" -# '山田 タロウ', '山田 ダイスケ' and '山田 ペーター' read family 山田 where every -# release read it given, the unspaced '山田タロウ' reads family where it -# read given, and 'タロウ·ヤマダ' divides where it was one given token. -name_regex = "^(?:山田 タロウ|山田 ダイスケ|山田 ペーター|山田タロウ|タロウ\\u00B7ヤマダ)$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#594) a period-marked halfwidth katakana word is not a title" -# 'タナカ. John' and 'マイケル.' read title at 2.1.0, as their full-width -# twins did until #cjk-full-stops (2.3); both read given now, as the -# twins do. -# Tolerated input (an edge full stop on a CJK word), so these are -# radar names and the rule classifies them for the release note rather -# than for the exit code. -# `_ambiguities` is the v2 surface's: the lone 'マイケル.' reports -# given-or-family as 'マイケル.' does, where the title reported nothing. -name_regex = "^(?:タナカ\\. John|マイケル\\.)$" -fields = ["title", "given", "_ambiguities"] - -# --------------------------------------------------------------- -# #596: A VOICING MARK TAKES THE SCRIPT OF THE KANA BEFORE IT. The -# four kana voicing marks U+3099-U+309C sit in the hiragana block, and -# NFC composes one only where a precomposed voiced kana exists, so the -# mark in 'ア゙イ' (ア + U+3099) or 'ア゛イ' (the spacing U+309B) stood in -# a katakana word as hiragana and the word took the kana license -# (rules.md#W4) -- family-first, where a wholly-katakana name keeps -# the declared order. Classification now deletes each mark that -# follows a kana, so the word reads as its base's script. The -# same names on hiragana or beside kanji ('あ゙い たろう', '山田 ア゙イ') -# are parity: their reading never depended on the mark. -# -# Literal-anchored to the names: no corpus line held an uncomposable -# voicing mark before this change, so the movers ARE the three case -# rows #596 added. LAST in the file: each diff reported unexplained -# on the run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#596) a voicing mark does not make a katakana word kana-licensed" -# 'ア゙イ タロウ' and 'ア゛イ タロウ' read given ア゙イ where 2.1.0-2.3.0 -# read it family, and 'マイケル ア゙イ' reads given マイケル where the -# licensed neighbour made it the family. -name_regex = "^(?:ア\\u3099イ タロウ|ア\\u309Bイ タロウ|マイケル ア\\u3099イ)$" -fields = ["given", "family"] - -# --------------------------------------------------------------- -# #597: THE HALFWIDTH CORNER BRACKETS ARE A DEFAULT NICKNAME PAIR. -# 「」 (U+FF62/U+FF63) join DEFAULT_NICKNAME_DELIMITERS beside 「」, and -# the v1 facade's nickname_delimiters gains halfwidth_corner_brackets, -# so a clause they enclose is lifted out as the nickname (rules.md#N1) -# where every release left it in the name -- a middle where spaced, -# part of one given word where glued. With the -# unclassified bracket gone, '山田 「タロー」 タロウ' is kanji plus katakana and -# takes the kana license; the unspaced '山田「タロー」太郎' divides at the -# extracted clause; 'John 「Jack」 Smith' shows the pair is not -# script-gated. -# -# Literal-anchored to the names: no corpus line held a halfwidth -# bracket before this change, so the movers ARE the three case rows -# #597 added. LAST in the file: each diff reported unexplained on the -# run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#597) halfwidth corner brackets enclose a nickname" -# The clause moves to nickname in all three, out of middle in the two -# spaced names and out of the one given word in '山田「タロー」太郎'; the -# two Japanese names also take their script order, family 山田. -name_regex = "^(?:山田 「タロー」 タロウ|山田「タロー」太郎|John 「Jack」 Smith)$" -fields = ["given", "middle", "family", "nickname"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). The clause these names -# carried at this baseline is gone: `maiden` empties and its words read -# where the given part reads any word. What else differs is the given -# part's own reading of those words -- the spaced credential run -# (#436/#437), the link join (#397), P6's attachment of a trailing -# particle to the family (#405), the capitals lean (#289) and the -# trailing title chain (H5) -- each as the same text with the marker -# replaced by an ordinary word reads it. -issue = "fix(#601) a marker in the given part after a family comma is an ordinary word" -name_regex = "^(?:Berg, Jane van der nee Smith DO|Berg, abd née Jones|Berg, abdul nee Jones MA|Doe, Dr\\. nee Smith MA|Doe, J\\. nee MA ba|Doe, Jane Q\\. nee Smith MA|Doe, Jane nee Puig i Soler|Doe, Jane nee Smith|Doe, Jane nee Smith DO|Doe, Jane nee Smith Do|Doe, Jane nee Smith MA|Doe, Jane nee Smith MA Prof\\.|Doe, Jane nee Smith MA do|Doe, Jane nee Smith Ma|Doe, Jane nee Smith PhD MEng|Doe, Jane nee Smith do|Doe, Jane nee Smith ma)$" -fields = ["_ambiguities", "family", "given", "maiden", "middle", "suffix", "title"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04): a tail part is read -# wholly as suffixes, the marker and its words with it. -issue = "fix(#601) a marker in a part after a suffix comma is an ordinary word" -name_regex = "^(?:Smith, John, MD - née Jones Smith|Smith, John, PhD née Jones)$" -fields = ["maiden", "suffix"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). Where a credential -# stands before the marker it also starts #602's run -- rules.md#S2: "A -# credential after the name core starts a run to the end of its part" -- -# which takes the marker and the words after it in. -issue = "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word" -name_regex = "^(?:Dr\\. nee Smith PhD Prof\\.|Jane Doe Jr\\. nee Smith|Jane Doe Jr\\. nee Smith Ma|Jane Doe PhD nee Smith|Jane and née Jones)$" -fields = ["_ambiguities", "family", "given", "maiden", "suffix", "title"] - -[[change]] -# 'Jane Doe nee Smith VI Prof.': rules.md#M2's numeral example -# behind a title, which the take reaches since #610 shared its -# candidate words with the peel (2026-10-04). -# rules.md#M2: "It takes them up to the trailing run of post-nominals and -# titles that the end of the name reads as if the clause were not -# written" (#601, 2026-10-04), and "The run so found ends the name: its -# words are the suffixes and titles that reading makes them, and no join -# reaches into it". The old walk's release check kept these words in the -# clause where a join it modelled might take them; the run is bound now. -issue = "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes" -name_regex = "^(?:Jane Doe nee Smith DO DO|Jane Doe nee Smith VI Prof\\.|Jane van der Berg nee Prof\\. King\\. MA|Jane van der Berg nee Smith Prof\\.|John nee Prof\\. ba MA)$" -fields = ["_ambiguities", "family", "given", "maiden", "suffix", "title"] - -[[change]] -# rules.md#S2: "A credential after the name core starts a run to the end -# of its part" (#602, 2026-10-04): every later word of the part is a -# suffix except a title word, which reads as a title, and an absorbed -# name word is reported. -# 'Jane van der Berg née Jr Jones' (radar): the marker declines on -# the suffix word after it (rules.md#M2) and stays a word, so 'Jr' -# stands behind a name core and its run takes 'Jones' (2026-10-04). -issue = "fix(#602) a credential after the name core starts a run to the end of its part" -name_regex = "^(?:Eric H\\. Holder Jr\\. Attorney General|Jane van der Berg née Jr Jones|John Smith PhD Jones|John Smith PhD Prof\\. Ma|Smith, John PhD Jones)$" -fields = ["_ambiguities", "family", "middle", "suffix", "title"] - -[[change]] -# rules.md#S2: "leaving it nothing to join: then it counts as a name -# word of its own" -# (#627, 2026-10-10): a lone particle with the credential right behind -# it counts toward the run's two name words, so it is the family and -# the run takes the rest. -issue = "fix(#627) a lone particle with the run right behind it is the surname" -name_regex = "^John von PhD Jones$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -[[change]] -# rules.md#P4: "of several such words the last does, so no particle -# chains inside the titles" (#624, 2026-10-10): the second -# title-particle is a title and the name starts behind it. -issue = "fix(#624) of two title-particles at the head the second leads the chain" -name_regex = "^Freiherr St John Smith$" -fields = ["_ambiguities", "family", "given", "title"] - -[[change]] -# rules.md#H5: "chain stops in front of the trailing run of suffixes and titles," -# (#623, 2026-10-10): after a family comma a trailing title is the -# title, and P6 attaches the particle in front of it. -issue = "fix(#623) after a family comma the chain stops before a trailing title" -name_regex = "^Smith, J[a-z]+ van( der Berg)? Prof\\.$" -fields = ["_ambiguities", "family", "middle", "title"] - -[[change]] -# rules.md#C2: "a part past the second comma holds no name word" (#636, -# 2026-10-11): group makes no particle chain in such a part, so a title -# word behind a particle run there is a title. -issue = "fix(#636) a part past the second comma makes no particle chain" -name_regex = "^John Smith, Jr\\., van der Berg Prof\\.$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#637, -# 2026-10-11): after a family comma the bound join declines the -# trailing title word H5's run takes, so it is read as the title. -issue = "fix(#637) the bound join declines a trailing title after a family comma" -name_regex = "^Berg, abdul Prof\\.$" -fields = ["middle", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#640, -# 2026-10-11): in a part past the second comma the bound join declines -# a title word, which C2 reads as a title. -issue = "fix(#640) the bound join declines a title word past the second comma" -name_regex = "^John Smith, Jr\\., abdul Dr\\. Smith$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#M2 reads the clause-free name, whose credential starts #602's -# run -- rules.md#S2: "A credential after the name core starts a run to -# the end of its part" -- and the take consumes that run: maiden 'Smith', -# suffix 'PhD Smith' (2026-10-04). -issue = "fix(#601/#602) a credential in the clause starts a run the take consumes" -name_regex = "^Jane Doe nee Smith PhD Smith$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -[[change]] -# The initials half of 'Jane and née Jones' (#601, 2026-10-04): a -# marker behind a connective is an ordinary word -- rules.md#M2: "In -# the given part after a family comma, in a part after a suffix comma, -# and behind a title, a suffix word or a connective, the marker is an -# ordinary word" -- so it joins the connective run 'Jane and née', and -# the run initials each of its words but the connective (R3). The -# roles' half is fix(#601/#602)'s marker-behind-a-connective rule above. -issue = "fix(#601) a marker behind a connective joins the connective run, and the run's initials follow" -name_regex = "^Jane and née Jones$" -fields = ["_initials"] - -[[change]] -# rules.md#P6: "a particle ending the name attaches to that family name", -# and a post-nominal written behind the particle does not end the name -# for this purpose. rules.md#S2's example for the given-part run leaving -# the particle to P6 (#601/#602 review, 2026-10-04); the attachment -# itself landed in 2.2 (#379). -issue = "fix(#379) a tussenvoegsel behind a post-nominal after a family comma attaches" -name_regex = "^Smith, John PhD de Jr\\.$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #604: THE IRISH AND MALAY PATRONYMIC PARTICLES. -# 'ó' and 'ní' join the never-given particles, 'ua', 'binti' and -# 'binte' the ambiguous half, so each opens the surname behind it -# (rules.md#P1, P2) where every release read it as a middle name. -# 'Ó.' written WITH its period stays the initial (rules.md#P7), so of -# the five P7 examples only the bare boundary moves; the two R4 case -# repair examples move for the vocabulary itself. -# -# Literal-anchored to the names: the movers are the P7 boundary and -# the two R4 examples, each reported unexplained on the run before -# this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#604) the Irish and Malay patronymic particles" -# The particle leaves the middle and opens the family: 'Ó Pérez', -# 'Ó MURCHÚ', 'binti navalamar'. -name_regex = "^(?:Juan Ó Pérez|SEÁN Ó MURCHÚ|ina binti navalamar)$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #606: SALUTATIONS AND TITLES IN OTHER LANGUAGES. -# TITLES gains salutations and offices for languages it had none -# for, among them three that are also surnames written last -# wherever they are borne (graaf, greve, knight), shipped on the -# graf precedent (rules.md#H1). The one corpus mover is the -# H1 Accepted example for that cost: the surname-first form -# without a comma gives the surname-borne word to the title. -# -# Literal-anchored to the name: it was reported unexplained on the -# run before this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#606) salutations and titles in other languages" -# The given name becomes the title: 'Greve'. -name_regex = "^Greve Anna$" -fields = ["title", "given"] - -[[change]] -# rules.md#C1: "A credential opening the part makes it the postnominal -# part however it goes on" (#603, 2026-10-04): where the first suffix -# word after the comma starts S2's run, every word behind it reads as -# that run reads, so a full name before the comma keeps its positional -# read ('John Smith, PhD Jones') and a one-word name is the family -# ('Doe, PhD Jones'). The undeclared dash of the delimiter rows is one -# more word of the run; a title/suffix dual opens nothing. -issue = "fix(#603) a credential opening the part after the comma makes it the postnominal part" -name_regex = "^(?:Doe, PhD Jones|John Smith, Ph\\. D\\. Jones|John Smith, PhD Jones|Smith, RN - CRNA|Steven Hardman, RN - CRNA)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] - -[[change]] -# rules.md#C2: "a word of the title vocabulary there that is not also -# suffix vocabulary is a title" (#603, 2026-10-04): a part past the -# second comma reads its title words as titles, and a part of title and -# suffix words is recognized rather than flagged ('Eric H. Holder, Jr., -# Attorney General'). 1.4.0, like 2.0 through 2.3, read them as -# suffixes. -issue = "fix(#603) a title word in a part past the second comma is a title" -name_regex = "^(?:1 & 2, 3 4 5, Mr\\.|Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29|Eric H\\. Holder, Jr\\., Attorney General|Eric H\\. Holder, Jr\\., Secretary of State|John Smith, Jr\\., Lt\\.Gov\\.|Smith, John, Prof\\.)$" -fields = ["_ambiguities", "suffix", "title"] - -[[change]] -# rules.md#S2: "The run at a name's trailing slot is read ONCE, once -# the connectives have joined (P3) and before the particle chain (P2) -# and the bound given-name join (P5) are made" and "neither join -# reaches a word the run took" (#614, 2026-10-07), and rules.md#H5: "a -# particle chain (P2) stops in front of a trailing title rather than -# taking it into the family name". This baseline made the chain first -# and the title was a word of the family name. -issue = "fix(#614) a trailing title is read before the particle chain" -name_regex = "^John van der Berg Prof\\.$" -fields = ["family", "title"] - -[[change]] -# rules.md#N1: "a comma inside it kept as written — the comma divides -# nothing there" (#653, 2026-10-12): a comma inside a delimited -# clause is the clause's text, as 1.4.0 kept it. This baseline split it -# off as a separator, and the nickname rendered without it. -issue = "fix(#653) a delimited clause keeps its comma" -# The two rules.md#M3 comma examples move only this way here: this -# baseline predates M3, so their clauses were nicknames either way -# and the comma is the whole diff. -name_regex = "^(?:Jane \\(Janey, Jo\\) Smith|Maria Kowalska \\(z, domu Nowak\\)|Jane \\(née , Jones\\) Smith|Jane \\(née ,Jones\\) Smith)$" -fields = ["nickname"] - -[[change]] -# rules.md#H2: "An accented Latin, Greek or Cyrillic letter written as a -# base letter plus a combining mark (NFD) is the same letter as the -# composed one" (#652, 2026-10-12): the abbreviation shape ended its -# word at the base letter, so a decomposed `inž.` was no abbreviation -# and became the given name. The rules example is the one name here. -issue = "fix(#652) a decomposed accented abbreviation is an abbreviation" -name_regex = "^Dr\\. inz\u030c\\. Janez Novak$" -fields = ["title", "given", "middle"] diff --git a/tools/differential/expected_since_2.2.0.toml b/tools/differential/expected_since_2.2.0.toml deleted file mode 100644 index e0bbbc46..00000000 --- a/tools/differential/expected_since_2.2.0.toml +++ /dev/null @@ -1,2534 +0,0 @@ -# Ledger for baseline 2.2.0 -- what changes for a user upgrading from -# the previous minor. Same rule grammar as the other ledgers: `issue`, -# `name_regex` and `fields` are every one of them required. Neither -# narrowing key may stand on its own -- #451 banned a rule with -# `fields` and no regex, #456 banned the reverse -- so a rule here -# narrows by name AND by role, never by just one. -# -# Opened empty the day 2.2.0 shipped (AGENTS.md release step 8), and it -# is opened before it has anything to say because DEFAULT_BASELINE and -# the ledger are coupled: _allowlist_for treats a missing file as a -# hard error, on the reasoning that an absent ledger classifies nothing -# and would make every diff report as unexplained. So the baseline -# could not advance to 2.2.0 without this file. -# -# Do not copy rules across from expected_since_2.1.0.toml: that ledger -# classifies what 2.2 changed on top of 2.1, and a rule copied without -# checking either over-matches -- hiding a real 2.3 regression behind a -# 2.2-era label -- or never fires at all. Where a 2.3 change is visible -# from several baselines the rule may legitimately appear in more than -# one file, but each copy is checked against its own run rather than -# assumed. -# -# tests/v2/test_differential.py permits exactly one empty ledger, the -# one DEFAULT_BASELINE names -- which is this file. That latitude went -# unused: the first entry arrived with fix(#462), the 2.3 cycle's first -# change to move a corpus name at the default order. -# -# tests/v2/test_ledger_guards.py records this file in -# _SPAN_BEARING_RULES with an empty set and in _CORPUS_CLAIMS with the -# one rule below. The empty set still holds: fix(#462)'s regex is a -# letter shape, not a hand copy of _SCRIPT_RANGES. The first rule that -# does copy a script range has to be recorded in the former or the -# sweep fails. -# -# There is deliberately no `change = []` line. TOML forbids appending a -# [[change]] table to a statically defined array, so that line would -# block the first entry. - -# #436/#437: the suffix view's entry boundary became the comma the -# writer typed. rules.md#R1: "a run of post-nominals written with -# spaces renders with spaces, and one written with commas keeps -# them" -- true of the comma paths since 1.4.0 and of the NO-COMMA -# path only since 2.3, which is the whole of what these names move. -# Every one goes from a comma-joined render to a space-joined one; -# none goes the other way, and none has a comma in the original -# between the two words. Roles do not move: this rule declares -# `suffix` alone, so a name whose ROLES move here arrives -# UNEXPLAINED, as it should. -# -# FIRST in the file, as in the other three ledgers, and here that is -# uniformity rather than force: measured 2026-09-06, moving this -# rule to the end of THIS file raises no undeclared contest and the -# run still exits 0, because no rule above it declares a strict -# superset of `fields = ["suffix"]` and reaches one of these names. -# It sits first anyway so the four ledgers read the same way, and so -# that a later rule widening into `suffix` finds the narrow one -# already ahead of it rather than making this file the one exception -# (#382, mechanisms.md#LEDGER-RULE-SEPARATION). -# -# An anchored alternation of the names rather than a shape, because -# the shape is "two post-nominals with a space between them" and -# that reaches every listing form in the corpora. The members are a -# list of NAMES and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 10 with its digest, and -# _MUST_NOT_MATCH pins the five comma and delimiter forms it must -# never claim. -[[change]] -issue = "fix(#436/#437) a space-separated post-nominal run renders with spaces, not commas" -# 2026-09-19, #533: one name joins the alternation and the rule -# does not change. 'Jane Doe nee Smith PhD MA' is a two-word -# credential run this baseline renders 'PhD, MA' and the tree -# renders 'PhD MA' -- the whole of its diff at this baseline, and -# the same diff at 2f57ff21, measured. #533 moves nothing on it: -# the unambiguous PhD already ended the walk, so the clause gave -# the MA up before this change as it does after it. What grew is -# the CORPUS, and the run rendering is what the diff is about. -# 2026-09-20, #397: three names join the alternation and the rule -# does not change. 'Josep Lluis Carod i III' ('i, III' -> 'i III'), -# 'Josep Lluis Carod i V' ('i, V' -> 'i V') and 'Rovira, Josep Carod -# i Jr.' ('i, Jr.' -> 'i Jr.') are two-word generation runs this -# baseline renders with a comma and the tree renders with a space -- -# the whole of each one's diff here. #397 moves NOTHING on any of -# them: measured against the parent 46651750 all three parse -# byte-identically before and after this bundle, the link having a -# generation marker and not a name word to its right. What grew is -# the CORPUS -- the three arrived with #397's own case rows -- and -# the run rendering is what the diffs are about. -# 2026-09-20, #397 review: one more name joins the alternation and -# the rule does not change. 'Jane Doe nee Puig i III' is the same -# two-word generation run inside a MAIDEN clause's tail: this -# baseline renders 'i, III' and the tree renders 'i III', which is -# the whole of its diff here. The link fix moves nothing on it -- -# measured against the parent 46651750, the parse is byte-identical -# before and after (given 'Jane', family 'Doe', suffix 'i III', -# maiden 'Puig'), the link having a generation and not a name word -# to its right. What grew is the CORPUS. -# 2026-09-27, #544: Three names join the alternation, each diffing here on -# the rendering alone -- the roles are this baseline's, and #544 is what -# put them back ('Jane Doe Jr. Ma', 'John Smith PhD Ed Ma', 'John Smith PhD Ma'). The tree before #544 read the Title-case -# member behind the credential as a name word, the case lean stopping -# the peel at it; the company clause (rules.md#S2) reads it as the -# credential, so the run is v1's again and only R1's spacing differs. -name_regex = "^(?:JOHN DOE PHD MD|Jane Doe Jr\\. Ma|Jane Doe nee Puig i III|Jane Doe nee Smith PhD MA|John Doe MD PhD|John Smith MD PhD|John Smith Mc V|John Smith PhD Ed Ma|John Smith PhD Ma|Josep Lluis Carod i III|Josep Lluis Carod i V|Kenneth Clarke QC MP|Rovira, Josep Carod i Jr\\.|Smith, John PhD I\\.|The Rt Hon Kenneth Clarke QC MP, HMG|Washington Jr\\. MD, Franklin|abdul Smith Jr Ma|abdul Smith Jr V)$" -fields = ["suffix"] - -# The seven #449 rules go SECOND, not first: the rule above -# declares its own first-in-file position with a measurement, -# and `fields = ["suffix"]` and `fields = ["_ambiguities"]` -# are disjoint, so nothing orders the two against each other. -# They DO sit ahead of every rule below whose `fields` is a -# strict superset of `_ambiguities`, which is the narrow-first -# default (#382). -[[change]] -issue = "feat(#449) a lone name word reports given-or-family" -# rules.md#O5's convention, now reported. Twenty-two corpus names at -# landing (twenty-three since #544, 2026-09-27), and -# the alternation is a list of NAMES rather than a copy of any -# wordlist -- what selects them is the SHAPE (one name word, and no -# title, maiden name, comma, script order or vocabulary claim deciding -# it), so it is declared in _NOT_A_VOCABULARY_COPY. A suffix beside -# the word does NOT decide it: 'Smith Jr.' and "'Smitty' Jones Jr." -# are the peel taking the suffix and the convention placing what is -# left. `_ambiguities` alone: no role moves anywhere in this change. -# The five CJK-bearing names in the same population ride five rules of -# their own below, because a CJK member in an alternation is claimed -# by the honorific pin, which would demand this be a copy of -# GLUED_HONORIFICS. #322/#323 opened the one escape from that pin -- -# a member set declared in _NOT_A_VOCABULARY_COPY -- and nothing here -# declares one, so the five rules stand. -# 2026-09-27, #544: 'Wang M.Eng.' joins the alternation. Its roles are -# this baseline's, suffix 'M.Eng.', and the whole of its diff here is -# the report this rule describes, as for 'Jack M.A.'. The two readings -# of the suffix have different grounds: this baseline read it because -# 'meng' was an unambiguous acronym, which rules.md#S2 consumes "even -# when that leaves no family name at all"; #540 made 'meng' ambiguous -# in the 2.4 cycle, which read the family, and the tree reads the -# suffix again because S2 counts an ambiguous acronym "written with its -# periods, one after each letter or one after each of two or more -# letter chunks" unambiguously. Neither change is what the name diffs -# on against this baseline. -name_regex = "(?i)^(?:'Smitty' Jones Jr\\.|Andrew|Carod i|Dean of Chemistry|Donald mc|Duke of Edinburgh|Duke of Wellington|Garcia|Jack M\\.A\\.|John & Jane|John V|John of the Doe|Juan & Garcia|Juan and Garcia|Mohamad X|Smith|Smith Jr\\.|Wang M\\.Eng\\.|e and e|part1 of The part2 of the part3 and part4|part1 of and The part2 of the part3 And part4|test|سلمان،)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a wholly-katakana name keeps the declared order, so the convention decides it" -# 'マイケル'. rules.md#W4 gives katakana no order of its own -- a -# wholly-katakana name is usually a transcription and keeps whatever -# the caller declared -- so the script rule DECLINES and O5's -# convention is what is left. Literal-anchored on one corpus name, so -# _CORPUS_CLAIMS cannot see a widening and _MUST_NOT_MATCH carries the -# wall. -name_regex = "^マイケル$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) an interpunct transcription declines the script order, so the convention decides it" -# '王·Smith'. rules.md#T3: "The interpunct divides a name only between -# two characters of a classified East Asian script; anywhere else it is -# part of the word" -- 'Smith' is Latin, so the name is one word, and -# '王Smith' reads the same. rules.md#W4: "A name written wholly in one -# East Asian script, or in the kana-licensed Japanese repertoire, reads -# family-first whatever order the caller declared" -- a word mixing Han -# and Latin is neither, so no script order applies, name_order governs -# and the one name word is O5's. The issue names the interpunct, which -# is incidental to this name. Literal-anchored, one corpus name. -name_regex = "^王·Smith$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) an ideographic comma leaves one name word, and the convention decides it" -# '田中、太郎'. The comma is inside the token, so the text resolves to -# no single script and the order lookup declines; what is left is one -# name word and O5. A rules.md#W3 shape -- tolerated, read -# best-effort -- and the report says only what the parse actually -# called. Literal-anchored, one corpus name. -name_regex = "^田中、太郎$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a glued Japanese honorific leaves a Latin name word the script cannot order" -# 'Andersonさん'. rules.md#W2 peels the glued honorific to SUFFIX and -# leaves 'Anderson' -- Latin, so effective_script answers None and no -# script order is resolved at all. One name word, and O5's convention -# places it. Literal-anchored, one corpus name. -# -# `_ambiguities` alone here, where the 2.0.0 ledger's twin also carries -# `given` and `suffix`: #308's peel is already in THIS baseline, so -# the report is the whole diff. The gate's OVER-DECLARED check is what -# forces the two spellings apart -- a rule may not declare a role no -# diff it explains moves. -name_regex = "^Andersonさん$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a glued Korean honorific leaves a Latin name word the script cannot order" -# 'Anderson선생님'. The Japanese rule's argument, in Hangul: the -# honorific is peeled, the Latin stem resolves to no script, and the -# convention places the one name word left. Literal-anchored, one -# corpus name, and `_ambiguities` alone for the reason given above. -name_regex = "^Anderson선생님$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#449) a hangul word holding a stray voicing mark has no script, so the convention decides it" -# '김゙민준' (김 + U+3099), a tolerated case row #596 added (2026-10-03). -# A kana -# voicing mark after hangul voices no kana, so it keeps its hiragana -# block and the token is mixed hangul and hiragana: no script, no -# surname division, and O5's convention places the one name word left. -# The roles are this baseline's; the report is the whole diff. -# Literal-anchored, one corpus name, `_ambiguities` alone. -name_regex = "^김\\u3099민준$" -fields = ["_ambiguities"] - -# The two #491 rules sit beside #449's, same position rule: both -# declare `fields = ["_ambiguities"]`, so they are disjoint from the -# `suffix` rule that owns first place and ahead of every rule below -# whose fields are a strict superset of theirs (#382's narrow-first -# default). -[[change]] -issue = "feat(#491) an all-titles input reports title-or-name for the word the title peel left as the name" -# rules.md#H4. Six corpus names, one alternative each -- a list of -# NAMES, not a copy of TITLES: what selects them is the SHAPE (the -# title peel leaves exactly one name word, and that word is itself -# title vocabulary), and a member copying the wordlist would reach -# 'Dr. Smith', 'Rabbi Cohen' and every other title-plus-surname name, -# none of which moves. Declared in _NOT_A_VOCABULARY_COPY for that -# reason. `_ambiguities` alone: the reading each of these gets is -# unchanged, only the silence about it. -name_regex = "(?i)^(?:Dr\\. King|His Holiness|His Holiness the Dalai Lama|Lord Chancellor|The Right Hon\\. the President of the Queen's Bench Division|The Rt Hon)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#491) an all-suffix input reports suffix-or-name for the word it made the name" -# rules.md#H4's suffix half, emitted at assign's bare-suffix -# carve-out. Two corpus names. A list of names, not a copy of -# SUFFIX_WORDS or SUFFIX_ACRONYMS: a member copying either would reach -# 'John Smith MD', 'Smith Jr.' and the rest of the corpus's credential -# names, none of which moves -- what selects these two is that nothing -# was left to be the name. -name_regex = "(?i)^(?:QC MP|Rinpoche)$" -fields = ["_ambiguities"] - -[[change]] -issue = "feat(#491) a joined unit carrying title vocabulary reports title-or-name" -# H4's join clause -- the one name unit is a join (P3) and one of the -# words it joins is title vocabulary, so the doubt is whether that -# word is a title rather than which field the unit takes. These two -# arrive because the #518 review round moved the clause out from under -# O5's field-deciding guards: both peel a title in front ('Attorney', -# 'Deputy') and leave the join standing as the last piece, which a -# title cannot be, so H3 never chains it. A list of two names, not a -# copy of TITLES: a member copying the wordlist would reach 'Prince of -# Wales' -- a join LED by a title word, which H3 does chain -- and -# every title-plus-surname name besides. `_ambiguities` alone: the -# reading is unchanged, only the silence about it. -name_regex = "(?i)^(?:Attorney General of Minnesota|Deputy Secretary of State)$" -fields = ["_ambiguities"] - -# The four CJK names of #436/#437's class, one rule each. Not one -# alternation: an UNDECLARED alternation holding a script-classified -# member is claimed by the honorific pin in -# tests/v2/test_ledger_guards.py, which requires its members to BE -# the config's CJK honorific entries -- and until #322/#323 opened -# the _NOT_A_VOCABULARY_COPY escape that was every such alternation. -# These are names, so each stands alone, literal-anchored, -# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) -# with _MUST_NOT_MATCH probes for the boundary each one has. -# rules.md#R1: "a run of post-nominals written with spaces renders -# with spaces, and one written with commas keeps them" -- the peel -# puts the honorific in `suffix` (rules.md#W2) and the run it lands -# in is spaced, so the render spaces it. At 1.4.0 and 2.0.0 these -# names already diff on `suffix` under the honorific rules and are -# claimed there; only at 2.1.0 and 2.2.0 is the render the whole of -# what moved. -[[change]] -issue = "fix(#436/#437) the glued honorific and the roman numeral are one post-nominal run" -name_regex = "^田中さん II$" -fields = ["suffix"] - -[[change]] -issue = "fix(#436/#437) the glued honorific and the polite address are one post-nominal run" -name_regex = "^田中さん 様\\.$" -fields = ["suffix"] - -[[change]] -issue = "fix(#436/#437) the spaced doctorate and the honorific after it are one post-nominal run" -name_regex = "^김민준 박사 씨$" -fields = ["suffix"] - -[[change]] -issue = "fix(#436/#437) the glued honorific and the generational suffix are one post-nominal run" -name_regex = "^김민준씨 Jr\\.$" -fields = ["suffix"] - -# #346: swami, guru, baba and lama moved from the TITLES-only block -# into GIVEN_NAME_TITLES on 2026-09-06. rules.md#H1's Accepted clause -# -- "a given-name title plus one name word leaves the family empty" -# -- now reaches them, so the one name word behind the title is the -# GIVEN name and the family is empty, where every baseline read it as -# the family. Both roles move together and `fields` declares both: a -# name whose TITLE moved would arrive UNEXPLAINED, as it should. -# -# Four corpus names, all from the radar-tier corpus_issues.jsonl and -# all moving the same way. The two- and three-word spellings are the -# boundary and are _MUST_NOT_MATCH probes: 'Swami Vivekananda -# Saraswati' and 'Guru Gobind Singh' keep their family names, because -# H1's fold requires exactly one name word, and a rule reaching them -# would stand ready to explain the very regression that boundary -# exists to catch. -# -# An anchored alternation of the four NAMES rather than a shape, -# under the ledger discipline expected_since_1.4.0.toml's header -# states for all four files: keep each entry's `name_regex` as tight -# as the diff allows. Four names moved, so a four-name list is the -# tightest expression of that diff; anything shape-shaped is wider -# than what was measured. It copies no wordlist either, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 4 with its digest. -[[change]] -issue = "fix(#346) a renunciate title and one name word leave the name a given name" -name_regex = "^(?:Baba Ramdev|Guru Nanak|Lama Zopa|Swami Vivekananda)$" -fields = ["family", "given"] - -# #519: prince and princess moved from the TITLES-only block into -# GIVEN_NAME_TITLES on 2026-09-10. rules.md#H1's fold now reaches -# them, so the one name word behind the title is the GIVEN name and -# the family is empty, where every baseline read it as the family. -# Both roles move together and `fields` declares both. -# -# Three corpus names: 'Prince Charles' and 'Prince Fielder' from the -# radar-tier corpus_issues.jsonl, and the contract-tier rules.md -# example 'Her Royal Highness Princess Anne' (corpus_rules.jsonl), all -# moving the same way. 'Prince Fielder' moves too and is wrong either -# way -- Prince is his given name, which is #348's collision and not -# this rule's subject. The boundary is pinned by _MUST_NOT_MATCH -# probes: two name words behind the title ('Prince Harry Windsor'), -# the chained run 'Prince of Wales Jr' (addresses by `wales`), the -# trailing 'Robert Prince', 'Lord Byron' (`lord` stays out) and the -# bare 'Prince'. -# -# An anchored alternation of the NAMES rather than a shape, under -# the ledger discipline expected_since_1.4.0.toml's header states: -# keep each entry's `name_regex` as tight as the diff allows. It -# copies no wordlist, which _NOT_A_VOCABULARY_COPY records; -# _CORPUS_CLAIMS pins the reach with its digest. -[[change]] -issue = "fix(#519) prince and princess join the given-name titles" -name_regex = "^(?:Her Royal Highness Princess Anne|Prince Charles|Prince Fielder)$" -fields = ["family", "given"] - -[[change]] -issue = "fix(#342) rai and cha left the credential acronyms, so a trailing Rai or CHA is a name word" -# 'rai' and 'cha' left SUFFIX_ACRONYMS on 2026-09-07 -# (nameparser/config/suffixes.py). Both arrived in the 2019-12-11 -# bulk wikipedia post-nominal import (af5bdab), neither was reviewed -# against a surname collision, and decisions.md#suffix-acronym-collisions -# states the criterion that removed them rather than marking them -# ambiguous: marking is for a short credential in wide use that is -# also a name, removal for a longer one that is tenuous and collides. -# -# Five corpus names, all from the radar-tier corpus_issues.jsonl, in -# THREE diff shapes with one cause -- the word stopped being -# credential vocabulary, so the positional read takes it: -# 'Aishwarya Rai' {family, suffix} suffix -> family -# (2.x only -- parity at 1.4.0) -# 'Lala Lajpat Rai' {middle, family, suffix} the family shifts right -# 'John Smith RAI' {middle, family, suffix} same -# 'John Smith, RAI' {given, family, suffix} an ordinary family comma (C1) -# 'Ahmad Jayadi, CHA' {given, family, suffix} same -# 2026-10-01, #564: the comma pair reads suffix again by its capitals -# (an unlisted all-caps word after a comma behind two name words), so -# it no longer moves {given, family, suffix} here; at 1.4.0 it reaches -# parity, at 2.x only the comma's report differs (the fix(#564) rule). -# `given` therefore leaves `fields`, which the OVER-DECLARED check -# requires: no name here moves it any more. -# The comma pair is why `fields` declares four roles rather than the -# two the pre-2.3 fix(#342) rule declared: with no credential after -# the comma, C1 reads the pre-comma run as the family and the -# post-comma word as the GIVEN name, so `given` moves where it did -# not before. The declaration is the exact union of the five, which -# is what the OVER-DECLARED check recomputes at the end of every run. -# -# 'Aishwarya Rai' is in this rule at 2.0.0/2.1.0/2.2.0 and NOT -# explained by it at 1.4.0, where the name now reaches PARITY: 1.4.0 -# read family 'Rai' too, which is what the deleted -# `fix(#342) NOT WANTED` rule existed to classify. The regex is the -# same in all four files anyway -- reach is not explanation, and a -# per-ledger regex would say a name left the corpus. -# -# An anchored alternation of the five NAMES rather than a shape. The -# shape is "a trailing word that used to be an acronym entry", which -# would be a copy of SUFFIX_ACRONYMS and would stand ready to explain -# every future trailing-acronym regression. The members are a list of -# names and copy no wordlist, which is what _NOT_A_VOCABULARY_COPY in -# tests/v2/test_ledger_guards.py records; _CORPUS_CLAIMS pins the -# reach at 5 with its digest, and _MUST_NOT_MATCH carries the -# boundaries the paragraph above argues. -name_regex = "^(?:Ahmad Jayadi, CHA|Aishwarya Rai|John Smith RAI|John Smith, RAI|Lala Lajpat Rai)$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#462) the facade keeps an initial-shaped conjunction letter" -# 'Scott E. Werner', 'Amy E Maid', 'Jose E. Maria Santos': -# HumanName.initials() dropped a dotted or bare-capital E/Y in the -# middle or family group because the 2.0 facade tested conjunction -# membership alone, where 1.4.0's is_conjunction also required "NOT -# is_an_initial". 'S. W.' -> 'S. E. W.', restoring 1.4.0; the core's -# initials() was already right. Fields identical -- the parse never -# read E as a conjunction, only the facade's render did -- so this is -# `_initials` alone (#484). The shape: a bare capital E or Y, or a -# dotted E./Y. of either case, standing as its own word. Lowercase -# bare e/y is the connective and does not move. The regex reaches 22 -# corpus names -- 18 until #383/#479's case rows and rules.md#P3 -# examples arrived on 2026-09-13 -- of which 14 diffed when the rule -# was written (2026-09-02); _CORPUS_CLAIMS in -# tests/v2/test_ledger_guards.py pins that reach as _Claim(22, ...) -# and fails if it moves, so the REACH digit is checked rather than -# remembered. The 14 is not: it is a -# dated snapshot of what diffed that day and nothing re-runs it. -# The names it reaches and does not move are -# what the gap IS, and the invariant behind it is a GROUP rather than -# a count: -# they carry the E/Y in the GIVEN group, which has always initialed -# every word it holds whatever the vocabulary says, so there was -# nothing for the fix to restore. -# -# 2026-09-13, #383/#479 then #528: 'JUAN Y GARCIA' diffed here for -# TWO reasons at once that morning -- the facade's, which is this -# rule's own, and the core's, which was new -- and #528 closed the -# facade's half the same day, the facade now reading the parse's tags -# and agreeing with this baseline's 'J. G.' again. The core's half -# remains and this rule still explains it, one `_initials` move. -# expected_since_2.0.0.toml's copy of this rule carries the account; -# it is the same at all three baselines. -# -# 2026-09-20, #461: THE SURFACE FLIPPED AND THE RULE DID NOT. At -# this baseline the wheel gives facade 'J. G.' and core 'J. Y. G.' -# for 'JUAN Y GARCIA' (measured 2026-09-20 from the 2.2.0 wheel). -# Before this change only the CORE moved, 'J. Y. G.' -> 'J. G.', a -# connective contributing no initial; after it only the FACADE moves, -# 'J. G.' -> 'J. Y. G.', and the core agrees with the baseline word -# for word -- the 'Y' holds its part alone, so it initials again on -# both surfaces and the tree gives 'J. Y. G.' from each. `_initials` -# is one pseudo-field, so this rule still explains the name; what -# changed is WHICH HALF of the disagreement it is explaining, which -# is why the paragraph above reads backwards without this one. -# rules.md#R3, decisions.md#R3. -# -# The name stops diffing at 1.4.0 and at 2.3.0 under the same move, -# and the rule written for it in each of those ledgers goes: the -# 2.3.0 `fix(#383/#479) a bare capital connective in an all-upper -# name stops initialing` is deleted outright, and 1.4.0's -# `fix(#528) ...` keeps its other three names and drops this one from -# its alternation. -name_regex = "(?:^|[\\s,])(?:[EY]|[EeYy]\\.)(?=[\\s,]|$)" -fields = ["_initials"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# THE 2.3 TITLE-RUN BUNDLE: #489 (a title run addresses by its last -# title), #316 (a trailing period-marked title word reads as a -# title), the leading peel's name-word floor, and the `esq` acronym -# drop that shipped beside them. Five rules, four of them one per -# ARGUMENT rather -# than one per name, plus a fifth that is #316's argument again on -# the one name a script-classified alternation cannot hold. Over -# twenty-four corpus names. -# -# LAST in the file, and that is narrow-first rather than a -# preference: every rule already here that REACHES one of the -# twenty-four declares `fields` that are a strict subset of the bundle -# rule claiming that name, and a wide rule sitting AHEAD of a -# narrower one it shares a corpus name with is an order-decided -# contest the run refuses (#382). Measured 2026-09-08, all four -# ledgers: at 1.4.0 fix(#296)'s trailing `dr` reaches 'John Smith -# Dr.' and 'John Smith Prof. Dr.', the two comma routings reach -# 'Smith, John Prof.', and the two-token `jr` rule reaches 'Dr Jr' -# and 'Sir Jr'; at 2.0.0 and 2.1.0 only fix(#296) reaches any of -# them; at 2.2.0 none does. Not one of those rules ADMITS the diff -# it reaches -- every shape is outside its field list, measured -- -# so nothing below takes a name off a rule that was explaining it, -# and no [[change.precedes_narrower]] block is needed anywhere. -# -# `_initials` appears in none of the four field lists although the -# initials of several of the twenty-four do move. The derived view -# enters a diff only where every ROLE and every ambiguity kind -# agrees (compare.py's _RULE_FIELDS, main()'s roles-identical -# guard), and all twenty-four move roles -- so no run can produce a -# shape here carrying it, and validate_rules refuses it beside -# another field in any case. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#489) a title run addresses by its last title" -# 'Her Majesty Queen Elizabeth' and 'Dr. Sir John': the -# given_name_titles lookup was keyed on the WHOLE run, and 'her -# majesty queen' is in no vocabulary and never could be, so the one -# name word behind the run stayed the family. Both sites that ask -- -# rules.md#H1's retag and rules.md#P5's licence -- now test the whole -# run's key OR the last word's, and 'queen' and 'sir' are -# GIVEN_NAME_TITLES entries. The name word behind the run becomes the -# GIVEN name and the family empties, which is H1's Accepted outcome. -# Both roles move together and `fields` declares both. `title` is -# deliberately OUT: the run itself is unchanged, only what it -# addresses as, so a name whose TITLE moved arrives UNEXPLAINED, as -# it should. -# -# 'Reverend Mother Teresa', 'Mr Sir John', 'Xyz. Sir John' and 'Sir -# Sheikh abdul rahman' move the same way and are NOT members: none of -# them is in any corpus file, so a member for one would be a rule -# reaching nothing. -# -# An anchored alternation of the two NAMES rather than a shape. What -# selects them is a SHAPE -- a multi-word title run whose LAST word -# is a given-name title -- and a member copying GIVEN_NAME_TITLES -# would reach 'Sir John Smith' and 'Dr. Smith', which do not move. -# The members are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY in tests/v2/test_ledger_guards.py records; -# _CORPUS_CLAIMS pins the reach at 2 with its digest, and -# _MUST_NOT_MATCH carries three boundaries: a ONE-word run ('Sir John -# Smith'), and two runs whose last word is title vocabulary that is -# not given-name vocabulary ('His Excellency Lord Duncan', 'Her Royal -# Highness Princess Anne' -- the open vocabulary question, #519). One -# set, identical in all four ledgers. -name_regex = "^(?:Dr\\. Sir John|Her Majesty Queen Elizabeth)$" -fields = ["family", "given"] - -[[change]] -issue = "change(suffix-acronym-collisions) esq leaves the acronym set" -# 'John Smith E.S.Q.': 'esq' left SUFFIX_ACRONYMS, where the 2019 bulk -# post-nominal import put it (af5bdab, #93) -- esquire is a -# contraction, not an initialism -- so the multi-dot spelling stops -# being credential vocabulary and falls to the positional read: given -# 'John', middle 'Smith', family 'E.S.Q.', where every release since -# 1.4.0 read family 'Smith', suffix 'E.S.Q.'. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: this is a deliberate parity break at all four baselines, and -# the entry that argues it is decisions.md#suffix-acronym-collisions -# -- there is no issue number to cite, and the release-log bullet -# carries none either. It is not NOT WANTED: that spelling is for a -# reading nobody wants, and this reading is the one the decision -# chose. -# -# 2026-09-18 (#516): this rule stopped explaining anything, and the -# reason is not a revert. The by-shape credential class reads a word -# of two or more period-separated chunks that no vocabulary claims by -# POSITION (rules.md#S3), so 'John Smith E.S.Q.' is a credential again -# with no wordlist entry behind it at all -- the reading this baseline -# has, recovered by a different route than the acronym entry gave it. -# The vocabulary decision the `change` tag records still stands and is -# still the history of that entry; what is gone is its ROLE diff. The -# name still diffs here, on the `suffix-or-name` report the positional -# reading brings with it -- `_ambiguities` is a compared surface from -# 2.0 on -- and fix(#516) classifies that, which is why this rule is -# dormant rather than explaining anything: `dormant` means "explained -# no diff", not "the name stopped moving". Only the 1.4.0 copy can say -# the diff is gone outright, that baseline comparing no ambiguities. -# Declared -# `dormant` rather than deleted, so the harness says so out loud if a -# future change takes the positional reading away again. -# -# Literal, no alternation, and exactly as wide as the diff: 'esq' -# keeps its SUFFIX_WORDS membership, so the acronym entry's only -# unique coverage was this one spelling and this one corpus name. -# _MUST_NOT_MATCH carries the three spellings that did NOT move -- -# 'John Smith Esq' (the word list still reads it), 'Esq. Smith' -# (rules.md#H2's leading inference) and 'Smith, Esq.' (the lone -# post-comma credential). -dormant = "'John Smith E.S.Q.' no longer moves any role this rule declares: #516's by-shape credential class reads the multi-dot spelling as a credential by position, with no wordlist entry, which is the reading this baseline already had. Its remaining diff here is the `suffix-or-name` report, which fix(#516) classifies. Kept because the vocabulary change it records is real history and the rule wakes if that reading is lost again" -name_regex = "^John Smith E\\.S\\.Q\\.$" -fields = ["family", "middle", "suffix"] - -[[change]] -issue = "fix(#489) the title peel leaves a name word a suffix cannot be" -# 'Dr King Jr', 'Dr. King MD', 'Dr Jr' and 'Sir Jr': the leading title -# peel's only floor was "leave one piece", so it peeled 'Dr King' -# whole and left the post-nominal to be the name -- title 'Dr King', -# family 'Jr', no suffix at all. leading_titles now also refuses to -# leave a word that is nothing but suffix vocabulary standing as the -# name and gives the run's last word back (rules.md#H3, -# decisions.md#H3), so 'Dr King Jr' reads title 'Dr', family 'King', -# suffix 'Jr' -- what the comma spelling 'King, Dr Jr' always read. -# -# FOUR roles, and no single name moves all four. The two three-word -# names move `title`, `family` and `suffix`; 'Dr Jr' and 'Sir Jr' -# move `given` as well, or instead of `family`, the word given back -# standing alone with the credential peeled off it. At the 2.x -# baselines `_ambiguities` joins the list -- with the title given -# back there is no title left to make the reading H1's, so all four -# report title-or-name through rules.md#H4 -- and it is absent at -# 1.4.0, which has no ambiguity surface to compare (compare.py's -# _RULE_FIELDS). -# -# An anchored alternation of the four NAMES rather than a shape. What -# selects them is a SHAPE -- a title run with nothing but post-nominal -# pieces behind it, whose own last word is not one of those -- and a -# member copying TITLES or SUFFIX_WORDS would reach 'MD DDS', 'Jr. -# Ph. D.' and 'Marquess of Bath', every one of which the floor -# declines and every one of which is a _MUST_NOT_MATCH probe, with -# 'Dr Smith Jr' beside them for the ordinary titled name the floor -# never sees. The members are a list of names and copy no wordlist, -# which is what _NOT_A_VOCABULARY_COPY records. THREE of the four are -# corpus names, not four: 'Sir Jr' left corpus_rules.jsonl on -# 2026-09-09, when the rules.md example pinning it was dropped as a -# duplicate of 'Dr Jr' (decisions.md#H5), and it stays in the -# alternation because a name_regex names INPUTS rather than corpus -# rows. _CORPUS_CLAIMS pins the reach at 3 with its digest. One set, -# identical in all four ledgers; only the `fields` differ, as the -# paragraph above says. -name_regex = "^(?:Dr Jr|Dr King Jr|Dr\\. King MD|Sir Jr)$" -fields = ["_ambiguities", "family", "given", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing period-marked title word reads as a title" -# 'John Smith Prof.' and thirteen more: a trailing run of period-marked -# LISTED title words is peeled as a title, so the surname behind it -# stops being lost -- title 'Prof.', given 'John', family 'Smith', -# where every baseline read middle 'Smith', family 'Prof.'. A RUN, -# mirroring the leading peel: 'John Smith Prof. Dr.' gives title -# 'Prof. Dr.' and 'Dr. John Smith Prof.' gives 'Dr. Prof.' in input -# order. The family-comma segment takes the same walk, so 'Smith, -# John Prof.' agrees with the bare spelling at last. And the trailing -# title is TRANSPARENT to the post-nominal peel that follows it: -# 'John Smith Jr. Prof.' keeps suffix 'Jr.' rather than promoting the -# generational suffix to the family name, and 'John Prof. MA' reads -# family 'MA' by rules.md#S2's reserve. -# -# 2026-09-18 (#289): the last clause is retracted. S2's reserve is no -# longer what decides 'John Prof. MA': written capitals in a -# mixed-case name lean credential with nothing to spare, so the name -# reads title 'Prof.', family 'John', suffix 'MA' -- the transparency -# this rule is about is unchanged and now shows a DIFFERENT reading -# through it, which is the point of transparency. This rule keeps the -# name at every baseline where it already had it; the fix(#289) rule -# at the bottom of each file carries the names whose reading moved and -# that nothing else claims. -# -# Five roles, the union of what the sixteen move; no name moves all -# five (four is the most, 'John Smith Jr. Prof.'), and two move only -# two at every baseline -- 'Smith, John Prof.' {title, middle} and -# 'Smith Sir.' {title, family} -- with 'Mary Jane King.' moving three. -# A third joins them at 1.4.0, 2.0.0 and 2.1.0, 'John Smith Dr.' -# {title, suffix}, which moves {title, middle, family} at 2.2.0 -# instead: 'dr' was suffix vocabulary until 2.2, so the role it -# vacates differs by baseline (measured 2026-09-09 against all four -# wheels). `_ambiguities` is not among the five, and it is not -# because no name reports one: 'John Prof. MA' reports -# `suffix-or-name`, which is S2's bare-acronym reserve and not this -# rule. None of the sixteen reports `title-or-name` -- the walk -# leaves a title standing, so H4's title half never fires -- and the -# derived view cannot enter a diff here in any case, every one of the -# sixteen moving roles (compare.py's _RULE_FIELDS). -# -# TWO of the sixteen end in ' Dr.' and are also reached by -# fix(#296)'s trailing-`dr` rule above -- 'John Smith Dr.', which -# that rule used to explain, and 'John Smith Prof. Dr.'. That rule -# declares {middle, family, suffix} and neither diff fits it now, so -# both fall through to here; its comment records the handover, and -# narrow-first placement is what keeps the pair out of contest. The -# BARE trailing spelling stays with fix(#296): 'Smith Dr' and the 'dr -# ... dr' names wear no abbreviation period and the walk never sees -# them (#316(b), left open). -# -# The last two members arrived with the 2026-09-09 review of the -# docs commit, which added two rules.md examples: 'Mary Jane King.', -# the H5 Accepted clause's collision example (title 'King.', given -# 'Mary', family 'Jane', where every baseline read family 'King.' -# behind middle 'Jane'), and 'Smith Sir.', which is an H1 example. -# 'Smith Sir.' is HERE and not on the fix(#489) run rule above -# because the DIFF decides: it moves {title, family}, the walk -# taking 'Sir.' out of the name, while its given/family reading is -# what every baseline already gave ('Smith' stays the given name on -# both sides). The #489 rule declares {family, given} and does not -# reach a title role at all, so it could not claim the diff even -# placed ahead of this one. -# -# An anchored alternation of the sixteen NAMES rather than a shape. -# What selects them is a SHAPE -- period-marked, listed, trailing -- -# and a member copying TITLES would reach the BARE 'Mary Jane King' -# and 'John Smith Sir', which do not move; the period-marked -# 'Mary Jane King.' is a member precisely because it does, which is -# the line the period draws and the whole of what it draws. A member -# spelled as the shape (a trailing dotted word) would reach 'John -# Smith Xyz.', which is the boundary the leading slot's H2 inference -# is deliberately NOT given here. Those three and 'Smith, Prof.' -- -# the comma shape whose segment holds no name word and which reads -# by a different gate -- are the _MUST_NOT_MATCH probes. The members -# are a list of names and copy no wordlist, which is what -# _NOT_A_VOCABULARY_COPY records; _CORPUS_CLAIMS pins the reach at -# 14 with its digest. -# -# 'Andrew Perkins (Mgr.)' is spelled with \\x28 and \\x29 rather than -# the \\( and \\) the paren-bearing rules above use. Escaped or not, a -# literal parenthesis inside an alternation body hides the WHOLE -# group from test_ledger_guards._alternations -- its member pattern -# excludes '(' and ')' -- and a sixteen-member alternation should not -# be invisible to the discovery pass that demands every alternation -# declare what it copies. The two spellings match the same string. -# One set, identical in all four ledgers. -# -# 2026-09-18 (#289/#516): a SEVENTEENTH member, 'JOHN PROF. MA'. It -# arrived in the rules corpus as rules.md#H5's one-case witness that -# the suffix reading is taken over what STANDS once the title chain -# has run, not over what stood -- the reading the mixed-case spelling -# stopped demonstrating when the caps lean started deciding it. The -# name is wholly one case, so no lean fires and nothing about it is -# this arc's: what moves is exactly this rule's trailing-title chain -# ({title, family, suffix}, inside the five roles already declared), -# and it stops diffing at 2.3.0 for the same reason its sixteen -# siblings do. -name_regex = "^(?:Andrew Perkins \\x28Mgr\\.\\x29|Dr\\. John Smith Prof\\.|Dr\\. Smith Sir\\.|JOHN PROF\\. MA|John Prof\\. MA|John Smith Dr\\.|John Smith Jr\\. Prof\\.|John Smith Mr\\.|John Smith Prof\\.|John Smith Prof\\. Dr\\.|John Smith Prof\\. Jr\\.|John Smith Rev\\.|Mary Jane King\\.|Sir John Prof\\.|Smith Prof\\.|Smith Sir\\.|Smith, John Prof\\.)$" -fields = ["family", "given", "middle", "suffix", "title"] - -[[change]] -issue = "fix(#316) a trailing Latin title on a native-script name is a title" -# '毛 泽东 Dr.': the rule above's walk, on the one corpus name it -# reaches that is not Latin-only. The walk runs BEFORE the positional -# read, so the pieces the script test sees are all Han and the -# family-first order stands -- this is '毛 泽东' plus a title, and -# it reads title 'Dr.', given '泽东', family '毛'. A Latin title at -# the back of a wholly-Han name is the one piece that would make the -# piece set look mixed-script, which is what the ordering of the two -# buys. -# -# ITS OWN rule rather than a seventeenth alternative above, and not by -# preference: an UNDECLARED alternation holding a script-classified -# member is claimed by the honorific pin in -# tests/v2/test_ledger_guards.py, which requires such an -# alternation's members to BE the config's CJK honorific entries -- -# and until #322/#323 taught that pin to ask whether the members FOLD -# into that vocabulary, that was every such alternation. So this -# stands alone, literal-anchored, -# reaching exactly one corpus name (_CORPUS_CLAIMS carries the 1) -# with _MUST_NOT_MATCH probes for the boundary -- the same shape the -# four #436/#437 CJK rules above take, and for the same reason. -# -# Classified 2026-09-09, in the review round that fixed which title -# run H1 addresses by; the reading itself is untouched by that fix. -# The name is RADAR tier (corpus_cjk_tolerated.jsonl, tolerated since -# the 2026-09-01 CJK demotion) and it had been printing UNCLASSIFIED -# at all four gates since the trailing walk landed. Radar never -# blocks, so nothing failed -- and this bundle's convention is that -# every gate line reads `radar unclassified: 0`, which is the whole -# reason the row is here rather than left to print. -# -# FOUR roles here, and NOT the same four at every baseline, which is -# why this is the one bundle rule whose `fields` differ per ledger. -# 2.2.0 took `dr` out of the post-nominal vocabulary, so 'Dr.' became -# a name word: first '毛', middle '泽东', last 'Dr.', the mixed-script -# piece set declining the Han order it had had since 2.1.0. `title`, -# `given`, `middle` and `family` move; `suffix` is empty on both sides. -# -# Literal and anchored, so the reach cannot widen into the other -# forty-six tolerated CJK names (twenty-nine until this bundle's -# seventeen rows joined the file). _MUST_NOT_MATCH carries three of -# them as probes: '王先生, V.' (a Latin post-nominal behind a -# comma, which the trailing walk never sees), '田中さん, Dr.' (the -# comma spelling of this very shape, routed by the segment gate) and -# '田中さん II' (a spaced Latin post-nominal that is not title -# vocabulary). -name_regex = "^毛 泽东 Dr\\.$" -fields = ["family", "given", "middle", "title"] - -# #322/#323, LAST in the file. The parser knew ONE full stop -- -# the ASCII period -- and knew it at ONE site, the lexicon's edge -# strip. It now reads four: the ASCII period, the fullwidth '.', -# the ideographic '。' and its halfwidth '。'. A full stop glued -# AFTER a script-written word is punctuation and not part of the -# word -- invisible to the script reading and to the vocabulary, -# and left in the text on the word it arrived with -- because no -# East Asian script writes an initial or an abbreviation with a -# period. A leading stop folds away for the vocabulary lookup, -# which reads both edges; the classification fold that feeds the -# surname site reads the TRAILING edge only, so a word wearing a -# leading stop is given no script and never becomes a surname -# site: '.김민준' stays one whole word, given. The honorific peel -# is not gated by that fold -- the tail alone licenses it -- so a -# leading stop does not stop it: '.김민준씨' peels to '.김민준' -# and '씨' (rules.md's W Background, decisions.md#cjk-full-stops). -# -# SEVENTEEN members, the same set and the same regex as -# expected_since_2.1.0.toml: nothing between 2.1.0 and 2.2.0 touched -# any of these readings, so the two ledgers report the same diffs -- -# twelve CJK, the katakana 'マイケル.' (a #323 mover added 2026-09-10 -# in the whole-branch review), the three stop-bearing FAMILY_COMMA -# rows and the bracketed '(김민준.) John Smith', all four from the -# review round after it. The 1.4.0 and 2.0.0 ledgers carry eleven, -# those baselines predating the hangul segmentation that puts -# '김. 민준', '양 지훈.' and '양. 지훈' in this bundle's hands rather -# than the native-script rule's, and answering the three comma rows -# with no diff (2.0.0) or with their own broad comma rules -# (1.4.0). -# -# No earlier rule in this file reaches any of the seventeen, so LAST -# is uniformity with the other three ledgers rather than a -# narrow-first ordering (#382) against anything: order_contests -# reports no pair here in either arrangement. -# -# An anchored alternation of the NAMES rather than a shape, and the -# two criteria a shape could be written on both reach names this -# rule must not claim. "An edge full stop on a word carrying a -# classified character" reaches 22 corpus names, measured -# 2026-09-10: the bundle's seventeen movers and FIVE that do not -# move ('田中さん 様.', '田中さん, 様.', '김민준 씨.', '김민준 양.', -# '김민준, 씨.'). Measured with the TOKENIZER and not a whitespace -# split -- two of the movers wear their stop before a comma -# ('김민준씨.,', '田中さん.,'), which a whitespace split does not -# see -- plus the bracketed '(김민준.) John Smith' by hand, its stop -# sitting inside the clause until rules.md#S1 unwraps it; an 18 -# recorded earlier the same day came from the split, over a corpus -# four names smaller. A character class of the four stops would be a hand -# copy of nameparser._lexicon.FULL_STOPS besides, and would reach -# the looser population of "an ASCII period anywhere in a CJK- -# bearing name" -- 20 distinct names on 21 corpus rows before this -# bundle ('Dr 김민준씨, Jr.' being the name on two of them), wider -# because it sweeps in periods sitting on LATIN tokens inside a CJK -# name ('毛 泽东 Dr.', '田中さん, V.'). None of the five or the -# twenty is moved BY THIS BUNDLE, so a shape spelling would -# reach names whose diffs belong to other rules (most of them -# do diff against the older baselines -- 19 of the twenty at -# 1.4.0) or to no rule at all; decisions.md#cjk-full-stops -# carries both counts and the recompute. The members copy no wordlist, -# which the honorific pin in tests/v2/test_ledger_guards.py DERIVES -# rather than taking on trust -- no member of this alternation folds -# into the CJK honorific vocabulary, bare or wearing a full stop; -# _CORPUS_CLAIMS pins the reach at 17 with its digest, and -# _MUST_NOT_MATCH pins the four readings the bundle left alone. -# -# Script-classified members inside an ALTERNATION are new here -- -# every other CJK rule in the four ledgers is a lone literal, and -# the only classified alternations are the honorific rules' hand -# copies of GLUED_HONORIFICS and SUFFIX_WORDS, which the honorific -# pin owns. That pin passes over this one on a MEASUREMENT rather -# than a declaration: no member of it folds into the honorific -# vocabulary, bare or wearing a full stop. -# The _SPAN_BEARING_RULES note in this file's header still holds -# either way: the members are literal names, not a hand copy of -# _SCRIPT_RANGES, so the empty set stands. -[[change]] -issue = "fix(#322/#323) a full stop on a CJK token is read as punctuation and stays on its token" -name_regex = "^(?:\\x28김민준\\.\\x29 John Smith|マイケル\\.|田中\\.|田中\\. 太郎|田中さん\\.|田中さん\\., V\\.|김\\. 민준|김민준 씨。|김민준 씨.|김민준 씨。|김민준\\.|김민준\\. 지훈|김민준씨\\.|김민준씨\\., J\\.씨|양 지훈\\.|양\\. 지훈|이, J\\.씨\\.)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix", "title"] - -# --------------------------------------------------------------- -# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. -# Two rules where 1.4.0 needs one, and the split is the pseudo-field -# rule (#484) rather than two behaviors: from 2.0 on the v2 surface -# is compared, so the new CONJUNCTION_OR_INITIAL report enters the -# diff -- on one of the two names whose roles move ('JUAN GARCIA Y -# LOPEZ' reports nothing, nothing about 'y' having been in doubt) -# and on six more whose roles do not -- and a role move and a -# report can co-occur while a role move and `_initials` cannot. -# -# The derived view is deliberately absent from both rules. The core's -# initials() DOES move for 'john e smith', 'john e jones' and -# 'jones, john e' ('j. j.' -> 'j. e. j.'), but `_initials` enters a -# diff only where every role AND every ambiguity kind agrees (#484, -# compare.py main()), and the report moved on each of them -- so at -# this baseline those names classify on the report alone. 'JUAN Y -# GARCIA' is the one name of the population whose view moves with no -# report beside it, and at this baseline it is already claimed: -# fix(#462) reaches it and admits `_initials`. That rule's comment -# records what moved and when: the FACADE's view moved there too on -# the morning #383/#479 landed, the 2.3 facade fix not having shipped -# at 2.2.0, and #528 closed the facade's half the same day -# (2026-09-13), leaving the core's as the whole diff. A competing -# rule here would be an equal-`fields` contest decided by file order, -# which is what _CROSS_RULE_WINNERS exists to stop rather than to -# create. It is classified on its own at 2.3.0, where BOTH surfaces -# now move, and the same way. -# -# LAST in the file: every diff these two claim reported UNEXPLAINED -# on the run that preceded them (measured 2026-09-13), so no rule -# above already claims one. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#383/#479) a single-letter connective joins only on case evidence" -# The 2.x view of the 1.4.0 rule of the same name: 'jose e maria -# santos' reads given 'jose', middle 'e maria' where 2.2.0 joined, -# and 'JUAN GARCIA Y LOPEZ' reads family 'GARCIA Y LOPEZ' where -# 2.2.0's bare-Latin-capital veto gave middle 'GARCIA Y'. A name -# written wholly in one case carries no case evidence about any -# letter in it, so the vocabulary decides: 'e' is marked as reading -# both ways and reads as an initial, 'y' is not and joins. Both are -# rules.md#P3 examples; see the 2026-09-13 entry of decisions.md#P3. -# -# `_ambiguities` is in the fields here and cannot be at 1.4.0 -- the -# report is a v2 surface -- and only ONE of the two names carries it: -# 'jose e maria santos' reports conjunction-or-initial, 'JUAN GARCIA -# Y LOPEZ' reports nothing, because nothing about 'y' was ever in -# doubt. The declaration is the union of the two diffs and #452's -# over-declaration check recomputes it every run. -# -# Literal-anchored to the two names, as at 1.4.0: the class is every -# one-case name carrying a cased single-letter connective, seventeen -# corpus names, and fifteen of them do not move a role. -# _MUST_NOT_MATCH carries the mixed-case probes. -name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" -fields = ["given", "middle", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" -# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e -# jones', 'jones, john e', 'e j smith': every role is identical -- -# the letter was already read as an initial, or rules.md#P3's -# three-word carve-out keeps it a name word either way -- and what is -# new is that the CALL is reported as conjunction-or-initial, because -# a one-case name gives the reader nothing to decide it by. -# -# The initials of 'john e smith' ('j. s.' -> 'j. e. s.'), 'john e -# jones' and 'jones, john e' (both 'j. j.' -> 'j. e. j.') move as -# well, and the view is deliberately not in the fields: see the -# section comment above for why no run can produce it here. -# -# 'e and e' is NOT in the alternation although it is in the same -# population and its report moves too (it LOSES given-or-family and -# gains conjunction-or-initial, both inside `_ambiguities`). At this -# baseline `feat(#449) a lone name word reports given-or-family` -# names it literally and admits the diff, so adding it here would -# create an equal-`fields` contest that only file order decides -- -# the class `precedes_narrower` cannot express and _CROSS_RULE_WINNERS -# has to pin. The name is radar tier, so nothing is hidden by leaving -# it where it is; 2.3.0's copy of this rule does claim it, that -# ledger carrying no #449 rule. -name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289) a written case contrast decides a bare ambiguous acronym" -# A member of suffix_acronyms_ambiguous written in CAPITALS inside a -# mixed-case name is written the way a credential is written, and is -# taken as one with nothing to spare ('Jack MA' -> given 'Jack', -# suffix 'MA'); one written in any other cased form is written the way -# a surname is written and stays one even with words to spare ('John -# Smith Ma' -> middle 'Smith', family 'Ma'). The same lean reaches the -# post-comma given slot ('Smith, MA' -> family 'Smith', suffix 'MA'; -# 'Smith, Ma' keeps its given), and the comma form's words-to-spare -# test becomes a count of NAME words, which is case-blind -- so -# 'John Smith, MA', 'John Smith, Ed', 'JOHN SMITH, MA' and -# 'john smith, ma' all read the credential where the token count read -# a given name. See rules.md#S2, rules.md#C1 and rules.md#P2. -# -# A name written wholly in one case carries no contrast and does not -# move on the LEAN at all -- 'JOHN SMITH MA', 'ANH DO', 'anh van do' -# and 'Jack Ma' are _MUST_NOT_MATCH probes in -# tests/v2/test_ledger_guards.py. The one-case comma forms in the list -# above are here on the NAME-word count instead, which asks nothing -# about case; that is why they sit beside the lean's own names rather -# than in a rule of their own. -# -# Accepted costs, both recorded rather than repaired: the walk stops -# at a declined pick instead of continuing past it, so a genuine -# suffix standing in front of a name-leaning acronym is never reached -# ('abdul Smith Jr Ma' reads middle 'Jr'), and the lean ends P2's -# particle chain with no words to spare where the count would not -# ('Freiherr von Berg MA', whose own rule further up this file carries -# the reading at 1.4.0). -# -# The Title-case half is witnessed at the comma by two minimal pairs -# rather than argued: 'John Smith, MA'/'John Smith, Ma' both read the -# credential, because two NAME words before the comma decide before -# the case is looked at, and 'Smith Jr., MA'/'Smith Jr., Ma' split, -# because one name word leaves the case to decide and Title-case -# declines. 'Smith Jr., Ma' diffs here for a reading this change did -# NOT move -- the pre-comma restructure v2 has done since 2.0, at -# 1.4.0, and the fork's new report at the 2.x baselines -- while -# 'John Smith, Ma' moves on the COUNT, as its all-caps twin does, and -# has no 1.4.0 diff at all, v1 having read the credential there too. -# Both sit on this rule because the rule is what states which way -# each member of a pair goes. -# -# 2026-09-19, #531: three names join, all of them lone post-comma -# credentials this rule's own second sentence already describes. -# 'Doe, MA' and 'Doe, MA PhD' are 'Smith, MA's family-word twins and -# 'Doe, Dr. MA' is the same slot behind a title; each reads family -# 'Doe' with the credential run in `suffix` -- 'MA', and 'MA PhD' -# on the one that has a run -- where the baseline read given 'MA'. -# They -# entered the corpora with #531's control rows and are NOT #531's -# doing -- verified byte-identical at cc78c960, the #530 merge -# (2026-09-19). No role joined the field list: the three move -# {given, suffix} and, on two of them, `_ambiguities`. -# -# 2026-09-19, #531 review round: 'Doe, Mr. MA PhD' joins them. It is -# 'Doe, Dr. MA' with a credential run behind the member -- the gate -# reads the segment whole, so the member joins the run rather than -# taking the given slot -- and it moves {given, suffix}, both already -# in the field list. It entered the corpora with that round's control -# rows and is NOT its doing: byte-identical at cc78c960 (measured -# 2026-09-19). -# -# Literal-anchored to the measured movers, and ONE SET FOR THE FOUR -# 2.x LEDGERS so the rule says the same thing at each of them. The -# 1.4.0 copy differs by FOUR members now. It holds 'John Prof. MA' -# out: 1.4.0 read a trailing period-marked title as a title itself, -# so that name's diff there belongs to fix(#316)'s rule and not to -# this one. "One set for all five" was written before that carve-out -# existed and was false in these four copies from the day it did -# (2026-09-18). And it holds the three names above out for a -# different reason: at 1.4.0 their diff is already claimed by -# `fix(comma-family) lone post-comma piece routes to suffix/title, -# not first`, which stands far earlier in that file, so adding them -# there would reach a name without explaining it and would put two -# rules on one diff with nothing to arbitrate between them -# (2026-09-19). The set is literal because the class is -# wide (every mixed-case name whose trailing piece is a listed -# ambiguous acronym, plus every comma form with two name words before -# the comma) and a regex for the class would claim the one-case -# spellings that do not move. A name here that does not diff at a -# given baseline is simply one the reading already agreed with there. -# `fields` is per-baseline, the union the run at THAT baseline -# measures (#452). -# 2026-09-27, #544: 'abdul Smith Jr Ma' leaves this alternation. The -# unambiguous 'Jr' in front of 'Ma' anchors it (rules.md#S2's company -# clause), the peel takes both, and P5's reserve declines the join, so -# the accepted cost this rule recorded for it -- middle 'Jr', family -# 'Ma' -- is gone; what the name still diffs on here is claimed by the -# rule that explains it, and a literal left in this one would stand -# ready to explain that cost coming back. -# 2026-10-01, #575: eight names join, the C1 examples of that change. -# Against these baselines none of them is #575's doing: each moves -# on this rule's own count and lean. 'John van Buren, Ed', 'Ortega y -# Gasset, Ed' and 'abdul Salam, Ed' are two name words before the -# comma and flip; 'De La Cruz, Ed', 'de la Cruz, Ma' and 'Van Buren, -# Ed' are one and gain only the comma's report; 'van der Berg, MA' -# is one, and its capitals make 'MA' the credential, as 'Smith, MA'. -# These baselines already read the particle surnames as one name; -# what #575 fixed is this cycle's count, which had not. -# 'Freiherr von Berg, Ed', rules.md#C1's Accepted example for #575, -# reads as these baselines read it and gains only the comma's report. -name_regex = "^(?:Davis Royce, Ed|De La Cruz, Ed|Doe, Dr\\. MA|Doe, MA|Doe, MA PhD|Doe, Mr\\. MA PhD|Freiherr von Berg MA|Freiherr von Berg, Ed|JOHN SMITH, MA|Jack MA|Jack MA\\.|Jack Wei Ma|John Prof\\. MA|John Smith Ma|John Smith, Ed|John Smith, MA|John Smith, Ma|John de Ma|John van Buren, Ed|John van der Berg Ma|Ortega y Gasset, Ed|Smith Jr\\., MA|Smith Jr\\., Ma|Smith, MA|Van Buren, Ed|abdul Salam, Ed|abdul Smith Berg Ma|abdul Smith Ma|de la Cruz, Ma|john smith, ma|van der Berg, MA)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#516) an unlisted dotted acronym is read by position" -# A token of two or more period-separated chunks that no vocabulary -# claims joins the ambiguous credential class by SHAPE and is read by -# POSITION, exactly as a bare listed acronym is: a credential where -# the name has words to spare, a name word where it does not, either -# reading reported (rules.md#S3). Case says nothing here -- the -# periods are the evidence -- which is why 'john smith x.y.z.' moves -# with 'John Smith X.Y.Z.'. The same admission reaches the comma form, -# where the NAME-word count decides ('John Smith, X.Y.Z.' -> suffix -# 'X.Y.Z.'; 'Smith, A.B.' keeps its given and only reports). Paired -# initials ('John Smith, A.B.') are the exception to that count and -# have their own rule below (#563); 'García Márquez, G.J.R.' is the -# exception's accepted boundary, three letters reading by the count. -# -# Two names here restore a reading a PRIOR bundle's vocabulary change -# took away, by a different route than it lost it: 'John Smith E.S.Q.' -# and 'John Smith C.H.A.' fell to a bare positional read when 'esq' -# and 'cha' left SUFFIX_ACRONYMS -# (decisions.md#suffix-acronym-collisions), and the shape class reads -# them as the credentials they always were with no wordlist entry. -# At the baselines where -# that restoration lands on the baseline's own reading, only the new -# report is left and `fields` says so. -# -# The narrow half of the same change: a chunk match no longer makes a -# dotted token a suffix where EVERY matched chunk is a single ASCII -# character, which reaches only the roman numerals -- 'Jack X.Y.I.' -# moves to family for that reason and not for the shape one. -# -# The shapes the vocabulary settles are untouched and are -# _MUST_NOT_MATCH probes in tests/v2/test_ledger_guards.py: a -# whole-token match ('Jack M.A.', 'John Smith Ph.D.', 'Smith, -# A.B.C.'), a surviving chunk claim ('Doe, John Msc.Ed.'), a leading -# dotted run ('X.Y.Z. Smith', 'J.R.R. Tolkien', 'A.B. Vajpayee'), the -# single trailing period that is no shape at all ('John Smith Xyz.'), -# and a digit chunk, which the alphabetic gate refuses ('Bridge (1.4)'). -# -# Literal-anchored to the measured movers, one set for all five -# ledgers, for the reason the fix(#289) rule above gives: the class is -# a SHAPE, and a regex for the shape would claim every dotted token -# the vocabulary already answers for. -name_regex = "^(?:García Márquez, G\\.J\\.R\\.|Jack X\\.Y\\.I\\.|John Smith B\\.Tech\\.|John Smith C\\.H\\.A\\.|John Smith E\\.S\\.Q\\.|John Smith Q\\.W\\.E\\.R\\.T\\.|John Smith X\\.Y\\.Z\\.|John Smith, X\\.Y\\.Z\\.|Smith, E\\.S\\.Q\\.|john smith x\\.y\\.z\\.)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#562) a comma credential run holding a particle chain reads by the name-word count" -# rules.md#C1: "A part read as postnominal [...] is read as its words -# stand, before any join, and that reading is final: [...] no particle -# run, connective join or bound given name reaches into it" (#613, -# 2026-10-06): no particle chain reaches it, so each member's -# capitals settle it and the run reads whole as the credential run -- -# silently since #613, where #562 (2026-10-01) had read the pair by the -# count and reported the flip. The family-comma path had read the part as name text: -# this baseline read given 'PhD', family 'DO DO John Smith', P6 -# attaching the chain. 'John Smith, PhD vd DO' is the same chain -# with a particle that is not a member ('vd', particle and -# unambiguous suffix vocabulary). 1.4.0 read both as suffixes, so -# that ledger has no rule. -# -# Literal. Probes: 'John Smith, PhD DO' (one particle beside no other -# stays settled and silent) and 'Smith, PhD DO DO' (one name word -# before the comma keeps the listing form) are _MUST_NOT_MATCH. -name_regex = "^(?:John Smith, PhD DO DO|John Smith, PhD vd DO)$" -fields = ["given", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#563) paired initials after a comma read as the given name unless a word speaks for them" -# rules.md#C1: "Paired initials are the exception to the count" -- -# two words before the comma may be one surname ('García Márquez, -# G.J.'), so two dotted single letters alone after it stay the given -# name and report the fork, where #516 had read them as a credential. -# "Only an unambiguous suffix word in front of them that is not also -# title vocabulary, or another word the class admits by its dotted shape -# standing in the same part, makes them the credential run": 'John -# Smith, PhD X.Y.' and 'John Smith, X.Y. P.Q.' move to the run, while -# 'De La Cruz, M.J. PhD' keeps its initials, the degree standing behind -# them, and so does 'García Márquez, Ms G.J.', a title in front ("an -# unambiguous suffix word in front of them that is not also title -# vocabulary"), which moves nothing at any baseline. The second period -# is optional ('García Márquez, G.J'). Two pairs speaking only for each -# other make the run and report it behind two name words ('John Smith, -# X.Y. P.Q.'). 2026-10-01, #575: 'De La Cruz, M.J. K.L.' is no longer -# that case -- 'De La Cruz' is one name word, so the count no longer -# reaches it and it reads as 'Cruz, M.J. K.L.' does, given 'M.J.', -# suffix 'K.L.', reporting both; a listed member in front speaks for -# nothing ('García Márquez, Ed G.J.' keeps the family comma, and the -# fix(#531) rule claims what its given part's trailing slot then reads). -# Against a 2.x baseline the names whose roles match the baseline's move -# only the report, which a 1.4.0 comparison cannot see; that ledger -# lists the three role movers alone. -name_regex = "^(?:De La Cruz, M\\.J\\. K\\.L\\.|De La Cruz, M\\.J\\. PhD|García Márquez, G\\.J|García Márquez, G\\.J\\.|García Márquez, PhD G\\.J\\.|John Smith, A\\.B\\.|John Smith, A\\.B\\. Ph\\.D\\.|John Smith, PhD X\\.Y\\.|John Smith, X\\.Y\\. P\\.Q\\.)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the ambiguous credential class reports at slots that were silent" -# Every decision at the trailing suffix slot, the post-comma given -# slot and a tail segment now reports the EXISTING SUFFIX_OR_NAME -- -# no new kind, and the report tracks the FORK BEING CONSULTED rather -# than which way it went, as the trailing slot always has. These names -# gain it with no role moving at any baseline: the fork was there all -# along and was called silently. It is the first time the comma's -# OWN decision is reported (rules.md#C1); P6's attachment fork has -# reported on a family-comma path since 2.3 ('Berg, Jan vd'). C2's -# structural flag was already the first comma-path report of a shape. -# -# 'Steven Hardman, MD, DO, DDS' goes the other way and LOSES its -# comma-structure flag: its third segment now reads as the credential -# run it is, because 'DO' leans credential in a mixed-case name. That -# is the one place this arc quiets a report rather than adding one. -# -# 2026-09-19, #531 review round: 'Doe, MA Smith' joins, and it is the -# post-comma given slot with a NAME WORD behind the member: no role -# moves at any baseline -- the member is the given name here as it -# always was -- and what is new is only the report that emitter makes. -# Its silent twin 'Doe, Dr. MA Smith' puts a title in that first piece -# instead, which the emitter reads rather than the member, and agrees -# with every baseline on every field, so it is in no ledger at all. -# -# `_ambiguities` alone, which keeps `_initials` out of these rows as -# always (#484), and the rule cannot absorb a role diff on any of -# these names for the same reason. Literal-anchored, one set for all -# the 2.x ledgers; the 1.4.0 ledger has no twin, `_ambiguities` being -# a v2 surface that cannot enter a diff below baseline 2.0. -name_regex = "^(?:Doe, MA Smith|J\\.A\\. K\\.D\\.|Jack X\\.Y\\.Z\\.|John Smith J\\.u\\.n\\.i\\.o\\.r\\.|John Smith R\\.A\\.I\\.|Royce, Ed|Smith Jr\\., A\\.B\\.|Smith, A\\.B\\.|Smith, Ma|Steven Hardman, MD, DO, DDS)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the glued CJK honorific peel reads the case lean" -# The suffix-run predicate the peel uses to decline a post-comma run -# (rules.md#W3, asking C1's own vocabulary question) now reads the -# same written-case evidence rule S2 reads, because `one_case` is -# passed to it. Before, one name written with two credentials divided -# two ways: a Kim-prefixed 김민준씨 input peeled its honorific when the -# credential was 'PhD' and did not when it was 'MA', the only -# difference being that 'MA' is an ambiguous acronym whose case lean -# the predicate was not given. -# -# Every name here is a COMPOSED form -- a Latin wrapper and a family -# comma around a CJK name -- which no East Asian writing system -# produces, so all three are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion). They are classified all the same: -# a radar diff this arc INTENDED is one a release note may be written -# from, and an unclassified one is not. -# -# `title` is in `fields` because 'Dr. 김민준씨, MA' had the whole -# string for its family at these baselines, the title included; at -# 2.0.0 and 2.1.0 'Jo김민준씨, DO' moves `title` instead of `given`, -# and the union is what a per-baseline `fields` records (#452). -name_regex = "^(?:Dr\\. 김민준씨, MA|Jo김민준씨, DO|Kim김민준씨, MA)$" -fields = ["title", "given", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# #531: THE GIVEN PART'S TRAILING SLOT. Four rules -- the thirteen -# the credential reading takes, the four the writing declines and that -# gain a REPORT alone, the one `do` spelling the capitals take from -# P6, and the two caseless comma forms on the radar tier. The decision is -# decisions.md#S2's 2026-09-18 pair; rules.md#S2, #S3, #C1 and #P6 -# carry the statements. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#531) a credential ending the given part of a family-comma listing reads as a credential" -# After a family comma, a word of the ambiguous credential class -# ending the given part is now read by the same rule the comma-less -# spelling uses for a word ending the name. The comma has already -# named the family and the first word after it is the given name, so -# the words to spare are there by construction and the count says -# nothing: the word reads as the credential unless its WRITING says -# otherwise. rules.md#S2 states it, rules.md#S3 carries the by-shape -# spelling, and decisions.md#S2 records the decision. -# -# Thirteen names, and they are the class read from every side the -# corpora reach it: the bare member ('Doe, John MA', 'Doe, John BA'); -# its one-case spellings, where the lean is inert and the POSITIONAL -# reading is not ('DOE, JOHN MA', 'doe, john ma', 'DOE, MARY JO MA'); -# the slot behind a title ('Doe, Dr. John MA') and behind an initial -# ('Doe, J. MA'); a run of two members, where the credential the -# member joins was already a suffix so `suffix` GROWS rather than -# filling ('Doe, John MA JD', 'Doe, John MA PhD', 'Doe, John PhD MA', -# 'Doe, John MA Jr'); the slot reached past a middle initial, which -# keeps its own middle ('Doe, John Q. MA', 'DOE, MARY JO MA'); and the -# by-shape member, whose lean is always None so the positional reading -# takes it outright ('Doe, John X.Y.Z.'). -# -# Do not read "thirteen role movers" off that list at every baseline: -# `fields` is the union the run at THIS baseline measures (#452), and -# a name here whose roles the baseline already agreed with contributes -# only its report. 'Doe, John BA' is that name at 2.1.0 and 2.2.0, and -# 'Doe, John MA JD' is the other end of the range, gaining TWO reports -# for its two members. -# -# `_ambiguities` is in the fields because every one of the thirteen -# gains the existing SUFFIX_OR_NAME -- no new kind, and the report -# tracks the FORK CONSULTED rather than which way it went, which is -# the rule fix(#289/#516) above already states for the other slots. -# #452's over-declaration check recomputes the union on every run. -# -# Literal-anchored for the reason the two rules above give, and it is -# sharper here: the class is a SLOT, so a regex for it would claim -# every family-comma name whose given part ends in an ambiguous -# acronym -- including the four the WRITING declines, which keep their -# name reading and have the rule below, and 'Doe, John MA Smith', -# where a name word behind the member ends the run so nothing moves -# and nothing reports. _MUST_NOT_MATCH carries both, with the `do` -# spellings P6 keeps and the third-comma-part name #144's restriction -# still owns. -# 2026-09-19, #533: TWO names join the alternation and the rule does -# not change. 'Doe, J. ba' and 'Smith nee Jones, Jane MA' are this -# slot read exactly as the thirteen above are -- a lower-case member -# ending the given part after an initial, and the same member behind -# a maiden clause that sits in the FAMILY segment, where no trailing -# rule reads the clause's own tail and the given part's slot is -# reached as usual. Both read the same way at 2f57ff21, before #533, -# measured name by name: what grew is the CORPUS, not the rule, and -# a `fix(#533)` rule for them would attribute a #531 reading to the -# wrong change. -name_regex = "^(?:DOE, JOHN MA|DOE, MARY JO MA|Doe, Dr\\. John MA|Doe, J\\. MA|Doe, J\\. ba|Doe, John BA|Doe, John MA|Doe, John MA JD|Doe, John MA Jr|Doe, John MA PhD|Doe, John PhD MA|Doe, John Q\\. MA|Doe, John X\\.Y\\.Z\\.|García Márquez, Ed G\\.J\\.|Smith nee Jones, Jane MA|doe, john ma)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) a member the writing declines keeps its name reading and reports the fork" -# The other direction of the same fork, and the whole of what these -# four names do: no role moves at any 2.x baseline and the decision -# starts being REPORTED. 'Doe, John Ma', 'Doe, John Ed' and -# 'Doe, Mary Jo Ma' carry a Title-cased member in a mixed-case name, -# which is written the way a name is written, so the member stays a -# middle name; 'Doe, John MA Ma' is a two-member run whose last member -# is Title-cased, so the walk declines there and the run stays middle -# text whole. -# -# Reporting a DECLINED fork is #530's stated rule -- the report tracks -# the fork consulted, not the lean -- and AGENTS.md's "A kind is worth -# adding only if a reader would hesitate too" is the standing -# objection it answers. It is also the only part of #531 that adds a -# report without moving a field, which is why it is a rule of its own -# rather than a widening of the one above: `fields` is -# `_ambiguities` alone, so this rule cannot absorb a role diff on any -# of the four. -# -# At 1.4.0 these same four DO move roles -- v1 read all four members -# as post-nominals -- and that divergence is carried there by -# fix(given-part-trailing-slot), whose comment records that #531 -# changed which names it holds rather than retiring it. -# -# 2026-09-28: 'Smith, MD PhD Ma' joins -- a title/suffix dual opening the -# given part is a title there, 'PhD' is the given name, and the -# Title-case 'Ma' ending the part is this slot's declining half, -# reported. No role moves from this baseline; #544 keeps it so -# (rules.md#S2: "A word of both the title and the suffix vocabulary -# standing in the given part's leading title run is a title there and -# speaks for nothing, and no credential behind it speaks for a word of -# this class in that part"). -# -# Literal-anchored: the class is the slot's declining half, and a -# regex for it would claim the thirteen movers above. -name_regex = "^(?:Doe, John Ed|Doe, John MA Ma|Doe, John Ma|Doe, Mary Jo Ma|Smith, MD PhD Ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) capitals take the do collision from the family-comma particle attachment" -# 'Doe, John DO', alone, because the word is alone in the class: `do` -# is the one ambiguous credential that is also particle vocabulary, so -# this slot and P6's attachment (rules.md#P6) want the same word. -# Derek's decision, recorded at decisions.md#S2: "CAPITALS DECIDE FOR -# `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING". An all-caps -# member in a name written in more than one case carries a positive -# credential lean, so the credential reading wins and P6 stands down -# (and since #544 so does an unambiguous credential in front of it, -# 'doe, jane v phd do'); every other spelling attaches exactly as it -# did, with P6's own `particle-or-given` and no second report. -# -# THE PAIRING IS THE ARGUMENT and the accepted cost is half of it. In -# ONE CASE the rule cannot tell 'NASCIMENTO, EDSON ARANTES DO' from -# 'SMITH, JOHN DO' and reads both as the particle -- right about the -# Brazilian record, wrong about the osteopath. Those two, and -# 'Doe, John Do', 'DOE, JOHN DO', 'doe, john do' and -# 'Nascimento, Edson Arantes do', are _MUST_NOT_MATCH probes: this -# rule has no business claiming a single one of them, and each is a -# name #531 measured to be byte-identical before and after. -# -# `fields` is per-baseline and the two halves of this file's set -# differ by which role the particle came OUT of, which is P6's own -# history: {middle, suffix} at 2.0.0 and 2.1.0, where the -# baseline read middle 'DO' because P6's family-comma attachment had -# not shipped, and {family, suffix} at 2.2.0 and 2.3.0, where it had -# and the baseline reads family 'DO Doe'. -name_regex = "^Doe, John DO$" -fields = ["_ambiguities", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) the trailing slot's positional reading reaches a caseless script" -# '田中, 太郎 MA' and '김, 민준 MA': middle 'MA' -> suffix 'MA', one -# report each. A caseless script answers True to the one-case test, so -# the lean is None -- and at this slot a None lean is not inert, -# because the POSITIONAL reading the lean would have overridden is -# itself the credential. "Caseless is inert" holds for the LEAN and -# not for the outcome, which is the sentence tests/v2/cases.py's two -# rows carry. -# -# Both are COMPOSED forms -- a family comma between a CJK family and a -# CJK given name, which no East Asian writing system produces -- so -# both are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion), and their diffs print under -# UNCLASSIFIED (radar) rather than blocking. They are classified all -# the same, on fix(#289/#516)'s stated ground: a radar diff an arc -# INTENDED is one a release note may be written from, and an -# unclassified one is not. -# -# Literal-anchored, and the probes are the CJK comma names that do NOT -# move at this slot: '田中 太郎, MA', '毛泽东, MA' and '마틴 킹, MA' put -# their member in the FIRST post-comma piece, which is #289's slot and -# not this one. -name_regex = "^(?:田中, 太郎 MA|김, 민준 MA)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#533) a credential ending a maiden clause reads as a credential" -# The PR review added one: 'Jane Doe nee Smith V MA', where a -# trailing roman numeral stands between the clause and the member. -# Both stops read the TRAILING word, so the numeral is not the word -# either fork asks about -- the acronym fork stops at 'MA' and the -# 'V' stays maiden text behind it. -# The words a maiden marker takes now end where a trailing credential -# begins, where the rule that reads the name left standing reads the -# word as one -- both as the name is written and as the take would -# leave it (rules.md#M2, decisions.md#M2). `suffix` fills as `maiden` -# shortens, and the fork is reported: the end of a maiden clause was -# the last trailing position in the library where a member of the -# ambiguous credential class was read in silence. -# -# Seventeen names, and they are the class read from every side the -# corpora reach it: the bare member ('Jane Doe nee Smith MA', -# 'Doe, Jane nee Smith MA'); its one-case spellings, where the lean is -# inert and the POSITIONAL reading is not ('JANE DOE NEE SMITH MA', -# 'JANE DOE NEE YO-YO MA', 'jane doe nee smith ma', 'Doe, Jane nee -# Smith ma'); the other markers, the German abbreviation and the -# Polish PHRASE the walk reads no differently for its being three -# words ('Jane Doe geb. Smith MA', 'Maria Kowalska z domu Nowak MA'); -# a member beside another credential, where `suffix` GROWS rather than -# filling ('Jane Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', -# 'Jane Doe nee Smith Ma JD' -- whose Title-cased Ma stays in the -# clause while the JD behind it leaves); past a trailing title, which -# the peel steps over ('Jane Doe nee Smith Prof. MA'); the slot -# reached past a middle initial ('Doe, Jane Q. nee Smith MA'); the -# `do` collision, where the capitals decide ('Doe, Jane nee Smith DO' -# beside 'Jane Doe nee Smith do', whose comma-less spelling P6 never -# reaches); the clause that leaves ONE name word, which rules.md#M4 -# makes the family ('John née Jones Smith MA'); and the CLAMP, where -# the member is the only word the marker would otherwise leave and -# only what stands BEHIND it is given up ('Doe, J. nee MA ba' keeps -# maiden 'MA' and reads suffix 'ba'). -# -# Literal-anchored for the reason fix(#531)'s rules give, and it is -# the same reason here: the subject is a SLOT, so a regex for it -# would claim the names whose WRITING declines the word -- which keep -# their maiden reading and have the rule below -- as readily as the -# ones it takes. _MUST_NOT_MATCH carries both directions. -name_regex = "^(?:JANE DOE NEE SMITH MA|JANE DOE NEE YO-YO MA|Jane Doe geb\\. Smith MA|Jane Doe nee Smith MA|Jane Doe nee Smith MA JD|Jane Doe nee Smith MA PhD|Jane Doe nee Smith Ma JD|Jane Doe nee Smith V MA|Jane Doe nee Smith do|John née Jones Smith MA|Maria Kowalska z domu Nowak MA|jane doe nee smith ma)$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) the maiden clause reports the credential it keeps" -# The PR review added six more, and they are the same declining half -# reached by four further routes -- each one a shape where the clause -# CANNOT promise the word a credential reading, so rules.md#M2 keeps -# it. No name word is left AHEAD of the member for a given part's -# trailing slot to be the end of ('Doe, Dr. nee Smith MA'); a -# particle chain below the marker pass would absorb it into the -# family ('Berg, Jane van der nee Smith DO', and 'Jane Doe nee Smith -# DO DO' where the other released member is what would do the -# joining); P5's bound-given join would swallow it into the pair -# ('Berg, abdul nee Jones MA'); the FIRST-WORD FLOOR spares it and -# only an unambiguous credential behind it leaves ('Jane Doe nee MA -# PhD'); and the lean declines it after a comma ('Doe, Jane nee -# Smith Do'). The first four READ as 2f57ff21 read them and gain -# only the report: an earlier round of this branch released all four -# and moved them in silence, which is what the review measured and -# reversed. -# The other direction of the same fork, and the whole of what these -# eight names do at this baseline: no role moves and the decision -# starts being REPORTED. A Title-cased member in a mixed-case name is -# written the way a name is written, so the clause keeps it ('Jane -# Doe nee Smith Ma', 'Jane Doe nee Yo-Yo Ma', 'John née Jones Smith -# Ma', 'Doe, Jane nee Smith Ma'); a member that is the ONLY word -# after the marker stays the maiden name whatever its writing says, -# the marker having announced a name ('Jane Doe nee MA'); a suffix -# word BEHIND the member ends the peel before it reaches the member, -# so the clause keeps it and the clause's own emitter is what raises -# the fork ('JOHN NEE JONES SMITH MA PHD'); and P6's attachment keeps -# every `do` spelling the capitals do not take ('Doe, Jane nee Smith -# do', 'Doe, Jane nee Smith MA do'); and 'Jane Doe nee King. ba' is -# the FLOOR's own boundary, whose roles this baseline already reads -# the way the tree does -- maiden 'King.', suffix 'ba' -- so from -# here it is visible only as the report it raises. The floor keeps a -# chained first word in the re-peel's count, which the parent's walk -# (it had no chain at all) never removed it from in the first place; -# so #535 moves nothing here either, and the whole diff is #533's. -# -# Reporting a DECLINED fork is #530's stated rule -- the report -# tracks the fork consulted, not the lean -- and it is why this is a -# rule of its own rather than a widening of the one above: `fields` -# is `_ambiguities` alone, so it cannot absorb a role diff on any of -# the nine. -# -# Literal-anchored: the class is the slot's declining half, and a -# regex for it would claim the seventeen movers above. -# 2026-09-27, #544: 'Jane Doe Jr. nee Smith Ma' joins, the declining -# half at rules.md#M2's new Accepted boundary: the credential in front -# of the marker speaks for no member of the clause, the Title-case 'Ma' -# is kept, and the clause reports it. -name_regex = "^(?:JOHN NEE JONES SMITH MA PHD|Jane Doe nee King\\. ba|Jane Doe nee MA|Jane Doe nee MA PhD|Jane Doe nee Smith Ma|Jane Doe nee Yo-Yo Ma|John née Jones Smith Ma)$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position" -# 'Jane Doe nee Smith X.Y.Z.'. A by-shape member carries no writing -# convention to read -- `listed_lean` returns None where the switch -# made the token credential-SHAPED and the vocabulary did not list it -# -- so the positional reading decides alone and the clause gives the -# word up. The same trade #516 recorded at its own slot and #531 at -# the given part's, taken to agree with the comma-less 'John Doe -# X.Y.Z.' and rules.md#S3's shape rule rather than to restore -# anything. -# -# Its own rule rather than one of the movers above, because the -# ARGUMENT is different: every name up there has a writing the lean -# could have read, and this one has none. -name_regex = "^Jane Doe nee Smith X\\.Y\\.Z\\.$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name" -# 'John Smith nee Jones R.A.I.', and it is a RESTORATION rather than -# a change: this baseline reads suffix 'R.A.I.' with maiden 'Jones', -# and so does the tree, so only the report diffs here. What moved in -# between was this unreleased 2.4 cycle. At 2.3.0 the token carried -# `vocab:suffix` and the maiden walk's suffix-piece test stopped at -# it; #516 retags it `shape:acronym` plus `vocab:suffix-ambiguous`, -# it stops being a suffix piece, and the walk took it -- maiden -# 'Jones R.A.I.' on this tree at 2f57ff21, measured. The name was in -# no corpus file, so no gate ever compared it and no 2.4.0 release -# bullet records the move; this change's rows admit it, which turns -# an unrecorded regression into a recorded restoration. -# -# `fields` is `_ambiguities` alone at this baseline and says so -# deliberately: the restoration is invisible from here, and the rule -# that could absorb a role move on this name is the one this file -# must not have. -name_regex = "^John Smith nee Jones R\\.A\\.I\\.$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#533) the maiden clause ends at the credential in a native-script name too" -# '田中 太郎 旧姓 佐藤 MA': maiden '佐藤 MA' -> maiden '佐藤', suffix -# 'MA', one report. The walk reads the same rule whatever script the -# clause is written in -- the marker is 旧姓, the member is Latin, and -# neither fact reaches the stop -- so this is the movers' rule above -# with a CJK marker in front of it. -# -# A COMPOSED form, so the name is a `tolerated` row on the radar tier -# (decisions.md#cjk-comma-demotion) and its diff prints under -# UNCLASSIFIED (radar) rather than blocking. It is classified all the -# same, on fix(#289/#516)'s stated ground and the one #531's caseless -# rule follows: a radar diff an arc INTENDED is one a release note -# may be written from, and an unclassified one is not. -# -# Its own rule rather than an alternative in the movers' regex: a CJK -# member in an alternation is claimed by the honorific pin, which -# would demand that alternation be a copy of GLUED_HONORIFICS. A lone -# literal has no alternation to pin. At 1.4.0 and 2.0.0 the marker's -# own rule carries this name instead, the 旧姓 consumption being the -# larger diff from those baselines. -name_regex = "^田中 太郎 旧姓 佐藤 MA$" -fields = ["_ambiguities", "maiden", "suffix"] - -# --------------------------------------------------------------- -# #397 + #461: THE CATALAN LINK JOINS, AND A CONNECTIVE INITIALS -# WHERE IT JOINS NOTHING (2026-09-20). Four rules: the join's ROLES, -# the REPORT a one-case spelling makes instead of joining, and -# #461's two halves, which are one sentence read in its two -# directions and need two rules because a rule listing `_initials` -# may list nothing else (#484). -# -# LAST in the file, and that is narrow-first rather than a -# preference: the join rule declares {family, middle, suffix} and -# every rule above it that reaches one of its five names declares a -# strict subset, so an earlier position is an order-decided contest -# the run refuses (#382). -# -# rules.md#P3 states the join and its two boundaries; rules.md#R3 -# states what a connective contributes. decisions.md#P3 carries the -# count decision and the both-sides scope, decisions.md#R3 the -# criterion and its measured blast radius. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#533/#535) a title in front of the credential the clause gives up" -# 'Jane Doe nee Smith Prof. MA' moves at this baseline for two -# reasons together: #533 already gives 'MA' up to the credential -# reading and reports the fork (`suffix='MA'`, `maiden='Smith -# Prof.'`, SUFFIX_OR_NAME -- rules.md#S2's ambiguity flag: "either -# reading carries the ambiguity flag") before #535 exists -- verified -# against the parent tree d9d80492, which already differs from this -# baseline exactly that way. #535 then reads the trailing title chain -# through the credential, so the title standing in front of 'MA' -# leaves the maiden name too (`title='Prof.'`, `maiden='Smith'`). -# Literal-anchored for the reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Smith Prof\\. MA$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -issue = "fix(#535) a trailing title ends the maiden clause" -# rules.md#M2: "It takes them up to the trailing run -# of post-nominals and titles that the end of the name reads as if -# the clause were not written" (#601; until #601 this cited the -# sentence it replaced, "a trailing title ends it too") -- the end of -# the name is read through -# the trailing title chain -- rules.md#H5: "successive single words -# that wear the abbreviation shape and are title vocabulary chain -# into the title from the end" -- so the title leaves the clause and -# the credential or numeral in front of it gets the stop it gets with -# the title absent; the pair 'Smith MA Prof.' / 'Smith Prof. MA' now -# agrees. Literal-anchored for the reason fix(#533)'s rules give: the -# subject is a SLOT, and a regex would claim the names whose view -# check keeps the title as readily as the ones that release it. One -# name moved to its own rule above ('Jane Doe nee Smith Prof. MA'), -# because the parent tree d9d80492 already differed from this -# baseline before #535 touched it; the eight names here are ones -# where #535 is the sole cause, verified the same way. ('Jane Doe nee -# Prof. Dr.' and 'Jane van der Berg nee Smith Prof.' need no such -# split at THIS baseline -- #296 and #399 both predate it, so this -# baseline already reads them as HEAD does apart from #535's own move, -# unlike at 2.0.0/2.1.0.) -name_regex = "^(?:Jane Doe \\x28nee Smith Prof\\.\\x29|Jane Doe nee Prof\\. Dr\\.|Jane Doe nee Smith King\\.|Jane Doe nee Smith MA Prof\\.|Jane Doe nee Smith Ma Prof\\.|Jane Doe nee Smith Prof\\.|Jane Doe nee Smith V Prof\\.|Mary Smith née Jones Prof\\.)$" -fields = ["title", "maiden", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#397) the Catalan/Polish link joins two surnames" -# 'i' is connective vocabulary since #397, and a connective counts as -# a name word toward rules.md#P3's three-word carve-out whatever else -# the vocabulary says it is -- which matters here because 'i' is also -# the roman numeral. So a four-word name reaches the threshold and the -# link joins. This baseline read 'Josep Carod i Rovira' as middle -# 'Carod i' with family 'Rovira', and 'Carod i Rovira, Josep' -- where -# the link stands after a family comma -- as family 'Carod Rovira' -# with the link taken as a GENERATION in `suffix`, which is why the -# two move different field sets and are grouped here rather than -# split. The three longer spellings move with the first: 'Josep Carod -# i Rovira III' and 'Josep Lluis Carod i Rovira' the same -# {family, middle}, and 'Josep Carod i de Rovira' the same pair with -# the link joining PAST a particle (middle 'Carod i' -> 'Carod i de', -# family 'de Rovira' -> 'Rovira'). Five names, `fields` the union of -# their two shapes. -# -# All five entered this corpus with #397's own rules.md#P3 example -# lines and case rows; before that they were radar-tier names the -# contract did not answer for, which is why a rule appears at this -# baseline only now. -# -# Literal-anchored to the five. The shape is "a single letter between -# two name words", which reaches every corpus name carrying a bare -# initial and would pre-excuse the opposite reading -- #397's whole -# accepted cost is that some such letters must NOT join. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the wall: -# 'Josep i Rovira' (three words, under the carve-out), 'Carod i' and -# 'John Quincy Smith i' (the link with nothing to its right) and the -# two 'Josep Lluis Carod i ' spellings, whose trailing -# generation run is the fix(#436/#437) rule's at the head of this -# file. -name_regex = "^(?:Carod i Rovira, Josep|Josep Carod i Rovira|Josep Carod i Rovira III|Josep Carod i de Rovira|Josep Lluis Carod i Rovira)$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#397) the Catalan link reports a connective-or-initial in a one-case name" -# 'JOSEP CAROD I ROVIRA', 'JOSEP LLUIS CAROD I ROVIRA' and 'josep -# carod i rovira'. 'i' joined `conjunctions_ambiguous` with #397, so -# in a name written wholly in one case -- where the writing says -# nothing either way -- the letter reads as an INITIAL and the call is -# reported, exactly as 'e' has read since #383/#479. #527's mechanism, -# a new letter: rules.md#P3's marked subset. -# -# ROLES DO NOT MOVE on any of the three, and that is the point of the -# marked subset rather than an accident of these spellings: reading -# the letter as an initial leaves it the middle-name word this -# baseline already had, so the whole diff is the report. Measured -# against the parent 46651750 on 2026-09-20, the same three names' -# parses are byte-identical before and after the bundle and only -# `ambiguities` moved. The lower-case spellings where the letter WAS -# the generation before -- 'rovira, i', 'john smith i jr', 'maier, amy -# i, jr.', 'josep de carod i rovira' -- do move fields under the same -# marked-subset reading, and no corpus carries one of them, so this -# rule is about the report alone. -# -# No rule at 1.4.0 and that is structural: `_ambiguities` is a v2 -# field and cannot diff against a v1 worker at all. -# -# Literal, three names. _MUST_NOT_MATCH carries the mixed-case -# spellings, where the writing decides the letter and the JOIN is what -# moved, and 'JUAN GARCIA Y LOPEZ', the unmarked letter in the same -# one-case position, which reports nothing. -name_regex = "^(?:JOSEP CAROD I ROVIRA|JOSEP LLUIS CAROD I ROVIRA|josep carod i rovira)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective holding its part alone contributes an initial" -# 17 names, each one a part whose only word is the connective, -# so there is nothing there for it to join and it initials as any -# other name word does -- which is what `family_base` and `given_base` -# have said about the same token since 2.2 (rules.md#R2/#R3). This -# baseline drops the letter; the tree keeps it. Measured from the -# 2.2.0 wheel and from the tree on 2026-09-20: 'Juan de y' 'J.' -> -# 'J. y.'; 'johnny y' 'j.' -> 'j. y.'; 'Garcia y Lopez' 'G. L.' -> -# 'G. y. L.'; 'Juan y Garcia' 'J. G.' -> 'J. y. G.' and 'juan y -# garcia' the lowercase twin; 'John y Jane' 'J. J.' -> 'J. y. J.'; -# 'Carod y de Rovira i' 'C. R.' -> 'C. y. R.'; 'John e Smith' 'J. S.' -# -> 'J. e. S.', with 'John e Smith III', 'John e Smith, III' and -# 'Smith, John e, III, Jr' giving the same pair; 'john e jones, III' -# 'j. j.' -> 'j. e. j.'; 'Jose e Maria' 'J. M.' -> 'J. e. M.'; -# 'Lt.Gov. juan e garcia' 'j. g.' -> 'j. e. g.'; and 'Fritz Freiherr -# und von Bar' 'F.' -> 'F. u.', the German spelling of the same shape. -# BOTH SURFACES move together and to the same string on every one of -# them, the two views reading one mark (#528, decisions.md#R3). -# 'محمد و علي' is the readmission RESTORING 1.4.0 parity rather than -# breaking it: 'م. ع.' here, 'م. و. ع.' in the tree, which is the -# string the 1.4.0 wheel gives. The 1.4.0 ledger's -# `feat(#269) a recognized non-Latin connective contributes no -# initial` explained that name and is DELETED in the same commit as -# this rule, the name having stopped diffing there. -# 'Juan y Garcia née Jones' is in this list at 2.2.0 and 2.3.0 and at -# no earlier baseline: below 2.2 the maiden clause moves ROLES on it, -# so `_initials` never enters its diff and `fix(#418)` explains what -# happened (compare.py's roles-identical guard, #484). -# -# ONE CAUSE, measured, not two. Every name here agreed with this -# baseline's wheel BEFORE the bundle -- the parent 46651750 gives the -# wheel's string on each -- so none of the diff belongs to #383/#479's -# one-case fork or to any older change; the whole of it is #461. -# -# Literal-anchored. The shape is "a part holding one connective and -# nothing else", which no regex over the raw string can state -- the -# reading is the parse's -- and the nearest writable shape, "a name -# carrying a lone cased letter", reaches every bare-initial name in -# the corpora. _MUST_NOT_MATCH carries the wall: 'Jon Dough and' (base -# 'Dough and', a connective with a name word beside it, keeping -# 'J. D.'), 'Juan Velasquez y Garcia' (the joined run, keeping -# 'J. V. G.') and 'Juan Garcia Lopez y'. -name_regex = "^(?:Carod y de Rovira i|Fritz Freiherr und von Bar|Garcia y Lopez|John e Smith|John e Smith III|John e Smith, III|John y Jane|Jose e Maria|Juan de y|Juan y Garcia|Juan y Garcia née Jones|Lt\\.Gov\\. juan e garcia|Smith, John e, III, Jr|john e jones, III|johnny y|juan y garcia|محمد و علي)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective with a name word beside it stops contributing an initial" -# 13 names, the other direction of the same sentence: where the -# part holds a name word for the connective to join, the connective is -# doing a connective's work and contributes nothing. This baseline -# initialed it; the tree does not. 'John and Jane Smith' 'J. a. J. S.' -# -> 'J. J. S.'; 'Juan y Eva Garcia' 'J. y. E. G.' -> 'J. E. G.'; -# 'Jose e Maria Santos' 'J. e. M. S.' -> 'J. M. S.'; 'and Jon Dough' -# 'a. J. D.' -> 'J. D.' and 'the and Jon Dough' 't. a. J. D.' -> -# 'J. D.'; 'Rob And Beth Edmunds' 'R. A. B. E.' -> 'R. B. E.'; the two -# 'Dean of Chemistry Robert Johns' spellings 'D. o. C. R. J.' -> -# 'D. C. R. J.'; 'John and Jane Aznar y Lopez' 'J. a. J. A. L.' -> -# 'J. J. A. L.'; 'Mr. and Mrs. John and Jane Smith' 'J. a. J. S.' -> -# 'J. J. S.'; and the three Cyrillic spellings of 'Хосе И Мария -# Сантос' 'Х. И. М. С.' -> 'Х. М. С.', lower case and upper case -# alike. Both surfaces move together and to the same string. -# -# ONE CAUSE, measured. Each of these names agreed with this -# baseline's wheel before the bundle -- the parent 46651750 gives the -# wheel's string on every one, the Cyrillic three included, whose 'и' -# has been decided vocabulary since 2.0 and reaches no case fork. So -# none of the diff is #383/#479's and the whole of it is #461. At -# 1.4.0 these names diff too, but they diffed there BEFORE this -# bundle, on the 2.0.0 per-word GROUPING, and that ledger keeps them -# on `fix(initials-per-word) a connective run initials each word` -# with a dated paragraph naming both halves. -# -# CYRILLIC SPELLED OUT, not classed. A `\p{Cyrillic}`-style span -# would declare a script this rule is not about -- the sentence is -# about a PART, in any script -- and _SPAN_BEARING_RULES in -# tests/v2/test_ledger_guards.py records that this rule declares no -# span. -# -# Literal-anchored, for the GAIN rule's reason, and with the same -# wall: 'Jon Dough and', 'Juan Velasquez y Garcia' and 'Juan Garcia -# Lopez y' are _MUST_NOT_MATCH probes here too -- the criterion is the -# PART and never a word count, which is exactly what keeps those three -# fixed. -name_regex = "^(?:Assoc Dean of Chemistry Robert Johns|Dean of Chemistry Robert Johns|John and Jane Aznar y Lopez|John and Jane Smith|Jose e Maria Santos|Juan y Eva Garcia|Mr\\. and Mrs\\. John and Jane Smith|Rob And Beth Edmunds|and Jon Dough|the and Jon Dough|ХОСЕ И МАРИЯ САНТОС|Хосе И Мария Сантос|хосе и мария сантос)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -# #397 review: A LINK INSIDE A MAIDEN CLAUSE STAYS IN THE BIRTH NAME -# (2026-09-20). LAST in the file, narrow-first: this rule declares -# {family, maiden, middle, suffix} and every rule above it that could -# reach any of the three declares a strict subset, so an earlier -# position would be an order-decided contest the run refuses (#382). -# -# rules.md#M2 states the exception; decisions.md#M2's 2026-09-20 -# bullet carries the measurement and why it is fixed in #397's own PR -# rather than filed. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#397) a link inside a maiden clause stays in the birth name" -# Three names, one sentence, three shapes -- `fields` is the union. -# -# The maiden walk has always ended the birth name at the first suffix -# WORD after the marker, and the Catalan link is also the roman -# numeral, so this baseline read maiden 'Puig' and left 'i Soler' -# outside the clause: 'Jane Doe nee Puig i Soler' middle 'Doe i', -# family 'Soler'; 'Doe, Jane nee Puig i Soler' middle 'Soler', suffix -# 'i'. That truncation PREDATES this bundle. What #397 added is -# worse and is why the stop is fixed here: with the link joining, the -# leaked words merged into the CURRENT surname -- measured on the -# parent 46651750 and on this branch's 0fbcaa0b, family 'Doe i Soler' -# and given 'Jane i Soler' respectively -- a word of the birth name -# inside the current one. -# -# The 'y' spelling is the control and never moved at any baseline: -# 'Jane Doe nee Puig y Soler' reads maiden 'Puig y Soler' here and in -# the tree, 'y' being connective vocabulary and no generation. That -# twin is the property invariant in tests/v2/test_properties.py. -# -# The THIRD name arrives from rules.md#M2 rather than from a report: -# 'Smith, John, PhD née Puig Mr. - i Soler' is one of rules.md#M2's -# delimiter examples, added with #538 (under a configured ' - ' it -# reads maiden 'Puig Mr.', by the comma reading since #549). This corpus does not configure that delimiter, -# so the gate parses it with the DEFAULT facade, where the dash is an -# ordinary name word: the link has one on each side and the clause -# keeps 'i Soler' where this baseline left it in the suffix: suffix -# 'PhD i Soler' -> 'PhD', maiden 'Puig Mr. -' -> 'Puig Mr. - i Soler' -# (measured 2026-09-22 at all four 2.x baselines, identical at each). -# That is this rule's own sentence and no part of #538 or #549, whose -# readings need the delimiter declared; {maiden, suffix} is a subset of the -# union above. -# -# Literal-anchored to the four. The shape is "a one-letter connective -# inside a maiden clause", which would stand ready to excuse every -# future regression at this walk -- and the walk's whole design is -# that some such letters must NOT be taken. _MUST_NOT_MATCH carries -# the wall: the i-last control, the generation behind it, and the -# spellings where the letter is an initial. -# -# 'Smith, John, PhD née Puig - i Soler' joined the alternation with -# #538 (2026-09-26): it is rules.md#M2's second live example, and it -# reaches the DEFAULT facade the same way its neighbour does -- no -# delimiter configured here either, so the dash is an ordinary name -# word. Measured at this baseline: suffix 'PhD i Soler' -> 'PhD', -# maiden 'Puig -' -> 'Puig - i Soler' -- the same shape as the -# neighbour's own reading with 'Mr.' dropped from it, unchanged by -# the fix; only the corpus name is new. -name_regex = "^(?:Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler|Smith, John, PhD née Puig - i Soler)$" -fields = ["family", "maiden", "middle"] -orders = ["DEFAULT"] - -[[change]] -issue = "change(suffix-acronym-collisions) ph leaves the acronym set" -# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps -# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' -# token pass the acronym test on its first piece -- the merge itself -# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working -# without it -- and its only unique coverage was a bare trailing -# 'Ph.' with no 'D.' behind it, read as a credential by every release -# since 1.4.0. Out of the set that spelling falls to the positional -# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: a deliberate 2.x parity break at every baseline, argued in -# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's -# and carried by the 2.4.0 release-log bullet. No corpus name had the -# shape until the cases.py row was written to carry it, which is why -# the removal measured as 0 role movers at review time; 'John Smith -# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. -# -# Literal to the one corpus name that carries the shape. The CLASS -# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', -# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', -# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every -# one a bare ph with no D. behind it (the decisions block lists them, -# measured 2026-09-22). A row carrying one of those arrives -# unexplained here by design: widen this rule to it, do not file it. -name_regex = "^John Smith Ph\\.$" -fields = ["family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. The rules -# below: the two names whose family name comes back, the marking's -# cost behind a full name, the same cost after a family comma, the -# lone word after a family comma (given name back, cost for the -# conventional spelling), the Title-case Lac gain that is the cost's -# mirror, and the two that keep the credential and gain a report. No -# rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. -# -# Every name here is one of #540's own shape-tagged case rows. No -# corpus name written before the change carried a bare trailing -# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at -# 120033b5), so the gate saw nothing until the rows admitted it. The -# same rules stand in the four 2.x ledgers; the 1.4.0 ledger carries -# the two cost rules and the Title-case Lac rule, 1.4.0 having read -# the other names' roles as the tree does. -# -# At landing (2026-09-25) there were eight rules here, and two more -# costs among them: the credential run behind a suffix comma re-read -# as a family comma, and the chunked dotted 'Wang M.Eng.' read as the -# family. #544 (2026-09-27) reads both shapes again and retired both -# rules. The comma-run names are the fix(#544) rules' at the -# bottom of this file; 'Wang M.Eng.' reads the roles this baseline -# read, and its report is the feat(#449) lone-name-word rule's near -# the top. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" -# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and -# 'tran', 'lac') with a suffix-or-name report, where this baseline -# read suffix 'meng' and no family name at all -- reporting nothing -# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the -# one-word-name fork, which was the wrong fork. 1.4.0 read the -# family, which is why that ledger has no copy of this rule. -# -# Literal-anchored to the two names. The class is every name whose -# last word is a bare member with nothing to spare, and a regex for -# it would stand ready to explain a regression on the spellings that -# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py -# carries 'meng li', where the word LEADS and nothing moved. The -# members are names, not a copy of any wordlist, which is what -# _NOT_A_VOCABULARY_COPY records. -name_regex = "^(?:tran lac|wang meng)$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" -# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with -# a suffix-or-name report, where every release read suffix 'MEng'. -# The marking's stated cost. In a name written in more than one case -# rules.md#S2 reads a member "written in any other cased form that is -# not wholly lower" as the name "even with words to spare", and MEng -# and LAc are conventionally written in exactly that form -- the cost -# 'John Smith Ma' already carries. -# -# 'John Smith MEng PhD' pays the same cost and keeps going: the -# declined pick stops the walk (family 'MEng'), but the unambiguous -# credential behind it is still peeled (suffix 'PhD'), where every -# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). -# -# A rule of its own rather than a member of the one above, so the -# gate's summary and the release note name the cost under its own -# heading. Literal, two names: the one-case spellings 'john smith -# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding -# where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" -fields = ["family", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" -# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', -# with a suffix-or-name report, where every release read suffix -# 'MEng'. The same S2 case reading the rule above pins, arriving -# after a family comma, where C1 has already settled the word count -# and the writing is the only evidence left. -# -# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's -# name-word count, and the all-lower 'Smith, John meng' keeps the -# credential too -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, John MEng$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" -# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family -# 'Smith', with a suffix-or-name report, where this baseline read -# suffix and no given name at all -- the third comma path, S2 -# declining the post-comma credential when the word count leaves it -# a name and C1 reading it as the given name. 1.4.0 already read -# the given name here, so that ledger carries no copy of this rule: -# the all-lower spelling restores 1.4.0's reading, and the -# mixed-case spelling is the cost side of the same path. -# -# Literal, two names; 'Smith, MENG' keeps the credential on the -# capitals lean, and 'Smith, John MEng' belongs to the middle-name -# rule above -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, (?:MEng|meng)$" -fields = ["given", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" -# 'john smith meng' and 'nguyen van lac': every role is what this -# baseline read -- suffix 'meng', suffix 'lac' -- because a full name -# in front leaves the credential reading available, and what is new -# is the report rules.md#S2 makes at the trailing slot, where "either -# reading carries the ambiguity flag". `_ambiguities` alone, so this -# rule cannot absorb a ROLE diff on either name, which is the point -# of keeping it apart from the two above. -name_regex = "^(?:john smith meng|nguyen van lac)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports" -# Every role is what this baseline read -- the whole run after the -# comma a suffix -- and what is new is the report. rules.md#C1: "The -# same count reads a part of two or more words as the credential run -# when every word of it is a suffix word or a word of this class, at -# least one of them of this class", and "A decision either way at this -# comma is reported". 'John Smith, PhD MEng' and 'john smith, phd meng' -# come back here from #540's accepted cost, which had read the comma as -# a family comma; 'Jane Doe, MS LAc' and 'John Smith, MD MEng' open the -# part with a title-and-suffix word, which counts as the suffix word it -# is after a full name, where #540 had read it as a title. -# -# `_ambiguities` alone, so this rule cannot absorb a ROLE diff on any of -# them -- the names whose roles move are the rule below. Literal; the -# probes 'John Smith, PhD MA' (capitals in a mixed-case name already -# settle the run, so it reports nothing new) and the superstring 'Dr. -# John Smith, PhD MEng' are _MUST_NOT_MATCH. -name_regex = "^(?:Jane Doe, MS LAc|John Smith, MD MEng|John Smith, MEng PhD|John Smith, PhD MEng|john smith, phd meng)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count" -# The part after the comma is the credential run on C1's count of the -# two name words before it, where this baseline read it as the listing -# form ('John Smith, Ed Ma' read given 'Ed', middle 'Ma'). A -# title-and-suffix word opening the part counts as the suffix word it is -# after a full name ('John Smith, Ms Ma', the accepted cost, Derek -# 2026-09-27; 'john smith, md ma'), and 'X.Y.Z.' is a member by shape, -# whose capitals lean nothing -- rules.md#S2 reads the writing of a -# LISTED member only -- so its run flips too. 1.4.0 read the listed- -# member names as suffixes as well. -# -# 2026-09-28: 'John Smith, PhD Ma' joins, the same run with an -# unambiguous word in front of the member, which the count reads whole -# like the rest. -# -# 2026-10-01, #554: 'John Smith, vd Ma' and 'JOHN SMITH, VD MA' join, -# rules.md#C1's examples for a word of both the particle and the -# unambiguous suffix vocabulary: "A word's particle reading does not -# take it out of the run". This baseline read the whole name as the -# family ('John Smith vd Ma'). 'John Smith, Jr do' joins as the -# contrast to the silent 'John Smith, Jr vd': `do` is a word of the -# ambiguous class, so the count flips the part and reports, where this -# baseline read given 'Jr', family 'do John Smith'. -# -# Literal; `fields` is the union the names move ('title' is the duals'). -# Probes: 'Smith, Ed Ma' (one name word before the comma keeps the -# listing form), 'Smith, Ms Ma' (a dual opening the given part is a -# title) and the superstring 'Dr. John Smith, Ed Ma' are _MUST_NOT_MATCH. -name_regex = "^(?:JOHN SMITH, VD MA|John Smith, Ed Ma|John Smith, Jr do|John Smith, Ms Ma|John Smith, PhD Ma|John Smith, X\\.Y\\.Z\\. MA|John Smith, vd Ma|john smith, md ma)$" -fields = ["given", "middle", "family", "suffix", "title", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#436/#437/#544) an unambiguous credential in front anchors the member behind it" -# rules.md#S2's company clause: a member written behind an unambiguous -# credential in one run "reads as the credential whatever its writing -# and whatever the count", where the tree before #544 read the Title-case -# MEng as a name word ('John Smith PhD MEng' family 'MEng'; 'Doe, Jane -# PhD MEng' middle 'MEng'), in the maiden clause's reading of the name -# too. The diff against this baseline is what remains once the roles -# come back: the report of the member the peel picked, and -- through -# 2.2 -- the run 1.4.0 and 2.x wrote 'PhD, MEng', which rules.md#R1 -# writes as the writer spaced it (#436/#437), so one rule explains the -# whole of each diff. -# -# The label is joint for that reason, as the 1.4.0 ledger's -# fix(#274/#436/#437) rules are: the `suffix` this rule claims moves on -# the spacing alone, which is #436/#437's, and `_ambiguities` is the -# report #544 adds. The 2.3.0 ledger, whose baseline already spaces the -# run, carries the same rule as #544's alone. -# -# 2026-09-28: 'Smith, PhD MEng' joins. The one-word family comma leaves -# its part holding no name word once 'PhD' speaks for 'MEng', so the -# part reads wholly as the credential run -- the fields this baseline -# read -- and the pick the company made reports where it was made. -# -# Literal. Probes: 'Wang Ma PhD' (the credential BEHIND the member speaks -# for nothing) and the superstring 'Dr. John Smith PhD MEng' are -# _MUST_NOT_MATCH. -name_regex = "^(?:Doe, Jane PhD MEng|Doe, Jane nee Smith PhD MEng|Jane Doe nee Smith PhD MEng|John Smith PhD MEng|Smith, PhD MEng)$" -fields = ["suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a degree in front outranks P6's attachment for a particle member" -# 'doe, jane v phd do': the one-case 'do' leans nothing and is particle -# vocabulary, so P6's attachment would take it into the family, and the -# unambiguous 'phd' in front of it outranks that attachment as capitals -# do (rules.md#S2, decided 2026-09-27) -- suffix 'v phd do', reporting -# suffix-or-name where this baseline read the particle into a name part. -# 'NASCIMENTO, EDSON ARANTES DO', with nothing in front, keeps P6's -# reading. Literal; the superstring 'Dr. doe, jane v phd do' and 'doe, -# jane do' are _MUST_NOT_MATCH. -name_regex = "^doe, jane v phd do$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a Title-case Lac behind a particle is the family name" -# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a -# suffix-or-name report, where every release read family 'Van', suffix -# 'Lac'. The mixed-case name's Title-case member leans 'name' with -# words to spare (S2) -- the gain side of the marking's cost, for a -# Vietnamese name (Lac is the trailing word in native order, Nguyen -# Van Lac). -# -# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix -# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, -# along with the superstring 'Dr. Nguyen Van Lac'. -name_regex = "^Nguyen Van Lac$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a credential in front anchors a member after a one-word family comma, and the pick reports" -# 'Smith, PhD Ma': family 'Smith', suffix 'PhD Ma', reporting the -# Title-case 'Ma', where this baseline read given 'PhD', middle 'Ma'. -# rules.md#S2's company clause: 'PhD' in front speaks for 'Ma', so the -# part after the one-word family comma holds no name word and reads -# wholly as the credential run, and "a member the company decides -# reports the fork as a counted pick does". 'Smith, PhD MEng', whose -# roles this baseline already read, is the anchor rule's above. -# -# Literal. Probes: 'Smith, PhD MA' (the capitals decide the member, -# nothing new to report), 'Smith, MD PhD Ma' (a dual in the leading -# title run: nothing in the part speaks) and the superstring 'Dr. -# Smith, PhD Ma' are _MUST_NOT_MATCH. -name_regex = "^Smith, PhD Ma$" -fields = ["given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#575) a particle surname before a comma is one name word" -# rules.md#C1: "a particle run and the one name word it attaches to -# are one word", so 'van der Berg, PhD' reads as 'Berg, PhD' -# does: family 'van der Berg', suffix 'PhD'. Every release counted -# tokens and split the chain, given 'van', family 'der Berg', with a -# particle-or-given report. -# -# Literal; the probe 'John van der Berg, PhD' (two name words) is -# _MUST_NOT_MATCH. -name_regex = "^van der Berg, PhD$" -fields = ["given", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default" -# rules.md#C1 and S2 (#564): Policy.unlisted_caps_suffixes defaults to -# CapsSuffixes.AFTER_COMMA, so the part after a comma with two or more -# name words before it reads an unlisted all-caps word as the -# credential, alone or in C1's run, where the name contrasts it, and -# reports the call; a lone two-letter word is declined as initials. The all-caps SURNAME convention never writes the -# capitals there. 'John Smith, XYZ' and 'John Smith, LEED AP' are the -# C1 examples (the latter closing the #291 deviation the doc carried); -# 'The Rt Hon Kenneth Clarke QC MP, HMG' is a radar corpus name. -# 'John Smith, PhD XYZ' is a caps word in C1's run beside a listed -# credential, and 'García Márquez, MJ JK' two words of two capitals, -# read as #563 reads two pairs (both C1 examples). 'John Smith, RAI' and 'Ahmad Jayadi, CHA' read -# suffix again, as this baseline read them by vocabulary before #342 -# removed both words, and differ only by the comma's report. -# -# Literal; the probes 'Smith, XYZ' (one name word), 'García Márquez, -# MJ' (a lone two-letter word) and 'John Smith XYZ' (no comma: the -# trailing position stays opt-in) are _MUST_NOT_MATCH. -name_regex = "^(?:Ahmad Jayadi, CHA|García Márquez, MJ JK|John Smith, LEED AP|John Smith, PhD XYZ|John Smith, RAI|John Smith, XYZ|The Rt Hon Kenneth Clarke QC MP, HMG)$" -fields = ["title", "given", "middle", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word before a family comma stays in the family" -# rules.md#P2: in the part before a family comma "a particle that is -# also suffix vocabulary heads the name word behind it, whatever case -# the name is written in". This baseline read family 'SMITH MA', -# suffix 'VD', peeling the particle from between two family words. -# -# Literal; the probe 'SMITH VD, JOHN' (nothing behind the particle, so -# the suffix stands) is _MUST_NOT_MATCH. -name_regex = "^SMITH VD MA, JOHN$" -fields = ["family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word between credentials after a family comma stays in the run" -# rules.md#P6's third exception: a lone such word with a credential -# read in front of it and another behind "does not end the name, and -# keeps the post-nominal reading in any case, reported as S2's -# credential fork" -- whatever made the credential in front, so 'DOE, -# JANE MA VD PHD' stays whole as 'DOE, JANE PHD VD MA' does. This -# baseline split both: 'MA' read as a middle name, and 'VD' as the -# family's particle where 'MA' stood in front of it. -# -# Literal; the probe 'DOE, JANE PHD VD' (nothing behind, so the -# attachment holds) is _MUST_NOT_MATCH. -name_regex = "^(?:DOE, JANE MA VD PHD|DOE, JANE PHD VD MA|doe, jane phd vd ma)$" -fields = ["middle", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a never-given particle opening the given part takes the member behind it" -# rules.md#S2: a never-given particle first after a family comma is no -# given name, and the member behind it "is the surname the particle -# heads, not a word to spare", a chained particle run included ('VD DE -# LA'). This baseline read the same family; the report is the fork the -# given slot now decides, which it did not consult then. -# -# Literal; the probe 'SMITH, VAN MA' (an ambiguous particle, which can -# be a given name, keeps it, and the member reads as the credential) -# is _MUST_NOT_MATCH. -name_regex = "^(?:SMITH, VD DE LA MA|SMITH, VD MA|smith, de ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#585) a decomposed initial keeps its whole first letter" -# initials() took a token's first CODE POINT, which in a name typed -# decomposed (NFD) is a base letter without the combining mark that -# completes it: 'e' + U+0301 initialled 'e.', and the katakana 'ジ', -# 'シ' + the combining dakuten U+3099, initialled 'シ.'. The initial is -# now the shortest start of the word that composes to the composed -# word's first character, written in the form the writer used -# (rules.md#R3), so both surfaces give the composed spelling's initial -# kept decomposed. A composed name's initials do not move, and nor do -# those of an NFD name whose first letters do not decompose (#542's -# 'josé garcía' rows), which is why the rules corpus's two other NFD -# rows are the only names here. -# -# Roles do not move; the derived view alone does, so `_initials` is -# the one field. Literal names rather than a shape: what selects them -# is "a name typed decomposed whose initial letter decomposes", and no -# regex over the raw text says that without copying Unicode's -# decomposition data. -name_regex = "^(e\u0301mile zola|マイケル シ\u3099ャクソン)$" -fields = ["_initials"] - -# --------------------------------------------------------------- -# #594: HALFWIDTH KATAKANA IS KATAKANA. The script table classifies -# the halfwidth kana block U+FF65-U+FF9F as Script.KATAKANA, so what -# reaches full-width katakana reaches halfwidth now: the kana license -# (rules.md#W4) reads 山田 タロウ family-first as it reads 山田 エミ, the -# 间隔号 divides between two halfwidth kana (rules.md#T3), and H2's -# opening-abbreviation shape declines a period-marked katakana word -# because katakana has no initials (rules.md#H2's Accepted clause), -# closing the limit decisions.md#cjk-full-stops recorded. A WHOLLY -# halfwidth name keeps the declared order, as wholly full-width -# katakana does, so 'ヤマダ タロウ' is parity. -# -# Literal-anchored to the names: no corpus line held a halfwidth kana -# before this change, so the movers ARE the eight case rows #594 added, -# less the parity one -- five here, two in the title rule. A shape -# spelling -- any halfwidth kana -- would be a hand copy of the span -# the canonical CJK rule already carries. LAST in the file: each diff -# reported unexplained, or unclassified on the radar, on the run -# before these rules existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#594) halfwidth katakana takes the kana license and the 间隔号" -# '山田 タロウ', '山田 ダイスケ' and '山田 ペーター' read family 山田 where every -# release read it given, the unspaced '山田タロウ' reads family where it -# read given, and 'タロウ·ヤマダ' divides where it was one given token. -name_regex = "^(?:山田 タロウ|山田 ダイスケ|山田 ペーター|山田タロウ|タロウ\\u00B7ヤマダ)$" -fields = ["given", "family"] - -[[change]] -issue = "fix(#594) a period-marked halfwidth katakana word is not a title" -# 'タナカ. John' and 'マイケル.' read title at 2.2.0, as their full-width -# twins did until #cjk-full-stops (2.3); both read given now, as the -# twins do. -# Tolerated input (an edge full stop on a CJK word), so these are -# radar names and the rule classifies them for the release note rather -# than for the exit code. -# `_ambiguities` is the v2 surface's: the lone 'マイケル.' reports -# given-or-family as 'マイケル.' does, where the title reported nothing. -name_regex = "^(?:タナカ\\. John|マイケル\\.)$" -fields = ["title", "given", "_ambiguities"] - -# --------------------------------------------------------------- -# #596: A VOICING MARK TAKES THE SCRIPT OF THE KANA BEFORE IT. The -# four kana voicing marks U+3099-U+309C sit in the hiragana block, and -# NFC composes one only where a precomposed voiced kana exists, so the -# mark in 'ア゙イ' (ア + U+3099) or 'ア゛イ' (the spacing U+309B) stood in -# a katakana word as hiragana and the word took the kana license -# (rules.md#W4) -- family-first, where a wholly-katakana name keeps -# the declared order. Classification now deletes each mark that -# follows a kana, so the word reads as its base's script. The -# same names on hiragana or beside kanji ('あ゙い たろう', '山田 ア゙イ') -# are parity: their reading never depended on the mark. -# -# Literal-anchored to the names: no corpus line held an uncomposable -# voicing mark before this change, so the movers ARE the three case -# rows #596 added. LAST in the file: each diff reported unexplained -# on the run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#596) a voicing mark does not make a katakana word kana-licensed" -# 'ア゙イ タロウ' and 'ア゛イ タロウ' read given ア゙イ where 2.1.0-2.3.0 -# read it family, and 'マイケル ア゙イ' reads given マイケル where the -# licensed neighbour made it the family. -name_regex = "^(?:ア\\u3099イ タロウ|ア\\u309Bイ タロウ|マイケル ア\\u3099イ)$" -fields = ["given", "family"] - -# --------------------------------------------------------------- -# #597: THE HALFWIDTH CORNER BRACKETS ARE A DEFAULT NICKNAME PAIR. -# 「」 (U+FF62/U+FF63) join DEFAULT_NICKNAME_DELIMITERS beside 「」, and -# the v1 facade's nickname_delimiters gains halfwidth_corner_brackets, -# so a clause they enclose is lifted out as the nickname (rules.md#N1) -# where every release left it in the name -- a middle where spaced, -# part of one given word where glued. With the -# unclassified bracket gone, '山田 「タロー」 タロウ' is kanji plus katakana and -# takes the kana license; the unspaced '山田「タロー」太郎' divides at the -# extracted clause; 'John 「Jack」 Smith' shows the pair is not -# script-gated. -# -# Literal-anchored to the names: no corpus line held a halfwidth -# bracket before this change, so the movers ARE the three case rows -# #597 added. LAST in the file: each diff reported unexplained on the -# run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#597) halfwidth corner brackets enclose a nickname" -# The clause moves to nickname in all three, out of middle in the two -# spaced names and out of the one given word in '山田「タロー」太郎'; the -# two Japanese names also take their script order, family 山田. -name_regex = "^(?:山田 「タロー」 タロウ|山田「タロー」太郎|John 「Jack」 Smith)$" -fields = ["given", "middle", "family", "nickname"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). The clause these names -# carried at this baseline is gone: `maiden` empties and its words read -# where the given part reads any word. What else differs is the given -# part's own reading of those words -- the spaced credential run -# (#436/#437), the link join (#397), P6's attachment of a trailing -# particle to the family (#405), the capitals lean (#289) and the -# trailing title chain (H5) -- each as the same text with the marker -# replaced by an ordinary word reads it. -issue = "fix(#601) a marker in the given part after a family comma is an ordinary word" -name_regex = "^(?:Berg, Jane van der nee Smith DO|Berg, abd née Jones|Berg, abdul nee Jones MA|Doe, Dr\\. nee Smith MA|Doe, J\\. nee MA ba|Doe, Jane Q\\. nee Smith MA|Doe, Jane nee Puig i Soler|Doe, Jane nee Smith|Doe, Jane nee Smith DO|Doe, Jane nee Smith Do|Doe, Jane nee Smith MA|Doe, Jane nee Smith MA Prof\\.|Doe, Jane nee Smith MA do|Doe, Jane nee Smith Ma|Doe, Jane nee Smith PhD MEng|Doe, Jane nee Smith do|Doe, Jane nee Smith ma)$" -fields = ["_ambiguities", "family", "given", "maiden", "middle", "suffix", "title"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04): a tail part is read -# wholly as suffixes, the marker and its words with it. -issue = "fix(#601) a marker in a part after a suffix comma is an ordinary word" -name_regex = "^(?:Smith, John, MD - née Jones Smith|Smith, John, PhD née Jones)$" -fields = ["maiden", "suffix"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). Where a credential -# stands before the marker it also starts #602's run -- rules.md#S2: "A -# credential after the name core starts a run to the end of its part" -- -# which takes the marker and the words after it in. -issue = "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word" -name_regex = "^(?:Dr\\. nee Smith PhD Prof\\.|Jane Doe Jr\\. nee Smith|Jane Doe Jr\\. nee Smith Ma|Jane Doe PhD nee Smith|Jane and née Jones)$" -fields = ["_ambiguities", "family", "given", "maiden", "suffix", "title"] - -[[change]] -# 'Jane Doe nee Smith VI Prof.': rules.md#M2's numeral example -# behind a title, which the take reaches since #610 shared its -# candidate words with the peel (2026-10-04). -# rules.md#M2: "It takes them up to the trailing run of post-nominals and -# titles that the end of the name reads as if the clause were not -# written" (#601, 2026-10-04), and "The run so found ends the name: its -# words are the suffixes and titles that reading makes them, and no join -# reaches into it". The old walk's release check kept these words in the -# clause where a join it modelled might take them; the run is bound now. -issue = "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes" -name_regex = "^(?:Jane Doe nee Smith DO DO|Jane Doe nee Smith VI Prof\\.|Jane van der Berg nee Prof\\. King\\. MA|Jane van der Berg nee Smith Prof\\.|John nee Prof\\. ba MA)$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -# rules.md#M2's take "always takes the first word after it, the marker -# having announced a name" (#601, 2026-10-04): a lone roman numeral is no -# suffix word, so it is the maiden name, where the old walk read the -# numeral fork from the marker and declined. Decided with #601 (decisions.md#M2). -issue = "fix(#601) the first word after the marker is the maiden name" -name_regex = "^Jane Smith née V$" -fields = ["_ambiguities", "family", "maiden", "middle", "suffix"] - -[[change]] -# rules.md#S2: "A credential after the name core starts a run to the end -# of its part" (#602, 2026-10-04): every later word of the part is a -# suffix except a title word, which reads as a title, and an absorbed -# name word is reported. -# 'Jane van der Berg née Jr Jones' (radar): the marker declines on -# the suffix word after it (rules.md#M2) and stays a word, so 'Jr' -# stands behind a name core and its run takes 'Jones' (2026-10-04). -issue = "fix(#602) a credential after the name core starts a run to the end of its part" -name_regex = "^(?:Eric H\\. Holder Jr\\. Attorney General|Jane van der Berg née Jr Jones|John Smith PhD Jones|John Smith PhD Prof\\. Ma|Smith, John PhD Jones)$" -fields = ["_ambiguities", "family", "middle", "suffix", "title"] - -[[change]] -# rules.md#S2: "leaving it nothing to join: then it counts as a name -# word of its own" -# (#627, 2026-10-10): a lone particle with the credential right behind -# it counts toward the run's two name words, so it is the family and -# the run takes the rest. -issue = "fix(#627) a lone particle with the run right behind it is the surname" -name_regex = "^John von PhD Jones$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -[[change]] -# rules.md#P4: "of several such words the last does, so no particle -# chains inside the titles" (#624, 2026-10-10): the second -# title-particle is a title and the name starts behind it. -issue = "fix(#624) of two title-particles at the head the second leads the chain" -name_regex = "^Freiherr St John Smith$" -fields = ["_ambiguities", "family", "given", "title"] - -[[change]] -# rules.md#H5: "chain stops in front of the trailing run of suffixes and titles," -# (#623, 2026-10-10): after a family comma a trailing title is the -# title, and P6 attaches the particle in front of it. -issue = "fix(#623) after a family comma the chain stops before a trailing title" -name_regex = "^Smith, J[a-z]+ van( der Berg)? Prof\\.$" -fields = ["_ambiguities", "family", "middle", "title"] - -[[change]] -# rules.md#C2: "a part past the second comma holds no name word" (#636, -# 2026-10-11): group makes no particle chain in such a part, so a title -# word behind a particle run there is a title. -issue = "fix(#636) a part past the second comma makes no particle chain" -name_regex = "^John Smith, Jr\\., van der Berg Prof\\.$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#637, -# 2026-10-11): after a family comma the bound join declines the -# trailing title word H5's run takes, so it is read as the title. -issue = "fix(#637) the bound join declines a trailing title after a family comma" -name_regex = "^Berg, abdul Prof\\.$" -fields = ["given", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#640, -# 2026-10-11): in a part past the second comma the bound join declines -# a title word, which C2 reads as a title. -issue = "fix(#640) the bound join declines a title word past the second comma" -name_regex = "^John Smith, Jr\\., abdul Dr\\. Smith$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#M2 reads the clause-free name, whose credential starts #602's -# run -- rules.md#S2: "A credential after the name core starts a run to -# the end of its part" -- and the take consumes that run: maiden 'Smith', -# suffix 'PhD Smith' (2026-10-04). -issue = "fix(#601/#602) a credential in the clause starts a run the take consumes" -name_regex = "^Jane Doe nee Smith PhD Smith$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #604: THE IRISH AND MALAY PATRONYMIC PARTICLES. -# 'ó' and 'ní' join the never-given particles, 'ua', 'binti' and -# 'binte' the ambiguous half, so each opens the surname behind it -# (rules.md#P1, P2) where every release read it as a middle name. -# 'Ó.' written WITH its period stays the initial (rules.md#P7), so of -# the five P7 examples only the bare boundary moves; the two R4 case -# repair examples move for the vocabulary itself. -# -# Literal-anchored to the names: the movers are the P7 boundary and -# the two R4 examples, each reported unexplained on the run before -# this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#604) the Irish and Malay patronymic particles" -# The particle leaves the middle and opens the family: 'Ó Pérez', -# 'Ó MURCHÚ', 'binti navalamar'. -name_regex = "^(?:Juan Ó Pérez|SEÁN Ó MURCHÚ|ina binti navalamar)$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #606: SALUTATIONS AND TITLES IN OTHER LANGUAGES. -# TITLES gains salutations and offices for languages it had none -# for, among them three that are also surnames written last -# wherever they are borne (graaf, greve, knight), shipped on the -# graf precedent (rules.md#H1). The one corpus mover is the -# H1 Accepted example for that cost: the surname-first form -# without a comma gives the surname-borne word to the title. -# -# Literal-anchored to the name: it was reported unexplained on the -# run before this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#606) salutations and titles in other languages" -# The given name becomes the title: 'Greve'. -name_regex = "^Greve Anna$" -fields = ["title", "given"] - -[[change]] -# rules.md#C1: "A credential opening the part makes it the postnominal -# part however it goes on" (#603, 2026-10-04): where the first suffix -# word after the comma starts S2's run, every word behind it reads as -# that run reads, so a full name before the comma keeps its positional -# read ('John Smith, PhD Jones') and a one-word name is the family -# ('Doe, PhD Jones'). The undeclared dash of the delimiter rows is one -# more word of the run; a title/suffix dual opens nothing. -issue = "fix(#603) a credential opening the part after the comma makes it the postnominal part" -name_regex = "^(?:Doe, PhD Jones|John Smith, Ph\\. D\\. Jones|John Smith, PhD Jones|Smith, RN - CRNA|Steven Hardman, RN - CRNA)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix"] - -[[change]] -# rules.md#C2: "a word of the title vocabulary there that is not also -# suffix vocabulary is a title" (#603, 2026-10-04): a part past the -# second comma reads its title words as titles, and a part of title and -# suffix words is recognized rather than flagged ('Eric H. Holder, Jr., -# Attorney General'). 1.4.0, like 2.0 through 2.3, read them as -# suffixes. -issue = "fix(#603) a title word in a part past the second comma is a title" -name_regex = "^(?:1 & 2, 3 4 5, Mr\\.|Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29|Eric H\\. Holder, Jr\\., Attorney General|Eric H\\. Holder, Jr\\., Secretary of State|John Smith, Jr\\., Lt\\.Gov\\.|John, Smith, Dr\\.|Smith, John, Prof\\.)$" -fields = ["_ambiguities", "suffix", "title"] - -[[change]] -# rules.md#C1: "Wherever this rule counts the words before the comma, a -# particle run and the one name word it attaches to are one word", and -# "A connective join and a bound given-name pair are not one word here" -# (#575): C1's one count before the comma, read off classify's tags -# since #613 (2026-10-06), so a connective surname is a whole name and -# the comma followed by a title reads it positionally. This baseline -# read it through assign's second count over group's joined pieces, -# one piece and so the family. -issue = "fix(#613) a connective surname before the comma counts its words" -name_regex = "^Ortega y Gasset, Dr\\.$" -fields = ["family", "given", "middle"] - -[[change]] -# rules.md#S2: "The run at a name's trailing slot is read ONCE, once -# the connectives have joined (P3) and before the particle chain (P2) -# and the bound given-name join (P5) are made" and "neither join -# reaches a word the run took" (#614, 2026-10-07), and rules.md#H5: "a -# particle chain (P2) stops in front of a trailing title rather than -# taking it into the family name". This baseline made the chain first -# and the title was a word of the family name. -issue = "fix(#614) a trailing title is read before the particle chain" -name_regex = "^John van der Berg Prof\\.$" -fields = ["family", "title"] - -[[change]] -# rules.md#N1: "a comma inside it kept as written — the comma divides -# nothing there" (#653, 2026-10-12): a comma inside a delimited -# clause is the clause's text, as 1.4.0 kept it. This baseline split it -# off as a separator, and the nickname rendered without it. -issue = "fix(#653) a delimited clause keeps its comma" -name_regex = "^(?:Jane \\(Janey, Jo\\) Smith|Maria Kowalska \\(z, domu Nowak\\))$" -fields = ["nickname"] - -[[change]] -# rules.md#M3: "a marker followed by a comma is no marker" (#653, -# 2026-10-12): once a clause kept its comma, a spaced one after the -# marker would have passed M3's word-after test, so the comma now -# unmarks it and the clause stays a nickname, as 1.4.0 read it. This -# baseline dropped the comma and read the clause as the maiden name. -issue = "fix(#653) a comma after a marker unmarks it" -name_regex = "^(?:Jane \\(née , Jones\\) Smith|Jane \\(née ,Jones\\) Smith)$" -fields = ["maiden", "nickname"] - -[[change]] -# rules.md#H2: "An accented Latin, Greek or Cyrillic letter written as a -# base letter plus a combining mark (NFD) is the same letter as the -# composed one" (#652, 2026-10-12): the abbreviation shape ended its -# word at the base letter, so a decomposed `inž.` was no abbreviation -# and became the given name. The rules example is the one name here. -issue = "fix(#652) a decomposed accented abbreviation is an abbreviation" -name_regex = "^Dr\\. inz\u030c\\. Janez Novak$" -fields = ["title", "given", "middle"] diff --git a/tools/differential/expected_since_2.3.0.toml b/tools/differential/expected_since_2.3.0.toml deleted file mode 100644 index 76e85503..00000000 --- a/tools/differential/expected_since_2.3.0.toml +++ /dev/null @@ -1,1815 +0,0 @@ -# Ledger for baseline 2.3.0 -- what changes for a user upgrading from -# the previous minor. Same rule grammar as the other ledgers: `issue`, -# `name_regex` and `fields` are every one of them required. Neither -# narrowing key may stand on its own -- #451 banned a rule with -# `fields` and no regex, #456 banned the reverse -- so a rule here -# narrows by name AND by role, never by just one. -# -# Opened empty the day 2.3.0 shipped (AGENTS.md release step 8), and it -# is opened before it has anything to say because DEFAULT_BASELINE and -# the ledger are coupled: _allowlist_for treats a missing file as a -# hard error, on the reasoning that an absent ledger classifies nothing -# and would make every diff report as unexplained. So the baseline -# could not advance to 2.3.0 without this file. -# -# Do not copy rules across from expected_since_2.2.0.toml: that ledger -# classifies what 2.3 changed on top of 2.2, and a rule copied without -# checking either over-matches -- hiding a real 2.4 regression behind a -# 2.3-era label -- or never fires at all. Where a 2.4 change is visible -# from several baselines the rule may legitimately appear in more than -# one file, but each copy is checked against its own run rather than -# assumed. -# -# tests/v2/test_differential.py permits exactly one empty ledger, the -# one DEFAULT_BASELINE names -- which is this file. -# -# tests/v2/test_ledger_guards.py and tools/differential/compare.py -# enroll this file, empty, in every roster keyed by ledger filename -# (AGENTS.md release step 8 lists them). The first rule that copies a -# script range, a CJK honorific or a Latin vocabulary has to be -# recorded in its roster or the sweep fails. -# -# There is deliberately no `change = []` line. TOML forbids appending a -# [[change]] table to a statically defined array, so that line would -# block the first entry. - -# --------------------------------------------------------------- -# #383/#479: A SINGLE-LETTER CONNECTIVE JOINS ONLY ON CASE EVIDENCE. -# The first rules this ledger carries, and the cleanest picture of -# the change there is: 2.3.0 is the previous release, so every diff -# below is 2.4's own and nothing else's. Ten names, three rules -- -# the two whose ROLES move, the seven that gain (or exchange) a -# REPORT, and the one whose derived VIEW moves alone. -# -# rules.md#P3 states the rule: a single letter reads as an initial -# where the writing says so -- written as a bare LATIN capital in a -# name that is not written wholly in one case, or, in a name written -# wholly in one case where nothing says so, where the letter is one -# the vocabulary marks as reading both ways -# (`Lexicon.conjunctions_ambiguous`, shipping `{"e"}`). So in a -# one-case name 'e' reads as an initial and reports the call, and -# 'y' joins as the connective it always was. Mixed case is untouched -# in both directions, which every _MUST_NOT_MATCH probe on these -# three rules pins. History: the 2026-09-13 entry of decisions.md#P3. -# -# The whole population this change can reach is the seventeen corpus -# names written wholly in one case that carry a cased single-letter -# connective (measured 2026-09-13 over the 1174-name corpus). -# Ten of the seventeen diff here. Of the seven that do not: -# 'juan garcia y lopez' and 'juan y garcia' already read that way; -# 'johnny y', 'der, y van' and 'juan q. xavier velasquez y garcia -# iii' DO reach the fork -- each is one-case and carries a cased -# single letter -- and it takes the non-member branch, 'y' being no -# `conjunctions_ambiguous` entry, which tags the letter a connective -# exactly as the old veto-free path did and emits no report; and the -# Cyrillic pair reads its 'и' through vocabulary that has decided it -# since 2.0. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#383/#479) a single-letter connective joins only on case evidence" -# The two names whose ROLES move. 'jose e maria santos' read given -# 'jose e maria' at 2.3.0 -- the connective joined its neighbours -# into one part -- and reads given 'jose', middle 'e maria' now that -# the letter is an initial. 'JUAN GARCIA Y LOPEZ' read middle -# 'GARCIA Y', family 'LOPEZ', because 2.3.0 vetoed a join on any -# single-letter connective written as a bare Latin capital; that veto -# is gone, so the connective joins and the family is 'GARCIA Y -# LOPEZ'. One rule and not two: it is one sentence of rules.md#P3 -# read in its two directions, and both names are examples of it. -# -# `_ambiguities` is in the fields because ONE of the two carries the -# new report: 'jose e maria santos' reports conjunction-or-initial, -# 'JUAN GARCIA Y LOPEZ' reports nothing, nothing about 'y' having -# been in doubt. The declaration is the union of the two diffs and -# #452's over-declaration check recomputes it on every run. -# -# Literal-anchored to the two names. The class is wide -- every -# one-case name carrying a cased single-letter connective -- and -# fifteen of its seventeen members do not move a role, so a regex for -# the class would stand ready to explain a regression on any of them. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the -# probes, every one a MIXED-case spelling of a name this rule claims: -# mixed case is where the writing decides, nothing moved there, and a -# rule reaching one would be absorbing a regression in the half of -# rules.md#P3 this change did not touch. -name_regex = "^(?:jose e maria santos|JUAN GARCIA Y LOPEZ)$" -fields = ["given", "middle", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#383/#479) a marked connective letter in a one-case name is reported" -# 'JOSE E MARIA SANTOS', 'JOHN E SMITH', 'john e smith', 'john e -# jones', 'jones, john e', 'e j smith', 'e and e': every role is -# identical -- the letter was already read as an initial, or -# rules.md#P3's three-word carve-out keeps it a name word either way -# -- and what is new is that the CALL is now reported as -# conjunction-or-initial, because a one-case name gives the reader -# nothing to decide it by. 'e and e' EXCHANGES a report rather than -# gaining one: its two 'e's become initial-tagged words, so O5's -# lone-word branch no longer fires and the given-or-family report it -# made at 2.3.0 is replaced by conjunction-or-initial. Its roles and -# its initials are unchanged. -# -# The core's initials() moves for 'john e smith' ('j. s.' -> 'j. e. -# s.'), 'john e jones' and 'jones, john e' (both 'j. j.' -> 'j. e. -# j.'), and `_initials` is deliberately absent from the fields: the -# derived view enters a diff only where every role AND every -# ambiguity kind agrees (#484, compare.py main()), and the report -# moved on each of them, so no run can produce a shape here carrying -# it. That movement was visible nowhere else either, on the morning -# this rule was written: the v1 facade re-derived the connective -# decision from vocabulary and shape instead of reading the parse's -# tags, so `HumanName.initials()` did not move at all and the 1.4.0 -# ledger, which compares the facade alone, had nothing to classify. -# #528 closed that split the same day (2026-09-13) -- the facade -# reads the tags now and moves with the core -- so the 1.4.0 ledger -# carries `fix(#528) the facade's initials follow the parse's -# connective tags` over these three names and 'JUAN Y GARCIA'. -# Nothing here changes: the report still moves on each of them, so -# `_initials` still cannot enter a shape at THIS baseline. -# decisions.md#P3 records the split and decisions.md#R3 its closing. -name_regex = "^(?:JOSE E MARIA SANTOS|JOHN E SMITH|john e smith|john e jones|jones, john e|e j smith|e and e)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------------- -# The `_initials` rule (#484) -- see the block of the same name at the -# end of expected_since_1.4.0.toml for what the pseudo-field is. One -# rule, because exactly one name of this change's population moves the -# derived view with no report beside it to suppress it. -# --------------------------------------------------------------------- - -# `fix(#383/#479) a bare capital connective in an all-upper name -# stops initialing` STOOD HERE until 2026-09-20 and is DELETED, not -# declared dormant. It covered '^JUAN Y GARCIA$' on -# `fields = ["_initials"]`, saying that the unmarked 'Y' joins in a -# one-case name and so contributes no initial: 'J. Y. G.' -> 'J. G.'. -# #461 asks the question of the PART instead, and the 'Y' holds the -# middle part alone with no name word beside it to join, so it -# initials again -- measured 2026-09-20, the 2.3.0 wheel gives -# 'J. Y. G.' from both surfaces and so does the tree. The name stops -# diffing at this baseline and the rule reported EXPLAINED NOTHING. -# Its REASON is wrong now, not merely unreached, so a `dormant` -# declaration would assert something false and deletion is the -# ledger's convention (tools/differential/README.md). -# -# The name keeps a rule at 2.0.0, 2.1.0 and 2.2.0, where it still -# diffs and `fix(#462)` explains it -- with the SURFACE flipped, the -# facade moving now where the core moved before, which each of those -# ledgers records in a dated paragraph. The readmission is 1.4.0 -# parity RESTORED on this name, not a deviation: the 1.4.0 wheel gave -# 'J. Y. G.' all along. decisions.md#R3. -# -# The original comment is kept below, in full, because the rule it -# describes is the one #461 reversed and the argument is the -# provenance the release note cites: -# -# 'JUAN Y GARCIA': 'y' is not marked as reading both ways, so in a -# name written wholly in one case it joins rather than being vetoed -# into an initial -- and rules.md#P3's three-word carve-out then -# refuses the join, leaving given 'JUAN', middle 'Y', family 'GARCIA' -# exactly as they were. What moves is the derived view alone, 'J. Y. -# G.' -> 'J. G.', a connective contributing no initial (rules.md#R3). -# No report: nothing about 'y' was in doubt, which is why this name -# reaches `_initials` where the 'e' names do not. -# -# It has a rule of its own HERE and at no other baseline. At 2.0.0, -# 2.1.0 and 2.2.0 the same name also moves the FACADE's view, in the -# opposite direction and for the opposite reason (#462's fix keeps -# the initial-shaped capital the older facades dropped), and -# `_initials` is one field -- so `fix(#462)` explains both halves -# there as one move, and its comment in those three ledgers says so. -# That fix has shipped at this baseline, so the facade agreed with -# the core here and, when this rule was written, only the core moved. -# Since #528 BOTH surfaces move, and the same way: this baseline's -# wheel gives 'J. Y. G.' from HumanName.initials() and 'J. Y. G.' -# from parse(...).initials(), the tree gives 'J. G.' from each -# (measured 2026-09-13). One `_initials` field and one move, so the -# rule is unchanged and only this sentence is; decisions.md#R3 -# records why the facade started following the tags. -# -# Literal, one name: the shape would be "an all-upper three-word name -# whose middle word is an unmarked single-letter connective", which -# reaches 'JUAN GARCIA Y LOPEZ' (roles, the first rule above) and -# 'juan y garcia' (unchanged) the moment it is written as a regex. -# -# (end of the deleted rule's original comment) - -[[change]] -issue = "fix(#289) a written case contrast decides a bare ambiguous acronym" -# A member of suffix_acronyms_ambiguous written in CAPITALS inside a -# mixed-case name is written the way a credential is written, and is -# taken as one with nothing to spare ('Jack MA' -> given 'Jack', -# suffix 'MA'); one written in any other cased form is written the way -# a surname is written and stays one even with words to spare ('John -# Smith Ma' -> middle 'Smith', family 'Ma'). The same lean reaches the -# post-comma given slot ('Smith, MA' -> family 'Smith', suffix 'MA'; -# 'Smith, Ma' keeps its given), and the comma form's words-to-spare -# test becomes a count of NAME words, which is case-blind -- so -# 'John Smith, MA', 'John Smith, Ed', 'JOHN SMITH, MA' and -# 'john smith, ma' all read the credential where the token count read -# a given name. See rules.md#S2, rules.md#C1 and rules.md#P2. -# -# A name written wholly in one case carries no contrast and does not -# move on the LEAN at all -- 'JOHN SMITH MA', 'ANH DO', 'anh van do' -# and 'Jack Ma' are _MUST_NOT_MATCH probes in -# tests/v2/test_ledger_guards.py. The one-case comma forms in the list -# above are here on the NAME-word count instead, which asks nothing -# about case; that is why they sit beside the lean's own names rather -# than in a rule of their own. -# -# Accepted costs, both recorded rather than repaired: the walk stops -# at a declined pick instead of continuing past it, so a genuine -# suffix standing in front of a name-leaning acronym is never reached -# ('abdul Smith Jr Ma' reads middle 'Jr'), and the lean ends P2's -# particle chain with no words to spare where the count would not -# ('Freiherr von Berg MA', whose own rule further up this file carries -# the reading at 1.4.0). -# -# The Title-case half is witnessed at the comma by two minimal pairs -# rather than argued: 'John Smith, MA'/'John Smith, Ma' both read the -# credential, because two NAME words before the comma decide before -# the case is looked at, and 'Smith Jr., MA'/'Smith Jr., Ma' split, -# because one name word leaves the case to decide and Title-case -# declines. 'Smith Jr., Ma' diffs here for a reading this change did -# NOT move -- the pre-comma restructure v2 has done since 2.0, at -# 1.4.0, and the fork's new report at the 2.x baselines -- while -# 'John Smith, Ma' moves on the COUNT, as its all-caps twin does, and -# has no 1.4.0 diff at all, v1 having read the credential there too. -# Both sit on this rule because the rule is what states which way -# each member of a pair goes. -# -# 2026-09-19, #531: three names join, all of them lone post-comma -# credentials this rule's own second sentence already describes. -# 'Doe, MA' and 'Doe, MA PhD' are 'Smith, MA's family-word twins and -# 'Doe, Dr. MA' is the same slot behind a title; each reads family -# 'Doe' with the credential run in `suffix` -- 'MA', and 'MA PhD' -# on the one that has a run -- where the baseline read given 'MA'. -# They -# entered the corpora with #531's control rows and are NOT #531's -# doing -- verified byte-identical at cc78c960, the #530 merge -# (2026-09-19). No role joined the field list: the three move -# {given, suffix} and, on two of them, `_ambiguities`. -# -# 2026-09-19, #531 review round: 'Doe, Mr. MA PhD' joins them. It is -# 'Doe, Dr. MA' with a credential run behind the member -- the gate -# reads the segment whole, so the member joins the run rather than -# taking the given slot -- and it moves {given, suffix}, both already -# in the field list. It entered the corpora with that round's control -# rows and is NOT its doing: byte-identical at cc78c960 (measured -# 2026-09-19). -# -# Literal-anchored to the measured movers, and ONE SET FOR THE FOUR -# 2.x LEDGERS so the rule says the same thing at each of them. The -# 1.4.0 copy differs by FOUR members now. It holds 'John Prof. MA' -# out: 1.4.0 read a trailing period-marked title as a title itself, -# so that name's diff there belongs to fix(#316)'s rule and not to -# this one. "One set for all five" was written before that carve-out -# existed and was false in these four copies from the day it did -# (2026-09-18). And it holds the three names above out for a -# different reason: at 1.4.0 their diff is already claimed by -# `fix(comma-family) lone post-comma piece routes to suffix/title, -# not first`, which stands far earlier in that file, so adding them -# there would reach a name without explaining it and would put two -# rules on one diff with nothing to arbitrate between them -# (2026-09-19). The set is literal because the class is -# wide (every mixed-case name whose trailing piece is a listed -# ambiguous acronym, plus every comma form with two name words before -# the comma) and a regex for the class would claim the one-case -# spellings that do not move. A name here that does not diff at a -# given baseline is simply one the reading already agreed with there. -# `fields` is per-baseline, the union the run at THAT baseline -# measures (#452). -# 2026-09-27, #544: 'abdul Smith Jr Ma' leaves this alternation. The -# unambiguous 'Jr' in front of 'Ma' anchors it (rules.md#S2's company -# clause), the peel takes both, and P5's reserve declines the join, so -# the accepted cost this rule recorded for it -- middle 'Jr', family -# 'Ma' -- is gone; what the name still diffs on here is claimed by the -# rule that explains it, and a literal left in this one would stand -# ready to explain that cost coming back. -# 2026-10-01, #575: eight names join, the C1 examples of that change. -# Against these baselines none of them is #575's doing: each moves -# on this rule's own count and lean. 'John van Buren, Ed', 'Ortega y -# Gasset, Ed' and 'abdul Salam, Ed' are two name words before the -# comma and flip; 'De La Cruz, Ed', 'de la Cruz, Ma' and 'Van Buren, -# Ed' are one and gain only the comma's report; 'van der Berg, MA' -# is one, and its capitals make 'MA' the credential, as 'Smith, MA'. -# These baselines already read the particle surnames as one name; -# what #575 fixed is this cycle's count, which had not. -# 'Freiherr von Berg, Ed', rules.md#C1's Accepted example for #575, -# reads as these baselines read it and gains only the comma's report. -name_regex = "^(?:Davis Royce, Ed|De La Cruz, Ed|Doe, Dr\\. MA|Doe, MA|Doe, MA PhD|Doe, Mr\\. MA PhD|Freiherr von Berg MA|Freiherr von Berg, Ed|JOHN SMITH, MA|Jack MA|Jack MA\\.|Jack Wei Ma|John Prof\\. MA|John Smith Ma|John Smith, Ed|John Smith, MA|John Smith, Ma|John de Ma|John van Buren, Ed|John van der Berg Ma|Ortega y Gasset, Ed|Smith Jr\\., MA|Smith Jr\\., Ma|Smith, MA|Van Buren, Ed|abdul Salam, Ed|abdul Smith Berg Ma|abdul Smith Ma|de la Cruz, Ma|john smith, ma|van der Berg, MA)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#516) an unlisted dotted acronym is read by position" -# A token of two or more period-separated chunks that no vocabulary -# claims joins the ambiguous credential class by SHAPE and is read by -# POSITION, exactly as a bare listed acronym is: a credential where -# the name has words to spare, a name word where it does not, either -# reading reported (rules.md#S3). Case says nothing here -- the -# periods are the evidence -- which is why 'john smith x.y.z.' moves -# with 'John Smith X.Y.Z.'. The same admission reaches the comma form, -# where the NAME-word count decides ('John Smith, X.Y.Z.' -> suffix -# 'X.Y.Z.'; 'Smith, A.B.' keeps its given and only reports). Paired -# initials ('John Smith, A.B.') are the exception to that count and -# have their own rule below (#563); 'García Márquez, G.J.R.' is the -# exception's accepted boundary, three letters reading by the count. -# -# Two names here restore a reading a PRIOR bundle's vocabulary change -# took away, by a different route than it lost it: 'John Smith E.S.Q.' -# and 'John Smith C.H.A.' fell to a bare positional read when 'esq' -# and 'cha' left SUFFIX_ACRONYMS -# (decisions.md#suffix-acronym-collisions), and the shape class reads -# them as the credentials they always were with no wordlist entry. -# At the baselines where -# that restoration lands on the baseline's own reading, only the new -# report is left and `fields` says so. -# -# The narrow half of the same change: a chunk match no longer makes a -# dotted token a suffix where EVERY matched chunk is a single ASCII -# character, which reaches only the roman numerals -- 'Jack X.Y.I.' -# moves to family for that reason and not for the shape one. -# -# The shapes the vocabulary settles are untouched and are -# _MUST_NOT_MATCH probes in tests/v2/test_ledger_guards.py: a -# whole-token match ('Jack M.A.', 'John Smith Ph.D.', 'Smith, -# A.B.C.'), a surviving chunk claim ('Doe, John Msc.Ed.'), a leading -# dotted run ('X.Y.Z. Smith', 'J.R.R. Tolkien', 'A.B. Vajpayee'), the -# single trailing period that is no shape at all ('John Smith Xyz.'), -# and a digit chunk, which the alphabetic gate refuses ('Bridge (1.4)'). -# -# Literal-anchored to the measured movers, one set for all five -# ledgers, for the reason the fix(#289) rule above gives: the class is -# a SHAPE, and a regex for the shape would claim every dotted token -# the vocabulary already answers for. -name_regex = "^(?:García Márquez, G\\.J\\.R\\.|Jack X\\.Y\\.I\\.|John Smith B\\.Tech\\.|John Smith C\\.H\\.A\\.|John Smith E\\.S\\.Q\\.|John Smith Q\\.W\\.E\\.R\\.T\\.|John Smith X\\.Y\\.Z\\.|John Smith, X\\.Y\\.Z\\.|Smith, E\\.S\\.Q\\.|john smith x\\.y\\.z\\.)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#562) a comma credential run holding a particle chain reads by the name-word count" -# rules.md#C1: "A part read as postnominal [...] is read as its words -# stand, before any join, and that reading is final: [...] no particle -# run, connective join or bound given name reaches into it" (#613, -# 2026-10-06): no particle chain reaches it, so each member's -# capitals settle it and the run reads whole as the credential run -- -# silently since #613, where #562 (2026-10-01) had read the pair by the -# count and reported the flip. The family-comma path had read the part as name text: -# this baseline read given 'PhD', family 'DO DO John Smith', P6 -# attaching the chain. 'John Smith, PhD vd DO' is the same chain -# with a particle that is not a member ('vd', particle and -# unambiguous suffix vocabulary). 1.4.0 read both as suffixes, so -# that ledger has no rule. -# -# Literal. Probes: 'John Smith, PhD DO' (one particle beside no other -# stays settled and silent) and 'Smith, PhD DO DO' (one name word -# before the comma keeps the listing form) are _MUST_NOT_MATCH. -name_regex = "^(?:John Smith, PhD DO DO|John Smith, PhD vd DO)$" -fields = ["given", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#563) paired initials after a comma read as the given name unless a word speaks for them" -# rules.md#C1: "Paired initials are the exception to the count" -- -# two words before the comma may be one surname ('García Márquez, -# G.J.'), so two dotted single letters alone after it stay the given -# name and report the fork, where #516 had read them as a credential. -# "Only an unambiguous suffix word in front of them that is not also -# title vocabulary, or another word the class admits by its dotted shape -# standing in the same part, makes them the credential run": 'John -# Smith, PhD X.Y.' and 'John Smith, X.Y. P.Q.' move to the run, while -# 'De La Cruz, M.J. PhD' keeps its initials, the degree standing behind -# them, and so does 'García Márquez, Ms G.J.', a title in front ("an -# unambiguous suffix word in front of them that is not also title -# vocabulary"), which moves nothing at any baseline. The second period -# is optional ('García Márquez, G.J'). Two pairs speaking only for each -# other make the run and report it behind two name words ('John Smith, -# X.Y. P.Q.'). 2026-10-01, #575: 'De La Cruz, M.J. K.L.' is no longer -# that case -- 'De La Cruz' is one name word, so the count no longer -# reaches it and it reads as 'Cruz, M.J. K.L.' does, given 'M.J.', -# suffix 'K.L.', reporting both; a listed member in front speaks for -# nothing ('García Márquez, Ed G.J.' keeps the family comma, and the -# fix(#531) rule claims what its given part's trailing slot then reads). -# Against a 2.x baseline the names whose roles match the baseline's move -# only the report, which a 1.4.0 comparison cannot see; that ledger -# lists the three role movers alone. -name_regex = "^(?:De La Cruz, M\\.J\\. K\\.L\\.|De La Cruz, M\\.J\\. PhD|García Márquez, G\\.J|García Márquez, G\\.J\\.|García Márquez, PhD G\\.J\\.|John Smith, A\\.B\\.|John Smith, A\\.B\\. Ph\\.D\\.|John Smith, PhD X\\.Y\\.|John Smith, X\\.Y\\. P\\.Q\\.)$" -fields = ["family", "given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the ambiguous credential class reports at slots that were silent" -# Every decision at the trailing suffix slot, the post-comma given -# slot and a tail segment now reports the EXISTING SUFFIX_OR_NAME -- -# no new kind, and the report tracks the FORK BEING CONSULTED rather -# than which way it went, as the trailing slot always has. These names -# gain it with no role moving at any baseline: the fork was there all -# along and was called silently. It is the first time the comma's -# OWN decision is reported (rules.md#C1); P6's attachment fork has -# reported on a family-comma path since 2.3 ('Berg, Jan vd'). C2's -# structural flag was already the first comma-path report of a shape. -# -# 'Steven Hardman, MD, DO, DDS' goes the other way and LOSES its -# comma-structure flag: its third segment now reads as the credential -# run it is, because 'DO' leans credential in a mixed-case name. That -# is the one place this arc quiets a report rather than adding one. -# -# 2026-09-19, #531 review round: 'Doe, MA Smith' joins, and it is the -# post-comma given slot with a NAME WORD behind the member: no role -# moves at any baseline -- the member is the given name here as it -# always was -- and what is new is only the report that emitter makes. -# Its silent twin 'Doe, Dr. MA Smith' puts a title in that first piece -# instead, which the emitter reads rather than the member, and agrees -# with every baseline on every field, so it is in no ledger at all. -# -# `_ambiguities` alone, which keeps `_initials` out of these rows as -# always (#484), and the rule cannot absorb a role diff on any of -# these names for the same reason. Literal-anchored, one set for all -# the 2.x ledgers; the 1.4.0 ledger has no twin, `_ambiguities` being -# a v2 surface that cannot enter a diff below baseline 2.0. -name_regex = "^(?:Doe, MA Smith|J\\.A\\. K\\.D\\.|Jack X\\.Y\\.Z\\.|John Smith J\\.u\\.n\\.i\\.o\\.r\\.|John Smith R\\.A\\.I\\.|Royce, Ed|Smith Jr\\., A\\.B\\.|Smith, A\\.B\\.|Smith, Ma|Steven Hardman, MD, DO, DDS)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#289/#516) the glued CJK honorific peel reads the case lean" -# The suffix-run predicate the peel uses to decline a post-comma run -# (rules.md#W3, asking C1's own vocabulary question) now reads the -# same written-case evidence rule S2 reads, because `one_case` is -# passed to it. Before, one name written with two credentials divided -# two ways: a Kim-prefixed 김민준씨 input peeled its honorific when the -# credential was 'PhD' and did not when it was 'MA', the only -# difference being that 'MA' is an ambiguous acronym whose case lean -# the predicate was not given. -# -# Every name here is a COMPOSED form -- a Latin wrapper and a family -# comma around a CJK name -- which no East Asian writing system -# produces, so all three are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion). They are classified all the same: -# a radar diff this arc INTENDED is one a release note may be written -# from, and an unclassified one is not. -# -# `title` is in `fields` because 'Dr. 김민준씨, MA' had the whole -# string for its family at these baselines, the title included; at -# 2.0.0 and 2.1.0 'Jo김민준씨, DO' moves `title` instead of `given`, -# and the union is what a per-baseline `fields` records (#452). -name_regex = "^(?:Dr\\. 김민준씨, MA|Jo김민준씨, DO|Kim김민준씨, MA)$" -fields = ["title", "given", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -# --------------------------------------------------------------- -# #531: THE GIVEN PART'S TRAILING SLOT. Four rules -- the thirteen -# the credential reading takes, the four the writing declines and that -# gain a REPORT alone, the one `do` spelling the capitals take from -# P6, and the two caseless comma forms on the radar tier. The decision is -# decisions.md#S2's 2026-09-18 pair; rules.md#S2, #S3, #C1 and #P6 -# carry the statements. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#531) a credential ending the given part of a family-comma listing reads as a credential" -# After a family comma, a word of the ambiguous credential class -# ending the given part is now read by the same rule the comma-less -# spelling uses for a word ending the name. The comma has already -# named the family and the first word after it is the given name, so -# the words to spare are there by construction and the count says -# nothing: the word reads as the credential unless its WRITING says -# otherwise. rules.md#S2 states it, rules.md#S3 carries the by-shape -# spelling, and decisions.md#S2 records the decision. -# -# Thirteen names, and they are the class read from every side the -# corpora reach it: the bare member ('Doe, John MA', 'Doe, John BA'); -# its one-case spellings, where the lean is inert and the POSITIONAL -# reading is not ('DOE, JOHN MA', 'doe, john ma', 'DOE, MARY JO MA'); -# the slot behind a title ('Doe, Dr. John MA') and behind an initial -# ('Doe, J. MA'); a run of two members, where the credential the -# member joins was already a suffix so `suffix` GROWS rather than -# filling ('Doe, John MA JD', 'Doe, John MA PhD', 'Doe, John PhD MA', -# 'Doe, John MA Jr'); the slot reached past a middle initial, which -# keeps its own middle ('Doe, John Q. MA', 'DOE, MARY JO MA'); and the -# by-shape member, whose lean is always None so the positional reading -# takes it outright ('Doe, John X.Y.Z.'). -# -# Do not read "thirteen role movers" off that list at every baseline: -# `fields` is the union the run at THIS baseline measures (#452), and -# a name here whose roles the baseline already agreed with contributes -# only its report. 'Doe, John BA' is that name at 2.1.0 and 2.2.0, and -# 'Doe, John MA JD' is the other end of the range, gaining TWO reports -# for its two members. -# -# `_ambiguities` is in the fields because every one of the thirteen -# gains the existing SUFFIX_OR_NAME -- no new kind, and the report -# tracks the FORK CONSULTED rather than which way it went, which is -# the rule fix(#289/#516) above already states for the other slots. -# #452's over-declaration check recomputes the union on every run. -# -# Literal-anchored for the reason the two rules above give, and it is -# sharper here: the class is a SLOT, so a regex for it would claim -# every family-comma name whose given part ends in an ambiguous -# acronym -- including the four the WRITING declines, which keep their -# name reading and have the rule below, and 'Doe, John MA Smith', -# where a name word behind the member ends the run so nothing moves -# and nothing reports. _MUST_NOT_MATCH carries both, with the `do` -# spellings P6 keeps and the third-comma-part name #144's restriction -# still owns. -# 2026-09-19, #533: TWO names join the alternation and the rule does -# not change. 'Doe, J. ba' and 'Smith nee Jones, Jane MA' are this -# slot read exactly as the thirteen above are -- a lower-case member -# ending the given part after an initial, and the same member behind -# a maiden clause that sits in the FAMILY segment, where no trailing -# rule reads the clause's own tail and the given part's slot is -# reached as usual. Both read the same way at 2f57ff21, before #533, -# measured name by name: what grew is the CORPUS, not the rule, and -# a `fix(#533)` rule for them would attribute a #531 reading to the -# wrong change. -name_regex = "^(?:DOE, JOHN MA|DOE, MARY JO MA|Doe, Dr\\. John MA|Doe, J\\. MA|Doe, J\\. ba|Doe, John BA|Doe, John MA|Doe, John MA JD|Doe, John MA Jr|Doe, John MA PhD|Doe, John PhD MA|Doe, John Q\\. MA|Doe, John X\\.Y\\.Z\\.|García Márquez, Ed G\\.J\\.|Smith nee Jones, Jane MA|doe, john ma)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) a member the writing declines keeps its name reading and reports the fork" -# The other direction of the same fork, and the whole of what these -# four names do: no role moves at any 2.x baseline and the decision -# starts being REPORTED. 'Doe, John Ma', 'Doe, John Ed' and -# 'Doe, Mary Jo Ma' carry a Title-cased member in a mixed-case name, -# which is written the way a name is written, so the member stays a -# middle name; 'Doe, John MA Ma' is a two-member run whose last member -# is Title-cased, so the walk declines there and the run stays middle -# text whole. -# -# Reporting a DECLINED fork is #530's stated rule -- the report tracks -# the fork consulted, not the lean -- and AGENTS.md's "A kind is worth -# adding only if a reader would hesitate too" is the standing -# objection it answers. It is also the only part of #531 that adds a -# report without moving a field, which is why it is a rule of its own -# rather than a widening of the one above: `fields` is -# `_ambiguities` alone, so this rule cannot absorb a role diff on any -# of the four. -# -# At 1.4.0 these same four DO move roles -- v1 read all four members -# as post-nominals -- and that divergence is carried there by -# fix(given-part-trailing-slot), whose comment records that #531 -# changed which names it holds rather than retiring it. -# -# 2026-09-28: 'Smith, MD PhD Ma' joins -- a title/suffix dual opening the -# given part is a title there, 'PhD' is the given name, and the -# Title-case 'Ma' ending the part is this slot's declining half, -# reported. No role moves from this baseline; #544 keeps it so -# (rules.md#S2: "A word of both the title and the suffix vocabulary -# standing in the given part's leading title run is a title there and -# speaks for nothing, and no credential behind it speaks for a word of -# this class in that part"). -# -# Literal-anchored: the class is the slot's declining half, and a -# regex for it would claim the thirteen movers above. -name_regex = "^(?:Doe, John Ed|Doe, John MA Ma|Doe, John Ma|Doe, Mary Jo Ma|Smith, MD PhD Ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) capitals take the do collision from the family-comma particle attachment" -# 'Doe, John DO', alone, because the word is alone in the class: `do` -# is the one ambiguous credential that is also particle vocabulary, so -# this slot and P6's attachment (rules.md#P6) want the same word. -# Derek's decision, recorded at decisions.md#S2: "CAPITALS DECIDE FOR -# `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING". An all-caps -# member in a name written in more than one case carries a positive -# credential lean, so the credential reading wins and P6 stands down -# (and since #544 so does an unambiguous credential in front of it, -# 'doe, jane v phd do'); every other spelling attaches exactly as it -# did, with P6's own `particle-or-given` and no second report. -# -# THE PAIRING IS THE ARGUMENT and the accepted cost is half of it. In -# ONE CASE the rule cannot tell 'NASCIMENTO, EDSON ARANTES DO' from -# 'SMITH, JOHN DO' and reads both as the particle -- right about the -# Brazilian record, wrong about the osteopath. Those two, and -# 'Doe, John Do', 'DOE, JOHN DO', 'doe, john do' and -# 'Nascimento, Edson Arantes do', are _MUST_NOT_MATCH probes: this -# rule has no business claiming a single one of them, and each is a -# name #531 measured to be byte-identical before and after. -# -# `fields` is per-baseline and the two halves of this file's set -# differ by which role the particle came OUT of, which is P6's own -# history: {middle, suffix} at 2.0.0 and 2.1.0, where the -# baseline read middle 'DO' because P6's family-comma attachment had -# not shipped, and {family, suffix} at 2.2.0 and 2.3.0, where it had -# and the baseline reads family 'DO Doe'. -name_regex = "^Doe, John DO$" -fields = ["_ambiguities", "family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#531) the trailing slot's positional reading reaches a caseless script" -# '田中, 太郎 MA' and '김, 민준 MA': middle 'MA' -> suffix 'MA', one -# report each. A caseless script answers True to the one-case test, so -# the lean is None -- and at this slot a None lean is not inert, -# because the POSITIONAL reading the lean would have overridden is -# itself the credential. "Caseless is inert" holds for the LEAN and -# not for the outcome, which is the sentence tests/v2/cases.py's two -# rows carry. -# -# Both are COMPOSED forms -- a family comma between a CJK family and a -# CJK given name, which no East Asian writing system produces -- so -# both are `tolerated` case rows on the radar tier -# (decisions.md#cjk-comma-demotion), and their diffs print under -# UNCLASSIFIED (radar) rather than blocking. They are classified all -# the same, on fix(#289/#516)'s stated ground: a radar diff an arc -# INTENDED is one a release note may be written from, and an -# unclassified one is not. -# -# Literal-anchored, and the probes are the CJK comma names that do NOT -# move at this slot: '田中 太郎, MA', '毛泽东, MA' and '마틴 킹, MA' put -# their member in the FIRST post-comma piece, which is #289's slot and -# not this one. -name_regex = "^(?:田中, 太郎 MA|김, 민준 MA)$" -fields = ["_ambiguities", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#533) a credential ending a maiden clause reads as a credential" -# The PR review added one: 'Jane Doe nee Smith V MA', where a -# trailing roman numeral stands between the clause and the member. -# Both stops read the TRAILING word, so the numeral is not the word -# either fork asks about -- the acronym fork stops at 'MA' and the -# 'V' stays maiden text behind it. -# The words a maiden marker takes now end where a trailing credential -# begins, where the rule that reads the name left standing reads the -# word as one -- both as the name is written and as the take would -# leave it (rules.md#M2, decisions.md#M2). `suffix` fills as `maiden` -# shortens, and the fork is reported: the end of a maiden clause was -# the last trailing position in the library where a member of the -# ambiguous credential class was read in silence. -# -# Seventeen names, and they are the class read from every side the -# corpora reach it: the bare member ('Jane Doe nee Smith MA', -# 'Doe, Jane nee Smith MA'); its one-case spellings, where the lean is -# inert and the POSITIONAL reading is not ('JANE DOE NEE SMITH MA', -# 'JANE DOE NEE YO-YO MA', 'jane doe nee smith ma', 'Doe, Jane nee -# Smith ma'); the other markers, the German abbreviation and the -# Polish PHRASE the walk reads no differently for its being three -# words ('Jane Doe geb. Smith MA', 'Maria Kowalska z domu Nowak MA'); -# a member beside another credential, where `suffix` GROWS rather than -# filling ('Jane Doe nee Smith MA JD', 'Jane Doe nee Smith MA PhD', -# 'Jane Doe nee Smith Ma JD' -- whose Title-cased Ma stays in the -# clause while the JD behind it leaves); past a trailing title, which -# the peel steps over ('Jane Doe nee Smith Prof. MA'); the slot -# reached past a middle initial ('Doe, Jane Q. nee Smith MA'); the -# `do` collision, where the capitals decide ('Doe, Jane nee Smith DO' -# beside 'Jane Doe nee Smith do', whose comma-less spelling P6 never -# reaches); the clause that leaves ONE name word, which rules.md#M4 -# makes the family ('John née Jones Smith MA'); and the FLOOR, where -# the member is the only word the marker would otherwise leave and -# only what stands BEHIND it is given up ('Doe, J. nee MA ba' keeps -# maiden 'MA' and reads suffix 'ba'; 'Jane Doe nee King. ba' is the -# same shape with a title-vocabulary first word -- this baseline's -# own reading of it never split the credential at all, so the whole -# diff is #533's and #535 moves nothing here). -# -# Literal-anchored for the reason fix(#531)'s rules give, and it is -# the same reason here: the subject is a SLOT, so a regex for it -# would claim the names whose WRITING declines the word -- which keep -# their maiden reading and have the rule below -- as readily as the -# ones it takes. _MUST_NOT_MATCH carries both directions. -name_regex = "^(?:JANE DOE NEE SMITH MA|JANE DOE NEE YO-YO MA|Jane Doe geb\\. Smith MA|Jane Doe nee King\\. ba|Jane Doe nee Smith MA|Jane Doe nee Smith MA JD|Jane Doe nee Smith MA PhD|Jane Doe nee Smith Ma JD|Jane Doe nee Smith V MA|Jane Doe nee Smith do|John née Jones Smith MA|Maria Kowalska z domu Nowak MA|jane doe nee smith ma)$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) the maiden clause reports the credential it keeps" -# The PR review added six more, and they are the same declining half -# reached by four further routes -- each one a shape where the clause -# CANNOT promise the word a credential reading, so rules.md#M2 keeps -# it. No name word is left AHEAD of the member for a given part's -# trailing slot to be the end of ('Doe, Dr. nee Smith MA'); a -# particle chain below the marker pass would absorb it into the -# family ('Berg, Jane van der nee Smith DO', and 'Jane Doe nee Smith -# DO DO' where the other released member is what would do the -# joining); P5's bound-given join would swallow it into the pair -# ('Berg, abdul nee Jones MA'); the FIRST-WORD FLOOR spares it and -# only an unambiguous credential behind it leaves ('Jane Doe nee MA -# PhD'); and the lean declines it after a comma ('Doe, Jane nee -# Smith Do'). The first four READ as 2f57ff21 read them and gain -# only the report: an earlier round of this branch released all four -# and moved them in silence, which is what the review measured and -# reversed. -# The other direction of the same fork, and the whole of what these -# eight names do at this baseline: no role moves and the decision -# starts being REPORTED. A Title-cased member in a mixed-case name is -# written the way a name is written, so the clause keeps it ('Jane -# Doe nee Smith Ma', 'Jane Doe nee Yo-Yo Ma', 'John née Jones Smith -# Ma', 'Doe, Jane nee Smith Ma'); a member that is the ONLY word -# after the marker stays the maiden name whatever its writing says, -# the marker having announced a name ('Jane Doe nee MA'); a suffix -# word BEHIND the member ends the peel before it reaches the member, -# so the clause keeps it and the clause's own emitter is what raises -# the fork ('JOHN NEE JONES SMITH MA PHD'); and P6's attachment keeps -# every `do` spelling the capitals do not take ('Doe, Jane nee Smith -# do', 'Doe, Jane nee Smith MA do'). -# -# Reporting a DECLINED fork is #530's stated rule -- the report -# tracks the fork consulted, not the lean -- and it is why this is a -# rule of its own rather than a widening of the one above: `fields` -# is `_ambiguities` alone, so it cannot absorb a role diff on any of -# the eight. -# -# 'Doe nee Smith Prof. ba' (a rules.md#M2 Accepted example) is -# the same declining half: one name word is left, so the clause -# keeps 'ba' and reports it, and the title in front of it cannot -# leave without it -- the parent tree d9d80492 reads it as HEAD -# does, so #535 moves nothing. -# Literal-anchored: the class is the slot's declining half, and a -# regex for it would claim the seventeen movers above. -# 2026-09-27, #544: 'Jane Doe Jr. nee Smith Ma' joins, the declining -# half at rules.md#M2's new Accepted boundary: the credential in front -# of the marker speaks for no member of the clause, the Title-case 'Ma' -# is kept, and the clause reports it. -name_regex = "^(?:Doe nee Smith Prof\\. ba|JOHN NEE JONES SMITH MA PHD|Jane Doe nee MA|Jane Doe nee MA PhD|Jane Doe nee Smith Ma|Jane Doe nee Yo-Yo Ma|John née Jones Smith Ma)$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#533) accepted: an unlisted dotted acronym ending a maiden clause is read by position" -# 'Jane Doe nee Smith X.Y.Z.'. A by-shape member carries no writing -# convention to read -- `listed_lean` returns None where the switch -# made the token credential-SHAPED and the vocabulary did not list it -# -- so the positional reading decides alone and the clause gives the -# word up. The same trade #516 recorded at its own slot and #531 at -# the given part's, taken to agree with the comma-less 'John Doe -# X.Y.Z.' and rules.md#S3's shape rule rather than to restore -# anything. -# -# Its own rule rather than one of the movers above, because the -# ARGUMENT is different: every name up there has a writing the lean -# could have read, and this one has none. -name_regex = "^Jane Doe nee Smith X\\.Y\\.Z\\.$" -fields = ["_ambiguities", "maiden", "suffix"] - -[[change]] -issue = "fix(#533) restores the suffix reading a 2.4 retag had moved into the maiden name" -# 'John Smith nee Jones R.A.I.', and it is a RESTORATION rather than -# a change: this baseline reads suffix 'R.A.I.' with maiden 'Jones', -# and so does the tree, so only the report diffs here. What moved in -# between was this unreleased 2.4 cycle. At 2.3.0 the token carried -# `vocab:suffix` and the maiden walk's suffix-piece test stopped at -# it; #516 retags it `shape:acronym` plus `vocab:suffix-ambiguous`, -# it stops being a suffix piece, and the walk took it -- maiden -# 'Jones R.A.I.' on this tree at 2f57ff21, measured. The name was in -# no corpus file, so no gate ever compared it and no 2.4.0 release -# bullet records the move; this change's rows admit it, which turns -# an unrecorded regression into a recorded restoration. -# -# `fields` is `_ambiguities` alone at this baseline and says so -# deliberately: the restoration is invisible from here, and the rule -# that could absorb a role move on this name is the one this file -# must not have. -name_regex = "^John Smith nee Jones R\\.A\\.I\\.$" -fields = ["_ambiguities"] - -[[change]] -issue = "fix(#533) the maiden clause ends at the credential in a native-script name too" -# '田中 太郎 旧姓 佐藤 MA': maiden '佐藤 MA' -> maiden '佐藤', suffix -# 'MA', one report. The walk reads the same rule whatever script the -# clause is written in -- the marker is 旧姓, the member is Latin, and -# neither fact reaches the stop -- so this is the movers' rule above -# with a CJK marker in front of it. -# -# A COMPOSED form, so the name is a `tolerated` row on the radar tier -# (decisions.md#cjk-comma-demotion) and its diff prints under -# UNCLASSIFIED (radar) rather than blocking. It is classified all the -# same, on fix(#289/#516)'s stated ground and the one #531's caseless -# rule follows: a radar diff an arc INTENDED is one a release note -# may be written from, and an unclassified one is not. -# -# Its own rule rather than an alternative in the movers' regex: a CJK -# member in an alternation is claimed by the honorific pin, which -# would demand that alternation be a copy of GLUED_HONORIFICS. A lone -# literal has no alternation to pin. At 1.4.0 and 2.0.0 the marker's -# own rule carries this name instead, the 旧姓 consumption being the -# larger diff from those baselines. -name_regex = "^田中 太郎 旧姓 佐藤 MA$" -fields = ["_ambiguities", "maiden", "suffix"] - -# --------------------------------------------------------------- -# #397 + #461: THE CATALAN LINK JOINS, AND A CONNECTIVE INITIALS -# WHERE IT JOINS NOTHING (2026-09-20). Four rules: the join's ROLES, -# the REPORT a one-case spelling makes instead of joining, and -# #461's two halves, which are one sentence read in its two -# directions and need two rules because a rule listing `_initials` -# may list nothing else (#484). -# -# LAST in the file, and that is narrow-first rather than a -# preference: the join rule declares {family, middle, suffix} and -# every rule above it that reaches one of its five names declares a -# strict subset, so an earlier position is an order-decided contest -# the run refuses (#382). -# -# rules.md#P3 states the join and its two boundaries; rules.md#R3 -# states what a connective contributes. decisions.md#P3 carries the -# count decision and the both-sides scope, decisions.md#R3 the -# criterion and its measured blast radius. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#533/#535) a title in front of the credential the clause gives up" -# 'Jane Doe nee Smith Prof. MA' moves at this baseline for two -# reasons together: #533 already gives 'MA' up to the credential -# reading and reports the fork (`suffix='MA'`, `maiden='Smith -# Prof.'`, SUFFIX_OR_NAME -- rules.md#S2's ambiguity flag: "either -# reading carries the ambiguity flag") before #535 exists -- verified -# against the parent tree d9d80492, which already differs from this -# baseline exactly that way. #535 then reads the trailing title chain -# through the credential, so the title standing in front of 'MA' -# leaves the maiden name too (`title='Prof.'`, `maiden='Smith'`). -# Literal-anchored for the reason fix(#533)'s rules give. -name_regex = "^Jane Doe nee Smith Prof\\. MA$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -issue = "fix(#535) a trailing title ends the maiden clause" -# rules.md#M2: "It takes them up to the trailing run -# of post-nominals and titles that the end of the name reads as if -# the clause were not written" (#601; until #601 this cited the -# sentence it replaced, "a trailing title ends it too") -- the end of -# the name is read through -# the trailing title chain -- rules.md#H5: "successive single words -# that wear the abbreviation shape and are title vocabulary chain -# into the title from the end" -- so the title leaves the clause and -# the credential or numeral in front of it gets the stop it gets with -# the title absent; the pair 'Smith MA Prof.' / 'Smith Prof. MA' now -# agrees. Literal-anchored for the reason fix(#533)'s rules give: the -# subject is a SLOT, and a regex would claim the names whose view -# check keeps the title as readily as the ones that release it. One -# name moved to its own rule above ('Jane Doe nee Smith Prof. MA'), -# because the parent tree d9d80492 already differed from this -# baseline before #535 touched it; the ten names here are ones -# where #535 is the sole cause, verified the same way. Two of them -# are rules.md#M2 Accepted examples: 'Jane Doe nee Smith King.', -# H5's ordinary-surname reach at the end of a clause, and 'Doe nee -# Smith ba Prof.', the title leaving from behind a credential the -# clause keeps (the parent tree reads maiden 'Smith ba Prof.'). ('Jane Doe nee -# Prof. Dr.' and 'Jane van der Berg nee Smith Prof.' need no such -# split at THIS baseline -- #296 and #399 both predate it, so this -# baseline already reads them as HEAD does apart from #535's own move, -# unlike at 2.0.0/2.1.0.) -name_regex = "^(?:Doe nee Smith ba Prof\\.|Jane Doe \\x28nee Smith Prof\\.\\x29|Jane Doe nee Prof\\. Dr\\.|Jane Doe nee Smith King\\.|Jane Doe nee Smith MA Prof\\.|Jane Doe nee Smith Ma Prof\\.|Jane Doe nee Smith Prof\\.|Jane Doe nee Smith V Prof\\.|Mary Smith née Jones Prof\\.)$" -fields = ["title", "maiden", "suffix", "_ambiguities"] - -[[change]] -issue = "fix(#397) the Catalan/Polish link joins two surnames" -# 'i' is connective vocabulary since #397, and a connective counts as -# a name word toward rules.md#P3's three-word carve-out whatever else -# the vocabulary says it is -- which matters here because 'i' is also -# the roman numeral. So a four-word name reaches the threshold and the -# link joins. This baseline read 'Josep Carod i Rovira' as middle -# 'Carod i' with family 'Rovira', and 'Carod i Rovira, Josep' -- where -# the link stands after a family comma -- as family 'Carod Rovira' -# with the link taken as a GENERATION in `suffix`, which is why the -# two move different field sets and are grouped here rather than -# split. The three longer spellings move with the first: 'Josep Carod -# i Rovira III' and 'Josep Lluis Carod i Rovira' the same -# {family, middle}, and 'Josep Carod i de Rovira' the same pair with -# the link joining PAST a particle (middle 'Carod i' -> 'Carod i de', -# family 'de Rovira' -> 'Rovira'). Five names, `fields` the union of -# their two shapes. -# -# All five entered this corpus with #397's own rules.md#P3 example -# lines and case rows; before that they were radar-tier names the -# contract did not answer for, which is why a rule appears at this -# baseline only now. -# -# Literal-anchored to the five. The shape is "a single letter between -# two name words", which reaches every corpus name carrying a bare -# initial and would pre-excuse the opposite reading -- #397's whole -# accepted cost is that some such letters must NOT join. -# _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py carries the wall: -# 'Josep i Rovira' (three words, under the carve-out), 'Carod i' and -# 'John Quincy Smith i' (the link with nothing to its right) and the -# two 'Josep Lluis Carod i ' spellings, whose trailing -# generation run is the fix(#436/#437) rule's at the head of this -# file. -name_regex = "^(?:Carod i Rovira, Josep|Josep Carod i Rovira|Josep Carod i Rovira III|Josep Carod i de Rovira|Josep Lluis Carod i Rovira)$" -fields = ["family", "middle", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#397) the Catalan link reports a connective-or-initial in a one-case name" -# 'JOSEP CAROD I ROVIRA', 'JOSEP LLUIS CAROD I ROVIRA' and 'josep -# carod i rovira'. 'i' joined `conjunctions_ambiguous` with #397, so -# in a name written wholly in one case -- where the writing says -# nothing either way -- the letter reads as an INITIAL and the call is -# reported, exactly as 'e' has read since #383/#479. #527's mechanism, -# a new letter: rules.md#P3's marked subset. -# -# ROLES DO NOT MOVE on any of the three, and that is the point of the -# marked subset rather than an accident of these spellings: reading -# the letter as an initial leaves it the middle-name word this -# baseline already had, so the whole diff is the report. Measured -# against the parent 46651750 on 2026-09-20, the same three names' -# parses are byte-identical before and after the bundle and only -# `ambiguities` moved. The lower-case spellings where the letter WAS -# the generation before -- 'rovira, i', 'john smith i jr', 'maier, amy -# i, jr.', 'josep de carod i rovira' -- do move fields under the same -# marked-subset reading, and no corpus carries one of them, so this -# rule is about the report alone. -# -# No rule at 1.4.0 and that is structural: `_ambiguities` is a v2 -# field and cannot diff against a v1 worker at all. -# -# Literal, three names. _MUST_NOT_MATCH carries the mixed-case -# spellings, where the writing decides the letter and the JOIN is what -# moved, and 'JUAN GARCIA Y LOPEZ', the unmarked letter in the same -# one-case position, which reports nothing. -name_regex = "^(?:JOSEP CAROD I ROVIRA|JOSEP LLUIS CAROD I ROVIRA|josep carod i rovira)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective holding its part alone contributes an initial" -# 17 names, each one a part whose only word is the connective, -# so there is nothing there for it to join and it initials as any -# other name word does -- which is what `family_base` and `given_base` -# have said about the same token since 2.2 (rules.md#R2/#R3). This -# baseline drops the letter; the tree keeps it. Measured from the -# 2.3.0 wheel and from the tree on 2026-09-20: 'Juan de y' 'J.' -> -# 'J. y.'; 'johnny y' 'j.' -> 'j. y.'; 'Garcia y Lopez' 'G. L.' -> -# 'G. y. L.'; 'Juan y Garcia' 'J. G.' -> 'J. y. G.' and 'juan y -# garcia' the lowercase twin; 'John y Jane' 'J. J.' -> 'J. y. J.'; -# 'Carod y de Rovira i' 'C. R.' -> 'C. y. R.'; 'John e Smith' 'J. S.' -# -> 'J. e. S.', with 'John e Smith III', 'John e Smith, III' and -# 'Smith, John e, III, Jr' giving the same pair; 'john e jones, III' -# 'j. j.' -> 'j. e. j.'; 'Jose e Maria' 'J. M.' -> 'J. e. M.'; -# 'Lt.Gov. juan e garcia' 'j. g.' -> 'j. e. g.'; and 'Fritz Freiherr -# und von Bar' 'F.' -> 'F. u.', the German spelling of the same shape. -# BOTH SURFACES move together and to the same string on every one of -# them, the two views reading one mark (#528, decisions.md#R3). -# 'محمد و علي' is the readmission RESTORING 1.4.0 parity rather than -# breaking it: 'م. ع.' here, 'م. و. ع.' in the tree, which is the -# string the 1.4.0 wheel gives. The 1.4.0 ledger's -# `feat(#269) a recognized non-Latin connective contributes no -# initial` explained that name and is DELETED in the same commit as -# this rule, the name having stopped diffing there. -# 'Juan y Garcia née Jones' is in this list at 2.2.0 and 2.3.0 and at -# no earlier baseline: below 2.2 the maiden clause moves ROLES on it, -# so `_initials` never enters its diff and `fix(#418)` explains what -# happened (compare.py's roles-identical guard, #484). -# -# ONE CAUSE, measured, not two. Every name here agreed with this -# baseline's wheel BEFORE the bundle -- the parent 46651750 gives the -# wheel's string on each -- so none of the diff belongs to #383/#479's -# one-case fork or to any older change; the whole of it is #461. -# -# Literal-anchored. The shape is "a part holding one connective and -# nothing else", which no regex over the raw string can state -- the -# reading is the parse's -- and the nearest writable shape, "a name -# carrying a lone cased letter", reaches every bare-initial name in -# the corpora. _MUST_NOT_MATCH carries the wall: 'Jon Dough and' (base -# 'Dough and', a connective with a name word beside it, keeping -# 'J. D.'), 'Juan Velasquez y Garcia' (the joined run, keeping -# 'J. V. G.') and 'Juan Garcia Lopez y'. -name_regex = "^(?:Carod y de Rovira i|Fritz Freiherr und von Bar|Garcia y Lopez|John e Smith|John e Smith III|John e Smith, III|John y Jane|Jose e Maria|Juan de y|Juan y Garcia|Juan y Garcia née Jones|Lt\\.Gov\\. juan e garcia|Smith, John e, III, Jr|john e jones, III|johnny y|juan y garcia|محمد و علي)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#461) a connective with a name word beside it stops contributing an initial" -# 22 names, the other direction of the same sentence: where the -# part holds a name word for the connective to join, the connective is -# doing a connective's work and contributes nothing. This baseline -# initialed it; the tree does not. 'John and Jane Smith' 'J. a. J. S.' -# -> 'J. J. S.'; 'Juan y Eva Garcia' 'J. y. E. G.' -> 'J. E. G.'; -# 'Jose e Maria Santos' 'J. e. M. S.' -> 'J. M. S.'; 'and Jon Dough' -# 'a. J. D.' -> 'J. D.' and 'the and Jon Dough' 't. a. J. D.' -> -# 'J. D.'; 'Rob And Beth Edmunds' 'R. A. B. E.' -> 'R. B. E.'; the two -# 'Dean of Chemistry Robert Johns' spellings 'D. o. C. R. J.' -> -# 'D. C. R. J.'; 'John and Jane Aznar y Lopez' 'J. a. J. A. L.' -> -# 'J. J. A. L.'; 'Mr. and Mrs. John and Jane Smith' 'J. a. J. S.' -> -# 'J. J. S.'; and the three Cyrillic spellings of 'Хосе И Мария -# Сантос' 'Х. И. М. С.' -> 'Х. М. С.', lower case and upper case -# alike. Both surfaces move together and to the same string. -# NINE of these names carry a rule at 2.3.0 and at no earlier -# baseline, and the reason is the roles-identical guard rather than -# the behavior: 'Dean of Chemistry', 'Duke of Edinburgh', 'Duke of -# Wellington', 'John & Jane', 'John of the Doe', 'Juan & Garcia', -# 'Juan and Garcia' and the two 'part1 of ...' strings all move -# `_ambiguities` against 2.0.0 through 2.2.0 -- #449's lone-name-word -# report, which shipped in 2.3 -- and a derived view enters a diff -# only where every role AND every ambiguity kind agrees (#484). Their -# initials move at those baselines too; nothing there can see it. -# 'Duke of Edinburgh' 'D. o. E.' -> 'D. E.', 'Juan and Garcia' -# 'J. a. G.' -> 'J. G.', 'Juan & Garcia' 'J. &. G.' -> 'J. G.'. -# -# ONE CAUSE, measured. Each of these names agreed with this -# baseline's wheel before the bundle -- the parent 46651750 gives the -# wheel's string on every one, the Cyrillic three included, whose 'и' -# has been decided vocabulary since 2.0 and reaches no case fork. So -# none of the diff is #383/#479's and the whole of it is #461. At -# 1.4.0 these names diff too, but they diffed there BEFORE this -# bundle, on the 2.0.0 per-word GROUPING, and that ledger keeps them -# on `fix(initials-per-word) a connective run initials each word` -# with a dated paragraph naming both halves. -# -# CYRILLIC SPELLED OUT, not classed. A `\p{Cyrillic}`-style span -# would declare a script this rule is not about -- the sentence is -# about a PART, in any script -- and _SPAN_BEARING_RULES in -# tests/v2/test_ledger_guards.py records that this rule declares no -# span. -# -# Literal-anchored, for the GAIN rule's reason, and with the same -# wall: 'Jon Dough and', 'Juan Velasquez y Garcia' and 'Juan Garcia -# Lopez y' are _MUST_NOT_MATCH probes here too -- the criterion is the -# PART and never a word count, which is exactly what keeps those three -# fixed. -name_regex = "^(?:Assoc Dean of Chemistry Robert Johns|Dean of Chemistry|Dean of Chemistry Robert Johns|Duke of Edinburgh|Duke of Wellington|John & Jane|John and Jane Aznar y Lopez|John and Jane Smith|John of the Doe|Jose e Maria Santos|Juan & Garcia|Juan and Garcia|Juan y Eva Garcia|Mr\\. and Mrs\\. John and Jane Smith|Rob And Beth Edmunds|and Jon Dough|part1 of The part2 of the part3 and part4|part1 of and The part2 of the part3 And part4|the and Jon Dough|ХОСЕ И МАРИЯ САНТОС|Хосе И Мария Сантос|хосе и мария сантос)$" -fields = ["_initials"] -orders = ["DEFAULT"] - -# #397 review: A LINK INSIDE A MAIDEN CLAUSE STAYS IN THE BIRTH NAME -# (2026-09-20). LAST in the file, narrow-first: this rule declares -# {family, maiden, middle, suffix} and every rule above it that could -# reach any of the three declares a strict subset, so an earlier -# position would be an order-decided contest the run refuses (#382). -# -# rules.md#M2 states the exception; decisions.md#M2's 2026-09-20 -# bullet carries the measurement and why it is fixed in #397's own PR -# rather than filed. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#397) a link inside a maiden clause stays in the birth name" -# Three names, one sentence, three shapes -- `fields` is the union. -# -# The maiden walk has always ended the birth name at the first suffix -# WORD after the marker, and the Catalan link is also the roman -# numeral, so this baseline read maiden 'Puig' and left 'i Soler' -# outside the clause: 'Jane Doe nee Puig i Soler' middle 'Doe i', -# family 'Soler'; 'Doe, Jane nee Puig i Soler' middle 'Soler', suffix -# 'i'. That truncation PREDATES this bundle. What #397 added is -# worse and is why the stop is fixed here: with the link joining, the -# leaked words merged into the CURRENT surname -- measured on the -# parent 46651750 and on this branch's 0fbcaa0b, family 'Doe i Soler' -# and given 'Jane i Soler' respectively -- a word of the birth name -# inside the current one. -# -# The 'y' spelling is the control and never moved at any baseline: -# 'Jane Doe nee Puig y Soler' reads maiden 'Puig y Soler' here and in -# the tree, 'y' being connective vocabulary and no generation. That -# twin is the property invariant in tests/v2/test_properties.py. -# -# The THIRD name arrives from rules.md#M2 rather than from a report: -# 'Smith, John, PhD née Puig Mr. - i Soler' is one of rules.md#M2's -# delimiter examples, added with #538 (under a configured ' - ' it -# reads maiden 'Puig Mr.', by the comma reading since #549). This corpus does not configure that delimiter, -# so the gate parses it with the DEFAULT facade, where the dash is an -# ordinary name word: the link has one on each side and the clause -# keeps 'i Soler' where this baseline left it in the suffix: suffix -# 'PhD i Soler' -> 'PhD', maiden 'Puig Mr. -' -> 'Puig Mr. - i Soler' -# (measured 2026-09-22 at all four 2.x baselines, identical at each). -# That is this rule's own sentence and no part of #538 or #549, whose -# readings need the delimiter declared; {maiden, suffix} is a subset of the -# union above. -# -# Literal-anchored to the four. The shape is "a one-letter connective -# inside a maiden clause", which would stand ready to excuse every -# future regression at this walk -- and the walk's whole design is -# that some such letters must NOT be taken. _MUST_NOT_MATCH carries -# the wall: the i-last control, the generation behind it, and the -# spellings where the letter is an initial. -# -# 'Smith, John, PhD née Puig - i Soler' joined the alternation with -# #538 (2026-09-26): it is rules.md#M2's second live example, and it -# reaches the DEFAULT facade the same way its neighbour does -- no -# delimiter configured here either, so the dash is an ordinary name -# word. Measured at this baseline: suffix 'PhD i Soler' -> 'PhD', -# maiden 'Puig -' -> 'Puig - i Soler' -- the same shape as the -# neighbour's own reading with 'Mr.' dropped from it, unchanged by -# the fix; only the corpus name is new. -name_regex = "^(?:Jane Doe nee Puig i Soler|Smith, John, PhD née Puig Mr\\. - i Soler|Smith, John, PhD née Puig - i Soler)$" -fields = ["family", "maiden", "middle"] -orders = ["DEFAULT"] - -[[change]] -issue = "change(suffix-acronym-collisions) ph leaves the acronym set" -# 'John Smith Ph.': 'ph' left SUFFIX_ACRONYMS with #459's all-caps -# repair (PR #521, 2026-09-13). The fragment let the merged 'Ph. D.' -# token pass the acronym test on its first piece -- the merge itself -# keys on the PH/D regexes of _pipeline/_vocab.py and keeps working -# without it -- and its only unique coverage was a bare trailing -# 'Ph.' with no 'D.' behind it, read as a credential by every release -# since 1.4.0. Out of the set that spelling falls to the positional -# read: given 'John', middle 'Smith', family 'Ph.', suffix ''. -# -# A BEHAVIOR CHANGE rather than a fix, which is what the `change` tag -# says: a deliberate 2.x parity break at every baseline, argued in -# decisions.md's `Excluded (SUFFIX_ACRONYMS — ph)` block beside esq's -# and carried by the 2.4.0 release-log bullet. No corpus name had the -# shape until the cases.py row was written to carry it, which is why -# the removal measured as 0 role movers at review time; 'John Smith -# Ph. D.', 'Smith, Ph. D.' and 'john smith phd' do not move. -# -# Literal to the one corpus name that carries the shape. The CLASS -# the removal moves is wider -- the undotted 'John Smith Ph'/'PH', -# 'Ph.' inside a credential run ('John Smith Ph. MD', 'MD Ph.', -# 'Ph. Jr.'), and the comma form 'Smith, Ph.' from 2.2.0 on -- every -# one a bare ph with no D. behind it (the decisions block lists them, -# measured 2026-09-22). A row carrying one of those arrives -# unexplained here by design: widen this rule to it, do not file it. -name_regex = "^John Smith Ph\\.$" -fields = ["family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #540: MENG AND LAC ARE AMBIGUOUS CREDENTIAL ACRONYMS. The rules -# below: the two names whose family name comes back, the marking's -# cost behind a full name, the same cost after a family comma, the -# lone word after a family comma (given name back, cost for the -# conventional spelling), the Title-case Lac gain that is the cost's -# mirror, and the two that keep the credential and gain a report. No -# rule of the parser changed: the two words joined -# suffix_acronyms_ambiguous, and rules.md#S2 reads them as it reads -# 'ma' and 'ba' -- "A BARE ambiguous acronym is consumed only when the -# name has words to spare". decisions.md#suffix-acronym-collisions -# records the decision, and the removal and the masks it declined. -# -# Every name here is one of #540's own shape-tagged case rows. No -# corpus name written before the change carried a bare trailing -# 'meng' or 'lac' (measured 2026-09-25 over the corpus glob at -# 120033b5), so the gate saw nothing until the rows admitted it. The -# same rules stand in the four 2.x ledgers; the 1.4.0 ledger carries -# the two cost rules and the Title-case Lac rule, 1.4.0 having read -# the other names' roles as the tree does. -# -# At landing (2026-09-25) there were eight rules here, and two more -# costs among them: the credential run behind a suffix comma re-read -# as a family comma, and the chunked dotted 'Wang M.Eng.' read as the -# family. #544 (2026-09-27) reads both shapes again and retired both -# rules. The comma-run names are the fix(#544) rules' at the -# bottom of this file; 'Wang M.Eng.' does not diff here, reading as -# this baseline read it, report and all. -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#540) meng and lac are ambiguous credential acronyms, so a bare trailing Meng or Lac with no words to spare is the family name" -# 'wang meng' and 'tran lac': given 'wang', family 'meng' (and -# 'tran', 'lac') with a suffix-or-name report, where this baseline -# read suffix 'meng' and no family name at all -- reporting nothing -# at 2.0.0 through 2.2.0, and given-or-family at 2.3.0, the -# one-word-name fork, which was the wrong fork. 1.4.0 read the -# family, which is why that ledger has no copy of this rule. -# -# Literal-anchored to the two names. The class is every name whose -# last word is a bare member with nothing to spare, and a regex for -# it would stand ready to explain a regression on the spellings that -# did not move; _MUST_NOT_MATCH in tests/v2/test_ledger_guards.py -# carries 'meng li', where the word LEADS and nothing moved. The -# members are names, not a copy of any wordlist, which is what -# _NOT_A_VOCABULARY_COPY records. -name_regex = "^(?:tran lac|wang meng)$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng behind a full name reads as the family name" -# 'john smith MEng': given 'john', middle 'smith', family 'MEng', with -# a suffix-or-name report, where every release read suffix 'MEng'. -# The marking's stated cost. In a name written in more than one case -# rules.md#S2 reads a member "written in any other cased form that is -# not wholly lower" as the name "even with words to spare", and MEng -# and LAc are conventionally written in exactly that form -- the cost -# 'John Smith Ma' already carries. -# -# 'John Smith MEng PhD' pays the same cost and keeps going: the -# declined pick stops the walk (family 'MEng'), but the unambiguous -# credential behind it is still peeled (suffix 'PhD'), where every -# release read suffix 'MEng PhD' ('MEng, PhD' through 2.2). -# -# A rule of its own rather than a member of the one above, so the -# gate's summary and the release note name the cost under its own -# heading. Literal, two names: the one-case spellings 'john smith -# meng' and 'JOHN SMITH MENG' keep the credential, the count deciding -# where the writing says nothing, and are _MUST_NOT_MATCH probes. -name_regex = "^(?:John Smith MEng PhD|john smith MEng)$" -fields = ["family", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) accepted: a mixed-case MEng after a family comma reads as a middle name" -# 'Smith, John MEng': given 'John', middle 'MEng', family 'Smith', -# with a suffix-or-name report, where every release read suffix -# 'MEng'. The same S2 case reading the rule above pins, arriving -# after a family comma, where C1 has already settled the word count -# and the writing is the only evidence left. -# -# Literal, one name; 'John Smith, MEng' keeps the suffix on C1's -# name-word count, and the all-lower 'Smith, John meng' keeps the -# credential too -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, John MEng$" -fields = ["middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a lone meng or MEng after a family comma reads as the given name" -# 'Smith, meng' and 'Smith, MEng': given 'meng'/'MEng', family -# 'Smith', with a suffix-or-name report, where this baseline read -# suffix and no given name at all -- the third comma path, S2 -# declining the post-comma credential when the word count leaves it -# a name and C1 reading it as the given name. 1.4.0 already read -# the given name here, so that ledger carries no copy of this rule: -# the all-lower spelling restores 1.4.0's reading, and the -# mixed-case spelling is the cost side of the same path. -# -# Literal, two names; 'Smith, MENG' keeps the credential on the -# capitals lean, and 'Smith, John MEng' belongs to the middle-name -# rule above -- both are _MUST_NOT_MATCH probes. -name_regex = "^Smith, (?:MEng|meng)$" -fields = ["given", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a bare trailing meng or lac with words to spare stays the credential and reports the fork" -# 'john smith meng' and 'nguyen van lac': every role is what this -# baseline read -- suffix 'meng', suffix 'lac' -- because a full name -# in front leaves the credential reading available, and what is new -# is the report rules.md#S2 makes at the trailing slot, where "either -# reading carries the ambiguity flag". `_ambiguities` alone, so this -# rule cannot absorb a ROLE diff on either name, which is the point -# of keeping it apart from the two above. -name_regex = "^(?:john smith meng|nguyen van lac)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a credential run after a suffix comma keeps the comma, and the flip reports" -# Every role is what this baseline read -- the whole run after the -# comma a suffix -- and what is new is the report. rules.md#C1: "The -# same count reads a part of two or more words as the credential run -# when every word of it is a suffix word or a word of this class, at -# least one of them of this class", and "A decision either way at this -# comma is reported". 'John Smith, PhD MEng' and 'john smith, phd meng' -# come back here from #540's accepted cost, which had read the comma as -# a family comma; 'Jane Doe, MS LAc' and 'John Smith, MD MEng' open the -# part with a title-and-suffix word, which counts as the suffix word it -# is after a full name, where #540 had read it as a title. -# -# `_ambiguities` alone, so this rule cannot absorb a ROLE diff on any of -# them -- the names whose roles move are the rule below. Literal; the -# probes 'John Smith, PhD MA' (capitals in a mixed-case name already -# settle the run, so it reports nothing new) and the superstring 'Dr. -# John Smith, PhD MEng' are _MUST_NOT_MATCH. -name_regex = "^(?:Jane Doe, MS LAc|John Smith, MD MEng|John Smith, MEng PhD|John Smith, PhD MEng|john smith, phd meng)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a run of ambiguous members after a suffix comma reads by the name-word count" -# The part after the comma is the credential run on C1's count of the -# two name words before it, where this baseline read it as the listing -# form ('John Smith, Ed Ma' read given 'Ed', middle 'Ma'). A -# title-and-suffix word opening the part counts as the suffix word it is -# after a full name ('John Smith, Ms Ma', the accepted cost, Derek -# 2026-09-27; 'john smith, md ma'), and 'X.Y.Z.' is a member by shape, -# whose capitals lean nothing -- rules.md#S2 reads the writing of a -# LISTED member only -- so its run flips too. 1.4.0 read the listed- -# member names as suffixes as well. -# -# 2026-09-28: 'John Smith, PhD Ma' joins, the same run with an -# unambiguous word in front of the member, which the count reads whole -# like the rest. -# -# 2026-10-01, #554: 'John Smith, vd Ma' and 'JOHN SMITH, VD MA' join, -# rules.md#C1's examples for a word of both the particle and the -# unambiguous suffix vocabulary: "A word's particle reading does not -# take it out of the run". This baseline read the whole name as the -# family ('John Smith vd Ma'). 'John Smith, Jr do' joins as the -# contrast to the silent 'John Smith, Jr vd': `do` is a word of the -# ambiguous class, so the count flips the part and reports, where this -# baseline read given 'Jr', family 'do John Smith'. -# -# Literal; `fields` is the union the names move ('title' is the duals'). -# Probes: 'Smith, Ed Ma' (one name word before the comma keeps the -# listing form), 'Smith, Ms Ma' (a dual opening the given part is a -# title) and the superstring 'Dr. John Smith, Ed Ma' are _MUST_NOT_MATCH. -name_regex = "^(?:JOHN SMITH, VD MA|John Smith, Ed Ma|John Smith, Jr do|John Smith, Ms Ma|John Smith, PhD Ma|John Smith, X\\.Y\\.Z\\. MA|John Smith, vd Ma|john smith, md ma)$" -fields = ["given", "middle", "family", "suffix", "title", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) an unambiguous credential in front anchors the member behind it" -# rules.md#S2's company clause: a member written behind an unambiguous -# credential in one run "reads as the credential whatever its writing -# and whatever the count", where the tree before #544 read the Title-case -# MEng as a name word ('John Smith PhD MEng' family 'MEng'; 'Doe, Jane -# PhD MEng' middle 'MEng'), in the maiden clause's reading of the name -# too. The diff against this baseline is what remains once the roles -# come back: the report of the member the peel picked, and -- through -# 2.2 -- the run 1.4.0 and 2.x wrote 'PhD, MEng', which rules.md#R1 -# writes as the writer spaced it (#436/#437), so one rule explains the -# whole of each diff. -# -# 2026-09-28: 'Smith, PhD MEng' joins. The one-word family comma leaves -# its part holding no name word once 'PhD' speaks for 'MEng', so the -# part reads wholly as the credential run -- the fields this baseline -# read -- and the pick the company made reports where it was made. -# -# Literal. Probes: 'Wang Ma PhD' (the credential BEHIND the member speaks -# for nothing) and the superstring 'Dr. John Smith PhD MEng' are -# _MUST_NOT_MATCH. -name_regex = "^(?:Doe, Jane PhD MEng|Doe, Jane nee Smith PhD MEng|Jane Doe nee Smith PhD MEng|John Smith PhD MEng|Smith, PhD MEng)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a degree in front outranks P6's attachment for a particle member" -# 'doe, jane v phd do': the one-case 'do' leans nothing and is particle -# vocabulary, so P6's attachment would take it into the family, and the -# unambiguous 'phd' in front of it outranks that attachment as capitals -# do (rules.md#S2, decided 2026-09-27) -- suffix 'v phd do', reporting -# suffix-or-name where this baseline read the particle into a name part. -# 'NASCIMENTO, EDSON ARANTES DO', with nothing in front, keeps P6's -# reading. Literal; the superstring 'Dr. doe, jane v phd do' and 'doe, -# jane do' are _MUST_NOT_MATCH. -name_regex = "^doe, jane v phd do$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#540) a Title-case Lac behind a particle is the family name" -# 'Nguyen Van Lac': given 'Nguyen', family 'Van Lac', with a -# suffix-or-name report, where every release read family 'Van', suffix -# 'Lac'. The mixed-case name's Title-case member leans 'name' with -# words to spare (S2) -- the gain side of the marking's cost, for a -# Vietnamese name (Lac is the trailing word in native order, Nguyen -# Van Lac). -# -# Literal, one name; the all-lower 'nguyen van lac' keeps the suffix -# (the words-to-spare rule above) and is a _MUST_NOT_MATCH probe, -# along with the superstring 'Dr. Nguyen Van Lac'. -name_regex = "^Nguyen Van Lac$" -fields = ["family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#544) a credential in front anchors a member after a one-word family comma, and the pick reports" -# 'Smith, PhD Ma': family 'Smith', suffix 'PhD Ma', reporting the -# Title-case 'Ma', where this baseline read given 'PhD', middle 'Ma'. -# rules.md#S2's company clause: 'PhD' in front speaks for 'Ma', so the -# part after the one-word family comma holds no name word and reads -# wholly as the credential run, and "a member the company decides -# reports the fork as a counted pick does". 'Smith, PhD MEng', whose -# roles this baseline already read, is the anchor rule's above. -# -# Literal. Probes: 'Smith, PhD MA' (the capitals decide the member, -# nothing new to report), 'Smith, MD PhD Ma' (a dual in the leading -# title run: nothing in the part speaks) and the superstring 'Dr. -# Smith, PhD Ma' are _MUST_NOT_MATCH. -name_regex = "^Smith, PhD Ma$" -fields = ["given", "middle", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#575) a particle surname before a comma is one name word" -# rules.md#C1: "a particle run and the one name word it attaches to -# are one word", so 'van der Berg, PhD' reads as 'Berg, PhD' -# does: family 'van der Berg', suffix 'PhD'. Every release counted -# tokens and split the chain, given 'van', family 'der Berg', with a -# particle-or-given report. -# -# Literal; the probe 'John van der Berg, PhD' (two name words) is -# _MUST_NOT_MATCH. -name_regex = "^van der Berg, PhD$" -fields = ["given", "family", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#564) an unlisted all-caps word after a comma behind two name words is a credential by default" -# rules.md#C1 and S2 (#564): Policy.unlisted_caps_suffixes defaults to -# CapsSuffixes.AFTER_COMMA, so the part after a comma with two or more -# name words before it reads an unlisted all-caps word as the -# credential, alone or in C1's run, where the name contrasts it, and -# reports the call; a lone two-letter word is declined as initials. The all-caps SURNAME convention never writes the -# capitals there. 'John Smith, XYZ' and 'John Smith, LEED AP' are the -# C1 examples (the latter closing the #291 deviation the doc carried); -# 'The Rt Hon Kenneth Clarke QC MP, HMG' is a radar corpus name. -# 'John Smith, PhD XYZ' is a caps word in C1's run beside a listed -# credential, and 'García Márquez, MJ JK' two words of two capitals, -# read as #563 reads two pairs (both C1 examples). 'John Smith, RAI' and 'Ahmad Jayadi, CHA' move -# from this baseline's family comma (given 'RAI'/'CHA', the words -# removed from the vocabulary by #342) to the credential run. -# -# Literal; the probes 'Smith, XYZ' (one name word), 'García Márquez, -# MJ' (a lone two-letter word) and 'John Smith XYZ' (no comma: the -# trailing position stays opt-in) are _MUST_NOT_MATCH. -name_regex = "^(?:Ahmad Jayadi, CHA|García Márquez, MJ JK|John Smith, LEED AP|John Smith, PhD XYZ|John Smith, RAI|John Smith, XYZ|The Rt Hon Kenneth Clarke QC MP, HMG)$" -fields = ["title", "given", "middle", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word before a family comma stays in the family" -# rules.md#P2: in the part before a family comma "a particle that is -# also suffix vocabulary heads the name word behind it, whatever case -# the name is written in". This baseline read family 'SMITH MA', -# suffix 'VD', peeling the particle from between two family words. -# -# Literal; the probe 'SMITH VD, JOHN' (nothing behind the particle, so -# the suffix stands) is _MUST_NOT_MATCH. -name_regex = "^SMITH VD MA, JOHN$" -fields = ["family", "suffix"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a particle-suffix word between credentials after a family comma stays in the run" -# rules.md#P6's third exception: a lone such word with a credential -# read in front of it and another behind "does not end the name, and -# keeps the post-nominal reading in any case, reported as S2's -# credential fork" -- whatever made the credential in front, so 'DOE, -# JANE MA VD PHD' stays whole as 'DOE, JANE PHD VD MA' does. This -# baseline split both: 'MA' read as a middle name, and 'VD' as the -# family's particle where 'MA' stood in front of it. -# -# Literal; the probe 'DOE, JANE PHD VD' (nothing behind, so the -# attachment holds) is _MUST_NOT_MATCH. -name_regex = "^(?:DOE, JANE MA VD PHD|DOE, JANE PHD VD MA|doe, jane phd vd ma)$" -fields = ["middle", "family", "suffix", "_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#573) a never-given particle opening the given part takes the member behind it" -# rules.md#S2: a never-given particle first after a family comma is no -# given name, and the member behind it "is the surname the particle -# heads, not a word to spare", a chained particle run included ('VD DE -# LA'). This baseline read the same family; the report is the fork the -# given slot now decides, which it did not consult then. -# -# Literal; the probe 'SMITH, VAN MA' (an ambiguous particle, which can -# be a given name, keeps it, and the member reads as the credential) -# is _MUST_NOT_MATCH. -name_regex = "^(?:SMITH, VD DE LA MA|SMITH, VD MA|smith, de ma)$" -fields = ["_ambiguities"] -orders = ["DEFAULT"] - -[[change]] -issue = "fix(#585) a decomposed initial keeps its whole first letter" -# initials() took a token's first CODE POINT, which in a name typed -# decomposed (NFD) is a base letter without the combining mark that -# completes it: 'e' + U+0301 initialled 'e.', and the katakana 'ジ', -# 'シ' + the combining dakuten U+3099, initialled 'シ.'. The initial is -# now the shortest start of the word that composes to the composed -# word's first character, written in the form the writer used -# (rules.md#R3), so both surfaces give the composed spelling's initial -# kept decomposed. A composed name's initials do not move, and nor do -# those of an NFD name whose first letters do not decompose (#542's -# 'josé garcía' rows), which is why the rules corpus's two other NFD -# rows are the only names here. -# -# Roles do not move; the derived view alone does, so `_initials` is -# the one field. Literal names rather than a shape: what selects them -# is "a name typed decomposed whose initial letter decomposes", and no -# regex over the raw text says that without copying Unicode's -# decomposition data. -name_regex = "^(e\u0301mile zola|マイケル シ\u3099ャクソン)$" -fields = ["_initials"] - -# --------------------------------------------------------------- -# #594: HALFWIDTH KATAKANA IS KATAKANA. The script table classifies -# the halfwidth kana block U+FF65-U+FF9F as Script.KATAKANA, so what -# reaches full-width katakana reaches halfwidth now: the kana license -# (rules.md#W4) reads 山田 タロウ family-first as it reads 山田 エミ, the -# 间隔号 divides between two halfwidth kana (rules.md#T3), and H2's -# opening-abbreviation shape declines a period-marked katakana word -# because katakana has no initials (rules.md#H2's Accepted clause), -# closing the limit decisions.md#cjk-full-stops recorded. A WHOLLY -# halfwidth name keeps the declared order, as wholly full-width -# katakana does, so 'ヤマダ タロウ' is parity. -# -# Literal-anchored to the names: no corpus line held a halfwidth kana -# before this change, so the movers ARE the eight case rows #594 added, -# less the parity one -- five here, two in the title rule. A shape -# spelling -- any halfwidth kana -- would be a hand copy of the span -# the canonical CJK rule already carries. LAST in the file: each diff -# reported unexplained, or unclassified on the radar, on the run -# before these rules existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#594) halfwidth katakana takes the kana license and the 间隔号" -# '山田 タロウ', '山田 ダイスケ' and '山田 ペーター' read family 山田 where every -# release read it given, the unspaced '山田タロウ' reads family where it -# read given, and 'タロウ·ヤマダ' divides where it was one given token. -# `_ambiguities` moves on the two single-token readings 2.3 reported -# given-or-family (#449, new in 2.3): 'タロウ·ヤマダ' now reads as two -# tokens, and the license decides '山田タロウ'. -name_regex = "^(?:山田 タロウ|山田 ダイスケ|山田 ペーター|山田タロウ|タロウ\\u00B7ヤマダ)$" -fields = ["given", "family", "_ambiguities"] - -[[change]] -issue = "fix(#594) a period-marked halfwidth katakana word is not a title" -# 'タナカ. John' and 'マイケル.' read title where their full-width twins -# already read a name word (#cjk-full-stops, new in 2.3); both read -# given now, as the twins do. -# Tolerated input (an edge full stop on a CJK word), so these are -# radar names and the rule classifies them for the release note rather -# than for the exit code. -# `_ambiguities` is the v2 surface's: the lone 'マイケル.' reports -# given-or-family as 'マイケル.' does, where the title reported nothing. -name_regex = "^(?:タナカ\\. John|マイケル\\.)$" -fields = ["title", "given", "_ambiguities"] - -# --------------------------------------------------------------- -# #596: A VOICING MARK TAKES THE SCRIPT OF THE KANA BEFORE IT. The -# four kana voicing marks U+3099-U+309C sit in the hiragana block, and -# NFC composes one only where a precomposed voiced kana exists, so the -# mark in 'ア゙イ' (ア + U+3099) or 'ア゛イ' (the spacing U+309B) stood in -# a katakana word as hiragana and the word took the kana license -# (rules.md#W4) -- family-first, where a wholly-katakana name keeps -# the declared order. Classification now deletes each mark that -# follows a kana, so the word reads as its base's script. The -# same names on hiragana or beside kanji ('あ゙い たろう', '山田 ア゙イ') -# are parity: their reading never depended on the mark. -# -# Literal-anchored to the names: no corpus line held an uncomposable -# voicing mark before this change, so the movers ARE the three case -# rows #596 added. LAST in the file: each diff reported unexplained -# on the run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#596) a voicing mark does not make a katakana word kana-licensed" -# 'ア゙イ タロウ' and 'ア゛イ タロウ' read given ア゙イ where 2.1.0-2.3.0 -# read it family, and 'マイケル ア゙イ' reads given マイケル where the -# licensed neighbour made it the family. -name_regex = "^(?:ア\\u3099イ タロウ|ア\\u309Bイ タロウ|マイケル ア\\u3099イ)$" -fields = ["given", "family"] - -# --------------------------------------------------------------- -# #597: THE HALFWIDTH CORNER BRACKETS ARE A DEFAULT NICKNAME PAIR. -# 「」 (U+FF62/U+FF63) join DEFAULT_NICKNAME_DELIMITERS beside 「」, and -# the v1 facade's nickname_delimiters gains halfwidth_corner_brackets, -# so a clause they enclose is lifted out as the nickname (rules.md#N1) -# where every release left it in the name -- a middle where spaced, -# part of one given word where glued. With the -# unclassified bracket gone, '山田 「タロー」 タロウ' is kanji plus katakana and -# takes the kana license; the unspaced '山田「タロー」太郎' divides at the -# extracted clause; 'John 「Jack」 Smith' shows the pair is not -# script-gated. -# -# Literal-anchored to the names: no corpus line held a halfwidth -# bracket before this change, so the movers ARE the three case rows -# #597 added. LAST in the file: each diff reported unexplained on the -# run before this rule existed (2026-10-03). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#597) halfwidth corner brackets enclose a nickname" -# The clause moves to nickname in all three, out of middle in the two -# spaced names and out of the one given word in '山田「タロー」太郎'; the -# two Japanese names also take their script order, family 山田. -# `_ambiguities` moves on '山田「タロー」太郎', which 2.3 read as one name -# word and reported given-or-family (#449); extracted, the Han -# script order decides the two pieces and nothing is reported. -name_regex = "^(?:山田 「タロー」 タロウ|山田「タロー」太郎|John 「Jack」 Smith)$" -fields = ["given", "middle", "family", "nickname", "_ambiguities"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). The clause these names -# carried at this baseline is gone: `maiden` empties and its words read -# where the given part reads any word. What else differs is the given -# part's own reading of those words -- the spaced credential run -# (#436/#437), the link join (#397), P6's attachment of a trailing -# particle to the family (#405), the capitals lean (#289) and the -# trailing title chain (H5) -- each as the same text with the marker -# replaced by an ordinary word reads it. -issue = "fix(#601) a marker in the given part after a family comma is an ordinary word" -name_regex = "^(?:Berg, Jane van der nee Smith DO|Berg, abd née Jones|Berg, abdul nee Jones MA|Doe, Dr\\. nee Smith MA|Doe, J\\. nee MA ba|Doe, Jane Q\\. nee Smith MA|Doe, Jane nee Puig i Soler|Doe, Jane nee Smith|Doe, Jane nee Smith DO|Doe, Jane nee Smith Do|Doe, Jane nee Smith MA|Doe, Jane nee Smith MA Prof\\.|Doe, Jane nee Smith MA do|Doe, Jane nee Smith Ma|Doe, Jane nee Smith PhD MEng|Doe, Jane nee Smith do|Doe, Jane nee Smith ma)$" -fields = ["_ambiguities", "family", "given", "maiden", "middle", "suffix", "title"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04): a tail part is read -# wholly as suffixes, the marker and its words with it. -issue = "fix(#601) a marker in a part after a suffix comma is an ordinary word" -name_regex = "^(?:Smith, John, MD - née Jones Smith|Smith, John, PhD née Jones)$" -fields = ["maiden", "suffix"] - -[[change]] -# rules.md#M2: "In the given part after a family comma, in a part after a -# suffix comma, and behind a title, a suffix word or a connective, the -# marker is an ordinary word" (#601, 2026-10-04). Where a credential -# stands before the marker it also starts #602's run -- rules.md#S2: "A -# credential after the name core starts a run to the end of its part" -- -# which takes the marker and the words after it in. -issue = "fix(#601/#602) a marker behind a title, a suffix word or a connective is an ordinary word" -name_regex = "^(?:Dr\\. nee Smith PhD Prof\\.|Jane Doe Jr\\. nee Smith|Jane Doe Jr\\. nee Smith Ma|Jane Doe PhD nee Smith|Jane and née Jones)$" -fields = ["_ambiguities", "family", "given", "maiden", "suffix"] - -[[change]] -# 'Jane Doe nee Smith VI Prof.': rules.md#M2's numeral example -# behind a title, which the take reaches since #610 shared its -# candidate words with the peel (2026-10-04). -# rules.md#M2: "It takes them up to the trailing run of post-nominals and -# titles that the end of the name reads as if the clause were not -# written" (#601, 2026-10-04), and "The run so found ends the name: its -# words are the suffixes and titles that reading makes them, and no join -# reaches into it". The old walk's release check kept these words in the -# clause where a join it modelled might take them; the run is bound now. -issue = "fix(#601) the clause ends at the clause-free name's trailing run, which the take consumes" -name_regex = "^(?:Jane Doe nee Smith DO DO|Jane Doe nee Smith VI Prof\\.|Jane van der Berg nee Prof\\. King\\. MA|Jane van der Berg nee Smith Prof\\.|John nee Prof\\. ba MA)$" -fields = ["_ambiguities", "maiden", "suffix", "title"] - -[[change]] -# rules.md#M2's take "always takes the first word after it, the marker -# having announced a name" (#601, 2026-10-04): a lone roman numeral is no -# suffix word, so it is the maiden name, where the old walk read the -# numeral fork from the marker and declined. Decided with #601 (decisions.md#M2). -issue = "fix(#601) the first word after the marker is the maiden name" -name_regex = "^Jane Smith née V$" -fields = ["_ambiguities", "family", "maiden", "middle", "suffix"] - -[[change]] -# rules.md#S2: "A credential after the name core starts a run to the end -# of its part" (#602, 2026-10-04): every later word of the part is a -# suffix except a title word, which reads as a title, and an absorbed -# name word is reported. -# 'Jane van der Berg née Jr Jones' (radar): the marker declines on -# the suffix word after it (rules.md#M2) and stays a word, so 'Jr' -# stands behind a name core and its run takes 'Jones' (2026-10-04). -issue = "fix(#602) a credential after the name core starts a run to the end of its part" -name_regex = "^(?:Eric H\\. Holder Jr\\. Attorney General|Jane van der Berg née Jr Jones|John Smith PhD Jones|Smith, John PhD Jones)$" -fields = ["_ambiguities", "family", "middle", "suffix", "title"] - -[[change]] -# rules.md#S2: "leaving it nothing to join: then it counts as a name -# word of its own" -# (#627, 2026-10-10): a lone particle with the credential right behind -# it counts toward the run's two name words, so it is the family and -# the run takes the rest. -issue = "fix(#627) a lone particle with the run right behind it is the surname" -name_regex = "^John von PhD Jones$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -[[change]] -# rules.md#P4: "of several such words the last does, so no particle -# chains inside the titles" (#624, 2026-10-10): the second -# title-particle is a title and the name starts behind it. -issue = "fix(#624) of two title-particles at the head the second leads the chain" -name_regex = "^Freiherr St John Smith$" -fields = ["_ambiguities", "family", "given", "title"] - -[[change]] -# rules.md#H5: "chain stops in front of the trailing run of suffixes and titles," -# (#623, 2026-10-10): after a family comma a trailing title is the -# title, and P6 attaches the particle in front of it. -issue = "fix(#623) after a family comma the chain stops before a trailing title" -name_regex = "^Smith, J[a-z]+ van( der Berg)? Prof\\.$" -fields = ["_ambiguities", "family", "middle", "title"] - -[[change]] -# rules.md#C2: "a part past the second comma holds no name word" (#636, -# 2026-10-11): group makes no particle chain in such a part, so a title -# word behind a particle run there is a title. -issue = "fix(#636) a part past the second comma makes no particle chain" -name_regex = "^John Smith, Jr\\., van der Berg Prof\\.$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#637, -# 2026-10-11): after a family comma the bound join declines the -# trailing title word H5's run takes, so it is read as the title. -issue = "fix(#637) the bound join declines a trailing title after a family comma" -name_regex = "^Berg, abdul Prof\\.$" -fields = ["given", "title"] - -[[change]] -# rules.md#P5: "title word a trailing rule reads as a title" (#640, -# 2026-10-11): in a part past the second comma the bound join declines -# a title word, which C2 reads as a title. -issue = "fix(#640) the bound join declines a title word past the second comma" -name_regex = "^John Smith, Jr\\., abdul Dr\\. Smith$" -fields = ["suffix", "title"] - -[[change]] -# rules.md#M2 reads the clause-free name, whose credential starts #602's -# run -- rules.md#S2: "A credential after the name core starts a run to -# the end of its part" -- and the take consumes that run: maiden 'Smith', -# suffix 'PhD Smith' (2026-10-04). -issue = "fix(#601/#602) a credential in the clause starts a run the take consumes" -name_regex = "^Jane Doe nee Smith PhD Smith$" -fields = ["_ambiguities", "family", "middle", "suffix"] - -# --------------------------------------------------------------- -# #604: THE IRISH AND MALAY PATRONYMIC PARTICLES. -# 'ó' and 'ní' join the never-given particles, 'ua', 'binti' and -# 'binte' the ambiguous half, so each opens the surname behind it -# (rules.md#P1, P2) where every release read it as a middle name. -# 'Ó.' written WITH its period stays the initial (rules.md#P7), so of -# the five P7 examples only the bare boundary moves; the two R4 case -# repair examples move for the vocabulary itself. -# -# Literal-anchored to the names: the movers are the P7 boundary and -# the two R4 examples, each reported unexplained on the run before -# this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#604) the Irish and Malay patronymic particles" -# The particle leaves the middle and opens the family: 'Ó Pérez', -# 'Ó MURCHÚ', 'binti navalamar'. -name_regex = "^(?:Juan Ó Pérez|SEÁN Ó MURCHÚ|ina binti navalamar)$" -fields = ["middle", "family"] - -# --------------------------------------------------------------- -# #606: SALUTATIONS AND TITLES IN OTHER LANGUAGES. -# TITLES gains salutations and offices for languages it had none -# for, among them three that are also surnames written last -# wherever they are borne (graaf, greve, knight), shipped on the -# graf precedent (rules.md#H1). The one corpus mover is the -# H1 Accepted example for that cost: the surname-first form -# without a comma gives the surname-borne word to the title. -# -# Literal-anchored to the name: it was reported unexplained on the -# run before this rule existed (2026-10-04). -# --------------------------------------------------------------- - -[[change]] -issue = "fix(#606) salutations and titles in other languages" -# The given name becomes the title: 'Greve'. -name_regex = "^Greve Anna$" -fields = ["title", "given"] - -[[change]] -# rules.md#C1: "A credential opening the part makes it the postnominal -# part however it goes on" (#603, 2026-10-04): where the first suffix -# word after the comma starts S2's run, every word behind it reads as -# that run reads, so a full name before the comma keeps its positional -# read ('John Smith, PhD Jones') and a one-word name is the family -# ('Doe, PhD Jones'). The undeclared dash of the delimiter rows is one -# more word of the run; a title/suffix dual opens nothing. -issue = "fix(#603) a credential opening the part after the comma makes it the postnominal part" -name_regex = "^(?:Doe, PhD Jones|John Smith, Ph\\. D\\. Jones|John Smith, PhD Jones|Smith, RN - CRNA|Steven Hardman, RN - CRNA)$" -fields = ["_ambiguities", "family", "given", "middle", "suffix"] - -[[change]] -# rules.md#C2: "a word of the title vocabulary there that is not also -# suffix vocabulary is a title" (#603, 2026-10-04): a part past the -# second comma reads its title words as titles, and a part of title and -# suffix words is recognized rather than flagged ('Eric H. Holder, Jr., -# Attorney General'). 1.4.0, like 2.0 through 2.3, read them as -# suffixes. -issue = "fix(#603) a title word in a part past the second comma is a title" -name_regex = "^(?:1 & 2, 3 4 5, Mr\\.|Andrew Perkins, Jr\\., Col\\. \\x28Ret\\x29|Eric H\\. Holder, Jr\\., Attorney General|Eric H\\. Holder, Jr\\., Secretary of State|John Smith, Jr\\., Lt\\.Gov\\.|John, Smith, Dr\\.|Smith, John, Prof\\.)$" -fields = ["_ambiguities", "suffix", "title"] - -[[change]] -# rules.md#C1: "Wherever this rule counts the words before the comma, a -# particle run and the one name word it attaches to are one word", and -# "A connective join and a bound given-name pair are not one word here" -# (#575): C1's one count before the comma, read off classify's tags -# since #613 (2026-10-06), so a connective surname is a whole name and -# the comma followed by a title reads it positionally. This baseline -# read it through assign's second count over group's joined pieces, -# one piece and so the family. -issue = "fix(#613) a connective surname before the comma counts its words" -name_regex = "^Ortega y Gasset, Dr\\.$" -fields = ["family", "given", "middle"] - -[[change]] -# rules.md#S2: "The run at a name's trailing slot is read ONCE, once -# the connectives have joined (P3) and before the particle chain (P2) -# and the bound given-name join (P5) are made" and "neither join -# reaches a word the run took" (#614, 2026-10-07), and rules.md#H5: "a -# particle chain (P2) stops in front of a trailing title rather than -# taking it into the family name". This baseline made the chain first -# and the title was a word of the family name. -issue = "fix(#614) a trailing title is read before the particle chain" -name_regex = "^John van der Berg Prof\\.$" -fields = ["family", "title"] - -[[change]] -# rules.md#N1: "a comma inside it kept as written — the comma divides -# nothing there" (#653, 2026-10-12): a comma inside a delimited -# clause is the clause's text, as 1.4.0 kept it. This baseline split it -# off as a separator, and the nickname rendered without it. -issue = "fix(#653) a delimited clause keeps its comma" -name_regex = "^(?:Jane \\(Janey, Jo\\) Smith|Maria Kowalska \\(z, domu Nowak\\))$" -fields = ["nickname"] - -[[change]] -# rules.md#M3: "a marker followed by a comma is no marker" (#653, -# 2026-10-12): once a clause kept its comma, a spaced one after the -# marker would have passed M3's word-after test, so the comma now -# unmarks it and the clause stays a nickname, as 1.4.0 read it. This -# baseline dropped the comma and read the clause as the maiden name. -issue = "fix(#653) a comma after a marker unmarks it" -name_regex = "^(?:Jane \\(née , Jones\\) Smith|Jane \\(née ,Jones\\) Smith)$" -fields = ["maiden", "nickname"] - -[[change]] -# rules.md#H2: "An accented Latin, Greek or Cyrillic letter written as a -# base letter plus a combining mark (NFD) is the same letter as the -# composed one" (#652, 2026-10-12): the abbreviation shape ended its -# word at the base letter, so a decomposed `inž.` was no abbreviation -# and became the given name. The rules example is the one name here. -issue = "fix(#652) a decomposed accented abbreviation is an abbreviation" -name_regex = "^Dr\\. inz\u030c\\. Janez Novak$" -fields = ["title", "given", "middle"] diff --git a/tools/differential/expected_since_2.4.0.toml b/tools/differential/expected_since_2.4.0.toml deleted file mode 100644 index cb7923b5..00000000 --- a/tools/differential/expected_since_2.4.0.toml +++ /dev/null @@ -1,63 +0,0 @@ -# Ledger for baseline 2.4.0 -- what changes for a user upgrading from -# the previous minor. Same rule grammar as the other ledgers: `issue`, -# `name_regex` and `fields` are every one of them required. Neither -# narrowing key may stand on its own -- #451 banned a rule with -# `fields` and no regex, #456 banned the reverse -- so a rule here -# narrows by name AND by role, never by just one. -# -# Opened empty the day 2.4.0 shipped (AGENTS.md release step 8), and it -# is opened before it has anything to say because DEFAULT_BASELINE and -# the ledger are coupled: _allowlist_for treats a missing file as a -# hard error, on the reasoning that an absent ledger classifies nothing -# and would make every diff report as unexplained. So the baseline -# could not advance to 2.4.0 without this file. -# -# Do not copy rules across from expected_since_2.3.0.toml: that ledger -# classifies what 2.4 changed on top of 2.3, and a rule copied without -# checking either over-matches -- hiding a real 2.5 regression behind a -# 2.4-era label -- or never fires at all. Where a 2.5 change is visible -# from several baselines the rule may legitimately appear in more than -# one file, but each copy is checked against its own run rather than -# assumed. -# -# tests/v2/test_differential.py permits exactly one empty ledger, the -# one DEFAULT_BASELINE names -- which is this file. -# -# tests/v2/test_ledger_guards.py and tools/differential/compare.py -# enroll this file, empty, in every roster keyed by ledger filename -# (AGENTS.md release step 8 lists them). The first rule that copies a -# script range, a CJK honorific or a Latin vocabulary has to be -# recorded in its roster or the sweep fails. -# -# There is deliberately no `change = []` line. TOML forbids appending a -# [[change]] table to a statically defined array, so that line would -# block the first entry. - -[[change]] -# rules.md#N1: "a comma inside it kept as written — the comma divides -# nothing there" (#653, 2026-10-12): a comma inside a delimited -# clause is the clause's text, as 1.4.0 kept it. This baseline split it -# off as a separator, and the nickname rendered without it. -issue = "fix(#653) a delimited clause keeps its comma" -name_regex = "^(?:Jane \\(Janey, Jo\\) Smith|Maria Kowalska \\(z, domu Nowak\\))$" -fields = ["nickname"] - -[[change]] -# rules.md#M3: "a marker followed by a comma is no marker" (#653, -# 2026-10-12): once a clause kept its comma, a spaced one after the -# marker would have passed M3's word-after test, so the comma now -# unmarks it and the clause stays a nickname, as 1.4.0 read it. This -# baseline dropped the comma and read the clause as the maiden name. -issue = "fix(#653) a comma after a marker unmarks it" -name_regex = "^(?:Jane \\(née , Jones\\) Smith|Jane \\(née ,Jones\\) Smith)$" -fields = ["maiden", "nickname"] - -[[change]] -# rules.md#H2: "An accented Latin, Greek or Cyrillic letter written as a -# base letter plus a combining mark (NFD) is the same letter as the -# composed one" (#652, 2026-10-12): the abbreviation shape ended its -# word at the base letter, so a decomposed `inž.` was no abbreviation -# and became the given name. The rules example is the one name here. -issue = "fix(#652) a decomposed accented abbreviation is an abbreviation" -name_regex = "^Dr\\. inz\u030c\\. Janez Novak$" -fields = ["title", "given", "middle"] diff --git a/tools/differential/shapes.py b/tools/differential/shapes.py index b0d175f6..865c22b3 100644 --- a/tools/differential/shapes.py +++ b/tools/differential/shapes.py @@ -4,10 +4,10 @@ Shapes 1-3 are docs/usage.rst's three given-first arrangements. Shapes 4-5 are the family-first arrangements: their names are only -supported UNDER their order, so the differential compares them under -it and simply not at all below min_baseline -- "we don't care what -they do in other orders" is structural, not a ledger exception -(name_order and both constants shipped in 2.0.0). +supported UNDER their order, so the snapshot reads them under it, and +compare.py does not compare them at all below min_baseline -- "we +don't care what they do in other orders" is structural (name_order +and both constants shipped in 2.0.0). The notations are canonical SKELETONS, not exhaustive grammars: which bucket a written word lands in (a lone post-comma credential reading @@ -29,27 +29,10 @@ `order` to name. Their `min_baseline` (2.1.0, when East Asian support shipped) is -DOCUMENTARY rather than a skip trigger, and that is a real asymmetry -with shapes 4/5: those two skip below their baseline because Policy -itself (and the order it carries) did not exist yet -- an order- -bearing shape sent to a pre-Policy worker has nothing to apply. -Shapes 6/7 need no such protection: `order` is None here, so at -1.4.0/2.0.0 these strings are compared as opaque tokens rather than -skipped -- the old baselines DO parse them, just without the script -rules, and the resulting diffs are already the classified East Asian -arc (fix(cjk-*) et al.), not an unhandled gap. compare.py's -min-baseline skip (main(), the `dropped`/`kept` split) is gated on -`shapes_by_id[shape].order is not None` precisely so this holds: it -drops an order-bearing entry below its shape's minimum, and leaves an -order-None entry -- shapes 1-3 as much as 6/7 -- to compare at every -baseline regardless of min_baseline. (Verified 2026-09-01: before -that gate existed the skip was keyed on `shape is not None` alone, -which happened to be harmless only because shapes 1-3 all carry -min_baseline "1.4.0", the earliest baseline the gate ever runs, so no -order-None shape had ever actually triggered it. Shapes 6/7, the -first order-None shapes with a later minimum, would have -- silently -dropping the "already classified" comparison this paragraph -describes. The `order is not None` gate closes that.) +DOCUMENTARY: compare.py skips only an ORDER-bearing entry below its +shape's minimum, since an order sent to a pre-Policy worker has +nothing to apply. An order-None string is compared at every baseline; +the old baselines parse it, just without the script rules. `order` is the PUBLIC constant name on the nameparser package, as a string, because the consumer that matters is the generated baseline diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py index 93194929..18b8ebfe 100644 --- a/tools/differential/snapshot.py +++ b/tools/differential/snapshot.py @@ -7,7 +7,7 @@ tests/v2/test_snapshot.py fails when the tree's readings stop matching it. A change that moves readings re-records, and the PR diff of snapshot.jsonl is the list of names it moved -- one line per name, so -the review reads it directly and nothing has to be classified. +the review reads it directly and nothing has to be classified (#664). uv run python tools/differential/snapshot.py # check, grouped uv run python tools/differential/snapshot.py --record # re-record @@ -17,6 +17,11 @@ git ref, which is how a release asks what moved since the last tag. --tree PATH reads with the nameparser package of another checkout (the corpus is always this one's), for comparing two trees. + +A corpus that loses names shows them as removed, so a truncated file +cannot pass for a clean run. compare.py builds its baseline rows with +row() too, so a released wheel's readings and the tree's are compared +in one shape. """ from __future__ import annotations @@ -34,12 +39,20 @@ RECORD_COMMAND = "uv run python tools/differential/snapshot.py --record" V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", "maiden") +V1_FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", + "maiden") #: The facade's v1 field each 2.0 field corresponds to. -V1_FOR = dict(zip(V2_FIELDS, ("title", "first", "middle", "last", "suffix", - "nickname", "maiden"))) +V1_FOR = dict(zip(V2_FIELDS, V1_FIELDS)) +#: The keys a corpus line may carry. A misspelled key is refused rather +#: than ignored: ignoring it would drop the shape the line declares and +#: read the name under the default order with nothing saying so. +_CORPUS_KEYS = frozenset({"name", "tests", "shape"}) + +Entry = tuple[str, "str | None"] -def _shapes() -> dict[int, Any]: +def load_shapes() -> dict[int, Any]: + """SHAPES from shapes.py, loaded by path: tools/ is not a package.""" spec = importlib.util.spec_from_file_location("_shapes", HERE / "shapes.py") assert spec and spec.loader module = importlib.util.module_from_spec(spec) @@ -47,23 +60,100 @@ def _shapes() -> dict[int, Any]: return module.SHAPES # type: ignore[no-any-return] -def corpus_entries() -> list[tuple[str, str | None]]: - """Every distinct (name, order) in the corpora, sorted.""" - orders = {k: s.order for k, s in _shapes().items()} - found: set[tuple[str, str | None]] = set() - for path in sorted(HERE.glob("corpus*.jsonl")): - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - entry = json.loads(line) - if isinstance(entry, str): - found.add((entry, None)) - else: - found.add((entry["name"], orders.get(entry.get("shape")))) +def _corpus_line(path: Path, line: str, orders: dict[int, str | None]) -> Entry: + raw = json.loads(line) + if isinstance(raw, str): + return raw, None + if not (isinstance(raw, dict) and isinstance(raw.get("name"), str)): + raise SystemExit(f"{path.name}: corpus line is neither a JSON string " + f"nor an object with a string 'name': {line!r}") + if unknown := sorted(set(raw) - _CORPUS_KEYS): + raise SystemExit(f"{path.name}: a corpus line has unknown key(s) " + f"{unknown}; expected only {sorted(_CORPUS_KEYS)}: " + f"{line!r}") + tests = raw.get("tests") + if tests is not None and not (isinstance(tests, list) + and all(isinstance(t, str) for t in tests)): + raise SystemExit(f"{path.name}: a corpus line's 'tests' must be a " + f"list of strings, not {tests!r}: {line!r}") + shape = raw.get("shape") + if shape is None: + return raw["name"], None + # bool is an int subclass and hash(True) == hash(1): unexcluded, + # {"shape": true} would read the name under shape 1's order. + if isinstance(shape, bool) or shape not in orders: + raise SystemExit(f"{path.name}: a corpus line's 'shape' must name a " + f"shapes.py entry {sorted(orders)}, not {shape!r}: " + f"{line!r}") + return raw["name"], orders[shape] + + +def corpus_entries() -> list[Entry]: + """Every distinct (name, order) in the corpora, sorted. The same + string tagged with two shapes is two entries, one per order.""" + orders = {k: s.order for k, s in load_shapes().items()} + paths = sorted(HERE.glob("corpus*.jsonl")) + if not paths: + raise SystemExit(f"no corpus files found in {HERE}") + found: set[Entry] = set() + for path in paths: + lines = [ln for ln in path.read_text(encoding="utf-8").splitlines() + if ln.strip()] + if not lines: + raise SystemExit(f"{path.name} is empty") + found.update(_corpus_line(path, ln, orders) for ln in lines) return sorted(found, key=lambda e: (e[0], e[1] or "")) -def read(entries: list[tuple[str, str | None]]) -> list[dict[str, Any]]: +def row(name: str, order: str | None, v2: dict[str, Any] | None, + facade: dict[str, str] | None) -> dict[str, Any]: + """One snapshot line from one name's readings. + + `v2` is the 2.0 surface as {role: value, "_ambiguities": [...], + "_initials": ...}; `facade` is HumanName's as {v1 field: value, + "_initials": ...}, or None where it is not read (an order-bearing + entry, whose facade is never consulted). Empty values are left + out, so a reading that gains a field shows as one added key. With + no v2 surface (a 1.4 baseline) the facade's reading IS the row, + under role names; otherwise the facade is recorded only where it + differs.""" + out: dict[str, Any] = {"name": name} + if order: + out["order"] = order + if v2 is None: + assert facade is not None + out.update({f: v for f in V2_FIELDS if (v := facade[V1_FOR[f]])}) + if facade["_initials"]: + out["initials"] = facade["_initials"] + return out + out.update({f: v for f in V2_FIELDS if (v := v2[f])}) + if v2["_ambiguities"]: + out["ambiguities"] = list(v2["_ambiguities"]) + if v2["_initials"]: + out["initials"] = v2["_initials"] + if facade is not None: + differs = {f: facade[V1_FOR[f]] for f in V2_FIELDS + if facade[V1_FOR[f]] != v2[f]} + if facade["_initials"] != v2["_initials"]: + differs["initials"] = facade["_initials"] + if differs: + out["facade"] = differs + return out + + +def facade_view(r: dict[str, Any]) -> dict[str, Any]: + """A row as the facade alone reads it, for comparing against a + baseline that has no 2.0 surface.""" + facade = r.get("facade", {}) + out = {k: r[k] for k in ("name", "order") if k in r} + for f in (*V2_FIELDS, "initials"): + if v := facade.get(f, r.get(f, "")): + out[f] = v + return out + + +def read(entries: list[Entry]) -> list[dict[str, Any]]: + """The tree's row for every entry.""" import nameparser from nameparser import HumanName, Parser, Policy @@ -73,31 +163,20 @@ def read(entries: list[tuple[str, str | None]]) -> list[dict[str, Any]]: if order not in parsers: parsers[order] = Parser(policy=Policy(name_order=getattr(nameparser, order))) p = parsers[order].parse(name) - row: dict[str, Any] = {"name": name} - if order: - row["order"] = order - row.update({f: v for f in V2_FIELDS if (v := getattr(p, f) or "")}) - if kinds := sorted({a.kind.name for a in p.ambiguities}): - row["ambiguities"] = kinds - if initials := p.initials() or "": - row["initials"] = initials + v2 = {f: getattr(p, f) or "" for f in V2_FIELDS} + v2["_ambiguities"] = sorted({a.kind.name for a in p.ambiguities}) + v2["_initials"] = p.initials() or "" + facade = None if order is None: - # The facade is a translation of the same parse; record it - # only where it reads the name differently. hn = HumanName(name) - v1 = hn.as_dict() - facade = {f: v1[V1_FOR[f]] or "" for f in V2_FIELDS - if (v1[V1_FOR[f]] or "") != (getattr(p, f) or "")} - if (hn.initials() or "") != initials: - facade["initials"] = hn.initials() or "" - if facade: - row["facade"] = facade - rows.append(row) + facade = {k: v or "" for k, v in hn.as_dict().items() if k in V1_FIELDS} + facade["_initials"] = hn.initials() or "" + rows.append(row(name, order, v2, facade)) return rows -def key(row: dict[str, Any]) -> tuple[str, str]: - return row["name"], row.get("order", "") +def key(r: dict[str, Any]) -> tuple[str, str]: + return r["name"], r.get("order", "") def dump(rows: list[dict[str, Any]]) -> str: @@ -108,10 +187,10 @@ def load(text: str) -> dict[tuple[str, str], dict[str, Any]]: return {key(r): r for r in map(json.loads, text.splitlines()) if r} -def _flat(row: dict[str, Any]) -> dict[str, str]: +def _flat(r: dict[str, Any]) -> dict[str, str]: out = {k: ("|".join(v) if isinstance(v, list) else v) - for k, v in row.items() if k not in ("name", "order", "facade")} - out.update({f"facade.{k}": v for k, v in row.get("facade", {}).items()}) + for k, v in r.items() if k not in ("name", "order", "facade")} + out.update({f"facade.{k}": v for k, v in r.get("facade", {}).items()}) return out From f75197b19829acc5dc9faa9db5f711c0ba0120bd Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:21:24 -0700 Subject: [PATCH 4/9] Rewrite the harness docs around the snapshot (#664) tools/differential/README.md keeps the invocation traps and corpus provenance and drops the ledger, tier and roster sections (995 -> ~400 lines). AGENTS.md's Release-log claims paragraph now draws claims from `snapshot.py --since ` (or compare.py for a tag older than the snapshot), and release checklist steps 1 and 8 shrink to reading that report and moving DEFAULT_BASELINE. docs/design: a decisions.md entry records the retirement with its measurements and accepted costs; mechanisms.md keeps the headings decisions.md cites but marks LEDGER-RULE-SEPARATION, CROSS-RULE-OUTCOME-PINS and SENTINEL-SET-OVER-MATCH-CHECK retired, moves RECORDED-ROSTERS' exemplar to the snapshot, and drops the ledger-only field note; rules.md's tolerated-marker paragraph and two W3/S3 passages stop describing a radar tier. compare.py regains --corpus (now a path list through snapshot.py's loader), which docs/migrate.rst offers users for their own names. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 143 +----- docs/design/decisions.md | 22 + docs/design/mechanisms.md | 26 +- docs/design/rules.md | 14 +- docs/migrate.rst | 5 +- nameparser/_facade.py | 4 +- tests/v2/cases.py | 8 +- tests/v2/test_differential.py | 2 +- tools/differential/README.md | 834 +++++---------------------------- tools/differential/compare.py | 22 +- tools/differential/snapshot.py | 7 +- 11 files changed, 199 insertions(+), 888 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5738310d..377944b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three committed contributor docs carry the parser's normative rules and their re **Counting claims.** A bare count in prose is either an assertion or a liability, keyed by who observes its staleness: asserted counts (a test holds the number) fail CI at change time — the useful kind; dated snapshots ("51 sites at spec time") cannot go stale; standing present-tense prose counts are the forbidden class — promote to an assertion, add a date, or state the invariant and let a test count. After changing how many times something runs, sweep for counts, not for the thing's name. -**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the differential gate's classified summary, be verified against rules.md examples, or -- for a view the gate cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. The classified summary covers the CONTRACT tier plus whatever radar diffs a rule classifies; a radar corpus's unmatched diffs are listed under UNCLASSIFIED (radar) and are not in it, so a claim quantified from the summary alone is silent about them. The reverse holds for `corpus_cldr.jsonl` (2026-10-12): its fictional renderings DO enter the summary wherever a rule classifies them, so count them out of a release figure (tools/differential/README.md, the CLDR section). The gate compares the seven role fields, `_ambiguities` and -- since #484 -- `initials()`, under the `_initials` pseudo-field; so an initials-only change DOES show in the classified summary. But that pseudo-field sees only the names whose initials moved WHILE EVERY ROLE AND EVERY AMBIGUITY KIND STAYED PUT -- main()'s roles-identical guard keeps it out of any diff a role or a report is already in -- so a count taken from it is a FLOOR on initials movement, not the population: measured 2026-09-02 at 2.1.0 → tree on the v2 surface, 83 of the 1120 compared ENTRIES (1116 distinct names; seven entries carry a declared order rather than the default, and three strings are compared under more than one) changed their `initials()` string and only 28 were visible under `_initials`, the other 55 having moved a role as well. A bullet about how many names' initials changed still needs the recompute recipe. `capitalized()` and any other render view stay invisible to the gate (decisions.md#R4, #R3), and for those the first two sources still cannot reach a claim: a gate run is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Per-rule ledger toml comments asserting PARSER behavior cite rule IDs under the excerpt discipline, and since 2026-10-10 tests/v2/test_doc_citations.py checks them as it checks code. A ledger comment is copied from ledger to ledger and reworded by nothing: measured 2026-10-10 by running the check as it now stands over the tree before it (8e6e524a, master after #631), 27 quotes were stale and 5 colon citations quoted nothing, beyond the five #631's review found by hand -- most in a shape the colon-only check could not read (`rules.md#X -- "..."`, `rules.md#X's Accepted clause ("...")`, a second quote chained by "and"). A rule's statement and its `Accepted:` clauses are quotable, its example lines are not; `rules.md#H` names the H section, whose Background is quotable; a decisions.md citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all. Free prose is for ledger mechanics only (owned by tools/differential/README.md). +**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the snapshot's diff since the last release, be verified against rules.md examples, or -- for a view the snapshot cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. `uv run python tools/differential/snapshot.py --since v` prints every corpus name the release moved, grouped by changed field; for a release tagged before the snapshot existed (2.4.0 and earlier), `compare.py --baseline ` prints the same report against the PyPI wheel, with the Latin-only count. The snapshot records the seven role fields, ambiguity kinds and `initials()` for every name, so an initials count is read off it directly. `corpus_cldr.jsonl`'s fictional renderings are in it: count them out of a release figure, or say the figure includes them. `capitalized()` and any other render view stay invisible to the snapshot (decisions.md#R4, #R3), and for those the first two sources cannot reach a claim: the snapshot is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Code comments citing rule IDs follow the excerpt discipline, checked by tests/v2/test_doc_citations.py: a rule's statement and its `Accepted:` clauses are quotable, its example lines are not; `rules.md#H` names the H section, whose Background is quotable; a decisions.md citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all. **Writing the user docs (docs/*.rst) has its own AGENTS.md too.** `docs/AGENTS.md` carries the style the docs are written in — tables as indexes, subheadings per task, measured claims, recipe doctests — distilled from PR #588. It loads the same way as the docs/design/ one below; if your tool does not do nested discovery, read it before editing a `.rst` file under docs/. @@ -115,52 +115,13 @@ uv run sphinx-build -b html docs dist/docs # And check for open Dependabot PRs on uv.lock (namedivider-python) and merge them # first — pyproject floats >=0.4 so fresh installs get the newest namedivider, but # CI's ja-extra job installs from uv.lock and only tests what the lock pins -# 1. Run the differential gate at EVERY baseline that has a ledger with rules, -# and confirm each exits 0. The dynamic check only runs for the ledger -# matching the baseline being run, so a ledger left out here gets no -# dynamic dormancy check AND no over-declaration check (#452) at all -# -- and neither is hypothetical: #452 found 3, 5 and 6 stale -# declarations sitting in the three files. See -# tools/differential/README.md. The baselines are DERIVED from the -# ledger glob rather than enumerated, so a ledger opened at step 8 is -# covered at the next release without anyone remembering to add a line -# (the same reason compare.py's `--corpus` default is a glob: one that -# has to be asked for by name is one that stops being run). Redirect -# each run to a file, never pipe it -- under zsh a pipe replaces the -# exit code with the pipe's, so a failing run reads as a passing one: -# for f in tools/differential/expected_since_*.toml; do -# b=${f##*expected_since_}; b=${b%.toml} -# uv run python tools/differential/compare.py --baseline "$b" > "/tmp/gate-$b.txt"; echo "$b exit $?" -# done -# #503 (filed 2026-09-03, fixed on the branch 2026-09-05, closed by its -# merge): this step enumerated three -# commands against four ledgers, so `--baseline 2.1.0` got no dormancy, -# over-declaration or recorded-shape check from any releaser, the shape -# gap "latent" only while that ledger's roster sections were empty; the -# bundle that closed it ARMED it first -- rows now sit in -# expected_since_2.1.0.toml's _WATCHED_DIFFS section (31 on the day; -# `len(compare._WATCHED_DIFFS['expected_since_2.1.0.toml'])` is the -# live figure) -- and the loop above is the fix it chose. -# Cost is not the obstacle and no fix may be argued from it: measured -# 2026-09-03, a whole run is under a second at every baseline and all -# four back to back are about two seconds (0.43s at 1.4.0, 0.56s at -# 2.2.0, 1.97s for the four; the recompute lives ONCE, in decisions.md's -# rule-order arc under the Declined bullet on precise per-name contest -# detection, and the 2026-09-03 #497 timing finding points there and -# retires every cost argument in -# this repo's differential prose). -# The classified summary each run prints is the source for the release -# notes' behavior claims, including the count of changed names that are -# Latin-only. -# Exit 0 no longer means every diff is classified, and two blocks print -# without failing the run. Since the tier split (#468) a radar corpus's -# unmatched diffs print under UNCLASSIFIED (radar); since the watched-shapes -# arc (#501) a watched shape on a radar name that MOVED prints under -# MOVED SHAPE (radar). Read both. A radar diff worth a release note gets -# promoted (a cases.py row plus a shape tag) or classified with a rule -# BEFORE the log is drafted, not after; a moved shape is a finding, and if -# the move is intended the row is re-recorded in _WATCHED_DIFFS in the -# commit that moved it, saying why there. +# 1. Read what the release moved: `uv run python tools/differential/snapshot.py +# --since v`, or, while the last tag predates the snapshot +# (2.4.0), `uv run python tools/differential/compare.py --baseline `. Both print every corpus name that reads differently, grouped +# by changed field. That report is the source for the release notes' +# behavior claims (see Release-log claims above); a move nobody can +# account for is a finding to settle before the log is drafted. # 2. Clear PRE_RELEASE in nameparser/_version.py — it carries 'dev' through the # cycle (see step 9), so releasing is setting it to ''. VERSION should already # be the version you are shipping; bump it here only if step 9 was skipped. @@ -178,86 +139,14 @@ uv run sphinx-build -b html docs dist/docs # MILESTONE=$(gh api repos/derek73/python-nameparser/milestones --jq '.[] | select(.title=="vX.Y.Z") | .number') # gh api -X PATCH repos/derek73/python-nameparser/milestones/$MILESTONE -f state=closed # gh api -X POST repos/derek73/python-nameparser/milestones -f title="Next Release" -# 8. Open the next cycle's ledger: set DEFAULT_BASELINE in -# tools/differential/compare.py to the version just released, and create -# tools/differential/expected_since_.toml. Until both are done a -# bare compare.py measures against two minors back while reporting the -# previous one, and _allowlist_for hard-errors on the missing file. -# tests/v2/test_ledger_guards.py sweeps every expected_since_*.toml (#333), so -# the new ledger's hand copies of _SCRIPT_RANGES and of the honorific and -# Latin vocabularies are checked from the day the file lands. All three -# rosters find copies by their SYNTAX -- a span class or an alternation -- -# so a copy spelled some other way is not undeclared but unseen. What -# covers those is _CORPUS_CLAIMS, which records what every rule claims -# -- its regex's corpus reach, its roles, and which names -- and so -# needs no notion of how a copy is spelled. -# SIX rosters are keyed by FILENAME and checked by EQUALITY, so a new -# ledger must be enrolled in every one of them on the day it lands, even -# empty -- each fails loudly and names itself, but that is six separate -# red runs if you add them one at a time. Four sit in the test module -# and two in tools/differential/compare.py. Faster than reading this -# list: create the ledger, move DEFAULT_BASELINE, and run -# tests/v2/test_ledger_guards.py once -- every roster still missing the -# file fails by name. (Opening the 2.4 cycle found _ORDER_EXEMPTION_EFFECT -# that way; it landed with f478fb0 after this list was last written.) -# Two more are keyed by rule CONTENT and apply only where such a rule -# exists. -# Required, by filename: -# - _SPAN_BEARING_RULES: add the filename, mapped to the set of issue -# tags whose rules carry a script-span class (empty set if none). -# - _CORPUS_CLAIMS: a ledger with no entry hard-fails. Add the -# filename mapped to {} while the ledger is empty, then one _Claim -# per rule as rules land. The test prints the values to record. A -# _Claim's digest moves whenever the corpus text does, so a pure -# RENAME of a corpus name lands here as names-unchanged, -# roles-unchanged, digest-moved; a count that GREW means the rule -# absorbed something. -# - _CROSS_RULE_WINNERS: add the filename mapped to {} while the -# ledger has no contested name. Equality since #452, which found -# that a ledger with no rows had been indistinguishable from one -# needing none -- the two 2.x ledgers whose shapes #452 moved -# between rules had no section at all. -# - _WATCHED_DIFFS, in tools/differential/compare.py rather than the -# test module: add the filename mapped to {} while the ledger has -# no watched name, and the same for _RECORDED_DIFFS beside it. The -# run itself refuses, pre-worker, a ledger missing from either -# shape roster -- an empty section is a statement, a missing one is -# nobody having looked -- so the guard and the run agree on it. -# - _ORDER_EXEMPTION_EFFECT: add the filename mapped to [] while the -# ledger has no order-decided contest. It is the recorded negative -# control for precedes_narrower -- every contest measured with -# exemptions ignored -- so a row appears when two rules land that -# file order alone arbitrates, whether or not one declares it. -# Conditional, by rule content: -# - _HONORIFIC_SOURCES: if the ledger has a CJK honorific rule, add a -# substring of its issue (keyed that way, not by tag) mapped to the -# constant it copies -- but only if no existing key already matches -# that issue. A retroactive ledger can repeat an older one's rule -# verbatim (fix(#271/#272/#298) is in both today), and every rule -# must match exactly one key. -# - _LATIN_ALTERNATION_SOURCES: same, for a rule copying a Latin -# vocabulary (maiden markers, ambiguous acronyms). An alternation -# matching no key fails as undeclared -- add it, or record it in -# _NOT_A_VOCABULARY_COPY if it copies nothing. -# A ledger may also carry [[never]] entries -- shapes that must stay -# unexplained, documented in tools/differential/README.md. compare.py's -# validate_exclusions checks them at startup and -# tests/v2/test_ledger_guards.py records what each one silences -- -# which rules would claim it with exclusions off, and how much corpus it -# captures -- in _EXCLUSION_EFFECT. That IS an enrollment: an entry -# added, or its name_regex/fields/examples edited, moves the record and -# must be re-recorded deliberately, the same forcing function -# _CORPUS_CLAIMS applies to rules. (A `why`-only edit does not move it.) -# A rule that explains nothing must carry `dormant = ""`: the -# CI guard checks regex reach, compare.py checks whether it explained a -# diff, and both fail undeclared. Removing the last name a rule reached is -# therefore a decision, not a silent no-op. +# 8. Set DEFAULT_BASELINE in tools/differential/compare.py to the version +# just released, so a bare compare.py measures against it. # 9. Open the next cycle's VERSION: bump VERSION in nameparser/_version.py to # the minor now being worked, and set PRE_RELEASE = 'dev'. The tree then says # what it is building rather than what it last shipped -- docs/conf.py reads # __version__ directly, so Read the Docs' "latest" is otherwise stuck on the # previous release for the whole cycle. -# It also restores the differential harness's version tell: with the tree and +# It also restores compare.py's version tell: with the tree and # the baseline wheel both reporting the same release, only the __file__ path # distinguishes them, which is the weak state test_tell_rejects_a_module_ # loaded_from_the_checkout documents. A dev tree differs on both halves. @@ -278,7 +167,7 @@ logging.getLogger('HumanName').setLevel(logging.DEBUG) The library has two layers: `nameparser/config/` (data) and `nameparser/parser.py` (logic). -**Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it, plus a sixth that applies the same not-a-guess standard to specific WORDS rather than to a script (#308's honorific peel, below). Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana — full-width or halfwidth, the halfwidth block U+FF65–FF9F being katakana in the script table since #594 — is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order, and a Japanese reading written in katakana (legacy halfwidth data spells Japanese names that way too) cannot be told from one, so the script settles nothing and a caller who knows opts in through `script_orders`. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps the declared order and never segments — the orthography names the convention, with the divider carrying the signal that stands the script's family-first reading down, so the default reads it in its written order and a declared `FAMILY_FIRST` reverses it like any other name; pure katakana reaches the same reading by the opposite route, its script settling nothing so the declared order stands. And a listed CJK honorific glued to the END of a name token is split off it (#308) — 田中さん is 田中 plus さん — on the same orthography-settles-it test, narrowed for the glued position: an entry peels only where it can never end a name, so 씨/님/さん/様/先生 peel while 양/군/氏/博士/殿 stay spaced-only (김지양 and 田中博士 are names, and ~90 Japanese surnames end in 殿) and 君 is in NEITHER set (王君 is a complete Chinese name), though its kana spelling くん peels. Like the nakaguro's tokenize-level separation described next, it is reached by neither policy opt-out — but for its own reason: the vocabulary carries the license itself rather than borrowing the script's, so `segment_scripts` has nothing to say about it. Since #312 it also crosses the 间隔号, which still stops the surname split standing right beside it: it answers where a name DIVIDES into surname and given, and the peel never asks that question. Whether it also crosses the FAMILY comma is tolerated rather than settled: the 2026-09-01 demotion (rules.md#W3) narrowed that half from contract to best-effort, since no CJK writing system's own convention puts a comma between family and given at all — so `김, 민준씨` reads today exactly as the spaced `김 민준씨` does (family 김, given 민준, suffix 씨) while the split stands down as before, but that reading is watched on the differential's radar tier rather than pinned as contract. Its site is accordingly the name-bearing segment runs — `segments[:2]` under a family comma, and `segments[0]` as before otherwise, the family comma being the one structure that splits the name itself across two runs, with the honorific as often glued to the given side as to the family. That is the whole reach and nothing past it (`김, 민준 지훈씨` peels; `김, 민준, 지훈씨` and `김,, 민준씨` do not, both landing in a third run), and whether `segments[1]` is name text at all is now ASKED rather than inferred from the structure — `segment` does not guarantee it, since a one-word part before the comma reads as FAMILY_COMMA even when the part after it is entirely suffix-shaped, and the peel walking into such a run took `V.` for its site, found no listed tail and abandoned (#319). The question is asked in two parts. First the comma's own decision: `_comma.decide` on a classified copy of the name as written, since #613's /simplify -- a hand copy of it here drifted from decide four times in that PR's review (the ambiguous class's count, the numeral refusal, a suffix before the comma, the split `Ph. D.`), each drift dividing a name decide read as a family comma or leaving whole one it read as a suffix comma, and asking the real decision is what ended it. Where that leaves the family comma, C1's suffix-word predicate, lifted into `_vocab.is_wholly_suffix` so the stages cannot drift: a wholly suffix-shaped second run is declined and the scan stays in `segments[0]`, so `田中さん, V.` and `田中さん, Ph. D.` give さん up as `田中さん, PhD` always did. The test is necessary but NOT sufficient, and the second condition is not decoration: every honorific tail is also a suffix word, so a glued honorific is itself part of what makes its run read as suffix-shaped, and declining a run that holds the ONLY site loses the peel outright. `segments[0]` must therefore offer a peel site of its own before the second run is declined — `이, J.씨` and `선생님, J.씨` pass the suffix test and are scanned anyway, keeping the pre-#319 reading, while `김민준씨, J.씨` has a site on both sides and peels the person's own 씨 rather than the junk one behind the comma. Uniform in the PEEL, that is — where the credential itself lands is `assign`'s question and still differs by spelling (`V.` → `given`, `PhD` and `Ph. D.` → `suffix`). Not `_is_post_nominal` pluralized: the run predicate says yes both to what the token predicate vetoes (`V.`, `V`, `I` — the class the defect was reported as) and to what the token predicate never sees at all, since `period_joined_vocab` and the delimiter routes are the run predicate's alone (`Msc.Ed.` and `J.씨` reach it that way, and `田中さん, Msc.Ed.` moves with the rest). `Policy(lenient_comma_suffixes=False)` drops this call to the strict token test too — so those three read as name text again and keep the pre-#319 answer, while `Ph. D.` peels under the knob regardless, its merged `phd` passing the strict test. `田中さん, 太郎` is unchanged, and not because of its comma — the honorific there is not at the end of the name, 太郎 is. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. +**Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule above is about Latin *transliteration*, where the signal genuinely is destroyed; native script is a different question, and it is answered per behavior rather than per script. Five defaults fall out of it, plus a sixth that applies the same not-a-guess standard to specific WORDS rather than to a script (#308's honorific peel, below). Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`) — no language detection needed, because zh and ja both write family-first in native script, so order cannot be misread even though the language is unknowable. Unspaced hangul splits into surname + given name (`config/surnames.py` ships the Korean census list as DEFAULT vocabulary) — nothing but Korean is written in hangul and the surnames are a closed census set, and the vocabulary is self-selecting besides: a hangul entry can only ever match hangul text. Hiragana licenses Japanese (#272) — a name whose characters stay inside Han∪kana while carrying at least one kana cannot be Chinese (the kana rules it out) and is not a transcription (foreign names are transcribed in katakana ALONE, マイケル has no kanji), so 高橋みなみ and 山田 エミ read family-first too; mechanically they resolve to the HIRAGANA entry, the license's carrier key. PURE katakana — full-width or halfwidth, the halfwidth block U+FF65–FF9F being katakana in the script table since #594 — is excluded and keeps the positional default: マイケル・ジャクソン is a transcribed foreign name in its source order, and a Japanese reading written in katakana (legacy halfwidth data spells Japanese names that way too) cannot be told from one, so the script settles nothing and a caller who knows opts in through `script_orders`. And the 间隔号 U+00B7 (#298) is the transcription marker for scripts that HAVE no transcription script: a name it divides (威廉·莎士比亚 — flanked by classified characters on both sides, so Catalan's Gal·la is untouched) keeps the declared order and never segments — the orthography names the convention, with the divider carrying the signal that stands the script's family-first reading down, so the default reads it in its written order and a declared `FAMILY_FIRST` reverses it like any other name; pure katakana reaches the same reading by the opposite route, its script settling nothing so the declared order stands. And a listed CJK honorific glued to the END of a name token is split off it (#308) — 田中さん is 田中 plus さん — on the same orthography-settles-it test, narrowed for the glued position: an entry peels only where it can never end a name, so 씨/님/さん/様/先生 peel while 양/군/氏/博士/殿 stay spaced-only (김지양 and 田中博士 are names, and ~90 Japanese surnames end in 殿) and 君 is in NEITHER set (王君 is a complete Chinese name), though its kana spelling くん peels. Like the nakaguro's tokenize-level separation described next, it is reached by neither policy opt-out — but for its own reason: the vocabulary carries the license itself rather than borrowing the script's, so `segment_scripts` has nothing to say about it. Since #312 it also crosses the 间隔号, which still stops the surname split standing right beside it: it answers where a name DIVIDES into surname and given, and the peel never asks that question. Whether it also crosses the FAMILY comma is tolerated rather than settled: the 2026-09-01 demotion (rules.md#W3) narrowed that half from contract to best-effort, since no CJK writing system's own convention puts a comma between family and given at all — so `김, 민준씨` reads today exactly as the spaced `김 민준씨` does (family 김, given 민준, suffix 씨) while the split stands down as before, but that reading is watched in the snapshot rather than pinned as contract. Its site is accordingly the name-bearing segment runs — `segments[:2]` under a family comma, and `segments[0]` as before otherwise, the family comma being the one structure that splits the name itself across two runs, with the honorific as often glued to the given side as to the family. That is the whole reach and nothing past it (`김, 민준 지훈씨` peels; `김, 민준, 지훈씨` and `김,, 민준씨` do not, both landing in a third run), and whether `segments[1]` is name text at all is now ASKED rather than inferred from the structure — `segment` does not guarantee it, since a one-word part before the comma reads as FAMILY_COMMA even when the part after it is entirely suffix-shaped, and the peel walking into such a run took `V.` for its site, found no listed tail and abandoned (#319). The question is asked in two parts. First the comma's own decision: `_comma.decide` on a classified copy of the name as written, since #613's /simplify -- a hand copy of it here drifted from decide four times in that PR's review (the ambiguous class's count, the numeral refusal, a suffix before the comma, the split `Ph. D.`), each drift dividing a name decide read as a family comma or leaving whole one it read as a suffix comma, and asking the real decision is what ended it. Where that leaves the family comma, C1's suffix-word predicate, lifted into `_vocab.is_wholly_suffix` so the stages cannot drift: a wholly suffix-shaped second run is declined and the scan stays in `segments[0]`, so `田中さん, V.` and `田中さん, Ph. D.` give さん up as `田中さん, PhD` always did. The test is necessary but NOT sufficient, and the second condition is not decoration: every honorific tail is also a suffix word, so a glued honorific is itself part of what makes its run read as suffix-shaped, and declining a run that holds the ONLY site loses the peel outright. `segments[0]` must therefore offer a peel site of its own before the second run is declined — `이, J.씨` and `선생님, J.씨` pass the suffix test and are scanned anyway, keeping the pre-#319 reading, while `김민준씨, J.씨` has a site on both sides and peels the person's own 씨 rather than the junk one behind the comma. Uniform in the PEEL, that is — where the credential itself lands is `assign`'s question and still differs by spelling (`V.` → `given`, `PhD` and `Ph. D.` → `suffix`). Not `_is_post_nominal` pluralized: the run predicate says yes both to what the token predicate vetoes (`V.`, `V`, `I` — the class the defect was reported as) and to what the token predicate never sees at all, since `period_joined_vocab` and the delimiter routes are the run predicate's alone (`Msc.Ed.` and `J.씨` reach it that way, and `田中さん, Msc.Ed.` moves with the rest). `Policy(lenient_comma_suffixes=False)` drops this call to the strict token test too — so those three read as name text again and keep the pre-#319 answer, while `Ph. D.` peels under the knob regardless, its merged `phd` passing the strict test. `田中さん, 太郎` is unchanged, and not because of its comma — the honorific there is not at the end of the name, 太郎 is. The nakaguro belongs to the same doctrine but is decided a layer down: U+30FB and its halfwidth twin U+FF65 separate tokens like whitespace, unconditionally and in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=()`) reaches it — the codepoints are CJK-only and appear in no other script's names, which is what licenses a tokenize-level rule where U+00B7 (also the Catalan punt volat, interior to Gal·la) needs the flanked-by-classified-script guard `_tokenize_region` gives it (#298). Han segmentation stays OPT-IN (`locales.ZH` for Chinese, `locales.JA` for Japanese) — a zh surname list corrupts Japanese kanji names, since 高 is a common Chinese surname and 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎; no surname list divides a kanji name at all, so `locales.JA` activates the stage and a pluggable `Parser(segmenter=...)` does the dividing. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. **A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`; 568 of 575 against seven, measured 2026-09-25 after #540), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23), as were `rai`, `cha`, `ba` and `mc` before `decisions.md#suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions.md#vocabulary-collisions`, with #360's positional qualifier. @@ -329,7 +218,7 @@ Each named attribute (`title`, `first`, etc.) is a `@property` that joins its co The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These conventions apply to all new-API code and are stricter than the v1 sections above. The full design record (rationale, settled-decision logs, dated amendments) lives in untracked `docs/superpowers/specs/`; this section is the enforceable subset. **A commit that establishes or amends one of these conventions must update this section in the same commit** — grep-driven staleness sweeps miss paraphrased prose (see Workflow above), so write-time maintenance is the mechanism, audits are the backstop. - **Module layout**: every new module is underscore-private (`_types.py`, `_lexicon.py`, `_policy.py`, `_locale.py`, `_render.py`, `_pipeline/`, `_parser.py`, plus the facade layer: `_facade.py`, `_config_shim.py`). The public import surface is exactly `nameparser` and `nameparser.locales`; `nameparser/__init__.py` holds re-exports and `__all__` only — no logic. Since the M11 swap, the old paths are import-path-preserving re-exports: `nameparser.parser` re-exports the `_facade` `HumanName`, `nameparser.config` re-exports the `_config_shim` names (`Constants`, `CONSTANTS`, `SetManager`, `TupleManager`, `RegexTupleManager`); the `config/` DATA modules stay the vocabulary source through 2.x. The whole facade layer is deleted in 3.0. -- **Facade layer** (`_facade.py`, `_config_shim.py`): the v1-compat `HumanName`/`Constants` over the core. Key mechanisms: `Constants._generation` dirty-tracking (every mutation bumps; facades resolve their `Parser` lazily via `_cached_parser(lexicon, policy)`); `Constants._snapshot()` mirrors `_lexicon._default_lexicon()` (equality-pinned); the facade pickles v1-SHAPED state (component lists, one `__setstate__` path for 1.4 and 2.x blobs; components rebuild via `replace()`, never a re-parse); `_V1_HOOKS` overrides warn once per subclass (#280). The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — `tools/differential/` (dev-only, not shipped) verifies this against 1.4-on-PyPI, and `--baseline` points the same gate at any released version — run it at 1.4.0 for the compat contract, at the previous minor for a release's blast radius. `tools/differential/README.md` owns the corpus roster and what each file is blind to; don't restate it here. `parser.py:NNNN` citations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them with `git show 2d5d8c2:nameparser/parser.py`. +- **Facade layer** (`_facade.py`, `_config_shim.py`): the v1-compat `HumanName`/`Constants` over the core. Key mechanisms: `Constants._generation` dirty-tracking (every mutation bumps; facades resolve their `Parser` lazily via `_cached_parser(lexicon, policy)`); `Constants._snapshot()` mirrors `_lexicon._default_lexicon()` (equality-pinned); the facade pickles v1-SHAPED state (component lists, one `__setstate__` path for 1.4 and 2.x blobs; components rebuild via `replace()`, never a re-parse); `_V1_HOOKS` overrides warn once per subclass (#280). The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — 2.0 was verified against 1.4-on-PyPI by a classified differential gate, retired in #664; `tools/differential/compare.py --baseline 1.4.0` still prints every corpus name that reads differently from 1.4.0, unclassified, when the question comes up. `tools/differential/README.md` owns the corpus roster and what each file is blind to; don't restate it here. `parser.py:NNNN` citations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them with `git show 2d5d8c2:nameparser/parser.py`. - **Layering is enforced by `tests/v2/test_layering.py`** (exact-module matching; `if TYPE_CHECKING:` imports don't count): `_types` imports nothing internal at module level — its rendering delegates import `_render` at call time; `_lexicon` and `_policy` sit above `_types` independently (`_lexicon` may import `nameparser.config.*` DATA modules only — vocabulary is single-sourced from the v1 data modules through 2.x, e.g. `config/maiden_markers.py`); `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types` and `_lexicon` (for `Lexicon.default()` and `_normalize`); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers; `_parser` sits on everything except `_render`; the facade layer (`_facade`, `_config_shim`, `parser`, `config/__init__`, `__main__`) may import anything public plus `_render`; locale pack modules (`locales/*.py`) import `_locale`/`_lexicon`/`_policy`/`_types` only (`_types` joined the list in #272 — `locales/ja.py`'s segmenter factory constructs a `Segmentation`), and the `locales/__init__` additionally lazy-imports its packs (PEP 562). Extend the test's `ALLOWED` table when adding a module. - **Canonical field order** — the seven roles in `Role` enum declaration order, defined once and derived everywhere (properties, `as_dict`, reprs, `comparison_key`). Never restate the order literally. `Role` is a `StrEnum`: members compare as their field-name strings, and `tokens_for()` coerces strings via `_coerce_enum`. - **Method organization**, fixed section order in every class: fields + `__post_init__` validation → alternative constructors → dunders (construction/equality → protocol → operators) → properties → public methods by concern (access → editing → comparison → rendering delegates) → private helpers last, except a helper serving exactly one section may sit at that section's head. Sanctioned deviation, facade layer only: `HumanName` and the shim `Constants` organize by v1 concern groups (`# -- render defaults --`, `# -- config / parsing --`, `# -- fields --`, ..., dunders and pickle last) — the classes mirror v1's own surface and die in 3.0; the canonical order still binds every core type. @@ -349,7 +238,7 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These - **The segmenter contract**: the optional `Parser(segmenter=...)` hook is parse-totality's ONE exception (locales spec section 4). Everything inside that exception is a bug in USER CODE, never a fact about the name, so it is surfaced rather than absorbed: the segmenter's own exceptions propagate, and the two protocol violations the stage can detect for itself — an answer of the wrong type, and one cutting at or past the end of the token it was handed — raise `TypeError`/`ValueError` from `_script_segment` for the same reason. The line to hold when adding a check there: a protocol violation by the segmenter's AUTHOR raises, while an adapter's defense against its own third-party library (`locales/ja.py`'s repertoire, length, reconstruction and score guards) declines with `None`, because what those catch is a fact about the content. - **Pickling**: v2 types must round-trip (`Parser` is picklable by construction, and it holds a `Lexicon`; the one qualifier is that a `Parser` pickles iff its segmenter does — see the segmenter bullet above). Every frozen type assigns `_guarded_getstate`/`_guarded_setstate` (`_types.py`) in its class body (`@dataclass(slots=True)` would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, and values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance). `Lexicon` keeps its own copy of the guard (layering) plus the `mappingproxy` slot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test. - **One sanctioned global**: the (future) cached default `Parser`. Lazily cached FROZEN singletons (`Lexicon.default()`'s `functools.cache`, the future default parser) are constants, not state; any second piece of module-level MUTABLE state requires amending the conventions doc, on purpose, in review. Sanctioned exceptions, facade layer only: `_config_shim.CONSTANTS` (the v1 shared singleton, mutable by design) and `_facade._WARNED_SUBCLASSES` (the once-per-subclass hook-warning dedup set) — both deleted with the layer in 3.0. -- **Tests**: all v2 tests live in the `tests/v2/` package (its `conftest.py` overrides the v1 dual-run fixture — v2 code never reads shared `CONSTANTS`), one test module per source module plus the cross-cutting ones (`test_reprs.py`, `test_layering.py`, `test_contracts.py`, `test_properties.py`, `test_benchmark.py`, the `cases.py`/`test_cases.py` table, `test_regex_sync.py`, which pins the pipeline's hand-copied patterns against their sources, and `test_ledger_guards.py`, which pins the differential harness's hand copies and bounds what each ledger rule may claim — sharing `_differential_fixtures.py` with `test_differential.py` for the handles on `tools/differential/`), names stating behavior. Never assert `Lexicon.default()` contents; the narrow sourcing spot-checks in `test_default_sources_v1_vocabulary` that pin the v1→v2 migration contract (e.g. the flipped `particles_ambiguous` model) are the sanctioned exception. +- **Tests**: all v2 tests live in the `tests/v2/` package (its `conftest.py` overrides the v1 dual-run fixture — v2 code never reads shared `CONSTANTS`), one test module per source module plus the cross-cutting ones (`test_reprs.py`, `test_layering.py`, `test_contracts.py`, `test_properties.py`, `test_benchmark.py`, the `cases.py`/`test_cases.py` table, `test_regex_sync.py`, which pins the pipeline's hand-copied patterns against their sources, and `test_snapshot.py`, which holds every corpus name's reading to `tools/differential/snapshot.jsonl` — sharing `_differential_fixtures.py` with `test_differential.py` and `test_corpora.py` for the handles on `tools/differential/`), names stating behavior. Never assert `Lexicon.default()` contents; the narrow sourcing spot-checks in `test_default_sources_v1_vocabulary` that pin the v1→v2 migration contract (e.g. the flipped `particles_ambiguous` model) are the sanctioned exception. ## Extension Patterns @@ -374,7 +263,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, `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. +**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. ## Gotchas @@ -398,7 +287,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; 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.md#H5 and #P5, rules.md#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. -**A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/` 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 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 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 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. **`SUFFIX_ACRONYMS` and `SUFFIX_WORDS` are asserted DISJOINT at import (since 2026-09-08) — a bulk post-nominal import that re-creates an overlap fails there, and the fix is to choose a set, not to relax the assert** — 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.md` if anyone wants them. diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 07b45f80..d0874bcc 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -1417,6 +1417,28 @@ Declined: - A word-boundary prefix (2026-09-05, #508's option 3) — answers the two Bob Jones rows and not `'Andrews, M.D.'`, and adds a tokenising rule to a string comparison. - Refusing two rules in one ledger from sharing a prefix (2026-09-05) — a property of the ledger, not of a pin; the ledger's rule families are deliberate (`fix(comma-family)`, `fix(#296)`), and the resolver already makes the only case that matters loud. +### differential-ledger, retired for a snapshot (2026-10-13, #664) + +The ledgers' last arc. They were built to show that 2.0 changed nothing against 1.4 except fixes the release log lists, and they did; as a standing check every later behavior change had to be classified once per ledger, and the guards policing the ledgers outgrew the ledgers. + +Decisions that landed: + +- 2026-10-13 #664 — **a committed snapshot replaces the classified gate.** `tools/differential/snapshot.jsonl` records every corpus name's reading (the seven role fields, ambiguity kinds, `initials()`, and the facade where it differs), one line per (name, order); `tests/v2/test_snapshot.py` fails on drift and prints the moves grouped by changed field. A change that moves readings re-records, and the PR diff of the snapshot is the list of names it moved. Nothing is classified: what a reviewer reads in the diff is what a ledger rule used to say in a regex. The six `expected_since_*.toml` ledgers and `tests/v2/test_ledger_guards.py` are deleted, and with them the contract/radar tiers, so the CJK corpus is one file again and the rules corpus harvests tolerated rules' examples too. +- 2026-10-13 #664 — **`compare.py` stays, unclassified.** It keeps the released-wheel runner and its checks against comparing one tree with itself (the version tell, the tree-side path check, the stripped `PYTHONPATH`; mechanisms.md#VERSION-TELL, #GENERATED-SCRIPT-OUTSIDE-THE-WORKTREE), prints the same grouped report as the snapshot and exits 0. It answers "how did release X read these names" for a release tagged before the snapshot existed; after that, `snapshot.py --since ` is the cheaper answer. +- 2026-10-13 #664 — **the CLDR agreement ratchet stays** (#654). It answers a different question, agreement with CLDR's stated fields rather than stability. + +Measured 2026-10-12, before the change, over the last 40 first-parent commits on master with corpus data excluded: 5,236 of 25,226 changed lines (20%) were ledger, `compare.py` or ledger-guard edits, touched by 21 of the 40, and 30–60% on parser-behavior PRs (#655: 236 of 448). Run against the code before and after #655, the snapshot reports five moved names, against that PR's 241 lines of ledger and guard edits; merging master afterwards, it reported #663's one new corpus name where #663 had added the same ten lines to each of six ledgers. RECOMPUTE (shares): `git log --first-parent -40 --format=%h master`, and for each commit sum `git show --numstat` over paths matching `expected_since|compare\.py|ledger_guards|test_differential|_differential_fixtures`, skipping `.jsonl` and `cldr_*` data. + +Accepted costs: + +- No forced explanation per moved name. A reviewer can approve a large snapshot diff without reading it; the grouping by changed fields is the mitigation. +- No bound on what a change may reach. `_CORPUS_CLAIMS`, the dormancy checks and the contest pins existed only to police ledgers; mechanisms.md#LEDGER-RULE-SEPARATION and #CROSS-RULE-OUTCOME-PINS are retired with them. +- The 1.4 compatibility promise is a question asked when needed (`compare.py --baseline 1.4.0`), not a gate at every release. The facade is deleted in 3.0. + +Declined: + +- Trimming the ledgers rather than retiring them (2026-10-13). The radar tier (#468, 2026-09-01) was that attempt: it stopped new corpora from failing the run, but every contract name still needed a rule in every ledger, and the cost grew by one ledger per release. + ### comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01) #291 was filed 2026-07-26 out of the 2.0 vocabulary cleanup, with diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index d356f68f..4aeea99a 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -75,22 +75,19 @@ Problem shape. A bracketed clause should be treated as something other than what ## CURATED-VOCABULARY-ALTERNATION — the config already splits them -Problem shape. Two string shapes collide — the same written form means two different things — and no predicate separates them. Contract statement. A curated vocabulary that OMITS the ambiguous entries is itself the separator: membership is the license, and the per-entry vetting reasons live beside the set. How it works. GLUED_HONORIFICS is the exemplar (rule W2): 씨 peels because it can never end a name; 양 stays out because 김지양 is a given name. The set, not a regex, draws the line. Lives in. nameparser/config/suffixes.py (the vetting block). Reach for it when. Arguing that "no regex can separate X from Y" — check whether a config set already splits them by listing one side. The second half of the pattern: to USE the set in a regex, hand-copy the alternation and pin the copy with a sync roster (_HONORIFIC_SOURCES) — the 2.0.0 ledger has done exactly this since -#308, which is why #376's fix was copying its twin verbatim rather -than inventing one. +Problem shape. Two string shapes collide — the same written form means two different things — and no predicate separates them. Contract statement. A curated vocabulary that OMITS the ambiguous entries is itself the separator: membership is the license, and the per-entry vetting reasons live beside the set. How it works. GLUED_HONORIFICS is the exemplar (rule W2): 씨 peels because it can never end a name; 양 stays out because 김지양 is a given name. The set, not a regex, draws the line. Lives in. nameparser/config/suffixes.py (the vetting block). Reach for it when. Arguing that "no regex can separate X from Y" — check whether a config set already splits them by listing one side. The second half of the pattern: to USE the set in a regex, hand-copy the alternation and pin the copy against the set (tests/v2/test_regex_sync.py holds the pipeline's copies). ## RECORDED-ROSTERS — record the answer, don't re-derive it -Problem shape. A guard needs to know what the answer WAS, so it can detect the answer changing. Contract statement. Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Known limit, and it is what the contract statement does NOT cover: a roster row can hold a recorded INPUT as well as a recorded expectation, and only the expectation half is asserted. `_CROSS_RULE_WINNERS` obeyed this entry to the letter and still carried wrong rows — it recorded a diff shape per contested name AND the rule that should win, fed the shape to `classify()` as an argument, and asserted only the winner, so a wrong shape that still routed to the same rule agreed with itself for as long as nobody re-measured it by hand. The test to apply to a recorded input is not "is it checked" but "would the consumer answer DIFFERENTLY if it were wrong" — which is a question about the PAIR of shapes and not about the row: a row is inert exactly where the recorded shape and the real one route to the same rule. Counting admitters does not answer it, and is the wrong instrument this entry itself reached for first. Measured 2026-09-05 over `_RECORDED_DIFFS['expected_since_1.4.0.toml']`: 13 of the 45 rows have exactly one rule admitting their recorded shape, and 11 of those 13 route to a DIFFERENT rule under some other shape, so the assertion would have caught a wrong one — `'Andrews, M.D.'` wins `fix(comma-family)` at its recorded `('given','suffix')` and `fix(comma-precomma-family)` at `('family',)`. The numerator held while the denominator grew: every one of the fourteen #498 added on 2026-09-05 has two or more admitters at its recorded shape, by the definition of the sweep that found them, so none of them joins the 13. Nor does "the same rule admits both shapes" make a row inert, since file order still decides: over that ledger 185 of the 338 shape pairs sharing an admitter route to different rules (measured 2026-09-05; it read 152 of 246 over the 31 rows the roster held on 2026-09-03). RECOMPUTE: load the ledger, sort with `_sorted_rules`, and call `classify(name, shape, rules, exclusions, None)` at each row's recorded shape and at every other subset of the roles the roster uses. The four rows #497 deleted were inert for the PARTICULAR wrong shape they happened to carry, and not for any wrong shape — `'Jane née and Jones Smith'` routes to `fix(#445)` at both its recorded and its real shape in either 2.x ledger, and to `fix(#412)` at `('family',)`. The repair is to move the input to where something measures it — the shapes now live in tools/differential/compare.py as `_RECORDED_DIFFS` and a differential run verifies them against the diffs it has already computed, the roster reading them from there — so a recorded roster's recorded half is checked by a run rather than by the guard that consumes it (#497). A recorded expectation whose only consumer is the run that checks it is the pure case of this entry, and `_WATCHED_DIFFS` is that case: a shape recorded alone, with no winner pinned and no guard reading it as an input, measured by every run and re-derived by nothing (#501). Lives in. tests/v2/test_ledger_guards.py (_CORPUS_CLAIMS, _EXCLUSION_EFFECT, _CROSS_RULE_WINNERS, _ORDER_EXEMPTION_EFFECT, _SPAN_BEARING_RULES, _HONORIFIC_SOURCES, _LATIN_ALTERNATION_SOURCES), tools/differential/compare.py (_CORPUS_FLOORS, _RECORDED_DIFFS, _WATCHED_DIFFS), tests/v2/test_facade_cases.py (_CORE_ONLY_IDS). Reach for it when. Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts. And when a roster row carries anything the check CONSUMES rather than compares, ask what measures that half. +Problem shape. A guard needs to know what the answer WAS, so it can detect the answer changing. Contract statement. Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Known limit, and it is what the contract statement does NOT cover: a roster row can hold a recorded INPUT as well as a recorded expectation, and only the expectation half is asserted. The test to apply to a recorded input is not "is it checked" but "would the consumer answer DIFFERENTLY if it were wrong". The differential ledgers' rosters were this entry's worked cases (`_CROSS_RULE_WINNERS` carried wrong recorded shapes that agreed with themselves until #497 moved them where a run measured them), and were retired with the ledgers in #664; decisions.md keeps their measurements. How it works. The purest case is a recorded expectation whose only consumer is the check that compares it, re-derived by nothing: tools/differential/snapshot.jsonl records every corpus name's reading, and tests/v2/test_snapshot.py compares the tree against it. Lives in. tools/differential/snapshot.jsonl, tests/v2/test_facade_cases.py (_CORE_ONLY_IDS), tests/v2/test_benchmark.py (_CALL_BASELINE). Reach for it when. Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts. And when a roster row carries anything the check CONSUMES rather than compares, ask what measures that half. ## LEDGER-RULE-SEPARATION — file order decides, fields narrow by subset -Problem shape. Two differential-ledger rules claim overlapping names. Contract statement. Every ledger rule must carry a `name_regex` — since #451 `validate_rules` REJECTS a rule with `fields` and no `name_regex`, and one with neither was already rejected — so every rule sits in ONE tier, the sort is stable, and FILE ORDER decides every contest. Narrow-first is the declaration-free DEFAULT, not the contract: a wider rule can be the better classifier where it describes a compound behavior its component rule does not — `马丁·路德·金씨` divides on the nakaguro AND peels its glued honorific, so `fix(#272/#308)` describes it and `fix(cjk-glued-honorific-peel)` describes half of it — which makes `fields`-subset a proxy for specificity and the wrong one there. What IS the contract is that such a pair must be DECLARED: the earlier rule carries a `precedes_narrower` block naming the later one and saying why, and `undeclared_contests` refuses the ledger otherwise (#382). `fields` narrows a rule by subset; it does not separate rules by sorting. Narrowing by subset is not the whole contract: since #452 a rule's `fields` must EQUAL the union of the diffs it explains, and `compare.py` reports OVER-DECLARED and exits non-zero otherwise — a declared role no diff moves is not inert, it lets the rule keep claiming a name whose diff SHRINKS into the excess (decisions.md#differential-ledger). Since #468 there is a THIRD narrowing key: `orders` admits only the comparison orders it lists, the key being optional and its absence the order-blind reading every earlier rule has — a name compared under two orders can move the same roles for opposite reasons, so a rule describing an order-scoped fold would otherwise absorb that fold leaking into the default order (decisions.md#differential-ledger carries the worked case, and the legal set is borrowed from tools/differential/shapes.py rather than copied — plus one member no shape can declare, the `DEFAULT` sentinel naming the comparison run under no declared order, TOML having no null to put in an array). Exclusions take no `orders` and stay order-blind, deliberately. The ban ends the SHAPE and not the property it enabled: a required `name_regex` bounds nothing by itself, since the only width check is the sentinel probe — measured, `[a-z]` validates and reaches 970 of 1120 comparisons (2026-09-01, re-measured the same day after #486 widened the shapes corpus; it read 963 of 1113 before that). What changed is that such a rule now carries a `_CORPUS_CLAIMS` reach and digest, so its breadth is visible once at recording time rather than never (#452). The two-tier sort in `_sorted_rules` is KEPT although the ban makes it the identity on every ledger that loads (four ledgers load today, measured 2026-09-02; the open cycle's carries one rule, `fix(#462)`, and it carries a `name_regex` like every other, so the identity holds there for the same reason and not for want of rules): it is the defence for a reader that does not call `validate_rules` first — a future tool, a REPL, a test fixture — and its docstring in tools/differential/compare.py says so. How it works. Detail is owned by tools/differential/README.md. The file-order clause is measured, not theoretical: in the 1.4 ledger the comma-honorific-peel rule's fields are a strict subset of the comma-compound rule's, both carry a name_regex, and a pure reorder reattributes seven names — caught by _CROSS_RULE_WINNERS and by nothing else in the suite (#375's mutation). That pair is narrow-first and so declares nothing, and #382 settled what to do about it by declining BOTH predicates on offer. Narrowing the peel rule's own `name_regex` to the honorific-bearing shapes (#382 option 1) cannot make the pair order-independent at all, which is measured and not argued: the two rules ship an IDENTICAL `name_regex`, each rule's reach is computed from its own pattern, and the peel rule's `fields` nest inside the compound rule's — so the compound rule goes on reaching those names and goes on admitting their diffs whatever the peel rule's regex is narrowed to, and it would grow the `_HONORIFIC_SOURCES` sync roster for nothing. What holds the pair is what the arc kept: narrow-first order, with the seven names whose diff both rules admit pinned by name in `_CROSS_RULE_WINNERS`. A mechanical narrow-first sort (option 3) was refused on a false premise: it reattributes names to a rule describing half of what happens to them. So order separates this pair, by the declaration-free narrow-first default, and it is the WIDE-first pairs that must declare themselves (the rule-order arc under decisions.md#differential-ledger). What that check covers is NESTED pairs: two rules whose `fields` merely INTERSECT are decided by file order too, and sit outside it by the same reasoning that leaves EQUAL `fields` outside — neither rule is narrower, so there is nothing for a `precedes_narrower` block to name — with #498 carrying the worked case and the measured size of the class, and closing it at RUN time rather than by widening this predicate: a contest a run measures whose winner outranks a loser by neither nesting nor a declaration must be pinned in `_CROSS_RULE_WINNERS`, and `compare.unowned_contests` fails the gate otherwise. A REGEX ACCIDENT — a rule reaching a name through its pattern rather than by describing it — has no declaration site at all when it lands in a narrow-first or a non-nested pair, `precedes_narrower` sitting on the wide-first rule by construction, so nothing can refuse it and nothing can require a `why`; the repair is to narrow the accident away until the pair stops being a contest, which is what #501 did to the glued-peel `name_regex` after it put a FALSE label on a contract-tier name and stayed green (decisions.md, the #501 adjudication). The old #271/#272 -slug taboo is RETIRED (#333): the canonical-rule selector that keyed on those substrings is deliberately deleted — rule authors are free to use them in compound slugs — and the surviving rosters select on their own explicit keys (_HONORIFIC_SOURCES and _LATIN_ALTERNATION_SOURCES by named issue strings, _SPAN_BEARING_RULES by exact leading fix(...) tag). Lives in. tools/differential/compare.py, the expected_since_*.toml ledgers. Reach for it when. A ledger rule's behavior seems to depend on where it sits in the file — it does, and the reorder mutation is the test (run twice in #375; it fails _CROSS_RULE_WINNERS). History: #372 (closed) measured the then-existing fields-only rule owning 1639 of 5257 name×field pairs as filed (2026-08-10); #375/#376 then cut its classifier-of-record share sharply, and the residual pair ownership was read as the last-resort tier working as designed rather than a defect — until #451 retired the shape outright (decisions.md#differential-ledger). #372's two proposed mechanical checks were DECLINED with measurements (see decisions.md#differential-ledger), not left open. +Problem shape. Two differential-ledger rules claimed overlapping names. Contract statement. Retired with the ledgers (#664): the snapshot that replaced them classifies nothing, so no rule can claim a name and no order can decide a contest. How it works. Through 2.4, each `tools/differential/expected_since_*.toml` rule carried a `name_regex` and `fields`, file order decided every contest, and wide-first pairs had to declare themselves; decisions.md#differential-ledger records the arc. Lives in. Nothing; git history before #664. Reach for it when. Reading a decisions.md entry that cites it. ## CANONICAL-VOCABULARY-AT-THE-BOUNDARY — one vocabulary at the comparison -Problem shape. Two surfaces name the same concept differently (first/last vs given/family), and a matcher needs to compare across them. Contract statement. Canonicalize at the point of comparison: every compared surface's output is converted to one vocabulary before matching, and the canonical choice is the one the codebase already derives everywhere else. How it works. Teaching the matcher both vocabularies doubles every rule silently; canonicalizing means existing rules keep matching when a second surface joins. The ledger canonicalizes v1 field names to Role's before matching. Lives in. tools/differential/compare.py (_canonical_field, _V1_TO_ROLE). Reach for it when. A second surface joins an existing matcher — without this, every existing rule silently stops matching the new surface, which looks like added coverage. +Problem shape. Two surfaces name the same concept differently (first/last vs given/family), and a matcher needs to compare across them. Contract statement. Canonicalize at the point of comparison: every compared surface's output is converted to one vocabulary before matching, and the canonical choice is the one the codebase already derives everywhere else. How it works. Teaching the matcher both vocabularies doubles every comparison silently; canonicalizing means a second surface joins without new keys. The snapshot records the facade's v1 fields under Role's names, and compare.py's 1.4.0 rows do the same, so a 1.4 reading and a 2.x one compare field by field. Lives in. tools/differential/snapshot.py (row, V1_FOR). Reach for it when. A second surface joins an existing matcher — without this, every existing comparison silently stops matching the new surface, which looks like added coverage. ## VOCABULARY-OVERLAP-AS-PRECONDITION — assert the intersection you stand on @@ -98,7 +95,7 @@ Problem shape. A test's input depends on two config sets intersecting (a word th ## CROSS-RULE-OUTCOME-PINS — pin who wins the contest -Problem shape. Every per-rule roster measures a rule alone and the gate total is per-corpus, but WHICH rule wins a contested name is neither — and it is exactly what a reorder or a narrowing changes. Contract statement. Contested outcomes are pinned as data: a roster records which rule classifies which contested name, so a change in the winner fails the suite even when every total is unchanged. A pin names exactly one rule — the rule whose issue equals it, else the single rule whose issue starts with it — and the guard refuses a pin that names none or several before asking `classify()` anything (#508; a full issue string can be a strict prefix of a sibling's, so the comparison is equality against the resolved rule, not a prefix). How it works. A pure file reorder in the 1.4 ledger fails _CROSS_RULE_WINNERS and nothing else in the suite — the pin is the only guard at that granularity. A shape recorded ALONE pins no winner and lives in the other roster, `compare._WATCHED_DIFFS`, so a reader looking for who wins a name does not look there (#501). The entry criterion is that the boundary is ARGUED ANYWHERE and not that it is argued in the guard module, because a ledger comment's argument about which rule wins a name is exactly what no guard and no run can see — the #501 adjudication is the instance, three of its six boundaries having been argued in a ledger comment and one of those three wrongly, green everywhere for a month (decisions.md, the #501 adjudication). The roster's COMPLETENESS is checked at run time over the contests a run measures: `compare.unowned_contests` refuses a measured contest the winner takes by neither nesting nor a declaration and that no row pins, so "`_CROSS_RULE_WINNERS` stays the instrument" for non-nested pairs is now enforced rather than stated (#498). Lives in. tests/v2/test_ledger_guards.py (_CROSS_RULE_WINNERS). Reach for it when. Two rules can claim the same name and you are about to change either one, or their order. +Problem shape. Which differential-ledger rule won a contested name was measured by no total, and was exactly what a reorder or a narrowing changed. Contract statement. Retired with the ledgers (#664): with nothing classifying a diff there is no contest to pin. How it works. Through 2.4, `_CROSS_RULE_WINNERS` in tests/v2/test_ledger_guards.py pinned which rule classified each contested name, and `compare.unowned_contests` refused an unpinned one (#498, #508). Lives in. Nothing; git history before #664. Reach for it when. Reading a decisions.md entry that cites it. ## VOCABULARY-FEEDS-STRUCTURE — a wordlist edit can move the comma decision @@ -128,7 +125,7 @@ Problem shape. Language-specific behavior needs a home that cannot drift from th ## FACADE-CONTRACT — HumanName wraps the core; 1.4-warning-free code keeps working -Problem shape. Where does v1-compatibility behavior live, and what may it do? Contract statement. HumanName is a mutable facade over the immutable core: code that runs warning-free on 1.4 keeps working through 2.x with identical results EXCEPT release-log-classified fixes, each verified in the differential ledger (the RFC's compatibility promise carries the same carve-out) — via validating setters, dirty-tracked re-parses, and pickle round-trips — and the facade never calls the v1 parsing hooks it still carries. "Warning-free" describes the code's 1.4 behavior, not a promise it STAYS warning-free: 2.x adds deliberate warnings (the multi-word-vocabulary and field-assignment ones), each release-log-classified, always with identical results. Lives in. nameparser/_facade.py; v1 import paths preserved by nameparser/parser.py and nameparser/config/. Reach for it when. A core change needs a v1-visible behavior — the facade, not the core, is where parity lives (and cite decisions.md#3-0-reevaluations when parity is the only reason). +Problem shape. Where does v1-compatibility behavior live, and what may it do? Contract statement. HumanName is a mutable facade over the immutable core: code that runs warning-free on 1.4 keeps working through 2.x with identical results EXCEPT release-log-classified fixes, verified for 2.0 by the differential ledger and since #664 visible against 1.4.0 through `tools/differential/compare.py --baseline 1.4.0` (the RFC's compatibility promise carries the same carve-out) — via validating setters, dirty-tracked re-parses, and pickle round-trips — and the facade never calls the v1 parsing hooks it still carries. "Warning-free" describes the code's 1.4 behavior, not a promise it STAYS warning-free: 2.x adds deliberate warnings (the multi-word-vocabulary and field-assignment ones), each release-log-classified, always with identical results. Lives in. nameparser/_facade.py; v1 import paths preserved by nameparser/parser.py and nameparser/config/. Reach for it when. A core change needs a v1-visible behavior — the facade, not the core, is where parity lives (and cite decisions.md#3-0-reevaluations when parity is the only reason). ## CONFIG-SHIM-SNAPSHOT — v1 config mutations reach the core by snapshot @@ -148,11 +145,11 @@ Contract statement. A worker that must run under a pinned dependency is rendered ### SENTINEL-SET-OVER-MATCH-CHECK — catching match-everything -Contract statement. A user-supplied pattern is rejected as over-matching by probing it against a small set of inputs sharing no script, vocabulary or punctuation; matching all of them means it targets no behavior family. Measured: `.`, `.+`, `\b` and `[\s\S]` all decline the empty string — the naive probe — and still match every corpus name. Lives in tools/differential/compare.py (_SENTINELS). +Contract statement. A user-supplied pattern is rejected as over-matching by probing it against a small set of inputs sharing no script, vocabulary or punctuation; matching all of them means it targets no behavior family. Measured: `.`, `.+`, `\b` and `[\s\S]` all decline the empty string — the naive probe — and still match every corpus name. Lived in tools/differential/compare.py (_SENTINELS) until the ledgers it checked were retired (#664); the shape stands for any user-supplied pattern. ### VOCABULARY-EXERCISES-FORKS — pin the branch, not the wordlist -Contract statement. A vocabulary set a caller configures is data; the code that reads it is what has behavior, so a case row earns its place by exercising a FORK — a branch the parser takes for one INPUT and not another — never by covering one more member. The unit is the input, not the member, because most forks are keyed on the shape of the surrounding name rather than on which word appeared. Find them by reading the sites that consult the set and enumerating the branches they take, and expect that enumeration to come back empty: `maiden_markers` is read at exactly two sites (`_classify.py`'s tagging and `_extract.py`'s `_maiden_marked`), both plain whole-token membership after `_normalize`, and every shipped member parses to one structural class in bare, bracketed and lone-bracketed shapes with the marker text normalised out — zero member forks. What earns the maiden rows is clause SHAPE: marker first against marker later, a word after the marker against none, a marker a separator divides from the name against one glued to it. Two adjacent things are NOT forks of this set and do not want rows: the substring-versus-token split is `_carries` in test_ledger_guards.py, a test guard deliberately asking a wider question than the parser (tools/differential/README.md says so), and edge-period normalization lives in `_lexicon._normalize`, applying to every token of every vocabulary, so it discriminates spellings rather than members. Only "a member borne as an ordinary name" is member-keyed (decisions.md#vocabulary-collisions), and it has its own rows. The reduction does NOT carry across to delimiter pairs, and the difference is the line to hold: a pair the library SHIPS is caller-visible behavior, and vocabulary a caller configures is data — deleting `("«", "»")` from the defaults reddens three tests, so cases.py's one-row-per-shipped-pair convention is load-bearing and this criterion is not permission to delete those rows. Within the pairs the code does fork, on whether open and close are the same character (N2) and again inside that class, where `WORD_INTERNAL_DELIMITERS` carves the apostrophe out: `Jane Smith"` reports UNBALANCED_DELIMITER and `Jane Smith'` reports nothing. ASCII against fullwidth is not one of them — swapping `( )` for `( )` across every corpus name holding a paren changes no field and no ambiguity kind. A row may also earn its place a second way, with no fork behind it: pinning a name a release note advertises BY NAME, which is a promise to a reader rather than a branch in the parser. It follows that "N of M entries have no test" is not a finding; it reads as one because it carries a number, and it inflates with every entry added, which is the tell that it measures the data. The finding is "this fork has no test". State the argument without present-tense counts over a drifting wordlist — see docs/design/AGENTS.md — and where a count is genuinely wanted, ship the one-liner instead: +Contract statement. A vocabulary set a caller configures is data; the code that reads it is what has behavior, so a case row earns its place by exercising a FORK — a branch the parser takes for one INPUT and not another — never by covering one more member. The unit is the input, not the member, because most forks are keyed on the shape of the surrounding name rather than on which word appeared. Find them by reading the sites that consult the set and enumerating the branches they take, and expect that enumeration to come back empty: `maiden_markers` is read at exactly two sites (`_classify.py`'s tagging and `_extract.py`'s `_maiden_marked`), both plain whole-token membership after `_normalize`, and every shipped member parses to one structural class in bare, bracketed and lone-bracketed shapes with the marker text normalised out — zero member forks. What earns the maiden rows is clause SHAPE: marker first against marker later, a word after the marker against none, a marker a separator divides from the name against one glued to it. Two adjacent things are NOT forks of this set and do not want rows: the substring-versus-token split is a question tools/differential/README.md's coverage recipe asks wider than the parser does, and edge-period normalization lives in `_lexicon._normalize`, applying to every token of every vocabulary, so it discriminates spellings rather than members. Only "a member borne as an ordinary name" is member-keyed (decisions.md#vocabulary-collisions), and it has its own rows. The reduction does NOT carry across to delimiter pairs, and the difference is the line to hold: a pair the library SHIPS is caller-visible behavior, and vocabulary a caller configures is data — deleting `("«", "»")` from the defaults reddens three tests, so cases.py's one-row-per-shipped-pair convention is load-bearing and this criterion is not permission to delete those rows. Within the pairs the code does fork, on whether open and close are the same character (N2) and again inside that class, where `WORD_INTERNAL_DELIMITERS` carves the apostrophe out: `Jane Smith"` reports UNBALANCED_DELIMITER and `Jane Smith'` reports nothing. ASCII against fullwidth is not one of them — swapping `( )` for `( )` across every corpus name holding a paren changes no field and no ambiguity kind. A row may also earn its place a second way, with no fork behind it: pinning a name a release note advertises BY NAME, which is a promise to a reader rather than a branch in the parser. It follows that "N of M entries have no test" is not a finding; it reads as one because it carries a number, and it inflates with every entry added, which is the tell that it measures the data. The finding is "this fork has no test". State the argument without present-tense counts over a drifting wordlist — see docs/design/AGENTS.md — and where a count is genuinely wanted, ship the one-liner instead: ``` uv run python -c " @@ -170,7 +167,7 @@ print(len(MAIDEN_MARKERS), 'members ->', len(cls), 'structural classes')" ### FORCE-A-DECISION-TABLE — no silent defaults on growth -Contract statement. Where adding an enum member or a file must not silently inherit a default, a local table's key set is asserted equal to the population, so growth fails the suite until someone decides — against a local table, not the constant under test. Exemplar: tests/v2/pipeline/test_vocab.py's per-script initials check; reused for _CORPUS_FLOORS in tools/differential/compare.py, and for the pack registry (test_registry_is_the_pack_contract: every registered pack must ship DEVIATES and its rotator list or the suite fails structurally — decisions.md#deviates-registry). Known gap it exposes: DEFAULT_SCRIPT_ORDERS has no such guard. +Contract statement. Where adding an enum member or a file must not silently inherit a default, a local table's key set is asserted equal to the population, so growth fails the suite until someone decides — against a local table, not the constant under test. Exemplar: tests/v2/pipeline/test_vocab.py's per-script initials check; reused for the pack registry (test_registry_is_the_pack_contract: every registered pack must ship DEVIATES and its rotator list or the suite fails structurally — decisions.md#deviates-registry). Known gap it exposes: DEFAULT_SCRIPT_ORDERS has no such guard. ### SELF-EXPIRING-GUARD — a decline keyed to a measured defect @@ -194,14 +191,13 @@ Problem shape. A test pins an ordering, a sort, a dedup or a partition, and its - Either route: hold one input the mutation MUST move and one it must NOT, and check both before believing a count. Every trap here produces confident numbers rather than an error. - Assert which tree you imported, on BOTH sides of a comparison. `python -c` puts CWD on sys.path; a script's own directory holds no nameparser in tools/differential/, so a stray PYTHONPATH outranks the editable install — measured: 89 diffs became 0, exit 0, both tell halves passing, because both sides had become the shadow. - Release notes describe intent; TAGS are ground truth. Before trusting a changelog cross-reference about what shipped where, run `git diff -- ` — the "1.2.1 pickle shim" claim recurred twice from a changelog line describing a thing that never shipped. -- Never pipe a gate's output. Under zsh, `compare.py | tail` makes `$?` tail's status. Redirect to a file and read the file. +- Never pipe a check's output when its exit code matters. Under zsh, `snapshot.py | tail` makes `$?` tail's status. Redirect to a file and read the file. - Mutation-test a new guard before believing it, and mutate the thing the guard watches — a survivor usually means the fixture satisfies the invariant for free. - Verify the restore, not just the mutation: diff against the pre-mutation copy; do not trust the harness's own restore report. - Run all the gates, not the ones you remember: ruff runs before mypy and pytest in CI, and each has caught what the others passed. - Purge __pycache__ between same-length source mutations; stale bytecode makes a changed file measure as unchanged. - After NARROWING a rule, check the receiver: the names a narrowed rule sheds land on a neighbour, and nothing guarantees the neighbour's prose describes what it inherited — #375 fixed an over-claiming rule and relocated the bug onto its neighbour. Ask also what the old behavior was CONCEALING: #379's attachment removed the input a test used to build an all-particle middle name (#402), and #400's reserve fix exposed the dual-membership count shape #397 names -- twice in one session a fix's real yield was a defect it stopped hiding. -- A gate run with no UNEXPLAINED line for a name is not evidence that the baseline read it the same: a broad rule may have claimed the diff. #603's first draft wrote that 1.4.0 read a title past the second comma as a title because no 1.4.0 name moved unexplained, when the 1.4.0 ledger's `fix(comma-family)` rule, a regex over every Latin comma name, was claiming each one in the classified summary; the released wheel read suffix 'Jr., Attorney General'. Read the classified summary for the name, and ask the wheel (`PYTHONSAFEPATH=1 uv run --isolated --no-project --with nameparser==X`, from outside the worktree) before writing what a release did. -- A ledger rule EXPLAINS a diff; nothing checks that its own sentence still DESCRIBES it, so a rule can go on covering a name it has stopped being true of and the gate stays green. Measured on #533: a `(?i)` anchor written for `John née Jones Smith Ma` — the clause keeping the credential — also reached the capitals spelling, which that change made read the opposite way, the clause giving the credential up. The rule still matched the name and still covered its fields, so `unexplained: 0` was silent about it. What found it was ATTRIBUTION rather than the gate: measuring every corpus name the change touches against the PARENT commit, the EXPLAINED ones as well as the unexplained, and asking of each which change actually moves it. Do that before writing a ledger rule's prose, not only when a name goes unexplained — the failure mode is a rule that reads as authoritative and argues for the reading it lost. Second instance, #397/#461 (2026-09-20), and it is the same method finding the OPPOSITE shape: `fix(initials-per-word)` had been silently ABSORBING eleven 1.4.0-parity breaks that belong to the new connective rule, so the gate was green and the attribution was wrong about which change owned them. A rule can over-cover as well as mis-describe, and neither is a diff the gate can report. +- A check that is quiet about a name is not evidence that the baseline read it the same. #603's first draft wrote that 1.4.0 read a title past the second comma as a title because no 1.4.0 name moved unexplained, when the 1.4.0 ledger's broad `fix(comma-family)` rule was claiming each one; the released wheel read suffix 'Jr., Attorney General'. Ask the wheel (`PYTHONSAFEPATH=1 uv run --isolated --no-project --with nameparser==X`, from outside the worktree, or `tools/differential/compare.py --baseline X`) before writing what a release did. - A skip is indistinguishable from "correctly declined": pytest turns an empty parametrize into a skip, and a filter that widens its own skip set cannot fail. After changing any selection shape, verify the guard still REACHES the code it watches — assert the selected set is non-empty, or force-a-decision on its size. - A differential corpus cannot evidence behavior keyed to OUT-of-vocabulary shapes: it holds only names someone wrote down, and an unrecognized word is by definition outside the vocabulary — a green run over the corpus proves nothing about such a rule. - The corpus can be near-blind to a WRITING CONVENTION even where the vocabulary is well covered, and a small honest count then reads as a small blast radius. Measured for rules.md#P6: of 782 corpus names, 245 carry a comma, TWO of those end in a particle, and ONE of the two clears the words-to-spare guard and actually moves — so the Dutch trailing-tussenvoegsel listing the rule exists for is essentially unsampled. Before reporting "N names move", report the size of the population that COULD move; when that is ~1, the number is evidence about the corpus. This note's first wording said "exactly ONE ends in a particle", conflating the population with the movers — the very error it exists to prevent, committed inside the correction. Caught by re-running the count against AGENTS.md's axis 1 rather than by re-reading it. diff --git a/docs/design/rules.md b/docs/design/rules.md index ebd47cd4..133472dd 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -15,7 +15,7 @@ An `annotation` names a policy, locale (`[ru]`), or extras gate (`[ja+segmenter] One class of rule stands outside the normative reading and declares it on a `tolerated: ` line beside its pointer line. Such a rule is ABOUT an input the writing systems it speaks about do not produce — a shape no convention writes, read best-effort because parsing is total over strings — and it describes what the parser does with that input instead of promising it. Its statement is still precedence-bearing and its examples are still executable and still run, but they illustrate rather than promise, and the behavior is changeable without notice. W3 is the only such rule today (the 2026-09-01 comma demotion). -The marker's unit is the whole rule, because that is the unit `tools/differential/build_rules_corpus.py` skips: it harvests no example from a marked rule, so none of them is enforced against released baselines. Two obligations follow, and neither is automatic. A clause that reaches past the tolerated shape does not belong in a marked rule — say it in a normative one. And the skip only STOPS enforcing a name; keeping it watched is a separate act, discharged here by giving each demoted example a `tolerated` row in `tests/v2/cases.py`, which projects it onto the differential's radar tier. +The marker's unit is the whole rule, so a clause that reaches past the tolerated shape does not belong in a marked rule — say it in a normative one. The marker changes what the rule promises, not what is watched: a marked rule's examples are in `tools/differential/corpus_rules.jsonl` like any other, so the snapshot shows when their reading moves (#664). ## Not in scope @@ -1396,13 +1396,11 @@ S3. Rationale: credentials are often written run together with writes no abbreviations with a period that only a Latin convention writes — `John Smith 田.中.` is the shape — and a composed form no writing system produces is tolerated input - rather than contract. A normative rule cannot hold an example of - it without putting the string into the corpus that enforces it - at released baselines, so the witnesses are the tolerated row + rather than contract, which an example of a normative rule would + promise. So the witnesses are the tolerated row tests/v2/cases.py's - an_initialless_script_glued_into_periods_is_not_this_shape, - which keeps the name on the differential's radar tier, and the - CJK assertions in tests/v2/pipeline/test_vocab.py's + an_initialless_script_glued_into_periods_is_not_this_shape and + the CJK assertions in tests/v2/pipeline/test_vocab.py's test_period_joined_vocab_retires_the_single_character_chunk. W3 states the same precedence for the same reason. history: decisions.md#S2 · interacts: S2, C1, H2, W3 · implemented: nameparser/_pipeline/_vocab.py @@ -2405,7 +2403,7 @@ W3. Rationale: a family name declared by a comma is the writer's "田中さん 様." → suffix="さん 様." "김민준씨." → suffix="씨." "田中. 太郎" → family="田中." - tolerated: native CJK writing has neither a family-comma convention nor an edge full stop of any width on a name word, so the five comma lines above and the three period lines under them illustrate current behavior — changeable without notice — rather than promise it; the line carrying neither, beside them, is W1's claim, which is normative. All eight stay watched at every released baseline on the differential's radar tier (tools/differential/corpus_cjk_tolerated.jsonl, projected from the `tolerated` rows of tests/v2/cases.py) instead of its contract tier, and those rows pin them at HEAD. + tolerated: native CJK writing has neither a family-comma convention nor an edge full stop of any width on a name word, so the five comma lines above and the three period lines under them illustrate current behavior — changeable without notice — rather than promise it; the line carrying neither, beside them, is W1's claim, which is normative. All eight stay watched by the snapshot (tools/differential/snapshot.jsonl, over corpus_rules.jsonl and corpus_cjk.jsonl), and the `tolerated` rows of tests/v2/cases.py pin them at HEAD. history: decisions.md#W3 · interacts: W1, W2, C1, H2 · implemented: nameparser/_pipeline/_script_segment.py W4. Rationale: Chinese, Japanese and Korean all write the family diff --git a/docs/migrate.rst b/docs/migrate.rst index be2c443d..9c2a903a 100644 --- a/docs/migrate.rst +++ b/docs/migrate.rst @@ -113,8 +113,9 @@ warned removals: - Gone; empty fields are always ``''`` Finally, if you want to see the difference on your own data rather than -ours, ``tools/differential/`` in the source repository diffs 1.4 and -2.0 parses over a corpus of names you supply. It is development +ours, ``tools/differential/compare.py`` in the source repository diffs +any released version's parses against the source tree over the corpora +beside it, or over a file of your own names (``--corpus``). It is development tooling, not part of the installed package — see its README. Attribute map diff --git a/nameparser/_facade.py b/nameparser/_facade.py index 8fadbdd6..1725b616 100644 --- a/nameparser/_facade.py +++ b/nameparser/_facade.py @@ -709,8 +709,8 @@ def all_particle_guard(got: list[str], words: list[str]) -> list[str]: # which compare the seven role fields only (Case carries # no initials column); the covering test is # tests/test_initials.py::test_initials_middle_name_all_prefixes, - # and since #484 the differential compares initials() on - # both surfaces for names whose roles agree. _split_last + # and tools/differential/snapshot.jsonl records initials() + # on both surfaces for every corpus name. _split_last # already applies the same guard to the base, which is why # last_base was never empty here. return [_render._first_letter(w) for w in words] diff --git a/tests/v2/cases.py b/tests/v2/cases.py index e1904a43..560b46a9 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -126,12 +126,8 @@ class Case: #: not shapes 6/7's. Restricted to CJK-bearing text (`_has_cjk`): #: it exists to demote composed/wrapped CJK forms specifically, and #: a Latin row asking for it is a smell until some future arc - #: argues otherwise. build_cjk_corpus.py reads the flag: a - #: tolerated row's text goes to corpus_cjk_tolerated.jsonl instead - #: of corpus_cjk.jsonl. Mark every row of a text, or none: the - #: generator reads the flag per TEXT (a corpus line is a name - #: string) and HARD-ERRORS on a split declaration, which - #: __post_init__ cannot catch from inside one row. + #: argues otherwise. Its text still goes to corpus_cjk.jsonl, so + #: the snapshot shows when its reading moves. tolerated: bool = False def __post_init__(self) -> None: diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 0d9b6327..023cdd43 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -341,7 +341,7 @@ def _fake_compare(monkeypatch: pytest.MonkeyPatch, baseline: str, """compare() over _ENTRIES with the worker replaced by the tree's own readings, optionally altered by `alter(results)`; also returns the entries the worker was sent.""" - monkeypatch.setattr(snapshot, "corpus_entries", lambda: list(_ENTRIES)) + monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: list(_ENTRIES)) monkeypatch.setattr(compare, "_load_snapshot", lambda: snapshot) sent: list[list[dict[str, Any]]] = [] diff --git a/tools/differential/README.md b/tools/differential/README.md index 21a07f6d..dfc81df4 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -1,89 +1,83 @@ -# Differential harness (a released baseline vs the working tree) +# Corpus snapshot and released-wheel comparison -Dev-only tooling for the 2.x line (migration plan S5). Not shipped -(excluded from the wheel by the packaging config -- only `nameparser/` -is packaged) and not CI-gated. Run it by hand when touching parsing -behavior, and before cutting a release. +Dev-only tooling (not shipped: only `nameparser/` is packaged). Two +tools read the same corpora, `corpus*.jsonl` beside this file: -Two processes, two environments: +- **`snapshot.py`** records how the working tree reads every corpus + name, one line per name in `snapshot.jsonl`, and + `tests/v2/test_snapshot.py` fails when the tree stops matching it. + This is the check that runs on every change (#664). +- **`compare.py`** reads the same names with a released nameparser + installed fresh from PyPI and prints how the tree differs. It is for + questions about a release older than the snapshot, and it fails on + nothing. -- A **baseline worker** runs under a pinned nameparser installed fresh - from PyPI via a PEP 723 inline script. It is not a checked-in file: - `compare.py` renders it from a template with the version pin - substituted and writes it to a temp directory outside the worktree. - That placement is a safety mechanism rather than plumbing -- it is - what makes the invocation traps below unreachable rather than merely - documented. -- `compare.py` runs in the project's own dev environment and imports - `nameparser` normally: the working tree, on whichever surfaces the - baseline supports. +Both print moves the same way: grouped by which fields changed, with +each name's old and new values, so a large change reads as a few +kinds of move rather than a wall of names. -## Running it +## The snapshot + +``` +uv run python tools/differential/snapshot.py # check, grouped +uv run python tools/differential/snapshot.py --record # re-record +uv run python tools/differential/snapshot.py --since v2.5.0 +``` + +Each line holds one (name, order): the seven role fields, the reported +ambiguity kinds and `initials()` on the 2.0 surface, and, under +`facade`, whatever `HumanName` reads differently from the 2.0 surface +(3 names on 2026-10-13). Empty values are left out, so a reading that +gains a field shows as one added key. + +**When the test fails.** Read the grouped moves it prints. If every +move is one the change intends, re-record and commit `snapshot.jsonl` +with the change: the diff of that file is the review's list of the +names the change moved, one line per name. A move nobody predicted is +a finding, the same as any failing test. A corpus that loses names +shows them as `(removed from corpus)`, so a truncated file cannot +pass for a clean run. + +**At release time.** `--since ` compares the tree +against the snapshot as committed at that tag: the names this release +moved. That is the measurement a release-note claim about moved names +is drawn from. Count CLDR's fictional renderings out of such a figure, +or say it includes them. + +`--tree PATH` reads with another checkout's `nameparser` (the corpus is +always this checkout's), and checks that the import really came from +there. It is how two commits are compared without a release between +them. + +## Comparing against a released version ``` -uv run python tools/differential/build_corpus.py --ref > tools/differential/corpus.jsonl # only when regenerating uv run python tools/differential/compare.py --baseline 1.4.0 uv run python tools/differential/compare.py # bare = DEFAULT_BASELINE, the last release +uv run python tools/differential/compare.py --baseline 1.4.0 --corpus my_names.jsonl ``` -`compare.py` spawns the worker as a subprocess, feeds it every corpus -entry as a line of JSON -- `{"name": ..., "order": ...}`, where `order` -names the `name_order` constant the entry is parsed under on both -sides and is `null` for an entry carrying no order -- and diffs the two -sides field by field. Every diff is checked against that baseline's -ledger: - -- Matches a rule -> counted as an intentional, classified change. -- Matches no rule -> reported, and what happens next depends on the - name's tier (see below): on a CONTRACT corpus it prints under - `UNEXPLAINED` and the run exits 1; on a RADAR corpus it prints under - `UNCLASSIFIED (radar)` and the run keeps exiting 0. - -An unexplained diff on the contract tier means either a real parity -bug (fix it, don't allowlist it) or a known change whose ledger rule -needs widening. The run must exit 0 at every baseline you claim before -a release; the classified summary it prints is the source for the -"Behavior Changes" section of `docs/release_log.rst`. - -## Baselines - -`--baseline VERSION` chooses what the tree is compared against, and -two things follow from it: which ledger is read -(`expected_since_.toml`, a hard error if absent) and which -surfaces are compared (the facade alone below 2.0, which has no v2 -API; both from 2.0 on, ambiguity kinds included). - -Run both before cutting a release: - -- `--baseline 1.4.0` — the v1 compat contract. -- `--baseline ` — what changes for a user upgrading. - -The worker is generated per run, with the pin substituted, into a temp -directory outside the worktree. Its first output line is a version -tell, and `compare.py` aborts before comparing anything if the wrong -version answered or if the module resolved inside the checkout. - -**Both** sides are proved, not just the baseline. `compare.py` also -checks that its own `nameparser` is this checkout's source package and -prints it on a `tree:` line — see the third trap below for why a bare -import was not enough. - -A rule's `fields` names roles the way `Role` does, whichever surface -the diff came from, plus two pseudo-fields. `_ambiguities` carries a -change in reported `AmbiguityKind`s. `_initials` (#484) carries a -change in `initials()` -- the facade's at every baseline, the core's -from 2.0 on -- and enters a name's diff ONLY when every role and the -ambiguity kinds agree on every compared surface: a role move drags -its initials with it, so that movement is the role diff's consequence -and is neither compared nor printed, while an initials change with -the fields identical is render-layer drift the field comparison -cannot see. A rule that lists `_initials` therefore lists nothing -else; `validate_rules` refuses the mix as silently dead. The roster is -not restated here: it is `Role`'s members, `validate_rules` rejects -anything outside them, and a copy in prose is a copy that goes stale -when a role is added. The facade reports `first`/`last`; those are -canonicalized on the way in, and the `UNEXPLAINED` block prints the -canonical name so what you read is what you write. +`--corpus PATH` (repeatable) compares a file of your own instead of the +corpora here, one JSON string per line. + +`compare.py` renders a small worker script with the release pinned in +PEP 723 inline metadata, writes it to a temp directory outside the +worktree, and runs it with `uv run --no-project`. The worker parses +every corpus name with the pinned release; `compare.py` parses the same +names with the tree and prints the moves. From 2.0.0 on both surfaces +are compared; at 1.4.0 the facade alone, since 1.4 has no 2.0 API. +A name tagged with a family-first shape is skipped below 2.0.0, which +introduced `name_order`. The last line counts the moved names and how +many are Latin-only. + +Most of what is left in `compare.py` exists for one failure: a +comparison that silently reads the tree on both sides and reports no +differences. The worker prints its own `__version__` and `__file__` +first, and the run aborts unless the version matches the requested +release *and* the module loaded from outside the repository. +`compare.py` also checks that its own import is this checkout's source +package, and strips `PYTHONPATH` from the worker's environment. The +traps below are why. ## The three invocation traps @@ -143,13 +137,6 @@ the TREE's version, so version agreement proves nothing when tree and baseline share a number, while a genuine wheel at the wrong version passes any path check. -One shell note while you are here: `compare.py | tail` swallows the -exit code under zsh. `$?` after a pipeline is `tail`'s status, and -`PIPESTATUS` is a bash array zsh does not define at all -- zsh's own -is the 1-indexed `pipestatus`, so `${PIPESTATUS[0]}` is the empty -string and a failing run reads as a passing one. Redirect to a file -and read the file instead of piping. - ### Trap 3: `PYTHONPATH` shadows BOTH sides, and the tell passes The first two traps are about which library answers as the *baseline*. @@ -171,7 +158,9 @@ corpus: 751 names; intentional diffs: 0; unexplained: 0 exit 0, with **both halves of the baseline tell passing** -- the version matched, and the path was outside the repo. 89 diffs became 0. -Two things close it. `_worker_env()` strips `PYTHONPATH`/`PYTHONHOME` +(That output is the ledger-era summary line; the trap is the same for +today's report, which would print no differences.) Two things close +it. `_worker_env()` strips `PYTHONPATH`/`PYTHONHOME` from the child, and `_check_tree()` requires `compare.py`'s own `nameparser` to be this checkout's **source package**. @@ -187,192 +176,44 @@ Why this is easy to miss when probing by hand: from the repo root, the checkout wins and the trap does not reproduce. Only the script invocation shows it. -## The corpora and their tiers - -`compare.py` reads **every** `corpus*.jsonl` beside it by default -(deduped), because a corpus you have to ask for by name is a corpus -that stops being run. Pass `--corpus PATH` (repeatable) to narrow it. -The run's `corpora:` line names each file with its entry count, and -with `(N, K skipped)` where K entries were left out of this baseline's -comparison (see the shape key below). Each file also needs an entry in -`_CORPUS_FLOORS`, a minimum set a little under its real size: a corpus -that silently shrinks to nothing would otherwise report a green run. - -| File | Source | Tier | Blind to | -|---|---|---|---| -| `corpus.jsonl` | v1's own test suite at a pinned ref | radar | anything 2.0 added — v1's authors had no reason to test a typographic nickname delimiter or a Cyrillic title | -| `corpus_issues.jsonl` | name-like strings harvested from the GitHub issue tracker | radar | anything nobody ever reported | -| `corpus_cjk.jsonl` | the CJK-bearing rows of `tests/v2/cases.py` that do not declare `tolerated`, via `build_cjk_corpus.py` (#295) | contract | anything the case table itself missed — it re-witnesses reviewed expectations at the baseline boundary rather than discovering new shapes | -| `corpus_cjk_tolerated.jsonl` | the `tolerated` CJK rows of `tests/v2/cases.py`, via the same `build_cjk_corpus.py` run (2026-09-01) | radar | any composed or wrapped CJK form nobody wrote a case row for — it holds demoted names, and demotion presupposes admission | -| `corpus_cldr.jsonl` | Unicode CLDR's person-name formatting test data at `release-48` — each fictional name's long, formal rendering in givenFirst, surnameFirst and sorting order, about 110 locales in native scripts — via `build_cldr_corpus.py` (2026-10-12); Unicode-3.0, notice in `cldr_LICENSE` | radar | real-world name distributions: the names are written to fill every field, so full-house names are over-represented and plain two-word names under-represented | -| `corpus_rules.jsonl` | every example of every NORMATIVE rule in `docs/design/rules.md`, via `build_rules_corpus.py` (#414) — a rule carrying that doc's `tolerated:` marker is skipped whole, its examples illustrating rather than promising | contract | anything the rules doc has no example for, and anything a tolerated rule's examples are the only witness of | -| `corpus_shapes.jsonl` | shape-tagged rows of `tests/v2/cases.py`, via `build_shapes_corpus.py` (#468) | contract | anything no one has tagged a row for | - -Since the v2.3 tier split (#468), a corpus is CONTRACT or RADAR -- -the roster is `_CORPUS_TIERS` in compare.py, fail-closed like the -floors. Contract corpora hold names someone chose, and an unmatched -diff on one is UNEXPLAINED and fails the run. Radar corpora hold the -names the contract does not answer for -- the scraped and harvested -ones, and since 2026-09-01 the deliberately demoted ones too: their -diffs still classify against the ledger, so intended changes keep -their release-note grouping, but an unmatched radar diff prints under -UNCLASSIFIED (radar) and cannot fail the run or demand a ledger rule. Nothing is deleted to keep the -gate quiet -- a meaningless string in radar costs one parse and a -report line. To promote a radar name, give it a tests/v2/cases.py row -and a shape tag: it enters the contract by being chosen. A -name can also travel the other way, and one file records it: -`corpus_cjk_tolerated.jsonl` holds names that WERE chosen and were -then demoted, one reviewed row at a time, by the `tolerated` flag on -their case rows (the 2026-09-01 CJK demotion — composed and wrapped -CJK forms, read best-effort). Radar is what "we still watch it, we no -longer enforce it" costs: the diffs still classify and still group in -the release notes, the case rows still assert every one of these -parses in the suite, and clearing the flag promotes the name back. -What the flag moves is the file, which is not quite the same as the -tier: contract files load first and the dedup keeps the contract -reading, so a text another contract corpus also holds stays contract -until it leaves there too. Five of these were `rules.md` examples in -`corpus_rules.jsonl` when the file was created, and the same day's -rules.md edits took all five out of it — W3 marked `tolerated:`, -W2's two comma examples swapped for pure ones, C1's two moved into -W3 — so every text in the file reads radar today, and the sentence -above is the standing rule the next demotion has to satisfy rather -than a note about those five. A -`[[never]]` exclusion outranks the tier either way: it was chosen too --- someone wrote its `why` and its `examples` -- so a name it refuses -stays UNEXPLAINED and fails the run even when the name itself sits in -a radar file. - -A CONTEST row -- a recorded diff shape with a winner pinned beside it -(`MOVED SHAPE`, below) -- is the second thing that outranks the tier, -and the rule is stated rather than inherited: it is fatal on both -tiers because it carries an argument, and a moved shape has made that -argument's premise false. A WATCHED row -- a shape recorded alone, in -`_WATCHED_DIFFS` -- does not outrank the tier: it follows the tier of -the default-order entry it was measured on, fatal on a contract name -and printed under `MOVED SHAPE (radar)` on a radar one. Measured -2026-09-03, before the rule was written, MOST of the contest rows in -`_RECORDED_DIFFS['expected_since_1.4.0.toml']` sat on radar-tier names --- 21 of 31, and 32 of the 45 there were on 2026-09-05, the fourteen #498 pinned on -2026-09-05 adding ten radar rows and four contract ones on top of the -2026-09-05 period-class demotion, which moved `'김민준 씨.'` from the -contract CJK corpus to the tolerated one (measured 2026-09-05; -decisions.md's #501 adjudication carries the reconciliation, and the -recipe below is unchanged) -- so radar PARSER DRIFT could already fail -the run wherever someone had pinned a winner (RECOMPUTE: for each row, -take the tier of the first corpus file holding the name with contract -files sorted first, as `main()` loads them -- a NAME's tier, which is -the unit this count needs and not the one a watched row's severity -reads: that reads the default-order ENTRY's tier, per the -`MOVED SHAPE` section below, and the two recipes agree on every name -that has no declared-order entry); the rule keeps that, on the ground -that it is the argument and not the tier a contest row defends. Which -of the two outranks the tier more WIDELY is deliberately not claimed -here, because the answer inverts with the unit and neither unit is the -point: the exclusions number two against 45 rows, while the two -`[[never]]` patterns reach 60 corpus names between them, 37 of those -radar-tier (measured 2026-09-03 by matching each `name_regex` against -every name in the `corpus*.jsonl` glob, tiers read the same way) -- -more radar names than the roster pins, which is 32 as of 2026-09-05. -That is a pin doing what pins do -- someone wrote the row by hand and -it says what the name does -- but it is not what "a radar diff can -never fail the run" leads a reader to expect. Read the tier rule as -being about UNMATCHED diffs, which is the only thing it was ever -measured over. +## The corpora + +| File | Source | Blind to | +|---|---|---| +| `corpus.jsonl` | v1's own test suite at a pinned ref | anything 2.0 added: v1's authors had no reason to test a typographic nickname delimiter or a Cyrillic title | +| `corpus_issues.jsonl` | name-like strings harvested from the GitHub issue tracker | anything nobody ever reported | +| `corpus_cjk.jsonl` | the CJK-bearing rows of `tests/v2/cases.py`, via `build_cjk_corpus.py` (#295) | anything the case table itself missed | +| `corpus_cldr.jsonl` | Unicode CLDR's person-name formatting test data at `release-48`: each fictional name's long, formal rendering in givenFirst, surnameFirst and sorting order, about 110 locales in native scripts, via `build_cldr_corpus.py` (2026-10-12); Unicode-3.0, notice in `cldr_LICENSE` | real-world name distributions: the names are written to fill every field, so full-house names are over-represented and plain two-word names under-represented | +| `corpus_rules.jsonl` | every example in `docs/design/rules.md`, via `build_rules_corpus.py` (#414) | anything the rules doc has no example for | +| `corpus_shapes.jsonl` | shape-tagged rows of `tests/v2/cases.py`, via `build_shapes_corpus.py` (#468) | anything no one has tagged a row for | + +Each covers a blind spot of the others. `corpus.jsonl` is reproducible +forever from an immutable git ref, while the issue tracker is mutable, +so regenerating `corpus_issues.jsonl` is an explicit, reviewable act. +The CJK corpus exists because both of those are structurally blind to +unspaced CJK (v1's banks never tested it; `build_issues_corpus.py` +requires an internal space, which unspaced names never have). The +rules corpus watches the names the project wrote down as defining its +behavior: the doc tests pin each example against an expectation stored +beside it, so a deliberate change edits both and stays green, while +the snapshot shows the moved name in the diff. A corpus line is a bare JSON string or an object carrying `name` and, -optionally, `tests` or `shape` -- the input-shape id from -`tools/differential/shapes.py`, which is where each shape's notation, -the `name_order` it is an input shape FOR, and the oldest baseline -whose worker can honor that order are written down. `compare.py` -resolves every entry's shape through that table: an entry whose shape -declares an order is parsed under that order on both sides and -compared on the v2 surface alone (the facade is the v1-compat surface, -and a family-first name is not a v1 contract), and an entry whose run -predates its shape's `min_baseline` is left out of the comparison -- -reported as `skipped N names tagged shape(s) [...]` and counted in the -`corpora:` line, so a shrunken comparison is never silent. That a -family-first name is not compared under the default order is -structural, not a ledger exception: its shape says what the name is an -input FOR. - -`corpus_shapes.jsonl` is the corpus of those entries: every distinct -(shape, name) pair from the shape-tagged rows of the case table, so a -name tagged under two orders is two entries and two comparisons. -Regenerate it after tagging or editing a tagged row; `--coverage` -prints names-per-shape instead of writing, which is the "which shapes, -how many names deep" answer: +optionally, `tests` (labels, below) or `shape`, an input-shape id from +`shapes.py`, which records each shape's notation, the `name_order` it +is an input shape FOR, and the oldest release that can honor that +order. An entry whose shape declares an order is read under that order +on the 2.0 surface alone. Any other key is refused, since a misspelled +key would drop the shape and read the name under the default order +with nothing saying so. `corpus_shapes.jsonl` holds every distinct +(shape, name) pair from the tagged case rows, so a name tagged under +two orders is two entries. `--coverage` prints names per shape: ``` uv run python tools/differential/build_shapes_corpus.py uv run python tools/differential/build_shapes_corpus.py --coverage ``` -It is pinned exactly as the CJK corpus is -- `tests/v2/test_ledger_guards.py` -holds the checked-in file equal to the generator's selection, so a row -tagged without regenerating fails the suite instead of leaving the -contract tier narrower than the case table says it is. - -They are deliberately separate rather than merged: `corpus.jsonl` is -reproducible forever from an immutable git ref, while the issue -tracker is mutable, so regenerating `corpus_issues.jsonl` is an -explicit, reviewable act that can only add names while the harvester is unchanged (changing its screens, as #413 did, can remove one) — and the CJK corpus -exists because BOTH of those are structurally blind to unspaced CJK (v1's -banks never tested it; `build_issues_corpus.py` requires an internal -space, which unspaced names never have). It regenerates from the case -table, and `tests/v2/test_ledger_guards.py` pins the checked-in file -against the generator's selection, so a CJK case row added without -regenerating fails the suite instead of silently narrowing this gate. - -The rules corpus answers a different question from the doc tests that -already execute those examples. Those pin an example against an -expectation stored beside it, so a deliberate behavior change edits -both in one commit and the test stays green — a doc example cannot -warn about the change that edited it. Parsing the same name with a -*released* baseline can, because no commit can edit 1.4.0. It is -generated and pinned the same way the CJK corpus is. - -The issue corpus earned its place on the first run — 166 of its 198 -names were not in `corpus.jsonl`, and it immediately surfaced five -intended-but-unclassified 2.0 behaviors (#273 typographic delimiters, -#269 non-Latin vocabulary) plus one shape no test had considered: a -**leading** `"Ph. D."`, which v1 split into title `Ph.` + given `D.`. - -It earned it again at #413. The harvester matched names in quotes -only, while this tracker writes them in backticks, so the corpus whose -purpose is "what users reported" was blind to how this project -reports — 200 names became 381. Among the arrivals were the headline -example of nearly every issue that fixed them, including -`Ursula von der Leyen geb. Albrecht`, which sat in #399's own title -while that issue shipped noting the class was invisible to this -harness. Classifying the arrivals widened `fix(#367)`, which had -asked in writing to be widened, added six rules, gave -`fix(leading-credential)` the `family` role its own shape always moved, -and narrowed the trailing-`Ph. D.` exclusion as its comment instructed -the moment a ` Ph. D.` name appeared. - -Backticks prompted a second look at prose, and the same change added -two screens neither branch had: `:` joins the structural characters (it -appears in no name in any corpus, and accounted for three error -messages and a PyPI trove classifier), and a short list of English function words -rejects capitalized sentences the character screen cannot see — -`What this gate does not cover` is well-formed as a phrase. That list -is narrow on purpose: `and`, `the`, `of`, `will`, `can` and `do` all -stay out of it, because `Rob And Beth Edmunds`, `The Hon`, `Duke of -Wellington` and `Anh Do` are real corpus names. - -The rules corpus earned its place the same way — 113 of its 155 names -were in no other corpus, and on its first run 12 of them turned out to -have moved during the 2.2 cycle with nothing observing it. Classifying -them surfaced an unlogged behavior change (`mc` moving into the -never-given particles, which moves `Mc Donald`), two rules broad -enough to absorb a regression once a name of the right shape existed -(`fix(#274)`'s marker matching a *parenthesized* `Nee`, which is a -nickname under the default facade, and the acronym rule's dotted -`m\.?a\.?` reaching the real name `John Smith M.A.`), and two -pre-existing behaviors that had never had a corpus name to exercise -them. - ## Corpus provenance `corpus.jsonl` is checked in as a test fixture. It was built by @@ -411,8 +252,7 @@ apart from a test method at a glance (`bank:` when the assignment target isn't a plain name). Labels merge across files: two files sharing a method name (e.g. two `test_basic_parsing`s) produce one merged label set on any name both contribute, which is accepted -- -the merged labels still describe the same string. `compare.py` -surfaces these as `[v1: ...]` tags on radar rows. +the merged labels still describe the same string. Regenerate the corpus only (a) at the same ref, as a format-only enrichment, with the name set proven identical to the previous file by @@ -449,431 +289,20 @@ as new issues arrive. Over-collection is fine in both builders: the comparator just parses more names, and junk like `Bridge (1.4)` costs one parse and produces no diff. -`corpus_cjk.jsonl` and `corpus_cjk_tolerated.jsonl` are the two halves -of ONE projection of the case table, written by one run: +`corpus_cjk.jsonl` is a projection of the case table: ``` uv run python tools/differential/build_cjk_corpus.py ``` -Every distinct CJK-bearing `text` in `tests/v2/cases.py` goes to the -first file, or to the second when its rows declare `tolerated`. The -flag is read per TEXT, not per row, because a corpus line is a name -string: a text carried by a default row and a policy/locale fork of it -is one line in one file. A text marked on one of its rows and not -another is a hard error rather than a silent choice — the generator -refuses the run, since either answer puts a name on a tier half its -rows deny. - -## The ledgers (`expected_since_.toml`) - -One ledger per baseline, so each release's classified changes stay as -history rather than being edited into the next one's. - -Each `[[change]]` entry needs `issue` (a short label, ideally an -issue number or `fix()` matching a `tests/v2/cases.py` -classification) and `name_regex` (searched against the raw input -string) AND `fields` (the diffing rule matches only if the observed -diff fields are a subset of this list), which since #452 must also -name EXACTLY the roles that rule's own diffs move -- see below. Keep both as tight as the actual diff allows -- a loose -rule can mask a real regression. **Both keys are REQUIRED**, and each -ban has its own issue: `validate_rules` rejects a rule carrying -neither (it would match every diff), one carrying `fields` and no -`name_regex` (#451 -- no name narrowing, so it claims every name whose -diff fits its roles), and one carrying `name_regex` and no `fields` -(#456 -- no role narrowing, so on any name its regex reaches it claims -every diff shape there is, measured, 256 of them from baseline 2.0 on and 128 below it). The three are one -rule with one reason: a rule narrows by name AND by role, or it is not -a rule. Note the two bans are each other's obvious wrong answer -- -deleting `fields` to silence an over-declaration failure lands on -#456's, and adding `fields` while dropping the regex lands on #451's. - -**`fields = ["_initials"]` is a rule of its own kind** (#484). It -classifies a change in the derived `initials()` view on a name whose -seven roles did not move, and nothing else -- `main()` never puts -`_initials` into a diff beside a role, so a rule mixing the two is -refused at startup. Such rules close the 1.4.0 ledger, and each 2.x -ledger carries the subset visible from its baseline; their comments -say which view change each one names. - -**`orders` is the optional third narrowing** (#468). A rule may carry -`orders = ["FAMILY_FIRST", ...]` -- public order-constant names, taken -from the ones `shapes.py` declares, so `validate_rules` rejects a name -no shape asks for rather than letting the rule sit dormant. A rule -carrying it matches only diffs from comparisons run under one of those -orders. Omit the key and the rule is order-blind, which is what every -rule written before shape-tagged entries existed is. - -`"DEFAULT"` is the one member `shapes.py` does not supply, and cannot: -it names the comparison run under no declared order (`order` is -`None`), which is the absence of a shape rather than one of them. It -exists because TOML has no null to put inside an array, so without it -a rule explaining only default-order diffs had no way to SAY so and -had to stay order-blind -- and an order-blind rule leaks the other -way from the leak the key was added for, absorbing an order-bearing -diff on any name its regex happens to reach. `orders = ["DEFAULT"]` -is a default-order-only rule; `orders = ["DEFAULT", "FAMILY_FIRST"]` -is a rule that genuinely explains both and stops there. - -A run PRINTS the order-blind absorptions it sees: when a rule with no -`orders` key explains a diff from an order-bearing comparison, the -report carries an `ORDER-BLIND` block naming the issue, the name and -the order. It is informational and outside the exit code -- order-blind -rules stay legal -- but the absorption is no longer invisible. - -It exists because a name can now be compared more than once, and the -two diffs can move the SAME roles for opposite reasons. -`de la Cruz Juan Carlos` is compared under both family-first orders -from `corpus_shapes.jsonl`, where #395's fold reads family -`de la Cruz` and distributes the leftovers, and under the default -order from `corpus_rules.jsonl`, where rules.md#P1 says the whole -string is the family. If that fold ever leaked into the default order -it would move `{family, given, middle}` -- exactly what the -`feat(#395)` rule declares -- so an order-blind rule would claim the -leak, label it intentional and exit 0: #372's failure mode aimed at -the most plausible regression of the very change the rule describes. -Exclusions stay order-blind for now; refusal is monotone, so the worst -an over-wide one can do is make a name report `UNEXPLAINED`, which is -loud. - -**That closes the SHAPE, not the property.** A required `name_regex` -is not a bound on how much a rule reaches: the only width check is the -sentinel probe, which rejects a pattern matching all four of -`_SENTINELS` and nothing narrower. Measured over the 1090-name corpus, -`name_regex = "[a-z]"` passes validation and reaches 941 names, `" "` -reaches 1027 -- the old catch-all with a fig leaf. What #451 changed is -that such a rule now has a `_CORPUS_CLAIMS` reach and digest to record, -so its breadth is visible ONCE, to whoever reviews that number, instead -of being invisible forever. That roster is by its own docstring "inert -for a brand-new rule", so the review is the check. A reach ceiling is -the mechanism that would bound this; it is still proposed on -[#452](https://github.com/derek73/python-nameparser/issues/452), whose -other half -- the declared-fields check below -- has landed. - -**`fields` must be EXACT, not merely a bound.** A rule's declaration -must equal the union of the diffs it actually explains, and -`compare.py` recomputes that union at the end of every run: a rule -declaring a role none of its diffs moves is reported `OVER-DECLARED` -and exits the run non-zero, like an unexplained diff or a dormant rule -that explained one. The excess is not inert. `classify()` admits a -rule when the observed diff is a SUBSET of `fields`, so a role nothing -moves is a standing claim on every future diff that shrinks into it -- -which is how `fix(#424)` kept explaining 'Freiherr von Richthofen V' -after #410 narrowed that diff from three roles to two, with no run -ever naming it (see decisions.md#H1). The check is exact rather than -heuristic, and cheap for the same reason: `classify()` already -requires `declared >= union` for the rule to match the names it -matches, so the only possible error is the other direction, and the -union is simultaneously the check and the repair. Narrowing a rule to -it cannot orphan a name, since every name the rule explains -contributed to it. - -Width is BASELINE-RELATIVE, so measure each ledger on its own run -rather than copying an edit across the three. `fix(#296) a lone -post-comma credential is a suffix` declares all four of `{family, -given, suffix, title}` at 1.4.0, where v1 reads the pre-comma word as -`first` and every one of those roles moves, and only `{suffix, title}` -at both 2.x baselines, where the same behaviour moves nothing else. -One rule with two different field lists across the three files is -correct there, not drift. - -Two shapes are skipped, neither as an exemption: a rule declaring -`dormant` explains nothing by declaration, and the dormancy check -below owns that finding in both directions; a rule with no `fields` -declares no roles to exceed. There is no third way out, by decision -rather than by omission -- a rule that genuinely needs a wider -declaration must argue for a key of its own, the way `dormant` was -argued for in #373. - -**File order decides.** That is not a detail -- every rule in every -ledger carries a `name_regex`, so they all sit in one tier, the sort -is stable, and the order they are written in settles every tie between -them. Append a rule to the bottom of a file only after checking that -nothing above it already claims the diff you meant it for. Where the -EARLIER rule of such a tie is the wider one, the pair must say so -- -see "Declaring a wide-first pair" below. - -`_sorted_rules` still sorts `name_regex` rules ahead of `fields`-only -ones, and is now the identity on every ledger that loads. It is kept -as a defence for a reader that does not call `validate_rules` first -- -a future tool, a REPL, a test fixture -- rather than as a second tier -any ledger can reach; its docstring in `compare.py` says why. - -Some entries in `expected_since_1.4.0.toml` are for behavior families that -a corpus happens to contain no example of (e.g. custom suffix-delimiter -rendering, which only fires under a non-default `Policy` -- see the ceiling -below). They're kept in the file anyway, matching the family documented in -`tests/v2/cases.py`, so the rule is ready the moment a matching string is -added to the corpus. - -Such a rule must say so, with `dormant = ""`. Without it a rule -that explains nothing is indistinguishable from one that has stopped -explaining anything -- which is how a reverted fix leaves its rule inert -and the run still exits 0 (#372). Two tiers ask: - -- `tests/v2/test_ledger_guards.py` fails, in CI, when a rule's - `name_regex` reaches no corpus name and no `dormant` reason is given. It - needs no baseline wheel, so it is cheap and early. -- `compare.py` fails the run when a rule explained no diff, and equally - when a rule declaring `dormant` explained one -- a declaration that - stopped being true is a false statement in the ledger. It reports which - kind of nothing: **reverted** (matched no diffing name), **shadowed by - ``** (an earlier rule claimed every diff it would have), or - refused by a `[[never]]` exclusion. The three have three different fixes. - -One limit worth knowing. Only the ledger for the baseline being run gets the -dynamic check, so the release checklist runs `compare.py` at every baseline -that has a ledger with rules -- a ledger the checklist does not reach gets no -dynamic dormancy check at all, and a `dormant` declaration in it goes -unaudited. The root `AGENTS.md` checklist DERIVES its baselines from the -`expected_since_*.toml` glob, so a new ledger is reached at the next release -without a line being added; it enumerated three commands against four -ledgers until #503 replaced the list with the loop (the dated record is in -`AGENTS.md`, at the step). The over-declaration check (#452) and the -recorded-shape check (#497) are per-ledger for the same reason the dormancy -one is -- the latter reads `_RECORDED_DIFFS[ledger.name]` and -`_WATCHED_DIFFS[ledger.name]`, and refuses pre-worker a ledger either dict -has no section for -- so a ledger the loop did not reach would get none of -the three. - -The static tier still exempts a rule on the mere PRESENCE of `dormant`; it -never asks whether the reason is still true. Only the dynamic check can -catch a `dormant` that quietly stops being true, which is why every ledger -with rules needs one. - -### Declaring a wide-first pair (`precedes_narrower`) - -File order deciding is safe while the earlier rule is the NARROWER -one, and narrow-first is a default rather than a law (#382). A wider -rule can be the better classifier where it describes a compound -behavior its component rule does not: `马丁·路德·金씨` divides on the -nakaguro AND peels its glued hangul honorific, so `fix(#272/#308)` -describes what happens to the name and -`fix(cjk-glued-honorific-peel)` describes half of it -- and the wider -rule wins, correctly, by sitting first. `fields`-subset is a proxy -for specificity and the wrong one there, which is why the ordering -cannot simply be enforced. - -Such a pair is legal, and must be DECLARED on the rule that WINS it --- the earlier one, which is the only rule whose word can retire the -contest: - -```toml -[[change]] -issue = "fix(#272/#308) nakaguro division and a glued hangul honorific in one name" -name_regex = "..." -fields = ["family", "given", "middle", "suffix"] -[[change.precedes_narrower]] -issue = "fix(cjk-glued-honorific-peel) glued honorific peels into suffix" -why = """ -`middle` is the discriminator and the nakaguro is where it comes -from. ... -""" -``` +Every distinct CJK-bearing `text` in `tests/v2/cases.py`, `tolerated` +rows included. The generated corpora (`corpus_cjk`, `corpus_rules`, +`corpus_shapes`) are each pinned by `tests/v2/test_corpora.py` against +their generator's selection, so a case row or rules.md example added +without regenerating fails the suite instead of silently narrowing +what the snapshot watches. -DOUBLE brackets: a rule may outrank more than one neighbour, so the -key is an array of tables. Single-bracket -`[change.precedes_narrower]` makes ONE table rather than a list of -them and is refused, as is an empty list -- deleting the key says the -same thing in one place. - -Each block names ONE later rule, by its exact `issue` string, and -gives a `why`. Both are required, and each ban has its reason. The -named rule is the one and only rule this one outranks: a blanket -"may outrank anything narrower" would be inherited by every narrower -rule added afterwards, which is the widening this check exists to -refuse. The `why` is the whole safeguard, as it is for `dormant` -- -`fields` cannot say that a wider rule describes a compound behavior -its component does not, so the reason is the only place that fact can -live, and an exemption nobody had to justify is the one nobody -reviews. `validate_rules` also refuses a target naming no rule in -this ledger, a rule naming ITSELF, a target sitting EARLIER in the -file (the narrower rule of a declared pair is by definition the later -one, so an earlier one is a copy-paste of the wrong issue string), -and the same target twice (one pair takes one exemption, so a repeat -exempts nothing new and means one of the two reasons is stale, with -nothing to tell a reader which). - -**The trap: nothing may follow the block inside a rule.** TOML binds -every later bare `key = value` to the table the last header opened, -so a rule key written BELOW `[[change.precedes_narrower]]` leaves the -rule and joins the exemption. Put the block LAST in the rule. -`validate_rules` rejects any key inside an exemption other than -`issue` and `why` for exactly this reason: an `orders` landing there -deletes the rule's order narrowing, and nothing else would notice. - -**What counts as a contest.** `order_contests` asks the three -questions `_entry_matches` asks, one per narrowing key, and a pair is -a contest only where all three overlap. `fields`: the later rule's -are a STRICT subset of the earlier one's, so every diff fitting the -narrower set passes both rules' subset test. `name_regex`: some -corpus name reaches both. `orders`: some order reaches both -- two -rules scoped to disjoint orders never see the same comparison, so -file order decides nothing between them however nested their `fields` -are, and calling that a contest would demand a justification for a -hazard that cannot occur. No diff is computed, which is what makes -the check cheap enough to run before the worker spawns -- the nesting -supplies the contested shape's EXISTENCE, and computing real diffs -could only ever remove pairs from THIS list, never add one to it. - -**Nesting is SUFFICIENT for a contest and not necessary**, so read -that last sentence as a statement about the nested pairs and not -about contests in general. Two rules whose `fields` merely INTERSECT -both admit any diff inside that intersection, so `classify()` hands -such a name to whichever of them is written first, exactly as it does -for a nested pair -- and this check cannot see it. That class is -outside the check by REASONING and not by oversight, and the -reasoning is the one `order_contests`' docstring gives for EQUAL -`fields`: neither rule is narrower, so "narrow-first" says nothing -about the pair, `precedes_narrower` has no narrower rule to name, and -`_CROSS_RULE_WINNERS` stays the instrument there. That argument -covers every pair where neither `fields` set contains the other, and -equal `fields` is its special case. Measured 2026-09-02 over the four -ledgers -- pairs sharing a corpus name whose `orders` are not disjoint --- 11 are strictly nested wide-first, which is what this check -refuses; 40 nest in either direction, 11 have equal `fields`, and 111 -have any non-empty intersection, so 60 overlap without nesting or -equality. Read the gap and not the digits, and recompute before -quoting any of them: decisions.md#differential-ledger carries the same -five figures with the recipe, and #498's body the loop. -Widening the predicate to that general case would demand 111 written -justifications where the real number is eleven -- the same argument -decisions.md already makes about the 657 figure, that a predicate -nobody can answer is not a usable one. The worked blind spot is real -and was closed by -[#498](https://github.com/derek73/python-nameparser/issues/498): -`fix(#271/#272/#298)` and `fix(cjk-delimited-nickname)` intersect in -{`family`, `given`} without nesting, and swapping them reattributes -three contract-tier names this check never mentions in either -arrangement. All three are pinned now, along with eleven more names -measured the same way, and the second predicate below refuses the -next one (2026-09-05). - -**The second predicate is measured, not static.** `unowned_contests` -(#498) walks a run's own diffs and reports every contested one the -winner takes by neither narrow-first nesting nor a declaration and -that no `_RECORDED_DIFFS` row pins. It has to be run-time and there -is no static sibling: a pin is a shape beside a winner, and without a -diff the population is the whole equal-or-overlap class the predicate -names -- the 60 overlapping pairs above plus the 11 with equal -`fields`, 71 in all -- rather than the 14 names a run finds. The 71 is -those same 2026-09-02 figures re-measured 2026-09-05 by the recipe -they carry; decisions.md#differential-ledger has it. Severity is -FATAL on both tiers, in the exit -code beside the shape check -- a contest ROW is fatal on either tier -because it carries an argument, and an owed row because it carries -none yet. Under `--corpus` the population is the entries the run -loaded, so the check under-reports and never false-alarms, and no -flag is read. Note the ASYMMETRY with `vacant_exemptions` above: -there is no vacancy half here, because a pin whose contest dissolves -keeps saying what the name diffs to and which rule takes it -- 13 of -the 45 rows at 1.4.0 are already in that position (measured -2026-09-05). - -Two questions, in both tiers, as for `dormant`. Is every contest -DECLARED, and does every declaration still stand over a contest? The -second matters as much: a rule narrowed until it no longer overlaps -its neighbour leaves its exemption behind, and a justification for a -hazard that is gone reads exactly like one for a hazard that is live. -`tests/v2/test_ledger_guards.py` asks both over every corpus on disk -with no baseline wheel, so a rule added by a later bundle is checked -at pytest speed; `compare.py` asks both over the entries the run -actually loaded, before it spawns the worker. Do NOT answer either -failure by reordering: that moves which rule classifies a name and -breaks `_CROSS_RULE_WINNERS`. - -**Under `--corpus` the two checks are NOT symmetric**, which is why -only one of them refuses there. A smaller name set removes contests. -For the undeclared check that can only UNDER-REPORT, never -false-alarm -- fewer contests, fewer things to declare -- so -`--corpus` is only ever more lenient. Not "fail-closed": this file -uses that for the `_CORPUS_TIERS` and floor rosters, which REFUSE on -a missing entry, and a check that errs toward not refusing is the -opposite of one that errs toward refusing. For the vacancy check it -INVERTS: a live declaration whose contested names -all sit outside the subset reads as vacant, and following the advice -would delete an exemption the full gate needs and then fail the full -run for the undeclared contest that reappears. So a vacancy is a hard -failure on a full run and a printed NOTE under `--corpus`. - -Four checks now read the flag differently, and the differences are deliberate rather than untidy -- read them together before making any of them uniform. The corpus-floor roster is SKIPPED entirely under `--corpus`, because narrowing is the point of the flag. `over_declared_rules` still FAILS the run and appends a NOTE saying the union it computed is over a subset, so its repair advice is not followed blindly. The vacancy check does not fail at all, because its verdict INVERTS under narrowing rather than merely its evidence. The departed-name half of the recorded-shape check (below) inverts the same way and goes one step further, printing nothing at all under `--corpus`: a NOTE there would name most of the roster and tell the reader nothing, where the vacancy NOTE names a handful. The shape blocks themselves -- `MOVED SHAPE` on either roster, and `MOVED SHAPE (radar)` -- read no flag at all, and the section below says why that is deliberate rather than a fifth difference. - -### `MOVED SHAPE`: the two rosters' recorded diffs, checked against a run - -`tests/v2/test_ledger_guards.py` carries `_CROSS_RULE_WINNERS`, a roster pinning which rule should win a contested name. To ask that question it needs the name's diff SHAPE, which it feeds to `classify()` as an input -- so the shape is never itself checked, and a guessed one agrees with itself forever. That is how `田中さん II` sat recorded as `{given, suffix}` under a docstring promising the shapes were measured; the real diff is `{family, given, suffix}` (#497). The unit suite cannot catch it: it spawns no worker, deliberately, so it has no measured diff to compare against. - -So the shapes live in `compare.py`, keyed per ledger (a string moves a different set of roles against different baselines), in TWO dicts under two contracts, and every run checks both. `_RECORDED_DIFFS` is the CONTEST roster: a shape beside a winner ADJUDICATES a contest -- it records what a name diffs so that `_CROSS_RULE_WINNERS` can ask `classify()` which rule wins it, and every row has a partner pin there (the guard holds `set(winners) == set(shapes)` per ledger, in both directions). `_WATCHED_DIFFS` is the WATCHED roster: a shape alone, for a name NO WINNER IS PINNED FOR. Every row there is also sole-watched today -- a radar name that no test names and no contract corpus holds, whose only other watcher is the classification rule explaining its diff, and a rule asserts that a diff is intended, not what it IS, so a shape moving inside the rule's `fields` moved silently before the row existed. That is a property of today's rows and not a second entry condition: the four #501 contests at 2.0.0 and `'MD, PHD'` beside them sat there, measured and unadjudicated, with a `Case(...)` row in `tests/v2/cases.py` already pinning each one's PARSE, until #501 argued which RULE explains each diff and the five rows moved -- six with `'MD, PHD'`'s 2.1.0 twin, which is why that string is now keyed in three `_RECORDED_DIFFS` sections carrying two different shapes. Neither dict's contents are restated here: read the rosters, and decisions.md's #501 adjudication for what each of the six pins rests on. The two dicts are disjoint per ledger, and a name in both is refused pre-worker, beside the departed-name refusal, as the guard refuses it at pytest speed: a row is one kind or the other, and the run reads a row's severity and its repair text off the dict it sits in. The day a winner is argued for a watched name, the row MOVES to `_RECORDED_DIFFS` and the pin goes beside it there; nothing relaxes the equality. Out of the pair come two findings, a non-fatal block and a note, and they behave differently on purpose. - -- **`MOVED SHAPE`** -- a recorded shape the run contradicts. Printed after the comparison, alongside `EXPLAINED NOTHING` and `OVER-DECLARED`, and it feeds the exit code the same way. It does not raise: a refusal there would land mid-report and take the `UNEXPLAINED` block down with it, and a stale roster row must never hide an unexplained diff. A moved shape is a FINDING, not a number to update, and it names no cause because it cannot: the parser may have changed what the name does, or the row may have been wrong when it was recorded -- which is what #497 found, four rows recording a shape no run makes under a roster promising the shapes were measured. SEVERITY follows the row's kind first and the tier second, and the run prints one block per case, contest first. A CONTEST row is fatal on both tiers: it carries an argument, and the winner pinned beside it in `_CROSS_RULE_WINNERS` was recorded against the OLD shape, so read both before editing either. A WATCHED row on a contract-tier name is fatal too, as an unexplained diff on that name would be; its block names no partner, because no winner is pinned for it, and the repair it gives is the only one a snapshot admits -- if the move is intended, re-record the shape in `_WATCHED_DIFFS` in the commit that moved it and say why there. A WATCHED row on a radar-tier name prints under **`MOVED SHAPE (radar)`**, parallel to `UNCLASSIFIED (radar)`, and feeds no exit code: fatal-on-radar is reserved for a per-name deliberate choice -- a `[[never]]` entry with its `why`, or a contest row with its pinned winner -- and a measured snapshot is neither; a gate whose repair is "record whatever it does now" is a changelog entry in a gate's clothing. The tier a watched row follows is the DEFAULT-ORDER ENTRY's where the name has one, since that is the comparison the shape was measured on, and the first-loaded (contract-first) entry's only where it has none. `'John Smith, Dr.'` is the case that separates the two readings: contract in `corpus_shapes.jsonl` only as shape 4 (`FAMILY_FIRST`) and radar in `corpus_issues.jsonl` under the default order. It carries no watched row today -- a test names it, so it is not sole-watched -- but a watched row on it would print and not fail, and the family-first promise would be untouched. Where the run measured no default-order diff at all, the report says so and names no cause: two states reach it (the parser stopped moving the name, or the name is compared only under a declared order) and the check cannot separate them. -- **A row naming a name no corpus holds** -- refused, before the worker runs, on a FULL run only, over the UNION of the two dicts: a watched row is measured by nothing else either. Nothing measures such a row, so it agrees with itself forever, which is the defect above in a second form. The repair is not mechanical: ask first whether the name left deliberately (`git log -S'' -- tools/differential/corpus*.jsonl`), then either delete the row -- and, for a contest row only, its `_CROSS_RULE_WINNERS` partner; the message lists the two kinds apart so that a watched-row reader is not sent to delete a partner that does not exist -- or restore the name to the corpus that lost it. Under `--corpus` this prints nothing, since a narrowed run legitimately holds almost none of either roster. -- **`NOT CHECKED`** -- a NOTE rather than a finding, printed after the comparison and deliberately outside the exit code (#497, `9360919`). It names rows in either dict whose corpus entry THIS BASELINE SKIPPED: an order-bearing entry a baseline with no `Policy` cannot honor is dropped before the comparison runs, so there is no measured diff to check the row against. Such a row falls between the two checks above -- `MOVED SHAPE` skips a name it did not compare, and the departed-name refusal passes it because the skip takes a name out of the RUN and out of no file -- and without the note it is checked by neither and reported by neither. So the note says which rows the shape report is silent about and claims nothing else. Do NOT delete a row over it: the name is in a corpus this run read. Re-run at a baseline that can honor its order to check it. Measured at 1.4.0, which is the only baseline where the skip fires, the window is `de Mesnil Jean, Dr.`, `de la Cruz Juan Carlos, Dr.` and `de la Cruz née Vega` -- none carrying a row in either roster today, so the note prints on no run yet. "Do not refuse" and "say nothing" are two decisions, and only the first was ever argued. - -The two CHECKS read opposite name lists, and swapping them is the live hazard. "Was this name compared?" is about the RUN, so the shape check reads the list AFTER the baseline-minimum shape skip. "Does any corpus still hold this name?" is about the FILES, and the skip empties no file, so the departed-name check reads the list BEFORE it. The note reads both, being exactly the difference: the union of the rosters intersected with the pre-skip list, minus the post-skip one. Of these, the departed-name refusal is the only one that reads `full_corpus`: the note's intersection already narrows itself under `--corpus`, and `MOVED SHAPE` never asks the flag, on either roster and in its `(radar)` form alike -- it is silent under `--corpus` only about rows whose name this run did not compare, so a narrowed run holding a pinned name still reports and, where the row is fatal, still FAILS. Measured 2026-09-03: `--corpus corpus_issues.jsonl` at 1.4.0 with a corrupted shape on `Carod i` prints `MOVED SHAPE` and exits 1 (RECOMPUTE by corrupting that row in `compare._RECORDED_DIFFS` in memory around `main()`). Do not read the departed-name suppression across to it -- a `MOVED SHAPE` from a narrowed run is a real finding, and a `full_corpus` gate added here would be a behavior change and not a restoration. `compare.py`'s comment at the note says the note parts "from the two checks that do read that flag", counting `vacant` and `gone` -- its own frame, and a different pair from the three listed here. `recorded_diff_mismatches`' docstring in `compare.py` carries the measurement and the recompute for the two lists. - -### Shapes that must never be explained (`[[never]]`) - -A `[[change]]` rule says "this diff is intended, and here is what -changed". There was no rule meaning "whatever happens here is a -regression", so two comments in `expected_since_1.4.0.toml` promised -exactly that in prose while rules in the same file claimed those -shapes anyway -- both false from the day they were written until #328 -found them. A `[[never]]` entry is that promise made executable: it -names a shape that must stay unexplained. - -An entry needs `why` -- an exclusion nobody can justify is one nobody -can safely delete -- plus `name_regex` and `examples`. The examples are -required, not decoration: a protected shape need not appear in any -corpus, so the entry has to carry its own test data. `fields` is -optional and narrows WHICH READING is protected, by the same subset -test the rules use -- but only that test. An exclusion explains no -diff, so the exactness requirement above is not asked of it. - -That last key earns its keep on the ASCII pairs. Parens mark nicknames, -maiden names, suffixes and credentials alike, and no regex tells them -apart, so a name-only exclusion for the nickname promise would also -silence every diff on `Jenny (Johnson) Baker` and `Lon (Jr.) Williams`, -whose parens are a maiden name and a suffix. Nothing is hidden by that --- an excluded name reports UNEXPLAINED, which exits non-zero -- but -those names become permanently unexplainable, so an intended change -there could never be recorded. Typographic delimiters carry no such -ambiguity, which is why `feat(#273)`'s own rule can be a bare character -class and its exclusion cannot. - -The ASCII-pairs entry is narrowed by role ALONE, and covers a delimited -run in any position; how many corpus names that reaches is pinned as -its `captures` in `tests/v2/test_ledger_guards.py`'s -`_EXCLUSION_EFFECT`, which is the one place the number is checked and -so the one place it is worth reading. It was medial-only for three -rounds on the theory that trailing parens are credentials to be kept -out; 1.4.0 says otherwise, reading a trailing `(JD)` as a nickname just -as it reads `(Ben)`. Where 1.4 did read parens as a credential it put -them in `suffix`, outside this entry's `fields`, so the parser -discriminates by vocabulary where no delimiter regex could. Measured, -the widening lost zero classifications. - -`classify()` consults exclusions BEFORE the rules, and a match returns -`None`, so an excluded shape reports UNEXPLAINED however many rules -would claim it. That order is also what makes exclusions monotone: an -entry only ever removes a name from classification, never moves it -between rules, so its blast radius is exactly the names it captures, on -the readings it names -- there is no rule ordering to reason about. - -`tests/v2/test_ledger_guards.py` records what each entry silences and -holds it there. Asking whether a rule claims a protected shape *with* -the exclusion active answers nothing -- `classify()` returns `None` -before the rules are reached, so the answer is `None` however the rules -change. The pin therefore asks with exclusions switched OFF and records -which rules WOULD claim each protected reading. A rule widened to reach -one changes that record and fails in CI, rather than being invisible -until someone reasons about it. It records the FIRST rule matching each -subset, so a rule shadowed on every subset it claims by one already -sitting ahead of it does not move the record; a rule reaching a -protected reading does. The same record carries the number and -digest of the corpus names an entry captures, which is the opposite -drift: an over-wide exclusion silences real classifications, and that -is loud at release but otherwise silent on a push. - -## What this gate does not cover +## What the corpora do not cover The corpora run under the **default policy**, so any behavior gated behind a non-default `Policy` field is invisible here. Default @@ -897,13 +326,9 @@ earns another: `nee`, which appears in the corpora only inside brackets -- `Jane Smith (Nee)` and `Jane Smith (Nee) (Jones)` -- where the token carries them until they come off. `née` needs no strip, appearing bare in many names, and `né` is not counted at all: it occurs only as a -substring of `née`, never as a token. The convention matters because the neighbouring guard -`tests/v2/test_ledger_guards.py::_carries` deliberately asks a wider -question -- it also matches a non-ASCII entry anywhere inside a name, -since 旧姓 is written flush against the name it marks -- and under that -reading the marker count is 6, not 5: it adds `né`, which occurs only -as a substring of `née` and never as a token of its own. Both are right -about different questions. Recompute: +substring of `née`, never as a token. Matching a non-ASCII entry +anywhere inside a name instead (旧姓 is written flush against the name +it marks) makes the marker count 6, adding `né`. Recompute: A heredoc, not `python -c "..."`: the strip set contains a double quote, which closes the `-c` string and leaves the rest to the shell. @@ -926,7 +351,7 @@ for s in ('maiden_markers', 'honorific_tails'): runs |= {' '.join(t[i:i+k]) for k in range(1, longest+1) for i in range(len(t)-k+1)} carries = set(runs & v) | {e for e in v if not e.isascii() and any(e in n for n in names)} print(s, len(runs & v), 'of', len(v), sorted(runs & v), - '| _carries reading:', len(carries), sorted(carries)) + '| substring reading:', len(carries), sorted(carries)) PY ``` @@ -959,30 +384,9 @@ clause — along with a one-word clause like `Jane Smith (Nee)`, where nothing in the content says maiden and only a caller who knows the data can. -It does NOT put #329 within reach of this gate, and the reason -generalizes past this one change. #329 governs what a delimited maiden -clause CONTAINS -- the marker word is dropped from the value -- while -a ledger rule narrows by which FIELDS move, never by what they hold. -The six names the 2.1.0 ledger classifies under `fix(#335)` move -`{nickname, maiden}` whether the marker is dropped or not, so that rule -absorbs a #329 regression in silence. The field sets are per baseline -and only that ledger's are uniform: at 2.0.0 the CJK name declares four -fields and has a rule to itself, and at 1.4.0 it is not a `fix(#335)` -name at all. Measured 2026-08-26 by reverting the drop pass -in `_group.py`: `Jane Smith (née Jones)` reads maiden `née Jones`, and -all three gates still report 0 unexplained. #329 was out of reach -before #335 too, for a different reason -- under the default policy no -corpus name reached the drop at all, so the movement on -`山田 花子(旧姓 佐藤)` between 2.0.0 and 2.1.0 was the East Asian order -flip rather than #329 (`decisions.md#M1`'s 2026-08-05 entry calls that -change gate-visible; the 2026-08-26 entry beside it records the -correction). Value-level coverage for both is `tests/v2/cases.py`, -whose rows assert values, and whose opt-in rows carry their own -`policy=`. - ## The CLDR agreement ratchet -`corpus_cldr.jsonl` is the one corpus whose names arrive with ground truth: CLDR states every name's fields separately, and `build_cldr_corpus.py` keeps them in `cldr_fields.jsonl`. The harness above still compares those names version against version like any radar corpus. Separately, `cldr_agreement.py` compares them against CLDR, role by role — title, given names, family, suffix, and the particle/base split where CLDR states it — and records every disagreement in `cldr_agreement.json`. +`corpus_cldr.jsonl` is the one corpus whose names arrive with ground truth: CLDR states every name's fields separately, and `build_cldr_corpus.py` keeps them in `cldr_fields.jsonl`. The snapshot watches those names like any corpus's. Separately, `cldr_agreement.py` compares them against CLDR, role by role — title, given names, family, suffix, and the particle/base split where CLDR states it — and records every disagreement in `cldr_agreement.json`. `tests/v2/test_cldr_agreement.py` holds the tree to that record. It fails when a role that agreed stops agreeing, when a recorded disagreement starts agreeing (so it can be recorded and guarded), when a recorded disagreement's answer changes, and when a recorded rendering no longer exists. When every reported change is intended: @@ -990,6 +394,4 @@ whose rows assert values, and whose opt-in rows carry their own and say why in the commit. Run it without `--record` for per-role agreement counts. Nothing is special-cased: an unnatural rendering or a modelling difference (CLDR's `さん` title is a suffix here) simply stays a recorded disagreement, which makes the record a measured worklist as well as a guard. -Two places leave the file out, each for a stated reason (2026-10-12). `_CORPUS_NAMES` in `tests/v2/_differential_fixtures.py`, which the ledger guards measure rule reach over, excludes it through `_UNMEASURED_CORPORA`: counted there it moved fourteen 1.4.0-ledger rules' recorded reach at once, and the recorded claims stay about the names the ledgers were written for. `compare.py` holds the same set and does not adjudicate a name only those files hold: such a name never raises an UNPINNED CONTEST (CLDR raised three at 1.4.0, between rule pairs nobody wrote it to decide), and its diffs classify by file order. A test holds the two sets equal. `differential_corpus()` in `tests/v2/conftest.py`, which feeds the property tests, left it out until #652 (decomposed `inž.` was not read as a period abbreviation) was fixed and now includes it, so the decomposed-twin test walks its names. - -**At release time.** CLDR diffs a ledger rule classifies land in the classified summary like any other corpus's, and the rest print under `UNCLASSIFIED (radar)` — 60, 203, 200, 186, 12 and 1 of them at baselines 1.4.0 through 2.4.0 (measured 2026-10-12, after #653's fix). A release-log count drawn from the summary therefore includes fictional CLDR renderings unless they are counted out: quote names from the other corpora, or say that the figure includes CLDR's. +`_CORPUS_NAMES` in `tests/v2/_differential_fixtures.py` leaves the file out (2026-10-12): the tests reading it carry counts measured over the names the other corpora held. `differential_corpus()` in `tests/v2/conftest.py`, which feeds the property tests, includes it since #652, so the decomposed-twin test walks its names. diff --git a/tools/differential/compare.py b/tools/differential/compare.py index ef8f91a3..41c27ade 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -1,6 +1,6 @@ """A released nameparser against the working tree, over every corpus. - uv run python tools/differential/compare.py [--baseline VERSION] + uv run python tools/differential/compare.py [--baseline VERSION] [--corpus PATH] Reads every corpus name with a pinned release installed fresh from PyPI and with the working tree, and prints the names that read @@ -318,13 +318,15 @@ def _is_latin_only(name: str) -> bool: return all(ord(ch) < 0x250 for ch in name) -def compare(baseline: str) -> tuple[list[str], int, int]: - """The grouped moves between `baseline` and the tree, with the - number of moved names and how many of them are Latin-only.""" +def compare(baseline: str, corpora: list[Path] | None = None + ) -> tuple[list[str], int, int]: + """The grouped moves between `baseline` and the tree over every + corpus (or `corpora`), with the number of moved names and how many + of them are Latin-only.""" snapshot = _load_snapshot() shapes = snapshot.load_shapes() min_for = {s.order: s.min_baseline for s in shapes.values() if s.order} - entries = [(name, order) for name, order in snapshot.corpus_entries() + entries = [(name, order) for name, order in snapshot.corpus_entries(corpora) if order is None or _parse_version(baseline) >= _parse_version(min_for[order])] want_v2 = "v2" in _surfaces_for(baseline) @@ -349,11 +351,15 @@ def main() -> int: ap.add_argument("--baseline", default=DEFAULT_BASELINE, metavar="VERSION", help=f"released version to compare the tree against " f"(default {DEFAULT_BASELINE})") + ap.add_argument("--corpus", action="append", type=Path, metavar="PATH", + help="a corpus file to compare instead of every " + "corpus*.jsonl here; repeatable. One JSON string " + "per line is a name.") args = ap.parse_args() - moves, moved, latin = compare(args.baseline) + moves, moved, latin = compare(args.baseline, args.corpus) print("\n".join(moves) or "no differences") - print(f"\n{moved} names read differently from {args.baseline}, " - f"{latin} of them Latin-only") + print(f"\n{moved} name{'' if moved == 1 else 's'} read differently " + f"from {args.baseline}, {latin} of them Latin-only") return 0 diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py index 18b8ebfe..b80ebc71 100644 --- a/tools/differential/snapshot.py +++ b/tools/differential/snapshot.py @@ -88,11 +88,12 @@ def _corpus_line(path: Path, line: str, orders: dict[int, str | None]) -> Entry: return raw["name"], orders[shape] -def corpus_entries() -> list[Entry]: - """Every distinct (name, order) in the corpora, sorted. The same +def corpus_entries(paths: list[Path] | None = None) -> list[Entry]: + """Every distinct (name, order) in the corpora -- every + corpus*.jsonl beside this file, or `paths` -- sorted. The same string tagged with two shapes is two entries, one per order.""" orders = {k: s.order for k, s in load_shapes().items()} - paths = sorted(HERE.glob("corpus*.jsonl")) + paths = paths or sorted(HERE.glob("corpus*.jsonl")) if not paths: raise SystemExit(f"no corpus files found in {HERE}") found: set[Entry] = set() From aae9a446b23d6451e05247f8cc8fbcda1150e35f Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:26:19 -0700 Subject: [PATCH 5/9] Review fixes: snapshot checks its import, compare states its population (#664) - snapshot.py checks that the tree side is a source package (check_source, shared with compare.py's _check_tree); --tree used the weaker under-the-root test, which an ancestor of the checkout passed while the import fell through to the checkout itself. - compare.py reports how many entries it compared and how many order-bearing ones it skipped below 2.0.0 (788 at 1.4.0). - The four build_issues_corpus.py harvester tests, deleted with the old test_differential.py, return in test_corpora.py. - --since on a ref with no snapshot says so instead of a traceback. Co-Authored-By: Claude Opus 5.5 --- tests/v2/test_corpora.py | 64 +++++++++++++++++++++++++++ tests/v2/test_differential.py | 51 +++++++++++++++++----- tools/differential/compare.py | 79 +++++++++++++++------------------- tools/differential/snapshot.py | 46 +++++++++++++++----- 4 files changed, 176 insertions(+), 64 deletions(-) diff --git a/tests/v2/test_corpora.py b/tests/v2/test_corpora.py index 1e24c932..fce0a5e0 100644 --- a/tests/v2/test_corpora.py +++ b/tests/v2/test_corpora.py @@ -124,3 +124,67 @@ def test_a_malformed_corpus_line_is_a_hard_error(line: str, match: str) -> None: with pytest.raises(SystemExit, match=match): snapshot._corpus_line(Path("corpus_x.jsonl"), line, _ORDERS) + + +harvester = load_tool("build_issues_corpus") + + +def test_a_backticked_name_is_harvested() -> None: + """This tracker writes names in backticks, because issues are + markdown. Matching only quoted names left the corpus whose whole + purpose is "what users reported" blind to how this project reports + (#413). + """ + assert harvester._harvest( + "the tussenvoegsel in `Beethoven, Ludwig van` is filed behind " + "the given name") == {"Beethoven, Ludwig van"} + + +def test_a_call_inside_backticks_yields_the_name_not_the_call() -> None: + """The reason backticks are a SECOND PASS rather than a third + alternative in one pattern. + + A reporter writes the failing input as a call inside a code span. + Regex scanning takes the leftmost match, so a backtick alternative + starts one character before the HumanName branch can and swallows + the whole span -- harvesting the call and losing the name inside + it. This is the bug the two-pass structure exists to prevent, and + it is reachable from a single string. + """ + got = harvester._harvest('reported as `HumanName("John Q Doe")`') + assert got == {"John Q Doe"} + + # ... and a call anywhere in the span, not only at its start + assert not any("HumanName(" in n for n in + harvester._harvest('see `Foo HumanName("Jane Roe")`')) + + +def test_a_sentence_is_not_a_name() -> None: + """Prose is quoted and backticked as often as names are, and a + capitalized phrase is well-formed enough that the character screen + cannot see it. + """ + for text in ("`What this gate does not cover` is the section", + "the note said `Takes everything` about it", + "`C is CONSTANTS` in that example", + "wrote `NO Latin twins on purpose` there", + "raised `TypeError: a bytes-like object is required`"): + assert harvester._harvest(text) == set(), text + + +def test_the_sentence_screen_keeps_its_hands_off_names() -> None: + """The screen matches a function word only where it is written + LOWERCASE, which is what keeps it off names: a name capitalizes its + words and a sentence's function words do not. + + Every name here contains a word that is in the stop list, and every + one is a real naming convention -- Burmese, Norwegian, Estonian, + Cantonese, Luhya, Dutch, Lakota. Without the case test the screen + drops all of them, silently, from the one corpus meant to catch + what users report. + """ + for name in ("U Than Shwe", "Sven Are Olsen", "Kertu Must", + "Chan On Kei", "Wafula Were", "John Does", + "Marie Takes War Bonnet", "Rob And Beth Edmunds", + "Duke of Wellington", "Anh Do", "Will De Groot"): + assert harvester._harvest(f"reported `{name}` today") == {name}, name diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 023cdd43..4787679b 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -196,7 +196,7 @@ def test_check_tree_accepts_the_checkout_and_rejects_anything_else( tmp_path: Path) -> None: inside = _TOOLS.parents[1] / "nameparser" / "__init__.py" assert compare._check_tree(str(inside)) == inside.resolve() - with pytest.raises(SystemExit, match="not from this checkout's source"): + with pytest.raises(SystemExit, match="not from the source package"): compare._check_tree(str(tmp_path / "nameparser" / "__init__.py")) @@ -209,7 +209,7 @@ def test_check_tree_rejects_a_wheel_sitting_inside_the_checkout( rel: str) -> None: """The repo contains .venv/, build/ and dist/, any of which can hold a released wheel, so "under the repo" is not "the source package".""" - with pytest.raises(SystemExit, match="not from this checkout's source"): + with pytest.raises(SystemExit, match="not from the source package"): compare._check_tree(str(compare.REPO_ROOT / rel)) @@ -217,7 +217,7 @@ def test_check_tree_resolves_before_comparing() -> None: """Without .resolve(), a path escaping via .. reads as inside.""" escaped = compare.REPO_ROOT / "nameparser" / ".." / ".." / "x" \ / "nameparser" / "__init__.py" - with pytest.raises(SystemExit, match="not from this checkout's source"): + with pytest.raises(SystemExit, match="not from the source package"): compare._check_tree(str(escaped)) @@ -336,8 +336,7 @@ def _tree_results(entries: list[tuple[str, str | None]], want_v2: bool def _fake_compare(monkeypatch: pytest.MonkeyPatch, baseline: str, alter: "Callable[[list[dict[str, Any]]], None] | None" = None - ) -> tuple[tuple[list[str], int, int], - list[dict[str, Any]]]: + ) -> tuple[Any, list[dict[str, Any]]]: """compare() over _ENTRIES with the worker replaced by the tree's own readings, optionally altered by `alter(results)`; also returns the entries the worker was sent.""" @@ -358,7 +357,12 @@ def fake_run(version: str, want_v2: bool, entries: list[dict[str, Any]] def test_compare_reports_no_move_when_the_baseline_reads_like_the_tree( monkeypatch: pytest.MonkeyPatch) -> None: - assert _fake_compare(monkeypatch, "2.4.0")[0] == ([], 0, 0) + result, sent = _fake_compare(monkeypatch, "2.4.0") + assert (result.moves, result.moved, result.compared, result.skipped) \ + == ([], 0, 2, 0) + # The boundary: an order shipped in 2.0.0, so 2.0.0 itself sends it. + _, sent = _fake_compare(monkeypatch, "2.0.0") + assert [e["order"] for e in sent] == [None, "FAMILY_FIRST"] def test_compare_reports_a_move(monkeypatch: pytest.MonkeyPatch) -> None: @@ -367,7 +371,8 @@ def test_compare_reports_a_move(monkeypatch: pytest.MonkeyPatch) -> None: def alter(results: list[dict[str, Any]]) -> None: results[0]["v2"]["family"] = "Smyth" results[0]["facade"]["last"] = "Smyth" - (moves, moved, latin), _ = _fake_compare(monkeypatch, "2.4.0", alter) + r, _ = _fake_compare(monkeypatch, "2.4.0", alter) + moves, moved, latin = r.moves, r.moved, r.latin assert (moved, latin) == (1, 1) assert moves[0] == "family: 1" assert "'Smyth' -> 'Smith'" in moves[1] @@ -380,9 +385,12 @@ def test_compare_reads_the_facade_alone_below_2_0( sent, since no order existed before 2.0.""" def alter(results: list[dict[str, Any]]) -> None: results[0]["facade"]["last"] = "Smyth" - (moves, moved, _), sent = _fake_compare(monkeypatch, "1.4.0", alter) - assert moved == 1 and moves[0] == "family: 1" + r, sent = _fake_compare(monkeypatch, "1.4.0", alter) + assert r.moved == 1 and r.moves[0] == "family: 1" + # The order-bearing entry is skipped, and COUNTED as skipped: a + # figure quoted from this run has a stated population. assert [e["order"] for e in sent] == [None] + assert (r.compared, r.skipped) == (1, 1) def test_compare_checks_that_the_tree_side_is_the_checkout( @@ -392,7 +400,7 @@ def test_compare_checks_that_the_tree_side_is_the_checkout( the editable install and two wheels would agree on everything. REPO_ROOT is moved, which reproduces the trap's effect.""" monkeypatch.setattr(compare, "REPO_ROOT", tmp_path) - with pytest.raises(SystemExit, match="not from this checkout's source"): + with pytest.raises(SystemExit, match="not from the source package"): _fake_compare(monkeypatch, "2.4.0") @@ -407,3 +415,26 @@ def test_compare_checks_that_the_tree_side_is_the_checkout( ]) def test_latin_only_partition(name: str, latin: bool) -> None: assert compare._is_latin_only(name) is latin + + +def test_snapshot_checks_that_it_read_the_checkout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """snapshot.py has compare.py's exposure: run as a script, a + PYTHONPATH wheel outranks the editable install, and --record would + write the wheel's readings while --since reported parity. Moving + REPO_ROOT reproduces the trap's effect.""" + monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: []) + monkeypatch.setattr(snapshot, "REPO_ROOT", tmp_path) + with pytest.raises(SystemExit, match="not from the source package"): + snapshot.main([]) + + +def test_snapshot_tree_must_hold_its_own_source_package( + monkeypatch: pytest.MonkeyPatch) -> None: + """An ancestor of this checkout holds no nameparser/ of its own, so + the import falls through to this checkout; "under the root" would + pass it and compare the tree with its own snapshot.""" + monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: []) + monkeypatch.setattr(sys, "path", list(sys.path)) + with pytest.raises(SystemExit, match="not from the source package"): + snapshot.main(["--tree", str(_TOOLS.parents[2])]) diff --git a/tools/differential/compare.py b/tools/differential/compare.py index 41c27ade..f3193102 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -29,6 +29,7 @@ import tempfile from pathlib import Path from types import ModuleType +from typing import NamedTuple HERE = Path(__file__).resolve().parent DEFAULT_BASELINE = "2.4.0" @@ -229,41 +230,18 @@ def _worker_env() -> dict[str, str]: def _check_tree(module_file: str) -> Path: """Prove the OTHER side of the comparison is the working tree. - The baseline side gets a version tell, a pinned wheel and a temp - dir; the tree side was a bare import trusted on sight. It is - reachable by the same road: run as a script, sys.path[0] is - tools/differential/ -- which holds no nameparser -- so PYTHONPATH - outranks the editable install and compare.py imports a wheel while - believing it read the checkout. Two wheels agree on everything, so - the run reports parity. - - (The reason this is easy to miss when probing by hand: from the - repo root, `python -c` puts the CWD on sys.path first, so the - checkout wins there and the trap does not reproduce. Only the - script invocation shows it.) - - The test is "is this the SOURCE PACKAGE", not "is this somewhere - under the repo". The weaker form was written first and was wrong: - the checkout also contains .venv/, build/lib/ and dist/, any of - which can hold a released wheel, so `PYTHONPATH=/build/lib` - is the same trap with the shadowing directory moved one level to - the left -- and uv never touches build/, so nothing self-heals it. - Note the asymmetry the weak form created: /.venv/.../ - nameparser is REJECTED as a baseline by _check_tell and would have - been ACCEPTED as the tree here. + The baseline side gets a pinned wheel, a temp dir and a version + tell; without this the tree side would be a bare import trusted on + sight, reachable by the same PYTHONPATH road. snapshot.py holds the + predicate (check_source), so the two tools cannot disagree about + what counts as the checkout. + + (Why this is easy to miss when probing by hand: from the repo root, + `python -c` puts the CWD on sys.path first, so the checkout wins + there and the trap does not reproduce. Only the script invocation + shows it.) """ - at = Path(module_file).resolve() - source = REPO_ROOT / "nameparser" - if not at.is_relative_to(source): - raise SystemExit( - f"the tree side imported nameparser from {at}, not from " - f"this checkout's source package ({source}) -- so this run " - f"would compare that module against the baseline instead of " - f"the working tree. Unset PYTHONPATH; uninstall a " - f"non-editable nameparser from the active environment; or, " - f"in a git worktree, check that the editable install points " - f"at THIS worktree rather than the main checkout. " - f"Comparison aborted.") + at: Path = _load_snapshot().check_source(module_file, REPO_ROOT) return at @@ -318,15 +296,24 @@ def _is_latin_only(name: str) -> bool: return all(ord(ch) < 0x250 for ch in name) -def compare(baseline: str, corpora: list[Path] | None = None - ) -> tuple[list[str], int, int]: +class Result(NamedTuple): + moves: list[str] # snapshot.grouped's report + moved: int # entries that read differently + latin: int # ... of them Latin-only + compared: int # entries compared + skipped: int # order-bearing entries the baseline predates + + +def compare(baseline: str, corpora: list[Path] | None = None) -> Result: """The grouped moves between `baseline` and the tree over every - corpus (or `corpora`), with the number of moved names and how many - of them are Latin-only.""" + corpus (or `corpora`), with the population they were counted over: + an entry whose shape's order the baseline predates is skipped, and + a figure quoted without that denominator says nothing about it.""" snapshot = _load_snapshot() shapes = snapshot.load_shapes() min_for = {s.order: s.min_baseline for s in shapes.values() if s.order} - entries = [(name, order) for name, order in snapshot.corpus_entries(corpora) + every = snapshot.corpus_entries(corpora) + entries = [(name, order) for name, order in every if order is None or _parse_version(baseline) >= _parse_version(min_for[order])] want_v2 = "v2" in _surfaces_for(baseline) @@ -343,7 +330,8 @@ def compare(baseline: str, corpora: list[Path] | None = None after = {snapshot.key(r): r for r in new} moved = [k for k in after if before[k] != after[k]] latin = sum(_is_latin_only(name) for name, _ in moved) - return snapshot.grouped(before, after), len(moved), latin + return Result(snapshot.grouped(before, after), len(moved), latin, + len(entries), len(every) - len(entries)) def main() -> int: @@ -356,10 +344,13 @@ def main() -> int: "corpus*.jsonl here; repeatable. One JSON string " "per line is a name.") args = ap.parse_args() - moves, moved, latin = compare(args.baseline, args.corpus) - print("\n".join(moves) or "no differences") - print(f"\n{moved} name{'' if moved == 1 else 's'} read differently " - f"from {args.baseline}, {latin} of them Latin-only") + r = compare(args.baseline, args.corpus) + print("\n".join(r.moves) or "no differences") + print(f"\n{r.moved} of {r.compared} entries read differently from " + f"{args.baseline}, {r.latin} of them Latin-only") + if r.skipped: + print(f"{r.skipped} order-bearing entries skipped: {args.baseline} " + f"predates their name_order") return 0 diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py index b80ebc71..aa4cd4cf 100644 --- a/tools/differential/snapshot.py +++ b/tools/differential/snapshot.py @@ -35,6 +35,7 @@ from typing import Any HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[1] SNAPSHOT = HERE / "snapshot.jsonl" RECORD_COMMAND = "uv run python tools/differential/snapshot.py --record" V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", @@ -220,13 +221,29 @@ def grouped(old: dict[tuple[str, str], dict[str, Any]], return out -def _use_tree(path: str) -> None: - root = Path(path).resolve() - sys.path.insert(0, str(root)) - import nameparser - got = Path(nameparser.__file__).resolve() - if root not in got.parents: - sys.exit(f"--tree {root}: imported nameparser from {got} instead") +def check_source(module_file: str, root: Path = REPO_ROOT) -> Path: + """Abort unless `module_file` is inside `root`'s source package. + + Run as a script, sys.path[0] is tools/differential/, which holds no + nameparser, so a PYTHONPATH entry outranks the editable install and + the tree side reads a released wheel while believing it read the + checkout -- and a comparison of two wheels, or of a wheel with the + snapshot it wrote, reports no moves. The test is "is this the + SOURCE PACKAGE", not "is this under the root": the root also holds + .venv/, build/lib/ and dist/, any of which can hold a wheel, and an + ancestor of the checkout passes the weaker test while the import + falls through to the checkout itself.""" + at = Path(module_file).resolve() + source = (root / "nameparser").resolve() + if not at.is_relative_to(source): + raise SystemExit( + f"imported nameparser from {at}, not from the source package " + f"{source} -- so this run would read that module instead of " + f"the tree it names. Unset PYTHONPATH; uninstall a " + f"non-editable nameparser from the active environment; or, in " + f"a git worktree, check that the editable install points at " + f"THIS worktree rather than the main checkout. Aborted.") + return at def main(argv: list[str] | None = None) -> int: @@ -238,9 +255,12 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--out", type=Path, default=SNAPSHOT, help="where --record writes (default: snapshot.jsonl)") args = ap.parse_args(argv) + root = Path(args.tree).resolve() if args.tree else REPO_ROOT if args.tree: - _use_tree(args.tree) + sys.path.insert(0, str(root)) rows = read(corpus_entries()) + import nameparser + check_source(nameparser.__file__, root) if args.record: args.out.write_text(dump(rows), encoding="utf-8") print(f"recorded {len(rows)} readings to {args.out}") @@ -248,8 +268,14 @@ def main(argv: list[str] | None = None) -> int: if args.since: rel = SNAPSHOT.relative_to(Path(subprocess.check_output( ["git", "rev-parse", "--show-toplevel"], cwd=HERE, text=True).strip())) - text = subprocess.check_output(["git", "show", f"{args.since}:{rel}"], - cwd=HERE, text=True) + try: + text = subprocess.check_output( + ["git", "show", f"{args.since}:{rel}"], cwd=HERE, text=True) + except subprocess.CalledProcessError: + raise SystemExit( + f"no {rel} at {args.since}: the snapshot did not exist " + f"there. For a release tagged before it, use " + f"`compare.py --baseline `") from None else: text = SNAPSHOT.read_text(encoding="utf-8") moves = grouped(load(text), {key(r): r for r in rows}) From be2eac2514b85b3e4200e9696725d7e81548b53a Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:26:27 -0700 Subject: [PATCH 6/9] README: compare.py's closing lines state the population (#664) Co-Authored-By: Claude Opus 5.5 --- tools/differential/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/differential/README.md b/tools/differential/README.md index dfc81df4..5c4e527c 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -67,8 +67,10 @@ every corpus name with the pinned release; `compare.py` parses the same names with the tree and prints the moves. From 2.0.0 on both surfaces are compared; at 1.4.0 the facade alone, since 1.4 has no 2.0 API. A name tagged with a family-first shape is skipped below 2.0.0, which -introduced `name_order`. The last line counts the moved names and how -many are Latin-only. +introduced `name_order`. The closing lines count the moved entries +against the entries compared, how many of the moved are Latin-only, +and how many order-bearing entries were skipped: quote a figure with +its population. Most of what is left in `compare.py` exists for one failure: a comparison that silently reads the tree on both sides and reports no From 8ee579f2852ed0fc3ebfcea4a92b6512bd3311fc Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:32:22 -0700 Subject: [PATCH 7/9] Docs-review fixes: the decision's numbers, and what still described tiers (#664) - decisions.md: the share recipe names its head (04cc0814) and its exact exclusions; 21% not 20%; #655's ledger and guard edits were 236 lines, not 241 (the other 5 were corpus data); the per-PR spread is stated as measured (0-82%, median 11%, above 30% on 11 of the 29 commits touching nameparser/) instead of "30-60%". - AGENTS.md and mechanisms.md named _EXCLUSION_EFFECT, deleted with the guards, as the recorded-negative-control exemplar; it is _UNGUARDED_EFFECT now. - rules.md S2, C1 and C2 argued against example lines from the cost of entering the enforced corpus; the reason now given is the promise an example would make. - Both tools print how many moved names only corpus_cldr.jsonl holds, which a release figure counts out. - Stale tier/roster mentions in _script_segment.py and cases.py. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 ++-- docs/design/decisions.md | 2 +- docs/design/mechanisms.md | 2 +- docs/design/rules.md | 11 +++++------ nameparser/_pipeline/_script_segment.py | 4 ++-- tests/v2/cases.py | 7 +++---- tests/v2/test_snapshot.py | 11 +++++++++++ tools/differential/README.md | 3 ++- tools/differential/compare.py | 8 ++++++-- tools/differential/snapshot.py | 24 +++++++++++++++++++++++- 10 files changed, 56 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 377944b2..050d3013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three committed contributor docs carry the parser's normative rules and their re **Counting claims.** A bare count in prose is either an assertion or a liability, keyed by who observes its staleness: asserted counts (a test holds the number) fail CI at change time — the useful kind; dated snapshots ("51 sites at spec time") cannot go stale; standing present-tense prose counts are the forbidden class — promote to an assertion, add a date, or state the invariant and let a test count. After changing how many times something runs, sweep for counts, not for the thing's name. -**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the snapshot's diff since the last release, be verified against rules.md examples, or -- for a view the snapshot cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. `uv run python tools/differential/snapshot.py --since v` prints every corpus name the release moved, grouped by changed field; for a release tagged before the snapshot existed (2.4.0 and earlier), `compare.py --baseline ` prints the same report against the PyPI wheel, with the Latin-only count. The snapshot records the seven role fields, ambiguity kinds and `initials()` for every name, so an initials count is read off it directly. `corpus_cldr.jsonl`'s fictional renderings are in it: count them out of a release figure, or say the figure includes them. `capitalized()` and any other render view stay invisible to the snapshot (decisions.md#R4, #R3), and for those the first two sources cannot reach a claim: the snapshot is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Code comments citing rule IDs follow the excerpt discipline, checked by tests/v2/test_doc_citations.py: a rule's statement and its `Accepted:` clauses are quotable, its example lines are not; `rules.md#H` names the H section, whose Background is quotable; a decisions.md citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all. +**Release-log claims.** Quantified or universal behavior claims in release bullets must come from the snapshot's diff since the last release, be verified against rules.md examples, or -- for a view the snapshot cannot see -- carry a recompute recipe stored with the design entry the bullet cites; never write one from memory. `uv run python tools/differential/snapshot.py --since v` prints every corpus name the release moved, grouped by changed field; for a release tagged before the snapshot existed (2.4.0 and earlier), `compare.py --baseline ` prints the same report against the PyPI wheel, with the Latin-only count. The snapshot records the seven role fields, ambiguity kinds and `initials()` for every name, so an initials count is read off it directly. `corpus_cldr.jsonl`'s fictional renderings are in it: count them out of a release figure (both tools print how many moved names only that file holds), or say the figure includes them. `capitalized()` and any other render view stay invisible to the snapshot (decisions.md#R4, #R3), and for those the first two sources cannot reach a claim: the snapshot is byte-identical across the change, and an example line witnesses an output without counting anything. A recipe names the corpus files, the policy sweep, and -- the part that is easy to omit and fatal -- THE COMPARATOR, which must be something the shipped tree is not: #408's first recipe said to compare `initials()` against a folded-first partition, which is what `initials()` now IS, so it reproduced 0 where the bullet claimed 660 and was the only stated provenance for the number. Run the recipe as written before shipping the bullet. Cross-version numbers (a released wheel, the pre-change tree) are dated snapshots under Counting claims, since nothing in the repository re-runs them. The same standard binds decisions entries and PR bodies: a claim that a change restores the released reading is measured over the whole moved population against the wheel, never spot-checked (#625 claimed its 774 moves landed on the 1.4.0 and 2.3.0 readings from three names; measured, the tree matched 2.3.0 on 0 of 258 moved texts, master on 152), and a population size is re-counted from its recipe, never copied from another entry (the same entry carried #624's 137,810 for a grid of 395,148). Code comments citing rule IDs follow the excerpt discipline, checked by tests/v2/test_doc_citations.py: a rule's statement and its `Accepted:` clauses are quotable, its example lines are not; `rules.md#H` names the H section, whose Background is quotable; a decisions.md citation may paraphrase, but what it quotes must be verbatim; and a reference with no quote must still name something that exists. The check's reach is a quote within four words of the ID or chained to one that is: a quote further off reads exactly like a name written in double quotes, so it is NOT checked, and the second review of #632 found 14 such quotes stale by hand. Keep a quote beside its ID. Quotes of AGENTS.md itself are not checked at all. **Writing the user docs (docs/*.rst) has its own AGENTS.md too.** `docs/AGENTS.md` carries the style the docs are written in — tables as indexes, subheadings per task, measured claims, recipe doctests — distilled from PR #588. It loads the same way as the docs/design/ one below; if your tool does not do nested discovery, read it before editing a `.rst` file under docs/. @@ -38,7 +38,7 @@ Three committed contributor docs carry the parser's normative rules and their re **Markdown line width: don't hard-wrap prose.** Measured 2026-08-16 — wrapped and unwrapped render identically, docs/design/ is not in the Sphinx build, and unwrapping is byte-identical (newline→space is 1:1), so the width buys nothing and costs diff churn (74% of #386's decisions.md deletions were reflow, not change). AGENTS.md and docs/design/ were unwrapped in one whitespace-only commit on 2026-08-16; keep new prose unwrapped rather than reformatting again. The blame cost was 43 lines of 1717, measured, not the wholesale reset predicted before doing it — git's diff maps most joined lines back to whichever commit wrote their opening text. `.git-blame-ignore-revs` lists the commit and is worth enabling (`git config blame.ignoreRevsFile .git-blame-ignore-revs`) for future format-only commits, but it recovered none of those 43: `--ignore-revs-file` cannot pass blame through a commit that MERGES lines, because the joined line has no counterpart in the parent to re-attribute to. Exceptions, both enforced: rules.md's example and pointer lines are parsed per line, so a wrapped example is a hard error naming its rule; and rules.md's rule BODIES stay wrapped because a rule block is a record format, aligning 4-space statement prose above 6-space examples, not a paragraph. -**Guard tests** SHOULD carry a recorded negative control — the answer with the guard off, stored as data (the _EXCLUSION_EFFECT shape; see mechanisms.md's Verification shapes). Re-take it after the last commit that adds tests, before merge: a review round that adds rows moves the count, and three recorded controls in a row went stale that way (#625 27 → 30, #636 4 → 9, #637 5 → 7 → 11), each caught only by the next review. +**Guard tests** SHOULD carry a recorded negative control — the answer with the guard off, stored as data (the `_UNGUARDED_EFFECT` shape in tests/v2/pipeline/test_state.py; see mechanisms.md's Verification shapes). Re-take it after the last commit that adds tests, before merge: a review round that adds rows moves the count, and three recorded controls in a row went stale that way (#625 27 → 30, #636 4 → 9, #637 5 → 7 → 11), each caught only by the next review. **Invariant tests over a generated grid share ONE parsed grid, and a grid pairs a text only with configurations that can read it differently.** Both rules are in `tests/v2/test_properties.py` (`_connective_findings`, `_off_switch_findings`, `_rows`) and both were bought at a price: four tests each walking the same 170,100-row grid, and a grid that was a full cross product of texts × lexicons × policies, together took that module from 11s to 115s and CI's build jobs from ~5 minutes to 17–25 (under coverage every parse costs several times more, and every later PR pays it). A new invariant over an existing grid joins that grid's walk instead of opening its own; a new grid states its measured runtime where it is built. Trim a grid by SHAPE — a variant lexicon reaches only the texts holding the word it adds — never by sampling, and prove the dropped rows were duplicates by signature before dropping them. Mutation counts in the tests' docstrings are counts over the grid, so re-measure them whenever the grid moves -- and so do the DESIGN DOCS that quote them. A recorded control reaches `docs/design/decisions.md` and `docs/design/mechanisms.md` as well as the docstring, nothing checks those copies, and a grid change silently makes them wrong about a number they present as measured: the #397 second review moved four such figures at once (2026-09-20). Move them in the same commit, and where the older number is worth keeping, label it with the population it was measured over rather than deleting it. diff --git a/docs/design/decisions.md b/docs/design/decisions.md index d0874bcc..d12f1609 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -1427,7 +1427,7 @@ Decisions that landed: - 2026-10-13 #664 — **`compare.py` stays, unclassified.** It keeps the released-wheel runner and its checks against comparing one tree with itself (the version tell, the tree-side path check, the stripped `PYTHONPATH`; mechanisms.md#VERSION-TELL, #GENERATED-SCRIPT-OUTSIDE-THE-WORKTREE), prints the same grouped report as the snapshot and exits 0. It answers "how did release X read these names" for a release tagged before the snapshot existed; after that, `snapshot.py --since ` is the cheaper answer. - 2026-10-13 #664 — **the CLDR agreement ratchet stays** (#654). It answers a different question, agreement with CLDR's stated fields rather than stability. -Measured 2026-10-12, before the change, over the last 40 first-parent commits on master with corpus data excluded: 5,236 of 25,226 changed lines (20%) were ledger, `compare.py` or ledger-guard edits, touched by 21 of the 40, and 30–60% on parser-behavior PRs (#655: 236 of 448). Run against the code before and after #655, the snapshot reports five moved names, against that PR's 241 lines of ledger and guard edits; merging master afterwards, it reported #663's one new corpus name where #663 had added the same ten lines to each of six ledgers. RECOMPUTE (shares): `git log --first-parent -40 --format=%h master`, and for each commit sum `git show --numstat` over paths matching `expected_since|compare\.py|ledger_guards|test_differential|_differential_fixtures`, skipping `.jsonl` and `cldr_*` data. +Measured 2026-10-12, before the change, over the 40 first-parent commits ending at 04cc0814: 5,236 of 25,226 changed lines (21%) were ledger, `compare.py` or harness-test edits, and 21 of the 40 commits touched them. The share is uneven rather than steady: across the 29 of those commits that touch `nameparser/` it runs from 0% to 82%, median 11%, above 30% on 11 of them (#655: 236 of 448). Run against the code before and after #655, the snapshot reports five moved names, against that PR's 236 lines of ledger and guard edits; merging master afterwards, it reported #663's one new corpus name where #663 had added the same ten lines to each of six ledgers. RECOMPUTE (shares): `git log --first-parent -40 --format=%h 04cc0814`, and for each commit sum `git show --numstat` over every path except `*.jsonl` and `cldr_agreement.json`, the harness share over paths matching `expected_since|compare\.py|ledger_guards|test_differential|_differential_fixtures`; a commit "touches `nameparser/`" when any path starts with it. Accepted costs: diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index 4aeea99a..b14185f4 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -133,7 +133,7 @@ Problem shape. v1 code mutates CONSTANTS at runtime; the core is immutable. Cont ## Verification shapes -How to measure in this codebase without fooling yourself. The inert-measurement class — checks that run, print plausible results, and measure nothing — has recurred double-digit times; these shapes are its known antidotes. Convention (AGENTS.md): guard tests SHOULD carry a RECORDED negative control, the _EXCLUSION_EFFECT shape — the answer with the guard off, stored as data. Honest limit, precisely drawn: these reduce the inert-measurement class. Mutation testing reaches part of the wrong-predicate class too — mutating the thing a guard watches exposes a guard that never depended on it (the #329 survivors: deleting the tag check left the suite green while every multi-word maiden clause lost its first word, after adversarial review had passed that code twice) — but a predicate wrong in a way the fixture happens to satisfy still needs adversarial review. +How to measure in this codebase without fooling yourself. The inert-measurement class — checks that run, print plausible results, and measure nothing — has recurred double-digit times; these shapes are its known antidotes. Convention (AGENTS.md): guard tests SHOULD carry a RECORDED negative control, the `_UNGUARDED_EFFECT` shape (tests/v2/pipeline/test_state.py) — the answer with the guard off, stored as data. Honest limit, precisely drawn: these reduce the inert-measurement class. Mutation testing reaches part of the wrong-predicate class too — mutating the thing a guard watches exposes a guard that never depended on it (the #329 survivors: deleting the tag check left the suite green while every multi-word maiden clause lost its first word, after adversarial review had passed that code twice) — but a predicate wrong in a way the fixture happens to satisfy still needs adversarial review. ### VERSION-TELL — know who answered diff --git a/docs/design/rules.md b/docs/design/rules.md index 133472dd..0afa80ca 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -1313,8 +1313,7 @@ S2. Rationale: generational suffixes and credentials are recognized after the name. That reading is older than the company clause, so it is witnessed by tests/v2/cases.py's a_split_degree_in_front_is_out_of_the_walk rather than by a line - here, which would bring the name into the corpus that enforces - it at released baselines for a reading this clause did not make. + here, which would promise a reading this clause did not make. A title standing between the credential and the member, and a member a particle chain (P2) had taken, were two more such shapes until the credential's run (#602) took both in: the rows @@ -2061,8 +2060,8 @@ C1. Rationale: a credential run after the comma means the name is in Accepted: the further-comma qualifier carries no example line of its own. It discriminates PAIRS and spans both branches, so exemplifying it means a with-comma partner for each — every one - of them a name entering the rules corpus for behavior that has - not moved since v1. Its executable witness is instead the pair + of them a promise about behavior that has not moved since v1. + Its executable witness is instead the pair already standing in the v1-style bank, tests/test_suffixes.py's test_roman_numeral_i_after_single_initial_lastname_comma_format @@ -2135,8 +2134,8 @@ C2. Rationale: text beyond the recognized comma parts should be "John Smith, MD, XYZ" unlisted_caps_suffixes-everywhere → ambiguities=("comma-structure",) Accepted: the no-name-reading clause's REPORT half carries no example line of its own. What it moves is a report with no field - beside it, and an example line would enter the rules corpus for - that report alone; its title half is witnessed by the two `John + beside it, and an example line would promise that report alone; + its title half is witnessed by the two `John Smith, Jr., van der Berg Prof.` lines above, and its report half's executable witnesses are the case rows a_credential_run_after_the_comma_reports_no_chain_fork and diff --git a/nameparser/_pipeline/_script_segment.py b/nameparser/_pipeline/_script_segment.py index c7a5ce6f..d772c878 100644 --- a/nameparser/_pipeline/_script_segment.py +++ b/nameparser/_pipeline/_script_segment.py @@ -255,8 +255,8 @@ def _is_post_nominal(state: ParseState, i: int) -> bool: # classification rather than cut into 박사 + 님, which is what the # same input gives when the step is removed. '선생님' is post-nominal # entire with no name word behind it, so the scan yields no site at -# all and the token stays whole. Both are contract-tier corpus names -# and rules.md#W2 example lines; decisions.md#cjk-comma-demotion +# all and the token stays whole. Both are corpus names and +# rules.md#W2 example lines; decisions.md#cjk-comma-demotion # carries the forced-predicate measurement behind them. def _peel_site(state: ParseState, flat: Sequence[int], tails: frozenset[str]) -> tuple[int, int] | None: diff --git a/tests/v2/cases.py b/tests/v2/cases.py index 560b46a9..64db8a51 100644 --- a/tests/v2/cases.py +++ b/tests/v2/cases.py @@ -6182,8 +6182,7 @@ def _check_cjk_shape_purity(self) -> None: ambiguities=("suffix-or-name",), notes="a family comma names the family, so the declared " "order arbitrates nothing here. Measured identical " - "under all three orders, which is also why " - "`_ORDER_EXEMPTION_EFFECT` gains no row"), + "under all three orders"), Case("the_trailing_slot_reads_the_same_under_ff_given_last", "Doe, John MA", {"given": "John", "family": "Doe", "suffix": "MA"}, @@ -10669,8 +10668,8 @@ def _check_cjk_shape_purity(self) -> None: "divided the RAW text after 김, and the stranded mark " "opened the family: given 김, family \u3099민준. " "Tolerated: rules.md's W Background leaves a mark after " - "a non-kana unpromised, so this reading is watched on " - "the radar rather than pinned as contract", + "a non-kana unpromised, so this reading is watched by " + "the snapshot rather than pinned as contract", tolerated=True), Case("ja_iteration_mark_is_han", "佐々木 太郎", {"family": "佐々木", "given": "太郎"}, diff --git a/tests/v2/test_snapshot.py b/tests/v2/test_snapshot.py index 9199c2b0..6e5d9ef5 100644 --- a/tests/v2/test_snapshot.py +++ b/tests/v2/test_snapshot.py @@ -30,3 +30,14 @@ def test_a_name_leaving_the_corpus_is_reported() -> None: assert snapshot.grouped({snapshot.key(r): r for r in rows}, {snapshot.key(rows[0]): rows[0]}) \ == ["(removed from corpus): 1", " Jane Doe"] + + +def test_a_name_only_cldr_holds_is_counted_out() -> None: + # A release figure counts CLDR's fictional renderings out (AGENTS.md, + # Release-log claims); a name another corpus also holds stays in. + # One of #653's movers, a CLDR rendering; 'Jane (Janey, Jo) Smith' + # moved with it and is a rules.md example. + cldr_name = "Macáková, Alexandra Zuzana (pani, PhD)" + cldr = snapshot.only_in(snapshot.CLDR_CORPUS, + {cldr_name, "Jane (Janey, Jo) Smith"}) + assert cldr == {cldr_name} diff --git a/tools/differential/README.md b/tools/differential/README.md index 5c4e527c..e1fddbba 100644 --- a/tools/differential/README.md +++ b/tools/differential/README.md @@ -42,7 +42,8 @@ pass for a clean run. against the snapshot as committed at that tag: the names this release moved. That is the measurement a release-note claim about moved names is drawn from. Count CLDR's fictional renderings out of such a figure, -or say it includes them. +or say it includes them: the closing line says how many moved names +only `corpus_cldr.jsonl` holds. `--tree PATH` reads with another checkout's `nameparser` (the corpus is always this checkout's), and checks that the import really came from diff --git a/tools/differential/compare.py b/tools/differential/compare.py index f3193102..8b6764a9 100644 --- a/tools/differential/compare.py +++ b/tools/differential/compare.py @@ -300,6 +300,7 @@ class Result(NamedTuple): moves: list[str] # snapshot.grouped's report moved: int # entries that read differently latin: int # ... of them Latin-only + cldr: int # ... names of them held only by corpus_cldr.jsonl compared: int # entries compared skipped: int # order-bearing entries the baseline predates @@ -330,7 +331,9 @@ def compare(baseline: str, corpora: list[Path] | None = None) -> Result: after = {snapshot.key(r): r for r in new} moved = [k for k in after if before[k] != after[k]] latin = sum(_is_latin_only(name) for name, _ in moved) - return Result(snapshot.grouped(before, after), len(moved), latin, + cldr = len(snapshot.only_in(snapshot.CLDR_CORPUS, + {name for name, _ in moved})) + return Result(snapshot.grouped(before, after), len(moved), latin, cldr, len(entries), len(every) - len(entries)) @@ -347,7 +350,8 @@ def main() -> int: r = compare(args.baseline, args.corpus) print("\n".join(r.moves) or "no differences") print(f"\n{r.moved} of {r.compared} entries read differently from " - f"{args.baseline}, {r.latin} of them Latin-only") + f"{args.baseline}, {r.latin} of them Latin-only; {r.cldr} of " + f"their names held only by corpus_cldr.jsonl") if r.skipped: print(f"{r.skipped} order-bearing entries skipped: {args.baseline} " f"predates their name_order") diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py index aa4cd4cf..7577c08e 100644 --- a/tools/differential/snapshot.py +++ b/tools/differential/snapshot.py @@ -107,6 +107,22 @@ def corpus_entries(paths: list[Path] | None = None) -> list[Entry]: return sorted(found, key=lambda e: (e[0], e[1] or "")) +#: The corpus a release figure counts out (AGENTS.md, Release-log +#: claims): fictional CLDR renderings, not names anyone reported. +CLDR_CORPUS = HERE / "corpus_cldr.jsonl" + + +def only_in(path: Path, names: set[str]) -> set[str]: + """The `names` that `path` holds and no other corpus beside it does.""" + orders = {k: s.order for k, s in load_shapes().items()} + def held(p: Path) -> set[str]: + return {_corpus_line(p, ln, orders)[0] + for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()} + others = set().union(*(held(p) for p in sorted(HERE.glob("corpus*.jsonl")) + if p != path)) + return (names & held(path)) - others + + def row(name: str, order: str | None, v2: dict[str, Any] | None, facade: dict[str, str] | None) -> dict[str, Any]: """One snapshot line from one name's readings. @@ -278,8 +294,14 @@ def main(argv: list[str] | None = None) -> int: f"`compare.py --baseline `") from None else: text = SNAPSHOT.read_text(encoding="utf-8") - moves = grouped(load(text), {key(r): r for r in rows}) + old, new = load(text), {key(r): r for r in rows} + moves = grouped(old, new) print("\n".join(moves) or "matches the snapshot") + if moves: + moved = {k for k in set(old) | set(new) if old.get(k) != new.get(k)} + cldr = only_in(CLDR_CORPUS, {name for name, _ in moved}) + print(f"\n{len(moved)} entries differ, {len(cldr)} of their names " + f"held only by {CLDR_CORPUS.name}") if moves and not args.since: print(f"\nif these moves are intended: {RECORD_COMMAND}") return 1 if moves else 0 From f8ae69ed7ae6f10072df78283f0acaed19b3d378 Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:32:34 -0700 Subject: [PATCH 8/9] AGENTS.md: sort a deleted test module's tests by what they exercise (#664) Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 050d3013..cc35644b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,6 +335,8 @@ A parse-behavior test can still be a disguised constant-content test if it only **A guard that decides its own scope can stop guarding without failing.** Two shapes hit in #329, both in live code. `_POLICY_SHAPES` in `test_benchmark.py` had one entry, and pytest turns an **empty parametrize into a skip, not a failure**, so deleting it would have dissolved the quadratic guard into the skip count. And `test_facade_cases.py::_constants_for` decides which case rows it can express: its gate had been structurally unsatisfiable since the maiden-wins canonicalization landed, silently skipping seven rows, while an unenumerated `Policy` field would instead let a row run MISTRANSLATED. Neither could fail, because a skip is indistinguishable from "correctly declined". Assert the table is non-empty, give each shape a reachability probe, and tie any hand-written field enumeration to `dataclasses.fields(...)` the way `test_policy.py`'s `test_policy_patch_mirrors_policy_field_names` does. **A component that filters its own input needs a test on the filter, not only on what survives it.** +**Deleting a test module: sort its tests by the code they exercise, not by their names.** A module named for a retired tool also holds tests of code that survives it. #664 deleted `test_ledger_guards.py` and rewrote `test_differential.py`, triaging both by test name, and review found five tests guarding live code in the discard pile: four of `build_issues_corpus.py`'s harvester, and the check that `compare.py` reports the entries it skips. For each test, grep its module-level handles (`load_tool("...")`, imports) and ask whether that code is also being deleted. + **When adding a new aggregate property** (like `given_names` or `surnames`), always include a test for the empty path — a name that produces no value for that property — so the `or self.C.empty_attribute_default` guard is covered. The conftest dual-fixture then automatically exercises both `""` and `None` variants. Example: `HumanName("Williams")` for a given-names property (last-name-only string has no first or middle). **`python_classes = ["*Tests", "*TestCase"]`** in `pyproject.toml` — suffix style (`FooTests`), NOT prefix (`TestFoo`); wrong style silently skips discovery. From 767b49a5057ee885f8fbde3a8292f85a25ec6d1a Mon Sep 17 00:00:00 2001 From: Derek Gulbranson Date: Tue, 13 Oct 2026 00:35:36 -0700 Subject: [PATCH 9/9] Second-round review fixes (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - only_in's test now holds a name CLDR shares with another corpus (マイケル), so the subtraction is exercised; without it, dropping '- others' passed every test. - compare.py's CLDR-only count is asserted, at 0 and at 1. - --since names both causes of a missing snapshot (unknown ref, or one from before the snapshot). - A stale 'the gate witnesses it' comment in test_group.py. Co-Authored-By: Claude Opus 5.5 --- tests/v2/pipeline/test_group.py | 2 +- tests/v2/test_differential.py | 20 +++++++++++++++++--- tests/v2/test_snapshot.py | 9 +++++++-- tools/differential/snapshot.py | 6 +++--- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/tests/v2/pipeline/test_group.py b/tests/v2/pipeline/test_group.py index 8e4a05b1..c387b5c7 100644 --- a/tests/v2/pipeline/test_group.py +++ b/tests/v2/pipeline/test_group.py @@ -647,7 +647,7 @@ def test_the_licence_joins_a_word_not_a_particle_chain() -> None: # the bound word must not absorb it: untitled, "abdul van der # Berg" keeps the chain as its family, and the title cannot make # the surname vanish. Found in review; the rules.md example carries - # the shape into the rules corpus, so the gate witnesses it too. + # the shape into the rules corpus, so the snapshot watches it too. out = _grouped("sir abdul van der Berg", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul", "van der Berg"]] diff --git a/tests/v2/test_differential.py b/tests/v2/test_differential.py index 4787679b..87f2163a 100644 --- a/tests/v2/test_differential.py +++ b/tests/v2/test_differential.py @@ -335,12 +335,13 @@ def _tree_results(entries: list[tuple[str, str | None]], want_v2: bool def _fake_compare(monkeypatch: pytest.MonkeyPatch, baseline: str, - alter: "Callable[[list[dict[str, Any]]], None] | None" = None + alter: "Callable[[list[dict[str, Any]]], None] | None" = None, + entries: list[tuple[str, str | None]] = _ENTRIES, ) -> tuple[Any, list[dict[str, Any]]]: """compare() over _ENTRIES with the worker replaced by the tree's own readings, optionally altered by `alter(results)`; also returns the entries the worker was sent.""" - monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: list(_ENTRIES)) + monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: list(entries)) monkeypatch.setattr(compare, "_load_snapshot", lambda: snapshot) sent: list[list[dict[str, Any]]] = [] @@ -373,11 +374,24 @@ def alter(results: list[dict[str, Any]]) -> None: results[0]["facade"]["last"] = "Smyth" r, _ = _fake_compare(monkeypatch, "2.4.0", alter) moves, moved, latin = r.moves, r.moved, r.latin - assert (moved, latin) == (1, 1) + assert (moved, latin, r.cldr) == (1, 1, 0) assert moves[0] == "family: 1" assert "'Smyth' -> 'Smith'" in moves[1] +def test_compare_counts_a_moved_name_only_cldr_holds( + monkeypatch: pytest.MonkeyPatch) -> None: + """A release figure counts CLDR's fictional renderings out, so the + report says how many moved names only corpus_cldr.jsonl holds.""" + cldr_name = "Macáková, Alexandra Zuzana (pani, PhD)" + def alter(results: list[dict[str, Any]]) -> None: + results[0]["v2"]["nickname"] = "pani PhD" + results[0]["facade"]["nickname"] = "pani PhD" + r, _ = _fake_compare(monkeypatch, "2.4.0", alter, + entries=[(cldr_name, None)]) + assert (r.moved, r.cldr) == (1, 1) + + def test_compare_reads_the_facade_alone_below_2_0( monkeypatch: pytest.MonkeyPatch) -> None: """1.4 has no 2.0 surface, so the tree's facade reading is what it diff --git a/tests/v2/test_snapshot.py b/tests/v2/test_snapshot.py index 6e5d9ef5..7b5306ba 100644 --- a/tests/v2/test_snapshot.py +++ b/tests/v2/test_snapshot.py @@ -36,8 +36,13 @@ def test_a_name_only_cldr_holds_is_counted_out() -> None: # A release figure counts CLDR's fictional renderings out (AGENTS.md, # Release-log claims); a name another corpus also holds stays in. # One of #653's movers, a CLDR rendering; 'Jane (Janey, Jo) Smith' - # moved with it and is a rules.md example. + # moved with it and is a rules.md example only. 'マイケル' is in CLDR + # AND the CJK corpus, which is the half the subtraction decides. cldr_name = "Macáková, Alexandra Zuzana (pani, PhD)" + shared = "マイケル" + assert snapshot.only_in(snapshot.CLDR_CORPUS, {shared}) == set() + assert shared in {n for n, _ in snapshot.corpus_entries( + [snapshot.CLDR_CORPUS])}, "pick another name CLDR shares" cldr = snapshot.only_in(snapshot.CLDR_CORPUS, - {cldr_name, "Jane (Janey, Jo) Smith"}) + {cldr_name, "Jane (Janey, Jo) Smith", shared}) assert cldr == {cldr_name} diff --git a/tools/differential/snapshot.py b/tools/differential/snapshot.py index 7577c08e..62ec547a 100644 --- a/tools/differential/snapshot.py +++ b/tools/differential/snapshot.py @@ -289,9 +289,9 @@ def main(argv: list[str] | None = None) -> int: ["git", "show", f"{args.since}:{rel}"], cwd=HERE, text=True) except subprocess.CalledProcessError: raise SystemExit( - f"no {rel} at {args.since}: the snapshot did not exist " - f"there. For a release tagged before it, use " - f"`compare.py --baseline `") from None + 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 else: text = SNAPSHOT.read_text(encoding="utf-8") old, new = load(text), {key(r): r for r in rows}