diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 9910573cc..06adabf46 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -35,6 +35,7 @@ read first. | **Silent** | `limit(i, i, 0)` | unevaluated | `0` | | **Silent** | `integral(i, i)` | `-1/2 + C` | `i_1 ^ 2 / 2 + C` | | | `lambda(i, i + 1)` | `InvalidArgumentParseException` | the lambda | +| **Silent** | `MathS.ToSympyCode` for any set, lambda, piecewise or non-vector matrix | Python that does not run — `NameError`, `SyntaxError`, `TypeError`, or an exception out of the exporter | Python that runs | | **Silent** | `"{ k : k > 0 }".ToEntity().FreeVariables` — and `Vars`, and `VarsAndConsts` | `{ %1 }`, a name in no expression | `{ }`, `{ k }`, `{ k }` | | **Silent** | the symbolic determinant of a matrix, substituted where a pivot vanishes — `[[0,1,2],[3,4,5],[6,7,8]]` through `[[a,b,c],[d,e,f],[g,h,i]]` | `NaN` | `0` | | **Silent** | `((Entity.Matrix)"[[x, 1], [2, y]]".ToEntity()).Determinant.Simplify()` | `x * y - 2 provided not x = 0` | `x * y - 2` | @@ -58,6 +59,66 @@ read first. | **Silent** | `integral(x, [x, y]T)` | `[[C + x ^ 2, C + x * y]]` | `[[x ^ 2 / 2 + C, x * y + C]]` | | **Silent** | `derivative(e ^ 2, e)`, over a named constant | `0` | `2 * e` | +### `ToSympyCode` emits Python that runs + +`MathS.ToSympyCode` is documented as producing code you can run in SymPy. For every set, every +lambda, every `piecewise` and every non-vector matrix, it did not — and the failure was **silent** +in the sense that matters here: the string came back looking like Python. + +The preamble is `import sympy` and nothing else, so every SymPy name in the body has to be +qualified. Six exports were not, and a seventh threw before it got that far: + +```python +expr = FiniteSet(1, 2) # was: NameError: name 'FiniteSet' is not defined +expr = Interval(0, 1, ...) # was: NameError: name 'Interval' is not defined +expr = Union(FiniteSet(1, 2), FiniteSet(3)) # was: NameError: name 'Union' is not defined +expr = x in S.Reals # was: NameError: name 'S' is not defined +expr = sympy.Lambda(x, ) # was: TypeError: missing 1 required positional argument +``` + +| input | was | now | +|---|---|---| +| `{ 1, 2 }` | `FiniteSet(1, 2)` | `sympy.FiniteSet(1, 2)` | +| `ZZ` | `S.Integers` | `sympy.S.Integers` | +| `{1,2} \/ {3}` | `Union(FiniteSet(1, 2), FiniteSet(3))` | `sympy.Union(sympy.FiniteSet(1, 2), sympy.FiniteSet(3))` | +| `lambda(x, sin(x) + 1)` | `sympy.Lambda(x, )` | `sympy.Lambda(x, sympy.sin(x) + 1)` | +| `{ x : x > 0 }` | `AngouriBugException` out of the exporter | `sympy.ConditionSet(x, x > 0, sympy.S.UniversalSet)` | +| `[sin(a); b]` | `Interval(sin(a), b, ...)` | `sympy.Interval(sympy.sin(a), b, ...)` | +| `piecewise(sin(x) provided x > 0, …)` | `sympy.Piecewise((sin(x), x > 0), …)` | `sympy.Piecewise((sympy.sin(x), x > 0), …)` | +| `[[sin(a), b], [c, d]]` | `sympy.ImmutableMatrix([[sin(a), b], …])` | `sympy.ImmutableMatrix([[sympy.sin(a), b], …])` | +| `x in RR` | `x in S.Reals` | `(sympy.S.Reals).contains(x)` | + +Three separate faults, all of them invisible to the tests that were there: + +- **Unqualified names.** `import sympy` binds `sympy`, not `FiniteSet` or `S`. +- **Parts interpolated rather than exported.** `Interval`, `Piecewise` and the non-vector `Matrix` + wrote their children with `{Left}` instead of `{Left.ToSymPy()}`. A bare variable spells the same + in both languages, so it only shows once the part is a function — `sin(a)`, which Python does not + have. +- **A set builder threw.** `ConditionalSet.Codomain` is `Domain.Any`, which `SpecialSet.Create` has + no member for, so the exporter's cast raised `AngouriBugException` — "please report about it to + the official repository", which is [#985](https://github.com/asc-community/AngouriMath/issues/985). + SymPy names that set: `S.UniversalSet`, which is the one it prints `ConditionSet` *without* a + third argument for. + +`x in RR` also changes shape rather than just qualification. Python's `in` coerces its result to a +`bool`, and a membership that is not decided is not one: `x in sympy.S.Reals` raises +`TypeError: did not evaluate to a bool: (-oo < x) & (x < oo)`. `.contains` answers with the +condition, and still answers `True` or `False` where it can. + +**Why the tests passed.** They asserted substring containment — `Assert.Contains("Piecewise((a, b), +(c, d))")` — which holds whether the parts were exported or interpolated, and holds on a program +that does not run at all. The new cases pin the whole emitted expression, and one of them asserts +every SymPy name carries its qualifier. + +Measured with `work/sympycheck`, which executes the generated program rather than reading it: of 45 +emitted programs the corpus now covers, **43 run** and 0 return an inexact value. The two that do +not are the set builders, whose preamble still declares the placeholder that +[#989](https://github.com/asc-community/AngouriMath/issues/989) is about — `%1 = sympy.Symbol('%1')` +is a `SyntaxError` whatever the body says. Their `expr` line is correct here, and with #989's fix +in as well, 45 of 45 run. Suite 7385 passed, 0 failed; corpus unchanged at 116/119 with 0 wrong. +[#985](https://github.com/asc-community/AngouriMath/issues/985). + ### A set builder's internal placeholder no longer escapes into `Vars` or `FreeVariables` `ConditionalSet.DirectChildren` is its predicate with the bound name renamed to a fresh one, so that diff --git a/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Omni.Classes.cs b/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Omni.Classes.cs index f135036be..3230a7408 100644 --- a/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Omni.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Omni.Classes.cs @@ -16,19 +16,28 @@ partial record Set partial record FiniteSet { internal override string ToSymPy() - => $"FiniteSet({string.Join(", ", Elements.Select(c => c.ToSymPy()))})"; + => $"sympy.FiniteSet({string.Join(", ", Elements.Select(c => c.ToSymPy()))})"; } partial record Interval { internal override string ToSymPy() - => $"Interval({Left}, {Right}, left_open={((Boolean)!LeftClosed).ToSymPy()}, right_open={((Boolean)!RightClosed).ToSymPy()})"; + => $"sympy.Interval({Left.ToSymPy()}, {Right.ToSymPy()}, left_open={((Boolean)!LeftClosed).ToSymPy()}, right_open={((Boolean)!RightClosed).ToSymPy()})"; } partial record ConditionalSet { + // A set builder's Codomain is Domain.Any, which SpecialSet.Create has no + // member for and throws on -- so every set builder threw an AngouriBugException + // out of the exporter. SymPy names that set: S.UniversalSet is the one it + // prints ConditionSet without a third argument for, which is what "no + // restriction beyond the predicate" means here too. + // https://github.com/asc-community/AngouriMath/issues/985 internal override string ToSymPy() - => $"ConditionSet({Var.ToSymPy()}, {Predicate.ToSymPy()}, {((SpecialSet)Codomain).ToSymPy()})"; + => $"sympy.ConditionSet({Var.ToSymPy()}, {Predicate.ToSymPy()}, {CodomainToSymPy()})"; + + private string CodomainToSymPy() + => Codomain is Domain.Any ? "sympy.S.UniversalSet" : SpecialSet.Create(Codomain).ToSymPy(); } partial record SpecialSet @@ -36,25 +45,25 @@ partial record SpecialSet partial record Integers { internal override string ToSymPy() - => "S.Integers"; + => "sympy.S.Integers"; } partial record Rationals { internal override string ToSymPy() - => "S.Rationals"; + => "sympy.S.Rationals"; } partial record Reals { internal override string ToSymPy() - => "S.Reals"; + => "sympy.S.Reals"; } partial record Complexes { internal override string ToSymPy() - => "S.Complexes"; + => "sympy.S.Complexes"; } internal override string ToSymPy() @@ -64,25 +73,30 @@ internal override string ToSymPy() partial record Unionf { internal override string ToSymPy() - => $"Union({Left.ToSymPy()}, {Right.ToSymPy()})"; + => $"sympy.Union({Left.ToSymPy()}, {Right.ToSymPy()})"; } partial record Intersectionf { internal override string ToSymPy() - => $"Intersection({Left.ToSymPy()}, {Right.ToSymPy()})"; + => $"sympy.Intersection({Left.ToSymPy()}, {Right.ToSymPy()})"; } partial record SetMinusf { internal override string ToSymPy() - => $"Complement({Left.ToSymPy()}, {Right.ToSymPy()})"; + => $"sympy.Complement({Left.ToSymPy()}, {Right.ToSymPy()})"; } partial record Inf { + // Python's `in` forces its result to a bool, and a membership that is not + // decided is not one: `x in sympy.S.Reals` raises "did not evaluate to a + // bool: (-oo < x) & (x < oo)". `.contains` is the form that answers with the + // condition instead, and it still answers True or False when it can. + // https://github.com/asc-community/AngouriMath/issues/985 internal override string ToSymPy() - => $"{Element.ToSymPy(Element.Priority < Priority)} in {SupSet.ToSymPy(SupSet.Priority < Priority)}"; + => $"({SupSet.ToSymPy()}).contains({Element.ToSymPy()})"; } } @@ -96,7 +110,7 @@ partial record Providedf partial record Piecewise { - internal override string ToSymPy() => $"sympy.Piecewise({string.Join(", ", Cases.Select(c => $"({c.Expression}, {c.Predicate})"))})"; + internal override string ToSymPy() => $"sympy.Piecewise({string.Join(", ", Cases.Select(c => $"({c.Expression.ToSymPy()}, {c.Predicate.ToSymPy()})"))})"; } partial record Matrix @@ -107,7 +121,7 @@ internal override string ToSymPy() IsVector switch { true => this.Select(c => c.ToSymPy()), - false => this.Select(c => $"[{string.Join(", ", ((Matrix)c).T)}]"), + false => this.Select(c => $"[{string.Join(", ", ((Matrix)c).T.Select(e => e.ToSymPy()))}]"), }) + "])"; } @@ -123,7 +137,7 @@ internal override string ToSymPy() partial record Lambda { internal override string ToSymPy() - => $"sympy.Lambda({Parameter.ToSymPy()}, )"; + => $"sympy.Lambda({Parameter.ToSymPy()}, {Body.ToSymPy()})"; } } } diff --git a/Sources/Tests/UnitTests/Convenience/ToSymPyTEst.cs b/Sources/Tests/UnitTests/Convenience/ToSymPyTEst.cs index b5daaca27..64bef2823 100644 --- a/Sources/Tests/UnitTests/Convenience/ToSymPyTEst.cs +++ b/Sources/Tests/UnitTests/Convenience/ToSymPyTEst.cs @@ -68,8 +68,11 @@ public class ToSymPyTest [InlineData(@"A \/ B", "Union(A, B)")] [InlineData(@"A /\ B", "Intersection(A, B)")] [InlineData(@"A \ B", "Complement(A, B)")] - [InlineData(@"a in B", "a in B")] - [InlineData("domain({ x : x > 0 }, RR)", "ConditionSet(x, x > 0, S.Reals)")] + // both of these recorded the old output, and neither of those outputs ran: + // `a in B` raises rather than answering, and `ConditionSet`/`S` are unqualified. + // https://github.com/asc-community/AngouriMath/issues/985 + [InlineData(@"a in B", "(B).contains(a)")] + [InlineData("domain({ x : x > 0 }, RR)", "sympy.ConditionSet(x, x > 0, sympy.S.Reals)")] [InlineData("sec(x)", "sympy.sec(x)")] [InlineData("csc(x)", "sympy.csc(x)")] [InlineData("arcsec(x)", "sympy.asec(x)")] @@ -91,6 +94,69 @@ public void TestSymPy(string expression, string expectedToBeIn, bool contains = else Assert.DoesNotContain(expectedToBeIn, MathS.ToSympyCode(ent)); } + + // Substring containment is what let these ship. `Assert.Contains("Piecewise((a, b), + // (c, d))")` passes whether the parts were exported or merely interpolated, because a + // bare variable spells the same in both languages -- and it passes on a program that + // does not run at all, because `import sympy` alone binds neither `FiniteSet` nor `S`. + // These pin the whole emitted expression instead. The harness that actually executes + // it is `work/sympycheck` in the analysis workspace. + // https://github.com/asc-community/AngouriMath/issues/985 + [Theory] + // named without the qualifier the preamble's lone `import sympy` requires: NameError + [InlineData("{ 1, 2 }", "sympy.FiniteSet(1, 2)")] + [InlineData("[0; 1]", "sympy.Interval(0, 1, left_open=False, right_open=False)")] + [InlineData("ZZ", "sympy.S.Integers")] + [InlineData(@"{1,2} \/ {3}", "sympy.Union(sympy.FiniteSet(1, 2), sympy.FiniteSet(3))")] + [InlineData(@"{1,2} /\ {2}", "sympy.Intersection(sympy.FiniteSet(1, 2), sympy.FiniteSet(2))")] + [InlineData(@"{1,2} \ {2}", "sympy.Complement(sympy.FiniteSet(1, 2), sympy.FiniteSet(2))")] + // a body that was never emitted: sympy.Lambda(x, ) is a SyntaxError + [InlineData("lambda(x, sin(x) + 1)", "sympy.Lambda(x, sympy.sin(x) + 1)")] + // a set builder threw AngouriBugException out of the exporter, because its codomain is + // Domain.Any and SpecialSet.Create has no member for it + [InlineData("{ x : x > 0 }", "sympy.ConditionSet(x, x > 0, sympy.S.UniversalSet)")] + // parts interpolated as this library spells them rather than as SymPy does -- visible + // only once the part is a function, since a bare name spells the same either way + [InlineData("[sin(a); b]", "sympy.Interval(sympy.sin(a), b, left_open=False, right_open=False)")] + [InlineData("piecewise(sin(x) provided x > 0, 1 provided x < 0)", + "sympy.Piecewise((sympy.sin(x), x > 0), (1, x < 0))")] + [InlineData("[[sin(a), b], [c, d]]", "sympy.ImmutableMatrix([[sympy.sin(a), b], [c, d]])")] + // Python's `in` forces a bool, so `x in sympy.S.Reals` raises rather than answering + // with the condition + [InlineData("x in RR", "(sympy.S.Reals).contains(x)")] + public void TheWholeEmittedExpressionIsWhatItShouldBe(string expression, string expected) + => Assert.Equal(expected, EmittedExpressionOf(MathS.FromString(expression))); + + private static string EmittedExpressionOf(Entity entity) + { + var code = MathS.ToSympyCode(entity); + var at = code.LastIndexOf("expr = ", System.StringComparison.Ordinal); + return code.Substring(at + "expr = ".Length).Trim(); + } + + // The preamble is `import sympy` and nothing else, so every SymPy name in the body has + // to carry the qualifier. Nothing was checking that, and six exports did not. + [Theory] + [InlineData("{ 1, 2 }")] + [InlineData("[0; 1]")] + [InlineData("RR")] + [InlineData(@"{1,2} \/ {3}")] + [InlineData("x in RR")] + [InlineData("lambda(x, sin(x))")] + [InlineData("piecewise(sin(x) provided x > 0, 1 provided x < 0)")] + public void EverySymPyNameIsQualified(string expression) + { + var code = EmittedExpressionOf(MathS.FromString(expression)); + foreach (var name in new[] + { "FiniteSet", "Interval", "Union", "Intersection", "Complement", + "ConditionSet", "Lambda", "Piecewise", "ImmutableMatrix", "S.", "sin" }) + foreach (System.Text.RegularExpressions.Match match in + System.Text.RegularExpressions.Regex.Matches( + code, System.Text.RegularExpressions.Regex.Escape(name))) + Assert.True( + match.Index >= "sympy.".Length + && code.Substring(match.Index - "sympy.".Length, "sympy.".Length) == "sympy.", + $"`{name}` is unqualified in `{code}`"); + } } } -