use serde::{Deserialize, Serialize};
use super::ast::Comprehension;
use super::cardinality::CardinalityClass;
use super::metadata::{IndexFn, Metadata};
use super::source::Source;
use super::strategy::{StrategyName, ZipMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Mode {
#[default]
Permissive,
Strict,
}
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub warnings: Vec<ValidationWarning>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationError {
V1DuplicateName {
combinator: &'static str,
name: String,
},
V2ShapeMismatch {
expected: Vec<String>,
actual: Vec<String>,
},
V3UnresolvedNames {
reads: Vec<NameRead>,
},
V4InputShape {
strategy: StrategyName,
reason: String,
},
V6UnboundedDiscrete {
operator: &'static str,
cardinality: CardinalityClass,
},
V7ZipCardinality {
mode: ZipMode,
reason: String,
},
V8ContinuousRequirement {
reason: String,
},
V9UnionClassMismatch {
reason: String,
},
StrictWarning(ValidationWarning),
ContextRequired {
name: String,
references: Vec<String>,
},
PredicateContextRequired {
predicate: String,
references: Vec<String>,
},
SourceFailed {
name: String,
message: String,
},
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::V1DuplicateName { combinator, name } => {
write!(
f,
"V1: `{combinator}` binds the name `{name}` more than once"
)
}
Self::V2ShapeMismatch { expected, actual } => write!(
f,
"V2: tuple shape ({}) does not match ({})",
actual.join(", "),
expected.join(", ")
),
Self::V3UnresolvedNames { reads } => write_unresolved(f, reads, false),
Self::V4InputShape { strategy, reason } => {
write!(
f,
"V4: strategy `{strategy:?}` cannot take this input: {reason}"
)
}
Self::V6UnboundedDiscrete {
operator,
cardinality,
} => write!(
f,
"V6: `{operator}` cannot materialize a {cardinality:?} stream"
),
Self::V7ZipCardinality { mode, reason } => {
write!(f, "V7: zip in {mode:?} mode: {reason}")
}
Self::V8ContinuousRequirement { reason } => {
write!(f, "V8: continuous source: {reason}")
}
Self::StrictWarning(w) => write!(f, "strict mode: {w}"),
Self::ContextRequired { name, references } => write!(
f,
"clause '{name}' needs a scope to bind {}; a coordinate stream has none: \n traverse it with `for`, which captures those names when it opens",
references.join(", ")
),
Self::PredicateContextRequired {
predicate,
references,
} => write!(
f,
"predicate '{predicate}' needs a scope to bind {}; a coordinate stream has none: \
traverse it with `for`, which captures those names when it opens",
references.join(", ")
),
Self::V9UnionClassMismatch { reason } => {
write!(f, "V9: union children differ in class: {reason}")
}
Self::SourceFailed { name, message } => {
write!(f, "clause '{name}' cannot be evaluated: {message}")
}
}
}
}
impl std::error::Error for ValidationError {}
fn write_unresolved(
f: &mut std::fmt::Formatter<'_>,
reads: &[NameRead],
lax: bool,
) -> std::fmt::Result {
let mut each: Vec<String> = reads
.iter()
.map(
|r| match polydat_grammar::comprehension::source::cursor_of_extent_name(&r.name) {
Some(cursor) => format!("the extent of cursor `{cursor}` read by {}", r.site),
None => format!("`{}` read by {}", r.name, r.site),
},
)
.collect();
each.dedup();
write!(
f,
"V3: {} {} bound neither by the comprehension nor by the scope it is evaluated in",
each.join(", "),
if each.len() == 1 { "is" } else { "are" }
)?;
if lax {
write!(
f,
", so {} None, as a name nothing binds does outside `pragma strict`",
if each.len() == 1 {
"it reads"
} else {
"each reads"
}
)?;
}
if let Some(bare) = reads.iter().find(|r| r.bare) {
write!(
f,
"; a bare word in a predicate is a name, which nothing supplies: a string in a \
predicate is quoted, as in `\"{}\"`, and a scope's name is read as `{{{}}}`",
bare.name, bare.name
)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationWarning {
DegenerateGeometric {
strategy: StrategyName,
},
LhsDegenerate,
TriviallyTrueFilter,
TriviallyFalseFilter,
EmptySource {
var: String,
source: Option<String>,
},
SingletonCombinator {
combinator: &'static str,
},
UnresolvedNames {
reads: Vec<NameRead>,
},
}
impl std::fmt::Display for ValidationWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DegenerateGeometric { strategy } => write!(
f,
"`{strategy:?}` over a one-axis input collapses to its ends; use `Lex` with a truncation, or restate over a multi-axis cartesian"
),
Self::LhsDegenerate => {
write!(f, "`Lhs` over a one-axis input is `Shuffle`; say `Shuffle`")
}
Self::TriviallyTrueFilter => write!(f, "the filter is always true; drop it"),
Self::TriviallyFalseFilter => write!(
f,
"the filter is always false; the comprehension dispenses nothing"
),
Self::SingletonCombinator { combinator } => write!(
f,
"`{combinator}` over one child is that child; the wrapper adds nothing"
),
Self::EmptySource { var, source } => match source {
Some(text) => write!(
f,
"`{var} in {text}` has no values; every composition it takes part in dispenses nothing"
),
None => write!(
f,
"`{var}` has no values; every composition it takes part in dispenses nothing"
),
},
Self::UnresolvedNames { reads } => write_unresolved(f, reads, true),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadSite {
Source {
clause: String,
source: String,
},
Predicate {
predicate: String,
},
}
impl std::fmt::Display for ReadSite {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Source { clause, source } => write!(f, "clause '{clause}' in `{source}`"),
Self::Predicate { predicate } => write!(f, "predicate `{predicate}`"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NameRead {
pub name: String,
pub site: ReadSite,
pub bare: bool,
}
#[derive(Clone, Copy)]
pub enum Surface<'a> {
Stream,
Traversal(&'a dyn Fn(&str) -> bool),
}
impl Surface<'_> {
pub fn supplies(&self, name: &str) -> bool {
match self {
Surface::Stream => false,
Surface::Traversal(has) => has(name),
}
}
}
pub fn outer_reads(c: &Comprehension) -> Vec<NameRead> {
let mut out = Vec::new();
collect_outer_reads(c, &mut Vec::new(), &mut out);
out
}
fn collect_outer_reads(c: &Comprehension, before: &mut Vec<String>, out: &mut Vec<NameRead>) {
match c {
Comprehension::Clause { name, source } => {
for read in source.names_read() {
if !before.contains(&read) {
out.push(NameRead {
name: read,
site: ReadSite::Source {
clause: name.clone(),
source: source.to_text().unwrap_or_else(|| "<source>".into()),
},
bare: false,
});
}
}
}
Comprehension::Cartesian { children } => {
let depth = before.len();
for child in children {
collect_outer_reads(child, before, out);
before.extend(child.coordinate_names());
}
before.truncate(depth);
}
Comprehension::Zip { children, .. } | Comprehension::Union { children } => {
for child in children {
collect_outer_reads(child, before, out);
}
}
Comprehension::Filter { child, predicate } => {
collect_outer_reads(child, before, out);
let bound = child.coordinate_names();
let reads = polydat_grammar::comprehension::predicate::predicate_reads(predicate);
let site = || ReadSite::Predicate {
predicate: predicate.clone(),
};
for name in reads.elements {
if !bound.contains(&name) {
out.push(NameRead {
name,
site: site(),
bare: false,
});
}
}
for name in reads.bare {
out.push(NameRead {
name,
site: site(),
bare: true,
});
}
}
Comprehension::Order { child, .. } => collect_outer_reads(child, before, out),
}
}
pub fn unresolved_names(c: &Comprehension, surface: Surface<'_>) -> Vec<NameRead> {
outer_reads(c)
.into_iter()
.filter(|r| r.bare || !surface.supplies(&r.name))
.collect()
}
pub fn check_names(c: &Comprehension, surface: Surface<'_>) -> Result<(), ValidationError> {
let reads = unresolved_names(c, surface);
if reads.is_empty() {
Ok(())
} else {
Err(ValidationError::V3UnresolvedNames { reads })
}
}
pub fn validate(c: &Comprehension, mode: Mode) -> Result<ValidationReport, ValidationError> {
let mut report = ValidationReport {
warnings: Vec::new(),
};
visit(c, &mut report)?;
if mode == Mode::Strict
&& let Some(warning) = report.warnings.first()
{
return Err(ValidationError::StrictWarning(warning.clone()));
}
Ok(report)
}
fn visit(c: &Comprehension, report: &mut ValidationReport) -> Result<(), ValidationError> {
for child in c.children() {
visit(child, report)?;
}
match c {
Comprehension::Clause { name, source } => visit_clause(name, source, report),
Comprehension::Cartesian { children } => visit_cartesian(children, report),
Comprehension::Zip { children, mode } => visit_zip(children, *mode, report),
Comprehension::Union { children } => visit_union(children, report),
Comprehension::Filter { predicate, .. } => visit_filter(predicate, report),
Comprehension::Order {
child,
strategy,
truncation,
..
} => visit_order(child, *strategy, *truncation, report),
}
}
fn visit_clause(
name: &str,
source: &Source,
report: &mut ValidationReport,
) -> Result<(), ValidationError> {
if matches!(
source.cardinality(),
crate::iteration::comprehension::CardinalityClass::Bounded(0)
) {
report.warnings.push(ValidationWarning::EmptySource {
var: name.to_string(),
source: source.to_text(),
});
}
if let Source::ContinuousInterval { interval, measure } = source
&& !measure.is_integrable(std::slice::from_ref(interval))
{
let _ = report; return Err(ValidationError::V8ContinuousRequirement {
reason: format!(
"continuous source has non-integrable measure: \
interval [{}, {}] + {:?}",
interval.lo, interval.hi, measure
),
});
}
if let Source::Distribution {
distribution,
params,
..
} = source
&& let Err(reason) = distribution.resolve_params(params)
{
return Err(ValidationError::V8ContinuousRequirement { reason });
}
Ok(())
}
fn visit_cartesian(
children: &[Comprehension],
report: &mut ValidationReport,
) -> Result<(), ValidationError> {
check_disjoint_names("cartesian", children)?;
if children.len() == 1 {
report
.warnings
.push(ValidationWarning::SingletonCombinator {
combinator: "cartesian",
});
}
Ok(())
}
fn visit_zip(
children: &[Comprehension],
mode: ZipMode,
report: &mut ValidationReport,
) -> Result<(), ValidationError> {
check_disjoint_names("zip", children)?;
for child in children {
if contains_continuous_source(child) {
return Err(ValidationError::V7ZipCardinality {
mode,
reason: "zip children must all be discrete; \
a continuous source was found"
.to_string(),
});
}
}
if children.len() == 1 {
report
.warnings
.push(ValidationWarning::SingletonCombinator { combinator: "zip" });
}
if matches!(mode, ZipMode::Strict | ZipMode::Truncate) {
for child in children {
let card = child.metadata().cardinality;
if matches!(card, CardinalityClass::Unbounded) {
return Err(ValidationError::V6UnboundedDiscrete {
operator: "zip",
cardinality: card,
});
}
}
}
Ok(())
}
fn visit_union(
children: &[Comprehension],
report: &mut ValidationReport,
) -> Result<(), ValidationError> {
for child in children {
if contains_continuous_source(child) {
return Err(ValidationError::V9UnionClassMismatch {
reason: "union children must all be discrete; \
a continuous source was found"
.to_string(),
});
}
}
if let Some(first) = children.first() {
let expected = first.coordinate_names();
for sibling in &children[1..] {
let actual = sibling.coordinate_names();
if actual != expected {
return Err(ValidationError::V2ShapeMismatch { expected, actual });
}
}
}
if children.len() == 1 {
report
.warnings
.push(ValidationWarning::SingletonCombinator {
combinator: "union",
});
}
Ok(())
}
fn visit_filter(predicate: &str, report: &mut ValidationReport) -> Result<(), ValidationError> {
let trimmed = predicate.trim();
if trimmed.eq_ignore_ascii_case("true") {
report.warnings.push(ValidationWarning::TriviallyTrueFilter);
} else if trimmed.eq_ignore_ascii_case("false") {
report
.warnings
.push(ValidationWarning::TriviallyFalseFilter);
}
Ok(())
}
fn visit_order(
child: &Comprehension,
strategy: StrategyName,
truncation: Option<u64>,
report: &mut ValidationReport,
) -> Result<(), ValidationError> {
let metadata_target = match super::strategies::ranked_filter(child, strategy) {
Some((input, _)) => input,
None => super::strategies::shape_input(child, strategy),
};
if !matches!(strategy, StrategyName::Lex)
&& matches!(metadata_target, Comprehension::Filter { .. })
{
return Err(ValidationError::V4InputShape {
strategy,
reason: "non-Lex strategy applied to nested filters; \
write them as one filter, `where p && q` \
(comprehension_forms.md §5 V5)"
.to_string(),
});
}
let target_metadata = metadata_target.metadata();
check_strategy_input_shape(strategy, &target_metadata, report)?;
if !matches!(strategy, StrategyName::Lex)
&& matches!(target_metadata.cardinality, CardinalityClass::Unbounded)
{
return Err(ValidationError::V6UnboundedDiscrete {
operator: "order",
cardinality: target_metadata.cardinality.clone(),
});
}
let is_continuous = matches!(
target_metadata.cardinality,
CardinalityClass::Continuous { .. }
| CardinalityClass::ContinuousAtMost { .. }
| CardinalityClass::Hybrid(_)
);
if is_continuous {
if truncation.is_none() {
return Err(ValidationError::V8ContinuousRequirement {
reason: "continuous comprehension requires order(_, \
sampling-strategy, Some(n)) with finite \
truncation"
.to_string(),
});
}
if matches!(strategy, StrategyName::Lex) {
return Err(ValidationError::V8ContinuousRequirement {
reason: "Lex does not sample continuous inputs; use \
Halton / Sobol / Lhs / Shuffle / Extrema"
.to_string(),
});
}
}
Ok(())
}
fn check_strategy_input_shape(
strategy: StrategyName,
metadata: &Metadata,
report: &mut ValidationReport,
) -> Result<(), ValidationError> {
if matches!(strategy, StrategyName::Lex) {
return Ok(());
}
let idx = match &metadata.index_addressable {
Some(i) => i,
None => {
return Err(ValidationError::V4InputShape {
strategy,
reason: "input has no closed-form index function \
(a filter's output, a dependent cartesian, or \
a truncated Lex order over one)"
.to_string(),
});
}
};
let has_continuous = idx.has_continuous_axis();
if has_continuous {
match strategy {
StrategyName::Shuffle
| StrategyName::Halton
| StrategyName::Sobol
| StrategyName::Lhs => {}
StrategyName::Extrema => {}
StrategyName::ReverseLex
| StrategyName::Shells
| StrategyName::Diagonal
| StrategyName::Antidiagonal => {
return Err(ValidationError::V4InputShape {
strategy,
reason: format!("{} does not accept continuous input", strategy.as_str()),
});
}
StrategyName::Lex => unreachable!("Lex handled above"),
}
if matches!(strategy, StrategyName::Lhs | StrategyName::Extrema) {
let dim = continuous_dim(idx);
if dim < 2 {
if matches!(strategy, StrategyName::Lhs) {
report.warnings.push(ValidationWarning::LhsDegenerate);
} else {
report
.warnings
.push(ValidationWarning::DegenerateGeometric { strategy });
}
}
}
return Ok(());
}
if strategy.is_lattice_geometric() {
match idx {
IndexFn::Lattice { axis_sizes } => {
if axis_sizes.len() < 2 {
report
.warnings
.push(ValidationWarning::DegenerateGeometric { strategy });
}
}
IndexFn::Concatenation { .. } => {
return Err(ValidationError::V4InputShape {
strategy,
reason: format!(
"{} requires a cartesian input; got union",
strategy.as_str()
),
});
}
IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
report
.warnings
.push(ValidationWarning::DegenerateGeometric { strategy });
}
IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => unreachable!(),
}
return Ok(());
}
if matches!(strategy, StrategyName::Lhs) {
match idx {
IndexFn::Lattice { axis_sizes } if axis_sizes.len() < 2 => {
report.warnings.push(ValidationWarning::LhsDegenerate);
}
IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => {
report.warnings.push(ValidationWarning::LhsDegenerate);
}
_ => {}
}
}
Ok(())
}
fn continuous_dim(idx: &IndexFn) -> usize {
match idx {
IndexFn::Continuous { intervals, .. } => intervals.len(),
IndexFn::Hybrid {
discrete_axes,
continuous_axes,
..
} => discrete_axes.len() + continuous_axes.len(),
_ => 0,
}
}
fn check_disjoint_names(
combinator: &'static str,
children: &[Comprehension],
) -> Result<(), ValidationError> {
let mut seen: Vec<String> = Vec::new();
for child in children {
for name in child.coordinate_names() {
if seen.contains(&name) {
return Err(ValidationError::V1DuplicateName { combinator, name });
}
seen.push(name);
}
}
Ok(())
}
fn contains_continuous_source(c: &Comprehension) -> bool {
match c {
Comprehension::Clause { source, .. } => source.is_continuous(),
Comprehension::Cartesian { children }
| Comprehension::Zip { children, .. }
| Comprehension::Union { children } => children.iter().any(contains_continuous_source),
Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
contains_continuous_source(child)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::iteration::comprehension::cardinality::{Interval, ProductMeasure};
use crate::iteration::comprehension::source::{LiteralValue, Source};
fn clause(name: &str, vs: &[i64]) -> Comprehension {
Comprehension::clause(
name,
Source::Literal {
values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
},
)
}
fn empty_warnings(c: &Comprehension) -> Vec<String> {
validate(c, Mode::Permissive)
.expect("an empty source is degenerate, not invalid")
.warnings
.into_iter()
.filter_map(|w| match w {
ValidationWarning::EmptySource { var, .. } => Some(var),
_ => None,
})
.collect()
}
#[test]
fn a_provably_empty_source_warns() {
assert_eq!(empty_warnings(&clause("x", &[])), ["x"]);
assert_eq!(
empty_warnings(&Comprehension::clause(
"k",
Source::IntRange {
lo: 5,
hi: 5,
step: 1,
},
)),
["k"],
"a half-open range over no values is the same fact"
);
}
#[test]
fn a_source_of_unknown_count_does_not_warn() {
let generator = Comprehension::clause(
"g",
Source::Generator {
expr: "matching_profiles('a')".into(),
cardinality_hint: None,
},
);
assert!(empty_warnings(&generator).is_empty());
let param = Comprehension::clause(
"p",
Source::WorkloadParamList {
name: "sizes".into(),
len_hint: None,
},
);
assert!(empty_warnings(¶m).is_empty());
}
#[test]
fn a_generator_counted_as_zero_warns() {
let counted = Comprehension::clause(
"g",
Source::Generator {
expr: "matching_profiles('nope')".into(),
cardinality_hint: Some(0),
},
);
assert_eq!(empty_warnings(&counted), ["g"]);
}
#[test]
fn an_empty_source_is_the_error_under_strict() {
let c = clause("x", &[]);
assert!(validate(&c, Mode::Permissive).is_ok());
match validate(&c, Mode::Strict) {
Err(ValidationError::StrictWarning(ValidationWarning::EmptySource { var, .. })) => {
assert_eq!(var, "x");
}
other => panic!("expected the empty source to be the strict error, got {other:?}"),
}
}
#[test]
fn an_empty_clause_is_named_inside_a_cartesian() {
let c = Comprehension::cartesian(vec![clause("a", &[1, 2]), clause("b", &[])]);
assert_eq!(empty_warnings(&c), ["b"]);
}
fn continuous_clause(name: &str) -> Comprehension {
Comprehension::clause(
name,
Source::ContinuousInterval {
interval: Interval::closed(0.0, 1.0),
measure: ProductMeasure::Uniform,
},
)
}
#[test]
fn v1_rejects_duplicate_names_in_cartesian() {
let bad = Comprehension::cartesian(vec![clause("k", &[1]), clause("k", &[2])]);
let result = validate(&bad, Mode::Permissive);
assert!(matches!(
result,
Err(ValidationError::V1DuplicateName {
combinator: "cartesian",
..
})
));
}
#[test]
fn v1_accepts_disjoint_names() {
let ok = Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]);
assert!(validate(&ok, Mode::Permissive).is_ok());
}
#[test]
fn v2_rejects_union_shape_mismatch() {
let bad = Comprehension::union(vec![
Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
Comprehension::cartesian(vec![clause("limit", &[100]), clause("k", &[100])]),
]);
let result = validate(&bad, Mode::Permissive);
assert!(matches!(
result,
Err(ValidationError::V2ShapeMismatch { .. })
));
}
#[test]
fn v2_accepts_matching_union_shape() {
let ok = Comprehension::union(vec![
Comprehension::cartesian(vec![clause("k", &[1]), clause("limit", &[10])]),
Comprehension::cartesian(vec![clause("k", &[100]), clause("limit", &[100])]),
]);
assert!(validate(&ok, Mode::Permissive).is_ok());
}
#[test]
fn v4_rejects_lattice_geometric_over_union() {
let bad = Comprehension::order(
Comprehension::union(vec![clause("k", &[1, 2, 3]), clause("k", &[10, 20, 30])]),
StrategyName::Extrema,
Some(2),
);
assert!(matches!(
validate(&bad, Mode::Permissive),
Err(ValidationError::V4InputShape {
strategy: StrategyName::Extrema,
..
})
));
}
#[test]
fn v4_lattice_geometric_over_1axis_warns_not_errors() {
let degenerate =
Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
let report = validate(°enerate, Mode::Permissive).unwrap();
assert!(report.warnings.iter().any(|w| matches!(
w,
ValidationWarning::DegenerateGeometric {
strategy: StrategyName::Extrema
}
)));
}
#[test]
fn v4_strict_mode_promotes_warning() {
let degenerate =
Comprehension::order(clause("k", &[1, 2, 3]), StrategyName::Extrema, Some(2));
assert!(validate(°enerate, Mode::Strict).is_err());
}
#[test]
fn v7_rejects_continuous_in_zip() {
let bad = Comprehension::zip(
vec![continuous_clause("alpha"), continuous_clause("beta")],
ZipMode::Strict,
);
assert!(matches!(
validate(&bad, Mode::Permissive),
Err(ValidationError::V7ZipCardinality { .. })
));
}
#[test]
fn v8_rejects_continuous_without_sampling() {
let bad = continuous_clause("theta");
assert!(validate(&bad, Mode::Permissive).is_ok());
let bad_lex = Comprehension::order(continuous_clause("theta"), StrategyName::Lex, None);
assert!(matches!(
validate(&bad_lex, Mode::Permissive),
Err(ValidationError::V8ContinuousRequirement { .. })
));
}
#[test]
fn v8_accepts_continuous_with_sampling() {
let ok = Comprehension::order(
Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]),
StrategyName::Halton,
Some(100),
);
assert!(validate(&ok, Mode::Permissive).is_ok());
}
#[test]
fn v8_rejects_unbounded_uniform_at_source() {
let bad = Comprehension::clause(
"x",
Source::ContinuousInterval {
interval: Interval {
lo: 0.0,
hi: f64::INFINITY,
lo_open: false,
hi_open: true,
},
measure: ProductMeasure::Uniform,
},
);
assert!(matches!(
validate(&bad, Mode::Permissive),
Err(ValidationError::V8ContinuousRequirement { .. })
));
}
#[test]
fn v9_rejects_continuous_in_union() {
let bad = Comprehension::union(vec![
Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
Comprehension::cartesian(vec![continuous_clause("k"), continuous_clause("limit")]),
]);
assert!(matches!(
validate(&bad, Mode::Permissive),
Err(ValidationError::V9UnionClassMismatch { .. })
));
}
#[test]
fn singleton_combinator_warns() {
let degenerate = Comprehension::cartesian(vec![clause("k", &[1, 2])]);
let report = validate(°enerate, Mode::Permissive).unwrap();
assert!(report.warnings.iter().any(|w| matches!(
w,
ValidationWarning::SingletonCombinator {
combinator: "cartesian"
}
)));
}
#[test]
fn trivially_true_filter_warns() {
let degenerate = Comprehension::filter(clause("k", &[1, 2]), "true");
let report = validate(°enerate, Mode::Permissive).unwrap();
assert!(
report
.warnings
.iter()
.any(|w| matches!(w, ValidationWarning::TriviallyTrueFilter))
);
}
fn generator(name: &str, expr: &str) -> Comprehension {
Comprehension::clause(
name,
Source::Generator {
expr: expr.into(),
cardinality_hint: None,
},
)
}
fn unresolved(c: &Comprehension, surface: Surface<'_>) -> Vec<(String, bool)> {
match check_names(c, surface) {
Ok(()) => Vec::new(),
Err(ValidationError::V3UnresolvedNames { reads }) => {
reads.into_iter().map(|r| (r.name, r.bare)).collect()
}
Err(other) => panic!("expected V3, got {other}"),
}
}
#[test]
fn v3_binds_earlier_axes_for_sources_and_the_tuple_for_predicates() {
let dependent = Comprehension::cartesian(vec![
clause("a", &[1, 2]),
generator("b", "0..{a}"),
generator("c", "0..{b}"),
]);
assert!(check_names(&dependent, Surface::Stream).is_ok());
let filtered = Comprehension::filter(dependent.clone(), "{a} < {c}");
assert!(check_names(&filtered, Surface::Stream).is_ok());
let later = Comprehension::cartesian(vec![generator("b", "0..{a}"), clause("a", &[1])]);
assert_eq!(
unresolved(&later, Surface::Stream),
[("a".to_string(), false)]
);
let zipped = Comprehension::zip(
vec![clause("a", &[1]), generator("b", "0..{a}")],
ZipMode::Truncate,
);
assert_eq!(
unresolved(&zipped, Surface::Stream),
[("a".to_string(), false)]
);
let inner = Comprehension::cartesian(vec![
clause("a", &[1, 2]),
Comprehension::filter(clause("b", &[1, 2]), "{b} < {a}"),
]);
assert_eq!(
unresolved(&inner, Surface::Stream),
[("a".to_string(), false)]
);
}
#[test]
fn v3_closes_names_over_the_surface() {
let c = Comprehension::filter(
Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
"{k} < {limit} && {k} != s1",
);
assert_eq!(
unresolved(&c, Surface::Stream),
[
("n".to_string(), false),
("limit".to_string(), false),
("s1".to_string(), true)
]
);
let scope = |n: &str| n == "n" || n == "limit" || n == "s1";
assert_eq!(
unresolved(&c, Surface::Traversal(&scope)),
[("s1".to_string(), true)]
);
let quoted = Comprehension::filter(
Comprehension::cartesian(vec![clause("k", &[1, 2]), generator("j", "0..{n}")]),
"{k} < {limit} && {k} != \"s1\"",
);
assert!(check_names("ed, Surface::Traversal(&scope)).is_ok());
let err = check_names(&c, Surface::Stream).unwrap_err().to_string();
assert!(err.starts_with("V3:"), "{err}");
assert!(err.contains("`n` read by clause 'j' in `0..{n}`"), "{err}");
assert!(
err.contains("`limit` read by predicate `{k} < {limit} && {k} != s1`"),
"{err}"
);
assert!(err.contains("as in `\"s1\"`"), "{err}");
}
#[test]
fn v3_reads_opaque_expressions_as_the_language_does() {
let c = Comprehension::filter(
clause("k", &[1, 2]),
"u64_add({k}, width) > 1 && {k} as f64 > 0.5 && true",
);
assert_eq!(
unresolved(&c, Surface::Stream),
[("width".to_string(), true)]
);
}
}