Skip to content

A particle standing alone in a name part is not acting as a particle - #406

Merged
derek73 merged 1 commit into
masterfrom
feat/404-unjoined-particles
Aug 19, 2026
Merged

A particle standing alone in a name part is not acting as a particle#406
derek73 merged 1 commit into
masterfrom
feat/404-unjoined-particles

Conversation

@derek73

@derek73 derek73 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

A family name made only of particle words reported no base, so the surname vanished from family_base and from the initials:

                today                              here
Anh Do          base=''         initials 'A.'      base='Do'        initials 'A. D.'
Del Toro  (FF)  base=''         initials 'T.'      base='Del'       initials 'T. D.'
Juan van der    base=''         initials 'J.'      base='van der'   initials 'J. v. d.'
Nguyen, Van Le  middle='Le'     initials 'V. N.'                    initials 'V. L. N.'

43 of 751 corpus names × 3 orders had a non-empty family with an empty base. That is now 0, and asserted.

The rule

A particle earns its name by joining forward to the word it modifies. With nothing to join it is not doing a particle's work, so it reads as an ordinary name word — it anchors the base, leaves the particles view, and contributes an initial.

Position decides this, not vocabulary. That reverses rules.md#R2's stated discriminator, which was "a family written wholly out of particle vocabulary still has a base where one of those words is itself borne as an ordinary surname". That test would have required adjudicating all 70 particles for whether each is borne as a surname specifically — van is ambiguous because Vietnamese Văn is a given name, Do because Đỗ is a surname, and nothing in the vocabulary separates those two reasons today. The position test needs no per-word judgement at all.

Where the particles do join a name word nothing changes: "Juan de la Vega" keeps base Vega, particles de la, initials J. V.

The invariant

A non-empty family always has a non-empty base. A particle needs a base to attach to.

Asserted over the whole case table in test_a_non_empty_family_always_has_a_base. Mutation-checked: deleting the marking pass fails it on two rows. It was inert when first written — no case row had an all-particle family — so three rows were added, which is what makes it bite.

Marked, not stripped

particle stays on the token; a namespaced UNJOINED_TAG records the decision, following FOLDED_TAG's precedent. Stripping was the tempting version — every consumer becomes correct for free — but it destroys the record asymmetrically: particle is the only tag a never-given particle carries, while an ambiguous one keeps vocab:particle-ambiguous. The half where a later fork is most interesting is the half that would lose its evidence, and ParsedName has no lexicon to re-derive from. It would also be a breaking API change, since particle is documented stable and means "from the particle vocabulary wherever it lands" — which stays true.

This leaves #405's ambiguity emitter a positive predicate to key on.

Declined: the grouping half

#404 also proposed that a particle run joining nothing should not chain, splitting "Jong van der" into middle van plus family der. Measured and rejected — it changes no view on its own, and it cancels the rule above where it matters:

Juan Smith van der   today:  middle='Smith'      family='van der'   -> base fixed
                     with A: middle='Smith van'  family='der'       -> base fix stops firing
Juan Smith de la     with A: middle='Smith de'   family='la'

Grouping can decline to merge but cannot keep the words apart — roles re-assemble them, and two adjacent same-role pieces are one part at the field level. Recorded in decisions.md#R2 under Declined so nobody re-proposes it from first principles.

Verification

Views-only: title, given, middle, family, suffix are byte-identical over all 751 corpus names in all three orders. The differential harness compares only those seven roles, so it is blind to this change and reports 0 unexplained at every baseline — that is not evidence here, and the tests are.

The v1 facade needed its own twin, since it re-derives particle-ness from the lexicon rather than reading tags. _split_last already carried the guard, which is why only the initials disagreed; both surfaces now agree on every corpus name except 12 pre-existing conjunction cases (Amy E Maid, where v1 filters E as a conjunction and v2 does not) — untouched here.

3803 tests pass, mypy clean.

Closes #385
Closes #402

🤖 Generated with Claude Code

@derek73 derek73 self-assigned this Aug 19, 2026
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.55%. Comparing base (12f0216) to head (24bba2c).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #406      +/-   ##
==========================================
+ Coverage   98.54%   98.55%   +0.01%     
==========================================
  Files          44       44              
  Lines        2954     2977      +23     
==========================================
+ Hits         2911     2934      +23     
  Misses         43       43              

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

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

@derek73 derek73 added this to the v2.2 milestone Aug 19, 2026
@derek73
derek73 force-pushed the feat/404-unjoined-particles branch 2 times, most recently from f2f1ee4 to c7c831f Compare August 19, 2026 07:44
"Anh Do" reported last 'Do' with family_base '' and initials 'A.' --
the surname vanished from the base and from the initials. Under
FAMILY_FIRST "Del Toro" did the same with 'Del'. 43 of 751 corpus
names x 3 orders had a non-empty family with an empty base.

A particle earns its name by joining forward to the word it modifies.
With nothing to join it is not doing a particle's work, so it reads as
an ordinary name word: it anchors the base, leaves the particles view,
and initials. POSITION decides that, not vocabulary -- which reverses
R2's stated discriminator, "borne as an ordinary surname somewhere".
That test would have needed a per-word adjudication of all 70
particles; this one needs none.

The invariant it exists to hold, and now asserted over the whole case
table: a non-empty family always has a non-empty base. A particle
needs a base to attach to.

MARKED, not untagged (mechanisms.md#MARK-DONT-STRIP): `particle` is
stable API meaning "from the particle vocabulary wherever it lands",
which stays true. UNJOINED_TAG records the decision, following
FOLDED_TAG's precedent, and leaves #405's ambiguity emitter something
to key on -- stripping would have destroyed the record for exactly the
never-given half, whose only tag is `particle`.

The v1 facade needed its own twin, since it re-derives particle-ness
from the lexicon rather than reading tags; _split_last already had the
guard, which is why only initials disagreed.

Views-only: the seven role fields are byte-identical over all 751
corpus names in all three orders, so the differential is blind to this
and reports 0 unexplained at every baseline. Tests are the whole
verification.

Closes #385
Closes #402

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 force-pushed the feat/404-unjoined-particles branch from c7c831f to 24bba2c Compare August 19, 2026 07:51
@derek73
derek73 merged commit c43d0a3 into master Aug 19, 2026
11 checks passed
@derek73
derek73 deleted the feat/404-unjoined-particles branch August 19, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment