1use serde::{Deserialize, Serialize};
35
36use super::ast::Comprehension;
37use super::cardinality::CardinalityClass;
38use super::metadata::{IndexFn, Metadata};
39use super::source::Source;
40use super::strategy::{StrategyName, ZipMode};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48pub enum Mode {
49 #[default]
50 Permissive,
52 Strict,
54}
55
56#[derive(Debug, Clone)]
58pub struct ValidationReport {
59 pub warnings: Vec<ValidationWarning>,
61}
62
63#[derive(Debug, Clone, PartialEq)]
66pub enum ValidationError {
67 V1DuplicateName {
69 combinator: &'static str,
71 name: String,
73 },
74
75 V2ShapeMismatch {
77 expected: Vec<String>,
79 actual: Vec<String>,
81 },
82
83 V3UnresolvedNames {
87 reads: Vec<NameRead>,
89 },
90
91 V4InputShape {
98 strategy: StrategyName,
100 reason: String,
102 },
103
104 V6UnboundedDiscrete {
107 operator: &'static str,
109 cardinality: CardinalityClass,
111 },
112
113 V7ZipCardinality {
117 mode: ZipMode,
119 reason: String,
121 },
122
123 V8ContinuousRequirement {
126 reason: String,
128 },
129
130 V9UnionClassMismatch {
133 reason: String,
135 },
136
137 StrictWarning(ValidationWarning),
140 ContextRequired {
144 name: String,
146 references: Vec<String>,
148 },
149 PredicateContextRequired {
153 predicate: String,
155 references: Vec<String>,
157 },
158 SourceFailed {
163 name: String,
165 message: String,
167 },
168}
169
170impl std::fmt::Display for ValidationError {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Self::V1DuplicateName { combinator, name } => {
174 write!(
175 f,
176 "V1: `{combinator}` binds the name `{name}` more than once"
177 )
178 }
179 Self::V2ShapeMismatch { expected, actual } => write!(
180 f,
181 "V2: tuple shape ({}) does not match ({})",
182 actual.join(", "),
183 expected.join(", ")
184 ),
185 Self::V3UnresolvedNames { reads } => write_unresolved(f, reads, false),
186 Self::V4InputShape { strategy, reason } => {
187 write!(
188 f,
189 "V4: strategy `{strategy:?}` cannot take this input: {reason}"
190 )
191 }
192 Self::V6UnboundedDiscrete {
193 operator,
194 cardinality,
195 } => write!(
196 f,
197 "V6: `{operator}` cannot materialize a {cardinality:?} stream"
198 ),
199 Self::V7ZipCardinality { mode, reason } => {
200 write!(f, "V7: zip in {mode:?} mode: {reason}")
201 }
202 Self::V8ContinuousRequirement { reason } => {
203 write!(f, "V8: continuous source: {reason}")
204 }
205 Self::StrictWarning(w) => write!(f, "strict mode: {w}"),
206 Self::ContextRequired { name, references } => write!(
207 f,
208 "clause '{name}' needs a scope to bind {}; a coordinate stream has none: \n traverse it with `for`, which captures those names when it opens",
209 references.join(", ")
210 ),
211 Self::PredicateContextRequired {
212 predicate,
213 references,
214 } => write!(
215 f,
216 "predicate '{predicate}' needs a scope to bind {}; a coordinate stream has none: \
217 traverse it with `for`, which captures those names when it opens",
218 references.join(", ")
219 ),
220 Self::V9UnionClassMismatch { reason } => {
221 write!(f, "V9: union children differ in class: {reason}")
222 }
223 Self::SourceFailed { name, message } => {
224 write!(f, "clause '{name}' cannot be evaluated: {message}")
225 }
226 }
227 }
228}
229
230impl std::error::Error for ValidationError {}
231
232fn write_unresolved(
237 f: &mut std::fmt::Formatter<'_>,
238 reads: &[NameRead],
239 lax: bool,
240) -> std::fmt::Result {
241 let each: Vec<String> = reads
242 .iter()
243 .map(|r| format!("`{}` read by {}", r.name, r.site))
244 .collect();
245 write!(
246 f,
247 "V3: {} {} bound neither by the comprehension nor by the scope it is evaluated in",
248 each.join(", "),
249 if reads.len() == 1 { "is" } else { "are" }
250 )?;
251 if lax {
252 write!(
253 f,
254 ", so {} None, as a name nothing binds does outside `pragma strict`",
255 if reads.len() == 1 {
256 "it reads"
257 } else {
258 "each reads"
259 }
260 )?;
261 }
262 if let Some(bare) = reads.iter().find(|r| r.bare) {
263 write!(
264 f,
265 "; a bare word in a predicate is a name, which nothing supplies: a string in a \
266 predicate is quoted, as in `\"{}\"`, and a scope's name is read as `{{{}}}`",
267 bare.name, bare.name
268 )?;
269 }
270 Ok(())
271}
272
273#[derive(Debug, Clone, PartialEq)]
274pub enum ValidationWarning {
277 DegenerateGeometric {
282 strategy: StrategyName,
284 },
285
286 LhsDegenerate,
289
290 TriviallyTrueFilter,
293
294 TriviallyFalseFilter,
298
299 EmptySource {
313 var: String,
315 source: Option<String>,
317 },
318
319 SingletonCombinator {
324 combinator: &'static str,
326 },
327
328 UnresolvedNames {
333 reads: Vec<NameRead>,
335 },
336}
337
338impl std::fmt::Display for ValidationWarning {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 match self {
341 Self::DegenerateGeometric { strategy } => write!(
342 f,
343 "`{strategy:?}` over a one-axis input collapses to its ends; use `Lex` with a truncation, or restate over a multi-axis cartesian"
344 ),
345 Self::LhsDegenerate => {
346 write!(f, "`Lhs` over a one-axis input is `Shuffle`; say `Shuffle`")
347 }
348 Self::TriviallyTrueFilter => write!(f, "the filter is always true; drop it"),
349 Self::TriviallyFalseFilter => write!(
350 f,
351 "the filter is always false; the comprehension dispenses nothing"
352 ),
353 Self::SingletonCombinator { combinator } => write!(
354 f,
355 "`{combinator}` over one child is that child; the wrapper adds nothing"
356 ),
357 Self::EmptySource { var, source } => match source {
358 Some(text) => write!(
359 f,
360 "`{var} in {text}` has no values; every composition it takes part in dispenses nothing"
361 ),
362 None => write!(
363 f,
364 "`{var}` has no values; every composition it takes part in dispenses nothing"
365 ),
366 },
367 Self::UnresolvedNames { reads } => write_unresolved(f, reads, true),
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
374pub enum ReadSite {
375 Source {
377 clause: String,
379 source: String,
381 },
382 Predicate {
384 predicate: String,
386 },
387}
388
389impl std::fmt::Display for ReadSite {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 Self::Source { clause, source } => write!(f, "clause '{clause}' in `{source}`"),
393 Self::Predicate { predicate } => write!(f, "predicate `{predicate}`"),
394 }
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct NameRead {
402 pub name: String,
404 pub site: ReadSite,
406 pub bare: bool,
409}
410
411#[derive(Clone, Copy)]
414pub enum Surface<'a> {
415 Stream,
417 Traversal(&'a dyn Fn(&str) -> bool),
421}
422
423impl Surface<'_> {
424 pub fn supplies(&self, name: &str) -> bool {
426 match self {
427 Surface::Stream => false,
428 Surface::Traversal(has) => has(name),
429 }
430 }
431}
432
433pub fn outer_reads(c: &Comprehension) -> Vec<NameRead> {
444 let mut out = Vec::new();
445 collect_outer_reads(c, &mut Vec::new(), &mut out);
446 out
447}
448
449fn collect_outer_reads(c: &Comprehension, before: &mut Vec<String>, out: &mut Vec<NameRead>) {
450 match c {
451 Comprehension::Clause { name, source } => {
452 for read in source.referenced_names() {
453 if !before.contains(&read) {
454 out.push(NameRead {
455 name: read,
456 site: ReadSite::Source {
457 clause: name.clone(),
458 source: source.to_text().unwrap_or_else(|| "<source>".into()),
459 },
460 bare: false,
461 });
462 }
463 }
464 }
465 Comprehension::Cartesian { children } => {
466 let depth = before.len();
467 for child in children {
468 collect_outer_reads(child, before, out);
469 before.extend(child.coordinate_names());
470 }
471 before.truncate(depth);
472 }
473 Comprehension::Zip { children, .. } | Comprehension::Union { children } => {
474 for child in children {
475 collect_outer_reads(child, before, out);
476 }
477 }
478 Comprehension::Filter { child, predicate } => {
479 collect_outer_reads(child, before, out);
480 let bound = child.coordinate_names();
481 let reads = polydat_grammar::comprehension::predicate::predicate_reads(predicate);
482 let site = || ReadSite::Predicate {
483 predicate: predicate.clone(),
484 };
485 for name in reads.elements {
486 if !bound.contains(&name) {
487 out.push(NameRead {
488 name,
489 site: site(),
490 bare: false,
491 });
492 }
493 }
494 for name in reads.bare {
495 out.push(NameRead {
496 name,
497 site: site(),
498 bare: true,
499 });
500 }
501 }
502 Comprehension::Order { child, .. } => collect_outer_reads(child, before, out),
503 }
504}
505
506pub fn unresolved_names(c: &Comprehension, surface: Surface<'_>) -> Vec<NameRead> {
513 outer_reads(c)
514 .into_iter()
515 .filter(|r| r.bare || !surface.supplies(&r.name))
516 .collect()
517}
518
519pub fn check_names(c: &Comprehension, surface: Surface<'_>) -> Result<(), ValidationError> {
525 let reads = unresolved_names(c, surface);
526 if reads.is_empty() {
527 Ok(())
528 } else {
529 Err(ValidationError::V3UnresolvedNames { reads })
530 }
531}
532
533pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
544 let mut report = ValidationReport {
545 warnings: Vec::new(),
546 };
547 visit(c, &mut report)?;
548 if mode == Mode::Strict
549 && let Some(warning) = report.warnings.first()
550 {
551 return Err(ValidationError::StrictWarning(warning.clone()));
554 }
555 Ok(report)
556}
557
558fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
559 for child in c.children() {
562 visit(child, report)?;
563 }
564
565 match c {
566 Comprehension::Clause { name, source } => visit_clause(name, source, report),
567 Comprehension::Cartesian { children } => visit_cartesian(children, report),
568 Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
569 Comprehension::Union { children } => visit_union(children, report),
570 Comprehension::Filter { predicate, .. } => visit_filter(predicate, report),
571 Comprehension::Order {
572 child,
573 strategy,
574 truncation,
575 ..
576 } => visit_order(child, *strategy, *truncation, report),
577 }
578}
579
580fn visit_clause(
581 name: &str,
582 source: &Source,
583 report: &mut ValidationReport,
584) -> Result<(), ValidationError> {
585 if matches!(
591 source.cardinality(),
592 crate::iteration::comprehension::CardinalityClass::Bounded(0)
593 ) {
594 report.warnings.push(ValidationWarning::EmptySource {
595 var: name.to_string(),
596 source: source.to_text(),
597 });
598 }
599 if let Source::ContinuousInterval { interval, measure } = source
603 && !measure.is_integrable(std::slice::from_ref(interval))
604 {
605 let _ = report; return Err(ValidationError::V8ContinuousRequirement {
607 reason: format!(
608 "continuous source has non-integrable measure: \
609 interval [{}, {}] + {:?}",
610 interval.lo, interval.hi, measure
611 ),
612 });
613 }
614 if let Source::Distribution {
618 distribution,
619 params,
620 ..
621 } = source
622 && let Err(reason) = distribution.resolve_params(params)
623 {
624 return Err(ValidationError::V8ContinuousRequirement { reason });
625 }
626 Ok(())
627}
628
629fn visit_cartesian(
630 children: &[Comprehension],
631 report: &mut ValidationReport,
632) -> Result<(), ValidationError> {
633 check_disjoint_names("cartesian", children)?;
634 if children.len() == 1 {
635 report
636 .warnings
637 .push(ValidationWarning::SingletonCombinator {
638 combinator: "cartesian",
639 });
640 }
641 Ok(())
642}
643
644fn visit_zip(
645 children: &[Comprehension],
646 mode: ZipMode,
647 report: &mut ValidationReport,
648) -> Result<(), ValidationError> {
649 check_disjoint_names("zip", children)?;
650
651 for child in children {
655 if contains_continuous_source(child) {
656 return Err(ValidationError::V7ZipCardinality {
657 mode,
658 reason: "zip children must all be discrete; \
659 a continuous source was found"
660 .to_string(),
661 });
662 }
663 }
664
665 if children.len() == 1 {
666 report
667 .warnings
668 .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
669 }
670
671 if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
675 for child in children {
676 let card = child.metadata().cardinality;
677 if matches!(card, CardinalityClass::Unbounded) {
678 return Err(ValidationError::V6UnboundedDiscrete {
679 operator: "zip",
680 cardinality: card,
681 });
682 }
683 }
684 }
685
686 Ok(())
687}
688
689fn visit_union(
690 children: &[Comprehension],
691 report: &mut ValidationReport,
692) -> Result<(), ValidationError> {
693 for child in children {
695 if contains_continuous_source(child) {
696 return Err(ValidationError::V9UnionClassMismatch {
697 reason: "union children must all be discrete; \
698 a continuous source was found"
699 .to_string(),
700 });
701 }
702 }
703
704 if let Some(first) = children.first() {
706 let expected = first.coordinate_names();
707 for sibling in &children[1..] {
708 let actual = sibling.coordinate_names();
709 if actual != expected {
710 return Err(ValidationError::V2ShapeMismatch { expected, actual });
711 }
712 }
713 }
714
715 if children.len() == 1 {
716 report
717 .warnings
718 .push(ValidationWarning::SingletonCombinator {
719 combinator: "union",
720 });
721 }
722 Ok(())
723}
724
725fn visit_filter(predicate: &str, report: &mut ValidationReport) -> Result<(), ValidationError> {
726 let trimmed = predicate.trim();
733 if trimmed.eq_ignore_ascii_case("true") {
734 report.warnings.push(ValidationWarning::TriviallyTrueFilter);
735 } else if trimmed.eq_ignore_ascii_case("false") {
736 report
737 .warnings
738 .push(ValidationWarning::TriviallyFalseFilter);
739 }
740
741 Ok(())
742}
743
744fn visit_order(
745 child: &Comprehension,
746 strategy: StrategyName,
747 truncation: Option<u64>,
748 report: &mut ValidationReport,
749) -> Result<(), ValidationError> {
750 let metadata_target = match super::strategies::ranked_filter(child, strategy) {
755 Some((input, _)) => input,
756 None => super::strategies::shape_input(child, strategy),
757 };
758
759 if !matches!(strategy, StrategyName::Lex)
762 && matches!(metadata_target, Comprehension::Filter { .. })
763 {
764 return Err(ValidationError::V4InputShape {
765 strategy,
766 reason: "non-Lex strategy applied to nested filters; \
767 write them as one filter, `where p && q` \
768 (comprehension_forms.md §5 V5)"
769 .to_string(),
770 });
771 }
772
773 let target_metadata = metadata_target.metadata();
774 check_strategy_input_shape(strategy, &target_metadata, report)?;
775
776 if !matches!(strategy, StrategyName::Lex)
779 && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
780 {
781 return Err(ValidationError::V6UnboundedDiscrete {
782 operator: "order",
783 cardinality: target_metadata.cardinality.clone(),
784 });
785 }
786
787 let is_continuous = matches!(
790 target_metadata.cardinality,
791 CardinalityClass::Continuous { .. }
792 | CardinalityClass::ContinuousAtMost { .. }
793 | CardinalityClass::Hybrid(_)
794 );
795 if is_continuous {
796 if truncation.is_none() {
797 return Err(ValidationError::V8ContinuousRequirement {
798 reason: "continuous comprehension requires order(_, \
799 sampling-strategy, Some(n)) with finite \
800 truncation"
801 .to_string(),
802 });
803 }
804 if matches!(strategy, StrategyName::Lex) {
805 return Err(ValidationError::V8ContinuousRequirement {
806 reason: "Lex does not sample continuous inputs; use \
807 Halton / Sobol / Lhs / Shuffle / Extrema"
808 .to_string(),
809 });
810 }
811 }
812
813 Ok(())
814}
815
816fn check_strategy_input_shape(
832 strategy: StrategyName,
833 metadata: &Metadata,
834 report: &mut ValidationReport,
835) -> Result<(), ValidationError> {
836 if matches!(strategy, StrategyName::Lex) {
838 return Ok(());
839 }
840
841 let idx = match &metadata.index_addressable {
842 Some(i) => i,
843 None => {
844 return Err(ValidationError::V4InputShape {
845 strategy,
846 reason: "input has no closed-form index function \
847 (a filter's output, a dependent cartesian, or \
848 a truncated Lex order over one)"
849 .to_string(),
850 });
851 }
852 };
853
854 let has_continuous = idx.has_continuous_axis();
856 if has_continuous {
857 match strategy {
858 StrategyName::Shuffle
860 | StrategyName::Halton
861 | StrategyName::Sobol
862 | StrategyName::Lhs => {}
863 StrategyName::Extrema => {}
865 StrategyName::ReverseLex
867 | StrategyName::Shells
868 | StrategyName::Diagonal
869 | StrategyName::Antidiagonal => {
870 return Err(ValidationError::V4InputShape {
871 strategy,
872 reason: format!("{} does not accept continuous input", strategy.as_str()),
873 });
874 }
875 StrategyName::Lex => unreachable!("Lex handled above"),
876 }
877 if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
880 let dim = continuous_dim(idx);
881 if dim < 2 {
882 if matches!(strategy, StrategyName::Lhs) {
883 report.warnings.push(ValidationWarning::LhsDegenerate);
884 } else {
885 report
886 .warnings
887 .push(ValidationWarning::DegenerateGeometric { strategy });
888 }
889 }
890 }
891 return Ok(());
892 }
893
894 if strategy.is_lattice_geometric() {
897 match idx {
898 IndexFn::Lattice { axis_sizes } => {
899 if axis_sizes.len() < 2 {
900 report
901 .warnings
902 .push(ValidationWarning::DegenerateGeometric { strategy });
903 }
904 }
905 IndexFn::Concatenation { .. } => {
908 return Err(ValidationError::V4InputShape {
909 strategy,
910 reason: format!(
911 "{} requires a cartesian input; got union",
912 strategy.as_str()
913 ),
914 });
915 }
916 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
920 report
921 .warnings
922 .push(ValidationWarning::DegenerateGeometric { strategy });
923 }
924 IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
926 }
927 return Ok(());
928 }
929
930 if matches!(strategy, StrategyName::Lhs) {
932 match idx {
933 IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
934 report.warnings.push(ValidationWarning::LhsDegenerate);
935 }
936 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
937 report.warnings.push(ValidationWarning::LhsDegenerate);
938 }
939 _ => {}
940 }
941 }
942
943 Ok(())
947}
948
949fn continuous_dim(idx: &IndexFn) -> usize {
950 match idx {
951 IndexFn::Continuous { intervals, .. } => intervals.len(),
952 IndexFn::Hybrid {
953 discrete_axes,
954 continuous_axes,
955 ..
956 } => discrete_axes.len() + continuous_axes.len(),
957 _ => 0,
958 }
959}
960
961fn check_disjoint_names(
962 combinator: &'static str,
963 children: &[Comprehension],
964) -> Result<(), ValidationError> {
965 let mut seen: Vec<String> = Vec::new();
966 for child in children {
967 for name in child.coordinate_names() {
968 if seen.contains(&name) {
969 return Err(ValidationError::V1DuplicateName { combinator, name });
970 }
971 seen.push(name);
972 }
973 }
974 Ok(())
975}
976
977fn contains_continuous_source(c: &Comprehension) -> bool {
978 match c {
979 Comprehension::Clause { source, .. } => source.is_continuous(),
980 Comprehension::Cartesian { children }
981 | Comprehension::Zip { children, .. }
982 | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
983 Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
984 contains_continuous_source(child)
985 }
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992 use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
993 use crate::iteration::comprehension::source::{LiteralValue, Source};
994
995 fn clause(name: &str, vs: &[i64]) -> Comprehension {
996 Comprehension::clause(
997 name,
998 Source::Literal {
999 values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
1000 },
1001 )
1002 }
1003
1004 fn empty_warnings(c: &Comprehension) -> Vec<String> {
1005 validate(c, Mode::Permissive)
1006 .expect("an empty source is degenerate, not invalid")
1007 .warnings
1008 .into_iter()
1009 .filter_map(|w| match w {
1010 ValidationWarning::EmptySource { var, .. } => Some(var),
1011 _ => None,
1012 })
1013 .collect()
1014 }
1015
1016 #[test]
1019 fn a_provably_empty_source_warns() {
1020 assert_eq!(empty_warnings(&clause("x", &[])), ["x"]);
1021 assert_eq!(
1022 empty_warnings(&Comprehension::clause(
1023 "k",
1024 Source::IntRange {
1025 lo: 5,
1026 hi: 5,
1027 step: 1,
1028 },
1029 )),
1030 ["k"],
1031 "a half-open range over no values is the same fact"
1032 );
1033 }
1034
1035 #[test]
1039 fn a_source_of_unknown_count_does_not_warn() {
1040 let generator = Comprehension::clause(
1041 "g",
1042 Source::Generator {
1043 expr: "matching_profiles('a')".into(),
1044 cardinality_hint: None,
1045 },
1046 );
1047 assert!(empty_warnings(&generator).is_empty());
1048
1049 let param = Comprehension::clause(
1050 "p",
1051 Source::WorkloadParamList {
1052 name: "sizes".into(),
1053 len_hint: None,
1054 },
1055 );
1056 assert!(empty_warnings(¶m).is_empty());
1057 }
1058
1059 #[test]
1062 fn a_generator_counted_as_zero_warns() {
1063 let counted = Comprehension::clause(
1064 "g",
1065 Source::Generator {
1066 expr: "matching_profiles('nope')".into(),
1067 cardinality_hint: Some(0),
1068 },
1069 );
1070 assert_eq!(empty_warnings(&counted), ["g"]);
1071 }
1072
1073 #[test]
1076 fn an_empty_source_is_the_error_under_strict() {
1077 let c = clause("x", &[]);
1078 assert!(validate(&c, Mode::Permissive).is_ok());
1079 match validate(&c, Mode::Strict) {
1080 Err(ValidationError::StrictWarning(ValidationWarning::EmptySource { var, .. })) => {
1081 assert_eq!(var, "x");
1082 }
1083 other => panic!("expected the empty source to be the strict error, got {other:?}"),
1084 }
1085 }
1086
1087 #[test]
1089 fn an_empty_clause_is_named_inside_a_cartesian() {
1090 let c = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[])]);
1091 assert_eq!(empty_warnings(&c), ["b"]);
1092 }
1093
1094 fn continuous_clause(name: &str) -> Comprehension {
1095 Comprehension::clause(
1096 name,
1097 Source::ContinuousInterval {
1098 interval: Interval::closed(0.0, 1.0),
1099 measure: ProductMeasure::Uniform,
1100 },
1101 )
1102 }
1103
1104 #[test]
1105 fn v1_rejects_duplicate_names_in_cartesian() {
1106 let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
1107 let result = validate(&bad, Mode::Permissive);
1108 assert!(matches!(
1109 result,
1110 Err(ValidationError::V1DuplicateName {
1111 combinator: "cartesian",
1112 ..
1113 })
1114 ));
1115 }
1116
1117 #[test]
1118 fn v1_accepts_disjoint_names() {
1119 let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
1120 assert!(validate(&ok, Mode::Permissive).is_ok());
1121 }
1122
1123 #[test]
1124 fn v2_rejects_union_shape_mismatch() {
1125 let bad = Comprehension::union(vec![
1126 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
1127 Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
1128 ]);
1129 let result = validate(&bad, Mode::Permissive);
1130 assert!(matches!(
1131 result,
1132 Err(ValidationError::V2ShapeMismatch { .. })
1133 ));
1134 }
1135
1136 #[test]
1137 fn v2_accepts_matching_union_shape() {
1138 let ok = Comprehension::union(vec![
1139 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
1140 Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
1141 ]);
1142 assert!(validate(&ok, Mode::Permissive).is_ok());
1143 }
1144
1145 #[test]
1146 fn v4_rejects_lattice_geometric_over_union() {
1147 let bad = Comprehension::order(
1148 Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
1149 StrategyName::Extrema,
1150 Some(2),
1151 );
1152 assert!(matches!(
1153 validate(&bad, Mode::Permissive),
1154 Err(ValidationError::V4InputShape {
1155 strategy: StrategyName::Extrema,
1156 ..
1157 })
1158 ));
1159 }
1160
1161 #[test]
1162 fn v4_lattice_geometric_over_1axis_warns_not_errors() {
1163 let degenerate =
1164 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1165 let report = validate(°enerate, Mode::Permissive).unwrap();
1166 assert!(report.warnings.iter().any(|w| matches!(
1167 w,
1168 ValidationWarning::DegenerateGeometric {
1169 strategy: StrategyName::Extrema
1170 }
1171 )));
1172 }
1173
1174 #[test]
1175 fn v4_strict_mode_promotes_warning() {
1176 let degenerate =
1177 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1178 assert!(validate(°enerate, Mode::Strict).is_err());
1179 }
1180
1181 #[test]
1182 fn v7_rejects_continuous_in_zip() {
1183 let bad = Comprehension::zip(
1184 vec![continuous_clause("alpha"), continuous_clause("beta")],
1185 ZipMode::Strict,
1186 );
1187 assert!(matches!(
1188 validate(&bad, Mode::Permissive),
1189 Err(ValidationError::V7ZipCardinality { .. })
1190 ));
1191 }
1192
1193 #[test]
1194 fn v8_rejects_continuous_without_sampling() {
1195 let bad = continuous_clause("theta");
1197 assert!(validate(&bad, Mode::Permissive).is_ok());
1198 let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
1203 assert!(matches!(
1204 validate(&bad_lex, Mode::Permissive),
1205 Err(ValidationError::V8ContinuousRequirement { .. })
1206 ));
1207 }
1208
1209 #[test]
1210 fn v8_accepts_continuous_with_sampling() {
1211 let ok = Comprehension::order(
1212 Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
1213 StrategyName::Halton,
1214 Some(100),
1215 );
1216 assert!(validate(&ok, Mode::Permissive).is_ok());
1217 }
1218
1219 #[test]
1220 fn v8_rejects_unbounded_uniform_at_source() {
1221 let bad = Comprehension::clause(
1222 "x",
1223 Source::ContinuousInterval {
1224 interval: Interval {
1225 lo: 0.0,
1226 hi: f64::INFINITY,
1227 lo_open: false,
1228 hi_open: true,
1229 },
1230 measure: ProductMeasure::Uniform,
1231 },
1232 );
1233 assert!(matches!(
1234 validate(&bad, Mode::Permissive),
1235 Err(ValidationError::V8ContinuousRequirement { .. })
1236 ));
1237 }
1238
1239 #[test]
1240 fn v9_rejects_continuous_in_union() {
1241 let bad = Comprehension::union(vec![
1242 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1243 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1244 ]);
1245 assert!(matches!(
1247 validate(&bad, Mode::Permissive),
1248 Err(ValidationError::V9UnionClassMismatch { .. })
1249 ));
1250 }
1251
1252 #[test]
1253 fn singleton_combinator_warns() {
1254 let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
1255 let report = validate(°enerate, Mode::Permissive).unwrap();
1256 assert!(report.warnings.iter().any(|w| matches!(
1257 w,
1258 ValidationWarning::SingletonCombinator {
1259 combinator: "cartesian"
1260 }
1261 )));
1262 }
1263
1264 #[test]
1265 fn trivially_true_filter_warns() {
1266 let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
1267 let report = validate(°enerate, Mode::Permissive).unwrap();
1268 assert!(
1269 report
1270 .warnings
1271 .iter()
1272 .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
1273 );
1274 }
1275
1276 fn generator(name: &str, expr: &str) -> Comprehension {
1277 Comprehension::clause(
1278 name,
1279 Source::Generator {
1280 expr: expr.into(),
1281 cardinality_hint: None,
1282 },
1283 )
1284 }
1285
1286 fn unresolved(c: &Comprehension, surface: Surface<'_>) -> Vec<(String, bool)> {
1287 match check_names(c, surface) {
1288 Ok(()) => Vec::new(),
1289 Err(ValidationError::V3UnresolvedNames { reads }) => {
1290 reads.into_iter().map(|r| (r.name, r.bare)).collect()
1291 }
1292 Err(other) => panic!("expected V3, got {other}"),
1293 }
1294 }
1295
1296 #[test]
1300 fn v3_binds_earlier_axes_for_sources_and_the_tuple_for_predicates() {
1301 let dependent = Comprehension::cartesian(vec![
1302 clause("a", &[1, 2]),
1303 generator("b", "0..{a}"),
1304 generator("c", "0..{b}"),
1305 ]);
1306 assert!(check_names(&dependent, Surface::Stream).is_ok());
1307 let filtered = Comprehension::filter(dependent.clone(), "{a} < {c}");
1308 assert!(check_names(&filtered, Surface::Stream).is_ok());
1309 let later = Comprehension::cartesian(vec![generator("b", "0..{a}"), clause("a", &[1])]);
1312 assert_eq!(
1313 unresolved(&later, Surface::Stream),
1314 [("a".to_string(), false)]
1315 );
1316 let zipped = Comprehension::zip(
1317 vec![clause("a", &[1]), generator("b", "0..{a}")],
1318 ZipMode::Truncate,
1319 );
1320 assert_eq!(
1321 unresolved(&zipped, Surface::Stream),
1322 [("a".to_string(), false)]
1323 );
1324 let inner = Comprehension::cartesian(vec![
1326 clause("a", &[1, 2]),
1327 Comprehension::filter(clause("b", &[1, 2]), "{b} < {a}"),
1328 ]);
1329 assert_eq!(
1330 unresolved(&inner, Surface::Stream),
1331 [("a".to_string(), false)]
1332 );
1333 }
1334
1335 #[test]
1338 fn v3_closes_names_over_the_surface() {
1339 let c = Comprehension::filter(
1340 Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
1341 "{k} < {limit} && {k} != s1",
1342 );
1343 assert_eq!(
1344 unresolved(&c, Surface::Stream),
1345 [
1346 ("n".to_string(), false),
1347 ("limit".to_string(), false),
1348 ("s1".to_string(), true)
1349 ]
1350 );
1351 let scope = |n: &str| n == "n" || n == "limit" || n == "s1";
1352 assert_eq!(
1353 unresolved(&c, Surface::Traversal(&scope)),
1354 [("s1".to_string(), true)]
1355 );
1356 let quoted = Comprehension::filter(
1357 Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
1358 "{k} < {limit} && {k} != \"s1\"",
1359 );
1360 assert!(check_names("ed, Surface::Traversal(&scope)).is_ok());
1361 let err = check_names(&c, Surface::Stream).unwrap_err().to_string();
1362 assert!(err.starts_with("V3:"), "{err}");
1363 assert!(err.contains("`n` read by clause 'j' in `0..{n}`"), "{err}");
1364 assert!(
1365 err.contains("`limit` read by predicate `{k} < {limit} && {k} != s1`"),
1366 "{err}"
1367 );
1368 assert!(err.contains("as in `\"s1\"`"), "{err}");
1369 }
1370
1371 #[test]
1374 fn v3_reads_opaque_expressions_as_the_language_does() {
1375 let c = Comprehension::filter(
1376 clause("k", &[1, 2]),
1377 "u64_add({k}, width) > 1 && {k} as f64 > 0.5 && true",
1378 );
1379 assert_eq!(
1380 unresolved(&c, Surface::Stream),
1381 [("width".to_string(), true)]
1382 );
1383 }
1384}