Skip to content

Commit 0e643f3

Browse files
A nested radical comes apart (#1154)
A nested radical comes apart `sqrt(5 + 2*sqrt(6))` is a radical under a radical, and it is two plain ones added: `sqrt(3) + sqrt(2)`. So are `sqrt(7 - 4*sqrt(3))`, `sqrt(9 + 4*sqrt(5))`, `sqrt(11 + 6*sqrt(2))` and `sqrt(6 - 2*sqrt(5))`. Squaring `sqrt(x) + sqrt(y)` gives `x + y + 2*sqrt(x*y)`, so matching that against `a + b*sqrt(c)` makes `x` and `y` the roots of `t^2 - a*t + b^2*c/4`. They are rational exactly when `a^2 - b^2*c` is the square of a rational, and that is the whole test -- decidable in exact arithmetic rather than a search. The sign of `b` chooses the sum or the difference, since squaring either gives `a + |b|*sqrt(c)`. No condition is attached and none is owed. A non-negative `a` and a non-negative discriminant are required before anything is built, and together they make the radicand, `x` and `y` all non-negative -- so what fires is an identity between real numbers with no branch chosen. A negative `a` is refused rather than conditioned, which is why the rule is `Sound` and not `SoundUnderAssumptions`. The inner root is read however the neighbouring rules have left it. They gather `2 * sqrt(2)` into `2 ^ (3/2)`, so a reader insisting on a written `sqrt` declines a radicand it can perfectly well denest; any half-integer exponent counts, `m ^ (p/2)` being `m ^ ((p-1)/2) * sqrt(m)`. Written next to `ReduceRadical`, which takes a whole power out from under one root where this takes a root out from under another, and to the same contract: one helper answers both the `when` and the replacement, and null means decline. Both spellings of the rule are added, since `MatchedRulesAgreeWithTheSwitchTest` holds them to each other and the corpus it runs generates `sqrt` over sums. Where the denesting is longer, `Simplify` keeps the nested form. `sqrt(2 + sqrt(3))` does come apart -- to `(sqrt(6) + sqrt(2))/2` -- and the nested spelling is shorter, so that is what comes back. The rule offers the alternative and the selection is by size, as everywhere else. One radicand is recorded as a boundary rather than explained. `sqrt(3 + 2*sqrt(2))` is answered by the rule -- applying the set gives `sqrt(2) + 1`, the shorter form -- and `Simplify` returns the nested one anyway. The obvious suspect was that the perfect-square collapse re-nests the radicand; measured, `CollapseToPerfectSquare` answers null for it, so that is ruled out rather than unexamined. The test pins what happens and says the cause is not established. The rule census moves by one throughout: `WritingARule.md` and the four test files that hold the document to it, plus the reversible-rule classification list. Comparison.md's `radical denesting` cell is left as measured, its note now naming four moved rows rather than two. Full suite 9515 passed, 0 failed. Part of #717. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura
1 parent c19393e commit 0e643f3

12 files changed

Lines changed: 428 additions & 30 deletions

File tree

‎BREAKING-CHANGES.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,49 @@ read first.
107107
| **Silent** | `"arccotan(-1)".ToEntity().Simplify()`, and every negative argument the inverse-trigonometric table knows | `3/4 * pi` — the textbook range, and **not equal to `arccotan(-1)`**, whose value is `-pi/4` | `-1/4 * pi` |
108108
| | `"e ^ ln(x)".ToEntity().Simplify()`, and every exponential of a natural logarithm | `e ^ ln(x)` — left as written | `x` |
109109
| | `"a => a + 3".ToEntity()`, and every lambda written with an arrow | `UnhandledParseException` | `lambda(a, a + 3)` |
110+
| | `"sqrt(5 + 2 * sqrt(6))".ToEntity().Simplify()`, and every nested radical whose discriminant is a rational square | `sqrt(5 + 2 * sqrt(6))` — left as written | `sqrt(3) + sqrt(2)` |
110111
| | `"sum(k, k, 1, n)".ToEntity().Simplify()`, and every summation whose body is a polynomial in the index | `sum(k, k, 1, n)` — carried | `piecewise((n + n ^ 2) / 2 provided n >= 0, 0)` |
111112
| | `"sum(k, k, 1, 100000)".ToEntity().Simplify()`, and every concrete range past a hundred terms | `sum(k, k, 1, 100000)` — carried | `5000050000` |
112113
| | `"product(k, k, 1, n)".ToEntity().Simplify()`, and every product whose body is a monomial in the index | `product(k, k, 1, n)` — carried | `piecewise(n! provided n >= 1, 1)` |
113114

115+
### A nested radical comes apart
116+
117+
`sqrt(5 + 2*sqrt(6))` is a radical under a radical, and it is two plain ones added.
118+
119+
```
120+
"sqrt(5 + 2 * sqrt(6))".Simplify() was sqrt(5 + 2 * sqrt(6)) is sqrt(3) + sqrt(2)
121+
"sqrt(7 - 4 * sqrt(3))".Simplify() was as written is 2 - sqrt(3)
122+
"sqrt(9 + 4 * sqrt(5))".Simplify() was as written is sqrt(5) + 2
123+
"sqrt(11 + 6 * sqrt(2))".Simplify() was as written is 3 + sqrt(2)
124+
"sqrt(6 - 2 * sqrt(5))".Simplify() was as written is sqrt(5) - 1
125+
```
126+
127+
Squaring `sqrt(x) + sqrt(y)` gives `x + y + 2*sqrt(x*y)`, so matching that against
128+
`a + b*sqrt(c)` makes `x` and `y` the roots of `t^2 - a*t + b^2*c/4`. They are rational exactly
129+
when `a^2 - b^2*c` is the square of a rational, and that is the whole test — decidable in exact
130+
arithmetic rather than a search. The sign of `b` chooses the sum or the difference, since squaring
131+
either gives `a + |b|*sqrt(c)`.
132+
133+
**No condition is attached and none is owed.** A non-negative `a` and a non-negative discriminant
134+
are required before anything is built, and together they make the radicand, `x` and `y` all
135+
non-negative — so what fires is an identity between real numbers with no branch chosen. A negative
136+
`a` is refused rather than conditioned.
137+
138+
**A radicand with no rational split is left as written**, `sqrt(1 + sqrt(2))` among them, and so
139+
is anything that is not `a + b*sqrt(c)`.
140+
141+
**Where the denesting is longer, `Simplify` keeps the nested form.** `sqrt(2 + sqrt(3))` does come
142+
apart — to `(sqrt(6) + sqrt(2))/2` — and the nested spelling is the shorter of the two, so that is
143+
what comes back. The rule offers the alternative; the selection is by size, as it is everywhere
144+
else.
145+
146+
One radicand is a boundary rather than a rule: `sqrt(3 + 2*sqrt(2))` is answered by the rule —
147+
applying the set gives `sqrt(2) + 1`, the shorter form — and `Simplify` nonetheless returns the
148+
nested one. There is a test pinning what happens; the cause is not established, and the obvious
149+
suspect was measured and ruled out.
150+
151+
[#717](https://github.com/asc-community/AngouriMath/issues/717).
152+
114153
### A polynomial summand is summed in closed form
115154

116155
A summation wrote itself out term by term where the bounds were concrete and there were fewer

‎Sources/.editorconfig‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
186186
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
187187
[Tests/UnitTests/Core/ClosedFormProductTest.cs]
188188
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
189+
190+
[Tests/UnitTests/PatternsTest/DenestRadicalTest.cs]
191+
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n

‎Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2884,7 +2884,25 @@ is var (divided, remainder)
28842884
Soundness.Sound,
28852885
when: bound => Functions.Patterns.ReduceRadical(
28862886
(Integer)bound["radicand"], (Rational)bound["power"]) is not null,
2887-
description: "sqrt(8) = 2 * sqrt(2), and its like for a positive whole radicand"));
2887+
description: "sqrt(8) = 2 * sqrt(2), and its like for a positive whole radicand"),
2888+
2889+
// The rule above takes a whole power out from under one root; this takes a root out
2890+
// from under another, which is the nesting rather than the size. Sound rather than
2891+
// conditional: the helper refuses every radicand it cannot square its answer back
2892+
// to, so what fires is an identity between two non-negative reals with no branch
2893+
// chosen -- see the remarks on DenestRadical for why a non-negative `a` and a square
2894+
// discriminant are the whole of what it needs.
2895+
new MatchedRule(
2896+
"a-nested-radical-is-a-sum-of-two-plain-ones",
2897+
MatchPattern.Node<Powf>(
2898+
MatchPattern.Any("radicand"),
2899+
MatchPattern.Any<Rational>("power", ratio => ratio.ERational.Equals(
2900+
PeterO.Numbers.ERational.Create(
2901+
PeterO.Numbers.EInteger.One, PeterO.Numbers.EInteger.FromInt32(2))))),
2902+
bound => Functions.Patterns.DenestRadical(bound["radicand"])!,
2903+
Soundness.Sound,
2904+
when: bound => Functions.Patterns.DenestRadical(bound["radicand"]) is not null,
2905+
description: "sqrt(5 + 2*sqrt(6)) = sqrt(2) + sqrt(3)"));
28882906

28892907
/// <summary>
28902908
/// <see cref="Functions.Patterns.CommonRules"/>, as data.

‎Sources/AngouriMath/Docs/Contributing/WritingARule.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ effect". This is how to supply those seven things.
1818
## Where a rule goes
1919

2020
`Core/Transformations/Matching/MatchedRules.cs`, as a value in a `MatchedRuleSet`. **33** sets and
21-
**323** rules live there today.
21+
**324** rules live there today.
2222

2323
The `switch` statements in `Functions/Simplification/Patterns` are the older form. Twenty-seven of
2424
the thirty registered sets no longer run theirs, and **none of those twenty-seven describes it any
@@ -62,7 +62,7 @@ in a name and putting the identity in brackets after it:
6262
2. Tangent is sine over cosine (tan(a) = sin(a) / cos(a)), so tan(x) becomes sin(x) / cos(x).
6363
```
6464

65-
So the name has to be a clause that survives being read that way. **All 294 distinct rule names are,
65+
So the name has to be a clause that survives being read that way. **All 295 distinct rule names are,
6666
and `StepAsASentenceTest` holds them to it** — a name with a capital, a bracket or an underscore
6767
fails that test rather than degrading the prose quietly.
6868

@@ -89,7 +89,7 @@ debugged for an afternoon.
8989
| `Left.ToString()` | `Divf(var a, Divf(var b, var c))` — how the matcher spells it |
9090

9191
Write the identity with `=`, not `->`: it is an equality, and the arrow belongs to the direction the
92-
rule happens to be applied in. **293** rules carry one today; a new rule should.
92+
rule happens to be applied in. **294** rules carry one today; a new rule should.
9393

9494
## The pattern language
9595

@@ -121,7 +121,7 @@ matches and silently builds nothing. `BuildableNodeTypesTest` is the list.
121121

122122
## Replacement: a pattern, or code
123123

124-
**35** of the 323 rules have a pattern on both sides. The rest build their answer in code, and both
124+
**35** of the 324 rules have a pattern on both sides. The rest build their answer in code, and both
125125
are legitimate — but the choice costs two things, so make it deliberately.
126126

127127
A pattern replacement gets:
@@ -133,7 +133,7 @@ A pattern replacement gets:
133133
- **an exact growth**, counted from the two patterns rather than declared.
134134

135135
A code replacement gets neither, and its growth is `Unknown` unless you declare one. That is the
136-
honest default — **260** rules sit at `Unknown` — but declare it where you can justify it:
136+
honest default — **261** rules sit at `Unknown` — but declare it where you can justify it:
137137

138138
```csharp
139139
// The Chebyshev expansion of sin(n * a) is a sum of n terms where the pattern is one node,
@@ -154,7 +154,7 @@ limit, against nothing for handing the node over.
154154
| `SoundUnderAssumptions` | holds given something the rule does not check |
155155
| `Heuristic` | usually right |
156156

157-
**181** of the 323 rules are `Sound` and **142** are conditional. Every one of the thirty registered
157+
**182** of the 324 rules are `Sound` and **142** are conditional. Every one of the thirty registered
158158
*sets* declares `SoundUnderAssumptions`, because a set's tier is the **minimum** over its rules — so
159159
the set grain says nothing and the rule grain says everything. A derivation reports the rule's tier
160160
(`RewriteStep.Soundness`), which is why getting it right matters beyond the label.

‎Sources/AngouriMath/Docs/Usage/Comparison.md‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,28 @@ Long answers are truncated in the table below at 64 characters, with `…`.
248248
| holonomic | holonomic functions | `expr_to_holonomic exists: True` | `<no member at all matching "Holonomic">` | absent |
249249
| codegen | compile to a callable | `9` | `9` | answers |
250250

251-
### Two rows have moved since the run
252-
253-
**concrete / symbolic summation** and **concrete / symbolic product** were both measured as
254-
`declines` at `6b93b401` and are answered now: `sum(k, k, 1, n)` is `(n + n^2)/2` and
255-
`product(k, k, 1, n)` is `factorial(n)`, each carrying a condition on the range being non-empty
256-
that this library's empty-range convention requires and SymPy's reversed-range convention does
257-
not. The rows are left as they were measured rather than edited, because the table is a record of
258-
one run at one commit and a hand-corrected cell would be a measurement nobody took; they will
259-
read `answers` when `sympyparity` is next run.
260-
261-
The row between them has **not** moved. *Infinite series* is still declined —
262-
`sum(1 / k ^ 2, k, 1, +oo)` needs the zeta function, which the row four below records as absent,
263-
and an infinite bound is refused outright by both closed forms.
251+
### Four rows have moved since the run
252+
253+
Each was measured as `declines` at `6b93b401` and is answered now:
254+
255+
| row | this library, re-measured |
256+
|---|---|
257+
| polys / factor over the rationals | `x ^ 4 - 1` is `(x + 1) * (x - 1) * (x ^ 2 + 1)` |
258+
| simplify / radical denesting | `sqrt(5 + 2 * sqrt(6))` is `sqrt(3) + sqrt(2)` |
259+
| concrete / symbolic summation | `sum(k, k, 1, n)` is `(n + n^2)/2` |
260+
| concrete / symbolic product | `product(k, k, 1, n)` is `factorial(n)` |
261+
262+
The last two each carry a condition on the range being non-empty, which this library's
263+
empty-range convention requires and SymPy's reversed-range convention does not.
264+
265+
**The cells are left as they were measured rather than edited.** The table is a record of one run
266+
at one commit, and a hand-corrected cell would be a measurement nobody took; they will read
267+
`answers` when `sympyparity` is next run.
268+
269+
Two neighbours have **not** moved, and both for the same reason — a special function this library
270+
does not have. *Infinite series* needs `zeta`, which the row four below records as absent, and an
271+
infinite bound is refused outright by both closed forms; *combine logarithms* is declined here and
272+
by SymPy alike, so it was never a parity gap.
264273

265274
### Implemented, but not reachable from outside
266275

0 commit comments

Comments
 (0)