diff --git a/docs/design/decisions/M1-delimited-maiden-names.md b/docs/design/decisions/M1-delimited-maiden-names.md index f113f8b53..638fa15c3 100644 --- a/docs/design/decisions/M1-delimited-maiden-names.md +++ b/docs/design/decisions/M1-delimited-maiden-names.md @@ -1,10 +1,27 @@ # M1 — delimited maiden names -- 2026-07-03 (maiden-bucket design, landed via the v2 core, PR - #288) — the maiden reading of a delimiter pair is opt-in because +## Standing + +- No delimiter pair reads as maiden by default; a caller declares one in `maiden_delimiters`, because enclosure conventions vary — 2026-07-03 PR #288. +- A marker-led clause holding a word past its marker reads as maiden without the pair declared (M3), so what declaring a pair buys is a markerless clause and a clause of nothing but its marker: `Cherice J. (Johnson) Williams` reads nickname 'Johnson' by default and maiden 'Johnson' under a declared pair, and this rule still governs every clause in a declared pair — 2026-08-26 #335 (M3). +- A pair declared for both maiden and nickname names reads maiden, the overlap canonicalized out of the effective nickname set before parsing and through `apply_patch` too, silently; the v1 facade keeps v1's nickname-wins reading by subtracting on its side — 2026-07-18. +- The marker word inside a declared clause is dropped where the clause holds a word past the marker run (`Jane Smith (née Jones)` → maiden 'Jones'), and a clause of nothing but its marker keeps its words, Nee being an attested surname (`Jane Smith (Nee)` → maiden 'Nee') — 2026-08-04 #329; 2026-08-26 #434. +- Clauses are independent, each dropping or keeping its own marker (`Jane Smith (Nee) (Jones)` → maiden 'Nee Jones' under a declared pair), and the contentless `(née —)` alone parses to every field empty and `bool()` False — 2026-08-04 #329. +- Neighbour-scoping the drop is declined: it would read `Jane Smith nee Nee Jones` as maiden 'Jones' where it reads 'Nee Jones' — 2026-08-04 #329. +- A clause of nothing but a phrase marker keeps its words too, so `Maria Kowalska (z domu)` under a declared pair reads maiden 'z domu', a name nobody bears, emitted deliberately because the parser cannot know the pair did not enclose an unfamiliar surname — 2026-08-26 #434. + +Not standing: 2026-08-05 #329, the deferral of marker detection inside a nickname clause to #335, carried out when M3 landed (#335); its premise is corrected by 2026-08-26 #335; the differential gates both entries measure were retired in #664. + +Elsewhere: a marker followed by a comma is no marker in this rule's drop, nor a phrase marker the writer divided with a comma (`Maria Kowalska (z, domu Nowak)` and `Jane (née ,Jones) Smith` under a declared pair keep maiden 'z, domu Nowak' and 'née ,Jones') — decisions/N1's 2026-10-12 #653 entry; the delimiters trumping every reading inside them (`Jane Doe (nee Smith MA)` → maiden 'Smith MA', reporting nothing) — decisions/M2's 2026-09-19 (Derek), #533 review — THE DELIMITERS TRUMP entry; the phrase marker itself — decisions/M2's 2026-08-26 #434 entry. + +Open: none in this file. + +## Log + +- 2026-07-03 (maiden-bucket design, landed via the v2 core, PR #288) (where its opt-in boundary runs superseded in part by 2026-08-26 #335 (M3) below) — the maiden reading of a delimiter pair is opt-in because enclosure conventions genuinely vary; there is no default pair. - 2026-07-18 (cc7063f; Derek's proposal out of a docs-review pain point — routing parens to maiden used to require editing both buckets in tandem, and the nickname default's contents were not discoverable) — bucket overlap is canonicalized before parsing: a pair listed for maiden is dropped from the effective nickname set (maiden wins), and the public DEFAULT_NICKNAME_DELIMITERS constant landed with it. The v1 facade restores v1's nickname-wins reading by pre-subtracting on its side. Weighed and ruled the same day: maiden-wins applies THROUGH apply_patch too — a pack's maiden pair silently removes a user's unrelated explicit nickname pair; warn-on-removal was considered (a silent-failure review flagged the site) and rejected, the silence ruled intended. -- 2026-08-04 #329 (PR #331) — the marker word inside a delimited clause is dropped from a multi-word clause during grouping; the extraction itself keeps the whole enclosed span, so nothing is lost when there is no marker. Three decisions argued separately: the clause-size guard exists because Nee is an attested surname (Irish Ní/Nee, a Chinese romanization) — load-bearing, not defensive, and mutation-proven; clauses are independent — "(Nee) (Jones)" reads maiden "Nee Jones", each clause dropping or keeping its own marker (the first implementation leaked across clauses, a real defect); and the contentless "(née —)" alone now parses to every field empty and bool() False (an explicit alternative keeping maiden "née —" was rejected; pinned by the maiden_marker_delimited_content_free case). +- 2026-08-04 #329 (PR #331) (its "multi-word clause" condition and "clause-size guard" superseded in part by 2026-08-26 #434 below) — the marker word inside a delimited clause is dropped from a multi-word clause during grouping; the extraction itself keeps the whole enclosed span, so nothing is lost when there is no marker. Three decisions argued separately: the clause-size guard exists because Nee is an attested surname (Irish Ní/Nee, a Chinese romanization) — load-bearing, not defensive, and mutation-proven; clauses are independent — "(Nee) (Jones)" reads maiden "Nee Jones", each clause dropping or keeping its own marker (the first implementation leaked across clauses, a real defect); and the contentless "(née —)" alone now parses to every field empty and bool() False (an explicit alternative keeping maiden "née —" was rejected; pinned by the maiden_marker_delimited_content_free case). - M1 and M2 are disjoint by construction, which is the no-conflict guarantee: M2's walk covers joining structure that role-bearing words never enter, while M1's drop reaches only extracted content. Verified independently at review over 7,775 records: 967 diffs, every one in the maiden field. Declined: diff --git a/docs/design/decisions/M3-marker-led-clause.md b/docs/design/decisions/M3-marker-led-clause.md index 59b952ed2..b35a4d008 100644 --- a/docs/design/decisions/M3-marker-led-clause.md +++ b/docs/design/decisions/M3-marker-led-clause.md @@ -1,11 +1,28 @@ # M3 — the marker-led clause +## Standing + +- A bracketed clause whose content opens with a recognized marker and holds a word after it reads as the maiden name whichever bucket its pair sits in, under the default policy: `Jane Smith (née Jones)` → maiden 'Jones' — 2026-08-26 #335 (M3, PR on fix/335-maiden-marked-clause). +- The clause is extracted, not unmasked into M2's bare take, so the closing delimiter bounds it: `Jane (née Jones) Smith` → maiden 'Jones' (given 'Jane', family 'Smith' under the default order), where the bare `Jane née Jones Smith` reads maiden 'Jones Smith'; S1 takes suffix-shaped content first, so `Jane Smith (née Jr.)` reads suffix 'Jr.' and no maiden name, `née` a name word (the family under the default order) — 2026-08-26 #335, the MECHANISM. +- The condition is a word after the marker, not the marker alone, a marker with nothing after it marking nothing: `Jane Smith (née)` and `Jane Smith (Nee)` read nickname 'née' and 'Nee' by default, and `Jane Smith (Nee) (Jones)` nickname 'Nee Jones', so the division from M1 is one word against a marker plus a word — 2026-08-26 #335, the condition is a word AFTER. +- The word taken after the marker is not tested for being a name word, the bracket being the clause's right boundary, so a suffix word M2's bare take declines is the maiden name here: `Jane Smith (née PhD)` → maiden 'PhD', where the bare `Jane Smith née PhD` reads suffix 'PhD' and no maiden name, `née` a name word (the family under the default order) — 2026-08-26 #335, accepted consequence. +- The marker drop inside the clause is M1's mechanism and cites M1; rules/M3's `implemented:` names `_group.py` and `_vocab.py` beside `_extract.py` since #653 for its own comma clause, not for the drop — 2026-08-26 #335, rules/M3 lists. +- `_maiden_marked` asks a whitespace-split question of the clause's first word where tokenize asks a token-level one, and that divergence is what keeps group's drop pass for a non-maiden clause reachable; since #653 it lives on through rules/T2's nakaguro, pinned by `cases.py::marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname` (`佐藤 花子 "旧姓・田中" (旧姓 鈴木)` → nickname '旧姓 田中', maiden '鈴木'), so unifying the two tests orphans that filter — 2026-08-26 #335, the durable finding. +- A rule that extends where a vocabulary claim acts re-asks C-i of the whole set it reads; M3 made the marker vocabulary act on the first word of any bracketed clause, and the re-ask removed `roz` (`Rosalind Roz Smith` reads no maiden name, middle 'Roz' under the default order) — 2026-08-26 #335, M3 makes. +- Declaring `maiden_delimiters` is a declaration about the data, still needed for markerless and one-word clauses: `Cherice J. (Johnson) Williams` reads nickname 'Johnson' by default — 2026-08-26 #335, what configuring. + +Elsewhere: a marker followed by a comma, standing alone or glued to either word, is no marker (`Jane (née , Jones) Smith` → nickname 'née , Jones') — decisions/N1's 2026-10-12 #653 entry; the delimiters trumping every reading inside the clause (`Jane Doe (nee Smith MA)` → maiden 'Smith MA') — decisions/M2's 2026-09-19 (Derek), #533 review — THE DELIMITERS TRUMP entry; the bare take's first-word floor, which reads `Jane Smith née V` as maiden 'V' — decisions/M2's 2026-10-04 (Derek), #601 entry; `roz`'s exclusion, now on the word (`uv run python tools/vocab/query.py --word roz`) — decisions/vocabulary-exclusions' Excluded (MAIDEN_MARKERS) block. + +Open: the fullwidth-colon marker (`旧姓:佐藤` arriving as one word) is decisions/M2's Open line for #317. + +## Log + - 2026-08-26 #335 (M3, PR on fix/335-maiden-marked-clause) — a bracketed clause whose content opens with a recognized marker word and carries a word after it reads as the maiden name, whichever bucket the enclosing pair sits in. The parse already had the information: "Jane Smith née Jones" gave maiden "Jones" while "Jane Smith (née Jones)" gave nickname "née Jones", because ( ) is a nickname pair by default and nothing looked inside it. The clause says "maiden" out loud, so the caller should not have to say it in Policy. - 2026-08-26 #335 — the MECHANISM, and the issue proposed the other one. Masking the delimiters and letting M2's bare-marker rule consume the content would avoid a second implementation, and it was rejected on measurement: the two readings agree on a trailing clause and diverge on an interior one. "Jane (née Jones) Smith" reads given "Jane", family "Smith", maiden "Jones" under extraction; masking turns it into the bare "Jane née Jones Smith", which reads maiden "Jones Smith" and family "" (measured 2026-08-26), because M2's take runs to the end of the name. The closing delimiter is a boundary the writer typed and masking throws it away. Extraction was also the SMALLER change — extract already emits Role.MAIDEN regions and group already drops the marker inside one (#329), so this is a role swap at the existing emit site plus one predicate, in the branch S1 has already declined. That placement is what keeps S1 first, but be exact about the mechanism, because the obvious mutation does not test it: hoisting the swap above the _suffix_shaped call changes nothing at all (measured 2026-08-26), since the S1 branch never reads `role` -- it masks the two delimiter spans and hands the content to the token stream, and the role it would have carried is never consulted. What inverts the precedence is giving M3 the clause first, an early `extracted.append((Role.MAIDEN, inner))` ahead of the S1 test: that reads "Jane Smith (née Jr.)" as family "Smith", maiden "Jr." instead of family "née", suffix "Jr.", and M3's own example line for it fails. So the precedence is pinned, and by that example rather than by the branch shape. - 2026-08-26 #335 — the condition is a word AFTER the marker, not the marker alone. The reason is M1's: a one-word clause keeps its word because Nee is an attested surname (Irish Ní/Nee, and a Chinese romanization), and a marker with nothing after it marks nothing. Be precise about what pins it, because the obvious answer is wrong: M1's own boundary line carries the maiden-parens annotation, so it asserts the CONFIGURED reading and passes either way — built and measured 2026-08-26, a len(words) > 0 predicate still gives it maiden "Nee". What the loosened predicate actually breaks, measured by mutation 2026-08-26, is M3's own boundary example ("Jane Smith (née)" → nickname "née") and tests/v2/cases.py::maiden_marked_clause_one_word_stays_a_nickname in both runners — three pytest failures — AND the 2.1.0 differential gate, which goes red with three unexplained names: "Jane Smith (Nee)", "Jane Smith (Nee) (Jones)" and "Jane Smith (née)". The gate is the stronger signal and the one to look at first. The middle name is why: loosened, it reads maiden "Nee" and nickname "Jones", splitting one bracket pair into the maiden field and the next into the nickname field, which is the direct contradiction of M1's "two enclosures read as one maiden name". Anyone re-litigating the second-word test should check those, not M1's line. So the real division of labour is NOT markerless versus marker-led, which is how it was first written into rules.md and had to be corrected at review: it is one word versus a marker plus a word. "Jane Smith (Nee)" is marker-led and still reads nickname "Nee" by default, maiden "Nee" only with the pair configured (both measured 2026-08-26). -- 2026-08-26 #335 — accepted consequence: the word taken after the marker is not tested for being a name word, so M3 does not stop at a suffix word where M2's bare take does. "Jane Smith (née V)" reads maiden "V" while the bare "Jane Smith née V" reads suffix "V" and family "née" (both measured). The reason is structural rather than an oversight: M3 hands the WHOLE clause to Role.MAIDEN and the bracket is its right boundary, so the trailing-suffix rule never sees the word as trailing the name at all. Pinned as M3's Accepted line in rules.md, which puts "Jane Smith (née V)" in the differential corpus and classified in all three ledgers. -- 2026-08-26 #335 — rules/M3 lists implemented: nameparser/_pipeline/_extract.py alone, and that is deliberate rather than an omission. M3 decides a REGION'S ROLE; the marker drop it describes ("the marker itself dropped, as M1 drops it") is M1's mechanism in _group.py, which cites M1 and is governed by it. Adding _group.py to M3's list would mean inventing an M3 citation there for behavior M1 already owns — and test_doc_citations requires implemented: to equal the set of modules that cite the rule, so the two would have to move together. Do not re-open this. -- 2026-08-26 #335 — the durable finding, and the one worth reading before touching either side: _maiden_marked asks a WHITESPACE-SPLIT, _normalize'd question of the clause's first word, while tokenize and classify ask a token-level one. _normalize strips a trailing period but not a comma, so 'née,' is not a marker to M3 while the tokenizer splits the comma off and still tags the token vocab:maiden-marker. That divergence is the only thing keeping the "role is not Role.MAIDEN" branch of _group.group's clause-scoped drop pass reachable at all: without it, a marker-led NICKNAME clause could no longer exist, since M3 would have converted every one to MAIDEN before group ran. Anyone "unifying" the two tests would orphan that filter silently. Its pin was tests/v2/cases.py::marker_glued_to_punctuation_keeps_the_clause_a_nickname, with a maiden clause beside the nickname one because the drop pass is gated on the name holding a maiden region at all. The row that used to pin the filter, maiden_marker_delimited_beside_a_nickname_clause, lost the job to M3: both its clauses are marker-led, so both are maiden now and there is no contrast left in it. SUPERSEDED IN PART 2026-10-12 by #653: a clause's comma now stays on its word, so 'née,' is no marker to the tokenizer either and the comma row no longer reaches the branch; the divergence lives on through rules/T2's nakaguro, and the pin is cases.py::marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname (see #N1's #653 bullet). +- 2026-08-26 #335 (its bare `Jane Smith née V` contrast superseded in part by #601, decisions/M2, the bare take now reading maiden 'V' too; rules/M3's Accepted witness is `(née PhD)` since #762, the V row standing in the main fence) — accepted consequence: the word taken after the marker is not tested for being a name word, so M3 does not stop at a suffix word where M2's bare take does. "Jane Smith (née V)" reads maiden "V" while the bare "Jane Smith née V" reads suffix "V" and family "née" (both measured). The reason is structural rather than an oversight: M3 hands the WHOLE clause to Role.MAIDEN and the bracket is its right boundary, so the trailing-suffix rule never sees the word as trailing the name at all. Pinned as M3's Accepted line in rules.md, which puts "Jane Smith (née V)" in the differential corpus and classified in all three ledgers. +- 2026-08-26 #335 (its `_extract.py`-alone list superseded in part by #653, decisions/N1, which put M3's comma clause in `_group.py` and `_vocab.py`) — rules/M3 lists implemented: nameparser/_pipeline/_extract.py alone, and that is deliberate rather than an omission. M3 decides a REGION'S ROLE; the marker drop it describes ("the marker itself dropped, as M1 drops it") is M1's mechanism in _group.py, which cites M1 and is governed by it. Adding _group.py to M3's list would mean inventing an M3 citation there for behavior M1 already owns — and test_doc_citations requires implemented: to equal the set of modules that cite the rule, so the two would have to move together. Do not re-open this. +- 2026-08-26 #335 (its comma witness superseded in part by #653, the note at its end) — the durable finding, and the one worth reading before touching either side: _maiden_marked asks a WHITESPACE-SPLIT, _normalize'd question of the clause's first word, while tokenize and classify ask a token-level one. _normalize strips a trailing period but not a comma, so 'née,' is not a marker to M3 while the tokenizer splits the comma off and still tags the token vocab:maiden-marker. That divergence is the only thing keeping the "role is not Role.MAIDEN" branch of _group.group's clause-scoped drop pass reachable at all: without it, a marker-led NICKNAME clause could no longer exist, since M3 would have converted every one to MAIDEN before group ran. Anyone "unifying" the two tests would orphan that filter silently. Its pin was tests/v2/cases.py::marker_glued_to_punctuation_keeps_the_clause_a_nickname, with a maiden clause beside the nickname one because the drop pass is gated on the name holding a maiden region at all. The row that used to pin the filter, maiden_marker_delimited_beside_a_nickname_clause, lost the job to M3: both its clauses are marker-led, so both are maiden now and there is no contrast left in it. SUPERSEDED IN PART 2026-10-12 by #653: a clause's comma now stays on its word, so 'née,' is no marker to the tokenizer either and the comma row no longer reaches the branch; the divergence lives on through rules/T2's nakaguro, and the pin is cases.py::marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname (see #N1's #653 bullet). - 2026-08-26 #335 — M3 makes the marker vocabulary act in a THIRD position. M2's claim acts on the word after a name word; M1's on the content of a configured pair; M3's now on the first word of any bracketed clause, under the default policy. #vocabulary-collisions C-i is keyed on the position a claim acts on, so extending where a rule acts re-asks C-i of the whole set it reads, and nothing in the process prompts that — the rule was designed, reviewed and approved without the set being re-examined. Review afterwards found one entry that fails C-i, the Czech abbreviation roz, and its disposition is the Excluded (MAIDEN_MARKERS) block in decisions/vocabulary-exclusions, which is keyed to the vocabulary set rather than to any rule: it was removed. Two things worth separating, since the finding surfaced during this work and is easy to file under it — the defect was M2's and predates #335 (bare "Rosalind Roz Smith" lost its family name on master), and M3's own boundary already excluded the common one-word "(Roz)" spelling. What belongs to M3 is only the widening, and the lesson is the general one: re-ask C-i of a vocabulary set whenever a rule extends where its claim acts. - 2026-08-26 #335 — what configuring maiden_delimiters still buys, since M3 removes the commonest reason to reach for it: markerless clauses, and one-word clauses. "Cherice J. (Johnson) Williams" is a real US convention and a corpus name, and nothing in that clause says maiden, so it stays a nickname by default — only a caller who knows their data can say otherwise. The knob is a declaration about the DATA; M3 is a reading of what a clause declares about itself. diff --git a/docs/design/decisions/M4-maiden-marker-makes-the-lone-name-word-the-family.md b/docs/design/decisions/M4-maiden-marker-makes-the-lone-name-word-the-family.md index 95c6cfbcb..aa5c8e916 100644 --- a/docs/design/decisions/M4-maiden-marker-makes-the-lone-name-word-the-family.md +++ b/docs/design/decisions/M4-maiden-marker-makes-the-lone-name-word-the-family.md @@ -1,13 +1,32 @@ # M4 — a maiden marker makes the lone name word the family +## Standing + +- A maiden name beside exactly one name word makes that word the family name, under every name order: `Smith née Jones` → family 'Smith', maiden 'Jones', where through 2.1 it read given 'Smith' with the family empty. The rule is H1's sibling, so a titled name is H1's at both outcomes (`Dr. Jane née Smith` → family 'Jane'; `Sir John née Jones` keeps given 'John') — 2026-08-27 #445 (M4, PR on fix/445-maiden-marked-lone-name). +- O4 decides nothing at one name word, so the reading M4 replaces is O5's convention, a guess fixed in advance, and M4 is an exception to it rather than a contradiction of it — 2026-08-27 #445, O4 was SILENT. +- M4 changes only what position decided: a word the vocabulary claims as a given name keeps the reading its position gives it (`abd née Jones` → given 'abd' under the default order, family under either family-first order), and so does a word read as an initial (`J. née Jones Smith V` → given 'J.' by default), both read off tags classify recorded — 2026-08-27 #445, the two carve-outs. +- M4 counts what the take leaves, not where the marker stood, so the bracketed spelling restores 1.4.0's family (`Smith (née Jones)` → family 'Smith', maiden 'Jones') and the interior spelling is a reading no earlier version gave (`Jane née Jones Smith` → family 'Jane', maiden 'Jones Smith') — 2026-08-27 #445, the two halves. +- A nickname holds nothing off: where N3 declines, its count not setting a suffix aside, M4 names the family (`'Smitty' Jones née Smith Jr.` → family 'Jones', suffix 'Jr.', maiden 'Smith') — 2026-08-27 #445, a precedence claim. +- `tests/v2/test_parser.py::test_a_maiden_clause_changes_nothing_else` asserts M4's flip over the corpus rather than skipping the names it moves: family takes what given held and every other field stands still — 2026-08-27 #445, the corpus-wide. +- M4 is keyed on the maiden NAME, not on a marker, so a clause in a declared pair counts as a marked one does: `Smith (Jones)` under `Policy(maiden_delimiters=frozenset({("(", ")")}))` → family 'Smith', maiden 'Jones' — 2026-08-27 #445 (review round), M4 is keyed. +- M4 counts GIVEN tokens where H1 tests which roles are empty, so a joined name unit counts as its words and the two disagree, recorded as a gap and left open: `Dr. Dean of Chemistry` → family 'Dean of Chemistry', while `Dean of Chemistry née Jones` keeps given 'Dean of Chemistry' — 2026-08-27 #445 (review round), a known gap. + +Not standing: the ledger markers the entry corrected, that ledger having been retired in #664 — 2026-08-27 #445, what the ledger's; the re-measurement of #449's population the entry promised, taken in decisions/O5's #449 entry — 2026-08-27 #445, the relationship to #449. + +Elsewhere: a marker counts only behind a surname, so `'Smitty' Jones Jr. née Smith` takes no clause and reads no maiden name (given 'Jones', middle 'Jr. née', family 'Smith' under the default order) — decisions/M2's 2026-10-04 (Derek), #601 entry; the `initial` tag that carve-out reads is no longer purely a shape claim — decisions/P8's 2026-09-13 #383/#479, AMENDS entry; a one-case bare letter is the initial its capital is (`j nee smith` → given 'j' under the default order) — decisions/T4's 2026-10-18, #723 entry; the maiden name no longer making H1 decline — decisions/H1's 2026-08-25 (#410) entry; the report a lone name word with nothing to decide it carries — decisions/O5. + +Open: none in this file. + +## Log + - 2026-08-27 #445 (M4, PR on fix/445-maiden-marked-lone-name) — Derek's rationale, in his terms: normally if there is only one name we assume it is a given name because we have to pick one, but maiden markers mark a previous surname before marriage, so it would not make sense to include one if there was no other surname for it to clarify — and given that, when a marker leaves only one name word we switch the assumption and read that word as the family name. `parse("Smith née Jones")` reported given 'Smith' with an EMPTY family through 2.1 and reads family 'Smith', maiden 'Jones' since. The rule is written in post_rules as H1's sibling and placed directly under it, so the interaction is decidable by reading rather than by running: where H1 fired there is no GIVEN left for M4 to move, and where H1 declined because the title addresses by given name ("Sir John née Jones") the `not titles` guard keeps M4 off the same word. A titled name is H1's at both outcomes, which is what keeps H1's given-name-title carve-out intact. - 2026-08-27 #445 — the third time in the 2.2 bundle that a name carrying a maiden clause reported no family, and the three share a SYMPTOM rather than a cause. State that carefully, because the first draft of this entry claimed one cause for all three and was wrong. #410 (H1): a title plus one name word reported no family the moment a suffix, nickname or maiden name stood beside it. #411 (P5's reserve): the bound given-name join counted the marker and the maiden name as words it could spend, and spent the family name. Those two ARE one cause -- a count that included words which are no part of the name -- and the grep that would find a fourth is a rule counting name words without first asking which of them are name words. #445 is not that: it changes no count anywhere (post_rules gains a new block, assign gains only a comment), and `parse("Smith")` with no clause beside it at all already read given 'Smith' with an empty family, so nothing was being swallowed. Its cause is the one the next entry gives: O4 decides nothing at one name word, and the reading came from a convention nobody had written down. The site that grep would miss is `_name_positions`'s `count == 1` branch, silent for every order and every name -- which is exactly what O5 now records. - 2026-08-27 #445 — O4 was SILENT at exactly one name word, which is why the shipped reading had no rule to point at and why O5 now exists. O4 reads a name by comparing where its words stand ("the first name word is the given name, the last is the family name"); with one word the first IS the last, so there is nothing to compare and the rule decides nothing. O5 records the reading as the convention it is — a guess fixed in advance so that the same input always reads the same way, not a determination about the word — and names H1, N3 and M4 as the rules that DO decide such a name. Written that way deliberately: a rule asserting "a lone name word is the given name" as a fact would have made #445 a contradiction of the documented behavior instead of an exception to a documented guess, and would make #449 one too. - 2026-08-27 #445 — the two carve-outs are not inventions, and both rest on mechanisms.md#TWO-LAYER-ASSIGN: the positional layer never overrides a vocabulary claim, and M4 changes only what POSITION decided. A word the vocabulary claims as a given name keeps that reading ('abd née Jones' — `vocab:bound-given`), and so does a word written as an initial ('J. née Jones Smith V' — the `initial` shape tag; amended 2026-09-13 under P8: the tag is no longer purely a shape claim). Both are read off tags classify already recorded rather than off a predicate of M4's own, and both witnesses are real corpus names rather than constructed cases, so each carve-out has something in the differential that would notice its loss. Mutation-checked on a scratch copy: dropping the `initial` carve-out fails that name's case row, its facade twin, M4's own boundary example and the pre-existing assertion in test_the_chain_and_the_walk_stop_where_the_peel_begins; dropping `vocab:bound-given` fails the other name's two runners, M4's boundary, P5's own doc example, O5's own `abd née Jones` line and the corpus-wide maiden-clause property on 'abdul' -- six in all, the O5 line having been added by the review round, and the list is exhaustive as re-run against the final tree. - 2026-08-27 #445 — the two halves of Derek's answer differ in their relationship to 1.4.0, and a reader should not have to re-derive it. The bracketed spelling RESTORES v1: `Smith (née Jones)` read family 'Smith', nickname 'née Jones' on 1.4.0, 2.0.0 and 2.1.0 alike, and now reads family 'Smith', maiden 'Jones' — the clause changes hands and the family name stays put, so the 1.4.0 gate's diff on that name SHRANK to {nickname, maiden}. The interior spelling is a NEW reading: `Jane née Jones Smith` read first 'Jane', middle 'née Jones', last 'Smith' on 1.4.0 and reads family 'Jane', maiden 'Jones Smith' now, the real surname being inside the maiden value by M2's greedy take. That the marker stands inside the name changes nothing for M4, which counts what the take LEAVES rather than where the marker stood — one name word left that way is one name word, which is the widest half of the decision and the half no earlier version agrees with. -- 2026-08-27 #445 — a precedence claim corrected while drafting, and it is exactly the class this bundle keeps producing. The first draft of M4 said a name carrying a nickname is N3's rather than this rule's. Measured false: `'Smitty' Jones Jr. née Smith` reads family 'Jones', because N3's count does not set a suffix aside, so N3 declines and M4 fires. A nickname holds nothing off — where N3 has already named the family M4 finds nothing left to move, and where N3 declined M4 names it. The general lesson is the one #410 and #411 already taught from the other side: a rule that counts name words and a rule that counts something else will disagree at the edges, and the disagreement is only visible if the interaction is measured rather than asserted. +- 2026-08-27 #445 (its `'Smitty' Jones Jr. née Smith` witness superseded in part by #601, decisions/M2, a marker behind a credential being an ordinary word) — a precedence claim corrected while drafting, and it is exactly the class this bundle keeps producing. The first draft of M4 said a name carrying a nickname is N3's rather than this rule's. Measured false: `'Smitty' Jones Jr. née Smith` reads family 'Jones', because N3's count does not set a suffix aside, so N3 declines and M4 fires. A nickname holds nothing off — where N3 has already named the family M4 finds nothing left to move, and where N3 declined M4 names it. The general lesson is the one #410 and #411 already taught from the other side: a rule that counts name words and a rule that counts something else will disagree at the edges, and the disagreement is only visible if the interaction is measured rather than asserted. - 2026-08-27 #445 — what the ledger's `# revisit when #445 lands` markers bought, and what they got wrong. Four blocks carried them, and each predicted that a "keep the family" fix would leave the diff a subset of the fields its rule already declared, so the rule would go on explaining the name and the change would be absorbed with the gate green. That was wrong in a way the rule's own shape settles: M4 MOVES the one name word rather than adding one, so `given` empties as `family` fills, the diff outgrows every declaration, and eight names arrived UNEXPLAINED at all three baselines. Two counts run through this and they are different sets, so name which is which: NINE corpus names change reading, of which eight arrive as new diffs — the ninth, 'Smith (née Jones)', has its 1.4.0 diff SHRINK instead, its family agreeing with v1 again. The four markers between them named NINE names to re-measure, of which seven moved: the two that did not are M4's carve-outs, and the two movers the markers could not name were not corpus names until this change's own examples added them. The markers earned their keep all the same — they named the right rule, the right field and very nearly the right set — so they are corrected in place rather than deleted, and the correction says what actually happened. THREE of the four moved in the opposite direction from their own prediction, and getting that number right took two passes. The fix(#335) rule for 'Smith (née Jones)' appears in all three ledgers, and every copy feared absorbing a WIDENED diff while the diff in fact SHRANK to {nickname, maiden}. The first pass narrowed only the 1.4.0 copy, on a measurement of that wheel, and left the other two at four fields on the reasoning that 2.0.0 and 2.1.0 'read `given`, and the baseline cannot move'. They did not: only the BARE spelling ever read `given`, and this rule holds the bracketed one, which reads family 'Smith', nickname 'née Jones' on every released version. So two rules stood over a two-field diff declaring four, and classify's subset test would have explained a `given`/`family` regression on that name in silence -- the absorption those very notes existed to prevent, surviving the round that answered them. A third reviewer found it by instrumenting the comparator to dump real diff-field sets, and that is the lesson worth keeping: ledger prose cannot be checked by reading it, and the round that corrects a marker is exactly where a wrong premise gets written down with confidence. -- 2026-08-27 #445 — the corpus-wide property test is the repair worth copying. tests/v2/test_parser.py's `test_a_maiden_clause_changes_nothing_else` asserts over the corpus that appending " née Jones" adds a maiden name and moves no other field, and M4 falsifies it for fourteen names. Skipping them would have bought a green suite and lost the check; instead the test computes M4's guard from the base parse's tokens and asserts the flip — family takes what `given` held, every other field standing still — so the class that used to be a hole is now the strongest witness the rule has, fourteen names against the six rows cases.py carries. It is also what executes M4's nickname precedence, `'Smitty' Jones Jr.` being one of the fourteen. +- 2026-08-27 #445 (its `'Smitty' Jones Jr.` witness and the count of fourteen superseded in part by #601, decisions/M2) — the corpus-wide property test is the repair worth copying. tests/v2/test_parser.py's `test_a_maiden_clause_changes_nothing_else` asserts over the corpus that appending " née Jones" adds a maiden name and moves no other field, and M4 falsifies it for fourteen names. Skipping them would have bought a green suite and lost the check; instead the test computes M4's guard from the base parse's tokens and asserts the flip — family takes what `given` held, every other field standing still — so the class that used to be a hole is now the strongest witness the rule has, fourteen names against the six rows cases.py carries. It is also what executes M4's nickname precedence, `'Smitty' Jones Jr.` being one of the fourteen. - 2026-08-27 #445 (review round) — M4 is keyed on the maiden NAME, not on a marker, and the statement was corrected to say so after two reviewers measured the same defect independently. The guard tests for a token in the MAIDEN role, and M1's caller-configured pair produces one with no marker anywhere: under `Policy(maiden_delimiters=frozenset({("(", ")")}))`, `Smith (Jones)` reads family 'Smith', maiden 'Jones', and so do M1's own boundary examples `Smith (Nee)` and `Smith (z domu)`. The BEHAVIOUR is right — the rationale transfers to a declared pair without a word of change, since what announces a former surname is the clause, not the vocabulary that marked it — so the fix was to the rule, which had said "a recognized maiden marker that takes its name and leaves exactly one name word" and now says "a maiden name standing beside exactly one name word". That covers M1, M2 and M3 uniformly, M1 is in M4's `interacts:` and M4 in M1's, and the configured path has an example line of its own. The general lesson is the one this bundle keeps teaching from a new angle: a statement written from the case that prompted the change describes that case, not the code, and only measuring the OTHER paths into the same guard finds the difference. - 2026-08-27 #445 (review round) — a known gap recorded rather than closed, the way H1 carries its own. M4's guard counts GIVEN tokens; H1 counts nothing at all (it tests which roles are unoccupied). So a name word another rule has joined counts as several here and M4 declines where H1 fires: `Dr. Dean of Chemistry` reads family 'Dean of Chemistry' while `Dean of Chemistry née Jones` keeps given 'Dean of Chemistry', the connective join (P3) having left three GIVEN tokens. rules/P3 says the joined part is one name word wherever another rule counts them, so the two siblings genuinely disagree and this is a gap rather than a boundary. Not closed here: widening the count to units moves zero corpus names (measured), and a behaviour change nobody has approved does not belong in a branch whose blast radius was settled. The sibling claim in the code comment and in this entry is narrowed to match — sibling except in what it counts. - 2026-08-27 #445 — the relationship to #449, briefly, because the two look like the same question and are not. #449 asks whether a lone name word with nothing to decide it should REPORT an ambiguity; #445 removes a class of names from that population by giving them something that decides. So #445 reduces #449's reach rather than competing with it, and #449's measurement — a naive "one name word, nothing decided it" condition fires on 19 of 1,085 corpus names, only three or four of them the case it is about — is re-taken after this lands. O5 cites #449 in prose and deliberately carries no `deviates:` marker: that marker asserts an intended output the runner then checks strictly, and #449 had not decided one. It has since — 2026-09-07, decisions/O5 — and the answer arrived as ordinary example lines rather than as a marker, the report being shipped behavior and not a deviation. The re-measurement this bullet promises is taken there: the naive condition is wider than either number suggests, firing on 143 of the 1123 corpus names, and O5's own population is 27. diff --git a/docs/design/decisions/README.md b/docs/design/decisions/README.md index c76743fa3..cc9c94346 100644 --- a/docs/design/decisions/README.md +++ b/docs/design/decisions/README.md @@ -11,7 +11,7 @@ Entry conventions: 2. **A factual error is corrected in place**, with a note saying what was wrong — a date, an attribution, a measurement or a pointer. decisions/C1's 2026-07-12/13 entry, whose parenthetical moved a misdated credit, is the shape. 3. **Compaction, on a stated trigger.** A superseded entry is cut to a tombstone: its date, issue, verdict line and `SUPERSEDED by`, plus whatever it recorded that the superseding entry did not replace — a gap it found standing before its change, or an option it declined that the reversal does not answer, stays beneath the tombstone (decisions/T4's #731); a superseded passage inside a live entry is cut to its decision and what replaced it. A measurement paragraph (what moves, cost, blast radius) is cut to one line naming its population, counts, baseline and PR once its change has shipped in a release. The full text stays in git and in the PR the entry cites. #728 is the worked compaction. A harvested entry (below) keeps its framing line through compaction, a tombstone included, and its measured-vs-remembered markings wherever its measurements survive. A trap #759's review found: a one-line replacement carries each count with the classification the old text gave that count and no other (decisions/S2's #614 line had given a whole grid the classification measured on one slice of it). 4. **A per-word fact that moves into the vocabulary's TOML is REPLACED by a query that finds it** (`uv run python tools/vocab/query.py --issue 661`, `--excluded generation`), not left beside a dated "moved" note, which would keep two copies to drift. Only the per-word sentences go; the decision, its criterion and its measurement stay as written. Sanctioned since 2026-10-18 (decisions/vocabulary-model). -- **`## Standing`**, at the head of a file: the one part edited freely. One line per decision in force, in the present tense, ending with the entry that decided it by its exact date and the first issue its head names (`— 2026-10-06 #613`), or by its date alone where the head names none; where several entries share that date and issue, the pointer goes on with the words its entry's head continues with (`— 2026-09-14 (Derek)`, `— 2026-10-18 #718 review`), the longest such continuation winning and a date inside those words being part of them; a pointer with no words names the entry whose head ends there, and one whose words continue no head there fails, since nothing tells a typo from prose, so prose after such a pointer goes after a `.` or `;` (the words end there, or where the next pointer's date begins); where two heads are the same words, the pointer goes on with the words its entry's text opens with (`— 2026-09-13 #383/#479, AMENDS`), a key rather than prose; and a pointer still naming more than one entry fails as ambiguous (a parenthesized supersession note in a head is not read), plus a `Not standing:` line for entries whose plan was carried out or whose schedule events overtook — a decision reversed by a later DECISION is marked under 1 instead and an `Open:` line for each open question. It indexes the log and is never a decision itself: a Standing line with no entry behind it, or only a superseded one, fails `tests/v2/test_design_docs.py::test_every_standing_line_points_at_a_live_entry`, and changing a decision is still a new entry, which updates Standing in the same diff. The entries follow under `## Log`. An entry is a log bullet opening with its date, bare or in bold (`- **2026-10-14 #679 — …**`); a file's title founds no entry, so a bundle recorded as undated bullets under a dated title gains, when its block is written, one dated line opening its Log and naming the bundle, its verdict the title's words (`- 2026-09-10 #322/#323 — WHICH CHARACTERS ARE A PERIOD ON CJK TEXT: the bullets below.`). It dates what the title already dates, so it adds no decision and edits no entry. Where a later decision reversed part of the bundle, its head says which, under edit kind 1. That note is for the reader: a pointer never reads a parenthesized note containing "superseded" (edit kind 1's wording), so a Standing line resting on a bundle is checked by hand against what the bundle's later entries and other files reversed, as for any entry superseded in part. Every decisions file created after C1's block landed (2026-10-19) opens with one, the cheap moment to write it being the file's first entry; the older files are listed in `tests/v2/decisions_without_standing.json`, decisions/C12 and decisions/vocabulary-model among them though they too were created that day, before the rule. `test_a_decisions_file_off_the_list_opens_with_standing` fails on a file off that list with no block (the heading on a line of its own; the words quoted in prose do not count), on a listed file that has gained one, on a listed key no file carries, and on a listed key the list did not hold when the rule landed (a fixed set in `tests/v2/design_docs.py`, its size asserted), so the list never holds a key it did not start with: a new file can join it neither alone nor in exchange for one that leaves. The block stands ahead of every `## Log` heading; a `## Log` before it is a finding, and so is a heading that misspells `## Standing` (a trailing space, a different level), which would otherwise switch the pointer checks off. A decision of another rule's that this log is the record of stays a bullet naming its rule (decisions/C13's line for rules/C2, decisions/P9's for rules/S5); one recorded under another key is an `Elsewhere:` line pointing at that entry, not a bullet. Whether the older files get one is #762. A file of Declined, Excluded, Open, removal or mapping lines is a reference list instead, indexed under the Reference lists heading at the end of the index: it carries no block. A listed file carrying a block, taking a log's shape (a dated entry, a bullet opening with a tag and a date as `- (A) 2026-08-17 — …`, or a dated title) or sitting on the exempt list is a finding, as is a listed key the exempt list did not hold when the rule landed (2026-10-21, #762). Whether a file in none of those shapes is a list or a log is a judgment the check cannot make, made in review. 3-0-reevaluations is not one: other files amend its lines without its saying so (decisions/cjk-comma-demotion scopes its FAMILY_COMMA line), the shape a Standing block's `Not standing:` and `Elsewhere:` lines are for. Five files split out on 2026-10-19 (C13, C14, P8, P9, T5) showed the rule was needed: none had a block, though C1's had landed the same day. **Before writing a line, look for what reversed it elsewhere.** A decision can be overturned by an entry in another file, which the log it stands in never mentions; #776 found two lines wrong that way, one while writing and one in review (P9's #578 note, reversed by decisions/S8's #602; P8's subset, widened by decisions/P3's #397), and #762's first batch (2026-10-21) found five entries whose heads did not say what had replaced part of them (decisions/S6's accepted `abdul Smith Jr Ma`, reversed by decisions/S7's #544; decisions/S4's `Doe, Jane nee Smith MA`, by decisions/M2's #601; decisions/H3's `Dr Smith V`, by decisions/S10's #718, caught only in that batch's PR review; decisions/P5's view comparison and decisions/S10's one-case `v`, by their own files' #614 and #721). So grep the whole folder for each issue and example a line cites, parse each example on the tree, and where a later decision reversed part of an entry mark that entry's head under 1, so the log says what Standing says. +- **`## Standing`**, at the head of a file: the one part edited freely. One line per decision in force, in the present tense, ending with the entry that decided it by its exact date and the first issue its head names (`— 2026-10-06 #613`), or by its date alone where the head names none; where several entries share that date and issue, the pointer goes on with the words its entry's head continues with (`— 2026-09-14 (Derek)`, `— 2026-10-18 #718 review`), the longest such continuation winning and a date inside those words being part of them; a pointer with no words names the entry whose head ends there, and one whose words continue no head there fails, since nothing tells a typo from prose, so prose after such a pointer goes after a `.` or `;` (the words end there, or where the next pointer's date begins); where two heads are the same words, the pointer goes on with the words its entry's text opens with (`— 2026-09-13 #383/#479, AMENDS`), a key rather than prose, and so it does where another head of that date and issue continues past those words (decisions/M3's `— 2026-08-26 #335, the MECHANISM`, beside its `#335 (M3, …)` head); and a pointer still naming more than one entry fails as ambiguous (a parenthesized supersession note in a head is not read), plus a `Not standing:` line for entries whose plan was carried out or whose schedule events overtook, its pointers read and failing as a Standing line's do except that they may name a superseded entry — a decision reversed by a later DECISION is marked under 1 instead and an `Open:` line for each open question. It indexes the log and is never a decision itself: a Standing line with no entry behind it, or only a superseded one, fails `tests/v2/test_design_docs.py::test_every_standing_line_points_at_a_live_entry`, and changing a decision is still a new entry, which updates Standing in the same diff. The entries follow under `## Log`. An entry is a log bullet opening with its date, bare or in bold (`- **2026-10-14 #679 — …**`); a file's title founds no entry, so a bundle recorded as undated bullets under a dated title gains, when its block is written, one dated line opening its Log and naming the bundle, its verdict the title's words (`- 2026-09-10 #322/#323 — WHICH CHARACTERS ARE A PERIOD ON CJK TEXT: the bullets below.`). It dates what the title already dates, so it adds no decision and edits no entry. Where a later decision reversed part of the bundle, its head says which, under edit kind 1. That note is for the reader: a pointer never reads a parenthesized note containing "superseded" (edit kind 1's wording), so a Standing line resting on a bundle is checked by hand against what the bundle's later entries and other files reversed, as for any entry superseded in part. Every decisions file created after C1's block landed (2026-10-19) opens with one, the cheap moment to write it being the file's first entry; the older files are listed in `tests/v2/decisions_without_standing.json`, decisions/C12 and decisions/vocabulary-model among them though they too were created that day, before the rule. `test_a_decisions_file_off_the_list_opens_with_standing` fails on a file off that list with no block (the heading on a line of its own; the words quoted in prose do not count), on a listed file that has gained one, on a listed key no file carries, and on a listed key the list did not hold when the rule landed (a fixed set in `tests/v2/design_docs.py`, its size asserted), so the list never holds a key it did not start with: a new file can join it neither alone nor in exchange for one that leaves. The block stands ahead of every `## Log` heading; a `## Log` before it is a finding, and so is a heading that misspells `## Standing` (a trailing space, a different level), which would otherwise switch the pointer checks off. A decision of another rule's that this log is the record of stays a bullet naming its rule (decisions/C13's line for rules/C2, decisions/P9's for rules/S5); one recorded under another key is an `Elsewhere:` line pointing at that entry, not a bullet. Whether the older files get one is #762. A file of Declined, Excluded, Open, removal or mapping lines is a reference list instead, indexed under the Reference lists heading at the end of the index: it carries no block. A listed file carrying a block, taking a log's shape (a dated entry, a bullet opening with a tag and a date as `- (A) 2026-08-17 — …`, or a dated title) or sitting on the exempt list is a finding, as is a listed key the exempt list did not hold when the rule landed (2026-10-21, #762). Whether a file in none of those shapes is a list or a log is a judgment the check cannot make, made in review. 3-0-reevaluations is not one: other files amend its lines without its saying so (decisions/cjk-comma-demotion scopes its FAMILY_COMMA line), the shape a Standing block's `Not standing:` and `Elsewhere:` lines are for. Five files split out on 2026-10-19 (C13, C14, P8, P9, T5) showed the rule was needed: none had a block, though C1's had landed the same day. **Before writing a line, look for what reversed it elsewhere.** A decision can be overturned by an entry in another file, which the log it stands in never mentions; #776 found two lines wrong that way, one while writing and one in review (P9's #578 note, reversed by decisions/S8's #602; P8's subset, widened by decisions/P3's #397), and #762's first batch (2026-10-21) found five entries whose heads did not say what had replaced part of them (decisions/S6's accepted `abdul Smith Jr Ma`, reversed by decisions/S7's #544; decisions/S4's `Doe, Jane nee Smith MA`, by decisions/M2's #601; decisions/H3's `Dr Smith V`, by decisions/S10's #718, caught only in that batch's PR review; decisions/P5's view comparison and decisions/S10's one-case `v`, by their own files' #614 and #721). So grep the whole folder for each issue and example a line cites, parse each example on the tree, and where a later decision reversed part of an entry mark that entry's head under 1, so the log says what Standing says. - **`Retired:`**, for a rule taken out of rules/: a line of its own in the rule's decisions file, opening `Retired:` and naming the date, the issue and what replaced the rule (`Retired: 2026-11-02 #800 — folded into C1.`), in the Standing block where the file has one, beside the dated entry that decided it. The ID is then never reused: `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying it, a `Retired:` line in a file keyed by no rule ID, and a line that misspells `Retired:` (bold, a list marker or number, lowercase), which would switch the refusal off for its ID. No rule had been retired when the check landed (2026-10-20), so it guarded nothing on the tree until the first one is; its synthetic case is what shows it bites. - **Harvested entries** (content landed from a session's report) keep their provenance: a "measurements are that session's, spot-checked at landing" framing line, the contributor's measured-vs-remembered markings where they matter, and — the hard-won one — a flagged uncertainty is resolved by reading the source artifact, never by inference from the neighboring argument. - **`Declined:`** — proposals rejected WITH the evidence that killed them. Resolved-as-no is a decision; without a home for it, the next person re-derives the rejected proposal and its measurement. diff --git a/docs/design/rules/M3-marker-led-clause.md b/docs/design/rules/M3-marker-led-clause.md index ff1e817a0..a515ec6af 100644 --- a/docs/design/rules/M3-marker-led-clause.md +++ b/docs/design/rules/M3-marker-led-clause.md @@ -12,6 +12,7 @@ "Jane Smith (née Jones)" → maiden="Jones" "Jane (née Jones) Smith" → family="Smith" "Maria Kowalska (z domu Nowak)" → maiden="Nowak" +"Jane Smith (née V)" → maiden="V" ``` **Except:** @@ -37,9 +38,10 @@ **Accepted:** -- The word taken after the marker is not tested for being a name word, so unlike M2's bare take this one does not stop at a suffix word — the same two words read one way bracketed and another way bare. This does not contradict the S1 example under Precedence: S1 asks whether the WHOLE clause is suffix-shaped, which the trailing period makes true of the one and false of the other, so only the V clause reaches this rule. +- The word taken after the marker is not tested for being a name word, so unlike M2's bare take this one does not stop at a suffix word — the same two words read one way bracketed and another way bare. This does not contradict the S1 example under Precedence: S1 asks whether the WHOLE clause is suffix-shaped, which the trailing period makes true of the one and false of the other, so only the PhD clause reaches this rule. ``` - "Jane Smith (née V)" → maiden="V" + "Jane Smith (née PhD)" → maiden="PhD" + "Jane Smith née PhD" → suffix="PhD" · boundary ``` --- diff --git a/tests/v2/decisions_without_standing.json b/tests/v2/decisions_without_standing.json index ec1cceb55..e194fd743 100644 --- a/tests/v2/decisions_without_standing.json +++ b/tests/v2/decisions_without_standing.json @@ -16,10 +16,7 @@ "C9", "D1", "D2", - "M1", "M2", - "M3", - "M4", "N1", "N2", "N3", diff --git a/tests/v2/design_docs.py b/tests/v2/design_docs.py index 866070f38..b3be9fe52 100644 --- a/tests/v2/design_docs.py +++ b/tests/v2/design_docs.py @@ -175,7 +175,8 @@ def reused_id_findings(rule_ids: set[str], # candidate whose head ends there, and one whose words continue no # candidate's head is a finding, since nothing tells a typo from prose # there. Where heads are the same words (twins), the words past the -# head pick the entry whose text opens with them +# head pick the entry whose text opens with them, also where another +# head of that date and issue continues past those words # (`— 2026-09-13 #383/#479, AMENDS`): there the words are a key, not # prose. A pointer still naming more than one entry is ambiguous. An # entry whose text after the head opens with `SUPERSEDED by` is @@ -296,10 +297,13 @@ def _resolve(line: str, m: re.Match[str], if cut: hits, used = cut, len(said) elif said: + # Words past a bare head are a twin key where two or more + # heads end at the pointer, though another head continues. bare = [e for e in found if not tails[e]] - if len({_clean(h) for h, _ in found}) > 1 or not bare: + if not bare or (len(bare) < 2 + and len({_clean(h) for h, _ in found}) > 1): return _Resolved([], 0, said) - hits, used = found, 0 + hits, used = bare, 0 else: hits = [e for e in found if not tails[e]] or found used = 0 @@ -357,8 +361,10 @@ def standing_findings(text: str) -> list[str]: with no `— ` pointer, any pointer from there to the line's end naming no entry (dates match exactly), carrying words that continue no candidate's head where its date and issue are shared, - naming more than one (ambiguous), or naming a `SUPERSEDED by` entry, a - Not-standing pointer to no entry, a Standing with no Log after it. + naming more than one (ambiguous), or naming a `SUPERSEDED by` entry; + a Not-standing pointer naming no entry, carrying such words or + naming more than one (a superseded entry is what it may name); a + Standing with no Log after it. Empty for a file with no Standing block, whose absence is standing_roster_findings' question.""" head = _STANDING_HEADING.search(text) @@ -412,6 +418,11 @@ def standing_findings(text: str) -> list[str]: findings.append( f"not-standing names no entry {m['date']} " f"#{m['num']}") + elif len(got.hits) > 1: + findings.append( + f"ambiguous not-standing pointer {m['date']} " + f"#{m['num']}: add the words its entry's head " + "continues with") return findings diff --git a/tests/v2/test_design_docs.py b/tests/v2/test_design_docs.py index 8e22a83d8..72e911eec 100644 --- a/tests/v2/test_design_docs.py +++ b/tests/v2/test_design_docs.py @@ -148,7 +148,10 @@ def name(cid: str) -> str | None: "- 2026-09-24 — SUPERSEDED IN PART: the mark.\n" "- 2026-10-03 #549 — SUPERSEDED: the skip is reversed.\n" "- 2026-10-04 #611 (in part superseded by #613) — FILED.\n" - "- 2026-10-06 #613 — DECIDED ONCE.\n") + "- 2026-10-06 #613 — DECIDED ONCE.\n" + "- 2026-10-31 #750 (PR on x) — A.\n" + "- 2026-10-31 #750 — B.\n" + "- 2026-10-31 #750 — C.\n") @pytest.mark.parametrize("standing, finding", [ @@ -194,6 +197,19 @@ def name(cid: str) -> str | None: ("- Decided once — 2026-10-06 #613 and 2026-11-01 #999.\n", "no entry 2026-11-01 #999"), ("\nNot standing: 2026-10-04 #612.\n", "names no entry"), + ("- Beside a labelled head — 2026-10-31 #750, C.\n", None), + ("- The labelled head — 2026-10-31 #750 (PR on x).\n", None), + ("- Either bare twin — 2026-10-31 #750.\n", + "ambiguous pointer 2026-10-31 #750"), + ("- Keyed by no text — 2026-10-31 #750, D.\n", + "ambiguous pointer 2026-10-31 #750"), + ("- One bare head — 2026-10-18 #718, THE REPORT.\n", + "continues no entry's head"), + ("\nNot standing: 2026-10-31 #750, typo here.\n", + "ambiguous not-standing pointer 2026-10-31 #750"), + ("\nNot standing: 2026-10-31 #750.\n", + "ambiguous not-standing pointer 2026-10-31 #750"), + ("\nNot standing: 2026-10-31 #750, C.\n", None), ], ids=["clean", "no-pointer", "wrong-date", "number-prefix", "superseded", "announcing-is-live", "date-only", "date-only-qualified", "date-only-qualified-by-its-words", "date-only-shared-date", @@ -210,7 +226,13 @@ def name(cid: str) -> str | None: "date-only-qualified-beside-a-bare-date", "later-issue-in-a-head", "month-only-date", "second-pointer", - "not-standing-missing"]) + "not-standing-missing", "twins-beside-a-labelled-head", + "labelled-head-beside-twins", "twins-beside-a-label-unqualified", + "twins-beside-a-label-keyed-by-no-text", + "one-bare-head-words-are-no-twin-key", + "not-standing-twin-key-typo-ambiguous", + "not-standing-twins-unqualified-ambiguous", + "not-standing-twin-key-resolves"]) def test_standing_findings_name_each_broken_pointer( standing: str, finding: str | None) -> None: found = standing_findings(f"# C1 — x\n\n## Standing\n\n{standing}\n" diff --git a/tools/differential/corpus_rules.jsonl b/tools/differential/corpus_rules.jsonl index 5c5ccef3b..e55ee254f 100644 --- a/tools/differential/corpus_rules.jsonl +++ b/tools/differential/corpus_rules.jsonl @@ -198,10 +198,12 @@ "Jane Smith (Nee) (Jones)" "Jane Smith (née Jones)" "Jane Smith (née Jr.)" +"Jane Smith (née PhD)" "Jane Smith (née V)" "Jane Smith (née)" "Jane Smith née Jones" "Jane Smith née Jones PhD" +"Jane Smith née PhD" "Jane Smith née V" "Jane Smith, née Jones" "Jane and née Jones" diff --git a/tools/differential/snapshot.jsonl b/tools/differential/snapshot.jsonl index dd3434068..0bca6d0bb 100644 --- a/tools/differential/snapshot.jsonl +++ b/tools/differential/snapshot.jsonl @@ -1388,11 +1388,13 @@ {"name": "Jane Smith (Nee) (Jones)", "given": "Jane", "family": "Smith", "nickname": "Nee Jones", "initials": "J. S."} {"name": "Jane Smith (née Jones)", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} {"name": "Jane Smith (née Jr.)", "given": "Jane", "middle": "Smith", "family": "née", "suffix": "Jr.", "initials": "J. S. n."} +{"name": "Jane Smith (née PhD)", "given": "Jane", "family": "Smith", "maiden": "PhD", "initials": "J. S."} {"name": "Jane Smith (née V)", "given": "Jane", "family": "Smith", "maiden": "V", "initials": "J. S."} {"name": "Jane Smith (née)", "given": "Jane", "family": "Smith", "nickname": "née", "initials": "J. S."} {"name": "Jane Smith born Jones", "given": "Jane", "middle": "Smith born", "family": "Jones", "initials": "J. S. b. J."} {"name": "Jane Smith née Jones", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} {"name": "Jane Smith née Jones PhD", "given": "Jane", "family": "Smith", "suffix": "PhD", "maiden": "Jones", "initials": "J. S."} +{"name": "Jane Smith née PhD", "given": "Jane", "middle": "Smith", "family": "née", "suffix": "PhD", "initials": "J. S. n."} {"name": "Jane Smith née V", "given": "Jane", "family": "Smith", "maiden": "V", "initials": "J. S."} {"name": "Jane Smith, née Jones", "given": "née", "middle": "Jones", "family": "Jane Smith", "initials": "n. J. J. S."} {"name": "Jane and née Jones", "given": "Jane and née", "family": "Jones", "initials": "J. n. J."}