From 789da8f5bddfc0a41db545361786aa4b43e30452 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Fri, 14 Aug 2026 21:50:12 +0000 Subject: [PATCH 1/2] Read a BigInteger's bytes as bytes, so converting one to a Number stops throwing (#585) Entity and Entity.Number both offer an implicit conversion from System.Numerics.BigInteger, and they did not agree. Entity's reads the two's-complement bytes. Number's handed the same bytes to EInteger.FromString, whose byte[] overload reads them as ASCII digits: Entity fine = new BigInteger(123456789); // 123456789 Entity.Number threw = new BigInteger(123456789); // FormatException: Illegal character found So it failed for every value whose bytes are not digit characters, which is nearly all of them and includes 1. The handful that worked worked by accident: 12594 is the two bytes '2' and '1', and came back as 21. Nothing inside the library reaches that conversion, which is why nothing caught it -- it exists only for callers. Both now read the bytes, and the test requires the two conversions to agree rather than each to work on its own, since one value converted two ways giving two answers is the defect and not merely the exception. Found while documenting the members a CS1591 pragma was covering. The two conversions are in different files and neither looks wrong alone; reading them side by side is what showed it, and reading them side by side is what documenting them required. Measured: suite 7175 passed / 0 failed, 58 of them new, over values on both sides of every byte boundary that matters -- 127, 128, 255, 256, the long limits, and a value past 2^127. https://github.com/asc-community/AngouriMath/issues/585 Co-authored-by: Claude Opus 5 --- BREAKING-CHANGES.md | 34 +++++++++ .../Entity.Continuous.Number.Definition.cs | 7 +- .../Core/BigIntegerConversionTest.cs | 75 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 881918d2e..86d6b9071 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -15,6 +15,40 @@ read first. --- +## Unreleased — since 2.2.0 + +### At a glance + +| Silent? | What | Was | Is | +|---|---|---|---| +| | `(Entity.Number)someBigInteger` | `FormatException: Illegal character found`, for almost every value | the number | + +### A `BigInteger` converts to a `Number` instead of throwing + +`Entity` and `Entity.Number` both offer an implicit conversion from +`System.Numerics.BigInteger`, and they did not agree. `Entity`'s read the two's-complement bytes; +`Number`'s passed the same bytes to `EInteger.FromString`, whose `byte[]` overload reads them as +**ASCII digits**: + +```csharp +Entity fine = new BigInteger(123456789); // 123456789 +Entity.Number threw = new BigInteger(123456789); // FormatException: Illegal character found +``` + +So the conversion failed for every value whose bytes are not digit characters — which is nearly all +of them, `1` included. The few that worked did so by accident: `12594` is the two bytes `'2'` and +`'1'`, and came out as `21`. + +Nothing in the library reached it, which is why no test caught it; it is only on the public surface. +Both conversions now read the bytes, and a test requires the two to agree rather than merely to +work. + +Found while documenting the members a `#pragma warning disable CS1591` was covering — the two +conversions sit in different files and had to be read side by side to look wrong. +[#585](https://github.com/asc-community/AngouriMath/issues/585). + +--- + ## 2.2.0 — since 2.1.0 ### At a glance diff --git a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs index 6195a01f0..dc6882c0f 100644 --- a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs +++ b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs @@ -59,8 +59,13 @@ public abstract partial record Number : ContinuousNode public static implicit operator Number(decimal value) => Real.Create(EDecimal.FromDecimal(value)); public static implicit operator Number(System.Numerics.Complex value) => Complex.Create(EDecimal.FromDouble(value.Real), EDecimal.FromDouble(value.Imaginary)); + // Read as the two's-complement bytes it is, which is what BigInteger.ToByteArray + // returns and what the same conversion on Entity has always done. The overload + // reached before was EInteger.FromString(byte[]), which reads a byte array as ASCII + // digits, so every value whose bytes are not digit characters threw a FormatException + // -- which is nearly all of them, 1 included. public static implicit operator Number(System.Numerics.BigInteger bigInt) - => Integer.Create(EInteger.FromString(bigInt.ToByteArray())); + => Integer.Create(EInteger.FromBytes(bigInt.ToByteArray(), littleEndian: true)); #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } } diff --git a/Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs b/Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs new file mode 100644 index 000000000..17cfc48b7 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs @@ -0,0 +1,75 @@ +// +// 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 System.Numerics; +using AngouriMath; +using Xunit; + +namespace AngouriMath.Tests.Core +{ + /// + /// The two conversions from , which used to disagree: the one to + /// read the bytes and the one to parsed + /// them as text, so the second threw on nearly every input. + /// + [Trait("Area", "Core")] + public sealed class BigIntegerConversionTest + { + public static readonly TheoryData Values = new() + { + "0", "1", "-1", "2", "-2", "127", "128", "255", "256", "-128", "-129", + "123456789", "-123456789", "9223372036854775807", "9223372036854775808", + "-9223372036854775808", "-9223372036854775809", + "170141183460469231731687303715884105728", + "-170141183460469231731687303715884105728", + }; + + /// To , which is the one that was broken. + [Theory] + [MemberData(nameof(Values))] + public void ToNumber(string text) + { + Entity.Number converted = BigInteger.Parse(text); + Assert.Equal(text, converted.Stringize()); + } + + /// And to , which was not, pinned so it stays that way. + [Theory] + [MemberData(nameof(Values))] + public void ToEntity(string text) + { + Entity converted = BigInteger.Parse(text); + Assert.Equal(text, converted.Stringize()); + } + + /// + /// And the two agree, which is the property whose absence was the defect: one conversion + /// of one value should not depend on which of them the caller reaches. + /// + [Theory] + [MemberData(nameof(Values))] + public void AndTheTwoAgree(string text) + { + var big = BigInteger.Parse(text); + Entity asEntity = big; + Entity.Number asNumber = big; + Assert.Equal(asEntity, (Entity)asNumber); + } + + /// + /// A value whose bytes happen to be ASCII digits was the only kind that used to work, + /// and it worked by accident: 12594 is the two bytes '2' and '1', which the old code + /// read as the number 21. + /// + [Fact] + public void TheValuesThatUsedToWorkWorkedByAccident() + { + Entity.Number converted = new BigInteger(12594); + Assert.Equal("12594", converted.Stringize()); + } + } +} From a557a3f9ecec0a023f9e123d6cca5dc383d9dd7c Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Fri, 14 Aug 2026 22:09:19 +0000 Subject: [PATCH 2/2] Document the conversions to Number, clearing the last CS1591 pragma outside the parser (#585) The block this fix edits was the one region still suppressing the missing-documentation warning once the rest were dealt with, and it is documented here rather than on the documentation branch because two branches editing one block conflict for whichever merges second. Each conversion lands on the narrowest kind that holds the value exactly, and the float and double ones read the binary value that was stored rather than the decimal that was written. The BigInteger one now says in its remarks what it used to do and why that threw. With this and the documentation branch, no CS1591 pragma remains in the library outside the generated parser, which keeps its own because it is regenerated from the grammar. Measured: suite 7175 passed / 0 failed. https://github.com/asc-community/AngouriMath/issues/585 Co-authored-by: Claude Opus 5 --- .../Entity.Continuous.Number.Definition.cs | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs index dc6882c0f..c7d1a8c7b 100644 --- a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs +++ b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs @@ -42,31 +42,84 @@ public abstract partial record Number : ContinuousNode /// For example, integers and rationals are such /// public abstract bool IsExact { get; } -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + // Each of these lands on the narrowest kind that holds the value exactly: the + // integral types on Integer, a ratio on Rational, the rest on Real. Number is + // abstract, so what comes back is always one of its concrete kinds. + + /// The number as an . public static implicit operator Number(sbyte value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(byte value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(short value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(ushort value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(int value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(uint value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(long value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(ulong value) => Integer.Create(value); + + /// + /// The integer as an , of any size — this is the conversion to + /// reach for where a value will not fit in a . + /// public static implicit operator Number(EInteger value) => Integer.Create(value); + + /// + /// The ratio as a , in lowest terms and with a positive + /// denominator — or an where the denominator reduces to one. + /// public static implicit operator Number(ERational value) => Rational.Create(value); + + /// The decimal as a . public static implicit operator Number(EDecimal value) => Real.Create(value); + + /// + /// The value as a , read as the binary it + /// is — so 0.1f becomes the value that literal holds, not one tenth. + /// public static implicit operator Number(float value) => Real.Create(EDecimal.FromSingle(value)); + + /// + /// The value as a , read as the binary it + /// is rather than as the decimal it was written as. + /// public static implicit operator Number(double value) => Real.Create(EDecimal.FromDouble(value)); + + /// + /// The value as a . A is decimal + /// already, so this one keeps the digits that were written. + /// public static implicit operator Number(decimal value) => Real.Create(EDecimal.FromDecimal(value)); + + /// + /// The .NET complex number as a , through its two + /// parts and their precision. + /// public static implicit operator Number(System.Numerics.Complex value) => Complex.Create(EDecimal.FromDouble(value.Real), EDecimal.FromDouble(value.Imaginary)); - // Read as the two's-complement bytes it is, which is what BigInteger.ToByteArray - // returns and what the same conversion on Entity has always done. The overload - // reached before was EInteger.FromString(byte[]), which reads a byte array as ASCII - // digits, so every value whose bytes are not digit characters threw a FormatException - // -- which is nearly all of them, 1 included. + /// The big integer as an , exactly and at any size. + /// + /// Read as the two's-complement bytes it is, which is what + /// BigInteger.ToByteArray returns and what the same conversion on + /// has always done. The overload reached before was + /// EInteger.FromString(byte[]), which reads a byte array as ASCII digits, so + /// every value whose bytes are not digit characters threw a FormatException — + /// which is nearly all of them, 1 included. + /// public static implicit operator Number(System.Numerics.BigInteger bigInt) => Integer.Create(EInteger.FromBytes(bigInt.ToByteArray(), littleEndian: true)); -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } } }