Skip to content

fix: llm_guardrail folds confusables again after the case fold (#852) - #871

Merged
raeq merged 4 commits into
mainfrom
fix/guardrail-fold-order
Sep 1, 2026
Merged

fix: llm_guardrail folds confusables again after the case fold (#852)#871
raeq merged 4 commits into
mainfrom
fix/guardrail-fold-order

Conversation

@raeq

@raeq raeq commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

A cased letter whose folded form is in the confusable table and whose original is not folded only on a second call:

>>> g = disarm.get_pipeline("llm_guardrail")
>>> g("Þ")        # THORN — no entry, case-folds to þ
'þ'
>>> g(g("Þ"))     # and only then folds to p
'p'

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.

before after
llm_guardrail non-fixed-point code points 126 10
sampled outputs changed 19, all recoveries, 0 losses

Why 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:

uppercase lowercase
Ð / ð D unmapped
Η / η H n
Ƨ / ƨ 2 unmapped

Pre-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 — Ηello became nello. 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.py asserts nothing outside that class appears in the residue, and that it does converge.

Shape

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

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings September 1, 2026 13:46
@raeq
raeq enabled auto-merge (squash) September 1, 2026 13:46

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.

🟢 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 confusables pass after fold_case in the core Rust pipeline engine when both steps are enabled.
  • Update step-order locks to reflect the additional confusables step in llm_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 that CONFUSABLES_POST is 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.

Comment thread src/pipeline.rs Outdated
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>

# Conflicts:
#	CHANGELOG.md
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

📄 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>
@raeq
raeq merged commit a6493b1 into main Sep 1, 2026
22 checks passed
@raeq
raeq deleted the fix/guardrail-fold-order branch September 1, 2026 15:35
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.

llm_guardrail folds case after the confusable fold, so 126 uppercase code points need a second pass

2 participants