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,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vicary
|
|
6
|
+
# Structured entities and interpolated identity — the two legs regex does well.
|
|
7
|
+
#
|
|
8
|
+
# The Ruby port of `python/src/vicary/local_classifier.py`.
|
|
9
|
+
#
|
|
10
|
+
# Structured entities (EMAIL, PHONE, SSN, CARD, IP, ZIP, street ADDRESS) are
|
|
11
|
+
# *syntax*, and regex scored **100%** on them in the harness that measured the
|
|
12
|
+
# Bedrock Guardrail at 97.3%. No model beats 100%, and a regex is free and
|
|
13
|
+
# sub-millisecond.
|
|
14
|
+
#
|
|
15
|
+
# The student's own name and school are the NAME/SCHOOL spans that matter most,
|
|
16
|
+
# and they are not being guessed at: the caller knows who submitted the essay.
|
|
17
|
+
# Interpolating those into patterns turns the hardest category for a detector
|
|
18
|
+
# into an exact match.
|
|
19
|
+
#
|
|
20
|
+
# **Order is the contract, not an optimisation.** The first pattern to claim a
|
|
21
|
+
# span wins, and placeholder indices follow mint order, so reordering these
|
|
22
|
+
# tables changes the output bytes even when it changes no verdict. Identity
|
|
23
|
+
# runs first (an address line can otherwise swallow a surname); EMAIL before
|
|
24
|
+
# PHONE; SSN and CARD before the generic digit runs; ZIP and AGE last, because
|
|
25
|
+
# both are bare digits and would claim characters belonging to a phone, card or
|
|
26
|
+
# address.
|
|
27
|
+
#
|
|
28
|
+
# ## Regex dialect
|
|
29
|
+
#
|
|
30
|
+
# Ported from Python `re`. Three differences touch this file, each pinned by
|
|
31
|
+
# `test/dialect_test.rb` rather than reasoned about:
|
|
32
|
+
#
|
|
33
|
+
# * `$` in Ruby means end of *line*; in Python without `re.MULTILINE` it means
|
|
34
|
+
# end of string, or just before a trailing newline. Ruby spells that `\Z`,
|
|
35
|
+
# and {ZIP} uses it. With a bare `$` any five-digit number ending a line —
|
|
36
|
+
# a locker combination, a population, a year range — satisfies the ZIP
|
|
37
|
+
# lookahead and masks, in every hard-wrapped essay. Neither the conformance
|
|
38
|
+
# frames nor the primitives spec catches that, because both corpora are
|
|
39
|
+
# single-line.
|
|
40
|
+
# * `\w` is Unicode-aware in Python and ASCII-only in Ruby. Every `\w` here is
|
|
41
|
+
# written out as {W} so the two agree.
|
|
42
|
+
# * `\d` and `\s` diverge the same way and are left as-is, matching the
|
|
43
|
+
# TypeScript port, which has the same narrowing and reproduces every frame:
|
|
44
|
+
# no fixture distinguishes them, and widening them here alone would make this
|
|
45
|
+
# the odd port out.
|
|
46
|
+
#
|
|
47
|
+
# `\b` is left alone in the structured patterns. Unlike JavaScript's, Ruby's is
|
|
48
|
+
# Unicode-aware and agrees with Python — and it is exact for the ASCII
|
|
49
|
+
# neighbourhoods these patterns match either way. {word_pattern} still spells
|
|
50
|
+
# its boundaries out, because the literal it wraps is a caller's name and may
|
|
51
|
+
# end in punctuation that `\b` cannot assert against at all.
|
|
52
|
+
module Structured
|
|
53
|
+
# Python's `\w`, written out. Ruby's `\w` is `[a-zA-Z0-9_]`, so a phone
|
|
54
|
+
# number preceded by an accented letter would match here and not there if
|
|
55
|
+
# this were left alone.
|
|
56
|
+
W = '\p{L}\p{N}_'
|
|
57
|
+
|
|
58
|
+
# Practical email shape. Deliberately not RFC 5322 — the full grammar matches
|
|
59
|
+
# strings no student writes and is a known source of catastrophic
|
|
60
|
+
# backtracking.
|
|
61
|
+
EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/
|
|
62
|
+
|
|
63
|
+
# US SSN. Excludes the never-issued ranges (000/666/9xx area, 00 group, 0000
|
|
64
|
+
# serial) so dates and score ranges don't trip it.
|
|
65
|
+
SSN = /\b(?!000|666|9\d{2})\d{3}[-\s](?!00)\d{2}[-\s](?!0000)\d{4}\b/
|
|
66
|
+
|
|
67
|
+
# Candidate payment-card runs, 13–19 digits with optional space/hyphen
|
|
68
|
+
# grouping. Luhn-checked below, because an un-checked pattern this loose eats
|
|
69
|
+
# any long number a student writes.
|
|
70
|
+
CARD_CANDIDATE = /\b(?:\d[ -]?){12,18}\d\b/
|
|
71
|
+
|
|
72
|
+
# NANP phone, plus common international prefix. Requires separators or parens
|
|
73
|
+
# somewhere so a bare 10-digit number isn't assumed to be a phone.
|
|
74
|
+
PHONE = Regexp.new(
|
|
75
|
+
"(?<![#{W}-])" \
|
|
76
|
+
'(?:\+?\d{1,3}[-.\s]?)?' \
|
|
77
|
+
'(?:' \
|
|
78
|
+
'\(\d{3}\)[-.\s]*\d{3}[-.\s]?\d{4}' \
|
|
79
|
+
'|\d{3}[-.\s]\d{3}[-.\s]\d{4}' \
|
|
80
|
+
')' \
|
|
81
|
+
'(?:\s*(?:x|ext\.?|extension)\s*\d{1,6})?' \
|
|
82
|
+
"(?![#{W}-])",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
IP = /\b(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\b/
|
|
86
|
+
|
|
87
|
+
# US street address: number + street words + a suffix. The suffix list is
|
|
88
|
+
# what keeps this from matching "I ran 3 miles down the road" — a bare
|
|
89
|
+
# number-plus-words pattern has an unacceptable false-positive rate in prose.
|
|
90
|
+
STREET_SUFFIX =
|
|
91
|
+
'(?:Street|St|Avenue|Ave|Boulevard|Blvd|Road|Rd|Drive|Dr|Lane|Ln|Court|Ct' \
|
|
92
|
+
'|Circle|Cir|Place|Pl|Terrace|Ter|Way|Parkway|Pkwy|Highway|Hwy|Trail|Trl' \
|
|
93
|
+
'|Square|Sq|Loop|Alley|Commons)'
|
|
94
|
+
|
|
95
|
+
ADDRESS = Regexp.new(
|
|
96
|
+
'\b\d{1,6}\s+' \
|
|
97
|
+
'(?:[NSEW]\.?|North|South|East|West|Northeast|Northwest|Southeast|Southwest)?\s*' \
|
|
98
|
+
"(?:[A-Z][A-Za-z.'-]*\\s+){0,4}" \
|
|
99
|
+
"#{STREET_SUFFIX}" '\b\.?' \
|
|
100
|
+
"(?:\\s*(?:Apt|Apartment|Suite|Ste|Unit|\#)\\s*[#{W}-]+)?",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# US ZIP, with the optional +4. Bounded so it can't eat a 5-digit year range.
|
|
104
|
+
# `\Z` rather than `$` — see the dialect note above.
|
|
105
|
+
ZIP = /\b\d{5}(?:-\d{4})?\b(?=\s*\Z|\s*[,.]|\s+[A-Z]{2}\b)/
|
|
106
|
+
|
|
107
|
+
# Explicit age statements. Bare numbers are not ages; the phrasing is.
|
|
108
|
+
AGE = /\b(?:(?:I\s+am|I'm|aged?|age(?:d)?\s+of)\s+)(\d{1,2})\b(?=\s*(?:years?\s+old)?)|\b(\d{1,2})\s+years?\s+old\b/i
|
|
109
|
+
|
|
110
|
+
# URLs. Student essays cite them, and a personal profile URL is PII.
|
|
111
|
+
URL_PATTERN = %r{\bhttps?://[^\s<>"']+|\bwww\.[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+[^\s<>"']*}
|
|
112
|
+
|
|
113
|
+
# Anonymization markers somebody upstream already substituted for real PII.
|
|
114
|
+
#
|
|
115
|
+
# Text arriving with these in it has *already been redacted*, so masking them
|
|
116
|
+
# again destroys information while adding none. The kinds are the closed set
|
|
117
|
+
# the ASAP corpus authors used, measured over the full training set rather
|
|
118
|
+
# than taken from their documentation: 14 distinct kinds across 64,166
|
|
119
|
+
# occurrences.
|
|
120
|
+
#
|
|
121
|
+
# Why this is in the shipped classifier and not just the eval harness: real
|
|
122
|
+
# student prose contains none of these, so production behaviour is unchanged.
|
|
123
|
+
# What changes is every measurement taken over that corpus — a model trained
|
|
124
|
+
# on it saw these tokens at ~22 per essay, and rewriting them to `{USERNAME}`
|
|
125
|
+
# hands it a token it has never seen.
|
|
126
|
+
UPSTREAM_ANON_KINDS = %w[
|
|
127
|
+
CAPS NUM PERSON LOCATION ORGANIZATION MONTH DATE
|
|
128
|
+
PERCENT TIME MONEY EMAIL STATE CITY DR
|
|
129
|
+
].freeze
|
|
130
|
+
|
|
131
|
+
# `@handles`. Requires the `@` so it can't eat ordinary words, and a length
|
|
132
|
+
# floor so it can't eat an email's local part (email runs first anyway). The
|
|
133
|
+
# lookahead spares upstream anonymization markers; a genuine all-caps handle
|
|
134
|
+
# colliding with one of those 14 words is the accepted cost, and it is the
|
|
135
|
+
# right way round — a missed handle is one span, and eating `@PERSON1`
|
|
136
|
+
# corrupts every essay in the evaluation corpus.
|
|
137
|
+
USERNAME = Regexp.new(
|
|
138
|
+
"(?<![#{W}@.])@(?!(?:#{UPSTREAM_ANON_KINDS.join('|')})\\d*\\b)[A-Za-z0-9_]{3,30}\\b",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Date of birth, explicitly labelled.
|
|
142
|
+
DOB = %r{\b(?:date\s+of\s+birth|d\.?o\.?b\.?|born\s+on)\s*:?\s*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b}i
|
|
143
|
+
|
|
144
|
+
# (placeholder kind, pattern) in application order.
|
|
145
|
+
#
|
|
146
|
+
# CARD is handled separately because it needs the Luhn gate; ZIP and AGE run
|
|
147
|
+
# after it for the reason in the module docstring.
|
|
148
|
+
STRUCTURED = [
|
|
149
|
+
["EMAIL", EMAIL],
|
|
150
|
+
["URL", URL_PATTERN],
|
|
151
|
+
["US_SOCIAL_SECURITY_NUMBER", SSN],
|
|
152
|
+
["IP_ADDRESS", IP],
|
|
153
|
+
["PHONE", PHONE],
|
|
154
|
+
["ADDRESS", ADDRESS],
|
|
155
|
+
["DATE_OF_BIRTH", DOB],
|
|
156
|
+
["USERNAME", USERNAME],
|
|
157
|
+
].freeze
|
|
158
|
+
|
|
159
|
+
# Given names that are also ordinary English words.
|
|
160
|
+
#
|
|
161
|
+
# A bare first-name match on one of these destroys prose ("Will you go", "the
|
|
162
|
+
# Art of war", "a Grace period"), so a standalone occurrence is left alone;
|
|
163
|
+
# the full name and the surname still mask. Skewed toward over-inclusion on
|
|
164
|
+
# purpose: a missed first name is one span, a wrongly-masked common word
|
|
165
|
+
# corrupts every essay that uses it.
|
|
166
|
+
AMBIGUOUS_GIVEN_NAMES = %w[
|
|
167
|
+
art bill brook chase dawn drew faith frank
|
|
168
|
+
grace grant hope jack joy june mark may
|
|
169
|
+
mercy miles nick pat patience penny rich
|
|
170
|
+
robin rose sky summer sunny trinity will wills
|
|
171
|
+
].to_set.freeze
|
|
172
|
+
|
|
173
|
+
# Surnames common enough as words to need the same treatment.
|
|
174
|
+
AMBIGUOUS_SURNAMES = %w[
|
|
175
|
+
young white black green brown king moore price rich stone
|
|
176
|
+
].to_set.freeze
|
|
177
|
+
|
|
178
|
+
# Possessive tails, straight and curly.
|
|
179
|
+
#
|
|
180
|
+
# A word processor turns every apostrophe curly, so the straight forms alone
|
|
181
|
+
# miss the majority of real prose. `s'` is the plural-family form ("the
|
|
182
|
+
# Delacroix-Whitfields' house").
|
|
183
|
+
POSSESSIVE_TAIL = "(?:['’]s|s['’])?"
|
|
184
|
+
|
|
185
|
+
class << self
|
|
186
|
+
# Luhn checksum. Cuts the card pattern's false positives on long numbers.
|
|
187
|
+
def luhn_ok?(digits)
|
|
188
|
+
total = 0
|
|
189
|
+
digits.each_char.reverse_each.with_index do |char, i|
|
|
190
|
+
d = char.ord - 48
|
|
191
|
+
if i.odd?
|
|
192
|
+
d *= 2
|
|
193
|
+
d -= 9 if d > 9
|
|
194
|
+
end
|
|
195
|
+
total += d
|
|
196
|
+
end
|
|
197
|
+
(total % 10).zero?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Leading and trailing boundary assertions appropriate to `literal`.
|
|
201
|
+
#
|
|
202
|
+
# `\b` is a boundary only when there is a word character beside it, so a
|
|
203
|
+
# literal *ending* in punctuation — "O'Brien (Jr.)", which is exactly the
|
|
204
|
+
# shape roster data arrives in — can never satisfy a trailing `\b` and
|
|
205
|
+
# silently matches nothing at all. Asserting only on the side that has a
|
|
206
|
+
# word character to assert against masks that literal, and is identical to
|
|
207
|
+
# `\b` for every literal that does not.
|
|
208
|
+
#
|
|
209
|
+
# Written as lookarounds over {W} rather than `\b` because Ruby's `\b` is
|
|
210
|
+
# ASCII-only where Python's is Unicode-aware; these agree with Python for
|
|
211
|
+
# an accented name.
|
|
212
|
+
def literal_boundaries(literal)
|
|
213
|
+
word = /\A[#{W}]\z/
|
|
214
|
+
[
|
|
215
|
+
literal[0].to_s.match?(word) ? "(?<![#{W}])" : "",
|
|
216
|
+
literal[-1].to_s.match?(word) ? "(?![#{W}])" : "",
|
|
217
|
+
]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Case-insensitive whole-token match for a literal, possessive-tolerant.
|
|
221
|
+
#
|
|
222
|
+
# A bare boundary mis-handles a trailing apostrophe-s, which is exactly how
|
|
223
|
+
# a name appears in student prose ("Sarah's essay"), so the possessive is
|
|
224
|
+
# part of the match and gets masked with the name.
|
|
225
|
+
def word_pattern(literal)
|
|
226
|
+
lead, trail = literal_boundaries(literal)
|
|
227
|
+
Regexp.new("#{lead}#{Regexp.escape(literal)}#{POSSESSIVE_TAIL}#{trail}", Regexp::IGNORECASE)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# `"Lincoln High School"` => `"LHS"`. Nil when it would be too short.
|
|
231
|
+
#
|
|
232
|
+
# Students write the acronym far more often than the full name, and a
|
|
233
|
+
# two-letter acronym collides with ordinary words and state codes.
|
|
234
|
+
def school_acronym(name)
|
|
235
|
+
acronym = name.scan(/[A-Za-z][\p{L}\p{N}_'-]*/).map { |word| word[0] }.join.upcase
|
|
236
|
+
acronym.length >= 3 ? acronym : nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Patterns masking this student's own identifying strings.
|
|
240
|
+
#
|
|
241
|
+
# Ordered most-specific-first: the full name is matched before either part
|
|
242
|
+
# of it, so "Jane Quincy-Adams" becomes one `{NAME}` rather than two
|
|
243
|
+
# adjacent placeholders.
|
|
244
|
+
def identity_patterns(identity)
|
|
245
|
+
out = []
|
|
246
|
+
first = identity_field(identity, :first_name)
|
|
247
|
+
last = identity_field(identity, :last_name)
|
|
248
|
+
school = identity_field(identity, :school_name)
|
|
249
|
+
|
|
250
|
+
if !first.empty? && !last.empty?
|
|
251
|
+
out << ["NAME", word_pattern("#{first} #{last}")]
|
|
252
|
+
# "Adams, Jane" — the roster/header order.
|
|
253
|
+
out << ["NAME", word_pattern("#{last}, #{first}")]
|
|
254
|
+
end
|
|
255
|
+
out << ["NAME", word_pattern(last)] if !last.empty? && !AMBIGUOUS_SURNAMES.include?(last.downcase)
|
|
256
|
+
out << ["NAME", word_pattern(first)] if !first.empty? && !AMBIGUOUS_GIVEN_NAMES.include?(first.downcase)
|
|
257
|
+
|
|
258
|
+
extra_names(identity).each do |raw|
|
|
259
|
+
extra = raw.to_s.strip
|
|
260
|
+
out << ["NAME", word_pattern(extra)] unless extra.empty?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
unless school.empty?
|
|
264
|
+
out << ["SCHOOL", word_pattern(school)]
|
|
265
|
+
acronym = school_acronym(school)
|
|
266
|
+
unless acronym.nil?
|
|
267
|
+
# Case-SENSITIVE for the acronym: lowercasing it would match ordinary
|
|
268
|
+
# words (three-letter acronyms shaped like "was"/"his" are a real
|
|
269
|
+
# hazard).
|
|
270
|
+
out << ["SCHOOL", Regexp.new("\\b#{Regexp.escape(acronym)}\\b")]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
out
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Mask identity and structured spans, minting through the caller's minter.
|
|
277
|
+
#
|
|
278
|
+
# The minter is passed in rather than created here because it must serve
|
|
279
|
+
# the whole document: candidate generation numbers into the same counters,
|
|
280
|
+
# and a second minter would emit `{NAME_1}` for two different people.
|
|
281
|
+
#
|
|
282
|
+
# Returns `[masked_text, n_masked]`.
|
|
283
|
+
def mask(text, identity, minter)
|
|
284
|
+
return [text, 0] if text.nil? || text.empty?
|
|
285
|
+
|
|
286
|
+
masked = text
|
|
287
|
+
n = 0
|
|
288
|
+
|
|
289
|
+
# Identity patterns run FIRST: a name is the span most likely to be
|
|
290
|
+
# partially consumed by a looser pattern (an address line can swallow a
|
|
291
|
+
# surname), and masking it first makes that impossible.
|
|
292
|
+
(identity_patterns(identity) + STRUCTURED).each do |kind, pattern|
|
|
293
|
+
masked, count = minter.substitute(kind, pattern, masked)
|
|
294
|
+
n += count
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Cards need the Luhn gate, so they can't go through a plain
|
|
298
|
+
# substitution.
|
|
299
|
+
masked = masked.gsub(CARD_CANDIDATE) do |match|
|
|
300
|
+
digits = match.gsub(/\D/, "")
|
|
301
|
+
if luhn_ok?(digits)
|
|
302
|
+
n += 1
|
|
303
|
+
minter.mint("CREDIT_DEBIT_CARD_NUMBER", match)
|
|
304
|
+
else
|
|
305
|
+
match
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
masked, zip_count = minter.substitute("ZIP_CODE", ZIP, masked)
|
|
310
|
+
n += zip_count
|
|
311
|
+
|
|
312
|
+
masked = masked.gsub(AGE) do |match|
|
|
313
|
+
n += 1
|
|
314
|
+
# Only the digits are the age; the surrounding "I am … years old" is
|
|
315
|
+
# the student's prose and has to survive, so this mints against the
|
|
316
|
+
# digit run rather than the whole match.
|
|
317
|
+
digits = /\d{1,2}/.match(match)
|
|
318
|
+
if digits.nil?
|
|
319
|
+
match
|
|
320
|
+
else
|
|
321
|
+
"#{match[0, digits.begin(0)]}#{minter.mint('AGE', digits[0])}#{match[(digits.begin(0) + digits[0].length)..]}"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
[masked, n]
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def identity_field(identity, name)
|
|
331
|
+
return "" unless identity.respond_to?(name)
|
|
332
|
+
|
|
333
|
+
identity.public_send(name).to_s.strip
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def extra_names(identity)
|
|
337
|
+
return [] unless identity.respond_to?(:extra_names)
|
|
338
|
+
|
|
339
|
+
Array(identity.extra_names)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vicary
|
|
4
|
+
# Single source of this package's version.
|
|
5
|
+
#
|
|
6
|
+
# Shared across all three front doors on purpose: one detector, one number. A
|
|
7
|
+
# gem 0.3.0 that corresponds to nothing on PyPI cannot be reasoned about, and
|
|
8
|
+
# the parity claim is between *versions*, not between package names.
|
|
9
|
+
VERSION = "0.2.0"
|
|
10
|
+
end
|
data/lib/vicary.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# vicary — offline redaction of personal names in student compositions.
|
|
4
|
+
#
|
|
5
|
+
# The RubyGems front door. {Vicary.redact} is the one call most hosts want: hand
|
|
6
|
+
# it a composition and the student's own identity, get the masked text back.
|
|
7
|
+
# {Vicary.redact_with_report} returns the same bytes plus the map
|
|
8
|
+
# {Vicary.restore} needs to put the originals back.
|
|
9
|
+
#
|
|
10
|
+
# **What the surface is claiming.** `redact` does the deciding now, and it
|
|
11
|
+
# reproduces all 52 fixture frames byte-for-byte against the Python reference,
|
|
12
|
+
# placeholder numbering included — the arm being `local-gazetteer-lowercase`. It
|
|
13
|
+
# raised {Vicary::NotPortedError} before that rather than returning the text
|
|
14
|
+
# unchanged, because a partially ported redactor is a reasonable thing to measure
|
|
15
|
+
# and an unreasonable thing to hand a host: it would mask a phone number, miss
|
|
16
|
+
# every name in the essay, and give the caller no way to tell.
|
|
17
|
+
#
|
|
18
|
+
# Three layers check that claim, and each catches what the one above it cannot:
|
|
19
|
+
#
|
|
20
|
+
# * `rake conformance` scores the 52 frames — the final bar, and a coarse first
|
|
21
|
+
# one;
|
|
22
|
+
# * `rake test` runs `test/primitives_test.rb`, forty-odd primitives over the
|
|
23
|
+
# shared `primitives.json` corpus, which says *which brick is crooked*;
|
|
24
|
+
# * `rake redaction_parity` runs both implementations over prose neither corpus
|
|
25
|
+
# contains and diffs the bytes, because several rules only diverge across a
|
|
26
|
+
# newline and both corpora are single-line.
|
|
27
|
+
#
|
|
28
|
+
# The scoreboard prints the real count on every run precisely so readiness is
|
|
29
|
+
# never somebody's recollection.
|
|
30
|
+
module Vicary
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
require_relative "vicary/version"
|
|
34
|
+
require_relative "vicary/asset"
|
|
35
|
+
require_relative "vicary/lexicon"
|
|
36
|
+
require_relative "vicary/gazetteer"
|
|
37
|
+
require_relative "vicary/minter"
|
|
38
|
+
require_relative "vicary/structured"
|
|
39
|
+
require_relative "vicary/candidates"
|
|
40
|
+
require_relative "vicary/conformance"
|
|
41
|
+
require_relative "vicary/redact"
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: vicary
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Blake Thomas
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-11 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Finds the names a student writes about — classmates, teachers, relatives — and
|
|
15
|
+
replaces them with numbered placeholders a later pass can restore, while
|
|
16
|
+
leaving the public figures they are writing about alone. No model, no network,
|
|
17
|
+
no per-request cost: a folded gazetteer and a candidate generator.
|
|
18
|
+
email:
|
|
19
|
+
- bwthomas@gmail.com
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- LICENSE
|
|
25
|
+
- README.md
|
|
26
|
+
- assets/MANIFEST.json
|
|
27
|
+
- assets/notability.txt.gz
|
|
28
|
+
- assets/stop_words.txt
|
|
29
|
+
- lib/vicary.rb
|
|
30
|
+
- lib/vicary/asset.rb
|
|
31
|
+
- lib/vicary/candidates.rb
|
|
32
|
+
- lib/vicary/conformance.rb
|
|
33
|
+
- lib/vicary/gazetteer.rb
|
|
34
|
+
- lib/vicary/lexicon.rb
|
|
35
|
+
- lib/vicary/minter.rb
|
|
36
|
+
- lib/vicary/redact.rb
|
|
37
|
+
- lib/vicary/structured.rb
|
|
38
|
+
- lib/vicary/version.rb
|
|
39
|
+
homepage: https://github.com/bwthomas/vicary
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
homepage_uri: https://github.com/bwthomas/vicary
|
|
44
|
+
source_code_uri: https://github.com/bwthomas/vicary
|
|
45
|
+
changelog_uri: https://github.com/bwthomas/vicary/blob/main/CHANGELOG.md
|
|
46
|
+
bug_tracker_uri: https://github.com/bwthomas/vicary/issues
|
|
47
|
+
post_install_message:
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.1'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.5.22
|
|
63
|
+
signing_key:
|
|
64
|
+
specification_version: 4
|
|
65
|
+
summary: Offline redaction of personal names in student compositions
|
|
66
|
+
test_files: []
|