English | 简体中文
Arbitrary natural-language policy-driven PII redaction. Readout only. No generated text.
AnyPII is a local C++ service for text anonymization, built on llama.cpp. Each request supplies the text, a policy written in ordinary natural language, and a map of typed entity definitions. The policy is used as context exactly as written; it is not parsed into a fixed rule schema. The types are defined entirely by the request.
AnyPII proposes candidate spans, reads each type and redaction decision from
restricted next-token logits, and returns the redacted text together with
structured spans. The main path is prefill only: one shared context prefix,
one branch per unique candidate, multi-sequence decode, and zero generated
tokens. redacted_text is assembled from the original string using the returned
spans, not generated by the model.
CPU inference is the default. CUDA, Vulkan, ROCm, and Metal builds are optional.
Build and serve:
./build.sh
./run.sh --model /path/to/Qwen3.5-0.8B-Q8_0.gguf --port 8080Redact a medical note with five request-defined types:
curl -s http://127.0.0.1:8080/v1/anypii \
-H 'Content-Type: application/json' \
-d '{
"state": {
"text": "患者陈思远因肺炎入院。主治医师林婉清、责任护士周雅琴。予阿莫西林(商品名阿莫仙,辉瑞制药)抗感染。",
"policy": "打码患者姓名;不要打码医生与护士姓名;打码药厂名、药物品牌名与商品名;保留药品通用名。"
},
"types": {
"patient_name": {"description": "患者或患儿的姓名,不是医务人员。", "redact": true},
"clinician_name": {"description": "医生、护士、药师等医务人员姓名。", "redact": false},
"pharma_company": {"description": "药厂、制药企业名称。", "redact": true},
"drug_brand": {"description": "药品品牌名、商品名、商标名。", "redact": true},
"drug_generic": {"description": "药品通用名、成分名、INN。", "redact": false}
},
"threshold": 0.55,
"mask_style": "token"
}'{
"redacted_text": "患者[REDACTED]因肺炎入院。主治医师林婉清、责任护士周雅琴。予阿莫西林(商品名[REDACTED],[REDACTED])抗感染。",
"spans": [
{"type": "patient_name", "text": "陈思远", "redacted": true, "occurrences": [{"start": 2, "end": 5, "score": 0.995}]},
{"type": "clinician_name", "text": "主治医师林婉清", "redacted": false, "occurrences": [{"start": 11, "end": 18, "score": 0.952}]},
{"type": "clinician_name", "text": "责任护士周雅琴", "redacted": false, "occurrences": [{"start": 19, "end": 26, "score": 0.692}]},
{"type": "drug_generic", "text": "阿莫西林", "redacted": false, "occurrences": [{"start": 28, "end": 32, "score": 0.962}]},
{"type": "drug_brand", "text": "阿莫仙", "redacted": true, "occurrences": [{"start": 36, "end": 39, "score": 0.899}]},
{"type": "pharma_company", "text": "辉瑞制药", "redacted": true, "occurrences": [{"start": 40, "end": 44, "score": 0.798}]}
],
"model": "qwen35 0.8B Q8_0",
"backend": "qwen35 0.8B Q8_0 / llama.cpp CPU",
"usage": {"decodes": 2, "branches": 16, "generated_tokens": 0, "fallback": false,
"readout": {"cache_status": "miss", "cache_entries": 1, "checkpoint_bytes": 23637148},
"result_cache": {"status": "miss", "entries": 1}}
}POST /v1/anypii is the only inference endpoint. GET /health describes the
loaded model. Pass --api-key KEY to require Authorization: Bearer KEY.
The request contract is stable: state.text, state.policy, types,
threshold, mask_style, plus the optional skip_keep_types,
omit_keep_spans, mode, and parrot_typed.
examples/medical-long.json repeats the same patient and drug across eight
paragraphs. The response merges repeated mentions into one span per
(type, text, redacted) and lists every position in occurrences.
mask_style: "typed" renders [type:index] instead of [REDACTED].
Everything is prefill only. There is no autoregressive loop and no model-written
redacted_text. A request passes through five stages:
-
Candidate proposal. For CJK text, cppjieba supplies word boundaries and POS tags; adjacent words are merged to recover multi-word names. If the dictionary is unavailable, the model tokenizer is used as a fallback. Latin runs use character classes, with adjacent capitalized words merged into one candidate. Structural patterns cover emails, URLs, phone numbers, SSN/ITIN/EIN, payment cards (Luhn), IBAN (mod-97), IPv4/IPv6, NPI/MBI/DEA/VIN, passport numbers, AWS access keys, China mainland/HK/Macau/Taiwan IDs, regional phone numbers, ticket/order numbers, API keys, case numbers, and dates of birth. Generic long digit runs and high-entropy tokens are proposed as well.
A prefilter removes single characters, label-like words or acronyms from the caller's own type ids and descriptions, short label prefixes or suffixes, punctuation-crossing spans, short words immediately adjacent to a structured value, and CJK spans whose first, last, or interior POS is a weak boundary tag. In a merged CJK span, an
x,nr, ornrfgtoken is allowed only as the final word. This trims陈思远体温and林婉清建议while keeping主治医师林婉清. jieba is a proposal step, not the final classifier. -
Pass A: type readout. Each unique candidate becomes one branch appended to a shared context prefix containing the text, policy, and entity types. The branch ends at the assistant decision position. Next-token logits are restricted to the request's type codes plus
none; a softmax over those logits is the type distribution. A type must also beatnoneby--none-margin(default 0.65) for short spans. Repeated short candidates share one branch. -
Boundary arbitration. Overlapping candidates form components. Up to two options per component are compared in both option orders: the highest-weight option and a longer plausible variant. A completeness adjustment then prefers a longer complete mention when it is a proper substring and remains close in weight. This resolves cases such as
患者陈思远/陈思远/患者. -
Type refinement. Ambiguous spans get a pairwise type read. The shortlist comes from Pass A's best and second types, from exact digit lengths (
18位,16-19位), and from shared two-or-more-character substrings of the caller's descriptions (辉瑞制药and药厂、制药企业). High-confidence spans and spans whose type is already fixed by the type description skip this stage. -
Redaction.
redact: true/falseis enforced. Only"auto"types run the redact/keep readout (Pass C). If every type is explicit, Pass C is skipped entirely.
Branches share a decoded context prefix. Sequence 0 keeps the prefix KV
resident across passes, and branch sequences copy it with multi-sequence decode
in batches (--max-branches, default 32). No pass runs one forward per token or
one forward per candidate.
flowchart LR
A["state.text + state.policy + types"] --> B["Candidate proposal<br/>word boundaries + adjacent-word merges<br/>+ structural patterns"]
B --> C{"Prefilter<br/>single chars, caller type labels,<br/>punctuation, adjacent values, POS"}
C -->|format or length fast path| D["Direct type decision<br/>0 model calls"]
C -->|survives| E["Pass A: type readout<br/>shared context prefix<br/>one branch per unique candidate<br/>restricted softmax over types + none"]
E --> F["Group overlapping candidates<br/>into components"]
F --> G["Boundary arbitration<br/>up to two options per component<br/>both option orders"]
G --> H{"High confidence or<br/>description/format match?"}
H -->|yes| J["Selected entity spans"]
H -->|no| I["Type refinement<br/>pairwise readout over<br/>a description-driven shortlist"]
I --> J
D --> J
J --> K{"redact mode"}
K -->|true / false| L["Forced redaction decision"]
K -->|auto| M["Pass C: redact/keep readout"]
L --> N["Enforce non-overlap<br/>merge by type + text + redacted"]
M --> N
N --> O["Apply mask_style<br/>token / length / mask / typed / type"]
Coordinates follow the original string: start and end are Unicode code
point indices into a half-open range [start, end). Occurrences are
non-overlapping and sorted by start. Repeated occurrences of the same
(type, text, redacted) are merged into one span.
The model is not called when the request already determines the answer:
| path | condition | model calls |
|---|---|---|
| format | a regex hit whose canonical format name matches exactly one type id or description (email, phone, SSN, ITIN, EIN, payment card, IBAN, IP, URL, API key, ticket, case number, DOB, NPI, MBI, DEA, VIN, passport, AWS access key, China/HK/Macau/Taiwan IDs) | 0 |
| length | an ASCII digit candidate whose length appears in exactly one type description (18位身份证号, 16-19位银行卡号) |
0 |
| high confidence | Pass A probability and margin clear the skip threshold | 0 for type refinement |
| description match | a length or two-or-more-character description overlap fixes the type | 0 for type refinement |
skip_keep_types |
enabled and a component contains only redact:false types |
0 for that component |
| all keep | skip_keep_types is enabled and every type is redact:false |
0 total |
skip_keep_types does not remove keep labels from Pass A: they remain as a
sink so a preserved entity is not misread as a redact type. The option skips
boundary and type-refinement work for keep-only components, omits their output,
and consumes format-matched keep candidates such as ticket numbers without
emitting spans. If every type is keep, the request returns the original text
with zero decodes.
Structured fast paths cover:
- China mainland 18-digit resident ID, including the MOD 11-2 checksum and date check.
- Hong Kong ID (
A123456(3), check digit). - Macau ID (
1234567(8), seven digits plus a parenthesized check digit). - Taiwan ID (letter plus nine digits, checksum).
- China mobile numbers (
+86optional), China landlines with or without an area code (010-12345678,0755-12345678,12345678,1234567), Hong Kong (+852), Macau (+853), and Taiwan (+886, mobile or landline).
Use type ids or descriptions containing the canonical names, for example
cn_id, hkid, macau_id, twid, or phone. Seven- or eight-digit local
numbers without an area code or separators become phone candidates when a phone
type is defined; they can also be order numbers, so verify the policy for your
data.
CJK candidate boundaries use jieba POS tags. By default these tags, and their single-character subclasses, are weak:
v (verb), a (adjective), d (adverb), p (preposition), r (pronoun),
u (particle), m (numeral), q (quantifier), t (time word), f (direction
word), c (conjunction), y (modal particle), e (interjection), o
(onomatopoeia), z (status word), l (idiom/expression), i (idiom), w
(punctuation), b (distinguishing word), g (morpheme), h (prefix), k
(suffix).
vcoversvd,vg,vi,vn,vq;ucoversud,ug,uj,ul,uv,uz.a,d,m,r, andtbehave the same way.- Multi-character custom tags match exactly (
vndoes not weaken other verb subclasses). - Kept strong by default:
n(noun),ng(noun morpheme),nr(person name),nrfg(person-name subclass),nrt(transliterated name),ns(place name),nt(organization name),nz(proper noun),j(abbreviation),s(locative word),x(unknown or non-lexeme, often a name), andeng(English).
Customize the set at startup:
# append tags
./build/anypii --weak-pos vn,uj ...
# replace the defaults
./build/anypii --weak-pos-override v,n ...
# clear all weak tags
./build/anypii --weak-pos-override none ...ANYPPI_WEAK_POS and ANYPPI_WEAK_POS_OVERRIDE provide the same values through
environment variables.
The shared context prefix is the expensive part. It is decoded once per request
and kept in sequence 0; each branch copies that prefix KV and adds only a short
tail. The type, boundary, type-refinement, and redact passes all use the same
prefix, so switching between passes does not re-prefill. ANYPPI_TIMING=1
prints prefix size, branch count, batch tokens, and prefill/batch time. For
medical-long.json, the timing shows the cache directly:
prefix_tokens=556 branches=34 batch_tokens=4472 prefill_ms=883 batch_ms=6323
prefix_tokens=556 branches=6 batch_tokens=928 prefill_ms=0 batch_ms=1229
The zero prefill on the second line is a prefix-cache hit. Prefix checkpoints
also survive across requests in a bounded host-side LRU
(--readout-cache-entries, default 4; --readout-cache-bytes, default 256
MiB). PCD uses a separate checkpoint cache.
Identical requests skip inference entirely through the result cache. The key
includes the model name, text, policy, ordered type definitions, threshold,
mask_style, skip_keep_types, omit_keep_spans, mode, parrot_typed, and
fallback. The cache is a bounded LRU of output only
(--result-cache-entries, default 64; --result-cache-bytes, default 64 MiB)
and stores no KV state. On a hit, usage.result_cache.status = "hit",
decodes = 0, and usage.readout / usage.pcd are absent.
Candidate generation has its own small memo, and the PCD type stage keeps a bounded type-prior cache. Neither replaces the result cache.
For CPU tuning, --threads defaults to the physical core count. More threads are
not automatically faster: beyond a point, scheduling and synchronization
overhead can outweigh the gain, and the result depends on the CPU and workload.
Raising --n-batch, --n-ubatch, or --ctx also produced no consistent gain.
Start from the defaults and validate the gain on your device.
Moving branch-specific text from the tail into the prefix would make each branch's prefix different and break the shared cache. The current layout, with one common context prefix and short decision tails, is deliberate.
The standard type-branch layout is the production default. Several A/B options trade accuracy or validation effort for fewer branches:
| option | default | effect | caveat |
|---|---|---|---|
--compact-branches |
off | shorter type-branch instructions | in one A/B run, medical-long.json dropped from about 9.7s to 7.9s, but legal.json lost a person span; medical.json was slower at about 3.6s |
--single-type-orientation |
off | halves type-refinement branches | changed several spans on long documents |
--no-type-refine-dedup |
dedup on | decides type refinement per occurrence | more branches; use when identical text needs context-specific types |
--component-shortlist |
off | keeps only the longest span, top-weight option, shortest suffix, and regex hits per component | can drop a valid standalone short prefix, such as a place inside a company name |
Type-refinement decisions are deduplicated by repeated text plus the candidate
type shortlist. The first decision is reused, giving repeated entities one type
and redaction decision. On medical-long.json this reduced 62 branches to
48 (about 12.4s to 9.8s) and type-refinement pairs from 10 to 3. The risk is
context-dependent homonyms: identical text can deserve different types in
different roles. The shortlist guard prevents deduplication when contexts
produce different candidate types, but it cannot detect every homonym. Disable
deduplication when the policy assigns context-specific types to the same
literal. Also note that consistency is not correctness: a repeated decision can
be consistently wrong.
When both boundary options share the same keep type, or every plausible type for a span is keep, those branches are skipped. The engine keeps the longest complete span and Pass A's best type, which cannot change the redaction decision.
--component-shortlist trims the type pass per overlapping component. It keeps
the longest span, the top-weight option, the shortest suffix, and regex hits,
then drops common prefixes such as role titles. medical.json drops from 16 to
10 type branches (about 2.9s to 1.7s); medical-long.json drops from 48 to 40
branches (about 9.5s to 8.0s). It is off by default because a short prefix can be a valid
standalone entity, for example a place inside a company name.
--temperature scales the restricted logits before softmax. It affects the
readout path, PCD choice scoring, and the readout probabilities used as a
reference by PCD. Practical starting points:
0.85–0.95: higher precision, fewer borderline keep spans, but can miss ambiguous names or IDs.1.0: default.1.1–1.3: higher recall, but more false keep spans and occasional over-extension.
Pair it with --none-margin for keep types, --none-margin-redact for
redact:true types, and the per-request threshold.
--none-margin-max-len limits the margin check to short spans (default 4 code
points). Re-run the examples or tests after changing any of them.
Set "mode": "pcd" to use the Parallel Constrained Decoding backend adapted
from pcdServer. It renders bounded
fields, broadcasts one sequence per field, scores only the allowed choice
tokens, resolves shared token prefixes with a collision tree, and caches prefix
checkpoints (--pcd-cache-entries, --pcd-cache-bytes).
The default remains "readout". PCD is fastest on cache hits and uses the
readout type probabilities as a confidence gate and type reference. It matches
readout on medical.json and legal.json; placeholder names can still
over-extend some label mentions. It is intended for repeated field layouts and
benchmarking. Responses include usage.pcd with forward passes, cache status,
checkpoint bytes, type-prior cache hits, and phase timings.
mode: "parrot" is a type-free token-level PCD prototype. It creates one field
per token with choices copy / REDACTED, merges consecutive redacted tokens,
and returns type "redacted". types is optional and ignored unless
parrot_typed is true. The text must be tokenizer-reconstructible and at most
256 tokens.
With parrot_typed: true, each token's choices become copy plus the request's
type ids; consecutive tokens of the same type merge into a typed span. Use
mask_style: "type" to render [type], or "typed" for [type:index].
Current 0.8B results over-redact heavily; 2B at temperatures 0.7–1.3 either
over-redacts or copies everything, and typed 2B tends to copy all tokens. This
mode is kept for comparison and benchmarking, not recommended for production.
Use readout or pcd instead.
Requires CMake 3.20+, Git, and a C++20 compiler. The official llama.cpp
v0.8.0 and cppjieba are pinned as submodules, so no external checkout is
needed. cppjieba dictionaries ship in third_party/cppjieba/dict; set
ANYPPI_JIEBA_DICT to use another dictionary directory.
git clone --recurse-submodules https://github.com/DreamBlooms/AnyPii-Readout.git
cd AnyPii-Readout
./build.sh
# Existing checkout:
git submodule update --init --recursive
./build.sh
# Or point at another upstream llama.cpp checkout:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \
-DLLAMA_DIR=/path/to/llama.cpp
cmake --build build -j"$(nproc)"llama.cpp and ggml are linked statically; the result is a single
build/anypii binary, about 7 MB.
The default build is CPU only. Enable one optional backend at configure time:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DANYPPI_CUDA=ON
cmake --build build -j"$(nproc)"ANYPPI_CUDA, ANYPPI_VULKAN, ANYPPI_HIP, and ANYPPI_METAL map to the
llama.cpp GGML_* backends. Offload at runtime with --gpu-layers -1 (all
layers) or a positive layer count; --gpu-layers 0 stays on CPU. The same
command line works for CPU-only and GPU builds. For CUDA cross-compilation, pin
the target with -DCMAKE_CUDA_ARCHITECTURES=89.
--model PATH GGUF model (default $ANYPPI_MODEL or the local Qwen3.5-0.8B)
--host HOST bind host (default 127.0.0.1)
--port N bind port (default 8080)
--ctx N context size (default 65536)
--threads N CPU threads (default: physical CPU cores)
--n-batch N logical batch size (default 8192)
--n-ubatch N physical batch size (default 1024)
--gpu-layers N layers to offload (0 = CPU, negative = all; GPU builds)
--flash-attn MODE auto|on|off (default auto)
--max-branches N max decision branches per decode (default 32)
--max-text-cp N max input text length in code points (default 16000)
--max-candidates N max candidate spans (default 4000)
--temperature T softmax temperature (default 1.0)
--none-margin M minimum P(type)-P(none) margin (default 0.65)
--none-margin-max-len N apply the margin to spans up to N code points (default 4)
--none-margin-redact M margin for redact:true types (default 0.20)
--pcd-cache-entries N PCD prefix checkpoint entries (default 4)
--pcd-cache-bytes BYTES PCD prefix checkpoint budget (default 256 MiB)
--readout-cache-entries N readout prefix checkpoint entries (default 4)
--readout-cache-bytes BYTES readout prefix checkpoint budget (default 256 MiB)
--weak-pos LIST append jieba POS tags to the weak boundary set
--weak-pos-override LIST replace the weak boundary set (use none to clear)
--result-cache-entries N cached identical responses (default 64)
--result-cache-bytes BYTES result cache budget (default 64 MiB)
--compact-branches shorter readout type-branch prompts (A/B)
--single-type-orientation one orientation for type-refinement pairs (A/B)
--type-refine-dedup deduplicate refinement by text and shortlist (default)
--no-type-refine-dedup decide type refinement per occurrence (A/B)
--component-shortlist type-score only top/longest/suffix per component (A/B)
--api-key KEY require Authorization: Bearer KEY
--help show this help
Request:
| field | meaning |
|---|---|
state.text |
original text |
state.policy |
arbitrary natural-language policy, may be empty |
types |
map of type id to definition |
types.*.description |
what the type means |
types.*.redact |
true/false forced; omitted or "auto" uses readout |
threshold |
optional, default 0.55 |
mask_style |
"token" ([REDACTED]), "length" (█ per code point), "type" ([type]), or "typed" ([type:index]); "mask" is an alias for length |
skip_keep_types |
optional; skip keep-only detection, refinement, and output |
omit_keep_spans |
optional; omit redacted:false spans from the response (text is unchanged) |
parrot_typed |
optional; in parrot mode use type ids as token choices instead of REDACTED (requires types) |
mode |
"readout" (default), "pcd" (experimental), or "parrot" (experimental token-level) |
fallback |
optional; reserved for a future constrained-decoding path |
Response:
| field | meaning |
|---|---|
redacted_text |
original text with redacted spans replaced |
spans[] |
{type, text, redacted, occurrences: [{start, end, score[, redact_score]}]} |
model, backend |
loaded model and compute backend |
usage |
{decodes, branches, generated_tokens, fallback[, readout][, pcd][, result_cache]} |
score is the readout and arbitration confidence, including the influence of
type descriptions and length information; it is not a calibrated probability. redact_score is present only for
"auto" types. Repeated occurrences of the same (type, text, redacted) are
merged into one span. For mask_style: "typed", index is the zero-based
position of the span in spans[].
usage.decodes is the pipeline's accounted decode-round count: one prefix round
plus the branch batches for each pass. usage.branches counts decision
branches. Neither is a generated-token count, and generated_tokens is always
zero; when a prefix is cached, decodes can still include the accounted prefix
round.
Redaction semantics:
redact |
behavior |
|---|---|
true |
every hit of this type is redacted |
false |
every hit is preserved but still returned in spans |
omitted / "auto" |
redact/keep readout under the policy; redact_score = P(redact) |
| policy conflict | explicit redact wins |
python3 tests/test_anypii.pyThe test script starts and stops its own server on a random port. It covers:
- exact medical and contact outputs;
- NIST and regional regex fast paths with zero decodes (SSN, ITIN, EIN, payment card, IBAN, IPv6, date of birth, NPI, MBI, DEA, VIN, passport, AWS access key, China/HK/Macau/Taiwan IDs, and regional phones);
- readout and PCD prefix-checkpoint cache hits;
- parrot mode plumbing, typed parrot, and
mask_style: "type"; - identical-request result cache and PCD type-prior cache;
omit_keep_spansoutput filtering;redactflip;- equal-length and typed masking;
- auto redaction;
- repeated-occurrence merging and a 14.4k-character input;
skip_keep_typesand the all-keep zero-decode shortcut;- empty text and invalid threshold (HTTP 400).
If a server is already running:
ANYPPI_URL=http://127.0.0.1:8080 python3 tests/test_anypii.pyFor debugging, ANYPPI_DEBUG=1 logs candidates and every readout stage.
ANYPPI_TIMING=1 logs prefix and branch timing.
- Non-overlapping spans only; nested entities are not returned as separate hits.
- Placeholder names such as 李四 or 李小明 can lower boundary and type accuracy for the 0.8B model and jieba.
- At most 25 types per readout request (option-label budget).
- Text limit 16000 code points and candidate limit 4000 by default
(
--max-text-cp,--max-candidates); longer input returns 400. The HTTP payload limit is 2 MiB. - The 0.8B model has limited confidence on very fine-grained custom types; clearer descriptions and an appropriate threshold improve results.
- CJK candidate generation uses cppjieba word and POS information; without the dictionary it falls back to the model tokenizer. Labels come from the caller's type ids and descriptions.
skip_keep_typesreduces unnecessary decisions. It keeps keep labels in Pass A to avoid false masking, so the savings are in boundary/type refinement and output, not in the type pass.- The local HTTP service has no TLS. Put it behind a reverse proxy for
deployment and use
--api-key. fallbackis reserved; the current implementation is readout only.- Type-refinement deduplication and
--component-shortlistare approximations. Review their output on your data before enabling them broadly. - PCD and parrot modes are experimental;
readoutis the default.
The architecture and prompt layout follow the System One / logit-readout line of work: ifreflex.cpp, reflex, and SemIf-OpenJev. AnyPII is an independent implementation for arbitrary natural-language policy-driven PII redaction.
Third-party components:
- llama.cpp (MIT)
- cppjieba (MIT)
- cpp-httplib (MIT)
- nlohmann/json (MIT)
PII format patterns were cross-checked against trackingplan/pii-regex-library (MIT) and the PII Crawler regex guide.
The default model is Qwen3.5-0.8B; model weights are separate artifacts with their own terms.