fix: a fold target must not itself be a source (#723) - #873
Merged
Conversation
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The behavioral change is applied at the data source, enforced by a stronger build-time invariant, and backed by targeted regression tests, with only minor test/message nits noted.
Pull request overview
This PR closes a confusables-table chaining hole where a fold target could itself be a fold source, causing single-pass callers (notably strip_obfuscation) to stop short of the fixed point that iterating callers already reached. The fix is applied at data-generation time, enforced at build time, and covered by a new regression test.
Changes:
- Resolve transitive target chains during
scripts/gen_confusables.pygeneration so fold targets are fixed points. - Strengthen
build.rs’s “target must not itself be a source” assertion to check every character of multi-character targets. - Update affected TSV rows, add a regression test suite, and document the upgrade impact in
CHANGELOG.md.
File summaries
| File | Description |
|---|---|
| tests/test_target_chains.py | Adds regression coverage for target-chaining, single-pass vs fixed-point agreement, and strip_obfuscation BMP fixed-point behavior. |
| src/tables/data/confusables_to_latin.tsv | Updates the two resolved rows (044B -> bi, 1D14 -> eo). |
| scripts/gen_confusables.py | Introduces _resolve_target_chains and applies it to generated mappings before writing TSVs. |
| CHANGELOG.md | Adds upgrade note + fixed entry describing key movement for stored strip_obfuscation outputs and the root cause/fix. |
| build.rs | Expands the build-time assertion to detect sources embedded anywhere in multi-character targets. |
Review details
- Files reviewed: 5/6 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
raeq
added a commit
that referenced
this pull request
Sep 1, 2026
Two findings, both correct. The non-convergence message said "two rows fold into each other", which is only one of the ways it can trigger -- a longer cycle, or a chain genuinely deeper than MAX_PASSES, look identical from inside the loop. It now names the likely cause without claiming it, and says the thing that matters: read the rows before raising the bound, because no bound fixes a cycle. test_strip_obfuscation_is_a_fixed_point_over_the_bmp called strip_obfuscation four times per code point -- twice on the left of the comparison and twice more nested on the right. Now two, with the first pass bound to a name, which is also the version a reader can follow. 0.05s to 0.03s. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
auto-merge was automatically disabled
September 1, 2026 15:33
Head branch was modified
044B ы mapped to ƅi, and ƅ (U+0185) is a source folding to b. So the entry points that iterate -- normalize_confusables, canonicalize -- reached bi, while the single-pass ones -- strip_obfuscation, and the confusables step inside get_pipeline -- stopped at ƅi. The issue's title names why it survived: the exhaustive idempotence gate tests the one function that iterates. A gate aimed at the forgiving caller cannot see a defect that only the strict one meets. Fixed in the data rather than in a consumer, which is the part that keeps it closed. gen_confusables.py resolves every target through the map until it is a fixed point, and build.rs's "a target must not itself be a source" assert now checks every character of every target -- it previously checked only single-character targets, which is exactly the hole ƅi fell through. The assert can hold only because the data satisfies it. Two rows resolved: 044B -> bi and 1D14 -> eo, the second a chain the issue does not name and which the widened assert found. strip_obfuscation is now a fixed point over the whole BMP, where it had two exceptions. 90 key-corpus rows move, all strip_obfuscation, all toward the answer the iterating callers already gave -- Apy6ƅi becomes Apy6bi. Carries an Upgrade note; KEY_SCHEMA_VERSION stays at 2, already bumped for this cycle. tests/test_target_chains.py asserts the property from three sides: no target contains a source (naming the row, where a build-script panic names only the file), the single-pass and iterating callers agree, and strip_obfuscation is a fixed point over the BMP. U+04F9 is deliberately excluded from the agreement test -- it carries a diaeresis, so the two functions differ there for a correct and unrelated reason, and comparing them would assert that accent preservation is a bug. Closes #723 Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
Two findings, both correct. The non-convergence message said "two rows fold into each other", which is only one of the ways it can trigger -- a longer cycle, or a chain genuinely deeper than MAX_PASSES, look identical from inside the loop. It now names the likely cause without claiming it, and says the thing that matters: read the rows before raising the bound, because no bound fixes a cycle. test_strip_obfuscation_is_a_fixed_point_over_the_bmp called strip_obfuscation four times per code point -- twice on the left of the comparison and twice more nested on the right. Now two, with the first pass bound to a name, which is also the version a reader can follow. 0.05s to 0.03s. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Richard Quinn <quinn.richard@gmail.com>
raeq
force-pushed
the
fix/multichar-target-chains-the-fold
branch
from
September 1, 2026 15:59
a8034ab to
bab77a4
Compare
raeq
enabled auto-merge (squash)
September 1, 2026 16:00
raeq
added a commit
that referenced
this pull request
Sep 1, 2026
#873 changed which fold targets are themselves sources, which moves five measurements across the confusable suites. All five are inside the noise floor, so the drift table reported them and flagged none — but the baseline should come from the tree it ships with rather than from the one before the rebase. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
raeq
added a commit
that referenced
this pull request
Sep 1, 2026
#873 changed which fold targets are themselves sources, which moves five measurements across the confusable suites. All five are inside the noise floor, so the drift table reported them and flagged none — but the baseline should come from the tree it ships with rather than from the one before the rebase. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
raeq
added a commit
that referenced
this pull request
Sep 1, 2026
#873 changed which fold targets are themselves sources, which moves five measurements across the confusable suites. All five are inside the noise floor, so the drift table reported them and flagged none — but the baseline should come from the tree it ships with rather than from the one before the rebase. Signed-off-by: Richard Quinn <quinn.richard@gmail.com> Assisted-by: Claude:claude-opus-5[1m]
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>
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
044Bы mapped toƅi, andƅ(U+0185) is a source folding tob:ыcanonicalize,normalize_confusables— iteratebistrip_obfuscation,get_pipeline's confusables step — one passƅiWhy it survived
The issue's title names it: the exhaustive idempotence gate tests the one function that iterates. A gate aimed at the forgiving caller cannot see a defect that only the strict one meets.
Fixed in the data, not in a consumer
That is the part that keeps it closed.
scripts/gen_confusables.pyresolves every target through the map until it is a fixed point.build.rs's "a target must not itself be a source" assert now checks every character of every target. It previously checked onlyif target.len() == 1— exactly the holeƅifell through.The assert can hold only because the data satisfies it.
Two rows resolved:
044B→bi, and1D14→eo(wasǝo) — a second chain the issue does not name, which the widened assert found.strip_obfuscationis now a fixed point over the whole BMP, where it had two exceptions.Keys move
90 rows, all
strip_obfuscation, all toward the answer the iterating callers already gave —Apy6ƅibecomesApy6bi.canonicalize,normalize_confusables,search_key,catalog_keyandsort_keyare byte-identical.Carries an Upgrade note.
KEY_SCHEMA_VERSIONstays at 2, already bumped for this cycle.Tests
tests/test_target_chains.pyasserts the property from three sides: no target contains a source (naming the row — a build-script panic names only the file), the single-pass and iterating callers agree, andstrip_obfuscationis a fixed point over the BMP.U+04F9is deliberately excluded from the agreement test. It carries a diaeresis, sostrip_obfuscationandcanonicalizediffer there for a correct and unrelated reason, and comparing them would assert that accent preservation is a bug. Its own claim — that the single-pass answer is now stable — is tested separately.Closes #723
🤖 Generated with Claude Code