From 16f5027be75d94b7115ed0a6693bc2d24b276fd8 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 5 Aug 2026 19:58:32 +0000 Subject: [PATCH] Read the trigonometric table around the whole circle (#743) Every angle in the table of exact values is written `2pi/n`, so it names only the angles that divide the turn evenly -- and nothing at all between `2pi/5` and `2pi/3`, which is most of the second quadrant and the whole lower half. Each lookup then tried a couple of identities to reach the rest, and they reach some of it and not the neighbouring rest: sin(2pi/3) -> sqrt(3)/2 the table has the entry sin(4pi/3) -> -sqrt(3)/2 reached by the doubled-angle identity sin(5pi/3) -> sin(5/3 * pi) the same value again, and not reached What was missing is the half turn: `sin(x) = -sin(x - pi)`, and the same for the cosine, while the tangent's period *is* half a circle so it takes no sign back. Applied once around the lookups rather than inside them, so it cannot turn back on itself and loop. The table entries are also read before a value is built out of the doubled angle, which was not the previous order and is what makes the output consistent. Both routes are exact; the doubled-angle one builds a nested radical where the table has a flat one, so `cos(6pi/5)` came back as `-sqrt((1 + (sqrt(5) - 1)/4) / 2)` for a number the table names as `-(sqrt(5) + 1)/4`, and the roots of `x^5 = 1` were written both ways at once. This is how #743 was reported: the roots of unity are handed back in a + bi form, and one of six came back carrying a sine. x^6 - 1 = 0 was { 1, 1/2 + i*sqrt(3)/2, ..., 1/2 + i * sin(5/3 * pi) } now { 1, 1/2 + i*sqrt(3)/2, ..., 1/2 + i * -1/2*sqrt(3) } x^5 - 1 = 0 had cos(4/5*pi) and sin(8/5*pi) in it; both are now exact, and each root is now written the way its conjugate is. Measured by sweeping every `k/n * pi` of the first turn for eighteen denominators -- 1158 calls -- and checking each resolved value against the numeric value of the call it replaced. 581 resolved before and 690 after, with no disagreement in either run: what was missing was missing rather than wrong, and the 109 newly exact values are right. Suite 4979 -> 4991 passed / 0 failed, F# 130/130, corpus 112/117 with 0 wrong and every verdict and answer byte-identical. rootcheck is 595/596 on this branch, its one incomplete case being #744, which is a separate defect in the solver and is fixed in PR #745 rather than here. Co-Authored-By: Claude Opus 5 --- .../TrigonometricTableValues.cs | 134 ++++++++++--- .../Common/TrigonometricTableTest.cs | 188 ++++++++++++++++++ 2 files changed, 289 insertions(+), 33 deletions(-) create mode 100644 Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs diff --git a/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs b/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs index 5a9dc6a03..38fe31cb6 100644 --- a/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs +++ b/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs @@ -62,58 +62,126 @@ private static bool TryPulling(TrigTable table, Complex arg, return false; } - internal static bool PullSin(Complex arg, + /// + /// Every angle in the tables below is written 2pi/n, so they name nothing at + /// all between 2pi/5 and 2pi/3 -- the whole lower half of the circle + /// is missing, and most of the second quadrant with it. The identities each Pull + /// tries reach some of what is missing and not the rest, which is why + /// sin(2pi/3) was answered exactly while its neighbour sin(5pi/3), the + /// same value negated, was left as written. Turning the argument by half a circle + /// and taking the sign back is what the tables are short of. It is applied once, + /// around the lookups rather than inside them, so it cannot turn back and loop. + /// https://github.com/asc-community/AngouriMath/issues/743 + /// + private static Complex HalfTurn(Complex arg) => arg - (Real)MathS.DecimalConst.pi; + + /// + /// Tries a lookup on the argument and then on the argument turned by half a circle, + /// taking the sign back over the turn if . Sine + /// and cosine both change sign; the tangent's period *is* half a circle, so it does + /// not. + /// + private static bool OrHalfTurn(System.Func lookup, + Complex arg, bool oddOverHalfTurn, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res) { - if (TryPulling(TableSin, arg, out res)) - return true; - if (TryPulling(TableSin, (Real)MathS.DecimalConst.pi - arg, out res)) + if (lookup(arg) is (true, { } here)) + { + res = here; return true; - if (TryPulling(TableCos, arg * 2, out res)) + } + if (lookup(HalfTurn(arg)) is (true, { } turned)) { - res = MathS.Sqrt((1 - res) / 2); - if (Number.Sin(arg) is Real real && real < 0) - res *= -1; + res = oddOverHalfTurn ? -turned : turned; return true; } + res = null; return false; } - internal static bool PullCos(Complex arg, + /// Sine read off the table itself, which is where the exact values are. + private static (bool, Entity?) SinFromTable(Complex arg) + { + if (TryPulling(TableSin, arg, out var res)) + return (true, res); + // sin(x) = sin(pi - x) + if (TryPulling(TableSin, (Real)MathS.DecimalConst.pi - arg, out res)) + return (true, res); + return (false, null); + } + + /// + /// Sine built out of the cosine of the doubled angle. Tried only after every table + /// reading has been, because what it builds is a nested radical where the table has + /// a flat one: at 6pi/5 it gives sqrt((1 + (sqrt(5) - 1)/4) / 2) for a value the + /// table names as (sqrt(5) + 1)/4, and the roots of x^5 = 1 then come back written + /// two different ways. + /// + private static (bool, Entity?) SinFromDoubledAngle(Complex arg) + { + // sin(x) = +-sqrt((1 - cos(2x)) / 2) + if (!TryPulling(TableCos, arg * 2, out var res)) + return (false, null); + res = MathS.Sqrt((1 - res) / 2); + if (Number.Sin(arg) is Real real && real < 0) + res *= -1; + return (true, res); + } + + internal static bool PullSin(Complex arg, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res) + => OrHalfTurn(SinFromTable, arg, oddOverHalfTurn: true, out res) + || OrHalfTurn(SinFromDoubledAngle, arg, oddOverHalfTurn: true, out res); + + private static (bool, Entity?) CosFromTable(Complex arg) { - if (TryPulling(TableCos, arg, out res)) - return true; + if (TryPulling(TableCos, arg, out var res)) + return (true, res); + // cos(x) = cos(-x) if (TryPulling(TableCos, -1 * arg, out res)) - return true; - if (TryPulling(TableCos, arg * 2, out res)) - { - res = MathS.Sqrt((1 + res) / 2); - if (Number.Cos(arg) is Real real && real < 0) - res *= -1; - return true; - } - return false; + return (true, res); + return (false, null); } - internal static bool PullTan(Complex arg, + private static (bool, Entity?) CosFromDoubledAngle(Complex arg) + { + // cos(x) = +-sqrt((1 + cos(2x)) / 2) + if (!TryPulling(TableCos, arg * 2, out var res)) + return (false, null); + res = MathS.Sqrt((1 + res) / 2); + if (Number.Cos(arg) is Real real && real < 0) + res *= -1; + return (true, res); + } + + internal static bool PullCos(Complex arg, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res) + => OrHalfTurn(CosFromTable, arg, oddOverHalfTurn: true, out res) + || OrHalfTurn(CosFromDoubledAngle, arg, oddOverHalfTurn: true, out res); + + private static (bool, Entity?) TanFromTable(Complex arg) { - if (TryPulling(TableTan, arg, out res)) - return true; + if (TryPulling(TableTan, arg, out var res)) + return (true, res); + // tan(x) = -tan(pi - x) if (TryPulling(TableTan, (Real)MathS.DecimalConst.pi - arg, out res)) - { - res *= -1; - return true; - } - if (TryPulling(TableCos, arg * 2, out res)) - { - res = MathS.Sqrt((1 - res) / (1 + res)); - return true; - } - return false; + return (true, res * -1); + return (false, null); } + private static (bool, Entity?) TanFromDoubledAngle(Complex arg) + { + // tan(x) = sqrt((1 - cos(2x)) / (1 + cos(2x))) + if (!TryPulling(TableCos, arg * 2, out var res)) + return (false, null); + return (true, MathS.Sqrt((1 - res) / (1 + res))); + } + + internal static bool PullTan(Complex arg, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Entity? res) + => OrHalfTurn(TanFromTable, arg, oddOverHalfTurn: false, out res) + || OrHalfTurn(TanFromDoubledAngle, arg, oddOverHalfTurn: false, out res); + private static Entity Sqrt(Entity a) => MathS.Pow(a, f1_2); private static Entity Cbrt(Entity a) => MathS.Pow(a, f1_3); [ConstantField] private static readonly Entity f1_2 = Rational.Create(1, 2); diff --git a/Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs b/Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs new file mode 100644 index 000000000..ecdd008a4 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/TrigonometricTableTest.cs @@ -0,0 +1,188 @@ +// +// Copyright (c) 2019-2022 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System.Collections.Generic; +using System.Linq; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Common +{ + /// + /// The table of exact trigonometric values is written as 2pi/n, so it names only + /// the angles of the first turn that divide it evenly, and the identities used to read + /// it reached some of the rest and not the others. sin(2pi/3) came back + /// sqrt(3)/2 while sin(5pi/3), the same value negated, was left written as + /// a sine -- so the roots of x^6 = 1 were answered five exactly and one as + /// 1/2 + i*sin(5/3*pi), its own conjugate spelled differently. + /// https://github.com/asc-community/AngouriMath/issues/743 + /// + public sealed class TrigonometricTableTest + { + /// The denominators the table has entries for, plus a few it has not. + private static readonly int[] Denominators = + { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 18, 20, 24 }; + + private static readonly string[] Functions = { "sin", "cos", "tan" }; + + private static IEnumerable<(string Function, string Angle)> EveryAngleOfTheFirstTurn() + { + foreach (var n in Denominators) + for (var k = 0; k <= 2 * n; k++) + foreach (var function in Functions) + yield return (function, $"{k}/{n} * pi"); + } + + private static bool IsUnevaluatedTrig(Entity expr) => + expr.Nodes.Any(node => node is Entity.Sinf or Entity.Cosf or Entity.Tanf + or Entity.Cotanf or Entity.Secantf or Entity.Cosecantf); + + private static double? Value(Entity expr) + { + try + { + var value = expr.EvalNumerical(); + if (!value.IsFinite) + return null; + return value.RealPart.EDecimal.ToDouble(); + } + catch (System.Exception) { return null; } + } + + /// + /// The property that matters most, and the one a wrong table entry would break: an + /// exact value handed back in place of a trigonometric call has to be that call's + /// value. Swept over every k/n of the first turn rather than over the angles the + /// issue names, because the risk of widening a lookup table is that it starts + /// answering angles it does not know. + /// + [Fact] + public void EveryExactValueAgreesWithTheCallItReplaces() + { + var resolved = 0; + foreach (var (function, angle) in EveryAngleOfTheFirstTurn()) + { + var call = $"{function}({angle})".ToEntity(); + var simplified = call.InnerSimplified; + if (IsUnevaluatedTrig(simplified)) + continue; + resolved++; + var expected = Value(call); + var actual = Value(simplified); + if (expected is null) + { + // tan(pi/2) and its kind: undefined either way is agreement. + Assert.True(actual is null, + $"{function}({angle}) has no finite value but was given {simplified.Stringize()}"); + continue; + } + Assert.True(actual is not null, + $"{function}({angle}) is {expected} but was given the non-finite " + + $"{simplified.Stringize()}"); + Assert.Equal(expected.Value, actual!.Value, 12); + } + // A sweep that resolved nothing would pass every assertion above. Measured over + // the 1158 calls swept: 581 of them resolved before the half turn was applied + // and 690 after, and the 581 were already sound -- what was missing was missing, + // not wrong. Raise this if the table gains entries; it is here to catch losing + // them silently. + Assert.True(resolved >= 690, $"only {resolved} of the swept angles resolved at all"); + } + + /// + /// The angles the issue names. Each is an exact value wearing an unevaluated form: + /// sin(5pi/3) is -sqrt(3)/2 and cos(4pi/5) is -(1 + sqrt(5))/4. + /// + [Theory] + [InlineData("sin(5/3 * pi)", "-sqrt(3) / 2")] + [InlineData("cos(4/5 * pi)", "-(1 + sqrt(5)) / 4")] + [InlineData("sin(8/5 * pi)", "-sqrt(10 + 2 * sqrt(5)) / 4")] + // Their neighbours in the lower half of the circle, which the table reached no + // better and which nothing had reported. + [InlineData("sin(7/6 * pi)", "-1/2")] + [InlineData("sin(11/6 * pi)", "-1/2")] + [InlineData("cos(5/4 * pi)", "-sqrt(2) / 2")] + [InlineData("cos(7/4 * pi)", "sqrt(2) / 2")] + [InlineData("tan(5/3 * pi)", "-sqrt(3)")] + [InlineData("cos(6/5 * pi)", "-(1 + sqrt(5)) / 4")] + public void AnAngleOfTheLowerHalfIsAnsweredExactly(string call, string expected) + { + var simplified = call.ToEntity().Simplify(); + Assert.False(IsUnevaluatedTrig(simplified), + $"{call} came back as {simplified.Stringize()}"); + Assert.Equal(Value(expected.ToEntity())!.Value, Value(simplified)!.Value, 12); + } + + /// + /// What the issue was found through: the roots of x^n = 1 are handed back in a + bi + /// form, and an unevaluated sine in the last of them made the answer inconsistent + /// with the conjugate two lines above it. + /// + [Theory] + [InlineData("x ^ 3 - 1 = 0", 3)] + [InlineData("x ^ 4 - 1 = 0", 4)] + [InlineData("x ^ 5 - 1 = 0", 5)] + [InlineData("x ^ 6 - 1 = 0", 6)] + [InlineData("x ^ 8 - 1 = 0", 8)] + [InlineData("x ^ 12 - 1 = 0", 12)] + public void NoRootOfUnityCarriesAnUnevaluatedTrigonometricCall(string equation, int count) + { + var roots = (Entity.Set.FiniteSet)equation.ToEntity().Solve("x"); + Assert.Equal(count, roots.Count); + foreach (var root in roots) + Assert.False(IsUnevaluatedTrig(root), + $"{equation} answered {root.Stringize()}, which is not an exact value"); + } + + /// + /// A root and its conjugate must be written the same way. Reading the table before + /// building a value out of the doubled angle is what settles this: the doubled-angle + /// route gives sqrt((1 + (sqrt(5) - 1)/4) / 2) where the table names the same number + /// (sqrt(5) + 1)/4, so before it x^5 = 1 came back written both ways at once. + /// + [Theory] + [InlineData("x ^ 5 - 1 = 0")] + [InlineData("x ^ 6 - 1 = 0")] + [InlineData("x ^ 12 - 1 = 0")] + public void ARootOfUnityIsWrittenAsItsConjugateIs(string equation) + { + var roots = (Entity.Set.FiniteSet)equation.ToEntity().Solve("x"); + var byValue = roots.ToDictionary( + root => (System.Math.Round(root.EvalNumerical().RealPart.EDecimal.ToDouble(), 9), + System.Math.Round(root.EvalNumerical().ImaginaryPart.EDecimal.ToDouble(), 9)), + root => root); + foreach (var ((re, im), root) in byValue) + { + if (im == 0 || !byValue.TryGetValue((re, -im), out var conjugate)) + continue; + Assert.Equal(root.Stringize().Replace("-", ""), + conjugate.Stringize().Replace("-", "")); + } + } + + // What must not change: the angles the table always answered. + [Theory] + [InlineData("sin(0)", "0")] + [InlineData("sin(pi)", "0")] + [InlineData("cos(pi)", "-1")] + [InlineData("sin(pi / 2)", "1")] + [InlineData("sin(2/3 * pi)", "sqrt(3) / 2")] + [InlineData("cos(1/5 * pi)", "(sqrt(5) + 1) / 4")] + [InlineData("cos(2/5 * pi)", "(sqrt(5) - 1) / 4")] + [InlineData("sin(4/3 * pi)", "-sqrt(3) / 2")] + [InlineData("cos(pi / 3)", "1/2")] + [InlineData("tan(pi / 4)", "1")] + public void TheAnglesTheTableAlreadyAnsweredAreUnchanged(string call, string expected) + { + var simplified = call.ToEntity().Simplify(); + Assert.False(IsUnevaluatedTrig(simplified), + $"{call} came back as {simplified.Stringize()}"); + Assert.Equal(Value(expected.ToEntity())!.Value, Value(simplified)!.Value, 12); + } + } +}