vicary 0.2.1 → 0.2.5
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 +29 -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 +610 -0
- data/lib/vicary/gates.rb +499 -0
- data/lib/vicary/gazetteer.rb +28 -0
- data/lib/vicary/latency_baseline.rb +228 -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,228 @@
|
|
|
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 gate has asked this three ways. The first two are worth keeping in view,
|
|
10
|
+
# because each looked correct until it decided a release.
|
|
11
|
+
#
|
|
12
|
+
# **An absolute bar — 10 ms.** A claim about the machine as much as about the
|
|
13
|
+
# code. It passed on a laptop and failed on the CI runner enforcing it, so
|
|
14
|
+
# v0.2.3 published to PyPI and npm and was refused by RubyGems on the same
|
|
15
|
+
# commit. This gem is the one that caught it.
|
|
16
|
+
#
|
|
17
|
+
# **A stored baseline** — record each release's number and compare the next run
|
|
18
|
+
# against it, refusing unless the run claims the profile the baseline was
|
|
19
|
+
# recorded on. Better, and still wrong, for a reason no estimator fixes: the
|
|
20
|
+
# profile `github-ubuntu-latest` is not a machine. Thirty-six processes across
|
|
21
|
+
# six runners per port, on identical code, spread 67% in THIS port — 6.53 ms on
|
|
22
|
+
# an Intel Xeon 6973P-C against 10.63 ms on an EPYC 7763 — 26% in Python and
|
|
23
|
+
# 21% in TypeScript, against an 8% bar. One probe run drew five CPU models from
|
|
24
|
+
# that one label, and two runners of the same model still differed by 26%.
|
|
25
|
+
#
|
|
26
|
+
# **A pair, measured here.** The previous release's code and this checkout,
|
|
27
|
+
# measured on the SAME machine, interleaved and counterbalanced, by
|
|
28
|
+
# `tools/latency_pair.py`. Every property of the machine is common to both
|
|
29
|
+
# sides and cancels; what is left is within-process noise, 1.7% in this port.
|
|
30
|
+
#
|
|
31
|
+
# Which leaves this module the job it has always had: REFUSING to compare when
|
|
32
|
+
# the two sides would not be like for like. What changed is that the refusals
|
|
33
|
+
# are about the pair record — is there one, is it this port's, was it measured
|
|
34
|
+
# on these essays, was it measured for this commit — rather than about the
|
|
35
|
+
# profile of a machine somewhere else.
|
|
36
|
+
#
|
|
37
|
+
# This port reaches its own verdict from the shared record. It does not read
|
|
38
|
+
# Python's answer.
|
|
39
|
+
module LatencyBaseline
|
|
40
|
+
# The tolerance and the protocol, in the repository. Not a measurement:
|
|
41
|
+
# nothing is recorded at release time any more, because the comparison point
|
|
42
|
+
# is the previous release's *code*, which the repository already has.
|
|
43
|
+
SPEC_FILENAME = "latency_baseline.json"
|
|
44
|
+
|
|
45
|
+
# Where `tools/latency_pair.py` left the paired measurement. Set by CI in the
|
|
46
|
+
# same job, seconds before the gate runs. Absent on a laptop unless the
|
|
47
|
+
# harness was run there by hand, and that absence is a refusal to compare
|
|
48
|
+
# rather than a pass — measuring one side of a comparison is not a gate.
|
|
49
|
+
PAIR_ENV_VAR = "VICARY_LATENCY_PAIR"
|
|
50
|
+
|
|
51
|
+
# What this reader understands. A record from a future shape is refused
|
|
52
|
+
# rather than half-read: a partly-understood record still yields a number,
|
|
53
|
+
# and a number is exactly what must not be invented here.
|
|
54
|
+
PAIR_DOCUMENT_VERSION = 1
|
|
55
|
+
|
|
56
|
+
IMPLEMENTATION = "ruby"
|
|
57
|
+
|
|
58
|
+
# The bar, chosen rather than derived — 8% is what a reviewer is willing to
|
|
59
|
+
# call a regression. What the noise decides is whether the bar is USABLE,
|
|
60
|
+
# and it is: the gate statistic holds **sigma 0.46%** in this port (95% CI
|
|
61
|
+
# 0.34-0.72%, sixteen runs across eight CI runners, fixed head and tag),
|
|
62
|
+
# putting 8% at 17.2 sigma — the widest margin of the three. It was about a
|
|
63
|
+
# third of a sigma under the stored baseline, which is how that one red-lit
|
|
64
|
+
# `main` on unchanged code, and how it refused this port's 0.2.3 while the
|
|
65
|
+
# other two took the same commit.
|
|
66
|
+
#
|
|
67
|
+
# This is also the port where the pairing earns the most. Across three CPU
|
|
68
|
+
# models its absolute figure spreads **31.8%** — the same axis, and nearly
|
|
69
|
+
# the same size, as the 67% that killed the stored baseline — while its ratio
|
|
70
|
+
# spreads 0.36 pp. Same runs, same data. See `tools/latency_pair.py`.
|
|
71
|
+
#
|
|
72
|
+
# It does not catch drift: +5% a release passes every time and compounds.
|
|
73
|
+
# That is deliberate — this gate is for the step change, not the trend.
|
|
74
|
+
DEFAULT_TOLERANCE_PCT = 8.0
|
|
75
|
+
|
|
76
|
+
# The gate's answer, and — when it declines — why.
|
|
77
|
+
Comparison = Struct.new(
|
|
78
|
+
:measured_ms, :previous_ms, :current_ms, :regression_pct, :tolerance_pct,
|
|
79
|
+
:against, :comparable, :reason,
|
|
80
|
+
keyword_init: true
|
|
81
|
+
) do
|
|
82
|
+
def holds?
|
|
83
|
+
return false unless comparable && !regression_pct.nil?
|
|
84
|
+
|
|
85
|
+
regression_pct <= tolerance_pct
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class << self
|
|
90
|
+
def spec_path(dir = nil)
|
|
91
|
+
root = dir || Conformance.directory
|
|
92
|
+
return nil if root.nil?
|
|
93
|
+
|
|
94
|
+
path = Pathname.new(root).join(SPEC_FILENAME)
|
|
95
|
+
path.exist? ? path : nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def load(dir = nil)
|
|
99
|
+
path = spec_path(dir)
|
|
100
|
+
return nil if path.nil?
|
|
101
|
+
|
|
102
|
+
JSON.parse(path.read)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The paired measurement, or why there is none to read.
|
|
106
|
+
#
|
|
107
|
+
# An unreadable file and an absent one stay distinguishable: the first is a
|
|
108
|
+
# broken harness and the second is an ordinary laptop, and they should not
|
|
109
|
+
# report the same thing.
|
|
110
|
+
def load_pair(path = nil)
|
|
111
|
+
given = (path || ENV[PAIR_ENV_VAR] || "").strip
|
|
112
|
+
if given.empty?
|
|
113
|
+
return [nil,
|
|
114
|
+
"#{PAIR_ENV_VAR} is unset, so no paired measurement was taken on " \
|
|
115
|
+
"this machine; the gate compares this build against the last " \
|
|
116
|
+
"release measured HERE, and one side of a comparison is not a gate"]
|
|
117
|
+
end
|
|
118
|
+
return [nil, "#{PAIR_ENV_VAR}=#{given.inspect} does not exist"] unless File.exist?(given)
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
[JSON.parse(File.read(given)), nil]
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
[nil, "the pair record at #{given} could not be read: #{e.message}"]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Compare the pair measured on this machine, for this port.
|
|
128
|
+
#
|
|
129
|
+
# +measured_ms+ is this process's own figure. It is reported either way and
|
|
130
|
+
# it is never the verdict: the verdict comes from the two numbers in the
|
|
131
|
+
# pair record, taken back to back on one machine. Mixing this process's
|
|
132
|
+
# measurement with the pair's other side would reintroduce exactly the
|
|
133
|
+
# machine difference the pair exists to cancel.
|
|
134
|
+
def compare(measured_ms, corpus_id, dir: nil, implementation: IMPLEMENTATION,
|
|
135
|
+
pair_path: nil, building_sha: nil)
|
|
136
|
+
doc = load(dir) || {}
|
|
137
|
+
tolerance = (doc["tolerance_pct"] || DEFAULT_TOLERANCE_PCT).to_f
|
|
138
|
+
|
|
139
|
+
declined = lambda do |reason|
|
|
140
|
+
Comparison.new(measured_ms: measured_ms, previous_ms: nil, current_ms: nil,
|
|
141
|
+
regression_pct: nil, tolerance_pct: tolerance, against: nil,
|
|
142
|
+
comparable: false, reason: reason)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
record, why = load_pair(pair_path)
|
|
146
|
+
return declined.call(why || "no paired measurement") if record.nil?
|
|
147
|
+
|
|
148
|
+
unless record["document_version"] == PAIR_DOCUMENT_VERSION
|
|
149
|
+
return declined.call(
|
|
150
|
+
"the pair record is document_version #{record['document_version']} " \
|
|
151
|
+
"and this reader knows #{PAIR_DOCUMENT_VERSION}"
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
unless record["implementation"] == implementation
|
|
155
|
+
return declined.call(
|
|
156
|
+
"the pair record measures #{record['implementation'].inspect}, " \
|
|
157
|
+
"not #{implementation.inspect}"
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
unless record["corpus"] == corpus_id
|
|
161
|
+
return declined.call(
|
|
162
|
+
"the pair was measured on corpus #{record['corpus'].inspect} and this " \
|
|
163
|
+
"run is #{corpus_id.inspect}; latency scales with essay length"
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Only where there is something to check against. `GITHUB_SHA` names the
|
|
168
|
+
# commit the job is building, so a record left over from an earlier
|
|
169
|
+
# commit is caught here rather than being read as this build's verdict.
|
|
170
|
+
# Locally there is no such witness and no such risk: the harness is run
|
|
171
|
+
# by hand, minutes before, on the tree in front of you.
|
|
172
|
+
building = (building_sha || ENV["GITHUB_SHA"] || "").strip
|
|
173
|
+
head = record["head_sha"].to_s
|
|
174
|
+
if !building.empty? && !head.empty? && building != head
|
|
175
|
+
return declined.call(
|
|
176
|
+
"the pair was measured for commit #{head[0, 12]} and this job is " \
|
|
177
|
+
"building #{building[0, 12]}; the record is stale"
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
previous = record["previous_ms"]
|
|
182
|
+
current = record["current_ms"]
|
|
183
|
+
unless previous.is_a?(Numeric) && current.is_a?(Numeric)
|
|
184
|
+
return declined.call("the pair record carries no pair of measurements")
|
|
185
|
+
end
|
|
186
|
+
if previous <= 0
|
|
187
|
+
return declined.call(
|
|
188
|
+
"the previous release measured #{previous} ms, which is not positive"
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
Comparison.new(
|
|
193
|
+
measured_ms: measured_ms, previous_ms: previous.to_f,
|
|
194
|
+
current_ms: current.to_f,
|
|
195
|
+
regression_pct: (current.to_f / previous.to_f - 1.0) * 100.0,
|
|
196
|
+
tolerance_pct: tolerance, against: (record["against"] || {})["ref"],
|
|
197
|
+
comparable: true, reason: nil
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def render(comparison)
|
|
202
|
+
c = comparison
|
|
203
|
+
unless c.comparable
|
|
204
|
+
return format("latency %.3f ms — NOT COMPARED against the last release: %s",
|
|
205
|
+
c.measured_ms, c.reason)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
sign = c.regression_pct >= 0 ? "+" : ""
|
|
209
|
+
format("latency %.3f ms here; paired on this machine, %.3f ms against " \
|
|
210
|
+
"%s's %.3f ms — %s%.2f%% against a %d%% bar",
|
|
211
|
+
c.measured_ms, c.current_ms, c.against || "the last release",
|
|
212
|
+
c.previous_ms, sign, c.regression_pct, c.tolerance_pct)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# The keyword arguments Gates.measure wants. Returns the *detail* rather
|
|
216
|
+
# than a value when the comparison was declined, so the gate reports NOT
|
|
217
|
+
# MEASURED with the reason attached instead of quietly passing.
|
|
218
|
+
def gate_fields(measured_ms, corpus_id, **opts)
|
|
219
|
+
c = compare(measured_ms, corpus_id, **opts)
|
|
220
|
+
if c.comparable
|
|
221
|
+
{ latency_regression_pct: c.regression_pct }
|
|
222
|
+
else
|
|
223
|
+
{ latency_regression_detail: render(c) }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
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.5"
|
|
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.5
|
|
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:
|