diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md
index 2072155fb..975447417 100644
--- a/BREAKING-CHANGES.md
+++ b/BREAKING-CHANGES.md
@@ -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)`,
diff --git a/Sources/AngouriMath/Convenience/MathS.cs b/Sources/AngouriMath/Convenience/MathS.cs
index 8d3a8249c..4dd6cf363 100644
--- a/Sources/AngouriMath/Convenience/MathS.cs
+++ b/Sources/AngouriMath/Convenience/MathS.cs
@@ -4613,7 +4613,8 @@ public static ParsingResult Parse(string source)
///
///
[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);
/// Translates a number in base into base 10
/// A in base to be translated into base 10
diff --git a/Sources/AngouriMath/Functions/BaseConversion.cs b/Sources/AngouriMath/Functions/BaseConversion.cs
index 223736dd1..d5b1838c0 100644
--- a/Sources/AngouriMath/Functions/BaseConversion.cs
+++ b/Sources/AngouriMath/Functions/BaseConversion.cs
@@ -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 ALPHABET_FROMCHAR =
- ALPHABET_TOCHAR.ToDictionary(c => c, ALPHABET_TOCHAR.IndexOf);
+ ///
+ /// The digits, to base 36, as the catalogue's to_base and from_base and the MCP server's
+ /// am_represent offer: the table stopped at F, so base 17 threw and G could not be read.
+ /// https://github.com/asc-community/AngouriMath/issues/2343,
+ /// https://github.com/asc-community/AngouriMath/issues/2344
+ ///
+ [ConstantField] internal static readonly string ALPHABET_TOCHAR = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+ /// The value of in base , a letter in either case.
+ 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");
+ }
/// Transforms an integer to the corresponding base (in string)
internal static string IntToBaseN(EInteger num, int N)
@@ -95,9 +120,7 @@ internal static string FloatToBaseN(EDecimal num /*should be < 1*/, int N)
///
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();
@@ -108,36 +131,59 @@ internal static string ToBaseN(EDecimal num, int N)
return leftPart + rightPart;
}
- /// Gets an integer from a string which should not contain any of ,.-
- internal static int IntFromBaseN(string num, int N)
+ ///
+ /// A rational in base , each digit of the fraction by long division of its numerator by its
+ /// denominator: exact, so that 1/7 in base 7 is 0.1, 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 cuts it.
+ /// https://github.com/asc-community/AngouriMath/issues/2344
+ ///
+ 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;
}
- /// if num is ABC, the initial number was 0.ABC
- internal static EDecimal FloatFromBaseN(string num, int N)
+ ///
+ /// The whole number written in , which holds no sign and no point. By Horner's rule in
+ /// : added up in an int, with (int)Math.Pow(N, i) for the place value, a sum
+ /// past 2^31 wrapped and a place value past it saturated, so FromBaseN("80000000", 16) was
+ /// -2147483648. https://github.com/asc-community/AngouriMath/issues/2343
+ ///
+ 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;
}
+ /// if num is ABC, the initial number was 0.ABC, as the exact quotient of ABC by N^3
+ internal static ERational FloatFromBaseN(string num, int N)
+ => ERational.Create(IntFromBaseN(num, N), EInteger.FromInt32(N).Pow(num.Length));
+
///
/// Performs operations on both the integer and floating parts of a number and concatenates
///
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('.');
@@ -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);
}
}
}
diff --git a/Sources/Tests/UnitTests/Common/ABaseConversionToThirtySixTest.cs b/Sources/Tests/UnitTests/Common/ABaseConversionToThirtySixTest.cs
new file mode 100644
index 000000000..35f8e542f
--- /dev/null
+++ b/Sources/Tests/UnitTests/Common/ABaseConversionToThirtySixTest.cs
@@ -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
+{
+ ///
+ /// 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: "102" in base 2 was 6. The digit table of both directions stopped at F, where the
+ /// catalogue offers bases to 36. And ToBaseN wrote a rational through its decimal, rounded and the rounding
+ /// multiplied up, so 1/7 in base 7 ran to 119 digits.
+ /// https://github.com/asc-community/AngouriMath/issues/2343,
+ /// https://github.com/asc-community/AngouriMath/issues/2344
+ ///
+ [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(() => 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);
+ }
+ }
+}