1use 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} #[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
56pub 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
80fn 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 out.atoms
130 .push(ShapeAtom::Rank(parse_rank_spec(r).ok_or_else(unparse)?));
131 continue;
132 }
133 if seg.starts_with("divisible(") {
134 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 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 (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 _ => {
184 out.freetext.push(seg.to_string());
185 continue;
186 }
187 }
188 }
189 out.freetext.push(seg.to_string()); }
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 }, SeedProfile {
205 base: 4,
206 odd_last: true,
207 }, SeedProfile {
209 base: 8,
210 odd_last: false,
211 }, ];
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, }
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}
235fn 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
258struct Solve {
263 dims: HashMap<String, Vec<i64>>,
264 sym: HashMap<String, i64>,
265 base: i64,
266}
267
268fn 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
302fn 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 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)), CostNode::Call { .. } => None,
343 }
344}
345
346fn warn(section: &str, message: String) -> ImportWarning {
347 ImportWarning {
348 section: section.into(),
349 message,
350 }
351}
352
353fn 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 deps.sort();
409 deps.dedup();
410 deps
411}
412
413fn 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 { .. } => {} 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 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 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
578pub fn solve_probe_shapes(
594 inputs: &[TensorDesc],
595 section: &str,
596 warnings: &mut Vec<ImportWarning>,
597) -> Result<Vec<ProbeCombo>, FkcError> {
598 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 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 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 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 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 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 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 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])); assert_eq!(combos[1][0].1, Shape::from_dims(&[4, 3])); assert_eq!(combos[2][0].1, Shape::from_dims(&[8, 8])); 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); 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 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 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 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 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 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 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 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()); assert!(parse_shape_constraint("dim[0]=", "s", "x").is_err()); }
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]; 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 #[test]
914 fn bare_symbol_is_shared_mutably_across_operands() {
915 let mut a = desc("a", &["F32"], Some(2));
919 a.shape_constraint = Some("divisible(k, 8)".into()); let mut b = desc("b", &["F32"], Some(2));
921 b.shape_constraint = Some("dim[-1]=k".into()); let combos = solve_probe_shapes(&[a, b], "s", &mut Vec::new()).unwrap();
923 let a0 = &combos[0]; 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 #[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 #[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()); 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 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()); 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 #[test]
1009 fn two_unnamed_operands_are_not_aliased() {
1010 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 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 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 #[test]
1079 fn double_reference_to_one_predecessor_is_not_a_false_cycle() {
1080 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()); let mut w = Vec::new();
1087 let combos = solve_probe_shapes(&[x, k], "s", &mut w).unwrap(); 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 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 #[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(); 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 #[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 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 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 #[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}