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 +4 -4
- data/README.md +21 -0
- data/lib/vicary/candidates.rb +86 -5
- data/lib/vicary/census.rb +285 -0
- data/lib/vicary/conformance.rb +32 -8
- 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 +56 -6
- data/lib/vicary/version.rb +1 -1
- data/lib/vicary.rb +4 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 585c77607d6c331c3cebe14a5da44463a0231f7c3a15e5c1ba68f79d8b9f588b
|
|
4
|
+
data.tar.gz: 883c07a66721ce9c67fb0cfe1f1ea16ce486decd6be62164b1ebbbf53fec9bf0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/vicary/candidates.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/vicary/conformance.rb
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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"),
|
|
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 —
|
|
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
|
-
|
|
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 << " ->
|
|
212
|
-
"
|
|
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
|