Skip to main content

fuel_ir/
dtype.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Types for elements that can be stored and manipulated using tensors.
3//!
4//! The [`DType`] enum represents the element type of a tensor, and the [`WithDType`] trait
5//! allows Rust native types to be used with tensors.
6#![allow(clippy::redundant_closure_call)]
7use crate::{Error, Result};
8
9#[cfg(feature = "serde")]
10use serde::{Deserialize, Serialize};
11
12/// The different types of elements allowed in tensors.
13// EXHAUSTIVE-BY-DESIGN: `DType` is a closed set. The consumers that map a dtype
14// to a concrete per-variant fact — `size_in_bytes`, `as_str`, the host-buffer
15// allocation tables (fuel-cpu-backend), the safetensors/FDX/DLPack tag maps
16// (`--features dlpack`), and scalar `zero`/`one`/`from_f64` — match it
17// exhaustively, and several are CROSS-CRATE, so a wildcard there cannot produce a
18// correct answer: it would fabricate a byte size / tag and silently corrupt.
19// Adding a variant is a breaking change: gate it workspace-wide AND under every
20// feature that has a consumer (`--features cuda`/`vulkan`/`dlpack`), never just
21// `-p fuel-ir`. Do NOT add `#[non_exhaustive]` here — it would force wildcards
22// onto those cross-crate exhaustive tables and route a new dtype to the wrong
23// buffer. (The `DType::ALL` + `_all_variants_witness` pair below is the existing
24// compile-time tripwire this marker names. Consumers that *decline* an unknown
25// dtype loudly — `Err`/`bail!`/`None` — are fine and expected; only the
26// exhaustive concrete-fact maps are load-bearing.) See GAP-049.
27#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
28#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
29pub enum DType {
30    /// Unsigned 8-bit integer.
31    U8,
32    /// Signed 8-bit integer. Added 2026-05-19 for int8 GEMM (W8A8
33    /// inference) support — baracuda's `gemm_s8_*` family accepts
34    /// `i8` operands and `mma.sync` `.s8.s8.s32` instructions.
35    I8,
36    /// Unsigned 32-bit integer.
37    U32,
38    /// Signed 16-bit integer.
39    I16,
40    /// Signed 32-bit integer.
41    I32,
42    /// Signed 64-bit integer.
43    I64,
44    /// Brain floating-point using half precision (16 bits).
45    BF16,
46    /// Floating-point using half precision (16 bits).
47    F16,
48    /// Floating-point using single precision (32 bits).
49    F32,
50    /// Floating-point using double precision (64 bits).
51    F64,
52    /// 8-bit floating point, 4-bit exponent + 3-bit mantissa — specifically
53    /// **OCP finite E4M3FN**: bias 7, max-finite ±448, no infinities, single
54    /// NaN. This is the only E4M3 format Fuel represents, so the bare name is
55    /// unambiguous today (enforced by
56    /// `reserved_token_tests::exactly_one_e4m3_family_dtype_or_revisit_gap_169`).
57    ///
58    /// The identity `F8E4M3 == OCP E4M3FN` is committed and tested at three
59    /// independent sites: the structure-key path (`fuel-dispatch`
60    /// `telemetry::baracuda_provider::map_element_kind`), the JIT seam
61    /// (`fuel-dispatch` `jit_adopt::fp8_maps_to_baracuda_ocp_element_kinds`),
62    /// and the external-contract parser (`fuel-dispatch` `fkc::lower`'s
63    /// "F8E4M3FNUZ must not lower to F8E4M3" prefix rejection).
64    ///
65    /// **The name is deliberately NOT `F8E4M3FN`** (GAP-169). A rename would
66    /// touch ~110 sites across every backend, cascade through the
67    /// `cuda_dtype!` / `ctor!` macros into `CudaStorageSlice` / `PinnedBuffer`,
68    /// and break 3 `fuel-metal-backend` sites that cannot be compiled on the
69    /// primary dev box — all for reader clarity only. The interop token is
70    /// already `f8e4m3fn` on the sk4 path (`token_kind`), and this dtype's own
71    /// token stays `f8e4m3` for the checked-in `.fkc.md` contracts that author
72    /// it (see the `RESERVED_DTYPE_TOKENS` note below). Revisit only when a
73    /// second E4M3-family variant (e.g. `F8E4M3FNUZ`) actually lands — the
74    /// witness above fires then.
75    F8E4M3,
76    /// 8-bit floating point with 5-bit exponent and 2-bit mantissa — the
77    /// OCP-standard FP8 pair's other half.
78    ///
79    /// E4M3 (above) carries more mantissa and is the forward-pass format;
80    /// E5M2 trades mantissa for exponent range and is the gradient format.
81    /// Shipping one without the other leaves FP8 training unrepresentable,
82    /// and `fuel-cuda-backend`'s cast table already documents six baracuda
83    /// cast pairs it skips for want of this variant. Distinct from
84    /// [`F8E6M2`](Self::F8E6M2), which is a non-OCP block-scale type.
85    F8E5M2,
86    /// 6-bit float with 2 exponent bits and 3 mantissa bits (MX6 format).
87    F6E2M3,
88    /// 6-bit float with 3 exponent bits and 2 mantissa bits (MX6 format).
89    F6E3M2,
90    /// 4-bit float (MX4 format).
91    F4,
92    /// 8-bit float with 8 exponent bits and 0 mantissa bits.
93    F8E8M0,
94    /// Unsigned 8-bit block-scale: 6 exponent bits, 2 mantissa bits, no sign
95    /// (0+6+2=8). A finer-granularity sibling of [`F8E8M0`](Self::F8E8M0) —
96    /// trades 2 exponent bits for 2 mantissa — so a scale type, not a signed
97    /// value dtype. (The "8-bit float, 6 exp, 2 mant" framing it shipped with in
98    /// e275538c was a misread: 1+6+2=9 doesn't fit a signed byte.)
99    F8E6M2,
100    /// Boolean truth value, one byte per element (`0` = false, `1` = true).
101    ///
102    /// The output dtype of the element-wise comparisons
103    /// (`eq`/`ne`/`lt`/`le`/`gt`/`ge`) — GAP-168(c) / CireSnave's 2026-08-12
104    /// ruling that Fuel "uses `Bool` where possible and casts to `U8` when
105    /// necessary". Storage width equals `u8` (KISS-CLASSIFY `bool`: "1-byte
106    /// truth value; storage width equals `u8`"), so a backend may store it in
107    /// a `U8` buffer — but it is a DISTINCT logical dtype: neither an integer
108    /// nor a floating-point value (`is_int` and `is_float` are both `false`),
109    /// so using a mask AS A NUMBER (multiply/sum/accumulate) is rejected at
110    /// build time and extracting a number requires an explicit `.cast(U8)`.
111    Bool,
112}
113
114impl DType {
115    /// Every [`DType`] variant, in declaration order.
116    ///
117    /// Completeness is REMINDED, not compiler-derived (GAP-248). `_all_variants_witness`
118    /// below is a wildcard-free `match`, so adding a `DType` fails to compile THERE — a
119    /// variant cannot be added without being dragged to this file. But extending THIS
120    /// list in the same edit is then convention: nothing ties `ALL` to the witness, so a
121    /// variant added to the match yet forgotten here would still compile. A hand-written
122    /// `const` (not a derived iterator) because the anchor is a compile-time tripwire —
123    /// the residual gap is that it reminds rather than derives. Measured complete at head
124    /// (`ALL` = witness = enum = 18). The enforced version is a single-source macro
125    /// declaring the enum and `ALL` together; see GAP-248.
126    pub const ALL: &'static [DType] = &[
127        DType::U8,
128        DType::I8,
129        DType::U32,
130        DType::I16,
131        DType::I32,
132        DType::I64,
133        DType::BF16,
134        DType::F16,
135        DType::F32,
136        DType::F64,
137        DType::F8E4M3,
138        DType::F8E5M2,
139        DType::F6E2M3,
140        DType::F6E3M2,
141        DType::F4,
142        DType::F8E8M0,
143        DType::F8E6M2,
144        DType::Bool,
145    ];
146}
147
148#[cfg(test)]
149mod reserved_token_tests {
150    use super::*;
151    use std::str::FromStr;
152
153    /// A reserved spelling (KISS-CLASSIFY §6.1's reserved vocabulary) must be
154    /// recognized and declined **distinguishably from an unknown token**.
155    ///
156    /// The whole claim is in one assertion pair: both reject, and they reject
157    /// DIFFERENTLY — matchably, not merely with different prose. (Scope: this
158    /// is the §6.1 vocabulary applied to Fuel's own parser, not §6.1-0001
159    /// conformance — see [`RESERVED_DTYPE_TOKENS`].)
160    #[test]
161    fn reserved_fnuz_declines_distinctly_from_an_unknown_token() {
162        for tok in RESERVED_DTYPE_TOKENS {
163            let err = DType::from_str(tok).expect_err("a reserved token must not parse");
164            assert!(
165                err.is_reserved(),
166                "{tok} is reserved by §6.1 and must decline AS reserved, not as \
167                 unknown — a caller cannot otherwise tell 'wait for a schema bump' \
168                 from 'fix your input'",
169            );
170        }
171
172        // The other side of the distinction. Without this, a deriver that
173        // reported EVERYTHING as reserved would pass the loop above.
174        let unknown = DType::from_str("asdf").expect_err("garbage must not parse");
175        assert!(
176            !unknown.is_reserved(),
177            "an unknown token must NOT report as reserved"
178        );
179        assert_eq!(unknown, DTypeParseError::Unknown("asdf".into()));
180    }
181
182    /// The FKC contract spelling is uppercase; the reserved list is canonical
183    /// lowercase. One list serves both, so the comparison must be
184    /// case-insensitive — otherwise the FKC site silently loses the
185    /// distinction while this one keeps it.
186    #[test]
187    fn reserved_lookup_is_case_insensitive_so_one_list_serves_both_sites() {
188        assert!(is_reserved_dtype_token("F8E4M3FNUZ"));
189        assert!(is_reserved_dtype_token("f8e5m2fnuz"));
190        assert!(
191            !is_reserved_dtype_token("f8e4m3fn"),
192            "the OCP form is NOT reserved"
193        );
194        assert!(!is_reserved_dtype_token("asdf"));
195    }
196
197    /// **The delimiter trap, named so the next reader sees it.** Every
198    /// supported token here is a PREFIX of a reserved one: `f8e4m3` prefixes
199    /// `f8e4m3fnuz`, `f8e5m2` prefixes `f8e5m2fnuz`. Any `starts_with` /
200    /// `contains` / prefix-strip classifier therefore reads the RESERVED token
201    /// as the SUPPORTED one — and the caller then computes on `fnuz` bytes as
202    /// if they were OCP, which is silent numerical corruption rather than a
203    /// loud failure.
204    ///
205    /// This site is safe *by construction* (`FromStr` is an exact-literal
206    /// `match`, `is_reserved_dtype_token` is whole-token `eq_ignore_ascii_case`)
207    /// — but "safe by construction" is a property of today's implementation,
208    /// so it is asserted rather than assumed. Both directions are checked: a
209    /// reserved token must not resolve to its prefix's dtype, and a supported
210    /// token must not be swept up as reserved.
211    #[test]
212    fn a_reserved_token_is_never_confused_with_the_supported_token_it_extends() {
213        // Direction 1 — the dangerous one. A prefix matcher would return
214        // Ok(F8E4M3) / Ok(F8E5M2) here.
215        assert!(
216            !matches!(DType::from_str("f8e4m3fnuz"), Ok(DType::F8E4M3)),
217            "f8e4m3fnuz must NOT parse as its OCP prefix f8e4m3 — different \
218             exponent bias, so the bytes decode to different numbers",
219        );
220        assert!(
221            !matches!(DType::from_str("f8e5m2fnuz"), Ok(DType::F8E5M2)),
222            "f8e5m2fnuz must NOT parse as its OCP prefix f8e5m2",
223        );
224
225        // Direction 2 — the mirror. A `contains`-style reserved check would
226        // sweep the supported tokens in, declining dtypes Fuel really has.
227        assert!(!is_reserved_dtype_token("f8e4m3"));
228        assert!(!is_reserved_dtype_token("f8e5m2"));
229
230        // Non-vacuity: these ARE genuine prefix pairs, so the assertions above
231        // are exercising the trap and not passing on unrelated strings.
232        for tok in RESERVED_DTYPE_TOKENS {
233            assert!(
234                DType::ALL.iter().any(|d| tok.starts_with(d.as_str())),
235                "{tok} was expected to EXTEND a supported token; if that is no \
236                 longer true this test has stopped testing the prefix trap",
237            );
238        }
239    }
240
241    /// A reserved token must never also be parseable as a real dtype — i.e. the
242    /// reserved list and the accepted set must not overlap. Guards the mirror
243    /// mistake: adding `fnuz` to `FromStr`'s accept arms later would make the
244    /// reserved arm unreachable and silently activate an unimplemented layout.
245    #[test]
246    fn reserved_tokens_are_disjoint_from_the_accepted_set() {
247        for tok in RESERVED_DTYPE_TOKENS {
248            assert!(
249                DType::from_str(tok).is_err(),
250                "{tok} is reserved and must not resolve to a DType",
251            );
252        }
253        // Non-vacuity: the accept path genuinely works, so the loop above is
254        // asserting rejection rather than a broken parser rejecting everything.
255        assert_eq!(DType::from_str("f8e4m3").unwrap(), DType::F8E4M3);
256        assert_eq!(DType::from_str("f8e5m2").unwrap(), DType::F8E5M2);
257    }
258
259    /// **GAP-169 deferral expiry witness.** The bare name `F8E4M3` is
260    /// unambiguous only while there is exactly ONE E4M3-family dtype — today,
261    /// the OCP-finite `F8E4M3`. If a second ever lands (e.g. an AMD
262    /// `F8E4M3FNUZ`: different bias, no −0), the bare name becomes ambiguous and
263    /// the rename deferred in GAP-169 (see `DType::F8E4M3`'s doc) must be
264    /// revisited. This asserts the **premise** (exactly one E4M3 today), not the
265    /// conclusion (the name is fine).
266    ///
267    /// **Completeness is inherited, not assumed:** the population is
268    /// [`DType::ALL`], which [`_all_variants_witness`] REMINDS the author to keep
269    /// exhaustive (a variant cannot be added without a compile error at the witness,
270    /// though nothing forces it into `ALL` — GAP-248; measured complete at head), and
271    /// the count is derived through
272    /// the same `is_e4m3_family` predicate that feeds the assertion — so the
273    /// perturbation control (widen the predicate, watch it fire at two) actually
274    /// exercises the detection path. Keep the filter on `DType::ALL`: this file
275    /// also holds the raw literals `"f8e4m3fn"` / `"f8e4m3fnuz"` (reserved-token
276    /// rejection), which are not any variant's [`as_str`](DType::as_str), so
277    /// pointing the predicate at raw tokens would inflate the count with no
278    /// variant added.
279    ///
280    /// **Bound:** this detects a sibling *spelled* `f8e4m3*`, not the E4M3
281    /// *format* in the abstract — a sibling spelled otherwise (e.g. a bare
282    /// `e4m3fnuz`, the pre-respell sk4 form) would slip past. The prefix match
283    /// is deliberate and is NOT the delimiter trap: that trap is a guard firing
284    /// on a longer string it did not mean to catch, whereas this witness WANTS
285    /// every longer `f8e4m3*` spelling — so `starts_with` is the intended
286    /// semantics; do not "fix" it into `==`, which silently destroys the
287    /// detection. The token it keys on cannot drift: GAP-169 ruled `as_str()`
288    /// must stay `f8e4m3` because external `.fkc.md` contracts parse it, so the
289    /// deferral protects its own expiry witness.
290    #[test]
291    fn exactly_one_e4m3_family_dtype_or_revisit_gap_169() {
292        fn is_e4m3_family(dt: DType) -> bool {
293            // Prefix, not equality: every `f8e4m3*` spelling (e.g. a future
294            // `f8e4m3fnuz`) is E4M3-family and must count. See the bound above.
295            dt.as_str().starts_with("f8e4m3")
296        }
297        let e4m3: Vec<DType> = DType::ALL
298            .iter()
299            .copied()
300            .filter(|&dt| is_e4m3_family(dt))
301            .collect();
302        assert_eq!(
303            e4m3.as_slice(),
304            [DType::F8E4M3].as_slice(),
305            "GAP-169 deferral premise broken: the E4M3 family now has {} members \
306             ({e4m3:?}), not one. A sibling E4M3 variant exists, so the bare \
307             `F8E4M3` name is now ambiguous — revisit GAP-169 (rename to \
308             F8E4M3FN vs the sibling; see DType::F8E4M3's doc).",
309            e4m3.len(),
310        );
311    }
312
313    /// Every variant in [`DType::ALL`] must return a consistent byte count
314    /// from [`size_in_bytes`](DType::size_in_bytes). An incorrect value
315    /// silently corrupts memory allocation, stride computation, and buffer
316    /// sizing — so this is a regression gate: a new variant that forgets its
317    /// size gets caught here rather than in a downstream OOM.
318    ///
319    /// Sub-byte types (F6E2M3, F6E3M2, F4) legitimately return 0 — they are
320    /// packed in hardware and have no natural byte-aligned storage width.
321    #[test]
322    fn size_in_bytes_is_consistent_for_every_variant() {
323        let expected: &[(DType, usize)] = &[
324            (DType::U8, 1),
325            (DType::I8, 1),
326            (DType::U32, 4),
327            (DType::I16, 2),
328            (DType::I32, 4),
329            (DType::I64, 8),
330            (DType::BF16, 2),
331            (DType::F16, 2),
332            (DType::F32, 4),
333            (DType::F64, 8),
334            (DType::F8E4M3, 1),
335            (DType::F8E5M2, 1),
336            (DType::F6E2M3, 0),
337            (DType::F6E3M2, 0),
338            (DType::F4, 0),
339            (DType::F8E8M0, 1),
340            (DType::F8E6M2, 1),
341            (DType::Bool, 1),
342        ];
343        assert_eq!(
344            DType::ALL.len(),
345            expected.len(),
346            "DType::ALL has {} variants but expected list has {} — \
347             add the new variant to the expected list above",
348            DType::ALL.len(),
349            expected.len(),
350        );
351        let map: std::collections::HashMap<DType, usize> = expected.iter().copied().collect();
352        for &dt in DType::ALL {
353            let size = map
354                .get(&dt)
355                .copied()
356                .unwrap_or_else(|| panic!("DType::{dt:?} missing from expected list"));
357            assert_eq!(
358                dt.size_in_bytes(),
359                size,
360                "{dt:?}.size_in_bytes() returned {} but expected {size}",
361                dt.size_in_bytes(),
362            );
363        }
364    }
365}
366
367/// Compile-time completeness tripwire for [`DType::ALL`]. This wildcard-free
368/// `match` names every variant, so adding a `DType` fails to compile HERE
369/// (non-exhaustive `match`) until the new variant is handled — and
370/// [`DType::ALL`] is to be extended in the same edit. Never called; it exists
371/// only so the compiler forces acknowledgement of every variant.
372fn _all_variants_witness(dt: DType) {
373    match dt {
374        DType::U8
375        | DType::I8
376        | DType::U32
377        | DType::I16
378        | DType::I32
379        | DType::I64
380        | DType::BF16
381        | DType::F16
382        | DType::F32
383        | DType::F64
384        | DType::F8E4M3
385        | DType::F8E5M2
386        | DType::F6E2M3
387        | DType::F6E3M2
388        | DType::F4
389        | DType::F8E8M0
390        | DType::F8E6M2
391        | DType::Bool => {}
392    }
393}
394
395/// The **single source of truth** for KISS-CLASSIFY §6.1 tokens that are
396/// RESERVED at this schema version.
397///
398/// Canonical lowercase; compare case-insensitively so the FKC contract
399/// spelling (`F8E4M3FNUZ`) resolves against the same list. **One list, two
400/// call sites** — `FromStr` here and the FKC token table in
401/// `fuel-dispatch/src/fkc/lower.rs` — because two hand-copied lists is the
402/// drift pattern this project keeps re-finding.
403///
404/// These are the AMD `fnuz` FP8 layouts. They are **byte-incompatible** with
405/// their OCP counterparts (different exponent bias, no −0, no infinities), so
406/// treating one as the other silently corrupts every element rather than
407/// failing — which is why the two rejections are worth telling apart.
408///
409/// # Provenance, and the spelling that changed
410///
411/// Verbatim from KISS-CLASSIFY at **sk4**: `conformance/src/structure_key.rs`
412/// `RESERVED_DTYPES: [&str; 2]`. sk3 spelled these `e4m3fnuz` / `e5m2fnuz`;
413/// sk4 respells them with the `f8` prefix. **A stale list here would be wrong
414/// in the most expensive way** — tokens that never occur decline nothing, and
415/// every test still passes. Re-check this list against KISS on any schema
416/// bump; read it with `git show origin/main:<path>`, since a KISS working tree
417/// may sit behind its own `origin/main`.
418///
419/// # Scope — what these do and do NOT discharge
420///
421/// KISS-CLASSIFY §6.1-0001 places its obligation on a **reader of a
422/// `structure_key`**. Fuel has no such reader: it DERIVES and emits keys, and
423/// treats foreign ones as opaque bytes by design (K1 opacity —
424/// `telemetry::structure_key::StructureKeyToken`). Measured, not assumed: no
425/// split/decode path over a structure_key exists outside test code, which
426/// parses only Fuel's own just-emitted token to assert its shape.
427///
428/// So this list does **not** make Fuel §6.1-0001-conformant; there is
429/// currently no site in Fuel where that clause applies. What it does is
430/// (a) harden the two dtype-token surfaces Fuel really does ingest — its own
431/// `FromStr` and FKC contract files, which are external, hand-authored input —
432/// so a correctly-spelled `f8e5m2fnuz` is told it is reserved rather than sent
433/// hunting for a typo, and (b) put the vocabulary and the decline shape in one
434/// place for the day Fuel gains a key reader.
435///
436/// Note the two vocabularies differ elsewhere even though they agree here:
437/// Fuel's internal token for the OCP type is `f8e4m3`, while the KISS seam
438/// spells it `f8e4m3fn` (see `telemetry::structure_key_derive`). The reserved
439/// spellings coincide only because Fuel has no `fnuz` type at all.
440///
441/// # This decline EXPIRES (GAP-161)
442///
443/// sk4 §6.1-0001 gives reserved spellings "no computation semantics at this
444/// schema version" and calls activation "a future additive schema event". So
445/// unlike a decline that is permanently correct, this one has a **scheduled
446/// trigger** — it becomes wrong when KISS activates the tokens. What
447/// distinguishes it from a missing-kernel decline is not that it never expires
448/// but that its trigger is **external and announced**: it cannot quietly
449/// become true through a Fuel-side change. The maintenance that follows is to
450/// watch KISS's `RESERVED_DTYPES` on a schema bump, and this const is the
451/// single grep target to flip when that happens.
452pub const RESERVED_DTYPE_TOKENS: &[&str] = &["f8e4m3fnuz", "f8e5m2fnuz"];
453
454/// Is `s` a §6.1 token that is RESERVED at this schema version?
455pub fn is_reserved_dtype_token(s: &str) -> bool {
456    RESERVED_DTYPE_TOKENS
457        .iter()
458        .any(|t| s.eq_ignore_ascii_case(t))
459}
460
461/// Error returned when a string cannot be parsed as a [`DType`].
462///
463/// # Why this distinguishes two rejections
464///
465/// `f8e5m2fnuz` is a real, specified layout this schema version declines to
466/// compute on; `asdf` is not a dtype at all. Those are different facts about
467/// the world, and they imply different actions — *wait for a schema bump* vs
468/// *fix your input*. Collapsing them tells a caller "no" without telling it
469/// *which* no.
470///
471/// **The distinction is a VARIANT, not a message.** A differing `Display`
472/// string is prose a caller cannot act on; the obligation is to a caller of
473/// this API, so it has to be matchable.
474///
475/// For what this does and does not discharge — in particular that it is NOT
476/// KISS-CLASSIFY §6.1-0001 conformance, because that clause binds a reader of
477/// a `structure_key` and Fuel has none — and for the scheduled external expiry
478/// this decline carries, see [`RESERVED_DTYPE_TOKENS`].
479#[derive(Debug, PartialEq, Eq)]
480pub enum DTypeParseError {
481    /// Not a §6.1 dtype token at all.
482    Unknown(String),
483    /// A §6.1 token that is **reserved** at this schema version: recognized,
484    /// deliberately not computed on.
485    Reserved(String),
486}
487
488impl DTypeParseError {
489    /// The offending token, whichever rejection this is.
490    pub fn token(&self) -> &str {
491        match self {
492            DTypeParseError::Unknown(t) | DTypeParseError::Reserved(t) => t,
493        }
494    }
495
496    /// Was this a recognized-but-reserved spelling?
497    pub fn is_reserved(&self) -> bool {
498        matches!(self, DTypeParseError::Reserved(_))
499    }
500}
501
502impl std::fmt::Display for DTypeParseError {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        match self {
505            DTypeParseError::Unknown(t) => write!(f, "cannot parse '{t}' as a dtype"),
506            DTypeParseError::Reserved(t) => write!(
507                f,
508                "dtype token '{t}' is RESERVED at this schema version \
509                 (KISS-CLASSIFY §6.1-0001): it names a real layout that is \
510                 byte-incompatible with its OCP counterpart, and is deliberately \
511                 not computed on — this is not an unknown token"
512            ),
513        }
514    }
515}
516
517impl std::error::Error for DTypeParseError {}
518
519impl std::str::FromStr for DType {
520    type Err = DTypeParseError;
521    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
522        match s {
523            "u8" => Ok(Self::U8),
524            "i8" => Ok(Self::I8),
525            "u32" => Ok(Self::U32),
526            "i16" => Ok(Self::I16),
527            "i32" => Ok(Self::I32),
528            "i64" => Ok(Self::I64),
529            "bf16" => Ok(Self::BF16),
530            "f16" => Ok(Self::F16),
531            "f32" => Ok(Self::F32),
532            "f64" => Ok(Self::F64),
533            "f8e4m3" => Ok(Self::F8E4M3),
534            "f8e5m2" => Ok(Self::F8E5M2),
535            "f6e2m3" => Ok(Self::F6E2M3),
536            "f6e3m2" => Ok(Self::F6E3M2),
537            "f4" => Ok(Self::F4),
538            "f8e8m0" => Ok(Self::F8E8M0),
539            "f8e6m2" => Ok(Self::F8E6M2),
540            "bool" => Ok(Self::Bool),
541            // §6.1-0001: a RESERVED spelling is recognized and declined
542            // DISTINCTLY from an unknown one. Deleting this arm makes
543            // `reserved_fnuz_declines_distinctly_from_an_unknown_token` fail —
544            // that is the born-red evidence for this change, and it was
545            // observed (3 passed / 1 failed, `Compiling fuel-ir` in the run).
546            _ if is_reserved_dtype_token(s) => Err(DTypeParseError::Reserved(s.to_string())),
547            _ => Err(DTypeParseError::Unknown(s.to_string())),
548        }
549    }
550}
551
552impl DType {
553    /// Returns the string representation of this dtype.
554    pub fn as_str(&self) -> &'static str {
555        match self {
556            Self::U8 => "u8",
557            Self::I8 => "i8",
558            Self::U32 => "u32",
559            Self::I16 => "i16",
560            Self::I32 => "i32",
561            Self::I64 => "i64",
562            Self::BF16 => "bf16",
563            Self::F16 => "f16",
564            Self::F32 => "f32",
565            Self::F64 => "f64",
566            Self::F8E4M3 => "f8e4m3",
567            Self::F8E5M2 => "f8e5m2",
568            Self::F6E2M3 => "f6e2m3",
569            Self::F6E3M2 => "f6e3m2",
570            Self::F4 => "f4",
571            Self::F8E8M0 => "f8e8m0",
572            Self::F8E6M2 => "f8e6m2",
573            Self::Bool => "bool",
574        }
575    }
576
577    /// Returns the size used by each element in bytes.
578    ///
579    /// Returns 0 for sub-byte types (F6E2M3, F6E3M2, F4).
580    pub fn size_in_bytes(&self) -> usize {
581        match self {
582            Self::U8 => 1,
583            Self::I8 => 1,
584            Self::U32 => 4,
585            Self::I16 => 2,
586            Self::I32 => 4,
587            Self::I64 => 8,
588            Self::BF16 => 2,
589            Self::F16 => 2,
590            Self::F32 => 4,
591            Self::F64 => 8,
592            Self::F8E4M3 => 1,
593            Self::F8E5M2 => 1,
594            Self::F6E2M3 => 0,
595            Self::F6E3M2 => 0,
596            Self::F4 => 0,
597            Self::F8E8M0 => 1,
598            Self::F8E6M2 => 1,
599            // Boolean: one byte per element (storage width equals u8).
600            Self::Bool => 1,
601        }
602    }
603
604    /// Returns `true` if this is an integer type (U8, I8, U32, I16, I32, I64).
605    pub fn is_int(&self) -> bool {
606        match self {
607            Self::U8 | Self::I8 | Self::U32 | Self::I16 | Self::I32 | Self::I64 => true,
608            Self::BF16
609            | Self::F16
610            | Self::F32
611            | Self::F64
612            | Self::F8E4M3
613            | Self::F8E5M2
614            | Self::F6E2M3
615            | Self::F6E3M2
616            | Self::F4
617            | Self::F8E8M0
618            | Self::F8E6M2
619            // Bool is a truth value, not an integer.
620            | Self::Bool => false,
621        }
622    }
623
624    /// Returns `true` if this is a floating-point type.
625    pub fn is_float(&self) -> bool {
626        match self {
627            // Bool is a truth value, neither integer nor float.
628            Self::U8 | Self::I8 | Self::U32 | Self::I16 | Self::I32 | Self::I64 | Self::Bool => {
629                false
630            }
631            Self::BF16
632            | Self::F16
633            | Self::F32
634            | Self::F64
635            | Self::F8E4M3
636            | Self::F8E5M2
637            | Self::F6E2M3
638            | Self::F6E3M2
639            | Self::F4
640            | Self::F8E8M0
641            | Self::F8E6M2 => true,
642        }
643    }
644}
645
646/// Trait for Rust types that can be stored as tensor elements.
647///
648/// This maps Rust native types (e.g., `f32`, `u8`) to their corresponding [`DType`].
649/// It provides conversion routines between Rust values and CPU tensor storage.
650pub trait WithDType:
651    Sized
652    + Copy
653    + num_traits::NumAssign
654    + std::cmp::PartialOrd
655    + std::fmt::Display
656    + 'static
657    + Send
658    + Sync
659    + std::any::Any
660{
661    const DTYPE: DType;
662
663    fn from_f64(v: f64) -> Self;
664    fn to_f64(self) -> f64;
665    fn to_scalar(self) -> crate::scalar::Scalar;
666}
667
668// B0.4 (WithDType weld break, part 1): the host-buffer conversion methods
669// (`cpu_storage_ref`/`to_cpu_storage_owned`/`to_cpu_storage`/
670// `cpu_storage_as_slice`/`cpu_storage_data`) moved off `WithDType` onto the
671// `HostDType: WithDType` extension trait in `cpu_storage.rs` (where `HostBuffer`
672// lives) — this breaks the `dtype <-> cpu_storage` cycle.
673//
674// B0.5 (WithDType weld break, part 2): the `VecOps` supertrait (the `dtype -> cpu/`
675// edge) is GONE — `cpu/` moved to the dedicated `fuel-cpu-kernels` crate, and the
676// ~5 fuel-cpu-backend call sites that need `T::vec_dot`/`T::vec_reduce_*` now carry
677// an explicit `T: fuel_cpu_kernels::VecOps` bound. `WithDType` is now pure vocabulary
678// with no impl edges out of fuel-ir.
679
680macro_rules! with_dtype {
681    ($ty:ty, $dtype:ident, $from_f64:expr, $to_f64:expr) => {
682        impl WithDType for $ty {
683            const DTYPE: DType = DType::$dtype;
684
685            fn from_f64(v: f64) -> Self {
686                $from_f64(v)
687            }
688
689            fn to_f64(self) -> f64 {
690                $to_f64(self)
691            }
692
693            fn to_scalar(self) -> crate::scalar::Scalar {
694                crate::scalar::Scalar::$dtype(self)
695            }
696        }
697    };
698}
699use float8::F8E4M3 as f8e4m3;
700use half::{bf16, f16};
701
702with_dtype!(u8, U8, |v: f64| v as u8, |v: u8| v as f64);
703with_dtype!(i8, I8, |v: f64| v as i8, |v: i8| v as f64);
704with_dtype!(u32, U32, |v: f64| v as u32, |v: u32| v as f64);
705with_dtype!(i16, I16, |v: f64| v as i16, |v: i16| v as f64);
706with_dtype!(i32, I32, |v: f64| v as i32, |v: i32| v as f64);
707with_dtype!(i64, I64, |v: f64| v as i64, |v: i64| v as f64);
708with_dtype!(f16, F16, f16::from_f64, f16::to_f64);
709with_dtype!(bf16, BF16, bf16::from_f64, bf16::to_f64);
710with_dtype!(f32, F32, |v: f64| v as f32, |v: f32| v as f64);
711with_dtype!(f64, F64, |v: f64| v, |v: f64| v);
712with_dtype!(f8e4m3, F8E4M3, f8e4m3::from_f64, |v: f8e4m3| v.to_f64());
713
714/// Trait for integer element types that can be used with tensor indexing and masking.
715///
716/// Implemented for `u8`, `u32`, `i16`, `i32`, and `i64`.
717pub trait IntDType: WithDType + num_traits::Bounded {
718    fn is_true(&self) -> bool;
719    fn as_usize(&self) -> usize;
720}
721
722impl IntDType for i64 {
723    fn is_true(&self) -> bool {
724        *self != 0
725    }
726    fn as_usize(&self) -> usize {
727        *self as usize
728    }
729}
730
731impl IntDType for u32 {
732    fn is_true(&self) -> bool {
733        *self != 0
734    }
735    fn as_usize(&self) -> usize {
736        *self as usize
737    }
738}
739
740impl IntDType for u8 {
741    fn is_true(&self) -> bool {
742        *self != 0
743    }
744    fn as_usize(&self) -> usize {
745        *self as usize
746    }
747}
748
749impl IntDType for i8 {
750    fn is_true(&self) -> bool {
751        *self != 0
752    }
753    fn as_usize(&self) -> usize {
754        *self as usize
755    }
756}
757
758impl IntDType for i16 {
759    fn is_true(&self) -> bool {
760        *self != 0
761    }
762    fn as_usize(&self) -> usize {
763        *self as usize
764    }
765}
766
767impl IntDType for i32 {
768    fn is_true(&self) -> bool {
769        *self != 0
770    }
771    fn as_usize(&self) -> usize {
772        *self as usize
773    }
774}
775
776/// Trait for floating-point element types.
777pub trait FloatDType: WithDType {}
778
779impl FloatDType for f16 {}
780impl FloatDType for bf16 {}
781impl FloatDType for f32 {}
782impl FloatDType for f64 {}
783impl FloatDType for f8e4m3 {}
784
785// Safetensors interop: DType <-> safetensors::Dtype conversions
786use safetensors::tensor as st;
787
788impl From<DType> for st::Dtype {
789    fn from(value: DType) -> Self {
790        match value {
791            DType::U8 => st::Dtype::U8,
792            DType::I8 => st::Dtype::I8,
793            DType::U32 => st::Dtype::U32,
794            DType::I16 => st::Dtype::I16,
795            DType::I32 => st::Dtype::I32,
796            DType::I64 => st::Dtype::I64,
797            DType::BF16 => st::Dtype::BF16,
798            DType::F16 => st::Dtype::F16,
799            DType::F32 => st::Dtype::F32,
800            DType::F64 => st::Dtype::F64,
801            DType::F8E4M3 => st::Dtype::F8_E4M3,
802            DType::F8E5M2 => st::Dtype::F8_E5M2,
803            DType::F6E2M3 => st::Dtype::F6_E2M3,
804            DType::F6E3M2 => st::Dtype::F6_E3M2,
805            DType::F4 => st::Dtype::F4,
806            DType::F8E8M0 => st::Dtype::F8_E8M0,
807            DType::Bool => st::Dtype::BOOL,
808            // safetensors 0.7.0 tracks the OCP MX + standard FP8 set
809            // (E4M3/E5M2/E8M0/F6/F4). F8E6M2 is a non-OCP-standard token with no
810            // safetensors representation, so it cannot be serialized there.
811            DType::F8E6M2 => panic!(
812                "F8E6M2 has no safetensors representation (0.7.0 supports \
813                 E4M3/E5M2/E8M0/F6/F4, not E6M2)"
814            ),
815        }
816    }
817}
818
819impl TryFrom<st::Dtype> for DType {
820    type Error = Error;
821    fn try_from(value: st::Dtype) -> Result<Self> {
822        match value {
823            st::Dtype::U8 => Ok(DType::U8),
824            st::Dtype::I8 => Ok(DType::I8),
825            st::Dtype::U32 => Ok(DType::U32),
826            st::Dtype::I16 => Ok(DType::I16),
827            st::Dtype::I32 => Ok(DType::I32),
828            st::Dtype::I64 => Ok(DType::I64),
829            st::Dtype::BF16 => Ok(DType::BF16),
830            st::Dtype::F16 => Ok(DType::F16),
831            st::Dtype::F32 => Ok(DType::F32),
832            st::Dtype::F64 => Ok(DType::F64),
833            st::Dtype::F8_E4M3 => Ok(DType::F8E4M3),
834            st::Dtype::F8_E5M2 => Ok(DType::F8E5M2),
835            st::Dtype::F6_E2M3 => Ok(DType::F6E2M3),
836            st::Dtype::F6_E3M2 => Ok(DType::F6E3M2),
837            st::Dtype::F4 => Ok(DType::F4),
838            st::Dtype::F8_E8M0 => Ok(DType::F8E8M0),
839            st::Dtype::BOOL => Ok(DType::Bool),
840            dtype => Err(Error::UnsupportedSafeTensorDtype(dtype)),
841        }
842    }
843}