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,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: []