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 +4 -4
- data/README.md +13 -0
- data/lib/vicary/conformance.rb +17 -0
- data/lib/vicary/placeholders.rb +196 -0
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e78fbd85fa4dde2e481bbf8b5349ceda35e4ab98fbf9ad0b336761be3f6379d
|
|
4
|
+
data.tar.gz: b9ad9cd2ebeaffa8b438c4b051d378edb47cfc86f1117cce68732f6cd3f2cdd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/vicary/conformance.rb
CHANGED
|
@@ -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
|
data/lib/vicary/version.rb
CHANGED
|
@@ -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.
|
|
9
|
+
VERSION = "0.2.16"
|
|
10
10
|
end
|
data/lib/vicary.rb
CHANGED
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.
|
|
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-
|
|
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
|