vicary 0.2.0 → 0.2.4

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,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Vicary
6
+ # The gates, measured by this port rather than read from the spec.
7
+ #
8
+ # Five of the nine gates in `conformance/gates.json` need no data beyond the
9
+ # fixture, so this port measures them. The other four declare `requires` —
10
+ # `corpus` or `census` — and the repository now carries both, in
11
+ # `conformance/corpora/` and `conformance/census/`, so a bare checkout measures
12
+ # all nine. A caller that supplies nothing still gets NOT MEASURED for those
13
+ # four, spelled out per gate rather than reduced out of the denominator,
14
+ # because eight of nine held is a different statement from nine of nine and a
15
+ # badge cannot tell them apart. That machinery stays whether or not a shortfall
16
+ # is currently reachable: it is what makes the next unmeasurable gate visible.
17
+ #
18
+ # **Why this is measured and not asserted from the golden.** The spec already
19
+ # carries `aligns` and `mapping` per frame, computed by the reference. Reading a
20
+ # gate's answer out of the file would make the port's gate report a restatement
21
+ # of Python's, which is exactly the self-report MUST #6 warns about wearing an
22
+ # external costume. Everything below is recovered from the port's own output by
23
+ # chunk matching — the same way the reference recovers it, and without asking
24
+ # the masker to report on itself.
25
+ module Gates
26
+ # Every placeholder the shipped classifier can emit.
27
+ #
28
+ # Anything else in masked output is malformed — a truncated or nested
29
+ # placeholder is how a masking bug presents, and it reads as ordinary prose
30
+ # to a downstream stage.
31
+ KNOWN_PLACEHOLDERS = Set[
32
+ "{NAME}",
33
+ "{SCHOOL}",
34
+ "{EMAIL}",
35
+ "{URL}",
36
+ "{US_SOCIAL_SECURITY_NUMBER}",
37
+ "{IP_ADDRESS}",
38
+ "{PHONE}",
39
+ "{ADDRESS}",
40
+ "{DATE_OF_BIRTH}",
41
+ "{USERNAME}",
42
+ "{ZIP_CODE}",
43
+ "{AGE}",
44
+ "{CREDIT_DEBIT_CARD_NUMBER}",
45
+ "{ORGANIZATION}",
46
+ "{LOCATION}",
47
+ ].freeze
48
+
49
+ # Deliberately loose, so it matches malformed output too — which is the point.
50
+ PLACEHOLDER_RE = /\{[A-Za-z_0-9]*\}/.freeze
51
+
52
+ # `\z` rather than `$`: Ruby's `$` also matches *before* a trailing newline,
53
+ # so a token arriving with one would have its index left on. JavaScript's `$`
54
+ # does not, and this must agree with the TypeScript port token for token.
55
+ PLACEHOLDER_INDEX_RE = /_(\d+)\}\z/.freeze
56
+
57
+ WEAK_TOKENS = Set["of", "van", "de", "la", "the", "der", "von", "mrs", "mr", "ms"].freeze
58
+
59
+ # Invariant violations present at this fixture version, each one accounted for.
60
+ #
61
+ # Gated as an exact SET rather than a count, so a *new* violation fails even
62
+ # though these do not — a ceiling of one would let a second defect in by
63
+ # silently displacing this one.
64
+ #
65
+ # * `Robinson` — the documented, deliberately unpaid cost: once a document
66
+ # establishes "Jackie Robinson", a bare "Robinson" in it keeps, including a
67
+ # neighbour who shares the surname. No surname-level rule separates them.
68
+ #
69
+ # The companion check is the load-bearing half: an entry here that STOPS
70
+ # occurring fails too, so a stale exemption cannot shelter the next defect of
71
+ # the same shape. Two entries were retired from the Python list exactly that
72
+ # way.
73
+ ACCEPTED_VIOLATIONS = Set["leak\u0000NAME:Robinson"].freeze
74
+
75
+ Alignment = Struct.new(:pairs, :ok, :reason, keyword_init: true)
76
+ Violation = Struct.new(:kind, :detail, keyword_init: true)
77
+ SpanOutcome = Struct.new(:frame_id, :entity, :literal, :verdict, :held_out,
78
+ :passed, keyword_init: true)
79
+ # `value` is nil when this port does not measure the gate — never 0, which
80
+ # would read as a measured failure.
81
+ # `bar` is the one actually applied — the gate's default unless the measured
82
+ # corpus has an override. Carried here rather than re-derived by the renderer,
83
+ # so what is printed cannot drift from what was compared.
84
+ GateMeasurement = Struct.new(:gate, :value, :passed, :detail, :bar,
85
+ keyword_init: true)
86
+ GateReport = Struct.new(:measurements, :violations, :unaccounted,
87
+ :missing_accepted, keyword_init: true)
88
+
89
+ class << self
90
+ # `"{NAME_3}"` → `"{NAME}"`; an unnumbered token is returned unchanged.
91
+ #
92
+ # The index identifies *which* entity, the kind identifies *what* it is, and
93
+ # every invariant here is about the kind.
94
+ def placeholder_kind(token)
95
+ token.sub(PLACEHOLDER_INDEX_RE, "}")
96
+ end
97
+
98
+ # Recover the span→placeholder mapping by matching the surviving prose.
99
+ #
100
+ # Splits `masked` at placeholder boundaries and reconstructs which region of
101
+ # `original` each placeholder replaced. Recovered by chunk matching rather
102
+ # than asked of the redactor, so it works against any masker without that
103
+ # masker having to report its own spans.
104
+ def align(original, masked)
105
+ placeholders = masked.scan(PLACEHOLDER_RE)
106
+ # The `-1` is load-bearing. Ruby's `split` DROPS trailing empty fields and
107
+ # JavaScript's does not: for "a{X}" it would return ["a"] where the port
108
+ # this mirrors returns ["a", ""]. That silently shortens the chunk list,
109
+ # so the reconstruction below loses its final anchor and a placeholder at
110
+ # the end of a sentence recovers the wrong region.
111
+ parts = masked.split(PLACEHOLDER_RE, -1)
112
+
113
+ if placeholders.empty?
114
+ return Alignment.new(pairs: [], ok: false,
115
+ reason: "text changed with no placeholder emitted") if masked != original
116
+
117
+ return Alignment.new(pairs: [], ok: true, reason: "")
118
+ end
119
+
120
+ # Anchored, all at once, rather than a left-to-right scan for each chunk in
121
+ # turn. A greedy per-chunk `index` misaligns whenever a surviving chunk is
122
+ # short enough to also occur inside the span that was just removed — a
123
+ # trailing "." after a masked email address matches the "." inside the
124
+ # address, and the recovered region collapses to one character. Anchoring
125
+ # the whole reconstruction makes it consistent simultaneously, so a
126
+ # candidate that cannot be completed to the end of the original is
127
+ # rejected and the engine backtracks. The chunks are long, distinctive
128
+ # prose, which is what keeps the lazy quantifiers from exploring.
129
+ #
130
+ # `\A`/`\z` rather than `^`/`$`, which in Ruby are line anchors: a
131
+ # sentence containing a newline would otherwise let a partial
132
+ # reconstruction satisfy the pattern and report `ok`.
133
+ pattern = +"\\A" + Regexp.escape(parts[0])
134
+ parts[1..].each { |chunk| pattern << "([\\s\\S]*?)#{Regexp.escape(chunk)}" }
135
+ pattern << "\\z"
136
+
137
+ found = Regexp.new(pattern).match(original)
138
+ if found.nil?
139
+ return Alignment.new(
140
+ pairs: [], ok: false,
141
+ reason: "masked text is not the original with spans replaced — prose was " \
142
+ "rewritten, reordered or dropped",
143
+ )
144
+ end
145
+
146
+ regions = found.captures
147
+ Alignment.new(
148
+ pairs: placeholders.each_with_index.map { |p, i| [p, regions[i] || ""] },
149
+ ok: true, reason: "",
150
+ )
151
+ end
152
+
153
+ # Put the originals back the way an echo-fidelity restore would have to.
154
+ #
155
+ # Keyed on the placeholder token, because that is all a downstream consumer
156
+ # has: the model echoes `{NAME}` and the caller must decide which name it
157
+ # meant. With one token per entity type it cannot, which is what
158
+ # `not-restorable` counts. Distinct from `Minter.restore`, which is handed a
159
+ # map the masker built.
160
+ def restore_by_token(masked, mapping)
161
+ masked.gsub(PLACEHOLDER_RE) { |token| mapping.fetch(token, token) }
162
+ end
163
+
164
+ # True when the frame's sentence survives mask-then-restore exactly.
165
+ def round_trips?(frame, masked)
166
+ alignment = align(frame.sentence, masked)
167
+ return false unless alignment.ok
168
+
169
+ mapping = {}
170
+ alignment.pairs.each { |placeholder, region| mapping[placeholder] ||= region }
171
+ restore_by_token(masked, mapping) == frame.sentence
172
+ end
173
+
174
+ # Substrings whose survival proves a partial leak of `span`.
175
+ #
176
+ # A name masked halfway still identifies the person, so "the whole literal
177
+ # is gone" is too weak a test on multi-token names.
178
+ def leak_probes(span)
179
+ return [] unless %w[NAME SCHOOL ORGANIZATION LOCATION].include?(span.entity)
180
+
181
+ span.literal
182
+ .split(/[\s\-]+/)
183
+ .reject(&:empty?)
184
+ .map { |t| t.sub(/\A[.,']+/, "").sub(/[.,']+\z/, "") }
185
+ .select { |t| t.length >= 3 && !WEAK_TOKENS.include?(t.downcase) }
186
+ end
187
+
188
+ # Every structural invariant the masked text must satisfy.
189
+ #
190
+ # `leak` — a REDACT literal survived. `partial-leak` — the literal is gone
191
+ # but a name token of it survived; worse than a miss, because it *looks*
192
+ # redacted and recall scores it as a pass. `keep-destroyed` — a KEEP literal
193
+ # was masked. `unknown-placeholder` — output carries a brace token nobody
194
+ # emits. `chunk-alignment` — prose was rewritten rather than replaced.
195
+ # `not-restorable` — one placeholder stands for two different originals.
196
+ # `wrong-type` — masked, but as the wrong entity.
197
+ def check_frame(frame, masked)
198
+ out = []
199
+
200
+ masked.scan(PLACEHOLDER_RE).uniq.each do |token|
201
+ unless KNOWN_PLACEHOLDERS.include?(placeholder_kind(token))
202
+ out << Violation.new(kind: "unknown-placeholder", detail: token)
203
+ end
204
+ end
205
+
206
+ frame.spans.reject { |s| keep?(s) }.each do |span|
207
+ if masked.include?(span.literal)
208
+ out << Violation.new(kind: "leak", detail: "#{span.entity}:#{span.literal}")
209
+ next
210
+ end
211
+ leak_probes(span).each do |probe|
212
+ if /\b#{Regexp.escape(probe)}\b/.match?(masked)
213
+ out << Violation.new(kind: "partial-leak",
214
+ detail: "#{span.entity}:#{span.literal} → #{probe}")
215
+ end
216
+ end
217
+ end
218
+
219
+ frame.spans.select { |s| keep?(s) }.each do |span|
220
+ unless masked.include?(span.literal)
221
+ out << Violation.new(kind: "keep-destroyed",
222
+ detail: "#{span.entity}:#{span.literal}")
223
+ end
224
+ end
225
+
226
+ alignment = align(frame.sentence, masked)
227
+ unless alignment.ok
228
+ out << Violation.new(kind: "chunk-alignment", detail: alignment.reason)
229
+ return out
230
+ end
231
+
232
+ seen = {}
233
+ alignment.pairs.each do |placeholder, region|
234
+ prior = seen[placeholder]
235
+ if !prior.nil? && prior != region
236
+ out << Violation.new(
237
+ kind: "not-restorable",
238
+ detail: "#{placeholder} ← #{prior.inspect} and #{region.inspect}",
239
+ )
240
+ end
241
+ seen[placeholder] ||= region
242
+ end
243
+
244
+ frame.spans.reject { |s| keep?(s) }.each do |span|
245
+ next if span.expect.nil? || masked.include?(span.literal)
246
+
247
+ covering = alignment.pairs
248
+ .select { |_p, region| region.include?(span.literal) }
249
+ .map { |p, _region| placeholder_kind(p) }
250
+ # `expect` carries its own braces — "{NAME}", not "NAME" — so it is
251
+ # compared to `placeholder_kind` output directly. Wrapping it again
252
+ # silently made every correctly-typed span a `wrong-type`, which read as
253
+ # 41 violations and printed "expected {NAME} got {NAME}".
254
+ if !covering.empty? && !covering.include?(span.expect)
255
+ out << Violation.new(
256
+ kind: "wrong-type",
257
+ detail: "#{span.literal.inspect} expected #{span.expect} got #{covering[0]}",
258
+ )
259
+ end
260
+ end
261
+
262
+ out
263
+ end
264
+
265
+ def score_spans(frame, masked)
266
+ frame.spans.map do |span|
267
+ passed =
268
+ if span.expect_count.nil?
269
+ present = masked.include?(span.literal)
270
+ keep?(span) ? present : !present
271
+ else
272
+ # Presence cannot decide a bare surname that also occurs inside a
273
+ # kept full name, so this one is counted rather than tested for
274
+ # absence.
275
+ occurrences(masked, span.literal) == span.expect_count
276
+ end
277
+ SpanOutcome.new(frame_id: frame.frame_id, entity: span.entity,
278
+ literal: span.literal, verdict: span.verdict,
279
+ held_out: frame.held_out, passed: passed)
280
+ end
281
+ end
282
+
283
+ # The key `ACCEPTED_VIOLATIONS` is written in. NUL, because neither half can
284
+ # contain one.
285
+ def violation_key(violation)
286
+ "#{violation.kind}\u0000#{violation.detail}"
287
+ end
288
+
289
+ # Measure every gate this port can measure from the fixture, plus any whose
290
+ # `requires` the caller has satisfied by supplying the data.
291
+ #
292
+ # `asset_entries` and `bare_surname_exposure` are passed in rather than
293
+ # read here so this module stays free of the gazetteer and the filesystem —
294
+ # a caller that wants those gates supplies the number, and one that does
295
+ # not gets NOT MEASURED rather than a load.
296
+ def measure(spec, gate_spec, asset_entries: nil, bare_surname_exposure: nil,
297
+ held_out_recall_carrier: nil, over_fire_per_essay: nil,
298
+ latency_regression_pct: nil, latency_regression_detail: nil,
299
+ corpus_id: nil)
300
+ outcomes = []
301
+ violations = []
302
+ round_tripped = 0
303
+
304
+ spec.frames.each do |frame|
305
+ masked = yield(frame.sentence, spec.identity)
306
+ outcomes.concat(score_spans(frame, masked))
307
+ violations.concat(check_frame(frame, masked))
308
+ round_tripped += 1 if round_trips?(frame, masked)
309
+ end
310
+
311
+ held_out_redact = outcomes.select { |o| o.held_out && o.verdict != "keep" }
312
+ keeps = outcomes.select { |o| o.verdict == "keep" }
313
+ unaccounted = violations.reject { |v| ACCEPTED_VIOLATIONS.include?(violation_key(v)) }
314
+ occurred = violations.map { |v| violation_key(v) }.to_set
315
+ missing_accepted = ACCEPTED_VIOLATIONS.reject { |k| occurred.include?(k) }
316
+
317
+ values = {
318
+ "held_out_recall" => {
319
+ value: pct(held_out_redact.count(&:passed), held_out_redact.size),
320
+ detail: "#{held_out_redact.count(&:passed)}/#{held_out_redact.size} " \
321
+ "held-out REDACT spans",
322
+ },
323
+ "keep_precision" => {
324
+ value: pct(keeps.count(&:passed), keeps.size),
325
+ detail: "#{keeps.count(&:passed)}/#{keeps.size} KEEP spans intact",
326
+ },
327
+ "round_trip" => {
328
+ value: pct(round_tripped, spec.frames.size),
329
+ detail: "#{round_tripped}/#{spec.frames.size} frames restore exactly",
330
+ },
331
+ "unaccounted_violations" => {
332
+ value: unaccounted.size,
333
+ detail: if unaccounted.empty?
334
+ "#{violations.size} violation(s), all accounted for"
335
+ else
336
+ unaccounted.map { |v| "#{v.kind}:#{v.detail}" }.join("; ")
337
+ end,
338
+ },
339
+ "asset_entries" => {
340
+ value: asset_entries,
341
+ detail: asset_entries.nil? ? "not supplied by the caller" : "#{asset_entries} entries",
342
+ },
343
+ }
344
+
345
+ # Kept in a SEPARATE hash from `values` on purpose. A gate declaring
346
+ # `requires` may be measured only from data that actually satisfies that
347
+ # requirement — never from anything derived from the fixture, because
348
+ # computing something else and calling it that gate is the more dangerous
349
+ # failure. Two hashes make that structural rather than a rule to remember.
350
+ no_corpus = "no corpus supplied by the caller"
351
+ supplied = {
352
+ "bare_surname_exposure" => {
353
+ value: bare_surname_exposure,
354
+ detail: if bare_surname_exposure.nil?
355
+ "no census file supplied by the caller"
356
+ else
357
+ "#{round3(bare_surname_exposure)}% of US surname bearers"
358
+ end,
359
+ },
360
+ "held_out_recall_carrier" => {
361
+ value: held_out_recall_carrier,
362
+ detail: if held_out_recall_carrier.nil?
363
+ no_corpus
364
+ else
365
+ "#{round3(held_out_recall_carrier)}% of held-out REDACT spans in carrier essays"
366
+ end,
367
+ },
368
+ "over_fire_prose" => {
369
+ value: over_fire_per_essay,
370
+ detail: if over_fire_per_essay.nil?
371
+ no_corpus
372
+ else
373
+ "#{round3(over_fire_per_essay)} spans masked per essay of un-injected prose"
374
+ end,
375
+ },
376
+ "latency_regression" => {
377
+ value: latency_regression_pct,
378
+ detail: if latency_regression_pct.nil?
379
+ # The reason the comparison was declined, when there is
380
+ # one. A silent skip here is the failure this gate exists
381
+ # to avoid.
382
+ latency_regression_detail || no_corpus
383
+ else
384
+ "#{round3(latency_regression_pct)}% against the last release's figure for this port"
385
+ end,
386
+ },
387
+ }
388
+
389
+ measurements = gate_spec.gates.map do |gate|
390
+ unless gate.requires.empty?
391
+ given = supplied[gate.id]
392
+ if given.nil? || given[:value].nil?
393
+ next GateMeasurement.new(gate: gate, value: nil, passed: nil,
394
+ bar: gate.bar_for(corpus_id), detail: "")
395
+ end
396
+
397
+ next GateMeasurement.new(gate: gate, value: given[:value],
398
+ bar: gate.bar_for(corpus_id),
399
+ passed: compare(given[:value], gate.op,
400
+ gate.bar_for(corpus_id)),
401
+ detail: given[:detail])
402
+ end
403
+
404
+ found = values[gate.id]
405
+ if found.nil? || found[:value].nil?
406
+ next GateMeasurement.new(gate: gate, value: nil, passed: nil,
407
+ bar: gate.bar_for(corpus_id),
408
+ detail: found ? found[:detail] : "")
409
+ end
410
+
411
+ GateMeasurement.new(gate: gate, value: found[:value],
412
+ bar: gate.bar_for(corpus_id),
413
+ passed: compare(found[:value], gate.op,
414
+ gate.bar_for(corpus_id)),
415
+ detail: found[:detail])
416
+ end
417
+
418
+ GateReport.new(measurements: measurements, violations: violations,
419
+ unaccounted: unaccounted, missing_accepted: missing_accepted)
420
+ end
421
+
422
+ # Render the gate block, NOT MEASURED spelled out per gate.
423
+ #
424
+ # Replaces the placeholder block `Conformance.report` prints when no caller
425
+ # measured anything.
426
+ def report(gate_report)
427
+ lines = [" gates:"]
428
+ gate_report.measurements.each do |m|
429
+ gate = m.gate
430
+ # `FROM` rather than `NEEDS` once it holds a value, so the line never
431
+ # reads as though a measured gate were still waiting on its data — and
432
+ # so the provenance of an operator-supplied number stays attached to it.
433
+ needs = if gate.requires.empty?
434
+ ""
435
+ else
436
+ " #{m.passed.nil? ? 'NEEDS' : 'FROM'} #{gate.requires.join('+')}"
437
+ end
438
+ status = if m.passed.nil?
439
+ "NOT MEASURED"
440
+ else
441
+ m.passed ? "PASS " : "FAIL "
442
+ end
443
+ measured = m.value.nil? ? "" : " measured #{round3(m.value)} #{gate.unit}"
444
+ lines << format(" %s %-28s %s %s %s%s%s", status, gate.label, gate.op,
445
+ m.bar, gate.unit, needs, measured)
446
+ lines << format(" %s", m.detail) if m.passed == false && !m.detail.empty?
447
+ end
448
+ measured = gate_report.measurements.reject { |m| m.passed.nil? }
449
+ held = measured.count(&:passed)
450
+ unmeasured = gate_report.measurements.size - measured.size
451
+ # The tally names the shortfall or says there is none, rather than
452
+ # trailing a clause about data an operator must supply — every
453
+ # requirement is satisfied from the repository now, so that clause would
454
+ # send a reader looking for a file to set. It has to keep working when
455
+ # that stops being true.
456
+ tail = if unmeasured.zero?
457
+ "all #{gate_report.measurements.size} were measured."
458
+ else
459
+ "#{unmeasured} are NOT MEASURED for want of the data they declare."
460
+ end
461
+ lines << " -> #{held} of #{measured.size} measured gates hold; #{tail}"
462
+ lines.join("\n")
463
+ end
464
+
465
+ private
466
+
467
+ def keep?(span)
468
+ span.verdict == "keep"
469
+ end
470
+
471
+ def occurrences(haystack, needle)
472
+ return 0 if needle.empty?
473
+
474
+ # `scan` with a String pattern matches it literally and does not overlap,
475
+ # which is what the TypeScript loop's `at + needle.length` step does.
476
+ haystack.scan(needle).size
477
+ end
478
+
479
+ def pct(passed, total)
480
+ return nil if total.zero?
481
+
482
+ (100.0 * passed) / total
483
+ end
484
+
485
+ def compare(value, op, bar)
486
+ case op
487
+ when ">=" then value >= bar
488
+ when "<=" then value <= bar
489
+ when "==" then value == bar
490
+ else raise Conformance::SpecError, "unknown gate operator #{op}"
491
+ end
492
+ end
493
+
494
+ def round3(value)
495
+ value == value.to_i ? value.to_i.to_s : format("%.3f", value)
496
+ end
497
+ end
498
+ end
499
+ end
@@ -125,7 +125,35 @@ module Vicary
125
125
  # outright. That changes a key only when such a mark sits *between* two
126
126
  # alphanumerics, which cannot happen in a gazetteer whose keys are
127
127
  # Latin-folded, nor in the English prose the conformance frames carry.
128
+ # How many folded keys to remember. Sized against the measurement that
129
+ # motivated the cache: 25 carrier essays, redacted twice each, made 29,361
130
+ # calls over 11,888 distinct inputs — 59.5% repeats. A document's vocabulary
131
+ # is the working set, so this holds several essays' worth and is cleared
132
+ # wholesale rather than evicted entry by entry.
133
+ NORMALIZE_CACHE_MAX = 20_000
134
+
135
+ # Memoized {normalize}. Pure function of its argument, so the cache cannot
136
+ # change an answer — it removes an NFKD decomposition and five intermediate
137
+ # strings per repeated token.
138
+ #
139
+ # Worth doing because of what it does to GC, not only to CPU: after the
140
+ # identity-pattern fix, sweeping and marking were ~29% of this port's time on
141
+ # the longest essays, and this path allocates on every call.
128
142
  def self.normalize(name)
143
+ cache = (@normalize_cache ||= {})
144
+ hit = cache[name]
145
+ return hit if hit
146
+
147
+ cache.clear if cache.size >= NORMALIZE_CACHE_MAX
148
+ cache[name] = normalize_uncached(name)
149
+ end
150
+
151
+ # Drop the fold cache. For tests that swap the asset underneath.
152
+ def self.reset_normalize_cache
153
+ @normalize_cache = nil
154
+ end
155
+
156
+ def self.normalize_uncached(name)
129
157
  folded = name.gsub(SMART_QUOTE_PATTERN) { |char| SMART_QUOTES.fetch(char, char) }
130
158
  folded = folded.unicode_normalize(:nfkd)
131
159
  folded = folded.gsub(/\p{M}/, "")