Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions BREAKING-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,23 @@ small rationals, the reduced form and `Rank` could throw `InvalidCastException`,
| the same, `.ReducedRowEchelonForm` | throws `InvalidCastException` | the identity |
| `"[[a, b], [c, d]]"`, `.RowEchelonForm` | `[[a, b], [(c * a + -c * a) / a, (d * a + -c * b) / a]]` | `[[a, b], [c + -c / a * a, d + -c / a * b]]` |

### Base conversion reads past 2^31, to base 36, and refuses a digit not in the base

**Silent: a different answer.** `FromBaseN` added its digits up in an `int`, so a number past 2^31 wrapped, and it read
a digit that is not in the base as its value. It now adds them up exactly and throws `InvalidNumericSystemException` for
a digit not below the base, or a base outside 2 to 36. The digits go to `Z` in both directions, in either case, as the
catalogue and the MCP server already offered. `ToBaseN` of a rational divides it out in the base, exactly, where it went
through the rounded decimal. https://github.com/asc-community/AngouriMath/issues/2343,
https://github.com/asc-community/AngouriMath/issues/2344

| Input | Was (2.6.0) | Now |
|---|---|---|
| `MathS.FromBaseN("80000000", 16)` | `-2147483648` | `2147483648` |
| `MathS.FromBaseN("102", 2)` | `6` | `InvalidNumericSystemException` |
| `MathS.FromBaseN("ff", 16)` | `KeyNotFoundException` | `255` |
| `MathS.ToBaseN(35, 36)` | `InvalidNumericSystemException` | `Z` |
| `MathS.ToBaseN(1/7, 7)` | 121 characters, the last digit wrong | `0.1` |

### `arcsin` and `arccos` of a real number past 1 have their value

**Silent: `arcsin(x)` and `arccos(x)` of a real `x` past 1 are `sign(x) pi/2 - i arccosh|x|` and `pi/2 - arcsin(x)`,
Expand Down
3 changes: 2 additions & 1 deletion Sources/AngouriMath/Convenience/MathS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4613,7 +4613,8 @@ public static ParsingResult Parse(string source)
/// </code>
/// </example>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ToBaseN(Real num, int N) => BaseConversion.ToBaseN(num.EDecimal, N);
public static string ToBaseN(Real num, int N)
=> num is Rational rational ? BaseConversion.ToBaseN(rational.ERational, N) : BaseConversion.ToBaseN(num.EDecimal, N);

/// <summary>Translates a number in base <paramref name="N"/> into base 10</summary>
/// <param name="num">A <see cref="Real"/> in base <paramref name="N"/> to be translated into base 10</param>
Expand Down
98 changes: 74 additions & 24 deletions Sources/AngouriMath/Functions/BaseConversion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,35 @@ namespace AngouriMath.Functions
using static Entity;
internal static class BaseConversion
{
// TODO: add more digits
[ConstantField] internal static readonly string ALPHABET_TOCHAR = "0123456789ABCDEF";
[ConstantField] internal static readonly Dictionary<char, int> ALPHABET_FROMCHAR =
ALPHABET_TOCHAR.ToDictionary(c => c, ALPHABET_TOCHAR.IndexOf);
/// <summary>
/// The digits, to base 36, as the catalogue's <c>to_base</c> and <c>from_base</c> and the MCP server's
/// <c>am_represent</c> offer: the table stopped at F, so base 17 threw and <c>G</c> could not be read.
/// https://github.com/asc-community/AngouriMath/issues/2343,
/// https://github.com/asc-community/AngouriMath/issues/2344
/// </summary>
[ConstantField] internal static readonly string ALPHABET_TOCHAR = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";

/// <summary>The value of <paramref name="digit"/> in base <paramref name="N"/>, a letter in either case.</summary>
private static int DigitValue(char digit, int N)
{
var value = digit switch
{
>= '0' and <= '9' => digit - '0',
>= 'A' and <= 'Z' => digit - 'A' + 10,
>= 'a' and <= 'z' => digit - 'a' + 10,
_ => -1,
};
if (value < 0 || value >= N)
throw new InvalidNumericSystemException($"'{digit}' is not a digit in base {N}");
return value;
}

private static void CheckBase(int N)
{
if (N < 2 || N > ALPHABET_TOCHAR.Length)
throw new InvalidNumericSystemException(
$"The base has to be from 2 to {ALPHABET_TOCHAR.Length}, since that is how many digits there are, and {N} was given");
}

/// <summary>Transforms an integer to the corresponding base (in string)</summary>
internal static string IntToBaseN(EInteger num, int N)
Expand Down Expand Up @@ -95,9 +120,7 @@ internal static string FloatToBaseN(EDecimal num /*should be < 1*/, int N)
/// </summary>
internal static string ToBaseN(EDecimal num, int N)
{
if (N > ALPHABET_TOCHAR.Length)
throw new InvalidNumericSystemException(
$"The base has to be at most {ALPHABET_TOCHAR.Length}, since that is how many digits there are, and {N} was given");
CheckBase(N);
string sign = num.IsNegative ? "-" : "";
num = num.Abs();
var (intPart, floatPart) = num.SplitDecimal();
Expand All @@ -108,36 +131,59 @@ internal static string ToBaseN(EDecimal num, int N)
return leftPart + rightPart;
}

/// <summary>Gets an integer from a string which should not contain any of ,.-</summary>
internal static int IntFromBaseN(string num, int N)
/// <summary>
/// A rational in base <paramref name="N"/>, each digit of the fraction by long division of its numerator by its
/// denominator: exact, so that <c>1/7</c> in base 7 is <c>0.1</c>, where through its decimal, rounded to the
/// precision and the rounding multiplied up digit by digit, it ran to 119 digits and ended on a wrong one. A
/// repeating expansion is cut where <see cref="FloatToBaseN"/> cuts it.
/// https://github.com/asc-community/AngouriMath/issues/2344
/// </summary>
internal static string ToBaseN(ERational num, int N)
{
int res = 0;
for (int i = 0; i < num.Length; i++)
CheckBase(N);
var sign = num.IsNegative ? "-" : "";
num = num.Abs();
var whole = num.Numerator.Divide(num.Denominator);
var remainder = num.Numerator.Remainder(num.Denominator);
if (remainder.IsZero)
return sign + IntToBaseN(whole, N);
var digits = new System.Text.StringBuilder();
var maxDigits = MaxFractionalDigits(N);
while (!remainder.IsZero && digits.Length < maxDigits)
{
int id = num.Length - i - 1;
char digit = num[id];
res += ALPHABET_FROMCHAR[digit] * (int)Math.Pow(N, i);
remainder *= N;
digits.Append(ALPHABET_TOCHAR[remainder.Divide(num.Denominator).ToInt32Checked()]);
remainder = remainder.Remainder(num.Denominator);
}
return res;
return sign + IntToBaseN(whole, N) + "." + digits;
}

/// <summary>if num is ABC, the initial number was 0.ABC</summary>
internal static EDecimal FloatFromBaseN(string num, int N)
/// <summary>
/// The whole number written in <paramref name="num"/>, which holds no sign and no point. By Horner's rule in
/// <see cref="EInteger"/>: added up in an <c>int</c>, with <c>(int)Math.Pow(N, i)</c> for the place value, a sum
/// past 2^31 wrapped and a place value past it saturated, so <c>FromBaseN("80000000", 16)</c> was
/// <c>-2147483648</c>. https://github.com/asc-community/AngouriMath/issues/2343
/// </summary>
internal static EInteger IntFromBaseN(string num, int N)
{
EDecimal res = 0;
for (int i = 0; i < num.Length; i++)
{
char digit = num[i];
res = Number.CtxAdd(res, Number.CtxDivide(ALPHABET_FROMCHAR[digit], EDecimal.FromInt32(N).Pow(i + 1)));
}
var res = EInteger.Zero;
foreach (var digit in num)
res = res * N + DigitValue(digit, N);
return res;
}

/// <summary>if num is ABC, the initial number was 0.ABC, as the exact quotient of ABC by N^3</summary>
internal static ERational FloatFromBaseN(string num, int N)
=> ERational.Create(IntFromBaseN(num, N), EInteger.FromInt32(N).Pow(num.Length));

/// <summary>
/// Performs operations on both the integer and floating parts of a number and concatenates
/// </summary>
internal static EDecimal FromBaseN(string num, int N)
{
CheckBase(N);
if (num.Length == 0 || num == "-")
throw new InvalidNumericSystemException($"'{num}' has no digits");
int sign = num[0] == '-' ? -1 : 1;
num = num[0] == '-' ? num.Substring(1) : num;
int pos = num.IndexOf('.');
Expand All @@ -153,7 +199,11 @@ internal static EDecimal FromBaseN(string num, int N)
leftPart = num;
rightPart = "";
}
return sign * (IntFromBaseN(leftPart, N) + (rightPart == "" ? 0 : FloatFromBaseN(rightPart, N)));
// Summed exactly and rounded once, into the precision context.
var value = ERational.FromEInteger(IntFromBaseN(leftPart, N));
if (rightPart != "")
value = value.Add(FloatFromBaseN(rightPart, N));
return (sign < 0 ? value.Negate() : value).ToEDecimal(MathS.Settings.DecimalPrecisionContext);
}
}
}
81 changes: 81 additions & 0 deletions Sources/Tests/UnitTests/Common/ABaseConversionToThirtySixTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// Copyright (c) 2019-2026 Angouri.
// AngouriMath is licensed under MIT.
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
// Website: https://am.angouri.org.
//

using AngouriMath.Core.Exceptions;
using PeterO.Numbers;
using Xunit;
using static AngouriMath.Entity.Number;

namespace AngouriMath.Tests.Common
{
/// <summary>
/// <c>FromBaseN</c> added its digits up in an <c>int</c>, so a number past 2^31 wrapped, and it read a digit that
/// is not in the base: <c>"102"</c> in base 2 was 6. The digit table of both directions stopped at F, where the
/// catalogue offers bases to 36. And <c>ToBaseN</c> wrote a rational through its decimal, rounded and the rounding
/// multiplied up, so <c>1/7</c> in base 7 ran to 119 digits.
/// https://github.com/asc-community/AngouriMath/issues/2343,
/// https://github.com/asc-community/AngouriMath/issues/2344
/// </summary>
[Trait("Area", "Common")]
public sealed class ABaseConversionToThirtySixTest
{
[Theory]
[InlineData("80000000", 16, "2147483648")]
[InlineData("100000000", 16, "4294967296")]
[InlineData("2147483648", 10, "2147483648")]
[InlineData("10000000000", 10, "10000000000")]
[InlineData("-10000000000", 10, "-10000000000")]
[InlineData("ff", 16, "255")]
[InlineData("FF", 16, "255")]
[InlineData("G", 17, "16")]
[InlineData("z", 36, "35")]
[InlineData("0.1", 3, "0.3333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333")]
public void IsRead(string digits, int numberBase, string expected)
=> Assert.Equal(EDecimal.FromString(expected), MathS.FromBaseN(digits, numberBase).EDecimal);

[Theory]
[InlineData("102", 2)]
[InlineData("19", 8)]
[InlineData("1g", 16)]
[InlineData("1-2", 10)]
[InlineData("12", 1)]
[InlineData("12", 37)]
public void ADigitNotInTheBaseIsRefused(string digits, int numberBase)
=> Assert.Throws<InvalidNumericSystemException>(() => MathS.FromBaseN(digits, numberBase));

[Theory]
[InlineData(35, 36, "Z")]
[InlineData(35, 17, "21")]
[InlineData(1099511627776, 16, "10000000000")]
public void AWholeNumberIsWritten(long value, int numberBase, string expected)
=> Assert.Equal(expected, MathS.ToBaseN(Integer.Create(value), numberBase));

[Theory]
[InlineData(1, 7, 7, "0.1")]
[InlineData(1, 3, 3, "0.1")]
[InlineData(-5, 9, 3, "-0.12")]
[InlineData(7, 4, 2, "1.11")]
public void ARationalIsWrittenExactly(int numerator, int denominator, int numberBase, string expected)
=> Assert.Equal(expected, MathS.ToBaseN(Rational.Create(numerator, denominator), numberBase));

[Fact]
public void ARepeatingExpansionIsCutAtThePrecision()
{
var third = MathS.ToBaseN(Rational.Create(1, 3), 10);
Assert.StartsWith("0.3333", third);
Assert.Equal(new string('3', third.Length - 2), third.Substring(2));
Assert.InRange(third.Length, 50, 200);
}

[Fact]
public void ALargeNumberRoundTrips()
{
var large = EInteger.FromString("123456789012345678901234567890");
Assert.Equal(EDecimal.FromEInteger(large), MathS.FromBaseN(MathS.ToBaseN(Integer.Create(large), 36), 36).EDecimal);
}
}
}
Loading