From 838b9fe9919be3f9ecd8d26de8f8aa7005726cf2 Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 12:28:07 +0100 Subject: [PATCH 1/8] ok --- src/rewriting/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/rewriting/mod.rs b/src/rewriting/mod.rs index 7796af7b..d44d34e3 100644 --- a/src/rewriting/mod.rs +++ b/src/rewriting/mod.rs @@ -403,9 +403,15 @@ mod tests { "SELECT department, AVG(sales_value) AS average_sales FROM retail_transactions INNER JOIN retail_products ON retail_transactions.product_id = retail_products.product_id GROUP BY department", "SELECT * FROM retail_transactions INNER JOIN retail_products ON retail_transactions.product_id = retail_products.product_id", "WITH ranked_products AS (SELECT product_id, COUNT(*) AS my_count FROM retail_transactions GROUP BY product_id) SELECT product_id FROM ranked_products ORDER BY my_count", - //"SELECT t.product_id, p.product_category, COUNT(*) AS purchase_count FROM retail_transactions t INNER JOIN retail_products p ON t.product_id = p.product_id WHERE t.transaction_timestamp < CAST('2023-02-01' AS date) GROUP BY t.product_id, p.product_category", + //"SELECT t.product_id, p.product_category, COUNT(*) AS purchase_count FROM retail_transactions t INNER JOIN retail_products p ON t.product_id = p.product_id WHERE t.transaction_timestamp < CAST('2023-02-01' AS date) GROUP BY t.product_id, p.product_category", // cast date from string does not work in where "SELECT t.product_id, p.product_category, COUNT(*) AS purchase_count FROM retail_transactions t INNER JOIN retail_products p ON t.product_id = p.product_id WHERE t.transaction_timestamp > '2023-01-01' AND t.transaction_timestamp < '2023-02-01' GROUP BY t.product_id, p.product_category", + //"SELECT DISTINCT age, income FROM retail_demographics", + "SELECT AVG(sales_value) FROM retail_transactions AS t WHERE sales_values > 0 AND sales_values < 100", + //"SELECT quantity, AVG(sales_value) FROM retail_demographics AS d JOIN retail_transactions AS t ON d.household_id = t.household_id WHERE quantity > 0 AND sales_values > 0 AND sales_values < 100 GROUP BY quantity", + "WITH stats_stores AS (SELECT store_id, SUM(sales_value) AS sum_sales_value, AVG(retail_disc) FROM retail_transactions GROUP BY store_id) SELECT * FROM stats_stores WHERE sum_sales_value != 1", "SELECT p.product_id, p.brand, COUNT(*) FROM retail_products p INNER JOIN retail_transactions t ON p.product_id = t.product_id GROUP BY p.product_id, p.brand", + "SELECT t.household_id, store_id, AVG(sales_value) FROM retail_demographics AS d JOIN retail_transactions AS t ON d.household_id = t.household_id GROUP BY t.household_id, store_id", + "SELECT * FROM retail_transactions AS t INNER JOIN retail_products p ON t.product_id = p.product_id", ]; for query_str in queries { println!("\n{query_str}"); From 5bbdedaeb02d36fa3d2a38a1a1754551811ec030 Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 14:02:01 +0100 Subject: [PATCH 2/8] ok --- CHANGELOG.md | 3 ++ src/differential_privacy/aggregates.rs | 73 ++++++++++++++++---------- src/rewriting/mod.rs | 14 ++--- 3 files changed, 56 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d823057..ed62a49b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [0.5.6] - 2023-12-18 ### Fixed - Fixed bug when using the same column in the GROUP BY and an Aggregate function [#222](https://github.com/Qrlew/qrlew/issues/222) - Natural joins [#221](https://github.com/Qrlew/qrlew/issues/221) - When the clipping factor is zero, multiply by zero instead of dividing by 1 / clipping_factor [#218](https://github.com/Qrlew/qrlew/issues/218) - GROUP BY column alias [#223](https://github.com/Qrlew/qrlew/issues/223) +- DP compilation for Reduce that contains only First aggregations [#224](https://github.com/Qrlew/qrlew/issues/224) ## Added - OFFSET [#224](https://github.com/Qrlew/qrlew/issues/224) diff --git a/src/differential_privacy/aggregates.rs b/src/differential_privacy/aggregates.rs index 4ba3241f..e0d8272c 100644 --- a/src/differential_privacy/aggregates.rs +++ b/src/differential_privacy/aggregates.rs @@ -237,22 +237,26 @@ impl PUPRelation { }, ); + let (dp_relation, private_query) = if named_sums.len() == 0 { + (self.deref().clone(), PrivateQuery::null()) + } else { let input: Relation = input_builder.input(self.deref().clone()).build(); - let pup_input = PUPRelation::try_from(input)?; - let (dp_relation, private_query) = pup_input - .differentially_private_sums( - named_sums - .iter() // Convert &str to String - .map(|(s1, s2)| (s1.as_str(), s2.as_str())) - .collect::>(), - group_by_names, - epsilon, - delta, - )? - .into(); + let pup_input = PUPRelation::try_from(input)?; + pup_input + .differentially_private_sums( + named_sums + .iter() // Convert &str to String + .map(|(s1, s2)| (s1.as_str(), s2.as_str())) + .collect::>(), + group_by_names, + epsilon, + delta, + )? + .into() + }; let dp_relation = output_builder - .input(dp_relation) - .build(); + .input(dp_relation) + .build(); Ok(DPRelation::new(dp_relation, private_query)) } } @@ -272,6 +276,9 @@ impl Reduce { let delta = delta / (cmp::max(reduces.len(), 1) as f64); // Rewritten into differential privacy each `Reduce` then join them. + if reduces.len() == 0 { + return Err(Error::DPCompilationError("Cannot rewrite into DP a Relation without any reduce.".to_string())) + } let (relation, private_query) = reduces.iter() .map(|r| pup_input.clone().differentially_private_aggregates( r.named_aggregates() @@ -319,19 +326,22 @@ impl Reduce { } } - first_aggs.extend( - self.group_by() - .into_iter() - .map(|x| (x.to_string(), AggregateColumn::new(aggregate::Aggregate::First, x.clone()))) - .collect::>() - ); - - distinct_map.into_iter() - .map(|(identifier, mut aggs)| { - aggs.extend(first_aggs.clone()); - self.rewrite_distinct(identifier, aggs) - }) - .collect() + if distinct_map.len() == 0 { + vec![self.clone()] + } else { + first_aggs.extend( + self.group_by() + .into_iter() + .map(|x| (x.to_string(), AggregateColumn::new(aggregate::Aggregate::First, x.clone()))) + .collect::>() + ); + distinct_map.into_iter() + .map(|(identifier, mut aggs)| { + aggs.extend(first_aggs.clone()); + self.rewrite_distinct(identifier, aggs) + }) + .collect() + } } /// Rewrite the `DISTINCT` aggregate with a `GROUP BY` @@ -980,6 +990,15 @@ mod tests { Relation::from(reduces[0].clone()).display_dot().unwrap(); Relation::from(reduces[1].clone()).display_dot().unwrap(); Relation::from(reduces[2].clone()).display_dot().unwrap(); + + // reduce without any aggregation + let reduce: Reduce = Relation::reduce() + .input(table.clone()) + .with_group_by_column("a") + .with_group_by_column("c") + .build(); + let reduces = reduce.split_distinct_aggregates(); + assert_eq!(reduces.len(), 1); } #[test] diff --git a/src/rewriting/mod.rs b/src/rewriting/mod.rs index d44d34e3..daf5425e 100644 --- a/src/rewriting/mod.rs +++ b/src/rewriting/mod.rs @@ -405,26 +405,26 @@ mod tests { "WITH ranked_products AS (SELECT product_id, COUNT(*) AS my_count FROM retail_transactions GROUP BY product_id) SELECT product_id FROM ranked_products ORDER BY my_count", //"SELECT t.product_id, p.product_category, COUNT(*) AS purchase_count FROM retail_transactions t INNER JOIN retail_products p ON t.product_id = p.product_id WHERE t.transaction_timestamp < CAST('2023-02-01' AS date) GROUP BY t.product_id, p.product_category", // cast date from string does not work in where "SELECT t.product_id, p.product_category, COUNT(*) AS purchase_count FROM retail_transactions t INNER JOIN retail_products p ON t.product_id = p.product_id WHERE t.transaction_timestamp > '2023-01-01' AND t.transaction_timestamp < '2023-02-01' GROUP BY t.product_id, p.product_category", - //"SELECT DISTINCT age, income FROM retail_demographics", - "SELECT AVG(sales_value) FROM retail_transactions AS t WHERE sales_values > 0 AND sales_values < 100", - //"SELECT quantity, AVG(sales_value) FROM retail_demographics AS d JOIN retail_transactions AS t ON d.household_id = t.household_id WHERE quantity > 0 AND sales_values > 0 AND sales_values < 100 GROUP BY quantity", - "WITH stats_stores AS (SELECT store_id, SUM(sales_value) AS sum_sales_value, AVG(retail_disc) FROM retail_transactions GROUP BY store_id) SELECT * FROM stats_stores WHERE sum_sales_value != 1", + "SELECT DISTINCT age, income FROM retail_demographics", + "SELECT age, income FROM retail_demographics GROUP BY age, income", + "SELECT quantity, AVG(sales_value) FROM retail_demographics AS d JOIN retail_transactions AS t ON d.household_id = t.household_id WHERE quantity > 0 AND sales_value > 0 AND sales_value < 100 GROUP BY quantity", + "WITH stats_stores AS (SELECT store_id, SUM(sales_value) AS sum_sales_value, AVG(retail_disc) FROM retail_transactions WHERE sales_value > 0 AND sales_value < 100 AND retail_disc > 0 AND retail_disc < 10 GROUP BY store_id) SELECT * FROM stats_stores WHERE sum_sales_value != 1", "SELECT p.product_id, p.brand, COUNT(*) FROM retail_products p INNER JOIN retail_transactions t ON p.product_id = t.product_id GROUP BY p.product_id, p.brand", - "SELECT t.household_id, store_id, AVG(sales_value) FROM retail_demographics AS d JOIN retail_transactions AS t ON d.household_id = t.household_id GROUP BY t.household_id, store_id", + "SELECT t.household_id, store_id, AVG(sales_value) FROM retail_demographics AS d JOIN retail_transactions AS t ON d.household_id = t.household_id WHERE sales_value > 0 AND sales_value < 100 GROUP BY t.household_id, store_id", "SELECT * FROM retail_transactions AS t INNER JOIN retail_products p ON t.product_id = p.product_id", ]; for query_str in queries { println!("\n{query_str}"); let query = parse(query_str).unwrap(); let relation = Relation::try_from(query.with(&relations)).unwrap(); - //relation.display_dot().unwrap(); + relation.display_dot().unwrap(); let dp_relation = relation.rewrite_with_differential_privacy( &relations, synthetic_data.clone(), privacy_unit.clone(), budget.clone() ).unwrap(); - //dp_relation.relation().display_dot().unwrap(); + dp_relation.relation().display_dot().unwrap(); } } From c6d66619ec1cc24b1079b93b486daaa3c626ac3b Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 14:02:20 +0100 Subject: [PATCH 3/8] ok --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 7f2966c2..6d746a92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Nicolas Grislain "] name = "qrlew" -version = "0.5.5" +version = "0.5.6" edition = "2021" description = "Sarus Qrlew Engine" documentation = "https://docs.rs/qrlew" From aba99932cb74df8b31b674bbb9dc6dcc7168617a Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 14:06:14 +0100 Subject: [PATCH 4/8] comment --- src/differential_privacy/aggregates.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/differential_privacy/aggregates.rs b/src/differential_privacy/aggregates.rs index e0d8272c..3e76bce8 100644 --- a/src/differential_privacy/aggregates.rs +++ b/src/differential_privacy/aggregates.rs @@ -238,6 +238,8 @@ impl PUPRelation { ); let (dp_relation, private_query) = if named_sums.len() == 0 { + // If `self` contains only `First` aggregations, do not rewrite these aggregations. + // `self`` is already dp since the grouping keys have been protected during the 1st step of the rewriting. (self.deref().clone(), PrivateQuery::null()) } else { let input: Relation = input_builder.input(self.deref().clone()).build(); From bad206fd93cae4f5671356a86a796e57504f0d7d Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 14:35:57 +0100 Subject: [PATCH 5/8] ok --- src/differential_privacy/aggregates.rs | 34 ++++++++++++-------------- src/differential_privacy/mod.rs | 29 ++++++++++++++++++---- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/differential_privacy/aggregates.rs b/src/differential_privacy/aggregates.rs index 3e76bce8..daccdbbf 100644 --- a/src/differential_privacy/aggregates.rs +++ b/src/differential_privacy/aggregates.rs @@ -236,26 +236,24 @@ impl PUPRelation { (input_b, sums, output_b) }, ); + if named_sums.len() == 0 { + return Err(Error::DPCompilationError("Cannot dp compile aggregations if there is no aggregations".to_string())) + } + - let (dp_relation, private_query) = if named_sums.len() == 0 { - // If `self` contains only `First` aggregations, do not rewrite these aggregations. - // `self`` is already dp since the grouping keys have been protected during the 1st step of the rewriting. - (self.deref().clone(), PrivateQuery::null()) - } else { let input: Relation = input_builder.input(self.deref().clone()).build(); - let pup_input = PUPRelation::try_from(input)?; - pup_input - .differentially_private_sums( - named_sums - .iter() // Convert &str to String - .map(|(s1, s2)| (s1.as_str(), s2.as_str())) - .collect::>(), - group_by_names, - epsilon, - delta, - )? - .into() - }; + let pup_input = PUPRelation::try_from(input)?; + let (dp_relation, private_query) = pup_input + .differentially_private_sums( + named_sums + .iter() // Convert &str to String + .map(|(s1, s2)| (s1.as_str(), s2.as_str())) + .collect::>(), + group_by_names, + epsilon, + delta, + )? + .into(); let dp_relation = output_builder .input(dp_relation) .build(); diff --git a/src/differential_privacy/mod.rs b/src/differential_privacy/mod.rs index d4c679a2..3f9d0582 100644 --- a/src/differential_privacy/mod.rs +++ b/src/differential_privacy/mod.rs @@ -13,7 +13,7 @@ use crate::{ differential_privacy::private_query::PrivateQuery, expr, privacy_unit_tracking, relation::{rewriting, Reduce, Relation}, - Ready, + Ready, data_type::function::Aggregate, }; use std::{error, fmt, ops::Deref, result}; @@ -114,6 +114,12 @@ impl From<(Relation, PrivateQuery)> for DPRelation { } impl Reduce { + fn has_only_first_aggregations(&self) -> bool { + self.aggregate() + .iter() + .all(|agg| agg.aggregate() == &expr::aggregate::Aggregate::First) + } + /// Rewrite a `Reduce` into DP: /// - Protect the grouping keys /// - Add noise on the aggregations @@ -126,6 +132,14 @@ impl Reduce { ) -> Result { let mut private_query = PrivateQuery::null(); + let has_only_first_agg = self.has_only_first_aggregations(); + let (epsilon, delta, epsilon_tau_thresholding, delta_tau_thresholding) = if has_only_first_agg { + // if the current Reduce has no aggregation other than First, the dp rewritting in only the protection of the grouping keys + (0., 0., epsilon_tau_thresholding + epsilon, delta_tau_thresholding + delta) + } else { + (epsilon, delta, epsilon_tau_thresholding, delta_tau_thresholding) + }; + // DP rewrite group by let reduce_with_dp_group_by = if self.group_by().is_empty() { self @@ -157,10 +171,15 @@ impl Reduce { }; // DP rewrite aggregates - let (dp_relation, private_query_agg) = reduce_with_dp_group_by - .differentially_private_aggregates(epsilon, delta)? - .into(); - private_query = private_query.compose(private_query_agg); + let dp_relation = if has_only_first_agg { + Relation::from(reduce_with_dp_group_by) + } else { + let (dp_relation, private_query_agg) = reduce_with_dp_group_by + .differentially_private_aggregates(epsilon, delta)? + .into(); + private_query = private_query.compose(private_query_agg); + dp_relation + }; Ok((dp_relation, private_query).into()) } } From 8eb935790fa303d47275525539edb20e8e282318 Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 15:31:13 +0100 Subject: [PATCH 6/8] ok --- src/differential_privacy/aggregates.rs | 58 +++++++++++++------------- src/differential_privacy/mod.rs | 27 ++---------- 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/src/differential_privacy/aggregates.rs b/src/differential_privacy/aggregates.rs index daccdbbf..c203ffe1 100644 --- a/src/differential_privacy/aggregates.rs +++ b/src/differential_privacy/aggregates.rs @@ -9,6 +9,7 @@ use crate::{ DataType, Ready, display::Dot, }; +use core::num; use std::{cmp, collections::HashMap, ops::Deref}; impl Field { @@ -35,28 +36,36 @@ impl Relation { // Cf. Theorem A.1. in (Dwork, Roth et al. 2014) log::warn!("Warning, epsilon>1 the gaussian mechanism applied will not be exactly epsilon,delta-DP!") } + let number_of_agg = bounds.len() as f64; - let noise_multipliers = bounds - .into_iter() - .map(|(name, bound)| { - ( - name, - private_query::gaussian_noise( - epsilon / number_of_agg, - delta / number_of_agg, - bound, - ), - ) - }) - .collect::>(); - let private_query = noise_multipliers - .iter() - .map(|(_, n)| PrivateQuery::Gaussian(*n)) - .collect::>() - .into(); - // DPRelation::new(self.add_gaussian_noise(noise_multipliers), private_query) + let (dp_relation, private_query) = if number_of_agg > 0. { + let noise_multipliers = bounds + .into_iter() + .map(|(name, bound)| { + ( + name, + private_query::gaussian_noise( + epsilon / number_of_agg, + delta / number_of_agg, + bound, + ), + ) + }) + .collect::>(); + let private_query = noise_multipliers + .iter() + .map(|(_, n)| PrivateQuery::Gaussian(*n)) + .collect::>() + .into(); + ( + self.add_clipped_gaussian_noise(&noise_multipliers), + private_query + ) + } else { + (self, PrivateQuery::null()) + }; DPRelation::new( - self.add_clipped_gaussian_noise(&noise_multipliers), + dp_relation, private_query, ) } @@ -236,10 +245,6 @@ impl PUPRelation { (input_b, sums, output_b) }, ); - if named_sums.len() == 0 { - return Err(Error::DPCompilationError("Cannot dp compile aggregations if there is no aggregations".to_string())) - } - let input: Relation = input_builder.input(self.deref().clone()).build(); let pup_input = PUPRelation::try_from(input)?; @@ -275,10 +280,7 @@ impl Reduce { let epsilon = epsilon / (cmp::max(reduces.len(), 1) as f64); let delta = delta / (cmp::max(reduces.len(), 1) as f64); - // Rewritten into differential privacy each `Reduce` then join them. - if reduces.len() == 0 { - return Err(Error::DPCompilationError("Cannot rewrite into DP a Relation without any reduce.".to_string())) - } + // Rewritte into differential privacy each `Reduce` then join them. let (relation, private_query) = reduces.iter() .map(|r| pup_input.clone().differentially_private_aggregates( r.named_aggregates() diff --git a/src/differential_privacy/mod.rs b/src/differential_privacy/mod.rs index 3f9d0582..7a1533ca 100644 --- a/src/differential_privacy/mod.rs +++ b/src/differential_privacy/mod.rs @@ -114,12 +114,6 @@ impl From<(Relation, PrivateQuery)> for DPRelation { } impl Reduce { - fn has_only_first_aggregations(&self) -> bool { - self.aggregate() - .iter() - .all(|agg| agg.aggregate() == &expr::aggregate::Aggregate::First) - } - /// Rewrite a `Reduce` into DP: /// - Protect the grouping keys /// - Add noise on the aggregations @@ -132,14 +126,6 @@ impl Reduce { ) -> Result { let mut private_query = PrivateQuery::null(); - let has_only_first_agg = self.has_only_first_aggregations(); - let (epsilon, delta, epsilon_tau_thresholding, delta_tau_thresholding) = if has_only_first_agg { - // if the current Reduce has no aggregation other than First, the dp rewritting in only the protection of the grouping keys - (0., 0., epsilon_tau_thresholding + epsilon, delta_tau_thresholding + delta) - } else { - (epsilon, delta, epsilon_tau_thresholding, delta_tau_thresholding) - }; - // DP rewrite group by let reduce_with_dp_group_by = if self.group_by().is_empty() { self @@ -171,15 +157,10 @@ impl Reduce { }; // DP rewrite aggregates - let dp_relation = if has_only_first_agg { - Relation::from(reduce_with_dp_group_by) - } else { - let (dp_relation, private_query_agg) = reduce_with_dp_group_by - .differentially_private_aggregates(epsilon, delta)? - .into(); - private_query = private_query.compose(private_query_agg); - dp_relation - }; + let (dp_relation, private_query_agg) = reduce_with_dp_group_by + .differentially_private_aggregates(epsilon, delta)? + .into(); + private_query = private_query.compose(private_query_agg); Ok((dp_relation, private_query).into()) } } From c8afc6324c571d3161634497da522b8a18910198 Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 15:33:52 +0100 Subject: [PATCH 7/8] ok --- src/differential_privacy/aggregates.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/differential_privacy/aggregates.rs b/src/differential_privacy/aggregates.rs index c203ffe1..790a80f8 100644 --- a/src/differential_privacy/aggregates.rs +++ b/src/differential_privacy/aggregates.rs @@ -9,7 +9,6 @@ use crate::{ DataType, Ready, display::Dot, }; -use core::num; use std::{cmp, collections::HashMap, ops::Deref}; impl Field { @@ -280,7 +279,7 @@ impl Reduce { let epsilon = epsilon / (cmp::max(reduces.len(), 1) as f64); let delta = delta / (cmp::max(reduces.len(), 1) as f64); - // Rewritte into differential privacy each `Reduce` then join them. + // Rewrite into differential privacy each `Reduce` then join them. let (relation, private_query) = reduces.iter() .map(|r| pup_input.clone().differentially_private_aggregates( r.named_aggregates() From 8a62fab69766e24f5e0b000094b4aa4706da7cfe Mon Sep 17 00:00:00 2001 From: victoria de sainte agathe Date: Mon, 18 Dec 2023 15:42:52 +0100 Subject: [PATCH 8/8] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed62a49b..afe8b750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Natural joins [#221](https://github.com/Qrlew/qrlew/issues/221) - When the clipping factor is zero, multiply by zero instead of dividing by 1 / clipping_factor [#218](https://github.com/Qrlew/qrlew/issues/218) - GROUP BY column alias [#223](https://github.com/Qrlew/qrlew/issues/223) -- DP compilation for Reduce that contains only First aggregations [#224](https://github.com/Qrlew/qrlew/issues/224) +- DP compilation for Reduce that contains only First aggregations [#225](https://github.com/Qrlew/qrlew/issues/225) ## Added - OFFSET [#224](https://github.com/Qrlew/qrlew/issues/224)