vicary 0.2.12 → 0.2.14
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 +4 -4
- data/assets/MANIFEST.json +6 -5
- data/assets/notability.txt.gz +0 -0
- data/lib/vicary/asset.rb +1 -1
- data/lib/vicary/candidates.rb +235 -7
- data/lib/vicary/gazetteer.rb +35 -2
- data/lib/vicary/redact.rb +10 -1
- data/lib/vicary/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 861bcafb7f5671834b082aea4da0edc1bdd050d71aab888d6b1944f58c70b36f
|
|
4
|
+
data.tar.gz: 31fbd6f351e16c1ff1cb0acea2f5422b94decfbeb8b0f8021e20386561cd309c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5da98338e5c69f9687c1d6daf0b937837dd8d8f074768781f73f47fe789f6e99cebed77b8449b79d32118e16e738e6e45903e79b0b9ab7333acd64575de8827
|
|
7
|
+
data.tar.gz: '0148acecb49a386d9cec1b5e3832a01385820572b32f8b30f4bfbbf49c3293af70a2ceba44b93990ffc85056ddb555e80630b0e4e3da786c95aec9c8500ae4a3'
|
data/assets/MANIFEST.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"assets": {
|
|
3
3
|
"notability.txt.gz": {
|
|
4
|
-
"bytes":
|
|
4
|
+
"bytes": 2291959,
|
|
5
5
|
"cut_date": "2026-08-07",
|
|
6
|
-
"format":
|
|
7
|
-
"min_package_version": "0.
|
|
8
|
-
"sha256": "
|
|
6
|
+
"format": 6,
|
|
7
|
+
"min_package_version": "0.2.13",
|
|
8
|
+
"sha256": "e26ad0a046c5935d8ba10bfde9fd073275d7bda490cf202ea61e9f606e16e916",
|
|
9
9
|
"sources": [
|
|
10
10
|
"https://qlever.dev/api/wikidata",
|
|
11
11
|
"https://www2.census.gov/topics/genealogy/2010surnames/names.zip"
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"demonym": 1047,
|
|
15
15
|
"full": 295049,
|
|
16
16
|
"given": 8138,
|
|
17
|
+
"given_corroboration": 21900,
|
|
17
18
|
"place": 25444,
|
|
18
19
|
"settlement": 23234,
|
|
19
20
|
"short": 1229,
|
|
@@ -36,5 +37,5 @@
|
|
|
36
37
|
}
|
|
37
38
|
},
|
|
38
39
|
"manifest_version": 1,
|
|
39
|
-
"written_by": "vicary 0.2.
|
|
40
|
+
"written_by": "vicary 0.2.13"
|
|
40
41
|
}
|
data/assets/notability.txt.gz
CHANGED
|
Binary file
|
data/lib/vicary/asset.rb
CHANGED
data/lib/vicary/candidates.rb
CHANGED
|
@@ -167,6 +167,61 @@ module Vicary
|
|
|
167
167
|
# Any word token, used to find all-caps runs and mid-sentence capitals.
|
|
168
168
|
WORD_TOKEN = /[A-Za-z][A-Za-z'’-]*/
|
|
169
169
|
|
|
170
|
+
# Word-initial particles that legitimately carry an interior capital, so an
|
|
171
|
+
# interior capital on one of them is a name shape rather than orthographic
|
|
172
|
+
# noise. Closed and small, which is the whole of this signal's safety
|
|
173
|
+
# argument: `McDonald`, `MacArthur`, `DeShawn`, `DiCaprio`, `LaGrange`,
|
|
174
|
+
# `VanHalen`.
|
|
175
|
+
#
|
|
176
|
+
# The exemption is paid for in misses and the price is named here rather
|
|
177
|
+
# than discovered later — `LaTer` and `DeCide` are indistinguishable from
|
|
178
|
+
# `LaGrange` and `DeShawn` by orthography alone. It allows the particle
|
|
179
|
+
# exactly ONE capital, at the position right after it, so a second interior
|
|
180
|
+
# capital still fires; `dePenDs` is caught that way.
|
|
181
|
+
INTERIOR_CAPITAL_PREFIXES = %w[mc mac de di la le van von du da del san st].freeze
|
|
182
|
+
|
|
183
|
+
# Splits a word into the pieces an apostrophe or hyphen makes. `O'Brien` and
|
|
184
|
+
# `Jean-Luc` are two initial capitals rather than one interior capital, and
|
|
185
|
+
# without this split both read as orthographic noise — which would veto two
|
|
186
|
+
# of the commonest surname shapes there are.
|
|
187
|
+
PIECE = /[^'’\-‐-―]+/
|
|
188
|
+
|
|
189
|
+
# The word tokens {Candidates.interior_capital?} can possibly say yes to,
|
|
190
|
+
# and the only ones {Candidates.capitalises_inside_a_word?} needs to look
|
|
191
|
+
# at.
|
|
192
|
+
#
|
|
193
|
+
# It is {WORD_TOKEN} with one letter of the continuation class removed: a
|
|
194
|
+
# capital somewhere after the first character. That is a *necessary*
|
|
195
|
+
# condition for an interior capital — every position the exemptions in
|
|
196
|
+
# {Candidates.interior_capital?} still allow to fire is at piece-index 1 or
|
|
197
|
+
# later, so it is at word-index 1 or later too — which makes this a filter
|
|
198
|
+
# and never a second opinion. The answer stays that method's.
|
|
199
|
+
#
|
|
200
|
+
# The match is the same substring {WORD_TOKEN} would have produced, not a
|
|
201
|
+
# fragment of one. A token whose first capital after position 0 sits at k
|
|
202
|
+
# has every earlier character in `[a-z'’-]` by construction, so the engine
|
|
203
|
+
# matches from the token's own start, and the trailing class then runs
|
|
204
|
+
# greedily to the token's own end.
|
|
205
|
+
#
|
|
206
|
+
# Why this is worth a second pattern: the scan is per-document and the words
|
|
207
|
+
# it is looking for are rare. On the twenty-essay gate corpus exactly ONE
|
|
208
|
+
# document contains an interior capital, so the other nineteen used to be
|
|
209
|
+
# tokenised to the last word — and a regex spent on each — to return false.
|
|
210
|
+
INTERIOR_CAP_WORD = /[A-Za-z][a-z'’-]*[A-Z][A-Za-z'’-]*/
|
|
211
|
+
|
|
212
|
+
# The two-character shape {INTERIOR_CAP_WORD} cannot match without: a
|
|
213
|
+
# capital with a word character in front of it. Every capital that pattern
|
|
214
|
+
# fires on is preceded either by its own first character or by one of
|
|
215
|
+
# `[a-z'’-]`, and both are inside this class, so a document that fails
|
|
216
|
+
# this has no interior capital and needs no word scan at all.
|
|
217
|
+
#
|
|
218
|
+
# It exists because the precise pattern backtracks. `[a-z'’-]*[A-Z]` is
|
|
219
|
+
# retried at every letter of every word that has no capital after it, which
|
|
220
|
+
# is most words in most documents; this is a two-character scan that cannot
|
|
221
|
+
# backtrack. On the twenty-essay gate corpus it clears thirteen documents
|
|
222
|
+
# outright at a twentieth of the cost.
|
|
223
|
+
INTERIOR_CAP_HINT = /[A-Za-z'’-][A-Z]/
|
|
224
|
+
|
|
170
225
|
# Where a sentence begins: start of text, after terminal punctuation and any
|
|
171
226
|
# closing quote, after a line break, or immediately inside an *opening*
|
|
172
227
|
# quote. A capital in one of these positions is required by orthography, so
|
|
@@ -1079,13 +1134,26 @@ module Vicary
|
|
|
1079
1134
|
# given-name tier. `is_given` is passed in rather than defaulted so this is
|
|
1080
1135
|
# only reachable on the path where an oracle exists.
|
|
1081
1136
|
#
|
|
1137
|
+
# **The tier is read at a lower floor here than it is for generation**,
|
|
1138
|
+
# when the caller supplies `is_given_for_corroboration`. Absent, it
|
|
1139
|
+
# defaults to `is_given` and this behaves exactly as it did before the dial
|
|
1140
|
+
# existed. The two roles cost differently: a generation hit invents a span
|
|
1141
|
+
# out of lower-case prose, a corroboration hit can only restore one the
|
|
1142
|
+
# writer's own capital already proposed, and the shipped 1,800-birth knee
|
|
1143
|
+
# was measured against the first. **This is the only rule that reads the
|
|
1144
|
+
# lower floor** — the mid-sentence stray rule and the case-variance rule
|
|
1145
|
+
# both keep the generation floor, because the corpus that priced this
|
|
1146
|
+
# channel has zero true catches in the first one's suppressed set.
|
|
1147
|
+
#
|
|
1082
1148
|
# ANY token counts, not just the first, and the heading rule is what made
|
|
1083
1149
|
# that distinction load-bearing. Before it, this was only ever reached for
|
|
1084
1150
|
# single-token spans, so "first token" and "any token" were the same thing.
|
|
1085
1151
|
# A heading is title-cased, so a multi-token span inside one also arrives
|
|
1086
1152
|
# here — and "My Brother Terrence Okonkwo" leads with an honorific, so
|
|
1087
1153
|
# checking only the first token consulted "Brother" and leaked the name.
|
|
1088
|
-
def corroborated?(tokens, written_as_a_capital, is_given
|
|
1154
|
+
def corroborated?(tokens, written_as_a_capital, is_given,
|
|
1155
|
+
is_given_for_corroboration = nil)
|
|
1156
|
+
vouches = is_given_for_corroboration || is_given
|
|
1089
1157
|
# Both channels see the same stripped token, and the strip set is `.,'’`
|
|
1090
1158
|
# rather than the `'’` {.mid_sentence_capitals} folds with. That
|
|
1091
1159
|
# asymmetry is deliberate and was a defect once: the capital channel
|
|
@@ -1095,7 +1163,7 @@ module Vicary
|
|
|
1095
1163
|
# character — asked the tier about `Terrence'` and was told no.
|
|
1096
1164
|
tokens.each do |token|
|
|
1097
1165
|
stripped = strip(token.downcase, ".,'’")
|
|
1098
|
-
return true if written_as_a_capital.include?(stripped) ||
|
|
1166
|
+
return true if written_as_a_capital.include?(stripped) || vouches.call(stripped)
|
|
1099
1167
|
|
|
1100
1168
|
# ...and again with the possessive off. "Terrence's" at a sentence
|
|
1101
1169
|
# start is the shape this is for: the writer capitalised "Terrence"
|
|
@@ -1114,13 +1182,44 @@ module Vicary
|
|
|
1114
1182
|
# reverse, so it can only reduce suppression, never increase it.
|
|
1115
1183
|
folded = without_clitic(stripped)
|
|
1116
1184
|
if folded != stripped &&
|
|
1117
|
-
(written_as_a_capital.include?(folded) ||
|
|
1185
|
+
(written_as_a_capital.include?(folded) || vouches.call(folded))
|
|
1118
1186
|
return true
|
|
1119
1187
|
end
|
|
1120
1188
|
end
|
|
1121
1189
|
false
|
|
1122
1190
|
end
|
|
1123
1191
|
|
|
1192
|
+
# Drop a lone capital on a word this same document also writes lower-case.
|
|
1193
|
+
#
|
|
1194
|
+
# The third of the capital-discounting rules, and the only one that needs
|
|
1195
|
+
# neither a position nor a list. {.suppressed_as_an_unevidenced_capital?}
|
|
1196
|
+
# asks whether orthography required the capital;
|
|
1197
|
+
# {.suppressed_as_a_stray_mid_sentence_capital?} asks whether the writer
|
|
1198
|
+
# is a sloppy capitaliser in general. This asks the narrowest question of
|
|
1199
|
+
# the three and the one with the best evidence behind it: did the writer,
|
|
1200
|
+
# in this document, write this exact word as a word? If they did, a
|
|
1201
|
+
# capital elsewhere on the same letters is not testimony about a name.
|
|
1202
|
+
#
|
|
1203
|
+
# The given-name tier still rescues, exactly as it does in
|
|
1204
|
+
# {.corroborated?}, and it is the reason this is safe to run outside the
|
|
1205
|
+
# stray-capital gate. `Bill` in a document that also writes "bill"
|
|
1206
|
+
# survives on the tier; `Summer`, `Space`, `Love` and `Fight` do not.
|
|
1207
|
+
#
|
|
1208
|
+
# Measured on the 56-paper NWP corpus as a post-hoc arm over the recorded
|
|
1209
|
+
# spans: +7 false positives recovered, 0 public entities, 0 PII lost, and
|
|
1210
|
+
# it is the only free arm that moves papers-damaged-for-nothing on its own
|
|
1211
|
+
# (23 -> 20).
|
|
1212
|
+
def suppressed_as_a_word_the_writer_also_writes_lower_case?(tokens, lower_cased, is_given)
|
|
1213
|
+
return false unless tokens.length == 1
|
|
1214
|
+
|
|
1215
|
+
stripped = strip(tokens[0].downcase, ".,'’")
|
|
1216
|
+
return false unless lower_cased.include?(stripped)
|
|
1217
|
+
return false if is_given.call(stripped)
|
|
1218
|
+
|
|
1219
|
+
folded = without_clitic(stripped)
|
|
1220
|
+
!(folded != stripped && is_given.call(folded))
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1124
1223
|
# The sentence-initial guard: drop a span whose only evidence is a capital
|
|
1125
1224
|
# that orthography required, unless a second channel vouches for it.
|
|
1126
1225
|
#
|
|
@@ -1143,9 +1242,11 @@ module Vicary
|
|
|
1143
1242
|
# **Do not "fix" it**; the tier feeding it was the defect, and that was
|
|
1144
1243
|
# addressed in 0.1.0 by adding SSA births to the given-name tier.
|
|
1145
1244
|
def suppressed_as_an_unevidenced_capital?(tokens, start, starts, emphasis, headings,
|
|
1146
|
-
written_as_a_capital, is_given
|
|
1245
|
+
written_as_a_capital, is_given,
|
|
1246
|
+
is_given_for_corroboration = nil)
|
|
1147
1247
|
capital_is_the_only_evidence?(tokens, start, starts, emphasis, headings) &&
|
|
1148
|
-
!corroborated?(tokens, written_as_a_capital, is_given
|
|
1248
|
+
!corroborated?(tokens, written_as_a_capital, is_given,
|
|
1249
|
+
is_given_for_corroboration)
|
|
1149
1250
|
end
|
|
1150
1251
|
|
|
1151
1252
|
# Whether this document capitalises words that cannot be names.
|
|
@@ -1166,6 +1267,17 @@ module Vicary
|
|
|
1166
1267
|
# therefore counts the names too, this cannot be satisfied by a document
|
|
1167
1268
|
# that simply names a lot of people. Headings are excluded because title
|
|
1168
1269
|
# case capitalises every word in one.
|
|
1270
|
+
#
|
|
1271
|
+
# Two channels. The stray-capital channel needs a
|
|
1272
|
+
# mid-sentence capital to land on a hand-curated list, so it can only ever
|
|
1273
|
+
# speak about words someone thought to curate. The second channel —
|
|
1274
|
+
# {.capitalises_inside_a_word?} — needs no list, because a capital in the
|
|
1275
|
+
# middle of a word is not a shape English produces under any rule.
|
|
1276
|
+
#
|
|
1277
|
+
# Measured on the 56-paper NWP corpus: the curated channel fires on 10
|
|
1278
|
+
# papers, the interior channel on 12 (14 counting headings, which it does
|
|
1279
|
+
# count), they overlap on 7, and 5 papers are reached by the interior
|
|
1280
|
+
# channel alone.
|
|
1169
1281
|
def capitalises_ordinary_words?(text, headings = [])
|
|
1170
1282
|
count = 0
|
|
1171
1283
|
each_match(text, MID_SENTENCE_CAP) do |m|
|
|
@@ -1178,7 +1290,98 @@ module Vicary
|
|
|
1178
1290
|
|
|
1179
1291
|
count += 1
|
|
1180
1292
|
end
|
|
1181
|
-
count >= STRAY_CAPITALS_MIN
|
|
1293
|
+
return true if count >= STRAY_CAPITALS_MIN
|
|
1294
|
+
|
|
1295
|
+
capitalises_inside_a_word?(text)
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
# A capital in a non-initial position that no name form explains.
|
|
1299
|
+
#
|
|
1300
|
+
# Four exemptions, and each one is a real name shape rather than a hedge:
|
|
1301
|
+
# all-caps pieces (`BILL` is a writer who stopped using case, a different
|
|
1302
|
+
# defect with a different rule), apostrophes and hyphens split (`O'Brien`
|
|
1303
|
+
# and `Jean-Luc` are two initial capitals — see {PIECE}), particles (see
|
|
1304
|
+
# {INTERIOR_CAPITAL_PREFIXES}), and single characters (`T.V` splits to `T`
|
|
1305
|
+
# and `V`; an initial has no interior).
|
|
1306
|
+
#
|
|
1307
|
+
# What survives is `ChoaCh`, `PoSitive`, `grandParints`, `surPise` —
|
|
1308
|
+
# orthographic noise, and unlike a mid-sentence capital it cannot be
|
|
1309
|
+
# confused with correct English, because correct English has no such form.
|
|
1310
|
+
def interior_capital?(word)
|
|
1311
|
+
word.scan(PIECE).each do |piece|
|
|
1312
|
+
next if piece.length < 2 || !piece.match?(/\A[A-Za-z]+\z/) || upper?(piece)
|
|
1313
|
+
|
|
1314
|
+
lowered = piece.downcase
|
|
1315
|
+
start = 1
|
|
1316
|
+
INTERIOR_CAPITAL_PREFIXES.each do |prefix|
|
|
1317
|
+
next unless lowered.start_with?(prefix) && piece.length > prefix.length &&
|
|
1318
|
+
piece[prefix.length].match?(/[A-Z]/)
|
|
1319
|
+
|
|
1320
|
+
start = [start, prefix.length + 1].max
|
|
1321
|
+
end
|
|
1322
|
+
return true if piece[start..].to_s.match?(/[A-Z]/)
|
|
1323
|
+
end
|
|
1324
|
+
false
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
# Whether this document puts a capital in the middle of a word.
|
|
1328
|
+
#
|
|
1329
|
+
# Headings are NOT excluded here, and that is a departure with a reason.
|
|
1330
|
+
# Everywhere else a heading's capitals are discounted because title case
|
|
1331
|
+
# put them there. Title case does not put a capital in the *middle* of a
|
|
1332
|
+
# word, so the argument does not transfer — and excluding headings anyway
|
|
1333
|
+
# costs signal that is measured rather than hypothetical: on the 56-paper
|
|
1334
|
+
# NWP corpus 14 papers carry an interior capital and 2 of them (`SPecial`,
|
|
1335
|
+
# `AFter`) carry it only inside a heading.
|
|
1336
|
+
#
|
|
1337
|
+
# The scan is over {INTERIOR_CAP_WORD} rather than every word token, which
|
|
1338
|
+
# is a filter and not a change of answer — see that pattern.
|
|
1339
|
+
def capitalises_inside_a_word?(text)
|
|
1340
|
+
return false unless INTERIOR_CAP_HINT.match?(text)
|
|
1341
|
+
|
|
1342
|
+
text.scan(INTERIOR_CAP_WORD) do |word|
|
|
1343
|
+
return true if interior_capital?(word)
|
|
1344
|
+
end
|
|
1345
|
+
false
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
# Lower-cased forms of every word this document writes with a lower-case
|
|
1349
|
+
# initial.
|
|
1350
|
+
#
|
|
1351
|
+
# The mirror of {.mid_sentence_capitals}: the same scan read for the
|
|
1352
|
+
# opposite testimony. That method records the words a document capitalises
|
|
1353
|
+
# where orthography would not have and reads them as evidence those words
|
|
1354
|
+
# are names; this records the words the writer themself also wrote as
|
|
1355
|
+
# words.
|
|
1356
|
+
#
|
|
1357
|
+
# Case-insensitively *equal*, not merely similar — no plural fold, no edit
|
|
1358
|
+
# distance, no stem. The evidence is the writer's own hand on the same
|
|
1359
|
+
# letters, which is what keeps the rule free of any imported collision: it
|
|
1360
|
+
# consults no list, so it cannot inherit one's mistakes.
|
|
1361
|
+
def written_in_lower_case(text)
|
|
1362
|
+
out = Set.new
|
|
1363
|
+
# `scan` rather than `each_match`, and a byte comparison rather than
|
|
1364
|
+
# `token[0].match?(/[a-z]/)` — which allocated a one-character string
|
|
1365
|
+
# and ran a regex for every word of every document. Exact, not an
|
|
1366
|
+
# approximation: {WORD_TOKEN}'s first character is `[A-Za-z]`, so it is
|
|
1367
|
+
# ASCII and a single byte by construction.
|
|
1368
|
+
text.scan(WORD_TOKEN) do |token|
|
|
1369
|
+
first = token.getbyte(0)
|
|
1370
|
+
next unless first >= 97 && first <= 122
|
|
1371
|
+
|
|
1372
|
+
# {.strip} copies the string whether or not it takes anything off,
|
|
1373
|
+
# and almost no word ends in an apostrophe. Same value either way — a
|
|
1374
|
+
# strip that removes nothing returns an equal string, and the set
|
|
1375
|
+
# holds values.
|
|
1376
|
+
#
|
|
1377
|
+
# Only the tail is worth testing. {.strip} works in from both ends,
|
|
1378
|
+
# and the head was just established to be a lower-case ASCII letter,
|
|
1379
|
+
# so the leading pass stops on the first character every time.
|
|
1380
|
+
lowered = token.downcase
|
|
1381
|
+
lowered = strip(lowered, "'’") if lowered.end_with?("'", "’")
|
|
1382
|
+
out << lowered
|
|
1383
|
+
end
|
|
1384
|
+
out
|
|
1182
1385
|
end
|
|
1183
1386
|
|
|
1184
1387
|
# The mid-sentence guard: drop a lone capital a sloppy capitaliser chose.
|
|
@@ -1685,6 +1888,9 @@ module Vicary
|
|
|
1685
1888
|
# Options, all optional:
|
|
1686
1889
|
# * `:given_name` — turns on the lowercase route. Absent, this keys on
|
|
1687
1890
|
# capitalisation alone and misses lowercase writing by construction.
|
|
1891
|
+
# * `:given_name_corroboration` — the same tier at its permissive floor,
|
|
1892
|
+
# read by the sentence-initial rule ONLY. Absent, that rule reads
|
|
1893
|
+
# `:given_name` and nothing changes. Inert without `:given_name`.
|
|
1688
1894
|
# * `:title`, `:title_prefix` — protect work titles and fictional-character
|
|
1689
1895
|
# names from generation entirely. Absent, a student writing about a book
|
|
1690
1896
|
# has the book redacted.
|
|
@@ -1700,12 +1906,14 @@ module Vicary
|
|
|
1700
1906
|
# second half of the same rule and neither half works alone.
|
|
1701
1907
|
def find_candidates(text, options = {})
|
|
1702
1908
|
given_name = options[:given_name]
|
|
1909
|
+
given_name_corroboration = options[:given_name_corroboration]
|
|
1703
1910
|
title = options[:title]
|
|
1704
1911
|
title_prefix = options[:title_prefix]
|
|
1705
1912
|
settlement = options[:settlement]
|
|
1706
1913
|
headings_are_orthographic = options.fetch(:headings_are_orthographic, true)
|
|
1707
1914
|
title_relation_refusal = options.fetch(:title_relation_refusal, true)
|
|
1708
1915
|
mid_sentence_corroboration = options.fetch(:mid_sentence_corroboration, true)
|
|
1916
|
+
case_variance = options.fetch(:case_variance, true)
|
|
1709
1917
|
|
|
1710
1918
|
blocked = each_match(text, PROTECTED).map { |m| [m.begin(0), m.begin(0) + m[0].length] }
|
|
1711
1919
|
starts = sentence_starts(text)
|
|
@@ -1744,6 +1952,16 @@ module Vicary
|
|
|
1744
1952
|
# `habit` is: two call sites computing it separately could disagree.
|
|
1745
1953
|
stray_capitals = mid_sentence_corroboration && !given_name.nil? &&
|
|
1746
1954
|
capitalises_ordinary_words?(text, headings)
|
|
1955
|
+
# The mirror of `written_as_a_capital`, read once for the same reason.
|
|
1956
|
+
# Empty unless the writer marks proper nouns at all: this rule reads the
|
|
1957
|
+
# ABSENCE of a capital as testimony, and the habit states in as many
|
|
1958
|
+
# words that an absence means nothing in a LOWERCASE or SILENT document.
|
|
1959
|
+
# Running it there would suppress every capital the writer did manage.
|
|
1960
|
+
lower_cased = if case_variance && !given_name.nil? && marks_proper_nouns?(habit)
|
|
1961
|
+
written_in_lower_case(text)
|
|
1962
|
+
else
|
|
1963
|
+
Set.new
|
|
1964
|
+
end
|
|
1747
1965
|
|
|
1748
1966
|
out = []
|
|
1749
1967
|
each_match(text, CANDIDATE_RE) do |m|
|
|
@@ -1763,12 +1981,22 @@ module Vicary
|
|
|
1763
1981
|
|
|
1764
1982
|
start = m.begin(0) + offset
|
|
1765
1983
|
next if is_protected.call(start, start + joined.length)
|
|
1984
|
+
|
|
1985
|
+
# Cheapest of the three capital-discounting rules and the only one
|
|
1986
|
+
# that reads neither position nor list, so it goes first.
|
|
1987
|
+
if !lower_cased.empty? && !given_name.nil? &&
|
|
1988
|
+
suppressed_as_a_word_the_writer_also_writes_lower_case?(run, lower_cased,
|
|
1989
|
+
given_name)
|
|
1990
|
+
next
|
|
1991
|
+
end
|
|
1992
|
+
|
|
1766
1993
|
# Requiring a second signal is only sound when there is a second
|
|
1767
1994
|
# signal to require, which is why this is reached only where an
|
|
1768
1995
|
# oracle exists.
|
|
1769
1996
|
if !given_name.nil? &&
|
|
1770
1997
|
suppressed_as_an_unevidenced_capital?(run, start, starts, emphasis, headings,
|
|
1771
|
-
written_as_a_capital, given_name
|
|
1998
|
+
written_as_a_capital, given_name,
|
|
1999
|
+
given_name_corroboration)
|
|
1772
2000
|
next
|
|
1773
2001
|
end
|
|
1774
2002
|
|
data/lib/vicary/gazetteer.rb
CHANGED
|
@@ -52,7 +52,8 @@ module Vicary
|
|
|
52
52
|
# forgotten here would read back as an empty set, and an empty KEEP tier
|
|
53
53
|
# redacts everything it was built to protect while presenting as
|
|
54
54
|
# over-aggressive tuning.
|
|
55
|
-
TIER_NAMES = %w[full short place given title
|
|
55
|
+
TIER_NAMES = %w[full short place given given_corroboration title
|
|
56
|
+
demonym settlement].freeze
|
|
56
57
|
|
|
57
58
|
# Name particles that may lead a two- or three-token *partial* surname.
|
|
58
59
|
#
|
|
@@ -179,7 +180,8 @@ module Vicary
|
|
|
179
180
|
# use rather than taken as constructor arguments, because they are functions
|
|
180
181
|
# of +title+ and must never be able to disagree with it.
|
|
181
182
|
class Index
|
|
182
|
-
attr_reader :full, :short, :place, :given, :
|
|
183
|
+
attr_reader :full, :short, :place, :given, :given_corroboration, :title,
|
|
184
|
+
:demonym, :settlement, :meta
|
|
183
185
|
|
|
184
186
|
def initialize(asset)
|
|
185
187
|
asset.tiers.each_key do |name|
|
|
@@ -198,6 +200,10 @@ module Vicary
|
|
|
198
200
|
@place = asset.tiers.fetch("place", EMPTY)
|
|
199
201
|
# Common given names. The INVERSE signal — see #common_given_name?.
|
|
200
202
|
@given = asset.tiers.fetch("given", EMPTY)
|
|
203
|
+
# Given names between the corroboration floor and the generation floor —
|
|
204
|
+
# the INCREMENT over +given+, never the whole set. Readers union the two;
|
|
205
|
+
# see #vouches_for_a_given_name_in_corroboration?.
|
|
206
|
+
@given_corroboration = asset.tiers.fetch("given_corroboration", EMPTY)
|
|
201
207
|
# Works and fictional characters — multi-token only. See #title?.
|
|
202
208
|
@title = asset.tiers.fetch("title", EMPTY)
|
|
203
209
|
# English demonyms — `cuban`, `nigerian`. A KEEP, see DEMONYM.
|
|
@@ -297,6 +303,28 @@ module Vicary
|
|
|
297
303
|
!key.empty? && !key.include?(" ") && given.include?(key)
|
|
298
304
|
end
|
|
299
305
|
|
|
306
|
+
# #common_given_name?, at the permissive corroboration floor.
|
|
307
|
+
#
|
|
308
|
+
# **Strictly wider than #common_given_name? and never narrower**, because
|
|
309
|
+
# +given_corroboration+ holds the increment and this unions it with
|
|
310
|
+
# +given+ rather than replacing it. A caller reaching for this one cannot
|
|
311
|
+
# accidentally get a smaller answer than the generation tier would give,
|
|
312
|
+
# which is the failure a second independently-built list would allow.
|
|
313
|
+
#
|
|
314
|
+
# The two floors exist because the two roles cost differently. A
|
|
315
|
+
# generation hit creates a span out of lower-case prose; a corroboration
|
|
316
|
+
# hit can only un-suppress a span the writer's own capital already
|
|
317
|
+
# proposed. The shipped 1,800-birth knee was measured against the first
|
|
318
|
+
# and does not transfer to the second.
|
|
319
|
+
#
|
|
320
|
+
# Consulted by Candidates.corroborated? and by nothing else.
|
|
321
|
+
def vouches_for_a_given_name_in_corroboration?(token)
|
|
322
|
+
key = Gazetteer.normalize(token)
|
|
323
|
+
return false if key.empty? || key.include?(" ")
|
|
324
|
+
|
|
325
|
+
given.include?(key) || given_corroboration.include?(key)
|
|
326
|
+
end
|
|
327
|
+
|
|
300
328
|
# True when +name+ is a town, city or village.
|
|
301
329
|
#
|
|
302
330
|
# **Not part of the notability decision, and deliberately not consulted by
|
|
@@ -391,6 +419,11 @@ module Vicary
|
|
|
391
419
|
load.common_given_name?(token)
|
|
392
420
|
end
|
|
393
421
|
|
|
422
|
+
# The given-name tier at its permissive, corroboration-only floor.
|
|
423
|
+
def vouches_for_a_given_name_in_corroboration?(token)
|
|
424
|
+
load.vouches_for_a_given_name_in_corroboration?(token)
|
|
425
|
+
end
|
|
426
|
+
|
|
394
427
|
# True when +name+ is a town or city — a TYPING signal, not a keep.
|
|
395
428
|
def settlement?(name)
|
|
396
429
|
load.settlement?(name)
|
data/lib/vicary/redact.rb
CHANGED
|
@@ -120,7 +120,16 @@ module Vicary
|
|
|
120
120
|
# The one difference between the two gazetteer levels. Absent rather than
|
|
121
121
|
# nil, so `gazetteer` and `gazetteer-lowercase` differ by the presence of a
|
|
122
122
|
# key rather than by a value the merge would have to strip.
|
|
123
|
-
|
|
123
|
+
if level == NAMES_LOWERCASE
|
|
124
|
+
oracles[:given_name] = ->(token) { Gazetteer.common_given_name?(token) }
|
|
125
|
+
# Paired deliberately: the corroboration floor is inert without
|
|
126
|
+
# `given_name` — every rule that could read it is gated on an oracle
|
|
127
|
+
# being supplied — so wiring it at the bare level too would be a dial
|
|
128
|
+
# that looks set and does nothing, which is worse than an absent one.
|
|
129
|
+
oracles[:given_name_corroboration] = lambda { |token|
|
|
130
|
+
Gazetteer.vouches_for_a_given_name_in_corroboration?(token)
|
|
131
|
+
}
|
|
132
|
+
end
|
|
124
133
|
oracles
|
|
125
134
|
end
|
|
126
135
|
|
data/lib/vicary/version.rb
CHANGED
|
@@ -6,5 +6,5 @@ module Vicary
|
|
|
6
6
|
# Shared across all three front doors on purpose: one detector, one number. A
|
|
7
7
|
# gem 0.3.0 that corresponds to nothing on PyPI cannot be reasoned about, and
|
|
8
8
|
# the parity claim is between *versions*, not between package names.
|
|
9
|
-
VERSION = "0.2.
|
|
9
|
+
VERSION = "0.2.14"
|
|
10
10
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: vicary
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Blake Thomas
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Finds the names a student writes about — classmates, teachers, relatives — and
|