vicary 0.2.4 → 0.2.6
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 +31 -10
- data/lib/vicary/conformance.rb +26 -0
- data/lib/vicary/corpus.rb +17 -7
- data/lib/vicary/latency_baseline.rb +132 -74
- data/lib/vicary/spans.rb +129 -0
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 537ad53f4299fd2a3cdfc7bceab94b4678bb3a7da01c2e73f5f0c1883d1dad2e
|
|
4
|
+
data.tar.gz: e51c1775be63158b1724739c94ddecb48b406c20a339d1b4b8a0acba1d753e5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d406808fe2dfc71599bb85516237fe3272876bd2302e3d794c12fb2d296fb2e4f65ea9906be0a8fe1f7deaaf1afab7cb3e6fa3c15dbe5f77f68c861b5f70ad39
|
|
7
|
+
data.tar.gz: 70e6fbef81ab903425190feda6683d3343604b980da6033f0ad7471816f4c2887eaf8408637dc1092e394e60162b877a1e5de6aa1a91516ba4c5d161bcd0b116
|
data/README.md
CHANGED
|
@@ -23,8 +23,21 @@ Vicary.redact("My cousin Terrence Okonkwo came over that summer.", identity)
|
|
|
23
23
|
|
|
24
24
|
masked, n, restore_map = Vicary.redact_with_report(essay, identity)
|
|
25
25
|
Vicary.restore(masked, restore_map) == essay # => true
|
|
26
|
+
|
|
27
|
+
# Where each replacement came from, for a host that draws on the essay: a
|
|
28
|
+
# placeholder is not the width of the name it replaced, so an offset measured
|
|
29
|
+
# against `masked` is displaced against the composition the student holds.
|
|
30
|
+
spans = Vicary::Spans.derive(masked, restore_map)
|
|
31
|
+
Vicary::Spans.to_original(offset, spans) # masked coordinates -> the student's
|
|
32
|
+
Vicary::Spans.to_redacted(offset, spans) # and back
|
|
33
|
+
Vicary::Spans.original(masked, restore_map) == essay # => true
|
|
26
34
|
```
|
|
27
35
|
|
|
36
|
+
An offset landing inside a placeholder resolves to the start of the span it
|
|
37
|
+
replaced. A map that is absent, or that covers only some of the placeholders in
|
|
38
|
+
the text, yields **no** spans and translates as the identity — a caller can
|
|
39
|
+
handle "no spans" and cannot detect a wrong offset.
|
|
40
|
+
|
|
28
41
|
## Checking it
|
|
29
42
|
|
|
30
43
|
Three layers, because each catches what the one above it cannot.
|
|
@@ -33,7 +46,7 @@ Three layers, because each catches what the one above it cannot.
|
|
|
33
46
|
|---|---|
|
|
34
47
|
| `rake conformance` | the scoreboard against the 54 frames — the final bar, and a coarse first one |
|
|
35
48
|
| `rake gates` | the nine gates, all nine measured from what the repository ships |
|
|
36
|
-
| `rake test` | the unit suites, including `primitives_test.rb
|
|
49
|
+
| `rake test` | the unit suites, including `primitives_test.rb` (forty-odd primitives over the shared corpus, which says *which brick* is crooked) and `spans_test.rb` (the offset arithmetic over the shared `spans.json`) |
|
|
37
50
|
| `rake parity` | gazetteer verdicts, name by name, against the Python reference |
|
|
38
51
|
| `rake redaction_parity` | masked bytes against the Python reference, on prose no fixture contains |
|
|
39
52
|
|
|
@@ -47,15 +60,23 @@ zip reader and a binary read parsed as CSV yields zero rows — a *lower* exposu
|
|
|
47
60
|
than the truth, and the wrong direction to fail in silently. The shipped table is
|
|
48
61
|
gzip, which `zlib` reads, so that hazard does not arise on the default path.
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
identical to Python and TypeScript
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
Two of the last three read the corpus the repository now ships in
|
|
64
|
+
`conformance/corpora/`, so they measure on a bare checkout with no environment
|
|
65
|
+
set: 100% carrier recall and 8.150 over-fired spans per essay against a ≤ 8.15
|
|
66
|
+
bar, identical to Python and TypeScript. `VICARY_EVAL_CORPUS_TSV` is an override
|
|
67
|
+
for a different corpus, not a requirement.
|
|
68
|
+
|
|
69
|
+
The ninth is latency, and **this port's absolute figure no longer constrains it**.
|
|
70
|
+
The gate was a ≤ 10 ms bar, which this port ran nearest of the three; it is now a
|
|
71
|
+
ratio against the last release timed on the same machine, held to ≤ +8%. That
|
|
72
|
+
change matters most here: across three CPU models this port's absolute median
|
|
73
|
+
spreads **31.8%**, the same axis that made the old bar a coin flip, while its
|
|
74
|
+
ratio spreads 0.36 pp. Measured, the ratio holds σ 0.46% — the *widest* margin of
|
|
75
|
+
the three ports, where the absolute figure gave it the narrowest.
|
|
76
|
+
|
|
77
|
+
The carrier essays are built from offsets recorded in `conformance/carrier.json`
|
|
78
|
+
rather than from a reimplementation of Python's RNG, and the suite asserts their
|
|
79
|
+
sha256.
|
|
59
80
|
|
|
60
81
|
The last two need the reference interpreter — run `just py-setup` from the
|
|
61
82
|
repository root first.
|
data/lib/vicary/conformance.rb
CHANGED
|
@@ -155,6 +155,32 @@ module Vicary
|
|
|
155
155
|
raw
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# The offset-translation spec — {Vicary::Spans}' cases and their answers.
|
|
159
|
+
#
|
|
160
|
+
# A third layer, checking a third thing. `frames.json` says what is masked
|
|
161
|
+
# and `primitives.json` says which rule decided it; neither constrains
|
|
162
|
+
# WHERE a replacement came from, and a port can reproduce every byte of
|
|
163
|
+
# both while being unable to tell a host where to draw a highlight on the
|
|
164
|
+
# student's own essay.
|
|
165
|
+
#
|
|
166
|
+
# Absent is an error rather than an empty document, for the same reason
|
|
167
|
+
# `just _conformance-check` refuses to run without a spec: a port that
|
|
168
|
+
# finds no cases and reports success has checked its arithmetic against
|
|
169
|
+
# nothing.
|
|
170
|
+
def load_spans(dir = nil)
|
|
171
|
+
dir = Pathname.new(dir || directory)
|
|
172
|
+
path = dir.join("spans.json")
|
|
173
|
+
unless path.file?
|
|
174
|
+
raise SpecError,
|
|
175
|
+
"no spans.json at #{path}. The ports would check their offset " \
|
|
176
|
+
"translation against nothing."
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raw = JSON.parse(path.read)
|
|
180
|
+
require_version(raw["document_version"], "spans.json")
|
|
181
|
+
raw
|
|
182
|
+
end
|
|
183
|
+
|
|
158
184
|
# Score an implementation against every frame.
|
|
159
185
|
#
|
|
160
186
|
# The block receives (sentence, identity) — the same input every Python arm
|
data/lib/vicary/corpus.rb
CHANGED
|
@@ -445,13 +445,23 @@ module Vicary
|
|
|
445
445
|
over_fire = 0
|
|
446
446
|
rewrites = 0
|
|
447
447
|
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
|
|
448
|
+
# Warm up before the clock starts, over the WHOLE corpus rather than one
|
|
449
|
+
# 200-char call. Two costs are being excluded, and the second one is why
|
|
450
|
+
# this grew.
|
|
451
|
+
#
|
|
452
|
+
# The gazetteer load is a one-time ~207 ms cost in this port, and
|
|
453
|
+
# whichever essay happens to be first pays all of it — 14.3 ms cold
|
|
454
|
+
# against 7.6 ms warm.
|
|
455
|
+
#
|
|
456
|
+
# The second belongs to TypeScript, where V8 tiers the redaction path up
|
|
457
|
+
# over roughly the first four essays and runs them at about twice their
|
|
458
|
+
# steady-state cost. This port barely moves under a full warmup and does
|
|
459
|
+
# it anyway: the three ports measure identically or the gate is three
|
|
460
|
+
# different gates.
|
|
461
|
+
cases.each do |kase|
|
|
462
|
+
yield(kase.text, identity)
|
|
463
|
+
yield(kase.base, identity)
|
|
464
|
+
end
|
|
455
465
|
|
|
456
466
|
cases.each do |kase|
|
|
457
467
|
# The median of LATENCY_REPEATS, not one sample — see that constant.
|
|
@@ -6,37 +6,77 @@ require "pathname"
|
|
|
6
6
|
module Vicary
|
|
7
7
|
# Is this build slower than the last release, and is that a fair question here?
|
|
8
8
|
#
|
|
9
|
-
# The
|
|
10
|
-
#
|
|
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.
|
|
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.
|
|
13
11
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# code regression is worse than no gate, because it trains the reader to ignore
|
|
19
|
-
# it.
|
|
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.
|
|
20
16
|
#
|
|
21
|
-
#
|
|
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
|
|
22
38
|
# Python's answer.
|
|
23
39
|
module LatencyBaseline
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
#
|
|
30
|
-
|
|
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
|
|
31
55
|
|
|
32
56
|
IMPLEMENTATION = "ruby"
|
|
33
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.
|
|
34
74
|
DEFAULT_TOLERANCE_PCT = 8.0
|
|
35
75
|
|
|
36
76
|
# The gate's answer, and — when it declines — why.
|
|
37
77
|
Comparison = Struct.new(
|
|
38
|
-
:measured_ms, :
|
|
39
|
-
:comparable, :reason,
|
|
78
|
+
:measured_ms, :previous_ms, :current_ms, :regression_pct, :tolerance_pct,
|
|
79
|
+
:against, :comparable, :reason,
|
|
40
80
|
keyword_init: true
|
|
41
81
|
) do
|
|
42
82
|
def holds?
|
|
@@ -47,97 +87,114 @@ module Vicary
|
|
|
47
87
|
end
|
|
48
88
|
|
|
49
89
|
class << self
|
|
50
|
-
def
|
|
90
|
+
def spec_path(dir = nil)
|
|
51
91
|
root = dir || Conformance.directory
|
|
52
92
|
return nil if root.nil?
|
|
53
93
|
|
|
54
|
-
path = Pathname.new(root).join(
|
|
94
|
+
path = Pathname.new(root).join(SPEC_FILENAME)
|
|
55
95
|
path.exist? ? path : nil
|
|
56
96
|
end
|
|
57
97
|
|
|
58
98
|
def load(dir = nil)
|
|
59
|
-
path =
|
|
99
|
+
path = spec_path(dir)
|
|
60
100
|
return nil if path.nil?
|
|
61
101
|
|
|
62
102
|
JSON.parse(path.read)
|
|
63
103
|
end
|
|
64
104
|
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
68
125
|
end
|
|
69
126
|
|
|
70
|
-
# Compare
|
|
127
|
+
# Compare the pair measured on this machine, for this port.
|
|
71
128
|
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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.
|
|
75
134
|
def compare(measured_ms, corpus_id, dir: nil, implementation: IMPLEMENTATION,
|
|
76
|
-
|
|
77
|
-
doc = load(dir)
|
|
78
|
-
tolerance = (doc
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
regression_pct: nil, tolerance_pct: tolerance,
|
|
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,
|
|
84
142
|
comparable: false, reason: reason)
|
|
85
143
|
end
|
|
86
144
|
|
|
87
|
-
|
|
145
|
+
record, why = load_pair(pair_path)
|
|
146
|
+
return declined.call(why || "no paired measurement") if record.nil?
|
|
88
147
|
|
|
89
|
-
|
|
90
|
-
want_profile = profile["id"]
|
|
91
|
-
have_profile = (profile_env || ENV[PROFILE_ENV_VAR] || "").strip
|
|
92
|
-
if have_profile.empty?
|
|
148
|
+
unless record["document_version"] == PAIR_DOCUMENT_VERSION
|
|
93
149
|
return declined.call(
|
|
94
|
-
"
|
|
95
|
-
"
|
|
150
|
+
"the pair record is document_version #{record['document_version']} " \
|
|
151
|
+
"and this reader knows #{PAIR_DOCUMENT_VERSION}"
|
|
96
152
|
)
|
|
97
153
|
end
|
|
98
|
-
unless
|
|
154
|
+
unless record["implementation"] == implementation
|
|
99
155
|
return declined.call(
|
|
100
|
-
"#{
|
|
101
|
-
"
|
|
156
|
+
"the pair record measures #{record['implementation'].inspect}, " \
|
|
157
|
+
"not #{implementation.inspect}"
|
|
102
158
|
)
|
|
103
159
|
end
|
|
104
|
-
|
|
105
|
-
want_lang = (profile["language_versions"] || {})[implementation]
|
|
106
|
-
if !want_lang.nil? && want_lang.to_s != lang
|
|
160
|
+
unless record["corpus"] == corpus_id
|
|
107
161
|
return declined.call(
|
|
108
|
-
"
|
|
109
|
-
"
|
|
162
|
+
"the pair was measured on corpus #{record['corpus'].inspect} and this " \
|
|
163
|
+
"run is #{corpus_id.inspect}; latency scales with essay length"
|
|
110
164
|
)
|
|
111
165
|
end
|
|
112
166
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
115
175
|
return declined.call(
|
|
116
|
-
"
|
|
117
|
-
"
|
|
176
|
+
"the pair was measured for commit #{head[0, 12]} and this job is " \
|
|
177
|
+
"building #{building[0, 12]}; the record is stale"
|
|
118
178
|
)
|
|
119
179
|
end
|
|
120
180
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return declined.call(
|
|
125
|
-
"no baseline recorded for #{implementation} yet — the next release " \
|
|
126
|
-
"records one"
|
|
127
|
-
)
|
|
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")
|
|
128
185
|
end
|
|
129
|
-
|
|
130
|
-
recorded = recorded.to_f
|
|
131
|
-
if recorded <= 0
|
|
186
|
+
if previous <= 0
|
|
132
187
|
return declined.call(
|
|
133
|
-
"
|
|
188
|
+
"the previous release measured #{previous} ms, which is not positive"
|
|
134
189
|
)
|
|
135
190
|
end
|
|
136
191
|
|
|
137
192
|
Comparison.new(
|
|
138
|
-
measured_ms: measured_ms,
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
141
198
|
)
|
|
142
199
|
end
|
|
143
200
|
|
|
@@ -149,9 +206,10 @@ module Vicary
|
|
|
149
206
|
end
|
|
150
207
|
|
|
151
208
|
sign = c.regression_pct >= 0 ? "+" : ""
|
|
152
|
-
format("latency %.3f ms
|
|
153
|
-
"against a %d%% bar",
|
|
154
|
-
c.measured_ms, c.
|
|
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)
|
|
155
213
|
end
|
|
156
214
|
|
|
157
215
|
# The keyword arguments Gates.measure wants. Returns the *detail* rather
|
data/lib/vicary/spans.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vicary
|
|
4
|
+
# One replacement, located in both the original and the masked text.
|
|
5
|
+
#
|
|
6
|
+
# `orig_start...orig_end` is what was removed; `new_start...new_end` is the
|
|
7
|
+
# placeholder standing in its place. The two widths differ — that difference
|
|
8
|
+
# is the whole reason this type exists.
|
|
9
|
+
RedactionSpan = Struct.new(:orig_start, :orig_end, :new_start, :new_end) do
|
|
10
|
+
# How much this replacement moved everything after it.
|
|
11
|
+
def delta
|
|
12
|
+
(new_end - new_start) - (orig_end - orig_start)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{ orig_start: orig_start, orig_end: orig_end,
|
|
17
|
+
new_start: new_start, new_end: new_end }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Offset translation between the masked text and the composition the student
|
|
22
|
+
# holds — the Ruby port of `python/src/vicary/redaction.py`'s span layer.
|
|
23
|
+
#
|
|
24
|
+
# Why a host needs it. `redact_with_report` hands back masked bytes and a
|
|
25
|
+
# restore map, and between them they still say nothing about WHERE anything
|
|
26
|
+
# sits. A host rendering a highlight works in the student's coordinates, the
|
|
27
|
+
# detector produced its text in masked coordinates, and `{NAME_1}` is not the
|
|
28
|
+
# width of the name it replaced: every offset after the first replacement is
|
|
29
|
+
# displaced by the cumulative delta. Measured on a 56-paper corpus, redaction
|
|
30
|
+
# fired on 43 essays and not one recorded offset pair on those 43 landed on
|
|
31
|
+
# the original text.
|
|
32
|
+
#
|
|
33
|
+
# **Nothing in the masker records anything for this to work.** That is the
|
|
34
|
+
# point, and it is why this is a pure module rather than instrumentation:
|
|
35
|
+
# {Vicary::Candidates.mask_candidates} is many passes over a string each pass
|
|
36
|
+
# mutates, so a span recorded inside one pass is in that pass's intermediate
|
|
37
|
+
# coordinates and would have to be composed forward through every later one.
|
|
38
|
+
# It is unnecessary — the finished text still *contains* every placeholder, so
|
|
39
|
+
# each replacement's new span is where its placeholder sits, and the restore
|
|
40
|
+
# map gives the original, so its width is a `length`.
|
|
41
|
+
#
|
|
42
|
+
# Checked against `conformance/spans.json`, generated from the Python
|
|
43
|
+
# reference, by `test/spans_test.rb`. The arithmetic is shared with the other
|
|
44
|
+
# two ports and pinned there rather than described here.
|
|
45
|
+
module Spans
|
|
46
|
+
# A placeholder token as it appears in masked text, e.g. `{NAME_1}`.
|
|
47
|
+
PLACEHOLDER = /\{[A-Z_]+(?:_\d+)?\}/.freeze
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# The span map, derived from the masked text and the restore map alone.
|
|
51
|
+
#
|
|
52
|
+
# Replacement preserves order, so one left-to-right walk accumulating the
|
|
53
|
+
# running delta recovers the original offsets exactly.
|
|
54
|
+
#
|
|
55
|
+
# Returns `[]` when `restore_map` is empty or does not cover a placeholder
|
|
56
|
+
# present in the text. A partial map is refused rather than answered for
|
|
57
|
+
# the half it can place, because a translation that is right for some
|
|
58
|
+
# offsets and silently wrong for others is worse than one that declines:
|
|
59
|
+
# the caller can handle "no map" and cannot detect "wrong offset".
|
|
60
|
+
def derive(masked, restore_map)
|
|
61
|
+
return [] if restore_map.nil? || restore_map.empty?
|
|
62
|
+
|
|
63
|
+
spans = []
|
|
64
|
+
delta = 0
|
|
65
|
+
masked.to_s.enum_for(:scan, PLACEHOLDER).each do
|
|
66
|
+
match = Regexp.last_match
|
|
67
|
+
original = restore_map[match[0]]
|
|
68
|
+
return [] if original.nil?
|
|
69
|
+
|
|
70
|
+
orig_start = match.begin(0) - delta
|
|
71
|
+
span = RedactionSpan.new(orig_start, orig_start + original.length,
|
|
72
|
+
match.begin(0), match.end(0))
|
|
73
|
+
spans << span
|
|
74
|
+
delta += span.delta
|
|
75
|
+
end
|
|
76
|
+
spans
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A redacted-text offset in original coordinates.
|
|
80
|
+
#
|
|
81
|
+
# An offset landing INSIDE a placeholder maps to the start of the span it
|
|
82
|
+
# replaced: the placeholder's interior has no counterpart in the original,
|
|
83
|
+
# so any position within it is the same position in original terms.
|
|
84
|
+
def to_original(offset, spans)
|
|
85
|
+
result = offset
|
|
86
|
+
spans.each do |span|
|
|
87
|
+
break if offset < span.new_start
|
|
88
|
+
return span.orig_start if offset < span.new_end
|
|
89
|
+
|
|
90
|
+
result = span.orig_end + (offset - span.new_end)
|
|
91
|
+
end
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# An original-text offset in redacted coordinates.
|
|
96
|
+
#
|
|
97
|
+
# An offset inside a replaced span maps to the start of its placeholder,
|
|
98
|
+
# for the mirror-image reason.
|
|
99
|
+
def to_redacted(offset, spans)
|
|
100
|
+
result = offset
|
|
101
|
+
spans.each do |span|
|
|
102
|
+
break if offset < span.orig_start
|
|
103
|
+
return span.new_start if offset < span.orig_end
|
|
104
|
+
|
|
105
|
+
result = span.new_end + (offset - span.orig_end)
|
|
106
|
+
end
|
|
107
|
+
result
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Reconstruct the pre-redaction text from the masked bytes and the map.
|
|
111
|
+
#
|
|
112
|
+
# Exact where a span map exists, and the masked text unchanged where one
|
|
113
|
+
# does not — which is the honest answer rather than a partial restoration,
|
|
114
|
+
# for the reason {derive} declines.
|
|
115
|
+
def original(masked, restore_map)
|
|
116
|
+
masked = masked.to_s
|
|
117
|
+
out = +""
|
|
118
|
+
prev = 0
|
|
119
|
+
derive(masked, restore_map).each do |span|
|
|
120
|
+
out << masked[prev...span.new_start]
|
|
121
|
+
out << restore_map[masked[span.new_start...span.new_end]]
|
|
122
|
+
prev = span.new_end
|
|
123
|
+
end
|
|
124
|
+
out << masked[prev..].to_s
|
|
125
|
+
out
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
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.6"
|
|
10
10
|
end
|
data/lib/vicary.rb
CHANGED
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.6
|
|
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-
|
|
11
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Finds the names a student writes about — classmates, teachers, relatives — and
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- lib/vicary/lexicon.rb
|
|
39
39
|
- lib/vicary/minter.rb
|
|
40
40
|
- lib/vicary/redact.rb
|
|
41
|
+
- lib/vicary/spans.rb
|
|
41
42
|
- lib/vicary/structured.rb
|
|
42
43
|
- lib/vicary/version.rb
|
|
43
44
|
homepage: https://github.com/bwthomas/vicary
|