vicary 0.2.0 → 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 +25 -4
- data/lib/vicary/candidates.rb +86 -5
- data/lib/vicary/census.rb +285 -0
- data/lib/vicary/conformance.rb +33 -9
- 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 +93 -16
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +5 -1
- metadata +6 -3
data/lib/vicary/gates.rb
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vicary
|
|
6
|
+
# The gates, measured by this port rather than read from the spec.
|
|
7
|
+
#
|
|
8
|
+
# Five of the nine gates in `conformance/gates.json` need no data beyond the
|
|
9
|
+
# fixture, so this port measures them. The other four declare `requires` —
|
|
10
|
+
# `corpus` or `census` — and the repository now carries both, in
|
|
11
|
+
# `conformance/corpora/` and `conformance/census/`, so a bare checkout measures
|
|
12
|
+
# all nine. A caller that supplies nothing still gets NOT MEASURED for those
|
|
13
|
+
# four, spelled out per gate rather than reduced out of the denominator,
|
|
14
|
+
# because eight of nine held is a different statement from nine of nine and a
|
|
15
|
+
# badge cannot tell them apart. That machinery stays whether or not a shortfall
|
|
16
|
+
# is currently reachable: it is what makes the next unmeasurable gate visible.
|
|
17
|
+
#
|
|
18
|
+
# **Why this is measured and not asserted from the golden.** The spec already
|
|
19
|
+
# carries `aligns` and `mapping` per frame, computed by the reference. Reading a
|
|
20
|
+
# gate's answer out of the file would make the port's gate report a restatement
|
|
21
|
+
# of Python's, which is exactly the self-report MUST #6 warns about wearing an
|
|
22
|
+
# external costume. Everything below is recovered from the port's own output by
|
|
23
|
+
# chunk matching — the same way the reference recovers it, and without asking
|
|
24
|
+
# the masker to report on itself.
|
|
25
|
+
module Gates
|
|
26
|
+
# Every placeholder the shipped classifier can emit.
|
|
27
|
+
#
|
|
28
|
+
# Anything else in masked output is malformed — a truncated or nested
|
|
29
|
+
# placeholder is how a masking bug presents, and it reads as ordinary prose
|
|
30
|
+
# to a downstream stage.
|
|
31
|
+
KNOWN_PLACEHOLDERS = Set[
|
|
32
|
+
"{NAME}",
|
|
33
|
+
"{SCHOOL}",
|
|
34
|
+
"{EMAIL}",
|
|
35
|
+
"{URL}",
|
|
36
|
+
"{US_SOCIAL_SECURITY_NUMBER}",
|
|
37
|
+
"{IP_ADDRESS}",
|
|
38
|
+
"{PHONE}",
|
|
39
|
+
"{ADDRESS}",
|
|
40
|
+
"{DATE_OF_BIRTH}",
|
|
41
|
+
"{USERNAME}",
|
|
42
|
+
"{ZIP_CODE}",
|
|
43
|
+
"{AGE}",
|
|
44
|
+
"{CREDIT_DEBIT_CARD_NUMBER}",
|
|
45
|
+
"{ORGANIZATION}",
|
|
46
|
+
"{LOCATION}",
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Deliberately loose, so it matches malformed output too — which is the point.
|
|
50
|
+
PLACEHOLDER_RE = /\{[A-Za-z_0-9]*\}/.freeze
|
|
51
|
+
|
|
52
|
+
# `\z` rather than `$`: Ruby's `$` also matches *before* a trailing newline,
|
|
53
|
+
# so a token arriving with one would have its index left on. JavaScript's `$`
|
|
54
|
+
# does not, and this must agree with the TypeScript port token for token.
|
|
55
|
+
PLACEHOLDER_INDEX_RE = /_(\d+)\}\z/.freeze
|
|
56
|
+
|
|
57
|
+
WEAK_TOKENS = Set["of", "van", "de", "la", "the", "der", "von", "mrs", "mr", "ms"].freeze
|
|
58
|
+
|
|
59
|
+
# Invariant violations present at this fixture version, each one accounted for.
|
|
60
|
+
#
|
|
61
|
+
# Gated as an exact SET rather than a count, so a *new* violation fails even
|
|
62
|
+
# though these do not — a ceiling of one would let a second defect in by
|
|
63
|
+
# silently displacing this one.
|
|
64
|
+
#
|
|
65
|
+
# * `Robinson` — the documented, deliberately unpaid cost: once a document
|
|
66
|
+
# establishes "Jackie Robinson", a bare "Robinson" in it keeps, including a
|
|
67
|
+
# neighbour who shares the surname. No surname-level rule separates them.
|
|
68
|
+
#
|
|
69
|
+
# The companion check is the load-bearing half: an entry here that STOPS
|
|
70
|
+
# occurring fails too, so a stale exemption cannot shelter the next defect of
|
|
71
|
+
# the same shape. Two entries were retired from the Python list exactly that
|
|
72
|
+
# way.
|
|
73
|
+
ACCEPTED_VIOLATIONS = Set["leak\u0000NAME:Robinson"].freeze
|
|
74
|
+
|
|
75
|
+
Alignment = Struct.new(:pairs, :ok, :reason, keyword_init: true)
|
|
76
|
+
Violation = Struct.new(:kind, :detail, keyword_init: true)
|
|
77
|
+
SpanOutcome = Struct.new(:frame_id, :entity, :literal, :verdict, :held_out,
|
|
78
|
+
:passed, keyword_init: true)
|
|
79
|
+
# `value` is nil when this port does not measure the gate — never 0, which
|
|
80
|
+
# would read as a measured failure.
|
|
81
|
+
# `bar` is the one actually applied — the gate's default unless the measured
|
|
82
|
+
# corpus has an override. Carried here rather than re-derived by the renderer,
|
|
83
|
+
# so what is printed cannot drift from what was compared.
|
|
84
|
+
GateMeasurement = Struct.new(:gate, :value, :passed, :detail, :bar,
|
|
85
|
+
keyword_init: true)
|
|
86
|
+
GateReport = Struct.new(:measurements, :violations, :unaccounted,
|
|
87
|
+
:missing_accepted, keyword_init: true)
|
|
88
|
+
|
|
89
|
+
class << self
|
|
90
|
+
# `"{NAME_3}"` → `"{NAME}"`; an unnumbered token is returned unchanged.
|
|
91
|
+
#
|
|
92
|
+
# The index identifies *which* entity, the kind identifies *what* it is, and
|
|
93
|
+
# every invariant here is about the kind.
|
|
94
|
+
def placeholder_kind(token)
|
|
95
|
+
token.sub(PLACEHOLDER_INDEX_RE, "}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Recover the span→placeholder mapping by matching the surviving prose.
|
|
99
|
+
#
|
|
100
|
+
# Splits `masked` at placeholder boundaries and reconstructs which region of
|
|
101
|
+
# `original` each placeholder replaced. Recovered by chunk matching rather
|
|
102
|
+
# than asked of the redactor, so it works against any masker without that
|
|
103
|
+
# masker having to report its own spans.
|
|
104
|
+
def align(original, masked)
|
|
105
|
+
placeholders = masked.scan(PLACEHOLDER_RE)
|
|
106
|
+
# The `-1` is load-bearing. Ruby's `split` DROPS trailing empty fields and
|
|
107
|
+
# JavaScript's does not: for "a{X}" it would return ["a"] where the port
|
|
108
|
+
# this mirrors returns ["a", ""]. That silently shortens the chunk list,
|
|
109
|
+
# so the reconstruction below loses its final anchor and a placeholder at
|
|
110
|
+
# the end of a sentence recovers the wrong region.
|
|
111
|
+
parts = masked.split(PLACEHOLDER_RE, -1)
|
|
112
|
+
|
|
113
|
+
if placeholders.empty?
|
|
114
|
+
return Alignment.new(pairs: [], ok: false,
|
|
115
|
+
reason: "text changed with no placeholder emitted") if masked != original
|
|
116
|
+
|
|
117
|
+
return Alignment.new(pairs: [], ok: true, reason: "")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Anchored, all at once, rather than a left-to-right scan for each chunk in
|
|
121
|
+
# turn. A greedy per-chunk `index` misaligns whenever a surviving chunk is
|
|
122
|
+
# short enough to also occur inside the span that was just removed — a
|
|
123
|
+
# trailing "." after a masked email address matches the "." inside the
|
|
124
|
+
# address, and the recovered region collapses to one character. Anchoring
|
|
125
|
+
# the whole reconstruction makes it consistent simultaneously, so a
|
|
126
|
+
# candidate that cannot be completed to the end of the original is
|
|
127
|
+
# rejected and the engine backtracks. The chunks are long, distinctive
|
|
128
|
+
# prose, which is what keeps the lazy quantifiers from exploring.
|
|
129
|
+
#
|
|
130
|
+
# `\A`/`\z` rather than `^`/`$`, which in Ruby are line anchors: a
|
|
131
|
+
# sentence containing a newline would otherwise let a partial
|
|
132
|
+
# reconstruction satisfy the pattern and report `ok`.
|
|
133
|
+
pattern = +"\\A" + Regexp.escape(parts[0])
|
|
134
|
+
parts[1..].each { |chunk| pattern << "([\\s\\S]*?)#{Regexp.escape(chunk)}" }
|
|
135
|
+
pattern << "\\z"
|
|
136
|
+
|
|
137
|
+
found = Regexp.new(pattern).match(original)
|
|
138
|
+
if found.nil?
|
|
139
|
+
return Alignment.new(
|
|
140
|
+
pairs: [], ok: false,
|
|
141
|
+
reason: "masked text is not the original with spans replaced — prose was " \
|
|
142
|
+
"rewritten, reordered or dropped",
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
regions = found.captures
|
|
147
|
+
Alignment.new(
|
|
148
|
+
pairs: placeholders.each_with_index.map { |p, i| [p, regions[i] || ""] },
|
|
149
|
+
ok: true, reason: "",
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Put the originals back the way an echo-fidelity restore would have to.
|
|
154
|
+
#
|
|
155
|
+
# Keyed on the placeholder token, because that is all a downstream consumer
|
|
156
|
+
# has: the model echoes `{NAME}` and the caller must decide which name it
|
|
157
|
+
# meant. With one token per entity type it cannot, which is what
|
|
158
|
+
# `not-restorable` counts. Distinct from `Minter.restore`, which is handed a
|
|
159
|
+
# map the masker built.
|
|
160
|
+
def restore_by_token(masked, mapping)
|
|
161
|
+
masked.gsub(PLACEHOLDER_RE) { |token| mapping.fetch(token, token) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# True when the frame's sentence survives mask-then-restore exactly.
|
|
165
|
+
def round_trips?(frame, masked)
|
|
166
|
+
alignment = align(frame.sentence, masked)
|
|
167
|
+
return false unless alignment.ok
|
|
168
|
+
|
|
169
|
+
mapping = {}
|
|
170
|
+
alignment.pairs.each { |placeholder, region| mapping[placeholder] ||= region }
|
|
171
|
+
restore_by_token(masked, mapping) == frame.sentence
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Substrings whose survival proves a partial leak of `span`.
|
|
175
|
+
#
|
|
176
|
+
# A name masked halfway still identifies the person, so "the whole literal
|
|
177
|
+
# is gone" is too weak a test on multi-token names.
|
|
178
|
+
def leak_probes(span)
|
|
179
|
+
return [] unless %w[NAME SCHOOL ORGANIZATION LOCATION].include?(span.entity)
|
|
180
|
+
|
|
181
|
+
span.literal
|
|
182
|
+
.split(/[\s\-]+/)
|
|
183
|
+
.reject(&:empty?)
|
|
184
|
+
.map { |t| t.sub(/\A[.,']+/, "").sub(/[.,']+\z/, "") }
|
|
185
|
+
.select { |t| t.length >= 3 && !WEAK_TOKENS.include?(t.downcase) }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Every structural invariant the masked text must satisfy.
|
|
189
|
+
#
|
|
190
|
+
# `leak` — a REDACT literal survived. `partial-leak` — the literal is gone
|
|
191
|
+
# but a name token of it survived; worse than a miss, because it *looks*
|
|
192
|
+
# redacted and recall scores it as a pass. `keep-destroyed` — a KEEP literal
|
|
193
|
+
# was masked. `unknown-placeholder` — output carries a brace token nobody
|
|
194
|
+
# emits. `chunk-alignment` — prose was rewritten rather than replaced.
|
|
195
|
+
# `not-restorable` — one placeholder stands for two different originals.
|
|
196
|
+
# `wrong-type` — masked, but as the wrong entity.
|
|
197
|
+
def check_frame(frame, masked)
|
|
198
|
+
out = []
|
|
199
|
+
|
|
200
|
+
masked.scan(PLACEHOLDER_RE).uniq.each do |token|
|
|
201
|
+
unless KNOWN_PLACEHOLDERS.include?(placeholder_kind(token))
|
|
202
|
+
out << Violation.new(kind: "unknown-placeholder", detail: token)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
frame.spans.reject { |s| keep?(s) }.each do |span|
|
|
207
|
+
if masked.include?(span.literal)
|
|
208
|
+
out << Violation.new(kind: "leak", detail: "#{span.entity}:#{span.literal}")
|
|
209
|
+
next
|
|
210
|
+
end
|
|
211
|
+
leak_probes(span).each do |probe|
|
|
212
|
+
if /\b#{Regexp.escape(probe)}\b/.match?(masked)
|
|
213
|
+
out << Violation.new(kind: "partial-leak",
|
|
214
|
+
detail: "#{span.entity}:#{span.literal} → #{probe}")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
frame.spans.select { |s| keep?(s) }.each do |span|
|
|
220
|
+
unless masked.include?(span.literal)
|
|
221
|
+
out << Violation.new(kind: "keep-destroyed",
|
|
222
|
+
detail: "#{span.entity}:#{span.literal}")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
alignment = align(frame.sentence, masked)
|
|
227
|
+
unless alignment.ok
|
|
228
|
+
out << Violation.new(kind: "chunk-alignment", detail: alignment.reason)
|
|
229
|
+
return out
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
seen = {}
|
|
233
|
+
alignment.pairs.each do |placeholder, region|
|
|
234
|
+
prior = seen[placeholder]
|
|
235
|
+
if !prior.nil? && prior != region
|
|
236
|
+
out << Violation.new(
|
|
237
|
+
kind: "not-restorable",
|
|
238
|
+
detail: "#{placeholder} ← #{prior.inspect} and #{region.inspect}",
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
seen[placeholder] ||= region
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
frame.spans.reject { |s| keep?(s) }.each do |span|
|
|
245
|
+
next if span.expect.nil? || masked.include?(span.literal)
|
|
246
|
+
|
|
247
|
+
covering = alignment.pairs
|
|
248
|
+
.select { |_p, region| region.include?(span.literal) }
|
|
249
|
+
.map { |p, _region| placeholder_kind(p) }
|
|
250
|
+
# `expect` carries its own braces — "{NAME}", not "NAME" — so it is
|
|
251
|
+
# compared to `placeholder_kind` output directly. Wrapping it again
|
|
252
|
+
# silently made every correctly-typed span a `wrong-type`, which read as
|
|
253
|
+
# 41 violations and printed "expected {NAME} got {NAME}".
|
|
254
|
+
if !covering.empty? && !covering.include?(span.expect)
|
|
255
|
+
out << Violation.new(
|
|
256
|
+
kind: "wrong-type",
|
|
257
|
+
detail: "#{span.literal.inspect} expected #{span.expect} got #{covering[0]}",
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
out
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def score_spans(frame, masked)
|
|
266
|
+
frame.spans.map do |span|
|
|
267
|
+
passed =
|
|
268
|
+
if span.expect_count.nil?
|
|
269
|
+
present = masked.include?(span.literal)
|
|
270
|
+
keep?(span) ? present : !present
|
|
271
|
+
else
|
|
272
|
+
# Presence cannot decide a bare surname that also occurs inside a
|
|
273
|
+
# kept full name, so this one is counted rather than tested for
|
|
274
|
+
# absence.
|
|
275
|
+
occurrences(masked, span.literal) == span.expect_count
|
|
276
|
+
end
|
|
277
|
+
SpanOutcome.new(frame_id: frame.frame_id, entity: span.entity,
|
|
278
|
+
literal: span.literal, verdict: span.verdict,
|
|
279
|
+
held_out: frame.held_out, passed: passed)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# The key `ACCEPTED_VIOLATIONS` is written in. NUL, because neither half can
|
|
284
|
+
# contain one.
|
|
285
|
+
def violation_key(violation)
|
|
286
|
+
"#{violation.kind}\u0000#{violation.detail}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Measure every gate this port can measure from the fixture, plus any whose
|
|
290
|
+
# `requires` the caller has satisfied by supplying the data.
|
|
291
|
+
#
|
|
292
|
+
# `asset_entries` and `bare_surname_exposure` are passed in rather than
|
|
293
|
+
# read here so this module stays free of the gazetteer and the filesystem —
|
|
294
|
+
# a caller that wants those gates supplies the number, and one that does
|
|
295
|
+
# not gets NOT MEASURED rather than a load.
|
|
296
|
+
def measure(spec, gate_spec, asset_entries: nil, bare_surname_exposure: nil,
|
|
297
|
+
held_out_recall_carrier: nil, over_fire_per_essay: nil,
|
|
298
|
+
latency_regression_pct: nil, latency_regression_detail: nil,
|
|
299
|
+
corpus_id: nil)
|
|
300
|
+
outcomes = []
|
|
301
|
+
violations = []
|
|
302
|
+
round_tripped = 0
|
|
303
|
+
|
|
304
|
+
spec.frames.each do |frame|
|
|
305
|
+
masked = yield(frame.sentence, spec.identity)
|
|
306
|
+
outcomes.concat(score_spans(frame, masked))
|
|
307
|
+
violations.concat(check_frame(frame, masked))
|
|
308
|
+
round_tripped += 1 if round_trips?(frame, masked)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
held_out_redact = outcomes.select { |o| o.held_out && o.verdict != "keep" }
|
|
312
|
+
keeps = outcomes.select { |o| o.verdict == "keep" }
|
|
313
|
+
unaccounted = violations.reject { |v| ACCEPTED_VIOLATIONS.include?(violation_key(v)) }
|
|
314
|
+
occurred = violations.map { |v| violation_key(v) }.to_set
|
|
315
|
+
missing_accepted = ACCEPTED_VIOLATIONS.reject { |k| occurred.include?(k) }
|
|
316
|
+
|
|
317
|
+
values = {
|
|
318
|
+
"held_out_recall" => {
|
|
319
|
+
value: pct(held_out_redact.count(&:passed), held_out_redact.size),
|
|
320
|
+
detail: "#{held_out_redact.count(&:passed)}/#{held_out_redact.size} " \
|
|
321
|
+
"held-out REDACT spans",
|
|
322
|
+
},
|
|
323
|
+
"keep_precision" => {
|
|
324
|
+
value: pct(keeps.count(&:passed), keeps.size),
|
|
325
|
+
detail: "#{keeps.count(&:passed)}/#{keeps.size} KEEP spans intact",
|
|
326
|
+
},
|
|
327
|
+
"round_trip" => {
|
|
328
|
+
value: pct(round_tripped, spec.frames.size),
|
|
329
|
+
detail: "#{round_tripped}/#{spec.frames.size} frames restore exactly",
|
|
330
|
+
},
|
|
331
|
+
"unaccounted_violations" => {
|
|
332
|
+
value: unaccounted.size,
|
|
333
|
+
detail: if unaccounted.empty?
|
|
334
|
+
"#{violations.size} violation(s), all accounted for"
|
|
335
|
+
else
|
|
336
|
+
unaccounted.map { |v| "#{v.kind}:#{v.detail}" }.join("; ")
|
|
337
|
+
end,
|
|
338
|
+
},
|
|
339
|
+
"asset_entries" => {
|
|
340
|
+
value: asset_entries,
|
|
341
|
+
detail: asset_entries.nil? ? "not supplied by the caller" : "#{asset_entries} entries",
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Kept in a SEPARATE hash from `values` on purpose. A gate declaring
|
|
346
|
+
# `requires` may be measured only from data that actually satisfies that
|
|
347
|
+
# requirement — never from anything derived from the fixture, because
|
|
348
|
+
# computing something else and calling it that gate is the more dangerous
|
|
349
|
+
# failure. Two hashes make that structural rather than a rule to remember.
|
|
350
|
+
no_corpus = "no corpus supplied by the caller"
|
|
351
|
+
supplied = {
|
|
352
|
+
"bare_surname_exposure" => {
|
|
353
|
+
value: bare_surname_exposure,
|
|
354
|
+
detail: if bare_surname_exposure.nil?
|
|
355
|
+
"no census file supplied by the caller"
|
|
356
|
+
else
|
|
357
|
+
"#{round3(bare_surname_exposure)}% of US surname bearers"
|
|
358
|
+
end,
|
|
359
|
+
},
|
|
360
|
+
"held_out_recall_carrier" => {
|
|
361
|
+
value: held_out_recall_carrier,
|
|
362
|
+
detail: if held_out_recall_carrier.nil?
|
|
363
|
+
no_corpus
|
|
364
|
+
else
|
|
365
|
+
"#{round3(held_out_recall_carrier)}% of held-out REDACT spans in carrier essays"
|
|
366
|
+
end,
|
|
367
|
+
},
|
|
368
|
+
"over_fire_prose" => {
|
|
369
|
+
value: over_fire_per_essay,
|
|
370
|
+
detail: if over_fire_per_essay.nil?
|
|
371
|
+
no_corpus
|
|
372
|
+
else
|
|
373
|
+
"#{round3(over_fire_per_essay)} spans masked per essay of un-injected prose"
|
|
374
|
+
end,
|
|
375
|
+
},
|
|
376
|
+
"latency_regression" => {
|
|
377
|
+
value: latency_regression_pct,
|
|
378
|
+
detail: if latency_regression_pct.nil?
|
|
379
|
+
# The reason the comparison was declined, when there is
|
|
380
|
+
# one. A silent skip here is the failure this gate exists
|
|
381
|
+
# to avoid.
|
|
382
|
+
latency_regression_detail || no_corpus
|
|
383
|
+
else
|
|
384
|
+
"#{round3(latency_regression_pct)}% against the last release's figure for this port"
|
|
385
|
+
end,
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
measurements = gate_spec.gates.map do |gate|
|
|
390
|
+
unless gate.requires.empty?
|
|
391
|
+
given = supplied[gate.id]
|
|
392
|
+
if given.nil? || given[:value].nil?
|
|
393
|
+
next GateMeasurement.new(gate: gate, value: nil, passed: nil,
|
|
394
|
+
bar: gate.bar_for(corpus_id), detail: "")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
next GateMeasurement.new(gate: gate, value: given[:value],
|
|
398
|
+
bar: gate.bar_for(corpus_id),
|
|
399
|
+
passed: compare(given[:value], gate.op,
|
|
400
|
+
gate.bar_for(corpus_id)),
|
|
401
|
+
detail: given[:detail])
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
found = values[gate.id]
|
|
405
|
+
if found.nil? || found[:value].nil?
|
|
406
|
+
next GateMeasurement.new(gate: gate, value: nil, passed: nil,
|
|
407
|
+
bar: gate.bar_for(corpus_id),
|
|
408
|
+
detail: found ? found[:detail] : "")
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
GateMeasurement.new(gate: gate, value: found[:value],
|
|
412
|
+
bar: gate.bar_for(corpus_id),
|
|
413
|
+
passed: compare(found[:value], gate.op,
|
|
414
|
+
gate.bar_for(corpus_id)),
|
|
415
|
+
detail: found[:detail])
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
GateReport.new(measurements: measurements, violations: violations,
|
|
419
|
+
unaccounted: unaccounted, missing_accepted: missing_accepted)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Render the gate block, NOT MEASURED spelled out per gate.
|
|
423
|
+
#
|
|
424
|
+
# Replaces the placeholder block `Conformance.report` prints when no caller
|
|
425
|
+
# measured anything.
|
|
426
|
+
def report(gate_report)
|
|
427
|
+
lines = [" gates:"]
|
|
428
|
+
gate_report.measurements.each do |m|
|
|
429
|
+
gate = m.gate
|
|
430
|
+
# `FROM` rather than `NEEDS` once it holds a value, so the line never
|
|
431
|
+
# reads as though a measured gate were still waiting on its data — and
|
|
432
|
+
# so the provenance of an operator-supplied number stays attached to it.
|
|
433
|
+
needs = if gate.requires.empty?
|
|
434
|
+
""
|
|
435
|
+
else
|
|
436
|
+
" #{m.passed.nil? ? 'NEEDS' : 'FROM'} #{gate.requires.join('+')}"
|
|
437
|
+
end
|
|
438
|
+
status = if m.passed.nil?
|
|
439
|
+
"NOT MEASURED"
|
|
440
|
+
else
|
|
441
|
+
m.passed ? "PASS " : "FAIL "
|
|
442
|
+
end
|
|
443
|
+
measured = m.value.nil? ? "" : " measured #{round3(m.value)} #{gate.unit}"
|
|
444
|
+
lines << format(" %s %-28s %s %s %s%s%s", status, gate.label, gate.op,
|
|
445
|
+
m.bar, gate.unit, needs, measured)
|
|
446
|
+
lines << format(" %s", m.detail) if m.passed == false && !m.detail.empty?
|
|
447
|
+
end
|
|
448
|
+
measured = gate_report.measurements.reject { |m| m.passed.nil? }
|
|
449
|
+
held = measured.count(&:passed)
|
|
450
|
+
unmeasured = gate_report.measurements.size - measured.size
|
|
451
|
+
# The tally names the shortfall or says there is none, rather than
|
|
452
|
+
# trailing a clause about data an operator must supply — every
|
|
453
|
+
# requirement is satisfied from the repository now, so that clause would
|
|
454
|
+
# send a reader looking for a file to set. It has to keep working when
|
|
455
|
+
# that stops being true.
|
|
456
|
+
tail = if unmeasured.zero?
|
|
457
|
+
"all #{gate_report.measurements.size} were measured."
|
|
458
|
+
else
|
|
459
|
+
"#{unmeasured} are NOT MEASURED for want of the data they declare."
|
|
460
|
+
end
|
|
461
|
+
lines << " -> #{held} of #{measured.size} measured gates hold; #{tail}"
|
|
462
|
+
lines.join("\n")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
private
|
|
466
|
+
|
|
467
|
+
def keep?(span)
|
|
468
|
+
span.verdict == "keep"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def occurrences(haystack, needle)
|
|
472
|
+
return 0 if needle.empty?
|
|
473
|
+
|
|
474
|
+
# `scan` with a String pattern matches it literally and does not overlap,
|
|
475
|
+
# which is what the TypeScript loop's `at + needle.length` step does.
|
|
476
|
+
haystack.scan(needle).size
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def pct(passed, total)
|
|
480
|
+
return nil if total.zero?
|
|
481
|
+
|
|
482
|
+
(100.0 * passed) / total
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def compare(value, op, bar)
|
|
486
|
+
case op
|
|
487
|
+
when ">=" then value >= bar
|
|
488
|
+
when "<=" then value <= bar
|
|
489
|
+
when "==" then value == bar
|
|
490
|
+
else raise Conformance::SpecError, "unknown gate operator #{op}"
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def round3(value)
|
|
495
|
+
value == value.to_i ? value.to_i.to_s : format("%.3f", value)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
data/lib/vicary/gazetteer.rb
CHANGED
|
@@ -125,7 +125,35 @@ module Vicary
|
|
|
125
125
|
# outright. That changes a key only when such a mark sits *between* two
|
|
126
126
|
# alphanumerics, which cannot happen in a gazetteer whose keys are
|
|
127
127
|
# Latin-folded, nor in the English prose the conformance frames carry.
|
|
128
|
+
# How many folded keys to remember. Sized against the measurement that
|
|
129
|
+
# motivated the cache: 25 carrier essays, redacted twice each, made 29,361
|
|
130
|
+
# calls over 11,888 distinct inputs — 59.5% repeats. A document's vocabulary
|
|
131
|
+
# is the working set, so this holds several essays' worth and is cleared
|
|
132
|
+
# wholesale rather than evicted entry by entry.
|
|
133
|
+
NORMALIZE_CACHE_MAX = 20_000
|
|
134
|
+
|
|
135
|
+
# Memoized {normalize}. Pure function of its argument, so the cache cannot
|
|
136
|
+
# change an answer — it removes an NFKD decomposition and five intermediate
|
|
137
|
+
# strings per repeated token.
|
|
138
|
+
#
|
|
139
|
+
# Worth doing because of what it does to GC, not only to CPU: after the
|
|
140
|
+
# identity-pattern fix, sweeping and marking were ~29% of this port's time on
|
|
141
|
+
# the longest essays, and this path allocates on every call.
|
|
128
142
|
def self.normalize(name)
|
|
143
|
+
cache = (@normalize_cache ||= {})
|
|
144
|
+
hit = cache[name]
|
|
145
|
+
return hit if hit
|
|
146
|
+
|
|
147
|
+
cache.clear if cache.size >= NORMALIZE_CACHE_MAX
|
|
148
|
+
cache[name] = normalize_uncached(name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Drop the fold cache. For tests that swap the asset underneath.
|
|
152
|
+
def self.reset_normalize_cache
|
|
153
|
+
@normalize_cache = nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.normalize_uncached(name)
|
|
129
157
|
folded = name.gsub(SMART_QUOTE_PATTERN) { |char| SMART_QUOTES.fetch(char, char) }
|
|
130
158
|
folded = folded.unicode_normalize(:nfkd)
|
|
131
159
|
folded = folded.gsub(/\p{M}/, "")
|