vicary 0.2.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 030a648056336b3ad6905e692be55844a1a3fbffd16b73a5ef37bcfc275402cc
4
+ data.tar.gz: 3753c57622c1b2ab6dfb82fa12f79eb862681dcc8377a8fe01094e471d235ad7
5
+ SHA512:
6
+ metadata.gz: 37aa4e0e3ccd3bb7860f97125862e1f7b3b30e63594549f201fec0f42bd92e4883e34a4b0c01cffe0f631e46f6576f4dbfc0e56f89e8d30df9e844575b512eb8
7
+ data.tar.gz: ae700f44fc829bb7431ffd26a20db4e3dc3c3e15ab0b4e047e5c57ff996b1ac701da0d9023ad2de6167b3047fff929469b948068c77421633ba8935a741a616f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blake Thomas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # vicary (Ruby)
2
+
3
+ The RubyGems front door. **Not published yet** — the gem name is claimed by a
4
+ pending trusted publisher and the first release has not run.
5
+
6
+ The detector, the data asset and the measured numbers are described in the
7
+ [project README](https://github.com/bwthomas/vicary#readme). What lives here is a
8
+ port, and the bar it has to clear before it is published is the shared
9
+ conformance suite in [`conformance/`](../conformance): for every fixture frame it
10
+ must produce **byte-identical output to the Python implementation, placeholder
11
+ numbering included**.
12
+
13
+ It clears that bar — 36 of 36 masking-required frames, 52 of 52 overall.
14
+
15
+ ```ruby
16
+ require "vicary"
17
+
18
+ Identity = Struct.new(:first_name, :last_name, :school_name)
19
+ identity = Identity.new("Marguerite", "Delacroix-Whitfield", "Westfield High School")
20
+
21
+ Vicary.redact("My cousin Terrence Okonkwo came over that summer.", identity)
22
+ # => "My cousin {NAME_1} came over that summer."
23
+
24
+ masked, n, restore_map = Vicary.redact_with_report(essay, identity)
25
+ Vicary.restore(masked, restore_map) == essay # => true
26
+ ```
27
+
28
+ ## Checking it
29
+
30
+ Three layers, because each catches what the one above it cannot.
31
+
32
+ | command | what it says |
33
+ |---|---|
34
+ | `rake conformance` | the scoreboard against the 52 frames — the final bar, and a coarse first one |
35
+ | `rake test` | the unit suites, including `primitives_test.rb`: forty-odd primitives over the shared corpus, which says *which brick* is crooked |
36
+ | `rake parity` | gazetteer verdicts, name by name, against the Python reference |
37
+ | `rake redaction_parity` | masked bytes against the Python reference, on prose no fixture contains |
38
+
39
+ The last two need the reference interpreter — run `just py-setup` from the
40
+ repository root first.
41
+
42
+ **Why there are four and not one**, measured on the day the port landed: of
43
+ eleven deliberate mutations to `candidates.rb`, the conformance frames caught
44
+ **one**. The primitives spec caught seven. Three were inert. The last was a real
45
+ divergence that both corpora were blind to, because both are single-line and the
46
+ rule only differs across a newline — which is what `redaction_parity` and
47
+ `test/dialect_test.rb` exist for.
48
+
49
+ ## Porting notes
50
+
51
+ `lib/vicary/candidates.rb` opens with the regex-dialect differences between Ruby
52
+ and Python that run through the detector. The short version: `^` and `$` mean
53
+ *line* in Ruby and *string* in Python, so every one of them is written `\A`, `\z`
54
+ or `\Z`; `\w`, `\d` and `\s` are ASCII-only in Ruby and Unicode-aware in Python;
55
+ and `\b` — unlike JavaScript's — already agrees with Python, so the explicit
56
+ lookarounds here are belt-and-braces rather than load-bearing.
57
+ `test/dialect_test.rb` pins all of it in both directions.
@@ -0,0 +1,33 @@
1
+ {
2
+ "assets": {
3
+ "notability.txt.gz": {
4
+ "bytes": 2234918,
5
+ "cut_date": "2026-08-07",
6
+ "format": 5,
7
+ "min_package_version": "0.1.0",
8
+ "sha256": "ea882c2ab1a80bd5f3b56e32a41e9c32a314ae44389f9596ea5e7ec013d92d69",
9
+ "sources": [
10
+ "https://qlever.dev/api/wikidata",
11
+ "https://www2.census.gov/topics/genealogy/2010surnames/names.zip"
12
+ ],
13
+ "tiers": {
14
+ "demonym": 1047,
15
+ "full": 295049,
16
+ "given": 8138,
17
+ "place": 25444,
18
+ "settlement": 23234,
19
+ "short": 1229,
20
+ "title": 38024
21
+ }
22
+ },
23
+ "stop_words.txt": {
24
+ "bytes": 3996,
25
+ "entries": 421,
26
+ "format": 1,
27
+ "min_package_version": "0.2.0",
28
+ "sha256": "f4dfe89490efe47c7520dac3b9185f39c0eeb397f3ab33f5d1c1211591dfcf30"
29
+ }
30
+ },
31
+ "manifest_version": 1,
32
+ "written_by": "vicary 0.2.0"
33
+ }
Binary file
@@ -0,0 +1,63 @@
1
+ #!lexicon 1
2
+ #!list stop_words 421
3
+ # Ordinary words that must never become name candidates.
4
+ #
5
+ # Language-neutral on purpose. This list is the only thing standing between
6
+ # candidate generation and "mask every capitalised word", so all three front doors
7
+ # have to work from the same 421 words — a stoplist transliterated by hand into a
8
+ # second language is a second detector wearing the first one's name, and the
9
+ # difference shows up as prose corruption in one language and not the others,
10
+ # which no parity check on masked output would catch.
11
+ #
12
+ # Skewed toward over-inclusion deliberately. A missed name is one span and shows
13
+ # up in the recall number; a wrongly-masked common word corrupts every essay that
14
+ # uses it and shows up nowhere unless somebody reads the prose.
15
+ #
16
+ # Format: `#!` lines are directives, `#` lines are comments, and every other line
17
+ # contributes whitespace-separated words. The count on the `#!list` directive is
18
+ # the number of DISTINCT words after case-folding, and each front door asserts it
19
+ # against what it parsed. That is not ceremony: a short read here silently makes
20
+ # the redactor MORE aggressive, which looks privacy-safe and passes any check that
21
+ # only asks whether something was masked.
22
+ #
23
+ # A word appearing twice is not an error — the groupings below are thematic and
24
+ # overlap ("else", "may", "us"), and enforcing uniqueness in the source would make
25
+ # the list harder to read for no benefit.
26
+ a an the this that these those there here it its it's
27
+ i me my mine myself we us our ours ourselves you your yours
28
+ he him his she her hers they them their theirs who whom whose which what
29
+ and or but so because although though however therefore thus hence yet
30
+ if then else when while until since before after during once whenever
31
+ for from to into onto out off over under above below between among across
32
+ through around about against along beside besides beyond within without
33
+ at by in on up down near next last first second third finally
34
+ is am are was were be been being have has had having do does did doing
35
+ can could will would shall should may might must let lets
36
+ not no nor none nothing never always sometimes often usually rarely
37
+ all any both each every few many more most much several some such
38
+ another other others same different new old good bad better best worst
39
+ great big small long short high low young happy sad hard easy
40
+ one two three four five six seven eight nine ten hundred thousand million
41
+ also even just only really very too still again ever else quite rather
42
+ call called come came go went get got give gave take took make made
43
+ see saw look looked think thought know knew say said tell told ask asked
44
+ want wanted need needed try tried help helped work worked feel felt
45
+ find found keep kept leave left put set start started stop stopped
46
+ remember remembered learn learned teach taught write wrote read
47
+ everyone everybody someone somebody anyone anybody nobody everything
48
+ something anything people person thing things time times day days
49
+ year years week weeks month months hour hours minute minutes
50
+ school schools class classes teacher teachers student students friend
51
+ friends family families home house mom dad mother father parent parents
52
+ brother sister sisters brothers grandma grandpa
53
+ life world way ways place places part parts kind sort lot lots
54
+ yes yeah ok okay maybe perhaps well now today tomorrow yesterday
55
+ january february march april may june july august september october
56
+ november december monday tuesday wednesday thursday friday saturday sunday
57
+ mr mrs ms dr am pm usa us u.s tv
58
+ im ive ill id dont cant wont didnt isnt aint thats theres whats
59
+ as than instead unless whether either neither plus versus etc
60
+ getting making looking thinking talking playing living walking running
61
+ sitting standing growing learning moving trying using
62
+ back away together alone everywhere somewhere anywhere nowhere
63
+ right wrong true false sure certain important special favorite
@@ -0,0 +1,190 @@
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
+ # Load the gazetteer asset — the same bytes the Python package loads.
11
+ #
12
+ # The asset is a gzipped, line-oriented text file, chosen over a binary format
13
+ # precisely so three languages can read it without a schema compiler:
14
+ #
15
+ # #!gazetteer 5 format number, checked not sniffed
16
+ # #!meta {"cut_date": ...} provenance, one JSON object
17
+ # #!tier demonym 1047 tier name and its DECLARED entry count
18
+ # abidjanese one normalised entry per line
19
+ #
20
+ # Two properties matter more than convenience.
21
+ #
22
+ # **The format number is refused, not tolerated.** An unknown format means the
23
+ # file's meaning changed, and a reader that skips lines it does not recognise
24
+ # degrades into a smaller gazetteer — which redacts MORE, reads as
25
+ # privacy-safe, and is invisible to any test that only asks whether something
26
+ # was masked.
27
+ #
28
+ # **The declared tier count is checked against the parsed count.** A truncated
29
+ # read is the same silent failure in a different costume: fewer notable people
30
+ # means fewer public figures kept, so an essay about Rosa Parks comes back with
31
+ # her name removed.
32
+ module Asset
33
+ # Asset format this reader understands. Refuse anything else.
34
+ SUPPORTED_FORMAT = 5
35
+
36
+ ASSET_FILENAME = "notability.txt.gz"
37
+ MANIFEST_FILENAME = "MANIFEST.json"
38
+
39
+ # Environment override, spelled the same as the Python package's.
40
+ ASSET_PATH_ENV_VAR = "VICARY_ASSET_PATH"
41
+
42
+ Gazetteer = Struct.new(:format, :meta, :tiers, :sha256, :path,
43
+ keyword_init: true)
44
+
45
+ class FormatError < StandardError; end
46
+ class MissingAssetError < StandardError; end
47
+
48
+ class << self
49
+ # Candidate asset locations, most specific first.
50
+ #
51
+ # The env override first, so an operator can point at a different cut
52
+ # without reinstalling. Then this gem's vendored copy, which is what an
53
+ # installed gem has. Then the monorepo's Python package, which is what a
54
+ # checkout has before `rake sync_assets` — so `git clone && rake test`
55
+ # works with no bootstrap step rather than failing in a way that reads as
56
+ # a broken port.
57
+ def search_path
58
+ gem_root = Pathname.new(__dir__).join("..", "..").expand_path
59
+ repo_root = gem_root.join("..").expand_path
60
+ candidates = []
61
+ override = ENV.fetch(ASSET_PATH_ENV_VAR, "").strip
62
+ candidates << Pathname.new(override) unless override.empty?
63
+ candidates << gem_root.join("assets")
64
+ candidates << repo_root.join("python", "src", "vicary", "data")
65
+ candidates
66
+ end
67
+
68
+ def locate
69
+ tried = search_path
70
+ found = tried.find { |dir| dir.join(ASSET_FILENAME).file? }
71
+ return found if found
72
+
73
+ raise MissingAssetError,
74
+ "no #{ASSET_FILENAME} found. Looked in: " \
75
+ "#{tried.join(', ')}. In a checkout, run `rake sync_assets`; set " \
76
+ "#{ASSET_PATH_ENV_VAR} to override."
77
+ end
78
+
79
+ # Parse the decompressed asset text.
80
+ #
81
+ # Public so a test can feed it a deliberately malformed document. A parser
82
+ # reachable only through a 2.1 MB file on disk is a parser whose failure
83
+ # paths are never exercised.
84
+ def parse(text)
85
+ lines = text.split("\n", -1)
86
+ header = /\A\#!gazetteer (\d+)\z/.match(lines.first.to_s)
87
+ unless header
88
+ raise FormatError,
89
+ "asset does not begin with a \#!gazetteer header (got " \
90
+ "#{lines.first.to_s[0, 40].inspect})"
91
+ end
92
+
93
+ format = header[1].to_i
94
+ unless format == SUPPORTED_FORMAT
95
+ raise FormatError,
96
+ "asset format #{format} is not #{SUPPORTED_FORMAT}. Refusing to " \
97
+ "read it rather than skipping the parts that changed: a " \
98
+ "partially understood gazetteer is a smaller one, and a smaller " \
99
+ "one redacts more while looking correct."
100
+ end
101
+
102
+ meta = {}
103
+ tiers = {}
104
+ declared = {}
105
+ current = nil
106
+
107
+ lines.each_with_index do |line, index|
108
+ next if index.zero? || line.empty?
109
+
110
+ if line.start_with?("#!meta ")
111
+ meta = JSON.parse(line.delete_prefix("#!meta "))
112
+ next
113
+ end
114
+
115
+ if (tier = /\A\#!tier (\S+) (\d+)\z/.match(line))
116
+ current = Set.new
117
+ tiers[tier[1]] = current
118
+ declared[tier[1]] = tier[2].to_i
119
+ next
120
+ end
121
+
122
+ if line.start_with?("#!")
123
+ raise FormatError,
124
+ "unrecognised directive #{line[0, 40].inspect} at line " \
125
+ "#{index + 1}; the asset format changed without its number " \
126
+ "changing"
127
+ end
128
+
129
+ raise FormatError, "entry at line #{index + 1} appears before any \#!tier" if current.nil?
130
+
131
+ current << line
132
+ end
133
+
134
+ declared.each do |name, count|
135
+ actual = tiers.fetch(name).size
136
+ next if actual == count
137
+
138
+ raise FormatError,
139
+ "tier #{name} declares #{count} entries and parsed #{actual}. A " \
140
+ "short read here removes public figures from the keep list, so " \
141
+ "an essay about a historical figure comes back with their name " \
142
+ "redacted."
143
+ end
144
+
145
+ [format, meta, tiers]
146
+ end
147
+
148
+ # Load and cache the gazetteer.
149
+ def load(directory: nil)
150
+ return @cached if @cached && directory.nil?
151
+
152
+ dir = Pathname.new(directory || locate)
153
+ asset_path = dir.join(ASSET_FILENAME)
154
+ compressed = asset_path.binread
155
+ sha256 = Digest::SHA256.hexdigest(compressed)
156
+
157
+ verify_against_manifest(dir, asset_path, sha256)
158
+
159
+ format, meta, tiers = parse(Zlib::GzipReader.new(StringIO.new(compressed)).read)
160
+ gazetteer = Gazetteer.new(format: format, meta: meta, tiers: tiers,
161
+ sha256: sha256, path: asset_path.to_s)
162
+ @cached = gazetteer if directory.nil?
163
+ gazetteer
164
+ end
165
+
166
+ # Forget the cached gazetteer. For tests.
167
+ def reset_cache
168
+ @cached = nil
169
+ end
170
+
171
+ private
172
+
173
+ def verify_against_manifest(dir, asset_path, sha256)
174
+ manifest_path = dir.join(MANIFEST_FILENAME)
175
+ return unless manifest_path.file?
176
+
177
+ entry = JSON.parse(manifest_path.read).dig("assets", ASSET_FILENAME)
178
+ return if entry.nil? || entry["sha256"] == sha256
179
+
180
+ raise FormatError,
181
+ "#{asset_path} sha256 #{sha256} does not match the manifest's " \
182
+ "#{entry['sha256']}. The asset was modified or truncated in " \
183
+ "transit; every front door must load identical bytes or " \
184
+ "\"byte-identical output\" is not a claim anybody can make."
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ require "stringio"