diff --git a/docs/design/mechanisms.md b/docs/design/mechanisms.md index bc2309f2..cbaffa39 100644 --- a/docs/design/mechanisms.md +++ b/docs/design/mechanisms.md @@ -53,6 +53,10 @@ Problem shape. A fact known during tokenization matters to a much later stage. C Problem shape. "Which stage does X?" — asked before attributing behavior in prose, comments, or fixes. Contract statement. Each stage's docstring header declares what it consumes, produces and reads, and ParseState's docstring holds the cross-stage map, pinned by tests/v2/pipeline/test_state.py. How it works. A claim about which stage or layer does something is CHECKABLE — `parse(s).tokens` prints every token's role and tags — so check it before writing it; one plausible attribution sentence once shipped six times wrong (AGENTS.md's stage-attribution note). Lives in. nameparser/_pipeline/_state.py and every stage header. Reach for it when. Writing any sentence of the form "X happens before Y sees it." +## ONE-PREDICATE-PER-QUESTION — the stage that does not decide calls the one that does + +Problem shape. Two stages need the same answer about the same input, and the one that does not own the decision is about to test for it. Contract statement. Where two sites ask the same question, exactly one predicate answers it, and the site that does not own the decision calls the deciding stage's own predicate — never a condition written to match it. How it works. A hand-written mirror agrees with its original only until one of them moves, and the drift is invisible in both directions: each site keeps passing its own tests while they disagree about an input neither covers. Five instances, every one found as a defect before it was found as a pattern — #319 lifted the wholly-suffix predicate into the vocabulary layer "so the comma decision and the honorific peel's segment test cannot drift apart"; #401/#421 lifted the trailing-numeral fork out of assign so the bound-given reserve stopped carrying a copy, its hand-written mirror having been falsified in review more than once — the lesson recorded there being that what must be mirrored is assign's WALK, not merely its condition; #425 replaced that reserve's hand re-derivation of the trailing peel with one function over the view the join would leave; #424 moved assign's leading-title test down because group's own `title()` does not see H2's unlisted abbreviations, so `Xyz. van Johnson` chained where `Dr. van Johnson` did not; #429 moved the no-name-segment test down because group asked by segment INDEX where assign asks by CONTENT. The destination follows the LAYER, not the topic: a predicate over token text goes to `_vocab`, one over pieces and tags goes to `_group` — not because grouping owns it, but because `_assign` imports `_group` and cannot be imported back. That import direction is this mechanism's limit, and it forecloses the alternative: where the reader comes AFTER the decider, record the answer on the state instead — `ParseState.order` is that shape, "Recorded rather than recomputed downstream, because the two can differ" — which is unavailable whenever the EARLIER stage is the one asking. The cost is a second evaluation of the same predicate, measured for #429 at 1.2–2.2% of a family-comma parse and 0% of every other; recording that number was the right answer there over plumbing a state field the two sites would not otherwise share. Lives in. nameparser/_pipeline/_vocab.py (is_wholly_suffix, is_trailing_numeral_suffix) and nameparser/_pipeline/_group.py (_is_suffix_piece, _is_leading_title, _leading_titles, _peel_walk, _peel_trailing, _segment_holds_no_name), each called by a stage that does not define it. Reach for it when. You are about to write a condition that mirrors, matches or "does what X does" — or you find a comment saying one does. Grep for the other site's predicate and call it instead. + ## CLAUSE-CONTENT-OVERRULES-DELIMITER — content wins Problem shape. A bracketed clause should be treated as something other than what its delimiter pair says. Contract statement. extract may inspect a clause's content against the lexicon and, when it matches, mask only the two delimiter spans so the inner content rejoins the main token stream for ordinary downstream parsing. How it works. "Andrew Perkins (MBA)" is not a nickname (rule S1): the parens are masked away and MBA is classified by the normal machinery — reusing the downstream path, so the delimited and bare forms cannot drift. Lives in. nameparser/_pipeline/_extract.py (_suffix_shaped and the inner-span branch). Reach for it when. About to add a second code path that duplicates what the bare form already does — #335's fix is this shape (a _maiden_marked sibling predicate). diff --git a/nameparser/_pipeline/_group.py b/nameparser/_pipeline/_group.py index 738fd0a6..e7737ba2 100644 --- a/nameparser/_pipeline/_group.py +++ b/nameparser/_pipeline/_group.py @@ -25,6 +25,10 @@ test (_is_leading_title, _leading_titles), which the chain's scan shares since #424, and its no-name-segment test (_segment_holds_no_name), which the one-entry join reads since #429. +These are here by import direction, not by topic +(mechanisms.md#ONE-PREDICATE-PER-QUESTION): assign imports group and +cannot be imported back, so a piece-level predicate both stages ask +lives here. A text-level one goes to _vocab instead. """ from __future__ import annotations