Skip to content

test: guard the runtime sets both sides depend on - #52

Merged
michen00 merged 6 commits into
mainfrom
test/version-parity-guards
Sep 5, 2026
Merged

test: guard the runtime sets both sides depend on#52
michen00 merged 6 commits into
mainfrom
test/version-parity-guards

Conversation

@michen00

@michen00 michen00 commented Sep 5, 2026

Copy link
Copy Markdown
Owner

What changes, and why

Parity between the two implementations is only as stable as the sets they take from their runtimes — and one of those already moved: argparse's negative-number matcher changed in 3.14 and nothing went red (#30). This sweeps the rest of that class.

The transform depends on five runtime-defined sets. Three are already stated by the tool rather than by a runtime — the \d digit class (narrowed to [0-9]), the line boundaries (narrowed to three), and the negative-number matcher (#30). The other two are what this closes.

Whitespace — was half guarded

scan.rs writes the 29 code points out and carries a detector that fails if Rust's tables move; its own comment calls it "the drift detector for that choice." Nothing on the Python side asserted the same list, so an interpreter that changed str.strip() would have diverged from a Rust that could not follow. Both sides now pin the same list against their own runtime — they agree by construction rather than because two runtimes happen to match.

The Python side checks all three APIs that share the set — str.strip(), str.isspace(), and \s on a str pattern — because is_python_space is pinned as the equivalent of all three and the matcher constants (_MATCH_LIST, _MATCH_ALPHA_LIST, _MATCH_SETEXT, _MATCH_THEMATIC) use \s directly. A runtime that moved \s alone would otherwise leave a strip()-only guard green while the transform had already diverged. One sweep of the range covers all three, since the sweep is the cost of the guard and cannot be sampled without losing the additions it exists to catch.

Measured identical on 3.10, 3.11, 3.12, 3.13 and 3.14 (sha 690d49e0bc6f, 29 code points).

Lowercasing — was not guarded at all

The HTML block matcher folds a whole line and looks for an ASCII needle like </blockquote>, so a character whose lowercase contains ASCII can complete one. That set is 26 letters plus exactly two others:

U+0130  'I with dot'  -> two code points, an i and a combining dot
U+212A  'Kelvin sign' -> k

This is why the fold cannot be narrowed to to_ascii_lowercase. And the case tables do move — 1393 code points gained a lowercase mapping by 3.11, 1460 by 3.14 — so it is only luck that none of those additions landed in this set. Both implementations now pin it.

Rust's tables were spot-checked locally before pinning the values, across whatever toolchains this machine had (1.86 through 1.98) — identical digests for both sets, which is why the guard could be written against the current numbers without failing on arrival. That sample was opportunistic and is not the coverage.

The coverage is CI, and it is the two versions the MSRV policy already names: rust-test at the pinned 1.86.0 floor on three platforms, and rust-test-stable at whatever stable is on the day. Both run cargo test, so both run these guards — on this PR's last run rust-test-stable installed rustc 1.98.1 and executed python_whitespace_matches_rusts_view_plus_four and the_characters_that_fold_into_ascii_have_not_moved. That is also why the Rust guard belongs in cargo test rather than the CLI tier: the CLI tier is never run against a floating toolchain.

Two corpus cases

They cover the two characters, which is the whole of what that dependency can reach:

  • a-kelvin-sign-folds-into-an-html-closing-tag — the closing tag folds to </blockquote> and closes the block, so the prose after it joins. An ASCII-only fold leaves the block open to end of file and joins nothing, which is what the counts separate. Verified discriminating: an ordinary X in the same position leaves it open.
  • a-dotted-capital-i-does-not-close-an-html-block — folds to two code points and does not match. This is the case that says the fold feeds a containment test rather than an offset; a port that folded in place and then indexed by the result would read past its own line.

Every guard was checked by breaking it

$ # remove U+212A from the Rust expected set
assertion `left == right` failed: lowercasing now maps a different set onto ASCII
  left:  [65, ..., 90, 304, 8490]
  right: [65, ..., 90, 304]

$ # add U+3001 to the Python whitespace set
AssertionError: the interpreter no longer strips exactly this set;
gained [], lost ['U+3001']

is_python_space's doc comment also gains a re-measured date — it said "verified across all three on 3.10 and 3.13", and the range is now 3.10 through 3.14.

Merge position

Rebased onto main after #30 landed; the append conflict in tests/test_unwrap.py is resolved and both tests are present. It touches nothing that #37 or #49 touch.

Review rounds

Five findings were raised by Copilot and Qodo across three convergence rounds and fixed one commit each — the whitespace guard widened from one API to three (a2b31ab), the fold predicate corrected to compare the whole mapping rather than its first code point (bf164ae), the guard's range sweep reduced from three passes to one (a476a98, 0.45s to 0.14s), and two comments corrected to name what they meant (9d8bd88, 643ae81). Both reviewers are clean on the current head.

Corpus

The corpus is the specification, and both implementations answer to it. Tick what applies.

  • This changes no behavior the corpus specifies.
  • This changes what gets joined, and a case in corpus/ pins the new behavior. The case was written first and failed first.
  • The change makes the tool join more than it did. The section above says what it will not eat.

No behaviour changes — both implementations already did all of this. The cases and guards say so, which is the point: the parity was real and unasserted.

Checks

  • make check passes, or make test does and this touches no Rust.

make check — tidy, the Python suite, the 3.10 floor, rust-lint, rust-test and parity.


Closes #56 (review-convergence bulletin)

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Guard cross-runtime Unicode parity for whitespace and lowercasing

🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Pin Python and Rust whitespace and Unicode lowercase sets against runtime drift.
• Add corpus coverage for Kelvin-sign and dotted-I HTML closing tags.
• Document verified Unicode behavior across supported interpreter and compiler versions.
Diagram

graph TD
  PY["Python Unicode"] --> PT["Python guards"] --> SETS["Pinned sets"] --> CORPUS["Corpus cases"]
  RS["Rust Unicode"] --> RT["Rust guard"] --> SETS
Loading
High-Level Assessment

The PR’s approach is appropriate: each language validates its own runtime tables against explicit, identical expectations, while corpus cases confirm externally visible behavior. A shared generated fixture could reduce duplication, but would add cross-language loading and build complexity for two small, intentionally explicit sets.

Files changed (8) +146 / -2

Tests (8) +146 / -2
case.txtDefine dotted-capital-I HTML block expectations +4/-0

Define dotted-capital-I HTML block expectations

• Documents why U+0130 lowercases to a length-changing sequence that cannot match '</div>'. Records that the HTML block remains open and no prose is unwrapped.

corpus/cases/a-dotted-capital-i-does-not-close-an-html-block/case.txt

expected.mdCapture unchanged output for dotted-capital-I tag +5/-0

Capture unchanged output for dotted-capital-I tag

• Defines the expected document where the non-matching '</DİV>' tag leaves subsequent prose inside the HTML block.

corpus/cases/a-dotted-capital-i-does-not-close-an-html-block/expected.md

input.mdAdd dotted-capital-I closing-tag fixture +5/-0

Add dotted-capital-I closing-tag fixture

• Adds an HTML block containing '</DİV>' followed by wrapped prose to exercise length-changing Unicode lowercasing.

corpus/cases/a-dotted-capital-i-does-not-close-an-html-block/input.md

case.txtDefine Kelvin-sign HTML block expectations +4/-0

Define Kelvin-sign HTML block expectations

• Documents that U+212A lowercases to ASCII 'k', allowing the closing tag to match. Records one unwrapped paragraph and one removed line break.

corpus/cases/a-kelvin-sign-folds-into-an-html-closing-tag/case.txt

expected.mdCapture joined prose after Kelvin-sign tag +4/-0

Capture joined prose after Kelvin-sign tag

• Defines the expected output where the Kelvin-sign closing tag ends the HTML block and the following prose is joined.

corpus/cases/a-kelvin-sign-folds-into-an-html-closing-tag/expected.md

input.mdAdd Kelvin-sign closing-tag fixture +5/-0

Add Kelvin-sign closing-tag fixture

• Adds a blockquote closing tag containing U+212A and wrapped prose to verify non-ASCII lowercase matching.

corpus/cases/a-kelvin-sign-folds-into-an-html-closing-tag/input.md

scan.rsPin Rust lowercase-to-ASCII Unicode mappings +32/-2

Pin Rust lowercase-to-ASCII Unicode mappings

• Adds a full-Unicode test asserting that only ASCII uppercase letters, U+0130, and U+212A lowercase into sequences containing ASCII. Also expands the whitespace-set verification comment through Python 3.14.

src/scan.rs

test_unwrap.pyGuard Python whitespace and lowercase runtime sets +87/-0

Guard Python whitespace and lowercase runtime sets

• Adds exhaustive Unicode tests pinning the 29 characters removed by 'str.strip()' and the 28 characters whose lowercase mappings contain ASCII. Diagnostic assertions report code points gained or lost when interpreter Unicode tables drift.

tests/test_unwrap.py

@codecov-commenter

codecov-commenter commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.40%. Comparing base (5e34e5a) to head (a476a98).

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #52   +/-   ##
=======================================
  Coverage   87.40%   87.40%           
=======================================
  Files           3        3           
  Lines         691      691           
=======================================
  Hits          604      604           
  Misses         87       87           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@qodo-code-review

qodo-code-review Bot commented Sep 5, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Whitespace guard misses regexes 🐞
Description
The new drift guard enumerates only characters removed by str.strip(), but production matchers
also depend directly on regex \s. If those runtime-defined sets diverge, the test remains green
while Python behavior no longer matches Rust's pinned is_python_space set.
Code

tests/test_unwrap.py[468]

+    found = tuple(cp for cp in range(0x110000) if chr(cp).strip() == '')
Evidence
The new test claims to guard str.strip(), str.isspace(), and regex \s, but its only
enumeration invokes chr(cp).strip(). Production patterns use \s for list markers, setext
headings, thematic breaks, and other structural detection, while Rust deliberately represents all
three APIs with one explicit predicate.

tests/test_unwrap.py[420-468]
src/markdown_prose_hooks/unwrap.py[38-56]
src/markdown_prose_hooks/unwrap.py[79-80]
src/scan.rs[14-41]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The whitespace parity test checks only `str.strip()`, despite the Python implementation also using regex `\s`. It therefore does not fully guard all runtime-defined whitespace behavior on which parity depends.

## Issue Context
Rust pins one explicit set as the equivalent of Python's `str.strip()`, `str.isspace()`, and regex `\s`. The Python test should independently enumerate each relevant API against the same expected tuple rather than assuming they always remain identical.

## Fix Focus Areas
- tests/test_unwrap.py[420-473]
- src/markdown_prose_hooks/unwrap.py[38-56]
- src/scan.rs[14-41]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 9 rules
Review mode: 🚀 Fast: This is a localized test-performance refactor plus comment reflow, with no production behavior or high-risk surface changed.

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread tests/test_unwrap.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new Rust drift-detector test’s predicate can miss lowercase expansions that begin with the original code point, and one new Python test comment misstates what the Rust implementation includes.

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

Pull request overview

This PR strengthens cross-implementation parity by adding explicit “drift detector” tests that pin two runtime-dependent Unicode sets (Python whitespace stripping and full-Unicode lowercasing behavior relevant to HTML-block detection), and adds corpus cases covering the only two non-ASCII characters that can fold into ASCII closing-tag needles.

Changes:

  • Add Python unit tests that assert the interpreter’s str.strip() whitespace set and str.lower() “folds into ASCII” set remain exactly as expected.
  • Add a Rust unit test that asserts the same “folds into ASCII” set against Rust’s Unicode tables, and update is_python_space docs with a re-measured interpreter range/date.
  • Add two new corpus cases covering Kelvin sign and dotted capital I behavior in HTML closing tags.
File summaries
File Description
tests/test_unwrap.py Adds Python-side guards pinning str.strip() whitespace code points and str.lower() ASCII-reachable folds.
src/scan.rs Updates whitespace doc comment and adds a Rust-side guard test for lowercase-to-ASCII reachability.
corpus/cases/a-kelvin-sign-folds-into-an-html-closing-tag/input.md New corpus input covering Kelvin sign in a closing tag.
corpus/cases/a-kelvin-sign-folds-into-an-html-closing-tag/expected.md New corpus expected output demonstrating the block closes and prose unwraps.
corpus/cases/a-kelvin-sign-folds-into-an-html-closing-tag/case.txt New corpus metadata pinning the rationale and expected unwrap counts.
corpus/cases/a-dotted-capital-i-does-not-close-an-html-block/input.md New corpus input covering dotted capital I in a closing tag.
corpus/cases/a-dotted-capital-i-does-not-close-an-html-block/expected.md New corpus expected output demonstrating the block stays open and nothing unwraps.
corpus/cases/a-dotted-capital-i-does-not-close-an-html-block/case.txt New corpus metadata pinning the rationale and expected counts.
Review details
  • Files reviewed: 8/8 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 src/scan.rs
Comment thread tests/test_unwrap.py Outdated
Parity between the implementations is only as stable as the sets they
take from their runtimes, and one of those moved under this tool
already: argparse's negative-number matcher changed in 3.14 and nothing
went red. Sweeping for the rest of that class found the transform
depends on four such sets, three of which are already stated by the tool
rather than by a runtime -- the digit class, the line boundaries, and
now the negative-number rule.

The fourth is the whitespace set, and it was half guarded. `scan.rs`
writes the 29 code points out and has a detector that fails if Rust's
tables move. Nothing on the Python side asserted the same list, so an
interpreter that changed `str.strip()` would have diverged from a Rust
that could not follow. Both sides now pin the same list against their
own runtime, which is what makes them agree by construction rather than
by coincidence. Measured identical on 3.10 through 3.14.

The fifth is lowercasing, and it was not guarded at all. The HTML block
matcher folds a whole line and looks for an ASCII needle, so a character
whose lowercase contains ASCII can complete one. That set is 26 letters
plus exactly two others, and it is why the fold cannot be narrowed to
ASCII. The case tables do move -- 1393 code points gained a mapping by
3.11 and 1460 by 3.14 -- and none of those additions landed in this set
only by luck. Both implementations now pin it.

Two corpus cases cover the two characters, which is the whole of what
that dependency can reach: the Kelvin sign, which folds into an ASCII
needle and closes a block, and the dotted capital I, which folds to two
code points and does not. The second is the one that says the fold feeds
a containment test rather than an offset.

Every guard was checked by mutating its expected set and watching the
test name the character that moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michen00
michen00 force-pushed the test/version-parity-guards branch from 5ca1458 to d335ebf Compare September 5, 2026 05:09
The drift detector asked whether the first code point of the fold
differed from the char. Python asks whether the fold differs from the
char. Those part ways for a mapping that expands while keeping the
original first -- `X` becoming `Xy` -- which the Rust would drop
from the set and the Python would keep, so the two sides would
disagree about a character neither had been told about. No such
character exists today, which is exactly why the predicate has to be
right before one does.

Folded once and reused rather than walked twice.
`is_python_space` is pinned as the Rust equivalent of three Python
things at once -- `str.strip()`, `str.isspace()`, and `\s` on a `str`
pattern -- and the guard only asked the first. The matcher constants use
`\s` directly, so an interpreter that moved that one without moving
`strip()` would leave the guard green while the transform had already
diverged from the set the Rust holds.

All three are enumerated against the same expected tuple now, and the
failure names which one moved. Verified by breaking each arm in turn:
each reports itself and not the others. Measured identical across 3.10,
3.13 and 3.14, all three APIs, one 29-point set.
The comment called the C0 separators "the four Rust does not carry", and
`is_python_space` carries them explicitly -- they are half of why it
writes the set out by hand. What omits them is `char::is_whitespace`,
which is the thing the sentence meant and the thing a reader of a
parity guard needs named.
@michen00

michen00 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

/agentic_review

@michen00

michen00 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

[Review-Convergence] Round 1: active

  • Head: 9d8bd88
  • Base ref: main
  • Base: 5e34e5a
  • CI: pending
  • Bot threads: 0 unresolved (3 fixed and resolved this round)
  • Human threads: 0 unresolved
  • Clean signals: none — both prior reviews were against 5ca1458 and did not propagate across the rebase
  • Pending reviewers: Copilot, Qodo
  • Catch-up: none (base is an ancestor of the head)
  • Next action: wait for both reviews on the new head
  • Next wakeup: ~4min (reviewer-pending first tier)
  • Bulletin: Review convergence: PR #52 #56

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9d8bd88

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a couple of concrete correctness/maintainability issues in the newly added guard tests/comments (including a misnamed referenced function and avoidable full-Unicode rescans) that should be addressed before approval.

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

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread tests/test_unwrap.py Outdated
Comment thread src/scan.rs Outdated
The drift detector's comment credited the lowercase fold to
`match_html_block_open`. No such symbol exists; the function is
`match_opening_html_block`. A guard explaining why it pins a set should
be traceable to the code path that depends on it, and a name that
resolves to nothing breaks that trail at the first hop.
The guard has to cover the whole of Unicode to catch an addition
anywhere in it, so the sweep is the cost of the test -- and it was being
paid three times over for the same answer, with a fresh `chr` per code
point per API and the pattern recompiled on every call.

One pass now, one `chr` per code point, the pattern compiled once, the
three hit sets collected side by side. Same assertions and the same
failure messages, which still name the API that moved: each arm was
broken in turn and reported itself. 0.45s to 0.14s.
@michen00
michen00 requested a lite review from Copilot September 5, 2026 05:27
@michen00

michen00 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

/agentic_review

@michen00

michen00 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

[Review-Convergence] Round 2: active

  • Head: a476a98
  • Base ref: main
  • Base: 5e34e5a
  • CI: pending (green on 9d8bd88; re-running on the new head)
  • Bot threads: 0 unresolved (2 fixed and resolved this round)
  • Human threads: 0 unresolved
  • Clean signals: none — Qodo was clean on 9d8bd88, but this round's fixes touch PR-owned files so it did not propagate
  • Pending reviewers: Copilot, Qodo
  • Catch-up: none
  • Next action: wait for both reviews on a476a98
  • Next wakeup: ~4min (reviewer-pending first tier)
  • Bulletin: Review convergence: PR #52 #56

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a476a98

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new full-Unicode Rust/Python drift-detector sweeps introduce avoidable per-codepoint overhead (extra allocations / repeated chr()), which should be addressed to keep the test suite cost bounded.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

src/scan.rs:616

  • This drift-detector allocates a fresh String for c.to_string() on every iteration to compare against lowered. Over a full Unicode sweep this is a lot of extra allocation and can be avoided by comparing the lowered character sequence directly to c (while still correctly treating multi-code-point folds as different).
    tests/test_unwrap.py:575
  • In the Unicode sweep for the lowercasing drift detector, chr(cp) is computed twice per code point (chr(cp).lower() and then != chr(cp)). Since this runs over the full Unicode range, the extra chr() call is measurable overhead and can be avoided by storing the original character once.
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@michen00

michen00 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

[Review-Convergence] Round 3: converged-merge-blocked

  • Head: a476a98
  • Base ref: main
  • Base: 5e34e5a
  • CI: green (24 passing, 1 skipping, 0 failing)
  • Bot threads: 0 unresolved
  • Human threads: 0 unresolved
  • Clean signals: Copilot (review at a476a98, no findings), Qodo (summary updated to a476a98, all six categories 0)
  • Pending reviewers: none
  • Catch-up: none (base is an ancestor of the head)
  • Remaining blocker: REVIEW_REQUIRED — the approving review, which GitHub will not let the author supply
  • Bulletin: Review convergence: PR #52 #56

Five findings were raised and fixed across three rounds, one commit each. Two clean signals from two families now hold on the same head.

@michen00
michen00 merged commit 46d3a09 into main Sep 5, 2026
26 checks passed
@michen00
michen00 deleted the test/version-parity-guards branch September 5, 2026 05:42
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.

Review convergence: PR #52

3 participants