Skip to content

Commit 6a0eecd

Browse files
Merge pull request #69 from ktsu-dev/claude/library-optimization-9wvabg
Divide exactly, or to a chosen precision
2 parents b0564d7 + 8c2cb14 commit 6a0eecd

7 files changed

Lines changed: 282 additions & 20 deletions

File tree

‎.github/workflows/dotnet.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ jobs:
352352
'/d:sonar.host.url=https://sonarcloud.io'
353353
'/d:sonar.projectBaseDir=${{ github.workspace }}'
354354
'/d:sonar.cs.vscoveragexml.reportsPaths=coverage/**/coverage.xml'
355-
'/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/*.Benchmarks/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs'
355+
'/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs'
356356
'/d:sonar.cs.vstest.reportsPaths=coverage/**/*.trx'
357357
'/d:sonar.exclusions=**/NativeExports.cs'
358358
)

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
3333
### Key Design Patterns
3434

3535
- Factory methods `CreateFromInteger<T>()` and `CreateFromFloatingPoint<T>()` handle type-specific conversion logic
36-
- Arithmetic operations use `MakeCommonized()` to align exponents before calculation
36+
- Addition, subtraction and modulus align exponents before calculating; multiplication and division work on the significands directly
37+
- `Divide` is exact when the quotient terminates, and otherwise rounds to a precision that never falls below the wider operand or `MinimumDivisionPrecision`. `Exp` and non-integer `Pow` still route through `double`
3738
- The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true)
3839
- Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances
3940

‎PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
internal copy here through InternalsVisibleTo - referencing it again would make
1717
every polyfilled member ambiguous. -->
1818
<NoWarn>$(NoWarn);KTSU0001</NoWarn>
19+
<!-- Benchmarks are tooling: not shipped, and not something unit tests cover. Telling the
20+
Sonar scanner so here keeps the coverage gate off this project's back without any
21+
workflow needing to know benchmarks exist. The equivalent workflow-level exclusion
22+
would have to be repeated in every repository that gains a benchmark project, and
23+
re-applied whenever the shared .NET workflow is resynced. -->
24+
<SonarQubeExclude>true</SonarQubeExclude>
1925
</PropertyGroup>
2026

2127
<ItemGroup>

‎PreciseNumber.Benchmarks/README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ operation returns a new instance, so a change that avoids an intermediate value
5757
`Allocated` column before it shows up in `Mean`. A comparison that allocates at all is a
5858
regression — none of them should.
5959

60-
One entry is easy to misread. `Divide` barely moves between 8 and 200 digits, and at 200 digits
61-
it comes out faster than `Add`. That is not division being efficient — it computes its fractional
62-
part through a `double`, so it discards everything past roughly the 17th significant digit and
63-
the result it constructs is small no matter how large the operands were. Read it as a measure of
64-
how much precision the operation throws away, not how fast it is.
60+
`Divide` is the one row whose cost is not driven by the operands alone. It produces a terminating
61+
quotient exactly and a repeating one to a chosen precision, so the `Digits` column moves it twice
62+
over: wider operands are more work to divide, and they also raise the precision the quotient is
63+
taken to. A number that looks expensive at 200 digits is doing proportionally more work, not doing
64+
the same work badly.
6565

6666
Benchmark operands come from a fixed digit pattern rather than a random source, so two runs on
6767
the same machine measure the same work. Numbers are still only comparable within a single run on

‎PreciseNumber.Test/PreciseNumberTests.cs‎

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,8 +1031,10 @@ public void TestOperatorDivide()
10311031
PreciseNumber number1 = PreciseNumber.CreateFromComponents(-2, 12345);
10321032
PreciseNumber number2 = PreciseNumber.CreateFromComponents(-3, 678);
10331033
PreciseNumber result = number1 / number2;
1034-
Assert.AreEqual(BigInteger.Parse("18207964601769911504"), result.Significand);
1035-
Assert.AreEqual(-17, result.Exponent);
1034+
// 123.45 / 0.678 does not terminate, so it comes out at the default precision.
1035+
Assert.AreEqual(BigInteger.Parse("18207964601769911504424778761061946902654867256637"), result.Significand);
1036+
Assert.AreEqual(-47, result.Exponent);
1037+
Assert.AreEqual(PreciseNumber.MinimumDivisionPrecision, result.SignificantDigits);
10361038
}
10371039

10381040
[TestMethod]
@@ -1603,7 +1605,11 @@ public void TestExpWithPositivePower()
16031605
public void TestExpWithNegativePower()
16041606
{
16051607
PreciseNumber result = PreciseNumber.Exp(-1.ToPreciseNumber());
1606-
PreciseNumber expected = PreciseNumber.One / PreciseNumber.E; // e^-1 = 1/e
1608+
1609+
// Exp routes through a double, so 1/e is taken to the precision Exp actually delivers
1610+
// rather than the full precision Divide is now capable of.
1611+
PreciseNumber expected = PreciseNumber.Divide(PreciseNumber.One, PreciseNumber.E, result.SignificantDigits);
1612+
16071613
Assert.AreEqual(expected, result);
16081614
}
16091615

@@ -2000,6 +2006,122 @@ public void TestCountDigitsMatchesDecimalText()
20002006
}
20012007
}
20022008

2009+
[TestMethod]
2010+
public void TestDivideIsExactWhenTheQuotientTerminates()
2011+
{
2012+
// A quotient terminates exactly when the reduced denominator is a product of twos and
2013+
// fives, and those come out exact however many digits that takes.
2014+
(int Numerator, int Denominator, string Expected)[] cases =
2015+
[
2016+
(1, 2, "0.5"),
2017+
(1, 4, "0.25"),
2018+
(1, 5, "0.2"),
2019+
(1, 8, "0.125"),
2020+
(1, 10, "0.1"),
2021+
(1, 16, "0.0625"),
2022+
(1, 20, "0.05"),
2023+
(1, 25, "0.04"),
2024+
(3, 8, "0.375"),
2025+
(-3, 8, "-0.375"),
2026+
(7, 1, "7"),
2027+
];
2028+
2029+
foreach ((int numerator, int denominator, string expected) in cases)
2030+
{
2031+
PreciseNumber quotient = numerator.ToPreciseNumber() / denominator.ToPreciseNumber();
2032+
Assert.AreEqual(expected, quotient.ToString(CultureInfo.InvariantCulture), $"{numerator}/{denominator}");
2033+
}
2034+
}
2035+
2036+
[TestMethod]
2037+
public void TestDivideKeepsEveryDigitOfALongTerminatingQuotient()
2038+
{
2039+
// 2^-64 terminates, but only after 64 decimal places - far more than the default
2040+
// precision would allow, and far more than a double could carry.
2041+
PreciseNumber quotient = PreciseNumber.One / PreciseNumber.CreateFromComponents(0, BigInteger.Pow(2, 64));
2042+
2043+
Assert.AreEqual(
2044+
"0.0000000000000000000542101086242752217003726400434970855712890625",
2045+
quotient.ToString(CultureInfo.InvariantCulture));
2046+
2047+
// Exact means exact: multiplying back reproduces the dividend.
2048+
Assert.AreEqual(PreciseNumber.One, quotient * PreciseNumber.CreateFromComponents(0, BigInteger.Pow(2, 64)));
2049+
}
2050+
2051+
[TestMethod]
2052+
public void TestDivideProducesTheRequestedPrecisionWhenTheQuotientRepeats()
2053+
{
2054+
PreciseNumber one = PreciseNumber.One;
2055+
PreciseNumber three = 3.ToPreciseNumber();
2056+
2057+
Assert.AreEqual("0.3", PreciseNumber.Divide(one, three, 1).ToString(CultureInfo.InvariantCulture));
2058+
Assert.AreEqual("0.333", PreciseNumber.Divide(one, three, 3).ToString(CultureInfo.InvariantCulture));
2059+
Assert.AreEqual(20, PreciseNumber.Divide(one, three, 20).SignificantDigits);
2060+
Assert.AreEqual(200, PreciseNumber.Divide(one, three, 200).SignificantDigits);
2061+
}
2062+
2063+
[TestMethod]
2064+
public void TestDivideRoundsHalfAwayFromZero()
2065+
{
2066+
PreciseNumber two = 2.ToPreciseNumber();
2067+
PreciseNumber three = 3.ToPreciseNumber();
2068+
PreciseNumber five = 5.ToPreciseNumber();
2069+
PreciseNumber nine = 9.ToPreciseNumber();
2070+
2071+
// 0.666... rounds up, 0.333... rounds down, and the sign does not change which way.
2072+
Assert.AreEqual("0.667", PreciseNumber.Divide(two, three, 3).ToString(CultureInfo.InvariantCulture));
2073+
Assert.AreEqual("0.333", PreciseNumber.Divide(PreciseNumber.One, three, 3).ToString(CultureInfo.InvariantCulture));
2074+
Assert.AreEqual("-0.667", PreciseNumber.Divide(-two, three, 3).ToString(CultureInfo.InvariantCulture));
2075+
Assert.AreEqual("-0.333", PreciseNumber.Divide(-PreciseNumber.One, three, 3).ToString(CultureInfo.InvariantCulture));
2076+
2077+
// 0.555... cut after one digit sits just above the halfway mark, so it rounds away.
2078+
Assert.AreEqual("0.6", PreciseNumber.Divide(five, nine, 1).ToString(CultureInfo.InvariantCulture));
2079+
Assert.AreEqual("-0.6", PreciseNumber.Divide(-five, nine, 1).ToString(CultureInfo.InvariantCulture));
2080+
}
2081+
2082+
[TestMethod]
2083+
public void TestDivideNeverReducesTheOperandsPrecision()
2084+
{
2085+
// An operand carrying more digits than the floor pulls the quotient up to match it.
2086+
PreciseNumber wide = PreciseNumber.Parse(new string('7', 120), CultureInfo.InvariantCulture);
2087+
PreciseNumber three = 3.ToPreciseNumber();
2088+
2089+
Assert.AreEqual(120, (wide / three).SignificantDigits);
2090+
Assert.AreEqual(120, (three / wide).SignificantDigits);
2091+
Assert.AreEqual(PreciseNumber.MinimumDivisionPrecision, (three / 7.ToPreciseNumber()).SignificantDigits);
2092+
}
2093+
2094+
[TestMethod]
2095+
public void TestDivideRejectsNonPositivePrecision()
2096+
{
2097+
PreciseNumber one = PreciseNumber.One;
2098+
PreciseNumber three = 3.ToPreciseNumber();
2099+
2100+
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Divide(one, three, 0));
2101+
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Divide(one, three, -1));
2102+
}
2103+
2104+
[TestMethod]
2105+
public void TestDivideZeroDividend()
2106+
{
2107+
Assert.AreEqual(PreciseNumber.Zero, PreciseNumber.Zero / 3.ToPreciseNumber());
2108+
Assert.AreEqual(PreciseNumber.Zero, PreciseNumber.Zero / PreciseNumber.NegativeOne);
2109+
}
2110+
2111+
[TestMethod]
2112+
public void TestDivideRoundTripsThroughMultiplication()
2113+
{
2114+
// For a terminating quotient the round trip is exact, which is the strongest statement
2115+
// that can be made about a division.
2116+
foreach (int denominator in new[] { 2, 4, 5, 8, 10, 16, 20, 25, 32, 50, 64, 100, 125, 128 })
2117+
{
2118+
PreciseNumber divisor = denominator.ToPreciseNumber();
2119+
PreciseNumber quotient = 123456789.ToPreciseNumber() / divisor;
2120+
2121+
Assert.AreEqual(123456789.ToPreciseNumber(), quotient * divisor, $"123456789/{denominator}");
2122+
}
2123+
}
2124+
20032125
[TestMethod]
20042126
public void TestPow10IsCorrectAcrossCacheBoundaries()
20052127
{
@@ -2057,7 +2179,7 @@ public void TestDivideWithExponentsBeyondDoubleRange()
20572179

20582180
PreciseNumber result = left / right;
20592181

2060-
Assert.AreEqual("2.3333333333333333", result.ToString(CultureInfo.InvariantCulture));
2182+
Assert.AreEqual("2.3333333333333333333333333333333333333333333333333", result.ToString(CultureInfo.InvariantCulture));
20612183
}
20622184

20632185
[TestMethod]

‎PreciseNumber/PreciseNumber.cs‎

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ public record PreciseNumber
4444
/// </summary>
4545
private const string InvalidFormatMessage = "Input string was not in a correct format.";
4646

47+
/// <summary>
48+
/// The fewest significant digits <see cref="Divide(PreciseNumber, PreciseNumber)"/> produces
49+
/// when a quotient does not terminate.
50+
/// </summary>
51+
public const int MinimumDivisionPrecision = 50;
52+
4753
/// <summary>
4854
/// Pre-computed powers of ten, grown on demand. Declared before any other static state so
4955
/// that the static constants below can rely on it while they are being initialized.
@@ -1291,30 +1297,149 @@ public static PreciseNumber Multiply(PreciseNumber left, PreciseNumber right)
12911297
/// <param name="left">The number to divide.</param>
12921298
/// <param name="right">The number to divide by.</param>
12931299
/// <returns>The result of the division.</returns>
1300+
/// <exception cref="DivideByZeroException">Thrown when <paramref name="right"/> is zero.</exception>
1301+
/// <remarks>
1302+
/// A quotient whose decimal expansion terminates is produced exactly, however many digits that
1303+
/// takes. One that repeats is produced to the precision of the wider operand, and never fewer
1304+
/// than <see cref="MinimumDivisionPrecision"/> significant digits, with the last digit rounded
1305+
/// half away from zero. Use <see cref="Divide(PreciseNumber, PreciseNumber, int)"/> to choose
1306+
/// that precision.
1307+
/// </remarks>
12941308
public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right)
12951309
{
12961310
Ensure.NotNull(left);
12971311
Ensure.NotNull(right);
12981312

1313+
// Dividing must not silently discard precision the operands already carry.
1314+
int significantDigits = Math.Max(
1315+
Math.Max(left.SignificantDigits, right.SignificantDigits),
1316+
MinimumDivisionPrecision);
1317+
1318+
return Divide(left, right, significantDigits);
1319+
}
1320+
1321+
/// <summary>
1322+
/// Divides one number by another, to a chosen number of significant digits.
1323+
/// </summary>
1324+
/// <param name="left">The number to divide.</param>
1325+
/// <param name="right">The number to divide by.</param>
1326+
/// <param name="significantDigits">
1327+
/// The number of significant digits to produce when the quotient does not terminate. A quotient
1328+
/// that does terminate is exact regardless of this value.
1329+
/// </param>
1330+
/// <returns>The result of the division.</returns>
1331+
/// <exception cref="DivideByZeroException">Thrown when <paramref name="right"/> is zero.</exception>
1332+
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="significantDigits"/> is less than one.</exception>
1333+
public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right, int significantDigits)
1334+
{
1335+
Ensure.NotNull(left);
1336+
Ensure.NotNull(right);
1337+
1338+
if (significantDigits < 1)
1339+
{
1340+
throw new ArgumentOutOfRangeException(nameof(significantDigits), significantDigits, "At least one significant digit is required.");
1341+
}
1342+
12991343
if (right.Significand.IsZero)
13001344
{
13011345
throw new DivideByZeroException();
13021346
}
13031347

1304-
if (Compare(left, right) == 0)
1348+
if (left.Significand.IsZero)
13051349
{
1306-
return One;
1350+
return Zero;
13071351
}
13081352

1309-
(BigInteger commonLeft, BigInteger commonRight, _) = CommonizeSignificands(left, right);
1353+
BigInteger numerator = left.Significand;
1354+
BigInteger denominator = right.Significand;
1355+
int exponent = left.Exponent - right.Exponent;
13101356

1311-
BigInteger integerComponent = BigInteger.DivRem(commonLeft, commonRight, out BigInteger remainder);
1357+
// Carry the sign on the numerator so the denominator can be factorized as a positive value.
1358+
if (denominator.Sign < 0)
1359+
{
1360+
numerator = -numerator;
1361+
denominator = -denominator;
1362+
}
13121363

1313-
// The common power of ten cancels between the remainder and the divisor, so it is left out
1314-
// entirely; including it would overflow to infinity for large exponents.
1315-
double fractionalComponent = double.CreateTruncating(remainder) / double.CreateTruncating(commonRight);
1364+
return TryDivideExactly(numerator, denominator, exponent, out PreciseNumber? exact)
1365+
? exact
1366+
: DivideToPrecision(numerator, denominator, exponent, significantDigits);
1367+
}
1368+
1369+
/// <summary>
1370+
/// Divides exactly, when the quotient has a terminating decimal expansion.
1371+
/// </summary>
1372+
/// <param name="numerator">The numerator, carrying the sign of the quotient.</param>
1373+
/// <param name="denominator">The denominator, which must be positive.</param>
1374+
/// <param name="exponent">The exponent the quotient's significand sits at.</param>
1375+
/// <param name="result">The exact quotient, when there is one.</param>
1376+
/// <returns><c>true</c> if the quotient terminates and <paramref name="result"/> is exact; otherwise <c>false</c>.</returns>
1377+
private static bool TryDivideExactly(BigInteger numerator, BigInteger denominator, int exponent, [NotNullWhen(true)] out PreciseNumber? result)
1378+
{
1379+
// A fraction terminates in base ten exactly when its denominator is 2^twos * 5^fives. Most
1380+
// denominators are rejected by the first remainder test, which is why this is worth trying
1381+
// before falling back to a rounded quotient.
1382+
int twos = (int)BigInteger.TrailingZeroCount(denominator);
1383+
BigInteger remaining = denominator >> twos;
1384+
1385+
int fives = 0;
1386+
while ((remaining % 5).IsZero)
1387+
{
1388+
remaining /= 5;
1389+
fives++;
1390+
}
1391+
1392+
if (!remaining.IsOne)
1393+
{
1394+
result = null;
1395+
return false;
1396+
}
1397+
1398+
// 1 / (2^p * 5^q) == (2^(k-p) * 5^(k-q)) / 10^k, where k is the larger of p and q.
1399+
int scale = Math.Max(twos, fives);
1400+
BigInteger significand = numerator * BigInteger.Pow(2, scale - twos) * BigInteger.Pow(5, scale - fives);
1401+
1402+
result = new PreciseNumber(exponent - scale, significand);
1403+
return true;
1404+
}
1405+
1406+
/// <summary>
1407+
/// Divides to a fixed number of significant digits, rounding the last of them half away from zero.
1408+
/// </summary>
1409+
/// <param name="numerator">The numerator, carrying the sign of the quotient.</param>
1410+
/// <param name="denominator">The denominator, which must be positive.</param>
1411+
/// <param name="exponent">The exponent the quotient's significand sits at.</param>
1412+
/// <param name="significantDigits">The number of significant digits to produce.</param>
1413+
/// <returns>The rounded quotient.</returns>
1414+
private static PreciseNumber DivideToPrecision(BigInteger numerator, BigInteger denominator, int exponent, int significantDigits)
1415+
{
1416+
// A quotient has either digits(numerator) - digits(denominator) digits or one more, so
1417+
// scaling by this much leaves at least one digit past the ones being kept: the digit the
1418+
// rounding decision is made on.
1419+
int scale = significantDigits + 1 - CountDigits(numerator) + CountDigits(denominator);
1420+
BigInteger scaled = scale > 0 ? numerator * Pow10(scale) : numerator;
1421+
int scaledExponent = exponent - Math.Max(scale, 0);
1422+
1423+
BigInteger quotient = scaled / denominator;
1424+
int excess = CountDigits(quotient) - significantDigits;
1425+
1426+
if (excess <= 0)
1427+
{
1428+
return new PreciseNumber(scaledExponent, quotient);
1429+
}
1430+
1431+
BigInteger divisor = Pow10(excess);
1432+
BigInteger kept = BigInteger.DivRem(quotient, divisor, out BigInteger dropped);
1433+
1434+
// Round half away from zero. Whatever the division above discarded is worth less than one
1435+
// unit of the dropped digits, so it can never carry the comparison across the halfway mark;
1436+
// at most it turns an exact tie into something above it, which rounds the same way.
1437+
if (BigInteger.Abs(dropped) * 2 >= divisor)
1438+
{
1439+
kept += quotient.Sign;
1440+
}
13161441

1317-
return new PreciseNumber(0, integerComponent) + fractionalComponent.ToPreciseNumber();
1442+
return new PreciseNumber(scaledExponent + excess, kept);
13181443
}
13191444

13201445
/// <summary>

‎README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,17 @@ You can control precision using:
351351

352352
- **ReduceSignificance()**: Reduces to a specific number of significant digits
353353

354+
- **Divide(left, right, significantDigits)**: Chooses the precision of a quotient
355+
356+
Division produces a terminating quotient exactly, however many digits that takes — `1 / 8` is
357+
`0.125`, and `1 / 2^64` keeps all 64 decimal places. A repeating quotient is produced to the
358+
precision of the wider operand, never fewer than `MinimumDivisionPrecision` (50) significant
359+
digits, with the last digit rounded half away from zero. Pass an explicit precision to the
360+
three-argument overload when you want something other than that.
361+
354362
## Limitations
355363

356-
- Operations that inherently require approximation (like certain roots or logarithms) fall back to `double` precision for calculation
364+
- `Exp()`, and `Pow()` with a non-integer power, are computed through `double` and are therefore limited to its precision. Addition, subtraction, multiplication and division are not
357365

358366
- Conversion to standard types may throw `OverflowException` if the value is too large
359367

0 commit comments

Comments
 (0)