Skip to content

fix: a fold target must not itself be a source (#723) - #873

Merged
raeq merged 2 commits into
mainfrom
fix/multichar-target-chains-the-fold
Sep 1, 2026
Merged

fix: a fold target must not itself be a source (#723)#873
raeq merged 2 commits into
mainfrom
fix/multichar-target-chains-the-fold

Conversation

@raeq

@raeq raeq commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Summary

044B ы mapped to ƅi, and ƅ (U+0185) is a source folding to b:

ы
canonicalize, normalize_confusablesiterate bi
strip_obfuscation, get_pipeline's confusables step — one pass ƅi

Why 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.py resolves 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 only if target.len() == 1 — exactly the hole ƅi fell through.

The assert can hold only because the data satisfies it.

Two rows resolved: 044Bbi, and 1D14eo (was ǝo) — a second chain the issue does not name, which the widened assert found. strip_obfuscation is 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ƅi becomes Apy6bi. canonicalize, normalize_confusables, search_key, catalog_key and sort_key are byte-identical.

Carries an Upgrade note. KEY_SCHEMA_VERSION stays at 2, already bumped for this cycle.

Tests

tests/test_target_chains.py asserts 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, and strip_obfuscation is a fixed point over the BMP.

U+04F9 is deliberately excluded from the agreement test. It carries a diaeresis, so strip_obfuscation and canonicalize differ 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

Copilot AI lite review requested due to automatic review settings September 1, 2026 15:10
@raeq
raeq enabled auto-merge (squash) September 1, 2026 15:10

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 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.py generation 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.

Comment thread scripts/gen_confusables.py
Comment thread tests/test_target_chains.py
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

raeq added 2 commits September 1, 2026 17:57
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
raeq force-pushed the fix/multichar-target-chains-the-fold branch from a8034ab to bab77a4 Compare September 1, 2026 15:59
@raeq
raeq enabled auto-merge (squash) September 1, 2026 16:00
@raeq
raeq merged commit 6600be8 into main Sep 1, 2026
24 checks passed
@raeq
raeq deleted the fix/multichar-target-chains-the-fold branch September 1, 2026 16:04
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>
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.

strip_obfuscation is not a fixed point: ы folds to ƅi, and the exhaustive idempotence gate tests the one function that iterates

2 participants