Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6fe92d1
Consume a maiden marker inside delimited content (#329)
derek73 Aug 3, 2026
6a249a5
Pin the delimited maiden marker and classify it against 1.4.0 (#329)
derek73 Aug 4, 2026
7bdce66
Say what actually happens to a marker in delimited maiden content (#329)
derek73 Aug 3, 2026
dc73027
Correct the replacement rule, and pin the empty-parse edge (#329)
derek73 Aug 3, 2026
c8abd63
Pin what the delimited maiden drop consumes, not only where (#329)
derek73 Aug 3, 2026
da50cf7
Give the default 'nee' marker a row that fails when it goes (#329)
derek73 Aug 3, 2026
944f32e
Cover the delimited maiden marker through the v1 facade (#329)
derek73 Aug 3, 2026
f60d9c4
Stop the policy-gated benchmark from guarding nothing
derek73 Aug 3, 2026
c603f55
Pin the #329 clause drop across script segmentation
derek73 Aug 3, 2026
efa7dd3
Correct a unit, and say what an inert-looking test guards
derek73 Aug 3, 2026
300d645
Correct two note claims to what was measured
derek73 Aug 3, 2026
f98bfad
Record the policy-gated benchmark rule in AGENTS.md
derek73 Aug 3, 2026
3d7dce8
Say which v1 spelling the dead facade branch actually uses
derek73 Aug 4, 2026
3f083b0
Let the maiden-delimiter rows run through the v1 facade
derek73 Aug 4, 2026
6d0587e
Bound the delimited maiden drop at both ends of a 3-token clause
derek73 Aug 4, 2026
8fbd285
Pin that revise sub-parses under its own parser's policy
derek73 Aug 4, 2026
a74273d
Assert which rows the facade runner declines, and close the field gap
derek73 Aug 4, 2026
e29e8cf
Correct four claims to what the tree measures
derek73 Aug 4, 2026
45b2ec2
Point the two clause comments at the rows that now pin them
derek73 Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These
- **Invariants guard harm, not no-ops**: add a constructor check when violating it produces a *wrong parse*, not when it produces *nothing*. A false positive costs a working configuration; a true positive on an inert condition costs the user nothing, so that trade is never worth taking. `suffix_acronyms_ambiguous ∩ suffix_words` is guarded because the overlap loses a family name; `given_name_titles` is not, because an unreachable entry is simply never consulted (see Gotchas). Before adding one, 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**: `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. Six 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), `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`), `honorific_tails = GLUED_HONORIFICS ∩ suffix_words` (#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off, `test_snapshot_removing_a_honorific_word_turns_the_peel_off`), and `maiden_delimiters − nickname_delimiters` on the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, while `Policy` resolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior, `test_snapshot_overlap_keeps_v1_nickname_precedence`). Note that last one is on the `Policy`, not the `Lexicon` — the roster is per-`_snapshot()`, not per-vocabulary-field, so a sweep that only reads the `Lexicon(...)` call misses it. 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.
- **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.
- **A claim about WHICH STAGE does something is checkable — check it before writing it.** The pipeline is eight stages with a written ownership map (`ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`), and `parse(s).tokens` prints every token's role and tags, so "extract assigns this", "classify never sees that", "group consumes it" each have a one-command answer. #329's prose claimed delimited maiden content is *"claimed whole before classify has tagged anything inside it"*; measured, `classify` tags the marker fine and only the CONSUMING is missing, because `_group`'s rule walks `pieces` and a token that already carries a role is not in `pieces`. Two different mechanisms, one plausible sentence covering both. **And when a mechanism claim turns out wrong, sweep for where else you wrote it**: that one shipped in three places — the release-log entry, `config/maiden_markers.py`'s docstring, and a case-row note — because the correction reached the issue it was found on and nowhere else. Prose density here means one idea routinely lives in a docstring, a case note, a release-log entry and this file; a claim worth writing is worth grepping for when it changes.
- **A claim about WHICH STAGE does something is checkable — check it before writing it.** The pipeline is eight stages with a written ownership map (`ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`), and `parse(s).tokens` prints every token's role and tags, so "extract assigns this", "classify never sees that", "group consumes it" each have a one-command answer. #329's prose claimed delimited maiden content is *"claimed whole before classify has tagged anything inside it"*; measured, `classify` tags the marker fine and only the CONSUMING is missing, because `_group`'s rule walks `pieces` and a token that already carries a role is not in `pieces`. Two different mechanisms, one plausible sentence covering both. **And when a mechanism claim turns out wrong, sweep for where else you wrote it**: that one shipped in three places — the release-log entry, `config/maiden_markers.py`'s docstring, and a case-row note — because the correction reached the issue it was found on and nowhere else. Prose density here means one idea routinely lives in a docstring, a case note, a release-log entry and this file; a claim worth writing is worth grepping for when it changes. It then shipped a fourth and fifth time — the fix's own comment in `_group.py` and its test docstring — written by the commit that closed the bug, after this bullet existed: a wrong mechanism gets reused most readily by the person implementing against it, so the sweep belongs at the END of the change too, over the words the change itself just added. The correction pass then made a FRESH stage error in the very sentence fixing the old one — "extract assigned the whole clause `Role.MAIDEN`", when `extract_delimited` produces no tokens at all (it records `extracted`/`masked` spans; `tokenize` sets the role) — which is this bullet turned on itself: rewriting a mechanism claim is writing one, and earns the same one-command check.
- **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.
- **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.
- **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.
Expand Down Expand Up @@ -187,7 +187,7 @@ Add a dedicated `copy.deepcopy()` round-trip test for it too (see `test_regexes_

**`_normalize` must reach a fixed point** — storage and match-time share the one fold, and `Lexicon.__setstate__` re-validates, so a value that changes on re-normalization changes under its owner. `strip().strip(".")` alone is not idempotent (`'. a .'` → `' a '` → `'a'`). The loop is the fix; keep any new stripping inside it. **Anything built on `_normalize` must converge too** — `_title_key` joins per-word `_normalize` and DROPS words that fold away; keeping the empty slot stored `'lt .'` as `'lt '`, a key match-time can never rebuild (so the entry is silently inert) and `__setstate__` rejects on the next round-trip as "not written by this version".

**Perf regressions are caught by the scaling test, not the absolute-time ones** — `tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly` times a repeated unit at n vs 4n over ten shapes (one per pipeline inner loop) and bounds the ratio; the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate `_MAX_RATIO` against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point `_BASE` matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The ten shapes cover different dimensions (segment count only via `commas`, intra-piece accumulation only via `particles`/`conjunctions`, non-ASCII input only via `honorifics` — the other nine are pure ASCII, so `script_segment` returns at its bail and the CJK stages go unmeasured); measure before pruning one.
**Perf regressions are caught by the scaling test, not the absolute-time ones** — `tests/v2/test_benchmark.py::test_parse_cost_grows_no_worse_than_linearly` times a repeated unit at n vs 4n over ten shapes (one per pipeline inner loop) and bounds the ratio; the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. Two rules when touching it: calibrate `_MAX_RATIO` against the WEAKEST quadratic's signal (a mixed quadratic surfaces far below the textbook 16×, so the operating point `_BASE` matters more than the bound), and confirm a planted regression fails it across REPEATED runs — one failure is a coin-flip on a timing test. The ten shapes cover different dimensions (segment count only via `commas`, intra-piece accumulation only via `particles`/`conjunctions`, non-ASCII input only via `honorifics` — the other nine are pure ASCII, so `script_segment` returns at its bail and the CJK stages go unmeasured); measure before pruning one. A stage gated on an opt-in `Policy` field needs a `_POLICY_SHAPES` entry instead, since bare `parse()` never enters it — and that table's rows carry a **reachability probe** run before the measurement, because a precedence change can quietly stop the shape reaching the stage and leave a green test measuring a no-op (`_POLICY_SHAPES` is also asserted non-empty: an empty `parametrize` is a skip, not a failure, so deleting its last row would retire the guard silently).

**Expected-failure tests use `@pytest.mark.xfail`** — the conftest parametrized fixture breaks `@unittest.expectedFailure`; always use `@pytest.mark.xfail` instead.

Expand Down
11 changes: 9 additions & 2 deletions docs/customize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,15 @@ listed below.
* - ``maiden_delimiters``
- ``frozenset[tuple[str, str]]``
- Routes content enclosed by these delimiter pairs to ``maiden``
instead, and drops them from the effective nickname set.
Defaults to empty — see the routing example below.
instead, and drops them from the effective nickname set. A
marker word opening the enclosed content is dropped from the
value, so ``"Jane Smith (née Jones)"`` gives maiden ``Jones``
— but only where that content holds more than one *token*,
since a lone ``"(Nee)"`` is a maiden name rather than a
marker. Tokens, not words: a marker written against the name
it marks is one token with them, so ``"山田花子(旧姓佐藤)"``
keeps its ``旧姓``. Defaults to empty — see the routing
example below.
* - ``extra_suffix_delimiters``
- ``frozenset[str]``
- Adds separators that split suffix groups, e.g. ``" - "`` for
Expand Down
Loading