vicary 0.1.0__tar.gz

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.
Files changed (42) hide show
  1. vicary-0.1.0/LICENSE +21 -0
  2. vicary-0.1.0/PKG-INFO +413 -0
  3. vicary-0.1.0/README.md +384 -0
  4. vicary-0.1.0/pyproject.toml +87 -0
  5. vicary-0.1.0/setup.cfg +4 -0
  6. vicary-0.1.0/src/vicary/__init__.py +86 -0
  7. vicary-0.1.0/src/vicary/_version.py +7 -0
  8. vicary-0.1.0/src/vicary/assets.py +445 -0
  9. vicary-0.1.0/src/vicary/bedrock/__init__.py +10 -0
  10. vicary-0.1.0/src/vicary/bedrock/guardrail.py +241 -0
  11. vicary-0.1.0/src/vicary/build/__init__.py +10 -0
  12. vicary-0.1.0/src/vicary/build/gazetteer.py +1336 -0
  13. vicary-0.1.0/src/vicary/config.py +224 -0
  14. vicary-0.1.0/src/vicary/data/MANIFEST.json +26 -0
  15. vicary-0.1.0/src/vicary/data/notability.txt.gz +0 -0
  16. vicary-0.1.0/src/vicary/eval/__init__.py +11 -0
  17. vicary-0.1.0/src/vicary/eval/census.py +170 -0
  18. vicary-0.1.0/src/vicary/eval/fixture.py +1020 -0
  19. vicary-0.1.0/src/vicary/eval/held_out_figures.py +224 -0
  20. vicary-0.1.0/src/vicary/eval/overfire.py +349 -0
  21. vicary-0.1.0/src/vicary/eval/recall.py +833 -0
  22. vicary-0.1.0/src/vicary/gazetteer.py +577 -0
  23. vicary-0.1.0/src/vicary/local_classifier.py +510 -0
  24. vicary-0.1.0/src/vicary/name_candidates.py +1750 -0
  25. vicary-0.1.0/src/vicary/redaction.py +901 -0
  26. vicary-0.1.0/src/vicary.egg-info/PKG-INFO +413 -0
  27. vicary-0.1.0/src/vicary.egg-info/SOURCES.txt +40 -0
  28. vicary-0.1.0/src/vicary.egg-info/dependency_links.txt +1 -0
  29. vicary-0.1.0/src/vicary.egg-info/entry_points.txt +3 -0
  30. vicary-0.1.0/src/vicary.egg-info/requires.txt +12 -0
  31. vicary-0.1.0/src/vicary.egg-info/top_level.txt +1 -0
  32. vicary-0.1.0/tests/test_assets.py +337 -0
  33. vicary-0.1.0/tests/test_config.py +313 -0
  34. vicary-0.1.0/tests/test_demonyms_and_carryover.py +197 -0
  35. vicary-0.1.0/tests/test_fixture.py +481 -0
  36. vicary-0.1.0/tests/test_gates.py +541 -0
  37. vicary-0.1.0/tests/test_gazetteer.py +1008 -0
  38. vicary-0.1.0/tests/test_local_classifier.py +250 -0
  39. vicary-0.1.0/tests/test_name_candidates.py +1302 -0
  40. vicary-0.1.0/tests/test_overfire.py +168 -0
  41. vicary-0.1.0/tests/test_packaging.py +69 -0
  42. vicary-0.1.0/tests/test_redaction.py +652 -0
vicary-0.1.0/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.
vicary-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,413 @@
1
+ Metadata-Version: 2.4
2
+ Name: vicary
3
+ Version: 0.1.0
4
+ Summary: Offline redaction of personal names in student compositions
5
+ Author-email: Blake Thomas <bwthomas@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bwthomas/vicary
8
+ Project-URL: Repository, https://github.com/bwthomas/vicary
9
+ Project-URL: Changelog, https://github.com/bwthomas/vicary/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/bwthomas/vicary/issues
11
+ Keywords: redaction,anonymization,privacy,education,names
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Text Processing :: Linguistic
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Provides-Extra: bedrock
20
+ Requires-Dist: boto3>=1.34; extra == "bedrock"
21
+ Provides-Extra: eval
22
+ Requires-Dist: pytest>=8; extra == "eval"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Requires-Dist: mypy>=1.8; extra == "dev"
26
+ Requires-Dist: ruff>=0.5; extra == "dev"
27
+ Requires-Dist: build>=1.0; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # vicary
31
+
32
+ [![ci](https://github.com/bwthomas/vicary/actions/workflows/ci.yml/badge.svg)](https://github.com/bwthomas/vicary/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/vicary.svg)](https://pypi.org/project/vicary/)
34
+ [![Python](https://img.shields.io/pypi/pyversions/vicary.svg)](https://pypi.org/project/vicary/)
35
+ [![License](https://img.shields.io/pypi/l/vicary.svg)](LICENSE)
36
+
37
+ Scrub personal names out of student compositions. Offline, no model, no network,
38
+ no per-request cost.
39
+
40
+ **A green CI badge means the five corpus-free gates hold, not that the gate set
41
+ is clear.** Four of the nine need data a public runner cannot have — three the
42
+ licensed ASAP essay corpus, one the Census surname file — and the gate report
43
+ prints `NOT MEASURED` for them rather than passing them by default.
44
+
45
+ Structured identifiers — email, phone, SSN, card numbers — are a solved regex
46
+ exercise. Names are not, because in English prose a classmate and a public figure
47
+ are the same object: two capitalised words.
48
+
49
+ ```
50
+ My cousin Terrence Okonkwo came over -> redact
51
+ My inspiration, Vincent van Gogh, painted ... -> keep
52
+ ```
53
+
54
+ No syntactic feature separates those, so the separation has to be a lookup.
55
+ vicary ships one: a 2.1 MB gazetteer of public figures, places, published works
56
+ and fictional characters, built from Wikidata and the US Census surname list,
57
+ consulted by candidate *shape*, and combined with evidence about how the writer
58
+ uses each name elsewhere in the same document.
59
+
60
+ ### Why "vicary"
61
+
62
+ Latin *vicarius*: one who stands in the place of another. It is the root of
63
+ *vicarious*, and of *vicar* — and a placeholder is exactly that, a substitute
64
+ holding a name's position in the text so the position survives when the name does
65
+ not. `{NAME_1}` is not the name deleted; it is a stand-in that keeps the sentence
66
+ a sentence, keeps two mentions of one person the same person, and keeps the way
67
+ back.
68
+
69
+ The other half of the word is the point. *Vicarious* experience is experience had
70
+ through a substitute, and that is precisely the relationship a scoring model is
71
+ put into here: it reads the essay through stand-ins, never through the student's
72
+ actual classmates and neighbours. It grades the writing and never meets the
73
+ people. That is the whole design in one word — which is why the placeholders are
74
+ numbered and the redaction is reversible. A substitute that cannot be traced back
75
+ is not standing in for anything.
76
+
77
+ ## Install
78
+
79
+ ```sh
80
+ pip install vicary # the offline detector, stdlib only
81
+ pip install 'vicary[bedrock]' # adds the AWS Bedrock Guardrail arm
82
+ pip install 'vicary[dev]' # tests, lint, type-check
83
+ ```
84
+
85
+ Python 3.11+. The default install has **no runtime dependencies**.
86
+
87
+ ## Use
88
+
89
+ ```python
90
+ from vicary import StudentIdentity, build_redactor_if_enabled
91
+
92
+ redactor = build_redactor_if_enabled(
93
+ identity=StudentIdentity(first_name="Marisol", last_name="Okonkwo"),
94
+ )
95
+ if redactor: # None when configured off
96
+ result = redactor.redact_inbound(essay_text)
97
+ scored = my_pipeline.score(result.text) # the model never sees a name
98
+ feedback = redactor.redact_outbound(scored.feedback)
99
+ ```
100
+
101
+ `build_redactor_if_enabled` returns `None` unless redaction is turned on, so
102
+ wiring it in changes nothing until you configure it. Redaction is reversible:
103
+ `result.restore_map` maps each placeholder back to the span it replaced, for a
104
+ host that needs to show a student their own words.
105
+
106
+ ### Modes
107
+
108
+ | mode | what it does | cost |
109
+ |---|---|---|
110
+ | `off` | nothing. The default. | — |
111
+ | `local` | offline regex + name candidates + the gazetteer. **Recommended.** | free |
112
+ | `stub` | regex-only, structured identifiers, cannot mask names | free |
113
+ | `guardrail` | AWS Bedrock `ApplyGuardrail`, managed entity detection | billed per call |
114
+
115
+ `stub` exists to exercise a host's whole redaction code path without spend; it is
116
+ a wiring test, never a privacy control. `guardrail` is mostly here as the external
117
+ baseline the eval scores `local` against — a library whose only benchmark is
118
+ itself has no benchmark.
119
+
120
+ ## Configuration
121
+
122
+ Every variable vicary reads, resolved in `vicary/config.py`:
123
+
124
+ | variable | meaning |
125
+ |---|---|
126
+ | `VICARY_REDACTION` | mode: `off` / `local` / `stub` / `guardrail` |
127
+ | `VICARY_NAME_DETECTION` | how hard `local` looks for names it was not handed: `identity` / `gazetteer` / `gazetteer-lowercase` |
128
+ | `VICARY_NAME_DETECTION_OUTBOUND` | the same dial for the outbound pass. Unset, it inherits the inbound one |
129
+ | `VICARY_DEPLOY_ENV` | environment name; a production one defaults the mode to `local` |
130
+ | `VICARY_ASSET_PATH` | load a different gazetteer asset (file or directory) |
131
+ | `VICARY_BEDROCK_GUARDRAIL_ID` | Guardrail identifier, `guardrail` mode only |
132
+ | `VICARY_BEDROCK_GUARDRAIL_VERSION` | Guardrail version, default `DRAFT` |
133
+ | `VICARY_BEDROCK_GUARDRAIL_REGION` | Guardrail region; no default, on purpose |
134
+ | `VICARY_EVAL_CORPUS_TSV` | eval corpus path (see *Measurement*) |
135
+ | `VICARY_EVAL_CORPUS_DIR` | directory holding `training_set_rel3.tsv` |
136
+ | `VICARY_EVAL_CENSUS_CSV` | local copy of the US Census surname file, for the false-positive control |
137
+
138
+ `ENVIRONMENT` is honoured as a host convention where `VICARY_DEPLOY_ENV` is
139
+ unset. The earlier `GRADER_*` spellings of these are still read, at lower
140
+ precedence, with a one-time deprecation warning.
141
+
142
+ A host whose production environment has a local name registers it rather than
143
+ patching a literal:
144
+
145
+ ```python
146
+ from vicary import config
147
+ config.add_production_alias("acme-prod")
148
+ ```
149
+
150
+ ## The data asset
151
+
152
+ One file, `vicary/data/notability.txt.gz`, holding seven independent tiers:
153
+
154
+ | tier | entries | answers |
155
+ |---|---|---|
156
+ | `full` | 295,049 | full name of a public figure |
157
+ | `short` | 1,229 | bare surname iconic enough to stand alone |
158
+ | `place` | 25,444 | public place or landmark (settlements excluded — a town name is where a student lives) |
159
+ | `given` | 8,138 | common given name — a **redact** signal, not a keep |
160
+ | `title` | 38,024 | published work or fictional character |
161
+ | `demonym` | 1,044 | nationality or regional adjective — `Cuban`, `Nigerian` |
162
+ | `settlement` | 23,277 | town or city — a **typing** signal, neither keep nor redact |
163
+
164
+ Five of the seven grant a keep. `given` and `settlement` do not, and they are the
165
+ two the asset's `entry_count` leaves out for that reason.
166
+
167
+ `given` is built from **SSA US birth counts**, all years and both sexes, at
168
+ 1,800 births or more — not from the first tokens of notable people's names, which
169
+ is what it was until 2026-08-07. That was an equity defect rather than a tuning
170
+ miss: it answered "was a famous person called this", and the misses skewed toward
171
+ Black and South Asian given names — `Deshawn`, `Ayaan` and `Meisha` absent while
172
+ `Marguerite`, `Terrence`, `Priya` and `Marisol` were present. Births are a
173
+ *dense* signal where a bearer count is sparse, so the tier got better on both
174
+ legs at once: it closed `Deshawn` (visible recall 96.2% → 100%) **and** over-firing
175
+ fell 0.72 → 0.60 spans/essay. No US child is registered as `Like` or `Pride` at
176
+ any threshold, which is why the ordinary-word collision that capped the old
177
+ approach does not exist here.
178
+
179
+ `settlement` answers a question the others never ask: not *should this be
180
+ masked*, but *which placeholder should it get*. A student's hometown must
181
+ redact — which is exactly why `place` excludes settlements in its SPARQL — and
182
+ before this tier existed it redacted as `{NAME}`, so a host echoing the
183
+ placeholder wrote "great job describing your trip to {NAME}". The tier is built
184
+ from the names that exclusion throws away, and it can only relabel a span that
185
+ was already going to be masked: it cannot keep anything and cannot suppress a
186
+ mask.
187
+
188
+ Half of American town names are somebody's surname, because the towns were named
189
+ after the people, so a settlement that is also a common given name or a surname
190
+ borne by 10,000+ Americans is dropped and falls back to `{NAME}` — the
191
+ conservative type, since "your friend {LOCATION}" is a worse thing for a student
192
+ to read than "your trip to {NAME}". That drops `Jackson`, `Austin`, `Houston`,
193
+ `Cleveland`, `Madison`, `Brooklyn` and `Aurora`; `Akron`, `Westfield`,
194
+ `Springfield` and `Phoenix` survive.
195
+
196
+ `demonym` is the only tier with no notability evidence behind it: it is a keep
197
+ granted to a bare token for being a *word*. So it is subtracted harder than any
198
+ other — anything already in `given` is dropped (a common first name is evidence
199
+ of a person), as is any surname borne by 10,000+ Americans. That bar is 2.5×
200
+ stricter than the short tier's and `Horner` is why: a demonym of Horn and 23,881
201
+ Americans' surname, which would otherwise stop a coach named Horner redacting.
202
+ 13 of 1,057 demonyms are dropped; `English`, `Welsh`, `Thai`, `French`, `German`
203
+ and `Roman` still over-fire, which is the direction this tier may fail in.
204
+
205
+ A tier is a lookup, and a lookup answers about a *string*, not about the person
206
+ in front of you. **578 keys in `title` and 33,682 in `full`** are a common given
207
+ name beside an ordinary US surname — "Alice Adams" is a 1921 novel, "Alan Ford" a
208
+ footballer, and both are also somebody's neighbour. No threshold separates them
209
+ from the people students write about (Atticus Finch sits at 17 sitelinks, the
210
+ hole "Alice Adams" at 24), so a keep is overridable by the sentence: a
211
+ **first-person relation attached to the name** — immediately before it, or in the
212
+ appositive immediately after — beats a `title` or `full` keep.
213
+
214
+ ```
215
+ My neighbor Alice Adams walked me to the bus stop -> redact
216
+ Atticus Finch is the kind of father who ... -> keep (relation, not the writer's)
217
+ I read Harry Potter with my little brother -> keep (first person, not attached)
218
+ ```
219
+
220
+ Both halves are required, and the guards say why: characters are *described by*
221
+ their relations, so a bare relation cue in the window refuses six of seven
222
+ curriculum characters, and first person alone redacts a book whenever a student
223
+ says who they read it with. On 27 un-scrubbed student documents the override
224
+ fires 0 times; it recovers 508 of the 578 name-shaped title keys and 33,182 of
225
+ the 33,269 full-name ones.
226
+
227
+ What is *not* a relation, and this is the load-bearing half: **admiration
228
+ invocations**. "My hero Abraham Lincoln", "my muse Joan Jett", "my inspiration
229
+ Vincent van Gogh", "my role model Rosa Parks" all keep, because *hero*, *muse*,
230
+ *inspiration* and *role model* attach to a public figure as readily as to a
231
+ relative and are therefore evidence of nothing. That is why the cue list is
232
+ closed and hand-written rather than "any noun between *my* and the name", and it
233
+ is pinned by a held-out frame.
234
+
235
+ ### Carrying a keep across the two passes
236
+
237
+ Feedback about a memoir by Narciso Rodriguez reads *"introducing who Narciso
238
+ is"*. The essay writes the full name and the gazetteer keeps it; the feedback
239
+ writes only the first name, which is in the `given` tier — a **redact** signal —
240
+ so a student read `introducing who {NAME} is` about the author they had just
241
+ written about.
242
+
243
+ No lookup fixes that. `Narciso` is in the `given` tier because US children are
244
+ named Narciso, not because the tier knows about Narciso Rodriguez — who sits far
245
+ below the short tier's floor — so nothing in the asset connects the bare first
246
+ name to the designer. The evidence has to be
247
+ per-document, and the document holding it is the **essay**, not the feedback. So
248
+ `redact_inbound` records the bare tokens of the notable full names it kept, and
249
+ `redact_outbound` treats them as topical:
250
+
251
+ ```python
252
+ redactor.redact_inbound(essay) # records: {"narciso", "rodriguez"}
253
+ redactor.redact_outbound(feedback) # "Narciso" and "Narciso's" now survive
254
+ ```
255
+
256
+ This is safe because **outbound text is generated from inbound-redacted input**:
257
+ a classmate named Narciso was masked on the way in, so the model never saw the
258
+ token and cannot write it back. That is also the condition — a host redacting
259
+ outbound text from some *other* source must pass `carry_notable_keeps=False`.
260
+ Two keeps are refused anyway: tokens of the student's own name (identity masking
261
+ is exact-match, so it never enters the notable set), and any token a *second,
262
+ private* full name in the same essay also claims.
263
+
264
+ It ships as package data, and `data/MANIFEST.json` records its SHA-256, byte
265
+ count, tier counts, cut date, upstream sources, and the minimum vicary version
266
+ that can read it. `load()` checks all of that and **raises rather than
267
+ degrading**: an empty gazetteer would mask every public figure in every essay,
268
+ which is privacy-safe, product-hostile, and indistinguishable from
269
+ over-aggressive tuning until somebody notices months later.
270
+
271
+ ```sh
272
+ vicary-assets show # tier counts, cut date, provenance, which file is loaded
273
+ vicary-assets verify # checksum the installed asset against the manifest
274
+ vicary-assets fetch # rebuild from Wikidata + Census, rewrite the manifest
275
+ ```
276
+
277
+ `fetch` reaches the network and takes a while. Both endpoints are donated
278
+ infrastructure; a deployment doing large rebuilds should set
279
+ `vicary.build.gazetteer.USER_AGENT_SUFFIX` to a contact address.
280
+
281
+ ## How it reads the writer's capitalisation
282
+
283
+ Capitalisation is the cheapest evidence in the document and the easiest to
284
+ over-trust. A writer who marks proper nouns with capitals has told us something
285
+ about every *lowercase* token — probably not a name. A writer who does not has
286
+ told us nothing, and the given-name tier is the only handle left. So the detector
287
+ classifies the document once, into one of four states, and every rule that weighs
288
+ case reads the same verdict:
289
+
290
+ | state | the writer | a lowercase seed then needs |
291
+ |---|---|---|
292
+ | `consistent` | marks proper nouns, keeps sentence capitals | the same word capitalised elsewhere |
293
+ | `inconsistent` | does both — marks some, drops some | the same word capitalised elsewhere |
294
+ | `lowercase` | drops capitals and marks nothing | nothing; the given-name tier stands alone |
295
+ | `silent` | no proper nouns, no dropped capitals | the same word capitalised elsewhere |
296
+
297
+ `inconsistent` is why this is not a boolean. On 27 un-scrubbed student documents
298
+ **7 satisfied both of the predicates this replaced** — "capitalises its proper
299
+ nouns" *and* "does not keep standard capitalisation" — so the treatment they got
300
+ depended on which predicate a call site happened to read. Neither
301
+ document-level treatment fits them: suppressing the lowercase route loses the
302
+ names they wrote lower-case, and opening it wide fires on ordinary words. They
303
+ get no document-level answer on purpose, and fall through to per-token evidence.
304
+
305
+ `silent` is the other half, and it is a defect that shipped. **Silence is not
306
+ consent**: a 108–290 character feedback field is ordinary prose with nothing in it
307
+ to capitalise, and reading its zero mid-sentence capitals as "this writer drops
308
+ capitals" put `tone toward`, `line makes` and `line circles` in front of students.
309
+ `tone` and `line` are both genuine given names, so the seed was legitimate and the
310
+ absent guard was the whole defect.
311
+
312
+ Two things were measured and are worth stating because they are the obvious
313
+ repairs and they do not work:
314
+
315
+ * **A rate does not fix the presence side.** The floor is a count of 2, which
316
+ decides 6 of the 27 documents, so marks-per-1,000-characters looks like the
317
+ obvious repair. It re-orders that band rather than separating it, and gets the
318
+ two closest cases backwards: `141-693` marks "Powerball" twice in 3,478
319
+ characters (0.58/1k, a real capitaliser) and `141-433` marks "The" and "There"
320
+ in 1,144 (1.75/1k, both artefacts of a missed sentence break). What separates
321
+ them is the *content* of the mark, which is per-token evidence.
322
+ * **A rate fixes the absence side only where there is a presence signal to weigh
323
+ it against.** Above the floor it is right, and it stops one line wrap in sixty
324
+ sentences libelling a writer who marks 26 proper nouns correctly. Below the
325
+ floor it costs a held-out name: at a 1.7% drop rate one carrier essay was
326
+ demoted to `silent`, the permissive path was withdrawn, and held-out recall went
327
+ 28/28 → 27/28 to buy one span of over-firing. Below the floor the document has
328
+ offered one bit and it is taken as given.
329
+
330
+ ## Measurement
331
+
332
+ The gates live with the library, in `tests/test_gates.py`, and are ordinary
333
+ pytest tests with a bar. Each prints the number it measured.
334
+
335
+ ```sh
336
+ pytest # everything
337
+ pytest -m "not gates" # fast unit tests only
338
+ pytest -m gates -s # the gates, with their numbers
339
+ vicary-eval --frames # per-frame scoring table, no corpus needed
340
+ ```
341
+
342
+ Current numbers, fixture `2026-08-06.3`, arm `local-gazetteer-lowercase`:
343
+
344
+ | gate | bar | measured |
345
+ |---|---|---|
346
+ | held-out recall | ≥ 100% | 100% |
347
+ | KEEP precision | ≥ 100% | 100% |
348
+ | round-trip restorability | ≥ 100% | 100% |
349
+ | unaccounted invariant violations | 0 | 0 |
350
+ | over-firing on real prose | ≤ 0.72 spans/essay | 0.72 |
351
+ | bare-surname Census exposure | ≤ 1.25% | 1.20% |
352
+ | latency p95 (essay-length) | ≤ 10 ms | 3.4 ms |
353
+
354
+ Measured separately, on 14 real generated-feedback responses rather than on
355
+ essays: **over-firing on the outbound pass is 0.00 spans/response**, down from
356
+ 0.21. Two of the three residual spans needed the essay to close, not a rule —
357
+ see *Carrying a keep across the two passes* below.
358
+
359
+ Recall cannot be measured on a pre-anonymized corpus — ASAP set-8 already
360
+ replaced every name with `@PERSON1`-style tokens before publication, so a
361
+ detector that does nothing scores perfectly. The harness therefore injects ground
362
+ truth whose literals it knows, from `vicary/eval/fixture.py`, and reports the
363
+ **held-out** frames separately: once a detector has been tuned against a fixture,
364
+ only the held-out half of its recall is honest.
365
+
366
+ Three known invariant violations are listed, with reasons, in
367
+ `ACCEPTED_VIOLATIONS`. The gate is that no *unlisted* violation appears, and a
368
+ second test fails if a listed one stops occurring — a stale exemption would
369
+ shelter the next real defect of the same shape.
370
+
371
+ Neither the essay corpus nor the Census surname file is packaged: one is licensed
372
+ third-party data, the other is 3 MB the redaction path never reads. Gates that
373
+ need them skip, and the report names what it could not measure, so a partial run
374
+ cannot read as a clean sweep.
375
+
376
+ ```sh
377
+ export VICARY_EVAL_CORPUS_DIR=/path/to/asap-aes # training_set_rel3.tsv
378
+ export VICARY_EVAL_CENSUS_CSV=/path/to/names.zip # census.gov 2010 surnames
379
+ pytest -m gates -s
380
+ ```
381
+
382
+ ## What it does not do
383
+
384
+ * It is not a general PII scanner. It is tuned for first-person student prose,
385
+ and its bias — over-redact rather than leak — assumes a downstream model for
386
+ which a placeholder token is ordinary input.
387
+ * It does not detect names it has no evidence for. A private surname written
388
+ lower-case throughout, or a bare surname in a document that also names a famous
389
+ bearer of it, are known and documented misses rather than bugs.
390
+ * The `given` tier's births floor leaves the rarest tail out. `Meisha` has 1,048
391
+ US births since 1880; reaching her needs a floor at or below that, which
392
+ measures 0.80 spans/essay and fails the over-firing gate. That trade is left
393
+ unmade rather than overlooked — it buys the rarest names at the cost of the
394
+ tightest gate.
395
+ * The relation override reaches `title` and `full`, not `place`. A private
396
+ person whose name is also a public *place* still keeps.
397
+ * 87 of the 33,269 full-tier holes survive the override, and a title span still
398
+ shelters a bare uncommon given name ("my cousin Vinny" with no surname) —
399
+ a single mid-sentence capital needs corroboration the given-name tier cannot
400
+ supply for an uncommon name.
401
+ * Raising the single-token place floor to 150 dropped `Auschwitz`, `Alsace`,
402
+ `Burgundy`, `Bohemia` and `Anatolia` from the place tier, so a bare mention of
403
+ one over-fires. Multi-token forms are unaffected — "Auschwitz concentration
404
+ camp" still resolves.
405
+ * Placeholder *types* are three — `{NAME}`, `{ORGANIZATION}`, `{LOCATION}` —
406
+ and everything else masked as a third-party name gets `{NAME}`. A holiday, a
407
+ brand or a pet that over-fires is typed as a person, and `Christmas` is the
408
+ measured case: it is a US town at exactly the settlement floor, so on real
409
+ student prose it retypes to `{LOCATION}`. Both labels are wrong; the span is a
410
+ pre-existing over-fire that should not have been masked at all, so the tier
411
+ relabels a defect rather than creating one.
412
+ * It makes no network call and creates no cloud resource unless you choose
413
+ `guardrail` mode.