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
11 changes: 10 additions & 1 deletion docs/design/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,15 +520,24 @@ Declined:

### R2 — the all-particles family_base divergence

- 2026-08-18 #404/#385 — R2's DISCRIMINATOR REVERSED. It said a family written wholly out of particle vocabulary has a base where one of those words "is itself borne as an ordinary surname", and that only the words that are never anyone's name stay particles. That is a vocabulary test, and it is the wrong one: a particle earns its name by joining forward to the word it modifies, so a particle with nothing to join is not doing a particle's work whatever it is borne as. POSITION decides, and the borne-as-surname question does not arise.
The invariant that replaces it, and the reason this is worth a rule rather than three bug fixes: a non-empty family always has a non-empty base. A particle needs a base to attach to. Measured, 43 of 751 corpus names x 3 orders violated it — "Del Toro" under FAMILY_FIRST reported family 'Del' with no base, losing the surname from the base and from the initials.
Marked rather than untagged (mechanisms.md#MARK-DONT-STRIP): `particle` is stable API and means "from the particle vocabulary wherever it lands", which stays true; UNJOINED_TAG records what was decided. That also leaves #405's ambiguity emitter a predicate to key on, which stripping would have destroyed for exactly the never-given half where the fork is most interesting.
Blast radius: 44 of 2,253 parses move and every one is VIEWS-only — the seven role fields are byte-identical everywhere, so the differential harness cannot see this change at all (it compares roles only). Tests are the whole verification, and the invariant is one of them.

- Recorded 2026-08-16, intent UNVERIFIED: the v1-era design held that a family name cannot be only particles ("Anh Do" — Do is a surname AND a particle), so last_base was guarded non-empty. The facade still guards (HumanName("Anh Do").last_base == "Do"); the v2 core does not (parse("Anh Do").family_base == "", family_particles == "Do"), and the surname vanishes from initials (parse("Anh Do").initials() == "A.").
- 2026-08-16 #385 RESOLVED by the collision criterion, not on its own terms: the issue's option 3 ("guard the view only when a word is vocabulary-ambiguous") is what decisions.md#vocabulary-collisions produces when applied here.
"Do" is borne as an ordinary surname, so it is ambiguous
vocabulary and anchors the base; "van der" is never anyone's name, so an all-particle family there genuinely has no base. The two rows of the issue's table were never one case. This is the keystone's clearest payoff: #385 was filed as a leaf with three options and no way to choose between them, and the criterion picks one without arguing about family_base at all.
- Still open inside the resolution: whether "Do" remains in family_particles once it is also the base. Recorded here rather than left to the implementing PR to decide by accident.
- ANSWERED 2026-08-18 by the entry above, and recorded rather than left implicit: "Do" does NOT remain in family_particles once it is the base. The two views partition the family — parse("Anh Do").family_particles is "" and family_base is "Do" — which is asserted as a partition over the whole case table, not just as an example. Was: "Still open inside the resolution: whether Do remains in family_particles once it is also the base. Recorded here rather than left to the implementing PR to decide by accident."
- 2026-08-16 (pre-merge coherence pass) — the resolution moves R3 too, and R3 now carries its own marker. Initials read the BASE family word, so anchoring "Do" changes parse("Anh Do").initials() from "A." to "A. D." while
"Juan van der" stays "J." (no borne name, no base, and initials
of a bare particle run would be nonsense). The general lesson, worth more than this instance: a deviates: marker gets written on the rule whose STATEMENT changed, but a rule can change another rule's OUTPUT without touching its statement, and nothing looks for that — the runner asserts per example line, so an unmarked downstream rule stays green precisely because its own examples avoid the affected input. When adding a marker, walk the changed rule's `interacts:` targets and ask whether any of THEIR examples move.

Declined:

- 2026-08-18 — the GROUPING half of #404: a particle run that joins nothing does not chain, so "Jong van der" would split into middle 'van' plus family 'der'. Measured and rejected, though NOT for the reason first recorded here. The first draft said the split makes the family "no longer all-particle so the base fix stops firing" — false, and `der` and `la` are both shipped particles, so a family of either IS all-particle and the rule fires on it ("Juan Smith der" gives base 'der'). What the split actually costs is the SCOPE of the base and a stray particle relocated: grouping can decline to merge but cannot keep the words apart, because roles re-assemble them and two adjacent same-role pieces are one part at the field level. "Juan Smith van der" becomes middle 'Smith van', family 'der' — a base of 'der' rather than 'van der', and a middle name nobody wrote. Keeping the run whole in one part is what gives the base its full extent. The split reading needs the leftover distribution to know these are separate units, which is mechanisms.md#UNIT-PARTITION's problem.

### removed-v1-surface

- empty_attribute_default: removed in 2.0 (#255; deprecated in 1.4 per the bridge discipline). Origin #44 (2016): a DB-NULL convenience whose first answer — `name.title or None` — became the migration path. The in-band-signaling bug that sealed it (#254): the 2016 `.replace('None','')` scrub could not tell interpolated None from name text, so "Nonez Smith" rendered
Expand Down
4 changes: 4 additions & 0 deletions docs/design/mechanisms.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Problem shape. A rule needs to know how words were JOINED (chained titles, parti

Problem shape. A rule counts "one name word", but the input holds words that another rule has already joined into one name — and the joining structure it would read has been merged away. Contract statement. Three rules build multi-word units: a particle chain (P2), a conjunction join (P3), and a bound given-name pair (P5). A rule that counts name words counts those units, and takes each whole or not at all. How it works. group builds each join as a piece, but its own prefix chain then merges the joined piece into a longer one, so PIECES no longer carries the boundary — the units are rebuilt from the tags the vocabulary layer left (`particle`, `conjunction`, `vocab:bound-given`). The rebuild is RECURSIVE: what a conjunction or a bound word joins is the next UNIT, not the next word, and absorbing a single token instead strands a particle severed from the words it chains. Note the two joins arrive here for opposite reasons — the conjunction join was built and then swallowed, while the bound-given join was never built at all (P5 joins only at the first non-title piece), so restoring piece boundaries in group would fix the first and silently split the second. Lives in. nameparser/_pipeline/_post_rules.py (`_unit_end`, `_units`); rules.md P1 is the counting rule, P2/P3/P5 the joining ones. Reach for it when. A rule says "one name word" and the input can contain a join — enumerate the joining rules out of rules.md rather than the ones you remember.

## MARK-DONT-STRIP — record the decision, keep the fact

Problem shape. A stage concludes that a vocabulary fact is not operative for one occurrence — a particle that joins nothing, a middle that will render as family. Contract statement. It MARKS the token with a namespaced marker rather than removing the vocabulary tag: the tag says what the word IS, the marker says what was decided about it, and consumers ask for the pair. The cost, which has to be paid in the docs rather than avoided: where a stable tag was documented with a RECIPE — `particle` combined with Role.FAMILY "for actual family particles" — the marker makes the recipe diverge from the view it reproduced, so the recipe's documentation has to say so. How it works. Stripping is tempting because every downstream consumer becomes correct for free, with no predicate to change; it pays for that by destroying the record, and asymmetrically. `particle` is the only tag 31 of the 33 never-given particles carry, so stripping it leaves nothing on the token, while every ambiguous one still carries `vocab:particle-ambiguous` (the two exceptions, `mc` and `vd`, carry `vocab:suffix` — the same dual membership P6's S2 precedence turns on) — the half where a later fork is most interesting is the half that loses its evidence, and ParsedName has no lexicon to re-derive from. Marking also leaves a POSITIVE predicate: "this token used to have a tag" is not testable, `UNJOINED_TAG in tags` is, which is what an ambiguity emitter needs (#405). And `particle` is documented stable API meaning "a word from the particle vocabulary wherever it lands", so removing it would be a breaking change as well as a lossy one. Lives in. nameparser/_types.py — FOLDED_TAG (O3's fold, P6's attachment) and UNJOINED_TAG (R2's standalone particle), both namespaced and internal, qualifying stable tags that stay put. Reach for it when. A rule decides a word is not doing the job its vocabulary implies.

## STRUCTURE-GATES — comma shape as an explicit state

Problem shape. A rule should fire only under one comma convention. Contract statement. segment decides the comma structure once (NO_COMMA, FAMILY_COMMA, SUFFIX_COMMA) and every later stage gates on that single decision rather than re-inspecting commas. Lives in. nameparser/_pipeline/_state.py (Structure), _pipeline/_segment.py (the one decider). Reach for it when. New code is about to count commas.
Expand Down
54 changes: 37 additions & 17 deletions docs/design/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,32 +703,52 @@ R1. Rationale: a field is a way of reading the parse, not a stored
R2. Rationale: callers need the surname with and without its
particles — sorting wants "Vega", display wants "de la Vega".
The family name splits into further views: the base (the family
without its leading particles) and the particles themselves.
without its leading particles) and the particles themselves. A
name part whose every word is particle vocabulary is a part where
none of them is doing a particle's work — nothing joins them to a
name — so they read as ordinary name words: they anchor the base
and leave the particles view. "Every word", not "standing alone":
a two-particle run has neither word alone and both are name words
there. Position decides that, not vocabulary; whether the word is
borne as a surname somewhere does not enter into it.
"Dr. Juan Q. Xavier de la Vega III" → family_base="Vega"
"Dr. Juan Q. Xavier de la Vega III" → family_particles="de la"
"Sean O'Connor" → family_base="O'Connor" · boundary
A family name written wholly out of particle vocabulary still
has a base where one of those words is itself borne as an
ordinary surname: that word anchors the base, and only the words
that are never anyone's name stay particles.
"Anh Do" → family_base="Do" deviates: #385 (today: family_base="")
"Juan van der" → family_base=""
history: decisions.md#R2 · interacts: R3 · implemented: nameparser/_types.py
"Anh Do" → family_base="Do"
"Juan van der" → family_base="van der"
"Juan van der" → family_particles=""
"Juan de la Vega" → family_base="Vega" · boundary
"Juan de la Vega" → family_particles="de la"
"Sean O'Connor" → family_base="O'Connor"
Accepted, and the invariant it exists to hold: a non-empty
family always has a non-empty base. A particle needs a base to
attach to, so a family that is all particles is a family whose
words are not acting as particles.
"Del Toro" family-first → family_base="Del"
Accepted: the test runs after every rule that moves a token
between parts, so O3's fold decides it too — a middle folded into
the family can leave the family all particles, or can give a
trailing particle the name word it was missing.
"Anh Van Do" middle_as_family → family_base="Van Do"
"Nguyen, Van Le" middle_as_family → family_particles="Le"
history: decisions.md#R2 · interacts: R3 · implemented: nameparser/_types.py, nameparser/_pipeline/_post_rules.py, nameparser/_facade.py

R3. Rationale: initials abbreviate the person's name words; titles,
suffixes, particles and nicknames are not name words.
Initials take the first letter of each given, middle, and base
family word; titles, suffixes, particles and nicknames
contribute nothing.
contribute nothing — except the particles of a part whose every
word is one, which are not acting as particles there (R2) and
initial like any other name word. A CONJUNCTION never initials,
so a base that is one contributes nothing even then.
"Dr. Juan Q. Xavier de la Vega III" → initials="J. Q. X. V."
"Anh Do" → initials="A. D." deviates: #385 (today: initials="A.")
"Anh Do" → initials="A. D."
"Nguyen, Van Le" → initials="V. L. N."
"Sean O'Connor" → initials="S. O." · boundary
Accepted: a family that is ALL particles contributes nothing,
so the initials are the given words alone — "van der" has no
borne name to anchor a base (R2), and initials of a bare
particle run would be nonsense.
"Juan van der" → initials="J."
history: decisions.md#R2 · interacts: R2 · implemented: nameparser/_render.py
A family that is ALL particles therefore contributes its words
rather than nothing: they are the base (R2), so they initial.
"Juan van der" → initials="J. v. d."
"Juan de y" → initials="J."
history: decisions.md#R2 · interacts: R2 · implemented: nameparser/_render.py, nameparser/_facade.py

R4. Rationale: case repair is a display concern, applied only on
request and never destructively.
Expand Down
Loading