logarithmic functions implementation
.NET Test / .NET tests (push) Successful in 2m14s

This commit is contained in:
2026-09-24 01:23:09 +04:00
parent 720fd1e21f
commit cb06df8865
11 changed files with 1944 additions and 22 deletions
@@ -16,12 +16,18 @@ public class DoubleDoubleExtensionsTests
[InlineData("Exp2M1")]
[InlineData("Exp10M1")]
[InlineData("Log")]
[InlineData("Log2")]
[InlineData("Log10")]
[InlineData("LogP1")]
[InlineData("Log2P1")]
[InlineData("Log10P1")]
public void UnaryExtensionsPreserveStaticFunctionComponentBits(string operation)
{
// These are forwarding contracts, not accuracy oracles. The static
// kernels have separate independent numerical-reference tests.
foreach (DoubleDouble value in Values())
{
DoubleDouble original = value;
(DoubleDouble expected, DoubleDouble actual) = operation switch
{
"Abs" => (DoubleDouble.Abs(value), value.Abs()),
@@ -37,12 +43,49 @@ public class DoubleDoubleExtensionsTests
"Exp2M1" => (DoubleDouble.Exp2M1(value), value.Exp2M1()),
"Exp10M1" => (DoubleDouble.Exp10M1(value), value.Exp10M1()),
"Log" => (DoubleDouble.Log(value), value.Log()),
"Log2" => (DoubleDouble.Log2(value), value.Log2()),
"Log10" => (DoubleDouble.Log10(value), value.Log10()),
"LogP1" => (DoubleDouble.LogP1(value), value.LogP1()),
"Log2P1" => (DoubleDouble.Log2P1(value), value.Log2P1()),
"Log10P1" => (DoubleDouble.Log10P1(value), value.Log10P1()),
_ => throw new ArgumentOutOfRangeException(nameof(operation))
};
AssertBits(expected, actual);
AssertBits(original, value);
}
}
[Fact]
public void BaseLogarithmExtensionPreservesBothOperands()
{
DoubleDouble[] bases = [DoubleDouble.Zero, DoubleDouble.NegativeZero,
DoubleDouble.One, DoubleDouble.NegativeOne, DoubleDouble.NaN,
DoubleDouble.PositiveInfinity, DoubleDouble.NegativeInfinity,
new(0.5), new(2.0), new(10.0), new(double.Epsilon), new(double.MaxValue),
DoubleDouble.FromComponents(1.0, double.Epsilon),
DoubleDouble.FromComponents(1.0, -double.Epsilon),
DoubleDouble.FromComponents(2.0, Math.ScaleB(1.0, -54))];
foreach (DoubleDouble value in Values())
{
foreach (DoubleDouble newBase in bases)
{
DoubleDouble originalValue = value;
DoubleDouble originalBase = newBase;
AssertBits(DoubleDouble.Log(value, newBase), value.Log(newBase));
AssertBits(originalValue, value);
AssertBits(originalBase, newBase);
}
}
}
[Fact]
public void LogarithmExtensionsCanBeChainedWithoutChangingTheReceiver()
{
DoubleDouble value = new(8.0);
AssertBits(new DoubleDouble(8.0), value.Log2().Exp2());
AssertBits(new DoubleDouble(8.0), value);
}
[Fact]
public void ExtensionsCanBeChainedWithoutChangingTheReceiver()
{
@@ -7,6 +7,141 @@ public class PreciseMathLogTests
{
public static IEnumerable<TheoryDataRow<string, double, double, string>> ReferenceCases => LogReferenceData.Cases();
public static IEnumerable<TheoryDataRow<string, double, double, double, double, string, bool, bool>> FamilyReferenceCases =>
LogarithmicFunctionsReferenceData.Cases();
[Theory]
[MemberData(nameof(FamilyReferenceCases))]
public void LogarithmicFamilyMeetsIndependentReferenceBound(string operation, double high, double low,
double baseHigh, double baseLow, string reference, bool overflow, bool underflow)
{
// Decimal.ln at 450/650 digits; sums, including 1+x, are exact at 2200
// digits and checked with Fraction before taking logs. No DD-derived oracle.
DoubleDouble input = DoubleDouble.FromComponents(high, low);
DoubleDouble newBase = DoubleDouble.FromComponents(baseHigh, baseLow);
(Units(input.High) + Units(input.Low)).ShouldBe(Units(high) + Units(low));
(Units(newBase.High) + Units(newBase.Low)).ShouldBe(Units(baseHigh) + Units(baseLow));
DoubleDouble actual = operation == "LogBase" ? DoubleDouble.Log(input, newBase) : Evaluate(operation, input);
DoubleDouble generic = operation == "LogBase" ? LogGeneric(input, newBase) : EvaluateGeneric(operation, input);
AssertBits(generic, actual.High, actual.Low);
string context = $"{operation}({high:R}, {low:R}), base=({baseHigh:R}, {baseLow:R})";
if (overflow)
{
AssertBits(actual, reference[0] == '-' ? double.NegativeInfinity : double.PositiveInfinity, 0.0);
}
else if (underflow)
{
AssertBits(actual, reference[0] == '-' ? -0.0 : 0.0, 0.0);
}
else
{
AssertReferenceBound(actual, reference, 1, context);
if (input != DoubleDouble.One && !DoubleDouble.IsZero(input))
{
DoubleDouble.IsZero(actual).ShouldBeFalse(context);
}
}
}
[Theory]
[InlineData("Log")]
[InlineData("Log2")]
[InlineData("Log10")]
[InlineData("LogP1")]
[InlineData("Log2P1")]
[InlineData("Log10P1")]
public void FamilyDomainAndSpecialValuesHaveGenericDispatch(string operation)
{
bool plusOne = operation.EndsWith("P1", StringComparison.Ordinal);
DoubleDouble[] inputs = [DoubleDouble.Zero, DoubleDouble.NegativeZero,
DoubleDouble.NaN, DoubleDouble.PositiveInfinity, DoubleDouble.NegativeInfinity,
new(-2.0), new(-1.0), new(1.0),
DoubleDouble.FromComponents(-1.0, -double.Epsilon)];
foreach (DoubleDouble input in inputs)
{
DoubleDouble actual = Evaluate(operation, input);
AssertBits(EvaluateGeneric(operation, input), actual.High, actual.Low);
if (DoubleDouble.IsNaN(input) || input < (plusOne ? DoubleDouble.NegativeOne : DoubleDouble.Zero))
{
AssertBits(actual, double.NaN, 0.0);
}
else if (DoubleDouble.IsPositiveInfinity(input))
{
AssertBits(actual, double.PositiveInfinity, 0.0);
}
else if (plusOne && input == DoubleDouble.NegativeOne || !plusOne && DoubleDouble.IsZero(input))
{
AssertBits(actual, double.NegativeInfinity, 0.0);
}
else if (DoubleDouble.IsZero(input))
{
AssertBits(actual, input.High, 0.0);
}
else if (!plusOne)
{
AssertBits(actual, 0.0, 0.0);
}
}
}
[Fact]
public void BinaryLogarithmOfEveryRepresentablePowerOfTwoIsExact()
{
for (int exponent = -1074; exponent <= 1023; ++exponent)
{
DoubleDouble input = new(Math.ScaleB(1.0, exponent));
AssertBits(Log2Generic(input), exponent, 0.0);
}
}
[Fact]
public void DecimalLogarithmAndArbitraryBaseHaveElementaryValues()
{
AssertReferenceBound(Log10Generic(new DoubleDouble(10.0)), "1e+0", 1, "log10(10)");
AssertReferenceBound(LogGeneric(new DoubleDouble(8.0), new DoubleDouble(2.0)), "3e+0", 1, "log2(8)");
AssertReferenceBound(LogGeneric(new DoubleDouble(8.0), new DoubleDouble(0.5)), "-3e+0", 1, "log0.5(8)");
}
[Fact]
public void ArbitraryBaseSpecialValuesMatchRuntimeWithCanonicalNaNs()
{
double[] values = [-1.0, -0.0, 0.0, 0.5, 1.0, 2.0,
double.PositiveInfinity, double.NegativeInfinity, double.NaN];
foreach (double value in values)
{
foreach (double newBase in values)
{
DoubleDouble expected = new(Math.Log(value, newBase));
AssertBits(LogGeneric(new DoubleDouble(value), new DoubleDouble(newBase)), expected.High, 0.0);
}
}
}
[Theory]
[InlineData("LogP1")]
[InlineData("Log2P1")]
[InlineData("Log10P1")]
public void PlusOneFunctionsPreserveSignedZero(string operation)
{
AssertBits(EvaluateGeneric(operation, DoubleDouble.NegativeZero), -0.0, 0.0);
AssertBits(EvaluateGeneric(operation, DoubleDouble.Zero), 0.0, 0.0);
}
[Fact]
public void NaturalPlusOneLogarithmRetainsTinyQuadraticCorrection()
{
foreach (double sign in new[] { -1.0, 1.0 })
{
double tiny = sign * Math.ScaleB(1.0, -100);
// ln(1+x) = x-x²/2+O(x³). The cubic is far below half an ulp
// of this low. Adding one first loses the sparse input's second component.
AssertBits(EvaluateGeneric("LogP1", new DoubleDouble(tiny)), tiny, -Math.ScaleB(1.0, -201));
DoubleDouble sparse = DoubleDouble.FromComponents(tiny, Math.ScaleB(1.0, -180));
AssertBits(EvaluateGeneric("LogP1", sparse), tiny, Math.ScaleB(1.0, -180) - Math.ScaleB(1.0, -201));
AssertBits(EvaluateGeneric("LogP1", new DoubleDouble(sign * double.Epsilon)), sign * double.Epsilon, 0.0);
}
}
[Fact]
public void SpecialValuesHaveExplicitCanonicalResults()
{
@@ -93,6 +228,40 @@ public class PreciseMathLogTests
AssertBits(DoubleDouble.Log(DoubleDouble.FromComponents(0.0, -2.0)), double.NaN, 0.0);
}
private static DoubleDouble Evaluate(string operation, DoubleDouble value)
{
return operation switch
{
"Log" => DoubleDouble.Log(value),
"Log2" => DoubleDouble.Log2(value),
"Log10" => DoubleDouble.Log10(value),
"LogP1" => DoubleDouble.LogP1(value),
"Log2P1" => DoubleDouble.Log2P1(value),
"Log10P1" => DoubleDouble.Log10P1(value),
_ => throw new ArgumentOutOfRangeException(nameof(operation))
};
}
private static T EvaluateGeneric<T>(string operation, T value) where T : ILogarithmicFunctions<T>
{
return operation switch
{
"Log" => T.Log(value),
"Log2" => T.Log2(value),
"Log10" => T.Log10(value),
"LogP1" => T.LogP1(value),
"Log2P1" => T.Log2P1(value),
"Log10P1" => T.Log10P1(value),
_ => throw new ArgumentOutOfRangeException(nameof(operation))
};
}
private static T LogGeneric<T>(T value, T newBase) where T : ILogarithmicFunctions<T> => T.Log(value, newBase);
private static T Log2Generic<T>(T value) where T : ILogarithmicFunctions<T> => T.Log2(value);
private static T Log10Generic<T>(T value) where T : ILogarithmicFunctions<T> => T.Log10(value);
private static void AssertReferenceBound(DoubleDouble actual, string reference, int factor, string context)
{
DoubleDouble.IsFinite(actual).ShouldBeTrue(context);
@@ -0,0 +1,137 @@
"""Generate exact-input logarithmic-family references using only the standard library.
Run this file with --check to verify the checked-in C# fixtures. Component sums
and 1+x are formed exactly at 2200 digits and checked with Fraction. Decimal.ln
and base conversion are evaluated independently at 450 and 650 digits, requiring
identical 120-digit output and range flags. Tests do not require Python.
"""
from decimal import Decimal, localcontext
from fractions import Fraction
from pathlib import Path
import argparse
import math
import random
import sys
from generate_log import exact_sum, inputs
OUTPUT = Path(__file__).with_name('LogarithmicFunctionsReferenceData.cs')
def plus_one_inputs():
epsilon = math.ulp(0.0)
yield -1.0, epsilon
yield -1.0, 2.0 ** -1000
yield -1.0, 2.0 ** -54
for high in [-0.999, -0.75, -0.5, -0.125, 0.125, 0.5, 1.0, 3.0, 9.0,
sys.float_info.min, 1e100, sys.float_info.max]:
for low in [0.0, math.ulp(high) / 4, -math.ulp(high) / 4]:
yield high, low
for exponent in [-1074, -1073, -1022, -1000, -500, -100, -55, -54, -53]:
for sign in [-1, 1]:
high = sign * 2.0 ** exponent
for low in [0.0, math.ulp(high) / 4, -math.ulp(high) / 4]:
yield high, low
# Sparse lows and both sides of the actual high-component dispatch boundaries.
for high in [-2.0 ** -100, 2.0 ** -100]:
for low in [-2.0 ** -180, 2.0 ** -180]:
yield high, low
for high in [-0.5, 0.5, -2.0 ** -54, 2.0 ** -54]:
for neighbor in [math.nextafter(high, -math.inf), high, math.nextafter(high, math.inf)]:
for low in [-math.ulp(neighbor) / 4, 0.0, math.ulp(neighbor) / 4]:
yield neighbor, low
rng = random.Random(271828)
for _ in range(96):
high = rng.uniform(-0.99, 1.0)
yield high, rng.choice([-1, 1]) * math.ulp(high) / 4
def base_inputs():
values = [(0.5, 0.0), (3.0, 2.0 ** -53), (10.0, -2.0 ** -51),
(math.ulp(0.0), 0.0), (sys.float_info.max, 0.0),
(1.0, 2.0 ** -54), (1.0, -2.0 ** -54),
(1.0, math.ulp(0.0)), (1.0, -math.ulp(0.0)),
(1.0, 2.0 ** -500), (1.0, -2.0 ** -500)]
for high, low in values:
for base_high, base_low in values:
yield high, low, base_high, base_low
def cases():
for _, high, low in inputs():
for operation in ['Log2', 'Log10']:
yield operation, high, low, 0.0, 0.0
for high, low in plus_one_inputs():
for operation in ['LogP1', 'Log2P1', 'Log10P1']:
yield operation, high, low, 0.0, 0.0
for high, low, base_high, base_low in base_inputs():
yield 'LogBase', high, low, base_high, base_low
def reference(operation, high, low, base_high, base_low, precision):
value = exact_sum(high, low)
if operation.endswith('P1'):
with localcontext() as context:
context.prec = 2200
value += 1
assert Fraction(value) == Fraction(high) + Fraction(low) + 1
assert value > 0
base = exact_sum(base_high, base_low) if operation == 'LogBase' else None
with localcontext() as context:
context.prec = precision
result = value.ln()
if base is not None:
result /= base.ln()
elif operation.startswith('Log2'):
result /= Decimal(2).ln()
elif operation.startswith('Log10'):
result /= Decimal(10).ln()
with localcontext() as output_context:
output_context.prec = 120
rounded = +result
assert abs(result - rounded) <= abs(rounded) * Decimal(2) ** -350
# Independently classify against binary64's exact range midpoints.
magnitude = abs(Fraction(result))
overflow = magnitude >= Fraction((2 ** 54 - 1) * 2 ** 970)
underflow = magnitude != 0 and magnitude <= Fraction(1, 2 ** 1075)
return format(rounded, 'e') if rounded else '0e+0', overflow, underflow
def generate():
rows = []
for case in dict.fromkeys(cases()):
result = reference(*case, 450)
assert result == reference(*case, 650), case
operation, high, low, base_high, base_low = case
text, overflow, underflow = result
rows.append(f' yield return new("{operation}", {high!r}, {low!r}, '
f'{base_high!r}, {base_low!r}, "{text}", '
f'{str(overflow).lower()}, {str(underflow).lower()});')
content = '''// Generated by generate_logarithmic_functions.py; do not hand-edit reference literals.
// Exact component sums and 1+x; Decimal.ln at 450/650 digits, rounded to 120 digits.
namespace Just.PreciseMath.Tests.ReferenceData;
internal static class LogarithmicFunctionsReferenceData
{
internal static IEnumerable<TheoryDataRow<string, double, double, double, double, string, bool, bool>> Cases()
{
''' + '\n'.join(rows) + '\n }\n}\n'
return content, len(rows)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
content, count = generate()
if args.check:
assert OUTPUT.read_text() == content, 'Fixture is stale; regenerate it'
print(f'Verified {count} logarithmic-family references at 450/650 digits.')
else:
OUTPUT.write_text(content)
print(f'Generated {count} references in {OUTPUT.name}.')
if __name__ == '__main__':
main()