Skip to content
Merged
1 change: 1 addition & 0 deletions .github/workflows/CSharpTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ jobs:
uses: codecov/codecov-action@v5
with:
file: ./Sources/Tests/UnitTests/coverage.opencover.xml
flags: csharp
13 changes: 13 additions & 0 deletions .github/workflows/FSharpTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,18 @@ jobs:
dotnet build Sources/Tests/FSharpWrapperUnitTests

- name: Test
if: ${{ matrix.os != 'ubuntu-latest' }}
run: |
dotnet test "Sources/Tests/FSharpWrapperUnitTests" -c Release -l GitHubActions

- name: Test with coverage
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
dotnet test "Sources/Tests/FSharpWrapperUnitTests" -c Release -l GitHubActions --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover

- name: 'Send to codecov'
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: codecov/codecov-action@v5
with:
directory: ./coverage
flags: fsharp
17 changes: 16 additions & 1 deletion .github/workflows/InteractiveTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,24 @@ jobs:
dotnet build Sources/Tests/TerminalUnitTests -c Release

- name: Test Interactive Wrapper
if: ${{ matrix.os != 'ubuntu-latest' }}
run: |
dotnet test "Sources/Tests/InteractiveWrapperUnitTests" -c Release

- name: Test Terminal Lib
if: ${{ matrix.os != 'ubuntu-latest' }}
run: |
dotnet test "Sources/Tests/TerminalUnitTests" -c Release

- name: Test with coverage
if: ${{ matrix.os == 'ubuntu-latest' }}
run: |
dotnet test "Sources/Tests/InteractiveWrapperUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
dotnet test "Sources/Tests/TerminalUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover

- name: 'Send to codecov'
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: codecov/codecov-action@v5
with:
directory: ./coverage
flags: interactive
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ go, named for the issue.
**Reproduce on a stock `master` build before claiming anything is broken.** More than one confident
report here has turned out to describe behaviour that was never broken.

The same measurement is worth taking before you plan a fix at all. An open issue names the version
its reporter was on, and the tracker is older than the code: on 2026-08-05, eleven of the open
issues turned out to be already fixed, and re-measuring them cost minutes where planning fixes for
them would have cost days. Run the reporter's expression first. Close it with the measurement if it
answers, and say which build you measured.

## Aim high

The ambition is to answer the hard problems: olympiad and competition algebra, calculus and number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ n0.Evaled is Complex c
(_, Integer(0)) => Real.NaN,
(Complex n1, Complex n2) when !isExact => n1 / n2,
(var n1, Integer(1)) => n1,
// A radical in the denominator is the reciprocal power asked for outright,
// and answering it there is what makes 1/sqrt(2) and sqrt(1/2) the same
// thing on the page as well as in value.
// https://github.com/asc-community/AngouriMath/issues/205
(var n1, Powf(Rational radicand, Rational exponent)) when
RootExtraction.PullOutOfDenominator(radicand, exponent) is { } rationalized
=> (n1 * rationalized).InnerSimplified(isExact),
_ => null
},
(@this, a, b) => ((Divf)@this).New(a, b), isExact);
Expand Down Expand Up @@ -182,11 +189,14 @@ x.Evaled is Complex c
: 1
: new Providedf(1, !x.EqualTo(0)),
(var n1, Integer(1)) => n1,
// https://github.com/asc-community/AngouriMath/issues/281
(Rational radicand, Rational exponent) when
RootExtraction.PullOutOfRadical(radicand, exponent) is { } pulled => pulled.InnerSimplified(isExact),
_ => null
},
(@this, a, b) => ((Powf)@this).New(a, b), isExact);
}

public partial record Logf
{
// Logarithm is undefined when:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ protected override Entity InnerSimplify(bool isExact) =>
a => a switch
{
Complex n when !isExact => Number.Arcsin(n),
{ Evaled: Complex n } when isExact && InverseTrigonometricTableValues.PullArcsin(n, out var res) => res,
_ => null
},
(@this, a) => ((Arcsinf)@this).New(a), isExact);
Expand All @@ -209,6 +210,7 @@ protected override Entity InnerSimplify(bool isExact) =>
a => a switch
{
Complex n when !isExact => Number.Arccos(n),
{ Evaled: Complex n } when isExact && InverseTrigonometricTableValues.PullArccos(n, out var res) => res,
_ => null
},
(@this, a) => ((Arccosf)@this).New(a), isExact);
Expand All @@ -232,6 +234,7 @@ Real r when r.EDecimal.IsNegativeInfinity() => -MathS.pi / 2,
// pi/4 stopped one step short of it.
Integer(1) => MathS.pi / 4,
Integer(-1) => -MathS.pi / 4,
{ Evaled: Complex n } when isExact && InverseTrigonometricTableValues.PullArctan(n, out var res) => res,
_ => null
},
(@this, a) => ((Arctanf)@this).New(a), isExact);
Expand All @@ -247,6 +250,7 @@ protected override Entity InnerSimplify(bool isExact) =>
a => a switch
{
Complex n when !isExact => Number.Arccotan(n),
{ Evaled: Complex n } when isExact && InverseTrigonometricTableValues.PullArccotan(n, out var res) => res,
_ => null
},
(@this, a) => ((Arccotanf)@this).New(a), isExact);
Expand Down
168 changes: 168 additions & 0 deletions Sources/AngouriMath/Functions/NumberTheory/RootExtraction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//
// 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 PeterO.Numbers;

namespace AngouriMath.Functions
{
using static Entity;
using static Entity.Number;

/// <summary>
/// Writes a rational raised to a rational power as a whole part times a radical that has
/// nothing left to give up, so that sqrt(12) is 2 * sqrt(3) and sqrt(1/2) is sqrt(2) / 2.
/// https://github.com/asc-community/AngouriMath/issues/281
/// </summary>
internal static class RootExtraction
{
// Trial division is the whole method, and these two bounds are what keep it from costing
// more than the tidier answer is worth: a power is simplified over and over, and most of
// them have nothing to give up, so the ordinary case has to be a handful of divisions.
//
// They are also chosen so that the extraction is *complete* rather than best-effort. Once
// every factor below the divisor bound has been taken out, what is left is either one, a
// prime, or a product of two primes above the bound -- three primes above 10^4 would
// already exceed 10^12. A product of two distinct primes has nothing to extract for any
// root, and the one remaining case, a square of a single prime, is what the perfect-power
// check at the end is for. So nothing within the bound is missed.
[ConstantField] private static readonly EInteger maxRadicand = EInteger.FromInt64(1_000_000_000_000L);
[ConstantField] private static readonly EInteger maxDivisor = EInteger.FromInt32(10_000);

/// <summary>
/// <see cref="maxRadicand"/> as a bit count, rounded up, so that a radicand can be
/// rejected before it is raised to the exponent's numerator.
/// </summary>
private const int MaxRadicandBits = 40;

/// <summary>
/// A root of an order beyond this is not one anybody wrote down, and the radicand is
/// raised to the numerator before anything else happens, so both have to stay small.
/// </summary>
private const int MaxExponentPart = 64;

/// <summary>
/// The reciprocal of <paramref name="radicand"/> ^ <paramref name="exponent"/>, written
/// without a radical below the line, or null when the exponent is not a root at all.
/// </summary>
internal static Entity? PullOutOfDenominator(Rational radicand, Rational exponent)
=> PullOutOfRadical(radicand,
Rational.Create(exponent.Numerator.EInteger.Negate(), exponent.Denominator.EInteger));

/// <summary>
/// Rewrites <paramref name="radicand"/> ^ <paramref name="exponent"/> as an exact factor
/// times a radical, or returns null when there is nothing to pull out.
/// </summary>
internal static Entity? PullOutOfRadical(Rational radicand, Rational exponent)
{
// A negative radicand is declined for correctness and not out of caution: the
// principal cube root of -8 is 1 + i * sqrt(3), not -2, so pulling the sign out
// would quietly pick the real branch over the one the rest of the library uses.
if (radicand.Numerator.EInteger.Sign <= 0)
return null;
if (!exponent.Numerator.EInteger.CanFitInInt32() || !exponent.Denominator.EInteger.CanFitInInt32())
return null;
var power = exponent.Numerator.EInteger.ToInt32Checked();
var rootIndex = exponent.Denominator.EInteger.ToInt32Checked();
// A whole exponent is somebody else's case.
if (rootIndex < 2 || power is 0 || System.Math.Abs(power) > MaxExponentPart || rootIndex > MaxExponentPart)
return null;

// A negative exponent is the same question asked of the reciprocal: 2^(-1/2) is
// (1/2)^(1/2). Turning it round rather than declining it is what keeps 1/sqrt(2)
// and sqrt(1/2) from printing differently, which they did.
var (above, below) = power < 0
? (radicand.Denominator.EInteger, radicand.Numerator.EInteger)
: (radicand.Numerator.EInteger, radicand.Denominator.EInteger);
power = System.Math.Abs(power);

// Checked before the power is taken rather than after. A thousand-digit rational
// raised to the 64th is a sixty-thousand-digit integer, and building one only to
// find it over the bound and throw it away is the whole cost of the call.
if (TooLargeRaised(above, power) || TooLargeRaised(below, power))
return null;

var numerator = above.Pow(power);
var denominator = below.Pow(power);
if (numerator.CompareTo(maxRadicand) > 0 || denominator.CompareTo(maxRadicand) > 0)
return null;

Split(numerator, rootIndex, out var wholeAbove, out var underAbove);
Split(denominator, rootIndex, out var wholeBelow, out var underBelow);

// A radical left in the denominator is worse than the form we started from, so it is
// moved upstairs the usual way: 1 / b^(1/n) is b^((n-1)/n) / b. What arrives up there
// may itself have a whole part -- 1/8 under a cube root leaves 8^2, which is 4 cubed
// -- so it goes back through the same split rather than being multiplied on as is.
if (!underBelow.Equals(EInteger.One))
{
var raised = underBelow.Pow(rootIndex - 1);
if (raised.CompareTo(maxRadicand) > 0)
return null;
var moved = underAbove.Multiply(raised);
if (moved.CompareTo(maxRadicand) > 0)
return null;
Split(moved, rootIndex, out var wholeMoved, out underAbove);
wholeAbove = wholeAbove.Multiply(wholeMoved);
wholeBelow = wholeBelow.Multiply(underBelow);
underBelow = EInteger.One;
}

if (wholeAbove.Equals(EInteger.One) && wholeBelow.Equals(EInteger.One)
&& underAbove.Equals(numerator) && underBelow.Equals(denominator))
return null;

var whole = Rational.Create(wholeAbove, wholeBelow);
if (underAbove.Equals(EInteger.One))
return whole;
return whole * new Powf(Integer.Create(underAbove), Rational.Create(EInteger.One, EInteger.FromInt32(rootIndex)));
}

/// <summary>
/// Whether <paramref name="value"/> raised to <paramref name="power"/> would exceed the
/// radicand bound, decided from the bit length so that the power is never taken.
/// </summary>
private static bool TooLargeRaised(EInteger value, int power)
=> value.GetUnsignedBitLengthAsEInteger()
.Multiply(power)
.CompareTo(EInteger.FromInt32(MaxRadicandBits)) > 0;

/// <summary>
/// Splits <paramref name="value"/> into the part that comes out from under a root of
/// order <paramref name="rootIndex"/> and the part that stays under it.
/// </summary>
private static void Split(EInteger value, int rootIndex, out EInteger whole, out EInteger under)
{
whole = EInteger.One;
under = EInteger.One;
var remaining = value;
for (var divisor = EInteger.FromInt32(2);
divisor.CompareTo(maxDivisor) <= 0 && divisor.Multiply(divisor).CompareTo(remaining) <= 0;
divisor = divisor.Add(EInteger.One))
{
var multiplicity = 0;
while (remaining.Remainder(divisor).IsZero)
{
remaining = remaining.Divide(divisor);
multiplicity++;
}
if (multiplicity is 0)
continue;
whole = whole.Multiply(divisor.Pow(multiplicity / rootIndex));
under = under.Multiply(divisor.Pow(multiplicity % rootIndex));
}
if (remaining.Equals(EInteger.One))
return;
// What survives trial division is prime or a product of two primes, so the only thing
// it can still hide is being an exact power itself.
var root = remaining.Root(rootIndex);
if (root.Pow(rootIndex).Equals(remaining))
whole = whole.Multiply(root);
else
under = under.Multiply(remaining);
}
}
}
Loading
Loading