Skip to content

Commit 848a951

Browse files
committed
feat(core): 16-bit float conversion operators, from direction (#2384 unit 3, closes the ticket)
Nine `from` conversions on each 16-bit float, truncating toward zero -- where truncate and floor part company for negatives -- and all explicit, so a 16-bit float can never silently become an integer in arithmetic or overload resolution. Purely additive. ONLY THE `from` DIRECTION LANDED, AND WHY THE OTHER HALF DID NOT IS THE UNIT'S REAL FINDING. .NET declares 43 conversions on Half and 47 on BFloat16; four groups cannot be transcribed: * the 13 `operator checked` variants -- C# selects them inside a checked context, C++ has no such context, and there is nothing to write; * nint/nuint -- measured, std::intptr_t IS long here and longcs is int64_t, so a separate overload is a REDEFINITION, not an addition; the conversions exist through longcs/ulongcs, which are the same type; * ushort -> Half -- the signature is already taken BY THE OPPOSITE MEANING: Half(uint16_t) is the raw bit pattern where .NET's is (Half)(float)value; * every other `to` conversion, found by BUILDING it rather than predicting it. Adding explicit Half(intcs) makes C++ prefer it for an INT LITERAL -- an exact match beats an int -> uint16_t conversion -- so Half(0x7BFF) silently stops meaning "these bits" and starts meaning "the number 31743". This type's OWN constants are written that way, and landing the constructors turned 44 SHIPPED TESTS RED, Half::MaxValue and Half::NegativeInfinity among them. Resolving it means renaming the raw-bits constructor: a public source break across 66 first-party sites WITH A SILENT MEANING CHANGE for any site not migrated. Filed as #2395 -- a decision, not a transcription. Two further measurements came from writing the test rather than reading the type: the raw-bits constructor ALREADY accepts any integer convertible to uint16_t, so "is it constructible" cannot be the pin and only the MEANING can be; and the two types have DIFFERENT SHAPES of the same hazard, because BFloat16's extra float constructor makes an int literal ambiguous there where Half silently picks raw bits. One divergence is already decidable and recorded with #2395: .NET makes the byte/sbyte conversions IMPLICIT, and reproduced as implicit converting constructors they make every int argument ambiguous -- measured. Whatever #2395 decides, those two must be explicit. What is lost is the implicitness, never the conversion. Four mutations, all caught. #2384 closes: units 1, 2a, 2b and 3 all landed. Gate: 17,585 run, 17,585 passed, 0 failed, 0 skipped across 38 executables (+3). Build directory: build/ only, --parallel 2 throughout.
1 parent 790de37 commit 848a951

6 files changed

Lines changed: 251 additions & 4 deletions

File tree

CLAUDE.md

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

docs/Migration-SixteenBitFloatMathSurface.md

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,71 @@ Seven mutations, all caught: `Sin` wired to `Cos`; `Atan2` arguments swapped; tw
167167
swapped; `ScaleB`'s integer argument routed through `float`; `Exp2` wired to `Exp10`;
168168
`BFloat16::Sqrt` wired to `Cbrt`; and `Ieee754Remainder` wired to `Pow`.
169169

170-
## What is still to come
170+
---
171+
172+
# Unit 3 — the conversion operators, `from` direction only
173+
174+
Landed 2026-08-19, same day. Purely additive: **nine `from` conversions on each type**, on top of
175+
the `float` and `double` operators that already existed.
176+
177+
## What landed
178+
179+
`explicit operator` to `charcs`, `bytecs`, `sbytecs`, `shortcs`, `ushortcs`, `intcs`, `uintcs`,
180+
`longcs`, `ulongcs` — each truncating **toward zero**, which is where "truncate" and "floor" part
181+
company for negatives, and each `explicit`, so a 16-bit float can never silently become an integer
182+
in arithmetic or overload resolution.
183+
184+
## What did not, and the reason is measured rather than argued
185+
186+
.NET declares 43 conversions on `Half` and 47 on `BFloat16`. **Four groups cannot be transcribed:**
187+
188+
1. **The 13 `operator checked` variants.** C# selects them inside a `checked` context; C++ has no
189+
such context and no way to declare a conversion distinguished only by it. Not a cost question —
190+
there is nothing to write.
191+
2. **`nint` / `nuint`.** Measured on this platform: `std::intptr_t` **is** `long`, and `longcs` is
192+
`int64_t`, which is also `long`. A separate overload is a **redefinition**, not an addition — so
193+
these conversions do exist, through `longcs`/`ulongcs`, which are the same type.
194+
3. **`ushort``Half`.** The signature is already taken, **by the opposite meaning**:
195+
`Half(uint16_t)` is the raw bit pattern; .NET's is `(Half)(float)value`.
196+
4. **Every other `to` conversion**, for a reason found by building it rather than by predicting it.
197+
198+
## The finding that blocked half the unit
199+
200+
Adding `explicit Half(intcs)` makes C++ overload resolution prefer it for an **int literal** — an
201+
exact match beats an `int → uint16_t` conversion — so `Half(0x7BFF)` silently stops meaning *"these
202+
bits"* and starts meaning *"the number 31743"*.
203+
204+
**This type's own constants are written that way.** The constructors were written and built, and
205+
**44 shipped tests turned red**, `Half::MaxValue` and `Half::NegativeInfinity` among them. A minimal
206+
probe isolates it: with only a `uint16_t` constructor, `H(0x7BFF).bits` is `31743`; add an `int32_t`
207+
constructor and the same expression takes it.
208+
209+
Two further measurements shape the decision, and both were found by *writing the test*:
210+
211+
* the raw-bits constructor **already** accepts any integer convertible to `uint16_t`, so
212+
`Half(someSByte)` compiles today and means bits. So *"is it constructible"* cannot be the pin —
213+
only the **meaning** can be, which is how `Decl2395_*` is written;
214+
* **the two types have different shapes of the same hazard.** `BFloat16` has *both* a `uint16_t` and
215+
a `float` constructor, so an int literal is **ambiguous** there and does not compile at all, while
216+
on `Half` it silently picks raw bits. One decision, two migrations.
217+
218+
Resolving it means renaming the raw-bits constructor — a public source break across **66**
219+
first-party sites **with a silent meaning change** for any site not migrated, which is the dangerous
220+
class. That is **#2395**, a decision rather than a transcription.
221+
222+
## One divergence that is already decidable
223+
224+
.NET makes the `byte` and `sbyte` conversions **implicit**. Reproduced as implicit converting
225+
constructors they make **every `int` argument ambiguous** — measured: `int → bytecs → Half` and
226+
`int → sbytecs → Half` are equally viable, because C++ permits a standard conversion before a
227+
user-defined one and C# does not. So whatever #2395 decides, those two must be `explicit`. **What is
228+
lost is the implicitness, never the conversion.**
229+
230+
## Mutation testing
171231

172-
**Unit 3** — the conversion operators, measured at **43 on `Half` and 47 on `BFloat16`**. It must
173-
move the two types in step like every unit before it.
232+
Four mutations, all caught: the `int` conversion rounding instead of truncating; the `short`
233+
conversion flooring instead of truncating toward zero; a conversion made implicit (compile error);
234+
and `BFloat16`'s `int` conversion reading the raw bits instead of the value.
174235

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

modules/core/include/System/Half.hpp

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,67 @@ namespace System {
168168
[[nodiscard]] double ToDouble() const noexcept { return static_cast<double>(ToSingle()); }
169169

170170
/** @brief Explicit conversion to a 32-bit float. */
171+
// -----------------------------------------------------------------------------------
172+
// #2384 unit 3: the conversion operators. THIS UNIT LANDS THE `from Half` DIRECTION ONLY,
173+
// and the `to Half` direction is BLOCKED ON A DECISION -- measured, not guessed.
174+
//
175+
// .NET declares 43 conversions on Half. Four groups cannot be transcribed here:
176+
//
177+
// 1. THE 13 `operator checked` VARIANTS. C# selects them inside a `checked` context;
178+
// C++ has no such context and no way to declare a conversion distinguished only by
179+
// it. Not a cost question -- there is nothing to write.
180+
//
181+
// 2. `nint` / `nuint`. Measured on this platform: `std::intptr_t` IS `long`, and
182+
// `longcs` is `int64_t`, which is also `long`. A separate overload is a REDEFINITION,
183+
// not an addition -- so these conversions exist, through `longcs`/`ulongcs`, which
184+
// are the same type.
185+
//
186+
// 3. `ushort` -> Half. THE SIGNATURE IS ALREADY TAKEN, BY THE OPPOSITE MEANING:
187+
// `Half(uint16_t)` above is the RAW BIT PATTERN, while .NET's
188+
// `explicit operator Half(ushort)` is `(Half)(float)value`. `ushortcs` and `uint16_t`
189+
// are the same type, so the two cannot coexist.
190+
//
191+
// 4. EVERY OTHER `to Half` INTEGER CONVERSION, for a reason measured rather than
192+
// predicted. Adding `explicit Half(intcs)` makes C++ overload resolution prefer it
193+
// over `Half(uint16_t)` for an INT LITERAL -- an exact match beats a conversion -- so
194+
// `Half(0x7BFF)` silently stops meaning "these bits" and starts meaning "the number
195+
// 31743". This type's OWN constants are written that way, and landing the
196+
// constructors turned 44 shipped tests red, including `Half::MaxValue` and
197+
// `Half::NegativeInfinity` themselves. Resolving it means renaming the raw-bits
198+
// constructor, which is a public source break with a SILENT meaning change across 66
199+
// first-party sites -- a decision, not a transcription. Ticket #2395.
200+
//
201+
// The `from Half` direction below has none of those problems: a conversion operator
202+
// cannot be hijacked by a literal and collides with nothing.
203+
//
204+
// ONE DELIBERATE DIVERGENCE IS ALREADY DECIDABLE, and it belongs with #2395: .NET makes
205+
// the `byte` and `sbyte` conversions IMPLICIT. Reproduced as implicit converting
206+
// constructors they make EVERY int argument ambiguous -- measured: `int -> bytecs -> Half`
207+
// and `int -> sbytecs -> Half` are equally viable, because C++ permits a standard
208+
// conversion before a user-defined one and C# does not. So whatever #2395 decides, those
209+
// two must be `explicit`: what is lost is the IMPLICITNESS, never the conversion.
210+
// -----------------------------------------------------------------------------------
211+
212+
/** @brief Converts to `char16_t`, truncating toward zero. .NET: `explicit operator char(Half)`. */
213+
explicit operator SharpRuntime::charcs() const noexcept { return static_cast<SharpRuntime::charcs>(ToSingle()); }
214+
/** @brief Converts to an 8-bit unsigned value, truncating toward zero. */
215+
explicit operator SharpRuntime::bytecs() const noexcept { return static_cast<SharpRuntime::bytecs>(ToSingle()); }
216+
/** @brief Converts to an 8-bit signed value, truncating toward zero. */
217+
explicit operator SharpRuntime::sbytecs() const noexcept { return static_cast<SharpRuntime::sbytecs>(ToSingle()); }
218+
/** @brief Converts to a 16-bit signed value, truncating toward zero. */
219+
explicit operator SharpRuntime::shortcs() const noexcept { return static_cast<SharpRuntime::shortcs>(ToSingle()); }
220+
/** @brief Converts to a 16-bit unsigned value, truncating toward zero. */
221+
explicit operator SharpRuntime::ushortcs() const noexcept { return static_cast<SharpRuntime::ushortcs>(ToSingle()); }
222+
/** @brief Converts to a 32-bit signed value, truncating toward zero. */
223+
explicit operator SharpRuntime::intcs() const noexcept { return static_cast<SharpRuntime::intcs>(ToSingle()); }
224+
/** @brief Converts to a 32-bit unsigned value, truncating toward zero. */
225+
explicit operator SharpRuntime::uintcs() const noexcept { return static_cast<SharpRuntime::uintcs>(ToSingle()); }
226+
/** @brief Converts to a 64-bit signed value, truncating toward zero. Also serves .NET's
227+
* `nint` conversion, which is the same C++ type here. */
228+
explicit operator SharpRuntime::longcs() const noexcept { return static_cast<SharpRuntime::longcs>(ToSingle()); }
229+
/** @brief Converts to a 64-bit unsigned value, truncating toward zero. Also serves `nuint`. */
230+
explicit operator SharpRuntime::ulongcs() const noexcept { return static_cast<SharpRuntime::ulongcs>(ToSingle()); }
231+
171232
explicit operator float() const noexcept { return ToSingle(); }
172233
/** @brief Explicit conversion to a 64-bit double. */
173234
explicit operator double() const noexcept { return ToDouble(); }

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,35 @@ class BFloat16 {
123123
[[nodiscard]] uint16_t getBitsProperty() const { return bits_; }
124124

125125
/** Converts to float (lossless — BFloat16 is a subset of float32). */
126+
// -----------------------------------------------------------------------------------
127+
// #2384 unit 3: the conversion operators, `from BFloat16` direction only.
128+
//
129+
// The `to BFloat16` direction is blocked on the same decision as Half's -- see Half.hpp for
130+
// the measurement. In short: `BFloat16(uint16_t)` here is the RAW BIT PATTERN, and adding any
131+
// value-taking integer constructor lets C++ overload resolution hijack every
132+
// `BFloat16(<int literal>)` call, because an exact `int` match beats an `int -> uint16_t`
133+
// conversion. 46 first-party sites use the raw form. Ticket #2395.
134+
// -----------------------------------------------------------------------------------
135+
136+
/** @brief Converts to `char16_t`, truncating toward zero. */
137+
explicit operator SharpRuntime::charcs() const { return static_cast<SharpRuntime::charcs>(toFloat(bits_)); }
138+
/** @brief Converts to an 8-bit unsigned value, truncating toward zero. */
139+
explicit operator SharpRuntime::bytecs() const { return static_cast<SharpRuntime::bytecs>(toFloat(bits_)); }
140+
/** @brief Converts to an 8-bit signed value, truncating toward zero. */
141+
explicit operator SharpRuntime::sbytecs() const { return static_cast<SharpRuntime::sbytecs>(toFloat(bits_)); }
142+
/** @brief Converts to a 16-bit signed value, truncating toward zero. */
143+
explicit operator SharpRuntime::shortcs() const { return static_cast<SharpRuntime::shortcs>(toFloat(bits_)); }
144+
/** @brief Converts to a 16-bit unsigned value, truncating toward zero. */
145+
explicit operator SharpRuntime::ushortcs() const { return static_cast<SharpRuntime::ushortcs>(toFloat(bits_)); }
146+
/** @brief Converts to a 32-bit signed value, truncating toward zero. */
147+
explicit operator SharpRuntime::intcs() const { return static_cast<SharpRuntime::intcs>(toFloat(bits_)); }
148+
/** @brief Converts to a 32-bit unsigned value, truncating toward zero. */
149+
explicit operator SharpRuntime::uintcs() const { return static_cast<SharpRuntime::uintcs>(toFloat(bits_)); }
150+
/** @brief Converts to a 64-bit signed value, truncating toward zero. Also serves `nint`. */
151+
explicit operator SharpRuntime::longcs() const { return static_cast<SharpRuntime::longcs>(toFloat(bits_)); }
152+
/** @brief Converts to a 64-bit unsigned value, truncating toward zero. Also serves `nuint`. */
153+
explicit operator SharpRuntime::ulongcs() const { return static_cast<SharpRuntime::ulongcs>(toFloat(bits_)); }
154+
126155
explicit operator float() const { return toFloat(bits_); }
127156
/** Converts to double. */
128157
explicit operator double() const { return static_cast<double>(toFloat(bits_)); }

modules/core/tests/System/BFloat16RoundingTests.cpp

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,3 +852,99 @@ TEST(Fix2384Unit2b, Decl2384_TenMembersAreAbsentBecauseFloatItselfLacksThem) {
852852
"#2384 unit 2b landed on both types");
853853
SUCCEED();
854854
}
855+
856+
// =================================================================================================
857+
// #2384 unit 3 -- the conversion operators, `from` direction, on BOTH types.
858+
//
859+
// The `to` direction is blocked on ticket #2395; the reason is measured and pinned below rather
860+
// than described, because it is the kind of hazard that reads as theoretical until it is shown.
861+
// =================================================================================================
862+
863+
TEST(Fix2384Unit3, EveryFromConversionTruncatesTowardZero) {
864+
using System::Half;
865+
using System::Numerics::BFloat16;
866+
867+
const Half h = Half::FromSingle(3.75f);
868+
EXPECT_EQ(static_cast<SharpRuntime::intcs>(h), 3) << "truncates, does not round";
869+
EXPECT_EQ(static_cast<SharpRuntime::longcs>(h), 3);
870+
EXPECT_EQ(static_cast<SharpRuntime::shortcs>(h), 3);
871+
EXPECT_EQ(static_cast<SharpRuntime::bytecs>(h), 3);
872+
EXPECT_EQ(static_cast<SharpRuntime::uintcs>(h), 3u);
873+
EXPECT_EQ(static_cast<SharpRuntime::ulongcs>(h), 3u);
874+
EXPECT_EQ(static_cast<SharpRuntime::ushortcs>(h),3u);
875+
EXPECT_EQ(static_cast<SharpRuntime::charcs>(h), 3);
876+
877+
// Toward zero for NEGATIVES too, which is where "truncate" and "floor" part company.
878+
const Half neg = Half::FromSingle(-3.75f);
879+
EXPECT_EQ(static_cast<SharpRuntime::intcs>(neg), -3) << "toward zero, not -4";
880+
EXPECT_EQ(static_cast<SharpRuntime::longcs>(neg), -3);
881+
EXPECT_EQ(static_cast<SharpRuntime::shortcs>(neg), -3);
882+
EXPECT_EQ(static_cast<SharpRuntime::sbytecs>(neg), -3);
883+
884+
const BFloat16 b(3.75f);
885+
EXPECT_EQ(static_cast<SharpRuntime::intcs>(b), 3);
886+
EXPECT_EQ(static_cast<SharpRuntime::longcs>(b), 3);
887+
EXPECT_EQ(static_cast<SharpRuntime::sbytecs>(BFloat16(-3.75f)), -3);
888+
889+
// The float/double operators that predate this unit still work and are unaffected.
890+
EXPECT_FLOAT_EQ(static_cast<float>(h), 3.75f);
891+
EXPECT_DOUBLE_EQ(static_cast<double>(h), 3.75);
892+
}
893+
894+
TEST(Fix2384Unit3, EveryFromConversionIsExplicit) {
895+
// .NET's `from Half` conversions are all `explicit`, and so are these -- a Half must never
896+
// silently become an integer in arithmetic or overload resolution. Dependent parameters, per
897+
// the #2299 gcc trap.
898+
using System::Half;
899+
using System::Numerics::BFloat16;
900+
static_assert(!std::is_convertible_v<Half, SharpRuntime::intcs>);
901+
static_assert(!std::is_convertible_v<Half, SharpRuntime::longcs>);
902+
static_assert(!std::is_convertible_v<Half, float>);
903+
static_assert(std::is_constructible_v<SharpRuntime::intcs, Half>);
904+
static_assert(std::is_constructible_v<SharpRuntime::longcs, Half>);
905+
static_assert(!std::is_convertible_v<BFloat16, SharpRuntime::intcs>);
906+
static_assert(std::is_constructible_v<SharpRuntime::intcs, BFloat16>);
907+
SUCCEED();
908+
}
909+
910+
TEST(Fix2384Unit3, Decl2395_TheToDirectionWouldHijackEveryIntegerLiteral) {
911+
// THE MEASUREMENT THAT BLOCKED THE OTHER HALF OF THIS UNIT, pinned as a live demonstration
912+
// rather than as prose -- it reads as theoretical until it is shown.
913+
//
914+
// `Half(uint16_t)` is the RAW BIT PATTERN. Adding .NET's `explicit operator Half(int)` as a
915+
// constructor makes C++ prefer it for an INT LITERAL, because an exact match beats an
916+
// int -> uint16_t conversion. So `Half(0x7BFF)` would silently stop meaning "these bits" and
917+
// start meaning "the number 31743". This type's OWN constants are written that way, and
918+
// landing the constructors turned 44 shipped tests red -- Half::MaxValue and
919+
// Half::NegativeInfinity among them.
920+
using System::Half;
921+
using System::Numerics::BFloat16;
922+
923+
// What the raw-bits constructor means TODAY, from an int literal. THIS is the pin: if a
924+
// value-taking constructor is ever added, these two assertions flip, and #2395's decision
925+
// must have been taken first.
926+
EXPECT_EQ(Half(0x3C00).bits, 0x3C00u) << "an int literal reaches the RAW-BITS constructor";
927+
EXPECT_FLOAT_EQ(Half(0x3C00).ToSingle(), 1.0f) << "...so 0x3C00 is the number 1.0, not 15360";
928+
EXPECT_EQ(Half::One.bits, 0x3C00u);
929+
EXPECT_EQ(Half::MaxValue.bits, 0x7BFFu);
930+
EXPECT_EQ(Half::NegativeInfinity.bits, 0xFC00u);
931+
932+
// A SECOND MEASURED DIFFERENCE, found by writing this case rather than by reading the types:
933+
// the raw-bits constructor already accepts ANY integer that converts to uint16_t, so
934+
// `Half(someSByte)` compiles TODAY and means raw bits. So "is it constructible" cannot be the
935+
// pin -- only the MEANING can be, which is what the assertions above check.
936+
static_assert(std::is_constructible_v<Half, SharpRuntime::sbytecs>,
937+
"the raw-bits constructor already accepts a narrower integer");
938+
EXPECT_EQ(Half(static_cast<SharpRuntime::sbytecs>(3)).bits, 3u)
939+
<< "and it means BITS -- .NET's value conversion would give 0x4200 (3.0)";
940+
941+
// AND A THIRD, which is a real difference between the port's two 16-bit floats: BFloat16 has
942+
// BOTH a uint16_t and a float constructor, so an int literal is AMBIGUOUS there and does not
943+
// compile at all -- while on Half it silently picks raw bits. The hazard therefore has
944+
// different shapes on the two types, and #2395 must decide for both.
945+
EXPECT_EQ(std::bit_cast<uint16_t>(BFloat16(static_cast<uint16_t>(0x3F80))), 0x3F80u);
946+
EXPECT_FLOAT_EQ(static_cast<float>(BFloat16(static_cast<uint16_t>(0x3F80))), 1.0f);
947+
static_assert(!std::is_constructible_v<BFloat16, int>,
948+
"#2395: an int literal is AMBIGUOUS on BFloat16 (uint16_t vs float ctor), where "
949+
"on Half it silently picks raw bits -- the same decision, two different shapes");
950+
}

plan.sqlite3

8 KB
Binary file not shown.

0 commit comments

Comments
 (0)