vicary 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94923f9a64b909c5a6ab25e3d7dc1d8caa41843068e1ede77fe938905a1a5745
4
- data.tar.gz: 43e9513288654b871a78115c0f90d86c4f51701944dc01e20df469a62991852d
3
+ metadata.gz: 585c77607d6c331c3cebe14a5da44463a0231f7c3a15e5c1ba68f79d8b9f588b
4
+ data.tar.gz: 883c07a66721ce9c67fb0cfe1f1ea16ce486decd6be62164b1ebbbf53fec9bf0
5
5
  SHA512:
6
- metadata.gz: 888df67ec5e7842da4d43a9398ea28edcb6552f47c84fada77bc9912e3dcbc4a56f1ea4f2b19c67dc0c5701eefc9fef6b6e671e7b29f2c034c92fc7d5dc0e129
7
- data.tar.gz: 650b3ca105ef6dc6801c0232606077bef713f3c84d55668183707c296233c9d6c6c3438e21dbb5d218457735782fb671c5bb97a7fd15d06242059649c46ca549
6
+ metadata.gz: 060ff017a7a0de4ede9652d3e87ddd14083022725ce225739758ce0c7882fe953d6bfaf27ccf48f4fa8b73b480fce98f623dfaf02406944e19e841763b42c022
7
+ data.tar.gz: 0b80b3df83f7399b5118d67abbf2e8c1da589b0ec902d7f2cc04a60be8498e41df9ebdbc569af9c57b487025994bfd5715a58b0d0c2a9a6994e06271fa2e2dcd
data/README.md CHANGED
@@ -32,10 +32,31 @@ Three layers, because each catches what the one above it cannot.
32
32
  | command | what it says |
33
33
  |---|---|
34
34
  | `rake conformance` | the scoreboard against the 54 frames — the final bar, and a coarse first one |
35
+ | `rake gates` | the nine gates, all nine measured from what the repository ships |
35
36
  | `rake test` | the unit suites, including `primitives_test.rb`: forty-odd primitives over the shared corpus, which says *which brick* is crooked |
36
37
  | `rake parity` | gazetteer verdicts, name by name, against the Python reference |
37
38
  | `rake redaction_parity` | masked bytes against the Python reference, on prose no fixture contains |
38
39
 
40
+ A sixth gate — bare-surname exposure — this gem measures from the surname table
41
+ shipped in `conformance/census/`, reporting 1.20% of US surname bearers, the same
42
+ figure Python and TypeScript report from the same table. `VICARY_EVAL_CENSUS_CSV`
43
+ overrides it with your own Census copy, and must be the **extracted**
44
+ `Names_2010Census.csv` from the census.gov 2010 surnames release: a `.zip` is
45
+ refused by name rather than read as text, because Ruby's standard library has no
46
+ zip reader and a binary read parsed as CSV yields zero rows — a *lower* exposure
47
+ than the truth, and the wrong direction to fail in silently. The shipped table is
48
+ gzip, which `zlib` reads, so that hazard does not arise on the default path.
49
+
50
+ The last three need an essay corpus no package here ships, and this gem measures
51
+ them once `VICARY_EVAL_CORPUS_TSV` points at one: 100% carrier recall (29/29
52
+ held-out REDACT spans) and 0.60 over-fired spans per essay (15 across 25),
53
+ identical to Python and TypeScript, plus its own latency p95 of 8.8–9.9 ms. **That
54
+ last one is worth knowing about**: the bar is ≤ 10 ms, and this port runs nearest
55
+ it of the three — roughly 4× Python — so the latency gate is a live constraint
56
+ here rather than a formality. The carrier essays are built from offsets recorded
57
+ in `conformance/carrier.json` rather than from a reimplementation of Python's
58
+ RNG, and the suite asserts their sha256.
59
+
39
60
  The last two need the reference interpreter — run `just py-setup` from the
40
61
  repository root first.
41
62
 
@@ -166,6 +166,47 @@ module Vicary
166
166
  # corpus.
167
167
  SENTENCE_BREAK = /(?:\A|[.!?]["'’”)]*\s+|\n+|(?:(?<=\s)|\A)["'‘“](?=[A-Za-z]))\s*/
168
168
 
169
+ # Words whose trailing period abbreviates rather than ends a sentence, and
170
+ # which are followed by a name more often than not.
171
+ #
172
+ # Load-bearing for {.sentence_starts}, and the reason is a leak. The break
173
+ # pattern reads `[.!?]\s+` as a sentence boundary, so "Mrs. Okonkwo" put
174
+ # "Okonkwo" in sentence-initial position — where a capital is orthographically
175
+ # required and therefore proves nothing — and the document's one piece of
176
+ # testimony about that surname was discarded. In a persuade-20 carrier essay
177
+ # that withdrew the corroboration the lowercase route needed and leaked
178
+ # "terrence okonkwo". An honorific is the exact case where the capital that
179
+ # follows is *most* likely to be a name, so reading it as a sentence start
180
+ # inverts the signal.
181
+ #
182
+ # Deliberately only titles, not every abbreviation. "etc." or "vs." are also
183
+ # not sentence ends, but nothing follows them that this set exists to protect,
184
+ # and a wider list costs precision everywhere for no recall.
185
+ TITLE_ABBREVIATIONS = Set.new(%w[
186
+ mr mrs ms dr prof rev fr sr jr st
187
+ sgt capt lt col gen gov sen rep hon
188
+ ]).freeze
189
+
190
+ # Matches the abbreviation a break candidate sits directly behind.
191
+ TRAILING_WORD = /([A-Za-z]+)\.\s*\z/
192
+
193
+ # {SENTENCE_BREAK} with the `\A` arm removed, so it cannot match empty.
194
+ #
195
+ # A dialect difference, and it moved a number. `SENTENCE_BREAK` matches the
196
+ # empty string at offset 0 via its `\A` arm. Python's `re` then retries a
197
+ # **non-empty** match at that same offset before advancing; {.each_match}
198
+ # advances past a zero-width match instead and never looks again. On an essay
199
+ # that opens with a quotation — `"Pedestrian, bicycle, private cars…` —
200
+ # Python records sentence starts at both 0 and 1, this port recorded only 0,
201
+ # and `Pedestrian` lost the sentence-initial discount its capital is owed. It
202
+ # was then read as a name and masked: one over-fire span on `persuade-20`
203
+ # that Python does not produce.
204
+ #
205
+ # Every arm here consumes at least one character, which is what makes the
206
+ # retry terminate.
207
+ SENTENCE_BREAK_NONEMPTY =
208
+ /(?:[.!?]["'’”)]*\s+|\n+|(?:(?<=\s)|\A)["'‘“](?=[A-Za-z]))\s*/
209
+
169
210
  # One entirely-lowercase word. The leading boundary is what keeps this from
170
211
  # matching the tail of a capitalised word — there is no word boundary between
171
212
  # the "T" and the "errence" of "Terrence", so the capitalised route keeps
@@ -728,8 +769,35 @@ module Vicary
728
769
  # ---------------------------------------------------------------------
729
770
 
730
771
  # Offsets at which a sentence begins.
772
+ #
773
+ # A break directly behind a title abbreviation is not one — see
774
+ # {TITLE_ABBREVIATIONS} for the leak that rule exists to close.
731
775
  def sentence_starts(text)
732
- each_match(text, SENTENCE_BREAK).map { |m| m.begin(0) + m[0].length }.to_set
776
+ out = Set.new
777
+ keep = lambda do |finish|
778
+ preceding = TRAILING_WORD.match(text[0...finish])
779
+ next if preceding && TITLE_ABBREVIATIONS.include?(preceding[1].downcase)
780
+
781
+ out << finish
782
+ end
783
+
784
+ pos = 0
785
+ while pos <= text.length && (m = SENTENCE_BREAK.match(text, pos))
786
+ keep.call(m.begin(0) + m[0].length)
787
+ if m[0].empty?
788
+ # Empty match: Python retries a non-empty one here before moving on.
789
+ again = SENTENCE_BREAK_NONEMPTY.match(text, m.begin(0))
790
+ if again && again.begin(0) == m.begin(0)
791
+ keep.call(again.end(0))
792
+ pos = again.end(0)
793
+ else
794
+ pos = m.begin(0) + 1
795
+ end
796
+ else
797
+ pos = m.end(0)
798
+ end
799
+ end
800
+ out
733
801
  end
734
802
 
735
803
  # Character ranges of all-caps runs SHORTER than {ALLCAPS_RUN}.
@@ -1427,10 +1495,6 @@ module Vicary
1427
1495
  index += 1
1428
1496
  next
1429
1497
  end
1430
- if !corroborate.nil? && !corroborate.include?(strip(word, "'’"))
1431
- index += 1
1432
- next
1433
- end
1434
1498
  if index.positive? && DETERMINERS.include?(tokens[index - 1][0])
1435
1499
  # Only a directly-adjacent determiner counts. "the day terrence
1436
1500
  # arrived" must stay reachable, and punctuation between the two means
@@ -1460,6 +1524,23 @@ module Vicary
1460
1524
  index += 1
1461
1525
  next
1462
1526
  end
1527
+ # Corroboration is asked of the WHOLE span, not of its first token, and
1528
+ # it is asked here rather than before `reach` is known because the span's
1529
+ # extent is what decides which tokens may vouch for it.
1530
+ #
1531
+ # Checking only the given name is what leaked "terrence okonkwo" out of a
1532
+ # persuade-20 carrier essay. That document is INCONSISTENT, so this route
1533
+ # runs with corroboration required; it capitalises "Okonkwo" mid-sentence
1534
+ # and never writes "Terrence" at all, so the one token consulted was the
1535
+ # one the writer happened not to capitalise — while the surname of the
1536
+ # same person sat in the same document as exactly the evidence being
1537
+ # asked for. {.corroborated?} already settled this question the other way
1538
+ # ("ANY token counts, not just the first"); this channel simply never
1539
+ # adopted it.
1540
+ if !corroborate.nil? && (index..reach).none? { |i| corroborate.include?(strip(tokens[i][0], "'’")) }
1541
+ index += 1
1542
+ next
1543
+ end
1463
1544
  joined = text[start...span_end]
1464
1545
  out << Candidate.new(joined, start, span_end, classify(words(joined), settlement))
1465
1546
  index = reach + 1
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "pathname"
6
+ require "set"
7
+ require "zlib"
8
+
9
+ module Vicary
10
+ # The false-positive control the fixture cannot provide.
11
+ #
12
+ # The fixture reports zero leaks on bare surnames partly because the private
13
+ # surnames in it are rare — Okonkwo, Bramwell, Pritchard, Ybarra. A clean
14
+ # control needs an *unlikely* clean, so this scores the single-token tiers
15
+ # against **every American surname**: the population-weighted rate at which a
16
+ # bare surname resolves notable, regardless of whose surname it is.
17
+ #
18
+ # Read the headline number as: *for a private person named by bare surname
19
+ # only — no first name, no title, no same-document corroboration — this share
20
+ # resolves "notable" and leaks.* It is conditional on that surface form, which
21
+ # is a minority of private-name mentions in real prose, so it is not an
22
+ # essay-level leak rate.
23
+ #
24
+ # The source is the US Census 2010 surname file, and this repository now ships
25
+ # the two columns of it this measurement uses — see `conformance/census/`,
26
+ # built by `tools/census_build.py`. So the gate is measured on a bare checkout
27
+ # and in CI, which it was not: census.gov stopped serving the upstream, and the
28
+ # gate reported NOT MEASURED everywhere but on a machine holding a
29
+ # hand-downloaded copy.
30
+ #
31
+ # `VICARY_EVAL_CENSUS_CSV` still wins when set — an operator holding a newer
32
+ # release gets the number their file gives.
33
+ #
34
+ # **For that operator file, this port reads the extracted `.csv` only.** Python
35
+ # additionally accepts the distributed `.zip` because its standard library has
36
+ # a zip reader and Ruby's does not. A `.zip` here is refused by name rather
37
+ # than parsed as text, since the alternative is a binary read that yields zero
38
+ # rows — which is a *lower* exposure rate than the truth, and the wrong
39
+ # direction to fail in silently. The shipped table sidesteps this entirely: it
40
+ # is gzip, which `zlib` reads.
41
+ module Census
42
+ # Where a locally-held copy of the Census surname file is configured.
43
+ EVAL_CENSUS_CSV_ENV_VAR = "VICARY_EVAL_CENSUS_CSV"
44
+
45
+ # The member name inside the distributed archive, and the file this port
46
+ # wants handed to it directly.
47
+ CENSUS_SURNAMES_MEMBER = "Names_2010Census.csv"
48
+
49
+ # Where the operator gets the file, quoted when it is missing.
50
+ CENSUS_SURNAMES_URL = "https://www2.census.gov/topics/genealogy/2010surnames/names.zip"
51
+
52
+ # The row-count floor. Not decoration: this list is scored *against* the
53
+ # single-token tiers, so a short read shrinks the denominator and reports a
54
+ # more comfortable exposure rate than the truth.
55
+ MINIMUM_ROWS = 100_000
56
+
57
+ # Directory under `conformance/` holding the shipped table and its provenance.
58
+ SHIPPED_DIRNAME = "census"
59
+ SHIPPED_TABLE_FILENAME = "surnames.txt.gz"
60
+ SHIPPED_PROFILE_FILENAME = "profile.json"
61
+
62
+ # How much of the US surname population the single-token tiers claim.
63
+ Exposure = Struct.new(
64
+ :surnames_scored, # Distinct surnames in the Census file.
65
+ :surnames_matched, # Distinct surnames matching some single-token tier.
66
+ :bearers_total, # Total bearers across the file.
67
+ :bearers_exposed, # Bearers whose surname matches some single-token tier.
68
+ :bearers_via_short, # Bearers exposed via the `short` tier specifically.
69
+ :bearers_via_place, # Bearers exposed via a single-token `place` entry.
70
+ # Bearers exposed via the `demonym` tier. Counted here for the same reason
71
+ # the other two are: it is a KEEP granted to a bare single token, which is
72
+ # exactly the surface form this control measures. Leaving it out would make
73
+ # adding a tier look free.
74
+ :bearers_via_demonym,
75
+ keyword_init: true
76
+ ) do
77
+ # Population-weighted exposure, as a percentage. The headline.
78
+ def rate
79
+ 100.0 * bearers_exposed / bearers_total
80
+ end
81
+
82
+ def short_rate
83
+ 100.0 * bearers_via_short / bearers_total
84
+ end
85
+
86
+ def place_rate
87
+ 100.0 * bearers_via_place / bearers_total
88
+ end
89
+
90
+ def demonym_rate
91
+ 100.0 * bearers_via_demonym / bearers_total
92
+ end
93
+
94
+ def distinct_rate
95
+ 100.0 * surnames_matched / surnames_scored
96
+ end
97
+ end
98
+
99
+ class << self
100
+ # Configured path to a local Census surname file, or `""`.
101
+ def census_source
102
+ (ENV[EVAL_CENSUS_CSV_ENV_VAR] || "").strip
103
+ end
104
+
105
+ # `{normalised surname => number of US bearers}` from the Census CSV text.
106
+ #
107
+ # Field-indexed off the header rather than positional, so a column added
108
+ # upstream shifts nothing. The file carries no quoted fields — every row is
109
+ # 11 bare comma-separated values — so this splits rather than requiring
110
+ # `csv`, and a row that does not yield an integer count is skipped the same
111
+ # way Python's `DictReader` loop skips it.
112
+ def parse_census_surnames(text)
113
+ counts = {}
114
+ lines = text.split(/\r?\n/, -1)
115
+ header = (lines.first || "").split(",")
116
+ name_at = header.index("name")
117
+ count_at = header.index("count")
118
+ if name_at.nil? || count_at.nil?
119
+ raise ArgumentError,
120
+ "Census surname file has no 'name'/'count' header; got #{header.join(',')}"
121
+ end
122
+
123
+ lines.drop(1).each do |line|
124
+ next if line.empty?
125
+
126
+ fields = line.split(",")
127
+ name = Gazetteer.normalize(fields[name_at] || "")
128
+ next if name.empty? || name == "all other names"
129
+
130
+ count = Integer(fields[count_at], exception: false)
131
+ next if count.nil?
132
+
133
+ counts[name] = count
134
+ end
135
+
136
+ if counts.size < MINIMUM_ROWS
137
+ raise RuntimeError,
138
+ "Census surname file parsed to only #{counts.size} rows; expected ~162k. " \
139
+ "Refusing to score exposure against a truncated list, because the " \
140
+ "failure mode is a more comfortable rate than the truth."
141
+ end
142
+ counts
143
+ end
144
+
145
+ # `conformance/census/`, or nil outside a checkout.
146
+ def shipped_dir
147
+ candidate = Conformance.directory.join(SHIPPED_DIRNAME)
148
+ candidate.join(SHIPPED_TABLE_FILENAME).file? ? candidate : nil
149
+ rescue Conformance::SpecError
150
+ nil
151
+ end
152
+
153
+ # `{normalised surname => bearers}` from the table this repository ships.
154
+ #
155
+ # The digest in `profile.json` is checked, not trusted. This table is used
156
+ # to SUBTRACT exposure from a permissive tier, so a truncated or edited
157
+ # copy scores the gazetteer against a smaller America and reads as a
158
+ # *better* number — the one direction this measurement must never fail in
159
+ # quietly. A bad digest raises rather than degrading.
160
+ def load_shipped_census(directory = nil)
161
+ dir = directory ? Pathname.new(directory) : shipped_dir
162
+ if dir.nil?
163
+ raise Errno::ENOENT,
164
+ "no conformance/#{SHIPPED_DIRNAME}/ above this module. The shipped " \
165
+ "table lives in the repository, not in an installed gem."
166
+ end
167
+
168
+ payload = dir.join(SHIPPED_TABLE_FILENAME).binread
169
+ profile = JSON.parse(dir.join(SHIPPED_PROFILE_FILENAME).read)
170
+ expected = profile.dig("table", "sha256").to_s
171
+ actual = Digest::SHA256.hexdigest(payload)
172
+ if !expected.empty? && actual != expected
173
+ raise RuntimeError,
174
+ "#{SHIPPED_TABLE_FILENAME} has sha256 #{actual}, but " \
175
+ "#{SHIPPED_PROFILE_FILENAME} pins #{expected}. Refusing to score the " \
176
+ "gazetteer against a table that is not the one this repository " \
177
+ "measured, because a short read reads as a better number. Rebuild " \
178
+ "with `python tools/census_build.py --write`."
179
+ end
180
+
181
+ counts = {}
182
+ Zlib.gunzip(payload).force_encoding("UTF-8").each_line do |line|
183
+ name, _, bearers = line.chomp.partition("\t")
184
+ counts[name] = Integer(bearers) unless name.empty?
185
+ end
186
+ if counts.size < MINIMUM_ROWS
187
+ raise RuntimeError,
188
+ "#{SHIPPED_TABLE_FILENAME} parsed to only #{counts.size} rows; " \
189
+ "expected at least #{MINIMUM_ROWS}."
190
+ end
191
+ counts
192
+ end
193
+
194
+ # `{normalised surname => bearers}`, resolved in this order:
195
+ #
196
+ # 1. An explicit `source`, or `VICARY_EVAL_CENSUS_CSV`. An operator holding
197
+ # a newer Census release still wins, and gets the number *their* file
198
+ # gives.
199
+ # 2. The table shipped in `conformance/census/`, which is the same 162,253
200
+ # rows the 2010 release carries and therefore the same rate to the last
201
+ # bearer. This is why the gate no longer skips on a bare checkout.
202
+ #
203
+ # There is no third step. census.gov answers the documented URL with a WAF
204
+ # rejection page under a 200 status, which is why the shipped table exists.
205
+ def load_census(source = nil)
206
+ path = (source || census_source).strip
207
+ if path.empty?
208
+ return load_shipped_census unless shipped_dir.nil?
209
+
210
+ raise Errno::ENOENT,
211
+ "no conformance/#{SHIPPED_DIRNAME}/ in this tree and no " \
212
+ "#{EVAL_CENSUS_CSV_ENV_VAR} set. Point that at a copy of " \
213
+ "#{CENSUS_SURNAMES_MEMBER}, extracted from #{CENSUS_SURNAMES_URL}, " \
214
+ "or run from a checkout"
215
+ end
216
+ if path.downcase.end_with?(".zip")
217
+ raise ArgumentError,
218
+ "#{path} is a .zip and this port reads the extracted .csv only. " \
219
+ "Extract #{CENSUS_SURNAMES_MEMBER} from it and point " \
220
+ "#{EVAL_CENSUS_CSV_ENV_VAR} at that."
221
+ end
222
+ parse_census_surnames(File.read(path, encoding: "UTF-8"))
223
+ end
224
+
225
+ # Score the loaded gazetteer's single-token tiers against the Census file.
226
+ def measure(census, gaz = nil)
227
+ gaz ||= Gazetteer.load
228
+ single_token_places = gaz.place.reject { |n| n.include?(" ") }.to_set
229
+ single = single_token_places | gaz.short.to_set | gaz.demonym.to_set
230
+
231
+ bearers_total = 0
232
+ bearers_exposed = 0
233
+ surnames_matched = 0
234
+ via_short = 0
235
+ via_place = 0
236
+ via_demonym = 0
237
+
238
+ census.each do |name, count|
239
+ bearers_total += count
240
+ if single.include?(name)
241
+ bearers_exposed += count
242
+ surnames_matched += 1
243
+ end
244
+ via_short += count if gaz.short.include?(name)
245
+ via_place += count if single_token_places.include?(name)
246
+ via_demonym += count if gaz.demonym.include?(name)
247
+ end
248
+
249
+ Exposure.new(
250
+ surnames_scored: census.size,
251
+ surnames_matched: surnames_matched,
252
+ bearers_total: bearers_total,
253
+ bearers_exposed: bearers_exposed,
254
+ bearers_via_short: via_short,
255
+ bearers_via_place: via_place,
256
+ bearers_via_demonym: via_demonym
257
+ )
258
+ end
259
+
260
+ # The report block, for a CLI or a gate's failure message.
261
+ def render(exposure)
262
+ [
263
+ "BARE-SURNAME FALSE-POSITIVE RATE (US Census 2010 surname file)",
264
+ " distinct surnames scored #{group(exposure.surnames_scored)}",
265
+ " any single-token tier hit #{group(exposure.surnames_matched)} " \
266
+ "(#{format('%.2f', exposure.distinct_rate)}% of distinct)",
267
+ " population-weighted rate #{format('%.2f', exposure.rate)}% " \
268
+ "(#{group(exposure.bearers_exposed)} / #{group(exposure.bearers_total)} bearers)",
269
+ " via the short tier #{format('%.2f', exposure.short_rate)}%",
270
+ " via single-token places #{format('%.2f', exposure.place_rate)}%",
271
+ " via the demonym tier #{format('%.2f', exposure.demonym_rate)}%",
272
+ " reads as: for a private person named by BARE SURNAME ONLY — no",
273
+ " first name, no title, no corroboration — this share",
274
+ " resolves 'notable'. Conditional on that surface form."
275
+ ].join("\n")
276
+ end
277
+
278
+ private
279
+
280
+ def group(value)
281
+ value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
282
+ end
283
+ end
284
+ end
285
+ end
@@ -33,8 +33,20 @@ module Vicary
33
33
  keyword_init: true)
34
34
  Spec = Struct.new(:fixture_version, :reference_arm, :identity, :frames,
35
35
  :golden, keyword_init: true)
36
- Gate = Struct.new(:id, :label, :unit, :op, :bar, :requires, :why,
37
- keyword_init: true)
36
+ # `bars_by_corpus` overrides `bar` per corpus id. Only `over_fire_prose`
37
+ # carries one: it is the sole gate whose bar describes the *prose* rather
38
+ # than the detector, and ASAP-AES's 0.61 against PERSUADE's 8.15 is what one
39
+ # corpus naming real entities and another naming `@PERSON` tokens costs.
40
+ # `bar` remains the fallback, and is the tighter of the two.
41
+ Gate = Struct.new(:id, :label, :unit, :op, :bar, :bars_by_corpus, :requires,
42
+ :why, keyword_init: true) do
43
+ # The bar this gate holds `corpus_id` to — its override, else its default.
44
+ def bar_for(corpus_id)
45
+ return bar if corpus_id.nil?
46
+
47
+ (bars_by_corpus || {}).fetch(corpus_id, bar)
48
+ end
49
+ end
38
50
  GateSpec = Struct.new(:reference_arm, :requirements, :gates,
39
51
  keyword_init: true)
40
52
  Outcome = Struct.new(:frame_id, :requires_masking, :matched, :expected,
@@ -110,8 +122,8 @@ module Vicary
110
122
  gates: raw.fetch("gates").map do |g|
111
123
  Gate.new(id: g.fetch("id"), label: g.fetch("label"),
112
124
  unit: g.fetch("unit"), op: g.fetch("op"),
113
- bar: g.fetch("bar"), requires: g.fetch("requires"),
114
- why: g.fetch("why"))
125
+ bar: g.fetch("bar"), bars_by_corpus: g["bars_by_corpus"],
126
+ requires: g.fetch("requires"), why: g.fetch("why"))
115
127
  end,
116
128
  )
117
129
  end
@@ -189,9 +201,16 @@ module Vicary
189
201
  #
190
202
  # Leads with the masking-required ratio, the one a null implementation
191
203
  # cannot inflate. Gates print NOT MEASURED per gate rather than being
192
- # reduced out of the denominator — five of nine held is a different
204
+ # reduced out of the denominator — eight of nine held is a different
193
205
  # statement from nine of nine, and a badge cannot tell them apart.
194
- def report(board, gates)
206
+ #
207
+ # +gate_block+ is a rendered gate block from `gates.rb`. Passed in rather
208
+ # than computed here because measuring a gate needs the detector and the
209
+ # asset, and this module is the spec loader — requiring them would make the
210
+ # loader depend on the thing it exists to score. Absent, the unmeasured
211
+ # block below is printed, which is the honest output for a caller that
212
+ # measured nothing.
213
+ def report(board, gates, gate_block = nil)
195
214
  lines = []
196
215
  lines << "conformance — fixture #{board.fixture_version}, arm #{board.reference_arm}"
197
216
  lines << ("-" * 58)
@@ -202,14 +221,19 @@ module Vicary
202
221
  board.matched, board.total,
203
222
  board.total - board.requiring_masking)
204
223
  lines << ("-" * 58)
224
+ if gate_block
225
+ lines << gate_block
226
+ return lines.join("\n")
227
+ end
228
+
205
229
  lines << " gates:"
206
230
  gates.gates.each do |gate|
207
231
  needs = gate.requires.empty? ? "" : " NEEDS #{gate.requires.join('+')}"
208
232
  lines << format(" NOT MEASURED %-28s %s %s %s%s",
209
233
  gate.label, gate.op, gate.bar, gate.unit, needs)
210
234
  end
211
- lines << " -> no gate is measured by this port yet. A green run here " \
212
- "means the spec loads,"
235
+ lines << " -> the caller measured no gate. A green run here means the " \
236
+ "spec loads,"
213
237
  lines << " never that the gate set is clear."
214
238
  lines.join("\n")
215
239
  end