Skip to content

Commit 12ea2d4

Browse files
authored
Merge pull request #165 from DataFog/fix/redact-token-document-order
fix: number redaction tokens in document order
2 parents 3691dbc + 3be0fcd commit 12ea2d4

3 files changed

Lines changed: 202 additions & 6 deletions

File tree

CHANGELOG.MD

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
# ChangeLog
22

3+
## [Unreleased]
4+
5+
### `datafog-python`
6+
7+
#### Fixed
8+
9+
- **Redaction token numbering now follows document order**: `redact()`
10+
previously assigned per-type counters while replacing spans right-to-left,
11+
so with multiple entities of the same type the *last* occurrence received
12+
`_1` (two emails redacted as `[EMAIL_2] ... [EMAIL_1]`). Tokens for the
13+
`token` and `pseudonymize` strategies are now numbered left-to-right, so
14+
the first occurrence is always `_1`. This changes redacted output text for
15+
multi-entity inputs; `RedactResult.entities` is now also ordered by
16+
document position (ascending) instead of descending. Replacement values
17+
in `mapping` are now always read from the original input text, fixing a
18+
case where an already-inserted token could leak into a mapping value when
19+
entity spans overlapped.
20+
- **`redact()` now resolves overlapping and duplicate entity spans**:
21+
`redact()` is public API and accepts arbitrary entity lists, but applied
22+
every span verbatim — overlapping spans (e.g. a phone number and a
23+
sub-span of it) corrupted the output with stray fragments like
24+
`[PHONE_1]]`. For each group of overlapping spans only one is now
25+
redacted: the longest, breaking ties by entity type priority and then
26+
confidence — the same suppression rule the regex scan pipeline already
27+
used. Suppressed spans are omitted from `RedactResult.entities` and
28+
`mapping`. Output for non-overlapping entity lists (including everything
29+
produced by `scan()`) is unchanged.
30+
- **`mask` strategy mapping no longer collides on same-length values**:
31+
the mask replacement (`***…`) previously keyed `mapping` directly, so two
32+
distinct values of the same length produced the same key and only one
33+
original survived. Mask mappings are now keyed by per-type indexed keys
34+
in document order (`[EMAIL_MASK_1]`, `[EMAIL_MASK_2]`), preserving every
35+
original value. Consumers that looked up mask mappings by the mask string
36+
must switch to the indexed keys; `token`, `hash`, and `pseudonymize`
37+
mapping keys are unchanged.
38+
339
## [2026-07-02]
440

541
### `datafog-python` [4.7.0]

datafog/engine.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def _suppress_overlapping_entities(entities: list[Entity]) -> list[Entity]:
173173
key=lambda item: (
174174
-_entity_length(item),
175175
-ENTITY_TYPE_PRIORITY.get(item.type, 0),
176+
-item.confidence,
176177
item.start,
177178
item.end,
178179
item.type,
@@ -465,7 +466,18 @@ def redact(
465466
entities: list[Entity],
466467
strategy: str = "token",
467468
) -> RedactResult:
468-
"""Redact PII entities from text."""
469+
"""Redact PII entities from text.
470+
471+
Callers may pass arbitrary entity lists (not just ``scan()`` output), so
472+
overlapping or duplicate spans are resolved here: for each overlapping
473+
group only one span is redacted — the longest, breaking ties by entity
474+
type priority and then confidence. Suppressed spans are omitted from
475+
``RedactResult.entities`` and ``mapping``.
476+
477+
``mapping`` for the ``mask`` strategy is keyed by per-type indexed keys
478+
(``[EMAIL_MASK_1]``) rather than the mask string, because distinct
479+
same-length values produce identical masks and would collide.
480+
"""
469481
if not isinstance(text, str):
470482
raise TypeError("text must be a string")
471483
if strategy not in {"token", "mask", "hash", "pseudonymize"}:
@@ -481,14 +493,22 @@ def redact(
481493
for entity in entities
482494
if 0 <= entity.start < entity.end <= len(text) and entity.text
483495
]
484-
valid_entities = sorted(
485-
valid_entities, key=lambda e: (e.start, e.end), reverse=True
486-
)
496+
# Returns non-overlapping spans sorted by document position.
497+
valid_entities = _suppress_overlapping_entities(valid_entities)
487498

499+
# Assign replacements in document order so the per-type counters used by
500+
# the token/pseudonymize strategies number the first occurrence _1; spans
501+
# are applied right-to-left below so earlier offsets stay valid.
502+
replacements: list[tuple[Entity, str]] = []
488503
for entity in valid_entities:
489-
original = redacted_text[entity.start : entity.end]
504+
original = text[entity.start : entity.end]
505+
mapping_key: Optional[str] = None
490506
if strategy == "mask":
491507
replacement = "*" * max(len(original), 1)
508+
# Distinct same-length values share a mask, so the mask string
509+
# cannot key the mapping without collisions.
510+
counters[entity.type] = counters.get(entity.type, 0) + 1
511+
mapping_key = f"[{entity.type}_MASK_{counters[entity.type]}]"
492512
elif strategy == "hash":
493513
digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:12]
494514
replacement = f"[{entity.type}_{digest}]"
@@ -504,10 +524,13 @@ def redact(
504524
counters[entity.type] = counters.get(entity.type, 0) + 1
505525
replacement = f"[{entity.type}_{counters[entity.type]}]"
506526

527+
replacements.append((entity, replacement))
528+
mapping[mapping_key if mapping_key is not None else replacement] = original
529+
530+
for entity, replacement in reversed(replacements):
507531
redacted_text = (
508532
redacted_text[: entity.start] + replacement + redacted_text[entity.end :]
509533
)
510-
mapping[replacement] = original
511534

512535
return RedactResult(
513536
redacted_text=redacted_text,

tests/test_engine_api.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,143 @@ def test_redact_strategies(strategy: str) -> None:
5656
assert result.mapping
5757

5858

59+
def _entity_at(text: str, value: str, entity_type: str = "EMAIL") -> Entity:
60+
start = text.index(value)
61+
return Entity(
62+
type=entity_type,
63+
text=value,
64+
start=start,
65+
end=start + len(value),
66+
confidence=1.0,
67+
engine="regex",
68+
)
69+
70+
71+
def test_redact_token_numbering_follows_document_order() -> None:
72+
text = "first alpha@example.com then beta@example.com end"
73+
entities = [
74+
_entity_at(text, "alpha@example.com"),
75+
_entity_at(text, "beta@example.com"),
76+
]
77+
78+
result = redact(text=text, entities=entities, strategy="token")
79+
80+
assert result.redacted_text == "first [EMAIL_1] then [EMAIL_2] end"
81+
assert result.mapping == {
82+
"[EMAIL_1]": "alpha@example.com",
83+
"[EMAIL_2]": "beta@example.com",
84+
}
85+
86+
87+
def test_redact_pseudonymize_numbering_follows_document_order() -> None:
88+
text = "first alpha@example.com then beta@example.com end"
89+
entities = [
90+
_entity_at(text, "alpha@example.com"),
91+
_entity_at(text, "beta@example.com"),
92+
]
93+
94+
result = redact(text=text, entities=entities, strategy="pseudonymize")
95+
96+
assert result.redacted_text == "first [EMAIL_PSEUDO_1] then [EMAIL_PSEUDO_2] end"
97+
assert result.mapping == {
98+
"[EMAIL_PSEUDO_1]": "alpha@example.com",
99+
"[EMAIL_PSEUDO_2]": "beta@example.com",
100+
}
101+
102+
103+
def _span(
104+
text: str,
105+
start: int,
106+
end: int,
107+
entity_type: str = "PHONE",
108+
confidence: float = 1.0,
109+
engine: str = "regex",
110+
) -> Entity:
111+
return Entity(
112+
type=entity_type,
113+
text=text[start:end],
114+
start=start,
115+
end=end,
116+
confidence=confidence,
117+
engine=engine,
118+
)
119+
120+
121+
@pytest.mark.parametrize("strategy", ["token", "mask", "hash", "pseudonymize"])
122+
def test_redact_overlapping_spans_match_longest_only(strategy: str) -> None:
123+
text = "call 555-000-1111 now"
124+
full = _span(text, 5, 17)
125+
suffix = _span(text, 9, 17)
126+
127+
result = redact(text=text, entities=[full, suffix], strategy=strategy)
128+
expected = redact(text=text, entities=[full], strategy=strategy)
129+
130+
assert result.redacted_text == expected.redacted_text
131+
assert result.mapping == expected.mapping
132+
assert result.entities == [full]
133+
134+
135+
@pytest.mark.parametrize("strategy", ["token", "mask", "hash", "pseudonymize"])
136+
def test_redact_nested_spans_keep_outer(strategy: str) -> None:
137+
text = "mail alice@example.com today"
138+
outer = _span(text, 5, 22, entity_type="EMAIL")
139+
inner = _span(text, 11, 18, entity_type="EMAIL")
140+
141+
result = redact(text=text, entities=[inner, outer], strategy=strategy)
142+
expected = redact(text=text, entities=[outer], strategy=strategy)
143+
144+
assert result.redacted_text == expected.redacted_text
145+
assert result.mapping == expected.mapping
146+
assert result.entities == [outer]
147+
148+
149+
def test_redact_overlapping_spans_produce_clean_token_output() -> None:
150+
text = "call 555-000-1111 now"
151+
entities = [_span(text, 5, 17), _span(text, 9, 17)]
152+
153+
result = redact(text=text, entities=entities, strategy="token")
154+
155+
assert result.redacted_text == "call [PHONE_1] now"
156+
assert result.mapping == {"[PHONE_1]": text[5:17]}
157+
158+
159+
def test_redact_duplicate_spans_applied_once() -> None:
160+
text = "mail alice@example.com today"
161+
entity = _entity_at(text, "alice@example.com")
162+
163+
result = redact(text=text, entities=[entity, entity], strategy="token")
164+
165+
assert result.redacted_text == "mail [EMAIL_1] today"
166+
assert result.entities == [entity]
167+
168+
169+
def test_redact_overlap_tiebreak_prefers_higher_confidence() -> None:
170+
text = "Acme Corporation announced"
171+
org = _span(text, 0, 16, entity_type="ORGANIZATION", confidence=0.7, engine="spacy")
172+
person = _span(text, 0, 16, entity_type="PERSON", confidence=0.9, engine="gliner")
173+
174+
result = redact(text=text, entities=[org, person], strategy="token")
175+
176+
assert result.redacted_text == "[PERSON_1] announced"
177+
assert result.entities == [person]
178+
179+
180+
def test_redact_mask_mapping_distinguishes_same_length_values() -> None:
181+
text = "a alice@example.com b bobby@example.com c"
182+
entities = [
183+
_entity_at(text, "alice@example.com"),
184+
_entity_at(text, "bobby@example.com"),
185+
]
186+
187+
result = redact(text=text, entities=entities, strategy="mask")
188+
189+
assert result.redacted_text == "a " + "*" * 17 + " b " + "*" * 17 + " c"
190+
assert result.mapping == {
191+
"[EMAIL_MASK_1]": "alice@example.com",
192+
"[EMAIL_MASK_2]": "bobby@example.com",
193+
}
194+
195+
59196
def test_redact_invalid_strategy_raises_value_error() -> None:
60197
with pytest.raises(ValueError, match="strategy must be one of"):
61198
redact("test", entities=[], strategy="invalid")

0 commit comments

Comments
 (0)