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