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 +7 -0
- data/LICENSE +21 -0
- data/README.md +57 -0
- data/assets/MANIFEST.json +33 -0
- data/assets/notability.txt.gz +0 -0
- data/assets/stop_words.txt +63 -0
- data/lib/vicary/asset.rb +190 -0
- data/lib/vicary/candidates.rb +1702 -0
- data/lib/vicary/conformance.rb +242 -0
- data/lib/vicary/gazetteer.rb +399 -0
- data/lib/vicary/lexicon.rb +174 -0
- data/lib/vicary/minter.rb +95 -0
- data/lib/vicary/redact.rb +172 -0
- data/lib/vicary/structured.rb +343 -0
- data/lib/vicary/version.rb +10 -0
- data/lib/vicary.rb +41 -0
- metadata +66 -0
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
|
data/lib/vicary/asset.rb
ADDED
|
@@ -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"
|