fix: llm_guardrail folds confusables again after the case fold (#852) - #871
Merged
Conversation
A cased letter whose folded form is in the confusable table and whose original is not folded only on the second call: Þ has no entry, case-folds to þ, and only then folds to p. Measured over the BMP, 126 code points behaved that way and the profile was not a fixed point. Fixed by running the confusable fold AGAIN after the case fold, in any pipeline that has both. 126 non-fixed-point code points become 10, and 19 sampled outputs change -- all recoveries, none a loss. Not by folding case first, which I tried and which is the wrong trade. 73 cased code points fold to a different target than their case pair: Ð folds to D where ð is unmapped, and Η folds to H where η folds to n. Pre-folding reaches the lowercase entry and loses the uppercase one outright, where the current order reaches it one pass later. A step-order lock caught it -- "Ηello" became "nello" -- which is the test earning its keep, and the measurement is now a test of its own so the reasoning is not lost. The remaining 10 are Cherokee small letters, whose confusable target is an uppercase Latin letter, so the pair has to run more than twice. They converge in two further passes, which means what closes them is a fixed-point loop -- the structure the presets use -- rather than another fixed pass. Recorded as a named class rather than an unexplained residue, with a test asserting nothing outside it appears. CONFUSABLES_POST is its own flag rather than a second CONFUSABLES entry, so STEP_ORDER still lists every flag exactly once and the pass exists only where a case fold precedes it. A pipeline without confusables gains no step: reporting one that does nothing would make explain() describe a mechanism the pipeline does not run. Closes #852 Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The functional change is well-scoped and thoroughly regression-tested; only minor comment/doc wording inconsistencies were noted.
Pull request overview
This PR fixes a non–fixed-point behavior in the llm_guardrail pipeline (and any pipeline enabling both confusables and fold_case) by re-running the confusable fold after case folding, ensuring a single call reaches the intended confusable-normalized result.
Changes:
- Add a second
confusablespass afterfold_casein the core Rust pipeline engine when both steps are enabled. - Update step-order locks to reflect the additional
confusablesstep inllm_guardrail. - Add a regression test suite capturing the original failure mode and bounding/characterizing the remaining residue; update docs and changelog accordingly.
File summaries
| File | Description |
|---|---|
src/pipeline.rs |
Introduces CONFUSABLES_POST and executes a second confusable normalization after fold_case when applicable. |
tests/test_policy_profiles.py |
Updates the pinned llm_guardrail step list to include the post–case-fold confusables pass. |
tests/test_llm_presets.py |
Updates llm_guardrail step-order lock to include the second confusables step. |
tests/test_guardrail_fold_order.py |
Adds focused regression tests covering the idempotency break and the documented residue behavior. |
docs/user-guide/normalize-first.md |
Updates documented step ordering to match the new behavior when both steps are enabled. |
CHANGELOG.md |
Documents the behavioral fix and rationale in the release notes. |
Review details
Suppressed comments (1)
src/pipeline.rs:575
- This comment refers to
FOLD_CASE_PRE, but that flag does not exist in this file; the new invariant here is thatCONFUSABLES_POSTis a distinct flag so STEP_ORDER can still list each flag exactly once.
// `FOLD_CASE_PRE` is its own flag rather than a second `FOLD_CASE` entry, so
// this stays an exactly-once check (#852).
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Signed-off-by: Richard Quinn <quinn.richard@gmail.com> # Conflicts: # CHANGELOG.md
|
📄 Docs preview: https://da2dff96.disarm-docs.pages.dev |
Signed-off-by: Richard Quinn <quinn.richard@gmail.com> # Conflicts: # CHANGELOG.md # src/pipeline.rs
…t taken (#871 review) The comment said case folding happens "BEFORE the folds below as well as after". That describes a draft of this fix that was tried and abandoned: fold_case is not moved, and what #852 adds is a second CONFUSABLES pass after it. The comment now says what the engine does, and records why the other shape was rejected -- 73 cased code points fold to a different target than their case pair, so pre-folding would lose the uppercase mapping outright rather than reaching it one pass later. A reader inferring a fold_case-pre step would be looking for something that does not exist and would not learn why. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
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.
Summary
A cased letter whose folded form is in the confusable table and whose original is not folded only on a second call:
Measured over the BMP: 126 code points behaved that way, so the profile was not a fixed point.
The fix is a second fold pass, not a fold placed first
Running the confusable fold again after the case fold, in any pipeline that has both.
llm_guardrailnon-fixed-point code pointsWhy not fold case first
I tried that. It also closes the class, and it is the wrong trade.
73 cased code points fold to a different target than their case pair:
Ð/ðDΗ/ηHnƧ/ƨ2Pre-folding reaches the lowercase entry and loses the uppercase mapping outright, where the current order reaches it one pass later.
A step-order lock caught it —
Ηellobecamenello. That measurement is now a test of its own, so the reasoning survives the PR.The residue is named, not unexplained
The remaining 10 are Cherokee small letters, whose confusable target is an uppercase Latin letter, so the pair has to run more than twice. They converge in two further passes — which means what closes them is a fixed-point loop, the structure the presets already use, rather than another fixed pass.
tests/test_guardrail_fold_order.pyasserts nothing outside that class appears in the residue, and that it does converge.Shape
CONFUSABLES_POSTis its own flag rather than a secondCONFUSABLESentry, soSTEP_ORDERstill lists every flag exactly once and the pass exists only where a case fold precedes it.A pipeline without
confusablesgains no step — reporting one that does nothing would makeexplain()describe a mechanism the pipeline does not run.Closes #852
🤖 Generated with Claude Code