Module: Vicary::Corpus
- Defined in:
- lib/vicary/corpus.rb
Overview
The three gates that need an essay corpus, measured by this port.
Held-out recall in a carrier essay, over-firing on real prose, and latency at
essay length cannot be measured on isolated sentences. They need fixture
frames planted inside genuine student prose. That prose ships: persuade-20
lives in conformance/corpora/ and is the registry default, so all three are
measured on a bare checkout. They fall back to NOT MEASURED only when the
corpus that resolves is operator-supplied — ASAP-AES, selected either by
VICARY_EVAL_CORPUS or by having VICARY_EVAL_CORPUS_TSV configured — and no
TSV is there to read.
Where the carrier text comes from. Everything about building it is
deterministic except which sentence ends the frames land on, which the Python
reference draws from its Mersenne Twister. Rather than reimplement MT19937
and random.sample here — several hundred lines with nothing to do with
redaction, whose failure mode is silent — the draw is recorded once in
conformance/carrier.json and read back. The plan is an input, exactly as
frames.json is: it says where to inject. What this port then measures from
the resulting text is recovered from its own output, never read from the spec.
Why the digest check is not paranoia. An offset into the wrong essay is not an error anything downstream notices; it produces a plausible number from text nobody intended. So each essay is checked against the digest the plan was built from, and a mismatch raises rather than measuring.
Defined Under Namespace
Constant Summary collapse
- EVAL_CORPUS_TSV_ENV_VAR =
"VICARY_EVAL_CORPUS_TSV"- EVAL_CORPUS_DIR_ENV_VAR =
"VICARY_EVAL_CORPUS_DIR"- EVAL_CORPUS_PREFERRED_FILENAME =
"corpus.tsv"- LATENCY_REPEATS =
How many times each essay is redacted for the latency figure. The recorded number is the MEDIAN of these, not one sample. Must stay odd, so the median is a sample rather than a mean of two.
Why: the latency gate takes p95 across essays, and at n=20 that index is the maximum. So a single-sample-per-essay design asked "did a GC pause land in any one of twenty calls" and answered a
<=gate with it. Five consecutive runs of unchanged code in this port gave 13.8, 7.4, 13.1, 7.7, 6.8 ms against a 10 ms bar — two failures out of five, bimodal at 2x rather than noisy, which is the signature of a pause landing on the one sample that decides the answer. A median of three per essay means a pause has to hit the same essay twice to move the number.Five rather than three because the gated number is now a regression bar with 8% of room, and the estimator has to reproduce itself to well inside that on unchanged code. Every repeat re-redacts the whole corpus, so this is not free; five is where the measured gain flattened. The same constant lives in all three ports, because a gate two ports estimate differently is not the same gate.
5- CARRIER_FILENAME =
"carrier.json"- CARRIER_DOCUMENT_VERSION =
Bumped when a field's meaning changes. An unknown version is refused. 2 keyed the plans by corpus id. A version-1 reader handed a version-2 file finds no
casesat the top level and builds zero carrier essays — which in a<=gate is the most comfortable pass on the board, so the refusal is the point of the number. 2- CORPORA_DIRNAME =
Where the corpus profiles live, under
conformance/. "corpora"- CORPORA_INDEX_FILENAME =
"index.json"- CORPUS_PROFILE_FILENAME =
"profile.json"- KIND_SHIPPED =
Source kinds a corpus profile may declare, and the file the shipped kind keeps beside its profile.
"shipped"- KIND_OPERATOR_TSV =
"operator_tsv"- ESSAYS_FILENAME =
"essays.json"- PROFILE_DOCUMENT_VERSION =
1- EVAL_CORPUS_ENV_VAR =
Names a corpus id directly, overriding the operator-TSV inference.
"VICARY_EVAL_CORPUS"- MEASURED_FILENAME =
The reference's ANSWERS on the plan the carrier file describes. Separate file because they are a different kind of thing:
carrier.jsonis an input every port replays,measured.jsonis what Python got from replaying it. "measured.json"- MEASURED_DOCUMENT_VERSION =
2 keyed the measurements by corpus id. Two of the three numbers are properties of the prose rather than of the detector, so an unkeyed block invited comparing one corpus's figures against another's.
2- ASAP_TOKEN_RE =
One of ASAP's own anonymization tokens —
@PERSON1,@LOCATION2.Load-bearing for the over-fire metric, because the two legs it separates are unrelated. Masking genuine prose is a precision defect; masking
@PERSON1is not, since the PII is already gone. Summed they read as one catastrophic precision failure while the prose leg is zero.\A/\z, not^/$: Ruby anchors those at every line boundary, so a region spanning a newline would match on its last line alone. /\A@[A-Z]+\d*\z/
Class Method Summary collapse
- .asap_token?(region) ⇒ Boolean
-
.build_cases(essays, plan, spec) ⇒ Object
Rebuild the carrier essays from the plan.
- .build_each(essays, planned, by_id) ⇒ Object
- .carrier_path(dir = nil) ⇒ Object
-
.corpus_source ⇒ Object
Configured path to the corpus TSV, or
"". - .load_carrier_plan(corpus_id = nil, dir = nil) ⇒ Object
-
.load_corpus_index(dir = nil) ⇒ Object
The corpus registry: which corpora exist, and which applies by default.
-
.load_corpus_profile(corpus_id, dir = nil) ⇒ Object
One corpus's profile: where its essays come from and which are in.
-
.load_essays(corpus_id = nil, dir = nil) ⇒ Object
The resolved corpus's essays, whichever kind it is.
-
.load_measured(corpus_id = nil, dir = nil) ⇒ Object
The counts the Python reference gets on the carrier text the plan builds.
-
.load_set(tsv, essay_set, limit) ⇒ Object
[[essay_id, text], ...]for the firstlimitessays of the named set, in file order. -
.load_shipped(corpus_id, dir = nil) ⇒ Object
[essay_id, text]for a corpus whose essays ship in this repository. -
.measure(cases, identity) {|, identity| ... } ⇒ Object
Measure the three corpus gates.
-
.measure_from_config(spec, &redact) ⇒ Object
Load the corpus, rebuild the carriers, and measure.
- .measured_path(dir = nil) ⇒ Object
-
.median_of(xs) ⇒ Object
Mean of the two middle samples at even length, matching how the other two ports define it.
- .read_versioned(path, what) ⇒ Object
-
.reconcile_against_corpus(essays, plan, cases) ⇒ Object
Every corpus essay is either carried or named unusable.
-
.resolve_corpus_id(dir = nil) ⇒ Object
Which corpus applies here, in the reference's order: an explicit VICARY_EVAL_CORPUS wins, then an operator with a configured TSV keeps measuring the corpus they always measured, then the registry default.
Class Method Details
.asap_token?(region) ⇒ Boolean
125 126 127 |
# File 'lib/vicary/corpus.rb', line 125 def asap_token?(region) ASAP_TOKEN_RE.match?(region.strip) end |
.build_cases(essays, plan, spec) ⇒ Object
Rebuild the carrier essays from the plan.
Slots are applied in the order recorded — descending — so an earlier insertion cannot shift a later one.
353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 |
# File 'lib/vicary/corpus.rb', line 353 def build_cases(essays, plan, spec) by_id = spec.frames.each_with_object({}) { |f, h| h[f.frame_id] = f } planned = plan["cases"].each_with_object({}) { |c, h| h[c["essay_id"]] = c } cases = build_each(essays, planned, by_id) # Every planned essay, or none of them. A corpus that matches the plan # only partly would measure a *subset* and report it under the same gate # — and the degenerate case of matching nothing is worse than wrong, # because over-firing and latency both then compute as 0.0, which in a # `<=` gate is the most comfortable pass on the board. Refusing is the # only outcome that cannot be mistaken for a green run. if cases.size != plan["cases"].size found = cases.map(&:essay_id).to_set missing = plan["cases"].map { |e| e["essay_id"] }.reject { |id| found.include?(id) } raise Conformance::SpecError, "the carrier plan names #{plan['cases'].size} essays and this corpus " \ "supplied #{cases.size} of them; missing #{missing.first(5).join(', ')}" \ "#{missing.size > 5 ? ' …' : ''}. Refusing to measure a subset, because " \ "over-firing and latency on an empty or partial set compute as 0.0 and " \ "read as a pass." end reconcile_against_corpus(essays, plan, cases) cases end |
.build_each(essays, planned, by_id) ⇒ Object
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 |
# File 'lib/vicary/corpus.rb', line 402 def build_each(essays, planned, by_id) essays.filter_map do |essay_id, base| entry = planned[essay_id] next if entry.nil? digest = Digest::SHA256.hexdigest(base) if digest != entry["base_sha256"] raise Conformance::SpecError, "essay #{essay_id} in this corpus does not match the one the carrier " \ "plan was built from (sha256 #{digest[0, 12]} vs " \ "#{entry['base_sha256'][0, 12]}). The recorded offsets point into " \ "different text, so every number downstream would be wrong without " \ "being detectably wrong." end picks = entry["frames"].map do |fid| by_id.fetch(fid) do raise Conformance::SpecError, "carrier plan names frame #{fid}, absent from the spec" end end text = base.dup picks.each_with_index do |frame, i| at = entry["slots"][i] text = text[0, at] + " " + frame.sentence + text[at..] end Case.new(essay_id: essay_id, text: text, base: base, frames: picks) end end |
.carrier_path(dir = nil) ⇒ Object
228 229 230 |
# File 'lib/vicary/corpus.rb', line 228 def carrier_path(dir = nil) Pathname.new(dir || Conformance.directory).join(CARRIER_FILENAME) end |
.corpus_source ⇒ Object
Configured path to the corpus TSV, or "".
130 131 132 133 134 135 136 137 138 |
# File 'lib/vicary/corpus.rb', line 130 def corpus_source explicit = (ENV[EVAL_CORPUS_TSV_ENV_VAR] || "").strip return explicit unless explicit.empty? directory = (ENV[EVAL_CORPUS_DIR_ENV_VAR] || "").strip return "" if directory.empty? File.join(directory, EVAL_CORPUS_PREFERRED_FILENAME) end |
.load_carrier_plan(corpus_id = nil, dir = nil) ⇒ Object
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 |
# File 'lib/vicary/corpus.rb', line 268 def load_carrier_plan(corpus_id = nil, dir = nil) raw = JSON.parse(carrier_path(dir).read) version = raw["document_version"] unless version == CARRIER_DOCUMENT_VERSION raise Conformance::SpecError, "#{CARRIER_FILENAME} is document_version #{version.inspect}, and this " \ "reader knows #{CARRIER_DOCUMENT_VERSION}. Refusing rather than reading " \ "the fields it recognises, because a partly-read plan produces carrier " \ "text that is wrong without being detectably wrong." end id = corpus_id || resolve_corpus_id(dir) plans = raw["plans"] || {} plan = plans[id] if plan.nil? raise Conformance::SpecError, "#{CARRIER_FILENAME} holds no plan for corpus #{id}; it has " \ "#{plans.keys.sort.join(', ')}. Regenerate with " \ "`python -m vicary.eval.carrier --write` on a machine that can read " \ "that corpus." end # The row filter and essay count are properties of the corpus, so they are # read off its profile rather than restated here — two records of one fact # is how they drift. profile = load_corpus_profile(id, dir) plan.merge( "corpus_id" => id, "essay_set" => profile.dig("source", "filter", "equals"), "limit" => profile.dig("selection", "limit") ) end |
.load_corpus_index(dir = nil) ⇒ Object
The corpus registry: which corpora exist, and which applies by default.
233 234 235 236 237 238 |
# File 'lib/vicary/corpus.rb', line 233 def load_corpus_index(dir = nil) read_versioned( Pathname.new(dir || Conformance.directory) .join(CORPORA_DIRNAME, CORPORA_INDEX_FILENAME), "registry" ) end |
.load_corpus_profile(corpus_id, dir = nil) ⇒ Object
One corpus's profile: where its essays come from and which are in.
241 242 243 244 245 246 |
# File 'lib/vicary/corpus.rb', line 241 def load_corpus_profile(corpus_id, dir = nil) read_versioned( Pathname.new(dir || Conformance.directory) .join(CORPORA_DIRNAME, corpus_id, CORPUS_PROFILE_FILENAME), "profile" ) end |
.load_essays(corpus_id = nil, dir = nil) ⇒ Object
The resolved corpus's essays, whichever kind it is.
nil only for an operator corpus with no TSV configured — the one case
where the data genuinely is not here. A shipped corpus always loads, which
is the whole point of shipping one.
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/vicary/corpus.rb', line 210 def load_essays(corpus_id = nil, dir = nil) id = corpus_id || resolve_corpus_id(dir) profile = load_corpus_profile(id, dir) kind = profile.dig("source", "kind") return load_shipped(id, dir) if kind == KIND_SHIPPED unless kind == KIND_OPERATOR_TSV raise Conformance::SpecError, "corpus #{id} declares source kind #{kind}; this reader knows " \ "#{KIND_SHIPPED} and #{KIND_OPERATOR_TSV}" end tsv = corpus_source return nil if tsv.empty? load_set(tsv, profile.dig("source", "filter", "equals") || "", profile.dig("selection", "limit")) end |
.load_measured(corpus_id = nil, dir = nil) ⇒ Object
The counts the Python reference gets on the carrier text the plan builds.
Read rather than transcribed. These were literals in this port's gate
test — assert_equal 29, m.recall_held_out_passed — and in TypeScript's,
and in Python's. Three copies of a number is not three checks of it: when
the reference's figure legitimately moves, Python's suite is updated
because that is where the change was made, and the other two keep
asserting the stale value and stay green while measuring something else.
Returns the raw document. The envelope matters as much as the numbers, so
nothing here flattens it away — see Gates.check_measured_envelope.
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/vicary/corpus.rb', line 314 def load_measured(corpus_id = nil, dir = nil) raw = JSON.parse(measured_path(dir).read) version = raw["document_version"] unless version == MEASURED_DOCUMENT_VERSION raise Conformance::SpecError, "#{MEASURED_FILENAME} is document_version #{version.inspect}, and this " \ "reader knows #{MEASURED_DOCUMENT_VERSION}. Refusing rather than reading " \ "the fields it recognises: a partly-read document compares this port " \ "against numbers whose meaning it is guessing at." end id = corpus_id || resolve_corpus_id(dir) corpora = raw["corpora"] || {} entry = corpora[id] if entry.nil? raise Conformance::SpecError, "#{MEASURED_FILENAME} holds no measurements for corpus #{id}; it has " \ "#{corpora.keys.sort.join(', ')}. Regenerate with `just sync-conformance` " \ "on a machine that can read that corpus." end entry end |
.load_set(tsv, essay_set, limit) ⇒ Object
[[essay_id, text], ...] for the first limit essays of the named set,
in file order.
Read as latin-1, then converted to UTF-8. ASAP-AES is not UTF-8, and
reading it as UTF-8 yields invalid byte sequences that break both the
digests and every offset computed against them. The conversion afterwards
matters too: the frame sentences come from JSON as UTF-8, and Ruby raises
Encoding::CompatibilityError on concatenating the two encodings once
either side holds a non-ASCII byte.
Parsed here rather than by CSV, for two reasons that both bite on this
file. ASAP essays contain " characters and some records span more than
one physical line inside a quoted field — 12,980 lines for 12,976
records — so splitting on tabs and newlines silently truncates essays
mid-sentence. And the line endings are mixed, 12,979 LF against 12,977
CR, which Ruby's CSV refuses outright ("New line must be <"\n">")
while Python's csv accepts. The state machine below takes CRLF, LF and
a lone CR all as record separators, which is what the reference does.
158 159 160 161 |
# File 'lib/vicary/corpus.rb', line 158 def load_set(tsv, essay_set, limit) text = File.read(tsv, encoding: "ISO-8859-1").encode("UTF-8") parse_delimited(text, "\t", essay_set, limit) end |
.load_shipped(corpus_id, dir = nil) ⇒ Object
[essay_id, text] for a corpus whose essays ship in this repository.
The essays ARE the baseline, so every byte is checked against the digest the profile pins. A corrupted or edited file has to fail here rather than quietly rebase what every corpus gate means — the numbers describe this exact prose and nothing warns you when the prose changes underneath them. The carrier plan checks the same bytes again from its own digests, which is deliberate: two independent records of what this corpus is, and either catches an edit to the other.
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/vicary/corpus.rb', line 172 def load_shipped(corpus_id, dir = nil) profile = load_corpus_profile(corpus_id, dir) text_file = profile.dig("source", "text_file") || ESSAYS_FILENAME path = Pathname.new(dir || Conformance.directory) .join(CORPORA_DIRNAME, corpus_id, text_file) document = read_versioned(path, "corpus") essays = document["essays"].map { |e| [e["id"], e["text"]] } pinned = (profile["essays"] || []).to_h { |e| [e["id"], e["sha256"]] } essays.each do |essay_id, text| want = pinned[essay_id] if want.nil? raise Conformance::SpecError, "#{corpus_id}: #{text_file} carries essay #{essay_id}, which " \ "#{CORPUS_PROFILE_FILENAME} does not list" end got = Digest::SHA256.hexdigest(text) next if got == want raise Conformance::SpecError, "#{corpus_id}: essay #{essay_id} in #{text_file} is sha256 #{got}, and " \ "#{CORPUS_PROFILE_FILENAME} pins #{want}. Refusing: the essays are the " \ "baseline, so different text means every gate number measured on this " \ "corpus describes different prose." end if pinned.size != essays.size raise Conformance::SpecError, "#{corpus_id}: #{CORPUS_PROFILE_FILENAME} lists #{pinned.size} essays " \ "and #{text_file} holds #{essays.size}" end essays end |
.measure(cases, identity) {|, identity| ... } ⇒ Object
Measure the three corpus gates.
Each essay is redacted twice — once with the frames injected, to score recall, and once bare, to see what the redactor does to prose with nothing planted in it. The bare pass is where over-firing comes from, and it is why the metric means anything: the frames cannot contaminate it.
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 |
# File 'lib/vicary/corpus.rb', line 439 def measure(cases, identity) outcomes = [] latencies = [] # Every essay's every sample. The gated figure is the median of THESE, # not a percentile over the per-essay collapses in `latencies`. pooled = [] over_fire = 0 rewrites = 0 # Load the gazetteer before the clock starts. It is a one-time ~207 ms # cost in this port, and whichever essay happens to be first pays all of # it: at n=25 that single sample lands at or above p95 and sets the # gate's answer by itself — 14.3 ms cold against 7.6 ms warm, on a 10 ms # bar. The number the gate claims is essay-length redaction latency, not # process startup. Excluded in all three ports alike. yield(cases.first.base[0, 200], identity) unless cases.empty? cases.each do |kase| # The median of LATENCY_REPEATS, not one sample — see that constant. masked = nil timings = Array.new(LATENCY_REPEATS) do started = Process.clock_gettime(Process::CLOCK_MONOTONIC) masked = yield(kase.text, identity) (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0 end latencies << timings.sort[(LATENCY_REPEATS - 1) / 2] pooled.concat(timings) kase.frames.each { |frame| outcomes.concat(Gates.score_spans(frame, masked)) } masked_base = yield(kase.base, identity) pairs = Gates.align(kase.base, masked_base).pairs prose = pairs.reject { |(_, region)| asap_token?(region) } over_fire += prose.size rewrites += pairs.size - prose.size end held_out = outcomes.select { |o| o.held_out && o.verdict != "keep" } passed = held_out.count(&:passed) sorted = latencies.sort at = lambda do |q| next 0.0 if sorted.empty? sorted[[(sorted.size * q).floor, sorted.size - 1].min] end Metrics.new( essays: cases.size, recall_held_out: held_out.empty? ? 0.0 : 100.0 * passed / held_out.size, recall_held_out_passed: passed, recall_held_out_total: held_out.size, over_fire_spans_per_essay: cases.empty? ? 0.0 : over_fire.to_f / cases.size, over_fire_spans_total: over_fire, asap_rewrites_per_essay: cases.empty? ? 0.0 : rewrites.to_f / cases.size, latency_p50_ms: at.call(0.5), latency_p95_ms: at.call(0.95), latency_pooled_median_ms: median_of(pooled) ) end |
.measure_from_config(spec, &redact) ⇒ Object
Load the corpus, rebuild the carriers, and measure. nil with no corpus.
500 501 502 503 504 505 506 507 |
# File 'lib/vicary/corpus.rb', line 500 def measure_from_config(spec, &redact) corpus_id = resolve_corpus_id essays = load_essays(corpus_id) return nil if essays.nil? || essays.empty? plan = load_carrier_plan(corpus_id) measure(build_cases(essays, plan, spec), spec.identity, &redact) end |
.measured_path(dir = nil) ⇒ Object
299 300 301 |
# File 'lib/vicary/corpus.rb', line 299 def measured_path(dir = nil) Pathname.new(dir || Conformance.directory).join(MEASURED_FILENAME) end |
.median_of(xs) ⇒ Object
Mean of the two middle samples at even length, matching how the other two ports define it. Pooled n is 20 x 5, so the even branch is the one that runs.
117 118 119 120 121 122 123 |
# File 'lib/vicary/corpus.rb', line 117 def median_of(xs) return 0.0 if xs.empty? s = xs.sort mid = s.size / 2 s.size.odd? ? s[mid] : (s[mid - 1] + s[mid]) / 2.0 end |
.read_versioned(path, what) ⇒ Object
336 337 338 339 340 341 342 343 344 345 346 347 |
# File 'lib/vicary/corpus.rb', line 336 def read_versioned(path, what) raw = JSON.parse(path.read) version = raw["document_version"] unless version == PROFILE_DOCUMENT_VERSION raise Conformance::SpecError, "#{path.basename} is document_version #{version.inspect} and this reader " \ "knows #{PROFILE_DOCUMENT_VERSION}. Refusing to read the fields it " \ "recognises: a partly-read #{what} selects a different slice of prose " \ "without being detectably wrong." end raw end |
.reconcile_against_corpus(essays, plan, cases) ⇒ Object
Every corpus essay is either carried or named unusable.
The check above only proves the plan got what it asked for; it cannot see
an essay the plan never asked about. That was safe while a plan always
covered its whole corpus, and stopped being safe when unusable made a
short plan legitimate — without this, a plan that quietly lost ten essays
would measure the fifteen it kept and report them under the same gate.
387 388 389 390 391 392 393 394 395 396 397 398 399 400 |
# File 'lib/vicary/corpus.rb', line 387 def reconcile_against_corpus(essays, plan, cases) unusable = (plan["unusable"] || []).map { |e| e["essay_id"] } accounted = cases.map(&:essay_id).to_set | unusable.to_set unaccounted = essays.map(&:first).reject { |id| accounted.include?(id) } return if unaccounted.empty? raise Conformance::SpecError, "the corpus supplies #{essays.size} essays and the carrier plan accounts " \ "for #{accounted.size} of them — #{plan['cases'].size} carried and " \ "#{unusable.size} declared unusable. Unaccounted: " \ "#{unaccounted.first(5).join(', ')}#{unaccounted.size > 5 ? ' …' : ''}. " \ "An essay the plan neither carries nor names is one it dropped silently, " \ "which is the same comfortable pass as a partial match." end |
.resolve_corpus_id(dir = nil) ⇒ Object
Which corpus applies here, in the reference's order: an explicit VICARY_EVAL_CORPUS wins, then an operator with a configured TSV keeps measuring the corpus they always measured, then the registry default.
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/vicary/corpus.rb', line 251 def resolve_corpus_id(dir = nil) index = load_corpus_index(dir) known = index["corpora"] || [] explicit = (ENV[EVAL_CORPUS_ENV_VAR] || "").strip unless explicit.empty? unless known.include?(explicit) raise Conformance::SpecError, "#{EVAL_CORPUS_ENV_VAR}=#{explicit} is not a registered corpus; this " \ "checkout registers #{known.join(', ')}" end return explicit end return index["operator_default"] if !corpus_source.empty? && index["operator_default"] index["default"] end |