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/.github/workflows/run_coverage.yml b/.github/workflows/run_coverage.yml
index 0e3936e09..3a668a990 100644
--- a/.github/workflows/run_coverage.yml
+++ b/.github/workflows/run_coverage.yml
@@ -41,6 +41,7 @@ jobs:
       run: |
         pytest lib/test --cov-report=xml --cov=lib --log-level=DEBUG --color=yes
     - name: Format coverage data
+      if: ${{ !cancelled()}}
       run: |
         lcov --rc lcov_branch_coverage=1 --capture --directory . \
           --output-file coverage.info
@@ -50,6 +51,7 @@ jobs:
          find . -name '*.gc*' -delete
         # gcovr --xml-pretty --exclude-unreachable-branches --print-summary -o cobertura.xml --root $PWD
     - name: Upload coverage reports to Codecov
+      if: ${{ !cancelled()}}
       uses: codecov/codecov-action@v3
       env:
         CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/src/apyfloat.cc b/src/apyfloat.cc
index 5ef5eed13..89254cf76 100644
--- a/src/apyfloat.cc
+++ b/src/apyfloat.cc
@@ -220,13 +220,14 @@ APyFloat APyFloat::_cast(
         prev_man = remainder << (man_bits - subn_adjustment);
     }
 
-    if (new_exp < -static_cast<std::int64_t>(res.man_bits
-        )) { // Exponent too small after rounding
-        return res.construct_zero();
-    }
-
     // Check if the number will be converted to a subnormal
     if (new_exp <= 0) {
+        if (new_exp < -static_cast<std::int64_t>(res.man_bits)) {
+            // Exponent too small after rounding
+            res.man = quantize_close_to_zero(sign, prev_man, quantization);
+            res.exp = 0;
+            return res;
+        }
         prev_man |= leading_one();
         res.exp = 0;
         // Cast mantissa
@@ -1081,6 +1082,12 @@ 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.exp = 0;
+                return res;
+            }
             new_man = (new_man >> (-tmp_exp + 1))
                 | ((new_man & ((1 << (-tmp_exp + 1)) - 1)) != 0);
             tmp_exp = 0;
diff --git a/src/apyfloat_util.h b/src/apyfloat_util.h
index a8bfac408..3e74c5356 100644
--- a/src/apyfloat_util.h
+++ b/src/apyfloat_util.h
@@ -161,6 +161,21 @@ void APY_INLINE quantize_mantissa(
     );
 }
 
+man_t APY_INLINE
+quantize_close_to_zero(bool sign, int64_t man, QuantizationMode quantization)
+{
+    switch (quantization) {
+    case QuantizationMode::TRN_AWAY:
+        return 1;
+    case QuantizationMode::TRN:
+        return sign;
+    case QuantizationMode::TRN_INF:
+        return !sign;
+    default:
+        return 0;
+    }
+}
+
 //! Fast integer power by squaring.
 man_t ipow(man_t base, unsigned int n);
 
diff --git a/src/apyfloatarray.cc b/src/apyfloatarray.cc
index bda605b6e..3546ec3a6 100644
--- a/src/apyfloatarray.cc
+++ b/src/apyfloatarray.cc
@@ -610,6 +610,13 @@ void APyFloatArray::hadamard_multiplication(
             }
 
             if (tmp_exp <= 0) {
+                if (tmp_exp < -static_cast<std::int64_t>(res.man_bits)) {
+                    // Exponent too small after rounding
+                    man_t res_man
+                        = quantize_close_to_zero(res_sign, new_man, quantization);
+                    res.data[i] = { res_sign, 0, res_man };
+                    continue;
+                }
                 // Shift and add sticky bit
                 new_man = (new_man >> (-tmp_exp + 1))
                     | ((new_man & ((1 << (-tmp_exp + 1)) - 1)) != 0);
@@ -802,6 +809,13 @@ APyFloatArray APyFloatArray::operator*(const APyFloat& rhs) const
             }
 
             if (tmp_exp <= 0) {
+                if (tmp_exp < -static_cast<std::int64_t>(res.man_bits)) {
+                    // Exponent too small after rounding
+                    man_t res_man
+                        = quantize_close_to_zero(res_sign, new_man, quantization);
+                    res.data[i] = { res_sign, 0, res_man };
+                    continue;
+                }
                 // Shift and add sticky bit
                 new_man = (new_man >> (-tmp_exp + 1))
                     | ((new_man & ((1 << (-tmp_exp + 1)) - 1)) != 0);


Test patch:
diff --git a/lib/test/apyfloat/test_arithmetic.py b/lib/test/apyfloat/test_arithmetic.py
index 50b6b57af..7a6fd33be 100644
--- a/lib/test/apyfloat/test_arithmetic.py
+++ b/lib/test/apyfloat/test_arithmetic.py
@@ -478,6 +478,10 @@ def test_mul_underflow():
     res = APyFloat(0, 0, 31, 4, 5) * APyFloat(0, 1, 1, 4, 5)
     assert res == 0
 
+    # From GH#406
+    res = APyFloat(0, 53, 32895, 8, 23) * APyFloat(1, 1, 1999049, 8, 23)
+    assert res == 0
+
 
 @pytest.mark.float_mul
 def test_mul_with_one():