1use serde::{Deserialize, Serialize};
36
37use super::ast::Comprehension;
38use super::cardinality::CardinalityClass;
39use super::metadata::{IndexFn, Metadata};
40use super::source::Source;
41use super::strategy::{StrategyName, ZipMode};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
49pub enum Mode {
50 #[default]
51 Permissive,
53 Strict,
55}
56
57#[derive(Debug, Clone)]
59pub struct ValidationReport {
60 pub warnings: Vec<ValidationWarning>,
62}
63
64#[derive(Debug, Clone, PartialEq)]
67pub enum ValidationError {
68 V1DuplicateName {
70 combinator: &'static str,
72 name: String,
74 },
75
76 V2ShapeMismatch {
78 expected: Vec<String>,
80 actual: Vec<String>,
82 },
83
84 V3UnresolvedNames {
93 predicate: String,
95 coords: Vec<String>,
97 unresolved: Vec<String>,
99 },
100
101 V4InputShape {
106 strategy: StrategyName,
108 reason: String,
110 },
111
112 V6UnboundedDiscrete {
115 operator: &'static str,
117 cardinality: CardinalityClass,
119 },
120
121 V7ZipCardinality {
125 mode: ZipMode,
127 reason: String,
129 },
130
131 V8ContinuousRequirement {
134 reason: String,
136 },
137
138 V9UnionClassMismatch {
141 reason: String,
143 },
144}
145
146#[derive(Debug, Clone, PartialEq)]
149pub enum ValidationWarning {
150 DegenerateGeometric {
155 strategy: StrategyName,
157 },
158
159 LhsDegenerate,
162
163 TriviallyTrueFilter,
166
167 TriviallyFalseFilter,
171
172 SingletonCombinator {
176 combinator: &'static str,
178 },
179}
180
181pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
188 let mut report = ValidationReport {
189 warnings: Vec::new(),
190 };
191 visit(c, &mut report)?;
192 if mode == Mode::Strict
193 && let Some(_warning) = report.warnings.first()
194 {
195 return Err(ValidationError::V8ContinuousRequirement {
202 reason: format!(
203 "strict mode: warning promoted: {:?}",
204 report.warnings.first().unwrap()
205 ),
206 });
207 }
208 Ok(report)
209}
210
211fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
212 for child in c.children() {
215 visit(child, report)?;
216 }
217
218 match c {
219 Comprehension::Clause { source, .. } => visit_clause(source, report),
220 Comprehension::Cartesian { children } => visit_cartesian(children, report),
221 Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
222 Comprehension::Union { children } => visit_union(children, report),
223 Comprehension::Filter { child, predicate } => visit_filter(child, predicate, report),
224 Comprehension::Order {
225 child,
226 strategy,
227 truncation,
228 } => visit_order(child, *strategy, *truncation, report),
229 }
230}
231
232fn visit_clause(source: &Source, report: &mut ValidationReport) -> Result<(), ValidationError> {
233 if let Source::ContinuousInterval { interval, measure } = source
237 && !measure.is_integrable(std::slice::from_ref(interval))
238 {
239 let _ = report; return Err(ValidationError::V8ContinuousRequirement {
241 reason: format!(
242 "continuous source has non-integrable measure: \
243 interval [{}, {}] + {:?}",
244 interval.lo, interval.hi, measure
245 ),
246 });
247 }
248 Ok(())
249}
250
251fn visit_cartesian(
252 children: &[Comprehension],
253 report: &mut ValidationReport,
254) -> Result<(), ValidationError> {
255 check_disjoint_names("cartesian", children)?;
256 if children.len() == 1 {
257 report
258 .warnings
259 .push(ValidationWarning::SingletonCombinator {
260 combinator: "cartesian",
261 });
262 }
263 Ok(())
264}
265
266fn visit_zip(
267 children: &[Comprehension],
268 mode: ZipMode,
269 report: &mut ValidationReport,
270) -> Result<(), ValidationError> {
271 check_disjoint_names("zip", children)?;
272
273 for child in children {
277 if contains_continuous_source(child) {
278 return Err(ValidationError::V7ZipCardinality {
279 mode,
280 reason: "zip children must all be discrete; \
281 a continuous source was found"
282 .to_string(),
283 });
284 }
285 }
286
287 if children.len() == 1 {
288 report
289 .warnings
290 .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
291 }
292
293 if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
298 for child in children {
299 if let Some(card) = direct_source_cardinality(child)
300 && matches!(card, CardinalityClass::Unbounded)
301 {
302 return Err(ValidationError::V6UnboundedDiscrete {
303 operator: "zip",
304 cardinality: card,
305 });
306 }
307 }
308 }
309
310 Ok(())
311}
312
313fn visit_union(
314 children: &[Comprehension],
315 report: &mut ValidationReport,
316) -> Result<(), ValidationError> {
317 for child in children {
319 if contains_continuous_source(child) {
320 return Err(ValidationError::V9UnionClassMismatch {
321 reason: "union children must all be discrete; \
322 a continuous source was found"
323 .to_string(),
324 });
325 }
326 }
327
328 if let Some(first) = children.first() {
330 let expected = first.coordinate_names();
331 for sibling in &children[1..] {
332 let actual = sibling.coordinate_names();
333 if actual != expected {
334 return Err(ValidationError::V2ShapeMismatch { expected, actual });
335 }
336 }
337 }
338
339 if children.len() == 1 {
340 report
341 .warnings
342 .push(ValidationWarning::SingletonCombinator {
343 combinator: "union",
344 });
345 }
346 Ok(())
347}
348
349fn visit_filter(
350 child: &Comprehension,
351 predicate: &str,
352 report: &mut ValidationReport,
353) -> Result<(), ValidationError> {
354 let coords = child.coordinate_names();
360 let referenced = extract_interpolated_names(predicate);
361 let unresolved: Vec<String> = referenced
362 .into_iter()
363 .filter(|n| !coords.contains(n))
364 .collect();
365
366 let _ = unresolved;
373
374 let trimmed = predicate.trim();
379 if trimmed.eq_ignore_ascii_case("true") {
380 report.warnings.push(ValidationWarning::TriviallyTrueFilter);
381 } else if trimmed.eq_ignore_ascii_case("false") {
382 report
383 .warnings
384 .push(ValidationWarning::TriviallyFalseFilter);
385 }
386
387 let _ = child;
388 Ok(())
389}
390
391fn visit_order(
392 child: &Comprehension,
393 strategy: StrategyName,
394 truncation: Option<u64>,
395 report: &mut ValidationReport,
396) -> Result<(), ValidationError> {
397 let metadata_target = match child {
403 Comprehension::Filter { child: inner, .. } => inner.as_ref(),
404 other => other,
405 };
406
407 if !matches!(strategy, StrategyName::Lex)
410 && matches!(metadata_target, Comprehension::Filter { .. })
411 {
412 return Err(ValidationError::V4InputShape {
413 strategy,
414 reason: "non-Lex strategy applied to nested filter; \
415 fold filters first (spec F1 / R6)"
416 .to_string(),
417 });
418 }
419
420 let target_metadata = metadata_target.metadata();
421 check_strategy_input_shape(strategy, &target_metadata, report)?;
422
423 if !matches!(strategy, StrategyName::Lex)
426 && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
427 {
428 return Err(ValidationError::V6UnboundedDiscrete {
429 operator: "order",
430 cardinality: target_metadata.cardinality.clone(),
431 });
432 }
433
434 let is_continuous = matches!(
437 target_metadata.cardinality,
438 CardinalityClass::Continuous { .. }
439 | CardinalityClass::ContinuousAtMost { .. }
440 | CardinalityClass::Hybrid(_)
441 );
442 if is_continuous {
443 if truncation.is_none() {
444 return Err(ValidationError::V8ContinuousRequirement {
445 reason: "continuous comprehension requires order(_, \
446 sampling-strategy, Some(n)) with finite \
447 truncation"
448 .to_string(),
449 });
450 }
451 if matches!(strategy, StrategyName::Lex) {
452 return Err(ValidationError::V8ContinuousRequirement {
453 reason: "Lex does not sample continuous inputs; use \
454 Halton / Sobol / Lhs / Shuffle / Extrema"
455 .to_string(),
456 });
457 }
458 }
459
460 Ok(())
461}
462
463fn check_strategy_input_shape(
476 strategy: StrategyName,
477 metadata: &Metadata,
478 report: &mut ValidationReport,
479) -> Result<(), ValidationError> {
480 if matches!(strategy, StrategyName::Lex) {
482 return Ok(());
483 }
484
485 let idx = match &metadata.index_addressable {
486 Some(i) => i,
487 None => {
488 return Err(ValidationError::V4InputShape {
489 strategy,
490 reason: "input has no closed-form index function \
491 (raw filter output, dependent cartesian, or \
492 nested non-Lex order)"
493 .to_string(),
494 });
495 }
496 };
497
498 let has_continuous = idx.has_continuous_axis();
500 if has_continuous {
501 match strategy {
502 StrategyName::Shuffle
504 | StrategyName::Halton
505 | StrategyName::Sobol
506 | StrategyName::Lhs => {}
507 StrategyName::Extrema => {}
509 StrategyName::ReverseLex
511 | StrategyName::Shells
512 | StrategyName::Diagonal
513 | StrategyName::Antidiagonal => {
514 return Err(ValidationError::V4InputShape {
515 strategy,
516 reason: format!("{} does not accept continuous input", strategy.as_str()),
517 });
518 }
519 StrategyName::Lex => unreachable!("Lex handled above"),
520 }
521 if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
524 let dim = continuous_dim(idx);
525 if dim < 2 {
526 if matches!(strategy, StrategyName::Lhs) {
527 report.warnings.push(ValidationWarning::LhsDegenerate);
528 } else {
529 report
530 .warnings
531 .push(ValidationWarning::DegenerateGeometric { strategy });
532 }
533 }
534 }
535 return Ok(());
536 }
537
538 if strategy.is_lattice_geometric() {
541 match idx {
542 IndexFn::Lattice { axis_sizes } => {
543 if axis_sizes.len() < 2 {
544 report
545 .warnings
546 .push(ValidationWarning::DegenerateGeometric { strategy });
547 }
548 }
549 IndexFn::Concatenation { .. } => {
552 return Err(ValidationError::V4InputShape {
553 strategy,
554 reason: format!(
555 "{} requires a cartesian input; got union",
556 strategy.as_str()
557 ),
558 });
559 }
560 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
564 report
565 .warnings
566 .push(ValidationWarning::DegenerateGeometric { strategy });
567 }
568 IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
570 }
571 return Ok(());
572 }
573
574 if matches!(strategy, StrategyName::Lhs) {
576 match idx {
577 IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
578 report.warnings.push(ValidationWarning::LhsDegenerate);
579 }
580 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
581 report.warnings.push(ValidationWarning::LhsDegenerate);
582 }
583 _ => {}
584 }
585 }
586
587 Ok(())
591}
592
593fn continuous_dim(idx: &IndexFn) -> usize {
594 match idx {
595 IndexFn::Continuous { intervals, .. } => intervals.len(),
596 IndexFn::Hybrid {
597 discrete_axes,
598 continuous_axes,
599 ..
600 } => discrete_axes.len() + continuous_axes.len(),
601 _ => 0,
602 }
603}
604
605fn check_disjoint_names(
606 combinator: &'static str,
607 children: &[Comprehension],
608) -> Result<(), ValidationError> {
609 let mut seen: Vec<String> = Vec::new();
610 for child in children {
611 for name in child.coordinate_names() {
612 if seen.contains(&name) {
613 return Err(ValidationError::V1DuplicateName { combinator, name });
614 }
615 seen.push(name);
616 }
617 }
618 Ok(())
619}
620
621fn contains_continuous_source(c: &Comprehension) -> bool {
622 match c {
623 Comprehension::Clause { source, .. } => source.is_continuous(),
624 Comprehension::Cartesian { children }
625 | Comprehension::Zip { children, .. }
626 | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
627 Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
628 contains_continuous_source(child)
629 }
630 }
631}
632
633fn direct_source_cardinality(c: &Comprehension) -> Option<CardinalityClass> {
638 match c {
639 Comprehension::Clause { source, .. } => Some(source.cardinality()),
640 _ => None,
641 }
642}
643
644fn extract_interpolated_names(predicate: &str) -> Vec<String> {
649 let mut out = Vec::new();
650 let bytes = predicate.as_bytes();
651 let mut i = 0;
652 while i < bytes.len() {
653 if bytes[i] == b'{'
654 && let Some(close) = predicate[i + 1..].find('}')
655 {
656 let name = predicate[i + 1..i + 1 + close].trim();
657 if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
658 out.push(name.to_string());
659 }
660 i += close + 2;
661 continue;
662 }
663 i += 1;
664 }
665 out
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
672 use crate::iteration::comprehension::source::{LiteralValue, Source};
673
674 fn clause(name: &str, vs: &[i64]) -> Comprehension {
675 Comprehension::clause(
676 name,
677 Source::Literal {
678 values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
679 },
680 )
681 }
682
683 fn continuous_clause(name: &str) -> Comprehension {
684 Comprehension::clause(
685 name,
686 Source::ContinuousInterval {
687 interval: Interval::closed(0.0, 1.0),
688 measure: ProductMeasure::Uniform,
689 },
690 )
691 }
692
693 #[test]
694 fn v1_rejects_duplicate_names_in_cartesian() {
695 let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
696 let result = validate(&bad, Mode::Permissive);
697 assert!(matches!(
698 result,
699 Err(ValidationError::V1DuplicateName {
700 combinator: "cartesian",
701 ..
702 })
703 ));
704 }
705
706 #[test]
707 fn v1_accepts_disjoint_names() {
708 let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
709 assert!(validate(&ok, Mode::Permissive).is_ok());
710 }
711
712 #[test]
713 fn v2_rejects_union_shape_mismatch() {
714 let bad = Comprehension::union(vec![
715 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
716 Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
717 ]);
718 let result = validate(&bad, Mode::Permissive);
719 assert!(matches!(
720 result,
721 Err(ValidationError::V2ShapeMismatch { .. })
722 ));
723 }
724
725 #[test]
726 fn v2_accepts_matching_union_shape() {
727 let ok = Comprehension::union(vec![
728 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
729 Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
730 ]);
731 assert!(validate(&ok, Mode::Permissive).is_ok());
732 }
733
734 #[test]
735 fn v4_rejects_lattice_geometric_over_union() {
736 let bad = Comprehension::order(
737 Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
738 StrategyName::Extrema,
739 Some(2),
740 );
741 assert!(matches!(
742 validate(&bad, Mode::Permissive),
743 Err(ValidationError::V4InputShape {
744 strategy: StrategyName::Extrema,
745 ..
746 })
747 ));
748 }
749
750 #[test]
751 fn v4_lattice_geometric_over_1axis_warns_not_errors() {
752 let degenerate =
753 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
754 let report = validate(°enerate, Mode::Permissive).unwrap();
755 assert!(report.warnings.iter().any(|w| matches!(
756 w,
757 ValidationWarning::DegenerateGeometric {
758 strategy: StrategyName::Extrema
759 }
760 )));
761 }
762
763 #[test]
764 fn v4_strict_mode_promotes_warning() {
765 let degenerate =
766 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
767 assert!(validate(°enerate, Mode::Strict).is_err());
768 }
769
770 #[test]
771 fn v7_rejects_continuous_in_zip() {
772 let bad = Comprehension::zip(
773 vec![continuous_clause("alpha"), continuous_clause("beta")],
774 ZipMode::Strict,
775 );
776 assert!(matches!(
777 validate(&bad, Mode::Permissive),
778 Err(ValidationError::V7ZipCardinality { .. })
779 ));
780 }
781
782 #[test]
783 fn v8_rejects_continuous_without_sampling() {
784 let bad = continuous_clause("theta");
786 assert!(validate(&bad, Mode::Permissive).is_ok());
787 let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
792 assert!(matches!(
793 validate(&bad_lex, Mode::Permissive),
794 Err(ValidationError::V8ContinuousRequirement { .. })
795 ));
796 }
797
798 #[test]
799 fn v8_accepts_continuous_with_sampling() {
800 let ok = Comprehension::order(
801 Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
802 StrategyName::Halton,
803 Some(100),
804 );
805 assert!(validate(&ok, Mode::Permissive).is_ok());
806 }
807
808 #[test]
809 fn v8_rejects_unbounded_uniform_at_source() {
810 let bad = Comprehension::clause(
811 "x",
812 Source::ContinuousInterval {
813 interval: Interval {
814 lo: 0.0,
815 hi: f64::INFINITY,
816 lo_open: false,
817 hi_open: true,
818 },
819 measure: ProductMeasure::Uniform,
820 },
821 );
822 assert!(matches!(
823 validate(&bad, Mode::Permissive),
824 Err(ValidationError::V8ContinuousRequirement { .. })
825 ));
826 }
827
828 #[test]
829 fn v9_rejects_continuous_in_union() {
830 let bad = Comprehension::union(vec![
831 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
832 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
833 ]);
834 assert!(matches!(
836 validate(&bad, Mode::Permissive),
837 Err(ValidationError::V9UnionClassMismatch { .. })
838 ));
839 }
840
841 #[test]
842 fn singleton_combinator_warns() {
843 let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
844 let report = validate(°enerate, Mode::Permissive).unwrap();
845 assert!(report.warnings.iter().any(|w| matches!(
846 w,
847 ValidationWarning::SingletonCombinator {
848 combinator: "cartesian"
849 }
850 )));
851 }
852
853 #[test]
854 fn trivially_true_filter_warns() {
855 let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
856 let report = validate(°enerate, Mode::Permissive).unwrap();
857 assert!(
858 report
859 .warnings
860 .iter()
861 .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
862 );
863 }
864
865 #[test]
866 fn name_extraction_handles_simple_predicates() {
867 assert_eq!(extract_interpolated_names("{k} > 0"), vec!["k"]);
868 assert_eq!(
869 extract_interpolated_names("{k} * {limit} <= 1000"),
870 vec!["k", "limit"]
871 );
872 assert_eq!(
873 extract_interpolated_names("no refs here"),
874 Vec::<String>::new()
875 );
876 }
877}