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.
@@ -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