Skip to content

perf: narrow \d to ASCII [0-9] in metadata-matched regex patterns - #446

Merged
twcclegg merged 2 commits into
mainfrom
perf/ascii-digit-regex-classes
Aug 31, 2026
Merged

perf: narrow \d to ASCII [0-9] in metadata-matched regex patterns#446
twcclegg merged 2 commits into
mainfrom
perf/ascii-digit-regex-classes

Conversation

@twcclegg

@twcclegg twcclegg commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Changes

  • Narrows \d to [0-9] in every metadata-derived regex pattern that's actually matched against phone-number input: territory leadingDigits, internationalPrefix, nationalPrefixForParsing, numberFormat's pattern attribute (national and the intlNumberFormat_ copy), per-format leadingDigits, and nationalNumberPattern.
  • Why this is safe: .NET's \d (without RegexOptions.ECMAScript) matches the full Unicode "decimal digit" category — hundreds of non-ASCII characters — which is both broader and more expensive to match than a plain [0-9] range check. Upstream Java's \d defaults to ASCII-only ([0-9]) unless Pattern.UNICODE_CHARACTER_CLASS is set, and these patterns were authored/verified against that ASCII-only default, so this also closes a semantic gap from upstream, not just a perf one.
  • Why it's correct: every one of these patterns is only ever matched against input that's already passed through PhoneNumberUtil.Normalize/NormalizeDigits (which converts fullwidth/other-script digits to ASCII) or against GetNationalSignificantNumber, which is rebuilt from the numeric NationalNumber proto field and is ASCII by construction. Traced through the parse path (MaybeStripInternationalPrefixAndNormalize, ParseHelper/MaybeStripNationalPrefixAndCarrierCode), AsYouTypeFormatter (NormalizeAndAccrueDigitsAndPlusSign), and PhoneNumberMatcher (ParseAndVerify/ParseAndKeepRawInput) — full detail in commit a1204a2. PhoneNumberMatcher's free-text candidate-scanning regexes and PhoneNumberUtil's extension-parsing pattern are hand-written, not XML-metadata-derived, and are deliberately left untouched since they run on raw, un-normalized text.
  • The rewrite (BuildMetadataFromXml.NarrowDigitClassToAscii) is structure-aware, not a blind string replace: it tracks escape pairs and character-class entry/exit so \d already nested inside a class (e.g. [\d-]) narrows to [0-9-] rather than producing broken nested brackets, and handles the POSIX literal-]-as-first-member idiom. \D narrows to [^0-9] outside a class; inside a class it throws rather than silently mishandling it (doesn't occur in shipped metadata today — verified by grep across all four resource XML files).

Test plan

  • dotnet build csharp — clean on all three TFMs (netstandard2.0, net8.0, net10.0), zero warnings under TreatWarningsAsErrors + trim/AOT analyzers
  • dotnet test csharp/PhoneNumbers.slnx — 434 + 37 tests pass on net8.0 and net10.0, including new TestNarrowDigitClassToAscii theory tests covering real patterns from resources/PhoneNumberMetadata.xml, the nested-class case, [^\d], POSIX-literal-bracket shapes, escaped-backslash-before-d, and a match-semantics-preservation check
  • dotnet run -c Release --framework net10.0 -- --filter "*PhoneNumberWorkflowBenchmark*" / *ColdStartBenchmark* — modest, directionally consistent steady-state win (~5-9% on ParseOnly/ParseNationalFormat/ValidateOnly/FormatOnly, several exceeding their reported confidence margins), cold start flat as expected (this is a matching-cost change, not a JIT-cost change)

claude added 2 commits August 30, 2026 17:28
.NET's \d (without RegexOptions.ECMAScript) matches the full Unicode
"decimal digit" category -- hundreds of non-ASCII digit characters -- which
is both a broader and more expensive character-class check than a plain
[0-9] range test, and a semantic divergence from upstream: Java's \d
defaults to ASCII-only ([0-9]) unless Pattern.UNICODE_CHARACTER_CLASS is
set, and these metadata patterns were authored/verified upstream against
that ASCII-only default.

Correctness trace (why this is safe): every metadata-derived pattern that
is actually matched against phone-number input -- nationalNumberPattern,
leadingDigits (territory- and format-level), internationalPrefix,
nationalPrefixForParsing, and a numberFormat's pattern attribute (national
and, separately, the copy read again for intlNumberFormat_) -- is only
ever matched, at run time, against a string PhoneNumberUtil has already
normalized to ASCII 0-9:
  - PhoneNumberUtil.Normalize/NormalizeDigits and its callers
    (MaybeStripInternationalPrefixAndNormalize, ParseHelper,
    MaybeStripNationalPrefixAndCarrierCode) run before any metadata
    pattern match in the parse path.
  - IsValidNumber*, Format*, and ShortNumberInfo's checks all match
    against GetNationalSignificantNumber(Impl), which is rebuilt from the
    numeric NationalNumber field of an already-parsed PhoneNumber -- ASCII
    by construction, never the raw input string.
  - AsYouTypeFormatter accrues into `nationalNumber` exclusively through
    NormalizeAndAccrueDigitsAndPlusSign, which normalizes each character
    (including full-width digits) as it is typed, before any pattern match.
  - PhoneNumberMatcher's post-candidate-extraction verification
    (leniency.Verify) parses the candidate via ParseAndKeepRawInput first,
    so it goes through the same normalization as any other Parse call.
PhoneNumberMatcher's free-text *candidate-scanning* patterns (matching
phone-number-shaped substrings in arbitrary raw text) are hand-written C#
constants in PhoneNumberUtil.cs/PhoneNumberMatcher.cs (ValidPhoneNumber,
ExtnPattern, and PhoneNumberMatcher's own GeneratedRegex patterns) -- not
sourced from BuildMetadataFromXml's XML parsing -- and are deliberately
left untouched; they must stay Unicode-digit-aware since they run before
normalization.

Only the four resource XML files' *replacement-role* fields
(nationalPrefixTransformRule, nationalPrefixFormattingRule,
carrierCodeFormattingRule, format/intlFormat text,
preferredInternationalPrefix, preferredExtnPrefix) are left going through
the original ValidateRE -- they're never matched against input, only used
as Regex.Replace templates, and (verified by inspection) never contain \d
in the shipped metadata anyway.

Implementation: NarrowDigitClassToAscii is a small regex-structure-aware
rewriter (tracks escape pairs and character-class entry/exit, including
the POSIX literal-']'-as-first-member idiom) rather than a blind string
replace: a bare \d becomes [0-9], but a \d already inside a class (e.g.
[\d-]) substitutes the bare 0-9 in place ([0-9-]), never the broken nested
[[0-9]-]. \D narrows to [^0-9] outside a class; inside a class it throws,
since it does not occur anywhere in the four shipped XML files today and
cannot in general be soundly unioned into an existing bracket expression.
ValidateAndNarrowPatternRE wraps this ahead of the existing ValidateRE
compile-check, wired in at exactly the six matched-as-pattern call sites
above; every other ValidateRE call site is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8DATgGMQCmTpkg9dkdxks
…tations

Adds direct coverage of BuildMetadataFromXml.NarrowDigitClassToAscii
against real patterns pulled verbatim from resources/PhoneNumberMetadata.xml
(nationalNumberPattern, internationalPrefix, nationalPrefixForParsing,
numberFormat pattern), plus synthetic cases for shapes that don't currently
occur in the shipped metadata but the rewrite must still handle correctly:
\d already inside a character class (including with other members, a
leading '^', and the POSIX literal-']'-as-first-member idiom), an escaped
backslash immediately before a literal "d" (must not be read as \d), and
\D inside a class (must throw rather than silently emit something wrong).
Every narrowed pattern is also round-tripped through Regex's own
constructor, and one case additionally asserts the narrowed pattern
matches/rejects the same ASCII inputs as the original.

Also updates the handful of existing BuildMetadataFromXml/PhoneNumberUtil
tests that asserted a metadata pattern's literal string value (e.g.
"\\d{3}") to the now-narrowed "[0-9]{3}" -- these are metadata loaded via
the same BuildMetadataFromXml path production code uses, so the change in
expected literal text is the intended, correct effect of the narrowing,
not a regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8DATgGMQCmTpkg9dkdxks
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.61702% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.31%. Comparing base (0bf7478) to head (828ecea).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
csharp/PhoneNumbers/BuildMetadataFromXml.cs 93.61% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #446      +/-   ##
==========================================
+ Coverage   87.26%   87.31%   +0.05%     
==========================================
  Files          41       41              
  Lines        3831     3871      +40     
  Branches      978      989      +11     
==========================================
+ Hits         3343     3380      +37     
- Misses        284      286       +2     
- Partials      204      205       +1     

☔ 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.

Copy link
Copy Markdown
Owner Author

Local benchmark numbers

The automated performance comment only posts when compare-benchmarks.js's Welch's t-test clears a 20% relative-delta floor (see PhoneNumbers.PerformanceTest/README.md), and this change doesn't hit that — it's a real but modest steady-state win, not a dramatic one. Posting the local numbers manually since CI's own comment won't show them.

PhoneNumberWorkflowBenchmark (net10.0, Release, n=1000, single run before/after — not the multi-run statistical comparison CI does, so treat these as directional):

Method Before After Δ
ParseValidateAndFormatPhoneNumbers 3656.6 us 3641.1 us -0.4% (noise)
ParseOnly 615.2 us 579.6 us -5.8%
ParseNationalFormat 1238.1 us 1127.3 us -8.9%
ParseWithExtension 1633.9 us 1676.8 us +2.6% (noise — extension parsing uses the untouched hand-written ExtnPattern, so there's no mechanism for this one to improve)
ValidateOnly 989.0 us 941.4 us -4.8%
FormatOnly 1530.0 us 1438.2 us -6.0%

ColdStartBenchmark — flat, as expected (this narrows a character class matched during steady-state parsing/validation/formatting, not anything on the metadata-load or first-touch path):

Case Before After
CreateInstance 613.4 us ± 209.8 529.9 us ± 153.2 (within noise)
CreateInstanceAndLoadAllRegions 11653.0 us 11649.0 us
FirstRegionLookup 613.5 us 616.2 us

Generated by Claude Code

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.

2 participants