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.
- vicary-0.1.0/LICENSE +21 -0
- vicary-0.1.0/PKG-INFO +413 -0
- vicary-0.1.0/README.md +384 -0
- vicary-0.1.0/pyproject.toml +87 -0
- vicary-0.1.0/setup.cfg +4 -0
- vicary-0.1.0/src/vicary/__init__.py +86 -0
- vicary-0.1.0/src/vicary/_version.py +7 -0
- vicary-0.1.0/src/vicary/assets.py +445 -0
- vicary-0.1.0/src/vicary/bedrock/__init__.py +10 -0
- vicary-0.1.0/src/vicary/bedrock/guardrail.py +241 -0
- vicary-0.1.0/src/vicary/build/__init__.py +10 -0
- vicary-0.1.0/src/vicary/build/gazetteer.py +1336 -0
- vicary-0.1.0/src/vicary/config.py +224 -0
- vicary-0.1.0/src/vicary/data/MANIFEST.json +26 -0
- vicary-0.1.0/src/vicary/data/notability.txt.gz +0 -0
- vicary-0.1.0/src/vicary/eval/__init__.py +11 -0
- vicary-0.1.0/src/vicary/eval/census.py +170 -0
- vicary-0.1.0/src/vicary/eval/fixture.py +1020 -0
- vicary-0.1.0/src/vicary/eval/held_out_figures.py +224 -0
- vicary-0.1.0/src/vicary/eval/overfire.py +349 -0
- vicary-0.1.0/src/vicary/eval/recall.py +833 -0
- vicary-0.1.0/src/vicary/gazetteer.py +577 -0
- vicary-0.1.0/src/vicary/local_classifier.py +510 -0
- vicary-0.1.0/src/vicary/name_candidates.py +1750 -0
- vicary-0.1.0/src/vicary/redaction.py +901 -0
- vicary-0.1.0/src/vicary.egg-info/PKG-INFO +413 -0
- vicary-0.1.0/src/vicary.egg-info/SOURCES.txt +40 -0
- vicary-0.1.0/src/vicary.egg-info/dependency_links.txt +1 -0
- vicary-0.1.0/src/vicary.egg-info/entry_points.txt +3 -0
- vicary-0.1.0/src/vicary.egg-info/requires.txt +12 -0
- vicary-0.1.0/src/vicary.egg-info/top_level.txt +1 -0
- vicary-0.1.0/tests/test_assets.py +337 -0
- vicary-0.1.0/tests/test_config.py +313 -0
- vicary-0.1.0/tests/test_demonyms_and_carryover.py +197 -0
- vicary-0.1.0/tests/test_fixture.py +481 -0
- vicary-0.1.0/tests/test_gates.py +541 -0
- vicary-0.1.0/tests/test_gazetteer.py +1008 -0
- vicary-0.1.0/tests/test_local_classifier.py +250 -0
- vicary-0.1.0/tests/test_name_candidates.py +1302 -0
- vicary-0.1.0/tests/test_overfire.py +168 -0
- vicary-0.1.0/tests/test_packaging.py +69 -0
- 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
|
+
[](https://github.com/bwthomas/vicary/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/vicary/)
|
|
34
|
+
[](https://pypi.org/project/vicary/)
|
|
35
|
+
[](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.
|