From a8dc6879de86c732b5e53331554b1e7fe855bbc6 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:12:17 +0700 Subject: [PATCH] fix(kernel): treat +0.0 and -0.0 as equal in Double/Float Order Order[Double].compare used java.lang.Double.compare, which distinguishes signed zeros, while eqv used primitive ==. OptionOrder.eqv derives from compare, so Option(0.0) === Option(-0.0) was false despite 0.0 === -0.0. Align compare with IEEE floating equality (+0 equals -0) and fall back to Double/Float.compare only for NaN total ordering. Canonicalize zero in hash so Hash agrees with eqv. Fixes #4807 --- .../kernel/instances/DoubleInstances.scala | 18 +++++++++++-- .../kernel/instances/FloatInstances.scala | 17 +++++++++++-- .../test/scala/cats/tests/OrderSuite.scala | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/kernel/src/main/scala/cats/kernel/instances/DoubleInstances.scala b/kernel/src/main/scala/cats/kernel/instances/DoubleInstances.scala index 240a69b54e..77552070bb 100644 --- a/kernel/src/main/scala/cats/kernel/instances/DoubleInstances.scala +++ b/kernel/src/main/scala/cats/kernel/instances/DoubleInstances.scala @@ -36,9 +36,23 @@ class DoubleGroup extends CommutativeGroup[Double] { class DoubleOrder extends Order[Double] with Hash[Double] { - def hash(x: Double): Int = x.hashCode() + // Canonicalize +0.0 / -0.0 so Hash agrees with eqv (primitive ==). + // java.lang.Double.hashCode distinguishes the two zeros via bit patterns. + def hash(x: Double): Int = + java.lang.Double.hashCode(if (x == 0.0) 0.0 else x) + + /** + * Compare using primitive relational operators so +0.0 and -0.0 are equal, + * matching [[eqv]] / IEEE floating equality. `java.lang.Double.compare` + * implements a total order that treats them as different, which made + * `Order[Option[Double]].eqv` disagree with `Order[Double].eqv` (#4807). + * NaN values fall through to `Double.compare` for a stable total order. + */ def compare(x: Double, y: Double): Int = - java.lang.Double.compare(x, y) + if (x < y) -1 + else if (x > y) 1 + else if (x == y) 0 + else java.lang.Double.compare(x, y) override def eqv(x: Double, y: Double): Boolean = x == y override def neqv(x: Double, y: Double): Boolean = x != y diff --git a/kernel/src/main/scala/cats/kernel/instances/FloatInstances.scala b/kernel/src/main/scala/cats/kernel/instances/FloatInstances.scala index 42b0838694..0b0d230117 100644 --- a/kernel/src/main/scala/cats/kernel/instances/FloatInstances.scala +++ b/kernel/src/main/scala/cats/kernel/instances/FloatInstances.scala @@ -47,10 +47,23 @@ class FloatGroup extends CommutativeGroup[Float] { */ class FloatOrder extends Order[Float] with Hash[Float] { - def hash(x: Float): Int = x.hashCode() + // Canonicalize +0.0f / -0.0f so Hash agrees with eqv (primitive ==). + // java.lang.Float.hashCode distinguishes the two zeros via bit patterns. + def hash(x: Float): Int = + java.lang.Float.hashCode(if (x == 0.0f) 0.0f else x) + /** + * Compare using primitive relational operators so +0.0f and -0.0f are equal, + * matching [[eqv]] / IEEE floating equality. `java.lang.Float.compare` + * implements a total order that treats them as different, which made + * `Order[Option[Float]].eqv` disagree with `Order[Float].eqv` (#4807). + * NaN values fall through to `Float.compare` for a stable total order. + */ def compare(x: Float, y: Float): Int = - java.lang.Float.compare(x, y) + if (x < y) -1 + else if (x > y) 1 + else if (x == y) 0 + else java.lang.Float.compare(x, y) override def eqv(x: Float, y: Float): Boolean = x == y override def neqv(x: Float, y: Float): Boolean = x != y diff --git a/tests/shared/src/test/scala/cats/tests/OrderSuite.scala b/tests/shared/src/test/scala/cats/tests/OrderSuite.scala index 9901ade6eb..44f09a65da 100644 --- a/tests/shared/src/test/scala/cats/tests/OrderSuite.scala +++ b/tests/shared/src/test/scala/cats/tests/OrderSuite.scala @@ -76,6 +76,31 @@ class OrderSuite extends CatsSuite { assert(OrderOfCmpSub.gt(OrderSuite.CmpSub(2, "ignored"), OrderSuite.CmpSub(1, "ignored"))) assert(OrderOfCmpSub.eqv(OrderSuite.CmpSub(1, "a"), OrderSuite.CmpSub(1, "b"))) } + + // #4807: +0.0 and -0.0 must agree across eqv, compare, and derived Option eqv + test("#4807 Double signed zeros are equal under Order and Option") { + val pos = 0.0 + val neg = -0.0 + assert(Order[Double].eqv(pos, neg)) + assertEquals(Order[Double].compare(pos, neg), 0) + assert(cats.kernel.Hash[Double].hash(pos) === cats.kernel.Hash[Double].hash(neg)) + assert(Order[Option[Double]].eqv(Some(pos), Some(neg))) + assertEquals(Order[Option[Double]].compare(Some(pos), Some(neg)), 0) + assert((Some(pos): Option[Double]) === (Some(neg): Option[Double])) + assert(pos === neg) + } + + test("#4807 Float signed zeros are equal under Order and Option") { + val pos = 0.0f + val neg = -0.0f + assert(Order[Float].eqv(pos, neg)) + assertEquals(Order[Float].compare(pos, neg), 0) + assert(cats.kernel.Hash[Float].hash(pos) === cats.kernel.Hash[Float].hash(neg)) + assert(Order[Option[Float]].eqv(Some(pos), Some(neg))) + assertEquals(Order[Option[Float]].compare(Some(pos), Some(neg)), 0) + assert((Some(pos): Option[Float]) === (Some(neg): Option[Float])) + assert(pos === neg) + } } object OrderSuite {