vicary 0.2.15 → 0.2.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9a6c30ad9946f8263d26b94e52b3d5d05db3d2ac0629425d992d5cebb1a7981
4
- data.tar.gz: 8102ad087d3e7d1e3e4ce63a459de3d8598efb96f584e6c5ba02c815ac663611
3
+ metadata.gz: 4e78fbd85fa4dde2e481bbf8b5349ceda35e4ab98fbf9ad0b336761be3f6379d
4
+ data.tar.gz: b9ad9cd2ebeaffa8b438c4b051d378edb47cfc86f1117cce68732f6cd3f2cdd3
5
5
  SHA512:
6
- metadata.gz: 1956a3e47dc351c62ac4a58266ed48fbf936745ed6d6cf72b2abfc35df0f50b19ae2b4f603b80248e330153c866312ade9017777cbdf0f46c211ab2abeccb1fb
7
- data.tar.gz: 42fa8950725aa8c58efa07462aec67843e671eef013c211a9025655df09970ddba05c54d92c524a3f244241d8bc1d147ee8acea9bd62f01b6b3e1c6d5eed8e15
6
+ metadata.gz: 9ef2a213c75f9cf539620a393b20e7f2521173fb14a2bfbc2d6753654a8a19f19875b143a6b4240cc4ce09b01667cccc4d1da8d2e4c52d774bc9678c52eb13cb
7
+ data.tar.gz: 7102716c3687292aff85bc8191e0fbbe65ef457930f9fcce2c5b78e58ed3134a8e69d5b07f728a09438f0aeda84f97753a82419e91478ebd401ba99df5c2dd47
data/README.md CHANGED
@@ -45,6 +45,19 @@ replaced. A map that is absent, or that covers only some of the placeholders in
45
45
  the text, yields **no** spans and translates as the identity — a caller can
46
46
  handle "no spans" and cannot detect a wrong offset.
47
47
 
48
+ Before a reader sees it, check that no placeholder the host did not restore
49
+ survived — echoed by a model, or paraphrased as `[NAME_1]`, `{Name_1}` or a bare
50
+ `NAME_1`:
51
+
52
+ ```ruby
53
+ show(Vicary.assert_no_placeholders(feedback)) # raises PlaceholderLeakError
54
+ show(Vicary.scrub_placeholders(feedback).text) # or drop each sentence carrying one
55
+ ```
56
+
57
+ The error names the kinds and the count, never the text.
58
+ `Vicary.find_placeholders` and `Vicary.has_placeholders?` answer without raising;
59
+ `scrub_placeholders(text, quote: true)` elides instead, for a verbatim quote.
60
+
48
61
  ## Checking it
49
62
 
50
63
  Three layers, because each catches what the one above it cannot.
@@ -181,6 +181,23 @@ module Vicary
181
181
  raw
182
182
  end
183
183
 
184
+ # The placeholder guard's contract: the pattern source, the cases and the
185
+ # reference's answers. Absent is an error, as for `spans.json`: a guard
186
+ # checked against nothing reports the same green as one that agrees.
187
+ def load_placeholders(dir = nil)
188
+ dir = Pathname.new(dir || directory)
189
+ path = dir.join("placeholders.json")
190
+ unless path.file?
191
+ raise SpecError,
192
+ "no placeholders.json at #{path}. The ports would check their " \
193
+ "placeholder guard against nothing."
194
+ end
195
+
196
+ raw = JSON.parse(path.read)
197
+ require_version(raw["document_version"], "placeholders.json")
198
+ raw
199
+ end
200
+
184
201
  # Score an implementation against every frame.
185
202
  #
186
203
  # The block receives (sentence, identity) — the same input every Python arm
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vicary
4
+ # Text bound for a reader still carries a redaction placeholder.
5
+ #
6
+ # Names the kinds and the count, never the text around them: the message is
7
+ # going to a log, and the text is exactly what was not supposed to leave.
8
+ class PlaceholderLeakError < ArgumentError
9
+ attr_reader :kinds, :count
10
+
11
+ def initialize(kinds, count)
12
+ @kinds = kinds.freeze
13
+ @count = count
14
+ noun = count == 1 ? "placeholder" : "placeholders"
15
+ super("#{count} redaction #{noun} would reach a reader " \
16
+ "(kinds: #{kinds.join(', ')})")
17
+ end
18
+ end
19
+
20
+ # The host boundary: assert that no placeholder reaches a reader — the Ruby
21
+ # port of `python/src/vicary/placeholders.py`.
22
+ #
23
+ # vicary masks what it redacts as typed placeholders (`{NAME_1}`,
24
+ # `{LOCATION_2}`, `{EMAIL}`) and a host restores the ones a reader may see.
25
+ # Everything else must never be displayed, and two ways one gets out anyway
26
+ # are invisible to the masker: a model echoes a placeholder the host's restore
27
+ # map does not cover, or paraphrases one — `[NAME_1]`, `{Name_1}`,
28
+ # `{ NAME_1 }`, a bare `NAME_1`.
29
+ #
30
+ # {assert_no_placeholders} raises before the text is shown;
31
+ # {scrub_placeholders} removes them (a prose sentence carrying one is dropped,
32
+ # a quote elides it with `…`). Nothing is rewritten back into a word here.
33
+ #
34
+ # The pattern source is byte-identical in all three front doors, written in
35
+ # the dialect the engines share — ASCII classes rather than `\b`/`\w`/`\s`,
36
+ # and `\A`/`\z` rather than `^`/`$`, which anchor per line in Ruby.
37
+ # `test/placeholders_test.rb` compares it with `conformance/placeholders.json`.
38
+ module Placeholders
39
+ KINDS = %w[
40
+ ADDRESS AGE AWS_ACCESS_KEY AWS_SECRET_KEY
41
+ CREDIT_DEBIT_CARD_CVV CREDIT_DEBIT_CARD_EXPIRY
42
+ CREDIT_DEBIT_CARD_NUMBER DATE_OF_BIRTH DRIVER_ID EMAIL
43
+ IP_ADDRESS LICENSE_PLATE LOCATION MAC_ADDRESS NAME
44
+ ORGANIZATION PASSWORD PERSON PHONE PIN SCHOOL URL
45
+ US_BANK_ACCOUNT_NUMBER US_BANK_ROUTING_NUMBER US_PASSPORT_NUMBER
46
+ US_SOCIAL_SECURITY_NUMBER USERNAME ZIP_CODE
47
+ ].freeze
48
+ private_constant :KINDS
49
+
50
+ # Every placeholder kind vicary or its Guardrail arm can mint, plus
51
+ # `PERSON`, which is what a model writes when it paraphrases `NAME`.
52
+ # Longest first, so a match reports the whole kind.
53
+ PLACEHOLDER_KINDS = KINDS.sort_by { |kind| [-kind.length, kind] }.freeze
54
+
55
+ # The kind reported for a `{UPPER_CASE}` token that names no known kind.
56
+ OTHER_KIND = "OTHER"
57
+
58
+ WORD = "[A-Za-z0-9_]"
59
+ private_constant :WORD
60
+
61
+ # `NAME` -> `[Nn][Aa][Mm][Ee]`: case-insensitive in every dialect.
62
+ def self.fold(kind)
63
+ kind.chars.map { |c| c.match?(/[A-Z]/) ? "[#{c}#{c.downcase}]" : c }.join
64
+ end
65
+ private_class_method :fold
66
+
67
+ # Anything placeholder-shaped: vicary's own `{KIND}` / `{KIND_n}` for any
68
+ # upper-case kind; a known kind in any case inside `{}`, `[]` or `<>` with
69
+ # stray spaces or tabs; or a bare upper-case `KIND_n` as a whole word.
70
+ PLACEHOLDER_LIKE = Regexp.new(
71
+ "\\{[A-Z][A-Z_]*(?:_[0-9]+)?\\}" \
72
+ "|[{\\[<][ \\t]*(?:#{PLACEHOLDER_KINDS.map { |k| fold(k) }.join('|')})" \
73
+ "(?:_[0-9]+)?[ \\t]*[}\\]>]" \
74
+ "|(?<!#{WORD})(?:#{PLACEHOLDER_KINDS.join('|')})_[0-9]+(?!#{WORD})"
75
+ )
76
+
77
+ # A sentence ends at `.`, `!` or `?` followed by whitespace — but not after
78
+ # an honorific, an initial, `e.g.`, `i.e.` or `vs.`.
79
+ SENTENCE_END = Regexp.new(
80
+ "(?<![A-Z][a-z]\\.)" \
81
+ "(?<!(?<!#{WORD})[A-Z][a-z][a-z]\\.)" \
82
+ "(?<!(?<!#{WORD})[A-Z]\\.)" \
83
+ "(?<!(?<!#{WORD})e\\.g\\.)" \
84
+ "(?<!(?<!#{WORD})i\\.e\\.)" \
85
+ "(?<!(?<!#{WORD})vs\\.)" \
86
+ "(?<=[.!?])[ \\t\\n\\r\\f\\v]+"
87
+ )
88
+
89
+ # What stands in for a placeholder when every sentence of a prose field
90
+ # carries one. Any other kind reads "this".
91
+ GENERIC_STAND_INS = {
92
+ "NAME" => "someone",
93
+ "PERSON" => "someone",
94
+ "LOCATION" => "a place",
95
+ "SCHOOL" => "the school",
96
+ "ORGANIZATION" => "an organization"
97
+ }.freeze
98
+
99
+ TRIM = /\A[ \t\n\r\f\v]+|[ \t\n\r\f\v]+\z/
100
+ TRIM_TOKEN = /\A[{}\[\]<> \t\n\r\f\v]+|[{}\[\]<> \t\n\r\f\v]+\z/
101
+ ELISION = /[ \t\n\r\f\v]*…[ \t\n\r\f\v]*/
102
+ ELISION_BEFORE_PUNCT = / … ([.,;:!?])/
103
+ NUMBER_SUFFIX = /_[0-9]+\z/
104
+ private_constant :TRIM, :TRIM_TOKEN, :ELISION, :ELISION_BEFORE_PUNCT,
105
+ :NUMBER_SUFFIX
106
+
107
+ # One placeholder found in text. `start`/`end` count characters.
108
+ Match = Struct.new(:text, :kind, :start, :end) do
109
+ def to_h
110
+ { "text" => text, "kind" => kind, "start" => start, "end" => self.end }
111
+ end
112
+ end
113
+
114
+ # Text with every placeholder removed, and how many were.
115
+ ScrubResult = Struct.new(:text, :removed)
116
+
117
+ class << self
118
+ # The kind a placeholder token names: `[ Name_2 ]` -> `NAME`; else
119
+ # {OTHER_KIND}.
120
+ def kind_of(token)
121
+ kind = token.gsub(TRIM_TOKEN, "").upcase.sub(NUMBER_SUFFIX, "")
122
+ PLACEHOLDER_KINDS.include?(kind) ? kind : OTHER_KIND
123
+ end
124
+
125
+ # Every placeholder in `text`, in order.
126
+ def find(text)
127
+ return [] if text.nil? || text.empty?
128
+
129
+ text.to_enum(:scan, PLACEHOLDER_LIKE).map do
130
+ m = Regexp.last_match
131
+ Match.new(m[0], kind_of(m[0]), m.begin(0), m.end(0))
132
+ end
133
+ end
134
+
135
+ # Whether `text` carries any placeholder.
136
+ def any?(text)
137
+ !text.nil? && !text.empty? && PLACEHOLDER_LIKE.match?(text)
138
+ end
139
+
140
+ # Return `text` unchanged, or raise {Vicary::PlaceholderLeakError}. Call
141
+ # it on the last string a host builds before a reader sees it.
142
+ def assert_none(text)
143
+ found = find(text)
144
+ unless found.empty?
145
+ raise PlaceholderLeakError.new(found.map(&:kind).uniq.sort, found.length)
146
+ end
147
+
148
+ text
149
+ end
150
+
151
+ # Remove every placeholder from `text`. Prose (the default) drops each
152
+ # sentence carrying one, or — when every sentence does — replaces each
153
+ # with its {GENERIC_STAND_INS} entry; `quote: true` elides each as `…`.
154
+ def scrub(text, quote: false)
155
+ return ScrubResult.new(text, 0) unless any?(text)
156
+
157
+ removed = text.scan(PLACEHOLDER_LIKE).length
158
+ if quote
159
+ out = text.gsub(PLACEHOLDER_LIKE, "…").gsub(ELISION, " … ")
160
+ .gsub(ELISION_BEFORE_PUNCT, ' …\1').gsub(TRIM, "")
161
+ return ScrubResult.new(out, removed)
162
+ end
163
+
164
+ kept = text.gsub(TRIM, "").split(SENTENCE_END).reject { |s| any?(s) }
165
+ return ScrubResult.new(kept.join(" "), removed) unless kept.empty?
166
+
167
+ out = text.gsub(PLACEHOLDER_LIKE) do |token|
168
+ GENERIC_STAND_INS.fetch(kind_of(token), "this")
169
+ end
170
+ ScrubResult.new(out, removed)
171
+ end
172
+ end
173
+ end
174
+
175
+ class << self
176
+ # Every placeholder in `text` — see {Vicary::Placeholders}.
177
+ def find_placeholders(text)
178
+ Placeholders.find(text)
179
+ end
180
+
181
+ # Whether `text` carries any placeholder.
182
+ def has_placeholders?(text)
183
+ Placeholders.any?(text)
184
+ end
185
+
186
+ # Return `text`, or raise {Vicary::PlaceholderLeakError}.
187
+ def assert_no_placeholders(text)
188
+ Placeholders.assert_none(text)
189
+ end
190
+
191
+ # Remove every placeholder from `text`.
192
+ def scrub_placeholders(text, quote: false)
193
+ Placeholders.scrub(text, quote: quote)
194
+ end
195
+ end
196
+ end
@@ -6,5 +6,5 @@ module Vicary
6
6
  # Shared across all three front doors on purpose: one detector, one number. A
7
7
  # gem 0.3.0 that corresponds to nothing on PyPI cannot be reasoned about, and
8
8
  # the parity claim is between *versions*, not between package names.
9
- VERSION = "0.2.15"
9
+ VERSION = "0.2.16"
10
10
  end
data/lib/vicary.rb CHANGED
@@ -44,3 +44,4 @@ require_relative "vicary/corpus"
44
44
  require_relative "vicary/latency_baseline"
45
45
  require_relative "vicary/redact"
46
46
  require_relative "vicary/spans"
47
+ require_relative "vicary/placeholders"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vicary
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.15
4
+ version: 0.2.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Blake Thomas
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Finds the names a student writes about — classmates, teachers, relatives — and
@@ -38,6 +38,7 @@ files:
38
38
  - lib/vicary/latency_baseline.rb
39
39
  - lib/vicary/lexicon.rb
40
40
  - lib/vicary/minter.rb
41
+ - lib/vicary/placeholders.rb
41
42
  - lib/vicary/redact.rb
42
43
  - lib/vicary/spans.rb
43
44
  - lib/vicary/structured.rb