Skip to content

Commit 332bd7a

Browse files
derek73claude
andcommitted
Apply the simplify pass (#272)
Deduped findings of a four-angle /simplify review, no behavior changes: * _vocab: extract the shared _classify(normalized) step, so effective_script normalizes ONCE instead of twice -- deleting the comment block that defended the second normalize. * _script_segment: the two-character-presumption paragraph was written at near-identical length here and in locales/ja.py; the stage keeps one sentence and points at the pack that knows namedivider's rules. * _script_segment: "one split path so nothing drifts" was stated three times (module docstring, _pieces, _split); it stays on _split, the shared path itself. * _script_segment: the upper-bound ownership fact was at three sites; _split's precondition keeps one clause, the raise site and Segmentation's docstring keep the argument. * tests: six `return None` segmenter callables collapse to the pickle test's module-level one plus two named siblings -- two, because the override test needs distinct objects for its identity assertion. * tests: the capture-the-argument closure was written twice; both cases now share a _capture() helper beside _fake. * tests: normalize two files' triple trailing newline to one. * AGENTS.md: promote the raise-vs-decline segmenter doctrine out of the Pickling bullet into its own, leaving Pickling one clause. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 94fd02e commit 332bd7a

6 files changed

Lines changed: 69 additions & 80 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These
131131
- **The shim TRANSLATES; it never raises on a config v1 accepted, and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. Four exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), and `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`). When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing.
132132
- **Reprs are bounded**: render which fields deviate from a named baseline and by how much, never contents (`Lexicon(default + titles: +2)`). `PolicyPatch`'s repr shows only set (non-UNSET) fields; `_order_repr` must never raise even on an unvalidated patch's garbage `name_order` (PolicyPatch defers validation to apply time); the sweep test in `tests/v2/test_reprs.py` pins that no config repr leaks the UNSET sentinel.
133133
- **Typing/docs**: `from __future__ import annotations`; `frozen=True, slots=True` on every public dataclass; strict-profile mypy flags via per-module overrides in pyproject (`strict = true` itself is not valid per-module). Docstrings state contracts in prose with **no doctest blocks**`--doctest-modules` makes every example a test; behavior examples go to unit tests per the lean-docs rule. **Document the positive direction of a partial property**: "a non-empty `ambiguities` is a signal to act on" is checkable, while "an empty one means no fork occurred" is a universal negative needing exhaustive verification -- that claim was written twice and falsified twice, at sites the author had not audited.
134-
- **Pickling**: v2 types must round-trip (`Parser` is picklable by construction, and it holds a `Lexicon`; the one qualifier is the optional `Parser(segmenter=...)` hook — a Parser holding a callable pickles iff that callable does, and the same hook is parse-totality's one exception. Everything inside that exception is a bug in USER CODE, never a fact about the name, so it is surfaced rather than absorbed: the segmenter's own exceptions propagate, and the two protocol violations the stage can detect for itself — an answer of the wrong type, and one cutting at or past the end of the token it was handed — raise `TypeError`/`ValueError` from `_script_segment` for the same reason. The line to hold when adding a check there: a protocol violation by the segmenter's AUTHOR raises, while an adapter's defense against its own third-party library (`locales/ja.py`'s repertoire, length, reconstruction and score guards) declines with `None`, because what those catch is a fact about the content). Every frozen type assigns `_guarded_getstate`/`_guarded_setstate` (`_types.py`) in its class body (`@dataclass(slots=True)` would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, and values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance). `Lexicon` keeps its own copy of the guard (layering) plus the `mappingproxy` slot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test.
134+
- **The segmenter contract**: the optional `Parser(segmenter=...)` hook is parse-totality's ONE exception (locales spec section 4). Everything inside that exception is a bug in USER CODE, never a fact about the name, so it is surfaced rather than absorbed: the segmenter's own exceptions propagate, and the two protocol violations the stage can detect for itself — an answer of the wrong type, and one cutting at or past the end of the token it was handed — raise `TypeError`/`ValueError` from `_script_segment` for the same reason. The line to hold when adding a check there: a protocol violation by the segmenter's AUTHOR raises, while an adapter's defense against its own third-party library (`locales/ja.py`'s repertoire, length, reconstruction and score guards) declines with `None`, because what those catch is a fact about the content.
135+
- **Pickling**: v2 types must round-trip (`Parser` is picklable by construction, and it holds a `Lexicon`; the one qualifier is that a `Parser` pickles iff its segmenter does — see the segmenter bullet above). Every frozen type assigns `_guarded_getstate`/`_guarded_setstate` (`_types.py`) in its class body (`@dataclass(slots=True)` would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, and values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance). `Lexicon` keeps its own copy of the guard (layering) plus the `mappingproxy` slot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test.
135136
- **One sanctioned global**: the (future) cached default `Parser`. Lazily cached FROZEN singletons (`Lexicon.default()`'s `functools.cache`, the future default parser) are constants, not state; any second piece of module-level MUTABLE state requires amending the conventions doc, on purpose, in review. Sanctioned exceptions, facade layer only: `_config_shim.CONSTANTS` (the v1 shared singleton, mutable by design) and `_facade._WARNED_SUBCLASSES` (the once-per-subclass hook-warning dedup set) — both deleted with the layer in 3.0.
136137
- **Tests**: all v2 tests live in the `tests/v2/` package (its `conftest.py` overrides the v1 dual-run fixture — v2 code never reads shared `CONSTANTS`), one test module per source module plus the cross-cutting ones (`test_reprs.py`, `test_layering.py`, `test_contracts.py`, `test_properties.py`, `test_benchmark.py`, the `cases.py`/`test_cases.py` table, and `test_regex_sync.py`, which pins every hand-copied pattern or codepoint table against its source wherever the copy lives — including copies outside the package), names stating behavior. Never assert `Lexicon.default()` contents; the narrow sourcing spot-checks in `test_default_sources_v1_vocabulary` that pin the v1→v2 migration contract (e.g. the flipped `particles_ambiguous` model) are the sanctioned exception.
137138

nameparser/_pipeline/_script_segment.py

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,9 @@
3030
packs are corpus ALTERNATIVES, one per corpus; stacking them is for
3131
genuinely mixed data that accepts that trade. Its Segmentation may
3232
cut anywhere and any number of times, which is why the split path
33-
below is written for n cuts and the vocabulary hit is simply its
34-
one-cut case. One precondition guards it that the vocabulary has no
35-
twin of: a segmenter answers where an UNDIVIDED name divides, so it is
36-
consulted only when the gated token is the name part's ONLY
33+
below takes n cuts. One precondition guards it that the vocabulary
34+
has no twin of: a segmenter answers where an UNDIVIDED name divides,
35+
so it is consulted only when the gated token is the name part's ONLY
3736
script-written one -- "山田 太郎" was divided by its writer and must
3837
not have its family divided again (a Latin title or suffix draws no
3938
such boundary). A segmenter's own exceptions PROPAGATE -- the single
@@ -109,14 +108,9 @@
109108
#: STATISTICALLY divided name carries a SEGMENTATION report, and every
110109
#: RULE-divided one -- the kana boundary, a two-character name,
111110
#: namedivider's specific-name rules -- carries none.
112-
#: One case is worth writing down rather than rediscovering: the
113-
#: two-character division is a PRESUMPTION, not a measurement (usage.rst
114-
#: says exactly that), yet namedivider scores it 1.0 and this floor
115-
#: therefore keeps it silent. That is the divider's own stated
116-
#: certainty, accepted here as-is rather than second-guessed by a
117-
#: length rule of ours. If presumption-reporting is ever wanted, the
118-
#: hook is namedivider's DividedName.algorithm, which names WHICH rule
119-
#: answered -- not the score, which cannot tell the two apart.
111+
#: namedivider scores its own two-character rule 1.0, so this floor
112+
#: keeps that division silent -- see locales/ja.py for why the
113+
#: presumption is accepted as stated.
120114
#: Not configurable in 2.x (YAGNI).
121115
_SEGMENTER_CONFIDENCE_FLOOR = 0.9
122116

@@ -136,9 +130,7 @@ def _remap(run: tuple[int, ...], split_at: int,
136130

137131

138132
def _pieces(text: str, splits: tuple[int, ...]) -> tuple[str, ...]:
139-
"""`text` cut at every offset in `splits`: n offsets, n+1 pieces.
140-
One idiom for both callers -- the split itself and the report that
141-
describes it must never disagree about what the cuts produce."""
133+
"""`text` cut at every offset in `splits`: n offsets, n+1 pieces."""
142134
cuts = (0, *splits, len(text))
143135
return tuple(text[a:b] for a, b in zip(cuts, cuts[1:]))
144136

@@ -149,13 +141,11 @@ def _split(state: ParseState, i: int, splits: tuple[int, ...],
149141
a SEGMENTATION report when there is one.
150142
151143
The offsets arrive non-empty, ascending and interior whatever chose
152-
them. From a segmenter: non-empty is the caller's own check, and
144+
them. From a segmenter: non-empty is the caller's own check,
153145
strictly ascending with each >= 1 is Segmentation.__post_init__'s,
154-
while the last offset being < len(text) is the caller's bounds
155-
check -- that half cannot live in Segmentation, which never sees
156-
the text. From the vocabulary: the single offset is >= 1 and
157-
< len(text) by the range(cap, 0, -1) construction and its len-1
158-
cap.
146+
and the last offset is < len(text) -- checked by the caller. From
147+
the vocabulary: the single offset is >= 1 and < len(text) by the
148+
range(cap, 0, -1) construction and its len-1 cap.
159149
160150
The ONE split path: the vocabulary hit is the single-offset case
161151
and the segmenter's answer the general one, so neither can drift

nameparser/_pipeline/_vocab.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,16 @@ def _normalized_for_script(text: str) -> str | None:
249249
return unicodedata.normalize("NFC", text)
250250

251251

252+
def _classify(normalized: str) -> Script | None:
253+
"""The FIRST _SCRIPT_PATTERNS entry covering all of `normalized`
254+
(already NFC, via _normalized_for_script), else None. Shared by
255+
both public classifiers so each of them normalizes exactly once."""
256+
for script, pattern in _SCRIPT_PATTERNS.items():
257+
if pattern.fullmatch(normalized):
258+
return script
259+
return None
260+
261+
252262
def single_script(text: str) -> Script | None:
253263
"""The one Script whose ranges cover EVERY char of `text`, else
254264
None (mixed-script text has no well-defined convention to apply;
@@ -259,10 +269,7 @@ def single_script(text: str) -> Script | None:
259269
normalized = _normalized_for_script(text)
260270
if normalized is None:
261271
return None
262-
for script, pattern in _SCRIPT_PATTERNS.items():
263-
if pattern.fullmatch(normalized):
264-
return script
265-
return None
272+
return _classify(normalized)
266273

267274

268275
def effective_script(text: str) -> Script | None:
@@ -275,26 +282,18 @@ def effective_script(text: str) -> Script | None:
275282
HIRAGANA carrier entry. Pure-katakana stays KATAKANA
276283
(single_script's answer): a lone katakana token is predominantly a
277284
transcribed foreign name, so nothing defaults on it."""
278-
script = single_script(text)
285+
# None for both shapes _JA_PATTERN could never match anyway (empty
286+
# text, or all-ASCII text): real work, not a leftover "if text"
287+
# guard, since the ASCII case is one a bare emptiness check would
288+
# let through. The single normalized copy then serves both the
289+
# single-script answer and the license below.
290+
normalized = _normalized_for_script(text)
291+
if normalized is None:
292+
return None
293+
script = _classify(normalized)
279294
if script is not None:
280295
return script
281-
# Renormalizes text that single_script (above) already normalized
282-
# once: deliberately NOT hoisted into a shared `_classify(normalized)`
283-
# helper. The second call only runs on the fall-through path (a
284-
# token single_script could not classify at all, i.e. genuinely
285-
# mixed-script or empty/ASCII), never on the common single-script
286-
# hit above, so it is a quick re-check on already-NFC text, not
287-
# measured work -- the per-char-vs-regex history in this module's
288-
# header is what a real cost here would look like, and this isn't
289-
# it.
290-
# normalized is None for both shapes _JA_PATTERN could never match
291-
# anyway (empty text, or the all-ASCII text single_script's fast
292-
# path already ruled out) -- real work, not a leftover "if text"
293-
# guard: unlike the pre-NFC version, None here also covers the
294-
# ASCII case, which single_script's own empty check alone would
295-
# not.
296-
normalized = _normalized_for_script(text)
297-
if normalized is not None and _JA_PATTERN.fullmatch(normalized):
296+
if _JA_PATTERN.fullmatch(normalized):
298297
return Script.HIRAGANA
299298
return None
300299

tests/v2/pipeline/test_script_segment.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ def segmenter(text: str) -> Segmentation | None:
4949
return segmenter
5050

5151

52+
def _capture(
53+
splits: tuple[int, ...] = (2,)) -> tuple[list[str], Segmenter]:
54+
"""_fake, plus the list of texts it was handed: the two cases below
55+
that pin WHETHER and WITH WHAT the stage consults a segmenter need
56+
the same closure."""
57+
asked: list[str] = []
58+
59+
def segmenter(text: str) -> Segmentation | None:
60+
asked.append(text)
61+
return Segmentation(splits)
62+
return asked, segmenter
63+
64+
5265
def _declines(text: str) -> Segmentation | None:
5366
return None
5467

@@ -232,12 +245,7 @@ def test_vocabulary_wins_over_the_segmenter() -> None:
232245
# precedence, and the reason parser_for(ZH, JA, segmenter=...)
233246
# composes: a matched surname is a dictionary certainty, so the
234247
# segmenter is not even asked
235-
asked: list[str] = []
236-
237-
def seg(text: str) -> Segmentation | None:
238-
asked.append(text)
239-
return Segmentation((2,))
240-
248+
asked, seg = _capture()
241249
state = _run("毛泽东", policy=_JA, segmenter=seg)
242250
assert _texts(state) == ["毛", "泽东"]
243251
assert asked == []
@@ -398,14 +406,7 @@ def test_the_segmenter_is_handed_the_gated_token_only() -> None:
398406
# the difference: it is skipped by the gate (no script) and is not
399407
# a neighbour for the precondition either, so the consult happens
400408
# and the argument is observable.
401-
asked: list[str] = []
402-
403-
def seg(text: str) -> Segmentation | None:
404-
asked.append(text)
405-
return Segmentation((2,))
406-
409+
asked, seg = _capture()
407410
out = _run("Dr 山田太郎", policy=_JA, segmenter=seg)
408411
assert asked == ["山田太郎"]
409412
assert _texts(out) == ["Dr", "山田", "太郎"]
410-
411-

tests/v2/test_parser.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,17 @@ def _module_level_decline(text: str) -> Segmentation | None:
620620
return None # module-level so pickle can find it
621621

622622

623+
# Inert sentinels for the plumbing tests below, which only ever compare
624+
# identity. Two of them, because the override test needs the loser and
625+
# the winner to be DISTINCT objects or its assertion is vacuous.
626+
def _decline_a(text: str) -> Segmentation | None:
627+
return None
628+
629+
630+
def _decline_b(text: str) -> Segmentation | None:
631+
return None
632+
633+
623634
def test_parser_segmenter_is_keyword_only_and_validated() -> None:
624635
assert Parser().segmenter is None
625636
with pytest.raises(TypeError, match="callable"):
@@ -629,11 +640,9 @@ def test_parser_segmenter_is_keyword_only_and_validated() -> None:
629640

630641

631642
def test_parser_for_carries_the_base_segmenter() -> None:
632-
def seg(text: str) -> Segmentation | None:
633-
return None
634643
# not given: the base's carries through unchanged
635-
p = parser_for(locales.get("zh"), base=Parser(segmenter=seg))
636-
assert p.segmenter is seg
644+
p = parser_for(locales.get("zh"), base=Parser(segmenter=_decline_a))
645+
assert p.segmenter is _decline_a
637646

638647

639648
def test_parser_for_rejects_a_non_parser_base() -> None:
@@ -642,23 +651,16 @@ def test_parser_for_rejects_a_non_parser_base() -> None:
642651

643652

644653
def test_parser_for_takes_a_segmenter_keyword() -> None:
645-
def seg(text: str) -> Segmentation | None:
646-
return None
647-
p = parser_for(locales.get("zh"), segmenter=seg)
648-
assert p.segmenter is seg
654+
p = parser_for(locales.get("zh"), segmenter=_decline_a)
655+
assert p.segmenter is _decline_a
649656
assert parser_for(locales.get("zh")).segmenter is None
650657

651658

652659
def test_parser_for_segmenter_keyword_overrides_the_base() -> None:
653660
# later wins, the same rule scalar policy fields follow
654-
def from_base(text: str) -> Segmentation | None:
655-
return None
656-
657-
def explicit(text: str) -> Segmentation | None:
658-
return None
659-
p = parser_for(locales.get("zh"), base=Parser(segmenter=from_base),
660-
segmenter=explicit)
661-
assert p.segmenter is explicit
661+
p = parser_for(locales.get("zh"), base=Parser(segmenter=_decline_a),
662+
segmenter=_decline_b)
663+
assert p.segmenter is _decline_b
662664

663665

664666
def test_parser_for_segmenter_none_clears_the_base() -> None:
@@ -669,13 +671,11 @@ def test_parser_for_segmenter_none_clears_the_base() -> None:
669671
# (the test above), and this is how you derive an unsegmented
670672
# parser from a segmented one without rebuilding its lexicon and
671673
# policy by hand.
672-
def seg(text: str) -> Segmentation | None:
673-
return None
674-
base = Parser(segmenter=seg)
674+
base = Parser(segmenter=_decline_a)
675675
pack = locales.get("zh")
676676
assert parser_for(pack, base=base, segmenter=None).segmenter is None
677677
# ...and the base is untouched: parser_for builds a fresh Parser
678-
assert base.segmenter is seg
678+
assert base.segmenter is _decline_a
679679

680680

681681
def test_parser_picklability_is_conditional_on_the_segmenter() -> None:

tests/v2/test_types.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,5 +528,3 @@ def test_with_field_tokens_rejects_mismatched_roles() -> None:
528528
with pytest.raises(ValueError, match="has role family, not given"):
529529
name._with_field_tokens(
530530
{Role.GIVEN: (Token("x", None, Role.FAMILY),)})
531-
532-

0 commit comments

Comments
 (0)