Detection layers¶
An entity can be detected by one or more of three layers — regex,
ner, and llm. When you enable an entity it runs on every layer that
supports it; pass layers=[...] to target one:
guard.add_entity("EMAIL", action="redact", layers=["regex"])
guard.add_entity("SPECIAL_CATEGORY", action="redact", layers=["llm"])
The engine merges every layer's spans and resolves overlaps confidence-first.
Regex spans are tiered by certainty — checksum 1.0, high-precision 0.97,
fuzzy 0.90 — and all sit above the model layers (NER/LLM 0.85), so a regex
match always wins an overlap and is never dropped by adjudication.
Discover what each layer can detect:
Wardcat.supported_entities() # every known type
Wardcat.supported_entities("ner") # {"PERSON", "ORG", "ADDRESS", "LOCATION", "NRP"}
Regex¶
Deterministic, exhaustive, and free — the backbone. Always on for any enabled regex-supported entity, with no extra dependency. The full list, with each entity's default action, is on the entity types page.
Text is folded before the patterns run: confusable characters — Cyrillic and
Greek lookalikes, fullwidth and Arabic-Indic digits — are mapped to their ASCII
skeleton, so ali@tеst.com with a Cyrillic е or a card written 4111… is
matched like its plain form, and offsets still point into the original text.
That is normalize_confusables, on by default; it is a curated skeleton rather
than the full Unicode table, so an exotic lookalike may still slip through.
Checksum-validated, so a match is proof rather than a guess: TC_ID (Nüfus
İdaresi, whole or grouped 111 654 670 34), IBAN (mod-97), CREDIT_CARD (Luhn,
Troy included), CRYPTO_WALLET (Base58Check for
legacy Bitcoin addresses, the bech32/bech32m polymod for segwit), NHS_NUMBER
(mod-11), BANK_ROUTING (ABA mod-10 over a Federal Reserve prefix), IMEI
(Luhn), and every scheme inside EU_NATIONAL_ID — Spanish DNI/NIE check letters,
the French INSEE key, the Dutch BSN elfproef, the Polish PESEL check digit.
Structural, matched on a distinctive shape: email, phone, JWT, UUID, IPv4 and
IPv6, MAC address, postcodes, VAT numbers, and the provider-prefixed secrets
(sk-, ghp_, github_pat_, AKIA, hf_, Azure account keys, Sentry DSNs,
and more).
Cued by the word beside them, because they have no shape of their own: a
credential written into a sentence (parolası ise …, password is …),
USERNAME (kullanıcı adı ahmet.yilmaz), an SSN without dashes
(social security number 412 76 9038), and a phone number in any national
format once it is labelled — Phone: 0490 75 40 81, call me at 905-674-3793, or
781 1704 office in a signature. Only the value is taken, never the keyword, so
the redacted line still reads. A labelled number scores 0.90, below the
structural pattern's 0.97.
Guessed from shape alone: HIGH_ENTROPY_STRING, a run of 32+ base64-shaped
characters with the Shannon entropy of a generated key, or a hex digest longer
than a git SHA. A secret with no known prefix and no keyword beside it has no
other signature, and many innocent tokens share this one, so it is off unless
enabled and scores 0.70, under the default floor: turn it on with
add_entity(Entity.HIGH_ENTROPY_STRING, Action.REDACT, min_confidence=0.7) and
measure it on your own logs first. Known secret shapes (a JWT, a UUID, a prefixed
API key) win the overlap.
Three of those checksums are weak enough that a bare digit run passes about one
time in ten — the ABA, NHS and IMEI checks. Both the cued and the bare form are
matched, and the bare one is scored at 0.70 instead, under the
confidence floor.
SpaCy NER (ner)¶
Names, organisations, places and group affiliations via SpaCy — PERSON, ORG,
ADDRESS, LOCATION and NRP. Off by default and ships no
default model — enable with a language (recommended) or an explicit model:
from wardcat import Wardcat, Language
guard = Wardcat(salt="s").with_ner(language=Language.TR).add_entity("PERSON")
guard = Wardcat(salt="s").with_ner(spacy_model=["en_core_web_sm", "de_core_news_sm"])
Pick a language, not a model name. Use the Language constants (or their
ISO codes — en, de, fr, es, it, nl, pt, tr) and an optional size
tier, and wardcat resolves the package from its catalog and downloads it if it
is missing:
guard = Wardcat().with_ner(language=Language.DE) # → de_core_news_sm
guard = Wardcat().with_ner(language=Language.FR, spacy_size="md") # → fr_core_news_md (sm/md/lg/trf)
guard = Wardcat().with_ner(language=Language.TR, spacy_size="lg") # → tr_core_news_lg
guard = Wardcat().with_ner(language=Language.EN, auto_download=False) # never download
If the requested size is unavailable for a language, the recommended model is
used. Selecting a language implies auto-download; auto_download=False turns
it off. tr_core_news_trf is incompatible with SpaCy 3.5+ — use md or lg.
A model that cannot be loaded — SpaCy is not installed, or the model is missing
and could not be downloaded — is skipped, and every ScanResult.warnings says so.
If a missing model is replaced by another one that is installed, the warning names
both, and says plainly when the replacement is for a different language: an
English model reading Turkish text misses most names.
Case endings written after an apostrophe are left out of the span. Turkish SpaCy
models return Ahmet Yılmaz'ın and İstanbul'da as entities; wardcat replaces
only the name, so the text reads [PERSON:…]'ın and one person keeps one
placeholder whatever case the sentence puts them in — which is what lets an index
link the mentions. O'Brien and similar names, where the apostrophe is part of
the name, are kept whole.
A multilingual gazetteer filters out job titles, abbreviations, form-field labels
(SSN, IBAN, Phone) and postal designators (P.O. Box, APO AP) that NER
models commonly mislabel as names or organisations. An organisation whose last
word is a street designator (Pollen Crescent) is dropped as a street name.
Span edges are cleaned before a value is replaced, so one name keeps one
placeholder. Of a span that crosses a line break — in an address block the model
runs on into the next field (Anna Josefsen\nAddress), under a heading it runs
back over the heading (Renewals Team\nDalton Inc.) — the line with the most
capitalised words is kept, a line ending in a legal form winning for an
organisation. Where a span runs into markup or a record delimiter
(Carolyn Hill running into a closing XML tag, Dawn Perkins|560=726,
BkCode=1290:::ABC Bank) the
longest delimiter-free fragment without a digit is kept. A short, fixed list of words is
trimmed from the front: articles, greetings and salutations (The, Dear,
Sayın, dün). Nothing outside that list is trimmed, because a word too many
costs consistency and a word too few leaks part of a name. That is why the
Turkish md model's sentence-initial run-ons (Raporu Ayşe Demir) are left as
they are; tr_core_news_lg makes that mistake far less often.
LOCATION covers countries, cities and regions, kept apart from ADDRESS (a
street address) because the two carry different risk. NRP is nationality,
religious or political affiliation — GDPR Article 9 data — and ships off by
default: "Turkish" and "Catholic" are ordinary vocabulary, so redacting every
occurrence would wreck the text for anyone not doing Article 9 work.
A PERSON normally has to contain a capitalized word. That rule is skipped where
the surrounding text carries no capitals at all, since a chat log or an ASR
transcript would otherwise lose every name to a property it never had. The trade
is that a lower-cased document can surface a common-word sequence as a PERSON.
Over-flagging is the safer direction for a redaction tool, and on
presidio-research's corpus it is also the more accurate one: the old rule
removed 15 real names to remove 7 false ones. Feed the layer text that keeps its
original casing if you want the stricter behaviour.
Choosing a language (and auto-detection)¶
One model loads per language, so NER only recognises the language(s) you select.
wardcat does not bundle language detection — that would add an opinion and a
dependency to a library that keeps its core to pyyaml + httpx. Instead it
exposes the selection so you can wire in your own detector when you need it:
detect the language with any tool you like, then pass the code. supported_languages()
lets you check support first:
from wardcat import Wardcat, supported_languages
code = detect(text) # your language detector of choice
if code in supported_languages(): # ('de', 'en', 'es', 'fr', 'it', 'nl', 'pt', 'tr')
guard = Wardcat(salt="s").with_ner(language=code)
else:
guard = Wardcat(salt="s").with_llm(...) # LLM layer is language-agnostic
For genuinely mixed-language text, either pass a list (language=["en", "de"],
one model each — each model adds RAM and roughly multiplies NER scan time) or
lean on the LLM layer, whose prompt is multilingual and needs no per-language
model. Either way the choice is explicit: wardcat does not guess the language of
the input. The regex layer is multilingual regardless.
On-prem LLM (llm)¶
The strongest context — detects semantic PII the others can't: GDPR Article 9
special-category data (a stated health condition, religious or political
affiliation, trade-union membership), contextual secrets (password=…),
unlabeled passports. It is never trusted blindly: the model returns
{"type","text"} JSON, which is filtered by structural validators — a card,
IBAN or TC number must also pass the same checksum the regex layer applies — and
located back in the original text. If the backend is unreachable the whole layer is
skipped and recorded in ScanResult.warnings; a transient per-chunk error
(timeout, malformed JSON) is logged and that chunk is skipped while the rest
continue.
from wardcat import Wardcat, Backend
# Ollama (default): needs a running Ollama service — https://ollama.com, then `ollama pull llama3.1:8b`
guard = Wardcat(salt="s").with_llm(backend=Backend.OLLAMA, model="llama3.1:8b")
# vLLM server (OpenAI-compatible API; native chat, defaults to :8000/v1)
# started with `vllm serve meta-llama/Llama-3.1-8B-Instruct`
guard = Wardcat(salt="s").with_llm(backend=Backend.VLLM,
model="meta-llama/Llama-3.1-8B-Instruct",
base_url="http://localhost:8000/v1")
# In-process HuggingFace Transformers (no daemon): pip install "wardcat[transformers]"
guard = Wardcat(salt="s").with_llm(backend=Backend.TRANSFORMERS,
model="Qwen/Qwen2.5-3B-Instruct",
load_in_8bit=True) # optional: less VRAM
The LLM layer is configured only through with_llm() (or a YAML
config_path), not constructor arguments. backend is the backend type —
Backend.OLLAMA, Backend.VLLM, Backend.OPENAI_COMPATIBLE,
Backend.TRANSFORMERS, plain strings accepted — and the address goes to
base_url. Backend.VLLM sends the chat messages natively and defaults to
http://localhost:8000/v1; for LM Studio, LocalAI, LiteLLM and other
OpenAI-style servers use Backend.OPENAI_COMPATIBLE with the endpoint's
base_url (pass api_key= only if the server was started with one).
with_llm() also switches on the layer's
own entity policy.
Reasoning models such as Qwen3 and DeepSeek-R1 are asked not to think: wardcat
wants a bare JSON list, and on qwen3:14b thinking stretched a one-sentence scan
from about ten seconds to minutes. The Ollama backend sends think: false. Other
servers are not sent a vendor-specific flag, so switch reasoning off in the
server's own configuration (vLLM and llama.cpp accept
chat_template_kwargs={"enable_thinking": false}); any <think>…</think> that
still reaches wardcat is removed before the reply is parsed.
Model lifecycle & choosing a backend¶
The transformers backend loads the model in-process. Weights are cached
on disk (~/.cache/huggingface, downloaded once), but the pipeline is loaded
into RAM/VRAM the first time you scan and then reused for the lifetime of
that Wardcat object. On a tiny 135M model the first scan pays ~3–5 s of load;
every subsequent scan in the same process is warm (~0.1 s). For an 8B model the
cold load is tens of seconds — so where you create the Wardcat matters.
Weight dtype¶
The transformers backend picks a dtype the device runs natively: bfloat16 on
a CUDA card that reports support for it, float16 on Apple Silicon, float32 on
plain CPU. Override it when you have a reason:
guard = Wardcat(salt="s").with_llm(
backend=Backend.TRANSFORMERS, model="Qwen/Qwen2.5-3B-Instruct", dtype="float32"
)
The choice matters more than it looks. Apple Silicon has no bf16 arithmetic unit
— Metal emulates it, so every matmul pays a conversion the fp16 path does not —
and most CPU kernels have no half-precision path at all, falling back through
fp32 anyway. Ampere and later keep bfloat16 by default: it runs natively there,
and its wider exponent range is the safer of the two half formats.
float16 on Apple Silicon
Some torch/transformers combinations segfault loading float16 through
device_map="auto" on MPS. If that happens, name bfloat16 explicitly — it
is slower there, but it loads.
The HTTP backends (ollama, vllm, openai_compatible) don't load anything
in your process — they call a daemon/server that keeps the model resident (Ollama
warms it via keep_alive, ~5 min; vLLM stays loaded for the server's lifetime).
Serving from FastAPI (or any long-lived process)
Create one Wardcat at startup and reuse it — the model loads once and
stays warm for every request:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from wardcat import Wardcat, Backend
guard: Wardcat | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global guard
guard = Wardcat(salt="s").with_llm(
backend=Backend.TRANSFORMERS, model="meta-llama/Llama-3.1-8B-Instruct"
)
guard.scan("warmup") # optional: pay the cold load at startup
yield
app = FastAPI(lifespan=lifespan)
@app.post("/scan")
async def scan(text: str):
# await the async API so concurrent requests overlap instead of blocking
return (await guard.scan_async(text)).sanitized_text # warm model
Do not build Wardcat(...) inside the request handler — that reloads the
model on every request. One shared guard is safe across concurrent scans;
just don't reconfigure it while it is serving requests.
Multiple workers multiply VRAM
Each uvicorn --workers N / gunicorn worker is a separate process, so the
transformers backend loads its own copy of the model — N workers ≈ N×
VRAM. For horizontally-scaled serving use vllm (throughput) or
ollama (simple setup) instead: every worker shares one server, so the
weights live in VRAM once regardless of worker count.
Quick guide:
| Scenario | Backend |
|---|---|
| Prod serving, multiple workers, high traffic | vllm / ollama |
| Single long-lived process, batch jobs, dev, air-gapped | transformers |
| Repeated short-lived CLI runs | ollama / vllm (daemon stays warm between runs) |
Async & concurrency¶
Every scanning call has an async twin — scan_async, scan_batch_async,
is_sensitive_async. CPU layers run in a thread pool; the LLM layer uses native
async I/O, so concurrent requests overlap instead of blocking:
- A shared
Wardcatis safe across concurrent scans (caches/detectors are lock-protected); don't reconfigure it while it is serving. scan_asyncis non-blocking, but a single Ollama on one GPU can still process LLM requests near-sequentially — use vLLM (continuous batching) or raiseOLLAMA_NUM_PARALLELfor genuine parallel LLM throughput.- Regex/NER-only scans are already sub-millisecond to ~100 ms, so concurrency
there is rarely the bottleneck.
scan_batchruns SpaCy over the whole list in onenlp.pipepass and bounds the LLM requests in flight (max_concurrency), so a list is cheaper than a loop of single scans.
Ensemble adjudication¶
With with_llm(adjudicate=True) the LLM verifies/relabels/drops the regex+NER
candidates and adds what they missed, in a single call — cleaning NER noise
(e.g. a job title mislabeled as a name). Deterministic regex spans are always
kept regardless of the LLM verdict. Adjudication has no effect in an LLM-only
setup — there are no regex/NER candidates to judge, so the LLM runs as a pure
detector.
Semantic sensitivity gate — is_sensitive()¶
For a yes/no guardrail rather than per-entity extraction, Wardcat.is_sensitive(text)
returns a single boolean: does the text contain sensitive information (PII,
credentials, financial, special-category, or confidential business data)? It is
a holistic LLM judgement, so it also flags things the typed detectors don't —
unreleased financials, deal terms, a confidential project.
guard = Wardcat().with_llm(model="gemma3:12b")
if guard.is_sensitive(user_text): # or: await guard.is_sensitive_async(...)
raise ValueError("won't forward sensitive text")
LLM-only (no entities to enable); requires with_llm(...), raising ConfigError
without it; empty text is False. Fail-closed — a backend error propagates
rather than returning a misleading False. with_llm(language="tr") (or de,
fr) asks the question with a system prompt written in that language, which
can help smaller models; any other value uses the English, multilingual-aware
prompt, and the scan() detection prompt is not affected. Long inputs are
chunked at paragraph boundaries — any sensitive chunk makes the whole text
sensitive — and input over max_text_bytes is rejected.
classify(text) gives the same judgement as a SensitivityVerdict: sensitive,
the categories present (pii, credentials, financial, health,
special_category, business_confidential — the SENSITIVITY_CATEGORIES
constant — or unknown when the model said sensitive but its answer could not
be read in full) and the model's one-line reason, which may quote the text. Route on the kind — block health data, allow business data inside the
company. It is a separate prompt: asked for structure the model is more precise
but misses more, chiefly confidential business plans (on the 100-text benchmark
with qwen3:14b: 1 false alarm against 11, but 8 misses against 1), and answers
several times slower, so is_sensitive() stays the gate. Its prompt names placeholders and
format examples, a company's public customer-service number and order or version
numbers as not sensitive; the is_sensitive() prompt is unchanged.
See the full API on the Wardcat reference page.