From debd7b51bddc4903c2cd8a8310e369b732a534f7 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 3 Nov 2026 14:08:17 +0000 Subject: [PATCH] Convert bases to 36 exactly, and refuse a digit not in the base FromBaseN added its digits up in an int with (int)Math.Pow(N, i) for the place value, so a number past 2^31 wrapped ("80000000" in base 16 was -2147483648), and it looked each digit up without checking it against the base ("102" in base 2 was 6). It adds them by Horner's rule in EInteger, sums the fraction exactly and rounds once, and throws InvalidNumericSystemException for a digit not below the base or a base outside 2 to 36. The digit table stopped at F, where the catalogue's to_base and from_base and the MCP server's am_represent offer bases to 36: ToBaseN(35, 36) threw and "ff" was a KeyNotFoundException. It goes to Z now, read in either case. And ToBaseN of a rational went through its decimal, rounded to the precision and the rounding multiplied up digit by digit, so 1/7 in base 7 ran to 119 digits ending on a wrong one. A rational is divided out in the base, exactly; a repeating expansion is cut where it was. Closes #2343 Closes #2344 Co-Authored-By: Claude Opus 5.5 --- BREAKING-CHANGES.md | 17 ++++ Sources/AngouriMath/Convenience/MathS.cs | 3 +- .../AngouriMath/Functions/BaseConversion.cs | 98 ++++++++++++++----- .../Common/ABaseConversionToThirtySixTest.cs | 81 +++++++++++++++ 4 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 Sources/Tests/UnitTests/Common/ABaseConversionToThirtySixTest.cs 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); + } + } +}