diff --git a/nemo-cli/src/main.rs b/nemo-cli/src/main.rs index 44d2e8749..532f4fca3 100644 --- a/nemo-cli/src/main.rs +++ b/nemo-cli/src/main.rs @@ -116,13 +116,6 @@ fn run(mut cli: CliApp) -> Result<(), Error> { log::info!("Rules parsed"); log::trace!("{:?}", program); - for atom in program.rules().iter().flat_map(|rule| rule.head()) { - if atom.aggregates().next().is_some() { - log::warn!("Program is using the experimental aggregates feature and currently depends on the internally chosen variable orders for predicates.",); - break; - } - } - let parsed_facts = cli .tracing .traced_facts diff --git a/nemo-cli/tests/blackbox_integration.rs b/nemo-cli/tests/blackbox_integration.rs index dfacf4946..ccb44ede8 100644 --- a/nemo-cli/tests/blackbox_integration.rs +++ b/nemo-cli/tests/blackbox_integration.rs @@ -117,7 +117,7 @@ impl TestCase { .lines() .map(|s| s.to_string()) .collect::>(); - let mut expected_lines = read_to_string(expected_file) + let mut expected_lines = read_to_string(expected_file.clone()) .unwrap() .trim() .lines() @@ -125,7 +125,10 @@ impl TestCase { .collect::>(); output_lines.sort(); expected_lines.sort(); - assert_eq!(output_lines, expected_lines); + assert_eq!( + output_lines, expected_lines, + "actual output does not match expected output from {expected_file:?}" + ); }); Ok(()) } diff --git a/nemo/src/execution/planning/aggregates.rs b/nemo/src/execution/planning/aggregates.rs index ff06dc9b1..6dad67af9 100644 --- a/nemo/src/execution/planning/aggregates.rs +++ b/nemo/src/execution/planning/aggregates.rs @@ -50,9 +50,8 @@ pub(super) fn generate_node_aggregate( let reordering = ProjectReordering::from_vector(reordering_column_indices, variable_order.len()); - node = current_plan.plan_mut().project(node, reordering); - current_plan.add_temporary_table(node.clone(), "Subtable Aggregate Reorder"); + node = current_plan.plan_mut().project(node, reordering); // Update variable order to reordering variable_order = variable_order_after_reordering; diff --git a/nemo/src/execution/planning/arithmetic.rs b/nemo/src/execution/planning/arithmetic.rs index aafd13805..1626551f2 100644 --- a/nemo/src/execution/planning/arithmetic.rs +++ b/nemo/src/execution/planning/arithmetic.rs @@ -84,6 +84,9 @@ pub(super) fn generate_node_arithmetic( constructor.variable().clone(), first_unused_index + constructor_index, ); + + // Replace arithmetic instructions in term tree by the aggregate placeholder variables + constructor_instructions.push(AppendInstruction::Arithmetic(compile_termtree( constructor.term(), variable_order, diff --git a/nemo/src/execution/planning/plan_body_seminaive.rs b/nemo/src/execution/planning/plan_body_seminaive.rs index 57f9bf43f..cf5dc9cf7 100644 --- a/nemo/src/execution/planning/plan_body_seminaive.rs +++ b/nemo/src/execution/planning/plan_body_seminaive.rs @@ -7,7 +7,7 @@ use nemo_physical::management::execution_plan::ExecutionNodeRef; use crate::{ execution::execution_engine::RuleInfo, model::{ - chase_model::{ChaseAggregate, ChaseRule, Constructor, AGGREGATE_VARIABLE_PREFIX}, + chase_model::{variable::is_aggregate_variable, ChaseAggregate, ChaseRule, Constructor}, Variable, }, program_analysis::{analysis::RuleAnalysis, variable_order::VariableOrder}, @@ -65,9 +65,9 @@ impl SeminaiveStrategy { None } else { // Compute group-by variables for all aggregates in the rule - // This is the set of all universal variables in the head except for the aggregated variables - Some(analysis.head_variables.iter().filter(|variable| match variable { - Variable::Universal(identifier) => !identifier.0.starts_with(AGGREGATE_VARIABLE_PREFIX), + // This is the set of all universal variables in the head (before any arithmetic operations) except for the aggregated variables + Some(used_variables_before_arithmetic_operations.iter().filter(|variable| match variable { + Variable::Universal(_) => !is_aggregate_variable(variable), Variable::Existential(_) => panic!("existential head variables are currently not supported together with aggregates"), }).cloned().collect()) }; @@ -138,6 +138,13 @@ impl BodyStrategy for SeminaiveStrategy { &self.aggregates, aggregate_group_by_variables, ); + + // This check can be removed when [`nemo_physical::tabular::operations::triescan_aggregate::TrieScanAggregateWrapper`] is removed + // Currently, this wrapper can only be turned into a partial trie scan using materialization + if !self.constructors.is_empty() { + current_plan + .add_temporary_table(node_seminaive.clone(), "Subtable Aggregate Arithmetics"); + } } // Cut away layers not used after arithmetic operations diff --git a/nemo/src/execution/selection_strategy/strategy_stratified_negation.rs b/nemo/src/execution/selection_strategy/strategy_stratified_negation.rs index e757514ec..6d68cc27a 100644 --- a/nemo/src/execution/selection_strategy/strategy_stratified_negation.rs +++ b/nemo/src/execution/selection_strategy/strategy_stratified_negation.rs @@ -33,7 +33,7 @@ pub struct StrategyStratifiedNegation { } impl StrategyStratifiedNegation { - fn build_graph(rule_analyses: &Vec<&RuleAnalysis>) -> NegationGraph { + fn build_graph(rule_analyses: &[&RuleAnalysis]) -> NegationGraph { let mut predicate_to_rules_body_positive = HashMap::>::new(); let mut predicate_to_rules_body_negative = HashMap::>::new(); let mut predicate_to_rules_head = HashMap::>::new(); diff --git a/nemo/src/io/parser.rs b/nemo/src/io/parser.rs index ec3d1342e..416c68e5a 100644 --- a/nemo/src/io/parser.rs +++ b/nemo/src/io/parser.rs @@ -1276,6 +1276,7 @@ impl<'a> RuleParser<'a> { map_error( alt(( self.parse_function_term(), + self.parse_aggregate(), map(self.parse_primitive_term(), Term::Primitive), self.parse_parenthesised_term(), )), diff --git a/nemo/src/io/parser/types.rs b/nemo/src/io/parser/types.rs index 3c681256a..acd50db73 100644 --- a/nemo/src/io/parser/types.rs +++ b/nemo/src/io/parser/types.rs @@ -319,9 +319,6 @@ pub enum ParseError { /// An aggregate term occurs in the body of a rule. #[error(r#"An aggregate term ("{0}") occurs in the body of a rule"#)] AggregateInBody(Aggregate), - /// An aggregate may not be used within a complex term. - #[error(r#"A term ("{0}") may not contain an aggregate as a subterm."#)] - AggregateSubterm(String), /// Unknown aggregate operation #[error(r#"Aggregate operation "{0}" is not known"#)] UnknownAggregateOperation(String), diff --git a/nemo/src/model/chase_model.rs b/nemo/src/model/chase_model.rs index 1c6373c5d..a2146761c 100644 --- a/nemo/src/model/chase_model.rs +++ b/nemo/src/model/chase_model.rs @@ -14,3 +14,5 @@ pub use atom::*; mod constructor; pub use constructor::*; + +pub(crate) mod variable; diff --git a/nemo/src/model/chase_model/rule.rs b/nemo/src/model/chase_model/rule.rs index bc9ad57ea..31d662ea5 100644 --- a/nemo/src/model/chase_model/rule.rs +++ b/nemo/src/model/chase_model/rule.rs @@ -4,17 +4,15 @@ use std::collections::{HashMap, HashSet}; use crate::{ error::Error, - model::{Constraint, Identifier, Literal, PrimitiveTerm, Rule, Term, Variable}, + model::{ + chase_model::variable::{AGGREGATE_VARIABLE_PREFIX, CONSTRUCT_VARIABLE_PREFIX}, + Constraint, Identifier, Literal, PrimitiveTerm, Rule, Term, Variable, + }, }; -use super::{ChaseAggregate, Constructor, PrimitiveAtom, VariableAtom}; - -/// Prefix used for generated aggregate variables in a [`ChaseRule`] -pub const AGGREGATE_VARIABLE_PREFIX: &str = "_AGGREGATE_"; -/// Prefix used for generated variables encoding equality constraints in a [`ChaseRule`] -pub const EQUALITY_VARIABLE_PREFIX: &str = "_EQUALITY_"; -/// Prefix used for generated variables for storing the value of complex terms in a [`ChaseRule`]. -pub const CONSTRUCT_VARIABLE_PREFIX: &str = "_CONSTRUCT_"; +use super::{ + variable::EQUALITY_VARIABLE_PREFIX, ChaseAggregate, Constructor, PrimitiveAtom, VariableAtom, +}; /// Representation of a rule in a [`super::ChaseProgram`]. /// @@ -156,8 +154,14 @@ impl ChaseRule { } impl ChaseRule { - fn generate_variable_name(prefix: &str, counter: usize) -> Identifier { - Identifier(format!("{}{}", prefix, counter)) + /// Increments `next_variable_id`, but returns it's old value with a prefix. + fn generate_incrementing_variable_name( + prefix: &str, + next_variable_id: &mut usize, + ) -> Identifier { + let i = Identifier(format!("{}{}", prefix, next_variable_id)); + *next_variable_id += 1; + i } // Remove constraints of the form ?X = ?Y from the rule @@ -202,39 +206,48 @@ impl ChaseRule { // New constraints that will be introduced due to the flattening of the atom let mut positive_constraints = Vec::::new(); - let mut global_term_index: usize = 0; + let mut rule_next_variable_id: usize = 0; // Head atoms may only contain primitive terms for atom in rule.head_mut() { for term in atom.terms_mut() { - if !term.is_primitive() { - let new_variable = if let Term::Aggregation(aggregate) = term { - let new_variable = Variable::Universal(Self::generate_variable_name( - AGGREGATE_VARIABLE_PREFIX, - global_term_index, - )); + // Replace aggregate terms or aggregates inside of arithmetic expressions with placeholder variables + term.update_subterms_recursively(&mut |subterm| match subterm { + Term::Aggregation(aggregate) => { + let new_variable = + Variable::Universal(Self::generate_incrementing_variable_name( + AGGREGATE_VARIABLE_PREFIX, + &mut rule_next_variable_id, + )); aggregates.push(ChaseAggregate::from_aggregate( aggregate.clone(), new_variable.clone(), )); - new_variable - } else { - let new_variable = Variable::Universal(Self::generate_variable_name( + *subterm = Term::Primitive(PrimitiveTerm::Variable(new_variable)); + + false + } + _ => true, + }); + + debug_assert!( + !matches!(term, Term::Aggregation(_)), + "Aggregate terms should have been replaced with placeholder variables" + ); + + if !term.is_primitive() { + let new_variable = + Variable::Universal(Self::generate_incrementing_variable_name( CONSTRUCT_VARIABLE_PREFIX, - global_term_index, + &mut rule_next_variable_id, )); - constructors.push(Constructor::new(new_variable.clone(), term.clone())); - - new_variable - }; + constructors.push(Constructor::new(new_variable.clone(), term.clone())); *term = Term::Primitive(PrimitiveTerm::Variable(new_variable)); } - - global_term_index += 1; } } @@ -247,7 +260,10 @@ impl ChaseRule { for term in atom.terms_mut() { let new_variable = Term::Primitive(PrimitiveTerm::Variable(Variable::Universal( - Self::generate_variable_name(EQUALITY_VARIABLE_PREFIX, global_term_index), + Self::generate_incrementing_variable_name( + EQUALITY_VARIABLE_PREFIX, + &mut rule_next_variable_id, + ), ))); if let Term::Primitive(PrimitiveTerm::Variable(variable)) = term.clone() { @@ -266,8 +282,6 @@ impl ChaseRule { } *term = new_variable; - - global_term_index += 1; } } diff --git a/nemo/src/model/chase_model/variable.rs b/nemo/src/model/chase_model/variable.rs new file mode 100644 index 000000000..896c72174 --- /dev/null +++ b/nemo/src/model/chase_model/variable.rs @@ -0,0 +1,44 @@ +use crate::model::{Identifier, Variable}; + +/// Prefix used for generated aggregate variables in a [`ChaseRule`] +pub(super) const AGGREGATE_VARIABLE_PREFIX: &str = "_AGGREGATE_"; +/// Prefix used for generated variables encoding equality constraints in a [`ChaseRule`] +pub(super) const EQUALITY_VARIABLE_PREFIX: &str = "_EQUALITY_"; +/// Prefix used for generated variables for storing the value of complex terms in a [`ChaseRule`]. +pub(super) const CONSTRUCT_VARIABLE_PREFIX: &str = "_CONSTRUCT_"; + +fn is_aggregate_identifier(identifier: &Identifier) -> bool { + identifier.name().starts_with(AGGREGATE_VARIABLE_PREFIX) +} + +/// Check if a variable is a aggregate placeholder variable, representing the output of an aggregate. +pub(crate) fn is_aggregate_variable(variable: &Variable) -> bool { + match variable { + Variable::Universal(identifier) => is_aggregate_identifier(identifier), + Variable::Existential(identifier) => { + debug_assert!( + !is_aggregate_identifier(identifier), + "aggregate variables must be universal variables" + ); + false + } + } +} + +fn is_construct_identifier(identifier: &Identifier) -> bool { + identifier.name().starts_with(CONSTRUCT_VARIABLE_PREFIX) +} + +/// Check if a variable is a constructor variable +pub(crate) fn is_construct_variable(variable: &Variable) -> bool { + match variable { + Variable::Universal(identifier) => is_construct_identifier(identifier), + Variable::Existential(identifier) => { + debug_assert!( + !is_construct_identifier(identifier), + "construct variables must be universal variables" + ); + false + } + } +} diff --git a/nemo/src/model/rule_model/rule.rs b/nemo/src/model/rule_model/rule.rs index 825e842d9..047be7994 100644 --- a/nemo/src/model/rule_model/rule.rs +++ b/nemo/src/model/rule_model/rule.rs @@ -174,13 +174,6 @@ impl Rule { } } - // Check if aggregate is used within another complex term - for term in head.iter().flat_map(|a| a.terms()) { - if term.aggregate_subterm() { - return Err(ParseError::AggregateSubterm(term.to_string())); - } - } - Ok(Rule { head, body, diff --git a/nemo/src/model/rule_model/term.rs b/nemo/src/model/rule_model/term.rs index 712e55e69..24a2bed4b 100644 --- a/nemo/src/model/rule_model/term.rs +++ b/nemo/src/model/rule_model/term.rs @@ -398,30 +398,37 @@ impl Term { } } - fn aggregate_subterm_recursive(term: &Term) -> bool { - match term { - Term::Primitive(_primitive) => false, - Term::Binary { lhs, rhs, .. } => { - Self::aggregate_subterm_recursive(lhs) || Self::aggregate_subterm(rhs) + fn subterms_mut(&mut self) -> Vec<&mut Term> { + match self { + Term::Primitive(_primitive) => Vec::new(), + Term::Binary { + ref mut lhs, + ref mut rhs, + .. + } => { + vec![lhs, rhs] } - Term::Unary(_, inner) => Self::aggregate_subterm_recursive(inner), - Term::Aggregation(_aggregate) => true, - Term::Function(_, subterms) => subterms.iter().any(Self::aggregate_subterm_recursive), + Term::Unary(_, ref mut inner) => vec![inner], + Term::Aggregation(_aggregate) => Vec::new(), + Term::Function(_, subterms) => subterms.iter_mut().collect(), } } - /// Checks if this term contains an aggregate as a sub term. - /// This is currently not allowed. - pub fn aggregate_subterm(&self) -> bool { - match self { - Term::Primitive(_primitive) => false, - Term::Binary { lhs, rhs, .. } => { - Self::aggregate_subterm_recursive(lhs) || Self::aggregate_subterm(rhs) + /// Mutate the term in place, calling the function `f` on itself and recursively on it's subterms if the function `f` returns true + /// + /// This is used e.g. to rewrite aggregates inside of constructors with placeholder variables + pub fn update_subterms_recursively(&mut self, f: &mut F) + where + F: FnMut(&mut Term) -> bool, + { + f(self); + + for subterm in self.subterms_mut() { + let should_recurse = f(subterm); + + if should_recurse { + subterm.update_subterms_recursively(f); } - Term::Unary(_, inner) => Self::aggregate_subterm_recursive(inner), - // this is allowed, because the aggregate is on the top-level - Term::Aggregation(_aggregate) => false, - Term::Function(_, subterms) => subterms.iter().any(Self::aggregate_subterm_recursive), } } diff --git a/nemo/src/program_analysis/type_inference/position_graph.rs b/nemo/src/program_analysis/type_inference/position_graph.rs index d046ffd9c..b590f8d27 100644 --- a/nemo/src/program_analysis/type_inference/position_graph.rs +++ b/nemo/src/program_analysis/type_inference/position_graph.rs @@ -107,17 +107,35 @@ impl PositionGraph { ); } - // NOTE: we connect every aggregate input variable to it's corresponding output variable in the head - for (output_variable_identifier, edge_label) in - aggregate_input_to_output_variables - .get(variable) - .into_iter() - .flatten() + // NOTE: we connect every aggregate input variable to it's corresponding output variable in the head. + // If there the output variable is used inside a constructor, we connect the aggregate input variable to the constructor instead + // Aggregate output variables may be used multiple times in the future, possibly also inside of constructors + for (output_variable, edge_label) in aggregate_input_to_output_variables + .get(variable) + .into_iter() + .flatten() { - for pos in variables_to_head_positions - .get(&output_variable_identifier.clone()) - .unwrap() - { + // The aggregate output is used in all head atoms that use the aggregate output variable + let mut variables_using_aggregate_variable = vec![output_variable]; + + // Furthermore, all constructors that use the aggregate output variable should get the same type restrictions + for constructor in rule.constructors() { + if constructor + .term() + .primitive_terms() + .contains(&&PrimitiveTerm::Variable(output_variable.clone())) + { + variables_using_aggregate_variable.push(constructor.variable()); + } + } + + // Head positions that use the body variable through aggregates/constructors + let relevant_head_positions = variables_using_aggregate_variable + .into_iter() + .filter_map(|variable| variables_to_head_positions.get(variable)) + .flatten(); + + for pos in relevant_head_positions { graph.add_edge( predicate_position.clone(), pos.clone(), @@ -157,12 +175,10 @@ impl PositionGraph { { for term in constructor.term().primitive_terms() { if let PrimitiveTerm::Variable(body_variable) = term { - let body_position_opt = - variables_to_last_node.get(body_variable).cloned(); - - if let Some(body_position) = body_position_opt { + // There might be no entry in `variables_to_last_node`, e.g. when the variable is an aggregate output variable, because in this case the variable is not in the body + if let Some(body_position) = variables_to_last_node.get(body_variable) { graph.add_edge( - body_position, + body_position.clone(), head_position.clone(), PositionGraphEdge::BodyToHeadSameVariable, ); diff --git a/nemo/src/program_analysis/type_inference/type_requirement.rs b/nemo/src/program_analysis/type_inference/type_requirement.rs index a0020ba66..83a1f50c8 100644 --- a/nemo/src/program_analysis/type_inference/type_requirement.rs +++ b/nemo/src/program_analysis/type_inference/type_requirement.rs @@ -5,7 +5,10 @@ use nemo_physical::aggregates::operation::AggregateOperation; use crate::{ io::formats::types::ImportSpec, model::{ - chase_model::{ChaseAtom, ChaseFact, ChaseRule, AGGREGATE_VARIABLE_PREFIX}, + chase_model::{ + variable::{is_aggregate_variable, is_construct_variable}, + ChaseAtom, ChaseFact, ChaseRule, Constructor, + }, types::error::TypeError, Identifier, PrimitiveTerm, PrimitiveType, Term, TypeConstraint, Variable, }, @@ -264,6 +267,25 @@ pub(super) fn requirements_from_literals_in_rules( Ok(literal_decls) } +pub(super) fn aggregate_output_variables_in_constructor( + constructor: &Constructor, +) -> impl Iterator { + constructor + .term() + .primitive_terms() + .into_iter() + .filter_map(|term| match term { + PrimitiveTerm::Variable(variable) => { + if is_aggregate_variable(variable) { + Some(variable) + } else { + None + } + } + _ => None, + }) +} + pub(super) fn requirements_from_aggregates_in_rules( rules: &[ChaseRule], ) -> Result { @@ -279,17 +301,36 @@ pub(super) fn requirements_from_aggregates_in_rules( atom.terms() .iter() .map(|term| { - if let PrimitiveTerm::Variable(Variable::Universal(identifier)) = term { - if identifier.name().starts_with(AGGREGATE_VARIABLE_PREFIX) { + if let PrimitiveTerm::Variable(variable) = term { + // Add static output type to aggregate output variables and constructors using them + + let aggregate_output_variables_used = if is_aggregate_variable(variable) { + // If the term is a aggregate variable it self, add it + vec![variable] + } else if is_construct_variable(variable) { + let constructor = rule + .constructors() + .iter() + .find(|constructor| constructor.variable() == variable).expect("variable with constructor prefix is missing an associated constructor"); + + // Add aggregate variables contained in this constructor + aggregate_output_variables_in_constructor(constructor).collect() + } else { + vec![] + }; + + for aggregate_output_variable in aggregate_output_variables_used { let aggregate = rule .aggregates() .iter() - .find(|aggregate| aggregate.output_variable.name() == *identifier.0).expect("variable with aggregate prefix is missing an associated aggregate"); + .find(|aggregate| aggregate.output_variable == *aggregate_output_variable).expect("variable with aggregate prefix is missing an associated aggregate"); if aggregate.aggregate_operation == AggregateOperation::Count { return TypeRequirement::Hard(PrimitiveType::Integer) + } else { + debug_assert_eq!(aggregate.aggregate_operation.static_output_type(), None) } } } diff --git a/resources/testcases/aggregate/arithmetic.rls b/resources/testcases/aggregate/arithmetic.rls new file mode 100644 index 000000000..4afaf8d82 --- /dev/null +++ b/resources/testcases/aggregate/arithmetic.rls @@ -0,0 +1,8 @@ +@source sourceA[integer,integer,integer]: load-csv("sources/dataA.csv"). + +r0(?X + 2, ?Y, #count(?Z)) :- sourceA(?X, ?Y, ?Z). + +r1(?X, ?Y, #count(?Z) * 2) :- sourceA(?X, ?Y, ?Z). +r2(?X, ?Y, #count(?Z) * ?Y) :- sourceA(?X, ?Y, ?Z). + +r3(?X + 2, ?Y, #count(?Z) * 2) :- sourceA(?X, ?Y, ?Z). diff --git a/resources/testcases/aggregate/arithmetic/r0.csv b/resources/testcases/aggregate/arithmetic/r0.csv new file mode 100644 index 000000000..534e7deaa --- /dev/null +++ b/resources/testcases/aggregate/arithmetic/r0.csv @@ -0,0 +1,7 @@ +-28,2,1 +3,2,1 +3,5,1 +3,9,2 +4,2,1 +5,4,1 +44,1,1 diff --git a/resources/testcases/aggregate/arithmetic/r1.csv b/resources/testcases/aggregate/arithmetic/r1.csv new file mode 100644 index 000000000..e00e6824a --- /dev/null +++ b/resources/testcases/aggregate/arithmetic/r1.csv @@ -0,0 +1,7 @@ +-30,2,2 +1,2,2 +1,5,2 +1,9,4 +2,2,2 +3,4,2 +42,1,2 diff --git a/resources/testcases/aggregate/arithmetic/r2.csv b/resources/testcases/aggregate/arithmetic/r2.csv new file mode 100644 index 000000000..e1fb7b9c4 --- /dev/null +++ b/resources/testcases/aggregate/arithmetic/r2.csv @@ -0,0 +1,7 @@ +-30,2,2 +1,2,2 +1,5,5 +1,9,18 +2,2,2 +3,4,4 +42,1,1 diff --git a/resources/testcases/aggregate/arithmetic/r3.csv b/resources/testcases/aggregate/arithmetic/r3.csv new file mode 100644 index 000000000..8edaed888 --- /dev/null +++ b/resources/testcases/aggregate/arithmetic/r3.csv @@ -0,0 +1,7 @@ +-28,2,2 +3,2,2 +3,5,2 +3,9,4 +4,2,2 +5,4,2 +44,1,2 diff --git a/resources/testcases/aggregate/filtered.rls b/resources/testcases/aggregate/filtered.rls new file mode 100644 index 000000000..cb530c918 --- /dev/null +++ b/resources/testcases/aggregate/filtered.rls @@ -0,0 +1,5 @@ +@source sourceA[integer,integer,integer]: load-csv("sources/dataA.csv"). + +r0(?X, #count(?Y)) :- sourceA(?X, _, _), sourceA(?Y, _, _), ?X > ?Y. + +@output r0. diff --git a/resources/testcases/aggregate/filtered/r0.csv b/resources/testcases/aggregate/filtered/r0.csv new file mode 100644 index 000000000..54b934cfd --- /dev/null +++ b/resources/testcases/aggregate/filtered/r0.csv @@ -0,0 +1,4 @@ +1,1 +2,2 +3,3 +42,4