Skip to content

Commit b2debc0

Browse files
authored
Merge pull request #419 from derek73/fix/413-harvest-backticked-names
fix(differential): harvest names written in backticks
2 parents 8c11633 + 0848ba5 commit b2debc0

10 files changed

Lines changed: 696 additions & 51 deletions

‎docs/release_log.rst‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ Release Log
1313
Nothing moved between vocabularies. The rename itself changes no
1414
parse at all; the parsing changes below are separate fixes, and
1515
only one of them reaches the default name order -- a title no
16-
longer changing how the name behind it is read, which moves one of
17-
the 751 names of the differential corpora. Both family-first
18-
orders change too, below -- the same eight names under each, seven
19-
of them from the family-first fix and the eighth the one name the
20-
title fix moves in every order.
16+
longer changing how the name behind it is read, which moves four
17+
names of the differential corpora. Both family-first orders change too,
18+
below -- seven of those from the family-first fix, plus the names
19+
the title fix moves in every order. The corpora have roughly
20+
doubled across this cycle (#414 added the rules doc's examples,
21+
#413 taught the issue harvester to read backticks), so the counts
22+
in the bullets below are the classified summary at the time each
23+
was written; the gate output is the source of record.
2124
What breaks is code that *writes* to a default word list, and code
2225
that imports one by its 1.x name has until 3.0.
2326

‎tests/v2/test_differential.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,3 +1057,67 @@ def test_validate_rules_rejects_two_rules_sharing_an_issue() -> None:
10571057
[{"issue": "dup", "name_regex": "Smith", "fields": ["given"]},
10581058
{"issue": "dup", "name_regex": "Jones", "fields": ["given"]}],
10591059
"expected_since_1.4.0.toml")
1060+
1061+
1062+
harvester = load_tool("build_issues_corpus")
1063+
1064+
1065+
def test_a_backticked_name_is_harvested() -> None:
1066+
"""This tracker writes names in backticks, because issues are
1067+
markdown. Matching only quoted names left the corpus whose whole
1068+
purpose is "what users reported" blind to how this project reports
1069+
(#413).
1070+
"""
1071+
assert harvester._harvest(
1072+
"the tussenvoegsel in `Beethoven, Ludwig van` is filed behind "
1073+
"the given name") == {"Beethoven, Ludwig van"}
1074+
1075+
1076+
def test_a_call_inside_backticks_yields_the_name_not_the_call() -> None:
1077+
"""The reason backticks are a SECOND PASS rather than a third
1078+
alternative in one pattern.
1079+
1080+
A reporter writes the failing input as a call inside a code span.
1081+
Regex scanning takes the leftmost match, so a backtick alternative
1082+
starts one character before the HumanName branch can and swallows
1083+
the whole span -- harvesting the call and losing the name inside
1084+
it. This is the bug the two-pass structure exists to prevent, and
1085+
it is reachable from a single string.
1086+
"""
1087+
got = harvester._harvest('reported as `HumanName("John Q Doe")`')
1088+
assert got == {"John Q Doe"}
1089+
1090+
# ... and a call anywhere in the span, not only at its start
1091+
assert not any("HumanName(" in n for n in
1092+
harvester._harvest('see `Foo HumanName("Jane Roe")`'))
1093+
1094+
1095+
def test_a_sentence_is_not_a_name() -> None:
1096+
"""Prose is quoted and backticked as often as names are, and a
1097+
capitalized phrase is well-formed enough that the character screen
1098+
cannot see it.
1099+
"""
1100+
for text in ("`What this gate does not cover` is the section",
1101+
"the note said `Takes everything` about it",
1102+
"`C is CONSTANTS` in that example",
1103+
"wrote `NO Latin twins on purpose` there",
1104+
"raised `TypeError: a bytes-like object is required`"):
1105+
assert harvester._harvest(text) == set(), text
1106+
1107+
1108+
def test_the_sentence_screen_keeps_its_hands_off_names() -> None:
1109+
"""The screen matches a function word only where it is written
1110+
LOWERCASE, which is what keeps it off names: a name capitalizes its
1111+
words and a sentence's function words do not.
1112+
1113+
Every name here contains a word that is in the stop list, and every
1114+
one is a real naming convention -- Burmese, Norwegian, Estonian,
1115+
Cantonese, Luhya, Dutch, Lakota. Without the case test the screen
1116+
drops all of them, silently, from the one corpus meant to catch
1117+
what users report.
1118+
"""
1119+
for name in ("U Than Shwe", "Sven Are Olsen", "Kertu Must",
1120+
"Chan On Kei", "Wafula Were", "John Does",
1121+
"Marie Takes War Bonnet", "Rob And Beth Edmunds",
1122+
"Duke of Wellington", "Anh Do", "Will De Groot"):
1123+
assert harvester._harvest(f"reported `{name}` today") == {name}, name

‎tests/v2/test_ledger_guards.py‎

Lines changed: 88 additions & 31 deletions
Large diffs are not rendered by default.

‎tools/differential/README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ that stops being run. Pass `--corpus PATH` (repeatable) to narrow it.
189189
They are deliberately separate rather than merged: `corpus.jsonl` is
190190
reproducible forever from an immutable git ref, while the issue
191191
tracker is mutable, so regenerating `corpus_issues.jsonl` is an
192-
explicit, reviewable act that can only add names — and the CJK corpus
192+
explicit, reviewable act that can only add names while the harvester is unchanged (changing its screens, as #413 did, can remove one) — and the CJK corpus
193193
exists because BOTH of those are structurally blind to unspaced CJK (v1's
194194
banks never tested it; `build_issues_corpus.py` requires an internal
195195
space, which unspaced names never have). It regenerates from the case
@@ -211,6 +211,29 @@ intended-but-unclassified 2.0 behaviors (#273 typographic delimiters,
211211
#269 non-Latin vocabulary) plus one shape no test had considered: a
212212
**leading** `"Ph. D."`, which v1 split into title `Ph.` + given `D.`.
213213

214+
It earned it again at #413. The harvester matched names in quotes
215+
only, while this tracker writes them in backticks, so the corpus whose
216+
purpose is "what users reported" was blind to how this project
217+
reports — 200 names became 381. Among the arrivals were the headline
218+
example of nearly every issue that fixed them, including
219+
`Ursula von der Leyen geb. Albrecht`, which sat in #399's own title
220+
while that issue shipped noting the class was invisible to this
221+
harness. Classifying the arrivals widened `fix(#367)`, which had
222+
asked in writing to be widened, added six rules, gave
223+
`fix(leading-credential)` the `family` role its own shape always moved,
224+
and narrowed the trailing-`Ph. D.` exclusion as its comment instructed
225+
the moment a `<suffix> Ph. D.` name appeared.
226+
227+
Backticks prompted a second look at prose, and the same change added
228+
two screens neither branch had: `:` joins the structural characters (it
229+
appears in no name across all four corpora, and accounts for three
230+
error messages and a PyPI trove classifier), and a short list of English function words
231+
rejects capitalized sentences the character screen cannot see —
232+
`What this gate does not cover` is well-formed as a phrase. That list
233+
is narrow on purpose: `and`, `the`, `of`, `will`, `can` and `do` all
234+
stay out of it, because `Rob And Beth Edmunds`, `The Hon`, `Duke of
235+
Wellington` and `Anh Do` are real corpus names.
236+
214237
The rules corpus earned its place the same way — 113 of its 155 names
215238
were in no other corpus, and on its first run 12 of them turned out to
216239
have moved during the 2.2 cycle with nothing observing it. Classifying

‎tools/differential/build_issues_corpus.py‎

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,110 @@
3333
import sys
3434

3535
# Either an explicit HumanName("...") call -- the reporter showing the
36-
# failing input -- or a quoted, capitalized phrase, which is how names
37-
# appear in prose ("parsing 'Hans "Hansi" Müller' gives ...").
36+
# failing input -- or a capitalized phrase in quotes or BACKTICKS,
37+
# which is how names appear in prose ("parsing 'Hans "Hansi" Müller'
38+
# gives ...", or `Beethoven, Ludwig van` in a markdown issue).
39+
#
40+
# The backtick branch was missing until #413, and the cost was not
41+
# marginal: this tracker writes names in backticks because issues are
42+
# markdown, so the corpus whose whole purpose is "what users reported"
43+
# was blind to the way this project reports. Under the screens this
44+
# file ships the backtick pass adds 94 names that the quoted branch
45+
# never saw, none of them in the previous corpus, and they include the
46+
# headline example of the issue that fixed them -- `Beethoven, Ludwig
47+
# van` (#379), `Berg, Jan vd` (#380), `Ursula von der Leyen geb.
48+
# Albrecht` (#399). That last one sat in its issue's TITLE while #399
49+
# shipped noting the class was invisible to this harness.
3850
_CANDIDATE = re.compile(
3951
r"""HumanName\(\s*["']([^"']{3,60})["']"""
4052
r"""|["']([A-Z][^"'\n]{4,60})["']"""
4153
)
54+
# Backticks are scanned SEPARATELY, not as a third alternative, and the
55+
# reason is a name this cost before it was split out. A reporter writes
56+
# the failing input as `HumanName("John Q Doe")` -- a call inside a
57+
# code span. Regex scanning takes the leftmost match, so a backtick
58+
# alternative starts one character before the HumanName branch can and
59+
# swallows the whole span, harvesting the CALL and losing the name
60+
# inside it. Two passes over the same text cannot shadow each other.
61+
_BACKTICKED = re.compile(r"""`([A-Z][^`\n]{4,60})`""")
62+
# ... and the same code span is dropped from the backtick pass, or the
63+
# call itself is harvested as a name. The HumanName branch above has
64+
# already taken the argument out of it, which is the part anyone meant.
65+
#
66+
# Searched, not matched: a call is code wherever it sits in the span,
67+
# and anchoring at offset 0 let `Foo HumanName("x y")` through with the
68+
# call still attached.
69+
_A_CALL = re.compile(r"""[A-Za-z_]\w*\(""")
4270
# Structural characters that mean we caught code or markup, not a name.
43-
_NOT_A_NAME = set('{}<>=/\\|')
71+
#
72+
# Extended when the backtick branch landed (#413): backticks wrap code
73+
# far more often than quotes do, so the branch admits code-shaped
74+
# strings a quoted phrase rarely produces -- 'Constants.__init__(self,
75+
# **state)', 'DEVIATION #364', 'SUFFIX_ACRONYMS ∩ SUFFIX_NOT_ACRONYMS',
76+
# 'TypeError: a bytes-like object is required'. None of '*#~;_:'
77+
# appears in a name; the colon alone accounts for three error messages
78+
# and a PyPI trove classifier, and none of the six characters occurs in
79+
# any name across the four corpora. The one existing entry this drops,
80+
# 'St. ___', is a placeholder, not a name.
81+
_NOT_A_NAME = set('{}<>=/\\|*#~;_:')
82+
83+
#: Words that make a string a SENTENCE rather than a name. The
84+
#: character screen above cannot reach prose that is merely prose --
85+
#: 'What this gate does not cover', 'Takes everything', 'C is
86+
#: CONSTANTS' are all well-formed capitalized phrases.
87+
#:
88+
#: NOT a backtick problem, though the backtick branch is what prompted
89+
#: looking: five of the six sentences this drops come from the QUOTED
90+
#: branch, which has been running since the harvester was written.
91+
#: They are new to the corpus because the tracker grew, not because of
92+
#: the new pass -- re-harvesting with backticks disabled still adds 88
93+
#: names over the previous snapshot. Scoping this screen to the
94+
#: backtick pass would look like a tightening and would silently
95+
#: readmit five of them.
96+
#:
97+
#: Matched only against a word written in LOWERCASE, which is what
98+
#: keeps the screen off names. A name capitalizes its words and a
99+
#: sentence's function words do not, so 'Chan On Kei', 'U Than Shwe',
100+
#: 'Sven Are Olsen', 'Kertu Must', 'Wafula Were' and 'John Does' all
101+
#: survive while 'NO Latin twins on purpose' does not. Without the
102+
#: case test this list silently drops real names, and the corpus it
103+
#: drops them from is the one whose whole purpose is catching what
104+
#: users report.
105+
#:
106+
#: The exclusions are the load-bearing part, and they divide into two
107+
#: kinds. 'and', 'the', 'of', 'do' and 'will' are excluded on corpus
108+
#: evidence -- 'Rob And Beth Edmunds', 'The Hon', 'Duke of Wellington',
109+
#: 'Anh Do' and 'Will De Groot' are all in the corpora. 'can' and 'no'
110+
#: are excluded on no corpus evidence at all: they are Turkish Can and
111+
#: Korean No, names this harvest has not reached yet. The distinction
112+
#: matters because the corpus cannot supply evidence for a word any
113+
#: screen already rejects, so absence from it is not a licence. A name is allowed to
114+
#: be odd -- 'der, y van' is real -- it is not allowed to be a
115+
#: sentence.
116+
_PROSE = frozenset({
117+
"is", "are", "was", "were", "be", "been", "this", "that", "these",
118+
"those", "what", "which", "how", "why", "when", "not", "does",
119+
"did", "without", "everything", "takes", "reads", "sure", "about",
120+
"because", "than", "then", "into", "over", "under", "should",
121+
"would", "could", "must", "on",
122+
})
44123

45124

46125
def _harvest(text: str) -> set[str]:
47126
found = set()
48-
for match in _CANDIDATE.finditer(text):
49-
value = (match.group(1) or match.group(2) or "").strip()
127+
values = [(m.group(1) or m.group(2) or "").strip()
128+
for m in _CANDIDATE.finditer(text)]
129+
values += [m.group(1).strip() for m in _BACKTICKED.finditer(text)
130+
if not _A_CALL.search(m.group(1))]
131+
for value in values:
50132
# Require an internal space: a single word is a surname at best
51133
# and carries no structure to disagree about.
52-
if value and " " in value and not (_NOT_A_NAME & set(value)):
53-
found.add(value)
134+
if not value or " " not in value or (_NOT_A_NAME & set(value)):
135+
continue
136+
if any(s and s == s.lower() and s in _PROSE
137+
for s in (w.strip(".,'\"") for w in value.split())):
138+
continue
139+
found.add(value)
54140
return found
55141

56142

‎tools/differential/compare.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ def _is_latin_only(name: str) -> bool:
357357
_CORPUS_FLOORS = {
358358
"corpus.jsonl": 480, # 486 today, from v1's banks at a pinned ref
359359
"corpus_cjk.jsonl": 95, # 97 today, generated from the case table
360-
"corpus_issues.jsonl": 190, # 200 today, harvested and append-only
360+
"corpus_issues.jsonl": 370, # 381 today, harvested and append-only
361361
"corpus_rules.jsonl": 150, # 155 today, generated from rules.md
362362
}
363363

0 commit comments

Comments
 (0)