Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,51 @@ stability commitment. See [ROADMAP.md](ROADMAP.md) for planned milestones.

## [Unreleased]

## [0.14.1] — 2026-10-08

Patch release: fewer PII false positives on numbers, codes and non-English
text, on the three PII engines; the redteam scorecard gives the counts
behind each recall. Upgrading is recommended.

### Fixed

- The Rust PII scanner (`admina-core`) reports a card number only when it
passes the Luhn check, as the Python redactor does: a 16-digit order
number that fails the check is no longer masked as `CREDIT_CARD`.
- The Rust PII scanner matches the phone formats of the Python redactor
(North American numbers, and Italian mobile and landline numbers with or
without +39 or 0039, with the same boundaries). It reported an ISBN, a
build number and other digit runs as `PHONE`.
- The PII redactor, the `spacy-regex` engine and the Presidio engine mask a
`PERSON` entity of the NER model only when it reads as a name: no digit,
no character of an e-mail address or a path (`@ / \ _ # : =`), and no
lowercase word that is a stop word of English, Italian, German, French,
Spanish or Portuguese (spaCy's lists, without the words that are also
first names: "will", "may", "sara", "mia", "ali"). The English spaCy
model labelled phrases such as "il codice articolo", "ci vediamo
domani" or "das Wetter" as `PERSON`. Names written in lowercase ("mario
rossi") are still masked. With Presidio, such a span no longer takes
over an overlapping entity of another type.

### Changed

- `admina redteam --format md`: each cell gives the recall with the
detected and expected counts (`89% (8/9)`), and a note says that the
figures are indicative (a class often has one to three samples) and that
the labels of the corpora are assigned in this repository.
- The PII corpus has eight hard negatives (`hard_negative`): numbers and
codes that are not personal data. The baseline records the false
positives they produce: the Rust scanner reports a number that fails the
Luhn check as `CREDIT_CARD` and an ISBN and a build number as `PHONE`;
spaCy and Presidio report `PERSON` on two Italian sentences. PII false
positives: Python 8/24, Rust 3/24, Presidio 12/24 (6/16, 0/16 and 9/16
before).
codes that are not personal data. The baseline is regenerated with the
fixes above. PII false positives: Python 3/24, Rust 0/24, Presidio 9/24
(6/16, 0/16 and 9/16 in 0.14.0); Presidio type-level recall 59% (52% in
0.14.0). The remaining false positives are listed in MODEL_CARD §9.

### Notes

- `tests/test_pipeline_executor.py`: the event-loop tests run a 1 s job and
accept a stall shorter than the job. A loop blocked by the job stalls for
at least its whole duration, so the tests still fail for it, and the
scheduling delays of loaded CI runners (up to 0.51 s on macOS) no longer
stop the release test gate.

## [0.14.0] — 2026-10-08

Expand Down Expand Up @@ -2280,7 +2312,8 @@ environment in `docker-compose.benchmark.yml`.

---

[Unreleased]: https://github.com/admina-org/admina/compare/v0.14.0...HEAD
[Unreleased]: https://github.com/admina-org/admina/compare/v0.14.1...HEAD
[0.14.1]: https://github.com/admina-org/admina/compare/v0.14.0...v0.14.1
[0.14.0]: https://github.com/admina-org/admina/compare/v0.13.2...v0.14.0
[0.13.2]: https://github.com/admina-org/admina/compare/v0.13.1...v0.13.2
[0.13.1]: https://github.com/admina-org/admina/compare/v0.13.0...v0.13.1
Expand Down
39 changes: 24 additions & 15 deletions MODEL_CARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ All Rust components are pure functions exposed via PyO3. Rust is faster,
but the two engines are not behaviorally equivalent: on an internal
14-attack evasion corpus the Python firewall blocks all 14 while the Rust
firewall blocks 7 (plain-text and single-encoding attacks only). The Rust
PII engine also lacks EU national-ID patterns, spaCy NER, and Luhn
validation. Python is the higher-recall engine; Rust is opt-in for
PII engine also lacks EU national-ID patterns and spaCy NER (it checks the
Luhn sum of card numbers since 0.14.1). Python is the higher-recall engine; Rust is opt-in for
latency-sensitive workloads where the narrower coverage is acceptable.

Opt-in means the `[rust]` extra of a `pip install`. The official proxy
Expand Down Expand Up @@ -263,7 +263,7 @@ optionally mirrored into the Rust accelerator at
Detects and redacts PII in text. Three modes:

- **Regex-only** (default, fast): email, phone, SSN, US credit card
(Luhn-validated — Python engine only; Rust path does not run Luhn),
(Luhn-validated on both engines; the Rust engine since 0.14.1),
IBAN, IPv4, Italian codice fiscale, Spanish DNI/NIE, and German
Personalausweis (shipped but **disabled by default** — the format is
too ambiguous to regex safely). Python engine default; Rust path
Expand All @@ -290,10 +290,12 @@ Detects and redacts PII in text. Three modes:
models it needs. Note that on Admina's own corpus Presidio measures
*lower* type-level recall than the default spaCy+regex engine on
EU identifiers — see §9.
- **Regex precision varies by category.** Phone-number regex has high
recall but low precision (matches version strings, IDs). Credit-card
regex uses Luhn validation (Python engine) and is reliable. IBAN regex does not
validate the country-specific checksum and may match invalid IBANs.
- **Regex precision varies by category.** Phone numbers are matched in the
North American and Italian formats only, so other national formats are
missed. Card numbers are reported only when they pass the Luhn check (both
engines). The Python engine checks the length and the mod-97 checksum of
an IBAN; the Rust engine matches the IBAN shape only and may report an
invalid IBAN.
- **No image or document parsing.** Admina sees text only. PII embedded
in images, PDFs, or audio passes through unchanged. Pre-process those
upstream.
Expand Down Expand Up @@ -1114,12 +1116,12 @@ mode (the mode pinned in the baseline):
| Detector | Python recall | Rust recall | False positives (py · rust) |
|-----------|:---:|:---:|:---:|
| injection | 57% (sample-level) | 35% | 0/27 · 0/27 |
| pii | 100% (type-level, nlp) | 66% (type-level) | 8/24 · 3/24 |
| pii | 100% (type-level, nlp) | 66% (type-level) | 3/24 · 0/24 |
| loop | 82% (sample-level) | 91% | 0/11 · 0/11 |

The optional Presidio PII engine is measured as a third row in the same
baseline (`admina/redteam/baselines/baseline.json`): **52%** type-level
recall with **12/24** false positives, pinned to mode
baseline (`admina/redteam/baselines/baseline.json`): **59%** type-level
recall with **9/24** false positives, pinned to mode
`presidio:2.2.363/en+it`. It is an alternative engine, not an
accelerator, so it is reported separately rather than in the
Python-vs-Rust matrix above.
Expand All @@ -1135,11 +1137,18 @@ mis-firing `PERSON`/`ORG` on non-English negative samples — which is also why
the PII baseline pins the NER mode. The PII negatives include eight hard
negatives (`hard_negative`: an order number that fails the Luhn check, a
version, an invoice number and a date, an ISBN, a build number, room and
chapter numbers, two Italian reference codes): the Rust scanner reports the
non-Luhn number as `CREDIT_CARD` and the ISBN and the build number as
`PHONE`; spaCy and Presidio report `PERSON` on the two Italian sentences. These measured gaps are consistent with §1:
the two engines are **not** behaviorally equivalent — Python is the
higher-recall default, Rust the narrower-coverage opt-in.
chapter numbers, two Italian reference codes). Since 0.14.1 the Rust scanner
checks the Luhn sum of card numbers and matches the phone formats of the
Python redactor, and the three engines mask a `PERSON` entity only when it
has no digit, no character of an e-mail address or a path, and no lowercase
stop word of English, Italian, German, French, Spanish or Portuguese (names
written in lowercase are masked): the remaining false positives are
`ORG`/`GPE` on non-English sentences, `PERSON` on a capitalised German noun
("Büro"), and, for Presidio, `PERSON` on "week" and "Friday" and `PHONE` on
an Italian reference number. These measured gaps
are consistent with §1: the two engines are **not** behaviorally
equivalent — Python is the higher-recall default, Rust the
narrower-coverage opt-in.

This replaces the previous "no accuracy benchmark suite" gap. Contributions
extending the corpora (more languages, larger adversarial sets, `garak` /
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ not on a calendar. Scope may shift in response to user feedback, security
findings, or upstream changes in the governance landscape (EU AI Act
implementing acts, new frameworks, new attack classes).

The current release is **0.14.0**. Admina is pre-1.0: the public API may
The current release is **0.14.1**. Admina is pre-1.0: the public API may
still evolve before the 1.0 stability commitment, so a minor release may
carry a declared breaking change once its replacement is in place. Shipped
detail lives in [CHANGELOG.md](CHANGELOG.md).
Expand Down
2 changes: 1 addition & 1 deletion admina/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from admina.sdk import ComplianceKit, GovernedAgent, GovernedData, GovernedModel

__version__ = "0.14.0"
__version__ = "0.14.1"

__all__ = [
"__version__",
Expand Down
70 changes: 70 additions & 0 deletions admina/domains/data_sovereignty/names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""Whether a PERSON entity of an NER model reads as a name.

The PII engines mask a PERSON entity only when :func:`is_name_like` holds:
no digit or character that a name does not hold (``@ / \\ _ # : =``, as in
an e-mail address or a path), and no lowercase word that is a stop word of English, Italian,
German, French, Spanish or Portuguese (the stop-word lists of spaCy, without
the words that are also first names, such as "will", "may" and "sara"). The
English spaCy model labels phrases as PERSON ("il codice articolo", "ci
vediamo domani", "das Wetter", "grab a coffee"); they hold such words.
Names written in lowercase pass ("mario rossi"), and capitalised words are
not checked ("Sara", "Il Signore").
"""

from __future__ import annotations

import functools
import importlib

# The stop-word lists of spaCy whose lowercase words reject a PERSON span.
_STOP_WORD_LANGUAGES = ("en", "it", "de", "fr", "es", "pt")

# Characters of e-mail addresses, paths and identifiers, not of names.
_NOT_IN_NAMES = frozenset("@/\\_#:=")

# Stripped from both ends of a word before the comparison.
_PUNCTUATION = ".,;:!?\"'()[]«»“”‘’"

# Words of those lists that are also first names.
_FIRST_NAMES = frozenset({"ali", "may", "mia", "sara", "will"})


@functools.cache
def _stop_words() -> frozenset[str]:
"""The stop words of :data:`_STOP_WORD_LANGUAGES`, without
:data:`_FIRST_NAMES`; empty without spaCy (no NER runs then)."""
words: set[str] = set()
for lang in _STOP_WORD_LANGUAGES:
try:
module = importlib.import_module(f"spacy.lang.{lang}.stop_words")
except ImportError:
return frozenset()
words |= module.STOP_WORDS
return frozenset(words - _FIRST_NAMES)


def is_name_like(text: str) -> bool:
"""True when *text* has a word, no digit nor character of
:data:`_NOT_IN_NAMES`, and no lowercase word that is a stop word of
English, Italian, German, French, Spanish or Portuguese."""
words = text.split()
if not words or any(ch.isdigit() or ch in _NOT_IN_NAMES for ch in text):
return False
stop = _stop_words()
bare = (word.strip(_PUNCTUATION) for word in words)
return not any(word in stop for word in bare if word.islower())
5 changes: 5 additions & 0 deletions admina/domains/data_sovereignty/pii.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
placeholder_spans,
replace_spans,
)
from admina.domains.data_sovereignty.names import is_name_like

# spaCy is part of the [nlp] extra. When absent, PIIRedactor falls back
# to regex-only mode (still covers EMAIL/PHONE/SSN/IBAN/IP/credit-card/EU IDs).
Expand Down Expand Up @@ -301,6 +302,10 @@ def redact(self, text: str, categories: dict | None = None) -> dict:
cat_config = active_categories.get(ent.label_, {})
if not cat_config.get("enabled", False):
continue
if ent.label_ == "PERSON" and not is_name_like(
redacted[ent.start_char : ent.end_char]
):
continue
mask = self._mask(ent.label_, cat_config)
parts = outside_placeholders(ent.start_char, ent.end_char, placeholders, redacted)
for start, end in reversed(parts):
Expand Down
3 changes: 3 additions & 0 deletions admina/engines/presidio.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
placeholder_spans,
replace_spans,
)
from admina.domains.data_sovereignty.names import is_name_like
from admina.domains.data_sovereignty.pii import PII_CATEGORIES

logger = logging.getLogger("admina.engines.presidio")
Expand Down Expand Up @@ -272,6 +273,8 @@ def redact(self, text: str) -> dict[str, Any]:
cfg = PII_CATEGORIES.get(category, {})
if not cfg.get("enabled", False):
continue
if category == "PERSON" and not is_name_like(text[r.start : r.end]):
continue
mask = OMISSIS if self.mask_style == "omissis" else cfg.get("mask", f"[{category}]")
for start, end in outside_placeholders(r.start, r.end, placeholders, text):
spans.append((start, end, category, mask))
Expand Down
3 changes: 3 additions & 0 deletions admina/plugins/builtin/pii/spacy_regex.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from admina.domains.data_sovereignty.email_matching import EMAIL_RX, iter_email_matches
from admina.domains.data_sovereignty.masking import replace_spans
from admina.domains.data_sovereignty.names import is_name_like
from admina.plugins.base import BasePIIEngine

logger = logging.getLogger("admina.plugins.pii.spacy_regex")
Expand Down Expand Up @@ -125,6 +126,8 @@ async def detect(
continue
if allowed and ent.label_ not in allowed:
continue
if ent.label_ == "PERSON" and not is_name_like(ent.text):
continue
# Skip if already covered by a regex match (full interval-overlap test)
overlap = any(
not (ent.end_char <= mm["start"] or ent.start_char >= mm["end"])
Expand Down
8 changes: 4 additions & 4 deletions admina/redteam/baselines/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@
"pii": {
"python": {
"type_recall": 1.0,
"fp": 8,
"fp": 3,
"fp_samples": 24,
"mode": "nlp:en_core_web_sm@3.8.0"
},
"rust": {
"type_recall": 0.6551724137931034,
"fp": 3,
"fp": 0,
"fp_samples": 24
},
"presidio": {
"type_recall": 0.5172413793103449,
"fp": 12,
"type_recall": 0.5862068965517241,
"fp": 9,
"fp_samples": 24,
"mode": "presidio:2.2.363/en+it"
}
Expand Down
2 changes: 1 addition & 1 deletion core-rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core-rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

[package]
name = "admina_core"
version = "0.14.0"
version = "0.14.1"
edition = "2021"
description = "Admina governance engines — Rust core with Python bindings"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion core-rust/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ build-backend = "maturin"

[project]
name = "admina-core"
version = "0.14.0"
version = "0.14.1"
description = "Admina governance engines — Rust core with Python bindings (firewall, PII, loop breaker, hash chain)"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
Loading
Loading