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.
@@ -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