Render real constants invariantly - #77
Conversation
ExpressionVisitor handed real constants to Z3 with the current culture, so a comma-decimal culture produced "1,5" and Z3's parser rejected it. Measured over the 606 specific cultures installed on this machine, 301 of them - about half - could not solve a theorem containing a real literal. The two culture tests move out of SymbolTypeMarshallingTests into a file of their own, joined by coverage for the negative-sign and non-comma-separator cases that a naive fix would miss. Fixes #52. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes culture-sensitive rendering of real (float/double/decimal) constants when translating C# expressions to Z3, ensuring literals are formatted using invariant culture so Z3’s parser consistently accepts them across locales (fixing #52). It also restructures and expands test coverage to pin culture-invariant behavior (including non-ASCII minus sign and non-comma decimal separator cases).
Changes:
- Format real-valued constants using
CultureInfo.InvariantCulturewhen callingContext.MkReal(...). - Move prior culture-specific tests out of
SymbolTypeMarshallingTestsand add a dedicatedCultureInvarianceTestssuite. - Update test comments that reference
ExpressionVisitorline numbers.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| solutions/Z3.Linq/ExpressionVisitor.cs | Uses invariant formatting for real constants before handing literals to Z3. |
| solutions/Z3.Linq.Tests/UnsupportedExpressionTests.cs | Updates line-number references in comments after code movement. |
| solutions/Z3.Linq.Tests/SymbolTypeMarshallingTests.cs | Removes culture-related tests and shared timeout from this suite. |
| solutions/Z3.Linq.Tests/RewriterTests.cs | Updates line-number references in comments after code movement. |
| solutions/Z3.Linq.Tests/CultureInvarianceTests.cs | Adds dedicated, threaded culture-invariance tests covering decimals, signs, separators, and control cases. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var thread = new Thread(() => | ||
| { | ||
| CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); | ||
|
|
||
| try | ||
| { | ||
| result = body(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| failure = ExceptionDispatchInfo.Capture(ex); | ||
| } | ||
| }); | ||
|
|
There was a problem hiding this comment.
Both taken, in d25a1e5 - the first as a correctness fix, the second as readability.
The background thread is a real finding. Measured with a process whose Main returns
after a bounded Join times out on a still-running thread:
IsBackground |
Process exited within 8s |
|---|---|
false (the default, what this had) |
no - still alive at 8s, killed |
true |
yes, after 246ms |
So the timeout was producing a stalled run rather than a clean failure, exactly as you describe.
The harness inherited that from the two tests it replaces, but it now backs six of them, so it
is worth getting right. Fixed, with the measurement recorded in a comment next to it.
GetCultureInfo("") was not relying on anything undocumented, though. The empty string is
the invariant culture's actual Name, and it resolves to it - measured: Equals true, LCID 127
(same as CultureInfo.InvariantCulture), decimal separator .. Not reference-equal, which does
not matter here.
What was genuinely poor was the call site: SolveOn(string.Empty, ...) in the control test
reads like an oversight, and the timeout message came out as "the solve on the thread did not
finish" with a hole in it. So rather than special-case the empty name - a branch guarding
against something that already works - SolveOn now takes the resolved CultureInfo. The
control test says CultureInfo.InvariantCulture, each culture test resolves its culture once
instead of twice, and the message falls back to "invariant" for the nameless one.
Verified: 155/155, and reverting the fix still fails exactly the same 10 tests as before the
change, so the harness rework did not blunt anything.
The solve threads were foreground, so one that outlived its bounded Join would hold the test host open indefinitely - measured: a process whose Main has returned does not exit at all while a foreground thread is still running. That turned the timeout from a clean failure into a stalled run. SolveOn now takes the resolved CultureInfo rather than a name, so the control test names the invariant culture instead of passing an empty string, and the timeout message has something to say for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #52.
The defect
ExpressionVisitorrendered every real constant with the ambient culture:Z3's parser accepts only
.as a decimal separator and only ASCII-as a sign, so under acomma-decimal culture the literal
1.5arrived as"1,5":It is much wider than the comma. Sweeping all 606 specific cultures installed on this
machine, solving
X1 == 1.5:parser errorAbout half of all cultures, including every widely used European language. The one merciful
thing is the last row: no culture produced a wrong answer, so the defect always announced
itself rather than quietly corrupting a model.
The separator is not the only character involved:
(1.5).ToString()(-1.5).ToString()1.5-1.51,5-1,51,5−1,5- U+2212 MINUS SIGN, not ASCII1٫5- U+066B-1٫5The change
One line, plus the
usingit needs:valis anobject, andobject.ToString()has no format-provider overload. TheIFormattablecast is safe here and preserves the existing formatting exactly: the switch is onType.GetTypeCode(val.GetType()), so the runtime type isfloat,doubleordecimal, all ofwhich implement it, and
ToString(null, provider)is what each type's ownToString(IFormatProvider)calls. Only the provider changes.The
using System.Globalization;shifts every line in the file by one, so the sixExpressionVisitor.cs:NNNcitations in the test suite are updated in the same commit. Each waschecked against its target line before and after.
The rest of the file was already right.
Convert.ToInt64for the integer arm performs noformatting, and the
MkString(val.ToString())two lines below isString.ToString(), whichreturns the instance. That asymmetry now has a test rather than being left to look like an
oversight.
Tests
145 → 155. The two culture tests move out of
SymbolTypeMarshallingTestsintoCultureInvarianceTests: the subject is the constant visitor rather than symbol marshalling,the dedicated-thread harness is now shared by six tests, and the extraction takes
System.ThreadingandSolveTimeoutout of a file that no longer needs them.The solve threads are background threads. Measured: a process whose
Mainhas returned does notexit at all while a foreground thread is still running, so a solve that outlived the bounded
Joinwould have held the whole test host open - turning the timeout from a clean failure intoa stalled run. The two tests this replaces had the same flaw; it is worth fixing once now that
six tests share the harness.
Solve_DoubleConstantUnderANonInvariantCulture_RoundTripsTheValueSolve_NegativeDoubleConstantUnderACultureWithANonAsciiSign_RoundTripsTheValueSolve_DecimalConstantUnderANonInvariantCulture_RoundTripsTheValueSolve_FloatConstantUnderANonInvariantCulture_FailsInMarshallingNotTranslationSolve_StringConstantUnderANonInvariantCulture_RoundTripsTheValueSolve_DoubleConstantUnderTheInvariantCulture_RoundTripsTheValueEach culture test first asserts that the culture really does render its probe differently from
the invariant one. Under globalization-invariant mode every culture falls back to invariant data
and these tests would otherwise pass while exercising nothing - a green that means the opposite
of what it looks like.
The
floatcase cannot be a round-trip test, because afloatsymbol cannot round-trip at all(#54). It asserts
ArgumentExceptioninstead: under the defect it threwZ3Exceptionduringtranslation, so reaching the marshalling failure is the evidence that the
TypeCode.Singlearm is now correct. Without it, a third of the fixed call site would be untested.
The issue says the defect was "deliberately not covered by a test", on the grounds that
CurrentCultureis per-thread and the suite runs in parallel at method level. That reasoningholds for setting the culture on the test's own thread, but the pin added later already solved
it by running the solve on a thread created for the purpose, which cannot leak anywhere. This
PR generalises that harness rather than working around the constraint again.
Mutation results
val.ToString()val.ToString().Replace(',', '.')- the naive fixMkReal("0")Verification
dotnet build solutions/Z3.Linq.slnx -c Release- clean,TreatWarningsAsErrorson./build.ps1 -Configuration Release- 46 tasks, 0 errors, 0 warningsalready reached, under a different culture
Found along the way
#76 - comparing a real symbol against a
floatvariable throwsInvalidCastException.C# inserts a
Convert(Single -> Double), andVisitUnarypicks the conversion from the targettype alone, so it treats an already-real operand as an integer. Same root cause as #63, one arm
along. Raised, not fixed.
Considered and rejected: running the whole suite under a foreign culture via an
AssemblyInitializehook. It would have caught this class of defect everywhere rather than atone call site, but it changes the environment of all 155 tests to catch a defect at one, and
makes every future failure culture-dependent to reproduce.
Release note
Releases remain on hold under #60 until Microsoft.Z3 5.x reaches nuget.org, so this reaches
mainbut not consumers. Nothing about the hold changes.