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