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,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module Vicary
7
+ # Read the shared spec and score this implementation against it.
8
+ #
9
+ # The spec lives in the repository's `conformance/` directory, is generated from
10
+ # the Python implementation, and is what all three front doors run against. See
11
+ # `conformance/README.md` for the bar; the short version is that every frame's
12
+ # masked output must be byte-identical **including placeholder numbering**.
13
+ #
14
+ # **Why the scoreboard reports two denominators.** 16 of the 51 frames expect
15
+ # nothing to be masked — they exist to catch over-redaction. An implementation
16
+ # that returns its input unchanged therefore scores 16 of 51 and looks a third
17
+ # of the way done while detecting nothing. So the number that leads is
18
+ # `matched of frames_requiring_masking`, with the 51-frame total beside it
19
+ # rather than instead of it. A ratio whose numerator a null implementation can
20
+ # inflate is not a measure of progress.
21
+ module Conformance
22
+ DOCUMENT_VERSION = 1
23
+
24
+ class SpecError < StandardError; end
25
+
26
+ Span = Struct.new(:entity, :literal, :verdict, :expect_count, :expect,
27
+ :kept_by, :redacted_by, :note, keyword_init: true)
28
+ Frame = Struct.new(:frame_id, :group, :sentence, :spans, :held_out,
29
+ :prompt_context, :note, keyword_init: true)
30
+ Identity = Struct.new(:first_name, :last_name, :school_name,
31
+ keyword_init: true)
32
+ Golden = Struct.new(:masked, :placeholders, :mapping, :aligns,
33
+ keyword_init: true)
34
+ Spec = Struct.new(:fixture_version, :reference_arm, :identity, :frames,
35
+ :golden, keyword_init: true)
36
+ Gate = Struct.new(:id, :label, :unit, :op, :bar, :requires, :why,
37
+ keyword_init: true)
38
+ GateSpec = Struct.new(:reference_arm, :requirements, :gates,
39
+ keyword_init: true)
40
+ Outcome = Struct.new(:frame_id, :requires_masking, :matched, :expected,
41
+ :produced, :error, keyword_init: true)
42
+ Scoreboard = Struct.new(:fixture_version, :reference_arm, :total, :matched,
43
+ :requiring_masking, :matched_requiring_masking,
44
+ :outcomes, keyword_init: true)
45
+
46
+ class << self
47
+ # Locate the repository's `conformance/` directory, or raise naming the search.
48
+ def directory
49
+ tried = []
50
+ current = Pathname.new(__dir__).expand_path
51
+ 8.times do
52
+ candidate = current.join("conformance")
53
+ tried << candidate
54
+ return candidate if candidate.join("frames.json").file?
55
+
56
+ parent = current.parent
57
+ break if parent == current
58
+
59
+ current = parent
60
+ end
61
+ raise SpecError,
62
+ "no conformance/frames.json found. Looked in: #{tried.join(', ')}. " \
63
+ "The spec lives in the repository, not in an installed gem — a " \
64
+ "packaged copy would imply the installed one is authoritative."
65
+ end
66
+
67
+ def load_spec(dir = nil)
68
+ dir = Pathname.new(dir || directory)
69
+ raw = JSON.parse(dir.join("frames.json").read)
70
+ require_version(raw["document_version"], "frames.json")
71
+
72
+ frames = raw.fetch("frames").map do |f|
73
+ Frame.new(
74
+ frame_id: f.fetch("frame_id"),
75
+ group: f.fetch("group"),
76
+ sentence: f.fetch("sentence"),
77
+ held_out: f.fetch("held_out", false),
78
+ prompt_context: f.fetch("prompt_context", ""),
79
+ note: f.fetch("note", ""),
80
+ spans: f.fetch("spans").map { |s| span_from(s) },
81
+ )
82
+ end
83
+
84
+ golden = raw.fetch("golden").transform_values do |g|
85
+ Golden.new(masked: g.fetch("masked"),
86
+ placeholders: g.fetch("placeholders"),
87
+ mapping: g.fetch("mapping"),
88
+ aligns: g.fetch("aligns"))
89
+ end
90
+
91
+ identity = raw.fetch("identity")
92
+ Spec.new(
93
+ fixture_version: raw.fetch("fixture_version"),
94
+ reference_arm: raw.fetch("reference_arm"),
95
+ identity: Identity.new(first_name: identity.fetch("first_name"),
96
+ last_name: identity.fetch("last_name"),
97
+ school_name: identity.fetch("school_name")),
98
+ frames: frames,
99
+ golden: golden,
100
+ )
101
+ end
102
+
103
+ def load_gates(dir = nil)
104
+ dir = Pathname.new(dir || directory)
105
+ raw = JSON.parse(dir.join("gates.json").read)
106
+ require_version(raw["document_version"], "gates.json")
107
+ GateSpec.new(
108
+ reference_arm: raw.fetch("reference_arm"),
109
+ requirements: raw.fetch("requirements"),
110
+ gates: raw.fetch("gates").map do |g|
111
+ Gate.new(id: g.fetch("id"), label: g.fetch("label"),
112
+ unit: g.fetch("unit"), op: g.fetch("op"),
113
+ bar: g.fetch("bar"), requires: g.fetch("requires"),
114
+ why: g.fetch("why"))
115
+ end,
116
+ )
117
+ end
118
+
119
+ # The primitives spec — the layer underneath the frames.
120
+ #
121
+ # `frames.json` scores finished output, which is the right final bar and a
122
+ # poor first one: a port with nothing implemented scores 0 of 36 and learns
123
+ # nothing about which of the forty-odd primitives underneath is wrong.
124
+ # `primitives.json` is that missing layer, generated from the Python
125
+ # functions and byte-compared against a fresh export by
126
+ # `python/tests/test_conformance.py`.
127
+ #
128
+ # Returned as the parsed document rather than as structs: it is a table of
129
+ # forty-odd differently-shaped sections, and a struct per section would be
130
+ # forty transcriptions of the thing the file exists to stop anyone
131
+ # transcribing.
132
+ def load_primitives(dir = nil)
133
+ dir = Pathname.new(dir || directory)
134
+ path = dir.join("primitives.json")
135
+ unless path.file?
136
+ raise SpecError,
137
+ "no primitives.json at #{path}. The ports would check their " \
138
+ "tokenisation against nothing."
139
+ end
140
+
141
+ raw = JSON.parse(path.read)
142
+ require_version(raw["document_version"], "primitives.json")
143
+ raw
144
+ end
145
+
146
+ # Score an implementation against every frame.
147
+ #
148
+ # The block receives (sentence, identity) — the same input every Python arm
149
+ # receives. Omitting the identity measures a different system and misses the
150
+ # easiest spans in the fixture.
151
+ def score(spec)
152
+ outcomes = spec.frames.map do |frame|
153
+ golden = spec.golden[frame.frame_id]
154
+ if golden.nil?
155
+ raise SpecError,
156
+ "frame #{frame.frame_id} has no golden output in the spec; the " \
157
+ "file is internally inconsistent and scoring against it would " \
158
+ "be meaningless"
159
+ end
160
+
161
+ produced = nil
162
+ error = nil
163
+ begin
164
+ produced = yield(frame.sentence, spec.identity)
165
+ rescue StandardError => e
166
+ produced = ""
167
+ error = e.message
168
+ end
169
+
170
+ Outcome.new(frame_id: frame.frame_id,
171
+ requires_masking: !golden.placeholders.empty?,
172
+ matched: error.nil? && produced == golden.masked,
173
+ expected: golden.masked, produced: produced, error: error)
174
+ end
175
+
176
+ requiring = outcomes.select(&:requires_masking)
177
+ Scoreboard.new(
178
+ fixture_version: spec.fixture_version,
179
+ reference_arm: spec.reference_arm,
180
+ total: outcomes.size,
181
+ matched: outcomes.count(&:matched),
182
+ requiring_masking: requiring.size,
183
+ matched_requiring_masking: requiring.count(&:matched),
184
+ outcomes: outcomes,
185
+ )
186
+ end
187
+
188
+ # Render the scoreboard.
189
+ #
190
+ # Leads with the masking-required ratio, the one a null implementation
191
+ # cannot inflate. Gates print NOT MEASURED per gate rather than being
192
+ # reduced out of the denominator — five of nine held is a different
193
+ # statement from nine of nine, and a badge cannot tell them apart.
194
+ def report(board, gates)
195
+ lines = []
196
+ lines << "conformance — fixture #{board.fixture_version}, arm #{board.reference_arm}"
197
+ lines << ("-" * 58)
198
+ lines << format(" frames requiring masking %3d / %d",
199
+ board.matched_requiring_masking, board.requiring_masking)
200
+ lines << format(" all frames %3d / %d (%d expect no " \
201
+ "masking, so an identity function scores that many)",
202
+ board.matched, board.total,
203
+ board.total - board.requiring_masking)
204
+ lines << ("-" * 58)
205
+ lines << " gates:"
206
+ gates.gates.each do |gate|
207
+ needs = gate.requires.empty? ? "" : " NEEDS #{gate.requires.join('+')}"
208
+ lines << format(" NOT MEASURED %-28s %s %s %s%s",
209
+ gate.label, gate.op, gate.bar, gate.unit, needs)
210
+ end
211
+ lines << " -> no gate is measured by this port yet. A green run here " \
212
+ "means the spec loads,"
213
+ lines << " never that the gate set is clear."
214
+ lines.join("\n")
215
+ end
216
+
217
+ private
218
+
219
+ def span_from(raw)
220
+ # The defaults are documented in conformance/README.md. Applying them here
221
+ # rather than requiring the exporter to write them keeps the file
222
+ # readable; getting one wrong silently changes what a frame asserts.
223
+ Span.new(entity: raw.fetch("entity"), literal: raw.fetch("literal"),
224
+ verdict: raw.fetch("verdict", "redact"),
225
+ expect_count: raw.fetch("expect_count", nil),
226
+ expect: raw.fetch("expect", nil),
227
+ kept_by: raw.fetch("kept_by", "notability"),
228
+ redacted_by: raw.fetch("redacted_by", "absence"),
229
+ note: raw.fetch("note", ""))
230
+ end
231
+
232
+ def require_version(version, file)
233
+ return if version == DOCUMENT_VERSION
234
+
235
+ raise SpecError,
236
+ "#{file} is document_version #{version.inspect}, this reader " \
237
+ "understands #{DOCUMENT_VERSION}. Refusing to read it rather than " \
238
+ "guessing which fields moved."
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,399 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Vicary
6
+ # Offline notability lookup: is this name a public figure, or somebody's cousin?
7
+ #
8
+ # The Ruby port of `python/src/vicary/gazetteer.py`. Same tiers, same verdicts,
9
+ # same asymmetry — **notable => KEEP, everything else => REDACT** — so a miss
10
+ # here costs precision (a public figure masked) while a false positive costs
11
+ # recall, which is the gap the library exists to close.
12
+ #
13
+ # Candidate generation over capitalised token sequences proposes
14
+ # `Terrence Okonkwo` and `Vincent van Gogh` with equal confidence, because in
15
+ # English prose they are the same thing: two capitalised words. No syntactic
16
+ # feature separates them, so the filter is a set-membership lookup, and this
17
+ # module is the lookup.
18
+ #
19
+ # **A candidate is never split into tokens and tested piecewise.** If it were,
20
+ # `Priya Raghunathan-Bell` would resolve notable off `Bell` and a real student's
21
+ # name would leak. Whole-string matching is what makes the multi-token tier safe
22
+ # to populate broadly, and it is why honorifics are *not* stripped before
23
+ # lookup: `Coach Bramwell` matches no label and therefore redacts, where
24
+ # stripping the title would demote it to a bare surname — the shape most likely
25
+ # to collide with a public figure. The accepted cost is that `President Lincoln`
26
+ # over-redacts.
27
+ #
28
+ # Two tiers are deliberately invisible to {Index#notability}: `given` points the
29
+ # other way (a common first name is evidence of a *person*, so on the inbound
30
+ # path it means redact), and `settlement` types a mask rather than granting one.
31
+ # Wiring either into `notability` would readmit the exact PII the tiers exist to
32
+ # remove, which is why each has its own guard test.
33
+ module Gazetteer
34
+ # Lookup verdicts. Strings rather than symbols so they survive a JSON round
35
+ # trip into and out of the conformance spec unchanged.
36
+ NOT_NOTABLE = "not_notable"
37
+ TITLE = "title"
38
+ FULL_NAME = "full_name"
39
+ ICONIC_SHORT = "iconic_short"
40
+ PLACE = "place"
41
+
42
+ # A nationality or regional adjective — `Cuban`, `Nigerian`, `Bostonian`.
43
+ #
44
+ # Its own verdict rather than folded into PLACE because it is not a place: it
45
+ # is a word *derived* from one, it is the only keep tier with no notability
46
+ # evidence behind it, and eval attribution needs to see it separately to tell
47
+ # whether this tier is where a leak came from.
48
+ DEMONYM = "demonym"
49
+
50
+ # Every tier this reader knows. An asset carrying a tier absent from this
51
+ # list is refused rather than ignored — a tier added to the builder and
52
+ # forgotten here would read back as an empty set, and an empty KEEP tier
53
+ # redacts everything it was built to protect while presenting as
54
+ # over-aggressive tuning.
55
+ TIER_NAMES = %w[full short place given title demonym settlement].freeze
56
+
57
+ # Name particles that may lead a two- or three-token *partial* surname.
58
+ #
59
+ # Kept in sync with the Python runtime's list by a unit test rather than by
60
+ # import; the asset itself carries no copy.
61
+ PARTICLES = Set.new(%w[
62
+ van von de del della di da du la le
63
+ les der den ten ter dos das al bin ibn
64
+ mac mc st saint san abu ben op vander
65
+ ]).freeze
66
+
67
+ # Honorifics and role titles. NOT stripped before lookup — exposed because a
68
+ # leading title is a positive signal that a candidate is a real person in the
69
+ # student's life, which is a candidate-generator concern.
70
+ ROLE_TITLES = Set.new(%w[
71
+ mr mrs ms miss mx dr doctor prof professor
72
+ coach principal officer sgt sergeant capt captain
73
+ rev reverend father sister brother pastor rabbi
74
+ imam nurse sen senator rep gov governor mayor
75
+ sir dame lady lord aunt uncle grandma grandpa
76
+ ]).freeze
77
+
78
+ # Curly quotes and dashes that NFKD leaves alone.
79
+ #
80
+ # Student prose is full of them — a word processor turns every apostrophe
81
+ # curly — and without this mapping `Lincoln’s` folds to `lincoln s` and misses
82
+ # every tier, silently over-masking a notable name on the most ordinary
83
+ # punctuation there is. Identical to the Python runtime's `_SMART_QUOTES`; a
84
+ # unit test pins them together.
85
+ SMART_QUOTES = {
86
+ "‘" => "'", "’" => "'", "ʼ" => "'", "′" => "'",
87
+ "“" => '"', "”" => '"',
88
+ "‐" => "-", "‑" => "-", "‒" => "-", "–" => "-",
89
+ "—" => "-", "−" => "-"
90
+ }.freeze
91
+
92
+ SMART_QUOTE_PATTERN = Regexp.union(SMART_QUOTES.keys).freeze
93
+
94
+ # Letters and digits, matching Python's Unicode-aware `str.isalnum()`.
95
+ ALNUM = /[[:alpha:][:digit:]]/.freeze
96
+
97
+ # The asset is absent, unreadable, or not the shape this reader understands.
98
+ #
99
+ # Raised rather than degrading to "nothing is notable". That fallback is
100
+ # privacy-safe and product-hostile — every public figure in every essay masked
101
+ # — and it looks like a tuning regression rather than a packaging bug for
102
+ # however long it takes somebody to notice.
103
+ class AssetError < StandardError; end
104
+
105
+ EMPTY = Set.new.freeze
106
+
107
+ # Fold a name to its lookup key.
108
+ #
109
+ # Accent-stripped, lower-cased, punctuation reduced to spaces. The apostrophe
110
+ # and internal hyphen survive because they belong to the name (`O'Keeffe`,
111
+ # `Raghunathan-Bell`) rather than surrounding it. A trailing possessive is
112
+ # dropped, because `Terrence's older brother` presents the name as
113
+ # `Terrence's` and a lookup that misses on the clitic is a leak.
114
+ #
115
+ # Must fold identically to the Python runtime's `normalize`, because the asset
116
+ # is keyed by one fold and probed by the other. If they drift, every lookup
117
+ # silently misses and the gazetteer answers "nothing is notable" while looking
118
+ # perfectly healthy.
119
+ #
120
+ # **One documented divergence from Python, unreachable on this asset**, shared
121
+ # with the TypeScript port for the same reason. Python drops characters whose
122
+ # *canonical combining class* is non-zero; this drops `\p{M}` — every mark.
123
+ # The two sets differ only for marks with a combining class of zero (some Thai
124
+ # and Indic vowel signs), which Python turns into a space and this drops
125
+ # outright. That changes a key only when such a mark sits *between* two
126
+ # alphanumerics, which cannot happen in a gazetteer whose keys are
127
+ # Latin-folded, nor in the English prose the conformance frames carry.
128
+ def self.normalize(name)
129
+ folded = name.gsub(SMART_QUOTE_PATTERN) { |char| SMART_QUOTES.fetch(char, char) }
130
+ folded = folded.unicode_normalize(:nfkd)
131
+ folded = folded.gsub(/\p{M}/, "")
132
+ folded = folded.downcase
133
+
134
+ key = folded.each_char.map { |char|
135
+ ALNUM.match?(char) || char == "'" || char == "-" ? char : " "
136
+ }.join.split(" ").reject(&:empty?).join(" ")
137
+
138
+ ["'s", "s'"].each do |clitic|
139
+ next unless key.end_with?(clitic) && key.length > clitic.length + 1
140
+
141
+ key = key[0...-clitic.length].sub(/'+\z/, "").strip
142
+ break
143
+ end
144
+
145
+ key
146
+ end
147
+
148
+ # An immutable, loaded notability index over the tiers the asset carries.
149
+ #
150
+ # The derived indices (+title_heads+, +title_prefixes+) are memoized on first
151
+ # use rather than taken as constructor arguments, because they are functions
152
+ # of +title+ and must never be able to disagree with it.
153
+ class Index
154
+ attr_reader :full, :short, :place, :given, :title, :demonym, :settlement, :meta
155
+
156
+ def initialize(asset)
157
+ asset.tiers.each_key do |name|
158
+ next if TIER_NAMES.include?(name)
159
+
160
+ raise AssetError,
161
+ "unknown gazetteer tier #{name.inspect}. Refusing the asset " \
162
+ "rather than ignoring the tier: a tier this reader drops is a " \
163
+ "tier that reads back empty, and an empty keep tier redacts " \
164
+ "everything it was built to protect while looking like " \
165
+ "over-aggressive tuning."
166
+ end
167
+
168
+ @full = asset.tiers.fetch("full", EMPTY)
169
+ @short = asset.tiers.fetch("short", EMPTY)
170
+ @place = asset.tiers.fetch("place", EMPTY)
171
+ # Common given names. The INVERSE signal — see #common_given_name?.
172
+ @given = asset.tiers.fetch("given", EMPTY)
173
+ # Works and fictional characters — multi-token only. See #title?.
174
+ @title = asset.tiers.fetch("title", EMPTY)
175
+ # English demonyms — `cuban`, `nigerian`. A KEEP, see DEMONYM.
176
+ @demonym = asset.tiers.fetch("demonym", EMPTY)
177
+ # Human settlements. Neither a keep nor a redact signal — the only tier
178
+ # that is neither. See #settlement?.
179
+ @settlement = asset.tiers.fetch("settlement", EMPTY)
180
+ @meta = asset.meta
181
+ end
182
+
183
+ # Entries that can make something KEEP.
184
+ #
185
+ # +given+ and +settlement+ are excluded on purpose: neither grants a keep,
186
+ # so counting them would inflate the one number that answers "how much
187
+ # notability does this asset carry".
188
+ def entry_count
189
+ full.size + short.size + place.size + title.size + demonym.size
190
+ end
191
+
192
+ # First tokens of every title, so a scanner can skip most positions.
193
+ #
194
+ # Without this the title scan costs one lookup per candidate length at
195
+ # every token. With it the common case is a single set miss.
196
+ def title_heads
197
+ @title_heads ||= begin
198
+ heads = Set.new
199
+ title.each do |key|
200
+ space = key.index(" ")
201
+ heads << (space.nil? ? key : key[0, space])
202
+ end
203
+ heads
204
+ end
205
+ end
206
+
207
+ # Every token-prefix of every title, so a scan can stop the moment no title
208
+ # can still be reached.
209
+ #
210
+ # This is the automaton the per-position n-gram scan was standing in for: a
211
+ # walk advances only while some title still starts with what it has read,
212
+ # which on ordinary prose is one or two tokens. A flat set of pre-joined
213
+ # prefixes rather than a trie of objects — same asymptotics, a fraction of
214
+ # the allocations, built by a single pass over keys that are already
215
+ # normalised.
216
+ def title_prefixes
217
+ @title_prefixes ||= begin
218
+ prefixes = Set.new
219
+ title.each do |key|
220
+ tokens = key.split(" ")
221
+ (1...tokens.length).each { |length| prefixes << tokens[0, length].join(" ") }
222
+ end
223
+ prefixes
224
+ end
225
+ end
226
+
227
+ # Longest title in tokens, so a scanner knows how far to look ahead.
228
+ def max_title_tokens
229
+ @max_title_tokens ||= title.map { |key| key.count(" ") + 1 }.max || 0
230
+ end
231
+
232
+ # True when some title starts with (or equals) the token sequence +key+.
233
+ #
234
+ # +key+ is an already-folded lookup key — space-joined lower-cased tokens —
235
+ # not raw text. The scan folds each token of the document once and joins,
236
+ # rather than re-normalising a growing substring at every length.
237
+ def title_prefix?(key)
238
+ title_prefixes.include?(key) || title.include?(key)
239
+ end
240
+
241
+ # True when +name+ is a published work or a fictional character.
242
+ #
243
+ # The full tier is `P31 wd:Q5` — human — so before this tier existed every
244
+ # work title and every fictional character redacted: "Harry Potter taught me
245
+ # about friendship" came back as "{NAME} taught me about friendship".
246
+ #
247
+ # Multi-token by construction, and that is a safety property rather than a
248
+ # convenience. "It", "Up", "Her", "Room", "Brave" and "Cats" are all films;
249
+ # a single-token title tier would make those ordinary words permanently
250
+ # notable, and notable means KEEP, so the cost would land on recall.
251
+ def title?(name)
252
+ key = Gazetteer.normalize(name)
253
+ key.include?(" ") && title.include?(key)
254
+ end
255
+
256
+ # True when +token+ is a first name lots of notable people share.
257
+ #
258
+ # Not part of the notability decision, and deliberately not consulted by
259
+ # #notability — it points the other way. A given-name hit is evidence the
260
+ # token names a *person*, which on the inbound path means redact.
261
+ #
262
+ # It exists for the two frames capitalisation cannot reach: `then terrence
263
+ # okonkwo showed up` and `MY BEST FRIEND DESHAWN PRITCHARD` score zero for
264
+ # any candidate generator keyed on capitalisation, by construction. A
265
+ # case-insensitive scan closes that, and a scan needs a list. This is the
266
+ # list; the scan belongs to the candidate generator.
267
+ def common_given_name?(token)
268
+ key = Gazetteer.normalize(token)
269
+ !key.empty? && !key.include?(" ") && given.include?(key)
270
+ end
271
+
272
+ # True when +name+ is a town, city or village.
273
+ #
274
+ # **Not part of the notability decision, and deliberately not consulted by
275
+ # #notability.** A settlement is a student's hometown, so it must redact;
276
+ # that is the whole reason settlements are subtracted from the place tier.
277
+ # What this answers is the *next* question, asked only about a span already
278
+ # being masked: which placeholder does it get. A host that reads the type
279
+ # back writes "great job describing your trip to {LOCATION}", and before
280
+ # this tier existed it wrote "{NAME}".
281
+ #
282
+ # The failure modes are not symmetric with a keep tier's: a miss types a
283
+ # place `{NAME}` and a false positive types a person `{LOCATION}`. Both are
284
+ # already redacted.
285
+ def settlement?(name)
286
+ key = Gazetteer.normalize(name)
287
+ !key.empty? && settlement.include?(key)
288
+ end
289
+
290
+ # Classify +name+. One of the verdict constants above.
291
+ #
292
+ # Places are checked first: the string is being judged on what it *names*,
293
+ # and a place-name that is also a surname (`Washington`, `Delaware`) is
294
+ # keepable either way, so resolving it as a place costs nothing and saves a
295
+ # probe.
296
+ def notability(name)
297
+ key = Gazetteer.normalize(name)
298
+ return NOT_NOTABLE if key.empty?
299
+
300
+ tokens = key.split(" ")
301
+ return PLACE if place.include?(key)
302
+
303
+ if tokens.length == 1
304
+ return ICONIC_SHORT if short.include?(key)
305
+
306
+ # After `short`, because a token that is both — none today, but the
307
+ # tiers are rebuilt from a moving upstream — should report the tier that
308
+ # carries notability evidence rather than the one that does not.
309
+ return demonym.include?(key) ? DEMONYM : NOT_NOTABLE
310
+ end
311
+
312
+ # "van Gogh", "de Gaulle" — a partial, not a full name, so it is held to
313
+ # the strict short-tier threshold.
314
+ return ICONIC_SHORT if tokens.length <= 3 && PARTICLES.include?(tokens[0]) && short.include?(key)
315
+ return FULL_NAME if full.include?(key)
316
+
317
+ # Titles resolve LAST. "Joan of Arc" and "van Gogh" are both also film
318
+ # titles, and attributing them to the title tier would be true but less
319
+ # specific — the person is who the student wrote about. Either way the
320
+ # verdict is KEEP; only the reported tier changes, and that tier is what
321
+ # eval attribution and telemetry read.
322
+ return TITLE if tokens.length > 1 && title.include?(key)
323
+
324
+ NOT_NOTABLE
325
+ end
326
+
327
+ def notable?(name)
328
+ notability(name) != NOT_NOTABLE
329
+ end
330
+ end
331
+
332
+ class << self
333
+ # Load (and memoize) the notability index.
334
+ #
335
+ # Lazy: requiring this file reads nothing. Call it at process init to move
336
+ # the decompression off the first request's latency; otherwise the first
337
+ # lookup pays it.
338
+ def load(directory: nil)
339
+ return @cached if @cached && directory.nil?
340
+
341
+ index = Index.new(Asset.load(directory: directory))
342
+ @cached = index if directory.nil?
343
+ index
344
+ end
345
+
346
+ # Drop the memoized index. For tests that swap in a fixture asset.
347
+ def reset_cache
348
+ @cached = nil
349
+ end
350
+
351
+ # True when +name+ is a public figure or a public place. `notable => KEEP`.
352
+ def notable?(name)
353
+ load.notable?(name)
354
+ end
355
+
356
+ # Which tier matched +name+, for telemetry and for eval attribution.
357
+ def notability(name)
358
+ load.notability(name)
359
+ end
360
+
361
+ # True when +token+ is a common given name — a REDACT signal, not a KEEP.
362
+ def common_given_name?(token)
363
+ load.common_given_name?(token)
364
+ end
365
+
366
+ # True when +name+ is a town or city — a TYPING signal, not a keep.
367
+ def settlement?(name)
368
+ load.settlement?(name)
369
+ end
370
+
371
+ # True when +name+ is a published work or a fictional character.
372
+ def title?(name)
373
+ load.title?(name)
374
+ end
375
+
376
+ # True when some title *starts* with +token+ — the scan's cheap prefilter.
377
+ #
378
+ # Deliberately uses +downcase+ rather than {normalize}. This runs once per
379
+ # word of every essay, and +normalize+ does an NFKD decomposition and a
380
+ # per-character rebuild. The heads are already folded and overwhelmingly
381
+ # plain ASCII, so the only cost is that a title beginning with an accented
382
+ # word fails the prefilter and is not matched. That loses a keep, never a
383
+ # redaction.
384
+ def title_head?(token)
385
+ load.title_heads.include?(token.downcase)
386
+ end
387
+
388
+ # True when some title starts with the folded token sequence +key+.
389
+ def title_prefix?(key)
390
+ load.title_prefix?(key)
391
+ end
392
+
393
+ # Longest title in tokens — how far a title scanner must look ahead.
394
+ def max_title_tokens
395
+ load.max_title_tokens
396
+ end
397
+ end
398
+ end
399
+ end