fix: iterate normalize_confusables to a fixed point (idempotent + complete) - #523
Conversation
There was a problem hiding this comment.
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_confusablesidempotent 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_charsproperty 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.
…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
4995621 to
918db50
Compare
|
Resolved the Copilot thread as obsolete: it flagged |
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
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
…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
…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>
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
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>
The bug
normalize_confusablesisn't idempotent on some confusable-base + combining-mark inputs. Thenormalize_confusables_idempotentproptest 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 onmain.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:
The second case also breaks the completeness invariant: a one-shot recompose stops at
Ç, which is itself a confusable (is_confusablestays 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-internalnormalize_confusables_intois 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:exhaustive_fold_compose_idempotent_and_complete: every confusable source code point × every combining mark (~9M pairs, ~6s release), asserting idempotency and completeness.0counterexamples.tier3.yml(--lib -- --ignored) — also picks up a previously-orphanedpresetsfast-path sweep that the gate never ran.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.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