edgepiityped character spans
taxonomy 2026.11 GitHub

Public API

Package name: edgepii. Python 3.11 or newer. The result shape is the span contract. Types come from taxonomy 2026.11. Presets come from the catalog.

Locate

python
from edgepii import Locator

locator = Locator.preset("en-closed")
result = locator.locate(source, types=["PERSON", "EMAIL", "PHONE"])
ArgumentRequiredMeaning
sourceyesThe str to scan. Not bytes
typesnoSubset of the taxonomy. Default: all types except class quasi
taxonomynoDefault 2026.11
min_scorenoCaller policy. Default None, which keeps every span. A span with no score survives a floor, because a grammar match carries no probability and dropping it would leave the value unmasked. See thresholds
drop_unscorednoDefault False. Set it to also remove spans that have no score. Do this only after measuring what it costs
foldnoDefault "confusables". Folds invisible characters, compatibility forms, and cross-script look-alikes before matching, then maps every hit back onto the source string. Pass False to scan the raw text

result.taxonomy_version is a string. result.spans is a tuple of Span. result.dropped is a count of hits that failed the slice check. result.preset is the preset name. result.revision is the pinned weight revision, or rules for the rule preset. result.quasi is a tuple of Combination for any re-identification risk the text carries.

result.unmapped counts valid model hits whose labels have no public taxonomy mapping. The JSON export contains this count, without copying unknown labels. It is separate from invalid offsets: a valid range can still have an unsupported category. Neither count establishes that all PII was found.

Offsets always index source, never the folded text. Folding is the reason a value written with a Cyrillic а or a fullwidth @ is found at all, and the span still satisfies source[start:end] == text.

python
span.type          # "EMAIL"
span.start         # int, inclusive
span.end           # int, exclusive
span.text          # source[span.start:span.end]
span.score         # float or None
span.detector_id   # "hf.antoniogr7/pii-identifier-v0.1"
span.raw_label     # "EMAIL" or "email"

Span uses a frozen dataclass. Callers cannot edit a span in place.

Presets

NameDetectorExtra
rulesRules onlynone
rules-usExplicit US, cue-required SSN profile plus rulesnone
en-bertBERT-base control plus ruleshf
en-closedModernBERT plus ruleshf
multi-openGLiNER2 plus rulesgliner2
multi-encoderHorizon Small multilingual encoder plus rules; experimentalhf
multi-encoder-baseHorizon Base plus rules; experimentalhf
multi-encoder-usHorizon Small plus explicit US rule profile; experimentalhf
multi-boundaryGLiNER2.5 boundary extractor plus rules; experimentalgliner2
phi-openNVIDIA GLiNER-PII plus rulesgliner
zh-schemaSiamese-UIE plus rulesms

Every preset except a future model-only test flag includes the rule detector. Structured types would otherwise depend on the model alone, which breaks the ladder in the research note.

python
locator = Locator.preset("multi-open", device="cpu")

device is cpu or cuda. The default is cpu. The library does not pick a device from a global config file.

Redact

Starting in edgepii 0.4.0, the opt-in locator.plan(source) and redact_protected(source, plan) retain candidate protection independently of typed overlap selection. Plans reject dropped/unmapped diagnostics by default; this does not certify recall. A secret taints its entire overlap component, and exact known-secret repeats are irreversible. See the API, policies, and measurements. protect_mapping(..., field_types={("password",): "SECRET"}) can protect declared string fields directly and propagates known secrets across leaves. Install or upgrade with pip install --upgrade edgepii==0.5.0; see the release and protection gate guide.

python
from edgepii import redact

masked, vault = redact(source, result.spans)

redact is a pure function. It walks spans from the end of the string toward the start, so earlier indexes stay valid. It writes <TYPE_N> for vaulted types. It writes <SECRET> for class secret, and it does not put the secret in vault.

vault maps the placeholder to the original slice. The caller stores it. The function returns it and keeps nothing.

If two spans overlap, redact raises OverlapError. The locator must not emit overlaps. The error is a guard, not a second merger.

Restore

python
from edgepii import restore

answer = restore(text, vault, locator=None, types=None)

restore is the inbound half of the loop. A model response is new text, so the only way to put the original values back is to find the placeholders the model echoed.

It returns RestoreResult, which is a frozen record with five fields:

FieldMeaning
textThe restored string
resolvedPlaceholders that were found and replaced
unresolvedPlaceholder-shaped tokens still in the text
unknownSpans whose text the vault never held
okTrue only when unresolved and unknown are both empty

A placeholder resolves under case drift (<email_1>) and spacing drift (< EMAIL 1 >, <EMAIL-1>), because a model changes both. The longest token is replaced first, so <EMAIL_10> is never eaten by the <EMAIL_1> pattern.

Pass locator to also scan the raw response for PII the vault never sent. The scan runs before restoration, because restoration inserts real values on purpose.

A secret has no vault entry, so <SECRET> always appears in unresolved. That is the design: restore cannot invent a password.

Mapping

python
from edgepii import mask_mapping, restore_mapping

masked = mask_mapping(args, locator, types=None)
back = restore_mapping(answer, vault, locator=None, types=None)

mask_mapping walks a nested dict or list, scans each string leaf, and returns a copy with the leaves masked. The input is not modified and the structure is unchanged, so a tool still parses it.

MaskedMapping holds four fields:

FieldMeaning
valueThe masked tree
vaultPlaceholder to original, unique across the whole tree
hitsFieldHit rows with a path and the types found. Never a value
droppedCount of hits that failed the slice check

A placeholder carries a leaf index, <EMAIL_1_0> and <EMAIL_1_1>, because two fields of the same type would otherwise both be <EMAIL_1> and restore would write the first value into both.

restore_mapping is the inbound equivalent. It returns RestoredMapping with value, resolved, unresolved, unknown, and ok.

Decode the payload before the scan. A JSON escape such as ada\u0040example.com holds no @, so a scan of the raw text misses it.

Re-identification risk

python
from edgepii import find_combinations, has_combination

rows = find_combinations(source)

A span list cannot express a combination. A postcode, a birth date, and a sex are each ordinary, and together they single out most of a population. find_combinations returns a Combination for each set it recognises, and LocateResult.quasi carries the same rows beside the spans.

FieldMeaning
nameThe combination, such as zip_dob_sex
fieldsThe members that were found
riskhigh or medium
sourceThe rule that justifies the row

A field needs evidence. A numeric postcode requires a cue word, and a date is a birth date only when the text says so. A UK outward code or a Canadian forward sortation area identifies itself.

The finding is advisory. The library does not mask a combination, because the right response depends on policy.

Errors

ErrorWhenMessage contents
TypeErrorsource is not a strType name only
UnknownTypeA requested type is not in the taxonomyThe bad name
DetectorLoadErrorWeights fail to loadRepo id and revision. No source text
OverlapErrorredact sees an overlapStart and end only. No slice text
OffsetErrorInternal. Not raised to the callerThe locator drops the span and counts it

INFO logs can include detector_id, type, start, end, and score. They must not include source or span.text. A debug flag EDGEPII_LOG_TEXT=1 can log text. The default is off.

WARNING: A log line that includes span.text is a copy of PII. Leave the flag off in production.

Batch

python
results = locator.locate_many(sources, types=["EMAIL"])

locate_many preserves order. It does not join strings into one document. Offsets in each result refer to that element only. An empty element returns an empty span list.

Windowing

Callers pass the full source string. The locator can split it inside the model adapter when the string exceeds max_chars for that detector. Public offsets still refer to the original source string. The split rule is in the architecture. Callers do not pass windows.

The HF and GLiNER2 adapters additionally check the actual tokenizer input against the model context, including special tokens and GLiNER2's schema. Each checked window advances and the windows cover the source. If even the schema or one source character cannot fit, inference raises a source-free ValueError. Processing every character does not guarantee recognition of an entity longer than the window overlap. The optional legacy GLiNER and ModelScope adapters have not earned the same token-coverage admission.

What the API refuses

  • A method named contains_pii that returns only a bool. The method is a document flag.
  • A method that returns token ids as the public span.
  • A method that sends source to a URL.
  • A default argument that turns on fuzzy restore.

Minimal types

The implementation uses @dataclass(frozen=True, slots=True) for Span and LocateResult. No dict subclass. No pandas object in the public API. JSON export is result.to_dict(), and it includes text because the contract includes text. Callers who write that dict to a log copy PII. The docstring says so.