Skip to content

perf: O(n log n) entity merge + single-pass pseudonymize - #8

Draft
comppaz wants to merge 20 commits into
mainfrom
perf/merge-and-pseudonymize
Draft

perf: O(n log n) entity merge + single-pass pseudonymize#8
comppaz wants to merge 20 commits into
mainfrom
perf/merge-and-pseudonymize

Conversation

@comppaz

@comppaz comppaz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • EnsembleDetector._merge_entities was an O(n²) nested loop over all entities from all three detectors; it is now an O(n log n) sort+sweep (ddc5cbe). Detection semantics are unchanged: the sweep is property-tested against the old implementation as an in-test oracle (10,000 seeded randomized cases including same-type overlap chains, dual-type overlaps, zero-length spans, and forced score ties), and the individual detectors (presidio/spaCy, Flair, GLiNER) have zero changes on this branch. Additionally verified end-to-end on real model outputs — see the A/B comment below (709 entities, 100 samples, 0 mismatches).
  • PseudonymizationEngine.pseudonymize rebuilt the full string once per entity — O(entities × text length); it is now a single-pass builder, O(n) (0d771a7). Non-overlapping behavior is byte-identical to the old implementation (property-tested against the oracle including full mapper state; pseudonym mint order preserved under the stable sort).
  • Overlapping spans are now fully masked and reversible (5be58d8, superseding an interim clip): see the next section.
  • File reconstruction (DOCX/XLSX redact→reveal) rebuilds its replacement map from the spans the engine actually emitted, via the additive EmittedSpan + pseudonymize_detailed API (c576f7f) — pseudonymize()'s signature is untouched. Two independent guards fail safe to text conversion (masking and reversibility preserved, formatting dropped, warning logged): a pre-apply self-check that the replacement map reproduces the masked text (5e82b7c, 1021ccf), and post-write verification (12d8ff9) — the produced file is re-extracted and its text must equal the masked text, so writer blind spots (hyperlink runs, paragraph-spanning entities, non-string cells) can never ship cleartext with formatting. Formatting is best-effort; masking is not.
  • Merge + person-validation run via asyncio.to_thread above 64 entities (23a8c68). The 64 threshold was calibrated against the old O(n²) merge; post-rewrite a 1000-entity merge is ~0.18 ms, so it is worth retuning once real traffic numbers exist.
  • Benchmarks (benchmark/bench_hot_loops.py, re-measured at HEAD): merge ~53× at 1000 entities; pseudonymize ~8.7× on 200 KB text. Honest caveat: at small inputs the new builder is slower — ~+5 µs per call at 10 entities, ~+30 µs at 100, crossover around 1000 entities / large texts. Absolute costs are microseconds against model inference.
  • Characterization tests for the previous behavior — including the latent dual-type-overlap corruption bug — were committed first (f90114d) and carry the full before/after trail.

Overlap handling

The old splice corrupted output when dual-type spans overlapped (mangled tokens like <<PERSON_1>>ATION_1>>, characterized in f90114d). Overlaps are now handled in one pass:

  • The first-starting span is masked whole; ties at the same start resolve by caller order (stable sort).
  • A later overlapping span that extends past the consumed region gets its remainder masked under its own mapping — the mapping's original text is exactly the remainder slice, so reveal restores the input character for character. Edge whitespace is emitted literally (whitespace is not PII) and whitespace-only remainders mint nothing, so remainder mappings are shaped like detector spans.
  • Fully-consumed overlaps (identical spans — the most common real shape — and enclosed spans) are skipped without minting phantom mappings.
  • No cleartext from any detected span survives in any overlap shape (property-tested with whitespace-bearing overhangs; seeded, non-vacuous).

Reversibility qualifier: the mapper deliberately dedups case-insensitively across all entities (pre-existing behavior). Because minting runs back-to-front, colliding spellings (GMBH/GmbH) reveal as the last document occurrence's spelling, and the placeholder's type label is likewise the last occurrence's — stated in the docs and pinned by test.

Test plan

  • make check — ruff clean, strict mypy clean, 302 passed (2 skipped, 28 slow deselected)
  • 10,000-case seeded property equivalence for the merge vs the old implementation as oracle (tests/test_merge_equivalence.py)
  • 2,500-case seeded property equivalence for pseudonymize on non-overlapping inputs incl. full mapper state, plus whitespace-bearing overlap round-trip/no-cleartext properties (tests/test_pseudonymize_equivalence.py)
  • Characterization tests pinning the old merge quirks and the corruption bug, mutation-tested (two deliberate mutations, both caught)
  • Reconstruction: mixed-document byte-exact for DOCX and XLSX, no-overlap pin, and a 3,000-case seeded randomized sweep — 0 missing, 0 wrong replacements (the sweep was RED against the naive offset arithmetic it replaced: 4,217 missing / 53 wrong)
  • Post-write verification: three real leak classes (hyperlink run, paragraph-spanning entity, numeric cell) reproduced RED with real DOCX/XLSX round-trips, then refused to the text fail-safe; multi-paragraph/table/header/footer non-trigger pins green
  • Self-check guard: known-corruption repro refuses to fail-safe with exactly one warning event; benign self-pair case ("1" inside its own <<ID_1>>) reconstructs byte-exact with zero events
  • to_thread path: equal-results test above/below the 64-entity threshold; 32-way concurrent shared-instance run matches serial results
  • A/B on real detector outputs (all three ML models) confirming recognition results are identical old-vs-new — 709 entities across 100 real German samples, 0 mismatches (see PR comment)

…ehavior

Pin the observable behavior of the two masking hot loops before touching
them, so the upcoming rewrites cannot change semantics unnoticed:

- `_merge_entities`: the (start, -length) sort order, dual-type overlaps
  kept as-is, in-place replacement of the accepted slot (the returned list
  is therefore not sorted by start), chains resolving against whatever
  currently occupies the slot rather than against the union of all losers,
  and zero-length spans never satisfying the overlap test.
- `pseudonymize`: pseudonyms are assigned back-to-front, so the counter
  numbering depends on the descending replacement order. A recording mapper
  pins the exact get_or_create call sequence.
- The latent corruption bug: two overlapping entities of different types
  splice a half-eaten placeholder into the output and mint a phantom
  mapping. The test asserts the mangled string and says so in its docstring
  — it documents a bug, not desired behavior.

No implementation changes.
`_merge_entities` rescanned the whole accepted list for every candidate,
which is O(n²) — noticeable on documents where three detectors each return
hundreds of spans, and it runs on the caller's event loop.

Candidates are already processed in start order, so the accepted spans of
any one type are pairwise disjoint and ordered: once a second span of a type
is accepted, the earlier ones end at or before its start and can never
overlap a later candidate again. Only the most recently accepted span per
type can match, so the rescan collapses into a per-type "open slot" lookup
and the sort dominates.

Zero-length spans are excluded from the open slot: they satisfy neither half
of the overlap test, so they neither get replaced nor shadow the preceding
span of their type. Without that exclusion the sweep diverges from the
original on inputs containing empty spans (4 of the 5 equivalence tests
catch it).

No semantic change. `tests/test_merge_equivalence.py` keeps the pre-0.1.3
implementation as an oracle and asserts exact list equality across 10 000
randomized entity sets — mixed types, single-type overlap chains, dual-type
duplicates, zero-length spans, and identical spans from several sources.
Substitution replaced entities back to front, rebuilding the whole string
once per entity: O(entities × len(text)), and it corrupted overlapping
entities.

Overlaps are not hypothetical — `_merge_entities` deliberately keeps
dual-type annotations (rule 4), so a PERSON and a LOCATION span can cover
the same characters. Replacing the later one first left the earlier one's
offsets pointing into already-substituted text, which spliced a pseudonym
into the middle of another pseudonym ("<<PERSON_1>>ATION_1>>") and minted a
mapping for a value that never reached the output.

The builder now walks the entities in start order once, emitting
text[cursor:start] + pseudonym and joining at the end, and clips: an entity
starting inside the consumed span is skipped whole. Skipped means skipped —
get_or_create is not called for it, so no phantom mappings.

This commit is deliberately self-contained, CHANGELOG entry included, so it
can be dropped on its own if the clip semantics are not wanted; the two
performance commits do not depend on it.

Behavior for non-overlapping entities is byte-identical, pseudonym numbering
included: pseudonyms are still minted back-to-front because the mapper's
counters are order-sensitive and callers persist the mapping.
`tests/test_pseudonymize_equivalence.py` keeps the pre-0.1.3 implementation
as an oracle and compares output plus full mapper state over 2 500
randomized non-overlapping cases.
The library is used in-process by an async gateway, so every millisecond
`detect` spends merging is a millisecond that gateway's event loop is not
serving other requests. Above 64 entities the overlap resolution and the
PERSON false-positive filtering move to a worker thread via
asyncio.to_thread; below it they stay inline, because the thread hop costs
more than the work it offloads.

The two steps are folded into `_merge_and_validate` so the offload is a
single hop. `pseudonymize` stays synchronous — it is O(n) after the
single-pass rewrite.
There is no perf harness in the repo, so this is a plain script rather than
a CI gate: it times the shipped `_merge_entities` and `pseudonymize` against
the pre-0.1.3 implementations on synthetic input and prints a markdown table
for PR descriptions. The legacy code is copied in, mirroring the oracles the
equivalence tests use.

Measured on the development machine (Python 3.12, best of 5):

| loop | entities | text | old (ms) | new (ms) | speedup |
| --- | ---: | ---: | ---: | ---: | ---: |
| _merge_entities |   10 |       - |    0.0021 |    0.0015 |    1.4x |
| _merge_entities |  100 |       - |    0.0790 |    0.0163 |    4.8x |
| _merge_entities | 1000 |       - |    9.8572 |    0.1875 |   52.6x |
| pseudonymize |   10 |     128 |    0.0040 |    0.0046 |    0.9x |
| pseudonymize |  100 |   1,208 |    0.0492 |    0.0404 |    1.2x |
| pseudonymize | 1000 |  12,008 |    0.8649 |    0.4057 |    2.1x |
| pseudonymize | 1000 | 200,000 |    7.1434 |    0.4077 |   17.5x |

The pseudonymize rows carry a text size because the old cost was entities ×
text length: at ten entities on a 128-character string the new builder is
0.6 µs slower (two sorts instead of ten splices of a tiny string), and the
gap only ever moves in its favour from there.
The clip's caveat was described as a lost annotation. It is more than that:
skipping the overlapping entity means the characters of the losing span that
reach beyond the winner's end are emitted verbatim. For PERSON [10, 20)
overlapping LOCATION [15, 25) the output now contains text[20:25] unmasked —
the old back-to-front splice did consume those characters, at the price of
corrupting the placeholder around them.

So the change trades "corrupt but fully masked" for "clean but partially
unmasked" on overlapping spans, which for a redaction engine is the
decision-relevant direction. It is now spelled out in the CHANGELOG, in the
`pseudonymize` docstring, and in the characterization test that asserts the
"KLMNO" residue, so the drop-or-keep call on the clip commit can be made on
the real trade-off.

The CHANGELOG also names the better variant that is deliberately NOT shipped
here: keep the skip for the mapping decision but pseudonymize the remainder
slice text[consumed_to:entity.end] under its own mapping — full coverage,
reversible, still one pass — left out because it exceeds this change's
reviewed scope.

Documentation only: no executable line changed, the engine diff is twelve
added docstring lines.
The docstring claimed zero-length spans "satisfy neither half of the overlap
test". That is false in the candidate direction: a zero-length span strictly
inside the open slot's span — [6, 6) against [5, 10) — satisfies both halves
(6 < 10 and 5 < 6), reaches `_pick_winner`, and with the higher score
replaces the real span. The nested loop did the same, so the sweep is still
equivalent (the 50 %-zero-length property test covers this shape), but the
written safety argument was wrong and the "last non-empty span" comment on
`open_slot` is violated on exactly that path.

The true invariant is one-directional: a zero-length span can never be
overlapped ONCE ACCEPTED, because the overlap test needs a candidate starting
strictly before its point and every later candidate starts at or after it.
That is why it must not become the open slot — otherwise it would shadow the
real span in front of it. As a candidate it is fully live.

`test_merge_char_zero_length_entity_never_overlaps` overclaimed the same
thing while only exercising the equal-start case; renamed to
`..._zero_length_span_at_an_accepted_start_shadows_nothing` and narrowed to
what its fixture proves, with a new test pinning the candidate direction and
the fact that nothing can overlap the zero-length span left in the slot.

Documentation and tests only; no executable line changed in the merge.
The clip tests covered a partial overlap and an enclosed span, but not the
shape that actually dominates in production: two detectors reporting the
IDENTICAL span under different labels. It is the reassuring case and the
maintainer's drop-or-keep call needs it stated, because the residual
cleartext of a clip is text[winner.end:loser.end] — empty when the spans end
together, so an identical-span overlap is fully masked. The test also pins
the tie-break: equal starts mean the stable sort keeps the caller's list
order, so whichever entity was listed first supplies the label.

The test sits in the clip commit's drop radius and says so in its docstring.

Also drops "Say the word and it will be added." from the CHANGELOG's
not-implemented-alternative note; an offer to the reviewer belongs in the PR
description, not in a released changelog. The factual part stays.
@comppaz
comppaz force-pushed the perf/merge-and-pseudonymize branch from 254d824 to 23419b1 Compare August 14, 2026 06:46
@comppaz comppaz changed the title perf: O(n log n) entity merge + single-pass pseudonymize (latency F1, → 0.1.3) perf: O(n log n) entity merge + single-pass pseudonymize Aug 14, 2026
@comppaz

comppaz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Verification: recognition A/B on real model outputs

To confirm the merge rewrite changes nothing about entity recognition beyond the synthetic property proof, an A/B was run on real detector outputs:

Setup. All three production detectors constructed as the noirdoc-cloud gateway does (Presidio + spaCy de_core_news_lg, flair/ner-german-large, knowledgator/gliner-pii-edge-v1.0, score_threshold 0.5). Raw per-detector outputs were memoized so both pipelines saw byte-identical inputs, then fed through (a) the pre-branch tail — score filter → the old O(n²) merge (verbatim) → person validation → sort — and (b) this branch's EnsembleDetector.detect().

Corpus. 100 real German PII samples (ai4privacy/Faker-derived + LLM-generated contract/letter texts).

Result: 709 final entities compared, 0 mismatches. A concatenated 17,346-char composite document (565 filtered entities, to_thread offload confirmed triggered) also matched exactly: 310 = 310.

Overlap census on the same corpus (relevant to the overlap-handling discussion above): 96/100 samples contained overlapping (dual-type) entities — 170 overlapping pairs total — but only 1 pair was leak-shaped (loser extending beyond the winner's end): PERSON [280,296) "Erwin Junken MBA" vs ORGANIZATION [280,297), leaked fragment = a single trailing ".". Identical-span and enclosed overlaps (which leak nothing) account for the other 169.

comppaz added 11 commits August 14, 2026 09:06
Clipping an overlap dropped the losing entity whole, which kept the
output clean but emitted the part of that entity reaching past the
winner in cleartext: with PERSON [10, 20) overlapping LOCATION
[15, 25), text[20:25] survived verbatim.

The single forward pass now masks overlaps in pieces. The span that
starts first is replaced whole; an entity reaching past it contributes
its remainder, text[consumed_to:entity.end], replaced by its own
pseudonym under its own entity type. An entity lying entirely inside
the consumed span still has no remainder and is skipped whole, mapping
included, so identical and enclosed spans mint exactly one pseudonym as
before. Chains compose: A [0, 10), B [5, 15), C [8, 20) mask as A whole
plus the remainders [10, 15) and [15, 20).

A remainder's mapping stores the slice it replaced, not the span it came
from, so reidentification restores the input character for character.
Remainders are minted in the slot their entity would have had, keeping
the back-to-front numbering callers persist. Non-overlapping entities
take the same path as before, byte for byte.

Also renames the mint loop's shadowing lambda parameter to idx.
Replaces the residual-cleartext caveat: overlapping spans are now masked
whole plus remainder, each piece reversible under its own mapping, which
supersedes the interim clip behaviour described in the same entry.
…d spans

Masking overlap remainders added a placeholder that nothing outside the
builder could predict, and DOCX/XLSX reconstruction was predicting.
_build_replacements paired entities with placeholders by walking the
entity list and accumulating an offset shift for each entity whose
shifted start happened to land on a "<<". The remainder's placeholder is
invisible to that arithmetic, so from the first overlap onwards every
later entity was read at the wrong offset: its replacement was dropped
(the original stayed in the reconstructed file) or paired with another
entity's placeholder (a reveal would then restore the wrong value). On a
mixed document with one overlapping pair, "Hamburg" was dropped; a
3 000-case randomized sweep found 4 217 missing and 53 wrong pairings,
and 1 556 documents whose replacement map did not reproduce the
pseudonymized text.

The pairing now comes from the substitution that created it.
PseudonymizationEngine.pseudonymize_detailed returns the text plus one
EmittedSpan per placeholder — original text, placeholder, position in
the output; pseudonymize() is unchanged and delegates to it.
pseudonymize_block records both on the FileBlock, and the sdk and
pipeline call sites go through it. Replacements are applied longest
original first, so a short original cannot eat a longer one containing
it before that one is replaced ("Weber" inside "Anna Weber"). A block
carrying entities but no record of the substitution is refused instead
of rewritten from a guess; callers already fall back to converted text.

Remainders are also trimmed before minting: leading and trailing
whitespace is emitted as literal text and only the core is
pseudonymized, and a whitespace-only remainder mints nothing.
Remainders cut at arbitrary offsets, so a slice like " GmbH" would key
the mapper at "gmbh" while storing the leading space, colliding with a
real "GmbH" elsewhere and revealing with the wrong spacing. Trimmed
remainders are shaped like detector spans, which never carry edge
whitespace, so that collision class is unreachable. The mapper's
designed case-insensitive dedup remains and is now pinned by a test.
Adds the DOCX/XLSX replacement regression under Fixed, the additive
pseudonymize_detailed / EmittedSpan entry under Added, and the remainder
trimming. Qualifies the reversibility claim: exact, up to the mapper's
designed case-insensitive dedup, which predates overlap handling and
applies to every entity.
…ce the masked text

Reconstruction rewrites a DOCX or XLSX by text, not by offset, so an
original drawn from the placeholder charset can land inside a placeholder
that was already inserted. An ID "12" in a document with twelve people
turns <<PERSON_12>> into <<PERSON_<<ID_1>>>>: still masked, but no
reverse mapping can undo it, and nothing noticed.

The map is now checked before it is used. An original that is a
substring of any placeholder is rejected outright — that is the direct
signature of the hazard and gets its own log line — and applying the map
to the extracted text must reproduce the pseudonymized text. On failure
_build_replacements returns None, so the caller takes the fallback it
already has and converts the file to text: masking and reveal both
survive, only the formatting is lost. Neither log line carries any of
the values involved.

The randomized sweep cannot see this class: its alphabet is deliberately
disjoint from the placeholder charset so that substring assertions stay
decidable. The two cases here cover it instead — the repro, and an
ordinary document pinning that the guard does not fire.
The pre-check refused any original that occurred inside any placeholder,
which includes an original inside its OWN placeholder: an ID "1" mints
<<ID_1>>, "1" is in it, and an otherwise ordinary document lost its
formatting to the text fallback for nothing. Self-pairing cannot corrupt
anything — str.replace makes one left-to-right pass and never rescans
what it inserted, and a shorter original's placeholder is not in the
text yet while the longer originals are applied.

The self-check that follows it is necessary and sufficient on its own:
if replacement lands anywhere it should not, the rebuilt text stops
matching what the pseudonymizer produced. That covers the "12" inside
<<PERSON_12>> case and equally an original replaced at an occurrence the
detector never flagged. One check now, one event.
Reconstruction rewrites paragraph runs and string cells. A document has
text that lives in neither, and the extracted text does not say where in
the file its characters came from, so a replacement map can be perfect
and the write still incomplete. Three shapes shipped the original next
to its own placeholder, with every existing check passing:

  hyperlink run   masked 'Kontakt: <<PERSON_1>>'
                  shipped 'Kontakt: <<PERSON_1>>Anna Beispiel'
  paragraph span  masked 'Rechnung von <<ORGANIZATION_1>> in Hamburg'
                  shipped 'Rechnung von Muster\nGmbH & Co in Hamburg'
  numeric cell    masked '<<ID_1>>\n<<PERSON_1>>'
                  shipped '480815\n<<PERSON_1>>'

python-docx counts hyperlink text in Paragraph.text but leaves it out of
Paragraph.runs; the extractor joins paragraphs with a newline, so a
detected span can match the flat text and no paragraph; the XLSX writer
only touches string cells while the extractor stringifies numbers too.

The bytes that were written are now extracted again with the same
extractor and must yield the masked text. On a mismatch the file is
refused and the caller converts it to text, as it already does for the
other refusals: formatting is lost, masking is not. The pre-apply check
on the map stays as a cheap early-out — it catches a map that would
over-reach before any file is parsed — but it no longer claims to cover
what the writers can reach.

A span with an empty original is left out of the map: it replaces no
characters, and str.replace("", …) would splice its placeholder between
every character in the document. A placeholder that replaces nothing has
nothing to anchor it in the file either, so the comparison ignores it —
no PII rides on a mapping whose original is the empty string.

Bytes that come out of reconstruct() are kept on the block, and the
pipeline now counts a file as reconstructed only if there are bytes. A
refused rebuild goes out as text and is counted as a conversion, which
is what the caller actually shipped.
emitted_spans was inserted between pseudonymized_text and
reconstructed_bytes, which rebinds the arguments of anyone constructing
a FileBlock positionally. Moved to the end, with a note that new fields
belong there.
Two claims in the CHANGELOG and the engine docstring said colliding
spellings reveal as the one "minted first", glossed as "GMBH after GmbH
reads back as GmbH". Minting runs back to front, so the occurrence that
mints first is the LAST one in the document — which is what the pinned
test asserts. Both surfaces now say that, add the same for the type
label (the mapper's key ignores the entity type), and state the
guarantee as character-exact reveal for identical spellings.

The substitution row of the perf table was measured before remainder
masking and the emitted-span record added per-entity work, and it only
advertised the win. Re-measured at HEAD with benchmark/bench_hot_loops.py
and given as a table: the 200 KB case is 8.7x rather than 17.5x, and
below roughly a thousand entities the new path is slower than 0.1.2 —
about 2x at ten entities, single-digit microseconds in absolute terms.

Also qualifies "every character any entity covers is replaced", which
the whitespace trimming two paragraphs above already contradicted.
Only the nested sdd/.gitignore kept review artifacts out of this public
repo; a file added anywhere else under .superpowers/ would be swept up
by `git add -A`.
Four places said more than the code does.

The README promised DOCX and XLSX "round-trip fully". They round-trip
their formatting only when the redacted file verifies; a mailto: in a
signature block is enough to send a document out as masked plain text
instead. The format table and the caveat now say that, in one line:
formatting is best-effort, masking is not.

The CHANGELOG called the small-input slowdown "single-digit
microseconds", which is the ten-entity row only — the hundred-entity row
in the same table is about +30 µs. Both are named now, with the
crossover.

The reconstruction guarantee was stated unconditionally, but it covers
what this module writes: the column-aware XLSX path produces its own
bytes and reconstruct() hands them back through the short-circuit
without verifying them. That is now said in the module docstring and
carried into the CHANGELOG as a named follow-up, along with the fact
that the generic writer's numeric-cell gap was reachable by calling that
writer directly rather than through the SDK or the pipeline.

Plus two notes: the early-out runs before the rewrite and the re-parse,
not before any parse, and reconstruct() caches its bytes on the block,
so editing the masked text afterwards does not rebuild anything.
@comppaz

comppaz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the overlap census in the A/B comment above. The census's replica of the sequential overlap walk did not advance consumed_to through 3+-entity overlap chains, so its "170 overlapping pairs / 1 leak-shaped" figures are a 2-deep simplification — a lower bound, not an exact match to the engine's behavior on deeper chains. The recognition-equivalence result (709 entities, 0 mismatches) is unaffected: it compared final entity lists directly and made no use of the census logic. The census is also moot for current behavior: overlap remainders are now masked under their own mappings (see the Overlap handling section), so no overlap shape leaks regardless of chain depth — that property is enforced by the seeded round-trip/no-cleartext property tests rather than by corpus statistics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant