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,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module Vicary
7
+ # Is this build slower than the last release, and is that a fair question here?
8
+ #
9
+ # The latency gate used to hold an absolute number — 10 ms — which is a claim
10
+ # about the machine as much as about the code. It passed on a laptop and failed
11
+ # on the CI runner enforcing it, so v0.2.3 published to PyPI and npm and was
12
+ # refused by RubyGems on the same commit. This gem is the one that caught it.
13
+ #
14
+ # What replaced it asks a relative question: is this port slower than it was at
15
+ # the last release, by more than the tolerance. That only means something
16
+ # between measurements taken on comparable hardware, so this module's real work
17
+ # is REFUSING to compare when they are not — a machine difference reported as a
18
+ # code regression is worse than no gate, because it trains the reader to ignore
19
+ # it.
20
+ #
21
+ # This port reaches its own verdict from the shared file. It does not read
22
+ # Python's answer.
23
+ module LatencyBaseline
24
+ BASELINE_FILENAME = "latency_baseline.json"
25
+
26
+ # Set by CI on the one matrix entry whose language version matches the
27
+ # recorded profile. Absent everywhere else on purpose: a developer's laptop
28
+ # measures the same commit two to three times faster than the runner, and
29
+ # comparing that against a runner baseline reports a phantom improvement.
30
+ PROFILE_ENV_VAR = "VICARY_LATENCY_PROFILE"
31
+
32
+ IMPLEMENTATION = "ruby"
33
+
34
+ DEFAULT_TOLERANCE_PCT = 8.0
35
+
36
+ # The gate's answer, and — when it declines — why.
37
+ Comparison = Struct.new(
38
+ :measured_ms, :baseline_ms, :regression_pct, :tolerance_pct,
39
+ :comparable, :reason,
40
+ keyword_init: true
41
+ ) do
42
+ def holds?
43
+ return false unless comparable && !regression_pct.nil?
44
+
45
+ regression_pct <= tolerance_pct
46
+ end
47
+ end
48
+
49
+ class << self
50
+ def baseline_path(dir = nil)
51
+ root = dir || Conformance.directory
52
+ return nil if root.nil?
53
+
54
+ path = Pathname.new(root).join(BASELINE_FILENAME)
55
+ path.exist? ? path : nil
56
+ end
57
+
58
+ def load(dir = nil)
59
+ path = baseline_path(dir)
60
+ return nil if path.nil?
61
+
62
+ JSON.parse(path.read)
63
+ end
64
+
65
+ # `major.minor` of the running Ruby, matching how the profile records it.
66
+ def language_version
67
+ RUBY_VERSION.split(".").first(2).join(".")
68
+ end
69
+
70
+ # Compare +measured_ms+ against the recorded baseline for this port.
71
+ #
72
+ # Every reason below is a refusal to compare, not a failure to measure: the
73
+ # number was measured either way and is reported either way. What is
74
+ # withheld is the verdict, because the two sides would not be like for like.
75
+ def compare(measured_ms, corpus_id, dir: nil, implementation: IMPLEMENTATION,
76
+ observed_language_version: nil, profile_env: nil)
77
+ doc = load(dir)
78
+ tolerance = (doc && doc["tolerance_pct"] || DEFAULT_TOLERANCE_PCT).to_f
79
+ lang = observed_language_version || language_version
80
+
81
+ declined = lambda do |reason, baseline_ms = nil|
82
+ Comparison.new(measured_ms: measured_ms, baseline_ms: baseline_ms,
83
+ regression_pct: nil, tolerance_pct: tolerance,
84
+ comparable: false, reason: reason)
85
+ end
86
+
87
+ return declined.call("no #{BASELINE_FILENAME} in this checkout") if doc.nil?
88
+
89
+ profile = doc["profile"] || {}
90
+ want_profile = profile["id"]
91
+ have_profile = (profile_env || ENV[PROFILE_ENV_VAR] || "").strip
92
+ if have_profile.empty?
93
+ return declined.call(
94
+ "#{PROFILE_ENV_VAR} is unset, so this machine does not claim to be " \
95
+ "#{want_profile.inspect}; the baseline was recorded there"
96
+ )
97
+ end
98
+ unless have_profile == want_profile
99
+ return declined.call(
100
+ "#{PROFILE_ENV_VAR}=#{have_profile.inspect} but the baseline was " \
101
+ "recorded on #{want_profile.inspect}"
102
+ )
103
+ end
104
+
105
+ want_lang = (profile["language_versions"] || {})[implementation]
106
+ if !want_lang.nil? && want_lang.to_s != lang
107
+ return declined.call(
108
+ "#{implementation} #{lang} is not the #{want_lang} the baseline was " \
109
+ "recorded on; interpreter versions differ by more than the bar"
110
+ )
111
+ end
112
+
113
+ want_corpus = doc["corpus"]
114
+ if !want_corpus.nil? && want_corpus != corpus_id
115
+ return declined.call(
116
+ "corpus #{corpus_id.inspect} is not the #{want_corpus.inspect} the " \
117
+ "baseline was recorded on; latency scales with essay length"
118
+ )
119
+ end
120
+
121
+ entry = (doc["implementations"] || {})[implementation] || {}
122
+ recorded = entry["pooled_median_ms"]
123
+ if recorded.nil?
124
+ return declined.call(
125
+ "no baseline recorded for #{implementation} yet — the next release " \
126
+ "records one"
127
+ )
128
+ end
129
+
130
+ recorded = recorded.to_f
131
+ if recorded <= 0
132
+ return declined.call(
133
+ "recorded baseline for #{implementation} is not positive", recorded
134
+ )
135
+ end
136
+
137
+ Comparison.new(
138
+ measured_ms: measured_ms, baseline_ms: recorded,
139
+ regression_pct: (measured_ms / recorded - 1.0) * 100.0,
140
+ tolerance_pct: tolerance, comparable: true, reason: nil
141
+ )
142
+ end
143
+
144
+ def render(comparison)
145
+ c = comparison
146
+ unless c.comparable
147
+ return format("latency %.3f ms — NOT COMPARED against the last release: %s",
148
+ c.measured_ms, c.reason)
149
+ end
150
+
151
+ sign = c.regression_pct >= 0 ? "+" : ""
152
+ format("latency %.3f ms vs %.3f ms at the last release — %s%.2f%% " \
153
+ "against a %d%% bar",
154
+ c.measured_ms, c.baseline_ms, sign, c.regression_pct, c.tolerance_pct)
155
+ end
156
+
157
+ # The keyword arguments Gates.measure wants. Returns the *detail* rather
158
+ # than a value when the comparison was declined, so the gate reports NOT
159
+ # MEASURED with the reason attached instead of quietly passing.
160
+ def gate_fields(measured_ms, corpus_id, **opts)
161
+ c = compare(measured_ms, corpus_id, **opts)
162
+ if c.comparable
163
+ { latency_regression_pct: c.regression_pct }
164
+ else
165
+ { latency_regression_detail: render(c) }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -19,11 +19,12 @@ module Vicary
19
19
  #
20
20
  # **Order is the contract, not an optimisation.** The first pattern to claim a
21
21
  # span wins, and placeholder indices follow mint order, so reordering these
22
- # tables changes the output bytes even when it changes no verdict. Identity
23
- # runs first (an address line can otherwise swallow a surname); EMAIL before
24
- # PHONE; SSN and CARD before the generic digit runs; ZIP and AGE last, because
25
- # both are bare digits and would claim characters belonging to a phone, card or
26
- # address.
22
+ # tables changes the output bytes even when it changes no verdict. EMAIL and URL
23
+ # run first, because a school-issued address and a profile URL *contain* the
24
+ # writer's name and both are anchored too tightly to take one out of prose;
25
+ # identity next (an address line can otherwise swallow a surname); SSN and CARD
26
+ # before the generic digit runs; ZIP and AGE last, because both are bare digits
27
+ # and would claim characters belonging to a phone, card or address.
27
28
  #
28
29
  # ## Regex dialect
29
30
  #
@@ -141,13 +142,34 @@ module Vicary
141
142
  # Date of birth, explicitly labelled.
142
143
  DOB = %r{\b(?:date\s+of\s+birth|d\.?o\.?b\.?|born\s+on)\s*:?\s*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b}i
143
144
 
144
- # (placeholder kind, pattern) in application order.
145
+ # The two structured patterns that run BEFORE identity interpolation, because
146
+ # their match text can legitimately *contain* the writer's own name:
147
+ # `first.last@district.org` and a profile URL ending in a name slug. Identity
148
+ # interpolation is a literal-name substitution, so running it first left these
149
+ # shredded rather than masked — `{NAME_2}.{NAME_1}{USERNAME_1}.k12.oh.us`
150
+ # instead of `{EMAIL_1}`, with the domain tail surviving in the clear and the
151
+ # span unrestorable on the round trip.
152
+ #
153
+ # Putting them first is safe in the direction that matters, and that asymmetry
154
+ # is the whole argument. Both are anchored on structure a name cannot supply —
155
+ # EMAIL needs an `@` and a dotted TLD, URL needs a scheme or a `www.` — so
156
+ # neither can reach into prose and take a bare surname out of it. Patterns
157
+ # that *could* still run after identity.
158
+ STRUCTURED_BEFORE_IDENTITY = [
159
+ ["EMAIL", EMAIL],
160
+ ["URL", URL_PATTERN],
161
+ ].freeze
162
+
163
+ # (placeholder kind, pattern) in application order, running AFTER identity
164
+ # interpolation.
145
165
  #
146
166
  # CARD is handled separately because it needs the Luhn gate; ZIP and AGE run
147
167
  # after it for the reason in the module docstring.
168
+ #
169
+ # Numbering is unaffected by which of these two tables a pattern sits in: the
170
+ # minter counts per kind, so `{EMAIL_1}` is the first email whether emails are
171
+ # matched before or after names.
148
172
  STRUCTURED = [
149
- ["EMAIL", EMAIL],
150
- ["URL", URL_PATTERN],
151
173
  ["US_SOCIAL_SECURITY_NUMBER", SSN],
152
174
  ["IP_ADDRESS", IP],
153
175
  ["PHONE", PHONE],
@@ -182,6 +204,29 @@ module Vicary
182
204
  # Delacroix-Whitfields' house").
183
205
  POSSESSIVE_TAIL = "(?:['’]s|s['’])?"
184
206
 
207
+ # Is this single character a word character? Used to decide whether a literal
208
+ # needs a boundary lookaround on each end.
209
+ #
210
+ # A constant because it was previously built inside {literal_boundaries},
211
+ # which runs five times per redaction: at 50 redactions of one identity that
212
+ # was 250 regex compilations of a pattern that never varies.
213
+ #
214
+ # `Regexp#initialize` was 26% of this port's redaction CPU before this and
215
+ # the identity-pattern cache below; the fold cache in `gazetteer.rb` took the
216
+ # allocation half. Over ten runs of the 25-essay corpus gate, median p50 went
217
+ # 5.79 -> 3.10 ms and median p95 8.46 -> 4.43 ms.
218
+ #
219
+ # The number that mattered is the worst run, not the median: p95 ranged
220
+ # 7.75-11.35 ms before, so the 10 ms latency gate was failing outright about
221
+ # one run in ten and being read as a busy machine. It ranges 4.13-8.63 ms now.
222
+ # Three samples could not see that — the tail is one essay plus a GC pause,
223
+ # and it took ten runs per arm to separate the fix from the noise.
224
+ WORD_CHARACTER = /\A[#{W}]\z/.freeze
225
+
226
+ # How many identities' compiled patterns to keep. Small on purpose: the shape
227
+ # this serves is one student's essays in a row, not a working set.
228
+ IDENTITY_CACHE_MAX = 64
229
+
185
230
  class << self
186
231
  # Luhn checksum. Cuts the card pattern's false positives on long numbers.
187
232
  def luhn_ok?(digits)
@@ -210,10 +255,9 @@ module Vicary
210
255
  # ASCII-only where Python's is Unicode-aware; these agree with Python for
211
256
  # an accented name.
212
257
  def literal_boundaries(literal)
213
- word = /\A[#{W}]\z/
214
258
  [
215
- literal[0].to_s.match?(word) ? "(?<![#{W}])" : "",
216
- literal[-1].to_s.match?(word) ? "(?![#{W}])" : "",
259
+ literal[0].to_s.match?(WORD_CHARACTER) ? "(?<![#{W}])" : "",
260
+ literal[-1].to_s.match?(WORD_CHARACTER) ? "(?![#{W}])" : "",
217
261
  ]
218
262
  end
219
263
 
@@ -242,10 +286,39 @@ module Vicary
242
286
  # of it, so "Jane Quincy-Adams" becomes one `{NAME}` rather than two
243
287
  # adjacent placeholders.
244
288
  def identity_patterns(identity)
245
- out = []
246
289
  first = identity_field(identity, :first_name)
247
290
  last = identity_field(identity, :last_name)
248
291
  school = identity_field(identity, :school_name)
292
+ extras = extra_names(identity).map { |raw| raw.to_s.strip }
293
+
294
+ # Keyed on the field VALUES, never on the identity object: a host that
295
+ # reuses one mutable struct per request would otherwise get the previous
296
+ # student's patterns, which is a privacy failure rather than a stale
297
+ # cache. Two identities with the same fields produce the same patterns by
298
+ # construction, so sharing an entry between them is exact.
299
+ key = [first, last, school, extras].freeze
300
+ cached = @identity_patterns_cache&.[](key)
301
+ return cached if cached
302
+
303
+ patterns = build_identity_patterns(first, last, school, extras)
304
+
305
+ # Bounded, and cleared wholesale rather than evicted one at a time. The
306
+ # win is a batch redacting many essays for ONE student, where the cache
307
+ # holds a single entry; a long-running host cycling through thousands
308
+ # gets the bound instead of a leak, and refilling it costs what building
309
+ # the patterns cost before this existed.
310
+ @identity_patterns_cache ||= {}
311
+ @identity_patterns_cache.clear if @identity_patterns_cache.size >= IDENTITY_CACHE_MAX
312
+ @identity_patterns_cache[key] = patterns
313
+ end
314
+
315
+ # Drop the memoized identity patterns. For tests that measure the build.
316
+ def reset_identity_cache
317
+ @identity_patterns_cache = nil
318
+ end
319
+
320
+ def build_identity_patterns(first, last, school, extras)
321
+ out = []
249
322
 
250
323
  if !first.empty? && !last.empty?
251
324
  out << ["NAME", word_pattern("#{first} #{last}")]
@@ -255,8 +328,7 @@ module Vicary
255
328
  out << ["NAME", word_pattern(last)] if !last.empty? && !AMBIGUOUS_SURNAMES.include?(last.downcase)
256
329
  out << ["NAME", word_pattern(first)] if !first.empty? && !AMBIGUOUS_GIVEN_NAMES.include?(first.downcase)
257
330
 
258
- extra_names(identity).each do |raw|
259
- extra = raw.to_s.strip
331
+ extras.each do |extra|
260
332
  out << ["NAME", word_pattern(extra)] unless extra.empty?
261
333
  end
262
334
 
@@ -286,10 +358,15 @@ module Vicary
286
358
  masked = text
287
359
  n = 0
288
360
 
289
- # Identity patterns run FIRST: a name is the span most likely to be
361
+ # Identity patterns run early: a name is the span most likely to be
290
362
  # partially consumed by a looser pattern (an address line can swallow a
291
363
  # surname), and masking it first makes that impossible.
292
- (identity_patterns(identity) + STRUCTURED).each do |kind, pattern|
364
+ #
365
+ # Early, not first. Email and URL precede it, because those two are the
366
+ # patterns whose own match text contains a name — see
367
+ # STRUCTURED_BEFORE_IDENTITY for why that direction is the safe one.
368
+ patterns = STRUCTURED_BEFORE_IDENTITY + identity_patterns(identity) + STRUCTURED
369
+ patterns.each do |kind, pattern|
293
370
  masked, count = minter.substitute(kind, pattern, masked)
294
371
  n += count
295
372
  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.0"
9
+ VERSION = "0.2.4"
10
10
  end
data/lib/vicary.rb CHANGED
@@ -17,7 +17,7 @@
17
17
  #
18
18
  # Three layers check that claim, and each catches what the one above it cannot:
19
19
  #
20
- # * `rake conformance` scores the 52 frames — the final bar, and a coarse first
20
+ # * `rake conformance` scores the 54 frames — the final bar, and a coarse first
21
21
  # one;
22
22
  # * `rake test` runs `test/primitives_test.rb`, forty-odd primitives over the
23
23
  # shared `primitives.json` corpus, which says *which brick is crooked*;
@@ -38,4 +38,8 @@ require_relative "vicary/minter"
38
38
  require_relative "vicary/structured"
39
39
  require_relative "vicary/candidates"
40
40
  require_relative "vicary/conformance"
41
+ require_relative "vicary/gates"
42
+ require_relative "vicary/census"
43
+ require_relative "vicary/corpus"
44
+ require_relative "vicary/latency_baseline"
41
45
  require_relative "vicary/redact"
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.0
4
+ version: 0.2.4
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-08-11 00:00:00.000000000 Z
11
+ date: 2026-08-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Finds the names a student writes about — classmates, teachers, relatives — and
@@ -29,8 +29,12 @@ files:
29
29
  - lib/vicary.rb
30
30
  - lib/vicary/asset.rb
31
31
  - lib/vicary/candidates.rb
32
+ - lib/vicary/census.rb
32
33
  - lib/vicary/conformance.rb
34
+ - lib/vicary/corpus.rb
35
+ - lib/vicary/gates.rb
33
36
  - lib/vicary/gazetteer.rb
37
+ - lib/vicary/latency_baseline.rb
34
38
  - lib/vicary/lexicon.rb
35
39
  - lib/vicary/minter.rb
36
40
  - lib/vicary/redact.rb
@@ -41,7 +45,6 @@ licenses:
41
45
  - MIT
42
46
  metadata:
43
47
  homepage_uri: https://github.com/bwthomas/vicary
44
- source_code_uri: https://github.com/bwthomas/vicary
45
48
  changelog_uri: https://github.com/bwthomas/vicary/blob/main/CHANGELOG.md
46
49
  bug_tracker_uri: https://github.com/bwthomas/vicary/issues
47
50
  post_install_message: