Skip to content

Commit 289253b

Browse files
Rafael-SOWNetclaude
andcommitted
Emit SymPy code that runs (#909)
MathS.ToSympyCode is documented as generating code you can run in SymPy, and for two whole classes of expression it emitted code that did not run. Rational's exporter was missing its closing parenthesis -- sympy.Rational(1, 2 -- so every expression carrying a non-integer rational produced SyntaxError: '(' was never closed. That is most of what a computer algebra system hands back. Real emitted Stringize(), so the three non-finite values arrived as this library spells them: NaN, +oo, -oo. The generated preamble binds a sympy.Symbol for each free variable, and none of those three is a variable, so the program stopped with NameError. They now use SymPy's own spellings, sympy.nan, sympy.oo and -sympy.oo. Verified by running the emitted programs against SymPy 1.14 rather than by reading them, which is also how it was established that they come back exact: 1/2 arrives as SymPy's Half and not as the float 0.5. Eight expressions, previously two failures and six imprecise or broken, now zero failures. Nothing else changes. pi, e and i were already sympy.pi, sympy.E and sympy.I, and sqrt was already sympy.sqrt -- measured before touching them, since the same-shape guess was wrong there. The two new tests hold the properties that failed, without an interpreter in the suite: the parentheses balance, and every name in the emitted body is either declared in the preamble or reached through sympy. 17 of their 23 cases fail against the old exporter and 23 pass here. ToSymPy is called only from ToSympyCode, so evaluation and simplification cannot see this; suite 6474 passed, F# wrapper 130 passed, casbench 117/119 with 0 wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 548ea17 commit 289253b

3 files changed

Lines changed: 168 additions & 2 deletions

File tree

BREAKING-CHANGES.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ read first.
2828
| **silent** | `sqrt(x^2)`, `sqrt(-x)` and their kind | `x`, `i*sqrt(x)` — wrong for negative x | left as written |
2929
| loud | `mod` as a variable name | a variable | a keyword, so a parse error |
3030
| loud | `NaN` as a variable name | a variable | a keyword, so the NaN value |
31+
| loud | `MathS.ToSympyCode` of any non-integer rational | `SyntaxError` — a parenthesis was never closed | code that runs |
32+
| loud | `MathS.ToSympyCode` of `NaN`, `+oo`, `-oo` | `NameError` — the name is never bound | `sympy.nan`, `sympy.oo`, `-sympy.oo` |
3133
| **silent** | `NaN` printed and read back | a variable of that name, which cancels and collects | the NaN value |
3234
| **silent** | `Stringize` of powers, lambdas, applications, piecewises | did not parse back | parses back |
3335
| **silent** | `Stringize` of a complex number with a fractional imaginary part | read back as its negation, or as a power | parses back |
@@ -1009,6 +1011,35 @@ disagreements over 10463 expressions goes **30 to 0**.
10091011
[#752](https://github.com/asc-community/AngouriMath/issues/752), PR
10101012
[#758](https://github.com/asc-community/AngouriMath/pull/758).
10111013

1014+
### `MathS.ToSympyCode` emits Python that runs
1015+
1016+
Its documented purpose is code you can run in SymPy, and for two whole classes of expression it
1017+
emitted code that did not run at all.
1018+
1019+
| expression | was | is |
1020+
|---|---|---|
1021+
| `1/2`, `1/3 + 1/6` | `sympy.Rational(1, 2``SyntaxError: '(' was never closed` | `sympy.Rational(1, 2)` |
1022+
| `0/0`, `1/0` | `NaN``NameError: name 'NaN' is not defined` | `sympy.nan` |
1023+
| `+oo`, `-oo` | `+oo`, `-oo``NameError` | `sympy.oo`, `-sympy.oo` |
1024+
1025+
The first is a missing parenthesis, and it broke **every** expression carrying a non-integer rational,
1026+
which is most of what a computer algebra system hands back. The second is a value with no binding: the
1027+
generated preamble declares a `sympy.Symbol` for each free *variable*, and a `NaN` or an infinity is
1028+
neither a variable nor something SymPy names the same way this library does.
1029+
1030+
Both were checked by running the emitted programs against SymPy 1.14 rather than by reading them, which
1031+
is also how it was established that they now come back **exact**`1/2` arrives as SymPy's `Half` and
1032+
not as the float `0.5`.
1033+
1034+
Nothing else about the exporter changes. `pi`, `e` and `i` were already emitted as `sympy.pi`,
1035+
`sympy.E` and `sympy.I`, and `sqrt` as `sympy.sqrt`.
1036+
1037+
Two tests now hold the properties that failed, without needing an interpreter in the suite: the
1038+
parentheses balance, and every name in the emitted body is either declared in the preamble or reached
1039+
through `sympy.`. 17 of their 23 cases fail against the old exporter.
1040+
1041+
Issue [#909](https://github.com/asc-community/AngouriMath/issues/909).
1042+
10121043
### `NaN` is now a keyword, and the printed form of NaN reads back
10131044

10141045
`Stringize` prints the NaN value as `NaN`, and the grammar had no such token, so reading it back gave a

Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Number.Classes.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,34 @@ internal override string ToSymPy()
2525

2626
partial record Real
2727
{
28+
/// <summary>
29+
/// A finite real prints as its decimal, which Python reads as a float. The three
30+
/// non-finite ones have no such reading: this library spells them <c>NaN</c>,
31+
/// <c>+oo</c> and <c>-oo</c>, and <c>ToSympyCode</c>'s preamble binds a name only
32+
/// for each free <em>variable</em>, so they arrived in the generated program as
33+
/// bare names and it stopped with <c>NameError: name 'NaN' is not defined</c>.
34+
/// SymPy's own spellings are used instead.
35+
/// https://github.com/asc-community/AngouriMath/issues/909
36+
/// </summary>
2837
internal override string ToSymPy()
29-
=> Stringize();
38+
=> this switch
39+
{
40+
{ IsFinite: true } => Stringize(),
41+
{ IsNaN: true } => "sympy.nan",
42+
{ IsNegative: true } => "-sympy.oo",
43+
_ => "sympy.oo",
44+
};
3045
}
3146

3247
partial record Rational
3348
{
49+
// The closing parenthesis was missing, so every expression carrying a non-integer
50+
// rational -- which is most of what a CAS hands back -- emitted Python that would
51+
// not even parse: `sympy.Rational(1, 2` is `SyntaxError: '(' was never closed`.
52+
// Nothing caught it because nothing runs the generated code.
53+
// https://github.com/asc-community/AngouriMath/issues/909
3454
internal override string ToSymPy()
35-
=> $"sympy.Rational({Numerator.ToSymPy()}, {Denominator.ToSymPy()}";
55+
=> $"sympy.Rational({Numerator.ToSymPy()}, {Denominator.ToSymPy()})";
3656
}
3757

3858
partial record Integer
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//
2+
// Copyright (c) 2019-2022 Angouri.
3+
// AngouriMath is licensed under MIT.
4+
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
5+
// Website: https://am.angouri.org.
6+
//
7+
8+
using AngouriMath.Extensions;
9+
using System.Collections.Generic;
10+
using System.Linq;
11+
using System.Text.RegularExpressions;
12+
using Xunit;
13+
14+
namespace AngouriMath.Tests.Convenience
15+
{
16+
/// <summary>
17+
/// <see cref="MathS.ToSympyCode(Entity)"/> is documented as generating code you can run in
18+
/// SymPy, so code that cannot run is the whole of the defect.
19+
/// </summary>
20+
/// <remarks>
21+
/// Nothing here executes Python — the suite cannot depend on an interpreter — so these check
22+
/// the two properties that made the generated programs fail without one:
23+
/// <list type="bullet">
24+
/// <item>the parentheses balance, which <c>sympy.Rational(1, 2</c> did not;</item>
25+
/// <item>every name the body mentions is either declared in the preamble or reached through
26+
/// <c>sympy.</c>, which a bare <c>NaN</c> or <c>+oo</c> was not.</item>
27+
/// </list>
28+
/// https://github.com/asc-community/AngouriMath/issues/909
29+
/// </remarks>
30+
[Trait("Area", "Convenience")]
31+
public sealed class ToSympyCodeTest
32+
{
33+
private static (IReadOnlySet<string> Declared, string Body) Split(string code)
34+
{
35+
var declared = new HashSet<string>();
36+
string body = "";
37+
foreach (var line in code.Split('\n'))
38+
{
39+
var declaration = Regex.Match(line, @"^(\S+) = sympy\.Symbol\(");
40+
if (declaration.Success)
41+
declared.Add(declaration.Groups[1].Value);
42+
else if (line.StartsWith("expr = "))
43+
body = line.Substring("expr = ".Length);
44+
}
45+
return (declared, body);
46+
}
47+
48+
/// <summary>
49+
/// Every name in the emitted body is bound: declared as a symbol above, or qualified with
50+
/// <c>sympy.</c>. A bare name is a <c>NameError</c> waiting to happen, and that is how
51+
/// <c>NaN</c>, <c>+oo</c> and <c>-oo</c> used to leave here.
52+
/// </summary>
53+
[Theory]
54+
[InlineData("0/0")]
55+
[InlineData("1/0")]
56+
[InlineData("-1/0")]
57+
[InlineData("+oo")]
58+
[InlineData("-oo")]
59+
[InlineData("+oo + 1")]
60+
[InlineData("NaN")]
61+
[InlineData("x + 1/2")]
62+
[InlineData("sqrt(2) + pi")]
63+
[InlineData("sin(x) / 2")]
64+
[InlineData("x + y + e")]
65+
[InlineData("i")]
66+
[InlineData("2 + 3 * i")]
67+
public void EveryNameInTheEmittedBodyIsBound(string expression)
68+
{
69+
var (declared, body) = Split(MathS.ToSympyCode(expression.ToEntity().Simplify()));
70+
// Whatever is reached through sympy. is bound by the import, so take those out first
71+
// and require the rest to have been declared.
72+
var unqualified = Regex.Replace(body, @"sympy\.\w+", " ");
73+
var loose = Regex.Matches(unqualified, @"[A-Za-z_]\w*")
74+
.Select(match => match.Value)
75+
.Where(name => !declared.Contains(name))
76+
.Distinct()
77+
.ToArray();
78+
Assert.True(loose.Length == 0,
79+
$"{expression} emitted `{body}`, which mentions {string.Join(", ", loose)} "
80+
+ "without binding it");
81+
}
82+
83+
/// <summary>
84+
/// The parentheses balance. <c>Rational</c>'s exporter was missing its closing one, so any
85+
/// expression carrying a non-integer rational -- most of what a CAS hands back -- emitted
86+
/// `SyntaxError: '(' was never closed`.
87+
/// </summary>
88+
[Theory]
89+
[InlineData("1/2")]
90+
[InlineData("1/3 + 1/6")]
91+
[InlineData("x + 1/2")]
92+
[InlineData("2/3 * x ^ (1/2)")]
93+
[InlineData("sin(x) / 2 + 1/4")]
94+
public void TheEmittedCodeHasBalancedParentheses(string expression)
95+
{
96+
var code = MathS.ToSympyCode(expression.ToEntity().Simplify());
97+
Assert.Equal(code.Count(character => character == '('),
98+
code.Count(character => character == ')'));
99+
}
100+
101+
/// <summary>
102+
/// And a rational keeps its exactness, which is the reason to emit
103+
/// <c>sympy.Rational</c> rather than a division of two Python integers: <c>1 / 2</c> is
104+
/// <c>0.5</c> there, a float.
105+
/// </summary>
106+
[Theory]
107+
[InlineData("1/2", "sympy.Rational(1, 2)")]
108+
[InlineData("1/3 + 1/6", "sympy.Rational(1, 2)")]
109+
[InlineData("0/0", "sympy.nan")]
110+
[InlineData("+oo", "sympy.oo")]
111+
[InlineData("-oo", "-sympy.oo")]
112+
public void AValueIsEmittedWithSympysOwnSpelling(string expression, string expected) =>
113+
Assert.Contains(expected, MathS.ToSympyCode(expression.ToEntity().Simplify()));
114+
}
115+
}

0 commit comments

Comments
 (0)