diff --git a/CLAUDE.md b/CLAUDE.md index 5d7df3d..0e7c0ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s - Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances - `ConstantPrecision` is a ceiling, not just a width. `PiTo`/`ETo`/`Ln2To`/`Ln10To` serve at most that many digits and return the capped constant rather than failing, which is deliberate and pinned by `TestConstantAccessorsReturnTheWholeConstantWhenAskedForMore` — so a caller that *needs* what it asked for has to check, because the accessor will not. The circular functions are where this bites: reducing modulo `π/2` spends one digit of π per integer digit of the argument, so only about `ConstantPrecision - integerDigits` are left for the answer. `RequireReducibleArgument` enforces exactly that sum and nothing wider. Do not tighten it to `reductionDigits`: that pads the requirement with `TrigonometricGuardDigits` twice over as margin, margin is allowed to be unavailable, and bounding it would refuse `Sin(1000000, 130)` — a call whose every reported digit is correct. The half-turn family (`SinPi` and the rest) has no such ceiling, because it reduces before it multiplies, and `Log` has none either, because it carries the magnitude in the decimal exponent rather than cancelling it against a constant - As a value type it can't be null or inherited. Don't add null checks for `PreciseNumber` parameters, and don't reintroduce `protected` members -- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render normalized scientific notation (`d.ddd…E±n`) and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. Keep one digit before the point. The .NET 7 and 8 parsers clamp an exponent above 1000 and still offset it by every digit ahead of the point, so a long significand rendered as an integer parses as zero there. Conversions from `double`, `float`, and `Half` use the shortest text that round-trips (`"R"`). NaN and infinity coming in follow `BigInteger` too +- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render normalized scientific notation (`d.ddd…E±n`) and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. Keep one digit before the point. The .NET 7 and 8 parsers clamp an exponent above 1000 and still offset it by every digit ahead of the point, so a long significand rendered as an integer parses as zero there. Conversions from `double`, `float`, and `Half` use the shortest text that round-trips (`"R"`). NaN and infinity coming in follow `BigInteger` too. `NFloat` converts through `float` or `double`, whichever its `NFloat.Size` says it is, in both directions ### Test Structure diff --git a/PreciseNumber.Test/PreciseNumberConversionTests.cs b/PreciseNumber.Test/PreciseNumberConversionTests.cs index e0ef1da..d99a6df 100644 --- a/PreciseNumber.Test/PreciseNumberConversionTests.cs +++ b/PreciseNumber.Test/PreciseNumberConversionTests.cs @@ -4,6 +4,7 @@ namespace ktsu.PreciseNumber.Test; using System.Globalization; using System.Numerics; +using System.Runtime.InteropServices; /// /// Covers conversion through generic math, which is how code written against @@ -106,6 +107,53 @@ public void FromBinaryFloatingPointUsesDecimalText() AssertFromInAllModes(0.0, "0"); } + [TestMethod] + public void FromNFloatMatchesToPreciseNumber() + { + AssertFromInAllModes((NFloat)1.5, "1.5"); + AssertFromInAllModes((NFloat)(-0.3048), "-0.3048"); + AssertFromInAllModes((NFloat)0.0, "0"); + + foreach (NFloat value in new[] { (NFloat)0.1, NFloat.Epsilon, NFloat.MaxValue, NFloat.MinValue }) + { + Assert.AreEqual(value.ToPreciseNumber(), Checked(value), value.ToString(CultureInfo.InvariantCulture)); + } + + Assert.ThrowsExactly(() => Checked(NFloat.NaN)); + Assert.AreEqual(PreciseNumber.Zero, Saturating(NFloat.NaN)); + Assert.ThrowsExactly(() => Truncating(NFloat.PositiveInfinity)); + } + + [TestMethod] + public void ToNFloatIsCorrectlyRounded() + { + AssertToInAllModes("1.5", (NFloat)1.5); + Assert.AreEqual((NFloat)1.5, P("1.5").To()); + + NFloat expectedTenth = NFloat.Size == sizeof(float) ? new NFloat(0.1f) : new NFloat(0.1); + AssertToInAllModes("0.1", expectedTenth); + Assert.AreEqual(expectedTenth, P("0.1").To()); + + // Beyond range overflows to an infinity, and below it underflows to zero, as for double and float. + AssertToInAllModes("1E400", NFloat.PositiveInfinity); + AssertToInAllModes("-1E400", NFloat.NegativeInfinity); + AssertToInAllModes("1E-400", (NFloat)0.0); + } + + [TestMethod] + public void NFloatRoundTripsThroughPreciseNumber() + { + foreach (NFloat value in new[] { (NFloat)1.5, (NFloat)0.1, NFloat.Epsilon, NFloat.MaxValue, NFloat.MinValue, (NFloat)(1.0 / 3) }) + { + string label = value.ToString(CultureInfo.InvariantCulture); + PreciseNumber number = Checked(value); + Assert.AreEqual(value, Checked(number), label); + Assert.AreEqual(value, Saturating(number), label); + Assert.AreEqual(value, Truncating(number), label); + Assert.AreEqual(value, number.To(), label); + } + } + [TestMethod] public void FromPreciseNumberIsIdentity() { diff --git a/PreciseNumber/PreciseNumber.Conversions.cs b/PreciseNumber/PreciseNumber.Conversions.cs index bd32012..6e3c43e 100644 --- a/PreciseNumber/PreciseNumber.Conversions.cs +++ b/PreciseNumber/PreciseNumber.Conversions.cs @@ -7,6 +7,7 @@ namespace ktsu.PreciseNumber; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Numerics; +using System.Runtime.InteropServices; /// /// Conversions between and the other numeric types, as generic math @@ -147,8 +148,8 @@ public static bool TryConvertFromTruncating(TOther value, out PreciseNum /// is an integer type or , and the value is outside its range. /// /// - /// Integer types keep the integral part, truncated toward zero. , - /// and receive the nearest representable value, correctly rounded however many digits + /// Integer types keep the integral part, truncated toward zero. , , + /// and receive the nearest representable value, correctly rounded however many digits /// the number has, and overflow to an infinity rather than throwing, as every built-in conversion to a /// binary floating point type does. keeps as many digits as it can hold, rounding /// the rest the way does. @@ -219,6 +220,11 @@ private static bool TryConvertFrom(TOther value, ConversionMode mode, ou return TryConvertFromBinaryFloatingPoint((Half)(object)value, mode, out result); } + if (typeof(TOther) == typeof(NFloat)) + { + return TryConvertFromBinaryFloatingPoint((NFloat)(object)value, mode, out result); + } + if (typeof(TOther) == typeof(decimal)) { result = CreateFromFloatingPoint((decimal)(object)value); @@ -292,6 +298,13 @@ private static bool TryConvertTo(PreciseNumber value, ConversionMode mod return true; } + if (typeof(TOther) == typeof(NFloat)) + { + // NFloat is a float or a double depending on the platform, so round once, to whichever it is. + result = (TOther)(object)(NFloat.Size == sizeof(float) ? new NFloat(value.ToSingle()) : new NFloat(value.ToDouble())); + return true; + } + if (typeof(TOther) == typeof(decimal)) { result = (TOther)(object)value.ToDecimal(mode); diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 53be79b..40355c7 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -2152,8 +2152,11 @@ internal static bool DoesImplementGenericInterface(Type type, Type genericInterf /// /// Built-in numeric types and convert as /// describes: integer types truncate - /// toward zero, and binary floating point types are correctly rounded. Any other type is built from - /// the significand and a power of ten, and is limited to that precision. + /// toward zero, and binary floating point types, + /// included, are correctly rounded. Any other type is built from the significand and a + /// power of ten, and is limited to that precision; it must support + /// CreateChecked from and , or this throws + /// . /// public TOutput To() where TOutput : INumber =>