From 18d4971791be77d63dcc51b75a9c68a61fb9b39c Mon Sep 17 00:00:00 2001 From: Howard van Rooijen Date: Tue, 1 Sep 2026 20:31:15 +0100 Subject: [PATCH] Make short and enum symbols work A short symbol could not be solved at all, and neither could an enum. Two defects in series, either of which hid the other. Translation: C# widens short to int for a comparison, and an enum's TypeCode is that of its underlying type, so both arrive at the Int32 arm of the conversion switch - which assumed the target type told it the operand's Z3 sort and cast an IntExpr to RealExpr. The arm now checks the operand: a value already at integer sort passes through, and only a real goes to MkReal2Int. The Char arm three lines below already did exactly this. Marshalling: TypeCode.Int16 shared the Int32 arm and handed reflection an int, which a short member rejects. It gets its own arm with a checked cast. Checked, not plain: the symbol is an unbounded MkIntConst, so Z3 can pick a value no short can hold, and an unchecked cast wraps 40000 to -25536 - a wrong answer that looks like a right one. Raised as #87, which would bound the symbol so the situation cannot arise. Enums need nothing on the marshalling side. #63 predicted a second defect there; reflection converts an int to an enum member on its own, measured rather than assumed. Also removes a switch on the operand type whose only cases were empty breaks, and drops the two characterisation pins these replace. Co-Authored-By: Claude Opus 5 (1M context) --- .../SymbolTypeMarshallingTests.cs | 242 ++++++++++++++++-- .../UnsupportedExpressionTests.cs | 40 ++- solutions/Z3.Linq/ExpressionVisitor.cs | 12 +- solutions/Z3.Linq/Theorem.cs | 7 + 4 files changed, 260 insertions(+), 41 deletions(-) diff --git a/solutions/Z3.Linq.Tests/SymbolTypeMarshallingTests.cs b/solutions/Z3.Linq.Tests/SymbolTypeMarshallingTests.cs index e058428..69dd071 100644 --- a/solutions/Z3.Linq.Tests/SymbolTypeMarshallingTests.cs +++ b/solutions/Z3.Linq.Tests/SymbolTypeMarshallingTests.cs @@ -11,11 +11,11 @@ namespace Z3.Linq.Tests; /// which solution was chosen. /// /// -/// One of the types listed as supported still does not work, and is pinned as a characterisation -/// test rather than skipped - short (#63). It fails in the marshalling layer, which no example in -/// the repository exercises, which is why it went unnoticed. float was a second until #54 was -/// fixed, and DateTime a third until #56 - that one returned a value rather than throwing, so it -/// took a test asserting the value to find it at all. +/// Every type listed as supported now works. Three did not when this file was written: short +/// (#63), float (#54) and DateTime (#56). All three failed in the marshalling layer, which no +/// example in the repository exercises, which is why they went unnoticed. DateTime is the one +/// worth remembering - it returned a value rather than throwing, so only a test asserting the +/// value could find it at all. /// /// [TestClass] @@ -160,25 +160,192 @@ public void Solve_DoubleSymbolWithInequality_SatisfiesTheConstraint() } /// - /// KNOWN DEFECT (#63): no theorem over a short symbol can be solved. + /// A short symbol round-trips the value it was constrained to. /// /// - /// A short symbol is created with MkIntConst, but C# widens short to int for the comparison - /// and ExpressionVisitor.VisitUnary reads that Convert node as a real-to-int conversion, - /// casting the IntExpr to RealExpr (ExpressionVisitor.cs:132). A second defect waits behind - /// it: TypeCode.Int16 marshals to an int, which a short property rejects. - /// This test pins current behaviour and must be updated when the defect is fixed. + /// The case in #63, which had to be fixed twice over. C# widens short to int + /// for the comparison, and the visitor read that Convert node as a real-to-int + /// conversion and cast the IntExpr to RealExpr; behind that, + /// TypeCode.Int16 shared the Int32 arm of the marshalling switch and handed + /// reflection an int, which a short member rejects. Neither defect was + /// reachable while the other stood. + /// + [TestMethod] + [DataRow((short)0, DisplayName = "Zero")] + [DataRow((short)42, DisplayName = "Positive")] + [DataRow((short)-42, DisplayName = "Negative")] + [DataRow(short.MaxValue, DisplayName = "Int16.MaxValue")] + [DataRow(short.MinValue, DisplayName = "Int16.MinValue")] + public void Solve_ShortSymbol_RoundTripsTheValue(short value) + { + // Arrange + using var context = new Z3Context(); + + // Act + var result = context.NewTheorem>() + .Where(t => t.X1 == value) + .Solve(); + + // Assert + result.ShouldNotBeNull(); + result.X1.ShouldBe(value); + } + + /// + /// A short symbol whose model value no short can hold fails loudly. + /// + /// + /// + /// The symbol is an unbounded MkIntConst - nothing tells Z3 the value has to fit in + /// 16 bits - so a constraint written against the widened int can be satisfied by a + /// number the member cannot hold. The read is a checked cast, which throws; an unchecked one + /// would wrap 40000 to -25536 and hand back a wrong answer that looks like a right one. + /// + /// + /// C# blocks the direct spelling of this - t.X1 == 40000 against a short is + /// error CS0652 - so it takes an int variable to reach. Pinned because the + /// choice between wrapping and throwing is the whole point of the arm, and a later + /// simplification to a plain cast would pass every other test in this file. See #63, and + /// #87 for bounding the symbol so Z3 cannot pick the value in the first place. + /// /// [TestMethod] - public void Solve_ShortSymbol_ThrowsInvalidCastException() + public void Solve_ShortSymbolConstrainedOutsideShortRange_ThrowsOverflowException() { // Arrange using var context = new Z3Context(); - var theorem = context.NewTheorem>() - .Where(t => t.X1 == 7); + int beyondShortRange = 40000; + var theorem = context.NewTheorem>() + .Where(t => t.X1 == beyondShortRange); // Act & Assert - Should.Throw(() => theorem.Solve()); + Should.Throw(() => theorem.Solve()); + } + + /// + /// short symbols work in arithmetic, not only in a bare equality. + /// + /// + /// The fix guards a Convert node, and C# emits one wherever a short is used in + /// arithmetic - so a fix that covered only the comparison form would leave this failing. + /// + [TestMethod] + public void Solve_ShortSymbolsInArithmetic_RoundTripTheValues() + { + // Arrange + using var context = new Z3Context(); + + // Act + var result = context.NewTheorem>() + .Where(t => t.X1 + t.X2 == 10) + .Where(t => t.X1 == 4) + .Solve(); + + // Assert + result.ShouldNotBeNull(); + result.X1.ShouldBe((short)4); + result.X2.ShouldBe((short)6); + } + + /// + /// A short symbol can be compared against an int one. + /// + /// + /// Both sides widen to int, so this is the case where the guard has to leave one + /// operand alone and still produce a well-sorted comparison. + /// + [TestMethod] + public void Solve_ShortSymbolComparedToAnIntSymbol_RoundTripsBoth() + { + // Arrange + using var context = new Z3Context(); + + // Act + var result = context.NewTheorem>() + .Where(t => t.X1 == t.X2) + .Where(t => t.X2 == 9) + .Solve(); + + // Assert + result.ShouldNotBeNull(); + result.X1.ShouldBe((short)9); + result.X2.ShouldBe(9); + } + + /// + /// An enum symbol round-trips the member it was constrained to. + /// + /// + /// + /// An enum's TypeCode is that of its underlying type, so is + /// Int32 and takes the same path a short does - which is why the same guard + /// fixes both, as #63 records. + /// + /// + /// Unlike short, an enum needs nothing on the marshalling side: the model value is an + /// int, and reflection converts an int to an enum member on its own. #63 + /// predicted a second defect here and there is not one - measured, not assumed. + /// + /// + [TestMethod] + [DataRow(DayOfWeek.Sunday, DisplayName = "Underlying value zero")] + [DataRow(DayOfWeek.Monday, DisplayName = "Positive")] + [DataRow(DayOfWeek.Saturday, DisplayName = "Largest member")] + public void Solve_EnumSymbol_RoundTripsTheValue(DayOfWeek value) + { + // Arrange + using var context = new Z3Context(); + + // Act + var result = context.NewTheorem() + .Where(t => t.Day == value) + .Where(t => t.Other == 1) + .Solve(); + + // Assert + result.ShouldNotBeNull(); + result.Day.ShouldBe(value); + result.Other.ShouldBe(1); + } + + /// + /// An explicit (int) cast of a real-sorted symbol still converts. + /// + /// + /// + /// The other side of the guard #63 added. That guard makes the Int32 arm of the + /// conversion switch conditional on the operand's Z3 sort: an integer passes through, a real + /// goes to MkReal2Int. Only the first half is exercised by the short and enum + /// tests above, so without this one a guard widened to return the operand unconditionally + /// would pass the whole suite and silently stop converting reals. + /// + /// + /// Worth knowing that this path had no test before #63 either. It was reachable only from a + /// widening the visitor misread, so every execution of it threw - it was covered without + /// ever having worked. + /// + /// + [TestMethod] + public void Solve_DoubleSymbolCastToInt_ConvertsRatherThanPassingThrough() + { + // Arrange + using var context = new Z3Context(); + + // Act: the second constraint is what makes the truncation observable. Z3.s real-to-int + // is a floor, so together these ask for a real in (3.5, 4). Pass the operand through + // unconverted and they read as X1 == 3 and X1 > 3.5, which has no solution at all - so + // this fails by returning null rather than by returning a wrong number. + var result = context.NewTheorem>() + .Where(t => (int)t.X1 == 3) + .Where(t => t.X1 > 3.5) + .Where(t => t.X2 == 1) + .Solve(); + + // Assert + result.ShouldNotBeNull(); + result.X1.ShouldBeGreaterThan(3.5); + result.X1.ShouldBeLessThan(4d); + result.X2.ShouldBe(1); } /// @@ -508,18 +675,17 @@ public void Solve_MixedTypeSymbols_MarshalsEveryPropertyIndependently() /// /// /// - /// The tests above pin what each type does with a value; these seven pin what each does with + /// The tests above pin what each type does with a value; these eight pin what each does with /// no value at all. Every type takes a different arm of the marshalling switch, so each can /// regress on its own, and all but bool threw before #51. The value a free symbol /// comes back with is supplied by model completion and is deliberately not asserted. /// /// - /// short is absent on purpose: an unconstrained one evaluates cleanly and then fails - /// at the reflection write, which is #63 rather than anything to do with completion. Its pin - /// above is unchanged. float failed the same way until #54 was fixed, and now has a - /// case here like every other working type. DateTime never threw here, but read back - /// in local time until #56, so its case asserts the kind rather than only that a result - /// appeared. + /// Two of the eight could not be written when this family was: float and short + /// both evaluated cleanly under completion and then failed at the reflection write, which + /// was #54 and #63 rather than anything to do with completion. DateTime never threw + /// here, but read back in local time until #56, so its case asserts the kind rather than + /// only that a result appeared. /// /// [TestMethod] @@ -662,4 +828,36 @@ public void Solve_UnconstrainedDateTimeSymbol_ReturnsAResult() // path, so it can be asserted where the value cannot. result.X1.Kind.ShouldBe(DateTimeKind.Utc); } + + /// + /// An unconstrained short symbol is populated rather than throwing. + /// + /// + /// short could not be in this family until #63: it evaluated cleanly under completion + /// and then failed at the reflection write, so its absence here said nothing about + /// completion. It takes the Int16 arm of the marshalling switch, which no other test + /// in this family reaches. + /// + [TestMethod] + public void Solve_UnconstrainedShortSymbol_ReturnsAResult() + { + // Arrange + using var context = new Z3Context(); + + // Act + var result = context.NewTheorem>() + .Where(t => t.X2 == 0) + .Solve(); + + // Assert + result.ShouldNotBeNull(); + result.X2.ShouldBe(0); + } + + private sealed class EnumEnvironment + { + public DayOfWeek Day { get; set; } + + public int Other { get; set; } + } } diff --git a/solutions/Z3.Linq.Tests/UnsupportedExpressionTests.cs b/solutions/Z3.Linq.Tests/UnsupportedExpressionTests.cs index 2a980ed..8051e7f 100644 --- a/solutions/Z3.Linq.Tests/UnsupportedExpressionTests.cs +++ b/solutions/Z3.Linq.Tests/UnsupportedExpressionTests.cs @@ -114,26 +114,36 @@ public void Solve_NullableProperty_ThrowsArgumentException() } /// - /// KNOWN DEFECT (#63): an enum symbol fails the same way a short does. + /// An enum whose underlying type is not one the environment builder maps is rejected by + /// name, before anything is translated. /// /// - /// An enum's TypeCode is that of its underlying type, so the symbol is created as an - /// integer - and then C# emits a Convert to int for the comparison, which - /// ExpressionVisitor.cs:132 reads as a real-to-int conversion and casts to RealExpr. Same - /// root cause as short, so the same fix covers both. - /// This test pins current behaviour and must be updated when the defect is fixed. + /// + /// An enum is only ever as supported as its underlying type. #63 fixed the int-backed + /// case - and friends - which is now round-tripped in + /// SymbolTypeMarshallingTests. A byte-backed enum is TypeCode.Byte, + /// which the sort mapping has never handled, so it stops at the same guard that rejects a + /// bare byte or uint. + /// + /// + /// This is the outcome #63 asked for where a type genuinely is not supported: a + /// naming the member, rather than an + /// from inside the visitor. Pinned so the distinction + /// between the two enum cases does not quietly collapse. + /// /// [TestMethod] - public void Solve_EnumProperty_ThrowsInvalidCastException() + public void Solve_EnumPropertyWithAnUnsupportedUnderlyingType_ThrowsNotSupportedException() { // Arrange using var context = new Z3Context(); - var theorem = context.NewTheorem() - .Where(t => t.Day == DayOfWeek.Monday) + var theorem = context.NewTheorem() + .Where(t => t.Size == ByteBackedEnum.Large) .Where(t => t.Other == 1); // Act & Assert - Should.Throw(() => theorem.Solve()); + Should.Throw(() => theorem.Solve()) + .Message.ShouldContain("Size"); } [TestMethod] @@ -188,9 +198,15 @@ private sealed class NullableEnvironment public int Other { get; set; } } - private sealed class EnumEnvironment + private enum ByteBackedEnum : byte { - public DayOfWeek Day { get; set; } + Small = 1, + Large = 2, + } + + private sealed class ByteEnumEnvironment + { + public ByteBackedEnum Size { get; set; } public int Other { get; set; } } diff --git a/solutions/Z3.Linq/ExpressionVisitor.cs b/solutions/Z3.Linq/ExpressionVisitor.cs index 0824642..2d60cbc 100644 --- a/solutions/Z3.Linq/ExpressionVisitor.cs +++ b/solutions/Z3.Linq/ExpressionVisitor.cs @@ -117,17 +117,15 @@ private static Expr VisitConvert(Context context, Environment environment, Unary var inner = Visit(context, environment, expression.Operand, param); - switch (Type.GetTypeCode(expression.Operand.Type)) - { - case TypeCode.Int16: - case TypeCode.Int32: - break; - } - switch (Type.GetTypeCode(expression.Type)) { case TypeCode.Double: return context.MkInt2Real((IntExpr)inner); + case TypeCode.Int32 when inner.IsInt: + // A widening onto a value Z3 already holds at integer sort is a no-op: short to + // int, or an enum to its underlying int. Only a real operand needs converting, + // and reading the target type alone cannot tell the two apart. See #63. + return inner; case TypeCode.Int32: return context.MkReal2Int((RealExpr)inner); case TypeCode.Char: diff --git a/solutions/Z3.Linq/Theorem.cs b/solutions/Z3.Linq/Theorem.cs index b7f7377..6959c71 100644 --- a/solutions/Z3.Linq/Theorem.cs +++ b/solutions/Z3.Linq/Theorem.cs @@ -560,6 +560,13 @@ private static object ConvertZ3Expression(object destinationObject, Context cont value = val.String; break; case TypeCode.Int16: + // Int16 cannot share the Int32 arm: the model value is an int, and reflection + // refuses to write an int to a short member. The cast is checked because the + // symbol is an unbounded MkIntConst, so Z3 may return a value no short can + // hold - an unchecked cast would wrap it into a plausible wrong answer. + // See #63. + value = checked((short)((IntNum)val).Int); + break; case TypeCode.Int32: value = ((IntNum)val).Int; break;