From c240805037a2763589482e86c23293389a1feca3 Mon Sep 17 00:00:00 2001 From: Rafael Date: Sun, 23 Aug 2026 02:44:58 +0000 Subject: [PATCH 1/3] Print the brackets an operator that is not associative has Stringize is the library's own input format, so parsing what it prints has to give back the expression it printed. Five operators broke that in one way: the printer left the *right* operand unbracketed at its own precedence level, while the grammar folds that level to the left, so the text came back re-associated. Four of the five are not associative, so the answer moved and not merely the shape. Measured on a build of 2.3.0 and a build of this branch: false implies (true implies false) printed False implies True implies False read back as (False implies True) implies False: True -> False { 1, 2, 3 } \ ({ 2, 3 } \ { 3 }) printed without its brackets: { 1, 3 } -> { 1 } { 1, 2 } \/ ({ 3 } \ { 1, 2 }) \/ and \ share one precedence level: { 1, 2, 3 } -> { 3 } 2 * (3 mod 2) mod shares a level with * and /: 2 -> 0 implies had the rule the wrong way round rather than missing: it bracketed the assumption, which a left fold never needs, and not the conclusion. So (a implies b) implies c loses its redundant brackets here, which is what made the genuinely ambiguous case look no different from it. \/ and * are bracketed only against the operator that makes them ambiguous, since both are associative themselves and share a level with one that is not: A \/ (B \/ C) still prints flat, A \/ (B \ C) does not; x * (y * z) and x * (y / z) still print flat, x * (y mod z) does not. What does not change, deliberately: an operator that *is* associative still prints flat. x + (y + z) prints x + y + z and comes back as (x + y) + z -- a differently shaped tree and the same number, because the bracketing carries no mathematics. Bracketing those would print every expanded polynomial as a right-nested pile of parentheses. StringizeRoundTripTest pins both halves so the flat half stays a decision rather than an oversight. Latexize moves for the set and mod cases and not for implies. CSharpMath.Evaluation, which reads our LaTeX back, folds \cup, \setminus, \in, \cdot and \bmod to the left at the same precedences this grammar uses, and folds \to to the right -- the usual convention for implication, and what Latexize was already bracketing for. The change only adds \left( \right) groups, which CSharpMath already parses, so nothing is owed downstream (#822). Syntax.md said everything but ^ groups to the left; provided groups to the right, and has since it was written. Suite 7591 passed, 1 failed, 14 skipped. The failure is OneSidedLimitTest.ADifferenceOfReciprocalLogarithms, a Task.Wait timeout guard that a different limit test tripped on the previous run and that passes when run alone -- contention, not this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --- BREAKING-CHANGES.md | 74 +++++++++++++++++ Sources/AngouriMath/Docs/Usage/Syntax.md | 17 ++-- .../Output/Latex/Latex.Arithmetics.Classes.cs | 7 +- .../Output/Latex/Latex.Omni.Classes.cs | 13 ++- .../ToString/ToString.Arithmetics.Classes.cs | 6 +- .../ToString/ToString.Discrete.Classes.cs | 7 +- .../Output/ToString/ToString.Omni.Classes.cs | 17 +++- .../Tests/UnitTests/Convenience/LatexTest.cs | 33 +++++++- .../UnitTests/Convenience/PriorityTest.cs | 14 ++++ .../Convenience/StringizeRoundTripTest.cs | 81 ++++++++++++++++++- 10 files changed, 251 insertions(+), 18 deletions(-) diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index c194d9725..c0d096080 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -15,6 +15,80 @@ read first. --- +## Unreleased — since 2.3.0 + +### At a glance + +| Silent? | What | Was | Is | +|---|---|---|---| +| **Silent** | `"a implies (b implies c)".ToEntity().Stringize()` | `a implies b implies c`, which reads back as `(a implies b) implies c` | `a implies (b implies c)` | +| | `"(a implies b) implies c".ToEntity().Stringize()` | `(a implies b) implies c` | `a implies b implies c` | +| **Silent** | `@"A \ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \ C` / `A \setminus B \setminus C` | `A \ (B \ C)` / `A \setminus \left(B \setminus C\right)` | +| **Silent** | `@"A \ (B \/ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \/ C` / `A \setminus B \cup C` | `A \ (B \/ C)` / `A \setminus \left(B \cup C\right)` | +| **Silent** | `@"A \/ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \/ B \ C` / `A \cup B \setminus C` | `A \/ (B \ C)` / `A \cup \left(B \setminus C\right)` | +| **Silent** | `"a in (b in c)".ToEntity().Stringize()`, and `Latexize` | `a in b in c` / `a \in b \in c` | `a in (b in c)` / `a \in \left(b \in c\right)` | +| **Silent** | `"x * (y mod z)".ToEntity().Stringize()`, and `Latexize` | `x * y mod z` / `x y \bmod z` | `x * (y mod z)` / `x \left(y \bmod z\right)` | +| **Silent** | `"-1 * (y mod z)".ToEntity().Stringize()` | `-y mod z` | `-(y mod z)` | + +### A printed operator that is not associative keeps its brackets + +`Stringize` is the library's own input format: parsing what it prints has to give back the +expression it printed, and that is what [`StringizeRoundTripTest`](Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs) +enforces. Five operators broke it, all in the same way — the printer left the **right** operand +unbracketed at its own precedence level, while the grammar folds that level to the left, so the +printed text came back re-associated. + +Four of the five are not associative, so the re-association changed the answer and not merely +the shape. Measured on a build of 2.3.0 and a build of this branch: + +| input | 2.3.0 printed | 2.3.0 read that back as | value moved | +|---|---|---|---| +| `false implies (true implies false)` | `False implies True implies False` | `(False implies True) implies False` | `True` → `False` | +| `{ 1, 2, 3 } \ ({ 2, 3 } \ { 3 })` | `{ 1, 2, 3 } \ { 2, 3 } \ { 3 }` | `({ 1, 2, 3 } \ { 2, 3 }) \ { 3 }` | `{ 1, 3 }` → `{ 1 }` | +| `{ 1, 2 } \/ ({ 3 } \ { 1, 2 })` | `{ 1, 2 } \/ { 3 } \ { 1, 2 }` | `({ 1, 2 } \/ { 3 }) \ { 1, 2 }` | `{ 1, 2, 3 }` → `{ 3 }` | +| `{ 1, 2 } \ ({ 2 } \/ { 3 })` | `{ 1, 2 } \ { 2 } \/ { 3 }` | `({ 1, 2 } \ { 2 }) \/ { 3 }` | `{ 1 }` → `{ 1, 3 }` | +| `2 * (3 mod 2)` | `2 * 3 mod 2` | `(2 * 3) mod 2` | `2` → `0` | + +On this branch each of those prints its brackets and reads back as itself, so the value column +no longer moves. + +`implies` also changes in the other direction, because its printer had the rule the wrong way +round rather than missing: it bracketed the **assumption** — the operand that never needs it under +a left fold — and not the conclusion. So `(a implies b) implies c`, which used to print as +`(a implies b) implies c`, now prints flat as `a implies b implies c`. Both read back as the same +expression; the brackets were redundant, and printing them is what made the genuinely ambiguous +case look no different. + +`\/` and `mod` are bracketed only against the operator that makes them ambiguous, because both +share a precedence level with an operator that is not associative while being associative +themselves: + +- `A \/ (B \/ C)` still prints `A \/ B \/ C`; `A \/ (B \ C)` now prints `A \/ (B \ C)`. +- `x * (y * z)` still prints `x * y * z` and `x * (y / z)` still prints `x * y / z`; + `x * (y mod z)` now prints `x * (y mod z)`. +- `-1 * (y mod z)` printed `-y mod z` and now prints `-(y mod z)`. + +**What has not changed, deliberately.** An operator that *is* associative still prints flat, so +`x + (y + z)` still prints `x + y + z`, and likewise for `*`, `and`, `or`, `xor`, `unite`, +`intersect` and `provided`. Those come back as a different `Entity` — a left-nested tree instead of +a right-nested one — and as the same number, because the bracketing carries no mathematics. The +alternative would print every expanded polynomial as a right-nested pile of parentheses: +`"(a + b + c + d) ^ 2".ToEntity().Expand()` prints, on both versions, +`d ^ 2 + 2 * c * d + c ^ 2 + 2 * b * d + 2 * b * c + b ^ 2 + 2 * a * d + 2 * a * c + 2 * a * b + a ^ 2`, +and its sum is right-nested throughout. `StringizeRoundTripTest` pins that decision so that it +stays one. + +**LaTeX.** `Latexize` moves for the four set and `mod` cases above and **not** for `implies`. +[CSharpMath.Evaluation](https://github.com/verybadcat/CSharpMath/blob/master/CSharpMath.Evaluation/Evaluation.cs), +which reads our LaTeX back, folds `\cup`, `\setminus`, `\in`, `\cdot` and `\bmod` to the left at +the same precedences this grammar uses — so those needed the same brackets — but folds `\to` to the +**right**, which is the usual convention for implication and is what `Latexize` was already +bracketing for. The change only ever adds `\left(`/`\right)` groups, which CSharpMath already +parses, so nothing downstream needs a matching change +([#822](https://github.com/asc-community/AngouriMath/issues/822)). + +--- + ## 2.3.0 — since 2.2.0 ### At a glance diff --git a/Sources/AngouriMath/Docs/Usage/Syntax.md b/Sources/AngouriMath/Docs/Usage/Syntax.md index af2d04094..dfbbadbcd 100644 --- a/Sources/AngouriMath/Docs/Usage/Syntax.md +++ b/Sources/AngouriMath/Docs/Usage/Syntax.md @@ -1,4 +1,4 @@ -# Expression syntax +# Expression syntax What `MathS.FromString` accepts, and what `Entity.Stringize` produces. The authority is [`Core/Antlr/AngouriMath.g`](../../Core/Antlr/AngouriMath.g); this page is a reading of it. To @@ -22,12 +22,17 @@ will say so ([#822](https://github.com/asc-community/AngouriMath/issues/822)). ## Operators, loosest first -Everything on one line has the same precedence and groups to the left, except `^`, which groups -to the right. +Everything on one line has the same precedence and groups to the left, except `^` and `provided`, +which group to the right. + +Grouping is not decoration: an operator that is not associative prints the brackets it has, so +`a implies (b implies c)` prints with them and `(a implies b) implies c` prints without. Where the +operator *is* associative the brackets carry no mathematics and are not printed, so `x + (y + z)` +prints as `x + y + z` and reads back as `(x + y) + z` — the same number, a differently shaped tree. | | operators | notes | |---|---|---| -| 1 | `provided` | | +| 1 | `provided` | **groups to the right**: `a provided b provided c` is `a provided (b provided c)` | | 2 | `implies` | | | 3 | `or` `\|` | | | 4 | `xor` | | @@ -35,10 +40,10 @@ to the right. | 6 | `not` | prefix | | 7 | `=` `<>` `>` `>=` `<` `<=` | chained: `a < b < c` means `a < b and b < c` | | 8 | `in` | | -| 9 | `unite` `\/`, `setsubtract` `\` | | +| 9 | `unite` `\/`, `setsubtract` `\` | one level, so `A \/ B \ C` is `(A \/ B) \ C` | | 10 | `intersect` `/\` | | | 11 | `+` `-` | | -| 12 | `*` `/` `mod` | | +| 12 | `*` `/` `mod` | one level, so `x * y mod z` is `(x * y) mod z` | | 13 | `+` `-` | prefix | | 14 | `^` | **groups to the right**: `a ^ b ^ c` is `a ^ (b ^ c)` | | 15 | `!` | postfix factorial | diff --git a/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs index 7e0ccc40e..2257437bd 100644 --- a/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/Latex/Latex.Arithmetics.Classes.cs @@ -49,10 +49,13 @@ public override string Latexize() }; case 1: if (longArray[index - 1] is Integer(-1)) - return $"-{currIn.Latexize(currIn.LatexPriority < LatexPriority)}"; // display "-1 * x * y" as "-x \cdot y", only for the first -1 + return $"-{currIn.Latexize(currIn.LatexPriority < LatexPriority || currIn is Modf)}"; // display "-1 * x * y" as "-x \cdot y", only for the first -1 break; } - var currOut = currIn.Latexize(currIn.LatexPriority < LatexPriority); + // A factor other than the first that is a `mod` needs brackets: \bmod sits + // at this precedence level and is not associative, so "x \cdot y \bmod z" + // reads as "(x y) mod z". 2 * (3 mod 2) is 2 and (2 * 3) mod 2 is 0. + var currOut = currIn.Latexize(currIn.LatexPriority < LatexPriority || currIn is Modf); return (longArray[index - 1], currIn) switch // whether we use juxtaposition and omit \cdot { diff --git a/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs b/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs index ad97a0428..6d6709be5 100644 --- a/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs @@ -50,8 +50,12 @@ public override string Latexize() partial record Unionf { /// + // CSharpMath.Evaluation reads \cup and \setminus at one precedence level and folds + // them to the left, exactly as the grammar here does, so a \setminus on the right + // needs bracketing or it comes back as the outer operator. Union with union is + // associative and stays flat. public override string Latexize() - => $@"{Left.Latexize(Left.LatexPriority < LatexPriority)} \cup {Right.Latexize(Right.LatexPriority < LatexPriority)}"; + => $@"{Left.Latexize(Left.LatexPriority < LatexPriority)} \cup {Right.Latexize(Right.LatexPriority < LatexPriority || Right is SetMinusf)}"; } partial record Intersectionf @@ -64,15 +68,18 @@ public override string Latexize() partial record SetMinusf { /// + // Not associative, and sharing a precedence level with union: anything at that + // level on the right is bracketed, the same rule \frac's text form follows. public override string Latexize() - => $@"{Left.Latexize(Left.LatexPriority < LatexPriority)} \setminus {Right.Latexize(Right.LatexPriority < LatexPriority)}"; + => $@"{Left.Latexize(Left.LatexPriority < LatexPriority)} \setminus {Right.Latexize(Right.LatexPriority <= LatexPriority)}"; } partial record Inf { /// + // \in is left-folded by CSharpMath too, and membership is not associative. public override string Latexize() - => $@"{Element.Latexize(Element.LatexPriority < LatexPriority)} \in {SupSet.Latexize(SupSet.LatexPriority < LatexPriority)}"; + => $@"{Element.Latexize(Element.LatexPriority < LatexPriority)} \in {SupSet.Latexize(SupSet.LatexPriority <= LatexPriority)}"; } } diff --git a/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs index be2782930..f549f2769 100644 --- a/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToString/ToString.Arithmetics.Classes.cs @@ -30,10 +30,14 @@ public override string Stringize() => public partial record Mulf { /// + // Multiplication is associative, so a product or a quotient on the right may stay + // unbracketed -- but `mod` shares this precedence level and is not associative, so a + // `mod` on the right must be bracketed or it is re-read as the outer operator: + // `2 * (3 mod 2)` is 2 and `(2 * 3) mod 2` is 0. public override string Stringize() => (Multiplier is Integer(-1) && !MathS.Diagnostic.OutputExplicit ? "-" : Multiplier.Stringize(Multiplier.Priority < Priority) + " * ") - + Multiplicand.Stringize(Multiplicand.Priority < Priority); + + Multiplicand.Stringize(Multiplicand.Priority < Priority || Multiplicand is Modf); /// public override string ToString() => Stringize(); } diff --git a/Sources/AngouriMath/Functions/Output/ToString/ToString.Discrete.Classes.cs b/Sources/AngouriMath/Functions/Output/ToString/ToString.Discrete.Classes.cs index 6c952b0dc..9f305e7bf 100644 --- a/Sources/AngouriMath/Functions/Output/ToString/ToString.Discrete.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToString/ToString.Discrete.Classes.cs @@ -55,8 +55,13 @@ public override string Stringize() partial record Impliesf { /// + // The grammar folds `implies` to the left, like everything but `^` and `provided`, so + // it is the *conclusion* that needs bracketing when it is an implication of its own. + // The two are not interchangeable: `false implies (true implies false)` is true, and + // `(false implies true) implies false` is false, so printing the first without its + // brackets said the opposite of what it meant. public override string Stringize() - => $"{Assumption.Stringize(Assumption.Priority <= Priority)} implies {Conclusion.Stringize(Conclusion.Priority < Priority)}"; + => $"{Assumption.Stringize(Assumption.Priority < Priority)} implies {Conclusion.Stringize(Conclusion.Priority <= Priority)}"; /// public override string ToString() => Stringize(); } diff --git a/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs b/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs index a69393263..f22058817 100644 --- a/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs @@ -88,8 +88,13 @@ partial record Complexes partial record Unionf { /// + // Union is associative, so a union on the right may stay unbracketed -- but `\/` + // and `\` share one precedence level and are folded by one loop in the grammar, so + // a *set difference* on the right must be bracketed or it is re-read as the outer + // operator: `{ 1, 2 } \/ ({ 3 } \ { 1, 2 })` is { 1, 2, 3 } and + // `({ 1, 2 } \/ { 3 }) \ { 1, 2 }` is { 3 }. public override string Stringize() - => $@"{Left.Stringize(Left.Priority < Priority)} \/ {Right.Stringize(Right.Priority < Priority)}"; + => $@"{Left.Stringize(Left.Priority < Priority)} \/ {Right.Stringize(Right.Priority < Priority || Right is SetMinusf)}"; /// public override string ToString() => Stringize(); } @@ -106,8 +111,12 @@ public override string Stringize() partial record SetMinusf { /// + // Set difference is not associative, and it shares its precedence level with + // union, so anything at that level on the right needs bracketing -- the same rule + // `-` and `/` follow. `{1,2,3} \ ({2,3} \ {3})` is { 1, 3 } where + // `({1,2,3} \ {2,3}) \ {3}` is { 1 }. public override string Stringize() - => $@"{Left.Stringize(Left.Priority < Priority)} \ {Right.Stringize(Right.Priority < Priority)}"; + => $@"{Left.Stringize(Left.Priority < Priority)} \ {Right.Stringize(Right.Priority <= Priority)}"; /// public override string ToString() => Stringize(); } @@ -115,8 +124,10 @@ public override string Stringize() partial record Inf { /// + // `in` is folded to the left and is not associative: `(a in b) in c` asks whether + // a truth value is an element of c, `a in (b in c)` whether a is an element of one. public override string Stringize() - => $@"{Element.Stringize(Element.Priority < Priority)} in {SupSet.Stringize(SupSet.Priority < Priority)}"; + => $@"{Element.Stringize(Element.Priority < Priority)} in {SupSet.Stringize(SupSet.Priority <= Priority)}"; /// public override string ToString() => Stringize(); } diff --git a/Sources/Tests/UnitTests/Convenience/LatexTest.cs b/Sources/Tests/UnitTests/Convenience/LatexTest.cs index 8179e86ad..dfea56d1c 100644 --- a/Sources/Tests/UnitTests/Convenience/LatexTest.cs +++ b/Sources/Tests/UnitTests/Convenience/LatexTest.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2019-2022 Angouri. // AngouriMath is licensed under MIT. // Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. @@ -532,6 +532,37 @@ [Fact] public void SetMinus() => Test(@"\left\{ 1, 2, 3 \right\} \setminus \left\{ 2 \right\}", MathS.Sets.Finite(1, 2, 3).SetSubtract(MathS.Sets.Finite(2))); [Fact] public void SetIn() => Test(@"x \in \left\{ 1, 2, 3 \right\}", x.In(MathS.Sets.Finite(1, 2, 3))); + + // Associativity, which LaTeX has to express too: CSharpMath.Evaluation reads the LaTeX + // back, and it folds \cup, \setminus and \in to the left at the precedences the grammar + // here uses, so a right operand at the same level must be bracketed or it comes back as + // the outer operator. \to is the exception -- CSharpMath folds it to the *right*, the + // usual convention for implication, so it is the assumption that gets the brackets. + private static readonly Entity A = MathS.Sets.Finite(1, 2), B = MathS.Sets.Finite(2, 3), C = MathS.Sets.Finite(3); + private static readonly string As = @"\left\{ 1, 2 \right\}", Bs = @"\left\{ 2, 3 \right\}", Cs = @"\left\{ 3 \right\}"; + + [Fact] public void SetMinusOfSetMinusOnTheRight() + => Test($@"{As} \setminus \left({Bs} \setminus {Cs}\right)", A.SetSubtract(B.SetSubtract(C))); + [Fact] public void SetMinusOfSetMinusOnTheLeft() + => Test($@"{As} \setminus {Bs} \setminus {Cs}", A.SetSubtract(B).SetSubtract(C)); + [Fact] public void SetMinusOfUnionOnTheRight() + => Test($@"{As} \setminus \left({Bs} \cup {Cs}\right)", A.SetSubtract(B.Unite(C))); + [Fact] public void UnionOfSetMinusOnTheRight() + => Test($@"{As} \cup \left({Bs} \setminus {Cs}\right)", A.Unite(B.SetSubtract(C))); + [Fact] public void UnionOfUnionOnTheRightStaysFlat() + => Test($@"{As} \cup {Bs} \cup {Cs}", A.Unite(B.Unite(C))); + [Fact] public void IntersectionOfIntersectionOnTheRightStaysFlat() + => Test($@"{As} \cap {Bs} \cap {Cs}", A.Intersect(B.Intersect(C))); + [Fact] public void InOfInOnTheRight() + => Test(@"x \in \left(y \in z\right)", x.In(MathS.Var("y").In(MathS.Var("z")))); + [Fact] public void ImpliesOfImpliesOnTheRightStaysFlat() + => Test(@"x \to y \to z", x.Implies(MathS.Var("y").Implies(MathS.Var("z")))); + [Fact] public void ImpliesOfImpliesOnTheLeft() + => Test(@"\left(x \to y\right) \to z", x.Implies(MathS.Var("y")).Implies(MathS.Var("z"))); + [Fact] public void ModAsANonLeadingFactor() + => Test(@"x \left(y \bmod z\right)", x * (MathS.Var("y") % MathS.Var("z"))); + [Fact] public void ModAsTheLeadingFactorStaysFlat() + => Test(@"x \bmod y \cdot z", (x % MathS.Var("y")) * MathS.Var("z")); // Special sets [Fact] public void SetBooleans() diff --git a/Sources/Tests/UnitTests/Convenience/PriorityTest.cs b/Sources/Tests/UnitTests/Convenience/PriorityTest.cs index eff56bb0b..c2797df6b 100644 --- a/Sources/Tests/UnitTests/Convenience/PriorityTest.cs +++ b/Sources/Tests/UnitTests/Convenience/PriorityTest.cs @@ -49,6 +49,20 @@ public class PriorityTest [InlineData("not a = b", "a <> b")] [InlineData("a = b and not b = c and c < d", "a = b <> c < d")] [InlineData("not a = b and not b = c", "a <> b <> c")] + // The right operand of a left-folded operator that is not associative has to keep its + // brackets, or the normalized form is a different expression. + [InlineData("a implies (b implies c)", "a implies (b implies c)")] + [InlineData("a implies b implies c", "(a implies b) implies c")] + [InlineData("a implies (b implies c) implies d", "(a implies (b implies c)) implies d")] + [InlineData(@"A \ (B \ C)", @"A \ (B \ C)")] + [InlineData(@"A \ B \ C", @"(A \ B) \ C")] + [InlineData(@"A \/ (B \ C)", @"A \/ (B \ C)")] + [InlineData(@"A \ (B \/ C)", @"A \ (B \/ C)")] + [InlineData(@"A \/ B \ C", @"(A \/ B) \ C")] + [InlineData("a in (b in c)", "a in (b in c)")] + [InlineData("a in b in c", "(a in b) in c")] + [InlineData("a * (b mod c)", "a * (b mod c)")] + [InlineData("a * b mod c", "(a * b) mod c")] public void Test(string normalizedForm, string alternateForm) { Assert.Equal(alternateForm.ToEntity(), normalizedForm.ToEntity()); diff --git a/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs b/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs index f2507b5ec..aa8f43a96 100644 --- a/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs +++ b/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2019-2022 Angouri. // AngouriMath is licensed under MIT. // Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. @@ -136,6 +136,85 @@ public void TheNodesWithoutAnOperatorSpellingPrintAsTheirFunction(string source) [InlineData("x provided y")] public void BooleansRoundTrip(string source) => AssertRoundTrip(source); + /// + /// An operator that is not associative has to print the bracketing it has, because + /// the flat form is read the way the grammar folds -- to the left for everything but + /// ^ and provided. Printing a implies (b implies c) as + /// a implies b implies c did not merely look wrong: it comes back as + /// (a implies b) implies c, and the two do not agree + /// (false implies (true implies false) is true, the other is false). + /// + /// + /// \/ and \ share one precedence level and are folded by the same loop, so a + /// \ on the right of a \/ mis-associates too even though union on its own is + /// associative; and mod shares a level with * and /, so a mod on + /// the right of a * does the same. + /// + [Theory] + [InlineData("a implies (b implies c)")] + [InlineData("(a implies b) implies c")] + [InlineData("a implies b implies c")] + [InlineData("a implies (b implies (c implies d))")] + [InlineData("(a implies (b implies c)) implies d")] + [InlineData("a implies (b or c)")] + [InlineData(@"A \ (B \ C)")] + [InlineData(@"(A \ B) \ C")] + [InlineData(@"A \/ (B \ C)")] + [InlineData(@"A \ (B \/ C)")] + [InlineData(@"(A \/ B) \ C")] + [InlineData(@"A \ (B /\ C)")] + [InlineData(@"A /\ (B \ C)")] + [InlineData("a in (b in c)")] + [InlineData("(a in b) in c")] + [InlineData("x * (y mod z)")] + [InlineData("(x * y) mod z")] + [InlineData("x mod (y * z)")] + [InlineData("x / (y mod z)")] + [InlineData("x mod (y mod z)")] + public void ANonAssociativeOperatorKeepsItsGrouping(string source) => AssertRoundTrip(source); + + /// + /// The same defect stated in values rather than in trees: each of these is a case where + /// reading the printed form back the way the grammar folds gives a different answer. + /// + [Theory] + [InlineData("false implies (true implies false)")] + [InlineData(@"{ 1, 2, 3 } \ ({ 2, 3 } \ { 3 })")] + [InlineData(@"{ 1, 2 } \ ({ 2 } \/ { 3 })")] + [InlineData(@"{ 1, 2 } \/ ({ 3 } \ { 1, 2 })")] + [InlineData("2 * (3 mod 2)")] + [InlineData("(3 in { 3 }) in BB")] + public void AMisreadGroupingWouldChangeTheValue(string source) + { + var original = source.ToEntity(); + Assert.Equal(original.Evaled, original.Stringize().ToEntity().Evaled); + } + + /// + /// The other half of the rule, pinned so that it is a decision rather than an oversight: + /// an operator that is associative prints flat, because the bracketing then + /// carries no mathematics. x + (y + z) comes back as (x + y) + z, which is a + /// different and the same number -- and bracketing it instead would + /// turn every expanded polynomial into a right-nested pile of parentheses. + /// + [Theory] + [InlineData("1 + (2 + 3)", "1 + 2 + 3")] + [InlineData("1 + (2 - 3)", "1 + 2 - 3")] + [InlineData("2 * (3 * 4)", "2 * 3 * 4")] + [InlineData("2 * (6 / 3)", "2 * 6 / 3")] + [InlineData("true and (false and true)", "True and False and True")] + [InlineData("true or (false or true)", "True or False or True")] + [InlineData("true xor (false xor true)", "True xor False xor True")] + [InlineData(@"{ 1 } \/ ({ 2 } \/ { 3 })", @"{ 1 } \/ { 2 } \/ { 3 }")] + [InlineData(@"{ 1, 2 } /\ ({ 2, 3 } /\ { 2 })", @"{ 1, 2 } /\ { 2, 3 } /\ { 2 }")] + [InlineData("(x provided p) provided q", "x provided p provided q")] + public void AnAssociativeOperatorPrintsFlatAndKeepsItsValue(string source, string expectedFlat) + { + var original = source.ToEntity(); + Assert.Equal(expectedFlat, original.Stringize()); + Assert.Equal(original.Evaled, original.Stringize().ToEntity().Evaled); + } + [Theory] [InlineData("{ 1, 2, 3 }")] [InlineData("[1; 2]")] From b78aacad5c124c9de9e0b481dc0a6dbfc879c551 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 23 Aug 2026 03:26:00 +0000 Subject: [PATCH 2/3] Bracket the operand `provided` mis-associates, which is the other one `provided` is the one infix operator the grammar folds to the right, so it is the *left* operand that has to say when it is an attached condition of its own. `(x provided p) provided q` printed as `x provided p provided q` and read back as `x provided (p provided q)`. It was classified as safe here on the strength of a value test, and the value is genuinely safe: both readings are `x` exactly when `p` and `q` hold. The contract is about the expression, not the number, and asking the expression is what catches it -- so the case moves from the flat-and-keeps-its-value theory to the keeps-its-grouping one. LatexTests.Provided3 and Provided4 asserted the flat form. They were pinning the ambiguity rather than a behaviour: both build a left-nested Providedf and expect it to print without the grouping that distinguishes it. Updated, with the reason beside them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --- BREAKING-CHANGES.md | 19 +++++++++++++------ .../Output/Latex/Latex.Omni.Classes.cs | 4 +++- .../Output/ToString/ToString.Omni.Classes.cs | 9 ++++++++- .../Tests/UnitTests/Convenience/LatexTest.cs | 7 +++++-- .../Convenience/StringizeRoundTripTest.cs | 9 ++++++++- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index c0d096080..b139f4ba3 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -26,6 +26,7 @@ read first. | **Silent** | `@"A \ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \ C` / `A \setminus B \setminus C` | `A \ (B \ C)` / `A \setminus \left(B \setminus C\right)` | | **Silent** | `@"A \ (B \/ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \/ C` / `A \setminus B \cup C` | `A \ (B \/ C)` / `A \setminus \left(B \cup C\right)` | | **Silent** | `@"A \/ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \/ B \ C` / `A \cup B \setminus C` | `A \/ (B \ C)` / `A \cup \left(B \setminus C\right)` | +| **Silent** | `"(x provided p) provided q".ToEntity().Stringize()`, and `Latexize` | `x provided p provided q`, which reads back as `x provided (p provided q)` | `(x provided p) provided q` | | **Silent** | `"a in (b in c)".ToEntity().Stringize()`, and `Latexize` | `a in b in c` / `a \in b \in c` | `a in (b in c)` / `a \in \left(b \in c\right)` | | **Silent** | `"x * (y mod z)".ToEntity().Stringize()`, and `Latexize` | `x * y mod z` / `x y \bmod z` | `x * (y mod z)` / `x \left(y \bmod z\right)` | | **Silent** | `"-1 * (y mod z)".ToEntity().Stringize()` | `-y mod z` | `-(y mod z)` | @@ -34,11 +35,12 @@ read first. `Stringize` is the library's own input format: parsing what it prints has to give back the expression it printed, and that is what [`StringizeRoundTripTest`](Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs) -enforces. Five operators broke it, all in the same way — the printer left the **right** operand -unbracketed at its own precedence level, while the grammar folds that level to the left, so the -printed text came back re-associated. +enforces. Six operators broke it, five of them in the same way — the printer left the **right** +operand unbracketed at its own precedence level, while the grammar folds that level to the left, so +the printed text came back re-associated. The sixth, `provided`, is the mirror: it is the one infix +operator the grammar folds to the **right**, so there it is the *left* operand that mis-associates. -Four of the five are not associative, so the re-association changed the answer and not merely +Four of the six are not associative, so the re-association changed the answer and not merely the shape. Measured on a build of 2.3.0 and a build of this branch: | input | 2.3.0 printed | 2.3.0 read that back as | value moved | @@ -68,9 +70,14 @@ themselves: `x * (y mod z)` now prints `x * (y mod z)`. - `-1 * (y mod z)` printed `-y mod z` and now prints `-(y mod z)`. +`provided` is bracketed on the other side, and it is the case where the value is *not* the test. +`(x provided p) provided q` and `x provided (p provided q)` are both `x` exactly when `p` and `q` +hold, so no answer moves — and they are different expressions, which is what the round trip is +about. It printed flat and read back as the right-nested one. + **What has not changed, deliberately.** An operator that *is* associative still prints flat, so -`x + (y + z)` still prints `x + y + z`, and likewise for `*`, `and`, `or`, `xor`, `unite`, -`intersect` and `provided`. Those come back as a different `Entity` — a left-nested tree instead of +`x + (y + z)` still prints `x + y + z`, and likewise for `*`, `and`, `or`, `xor`, `unite` and +`intersect`. Those come back as a different `Entity` — a left-nested tree instead of a right-nested one — and as the same number, because the bracketing carries no mathematics. The alternative would print every expanded polynomial as a right-nested pile of parentheses: `"(a + b + c + d) ^ 2".ToEntity().Expand()` prints, on both versions, diff --git a/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs b/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs index 6d6709be5..00a9f5f9f 100644 --- a/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/Latex/Latex.Omni.Classes.cs @@ -86,7 +86,9 @@ public override string Latexize() partial record Providedf { /// - public override string Latexize() => $@"{Expression.Latexize(Expression.LatexPriority < LatexPriority)} \quad \text{{for}} \quad {Predicate.Latexize(Predicate.LatexPriority < LatexPriority)}"; + // Right-folding, so the left operand is the one that needs bracketing -- see the + // remark on Stringize. + public override string Latexize() => $@"{Expression.Latexize(Expression.LatexPriority <= LatexPriority)} \quad \text{{for}} \quad {Predicate.Latexize(Predicate.LatexPriority < LatexPriority)}"; } partial record Piecewise diff --git a/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs b/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs index f22058817..8cd273c52 100644 --- a/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToString/ToString.Omni.Classes.cs @@ -138,7 +138,14 @@ public override string Stringize() partial record Providedf { /// - public override string Stringize() => $@"{Expression.Stringize(Expression.Priority < Priority.Provided)} provided {Predicate.Stringize(Predicate.Priority < Priority.Provided)}"; + // `provided` is the one infix operator the grammar folds to the *right*, so this is + // the mirror of the `implies` rule: the flat form is read as + // `a provided (b provided c)`, and it is the left operand that has to say when it is + // an attached condition of its own. `(x provided p) provided q` printed flat came + // back as `x provided (p provided q)` -- the same value, since both are `x` exactly + // when `p` and `q` hold, and a different expression, which is what the round trip is + // about. + public override string Stringize() => $@"{Expression.Stringize(Expression.Priority <= Priority.Provided)} provided {Predicate.Stringize(Predicate.Priority < Priority.Provided)}"; /// public override string ToString() => Stringize(); } diff --git a/Sources/Tests/UnitTests/Convenience/LatexTest.cs b/Sources/Tests/UnitTests/Convenience/LatexTest.cs index dfea56d1c..fd9b43aed 100644 --- a/Sources/Tests/UnitTests/Convenience/LatexTest.cs +++ b/Sources/Tests/UnitTests/Convenience/LatexTest.cs @@ -312,10 +312,13 @@ [Fact] public void Provided1() => Test(@"x \quad \text{for} \quad y", MathS.Provided("x", "y")); [Fact] public void Provided2() => Test(@"x+1 \quad \text{for} \quad y > 0", MathS.Provided("x + 1", "y > 0")); + // Both of these are left-nested, and `provided` folds to the right -- so the flat form + // these used to expect is read back as the *right*-nested tree, which is a different + // expression. The grouping is the assertion; the two used to pin its absence. [Fact] public void Provided3() - => Test(@"a \quad \text{for} \quad b \quad \text{for} \quad c", MathS.Provided(MathS.Provided("a", "b"), "c")); + => Test(@"\left(a \quad \text{for} \quad b\right) \quad \text{for} \quad c", MathS.Provided(MathS.Provided("a", "b"), "c")); [Fact] public void Provided4() - => Test(@"\left(a \quad \text{for} \quad b \quad \text{for} \quad c \quad \text{for} \quad d\right) \to \top ", MathS.Provided(MathS.Provided("a", "b"), MathS.Provided("c", "d")).Implies(Entity.Boolean.True)); + => Test(@"\left(\left(a \quad \text{for} \quad b\right) \quad \text{for} \quad c \quad \text{for} \quad d\right) \to \top ", MathS.Provided(MathS.Provided("a", "b"), MathS.Provided("c", "d")).Implies(Entity.Boolean.True)); // Juxtaposition tests [Fact] public void M1InTheMiddle() => Test(@"x \left(-1\right) \cdot x", (x * (-1)) * x); [Fact] public void MultiplyNumberWithPower() => Test(@"2 \cdot {3}^{4}", 2 * ((Entity)3).Pow(4)); diff --git a/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs b/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs index aa8f43a96..25de960a1 100644 --- a/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs +++ b/Sources/Tests/UnitTests/Convenience/StringizeRoundTripTest.cs @@ -171,6 +171,14 @@ public void TheNodesWithoutAnOperatorSpellingPrintAsTheirFunction(string source) [InlineData("x mod (y * z)")] [InlineData("x / (y mod z)")] [InlineData("x mod (y mod z)")] + // `provided` is the mirror case: it folds to the *right*, so it is the left operand that + // mis-associates. Its value survives either reading -- `(x provided p) provided q` and + // `x provided (p provided q)` are both `x` exactly when `p` and `q` hold -- which is why + // a check on the value alone passes it and a check on the expression does not. + [InlineData("(x provided p) provided q")] + [InlineData("x provided (p provided q)")] + [InlineData("x provided p provided q")] + [InlineData("((x provided p) provided q) provided r")] public void ANonAssociativeOperatorKeepsItsGrouping(string source) => AssertRoundTrip(source); /// @@ -207,7 +215,6 @@ public void AMisreadGroupingWouldChangeTheValue(string source) [InlineData("true xor (false xor true)", "True xor False xor True")] [InlineData(@"{ 1 } \/ ({ 2 } \/ { 3 })", @"{ 1 } \/ { 2 } \/ { 3 }")] [InlineData(@"{ 1, 2 } /\ ({ 2, 3 } /\ { 2 })", @"{ 1, 2 } /\ { 2, 3 } /\ { 2 }")] - [InlineData("(x provided p) provided q", "x provided p provided q")] public void AnAssociativeOperatorPrintsFlatAndKeepsItsValue(string source, string expectedFlat) { var original = source.ToEntity(); From 223c7bd31702e1fb97f75d1254711dc206a15ba1 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 23 Aug 2026 03:44:15 +0000 Subject: [PATCH 3/3] Assert that two different provided expressions print differently FromStringTest.TestProvided4 asserted that `a provided (b provided c)` and `(a provided b) provided c` produce the same string. They are different expressions -- the parse is right-associative, which the two tests above it pin -- so that assertion was the round trip failing, written down as an expectation. It now asserts what the contract asks: the two print differently, and each reads back as itself. Found by CI rather than locally, because the run before this one was filtered to the printer tests and this one lives with the parser. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e --- .../Tests/UnitTests/Convenience/FromStringTest.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/Tests/UnitTests/Convenience/FromStringTest.cs b/Sources/Tests/UnitTests/Convenience/FromStringTest.cs index 08f80817f..ad8d5a331 100644 --- a/Sources/Tests/UnitTests/Convenience/FromStringTest.cs +++ b/Sources/Tests/UnitTests/Convenience/FromStringTest.cs @@ -216,7 +216,20 @@ public void TestFormula8() [Fact] public void TestProvided1() => Assert.Equal(MathS.Provided("a", "b"), FromString("a provided b")); [Fact] public void TestProvided2() => Assert.Equal(MathS.Provided("a", MathS.Provided("b", "c")), FromString("a provided b provided c")); [Fact] public void TestProvided3() => Assert.Equal(MathS.Provided(MathS.Provided("a", "b"), "c"), FromString("(a provided b) provided c")); - [Fact] public void TestProvided4() => Assert.Equal(FromString("a provided (b provided c)").Stringize(), FromString("(a provided b) provided c").Stringize()); + // These two are different expressions -- `provided` folds to the right, so only the first + // is what the flat spelling means -- and this used to assert that they print alike. That + // is the round trip failing, written down as an expectation: the printed form of the + // second came back as the first. They must print differently, and each must read back as + // itself. + [Fact] public void TestProvided4() + { + var rightNested = FromString("a provided (b provided c)"); + var leftNested = FromString("(a provided b) provided c"); + Assert.NotEqual(rightNested, leftNested); + Assert.NotEqual(rightNested.Stringize(), leftNested.Stringize()); + Assert.Equal(rightNested, FromString(rightNested.Stringize())); + Assert.Equal(leftNested, FromString(leftNested.Stringize())); + } [Theory] [InlineData("sh", "Sinh")]