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 mut each: Vec<String> = reads
244 .iter()
245 .map(
246 |r| match polydat_grammar::comprehension::source::cursor_of_extent_name(&r.name) {
247 Some(cursor) => format!("the extent of cursor `{cursor}` read by {}", r.site),
248 None => format!("`{}` read by {}", r.name, r.site),
249 },
250 )
251 .collect();
252 each.dedup();
253 write!(
254 f,
255 "V3: {} {} bound neither by the comprehension nor by the scope it is evaluated in",
256 each.join(", "),
257 if each.len() == 1 { "is" } else { "are" }
258 )?;
259 if lax {
260 write!(
261 f,
262 ", so {} None, as a name nothing binds does outside `pragma strict`",
263 if each.len() == 1 {
264 "it reads"
265 } else {
266 "each reads"
267 }
268 )?;
269 }
270 if let Some(bare) = reads.iter().find(|r| r.bare) {
271 write!(
272 f,
273 "; a bare word in a predicate is a name, which nothing supplies: a string in a \
274 predicate is quoted, as in `\"{}\"`, and a scope's name is read as `{{{}}}`",
275 bare.name, bare.name
276 )?;
277 }
278 Ok(())
279}
280
281#[derive(Debug, Clone, PartialEq)]
282pub enum ValidationWarning {
285 DegenerateGeometric {
290 strategy: StrategyName,
292 },
293
294 LhsDegenerate,
297
298 TriviallyTrueFilter,
301
302 TriviallyFalseFilter,
306
307 EmptySource {
321 var: String,
323 source: Option<String>,
325 },
326
327 SingletonCombinator {
332 combinator: &'static str,
334 },
335
336 UnresolvedNames {
341 reads: Vec<NameRead>,
343 },
344}
345
346impl std::fmt::Display for ValidationWarning {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 match self {
349 Self::DegenerateGeometric { strategy } => write!(
350 f,
351 "`{strategy:?}` over a one-axis input collapses to its ends; use `Lex` with a truncation, or restate over a multi-axis cartesian"
352 ),
353 Self::LhsDegenerate => {
354 write!(f, "`Lhs` over a one-axis input is `Shuffle`; say `Shuffle`")
355 }
356 Self::TriviallyTrueFilter => write!(f, "the filter is always true; drop it"),
357 Self::TriviallyFalseFilter => write!(
358 f,
359 "the filter is always false; the comprehension dispenses nothing"
360 ),
361 Self::SingletonCombinator { combinator } => write!(
362 f,
363 "`{combinator}` over one child is that child; the wrapper adds nothing"
364 ),
365 Self::EmptySource { var, source } => match source {
366 Some(text) => write!(
367 f,
368 "`{var} in {text}` has no values; every composition it takes part in dispenses nothing"
369 ),
370 None => write!(
371 f,
372 "`{var}` has no values; every composition it takes part in dispenses nothing"
373 ),
374 },
375 Self::UnresolvedNames { reads } => write_unresolved(f, reads, true),
376 }
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum ReadSite {
383 Source {
385 clause: String,
387 source: String,
389 },
390 Predicate {
392 predicate: String,
394 },
395}
396
397impl std::fmt::Display for ReadSite {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 match self {
400 Self::Source { clause, source } => write!(f, "clause '{clause}' in `{source}`"),
401 Self::Predicate { predicate } => write!(f, "predicate `{predicate}`"),
402 }
403 }
404}
405
406#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct NameRead {
410 pub name: String,
412 pub site: ReadSite,
414 pub bare: bool,
417}
418
419#[derive(Clone, Copy)]
422pub enum Surface<'a> {
423 Stream,
425 Traversal(&'a dyn Fn(&str) -> bool),
429}
430
431impl Surface<'_> {
432 pub fn supplies(&self, name: &str) -> bool {
434 match self {
435 Surface::Stream => false,
436 Surface::Traversal(has) => has(name),
437 }
438 }
439}
440
441pub fn outer_reads(c: &Comprehension) -> Vec<NameRead> {
456 let mut out = Vec::new();
457 collect_outer_reads(c, &mut Vec::new(), &mut out);
458 out
459}
460
461fn collect_outer_reads(c: &Comprehension, before: &mut Vec<String>, out: &mut Vec<NameRead>) {
462 match c {
463 Comprehension::Clause { name, source } => {
464 for read in source.names_read() {
465 if !before.contains(&read) {
466 out.push(NameRead {
467 name: read,
468 site: ReadSite::Source {
469 clause: name.clone(),
470 source: source.to_text().unwrap_or_else(|| "<source>".into()),
471 },
472 bare: false,
473 });
474 }
475 }
476 }
477 Comprehension::Cartesian { children } => {
478 let depth = before.len();
479 for child in children {
480 collect_outer_reads(child, before, out);
481 before.extend(child.coordinate_names());
482 }
483 before.truncate(depth);
484 }
485 Comprehension::Zip { children, .. } | Comprehension::Union { children } => {
486 for child in children {
487 collect_outer_reads(child, before, out);
488 }
489 }
490 Comprehension::Filter { child, predicate } => {
491 collect_outer_reads(child, before, out);
492 let bound = child.coordinate_names();
493 let reads = polydat_grammar::comprehension::predicate::predicate_reads(predicate);
494 let site = || ReadSite::Predicate {
495 predicate: predicate.clone(),
496 };
497 for name in reads.elements {
498 if !bound.contains(&name) {
499 out.push(NameRead {
500 name,
501 site: site(),
502 bare: false,
503 });
504 }
505 }
506 for name in reads.bare {
507 out.push(NameRead {
508 name,
509 site: site(),
510 bare: true,
511 });
512 }
513 }
514 Comprehension::Order { child, .. } => collect_outer_reads(child, before, out),
515 }
516}
517
518pub fn unresolved_names(c: &Comprehension, surface: Surface<'_>) -> Vec<NameRead> {
525 outer_reads(c)
526 .into_iter()
527 .filter(|r| r.bare || !surface.supplies(&r.name))
528 .collect()
529}
530
531pub fn check_names(c: &Comprehension, surface: Surface<'_>) -> Result<(), ValidationError> {
537 let reads = unresolved_names(c, surface);
538 if reads.is_empty() {
539 Ok(())
540 } else {
541 Err(ValidationError::V3UnresolvedNames { reads })
542 }
543}
544
545pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
556 let mut report = ValidationReport {
557 warnings: Vec::new(),
558 };
559 visit(c, &mut report)?;
560 if mode == Mode::Strict
561 && let Some(warning) = report.warnings.first()
562 {
563 return Err(ValidationError::StrictWarning(warning.clone()));
566 }
567 Ok(report)
568}
569
570fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
571 for child in c.children() {
574 visit(child, report)?;
575 }
576
577 match c {
578 Comprehension::Clause { name, source } => visit_clause(name, source, report),
579 Comprehension::Cartesian { children } => visit_cartesian(children, report),
580 Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
581 Comprehension::Union { children } => visit_union(children, report),
582 Comprehension::Filter { predicate, .. } => visit_filter(predicate, report),
583 Comprehension::Order {
584 child,
585 strategy,
586 truncation,
587 ..
588 } => visit_order(child, *strategy, *truncation, report),
589 }
590}
591
592fn visit_clause(
593 name: &str,
594 source: &Source,
595 report: &mut ValidationReport,
596) -> Result<(), ValidationError> {
597 if matches!(
603 source.cardinality(),
604 crate::iteration::comprehension::CardinalityClass::Bounded(0)
605 ) {
606 report.warnings.push(ValidationWarning::EmptySource {
607 var: name.to_string(),
608 source: source.to_text(),
609 });
610 }
611 if let Source::ContinuousInterval { interval, measure } = source
615 && !measure.is_integrable(std::slice::from_ref(interval))
616 {
617 let _ = report; return Err(ValidationError::V8ContinuousRequirement {
619 reason: format!(
620 "continuous source has non-integrable measure: \
621 interval [{}, {}] + {:?}",
622 interval.lo, interval.hi, measure
623 ),
624 });
625 }
626 if let Source::Distribution {
630 distribution,
631 params,
632 ..
633 } = source
634 && let Err(reason) = distribution.resolve_params(params)
635 {
636 return Err(ValidationError::V8ContinuousRequirement { reason });
637 }
638 Ok(())
639}
640
641fn visit_cartesian(
642 children: &[Comprehension],
643 report: &mut ValidationReport,
644) -> Result<(), ValidationError> {
645 check_disjoint_names("cartesian", children)?;
646 if children.len() == 1 {
647 report
648 .warnings
649 .push(ValidationWarning::SingletonCombinator {
650 combinator: "cartesian",
651 });
652 }
653 Ok(())
654}
655
656fn visit_zip(
657 children: &[Comprehension],
658 mode: ZipMode,
659 report: &mut ValidationReport,
660) -> Result<(), ValidationError> {
661 check_disjoint_names("zip", children)?;
662
663 for child in children {
667 if contains_continuous_source(child) {
668 return Err(ValidationError::V7ZipCardinality {
669 mode,
670 reason: "zip children must all be discrete; \
671 a continuous source was found"
672 .to_string(),
673 });
674 }
675 }
676
677 if children.len() == 1 {
678 report
679 .warnings
680 .push(ValidationWarning::SingletonCombinator { combinator: "zip" });
681 }
682
683 if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
687 for child in children {
688 let card = child.metadata().cardinality;
689 if matches!(card, CardinalityClass::Unbounded) {
690 return Err(ValidationError::V6UnboundedDiscrete {
691 operator: "zip",
692 cardinality: card,
693 });
694 }
695 }
696 }
697
698 Ok(())
699}
700
701fn visit_union(
702 children: &[Comprehension],
703 report: &mut ValidationReport,
704) -> Result<(), ValidationError> {
705 for child in children {
707 if contains_continuous_source(child) {
708 return Err(ValidationError::V9UnionClassMismatch {
709 reason: "union children must all be discrete; \
710 a continuous source was found"
711 .to_string(),
712 });
713 }
714 }
715
716 if let Some(first) = children.first() {
718 let expected = first.coordinate_names();
719 for sibling in &children[1..] {
720 let actual = sibling.coordinate_names();
721 if actual != expected {
722 return Err(ValidationError::V2ShapeMismatch { expected, actual });
723 }
724 }
725 }
726
727 if children.len() == 1 {
728 report
729 .warnings
730 .push(ValidationWarning::SingletonCombinator {
731 combinator: "union",
732 });
733 }
734 Ok(())
735}
736
737fn visit_filter(predicate: &str, report: &mut ValidationReport) -> Result<(), ValidationError> {
738 let trimmed = predicate.trim();
745 if trimmed.eq_ignore_ascii_case("true") {
746 report.warnings.push(ValidationWarning::TriviallyTrueFilter);
747 } else if trimmed.eq_ignore_ascii_case("false") {
748 report
749 .warnings
750 .push(ValidationWarning::TriviallyFalseFilter);
751 }
752
753 Ok(())
754}
755
756fn visit_order(
757 child: &Comprehension,
758 strategy: StrategyName,
759 truncation: Option<u64>,
760 report: &mut ValidationReport,
761) -> Result<(), ValidationError> {
762 let metadata_target = match super::strategies::ranked_filter(child, strategy) {
767 Some((input, _)) => input,
768 None => super::strategies::shape_input(child, strategy),
769 };
770
771 if !matches!(strategy, StrategyName::Lex)
774 && matches!(metadata_target, Comprehension::Filter { .. })
775 {
776 return Err(ValidationError::V4InputShape {
777 strategy,
778 reason: "non-Lex strategy applied to nested filters; \
779 write them as one filter, `where p && q` \
780 (comprehension_forms.md §5 V5)"
781 .to_string(),
782 });
783 }
784
785 let target_metadata = metadata_target.metadata();
786 check_strategy_input_shape(strategy, &target_metadata, report)?;
787
788 if !matches!(strategy, StrategyName::Lex)
791 && matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
792 {
793 return Err(ValidationError::V6UnboundedDiscrete {
794 operator: "order",
795 cardinality: target_metadata.cardinality.clone(),
796 });
797 }
798
799 let is_continuous = matches!(
802 target_metadata.cardinality,
803 CardinalityClass::Continuous { .. }
804 | CardinalityClass::ContinuousAtMost { .. }
805 | CardinalityClass::Hybrid(_)
806 );
807 if is_continuous {
808 if truncation.is_none() {
809 return Err(ValidationError::V8ContinuousRequirement {
810 reason: "continuous comprehension requires order(_, \
811 sampling-strategy, Some(n)) with finite \
812 truncation"
813 .to_string(),
814 });
815 }
816 if matches!(strategy, StrategyName::Lex) {
817 return Err(ValidationError::V8ContinuousRequirement {
818 reason: "Lex does not sample continuous inputs; use \
819 Halton / Sobol / Lhs / Shuffle / Extrema"
820 .to_string(),
821 });
822 }
823 }
824
825 Ok(())
826}
827
828fn check_strategy_input_shape(
844 strategy: StrategyName,
845 metadata: &Metadata,
846 report: &mut ValidationReport,
847) -> Result<(), ValidationError> {
848 if matches!(strategy, StrategyName::Lex) {
850 return Ok(());
851 }
852
853 let idx = match &metadata.index_addressable {
854 Some(i) => i,
855 None => {
856 return Err(ValidationError::V4InputShape {
857 strategy,
858 reason: "input has no closed-form index function \
859 (a filter's output, a dependent cartesian, or \
860 a truncated Lex order over one)"
861 .to_string(),
862 });
863 }
864 };
865
866 let has_continuous = idx.has_continuous_axis();
868 if has_continuous {
869 match strategy {
870 StrategyName::Shuffle
872 | StrategyName::Halton
873 | StrategyName::Sobol
874 | StrategyName::Lhs => {}
875 StrategyName::Extrema => {}
877 StrategyName::ReverseLex
879 | StrategyName::Shells
880 | StrategyName::Diagonal
881 | StrategyName::Antidiagonal => {
882 return Err(ValidationError::V4InputShape {
883 strategy,
884 reason: format!("{} does not accept continuous input", strategy.as_str()),
885 });
886 }
887 StrategyName::Lex => unreachable!("Lex handled above"),
888 }
889 if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
892 let dim = continuous_dim(idx);
893 if dim < 2 {
894 if matches!(strategy, StrategyName::Lhs) {
895 report.warnings.push(ValidationWarning::LhsDegenerate);
896 } else {
897 report
898 .warnings
899 .push(ValidationWarning::DegenerateGeometric { strategy });
900 }
901 }
902 }
903 return Ok(());
904 }
905
906 if strategy.is_lattice_geometric() {
909 match idx {
910 IndexFn::Lattice { axis_sizes } => {
911 if axis_sizes.len() < 2 {
912 report
913 .warnings
914 .push(ValidationWarning::DegenerateGeometric { strategy });
915 }
916 }
917 IndexFn::Concatenation { .. } => {
920 return Err(ValidationError::V4InputShape {
921 strategy,
922 reason: format!(
923 "{} requires a cartesian input; got union",
924 strategy.as_str()
925 ),
926 });
927 }
928 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
932 report
933 .warnings
934 .push(ValidationWarning::DegenerateGeometric { strategy });
935 }
936 IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
938 }
939 return Ok(());
940 }
941
942 if matches!(strategy, StrategyName::Lhs) {
944 match idx {
945 IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
946 report.warnings.push(ValidationWarning::LhsDegenerate);
947 }
948 IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
949 report.warnings.push(ValidationWarning::LhsDegenerate);
950 }
951 _ => {}
952 }
953 }
954
955 Ok(())
959}
960
961fn continuous_dim(idx: &IndexFn) -> usize {
962 match idx {
963 IndexFn::Continuous { intervals, .. } => intervals.len(),
964 IndexFn::Hybrid {
965 discrete_axes,
966 continuous_axes,
967 ..
968 } => discrete_axes.len() + continuous_axes.len(),
969 _ => 0,
970 }
971}
972
973fn check_disjoint_names(
974 combinator: &'static str,
975 children: &[Comprehension],
976) -> Result<(), ValidationError> {
977 let mut seen: Vec<String> = Vec::new();
978 for child in children {
979 for name in child.coordinate_names() {
980 if seen.contains(&name) {
981 return Err(ValidationError::V1DuplicateName { combinator, name });
982 }
983 seen.push(name);
984 }
985 }
986 Ok(())
987}
988
989fn contains_continuous_source(c: &Comprehension) -> bool {
990 match c {
991 Comprehension::Clause { source, .. } => source.is_continuous(),
992 Comprehension::Cartesian { children }
993 | Comprehension::Zip { children, .. }
994 | Comprehension::Union { children } => children.iter().any(contains_continuous_source),
995 Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
996 contains_continuous_source(child)
997 }
998 }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004 use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
1005 use crate::iteration::comprehension::source::{LiteralValue, Source};
1006
1007 fn clause(name: &str, vs: &[i64]) -> Comprehension {
1008 Comprehension::clause(
1009 name,
1010 Source::Literal {
1011 values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
1012 },
1013 )
1014 }
1015
1016 fn empty_warnings(c: &Comprehension) -> Vec<String> {
1017 validate(c, Mode::Permissive)
1018 .expect("an empty source is degenerate, not invalid")
1019 .warnings
1020 .into_iter()
1021 .filter_map(|w| match w {
1022 ValidationWarning::EmptySource { var, .. } => Some(var),
1023 _ => None,
1024 })
1025 .collect()
1026 }
1027
1028 #[test]
1031 fn a_provably_empty_source_warns() {
1032 assert_eq!(empty_warnings(&clause("x", &[])), ["x"]);
1033 assert_eq!(
1034 empty_warnings(&Comprehension::clause(
1035 "k",
1036 Source::IntRange {
1037 lo: 5,
1038 hi: 5,
1039 step: 1,
1040 },
1041 )),
1042 ["k"],
1043 "a half-open range over no values is the same fact"
1044 );
1045 }
1046
1047 #[test]
1051 fn a_source_of_unknown_count_does_not_warn() {
1052 let generator = Comprehension::clause(
1053 "g",
1054 Source::Generator {
1055 expr: "matching_profiles('a')".into(),
1056 cardinality_hint: None,
1057 },
1058 );
1059 assert!(empty_warnings(&generator).is_empty());
1060
1061 let param = Comprehension::clause(
1062 "p",
1063 Source::WorkloadParamList {
1064 name: "sizes".into(),
1065 len_hint: None,
1066 },
1067 );
1068 assert!(empty_warnings(¶m).is_empty());
1069 }
1070
1071 #[test]
1074 fn a_generator_counted_as_zero_warns() {
1075 let counted = Comprehension::clause(
1076 "g",
1077 Source::Generator {
1078 expr: "matching_profiles('nope')".into(),
1079 cardinality_hint: Some(0),
1080 },
1081 );
1082 assert_eq!(empty_warnings(&counted), ["g"]);
1083 }
1084
1085 #[test]
1088 fn an_empty_source_is_the_error_under_strict() {
1089 let c = clause("x", &[]);
1090 assert!(validate(&c, Mode::Permissive).is_ok());
1091 match validate(&c, Mode::Strict) {
1092 Err(ValidationError::StrictWarning(ValidationWarning::EmptySource { var, .. })) => {
1093 assert_eq!(var, "x");
1094 }
1095 other => panic!("expected the empty source to be the strict error, got {other:?}"),
1096 }
1097 }
1098
1099 #[test]
1101 fn an_empty_clause_is_named_inside_a_cartesian() {
1102 let c = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[])]);
1103 assert_eq!(empty_warnings(&c), ["b"]);
1104 }
1105
1106 fn continuous_clause(name: &str) -> Comprehension {
1107 Comprehension::clause(
1108 name,
1109 Source::ContinuousInterval {
1110 interval: Interval::closed(0.0, 1.0),
1111 measure: ProductMeasure::Uniform,
1112 },
1113 )
1114 }
1115
1116 #[test]
1117 fn v1_rejects_duplicate_names_in_cartesian() {
1118 let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
1119 let result = validate(&bad, Mode::Permissive);
1120 assert!(matches!(
1121 result,
1122 Err(ValidationError::V1DuplicateName {
1123 combinator: "cartesian",
1124 ..
1125 })
1126 ));
1127 }
1128
1129 #[test]
1130 fn v1_accepts_disjoint_names() {
1131 let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
1132 assert!(validate(&ok, Mode::Permissive).is_ok());
1133 }
1134
1135 #[test]
1136 fn v2_rejects_union_shape_mismatch() {
1137 let bad = Comprehension::union(vec![
1138 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
1139 Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
1140 ]);
1141 let result = validate(&bad, Mode::Permissive);
1142 assert!(matches!(
1143 result,
1144 Err(ValidationError::V2ShapeMismatch { .. })
1145 ));
1146 }
1147
1148 #[test]
1149 fn v2_accepts_matching_union_shape() {
1150 let ok = Comprehension::union(vec![
1151 Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
1152 Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
1153 ]);
1154 assert!(validate(&ok, Mode::Permissive).is_ok());
1155 }
1156
1157 #[test]
1158 fn v4_rejects_lattice_geometric_over_union() {
1159 let bad = Comprehension::order(
1160 Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
1161 StrategyName::Extrema,
1162 Some(2),
1163 );
1164 assert!(matches!(
1165 validate(&bad, Mode::Permissive),
1166 Err(ValidationError::V4InputShape {
1167 strategy: StrategyName::Extrema,
1168 ..
1169 })
1170 ));
1171 }
1172
1173 #[test]
1174 fn v4_lattice_geometric_over_1axis_warns_not_errors() {
1175 let degenerate =
1176 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1177 let report = validate(°enerate, Mode::Permissive).unwrap();
1178 assert!(report.warnings.iter().any(|w| matches!(
1179 w,
1180 ValidationWarning::DegenerateGeometric {
1181 strategy: StrategyName::Extrema
1182 }
1183 )));
1184 }
1185
1186 #[test]
1187 fn v4_strict_mode_promotes_warning() {
1188 let degenerate =
1189 Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
1190 assert!(validate(°enerate, Mode::Strict).is_err());
1191 }
1192
1193 #[test]
1194 fn v7_rejects_continuous_in_zip() {
1195 let bad = Comprehension::zip(
1196 vec![continuous_clause("alpha"), continuous_clause("beta")],
1197 ZipMode::Strict,
1198 );
1199 assert!(matches!(
1200 validate(&bad, Mode::Permissive),
1201 Err(ValidationError::V7ZipCardinality { .. })
1202 ));
1203 }
1204
1205 #[test]
1206 fn v8_rejects_continuous_without_sampling() {
1207 let bad = continuous_clause("theta");
1209 assert!(validate(&bad, Mode::Permissive).is_ok());
1210 let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
1215 assert!(matches!(
1216 validate(&bad_lex, Mode::Permissive),
1217 Err(ValidationError::V8ContinuousRequirement { .. })
1218 ));
1219 }
1220
1221 #[test]
1222 fn v8_accepts_continuous_with_sampling() {
1223 let ok = Comprehension::order(
1224 Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
1225 StrategyName::Halton,
1226 Some(100),
1227 );
1228 assert!(validate(&ok, Mode::Permissive).is_ok());
1229 }
1230
1231 #[test]
1232 fn v8_rejects_unbounded_uniform_at_source() {
1233 let bad = Comprehension::clause(
1234 "x",
1235 Source::ContinuousInterval {
1236 interval: Interval {
1237 lo: 0.0,
1238 hi: f64::INFINITY,
1239 lo_open: false,
1240 hi_open: true,
1241 },
1242 measure: ProductMeasure::Uniform,
1243 },
1244 );
1245 assert!(matches!(
1246 validate(&bad, Mode::Permissive),
1247 Err(ValidationError::V8ContinuousRequirement { .. })
1248 ));
1249 }
1250
1251 #[test]
1252 fn v9_rejects_continuous_in_union() {
1253 let bad = Comprehension::union(vec![
1254 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1255 Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
1256 ]);
1257 assert!(matches!(
1259 validate(&bad, Mode::Permissive),
1260 Err(ValidationError::V9UnionClassMismatch { .. })
1261 ));
1262 }
1263
1264 #[test]
1265 fn singleton_combinator_warns() {
1266 let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
1267 let report = validate(°enerate, Mode::Permissive).unwrap();
1268 assert!(report.warnings.iter().any(|w| matches!(
1269 w,
1270 ValidationWarning::SingletonCombinator {
1271 combinator: "cartesian"
1272 }
1273 )));
1274 }
1275
1276 #[test]
1277 fn trivially_true_filter_warns() {
1278 let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
1279 let report = validate(°enerate, Mode::Permissive).unwrap();
1280 assert!(
1281 report
1282 .warnings
1283 .iter()
1284 .any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
1285 );
1286 }
1287
1288 fn generator(name: &str, expr: &str) -> Comprehension {
1289 Comprehension::clause(
1290 name,
1291 Source::Generator {
1292 expr: expr.into(),
1293 cardinality_hint: None,
1294 },
1295 )
1296 }
1297
1298 fn unresolved(c: &Comprehension, surface: Surface<'_>) -> Vec<(String, bool)> {
1299 match check_names(c, surface) {
1300 Ok(()) => Vec::new(),
1301 Err(ValidationError::V3UnresolvedNames { reads }) => {
1302 reads.into_iter().map(|r| (r.name, r.bare)).collect()
1303 }
1304 Err(other) => panic!("expected V3, got {other}"),
1305 }
1306 }
1307
1308 #[test]
1312 fn v3_binds_earlier_axes_for_sources_and_the_tuple_for_predicates() {
1313 let dependent = Comprehension::cartesian(vec![
1314 clause("a", &[1, 2]),
1315 generator("b", "0..{a}"),
1316 generator("c", "0..{b}"),
1317 ]);
1318 assert!(check_names(&dependent, Surface::Stream).is_ok());
1319 let filtered = Comprehension::filter(dependent.clone(), "{a} < {c}");
1320 assert!(check_names(&filtered, Surface::Stream).is_ok());
1321 let later = Comprehension::cartesian(vec![generator("b", "0..{a}"), clause("a", &[1])]);
1324 assert_eq!(
1325 unresolved(&later, Surface::Stream),
1326 [("a".to_string(), false)]
1327 );
1328 let zipped = Comprehension::zip(
1329 vec![clause("a", &[1]), generator("b", "0..{a}")],
1330 ZipMode::Truncate,
1331 );
1332 assert_eq!(
1333 unresolved(&zipped, Surface::Stream),
1334 [("a".to_string(), false)]
1335 );
1336 let inner = Comprehension::cartesian(vec![
1338 clause("a", &[1, 2]),
1339 Comprehension::filter(clause("b", &[1, 2]), "{b} < {a}"),
1340 ]);
1341 assert_eq!(
1342 unresolved(&inner, Surface::Stream),
1343 [("a".to_string(), false)]
1344 );
1345 }
1346
1347 #[test]
1350 fn v3_closes_names_over_the_surface() {
1351 let c = Comprehension::filter(
1352 Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
1353 "{k} < {limit} && {k} != s1",
1354 );
1355 assert_eq!(
1356 unresolved(&c, Surface::Stream),
1357 [
1358 ("n".to_string(), false),
1359 ("limit".to_string(), false),
1360 ("s1".to_string(), true)
1361 ]
1362 );
1363 let scope = |n: &str| n == "n" || n == "limit" || n == "s1";
1364 assert_eq!(
1365 unresolved(&c, Surface::Traversal(&scope)),
1366 [("s1".to_string(), true)]
1367 );
1368 let quoted = Comprehension::filter(
1369 Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
1370 "{k} < {limit} && {k} != \"s1\"",
1371 );
1372 assert!(check_names("ed, Surface::Traversal(&scope)).is_ok());
1373 let err = check_names(&c, Surface::Stream).unwrap_err().to_string();
1374 assert!(err.starts_with("V3:"), "{err}");
1375 assert!(err.contains("`n` read by clause 'j' in `0..{n}`"), "{err}");
1376 assert!(
1377 err.contains("`limit` read by predicate `{k} < {limit} && {k} != s1`"),
1378 "{err}"
1379 );
1380 assert!(err.contains("as in `\"s1\"`"), "{err}");
1381 }
1382
1383 #[test]
1386 fn v3_reads_opaque_expressions_as_the_language_does() {
1387 let c = Comprehension::filter(
1388 clause("k", &[1, 2]),
1389 "u64_add({k}, width) > 1 && {k} as f64 > 0.5 && true",
1390 );
1391 assert_eq!(
1392 unresolved(&c, Surface::Stream),
1393 [("width".to_string(), true)]
1394 );
1395 }
1396}