Skip to content

nameparser 2.0 implementation#288

Draft
derek73 wants to merge 273 commits into
masterfrom
v2/core-foundation
Draft

nameparser 2.0 implementation#288
derek73 wants to merge 273 commits into
masterfrom
v2/core-foundation

Conversation

@derek73

@derek73 derek73 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Implementation branch for the 2.0 design — see the RFC in #285 and the umbrella issue #284 for the design discussion and feedback questions. Implementation learnings that change the design will land as amendment commits on #285, per the plan described there.

⚠️ Merges only at 2.0.0. This branch raises the Python floor to >=3.11 (#257) and drops the typing_extensions conditional dependency (nameparser now has zero runtime dependencies). Master must remain able to cut 1.4.x patch releases for Python 3.10 users, so this PR stays a draft until the 2.0.0 release.

Progress

  • Core data & config modelSpan, Role, Token, Ambiguity/AmbiguityKind, ParsedName; Lexicon, Policy/PolicyPatch/apply_patch, Locale. Frozen, slotted, hashable, picklable dataclasses with eager fail-loud validation; ~110 dedicated tests under tests/v2/.
  • Rendering (render() / initials() / capitalized() / __str__)
  • Parse pipeline + Parser / parse() / parser_for() / matches() + shared case table
  • v1 HumanName facade + CONSTANTS shim (existing test corpus as regression harness) — full v1 suite (1,240+ tests) reconciled and passing against the facade; differential harness (tools/differential/) verifies 1.4-on-PyPI vs the facade over 486 corpus names with one classified diff
  • Locale packs (ru, tr_az) — shipped with the non-interference gate, Provide constants in non-Latin scripts (Cyrillic, Greek, Arabic, Hebrew) #269 default vocabulary, and CLI --locale
  • Documentation rewrite — new-API-first docs (getting started / concepts / customize / locales / migrate + regrouped API reference) and a README that doubles as the PyPI page; every doc code block runs as a Sphinx doctest in CI

Notes on what's here so far

🤖 Generated with Claude Code

derek73 and others added 30 commits July 12, 2026 12:46
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds tests/v2/test_layering.py to mechanically enforce the conventions
doc's import layering and the public v2 export surface. Appends the v2
core types (Span, Role, Token, Ambiguity, AmbiguityKind, ParsedName,
Lexicon, Policy, PolicyPatch, PatronymicRule, UNSET, GIVEN_FIRST,
FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, Locale) to nameparser/__init__.py
alongside the existing v1 HumanName export.

Turns on component-flag mypy strictness for the four v2 modules and
check_untyped_defs for tests/v2, then fixes the resulting test-only
type mismatches by using the precise constructed types (Span(...),
frozenset({...}), tuple-of-pairs) where the literal type didn't matter
to the test, and adding narrow # type: ignore[arg-type] comments only
where a test deliberately exercises runtime coercion/validation of an
intentionally mismatched static type (e.g. Token span/Ambiguity kind
coercion tests, PatronymicRule string coercion, dict aliasing test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pickle.dumps(Lexicon.default()) raised TypeError because the default
slots-dataclass pickle path serializes the _cap_map MappingProxyType.
Ship every other slot and rebuild the proxy from the canonical
capitalization_exceptions tuple on load. Parser (Plan 3) is picklable
by construction per the core spec, and a Parser holds a Lexicon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
requires-python is >=3.11 on this branch, so the 3.10 job can no
longer install the package (uv sync either fails or silently
substitutes a managed 3.11). The rest of #257 (typing_extensions,
classifiers) stays tracked on the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With requires-python at >=3.11, Self imports directly from typing and
the conditional dependency can never activate; ruff (UP035/UP036)
flags the dead version block once the floor is raised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v1 suite is fully annotated (since #250 put tests/ under mypy), so
pyproject carries no per-file ANN ignores; the new tests/v2 modules
must be annotated too or 'ruff check' fails in CI. Also drops an
unused import ruff flagged (F401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare startswith('nameparser._types') would also admit a future
sibling like nameparser._types_helpers. Entries ending in '.' stay
pure prefixes (subpackage contents); anything else now means that
exact module or its submodules. Also carries this file's ANN
annotations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing tied the 'particle' string hard-coded in _text_for callers to
the published STABLE_TAGS constant; assert the linkage in the
derived-view test until Plan 3's tag-emission contract tests land.
Also carries this file's ANN annotations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lexicon(titles={'Dr.': 'Doctor'}) silently kept only the keys -- the
lone quiet coercion on an otherwise fail-loud surface, and a dict here
almost always means the field was confused with
capitalization_exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
repr(Lexicon.empty().add(titles={'zqx'})) rendered as default() plus
ten '-N' deltas -- technically bounded, but the wrong story for the
documented build-from-empty() power-user path. Compare against both
named constructors and render the smaller diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pure move: constructors ahead of dunders, __or__ grouped with the
dunders, then properties, then the editing methods (with _edit at the
head of its section per the documented exception). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrong type (including wrong element type in a collection, bare str for
an iterable-of-strings field, Mapping for a plain iterable) raises
TypeError; well-typed but unacceptable values (empty required strings,
inverted spans, unknown enum values, subset violations) raise
ValueError. Matches the boundary the generated dataclass __init__
already draws -- unknown kwargs are TypeError natively -- so the
constructors can never present an all-ValueError contract anyway.

Mixed checks (Token.text, Ambiguity.detail, Locale.code, delimiter
pairs, extra_suffix_delimiters entries) split into a type check and a
value check. Enum lookups stay ValueError for any element, matching
stdlib EnumType.__call__; non-iterable patronymic_rules now surfaces
its natural TypeError instead of being converted. Rule recorded in the
conventions doc §6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maiden_markers was the only Lexicon.default() field fed by an inline
literal instead of a nameparser/config data module. Add
config/maiden_markers.py with the #274 candidate set: French, German,
Dutch, Czech/Slovak, and Russian markers, both genders and both e/yo
spellings (casefold does not fold them). Non-colliding Cyrillic entries
belong in the default lexicon per the locales design's sorting rule.
Deliberately absent: 'z domu' (two-token marker, pending the pipeline's
multi-token matching decision) and Scandinavian 'f.' (collides with the
initial F). The 1.x parser does not read the module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The issue's veto covered only the abbreviation 'f.' (collides with the
initial F); the full participles født (da/nb), fødd (nn), and född (sv)
cannot appear as name tokens and are the standard convention in running
text. No ASCII variants -- dropping the diacritic is not standard
practice in Scandinavian text, unlike nee in English.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The enforceable subset of the (untracked) conventions doc -- module
layout, layering, canonical field order, method organization, exception
taxonomy, bounded reprs, typing/doctest posture, pickling, the
one-global rule, and tests/v2 conventions -- now has a tracked home the
v1 sections don't cover, including this week's two amendments
(section-head helpers, TypeError/ValueError split). Establishes the
same-commit rule: convention changes update this section in the commit
that makes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tags was the one collection input on the v2 surface without the
bare-string/mapping/element-type guards: tags='particle' silently
became its character set and every STABLE_TAGS-based derived view
misclassified with no error. role was the one enum field without
coercion; a raw 'given' string broke 'role is Role.GIVEN' identity
checks silently. role now mirrors Ambiguity.kind: coerce the string
form, enriched ValueError naming valid roles on any failed lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four bool flags were the only unvalidated Policy fields: truthy
strings like 'no' stored fine and behaved as True, silently inverting
the caller's intent. And the patronymic-rule try wrapped the whole
iteration, so a ValueError raised inside a caller's generator was
rewritten as 'unknown patronymic rule' with the real traceback erased
by 'from None'. Materialize first (the _normset pattern), convert
per-element, and name the offending entry in the error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Union fields were already canonicalized at patch construction, but a
list name_order stored as-is -- making the patch and any Locale holding
it unhashable, with the failure surfacing only when a locale became a
dict key. Policy re-coerces at apply time, so the patch was the one
container in the chain that could silently carry the list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare string or a mis-shaped entry leaked raw unpack errors ('not
enough values to unpack') naming neither the field nor the fix -- and a
2-char string entry like 'ab' unpacked silently into {'a': 'b'}. All
three now raise the taxonomy's TypeError with the offending value and
expected form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
__setstate__ accepted any state dict: a pickle from an older or newer
Lexicon (field added, renamed) loaded fine and failed at the first
attribute read, far from the unpickle site with no hint the cause was
a stale pickle. Check the field-name set at load and name the
missing/unexpected fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'bishop' is a v1 TITLE, not a suffix word, so removing it from
suffix_words asserted nothing -- the test kept passing even if remove()
were a no-op. Remove an entry that is actually present and assert the
precondition first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- apply_patch revalidates deferred patch values (PolicyPatch documents
  lazy validation; nothing asserted the apply-time failure)
- all four set-valued PolicyPatch fields declare union composition
  (apply_patch is metadata-driven; dropping one silently flips locale
  layering from union to override)
- pickle round-trips for Token/Ambiguity/ParsedName/Policy/PolicyPatch,
  pinning that UNSET survives unpickling BY IDENTITY -- apply_patch
  gates on 'is UNSET', which only works because Enum members unpickle
  to the same object
- _normalize is casefold + all-periods stripping, stricter than v1's
  lc() (lower + edge periods only)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three 'added in a later task' notes were false at HEAD; the
_VOCAB_FIELDS comment claimed __or__ excludes capitalization_exceptions
(it merges them right-biased); the _lexicon module docstring's data-
module list omitted maiden_markers (now points at _default_lexicon's
imports, which cannot rot); _normalize claimed equivalence with v1's
lc() (lc lowers and trims only edge periods); the maiden_markers
docstring omitted the masculine Russian forms it ships; replace() did
not document that it drops ambiguities referencing replaced tokens; and
AGENTS.md overstated 'mypy strict' (per-module strict is not valid
mypy config) and the one-test-module-per-source rule. Also notes the
layering table's config prefix enforces package granularity only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'.', '', or whitespace is a data bug (stray split artifact, empty CSV
cell) on the primary customization surface; silently dropping it also
meant a future data-module typo would vanish instead of failing CI.
One rule for vocab fields AND capitalization-exception keys -- the
previous behavior dropped both silently (keys deliberately, vocab
undocumented). Raising now and relaxing later is compatible; the
reverse is a break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Span(0,2) + Span(3,4) inherited tuple concatenation, producing a
4-tuple -- the natural but wrong spelling of 'covering span' that the
pipeline's join stage will tempt. A real cover() operation ships with
that consumer; blocking + now is compatible, blocking it after 2.0.0
would be a break. Also reject bools in span coordinates (bool is an
int subclass; (False, True) is a leaked comparison result, not a span).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codes become registry keys the moment parser_for and third-party packs
exist; every character accepted today is supported forever. The pin
matches the shipped ru/tr_az convention and deliberately allows one
separator only -- accepting '-' as well would make tr-az and tr_az
distinct keys. Relaxing later is compatible; tightening later breaks
published packs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Token.role and Ambiguity.kind carried byte-parallel coerce-or-raise
blocks that had to be kept in sync by hand; both message shapes are
pinned by tests. One parametrized module-private helper, two one-line
call sites, identical messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module's convention is that collection normalization lives in a
module-level _norm* helper the constructor calls; capitalization
exceptions were the one field whose ~40-line validation body sat
inlined in __post_init__, mixing orchestration with one field's rules.
Pure move -- messages and behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The [a-z0-9_]+ fullmatch landed after the whitespace check and fully
absorbs it: all-whitespace input already fails the strip() check, and
interior whitespace fails the charset with an accurate message. The
empty and lowercase pre-checks stay -- they guard the common mistakes
with materially friendlier messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
derek73 and others added 30 commits July 19, 2026 15:09
d8feb3e added given_name_titles <= titles to _SUBSET_FIELDS. That is the
wrong shape for the field: _post_rules space-joins a multi-token title
run and looks the JOINED string up, so "grand duke" is a legitimate
entry whose WORDS are titles while the phrase itself is not. The
invariant made multi-word honorifics inexpressible in the 2.0 API, and
the matching shim intersection silently dropped them, reclassifying the
given name:

    Constants: titles += {grand, duke}, first_name_titles += {grand duke}
    "Grand Duke John"   1.4.0: first='John'    d8feb3e: last='John'

Check the words instead. The original footgun -- add(given_name_titles=
{"sheikh"}) doing nothing -- still raises, because 'sheikh'.split() is
not in titles either. The shim now drops only entries with a non-title
word, which are the ones v1 could not reach.

Two more fixes to the same validation block:

- _SUBSET_FIELDS carried one rationale ("an orphan is never consulted")
  for all its pairs, and it was false for both survivors: an orphan
  particles_ambiguous entry makes _assign emit a spurious ambiguity,
  and an orphan suffix_acronyms_ambiguous entry makes _vocab treat the
  word as a period-gated suffix. Each pair now carries its own reason,
  and the reason appears in the error.

- The remedy was "Add them to {base} as well", which inverts the intent
  of a caller who was REMOVING from the base -- telling them to add back
  what they just removed. Now names both directions.

And adds the invariant that guard should have been: an ambiguous suffix
acronym must not also be a suffix word. suffix_as_written ORs the two
branches, so the word membership bypasses the period gate the ambiguous
set exists to impose -- add(suffix_words={"ma"}) was accepted and made
"Jack Ma" lose its family name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
__setstate__ checked the field LAYOUT and then hand-assigned every
value, so a state dict could load a Lexicon that no constructor would
produce: invariants unchecked, entries unnormalized, a plain set on a
frozenset field.

The layout guard exists to catch version skew, but the likeliest skew
is same-name/flipped-meaning rather than a changed field list --
particles_ambiguous inverted sense between v1's never-given set and
v2's may-be-given set without changing its name. A 2.0-alpha pickle
carrying the old sense passed the name check and loaded silently
inverted.

Call __post_init__ after restoring state. That re-runs normalization
and all four invariant checks, and rebuilds _cap_map for free -- the
hand-rolled rebuild it replaces was already duplicating that line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
020e5ad fixed .get() being swallowed by _RegexesProxy.__getattr__ but
stopped at the one method that had a deprecation promise attached.
items(), values() and len() had the identical failure -- v1's regexes
was a dict subclass, this proxy is not, and the catch-all __getattr__
claims every unimplemented mapping name as a regex lookup:

    CONSTANTS.regexes.items()  ->  AttributeError: no regex named 'items'

That is the exact confusing message the get() fix was written to
eliminate, and items()/values() are the next most likely v1 idioms
after get(). Spell out the read half of the mapping surface, and say in
the comment why a proxy has to.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182249a's comment claimed the particles_ambiguous union "reproduces"
v1 for a config that puts a word in both bound_first_names and
non_first_name_prefixes. It reproduces two of three shapes. v1's join
has a reserve_last guard, so with only two pieces it does not fire:

    "dos Santos"   1.4.0: last='dos Santos'   here: given='dos'

v1's rule is piece-count dependent; a static vocabulary set cannot
express that, so the deviation is irreducible without reintroducing the
raise on a config v1 accepted. Narrow the claim to what the code does
and pin the divergent case as a deviation, so it is recorded rather
than discovered later as a bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each verified against a live 1.4.0.

- release_log: assigning empty_attribute_default raises AttributeError,
  not TypeError.
- release_log + migrate: only the mapping manager's AttributeError
  lists the known keys; the regexes proxy says "no regex named 'typo'".
  The two pages had also disagreed with each other.
- migrate: v1's CONSTANTS.regexes.typo returned EMPTY_REGEX, not None.
  None was the capitalization_exceptions behavior.
- README + migrate: "four removals ... which all raise on contact" was
  false for one of the four. A subclass overriding a parsing hook gets
  a DeprecationWarning and parsing proceeds -- and DeprecationWarning
  is suppressed by default outside __main__, so it is the opposite of
  raising on contact. This matters: it is the one pre-upgrade check
  item a reader could reasonably skip.
- release_log: the "Smith, Dr." example claimed a title was being
  routed to first in 1.x. It was not -- 1.4.0 already gave title='Dr.'.
  What moved is the pre-comma piece, first -> family. The bullet now
  describes the change that happened instead of one that didn't.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Test gaps:

- __setstate__ now rejects a bare str component list. A str is
  iterable, so the per-entry rebuild split its CHARACTERS and produced
  a plausible-looking wrong name ("John" -> first "J o h n") rather
  than failing at the load site. Only reachable from foreign or
  hand-built state, since v1 stored lists.
- Added a test that restores a FOREIGN state dict carrying a multi-word
  suffix entry. The parametrized round-trips all go through v2's own
  __getstate__, which is self-consistent by construction, so none of
  them exercised the 1.4-produced-blob case the fix exists for. It
  passes as written; it was simply untested.
- Added a test importing all seven guarded config modules. An
  import-time assert only runs if its module is imported, and
  maiden_markers is lazy -- its guard fired only incidentally, whenever
  some other test happened to build a default Lexicon.

Docs and comments:

- locales.rst: a pack's Lexicon fragment is validated standalone,
  before the union onto the base, so a fragment that marks a word must
  also carry it. Latent today (both shipped packs are policy-only) but
  a trap for the next pack author.
- titles.py: the normalization comment was past tense about a bug fixed
  in the same commit, so a later reader would find the claim false and
  not know if it was stale. Restated as the hypothetical, which reads
  correctly forever.
- _config_shim.py: name the drift-guard test instead of describing it.

Not changed: the "spec §2" citations flagged as dangling (the specs
directory is gitignored). They predate this branch and appear four
times in _facade.py; fixing one would be inconsistent, and fixing all
is its own change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A second review of the remediation found the per-word rule was wrong
too, in the opposite direction from the whole-entry rule it replaced.
Both are now gone.

The lookup key is the space-joined run of Role.TITLE tokens, built by
the PARSE rather than drawn from this vocabulary. It can contain a
multi-word TITLES member ("chargé d'affaires" is one, and the per-word
rule rejected it), and it can contain a conjunction, because 'and' in
"Sir and Dame Alex" is tagged Role.TITLE. No static relation over these
sets decides reachability. Two attempts each rejected working configs,
while the condition they guarded -- an entry nothing consults -- is
inert. Guarding a harmless case cost three broken configurations, so
the guard goes.

The shim now TRANSLATES rather than filters. v1 joins the raw title run
and applies lc(), which strips only the whole string's edge periods, so
an interior word keeps its own ("lt. col"); v2 normalizes per token and
then joins ("lt col"). Re-folding per word converts between them.
Filtering had silently swapped given and family for every multi-word
honorific containing an abbreviation or a conjunction. Verified against
a live 1.4.0: "Grand Duke John", "Lt. Col. Smith" and "Sir and Dame
Alex" now all match.

Also fixed, all from the same review:

- _normalize now strips to a fixed point. strip().strip(".") left
  periods-around-whitespace half done ('. a .' -> ' a ' -> 'a'), so any
  value re-normalized later changed under its owner -- which the
  unpickle revalidation had just made reachable.
- __setstate__ RAISES on unnormalized state instead of quietly
  correcting it. Silently rewriting caller data on load would have made
  it a fourth undocumented transformation.
- The suffix_acronyms_ambiguous/suffix_words invariant kept, but the
  shim now subtracts, so a v1-legal config translates instead of
  raising at parse time naming a field (suffix_words) that does not
  exist on v1's Constants.
- The facade's setstate guard covers Mapping and bytes and checks entry
  types, not just bare str. A dict silently loaded its keys.
- _RegexesProxy gained copy(), and reports dict mutators as unsupported
  rather than as missing regexes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The translation added in 23ee08e handled the reachable cases correctly
but widened the degenerate ones, in both directions.

v1's lookup key is lc(" ".join(title_list)) -- always single-spaced,
never empty. So:

- An entry holding a whitespace run ("grand  duke") could never match
  there. Translating it collapsed the run and started matching, turning
  an inert entry into a behavior change.
- An entry that is all periods (".", "..") folds to "" under per-word
  normalization, which _normset rejects -- so a config 1.4.0 accepts
  and ignores raised ValueError at parse time, naming a field that does
  not exist on v1's Constants. The same complaint 23ee08e leveled at
  the suffix_words error, reintroduced two lines away.

Filter to entries v1's key shape can produce, then drop any that fold
away. Both verified against a live 1.4.0.

Two comment corrections from the same review:

- the suffix_acronyms_ambiguous subtraction claimed parity with v1; it
  trades a raise for the two-piece deviation already documented below
  it ("Jack Ma" is last='Ma' in v1, suffix='Ma' here), so say that
  rather than claiming equivalence.
- __setstate__ said "not written by nameparser" about state that an
  earlier commit on this branch could legitimately have written, before
  _normalize converged. Now "not written by this version".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The subtraction in 23ee08e traded a raise for something worse. It
dropped the word from suffix_acronyms_ambiguous, which ungates it --
and v1 does NOT ungate. v1's is_suffix already accepts an ambiguous
acronym through the acronym branch, and reserve_last keeps it as the
surname, so adding it to suffix_not_acronyms is INERT there:

    c.suffix_not_acronyms.add("ma"); "Jack Ma"
        1.4.0:   first='Jack' last='Ma'    (same as unconfigured)
        23ee08e: first='Jack' last=''  suffix='Ma'

A person with no family name, silently -- from a change whose stated
purpose was avoiding a loud error. Subtract from suffix_words instead,
which reproduces v1's inertness.

The test that was supposed to cover this used "John Smith jd", where v1
yields suffix='jd' with or without the config, so it passed on a case
the translation does not decide. Rewritten to "Jack Ma", where it does.

Also from the same review:

- The justification for removing the given_name_titles guard cited
  "chargé d'affaires" as a multi-word TITLES member that would be
  rejected. Neither version ever tags it -- v2 builds its key per
  token, so a space-bearing vocabulary entry cannot appear in one. The
  conjunction case ("sir and dame", where 'and' is tagged Role.TITLE)
  is real and sufficient on its own; the false premise is struck from
  the comment and from the test that pinned it.
- A stale comment still pointed at the per-word check deleted in the
  same commit.
- The unpickle drift check covered ten fields and silently
  re-canonicalized the eleventh; capitalization_exceptions now raises
  like the rest.

Every configuration broken across this session's four attempts now
matches a live 1.4.0: grand duke, lt. col, sir and dame, ma-as-word,
all-period entry, and E.S.Q.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four rounds of review found the same class of mistake repeatedly:
inferring an invariant from data that looked inconsistent, then editing
the data or config to fit. Two of the notes below are anti-cleanup
warnings for exactly the fences that got removed.

Gotchas:

- Comparing against v1 needs PYTHONSAFEPATH=1 and a directory outside
  the worktree, or --isolated still imports the branch as "v1" and
  every comparison reports parity. This produces false confidence
  rather than an error, which is the worst failure mode a verification
  method can have.
- 'esq' is in both suffix sets on purpose; the branches normalize
  differently, so it is coverage of two spellings, not duplication.
  Deleting it lost the family name for "John Smith E.S.Q.".
- Lexicon.given_name_titles is deliberately unvalidated, with the
  reason: a conjunction inside a title run is itself tagged
  Role.TITLE, so no static relation over the vocabulary decides
  reachability. Two guards, three broken configurations.
- _normalize must reach a fixed point, now that storage and match-time
  share one fold and unpickling re-validates.

2.0 conventions:

- Invariants guard harm, not no-ops. The criterion that would have
  prevented most of the above: construct the config it forbids and
  check what actually breaks.
- The shim translates; it never raises on a config v1 accepted and
  never silently changes the parse. Documents all four transformations
  with their v1-reachability arguments, and the testing rule that would
  have caught the last bug -- test the case the translation DECIDES,
  not one where both branches agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
v1 reads "John Smith MA" as suffix='MA' and "Jack MA" as last='MA'.
That is not inconsistency — it is evidence-weighing. With only two
pieces, "one of them is a credential" is the less likely reading, so
the ambiguous acronym stays the surname; with three, peeling it still
leaves a given and a family name, so the credential reading wins.

2.0 had generalized the period gate over every position, making a bare
ambiguous acronym never a suffix, so "John Smith MA" came out
family='MA', middle='Smith'. That was an unclassified divergence, and
the wrong call: MA after a full name really is more likely a degree.

Root cause is the same shape as the rest of this session's bugs. In v1
suffix_acronyms_ambiguous has exactly ONE use site -- parse_nicknames'
delimited-content escape, deciding whether "(JD)" is a nickname or a
suffix. It is a delimiter-path disambiguator, not a vocabulary
property, and 2.0 promoted it to a universal rule.

Narrow it to what it can decide: peel a bare ambiguous acronym only
when at least two name pieces remain. This is v1's reserve_last
restricted to the ambiguous set -- 2.0 still peels UNambiguous suffixes
when nothing is left ("Smith PhD" -> suffix, a classified fix),
because there the vocabulary is not in doubt.

Verified against a live 1.4.0: 9 of 10 shapes now identical, the tenth
being that classified PhD fix. Four case-table rows pin both halves of
the rule, and the release-log bullet now describes it instead of
claiming periods are the only path.

Case rows use uppercase "MA" where a suffix is expected, so the
intended reading is legible without running the parser. (Case itself is
not yet an input to the decision; it could reasonably become one.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
particles_ambiguous emitted PARTICLE_OR_GIVEN; suffix_acronyms_ambiguous
emitted nothing, for the structurally identical situation -- a word in
an ambiguous vocabulary where the parser picks one of two readings.
"parse('derek gulbranson MA').ambiguities" was empty even though the
parser had just chosen credential over surname.

Two kinds now emit:

SUFFIX_OR_FAMILY (new) at the peel in _assign, in BOTH directions --
"John Smith MA" reports that MA was read as a suffix, "Jack MA" that it
was read as the family name. Both are guesses.

SUFFIX_OR_NICKNAME (reserved since the core API landed) at classify,
for delimited content the vocabulary cannot settle: "(MBA)" escapes to
suffix on vocabulary alone, so "(JD)" keeping the nickname reading was
a silent coin-flip. Emitted at classify rather than at the escape in
extract, which runs before tokenize and so has no token index to
point at.

The emission has to be at the DECISION site, not on tag presence. In
"Joao da Silva do Amaral de Souza" the token 'do' carries
vocab:suffix-ambiguous but sits mid-name and is never at the peel
boundary -- no choice was made, so nothing is reported. Same reasoning
excludes "Ma, Jack", where a comma fixes the family before the question
arises: that mirrors the existing rule that PARTICLE_OR_GIVEN is not
emitted on the FAMILY_COMMA path. An ambiguity is a property of a
decision, not of a token.

Corpus impact is ~1%: 5 of 486 names.

test_every_ambiguity_kind_has_a_registered_trigger caught the new kind
immediately and the case table's exact-ambiguities assertion caught all
six affected rows -- both guards did their job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The constraint that shaped the two new emitters is not obvious from
either side, and ORDER is still reserved -- whoever wires it next needs
the rule, and users need to know why a name containing an ambiguous
word can report nothing.

Three places, each for its own audience:

- concepts.rst, in the Honest ambiguity essay: an empty ambiguities
  means the parse faced no fork, not that every word was unambiguous.
  Uses the two cases that surprised me -- 'do' mid-name in "Joao da
  Silva do Amaral de Souza", where nothing chooses, and "Ma, Jack",
  where the comma settles it before the question arises.
- AmbiguityKind's docstring, which is what modules.rst renders into the
  API reference: the same point in two sentences, for a reader who
  never opens the concepts page.
- AGENTS.md: the implementer rule. Emit at the decision site, never by
  scanning for a vocab:*-ambiguous tag; report both directions of a
  two-way fork; register a trigger in _AMBIGUITY_TRIGGERS and pin the
  kinds in the case table. Notes that the decision site already holds
  the token index and detail text a tag scan would have to rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AmbiguityKind.UNBALANCED_DELIMITER has always documented itself as "a
delimiter opened without closing (or closed without opening)", but only
the first half existed. The scan is opener-driven -- it finds an open
and looks rightward for its close -- so a close with nothing to its
left was never in the search space:

    'Jon "Nick Smith'  -> unbalanced-delimiter
    'John Smith)'      -> (nothing)

No design reason, just the direction of the walk. Both signal the same
malformed input.

Sweep for boundary-valid closes that no match consumed, reusing
_close_ok -- which is what keeps apostrophes out of it. Verified on the
486-name corpus: "O'connor", "O'B.", "Queen's" all have close_ok=False
because the apostrophe is mid-word, so none of them fire.

Offsets already reported as unmatched OPENS are skipped, so a symmetric
delimiter that satisfies both boundary tests still yields exactly one
ambiguity ('John " Smith').

One corpus name newly reports: "Mari' Aube'", two word-final
apostrophes. That is arguably correct -- a trailing apostrophe really
is ambiguous between punctuation and a closing quote -- and the
ambiguity is advisory, so the parse is unchanged (given "Mari'", family
"Aube'"). Worth knowing it will be commoner on transliterated
Arabic/Hebrew/Slavic data, where word-final apostrophes are ordinary.

Found by auditing the existing emitters against the decision-site rule
documented in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The close sweep added in d993db0 flagged "Mari' Aube'". It should not:
an ambiguity means the part could REASONABLY read two ways where it
sits, and a "'" after a word character cannot -- it is an apostrophe.

"'" is the only delimiter character that occurs inside and at the end
of real name parts, and the least likely of them to mark a nickname.
The same position with a quote IS ambiguous, because quotes do not
appear inside names, so the carve-out is deliberately this one
character:

    "Mari' Aube'"  -> 0 ambiguities
    'Mari" Aube"'  -> 2 ambiguities

#273 excluded the curly apostrophe from the delimiter set outright for
this reason. The straight one has to stay a delimiter (v1's
quoted_word), so it gets the narrower treatment here.

The carve-out is about what PRECEDES the character, so an apostrophe
opening a quote is untouched: "John 'Jack Smith" still reports an
unmatched open. Also allows a preceding period, for initials
("O'B. Smith'").

Corpus back to 0 of 486 reporting, where the naive close sweep had 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Last gap from the emitter audit. The kind carried no tokens, so the
ambiguity was locatable only by parsing an offset back out of its
detail string -- which is prose, not an API. The stray character does
survive into a token ('"Nick', 'Smith)'); nothing was pointing at it.

extract_delimited runs before tokens exist, so PendingAmbiguity gains
an `origin` character offset that tokenize resolves to the containing
token's index once the stream is built. Stages after tokenize set
indices directly and leave origin None.

Every emitted kind now carries its tokens:

    'Jon "Nick Smith'         unbalanced-delimiter  ['"Nick']
    'John Smith)'             unbalanced-delimiter  ['Smith)']
    'Van Johnson'             particle-or-given     ['Van']
    'John Smith MA'           suffix-or-family      ['MA']
    'JEFFREY (JD) BRICKEN'    suffix-or-nickname    ['JD']
    'Smith, John, Extra, Jr.' comma-structure       ['Extra']

The docstring's "May carry no tokens" is narrowed to what remains true:
a character inside a masked region belongs to no token.

test_stage_field_ownership caught tokenize writing a field it did not
own before -- fixed, and classify's entry corrected in the same pass,
since its SUFFIX_OR_NICKNAME emitter had the same omission and only
passed because no case-table row triggers it yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All three are mine, from the previous round, found by review.

1. Only one SUFFIX_OR_FAMILY survived per segment. ambiguous_pick was a
   single slot the peel loop overwrote, so "John Smith MA JD" made two
   coin-flips and reported one -- defeating the point of reporting.
   Collect them instead. The sibling SUFFIX_OR_NICKNAME emitter already
   did this correctly, so it was an oversight, not a design.

2. The detail string was wrong under two of three name orders. The
   comment claimed the unpeeled piece "stays the last name piece -- the
   family name under every order"; _name_positions maps a two-piece
   name to [FAMILY, GIVEN] under FAMILY_FIRST, so it is the GIVEN name
   there and the text said otherwise. Ambiguity.detail is public, so
   this misinformed callers. Now reads the role back after assignment.

3. Origin resolution was quadratic, and so was the check it fed.
   Resolving each ambiguity's offset rescanned every token, and
   ParsedName.__post_init__'s subset check then did `tok not in
   self.tokens` per referenced token -- O(ambiguities x tokens) twice
   over. Reachable because the new closer sweep can emit one ambiguity
   per delimiter character. Bisect over the (already sorted) token
   starts, and hash the token tuple once:

       ') ' * N     before      after
       N=1600       182 ms      12 ms
       N=3200        --         25 ms   (linear)

   The subset check was pre-existing but only became reachable at scale
   once ambiguities started carrying tokens.

Verified: differential harness 486 names, 2 intentional diffs, 0
unexplained. The review separately confirmed the 8147ac6 peel change
against 304 generated shapes under all three name orders -- 86 changed,
all 86 from disagreeing with v1 to matching it, 0 regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Option 2 from the review. Three silent forks now report, and the
documented claim that made them defects is corrected.

Renamed SUFFIX_OR_FAMILY -> SUFFIX_OR_NAME. The old name asserted a
role that is only right under one of the three name orders: FAMILY_FIRST
puts the declined reading in GIVEN, and the roman-numeral and comma
forks decline a MIDDLE. I had already fixed the detail string for this
reason; the kind name had the same bug. Generalizing it also means one
kind covers every suffix-versus-name-part fork instead of accreting one
per cause.

Newly reported:

- The trailing roman numeral. "John Smith V" takes V as a suffix where
  "John Smith B" makes B the family name -- V/X/I are ordinary middle
  initials, so that is a call, not a fact.
- PARTICLE_OR_GIVEN's missing branch. "Van Johnson" reported that Van
  was read as a given name; "Dr. Van Johnson" made the OPPOSITE call
  silently, because a leading title shifts Van off index 0, the prefix
  chain claims it, and that branch is taken in _group while the emitter
  lived in _assign. Both stages now report the side they decide.

The group emitter records at the merge site rather than matching the
merged shape -- the first attempt keyed on "multi-token piece whose
head is particle-ambiguous" and fired on "Joao da Silva do Amaral de
Souza" (mid-name 'do', no fork) and on the Arabic kunya (a bound-given
merge, which becomes the GIVEN name). Same decision-not-token lesson,
one level in.

Docs corrected: concepts.rst and the AmbiguityKind docstring both said
an empty ambiguities means the parse faced no fork. That was a
universal claim about the whole parser made on the evidence of three
emitters, and these findings falsify it. Now: an empty tuple means none
of the LISTED forks came up, coverage grows over releases, and a
non-empty tuple is the signal -- an empty one is not a guarantee.

AGENTS.md gains the cross-stage rule: if a fork's branches are taken in
different stages, each needs the emitter, the ownership map must list
ambiguities for each, and it passes vacuously until a case row
exercises the path.

Verified: 486-name corpus, 2 intentional diffs, 0 unexplained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third instance of the same mistake in this emitter, and the same root
cause both previous times: keyed on a SHAPE rather than the DECISION.

When the piece after an ambiguous particle is a suffix, the chain's
inner scan never advances, so merge(k, k+1) folds a piece into itself.
Nothing is chained and the particle stays a lone leading piece -- but
the record was appended before the merge, keyed only on "is a prefix"
plus the ambiguous tag. Result:

    'Dr. Van Jr.'  -> given='Van'  AMB "'Van' was chained into the
                                        family name"

The detail asserted the opposite of what happened, for all 39
particles_ambiguous members. For do/freiherr/st the token lands in the
TITLE instead.

Worse, the no-op leaves exactly the shape _assign triggers on, so 36 of
39 double-reported the same token from two stages.

`j > k + 1` is the discriminator -- it distinguishes a chain that
claimed a following piece from one that claimed nothing -- and it fixes
both defects, since the no-op was the only overlap between the two
emitters' domains. They now partition cleanly.

Also fixed the detail's wording. _group runs before assignment, so it
cannot know which field the chained piece lands in: "Dr. Van Johnson de
la Cruz" puts it in GIVEN, not family. This is the same defect the
role-readback cured in _assign one commit earlier -- the lesson did not
carry to the new emitter, and _group cannot read a role back, so the
text now describes the decision rather than guessing an outcome.

A "Dr. Van Jr." case row would have caught both criticals; 1539 tests
passed with them present because the only titled-particle test used an
UNAMBIGUOUS particle, pinning vocabulary instead of the decision.

Verified: 0 spurious reports across all 39 ambiguous particles; corpus
486 names, 2 intentional diffs, 0 unexplained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two findings from the review.

The kind has one name and two causes, and they were sharing one
template. The acronym branch turns on whether periods are written; the
numeral branch turns on the letter being a numeral at all. Sharing the
first produced:

    'V' written without periods is both a post-nominal and an ordinary
    name; read as a suffix rather than a name part

"written without periods" is the MA/M.A. distinction, which does not
exist for V, and the flattened "a name part" hid that the declined
reading is specifically a middle initial. The kind's own docstring says
detail names what was declined; it could not even tell you which branch
fired. Each branch now writes its own.

And the documented claim is narrowed, for the second time. The review
found a THIRD silent site for the roman-numeral fork -- the
SUFFIX_COMMA tail, decided in _segment by is_suffix_lenient and stamped
in _assign's blanket tail loop, so it is neither of the two comma
exceptions already documented. "John Smith, V" reports nothing.

Rather than chase coverage a third time, the claim no longer depends on
it. It said an empty tuple means "none of the forks listed here came
up", which is only true if every listed kind is emitted everywhere its
fork occurs -- a property I cannot verify and have now been wrong about
twice. It now says reporting is deliberately partial, names the comma
paths as the known quiet ones, and states the only thing that holds
regardless: a non-empty tuple is a signal, an empty one is not a
guarantee.

Verified: corpus 486 names, 2 intentional diffs, 0 unexplained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ambiguity convention added earlier covered where to emit and how to
test presence. Six review rounds and Derek's scope call exposed four
gaps, three of them things I got wrong AFTER writing the rule.

- A kind is worth adding only if a reader would hesitate too. The
  existing rule said where to emit, never whether a fork deserves a
  kind at all. "Smith, John V" reads as a middle initial to anyone, so
  reporting it is noise that teaches callers to ignore the field --
  reachability of the second branch is necessary, not sufficient. This
  is what stopped the feature growing indefinitely; every reviewer kept
  finding more reachable forks, and reachability was the wrong measure.

- A branch that runs but changes nothing is not a decision. merge(k, j)
  executes when j == k + 1, folding a piece into itself; keying on "the
  code got here" reported a fork for all 39 ambiguous particles on
  "Dr. Van Jr." and double-reported with _assign. Third instance of the
  same mistake, the last one after the rule was written down.

- Pin the decision, not the vocabulary. The only titled-particle test
  used an UNAMBIGUOUS particle, so it walked the right code path and
  proved nothing about the branch under test -- two criticals passed
  1539 tests.

- Document the positive direction of a partial property. "A non-empty
  ambiguities is a signal" is checkable; "an empty one means no fork
  occurred" is a universal negative needing exhaustive verification. It
  was written twice and falsified twice, at sites I had not audited.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_overlaps scanned the whole masked list, and the unmatched-close sweep
added last week calls it once per delimiter character found. On input
with many matched pairs that is O(hits x pairs): 400 pairs spent 5.35ms
here against 2.52ms before the sweep existed, growing 2.55x per
doubling.

masked is sorted and non-overlapping by construction -- the scan is
position-driven, so every later match starts at or after the previous
one's end -- which makes this the same bisect the origin resolution in
_tokenize already uses, for the same reason. Scaling is linear again
(1.9x per doubling, 800 pairs in 5.2ms).

Two smaller wins in the same sweep: iterate distinct closes (the
defaults list '”' twice, so the text was scanned 11 times for 10
closes), and reuse _unmatched() rather than restating its detail
template inline. Also guard the origin resolution on there being an
origin to resolve -- it was building starts and a closure on every
parse for the overwhelmingly common no-ambiguity case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lookup key was built in three places and one of them disagreed.
post_rules joins normalized title tokens ('lt col'); the v1 shim
translates first_name_titles the same way; but _normset stored native
2.0 entries with _normalize per whole entry, which leaves interior
periods alone. So Lexicon(given_name_titles={'lt. col'}) stored a key
the matcher can never build, and the entry silently matched nothing --
while the identical config through the Constants shim worked.

Give the rule one home: _title_key(words) in _lexicon, called by all
three. This is the field with no validation by design (two guards were
tried and both broke working configs), which makes a silent no-op its
most likely failure -- worth removing the one instance we control.

Strictly widening: 'lt col' folds to itself, so no config that works
today changes; entries with interior periods or repeated spaces start
working instead of being inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_group_segment widened its return to a 3-tuple to carry particle forks
out to the caller, which then wrote the message ~110 lines from the
branch that earned it, behind a suppression flag the caller had already
computed. _assign_main solves the same problem by taking the ambiguity
list as an out-parameter; do that here too. The message now sits with
the comment explaining why the fork exists, and one of the two
family-comma suppression comments goes away.

Same treatment for _assign's roman-numeral fork: its wording depends on
nothing assigned later, so it emits at its trigger rather than riding a
(piece, cause) tag through to a post-loop branch. That leaves
ambiguous_picks holding one cause, which is what made the tag necessary.

Also: collapse the two adjacent bare_ambiguous tests into one (the
shared "either way, record the fork" was invisible between them), drop
the unreachable role fallback in favour of an assert that says the
invariant out loud, and put the cheap selective tag test before the
per-piece title() scan in the group guard.

No parse output changes: 1549 tests, and the differential corpus holds
at 486 names / 2 intentional diffs / 0 unexplained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every vocabulary module carried the same all(w == w.strip().lower())
assert under a near-verbatim three-line comment, four of them differing
only in the set name. One helper, assert_normalized(name, entries),
states the rationale once and names the offending entries when it fires
(the old copies only said that something was wrong).

The fold stays the weaker w.strip().lower() rather than _lexicon's
_normalize: entries like 'esq.' are legitimate data here, and config
cannot import _lexicon anyway -- _lexicon imports these constants.

The relationship asserts (FIRST_NAME_TITLES <= TITLES, the
SUFFIX_ACRONYMS_AMBIGUOUS pair, the deliberate non-disjointness note)
stay in the modules that own them; those encode facts about the data
rather than hygiene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_lexicon.__setstate__: compute the drift set once per field instead of
three times, in _VOCAB_FIELDS declaration order rather than alphabetized
by formatted string, and drop the intermediate dict that existed only to
host a cast.

_types.ParsedName.__post_init__: nest the token-subset check under the
ambiguities guard rather than giving one local two types to skip work
the loop already skips.

_config_shim: inline a local used once.

tests: drop a bare-string __setstate__ test fully subsumed by the
parametrized case above it, and stop parsing the same input twice in
the unmatched-close assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_group and _assign each report one branch of this fork and coordinate
only through _group's `j > k + 1` guard, which mirrors _assign's
reachability by hand. Nothing checked the mirror: reachability drift in
either stage would over- or under-report, and only a case pinning that
exact name would notice.

Generate the shapes rather than trusting a corpus. The boundary is a
suffix straight after the particle ("Dr. Van Jr."), where the chain is a
no-op and only _assign should speak -- a shape plausible-name corpora
have little reason to hold, and indeed the 486-name differential corpus
does not. Swept over every ambiguous particle, so a vocabulary addition
is covered without editing the test. Verified to fail in both
directions by loosening and tightening the guard.

Asserts the count, not which stage spoke, on purpose: whether a fork was
decided by a vocabulary merge or by position is NOT recoverable from the
finished parse. 'Dr. aan Johnson Jr.' and 'أبو بكر أحمد' end with the
same roles and the same tags, and only one is a fork -- so a
reconstruction would have to re-implement _group instead of checking it.
That is also why the two emitters stay where the decisions are made.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parse(".,") returned given='.' and bool() True -- faithful v1 parity
(1.4 gives first='.' too), but it defeats the bool(parse(x)) idiom 2.0
documents as "did I get a name?". A caller filtering junk with
`if parse(x):` was fooled by all-punctuation input.

assemble now empties a parse whose surviving tokens carry no
alphanumeric character anywhere. isalnum() is Unicode-aware, so every
real name in any script has content -- the guard fires only on pure
punctuation/symbols ('.', '- -', the stray '∫≜⩕'). Junk embedded in a
name with real content ('John . Smith' keeps its dot) is left alone,
since that parse is already truthy and bool() is not misled.

Deliberate v1 divergence, both APIs (they share the core). Zero
differential impact: the corpus's three content-free names ('', '()',
',') already parsed empty for other reasons. Release-log note added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The table was keyed by destination field, which forced conjunctions
into the `family` row as "conjunctions inside a surname" -- misleading,
since the very next doctest shows `and` joining two GIVEN names. Keying
by word type gives conjunctions one honest row ("both neighbors,
whichever field they join") and lets the field column read as a map:
the five field-specific attachers cover title/suffix/given/family/
maiden, and the two fields you can't get by attachment (middle,
nickname) are exactly the ones absent.

Example words for titles and suffixes are drawn from the shipped
constants (dr/sir/prof/capt, phd/md/jr/esq).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each word type now links to its full set on the API reference (the
config-module sections render the whole {...}), so the few example
words are no longer needed inline -- they move to a restored Example
column showing a parse. Drops the Words/Field repetition the inline
lists caused.

Also tighten the conjunction row (Field "any"; "the words on both
sides") and spell out in the lead-in why titles/suffixes say "adjacent"
while the others say "the following word": the first attach only to
same-kind neighbors, the rest reach forward to the next word of any
kind.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants