Report satisfiability separately from the solution - #86
HowardvanRooijen wants to merge 3 commits into
Conversation
Solve reports "no solution" by returning default(T). For a value-type environment that is a fully populated all-zero instance, which is also the answer to plenty of satisfiable theorems, and which cannot even be compared against null - the comparison is error CS0037. So an unsatisfiable theorem over a value tuple was indistinguishable from a solved one. TrySolve and TryOptimize return the status alongside the solution, for every environment type; SolveOrNull and OptimizeOrNull lift a value-type environment to Nullable<T> so the usual null checks apply. Solve is unchanged, so existing callers keep compiling. Optimize now returns T? rather than a non-nullable T it was filling with default!, which turned six unchecked dereferences in the optimisation tests into compiler errors. The workaround the original report suggested - declaring the environment as (int a, int b)? - was measured and does not work: it returns null when unsatisfiable but throws when there is a solution to write. Fixes #57. Fixes #58.
There was a problem hiding this comment.
Pull request overview
This PR adds new API surface to report satisfiability separately from the solution value, addressing the ambiguity where Solve()/Optimize() return default(T) for unsatisfiable theorems (indistinguishable from valid all-zero solutions for value-type environments).
Changes:
- Introduces
TrySolve(out T)andTryOptimize(..., out T)to return aboolsatisfiable flag alongside the solution. - Adds
SolveOrNull()/OptimizeOrNull(...)extension methods to lift value-type environments toNullable<T>for idiomatic null checks. - Updates docs and expands test coverage (including characterization tests and deferred
orderbypaths).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| solutions/Z3.Linq/Theorem{T}.cs | Adds TrySolve/TryOptimize, updates Optimize nullability, and updates deferred orderby to carry satisfiability. |
| solutions/Z3.Linq/Theorem.cs | Refactors core solve/optimize paths to implement TrySolve/TryOptimize and have Solve/Optimize wrap them. |
| solutions/Z3.Linq/SolveableExtensions.cs | Adds SolveOrNull / OptimizeOrNull for value-type environments via Nullable<T>. |
| solutions/Z3.Linq/ISolveable{T}.cs | Extends the interface with TrySolve(out T) and documents ambiguity of Solve(). |
| solutions/Z3.Linq.Tests/UnsatisfiabilityTests.cs | Adds a comprehensive test suite pinning ambiguity + validating the new APIs across tuples/structs/reference types and deferred orderby. |
| solutions/Z3.Linq.Tests/OptimizationTests.cs | Updates tests for Optimize now returning T? and adds satisfiability assertions before dereference. |
| README.md | Documents “When there is no solution” and points users to the new APIs. |
Suppressed comments (1)
solutions/Z3.Linq/Theorem{T}.cs:151
- The XML doc
<returns>forOrderByDescendingis inaccurate: it returns anISolveable<T>(deferred optimization), not an environment instance. Updating this avoids misleading API documentation.
/// OrderBy query operator, used to optimize a solution using query expression syntax.
/// </summary>
/// <typeparam name="TResult">Type of the value being maximized.</typeparam>
/// <param name="lambda">Expression representing the value to maximize.</param>
/// <returns>Environment type instance with properties set to theorem-satisfying values.</returns>
public ISolveable<T> OrderByDescending<TResult>(Expression<Func<T, TResult>> lambda)
=> new DeferredSolvable(() =>
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// </summary> | ||
| /// <typeparam name="TResult">Type of the value being minimized.</typeparam> | ||
| /// <param name="lambda">Expression representing the value to minimize.</param> | ||
| /// <returns>Environment type instance with properties set to theorem-satisfying values.</returns> |
There was a problem hiding this comment.
Correct on both, and on the suppressed one for OrderByDescending too - fixed in d63ad00.
Both operators return an ISolveable<T>, so the <returns> was describing what Solve on that
value eventually produces rather than what the operator hands back. They now say it is a deferred
minimization/maximization and that nothing reaches Z3 until Solve or TrySolve is called on the
result, which is the part a consumer actually needs to know. While in the same blocks I also fixed
OrderByDescending's summary, which called it "OrderBy query operator".
Worth being precise about why the build missed it: no project here sets GenerateDocumentationFile,
so doc comments are never parsed - but that would not have caught this one either. The XML is well
formed and every cref resolves; the sentence was simply about the wrong thing, and no compiler
setting detects that.
It did prompt me to check the part a setting can catch. With GenerateDocumentationFile turned on
temporarily, the whole solution compiles with no CS1570 and no CS1574 - so nothing is malformed and
every cref in the new API resolves, including TrySolve(out T) and
SolveableExtensions.SolveOrNull{TEnvironment}(ISolveable{TEnvironment}). I confirmed that check
actually reports something before trusting it, by breaking one cref deliberately and watching
CS1574 fire; my first two attempts at that control silently changed nothing, and a clean result
from a check that cannot fail would have been worthless. The project change is reverted - it belongs
to #82.
Both operators return an ISolveable<T> - a deferred optimisation - but their returns docs described the solution instead, which is what Solve on that value eventually produces. OrderByDescending's summary also called it OrderBy. No compiler setting would have caught this: the XML is well formed and every cref resolves, the sentence is simply about the wrong thing. Checked the rest of the way while here - with GenerateDocumentationFile temporarily on, the whole solution compiles with no CS1570 and no CS1574, verified against a deliberately broken cref to confirm the check reports one. The project change was reverted; it belongs to #82.
The remark on Optimize_WithUnsatisfiableTheorem_ReturnsNull said the #58 signature change turned "six unchecked dereferences" in the file into compiler errors. Six is the number of tests; the number of dereferences is ten. Measured by deleting the ten ShouldNotBeNull guards the fix added and building: ten CS8602s, one per guard. The sentence exists to say that the compiler found the callers, so the count carrying it should be the one a reader can reproduce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #57.
Fixes #58.
Answers #28, the original report.
The defect
Solve()reports an unsatisfiable theorem by returningdefault(T)(Theorem.cs:85-87). For areference environment that is
nulland reads correctly. For a value type it is a fully populatedinstance with every symbol zero - which is also the answer to plenty of satisfiable theorems:
Measured, not inferred: those two calls return values that compare equal. And the caller cannot even
ask, because
result is nullon a non-nullable value type iserror CS0037: Cannot convert null to '(int a, int b)' because it is a non-nullable value type.It is not tuple-specific. A
structand arecord structenvironment both come back as populatedall-zero instances from an unsatisfiable theorem, so a fix that special-cased
ValueTuplewouldhave looked complete and not been.
The workaround the original report offered does not work. #28 noted that declaring the
environment as
(int a, int b)?returnsnullwhen unsatisfiable, and suggested it as the wayround. Measured: it returns
nullfor the unsatisfiable case, and throwsArgumentException: Property set method not found.for a satisfiable one.Nullable<T>exposesHasValueandValue, both get-only, so the moment there is a solution to write the marshallinglayer fails - and the unsatisfiable case never reaches that code, which is why it looked like it
worked. Before this PR there was no way to distinguish, not even the documented one. Pinned as a
test.
The change
Additive.
Solve()behaves exactly as before, so the README samples, the Demo and every existingtest still compile.
bool TrySolve(out T)onTheorem<T>andISolveable<T>orderbyquery returnsbool TryOptimize<TResult>(Optimization, Expression<Func<T, TResult>>, out T)T? SolveOrNull<T>()whereT : structNullable<T>, soresult is nullcompiles and means what it saysT? OptimizeOrNull<T, TResult>(...)whereT : structSolveOrNullandOptimizeOrNullare extension methods rather than members because thestructconstraint that makes
T?meanNullable<T>cannot be applied to a member ofTheorem<T>, whoseTis unconstrained.SolveOrNullhangs offISolveable<T>, so it reaches the deferredorderbyform too.
Solve<T>andOptimize<T, TResult>are now thin wrappers over the twoTryforms, so there isone solve path rather than two copies of it.
#58, in the same change
Optimizewas declared to return a non-nullableTwhile returningdefault!when the status wasnot
SATISFIABLE. It now returnsT?, matchingSolve(). That is warning-level for consumers, notan error - but under this repository's
TreatWarningsAsErrorsit turned ten uncheckeddereferences across six tests in
OptimizationTests.csinto compiler errors, each of them a realunguarded read of a value the library could return null for. Those tests now assert satisfiability
before reading, as they should always have. That is the clearest evidence the signature was wrong:
the compiler found the callers the moment it was allowed to.
Measured, not counted by eye: deleting the ten guards the fix added and rebuilding produces ten
CS8602s, one per guard.One part of #58's diagnosis does not survive checking. It says
OrderBy/OrderByDescendinginherit the defect "since both wrap
Optimize". They do wrap it, but they hand backISolveable<T>, whoseSolve()has been declaredT?since the interface was introduced in217dc16- and still is onmain. The query-syntax route was always well-typed; only the directOptimizecall was not.OrderBy_OnAnUnsatisfiableTheorem_ReturnsNullsays so in a comment, sothe distinction is pinned rather than lost.
Both issues are fixed together because #57 says they are best designed together, and because any
shape that answers #57 has to cover
OptimizeandOrderByanyway - they shareGetSolutionandthe same
default!return.What is deliberately not changed
Status.UNKNOWNis still reported as "no solution". #57 raises this as a third wrinkle, and itstays open, for a measured reason:
Z3Contextbuilds its context from a fixed{ "MODEL", "true" }and exposes no
timeoutorrlimit, so a caller cannot ask Z3 to give up. I tried to produce anUNKNOWNthrough the public API with a nonlinear integer problem - the sum of three cubes - and ithad not returned after 120 seconds; the process had to be killed. Z3 searched rather than
abandoning. A three-state result would model a state that cannot currently be observed, so
TrySolvereturns abool, and the missing timeout is raised as #85, which gates a properanswer to the wrinkle.
Solve()keeps its ambiguity rather than being made unambiguous by a breaking change. That wasa deliberate choice of the additive shape; the ambiguity is now documented on
Solveitself, inISolveable<T>, in a README section, and pinned by a characterisation test that shows the twotheorems returning the same value.
Note for reviewers
ISolveable<T>gains a member. That is source-breaking for anyone who implemented the interfacethemselves - unlikely, since it exists to let
OrderBydefer, and both implementations are in thisrepository - but it is a real break and
next-versionis already2.0.Tests
187 -> 202, in a new
UnsatisfiabilityTests.cs, plus the two #58 pins inOptimizationTests.csrewritten now that the signature tells the truth.
Every negative is paired with a positive over the same value, because the whole difficulty is
that the two look identical. A test that only checked the unsatisfiable case would pass against an
implementation that always answered "no solution".
Solve_UnsatisfiableValueTupleTheorem_ReturnsWhatASatisfiableAllZeroTheoremReturnsTrySolve_UnsatisfiableValueTupleTheorem_ReturnsFalse/TrySolve_SatisfiableAllZeroValueTupleTheorem_ReturnsTrue(0, 0); only thebooldiffersTrySolve_SatisfiableTheorem_ReturnsTheSolutionSolveWouldHaveReturnedTrySolve_UnsatisfiableReferenceTypeTheorem_ReturnsFalseTrySolve_UnsatisfiableStructTheorems_ReturnFalsestructand arecord struct- the problem is the value type, not the tupleSolveOrNull_UnsatisfiableValueTupleTheorem_ReturnsNull/..._SatisfiableAllZeroValueTupleTheorem_ReturnsTheAllZeroSolutionNullable<T>formTryOptimize_UnsatisfiableTheorem_ReturnsFalse/TryOptimize_SatisfiableTheoremWhoseOptimumIsZero_ReturnsTrueOptimizeOrNull_UnsatisfiableTheorem_ReturnsNull/OptimizeOrNull_SatisfiableTheorem_ReturnsTheOptimumNullable<T>formTrySolve_OnADeferredOrderBy_ReportsSatisfiabilityEitherWaySolveOrNull_OnADeferredOrderByDescending_ReturnsTheOptimumSolve_NullableValueTupleEnvironment_ThrowsWhenTheTheoremIsSatisfiableMutation results
TrySolvereports satisfiable whatever the statusTrySolveinfers satisfiability from the solution instead of the statusTryOptimizeinfers satisfiability the same wayorderbycase, confirming the two solve paths are covered independentlySolveOrNull/OptimizeOrNullignore the status they were givenOptimizedeclared non-nullable againerror CS8603Verification
dotnet build solutions/Z3.Linq.slnx -c Release- clean,TreatWarningsAsErrorson./build.ps1 -Configuration Release- 46 tasks, 0 errors, 0 warningsSolveableExtensionsandDeferredSolvableare both at 100%Documentation
A
When there is no solutionsection in the README, after the examples: whatSolve()returns,why that is ambiguous for a value type, and the three forms that are not. The remarks on
Solve,OptimizeandISolveable<T>say the same thing at the call site.New issue
#85 - no way to bound a solve.
Z3Context's configuration is fixed, notimeoutorrlimitcan be set, and there is no
CancellationToken, so an undecidable theorem runs until the process iskilled. Found while testing whether
Status.UNKNOWNis reachable; it also explains why it is not.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.