Skip to content

feat: duplicate_mark, and the key builders collapse a repeated mark (#835) - #874

Merged
raeq merged 2 commits into
mainfrom
feat/duplicate-mark
Sep 1, 2026
Merged

feat: duplicate_mark, and the key builders collapse a repeated mark (#835)#874
raeq merged 2 commits into
mainfrom
feat/duplicate-mark

Conversation

@raeq

@raeq raeq commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Closes #835. Refs #762.

The gap

UTS #39 §5.4 lists "forbid sequences of the same nonspacing mark" as an optional detection. None of it was implemented, and nothing else in the library covers it: a + two acutes is two marks, so no zalgo threshold reaches it, and it is one script, so mixed_script cannot see it. It renders indistinguishably from a + one acute.

>>> disarm.canonicalize("á́") == disarm.canonicalize("á")
False                      # two spellings of one word, two keys
>>> disarm.has_anomalies("á́")
False                      # and nothing says so

Both are now the other way round.

Two parts, and why they are separate

AnomalyKind::DuplicateMark reports after the zalgo rule. #724's enclosing-mark rule could go first because one enclosing mark per base is below every count threshold by construction, so the two can never both fire. A repeat has no such bound — four identical acutes are a repeat and a stack — and putting this first made every zalgo finding report as duplicate_mark instead.

Step::DropRepeatedMarks is a new pipeline step, deliberately not folded into the zalgo cap. #788 pairs strip_zalgo with is_zalgo so the cap never removes a mark from text the predicate calls ordinary — and two identical acutes are ordinary by that threshold. My first attempt put the dedupe inside strip_zalgo_into and broke the pairing on 540 strings. As its own step, both contracts hold:

>>> marks(disarm.strip_zalgo("Z" + "́" * 8))   # the cap, unchanged
3
>>> marks(disarm.canonicalize("Z" + "́" * 8))  # the cap + the repeat rule
1

It runs before the cap so the cap counts marks a reader can distinguish: a + five acutes + five graves capped first keeps three acutes and loses the grave entirely; deduplicating first keeps one of each.

Class-0 marks are out of scope, matching #842's discriminator — positioned rather than stacked, so a doubled Devanagari matra is an orthography question rather than this one.

Key movement

builder rows moved (of 22,977)
canonicalize 11
canonicalize_strict 13
sort_key 11
search_key, catalog_key, strip_obfuscation, normalize_confusables, fold_case 0

No row grew. KEY_SCHEMA_VERSION 2 → 3, fixture regenerated in the same commit, Upgrade note written.

Gate

The #850/#862 ordering gate now checks the new step as well as the cap — it has the identical hazard, since a zero-width between two acutes hides the repeat from it exactly as it hides the count from the cap. Mutation-checked: moving the step before the zero-width strip fails it with

canonicalize: Step::DropRepeatedMarks runs before Step::StripZeroWidth, which can delete a character from between two mark runs

Two existing tests were updated rather than weakened: test_real_zalgo_is_still_stripped now asserts the 3-vs-1 split above (and adds a distinct-marks case proving the cap itself is untouched inside canonicalize), and sort_key_zalgo_cap_runs_after_the_zero_width_strip switched to four distinct class-230 marks — its four-identical-acutes input would otherwise collapse for the new reason and stop testing the ordering it was written for.

Full gate green: cargo test 723, pytest 6,222, doc tests 41 pages, clippy both feature sets, perf_lint.sh, cargo doc, mkdocs --strict.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 1, 2026 15:56
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

📄 Docs preview: https://46cd4e11.disarm-docs.pages.dev

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.

🟡 Changes recommended

drop_repeated_marks_into currently performs redundant NFC normalization despite being immediately followed by the zalgo NFD→NFC step in key-builder pipelines, introducing avoidable hot-path overhead.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Implements UTS #39 §5.4’s optional detection for repeated nonspacing marks by adding a new anomaly kind and a dedicated key-builder pipeline step that collapses immediately repeated stacking marks, ensuring visually indistinguishable spellings (e.g. á́ vs ) collide in canonical keys while keeping strip_zalgo’s cap semantics unchanged.

Changes:

  • Add AnomalyKind::DuplicateMark detection (reported after zalgo) and document it.
  • Add Step::DropRepeatedMarks to the key-builder pipelines (canonicalize, canonicalize_strict, sort_key) and extend the ordering gate accordingly.
  • Bump KEY_SCHEMA_VERSION to 3 and update tests, docs, and Node binding typings for the new anomaly kind.
File summaries
File Description
tests/test_zalgo_cap.py Updates assertions to reflect dedupe-vs-cap behavior in canonicalize.
tests/test_anomalies.py Adds a reachability sample for the new duplicate_mark anomaly kind.
src/zalgo.rs Introduces drop_repeated_marks_into and its streaming predicate.
src/presets.rs Adds DropRepeatedMarks step, wires it into key builders, and expands ordering gate coverage.
src/api/metadata.rs Bumps KEY_SCHEMA_VERSION and documents key movement impact.
src/anomalies.rs Adds DuplicateMark kind, message text, and classification logic.
docs/user-guide/anomaly-detection.md Documents the new duplicate_mark anomaly in the user guide table.
docs/RUST_API.md Updates measured false-positive count and notes the change driver.
CHANGELOG.md Adds upgrade note + changelog entries for duplicate-mark key collapse and anomaly kind.
bindings/node/index.ts Extends the AnomalyKind union type with duplicate_mark.
Review details
  • Files reviewed: 10/11 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/zalgo.rs Outdated
…835)

UTS #39 §5.4 lists "forbid sequences of the same nonspacing mark" as an optional
detection. None of it was implemented, and the gap is not a small one: `a` +
two acutes renders exactly like `a` + one acute, so a reader cannot tell the two
spellings apart, and yet they produced different keys and reported clean at
every surface. Two marks is below every zalgo threshold, and one script is not
mixed, so no existing check could see it.

Two parts.

The detector gains `AnomalyKind::DuplicateMark`. It reports AFTER the zalgo
rule, not before it. #724's enclosing-mark rule could go first because one
enclosing mark per base is below every count threshold by construction, so the
two can never both fire; a repeat has no such bound — four identical acutes are
a repeat and a stack — and putting this first made every zalgo finding report as
`duplicate_mark` instead.

The key builders gain `Step::DropRepeatedMarks`, deliberately NOT folded into
the zalgo cap. #788 pairs `strip_zalgo` with `is_zalgo` so the cap never removes
a mark from text the predicate calls ordinary, and two identical acutes ARE
ordinary by that threshold — the repeat is a different fact about the text, not
a larger amount of the same one. Folding it in broke that pairing on 540
strings. As its own step the cap keeps its contract: `strip_zalgo("Z" + acute
× 8)` still keeps three marks while `canonicalize` of the same input keeps one.

The step runs BEFORE the cap so the cap counts marks a reader can distinguish:
`a` + five acutes + five graves capped first keeps three acutes and loses the
grave entirely, while deduplicating first keeps one of each.

Class-0 marks are out of scope, matching #842's discriminator — those are
positioned rather than stacked, so a doubled Devanagari matra is an orthography
question rather than this one.

Key movement: `canonicalize` 11 rows, `canonicalize_strict` 13, `sort_key` 11,
of 22,977. `search_key`, `catalog_key`, `strip_obfuscation`,
`normalize_confusables` and `fold_case` are byte-identical. No row grew.
KEY_SCHEMA_VERSION 2 → 3, fixture regenerated in this commit.

The #850/#862 ordering gate now checks the new step as well as the cap. It has
the identical hazard — a zero-width between two acutes hides the repeat from it
exactly as it hides the count from the cap — and a gate naming only
`Step::Zalgo(` would have gone on passing while a new step reintroduced the bug
it exists to catch. Mutation-checked: moving the step before the zero-width
strip fails it.

`tests/test_zalgo_cap.py` asserts the split between the two rules rather than
losing it, and `sort_key_zalgo_cap_runs_after_the_zero_width_strip` now uses
four DISTINCT class-230 marks — its four-identical-acutes input would otherwise
collapse for the new reason and stop exercising the ordering it was written for.

Refs #762
Closes #835

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
@raeq
raeq force-pushed the feat/duplicate-mark branch from 0603d25 to b848c2f Compare September 1, 2026 16:18
@raeq

raeq commented Sep 1, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto #873. Two conflicts, both resolved rather than picked:

While resolving that second one: #873 moved key output and left KEY_SCHEMA_VERSION at 2. tests/test_key_stability.py did not catch it, and cannot — it checks that the fixture header matches the constant, and gen_key_fixture.py writes the current constant into that header, so regenerating always agrees with whatever the constant happens to be. The counter's own doc comment names this exact failure ("Regenerating the fixture without bumping the constant — the exact way a counter like this goes stale") and the check aimed at it is anchored to the thing that drifts.

No action needed here — this PR's bump to 3 covers #723's movement too, so nothing shipped wrong. I'll raise the gate separately.

#874 review. `drop_repeated_marks_into` ran a full NFC pass even when the input
carried no repeated mark, and every pipeline using the step runs the zalgo cap
immediately after it — which does its own NFD→NFC pass. So the common case, text
with no repeat at all, paid for two full normalizations to arrive at the same
bytes.

`apply_into` already has a no-op signal for exactly this. The step now returns
`false` and leaves the scratch buffer untouched. Nothing here owed the pipeline
an NFC form: each step list carries an explicit `Step::Nfc` after the cap.

Output is unchanged — the key fixture is byte-identical, which is the check that
matters for a change made for speed.

The test asserts the *return value*, not the output. The output was correct
either way, which is what made this invisible to every other test in the file.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
@raeq
raeq merged commit 7bcd14d into main Sep 1, 2026
22 checks passed
@raeq
raeq deleted the feat/duplicate-mark branch September 1, 2026 16:41
raeq added a commit that referenced this pull request Sep 1, 2026
`KEY_SCHEMA_VERSION`'s doc comment claimed that regenerating the fixture without
bumping the constant "is a test failure rather than a silent lie".

It was not. `tests/test_key_stability.py` compared the fixture header against the
constant, and `scripts/gen_key_fixture.py` writes the current constant INTO that
header. So a regenerated fixture always agreed with whatever the constant
happened to be, stale or not. The gate was anchored to the thing it was meant to
watch — the third instance of that shape in this milestone, after #806's drift
gate comparing two derived lists and #850's ordering gate accepting the wrong
strip.

the fixture, did not touch `src/api/metadata.rs`, and stayed green with the
counter at 2. Nothing shipped wrong only because #874 bumped it in the same
unreleased cycle. That was luck, not the gate.

`KEY_FIXTURE_SHA256` is the anchor the generator does not author: the SHA-256 of
the fixture's decompressed bytes, on the line below the version. Regenerating
changes the digest and fails the gate, and fixing it means editing the file that
holds the version — with the version adjacent. Forgetting the bump becomes a
deliberate act rather than an invisible one.

Hashed decompressed rather than as stored, because gzip output carries a
timestamp and is not reproducible byte-for-byte.

The generator prints both lines to update, so this is a two-line edit rather
than a failing test to decode. The version's doc comment now says what is
actually guaranteed, rather than what was assumed.

Mutation-checked: simulating one moved key row fails the gate by name and prints
both digests.

Refs #644, #873, #874, #806, #762
Closes #887

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
raeq added a commit that referenced this pull request Sep 1, 2026
* fix: the key-schema gate could not detect a missed bump (#887)

`KEY_SCHEMA_VERSION`'s doc comment claimed that regenerating the fixture without
bumping the constant "is a test failure rather than a silent lie".

It was not. `tests/test_key_stability.py` compared the fixture header against the
constant, and `scripts/gen_key_fixture.py` writes the current constant INTO that
header. So a regenerated fixture always agreed with whatever the constant
happened to be, stale or not. The gate was anchored to the thing it was meant to
watch — the third instance of that shape in this milestone, after #806's drift
gate comparing two derived lists and #850's ordering gate accepting the wrong
strip.

the fixture, did not touch `src/api/metadata.rs`, and stayed green with the
counter at 2. Nothing shipped wrong only because #874 bumped it in the same
unreleased cycle. That was luck, not the gate.

`KEY_FIXTURE_SHA256` is the anchor the generator does not author: the SHA-256 of
the fixture's decompressed bytes, on the line below the version. Regenerating
changes the digest and fails the gate, and fixing it means editing the file that
holds the version — with the version adjacent. Forgetting the bump becomes a
deliberate act rather than an invisible one.

Hashed decompressed rather than as stored, because gzip output carries a
timestamp and is not reproducible byte-for-byte.

The generator prints both lines to update, so this is a two-line edit rather
than a failing test to decode. The version's doc comment now says what is
actually guaranteed, rather than what was assumed.

Mutation-checked: simulating one moved key row fails the gate by name and prints
both digests.

Refs #644, #873, #874, #806, #762
Closes #887

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>

* docs: correct why the digest is taken on decompressed bytes

#890 review. I blamed the gzip timestamp; `gen_key_fixture.py` already writes
with `mtime=0`, so that is not the source of drift.

The real reason is better: DEFLATE output varies across zlib builds and
compression-level changes even with a fixed mtime, so a digest over the stored
bytes would move when somebody's toolchain moved — a gate that cries wolf. The
decompressed content changes when, and only when, a key moved, which is the
question this constant exists to answer.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Richard Quinn <quinn.richard@gmail.com>

---------

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.

UTS #39 §5.4's "forbid sequences of the same nonspacing mark" is unimplemented: a + two identical acutes reports clean and does not collide with á

2 participants