Skip to content

fix(expr)!: match Python semantics in character classification functions - #341

Merged
mwiebe merged 7 commits into
OpenJobDescription:mainfrom
mwiebe:fix/issue-309-python-parity-predicates
Aug 25, 2026
Merged

fix(expr)!: match Python semantics in character classification functions#341
mwiebe merged 7 commits into
OpenJobDescription:mainfrom
mwiebe:fix/issue-309-python-parity-predicates

Conversation

@mwiebe

@mwiebe mwiebe commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes: #309

What was the problem/requirement? (What/Why)

Background: OpenJD job template expressions include string classification
functions — isdigit, isalpha, isalnum, and friends — that are named
after, and meant to behave like, Python's str methods. "Is this character a
digit?" sounds like it has one answer, but it doesn't: every language draws
its own lines through the Unicode character set. Rust's standard library
draws different lines than Python does, and this implementation was using
Rust's.

Issue #309 reported the visible symptom: for '٣' (the Arabic-Indic digit
three), isdigit said false while isalnum said true — the predicates
contradicted each other, because isdigit was checking only ASCII 0-9
while isalnum accepted anything Unicode calls alphabetic or numeric.

Comparing every one of Unicode's ~1.1 million code points against CPython
showed the problem was wider than the report:

  • isdigit was wrong for 878 characters (all non-ASCII digit systems, plus
    superscripts like ²)
  • isalpha/isalnum disagreed with Python on thousands of characters
    (Roman numerals like , combining marks, circled letters)
  • isspace missed four control characters Python counts as whitespace
  • isupper/islower ignored the wrong characters: Python skips uncased
    characters (so 'a五' is lowercase), this implementation skipped
    non-alphabetic ones

What was the solution? (How)

Since the expression language dialect is defined by Python's behavior, the
predicates now use lookup tables generated directly from CPython by a new
script, scripts/generate_unicode_tables.py. The script asks CPython itself
("is this character a digit to you?") for every code point, writes the
answers into crates/openjd-expr/src/functions/unicode_tables.rs as compact
range tables (checked in, currently CPython 3.14.3 / Unicode 16.0.0), and
verifies the generated tables round-trip exactly before writing. The six
predicates are now binary searches over those tables, with isupper/islower
implementing Python's cased-character rule.

To adopt a newer Unicode version later, rerun the script with a newer CPython.

What is the impact of this change?

The classification functions now return exactly what Python returns for any
input. Results change only for non-ASCII input (and the four isspace
control characters).

How was this change tested?

  • 22 new integration tests covering every divergence class found in the
    analysis, with CPython-verified expected values.
  • Generated tables carry embedded unit tests (well-formedness invariants and
    an exhaustive binary-search-vs-linear-scan check over all code points).
  • The generator itself asserts table/CPython agreement for all 1,114,112
    code points at generation time.
  • Full openjd-expr suite: 3,313 tests pass; cargo test --workspace green.
  • Full OpenJD conformance suite: 1,117 passed, 0 failed — including a new
    Unicode classification conformance test (submitted separately to
    openjd-specifications) that fails against the previous implementation and
    passes against this one.
  • Clippy -D warnings, rustfmt, and copyright header checks all clean.

Was this change documented?

Yes — specs/expr/function-library.md documents the Python-parity semantics,
the cased-character rule, and the table regeneration procedure. A companion
openjd-specifications PR clarifies RFC 0006 and the Expression Language spec,
which previously didn't say which character-class convention applies.

Is this a breaking change?

Yes — the commit is marked fix(expr)! with a BREAKING CHANGE footer.
Classification results change for non-ASCII input: e.g. isdigit('٣') is now
true, isalpha('Ⅻ') is now false, and islower('a五') is now true.
No code changes are required of users; templates that relied on the old
answers for non-ASCII input will see the Python-correct results.

Does this change impact security?

No.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@mwiebe
mwiebe requested a review from a team as a code owner August 21, 2026 23:39
@mwiebe
mwiebe force-pushed the fix/issue-309-python-parity-predicates branch from fa16d8f to f8350e2 Compare August 21, 2026 23:41
Comment thread crates/openjd-expr/src/functions/string.rs
Comment thread crates/openjd-expr/src/functions/string.rs
Comment thread crates/openjd-expr/src/functions/string.rs
Comment thread scripts/generate_unicode_tables.py
Comment thread crates/openjd-expr/src/functions/unicode_tables.rs Outdated
Comment thread crates/openjd-expr/src/functions/string.rs
Comment thread crates/openjd-expr/src/functions/string.rs Outdated

@leongdl leongdl 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.

Reviewed at tip 0258acf with the full suite green locally (3615 passed) and a mutation harness over the new behaviours — every claimed fix has a named falsifying test (7/7 mutants caught, including the table binary search being pinned by its own exhaustive binary-vs-linear test, which is a genuinely independent oracle). Expected values in the new integration tests reproduce 0-mismatch against a local CPython. Nice work — the generator-verifies-against-CPython-before-writing design is exactly right.

One real (low-severity, non-blocking) finding below on guard ordering, verified three ways rather than by inspection.

Comment thread crates/openjd-expr/src/functions/string.rs Outdated
@mwiebe
mwiebe force-pushed the fix/issue-309-python-parity-predicates branch from e8d9e1d to 45d3a9d Compare August 24, 2026 18:56
@mwiebe mwiebe changed the title fix(expr)!: match Python str semantics in character classification functions fix(expr)!: match Python semantics in character classification functions Aug 24, 2026
Comment thread crates/openjd-expr/src/functions/string.rs
Comment thread crates/openjd-expr/src/functions/conversion.rs
leongdl
leongdl previously approved these changes Aug 24, 2026
@leongdl

leongdl commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Reviewer orientation — TLDR and call stack (posted to help others review; verified locally at 45d3a9d, full suite 3,626 passed / 0 failed)

TLDR

The expression functions isdigit/isalpha/isalnum/isspace/isupper/islower are named after, and specified to behave like, Python's str methods — but they used Rust std classification, which draws different lines through Unicode (#309's symptom: isdigit('٣') false while isalnum('٣') true, self-contradictory). The three commits:

  1. Classification predicates → binary searches over range tables generated from CPython itself (scripts/generate_unicode_tables.py asks a running CPython about all 1,114,112 code points and verifies round-trip before writing). isupper/islower also gain Python's cased-character rule: ignore uncased chars, require ≥1 cased — so islower('a五') is now true.
  2. title()/capitalize() → CPython do_title/do_capitalize semantics: word boundaries at uncased chars (title('1st') = '1St'), ToTitleFull mapping (capitalize('džx')'Džx', not 'DŽx'), and the Final_Sigma context rule (Σ lowers to ς at word end, σ otherwise).
  3. int()/float() → closes a gap commit 1 created: once isdigit('٣') became true, the common guard int(Param.X) if isdigit(Param.X) else 0 passed the guard and then failed in int(), which was still ASCII-only. Nd digits are now normalized to ASCII before parsing, exactly like CPython's _PyUnicode_TransformDecimalAndSpaceToASCII. int('²') correctly stays an error (Numeric_Type=Digit, not Decimal), matching Python.

Call stack

job template "{{ isdigit(Param.X) }}"          (untrusted input)
└─ openjd-expr evaluator → FunctionLibrary dispatch (default_library.rs)
   └─ string.rs::isdigit_fn … islower_fn                      [CHANGED]
      ├─ ctx.count_string_ops(s.len())      pre-existing op-budget guard
      └─ unicode_tables::in_table(TABLE, c)                   [NEW]
         binary search over static CPython-derived (start, end) ranges

   └─ string.rs::title_fn / capitalize_fn                     [CHANGED]
      ├─ collect Vec<char> + ctx.check_memory (ordering under discussion above)
      ├─ push_titled(c)      → unicode_tables::title_mapping  (ToTitleFull)
      └─ push_lowered(..)    → is_final_sigma(..)             (Final_Sigma context)

"{{ int(Param.X) }}"
└─ conversion.rs::int_from_string / float_from_string         [CHANGED, commit 3]
   └─ normalize_decimal_digits(s.trim())                      [NEW]
      ├─ ASCII fast path → Cow::Borrowed (zero cost for the common case)
      └─ unicode_tables::decimal_digit_value(c)               [NEW]
         value = (cp − range_start) % 10   ← relies on the zero-aligned-run invariant
   └─ .parse::<i64/f64>()   → existing "Cannot convert" error path

All error paths return ExpressionError (Result), no panics added.

What makes the tables trustworthy (the crux of the review)

  • The generator verifies every emitted range table round-trips against CPython for all code points before writing, with an independent sweep (not the same code path that built the ranges).
  • The % 10 digit-value trick requires every DECIMAL range to be a zero-aligned run of ten; the generator checks each code point's value against unicodedata.decimal(). I independently re-verified the checked-in table against a local CPython: 760 code points, zero value/alignment errors (the only unverifiable entries were Unicode-16-only digit blocks my older host unicodedata doesn't know — a version-skew note, not a defect).
  • Mutation testing: 8 mutants across the three commits (revert isdigit to ASCII, weaken the cased rule, disable Final_Sigma, revert title word boundaries, revert ToTitleFull, off-by-one the table binary search, revert isspace, disable Nd normalization) — all 8 caught by named tests, including the guard-pattern test int_isdigit_guard_pattern_nd_digit for commit 3, and the table's own exhaustive binary-vs-linear test for the off-by-one (a genuinely independent oracle).

Known-open items are in the inline threads above: the check_memory-after-allocation ordering (acknowledged, fix pending) and the capture-name XID check cross-referenced from #337.

@mwiebe
mwiebe force-pushed the fix/issue-309-python-parity-predicates branch from 45d3a9d to 477af40 Compare August 24, 2026 23:20
mwiebe added 5 commits August 24, 2026 18:01
…nctions

isdigit/isalpha/isalnum/isspace/isupper/islower used Rust char predicates,
which draw from different Unicode properties than Python's str methods:

- isdigit used is_ascii_digit(), missing 878 code points (non-ASCII
  decimal digits like Arabic-Indic, and Numeric_Type=Digit characters
  like superscripts). This made isdigit inconsistent with isalnum for
  those characters, the inconsistency reported in the issue.
- isalpha used the Alphabetic property, a superset of Python's L*
  categories (extra: Nl, Other_Alphabetic marks Mn/Mc/So).
- isspace used White_Space, missing Python's U+001C..U+001F separators.
- isupper/islower filtered by alphabetic rather than Python's cased
  characters, diverging on uncased letters (e.g. CJK) and titlecase.

The predicates now use lookup tables generated directly from CPython by
scripts/generate_unicode_tables.py (currently CPython 3.14.3 /
Unicode 16.0.0), with an exhaustive all-code-points round-trip
verification at generation time. isupper/islower implement the
cased-character rule.

Fixes OpenJobDescription#309

BREAKING CHANGE: string classification results change for non-ASCII
input to match Python exactly — e.g. isdigit('٣') is now true,
isalpha('Ⅻ') is now false, isspace(FS/GS/RS/US) is now true, and
islower/isupper ignore uncased characters.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up to the classification predicate fix: the two
case-transforming functions in the same file also used Rust char
semantics and diverged from CPython.

- title() used is_alphanumeric() for word boundaries; Python's do_title
  advances words on uncased characters, so digits restart words
  (title('1st') == '1St').
- Both functions uppercased where Python titlecases: the dz-digraph
  U+01C6 must map to titlecase U+01C5, and word-start full mappings
  apply (title('ssß') == 'Ssß' start expansion, capitalize('ßx') ==
  'Ssx').
- Lowercasing the rest of a word ignored the Final_Sigma context rule
  (title('OΣ K') == 'Oς K'), which Rust's context-free
  char::to_lowercase cannot express.

The table generator now also emits TITLE_MAP (full ToTitleFull
mappings probed from CPython single-character str.title()) and a
CASE_IGNORABLE table (probed via CPython's Final_Sigma handling, since
unicodedata does not expose the property). title()/capitalize()
implement CPython's do_title/do_capitalize over those tables.

BREAKING CHANGE: title() and capitalize() results change to match
Python exactly — digits and other uncased characters now start new
words in title(), titlecase digraphs map to Lt forms instead of
uppercase, and U+03A3 lowers to final sigma where Python does.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
CPython's int() and float() replace Numeric_Type=Decimal characters
(general category Nd) with their ASCII values before parsing, so
int('\u0663') == 3. The expression functions previously parsed ASCII only,
which broke the guard pattern `int(Param.X) if isdigit(Param.X) else 0`
for Nd digits once isdigit() gained Python-parity Unicode semantics.

- Add a DECIMAL (Nd) table and decimal_digit_value() to the generated
  unicode tables; the generator verifies every range is a whole number
  of zero-aligned 0-9 runs so the value is (cp - range_start) % 10.
- Normalize Nd digits to ASCII in int_from_string and float_from_string.
  Numeric_Type=Digit characters like '\u00b2' remain errors, exactly as in
  CPython. Implicit string coercion (ExprValue::from_str_coerce) is
  deliberately unchanged.
- Document the semantics and remaining intentional divergences in
  specs/expr/function-library.md.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
CPython's str.strip/lstrip/rstrip and no-separator str.split/rsplit use
the same Py_UNICODE_ISSPACE predicate as str.isspace, which is Unicode
White_Space plus the information separators U+001C..U+001F. The
implementations used Rust's str::trim/split_whitespace (exactly
White_Space), so after isspace() gained the Python-parity SPACE table
the three disagreed: isspace('\u001c') was true but strip and split
ignored it. Route all of them through the SPACE table.

int()/float() trimming deliberately stays on Rust's str::trim: CPython's
int()/float() accept White_Space around the number but reject
U+001C..U+001F (int('\u001c5') raises even though isspace('\u001c') is
true), so White_Space is already the CPython-exact set there. Pinned by
test and documented in specs/expr/function-library.md.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
binary_search_matches_linear_scan did a full linear scan of the ranges
for each of the 1.1M code points (~290M iterator steps in the debug
profile), and title_map_is_well_formed's per-code-point find() over the
1,479-entry TITLE_MAP was worse (~1.6B steps). Replace both inner scans
with a forward-advancing cursor over the sorted entries — the same trick
verify() in the generator uses — keeping the exhaustive coverage of
every range boundary while making the sweeps linear overall.

Measured on the debug profile: the unicode_tables test module drops
from 2.65s to 0.27s.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the fix/issue-309-python-parity-predicates branch from 54bc72f to b6584df Compare August 25, 2026 01:10
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the fix/issue-309-python-parity-predicates branch from b6584df to ee81bc4 Compare August 25, 2026 01:12
Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe enabled auto-merge (squash) August 25, 2026 20:49
@mwiebe
mwiebe merged commit 634e8ba into OpenJobDescription:main Aug 25, 2026
22 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 25, 2026
@mwiebe
mwiebe deleted the fix/issue-309-python-parity-predicates branch August 25, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug(expr): isdigit/isalpha/isalnum are mutually inconsistent on Unicode-numeric, non-ASCII-digit input

2 participants