Skip to content

Results & constants

Results

wardcat.ScanResult dataclass

ScanResult(
    original_text,
    sanitized_text,
    violations=list(),
    scan_error=None,
    warnings=list(),
    context_id="",
    _salt="",
    _locale="en",
)

Result of a single guard.scan() call.

.. warning:: The original_text and violations[].original fields contain raw PII, as do :attr:token_map and anything :meth:restore returns. When writing this object to logs, databases, or API responses, use only sanitized_text. Use the :meth:redacted method to obtain a dict that contains no PII.

original_text instance-attribute

original_text

Unmodified original input. Contains raw PII — do not expose externally.

sanitized_text instance-attribute

sanitized_text

Output text with PII masked/reported.

violations class-attribute instance-attribute

violations = field(default_factory=list)

List of all detected violations. The original fields contain raw PII.

scan_error class-attribute instance-attribute

scan_error = None

Set when this item failed during :meth:~wardcat.Wardcat.scan_batch. The original text is returned unchanged. Non-None means the scan result is incomplete — callers should not treat the text as clean.

warnings class-attribute instance-attribute

warnings = field(default_factory=list)

Non-fatal issues during this scan — e.g. a detector layer that could not run (LLM backend unreachable). The scan still returns results from the other layers, but a non-empty list means detection was degraded: some PII may be missed. Empty when every configured layer ran.

context_id class-attribute instance-attribute

context_id = ''

Identifies this scan's anonymization pass. It is stamped into every reversible (:attr:Action.TOKENIZE) placeholder — [EMAIL_1_9f3a2c8b71d4] — so no two scans ever produce the same token and :meth:restore can tell another scan's placeholder from its own. Empty when nothing stamped one.

is_clean property

is_clean

True if no PII was detected.

token_map property

token_map

Placeholder → original value for every violation that replaced something.

The vault behind :meth:restore, exposed for callers that need to hand the mapping to another process (a queue worker restoring a reply later, say)::

vault = result.token_map          # {"[EMAIL_1]": "ali@example.com"}

Meaningful for reversible placeholders — see :attr:Action.TOKENIZE; with redact or mask several values can share one placeholder and the map keeps only the last. warn violations are absent (nothing was replaced).

.. warning:: Raw PII. Never log or persist this without protecting it like the original text.

redacted

redacted()

Return a safe :class:RedactedResult dict with no PII.

Excludes the original_text and violations[].original fields. Use this method for logs, API responses, or database records::

result = guard.scan(text)
log.info("scan result: %s", result.redacted())

Returns: A :class:RedactedResult (TypedDict) containing sanitized_text, is_clean, scan_error, and violation metadata (entity_type, start, end, action, replacement, confidence, source, and the sanitized-text offsets). Raw PII is not included.

Source code in src/wardcat/core/models.py
def redacted(self) -> RedactedResult:
    """Return a safe :class:`RedactedResult` dict with no PII.

    Excludes the ``original_text`` and ``violations[].original`` fields.
    Use this method for logs, API responses, or database records::

        result = guard.scan(text)
        log.info("scan result: %s", result.redacted())

    Returns:
        A :class:`RedactedResult` (``TypedDict``) containing ``sanitized_text``,
        ``is_clean``, ``scan_error``, and violation metadata (entity_type,
        start, end, action, replacement, confidence, source, and the
        sanitized-text offsets). Raw PII is not included.
    """
    return {
        "is_clean": self.is_clean,
        "sanitized_text": self.sanitized_text,
        "scan_error": self.scan_error,
        "warnings": list(self.warnings),
        "violations": [
            {
                "entity_type": v.entity_type,
                "start": v.start,
                "end": v.end,
                "action": v.action,
                "replacement": v.replacement,
                "confidence": v.confidence,
                "source": v.source,
                "sanitized_start": v.sanitized_start,
                "sanitized_end": v.sanitized_end,
            }
            for v in self.violations
        ],
    }

restore

restore(text=None, *, strict=False, also=())

Put the real values back — the reverse of the anonymization stage.

The round trip an LLM call needs: scan the prompt, send the anonymized text out, then restore the answer that comes back::

result = guard.scan(prompt)
answer = call_llm(result.sanitized_text)   # never sees the real values
print(result.restore(answer))              # answer + its source list

Printing the returned :class:~wardcat.core.restore.RestoredText appends an ordered source list naming, per placeholder, which filter fired and what it stood for; use .text for the bare restored text and .substitutions for the same information as data.

Restoring is reliable when the values were replaced with :attr:Action.TOKENIZE (or hash): each distinct value has its own placeholder. With redact/mask two values can collapse onto the same placeholder — those are reported in .unrestored as ambiguous and left in the text rather than guessed. Use :meth:reapply to derive a reversible view of a scan that used another action::

reversible = result.reapply(Action.TOKENIZE)

Placeholders carry this scan's :attr:context_id, so a token produced by a different scan cannot be matched by accident — restoring one request's answer against another request's result would otherwise substitute the wrong person's values. Such tokens are reported as foreign, left in the text, and make is_complete False; strict=True raises :class:~wardcat.exceptions.ContextMismatch instead. In a multi-turn exchange an answer may legitimately carry an earlier scan's placeholders — pass those results in also and they are restored too::

turn2.restore(answer, also=[turn1], strict=True)

Parameters:

Name Type Description Default
text str | None

the text to restore; defaults to :attr:sanitized_text, which round-trips back to the original input.

None
strict bool

raise :class:~wardcat.exceptions.ContextMismatch when the text carries a placeholder no known result produced.

False
also Iterable[ScanResult]

other results whose placeholders are legitimate here.

()

Returns:

Type Description
RestoredText

a :class:~wardcat.core.restore.RestoredText. Contains raw PII.

Source code in src/wardcat/core/models.py
def restore(
    self,
    text: str | None = None,
    *,
    strict: bool = False,
    also: Iterable[ScanResult] = (),
) -> RestoredText:
    """Put the real values back — the reverse of the anonymization stage.

    The round trip an LLM call needs: scan the prompt, send the anonymized
    text out, then restore the answer that comes back::

        result = guard.scan(prompt)
        answer = call_llm(result.sanitized_text)   # never sees the real values
        print(result.restore(answer))              # answer + its source list

    Printing the returned :class:`~wardcat.core.restore.RestoredText` appends an
    ordered source list naming, per placeholder, which filter fired and what it
    stood for; use ``.text`` for the bare restored text and ``.substitutions``
    for the same information as data.

    Restoring is reliable when the values were replaced with
    :attr:`Action.TOKENIZE` (or ``hash``): each distinct value has its own
    placeholder. With ``redact``/``mask`` two values can collapse onto the same
    placeholder — those are reported in ``.unrestored`` as ``ambiguous`` and
    left in the text rather than guessed. Use :meth:`reapply` to derive a
    reversible view of a scan that used another action::

        reversible = result.reapply(Action.TOKENIZE)

    Placeholders carry this scan's :attr:`context_id`, so a token produced by a
    *different* scan cannot be matched by accident — restoring one request's
    answer against another request's result would otherwise substitute the
    wrong person's values. Such tokens are reported as ``foreign``, left in the
    text, and make ``is_complete`` ``False``; ``strict=True`` raises
    :class:`~wardcat.exceptions.ContextMismatch` instead. In a multi-turn
    exchange an answer may legitimately carry an earlier scan's placeholders —
    pass those results in ``also`` and they are restored too::

        turn2.restore(answer, also=[turn1], strict=True)

    :param text: the text to restore; defaults to :attr:`sanitized_text`, which
                 round-trips back to the original input.
    :param strict: raise :class:`~wardcat.exceptions.ContextMismatch` when the
                 text carries a placeholder no known result produced.
    :param also: other results whose placeholders are legitimate here.
    :returns: a :class:`~wardcat.core.restore.RestoredText`. **Contains raw PII.**
    """
    from wardcat.core.restore import restore_text

    violations = list(self.violations)
    for other in also:
        violations.extend(other.violations)
    return restore_text(
        self.sanitized_text if text is None else text, violations, strict=strict
    )

reapply

reapply(action, entities=None)

Re-anonymize this already-detected result under a different action.

scan() does the expensive detection (regex + NER + LLM) once; this reuses those spans and re-runs only the cheap anonymization step, so you can derive several outputs from a single scan without re-scanning or reloading any model::

result = guard.scan(text)
masked = result.reapply(Action.MASK).sanitized_text
hashed = result.reapply(Action.HASH).sanitized_text
# re-anonymize only a subset of the detected types:
emails = result.reapply(Action.HASH, entities=["EMAIL"])

The salt from the originating :class:~wardcat.Wardcat is reused, so hash output matches what the guard would have produced. Types passed in entities that were not detected are simply absent (nothing to anonymize). Concurrency-safe: nothing shared is mutated.

Parameters:

Name Type Description Default
action Action | str

the action to apply (an :class:Action or its name).

required
entities Iterable[str] | None

optional subset of entity types to re-anonymize; None applies action to every detected violation.

None

Returns:

Type Description
ScanResult

a new :class:ScanResult under the requested action.

Raises:

Type Description
ConfigError

if action is not a registered action.

Source code in src/wardcat/core/models.py
def reapply(self, action: Action | str, entities: Iterable[str] | None = None) -> ScanResult:
    """Re-anonymize this already-detected result under a different ``action``.

    ``scan()`` does the expensive detection (regex + NER + LLM) once; this
    reuses those spans and re-runs only the cheap anonymization step, so you
    can derive several outputs from a single scan without re-scanning or
    reloading any model::

        result = guard.scan(text)
        masked = result.reapply(Action.MASK).sanitized_text
        hashed = result.reapply(Action.HASH).sanitized_text
        # re-anonymize only a subset of the detected types:
        emails = result.reapply(Action.HASH, entities=["EMAIL"])

    The salt from the originating :class:`~wardcat.Wardcat` is reused, so
    ``hash`` output matches what the guard would have produced. Types passed
    in ``entities`` that were not detected are simply absent (nothing to
    anonymize). Concurrency-safe: nothing shared is mutated.

    :param action:   the action to apply (an :class:`Action` or its name).
    :param entities: optional subset of entity types to re-anonymize;
                     ``None`` applies ``action`` to every detected violation.
    :returns:        a new :class:`ScanResult` under the requested action.
    :raises ConfigError: if ``action`` is not a registered action.
    """
    from wardcat.core.actions import new_context_id, registered_actions
    from wardcat.core.anonymizer import Anonymizer
    from wardcat.detectors.base import DetectedSpan
    from wardcat.exceptions import ConfigError

    name = action.value if isinstance(action, Action) else str(action)
    if name not in registered_actions():
        raise ConfigError(
            f"unknown action {name!r}; choose one of {sorted(registered_actions())}"
        )

    keep: set[str] | None = None
    if entities is not None:
        keep = {e.value if isinstance(e, Enum) else str(e) for e in entities}

    violations = self.violations
    if keep is not None:
        violations = [v for v in violations if v.entity_type in keep]

    spans = [
        DetectedSpan(v.entity_type, v.original, v.start, v.end, v.confidence, v.source)
        for v in violations
    ]
    config = {v.entity_type: {"action": name} for v in violations}
    # A new pass produces new placeholders, so it gets its own context id —
    # the derived result must not answer for the one it came from.
    context_id = new_context_id()
    sanitized, new_violations = Anonymizer(config, salt=self._salt, locale=self._locale).apply(
        self.original_text, spans, context_id=context_id
    )
    return ScanResult(
        original_text=self.original_text,
        sanitized_text=sanitized,
        violations=new_violations,
        scan_error=self.scan_error,
        warnings=list(self.warnings),
        context_id=context_id,
        _salt=self._salt,
        _locale=self._locale,
    )

wardcat.Violation dataclass

Violation(
    entity_type,
    original,
    start,
    end,
    action,
    replacement=None,
    confidence=1.0,
    source="",
    sanitized_start=-1,
    sanitized_end=-1,
)

A single PII violation detected in the text.

entity_type instance-attribute

entity_type

E.g. "EMAIL", "CREDIT_CARD", "PERSON".

original instance-attribute

original

Raw value from the original text.

start instance-attribute

start

Start index in the original text.

end instance-attribute

end

End index in the original text.

action instance-attribute

action

Name of the action that was applied ("warn" / "hash" / "redact" / "mask", or a custom registered action). Compares equal to the matching :class:Action constant (v.action == Action.HASH).

replacement class-attribute instance-attribute

replacement = None

What the original was replaced with — the hash/redact/mask output, or the reversible tokenize placeholder. None for warn, which replaces nothing. Together with :attr:original this is what :meth:ScanResult.restore reverses.

confidence class-attribute instance-attribute

confidence = 1.0

Detection confidence in [0.0, 1.0], tiered by how certain the match is:

  • Checksum-validated regex (TC_ID, IBAN, CREDIT_CARD): 1.0 — mathematically verified.
  • High-precision structural regex (email, JWT, IP, secrets, …): 0.97.
  • Fuzzy regex (ADDRESS, VEHICLE_PLATE): 0.90 — a distinctive but ambiguous, heuristic match.
  • NER (SpaCy) / LLM: 0.85 — model-based.

The engine resolves overlaps highest-confidence-first, so a regex span always beats a model guess, and in adjudication every regex span is protected while the model layers may be overridden. Use this field to threshold::

certain = [v for v in result.violations if v.confidence >= 1.0]  # checksum only

source class-attribute instance-attribute

source = ''

Which layer found it: "regex", "ner", "llm", "denylist", "propagation" (a copy of a value another layer found), or "custom" for a third-party detector that did not name itself. Where two layers found the same span, the one that won the overlap.

sanitized_start class-attribute instance-attribute

sanitized_start = -1

Start index of :attr:replacement in sanitized_text; equals :attr:start shifted by the replacements before it. -1 when unknown.

sanitized_end class-attribute instance-attribute

sanitized_end = -1

End index (exclusive) of :attr:replacement in sanitized_text. For warn, which replaces nothing, the original value's position there.

wardcat.RedactedResult

Bases: TypedDict

The PII-free dict returned by :meth:ScanResult.redacted (safe to log).

wardcat.RestoredText dataclass

RestoredText(text, substitutions=list(), unrestored=list())

Result of :meth:~wardcat.ScanResult.restore — the text plus its sources.

str(restored) is the text with the source list appended, which is usually what you want to show::

answer = call_llm(result.sanitized_text)
print(result.restore(answer))

.. warning:: Contains raw PII in text and in every substitutions[].original.

text instance-attribute

text

The text with every unambiguous placeholder replaced by its original value.

substitutions class-attribute instance-attribute

substitutions = field(default_factory=list)

What was put back, ordered by first appearance in :attr:text.

unrestored class-attribute instance-attribute

unrestored = field(default_factory=list)

Detected values that were not put back, with the reason for each.

is_complete property

is_complete

True when no placeholder was left sitting in the text.

False when something was skipped for being ambiguous or was foreign — in both cases a placeholder is still there for a reader to trip over. A not-present entry does not make a restore incomplete: an answer that never mentions a placeholder has nothing to put back.

sources_block

sources_block(*, title=_SOURCES_TITLE, notes=True)

Render the ordered source list — which filter fired, and what it hid.

::

[1] [PERSON_1] → Ali Veli (PERSON · tokenize · x2 · confidence 0.85)
[2] [EMAIL_1] → ali@example.com (EMAIL · tokenize · confidence 0.97)

Returns "" when this text carries nothing to cite — nothing was put back and no placeholder was left in it — so appending the block to a clean answer adds nothing. A value that was masked but never mentioned in the text is not, by itself, something to cite.

Parameters:

Name Type Description Default
title str

heading for the block.

_SOURCES_TITLE
notes bool

also summarize the values that were not put back.

True
Source code in src/wardcat/core/restore.py
def sources_block(self, *, title: str = _SOURCES_TITLE, notes: bool = True) -> str:
    """Render the ordered source list — which filter fired, and what it hid.

    ::

        [1] [PERSON_1] → Ali Veli (PERSON · tokenize · x2 · confidence 0.85)
        [2] [EMAIL_1] → ali@example.com (EMAIL · tokenize · confidence 0.97)

    Returns ``""`` when this text carries nothing to cite — nothing was put
    back and no placeholder was left in it — so appending the block to a
    clean answer adds nothing. A value that was masked but never mentioned in
    the text is not, by itself, something to cite.

    :param title: heading for the block.
    :param notes: also summarize the values that were *not* put back.
    """
    if not self.substitutions and self.is_complete:
        return ""

    lines = [f"--- {title} ---"]
    for sub in self.substitutions:
        facts = [sub.entity_type, sub.action]
        if sub.occurrences > 1:
            facts.append(f"x{sub.occurrences}")
        facts.append(f"confidence {sub.confidence:.2f}")
        lines.append(f"[{sub.index}] {sub.placeholder} → {sub.original} ({' · '.join(facts)})")

    if notes:
        lines.extend(self._notes())
    return "\n".join(lines) if len(lines) > 1 else ""

with_sources

with_sources(*, title=_SOURCES_TITLE, notes=True)

:attr:text with :meth:sources_block appended below it.

Source code in src/wardcat/core/restore.py
def with_sources(self, *, title: str = _SOURCES_TITLE, notes: bool = True) -> str:
    """:attr:`text` with :meth:`sources_block` appended below it."""
    block = self.sources_block(title=title, notes=notes)
    return f"{self.text}\n\n{block}" if block else self.text

wardcat.Substitution dataclass

Substitution(
    index,
    entity_type,
    action,
    placeholder,
    original,
    occurrences,
    confidence,
    source="",
)

One placeholder that was swapped back for its original value.

.. warning:: original is raw PII — the whole point of restoring — so a :class:RestoredText is as sensitive as the input it came from.

index instance-attribute

index

1-based position in the source list, ordered by first appearance in the text.

entity_type instance-attribute

entity_type

E.g. "EMAIL", "PERSON".

action instance-attribute

action

Action that produced the placeholder ("tokenize", "hash", …).

placeholder instance-attribute

placeholder

The text that stood in for the value ("[PERSON_1]").

original instance-attribute

original

The value that was put back. Raw PII.

occurrences instance-attribute

occurrences

How many times the placeholder appeared in the restored text.

confidence instance-attribute

confidence

Detection confidence of the underlying violation.

source class-attribute instance-attribute

source = ''

The layer that found the value ("regex", "ner", "llm", …).

wardcat.UnrestoredValue dataclass

UnrestoredValue(entity_type, action, placeholder, reason)

A detected value that was not put back, and why.

Carries no raw value: not-present is the common, boring case (the answer simply never mentioned that placeholder), and the caller can look the value up in violations when it matters.

placeholder instance-attribute

placeholder

None for report-only actions such as warn, which replace nothing.

reason instance-attribute

reason

"not-present" — the placeholder does not occur in the text (an empty replacement, which a custom action may return, occurs nowhere locatable); "ambiguous" — it stood for more than one distinct value, so restoring it would be a guess; "not-replaced" — the action left the original text in place (warn), so there is nothing to reverse; "foreign" — the text carries a placeholder this result never produced (another scan's, or one the model invented), so it was left alone.

wardcat.SensitivityVerdict dataclass

SensitivityVerdict(sensitive, categories=(), reason='')

What :meth:~wardcat.Wardcat.classify decided about a text.

categories draws from :data:SENSITIVITY_CATEGORIES, plus "unknown" when the model said the text is sensitive but its answer could not be read in full — the verdict fails closed rather than clean. reason is the model's own one-line justification and may quote the text: treat it as sensitive as the input.

Exceptions

Everything wardcat raises derives from WardcatError, so one except catches the lot. ConfigError and ModelDownloadError also subclass the built-in they replaced (ValueError / RuntimeError), so existing handlers keep working.

wardcat.WardcatError

Bases: Exception

Base class for every error raised by wardcat.

wardcat.ContextMismatch

ContextMismatch(placeholders)

Bases: WardcatError

A text carries a reversible placeholder that belongs to a different scan.

Raised by ScanResult.restore(..., strict=True). Every scan stamps its own context id into the placeholders it produces, so a token from another scan can be recognized rather than silently matched — restoring one request's answer against another request's result would otherwise substitute the wrong person's values. Nothing is substituted for the foreign tokens either way; strict only decides whether that is an error or a report.

placeholders lists the foreign tokens found.

Source code in src/wardcat/exceptions.py
def __init__(self, placeholders: list[str]) -> None:
    self.placeholders = placeholders
    super().__init__(
        f"{len(placeholders)} placeholder(s) in this text belong to a different "
        f"scan and were left in place: {', '.join(sorted(placeholders))}. Restore "
        "with the ScanResult that produced them, or pass them in `also=[...]`."
    )

wardcat.ConfigError

Bases: WardcatError, ValueError

Invalid configuration (bad action, backend, pattern, entity spec, …).

Subclasses :class:ValueError for backward compatibility.

wardcat.ModelDownloadError

Bases: WardcatError, RuntimeError

A SpaCy/LLM model could not be downloaded or is incompatible.

Subclasses :class:RuntimeError for backward compatibility.

wardcat.UnsupportedLanguageError

Bases: ConfigError

The requested NER language (or size tier) has no compatible model.

wardcat.DegradedScanError

DegradedScanError(warnings, result=None)

Bases: WardcatError

A strict guard refused to return a scan that covered less than configured.

Raised only when the guard was built with with_strict() (or strict: true in YAML). Without strict mode the same condition is reported on :attr:ScanResult.warnings and the partial result is returned.

warnings lists what did not run — a SpaCy model that failed to load, an LLM backend that was unreachable. result carries the partial :class:~wardcat.ScanResult when the scan ran and failed only its coverage check; it is None when the guard refused at build time.

Source code in src/wardcat/exceptions.py
def __init__(self, warnings: list[str], result: object | None = None) -> None:
    self.warnings = list(warnings)
    self.result = result
    detail = "; ".join(self.warnings)
    super().__init__(
        f"strict mode: the scan covered less than configured, {len(self.warnings)} "
        f"layer problem(s): {detail}"
    )

wardcat.CircuitOpen

CircuitOpen(failures, retry_after)

Bases: WardcatError

The LLM backend is being skipped after repeated failures.

retry_after is how many seconds remain before the next trial call.

Source code in src/wardcat/llm/circuit.py
def __init__(self, failures: int, retry_after: float) -> None:
    self.failures = failures
    self.retry_after = retry_after
    super().__init__(
        f"LLM layer skipped: backend circuit open for another {retry_after:.0f} s "
        f"after {failures} consecutive failures"
    )

Constants

wardcat.Entity

Bases: str, Enum

Known entity types, as constants — for autocomplete and typo-proofing.

Use these instead of bare strings when configuring the guard::

from wardcat import Entity, Action

guard.add_entity(Entity.CREDIT_CARD, action=Action.HASH)

Each member is its string value (Entity.EMAIL == "EMAIL"), so it can be used anywhere a plain entity-type string is accepted. Note: because this is a (str, Enum), use .value to get the canonical string — str(Entity.EMAIL) returns "Entity.EMAIL", not "EMAIL".

The special member :attr:Entity.ALL is a sentinel, not a real entity type: passing it to :meth:~wardcat.Wardcat.add_entity / :meth:~wardcat.Wardcat.remove_entity enables/disables every known entity in one call. It is excluded from :data:KNOWN_ENTITY_TYPES. Entity.All is a deprecated alias of Entity.ALL kept for backward compatibility.

ALL class-attribute instance-attribute

ALL = '__ALL__'

Sentinel selecting every known entity type (not a real entity).

All class-attribute instance-attribute

All = '__ALL__'

Deprecated PascalCase alias of :attr:ALL.

HIGH_ENTROPY_STRING class-attribute instance-attribute

HIGH_ENTROPY_STRING = 'HIGH_ENTROPY_STRING'

A long token that looks like a secret with no known prefix and no keyword beside it: 32+ base64-shaped characters with high Shannon entropy, or a hex digest longer than a git SHA. Scored 0.70, under the default floor, so it only acts when enabled with its own min_confidence: it is a guess by construction, and long tokens of many innocent kinds look the same.

wardcat.Action

Bases: str, Enum

Action to apply to detected PII.

WARN class-attribute instance-attribute

WARN = 'warn'

Leave the text as-is, report only as a violation.

HASH class-attribute instance-attribute

HASH = 'hash'

Mask with SHA-256 + salt: [ENTITY_TYPE:abcd1234].

REDACT class-attribute instance-attribute

REDACT = 'redact'

Replace with a plain label: [ENTITY_TYPE] — no hash, no original value.

MASK class-attribute instance-attribute

MASK = 'mask'

Partially obscure the value, entity-aware. Most types reveal only the last few characters — e.g. CREDIT_CARD → ************1111, EMAIL → u***@example.com, SSN → ***-**-6789. Types without a specific rule fall back to first 2 + * + last 2 (abcdef → ab**ef); values shorter than 4 characters are fully replaced with *. See wardcat.core.actions._mask_value for the per-type rules.

TOKENIZE class-attribute instance-attribute

TOKENIZE = 'tokenize'

Reversible masking: replace with a numbered placeholder — [PERSON_1], [EMAIL_1], [PERSON_2] — numbered per entity type in order of appearance, with one token per distinct value (a name repeated stays one referent). The mapping lives on the :class:ScanResult, so :meth:ScanResult.restore can put the real values back into whatever comes out the other side — an LLM's answer, typically. Unlike hash/redact/mask this keeps the originals in memory: the result object is as sensitive as the input.

SURROGATE class-attribute instance-attribute

SURROGATE = 'surrogate'

Reversible replacement with a realistic stand-in of the same shape — a name from the guard's locale for a name, an address on a reserved domain for an e-mail, a Luhn-valid number for a card. The same value gets the same surrogate under the same salt, and no two values share one within a scan. A type without a generator falls back to tokenize and the violation says so. Surrogates look real, which is the point and the risk: see the security guide.

wardcat.Layer

Bases: str, Enum

A detector layer, as named in add_entity(..., layers=[...]) and in :attr:Violation.source.

REGEX class-attribute instance-attribute

REGEX = 'regex'

Deterministic patterns and checksums.

NER class-attribute instance-attribute

NER = 'ner'

SpaCy named-entity recognition.

LLM class-attribute instance-attribute

LLM = 'llm'

The on-prem language model.

wardcat.Backend

Bases: str, Enum

LLM backend types, as constants — for typo-proof selection.

Pass these to :meth:Wardcat.with_llm instead of bare strings::

from wardcat import Wardcat, Backend

Wardcat(salt="s").with_llm(backend=Backend.OPENAI_COMPATIBLE, model="...")

Each member is its string value (Backend.OLLAMA == "ollama"), so the plain string form is still accepted.

OLLAMA class-attribute instance-attribute

OLLAMA = 'ollama'

Local Ollama service (supports model download).

OPENAI_COMPATIBLE class-attribute instance-attribute

OPENAI_COMPATIBLE = 'openai_compatible'

OpenAI-compatible HTTP API — LM Studio, LocalAI, LiteLLM, …

VLLM class-attribute instance-attribute

VLLM = 'vllm'

vLLM server (OpenAI-compatible API) — native chat, vLLM defaults.

TRANSFORMERS class-attribute instance-attribute

TRANSFORMERS = 'transformers'

In-process HuggingFace Transformers (no HTTP; loads the model locally).

wardcat.Language

Bases: str, Enum

Supported NER languages, as constants — for documented, typo-proof selection.

Pass these to Wardcat().with_ner(language=...) instead of bare ISO codes::

from wardcat import Wardcat, Language

Wardcat().with_ner(language=Language.EN)                 # one language
Wardcat().with_ner(language=[Language.EN, Language.DE])  # multilingual NER

Each member is its ISO 639-1 code (Language.EN == "en"), so the plain string form is still accepted.