vicary 0.2.15 → 0.2.17

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: 381ed16c17b2aa427a72b173af92825ad08db621b13e562fdc7977d41d760a26
4
+ data.tar.gz: 9e7a68508af7f405888c9a0e5ebc9b7993b3f0e7de6149c91156cf7c23b9fe29
5
5
  SHA512:
6
- metadata.gz: 1956a3e47dc351c62ac4a58266ed48fbf936745ed6d6cf72b2abfc35df0f50b19ae2b4f603b80248e330153c866312ade9017777cbdf0f46c211ab2abeccb1fb
7
- data.tar.gz: 42fa8950725aa8c58efa07462aec67843e671eef013c211a9025655df09970ddba05c54d92c524a3f244241d8bc1d147ee8acea9bd62f01b6b3e1c6d5eed8e15
6
+ metadata.gz: 87ffdbf4edb505a3c1d5faca88c817a5a1ad44b093fbfc329f99958c05a528b078c3fdcc1d97c9e6ffca2d255be617cb3912d6d6e2a9735f02861600aa0f0ce6
7
+ data.tar.gz: ec871699f441d07106e5f32981eb9a34f35ce2935f75a7f78163fc0ae277dc1afbe8fdd042e3dbf9676b8a4493dc9fb8078c36a0e3ba9cb65804cad79b17c93b
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.
@@ -300,6 +300,39 @@ module Vicary
300
300
  most much many few several enough
301
301
  ]).freeze
302
302
 
303
+ # Phonetic spellings of English stop words, each with the stop word it
304
+ # spells. Young writers, and young Spanish-L1 writers most of all,
305
+ # spell the closed-class words by ear — "dey sai ai smel", "so ai dont get
306
+ # mai clos dirtier" — and several of those spellings are given names the
307
+ # tier knows, so the lowercase route masked "sai ai" and "mai clos" and the
308
+ # sentence-initial rule let the tier vouch for "Ai", "Mi" and "De". A
309
+ # spelling of a stop word is the same word, so in a document that writes
310
+ # this way it is a stop word too. Only the spellings are read; the pairing
311
+ # is documentation. See `phonetic_function_words` for when a document
312
+ # qualifies.
313
+ PHONETIC_FUNCTION_WORDS = {
314
+ "ai" => "i", "becuz" => "because", "bicuz" => "because", "bot" => "but",
315
+ "cud" => "could", "da" => "the", "dan" => "than", "dat" => "that",
316
+ "de" => "the", "deir" => "their", "dem" => "them", "den" => "then",
317
+ "dere" => "there", "dese" => "these", "dey" => "they", "dis" => "this",
318
+ "dose" => "those", "frum" => "from", "hiz" => "his", "iz" => "is",
319
+ "mai" => "my", "mi" => "me", "sai" => "say", "sed" => "said",
320
+ "shud" => "should", "sum" => "some", "tu" => "to", "wat" => "what",
321
+ "wen" => "when", "wud" => "would", "wuz" => "was", "yu" => "you",
322
+ "yur" => "your",
323
+ }.freeze
324
+
325
+ # Distinct phonetic spellings a document must write in lower case before
326
+ # any of them is read as a word rather than a name. One is a coincidence —
327
+ # a friend called Mai — two different ones is a writer spelling by ear.
328
+ PHONETIC_REGISTER_MIN = 2
329
+
330
+ PHONETIC_TOKEN = Regexp.new(
331
+ "(?<![A-Za-z'’-])(?:" +
332
+ PHONETIC_FUNCTION_WORDS.keys.sort_by { |w| [-w.length, w] }.join("|") +
333
+ ")(?![A-Za-z'’-])"
334
+ )
335
+
303
336
  HONORIFIC_SET = Set.new(HONORIFICS.map(&:downcase)).freeze
304
337
 
305
338
  # One capitalised word, hyphens and apostrophes included so
@@ -742,6 +775,20 @@ module Vicary
742
775
  strip(without_clitic(strip(token.downcase, ".,")), "'’")
743
776
  end
744
777
 
778
+ # The phonetic spellings this document uses as words, to be read as
779
+ # stops. Empty unless the document writes at least PHONETIC_REGISTER_MIN
780
+ # distinct spellings in lower case. When it does, the spellings it writes
781
+ # in lower case — the writer's own testimony that the token is a word to
782
+ # them — less any it also capitalises mid-sentence, which is the opposite
783
+ # testimony and wins.
784
+ def phonetic_function_words(text, written_as_a_capital = Set.new)
785
+ seen = Set.new
786
+ each_match(text, PHONETIC_TOKEN) { |m| seen << m[0] }
787
+ return Set.new if seen.size < PHONETIC_REGISTER_MIN
788
+
789
+ Set.new(seen.reject { |w| written_as_a_capital.include?(w) })
790
+ end
791
+
745
792
  # Whether a capital on `token` mid-sentence is a mistake rather than a
746
793
  # construction — the narrow half of the stoplist.
747
794
  #
@@ -846,7 +893,11 @@ module Vicary
846
893
  # the stoplist so that a bare "Mrs." cannot become a candidate on its own,
847
894
  # but "Mrs. Okonkwo" has to stay whole: masking only the surname leaves the
848
895
  # relationship and the surname's position in the text.
849
- def trim(tokens)
896
+ #
897
+ # `phonetic` is `phonetic_function_words` for the document: spellings read
898
+ # as stop words here. Except a particle *inside* a run — "Maria de Cruz"
899
+ # keeps its `de` however the rest of the document spells "the".
900
+ def trim(tokens, phonetic = Set.new)
850
901
  runs = []
851
902
  current = []
852
903
  tokens.each_with_index do |token, index|
@@ -854,7 +905,10 @@ module Vicary
854
905
  HONORIFIC_SET.include?(strip(token.downcase, ".,")) &&
855
906
  index + 1 < tokens.length &&
856
907
  !stop?(tokens[index + 1])
857
- if stop?(token) && !introduces_a_name
908
+ spelled_stop = !phonetic.empty? && phonetic.include?(fold(token)) &&
909
+ !(index.positive? && index < tokens.length - 1 &&
910
+ PARTICLES.include?(token))
911
+ if (stop?(token) || spelled_stop) && !introduces_a_name
858
912
  unless current.empty?
859
913
  runs << current
860
914
  current = []
@@ -1849,7 +1903,7 @@ module Vicary
1849
1903
  # capitalisation signal, and the seed stands on the given-name tier
1850
1904
  # alone.
1851
1905
  def find_lowercase_candidates(text, is_given, protected_span, corroborate = nil,
1852
- settlement = nil)
1906
+ settlement = nil, phonetic = Set.new)
1853
1907
  tokens = each_match(text, LOWER_TOKEN).map do |m|
1854
1908
  [m[0], m.begin(0), m.begin(0) + m[0].length]
1855
1909
  end
@@ -1857,7 +1911,10 @@ module Vicary
1857
1911
  index = 0
1858
1912
  while index < tokens.length
1859
1913
  word, start, = tokens[index]
1860
- if stop?(word) || !is_given.call(word)
1914
+ # A phonetic spelling may not SEED a span ("sai ai smel"), but may
1915
+ # still be the token a real seed reaches ("jeni sai") — see the
1916
+ # reference.
1917
+ if stop?(word) || phonetic.include?(word) || !is_given.call(word)
1861
1918
  index += 1
1862
1919
  next
1863
1920
  end
@@ -1996,6 +2053,10 @@ module Vicary
1996
2053
  )
1997
2054
  written_as_a_capital = casing.written_as_a_capital
1998
2055
  lower_cased = casing.written_in_lower_case
2056
+ # Read off the same casing pass, so a spelling the writer capitalises
2057
+ # mid-sentence is never one of them; and only where a given-name oracle
2058
+ # exists, because it only withdraws what the tier proposed or vouched for.
2059
+ phonetic = given_name.nil? ? Set.new : phonetic_function_words(text, written_as_a_capital)
1999
2060
  stray_capitals = mid_sentence_corroboration && !given_name.nil? &&
2000
2061
  capitalises_ordinary_words?(text, headings)
2001
2062
 
@@ -2007,7 +2068,7 @@ module Vicary
2007
2068
  tokens = words(span)
2008
2069
  # A long all-caps run means the capitalisation told us nothing, so the
2009
2070
  # stoplist is carrying the whole decision.
2010
- trim(tokens).each do |run|
2071
+ trim(tokens, phonetic).each do |run|
2011
2072
  next if run.empty?
2012
2073
 
2013
2074
  joined = run.join(" ")
@@ -2078,7 +2139,7 @@ module Vicary
2078
2139
  # evidence to offer and is better served by it.
2079
2140
  find_lowercase_candidates(
2080
2141
  text, given_name, is_protected,
2081
- habit == LOWERCASE ? nil : written_as_a_capital, settlement
2142
+ habit == LOWERCASE ? nil : written_as_a_capital, settlement, phonetic
2082
2143
  ).each do |candidate|
2083
2144
  next if claimed.any? { |s, e| candidate.start < e && candidate.end > s }
2084
2145
 
@@ -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.17"
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.17
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-21 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