A very small model that answers one question about a piece of text:
Must this stay on this machine?
Try the demo · Model on Hugging Face · Using it in your own project
Not a medical device. This is a privacy tool. It decides whether text should leave a machine and says nothing about what a lesion is. MoleCare does not diagnose.
It exists to sit in front of llm-harness
as a second gate, behind the regex ruleset in rules/v1.yaml, and to catch what
patterns cannot see.
The rules are good at anything shaped like a marker: sk-ant-…, AKIA…,
patient_id: 40219, a base64 image. They are blind to the same information
written as ordinary English.
This sentence passes every privacy rule in the ruleset today:
"The woman from Tuesday's clinic, 34, has a 7mm asymmetric lesion on her left shoulder and a biopsy booked for the 20th."
Measured on the 206-example gold set in data/gold.jsonl:
| catch rate | friction rate | |
|---|---|---|
| regex ruleset alone | 9.0% (10 of 111) | 1.1% |
101 of the 111 sensitive examples go straight through. That is the gap this model is for.
Not a fine-tuned chat model. An embedding model and a logistic head.
| AUC | catch | friction | |
|---|---|---|---|
| regex ruleset alone | — | 9.0% | 1.1% |
| hashed word unigrams | 0.7818 | 98% | 83.2% |
Qwen3.5-0.8B, prompted |
0.4609 | at chance | |
bge-m3 + logistic head, v1 |
0.9927 | 99.1% | 5.3% |
Five-fold cross-validated: every score comes from a head that never saw that
example. 110 of 111 sensitive examples caught, at 5.3% friction. On 20 sentences
written after training (data/fresh.jsonl) it catches all 11 sensitive ones
and holds 1 of 9 harmless ones. On 21 short prompts held out of training
(data/short-probe.jsonl) it catches all 6 sensitive ones and holds none of the
15 harmless ones; head v0 held 8.
Prompting a small chat model asks it to reason its way to a word, and a 0.8B cannot. An embedding does one forward pass and a linear head decides. That is also the right shape for something that runs inline on every request: no generation step, and the output is a calibrated score, so catch against friction is a dial rather than whatever the model felt like saying.
The whole gate is bge-m3 plus 1024 weights and a bias — model/head-v1.json.
It costs 34–43 ms per call at p50 and under 50 ms at p95, measured on a
loaded shared box, and 18 ms per text when batched.
from privacy_gate.gate import Gate
gate = Gate.load("model/head-v1.json")
gate.decide("the woman from Tuesday's clinic has a 7mm lesion on her shoulder")
# Decision(hold=True, score=6.72, threshold=0.1209)It is not ready to ship. 247 examples written by one person are
enough to choose an architecture, not enough to set a threshold that decides what
leaves a machine holding real patient data. See the end of docs/JOURNAL.md for
what would have to be true first.
The model may only ever add a hold, never clear one.
text ──▶ regex ruleset ──▶ hold? ──yes──▶ local, locked
│
no
▼
this model ──▶ hold? ──yes──▶ local, locked
│
no
▼
the caller's own routing stands
Two consequences, and they are the point:
- A false negative from the model leaves today's protection exactly as strong as it already is. The model cannot make things worse.
- The model is never the reason something is sent, only ever a reason something is held back.
So it is not judged on accuracy in isolation. It is judged on residual catch (what it finds that the rules miss) against friction (how often it interrupts work that was fine). A guard that fires on ordinary engineering gets switched off, and then it protects nothing.
| Path | What |
|---|---|
docs/TAXONOMY.md |
The decision boundary. The spec every label obeys, and the owner decisions still open. |
docs/JOURNAL.md |
Every run, with its numbers, in order. Including the ones that did not work. |
data/gold.jsonl |
206 hand-written examples: 95 clean, 111 sensitive. The test set. Never trained on. |
data/rules-baseline.json |
What the regex ruleset catches, per example. Generated by scripts/rules_oracle.py. |
docs/INTEGRATION.md |
How to use it from llm-harness, molecare-mcp, Spring Boot, CI, or over HTTP. |
data/fresh.jsonl |
20 examples written after training, as a generalisation check. |
data/short-probe.jsonl |
21 short prompts held out of training: the ones that showed v0 holding "ok" and "thanks!". |
model/head-v1.json |
The gate: 1024 weights and a bias. 68 KB. |
model/head-v0.json |
The previous head, kept so runs 6–9 stay reproducible. |
src/privacy_gate/ |
Loading, prompting, scoring, serving. Standard library only. |
scripts/serve.py |
A loopback HTTP sidecar, so any language can call it. |
Everything here is standard library only. The one external dependency is an
Ollama endpoint serving bge-m3, which can be your own
machine:
ollama pull bge-m3
# Embed the gold set, then cross-validate the head over it.
PYTHONPATH=src python3 -m privacy_gate.embed \
--data data/gold.jsonl --out runs/gold-bge-m3.jsonl
PYTHONPATH=src python3 -m privacy_gate.head \
--embeddings runs/gold-bge-m3.jsonl --rules data/rules-baseline.jsonPoint it elsewhere with --url http://host:11434 if the models live on another
box. If that box is shared, scripts/run-when-free.sh waits for it to go idle
rather than queueing behind whatever else is running.
The no-model baseline needs nothing at all, and is worth running first so you know what a neural result has to beat:
PYTHONPATH=src python3 scripts/lexical_baseline.pyRegenerating data/rules-baseline.json needs the llm-harness CLI, which is a
separate project; the generated file is committed so you do not need it.
llm-harness explain decides locally and sends nothing anywhere, which is why it
is safe to run over a file of fixtures.
Every example in data/gold.jsonl is invented. No real person, no real
credential, no real patient. Phone numbers come from Ofcom's drama range
(07700 900xxx), the AWS key is Amazon's own documentation placeholder, and the
private key fixture is base64 for a sentence saying it is not a key.
That is a deliberate property, not a convenience: it is what allows this
repository to be public at all. SECURITY.md explains why each fixture is safe,
and CONTRIBUTING.md explains what to do if you add one.
Nothing consumes this in production yet. docs/JOURNAL.md records what has
actually been measured, including the two architectures that did not work, and
ends with the five things that would have to be true before it should guard
anything real. docs/TAXONOMY.md lists the decisions still open.
Contributions that would help most are in CONTRIBUTING.md; the short version is
more hard negatives — text that looks sensitive and is not.
Thank you to everyone who has helped privacy-gate-llm. The most useful contribution is a hard negative: text that looks sensitive and is not.
The list is filled by Contributors from GitHub commits, bots omitted — never hand-maintained, because a stale list is worse than none. Contributor graph · good first issue
Apache-2.0, see LICENSE and NOTICE. The base encoder, BAAI/bge-m3, is MIT and is neither included nor redistributed here — only the head is ours.