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.
- checksums.yaml +4 -4
- data/README.md +21 -0
- data/lib/vicary/candidates.rb +86 -5
- data/lib/vicary/census.rb +285 -0
- data/lib/vicary/conformance.rb +32 -8
- data/lib/vicary/corpus.rb +600 -0
- data/lib/vicary/gates.rb +499 -0
- data/lib/vicary/gazetteer.rb +28 -0
- data/lib/vicary/latency_baseline.rb +170 -0
- data/lib/vicary/structured.rb +56 -6
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +4 -0
- metadata +6 -3
|
@@ -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
|
data/lib/vicary/structured.rb
CHANGED
|
@@ -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?(
|
|
238
|
-
literal[-1].to_s.match?(
|
|
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
|
-
|
|
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
|
|
data/lib/vicary/version.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
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:
|