Skip to main content

fuel_dispatch/fkc/
shape_constraint.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! §3.5 shape/rank constraint vocabulary — parser + probe-shape solver.
3//!
4//! Structured like `cost_expr.rs`. The `<expr>` grammar inside `dim[i]=<expr>`,
5//! `divisible(...)`, `capacity_ge(...)` reuses `cost_expr::parse_expr`.
6use std::collections::{HashMap, HashSet};
7
8use crate::fkc::ImportWarning;
9use crate::fkc::cost_expr::{CostNode, parse_expr as parse_cost_expr};
10use crate::fkc::error::FkcError;
11use crate::fkc::schema::TensorDesc;
12use fuel_ir::{DType, Shape};
13
14pub type ProbeCombo = Vec<(String, fuel_ir::Shape, fuel_ir::DType)>;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum RankSpec {
18    Exact(usize),
19    Any,
20    Range { lo: usize, hi: Option<usize> },
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AxisIndex {
25    FromStart(usize),
26    FromEnd(usize),
27} // dim[2]=FromStart(2); dim[-1]=FromEnd(1)
28
29#[derive(Debug, Clone, PartialEq)]
30pub enum ShapeAtom {
31    SameAs(String),
32    SameRank(String),
33    Rank(RankSpec),
34    BroadcastTo(String),
35    LastDimEq(String),
36    DimEq { axis: AxisIndex, expr: CostNode },
37    Divisible { lhs: CostNode, rhs: CostNode },
38    CapacityGe { axis: AxisIndex, sym: String },
39}
40
41#[derive(Debug, Clone, PartialEq, Default)]
42pub struct ShapeConstraint {
43    pub atoms: Vec<ShapeAtom>,
44    pub freetext: Vec<String>,
45}
46
47fn parse_axis(s: &str) -> Option<AxisIndex> {
48    let s = s.trim();
49    if let Some(n) = s.strip_prefix('-') {
50        n.trim().parse::<usize>().ok().map(AxisIndex::FromEnd)
51    } else {
52        s.parse::<usize>().ok().map(AxisIndex::FromStart)
53    }
54}
55
56/// `4` | `any` | `2..=4` | `2..` -> RankSpec; None on anything else.
57pub fn parse_rank_spec(s: &str) -> Option<RankSpec> {
58    let s = s.trim();
59    if s == "any" {
60        return Some(RankSpec::Any);
61    }
62    if let Ok(n) = s.parse::<usize>() {
63        return Some(RankSpec::Exact(n));
64    }
65    if let Some((lo, hi)) = s.split_once("..=") {
66        return Some(RankSpec::Range {
67            lo: lo.trim().parse().ok()?,
68            hi: Some(hi.trim().parse().ok()?),
69        });
70    }
71    if let Some(lo) = s.strip_suffix("..") {
72        return Some(RankSpec::Range {
73            lo: lo.trim().parse().ok()?,
74            hi: None,
75        });
76    }
77    None
78}
79
80/// Split `a, b` on the FIRST top-level comma, tracking `(` and `[` depth so
81/// `capacity_ge(dim[0], seqlen)` / `divisible(q.dim[2], k.dim[2])` split correctly.
82fn split_two_args(inner: &str) -> Option<(&str, &str)> {
83    let mut depth = 0i32;
84    for (i, c) in inner.char_indices() {
85        match c {
86            '(' | '[' => depth += 1,
87            ')' | ']' => depth -= 1,
88            ',' if depth == 0 => return Some((&inner[..i], &inner[i + 1..])),
89            _ => {}
90        }
91    }
92    None
93}
94
95pub fn parse_shape_constraint(
96    raw: &str,
97    section: &str,
98    operand: &str,
99) -> Result<ShapeConstraint, FkcError> {
100    let mut out = ShapeConstraint::default();
101    for seg_raw in raw.split(';') {
102        let seg = seg_raw.trim();
103        if seg.is_empty() {
104            continue;
105        }
106        let unparse = || FkcError::UnparseableShapeConstraint {
107            section: section.into(),
108            operand: operand.into(),
109            raw: seg.to_string(),
110        };
111        if let Some(r) = seg.strip_prefix("same_as=") {
112            out.atoms.push(ShapeAtom::SameAs(r.trim().into()));
113            continue;
114        }
115        if let Some(r) = seg.strip_prefix("same_rank=") {
116            out.atoms.push(ShapeAtom::SameRank(r.trim().into()));
117            continue;
118        }
119        if let Some(r) = seg.strip_prefix("broadcast_to=") {
120            out.atoms.push(ShapeAtom::BroadcastTo(r.trim().into()));
121            continue;
122        }
123        if let Some(r) = seg.strip_prefix("last_dim_eq=") {
124            out.atoms.push(ShapeAtom::LastDimEq(r.trim().into()));
125            continue;
126        }
127        if let Some(r) = seg.strip_prefix("rank=") {
128            // COMMITTED keyword
129            out.atoms
130                .push(ShapeAtom::Rank(parse_rank_spec(r).ok_or_else(unparse)?));
131            continue;
132        }
133        if seg.starts_with("divisible(") {
134            // COMMITTED: require close paren
135            let inner = seg
136                .strip_prefix("divisible(")
137                .and_then(|s| s.strip_suffix(')'))
138                .ok_or_else(unparse)?;
139            let (a, b) = split_two_args(inner).ok_or_else(unparse)?;
140            let lhs = parse_cost_expr(a.trim()).map_err(|_| unparse())?;
141            let rhs = parse_cost_expr(b.trim()).map_err(|_| unparse())?;
142            out.atoms.push(ShapeAtom::Divisible { lhs, rhs });
143            continue;
144        }
145        if seg.starts_with("capacity_ge(") {
146            // COMMITTED: require close paren
147            let inner = seg
148                .strip_prefix("capacity_ge(")
149                .and_then(|s| s.strip_suffix(')'))
150                .ok_or_else(unparse)?;
151            let (a, b) = split_two_args(inner).ok_or_else(unparse)?;
152            let axis = a
153                .trim()
154                .strip_prefix("dim[")
155                .and_then(|s| s.strip_suffix(']'))
156                .and_then(parse_axis)
157                .ok_or_else(unparse)?;
158            out.atoms.push(ShapeAtom::CapacityGe {
159                axis,
160                sym: b.trim().to_string(),
161            });
162            continue;
163        }
164        if seg.starts_with("dim[")
165            && let Some(close) = seg.find(']')
166        {
167            let idx = &seg["dim[".len()..close];
168            let after = seg[close + 1..].trim_start();
169            match (parse_axis(idx), after.strip_prefix('=')) {
170                // committed `dim[<int>]=<expr>` with a SINGLE '=' (not `==`)
171                (Some(axis), Some(rhs)) if !rhs.starts_with('=') => {
172                    let rhs = rhs.trim();
173                    if rhs.is_empty() {
174                        return Err(unparse());
175                    }
176                    out.atoms.push(ShapeAtom::DimEq {
177                        axis,
178                        expr: parse_cost_expr(rhs).map_err(|_| unparse())?,
179                    });
180                    continue;
181                }
182                // symbolic index (`dim[i]`) or `==` ⇒ pseudocode ⇒ free text
183                _ => {
184                    out.freetext.push(seg.to_string());
185                    continue;
186                }
187            }
188        }
189        out.freetext.push(seg.to_string()); // no recognized keyword ⇒ §3.5 notes-style free text
190    }
191    Ok(out)
192}
193
194#[derive(Clone, Copy)]
195struct SeedProfile {
196    base: i64,
197    odd_last: bool,
198}
199const PROFILES: [SeedProfile; 3] = [
200    SeedProfile {
201        base: 2,
202        odd_last: false,
203    }, // A all-2
204    SeedProfile {
205        base: 4,
206        odd_last: true,
207    }, // B all-4, last axis ->3
208    SeedProfile {
209        base: 8,
210        odd_last: false,
211    }, // C all-8
212];
213
214fn resolve_rank_spec_field(v: Option<&serde_yaml_ng::Value>) -> Option<RankSpec> {
215    match v {
216        Some(serde_yaml_ng::Value::Number(n)) => n.as_u64().map(|u| RankSpec::Exact(u as usize)),
217        Some(serde_yaml_ng::Value::String(s)) => parse_rank_spec(s),
218        _ => None,
219    }
220}
221fn rank_for_probe(spec: Option<RankSpec>) -> usize {
222    match spec {
223        Some(RankSpec::Exact(n)) => n,
224        Some(RankSpec::Range { lo, .. }) => lo,
225        Some(RankSpec::Any) | None => 4, // `any`/absent default rank 4
226    }
227}
228fn seed_axis(profile: SeedProfile, axis: usize, rank: usize) -> i64 {
229    if profile.odd_last && rank > 0 && axis == rank - 1 {
230        3
231    } else {
232        profile.base
233    }
234}
235/// First declared dtype, else first `dtype_class` expansion, else F32.
236///
237/// The class -> dtype-list mapping is `lower::expand_dtype_class` (single
238/// source of truth with §3.4's real lowering path); this just takes its
239/// first element as the probe pick. `"any"` expands against an empty
240/// `enumerated` list here (no enumerated dtypes reach this branch — a
241/// non-empty `d.dtypes` returns early above), so it falls through to `None`
242/// -> `F32`, matching the pre-Task-1.5 inline match's behavior.
243fn first_probe_dtype(d: &TensorDesc) -> DType {
244    if let Some(tok) = d.dtypes.first()
245        && let Ok(dt) = crate::fkc::lower::lower_dtype(tok, "", "")
246    {
247        return dt;
248    }
249    match d.dtype_class.as_deref() {
250        Some(class) => crate::fkc::lower::expand_dtype_class(class, &[], "", "")
251            .ok()
252            .and_then(|v| v.first().copied())
253            .unwrap_or(DType::F32),
254        None => DType::F32,
255    }
256}
257
258/// Per-profile solver state: each operand's working dims (seeded, then mutated
259/// in place by `apply_atom`), a lazily-seeded shared-symbol env (so a bare
260/// `dim[i]=k` on one operand and `dim[j]=k` on another bind the SAME value),
261/// and the profile's seed base (the value a freshly-seen symbol takes).
262struct Solve {
263    dims: HashMap<String, Vec<i64>>,
264    sym: HashMap<String, i64>,
265    base: i64,
266}
267
268/// Recognize `dim[i]` (self) or `role.dim[i]` as a dim reference.
269fn as_dim_ref(node: &CostNode) -> Option<(Option<String>, AxisIndex)> {
270    if let CostNode::Index { base, index } = node {
271        let axis = match &**index {
272            CostNode::Lit(v) => AxisIndex::FromStart(*v as usize),
273            CostNode::Neg(inner) => {
274                if let CostNode::Lit(v) = &**inner {
275                    AxisIndex::FromEnd(*v as usize)
276                } else {
277                    return None;
278                }
279            }
280            _ => return None,
281        };
282        if let CostNode::Sym(s) = &**base {
283            return Some(if let Some(r) = s.strip_suffix(".dim") {
284                (Some(r.to_string()), axis)
285            } else if s == "dim" {
286                (None, axis)
287            } else {
288                return None;
289            });
290        }
291    }
292    None
293}
294
295fn axis_to_index(axis: AxisIndex, rank: usize) -> Option<usize> {
296    match axis {
297        AxisIndex::FromStart(i) => Some(i),
298        AxisIndex::FromEnd(n) => rank.checked_sub(n),
299    }
300}
301
302/// Evaluate a CostNode to a concrete i64. None ⇒ genuinely unresolvable.
303fn eval_dim_expr(
304    node: &CostNode,
305    s: &mut Solve,
306    ranks: &HashMap<String, usize>,
307    self_role: &str,
308) -> Option<i64> {
309    use crate::fkc::cost_expr::BinOp::*;
310    match node {
311        CostNode::Lit(v) => Some(*v as i64),
312        CostNode::Neg(i) => eval_dim_expr(i, s, ranks, self_role)?.checked_neg(),
313        CostNode::Bin { op, lhs, rhs } => {
314            let (l, r) = (
315                eval_dim_expr(lhs, s, ranks, self_role)?,
316                eval_dim_expr(rhs, s, ranks, self_role)?,
317            );
318            // Checked: `overflow-checks = true` under `cargo test` PANICS on
319            // unchecked i64 overflow. `?` propagates `None` (genuinely
320            // unresolvable) rather than crashing on a huge-but-syntactically-
321            // valid dim expression (review Finding 1). `checked_div`/
322            // `checked_rem` ALSO subsume the zero-divisor guard (both return
323            // `None` for `r == 0`) AND the `i64::MIN / -1` / `i64::MIN % -1`
324            // edge case, which panics unconditionally in ALL profiles
325            // (release included) — that panic is hard-baked into `/`/`%`,
326            // not gated by `overflow-checks` (fix-pass-2 residual finding).
327            Some(match op {
328                Add => l.checked_add(r)?,
329                Sub => l.checked_sub(r)?,
330                Mul => l.checked_mul(r)?,
331                Div => l.checked_div(r)?,
332                Rem => l.checked_rem(r)?,
333            })
334        }
335        CostNode::Index { .. } => {
336            let (role, axis) = as_dim_ref(node)?;
337            let rrole = role.as_deref().unwrap_or(self_role);
338            let idx = axis_to_index(axis, *ranks.get(rrole)?)?;
339            s.dims.get(rrole)?.get(idx).copied()
340        }
341        CostNode::Sym(name) => Some(*s.sym.entry(name.clone()).or_insert(s.base)), // lazy-seed shared symbol
342        CostNode::Call { .. } => None,
343    }
344}
345
346fn warn(section: &str, message: String) -> ImportWarning {
347    ImportWarning {
348        section: section.into(),
349        message,
350    }
351}
352
353/// Input SOLVE-KEYS whose dims must resolve before `atoms` can apply. `roles`
354/// is the set of SOLVE-KEYS (Task 1.3), not raw operand names — a named
355/// operand's key IS its name, so `same_as=k` / `role.dim[i]` still match
356/// directly; an unnamed `#unnamed{i}` key is never referenced (nothing can
357/// name it), so it naturally gets no incoming edges.
358fn dep_sources(atoms: &[ShapeAtom], input_roles: &HashSet<String>) -> Vec<String> {
359    fn collect(n: &CostNode, roles: &HashSet<String>, out: &mut Vec<String>) {
360        if let Some((Some(r), _)) = as_dim_ref(n)
361            && roles.contains(&r)
362        {
363            out.push(r);
364        }
365        match n {
366            CostNode::Bin { lhs, rhs, .. } => {
367                collect(lhs, roles, out);
368                collect(rhs, roles, out);
369            }
370            CostNode::Neg(i) => collect(i, roles, out),
371            CostNode::Index { base, index } => {
372                collect(base, roles, out);
373                collect(index, roles, out);
374            }
375            CostNode::Call { args, .. } => {
376                for a in args {
377                    collect(a, roles, out);
378                }
379            }
380            _ => {}
381        }
382    }
383    let mut deps = Vec::new();
384    for a in atoms {
385        match a {
386            ShapeAtom::SameAs(r) | ShapeAtom::BroadcastTo(r) | ShapeAtom::LastDimEq(r)
387                if input_roles.contains(r) =>
388            {
389                deps.push(r.clone())
390            }
391            ShapeAtom::DimEq { expr, .. } => collect(expr, input_roles, &mut deps),
392            ShapeAtom::Divisible { lhs, rhs } => {
393                collect(lhs, input_roles, &mut deps);
394                collect(rhs, input_roles, &mut deps);
395            }
396            _ => {}
397        }
398    }
399    // Dedup: a predecessor referenced MORE THAN ONCE (same predecessor's dims
400    // used twice in one expr, e.g. `k.dim[0]+k.dim[1]`, or across `;`-segments)
401    // must still count as ONE indegree edge in `topo_order`'s Kahn pass. That
402    // pass's decrement step fires once per DISTINCT predecessor (membership
403    // via `deps.contains(cur)`, not a count), so an undeduped multi-entry list
404    // makes indegree overcount relative to how many times it can ever be
405    // decremented — an acyclic operand then never reaches indegree 0 and gets
406    // misclassified as part of a cycle (spurious `cycle` warning), silently
407    // reverting to source-order application (review Finding 1).
408    deps.sort();
409    deps.dedup();
410    deps
411}
412
413/// Kahn topological order over input SOLVE-KEYS; residual (cyclic) keys get
414/// ONE `cycle` warning and are appended in source order so their atoms still
415/// run best-effort (copying whatever seed/partial value the other cyclic
416/// operand currently holds). Never errors, always terminates: the `while i <
417/// queue.len()` loop only ever advances `i` or grows `queue` with a key not
418/// already queued/emitted, so it's bounded by `order_in.len()`.
419fn topo_order(
420    order_in: &[String],
421    edges: &HashMap<String, Vec<String>>,
422    section: &str,
423    w: &mut Vec<ImportWarning>,
424) -> Vec<String> {
425    let set: HashSet<&String> = order_in.iter().collect();
426    let mut indeg: HashMap<&String, usize> = order_in.iter().map(|r| (r, 0usize)).collect();
427    for (r, deps) in edges {
428        if set.contains(r) {
429            for d in deps {
430                if set.contains(d) {
431                    *indeg.get_mut(r).unwrap() += 1;
432                }
433            }
434        }
435    }
436    let mut queue: Vec<&String> = order_in.iter().filter(|r| indeg[r] == 0).collect();
437    let mut out = Vec::new();
438    let mut i = 0;
439    while i < queue.len() {
440        let cur = queue[i];
441        i += 1;
442        out.push(cur.clone());
443        for r in order_in {
444            if let Some(deps) = edges.get(r)
445                && deps.contains(cur)
446            {
447                let e = indeg.get_mut(r).unwrap();
448                *e = e.saturating_sub(1);
449                if *e == 0 && !out.contains(r) && !queue.contains(&r) {
450                    queue.push(r);
451                }
452            }
453        }
454    }
455    if out.len() < order_in.len() {
456        let residual: Vec<&String> = order_in.iter().filter(|r| !out.contains(r)).collect();
457        w.push(warn(
458            section,
459            format!("shape_constraint dependency cycle among {residual:?}; using seed shapes"),
460        ));
461        for r in order_in {
462            if !out.contains(r) {
463                out.push(r.clone());
464            }
465        }
466    }
467    out
468}
469
470fn set_axis(s: &mut Solve, role: &str, axis: AxisIndex, rank: usize, v: i64) {
471    if let Some(idx) = axis_to_index(axis, rank)
472        && let Some(d) = s.dims.get_mut(role)
473        && idx < d.len()
474    {
475        d[idx] = v.max(1);
476    }
477}
478
479fn apply_atom(
480    atom: &ShapeAtom,
481    self_role: &str,
482    s: &mut Solve,
483    ranks: &HashMap<String, usize>,
484    w: &mut Vec<ImportWarning>,
485    section: &str,
486) {
487    let self_rank = *ranks.get(self_role).unwrap_or(&0);
488    match atom {
489        ShapeAtom::Rank(_) | ShapeAtom::SameRank(_) | ShapeAtom::CapacityGe { .. } => {} // rank-phase / trivial
490        ShapeAtom::SameAs(src) | ShapeAtom::BroadcastTo(src) => match s.dims.get(src).cloned() {
491            Some(src_dims) => {
492                let n = self_rank.min(src_dims.len());
493                if let Some(d) = s.dims.get_mut(self_role) {
494                    d[..n].copy_from_slice(&src_dims[..n]);
495                }
496            }
497            None => w.push(warn(
498                section,
499                format!("operand `{self_role}` references unknown role `{src}`; using seed shape"),
500            )),
501        },
502        ShapeAtom::LastDimEq(src) => match s.dims.get(src).and_then(|d| d.last().copied()) {
503            Some(sr) => set_axis(s, self_role, AxisIndex::FromEnd(1), self_rank, sr),
504            None => w.push(warn(
505                section,
506                format!(
507                    "operand `{self_role}` last_dim_eq references unknown role `{src}`; using seed"
508                ),
509            )),
510        },
511        ShapeAtom::DimEq { axis, expr } => match eval_dim_expr(expr, s, ranks, self_role) {
512            Some(v) => set_axis(s, self_role, *axis, self_rank, v),
513            None => w.push(warn(
514                section,
515                format!("operand `{self_role}` dim rule unresolved; using seed"),
516            )),
517        },
518        ShapeAtom::Divisible { lhs, rhs } => {
519            if let (Some((role, axis)), Some(v)) =
520                (as_dim_ref(lhs), eval_dim_expr(rhs, s, ranks, self_role))
521            {
522                if v > 0 {
523                    let target = role.as_deref().unwrap_or(self_role).to_string();
524                    let trank = *ranks.get(&target).unwrap_or(&0);
525                    if let Some(idx) = axis_to_index(axis, trank)
526                        && let Some(cur) = s.dims.get(&target).and_then(|d| d.get(idx).copied())
527                    {
528                        // Checked ceil-round: `cur + (v-1)` then `* v` can each
529                        // overflow i64 on an adversarial-but-parseable input;
530                        // SKIP the round (leave the axis unrounded) rather than
531                        // panic under `overflow-checks = true` (Finding 1).
532                        if let Some(rounded) = cur
533                            .checked_add(v - 1)
534                            .map(|x| x / v)
535                            .and_then(|q| q.checked_mul(v))
536                        {
537                            // §3.5 GQA distinctness (review Finding 2). A
538                            // CROSS-operand `divisible(other.dim[i], self.dim[j])`
539                            // (attention's `divisible(q.dim[1], k.dim[1])`,
540                            // Hq % Hkv == 0) seeds both operands identically, so
541                            // the coincidental round leaves target == divisor
542                            // (Hq == Hkv, the degenerate NON-grouped case).
543                            // Promote it to a genuine group ratio (Hq = 2·Hkv) so
544                            // the divisor operand is strictly SMALLER than the
545                            // target — otherwise an operand-role swap in a return
546                            // rule (`same_as(k)` vs `same_as(q)`) evaluates equal
547                            // and the shape-oracle cross-check can't discriminate
548                            // it. ONLY the cross-operand case (`target` role !=
549                            // the constraint owner); a self-target
550                            // `divisible(dim[i], lit)` (rope, matmul's shared `k`)
551                            // keeps its exact round untouched.
552                            let final_val = if target != self_role && rounded == v {
553                                rounded.checked_mul(2).unwrap_or(rounded)
554                            } else {
555                                rounded
556                            };
557                            set_axis(s, &target, axis, trank, final_val);
558                        }
559                    }
560                }
561            } else if let CostNode::Sym(k) = lhs
562                && let Some(v) = eval_dim_expr(rhs, s, ranks, self_role)
563                && v > 0
564            {
565                let e = s.sym.entry(k.clone()).or_insert(s.base);
566                if let Some(rounded) = e
567                    .checked_add(v - 1)
568                    .map(|x| x / v)
569                    .and_then(|q| q.checked_mul(v))
570                {
571                    *e = rounded;
572                }
573            }
574        }
575    }
576}
577
578/// Seed the probe combos for §3.5 shape-solver rank resolution, then APPLY the
579/// structural atoms (Task 1.3): `same_as`/`broadcast_to` copy a source
580/// operand's (already-solved) dims, `dim[i]=<expr>` evaluates an expression
581/// (reusing `cost_expr::CostNode`, including a bare shared symbol like
582/// matmul's `k`), and `divisible(dim[i], v)` rounds an axis UP to a multiple
583/// of `v`. Atoms are applied in DEPENDENCY order (Task 1.4): an operand whose
584/// `same_as=k`/`dim[i]` references another operand `k` is applied AFTER `k`,
585/// even if `k` is declared later in `inputs` — a bounded Kahn topo sort over
586/// the Task-1.3 SOLVE-KEYS, computed once (constraints don't vary by
587/// profile). A dependency cycle (`a: same_as=b`, `b: same_as=a`) never
588/// hard-errors: it degrades to ONE `cycle` `ImportWarning` and the cyclic
589/// operands are applied best-effort in source order (each copies whatever
590/// seed/partial value the other currently holds). Parses each operand's
591/// `shape_constraint` up front so a malformed-vocabulary segment hard-errors
592/// before any probe is built.
593pub fn solve_probe_shapes(
594    inputs: &[TensorDesc],
595    section: &str,
596    warnings: &mut Vec<ImportWarning>,
597) -> Result<Vec<ProbeCombo>, FkcError> {
598    // Parse each operand's constraint + compute its rank once (shared across
599    // all profiles — the rank spec doesn't vary by profile).
600    //
601    // `Solve.dims`/`ranks` are keyed by a per-operand SOLVE-KEY, not the bare
602    // display role: two operands sharing a role string (most plausibly two
603    // UNNAMED operands, both `name.unwrap_or_default() == ""`) would
604    // otherwise collide — the second insert silently overwrites the first,
605    // and both later read back the same (wrong) entry (review Finding 3).
606    // A named operand's key IS its name (so `same_as=k` / `role.dim[i]`
607    // still resolve against named-operand keys exactly as before); an
608    // unnamed operand gets a unique `#unnamed{i}` key that no `same_as=`
609    // can reference — correct, since an unnamed operand can't be
610    // referenced by name. The DISPLAY role in the returned `ProbeCombo`
611    // stays the plain (possibly empty) name, unchanged.
612    let mut parsed = Vec::with_capacity(inputs.len());
613    let mut roles = Vec::with_capacity(inputs.len());
614    let mut keys = Vec::with_capacity(inputs.len());
615    let mut ranks: HashMap<String, usize> = HashMap::with_capacity(inputs.len());
616    // Review Finding 2: two operands sharing the same EXPLICIT non-empty name
617    // collapse to one `key_to_idx` entry (last-write-wins over the
618    // `keys`/`key_to_idx` map built below), so only ONE operand's atoms are
619    // ever applied — a silent drop. This is an authoring bug (a duplicate
620    // name also makes `same_as=` ambiguous), so surface it with ONE warning
621    // per duplicated name rather than silently proceeding.
622    let mut seen_names: HashSet<String> = HashSet::with_capacity(inputs.len());
623    let mut warned_dup_names: HashSet<String> = HashSet::new();
624    for (i, d) in inputs.iter().enumerate() {
625        let operand = d.name.as_deref().unwrap_or("<unnamed>");
626        let sc = match &d.shape_constraint {
627            Some(raw) => parse_shape_constraint(raw, section, operand)?,
628            None => ShapeConstraint::default(),
629        };
630        // Soft diagnostic: a `;`-segment that didn't match any ratified §3.5
631        // keyword parses into `freetext` rather than hard-erroring (prose
632        // notes like "byte length >= 4 (one u32)" are legitimate authoring),
633        // but it should still be visible to the importer rather than
634        // vanishing silently.
635        for seg in &sc.freetext {
636            warnings.push(warn(
637                section,
638                format!("operand `{operand}` shape_constraint free text: {seg}"),
639            ));
640        }
641        let role = d.name.clone().unwrap_or_default();
642        let key = d
643            .name
644            .clone()
645            .filter(|n| !n.is_empty())
646            .unwrap_or_else(|| format!("#unnamed{i}"));
647        if let Some(name) = d.name.as_deref().filter(|n| !n.is_empty())
648            && !seen_names.insert(name.to_string())
649            && warned_dup_names.insert(name.to_string())
650        {
651            warnings.push(warn(section, format!(
652                    "operand name `{name}` is declared more than once; references to it are ambiguous and only one operand's shape constraints are applied"
653                )));
654        }
655        let rank_spec = resolve_rank_spec_field(d.rank.as_ref());
656        // Soft diagnostic: `rank:` PRESENT but unrecognized (neither a plain
657        // integer, `any`, nor a `lo..`/`lo..=hi` range) silently defaulted to
658        // rank 4 before Task 1.5 — now surfaced so a typo'd `rank:` field
659        // doesn't quietly change probe behavior.
660        if d.rank.is_some() && rank_spec.is_none() {
661            warnings.push(warn(
662                section,
663                format!("operand `{operand}` rank field is malformed; defaulting to rank 4"),
664            ));
665        }
666        let rank = rank_for_probe(rank_spec);
667        ranks.insert(key.clone(), rank);
668        roles.push(role);
669        keys.push(key);
670        parsed.push(sc);
671    }
672
673    // Task 1.4: dependency edges + topo order over SOLVE-KEYS. Constraints
674    // are the same across all 3 profiles, so this is computed ONCE — a
675    // cyclic operand set therefore warns exactly once, not once per profile.
676    let key_set: HashSet<String> = keys.iter().cloned().collect();
677    let mut edges: HashMap<String, Vec<String>> = HashMap::with_capacity(keys.len());
678    for (i, key) in keys.iter().enumerate() {
679        edges.insert(key.clone(), dep_sources(&parsed[i].atoms, &key_set));
680    }
681    let topo = topo_order(&keys, &edges, section, warnings);
682    let key_to_idx: HashMap<&String, usize> =
683        keys.iter().enumerate().map(|(i, k)| (k, i)).collect();
684
685    let mut combos = Vec::with_capacity(PROFILES.len());
686    for profile in PROFILES {
687        // 1. Seed every operand's dims into `s.dims`, keyed by its unique SOLVE-KEY.
688        let mut s = Solve {
689            dims: HashMap::with_capacity(inputs.len()),
690            sym: HashMap::new(),
691            base: profile.base,
692        };
693        for key in &keys {
694            let rank = *ranks.get(key).unwrap_or(&0);
695            let dims: Vec<i64> = (0..rank).map(|a| seed_axis(profile, a, rank)).collect();
696            s.dims.insert(key.clone(), dims);
697        }
698        // 2. Apply every operand's atoms, in DEPENDENCY order (Task 1.4): a
699        //    `same_as=k` operand resolves only after `k` does, regardless of
700        //    declaration order; cyclic operands (no valid order) apply
701        //    best-effort in source order (see `topo_order`).
702        for key in &topo {
703            if let Some(&i) = key_to_idx.get(key) {
704                for atom in &parsed[i].atoms {
705                    apply_atom(atom, key, &mut s, &ranks, warnings, section);
706                }
707            }
708        }
709        // 3. Read the solved dims back into Shape, paired with the operand's
710        //    dtype. Lookup goes through the SOLVE-KEY; the emitted tuple's
711        //    role is the plain DISPLAY name.
712        let mut combo: ProbeCombo = Vec::with_capacity(inputs.len());
713        for ((d, role), key) in inputs.iter().zip(roles.iter()).zip(keys.iter()) {
714            let dims: Vec<usize> = s
715                .dims
716                .get(key)
717                .map(|v| v.iter().map(|&x| x.max(1) as usize).collect())
718                .unwrap_or_default();
719            combo.push((role.clone(), Shape::from_dims(&dims), first_probe_dtype(d)));
720        }
721        combos.push(combo);
722    }
723    Ok(combos)
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use crate::fkc::cost_expr::CostNode;
730
731    fn desc(name: &str, dtypes: &[&str], rank: Option<u64>) -> crate::fkc::schema::TensorDesc {
732        crate::fkc::schema::TensorDesc {
733            name: Some(name.into()),
734            optional: false,
735            dtypes: dtypes.iter().map(|s| s.to_string()).collect(),
736            dtype_class: None,
737            layout: None,
738            rank: rank.map(|r| serde_yaml_ng::Value::Number(r.into())),
739            shape_constraint: None,
740            fdx: None,
741            device: None,
742            substrate: None,
743        }
744    }
745
746    #[test]
747    fn seed_unconstrained_operands_over_three_profiles() {
748        use fuel_ir::Shape;
749        let inputs = vec![
750            desc("lhs", &["F32"], Some(2)),
751            desc("rhs", &["F32"], Some(2)),
752        ];
753        let mut w = Vec::new();
754        let combos = solve_probe_shapes(&inputs, "s", &mut w).unwrap();
755        assert_eq!(combos.len(), 3);
756        assert_eq!(combos[0][0].1, Shape::from_dims(&[2, 2])); // profile A all-2
757        assert_eq!(combos[1][0].1, Shape::from_dims(&[4, 3])); // profile B all-4, last->3
758        assert_eq!(combos[2][0].1, Shape::from_dims(&[8, 8])); // profile C all-8
759        assert!(w.is_empty());
760    }
761
762    #[test]
763    fn rank_any_defaults_to_4_and_open_range_uses_lo() {
764        let any = desc("a", &["F32"], None); // no rank ⇒ Any ⇒ 4
765        assert_eq!(
766            solve_probe_shapes(&[any], "s", &mut Vec::new()).unwrap()[0][0]
767                .1
768                .rank(),
769            4
770        );
771        let mut open = desc("b", &["F32"], None);
772        open.rank = Some(serde_yaml_ng::Value::String("2..".into()));
773        assert_eq!(
774            solve_probe_shapes(&[open], "s", &mut Vec::new()).unwrap()[0][0]
775                .1
776                .rank(),
777            2
778        );
779    }
780
781    #[test]
782    fn parse_covers_vocab_freetext_and_rejects_malformed_vocab() {
783        assert_eq!(
784            parse_shape_constraint("same_as=k", "s", "v").unwrap().atoms,
785            vec![ShapeAtom::SameAs("k".into())]
786        );
787        assert_eq!(
788            parse_shape_constraint("same_rank=k", "s", "v")
789                .unwrap()
790                .atoms,
791            vec![ShapeAtom::SameRank("k".into())]
792        );
793        assert_eq!(
794            parse_shape_constraint("broadcast_to=x", "s", "v")
795                .unwrap()
796                .atoms,
797            vec![ShapeAtom::BroadcastTo("x".into())]
798        );
799        assert_eq!(
800            parse_shape_constraint("last_dim_eq=x", "s", "v")
801                .unwrap()
802                .atoms,
803            vec![ShapeAtom::LastDimEq("x".into())]
804        );
805        assert_eq!(
806            parse_shape_constraint("rank=4", "s", "x").unwrap().atoms,
807            vec![ShapeAtom::Rank(RankSpec::Exact(4))]
808        );
809        assert_eq!(
810            parse_shape_constraint("rank=2..=4", "s", "x")
811                .unwrap()
812                .atoms,
813            vec![ShapeAtom::Rank(RankSpec::Range { lo: 2, hi: Some(4) })]
814        );
815        // `Any` and open-ended `Range{hi:None}` (parse_rank_spec branches otherwise unpinned)
816        assert_eq!(
817            parse_shape_constraint("rank=any", "s", "x").unwrap().atoms,
818            vec![ShapeAtom::Rank(RankSpec::Any)]
819        );
820        assert_eq!(
821            parse_shape_constraint("rank=2..", "s", "x").unwrap().atoms,
822            vec![ShapeAtom::Rank(RankSpec::Range { lo: 2, hi: None })]
823        );
824        // negative axis + bare-symbol RHS (linear-quant.fkc.md:108)
825        let a = parse_shape_constraint("dim[-1]=k; same_rank=b", "linear", "a").unwrap();
826        assert_eq!(a.atoms.len(), 2);
827        match &a.atoms[0] {
828            ShapeAtom::DimEq { axis, expr } => {
829                assert_eq!(*axis, AxisIndex::FromEnd(1));
830                assert_eq!(*expr, CostNode::Sym("k".into()));
831            }
832            other => panic!("got {other:?}"),
833        }
834        assert_eq!(a.atoms[1], ShapeAtom::SameRank("b".into()));
835        assert!(matches!(
836            parse_shape_constraint("divisible(q.dim[2], k.dim[2])", "f", "k")
837                .unwrap()
838                .atoms[0],
839            ShapeAtom::Divisible { .. }
840        ));
841        assert!(matches!(
842            parse_shape_constraint("capacity_ge(dim[0], seqlen)", "f", "kv")
843                .unwrap()
844                .atoms[0],
845            ShapeAtom::CapacityGe { .. }
846        ));
847        // bracket-depth-aware split_two_args: the outer `divisible(...)` comma
848        // must split at DEPTH 0, not at the nested comma inside `foo(a, b)`. A
849        // naive `inner.split_once(',')` would split lhs="foo(a" / rhs=" b), c" —
850        // "foo(a" fails to parse (unbalanced '(') and the whole call errors.
851        let nested = parse_shape_constraint("divisible(foo(a, b), c)", "f", "k").unwrap();
852        assert_eq!(
853            nested.atoms,
854            vec![ShapeAtom::Divisible {
855                lhs: CostNode::Call {
856                    name: "foo".into(),
857                    args: vec![CostNode::Sym("a".into()), CostNode::Sym("b".into())],
858                },
859                rhs: CostNode::Sym("c".into()),
860            }]
861        );
862        // free text: valid-vocab head + prose tail (shape-ops.fkc.md:639) — NOT rejected
863        let mixed = parse_shape_constraint(
864            "same_as=out; read-modify-written in place (this operand IS the output)",
865            "shape-ops",
866            "dst",
867        )
868        .unwrap();
869        assert_eq!(mixed.atoms, vec![ShapeAtom::SameAs("out".into())]);
870        assert_eq!(mixed.freetext.len(), 1);
871        // pure free text (shape-ops.fkc.md:721)
872        let ft = parse_shape_constraint("byte length >= 4 (one u32)", "shape-ops", "seed").unwrap();
873        assert!(ft.atoms.is_empty());
874        assert_eq!(ft.freetext.len(), 1);
875        // symbolic index + `==` (shape-ops.fkc.md:98) ⇒ free text, not reject
876        let sym_i = parse_shape_constraint("dim[i] == in_shape[i]", "shape-ops", "out").unwrap();
877        assert!(sym_i.atoms.is_empty());
878        assert_eq!(sym_i.freetext.len(), 1);
879        // HARD reject: keyword-committed segment with malformed argument
880        assert!(matches!(
881            parse_shape_constraint("rank=banana", "s", "x").unwrap_err(),
882            FkcError::UnparseableShapeConstraint { .. }
883        ));
884        assert!(parse_shape_constraint("divisible(x.dim[0]", "s", "x").is_err()); // unclosed (
885        assert!(parse_shape_constraint("dim[0]=", "s", "x").is_err()); // committed, empty rhs
886    }
887
888    #[test]
889    fn solve_same_as_copies_dims_and_divisible_rounds_up() {
890        use fuel_ir::Shape;
891        let mut k = desc("k", &["F32"], Some(3));
892        k.shape_constraint = Some("divisible(dim[0], 8)".into());
893        let mut v = desc("v", &["F32"], Some(3));
894        v.shape_constraint = Some("same_as=k".into());
895        let combos = solve_probe_shapes(&[k, v], "s", &mut Vec::new()).unwrap();
896        let a = &combos[0]; // profile A base 2 ⇒ ceil(2/8)*8 = 8
897        let ks = &a.iter().find(|(r, _, _)| r == "k").unwrap().1;
898        let vs = &a.iter().find(|(r, _, _)| r == "v").unwrap().1;
899        assert_eq!(ks, &Shape::from_dims(&[8, 2, 2]));
900        assert_eq!(vs, ks);
901    }
902
903    // Replaces `dim_eq_bare_symbol_is_shared_across_operands` (review Finding
904    // 2): that test only ever read the SAME seeded value (profile A seeds
905    // every axis of a rank-2 operand to `2`), so it passed identically
906    // whether or not the `sym` env was actually shared — it never exercised
907    // sharing. This version MUTATES the shared symbol on operand `a`
908    // (`divisible(k, 8)`, bare-symbol lhs, base 2 -> rounds to 8) and reads
909    // it back on operand `b` (`dim[-1]=k`); it only passes if `b` observes
910    // the write `a` made, i.e. if `Solve.sym` is truly one shared `&mut`
911    // table across all operands within a profile, not a fresh env per
912    // operand.
913    #[test]
914    fn bare_symbol_is_shared_mutably_across_operands() {
915        // operand a bumps the shared symbol k (2 -> 8 via divisible-on-symbol);
916        // operand b then READS k via dim[-1]=k. b sees 8 ONLY if the sym env is
917        // shared. With independent per-operand sym envs, b would seed its own k=2.
918        let mut a = desc("a", &["F32"], Some(2));
919        a.shape_constraint = Some("divisible(k, 8)".into()); // bumps shared symbol k
920        let mut b = desc("b", &["F32"], Some(2));
921        b.shape_constraint = Some("dim[-1]=k".into()); // b.last = shared k
922        let combos = solve_probe_shapes(&[a, b], "s", &mut Vec::new()).unwrap();
923        let a0 = &combos[0]; // profile A, base 2
924        let bk = a0.iter().find(|(r, _, _)| r == "b").unwrap().1.dims()[1];
925        assert_eq!(
926            bk, 8,
927            "b's last axis must observe the SHARED, bumped k (=8), not a fresh k=2"
928        );
929    }
930
931    /// Review Finding 1: unchecked i64 arithmetic must not panic under
932    /// `overflow-checks = true` (the default `cargo test` profile) on a
933    /// syntactically-valid-but-huge `dim[i]=<expr>`. The multiply overflows
934    /// i64 (10^6 * 10^6 * 10^6 * 10^6 = 10^24 >> i64::MAX) so `eval_dim_expr`
935    /// must return `None` (checked op propagates via `?`), degrading to a
936    /// warning + the seed shape rather than crashing.
937    #[test]
938    fn dim_expr_overflow_degrades_without_panic() {
939        let mut a = desc("a", &["F32"], Some(2));
940        a.shape_constraint = Some("dim[0]=1000000*1000000*1000000*1000000".into());
941        let mut w = Vec::new();
942        let result = solve_probe_shapes(&[a], "s", &mut w);
943        assert!(
944            result.is_ok(),
945            "overflow must degrade gracefully, not panic or error"
946        );
947        assert!(
948            !w.is_empty(),
949            "the unresolved (overflowed) atom should surface an ImportWarning"
950        );
951    }
952
953    /// Fix-pass-2 residual: `i64::MIN / -1` (and `% -1`) panics UNCONDITIONALLY
954    /// — in every cargo profile, release included — because that trap is
955    /// hard-baked into the CPU `idiv`/`div` instruction, not gated by
956    /// `overflow-checks`. `-i64::MIN` additionally panics under
957    /// `overflow-checks = true` (the default `cargo test` profile). Both must
958    /// degrade to a warning + the untouched seed shape via `checked_div` /
959    /// `checked_neg` rather than crash the FKC importer at shape-solve time.
960    ///
961    /// The literal `9223372036854775807` (i64::MAX, 19 digits) round-trips
962    /// through `CostNode::Lit(f64)` as `9223372036854775808.0` (nearest
963    /// representable `f64`, = 2^63, since doubles can't represent all
964    /// 19-digit integers exactly) and `as i64`-saturates back down to
965    /// `i64::MAX` — so `-9223372036854775807 - 1` evaluates to EXACTLY
966    /// `i64::MIN` (`checked_sub` sees `-i64::MAX - 1`, which fits). That
967    /// lands the div/rem and neg cases squarely on the target operand:
968    /// `dim[0]=(i64::MIN)/-1` drives the `Div` arm with `l=i64::MIN, r=-1`;
969    /// `dim[0]=-(i64::MIN)` drives the `Neg` arm with `x=i64::MIN`.
970    #[test]
971    fn pathological_int_min_div_and_neg_degrade_without_panic() {
972        use fuel_ir::Shape;
973        let mut a = desc("a", &["F32"], Some(1));
974        a.shape_constraint = Some("dim[0]=(-9223372036854775807-1)/-1".into()); // i64::MIN / -1
975        let mut wa = Vec::new();
976        let ra = solve_probe_shapes(&[a], "s", &mut wa);
977        assert!(ra.is_ok(), "MIN/-1 must degrade, not panic");
978        assert!(
979            !wa.is_empty(),
980            "the unresolved (MIN/-1) atom should surface an ImportWarning"
981        );
982        // Degraded ⇒ set_axis was never called ⇒ profile A's rank-1 operand
983        // keeps its seeded shape (base 2; odd_last doesn't apply to profile A).
984        assert_eq!(ra.unwrap()[0][0].1, Shape::from_dims(&[2]));
985
986        let mut b = desc("b", &["F32"], Some(1));
987        b.shape_constraint = Some("dim[0]=-(-9223372036854775807-1)".into()); // -(i64::MIN)
988        let mut wb = Vec::new();
989        let rb = solve_probe_shapes(&[b], "s", &mut wb);
990        assert!(rb.is_ok(), "-(i64::MIN) must degrade, not panic");
991        assert!(
992            !wb.is_empty(),
993            "the unresolved (Neg MIN) atom should surface an ImportWarning"
994        );
995        assert_eq!(rb.unwrap()[0][0].1, Shape::from_dims(&[2]));
996    }
997
998    /// Review Finding 3: `Solve.dims`/`ranks` must be keyed by a unique
999    /// per-operand SOLVE-KEY, not the bare (possibly-empty) role string —
1000    /// otherwise two unnamed operands (`name.unwrap_or_default() == ""` for
1001    /// both) collide on the same HashMap entry and the second silently
1002    /// overwrites the first. Under the pre-fix role-"" keying, BOTH the
1003    /// `ranks[""]` entry and the `s.dims[""]` entry are only ever the
1004    /// LAST-inserted unnamed operand's — so operand 0 would incorrectly
1005    /// read back operand 1's rank-3 shape (first assert below fails: actual
1006    /// rank 3, expected 2). With a unique `#unnamed{i}` key per operand,
1007    /// each keeps its own rank/dims.
1008    #[test]
1009    fn two_unnamed_operands_are_not_aliased() {
1010        // Two unnamed operands with DIFFERENT ranks must get their own shapes,
1011        // not alias to the last-inserted rank.
1012        let a = crate::fkc::schema::TensorDesc {
1013            name: None,
1014            optional: false,
1015            dtypes: vec!["F32".into()],
1016            dtype_class: None,
1017            layout: None,
1018            rank: Some(serde_yaml_ng::Value::Number(2u64.into())),
1019            shape_constraint: None,
1020            fdx: None,
1021            device: None,
1022            substrate: None,
1023        };
1024        let mut b = a.clone();
1025        b.rank = Some(serde_yaml_ng::Value::Number(3u64.into()));
1026        let combos = solve_probe_shapes(&[a, b], "s", &mut Vec::new()).unwrap();
1027        assert_eq!(
1028            combos[0][0].1.rank(),
1029            2,
1030            "first unnamed operand keeps rank 2"
1031        );
1032        assert_eq!(
1033            combos[0][1].1.rank(),
1034            3,
1035            "second unnamed operand keeps rank 3, not aliased to 2"
1036        );
1037    }
1038
1039    #[test]
1040    fn dependency_ordering_resolves_and_cycles_fall_back_with_warning() {
1041        use fuel_ir::Shape;
1042        // ordering: v depends on k even though v is listed first
1043        let mut k = desc("k", &["F32"], Some(2));
1044        k.shape_constraint = Some("divisible(dim[0], 8)".into());
1045        let mut v = desc("v", &["F32"], Some(2));
1046        v.shape_constraint = Some("same_as=k".into());
1047        let combos = solve_probe_shapes(&[v, k], "s", &mut Vec::new()).unwrap();
1048        assert_eq!(
1049            combos[0].iter().find(|(r, _, _)| r == "v").unwrap().1,
1050            Shape::from_dims(&[8, 2])
1051        );
1052        // cycle: no panic, no Err, Ok + a `cycle` warning + seed shapes
1053        let mut ca = desc("a", &["F32"], Some(2));
1054        ca.shape_constraint = Some("same_as=b".into());
1055        let mut cb = desc("b", &["F32"], Some(2));
1056        cb.shape_constraint = Some("same_as=a".into());
1057        let mut w = Vec::new();
1058        let combos = solve_probe_shapes(&[ca, cb], "cyc", &mut w).unwrap();
1059        assert_eq!(combos.len(), 3);
1060        assert_eq!(combos[0][0].1, Shape::from_dims(&[2, 2]));
1061        assert!(
1062            w.iter().any(|x| x.message.to_lowercase().contains("cycle")),
1063            "warns: {w:?}"
1064        );
1065    }
1066
1067    /// Fix-pass Finding 1: `dep_sources` must dedup a predecessor referenced
1068    /// MORE THAN ONCE (here, `k` appears twice in one `dim[0]=k.dim[0]+k.dim[1]`
1069    /// expression) so `topo_order`'s Kahn indegree count matches the number
1070    /// of times its decrement pass can ever fire (once per DISTINCT
1071    /// predecessor, via `deps.contains(cur)` membership, not a per-occurrence
1072    /// count). Undeduped, `x`'s indegree is counted as 2 but only ever
1073    /// decremented once (when `k` — itself a leaf, indegree 0 — is
1074    /// processed), so `x` never reaches indegree 0 and is misclassified as a
1075    /// cycle residual: a spurious `cycle` warning on a genuinely acyclic DAG.
1076    /// x before k in `inputs` order proves the fix also still applies the
1077    /// correct DEPENDENCY order (k resolves before x), not source order.
1078    #[test]
1079    fn double_reference_to_one_predecessor_is_not_a_false_cycle() {
1080        // x references k's dims TWICE — must NOT be flagged as a cycle, and must
1081        // resolve AFTER k (topo order), not fall back to source order.
1082        let mut x = desc("x", &["F32"], Some(2));
1083        x.shape_constraint = Some("dim[0]=k.dim[0]+k.dim[1]".into());
1084        let mut k = desc("k", &["F32"], Some(2));
1085        k.shape_constraint = Some("divisible(dim[0], 8)".into()); // k.dim0: 2 -> 8
1086        let mut w = Vec::new();
1087        let combos = solve_probe_shapes(&[x, k], "s", &mut w).unwrap(); // x before k in source
1088        assert!(
1089            !w.iter().any(|m| m.message.to_lowercase().contains("cycle")),
1090            "a double reference to one predecessor is NOT a cycle: {w:?}"
1091        );
1092        // x.dim[0] = k.dim[0](8) + k.dim[1](2) = 10 — proves k resolved before x
1093        let a0 = &combos[0];
1094        let x0 = a0.iter().find(|(r, _, _)| r == "x").unwrap().1.dims()[0];
1095        assert_eq!(
1096            x0, 10,
1097            "x.dim0 = rounded k.dim0(8) + k.dim1(2); proves k-before-x ordering"
1098        );
1099    }
1100
1101    /// Fix-pass Finding 2: two operands sharing the same EXPLICIT non-empty
1102    /// name collapse to one `key_to_idx` entry (last-write-wins), silently
1103    /// dropping one operand's shape-constraint atoms. That's an authoring
1104    /// bug (a duplicate name also makes `same_as=` ambiguous), so it must
1105    /// surface as an `ImportWarning` naming the duplicated operand, not
1106    /// silently proceed. Must not panic or error.
1107    #[test]
1108    fn duplicate_operand_name_emits_a_warning() {
1109        let a = desc("dup", &["F32"], Some(2));
1110        let b = desc("dup", &["F32"], Some(2));
1111        let mut w = Vec::new();
1112        let _ = solve_probe_shapes(&[a, b], "s", &mut w).unwrap(); // no panic, Ok
1113        assert!(
1114            w.iter().any(|m| m.message.contains("dup")
1115                && m.message.to_lowercase().contains("more than once")),
1116            "duplicate operand name must be surfaced as a warning: {w:?}"
1117        );
1118    }
1119
1120    /// Task 1.5: `first_probe_dtype`'s dtype pick (first-declared dtype wins;
1121    /// else first `dtype_class` expansion), the ALREADY-SHIPPED (Task 1.3)
1122    /// unknown-role warning for `same_as=out` (an output role absent from
1123    /// `inputs`), and the NEW free-text soft-diagnostic warning (a `;`-segment
1124    /// that parses to `ShapeConstraint::freetext` rather than a ratified atom
1125    /// must still surface something to the importer, not vanish silently).
1126    #[test]
1127    fn dtype_pick_and_soft_fallback_warnings() {
1128        use fuel_ir::{DType, Shape};
1129        assert_eq!(
1130            solve_probe_shapes(
1131                &[desc("a", &["BF16", "F16", "F32"], Some(1))],
1132                "s",
1133                &mut Vec::new()
1134            )
1135            .unwrap()[0][0]
1136                .2,
1137            DType::BF16
1138        );
1139        let mut c = desc("a", &[], Some(1));
1140        c.dtype_class = Some("float".into());
1141        assert_eq!(
1142            solve_probe_shapes(&[c], "s", &mut Vec::new()).unwrap()[0][0].2,
1143            DType::BF16
1144        );
1145        // same_as=out (output role, absent from inputs) ⇒ seed shape + warning naming `out`
1146        let mut r = desc("residual", &["F32"], Some(2));
1147        r.shape_constraint = Some("same_as=out".into());
1148        let mut w = Vec::new();
1149        let combos = solve_probe_shapes(&[r], "norm-softmax", &mut w).unwrap();
1150        assert_eq!(combos[0][0].1, Shape::from_dims(&[2, 2]));
1151        assert!(w.iter().any(|x| x.message.contains("out")));
1152        // pure free-text constraint ⇒ warning, still Ok
1153        let mut f = desc("seed", &["U8"], Some(1));
1154        f.shape_constraint = Some("byte length >= 4 (one u32)".into());
1155        let mut w2 = Vec::new();
1156        solve_probe_shapes(&[f], "shape-ops", &mut w2).unwrap();
1157        assert!(!w2.is_empty());
1158    }
1159
1160    /// Task 1.5: `rank:` PRESENT but unrecognized (not an integer, not `any`,
1161    /// not a `lo..`/`lo..=hi` range) must warn (naming the operand) AND still
1162    /// default to rank 4 (the pre-existing silent default in
1163    /// `rank_for_probe`, now surfaced) — never a hard error.
1164    #[test]
1165    fn malformed_rank_field_warns_and_defaults_to_rank_4() {
1166        let mut bad = desc("weird", &["F32"], None);
1167        bad.rank = Some(serde_yaml_ng::Value::String("banana".into()));
1168        let mut w = Vec::new();
1169        let combos = solve_probe_shapes(&[bad], "s", &mut w).unwrap();
1170        assert_eq!(
1171            combos[0][0].1.rank(),
1172            4,
1173            "malformed rank field still defaults to rank 4"
1174        );
1175        assert!(
1176            w.iter()
1177                .any(|x| x.message.contains("weird") && x.message.to_lowercase().contains("rank")),
1178            "malformed rank field must surface a warning naming the operand: {w:?}"
1179        );
1180    }
1181}