vicary 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +57 -0
- data/assets/MANIFEST.json +33 -0
- data/assets/notability.txt.gz +0 -0
- data/assets/stop_words.txt +63 -0
- data/lib/vicary/asset.rb +190 -0
- data/lib/vicary/candidates.rb +1702 -0
- data/lib/vicary/conformance.rb +242 -0
- data/lib/vicary/gazetteer.rb +399 -0
- data/lib/vicary/lexicon.rb +174 -0
- data/lib/vicary/minter.rb +95 -0
- data/lib/vicary/redact.rb +172 -0
- data/lib/vicary/structured.rb +343 -0
- data/lib/vicary/version.rb +10 -0
- data/lib/vicary.rb +41 -0
- metadata +66 -0
|
@@ -0,0 +1,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
|