Repository: apytypes/apytypes (language: python)

Issue:
Deviations from Berkeley TestFloat
Some deviations and bugs in floating-point arithmetic were found when testing against Berkeley TestFloat:
* [x] (#408) As already known, the sign of zero is incorrect as it doesn't consider the quantization mode. 
* [x] (#407) The result sometimes becomes infinity instead of the maximum normal number for certain quantization modes. This makes sense when reading the definitions again, e.g. for TO_POS (from the 754 standard): "the result shall be the format’s floating-point number (possibly +∞) closest to and no less than the infinitely precise result". The result should therefore never become negative infinity for TO_POS.
* [x] (#428) f16_mul sometimes fails for directed quantization modes, e.g. the following for TO_POS:
```Python
APyFloat(1, 1, 1023, 5, 10) * APyFloat(1, 26, 80, 5, 10)
# Becomes APyFloat(sign=0, exp=13, man=79, exp_bits=5, man_bits=10)
# and not APyFloat(sign=0, exp=13, man=80, exp_bits=5, man_bits=10)
```
* [x] (#411) f32_mul sometimes results in a subnormal number instead of zero, however this isn't seen for f64_mul or f16_mul):
```Python
APyFloat(0, 53, 32895, 8, 23) * APyFloat(1, 1, 1999049, 8, 23)
# Becomes APyFloat(sign=1, exp=0, man=10184, exp_bits=8, man_bits=23)
# and not APyFloat(sign=1, exp=0, man=0, exp_bits=8, man_bits=23)
```
* [x] Added bullet point. The mantissa is sometimes wrong when casting from a larger format to a smaller, similar to the previous bullet. See comment below.

These were found from running
```cmd
python run_berkeley_cases.py -op all -qm all -s 1 -l 1
```
but dedicated test cases should of course be made while fixing these.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac48d95fa..d77848197 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Changed
 
+- v0.1.1 Smaller fixes in quantization for floating-point scalar multiplication.
+
 ### Removed
 
 ## [0.1.0] - 2024-05-15
diff --git a/src/apyfloat.cc b/src/apyfloat.cc
index 6ca872838..f0a8f1df2 100644
--- a/src/apyfloat.cc
+++ b/src/apyfloat.cc
@@ -1101,7 +1101,7 @@ APyFloat APyFloat::operator*(const APyFloat& y) const
         if (tmp_exp <= 0) {
             if (tmp_exp < -static_cast<std::int64_t>(res.man_bits)) {
                 // Exponent too small after rounding
-                res.man = quantize_close_to_zero(sign, new_man, quantization);
+                res.man = quantize_close_to_zero(res.sign, new_man, quantization);
                 res.exp = 0;
                 return res;
             }
@@ -1117,7 +1117,7 @@ APyFloat APyFloat::operator*(const APyFloat& y) const
             res_exp,
             res.max_exponent(),
             man_bits_delta,
-            sign,
+            res.sign,
             two_res,
             quantization
         );
diff --git a/src/apyfloat_util.h b/src/apyfloat_util.h
index 62214d7e7..bb54d63df 100644
--- a/src/apyfloat_util.h
+++ b/src/apyfloat_util.h
@@ -184,15 +184,20 @@ void APY_INLINE quantize_mantissa(
 }
 
 man_t APY_INLINE
-quantize_close_to_zero(bool sign, int64_t man, QuantizationMode quantization)
+quantize_close_to_zero(bool sign, man_t man, QuantizationMode quantization)
 {
     switch (quantization) {
     case QuantizationMode::TRN_AWAY:
+    case QuantizationMode::JAM:
+    case QuantizationMode::JAM_UNBIASED:
         return 1;
     case QuantizationMode::TRN:
         return sign;
     case QuantizationMode::TRN_INF:
         return !sign;
+    case QuantizationMode::STOCH_EQUAL:
+        // STOCH_WEIGHTED should not use this function
+        return random_number_float() & 1;
     default:
         return 0;
     }


Test patch:
diff --git a/lib/test/apyfloat/test_rounding.py b/lib/test/apyfloat/test_rounding.py
index df144ac8f..511c7bf9c 100644
--- a/lib/test/apyfloat/test_rounding.py
+++ b/lib/test/apyfloat/test_rounding.py
@@ -313,6 +313,19 @@ def test_to_pos(self):
             res = APyFloat(1, 30, 3, 5, 2) * APyFloat(0, 30, 3, 5, 2)
             assert res.is_identical(APyFloat(1, 30, 3, 5, 2))
 
+            # Tests from https://github.com/apytypes/apytypes/issues/406
+            res = APyFloat(1, 1, 1023, 5, 10) * APyFloat(1, 26, 80, 5, 10)
+            assert res.is_identical(
+                APyFloat(sign=0, exp=13, man=80, exp_bits=5, man_bits=10)
+            )
+
+            res = APyFloat(sign=0, exp=2, man=72, exp_bits=5, man_bits=10) * APyFloat(
+                sign=0, exp=0, man=58, exp_bits=5, man_bits=10
+            )
+            assert res.is_identical(
+                APyFloat(sign=0, exp=0, man=1, exp_bits=5, man_bits=10)
+            )
+
     def test_to_neg(self):
         with APyFloatQuantizationContext(QuantizationMode.TO_NEG):
             # Should round down
@@ -346,6 +359,80 @@ def test_to_neg(self):
             res = APyFloat(1, 30, 3, 5, 2) * APyFloat(0, 30, 3, 5, 2)
             assert res.is_identical(APyFloat(1, 31, 0, 5, 2))
 
+    def test_jam(self):
+        # TODO: more tests
+        with APyFloatQuantizationContext(QuantizationMode.JAM):
+            # Test jamming for subnormal result
+            res = APyFloat(sign=0, exp=0, man=1, exp_bits=5, man_bits=10) * APyFloat(
+                sign=1, exp=0, man=1, exp_bits=5, man_bits=10
+            )
+            assert res.is_identical(
+                APyFloat(sign=1, exp=0, man=1, exp_bits=5, man_bits=10)
+            )
+
+    def test_jam_unbiased(self):
+        # TODO: more tests
+        with APyFloatQuantizationContext(QuantizationMode.JAM_UNBIASED):
+            # Test jamming for subnormal result
+            res = APyFloat(sign=0, exp=0, man=1, exp_bits=5, man_bits=10) * APyFloat(
+                sign=1, exp=0, man=1, exp_bits=5, man_bits=10
+            )
+            assert res.is_identical(
+                APyFloat(sign=1, exp=0, man=1, exp_bits=5, man_bits=10)
+            )
+
+    def test_stoch_weighted(self):
+        with APyFloatQuantizationContext(QuantizationMode.STOCH_WEIGHTED):
+            # 1.25 * 1.25 should quantize to 1.5 or 1.75
+            res = APyFloat(0, 15, 1, 5, 2) * APyFloat(0, 15, 1, 5, 2)
+            assert res.is_identical(APyFloat(0, 15, 2, 5, 2)) or (
+                res.is_identical(APyFloat(0, 15, 3, 5, 2))
+            )
+
+            res = APyFloat(0, 15, 1, 5, 52) * APyFloat(0, 15, 1, 5, 52)
+            assert res.is_identical(APyFloat(0, 15, 2, 5, 52)) or (
+                res.is_identical(APyFloat(0, 15, 3, 5, 52))
+            )
+
+            # Should quantize to zero or smallest subnormal
+            res = APyFloat(0, 1, 0, 4, 2) * APyFloat(0, 1, 0, 4, 2)
+            assert res.is_identical(APyFloat(0, 0, 0, 4, 2)) or (
+                res.is_identical(APyFloat(0, 0, 1, 4, 2))
+            )
+
+            res = APyFloat(0, 0, (1 << 27) - 1, 4, 52) * APyFloat(
+                0, 0, (1 << 30) - 1, 4, 52
+            )
+            assert res.is_identical(APyFloat(0, 0, 0, 4, 52)) or (
+                res.is_identical(APyFloat(0, 0, 1, 4, 52))
+            )
+
+    def test_stoch_equal(self):
+        with APyFloatQuantizationContext(QuantizationMode.STOCH_EQUAL):
+            # 1.25 * 1.25 should quantize to 1.5 or 1.75
+            res = APyFloat(0, 15, 1, 5, 2) * APyFloat(0, 15, 1, 5, 2)
+            assert res.is_identical(APyFloat(0, 15, 2, 5, 2)) or (
+                res.is_identical(APyFloat(0, 15, 3, 5, 2))
+            )
+
+            res = APyFloat(0, 15, 1, 5, 52) * APyFloat(0, 15, 1, 5, 52)
+            assert res.is_identical(APyFloat(0, 15, 2, 5, 52)) or (
+                res.is_identical(APyFloat(0, 15, 3, 5, 52))
+            )
+
+            # Should quantize to zero or smallest subnormal
+            res = APyFloat(0, 1, 0, 4, 2) * APyFloat(0, 1, 0, 4, 2)
+            assert res.is_identical(APyFloat(0, 0, 0, 4, 2)) or (
+                res.is_identical(APyFloat(0, 0, 1, 4, 2))
+            )
+
+            res = APyFloat(0, 0, (1 << 27) - 1, 4, 52) * APyFloat(
+                0, 0, (1 << 30) - 1, 4, 52
+            )
+            assert res.is_identical(APyFloat(0, 0, 0, 4, 52)) or (
+                res.is_identical(APyFloat(0, 0, 1, 4, 52))
+            )
+
 
 @pytest.mark.float_div
 class TestAPyFloatQuantizationDiv: