vicary 0.2.6 → 0.2.8

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: 537ad53f4299fd2a3cdfc7bceab94b4678bb3a7da01c2e73f5f0c1883d1dad2e
4
- data.tar.gz: e51c1775be63158b1724739c94ddecb48b406c20a339d1b4b8a0acba1d753e5d
3
+ metadata.gz: 70cca39c8c91256c36ed5952eb12710da4125bd605f65dc4c1c71de8e0d77395
4
+ data.tar.gz: 879ab66dc00f074accad84a80887ac48a93fc9fe09968bc04f819bee1e0e20a8
5
5
  SHA512:
6
- metadata.gz: d406808fe2dfc71599bb85516237fe3272876bd2302e3d794c12fb2d296fb2e4f65ea9906be0a8fe1f7deaaf1afab7cb3e6fa3c15dbe5f77f68c861b5f70ad39
7
- data.tar.gz: 70e6fbef81ab903425190feda6683d3343604b980da6033f0ad7471816f4c2887eaf8408637dc1092e394e60162b877a1e5de6aa1a91516ba4c5d161bcd0b116
6
+ metadata.gz: ecf9bbecf10214d42fc5e495d6b3b161f7d1f1f1a080fb13631c379f06dd609ec182065685060d63f733890c468eacb0b854e371a2fec6b3fdb5bebdd9f45f03
7
+ data.tar.gz: 704e8aaf854c08b3128299dc425e543c00cf3789909dd98ccbb13509d9c80020d72d991a8e4c690424f9508ba8dfef6a5c9da1c9ca8f513a642407a33bfdf5b5
data/README.md CHANGED
@@ -31,6 +31,13 @@ spans = Vicary::Spans.derive(masked, restore_map)
31
31
  Vicary::Spans.to_original(offset, spans) # masked coordinates -> the student's
32
32
  Vicary::Spans.to_redacted(offset, spans) # and back
33
33
  Vicary::Spans.original(masked, restore_map) == essay # => true
34
+
35
+ # Several fields, one pass, and one restore map per field. Numbering is the
36
+ # JOINED document's, so one person keeps one placeholder across every field —
37
+ # masking field by field renumbers and hands `{NAME_1}` to two people.
38
+ masked, n, maps, batched = Vicary.redact_batch_with_report(fields, identity)
39
+ # `batched == false` means the join did not round-trip and each field was
40
+ # passed separately, so cross-field placeholder identity does NOT hold.
34
41
  ```
35
42
 
36
43
  An offset landing inside a placeholder resolves to the start of the span it
data/lib/vicary/redact.rb CHANGED
@@ -32,6 +32,15 @@ module Vicary
32
32
 
33
33
  NAME_DETECTION_ENV_VAR = "VICARY_NAME_DETECTION"
34
34
 
35
+ # Separator for a batched pass, byte-identical to the Python reference's
36
+ # `BATCH_SEPARATOR`. Chosen to be something a writing-coach model will not
37
+ # emit and a PII policy will not touch: no words, no digits, no name-shaped or
38
+ # address-shaped substrings for an entity detector to bite on, and distinctive
39
+ # enough that a stray single character cannot fake it. A round-trip check runs
40
+ # anyway — see {redact_batch_with_report} — because a separator that "should"
41
+ # survive is exactly the kind of assumption that silently mis-aligns fields.
42
+ BATCH_SEPARATOR = "\n\u241E\u241E\u241E\n"
43
+
35
44
  IDENTITY_ALIASES = Set.new(%w[identity off none 0 false no]).freeze
36
45
  GAZETTEER_ALIASES = Set.new(%w[gazetteer on 1 true yes names]).freeze
37
46
  LOWERCASE_ALIASES = Set.new(%w[gazetteer-lowercase gazetteer_lowercase lowercase full max]).freeze
@@ -156,6 +165,90 @@ module Vicary
156
165
  [masked, n, minter.assigned]
157
166
  end
158
167
 
168
+ # Split a joined document's restore map into one map per part.
169
+ #
170
+ # By OCCURRENCE, not by re-deriving: the placeholders are unique strings
171
+ # over the joined document, so a part's map is exactly the entries whose
172
+ # placeholder survived into that part. Re-running detection per part would
173
+ # renumber, and renumbering is the one thing this must not do.
174
+ def split_joined_restore_map(restore_map, parts)
175
+ parts.map do |part|
176
+ restore_map.select { |placeholder, _original| part.include?(placeholder) }
177
+ end
178
+ end
179
+
180
+ # Redact several fields in ONE pass, and return the way back for each.
181
+ #
182
+ # Numbering is the JOINED document's, not each field's, so the same entity
183
+ # carries the same placeholder across fields — which is the property that
184
+ # lets a reader match a name in one field to the same name in another. A
185
+ # caller must therefore not assume a field's map starts at `{NAME_1}`.
186
+ #
187
+ # **Why a host wants this.** Masking field by field is irreversible in the
188
+ # way that matters: a caller holding only the masked strings cannot tell
189
+ # which placeholder belongs to which field, nor what any of them stood for,
190
+ # so it cannot put back the words its reader is already entitled to see.
191
+ # It also renumbers, so one entity gets a different token per field.
192
+ #
193
+ # **Where it can go wrong, and what happens then.** The join/split round
194
+ # trip is the risk: masking changes lengths, so offsets cannot be trusted,
195
+ # and the split relies on {BATCH_SEPARATOR} surviving the pass intact. If
196
+ # the masked document does not split back into exactly as many parts as
197
+ # went in, this **falls back to per-field passes and says so**
198
+ # (`batched == false`) rather than returning a mis-aligned list — one
199
+ # field's suggestion pasted into another's is a worse outcome than a slower
200
+ # call. Cross-field placeholder identity does not survive that fallback,
201
+ # which is stated rather than papered over: a caller correlating names
202
+ # across fields would otherwise be silently wrong on exactly those inputs.
203
+ #
204
+ # Returns `[masked_texts, n_masked, restore_maps, batched]`. `restore_maps`
205
+ # is positionally aligned with `texts` — one map per field, empty for a
206
+ # field that was empty or that nothing was masked in.
207
+ def redact_batch_with_report(texts, identity, options = {})
208
+ texts = texts.to_a
209
+ return [[], 0, [], true] if texts.empty?
210
+
211
+ empty_maps = texts.map { {} }
212
+ nonempty = texts.each_index.reject { |i| texts[i].nil? || texts[i].empty? }
213
+ return [texts.dup, 0, empty_maps, true] if nonempty.empty?
214
+
215
+ if nonempty.length == 1
216
+ only = nonempty.first
217
+ masked, n, map = redact_with_report(texts[only], identity, options)
218
+ out = texts.dup
219
+ out[only] = masked
220
+ maps = empty_maps.dup
221
+ maps[only] = map
222
+ return [out, n, maps, true]
223
+ end
224
+
225
+ joined = nonempty.map { |i| texts[i] }.join(BATCH_SEPARATOR)
226
+ masked, n, map = redact_with_report(joined, identity, options)
227
+ parts = masked.split(BATCH_SEPARATOR, -1)
228
+
229
+ if parts.length != nonempty.length
230
+ out = texts.dup
231
+ maps = empty_maps.dup
232
+ total = 0
233
+ nonempty.each do |i|
234
+ field_masked, field_n, field_map = redact_with_report(texts[i], identity, options)
235
+ out[i] = field_masked
236
+ maps[i] = field_map
237
+ total += field_n
238
+ end
239
+ return [out, total, maps, false]
240
+ end
241
+
242
+ out = texts.dup
243
+ maps = empty_maps.dup
244
+ split = split_joined_restore_map(map, parts)
245
+ nonempty.each_with_index do |i, k|
246
+ out[i] = parts[k]
247
+ maps[i] = split[k]
248
+ end
249
+ [out, n, maps, true]
250
+ end
251
+
159
252
  # Redact personal names and structured PII from `text`.
160
253
  #
161
254
  # @param text [String] the composition to redact.
@@ -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.6"
9
+ VERSION = "0.2.8"
10
10
  end
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.6
4
+ version: 0.2.8
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-09 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Finds the names a student writes about — classmates, teachers, relatives — and