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,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "set"
5
+
6
+ module Vicary
7
+ # Read a language-neutral word list that ships beside the gazetteer.
8
+ #
9
+ # The stoplist used to be a literal in Python's `name_candidates.py`, which was
10
+ # fine while there was one front door. With three, a hand-transliterated word
11
+ # list is a second detector wearing the first one's name: the divergence shows
12
+ # up as prose corruption in one language and not the others, and no parity
13
+ # check on *masked output* would catch it, because a stop word going missing
14
+ # changes what gets masked in essays nobody put in a fixture.
15
+ #
16
+ # So the list is authored once, language-neutrally, under `asset/lexicon/` in
17
+ # the repository, and vendored into each package the same way the gazetteer is.
18
+ # This is the reader; it owns nothing.
19
+ #
20
+ # Format
21
+ # ------
22
+ # `#!` lines are directives, `#` lines are comments, blank lines are skipped,
23
+ # and every other line contributes whitespace-separated words. One directive is
24
+ # required:
25
+ #
26
+ # #!lexicon 1 format version
27
+ # #!list <name> <count> the list's name, and its DISTINCT word count
28
+ #
29
+ # The count is asserted, not trusted. A short read here makes the redactor
30
+ # **more** aggressive — fewer stop words means more capitalised ordinary words
31
+ # become name candidates — which looks privacy-safe, corrupts prose, and passes
32
+ # any check that only asks whether something was masked. Same reasoning as the
33
+ # gazetteer's per-tier counts; same failure mode if it is skipped.
34
+ #
35
+ # This is the fourth reader of this format, after `asset/vicary_build/lexicon.py`,
36
+ # `python/src/vicary/lexicon.py` and `typescript/src/lexicon.ts`. The duplication
37
+ # is deliberate — the build tool must not import one of the implementations it
38
+ # feeds — and it is only honest because something compares the results.
39
+ # `test/lexicon_test.rb` pins this reader's output against the count and the
40
+ # spot-checks the other three are pinned against.
41
+ module Lexicon
42
+ # On-disk format version for a lexicon file. Bump when the parse changes, so
43
+ # a stale vendored copy fails loudly rather than parsing to a different list.
44
+ LEXICON_FORMAT = 1
45
+
46
+ # Filename suffix. Named so a second list costs a file rather than a refactor.
47
+ SUFFIX = ".txt"
48
+
49
+ # A lexicon is absent, unreadable, or not the shape this reader understands.
50
+ #
51
+ # Its own class rather than a bare RuntimeError for the same reason
52
+ # {Asset::FormatError} has one: a caller that wants to tell "the install is
53
+ # incomplete" apart from "something else raised" cannot do it on a message.
54
+ class LexiconError < StandardError; end
55
+
56
+ class << self
57
+ # Where the vendored copy of +name+ lives, whether or not it exists.
58
+ #
59
+ # Searched along the same path as the gazetteer, in the same order, so a
60
+ # package cannot end up reading its stoplist from one cut and its
61
+ # gazetteer from another.
62
+ def path(name)
63
+ filename = "#{name}#{SUFFIX}"
64
+ tried = Asset.search_path
65
+ found = tried.find { |dir| dir.join(filename).file? }
66
+ return found.join(filename) if found
67
+
68
+ # Return the first location rather than raising, so `load` reports the
69
+ # same "missing" error whether the directory is absent or the file
70
+ # inside it is.
71
+ (tried.first || Pathname.new(".")).join(filename)
72
+ end
73
+
74
+ # Parse lexicon text into its case-folded distinct words.
75
+ #
76
+ # Public so a test can feed it a deliberately malformed document. A parser
77
+ # reachable only through a vendored file on disk is a parser whose failure
78
+ # paths are never exercised — and every one of this parser's failure paths
79
+ # exists to turn a silent short read into a loud one.
80
+ #
81
+ # @param where [String] a path, used only in error messages, so a failure
82
+ # names a file.
83
+ def parse(name, text, where)
84
+ declared = nil
85
+ saw_format = false
86
+ words = Set.new
87
+
88
+ text.split(/\r?\n/, -1).each_with_index do |line, index|
89
+ lineno = index + 1
90
+ stripped = line.strip
91
+
92
+ if stripped.start_with?("#!")
93
+ parts = stripped[2..].to_s.split(/\s+/).reject(&:empty?)
94
+ raise LexiconError, "#{where}:#{lineno}: empty directive" if parts.empty?
95
+
96
+ case parts[0]
97
+ when "lexicon"
98
+ saw_format = true
99
+ unless parts.length == 2 && parts[1] == LEXICON_FORMAT.to_s
100
+ raise LexiconError,
101
+ "#{where}:#{lineno}: lexicon format " \
102
+ "#{parts[1..].join(' ').inspect}, this build reads " \
103
+ "#{LEXICON_FORMAT}"
104
+ end
105
+ when "list"
106
+ unless parts.length == 3 && parts[1] == name
107
+ raise LexiconError,
108
+ "#{where}:#{lineno}: expected `\#!list #{name} <count>`, " \
109
+ "got #{stripped.inspect}"
110
+ end
111
+ # `to_i` would read "3x" as 3, where Python's `int()` raises. The
112
+ # difference is the whole guard: a count this reader silently
113
+ # repaired is a count that no longer proves anything about the parse.
114
+ unless /\A\d+\z/.match?(parts[2])
115
+ raise LexiconError,
116
+ "#{where}:#{lineno}: count #{parts[2].inspect} is not an integer"
117
+ end
118
+
119
+ declared = parts[2].to_i
120
+ else
121
+ # Refused rather than ignored: an unrecognised directive means the
122
+ # file was written by something that knows more than this reader,
123
+ # and guessing which lines are still words is how a partial list
124
+ # loads as a whole one.
125
+ raise LexiconError, "#{where}:#{lineno}: unknown directive #{parts[0].inspect}"
126
+ end
127
+ next
128
+ end
129
+
130
+ next if stripped.empty? || stripped.start_with?("#")
131
+
132
+ stripped.split(/\s+/).each do |word|
133
+ words << word.downcase unless word.empty?
134
+ end
135
+ end
136
+
137
+ unless saw_format
138
+ raise LexiconError, "#{where}: no `\#!lexicon` directive; not a lexicon file"
139
+ end
140
+ raise LexiconError, "#{where}: no `\#!list #{name} <count>` directive" if declared.nil?
141
+
142
+ unless words.size == declared
143
+ raise LexiconError,
144
+ "#{where}: declares #{declared} distinct words, parsed " \
145
+ "#{words.size}. A short read makes the redactor more aggressive, " \
146
+ "which is why this is an error and not a warning."
147
+ end
148
+
149
+ words
150
+ end
151
+
152
+ # The case-folded distinct words of lexicon +name+.
153
+ #
154
+ # Raises {LexiconError} rather than returning a partial list. An empty or
155
+ # truncated stoplist is the quiet failure this whole module exists to
156
+ # prevent.
157
+ def load(name, path: nil)
158
+ location = Pathname.new(path || self.path(name))
159
+ begin
160
+ text = location.read(encoding: "UTF-8")
161
+ rescue Errno::ENOENT
162
+ raise LexiconError,
163
+ "lexicon #{name.inspect} missing at #{location}. The installed " \
164
+ "vicary gem is incomplete — reinstall it, or vendor the asset " \
165
+ "with `rake sync_assets` from a checkout."
166
+ rescue SystemCallError => e
167
+ raise LexiconError, "cannot read lexicon at #{location}: #{e.message}"
168
+ end
169
+
170
+ parse(name, text, location.to_s)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vicary
4
+ # Hands out `{KIND_n}` placeholders, stable per distinct original.
5
+ #
6
+ # The Ruby port of `python/src/vicary/redaction.py`'s minter.
7
+ #
8
+ # Why numbering, stated as a measurement rather than a preference: a bare
9
+ # `{NAME}` standing for every person in a document is **not reversible**. On 25
10
+ # injected essays the unnumbered masker produced 37 not-restorable violations
11
+ # and only 36% of essays round-tripped — one token meant `Marisol` in one
12
+ # paragraph and `Terrence Okonkwo` in the next, so no map keyed on the token
13
+ # can put either back.
14
+ #
15
+ # Two properties, and the second is the one that needs care:
16
+ #
17
+ # * **Injective** — distinct originals never share a placeholder, which is what
18
+ # makes restore well-defined.
19
+ # * **Stable within a document** — the *same* original always gets the same
20
+ # index, so a name written five times masks to one placeholder rather than
21
+ # five. That matters beyond restorability: a scoring model reading
22
+ # `{NAME_1} argued … {NAME_1} concluded` can still see one person doing two
23
+ # things, where `{NAME_1} … {NAME_5}` reads as two strangers.
24
+ #
25
+ # Keyed on the exact original text, because restore must return the exact
26
+ # bytes. `Terrence` and `Terrence's` are therefore different keys — correct but
27
+ # unsatisfying, and the reason surname-folding does NOT belong here: folding
28
+ # them together would make the mapping non-injective again.
29
+ #
30
+ # **Indices follow mint order, which is discovery order, not position in the
31
+ # text.** One minter serves the whole document precisely so that holds;
32
+ # per-pass minters would restart each counter and emit `{NAME_1}` twice for two
33
+ # different people, which is the bug numbering exists to remove.
34
+ class PlaceholderMinter
35
+ # Off reproduces the unnumbered output byte for byte, so the two arms stay
36
+ # separately measurable.
37
+ attr_reader :number
38
+
39
+ def initialize(number: true)
40
+ @number = number
41
+ # `[kind, original]` -> index. A tuple key rather than the joined string
42
+ # the other two ports use: Ruby hashes take array keys directly, so there
43
+ # is no separator to pick and no way for a name containing one to collide.
44
+ # Insertion-ordered, which is what makes {#assigned} come back in discovery
45
+ # order.
46
+ @assigned = {}
47
+ @high = Hash.new(0)
48
+ end
49
+
50
+ # The placeholder `original` should be replaced by.
51
+ def mint(kind, original)
52
+ return "{#{kind}}" unless @number
53
+
54
+ key = [kind, original]
55
+ index = @assigned[key]
56
+ if index.nil?
57
+ index = @high[kind] + 1
58
+ @high[kind] = index
59
+ @assigned[key] = index
60
+ end
61
+ "{#{kind}_#{index}}"
62
+ end
63
+
64
+ # Replace every match with a minted placeholder; returns the text and count.
65
+ def substitute(kind, pattern, text)
66
+ count = 0
67
+ replaced = text.gsub(pattern) do |match|
68
+ count += 1
69
+ mint(kind, match)
70
+ end
71
+ [replaced, count]
72
+ end
73
+
74
+ # `{placeholder => original}` — the restore map, for free.
75
+ #
76
+ # Insertion-ordered, so the map reads in the order the document discovered
77
+ # each span rather than in the order the placeholders sort.
78
+ def assigned
79
+ @assigned.keys.to_h { |kind, original| [mint(kind, original), original] }
80
+ end
81
+ end
82
+
83
+ # Put the originals back.
84
+ #
85
+ # Longest placeholder first, so `{NAME_1}` cannot be partially consumed while
86
+ # `{NAME_11}` is still pending.
87
+ def self.restore(text, map)
88
+ map.keys.sort_by { |k| -k.length }.reduce(text) do |out, placeholder|
89
+ # Block form, not the two-argument one: a replacement *string* interprets
90
+ # `\1`, `\&` and `\\`, so a restored name containing a backslash would come
91
+ # back altered. The block returns the original bytes untouched.
92
+ out.gsub(placeholder) { map[placeholder] }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Vicary
6
+ # Retained, and no longer raised by anything.
7
+ #
8
+ # The reason it existed still holds: the alternative to an error is a silent
9
+ # no-op redactor, which satisfies every caller and redacts nothing. Kept so a
10
+ # future incomplete arm can raise it rather than inventing a new type.
11
+ class NotPortedError < StandardError; end
12
+
13
+ # Whether this build detects third-party names.
14
+ #
15
+ # Exported so a host can assert on it rather than infer it from a version
16
+ # number. True since candidate generation landed; it is still meaningful,
17
+ # because {NAMES_IDENTITY} turns the whole route back off at runtime.
18
+ DETECTS_NAMES = true
19
+
20
+ # Only the identity the caller handed over, plus the structured entities. No
21
+ # gazetteer is loaded and no candidate is generated — 0% recall on third-party
22
+ # names, which is a defensible choice only when it is a chosen one.
23
+ NAMES_IDENTITY = "identity"
24
+ # Generation plus the offline notability oracle: the shippable arm.
25
+ NAMES_GAZETTEER = "gazetteer"
26
+ # ...and the lowercase route, the only one that reaches a student who writes
27
+ # without capitals.
28
+ NAMES_LOWERCASE = "gazetteer-lowercase"
29
+
30
+ # The default, matching the reference. Recall is what to buy inbound.
31
+ DEFAULT_NAME_DETECTION = NAMES_LOWERCASE
32
+
33
+ NAME_DETECTION_ENV_VAR = "VICARY_NAME_DETECTION"
34
+
35
+ IDENTITY_ALIASES = Set.new(%w[identity off none 0 false no]).freeze
36
+ GAZETTEER_ALIASES = Set.new(%w[gazetteer on 1 true yes names]).freeze
37
+ LOWERCASE_ALIASES = Set.new(%w[gazetteer-lowercase gazetteer_lowercase lowercase full max]).freeze
38
+
39
+ # The redaction entry point — the whole detector, wired end to end.
40
+ #
41
+ # Two passes over one document, in this order and for this reason:
42
+ #
43
+ # 1. **The identity and structured pass** ({Vicary::Structured}) — the
44
+ # student's own name, school and school acronym, then every syntactic
45
+ # entity: email, URL, SSN, IP, phone, street address, date of birth,
46
+ # `@handle`, payment card behind a Luhn gate, ZIP and age. These are exact
47
+ # patterns, and they run first so that no looser match can consume part of
48
+ # one.
49
+ # 2. **Candidate generation** ({Vicary::Candidates}) — the third-party names
50
+ # nothing hands over: the classmate, the teacher, the relative, the
51
+ # neighbour. High recall by construction, filtered by the offline notability
52
+ # oracle so the public figures a student writes *about* survive.
53
+ #
54
+ # Generation runs LAST, for the same reason it does in the reference: a broad
55
+ # capitalised-word match run early would swallow the first token of an address
56
+ # or the local part of an email, and a name half-eaten by another pattern leaks
57
+ # the remainder.
58
+ #
59
+ # **One minter for the whole document.** Placeholder indices follow mint order
60
+ # across both passes, so `{NAME_1}` means one person from the first line to the
61
+ # last. Two minters would restart each counter and hand the same token to two
62
+ # different people, which is the defect numbering exists to remove.
63
+ #
64
+ # The arm this reproduces is `local-gazetteer-lowercase` — generation, plus the
65
+ # gazetteer notability oracle, plus the lowercase route. That is the arm the
66
+ # conformance golden was produced by, and a port comparing against those bytes
67
+ # while implementing a different arm is measuring two changes at once.
68
+ class << self
69
+ # Resolve how hard the detector looks for names it was not handed.
70
+ #
71
+ # Explicit argument, then `VICARY_NAME_DETECTION`, then the code default.
72
+ #
73
+ # An unrecognized non-empty value resolves to the **default**, not to
74
+ # `identity`. Dropping silently to `identity` would leave redaction on and
75
+ # reporting spans while finding none of the names a reader would call PII — a
76
+ # failure that looks exactly like success from every log line and metric.
77
+ def name_detection(value = nil)
78
+ raw = (value || ENV[NAME_DETECTION_ENV_VAR] || "").strip.downcase
79
+ return NAMES_IDENTITY if !raw.empty? && IDENTITY_ALIASES.include?(raw)
80
+ return NAMES_GAZETTEER if GAZETTEER_ALIASES.include?(raw)
81
+ return NAMES_LOWERCASE if LOWERCASE_ALIASES.include?(raw)
82
+
83
+ DEFAULT_NAME_DETECTION
84
+ end
85
+
86
+ # Wire the bundled gazetteer into a detection level.
87
+ #
88
+ # Generation and the oracle are ONE decision, not two: generation alone masks
89
+ # every public figure a student writes about, and the oracle alone has
90
+ # nothing to judge. There is deliberately no supported way to ask for half of
91
+ # it.
92
+ #
93
+ # At {NAMES_IDENTITY} this returns nothing and the 2.1 MB asset is never
94
+ # touched. At the other two levels the first lookup pays the decompression;
95
+ # call `Vicary::Gazetteer.load` at process start to move that off the first
96
+ # request.
97
+ def gazetteer_oracles(level)
98
+ return { candidates: false } if level == NAMES_IDENTITY
99
+
100
+ oracles = {
101
+ candidates: true,
102
+ notable: ->(name) { Gazetteer.notable?(name) },
103
+ notability_tier: ->(name) { Gazetteer.notability(name) },
104
+ title: ->(name) { Gazetteer.title?(name) },
105
+ title_prefix: ->(key) { Gazetteer.title_prefix?(key) },
106
+ # Wired at BOTH gazetteer levels, unlike `given_name` below. This one
107
+ # decides a placeholder's type, not a verdict, so it has nothing to do
108
+ # with which candidate routes are on.
109
+ settlement: ->(name) { Gazetteer.settlement?(name) },
110
+ }
111
+ # The one difference between the two gazetteer levels. Absent rather than
112
+ # nil, so `gazetteer` and `gazetteer-lowercase` differ by the presence of a
113
+ # key rather than by a value the merge would have to strip.
114
+ oracles[:given_name] = ->(token) { Gazetteer.common_given_name?(token) } if level == NAMES_LOWERCASE
115
+ oracles
116
+ end
117
+
118
+ # Redact `text`, returning the masked bytes and everything needed to undo it.
119
+ #
120
+ # One minter for the whole document, because placeholder indices follow mint
121
+ # order across every pass.
122
+ #
123
+ # Returns `[masked_text, n_masked, restore_map]`.
124
+ def redact_with_report(text, identity, options = {})
125
+ names = options[:names]
126
+ keep = options[:keep] || Set.new
127
+ number_placeholders = options.fetch(:number_placeholders, true)
128
+ headings_are_orthographic = options.fetch(:headings_are_orthographic, true)
129
+ corroborate = options.fetch(:corroborate, true)
130
+ relation_refusal = options.fetch(:relation_refusal, true)
131
+ title_relation_refusal = options.fetch(:title_relation_refusal, true)
132
+
133
+ minter = PlaceholderMinter.new(number: number_placeholders)
134
+ return [text, 0, minter.assigned] if text.nil? || text.empty?
135
+
136
+ masked, n = Structured.mask(text, identity, minter)
137
+
138
+ # Candidate generation runs LAST, so every exact pattern has already
139
+ # claimed its span.
140
+ oracles = gazetteer_oracles(name_detection(names))
141
+ if oracles.delete(:candidates)
142
+ masked, count = Candidates.mask_candidates(
143
+ masked,
144
+ oracles.merge(
145
+ keep: keep,
146
+ corroborate: corroborate,
147
+ minter: minter,
148
+ headings_are_orthographic: headings_are_orthographic,
149
+ relation_refusal: relation_refusal,
150
+ title_relation_refusal: title_relation_refusal,
151
+ ),
152
+ )
153
+ n += count
154
+ end
155
+
156
+ [masked, n, minter.assigned]
157
+ end
158
+
159
+ # Redact personal names and structured PII from `text`.
160
+ #
161
+ # @param text [String] the composition to redact.
162
+ # @param identity [Object] the student the detector is told about — anything
163
+ # answering `first_name`, `last_name` and `school_name`. Every reference arm
164
+ # interpolates these strings, so a caller that omits them is measuring a
165
+ # different system and misses the easiest spans in the fixture.
166
+ # @param options [Hash] the defaults are the reference arm; every flag exists
167
+ # so its arm stays separately measurable.
168
+ def redact(text, identity, options = {})
169
+ redact_with_report(text, identity, options)[0]
170
+ end
171
+ end
172
+ end