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 ¶ms,
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}