deberta-conpara / src /unicode_preprocessing_v2.py
mohamedmady's picture
Initial demo deployment
d3774d5
Raw History Blame Contribute Delete
20.1 kB
#!/usr/bin/env python3
"""
Unicode Preprocessing v2 โ€” Optimized for RAID Adversarial Robustness
=====================================================================
Drop-in replacement for `unicode_normalize()` in test_preprocessing_effect.py.
Three-layer defence:
Layer 1 โ€” Explicit homoglyph confusables (Cyrillic/Greek/Fullwidth โ†’ Latin)
Layer 2 โ€” NFKD decomposition + combining-mark strip (accented Latin โ†’ ASCII)
Layer 3 โ€” Typographic normalization (smart quotes, dashes, symbols โ†’ ASCII)
Layer 4 โ€” Strip invisible / format characters (ZWSP, Cf category)
Author : Mohamed Mady
Date : 2026-03-10
"""
import unicodedata
import re
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# LAYER 1 โ€” EXPLICIT HOMOGLYPH CONFUSABLES
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Only characters that are visually similar to Latin characters but come from
# other scripts. NFKD cannot fix these because they are canonically distinct.
#
# CORRECTIONS from v1:
# ฤฑ (U+0131) โ†’ 'i' (was 'd' โ€” Turkish dotless i looks like i, not d)
# ะฝ (U+043D) โ†’ 'h' (was 'e' โ€” Cyrillic EN looks like lowercase h)
# ะป (U+043B) โ†’ 'n' (was 'e' โ€” Cyrillic EL looks like inverted-V / n in many fonts)
# ะผ (U+043C) โ†’ 'm' (was 'e' โ€” Cyrillic EM is visually m)
# ะ› (U+041B) โ†’ 'L' (was 'e' โ€” Cyrillic capital EL)
# ฮ– (U+0396) โ†’ 'Z' (was 'z' โ€” it's uppercase, map to uppercase)
# ฯ‚ (U+03C2) โ†’ 's' (was 'f' โ€” final sigma looks like s, not f)
# ั„ (U+0444) โ†’ 'f' (was 'e' โ€” Cyrillic EF looks like f in many fonts)
CONFUSABLES = {
# โ”€โ”€ Cyrillic โ†’ Latin (lowercase) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\u0430': 'a', # ะฐ
'\u0435': 'e', # ะต
'\u043e': 'o', # ะพ
'\u0440': 'p', # ั€ (VISUAL: looks like Latin p)
'\u0423': 'Y', # ะฃ (VISUAL: Latin Y)
'\u0455': 's', # ั• (VISUAL: Latin s)
'\u0458': 'j', # ั˜ (VISUAL: Latin j)
'\u04bb': 'h', # าป (VISUAL: Latin h)
'\u0441': 'c', # ั
'\u0445': 'x', # ั…
'\u0456': 'i', # ั–
'\u0443': 'y', # ัƒ
'\u0451': 'e', # ั‘
'\u0438': 'i', # ะธ (NEW โ€” count=2,119)
'\u043d': 'h', # ะฝ (FIXED: was 'e' โ†’ 'h')
'\u0432': 'v', # ะฒ (NEW โ€” count=1,383)
'\u0442': 't', # ั‚ (NEW โ€” count=1,305)
'\u043b': 'n', # ะป (FIXED: was 'e' โ†’ 'n')
'\u043a': 'k', # ะบ (NEW โ€” count=794)
'\u043c': 'm', # ะผ (FIXED: was 'e' โ†’ 'm')
'\u0447': 'c', # ั‡ (NEW โ€” count=603)
'\u0434': 'd', # ะด (NEW โ€” count=585)
'\u043f': 'p', # ะฟ (NEW โ€” count=363)
'\u044c': "'", # ัŒ soft sign (NEW โ€” count=342)
'\u0433': 'g', # ะณ (NEW โ€” count=320)
'\u044b': 'y', # ั‹ (NEW โ€” count=310)
'\u0439': 'i', # ะน short I (NEW โ€” count=444)
'\u044f': 'ya', # ั (NEW โ€” count=439)
'\u0431': 'b', # ะฑ (NEW โ€” count=258)
'\u0437': 'z', # ะท (NEW โ€” count=249)
'\u0436': 'zh', # ะถ (NEW โ€” count=111)
'\u0446': 'ts', # ั† (NEW โ€” count=120)
'\u0448': 'sh', # ัˆ (NEW โ€” count=133)
'\u044e': 'yu', # ัŽ (NEW โ€” count=132)
'\u0444': 'f', # ั„ (FIXED: was 'e' โ†’ 'f', count=52)
'\u045b': 't', # ั› tshe (NEW โ€” count=68)
# โ”€โ”€ Cyrillic โ†’ Latin (uppercase) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\u0406': 'I', # ะ†
'\u0410': 'A', # ะ
'\u0412': 'B', # ะ’
'\u0415': 'E', # ะ•
'\u041a': 'K', # ะš
'\u041c': 'M', # ะœ
'\u041d': 'H', # ะ
'\u041e': 'O', # ะž
'\u0420': 'P', # ะ 
'\u0421': 'C', # ะก
'\u0422': 'T', # ะข
'\u0425': 'X', # ะฅ
'\u0418': 'I', # ะ˜ (NEW โ€” count=104)
'\u0411': 'B', # ะ‘ (NEW โ€” count=159)
'\u041b': 'L', # ะ› (FIXED: was 'e' โ†’ 'L', count=128)
'\u041f': 'P', # ะŸ (NEW โ€” count=119)
'\u0414': 'D', # ะ” (NEW โ€” count=88)
'\u0428': 'Sh', # ะจ (NEW โ€” count=64)
# โ”€โ”€ Greek โ†’ Latin (lowercase) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\u03b1': 'a', # ฮฑ
'\u03b5': 'e', # ฮต
'\u03b7': 'n', # ฮท
'\u03b9': 'i', # ฮน
'\u03ba': 'k', # ฮบ
'\u03bd': 'v', # ฮฝ
'\u03bf': 'o', # ฮฟ
'\u03c1': 'p', # ฯ (visually similar to p)
'\u03c5': 'u', # ฯ…
'\u03c7': 'x', # ฯ‡
'\u03c4': 't', # ฯ„ (NEW โ€” count=1,668)
'\u03bc': 'm', # ฮผ (NEW โ€” count=1,401)
'\u03c3': 's', # ฯƒ (NEW โ€” count=971)
'\u03c0': 'p', # ฯ€ (NEW โ€” count=962)
'\u03bb': 'l', # ฮป (NEW โ€” count=857)
'\u03c2': 's', # ฯ‚ final sigma (FIXED: was 'f' โ†’ 's', count=784)
'\u03c9': 'o', # ฯ‰ (NEW โ€” count=523)
'\u03b4': 'd', # ฮด (NEW โ€” count=487)
'\u03b3': 'g', # ฮณ (NEW โ€” count=467)
'\u03b8': 'th', # ฮธ (NEW โ€” count=464)
'\u03b2': 'b', # ฮฒ (NEW โ€” count=463)
'\u03b6': 'z', # ฮถ (NEW โ€” count=124)
'\u03c6': 'ph', # ฯ† (NEW โ€” count=272)
'\u03c8': 'ps', # ฯˆ (NEW โ€” count=108)
'\u03be': 'x', # ฮพ (NEW โ€” count=86)
'\u03d5': 'ph', # ฯ• phi symbol (NEW โ€” count=52)
# Greek with tonos (accented)
'\u03af': 'i', # ฮฏ (NEW โ€” count=514)
'\u03ac': 'a', # ฮฌ (NEW โ€” count=435)
'\u03cc': 'o', # ฯŒ (NEW โ€” count=364)
'\u03ad': 'e', # ฮญ (NEW โ€” count=339)
'\u03ae': 'e', # ฮฎ (NEW โ€” count=269)
'\u03cd': 'u', # ฯ (NEW โ€” count=193)
'\u03ce': 'o', # ฯŽ (NEW โ€” count=155)
# โ”€โ”€ Greek โ†’ Latin (uppercase) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\u0391': 'A', # ฮ‘
'\u0392': 'B', # ฮ’
'\u0395': 'E', # ฮ•
'\u0397': 'H', # ฮ—
'\u0399': 'I', # ฮ™
'\u039a': 'K', # ฮš
'\u039c': 'M', # ฮœ
'\u039d': 'N', # ฮ
'\u039f': 'O', # ฮŸ
'\u03a1': 'P', # ฮก
'\u03a4': 'T', # ฮค
'\u03a7': 'X', # ฮง
'\u0396': 'Z', # ฮ– (FIXED: was lowercase 'z' โ†’ 'Z', count=35,053)
'\u0393': 'G', # ฮ“ (NEW โ€” count=114)
'\u0394': 'D', # ฮ” (NEW โ€” count=160)
'\u039b': 'L', # ฮ› (NEW โ€” count=62)
'\u03a0': 'P', # ฮ  (NEW โ€” count=65)
'\u03a3': 'S', # ฮฃ (NEW โ€” count=176)
'\u03a6': 'Ph', # ฮฆ (NEW โ€” count=66)
'\u03a9': 'O', # ฮฉ (NEW โ€” count=202)
# โ”€โ”€ Fullwidth Latin โ†’ ASCII โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\uff41': 'a', '\uff42': 'b', '\uff43': 'c', '\uff44': 'd', '\uff45': 'e',
'\uff46': 'f', '\uff47': 'g', '\uff48': 'h', '\uff49': 'i', '\uff4a': 'j',
'\uff4b': 'k', '\uff4c': 'l', '\uff4d': 'm', '\uff4e': 'n', '\uff4f': 'o',
'\uff50': 'p', '\uff51': 'q', '\uff52': 'r', '\uff53': 's', '\uff54': 't',
'\uff55': 'u', '\uff56': 'v', '\uff57': 'w', '\uff58': 'x', '\uff59': 'y',
'\uff5a': 'z',
'\uff21': 'A', '\uff22': 'B', '\uff23': 'C', '\uff24': 'D', '\uff25': 'E',
'\uff26': 'F', '\uff27': 'G', '\uff28': 'H', '\uff29': 'I', '\uff2a': 'J',
'\uff2b': 'K', '\uff2c': 'L', '\uff2d': 'M', '\uff2e': 'N', '\uff2f': 'O',
'\uff30': 'P', '\uff31': 'Q', '\uff32': 'R', '\uff33': 'S', '\uff34': 'T',
'\uff35': 'U', '\uff36': 'V', '\uff37': 'W', '\uff38': 'X', '\uff39': 'Y',
'\uff3a': 'Z',
# Fullwidth digits
'\uff10': '0', '\uff11': '1', '\uff12': '2', '\uff13': '3', '\uff14': '4',
'\uff15': '5', '\uff16': '6', '\uff17': '7', '\uff18': '8', '\uff19': '9',
# Fullwidth punctuation
'\uff01': '!', '\uff0c': ',', '\uff0e': '.', '\uff1a': ':', '\uff1b': ';',
'\uff1f': '?', '\uff08': '(', '\uff09': ')',
# โ”€โ”€ Latin Extended look-alikes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\u0131': 'i', # ฤฑ dotless i (FIXED: was 'd' โ†’ 'i', count=5,734)
'\u0130': 'I', # ฤฐ dotted I (NEW โ€” count=249)
'\u0142': 'l', # ล‚ (NEW โ€” count=307)
'\u00f0': 'd', # รฐ eth (count=174 โ€” better mapped to 'd' than 'e')
'\u00fe': 'th', # รพ thorn (count=58)
'\u00de': 'Th', # รž Thorn (count=95)
'\u00df': 'ss', # รŸ sharp s (count=368)
'\u00e6': 'ae', # รฆ (count=347)
'\u00c6': 'Ae', # ร† (count=215)
'\u0153': 'oe', # ล“ (count=101)
'\u0152': 'Oe', # ล’
'\u0259': 'e', # ษ™ schwa (count=285)
'\u026a': 'i', # ษช small capital I (count=153)
'\u025b': 'e', # ษ› open E (count=145)
'\u0254': 'o', # ษ” open O (count=89)
'\u0251': 'a', # ษ‘ Latin alpha (count=87)
'\u0250': 'a', # ษ turned a (count=64)
'\u028a': 'u', # สŠ upsilon (count=57)
'\u0283': 'sh', # สƒ esh (count=61)
'\u0292': 'zh', # ส’ ezh (count=58)
'\u0281': 'r', # ส inverted R (count=63)
'\u2113': 'l', # โ„“ script small L (count=76)
# โ”€โ”€ Letterlike symbols โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
'\u2103': 'C', # โ„ƒ degree Celsius
'\u2109': 'F', # โ„‰ degree Fahrenheit
'\u212b': 'A', # ร… angstrom
}
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# LAYER 3 โ€” TYPOGRAPHIC NORMALIZATION
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# These are NOT homoglyphs โ€” they are normal typographic characters.
# But they affect tokenization (DeBERTa treats U+2019 โ‰  U+0027), so
# normalizing them removes an uncontrolled variable.
TYPOGRAPHIC = {
# Quotes
'\u2018': "'", # ' left single
'\u2019': "'", # ' right single (count=78,314 โ€” #1 by frequency!)
'\u201a': "'", # โ€š single low-9
'\u201b': "'", # โ€› single high-reversed-9
'\u201c': '"', # " left double
'\u201d': '"', # " right double
'\u201e': '"', # โ€ž double low-9
'\u201f': '"', # โ€Ÿ double high-reversed-9
'\u00ab': '"', # ยซ left guillemet
'\u00bb': '"', # ยป right guillemet
'\u2039': "'", # โ€น single left guillemet
'\u203a': "'", # โ€บ single right guillemet
'\u0060': "'", # ` grave accent (sometimes used as quote)
'\u00b4': "'", # ยด acute accent
# Dashes
'\u2013': '-', # โ€“ en dash (count=55,032)
'\u2014': '--', # โ€” em dash (count=24,725)
'\u2015': '--', # โ€• horizontal bar
'\u2012': '-', # โ€’ figure dash
'\u2212': '-', # โˆ’ minus sign (count=818)
'\uff0d': '-', # ๏นฃ fullwidth hyphen-minus
# Ellipsis
'\u2026': '...', # โ€ฆ (count=25,340)
# Spaces
'\u00a0': ' ', # no-break space (count=5,407)
'\u2002': ' ', # en space
'\u2003': ' ', # em space
'\u2009': ' ', # thin space
'\u200a': ' ', # hair space
'\u202f': ' ', # narrow no-break space
'\u205f': ' ', # medium mathematical space
# Bullets / dots
'\u2022': '*', # โ€ข bullet (count=6,029)
'\u00b7': '.', # ยท middle dot (count=1,000)
# Math / symbols
'\u00d7': 'x', # ร— multiplication (count=1,221)
'\u00f7': '/', # รท division
'\u2260': '!=', # โ‰ 
'\u2264': '<=', # โ‰ค
'\u2265': '>=', # โ‰ฅ
'\u2248': '~', # โ‰ˆ
# Fractions โ†’ text (avoids unknown tokens)
'\u00bd': '1/2', # ยฝ (count=9,508)
'\u00bc': '1/4', # ยผ (count=5,211)
'\u00be': '3/4', # ยพ (count=2,200)
'\u2153': '1/3', # โ…“
'\u2154': '2/3', # โ…”
# Misc
'\u00b0': ' degrees', # ยฐ degree sign (count=35,269)
'\ufffd': '', # ๏ฟฝ replacement character โ€” pure noise, strip it
}
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# COMBINED NORMALIZATION FUNCTION
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Build a single lookup table for maximum speed
_COMBINED_MAP = {}
_COMBINED_MAP.update(CONFUSABLES)
_COMBINED_MAP.update(TYPOGRAPHIC)
# Pre-build translation table for single-char mappings (faster than dict lookup)
_SINGLE_CHAR = {ord(k): v for k, v in _COMBINED_MAP.items() if len(v) <= 1}
_MULTI_CHAR = {k: v for k, v in _COMBINED_MAP.items() if len(v) > 1}
def unicode_normalize(text: str) -> str:
"""
Four-layer Unicode normalization for adversarial robustness.
Layer 1: Explicit homoglyph substitution (Cyrillic/Greek/Fullwidth โ†’ Latin)
Layer 2: NFKD decomposition + combining-mark strip (รฉ โ†’ e, รผ โ†’ u, etc.)
Layer 3: Typographic normalization (smart quotes, dashes, fractions โ†’ ASCII)
Layer 4: Strip invisible format characters (ZWSP, ZWNJ, BOM, etc.)
This function is idempotent: applying it twice gives the same result.
"""
if not text:
return text
# โ”€โ”€ Layer 1 + 3: Apply all explicit mappings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Single-char mappings via str.translate (C-speed)
text = text.translate(_SINGLE_CHAR)
# Multi-char mappings (e.g. รŸ โ†’ ss, โ€” โ†’ --, ฮธ โ†’ th)
for src, dst in _MULTI_CHAR.items():
if src in text:
text = text.replace(src, dst)
# โ”€โ”€ Layer 2: NFKD decomposition + strip combining marks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# This handles ALL accented Latin characters automatically:
# รฉ โ†’ e + โ—Œฬ โ†’ strip mark โ†’ e
# รผ โ†’ u + โ—Œฬˆ โ†’ strip mark โ†’ u
# รฑ โ†’ n + โ—Œฬƒ โ†’ strip mark โ†’ n
# Also normalizes compatibility forms (e.g. ๏ฌ โ†’ fi, ยฒ โ†’ 2)
text = unicodedata.normalize('NFKD', text)
text = ''.join(c for c in text if unicodedata.category(c) != 'Mn')
# Re-compose any remaining valid sequences
text = unicodedata.normalize('NFC', text)
# โ”€โ”€ Layer 4: Strip invisible format characters (Cf category) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Catches: ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D), BOM (U+FEFF),
# LRM (U+200E), RLM (U+200F), soft hyphen (U+00AD), etc.
text = ''.join(c for c in text if unicodedata.category(c) != 'Cf')
# โ”€โ”€ Cleanup: collapse multiple spaces โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
text = re.sub(r' +', ' ', text)
return text
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# LEGACY WRAPPER (drop-in for old code)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def unicode_normalize_v1(text: str) -> str:
"""Original v1 normalization โ€” kept for A/B comparison."""
CONFUSABLES_V1 = {
'\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p',
'\u0441': 'c', '\u0445': 'x', '\u0456': 'i', '\u0443': 'y',
'\u0451': 'e', '\u0406': 'I', '\u0410': 'A', '\u0412': 'B',
'\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H',
'\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T',
'\u0425': 'X', '\u03b1': 'a', '\u03b5': 'e', '\u03b7': 'n',
'\u03b9': 'i', '\u03ba': 'k', '\u03bd': 'v', '\u03bf': 'o',
'\u03c1': 'p', '\u03c5': 'u', '\u03c7': 'x', '\u0391': 'A',
'\u0392': 'B', '\u0395': 'E', '\u0397': 'H', '\u0399': 'I',
'\u039a': 'K', '\u039c': 'M', '\u039d': 'N', '\u039f': 'O',
'\u03a1': 'P', '\u03a4': 'T', '\u03a7': 'X',
'\uff41': 'a', '\uff45': 'e', '\uff49': 'i', '\uff4f': 'o', '\uff55': 'u',
'\uff21': 'A', '\uff25': 'E', '\uff29': 'I', '\uff2f': 'O', '\uff35': 'U',
}
text = ''.join(CONFUSABLES_V1.get(c, c) for c in text)
text = ''.join(c for c in text if unicodedata.category(c) != 'Cf')
return text
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# STATS / TESTING
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def count_surviving_nonascii(text: str) -> dict:
"""Count non-ASCII characters remaining after normalization."""
counts = {}
for c in text:
if ord(c) > 127:
counts[c] = counts.get(c, 0) + 1
return counts
def get_normalization_stats(raw_text: str) -> dict:
"""Compare v1 vs v2 normalization on a single text."""
v1 = unicode_normalize_v1(raw_text)
v2 = unicode_normalize(raw_text)
raw_non = sum(1 for c in raw_text if ord(c) > 127)
v1_non = sum(1 for c in v1 if ord(c) > 127)
v2_non = sum(1 for c in v2 if ord(c) > 127)
return {
'raw_nonascii': raw_non,
'v1_nonascii': v1_non,
'v2_nonascii': v2_non,
'v1_fixed': raw_non - v1_non,
'v2_fixed': raw_non - v2_non,
'v2_improvement': (v1_non - v2_non),
}
if __name__ == '__main__':
# Quick self-test
tests = [
("Hะตllo wะพrld", "Hello world", "Basic Cyrillic homoglyphs"),
("It\u2019s a tะตst", "It's a test", "Smart quote + Cyrillic"),
("cafรฉ rรฉsumรฉ naรฏve", "cafe resume naive", "Accented Latin (NFKD)"),
("ยฝ cup of ๏ฌ‚our", "1/2 cup of flour", "Fraction + ligature"),
("zero\u200Bwidth", "zerowidth", "Zero-width space"),
("ฮ–ero day", "Zero day", "Greek Zeta (U+0396)"),
("tรผrkรงe ฤฑลŸฤฑk", "turkce isik", "Turkish chars"),
("naรฏve\u00A0cafรฉ", "naive cafe", "NBSP + accents"),
("StraรŸe Mรผnchen", "Strasse Munchen", "German special chars"),
]
print("=" * 70)
print(" Unicode Preprocessing v2 โ€” Self-Test")
print("=" * 70)
all_pass = True
for raw, expected, desc in tests:
result = unicode_normalize(raw)
ok = result == expected
status = "PASS" if ok else "FAIL"
if not ok:
all_pass = False
print(f" [{status}] {desc}")
if not ok:
print(f" Input : {repr(raw)}")
print(f" Expected: {repr(expected)}")
print(f" Got : {repr(result)}")
print(f"\n Table size: {len(CONFUSABLES)} homoglyphs + {len(TYPOGRAPHIC)} typographic")
print(f" Total mappings: {len(_COMBINED_MAP)}")
print(f" Result: {'ALL PASSED' if all_pass else 'SOME FAILED'}\n")