1use super::coordset::{CoordKind, CoordSet};
27use super::info::{
28 ConstValue, Determinism, Factorization, Monotonicity, OpaqueReason, PerAxisMap, PredicateInfo,
29 RangeConstraint,
30};
31
32pub fn extract_coord_refs(predicate: &str) -> Vec<String> {
35 let mut out = Vec::new();
36 let bytes = predicate.as_bytes();
37 let mut i = 0;
38 while i < bytes.len() {
39 if bytes[i] == b'{'
40 && let Some(close) = predicate[i + 1..].find('}')
41 {
42 let name = predicate[i + 1..i + 1 + close].trim();
43 if !name.is_empty()
44 && name.chars().all(|c| c.is_alphanumeric() || c == '_')
45 && !out.contains(&name.to_string())
46 {
47 out.push(name.to_string());
48 }
49 i += close + 2;
50 continue;
51 }
52 i += 1;
53 }
54 out
55}
56
57pub fn recognize(predicate: &str, coords: &CoordSet) -> PredicateInfo {
60 let coord_refs = extract_coord_refs(predicate);
61
62 for r in &coord_refs {
66 if matches!(coords.get(r).map(|c| c.kind), Some(CoordKind::Continuous)) {
67 return PredicateInfo {
68 factorization: Factorization::Opaque(OpaqueReason::Continuous),
69 monotonicity: PerAxisMap::new(),
70 range_constraint: PerAxisMap::new(),
71 determinism: Determinism::Deterministic,
72 coords_referenced: coord_refs,
73 };
74 }
75 }
76
77 let trimmed = predicate.trim();
88
89 if trimmed.eq_ignore_ascii_case("true") {
92 return PredicateInfo {
93 factorization: Factorization::PerAxis(PerAxisMap::new()),
94 monotonicity: PerAxisMap::new(),
95 range_constraint: PerAxisMap::new(),
96 determinism: Determinism::Deterministic,
97 coords_referenced: coord_refs,
98 };
99 }
100 if trimmed.eq_ignore_ascii_case("false") {
101 return PredicateInfo {
102 factorization: Factorization::Conjunctive(vec!["false".to_string()]),
103 monotonicity: PerAxisMap::new(),
104 range_constraint: PerAxisMap::new(),
105 determinism: Determinism::Deterministic,
106 coords_referenced: coord_refs,
107 };
108 }
109
110 if let Some(parts) = split_top_level(trimmed, "&&") {
112 return recognize_conjunction(&parts, coords, coord_refs);
113 }
114
115 if let Some(parts) = split_top_level(trimmed, "||") {
117 return recognize_disjunction(&parts, coords, coord_refs);
118 }
119
120 if let Some(inner) = trimmed.strip_prefix('!') {
122 let inner_info = recognize(inner.trim(), coords);
123 return invert_predicate(&inner_info, coord_refs);
124 }
125
126 if let Some(info) = recognize_discrete_set(trimmed, coords, &coord_refs) {
128 return info;
129 }
130
131 if let Some(info) = recognize_per_axis_comparison(trimmed, coords, &coord_refs) {
136 return info;
137 }
138
139 if let Some(info) = recognize_cross_axis_comparison(trimmed, coords, &coord_refs) {
141 return info;
142 }
143
144 PredicateInfo {
146 factorization: Factorization::Opaque(OpaqueReason::UnknownPattern),
147 monotonicity: PerAxisMap::new(),
148 range_constraint: PerAxisMap::new(),
149 determinism: classify_determinism(trimmed),
150 coords_referenced: coord_refs,
151 }
152}
153
154fn classify_determinism(predicate: &str) -> Determinism {
159 const NONDET_FUNCTIONS: &[&str] = &[
160 "random",
161 "rand",
162 "pcg(",
163 "pcg_stream(",
164 "now(",
165 "time(",
166 "uuid(",
167 "thread_id(",
168 "wall_clock(",
169 ];
170 let lower = predicate.to_lowercase();
171 for fn_name in NONDET_FUNCTIONS {
172 if lower.contains(fn_name) {
173 return Determinism::Opaque;
174 }
175 }
176 Determinism::Deterministic
177}
178
179const COMPARISON_OPS: &[(&str, ComparisonKind)] = &[
182 ("==", ComparisonKind::Eq),
183 ("!=", ComparisonKind::Ne),
184 ("<=", ComparisonKind::Le),
185 (">=", ComparisonKind::Ge),
186 ("<", ComparisonKind::Lt),
188 (">", ComparisonKind::Gt),
189];
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum ComparisonKind {
193 Eq,
194 Ne,
195 Lt,
196 Le,
197 Gt,
198 Ge,
199}
200
201fn recognize_per_axis_comparison(
202 predicate: &str,
203 coords: &CoordSet,
204 coord_refs: &[String],
205) -> Option<PredicateInfo> {
206 let trimmed = predicate.trim();
208 for (op_str, op_kind) in COMPARISON_OPS {
209 if let Some((lhs, rhs)) = split_top_level_op(trimmed, op_str) {
210 let lhs = lhs.trim();
211 let rhs = rhs.trim();
212 if let Some(name) = strip_curly(lhs)
214 && let Some(value) = parse_literal(rhs)
215 && coords.contains(&name)
216 {
217 return Some(per_axis_info(&name, *op_kind, value, coord_refs));
218 }
219 if let Some(name) = strip_curly(rhs)
221 && let Some(value) = parse_literal(lhs)
222 && coords.contains(&name)
223 {
224 let inv = invert_op_position(*op_kind);
225 return Some(per_axis_info(&name, inv, value, coord_refs));
226 }
227 }
228 }
229 None
230}
231
232fn per_axis_info(
233 axis: &str,
234 op: ComparisonKind,
235 rhs: ConstValue,
236 coord_refs: &[String],
237) -> PredicateInfo {
238 let mut factor = PerAxisMap::new();
239 factor.insert(
240 axis,
241 format!("{{{axis}}} {} {}", op_str(op), const_repr(&rhs)),
242 );
243
244 let mut mono = PerAxisMap::new();
245 let direction = match op {
246 ComparisonKind::Lt | ComparisonKind::Le => Monotonicity::Decreasing,
247 ComparisonKind::Gt | ComparisonKind::Ge => Monotonicity::Increasing,
248 ComparisonKind::Eq | ComparisonKind::Ne => Monotonicity::None,
249 };
250 if !matches!(direction, Monotonicity::None) {
251 mono.insert(axis, direction);
252 }
253
254 let mut range = PerAxisMap::new();
255 let constraint = match op {
256 ComparisonKind::Eq => RangeConstraint::Discrete(vec![rhs.clone()]),
257 ComparisonKind::Ne => RangeConstraint::None,
258 ComparisonKind::Lt => RangeConstraint::Bounded {
259 lo: None,
260 hi: Some(rhs.clone()),
261 lo_inclusive: false,
262 hi_inclusive: false,
263 },
264 ComparisonKind::Le => RangeConstraint::Bounded {
265 lo: None,
266 hi: Some(rhs.clone()),
267 lo_inclusive: false,
268 hi_inclusive: true,
269 },
270 ComparisonKind::Gt => RangeConstraint::Bounded {
271 lo: Some(rhs.clone()),
272 hi: None,
273 lo_inclusive: false,
274 hi_inclusive: false,
275 },
276 ComparisonKind::Ge => RangeConstraint::Bounded {
277 lo: Some(rhs.clone()),
278 hi: None,
279 lo_inclusive: true,
280 hi_inclusive: false,
281 },
282 };
283 range.insert(axis, constraint);
284
285 PredicateInfo {
286 factorization: Factorization::PerAxis(factor),
287 monotonicity: mono,
288 range_constraint: range,
289 determinism: Determinism::Deterministic,
290 coords_referenced: coord_refs.to_vec(),
291 }
292}
293
294fn invert_op_position(op: ComparisonKind) -> ComparisonKind {
295 match op {
296 ComparisonKind::Lt => ComparisonKind::Gt,
297 ComparisonKind::Le => ComparisonKind::Ge,
298 ComparisonKind::Gt => ComparisonKind::Lt,
299 ComparisonKind::Ge => ComparisonKind::Le,
300 ComparisonKind::Eq => ComparisonKind::Eq,
301 ComparisonKind::Ne => ComparisonKind::Ne,
302 }
303}
304
305fn op_str(op: ComparisonKind) -> &'static str {
306 match op {
307 ComparisonKind::Eq => "==",
308 ComparisonKind::Ne => "!=",
309 ComparisonKind::Lt => "<",
310 ComparisonKind::Le => "<=",
311 ComparisonKind::Gt => ">",
312 ComparisonKind::Ge => ">=",
313 }
314}
315
316fn const_repr(v: &ConstValue) -> String {
317 match v {
318 ConstValue::Int(n) => n.to_string(),
319 ConstValue::Float(f) => f.to_string(),
320 ConstValue::String(s) => format!("\"{s}\""),
321 ConstValue::Bool(b) => b.to_string(),
322 }
323}
324
325fn recognize_cross_axis_comparison(
328 predicate: &str,
329 coords: &CoordSet,
330 coord_refs: &[String],
331) -> Option<PredicateInfo> {
332 for (op_str, _) in COMPARISON_OPS {
333 if let Some((lhs, rhs)) = split_top_level_op(predicate.trim(), op_str)
334 && let (Some(a), Some(b)) = (strip_curly(lhs.trim()), strip_curly(rhs.trim()))
335 && coords.contains(&a)
336 && coords.contains(&b)
337 && a != b
338 {
339 return Some(PredicateInfo {
340 factorization: Factorization::Conjunctive(vec![predicate.trim().to_string()]),
341 monotonicity: PerAxisMap::new(),
342 range_constraint: PerAxisMap::new(),
343 determinism: Determinism::Deterministic,
344 coords_referenced: coord_refs.to_vec(),
345 });
346 }
347 }
348 None
349}
350
351fn recognize_conjunction(
354 parts: &[String],
355 coords: &CoordSet,
356 coord_refs: Vec<String>,
357) -> PredicateInfo {
358 let sub_infos: Vec<PredicateInfo> = parts.iter().map(|p| recognize(p, coords)).collect();
359
360 let mut merged_factor = PerAxisMap::<String>::new();
363 let mut all_per_axis = true;
364 for info in &sub_infos {
365 match &info.factorization {
366 Factorization::PerAxis(m) => {
367 for (axis, expr) in m.iter() {
368 if merged_factor.get(axis).is_some() {
369 let existing = merged_factor.get(axis).cloned().unwrap();
372 merged_factor.insert(axis, format!("({existing}) && ({expr})"));
373 } else {
374 merged_factor.insert(axis, expr.to_string());
375 }
376 }
377 }
378 _ => {
379 all_per_axis = false;
380 break;
381 }
382 }
383 }
384
385 let mut merged_mono = PerAxisMap::<Monotonicity>::new();
389 let mut merged_range = PerAxisMap::<RangeConstraint>::new();
390 for info in &sub_infos {
391 for (axis, dir) in info.monotonicity.iter() {
392 match merged_mono.get(axis).copied() {
393 None => merged_mono.insert(axis, *dir),
394 Some(existing) if existing == *dir => {}
395 _ => {
396 }
399 }
400 }
401 for (axis, range) in info.range_constraint.iter() {
402 match merged_range.get(axis).cloned() {
403 None => merged_range.insert(axis, range.clone()),
404 Some(existing) => {
405 let intersected = intersect_ranges(&existing, range);
406 merged_range.insert(axis, intersected);
407 }
408 }
409 }
410 }
411
412 let determinism = if sub_infos
413 .iter()
414 .all(|i| i.determinism == Determinism::Deterministic)
415 {
416 Determinism::Deterministic
417 } else {
418 Determinism::Opaque
419 };
420
421 let factorization = if all_per_axis {
422 Factorization::PerAxis(merged_factor)
423 } else {
424 Factorization::Conjunctive(parts.to_vec())
425 };
426
427 PredicateInfo {
428 factorization,
429 monotonicity: merged_mono,
430 range_constraint: merged_range,
431 determinism,
432 coords_referenced: coord_refs,
433 }
434}
435
436fn intersect_ranges(a: &RangeConstraint, b: &RangeConstraint) -> RangeConstraint {
437 match (a, b) {
438 (
439 RangeConstraint::Bounded {
440 lo: lo_a,
441 hi: hi_a,
442 lo_inclusive: li_a,
443 hi_inclusive: hi_inc_a,
444 },
445 RangeConstraint::Bounded {
446 lo: lo_b,
447 hi: hi_b,
448 lo_inclusive: li_b,
449 hi_inclusive: hi_inc_b,
450 },
451 ) => {
452 let (lo, lo_inclusive) = pick_lo(lo_a.as_ref(), *li_a, lo_b.as_ref(), *li_b);
454 let (hi, hi_inclusive) = pick_hi(hi_a.as_ref(), *hi_inc_a, hi_b.as_ref(), *hi_inc_b);
455 RangeConstraint::Bounded {
456 lo,
457 hi,
458 lo_inclusive,
459 hi_inclusive,
460 }
461 }
462 (RangeConstraint::Discrete(vs), RangeConstraint::Bounded { .. })
463 | (RangeConstraint::Bounded { .. }, RangeConstraint::Discrete(vs)) => {
464 RangeConstraint::Discrete(vs.clone())
468 }
469 (RangeConstraint::Discrete(vs_a), RangeConstraint::Discrete(vs_b)) => {
470 let intersection: Vec<ConstValue> =
471 vs_a.iter().filter(|v| vs_b.contains(v)).cloned().collect();
472 RangeConstraint::Discrete(intersection)
473 }
474 (RangeConstraint::None, other) | (other, RangeConstraint::None) => other.clone(),
475 }
476}
477
478fn pick_lo(
479 a: Option<&ConstValue>,
480 a_inc: bool,
481 b: Option<&ConstValue>,
482 b_inc: bool,
483) -> (Option<ConstValue>, bool) {
484 match (a, b) {
485 (None, None) => (None, false),
486 (Some(v), None) => (Some(v.clone()), a_inc),
487 (None, Some(v)) => (Some(v.clone()), b_inc),
488 (Some(av), Some(bv)) => {
489 let cmp = compare_const(av, bv);
490 if cmp.is_lt() {
491 (Some(bv.clone()), b_inc)
492 } else if cmp.is_gt() {
493 (Some(av.clone()), a_inc)
494 } else {
495 (Some(av.clone()), a_inc && b_inc)
497 }
498 }
499 }
500}
501
502fn pick_hi(
503 a: Option<&ConstValue>,
504 a_inc: bool,
505 b: Option<&ConstValue>,
506 b_inc: bool,
507) -> (Option<ConstValue>, bool) {
508 match (a, b) {
509 (None, None) => (None, false),
510 (Some(v), None) => (Some(v.clone()), a_inc),
511 (None, Some(v)) => (Some(v.clone()), b_inc),
512 (Some(av), Some(bv)) => {
513 let cmp = compare_const(av, bv);
514 if cmp.is_lt() {
515 (Some(av.clone()), a_inc)
516 } else if cmp.is_gt() {
517 (Some(bv.clone()), b_inc)
518 } else {
519 (Some(av.clone()), a_inc && b_inc)
520 }
521 }
522 }
523}
524
525fn compare_const(a: &ConstValue, b: &ConstValue) -> std::cmp::Ordering {
526 match (a, b) {
527 (ConstValue::Int(a), ConstValue::Int(b)) => a.cmp(b),
528 (ConstValue::Float(a), ConstValue::Float(b)) => {
529 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
530 }
531 (ConstValue::Int(a), ConstValue::Float(b)) => (*a as f64)
532 .partial_cmp(b)
533 .unwrap_or(std::cmp::Ordering::Equal),
534 (ConstValue::Float(a), ConstValue::Int(b)) => a
535 .partial_cmp(&(*b as f64))
536 .unwrap_or(std::cmp::Ordering::Equal),
537 _ => std::cmp::Ordering::Equal,
538 }
539}
540
541fn recognize_disjunction(
544 parts: &[String],
545 coords: &CoordSet,
546 coord_refs: Vec<String>,
547) -> PredicateInfo {
548 let sub_infos: Vec<PredicateInfo> = parts.iter().map(|p| recognize(p, coords)).collect();
549
550 let all_known = sub_infos.iter().all(|i| {
554 matches!(
555 i.factorization,
556 Factorization::PerAxis(_) | Factorization::Conjunctive(_)
557 )
558 });
559 let factorization = if all_known {
560 Factorization::Disjunctive(parts.to_vec())
561 } else {
562 Factorization::Opaque(OpaqueReason::UnknownPattern)
563 };
564
565 let determinism = if sub_infos
566 .iter()
567 .all(|i| i.determinism == Determinism::Deterministic)
568 {
569 Determinism::Deterministic
570 } else {
571 Determinism::Opaque
572 };
573
574 let mut merged_range = PerAxisMap::<RangeConstraint>::new();
578 for info in &sub_infos {
579 for (axis, range) in info.range_constraint.iter() {
580 match merged_range.get(axis).cloned() {
581 None => merged_range.insert(axis, range.clone()),
582 Some(existing) => merged_range.insert(axis, union_ranges(&existing, range)),
583 }
584 }
585 }
586
587 PredicateInfo {
588 factorization,
589 monotonicity: PerAxisMap::new(),
590 range_constraint: merged_range,
591 determinism,
592 coords_referenced: coord_refs,
593 }
594}
595
596fn union_ranges(a: &RangeConstraint, b: &RangeConstraint) -> RangeConstraint {
597 match (a, b) {
598 (RangeConstraint::Discrete(va), RangeConstraint::Discrete(vb)) => {
599 let mut merged = va.clone();
600 for v in vb {
601 if !merged.contains(v) {
602 merged.push(v.clone());
603 }
604 }
605 RangeConstraint::Discrete(merged)
606 }
607 _ => RangeConstraint::None,
610 }
611}
612
613fn invert_predicate(inner: &PredicateInfo, coord_refs: Vec<String>) -> PredicateInfo {
616 let factorization = match &inner.factorization {
622 Factorization::PerAxis(m) => {
623 let mut inverted = PerAxisMap::<String>::new();
624 for (axis, expr) in m.iter() {
625 inverted.insert(axis, format!("!({expr})"));
626 }
627 Factorization::PerAxis(inverted)
628 }
629 Factorization::Opaque(reason) => Factorization::Opaque(reason.clone()),
630 _ => Factorization::Opaque(OpaqueReason::UnknownPattern),
631 };
632
633 let mut inverted_mono = PerAxisMap::<Monotonicity>::new();
635 for (axis, dir) in inner.monotonicity.iter() {
636 let new = match dir {
637 Monotonicity::Increasing => Monotonicity::Decreasing,
638 Monotonicity::Decreasing => Monotonicity::Increasing,
639 Monotonicity::None => Monotonicity::None,
640 };
641 inverted_mono.insert(axis, new);
642 }
643
644 let inverted_range = PerAxisMap::<RangeConstraint>::new();
647
648 PredicateInfo {
649 factorization,
650 monotonicity: inverted_mono,
651 range_constraint: inverted_range,
652 determinism: inner.determinism,
653 coords_referenced: coord_refs,
654 }
655}
656
657fn recognize_discrete_set(
660 predicate: &str,
661 coords: &CoordSet,
662 coord_refs: &[String],
663) -> Option<PredicateInfo> {
664 let trimmed = predicate.trim();
666 let in_pos = trimmed.find(" in ")?;
667 let lhs = trimmed[..in_pos].trim();
668 let rhs = trimmed[in_pos + 4..].trim();
669 let name = strip_curly(lhs)?;
670 if !coords.contains(&name) {
671 return None;
672 }
673 let inner = rhs.strip_prefix('[')?.strip_suffix(']')?;
675 let values: Vec<ConstValue> = inner
676 .split(',')
677 .map(|s| parse_literal(s.trim()))
678 .collect::<Option<Vec<_>>>()?;
679 if values.is_empty() {
680 return None;
681 }
682
683 let mut factor = PerAxisMap::new();
684 factor.insert(name.clone(), predicate.trim().to_string());
685 let mut range = PerAxisMap::new();
686 range.insert(name.clone(), RangeConstraint::Discrete(values));
687
688 Some(PredicateInfo {
689 factorization: Factorization::PerAxis(factor),
690 monotonicity: PerAxisMap::new(),
691 range_constraint: range,
692 determinism: Determinism::Deterministic,
693 coords_referenced: coord_refs.to_vec(),
694 })
695}
696
697fn strip_curly(s: &str) -> Option<String> {
703 let s = s.trim();
704 if s.starts_with('{') && s.ends_with('}') {
705 let inner = &s[1..s.len() - 1];
706 let trimmed = inner.trim();
707 if trimmed.chars().all(|c| c.is_alphanumeric() || c == '_') && !trimmed.is_empty() {
708 return Some(trimmed.to_string());
709 }
710 }
711 None
712}
713
714fn parse_literal(s: &str) -> Option<ConstValue> {
717 let s = s.trim();
718 if s.eq_ignore_ascii_case("true") {
719 return Some(ConstValue::Bool(true));
720 }
721 if s.eq_ignore_ascii_case("false") {
722 return Some(ConstValue::Bool(false));
723 }
724 if s.len() >= 2
726 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
727 {
728 return Some(ConstValue::String(s[1..s.len() - 1].to_string()));
729 }
730 if let Ok(n) = s.parse::<i64>() {
732 return Some(ConstValue::Int(n));
733 }
734 if let Ok(f) = s.parse::<f64>() {
735 return Some(ConstValue::Float(f));
736 }
737 None
738}
739
740fn split_top_level(s: &str, sep: &str) -> Option<Vec<String>> {
744 let mut parts = Vec::new();
745 let mut depth = 0i64;
746 let mut last = 0usize;
747 let bytes = s.as_bytes();
748 let sep_bytes = sep.as_bytes();
749 let mut i = 0;
750 while i < bytes.len() {
751 match bytes[i] {
752 b'(' | b'[' | b'{' => depth += 1,
753 b')' | b']' | b'}' => depth -= 1,
754 _ => {}
755 }
756 if depth == 0
757 && i + sep_bytes.len() <= bytes.len()
758 && &bytes[i..i + sep_bytes.len()] == sep_bytes
759 {
760 parts.push(s[last..i].trim().to_string());
761 last = i + sep_bytes.len();
762 i = last;
763 continue;
764 }
765 i += 1;
766 }
767 if parts.is_empty() {
768 return None;
769 }
770 parts.push(s[last..].trim().to_string());
771 Some(parts)
772}
773
774fn split_top_level_op<'a>(s: &'a str, op: &str) -> Option<(&'a str, &'a str)> {
778 let mut depth = 0i64;
779 let bytes = s.as_bytes();
780 let op_bytes = op.as_bytes();
781 let mut i = 0;
782 while i < bytes.len() {
783 match bytes[i] {
784 b'(' | b'[' | b'{' => depth += 1,
785 b')' | b']' | b'}' => depth -= 1,
786 _ => {}
787 }
788 if depth == 0
789 && i + op_bytes.len() <= bytes.len()
790 && &bytes[i..i + op_bytes.len()] == op_bytes
791 {
792 if op.len() == 1 {
796 let next = bytes.get(i + 1).copied();
797 if next == Some(b'=') {
798 i += 1;
799 continue;
800 }
801 }
802 return Some((&s[..i], &s[i + op_bytes.len()..]));
803 }
804 i += 1;
805 }
806 None
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812
813 fn coords(names: &[&str]) -> CoordSet {
814 CoordSet::all_discrete(names.iter().copied())
815 }
816
817 #[test]
818 fn recognize_per_axis_eq() {
819 let info = recognize("{k} == 5", &coords(&["k", "limit"]));
820 match info.factorization {
821 Factorization::PerAxis(m) => {
822 assert!(m.get("k").is_some());
823 assert!(m.get("limit").is_none());
824 }
825 other => panic!("expected PerAxis, got {other:?}"),
826 }
827 assert_eq!(info.coords_referenced, vec!["k"]);
828 let range = info.range_constraint.get("k").unwrap();
829 assert!(matches!(range, RangeConstraint::Discrete(vs) if vs.len() == 1));
830 }
831
832 #[test]
833 fn recognize_per_axis_gt() {
834 let info = recognize("{k} > 10", &coords(&["k"]));
835 assert!(matches!(info.factorization, Factorization::PerAxis(_)));
836 assert_eq!(info.monotonicity.get("k"), Some(&Monotonicity::Increasing));
837 let range = info.range_constraint.get("k").unwrap();
838 match range {
839 RangeConstraint::Bounded {
840 lo: Some(ConstValue::Int(10)),
841 hi: None,
842 ..
843 } => {}
844 other => panic!("expected Bounded lo=10, got {other:?}"),
845 }
846 }
847
848 #[test]
849 fn recognize_per_axis_le_reversed() {
850 let info = recognize("5 <= {k}", &coords(&["k"]));
852 assert_eq!(info.monotonicity.get("k"), Some(&Monotonicity::Increasing));
853 let range = info.range_constraint.get("k").unwrap();
854 match range {
855 RangeConstraint::Bounded {
856 lo: Some(ConstValue::Int(5)),
857 lo_inclusive: true,
858 ..
859 } => {}
860 other => panic!("expected Bounded lo=5 inclusive, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn recognize_cross_axis_comparison_is_conjunctive() {
866 let info = recognize("{k} == {limit}", &coords(&["k", "limit"]));
867 assert!(matches!(info.factorization, Factorization::Conjunctive(_)));
868 }
869
870 #[test]
871 fn recognize_conjunction_of_per_axis() {
872 let info = recognize("{k} > 5 && {limit} < 100", &coords(&["k", "limit"]));
873 match &info.factorization {
874 Factorization::PerAxis(m) => {
875 assert!(m.get("k").is_some());
876 assert!(m.get("limit").is_some());
877 }
878 other => panic!("expected PerAxis, got {other:?}"),
879 }
880 assert_eq!(info.monotonicity.get("k"), Some(&Monotonicity::Increasing));
881 assert_eq!(
882 info.monotonicity.get("limit"),
883 Some(&Monotonicity::Decreasing)
884 );
885 }
886
887 #[test]
888 fn recognize_conjunction_range_fold() {
889 let info = recognize("10 <= {k} && {k} <= 100", &coords(&["k"]));
890 match &info.factorization {
891 Factorization::PerAxis(m) => {
892 assert!(m.get("k").is_some());
893 }
894 other => panic!("expected PerAxis after range fold, got {other:?}"),
895 }
896 let range = info.range_constraint.get("k").unwrap();
897 match range {
898 RangeConstraint::Bounded {
899 lo: Some(ConstValue::Int(10)),
900 hi: Some(ConstValue::Int(100)),
901 lo_inclusive: true,
902 hi_inclusive: true,
903 } => {}
904 other => panic!("expected folded [10, 100], got {other:?}"),
905 }
906 }
907
908 #[test]
909 fn recognize_disjunction_per_axis_is_disjunctive() {
910 let info = recognize("{k} == 1 || {k} == 100", &coords(&["k"]));
911 assert!(matches!(info.factorization, Factorization::Disjunctive(_)));
912 }
913
914 #[test]
915 fn recognize_negation_per_axis() {
916 let info = recognize("!{k} > 0", &coords(&["k"]));
917 match info.factorization {
922 Factorization::PerAxis(_) => {
923 assert_eq!(info.monotonicity.get("k"), Some(&Monotonicity::Decreasing));
924 }
925 Factorization::Opaque(_) => {
926 }
928 other => panic!("unexpected factorization {other:?}"),
929 }
930 }
931
932 #[test]
933 fn recognize_discrete_set() {
934 let info = recognize("{k} in [1, 7, 42]", &coords(&["k"]));
935 match &info.factorization {
936 Factorization::PerAxis(m) => assert!(m.get("k").is_some()),
937 other => panic!("expected PerAxis, got {other:?}"),
938 }
939 let range = info.range_constraint.get("k").unwrap();
940 match range {
941 RangeConstraint::Discrete(vs) => {
942 assert_eq!(vs.len(), 3);
943 assert_eq!(vs[0], ConstValue::Int(1));
944 assert_eq!(vs[1], ConstValue::Int(7));
945 assert_eq!(vs[2], ConstValue::Int(42));
946 }
947 other => panic!("expected Discrete, got {other:?}"),
948 }
949 }
950
951 #[test]
952 fn unknown_pattern_is_opaque() {
953 let info = recognize("complicated_function({k}) > 0", &coords(&["k"]));
954 assert!(matches!(
955 info.factorization,
956 Factorization::Opaque(OpaqueReason::UnknownPattern)
957 ));
958 }
959
960 #[test]
961 fn nondeterministic_function_marks_opaque_determinism() {
962 let info = recognize("random() > 0.5", &coords(&[]));
963 assert_eq!(info.determinism, Determinism::Opaque);
964 }
965
966 #[test]
967 fn continuous_coord_short_circuit() {
968 use crate::iteration::comprehension::predicate::coordset::{CoordInfo, CoordKind};
969 let mut coords = CoordSet::new();
970 coords.push(CoordInfo {
971 name: "theta".to_string(),
972 kind: CoordKind::Continuous,
973 });
974 let info = recognize("{theta} > 1.5", &coords);
975 assert!(matches!(
976 info.factorization,
977 Factorization::Opaque(OpaqueReason::Continuous)
978 ));
979 }
980
981 #[test]
982 fn extract_coord_refs_simple() {
983 assert_eq!(extract_coord_refs("{k} > 0"), vec!["k"]);
984 assert_eq!(
985 extract_coord_refs("{k} * {limit} <= 1000"),
986 vec!["k", "limit"]
987 );
988 }
989
990 #[test]
991 fn split_top_level_respects_parens() {
992 let s = "f(a && b) && c";
993 let parts = split_top_level(s, "&&").unwrap();
994 assert_eq!(parts, vec!["f(a && b)", "c"]);
995 }
996}