Module: Vicary::Census
- Defined in:
- lib/vicary/census.rb
Overview
The false-positive control the fixture cannot provide.
The fixture reports zero leaks on bare surnames partly because the private surnames in it are rare — Okonkwo, Bramwell, Pritchard, Ybarra. A clean control needs an unlikely clean, so this scores the single-token tiers against every American surname: the population-weighted rate at which a bare surname resolves notable, regardless of whose surname it is.
Read the headline number as: for a private person named by bare surname only — no first name, no title, no same-document corroboration — this share resolves "notable" and leaks. It is conditional on that surface form, which is a minority of private-name mentions in real prose, so it is not an essay-level leak rate.
The source is the US Census 2010 surname file, and this repository now ships
the two columns of it this measurement uses — see conformance/census/,
built by tools/census_build.py. So the gate is measured on a bare checkout
and in CI, which it was not: census.gov stopped serving the upstream, and the
gate reported NOT MEASURED everywhere but on a machine holding a
hand-downloaded copy.
VICARY_EVAL_CENSUS_CSV still wins when set — an operator holding a newer
release gets the number their file gives.
For that operator file, this port reads the extracted .csv only. Python
additionally accepts the distributed .zip because its standard library has
a zip reader and Ruby's does not. A .zip here is refused by name rather
than parsed as text, since the alternative is a binary read that yields zero
rows — which is a lower exposure rate than the truth, and the wrong
direction to fail in silently. The shipped table sidesteps this entirely: it
is gzip, which zlib reads.
Defined Under Namespace
Classes: Exposure
Constant Summary collapse
- EVAL_CENSUS_CSV_ENV_VAR =
Where a locally-held copy of the Census surname file is configured.
"VICARY_EVAL_CENSUS_CSV"- CENSUS_SURNAMES_MEMBER =
The member name inside the distributed archive, and the file this port wants handed to it directly.
"Names_2010Census.csv"- CENSUS_SURNAMES_URL =
Where the operator gets the file, quoted when it is missing.
"https://www2.census.gov/topics/genealogy/2010surnames/names.zip"- MINIMUM_ROWS =
The row-count floor. Not decoration: this list is scored against the single-token tiers, so a short read shrinks the denominator and reports a more comfortable exposure rate than the truth.
100_000- SHIPPED_DIRNAME =
Directory under
conformance/holding the shipped table and its provenance. "census"- SHIPPED_TABLE_FILENAME =
"surnames.txt.gz"- SHIPPED_PROFILE_FILENAME =
"profile.json"
Class Method Summary collapse
-
.census_source ⇒ Object
Configured path to a local Census surname file, or
"". -
.load_census(source = nil) ⇒ Object
{normalised surname => bearers}, resolved in this order:. -
.load_shipped_census(directory = nil) ⇒ Object
{normalised surname => bearers}from the table this repository ships. -
.measure(census, gaz = nil) ⇒ Object
Score the loaded gazetteer's single-token tiers against the Census file.
-
.parse_census_surnames(text) ⇒ Object
{normalised surname => number of US bearers}from the Census CSV text. -
.render(exposure) ⇒ Object
The report block, for a CLI or a gate's failure message.
-
.shipped_dir ⇒ Object
conformance/census/, or nil outside a checkout.
Class Method Details
.census_source ⇒ Object
Configured path to a local Census surname file, or "".
101 102 103 |
# File 'lib/vicary/census.rb', line 101 def census_source (ENV[EVAL_CENSUS_CSV_ENV_VAR] || "").strip end |
.load_census(source = nil) ⇒ Object
{normalised surname => bearers}, resolved in this order:
- An explicit
source, orVICARY_EVAL_CENSUS_CSV. An operator holding a newer Census release still wins, and gets the number their file gives. - The table shipped in
conformance/census/, which is the same 162,253 rows the 2010 release carries and therefore the same rate to the last bearer. This is why the gate no longer skips on a bare checkout.
There is no third step. census.gov answers the documented URL with a WAF rejection page under a 200 status, which is why the shipped table exists.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 |
# File 'lib/vicary/census.rb', line 205 def load_census(source = nil) path = (source || census_source).strip if path.empty? return load_shipped_census unless shipped_dir.nil? raise Errno::ENOENT, "no conformance/#{SHIPPED_DIRNAME}/ in this tree and no " \ "#{EVAL_CENSUS_CSV_ENV_VAR} set. Point that at a copy of " \ "#{CENSUS_SURNAMES_MEMBER}, extracted from #{CENSUS_SURNAMES_URL}, " \ "or run from a checkout" end if path.downcase.end_with?(".zip") raise ArgumentError, "#{path} is a .zip and this port reads the extracted .csv only. " \ "Extract #{CENSUS_SURNAMES_MEMBER} from it and point " \ "#{EVAL_CENSUS_CSV_ENV_VAR} at that." end parse_census_surnames(File.read(path, encoding: "UTF-8")) end |
.load_shipped_census(directory = nil) ⇒ Object
{normalised surname => bearers} from the table this repository ships.
The digest in profile.json is checked, not trusted. This table is used
to SUBTRACT exposure from a permissive tier, so a truncated or edited
copy scores the gazetteer against a smaller America and reads as a
better number — the one direction this measurement must never fail in
quietly. A bad digest raises rather than degrading.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/vicary/census.rb', line 160 def load_shipped_census(directory = nil) dir = directory ? Pathname.new(directory) : shipped_dir if dir.nil? raise Errno::ENOENT, "no conformance/#{SHIPPED_DIRNAME}/ above this module. The shipped " \ "table lives in the repository, not in an installed gem." end payload = dir.join(SHIPPED_TABLE_FILENAME).binread profile = JSON.parse(dir.join(SHIPPED_PROFILE_FILENAME).read) expected = profile.dig("table", "sha256").to_s actual = Digest::SHA256.hexdigest(payload) if !expected.empty? && actual != expected raise RuntimeError, "#{SHIPPED_TABLE_FILENAME} has sha256 #{actual}, but " \ "#{SHIPPED_PROFILE_FILENAME} pins #{expected}. Refusing to score the " \ "gazetteer against a table that is not the one this repository " \ "measured, because a short read reads as a better number. Rebuild " \ "with `python tools/census_build.py --write`." end counts = {} Zlib.gunzip(payload).force_encoding("UTF-8").each_line do |line| name, _, bearers = line.chomp.partition("\t") counts[name] = Integer(bearers) unless name.empty? end if counts.size < MINIMUM_ROWS raise RuntimeError, "#{SHIPPED_TABLE_FILENAME} parsed to only #{counts.size} rows; " \ "expected at least #{MINIMUM_ROWS}." end counts end |
.measure(census, gaz = nil) ⇒ Object
Score the loaded gazetteer's single-token tiers against the Census file.
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/vicary/census.rb', line 226 def measure(census, gaz = nil) gaz ||= Gazetteer.load single_token_places = gaz.place.reject { |n| n.include?(" ") }.to_set single = single_token_places | gaz.short.to_set | gaz.demonym.to_set bearers_total = 0 bearers_exposed = 0 surnames_matched = 0 via_short = 0 via_place = 0 via_demonym = 0 census.each do |name, count| bearers_total += count if single.include?(name) bearers_exposed += count surnames_matched += 1 end via_short += count if gaz.short.include?(name) via_place += count if single_token_places.include?(name) via_demonym += count if gaz.demonym.include?(name) end Exposure.new( surnames_scored: census.size, surnames_matched: surnames_matched, bearers_total: bearers_total, bearers_exposed: bearers_exposed, bearers_via_short: via_short, bearers_via_place: via_place, bearers_via_demonym: via_demonym ) end |
.parse_census_surnames(text) ⇒ Object
{normalised surname => number of US bearers} from the Census CSV text.
Field-indexed off the header rather than positional, so a column added
upstream shifts nothing. The file carries no quoted fields — every row is
11 bare comma-separated values — so this splits rather than requiring
csv, and a row that does not yield an integer count is skipped the same
way Python's DictReader loop skips it.
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/vicary/census.rb', line 112 def parse_census_surnames(text) counts = {} lines = text.split(/\r?\n/, -1) header = (lines.first || "").split(",") name_at = header.index("name") count_at = header.index("count") if name_at.nil? || count_at.nil? raise ArgumentError, "Census surname file has no 'name'/'count' header; got #{header.join(',')}" end lines.drop(1).each do |line| next if line.empty? fields = line.split(",") name = Gazetteer.normalize(fields[name_at] || "") next if name.empty? || name == "all other names" count = Integer(fields[count_at], exception: false) next if count.nil? counts[name] = count end if counts.size < MINIMUM_ROWS raise RuntimeError, "Census surname file parsed to only #{counts.size} rows; expected ~162k. " \ "Refusing to score exposure against a truncated list, because the " \ "failure mode is a more comfortable rate than the truth." end counts end |
.render(exposure) ⇒ Object
The report block, for a CLI or a gate's failure message.
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 |
# File 'lib/vicary/census.rb', line 261 def render(exposure) [ "BARE-SURNAME FALSE-POSITIVE RATE (US Census 2010 surname file)", " distinct surnames scored #{group(exposure.surnames_scored)}", " any single-token tier hit #{group(exposure.surnames_matched)} " \ "(#{format('%.2f', exposure.distinct_rate)}% of distinct)", " population-weighted rate #{format('%.2f', exposure.rate)}% " \ "(#{group(exposure.bearers_exposed)} / #{group(exposure.bearers_total)} bearers)", " via the short tier #{format('%.2f', exposure.short_rate)}%", " via single-token places #{format('%.2f', exposure.place_rate)}%", " via the demonym tier #{format('%.2f', exposure.demonym_rate)}%", " reads as: for a private person named by BARE SURNAME ONLY — no", " first name, no title, no corroboration — this share", " resolves 'notable'. Conditional on that surface form." ].join("\n") end |
.shipped_dir ⇒ Object
conformance/census/, or nil outside a checkout.
146 147 148 149 150 151 |
# File 'lib/vicary/census.rb', line 146 def shipped_dir candidate = Conformance.directory.join(SHIPPED_DIRNAME) candidate.join(SHIPPED_TABLE_FILENAME).file? ? candidate : nil rescue Conformance::SpecError nil end |