From 9e39860c56a200710ab795335eb0c3bf038b5b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:27:41 +0000 Subject: [PATCH] Convert NFloat in CreateChecked, CreateSaturating, CreateTruncating and To() [patch] TryConvertFrom and TryConvertTo recognised every built-in numeric type except NFloat, so the generic Create* conversions threw NotSupportedException in both directions, and To() fell back to NFloat.CreateChecked(BigInteger), which throws too. ToPreciseNumber() already accepted NFloat. NFloat now goes through the same binary floating point path as double, float and Half: coming in, with the same NaN and infinity rules; going out, correctly rounded to float or double, whichever NFloat.Size says it is. The To() remarks now say the fallback for other types needs CreateChecked from BigInteger and double. Fixes ktsu-dev/PreciseNumber#138 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MiwudJMQgsVwkkrRfMfwG2 --- CLAUDE.md | 2 +- .../PreciseNumberConversionTests.cs | 48 +++++++++++++++++++ PreciseNumber/PreciseNumber.Conversions.cs | 17 ++++++- PreciseNumber/PreciseNumber.cs | 7 ++- 4 files changed, 69 insertions(+), 5 deletions(-) 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 =>