vicary 0.2.11 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2417f5e9aa64949da91c1ca4cf5cc1dac1b8a4bdc70929576bbc7b64d8e629f9
4
- data.tar.gz: 67cd930a6714621ed7872054fac572b87a210940b1644032a845b16d2b2bbfb3
3
+ metadata.gz: 861bcafb7f5671834b082aea4da0edc1bdd050d71aab888d6b1944f58c70b36f
4
+ data.tar.gz: 31fbd6f351e16c1ff1cb0acea2f5422b94decfbeb8b0f8021e20386561cd309c
5
5
  SHA512:
6
- metadata.gz: dfe4bc4a8eb71174d969e41f270eadf8c7170a97e54870fc27b8f2610a8ce2e6b976e39cba2ed87435652d8ca366ca4fb9d90795f773bd8b20db6017a3bce99b
7
- data.tar.gz: 3bef064a891171f7668d8773e813c6efedea6f25385c5da72a4141279662838b623bcefe4a06de28866efdf7f53b04051fa74a02ad6f9c35acc70b9eba1b0f9a
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": 2234918,
4
+ "bytes": 2291959,
5
5
  "cut_date": "2026-08-07",
6
- "format": 5,
7
- "min_package_version": "0.1.0",
8
- "sha256": "ea882c2ab1a80bd5f3b56e32a41e9c32a314ae44389f9596ea5e7ec013d92d69",
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.10"
40
+ "written_by": "vicary 0.2.13"
40
41
  }
Binary file
data/lib/vicary/asset.rb CHANGED
@@ -31,7 +31,7 @@ module Vicary
31
31
  # her name removed.
32
32
  module Asset
33
33
  # Asset format this reader understands. Refuse anything else.
34
- SUPPORTED_FORMAT = 5
34
+ SUPPORTED_FORMAT = 6
35
35
 
36
36
  ASSET_FILENAME = "notability.txt.gz"
37
37
  MANIFEST_FILENAME = "MANIFEST.json"
@@ -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) || is_given.call(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) || is_given.call(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
 
@@ -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 demonym settlement].freeze
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, :title, :demonym, :settlement, :meta
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
- oracles[:given_name] = ->(token) { Gazetteer.common_given_name?(token) } if level == NAMES_LOWERCASE
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
 
@@ -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.11"
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.11
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-10 00:00:00.000000000 Z
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