Sitelet https://github.com/derek73/python-nameparser/pull/441
Skip to content

refactor(pipeline): piece-level predicates get their own module (#439) - #441

Merged
derek73 merged 4 commits into
masterfrom
refactor/439-shared-piece-predicates
Aug 25, 2026
Merged

refactor(pipeline): piece-level predicates get their own module (#439)#441
derek73 merged 4 commits into
masterfrom
refactor/439-shared-piece-predicates

Conversation

@derek73

@derek73 derek73 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #439.

Ten objects leave _group.py for a new nameparser/_pipeline/_pieces.py. No behavior change — the differential is byte-identical at every baseline, because no parse moves.

Why a new module rather than _vocab.py

The issue offered both. _vocab.py rules itself out in its own docstring:

Text-level tests used by more than one stage; token/piece-level predicates live with their stage. All take normalized-or-raw text explicitly — no state.

That contract is deliberate — is_wholly_suffix documents two explicit departures from it — so the piece layer gets its own module rather than bending the text layer's. The split is by what the question takes, which is what mechanisms.md#ONE-PREDICATE-PER-QUESTION states: _vocab answers how a word reads, _pieces how a piece reads.

What moved, and what didn't

Moved (10): _is_title_piece, _PERIOD_ABBREV, _is_leading_title, _leading_titles, _is_suffix_piece, _segment_holds_no_name, Peel, _peel_walk, _trailing_start, _peel_trailing — verbatim, comment blocks included, because the rules.md citations live in those comments and have to travel with the code they cite.

Stayed (group's own): _is_prefix_piece, _is_conj_piece, _is_rootname, _is_maiden_marker_piece.

_trailing_start travels with the peel even though only _peel_walk and _peel_trailing cross a stage boundary: decisions.md describes the S2 peel as those three together, and splitting a documented unit across modules to satisfy a usage rule would be worse than carrying one group-only function along.

_group imports back what it still uses. Nothing imports _pieces from below, so there is no cycle.

Three couplings the move had to carry

None of these are findable by grepping the function names, and each would have failed CI on its own:

  1. rules.md H2, H3 and S2 name the module their citation sits in, and test_doc_citations asserts set equality against citing modules — so their implemented: lines move to _pieces.py.
  2. test_regex_sync tracks _PERIOD_ABBREV by module path in three places: the import, the assertion, and the declared-copy roster.
  3. test_layering iterates ALLOWED.items(), so a module absent from that dict is never checked at all. A new pipeline module escapes the layering contract silently.

On (3), _pieces.py is registered with a tighter contract than the generic stage allowance — _state and _vocab only — which makes the module docstring's claim executable rather than merely asserted. Verified non-vacuous: the entry rejects an added nameparser._render import on a scratch copy. (The first probe used _assign and only produced a circular-import error at collection, which proves the cycle but not the assertion; the acyclic probe is the one that shows the guard firing.)

And the gap itself is now closed, not just stepped around. Registering _pieces.py fixed one module and left the mechanism intact for the next, so test_every_pipeline_module_is_keyed_in_allowed asserts that every _pipeline/*.py is keyed. Scoped there because that is what the contract governs — package-wide, 16 of 45 modules are unkeyed (config/ vocabulary data, util.py, _version.py) and were never in scope.

It was latent rather than active: all 13 pipeline modules were keyed, so _pieces.py would have been the first to slip through. That measurement lives in the docstring rather than an assertion, because asserting the count is the constant-content pattern AGENTS.md forbids — it would fail on every legitimate new module.

Verified by unregistering _pieces.py on a scratch copy: the new test fails and test_layering_contract still passes — the silent gap, demonstrated.

Docs the move falsified

The same-PR amendment rule landing on text merged yesterday in #440. Both statements were true when written:

  • _group's docstring said it houses the shared piece predicates and explained why.
  • mechanisms.md#ONE-PREDICATE-PER-QUESTION said a piece-and-tag predicate goes to _group.

It goes to _pieces now; _group is where they collected before the module existed, which is the accumulation the entry describes rather than the destination it should recommend. The entry's Lives in also gains the layering test, since that is now where each module's contract is written down.

Deliberately not done

No renames. The moved names keep their leading underscore, though _vocab's convention would drop it for a shared module's public surface. That would touch ~60 call sites across three files and dilute a diff whose value is being a verifiable pure move — a follow-up if you want it.

Verification

Gate Result
uv run pytest 5669 passed, 221 skipped, 10 xfailed
uv run mypy clean (109 source files)
uv run ruff check clean
differential (default baseline) 80 intentional, 0 unexplained — unchanged

_group.py goes from 1052 to 843 lines.

🤖 Generated with Claude Code

derek73 and others added 2 commits August 24, 2026 23:27
Pure relocation. No behavior change, no ledger entry, no release-log
bullet -- the differential is byte-identical because no parse moves.

Ten objects leave _group.py verbatim, comment blocks included: the
rules.md citations live in those comments and have to travel with the
code they cite. _is_title_piece, _PERIOD_ABBREV, _is_leading_title,
_leading_titles, _is_suffix_piece, _segment_holds_no_name, Peel,
_peel_walk, _trailing_start, _peel_trailing.

_vocab.py was the other candidate and rules itself out in its own
docstring -- "Text-level tests used by more than one stage;
token/piece-level predicates live with their stage." That contract is
deliberate (is_wholly_suffix documents two departures from it), so the
piece layer gets its own module rather than bending the text layer's.
The split is by what the question TAKES, which is the rule
mechanisms.md#ONE-PREDICATE-PER-QUESTION states.

_trailing_start travels with the peel although only _peel_walk and
_peel_trailing cross a stage boundary: decisions.md describes the S2
peel as those three together, and splitting a documented unit across
modules to satisfy a usage rule would be worse than carrying one
group-only function along.

Five predicates stay in _group, being group's alone: _is_prefix_piece,
_is_conj_piece, _is_rootname, _is_maiden_marker_piece, and the local
closures. group imports back what it still uses; nothing imports
_pieces from below, so there is no cycle.

Three couplings the move had to carry, none of which grep would have
found from the function names alone:

- rules.md H2, H3 and S2 name the module their citation sits in, and
  test_doc_citations asserts set EQUALITY against citing modules, so
  their implemented: lines move to _pieces.py.
- test_regex_sync tracks _PERIOD_ABBREV by module path in three places
  (the import, the assertion, and the declared-copy roster).
- test_layering iterates ALLOWED.items(), so a module absent from that
  dict is never checked AT ALL. _pieces.py is registered with a
  tighter contract than the generic stage allowance -- _state and
  _vocab only -- which makes the module docstring's claim executable.
  Verified non-vacuous: the entry rejects an added nameparser._render
  import on a scratch copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same-PR amendment rule landing on text I merged yesterday. Both
statements were true when written and the move makes them false:

- _group's docstring said it houses the shared piece predicates and
  explained why they were there. They are not there now, and what
  remains is group's own.
- mechanisms.md#ONE-PREDICATE-PER-QUESTION said a predicate over pieces
  and tags goes to _group. It goes to _pieces; group was where they
  collected BEFORE the module existed, which is the accumulation the
  entry describes rather than the destination it should recommend.

The entry's Lives in gains the layering test, because that is now where
each module's contract is written down -- and a piece predicate that
grows a dependency on a stage shows up there as a widened entry rather
than as a review comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73 derek73 self-assigned this Aug 25, 2026
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.59%. Comparing base (fcebf79) to head (7beecf1).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #441   +/-   ##
=======================================
  Coverage   98.59%   98.59%           
=======================================
  Files          44       45    +1     
  Lines        3064     3068    +4     
=======================================
+ Hits         3021     3025    +4     
  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 25, 2026
derek73 and others added 2 commits August 24, 2026 23:44
test_layering_contract iterates ALLOWED.items(), so its coverage is
whatever someone remembered to key. A module absent from that dict is
not checked loosely -- it is not checked at all, and nothing notices:
the suite stays green and the new module may import whatever it likes.
Registering _pieces.py in the commit before this one fixed that one
module and left the mechanism intact for the next.

Scoped to _pipeline/ because that is what this contract governs.
Package-wide, 16 of 45 modules are unkeyed -- config/ vocabulary data,
util.py, _version.py -- and they were never in scope; a whole-package
assertion would be asserting the wrong thing.

Latent rather than active when closed: all 13 _pipeline modules were
keyed, so #439's _pieces.py would have been the first to slip through.
That measurement is in the docstring rather than in an assertion,
because asserting the count is the constant-content pattern AGENTS.md
forbids -- it would fail on every legitimate new module, which is the
opposite of what this guard wants.

Verified non-vacuous by unregistering _pieces.py on a scratch copy:
this test fails and test_layering_contract still PASSES, which is the
silent gap it closes, demonstrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four reviewers. The move itself survived every mechanical check: ten
objects byte-identical, object sets conserved, no new cycle, and 0 of
1069 corpus names differ across all seven role fields plus str(). All
ten are killed by the behavior suite -- zero mutation survivors. The
findings are the guards around the move and the prose describing it.

THE GUARD I SHIPPED WAS DISARMABLE BY ONE CHARACTER. Path.glob on a
missing directory returns empty rather than raising, so a typo in the
directory literal or the pattern left test_every_pipeline_module_is_
keyed_in_allowed asserting `set() - keyed` -- true for every possible
ALLOWED. A completeness check measuring nothing, which is the shape of
the bug it exists to prevent. The directory now comes from the imported
subpackage (a typo is an AttributeError) and a known member is asserted,
which also covers a mistyped glob.

The same registry shape one file over, and this one was ACTIVE rather
than latent: test_regex_sync's _MODULES was missing six pipeline modules
(_assemble, _classify, _extract, _script_segment, _segment, _state), so
they were never scanned for undeclared hand copies of a config regex.
Measured on a scratch copy: a new module carrying a wrong copy of the
mac pattern passed all 20 tests. Added, and given the symmetric guard --
which catches it now, where before only the layering twin did. Nothing
had slipped through; the six carry no hand copies.

Three of the five prefixes in the _pieces ALLOWED entry were dead
permission carried over from the stage allowance. _lexicon is the sharp
one: a title predicate reaching for _title_key is the likeliest next
widening, and the entry pre-authorised exactly what its comment claimed
to watch for. Narrowed to what the module imports.

Prose. Every present-tense "assign imports group" is now FALSE -- that
import is precisely what this PR removed; _assign no longer names _group
at all. Past-tensed, with the rule that replaced it named instead. Also:
_peel_trailing's docstring justified its own placement by that import
(the one sentence in the moved block that could not survive verbatim);
_assign's two "group's _leading_titles"/"group's _peel_trailing";
_vocab's "piece-level predicates live with their stage", which #439
repealed and which _pieces was quoting AS CORROBORATION; the tags
attributed to _vocab when classify and group write them; a "three group
sites" count the move made two; group's header claiming H2 and H3 after
their citations left; and AGENTS.md's _PERIOD_ABBREV, stale by two hops.

S2 regains _group in implemented:, via a citation at the site that acts
on it -- group holds thirteen call sites of the peel trio deciding where
the family and maiden names stop, and dropping off S2's map because the
citation moved was the map getting worse.

Counts: mine were falsified three times this session, and my own first
correction here said "five predicates" over a list of six. These name
the PRs and let the reader count. mechanisms.md's Lives in no longer
claims a qualifier that stopped selecting its list, and records that
is_trailing_numeral_suffix lost its stage caller to this very move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@derek73
derek73 merged commit f1ef995 into master Aug 25, 2026
11 checks passed
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.

Five shared piece predicates live in _group.py only because assign imports group

1 participant