Skip to main content

fuel_dispatch/fkc/
cost_expr.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cost-expression mini-parser + evaluator (FKC §4.4 / adoption plan §2.3,
3//! strategy **(A)** — the AST half only).
4//!
5//! A cost expression (`flops`, `bytes_moved`, `overhead_ns`,
6//! `memory.device_bytes`) is a small arithmetic formula over shape /
7//! parameter symbols. This module parses such a string into a
8//! [`CompiledCostExpr`] AST and evaluates it against a `symbol_bindings`
9//! map ([`eval`]). It does **not** produce a `CostFn` fn-pointer — that
10//! is the next slice (the trampoline / global side-table /
11//! `register_into`). This slice stops at the parsed AST + a capacity-only
12//! evaluator.
13//!
14//! ## Grammar (§2.3)
15//!
16//! ```text
17//! expr    := term  (('+' | '-') term)*
18//! term    := factor (('*' | '/' | '%') factor)*
19//! factor  := number | symbol | '(' expr ')' | ('-' factor)
20//! number  := integer | float        (decimal; `1`, `4`, `2.5`)
21//! symbol  := identifier             (`m`, `n`, `k`, `dtype_bytes`, …)
22//! ```
23//!
24//! Accepted shape/param symbols are open: any identifier resolves at
25//! eval time from the supplied bindings (a missing symbol is an eval
26//! error, not a parse error — the §2.3 vocabulary `m`, `n`, `k`,
27//! `n`(=elem count), `dtype_bytes` plus any param name the kernel uses).
28//! Anything outside the grammar (a stray `(`, `==`, an empty expression,
29//! a bad character) is a parse error surfaced to the caller as
30//! [`FkcError::CostExprParse`].
31//!
32//! ## The `Unknown` sentinel
33//!
34//! A cost block that is absent, `class`-only, or `judge_measured` with no
35//! coefficient expressions compiles to [`CompiledCostExpr::Unknown`].
36//! The register slice maps that to the existing `unknown_cost` fn-pointer
37//! sentinel; this slice only records it.
38//!
39//! ## Eval is capacity-only (v1)
40//!
41//! [`eval`] evaluates over `u64`-equivalent `f64` symbol values
42//! (capacity / `Extent::bound()`), matching what today's `CostFn`
43//! receives. The per-tier `memory` beyond `device_bytes` and any
44//! `SymEnv`-resolved live-extent term are parsed-but-not-evaluated (held
45//! by the caller, documented at the construction site).
46
47use std::collections::HashMap;
48
49use crate::fused::CostEstimate;
50use crate::kernel::OpParams;
51use fuel_ir::dispatch::OpKind;
52use fuel_ir::{DType, Shape};
53
54/// A compiled cost-expression AST (FKC §4.4 / §2.3 strategy A).
55///
56/// Cloneable + comparable so it can ride on a resolved record and be
57/// asserted in tests. Held as data, never as a fn-pointer (this slice).
58#[derive(Debug, Clone, PartialEq)]
59pub enum CompiledCostExpr {
60    /// Sentinel: no cost claim (absent block / class-only /
61    /// judge_measured with no expressions). Maps to `unknown_cost` in the
62    /// register slice.
63    Unknown,
64    /// A parsed expression tree.
65    Expr(CostNode),
66}
67
68/// One node of a cost-expression AST.
69#[derive(Debug, Clone, PartialEq)]
70pub enum CostNode {
71    /// A numeric literal (integer or float; held as `f64`).
72    Lit(f64),
73    /// A shape / param symbol resolved at eval time (`m`, `n`, `k`,
74    /// `dtype_bytes`, …). A dotted member path (`lhs.dim`, `x.dim`,
75    /// FKC §4.4 "shape dims by role") is carried as one dotted `Sym`.
76    Sym(String),
77    /// A binary arithmetic operation.
78    Bin {
79        op: BinOp,
80        lhs: Box<CostNode>,
81        rhs: Box<CostNode>,
82    },
83    /// Unary negation.
84    Neg(Box<CostNode>),
85    /// A shape-axis index: `base[index]` (FKC §3.5 `dim[i]`, §4.4
86    /// `lhs.dim[0]` / `out_shape[2]`). `base` is the symbol path, `index`
87    /// the (usually literal) axis expression.
88    Index {
89        base: Box<CostNode>,
90        index: Box<CostNode>,
91    },
92    /// A function-style term: `f(arg, ...)` (e.g. `block_bytes(quant_type)`
93    /// in the quant-matmul cost hints). Resolved at eval time from the
94    /// bindings by its canonical string spelling.
95    Call { name: String, args: Vec<CostNode> },
96}
97
98/// The four supported binary operators (§2.3: `+ - * / %`).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum BinOp {
101    Add,
102    Sub,
103    Mul,
104    Div,
105    Rem,
106}
107
108/// A parse failure (kept local; the lower layer wraps it into
109/// [`crate::fkc::FkcError::CostExprParse`] with the field/section context).
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct CostParseError(pub String);
112
113impl std::fmt::Display for CostParseError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.write_str(&self.0)
116    }
117}
118
119/// An eval failure (an undefined symbol, or division by zero).
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct CostEvalError(pub String);
122
123impl std::fmt::Display for CostEvalError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.write_str(&self.0)
126    }
127}
128
129// ===========================================================================
130// Tokenizer
131// ===========================================================================
132
133#[derive(Debug, Clone, PartialEq)]
134enum Tok {
135    Num(f64),
136    Ident(String),
137    Plus,
138    Minus,
139    Star,
140    Slash,
141    Percent,
142    LParen,
143    RParen,
144    LBracket,
145    RBracket,
146    Comma,
147}
148
149fn tokenize(src: &str) -> Result<Vec<Tok>, CostParseError> {
150    let mut toks = Vec::new();
151    let bytes: Vec<char> = src.chars().collect();
152    let mut i = 0;
153    while i < bytes.len() {
154        let c = bytes[i];
155        match c {
156            ' ' | '\t' | '\n' | '\r' => {
157                i += 1;
158            }
159            '+' => {
160                toks.push(Tok::Plus);
161                i += 1;
162            }
163            '-' => {
164                toks.push(Tok::Minus);
165                i += 1;
166            }
167            '*' => {
168                toks.push(Tok::Star);
169                i += 1;
170            }
171            '/' => {
172                toks.push(Tok::Slash);
173                i += 1;
174            }
175            '%' => {
176                toks.push(Tok::Percent);
177                i += 1;
178            }
179            '(' => {
180                toks.push(Tok::LParen);
181                i += 1;
182            }
183            ')' => {
184                toks.push(Tok::RParen);
185                i += 1;
186            }
187            '[' => {
188                toks.push(Tok::LBracket);
189                i += 1;
190            }
191            ']' => {
192                toks.push(Tok::RBracket);
193                i += 1;
194            }
195            ',' => {
196                toks.push(Tok::Comma);
197                i += 1;
198            }
199            c if c.is_ascii_digit() => {
200                let start = i;
201                let mut seen_dot = false;
202                while i < bytes.len()
203                    && (bytes[i].is_ascii_digit() || (bytes[i] == '.' && !seen_dot))
204                {
205                    if bytes[i] == '.' {
206                        seen_dot = true;
207                    }
208                    i += 1;
209                }
210                let lit: String = bytes[start..i].iter().collect();
211                let val: f64 = lit
212                    .parse()
213                    .map_err(|_| CostParseError(format!("bad numeric literal `{lit}`")))?;
214                toks.push(Tok::Num(val));
215            }
216            c if c.is_ascii_alphabetic() || c == '_' => {
217                let start = i;
218                // An identifier may be a DOTTED member path (`lhs.dim`,
219                // `x.dim`, `out_shape`) per FKC §4.4 "shape dims by role". A
220                // `.` is consumed into the identifier only when followed by an
221                // alphabetic/underscore (a member name), so it is never
222                // confused with a decimal point.
223                loop {
224                    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
225                        i += 1;
226                    }
227                    if i + 1 < bytes.len()
228                        && bytes[i] == '.'
229                        && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == '_')
230                    {
231                        i += 1; // consume the '.'
232                        continue;
233                    }
234                    break;
235                }
236                let ident: String = bytes[start..i].iter().collect();
237                toks.push(Tok::Ident(ident));
238            }
239            other => {
240                return Err(CostParseError(format!(
241                    "unexpected character `{other}` in cost expression"
242                )));
243            }
244        }
245    }
246    Ok(toks)
247}
248
249// ===========================================================================
250// Recursive-descent parser
251// ===========================================================================
252
253struct Parser {
254    toks: Vec<Tok>,
255    pos: usize,
256}
257
258impl Parser {
259    fn peek(&self) -> Option<&Tok> {
260        self.toks.get(self.pos)
261    }
262
263    fn bump(&mut self) -> Option<Tok> {
264        let t = self.toks.get(self.pos).cloned();
265        if t.is_some() {
266            self.pos += 1;
267        }
268        t
269    }
270
271    fn parse_expr(&mut self) -> Result<CostNode, CostParseError> {
272        let mut lhs = self.parse_term()?;
273        while let Some(tok) = self.peek() {
274            let op = match tok {
275                Tok::Plus => BinOp::Add,
276                Tok::Minus => BinOp::Sub,
277                _ => break,
278            };
279            self.bump();
280            let rhs = self.parse_term()?;
281            lhs = CostNode::Bin {
282                op,
283                lhs: Box::new(lhs),
284                rhs: Box::new(rhs),
285            };
286        }
287        Ok(lhs)
288    }
289
290    fn parse_term(&mut self) -> Result<CostNode, CostParseError> {
291        let mut lhs = self.parse_factor()?;
292        while let Some(tok) = self.peek() {
293            let op = match tok {
294                Tok::Star => BinOp::Mul,
295                Tok::Slash => BinOp::Div,
296                Tok::Percent => BinOp::Rem,
297                _ => break,
298            };
299            self.bump();
300            let rhs = self.parse_factor()?;
301            lhs = CostNode::Bin {
302                op,
303                lhs: Box::new(lhs),
304                rhs: Box::new(rhs),
305            };
306        }
307        Ok(lhs)
308    }
309
310    fn parse_factor(&mut self) -> Result<CostNode, CostParseError> {
311        let atom = match self.bump() {
312            Some(Tok::Num(v)) => CostNode::Lit(v),
313            Some(Tok::Ident(name)) => {
314                // A `(` immediately after an identifier is a function call
315                // (`block_bytes(quant_type)`, FKC §4.4 cost hints).
316                if matches!(self.peek(), Some(Tok::LParen)) {
317                    self.bump(); // consume '('
318                    let mut args = Vec::new();
319                    if !matches!(self.peek(), Some(Tok::RParen)) {
320                        loop {
321                            args.push(self.parse_expr()?);
322                            match self.peek() {
323                                Some(Tok::Comma) => {
324                                    self.bump();
325                                }
326                                _ => break,
327                            }
328                        }
329                    }
330                    match self.bump() {
331                        Some(Tok::RParen) => {}
332                        _ => {
333                            return Err(CostParseError(
334                                "unbalanced `(` in function call — expected `)`".into(),
335                            ));
336                        }
337                    }
338                    CostNode::Call { name, args }
339                } else {
340                    CostNode::Sym(name)
341                }
342            }
343            Some(Tok::Minus) => {
344                let inner = self.parse_factor()?;
345                return Ok(CostNode::Neg(Box::new(inner)));
346            }
347            Some(Tok::LParen) => {
348                let inner = self.parse_expr()?;
349                match self.bump() {
350                    Some(Tok::RParen) => inner,
351                    _ => return Err(CostParseError("unbalanced `(` — expected `)`".into())),
352                }
353            }
354            Some(other) => {
355                return Err(CostParseError(format!(
356                    "unexpected token `{other:?}` where a value was expected"
357                )));
358            }
359            None => {
360                return Err(CostParseError(
361                    "unexpected end of cost expression (expected a value)".into(),
362                ));
363            }
364        };
365        // Postfix `[index]` runs (shape-axis index: `dim[i]`, `out_shape[2]`,
366        // `lhs.dim[0]`; FKC §3.5 / §4.4). Chained indexing is allowed.
367        let mut node = atom;
368        while matches!(self.peek(), Some(Tok::LBracket)) {
369            self.bump(); // consume '['
370            let index = self.parse_expr()?;
371            match self.bump() {
372                Some(Tok::RBracket) => {}
373                _ => return Err(CostParseError("unbalanced `[` — expected `]`".into())),
374            }
375            node = CostNode::Index {
376                base: Box::new(node),
377                index: Box::new(index),
378            };
379        }
380        Ok(node)
381    }
382}
383
384/// Parse a single cost-expression string into a [`CostNode`] AST.
385///
386/// Returns [`CostParseError`] on anything outside the §2.3 grammar; the
387/// lower layer wraps it into [`crate::fkc::FkcError::CostExprParse`].
388pub fn parse_expr(src: &str) -> Result<CostNode, CostParseError> {
389    let toks = tokenize(src)?;
390    if toks.is_empty() {
391        return Err(CostParseError("empty cost expression".into()));
392    }
393    let mut parser = Parser { toks, pos: 0 };
394    let node = parser.parse_expr()?;
395    if parser.pos != parser.toks.len() {
396        return Err(CostParseError(format!(
397            "trailing tokens after a complete expression (at token {})",
398            parser.pos
399        )));
400    }
401    Ok(node)
402}
403
404/// Compile a single cost-field expression into a [`CompiledCostExpr`].
405/// `None` (an absent field, i.e. YAML `~`) compiles to
406/// [`CompiledCostExpr::Unknown`].
407pub fn compile_field(src: Option<&str>) -> Result<CompiledCostExpr, CostParseError> {
408    match src {
409        None => Ok(CompiledCostExpr::Unknown),
410        Some(s) if s.trim().is_empty() => Ok(CompiledCostExpr::Unknown),
411        Some(s) => Ok(CompiledCostExpr::Expr(parse_expr(s)?)),
412    }
413}
414
415// ===========================================================================
416// Evaluator (capacity-only)
417// ===========================================================================
418
419/// Evaluate a [`CompiledCostExpr`] against `bindings` (symbol → capacity
420/// value). The [`CompiledCostExpr::Unknown`] sentinel evaluates to `0.0`.
421///
422/// Symbols absent from `bindings` are an [`CostEvalError`] — the caller
423/// (the register slice) supplies the §2.3 vocabulary (`m`, `n`, `k`,
424/// element count, `dtype_bytes`, plus op-param names).
425pub fn eval(
426    expr: &CompiledCostExpr,
427    bindings: &HashMap<String, f64>,
428) -> Result<f64, CostEvalError> {
429    match expr {
430        CompiledCostExpr::Unknown => Ok(0.0),
431        CompiledCostExpr::Expr(node) => eval_node(node, bindings),
432    }
433}
434
435fn eval_node(node: &CostNode, bindings: &HashMap<String, f64>) -> Result<f64, CostEvalError> {
436    match node {
437        CostNode::Lit(v) => Ok(*v),
438        CostNode::Sym(name) => bindings
439            .get(name)
440            .copied()
441            .ok_or_else(|| CostEvalError(format!("undefined cost symbol `{name}`"))),
442        CostNode::Index { base, index } => {
443            // Resolve by the canonical spelling `base[index]` from bindings
444            // (capacity-eval; the importer supplies the resolved axis values).
445            let key = canonical_key(node);
446            if let Some(v) = bindings.get(&key) {
447                return Ok(*v);
448            }
449            // Fall through: a numeric index against a bound `base` symbol is
450            // not statically resolvable here without a shape vector, so an
451            // unbound shape-index is an eval error (capacity-eval supplies the
452            // full key). Touch the children so a malformed index still errors.
453            let _ = eval_node(base, bindings);
454            let _ = eval_node(index, bindings);
455            Err(CostEvalError(format!("undefined cost shape-index `{key}`")))
456        }
457        CostNode::Call { .. } => {
458            let key = canonical_key(node);
459            bindings
460                .get(&key)
461                .copied()
462                .ok_or_else(|| CostEvalError(format!("undefined cost term `{key}`")))
463        }
464        CostNode::Neg(inner) => Ok(-eval_node(inner, bindings)?),
465        CostNode::Bin { op, lhs, rhs } => {
466            let l = eval_node(lhs, bindings)?;
467            let r = eval_node(rhs, bindings)?;
468            match op {
469                BinOp::Add => Ok(l + r),
470                BinOp::Sub => Ok(l - r),
471                BinOp::Mul => Ok(l * r),
472                BinOp::Div => {
473                    if r == 0.0 {
474                        Err(CostEvalError("division by zero in cost expression".into()))
475                    } else {
476                        Ok(l / r)
477                    }
478                }
479                BinOp::Rem => {
480                    if r == 0.0 {
481                        Err(CostEvalError("modulo by zero in cost expression".into()))
482                    } else {
483                        Ok(l % r)
484                    }
485                }
486            }
487        }
488    }
489}
490
491/// Bind the §2.3 cost-expression symbol vocabulary (`m`/`n`/`k`/`batch`/
492/// `dtype_bytes`, plus a generic element count) from a dispatch context, so a
493/// contract's *declared* cost expression can be evaluated to a concrete FLOP
494/// count. Op-specific symbols come from [`OpParams`]; the vocabulary extends as
495/// declared-cost contracts for more op families land.
496pub fn bind_cost_symbols(
497    _op: OpKind,
498    shapes: &[Shape],
499    dtypes: &[DType],
500    params: &OpParams,
501) -> HashMap<String, f64> {
502    let mut b = HashMap::new();
503    if let Some(out) = dtypes.last() {
504        b.insert("dtype_bytes".to_string(), out.size_in_bytes() as f64);
505    }
506    match params {
507        OpParams::Matmul {
508            lhs_batch_dims,
509            m,
510            n,
511            k,
512            ..
513        } => {
514            let batch = lhs_batch_dims.iter().copied().product::<usize>().max(1);
515            b.insert("batch".to_string(), batch as f64);
516            b.insert("m".to_string(), *m as f64);
517            b.insert("n".to_string(), *n as f64);
518            b.insert("k".to_string(), *k as f64);
519        }
520        _ => {
521            // Generic fallback: the total output element count as `n`.
522            if let Some(out_shape) = shapes.last() {
523                b.insert("n".to_string(), out_shape.elem_count() as f64);
524            }
525        }
526    }
527    b
528}
529
530/// The cost trampoline: evaluate a contract's *declared* cost expression against
531/// a dispatch context, turning a parsed [`CompiledCostExpr`] into a concrete
532/// [`CostEstimate`] — the value FKC import previously dropped in favor of the
533/// `unknown_cost` sentinel. An undefined symbol is a typed [`CostEvalError`]
534/// (never a panic, never a silent zero). `bytes_moved` stays 0 until contracts
535/// carry a separate `bytes:` expression.
536pub fn cost_estimate(
537    expr: &CompiledCostExpr,
538    op: OpKind,
539    shapes: &[Shape],
540    dtypes: &[DType],
541    params: &OpParams,
542) -> Result<CostEstimate, CostEvalError> {
543    let symbols = bind_cost_symbols(op, shapes, dtypes, params);
544    let flops = eval(expr, &symbols)?;
545    Ok(CostEstimate {
546        flops: flops.max(0.0) as u64,
547        ..CostEstimate::default()
548    })
549}
550
551/// Reconstruct a canonical string spelling of a shape-index / call node so it
552/// can be looked up in the `bindings` map (the importer keys resolved
553/// shape-axis / term values by this spelling at capacity-eval time).
554fn canonical_key(node: &CostNode) -> String {
555    match node {
556        CostNode::Lit(v) => {
557            if v.fract() == 0.0 {
558                format!("{}", *v as i64)
559            } else {
560                format!("{v}")
561            }
562        }
563        CostNode::Sym(s) => s.clone(),
564        CostNode::Index { base, index } => {
565            format!("{}[{}]", canonical_key(base), canonical_key(index))
566        }
567        CostNode::Call { name, args } => {
568            let inner: Vec<String> = args.iter().map(canonical_key).collect();
569            format!("{name}({})", inner.join(","))
570        }
571        CostNode::Neg(inner) => format!("-{}", canonical_key(inner)),
572        CostNode::Bin { .. } => "<expr>".to_string(),
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    fn b(pairs: &[(&str, f64)]) -> HashMap<String, f64> {
581        pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
582    }
583
584    #[test]
585    fn parses_and_evals_3_times_n_times_4() {
586        // "3 * n * 4" with n=8 → 96 (the prompt's canonical case).
587        let expr = compile_field(Some("3 * n * 4")).expect("parses");
588        let v = eval(&expr, &b(&[("n", 8.0)])).expect("evals");
589        assert_eq!(v, 96.0);
590    }
591
592    #[test]
593    fn precedence_mul_binds_tighter_than_add() {
594        // 2 + 3 * 4 = 14, not 20.
595        let expr = compile_field(Some("2 + 3 * 4")).unwrap();
596        assert_eq!(eval(&expr, &b(&[])).unwrap(), 14.0);
597    }
598
599    #[test]
600    fn parens_override_precedence() {
601        let expr = compile_field(Some("(2 + 3) * 4")).unwrap();
602        assert_eq!(eval(&expr, &b(&[])).unwrap(), 20.0);
603    }
604
605    #[test]
606    fn matmul_flops_2_m_n_k() {
607        let expr = compile_field(Some("2 * m * n * k")).unwrap();
608        let v = eval(&expr, &b(&[("m", 4.0), ("n", 8.0), ("k", 16.0)])).unwrap();
609        assert_eq!(v, 2.0 * 4.0 * 8.0 * 16.0);
610    }
611
612    #[test]
613    fn rem_and_div_and_float_literal() {
614        assert_eq!(
615            eval(&compile_field(Some("10 % 3")).unwrap(), &b(&[])).unwrap(),
616            1.0
617        );
618        assert_eq!(
619            eval(&compile_field(Some("12 / 4")).unwrap(), &b(&[])).unwrap(),
620            3.0
621        );
622        assert_eq!(
623            eval(&compile_field(Some("2.5 * 2")).unwrap(), &b(&[])).unwrap(),
624            5.0
625        );
626    }
627
628    #[test]
629    fn unary_negation() {
630        assert_eq!(
631            eval(&compile_field(Some("-3 + 5")).unwrap(), &b(&[])).unwrap(),
632            2.0
633        );
634    }
635
636    #[test]
637    fn dtype_bytes_symbol() {
638        let expr = compile_field(Some("3 * n * dtype_bytes")).unwrap();
639        let v = eval(&expr, &b(&[("n", 10.0), ("dtype_bytes", 4.0)])).unwrap();
640        assert_eq!(v, 120.0);
641    }
642
643    #[test]
644    fn none_and_empty_are_unknown() {
645        assert_eq!(compile_field(None).unwrap(), CompiledCostExpr::Unknown);
646        assert_eq!(
647            compile_field(Some("   ")).unwrap(),
648            CompiledCostExpr::Unknown
649        );
650        // Unknown evaluates to 0.
651        assert_eq!(eval(&CompiledCostExpr::Unknown, &b(&[])).unwrap(), 0.0);
652    }
653
654    #[test]
655    fn malformed_expressions_are_parse_errors() {
656        // The prompt's garbage case (after a comparator that the grammar
657        // doesn't have) and other malformed forms.
658        assert!(compile_field(Some("k_len <= sk garbage(")).is_err());
659        assert!(compile_field(Some("2 *")).is_err());
660        assert!(compile_field(Some("(2 + 3")).is_err());
661        assert!(compile_field(Some("2 2")).is_err());
662        assert!(compile_field(Some("@")).is_err());
663        assert!(compile_field(Some("==")).is_err());
664    }
665
666    #[test]
667    fn undefined_symbol_is_eval_error() {
668        let expr = compile_field(Some("n * missing")).unwrap();
669        assert!(eval(&expr, &b(&[("n", 4.0)])).is_err());
670    }
671
672    // --- Extended grammar (FKC §3.5 / §4.4): shape-index, member path, call ---
673
674    #[test]
675    fn shape_index_and_member_path_parse() {
676        // The corpus conv cost expressions.
677        compile_field(Some(
678            "2 * out_shape[0] * (x_shape[1] / groups) * w_shape[2] * w_shape[3]",
679        ))
680        .expect("out_shape[i] parses");
681        compile_field(Some(
682            "2 * out_elems * (x.dim[1] / groups) * weight.dim[2] * weight.dim[3]",
683        ))
684        .expect("role.dim[i] parses");
685        compile_field(Some(
686            "2 * x.dim[0] * x.dim[1] * (x.dim[2] - weight.dim[2] + 1) * weight.dim[2]",
687        ))
688        .expect("causal-conv expr parses");
689    }
690
691    #[test]
692    fn function_call_term_parses() {
693        // The quant-matmul cost hint with a function-style term.
694        compile_field(Some(
695            "(batch_count*m*k*4 + n*k*block_bytes(quant_type) + batch_count*m*n*4)",
696        ))
697        .expect("block_bytes(quant_type) parses");
698    }
699
700    #[test]
701    fn shape_index_evaluates_from_canonical_binding() {
702        let expr = compile_field(Some("2 * out_shape[0]")).unwrap();
703        let v = eval(&expr, &b(&[("out_shape[0]", 8.0)])).unwrap();
704        assert_eq!(v, 16.0);
705    }
706
707    #[test]
708    fn member_path_is_one_dotted_symbol() {
709        // `x.dim` is a single dotted symbol, NOT `x` `.` `dim`.
710        let node = parse_expr("x.dim").unwrap();
711        assert_eq!(node, CostNode::Sym("x.dim".to_string()));
712    }
713
714    #[test]
715    fn unbalanced_bracket_is_parse_error() {
716        assert!(compile_field(Some("out_shape[0")).is_err());
717        assert!(compile_field(Some("x.dim 0]")).is_err());
718    }
719
720    #[test]
721    fn lone_dot_is_still_a_parse_error() {
722        // A bare `.` (not inside a number, not a member path) is rejected.
723        assert!(compile_field(Some("2 . 3")).is_err());
724    }
725}