Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/design/decisions/C15-comma-with-nothing-before-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# C15 — a comma with nothing before it

## Standing

- A comma with nothing before it is a family comma naming no surname, the part after it read as the given part after a family comma reads, held by an invariant over two inputs rather than by a sweep shape: `, X` reads as `Smith, X` reads, less the surname, its fields and ambiguity kinds alike; a particle P6 attaches is then the family alone (`, Jane van` → family 'van') — 2026-10-27 #892.
- H1 is the one exception: a title and one name word, or a bound pair standing for it, read as the family (`, Dr. John` → family 'John', `, Dr. abdul Rahman` → family 'abdul Rahman'), the comma naming no surname to decide the field by, as a comma with nothing but titles after it names no given part — 2026-10-27 (Derek).

Open: whether the code written for the empty head alone (#890's floor for `, Dr. fils`, the P6 attachment site in post_rules) can go, the question #892's fourth point asks; the invariant answers it by mutation, one site at a time.

## Log

- 2026-10-27 #892 — A COMMA WITH NOTHING BEFORE IT IS A FAMILY COMMA NAMING NO SURNAME. The shape had no rule: clauses in rules/H8, rules/O6 and rules/M2 and review findings in decisions/C1, S10, A1 and H5 each decided it locally, and nameparser/AGENTS.md made every comma change sweep it. Real names almost never take it: 10 of about 6 million local-corpus names (FEC 4, tse 3, PSC 2, tw-donors 1, the last opening with a fullwidth comma, `,邱詩涵`, which reads as `, 邱詩涵` does), the issue's count of 2026-10-25 missing the fullwidth one; recounted 2026-10-27 by scanning each local corpus's names for one opening, after spaces, with `,` or `,`. The alternative, dropping the comma so `, John Smith` reads as `John Smith`, was declined: the shape's one source is an empty surname field in listing data (#613's review, `, PhD` and `, Jr.`), and reading it as a whole name contradicts that source.
- Measured on master (8a65ee18) before any change, over the grid `tests/v2/test_properties.py::test_an_empty_head_reads_as_its_family_comma_twin` builds (10 title runs × 32 bodies × 11 trailing words, 3,519 given parts under each of three orders): every given part reads as its twin but 1,075 per order, all of them H1's, so the rule landed with no code change. A first comparison's eight differing given parts holding `MA`, among them `, MA`, `, (Bob) MA`, `, J. MA` and `, JOHN SMITH MA`, were the twin's doing, not the empty head's: a surname written `Smith` brings a case contrast the given part lacks, so `Smith, MA` reads suffix 'MA' by its capitals (rules/S6) while `, MA` and `SMITH, MA` both read given 'MA'. The test writes the twin's surname in the given part's case for that reason, and rules/C15's Accepted bullet records the reading.
- The particle-and-suffix words `vd` and `mc` ending the name behind H1's family reading (`, Dr. Jones vd`) are the exception's knock-on, not a second one: the twin attaches the word to its surname (rules/P6), the empty head has no family to attach it to once H1 has made the lone word the family, and it reads as the suffix. Behind a given-name title H1 reads no family and P6 attaches as in the twin (`, Sir Jones vd` → family 'vd').
- The exception is stated by its result, not by the comma-less name: `, Jane Mrs` reads family 'Jane' where `Jane Mrs` reads family 'Mrs' (a bare salutation is a title only after a family comma, rules/H8), and `, Dr. abdul Rahman` reads family 'abdul Rahman' where `Dr. abdul Rahman` reads given 'abdul', family 'Rahman'. A first draft compared against the comma-less name and the design-docs review found it false on 493 of the 1,075 H1 rows per order.
- The invariant compares fields and ambiguity KINDS, never a report's detail: `, Sir Jones vd` reports that 'vd' "joins the family the comma named", a comma that names none, and passes. Report-detail grids keep the empty head (nameparser/AGENTS.md).
- Recompute: `uv run pytest -n0 "tests/v2/test_properties.py::test_an_empty_head_reads_as_its_family_comma_twin"`; its docstring records the negative control.

- 2026-10-27 (Derek) — H1 STAYS THE ONE EXCEPTION, stated as the comma deciding nothing. "Keep H1 as the one exception, stated as a comma with nothing before it not deciding the field." `, Dr. John` keeps family 'John', where its twin `Smith, Dr. John` reads given 'John': rules/H1 already says a comma with nothing but titles after it names no given part and so does not decide the field, and a comma with nothing before it names no family, the mirror case. Following the invariant instead would have read given 'John' for every such row, 1,075 per order on the grid; rules/H8's Accepted bullet records the trailing spellings' family reading (`, Jane Mrs`, and the period-marked one since H5, #316).
1 change: 1 addition & 0 deletions docs/design/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Entry conventions:
- [C12 — a suffix word ending the name before the comma](C12-suffix-word-ending-the-name-before-the-comma.md)
- [C13 — a credential opening the part after the comma](C13-credential-opening-the-part-after-the-comma.md)
- [C14 — strict mode: a suffix word written as an initial](C14-strict-mode-a-suffix-word-written-as-an-initial.md)
- [C15 — a comma with nothing before it](C15-comma-with-nothing-before-it.md)

## Name order (O)

Expand Down
54 changes: 54 additions & 0 deletions docs/design/rules/C15-comma-with-nothing-before-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# C15 — a comma with nothing before it

**When:** a name opens with a comma that has nothing written before it, as in ", John Smith".

**Then:** the comma is a family comma (C1) naming no surname, and the part after it reads as the given part after a family comma reads, with no surname supplied for it.

**Because:** a comma with nothing before it is how a listing writes a record whose surname field is empty, as CSV data writes one. The writer marked where the surname goes and left it empty, so the parser supplies none, and a floor that keeps a family word does not apply, the input naming no family to lose. Read this way the shape has no reading of its own: the part reads as it reads behind any surname.

```
", John Smith" → given="John"
", John Smith" → middle="Smith"
", John Smith" → family=""
", John Smith Jr." → suffix="Jr."
", John (Bob) Smith" → nickname="Bob"
", PhD" → suffix="PhD"
", Jr." → suffix="Jr."
", Sir John" → given="John"
```

**Except:**

- **A title and one name word** (H1): the comma names no surname, so it does not decide the field of that word, or of a bound pair standing for it, and the title's lone name word reads as the family. A particle that is also a suffix word, ending the name behind a title that addresses by surname, then has no family to attach to (P6) and reads as the suffix.
```
", Dr. John" → family="John" · boundary
", Jane Mrs" → family="Jane"
", Dr. abdul Rahman" → family="abdul Rahman"
", Dr. Jones vd" → suffix="vd"
```

**Precedence:**

- **A particle ending the name** (P6) attaches to the family after this comma as after any family comma, and with no surname written the family is that particle alone: `, Jane van` reads family `van`. P6 wins because the particle is in the part after the comma, which this rule reads as any given part is read.
```
", Jane van" → family="van"
", Sir Jones vd" → family="vd"
```

**Accepted:**

- A writer with a stray comma in front of a whole name gets the listing reading rather than the name's: `, John Smith` reads given `John`, middle `Smith`, where `John Smith` reads family `Smith`. The shape's one source is an empty surname field, and dropping the comma would contradict it.
```
", John Smith" → family=""
"John Smith" → family="Smith"
```
- The surname's case is evidence the empty head does not have. A word of the ambiguous credential class written alone in capitals reads as a name, the name being written wholly in one case (S6), where a surname written in mixed case before the comma makes the same word the credential.
```
", MA" → given="MA"
"SMITH, MA" → given="MA"
"Smith, MA" → suffix="MA"
```

---
**Signal:** comma: nothing written before the first comma
**history:** decisions/C15 · **implemented:** nameparser/_pipeline/_comma.py
5 changes: 5 additions & 0 deletions docs/design/rules/H1-title-and-one-name-word.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"Sir John" family-first → given="John"
"John III, Dr." → family="John" · boundary
```
- **Nor does a comma with nothing before it**, which names no surname (C15): the title run decides the field of the one name word after the comma, the family unless the run addresses by given name.
```
", Dr. John" → family="John"
", Sir John" → given="John"
```

**Because:** a title normally addresses by surname, so a title followed by a single name word usually names the family; but a given-name title addresses by given name. Several titles written together are one form of address, and the one that does the addressing is the last — the words in front of it rank the person rather than name them. What stands beside the name word — a suffix, a nickname, a maiden name — does not make the name any longer, so it does not decide this reading.

Expand Down
2 changes: 1 addition & 1 deletion docs/design/rules/H8-salutation-ending-the-given-part.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
```
"Doe, Jane Miss" → title="Miss"
```
- With nothing before the comma, the one given word the salutation leaves reads as a title's lone name word reads (H1), the family, as the period-marked spelling has read it since H5 (2.3.0, #316); written alone, that word is the given name.
- With nothing before the comma, the one given word the salutation leaves reads as a title's lone name word reads (H1, C15's one exception), the family, as the period-marked spelling has read it since H5 (2.3.0, #316); written alone, that word is the given name.
```
", Jane Mrs" → family="Jane"
", Jane" → given="Jane"
Expand Down
2 changes: 1 addition & 1 deletion docs/design/rules/O6-two-family-names.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"López, Ana García" two_family_names → family="López"
"García López, Ana" two_family_names → family="García López"
```
- **A family comma with nothing before it still names no family**, and the name after it reads as the default reads it.
- **A family comma with nothing before it still names no family** (C15), and the name after it reads as the default reads it.
```
", Ana García López" two_family_names → family=""
```
Expand Down
1 change: 1 addition & 0 deletions docs/design/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Terms the rules use without defining them each time, each with the rule that def
- [C12 — a suffix word ending the name before the comma](C12-suffix-word-ending-the-name-before-the-comma.md)
- [C13 — a credential opening the part after the comma](C13-credential-opening-the-part-after-the-comma.md)
- [C14 — strict mode: a suffix word written as an initial](C14-strict-mode-a-suffix-word-written-as-an-initial.md)
- [C15 — a comma with nothing before it](C15-comma-with-nothing-before-it.md)

## [Name order (O)](O-name-order.md)

Expand Down
4 changes: 2 additions & 2 deletions nameparser/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Loaded when a session reads or edits anything under `nameparser/`: the pipeline,

### Four comma shapes

No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses — #429, #430 and #432 are all the one-word path disagreeing with the full-name path on inputs the full-name path reads correctly, and #613's PR review found `, PhD` and `, Jr.` (an empty surname field, as CSV data writes it) read as given and title by a decision that returned early on an empty head, after two review rounds whose every grid had a name before the comma. Put an empty head and a nickname-only head (`(Bob), PhD`) in every comma grid.
No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses — #429, #430 and #432 are all the one-word path disagreeing with the full-name path on inputs the full-name path reads correctly, and #613's PR review found `, PhD` and `, Jr.` (an empty surname field, as CSV data writes it) read as given and title by a decision that returned early on an empty head, after two review rounds whose every grid had a name before the comma. Put a nickname-only head (`(Bob), PhD`) in every comma grid. The empty head no longer needs a row of its own in a grid that compares fields: rules/C15 reads it as a family comma naming an empty family, and `test_an_empty_head_reads_as_its_family_comma_twin` (tests/v2/test_properties.py) holds `, X` to `Smith, X` less the surname, fields and ambiguity kinds, over a grid of given parts, H1's title-and-one-name-word reading the one exception (#892). A change that breaks it gets a generic fix or an entry in decisions/C15, never code for the empty head alone; widen the test's grid with the change's own shapes instead.

### Counting the one-word path

Expand All @@ -26,7 +26,7 @@ Read a generated grid's moves by SHAPE, not by corpus membership: #614's first d

### A report's field

The second was in what the grids compared: they compared ambiguity KINDS, so a report telling the caller the wrong field (`Freiherr von Berg Ma`, 'read as a given name' of a word in the family) passed every pin (#614). So compare each report's `detail` against the field its tokens land in -- since #626 `test_a_report_names_the_field_its_word_lands_in` (tests/v2/test_cases.py) does it over every non-locale case row under all three orders (a row carrying its own policy only as declared), for the phrasings its `_FIELD_CLAIMS` lists -- so a row with the shape is the test only where the report's wording is listed: a new emitter's wording goes in that list, and segment's report on a part past the second comma names no field (#629). #626's own first grid then missed an emitter for want of one comma shape (`, Ma Dr.`: an empty head, an ambiguous word, a trailing title, which H1 moves to the family): a trailing title belongs in every empty-head comma grid too.
The second was in what the grids compared: they compared ambiguity KINDS, so a report telling the caller the wrong field (`Freiherr von Berg Ma`, 'read as a given name' of a word in the family) passed every pin (#614). So compare each report's `detail` against the field its tokens land in -- since #626 `test_a_report_names_the_field_its_word_lands_in` (tests/v2/test_cases.py) does it over every non-locale case row under all three orders (a row carrying its own policy only as declared), for the phrasings its `_FIELD_CLAIMS` lists -- so a row with the shape is the test only where the report's wording is listed: a new emitter's wording goes in that list, and segment's report on a part past the second comma names no field (#629). #626's own first grid then missed an emitter for want of one comma shape (`, Ma Dr.`: an empty head, an ambiguous word, a trailing title, which H1 moves to the family): a trailing title belongs in every empty-head comma grid too. C15's invariant holds that shape's fields and kinds but never a report's detail, so a grid checking details keeps the empty head (`, Sir Jones vd` reports 'vd' joining "the family the comma named", a comma C15 says names none).

### Exclusion lists and shared predicates

Expand Down
9 changes: 4 additions & 5 deletions nameparser/_pipeline/_comma.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,10 @@ def decide(state: ParseState, splits: bool | None = True) -> ParseState:
return decide(state, False)
seg0, seg1 = state.segments[0], state.segments[1]
# Nothing after the comma: nothing to read. An EMPTY part BEFORE it
# is read on, being no whole name (`_whole_name` answers no), so
# ', PhD' binds its credential under the family comma as 2.3.0 read
# it; returning here on an empty head left the part to the listing
# walk, which made 'PhD' the given name and ', Jr.' a title (#613's
# PR review; empty surname fields in CSV data write exactly this)
# is read on, being no whole name (`_whole_name` answers no):
# rules/C15: "the part after it reads as the given part after a
# family comma reads" -- so ', PhD' binds its credential as
# 'Smith, PhD' does; decisions/C15 (#892), decisions/C1 (#613)
if not seg1:
return state
tokens = list(state.tokens)
Expand Down
7 changes: 4 additions & 3 deletions tests/v2/rules_budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@
"C12": {"words": 316, "then_words": 107, "because_sentences": 2, "except": 2, "precedence": 1, "accepted": 2, "accepted_words": 59},
"C13": {"words": 259, "then_words": 74, "because_sentences": 1, "except": 1, "precedence": 1, "accepted": 0, "accepted_words": 0},
"C14": {"words": 231, "then_words": 111, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 0, "accepted_words": 0},
"C15": {"words": 253, "then_words": 31, "because_sentences": 3, "except": 1, "precedence": 1, "accepted": 2, "accepted_words": 101},
"D1": {"words": 73, "then_words": 26, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0},
"D2": {"words": 65, "then_words": 37, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0},
"H1": {"words": 427, "then_words": 270, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 4, "accepted_words": 370},
"H1": {"words": 463, "then_words": 306, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 4, "accepted_words": 370},
"H2": {"words": 508, "then_words": 172, "because_sentences": 1, "except": 1, "precedence": 3, "accepted": 3, "accepted_words": 203},
"H3": {"words": 142, "then_words": 72, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 3, "accepted_words": 113},
"H4": {"words": 410, "then_words": 183, "because_sentences": 3, "except": 1, "precedence": 1, "accepted": 1, "accepted_words": 127},
"H5": {"words": 757, "then_words": 289, "because_sentences": 2, "except": 0, "precedence": 5, "accepted": 1, "accepted_words": 117},
"H6": {"words": 316, "then_words": 79, "because_sentences": 3, "except": 4, "precedence": 0, "accepted": 2, "accepted_words": 100},
"H7": {"words": 339, "then_words": 270, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 54},
"H8": {"words": 431, "then_words": 68, "because_sentences": 3, "except": 3, "precedence": 3, "accepted": 3, "accepted_words": 128},
"H8": {"words": 431, "then_words": 68, "because_sentences": 3, "except": 3, "precedence": 3, "accepted": 3, "accepted_words": 131},
"H9": {"words": 704, "then_words": 263, "because_sentences": 3, "except": 0, "precedence": 5, "accepted": 9, "accepted_words": 354},
"M1": {"words": 231, "then_words": 120, "because_sentences": 2, "except": 1, "precedence": 2, "accepted": 0, "accepted_words": 0},
"M2": {"words": 714, "then_words": 435, "because_sentences": 1, "except": 4, "precedence": 1, "accepted": 6, "accepted_words": 209},
Expand All @@ -38,7 +39,7 @@
"O3": {"words": 47, "then_words": 18, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0},
"O4": {"words": 91, "then_words": 57, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0},
"O5": {"words": 478, "then_words": 160, "because_sentences": 2, "except": 6, "precedence": 1, "accepted": 0, "accepted_words": 0},
"O6": {"words": 572, "then_words": 138, "because_sentences": 2, "except": 5, "precedence": 4, "accepted": 7, "accepted_words": 244},
"O6": {"words": 573, "then_words": 138, "because_sentences": 2, "except": 5, "precedence": 4, "accepted": 7, "accepted_words": 244},
"P1": {"words": 415, "then_words": 287, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 4, "accepted_words": 319},
"P2": {"words": 315, "then_words": 279, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 3, "accepted_words": 167},
"P3": {"words": 639, "then_words": 598, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0},
Expand Down
Loading
Loading