From 587578068e1da92c44f1ec12c948fa65f93d7ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20M=C3=BCller-Widmann?= Date: Mon, 26 Oct 2026 13:26:00 +0100 Subject: [PATCH] Make `min`/`max` on `Dual` consistent with `isless` `min`/`max` were generated from DiffRules, which choose the partials by value only. On ties in value the result depended on the argument order rather than `isless`, which breaks ties on the partials since #695. Fixes #868. Co-Authored-By: Claude Opus 5.5 --- src/dual.jl | 9 ++++++++- test/DualTest.jl | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/dual.jl b/src/dual.jl index 6d13dec3..f3c197c4 100644 --- a/src/dual.jl +++ b/src/dual.jl @@ -474,7 +474,7 @@ Base.float(d::Dual) = convert(float(typeof(d)), d) ################################### for (M, f, arity) in DiffRules.diffrules(filter_modules = nothing) - if (M, f) in ((:Base, :^), (:NaNMath, :pow), (:Base, :/), (:Base, :+), (:Base, :-), (:Base, :sin), (:Base, :cos)) + if (M, f) in ((:Base, :^), (:NaNMath, :pow), (:Base, :/), (:Base, :+), (:Base, :-), (:Base, :sin), (:Base, :cos), (:Base, :min), (:Base, :max)) continue # Skip methods which we define elsewhere. elseif !(isdefined(@__MODULE__, M) && isdefined(getfield(@__MODULE__, M), f)) continue # Skip rules for methods not defined in the current scope @@ -543,6 +543,13 @@ end end ) +# min/max # +#---------# + +# Consistent with `isless` (also on ties in value), and propagate NaN like `min`/`max` on floats; see issue #868 +@inline Base.min(x::D, y::D) where {D<:Dual} = ifelse(isnan(x) | (!isnan(y) & !isless(y, x)), x, y) +@inline Base.max(x::D, y::D) where {D<:Dual} = ifelse(isnan(x) | (!isnan(y) & !isless(x, y)), x, y) + # exponentiation # #----------------# diff --git a/test/DualTest.jl b/test/DualTest.jl index 67d9c9f7..1988a2b8 100644 --- a/test/DualTest.jl +++ b/test/DualTest.jl @@ -702,6 +702,48 @@ end @test floatmin(Dual{Nothing, ForwardDiff.Dual{Nothing, Float64, 2}, 1}) === Dual{Nothing}(Dual{Nothing}(floatmin(Float64),0.0,0.0),Dual{Nothing}(0.0,0.0,0.0)) end +@testset "min/max consistent with isless (issue #868)" begin + D(v, p...) = Dual{TestTag}(v, p...) + one_ = D(1.0, 0.0) + one_pos = D(1.0, 1.0) + one_neg = D(1.0, -1.0) + one_nan = D(1.0, NaN) + nan_ = D(NaN, 0.0) + nested_one = D(D(1.0, 0.0), D(0.0, 0.0)) + nested_one_pos = D(D(1.0, 1.0), D(0.0, 0.0)) + nested_one_innernan = D(D(1.0, NaN), D(0.0, 0.0)) + nested_one_outernan = D(D(1.0, 0.0), D(NaN, 0.0)) + nested_nan = D(D(NaN, 0.0), D(0.0, 0.0)) + + # (a, b, min(a, b), max(a, b)) + cases = [ + (one_pos, 1.0, one_, one_pos), + (one_pos, 1, one_, one_pos), + (one_neg, 1.0, one_neg, one_), + (one_pos, one_neg, one_neg, one_pos), + (one_pos, D(2.0, -1.0), one_pos, D(2.0, -1.0)), + (one_nan, one_, one_, one_nan), + (nan_, one_pos, nan_, nan_), + (nan_, 1.0, nan_, nan_), + (nested_one_pos, 1.0, nested_one, nested_one_pos), + (nested_one_innernan, nested_one, nested_one, nested_one_innernan), + (nested_one_outernan, nested_one, nested_one, nested_one_outernan), + (nested_nan, nested_one_pos, nested_nan, nested_nan), + (nested_nan, 1.0, nested_nan, nested_nan), + ] + for (a, b, lo, hi) in cases + @test min(a, b) === min(b, a) === lo + @test max(a, b) === max(b, a) === hi + if !isnan(a) && !isnan(b) + @test !isless(a, min(a, b)) && !isless(b, min(a, b)) + @test !isless(max(a, b), a) && !isless(max(a, b), b) + end + end + + @test ForwardDiff.derivative(t -> min(t, 48.0), 48.0) === ForwardDiff.derivative(t -> min(48.0, t), 48.0) === 0.0 + @test ForwardDiff.derivative(t -> max(t, 48.0), 48.0) === ForwardDiff.derivative(t -> max(48.0, t), 48.0) === 1.0 +end + @testset "Integer" begin x = Dual(1.0,0) @test Int(x) ≡ Integer(x) ≡ convert(Int,x) ≡ convert(Integer,x) ≡ 1