Skip to content

Commit be56329

Browse files
committed
feat(core): 16-bit floats gain rounding, Sign and the IEEE *Number family (#2384 unit 2a)
Purely additive. Ceiling, Floor, Round x2, Truncate, Sign and the IEEE 754:2019 MaxNumber/MinNumber/MaxMagnitudeNumber/MinMagnitudeNumber on BOTH 16-bit floats, plus MaxNative/MinNative on Half. THE *Number FAMILY IS NOT Max/Min, and two rules separate them -- both pinned, because a forward to Max satisfies every ordinary row and fails exactly these: it does NOT propagate NaN (Max(NaN,2) is NaN; MaxNumber(NaN,2) is 2, from either side), and +0 is treated as LARGER than -0, which NO COMPARISON CAN SEE, so the pin asserts the bits. A naive (x > y) ? x : y returns the wrong zero and passes everything else in the file. Sign has two transcribed edges: it THROWS ArithmeticException on NaN rather than returning a sentinel, and it tests IsZero BEFORE IsNegative, so Sign(-0.0) is 0. Round is ties-to-even through MathF::Round, not std::round. THE UNIT'S MOST USEFUL FINDING IS A LIMIT ON #2340's OWN RULE. Measured by diffing the two reference surfaces, MaxNative, MinNative, ClampNative and MultiplyAddEstimate are declared on Half ONLY -- so "in step" means each type gets what .NET gives it, not that the two surfaces are identical. That only becomes visible once the surface is large enough to differ. Their absence on BFloat16 is pinned so a later unit that "completes the symmetry" has to justify inventing them. Six mutations, all caught. Remaining, measured rather than estimated: unit 2b is ~45 members per type, mostly one-line forwards; unit 3 is 43 conversion operators on Half and 47 on BFloat16. Unit 2b's absence is pinned on both types via Sqrt. Gate: 17,579 run, 17,579 passed, 0 failed, 0 skipped across 38 executables (+5). Build directory: build/ only, --parallel 2 throughout.
1 parent 0663e17 commit be56329

6 files changed

Lines changed: 360 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/Migration-SixteenBitFloatMathSurface.md

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,57 @@ unchanged. Probed exhaustively over all 65,536 patterns, the two forms differ on
6969
every one a **signalling** NaN, which `fromFloat` quiets by OR-ing in `0x0040`. The case now uses
7070
`0x7F81` and asserts the exact bits.
7171

72+
---
73+
74+
# Unit 2a — rounding, `Sign`, and the IEEE 754:2019 `*Number` family
75+
76+
Landed 2026-08-19, same day. Purely additive again.
77+
78+
## The `*Number` family is not `Max`/`Min`, and two rules separate them
79+
80+
`MaxNumber`/`MinNumber`/`MaxMagnitudeNumber`/`MinMagnitudeNumber` are IEEE 754:2019
81+
`maximumNumber` and friends (`Half.cs:1673-1720, 1854-1904`). Two differences from `Max`/`Min`,
82+
both real and both pinned, because a forward to `Max` satisfies every ordinary row and fails
83+
exactly these:
84+
85+
1. **They do not propagate NaN.** `Max(NaN, 2)` is NaN; `MaxNumber(NaN, 2)` is `2`, from either
86+
side. .NET says so in its own comment.
87+
2. **`+0` is treated as larger than `-0`** — and **no comparison can see that**, since `+0.0 ==
88+
-0.0`, so the pin asserts the **bits**. A naive `(x > y) ? x : y` returns the wrong zero and
89+
passes everything else in the file.
90+
91+
## `Sign` has two transcribed edges
92+
93+
It **throws** `ArithmeticException` on NaN rather than returning a sentinel, and it tests `IsZero`
94+
**before** `IsNegative` — so **`Sign(-0.0)` is `0`, not `-1`**.
95+
96+
## `Round` is ties-to-even
97+
98+
Forwarded to `MathF::Round`, not `std::round`: `Round(2.5)` is `2`, not `3`. The mutation that
99+
swaps them is caught.
100+
101+
## Four members exist on `Half` only, and that is transcription rather than asymmetry
102+
103+
Measured by diffing the two reference surfaces: **`MaxNative`, `MinNative`, `ClampNative` and
104+
`MultiplyAddEstimate` are declared on `Half` only.** So **#2340's in-step rule means *each type
105+
gets what .NET gives it*, not that the two surfaces are identical** — a distinction that only
106+
becomes visible once the surface is large enough to differ. Their absence on `BFloat16` is pinned,
107+
so a later unit that "completes the symmetry" has to justify inventing them.
108+
109+
## Mutation testing
110+
111+
Six mutations, all caught: `MaxNumber` forwarding to `Max`; dropping its signed-zero tie; `Sign`
112+
returning `-1` for `-0.0`; `Sign` returning `0` instead of throwing; `Round` ties away from zero;
113+
and `BFloat16::MinNumber` propagating NaN.
114+
72115
## What is still to come
73116

74-
Unit 1 is the sign/magnitude family. **Unit 2** is the transcendental families (`Sqrt`, `Exp`,
75-
`Log`, the trigonometric and hyperbolic sets, `Pow`, `FusedMultiplyAdd`, `ReciprocalEstimate`) and
76-
**unit 3** is the conversion operators. Both remain, both must move the two types in step, and
77-
unit 2's absence is pinned on both types today.
117+
**Unit 2b** is the transcendental families proper (`Sqrt`, `Cbrt`, `RootN`, `Exp`, `Log`, `Pow`,
118+
`Compound`, the trigonometric and `*Pi` sets, the hyperbolic set, `Hypot`, `ScaleB`, `Lerp`,
119+
`FusedMultiplyAdd`, `DegreesToRadians`/`RadiansToDegrees`, the two estimates) — measured at **~45
120+
members per type**, mostly one-line forwards. **Unit 3** is the conversion operators, measured at
121+
**43 on `Half` and 47 on `BFloat16`**. Both must move the two types in step, and unit 2b's absence
122+
is pinned on both types today via `Sqrt`.
78123

79124
Out of scope permanently, and unchanged by this ticket: **generic-math conformance**
80125
(`INumber<T>`, `IFloatingPointIeee754<T>`, `IMinMaxValue<T>`). .NET's `BFloat16` implements 36

modules/core/include/System/Half.hpp

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
55
#include <cstdint>
6+
#include "System/ArithmeticException.hpp"
67
#include "System/MathF.hpp"
78
#include <cstring>
89
#include <string>
@@ -282,6 +283,66 @@ namespace System {
282283
* (`Half.cs:1879`). */
283284
[[nodiscard]] static Half MinMagnitude(Half x, Half y) noexcept;
284285

286+
// -----------------------------------------------------------------------------------
287+
// #2384 unit 2a: rounding, Sign, and the IEEE 754:2019 *Number family.
288+
//
289+
// The *Number members are NOT the same as Max/Min: they are `maximumNumber` /
290+
// `minimumNumber`, which DO NOT PROPAGATE NaN and treat +0 as larger than -0. Both
291+
// differences are real and both are pinned -- a forward to MathF::Max would satisfy every
292+
// ordinary row and fail exactly those two.
293+
// -----------------------------------------------------------------------------------
294+
295+
/** @brief The smallest integral value >= @p x. .NET: `(Half)MathF.Ceiling((float)x)`
296+
* (`Half.cs:1312`). */
297+
[[nodiscard]] static Half Ceiling(Half x) noexcept;
298+
/** @brief The largest integral value <= @p x. .NET: `Half.cs:1323`. */
299+
[[nodiscard]] static Half Floor(Half x) noexcept;
300+
/** @brief Rounds to the nearest integral value, ties to even. .NET: `Half.cs:1326`. */
301+
[[nodiscard]] static Half Round(Half x) noexcept;
302+
/** @brief Rounds to @p digits fractional digits. .NET: `Half.cs:1329`. */
303+
[[nodiscard]] static Half Round(Half x, SharpRuntime::intcs digits);
304+
/** @brief The integral part of @p x. .NET: `Half.cs:1338`. */
305+
[[nodiscard]] static Half Truncate(Half x) noexcept;
306+
307+
/**
308+
* @brief The sign of @p value: -1, 0 or +1.
309+
*
310+
* C++ counterpart of .NET `Half.Sign(Half)` (`Half.cs:1723-1740`).
311+
* @throws System::ArithmeticException if @p value is NaN -- .NET throws
312+
* `ArithmeticException(SR.Arithmetic_NaN)` rather than returning a sentinel, and
313+
* that is transcribed rather than softened.
314+
* @note `Sign(-0.0)` is **0**, not -1: .NET tests `IsZero` BEFORE `IsNegative`, so the
315+
* sign of a signed zero is zero.
316+
*/
317+
[[nodiscard]] static SharpRuntime::intcs Sign(Half value);
318+
319+
/**
320+
* @brief IEEE 754:2019 `maximumNumber`. .NET: `Half.cs:1673-1692`.
321+
* @note **Does not propagate NaN** -- unlike @c Max, a NaN operand is ignored and the
322+
* other is returned. And **+0 is treated as larger than -0**, which no comparison
323+
* can see, so both are pinned.
324+
*/
325+
[[nodiscard]] static Half MaxNumber(Half x, Half y) noexcept;
326+
/** @brief IEEE 754:2019 `minimumNumber`. .NET: `Half.cs:1701-1720`. See @c MaxNumber. */
327+
[[nodiscard]] static Half MinNumber(Half x, Half y) noexcept;
328+
/** @brief IEEE 754:2019 `maximumMagnitudeNumber`. .NET: `Half.cs:1854-1876`. */
329+
[[nodiscard]] static Half MaxMagnitudeNumber(Half x, Half y) noexcept;
330+
/** @brief IEEE 754:2019 `minimumMagnitudeNumber`. .NET: `Half.cs:1882-1904`. */
331+
[[nodiscard]] static Half MinMagnitudeNumber(Half x, Half y) noexcept;
332+
333+
/**
334+
* @brief The larger of two values by the `>` operator alone.
335+
*
336+
* .NET: `(x > y) ? x : y` (`Half.cs:1670`). **`System::Numerics::BFloat16` has NO
337+
* counterpart**, and that is not an omission here: .NET declares `MaxNative`, `MinNative`,
338+
* `ClampNative` and `MultiplyAddEstimate` on `Half` ONLY. So #2340's in-step rule means
339+
* *each type gets what .NET gives it*, not *the two surfaces are identical* -- measured,
340+
* not assumed.
341+
*/
342+
[[nodiscard]] static Half MaxNative(Half x, Half y) noexcept { return (x > y) ? x : y; }
343+
/** @brief The smaller of two values by `<` alone. .NET: `Half.cs:1698`. Half-only. */
344+
[[nodiscard]] static Half MinNative(Half x, Half y) noexcept { return (x < y) ? x : y; }
345+
285346
/** @brief Represents the largest finite half-precision value (65504). C++ counterpart of .NET Half.MaxValue. */
286347
static const Half MaxValue;
287348
/** @brief Represents the most negative finite half-precision value (-65504). C++ counterpart of .NET Half.MinValue. */
@@ -525,6 +586,52 @@ namespace System {
525586
inline const Half Half::NaN = Half(0xFE00);
526587
inline const Half Half::PositiveInfinity = Half(0x7C00);
527588
inline const Half Half::NegativeInfinity = Half(0xFC00);
589+
// #2384 unit 2a definitions.
590+
inline Half Half::Ceiling(Half x) noexcept { return FromSingle(System::MathF::Ceiling(x.ToSingle())); }
591+
inline Half Half::Floor(Half x) noexcept { return FromSingle(System::MathF::Floor(x.ToSingle())); }
592+
inline Half Half::Round(Half x) noexcept { return FromSingle(System::MathF::Round(x.ToSingle())); }
593+
inline Half Half::Round(Half x, SharpRuntime::intcs digits) {
594+
return FromSingle(System::MathF::Round(x.ToSingle(), digits));
595+
}
596+
inline Half Half::Truncate(Half x) noexcept { return FromSingle(System::MathF::Truncate(x.ToSingle())); }
597+
598+
inline SharpRuntime::intcs Half::Sign(Half value) {
599+
// .NET throws rather than returning a sentinel, and tests IsZero BEFORE IsNegative, so
600+
// Sign(-0.0) is 0 rather than -1.
601+
if (IsNaN(value)) throw System::ArithmeticException("Function does not accept floating point Not-a-Number values.");
602+
if ((value.bits & 0x7FFFu) == 0u) return 0;
603+
return IsNegative(value) ? -1 : 1;
604+
}
605+
606+
inline Half Half::MaxNumber(Half x, Half y) noexcept {
607+
if (!(x.ToSingle() == y.ToSingle())) { // x != y, with NaN making this true
608+
if (!IsNaN(y)) return (y.ToSingle() < x.ToSingle()) ? x : y;
609+
return x;
610+
}
611+
return IsNegative(y) ? x : y; // equal: +0 is larger than -0
612+
}
613+
inline Half Half::MinNumber(Half x, Half y) noexcept {
614+
if (!(x.ToSingle() == y.ToSingle())) {
615+
if (!IsNaN(y)) return (x.ToSingle() < y.ToSingle()) ? x : y;
616+
return x;
617+
}
618+
return IsNegative(x) ? x : y;
619+
}
620+
inline Half Half::MaxMagnitudeNumber(Half x, Half y) noexcept {
621+
const float ax = Abs(x).ToSingle();
622+
const float ay = Abs(y).ToSingle();
623+
if ((ax > ay) || IsNaN(y)) return x;
624+
if (ax == ay) return IsNegative(x) ? y : x;
625+
return y;
626+
}
627+
inline Half Half::MinMagnitudeNumber(Half x, Half y) noexcept {
628+
const float ax = Abs(x).ToSingle();
629+
const float ay = Abs(y).ToSingle();
630+
if ((ax < ay) || IsNaN(y)) return x;
631+
if (ax == ay) return IsNegative(x) ? x : y;
632+
return y;
633+
}
634+
528635
// #2384 unit 1: the four float round-trip members, defined after the constants they need.
529636
// Each is .NET's own expression, not a re-derivation -- Clamp is float.Clamp, Max/Min are
530637
// float.Max/float.Min, and MaxMagnitude/MinMagnitude are MathF.MaxMagnitude/MinMagnitude.

modules/core/include/System/Numerics/BFloat16.hpp

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)
44
#pragma once
55
#include <algorithm>
6+
#include "System/ArithmeticException.hpp"
67
#include "System/MathF.hpp"
78
#include <array>
89
#include <charconv>
@@ -267,6 +268,82 @@ class BFloat16 {
267268
return BFloat16(System::MathF::MinMagnitude(toFloat(x.bits_), toFloat(y.bits_)));
268269
}
269270

271+
// -----------------------------------------------------------------------------------
272+
// #2384 unit 2a: rounding, Sign, and the IEEE 754:2019 *Number family.
273+
//
274+
// NOTE WHAT IS ABSENT AND WHY. .NET declares MaxNative, MinNative, ClampNative and
275+
// MultiplyAddEstimate on Half ONLY -- measured by diffing the two ref surfaces. So #2340's
276+
// in-step rule means EACH TYPE GETS WHAT .NET GIVES IT, not that the two surfaces are
277+
// identical, and the four members missing here are missing deliberately.
278+
// -----------------------------------------------------------------------------------
279+
280+
/** @brief The smallest integral value >= @p x. .NET: `BFloat16.cs`, `MathF.Ceiling` round-trip. */
281+
[[nodiscard]] static BFloat16 Ceiling(BFloat16 x) noexcept {
282+
return BFloat16(System::MathF::Ceiling(toFloat(x.bits_)));
283+
}
284+
/** @brief The largest integral value <= @p x. */
285+
[[nodiscard]] static BFloat16 Floor(BFloat16 x) noexcept {
286+
return BFloat16(System::MathF::Floor(toFloat(x.bits_)));
287+
}
288+
/** @brief Rounds to the nearest integral value, ties to even. */
289+
[[nodiscard]] static BFloat16 Round(BFloat16 x) noexcept {
290+
return BFloat16(System::MathF::Round(toFloat(x.bits_)));
291+
}
292+
/** @brief Rounds to @p digits fractional digits. */
293+
[[nodiscard]] static BFloat16 Round(BFloat16 x, SharpRuntime::intcs digits) {
294+
return BFloat16(System::MathF::Round(toFloat(x.bits_), digits));
295+
}
296+
/** @brief The integral part of @p x. */
297+
[[nodiscard]] static BFloat16 Truncate(BFloat16 x) noexcept {
298+
return BFloat16(System::MathF::Truncate(toFloat(x.bits_)));
299+
}
300+
301+
/**
302+
* @brief The sign of @p value: -1, 0 or +1.
303+
* @throws System::ArithmeticException if @p value is NaN, as .NET does.
304+
* @note `Sign(-0.0)` is **0** -- .NET tests IsZero BEFORE IsNegative.
305+
*/
306+
[[nodiscard]] static SharpRuntime::intcs Sign(BFloat16 value) {
307+
if (IsNaN(value)) throw System::ArithmeticException(
308+
"Function does not accept floating point Not-a-Number values.");
309+
if ((value.bits_ & 0x7FFFu) == 0u) return 0;
310+
return IsNegative(value) ? -1 : 1;
311+
}
312+
313+
/** @brief IEEE 754:2019 `maximumNumber` -- does NOT propagate NaN, and treats +0 as larger
314+
* than -0. Both differ from @c Max and both are pinned. */
315+
[[nodiscard]] static BFloat16 MaxNumber(BFloat16 x, BFloat16 y) noexcept {
316+
if (!(toFloat(x.bits_) == toFloat(y.bits_))) {
317+
if (!IsNaN(y)) return (toFloat(y.bits_) < toFloat(x.bits_)) ? x : y;
318+
return x;
319+
}
320+
return IsNegative(y) ? x : y;
321+
}
322+
/** @brief IEEE 754:2019 `minimumNumber`. See @c MaxNumber. */
323+
[[nodiscard]] static BFloat16 MinNumber(BFloat16 x, BFloat16 y) noexcept {
324+
if (!(toFloat(x.bits_) == toFloat(y.bits_))) {
325+
if (!IsNaN(y)) return (toFloat(x.bits_) < toFloat(y.bits_)) ? x : y;
326+
return x;
327+
}
328+
return IsNegative(x) ? x : y;
329+
}
330+
/** @brief IEEE 754:2019 `maximumMagnitudeNumber`. */
331+
[[nodiscard]] static BFloat16 MaxMagnitudeNumber(BFloat16 x, BFloat16 y) noexcept {
332+
const float ax = toFloat(Abs(x).bits_);
333+
const float ay = toFloat(Abs(y).bits_);
334+
if ((ax > ay) || IsNaN(y)) return x;
335+
if (ax == ay) return IsNegative(x) ? y : x;
336+
return y;
337+
}
338+
/** @brief IEEE 754:2019 `minimumMagnitudeNumber`. */
339+
[[nodiscard]] static BFloat16 MinMagnitudeNumber(BFloat16 x, BFloat16 y) noexcept {
340+
const float ax = toFloat(Abs(x).bits_);
341+
const float ay = toFloat(Abs(y).bits_);
342+
if ((ax < ay) || IsNaN(y)) return x;
343+
if (ax == ay) return IsNegative(x) ? x : y;
344+
return y;
345+
}
346+
270347
[[nodiscard]] static bool IsFinite(BFloat16 v) noexcept {
271348
return (v.bits_ & 0x7F80u) != 0x7F80u;
272349
}

0 commit comments

Comments
 (0)