Changelog¶
0.10.0 - 2026-11-11¶
Users can opt into one privacy policy across format matches and model findings. Caller-declared fields add explicit protection without guessing field meanings. Encrypted local references keep reversible maps separate from cleaned output. Document extraction and corpus selection add private, resumable data workflows. Credential-format scanning and language abstention are optional. Detector routing remains experimental; existing full execution stays the default. Trained Torch and ONNX quality scores agree in the tested profiles. Breaking changes: none to default behavior; unified callback ordering differs when explicitly enabled.
Added¶
- Unified detection, policy and rendering with value-free findings and explicit Unicode source mapping quality.
- Application-owned detector registries, caller context, field schemas and versioned framework summaries.
- Encrypted SQLite token references with expiry, rotation and logical purge operations.
- Report-first corpus selection, explicit deduplication bases, native DataTrove recipes, caller reference checks and checkpoint replay.
- Optional offline Docling PDF extraction with local verified artifacts and structural projection of cleaned text.
- Optional datafog-core credential-format scanning for API keys, Bearer tokens, JWTs and private keys.
- Opt-in language abstention and experimental calibrated detector routing with explicit fallback behavior.
- Cross-feature tests for encrypted references, field protection, extraction, corpus selection, replay and public error traces.
Compatibility and limits¶
- Legacy processing, language selection, replacement modes and three-value result unpacking stay unchanged by default.
- Unified callbacks run before all detection. Legacy callbacks still run between regex replacement and NER.
- Reversible framework/document output remains unsupported; direct text processing can use encrypted references.
- PDF source files remain unredacted. Corpus staging and signatures need caller-managed private storage and retention.
- Logical token deletion cannot erase journals, backups or storage media. Keys and authorization remain application-owned.
- The trained Torch quality scorer passed functional controls, but gibberish scored highest. No quality-accuracy claim is established.
- GPU inference, quality score parity and dataset resume passed on one prepared RTX 4090 setup. See the GPU qualification evidence.
0.9.0 - 2026-11-09¶
This cleanup preserves text output while simplifying configuration.
Streaming and whole-document cleaning share the same masking rules.
Batched documents keep matching fake values across cleaning stages.
Existing output formats and result tuples stay the same.
Benchmark reruns refresh cached predictions when shared adapter code changes.
Configure cleaners through TextCleanerConfig and set worker limits with max_workers.
Breaking changes: deprecated config globals, model tuples, worker arguments and cleaner helper aliases are removed.
Changed¶
- Configure cleaners with
TextCleanerConfig, includingner_modelsfor language-keyed model choices. Changingsct.configglobals no longer configures a cleaner. - Use
max_workersonprocess_batchandaprocess_batch; the deprecatedbatch_sizekeyword is removed. Model and dataset batch sizes keep their existing meaning. - The release benchmark uses
--max-workers, report settingmax_workers, and-Win throughput filenames. Archived reports keep their recorded formats. - Removed deprecated cleaner properties
NormaliseText,ProcessStopwords,ProcessDateTime,ProcessContactsandProcessSpecialSymbols. Use utility classes from their own modules when needed. - Removed the empty
OTTER_NER_MODELSandMMBERT_NER_MODELSplaceholders. Default model routing and pinned revisions are unchanged. - NER window planning lives with chunk splitting. Benchmark process execution is shared by quality and speed workflows.
0.8.4 - 2026-11-09¶
Streaming retries keep text after a processing failure.
Both redactors stay open when finish() fails during processing.
The windowed redactor retains the failed window for another attempt.
Previously processed output remains available after the error.
Breaking changes: none.
Fixed¶
- Windowed streaming consumes a prefix only after processing succeeds, so a failed window can be retried without losing text.
- Both streaming redactors close only after
finish()succeeds. Retryfinish(), or usefeed("")after a failed windowedfeed(). - Streaming finalization still rejects reentrant
feed()andfinish()calls, so callbacks cannot add text to an input snapshot already being processed.
0.8.3 - 2026-11-06¶
Leak fixes, cleaner internals, faster import, clearer errors.
- Leak fixes:
process_jsonnow masks strings in tuples and non-dict mappings, and reversible mode masks digits glued to a placeholder. - Ensembles use GLiNER: with default labels, the ONNX + GLiNER vote used to ignore GLiNER's spans.
- Honest signals:
sensitivenow means PII was found, and values your policy keeps no longer count. - Faster start:
import sctis about 1.1 seconds faster and loads NER libraries only when needed. - Clearer errors:
sct-clean,sct-reportand MCP report bad input and failed model loads as usage errors, not tracebacks. - Type checkers work: the package ships
py.typed, and CI checks it with mypy.
Heads-up: reversible output changes, and streaming and MCP now refuse reversible cleaners. sensitive is false for a date or number alone. process_batch refuses a plain string, and batch_size is now max_workers there. Legacy config.* globals that were ignored now take effect. Importers of private names must update, see below.
Behaviour changes¶
- In reversible mode, a digit run glued to a placeholder is now masked and restores exactly:
a@b.com-107683gives<EMAIL_0>-<NUMBER_0>, andJohn Smith-107683gives<PERSON_0>-<NUMBER_0>. A quote, dash,*or%touching a placeholder is now kept, so it comes back on restore. - The streaming redactors and the MCP server refuse a reversible cleaner with a clear error, because they have no way to return its token map.
signals['sensitive']andis_sensitive_resultnow meanpii_count > 0, so a replaced date, year, number, currency or HTML tag alone is no longer sensitive.entity_counts,pii_countand the metrics callback no longer count entities aPrivacyPolicykept, allowed or ignored.metadata['findings']still lists them.- OCR redaction no longer covers regions that hold only entities a policy exempted. It still covers everything replaced in the text, including dates and years.
- Framework adapters (DataTrove, LangChain, Haystack) write
metadata['sct']merged over all listed columns, so PII in a later column marks the document as sensitive. Score columns stay on the first column. process_batchandaprocess_batchtakemax_workersfor the worker thread limit.batch_sizeis a deprecated keyword alias, and passing both raisesTypeError.- Every
TextCleanerConfigfield now has a legacy module global with its upper-case name, for exampleconfig.NER_BACKEND. These were silently ignored before and now take effect. The exceptions are the computedsupported_languagesand the deprecatedner_models_list, whichNER_MODELS_LISTstill feeds. New globalconfig.NER_MODELStakes a language-keyed dict and wins overNER_MODELS_LIST, andconfig.NER_ENSEMBLE_DEFAULT_KEYSnow takes effect when changed. process_batchraisesValueErrorfor a plain string instead of treating each character as a text. Useprocess()for one text.WindowedStreamingRedactorraisesBufferErrorfor a broken or cut escape followed by a very long token. It used to stream this input with output that differed from whole-text cleaning.- ONNX model loading raises
ModelLoadErrorwhen a network or auth error hitsmodel.onnx_dataortokenizer_config.json, instead of treating the file as missing. GLiNER and Presidio construction errors are also wrapped asModelLoadError. score_recordsnow runs a supplied scorer whenScoreConfig.qualityis False, and adds thesct_qualityandsct_minhashcolumns. Before, the scorer was silently ignored.sct-cleanrefuses reversible mode before cleaning on any non-POSIX platform, including Windows on Python 3.13+, whereos.fchmodexists but cannot restrict the token map.- Importing SCT no longer changes the process-wide ONNX Runtime log level or adds a warnings filter for lingua. Other ONNX Runtime output in the host application is no longer silenced.
- The
torchextra now requirestransformers>=5.0; 4.x could not be installed alongside thehuggingface-hub>=1.5.0requirement.
Added¶
- The
presidioextra now also installs GLiNER. Newquantizeextra installsonnxforload_onnx_ner_model(quantize=True). process_dataframeandprocess_rag_chunksresults gainsensitiveandsignals;ProcessedRAGChunkalso gainsmetadata_findings.process_dataframeaccepts a single column name as a string.ModelLoadErroris exported fromsct.- MCP
detail='full'adds the warningfindings_not_collected, and itsoutputSchemadescribes thesignalskeys.
Fixed¶
- With
ner_device=None, GLiNER and Presidio backends no longer crash at load whenonnxruntime-gpuis installed but no GPU is usable, for example withCUDA_VISIBLE_DEVICESempty. GLiNER running in torch uses CPU, SCT logs a warning,cuda_fallbackis set and provenance showscpu. GLiNER in ONNX mode gets no such warning, and ONNX models still try CUDA first. An explicitner_device='cuda'still fails to load. - With
ner_device=None, GLiNER and Presidio backends use the GPU when torch can, even with the CPUonnxruntimepackage. They used to stay on CPU without saying so. GLiNER in ONNX mode is unchanged. - With
ner_device=None, acuda_device_idthat torch cannot see falls back to CPU with a warning instead of failing at load. docs/PERFORMANCE.mdno longer claims a CUDA version for theonnxruntime-gpu1.29 benchmark run, because the build was not recorded. It adds notes on older NVIDIA drivers and on GPU memory use on shared GPUs.- Synthetic mode gives each type a fake of its own kind, including Presidio's types: a company stays a company, a date stays a date, and a number stays a number. A type with no fake generator, such as your own label, is written as
<TYPE>instead of a person name. A fake no longer contains the original value as a whole word. presidio_glinerno longer masks inside SCT's own tags, so<EMAIL>stays<EMAIL>and is not counted as a finding. Text outside a tag and tag-shaped text from your source are still masked.- Without Faker, synthetic mode raises one clear
ImportErrornamingsqueakycleantext[synthetic]. process_jsonand RAG chunk metadata mask strings inside tuples and non-dict mappings, returned as lists and dicts, and inside tuple dict keys, followingjson_key_cleaning. Sets and bytes are still not masked.- The ensemble backends no longer ignore GLiNER's spans with default labels.
person,organizationandlocationreach the vote asPER,ORGandLOC. A tag you request asPERSON,ORGANIZATIONorLOCATIONstays under that name, and your owngliner_label_mapstill wins. ner_propagate_maskedandner_merge_location_gapalso work on thePERSON,ORGANIZATIONandLOCATIONspans of the plainglinerbackend, with the same output tokens.presidio_glinerhonoursreplacement_mode='reversible'and'synthetic'. Masked spans are unchanged.process_jsonmasks acustom_deny_termsvalue in a JSON key whencheck_to_ascii_unicodeis on (a keyJoséstayed readable).- Empty input includes
metadata['policy']andmetadata['findings']under the same conditions as other documents. WindowedStreamingRedactorno longer raises a falseBufferErrorwith a one-charactercustom_patternsrule, keeps processed windows across a caughtBufferError, and keeps a date whole when a cut escape ends the buffer.TextCleanerno longer keeps document text in per-thread state after processing, including after a step raises.AnonymizationMap.to_dictandfrom_dictkeep reserved tokens, so placeholders allocated after a round trip do not collide.sct-cleanwrites the token map before printing masked text.- MCP no longer names the root folder when
SCT_MCP_ROOTcannot be opened, and gives the unique-keys remedy only for duplicate-key errors. - A plain string for
quasi_identifiersinassess_reidentification_riskmeans one field, not separate characters. - A float, zero or negative
model_max_lengthintokenizer_config.jsonis read safely, with a fallback to 512.
Deprecated¶
batch_sizeonprocess_batchandaprocess_batch; usemax_workers.TextCleaner.NormaliseText,ProcessStopwords,ProcessDateTime,ProcessContactsandProcessSpecialSymbolsare now private attributes. The old names still work and raise aDeprecationWarning.
Removed or changed for deep importers (undocumented names)¶
GeneralNERnow takes aTextCleanerConfigplus its collaborators.process_batchis nowner_process_batch,ner_dataisentities_with_position_key,ner_ensembleisvote_entities, andpositional_tagsis required onner_process*.- Removed:
AnonymizeResult, eight unusedProcessContactsmethods,ProcessStopwords.STOP_WORDS_*, theno_emojiargument ofto_ascii_unicode, andDETECTOR,LANGUAGES,LANGUAGE_NAMEinsct.utils.resources. - Removed the stub
setup.pyand the rootrequirements.txt; usepyproject.tomland its extras.
0.8.2 - 2026-11-03¶
Smarter numbers, your own rules, safer streaming.
- Numbers stay, IDs go: prices, percents, times and short numbers are kept; ID-shaped numbers (
Order 4711,123-45-6789) are still masked. On the benchmark DEV split precision rises from 0.68 to 0.81 with recall at 0.94. - Amounts with a currency code stay (
1250 EUR), and codes or dates are no longer taken for phones. - Your own term lists: words to always mask, words to never mask, and your own regex, each with a time limit.
- Apple GPU:
ner_device="mps"runs NER about 1.6x faster on Apple silicon. - Safer streaming: the windowed redactor no longer shows a phone that whole-text cleaning masks.
Heads-up: output text changes for numbers and some phones. replace_numbers_mode='all' and phone_reject_embedded=False restore 0.8.1.
Number and phone masking¶
- New
replace_numbers_mode(default'identifiers', legacy globalREPLACE_NUMBERS_MODE):<NUMBER>only for whole digit runs of an identifier shape. Kept as written: amounts and decimals (1,250.00,12.5), numbers beside%,‰or a currency sign, times and ranges (14:30,1000-3000), and digits that are part of a longer token (glued to a Latin/Greek/Cyrillic letter, digit or_, or joined by- / + * . _ | ,or a dash to one).:=#do not join, soID:98765,id=98765and#12345are still masked. Digits of every script count (Arabic-Indic, Devanagari, full-width); letters of unspaced scripts do not glue (金额50000元).'all'= 0.8.1.check_replace_numbersstays the on/off switch. - A whole digit-only token of three or more hyphen-joined groups with at least
replace_numbers_min_digitsdigits in all is one<NUMBER>(SSN: 123-45-6789,1234-56-78-90), so grouped IDs do not leak. Date-shaped tokens (2023-11-05,05-11-23) and two-part ranges (100-200) are kept. - New
replace_numbers_min_digits(default4, legacy globalREPLACE_NUMBERS_MIN_DIGITS). Chosen on DEV only (9 datasets, 4,298 docs, macro): 4 digits gave F2 0.881 / recall 0.893 / precision 0.856 with years and dates off and the phone rule below, the best F2 of 4 to 7 (7 digits: F2 0.869, precision 0.865). The 0.8.1 default scored F2 0.850 / recall 0.944 / precision 0.680 on the same docs. Not yet confirmed on the test split. - Code fragments:
INV-2023-107683no longer gets a partialINV-<YEAR><NUMBER>. The number step leaves the code whole; the year step still takes2023when years are on (INV-<YEAR>-107683; thetrainingpreset has years off and keeps the code as written). - New
phone_reject_embedded(defaultTrue, legacy globalPHONE_REJECT_EMBEDDED): a digit-only phone match glued to a Latin/Greek/Cyrillic letter or_, directly or across a joiner (ID9876543210,ref-0612345678), or that is exactly a yyyymmdd date or a yyyymmddhhmm[ss] timestamp (20231105123456) is left as it is. A phone that only starts like a date (2023010123) is still a phone. Digits across a joiner do not count, so06-12345678keeps its 0.8.106-<PHONE>. Formatted and international phones,Tel:0612345678and phones glued to unspaced scripts (电话9876543210) are unchanged;Tel0612345678(glued to a Latin word) is no longer masked. DEV: precision +0.02, recall -0.01.False= 0.8.1. - Both rules reach the plain, reversible and synthetic modes,
process_batchand the streaming redactors. The browser manifest exports the new number pattern (withfold_decimal_digits);phone_reject_embeddedis server-side and listed under the manifest's limitations. Thetrainingpreset and the adapters keep numbers off as before.ProcessContacts.replace_numbers/replace_phone_numberskeep the 0.8.1 patterns. - Not measured: the joiner set differs from the DEV simulation (
:=#removed,,and dashes added, grouped numbers such as1 234 567kept); a rerun of the benchmark will show the exact numbers. - Three ID shapes the plain digit-run rule rejected are masked again in
'identifiers'mode, each a new field with a legacy global (defaultTrue):numbers_letter_prefix_ids(AB1234567,X12345678Z: 1-3 uppercase letters, 6+ digits, 0-2 uppercase letters; notUSD1000000),numbers_two_part_ids(123456/7890,12345-678901,123456-789K,12345.67890; not year pairs2023-2024, round ranges1000-3000, times,%or currency) andnumbers_spaced_pair_ids(123 45, a spaced 5-digit postal code, and spaced IDs with 3-6 groups and 7+ digits such as123 45 6789, also when spaces and hyphens mix (123 45-6789,1234 56-78-90), always masked whole; thousands-grouped amounts like1 234 567stay). Format rules only. DEV (9 datasets, language macro, NER threshold 0.8): F2 0.888 -> 0.901, recall 0.919 -> 0.937, precision 0.814 -> 0.811, over-mask 0.029 -> 0.033. Pooled gold ID/number recall 0.633 -> 0.791 (0.8.1: 0.809) and phone recall 0.965 -> 0.990 (0.8.1: 0.997); most lost phones were12345-678901-shaped and come back as<NUMBER>.1234/5678is now one<NUMBER>; a range like12345-67890cannot be told from an ID and is masked. The streaming redactors keep123 45and any space/hyphen-joined digit run in one window. - With
replace_numbers_mode='identifiers'the year step leaves a year-like group of a multi-group ID run (123-45 2020,123-45-2020) to the numbers step, which masks the whole run; before, the year was masked alone and123-45stayed visible. A lone year (born in 2020) and a run of years (1999 2000 2001) are still<YEAR>. The browser export keeps the old order (listed in its limitations). - The streaming split guard for digit-group runs no longer restarts inside a long digit run: 32,000 digits with no separator took ~5 s per buffer, now about 1 ms.
- New
numbers_keep_currency_code_amounts(defaultTrue, legacy globalNUMBERS_KEEP_CURRENCY_CODE_AMOUNTS): in'identifiers'mode a whole digit run right before or after an uppercase ISO 4217 code, with at most one space (1250 EUR,EUR 1250,USD 1,250), is an amount and stays. Only that amount shape: an SSN-like, slash-, dot- or space-joined ID beside a code (SSN 123-45-6789 USD,123456/7890 EUR,EUR 123 45 6789,6789 45 123 EUR) is masked whole, never in part (an earlier build of this release kept the first two and masked only one group of the last two). It reuses the code set that keepsUSD1000000out of letter-prefix IDs;eurandEUROdo not count. The IBAN and card steps still maskEUR NL91ABNA0417164300andUSD 4111 1111 1111 1111. Browser export and the streaming guard follow it.Falsemasks these numbers as before. - A spaced/hyphen-joined run of any length with 7+ digits stays one
<NUMBER>(national ID 12 34 56 78 90 12 34; an earlier build of this release capped runs at 6 groups and left a 7-group ID visible). Thousands grouping keeps an amount only up to 6 groups (1 234 567 890 123 456). The streaming guard reads runs after the date, IBAN/card and phone steps; a run masked whole may split where both halves keep 7+ groups, so a 10,000-number table streams instead of raisingBufferError, and comes out as several<NUMBER>tokens where the whole document gives one. A run of up to 13 groups never splits; a run the rule does not mask whole (beside a currency sign) is never split, so a buffer too small for it raisesBufferError. Years no longer end a run in the guard: an earlier build splitEUR 12 2023 2023 45 123 123after12and masked differently from the whole document. - A date written with spaces (
05 11 2023) is handled as before this release: the date step has no spaced shape, so with years on its year is<YEAR>; with years off the whole run is one<NUMBER>(an earlier build of this release masked only2023).
User term lists¶
- New
custom_deny_terms({LABEL: (term, ...)}),custom_allow_terms((term, ...)),custom_patterns({LABEL: regex}) andcustom_terms_case_sensitive(defaultFalse), each with a legacy global (CUSTOM_DENY_TERMS,CUSTOM_ALLOW_TERMS,CUSTOM_PATTERNS,CUSTOM_TERMS_CASE_SENSITIVE). All empty by default, so output does not change. - Terms match as whole words, Unicode-aware: no letter, mark or digit of a spaced script may touch them; in CJK and Thai the term edge is the word edge. Deny terms and patterns run before the built-in regex steps; allow terms are applied last to regex and NER detections (also to your own deny terms and patterns). A regex value that also appears outside an allowed term is masked everywhere.
- Every replacement mode (
<LABEL>, reversible<LABEL_0>restored bydeanonymize, synthetic Faker value for labels Faker knows, else<LABEL>),process_batch, both streaming redactors (the windowed split guard will not cut a term; it refuses allow terms, see Fixes),process_json,regex_countsandsignals(user labels count as PII; values never appear). - Clear
ValueErrors at config time: label not upper case, a string instead of a tuple of terms, empty terms, one term under two labels, an invalid regex, a regex that can match the empty string. - New
custom_patterns_timeout(default2.0seconds per pattern per document,None= no limit; legacy globalCUSTOM_PATTERNS_TIMEOUT) andcustom_patterns_on_timeout('raise'default |'warn'; legacy globalCUSTOM_PATTERNS_ON_TIMEOUT), using theregexmodule's timeout.'raise'raisessct.CustomPatternTimeout(aTimeoutErrornaming the label, never the text);'warn'logs it, adds thecustom_pattern_timeoutwarning code and processes that document without the pattern. Applies inprocess,process_batch, both streaming redactors (also the windowed split guard) andprocess_json. - Terms are normalised like the text before matching (
check_fix_bad_unicode,check_to_ascii_unicode,check_remove_emoji), so an NFDJoséor full-widthACMEterm still matches. A Unicode case variant (İfori) gets its term's label. An allowed term inside a longer detection (an emailbob@Acme.com) no longer hides that detection. When a pattern match holds an earlier deny term (Annin.+ secret), the reversible map keeps the source text, sodeanonymizerestoresAnn secret. The allow check runs once per value with a binary search, so long documents stay fast. Acustom_deny_termslabel with no terms raisesValueError. - The windowed streaming redactor does not split a deny term or pattern match that the buffer end cuts off
(a phrase fed one character at a time), nor a pattern match from the context it reads (a lookbehind such
as
(?<=account )\d{6}). A pattern that can grow across a whole line (.+ secret) may then needBufferedStreamingRedactor. - Server-side only: the browser manifest lists them under its limitations. Benchmarks never use them.
Apple GPU¶
ner_deviceaccepts'mps'. ONNX models and GLiClass stay on CPU; auto still never picks the Apple GPU.- If torch has no MPS backend, NER runs on CPU with a logged warning and the new
mps_fallbackcode inmetadata['warnings']; provenance showscpu. - ONNX on CoreML was measured and left out: not reliably faster, 35 minutes and 82 GB for the first compile.
Fixes¶
WindowedStreamingRedactor: the split guard also runscustom_deny_terms,custom_patternsand fuzzy dates in the cleaner's step order, and never cuts a digit run near the overlap, so a phone they make visible (117 235 991 12345with a12345pattern,117 235 991 15 Janury 1990) no longer leaks. Onefeed()now splits until the buffer fitsmax_buffer_chars(before, one large feed kept everything after its first split; it now raisesBufferErrorwhen no safe split exists). With whitespace normalised, a window that holds only whitespace adds no space (hello+ 100 spaces +worldstreamed ashello world). Withcustom_allow_termsset it raisesValueError: an allowed value is kept only if every occurrence in the document is allowed, which an emitted window cannot know. No option: pure leak and bound fixes; allow terms are new in this release.WindowedStreamingRedactoronly splits where every masking step up to phones (HTML, URL, email, IP, date, IBAN, card, ID, phone) finds the same matches in each half as in the whole buffer, read in the cleaner's step order (each step on the text the steps before it leave). Before, a split right after a phone whose rule reads past its match (a trunk-0 or+number counts its digits ahead:05 234 567 1,+1 2 3 4 5 6 7 8 x) emitted it whole, and the guard read phones and digit runs on the raw text, so a phone the cleaner finds only once an IP or card next to it is masked (ok 490 872 276 1.2.3.4,4111111111111111 117 235 991) or a number run that ends at a masked IP (01 023 59 9 90.217.217.177) could split and leak, even with an overlap longer than every value. Such a split now moves earlier, or the stream raisesBufferError. A 40,000-case streaming fuzz finds no leak with an overlap longer than every value (24 before); a value longer thanoverlap_charsstill unfinished at the buffer end can still split, as documented. Streaming is about 20% slower. No option: a pure leak fix.
0.8.1 - 2026-11-02¶
Fewer leaks, same speed. 0.8.1 closes the gaps found by our new open-dataset benchmark.
- Fewer names and IDs slip through: leaked PII drops on all 12 test datasets, for example 18% to 8% on REDACT and 42% to 12% on long court documents.
- Bank and card numbers get their own labels (
<IBAN>,<CARD>) instead of being treated as phone numbers. - Less collateral damage: JSON and longer text in brackets are no longer deleted during cleanup.
- Cleaner output: URLs no longer swallow the full stop, and
Name <email>is read as an email. - No slowdown.
Heads-up: some defaults changed, so output text can differ from 0.8.0. Every change has a setting to switch it back.
Benchmarks¶
- Scorer: the partial-leak check now splits values into runs of letters, digits and
combining marks (
[\w\p{M}]+). Python\wstops at vowel signs, so Devanagari, Tamil and Kannada words broke into 1-2 character pieces under the 4-character floor and a partial leak in those scripts was never counted. Leak rates for Brahmic-script data go up (benchmark section 14 measured multiconer 19.0 -> 25.8, naamapadam 16.2 -> 22.2); this is a scoring fix, SCT output is unchanged. Thai (no spaces) is still undercounted. - Scorer: new type-agnostic
masked_recall(withgold_masked) next to partial recall, overall, per type and per language, for every PII dataset; a gold span counts when any prediction overlaps it whatever its type. The summary gains a "masked R" column.
Performance¶
- The email step now runs a cheap pre-check (
constants.EMAIL_GATE:@or a bracketedat) and skips the email regex when the text has neither. Every email match needs that separator, so output is byte-identical; no config field. On email-free text the 0.8.0 regex was 5-18x slower than 0.7.1 (English 17x) and took about 25% of NER-off run time (benchmark section 12); the pre-check costs about 0.01 ms per 1,400-character document against 1.7 ms for the regex. CoversTextCleaner(all replacement modes) andProcessContacts.replace_emails/replace_emails_with_fn. Not re-measured on the benchmark mix yet; the other NER-off slowdowns (dates on by default, language ID, styled-character folding) are unchanged.
Behaviour changes¶
url_trailing_punct_outside(defaultTrue, legacy module globalURL_TRAILING_PUNCT_OUTSIDE):. , ; : ! ? 'at the end of a URL path or query stay outside the URL unless more URL text follows. 0.8.0 turnedVisit https://example.com/path. Next.intoVisit <URL> Next., losing the sentence's full stop; the 0.8.0 fix for a fully qualified host's final dot covered only a bare host, not a path or query. In the 0.8.0 benchmark dumps, nemotron had 40 of 94 gold URLs run past the end (37 by., 3 by,) and bayanat 3 of 31 (benchmark section 11); nothing leaked, but strict URL F1 suffered. Format regex only./a.html,?a=1,2&b=3,/v1.2/x,;jsessionid=andexample.com./?q=are unchanged. Not re-measured yet.Falserestores the 0.8.0 URL rule.ner_snap_word_edges(defaultTrue, legacy module globalNER_SNAP_WORD_EDGES): a NER span whose start or end falls inside a word now moves out to the word edges before the ensemble vote, for every backend. 0.8.0 widened only WordPiece (##) models, so the SentencePiece XLM-R fallback and GLiNER left pieces visible: gretelVia della Salutebecame<LOCATION>e, and 7-13% of placeholders were glued to a letter or mark (multiconer 7.4%, naamapadam 13.2%, gretel 8.1%; benchmark section 9). A word is a run of Unicode letters and marks (wide CJK characters never join); thener_subword_widencap and same-script rule apply, so an unspaced Thai clause or a Latin name glued to kana is left as tagged (ner_subword_widen=0also turns snapping off). Many Bengali glues are case suffixes on an already masked stem, so the leak gain is small; the main gain expected is strict span F1. Unicode-property rule only. Not re-measured yet.Falserestores 0.8.0 spans.html_tag_strict(defaultTrue, legacy module globalHTML_TAG_STRICT): the HTML rule matches only tag shapes (<b>,</a>,<br/>,<!DOCTYPE ...>,<?xml ...?>, comments, multi-line tags) and entities. 0.8.0 used<.*?>, which ran before the email rule, soJohn Smith <john.smith@example.com>becameJohn Smith <HTML>(typed as markup, not counted as PII: REDACT showed 12.8% of EMAIL gold as HTML),<https://...>became<HTML>, andIf a < b then mail <x@y.org>lost the words between the brackets. Now those read as<<EMAIL>>,<<URL>>and plain text. The rule applies to the plain and reversible paths, the streaming split guard and the browser export.Falserestores<.*?>. Not re-measured yet.ner_ensemble_vote(default'max', legacy module globalNER_ENSEMBLE_VOTE): the NER ensemble keeps a span when its best model score reachesner_confidence_threshold. 0.8.0 averaged the scores of every model on the span, so when WordPiece widening moved a weak second vote onto the same span, agreement pulled a strong hit under the cut:সুইডেনin a Bengali sentence got XLM-R 1.0 and wikineural 0.40, mean 0.70 < 0.85, and stayed visible. The new vote can only add masks. Benchmark section 1 (cut 0.85 to 0.7, a related lever) shows leak -5 to -23 pts, recall +1 to +7, precision -0 to -3 on tab, multiconer, iahlt, thainer, naamapadam, thai_pii, nemotron, stockmark and bayanat; the vote change itself, and openpii, gretel and REDACT, are not measured yet. The cut stays at 0.85.'mean'restores 0.8.0.ner_propagate_masked(defaultTrue, legacy module globalNER_PROPAGATE_MASKED) andner_propagate_min_score(defaultNone= the effective NER cut, legacyNER_PROPAGATE_MIN_SCORE): after NER, every other case-sensitive whole-word copy of a masked PER/ORG/LOC value in the same document gets the same type, soDear Mr. Okonkwo ... Signed Okonkwono longer masks one copy and leaves the others visible. The values come from the model's own spans; values under 2 characters or without a letter are not spread, and copies inside a placeholder or another span are left alone. Reversible maps stay exact. A replay on the 0.8.0 benchmark output fills missed gold spans (TAB 357/1216, REDACT 919/3760, gretel 524/5309, nemotron 49/226, iahlt 11/129) at a precision cost: about 50% of the new spans in TAB and gretel and 38% in REDACT fall outside gold, partly real PII the gold misses and partly wrong tags now repeated; raisener_propagate_min_scoreto trade leak for precision. Not measured end to end yet. Known limit: only exact copies of the whole masked value spread, so a single word of a masked multi-word value stays visible unless the model tags it: a surname after a full name (Lars Petersen...Petersen), orOkonkwoafter the model mergedOkonkwo\nOkonkwointo one span.ner_merge_location_gap(default0= off, legacy module globalNER_MERGE_LOCATION_GAP): when set, two neighbouring LOCATION spans become one when the text between them holds at most that many characters outside placeholders, no line break and no sentence end (.!?;:before whitespace,。!?). The NER models tag street, district, city and country as separate entities and the NUMBER rule takes house numbers and postcodes, so1234 Elm Street, Apt 5B, Springfield, IL 62704, USAgave five spans withApt 5BandILvisible; with12it gives<NUMBER> <LOCATION>. Placeholders inside the gap are taken into the merged span; reversible maps restore exactly. Format rule only, no word lists. A replay on the 0.8.0 benchmark output (benchmark section 8) at gap 20: LOCATION leak thai_pii 55% to 42%, gretel -4.5 pts, REDACT -3.1, openpii -2.2, tab flat; at 12 the gains are about a third of that. The cost is non-gold text masked inside merges (about 4 characters per merge on openpii) and short lists of places (Paris, London and Berlin) becoming one<LOCATION>, so it stays off until measured end to end. Known limit: Thai prefixes glued to a tagged name (ถนน,เขต) stay visible at the edge of a span (word-boundary snapping, a separate finding), and text the models never tag (road names after an abbreviation, landmark text before the address) is outside any merge.check_replace_iban_cards(defaultTrue, legacyCHECK_REPLACE_IBAN_CARDS), withreplace_with_iban="<IBAN>"andreplace_with_cards="<CARD>"(legacyREPLACE_WITH_IBAN,REPLACE_WITH_CARDS): before the phone step, an IBAN (country code, check digits, 11-30 letters/digits, unspaced or in groups of 4) whose ISO 7064 mod-97 checksum holds becomes one<IBAN>, and a 13-19 digit number starting 2-6 (spaces/hyphens allowed) whose Luhn checksum holds becomes one<CARD>. 0.8.0 gaveNL91ABNA<PHONE>(country, check digits and bank code visible) and<PHONE> <PHONE>for a card. Values with a bad checksum go to the phone/number steps as before. Benchmark section 3: bayanat IBAN 97/122 and gretel 51/77 pass the checksum; few synthetic values do (openpii cards 191/2,096). Not measured end to end yet.False= 0.8.0.check_replace_identifiers(defaultFalse, legacyCHECK_REPLACE_IDENTIFIERS), withreplace_with_identifiers="<ID>"(legacyREPLACE_WITH_IDENTIFIERS): a token of 6+ uppercase letters and digits (any script) with 2+ digits and a letter, not glued to another letter or digit (RBCON68T08,XK4729QP31), becomes<ID>. Format rule only. Off by default and in thetrainingpreset: the shape also takes product and reference codes (COVID19); benchmark section 3 simulates openpii leak 13.1 to 7.6, but 83% of gretel hits and 27% of REDACT hits are unlabelled reference codes.- New types
IBAN,CARD,IDin reversible maps, synthetic fakes,regex_countsand thesignalsPII types. The browser export does not include these rules (listed in its limitations).Falserestores 0.8.0. bracket_content_max_chars(default32, legacyBRACKET_CONTENT_MAX_CHARS),bracket_content_max_words(default3, legacyBRACKET_CONTENT_MAX_WORDS) andkeep_json_braces(defaultTrue, legacyKEEP_JSON_BRACES): bracket/brace removal no longer deletes JSON documents and bracketed prose. 0.8.0 removed every[...]and{...}up to the first closing bracket, soOrder: {"name": ..., "items": [{...}]} donebecameOrder: doneand nested JSON left"items": "payment":(benchmark section 4: REDACT lost 14.8% of gold spans, 108/1,000 docs lost more than half their text; gretel 7.0%). Now a{...}that parses as a JSON object (json.raw_decode, also inside invalid outer JSON) is kept whole, and only innermost spans of at most 32 characters and 3 whitespace-separated words are removed ([img.png],[12],[citation needed],{{template}}); a[...]never spans a brace. Format rules only. Limits come from the cached benchmark text: with both limits at least 87% of innermost[...]spans in fineweb-2 are still removed. Not measured end to end yet. Unspaced scripts (CJK, Thai) are held by the character limit only. Both limits0andkeep_json_braces=Falserestore 0.8.0.- Dates: new
date_builtin_month_languages(defaultNone= all, legacyDATE_BUILTIN_MONTH_LANGUAGES) picks which built-in month languages apply. Thai month names are not built in (no word lists); pass them withcustom_month_names({"th": ("ก.พ.", ...)}) and24 ก.พ. 2562(Buddhist-era year) becomes one<DATE>. - Dates: new
date_month_name_slash_year(defaultTrue, legacyDATE_MONTH_NAME_SLASH_YEAR): a month name,/and a 2- or 4-digit year is one<DATE>(October/55,Oct/2019); 0.8.0 gaveOctober/<NUMBER>.Falserestores 0.8.0. - Fix: month names are regex-escaped in
build_date_regex, so a dotted abbreviation such asก.พ.(or one passed viacustom_month_names) no longer matches any character in place of its dots.
Fixes¶
- JSON preservation (
keep_json_braces): the object scan is linear on malformed input.'{"a":' * 20000took seconds and then raisedRecursionErrorout ofprocess(); a'{'with no'}'after it is now skipped, a failed parse resumes after the point it failed, and nesting past the recursion limit ends the scan. - JSON preservation: replacement tokens (
<NUMBER>,<PERSON>) count as JSON values, so{"n":42,"name":"Alice"}stays as{"n":<NUMBER>,"name":"Alice"}instead of being removed as brace content. - NER propagation: the whole-word check treats combining marks as part of a word, so a name
राजno longer masks the start ofराजा(<PERSON>ा). html_tag_strict: an HTML comment ends at-->, so<!-- a > secret -->and comments holding tags are one<HTML>; an unclosed<!--still ends at the next>.- Browser export: the date rule follows
custom_month_names,date_builtin_month_languagesanddate_month_name_slash_year(shared helperresources.configured_month_names), so it matches Python. date_builtin_month_languages=()with fuzzy dates on no longer brings back every built-in month name.
0.8.0 - 2026-11-01¶
Faster, more languages, ready for data pipelines.
- Up to 4x faster batch cleaning on multi-core machines, plus GPU support.
- 75 languages detected instead of 4.
- More accurate: fewer false masks and more PII found on our benchmarks.
- Plugs into your stack: Hugging Face
datasets, DataTrove, LangChain and Haystack adapters, atrainingpreset and an MCP server. - Data reports:
sct-reportwrites quality scores and data cards without exposing any text.
Heads-up: this release has breaking changes. Read "Behaviour changes" in the changelog before you upgrade.
Highlights¶
- Speed: on the release benchmark (
evaluation/results/comparison-0.7.1-0.8.0.md)process_batchon 16 Linux cores is 4.2x faster on short documents (3.69 to 15.55 docs/s) and 3.2x faster on long documents (0.65 to 2.08 docs/s); on 64 cores 29.2 docs/s (short). Single-documentprocessis unchanged within noise (0.95x to 1.10x). On a high-end GPU, single documents are about 17x and batches about 4.5x faster than 64 CPU cores. CPU and GPU numbers, hardware advice and CUDA setup:docs/PERFORMANCE.md. - Correctness, release benchmark (30 documents, 84 entities, 9 languages): exact-span
TP/FP/FN 83/1/1 (P = R = F1 = 0.99; 0.7.1: 82/2/2) and 0 of 67 PII values leaked.
evaluation/results/benchmark-0.8.0.md. - New multilingual PII eval (220 synthetic records, 11 languages): the default ONNX
ensemble scores 0.93 / 0.95 recall / precision overall. GLiNER multi v2.1 (0.45 / 0.68)
and GLiNER multi PII v1 (0.57 / 0.91) score lower, so the default model is not switched.
docs/MULTILINGUAL_EVAL.md. - Linux worker processes (
fork,forkserver) are tested in CI; a crash when a forked child freed an inherited ONNX session is fixed.
Added¶
- Docs: a shorter README with a new pipeline diagram (
resources/sct_flow.excalidraw,.svg,.png). The reference moved todocs/:INSTALL.md,CONFIG.md,API.md,NER_BACKENDS.md,REPLACEMENT_MODES.md,PRIVACY.md,ADAPTERS.md,EVALUATION.md,LANGUAGES.md; release notes of 0.4.5 to 0.6.0 moved here. README links are full GitHub URLs, so they also work on the PyPI page. ProcessResult.signals: a value-free public view (language, confidence, PII counts, share and types, regex and entity counts, warnings, model provenance). Never entity text. Details under "Behaviour changes".- Presets:
sct.preset_config("training", **overrides),TextCleanerConfig.from_preset(),sct.PRESETS.trainingremoves only PII and repairs broken unicode, for training and RAG text. Details under "New options". - Framework adapters (
docs/INTEGRATIONS.md), each behind its extra:sct.integrations.hf.clean_dataset/SCTMap([datasets],DatasetandIterableDataset,score=Trueadds score columns),sct.integrations.datatrove.SCTCleaner([datatrove], countspii,pii_<TYPE>andsensitivestats),sct.integrations.langchain.SCTDocumentTransformer([langchain]) andsct.integrations.haystack.SCTDocumentCleaner([haystack],to_dict/from_dictround-trip throughPipeline.dumps). Shared options:columns(default the main text field),output="overwrite"(default) or"new_column"(<col>_clean, raw kept),config(default thetrainingpreset; reversible mode is rejected). Document adapters writemetadata["sct"]= signals of the first column.sct.integrations._runtime(CleanerHandle,clean_columns,clean_records) keeps one cleaner per process, rebuilt after a fork and pickled as config only.docs/RECIPES.mdcovers Ray, Spark, Polars, DuckDB, dlt and Daft (Polars and DuckDB run by hand). - Score columns and data cards (
docs/SCORE.md):sct.score.score_record/score_recordsgive flatsct_*columns. Score A (language, language score, PII count, share and types, warnings) is always on. Score B is off by default (ScoreConfig): a quality score on the original text (ONNXQualityScorer, anyQualityScorercallable;scripts/export_quality_head.pyexports FineWeb2-HQ-Plus to one ONNX graph) and a PII-normalised MinHash of the cleaned text ([score]extra, character shingles for Han, Kana, Thai, Lao, Khmer and Myanmar text).build_data_card/render_data_card. sct-reportcommand: JSONL or Parquet in; value-free report JSON, optional Markdown data card (--card), per-row score JSONL (--scores), MinHash metadata, and k-anonymity on the columns named in--quasi-identifiersonly.- MCP server (
docs/MCP.md): warnings-first output,outputSchema, tool annotations,ToolErrors with a remedy, optional file input (clean_text(path=...)underSCT_MCP_ROOT),SCT_MCP_PRESET. Details under "Behaviour changes". - Language: detection over all 75 Lingua languages with a confidence score; any Lingua
language can be pinned; a language's own
ner_modelsentry runs only that model. - GPU choice:
ner_device,cuda_device_id,onnx_cuda_provider_options; every result records which models, revisions and execution providers ran (metadata['provenance']). - Multilingual PII eval set:
evaluation/multilingual_pii.jsonl(en, nl, de, es, zh, ja, th, ar, hi, bn, ta; native-script names, a code-switch slice), built deterministically byscripts/build_multilingual_eval.pyand scored byscripts/run_multilingual_eval.py. - Fuzz harness for styled, full-width, native-digit and invisible-character PII:
scripts/fuzz_styled_pii.pyrenders emails, phones, IPs, URLs, dates and numbers with Unicode variants (font, wide, small, circled forms, native digits, ZWSP/ZWNJ/ZWJ, IDNA dots, typeset spaces and dashes, glued CJK/Thai prose), runs every replacement mode and the stream, and reports leaks, restore errors, stream mismatches and stream errors (a styled value that leaves no split where its plain spelling does, counted only when every token fits the window less its overlap and the longest stretch with no split, a space inside a value included, is no longer than in the plain spelling), restore errors against the cleaner's own normalised text (so an ftfy mojibake repair is not a restore error), and aslowcheck (fold time on 2000 vs 8000 characters of styled contact punctuation) with a shrunk minimal cause;tests/test_fuzz_styled_pii.pyreplays the seeds of every cluster found. Not generated, as out of scope: an IDNA dot before a native digit (a native-digit IPv4 host) and two addresses glued through one run of prose (no boundary to find). - New extras:
datasets,datatrove,langchain,haystack,score. - CI: a required
linux-workersjob (Python 3.13fork, 3.14forkserver) runs the worker-process tests and the adapter tests with their own coverage gate; lint coversscripts/.
Behaviour changes¶
- Dates:
check_replace_dates(legacy globalCHECK_REPLACE_DATES) now defaults toTrue, so a full date becomes one<DATE>before the year and number steps:2023-10-01was<YEAR><NUMBER><NUMBER>, now<DATE>, in placeholder, synthetic and reversible mode, counted once asDATEinregex_counts. The numeric pattern now covers ISO 8601 and day/month/year with one consistent-,/or.separator in either order (2024/1/15,2024.01.15,15.01.2024,01/15/2024), with an optional time (Tor space, seconds, fraction,Zor offset). Month and day must be in range (year-last takes day/month or month/day, so31/31/2024and14.25.2024are not dates), year-first with dots needs two-digit month and day, and a digit joined by.or,on either side blocks a match, so versions (3.12.0,10.1.12.2024, CalVer2024.1.5), IPs, decimals and phone numbers are not dates.check_replace_dates=Falserestores the 0.7.1 output. Thetrainingpreset keeps dates as written. - ASCII folding:
check_to_ascii_unicode(legacy globalCHECK_TO_ASCII_UNICODE) now defaults toFalse, so the library,sct-cleanandsct-mcpkeep every script:日本語stays日本語(wasRi Ben Yu),MünchenstaysMünchen. On the release benchmark the default NER ensemble found 0 of 8 Hindi, Bengali and Tamil names with folding on (they came out as transliterated text) and over-tagged folded Japanese (69 tags for 3 names); with folding off it matches the gold count in every language. Folding also broke IDN URLs (https://例え.jp/パスbecamehttps://Li e.jp/pasu, not replaced).check_to_ascii_unicode=True,sct-clean --to-asciiorSCT_MCP_TO_ASCII=1restore 0.7 output. IPv4 addresses written in other digit scripts (١٩٢.١٦٨.١.١,८.८.८.८) are now replaced without folding (they leaked as<NUMBER>.١.١). - Python 3.11 is no longer supported; 0.8.0 needs Python 3.12 or newer. pip on Python 3.11 keeps resolving to 0.7.1.
lingua-language-detectorminimum raised from 2.1.0 to 2.2. Lingua 2.2 can detect the language of some texts differently from 2.1.1, which changes the language-specific NER model, stopwords and date handling chosen for those texts.- ONNX NER models no longer share one lock per model: threads in
process_batchnow run ONNX inference at the same time (InferenceSession.runis thread-safe). Output is unchanged. Torch models and GLiNER still run one call at a time. With the ONNX Runtime default threads, a session uses every physical core of the machine and ignorestaskset/CPU affinity; setonnx_intra_op_threadsto cap CPU use. On the benchmark, fewer threads per session was slower, not faster, because the batched NER pass runs a few large calls rather than one per worker. - Two language keys that use the same model repo with different revisions now load each revision. Before, the second key silently reused the first key's revision. The INT8 quantized cache directory is now keyed by the resolved Hub commit (
<model>@<digest>, a lowercase hash of the commit), so a quantized file is only reused for the exact weights it came from, also when a branch such asmainmoves. The 0.7.1 directory (org__model) is not reused, so each quantized model is quantized again once. ner_batch_size(default 8, new legacy module globalNER_BATCH_SIZE) now batches model calls: NER chunks of one document, and of all documents in oneprocess_batchcall, go through each ONNX, torch or GLiNER modelner_batch_sizechunks per forward pass (rows right-padded).process_batchruns the steps before NER per document in threads, then one batched NER pass over all documents (ONNX batches run on the same number of threads), then the steps after NER. Output text, languages and finding spans are unchanged; padded batches can move scores in the last digits (max 5e-6 on the release and long-document sets with the default models).ner_batch_size=1keeps the 0.7.1 call pattern and scores exactly. GLiNER2 still runs one chunk per call.- Throughput with the default models on a 16-core CPU (
scripts/benchmark_release.py --batch 64, docs/s,ner_batch_size1 vs 8): short documents 14.8 vs 14.9; long documents (about 8 joined documents each) 2.66 vs 2.38 with cpu-count workers, 1.50 vs 1.64 with one worker. With many workers on CPU, threads already fill the cores, so batching mainly adds padding and memory (peak RSS on the long set about 6 GB vs 12 GB). The release benchmark on another machine (16 cores) measured the opposite:ner_batch_size8 vs 1 was 15.55 vs 10.31 docs/s (short) and 2.08 vs 1.82 (long) with 16 workers. Which setting is faster depends on the CPU; measure before changing it.ner_batch_size=1always uses less memory. - With
ner_batch_size > 1and NER on,metrics_callbackduration_msincludes the time a document waits for the shared NER pass of its batch. - NER now collects the spans of all chunks of a document in document coordinates, votes once and replaces once per document, instead of voting and replacing chunk by chunk. Chunks and model input are unchanged. Differences, only for documents longer than one chunk:
replacement_mode='reversible': placeholder numbers (<PERSON_0>...) are assigned right to left over the whole document, as for short documents, so indices can differ. Deanonymisation still round-trips.replacement_mode='synthetic': Faker values are drawn in whole-document order, so the fake values can differ.- Placeholder mode: two entities of the same type separated only by spaces at a chunk boundary now merge into one placeholder (
<PERSON>instead of<PERSON> <PERSON>), as they already did inside a chunk. If any chunk has a label outside PER/LOC/ORG/MISC, the whole document uses the plain right-to-left replacement that short documents with such labels use. - Privacy-policy output and findings are unchanged.
ProcessResult.metadatais now always a dict (it wasNonewhen no extended feature was on). It always holds two value-free count maps:regex_counts(every regex replacement per type:URL,HTML,EMAIL,IP,DATE,YEAR,PHONE,NUMBER,CURRENCY, each{"count", "chars"}withchars= length of the replaced source text) andentity_counts(NER findings per entity type, same shape). Counts cover placeholder, synthetic and reversible mode and fuzzy dates; marker text already in the source and IPs kept byreplace_ips_public_onlyare not counted. Withreplace_with_currency_symbols=Noneeach symbol is one count. Entity text stays out of metadata unlessinclude_entities=Trueor a privacy policy is set.- New
ProcessResult.signals(sct/signals.py:public_view,merge_views,PUBLIC_VIEW_KEYS,WARNING_CODES): a value-free view with the same keys for every input, including empty text andprocess_json(language, confidence and source,chars, PII count, chars, share and types, regex and entity counts,sensitive, warnings, model provenance). Metadata gainssource_chars; empty or whitespace input now gets full metadata with theempty_inputwarning instead ofNone.StructuredResult.signalsmerges every string leaf;process_rag_chunksputsmetadata["sct"]. Score columns,sct-report, the framework adapters and the MCP server read it. repr(ProcessResult)shows onlylm_text,languageandsignals;reprofDetectedEntity,MapEntryandAnonymizationMapno longer shows entity text or originals.sct.adapters.is_sensitive_result(result)now takes only the result and reads these counts (any regex replacement or NER finding). The old(cleaner, source, result)signature and the marker-count guessing are gone; it was wrong for empty or custom markers (a phone replaced with""was not sensitive).process_json,process_rag_chunksand OCR region selection use the new counts.- Language detection now covers all 75 Lingua languages (high accuracy, about 1.4 ms per short document and 246 MB peak for the detector). Text in Hindi, Thai, Arabic, Chinese, Japanese, Bengali, Tamil and other languages is labelled with its own language instead of English/Dutch/German/Spanish or
None. EN/NL/DE/ES labels are unchanged on a 41-sentence regression set, and release-benchmark outputs and scores are identical (its zh/ja/hi/bn/ta documents wereNoneand ran the same MULTILINGUAL + ENGLISH models). Texts in other languages (French, Italian, Portuguese, Polish, ...) that were mislabelled as one of the four now get their own label, so they run the MULTILINGUAL + ENGLISH NER fallback and English stopwords instead of the mislabelled language's. Very short or ambiguous texts can get a near language (for example"xyz"is now POLISH with low confidence).detect_all_languages=Falserestores the 0.7 detector. language=accepts any Lingua language as a pin or in a tuple (language='hi'raisedValueErrorbefore unless the language was also inextra_languages/ner_models/...).supported_languagesnow only means "has stopwords, dates or an NER key".- A language with its own entry in
ner_models/torch_ner_models(for example{"HINDI": "your/model"}) and noner_ensembleroute now runs only that model; before, it ran MULTILINGUAL + ENGLISH. The built-in FRENCH/PORTUGUESE/ITALIAN keys, which point at the default multilingual model, keep the fallback. ProcessResult.metadataalso always holdslanguage_confidence(Lingua top-1 confidence;1.0when pinned;Nonewhen unknown),language_source(pinned,detected,fallbackwhen alanguagetuple fell back to its first entry,none) andwarnings(sorted codeslanguage_unknown,language_fallback,language_low_confidence,ner_fallback_models).ner_confidence_thresholdandgliner_thresholdnow default toNone("not set") instead of0.85/0.4; legacyNER_CONFIDENCE_THRESHOLDisNonetoo. Before, PII mode replaced any value equal to the default, so an explicitner_confidence_threshold=0.85orgliner_threshold=0.4was silently turned into 0.3. Now an explicit value is always used. New read-only properties give the values that run:effective_gliner_threshold(explicit, else 0.3 in PII mode, else 0.4) andeffective_ner_confidence_threshold(explicit, else the GLiNER threshold in PII mode, else 0.85). Code that readcfg.ner_confidence_thresholdas a number must use the property. Explicit values outside 0..1 raiseValueError.ner_backendandgliner_labelskeep the old "equals the default" rule in PII mode (an explicitgliner_labels=('person', 'organization', 'location')still becomes the PII labels).metadata['detector'](only set withinclude_entities=Trueor a privacy policy) is gone. Every result now hasmetadata['provenance'] = {"backend", "models"}: one{"key", "id", "revision", "provider"}entry per NER model that runs for the document's language, with the resolved Hub commit asrevisionfor ONNX models and the execution provider (or torch device) that actually ran it.{"backend": None, "models": []}when NER is off.metadata['warnings']getscuda_fallbackwhen CUDA was requested but a model runs on CPU.ner_device=None(auto) keeps GLiClass on CPU as before; onlyner_device='cuda'moves it to the GPU.- The URL regex no longer takes
",<or>into the path (they are never raw in a URL).<a href="https://x.org/a">docs</a>now becomes<a href="<URL>">docs</a>instead of<a href="<URL> ...eating the rest of the tag; this only shows when HTML is kept (check_replace_html=False). - URL regex: user info (
user:pw@) stops at/ ? # " < > @, sohttps://x.org/?to=alice@example.com&cc=bob%40example.netis one<URL>(the percent-encoded email leaked before) and<a href="https://x.org">alice@example.com</a>keeps its markup. A bare host with a query or fragment (x.org/?q=1,host#frag) is replaced whole; a bare trailing?stays punctuation. - Phone regex: grouped national numbers with a trunk 0 (
06 12 34 56 78,010-6552 1234,020 7946 0958, 9 or more digits, optional extension) and 3-3-3 groups (699 123 456) are now replaced whole; before they were missed or cut.00stays the international prefix. Number tables with such groups can now become<PHONE>. - Email regex: the local part accepts RFC punctuation after its first character (
! # $ % & ' * + -) and single dots, and cannot end with a dot, soo'brien@x.organda!b@x.orgare replaced whole.= / | { } ~ ^ ?and backtick stay out, sokey=alice@x.orgkeepskey=. - Marker-shaped text already in the input (
<LONDON>, also after unicode repair:<LONDON>,<LONDON>) is no longer protected from NER, and reversible mode never assigns a placeholder spelled like one already in the input. Only SCT's own placeholders are protected. metadata['entity_counts']counts overlapping findings once (merged under the label with the highest score, the label that is replaced).WindowedStreamingRedactornever splits an email, phone, URL or IP between windows (matched on an NFKC view, so fullwidth digits count), and holds back a phone-like digit run at the end of a feed until the next feed completes it. If no safe split point is left, it raisesBufferError.process_dataframereads cells by position, so frames with duplicate row or column labels are cleaned (before, those cells were skipped).- Reversible mode on empty input returns an empty
AnonymizationMap(or stores one in the token store);sct-clean --token-mapwrites it. sct.risk.assess_reidentification_riskgroups on(type, value), soTrueand1, or1and1.0, are different quasi-identifier values.sct-reportJSON-encodes values the same way ([1]and"[1]"differ).- MCP
clean_text/clean_jsonreturn{warnings, summary, text|value, signals}in that order;detail="full"adds value-freefindings(entity_type, score, start, end, action, reason, pluspathfor JSON).stat_text,language,policyand the nestedfindings[].entity/findings[].findingshape are gone. Both tools declareoutputSchemaand read-only, non-destructive, idempotent, closed-world annotations. Errors arefastmcp.exceptions.ToolErrorwith a remedy (a PII-mode mismatch raisedValueError).create_mcp_server(cleaner=None, *, root=None, max_bytes=None, preset=None); concurrent first calls build one cleaner. - GLiNER on CUDA: after an ONNX-to-torch fallback, and for GLiNER2, the model now moves to the chosen GPU;
presidio_glinerpasses the torch device (map_location) and reports it in provenance.
New options¶
- Presets:
sct.preset_config(name="training", **overrides),TextCleanerConfig.from_preset(...)andsct.PRESETS."training"only removes PII and repairs broken unicode, for training and RAG text: URL, email, phone, IP replacement, NER (default ONNX ensemble), ftfy repair and language detection on; ASCII transliteration, numbers, years, dates, fuzzy dates, isolated letters and symbols, HTML, backslash-escape decoding, bracket and brace removal, currency, whitespace normalisation, casefolding, stopwords, punctuation andstat_textoff, so line breaks, indentation and code are kept."sct"is the library default config. Any field overrides; an unknown name raisesValueError. GLiNER PII stays opt-in (ner_mode="pii"). Known limit: a URL with a path swallows a trailing sentence period (see https://x.org/a.becomessee <URL>). check_decode_unicode_escapes(defaultTrue, legacy module globalCHECK_DECODE_UNICODE_ESCAPES): part ofcheck_fix_bad_unicode; decodes literal backslash escapes (\u00e9,\n) before ftfy.Falsekeeps them as written, for code and JSON.detect_all_languages(defaultTrue, legacy module globalDETECT_ALL_LANGUAGES): auto-detect over all 75 Lingua languages;False= 0.7 detector oversupported_languages. To restrict detection, pass alanguagetuple.language_margin_warning(default0.15, legacy module globalLANGUAGE_MARGIN_WARNING, 0..1) andlanguage_confidence_warning(default0.0= off, legacy module globalLANGUAGE_CONFIDENCE_WARNING, 0..1):metadata['warnings']getslanguage_low_confidencewhen the detected language leads the runner-up by less than the margin, or its confidence is below the confidence threshold. Never changes routing. Measured on the multilingual eval set and release benchmark (250 texts, plus 10/20/40-character prefixes, 994 detections, 83 wrong): the earlier default (confidence below0.5) fired on 272 and caught 74 wrong; the margin default fires on 178 and catches 73. On full texts it fires on 11 of 250 instead of 20 and catches all 4 wrong detections instead of 3 (it also flags a Hindi text detected as Marathi at confidence 0.57). Most of the old false alarms were correct English with names.language_confidence_warning=0.5, language_margin_warning=0.0restores the earlier warning.fix_character_width(defaultFalse, legacy module globalFIX_CHARACTER_WIDTH): part ofcheck_fix_bad_unicode. ftfy's width fix turned the Chinese comma,into,while。stayed, mixing punctuation in CJK text, and widened half-width katakana. The default now keeps CJK punctuation and katakana width and converts only full-width ASCII letters and digits (plus@ . - _ +and the ideographic space inside them), so full-width phones and emails are still detected and replaced.fix_character_width=Truerestores the 0.7 behaviour.ner_subword_widen(default1.0, legacy module globalNER_SUBWORD_WIDEN,Noneor >= 0): WordPiece models widen an entity to its whole word only when the added text is at most this many times the tagged span. Thai and Japanese kana have no spaces, so a whole clause is one WordPiece word:นครราชสีมาbecameย้ายไปนครราชสีมาเมื่อปีที่แล้ว...as one LOCATION.None= always widen (the earlier 0.8.0 fix),0= never (0.7). Multilingual eval (sct-default): Thai recall 0.65 to 0.85.ner_subword_widen_same_script(defaultTrue, legacy module globalNER_SUBWORD_WIDEN_SAME_SCRIPT): WordPiece widening never adds a piece whose letters are in another Unicode script than the entity's edge letter. mBERT readsはDavid MillerでandถึงคุณSarah Leeas one word, so the Latin name took the kana or Thai around it. Multilingual eval (sct-default): 573/56/87 to 578/51/82 (recall 0.868 to 0.876, precision 0.911 to 0.919), code-switch slice 0.78/0.82 to 0.83/0.88, ja 0.63/0.68 to 0.70/0.75, th 0.85/0.89 to 0.87/0.91, PERSON 0.95/0.94 to 0.97/0.96; the 5 changed documents only lose kana/Thai from a PERSON span. Release benchmark outputs identical, leaks 0/67.Falserestores widening across scripts.legacy_type_names(defaultFalse, legacy module globalLEGACY_TYPE_NAMES): one name per PII type on every surface. Before, phone and IP were<PHONE>/<IP>as tags butPHONE_NUMBER/IP_ADDRESSin reversible placeholders (<PHONE_NUMBER_0>),regex_counts,signals['pii_types'], score columns, DataTrovepii_<TYPE>stats, MCP summaries and PII-mode GLiNER tags (phone_numbergave<PHONE_NUMBER>). Now all of them sayPHONE/IP, likeEMAIL,URL,DATE,YEAR,NUMBERand the NER typesPERSON,LOCATION,ORGANISATION,MISC, which already had one name. The short names were kept because they are what the default tag text already showed, so default placeholder output does not change.PII_LABEL_MAPmapsphone_number->PHONEandip_address->IP; PII mode now expandspositional_tagsfrom the configuredgliner_label_map(a custom target such asTELwas filtered out before). Privacy-policy rules key on the new names. The eval datasets (evaluation/*.jsonl) label phonesPHONE; the release benchmark still maps the old names.legacy_type_names=Truerestores the 0.7 names; tag text stays set byreplace_with_*.fold_styled_characters(defaultTrue, legacy module globalFOLD_STYLED_CHARACTERS): part ofcheck_fix_bad_unicode. With the new defaultcheck_to_ascii_unicode=False, styled PII leaked:john@𝐞𝐱𝐚𝐦𝐩𝐥𝐞.com,ⓙⓞⓗⓝ@example.com,https://𝐞𝐱𝐚𝐦𝐩𝐥𝐞.com/astayed in the output andhttps://example.com/awas only half replaced. Letters and digits whose Unicode decomposition is<font>or<wide>now fold to their NFKC ASCII form,<circle>ones too when they touch ASCII letters/digits or URL punctuation, and full-width URL/email punctuation folds between ASCII letters/digits;@also folds between letters of any script,.beside an ASCII letter and a://scheme after ASCII letters, so山田@example.com,taro@例え.jpandhttps://例え.jp/パスare replaced; inside an address-shaped run (word characters around an@) full-width.-_+fold beside any script, sojohn@例え.テストandjosé_smith@example.comare replaced whole; after ascheme://orwww.full-width:/?=&#%~fold beside any script, and.too in the host, sohttps://例え.テスト?email=...,https://例え.テスト/?token=...andwww.例え.テスト?token=...are replaced with their query; a single circled letter before@folds (ⓙ@example.com) while東京-大阪in prose keeps its form (also withfold_styled_characters=False). CJK punctuation in prose, a lone①, super/subscripts and squared units keep their form. Release benchmark and multilingual eval unchanged (eval texts normalise byte-identically).Falserestores the earlier 0.8.0 behaviour.ner_device(defaultNone= auto, CUDA when available;'cpu','cuda'),cuda_device_id(default0) andonnx_cuda_provider_options(defaultNone, extraCUDAExecutionProvideroptions such as{"gpu_mem_limit": 8 << 30}, passed to ONNX Runtime unchecked). Legacy module globalsNER_DEVICE,CUDA_DEVICE_ID,ONNX_CUDA_PROVIDER_OPTIONS. Before, every model ran on GPU 0. GLiNER in ONNX mode (gliner_onnx=True) keeps its own session setup.json_key_cleaning(default'pii', legacy module globalJSON_KEY_CLEANING): howprocess_json(andsct-clean --json-input, the MCPclean_jsontool, RAG chunk metadata) cleans dict keys.'pii'runs only the regex PII steps on keys (emails, phones, URLs...; no NER, because models tag lone words such asmessageoruserout of context, which turned common keys into<MISC>and two such keys into a duplicate-key error; the steps thetrainingpreset turns off stay off), so with the library default{"k": 1}keeps its key instead of becoming{"": 1},{"b": 1, "c": 2}no longer raises "duplicate key", and keys are not ASCII-folded.'pii_ner'adds NER on keys;'full'cleans keys with the whole config (0.7 behaviour);sct-clean --json-inputreports a duplicate key as a usage error instead of a traceback;'off'keeps keys as written (keys can then carry PII). Values are unchanged.synthetic_seed(default42, legacy module globalSYNTHETIC_SEED) andsynthetic_per_document(defaultTrue, legacy module globalSYNTHETIC_PER_DOCUMENT): synthetic mode seeds each document's fakes from the base seed, a hash of the document text and the fake's position, so documents get different fake names and emails (before, every worker thread restarted one seeded stream, so aprocess_batchgave every document the same first fake per type, and sequential output depended on call order), the same document always gets the same fakes inprocess,process_batch(any workers, batched NER or not), a new cleaner or an adapter worker process, and repeated values inside one document keep one fake.Falsekeeps the 0.7 single stream per thread.synthetic_locale(defaultNone, legacy module globalSYNTHETIC_LOCALE): synthetic fakes follow the detected document language. Before, every fake was US English (Faker()), so Dutch text gotJohn SmithandNew York. The Lingua language maps to a Faker locale through Python'slocale.normalizeterritory table, then Faker's bare or first locale of that language (DUTCH ->nl_NL, GERMAN ->de_DE, CHINESE ->zh_CN, JAPANESE ->ja_JP, ARABIC ->ar_AA, HINDI ->hi_IN); 53 of Lingua's 75 languages (English included) get a native locale, the rest (and no detected language) useen_US. Faker locales without a provider fall back to itsen_USone. Fakes stay deterministic per document across threads and processes, also for locale providers that draw from Python's globalrandom(th_TH, uk_UA ID numbers, uk_UA plates) or order cities by hash seed (it_IT). A string forces one locale;'en_US'restores the 0.7 fakes.ner_sees_tags(defaultTrue, legacy module globalNER_SEES_TAGS): NER reads the same text in everyreplacement_mode. Before, synthetic mode put the fake email, phone and URL in before NER and reversible mode put<EMAIL_0>, so NER read a different sentence than in placeholder mode and found different entities (with the small test model,Jan de Vries werkt bij Philips in Eindhoven, mail jan@vries.nl.got an ORGANISATION only in synthetic mode; reversible offsets after a regex value were shifted). Now the regex steps put the placeholder-mode tag in the text NER reads, and each regex value goes back in after NER, in order (same fakes as before, per-document mapping andregex_countsunchanged; a tag already in the source stays). Multilingual eval (sct-default, reversible mode): 578/51/82 to 584/50/76 (recall 0.876 to 0.885, precision 0.919 to 0.921), LOCATION 0.97/0.90 to 0.99/0.93, ORGANISATION 0.66/0.89 to 0.69/0.87, bn recall 0.78 to 0.83, ja 0.70/0.75 to 0.73/0.77, ta 0.87/0.88 to 0.88/0.90, ar precision 0.80 to 0.79; release benchmark outputs identical, leaks 0/67. A regex value inside an NER span goes back into that span's reversible map entry (University of ann@example.comas one ORGANISATION restores the email, also when the span cuts the tag, as inof <EMAIL) and the other occurrences of its tag still get their values.Falserestores the 0.7 order.synthetic_low_confidence_locale(default'en_US', legacy module globalSYNTHETIC_LOW_CONFIDENCE_LOCALE): withsynthetic_locale=None, a document whose language detection gets thelanguage_low_confidencewarning uses this Faker locale instead of the detected language's. Before,Kontakt: Anna Schmidt, anna.schmidt@firma.de, Telefon 030 1234567.was detected as Danish (0.17) and got Danish fakes. Deterministic across processes as before.Nonefollows the detected language anyway.synthetic_collapse_period(defaultTrue, legacy module globalSYNTHETIC_COLLAPSE_PERIOD): a synthetic fake ending in.drops that period when the text already has a.right after the entity (de_DE names with a suffix such asB.Eng.gaveB.Eng..before a full stop).Falsekeeps both periods.synthetic_reference_date(default'2026-01-01', legacy module globalSYNTHETIC_REFERENCE_DATE): syntheticDATEandDATE_OF_BIRTHfakes fall in ranges that end on this ISO date instead of today, so the per-document seed gives the same dates on any day; Faker also reads it as today while generating, so nativeSOCIAL_SECURITY_NUMBER/TAX_IDfakes (zh_CN,et_EE,fi_FI,lv_LV,no_NO,sv_SE,pl_PL,sr_BA) encode the same birthday on any day.Noneuses the clock.sct-clean --preset {sct,training}(defaultsct) andSCT_MCP_PRESET/create_mcp_server(preset=...)(defaultsct) pick the config preset;trainingkeeps numbers, case and whitespace as written.sct-clean --to-ascii/SCT_MCP_TO_ASCII=1turn on ASCII folding.onnx_intra_op_threadsandonnx_inter_op_threads(defaultNone, the ONNX Runtime default, same as 0.7.1). Legacy module globalsONNX_INTRA_OP_THREADS/ONNX_INTER_OP_THREADS. Not applied to GLiNER's own ONNX session (gliner_onnx=True).SCT_MCP_ROOT(default unset = off) andSCT_MCP_MAX_BYTES(default 1 MiB): let MCPclean_text(path=...)read regular UTF-8 files under the root. Relative paths only;.., hidden parts and symlinks are refused (opened folder by folder withO_NOFOLLOW); off on Windows.ScoreConfig(sct.score, notTextCleanerConfig):quality=False,quality_model=None,quality_revision=None,minhash=False,minhash_num_perm=128,minhash_seed=1,minhash_shingle=5,minhash_min_tokens=5. (data_cardis gone:score_recordsnever read it; usebuild_data_card/sct-report --card.)- Adapter options
columns,output("overwrite"),config(None="training"); a single string forcolumnsis one column. ner_chunk_overlap(default0, legacy module globalNER_CHUNK_OVERLAP): each NER window adds this many tokens of the neighbouring text on both sides of its chunk (GLiNER-only: 2 characters per token), and a span is kept from the window whose chunk holds its start; a neighbouring window's span is kept too when no span of the owning window that passes the confidence threshold (and, with a privacy policy, is replaced or redacted by it) covers it. A span seen by several windows is one vote per model, with its best score and that score's label, so repeated context (even with a different label) never counts as extra models in ensemble voting. A document that fits one window is never split, so overlap only affects documents that are chunked. This finds entities that a chunk boundary would split (sentence, whitespace or hard cut). The default0keeps the 0.7.1 chunks; overlap changes the context the model sees in long documents, so detections can change either way, and inference grows by about2 * overlap / window. An entity longer than the overlap that starts at the very end of a chunk can still be cut. Must satisfy2 * overlap < window // 2; this is checked again for each document, so a model loaded later with a smaller limit raisesValueErrorinstead of running with a negative chunk budget.
Fixes¶
check_remove_stext_custom_stop_wordsnow works:Falsekeeps the built-in stopword lists instat_textand ignorescustom_stopwords. Before, no step read it, so custom sets always replaced the built-in lists.- HTML entities decode everywhere, each on its own (0.7 used ftfy's
"auto", which stopped decoding from the first line holding a<on).<p>john@example.com</p>left the address in the output, andWindowedStreamingRedactornever found a split in markup with entities: the right half (no<) decoded what the whole text kept, so each space was rejected after normalising the full prefix and suffix again ("intro <b> " + "word & " * 800, 9062 characters, took 7.4 s and 3205 normalise calls, emitted"intro "and the next feed raisedBufferError; now 0.03 s, 5 calls). Escaped<and>(<,<,>) stay escaped while normalising and decode after it, only outside tags and hidden from the tag step only (contacts, URLs and the reversible map see the plain bracket:john<at>example.comgives<EMAIL>, and a URL holding<restores with<), so escaped code stays visible text in every mode and the stream:<p>Use <b> for bold</p>gives<HTML>Use <b> for bold<HTML>(decoding every entity first had made it<HTML>Use <HTML> for bold<HTML>; 0.7 gave<HTML>Use <HTML>b<HTML> for bold<HTML>). No new option:check_fix_bad_unicode=Falsekeeps every entity as written.WindowedStreamingRedactoralso no longer splits inside an HTML tag that holds a space (<a title="x y">) whencheck_replace_htmlis on. - NER no longer tags text inside a regex placeholder: with the default models,
call +1-202-555-0147becamecall <<MISC>>(the model taggedPHONEin<PHONE>; 0.7.1 did the same). Spans inside an upper-case<TAG>/<TAG_n>marker are dropped; customreplace_with_*text is not protected. - International phone numbers no longer swallow a closing parenthesis of the sentence:
(tel +31 6 1234 5678)now gives(tel <PHONE>), not(tel <PHONE>. Area-code parentheses must be balanced (+44 (0)20 ...unchanged). ner_backend='presidio_gliner': a detected language that the Presidio analyzer has no NLP engine for is analyzed as English instead of failing (the defaultAnalyzerEngineonly has English).- A warning is logged when CUDA was requested but ONNX Runtime fell back to CPU (missing CUDA/cuDNN libraries or a driver that does not match the onnxruntime-gpu build). Before, this happened silently. New
docs/PERFORMANCE.mdgives CPU and GPU throughput and CUDA setup notes. - NER windows now fit every tokenizer of the models a document's language runs, not only the one with the smallest limit. Models loaded for other languages do not change a document's windows. With the default models, the multilingual model (WordPiece) can need far more tokens than XLM-RoBERTa for the same text and silently truncated the rest of a window, so it never saw entities past that point. Such texts now get more, smaller windows; on the release set 2 of 30 documents (and 2 of 4 joined long documents) are affected. Default-config output of the release set is unchanged, but these documents run more model calls (single-run Mac benchmark of the release set: 6.9 to 5.4 docs/s).
- Overlapping detections (for example
John Paul SmithandPaulfrom two ensemble models) are merged into one span before replacement in reversible and synthetic mode and when a custom label is present. Before, right-to-left replacement left part of the outer entity visible (<PERSON_1> Smith). Placeholder mode with standard labels already merged them through Presidio. In the release benchmark, reversible output ofzh-glued-001changes from the garbled<LOCATION_1>OCATION_0>to<LOCATION_0>. - ONNX models exported with a fixed batch dimension of 1 run one row per call instead of failing under
ner_batch_size > 1. replacement_mode='synthetic': the regex steps and NER now share one consistency cache per document underner_batch_size > 1, so the same value replaced by both gets the same fake value (as withner_batch_size=1).- The INT8 quantized cache directory name is collision-free: commits are hashed and repo ids that contain
__are %-quoted. - ONNX models with external weights (the default XLM-RoBERTa-large models) load from a huggingface-hub cache that stores each blob in its own directory (
hub/blobs/<xx>/<sha256>, seen with huggingface-hub 1.32.0). onnxruntime refused them with "External data path escapes model directory"; 0.7.1 fails the same way. In that layout SCT now hard-linksmodel.onnxandmodel.onnx_datainto~/.cache/sct_linked/<model>@<commit hash>(undercache_dirwhen set; one directory per snapshot, so a branch that moves back to an already-cached commit loads that commit's weights) and loads them from there; if hard links are not possible (another filesystem) the files are copied once (about 2.2 GB per model) and a warning is logged; later loads, and other processes starting at the same time, reuse the copy while its size matches and its source blob is not newer. Other cache layouts load in place as before. - Chunk planning ignores fixed padding set in a model's
tokenizer.json. Such a tokenizer counted every text as its padded length, so NER windows were split down to single characters and entities were missed (0.7.1 had this for the primary tokenizer; 0.8.0 had spread it to every ensemble tokenizer). None of the default models set padding. - Custom
replace_with_numbers/replace_with_currency_symbolsmarkers are inserted literally; before, a backslash in them was read as a regex group reference. - Linux worker processes: freeing a cleaner (its ONNX session) inside a forked child crashed (exit 139) or hung in garbage collection, for example in Hugging Face
num_procor DataTrove workers. Live NER objects are now pinned in the child at fork (os.register_at_fork) and never destroyed there.forkserverwas not affected. - Framework adapters build one cleaner per handle also when several threads ask at once; the lock is replaced after a fork so a child never deadlocks.
- Hugging Face
clean_datasetreturns only the columns SCT wrote, soremove_columns=["text"]withoutput="new_column"really drops the raw text, and a stale input column named like an output column is replaced. Its cache fingerprint is stable across runs (datasetshashed a frozenset in per-process order). - NER windows respect a tokenizer's saved truncation
max_lengthwhen it is belowmodel_max_length, so inference never cuts a planned window. - A language route given as a list (
["ENGLISH", ...]) is no longer changed in place when GLiNER is added to it. scripts/benchmark_release.pyimports on Windows (noresourcemodule); peak RSS is reported asnullthere.- Isolated-symbol removal (library default, off in
training) only removes symbols with whitespace or a text edge on both sides. Before, a)or full stop after a placeholder or bracket was dropped:(born 1980).became(born <YEAR>,bel +31 20 123 4567.lost its full stop, and a Chinese,after a word was dropped (0.7.1 did the same). On the release benchmark 11 of 30 outputs now keep such a.or comma; scores are unchanged. replacement_mode='synthetic'with NER: the model no longer rewrites a fake email or phone that a regex step just inserted (anna@beispiel.debecameJames Howard@Bernard LLC); those fakes are protected like placeholders.- WordPiece NER models (any tokenizer with a
##continuation prefix, such asprotectai/bert-base-NER-onnx): entities widen to whole words, soJohn Doeno longer becomes<PERSON>e. SentencePiece models (the defaults) are unchanged. - Worker processes no longer call the Hub when the cache is warm. After
model.onnxresolves, the other files load from that commit (served from the cache with no request) and the missingmodel.onnx_dataprobe uses the Hub's cached "no such file" answer (the default ensemble made one HEAD per load, an unpinned model eight). ACleanerHandlethat is forked or pickled after loading pins unpinned models to the commits the parent loaded. A forked worker on Linux could hang forever in DNS (getaddrinfo) during that request. language_confidenceis rounded to 6 decimals. Lingua's value differed in the last digit between processes, so worker and in-process metadata did not match.import sctno longer importspresidio_anonymizer(which pullspresidio_analyzer, GLiNER and torch when installed); it loads on first NER replacement.- MCP: an
SCT_MCP_ROOT/create_mcp_server(root=...)that is not an existing directory raisesValueErrorat start-up instead of failing on every call. WindowedStreamingRedactorchecks for a split contact token on HTML-entity-decoded text too, so a phone written with , or between its digit groups is no longer cut between windows.WindowedStreamingRedactorreads every decimal digit as ASCII when it looks for a split contact token, as the cleaner does, so a phone in Arabic-Indic or Devanagari digits (٠٦ ١٢ ٣٤ ٥٦ ٧٨) is no longer cut between windows and left unredacted. The browser manifest marks its phone and IP rules withfold_decimal_digits: trueand states how a client runs them.- An empty Presidio type map (
legacy_type_names=Truewith no label map) is used as given again, soPHONE_NUMBER/IP_ADDRESSprivacy-policy rules match analyzer findings. ner_backend='presidio_gliner': findings mapped toPER/LOC/ORG(the documented label map and the PII label map) becomePERSON/LOCATION/ORGANISATION, as with the other backends, so privacy-policy rules on those names match (withdefault_rulekeep,Anna Schmidtwas kept).WindowedStreamingRedactorfolds multi-digit styled numbers (⑫->12) when it looks for a split contact token, so a phone written with circled 10-50 is no longer cut between windows and left unredacted.- Haystack
SCTDocumentCleaner.to_dict()leaves out a derived PII label map, so a loaded config re-derives it (as pickle does) and a laterdataclasses.replace(legacy_type_names=...)gets the new phone/IP names. - Pickle, deepcopy and Haystack
to_dict()leave out the shared PII label map only in PII mode, where it is re-derived; a config moved to anotherner_modewithdataclasses.replacekeeps it, so a GLiNERnamespan keeps itsPERtag and privacy rule after a worker round trip. - An NER span that cuts a regex placeholder (
of <EMAIL) is widened to the whole placeholder, so placeholder and synthetic output no longer keep a fragment (Proctor>.); reversible mode was already repaired. ner_chunk_overlap: window offsets come from the tokenizer of the document's language route, not always the primary model's tokenizer.- A worker forked while another thread was building the language detector gets a fresh detector lock instead of inheriting it held (a possible hang).
- URLs whose host is a private or loopback IPv4 address (
10.x,127.x,172.16-31.x,169.254.x,192.168.x) or a bracketed IPv6 address ([::1]:8080) become one<URL>. Before, such a URL was not matched as a URL and query values such as?email=alice%40example.comstayed in the text. - URLs keep interior
),]and}when more URL text follows (?f=(a)&email=..., or a dot and a word:report(1).pdf?email=...), so the rest of the query is replaced too; a bracket closing around a URL still stays outside it. - URLs keep a run of closing brackets when more URL text follows it (ASP.NET
/(S(id))/a.aspx?email=...,?q=f(a(b))&email=...), so the query after it is replaced too. - URLs keep closing brackets followed by URL sub-delimiters (
; , : ! $ * @) and more URL text (?q=(report);email=alice%40example.com,/a(b);jsessionid=...), so that value is replaced too; a bracket before prose punctuation and a space, or before a possessive's, still stays outside. - NER: when one model gives two labels for the same span (repeated window context, WordPiece widening), it is one vote with its best score and that score's label, also without
ner_chunk_overlap. Before, the two labels could average a 0.99PERbelow the confidence threshold. sct.integrations.hf.clean_datasetdeclares the output column features, so a first batch of onlyNoneor[]no longer types a column as null and fails on the next batch.assess_reidentification_risktype-tags values inside tuples and frozensets too, so(1,)and(True,)are different quasi-identifier values.- Merged signals of a JSON value:
language_sourcecomes from the longest leaf of the chosen language, not the longest leaf overall. - The email domain stops at general, CJK and full-width punctuation. With
fix_character_width=False(the new default)seikyu@example.com(担当:鈴木花子)was one<EMAIL>up to), so the name inside was hidden from NER and labelled EMAIL; now the address ends at.com. Domains in any script (例え.jp,उदाहरण.भारत) still match, and the letters of the CJK punctuation block (々 〆 〇, by Unicode category) stay domain letters (info@佐々木.jp), as do the join controls ZWNJ and ZWJ (U+200C/D) that Persian and Devanagari domains use. - Isolated-symbol removal keeps a quote, dash,
*or%that touches a letter, digit or mark of any script. Before only ASCII letters and digits counted, so東京-大阪became東京大阪andইসলাম-এরbecameইসলামএর(which also made a WordPiece model widen a name over the suffix). - Email local parts take combining marks (
প্রিয়া@example.com,संपर्क@example.comwere left whole or cut toसंपर्<EMAIL>without ASCII folding); full-width@ .after a mark fold too. Email and URL hosts take letters beyond the BMP (info@𠮷野.jp) and punycode TLDs (example.xn--p1aiwas cut at.xn); a URL with an IPv4 host takes every valid octet (http://10.0.0.0/?...,.255) and only a whole address, so query values no longer stay visible. - Phones and IPs are matched on a view where every decimal digit is ASCII and full-width
. -are ASCII, with the source offsets kept:٠٦ ١٢ ٣٤ ٥٦ ٧٨,٩٨٧٦٥ ٤٣٢١٠,١٩٢.١٦٨.١.١are replaced and1.2.3.4١is one<IP>. dataclasses.replace(cfg, legacy_type_names=...)on aner_mode="pii"config re-derives the label map and positional tags (an explicitgliner_label_mapstays); thepresidio_glinerbackend maps analyzer types throughgliner_label_mapandPHONE_NUMBER/IP_ADDRESStoPHONE/IPunlesslegacy_type_names=True.- With
ner_sees_tags, a regex value stays hidden through the cleanup steps, so a tag they remove (replace_with_email="[EMAIL]"under bracket removal) no longer loses the value in reversible mode. A forked child gets a fresh synthetic random lock.llms.txtlists the currentcheck_replace_dates,language_confidence_warningandlanguage_margin_warningdefaults. - With
ner_sees_tagsin reversible mode, a display marker that is part of an NER placeholder (replace_with_email="PERSON"beside<PERSON_0>) is no longer counted inside that placeholder, so the email is restored; before, deanonymize returned the bare marker. - Fuzz round 1 (
scripts/fuzz_styled_pii.py, 6000 seeds;research/0.8.0-fuzz-round1.md): withfold_styled_characters=Trueevery compatibility form of contact punctuation is folded by one table built from Unicode data (decomposition<wide>,<small>or<font>with NFKC in@ . - _ + : / ? = & # % ~), so small forms (john﹫example﹒com,214﹒44﹒189﹒208,http﹕//...﹖q﹦...,018﹣2896,﹢31 ...) and the font plus﬩are replaced like full-width ones; a run may mix forms (http://x) and folds between digits of any script (١٩٢.١٦٨); a word that ends in a combining mark counts as a word before@/.(https://ळौछनै.de/...); a circled number folds beside any decimal digit or contact punctuation in any form and between space-separated digit groups (06 ⑬ 01 68 36); a plus form folds before any decimal digit. - Fuzz round 2 (9000 seeds;
research/0.8.0-fuzz-round2.md): withfold_styled_characters=Truea lone circled number after two or more space-separated digit groups folds as the last group (093 622 17 ㉝,06 73 86 88 ㊺), so the phone is replaced; a lone one before a number stays a list marker (① 2024年). - Fuzz round 3 (12000 seeds;
research/0.8.0-fuzz-round3.md): prose of a script written without spaces (Unicode line-break class SA, ID or CJ: Thai, Lao, Khmer, Myanmar, CJK, kana) glued to a Latin/Greek/Cyrillic letter or a digit ends an email local part, an email or URL TLD and a URL path/query, and an email may start right after it. Soดูa@b.jpกขคcj9rf@xyz.nlgivesดู<EMAIL>กขค<EMAIL>(the first TLD no longer takes the next local part),https://x.com/f?sid=xyzปถว06 10 29 37 39keeps the phone whole, and an email local part no longer takes a phone group and Thai prose before it. An address written in one such script stays whole (https://th.wikipedia.org/wiki/ภาษาไทย,info@佐々木.jp). Known limit: an IRI path that switches from Latin letters or digits straight into such a script (/wiki/東京2020オリンピック) is replaced only up to the switch. Format-regex rule, the same in Python and the browser export; no option. Release benchmark 82/2/2 to 83/1/1 (zh-long-001keeps邮箱是outside the EMAIL); multilingual eval 584/50/76 to 608/34/52 (recall 0.885 to 0.921, precision 0.921 to 0.947; 12 ja/zh documents: EMAIL, and the names the email span hid). - Fuzz round 5 (18000 seeds;
research/0.8.0-fuzz-round5.md): withfold_styled_characters=Trueinvisible format characters (Unicode category Cf: ZWSP, word joiner, soft hyphen, bidi marks) beside an ASCII letter/digit, any decimal digit, a circled letter/digit or contact punctuation are dropped when the other side is one of those, a letter of any script, a space or the text edge, so a line-break hint copied inside a value no longer hides it (06\u00ad71427605,192.168\u200b.1.1,jan.de\u200bvries@example.com,06\u200b 12 34 56 78were left whole). Between two other letters they stay (ZWSP between Thai words, ZWNJ in Persian); Latin prose loses its soft hyphens too (Donau\u00addampf->Donaudampf).WindowedStreamingRedactorskips Cf characters on its split-guard view, so such a phone is not cut between windows. - Fuzz round 6 (21000 seeds;
research/0.8.0-fuzz-round6.md): withfold_styled_characters=Truethe phone step (whole text, reversible, synthetic and the stream's split guard) reads every space of Unicode category Zs as a space and every dash of category Pd as-, one code point for one, so phones set off the way word processors and PDFs write them (06\u00a012\u00a034\u00a056\u00a078,+31\u20096\u20091234\u20095678,06\u20111234\u20115678) are replaced; they were left whole withcheck_replace_numbers=False(thetrainingpreset). The text keeps those characters. A lone circled number between or after digit groups set off by any Zs space folds as one (06\u202f98\u202f\u3256\u202f03), and an invisible format character before a dash of any form drops (131\u2060\u20107237).WindowedStreamingRedactoremits the whitespace it splits at as" "whencheck_normalize_whitespaceis on, as the whole text gets it (a split at\u202fgave a different output).Falsekeeps the earlier view (ASCII, full-width and ideographic forms only). - Email and URL host labels are matched in one way only (
X+(-+X+)*), so a dotted host with no TLD fails in linear time:a@orhttp://before five 20-digit labels took 5 to 10 seconds, six took over 100 (found when the email hypothesis test hung in the gate). Same matches; no option. - Email local parts take ZWNJ/ZWJ as domain labels do (
प्\u200dॄघप@example.orgwas not matched at all). - An address-shaped run (both the
fold_styled_charactersfold and the full-width-only fold) continues across invisible format characters (category Cf) inside a word and keeps them, so a ZWNJ/ZWJ in a label no longer stops a full-width dot after it from folding (john@نامه\u200cای.ایران,john@क्\u200dष.भारतwere left whole). The email TLD takes ZWNJ/ZWJ between its letters as other labels do (ab@नाम.भा\u200dरतwas cut before\u200dरत). Release benchmark unchanged; multilingual eval texts normalise byte-identically. No new option. - Numeric dates are matched on the decimal-digit view of the text (as phones and IPs are), so dates in native digits are replaced again (
١٥/٠١/١٩٩٠,१५/०१/१९९०,۱۵/۰۱/۱۹۹۰; the 0.8.0 day/month ranges were ASCII only); the browserdaterule getsfold_decimal_digits. A numeric date glued to letters of an unspaced script is a date, as numbers are (วันเกิด15/01/1990ครับ); a Latin/Greek/Cyrillic letter beside it still blocks a match. Withfold_styled_characters=Truedates read any space as a space and any dash as-, as phones do (1987\u201105\u201112). A circled number beside any dash folds as beside-(2026\u2013⑩\u201302). - With
fold_styled_characters=Truean IDNA label separator (。 。, as Python's idna codec reads them) in a mail domain or URL host is a dot when an ASCII letter or digit follows (alice@example。com,https://www。example。com/?token=...were left whole); before other text。ends a CJK sentence and stays. Squared Latin letters (🄹🄾🄷🄽, Unicode<square>forms of one letter) fold like circled ones; squared units (㎏) stay. Every format character but ZWNJ/ZWJ inside a mail domain is dropped whatever script is beside it (info@例\u200bえ.jpwas left whole). - Custom regex markers that are not
<TAG>-shaped (replace_with_email="EMAIL") are protected from NER like<EMAIL>, unless the source already holds them; NER splitEMAILintoEandIL, so reversible restore wrote the address twice and placeholder mode leftMAbehind. - Marker text the source already holds (
The EMAIL field,<LONDON>) loses NER protection only where it occurs in the source; the regex step's own markers of the same text keep it (a literalEMAILlet NER split every emittedEMAIL, so reversible restore wrote an address twice). - A URL whose host starts with IPv4-shaped labels and goes on with a Unicode label or a native digit is one host name (
http://10.0.0.1.école.fr/a?token=...,https://1.2.3.1௨3.example.de/?q=...matched only the IPv4, so the query was left); the IPv4 alternative now rejects every continuation the host grammar accepts. No option. WindowedStreamingRedactornever splits a window inside a date whencheck_replace_dates=True(the cleaner's own date regex, custom formats included);15 January 1990across a split was left as text (or<NUMBER> January <YEAR>). A date longer thanoverlap_charsthat is still incomplete in the buffer can still split, as any token longer than the overlap. No option.- A URL host ends at CJK and full-width prose punctuation (
,、「), as a mail domain does;https://example.com,電話:06 12 34 56 78took,電話:06as host and port, so thetrainingpreset left12 34 56 78. Withfold_styled_characters=Truethe URL fold stops there too (prose keeps its:), a host ends where a glued next scheme starts (a.jp芢。https://...keeps its。), and a circled run after a digit and an IDNA dot folds as a host label. No new option. - With
fold_styled_characters=Trueevery format character but ZWNJ/ZWJ inside a URL host is dropped, as in a mail domain (https://例\u200bえ.jp/?token=...and\u2060were left whole, query included). - With
fold_styled_characters=Truephones read full-width and small parentheses as()(+49(30)123456,+49﹙30﹚123456were left whole); the source keeps them. A format character beside a parenthesis drops inside a contact run, and a circled number in parentheses glued to a digit folds (+80(⑫)808...). An international number ends before a glued bracket unless digits follow it (+49 30 21513405(自宅)), so a CJK prose bracket no longer stops the match. In a URL a full-width@folds after any user-info character and prose punctuation right before it stays in the URL (https://alice:pass!@example.com/?token=...andpass!@were left whole, password and query included). No new option. - A dash or dot look-alike inside a URL host or mail domain no longer hides the whole value: with
fold_styled_characters=Trueevery dash (Unicode category Pd:‐ ‑ ‒ – —) is a label hyphen and every character whose NFKC form is.(․ ﹒) a label dot there, also afterwww(https://my‐site.example.com/?token=...andwww․example․comwere left whole, query included). Whatever the fold, ahttp://,https://orftp://run whose host fails the host grammar is still a URL up to a space or prose punctuation (check_replace_urls). No new option. - Long tokens no longer take quadratic time: the URL fold scanners match a scheme once per
[A-Za-z0-9+.-]run, the address-fold scanners start once per run, the URL user info has one way to split, and a mail local part is matched lazily up to 64 characters (RFC 5321) and a longer one whole from the start of its run, once per run (see the next entries)."http://" + "a1" * 8000took 1.0 s inprocess()(4x per doubling), a 16000-dash rule line 3 s; both are now linear. An IDNA dot right before a glued scheme with a leading digit (。1https://) now stays, as before other glued schemes. The styled-punctuation joiner finds the first styled form with a lazy lookahead and takes the run possessively, so a letter followed by a long run of styled punctuation with no letter or digit after it is linear ("a" + "-" * 32000 + " "took about 6 s, now 0.02 s; same matches). No new option. - An IPv4 after a dot that ends a word is found (
服务器.8.8.8.8, andx@ad.io銄黟。8.8.8.8wherefold_styled_characters=Truereads the。as a host dot of the run that started at the address); a dot before it blocks a match only after a digit (1.2.3.4.5stays). No new option. - A mail domain or URL host ends at punctuation of any script (Unicode category P), as at
,: the Arabic comma, the Devanagari danda and»after an address were taken into its TLD (ahmed.ali@example.com، أوlost the comma). Multilingual eval 608/34/52 to 612/29/48 (recall 0.927, precision 0.955). No new option. - The middle dot between two
ls, the one punctuation IDNA allows in a label in a Latin context (RFC 5892 A.3), stays inside a mail address or URL host (info@col·legi.catwas kept,https://www.col·legi.cat/?token=...left its query). Elsewhere·still ends a label. No new option. - That middle dot also carries the styled-address fold:
info@col·legi。cat(an ideographic full stop) andjosé_col·legi@example.cat(a full-width low line) were kept whole or in part, because the fold run stopped at the dot before the styled separator was normalised. An invisible line-break hint beside the dot (col\u00ad·legi) drops like any other in the run. Linear scan kept. No new option. - A mail local part longer than 64 characters is masked whole (
"a" * 65 + "@example.com"was left as text after the 64-character bound above, as were 200 and 5000 characters; 5000 now takes 0.07 s). The longer form starts only at the start of a run of local-part characters or at a script change, so each run is scanned once and the regex stays linear; there it also takes leading punctuation ('+ 65 letters +@) into the mask. No new option. - The other context rules of RFC 5892 for a label: a katakana middle dot
・beside Hiragana, Katakana or Han (A.7,https://例・え.jp/?token=...left its query,a@例・え.jpwas kept) and a Hebrew geresh or gershayim after a Hebrew character (A.4, A.5,a@צה״ל.co.il) stay inside a mail domain or URL host, in the regexes and in the fold's URL and address runs. Elsewhere・still ends a host and CJK prose keeps it. A label whose Han or kana sits away from the dot (ab・cd例) still ends at it (the rule is read on the dot's neighbours). No new option. - A run of katakana middle dots between Han or kana in a mail domain or URL host with no TLD (
info@例・例・…・え, or a Japanese@before a・list) backtracked exponentially (about 6 s at 22 dots, doubling per dot) and could stall a worker. The dot now has one parse, so the scan is linear. No new option. - A long run of styled punctuation after a letter or digit with no letter or digit after it (
"a" + "-" * 32000 + " ") took quadratic time in the styled-character fold (6 s at 32000, 4x per doubling). The run is now taken whole once a styled form is seen in it, so the scan is linear (0.02 s). Same output; no new option. - With
fold_styled_characters=Truecircled and squared letters and digits inside a mail address or URL run fold by NFKC whatever sits beside them (ab@éⓣ\u2024net,a@Ŗ🅂Ń.io,https://Ŗ🅂Ń.io/?token=...were kept: their neighbours were not ASCII). Outside an address run the neighbour rule stays, so list markers in prose (① 見て,Ⓐ plan) keep their form. No new option (part offold_styled_characters). - An address fold run takes every local-part character the email grammar takes, so a styled
@or_beside ASCII punctuation folds and the whole address is replaced (anna.smith!@example.comwas kept,josé_smith.work@example.comleftjosé_). Withfold_styled_characters=Trueevery dash (Pd) and NFKC dot in the local part is a hyphen or dot as in the domain (john.smith\u2011billing@example.comleftjohn.smith‑), and styled forms of! # $ % & ' *fold there too. Unchanged multilingual eval and release benchmark. No new option. - A process forked while another thread generates a synthetic SSN/TAX_ID with
reference_daterestores the real clock in the child; the child kept that thread's held date for good (zh_CNTAX_ID with the wrong birth year). No option. - A URL scheme glued to CJK/Thai letters is matched (
เว็บhttps://...); only an ASCII letter, digit,_,/or.before it blocks a match. - A URL path or query ends at CJK and full-width prose punctuation (
,。!( ・; full-width URL syntax/?#%&@:.-_excepted), so CJK prose glued after a URL is no longer swallowed with a phone after it (...?q=zz(備考)!06 08 66 90 80left08 66 90 80). - Trunk-0 phones count 9+ digits from the first two, so
018 566 00 17(3-3-2-2) is one<PHONE>; before, a 3-digit area code needed 11 digits. WindowedStreamingRedactorkeeps its overlap on the text its split guard reads, and reads the unfinished last token of the buffer too unless it changes the view before it (a cut escape). Before, the guard left that token out but measured the overlap on the whole buffer, so after a long run of CJK text with no spaces it could split at the last space it read, with no text to its right: in、﬩④𝟡 ㉚ ⓪⑦⓪㊵⑲③(...the lone㉚lost the digit group that folds it and the stream emitted+49 ㉚ <PHONE>(fuzz seed 15673, window 80/24). Such a phone now stays in one window, or the stream raisesBufferErrorwhen the window holds no other space. No option.WindowedStreamingRedactorfolds punctuation on its split-guard view only where the cleaner does (contact punctuation forms), so a phone glued before(is no longer cut between windows.WindowedStreamingRedactorcomposes a combining mark with the letter before it on its split-guard view, as the cleaner's NFC step does, so a date with a decomposed accent (15 fe\u0301vrier 1990) is no longer cut between windows and left unredacted. No option.WindowedStreamingRedactordecodes backslash escapes (\uXXXX,\UXXXXXXXX,\xNN,\N{...}, octal and one-letter escapes) on its split-guard view with the cleaner's own codec whencheck_decode_unicode_escapes=True(the default), so a JSON-escaped phone or date (06 12\u0033\u0034 56 78,06 12\u00a034 56 78,15 f\u00e9vrier 1990) is no longer cut between windows and left unredacted. Withcheck_fuzzy_replace_dates=Trueevery fuzzy date candidate (15 Janury 1990,Janury 15, 1990) is kept in one window too. No new option; both follow the existing ones.WindowedStreamingRedactordecodes HTML entities on its split-guard view with ftfy's own decoder to the same fixpoint as the cleaner (06 12&#32;34 56 78), keeps entities that decode to several characters (15 January  1990) and repairs mojibake word by word (15 février 1990), so those phones and dates are no longer cut between windows and left unredacted. No new option.WindowedStreamingRedactordrops terminal colour codes (\x1b[32m) and the control characters ftfy removes (NUL, U+FFFC ...) on its split-guard view, as the cleaner's ftfy step does, so a date or phone in a coloured log line (\x1b[32m15 février 1990\x1b[0m) is no longer cut between windows and left unredacted. No option.WindowedStreamingRedactor's split guard no longer keeps its own copy of the cleaner's pre-regex steps (the entity, escape, ftfy, NFC, fold and digit rules in the four entries above). It calls the newTextCleaner.normalise(text), the same functionprocessruns before its regexes, and matches each regex on the view its step uses (phone and date separator view, IP digit view, numbers whencheck_replace_numbers=True). A whitespace split is taken only where the normalised buffer is the two halves' own normalised text with at most one whitespace character between them (\r\ncounts as one) and no match covers it, so the guard and the cleaner cannot differ again. Steps the copy never had now count: emoji removal (06 12🙂 34 56 78), ASCII folding (06 12 〇〇 56 78), grouped numbers (5 379) and CRLF line breaks were cut between windows; text with only\r\nbetween rows now splits. The unfinished last token of the buffer is left out of the guard's view, so a feed that ends inside an escape (\U000000) no longer stops the escape decoding of the whole view. Each feed normalises the buffer about twice more (a cut that lands in a match jumps back to the last candidate whose own view ends before the match, found by a bisect on view offsets, so it stays linear; a character NFKC makes longer,⑫->12, no longer makes that jump skip a safe split). Speed on a 210 KB document (1 KB feeds, NER off, best of 7): 0.97 s -> 1.18 s mixed prose, 1.00 s -> 1.13 s a phone table (whole-documentprocess0.45 s). No option.- Seeded synthetic fakes from Faker providers that draw from the module-level
random(th_TH and uk_UA SSN and TAX_ID, uk_UA plates) now draw from a private seededRandomswapped into that provider module, not from the reseeded globalrandom. Another thread usingrandomat the same time no longer changes the fake, and its own draws are no longer thrown away. No option (synthetic_seedas before). - A fully qualified URL host with its final dot (
https://portal.example.com./?email=...,www.example.com./a?u=,http://localhost./) stays one URL up to its port, path, query or fragment; the match stopped at.comand left the query values visible in every mode. A sentence-ending period after a URL stays outside it. Part ofcheck_replace_urls. - Synthetic
DATE_OF_BIRTHfakes withsynthetic_reference_dateare drawn in UTC, so the same seed gives the same birth date under any worker timezone (TZ=America/Los_Angelesmoved some dates by a day). AGENTS.md(contributors): no word-list fixes for model mistakes; every behaviour is a safe default plus a config option.- Dates (fuzz date shapes; every mode and the stream's split guard, which reads the cleaner's own date view): a month-name date glued to Thai or CJK text is found (
生日是15 January 1990谢谢,เกิด15 January 1990ครับ): it ends where no digit,_or Latin/Greek/Cyrillic letter touches it, the rule numeric dates already used (it was\b, which never ends between a digit and a Thai or Han letter); the fuzzy date candidates use the same edges. CJK and Korean unit dates (1990年1月15日, full-width digits too,1990년 1월 15일), Korean dots each followed by a space (1990. 1. 15.) and Han-numeral dates (一九九〇年一月十五日, digits picked by their Unicode numeric value, no word list) are dates. Withfold_styled_characters=Truea month-name date's day may be a circled number (⑮ January 1990,January ⑮, 1990), the date view reads every character whose NFKC form is.as a dot (1990﹒ 1﹒ 15﹒), a circled letter folds beside any Latin letter (26 ⓓéⓒⓔⓜⓑⓡⓔ 1990was26 ⓓécembre 1990), and a format character (Cf) between a letter of any script and a space or the text edge drops (1990년 6월\u2060 1일; Thai or Persian prose loses such a character before a space too). Buddhist-era years (15/01/2533) were already dates (any 4-digit year). The fuzz harness generates these shapes (styled and glued month-name dates, unit, dotted, unpadded and Han-numeral dates). No new option:check_replace_datesandfold_styled_charactersswitch them. Multilingual eval unchanged (precision 0.955, recall 0.927), release benchmark 83/1/1, leaks 0/67.
Known limits¶
- Dates: only full dates. Year-month (
1990年1月) and month-day (1月15日) stay, asJanuary 1990does; Japanese era years (令和6年1月15日) stay (no installed date library reads them, and era names would be a word list);1990.1.15.stays (CalVer2024.1.5.has the same shape;1990. 1. 15.is found), and a dot-only date with a one-digit month or day (生日1990.1.15谢谢) becomes<YEAR><NUMBER>.15(1990.01.15is found); Thai month names are not in the built-in month data (add them withcustom_month_names); a circled number glued to a unit character (1月⑮日) stays a list marker. - In ensembles with GLiNER, windows are sized for the token-classification models, not for GLiNER's own word limit.
- Only
process_batchis faster.processof one document is not batched across documents. - GLiNER2 runs one chunk per call; torch models and GLiNER keep one lock per model, so they do not run in parallel across threads.
ner_chunk_overlap(off by default) can still cut an entity longer than the overlap that starts at the very end of a chunk.06-12345678becomes06-<PHONE>.- Two addresses glued with no separator in a script written without spaces can be read as one address or split at the wrong place.
- ORGANISATION is the weakest NER label in the multilingual eval (0.75 recall), mostly in non-Latin scripts; Bengali and Tamil name spans can be a few characters off. WordPiece widening makes the mBERT and XLM-RoBERTa spans agree more often, and the ensemble averages their scores, so a weak mBERT score can drop an agreed span below the threshold: some ar/bn/ta organisation names and 1 Thai name masked by the pre-review 0.8.0 code are not masked now (
docs/MULTILINGUAL_EVAL.md). - A URL with a path swallows a trailing sentence period (
see https://x.org/a.becomessee <URL>). - Adversarial strings such as
+000000000(000)0can leave(000)0after phone replacement (Pythonretakes the first alternative that matches, not the longest). - Score B's quality head was tested with a small ONNX graph, not the exported FineWeb2-HQ-Plus model; its speed is not measured. GLiNER PII mode (
ner_mode="pii") was not run end to end for this release. - An unpinned NER model makes one Hub request per worker when the parent never loaded it before forking; pin
ner_model_revisionsor setHF_HUB_OFFLINE=1. - With an empty regex marker (
replace_with_email="") andner_sees_tags, NER in synthetic/reversible mode reads the indexed placeholder or fake, not the empty text placeholder mode leaves, so its findings can differ there. WindowedStreamingRedactorcan still cut an HTML tag that holds a space when the tag reaches pastoverlap_charsfrom the cutoff (its>is not in the buffer yet).- A URL host with an ideographic full stop before a one-character styled label (
https://ab。⑧․cd.ex.com/p?sid=secret1) ends at that stop; the rest of the host, the path and the query stay (Linux fuzz run I, seed 19487; contrived). - An uppercase
L·Lnext to a styled separator (INFO@COL·LEGI。CAT) does not carry the styled-address fold the lowercasel·lgets. - CI does not install the
mcpextra; FastMCP-only tests skip there.
0.7.1 - 2026-10-30¶
This release fixes PII that 0.7.0 left in the output. On the release benchmark
(evaluation/results/comparison-0.7.0-0.7.1.md) exact-span recall goes from 0.62 to 0.98
and leaked PII values from 6 of 67 to 0.
Behaviour changes¶
- More PII is found and replaced: glued and international phone numbers, IP addresses, and GLiNER findings between 0.3 and 0.85 in PII mode. Output text changes for inputs that contain them.
- IP addresses are now replaced with
<IP>by default (<IP_ADDRESS_n>in reversible mode), including private ones. - New config flags:
check_replace_ips(defaultTrue),replace_ips_public_only(defaultFalse) andreplace_with_ips(default"<IP>"). They are the lastTextCleanerConfigfields, so positional arguments keep their 0.7.0 meaning. - The browser manifest rejects replacement tokens that contain digits, or that a later enabled rule would rewrite (for example an HTML token holding an email); the browser has no protection for emitted tokens.
process_jsonfinding paths for dict keys use["@key", <index>]instead of the key text.
Fixes¶
- Phone and year rules now find digits glued to letters in any script (for example
电话9876543210,वर्ष2023में). - The number rule now finds numbers glued to letters in scripts written without spaces (
金额5000元). Digits inside Latin, Greek and Cyrillic words stay as they are (GPT-4o,Qwen3-8B,H2O). Setreplace_numbers_in_words=Trueto replace those too (GPT4becomesGPT<NUMBER>). - IPv4 and IPv6 addresses (public and private) are replaced with
<IP>before the date, phone and year steps. Setreplace_ips_public_only=Trueto keep private, loopback and reserved addresses, orcheck_replace_ips=Falseto turn the step off. - Phones now run before years, so
+31 6 1234 2019and2001:4860:4860::8888are no longer split by the year rule. The browser manifest uses the same order. - IPv6 is matched in every valid compressed form, including IPv4 tails (
::abcd:192.0.2.1,1:2:3:4:5::192.0.2.1), and invalid forms are not matched, also in the browser rule. - Replacement tokens and synthetic values (
<EMAIL1>, a synthetic IP or phone) and IPs kept byreplace_ips_public_onlyare no longer rewritten by the later phone, year and number steps. - Phone detection covers more international formats (
+34 612 345 678,+91 98765 43210) and Indian mobiles (98765 43210,098765-43210). - Oversized NER chunks are split again, also long Chinese, Japanese and Hindi text without spaces: to the tokenizer's token budget for ONNX/torch models, and to a cap of
2 * max_tokenscharacters in GLiNER-only mode (a character estimate, not a token count; token-budget chunking is planned for 0.8.0). Text is split after。!?and। ॥, and reversible map offsets for NER entities are now document-relative. Long whitespace runs at a chunk cut no longer break chunk alignment. Chunk planning ignores truncation set in a model'stokenizer.json, and a slice that grows when re-tokenized is split again, so text past the model limit is no longer left out of model input. - Languages the
stop-wordspackage does not support (for exampleTHAI) no longer crashTextCleanercreation. - PII mode: the final
ner_confidence_thresholdnow followsgliner_threshold(0.3 by default) instead of a hidden 0.85, so GLiNER findings between 0.3 and 0.85 are no longer dropped. An explicitner_confidence_thresholdother than 0.85 still wins (0.85 equals the default and cannot be told apart), and0.0is no longer turned into 0.85. process_jsonand MCPclean_jsonno longer return original dict keys in finding paths: a key's own findings use["@key", <index>].is_sensitive_resultcompares placeholder counts, so<EMAIL> alice@example.com(HTML step off) is reported as sensitive. Marker-like tags removed by the HTML step (<EMAIL> Alicewithreplace_with_html="") no longer cancel a new placeholder.TextCleanerConfigcan be pickled and deep-copied, and the copy keeps the exact state.dataclasses.replace(cfg)no longer grows the default 5 NER models to 8 and 4 supported languages to 7. A partialner_modelsmapping gets the same defaults whatever its mapping type.
0.7.0 - 2026-09-19¶
SqueakyCleanText now supports privacy workflows beyond single-text cleanup. Applications can apply policies, inspect decisions, and use consistent adapters.
New features¶
- Added versioned privacy policies, structured findings, detector provenance, and metrics callbacks.
- Added external token storage for reversible anonymization maps.
- Added JSON, DataFrame, RAG, command-line, and MCP adapters.
- Added exact-span PII evaluation with language, domain, label, and custom slice reports.
- Added pinned model candidates, benchmark tooling, and strict model adoption gates.
- Added buffered streaming, OCR image redaction, browser manifests, and corpus risk indicators.
Improvements¶
- Pinned default ONNX, PyTorch, GLiNER, and GLiClass model revisions.
- Preserved source offsets across normalized NER chunks.
- Made
process_batch(batch_size=...)control document concurrency. - Added Python 3.14 CI and an 85 percent coverage gate.
- Added optional backend CI, standard lockfiles, and PyPI Trusted Publishing.
- Updated reversible mode to restore emails, phone numbers, URLs, dates, numbers, HTML, and currencies.
Fixes¶
- HTML replacement now honors its configured replacement token.
- Presidio now receives the detected document language.
- Default configuration no longer emits a deprecation warning.
- MCP findings no longer return original entity values.
- Overlapping policy findings now redact their complete combined span.
Migration notes¶
gliclass_onnx=Truenow raises an error because upstream GLiClass has no native ONNX loader.- Structured adapters reject reversible mode when they cannot return each token map safely.
- Reversible fuzzy date replacement remains unsupported and now fails during configuration.
0.6.1¶
- Improved GLiNER ONNX fallback behavior and test infrastructure.
0.6.0¶
- PII detection mode (
ner_mode='pii'): auto-configures GLiNER with 60+ PII entity labels (personal, financial, healthcare, identity, digital) - Synthetic replacement (
replacement_mode='synthetic'): Faker-generated realistic values instead of<TAG>placeholders, with per-document consistency - Reversible anonymization (
replacement_mode='reversible'): indexed placeholders (<PERSON_0>) withAnonymizationMapfor round-trip deanonymization - Document classification (
check_classify_document=True): zero-shot GLiClass pre-classification before text processing - ProcessResult:
process()returnsProcessResult(backward-compatible 3-tuple) with.metadatafor anonymization maps and classification results - GLiNER ONNX mode (
gliner_onnx=True): load GLiNER with pre-built ONNX weights from HuggingFace Hub (auto-set for PII + ONNX backend) - Bi-encoder support: auto-detects ModernBERT and other bi-encoder GLiNER models, caches label embeddings, dynamic context windows (2048-8192 tokens)
- Entity description labels: ZERONER-style natural-language descriptions for improved zero-shot accuracy
- Presidio GLiNER backend (beta): opt-in
ner_backend='presidio_gliner'for Presidio's context-aware recognition pipeline - ModernBERT ONNX export: updated export script with ModernBERT support (English, 8192 token context)
- Dynamic chunk sizing: GLiNER chunk size adapts to model's actual context window instead of hardcoded 384
0.5.x¶
aprocess_batch(): async batch processing for FastAPI / aiohttp integrationswarmup(languages): pre-load NER models at startup to eliminate first-request latencycustom_pipeline_steps: attach arbitrary(text: str) -> strcallables after the built-in pipeline- French, Portuguese, and Italian NER support via a shared multilingual ONNX session
- Improved NER sentence boundary detection with abbreviation guard
0.4.5¶
- Frozen
TextCleanerConfigdataclass: immutable, thread-safe, per-instance configuration - ONNX-first NER inference: torch-free base install (~400 MB models vs ~7 GB)
- Thread-parallel batch processing via
ThreadPoolExecutor - Five NER backends:
onnx,torch,gliner,ensemble_onnx,ensemble_torch - GLiNER zero-shot NER for custom entity types (PRODUCT, EVENT, SKILL, etc.)
- Ensemble voting across backends for improved recall
- Lazy per-language model loading
- Multilingual date detection and fuzzy date matching
- Configurable emoji removal, bracket/brace content removal, and smart case folding
stop-wordsreplaces NLTK (50 KB bundled vs 30 MB download)- PyTorch and Transformers moved to optional extras
- Migrated to
pyproject.toml(PEP 517), Python 3.11-3.13, ruff linter