Skip to main content

polydat_core/iteration/comprehension/predicate/
recognizers.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pattern recognizer catalog — spec §10.9.5.
5//!
6//! Each recognizer matches one syntactic shape against a
7//! predicate string and produces partial [`PredicateInfo`]
8//! data. The analyzer ([`super::analyzer::analyze`])
9//! composes the recognizers to build the full info.
10//!
11//! ## Initial catalog (spec §10.9.5)
12//!
13//! - `{a} OP K` for OP ∈ {==, !=, <, <=, >, >=}
14//! - `{a} OP {b}` (cross-axis)
15//! - `p1 && p2` (recursive)
16//! - `p1 || p2` (recursive)
17//! - `!p`
18//! - `K1 <= {a} && {a} <= K2` (range fold)
19//! - `{a} in [K1, K2, K3]` (discrete-set membership)
20//!
21//! Patterns NOT in this catalog return `Opaque(UnknownPattern)`.
22//! Per spec §10.9.4 property 2 ("Conservatively incomplete"):
23//! missing an optimization is acceptable; asserting a false
24//! property is not.
25
26use super::coordset::{CoordKind, CoordSet};
27use super::info::{
28    ConstValue, Determinism, Factorization, Monotonicity, OpaqueReason, PerAxisMap, PredicateInfo,
29    RangeConstraint,
30};
31
32/// Extract `{name}` interpolation references from a predicate
33/// string. Mirrors the helper in `validate.rs`'s V3 check.
34pub 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
57/// Top-level entry — try recognizers in priority order.
58/// Returns the most specific match.
59pub fn recognize(predicate: &str, coords: &CoordSet) -> PredicateInfo {
60    let coord_refs = extract_coord_refs(predicate);
61
62    // Continuous-coord short-circuit. Any reference to a
63    // continuous-classified coord makes the whole predicate
64    // Opaque(Continuous) per spec §10.9 + F20.
65    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    // Try recognizers in priority order:
78    //   1. Range-fold (`K1 OP {a} OP K2`) — most specific.
79    //   2. Discrete-set (`{a} in [...]`).
80    //   3. Negation (`!p`).
81    //   4. Conjunction (`p1 && p2`).
82    //   5. Disjunction (`p1 || p2`).
83    //   6. Per-axis comparison (`{a} OP K`).
84    //   7. Cross-axis comparison (`{a} OP {b}`).
85    //   8. Trivially-true / trivially-false.
86
87    let trimmed = predicate.trim();
88
89    // 8. Trivially-true / -false. Folded by R0a; we still
90    //    report so callers see the shape.
91    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    // 4. Conjunction — split on top-level `&&`.
111    if let Some(parts) = split_top_level(trimmed, "&&") {
112        return recognize_conjunction(&parts, coords, coord_refs);
113    }
114
115    // 5. Disjunction — split on top-level `||`.
116    if let Some(parts) = split_top_level(trimmed, "||") {
117        return recognize_disjunction(&parts, coords, coord_refs);
118    }
119
120    // 3. Negation — leading `!`.
121    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    // 2. Discrete-set — `{a} in [K1, K2, …]`.
127    if let Some(info) = recognize_discrete_set(trimmed, coords, &coord_refs) {
128        return info;
129    }
130
131    // 1. Range-fold — `K1 OP {a} OP K2` (already in conjunction
132    //    branch if user wrote `K1 <= {a} && {a} <= K2`).
133
134    // 6. Per-axis comparison `{a} OP K`.
135    if let Some(info) = recognize_per_axis_comparison(trimmed, coords, &coord_refs) {
136        return info;
137    }
138
139    // 7. Cross-axis comparison `{a} OP {b}`.
140    if let Some(info) = recognize_cross_axis_comparison(trimmed, coords, &coord_refs) {
141        return info;
142    }
143
144    // Fallback — unknown pattern.
145    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
154/// Detect non-deterministic constructs in the predicate text.
155/// Conservative: any reference to a function name we
156/// recognize as non-deterministic (PRNG, time, etc.) marks
157/// `Determinism::Opaque`.
158fn 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
179// ---- per-axis comparison ----
180
181const COMPARISON_OPS: &[(&str, ComparisonKind)] = &[
182    ("==", ComparisonKind::Eq),
183    ("!=", ComparisonKind::Ne),
184    ("<=", ComparisonKind::Le),
185    (">=", ComparisonKind::Ge),
186    // Strict variants AFTER non-strict so the longest match wins.
187    ("<", 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    // Looks for `{name} OP literal` or `literal OP {name}`.
207    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            // Case A: `{name} OP literal`
213            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            // Case B: `literal OP {name}` — invert op direction.
220            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
325// ---- cross-axis comparison ----
326
327fn 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
351// ---- conjunction ----
352
353fn 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    // If every sub-info is PerAxis with disjoint axes, the
361    // conjunction is PerAxis.
362    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                        // Two sub-predicates on the same axis —
370                        // fold them with `&&`.
371                        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    // Merge per-axis monotonicity + range. For PerAxis merges,
386    // intersection rules apply: monotonicity must agree;
387    // range_constraint Bounded variants intersect.
388    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                    // Conflicting directions — drop the entry.
397                    // (Cleanest signal: no asserted monotonicity.)
398                }
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            // Pick the tighter lo / hi.
453            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            // Keep the discrete set; its elements are
465            // self-contained and the bounded constraint is
466            // implied.
467            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                // Equal — exclusive wins (tighter).
496                (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
541// ---- disjunction ----
542
543fn 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    // Per spec §10.9.5's disjunction rule: Disjunctive only if
551    // every disjunct is Conjunctive / PerAxis; otherwise
552    // Opaque.
553    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    // Disjunction loses per-axis monotonicity claims (the
575    // union of strict-monotone slices isn't monotone). Range
576    // constraints take the per-axis union.
577    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        // Mixed types: drop the constraint (can't union
608        // Bounded with Discrete soundly without more work).
609        _ => RangeConstraint::None,
610    }
611}
612
613// ---- negation ----
614
615fn invert_predicate(inner: &PredicateInfo, coord_refs: Vec<String>) -> PredicateInfo {
616    // Inverting a PerAxis predicate inverts each per-axis
617    // sub-predicate. Inverting Opaque stays Opaque. Inverting
618    // Conjunctive becomes Disjunctive of inverted parts; we
619    // don't currently auto-De-Morgan beyond that, so the
620    // safe fallback is Opaque.
621    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    // Invert monotonicity direction.
634    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    // Inverting ranges is non-trivial; drop range claims on
645    // negated predicates for now.
646    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
657// ---- discrete-set membership ----
658
659fn recognize_discrete_set(
660    predicate: &str,
661    coords: &CoordSet,
662    coord_refs: &[String],
663) -> Option<PredicateInfo> {
664    // Form: `{name} in [v1, v2, ...]`
665    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    // RHS should be `[…]`
674    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
697// ---- string parsing helpers ----
698
699/// Strip `{name}` wrapper from a string; return the inner name
700/// if matched, else `None`. Only matches a fully-wrapped
701/// `{name}` with no surrounding text.
702fn 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
714/// Parse a literal value (int, float, string, bool). Returns
715/// `None` if the input isn't a recognizable literal.
716fn 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    // Quoted string?
725    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    // Integer or float?
731    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
740/// Split a string on top-level occurrences of a separator,
741/// respecting parens / brackets / braces. Returns `Some(parts)`
742/// if the separator was found at the top level (≥2 parts).
743fn 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
774/// Split exactly once on the first top-level occurrence of a
775/// 2-character operator. Used for binary comparison
776/// recognition where we want `lhs OP rhs` not `lhs OP rhs OP foo`.
777fn 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            // For the shorter ops (<, >) ensure we don't catch
793            // <=, >= (longer ops are checked first by the
794            // caller's loop order).
795            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        // `5 <= {k}` should canonicalize to `{k} >= 5`.
851        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        // The simple recognizer may not parse this depending
918        // on whitespace; check what it produces.
919        // !pattern where pattern is `{k} > 0` (per-axis) →
920        // inverted per-axis with flipped monotonicity.
921        match info.factorization {
922            Factorization::PerAxis(_) => {
923                assert_eq!(info.monotonicity.get("k"), Some(&Monotonicity::Decreasing));
924            }
925            Factorization::Opaque(_) => {
926                // Acceptable conservative fallback.
927            }
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}