vicary 0.2.5 → 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: 86411ff6192dfe36ba02d8159d4000bc55814d5eb8dd782713f373199d0bef98
4
- data.tar.gz: 85ad752a1663010614adec4820efb015e0ee9b63ad74bf048f8869760f438246
3
+ metadata.gz: 537ad53f4299fd2a3cdfc7bceab94b4678bb3a7da01c2e73f5f0c1883d1dad2e
4
+ data.tar.gz: e51c1775be63158b1724739c94ddecb48b406c20a339d1b4b8a0acba1d753e5d
5
5
  SHA512:
6
- metadata.gz: c2197ab3c0b5759a94bd8027e8c4096c886b3186e37be9cd06e562710db94677450cce228c3a9c106156a43857591a1c0c18fd3dd8eddba617c644fa6f3bee6c
7
- data.tar.gz: c1a2612a43896411f0462732d37641fb864fb84dc48eef2c81d96e182295bb69c0b4ad8be6c1fa04bc53368ba8a906669bd7e8e58735abc5fd034b7e7821f627
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
 
@@ -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
@@ -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.5"
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.5
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