Skip to content

strip_accents drops the negation slash, so folds to =: eight surfaces stably assert the opposite of their input #749

Description

@raeq

Summary

strip_accents treats U+0338 COMBINING LONG SOLIDUS OVERLAY and U+20D2 COMBINING LONG VERTICAL LINE OVERLAY as diacritics and drops them. They are not diacritics — they are the negation. Every surface that runs the step therefore emits the un-negated operator, and its output asserts the opposite of its input.

One primitive, one character, no preset needed, on 0.14.1:

>>> import disarm
>>> disarm.strip_accents("≠")     # NOT EQUAL TO
'='
>>> disarm.has_anomalies("≠")
False

45 assigned code points decompose to base + negation slash under NFD. Measured across every preset, profile and key builder — invert means the output is byte-identical to that surface's output for the positive base:

surface invert erase preserve example
search_key 45 0 0
catalog_key 45 0 0
strip_obfuscation 45 0 0 left-pointing arrow
profile llm_guardrail 45 0 0 left-pointing arrow
ml_normalize 32 0 13 there exists
profile ml_corpus_normalize 15 0 30
profile search_index 3 42 0 =
profile rag_ingest 3 42 0 =
sort_key 0 0 45
canonicalize 0 0 45
canonicalize_strict 0 0 45
strip_format 0 0 45

At sentence level, through the profile docs/policy-templates.md:79 presents as the corpus-normalization recipe:

>>> p = get_pipeline("ml_corpus_normalize")
>>> p("p ∤ q")        # p does not divide q
'p ∣ q'               # p divides q
>>> p("∄ x . P(x)")   # there does not exist x such that P(x)
'∃ x . p(x)'          # there exists x such that P(x)
>>> p("A ⊈ B")
'a ⊆ b'

ml_normalize is worse, because the exposed base is then rendered as English that states the opposite:

>>> disarm.ml_normalize("∄ x . P(x)")
'there exists x . p(x)'
>>> disarm.ml_normalize("p ∤ q")
'p divides q'
>>> disarm.ml_normalize("A ⊈ B")
'a subset equal b'

Mechanism

strip_accents (src/transliterate.rs:1521) is NFD → drop combining marks → NFC. The negation overlays are Mn, so they go with the accents.

STEP_ORDER (src/pipeline.rs:47) runs STRIP_ACCENTS (:51) before every step that could have named the negated form, so nothing downstream can tell that a negation was present.

The four clean surfaces are clean for one reason: they have no strip_accents step. canonicalize uses strip_zalgo, whose mark threshold leaves a single combining mark alone.

This is the half of #467/#498 that was never asked about

Both issues were filed as idempotence defects and closed as idempotence defects. Neither asks whether the fixed point they converge on is the right one.

The #498 fix is a second Demojize after StripAccents (src/presets.rs:910), and the comment above it states the mechanism precisely:

src/presets.rs:899-908"the strip-accents step above (NFD decompose → drop combining marks → NFC) then drops the overlay and exposes the bare base (), which IS named."

So the exposure was understood. The fix made the pipeline reach "approximately equal" for NEITHER APPROXIMATELY NOR ACTUALLY EQUAL TO in one pass instead of two. It converged the pipeline on the inverted reading, deterministically.

#467 has the same shape for catalog_key: l, closed by making the fold reach l in one pass.

Why it matters beyond mathematics

rag_ingest and search_index map onto =. A retrieval index built with either returns documents asserting the opposite of the query, and an LLM stack reading from that index is given the negation of what it asked for. ml_corpus_normalize is documented for "NLP/ML text preprocessing, corpus normalization, embedding preparation" (docs/policy-templates.md:81); a corpus normalized through it has its negated statements silently flipped before any model sees them.

Nothing reports this. has_anomalies, inspect_anomalies and is_zalgo are all clean on every one of the 45.

Scope

  1. Exempt U+0338 and U+20D2 from the mark set strip_accents removes, or from the Mn sweep generally where the mark carries the negation rather than a pronunciation.
  2. Decide the contract for the eight affected surfaces and write it down. Either the negation survives, or the surface erases the operator rather than inverting it — rag_ingest's [?]/empty result for 42 of the 45 is already the safer of the two behaviours it exhibits.
  3. Pin the 45 code points as a regression fixture asserting the verdict, not just f(f(x)) == f(x) — the current proptests pass on the inverted fixed point.
  4. Note the outcome on catalog_key not idempotent on negated-math-relation chars (∤ → ∣ → l) #467 and ml_normalize is non-idempotent on NFKD-decomposable symbols (≇ → ≅ → "approximately equal") #498; the idempotence they closed is real and unaffected.
  5. If any surface keeps the inversion deliberately, record it in docs/limitations.md, which does not mention it today.

Reproduction script and the 540-row per-cell dataset are in a comment below.

Refs #467, #498, #723

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions