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
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Vicary
|
|
9
|
+
# The three gates that need an essay corpus, measured by this port.
|
|
10
|
+
#
|
|
11
|
+
# Held-out recall in a carrier essay, over-firing on real prose, and latency at
|
|
12
|
+
# essay length cannot be measured on isolated sentences. They need fixture
|
|
13
|
+
# frames planted inside genuine student prose. That prose ships: `persuade-20`
|
|
14
|
+
# lives in `conformance/corpora/` and is the registry default, so all three are
|
|
15
|
+
# measured on a bare checkout. They fall back to NOT MEASURED only when the
|
|
16
|
+
# corpus that *resolves* is operator-supplied — ASAP-AES, selected either by
|
|
17
|
+
# `VICARY_EVAL_CORPUS` or by having `VICARY_EVAL_CORPUS_TSV` configured — and no
|
|
18
|
+
# TSV is there to read.
|
|
19
|
+
#
|
|
20
|
+
# **Where the carrier text comes from.** Everything about building it is
|
|
21
|
+
# deterministic except which sentence ends the frames land on, which the Python
|
|
22
|
+
# reference draws from its Mersenne Twister. Rather than reimplement MT19937
|
|
23
|
+
# and `random.sample` here — several hundred lines with nothing to do with
|
|
24
|
+
# redaction, whose failure mode is silent — the draw is recorded once in
|
|
25
|
+
# `conformance/carrier.json` and read back. The plan is an *input*, exactly as
|
|
26
|
+
# `frames.json` is: it says where to inject. What this port then measures from
|
|
27
|
+
# the resulting text is recovered from its own output, never read from the spec.
|
|
28
|
+
#
|
|
29
|
+
# **Why the digest check is not paranoia.** An offset into the wrong essay is
|
|
30
|
+
# not an error anything downstream notices; it produces a plausible number from
|
|
31
|
+
# text nobody intended. So each essay is checked against the digest the plan
|
|
32
|
+
# was built from, and a mismatch raises rather than measuring.
|
|
33
|
+
module Corpus
|
|
34
|
+
EVAL_CORPUS_TSV_ENV_VAR = "VICARY_EVAL_CORPUS_TSV"
|
|
35
|
+
EVAL_CORPUS_DIR_ENV_VAR = "VICARY_EVAL_CORPUS_DIR"
|
|
36
|
+
EVAL_CORPUS_PREFERRED_FILENAME = "corpus.tsv"
|
|
37
|
+
|
|
38
|
+
# How many times each essay is redacted for the latency figure. The recorded
|
|
39
|
+
# number is the MEDIAN of these, not one sample. Must stay odd, so the median
|
|
40
|
+
# is a sample rather than a mean of two.
|
|
41
|
+
#
|
|
42
|
+
# Why: the latency gate takes p95 across essays, and at n=20 that index *is*
|
|
43
|
+
# the maximum. So a single-sample-per-essay design asked "did a GC pause land
|
|
44
|
+
# in any one of twenty calls" and answered a `<=` gate with it. Five
|
|
45
|
+
# consecutive runs of unchanged code in this port gave 13.8, 7.4, 13.1, 7.7,
|
|
46
|
+
# 6.8 ms against a 10 ms bar — two failures out of five, bimodal at 2x rather
|
|
47
|
+
# than noisy, which is the signature of a pause landing on the one sample that
|
|
48
|
+
# decides the answer. A median of three per essay means a pause has to hit the
|
|
49
|
+
# same essay twice to move the number.
|
|
50
|
+
#
|
|
51
|
+
# Five rather than three because the gated number is now a regression bar
|
|
52
|
+
# with 8% of room, and the estimator has to reproduce itself to well inside
|
|
53
|
+
# that on unchanged code. Every repeat re-redacts the whole corpus, so this
|
|
54
|
+
# is not free; five is where the measured gain flattened. The same constant
|
|
55
|
+
# lives in all three ports, because a gate two ports estimate differently is
|
|
56
|
+
# not the same gate.
|
|
57
|
+
LATENCY_REPEATS = 5
|
|
58
|
+
|
|
59
|
+
CARRIER_FILENAME = "carrier.json"
|
|
60
|
+
|
|
61
|
+
# Bumped when a field's meaning changes. An unknown version is refused.
|
|
62
|
+
# 2 keyed the plans by corpus id. A version-1 reader handed a version-2 file
|
|
63
|
+
# finds no `cases` at the top level and builds zero carrier essays — which in a
|
|
64
|
+
# `<=` gate is the most comfortable pass on the board, so the refusal is the
|
|
65
|
+
# point of the number.
|
|
66
|
+
CARRIER_DOCUMENT_VERSION = 2
|
|
67
|
+
|
|
68
|
+
# Where the corpus profiles live, under `conformance/`.
|
|
69
|
+
CORPORA_DIRNAME = "corpora"
|
|
70
|
+
CORPORA_INDEX_FILENAME = "index.json"
|
|
71
|
+
CORPUS_PROFILE_FILENAME = "profile.json"
|
|
72
|
+
|
|
73
|
+
# Source kinds a corpus profile may declare, and the file the shipped kind
|
|
74
|
+
# keeps beside its profile.
|
|
75
|
+
KIND_SHIPPED = "shipped"
|
|
76
|
+
KIND_OPERATOR_TSV = "operator_tsv"
|
|
77
|
+
ESSAYS_FILENAME = "essays.json"
|
|
78
|
+
PROFILE_DOCUMENT_VERSION = 1
|
|
79
|
+
|
|
80
|
+
# Names a corpus id directly, overriding the operator-TSV inference.
|
|
81
|
+
EVAL_CORPUS_ENV_VAR = "VICARY_EVAL_CORPUS"
|
|
82
|
+
|
|
83
|
+
# The reference's ANSWERS on the plan the carrier file describes. Separate
|
|
84
|
+
# file because they are a different kind of thing: `carrier.json` is an input
|
|
85
|
+
# every port replays, `measured.json` is what Python got from replaying it.
|
|
86
|
+
MEASURED_FILENAME = "measured.json"
|
|
87
|
+
# 2 keyed the measurements by corpus id. Two of the three numbers are
|
|
88
|
+
# properties of the prose rather than of the detector, so an unkeyed block
|
|
89
|
+
# invited comparing one corpus's figures against another's.
|
|
90
|
+
MEASURED_DOCUMENT_VERSION = 2
|
|
91
|
+
|
|
92
|
+
# One of ASAP's own anonymization tokens — `@PERSON1`, `@LOCATION2`.
|
|
93
|
+
#
|
|
94
|
+
# Load-bearing for the over-fire metric, because the two legs it separates
|
|
95
|
+
# are unrelated. Masking genuine prose is a precision defect; masking
|
|
96
|
+
# `@PERSON1` is not, since the PII is already gone. Summed they read as one
|
|
97
|
+
# catastrophic precision failure while the prose leg is zero.
|
|
98
|
+
#
|
|
99
|
+
# `\A`/`\z`, not `^`/`$`: Ruby anchors those at every line boundary, so a
|
|
100
|
+
# region spanning a newline would match on its last line alone.
|
|
101
|
+
ASAP_TOKEN_RE = /\A@[A-Z]+\d*\z/
|
|
102
|
+
|
|
103
|
+
Case = Struct.new(:essay_id, :text, :base, :frames, keyword_init: true)
|
|
104
|
+
|
|
105
|
+
Metrics = Struct.new(
|
|
106
|
+
:essays, :recall_held_out, :recall_held_out_passed, :recall_held_out_total,
|
|
107
|
+
:over_fire_spans_per_essay, :over_fire_spans_total,
|
|
108
|
+
:asap_rewrites_per_essay, :latency_p50_ms, :latency_p95_ms,
|
|
109
|
+
:latency_pooled_median_ms,
|
|
110
|
+
keyword_init: true
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
class << self
|
|
114
|
+
# Mean of the two middle samples at even length, matching how the other
|
|
115
|
+
# two ports define it. Pooled n is 20 x 5, so the even branch is the one
|
|
116
|
+
# that runs.
|
|
117
|
+
def median_of(xs)
|
|
118
|
+
return 0.0 if xs.empty?
|
|
119
|
+
|
|
120
|
+
s = xs.sort
|
|
121
|
+
mid = s.size / 2
|
|
122
|
+
s.size.odd? ? s[mid] : (s[mid - 1] + s[mid]) / 2.0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def asap_token?(region)
|
|
126
|
+
ASAP_TOKEN_RE.match?(region.strip)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Configured path to the corpus TSV, or `""`.
|
|
130
|
+
def corpus_source
|
|
131
|
+
explicit = (ENV[EVAL_CORPUS_TSV_ENV_VAR] || "").strip
|
|
132
|
+
return explicit unless explicit.empty?
|
|
133
|
+
|
|
134
|
+
directory = (ENV[EVAL_CORPUS_DIR_ENV_VAR] || "").strip
|
|
135
|
+
return "" if directory.empty?
|
|
136
|
+
|
|
137
|
+
File.join(directory, EVAL_CORPUS_PREFERRED_FILENAME)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# `[[essay_id, text], ...]` for the first `limit` essays of the named set,
|
|
141
|
+
# in file order.
|
|
142
|
+
#
|
|
143
|
+
# **Read as latin-1, then converted to UTF-8.** ASAP-AES is not UTF-8, and
|
|
144
|
+
# reading it as UTF-8 yields invalid byte sequences that break both the
|
|
145
|
+
# digests and every offset computed against them. The conversion afterwards
|
|
146
|
+
# matters too: the frame sentences come from JSON as UTF-8, and Ruby raises
|
|
147
|
+
# `Encoding::CompatibilityError` on concatenating the two encodings once
|
|
148
|
+
# either side holds a non-ASCII byte.
|
|
149
|
+
#
|
|
150
|
+
# Parsed here rather than by `CSV`, for two reasons that both bite on this
|
|
151
|
+
# file. ASAP essays contain `"` characters and some records span more than
|
|
152
|
+
# one physical line inside a quoted field — 12,980 lines for 12,976
|
|
153
|
+
# records — so splitting on tabs and newlines silently truncates essays
|
|
154
|
+
# mid-sentence. And the line endings are mixed, 12,979 LF against 12,977
|
|
155
|
+
# CR, which Ruby's `CSV` refuses outright ("New line must be <\"\\n\">")
|
|
156
|
+
# while Python's `csv` accepts. The state machine below takes CRLF, LF and
|
|
157
|
+
# a lone CR all as record separators, which is what the reference does.
|
|
158
|
+
def load_set(tsv, essay_set, limit)
|
|
159
|
+
text = File.read(tsv, encoding: "ISO-8859-1").encode("UTF-8")
|
|
160
|
+
parse_delimited(text, "\t", essay_set, limit)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `[essay_id, text]` for a corpus whose essays ship in this repository.
|
|
164
|
+
#
|
|
165
|
+
# **The essays ARE the baseline**, so every byte is checked against the
|
|
166
|
+
# digest the profile pins. A corrupted or edited file has to fail here
|
|
167
|
+
# rather than quietly rebase what every corpus gate means — the numbers
|
|
168
|
+
# describe this exact prose and nothing warns you when the prose changes
|
|
169
|
+
# underneath them. The carrier plan checks the same bytes again from its own
|
|
170
|
+
# digests, which is deliberate: two independent records of what this corpus
|
|
171
|
+
# is, and either catches an edit to the other.
|
|
172
|
+
def load_shipped(corpus_id, dir = nil)
|
|
173
|
+
profile = load_corpus_profile(corpus_id, dir)
|
|
174
|
+
text_file = profile.dig("source", "text_file") || ESSAYS_FILENAME
|
|
175
|
+
path = Pathname.new(dir || Conformance.directory)
|
|
176
|
+
.join(CORPORA_DIRNAME, corpus_id, text_file)
|
|
177
|
+
document = read_versioned(path, "corpus")
|
|
178
|
+
essays = document["essays"].map { |e| [e["id"], e["text"]] }
|
|
179
|
+
pinned = (profile["essays"] || []).to_h { |e| [e["id"], e["sha256"]] }
|
|
180
|
+
|
|
181
|
+
essays.each do |essay_id, text|
|
|
182
|
+
want = pinned[essay_id]
|
|
183
|
+
if want.nil?
|
|
184
|
+
raise Conformance::SpecError,
|
|
185
|
+
"#{corpus_id}: #{text_file} carries essay #{essay_id}, which " \
|
|
186
|
+
"#{CORPUS_PROFILE_FILENAME} does not list"
|
|
187
|
+
end
|
|
188
|
+
got = Digest::SHA256.hexdigest(text)
|
|
189
|
+
next if got == want
|
|
190
|
+
|
|
191
|
+
raise Conformance::SpecError,
|
|
192
|
+
"#{corpus_id}: essay #{essay_id} in #{text_file} is sha256 #{got}, and " \
|
|
193
|
+
"#{CORPUS_PROFILE_FILENAME} pins #{want}. Refusing: the essays are the " \
|
|
194
|
+
"baseline, so different text means every gate number measured on this " \
|
|
195
|
+
"corpus describes different prose."
|
|
196
|
+
end
|
|
197
|
+
if pinned.size != essays.size
|
|
198
|
+
raise Conformance::SpecError,
|
|
199
|
+
"#{corpus_id}: #{CORPUS_PROFILE_FILENAME} lists #{pinned.size} essays " \
|
|
200
|
+
"and #{text_file} holds #{essays.size}"
|
|
201
|
+
end
|
|
202
|
+
essays
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The resolved corpus's essays, whichever kind it is.
|
|
206
|
+
#
|
|
207
|
+
# `nil` only for an operator corpus with no TSV configured — the one case
|
|
208
|
+
# where the data genuinely is not here. A shipped corpus always loads, which
|
|
209
|
+
# is the whole point of shipping one.
|
|
210
|
+
def load_essays(corpus_id = nil, dir = nil)
|
|
211
|
+
id = corpus_id || resolve_corpus_id(dir)
|
|
212
|
+
profile = load_corpus_profile(id, dir)
|
|
213
|
+
kind = profile.dig("source", "kind")
|
|
214
|
+
return load_shipped(id, dir) if kind == KIND_SHIPPED
|
|
215
|
+
|
|
216
|
+
unless kind == KIND_OPERATOR_TSV
|
|
217
|
+
raise Conformance::SpecError,
|
|
218
|
+
"corpus #{id} declares source kind #{kind}; this reader knows " \
|
|
219
|
+
"#{KIND_SHIPPED} and #{KIND_OPERATOR_TSV}"
|
|
220
|
+
end
|
|
221
|
+
tsv = corpus_source
|
|
222
|
+
return nil if tsv.empty?
|
|
223
|
+
|
|
224
|
+
load_set(tsv, profile.dig("source", "filter", "equals") || "",
|
|
225
|
+
profile.dig("selection", "limit"))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def carrier_path(dir = nil)
|
|
229
|
+
Pathname.new(dir || Conformance.directory).join(CARRIER_FILENAME)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The corpus registry: which corpora exist, and which applies by default.
|
|
233
|
+
def load_corpus_index(dir = nil)
|
|
234
|
+
read_versioned(
|
|
235
|
+
Pathname.new(dir || Conformance.directory)
|
|
236
|
+
.join(CORPORA_DIRNAME, CORPORA_INDEX_FILENAME), "registry"
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# One corpus's profile: where its essays come from and which are in.
|
|
241
|
+
def load_corpus_profile(corpus_id, dir = nil)
|
|
242
|
+
read_versioned(
|
|
243
|
+
Pathname.new(dir || Conformance.directory)
|
|
244
|
+
.join(CORPORA_DIRNAME, corpus_id, CORPUS_PROFILE_FILENAME), "profile"
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Which corpus applies here, in the reference's order: an explicit
|
|
249
|
+
# VICARY_EVAL_CORPUS wins, then an operator with a configured TSV keeps
|
|
250
|
+
# measuring the corpus they always measured, then the registry default.
|
|
251
|
+
def resolve_corpus_id(dir = nil)
|
|
252
|
+
index = load_corpus_index(dir)
|
|
253
|
+
known = index["corpora"] || []
|
|
254
|
+
explicit = (ENV[EVAL_CORPUS_ENV_VAR] || "").strip
|
|
255
|
+
unless explicit.empty?
|
|
256
|
+
unless known.include?(explicit)
|
|
257
|
+
raise Conformance::SpecError,
|
|
258
|
+
"#{EVAL_CORPUS_ENV_VAR}=#{explicit} is not a registered corpus; this " \
|
|
259
|
+
"checkout registers #{known.join(', ')}"
|
|
260
|
+
end
|
|
261
|
+
return explicit
|
|
262
|
+
end
|
|
263
|
+
return index["operator_default"] if !corpus_source.empty? && index["operator_default"]
|
|
264
|
+
|
|
265
|
+
index["default"]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def load_carrier_plan(corpus_id = nil, dir = nil)
|
|
269
|
+
raw = JSON.parse(carrier_path(dir).read)
|
|
270
|
+
version = raw["document_version"]
|
|
271
|
+
unless version == CARRIER_DOCUMENT_VERSION
|
|
272
|
+
raise Conformance::SpecError,
|
|
273
|
+
"#{CARRIER_FILENAME} is document_version #{version.inspect}, and this " \
|
|
274
|
+
"reader knows #{CARRIER_DOCUMENT_VERSION}. Refusing rather than reading " \
|
|
275
|
+
"the fields it recognises, because a partly-read plan produces carrier " \
|
|
276
|
+
"text that is wrong without being detectably wrong."
|
|
277
|
+
end
|
|
278
|
+
id = corpus_id || resolve_corpus_id(dir)
|
|
279
|
+
plans = raw["plans"] || {}
|
|
280
|
+
plan = plans[id]
|
|
281
|
+
if plan.nil?
|
|
282
|
+
raise Conformance::SpecError,
|
|
283
|
+
"#{CARRIER_FILENAME} holds no plan for corpus #{id}; it has " \
|
|
284
|
+
"#{plans.keys.sort.join(', ')}. Regenerate with " \
|
|
285
|
+
"`python -m vicary.eval.carrier --write` on a machine that can read " \
|
|
286
|
+
"that corpus."
|
|
287
|
+
end
|
|
288
|
+
# The row filter and essay count are properties of the corpus, so they are
|
|
289
|
+
# read off its profile rather than restated here — two records of one fact
|
|
290
|
+
# is how they drift.
|
|
291
|
+
profile = load_corpus_profile(id, dir)
|
|
292
|
+
plan.merge(
|
|
293
|
+
"corpus_id" => id,
|
|
294
|
+
"essay_set" => profile.dig("source", "filter", "equals"),
|
|
295
|
+
"limit" => profile.dig("selection", "limit")
|
|
296
|
+
)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def measured_path(dir = nil)
|
|
300
|
+
Pathname.new(dir || Conformance.directory).join(MEASURED_FILENAME)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The counts the Python reference gets on the carrier text the plan builds.
|
|
304
|
+
#
|
|
305
|
+
# Read rather than transcribed. These were literals in this port's gate
|
|
306
|
+
# test — `assert_equal 29, m.recall_held_out_passed` — and in TypeScript's,
|
|
307
|
+
# and in Python's. Three copies of a number is not three checks of it: when
|
|
308
|
+
# the reference's figure legitimately moves, Python's suite is updated
|
|
309
|
+
# because that is where the change was made, and the other two keep
|
|
310
|
+
# asserting the stale value and stay green while measuring something else.
|
|
311
|
+
#
|
|
312
|
+
# Returns the raw document. The envelope matters as much as the numbers, so
|
|
313
|
+
# nothing here flattens it away — see `Gates.check_measured_envelope`.
|
|
314
|
+
def load_measured(corpus_id = nil, dir = nil)
|
|
315
|
+
raw = JSON.parse(measured_path(dir).read)
|
|
316
|
+
version = raw["document_version"]
|
|
317
|
+
unless version == MEASURED_DOCUMENT_VERSION
|
|
318
|
+
raise Conformance::SpecError,
|
|
319
|
+
"#{MEASURED_FILENAME} is document_version #{version.inspect}, and this " \
|
|
320
|
+
"reader knows #{MEASURED_DOCUMENT_VERSION}. Refusing rather than reading " \
|
|
321
|
+
"the fields it recognises: a partly-read document compares this port " \
|
|
322
|
+
"against numbers whose meaning it is guessing at."
|
|
323
|
+
end
|
|
324
|
+
id = corpus_id || resolve_corpus_id(dir)
|
|
325
|
+
corpora = raw["corpora"] || {}
|
|
326
|
+
entry = corpora[id]
|
|
327
|
+
if entry.nil?
|
|
328
|
+
raise Conformance::SpecError,
|
|
329
|
+
"#{MEASURED_FILENAME} holds no measurements for corpus #{id}; it has " \
|
|
330
|
+
"#{corpora.keys.sort.join(', ')}. Regenerate with `just sync-conformance` " \
|
|
331
|
+
"on a machine that can read that corpus."
|
|
332
|
+
end
|
|
333
|
+
entry
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def read_versioned(path, what)
|
|
337
|
+
raw = JSON.parse(path.read)
|
|
338
|
+
version = raw["document_version"]
|
|
339
|
+
unless version == PROFILE_DOCUMENT_VERSION
|
|
340
|
+
raise Conformance::SpecError,
|
|
341
|
+
"#{path.basename} is document_version #{version.inspect} and this reader " \
|
|
342
|
+
"knows #{PROFILE_DOCUMENT_VERSION}. Refusing to read the fields it " \
|
|
343
|
+
"recognises: a partly-read #{what} selects a different slice of prose " \
|
|
344
|
+
"without being detectably wrong."
|
|
345
|
+
end
|
|
346
|
+
raw
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Rebuild the carrier essays from the plan.
|
|
350
|
+
#
|
|
351
|
+
# Slots are applied in the order recorded — descending — so an earlier
|
|
352
|
+
# insertion cannot shift a later one.
|
|
353
|
+
def build_cases(essays, plan, spec)
|
|
354
|
+
by_id = spec.frames.each_with_object({}) { |f, h| h[f.frame_id] = f }
|
|
355
|
+
planned = plan["cases"].each_with_object({}) { |c, h| h[c["essay_id"]] = c }
|
|
356
|
+
|
|
357
|
+
cases = build_each(essays, planned, by_id)
|
|
358
|
+
|
|
359
|
+
# Every planned essay, or none of them. A corpus that matches the plan
|
|
360
|
+
# only partly would measure a *subset* and report it under the same gate
|
|
361
|
+
# — and the degenerate case of matching nothing is worse than wrong,
|
|
362
|
+
# because over-firing and latency both then compute as 0.0, which in a
|
|
363
|
+
# `<=` gate is the most comfortable pass on the board. Refusing is the
|
|
364
|
+
# only outcome that cannot be mistaken for a green run.
|
|
365
|
+
if cases.size != plan["cases"].size
|
|
366
|
+
found = cases.map(&:essay_id).to_set
|
|
367
|
+
missing = plan["cases"].map { |e| e["essay_id"] }.reject { |id| found.include?(id) }
|
|
368
|
+
raise Conformance::SpecError,
|
|
369
|
+
"the carrier plan names #{plan['cases'].size} essays and this corpus " \
|
|
370
|
+
"supplied #{cases.size} of them; missing #{missing.first(5).join(', ')}" \
|
|
371
|
+
"#{missing.size > 5 ? ' …' : ''}. Refusing to measure a subset, because " \
|
|
372
|
+
"over-firing and latency on an empty or partial set compute as 0.0 and " \
|
|
373
|
+
"read as a pass."
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
reconcile_against_corpus(essays, plan, cases)
|
|
377
|
+
cases
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Every *corpus* essay is either carried or named unusable.
|
|
381
|
+
#
|
|
382
|
+
# The check above only proves the plan got what it asked for; it cannot see
|
|
383
|
+
# an essay the plan never asked about. That was safe while a plan always
|
|
384
|
+
# covered its whole corpus, and stopped being safe when `unusable` made a
|
|
385
|
+
# short plan legitimate — without this, a plan that quietly lost ten essays
|
|
386
|
+
# would measure the fifteen it kept and report them under the same gate.
|
|
387
|
+
def reconcile_against_corpus(essays, plan, cases)
|
|
388
|
+
unusable = (plan["unusable"] || []).map { |e| e["essay_id"] }
|
|
389
|
+
accounted = cases.map(&:essay_id).to_set | unusable.to_set
|
|
390
|
+
unaccounted = essays.map(&:first).reject { |id| accounted.include?(id) }
|
|
391
|
+
return if unaccounted.empty?
|
|
392
|
+
|
|
393
|
+
raise Conformance::SpecError,
|
|
394
|
+
"the corpus supplies #{essays.size} essays and the carrier plan accounts " \
|
|
395
|
+
"for #{accounted.size} of them — #{plan['cases'].size} carried and " \
|
|
396
|
+
"#{unusable.size} declared unusable. Unaccounted: " \
|
|
397
|
+
"#{unaccounted.first(5).join(', ')}#{unaccounted.size > 5 ? ' …' : ''}. " \
|
|
398
|
+
"An essay the plan neither carries nor names is one it dropped silently, " \
|
|
399
|
+
"which is the same comfortable pass as a partial match."
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def build_each(essays, planned, by_id)
|
|
403
|
+
essays.filter_map do |essay_id, base|
|
|
404
|
+
entry = planned[essay_id]
|
|
405
|
+
next if entry.nil?
|
|
406
|
+
|
|
407
|
+
digest = Digest::SHA256.hexdigest(base)
|
|
408
|
+
if digest != entry["base_sha256"]
|
|
409
|
+
raise Conformance::SpecError,
|
|
410
|
+
"essay #{essay_id} in this corpus does not match the one the carrier " \
|
|
411
|
+
"plan was built from (sha256 #{digest[0, 12]} vs " \
|
|
412
|
+
"#{entry['base_sha256'][0, 12]}). The recorded offsets point into " \
|
|
413
|
+
"different text, so every number downstream would be wrong without " \
|
|
414
|
+
"being detectably wrong."
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
picks = entry["frames"].map do |fid|
|
|
418
|
+
by_id.fetch(fid) do
|
|
419
|
+
raise Conformance::SpecError,
|
|
420
|
+
"carrier plan names frame #{fid}, absent from the spec"
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
text = base.dup
|
|
425
|
+
picks.each_with_index do |frame, i|
|
|
426
|
+
at = entry["slots"][i]
|
|
427
|
+
text = text[0, at] + " " + frame.sentence + text[at..]
|
|
428
|
+
end
|
|
429
|
+
Case.new(essay_id: essay_id, text: text, base: base, frames: picks)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Measure the three corpus gates.
|
|
434
|
+
#
|
|
435
|
+
# Each essay is redacted twice — once with the frames injected, to score
|
|
436
|
+
# recall, and once bare, to see what the redactor does to prose with
|
|
437
|
+
# nothing planted in it. The bare pass is where over-firing comes from, and
|
|
438
|
+
# it is why the metric means anything: the frames cannot contaminate it.
|
|
439
|
+
def measure(cases, identity)
|
|
440
|
+
outcomes = []
|
|
441
|
+
latencies = []
|
|
442
|
+
# Every essay's every sample. The gated figure is the median of THESE,
|
|
443
|
+
# not a percentile over the per-essay collapses in `latencies`.
|
|
444
|
+
pooled = []
|
|
445
|
+
over_fire = 0
|
|
446
|
+
rewrites = 0
|
|
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?
|
|
455
|
+
|
|
456
|
+
cases.each do |kase|
|
|
457
|
+
# The median of LATENCY_REPEATS, not one sample — see that constant.
|
|
458
|
+
masked = nil
|
|
459
|
+
timings = Array.new(LATENCY_REPEATS) do
|
|
460
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
461
|
+
masked = yield(kase.text, identity)
|
|
462
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
463
|
+
end
|
|
464
|
+
latencies << timings.sort[(LATENCY_REPEATS - 1) / 2]
|
|
465
|
+
pooled.concat(timings)
|
|
466
|
+
|
|
467
|
+
kase.frames.each { |frame| outcomes.concat(Gates.score_spans(frame, masked)) }
|
|
468
|
+
|
|
469
|
+
masked_base = yield(kase.base, identity)
|
|
470
|
+
pairs = Gates.align(kase.base, masked_base).pairs
|
|
471
|
+
prose = pairs.reject { |(_, region)| asap_token?(region) }
|
|
472
|
+
over_fire += prose.size
|
|
473
|
+
rewrites += pairs.size - prose.size
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
held_out = outcomes.select { |o| o.held_out && o.verdict != "keep" }
|
|
477
|
+
passed = held_out.count(&:passed)
|
|
478
|
+
sorted = latencies.sort
|
|
479
|
+
at = lambda do |q|
|
|
480
|
+
next 0.0 if sorted.empty?
|
|
481
|
+
|
|
482
|
+
sorted[[(sorted.size * q).floor, sorted.size - 1].min]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
Metrics.new(
|
|
486
|
+
essays: cases.size,
|
|
487
|
+
recall_held_out: held_out.empty? ? 0.0 : 100.0 * passed / held_out.size,
|
|
488
|
+
recall_held_out_passed: passed,
|
|
489
|
+
recall_held_out_total: held_out.size,
|
|
490
|
+
over_fire_spans_per_essay: cases.empty? ? 0.0 : over_fire.to_f / cases.size,
|
|
491
|
+
over_fire_spans_total: over_fire,
|
|
492
|
+
asap_rewrites_per_essay: cases.empty? ? 0.0 : rewrites.to_f / cases.size,
|
|
493
|
+
latency_p50_ms: at.call(0.5),
|
|
494
|
+
latency_p95_ms: at.call(0.95),
|
|
495
|
+
latency_pooled_median_ms: median_of(pooled)
|
|
496
|
+
)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Load the corpus, rebuild the carriers, and measure. `nil` with no corpus.
|
|
500
|
+
def measure_from_config(spec, &redact)
|
|
501
|
+
corpus_id = resolve_corpus_id
|
|
502
|
+
essays = load_essays(corpus_id)
|
|
503
|
+
return nil if essays.nil? || essays.empty?
|
|
504
|
+
|
|
505
|
+
plan = load_carrier_plan(corpus_id)
|
|
506
|
+
measure(build_cases(essays, plan, spec), spec.identity, &redact)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
private
|
|
510
|
+
|
|
511
|
+
# RFC4180 as Python's `csv` implements it: a quote opens a field only at
|
|
512
|
+
# its start, `""` inside one is a literal quote, and anything after the
|
|
513
|
+
# closing quote is taken literally. Stops as soon as `limit` matching rows
|
|
514
|
+
# are found, so this walks only as far into a 16 MB file as it has to.
|
|
515
|
+
def parse_delimited(text, delimiter, essay_set, limit)
|
|
516
|
+
out = []
|
|
517
|
+
header = nil
|
|
518
|
+
set_at = id_at = essay_at = nil
|
|
519
|
+
row = []
|
|
520
|
+
field = +""
|
|
521
|
+
in_quotes = false
|
|
522
|
+
pending_quote = false
|
|
523
|
+
after_cr = false
|
|
524
|
+
|
|
525
|
+
finish_row = lambda do
|
|
526
|
+
row << field
|
|
527
|
+
field = +""
|
|
528
|
+
finished = row
|
|
529
|
+
row = []
|
|
530
|
+
if header.nil?
|
|
531
|
+
header = finished
|
|
532
|
+
set_at = header.index("essay_set")
|
|
533
|
+
id_at = header.index("essay_id")
|
|
534
|
+
essay_at = header.index("essay")
|
|
535
|
+
if set_at.nil? || id_at.nil? || essay_at.nil?
|
|
536
|
+
raise Conformance::SpecError,
|
|
537
|
+
"corpus has no essay_set/essay_id/essay header; got #{header.join(',')}"
|
|
538
|
+
end
|
|
539
|
+
next false
|
|
540
|
+
end
|
|
541
|
+
next false if finished.size == 1 && finished[0].empty?
|
|
542
|
+
next false unless finished[set_at] == essay_set
|
|
543
|
+
|
|
544
|
+
out << [finished[id_at].to_s, finished[essay_at].to_s]
|
|
545
|
+
out.size >= limit
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
text.each_char do |ch|
|
|
549
|
+
# CRLF is one record separator, not two. The CR ended the record; this
|
|
550
|
+
# swallows the LF that follows it rather than opening an empty one.
|
|
551
|
+
if after_cr
|
|
552
|
+
after_cr = false
|
|
553
|
+
next if ch == "\n" && !in_quotes && !pending_quote
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
if pending_quote
|
|
557
|
+
pending_quote = false
|
|
558
|
+
if ch == '"'
|
|
559
|
+
field << '"'
|
|
560
|
+
next
|
|
561
|
+
end
|
|
562
|
+
in_quotes = false
|
|
563
|
+
# fall through and handle `ch` as an unquoted character
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
if in_quotes
|
|
567
|
+
if ch == '"'
|
|
568
|
+
pending_quote = true
|
|
569
|
+
else
|
|
570
|
+
field << ch
|
|
571
|
+
end
|
|
572
|
+
next
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
case ch
|
|
576
|
+
when '"'
|
|
577
|
+
if field.empty?
|
|
578
|
+
in_quotes = true
|
|
579
|
+
else
|
|
580
|
+
field << ch
|
|
581
|
+
end
|
|
582
|
+
when delimiter
|
|
583
|
+
row << field
|
|
584
|
+
field = +""
|
|
585
|
+
when "\n"
|
|
586
|
+
return out if finish_row.call
|
|
587
|
+
when "\r"
|
|
588
|
+
after_cr = true
|
|
589
|
+
return out if finish_row.call
|
|
590
|
+
else
|
|
591
|
+
field << ch
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
finish_row.call unless field.empty? && row.empty?
|
|
596
|
+
out
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
end
|