Skip to content

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:

import asyncio

results = await asyncio.gather(*(guard.scan_async(t) for t in texts))
  • A shared Wardcat is safe across concurrent scans (caches/detectors are lock-protected); don't reconfigure it while it is serving.
  • scan_async is non-blocking, but a single Ollama on one GPU can still process LLM requests near-sequentially — use vLLM (continuous batching) or raise OLLAMA_NUM_PARALLEL for genuine parallel LLM throughput.
  • Regex/NER-only scans are already sub-millisecond to ~100 ms, so concurrency there is rarely the bottleneck. scan_batch runs SpaCy over the whole list in one nlp.pipe pass 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.