release: 0.11.1 - #522
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR cuts the 0.11.1 patch release for disarm by updating the published-version metadata across the Rust crate and language bindings, and by recording the release notes in the changelog.
Changes:
- Bump release version from
0.11.0→0.11.1in Rust, Python, Node, and Ruby version sources. - Add
0.11.1release notes toCHANGELOG.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pyproject.toml |
Bumps the Python package version to 0.11.1. |
CHANGELOG.md |
Adds the 0.11.1 release entry describing fixes/docs/internal changes since 0.11.0. |
Cargo.toml |
Bumps the Rust crate version to 0.11.1. |
bindings/ruby/lib/disarm/version.rb |
Bumps the Ruby binding version constant to 0.11.1. |
bindings/node/package.json |
Bumps the Node binding package version to 0.11.1. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
raeq
added a commit
that referenced
this pull request
Jul 13, 2026
…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
added a commit
that referenced
this pull request
Jul 13, 2026
…plete) (#523) `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. Assisted-by: Claude Code:claude-opus-4-8 Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Owner
Author
|
Good catch — fixed. Bumped CITATION.cff to 0.11.1 and regenerated uv.lock (disarm editable entry 0.11.0 → 0.11.1, no other dep churn). Swept the tree for any other stray 0.11.0: only the |
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Patch release cutting the backlog since 0.11.0 — lockstep across all four registries (crates.io, PyPI, npm, RubyGems).
What's in it
ml_normalizeis now idempotent on NFKD-exposed symbol bases (ml_normalize is non-idempotent on NFKD-decomposable symbols (≇ → ≅ → "approximately equal") #498) — the"cldr"preset no longer needs a second call to reach a fixed point on the 17-member negated-symbol class (e.g.≇→≅).unidecode()Cyrillic soft/hard-sign (ь/ъ) collisions, with a pointer to the script-awaretransliterate(…, lang=…)path ([bug] unidecode() silently drops Cyrillic soft/hard signs (ь ъ), causing distinct strings to collide #511).phf/phf_codegenheld at 0.13 to preserve MSRV 1.81 (chore(deps): group phf+phf_codegen and hold them at 0.13 (MSRV 1.81) #510).Version bump
0.11.0 → 0.11.1in the four full-version files (Cargo.toml,pyproject.toml,bindings/node/package.json,bindings/ruby/lib/disarm/version.rb). The binding glue crates stay at0.0.0with the minor-onlydisarm_core = "0.11"pin — unchanged for a patch, per the 0.11.0 precedent.Pre-release gate (local, all green)
cargo fmt --check, clippy (core + extension-module),perf_lint.sh, pyo3-leak guard — cleanmaturin buildproducesdisarm-0.11.1(abi3); version resolves to 0.11.1 across Rust/Python/Node/RubyPublishing is not triggered by this PR — it fires on the GitHub Release (
v0.11.1), which will be created separately after merge.Assisted-by: Claude Code:claude-opus-4-8