Skip to content

fix: iterate normalize_confusables to a fixed point (idempotent + complete) - #523

Merged
raeq merged 1 commit into
mainfrom
fix/confusables-idempotency-u0340
Jul 13, 2026
Merged

fix: iterate normalize_confusables to a fixed point (idempotent + complete)#523
raeq merged 1 commit into
mainfrom
fix/confusables-idempotency-u0340

Conversation

@raeq

@raeq raeq commented Jul 13, 2026

Copy link
Copy Markdown
Owner

The bug

normalize_confusables isn't idempotent on some confusable-base + combining-mark inputs. The normalize_confusables_idempotent proptest finds it only non-deterministically — it took an unlucky CI seed ("¥\u{340}") on the 0.11.1 release PR (#522) to surface it. It's a latent bug on main.

Root cause — a two-way interaction

Confusable folding and canonical composition feed each other, so a single pass is never guaranteed to be a fixed point:

¥ + ◌̀   → compose → ¥+U+0300 → fold ¥→Y → Y+U+0300 → compose → Ỳ   (fold exposes a composition)
Ҫ + ◌̧   → fold Ҫ→C → C+◌̧ → compose → Ç → fold Ç→C → C               (composition exposes a NEW fold)

The second case also breaks the completeness invariant: a one-shot recompose stops at Ç, which is itself a confusable (is_confusable stays true).

Fix

Re-run the fold/compose pass until the output stops changing. Idempotent and complete by construction — the loop can only exit once nothing folds. Converges fast (every fold moves toward the target script, composition only shrinks length → no cycles; observed max 3 internal passes, capped at MAX_PASSES=8 + debug_assert). The no-op fast path is preserved; the pipeline-internal normalize_confusables_into is untouched.

Why the testing changed — this is the point

A random \PC* proptest can only stumble onto the 2-code-point adjacency that triggers this. That's why 1000-case runs missed it for so long, and why an earlier one-shot fix that passed the proptest, a 100k-case stress run, and the full suite still left 61 residual failures — found only by enumerating the whole class:

  • New Tier-3 exhaustive gate exhaustive_fold_compose_idempotent_and_complete: every confusable source code point × every combining mark (~9M pairs, ~6s release), asserting idempotency and completeness. 0 counterexamples.
  • Wired ignored lib-level exhaustive tests into tier3.yml (--lib -- --ignored) — also picks up a previously-orphaned presets fast-path sweep that the gate never ran.
  • Replaced fold_never_drops_chars: iterating to a fixed point can legitimately shorten output (Ҫ+◌̧ → C), so its char-count proxy is false; its real guarantee (no empty table value) is now a deterministic table test.
  • Explicit regression test (both directions) + pinned proptest seed.

Verification

Full Rust suite, clippy (core + ext), perf_lint, pytest (3529), the ~9M-pair exhaustive gate (0 counterexamples), and a 50k-case debug proptest — all green.

Unblocks 0.11.1 (#522), which should rebase onto this.

Assisted-by: Claude Code:claude-opus-4-8

Copilot AI review requested due to automatic review settings July 13, 2026 20:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a latent non-idempotence bug in normalize_confusables when a confusables fold changes the base character in a way that enables a new canonical composition with an already-present combining mark (e.g. "¥\u{0340}"), ensuring normalize_confusables reaches a fixed point in one pass and stays aligned with is_confusable’s composed-form behavior.

Changes:

  • Re-compose the post-fold output in the compose-at-lookup path to make normalize_confusables idempotent when folding exposes additional canonical composition opportunities.
  • Add a deterministic regression unit test for the minimal failing input and pin the proptest regression seed.
  • Refine the fold_never_drops_chars property test to separate “fold doesn’t delete content” from “post-fold recomposition is content-preserving”.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/confusables.rs Recompose folded output to guarantee idempotence; adds regression unit test and adjusts related proptests.
proptest-regressions/confusables.txt Pins the newly discovered minimal failing case to prevent regressions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/confusables.rs Outdated
github-actions Bot added a commit that referenced this pull request Jul 13, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…plete)

`normalize_confusables` was not idempotent on some confusable-base + combining-mark
inputs. The `normalize_confusables_idempotent` proptest found it only
non-deterministically — it took an unlucky CI seed (`"¥\u{340}"`) on #522 to hit
one. It is a latent bug on `main`.

Root cause: confusable folding and canonical composition interact **both** ways,
so a single pass is not a fixed point.
  * A fold can expose a composition: `¥`+◌̀ composes to `¥`+U+0300 (yen has no
    precomposed grave), the fold maps `¥`→`Y`, and the emitted `Y`+U+0300 then
    composes to `Ỳ` (U+1EF2).
  * A composition can expose a *new* fold: `Ҫ`+◌̧ composes to `Ç` (U+00C7), which
    is itself a confusable that folds to `C` — so `Ç` in the output also violates
    the completeness invariant (`is_confusable` stays true).

Fix: re-run the fold/compose pass until the output stops changing. This is
idempotent by construction and complete by construction (the loop can only exit
once nothing folds). It converges fast — every fold moves toward the ASCII-ish
target script and composition only shrinks length, so no cycle is possible;
observed maximum is 3 internal passes, guarded by `MAX_PASSES = 8` +
`debug_assert`. The no-op fast path (borrow on first pass) is preserved, so
already-clean input still costs a single pass. The pipeline-internal
`normalize_confusables_into` is unaffected (presets canonicalize upstream).

Testing — this is the crux. A random `\PC*` proptest can only *stumble* on the
2-code-point adjacency that triggers this, which is why 1000-case (and even
100k-case) runs missed 61 further failures that a one-shot recompose left behind.
The bug class is local and bounded, so it is deterministically enumerable:

  * Add `exhaustive_fold_compose_idempotent_and_complete` (#[ignore], Tier 3):
    every confusable source code point × every combining mark (~9M pairs, ~6s in
    release), asserting idempotency AND completeness. This is the real gate.
  * Wire ignored lib-level exhaustive tests into tier3.yml (`--lib -- --ignored`),
    which also picks up the previously-orphaned `presets` fast-path sweep.
  * Replace the flawed `fold_never_drops_chars` proptest: iterating to a fixed
    point can legitimately shorten output (`Ҫ`+◌̧ → `C`, the mark absorbed then
    discarded when `Ç` folds), so its char-count proxy is false. The guarantee it
    stood for — no table value is empty — is now asserted directly and
    deterministically by `confusable_table_values_are_non_empty`.
  * Add an explicit regression unit test covering both interaction directions and
    pin the proptest counterexample seed.

Verified: full Rust suite, clippy (core + ext), perf_lint, pytest (3529), the
~9M-pair exhaustive gate (0 counterexamples), and a 50k-case debug proptest
(no debug_assert) all pass.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-4-8
@raeq
raeq force-pushed the fix/confusables-idempotency-u0340 branch from 4995621 to 918db50 Compare July 13, 2026 20:24
@raeq raeq changed the title fix: make normalize_confusables idempotent when a fold exposes composition fix: iterate normalize_confusables to a fixed point (idempotent + complete) Jul 13, 2026
github-actions Bot added a commit that referenced this pull request Jul 13, 2026
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
@raeq

raeq commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Resolved the Copilot thread as obsolete: it flagged compose_str(&out).into_owned() from the earlier one-shot-recompose version of this PR. That approach was replaced by the fixed-point loop (force-pushed), which reverts that line — the flagged code no longer exists.

@raeq
raeq merged commit ef34657 into main Jul 13, 2026
24 checks passed
@raeq
raeq deleted the fix/confusables-idempotency-u0340 branch July 13, 2026 21:00
raeq added a commit that referenced this pull request Jul 13, 2026
Patch release cutting the backlog since 0.11.0, lockstep across all four
registries (crates.io, PyPI, npm, RubyGems).

- Fixed: ml_normalize idempotent on NFKD-exposed symbol bases (#498)
- Fixed: normalize_confusables idempotent + complete on confusable+mark input,
  now iterated to a fixed point with an exhaustive Tier-3 gate (#523)
- Docs: unidecode() Cyrillic soft/hard-sign collision note (#511)
- Internal: binding publishers gated on the published core (#500); phf held
  at 0.13 to preserve MSRV 1.81 (#510)

Bump Cargo.toml, pyproject.toml, bindings/node/package.json, and
bindings/ruby/lib/disarm/version.rb to 0.11.1. The binding glue crates stay at
0.0.0 with the minor-only `disarm_core = "0.11"` pin — unchanged for a patch.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-4-8
raeq added a commit that referenced this pull request Jul 13, 2026
Patch release cutting the backlog since 0.11.0, lockstep across all four
registries (crates.io, PyPI, npm, RubyGems).

- Fixed: ml_normalize idempotent on NFKD-exposed symbol bases (#498)
- Fixed: normalize_confusables idempotent + complete on confusable+mark input,
  now iterated to a fixed point with an exhaustive Tier-3 gate (#523)
- Docs: unidecode() Cyrillic soft/hard-sign collision note (#511)
- Internal: binding publishers gated on the published core (#500); phf held
  at 0.13 to preserve MSRV 1.81 (#510)

Bump the version to 0.11.1 in every place it is recorded: Cargo.toml,
pyproject.toml, bindings/node/package.json, bindings/ruby/lib/disarm/version.rb,
CITATION.cff (citation metadata), and the disarm editable entry in uv.lock. The
binding glue crates stay at 0.0.0 with the minor-only `disarm_core = "0.11"` pin
— unchanged for a patch.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-4-8
raeq added a commit that referenced this pull request Jul 13, 2026
…ariants

Follow-up to #523: where an invariant is *local* (decidable per code point or
per small window) but guarded only by a random `\PC*` proptest, random generation
merely samples the failure space — exactly how the confusables idempotency bug
hid for so long. These invariants are bounded and enumerable, so add deterministic
exhaustive Tier-3 gates (all `#[ignore]`, run by the `--lib -- --ignored` step
wired in #523; ~11s combined in release). No production code changes; all pass —
the confusables fix (#523) plus the already-exhaustive case-fold / transliterate
layers leave the higher-level transforms clean.

- case_fold (`exhaustive_fold_case_invariants`): every code point. fold_case is
  composition-free and per-code-point, so single-code-point enumeration is a
  *complete proof* for all strings — idempotency, no residual ASCII uppercase,
  no drop, ASCII-in⇒ASCII-out.
- slugify (`exhaustive_slug_codomain`): every code point. The slug charset is a
  per-code-point property, so this completely proves `output_is_ascii` and the
  charset-membership half of `output_charset` (separator *position* stays with
  the proptests).
- whitespace (`exhaustive_collapse_whitespace`): every code point in a run
  context + every pattern over {SPACE, NBSP, x} to length 7 — the collapse/trim
  state machine (uniform whitespace handling).
- filename (`exhaustive_collapse_dot_sequences`): every dot pattern over {., a}
  to length 12 + every non-dot code point preserved — the dot-collapse machine.
- presets (`exhaustive_preset_idempotency`): every code point (the #498 class) +
  every BMP base × combining diacritical U+0300–036F (the #523 class), for
  canonicalize / sort_key / search_key / catalog_key / ml_normalize.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-4-8
raeq added a commit that referenced this pull request Jul 13, 2026
…ariants (#524)

Follow-up to #523: where an invariant is *local* (decidable per code point or
per small window) but guarded only by a random `\PC*` proptest, random generation
merely samples the failure space — exactly how the confusables idempotency bug
hid for so long. These invariants are bounded and enumerable, so add deterministic
exhaustive Tier-3 gates (all `#[ignore]`, run by the `--lib -- --ignored` step
wired in #523; ~11s combined in release). No production code changes; all pass —
the confusables fix (#523) plus the already-exhaustive case-fold / transliterate
layers leave the higher-level transforms clean.

- case_fold (`exhaustive_fold_case_invariants`): every code point. fold_case is
  composition-free and per-code-point, so single-code-point enumeration is a
  *complete proof* for all strings — idempotency, no residual ASCII uppercase,
  no drop, ASCII-in⇒ASCII-out.
- slugify (`exhaustive_slug_codomain`): every code point. The slug charset is a
  per-code-point property, so this completely proves `output_is_ascii` and the
  charset-membership half of `output_charset` (separator *position* stays with
  the proptests).
- whitespace (`exhaustive_collapse_whitespace`): every code point in a run
  context + every pattern over {SPACE, NBSP, x} to length 7 — the collapse/trim
  state machine (uniform whitespace handling).
- filename (`exhaustive_collapse_dot_sequences`): every dot pattern over {., a}
  to length 12 + every non-dot code point preserved — the dot-collapse machine.
- presets (`exhaustive_preset_idempotency`): every code point (the #498 class) +
  every BMP base × combining diacritical U+0300–036F (the #523 class), for
  canonicalize / sort_key / search_key / catalog_key / ml_normalize.


Assisted-by: Claude Code:claude-opus-4-8

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
raeq added a commit that referenced this pull request Jul 13, 2026
Patch release cutting the backlog since 0.11.0, lockstep across all four
registries (crates.io, PyPI, npm, RubyGems).

- Fixed: ml_normalize idempotent on NFKD-exposed symbol bases (#498)
- Fixed: normalize_confusables idempotent + complete on confusable+mark input,
  now iterated to a fixed point with an exhaustive Tier-3 gate (#523)
- Docs: unidecode() Cyrillic soft/hard-sign collision note (#511)
- Internal: binding publishers gated on the published core (#500); phf held
  at 0.13 to preserve MSRV 1.81 (#510)

Bump the version to 0.11.1 in every place it is recorded: Cargo.toml,
pyproject.toml, bindings/node/package.json, bindings/ruby/lib/disarm/version.rb,
CITATION.cff (citation metadata), and the disarm editable entry in uv.lock. The
binding glue crates stay at 0.0.0 with the minor-only `disarm_core = "0.11"` pin
— unchanged for a patch.

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Assisted-by: Claude Code:claude-opus-4-8
raeq added a commit that referenced this pull request Jul 13, 2026
Patch release cutting the backlog since 0.11.0, lockstep across all four
registries (crates.io, PyPI, npm, RubyGems).

- Fixed: ml_normalize idempotent on NFKD-exposed symbol bases (#498)
- Fixed: normalize_confusables idempotent + complete on confusable+mark input,
  now iterated to a fixed point with an exhaustive Tier-3 gate (#523)
- Docs: unidecode() Cyrillic soft/hard-sign collision note (#511)
- Internal: binding publishers gated on the published core (#500); phf held
  at 0.13 to preserve MSRV 1.81 (#510)

Bump the version to 0.11.1 in every place it is recorded: Cargo.toml,
pyproject.toml, bindings/node/package.json, bindings/ruby/lib/disarm/version.rb,
CITATION.cff (citation metadata), and the disarm editable entry in uv.lock. The
binding glue crates stay at 0.0.0 with the minor-only `disarm_core = "0.11"` pin
— unchanged for a patch.


Assisted-by: Claude Code:claude-opus-4-8

Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants