feat: duplicate_mark, and the key builders collapse a repeated mark (#835) - #874
Conversation
|
📄 Docs preview: https://46cd4e11.disarm-docs.pages.dev |
There was a problem hiding this comment.
🟡 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::DuplicateMarkdetection (reported afterzalgo) and document it. - Add
Step::DropRepeatedMarksto the key-builder pipelines (canonicalize,canonicalize_strict,sort_key) and extend the ordering gate accordingly. - Bump
KEY_SCHEMA_VERSIONto 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.
…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>
0603d25 to
b848c2f
Compare
|
Rebased onto #873. Two conflicts, both resolved rather than picked:
While resolving that second one: #873 moved key output and left No action needed here — this PR's bump to |
#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>
`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>
* 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>
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, somixed_scriptcannot see it. It renders indistinguishably froma+ one acute.Both are now the other way round.
Two parts, and why they are separate
AnomalyKind::DuplicateMarkreports 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 asduplicate_markinstead.Step::DropRepeatedMarksis a new pipeline step, deliberately not folded into the zalgo cap. #788 pairsstrip_zalgowithis_zalgoso 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 insidestrip_zalgo_intoand broke the pairing on 540 strings. As its own step, both contracts hold: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
canonicalizecanonicalize_strictsort_keysearch_key,catalog_key,strip_obfuscation,normalize_confusables,fold_caseNo row grew.
KEY_SCHEMA_VERSION2 → 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
Two existing tests were updated rather than weakened:
test_real_zalgo_is_still_strippednow asserts the 3-vs-1 split above (and adds a distinct-marks case proving the cap itself is untouched insidecanonicalize), andsort_key_zalgo_cap_runs_after_the_zero_width_stripswitched 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 test723,pytest6,222, doc tests 41 pages, clippy both feature sets,perf_lint.sh,cargo doc,mkdocs --strict.🤖 Generated with Claude Code