Repository navigation
Testing and Benchmarks
About 100 test files in tests/, a database-backed integration suite, a real-OCR suite, a real-local-model suite, an end-to-end smoke test run against the container stack on every pull request, and a labelled corpus with a benchmark harness for the architecture's central claim.
pnpm test # unit, adversarial and security suites (+ OCR once the pinned model is cached)
pnpm test:db # Postgres-backed integration suites (needs TEST_DATABASE_URL)
pnpm smoke # every format, against a running instance
pnpm smoke --ai # the model pass too, against an instance with a verified model
pnpm bench # extraction and export timed over large documents
pnpm corpus:score · pnpm bench:models · pnpm bench:charts # detection quality and cost (§6)Takes each exported artifact and tries to get the sensitive value back out: PDF text extraction, raw byte scans with glyph spacing stripped, annotation objects, every OOXML part, hidden sheets and rows, cached formula results, the shared-string table, sampled pixels, EXIF. All twenty-one must fail.
Through the real export path, for every exportable format:
- the accepted value is gone, read in that format's own terms (PPTX notes/layout/master, EML headers/nested parts/filenames, RTF group splits and escapes);
- a rejected value is still there, because a redactor that empties the file passes the first test and is useless;
- the artifact still opens as what it claims to be.
A guard test keeps the format register honest: a new format cannot be added without an adversarial case.
End-to-end redaction per format, including that a suggestion never accepted is still present in the output (invariant 2 as an assertion).
| Suite | Checks |
|---|---|
detectors.test.ts |
an order number is not a card; a date is a birth date only when labelled; g-flag regexes reset lastIndex
|
analysis-orchestration.test.ts |
analyzeDocument and image analysis driven by tests/helpers/scripted-provider.ts, a real LanguageModelV4 answering from a script: chunking, concurrency, dedupe, the locate-or-discard rule, and the "provider failed, keep going" path, with only the network removed (#30, since 1.6.1) |
ai-degradation.test.ts, ai-spend.test.ts, service-limits.test.ts
|
degraded events, the spend cap, pacing and retry classification |
providers.test.ts, ai-providers.test.ts, ai-cloud-providers.test.ts, ai-compatible.test.ts, ai-catalog.test.ts, ai-probe-errors.test.ts, ai-subscription.test.ts, ai-login-flow.test.ts, ai-status.test.ts, setup-ai.test.ts
|
every provider protocol against local HTTP fixtures: wire requests, structured output, images, auth, discovery, disabled choices, capability invalidation, per-model prices, error explanations, the ChatGPT sign-in flow. No live account. |
ollama-contract.test.ts |
a real Ollama + Qwen3-VL 4B contract, opt-in (ANONIFY_OLLAMA_TESTS=1) and run weekly in CI |
vision.test.ts |
the 0–1000 grid and region validation |
OCR against the real engine (since 1.6.1, #33)
tests/image-ocr.test.ts runs real Tesseract over tests/fixtures/ocr/account.png and checks every box against ink boxes measured from the fixture's own pixels, then exports a redaction and reads the result back. OCR spans are what both the canvas and the exporter place boxes from, so a test that checked only the words would pass with every box in the wrong place. tests/pdf-ocr.test.ts does the same end to end for scanned PDFs. The model is pinned by URL and hash (tests/fixtures/ocr/model.json). The suites skip where it is not cached, and CI's OCR job caches it and requires it.
chunked.test.ts, sealed-storage.test.ts, normalized-pages.test.ts, zip-reader.test.ts, xlsx-stream.test.ts, ooxml-stream.test.ts, pdf-ranged.test.ts, eml-scan.test.ts (the scanner against the old parser over 3,000 generated and mutated messages, fed in pieces from 1 byte to 1 MiB), text-stream.test.ts, mbox-scan.test.ts, ranged-parts.test.ts, streamed-downloads.test.ts. The rule throughout: every streamed reader must produce exactly what its whole-file counterpart would. See Streaming IO §10.
patterns.test.ts (RE2 compile rules and budgets), search.test.ts, search-offsets.test.tsx (the viewers' data-offset markers match page text), words.test.ts (wordAt token boundaries), shortcuts.test.ts (nothing fires while typing; nothing destructive on a bare key), hush-agent.test.ts, hush-learn.test.ts, hush-markdown.test.tsx (no fetchable links or images), session.test.ts (cookie renewal), store.test.ts / editor-state.test.ts (undo saves only what it changed).
Formats: pdf-extract, pdf-geometry (the GHSA fix), docx-extract, docx-redact, xlsx, pptx, delimited, text, rtf, eml, eml-markdown, eml-viewer, eml-attachments, mbox, mbox-rebuild, image, formats, geometry. Export: methods, export-options, report, value-matcher, batch-layout, batch, batch-concurrency. Platform: signed-url, rate-limit, rate-limit-config, quota-config, limits, retention, purge, cleanup-sweep, storage, failure, workflow-events, vercel-limits, public-url, byte-size, env-file, setup-finish, setup-tty, changelog. Benchmarks: corpus, score, render, bench.
Properties of the row rather than of a function, where a fake would agree with whatever the code did:
| Suite | Checks |
|---|---|
cleanup.integration |
storage is deleted before the row, and the row is kept when storage fails |
quota.integration |
a charge is atomic under concurrency and lands exactly once across a retried step |
attachments.integration |
attachment expansion, idempotence, substitution back into the message |
mailbox.integration |
mailbox expansion end to end |
batch-download.integration |
a mailbox uploaded, expanded, reviewed, exported, rebuilt and re-split: same count, byte-equal messages, no accepted value or source envelope sender anywhere; a left-out message named; a tampered message refused |
rules.integration |
rules at every scope, one-transaction writes, budgets |
hush-tools.integration, hush-allowance.integration
|
Hush's tools against real rows; the per-visitor allowance checked between steps |
export TEST_DATABASE_URL=postgresql://anonify:anonify@localhost:5432/anonify_test
DATABASE_URL="$TEST_DATABASE_URL" pnpm db:migrate:deploy
pnpm test:dbNever point it at DATABASE_URL, because these suites create and delete rows. Unset means they skip, and pnpm test still passes.
It drives a running instance over HTTP. For every supported format it uploads a synthetic document, waits for the pipeline, accepts every suggestion, exports, downloads, and opens the result the way an adversary would: re-parsing a CSV as a grid, re-parsing a message with an independent MIME library, unzipping a deck to read its notes and master, sampling an image's pixels. It also reads the delivered report bytes. --only=eml,pptx narrows it, and any URL works (pnpm smoke https://your-deployment).
pnpm smoke --ai adds the model pass: it asserts that calls were made and accounted for, that none failed, and that every suggestion quotes the document. It does not assert what a small model finds.
.github/workflows/ci.yml (a Decide job skips work already done):
| Job | What it does |
|---|---|
| Lint · Typecheck · Build | in parallel |
| Test |
pnpm test, plus "Corpus holds no real identifiers" (corpus:check) |
| OCR | restores or fetches the pinned model, runs the OCR suites |
| Migrations | applies every migration to an empty database, and fails if schema and migrations disagree |
| Database tests |
pnpm test:db against a Postgres service |
| Compose stack | builds the images, re-runs bucket init (idempotence), runs smoke:storage through RustFS, then pnpm smoke for every format |
| Changelog | fails a version-moving PR without a changelog.d/ fragment |
Also: local-model.yml (weekly, and on PRs touching the provider layer) runs the Ollama contract and a full Compose install with Qwen3-VL through pnpm ai verify and pnpm smoke --ai. release.yml cuts releases from labels. sweep.yml is the Hobby expiry backstop.
CI runs without a hosted AI key, so the suite passes on deterministic detection alone. That is deliberate: detection is an assist, and losing it must never cost the user the document.
The architecture makes a falsifiable claim:
Running deterministic detectors first, asking a model only the contextual question, and expanding repeated values by local search costs dramatically fewer tokens than sending the document to a model, at comparable or better quality.
It could be wrong. As of 1.9.x, the tooling to test it exists (#57, #59, #55, all closed; tracking #144). Full detail: benchmarks/README.md.
- Synthetic and labelled by construction. A cheap model writes each document from a seeded spec, and the labels come from how it was generated, not from someone reading it afterwards. The model is reached through the Claude Code or Codex CLI you are already signed in to (no API key read), in an empty temp directory.
- Distribution, exactly: 600 specs (150 dev, 450 test); 50/35/15% short/medium/long; PII density none/low/medium/high at 10/30/40/20%; locales en-US 45%, en-GB 25%, de-DE, fr-FR, es-ES, en-IN 7.5% each; twelve document types, 50 each; hard negatives; rare categories over-weighted.
-
No real identifiers. Every email, phone, URL and IP comes from a range reserved for fiction or documentation, as does every card and ID wherever such a range exists. CI fails if a committed file holds a value outside those ranges. Documents mentioning the operator's own identity (Claude Code/Codex account, git name and email, OS user,
--deny) are rejected. -
Cross-family validation (
corpus:validate): a model from another family reviews the labels, and disagreements are queued for a person. -
Status: 534 of 600 documents committed (
synthetic-v1.tar.gz), not yet human-reviewed. -
Rendering (
corpus:render) to txt, eml, pdf (DejaVu Sans), docx, csv or xlsx, deterministically (the same bytes on any machine), so extraction can be measured through each format.
Each document goes through analyzeDocument, the same function an upload goes through.
- Recall (covered), the primary number: every character of the value is under a redaction. Partly covered is a leak, so it counts as missed.
- Strict: covered by redactions of the right category. Precision: the share of redactions touching a labelled value (touching a hard negative is a false positive).
- Weighted cost: a missed government ID, bank account, financial value or API key costs 10; a missed person, address, date of birth, email, phone or customer ID costs 5; other misses 2; a false positive 1.
- Agreement between runs: observed agreement, Cohen's kappa, character Jaccard.
The deterministic baseline (test split, patterns only): 20.1% covered recall at 94.1% precision. It finds no names (the model's job), and none of the SSNs, because plausibleSsn rejects the reserved 987 block the corpus uses. Through each format:
| Format | Documents | Recall (covered) | Precision | Labelled text extracted |
|---|---|---|---|---|
| txt | 363 | 19.0% | 92.9% | 100% |
| 329 | 18.3% | 92.5% | 100% | |
| docx | 240 | 18.1% | 92.0% | 100% |
| eml | 35 | 28.8% | 96.8% | 100% |
| csv | 31 of 32 | 41.0% | 99.1% | 99.8% |
| xlsx | 32 of 62 | 38.4% | 97.0% | 99.8% |
Each row covers a different set of documents, so compare a format with txt over the same --ids, not down the table. The 30 XLSX failures are a known app bug in loadWorkbook (a small buffer's backing pool is sliced at the wrong offset, so small workbooks are intermittently misread as corrupt zips). See Troubleshooting.
Three phases per model, each checkpointed and resumable:
| Phase | Measures |
|---|---|
deterministic-first |
the pipeline as shipped: per-category quality, tokens and cost per document, time per stage, and what patterns, model and local search each contributed |
model-only |
the same model with the pattern pass switched off: the tokens and quality the patterns save, or do not |
throughput |
documents per minute at concurrency 1/2/4/8, and where the provider starts cutting the model pass short |
Each model is verified before its first phase (since 1.9.3). Results go one file per model under benchmarks/results/models/…, so adding a model never changes another's results or chart colour. pnpm bench:charts draws the five charts from CONTRIBUTING §4 plus two for #55, in light and dark, colour-blind-validated, and rewrites the README sections. --check fails if they are stale. It costs real money: try --limit 20 first.
No model run has been published yet. Running Haiku 4.5 and Sonnet 5 and writing up what it found is #153, and it is the single most useful contribution available right now.
Faces; handwriting and scan noise (OCR has its own fixtures); non-Latin scripts; real-world category distribution (categories are deliberately over-represented, so precision here is not precision on your documents); and the generator's own blind spots (score with at least one model from another family).
A change to redaction behaviour needs a test that reads the produced artifact, not one that asserts a function was called. Build a real file, redact it, then try to get the value back out. Several bugs were found only this way:
| Bug | How it hid |
|---|---|
sharp silently ignoring a second resize
|
pixelation kept full detail; the function was called, and the pixels were unchanged |
| undo snapshots aliasing an Immer draft | undo restored the state it was meant to replace |
| pdf.js detaching a buffer | the next reader got nothing |
tests/fixtures.ts builds synthetic files of each format. Never commit a document containing somebody's actual personal data.
| Gap | Issue |
|---|---|
| Browser end-to-end tests of the editor (canvas, inspector, export dialog) | #35 |
| A published model benchmark | #153, #144 |
| Human review of the corpus labels | #144 |
| A screen-reader pass and a contrast audit | #42 |
Anonify: AI proposes, the application applies, and only what a person accepted is removed.
Repository · Report a bug · Request a feature · Security policy · Apache-2.0
This wiki describes v1.10.0 and summarises and links the in-repo documentation. Where the two disagree, the files under docs/, CHANGELOG.md and SECURITY.md are canonical.
Getting going
Using it
- Supported Formats
- Review Workspace
- Search and Rules
- Hush Assistant
- Redaction Presets
- Export and Anonymisation
- Batches and Mailboxes
How it works
- Architecture
- Processing Workflow
- Redaction Engine
- Storage and Encryption
- Streaming IO
- Data Model
- API Reference
- Security Model
When it breaks
Contributing
Repository · Issues · Releases · Changelog