From ca876c0a3a4f8cbc7b84346454f418dd59317452 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Wed, 8 Jul 2026 18:19:03 +0100 Subject: [PATCH 1/2] fix(js/ts): match .NET NaN semantics in JS comparison, min and max --- src/fable-library-ts/Double.ts | 6 ++++-- src/fable-library-ts/Util.ts | 9 +++++++-- tests/Js/Main/ArithmeticTests.fs | 30 ++++++++++++++++++++++++++++++ tests/Js/Main/ComparisonTests.fs | 20 ++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/fable-library-ts/Double.ts b/src/fable-library-ts/Double.ts index c2952bc2eb..5c73a6b537 100644 --- a/src/fable-library-ts/Double.ts +++ b/src/fable-library-ts/Double.ts @@ -36,18 +36,20 @@ export function isInfinity(x: number) { } export function max(x: number, y: number): number { - return x > y ? x : y; + return Math.max(x, y); } export function min(x: number, y: number): number { - return x < y ? x : y; + return Math.min(x, y); } export function maxMagnitude(x: number, y: number): number { + if (Number.isNaN(x) || Number.isNaN(y)) { return NaN; } return Math.abs(x) > Math.abs(y) ? x : y; } export function minMagnitude(x: number, y: number): number { + if (Number.isNaN(x) || Number.isNaN(y)) { return NaN; } return Math.abs(x) < Math.abs(y) ? x : y; } diff --git a/src/fable-library-ts/Util.ts b/src/fable-library-ts/Util.ts index f8f0bb0663..9961578a69 100644 --- a/src/fable-library-ts/Util.ts +++ b/src/fable-library-ts/Util.ts @@ -564,7 +564,12 @@ export function compareDates(x: Date | IDateTime | IDateTimeOffset, y: Date | ID } export function comparePrimitives(x: T, y: T): number { - return x === y ? 0 : (x < y ? -1 : 1); + if (x === y) { return 0; } + if (x < y) { return -1; } + if (x > y) { return 1; } + // Neither equal nor ordered: at least one operand is NaN. + // Match .NET Double.CompareTo: NaN equals NaN and is less than any other value. + return Number.isNaN(x as unknown) ? (Number.isNaN(y as unknown) ? 0 : -1) : 1; } export function compareArraysWith(x: ArrayLike, y: ArrayLike, comp: (x: T, y: T) => number): number { @@ -616,7 +621,7 @@ export function compare(x: T, y: T): number { } else if (isArrayLike(x)) { return isArrayLike(y) ? compareArrays(x, y) : -1; } else if (typeof x !== "object") { - return x < y ? -1 : 1; + return comparePrimitives(x, y); } else if (x instanceof Date) { return y instanceof Date ? compareDates(x, y) : -1; } else { diff --git a/tests/Js/Main/ArithmeticTests.fs b/tests/Js/Main/ArithmeticTests.fs index 4103eb0d99..bde2028708 100644 --- a/tests/Js/Main/ArithmeticTests.fs +++ b/tests/Js/Main/ArithmeticTests.fs @@ -702,6 +702,16 @@ let tests = Decimal.MaxMagnitude(-4.0M, 3.0M) |> equal -4.0M bigint.MaxMagnitude(-4I, 3I) |> equal -4I + testCase "Numeric Min/Max/MinMagnitude/MaxMagnitude propagate NaN" <| fun () -> + Double.IsNaN(Double.Min(nan, 1.0)) |> equal true + Double.IsNaN(Double.Min(1.0, nan)) |> equal true + Double.IsNaN(Double.Max(nan, 1.0)) |> equal true + Double.IsNaN(Double.Max(1.0, nan)) |> equal true + Double.IsNaN(Double.MinMagnitude(nan, 1.0)) |> equal true + Double.IsNaN(Double.MinMagnitude(1.0, nan)) |> equal true + Double.IsNaN(Double.MaxMagnitude(nan, 1.0)) |> equal true + Double.IsNaN(Double.MaxMagnitude(1.0, nan)) |> equal true + testCase "Numeric Clamp works" <| fun () -> SByte.Clamp(5y, -4y, 3y) |> equal 3y Int16.Clamp(5s, -4s, 3s) |> equal 3s @@ -745,6 +755,26 @@ let tests = Math.MaxMagnitude(-4.0, 3.0) |> equal -4.0 MathF.MaxMagnitude(-4.0f, 3.0f) |> equal -4.0f + testCase "Math.Min/Max/MinMagnitude/MaxMagnitude propagate NaN" <| fun () -> + Double.IsNaN(Math.Min(nan, 1.0)) |> equal true + Double.IsNaN(Math.Min(1.0, nan)) |> equal true + Double.IsNaN(Math.Max(nan, 1.0)) |> equal true + Double.IsNaN(Math.Max(1.0, nan)) |> equal true + Double.IsNaN(Math.MinMagnitude(nan, 1.0)) |> equal true + Double.IsNaN(Math.MinMagnitude(1.0, nan)) |> equal true + Double.IsNaN(Math.MaxMagnitude(nan, 1.0)) |> equal true + Double.IsNaN(Math.MaxMagnitude(1.0, nan)) |> equal true + + // fable-compiler-js (used by both the npm package and the Standalone build) loses the sign + // when printing a `-0.0` literal, so this can only be tested against Fable.Cli. +#if !NPM_PACKAGE_FABLE_COMPILER_JAVASCRIPT + testCase "Math.Min/Max prefer +0.0 over -0.0 regardless of operand order" <| fun () -> + Double.IsPositiveInfinity(1.0 / Math.Max(0.0, -0.0)) |> equal true + Double.IsPositiveInfinity(1.0 / Math.Max(-0.0, 0.0)) |> equal true + Double.IsNegativeInfinity(1.0 / Math.Min(0.0, -0.0)) |> equal true + Double.IsNegativeInfinity(1.0 / Math.Min(-0.0, 0.0)) |> equal true +#endif + testCase "incr works" <| fun () -> let i = ref 5 incr i diff --git a/tests/Js/Main/ComparisonTests.fs b/tests/Js/Main/ComparisonTests.fs index 461d399a3f..2366c60c7a 100644 --- a/tests/Js/Main/ComparisonTests.fs +++ b/tests/Js/Main/ComparisonTests.fs @@ -371,6 +371,26 @@ let tests = equal 1 (compare c1 c3) equal true (c1 > c3) + testCase "compare with NaN follows .NET total ordering" <| fun () -> + equal 0 (compare nan nan) + equal -1 (compare nan 1.0) + equal 1 (compare 1.0 nan) + equal -1 (compare nan infinity) + equal 1 (compare infinity nan) + equal -1 (compare nan (-infinity)) + equal (compare nan 1.0) (-(compare 1.0 nan)) + + testCase "List.sort with NaN puts NaN first" <| fun () -> + let sorted = List.sort [ 3.; nan; 1.; 2. ] + Double.IsNaN(List.head sorted) |> equal true + equal [ 1.; 2.; 3. ] (List.tail sorted) + + testCase "min and max propagate NaN like .NET" <| fun () -> + Double.IsNaN(min nan 1.0) |> equal true + Double.IsNaN(min 1.0 nan) |> equal true + Double.IsNaN(max nan 1.0) |> equal true + Double.IsNaN(max 1.0 nan) |> equal true + testCase "max works with primitives" <| fun () -> max 1 2 |> equal 2 max 10m 2m |> equal 10m From a61706646b535738ab7cfe0494381eb24cf7b265 Mon Sep 17 00:00:00 2001 From: Mangel Maxime Date: Thu, 9 Jul 2026 11:18:19 +0200 Subject: [PATCH 2/2] fix(python): match .NET NaN and signed-zero semantics in comparison, min and max --- src/Fable.Transforms/Python/Replacements.fs | 14 +++++-- src/fable-library-py/fable_library/double.py | 43 +++++++++++++++++--- src/fable-library-py/fable_library/util.py | 10 ++++- tests/Python/TestArithmetic.fs | 14 +++++++ tests/Python/TestComparison.fs | 23 +++++++++++ 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/Fable.Transforms/Python/Replacements.fs b/src/Fable.Transforms/Python/Replacements.fs index 4b9e7a1200..f12b1c22a2 100644 --- a/src/Fable.Transforms/Python/Replacements.fs +++ b/src/Fable.Transforms/Python/Replacements.fs @@ -1351,10 +1351,18 @@ let operators (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr o | (Operators.greaterThanOrEqual | "Gte"), [ left; right ] -> booleanCompare com ctx r left right BinaryGreaterOrEqual |> Some | ("Min" | "Max" | "Clamp" as meth), _ -> - let f = makeComparerFunction com ctx t + let meth = Naming.lowerFirst meth - Helper.LibCall(com, "util", Naming.lowerFirst meth, t, f :: args, i.SignatureArgTypes, ?loc = r) - |> Some + match args with + // Floats need NaN-propagating Min/Max/Clamp + | ExprType(Number((Float16 | Float32 | Float64), _)) :: _ -> + Helper.LibCall(com, "double", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + |> Some + | _ -> + let f = makeComparerFunction com ctx t + + Helper.LibCall(com, "util", meth, t, f :: args, i.SignatureArgTypes, ?loc = r) + |> Some | "Not", [ operand ] -> // TODO: Check custom operator? makeUnOp r t operand UnaryNot |> Some | Patterns.SetContains Operators.standardSet, _ -> applyOp com ctx r t i.CompiledName args |> Some diff --git a/src/fable-library-py/fable_library/double.py b/src/fable-library-py/fable_library/double.py index 0324fa982c..a8d6372dc3 100644 --- a/src/fable-library-py/fable_library/double.py +++ b/src/fable-library-py/fable_library/double.py @@ -1,4 +1,5 @@ -from typing import Any +import math +from typing import Any, overload from .core import FSharpRef, float32, float64, floats @@ -7,12 +8,44 @@ def sign(x: float64) -> float64: return float64(-1) if x < 0 else float64(1) if x > 0 else float64(0) -def max(x: float64, y: float64) -> float64: - return x if x > y else y +@overload +def max(x: float32, y: float32) -> float32: ... +@overload +def max(x: float64, y: float64) -> float64: ... +def max(x: float32 | float64, y: float32 | float64) -> float32 | float64: + # IEEE 754 maximum (.NET Math.Max since .NET Core 3.0): NaN propagates (preserving + # whichever operand was NaN), and +0.0 beats -0.0 even though they compare equal. + if x != x: + return x + if y != y: + return y + if x != y: + return x if x > y else y + return x if not math.copysign(1.0, x) < 0 else y -def min(x: float64, y: float64) -> float64: - return x if x < y else y +@overload +def min(x: float32, y: float32) -> float32: ... +@overload +def min(x: float64, y: float64) -> float64: ... +def min(x: float32 | float64, y: float32 | float64) -> float32 | float64: + # IEEE 754 minimum (.NET Math.Min since .NET Core 3.0): NaN propagates (preserving + # whichever operand was NaN), and -0.0 beats +0.0 even though they compare equal. + if x != x: + return x + if y != y: + return y + if x != y: + return x if x < y else y + return x if math.copysign(1.0, x) < 0 else y + + +@overload +def clamp(value: float32, min: float32, max: float32) -> float32: ... +@overload +def clamp(value: float64, min: float64, max: float64) -> float64: ... +def clamp(value: float32 | float64, min: float32 | float64, max: float32 | float64) -> float32 | float64: + return min if value < min else max if value > max else value def try_parse(string: str, def_value: FSharpRef[float32 | float64 | Any]) -> bool: diff --git a/src/fable-library-py/fable_library/util.py b/src/fable-library-py/fable_library/util.py index d25e19b12b..37b84e4303 100644 --- a/src/fable-library-py/fable_library/util.py +++ b/src/fable-library-py/fable_library/util.py @@ -308,7 +308,15 @@ def equal_arrays[T](x: Sequence[T], y: Sequence[T]) -> bool: def compare_primitives[TSupportsLessThan: SupportsLessThan](x: TSupportsLessThan, y: TSupportsLessThan) -> int32: - return int32(0 if x == y else (-1 if x < y else 1)) + if x == y: + return int32(0) + if x < y: + return int32(-1) + if y < x: + return int32(1) + # Neither equal, less, nor greater: at least one operand is NaN. + # Match .NET Double.CompareTo: NaN equals NaN and is less than any other value. + return int32(0 if x != x and y != y else (-1 if x != x else 1)) def min[T](comparer: Callable[[T, T], int], x: T, y: T) -> T: diff --git a/tests/Python/TestArithmetic.fs b/tests/Python/TestArithmetic.fs index 2f46b16e49..1294a1139a 100644 --- a/tests/Python/TestArithmetic.fs +++ b/tests/Python/TestArithmetic.fs @@ -889,6 +889,20 @@ let ``test Math.Max works`` () = Math.Max(-4.0, 3.0) |> equal 3.0 MathF.Max(-4.0f, 3.0f) |> equal 3.0f +[] +let ``test Math.Min/Max propagate NaN`` () = + Double.IsNaN(Math.Min(nan, 1.0)) |> equal true + Double.IsNaN(Math.Min(1.0, nan)) |> equal true + Double.IsNaN(Math.Max(nan, 1.0)) |> equal true + Double.IsNaN(Math.Max(1.0, nan)) |> equal true + +[] +let ``test Math.Min/Max prefer +0.0 over -0.0 regardless of operand order`` () = + Double.IsPositiveInfinity(1.0 / Math.Max(0.0, -0.0)) |> equal true + Double.IsPositiveInfinity(1.0 / Math.Max(-0.0, 0.0)) |> equal true + Double.IsNegativeInfinity(1.0 / Math.Min(0.0, -0.0)) |> equal true + Double.IsNegativeInfinity(1.0 / Math.Min(-0.0, 0.0)) |> equal true + // [] // let ``test Math.MinMagnitude works`` () = // Math.MinMagnitude(-4.0, 3.0) |> equal 3.0 diff --git a/tests/Python/TestComparison.fs b/tests/Python/TestComparison.fs index ac62728894..eec68c6c7b 100644 --- a/tests/Python/TestComparison.fs +++ b/tests/Python/TestComparison.fs @@ -447,6 +447,29 @@ let ``test Comparison with objects implementing IComparable works`` () = equal 1 (compare c1 c3) equal true (c1 > c3) +[] +let ``test compare with NaN follows .NET total ordering`` () = + equal 0 (compare nan nan) + equal -1 (compare nan 1.0) + equal 1 (compare 1.0 nan) + equal -1 (compare nan infinity) + equal 1 (compare infinity nan) + equal -1 (compare nan (-infinity)) + equal (compare nan 1.0) (-(compare 1.0 nan)) + +[] +let ``test List.sort with NaN puts NaN first`` () = + let sorted = List.sort [ 3.; nan; 1.; 2. ] + Double.IsNaN(List.head sorted) |> equal true + equal [ 1.; 2.; 3. ] (List.tail sorted) + +[] +let ``test min and max propagate NaN like .NET`` () = + Double.IsNaN(min nan 1.0) |> equal true + Double.IsNaN(min 1.0 nan) |> equal true + Double.IsNaN(max nan 1.0) |> equal true + Double.IsNaN(max 1.0 nan) |> equal true + [] let ``test max works with primitives`` () = max 1 2 |> equal 2