vicary 0.2.1 → 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
@@ -204,6 +204,29 @@ module Vicary
204
204
  # Delacroix-Whitfields' house").
205
205
  POSSESSIVE_TAIL = "(?:['’]s|s['’])?"
206
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
+
207
230
  class << self
208
231
  # Luhn checksum. Cuts the card pattern's false positives on long numbers.
209
232
  def luhn_ok?(digits)
@@ -232,10 +255,9 @@ module Vicary
232
255
  # ASCII-only where Python's is Unicode-aware; these agree with Python for
233
256
  # an accented name.
234
257
  def literal_boundaries(literal)
235
- word = /\A[#{W}]\z/
236
258
  [
237
- literal[0].to_s.match?(word) ? "(?<![#{W}])" : "",
238
- 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}])" : "",
239
261
  ]
240
262
  end
241
263
 
@@ -264,10 +286,39 @@ module Vicary
264
286
  # of it, so "Jane Quincy-Adams" becomes one `{NAME}` rather than two
265
287
  # adjacent placeholders.
266
288
  def identity_patterns(identity)
267
- out = []
268
289
  first = identity_field(identity, :first_name)
269
290
  last = identity_field(identity, :last_name)
270
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 = []
271
322
 
272
323
  if !first.empty? && !last.empty?
273
324
  out << ["NAME", word_pattern("#{first} #{last}")]
@@ -277,8 +328,7 @@ module Vicary
277
328
  out << ["NAME", word_pattern(last)] if !last.empty? && !AMBIGUOUS_SURNAMES.include?(last.downcase)
278
329
  out << ["NAME", word_pattern(first)] if !first.empty? && !AMBIGUOUS_GIVEN_NAMES.include?(first.downcase)
279
330
 
280
- extra_names(identity).each do |raw|
281
- extra = raw.to_s.strip
331
+ extras.each do |extra|
282
332
  out << ["NAME", word_pattern(extra)] unless extra.empty?
283
333
  end
284
334
 
@@ -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.1"
9
+ VERSION = "0.2.4"
10
10
  end
data/lib/vicary.rb CHANGED
@@ -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.1
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-12 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: