vicary 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +57 -0
- data/assets/MANIFEST.json +33 -0
- data/assets/notability.txt.gz +0 -0
- data/assets/stop_words.txt +63 -0
- data/lib/vicary/asset.rb +190 -0
- data/lib/vicary/candidates.rb +1702 -0
- data/lib/vicary/conformance.rb +242 -0
- data/lib/vicary/gazetteer.rb +399 -0
- data/lib/vicary/lexicon.rb +174 -0
- data/lib/vicary/minter.rb +95 -0
- data/lib/vicary/redact.rb +172 -0
- data/lib/vicary/structured.rb +343 -0
- data/lib/vicary/version.rb +10 -0
- data/lib/vicary.rb +41 -0
- metadata +66 -0
|
@@ -0,0 +1,1702 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vicary
|
|
6
|
+
# Find the person-names a student wrote, so the notability filter can decide.
|
|
7
|
+
#
|
|
8
|
+
# The Ruby port of `python/src/vicary/name_candidates.py`.
|
|
9
|
+
#
|
|
10
|
+
# Why generation runs before the notability lookup, rather than instead of it
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Finding capitalised name-shaped spans in English student prose is close to
|
|
13
|
+
# free. The hard half is deciding which ones to *keep*, and the two cases look
|
|
14
|
+
# identical syntactically:
|
|
15
|
+
#
|
|
16
|
+
# My cousin Terrence Okonkwo came over that summer => redact
|
|
17
|
+
# My inspiration, Vincent van Gogh, painted for years => keep
|
|
18
|
+
#
|
|
19
|
+
# Both are first-person possessive, so a relational-trigger rule gets van Gogh
|
|
20
|
+
# wrong. The discriminator has to be **notability**, which is a lookup rather
|
|
21
|
+
# than a model. So: generate broadly here, then `notable => keep, everything
|
|
22
|
+
# else => redact`.
|
|
23
|
+
#
|
|
24
|
+
# Capitalisation is a clue, never the answer
|
|
25
|
+
# ------------------------------------------
|
|
26
|
+
# Every rule in this file weighs case rather than obeying it, because a writer
|
|
27
|
+
# who capitalises most of their proper nouns still misses some and informal
|
|
28
|
+
# writers shout in ALL CAPS. Each threshold below was measured on 27
|
|
29
|
+
# un-scrubbed student documents rather than argued from the shape of English;
|
|
30
|
+
# the numbers travel with the constants.
|
|
31
|
+
#
|
|
32
|
+
# ## Regex dialect
|
|
33
|
+
#
|
|
34
|
+
# Ported from Python `re`. Two differences run through this whole file:
|
|
35
|
+
#
|
|
36
|
+
# * `^` and `$` are start- and end-of-*line* in Ruby, where Python without
|
|
37
|
+
# `re.MULTILINE` means the whole string. Every one of them is written `\A` or
|
|
38
|
+
# `\z` here. This is not cosmetic, and **neither shared spec layer catches it
|
|
39
|
+
# if somebody writes it back**: with a bare `$`, {RELATION_ATTACHED_BEFORE}
|
|
40
|
+
# attaches "my cousin" on one line to a name on the next, and all 36
|
|
41
|
+
# conformance frames and all 2,526 primitive assertions stay green while it
|
|
42
|
+
# does. `test/dialect_test.rb` is what catches it.
|
|
43
|
+
# * `\w` is ASCII-only in Ruby and Unicode-aware in Python, which is why
|
|
44
|
+
# {NOT_WORD_BEFORE} spells its character class out rather than using `\w`.
|
|
45
|
+
# `\d` and `\s` diverge the same way and are deliberately left as-is,
|
|
46
|
+
# matching the TypeScript port, which has the identical narrowing and
|
|
47
|
+
# reproduces every frame.
|
|
48
|
+
#
|
|
49
|
+
# `\b` is NOT one of the differences, which is worth stating because the
|
|
50
|
+
# TypeScript port's identical-looking lookarounds exist for a reason that does
|
|
51
|
+
# not apply here: *JavaScript's* `\b` is ASCII-only and finds a boundary inside
|
|
52
|
+
# `naïve` that Python does not. Ruby's `\b` is Unicode-aware and already agrees
|
|
53
|
+
# with Python.
|
|
54
|
+
module Candidates
|
|
55
|
+
# Python's `\b` before a letter, written out.
|
|
56
|
+
#
|
|
57
|
+
# Belt-and-braces rather than load-bearing in Ruby — see the dialect note
|
|
58
|
+
# above — and kept because this is the form the shared spec pins, and
|
|
59
|
+
# `[\p{L}\p{N}_]` is the same set the gazetteer folds on.
|
|
60
|
+
NOT_WORD_BEFORE = '(?<![\p{L}\p{N}_])'
|
|
61
|
+
|
|
62
|
+
# The same on the trailing side: `\b` *after* a letter.
|
|
63
|
+
NOT_WORD_AFTER = '(?![\p{L}\p{N}_])'
|
|
64
|
+
|
|
65
|
+
# Role titles and honorifics that introduce a name. Part of the span: masking
|
|
66
|
+
# "Okonkwo" out of "Mrs. Okonkwo" leaves the relationship and the surname's
|
|
67
|
+
# position, and students name teachers and coaches constantly.
|
|
68
|
+
HONORIFICS = %w[
|
|
69
|
+
Mr Mrs Ms Miss Mx Dr Prof Professor Coach
|
|
70
|
+
Officer Principal Rev Reverend Sgt Sergeant Capt
|
|
71
|
+
Captain Sir Madam Fr Sister Brother Nurse Chief
|
|
72
|
+
Aunt Uncle Grandma Grandpa Grandmother Grandfather
|
|
73
|
+
Cousin Auntie
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
# Lowercase particles that sit *inside* a name. Without these, "Vincent van
|
|
77
|
+
# Gogh" generates two candidates and the gazetteer has to know both halves.
|
|
78
|
+
PARTICLES = %w[
|
|
79
|
+
van von de del della der den di da du la
|
|
80
|
+
le los bin ibn al of the y
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
# Suffixes that make a capitalised span an organisation rather than a person.
|
|
84
|
+
# Typed separately because the placeholder is what a student reads outbound.
|
|
85
|
+
ORG_SUFFIXES = Set.new(%w[
|
|
86
|
+
inc inc. llc ltd corp corp. corporation company
|
|
87
|
+
co co. insurance bank hospital clinic university
|
|
88
|
+
college school academy institute foundation church
|
|
89
|
+
temple mosque synagogue association society union
|
|
90
|
+
department agency bureau committee council league
|
|
91
|
+
team club store market restaurant airlines motors
|
|
92
|
+
industries systems technologies group partners holdings
|
|
93
|
+
]).freeze
|
|
94
|
+
|
|
95
|
+
# Suffixes that make a capitalised span a public landmark — topical by
|
|
96
|
+
# construction, so kept without consulting the gazetteer. "Lincoln Memorial"
|
|
97
|
+
# is the essay's subject; "Akron" in the same sentence is the student's town.
|
|
98
|
+
LANDMARK_SUFFIXES = Set.new(%w[
|
|
99
|
+
memorial monument museum cathedral capitol bridge
|
|
100
|
+
tower stadium arena park gardens canyon falls
|
|
101
|
+
island mountain mountains river lake ocean sea
|
|
102
|
+
desert valley peninsula statue palace castle temple
|
|
103
|
+
pyramid wall trail highway zoo aquarium planetarium
|
|
104
|
+
observatory library
|
|
105
|
+
]).freeze
|
|
106
|
+
|
|
107
|
+
# Capitalised words that are not names, read from the vendored lexicon.
|
|
108
|
+
#
|
|
109
|
+
# Deliberately broad: this list is the only thing standing between candidate
|
|
110
|
+
# generation and "mask every capitalised word", and a capitalised ordinary
|
|
111
|
+
# word is overwhelmingly sentence-initial. Skewed toward over-inclusion on
|
|
112
|
+
# purpose — a missed name is one span and shows up in the recall number,
|
|
113
|
+
# while a wrongly-masked common word corrupts every essay that uses it and
|
|
114
|
+
# shows up nowhere unless somebody reads the prose.
|
|
115
|
+
#
|
|
116
|
+
# It is *data*, not a literal, because all three front doors need the same
|
|
117
|
+
# 421 words and a hand-transliterated stoplist diverges silently. Loaded at
|
|
118
|
+
# first use rather than at require time — the difference from Python's
|
|
119
|
+
# load-at-import is that a host may `require "vicary"` to read
|
|
120
|
+
# {Vicary::VERSION} without a vendored asset, and raising there would fail a
|
|
121
|
+
# program that never redacts anything.
|
|
122
|
+
def self.stop_words
|
|
123
|
+
@stop_words ||= Lexicon.load("stop_words")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Contraction and possessive tails. `[A-Z][A-Za-z'’]*` matches "I'm" as one
|
|
127
|
+
# token, so without stripping these the stoplist never sees the word — "I'm"
|
|
128
|
+
# and "As" were the two most common over-fires on real prose. The
|
|
129
|
+
# *un*-apostrophized spellings students actually type ("im", "dont",
|
|
130
|
+
# "thats") cannot be stripped this way because there is no clitic boundary to
|
|
131
|
+
# find, so they are listed in the stoplist directly. "im" is a given name in
|
|
132
|
+
# Wikidata, which is how "im faithfull" and "im going" became name
|
|
133
|
+
# candidates.
|
|
134
|
+
CLITICS = ["n't", "n’t", "'s", "’s", "'m", "’m", "'re", "’re", "'ve", "’ve",
|
|
135
|
+
"'ll", "’ll", "'d", "’d", "'t", "’t"].freeze
|
|
136
|
+
|
|
137
|
+
# An all-caps run this long or longer means capitalisation is not a signal,
|
|
138
|
+
# so the stoplist carries the whole decision and a capital neither helps nor
|
|
139
|
+
# hurts. A run *shorter* than this in an otherwise mixed-case document is the
|
|
140
|
+
# opposite case: informal writers put one or two words in caps to shout, and
|
|
141
|
+
# "SLAM", "WHACK" and "Nooooooo" are not names. Measured on 27 un-scrubbed
|
|
142
|
+
# student documents, short all-caps runs were emphasis in every instance.
|
|
143
|
+
ALLCAPS_RUN = 3
|
|
144
|
+
|
|
145
|
+
# Any word token, used to find all-caps runs and mid-sentence capitals.
|
|
146
|
+
WORD_TOKEN = /[A-Za-z][A-Za-z'’-]*/
|
|
147
|
+
|
|
148
|
+
# Where a sentence begins: start of text, after terminal punctuation and any
|
|
149
|
+
# closing quote, after a line break, or immediately inside an *opening*
|
|
150
|
+
# quote. A capital in one of these positions is required by orthography, so
|
|
151
|
+
# it is evidence of nothing — which is the whole of the objection to treating
|
|
152
|
+
# a capital as proof that a word is a name.
|
|
153
|
+
#
|
|
154
|
+
# The opening-quote arm was missing, and quoted material is how feedback
|
|
155
|
+
# refers to a student's own words: "vivid words like 'Giggles filled the
|
|
156
|
+
# school'" put a capital on `Giggles` for the same orthographic reason a full
|
|
157
|
+
# stop does, and it masked as a name in text a student reads. Only the
|
|
158
|
+
# *capital* is discounted — a real name inside quotes still carries the
|
|
159
|
+
# given-name tier.
|
|
160
|
+
#
|
|
161
|
+
# An apostrophe inside a word cannot match: the quote must not be preceded by
|
|
162
|
+
# a letter, so "don't" and "Narciso's" are untouched.
|
|
163
|
+
#
|
|
164
|
+
# `\A` rather than `^`, so it is start-of-text and not start-of-line — the
|
|
165
|
+
# two differ here and the difference is every hard-wrapped line in the
|
|
166
|
+
# corpus.
|
|
167
|
+
SENTENCE_BREAK = /(?:\A|[.!?]["'’”)]*\s+|\n+|(?:(?<=\s)|\A)["'‘“](?=[A-Za-z]))\s*/
|
|
168
|
+
|
|
169
|
+
# One entirely-lowercase word. The leading boundary is what keeps this from
|
|
170
|
+
# matching the tail of a capitalised word — there is no word boundary between
|
|
171
|
+
# the "T" and the "errence" of "Terrence", so the capitalised route keeps
|
|
172
|
+
# exclusive claim on anything it can see.
|
|
173
|
+
LOWER_TOKEN = Regexp.new("#{NOT_WORD_BEFORE}[a-z][a-z'’-]*")
|
|
174
|
+
|
|
175
|
+
# Tokens a lowercase span must reach before it is emitted at all. Set to 2
|
|
176
|
+
# deliberately, and it is the single decision that makes the lowercase route
|
|
177
|
+
# affordable.
|
|
178
|
+
LOWERCASE_MIN_TOKENS = 2
|
|
179
|
+
|
|
180
|
+
# Determiners that make the word after them a common noun rather than a name.
|
|
181
|
+
# "a little bit", "the guy thats", "our joy" — English does not put a bare
|
|
182
|
+
# determiner in front of a person's given name, so this is a clean structural
|
|
183
|
+
# signal rather than a word blacklist, and it does not grow with the corpus.
|
|
184
|
+
# Measured on 25 ASAP essays it accounted for 22 of ~34 lowercase over-fire
|
|
185
|
+
# seeds, `a` alone for 12. Possessives are included: a student writes "my
|
|
186
|
+
# cousin terrence", never "my terrence".
|
|
187
|
+
DETERMINERS = Set.new(%w[
|
|
188
|
+
a an the this that these those
|
|
189
|
+
my your his her its our their
|
|
190
|
+
some any no every each either neither both all
|
|
191
|
+
another other such one two three
|
|
192
|
+
most much many few several enough
|
|
193
|
+
]).freeze
|
|
194
|
+
|
|
195
|
+
HONORIFIC_SET = Set.new(HONORIFICS.map(&:downcase)).freeze
|
|
196
|
+
|
|
197
|
+
# One capitalised word, hyphens and apostrophes included so
|
|
198
|
+
# "Raghunathan-Bell" and "O'Brien" stay whole, and the possessive comes with
|
|
199
|
+
# the name rather than being left behind as a fragment.
|
|
200
|
+
WORD = "[A-Z][A-Za-z'’]*(?:-[A-Z][A-Za-z'’]*)*"
|
|
201
|
+
|
|
202
|
+
# A capitalised, name-shaped span: an optional honorific, optional initials,
|
|
203
|
+
# then one or more capitalised words joined by optional lowercase particles.
|
|
204
|
+
#
|
|
205
|
+
# The honorific alternation is leftmost-first in all three languages, which
|
|
206
|
+
# is what makes "Mrs." work: `Mr` matches first, its trailing `\s+` fails
|
|
207
|
+
# against the "s", and the engine backtracks into `Mrs`.
|
|
208
|
+
CANDIDATE_RE = Regexp.new(
|
|
209
|
+
"#{NOT_WORD_BEFORE}" \
|
|
210
|
+
"(?:(?:#{HONORIFICS.join('|')})\\.?\\s+)?" \
|
|
211
|
+
'(?:[A-Z]\.\s*)*' \
|
|
212
|
+
"#{WORD}" \
|
|
213
|
+
"(?:\\s+(?:(?:#{PARTICLES.join('|')})\\s+)?#{WORD})*",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Spans that are already redacted and must be left strictly alone. Two kinds,
|
|
217
|
+
# and both were live defects rather than hypotheticals:
|
|
218
|
+
#
|
|
219
|
+
# * `{NAME}` — our own placeholders. The bare word inside the braces is
|
|
220
|
+
# capitalised, so without this a second pass generates "NAME" as a
|
|
221
|
+
# candidate and masking stops being idempotent. Both directions run this
|
|
222
|
+
# classifier and the outbound pass sees text the inbound pass already
|
|
223
|
+
# masked.
|
|
224
|
+
# * `@PERSON1` — an upstream anonymization marker. The `@` is not part of a
|
|
225
|
+
# capitalised-word match, so `PERSON` matched on its own and every ASAP
|
|
226
|
+
# marker's kind-word became a candidate: 23.24 spans/essay of "over-firing"
|
|
227
|
+
# that was really this.
|
|
228
|
+
PROTECTED = /\{[A-Za-z_0-9]*\}|@[A-Za-z]+\d*/
|
|
229
|
+
|
|
230
|
+
# Any word token, either case. Used only by the title scan, which cannot key
|
|
231
|
+
# on capitalisation because a student may write a title however they like.
|
|
232
|
+
ANY_TOKEN = /[A-Za-z][A-Za-z'’-]*/
|
|
233
|
+
|
|
234
|
+
# The one fold the title scan applies before consulting the prefix index. A
|
|
235
|
+
# word processor turns every apostrophe curly, so "Charlotte’s Web" tokenises
|
|
236
|
+
# with a character the gazetteer's keys never contain and the walk would stop
|
|
237
|
+
# on its first token. Deliberately not the gazetteer's full `normalize`: that
|
|
238
|
+
# does an NFKD decomposition and a per-character rebuild, and this runs once
|
|
239
|
+
# per word of every essay. An accented title head still fails the walk, which
|
|
240
|
+
# loses a keep and never a redaction.
|
|
241
|
+
CURLY_APOSTROPHE = /[’‘ʼ′]/
|
|
242
|
+
|
|
243
|
+
# How many tokens a title match may span. See {.find_title_spans}.
|
|
244
|
+
TITLE_MAX_TOKENS = 8
|
|
245
|
+
|
|
246
|
+
# Longest line still readable as a heading. Body prose in these documents is
|
|
247
|
+
# hard-wrapped at ~60–590 chars per line, so length alone does not separate a
|
|
248
|
+
# heading from a wrapped line — the blank line above it is what does.
|
|
249
|
+
HEADING_MAX_CHARS = 60
|
|
250
|
+
|
|
251
|
+
# A name-shaped span, with the placeholder it would be masked as.
|
|
252
|
+
#
|
|
253
|
+
# `LOCATION` was absent until 2026-08-07 on the argument that telling a place
|
|
254
|
+
# from a person needs NER and the inbound path does not need the distinction
|
|
255
|
+
# — both are placeholders in the training distribution. The first half of
|
|
256
|
+
# that is what changed: a settlement gazetteer tier is not NER, and it types
|
|
257
|
+
# the case that actually occurs. The second half was always the weaker claim,
|
|
258
|
+
# because the inbound path is not the only reader — a host that echoes the
|
|
259
|
+
# placeholder back writes "your trip to {NAME}".
|
|
260
|
+
#
|
|
261
|
+
# Still no `LOCATION` for a place that is *kept* (a landmark, a country): a
|
|
262
|
+
# kept span is never masked, so it has no placeholder to type.
|
|
263
|
+
Candidate = Struct.new(:text, :start, :end, :kind)
|
|
264
|
+
|
|
265
|
+
# One row of the precedence table: a tag, and what it decides.
|
|
266
|
+
PrecedenceRow = Struct.new(:tag, :mask, :kind)
|
|
267
|
+
|
|
268
|
+
# The precedence table. The first row whose tag the span carries decides both
|
|
269
|
+
# the mask/keep verdict and the placeholder, and that is the whole
|
|
270
|
+
# classification policy.
|
|
271
|
+
#
|
|
272
|
+
# Pinned against `precedence` in `conformance/primitives.json`, because this
|
|
273
|
+
# is the one part of the detector a port can get wrong while passing every
|
|
274
|
+
# frame: reordering two rows changes which spans survive, and only a
|
|
275
|
+
# colliding span can tell. The reference's frame set had no colliding span
|
|
276
|
+
# for the detector's whole life, which is how 383 real settlements came to be
|
|
277
|
+
# kept.
|
|
278
|
+
#
|
|
279
|
+
# **One principle orders the whole table: a lookup beats a guess, and a guess
|
|
280
|
+
# that masks beats a guess that keeps.** Tier membership is a lookup — the
|
|
281
|
+
# gazetteer positively asserts this exact string is a town. A suffix match is
|
|
282
|
+
# a guess from a word ending.
|
|
283
|
+
#
|
|
284
|
+
# 1. `LOCATION` first, the only row backed by a lookup. `settlement?` is an
|
|
285
|
+
# **exact** match on a normalised key, not a prefix reading, so a span
|
|
286
|
+
# reaches this row only where the tier vouches for the whole string. Of
|
|
287
|
+
# the 16 real tier entries that also carry an org suffix, 12 are ordinary
|
|
288
|
+
# towns (Falls Church, Cut Bank, Union, Agency, College, Council, ...) and
|
|
289
|
+
# 4 are tier noise (Byumba Hospital, Zeyrek Mosque, ...), so this is the
|
|
290
|
+
# better label 12 times in 16 — and a place is the more identifying
|
|
291
|
+
# reading.
|
|
292
|
+
# 2. `ORGANIZATION` second. The suffix is still direct evidence about *this*
|
|
293
|
+
# string, and it types the case that actually occurs: "Progressive
|
|
294
|
+
# Insurance" is in nobody's settlement tier, so the order above costs it
|
|
295
|
+
# nothing.
|
|
296
|
+
# 3. `LANDMARK` third — a guess like an org suffix, but one that *keeps*
|
|
297
|
+
# rather than masks, so it ranks below both. Ranking it above `LOCATION`
|
|
298
|
+
# is what kept 383 real hometowns whose names end in park, lake, valley or
|
|
299
|
+
# falls.
|
|
300
|
+
# 4. `PERSON` last, and always matching, so the table is total. Below
|
|
301
|
+
# `LANDMARK` is not a redact-wins violation: `PERSON` is the absence of
|
|
302
|
+
# evidence, and keeping "Lincoln Memorial" is the landmark row's whole
|
|
303
|
+
# purpose.
|
|
304
|
+
#
|
|
305
|
+
# Nothing outside this table branches on the kind — it selects the
|
|
306
|
+
# placeholder string and the minter's numbering namespace, while `mask` alone
|
|
307
|
+
# carries the verdict. So rows 1 and 2 trade label accuracy only, with no
|
|
308
|
+
# recall or privacy risk either way.
|
|
309
|
+
PRECEDENCE = [
|
|
310
|
+
PrecedenceRow.new("LOCATION", true, "LOCATION"),
|
|
311
|
+
PrecedenceRow.new("ORGANIZATION", true, "ORGANIZATION"),
|
|
312
|
+
PrecedenceRow.new("LANDMARK", false, nil),
|
|
313
|
+
PrecedenceRow.new("PERSON", true, "NAME"),
|
|
314
|
+
].freeze
|
|
315
|
+
|
|
316
|
+
# Mid-sentence capital. "I" is excluded because every writer capitalises it
|
|
317
|
+
# whether or not they capitalise names, so it is the one capital that says
|
|
318
|
+
# nothing about their habits.
|
|
319
|
+
MID_SENTENCE_CAP = /(?<=[a-z,;:]\s)([A-Z][a-z]{2,})/
|
|
320
|
+
|
|
321
|
+
# Mid-sentence capitals above which a document is taken to mark its proper
|
|
322
|
+
# nouns with capitals — at which point a *lowercase* token is evidence
|
|
323
|
+
# against a name.
|
|
324
|
+
#
|
|
325
|
+
# Measured on 36 un-scrubbed essay documents (~3,300 chars each, Project
|
|
326
|
+
# Gutenberg) against a lower-cased copy of the same text: as written the
|
|
327
|
+
# median is 10.5 and 35/36 documents are non-zero; lower-cased every document
|
|
328
|
+
# is 0. Clean separation, so the threshold is not delicate — 2 rather than 1
|
|
329
|
+
# only to tolerate a single stray capital.
|
|
330
|
+
#
|
|
331
|
+
# **A rate was measured against this floor and rejected.** A count is
|
|
332
|
+
# length-blind, so the obvious repair is marks per 1,000 characters — and on
|
|
333
|
+
# the 27 un-scrubbed student documents that does not separate the deciding
|
|
334
|
+
# band, it only re-orders it. Both documents sitting at exactly 2 marks with
|
|
335
|
+
# the closest rates are decided *the wrong way round* by a rate: `141-693`
|
|
336
|
+
# marks "Powerball" twice in 3,478 characters (0.58 per 1k, a genuine
|
|
337
|
+
# capitaliser) and `141-433` marks "The" and "There" in 1,144 (1.75 per 1k,
|
|
338
|
+
# both artefacts of a sentence break the detector missed). A rate threshold
|
|
339
|
+
# demotes the real one and promotes the false one. What actually separates
|
|
340
|
+
# them is the *content* of the mark, which is per-token evidence — so the
|
|
341
|
+
# band falls through to {.mid_sentence_capitals} rather than being decided at
|
|
342
|
+
# document level, and that is what `INCONSISTENT` is for.
|
|
343
|
+
MARKS_PROPER_NOUNS_MIN = 2
|
|
344
|
+
|
|
345
|
+
# A sentence opening on a lower-case letter, which is the writer telling us
|
|
346
|
+
# directly that they are not keeping standard capitalisation. Matched at the
|
|
347
|
+
# start of the text as well as after a sentence break.
|
|
348
|
+
LOWERCASE_SENTENCE_START = /(?:\A|(?<=[.!?]\s))\s*[a-z]/
|
|
349
|
+
|
|
350
|
+
# A bare lower-case first-person "i" — the other unambiguous tell, and the
|
|
351
|
+
# one that survives a writer who does capitalise sentence openings.
|
|
352
|
+
BARE_LOWERCASE_I = Regexp.new("#{NOT_WORD_BEFORE}i#{NOT_WORD_AFTER}")
|
|
353
|
+
|
|
354
|
+
# One terminal-punctuation unit. The denominator for the drop rate, and it
|
|
355
|
+
# has to be this rather than {.sentence_starts}: that counts `\n` as a break
|
|
356
|
+
# too, and these documents are hard-wrapped, so it would report a wrapped
|
|
357
|
+
# line as a sentence and halve the rate. This is the population
|
|
358
|
+
# {LOWERCASE_SENTENCE_START} actually draws from.
|
|
359
|
+
SENTENCE_UNIT = /[^.!?]+[.!?]*/
|
|
360
|
+
|
|
361
|
+
# Fraction of sentence openings that must be lower-case before a writer who
|
|
362
|
+
# *does* mark proper nouns is read as also dropping capitals, rather than as
|
|
363
|
+
# having made a typo. Read {.capitalisation_habit} for the reason this is
|
|
364
|
+
# consulted on only one side of the floor — it is the load-bearing half.
|
|
365
|
+
#
|
|
366
|
+
# On the 27 un-scrubbed student documents the boolean "any lower-case
|
|
367
|
+
# opening" fires on 8, and the openings split in two with a gap between 12.5%
|
|
368
|
+
# and 7%:
|
|
369
|
+
#
|
|
370
|
+
# * habit — `my-fabit-book` 2 of 3 openings (67%), `141-433` 6 of 35 (17%),
|
|
371
|
+
# `121-816` 1 of 8 (12.5%);
|
|
372
|
+
# * not — `my-first-tooth-gone` 1 of 14 (7%), `marching-to-his-own-beat` 3 of
|
|
373
|
+
# 60 (5%), `141-140` 2 of 41 (5%), `121-502` 1 of 25 (4%).
|
|
374
|
+
#
|
|
375
|
+
# Every opening in the second group was read, and they are line wraps,
|
|
376
|
+
# citations and one stylistic `Boy! did we cry`.
|
|
377
|
+
# `marching-to-his-own-beat` is an NWP anchor paper that marks 26 proper
|
|
378
|
+
# nouns correctly; the boolean called it a writer who does not keep standard
|
|
379
|
+
# capitalisation, on three artefacts.
|
|
380
|
+
DROPS_CAPITALS_MIN_RATE = 0.1
|
|
381
|
+
|
|
382
|
+
# What a document has told us about how its writer uses capital letters.
|
|
383
|
+
#
|
|
384
|
+
# These replace two booleans — "does it capitalise its proper nouns" and
|
|
385
|
+
# "does it drop standard capitals" — which were consulted separately and
|
|
386
|
+
# *contradict each other on 7 of 27 un-scrubbed student documents*. `141-433`
|
|
387
|
+
# has two mid-sentence capitals and six lower-case sentence openings, so it
|
|
388
|
+
# was simultaneously a writer who capitalises and a writer who does not, and
|
|
389
|
+
# whichever predicate a call site happened to read decided the treatment.
|
|
390
|
+
#
|
|
391
|
+
# Four states, because the two signals are independent and all four cells
|
|
392
|
+
# occur:
|
|
393
|
+
#
|
|
394
|
+
# `consistent`
|
|
395
|
+
# Marks its proper nouns, and does not drop sentence capitals. A
|
|
396
|
+
# lower-case token here is evidence *against* a name. 15 of the 27.
|
|
397
|
+
# `inconsistent`
|
|
398
|
+
# Does both. This is the writer the booleans had no cell for, and **both
|
|
399
|
+
# document-level treatments are wrong for them** — suppressing the
|
|
400
|
+
# lowercase route loses the names they wrote lower-case, and opening it
|
|
401
|
+
# wide fires on ordinary words. So there is no document-level answer here
|
|
402
|
+
# on purpose: the band falls through to per-token evidence
|
|
403
|
+
# ({.mid_sentence_capitals}), which is the right granularity and already
|
|
404
|
+
# existed. 4 of the 27.
|
|
405
|
+
# `lowercase`
|
|
406
|
+
# Drops capitals and marks nothing. The given-name tier is the only
|
|
407
|
+
# handle left, and the lowercase route runs without corroboration. 1 of
|
|
408
|
+
# the 27.
|
|
409
|
+
# `silent`
|
|
410
|
+
# Says nothing either way: no proper nouns to capitalise, and no dropped
|
|
411
|
+
# openings. **Silence is not consent.** Reading it as consent is what put
|
|
412
|
+
# "line circles" and "tone toward" in front of a student, because a
|
|
413
|
+
# 108-290 character feedback field is ordinary prose with nothing in it
|
|
414
|
+
# to capitalise. Treated like `inconsistent`: per-token evidence, never
|
|
415
|
+
# the permissive path. 7 of the 27.
|
|
416
|
+
#
|
|
417
|
+
# Strings rather than symbols so they survive a JSON round trip into and out
|
|
418
|
+
# of the conformance spec unchanged, and so the three languages can be diffed
|
|
419
|
+
# on the wire without a mapping table in between.
|
|
420
|
+
CONSISTENT = "consistent"
|
|
421
|
+
INCONSISTENT = "inconsistent"
|
|
422
|
+
LOWERCASE = "lowercase"
|
|
423
|
+
SILENT = "silent"
|
|
424
|
+
|
|
425
|
+
# The tiers whose keeps a first-person relation may override.
|
|
426
|
+
#
|
|
427
|
+
# Both are built from strings that are *also* ordinary people's names: 578
|
|
428
|
+
# title keys and 33,682 full-name keys are a common given name beside an
|
|
429
|
+
# ordinary US surname ("Alice Adams" is a 1921 novel; "Alan Ford" is a
|
|
430
|
+
# footballer), and each keeps whichever private individual happens to carry
|
|
431
|
+
# it.
|
|
432
|
+
#
|
|
433
|
+
# `place` and `iconic_short` are excluded and stay excluded. A place is not a
|
|
434
|
+
# person, and a bare iconic surname has its own document-level rule with its
|
|
435
|
+
# own guard ({.names_someone_in_the_writers_life?}).
|
|
436
|
+
OVERRIDABLE_TIERS = Set.new(%w[title full_name demonym]).freeze
|
|
437
|
+
|
|
438
|
+
# Words that make a nearby bare surname somebody in the WRITER'S life rather
|
|
439
|
+
# than the public figure the document established.
|
|
440
|
+
#
|
|
441
|
+
# Deliberately NOT "the appositive contains a first-person pronoun", which
|
|
442
|
+
# was the first design and is wrong: literary prose writes "Wright, who
|
|
443
|
+
# taught me to look away from nothing", and refusing corroboration there
|
|
444
|
+
# re-destroys the author the essay is about. A first-person pronoun says the
|
|
445
|
+
# sentence is personal; only these cues say the *person* is.
|
|
446
|
+
#
|
|
447
|
+
# Closed and hand-written on purpose rather than "any noun before the name":
|
|
448
|
+
# *hero*, *muse*, *inspiration*, *role model* and *favourite* are admiration
|
|
449
|
+
# invocations that pair with public figures as readily as with relatives,
|
|
450
|
+
# which is exactly why they are not evidence.
|
|
451
|
+
RELATION_CUES = Set.new(%w[
|
|
452
|
+
neighbor neighbour neighbors neighbours
|
|
453
|
+
cousin cousins brother brothers sister sisters
|
|
454
|
+
uncle aunt grandma grandpa grandmother grandfather
|
|
455
|
+
mom mother dad father stepdad stepmom
|
|
456
|
+
coach teacher tutor principal babysitter
|
|
457
|
+
friend friends bestfriend classmate classmates roommate
|
|
458
|
+
teammate teammates boss coworker
|
|
459
|
+
]).freeze
|
|
460
|
+
|
|
461
|
+
# Multi-word proximity phrases, matched on the folded context string.
|
|
462
|
+
#
|
|
463
|
+
# Needed because the shape that actually occurs is "lives two doors down from
|
|
464
|
+
# us" — a relation expressed as distance, with no relation noun in it
|
|
465
|
+
# anywhere.
|
|
466
|
+
PROXIMITY_CUES = [
|
|
467
|
+
"doors down", "door down", "down the street", "next door",
|
|
468
|
+
"across the street", "up the block", "down the block",
|
|
469
|
+
"in my class", "in my grade", "on my team", "at my school",
|
|
470
|
+
"in my neighborhood", "in my neighbourhood",
|
|
471
|
+
].freeze
|
|
472
|
+
|
|
473
|
+
# First-person tokens, for the proximity leg. A proximity phrase says
|
|
474
|
+
# somebody lives nearby; only a first-person pronoun says nearby *to the
|
|
475
|
+
# writer*.
|
|
476
|
+
FIRST_PERSON = Set.new(%w[i me my we us our]).freeze
|
|
477
|
+
|
|
478
|
+
# How far around a bare surname to look for the cues. One clause either side:
|
|
479
|
+
# long enough for "Robinson, who lives two doors down from us," and short
|
|
480
|
+
# enough that the next sentence's unrelated cousin does not reach back.
|
|
481
|
+
RELATION_WINDOW = 90
|
|
482
|
+
|
|
483
|
+
# The relation nouns as a regex alternation. Sorted so the pattern is stable
|
|
484
|
+
# across runs and diffs — and so it is the *same* pattern the reference
|
|
485
|
+
# builds, since `sorted()` over the Python frozenset and a sort here must
|
|
486
|
+
# agree.
|
|
487
|
+
RELATION_ALTERNATION = RELATION_CUES.to_a.sort.join("|")
|
|
488
|
+
|
|
489
|
+
# Up to two words may sit between the possessive and the relation noun — "my
|
|
490
|
+
# next-door neighbor", "my best friend", "my old soccer coach".
|
|
491
|
+
#
|
|
492
|
+
# The reference comments this class as "lower-case only, so a capitalised
|
|
493
|
+
# name cannot be swallowed as a modifier". That is **not** what it does:
|
|
494
|
+
# every caller folds its window with `.lower()` before matching, so no
|
|
495
|
+
# capital ever reaches `[a-z]` and the restriction cannot fire. "My Old
|
|
496
|
+
# soccer coach Deshawn" is accepted exactly as "my old soccer coach Deshawn"
|
|
497
|
+
# is. Kept as-is because the behaviour is identical in all three languages
|
|
498
|
+
# and a port is the wrong place to change a rule.
|
|
499
|
+
MODIFIERS = "(?:[a-z][a-z'’-]*\\s+){0,2}"
|
|
500
|
+
|
|
501
|
+
# "my cousin " immediately before the span. Anchored at the end: the relation
|
|
502
|
+
# phrase has to run right up to the name, which is what makes it name *that*
|
|
503
|
+
# person rather than merely appear in the same sentence.
|
|
504
|
+
RELATION_ATTACHED_BEFORE = Regexp.new(
|
|
505
|
+
"#{NOT_WORD_BEFORE}(?:my|our)\\s+#{MODIFIERS}(?:#{RELATION_ALTERNATION})\\s+\\z",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# ", my next-door neighbor" immediately after it. The comma is required — an
|
|
509
|
+
# appositive is punctuated and a prepositional phrase is not, and that is the
|
|
510
|
+
# whole difference between "Alice Adams, my neighbor," and "Harry Potter …
|
|
511
|
+
# with my little brother".
|
|
512
|
+
RELATION_ATTACHED_AFTER = Regexp.new(
|
|
513
|
+
"\\A\\s*,\\s*(?:who\\s+(?:is|was)\\s+)?(?:my|our)\\s+#{MODIFIERS}" \
|
|
514
|
+
"(?:#{RELATION_ALTERNATION})#{NOT_WORD_AFTER}",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# A title whose own first words are a first-person relation — "My Cousin
|
|
518
|
+
# Vinny", "My Sister Eileen", "My Best Friend Anne Frank". 41 keys in the
|
|
519
|
+
# shipped tier, and they are the most dangerous shape in it: the phrase they
|
|
520
|
+
# occupy is `kinship-possessive`, the single commonest frame a student names
|
|
521
|
+
# somebody in.
|
|
522
|
+
TITLE_LEADS_WITH_RELATION = Regexp.new(
|
|
523
|
+
"\\A(?:my|our)\\s+#{MODIFIERS}(?:#{RELATION_ALTERNATION})#{NOT_WORD_AFTER}",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# The tier a candidate must resolve to before it may establish a surname.
|
|
527
|
+
#
|
|
528
|
+
# A place, a landmark, a work title and an already-bare iconic surname are
|
|
529
|
+
# all excluded: none of them is a person written first-name-then-surname, so
|
|
530
|
+
# none carries evidence about what a bare surname in the same document means.
|
|
531
|
+
#
|
|
532
|
+
# Pinned against `corroboration.tier` in `conformance/primitives.json`,
|
|
533
|
+
# because a port that compared against some other string would corroborate
|
|
534
|
+
# nothing and still pass every other case — a corroboration that never fires
|
|
535
|
+
# is invisible in output the span was going to be masked in anyway.
|
|
536
|
+
CORROBORATING_TIER = "full_name"
|
|
537
|
+
|
|
538
|
+
# `PARTICLES` as a set, for the membership tests the surname folding does.
|
|
539
|
+
PARTICLE_SET = Set.new(PARTICLES).freeze
|
|
540
|
+
|
|
541
|
+
class << self
|
|
542
|
+
# ---------------------------------------------------------------------
|
|
543
|
+
# Primitives
|
|
544
|
+
# ---------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
# Every match of `pattern` in `text`, with offsets — Ruby's answer to
|
|
547
|
+
# `re.finditer`.
|
|
548
|
+
#
|
|
549
|
+
# A zero-length match advances by one character rather than looping
|
|
550
|
+
# forever, which is what both other languages' global iteration does.
|
|
551
|
+
# {SENTENCE_BREAK} can match empty at offset 0, so this is reached rather
|
|
552
|
+
# than theoretical.
|
|
553
|
+
def each_match(text, pattern)
|
|
554
|
+
return enum_for(:each_match, text, pattern) unless block_given?
|
|
555
|
+
|
|
556
|
+
pos = 0
|
|
557
|
+
length = text.length
|
|
558
|
+
while pos <= length && (m = pattern.match(text, pos))
|
|
559
|
+
yield m
|
|
560
|
+
pos = m.end(0) > m.begin(0) ? m.end(0) : m.begin(0) + 1
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Python's `str.strip(chars)`: drop any of `chars` from both ends.
|
|
565
|
+
def strip(text, chars)
|
|
566
|
+
first = 0
|
|
567
|
+
last = text.length
|
|
568
|
+
first += 1 while first < last && chars.include?(text[first])
|
|
569
|
+
last -= 1 while last > first && chars.include?(text[last - 1])
|
|
570
|
+
text[first...last]
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Python's `str.isupper()` for a token this module's patterns can produce.
|
|
574
|
+
#
|
|
575
|
+
# True when the token has at least one cased character and none of them is
|
|
576
|
+
# lowercase. Every token here starts with an ASCII letter, so the cased set
|
|
577
|
+
# is non-empty and the comparison is the whole test; the apostrophes and
|
|
578
|
+
# hyphens in between are uncased and drop out of it in every language.
|
|
579
|
+
def upper?(token)
|
|
580
|
+
token == token.upcase && token != token.downcase
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Python's `str.islower()` for a token this module produces.
|
|
584
|
+
#
|
|
585
|
+
# Every token here comes from {ANY_TOKEN}, which is `[A-Za-z][A-Za-z'’-]*`
|
|
586
|
+
# — so a cased character is always present and the "at least one cased
|
|
587
|
+
# char" half of Python's contract is satisfied by construction, leaving the
|
|
588
|
+
# comparison.
|
|
589
|
+
def lower?(token)
|
|
590
|
+
token == token.downcase
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# `word` with one trailing contraction or possessive tail removed.
|
|
594
|
+
#
|
|
595
|
+
# Returns `word` unchanged when there is nothing to remove, so a caller can
|
|
596
|
+
# compare the two and tell whether the fold did anything. Only one tail
|
|
597
|
+
# comes off — "Terrence's" is a name plus a possessive, not a name plus
|
|
598
|
+
# two.
|
|
599
|
+
def without_clitic(word)
|
|
600
|
+
CLITICS.each do |clitic|
|
|
601
|
+
return word[0...-clitic.length] if word.end_with?(clitic) && word.length > clitic.length
|
|
602
|
+
end
|
|
603
|
+
word
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Whether `token` is an ordinary word that must never become a candidate.
|
|
607
|
+
def stop?(token)
|
|
608
|
+
word = without_clitic(strip(token.downcase, ".,"))
|
|
609
|
+
stop_words.include?(strip(word, "'’"))
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Words split on whitespace, the way Python's bare `str.split()` does.
|
|
613
|
+
def words(text)
|
|
614
|
+
text.split(/\s+/).reject(&:empty?)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Every {ANY_TOKEN} match in `text`, as Python's `findall` returns them.
|
|
618
|
+
def any_tokens(text)
|
|
619
|
+
text.scan(ANY_TOKEN)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# The first clause of `text` — the scan stops at terminal punctuation.
|
|
623
|
+
def first_clause(text)
|
|
624
|
+
text.split(/[.!?\n]/, -1)[0].to_s
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Whether `[start, finish)` overlaps any of `spans`.
|
|
628
|
+
def overlaps?(spans, start, finish)
|
|
629
|
+
spans.any? { |span_start, span_end| start < span_end && finish > span_start }
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# ---------------------------------------------------------------------
|
|
633
|
+
# Classification
|
|
634
|
+
# ---------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
# The placeholder a candidate of this kind masks as.
|
|
637
|
+
def placeholder_for(kind)
|
|
638
|
+
%w[ORGANIZATION LOCATION].include?(kind) ? "{#{kind}}" : "{NAME}"
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Every tag the evidence supports for this span. Decides nothing.
|
|
642
|
+
#
|
|
643
|
+
# Separated from the decision on purpose: this reads evidence and
|
|
644
|
+
# {PRECEDENCE} applies policy, so changing what we do about a collision is
|
|
645
|
+
# an edit to a table rather than to a detector.
|
|
646
|
+
#
|
|
647
|
+
# `settlement` absent means the `LOCATION` tag is never reachable — the
|
|
648
|
+
# behaviour before the tier existed, and the behaviour a caller that wires
|
|
649
|
+
# no oracles still gets.
|
|
650
|
+
def classify_tags(tokens, settlement = nil)
|
|
651
|
+
# PERSON is unconditional: the span reached the table because it is
|
|
652
|
+
# name-shaped, so the tag records that there is no evidence *beyond* the
|
|
653
|
+
# shape. Making it unconditional is what makes the table total.
|
|
654
|
+
tags = Set.new(["PERSON"])
|
|
655
|
+
# No tokens is no evidence, which is what a bare PERSON tag already says.
|
|
656
|
+
return tags if tokens.empty?
|
|
657
|
+
|
|
658
|
+
tail = strip(tokens[-1].downcase, ".,")
|
|
659
|
+
tags << "ORGANIZATION" if ORG_SUFFIXES.include?(tail)
|
|
660
|
+
tags << "LOCATION" if !settlement.nil? && settlement.call(tokens.join(" "))
|
|
661
|
+
# Multi-token only: a bare "Park" is a surname far more often than a
|
|
662
|
+
# place.
|
|
663
|
+
tags << "LANDMARK" if tokens.length > 1 && LANDMARK_SUFFIXES.include?(tail)
|
|
664
|
+
tags
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# The first row of {PRECEDENCE} this span carries the tag for.
|
|
668
|
+
def resolve(tags)
|
|
669
|
+
row = PRECEDENCE.find { |r| tags.include?(r.tag) }
|
|
670
|
+
# Unreachable: PERSON is unconditional, so the last row always matches.
|
|
671
|
+
raise "no precedence row matched #{tags.to_a.sort.join(',')}" if row.nil?
|
|
672
|
+
|
|
673
|
+
row
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Which placeholder kind this span would mask as.
|
|
677
|
+
#
|
|
678
|
+
# The kind half of the table's verdict. A span the table *keeps* has no
|
|
679
|
+
# placeholder, and types `NAME` here as an inert default — nothing reads
|
|
680
|
+
# it, because the masking pass asks the same table for the verdict first.
|
|
681
|
+
def classify(tokens, settlement = nil)
|
|
682
|
+
resolve(classify_tags(tokens, settlement)).kind || "NAME"
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Whether `name` carries the `LANDMARK` tag — a suffix guess, no lookup.
|
|
686
|
+
#
|
|
687
|
+
# A tag, not a verdict. It says the span *looks* like a landmark, which is
|
|
688
|
+
# all a word ending can say; whether that keeps the span is {PRECEDENCE}'s
|
|
689
|
+
# call, and a settlement lookup outranks it.
|
|
690
|
+
def public_landmark?(name)
|
|
691
|
+
classify_tags(words(name)).include?("LANDMARK")
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Drop stoplisted tokens, splitting the span where one sits inside it.
|
|
695
|
+
#
|
|
696
|
+
# "MY BEST FRIEND DESHAWN PRITCHARD WOULD NEVER" is one match, because in
|
|
697
|
+
# an all-caps sentence every token is capitalised. Trimming the edges is
|
|
698
|
+
# not enough — the name is in the middle — so an interior stopword ends the
|
|
699
|
+
# run and starts a new one.
|
|
700
|
+
#
|
|
701
|
+
# The exception is an honorific introducing a name. "Mrs" and "Dr" are in
|
|
702
|
+
# the stoplist so that a bare "Mrs." cannot become a candidate on its own,
|
|
703
|
+
# but "Mrs. Okonkwo" has to stay whole: masking only the surname leaves the
|
|
704
|
+
# relationship and the surname's position in the text.
|
|
705
|
+
def trim(tokens)
|
|
706
|
+
runs = []
|
|
707
|
+
current = []
|
|
708
|
+
tokens.each_with_index do |token, index|
|
|
709
|
+
introduces_a_name =
|
|
710
|
+
HONORIFIC_SET.include?(strip(token.downcase, ".,")) &&
|
|
711
|
+
index + 1 < tokens.length &&
|
|
712
|
+
!stop?(tokens[index + 1])
|
|
713
|
+
if stop?(token) && !introduces_a_name
|
|
714
|
+
unless current.empty?
|
|
715
|
+
runs << current
|
|
716
|
+
current = []
|
|
717
|
+
end
|
|
718
|
+
next
|
|
719
|
+
end
|
|
720
|
+
current << token
|
|
721
|
+
end
|
|
722
|
+
runs << current unless current.empty?
|
|
723
|
+
runs
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# ---------------------------------------------------------------------
|
|
727
|
+
# Reading the document's own orthography
|
|
728
|
+
# ---------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
# Offsets at which a sentence begins.
|
|
731
|
+
def sentence_starts(text)
|
|
732
|
+
each_match(text, SENTENCE_BREAK).map { |m| m.begin(0) + m[0].length }.to_set
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
# Character ranges of all-caps runs SHORTER than {ALLCAPS_RUN}.
|
|
736
|
+
#
|
|
737
|
+
# A long all-caps run is a writer who has stopped using case at all, and
|
|
738
|
+
# the stoplist handles it. A one- or two-word run inside mixed-case prose
|
|
739
|
+
# is emphasis — the informal register's italics — and it is where "SLAM",
|
|
740
|
+
# "WHACK", "LAUGHTER" and "REDACT" came from on real student writing.
|
|
741
|
+
#
|
|
742
|
+
# Single-character tokens are excluded: "I" is upper-case for every writer,
|
|
743
|
+
# and the initials in "J. R. Tolkien" are part of a name rather than a
|
|
744
|
+
# shout.
|
|
745
|
+
def emphasis_spans(text)
|
|
746
|
+
runs = []
|
|
747
|
+
current = []
|
|
748
|
+
each_match(text, WORD_TOKEN) do |m|
|
|
749
|
+
token = m[0]
|
|
750
|
+
if token.length > 1 && upper?(token)
|
|
751
|
+
current << [m.begin(0), m.begin(0) + token.length]
|
|
752
|
+
next
|
|
753
|
+
end
|
|
754
|
+
unless current.empty?
|
|
755
|
+
runs << current
|
|
756
|
+
current = []
|
|
757
|
+
end
|
|
758
|
+
end
|
|
759
|
+
runs << current unless current.empty?
|
|
760
|
+
runs.select { |run| run.length < ALLCAPS_RUN }
|
|
761
|
+
.map { |run| [run[0][0], run[-1][1]] }
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# Character ranges of lines that are section headings, not prose.
|
|
765
|
+
#
|
|
766
|
+
# A heading is title-cased by convention, so **every capital in it is
|
|
767
|
+
# orthographic** and none of it is testimony about any word. This replaces
|
|
768
|
+
# a rule that read the same spans as emphasis, which the data does not
|
|
769
|
+
# support: across the 27 un-scrubbed documents there was not one instance
|
|
770
|
+
# of a writer capitalising an initial letter for emphasis. Emphasis in
|
|
771
|
+
# student prose is ALL CAPS ("this is BULLSHIT") or mixed caps, and
|
|
772
|
+
# {.emphasis_spans} already has it. What actually generates these spans is
|
|
773
|
+
# layout — "Horses" on its own line, "Horse Families", "Breeds I Like", "My
|
|
774
|
+
# Description of a Horse".
|
|
775
|
+
#
|
|
776
|
+
# Three conditions, all structural and none of them a word list:
|
|
777
|
+
#
|
|
778
|
+
# * short — under {HEADING_MAX_CHARS};
|
|
779
|
+
# * no terminal punctuation — a heading is not a sentence;
|
|
780
|
+
# * preceded by a blank line, or first in the document.
|
|
781
|
+
#
|
|
782
|
+
# The blank line is load-bearing rather than belt-and-braces. Body prose
|
|
783
|
+
# here is hard-wrapped, so "The INternet as we know it today first" is a
|
|
784
|
+
# short unpunctuated line too, and without the blank-line test it would
|
|
785
|
+
# read as a heading and take a real name's evidence with it.
|
|
786
|
+
def heading_spans(text)
|
|
787
|
+
out = []
|
|
788
|
+
offset = 0
|
|
789
|
+
previous_blank = true # start of document counts
|
|
790
|
+
# `-1` keeps the trailing empty field, so a document ending in a newline
|
|
791
|
+
# advances the offset the same way one that does not.
|
|
792
|
+
text.split("\n", -1).each do |line|
|
|
793
|
+
stripped = line.strip
|
|
794
|
+
if !stripped.empty? &&
|
|
795
|
+
stripped.length < HEADING_MAX_CHARS &&
|
|
796
|
+
!".!?".include?(stripped[-1]) &&
|
|
797
|
+
previous_blank
|
|
798
|
+
out << [offset, offset + line.length]
|
|
799
|
+
end
|
|
800
|
+
previous_blank = stripped.empty?
|
|
801
|
+
offset += line.length + 1
|
|
802
|
+
end
|
|
803
|
+
out
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Classify how this document's writer uses capitals. See {CONSISTENT} and
|
|
807
|
+
# its siblings.
|
|
808
|
+
#
|
|
809
|
+
# Two independent readings, each taken from evidence the writer supplied
|
|
810
|
+
# rather than inferred from what is missing.
|
|
811
|
+
#
|
|
812
|
+
# **Does it mark proper nouns?** Count mid-sentence capitals, excluding any
|
|
813
|
+
# that fall inside a heading. Sentence-initial capitals are not counted at
|
|
814
|
+
# all: a student who capitalises the start of each sentence but not the
|
|
815
|
+
# names inside them is exactly the case the lowercase route exists for, and
|
|
816
|
+
# counting those would suppress the route on them. The heading exclusion
|
|
817
|
+
# brings this counter into line with {.mid_sentence_capitals}, which the
|
|
818
|
+
# inconsistent band falls through to — the two channels were reading the
|
|
819
|
+
# same evidence through different rules, which is a defect whatever the
|
|
820
|
+
# threshold is. Its measured effect on the 27 documents is **none**: it
|
|
821
|
+
# lowers five counts (`horses` 52 to 27 is the largest) and none of them
|
|
822
|
+
# crosses the floor. It is a precision repair, not a fix, and is recorded
|
|
823
|
+
# as one.
|
|
824
|
+
#
|
|
825
|
+
# **Does it drop standard capitals?** A bare lower-case "i" anywhere, or a
|
|
826
|
+
# lower-case sentence opening. Both are the writer's own doing rather than
|
|
827
|
+
# an inference from what is missing.
|
|
828
|
+
#
|
|
829
|
+
# **The rate is consulted on only one side of the floor, and that asymmetry
|
|
830
|
+
# is the measurement, not an oversight.** Above the floor there is a
|
|
831
|
+
# presence signal to weigh the drop side against, so the rate can say "26
|
|
832
|
+
# marks and 3 dropped openings is a writer who typed three typos" — which
|
|
833
|
+
# is `marching-to-his-own-beat`, an NWP anchor paper the boolean libelled.
|
|
834
|
+
# Below the floor there is nothing to weigh it against, and applying it
|
|
835
|
+
# there **costs a held-out name**: the `lowercase-writing` fixture frame
|
|
836
|
+
# rides in two carrier essays, and in 20739 (one mid-sentence capital, one
|
|
837
|
+
# lower-case opening in 59 sentences, no bare "i") a 1.7% drop rate demoted
|
|
838
|
+
# a genuine lower-case-writing document to `silent`, withdrew the
|
|
839
|
+
# permissive path, and leaked "terrence okonkwo". Held-out recall 28/28 to
|
|
840
|
+
# 27/28 for one span of over-firing — the wrong direction for a tool whose
|
|
841
|
+
# whole bias is over-redact rather than leak.
|
|
842
|
+
#
|
|
843
|
+
# So below the floor the document has given us one bit and it is taken
|
|
844
|
+
# conservatively: any tell at all means `lowercase`. The cost of that is
|
|
845
|
+
# `my-first-tooth-gone` staying on the permissive path when it is really a
|
|
846
|
+
# capitaliser with nothing to capitalise — and that cost was measured at
|
|
847
|
+
# **zero** spans, because its only candidate is "Boy" from the capitalised
|
|
848
|
+
# route under either reading. A guard whose failing case costs nothing,
|
|
849
|
+
# against a rate whose correction costs a name, is not a guard worth
|
|
850
|
+
# having.
|
|
851
|
+
#
|
|
852
|
+
# @param headings spans whose capitals are orthographic because title case
|
|
853
|
+
# put them there. Passed in rather than computed so the arm that turns
|
|
854
|
+
# the heading rule off stays coherent — with it off, this reads headings
|
|
855
|
+
# as prose, exactly like every other consumer of that flag.
|
|
856
|
+
def capitalisation_habit(text, headings = [])
|
|
857
|
+
marks = 0
|
|
858
|
+
each_match(text, MID_SENTENCE_CAP) do |m|
|
|
859
|
+
# The lookbehind consumes nothing, so group 1 starts where the match
|
|
860
|
+
# does — which is what the reference's `m.start(1)` resolves to too.
|
|
861
|
+
marks += 1 unless overlaps?(headings, m.begin(0), m.begin(0) + m[0].length)
|
|
862
|
+
end
|
|
863
|
+
openings = each_match(text, LOWERCASE_SENTENCE_START).count
|
|
864
|
+
# The bare "i" stays a boolean on both sides. It is the higher-precision
|
|
865
|
+
# tell — 26 of the 27 un-scrubbed documents have none at all, and the one
|
|
866
|
+
# that does has nine — so there is no noise for a rate to remove.
|
|
867
|
+
bare_i = BARE_LOWERCASE_I.match?(text)
|
|
868
|
+
|
|
869
|
+
if marks >= MARKS_PROPER_NOUNS_MIN
|
|
870
|
+
sentences = each_match(text, SENTENCE_UNIT).count { |m| !m[0].strip.empty? }
|
|
871
|
+
habitual = openings.to_f / [1, sentences].max >= DROPS_CAPITALS_MIN_RATE
|
|
872
|
+
return bare_i || habitual ? INCONSISTENT : CONSISTENT
|
|
873
|
+
end
|
|
874
|
+
bare_i || openings.positive? ? LOWERCASE : SILENT
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Whether the writer puts capitals on proper nouns at all.
|
|
878
|
+
#
|
|
879
|
+
# True for both `consistent` and `inconsistent`: an inconsistent writer who
|
|
880
|
+
# capitalised "Vinny" and left "cousin" lower-case made a choice, and that
|
|
881
|
+
# choice is testimony. It is the *absence* of a capital that means nothing
|
|
882
|
+
# in a `lowercase` or `silent` document.
|
|
883
|
+
def marks_proper_nouns?(habit)
|
|
884
|
+
habit == CONSISTENT || habit == INCONSISTENT
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Whether the writer drops standard capitals as a habit.
|
|
888
|
+
def drops_capitals?(habit)
|
|
889
|
+
habit == INCONSISTENT || habit == LOWERCASE
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Lower-cased forms of every word this document capitalises mid-sentence.
|
|
893
|
+
#
|
|
894
|
+
# The document's own testimony about a particular word, which is the graded
|
|
895
|
+
# version of {.capitalisation_habit}, and what its `inconsistent` state
|
|
896
|
+
# falls through to. A writer who put a capital on "Cade" somewhere other
|
|
897
|
+
# than a sentence start has told us "Cade" is a name in this document; one
|
|
898
|
+
# who only ever writes "Eventually" after a full stop has told us nothing,
|
|
899
|
+
# because orthography would have put that capital there anyway.
|
|
900
|
+
#
|
|
901
|
+
# An entirely upper-case token is excluded, and that exclusion is
|
|
902
|
+
# load-bearing rather than tidy. Without it "SLAM" corroborates itself —
|
|
903
|
+
# the token is its own mid-sentence capital — so every emphasis shout would
|
|
904
|
+
# clear the bar the emphasis rule had just raised. A capital is testimony
|
|
905
|
+
# only where the writer had a lower-case alternative and declined it.
|
|
906
|
+
#
|
|
907
|
+
# A heading is excluded for the same reason: it is title-cased, so its
|
|
908
|
+
# non-initial capitals are orthographic too. Counting them let "The First
|
|
909
|
+
# Horses" vouch for "Horses" as a name — the heading corroborating itself,
|
|
910
|
+
# one line removed.
|
|
911
|
+
def mid_sentence_capitals(text, starts, headings = [])
|
|
912
|
+
out = Set.new
|
|
913
|
+
each_match(text, WORD_TOKEN) do |m|
|
|
914
|
+
token = m[0]
|
|
915
|
+
next if starts.include?(m.begin(0)) || !token[0].match?(/[A-Z]/)
|
|
916
|
+
next if token.length > 1 && upper?(token)
|
|
917
|
+
next if overlaps?(headings, m.begin(0), m.begin(0) + token.length)
|
|
918
|
+
|
|
919
|
+
out << strip(token.downcase, "'’")
|
|
920
|
+
end
|
|
921
|
+
out
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# ---------------------------------------------------------------------
|
|
925
|
+
# The sentence-initial guard
|
|
926
|
+
# ---------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
# Whether this span rests on a capital that had to be there anyway.
|
|
929
|
+
#
|
|
930
|
+
# Three shapes are excluded, because each carries evidence beyond the
|
|
931
|
+
# capital: a multi-token span ("Sadie Johnson") is a *shape*; an honorific
|
|
932
|
+
# in front of the name is a relationship; and a capital in the middle of a
|
|
933
|
+
# sentence is a choice the writer made rather than one orthography made for
|
|
934
|
+
# them.
|
|
935
|
+
#
|
|
936
|
+
# A heading is the exception to the first of those. Title case capitalises
|
|
937
|
+
# every word, so "Horse Families" is not a shape there — the second capital
|
|
938
|
+
# is as orthographic as the first, and a multi-token span inside a heading
|
|
939
|
+
# has no more evidence than a single-token one. So the multi-token
|
|
940
|
+
# exemption does not apply inside a heading, and "My Brother Terrence
|
|
941
|
+
# Okonkwo" as a heading is still caught: it needs the given-name tier
|
|
942
|
+
# rather than its own capitals, which is exactly the bar every other
|
|
943
|
+
# unevidenced capital has to clear.
|
|
944
|
+
def capital_is_the_only_evidence?(tokens, start, starts, emphasis, headings = [])
|
|
945
|
+
finish = start + tokens.join(" ").length
|
|
946
|
+
in_heading = overlaps?(headings, start, finish)
|
|
947
|
+
return false if tokens.length > 1 && !in_heading
|
|
948
|
+
return true if in_heading
|
|
949
|
+
return true if overlaps?(emphasis, start, finish)
|
|
950
|
+
|
|
951
|
+
starts.include?(start)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# A second signal, for a span whose capital proves nothing on its own.
|
|
955
|
+
#
|
|
956
|
+
# Two channels: the document's own mid-sentence capitalisation of the word
|
|
957
|
+
# (`written_as_a_capital`, from {.mid_sentence_capitals}), and the
|
|
958
|
+
# given-name tier. `is_given` is passed in rather than defaulted so this is
|
|
959
|
+
# only reachable on the path where an oracle exists.
|
|
960
|
+
#
|
|
961
|
+
# ANY token counts, not just the first, and the heading rule is what made
|
|
962
|
+
# that distinction load-bearing. Before it, this was only ever reached for
|
|
963
|
+
# single-token spans, so "first token" and "any token" were the same thing.
|
|
964
|
+
# A heading is title-cased, so a multi-token span inside one also arrives
|
|
965
|
+
# here — and "My Brother Terrence Okonkwo" leads with an honorific, so
|
|
966
|
+
# checking only the first token consulted "Brother" and leaked the name.
|
|
967
|
+
def corroborated?(tokens, written_as_a_capital, is_given)
|
|
968
|
+
# Both channels see the same stripped token, and the strip set is `.,'’`
|
|
969
|
+
# rather than the `'’` {.mid_sentence_capitals} folds with. That
|
|
970
|
+
# asymmetry is deliberate and was a defect once: the capital channel
|
|
971
|
+
# stripped `.,'’` and the given-name channel got the raw token, so a name
|
|
972
|
+
# against a closing quote — "words like 'Terrence'", which the candidate
|
|
973
|
+
# pattern hands over as `Terrence'` because an apostrophe is a name
|
|
974
|
+
# character — asked the tier about `Terrence'` and was told no.
|
|
975
|
+
tokens.each do |token|
|
|
976
|
+
stripped = strip(token.downcase, ".,'’")
|
|
977
|
+
return true if written_as_a_capital.include?(stripped) || is_given.call(stripped)
|
|
978
|
+
|
|
979
|
+
# ...and again with the possessive off. "Terrence's" at a sentence
|
|
980
|
+
# start is the shape this is for: the writer capitalised "Terrence"
|
|
981
|
+
# elsewhere in the document, which is testimony about the name, and the
|
|
982
|
+
# `'s` is not part of it. Without this the document's own capital
|
|
983
|
+
# cannot vouch for its own possessive, so the span is suppressed and
|
|
984
|
+
# the name ships.
|
|
985
|
+
#
|
|
986
|
+
# The gazetteer's given-name tier folds possessives itself, so the
|
|
987
|
+
# shipped arm already behaved this way through channel two and nothing
|
|
988
|
+
# changes for it. What the fold buys is the *first* channel, which had
|
|
989
|
+
# no such normalisation, and independence from an oracle contract
|
|
990
|
+
# nobody wrote down.
|
|
991
|
+
#
|
|
992
|
+
# Strictly additive: it can turn a false into a true and never the
|
|
993
|
+
# reverse, so it can only reduce suppression, never increase it.
|
|
994
|
+
folded = without_clitic(stripped)
|
|
995
|
+
if folded != stripped &&
|
|
996
|
+
(written_as_a_capital.include?(folded) || is_given.call(folded))
|
|
997
|
+
return true
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
false
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
# The sentence-initial guard: drop a span whose only evidence is a capital
|
|
1004
|
+
# that orthography required, unless a second channel vouches for it.
|
|
1005
|
+
#
|
|
1006
|
+
# The two halves are separate methods because they answer separate
|
|
1007
|
+
# questions — "is the capital all we have?" and "is there anything else?" —
|
|
1008
|
+
# and this is the conjunction {.find_candidates} applies.
|
|
1009
|
+
#
|
|
1010
|
+
# **Requiring a second signal is only sound when there is a second signal
|
|
1011
|
+
# to require.** Without a given-name list the document's own capitalisation
|
|
1012
|
+
# is the sole channel, and a name mentioned once at a sentence start is
|
|
1013
|
+
# then genuinely indistinguishable from "Eventually" — so the no-oracle arm
|
|
1014
|
+
# keeps its recall-maximal, precision-minimal character rather than
|
|
1015
|
+
# becoming quietly stricter. That is why the caller reaches this only when
|
|
1016
|
+
# an oracle was passed, and why this takes `is_given` rather than treating
|
|
1017
|
+
# its absence as permissive.
|
|
1018
|
+
#
|
|
1019
|
+
# Measured on real prose: 133 occurrences over 101 distinct spans
|
|
1020
|
+
# suppressed, 99 of the 101 correctly — about 98% precise. The two it gets
|
|
1021
|
+
# wrong are names written once, at a sentence start, that no tier knows.
|
|
1022
|
+
# **Do not "fix" it**; the tier feeding it was the defect, and that was
|
|
1023
|
+
# addressed in 0.1.0 by adding SSA births to the given-name tier.
|
|
1024
|
+
def suppressed_as_an_unevidenced_capital?(tokens, start, starts, emphasis, headings,
|
|
1025
|
+
written_as_a_capital, is_given)
|
|
1026
|
+
capital_is_the_only_evidence?(tokens, start, starts, emphasis, headings) &&
|
|
1027
|
+
!corroborated?(tokens, written_as_a_capital, is_given)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
# ---------------------------------------------------------------------
|
|
1031
|
+
# Titles, and the relations that override them
|
|
1032
|
+
# ---------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
# Character ranges covered by a work title or a fictional character name.
|
|
1035
|
+
#
|
|
1036
|
+
# Runs against the raw text *before* candidate generation, longest match
|
|
1037
|
+
# first, and the ranges it returns are protected exactly like an upstream
|
|
1038
|
+
# anonymization marker. That ordering is the whole point: the notability
|
|
1039
|
+
# oracle cannot save a title, because generation never hands it one. "To
|
|
1040
|
+
# Kill a Mockingbird" is split by the stoplisted "a" into two candidates,
|
|
1041
|
+
# and no lookup on either half recovers the book.
|
|
1042
|
+
#
|
|
1043
|
+
# Matches do not overlap — once a span is claimed the scan resumes after it
|
|
1044
|
+
# — so "The Lion King" cannot also match a shorter title inside itself.
|
|
1045
|
+
#
|
|
1046
|
+
# The 8-token limit is a named limit, not an oversight: the tier's longest
|
|
1047
|
+
# entry is 36 tokens, but scanning that far costs 36 lookups per token
|
|
1048
|
+
# position for titles nobody writes in an essay. 8 covers "To Kill a
|
|
1049
|
+
# Mockingbird"; "The Curious Incident of the Dog in the Night-Time" is 10
|
|
1050
|
+
# and is NOT matched.
|
|
1051
|
+
#
|
|
1052
|
+
# @param is_prefix answers "does some title start with these folded
|
|
1053
|
+
# tokens?" and is the automaton this scan walks. It doubles as the
|
|
1054
|
+
# first-token prefilter, since a length-1 prefix *is* a title head.
|
|
1055
|
+
# Supplied, the walk stops as soon as no title can still be reached — one
|
|
1056
|
+
# or two tokens on ordinary prose, against the eight-lookup worst case
|
|
1057
|
+
# the length-descending scan paid at every position whose first word
|
|
1058
|
+
# happens to head some title ("the", "a", "my"). Absent, every length up
|
|
1059
|
+
# to {TITLE_MAX_TOKENS} is tried and the result is identical; only the
|
|
1060
|
+
# cost differs.
|
|
1061
|
+
# @param requires_capital in a document that capitalises its proper nouns,
|
|
1062
|
+
# a title's first word is capitalised too. Requiring that skips almost
|
|
1063
|
+
# every position in ordinary prose. Documents that do NOT capitalise are
|
|
1064
|
+
# scanned at every position, because there the case carries nothing.
|
|
1065
|
+
def find_title_spans(text, is_title, is_prefix = nil, requires_capital: false)
|
|
1066
|
+
tokens = each_match(text, ANY_TOKEN).map do |m|
|
|
1067
|
+
[m.begin(0), m.begin(0) + m[0].length, m[0].downcase.gsub(CURLY_APOSTROPHE, "'")]
|
|
1068
|
+
end
|
|
1069
|
+
spans = []
|
|
1070
|
+
index = 0
|
|
1071
|
+
while index < tokens.length
|
|
1072
|
+
head_start, head_end, = tokens[index]
|
|
1073
|
+
if requires_capital && !text[head_start].match?(/[A-Z]/)
|
|
1074
|
+
index += 1
|
|
1075
|
+
next
|
|
1076
|
+
end
|
|
1077
|
+
longest = 0
|
|
1078
|
+
longest_end = head_end
|
|
1079
|
+
key = ""
|
|
1080
|
+
limit = [TITLE_MAX_TOKENS, tokens.length - index].min
|
|
1081
|
+
(1..limit).each do |length|
|
|
1082
|
+
_, token_end, token_key = tokens[index + length - 1]
|
|
1083
|
+
key = length == 1 ? token_key : "#{key} #{token_key}"
|
|
1084
|
+
# Multi-token only: "It" and "Up" must not make ordinary words
|
|
1085
|
+
# permanently notable.
|
|
1086
|
+
if length > 1 && is_title.call(text[head_start...token_end])
|
|
1087
|
+
longest = length
|
|
1088
|
+
longest_end = token_end
|
|
1089
|
+
end
|
|
1090
|
+
break if !is_prefix.nil? && !is_prefix.call(key)
|
|
1091
|
+
end
|
|
1092
|
+
spans << [head_start, longest_end] if longest.positive?
|
|
1093
|
+
index += longest.positive? ? longest : 1
|
|
1094
|
+
end
|
|
1095
|
+
spans
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# Whether the local context marks this surname as personal, not public.
|
|
1099
|
+
#
|
|
1100
|
+
# Checked only for a bare surname the document has otherwise *established*
|
|
1101
|
+
# as a public figure's, and it is the one signal that can separate the two
|
|
1102
|
+
# readings of "Robinson" in a document containing "Jackie Robinson": the
|
|
1103
|
+
# neighbour carries an appositive about the writer's own life, and the
|
|
1104
|
+
# ballplayer does not.
|
|
1105
|
+
#
|
|
1106
|
+
# Looks after the span for an appositive or relative clause, and before it
|
|
1107
|
+
# for a possessive introduction ("my neighbour Robinson"). Both sides
|
|
1108
|
+
# matter — English puts the relation either place — and neither reaches
|
|
1109
|
+
# past one clause.
|
|
1110
|
+
def names_someone_in_the_writers_life?(text, start, finish)
|
|
1111
|
+
after = text[finish, RELATION_WINDOW].to_s.downcase
|
|
1112
|
+
before = text[[0, start - RELATION_WINDOW].max...start].to_s.downcase
|
|
1113
|
+
|
|
1114
|
+
# After: only an appositive or relative clause counts. A new sentence
|
|
1115
|
+
# does not, so the scan stops at terminal punctuation. `before` is NOT
|
|
1116
|
+
# clipped the same way — the reference scans the whole leading window.
|
|
1117
|
+
[first_clause(after), before].each do |window|
|
|
1118
|
+
return true if PROXIMITY_CUES.any? { |cue| window.include?(cue) }
|
|
1119
|
+
return true if any_tokens(window).any? { |token| RELATION_CUES.include?(token) }
|
|
1120
|
+
end
|
|
1121
|
+
false
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# Whether a relation-led title span is really the writer naming somebody.
|
|
1125
|
+
#
|
|
1126
|
+
# "My Cousin Vinny is my favorite movie" and "My cousin Vinny Delgado came
|
|
1127
|
+
# over that summer" fold to the same lookup key, and the tier keeps both.
|
|
1128
|
+
# The difference is one the writer supplied: a title is title-cased, so its
|
|
1129
|
+
# relation word carries a capital, and a sentence about a relative does
|
|
1130
|
+
# not.
|
|
1131
|
+
#
|
|
1132
|
+
# That is the same evidence the heading rule reads and the same evidence
|
|
1133
|
+
# rule 1 of the capitalisation rules reads — the document's own
|
|
1134
|
+
# orthography, not a guess about intent. Callers gate this on
|
|
1135
|
+
# {.marks_proper_nouns?}, because in a document that capitalises nothing
|
|
1136
|
+
# the absent capital is not testimony about anything. An INCONSISTENT
|
|
1137
|
+
# writer passes that gate: they put a capital on "Vinny" and left "cousin"
|
|
1138
|
+
# lower-case, and that is a choice rather than an absence.
|
|
1139
|
+
#
|
|
1140
|
+
# The cost of being wrong is a student who writes "my cousin vinny is my
|
|
1141
|
+
# favorite movie" losing the film to a placeholder inbound. The cost of the
|
|
1142
|
+
# other error is a cousin's name reaching a third-party model.
|
|
1143
|
+
def title_is_the_writers_own_relation?(text, start, finish)
|
|
1144
|
+
span = text[start...finish].to_s
|
|
1145
|
+
return false unless TITLE_LEADS_WITH_RELATION.match?(span.downcase)
|
|
1146
|
+
|
|
1147
|
+
# Everything after the leading possessive: "Cousin Vinny" in the title,
|
|
1148
|
+
# "cousin Vinny" in the sentence. The relation word is the one that
|
|
1149
|
+
# differs.
|
|
1150
|
+
any_tokens(span)[1, 2].to_a.any? { |token| lower?(token) }
|
|
1151
|
+
end
|
|
1152
|
+
|
|
1153
|
+
# Whether the span alone proves the writer used capitals and skipped one.
|
|
1154
|
+
#
|
|
1155
|
+
# The document-level gate on {.title_is_the_writers_own_relation?} costs a
|
|
1156
|
+
# leak on the shortest documents. `marks_proper_nouns?` needs two
|
|
1157
|
+
# capitalised names *somewhere else* to be true, and "My cousin Vinny came
|
|
1158
|
+
# over that summer and never left." has none — the only other capital is
|
|
1159
|
+
# sentence-initial. So the refusal switched off, the 1992 film kept the
|
|
1160
|
+
# span, and the cousin's name shipped. Measured, not supposed: adding one
|
|
1161
|
+
# unrelated name ("the Alvarez family") to the same sentence flips the
|
|
1162
|
+
# document tell and the same cousin masks correctly. A leak that depends on
|
|
1163
|
+
# how much *else* the student wrote is a leak.
|
|
1164
|
+
#
|
|
1165
|
+
# What this reads instead is confined to the span, so it needs no document:
|
|
1166
|
+
#
|
|
1167
|
+
# My Cousin Vinny -- every token capitalised; the film. Already
|
|
1168
|
+
# excluded by title_is_the_writers_own_relation?.
|
|
1169
|
+
# My cousin Vinny -- the name carries a capital and the relation word
|
|
1170
|
+
# does not. MIXED: the writer uses capitals, and
|
|
1171
|
+
# chose not to put one on "cousin". A relative.
|
|
1172
|
+
# my cousin vinny -- nothing carries a capital. Not mixed, and the
|
|
1173
|
+
# document gate above applies in full.
|
|
1174
|
+
#
|
|
1175
|
+
# The trailing token is the test rather than "any token", because the
|
|
1176
|
+
# leading possessive is sentence-initial in every frame this shape occurs
|
|
1177
|
+
# in, and a sentence-initial capital is orthography, not evidence.
|
|
1178
|
+
def relation_led_title_is_internally_mixed?(text, start, finish)
|
|
1179
|
+
tokens = any_tokens(text[start...finish].to_s)
|
|
1180
|
+
return false if tokens.length < 2
|
|
1181
|
+
|
|
1182
|
+
last = tokens[-1]
|
|
1183
|
+
initial = last[0, 1]
|
|
1184
|
+
# Python's `[:1].isupper()`; the character is an ASCII letter by
|
|
1185
|
+
# construction.
|
|
1186
|
+
starts_upper = !initial.empty? && initial == initial.upcase
|
|
1187
|
+
starts_upper && tokens[1, 2].to_a.any? { |token| lower?(token) }
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
# Whether a first-person relation is syntactically attached to this name.
|
|
1191
|
+
#
|
|
1192
|
+
# The strict sibling of {.names_someone_in_the_writers_life?}, and strict
|
|
1193
|
+
# for a measured reason. That method scans a window for any relation cue,
|
|
1194
|
+
# which is right for a bare surname the document itself established — but
|
|
1195
|
+
# applied to the title tier it refuses six of the seven curriculum
|
|
1196
|
+
# characters it must keep, because characters are *described by* their
|
|
1197
|
+
# relations: Atticus Finch is a father, Peter Parker lives with his aunt,
|
|
1198
|
+
# Tom Sawyer talks his friends into whitewashing a fence. A relation noun
|
|
1199
|
+
# in the window is therefore no evidence at all about a work title.
|
|
1200
|
+
#
|
|
1201
|
+
# Two things separate "My neighbor Alice Adams" from those. The relation is
|
|
1202
|
+
# **first-person** — the writer's own — and it is **attached** to the name,
|
|
1203
|
+
# either immediately before it or inside the appositive immediately after
|
|
1204
|
+
# it. Both are required. First person alone keeps "I read Harry Potter with
|
|
1205
|
+
# my little brother"; attachment alone keeps "Atticus Finch, a father
|
|
1206
|
+
# who…".
|
|
1207
|
+
#
|
|
1208
|
+
# The error costs are asymmetric and that is what makes the rule affordable
|
|
1209
|
+
# at all: a title hit overridden wrongly over-redacts a book the student
|
|
1210
|
+
# wrote about, which the inbound placeholder absorbs; a title hit honoured
|
|
1211
|
+
# wrongly ships a classmate's name to a third-party model.
|
|
1212
|
+
def names_someone_the_writer_knows?(text, start, finish)
|
|
1213
|
+
before = text[[0, start - RELATION_WINDOW].max...start].to_s.downcase
|
|
1214
|
+
after = text[finish, RELATION_WINDOW].to_s.downcase
|
|
1215
|
+
return true if RELATION_ATTACHED_BEFORE.match?(before)
|
|
1216
|
+
# Anchored at the start of the window by the pattern's own `\A`, which is
|
|
1217
|
+
# what the reference's `match` rather than `search` carries.
|
|
1218
|
+
return true if RELATION_ATTACHED_AFTER.match?(after)
|
|
1219
|
+
|
|
1220
|
+
# The relation expressed as distance — "Alice Adams, who lives two doors
|
|
1221
|
+
# down from us". Same attachment requirement (the clause is the
|
|
1222
|
+
# appositive that follows the name), plus a first-person pronoun, because
|
|
1223
|
+
# "two doors down" on its own says nothing about whose street it is.
|
|
1224
|
+
if after.lstrip.start_with?(",")
|
|
1225
|
+
clause = first_clause(after)
|
|
1226
|
+
if PROXIMITY_CUES.any? { |cue| clause.include?(cue) } &&
|
|
1227
|
+
any_tokens(clause).any? { |token| FIRST_PERSON.include?(token) }
|
|
1228
|
+
return true
|
|
1229
|
+
end
|
|
1230
|
+
end
|
|
1231
|
+
false
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
# ---------------------------------------------------------------------
|
|
1235
|
+
# Corroboration
|
|
1236
|
+
# ---------------------------------------------------------------------
|
|
1237
|
+
|
|
1238
|
+
# Lower-cased tokens of `name` with the possessive tail removed.
|
|
1239
|
+
#
|
|
1240
|
+
# "Wright’s" and "Wright" must fold together or corroboration reaches the
|
|
1241
|
+
# citation form of the name and not the one literary analysis actually
|
|
1242
|
+
# writes — on the un-scrubbed corpus the possessive was 10 of the 27 masked
|
|
1243
|
+
# "Wright" spans, so this is most of the effect rather than an edge case.
|
|
1244
|
+
def surname_tokens(name)
|
|
1245
|
+
folded = name.gsub(CURLY_APOSTROPHE, "'").downcase
|
|
1246
|
+
out = []
|
|
1247
|
+
folded.strip.split(/\s+/).each do |raw|
|
|
1248
|
+
token = strip(raw, ".,;:!?'\"")
|
|
1249
|
+
token = token[0...-2] if token.end_with?("'s") && token.length > 3
|
|
1250
|
+
out << token unless token.empty?
|
|
1251
|
+
end
|
|
1252
|
+
out
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
# `name` as a corroboration key, or nil if it is not a bare form.
|
|
1256
|
+
#
|
|
1257
|
+
# A bare surname is one token, or a particle-led run ("van Gogh", "de
|
|
1258
|
+
# Beauvoir") where every token but the last is a particle. Anything else —
|
|
1259
|
+
# "Coach Wright", "Priya Wright" — is a *different* candidate that happens
|
|
1260
|
+
# to share a surname, and must not be reached by another name's
|
|
1261
|
+
# corroboration.
|
|
1262
|
+
def bare_surname_key(name)
|
|
1263
|
+
tokens = surname_tokens(name)
|
|
1264
|
+
return nil if tokens.empty?
|
|
1265
|
+
return tokens[0] if tokens.length == 1
|
|
1266
|
+
return tokens.join(" ") if tokens.length <= 3 && tokens[0...-1].all? { |t| PARTICLE_SET.include?(t) }
|
|
1267
|
+
|
|
1268
|
+
nil
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
# The bare surface forms a writer may substitute for `name` later on.
|
|
1272
|
+
#
|
|
1273
|
+
# `"Richard Wright"` yields `["wright"]`; `"Vincent van Gogh"` yields
|
|
1274
|
+
# `["gogh", "van gogh"]`. The bare *first* name is never a form, for the
|
|
1275
|
+
# same reason the builder refuses to emit one: a first name is the
|
|
1276
|
+
# commonest private surface form in student prose, and corroborating it
|
|
1277
|
+
# would make one notable full name keep every "Terrence" in the document.
|
|
1278
|
+
#
|
|
1279
|
+
# Returns `[]` for a single-token name — a mononym corroborates nothing,
|
|
1280
|
+
# because it is already the bare form.
|
|
1281
|
+
def surname_forms(name)
|
|
1282
|
+
tokens = surname_tokens(name)
|
|
1283
|
+
return [] if tokens.length < 2
|
|
1284
|
+
|
|
1285
|
+
forms = [tokens[-1]]
|
|
1286
|
+
if PARTICLE_SET.include?(tokens[-2])
|
|
1287
|
+
forms << tokens[-2..].join(" ")
|
|
1288
|
+
forms << tokens[-3..].join(" ") if tokens.length >= 3 && PARTICLE_SET.include?(tokens[-3])
|
|
1289
|
+
end
|
|
1290
|
+
forms
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
# Whether `name` may establish a surname, given the two oracle shapes a
|
|
1294
|
+
# caller might have. Factored out because {.corroborated_surnames} and
|
|
1295
|
+
# {.established_name_tokens} apply the identical three-way test and the two
|
|
1296
|
+
# drifting apart is a silent asymmetry between the inbound and outbound
|
|
1297
|
+
# paths.
|
|
1298
|
+
def establishes?(name, notable, lowered_keep, tier = nil)
|
|
1299
|
+
# A name the assignment prompt supplied. Topical by construction, and the
|
|
1300
|
+
# prompt naming "Richard Wright" is the same evidence as the essay naming
|
|
1301
|
+
# him — arguably better, since it is not the student's writing.
|
|
1302
|
+
return true if lowered_keep.include?(name.downcase)
|
|
1303
|
+
return tier.call(name) == CORROBORATING_TIER unless tier.nil?
|
|
1304
|
+
|
|
1305
|
+
notable.call(name) && !public_landmark?(name)
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
# Surnames this document has already established belong to a public figure.
|
|
1309
|
+
#
|
|
1310
|
+
# The observation is narrow and it is free: if a document writes "Richard
|
|
1311
|
+
# Wright" somewhere, and the gazetteer keeps "Richard Wright", then a bare
|
|
1312
|
+
# "Wright" elsewhere in *that document* is that person. Literary-analysis
|
|
1313
|
+
# convention makes this the dominant shape of the problem — a student names
|
|
1314
|
+
# the author once and writes the surname for the rest of the essay. On the
|
|
1315
|
+
# 27 un-scrubbed student essays the shipped arm masked "Wright" or
|
|
1316
|
+
# "Wright's" 27 times in a single document that also contained "Richard
|
|
1317
|
+
# Wright's".
|
|
1318
|
+
#
|
|
1319
|
+
# What it deliberately cannot do: corroborate from a name the gazetteer
|
|
1320
|
+
# does *not* keep. A student's own "Terrence Okonkwo" establishes nothing,
|
|
1321
|
+
# so bare "Okonkwo" still redacts.
|
|
1322
|
+
#
|
|
1323
|
+
# @param tier restricts corroboration to human full names, see
|
|
1324
|
+
# {CORROBORATING_TIER}. Strongly recommended: without it a kept *place*
|
|
1325
|
+
# can license a surname, which is a measured defect and not a
|
|
1326
|
+
# hypothetical one. Absent, landmark-shaped names are excluded as a
|
|
1327
|
+
# partial substitute and the rest of the place tier is not.
|
|
1328
|
+
def corroborated_surnames(candidates, notable, keep = Set.new, tier = nil)
|
|
1329
|
+
lowered_keep = Set.new(keep.map(&:downcase))
|
|
1330
|
+
out = Set.new
|
|
1331
|
+
candidates.each do |candidate|
|
|
1332
|
+
name = candidate.text
|
|
1333
|
+
next if words(name).length < 2
|
|
1334
|
+
next unless establishes?(name, notable, lowered_keep, tier)
|
|
1335
|
+
|
|
1336
|
+
surname_forms(name).each { |form| out << form }
|
|
1337
|
+
end
|
|
1338
|
+
out
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
# Every bare token of every notable full name `text` establishes.
|
|
1342
|
+
#
|
|
1343
|
+
# `"Narciso Rodriguez's memoir"` yields `{"narciso", "rodriguez"}`. The
|
|
1344
|
+
# **first** name is included, which is exactly what {.surname_forms}
|
|
1345
|
+
# refuses to do, so the difference has to be justified rather than assumed.
|
|
1346
|
+
#
|
|
1347
|
+
# {.surname_forms} is for the INBOUND pass, over prose a student wrote,
|
|
1348
|
+
# where a bare first name is the commonest private surface form there is.
|
|
1349
|
+
# That argument does not survive the trip to the outbound pass, and the
|
|
1350
|
+
# reason is structural rather than a judgement call: **outbound text was
|
|
1351
|
+
# generated from already-redacted input.** A classmate named Narciso was
|
|
1352
|
+
# masked on the way in, so the model never saw the token and cannot have
|
|
1353
|
+
# written it back. The only "Narciso" that can appear in feedback about
|
|
1354
|
+
# this essay is the one the essay kept.
|
|
1355
|
+
#
|
|
1356
|
+
# That is conditional on the pipeline shape — inbound first, outbound over
|
|
1357
|
+
# text derived only from the inbound result. A host that redacts outbound
|
|
1358
|
+
# text from some *other* source must not feed it this set.
|
|
1359
|
+
#
|
|
1360
|
+
# Only multi-token names contribute. A mononym is already the bare form and
|
|
1361
|
+
# establishes nothing new.
|
|
1362
|
+
def established_name_tokens(text, notable, keep = Set.new, tier = nil)
|
|
1363
|
+
lowered_keep = Set.new(keep.map(&:downcase))
|
|
1364
|
+
out = Set.new
|
|
1365
|
+
find_candidates(text).each do |candidate|
|
|
1366
|
+
name = candidate.text
|
|
1367
|
+
next if words(name).length < 2
|
|
1368
|
+
next unless establishes?(name, notable, lowered_keep, tier)
|
|
1369
|
+
|
|
1370
|
+
surname_tokens(name).each do |token|
|
|
1371
|
+
out << token if token.length > 1 && !PARTICLE_SET.include?(token)
|
|
1372
|
+
end
|
|
1373
|
+
end
|
|
1374
|
+
out
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
# ---------------------------------------------------------------------
|
|
1378
|
+
# Generation
|
|
1379
|
+
# ---------------------------------------------------------------------
|
|
1380
|
+
|
|
1381
|
+
# Names written in lowercase, seeded on the gazetteer's given-name tier.
|
|
1382
|
+
#
|
|
1383
|
+
# A given-name hit says "a person is being named", which inbound means
|
|
1384
|
+
# redact. But a hit on its own is not enough to fire on, and this is the
|
|
1385
|
+
# whole design problem: plenty of common given names are also ordinary
|
|
1386
|
+
# English words — hope, grace, mark, rose, art, may — so a single lowercase
|
|
1387
|
+
# hit in prose is indistinguishable from prose. Firing on one token would
|
|
1388
|
+
# put the given-name tier's 10,469 entries directly into the over-firing
|
|
1389
|
+
# number.
|
|
1390
|
+
#
|
|
1391
|
+
# So a span has to reach a second adjacent token that is not stoplisted,
|
|
1392
|
+
# which is the given-name-plus-surname shape ("terrence okonkwo"). The cost
|
|
1393
|
+
# is a bare lowercase first name ("terrence and i stayed up late") which
|
|
1394
|
+
# this route does not reach; the benefit is that "i had hope that day"
|
|
1395
|
+
# stops at the stopword and emits nothing.
|
|
1396
|
+
#
|
|
1397
|
+
# Adjacency is strict: only whitespace may sit between two tokens of one
|
|
1398
|
+
# span. "terrence, my cousin" therefore stops at the comma and drops to one
|
|
1399
|
+
# token. The span reaches exactly one token past the seed — a surname — and
|
|
1400
|
+
# a third only across a name particle ("maria de cruz"). Reaching two
|
|
1401
|
+
# ordinary tokens masks "terrence okonkwo showed" out of "then terrence
|
|
1402
|
+
# okonkwo showed up", because the stoplist is a few hundred words and
|
|
1403
|
+
# English is not.
|
|
1404
|
+
#
|
|
1405
|
+
# A seed sitting directly after a determiner is dropped: see {DETERMINERS}.
|
|
1406
|
+
# That is where most of the remaining over-firing lives, and it is
|
|
1407
|
+
# structural rather than a word list.
|
|
1408
|
+
#
|
|
1409
|
+
# @param corroborate how {.capitalisation_habit} participates without being
|
|
1410
|
+
# a kill switch. In a document that marks its proper nouns with capitals
|
|
1411
|
+
# a lowercase token is weak evidence, so the seed must additionally
|
|
1412
|
+
# appear *capitalised mid-sentence somewhere in the same document* — the
|
|
1413
|
+
# writer's own testimony that this particular word is a name they
|
|
1414
|
+
# sometimes slip on. Passing nil means the document supplies no
|
|
1415
|
+
# capitalisation signal, and the seed stands on the given-name tier
|
|
1416
|
+
# alone.
|
|
1417
|
+
def find_lowercase_candidates(text, is_given, protected_span, corroborate = nil,
|
|
1418
|
+
settlement = nil)
|
|
1419
|
+
tokens = each_match(text, LOWER_TOKEN).map do |m|
|
|
1420
|
+
[m[0], m.begin(0), m.begin(0) + m[0].length]
|
|
1421
|
+
end
|
|
1422
|
+
out = []
|
|
1423
|
+
index = 0
|
|
1424
|
+
while index < tokens.length
|
|
1425
|
+
word, start, = tokens[index]
|
|
1426
|
+
if stop?(word) || !is_given.call(word)
|
|
1427
|
+
index += 1
|
|
1428
|
+
next
|
|
1429
|
+
end
|
|
1430
|
+
if !corroborate.nil? && !corroborate.include?(strip(word, "'’"))
|
|
1431
|
+
index += 1
|
|
1432
|
+
next
|
|
1433
|
+
end
|
|
1434
|
+
if index.positive? && DETERMINERS.include?(tokens[index - 1][0])
|
|
1435
|
+
# Only a directly-adjacent determiner counts. "the day terrence
|
|
1436
|
+
# arrived" must stay reachable, and punctuation between the two means
|
|
1437
|
+
# they are not one noun phrase.
|
|
1438
|
+
preceding = text[tokens[index - 1][2]...start]
|
|
1439
|
+
if !preceding.empty? && preceding.strip.empty?
|
|
1440
|
+
index += 1
|
|
1441
|
+
next
|
|
1442
|
+
end
|
|
1443
|
+
end
|
|
1444
|
+
reach = index
|
|
1445
|
+
while reach + 1 < tokens.length
|
|
1446
|
+
break if reach > index && !PARTICLE_SET.include?(tokens[reach][0])
|
|
1447
|
+
|
|
1448
|
+
next_word, next_start, = tokens[reach + 1]
|
|
1449
|
+
gap = text[tokens[reach][2]...next_start]
|
|
1450
|
+
break if gap.empty? || !gap.strip.empty? || next_word.length < 2 || stop?(next_word)
|
|
1451
|
+
|
|
1452
|
+
reach += 1
|
|
1453
|
+
end
|
|
1454
|
+
# A span may not end on a particle: "maria de," is the name plus a
|
|
1455
|
+
# fragment of the next clause, and masking the fragment is a visible
|
|
1456
|
+
# defect on the outbound path.
|
|
1457
|
+
reach -= 1 while reach > index && PARTICLE_SET.include?(tokens[reach][0])
|
|
1458
|
+
span_end = tokens[reach][2]
|
|
1459
|
+
if reach - index + 1 < LOWERCASE_MIN_TOKENS || protected_span.call(start, span_end)
|
|
1460
|
+
index += 1
|
|
1461
|
+
next
|
|
1462
|
+
end
|
|
1463
|
+
joined = text[start...span_end]
|
|
1464
|
+
out << Candidate.new(joined, start, span_end, classify(words(joined), settlement))
|
|
1465
|
+
index = reach + 1
|
|
1466
|
+
end
|
|
1467
|
+
out
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
# Every name-shaped span, before any notability decision.
|
|
1471
|
+
#
|
|
1472
|
+
# High recall and deliberately poor precision — precision is what the
|
|
1473
|
+
# notability filter buys. Offsets are into `text`.
|
|
1474
|
+
#
|
|
1475
|
+
# Options, all optional:
|
|
1476
|
+
# * `:given_name` — turns on the lowercase route. Absent, this keys on
|
|
1477
|
+
# capitalisation alone and misses lowercase writing by construction.
|
|
1478
|
+
# * `:title`, `:title_prefix` — protect work titles and fictional-character
|
|
1479
|
+
# names from generation entirely. Absent, a student writing about a book
|
|
1480
|
+
# has the book redacted.
|
|
1481
|
+
# * `:settlement` — types a masked span `{LOCATION}` instead of `{NAME}`.
|
|
1482
|
+
# Changes no verdict — it cannot make a span keep or stop a span masking,
|
|
1483
|
+
# only relabel one that was already going to be masked.
|
|
1484
|
+
# * `:headings_are_orthographic` — treat a section heading's capitals as
|
|
1485
|
+
# required by title case rather than chosen by the writer. On by default.
|
|
1486
|
+
# * `:title_relation_refusal` — withdraw title protection from a span with
|
|
1487
|
+
# a first-person relation attached to it — "My neighbor Alice Adams". The
|
|
1488
|
+
# protection is applied *here*, before generation, so the refusal has to
|
|
1489
|
+
# be applied here too; the notability gate on the masking side is the
|
|
1490
|
+
# second half of the same rule and neither half works alone.
|
|
1491
|
+
def find_candidates(text, options = {})
|
|
1492
|
+
given_name = options[:given_name]
|
|
1493
|
+
title = options[:title]
|
|
1494
|
+
title_prefix = options[:title_prefix]
|
|
1495
|
+
settlement = options[:settlement]
|
|
1496
|
+
headings_are_orthographic = options.fetch(:headings_are_orthographic, true)
|
|
1497
|
+
title_relation_refusal = options.fetch(:title_relation_refusal, true)
|
|
1498
|
+
|
|
1499
|
+
blocked = each_match(text, PROTECTED).map { |m| [m.begin(0), m.begin(0) + m[0].length] }
|
|
1500
|
+
starts = sentence_starts(text)
|
|
1501
|
+
emphasis = emphasis_spans(text)
|
|
1502
|
+
headings = headings_are_orthographic ? heading_spans(text) : []
|
|
1503
|
+
# Read before the title pass, because the title pass needs it. The habit
|
|
1504
|
+
# is a property of the whole document, so it is computed once and every
|
|
1505
|
+
# consumer reads the same verdict — which two separate booleans could not
|
|
1506
|
+
# guarantee.
|
|
1507
|
+
habit = capitalisation_habit(text, headings)
|
|
1508
|
+
unless title.nil?
|
|
1509
|
+
title_spans = find_title_spans(text, title, title_prefix,
|
|
1510
|
+
requires_capital: marks_proper_nouns?(habit))
|
|
1511
|
+
if title_relation_refusal
|
|
1512
|
+
title_spans = title_spans.reject do |s, e|
|
|
1513
|
+
names_someone_the_writer_knows?(text, s, e) ||
|
|
1514
|
+
# ...or the title is itself a relation phrase the writer is using
|
|
1515
|
+
# literally. The document's capitalisation signal answers this,
|
|
1516
|
+
# EXCEPT on a document too short to have one — where the span's
|
|
1517
|
+
# own mixed case answers it instead, and the missing answer used
|
|
1518
|
+
# to ship a cousin's name.
|
|
1519
|
+
((marks_proper_nouns?(habit) ||
|
|
1520
|
+
relation_led_title_is_internally_mixed?(text, s, e)) &&
|
|
1521
|
+
title_is_the_writers_own_relation?(text, s, e))
|
|
1522
|
+
end
|
|
1523
|
+
end
|
|
1524
|
+
blocked.concat(title_spans)
|
|
1525
|
+
end
|
|
1526
|
+
|
|
1527
|
+
is_protected = lambda do |start, finish|
|
|
1528
|
+
blocked.any? { |block_start, block_end| start < block_end && finish > block_start }
|
|
1529
|
+
end
|
|
1530
|
+
|
|
1531
|
+
written_as_a_capital = mid_sentence_capitals(text, starts, headings)
|
|
1532
|
+
|
|
1533
|
+
out = []
|
|
1534
|
+
each_match(text, CANDIDATE_RE) do |m|
|
|
1535
|
+
span = m[0]
|
|
1536
|
+
next if is_protected.call(m.begin(0), m.begin(0) + span.length)
|
|
1537
|
+
|
|
1538
|
+
tokens = words(span)
|
|
1539
|
+
# A long all-caps run means the capitalisation told us nothing, so the
|
|
1540
|
+
# stoplist is carrying the whole decision.
|
|
1541
|
+
trim(tokens).each do |run|
|
|
1542
|
+
next if run.empty?
|
|
1543
|
+
|
|
1544
|
+
joined = run.join(" ")
|
|
1545
|
+
# Locate the run inside the original span so offsets stay exact.
|
|
1546
|
+
offset = span.index(joined)
|
|
1547
|
+
next if offset.nil?
|
|
1548
|
+
|
|
1549
|
+
start = m.begin(0) + offset
|
|
1550
|
+
next if is_protected.call(start, start + joined.length)
|
|
1551
|
+
# Requiring a second signal is only sound when there is a second
|
|
1552
|
+
# signal to require, which is why this is reached only where an
|
|
1553
|
+
# oracle exists.
|
|
1554
|
+
if !given_name.nil? &&
|
|
1555
|
+
suppressed_as_an_unevidenced_capital?(run, start, starts, emphasis, headings,
|
|
1556
|
+
written_as_a_capital, given_name)
|
|
1557
|
+
next
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
# A *trailing* apostrophe is the closing quote, not part of the name.
|
|
1561
|
+
# The candidate pattern treats `'` as a name character so O'Brien
|
|
1562
|
+
# survives, which also means "words like 'Terrence'" arrives as
|
|
1563
|
+
# `Terrence'` — and masking that ate the quote. Possessives are
|
|
1564
|
+
# untouched because they end in `s`. The one case this trims wrongly
|
|
1565
|
+
# is a plural possessive ("the Smiths'"), which reads `the {NAME_1}'`
|
|
1566
|
+
# — cosmetically odd, against a defect that unbalances a quotation in
|
|
1567
|
+
# text a student reads.
|
|
1568
|
+
finish = joined.length
|
|
1569
|
+
finish -= 1 while finish.positive? && ["'", "’"].include?(joined[finish - 1])
|
|
1570
|
+
masked_text = joined[0, finish]
|
|
1571
|
+
next if masked_text.empty?
|
|
1572
|
+
|
|
1573
|
+
out << Candidate.new(masked_text, start, start + masked_text.length,
|
|
1574
|
+
classify(run, settlement))
|
|
1575
|
+
end
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
unless given_name.nil?
|
|
1579
|
+
# The capitalised route claimed first, so a lowercase span overlapping
|
|
1580
|
+
# one it already found is dropped rather than merged: two candidates
|
|
1581
|
+
# over the same characters would mask the outer one and leave the inner
|
|
1582
|
+
# placeholder's braces as debris.
|
|
1583
|
+
claimed = out.map { |candidate| [candidate.start, candidate.end] }
|
|
1584
|
+
# nil here is the permissive path: "no capitalisation signal, so the
|
|
1585
|
+
# given-name tier stands alone". Exactly one of the four habits reaches
|
|
1586
|
+
# it. It is NOT reached on the mere absence of capitals — absence is
|
|
1587
|
+
# what a text with no names in it looks like, and reading its silence
|
|
1588
|
+
# as consent is what put "line circles" in front of a student — and it
|
|
1589
|
+
# is not reached by the INCONSISTENT writer either, who has per-token
|
|
1590
|
+
# evidence to offer and is better served by it.
|
|
1591
|
+
find_lowercase_candidates(
|
|
1592
|
+
text, given_name, is_protected,
|
|
1593
|
+
habit == LOWERCASE ? nil : written_as_a_capital, settlement
|
|
1594
|
+
).each do |candidate|
|
|
1595
|
+
next if claimed.any? { |s, e| candidate.start < e && candidate.end > s }
|
|
1596
|
+
|
|
1597
|
+
out << candidate
|
|
1598
|
+
end
|
|
1599
|
+
end
|
|
1600
|
+
out
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
# Mask every candidate the notability filter does not keep.
|
|
1604
|
+
#
|
|
1605
|
+
# Returns `[masked_text, count]`.
|
|
1606
|
+
#
|
|
1607
|
+
# The order of the four gates is the policy, and each one is the exception
|
|
1608
|
+
# to the one before it: the prompt's own keeps win outright, then the
|
|
1609
|
+
# precedence table decides mask-or-keep, then the notability oracle keeps a
|
|
1610
|
+
# public figure *unless* a first-person relation is attached to the name,
|
|
1611
|
+
# then a document-established surname keeps *unless* the sentence says this
|
|
1612
|
+
# one is somebody the writer knows.
|
|
1613
|
+
#
|
|
1614
|
+
# Options are {.find_candidates}'s, plus:
|
|
1615
|
+
# * `:notable` — returns true for a public figure. Absent, nothing is kept,
|
|
1616
|
+
# which is the recall-maximal, precision-minimal posture.
|
|
1617
|
+
# * `:keep` — exact strings to keep regardless, case-insensitively.
|
|
1618
|
+
# * `:corroborate` — keep a bare surname when the same document also writes
|
|
1619
|
+
# a full name the oracle keeps. No effect without `:notable`.
|
|
1620
|
+
# * `:notability_tier` — which tier vouched for a name. Needed by
|
|
1621
|
+
# `:title_relation_refusal`: the boolean oracle cannot say, and
|
|
1622
|
+
# overriding every tier would redact "my hero Abraham Lincoln".
|
|
1623
|
+
# * `:minter` — numbers the placeholders so masking is reversible. Shared
|
|
1624
|
+
# with the caller's identity and structured passes so indices do not
|
|
1625
|
+
# collide across them.
|
|
1626
|
+
# * `:relation_refusal` — refuse corroboration for a bare surname whose
|
|
1627
|
+
# local context marks it as someone in the writer's life.
|
|
1628
|
+
def mask_candidates(text, options = {})
|
|
1629
|
+
notable = options[:notable]
|
|
1630
|
+
keep = options[:keep] || Set.new
|
|
1631
|
+
settlement = options[:settlement]
|
|
1632
|
+
corroborate = options.fetch(:corroborate, true)
|
|
1633
|
+
notability_tier = options[:notability_tier]
|
|
1634
|
+
minter = options[:minter]
|
|
1635
|
+
relation_refusal = options.fetch(:relation_refusal, true)
|
|
1636
|
+
title_relation_refusal = options.fetch(:title_relation_refusal, true)
|
|
1637
|
+
|
|
1638
|
+
lowered_keep = Set.new(keep.map(&:downcase))
|
|
1639
|
+
candidates = find_candidates(text, options)
|
|
1640
|
+
established =
|
|
1641
|
+
if corroborate && !notable.nil?
|
|
1642
|
+
corroborated_surnames(candidates, notable, keep, notability_tier)
|
|
1643
|
+
else
|
|
1644
|
+
Set.new
|
|
1645
|
+
end
|
|
1646
|
+
|
|
1647
|
+
out = text
|
|
1648
|
+
count = 0
|
|
1649
|
+
# Right to left so earlier offsets stay valid as the text shrinks. The
|
|
1650
|
+
# sort must be STABLE and ties must NOT be reversed, which is what keeps
|
|
1651
|
+
# the minter handing out the same indices as the reference — Ruby's
|
|
1652
|
+
# `sort_by` is not stable, so the original position rides along as the
|
|
1653
|
+
# tiebreaker.
|
|
1654
|
+
ordered = candidates.each_with_index.sort_by { |c, i| [-c.start, i] }.map(&:first)
|
|
1655
|
+
ordered.each do |candidate|
|
|
1656
|
+
name = candidate.text
|
|
1657
|
+
# The possessive folds into the keep, for the same reason it folds into
|
|
1658
|
+
# corroboration: literary analysis writes "Wright's" far more often
|
|
1659
|
+
# than "Wright", and a keep list that only matched the citation form
|
|
1660
|
+
# would miss the shape students actually use.
|
|
1661
|
+
next if lowered_keep.include?(name.downcase) ||
|
|
1662
|
+
lowered_keep.include?(surname_tokens(name).join(" "))
|
|
1663
|
+
# The table decides keep-or-mask, and it is the only thing that does. A
|
|
1664
|
+
# bare landmark-suffix test here kept 383 real settlements — a
|
|
1665
|
+
# student's hometown leaked whenever it was named after a park, lake,
|
|
1666
|
+
# valley or falls.
|
|
1667
|
+
next unless resolve(classify_tags(words(name), settlement)).mask
|
|
1668
|
+
|
|
1669
|
+
if !notable.nil? && notable.call(name)
|
|
1670
|
+
# ...unless a work title is standing in for a person the writer
|
|
1671
|
+
# knows. "Alice Adams" is a 1921 novel and also 589 real people's
|
|
1672
|
+
# names in this tier alone; no threshold separates them from the
|
|
1673
|
+
# curriculum, so the separation has to come from the sentence.
|
|
1674
|
+
overridden = title_relation_refusal && !notability_tier.nil? &&
|
|
1675
|
+
OVERRIDABLE_TIERS.include?(notability_tier.call(name)) &&
|
|
1676
|
+
names_someone_the_writer_knows?(text, candidate.start, candidate.end)
|
|
1677
|
+
next unless overridden
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1680
|
+
# Only the bare form corroborates. "Coach Wright" and "Priya Wright"
|
|
1681
|
+
# stay masked even where "Wright" is established.
|
|
1682
|
+
bare = bare_surname_key(name)
|
|
1683
|
+
if !established.empty? && !bare.nil? && established.include?(bare)
|
|
1684
|
+
# ...unless the local context says this one is someone in the
|
|
1685
|
+
# writer's life who happens to share the surname. Corroboration is a
|
|
1686
|
+
# document-level inference and this is the sentence-level exception
|
|
1687
|
+
# to it; without it a neighbour named Robinson is protected by Jackie
|
|
1688
|
+
# Robinson's fame.
|
|
1689
|
+
refused = relation_refusal &&
|
|
1690
|
+
names_someone_in_the_writers_life?(text, candidate.start, candidate.end)
|
|
1691
|
+
next unless refused
|
|
1692
|
+
end
|
|
1693
|
+
|
|
1694
|
+
placeholder = minter.nil? ? placeholder_for(candidate.kind) : minter.mint(candidate.kind, name)
|
|
1695
|
+
out = out[0, candidate.start] + placeholder + out[candidate.end..].to_s
|
|
1696
|
+
count += 1
|
|
1697
|
+
end
|
|
1698
|
+
[out, count]
|
|
1699
|
+
end
|
|
1700
|
+
end
|
|
1701
|
+
end
|
|
1702
|
+
end
|