1use serde::{Deserialize, Serialize};
37
38use super::ast::Comprehension;
39use super::cardinality::CardinalityClass;
40use super::metadata::{IndexFn, Metadata};
41use super::source::Source;
42use super::strategy::{StrategyName, ZipMode};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50pub enum Mode {
51 #[default]
52 Permissive,
54 Strict,
56}
57
58#[derive(Debug, Clone)]
60pub struct ValidationReport {
61 pub warnings: Vec<ValidationWarning>,
63}
64
65#[derive(Debug, Clone, PartialEq)]
68pub enum ValidationError {
69 V1DuplicateName {
71 combinator: &'static str,
73 name: String,
75 },
76
77 V2ShapeMismatch {
79 expected: Vec<String>,
81 actual: Vec<String>,
83 },
84
85 V3UnresolvedNames {
94 predicate: String,
96 coords: Vec<String>,
98 unresolved: Vec<String>,
100 },
101
102 V4InputShape {
107 strategy: StrategyName,
109 reason: String,
111 },
112
113 V6UnboundedDiscrete {
116 operator: &'static str,
118 cardinality: CardinalityClass,
120 },
121
122 V7ZipCardinality {
126 mode: ZipMode,
128 reason: String,
130 },
131
132 V8ContinuousRequirement {
135 reason: String,
137 },
138
139 V9UnionClassMismatch {
142 reason: String,
144 },
145
146 StrictWarning(ValidationWarning),
149 ContextRequired {
153 name: String,
155 references: Vec<String>,
157 },
158 SourceFailed {
164 name: String,
166 message: String,
168 },
169}
170
171impl std::fmt::Display for ValidationError {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 match self {
174 Self::V1DuplicateName { combinator, name } => {
175 write!(
176 f,
177 "V1: `{combinator}` binds the name `{name}` more than once"
178 )
179 }
180 Self::V2ShapeMismatch { expected, actual } => write!(
181 f,
182 "V2: tuple shape ({}) does not match ({})",
183 actual.join(", "),
184 expected.join(", ")
185 ),
186 Self::V3UnresolvedNames {
187 predicate,
188 coords,
189 unresolved,
190 } => write!(
191 f,
192 "V3: predicate `{predicate}` names {} not bound by the tuple ({})",
193 unresolved.join(", "),
194 coords.join(", ")
195 ),
196 Self::V4InputShape { strategy, reason } => {
197 write!(
198 f,
199 "V4: strategy `{strategy:?}` cannot take this input: {reason}"
200 )
201 }
202 Self::V6UnboundedDiscrete {
203 operator,
204 cardinality,
205 } => write!(
206 f,
207 "V6: `{operator}` cannot materialize a {cardinality:?} stream"
208 ),
209 Self::V7ZipCardinality { mode, reason } => {
210 write!(f, "V7: zip in {mode:?} mode: {reason}")
211 }
212 Self::V8ContinuousRequirement { reason } => {
213 write!(f, "V8: continuous source: {reason}")
214 }
215 Self::StrictWarning(w) => write!(f, "strict mode: {w}"),
216 Self::ContextRequired { name, references } => write!(
217 f,
218 "clause '{name}' needs a scope to bind {}; a coordinate stream has none: \n traverse it with `for`, which captures those names when it opens",
219 references.join(", ")
220 ),
221 Self::V9UnionClassMismatch { reason } => {
222 write!(f, "V9: union children differ in class: {reason}")
223 }
224 Self::SourceFailed { name, message } => {
225 write!(f, "clause '{name}' cannot be evaluated: {message}")
226 }
227 }
228 }
229}
230
231impl std::error::Error for ValidationError {}
232
233#[derive(Debug, Clone, PartialEq)]
234pub enum ValidationWarning {
237 DegenerateGeometric {
242 strategy: StrategyName,
244 },
245
246 LhsDegenerate,
249
250 TriviallyTrueFilter,
253
254 TriviallyFalseFilter,
258
259 EmptySource {
273 var: String,
275 source: Option<String>,
277 },
278
279 SingletonCombinator {
283 combinator: &'static str,
285 },
286}
287
288impl std::fmt::Display for ValidationWarning {
289 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290 match self {
291 Self::DegenerateGeometric { strategy } => write!(
292 f,
293 "`{strategy:?}` over a one-axis input collapses to its ends; use `Lex` with a truncation, or restate over a multi-axis cartesian"
294 ),
295 Self::LhsDegenerate => {
296 write!(f, "`Lhs` over a one-axis input is `Shuffle`; say `Shuffle`")
297 }
298 Self::TriviallyTrueFilter => write!(f, "the filter is always true; drop it"),
299 Self::TriviallyFalseFilter => write!(
300 f,
301 "the filter is always false; the comprehension dispenses nothing"
302 ),
303 Self::SingletonCombinator { combinator } => write!(
304 f,
305 "`{combinator}` over one child is that child; the wrapper adds nothing"
306 ),
307 Self::EmptySource { var, source } => match source {
308 Some(text) => write!(
309 f,
310 "`{var} in {text}` has no values; every composition it takes part in dispenses nothing"
311 ),
312 None => write!(
313 f,
314 "`{var}` has no values; every composition it takes part in dispenses nothing"
315 ),
316 },
317 }
318 }
319}
320
321pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
328 let mut report = ValidationReport {
329 warnings: Vec::new(),
330 };
331 visit(c, &mut report)?;
332 if mode == Mode::Strict
333 && let Some(warning) = report.warnings.first()
334 {
335 return Err(ValidationError::StrictWarning(warning.clone()));
338 }
339 Ok(report)
340}
341
342fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
343 for child in c.children() {
346 visit(child, report)?;
347 }
348
349 match c {
350 Comprehension::Clause { name, source } => visit_clause(name, source, report),
351 Comprehension::Cartesian { children } => visit_cartesian(children, report),
352 Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
353 Comprehension::Union { children } => visit_union(children, report),
354 Comprehension::Filter { child, predicate } => visit_filter(child, predicate, report),
355 Comprehension::Order {
356 child,
357 strategy,
358 truncation,
359 ..
360 } => visit_order(child, *strategy, *truncation, report),
361 }
362}
363
364fn visit_clause(
365 name: &str,
366 source: &Source,
367 report: &mut ValidationReport,
368) -> Result<(), ValidationError> {
369 if matches!(
375 source.cardinality(),
376 crate::iteration::comprehension::CardinalityClass::Bounded(0)
377 ) {
378 report.warnings.push(ValidationWarning::EmptySource {
379 var: name.to_string(),
380 source: source.to_text(),
381 });
382 }
383 if let Source::ContinuousInterval { interval, measure } = source
387 && !measure.is_integrable(std::slice::from_ref(interval))
388 {
389 let _ = report; return Err(ValidationError::V8ContinuousRequirement {
391 reason: format!(
392 "continuous source has non-integrable measure: \
393 interval [{}, {}] + {:?}",
394 interval.lo, interval.hi, measure
395 ),
396 });
397 }
398 if let Source::Distribution {
402 distribution,
403 params,
404 ..
405 } = source
406 && let Err(reason) = distribution.resolve_params(params)
407 {
408 return Err(ValidationError::V8ContinuousRequirement { reason });
409 }
410 Ok(())
411}
412
413fn visit_cartesian(
414 children: &[Comprehension],
415 report: &mut ValidationReport,
416) -> Result<(), ValidationError> {
417 check_disjoint_names("cartesian", children)?;
418 if children.len() == 1 {
419 report
420 .warnings
421 .push(ValidationWarning::SingletonCombinator {
422 combinator: "cartesian",
423 });
424 }
425 Ok(())
426}
427
428fn visit_zip(
429 children: &[Comprehension],
430 mode: ZipMode,
431 report: &mut ValidationReport,
432) -> Result<(), ValidationError> {
433 check_disjoint_names("zip", children)?;
434
435 for child in children {
439 if contains_continuous_source(child) {
440 return Err(ValidationError::V7ZipCardinality {
441 mode,
442 reason: "zip children must all be discrete; \
443 a continuous source was found"
444 .to_string(),
445 });
446 }
447 }
448
449 if children.len() == 1 {
450 report
451 .warnings
452 .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
453 }
454
455 if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
460 for child in children {
461 if let Some(card) = direct_source_cardinality(child)
462 && matches!(card, CardinalityClass::Unbounded)
463 {
464 return Err(ValidationError::V6UnboundedDiscrete {
465 operator: "zip",
466 cardinality: card,
467 });
468 }
469 }
470 }
471
472 Ok(())
473}
474
475fn visit_union(
476 children: &[Comprehension],
477 report: &mut ValidationReport,
478) -> Result<(), ValidationError> {
479 for child in children {
481 if contains_continuous_source(child) {
482 return Err(ValidationError::V9UnionClassMismatch {
483 reason: "union children must all be discrete; \
484 a continuous source was found"
485 .to_string(),
486 });
487 }
488 }
489
490 if let Some(first) = children.first() {
492 let expected = first.coordinate_names();
493 for sibling in &children[1..] {
494 let actual = sibling.coordinate_names();
495 if actual != expected {
496 return Err(ValidationError::V2ShapeMismatch { expected, actual });
497 }
498 }
499 }
500
501 if children.len() == 1 {
502 report
503 .warnings
504 .push(ValidationWarning::SingletonCombinator {
505 combinator: "union",
506 });
507 }
508 Ok(())
509}
510
511fn visit_filter(
512 child: &Comprehension,
513 predicate: &str,
514 report: &mut ValidationReport,
515) -> Result<(), ValidationError> {
516 let coords = child.coordinate_names();
522 let referenced = extract_interpolated_names(predicate);
523 let unresolved: Vec<String> = referenced
524 .into_iter()
525 .filter(|n| !coords.contains(n))
526 .collect();
527
528 let _ = unresolved;
535
536 let trimmed = predicate.trim();
541 if trimmed.eq_ignore_ascii_case("true") {
542 report.warnings.push(ValidationWarning::TriviallyTrueFilter);
543 } else if trimmed.eq_ignore_ascii_case("false") {
544 report
545 .warnings
546 .push(ValidationWarning::TriviallyFalseFilter);
547 }
548
549 let _ = child;
550 Ok(())
551}
552
553fn visit_order(
554 child: &Comprehension,
555 strategy: StrategyName,
556 truncation: Option<u64>,
557 report: &mut ValidationReport,
558) -> Result<(), ValidationError> {
559 let metadata_target = match child {
565 Comprehension::Filter { child: inner, .. } => inner.as_ref(),
566 other => other,
567 };
568
569 if !matches!(strategy, StrategyName::Lex)
572 && matches!(metadata_target, Comprehension::Filter { .. })
573 {
574 return Err(ValidationError::V4InputShape {
575 strategy,
576 reason: "non-Lex strategy applied to nested filter; \
577 fold filters first (spec F1 / R6)"
578 .to_string(),
579 });
580 }
581
582 let target_metadata = metadata_target.metadata();
583 check_strategy_input_shape(strategy, &target_metadata, report)?;
584
585 if !matches!(strategy, StrategyName::Lex)
588 && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
589 {
590 return Err(ValidationError::V6UnboundedDiscrete {
591 operator: "order",
592 cardinality: target_metadata.cardinality.clone(),
593 });
594 }
595
596 let is_continuous = matches!(
599 target_metadata.cardinality,
600 CardinalityClass::Continuous { .. }
601 | CardinalityClass::ContinuousAtMost { .. }
602 | CardinalityClass::Hybrid(_)
603 );
604 if is_continuous {
605 if truncation.is_none() {
606 return Err(ValidationError::V8ContinuousRequirement {
607 reason: "continuous comprehension requires order(_, \
608 sampling-strategy, Some(n)) with finite \
609 truncation"
610 .to_string(),
611 });
612 }
613 if matches!(strategy, StrategyName::Lex) {
614 return Err(ValidationError::V8ContinuousRequirement {
615 reason: "Lex does not sample continuous inputs; use \
616 Halton / Sobol / Lhs / Shuffle / Extrema"
617 .to_string(),
618 });
619 }
620 }
621
622 Ok(())
623}
624
625fn check_strategy_input_shape(
638 strategy: StrategyName,
639 metadata: &Metadata,
640 report: &mut ValidationReport,
641) -> Result<(), ValidationError> {
642 if matches!(strategy, StrategyName::Lex) {
644 return Ok(());
645 }
646
647 let idx = match &metadata.index_addressable {
648 Some(i) => i,
649 None => {
650 return Err(ValidationError::V4InputShape {
651 strategy,
652 reason: "input has no closed-form index function \
653 (raw filter output, dependent cartesian, or \
654 nested non-Lex order)"
655 .to_string(),
656 });
657 }
658 };
659
660 let has_continuous = idx.has_continuous_axis();
662 if has_continuous {
663 match strategy {
664 StrategyName::Shuffle
666 | StrategyName::Halton
667 | StrategyName::Sobol
668 | StrategyName::Lhs => {}
669 StrategyName::Extrema => {}
671 StrategyName::ReverseLex
673 | StrategyName::Shells
674 | StrategyName::Diagonal
675 | StrategyName::Antidiagonal => {
676 return Err(ValidationError::V4InputShape {
677 strategy,
678 reason: format!("{} does not accept continuous input", strategy.as_str()),
679 });
680 }
681 StrategyName::Lex => unreachable!("Lex handled above"),
682 }
683 if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
686 let dim = continuous_dim(idx);
687 if dim < 2 {
688 if matches!(strategy, StrategyName::Lhs) {
689 report.warnings.push(ValidationWarning::LhsDegenerate);
690 } else {
691 report
692 .warnings
693 .push(ValidationWarning::DegenerateGeometric { strategy });
694 }
695 }
696 }
697 return Ok(());
698 }
699
700 if strategy.is_lattice_geometric() {
703 match idx {
704 IndexFn::Lattice { axis_sizes } => {
705 if axis_sizes.len() < 2 {
706 report
707 .warnings
708 .push(ValidationWarning::DegenerateGeometric { strategy });
709 }
710 }
711 IndexFn::Concatenation { .. } => {
714 return Err(ValidationError::V4InputShape {
715 strategy,
716 reason: format!(
717 "{} requires a cartesian input; got union",
718 strategy.as_str()
719 ),
720 });
721 }
722 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
726 report
727 .warnings
728 .push(ValidationWarning::DegenerateGeometric { strategy });
729 }
730 IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
732 }
733 return Ok(());
734 }
735
736 if matches!(strategy, StrategyName::Lhs) {
738 match idx {
739 IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
740 report.warnings.push(ValidationWarning::LhsDegenerate);
741 }
742 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
743 report.warnings.push(ValidationWarning::LhsDegenerate);
744 }
745 _ => {}
746 }
747 }
748
749 Ok(())
753}
754
755fn continuous_dim(idx: &IndexFn) -> usize {
756 match idx {
757 IndexFn::Continuous { intervals, .. } => intervals.len(),
758 IndexFn::Hybrid {
759 discrete_axes,
760 continuous_axes,
761 ..
762 } => discrete_axes.len() + continuous_axes.len(),
763 _ => 0,
764 }
765}
766
767fn check_disjoint_names(
768 combinator: &'static str,
769 children: &[Comprehension],
770) -> Result<(), ValidationError> {
771 let mut seen: Vec<String> = Vec::new();
772 for child in children {
773 for name in child.coordinate_names() {
774 if seen.contains(&name) {
775 return Err(ValidationError::V1DuplicateName { combinator, name });
776 }
777 seen.push(name);
778 }
779 }
780 Ok(())
781}
782
783fn contains_continuous_source(c: &Comprehension) -> bool {
784 match c {
785 Comprehension::Clause { source, .. } => source.is_continuous(),
786 Comprehension::Cartesian { children }
787 | Comprehension::Zip { children, .. }
788 | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
789 Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
790 contains_continuous_source(child)
791 }
792 }
793}
794
795fn direct_source_cardinality(c: &Comprehension) -> Option<CardinalityClass> {
800 match c {
801 Comprehension::Clause { source, .. } => Some(source.cardinality()),
802 _ => None,
803 }
804}
805
806fn extract_interpolated_names(predicate: &str) -> Vec<String> {
811 let mut out = Vec::new();
812 let bytes = predicate.as_bytes();
813 let mut i = 0;
814 while i < bytes.len() {
815 if bytes[i] == b'{'
816 && let Some(close) = predicate[i + 1..].find('}')
817 {
818 let name = predicate[i + 1..i + 1 + close].trim();
819 if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
820 out.push(name.to_string());
821 }
822 i += close + 2;
823 continue;
824 }
825 i += 1;
826 }
827 out
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
834 use crate::iteration::comprehension::source::{LiteralValue, Source};
835
836 fn clause(name: &str, vs: &[i64]) -> Comprehension {
837 Comprehension::clause(
838 name,
839 Source::Literal {
840 values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
841 },
842 )
843 }
844
845 fn empty_warnings(c: &Comprehension) -> Vec<String> {
846 validate(c, Mode::Permissive)
847 .expect("an empty source is degenerate, not invalid")
848 .warnings
849 .into_iter()
850 .filter_map(|w| match w {
851 ValidationWarning::EmptySource { var, .. } => Some(var),
852 _ => None,
853 })
854 .collect()
855 }
856
857 #[test]
860 fn a_provably_empty_source_warns() {
861 assert_eq!(empty_warnings(&clause("x", &[])), ["x"]);
862 assert_eq!(
863 empty_warnings(&Comprehension::clause(
864 "k",
865 Source::IntRange {
866 lo: 5,
867 hi: 5,
868 step: 1,
869 },
870 )),
871 ["k"],
872 "a half-open range over no values is the same fact"
873 );
874 }
875
876 #[test]
880 fn a_source_of_unknown_count_does_not_warn() {
881 let generator = Comprehension::clause(
882 "g",
883 Source::Generator {
884 expr: "matching_profiles('a')".into(),
885 cardinality_hint: None,
886 },
887 );
888 assert!(empty_warnings(&generator).is_empty());
889
890 let param = Comprehension::clause(
891 "p",
892 Source::WorkloadParamList {
893 name: "sizes".into(),
894 len_hint: None,
895 },
896 );
897 assert!(empty_warnings(¶m).is_empty());
898 }
899
900 #[test]
903 fn a_generator_counted_as_zero_warns() {
904 let counted = Comprehension::clause(
905 "g",
906 Source::Generator {
907 expr: "matching_profiles('nope')".into(),
908 cardinality_hint: Some(0),
909 },
910 );
911 assert_eq!(empty_warnings(&counted), ["g"]);
912 }
913
914 #[test]
917 fn an_empty_source_is_the_error_under_strict() {
918 let c = clause("x", &[]);
919 assert!(validate(&c, Mode::Permissive).is_ok());
920 match validate(&c, Mode::Strict) {
921 Err(ValidationError::StrictWarning(ValidationWarning::EmptySource { var, .. })) => {
922 assert_eq!(var, "x");
923 }
924 other => panic!("expected the empty source to be the strict error, got {other:?}"),
925 }
926 }
927
928 #[test]
930 fn an_empty_clause_is_named_inside_a_cartesian() {
931 let c = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[])]);
932 assert_eq!(empty_warnings(&c), ["b"]);
933 }
934
935 fn continuous_clause(name: &str) -> Comprehension {
936 Comprehension::clause(
937 name,
938 Source::ContinuousInterval {
939 interval: Interval::closed(0.0, 1.0),
940 measure: ProductMeasure::Uniform,
941 },
942 )
943 }
944
945 #[test]
946 fn v1_rejects_duplicate_names_in_cartesian() {
947 let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
948 let result = validate(&bad, Mode::Permissive);
949 assert!(matches!(
950 result,
951 Err(ValidationError::V1DuplicateName {
952 combinator: "cartesian",
953 ..
954 })
955 ));
956 }
957
958 #[test]
959 fn v1_accepts_disjoint_names() {
960 let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
961 assert!(validate(&ok, Mode::Permissive).is_ok());
962 }
963
964 #[test]
965 fn v2_rejects_union_shape_mismatch() {
966 let bad = Comprehension::union(vec![
967 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
968 Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
969 ]);
970 let result = validate(&bad, Mode::Permissive);
971 assert!(matches!(
972 result,
973 Err(ValidationError::V2ShapeMismatch { .. })
974 ));
975 }
976
977 #[test]
978 fn v2_accepts_matching_union_shape() {
979 let ok = Comprehension::union(vec![
980 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
981 Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
982 ]);
983 assert!(validate(&ok, Mode::Permissive).is_ok());
984 }
985
986 #[test]
987 fn v4_rejects_lattice_geometric_over_union() {
988 let bad = Comprehension::order(
989 Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
990 StrategyName::Extrema,
991 Some(2),
992 );
993 assert!(matches!(
994 validate(&bad, Mode::Permissive),
995 Err(ValidationError::V4InputShape {
996 strategy: StrategyName::Extrema,
997 ..
998 })
999 ));
1000 }
1001
1002 #[test]
1003 fn v4_lattice_geometric_over_1axis_warns_not_errors() {
1004 let degenerate =
1005 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1006 let report = validate(°enerate, Mode::Permissive).unwrap();
1007 assert!(report.warnings.iter().any(|w| matches!(
1008 w,
1009 ValidationWarning::DegenerateGeometric {
1010 strategy: StrategyName::Extrema
1011 }
1012 )));
1013 }
1014
1015 #[test]
1016 fn v4_strict_mode_promotes_warning() {
1017 let degenerate =
1018 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1019 assert!(validate(°enerate, Mode::Strict).is_err());
1020 }
1021
1022 #[test]
1023 fn v7_rejects_continuous_in_zip() {
1024 let bad = Comprehension::zip(
1025 vec![continuous_clause("alpha"), continuous_clause("beta")],
1026 ZipMode::Strict,
1027 );
1028 assert!(matches!(
1029 validate(&bad, Mode::Permissive),
1030 Err(ValidationError::V7ZipCardinality { .. })
1031 ));
1032 }
1033
1034 #[test]
1035 fn v8_rejects_continuous_without_sampling() {
1036 let bad = continuous_clause("theta");
1038 assert!(validate(&bad, Mode::Permissive).is_ok());
1039 let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
1044 assert!(matches!(
1045 validate(&bad_lex, Mode::Permissive),
1046 Err(ValidationError::V8ContinuousRequirement { .. })
1047 ));
1048 }
1049
1050 #[test]
1051 fn v8_accepts_continuous_with_sampling() {
1052 let ok = Comprehension::order(
1053 Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
1054 StrategyName::Halton,
1055 Some(100),
1056 );
1057 assert!(validate(&ok, Mode::Permissive).is_ok());
1058 }
1059
1060 #[test]
1061 fn v8_rejects_unbounded_uniform_at_source() {
1062 let bad = Comprehension::clause(
1063 "x",
1064 Source::ContinuousInterval {
1065 interval: Interval {
1066 lo: 0.0,
1067 hi: f64::INFINITY,
1068 lo_open: false,
1069 hi_open: true,
1070 },
1071 measure: ProductMeasure::Uniform,
1072 },
1073 );
1074 assert!(matches!(
1075 validate(&bad, Mode::Permissive),
1076 Err(ValidationError::V8ContinuousRequirement { .. })
1077 ));
1078 }
1079
1080 #[test]
1081 fn v9_rejects_continuous_in_union() {
1082 let bad = Comprehension::union(vec![
1083 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1084 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1085 ]);
1086 assert!(matches!(
1088 validate(&bad, Mode::Permissive),
1089 Err(ValidationError::V9UnionClassMismatch { .. })
1090 ));
1091 }
1092
1093 #[test]
1094 fn singleton_combinator_warns() {
1095 let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
1096 let report = validate(°enerate, Mode::Permissive).unwrap();
1097 assert!(report.warnings.iter().any(|w| matches!(
1098 w,
1099 ValidationWarning::SingletonCombinator {
1100 combinator: "cartesian"
1101 }
1102 )));
1103 }
1104
1105 #[test]
1106 fn trivially_true_filter_warns() {
1107 let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
1108 let report = validate(°enerate, Mode::Permissive).unwrap();
1109 assert!(
1110 report
1111 .warnings
1112 .iter()
1113 .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
1114 );
1115 }
1116
1117 #[test]
1118 fn name_extraction_handles_simple_predicates() {
1119 assert_eq!(extract_interpolated_names("{k} > 0"), vec!["k"]);
1120 assert_eq!(
1121 extract_interpolated_names("{k} * {limit} <= 1000"),
1122 vec!["k", "limit"]
1123 );
1124 assert_eq!(
1125 extract_interpolated_names("no refs here"),
1126 Vec::<String>::new()
1127 );
1128 }
1129}