Skip to main content

fuel_dispatch/fkc/
lower.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Lowering: parsed FKC schema (strings) → typed dispatch records
3//! (adoption plan §2, §2.1 / §2.2 mapping tables, §2.3 cost AST, §6 caps).
4//!
5//! This slice converts a parsed [`FkcKernel`] into a [`ResolvedPrimitive`]
6//! or [`ResolvedFused`] — typed records ready for the NEXT slice (the
7//! trampoline / global cost-table / `register_into`). It stops at typed
8//! records + a **parsed** cost AST; it does NOT build a `CostFn`
9//! fn-pointer.
10//!
11//! Every string → typed conversion is an **explicit, exhaustive `match`**
12//! (NOT `FromStr`-by-discriminant) so that adding a new source variant
13//! (a new `OpKind`, a new `DType`) forces a compile error here to extend
14//! the table — the table cannot silently drift behind the type. The one
15//! exception is `fused_op`, whose name table is **generated** from
16//! `fuel-graph`'s `default_registry()` so it cannot drift from the graph's
17//! single source of truth.
18
19use fuel_graph::registry::{FusedOpId, FusedOps};
20use fuel_ir::DType;
21use fuel_ir::dispatch::OpKind;
22use fuel_ir::probe::BackendId;
23use smallvec::SmallVec;
24
25use crate::fkc::caps_map::{self, ResolvedLayout};
26use crate::fkc::cost_expr::{self, CompiledCostExpr};
27use crate::fkc::error::FkcError;
28use crate::fkc::precision;
29use crate::fkc::revhash;
30use crate::fkc::schema::{CostBlock, FkcKernel};
31use crate::fused::{KernelRevisionHash, PrecisionGuarantee};
32use crate::kernel::{CostFn, KernelCaps, KernelDTypes, KernelRef};
33
34// ===========================================================================
35// LinkRegistry — entry_point symbol → KernelRef (P9, §12.6)
36// ===========================================================================
37
38/// Resolve a contract's `entry_point` symbol string into a concrete
39/// [`KernelRef`] function pointer. Each provider crate implements this
40/// over its exported `&'static [(&str, KernelRef)]` table (FKC §12.6); a
41/// test stub maps every symbol to a dummy kernel.
42///
43/// The importer never fabricates a function pointer — it looks the symbol
44/// up and errors ([`FkcError::UnknownEntryPoint`]) if absent. This keeps
45/// P9 (serializable contracts, no pointers in the file) honest.
46pub trait LinkRegistry {
47    /// Resolve a primitive (`op_kind`) kernel's entry point.
48    fn resolve_primitive(&self, symbol: &str) -> Option<KernelRef>;
49    /// Resolve a fused (`fused_op`) kernel's entry point.
50    fn resolve_fused(&self, symbol: &str) -> Option<KernelRef>;
51
52    /// Resolve a contract's `cost.cost_fn` NAME into a concrete [`CostFn`]
53    /// pointer — the cost-model analogue of [`Self::resolve_primitive`] (§4.4
54    /// cost-fn trampoline, Task-F). A provider that pins real cost fns exports
55    /// its own `&'static [(&str, CostFn)]` table and overrides this over it
56    /// (exactly as it exports its `entry_point` table); the built-in default
57    /// is `None` (no cost-fn table), so every existing `LinkRegistry` impl and
58    /// test stub compiles unchanged and a section that names no cost fn is
59    /// unaffected. The importer never fabricates a pointer: an unresolved name
60    /// is a typed [`FkcError::UnknownCostFn`], never a silent `unknown_cost`
61    /// fallback (P9 — the resolved cost model is a real registered fn, not a
62    /// serialized pointer).
63    fn resolve_cost_fn(&self, _name: &str) -> Option<CostFn> {
64        None
65    }
66}
67
68// ===========================================================================
69// Resolved records
70// ===========================================================================
71
72/// A fully-lowered primitive (`op_kind`) contract — typed dispatch record
73/// ready for the register slice. The cost is held as a parsed AST
74/// ([`CompiledCostExpr`]), NOT a `CostFn` fn-pointer (the trampoline is the
75/// next slice).
76#[derive(Debug, Clone)]
77pub struct ResolvedPrimitive {
78    /// The dispatch op (key.0).
79    pub op: OpKind,
80    /// Per-operand dtypes, inputs in order then outputs (key.1).
81    pub dtypes: KernelDTypes,
82    /// The backend (key.2).
83    pub backend: BackendId,
84    /// Projected layout capabilities (§6) — one bool today, every parsed
85    /// flag retained on `layouts`.
86    pub caps: KernelCaps,
87    /// Per-operand parsed five-flag layout sets (retained for forward use;
88    /// §6 [consumer-ahead]).
89    pub layouts: Vec<ResolvedLayout>,
90    /// Lowered precision guarantee.
91    pub precision: PrecisionGuarantee,
92    /// Parsed cost AST (capacity-eval next slice; `Unknown` ⇒ `unknown_cost`).
93    pub cost: CompiledCostExpr,
94    /// A CONTRACT-PINNED [`CostFn`] resolved from `cost.cost_fn` through the
95    /// `LinkRegistry` (§4.4 cost-fn trampoline, Task-F). `Some(fn)` ⇒ the
96    /// register slice stamps THIS `CostFn` on the binding, which SURVIVES the
97    /// `fill_unset_cost_for_backend` pass (that pass only replaces the
98    /// `unknown_cost` sentinel); `None` ⇒ the imported `unknown_cost` sentinel
99    /// (the fill pass upgrades it to the op/backend default). This is how the
100    /// CUDA `flash_decoding` contract keeps its static infeasibility-gate cost.
101    pub cost_fn: Option<CostFn>,
102    /// The resolved kernel function pointer.
103    pub kernel: KernelRef,
104    /// The `kernel_source` tag (`BindingEntry.kernel_source`).
105    pub kernel_source: String,
106    /// The kernel revision hash (§4.7).
107    pub revision: KernelRevisionHash,
108    /// OPAQUE specialization-variant tag (contract `variant:`), retained
109    /// verbatim from [`crate::fkc::schema::FkcKernel::variant`] so it survives
110    /// to where records / `ImplId` construction (the emission step) will read
111    /// it. Never parsed or validated here; the entry point remains the true
112    /// kernel identity.
113    pub variant: Option<String>,
114}
115
116/// A fully-lowered fused (`fused_op`) contract — analogous to
117/// [`ResolvedPrimitive`] but targeting the `FusedKernelRegistry`
118/// (`FusedOpId` instead of `OpKind`; the fused cost target).
119#[derive(Debug, Clone)]
120pub struct ResolvedFused {
121    /// The fused op id (registry key, from `default_registry()`).
122    pub id: FusedOpId,
123    /// Per-operand dtypes, inputs in order then outputs.
124    pub dtypes: KernelDTypes,
125    /// The backend (registry key).
126    pub backend: BackendId,
127    /// Projected layout capabilities (§6).
128    pub caps: KernelCaps,
129    /// Per-operand parsed five-flag layout sets (retained; §6).
130    pub layouts: Vec<ResolvedLayout>,
131    /// Lowered precision guarantee.
132    pub precision: PrecisionGuarantee,
133    /// Parsed cost AST (the fused cost target; `Unknown` ⇒ `unknown_cost`).
134    pub cost: CompiledCostExpr,
135    /// The resolved kernel function pointer.
136    pub kernel: KernelRef,
137    /// The `kernel_source` tag.
138    pub kernel_source: String,
139    /// The kernel revision hash (§4.7; `BackendImpl.revision`).
140    pub revision: KernelRevisionHash,
141    /// OPAQUE specialization-variant tag (contract `variant:`), retained
142    /// verbatim from [`crate::fkc::schema::FkcKernel::variant`] (see
143    /// [`ResolvedPrimitive::variant`]).
144    pub variant: Option<String>,
145    /// §5.5 `return.bundle` slot names, in declared order (empty for a
146    /// single-output section). Populated by
147    /// `crate::fkc::return_check::bundle_slot_names` (Finding 5.4, Task
148    /// 3.6); `register_into` (`register.rs`) threads non-empty values into
149    /// `FusedKernelRegistry::record_bundle_slot_names`.
150    pub bundle_slot_names: Vec<String>,
151}
152
153/// The result of lowering one kernel section: a primitive xor a fused
154/// record (exactly one of `op_kind` / `fused_op`).
155#[derive(Debug, Clone)]
156pub enum Resolved {
157    /// An `op_kind` contract → the binding table.
158    Primitive(ResolvedPrimitive),
159    /// A `fused_op` contract → the fused registry.
160    Fused(ResolvedFused),
161}
162
163// ===========================================================================
164// op_kind String → OpKind (explicit exhaustive match; §2.1)
165// ===========================================================================
166
167/// Map an `op_kind` string to an [`OpKind`]. The `match` is exhaustive
168/// over `OpKind` so adding a new variant forces this table to grow
169/// (a compile error, not a silent miss). `UnknownOpKind` on a bad string.
170pub(crate) fn lower_op_kind(s: &str, section: &str) -> Result<OpKind, FkcError> {
171    // Helper so the match arms read as `name => Variant`. We round-trip
172    // through a known-exhaustive coverage check below.
173    let mapped = match s {
174        "MatMul" => Some(OpKind::MatMul),
175        "AddElementwise" => Some(OpKind::AddElementwise),
176        "SubElementwise" => Some(OpKind::SubElementwise),
177        "MulElementwise" => Some(OpKind::MulElementwise),
178        "DivElementwise" => Some(OpKind::DivElementwise),
179        "ReluElementwise" => Some(OpKind::ReluElementwise),
180        "NegElementwise" => Some(OpKind::NegElementwise),
181        "SqrElementwise" => Some(OpKind::SqrElementwise),
182        "SqrtElementwise" => Some(OpKind::SqrtElementwise),
183        "RecipElementwise" => Some(OpKind::RecipElementwise),
184        "AbsElementwise" => Some(OpKind::AbsElementwise),
185        "TanhElementwise" => Some(OpKind::TanhElementwise),
186        "ExpElementwise" => Some(OpKind::ExpElementwise),
187        "LogElementwise" => Some(OpKind::LogElementwise),
188        "SinElementwise" => Some(OpKind::SinElementwise),
189        "CosElementwise" => Some(OpKind::CosElementwise),
190        "SigmoidElementwise" => Some(OpKind::SigmoidElementwise),
191        "SiluElementwise" => Some(OpKind::SiluElementwise),
192        "GeluElementwise" => Some(OpKind::GeluTanhElementwise),
193        "StepElementwise" => Some(OpKind::StepElementwise),
194        "SumReduce" => Some(OpKind::SumReduce),
195        "MaxReduce" => Some(OpKind::MaxReduce),
196        "MinReduce" => Some(OpKind::MinReduce),
197        "MeanReduce" => Some(OpKind::MeanReduce),
198        "Cast" => Some(OpKind::Cast),
199        "Conv2D" => Some(OpKind::Conv2D),
200        "ConvTranspose2D" => Some(OpKind::ConvTranspose2D),
201        "ReduceSumTo" => Some(OpKind::ReduceSumTo),
202        "ReduceMaxTo" => Some(OpKind::ReduceMaxTo),
203        "FusedLinear" => Some(OpKind::FusedLinear),
204        "FlashAttn" => Some(OpKind::FlashAttn),
205        "FlashAttnBackwardQ" => Some(OpKind::FlashAttnBackwardQ),
206        "FlashAttnBackwardK" => Some(OpKind::FlashAttnBackwardK),
207        "FlashAttnBackwardV" => Some(OpKind::FlashAttnBackwardV),
208        "PagedAttn" => Some(OpKind::PagedAttn),
209        "Affine" => Some(OpKind::Affine),
210        "ClampElementwise" => Some(OpKind::ClampElementwise),
211        "PowIElementwise" => Some(OpKind::PowIElementwise),
212        "PowIElementwiseBackward" => Some(OpKind::PowIElementwiseBackward),
213        "MaximumElementwise" => Some(OpKind::MaximumElementwise),
214        "MinimumElementwise" => Some(OpKind::MinimumElementwise),
215        "EqualElementwise" => Some(OpKind::EqualElementwise),
216        "NotEqualElementwise" => Some(OpKind::NotEqualElementwise),
217        "LessElementwise" => Some(OpKind::LessElementwise),
218        "LessEqualElementwise" => Some(OpKind::LessEqualElementwise),
219        "GreaterElementwise" => Some(OpKind::GreaterElementwise),
220        "GreaterEqualElementwise" => Some(OpKind::GreaterEqualElementwise),
221        "Where" => Some(OpKind::Where),
222        "FloorElementwise" => Some(OpKind::FloorElementwise),
223        "CeilElementwise" => Some(OpKind::CeilElementwise),
224        "RoundElementwise" => Some(OpKind::RoundElementwise),
225        "SignElementwise" => Some(OpKind::SignElementwise),
226        "ErfElementwise" => Some(OpKind::ErfElementwise),
227        "GeluErfElementwise" => Some(OpKind::GeluElementwise),
228        "PowElementwise" => Some(OpKind::PowElementwise),
229        "RsqrtElementwise" => Some(OpKind::RsqrtElementwise),
230        "RemElementwise" => Some(OpKind::RemElementwise),
231        "Flip" => Some(OpKind::Flip),
232        "Roll" => Some(OpKind::Roll),
233        "CumSum" => Some(OpKind::CumSum),
234        "Pad" => Some(OpKind::Pad),
235        "Triu" => Some(OpKind::Triu),
236        "Tril" => Some(OpKind::Tril),
237        "LogSoftmaxLastDim" => Some(OpKind::LogSoftmaxLastDim),
238        "LogSoftmaxLastDimBackward" => Some(OpKind::LogSoftmaxLastDimBackward),
239        "MaskedFill" => Some(OpKind::MaskedFill),
240        "PadBackward" => Some(OpKind::PadBackward),
241        "Concat" => Some(OpKind::Concat),
242        "SoftmaxLastDim" => Some(OpKind::SoftmaxLastDim),
243        "SoftmaxLastDimBackward" => Some(OpKind::SoftmaxLastDimBackward),
244        "RmsNormLastDim" => Some(OpKind::RmsNormLastDim),
245        "RmsNormLastDimBackward" => Some(OpKind::RmsNormLastDimBackward),
246        "LayerNormLastDim" => Some(OpKind::LayerNormLastDim),
247        "LayerNormLastDimBackward" => Some(OpKind::LayerNormLastDimBackward),
248        "ReduceMaxToBackward" => Some(OpKind::ReduceMaxToBackward),
249        "IndexSelect" => Some(OpKind::IndexSelect),
250        "Gather" => Some(OpKind::Gather),
251        "Rope" => Some(OpKind::Rope),
252        "IndexAdd" => Some(OpKind::IndexAdd),
253        "ScatterAdd" => Some(OpKind::ScatterAdd),
254        "ArgMaxDim" => Some(OpKind::ArgMaxDim),
255        "ArgMinDim" => Some(OpKind::ArgMinDim),
256        "QMatMul" => Some(OpKind::QMatMul),
257        "WriteSlice" => Some(OpKind::WriteSlice),
258        "WriteSliceRotating" => Some(OpKind::WriteSliceRotating),
259        "WriteSliceDoff" => Some(OpKind::WriteSliceDoff),
260        "Copy" => Some(OpKind::Copy),
261        "ReluInplace" => Some(OpKind::ReluInplace),
262        "SiluInplace" => Some(OpKind::SiluInplace),
263        "GeluInplace" => Some(OpKind::GeluTanhInplace),
264        "TanhInplace" => Some(OpKind::TanhInplace),
265        "SigmoidInplace" => Some(OpKind::SigmoidInplace),
266        "NegInplace" => Some(OpKind::NegInplace),
267        "AbsInplace" => Some(OpKind::AbsInplace),
268        "SqrInplace" => Some(OpKind::SqrInplace),
269        "SqrtInplace" => Some(OpKind::SqrtInplace),
270        "RsqrtInplace" => Some(OpKind::RsqrtInplace),
271        "RecipInplace" => Some(OpKind::RecipInplace),
272        "ExpInplace" => Some(OpKind::ExpInplace),
273        "LogInplace" => Some(OpKind::LogInplace),
274        "SinInplace" => Some(OpKind::SinInplace),
275        "CosInplace" => Some(OpKind::CosInplace),
276        "SignInplace" => Some(OpKind::SignInplace),
277        "FloorInplace" => Some(OpKind::FloorInplace),
278        "CeilInplace" => Some(OpKind::CeilInplace),
279        "RoundInplace" => Some(OpKind::RoundInplace),
280        "ErfInplace" => Some(OpKind::ErfInplace),
281        "GeluErfInplace" => Some(OpKind::GeluInplace),
282        "ClampInplace" => Some(OpKind::ClampInplace),
283        "PowIInplace" => Some(OpKind::PowIInplace),
284        "InplaceAffine" => Some(OpKind::InplaceAffine),
285        "FusedSoftmaxCrossEntropy" => Some(OpKind::FusedSoftmaxCrossEntropy),
286        "CausalConv1d" => Some(OpKind::CausalConv1d),
287        "SelectiveScan" => Some(OpKind::SelectiveScan),
288        "SsdChunkScan" => Some(OpKind::SsdChunkScan),
289        "Nf4Matmul" => Some(OpKind::Nf4Matmul),
290        _ => None,
291    };
292    // NOTE: `OpKind` is `#[non_exhaustive]` in `fuel-core-types`, so a
293    // wildcard-free exhaustiveness anchor is not possible across the crate
294    // boundary. The string table above is still explicit + audited (each
295    // `OpKind` variant has its own arm); a new upstream variant simply
296    // won't be reachable until a token is added here (an `UnknownOpKind`
297    // at runtime, not a compile error — the non_exhaustive contract).
298    match mapped {
299        Some(op) => Ok(op),
300        None => Err(FkcError::UnknownOpKind {
301            section: section.to_string(),
302            op_kind: s.to_string(),
303        }),
304    }
305}
306
307// ===========================================================================
308// fused_op String → FusedOpId (SCREAMING_SNAKE FusedOps constant table; §2.2)
309// ===========================================================================
310
311/// Map a `fused_op` token to its [`FusedOpId`].
312///
313/// **The spec/contracts use the `FusedOps` CONSTANT NAME** — SCREAMING_SNAKE,
314/// e.g. `SOFTMAX_LAST_DIM` / `FLASH_ATTN` / `QMATMUL` (FKC §3.1 token format,
315/// §3.7). This is NOT the registry entry's `name` field, which is PascalCase
316/// (`"SoftmaxLastDim"`). The earlier `default_registry().id_for_name(...)`
317/// resolver matched the PascalCase `name` and therefore always *missed* on a
318/// real contract token.
319///
320/// The mapping is an explicit `match` from each SCREAMING_SNAKE constant name
321/// to its `FusedOps::<NAME>` `FusedOpId` (`UnknownFusedOp` on a miss). It is
322/// kept honest by the [`tests::every_registry_id_is_reachable_through_table`]
323/// drift-guard: every `FusedOpId` present in `default_registry()` must be
324/// reachable through this table, so adding a new `FusedOps` const without a
325/// table entry fails that test.
326pub(crate) fn lower_fused_op(s: &str, section: &str) -> Result<FusedOpId, FkcError> {
327    fused_op_id_for_const_name(s).ok_or_else(|| FkcError::UnknownFusedOp {
328        section: section.to_string(),
329        fused_op: s.to_string(),
330    })
331}
332
333/// The SCREAMING_SNAKE `FusedOps::*` constant-name → [`FusedOpId`] table.
334/// Returns `None` for an unknown token. One arm per `FusedOps` associated
335/// const (`fuel-graph/src/registry.rs`); the drift-guard test asserts this
336/// covers every registered id.
337pub(crate) fn fused_op_id_for_const_name(s: &str) -> Option<FusedOpId> {
338    let id = match s {
339        "SOFTMAX_LAST_DIM" => FusedOps::SOFTMAX_LAST_DIM,
340        "FUSED_LINEAR" => FusedOps::FUSED_LINEAR,
341        "RMS_NORM_LAST_DIM" => FusedOps::RMS_NORM_LAST_DIM,
342        "LAYER_NORM_LAST_DIM" => FusedOps::LAYER_NORM_LAST_DIM,
343        "ROPE" => FusedOps::ROPE,
344        "CONV2D" => FusedOps::CONV2D,
345        "SOFTMAX_LAST_DIM_BACKWARD" => FusedOps::SOFTMAX_LAST_DIM_BACKWARD,
346        "LAYER_NORM_LAST_DIM_BACKWARD" => FusedOps::LAYER_NORM_LAST_DIM_BACKWARD,
347        "RMS_NORM_LAST_DIM_BACKWARD" => FusedOps::RMS_NORM_LAST_DIM_BACKWARD,
348        "REDUCE_MAX_TO_BACKWARD" => FusedOps::REDUCE_MAX_TO_BACKWARD,
349        "CONV_TRANSPOSE2D" => FusedOps::CONV_TRANSPOSE2D,
350        "FLASH_ATTN" => FusedOps::FLASH_ATTN,
351        "PAGED_ATTN" => FusedOps::PAGED_ATTN,
352        "QMATMUL" => FusedOps::QMATMUL,
353        "POWI_BACKWARD" => FusedOps::POWI_BACKWARD,
354        "INPLACE_AFFINE" => FusedOps::INPLACE_AFFINE,
355        "FUSED_SOFTMAX_CROSS_ENTROPY" => FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY,
356        "CAUSAL_CONV1D" => FusedOps::CAUSAL_CONV1D,
357        "SELECTIVE_SCAN" => FusedOps::SELECTIVE_SCAN,
358        "SSD_CHUNK_SCAN" => FusedOps::SSD_CHUNK_SCAN,
359        "NF4_MATMUL" => FusedOps::NF4_MATMUL,
360        "FLASH_ATTN_BACKWARD_Q" => FusedOps::FLASH_ATTN_BACKWARD_Q,
361        "FLASH_ATTN_BACKWARD_K" => FusedOps::FLASH_ATTN_BACKWARD_K,
362        "FLASH_ATTN_BACKWARD_V" => FusedOps::FLASH_ATTN_BACKWARD_V,
363        "AWQ_MATMUL" => FusedOps::AWQ_MATMUL,
364        _ => return None,
365    };
366    Some(id)
367}
368
369// ===========================================================================
370// dtype token / dtype_class String → DType (explicit match; §3.4)
371// ===========================================================================
372
373/// Map a single dtype token to a [`DType`]. Explicit exhaustive `match`
374/// (FDX codes are a different axis; this is NOT `FromStr`-by-discriminant).
375///
376/// Rejection is **two-way**: `ReservedScalarType` for a token KISS-CLASSIFY
377/// §6.1 reserves at this schema version, `BadScalarType` for anything else.
378/// Both are typed errors — what differs is which fact the contract author is
379/// told, and therefore what they should do about it (§6.1-0001).
380pub(crate) fn lower_dtype(token: &str, section: &str, operand: &str) -> Result<DType, FkcError> {
381    let mapped = match token {
382        "U8" => Some(DType::U8),
383        "I8" => Some(DType::I8),
384        "U32" => Some(DType::U32),
385        "I16" => Some(DType::I16),
386        "I32" => Some(DType::I32),
387        "I64" => Some(DType::I64),
388        "BF16" => Some(DType::BF16),
389        "F16" => Some(DType::F16),
390        "F32" => Some(DType::F32),
391        "F64" => Some(DType::F64),
392        "F8E4M3" => Some(DType::F8E4M3),
393        "F6E2M3" => Some(DType::F6E2M3),
394        "F6E3M2" => Some(DType::F6E3M2),
395        "F4" => Some(DType::F4),
396        "F8E8M0" => Some(DType::F8E8M0),
397        "F8E5M2" => Some(DType::F8E5M2),
398        "F8E6M2" => Some(DType::F8E6M2),
399        "BOOL" => Some(DType::Bool),
400        _ => None,
401    };
402    if let Some(dt) = mapped {
403        // Exhaustiveness anchor (no wildcard): a new DType variant breaks
404        // this match, forcing the token table above to grow.
405        match dt {
406            DType::U8
407            | DType::I8
408            | DType::U32
409            | DType::I16
410            | DType::I32
411            | DType::I64
412            | DType::BF16
413            | DType::F16
414            | DType::F32
415            | DType::F64
416            | DType::F8E4M3
417            | DType::F8E5M2
418            | DType::F6E2M3
419            | DType::F6E3M2
420            | DType::F4
421            | DType::F8E8M0
422            | DType::F8E6M2
423            | DType::Bool => (),
424        };
425        Ok(dt)
426    } else if fuel_ir::is_reserved_dtype_token(token) {
427        // Recognized-but-reserved is a DIFFERENT rejection from unknown, and a
428        // contract author must be able to tell them apart. (This is §6.1's
429        // reserved vocabulary applied to an ingest surface Fuel really has —
430        // NOT §6.1-0001 conformance, which binds a reader of a structure_key;
431        // Fuel has no such reader. See `fuel_ir::RESERVED_DTYPE_TOKENS`.)
432        //
433        // Order matches KISS's own reference impl: reserved is checked BEFORE
434        // unknown, because the reserved set is a SUBSET of the vocabulary and
435        // an unknown-first check would swallow it.
436        //
437        // The list lives in `fuel-ir` and is CONSULTED, not copied. Two
438        // hand-maintained lists is the drift pattern this repo keeps
439        // re-finding: the copy that stops being edited silently reverts to
440        // treating reserved tokens as unknown, and nothing fails.
441        //
442        // This does not contradict the module's "explicit table, never
443        // `FromStr`-by-discriminant" rule. The accept table above is still the
444        // exhaustive, compile-anchored `match`; what is shared here is the
445        // RESERVED-token list — a fact about the standard rather than about
446        // Fuel's as-built `DType` set, so it has no exhaustiveness anchor to
447        // lose, and every reserved token is by definition absent from `DType`.
448        //
449        // Order is load-bearing: this arm is only reached because the accept
450        // arms are exact literals and cannot capture a reserved token. That is
451        // asserted rather than assumed (see the tests below) — `F8E5M2` is a
452        // PREFIX of `F8E5M2FNUZ`, so any `starts_with`/`contains` classifier
453        // here would read the reserved token as the supported one and compute
454        // on `fnuz` bytes as if they were OCP: silent numerical corruption,
455        // not a loud failure.
456        Err(FkcError::ReservedScalarType {
457            section: section.to_string(),
458            operand: operand.to_string(),
459            token: token.to_string(),
460        })
461    } else {
462        Err(FkcError::BadScalarType {
463            section: section.to_string(),
464            operand: operand.to_string(),
465            token: token.to_string(),
466        })
467    }
468}
469
470/// Expand a `dtype_class` shorthand (§3.4) into its dtype list. `any`
471/// resolves to the operand's enumerated `dtypes` (passed in `enumerated`).
472///
473/// `pub(crate)`: also the single source of truth for
474/// `shape_constraint::first_probe_dtype`'s dtype-class fallback pick (its
475/// first element), so the §3.4 class -> dtype-list mapping is defined once.
476pub(crate) fn expand_dtype_class(
477    class: &str,
478    enumerated: &[DType],
479    section: &str,
480    operand: &str,
481) -> Result<Vec<DType>, FkcError> {
482    match class {
483        "float" => Ok(vec![DType::BF16, DType::F16, DType::F32, DType::F64]),
484        "int" => Ok(vec![DType::I8, DType::I16, DType::I32, DType::I64]),
485        "uint" => Ok(vec![DType::U8, DType::U32]),
486        "any" => Ok(enumerated.to_vec()),
487        other => Err(FkcError::BadScalarType {
488            section: section.to_string(),
489            operand: operand.to_string(),
490            token: format!("dtype_class={other}"),
491        }),
492    }
493}
494
495// ===========================================================================
496// backend String → BackendId (explicit match; §2.1)
497// ===========================================================================
498
499/// Map a `backend` string to a [`BackendId`]. Explicit exhaustive match.
500fn lower_backend(s: &str, section: &str) -> Result<BackendId, FkcError> {
501    let mapped = match s {
502        "Cpu" => Some(BackendId::Cpu),
503        "Cuda" => Some(BackendId::Cuda),
504        "Vulkan" => Some(BackendId::Vulkan),
505        "Metal" => Some(BackendId::Metal),
506        _ => None,
507    };
508    // `BackendId` is `#[non_exhaustive]`; same note as `lower_op_kind` —
509    // the explicit string table is the audit surface, not a compile gate.
510    match mapped {
511        Some(b) => Ok(b),
512        None => Err(FkcError::UnknownBackend {
513            section: section.to_string(),
514            backend: s.to_string(),
515        }),
516    }
517}
518
519// ===========================================================================
520// Per-operand dtype assembly → per-variant KernelDTypes (multi-dtype fan-out)
521// ===========================================================================
522
523/// One fanned variant of a section: its full binding key (`[in1, .., out]`)
524/// plus the fan dtype that produced it.
525#[derive(Debug, Clone)]
526pub(crate) struct DtypeVariant {
527    /// The binding-table key: input operand dtypes in order, then outputs.
528    pub(crate) dtypes: KernelDTypes,
529    /// The dtype the varying operands took for this variant. `Some(dt)` when
530    /// the section fans out (drives the `<entry_point>_<suffix>` resolution);
531    /// `None` for the single all-fixed variant (entry_point resolved as-is).
532    pub(crate) fan_dtype: Option<DType>,
533}
534
535/// The canonical `DType → FKC dtype suffix` spelling used to build a fanning
536/// section's per-variant `entry_point` symbol (`<base>_<suffix>`), e.g.
537/// `F32 → "f32"`, `BF16 → "bf16"`, `U8 → "u8"`, `F8E4M3 → "f8e4m3"`.
538///
539/// This is the **inverse** of [`lower_dtype`] and is deliberately the SAME
540/// spelling the byte-kernel `ep!` macro (`fkc/cpu_link.rs`) and the CPU
541/// backend's per-(op,dtype) thunks use — it reuses [`DType::as_str`] rather
542/// than hand-rolling a second table (the [`tests::dtype_suffix_is_the_inverse_of_lower_dtype`]
543/// drift-guard locks the round-trip).
544fn dtype_suffix(dt: DType) -> &'static str {
545    dt.as_str()
546}
547
548/// A resolved input operand: its role name + its enumerated dtype list
549/// (post `dtype_class` expansion; always ≥1) + whether it is OPTIONAL (§3.4).
550struct InputOperand {
551    name: String,
552    dtypes: Vec<DType>,
553    /// `optional: true` (§3.4): production registers the op BOTH with and
554    /// without this operand. Only the LAST input may be optional.
555    optional: bool,
556}
557
558/// Resolve one operand's enumerated dtype list (explicit `dtypes:` wins; a
559/// `dtype_class` shorthand only fills an empty explicit list; §3.4). Errors
560/// [`FkcError::BadScalarType`] on a bad token or an empty result.
561fn resolve_operand_dtypes(
562    d: &crate::fkc::schema::TensorDesc,
563    section: &str,
564    operand: &str,
565) -> Result<Vec<DType>, FkcError> {
566    let mut enumerated: Vec<DType> = Vec::new();
567    for tok in &d.dtypes {
568        enumerated.push(lower_dtype(tok, section, operand)?);
569    }
570    let resolved = if enumerated.is_empty() {
571        if let Some(class) = &d.dtype_class {
572            expand_dtype_class(class, &enumerated, section, operand)?
573        } else {
574            enumerated
575        }
576    } else {
577        // Explicit list present ⇒ it is the enumeration (a `dtype_class`, if
578        // also present, is descriptive only — the explicit list wins, §3.4).
579        enumerated
580    };
581    if resolved.is_empty() {
582        return Err(FkcError::BadScalarType {
583            section: section.to_string(),
584            operand: operand.to_string(),
585            token: "<no dtypes and no dtype_class>".to_string(),
586        });
587    }
588    Ok(resolved)
589}
590
591/// Assemble the per-variant binding-key dtype-lists for a section (§3.4
592/// **multi-dtype fan-out**) plus the shared per-operand layouts.
593///
594/// A section whose operands are all single-dtype (or a `dtype_class` that
595/// expands to exactly one) yields exactly ONE variant with `fan_dtype: None`
596/// — today's behavior, and what keeps the per-(op,dtype) binary / affine /
597/// cast families byte-identical. A section whose operand(s) **vary**
598/// (enumerate >1 dtype, or a `dtype_class` that expands to >1) fans out into
599/// N variants — one per fanned dtype.
600///
601/// Fan rules:
602/// - The fan-out dtype set is the enumerated list of the operand(s) that
603///   vary. ALL varying operands must enumerate the SAME list in the SAME
604///   order; a disagreement is [`FkcError::FanoutDtypeMismatch`] (never a
605///   silent pick).
606/// - Per fanned dtype `dt`, each INPUT operand contributes its dtype at this
607///   variant — a FIXED (single-enumerated) operand its one dtype (e.g.
608///   `where`'s `cond` = U8), a VARYING operand `dt`. Then OUTPUTS:
609///   `fixed(D)` → D; `passthrough(role)` → the dtype of the INPUT operand
610///   named `role` **at this variant** (so `where`'s `passthrough(a)` mirrors
611///   `a` = `dt`, NOT the first input `cond`). Key shape is inputs-then-outputs.
612///
613/// **Optional-operand fan-out (§3.4).** When the LAST input carries
614/// `optional: true` (e.g. conv's `bias`), EACH dtype variant fans into TWO
615/// keys — one OMITTING the optional operand (its dtype dropped from the input
616/// tail) and one INCLUDING it — so `variants = (dtype fan-out) × (optional
617/// {absent, present})`. Both keys resolve the SAME `entry_point`/kernel (the
618/// optional operand rides through op-params, not a distinct symbol). Rules:
619/// - Only the LAST input may be optional; an earlier optional operand would
620///   leave a hole mid-key and is [`FkcError::OptionalOperandNotLast`] (never a
621///   silent mis-key).
622/// - An output may NOT `passthrough(role)` the optional operand — absent, the
623///   two keys' output dtypes would disagree — that is
624///   [`FkcError::PassthroughNamesOptionalOperand`]. `passthrough` of any other
625///   (always-present) operand resolves identically for both keys.
626/// - A section with NO optional operand behaves EXACTLY as before (one key per
627///   dtype variant) — the already-migrated per-(op,dtype) families stay
628///   byte-identical.
629///
630/// The PRESENT (full-operand) key is emitted FIRST so the fused path's
631/// representative (first) variant is unchanged (`lower_fused` takes `.next()`).
632fn assemble_dtype_variants(
633    kernel: &FkcKernel,
634    section: &str,
635) -> Result<(Vec<DtypeVariant>, Vec<ResolvedLayout>), FkcError> {
636    let mut inputs: Vec<InputOperand> = Vec::new();
637    let mut layouts: Vec<ResolvedLayout> = Vec::new();
638
639    if let Some(accept) = &kernel.accept {
640        for d in &accept.inputs {
641            let operand = d.name.as_deref().unwrap_or("<input>");
642            let resolved = resolve_operand_dtypes(d, section, operand)?;
643            inputs.push(InputOperand {
644                name: operand.to_string(),
645                dtypes: resolved,
646                optional: d.optional,
647            });
648            layouts.push(caps_map::resolve_layout(
649                d.layout.as_ref(),
650                section,
651                operand,
652            )?);
653        }
654    }
655
656    // §3.4 optional-operand support: at most the LAST input may be `optional`.
657    // An earlier optional operand, when omitted, would leave a hole in the
658    // MIDDLE of the key and mis-align every following operand — a typed error,
659    // never a silent mis-key. `optional_last` drives the {absent, present} fan.
660    let optional_last = inputs.last().map(|op| op.optional).unwrap_or(false);
661    for (i, op) in inputs.iter().enumerate() {
662        if op.optional && i != inputs.len() - 1 {
663            return Err(FkcError::OptionalOperandNotLast {
664                section: section.to_string(),
665                operand: op.name.clone(),
666            });
667        }
668    }
669
670    // The fan-out dtype set = the enumerated list of the operand(s) that vary
671    // (enumerate >1). All varying operands must agree on the SAME list/order.
672    let mut fan_set: Option<&[DType]> = None;
673    for operand in &inputs {
674        if operand.dtypes.len() > 1 {
675            match fan_set {
676                None => fan_set = Some(&operand.dtypes),
677                Some(existing) => {
678                    if existing != operand.dtypes.as_slice() {
679                        return Err(FkcError::FanoutDtypeMismatch {
680                            section: section.to_string(),
681                            operand: operand.name.clone(),
682                            expected: format!("{existing:?}"),
683                            found: format!("{:?}", operand.dtypes),
684                        });
685                    }
686                }
687            }
688        }
689    }
690
691    // The list of fan dtypes: one all-fixed variant (None) when nothing
692    // varies, else one variant per fanned dtype.
693    let fan_dtypes: Vec<Option<DType>> = match fan_set {
694        None => vec![None],
695        Some(set) => set.iter().map(|dt| Some(*dt)).collect(),
696    };
697
698    // Capacity: one key per dtype variant, doubled when an optional operand
699    // fans each into {absent, present}.
700    let per_variant = if optional_last { 2 } else { 1 };
701    let mut variants: Vec<DtypeVariant> = Vec::with_capacity(fan_dtypes.len() * per_variant);
702    for fan in fan_dtypes {
703        // Each input operand contributes its dtype at THIS variant: a fixed
704        // operand its single dtype, a varying operand the fan dtype. Built with
705        // ALL inputs (incl. the optional last); the absent-key drops its tail.
706        let mut input_dtypes: KernelDTypes = SmallVec::new();
707        for operand in &inputs {
708            let dt = if operand.dtypes.len() > 1 {
709                // Varying: fan_dtype is Some by construction here.
710                fan.expect("a varying operand implies a fanned dtype")
711            } else {
712                operand.dtypes[0]
713            };
714            input_dtypes.push(dt);
715        }
716
717        // Outputs: `fixed(D)` → D; `passthrough(role)` → the role operand's
718        // dtype at this variant. Built ONCE and shared by BOTH the present and
719        // absent keys — an output may not `passthrough` the optional operand
720        // (checked below), so the output tail is identical for both. Best-
721        // effort otherwise: an output we cannot type (a passthrough naming no
722        // known input, no inputs at all) is omitted from the key tail rather
723        // than failing (full return validation is a separate slice).
724        let mut output_dtypes: KernelDTypes = SmallVec::new();
725        if let Some(ret) = &kernel.return_ {
726            for out in &ret.outputs {
727                let operand = out.name.as_deref().unwrap_or("<output>");
728                if let Some(rule) = out.dtype_rule.as_deref()
729                    && let Some(dt) = resolve_output_slot_dtype(
730                        rule,
731                        operand,
732                        &inputs,
733                        fan,
734                        optional_last,
735                        section,
736                    )?
737                {
738                    output_dtypes.push(dt);
739                }
740            }
741            // §5.5 multi-output bundle (Option C): a `return.bundle` packs
742            // several logical slots into ONE output buffer whose PRIMARY
743            // (first) slot's dtype tags the binding key — the multi-output
744            // contract on [`crate::kernel::KernelRef`] states the key
745            // "describes inputs + the bundle's primary dtype only". Derive that
746            // one slot dtype through the SAME `dtype_rule`/passthrough path as a
747            // regular output and append it to the key tail — so a 5-input scan
748            // with a `passthrough(u)` bundle keys `[T; 6]`, byte-for-byte the
749            // deleted hand-written reg. A section with NO bundle is unaffected
750            // (the migrated per-(op,dtype) families stay byte-identical).
751            if let Some(bundle) = &ret.bundle
752                && let Some((slot, rule)) = bundle_primary_dtype_rule(bundle)
753                && let Some(dt) =
754                    resolve_output_slot_dtype(&rule, &slot, &inputs, fan, optional_last, section)?
755            {
756                output_dtypes.push(dt);
757            }
758        }
759
760        if optional_last {
761            // PRESENT (full-operand) key FIRST so `lower_fused`'s representative
762            // (first) variant is unchanged: all inputs (incl. optional), outputs.
763            let mut present: KernelDTypes = input_dtypes.clone();
764            present.extend_from_slice(&output_dtypes);
765            variants.push(DtypeVariant {
766                dtypes: present,
767                fan_dtype: fan,
768            });
769            // ABSENT key: inputs MINUS the optional last, then outputs. Both
770            // resolve the SAME entry_point/kernel (same `fan_dtype`).
771            let mut absent: KernelDTypes = SmallVec::new();
772            absent.extend_from_slice(&input_dtypes[..input_dtypes.len() - 1]);
773            absent.extend_from_slice(&output_dtypes);
774            variants.push(DtypeVariant {
775                dtypes: absent,
776                fan_dtype: fan,
777            });
778        } else {
779            let mut dtypes: KernelDTypes = input_dtypes;
780            dtypes.extend_from_slice(&output_dtypes);
781            variants.push(DtypeVariant {
782                dtypes,
783                fan_dtype: fan,
784            });
785        }
786    }
787
788    Ok((variants, layouts))
789}
790
791/// Resolve `passthrough(role)` to the dtype of the input operand named
792/// `role` at this variant: a varying operand takes `fan`, a fixed operand
793/// its single dtype. Falls back to the first input's variant dtype when the
794/// role names no input (best-effort — the prior behavior for an untyped
795/// passthrough), and `None` when there are no inputs at all.
796fn passthrough_dtype(inputs: &[InputOperand], role: &str, fan: Option<DType>) -> Option<DType> {
797    let operand_dtype = |op: &InputOperand| -> DType {
798        if op.dtypes.len() > 1 {
799            fan.expect("a varying operand implies a fanned dtype")
800        } else {
801            op.dtypes[0]
802        }
803    };
804    if let Some(op) = inputs.iter().find(|op| op.name == role) {
805        Some(operand_dtype(op))
806    } else {
807        inputs.first().map(operand_dtype)
808    }
809}
810
811/// Resolve ONE output slot's key dtype from its `dtype_rule` at this variant —
812/// the single path shared by a regular `return.outputs` entry and a
813/// `return.bundle`'s primary slot (§5.5). `fixed(D)` → `D`;
814/// `passthrough(role)` → the role operand's dtype at this variant (best-effort
815/// untyped fallback per [`passthrough_dtype`]); an unrecognized rule → `None`
816/// (best-effort — the full return validation is a separate slice). A
817/// `passthrough` naming the OPTIONAL last operand is the typed
818/// [`FkcError::PassthroughNamesOptionalOperand`] (its dtype would disagree
819/// between the operand's present/absent keys, §3.4/§5.1).
820fn resolve_output_slot_dtype(
821    rule: &str,
822    operand: &str,
823    inputs: &[InputOperand],
824    fan: Option<DType>,
825    optional_last: bool,
826    section: &str,
827) -> Result<Option<DType>, FkcError> {
828    match parse_dtype_rule(rule, section, operand)? {
829        DtypeRule::Fixed(dt) => Ok(Some(dt)),
830        DtypeRule::Passthrough(role) => {
831            if optional_last && inputs.last().map(|o| o.name == role).unwrap_or(false) {
832                return Err(FkcError::PassthroughNamesOptionalOperand {
833                    section: section.to_string(),
834                    role,
835                });
836            }
837            Ok(passthrough_dtype(inputs, &role, fan))
838        }
839        DtypeRule::Other => Ok(None),
840    }
841}
842
843/// Extract the PRIMARY (first) slot's `(name, dtype_rule)` from a
844/// `return.bundle` value (§5.5 Option C multi-output). The bundle is carried
845/// opaquely in the schema — a `serde_yaml_ng::Value` sequence of
846/// `{ name, dtype_rule, shape_rule, … }` slot maps (its rank/name validation is
847/// a separate slice) — but the binding KEY only needs the first slot's
848/// `dtype_rule` string, which is then fed through the regular
849/// [`resolve_output_slot_dtype`] machinery exactly like a `return.outputs`
850/// entry. Returns `None` when the bundle is not a non-empty sequence of maps or
851/// the first slot declares no `dtype_rule` (best-effort — a malformed bundle is
852/// a validation concern, never a key that silently mirrors the wrong operand).
853fn bundle_primary_dtype_rule(bundle: &serde_yaml_ng::Value) -> Option<(String, String)> {
854    let serde_yaml_ng::Value::Sequence(slots) = bundle else {
855        return None;
856    };
857    let serde_yaml_ng::Value::Mapping(first) = slots.first()? else {
858        return None;
859    };
860    let rule = first
861        .get(serde_yaml_ng::Value::String("dtype_rule".into()))
862        .and_then(|v| v.as_str())?
863        .to_string();
864    let name = first
865        .get(serde_yaml_ng::Value::String("name".into()))
866        .and_then(|v| v.as_str())
867        .unwrap_or("<bundle>")
868        .to_string();
869    Some((name, rule))
870}
871
872/// A parsed `dtype_rule` (§5.1): `fixed(DType)`, `passthrough(role)`, or an
873/// unrecognized rule (typed later).
874enum DtypeRule {
875    Fixed(DType),
876    Passthrough(String),
877    Other,
878}
879
880/// Parse a `dtype_rule` string into a [`DtypeRule`]. `BadScalarType` only if
881/// `fixed(...)` names a bad dtype; `passthrough(role)` captures the role name.
882fn parse_dtype_rule(rule: &str, section: &str, operand: &str) -> Result<DtypeRule, FkcError> {
883    let rule = rule.trim();
884    if let Some(inner) = rule
885        .strip_prefix("fixed(")
886        .and_then(|s| s.strip_suffix(")"))
887    {
888        Ok(DtypeRule::Fixed(lower_dtype(
889            inner.trim(),
890            section,
891            operand,
892        )?))
893    } else if let Some(inner) = rule
894        .strip_prefix("passthrough(")
895        .and_then(|s| s.strip_suffix(")"))
896    {
897        Ok(DtypeRule::Passthrough(inner.trim().to_string()))
898    } else {
899        Ok(DtypeRule::Other)
900    }
901}
902
903// ===========================================================================
904// Cost block → CompiledCostExpr (primary: flops; §2.3 AST half)
905// ===========================================================================
906
907/// Compile a contract's cost block into a [`CompiledCostExpr`].
908///
909/// Strategy: this slice carries the AST. A cost block that is absent,
910/// `class`-only, or `judge_measured` with no coefficient expressions
911/// compiles to [`CompiledCostExpr::Unknown`] (the register slice maps
912/// that to `unknown_cost`). When expressions ARE present, the **`flops`**
913/// expression is compiled as the primary cost AST (the per-tier `memory`
914/// beyond device_bytes and the other coefficient fields are parsed for
915/// validation but the primary held expression is `flops`; a full
916/// multi-field cost vector is a register-slice concern).
917///
918/// Every present cost expression is parse-validated (V-FKC-8); a malformed
919/// one surfaces as [`FkcError::CostExprParse`] with the field name.
920fn compile_cost(block: Option<&CostBlock>, section: &str) -> Result<CompiledCostExpr, FkcError> {
921    let Some(cost) = block else {
922        return Ok(CompiledCostExpr::Unknown);
923    };
924
925    // Parse-validate every present coefficient expression (so a malformed
926    // bytes_moved is caught even though flops is the primary held AST).
927    let parse = |field: &str, src: Option<&str>| -> Result<CompiledCostExpr, FkcError> {
928        cost_expr::compile_field(src).map_err(|e| FkcError::CostExprParse {
929            section: section.to_string(),
930            field: field.to_string(),
931            expr: src.unwrap_or("").to_string(),
932            reason: e.to_string(),
933        })
934    };
935
936    let flops = parse("flops", cost.flops.as_deref())?;
937    let _bytes_moved = parse("bytes_moved", cost.bytes_moved.as_deref())?;
938    // overhead_ns / memory.device_bytes are raw scalars (number or `~`);
939    // when they are an expression STRING they are parse-validated too.
940    if let Some(mem) = &cost.memory
941        && let Some(serde_yaml_ng::Value::String(s)) = &mem.device_bytes
942    {
943        let _ = parse("memory.device_bytes", Some(s))?;
944    }
945    if let Some(serde_yaml_ng::Value::String(s)) = &cost.overhead_ns {
946        let _ = parse("overhead_ns", Some(s))?;
947    }
948
949    // The primary held AST is flops. If flops is Unknown but bytes_moved
950    // carries an expression, hold bytes_moved instead (so a cost block with
951    // only a bytes_moved formula is not collapsed to Unknown).
952    match flops {
953        CompiledCostExpr::Unknown => Ok(_bytes_moved),
954        expr => Ok(expr),
955    }
956}
957
958// ===========================================================================
959// The lowering entry points
960// ===========================================================================
961
962/// Resolve the effective `backend` / `kernel_source` / `entry_point` for a
963/// kernel, applying the front-matter fallbacks the caller passes in.
964struct Defaults<'a> {
965    backend: &'a str,
966    kernel_source: &'a str,
967    revision_base: &'a str,
968}
969
970/// Lower one parsed [`FkcKernel`] into its [`Resolved`] records, resolving
971/// each per-variant `entry_point` through `link`. Exactly one of `op_kind` /
972/// `fused_op` must be present.
973///
974/// A primitive (`op_kind`) section **fans out** into one [`Resolved`] per
975/// dtype variant (§3.4 multi-dtype fan-out); a section with no varying
976/// operand yields exactly one. A fused (`fused_op`) section fans out the SAME
977/// way — one [`Resolved::Fused`] per dtype variant (see [`lower_fused`]).
978///
979/// `defaults` carries the front-matter `backend` / `kernel_source` /
980/// `revision_base` so a kernel that omits them inherits the provider's.
981fn lower_kernel(
982    kernel: &FkcKernel,
983    defaults: &Defaults<'_>,
984    link: &dyn LinkRegistry,
985    warnings: &mut Vec<crate::fkc::ImportWarning>,
986) -> Result<Vec<Resolved>, FkcError> {
987    let section = kernel.kernel.as_str();
988
989    // Exactly one of op_kind / fused_op.
990    match (kernel.op_kind.as_deref(), kernel.fused_op.as_deref()) {
991        (Some(op_str), None) => {
992            let op = lower_op_kind(op_str, section)?;
993            // Phase 1 has no primitive-side warnings producer yet.
994            Ok(lower_primitive(kernel, op, defaults, link)?
995                .into_iter()
996                .map(Resolved::Primitive)
997                .collect())
998        }
999        (None, Some(fused_str)) => {
1000            let id = lower_fused_op(fused_str, section)?;
1001            Ok(lower_fused(kernel, id, defaults, link, warnings)?
1002                .into_iter()
1003                .map(Resolved::Fused)
1004                .collect())
1005        }
1006        (op, fused) => Err(FkcError::OpTargetAmbiguous {
1007            section: section.to_string(),
1008            op_kind: op.map(String::from),
1009            fused_op: fused.map(String::from),
1010        }),
1011    }
1012}
1013
1014/// Lower a primitive (`op_kind`) section into ONE [`ResolvedPrimitive`] per
1015/// dtype variant (§3.4 multi-dtype fan-out).
1016///
1017/// A section whose operand(s) vary fans out into N bindings — one per fanned
1018/// dtype, with the per-variant binding key rebuilt from
1019/// [`assemble_dtype_variants`]. Its declared `entry_point` is a **BASE**
1020/// symbol; each variant resolves `<base>_<dtype_suffix>` via `link`. A
1021/// non-fanning (single-variant) section keeps its specific `entry_point` and
1022/// resolves it AS-IS.
1023///
1024/// Everything except the per-variant `dtypes` + resolved `kernel` is
1025/// per-section (shared): backend, caps, layouts, precision, cost, and the
1026/// revision hash (which folds the declared BASE `entry_point`, so it is one
1027/// value for the whole section).
1028fn lower_primitive(
1029    kernel: &FkcKernel,
1030    op: OpKind,
1031    defaults: &Defaults<'_>,
1032    link: &dyn LinkRegistry,
1033) -> Result<Vec<ResolvedPrimitive>, FkcError> {
1034    let section = kernel.kernel.as_str();
1035    let backend_str = kernel.backend.as_deref().unwrap_or(defaults.backend);
1036    let backend = lower_backend(backend_str, section)?;
1037    let (variants, layouts) = assemble_dtype_variants(kernel, section)?;
1038    let caps = caps_map::project_kernel_caps(&layouts);
1039    let precision = precision::lower_precision(kernel.precision.as_ref(), section)?;
1040    let cost = compile_cost(kernel.cost.as_ref(), section)?;
1041    // §4.4 cost-fn trampoline (Task-F): a `cost.cost_fn` NAME pins a real,
1042    // shape-aware `CostFn` (per-section, shared by every dtype variant). Resolve
1043    // it ONCE through the LinkRegistry; an unresolved name is a typed error, not
1044    // a silent `unknown_cost` fallback. `None` ⇒ the register slice keeps the
1045    // imported `unknown_cost` sentinel (fill_unset then upgrades it).
1046    let cost_fn = match kernel.cost.as_ref().and_then(|c| c.cost_fn.as_deref()) {
1047        Some(name) => Some(
1048            link.resolve_cost_fn(name)
1049                .ok_or_else(|| FkcError::UnknownCostFn {
1050                    section: section.to_string(),
1051                    cost_fn: name.to_string(),
1052                })?,
1053        ),
1054        None => None,
1055    };
1056
1057    let base_entry_point =
1058        kernel
1059            .entry_point
1060            .as_deref()
1061            .ok_or_else(|| FkcError::MissingRequiredField {
1062                section: section.to_string(),
1063                field: "entry_point".to_string(),
1064            })?;
1065
1066    let kernel_source = kernel
1067        .kernel_source
1068        .as_deref()
1069        .unwrap_or(defaults.kernel_source)
1070        .to_string();
1071    // Per-section revision (same for all variants); folds the BASE entry_point
1072    // so it is byte-identical to the pre-fan-out single-binding revision.
1073    let revision = revhash::compute_revision(kernel, base_entry_point, defaults.revision_base)?;
1074
1075    let mut out = Vec::with_capacity(variants.len());
1076    for variant in variants {
1077        // Per-variant entry_point: a fanning section's declared entry_point is
1078        // a BASE symbol → resolve `<base>_<suffix>`; a non-fanning section
1079        // keeps its specific symbol and resolves AS-IS.
1080        let symbol: std::borrow::Cow<'_, str> = match variant.fan_dtype {
1081            Some(dt) => std::borrow::Cow::Owned(format!("{base_entry_point}_{}", dtype_suffix(dt))),
1082            None => std::borrow::Cow::Borrowed(base_entry_point),
1083        };
1084        let kernel_ref =
1085            link.resolve_primitive(&symbol)
1086                .ok_or_else(|| FkcError::UnknownEntryPoint {
1087                    section: section.to_string(),
1088                    entry_point: symbol.into_owned(),
1089                })?;
1090
1091        out.push(ResolvedPrimitive {
1092            op,
1093            dtypes: variant.dtypes,
1094            backend,
1095            caps,
1096            layouts: layouts.clone(),
1097            precision,
1098            cost: cost.clone(),
1099            cost_fn,
1100            kernel: kernel_ref,
1101            kernel_source: kernel_source.clone(),
1102            revision,
1103            // Retain the opaque `variant:` tag verbatim (per-section, shared by
1104            // every dtype variant) so it survives to the emission step.
1105            variant: kernel.variant.clone(),
1106        });
1107    }
1108    Ok(out)
1109}
1110
1111/// Lower a fused (`fused_op`) section into ONE [`ResolvedFused`] per dtype
1112/// variant (§3.4 multi-dtype fan-out — the fused analogue of
1113/// [`lower_primitive`]).
1114///
1115/// A section whose operand(s) vary fans out into N fused impls — one per fanned
1116/// dtype, with the per-variant binding key rebuilt from
1117/// [`assemble_dtype_variants`] (so a multi-dtype `SOFTMAX_LAST_DIM` section
1118/// registers a per-dtype impl for each of `[F32, F64, BF16, F16]`, 1:1 with the
1119/// hand-written `register_default_fused_kernels` seam it replaces). Its declared
1120/// `entry_point` is a **BASE** symbol; each variant resolves
1121/// `<base>_<dtype_suffix>` via `link` ([`crate::fkc::cpu_link`]'s
1122/// `CPU_FUSED_NORM_ENTRY_POINTS` maps those per-dtype symbols to the per-dtype
1123/// wrappers). A non-fanning (single-variant) section keeps its specific
1124/// `entry_point` and resolves it AS-IS, yielding exactly one record —
1125/// byte-identical to the pre-fan-out behavior.
1126///
1127/// Everything except the per-variant `dtypes` + resolved `kernel` is
1128/// per-section (shared): backend, caps, layouts, precision, cost, and the
1129/// revision hash (which folds the declared BASE `entry_point`, so it is one
1130/// value for the whole section — the primitive precedent).
1131fn lower_fused(
1132    kernel: &FkcKernel,
1133    id: FusedOpId,
1134    defaults: &Defaults<'_>,
1135    link: &dyn LinkRegistry,
1136    warnings: &mut Vec<crate::fkc::ImportWarning>,
1137) -> Result<Vec<ResolvedFused>, FkcError> {
1138    // §5 (Finding 5.1): cross-check the section's declared return rules
1139    // against the REAL registered FusedOpEntry fn BEFORE anything else lowers
1140    // — a disagreement fails the import (never silently drifts from the
1141    // graph's single source of truth). See `return_check::cross_check_fused_section`
1142    // for the never-panic guard + soft-catch-solver-errors invariants.
1143    crate::fkc::return_check::cross_check_fused_section(kernel, id, warnings)?;
1144    let section = kernel.kernel.as_str();
1145    let backend_str = kernel.backend.as_deref().unwrap_or(defaults.backend);
1146    let backend = lower_backend(backend_str, section)?;
1147    let (variants, layouts) = assemble_dtype_variants(kernel, section)?;
1148    let caps = caps_map::project_kernel_caps(&layouts);
1149    let precision = precision::lower_precision(kernel.precision.as_ref(), section)?;
1150    let cost = compile_cost(kernel.cost.as_ref(), section)?;
1151
1152    let base_entry_point =
1153        kernel
1154            .entry_point
1155            .as_deref()
1156            .ok_or_else(|| FkcError::MissingRequiredField {
1157                section: section.to_string(),
1158                field: "entry_point".to_string(),
1159            })?;
1160
1161    let kernel_source = kernel
1162        .kernel_source
1163        .as_deref()
1164        .unwrap_or(defaults.kernel_source)
1165        .to_string();
1166    // Per-section revision (same for all variants); folds the BASE entry_point
1167    // so it is byte-identical to the pre-fan-out single-binding revision.
1168    let revision = revhash::compute_revision(kernel, base_entry_point, defaults.revision_base)?;
1169
1170    let mut out = Vec::with_capacity(variants.len());
1171    for variant in variants {
1172        // Per-variant entry_point: a fanning section's declared entry_point is
1173        // a BASE symbol → resolve `<base>_<suffix>`; a non-fanning section
1174        // keeps its specific symbol and resolves AS-IS.
1175        let symbol: std::borrow::Cow<'_, str> = match variant.fan_dtype {
1176            Some(dt) => std::borrow::Cow::Owned(format!("{base_entry_point}_{}", dtype_suffix(dt))),
1177            None => std::borrow::Cow::Borrowed(base_entry_point),
1178        };
1179        let kernel_ref =
1180            link.resolve_fused(&symbol)
1181                .ok_or_else(|| FkcError::UnknownEntryPoint {
1182                    section: section.to_string(),
1183                    entry_point: symbol.into_owned(),
1184                })?;
1185
1186        out.push(ResolvedFused {
1187            id,
1188            dtypes: variant.dtypes,
1189            backend,
1190            caps,
1191            layouts: layouts.clone(),
1192            precision,
1193            cost: cost.clone(),
1194            kernel: kernel_ref,
1195            kernel_source: kernel_source.clone(),
1196            revision,
1197            // Retain the opaque `variant:` tag verbatim (per-section, shared by
1198            // every dtype variant) so it survives to the emission step.
1199            variant: kernel.variant.clone(),
1200            // Finding 5.4 (Task 3.6): the real §5.5 bundle-slot-name
1201            // extraction. Per-section (same for every dtype variant), so
1202            // this recomputes per variant — cheap (a handful of YAML-slot
1203            // reads), and keeps this call site a single self-contained
1204            // expression rather than hoisting a shared local above the loop.
1205            bundle_slot_names: crate::fkc::return_check::bundle_slot_names(&kernel.return_),
1206        });
1207    }
1208    Ok(out)
1209}
1210
1211/// Lower every kernel section of a parsed file into [`Resolved`] records,
1212/// using the file's front-matter for the backend / kernel_source /
1213/// revision_base defaults and resolving entry points through `link`.
1214///
1215/// **Describe-only sections (`registrable: false`, §3.10) are excluded** from
1216/// the lowered set: they are documentation, carry no dispatch target, and are
1217/// never registered — so they never become a [`Resolved`] record, never
1218/// resolve an `entry_point`, and never reach the binding table / fused
1219/// registry (§9.3, §12.5).
1220pub fn lower_file(
1221    file: &crate::fkc::schema::FkcFile,
1222    link: &dyn LinkRegistry,
1223    warnings: &mut Vec<crate::fkc::ImportWarning>,
1224) -> Result<Vec<Resolved>, FkcError> {
1225    let provider = &file.front_matter.provider;
1226    let defaults = Defaults {
1227        backend: provider.backend.as_str(),
1228        kernel_source: provider.kernel_source.as_str(),
1229        revision_base: provider.revision_base.as_deref().unwrap_or(""),
1230    };
1231    // Each registrable section lowers to ONE-OR-MORE Resolved records (a
1232    // primitive section fans out over its dtype variants, §3.4); flatten them
1233    // into the provider's flat list.
1234    let mut out = Vec::new();
1235    for kernel in file.kernels.iter().filter(|k| k.registrable) {
1236        // §3.10: describe-only documentation sections are skipped above.
1237        out.extend(lower_kernel(kernel, &defaults, link, warnings)?);
1238    }
1239    Ok(out)
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244    use super::*;
1245    use crate::fkc::parse_file;
1246    use std::sync::{Arc, RwLock};
1247
1248    const ELEMENTWISE_BINARY: &str =
1249        include_str!("../../kernel-contracts/cpu/elementwise-binary.fkc.md");
1250    const QUANT_MATMUL: &str = include_str!("../../kernel-contracts/cpu/quant-matmul.fkc.md");
1251    /// A real FUSED contract bundle whose `fused_op: SOFTMAX_LAST_DIM` etc.
1252    /// must now resolve through the SCREAMING_SNAKE constant table (Task 1).
1253    const FUSED_NORM_SOFTMAX: &str =
1254        include_str!("../../kernel-contracts/fused/norm-softmax.fkc.md");
1255
1256    // ---- KISS-CLASSIFY §6.1 reserved vocabulary: reserved != unknown ----
1257
1258    /// At the FKC parse site — a real ingest surface, since contract files are
1259    /// external and hand-authored — a reserved spelling declines **matchably
1260    /// differently** from an unknown one. Deleting the `ReservedScalarType`
1261    /// arm in `lower_dtype` makes this fail; that is the born-red evidence for
1262    /// this half of GAP-155.
1263    #[test]
1264    fn a_reserved_dtype_token_declines_distinctly_from_an_unknown_one() {
1265        let reserved = lower_dtype("F8E4M3FNUZ", "sec", "lhs").expect_err("must not lower");
1266        assert!(
1267            matches!(reserved, FkcError::ReservedScalarType { .. }),
1268            "a §6.1-reserved token must decline AS reserved, not as a bad token \
1269             — a contract author told 'bad dtype token' goes hunting for a typo \
1270             that does not exist; got {reserved:?}",
1271        );
1272
1273        // The other side. Without this, collapsing EVERYTHING into
1274        // ReservedScalarType would pass the assertion above.
1275        let unknown = lower_dtype("NOT_A_DTYPE", "sec", "lhs").expect_err("must not lower");
1276        assert!(
1277            matches!(unknown, FkcError::BadScalarType { .. }),
1278            "an unknown token must stay BadScalarType; got {unknown:?}",
1279        );
1280    }
1281
1282    /// Binds this site to `fuel-ir`'s list rather than to a copy of it. If
1283    /// someone adds a token to `RESERVED_DTYPE_TOKENS` and this site keeps a
1284    /// private list, the new token silently reverts to "unknown" here — a
1285    /// drift that no other test would notice.
1286    #[test]
1287    fn every_shared_reserved_token_declines_as_reserved_at_this_site() {
1288        for tok in fuel_ir::RESERVED_DTYPE_TOKENS {
1289            // FKC spells dtype tokens uppercase; the shared list is canonical
1290            // lowercase. That mismatch is exactly why the lookup is
1291            // case-insensitive, so exercise the FKC spelling here.
1292            let upper = tok.to_ascii_uppercase();
1293            let err = lower_dtype(&upper, "sec", "lhs")
1294                .expect_err("a reserved token must never lower to a DType");
1295            assert!(
1296                matches!(err, FkcError::ReservedScalarType { .. }),
1297                "{upper} is in RESERVED_DTYPE_TOKENS but declined as {err:?} — this \
1298                 site has drifted from the shared list",
1299            );
1300        }
1301        // Non-vacuity: an empty list would pass the loop above silently.
1302        assert!(!fuel_ir::RESERVED_DTYPE_TOKENS.is_empty());
1303    }
1304
1305    /// The delimiter trap. Every supported FP8 token is a PREFIX of a reserved
1306    /// one (`F8E5M2` / `F8E5M2FNUZ`), so a `starts_with`-style classifier would
1307    /// accept the reserved token AS the supported one. That failure is silent:
1308    /// `fnuz` uses a different exponent bias, so the same bytes decode to
1309    /// different numbers and the kernel returns wrong answers rather than an
1310    /// error.
1311    #[test]
1312    fn a_reserved_token_is_not_accepted_as_the_token_it_extends() {
1313        assert!(
1314            !matches!(lower_dtype("F8E5M2FNUZ", "sec", "lhs"), Ok(DType::F8E5M2)),
1315            "F8E5M2FNUZ must not lower to its OCP prefix F8E5M2",
1316        );
1317        assert!(
1318            !matches!(lower_dtype("F8E4M3FNUZ", "sec", "lhs"), Ok(DType::F8E4M3)),
1319            "F8E4M3FNUZ must not lower to its OCP prefix F8E4M3",
1320        );
1321        // Non-vacuity, both ways: the prefixes really are accepted tokens here
1322        // (so the pairs above are genuine traps), and the reserved forms really
1323        // do extend them.
1324        assert_eq!(lower_dtype("F8E5M2", "sec", "lhs").unwrap(), DType::F8E5M2);
1325        assert_eq!(lower_dtype("F8E4M3", "sec", "lhs").unwrap(), DType::F8E4M3);
1326        assert!(
1327            fuel_ir::RESERVED_DTYPE_TOKENS
1328                .iter()
1329                .any(|t| t.eq_ignore_ascii_case("F8E5M2FNUZ")),
1330        );
1331    }
1332
1333    // ---- A test LinkRegistry stub mapping every entry_point to a dummy ----
1334
1335    fn dummy_kernel(
1336        _inputs: &[Arc<RwLock<fuel_memory::Storage>>],
1337        _outputs: &mut [Arc<RwLock<fuel_memory::Storage>>],
1338        _layouts: &[fuel_ir::Layout],
1339        _params: &crate::kernel::OpParams,
1340    ) -> fuel_ir::Result<()> {
1341        Ok(())
1342    }
1343
1344    struct StubLink;
1345    impl LinkRegistry for StubLink {
1346        fn resolve_primitive(&self, _symbol: &str) -> Option<KernelRef> {
1347            Some(dummy_kernel)
1348        }
1349        fn resolve_fused(&self, _symbol: &str) -> Option<KernelRef> {
1350            Some(dummy_kernel)
1351        }
1352    }
1353
1354    /// A stub that resolves nothing (for the UnknownEntryPoint negative).
1355    struct EmptyLink;
1356    impl LinkRegistry for EmptyLink {
1357        fn resolve_primitive(&self, _symbol: &str) -> Option<KernelRef> {
1358            None
1359        }
1360        fn resolve_fused(&self, _symbol: &str) -> Option<KernelRef> {
1361            None
1362        }
1363    }
1364
1365    /// Lower every dtype variant of one section (a primitive fans out; a
1366    /// fused section yields one).
1367    fn lower_all(src: &str, kernel_name: &str) -> Result<Vec<Resolved>, FkcError> {
1368        let file = parse_file(src).expect("parses");
1369        let provider = &file.front_matter.provider;
1370        let defaults = Defaults {
1371            backend: provider.backend.as_str(),
1372            kernel_source: provider.kernel_source.as_str(),
1373            revision_base: provider.revision_base.as_deref().unwrap_or(""),
1374        };
1375        let kernel = file
1376            .kernels
1377            .iter()
1378            .find(|k| k.kernel == kernel_name)
1379            .unwrap_or_else(|| panic!("kernel {kernel_name} present"));
1380        lower_kernel(kernel, &defaults, &StubLink, &mut Vec::new())
1381    }
1382
1383    /// The first (representative) variant of a section — convenience for the
1384    /// single-variant positive tests.
1385    fn lower_one(src: &str, kernel_name: &str) -> Result<Resolved, FkcError> {
1386        Ok(lower_all(src, kernel_name)?
1387            .into_iter()
1388            .next()
1389            .expect("a section lowers to ≥1 variant"))
1390    }
1391
1392    // =====================================================================
1393    // POSITIVE: real contracts lower
1394    // =====================================================================
1395
1396    #[test]
1397    fn lowers_real_add_f32() {
1398        let r = lower_one(ELEMENTWISE_BINARY, "add_f32").expect("add_f32 lowers");
1399        let Resolved::Primitive(p) = r else {
1400            panic!("add_f32 is a primitive");
1401        };
1402        assert_eq!(p.op, OpKind::AddElementwise);
1403        // inputs lhs, rhs (F32, F32) then output (passthrough → F32).
1404        assert_eq!(p.dtypes.as_slice(), &[DType::F32, DType::F32, DType::F32]);
1405        assert_eq!(p.backend, BackendId::Cpu);
1406        // contiguous-only contract ⇒ strided_input false.
1407        assert!(!p.caps.strided_input);
1408        // precision mapped (bit-stable, ulp 0).
1409        assert!(p.precision.bit_stable_on_same_hardware);
1410        assert_eq!(p.precision.max_ulp, Some(0));
1411        // a non-null KernelRef.
1412        assert_eq!(
1413            p.kernel as *const () as usize,
1414            dummy_kernel as *const () as usize
1415        );
1416        // cost: flops = "n" parsed (not Unknown).
1417        assert!(matches!(p.cost, CompiledCostExpr::Expr(_)));
1418        assert_eq!(p.kernel_source, "portable-cpu");
1419    }
1420
1421    #[test]
1422    fn lowers_binary_chassis_section_fans_out_over_all_dtypes() {
1423        // The umbrella `binary` section has two operands both enumerating
1424        // [F32,F64,BF16,F16] (a UNIFORM multi-dtype section), so it FANS OUT
1425        // into one binding per dtype (§3.4) — not a single first-dtype
1426        // representative. `passthrough(lhs)` mirrors lhs at each variant.
1427        let all = lower_all(ELEMENTWISE_BINARY, "binary").expect("binary lowers");
1428        assert_eq!(all.len(), 4, "4 dtypes ⇒ 4 fanned variants");
1429        let keys: Vec<Vec<DType>> = all
1430            .iter()
1431            .map(|r| {
1432                let Resolved::Primitive(p) = r else {
1433                    panic!("binary is a primitive")
1434                };
1435                assert_eq!(p.op, OpKind::AddElementwise);
1436                p.dtypes.to_vec()
1437            })
1438            .collect();
1439        assert_eq!(
1440            keys,
1441            vec![
1442                vec![DType::F32, DType::F32, DType::F32],
1443                vec![DType::F64, DType::F64, DType::F64],
1444                vec![DType::BF16, DType::BF16, DType::BF16],
1445                vec![DType::F16, DType::F16, DType::F16],
1446            ],
1447            "each variant keys on [lhs, rhs, out(passthrough lhs)] at that dtype"
1448        );
1449    }
1450
1451    #[test]
1452    fn lowers_real_qmatmul_q4_0() {
1453        let r = lower_one(QUANT_MATMUL, "qmatmul_q4_0_f32").expect("q4_0 lowers");
1454        let Resolved::Primitive(p) = r else {
1455            panic!("QMatMul is a primitive")
1456        };
1457        assert_eq!(p.op, OpKind::QMatMul);
1458        // activations F32, weight U8, output fixed(F32).
1459        assert_eq!(p.dtypes.as_slice(), &[DType::F32, DType::U8, DType::F32]);
1460        assert_eq!(p.backend, BackendId::Cpu);
1461        // judge_measured with all-`~` coefficients ⇒ Unknown cost sentinel.
1462        assert_eq!(p.cost, CompiledCostExpr::Unknown);
1463        assert!(p.precision.bit_stable_on_same_hardware);
1464    }
1465
1466    #[test]
1467    fn lowers_real_nf4_matmul_f32() {
1468        // NF4 reaches the dedicated OpKind::Nf4Matmul path → it LOWERS in
1469        // this slice (the AFFINE_BLOCK / MxNotYetRegistrable gate is a
1470        // register-slice concern; lowering produces a typed record).
1471        let r = lower_one(QUANT_MATMUL, "nf4_matmul_f32").expect("nf4 lowers");
1472        let Resolved::Primitive(p) = r else { panic!() };
1473        assert_eq!(p.op, OpKind::Nf4Matmul);
1474        // activations F32, w_packed U8, absmax F32, output passthrough → F32.
1475        assert_eq!(
1476            p.dtypes.as_slice(),
1477            &[DType::F32, DType::U8, DType::F32, DType::F32]
1478        );
1479    }
1480
1481    #[test]
1482    fn lower_file_lowers_every_kernel() {
1483        let file = parse_file(ELEMENTWISE_BINARY).expect("parses");
1484        let resolved = lower_file(&file, &StubLink, &mut Vec::new()).expect("all lower");
1485        // Every *registrable* section lowers; describe-only sections (§3.10,
1486        // e.g. the shared `binary` chassis umbrella) are filtered out first.
1487        let registrable = file.kernels.iter().filter(|k| k.registrable).count();
1488        assert_eq!(resolved.len(), registrable);
1489    }
1490
1491    #[test]
1492    fn cost_trampoline_evaluates_matmul_declared_flops() {
1493        // The matmul contract declares `flops: "2 * batch * m * n * k"`. Lower it
1494        // (StubLink resolves entry_points to dummies — we only need the parsed
1495        // cost AST), then run that AST through the cost trampoline for a
1496        // 2×(3×4×5) batched matmul → 2·2·3·4·5 = 240 flops. This is what FKC
1497        // import previously dropped in favor of the `unknown_cost` sentinel.
1498        const MATMUL: &str = include_str!("../../kernel-contracts/cpu/matmul.fkc.md");
1499        let file = parse_file(MATMUL).expect("matmul contract parses");
1500        let resolved =
1501            lower_file(&file, &StubLink, &mut Vec::new()).expect("matmul contract lowers");
1502        let prim = resolved
1503            .iter()
1504            .find_map(|r| match r {
1505                Resolved::Primitive(p) if p.op == OpKind::MatMul => Some(p),
1506                _ => None,
1507            })
1508            .expect("a MatMul primitive in the matmul contract");
1509        let params = crate::kernel::OpParams::Matmul {
1510            lhs_batch_dims: vec![2],
1511            rhs_batch_dims: vec![2],
1512            m: 3,
1513            n: 4,
1514            k: 5,
1515            m_compute: crate::kernel::MatmulM::All,
1516        };
1517        let est = crate::fkc::cost_estimate(
1518            &prim.cost,
1519            OpKind::MatMul,
1520            &[],
1521            &[DType::F32, DType::F32, DType::F32],
1522            &params,
1523        )
1524        .expect("declared matmul cost evaluates");
1525        assert_eq!(est.flops, 240, "2 * batch(2) * m(3) * n(4) * k(5) = 240");
1526    }
1527
1528    /// Task 1 end-to-end verification: a REAL fused contract from the corpus
1529    /// (`fused/norm-softmax.fkc.md`, `fused_op: SOFTMAX_LAST_DIM`) lowers via
1530    /// the stub link — proving the SCREAMING_SNAKE token now resolves.
1531    #[test]
1532    fn lowers_real_fused_softmax_last_dim() {
1533        let r = lower_one(FUSED_NORM_SOFTMAX, "softmax_last_dim")
1534            .expect("softmax_last_dim (fused) lowers");
1535        let Resolved::Fused(f) = r else {
1536            panic!("softmax_last_dim is a fused op");
1537        };
1538        assert_eq!(f.id, FusedOps::SOFTMAX_LAST_DIM);
1539        assert_eq!(f.backend, BackendId::Cpu);
1540        assert_eq!(f.kernel_source, "portable-cpu");
1541
1542        // The whole bundle lowers (every fused_op token resolves). Each of the
1543        // 8 `fused_op` sections enumerates `dtypes: [F32, F64, BF16, F16]` and
1544        // now FANS into one `ResolvedFused` per dtype (§3.4 fused dtype-fan), so
1545        // the lowered set is 8 sections × 4 dtypes = 32 (was 8 pre-fan, when
1546        // `lower_fused` took only the representative first variant).
1547        let file = parse_file(FUSED_NORM_SOFTMAX).expect("parses");
1548        let resolved =
1549            lower_file(&file, &StubLink, &mut Vec::new()).expect("all fused kernels lower");
1550        assert_eq!(
1551            resolved.len(),
1552            file.kernels.len() * 4,
1553            "each fused section fans over its 4 dtypes (8 sections × 4 = 32)",
1554        );
1555        assert!(resolved.iter().all(|r| matches!(r, Resolved::Fused(_))));
1556    }
1557
1558    // =====================================================================
1559    // FUSED dtype-fan (§3.4): a multi-dtype `fused_op` section fans into N
1560    // per-dtype ResolvedFused (mirroring the primitive fan-out), each keyed
1561    // on its own dtype tuple; a single-dtype fused section yields exactly one.
1562    // =====================================================================
1563
1564    /// A synthetic FUSED bundle exercising the fused dtype-fan. `fanned_softmax`
1565    /// declares `fused_op: SOFTMAX_LAST_DIM`, a BASE `entry_point`, and its input
1566    /// `x` enumerates `[F32, F64, BF16, F16]` — so it must fan into 4 per-dtype
1567    /// `ResolvedFused` (keys `[T, T]`), each resolving `<base>_<dt>`.
1568    /// `single_norm` is the backward-compat guard: a single-dtype (F32-only)
1569    /// section → EXACTLY ONE `ResolvedFused`, its declared symbol resolved AS-IS.
1570    const FUSED_FANOUT_SYNTH: &str = r#"---
1571fkc_version: 1
1572provider:
1573  name: test-provider
1574  backend: Cpu
1575  kernel_source: "test-cpu"
1576---
1577
1578# fused dtype-fan synthetic
1579
1580## fanned_softmax
1581
1582A blurb.
1583
1584```fkc
1585kernel: fanned_softmax
1586fused_op: SOFTMAX_LAST_DIM
1587blurb: "synthetic multi-dtype fused softmax"
1588entry_point: "x::softmax_cpu"
1589accept:
1590  inputs:
1591    - name: x
1592      dtypes: [F32, F64, BF16, F16]
1593return:
1594  outputs:
1595    - name: out
1596      dtype_rule: passthrough(x)
1597```
1598
1599## single_norm
1600
1601A blurb.
1602
1603```fkc
1604kernel: single_norm
1605fused_op: RMS_NORM_LAST_DIM
1606blurb: "synthetic single-dtype fused rms-norm"
1607entry_point: "x::rms_norm_f32_cpu"
1608accept:
1609  inputs:
1610    - name: x
1611      dtypes: [F32]
1612return:
1613  outputs:
1614    - name: out
1615      dtype_rule: passthrough(x)
1616```
1617"#;
1618
1619    #[test]
1620    fn fused_multi_dtype_section_fans_into_per_dtype_resolved_fused() {
1621        // BORN-RED → GREEN: `fanned_softmax` (input `x` over 4 dtypes, BASE
1622        // entry_point) must fan into 4 `ResolvedFused` — one per dtype — each
1623        // keyed `[dt, dt]`. Pre-change `lower_fused` took only the representative
1624        // (first) variant → 1 record (RED); the fan yields 4 (GREEN).
1625        let all = lower_all(FUSED_FANOUT_SYNTH, "fanned_softmax").expect("fanned_softmax lowers");
1626        assert_eq!(
1627            all.len(),
1628            4,
1629            "4 dtypes ⇒ 4 fanned ResolvedFused (pre-change: 1 representative only)",
1630        );
1631        let keys: Vec<Vec<DType>> = all
1632            .iter()
1633            .map(|r| {
1634                let Resolved::Fused(f) = r else {
1635                    panic!("fanned_softmax is a fused op")
1636                };
1637                assert_eq!(f.id, FusedOps::SOFTMAX_LAST_DIM);
1638                assert_eq!(f.backend, BackendId::Cpu);
1639                f.dtypes.to_vec()
1640            })
1641            .collect();
1642        assert_eq!(
1643            keys,
1644            vec![
1645                vec![DType::F32, DType::F32],
1646                vec![DType::F64, DType::F64],
1647                vec![DType::BF16, DType::BF16],
1648                vec![DType::F16, DType::F16],
1649            ],
1650            "each variant keys on [x, out(passthrough x)] at that dtype",
1651        );
1652    }
1653
1654    #[test]
1655    fn fused_single_dtype_section_yields_exactly_one_resolved_fused() {
1656        // Backward-compat hard gate: a single-dtype fused section produces
1657        // EXACTLY ONE `ResolvedFused`, byte-identical to today (its declared
1658        // `entry_point` resolves AS-IS — no `_<suffix>` appended).
1659        let all = lower_all(FUSED_FANOUT_SYNTH, "single_norm").expect("single_norm lowers");
1660        assert_eq!(
1661            all.len(),
1662            1,
1663            "single-dtype fused section ⇒ exactly one ResolvedFused"
1664        );
1665        let Resolved::Fused(f) = &all[0] else {
1666            panic!("single_norm is a fused op");
1667        };
1668        assert_eq!(f.id, FusedOps::RMS_NORM_LAST_DIM);
1669        assert_eq!(f.dtypes.as_slice(), &[DType::F32, DType::F32]);
1670    }
1671
1672    // =====================================================================
1673    // return.bundle key derivation (§5.5 Option C multi-output)
1674    // =====================================================================
1675
1676    /// A synthetic contract exercising the `return.bundle` key-derivation gap.
1677    /// `bundle_op` has TWO inputs (`a`, `b`) and a `return.bundle` (Option C
1678    /// multi-output: one packed buffer) whose PRIMARY slot mirrors input `a`
1679    /// (`passthrough(a)` → F32) and NO `return.outputs` — so its binding key
1680    /// must be `[a, b, out] = [F32; 3]` (N+1 slots). The importer previously
1681    /// read `return.outputs` ONLY, so a bundle-only section produced the N-slot
1682    /// short key `[F32, F32]` (missing the bundled output slot) — exactly why
1683    /// the `selective_scan_*` / `ssd_chunk_scan_*` families were deferred.
1684    /// `plain_op` is the no-bundle backward-compat guard: TWO inputs + a regular
1685    /// `return.outputs`, key `[F32, F32, F32]` (unchanged).
1686    /// A `dtype_rule: passthrough(<role>)` naming the LAST input when that
1687    /// input is OPTIONAL. Rejected: with the optional operand absent there is
1688    /// no operand to take a dtype from, so the output dtype would be
1689    /// unresolvable in exactly one arm of the {absent, present} fan.
1690    const PASSTHROUGH_OPTIONAL_SYNTH: &str = r#"---
1691fkc_version: 1
1692provider:
1693  name: test-provider
1694  backend: Cpu
1695  kernel_source: "test-cpu"
1696---
1697
1698# passthrough-names-optional synthetic
1699
1700## bad_passthrough
1701
1702A blurb.
1703
1704```fkc
1705kernel: bad_passthrough
1706op_kind: AddElementwise
1707blurb: "output passthrough names the optional last operand"
1708entry_point: "x::add_cpu"
1709accept:
1710  inputs:
1711    - name: x
1712      dtypes: [F32]
1713    - name: bias
1714      dtypes: [F32]
1715      optional: true
1716return:
1717  outputs:
1718    - name: out
1719      dtype_rule: passthrough(bias)
1720```
1721"#;
1722
1723    /// GAP-036: closes a clause that the derived clause->test coverage gate
1724    /// (`tests/fkc_clause_coverage.rs`) found unreachable by any test.
1725    ///
1726    /// `FkcError::PassthroughNamesOptionalOperand` was constructed on a live
1727    /// production path (`resolve_output_slot_dtype`) yet nothing proved that
1728    /// path was reachable — the "existence != enforcement" shape. The gate
1729    /// flagged it; this is the provoking test.
1730    ///
1731    /// The positive control is the `bias`-vs-`x` contrast: the SAME contract
1732    /// with `passthrough(x)` must lower cleanly. Without it this test would
1733    /// still pass if the contract were malformed for some unrelated reason and
1734    /// every lowering failed — it would be asserting "an error happened",
1735    /// not "THIS rule fired".
1736    #[test]
1737    fn passthrough_naming_the_optional_last_operand_is_rejected() {
1738        let err = lower_all(PASSTHROUGH_OPTIONAL_SYNTH, "bad_passthrough")
1739            .expect_err("passthrough(bias) names the optional last operand");
1740        match err {
1741            FkcError::PassthroughNamesOptionalOperand { ref role, .. } => {
1742                assert_eq!(role, "bias", "the rejected role is the optional operand");
1743            }
1744            other => panic!("expected PassthroughNamesOptionalOperand, got {other:?}"),
1745        }
1746
1747        // POSITIVE CONTROL: the identical contract passing through the REQUIRED
1748        // operand lowers. Proves the rejection above is caused by the operand
1749        // being optional, not by the fixture being broken.
1750        let ok = PASSTHROUGH_OPTIONAL_SYNTH.replace("passthrough(bias)", "passthrough(x)");
1751        lower_all(&ok, "bad_passthrough")
1752            .expect("passthrough(x) names a REQUIRED operand and must lower cleanly");
1753    }
1754
1755    const BUNDLE_SYNTH: &str = r#"---
1756fkc_version: 1
1757provider:
1758  name: test-provider
1759  backend: Cpu
1760  kernel_source: "test-cpu"
1761---
1762
1763# bundle key-derivation synthetic
1764
1765## bundle_op
1766
1767A blurb.
1768
1769```fkc
1770kernel: bundle_op
1771op_kind: SelectiveScan
1772blurb: "synthetic multi-output bundle op"
1773entry_point: "x::bundle_op"
1774accept:
1775  inputs:
1776    - name: a
1777      dtypes: [F32]
1778    - name: b
1779      dtypes: [F32]
1780return:
1781  bundle:
1782    - { name: y,  dtype_rule: passthrough(a), shape_rule: same_as(a), layout_guarantee: contiguous }
1783    - { name: st, dtype_rule: passthrough(a), shape_rule: same_as(a), layout_guarantee: contiguous }
1784```
1785
1786## plain_op
1787
1788A blurb.
1789
1790```fkc
1791kernel: plain_op
1792op_kind: AddElementwise
1793blurb: "synthetic no-bundle op"
1794entry_point: "x::plain_op"
1795accept:
1796  inputs:
1797    - name: a
1798      dtypes: [F32]
1799    - name: b
1800      dtypes: [F32]
1801return:
1802  outputs:
1803    - name: out
1804      dtype_rule: passthrough(a)
1805```
1806"#;
1807
1808    #[test]
1809    fn return_bundle_appends_primary_output_slot_to_key() {
1810        // A `return.bundle` (Option C multi-output, one packed buffer) must
1811        // contribute its PRIMARY slot's dtype to the binding key tail — so a
1812        // bundle-only section with N inputs keys `[in.., out] = N+1 slots`, NOT
1813        // the N-slot short key the importer built when it read `return.outputs`
1814        // only. The primary slot mirrors input `a` (`passthrough(a)` → F32).
1815        let r = lower_one(BUNDLE_SYNTH, "bundle_op").expect("bundle_op lowers");
1816        let Resolved::Primitive(p) = r else {
1817            panic!("bundle_op is a primitive");
1818        };
1819        assert_eq!(
1820            p.dtypes.as_slice(),
1821            &[DType::F32, DType::F32, DType::F32],
1822            "a `return.bundle` must append its primary output slot to the key \
1823             (2 inputs + 1 bundle out = 3); the importer previously built the \
1824             2-slot short key `[F32, F32]` from `return.outputs` only",
1825        );
1826    }
1827
1828    #[test]
1829    fn no_bundle_section_key_is_unchanged() {
1830        // Backward-compat guard: a section WITHOUT a `return.bundle` is
1831        // byte-identical to today — 2 inputs + 1 regular `passthrough(a)`
1832        // output = `[F32; 3]`, with NO phantom bundle slot appended.
1833        let r = lower_one(BUNDLE_SYNTH, "plain_op").expect("plain_op lowers");
1834        let Resolved::Primitive(p) = r else {
1835            panic!("plain_op is a primitive");
1836        };
1837        assert_eq!(
1838            p.dtypes.as_slice(),
1839            &[DType::F32, DType::F32, DType::F32],
1840            "a no-bundle section keys on inputs + regular outputs only",
1841        );
1842    }
1843
1844    // =====================================================================
1845    // variant: retained opaque tag (Baracuda `variant:`)
1846    // =====================================================================
1847
1848    /// A synthetic contract whose primitive section declares an opaque
1849    /// specialization `variant:` tag, plus a sibling section that omits it (the
1850    /// backward-compat `None` guard). The tag is a top-level per-kernel field
1851    /// (NOT `accept.op_params.variant`, which names the `OpParams` Rust
1852    /// variant) and must survive lowering onto `ResolvedPrimitive.variant`
1853    /// verbatim.
1854    const VARIANT_SYNTH: &str = r#"---
1855fkc_version: 1
1856provider:
1857  name: test-provider
1858  backend: Cpu
1859  kernel_source: "test-cpu"
1860---
1861
1862# variant retention synthetic
1863
1864## tagged_op
1865
1866A blurb.
1867
1868```fkc
1869kernel: tagged_op
1870op_kind: MatMul
1871blurb: "synthetic split-K partial variant"
1872entry_point: "x::tagged_op"
1873variant: "splitk_partial"
1874accept:
1875  inputs:
1876    - name: lhs
1877      dtypes: [F32]
1878    - name: rhs
1879      dtypes: [F32]
1880return:
1881  outputs:
1882    - name: out
1883      dtype_rule: fixed(F32)
1884```
1885
1886## untagged_op
1887
1888A blurb.
1889
1890```fkc
1891kernel: untagged_op
1892op_kind: MatMul
1893blurb: "synthetic no-variant op"
1894entry_point: "x::untagged_op"
1895accept:
1896  inputs:
1897    - name: lhs
1898      dtypes: [F32]
1899    - name: rhs
1900      dtypes: [F32]
1901return:
1902  outputs:
1903    - name: out
1904      dtype_rule: fixed(F32)
1905```
1906"#;
1907
1908    #[test]
1909    fn variant_tag_is_retained_on_resolved_primitive() {
1910        // A contract-declared `variant:` tag must ride verbatim onto the
1911        // lowered record so it can reach the (later) records / ImplId emission
1912        // step. RED before the wiring: the tag is dropped (retained `None`);
1913        // GREEN once `lower_primitive` clones `kernel.variant`.
1914        let r = lower_one(VARIANT_SYNTH, "tagged_op").expect("tagged_op lowers");
1915        let Resolved::Primitive(p) = r else {
1916            panic!("tagged_op is a primitive");
1917        };
1918        assert_eq!(
1919            p.variant.as_deref(),
1920            Some("splitk_partial"),
1921            "the opaque `variant:` tag must survive lowering onto \
1922             ResolvedPrimitive.variant (it was dropped)",
1923        );
1924    }
1925
1926    #[test]
1927    fn no_variant_section_retains_none() {
1928        // Backward-compat guard: a section that omits `variant:` lowers with
1929        // `variant: None` (every existing contract is unaffected, additive §11).
1930        let r = lower_one(VARIANT_SYNTH, "untagged_op").expect("untagged_op lowers");
1931        let Resolved::Primitive(p) = r else {
1932            panic!("untagged_op is a primitive");
1933        };
1934        assert_eq!(p.variant, None, "a section without `variant:` retains None");
1935    }
1936
1937    // =====================================================================
1938    // NEGATIVES
1939    // =====================================================================
1940
1941    #[test]
1942    fn bogus_op_kind_is_unknown_op_kind() {
1943        let err = lower_op_kind("NotARealOp", "k").expect_err("bogus op");
1944        assert!(matches!(err, FkcError::UnknownOpKind { .. }), "got {err:?}");
1945    }
1946
1947    #[test]
1948    fn bogus_dtype_is_bad_scalar_type() {
1949        let err = lower_dtype("F99", "k", "lhs").expect_err("bogus dtype");
1950        assert!(matches!(err, FkcError::BadScalarType { .. }), "got {err:?}");
1951    }
1952
1953    /// DRIFT GUARD (§3.4 fan-out): the `DType → FKC dtype suffix` spelling
1954    /// used to build a fanning section's `<entry_point>_<suffix>` symbol must
1955    /// stay the exact INVERSE of `lower_dtype` — i.e. uppercasing the suffix
1956    /// round-trips back to the same `DType`. This locks `dtype_suffix`
1957    /// (`DType::as_str`, the `ep!`/byte-kernel convention) to the token table
1958    /// so the two cannot drift into a second spelling.
1959    #[test]
1960    fn dtype_suffix_is_the_inverse_of_lower_dtype() {
1961        for dt in [
1962            DType::U8,
1963            DType::I8,
1964            DType::U32,
1965            DType::I16,
1966            DType::I32,
1967            DType::I64,
1968            DType::BF16,
1969            DType::F16,
1970            DType::F32,
1971            DType::F64,
1972            DType::F8E4M3,
1973            DType::F6E2M3,
1974            DType::F6E3M2,
1975            DType::F4,
1976            DType::F8E8M0,
1977            DType::F8E6M2,
1978        ] {
1979            let suffix = super::dtype_suffix(dt);
1980            let token = suffix.to_uppercase();
1981            assert_eq!(
1982                super::lower_dtype(&token, "k", "op").expect("round-trips"),
1983                dt,
1984                "suffix {suffix:?} (token {token:?}) must lower back to {dt:?}",
1985            );
1986        }
1987    }
1988
1989    #[test]
1990    fn bogus_fused_op_is_unknown_fused_op() {
1991        let err = lower_fused_op("NotAFusedOp", "k").expect_err("bogus fused");
1992        assert!(
1993            matches!(err, FkcError::UnknownFusedOp { .. }),
1994            "got {err:?}"
1995        );
1996    }
1997
1998    #[test]
1999    fn screaming_snake_const_name_resolves_not_pascalcase() {
2000        // The contract token is the SCREAMING_SNAKE FusedOps constant name.
2001        assert_eq!(
2002            lower_fused_op("SOFTMAX_LAST_DIM", "k").unwrap(),
2003            FusedOps::SOFTMAX_LAST_DIM
2004        );
2005        assert_eq!(
2006            lower_fused_op("FLASH_ATTN", "k").unwrap(),
2007            FusedOps::FLASH_ATTN
2008        );
2009        assert_eq!(lower_fused_op("QMATMUL", "k").unwrap(), FusedOps::QMATMUL);
2010        // The PascalCase registry `name` (what the OLD id_for_name resolver
2011        // matched) must NOT resolve — that was the bug.
2012        assert!(lower_fused_op("SoftmaxLastDim", "k").is_err());
2013        assert!(lower_fused_op("FlashAttn", "k").is_err());
2014    }
2015
2016    /// DRIFT GUARD (Task 1): every `FusedOpId` present in `default_registry()`
2017    /// must be reachable through the SCREAMING_SNAKE constant-name table — so
2018    /// adding a new `FusedOps` const + registry entry without a table arm
2019    /// fails this test (it would otherwise be silently unimportable).
2020    #[test]
2021    fn every_registry_id_is_reachable_through_table() {
2022        use std::collections::HashSet;
2023        // Every id the constant-name table can produce.
2024        let reachable: HashSet<FusedOpId> = [
2025            "SOFTMAX_LAST_DIM",
2026            "FUSED_LINEAR",
2027            "RMS_NORM_LAST_DIM",
2028            "LAYER_NORM_LAST_DIM",
2029            "ROPE",
2030            "CONV2D",
2031            "SOFTMAX_LAST_DIM_BACKWARD",
2032            "LAYER_NORM_LAST_DIM_BACKWARD",
2033            "RMS_NORM_LAST_DIM_BACKWARD",
2034            "REDUCE_MAX_TO_BACKWARD",
2035            "CONV_TRANSPOSE2D",
2036            "FLASH_ATTN",
2037            "PAGED_ATTN",
2038            "QMATMUL",
2039            "POWI_BACKWARD",
2040            "INPLACE_AFFINE",
2041            "FUSED_SOFTMAX_CROSS_ENTROPY",
2042            "CAUSAL_CONV1D",
2043            "SELECTIVE_SCAN",
2044            "SSD_CHUNK_SCAN",
2045            "NF4_MATMUL",
2046            "FLASH_ATTN_BACKWARD_Q",
2047            "FLASH_ATTN_BACKWARD_K",
2048            "FLASH_ATTN_BACKWARD_V",
2049            "AWQ_MATMUL",
2050        ]
2051        .iter()
2052        .map(|n| super::fused_op_id_for_const_name(n).expect("table arm exists"))
2053        .collect();
2054
2055        // Every registered id MUST be in `reachable`.
2056        for entry in fuel_graph::registry::default_registry().entries_iter() {
2057            assert!(
2058                reachable.contains(&entry.id),
2059                "FusedOpId {:?} (registry name {:?}) is registered but NOT reachable through the \
2060                 SCREAMING_SNAKE constant-name table — add a `fused_op_id_for_const_name` arm",
2061                entry.id,
2062                entry.name,
2063            );
2064        }
2065    }
2066
2067    #[test]
2068    fn bogus_backend_is_unknown_backend() {
2069        let err = lower_backend("Tpu", "k").expect_err("bogus backend");
2070        assert!(
2071            matches!(err, FkcError::UnknownBackend { .. }),
2072            "got {err:?}"
2073        );
2074    }
2075
2076    #[test]
2077    fn unknown_entry_point_errors() {
2078        let file = parse_file(ELEMENTWISE_BINARY).expect("parses");
2079        let provider = &file.front_matter.provider;
2080        let defaults = Defaults {
2081            backend: provider.backend.as_str(),
2082            kernel_source: provider.kernel_source.as_str(),
2083            revision_base: provider.revision_base.as_deref().unwrap_or(""),
2084        };
2085        let kernel = file.kernels.iter().find(|k| k.kernel == "add_f32").unwrap();
2086        let err = lower_kernel(kernel, &defaults, &EmptyLink, &mut Vec::new())
2087            .expect_err("no entry point");
2088        assert!(
2089            matches!(err, FkcError::UnknownEntryPoint { .. }),
2090            "got {err:?}"
2091        );
2092    }
2093
2094    #[test]
2095    fn malformed_cost_expr_is_cost_expr_parse() {
2096        let mut block = CostBlock {
2097            provenance: Some("declared".into()),
2098            class: None,
2099            cost_fn: None,
2100            flops: Some("2 * * n".into()), // malformed
2101            bytes_moved: None,
2102            overhead_ns: None,
2103            memory: None,
2104        };
2105        let err = compile_cost(Some(&block), "k").expect_err("malformed flops");
2106        assert!(matches!(err, FkcError::CostExprParse { .. }), "got {err:?}");
2107
2108        // Also catch a malformed bytes_moved even when flops is fine.
2109        block.flops = Some("n".into());
2110        block.bytes_moved = Some("3 * n *".into());
2111        let err = compile_cost(Some(&block), "k").expect_err("malformed bytes_moved");
2112        assert!(matches!(err, FkcError::CostExprParse { field, .. } if field == "bytes_moved"));
2113    }
2114
2115    #[test]
2116    fn both_op_kind_and_fused_op_is_ambiguous() {
2117        let file = parse_file(ELEMENTWISE_BINARY).expect("parses");
2118        let mut kernel = file.kernels[0].clone();
2119        kernel.fused_op = Some("SoftmaxLastDim".into()); // now both present
2120        let defaults = Defaults {
2121            backend: "Cpu",
2122            kernel_source: "x",
2123            revision_base: "",
2124        };
2125        let err =
2126            lower_kernel(&kernel, &defaults, &StubLink, &mut Vec::new()).expect_err("ambiguous");
2127        assert!(
2128            matches!(err, FkcError::OpTargetAmbiguous { .. }),
2129            "got {err:?}"
2130        );
2131    }
2132}