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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 585c77607d6c331c3cebe14a5da44463a0231f7c3a15e5c1ba68f79d8b9f588b
4
- data.tar.gz: 883c07a66721ce9c67fb0cfe1f1ea16ce486decd6be62164b1ebbbf53fec9bf0
3
+ metadata.gz: 537ad53f4299fd2a3cdfc7bceab94b4678bb3a7da01c2e73f5f0c1883d1dad2e
4
+ data.tar.gz: e51c1775be63158b1724739c94ddecb48b406c20a339d1b4b8a0acba1d753e5d
5
5
  SHA512:
6
- metadata.gz: 060ff017a7a0de4ede9652d3e87ddd14083022725ce225739758ce0c7882fe953d6bfaf27ccf48f4fa8b73b480fce98f623dfaf02406944e19e841763b42c022
7
- data.tar.gz: 0b80b3df83f7399b5118d67abbf2e8c1da589b0ec902d7f2cc04a60be8498e41df9ebdbc569af9c57b487025994bfd5715a58b0d0c2a9a6994e06271fa2e2dcd
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`: forty-odd primitives over the shared corpus, which says *which brick* is crooked |
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
- The last three need an essay corpus no package here ships, and this gem measures
51
- them once `VICARY_EVAL_CORPUS_TSV` points at one: 100% carrier recall (29/29
52
- held-out REDACT spans) and 0.60 over-fired spans per essay (15 across 25),
53
- identical to Python and TypeScript, plus its own latency p95 of 8.8–9.9 ms. **That
54
- last one is worth knowing about**: the bar is ≤ 10 ms, and this port runs nearest
55
- it of the three — roughly 4× Python — so the latency gate is a live constraint
56
- here rather than a formality. The carrier essays are built from offsets recorded
57
- in `conformance/carrier.json` rather than from a reimplementation of Python's
58
- RNG, and the suite asserts their sha256.
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.
@@ -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
- # Load the gazetteer before the clock starts. It is a one-time ~207 ms
449
- # cost in this port, and whichever essay happens to be first pays all of
450
- # it: at n=25 that single sample lands at or above p95 and sets the
451
- # gate's answer by itself — 14.3 ms cold against 7.6 ms warm, on a 10 ms
452
- # bar. The number the gate claims is essay-length redaction latency, not
453
- # process startup. Excluded in all three ports alike.
454
- yield(cases.first.base[0, 200], identity) unless cases.empty?
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 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.
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
- # 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.
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
- # This port reaches its own verdict from the shared file. It does not read
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
- 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"
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, :baseline_ms, :regression_pct, :tolerance_pct,
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 baseline_path(dir = nil)
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(BASELINE_FILENAME)
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 = baseline_path(dir)
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
- # `major.minor` of the running Ruby, matching how the profile records it.
66
- def language_version
67
- RUBY_VERSION.split(".").first(2).join(".")
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 +measured_ms+ against the recorded baseline for this port.
127
+ # Compare the pair measured on this machine, for this port.
71
128
  #
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.
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
- 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,
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
- return declined.call("no #{BASELINE_FILENAME} in this checkout") if doc.nil?
145
+ record, why = load_pair(pair_path)
146
+ return declined.call(why || "no paired measurement") if record.nil?
88
147
 
89
- profile = doc["profile"] || {}
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
- "#{PROFILE_ENV_VAR} is unset, so this machine does not claim to be " \
95
- "#{want_profile.inspect}; the baseline was recorded there"
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 have_profile == want_profile
154
+ unless record["implementation"] == implementation
99
155
  return declined.call(
100
- "#{PROFILE_ENV_VAR}=#{have_profile.inspect} but the baseline was " \
101
- "recorded on #{want_profile.inspect}"
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
- "#{implementation} #{lang} is not the #{want_lang} the baseline was " \
109
- "recorded on; interpreter versions differ by more than the bar"
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
- want_corpus = doc["corpus"]
114
- if !want_corpus.nil? && want_corpus != corpus_id
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
- "corpus #{corpus_id.inspect} is not the #{want_corpus.inspect} the " \
117
- "baseline was recorded on; latency scales with essay length"
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
- 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
- )
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
- "recorded baseline for #{implementation} is not positive", recorded
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, baseline_ms: recorded,
139
- regression_pct: (measured_ms / recorded - 1.0) * 100.0,
140
- tolerance_pct: tolerance, comparable: true, reason: nil
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 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)
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
@@ -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
@@ -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.4"
9
+ VERSION = "0.2.6"
10
10
  end
data/lib/vicary.rb CHANGED
@@ -43,3 +43,4 @@ require_relative "vicary/census"
43
43
  require_relative "vicary/corpus"
44
44
  require_relative "vicary/latency_baseline"
45
45
  require_relative "vicary/redact"
46
+ require_relative "vicary/spans"
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
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-08-13 00:00:00.000000000 Z
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