diff --git a/docs/book/src/count-indexed-tree.md b/docs/book/src/count-indexed-tree.md index 339660950..56564f548 100644 --- a/docs/book/src/count-indexed-tree.md +++ b/docs/book/src/count-indexed-tree.md @@ -544,7 +544,7 @@ Ascending traversal is also supported for "smallest counts first" / ### How many entries fall in a count band -`indexed_count_range_aggregate(path, lo, hi, ..)` answers **how many +`indexed_count_aggregate_over_value_range(path, lo, hi, ..)` answers **how many entries have a `count_value` in `[lo, hi]`** — a bucket population, in which each matching entry contributes 1. It is *not* the total of those entries' counts: over counts `[3, 1, 5]`, the band `[2, 10]` selects the diff --git a/grovedb-query/src/axis_query.rs b/grovedb-query/src/axis_query.rs index fd043465d..bcb42eebc 100644 --- a/grovedb-query/src/axis_query.rs +++ b/grovedb-query/src/axis_query.rs @@ -90,9 +90,64 @@ impl IndexAxis { /// through the traversal. pub const MAX_RANK_OF_KEY_LEN: usize = 255; +/// Which aggregate an [`AxisTraversal::AggregateOverValueRange`] folds +/// over the entries the value band selects. Wire bytes are explicit and +/// frozen: `Population = 0`, `Total = 1`. +/// +/// The fold is EXPLICIT because the two readings genuinely differ and +/// the "obvious" one flips per axis: over counts `[3, 1, 5]`, the band +/// `[2, 10]` selects the `3` and the `5`, so `Population` answers **2** +/// (each selected entry contributes 1) while `Total` answers **8** (the +/// selected values are summed). Making the caller say which they mean +/// removes the ambiguity that an axis-default fold invited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AggregateFold { + /// How many entries the band selects; each contributes 1. + /// Entries, not distinct values: two entries sharing the same axis + /// value are two nodes of the secondary (it is keyed + /// `sort_key ‖ original_key`) and count as 2. Answered by the + /// secondary's count aggregate, so it needs a count-bearing + /// secondary. + Population, + /// The sum of the selected entries' axis values. Answered by the + /// secondary's sum aggregate, so it needs a sum-bearing secondary. + Total, +} + +impl AggregateFold { + /// The frozen wire byte. + #[inline] + pub const fn tag(&self) -> u8 { + match self { + AggregateFold::Population => 0, + AggregateFold::Total => 1, + } + } + + /// Inverse of [`Self::tag`]; any byte outside `0..=1` is an error. + #[inline] + pub const fn try_from_tag(b: u8) -> Result { + match b { + 0 => Ok(AggregateFold::Population), + 1 => Ok(AggregateFold::Total), + other => Err(other), + } + } +} + +impl fmt::Display for AggregateFold { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AggregateFold::Population => write!(f, "population"), + AggregateFold::Total => write!(f, "total"), + } + } +} + /// How an [`AxisQuery`] walks the secondary. Wire tags are explicit and /// frozen: `RankedPage = 0`, `Bounded = 1`, `RankOfKey = 2`, -/// `RangeAggregate = 3`. +/// `AggregateOverValueRange = 3`. /// /// # Cost /// @@ -177,44 +232,50 @@ pub enum AxisTraversal { /// The original (primary) key whose rank is requested. key: Vec, }, - /// `[lo, hi]` selects the entries; the axis's own secondary - /// aggregate over exactly those entries is the answer. Count and - /// Sum axes only — the Avg axis has no meaningful - /// average-of-averages. + /// `[lo, hi]` selects the entries by their own axis value; `fold` + /// says which aggregate over exactly those entries is the answer. + /// Count and Sum axes only — the Avg axis has no meaningful + /// aggregate-of-averages. + /// + /// The fold is explicit because both readings are meaningful on + /// both axes and the "obvious" one flips per axis. Over counts + /// `[3, 1, 5]`, the band `[2, 10]` selects the `3` and the `5`: /// - /// **The aggregation differs per axis, and the count axis is the - /// one that reads wrong.** Each secondary aggregates the way its - /// own tree type does, so: + /// * [`AggregateFold::Population`] answers **2** — how many entries + /// fall in the band, each contributing 1. + /// * [`AggregateFold::Total`] answers **8** — the selected values + /// summed. /// - /// * **Sum axis** — the answer is the TOTAL of the selected - /// entries' sums. `RangeAggregate { lo: 0, hi: 100 }` over sums - /// `[40, -10, 25]` selects `40` and `25` and answers `65`. - /// * **Count axis** — the answer is HOW MANY entries were - /// selected, each contributing 1. It is a bucket population, NOT - /// the total of their counts. `RangeAggregate { lo: 2, hi: 10 }` - /// over counts `[3, 1, 5]` selects the `3` and the `5` and - /// answers **2**, not 8. + /// The same pair over sums `[40, -10, 25]` with band `[0, 100]` + /// selects `40` and `25`: `Population` answers `2`, `Total` answers + /// `65`. /// - /// Reach for the count axis here to ask "how many entries fall in - /// this band?". There is no traversal that totals the counts in a - /// band — [`Self::Bounded`] lists the selected entries with their - /// values, and the caller sums them. + /// **Currently unsupported: `Total` on the count axis.** The query + /// validates and serializes (the vocabulary is stable), but every + /// execution surface — trusted read, embedded prover/verifier, + /// standalone prover/verifier — refuses it with a typed + /// `NotSupported` naming issue #806 until the count secondary + /// becomes sum-bearing (#806 part 2, which removes this paragraph). + /// The other three (axis, fold) cells are served today. /// - /// **Cost** — `O(log n)` in every case. The walk classifies each - /// subtree as fully Contained, Disjoint, or Partial and folds a - /// Contained subtree's stored aggregate in one step, descending - /// only along the two range boundaries. **No term in the number of - /// matched entries** — aggregating a million in-range entries costs - /// what aggregating one costs, which is what makes this preferable - /// to [`Self::Bounded`] whenever only the total is wanted. - RangeAggregate { + /// **Cost** — `O(log n)` in every case, either fold. The walk + /// classifies each subtree as fully Contained, Disjoint, or Partial + /// and folds a Contained subtree's stored aggregate in one step, + /// descending only along the two range boundaries. **No term in the + /// number of matched entries** — aggregating a million in-range + /// entries costs what aggregating one costs, which is what makes + /// this preferable to [`Self::Bounded`] whenever only the scalar is + /// wanted. + AggregateOverValueRange { /// Inclusive lower bound on the entry's own axis VALUE (its /// count on the count axis, its sum on the sum axis) — not on /// the aggregate this traversal returns. lo: i128, /// Inclusive upper bound on the entry's own axis value. See - /// [`Self::RangeAggregate::lo`]. + /// [`Self::AggregateOverValueRange::lo`]. hi: i128, + /// The aggregate to fold over the selected entries. + fold: AggregateFold, }, } @@ -236,10 +297,11 @@ impl Encode for AxisTraversal { 2u8.encode(encoder)?; key.encode(encoder) } - AxisTraversal::RangeAggregate { lo, hi } => { + AxisTraversal::AggregateOverValueRange { lo, hi, fold } => { 3u8.encode(encoder)?; lo.encode(encoder)?; - hi.encode(encoder) + hi.encode(encoder)?; + fold.tag().encode(encoder) } } } @@ -266,9 +328,11 @@ impl Decode for AxisTraversal { } Ok(AxisTraversal::RankOfKey { key }) } - 3 => Ok(AxisTraversal::RangeAggregate { + 3 => Ok(AxisTraversal::AggregateOverValueRange { lo: i128::decode(decoder)?, hi: i128::decode(decoder)?, + fold: AggregateFold::try_from_tag(u8::decode(decoder)?) + .map_err(|_| DecodeError::Other("unknown aggregate fold tag"))?, }), _ => Err(DecodeError::Other("unknown axis traversal tag")), } @@ -393,13 +457,20 @@ impl AxisQuery { } } - /// A single aggregate over entries whose value is in `[lo, hi]`. - /// Direction does not affect the answer; constructors set + /// A single `fold` aggregate over the entries whose axis value is + /// in `[lo, hi]` — [`AggregateFold::Population`] for how many, + /// [`AggregateFold::Total`] for the sum of their values. Direction + /// does not affect the answer; constructors set /// `descending = false`. - pub const fn range_aggregate(axis: IndexAxis, lo: i128, hi: i128) -> Self { + pub const fn aggregate_over_value_range( + axis: IndexAxis, + lo: i128, + hi: i128, + fold: AggregateFold, + ) -> Self { Self { axis, - traversal: AxisTraversal::RangeAggregate { lo, hi }, + traversal: AxisTraversal::AggregateOverValueRange { lo, hi, fold }, descending: false, } } @@ -440,11 +511,16 @@ impl AxisQuery { )); } } - AxisTraversal::RangeAggregate { lo, hi } => { + AxisTraversal::AggregateOverValueRange { lo, hi, fold: _ } => { + // Both folds are rejected on the Avg axis: a total of + // averages is not meaningful, and a population over the + // avg ordering is served by the other two axes' bands + // in every use case seen so far — permit it later if + // one appears (additive). if self.axis == IndexAxis::Avg { return Err(Error::InvalidOperation( - "axis query: the Avg axis has no range aggregate — a sum of averages \ - is not meaningful", + "axis query: the Avg axis has no value-range aggregate — an \ + aggregate of averages is not meaningful", )); } self.validate_bounds(*lo, *hi)?; @@ -454,7 +530,7 @@ impl AxisQuery { } /// Shared bound rules for [`AxisTraversal::Bounded`] and - /// [`AxisTraversal::RangeAggregate`]. + /// [`AxisTraversal::AggregateOverValueRange`]. fn validate_bounds(&self, lo: i128, hi: i128) -> Result<(), Error> { if lo > hi { return Err(Error::InvalidOperation( @@ -484,14 +560,14 @@ impl AxisQuery { /// The number of entries this query can return, when that is a /// fixed property of the traversal (`None` for - /// [`AxisTraversal::RangeAggregate`], which returns one scalar, not + /// [`AxisTraversal::AggregateOverValueRange`], which returns one scalar, not /// entries). pub const fn entry_cap(&self) -> Option { match &self.traversal { AxisTraversal::RankedPage { k, .. } => Some(*k), AxisTraversal::Bounded { limit, .. } => Some(*limit), AxisTraversal::RankOfKey { .. } => Some(1), - AxisTraversal::RangeAggregate { .. } => None, + AxisTraversal::AggregateOverValueRange { .. } => None, } } } @@ -508,8 +584,11 @@ impl fmt::Display for AxisTraversal { AxisTraversal::RankOfKey { key } => { write!(f, "RankOfKey {{ key: {} }}", crate::hex_to_ascii(key)) } - AxisTraversal::RangeAggregate { lo, hi } => { - write!(f, "RangeAggregate {{ lo: {lo}, hi: {hi} }}") + AxisTraversal::AggregateOverValueRange { lo, hi, fold } => { + write!( + f, + "AggregateOverValueRange {{ lo: {lo}, hi: {hi}, fold: {fold} }}" + ) } } } @@ -568,7 +647,16 @@ mod tests { AxisTraversal::RankOfKey { key: b"alice".to_vec(), }, - AxisTraversal::RangeAggregate { lo: 0, hi: 50 }, + AxisTraversal::AggregateOverValueRange { + lo: 0, + hi: 50, + fold: AggregateFold::Population, + }, + AxisTraversal::AggregateOverValueRange { + lo: 0, + hi: 50, + fold: AggregateFold::Total, + }, ] } @@ -599,7 +687,7 @@ mod tests { .into_iter() .map(|t| bincode::encode_to_vec(&t, config::standard()).unwrap()[0]) .collect(); - assert_eq!(tags, vec![0, 1, 2, 3]); + assert_eq!(tags, vec![0, 1, 2, 3, 3]); // First byte of an AxisQuery encoding is the axis tag byte. let q = AxisQuery::top_k(IndexAxis::Avg, 1, 0, true); let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); @@ -625,6 +713,30 @@ mod tests { assert!(bincode::decode_from_slice::(&bytes, config::standard()).is_ok()); } + #[test] + fn decode_rejects_unknown_fold_bytes() { + // tag 3, lo = 0, hi = 0 (i128s), then a fold byte outside 0..=1: + // fail closed, exactly like an unknown traversal tag. + let good = bincode::encode_to_vec( + &AxisTraversal::AggregateOverValueRange { + lo: 0, + hi: 0, + fold: AggregateFold::Total, + }, + config::standard(), + ) + .unwrap(); + let mut bad = good.clone(); + *bad.last_mut().unwrap() = 2; + assert!( + bincode::decode_from_slice::(&bad, config::standard()).is_err(), + "an unknown fold byte must not decode" + ); + // Sanity: the honest bytes decode, and the last byte IS the fold. + assert_eq!(*good.last().unwrap(), 1, "Total = 1 on the wire"); + assert!(bincode::decode_from_slice::(&good, config::standard()).is_ok()); + } + #[test] fn validate_rejects_unanswerable_queries() { // k = 0. @@ -644,15 +756,22 @@ mod tests { .validate() .is_err()); // Wholly out of domain: beyond i64 for sums. - assert!( - AxisQuery::range_aggregate(IndexAxis::Sum, i64::MAX as i128 + 1, i128::MAX) - .validate() - .is_err() - ); - // Range aggregate on Avg. - assert!(AxisQuery::range_aggregate(IndexAxis::Avg, 0, 10) - .validate() - .is_err()); + assert!(AxisQuery::aggregate_over_value_range( + IndexAxis::Sum, + i64::MAX as i128 + 1, + i128::MAX, + AggregateFold::Total + ) + .validate() + .is_err()); + // Aggregate over the value range on Avg — both folds. + for fold in [AggregateFold::Population, AggregateFold::Total] { + assert!( + AxisQuery::aggregate_over_value_range(IndexAxis::Avg, 0, 10, fold) + .validate() + .is_err() + ); + } // Empty rank key. assert!(AxisQuery::rank_of_key(IndexAxis::Count, vec![], true) .validate() @@ -717,9 +836,11 @@ mod tests { AxisQuery::rank_of_key(IndexAxis::Sum, b"k".to_vec(), true).entry_cap(), Some(1) ); - assert_eq!( - AxisQuery::range_aggregate(IndexAxis::Sum, 0, 1).entry_cap(), - None - ); + for fold in [AggregateFold::Population, AggregateFold::Total] { + assert_eq!( + AxisQuery::aggregate_over_value_range(IndexAxis::Sum, 0, 1, fold).entry_cap(), + None + ); + } } } diff --git a/grovedb-query/src/lib.rs b/grovedb-query/src/lib.rs index eeda76c33..0c555458b 100644 --- a/grovedb-query/src/lib.rs +++ b/grovedb-query/src/lib.rs @@ -56,7 +56,7 @@ mod query; mod subquery_branch; pub use aggregate_sum_query::AggregateSumQuery; -pub use axis_query::{AxisQuery, AxisTraversal, IndexAxis, UnknownAxisTag}; +pub use axis_query::{AggregateFold, AxisQuery, AxisTraversal, IndexAxis, UnknownAxisTag}; pub use proof_items::ProofItems; pub use proof_status::ProofStatus; pub use query::Query; diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 8107d0067..630699109 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -68,7 +68,7 @@ pub struct GroveDBOperationsIndexedAxisVersions { /// The trusted per-axis reads (`indexed_{count,sum,avg}_*`). pub read: FeatureVersion, /// The standalone single-path envelope provers - /// (`prove_indexed_axis_{top_k,top_k_paginated,query,rank_of_key,range_aggregate}`). + /// (`prove_indexed_axis_{top_k,top_k_paginated,query,rank_of_key,aggregate_over_value_range}`). pub prove_single_path: FeatureVersion, /// The matching standalone verifiers. pub verify_single_path: FeatureVersion, diff --git a/grovedb/src/operations/get/run_path_query.rs b/grovedb/src/operations/get/run_path_query.rs index 27288719a..8f80df455 100644 --- a/grovedb/src/operations/get/run_path_query.rs +++ b/grovedb/src/operations/get/run_path_query.rs @@ -27,7 +27,7 @@ //! unified proof dispatch arrives separately. use grovedb_costs::{cost_return_on_error, CostResult, CostsExt}; -use grovedb_merk::proofs::query::{AxisTraversal, IndexAxis}; +use grovedb_merk::proofs::query::{AggregateFold, AxisTraversal, IndexAxis}; use grovedb_path::SubtreePath; use grovedb_version::{ check_grovedb_v0_with_cost, error::GroveVersionError, version::GroveVersion, @@ -45,10 +45,10 @@ use crate::{ /// secondary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AxisAggregateValue { - /// Count of matching entries (count axis). - Count(u64), - /// Signed sum of matching entries (sum axis). - Sum(i64), + /// How many entries the value band selected; each contributes 1. + Population(u64), + /// The signed sum of the selected entries' axis values. + Total(i64), } /// The typed answer to [`GroveDb::run_path_query`] — one variant per @@ -93,7 +93,7 @@ pub enum PathQueryRun { BranchedAxisEntries(Vec<(Vec, Option)>), /// `RankOfKey` traversal: the item's 0-based rank in the walk. AxisRank(u64), - /// `RangeAggregate` traversal: one scalar over the value range. + /// `AggregateOverValueRange` traversal: one scalar over the value range. AxisAggregate(AxisAggregateValue), /// Sum-budget read: the budgeted walk's matches and stop state. SumBudget(AggregateSumQueryResult), @@ -406,12 +406,12 @@ impl GroveDb { ); Ok(PathQueryRun::AxisRank(rank)).wrap_with_cost(cost) } - AxisTraversal::RangeAggregate { lo, hi } => match axis { - IndexAxis::Count => { + AxisTraversal::AggregateOverValueRange { lo, hi, fold } => match (axis, fold) { + (IndexAxis::Count, AggregateFold::Population) => { let (lo_count, hi_count) = clamp_count_bounds(*lo, *hi); let value = cost_return_on_error!( &mut cost, - self.indexed_count_range_aggregate( + self.indexed_count_aggregate_over_value_range( path, lo_count, hi_count, @@ -419,16 +419,16 @@ impl GroveDb { grove_version ) ); - Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Count( + Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Population( value, ))) .wrap_with_cost(cost) } - IndexAxis::Sum => { + (IndexAxis::Sum, AggregateFold::Total) => { let (lo_sum, hi_sum) = clamp_sum_bounds(*lo, *hi); let value = cost_return_on_error!( &mut cost, - self.indexed_sum_range_aggregate( + self.indexed_sum_aggregate_over_value_range( path, lo_sum, hi_sum, @@ -436,12 +436,37 @@ impl GroveDb { grove_version ) ); - Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Sum(value))) - .wrap_with_cost(cost) + Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Total( + value, + ))) + .wrap_with_cost(cost) } - // classify rejects range aggregates on the Avg axis. - IndexAxis::Avg => Err(Error::CorruptedCodeExecution( - "range aggregate on the Avg axis survived classification", + (IndexAxis::Sum, AggregateFold::Population) => { + let (lo_sum, hi_sum) = clamp_sum_bounds(*lo, *hi); + let value = cost_return_on_error!( + &mut cost, + self.indexed_sum_population_over_value_range( + path, + lo_sum, + hi_sum, + transaction, + grove_version + ) + ); + Ok(PathQueryRun::AxisAggregate(AxisAggregateValue::Population( + value, + ))) + .wrap_with_cost(cost) + } + (IndexAxis::Count, AggregateFold::Total) => Err(Error::NotSupported( + "a TOTAL over the count axis needs a sum-bearing count secondary; \ + tracked in issue #806" + .to_string(), + )) + .wrap_with_cost(cost), + // classify rejects value-range aggregates on the Avg axis. + (IndexAxis::Avg, _) => Err(Error::CorruptedCodeExecution( + "value-range aggregate on the Avg axis survived classification", )) .wrap_with_cost(cost), }, diff --git a/grovedb/src/operations/indexed_tree.rs b/grovedb/src/operations/indexed_tree.rs index 9261867c1..cd1723b32 100644 --- a/grovedb/src/operations/indexed_tree.rs +++ b/grovedb/src/operations/indexed_tree.rs @@ -1589,9 +1589,9 @@ impl GroveDb { /// indexed-tree have?". This call has no cryptographic guarantee — /// the returned count is whatever the merk reports. For a /// verifiable count, use - /// [`Self::prove_indexed_count_range_aggregate`] + - /// [`Self::verify_indexed_count_range_aggregate`]. - pub fn indexed_count_range_aggregate<'b, B, P>( + /// [`Self::prove_indexed_count_aggregate_over_value_range`] + + /// [`Self::verify_indexed_count_aggregate_over_value_range`]. + pub fn indexed_count_aggregate_over_value_range<'b, B, P>( &self, path: P, lo_count: u64, @@ -1604,7 +1604,7 @@ impl GroveDb { P: Into>, { grovedb_version::check_grovedb_v0_with_cost!( - "indexed_count_range_aggregate", + "indexed_count_aggregate_over_value_range", grove_version.grovedb_versions.operations.indexed_axis.read ); use grovedb_merk::proofs::query::QueryItem as MerkQueryItemForRange; @@ -1789,7 +1789,7 @@ impl GroveDb { /// indexed-tree". Like the count counterpart, this call has no /// cryptographic guarantee; for a verifiable sum use the /// proof-bound variant in the proof submodule. - pub fn indexed_sum_range_aggregate<'b, B, P>( + pub fn indexed_sum_aggregate_over_value_range<'b, B, P>( &self, path: P, lo_sum: i64, @@ -1802,7 +1802,7 @@ impl GroveDb { P: Into>, { grovedb_version::check_grovedb_v0_with_cost!( - "indexed_sum_range_aggregate", + "indexed_sum_aggregate_over_value_range", grove_version.grovedb_versions.operations.indexed_axis.read ); use grovedb_merk::proofs::query::QueryItem as MerkQueryItemForRange; @@ -1838,6 +1838,69 @@ impl GroveDb { Ok(sum).wrap_with_cost(cost) } + /// How many entries of the sum axis fall in the inclusive sum band + /// `[lo_sum, hi_sum]` — the POPULATION of the band, each selected + /// entry contributing 1 regardless of its value. The + /// [`AggregateFold::Population`](grovedb_query::AggregateFold) + /// counterpart of [`Self::indexed_sum_aggregate_over_value_range`], + /// answered off the sum secondary's count aggregate (the sum + /// secondary is count-bearing — `ProvableCountProvableSumTree` — + /// which is also what makes its offset pagination provable). + /// + /// `O(log n)`: the walk folds contained subtrees' stored counts and + /// descends only along the two band boundaries. + pub fn indexed_sum_population_over_value_range<'b, B, P>( + &self, + path: P, + lo_sum: i64, + hi_sum: i64, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + // The version gate precedes the degenerate-range fast path: + // every input to this entry point is held to the same version + // contract, inverted bounds included. + grovedb_version::check_grovedb_v0_with_cost!( + "indexed_sum_population_over_value_range", + grove_version.grovedb_versions.operations.indexed_axis.read + ); + use grovedb_merk::proofs::query::QueryItem as MerkQueryItemForRange; + + let mut cost = OperationCost::default(); + if lo_sum > hi_sum { + return Ok(0u64).wrap_with_cost(cost); + } + let path: SubtreePath = path.into(); + let tx = TxRef::new(&self.db, transaction); + let tx_ref = tx.as_ref(); + + let secondary_merk = cost_return_on_error!( + &mut cost, + self.open_validated_axis_secondary(path, IndexAxis::Sum, tx_ref, grove_version) + ); + + let lo_bytes = encode_sum_sort_key(lo_sum).to_vec(); + let inner_range = if hi_sum == i64::MAX { + MerkQueryItemForRange::RangeFrom(lo_bytes..) + } else { + let upper_bytes = encode_sum_sort_key(hi_sum + 1).to_vec(); + MerkQueryItemForRange::Range(lo_bytes..upper_bytes) + }; + + let population = cost_return_on_error!( + &mut cost, + secondary_merk + .count_aggregate_on_range(&inner_range, grove_version) + .map_err(|e| Error::CorruptedData(format!("indexed sum population on range: {e}"))) + ); + + Ok(population).wrap_with_cost(cost) + } + // ---- avg axis (PCPSIT-only) ---- /// Iterate the avg-axis secondary in avg-order and return the @@ -1918,10 +1981,10 @@ impl GroveDb { /// vector. `lo_avg == i128::MIN && hi_avg == i128::MAX` is /// equivalent to a full scan. /// - /// No `indexed_avg_range_aggregate` exists — averaging an average + /// No `indexed_avg_aggregate_over_value_range` exists — averaging an average /// over a range is not a closed-form aggregate. Callers that need /// "aggregate avg in range" should compute it client-side from - /// `indexed_count_range_aggregate` + `indexed_sum_range_aggregate` + /// `indexed_count_aggregate_over_value_range` + `indexed_sum_aggregate_over_value_range` /// against the same path's count and sum secondaries. pub fn indexed_avg_range<'b, B, P>( &self, diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index f59b32fa9..9d176506d 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -2369,8 +2369,8 @@ impl GroveDb { "aggregate-on-range carrier queries cannot descend \ through an indexed tree (PCIT / PSIT / PCPSIT); use \ the dedicated indexed-axis aggregate proofs \ - (prove_indexed_count_range_aggregate / \ - prove_indexed_sum_range_aggregate) instead" + (prove_indexed_count_aggregate_over_value_range / \ + prove_indexed_sum_aggregate_over_value_range) instead" .to_string(), )) .wrap_with_cost(cost); @@ -2481,8 +2481,8 @@ impl GroveDb { "aggregate-on-range carrier queries cannot descend \ through an indexed tree (PCIT / PSIT / PCPSIT); use \ the dedicated indexed-axis aggregate proofs \ - (prove_indexed_count_range_aggregate / \ - prove_indexed_sum_range_aggregate) instead" + (prove_indexed_count_aggregate_over_value_range / \ + prove_indexed_sum_aggregate_over_value_range) instead" .to_string(), )) .wrap_with_cost(cost); @@ -2585,8 +2585,8 @@ impl GroveDb { "aggregate-on-range carrier queries cannot descend \ through an indexed tree (PCIT / PSIT / PCPSIT); use \ the dedicated indexed-axis aggregate proofs \ - (prove_indexed_count_range_aggregate / \ - prove_indexed_sum_range_aggregate) instead" + (prove_indexed_count_aggregate_over_value_range / \ + prove_indexed_sum_aggregate_over_value_range) instead" .to_string(), )) .wrap_with_cost(cost); diff --git a/grovedb/src/operations/proof/indexed_axis/axis_api.rs b/grovedb/src/operations/proof/indexed_axis/axis_api.rs index 23f695db6..35ca0732c 100644 --- a/grovedb/src/operations/proof/indexed_axis/axis_api.rs +++ b/grovedb/src/operations/proof/indexed_axis/axis_api.rs @@ -9,6 +9,7 @@ use grovedb_element::indexed::IndexAxis; use grovedb_merk::proofs::Query as MerkQuery; #[cfg(feature = "minimal")] use grovedb_path::SubtreePath; +use grovedb_query::AggregateFold; use grovedb_version::version::GroveVersion; #[cfg(feature = "minimal")] @@ -98,7 +99,7 @@ impl GroveDb { /// Prove the aggregate count of entries whose `count_value` is in /// `[lo_count, hi_count]`. #[cfg(feature = "minimal")] - pub fn prove_indexed_count_range_aggregate<'b, B, P>( + pub fn prove_indexed_count_aggregate_over_value_range<'b, B, P>( &self, path: P, lo_count: u64, @@ -110,11 +111,12 @@ impl GroveDb { B: AsRef<[u8]> + 'b, P: Into>, { - self.prove_indexed_axis_range_aggregate( + self.prove_indexed_axis_aggregate_over_value_range( path, IndexAxis::Count, lo_count as i128, hi_count as i128, + AggregateFold::Population, transaction, grove_version, ) @@ -177,19 +179,20 @@ impl GroveDb { } /// Verify a count-axis aggregate proof. - pub fn verify_indexed_count_range_aggregate( + pub fn verify_indexed_count_aggregate_over_value_range( proof_bytes: &[u8], path: &[&[u8]], expected_lo_count: u64, expected_hi_count: u64, grove_version: &GroveVersion, ) -> Result { - Self::verify_indexed_axis_range_aggregate( + Self::verify_indexed_axis_aggregate_over_value_range( proof_bytes, path, IndexAxis::Count, expected_lo_count as i128, expected_hi_count as i128, + AggregateFold::Population, grove_version, ) } @@ -276,7 +279,7 @@ impl GroveDb { /// Prove the aggregate sum of entries whose `sum_value` is in /// `[lo_sum, hi_sum]`. #[cfg(feature = "minimal")] - pub fn prove_indexed_sum_range_aggregate<'b, B, P>( + pub fn prove_indexed_sum_aggregate_over_value_range<'b, B, P>( &self, path: P, lo_sum: i64, @@ -288,11 +291,12 @@ impl GroveDb { B: AsRef<[u8]> + 'b, P: Into>, { - self.prove_indexed_axis_range_aggregate( + self.prove_indexed_axis_aggregate_over_value_range( path, IndexAxis::Sum, lo_sum as i128, hi_sum as i128, + AggregateFold::Total, transaction, grove_version, ) @@ -355,19 +359,20 @@ impl GroveDb { } /// Verify a sum-axis aggregate proof. - pub fn verify_indexed_sum_range_aggregate( + pub fn verify_indexed_sum_aggregate_over_value_range( proof_bytes: &[u8], path: &[&[u8]], expected_lo_sum: i64, expected_hi_sum: i64, grove_version: &GroveVersion, ) -> Result { - Self::verify_indexed_axis_range_aggregate( + Self::verify_indexed_axis_aggregate_over_value_range( proof_bytes, path, IndexAxis::Sum, expected_lo_sum as i128, expected_hi_sum as i128, + AggregateFold::Total, grove_version, ) } diff --git a/grovedb/src/operations/proof/indexed_axis/envelope.rs b/grovedb/src/operations/proof/indexed_axis/envelope.rs index 1205e85c0..e3bfedb86 100644 --- a/grovedb/src/operations/proof/indexed_axis/envelope.rs +++ b/grovedb/src/operations/proof/indexed_axis/envelope.rs @@ -144,8 +144,10 @@ pub struct IndexedAxisAggregateProof { /// Same as [`IndexedAxisRangeProof::target_is_pcpsit`]. pub target_is_pcpsit: bool, /// Encoded aggregate proof bytes for the per-axis secondary. - /// For count axis: `prove_aggregate_count_on_range` output. - /// For sum axis: `prove_aggregate_sum_on_range` output. + /// The walker follows the FOLD, not the axis: + /// `AggregateFold::Population` → `prove_aggregate_count_on_range` + /// output; `AggregateFold::Total` → `prove_aggregate_sum_on_range` + /// output. The byte range the proof covers follows the axis. pub secondary_proof: Vec, /// Echoed inclusive lower bound on the secondary's sort-value /// (i.e. `count_value` for count axis, `sum_value` for sum axis). @@ -153,6 +155,11 @@ pub struct IndexedAxisAggregateProof { pub lo: i128, /// Echoed inclusive upper bound. pub hi: i128, + /// Echoed [`AggregateFold::tag`](grovedb_query::AggregateFold::tag) + /// of the fold the proof answers. The verifier authenticates this + /// against the caller's `expected_fold` — a population proof must + /// not satisfy a question about a total, or vice versa. + pub fold_tag: u8, } /// Sort-value variants per axis. Returned in diff --git a/grovedb/src/operations/proof/indexed_axis/generate.rs b/grovedb/src/operations/proof/indexed_axis/generate.rs index 6024a2f9c..a9664bf97 100644 --- a/grovedb/src/operations/proof/indexed_axis/generate.rs +++ b/grovedb/src/operations/proof/indexed_axis/generate.rs @@ -8,21 +8,22 @@ use grovedb_costs::{ cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, }; -use grovedb_element::indexed::{encode_count_sort_key, encode_sum_sort_key, IndexAxis}; +use grovedb_element::indexed::IndexAxis; use grovedb_merk::{ element::get::ElementFetchFromStorageExtensions, proofs::{encode_into, query::QueryItem as MerkQueryItemForRange, Query as MerkQuery}, }; use grovedb_path::{SubtreePath, SubtreePathBuilder}; -use grovedb_query::QueryItem as MerkQueryItem; +use grovedb_query::{AggregateFold, QueryItem as MerkQueryItem}; use grovedb_storage::StorageBatch; use grovedb_version::version::GroveVersion; use crate::{util::TxRef, Element, Error, GroveDb, Transaction, TransactionArg}; use super::{ - aggregate_range_out_of_domain, AncestorAttestation, IndexedAxisAggregateProof, - IndexedAxisPaginatedProof, IndexedAxisRangeProof, + verify::{count_aggregate_inner_range, sum_aggregate_inner_range}, + AncestorAttestation, IndexedAxisAggregateProof, IndexedAxisPaginatedProof, + IndexedAxisRangeProof, }; use crate::operations::proof::AxisDescentProof; @@ -689,16 +690,17 @@ impl GroveDb { /// [`IndexAxis::Avg`] returns [`Error::NotSupported`] — averaging /// averages over a range is not a closed-form aggregate (callers /// should compute it client-side from - /// `indexed_count_range_aggregate` + `indexed_sum_range_aggregate` + /// `indexed_count_aggregate_over_value_range` + `indexed_sum_aggregate_over_value_range` /// against the same path). /// /// `lo > hi` is a degenerate range; the proof commits `0`. - pub fn prove_indexed_axis_range_aggregate<'b, B, P>( + pub fn prove_indexed_axis_aggregate_over_value_range<'b, B, P>( &self, path: P, axis: IndexAxis, lo: i128, hi: i128, + fold: AggregateFold, transaction: TransactionArg, grove_version: &GroveVersion, ) -> CostResult, Error> @@ -707,7 +709,7 @@ impl GroveDb { P: Into>, { grovedb_version::check_grovedb_v0_with_cost!( - "prove_indexed_axis_range_aggregate", + "prove_indexed_axis_aggregate_over_value_range", grove_version .grovedb_versions .operations @@ -736,6 +738,7 @@ impl GroveDb { axis, lo, hi, + fold, tx_ref, &batch, grove_version, @@ -1028,6 +1031,7 @@ impl GroveDb { axis: IndexAxis, lo: i128, hi: i128, + fold: AggregateFold, transaction: &'db Transaction, batch: &'db StorageBatch, grove_version: &GroveVersion, @@ -1092,7 +1096,7 @@ impl GroveDb { ); if !primary_merk.tree_type.is_indexed_primary() { return Err(Error::InvalidPath( - "prove_indexed_axis_range_aggregate requires the path's last segment to be an \ + "prove_indexed_axis_aggregate_over_value_range requires the path's last segment to be an \ indexed-tree element" .to_string(), )) @@ -1123,70 +1127,18 @@ impl GroveDb { grove_version, ) ); - let serialized = match axis { - IndexAxis::Count => { - // count_value ∈ [0, u64::MAX]. A range whose whole span is - // outside that domain (hi < 0 OR lo > u64::MAX) must commit - // an EMPTY (count = 0) proof — clamping the bounds into the - // domain would otherwise collapse the range onto a boundary - // key (e.g. lo = u64::MAX + 5, hi = u64::MAX + 10 → query - // `count == u64::MAX`) and erroneously count entries - // sitting exactly on the boundary. - if aggregate_range_out_of_domain(IndexAxis::Count, lo, hi) { - cost_return_on_error_no_add!( - cost, - build_empty_count_aggregate_proof( - &secondary_merk, - grove_version, - &mut cost, - ) - ) - } else { - let lo_u = if lo < 0 { - 0u64 - } else { - lo.min(u64::MAX as i128) as u64 - }; - let hi_u = hi.min(u64::MAX as i128) as u64; - cost_return_on_error_no_add!( - cost, - build_count_aggregate_secondary_proof( - &secondary_merk, - lo_u, - hi_u, - grove_version, - &mut cost, - ) - ) - } - } - IndexAxis::Sum => { - // sum_value ∈ [i64::MIN, i64::MAX]. As with count, a range - // entirely above or below that domain must commit an EMPTY - // (sum = 0) proof rather than clamping onto i64::MAX / - // i64::MIN (which would count/sum boundary entries). - if aggregate_range_out_of_domain(IndexAxis::Sum, lo, hi) { - cost_return_on_error_no_add!( - cost, - build_empty_sum_aggregate_proof(&secondary_merk, grove_version, &mut cost) - ) - } else { - let lo_i = lo.max(i64::MIN as i128).min(i64::MAX as i128) as i64; - let hi_i = hi.max(i64::MIN as i128).min(i64::MAX as i128) as i64; - cost_return_on_error_no_add!( - cost, - build_sum_aggregate_secondary_proof( - &secondary_merk, - lo_i, - hi_i, - grove_version, - &mut cost, - ) - ) - } - } - IndexAxis::Avg => unreachable!("avg axis rejected by public entry point"), - }; + let serialized = cost_return_on_error_no_add!( + cost, + build_aggregate_secondary_proof( + &secondary_merk, + axis, + lo, + hi, + fold, + grove_version, + &mut cost, + ) + ); Ok(IndexedAxisAggregateProof { axis_tag: axis.tag(), @@ -1198,6 +1150,7 @@ impl GroveDb { secondary_proof: serialized, lo, hi, + fold_tag: fold.tag(), }) .wrap_with_cost(cost) } @@ -1362,45 +1315,24 @@ impl GroveDb { sec_result.proof } } - AxisTraversal::RangeAggregate { lo, hi } => match axis { - IndexAxis::Count => { - // classify() rejects wholly-out-of-domain ranges, so - // clamping cannot collapse onto a boundary key here. - let lo_count = (*lo).clamp(0, u64::MAX as i128) as u64; - let hi_count = (*hi).clamp(0, u64::MAX as i128) as u64; - cost_return_on_error_no_add!( - cost, - build_count_aggregate_secondary_proof( - &secondary_merk, - lo_count, - hi_count, - grove_version, - &mut cost, - ) - ) - } - IndexAxis::Sum => { - let lo_sum = (*lo).clamp(i64::MIN as i128, i64::MAX as i128) as i64; - let hi_sum = (*hi).clamp(i64::MIN as i128, i64::MAX as i128) as i64; - cost_return_on_error_no_add!( - cost, - build_sum_aggregate_secondary_proof( - &secondary_merk, - lo_sum, - hi_sum, - grove_version, - &mut cost, - ) + AxisTraversal::AggregateOverValueRange { lo, hi, fold } => { + // classify() rejects wholly-out-of-domain ranges and the + // Avg axis before a descent is ever built; the builder + // still fails closed on both, plus on (Count, Total) + // until issue #806 lands. + cost_return_on_error_no_add!( + cost, + build_aggregate_secondary_proof( + &secondary_merk, + axis, + *lo, + *hi, + *fold, + grove_version, + &mut cost, ) - } - IndexAxis::Avg => { - return Err(Error::NotSupported( - "axis descent: range aggregates are not defined for the Avg axis" - .to_string(), - )) - .wrap_with_cost(cost); - } - }, + ) + } }; Ok(AxisDescentProof { @@ -1455,142 +1387,70 @@ where Ok(serialized) } -fn build_count_aggregate_secondary_proof<'db, S>( +/// The secondary-side aggregate proof for +/// [`AxisTraversal::AggregateOverValueRange`]: the byte range follows +/// the AXIS (its sort-key encoding, via the same +/// [`count_aggregate_inner_range`] / [`sum_aggregate_inner_range`] +/// reconstructors the verifier uses, so the two sides cannot drift on +/// clamping, degenerate, or out-of-domain shapes), and the walker +/// follows the FOLD ([`AggregateFold::Population`] proves the count +/// aggregate, [`AggregateFold::Total`] the sum aggregate). +/// +/// `(Count, Total)` is refused until the count secondary carries a sum +/// aggregate (issue #806): the walker would need +/// `prove_aggregate_sum_on_range` against a tree type that is not +/// sum-bearing. `Avg` is refused for both folds, mirroring +/// `AxisQuery::validate`. +fn build_aggregate_secondary_proof<'db, S>( secondary_merk: &grovedb_merk::Merk, - lo_count: u64, - hi_count: u64, + axis: IndexAxis, + lo: i128, + hi: i128, + fold: AggregateFold, grove_version: &GroveVersion, cost: &mut OperationCost, ) -> Result, Error> where S: grovedb_storage::StorageContext<'db>, { - if lo_count > hi_count { - // Degenerate; build a guaranteed-empty range so the merk still - // emits a proof that hashes to the actual secondary root. - let lo_bytes = hi_count.saturating_add(1).to_be_bytes().to_vec(); - let inner_range = MerkQueryItemForRange::Range(lo_bytes.clone()..lo_bytes); - let (ops, _) = secondary_merk - .prove_aggregate_count_on_range(&inner_range, grove_version) - .unwrap_add_cost(cost) - .map_err(|e| { - Error::CorruptedData(format!( - "indexed-axis count-aggregate degenerate-range proof: {e}" - )) - })?; - let mut serialized = Vec::with_capacity(128); - encode_into(ops.iter(), &mut serialized); - return Ok(serialized); - } - let lo_bytes = encode_count_sort_key(lo_count).to_vec(); - let inner_range = if hi_count == u64::MAX { - MerkQueryItemForRange::RangeFrom(lo_bytes..) - } else { - let upper_bytes = encode_count_sort_key(hi_count + 1).to_vec(); - MerkQueryItemForRange::Range(lo_bytes..upper_bytes) + let inner_range = match axis { + IndexAxis::Count => { + if fold == AggregateFold::Total { + return Err(Error::NotSupported( + "a TOTAL over the count axis needs a sum-bearing count secondary; \ + tracked in issue #806" + .to_string(), + )); + } + count_aggregate_inner_range(lo, hi) + } + IndexAxis::Sum => sum_aggregate_inner_range(lo, hi), + IndexAxis::Avg => { + return Err(Error::NotSupported( + "value-range aggregates are not defined for the Avg axis".to_string(), + )); + } }; - let (ops, _) = secondary_merk - .prove_aggregate_count_on_range(&inner_range, grove_version) - .unwrap_add_cost(cost) - .map_err(|e| { - Error::CorruptedData(format!("indexed-axis count-aggregate range proof: {e}")) - })?; - let mut serialized = Vec::with_capacity(128); - encode_into(ops.iter(), &mut serialized); - Ok(serialized) -} - -fn build_empty_count_aggregate_proof<'db, S>( - secondary_merk: &grovedb_merk::Merk, - grove_version: &GroveVersion, - cost: &mut OperationCost, -) -> Result, Error> -where - S: grovedb_storage::StorageContext<'db>, -{ - // Empty range = "count = 0", emitted as a guaranteed-empty range - // so the secondary root is still committed. - let bytes = u64::MAX.to_be_bytes().to_vec(); - let inner_range = MerkQueryItemForRange::Range(bytes.clone()..bytes); - let (ops, _) = secondary_merk - .prove_aggregate_count_on_range(&inner_range, grove_version) - .unwrap_add_cost(cost) - .map_err(|e| { - Error::CorruptedData(format!( - "indexed-axis count-aggregate empty-range proof: {e}" - )) - })?; - let mut serialized = Vec::with_capacity(128); - encode_into(ops.iter(), &mut serialized); - Ok(serialized) -} - -fn build_sum_aggregate_secondary_proof<'db, S>( - secondary_merk: &grovedb_merk::Merk, - lo_sum: i64, - hi_sum: i64, - grove_version: &GroveVersion, - cost: &mut OperationCost, -) -> Result, Error> -where - S: grovedb_storage::StorageContext<'db>, -{ - if lo_sum > hi_sum { - // Degenerate: emit an empty-range proof against the secondary. - let bytes = encode_sum_sort_key(hi_sum.saturating_add(1)).to_vec(); - let inner_range = MerkQueryItemForRange::Range(bytes.clone()..bytes); - let (ops, _) = secondary_merk - .prove_aggregate_sum_on_range(&inner_range, grove_version) - .unwrap_add_cost(cost) - .map_err(|e| { - Error::CorruptedData(format!( - "indexed-axis sum-aggregate degenerate-range proof: {e}" - )) - })?; - let mut serialized = Vec::with_capacity(128); - encode_into(ops.iter(), &mut serialized); - return Ok(serialized); - } - let lo_bytes = encode_sum_sort_key(lo_sum).to_vec(); - let inner_range = if hi_sum == i64::MAX { - MerkQueryItemForRange::RangeFrom(lo_bytes..) - } else { - let upper_bytes = encode_sum_sort_key(hi_sum + 1).to_vec(); - MerkQueryItemForRange::Range(lo_bytes..upper_bytes) + let ops = match fold { + AggregateFold::Population => { + secondary_merk + .prove_aggregate_count_on_range(&inner_range, grove_version) + .unwrap_add_cost(cost) + .map_err(|e| { + Error::CorruptedData(format!("indexed-axis population-over-range proof: {e}")) + })? + .0 + } + AggregateFold::Total => { + secondary_merk + .prove_aggregate_sum_on_range(&inner_range, grove_version) + .unwrap_add_cost(cost) + .map_err(|e| { + Error::CorruptedData(format!("indexed-axis total-over-range proof: {e}")) + })? + .0 + } }; - let (ops, _) = secondary_merk - .prove_aggregate_sum_on_range(&inner_range, grove_version) - .unwrap_add_cost(cost) - .map_err(|e| { - Error::CorruptedData(format!("indexed-axis sum-aggregate range proof: {e}")) - })?; - let mut serialized = Vec::with_capacity(128); - encode_into(ops.iter(), &mut serialized); - Ok(serialized) -} - -/// Canonical empty (sum = 0) aggregate proof for the sum axis. Emits a -/// guaranteed-empty range `[encode(i64::MAX) .. encode(i64::MAX))` so -/// the secondary root is still committed. Mirrors -/// [`build_empty_count_aggregate_proof`] for the sum axis; the verifier -/// reconstructs the identical range in [`sum_aggregate_inner_range`]'s -/// out-of-domain branch. -fn build_empty_sum_aggregate_proof<'db, S>( - secondary_merk: &grovedb_merk::Merk, - grove_version: &GroveVersion, - cost: &mut OperationCost, -) -> Result, Error> -where - S: grovedb_storage::StorageContext<'db>, -{ - let bytes = encode_sum_sort_key(i64::MAX).to_vec(); - let inner_range = MerkQueryItemForRange::Range(bytes.clone()..bytes); - let (ops, _) = secondary_merk - .prove_aggregate_sum_on_range(&inner_range, grove_version) - .unwrap_add_cost(cost) - .map_err(|e| { - Error::CorruptedData(format!("indexed-axis sum-aggregate empty-range proof: {e}")) - })?; let mut serialized = Vec::with_capacity(128); encode_into(ops.iter(), &mut serialized); Ok(serialized) diff --git a/grovedb/src/operations/proof/indexed_axis/verify.rs b/grovedb/src/operations/proof/indexed_axis/verify.rs index 07439a1c5..93a01f542 100644 --- a/grovedb/src/operations/proof/indexed_axis/verify.rs +++ b/grovedb/src/operations/proof/indexed_axis/verify.rs @@ -21,7 +21,7 @@ use grovedb_merk::{ }, tree::{axes_digest, combine_hash, combine_hash_three, value_hash, CryptoHash}, }; -use grovedb_query::QueryItem as MerkQueryItem; +use grovedb_query::{AggregateFold, QueryItem as MerkQueryItem}; use grovedb_version::{check_grovedb_v0, version::GroveVersion}; use crate::{Error, GroveDb}; @@ -514,16 +514,17 @@ impl GroveDb { } /// Verify an `IndexedAxisAggregateProof`-shaped aggregate proof. - pub fn verify_indexed_axis_range_aggregate( + pub fn verify_indexed_axis_aggregate_over_value_range( proof_bytes: &[u8], path: &[&[u8]], expected_axis: IndexAxis, expected_lo: i128, expected_hi: i128, + expected_fold: AggregateFold, grove_version: &GroveVersion, ) -> Result { check_grovedb_v0!( - "verify_indexed_axis_range_aggregate", + "verify_indexed_axis_aggregate_over_value_range", grove_version .grovedb_versions .operations @@ -550,6 +551,15 @@ impl GroveDb { "indexed-axis aggregate proofs are not defined for the Avg axis".to_string(), )); } + if envelope.fold_tag != expected_fold.tag() { + return Err(Error::CorruptedData(format!( + "indexed-axis aggregate proof fold mismatch: expected {expected_fold} \ + (tag={}), envelope carries tag={} — a population proof cannot answer a \ + question about a total, or vice versa", + expected_fold.tag(), + envelope.fold_tag + ))); + } if envelope.lo != expected_lo { return Err(Error::CorruptedData(format!( "indexed-axis aggregate proof lo mismatch: expected {}, envelope carries {}", @@ -562,7 +572,7 @@ impl GroveDb { expected_hi, envelope.hi ))); } - verify_indexed_axis_aggregate_inner(envelope, expected_axis, path) + verify_indexed_axis_aggregate_inner(envelope, expected_axis, expected_fold, path) } } @@ -725,6 +735,7 @@ fn verify_indexed_axis_paginated_inner( fn verify_indexed_axis_aggregate_inner( envelope: IndexedAxisAggregateProof, axis: IndexAxis, + fold: AggregateFold, path: &[&[u8]], ) -> Result { if envelope.layer_proofs.len() != path.len() { @@ -740,38 +751,53 @@ fn verify_indexed_axis_aggregate_inner( )); } - let (secondary_root_hash, aggregate_value) = match axis { + // The byte range follows the AXIS; the walker follows the FOLD — + // the same split the prover's `build_aggregate_secondary_proof` + // makes, sharing these exact range reconstructors so the two sides + // cannot drift on clamping, degenerate, or out-of-domain shapes. + let inner_range = match axis { IndexAxis::Count => { - let inner_range = count_aggregate_inner_range(envelope.lo, envelope.hi); + if fold == AggregateFold::Total { + return Err(Error::NotSupported( + "a TOTAL over the count axis needs a sum-bearing count secondary; \ + tracked in issue #806" + .to_string(), + )); + } + count_aggregate_inner_range(envelope.lo, envelope.hi) + } + IndexAxis::Sum => sum_aggregate_inner_range(envelope.lo, envelope.hi), + IndexAxis::Avg => { + return Err(Error::NotSupported( + "indexed-axis aggregate proofs are not defined for the Avg axis".to_string(), + )); + } + }; + let (secondary_root_hash, aggregate_value) = match fold { + AggregateFold::Population => { let (root, count) = verify_aggregate_count_on_range_proof(&envelope.secondary_proof, &inner_range) .unwrap() .map_err(|e| { Error::CorruptedData(format!( - "indexed-axis aggregate proof: secondary aggregate-count proof \ + "indexed-axis aggregate proof: secondary population proof \ failed to verify: {e}" )) })?; (root, count as i128) } - IndexAxis::Sum => { - let inner_range = sum_aggregate_inner_range(envelope.lo, envelope.hi); + AggregateFold::Total => { let (root, sum) = verify_aggregate_sum_on_range_proof(&envelope.secondary_proof, &inner_range) .unwrap() .map_err(|e| { Error::CorruptedData(format!( - "indexed-axis aggregate proof: secondary aggregate-sum proof failed \ + "indexed-axis aggregate proof: secondary total proof failed \ to verify: {e}" )) })?; (root, sum as i128) } - IndexAxis::Avg => { - return Err(Error::NotSupported( - "indexed-axis aggregate proofs are not defined for the Avg axis".to_string(), - )); - } }; let initial_root = verify_deepest_layer( diff --git a/grovedb/src/operations/proof/mod.rs b/grovedb/src/operations/proof/mod.rs index 6f4a4e1cd..0f3aeec78 100644 --- a/grovedb/src/operations/proof/mod.rs +++ b/grovedb/src/operations/proof/mod.rs @@ -400,7 +400,7 @@ pub struct AxisDescentProof { /// The secondary-Merk proof for the query's traversal: a /// count-offset paginated proof for `TopK` / `RankOfKey`, a plain /// Merk range proof for `Bounded`, an aggregate-on-range proof for - /// `RangeAggregate`. + /// `AggregateOverValueRange`. pub secondary_proof: Vec, } diff --git a/grovedb/src/operations/proof/verify.rs b/grovedb/src/operations/proof/verify.rs index 2e0e679f3..07fdfe4f2 100644 --- a/grovedb/src/operations/proof/verify.rs +++ b/grovedb/src/operations/proof/verify.rs @@ -56,7 +56,7 @@ pub(crate) enum AxisWalkResult { }, /// `RankOfKey`: the attested 0-based rank of the queried key. Rank { rank: u64 }, - /// `RangeAggregate`: the attested aggregate over the value range. + /// `AggregateOverValueRange`: the attested aggregate over the value range. Aggregate { value: i128 }, /// Sum-budget window: the matched `(key, value)` pairs, their net /// total, and the replay-attested stop condition. @@ -1014,48 +1014,71 @@ impl GroveDb { ) } } - AxisTraversal::RangeAggregate { lo, hi } => match axis { - grovedb_merk::proofs::query::IndexAxis::Count => { - let inner_range = count_aggregate_inner_range(*lo, *hi); - let (root, count) = verify_aggregate_count_on_range_proof( - &payload.secondary_proof, - &inner_range, - ) - .unwrap() - .map_err(|e| { - Error::InvalidProof( + AxisTraversal::AggregateOverValueRange { lo, hi, fold } => { + // The byte range follows the AXIS; the walker follows + // the FOLD — the same split the prover makes, through + // the same range reconstructors, so the two sides + // cannot drift on clamping, degenerate, or + // out-of-domain shapes. + let inner_range = match axis { + grovedb_merk::proofs::query::IndexAxis::Count => { + if *fold == grovedb_merk::proofs::query::AggregateFold::Total { + return Err(Error::NotSupported( + "a TOTAL over the count axis needs a sum-bearing count \ + secondary; tracked in issue #806" + .to_string(), + )); + } + count_aggregate_inner_range(*lo, *hi) + } + grovedb_merk::proofs::query::IndexAxis::Sum => { + sum_aggregate_inner_range(*lo, *hi) + } + grovedb_merk::proofs::query::IndexAxis::Avg => { + return Err(Error::InvalidProof( query.clone(), - format!("axis descent: aggregate-count proof failed: {e}"), + "axis descent: value-range aggregates are not defined for the \ + Avg axis" + .to_string(), + )); + } + }; + match fold { + grovedb_merk::proofs::query::AggregateFold::Population => { + let (root, count) = verify_aggregate_count_on_range_proof( + &payload.secondary_proof, + &inner_range, ) - })?; - ( - root, - AxisWalkResult::Aggregate { - value: count as i128, - }, - ) - } - grovedb_merk::proofs::query::IndexAxis::Sum => { - let inner_range = sum_aggregate_inner_range(*lo, *hi); - let (root, sum) = - verify_aggregate_sum_on_range_proof(&payload.secondary_proof, &inner_range) - .unwrap() - .map_err(|e| { - Error::InvalidProof( - query.clone(), - format!("axis descent: aggregate-sum proof failed: {e}"), - ) - })?; - (root, AxisWalkResult::Aggregate { value: sum as i128 }) - } - grovedb_merk::proofs::query::IndexAxis::Avg => { - return Err(Error::InvalidProof( - query.clone(), - "axis descent: range aggregates are not defined for the Avg axis" - .to_string(), - )); + .unwrap() + .map_err(|e| { + Error::InvalidProof( + query.clone(), + format!("axis descent: population proof failed: {e}"), + ) + })?; + ( + root, + AxisWalkResult::Aggregate { + value: count as i128, + }, + ) + } + grovedb_merk::proofs::query::AggregateFold::Total => { + let (root, sum) = verify_aggregate_sum_on_range_proof( + &payload.secondary_proof, + &inner_range, + ) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + query.clone(), + format!("axis descent: total proof failed: {e}"), + ) + })?; + (root, AxisWalkResult::Aggregate { value: sum as i128 }) + } } - }, + } }; // 2. Family check + third-input digest, with the RECOMPUTED diff --git a/grovedb/src/operations/proof/verify_path_query.rs b/grovedb/src/operations/proof/verify_path_query.rs index d33839d82..75329a29a 100644 --- a/grovedb/src/operations/proof/verify_path_query.rs +++ b/grovedb/src/operations/proof/verify_path_query.rs @@ -106,18 +106,21 @@ pub enum VerifiedPathQuery { /// The attested rank. rank: u64, }, - /// `RangeAggregate`: the attested aggregate over the entries the + /// `AggregateOverValueRange`: the attested aggregate over the entries the /// value range selected. AxisAggregate { /// Reconstructed GroveDB root hash. root_hash: CryptoHash, /// The attested aggregate, whose meaning follows the queried - /// axis: on the **sum** axis the signed TOTAL of the selected - /// entries' sums; on the **count** axis HOW MANY entries the - /// range selected (`>= 0`, each entry contributing 1) — a - /// bucket population, not the total of their counts. The query - /// names the axis, so the caller already knows which reading - /// applies. + /// FOLD, not the axis: + /// + /// * `AggregateFold::Population` — HOW MANY entries the value + /// range selected (`>= 0`, each entry contributing 1). + /// * `AggregateFold::Total` — the signed sum of the selected + /// entries' axis values. + /// + /// The query carries the fold, so the caller already knows + /// which reading applies. value: i128, }, /// Sum-budget read: the proved window's matched sum items, their @@ -489,9 +492,10 @@ impl GroveDb { (AxisWalkResult::Rank { rank }, AxisTraversal::RankOfKey { .. }) => { Ok(VerifiedPathQuery::AxisRank { root_hash, rank }) } - (AxisWalkResult::Aggregate { value }, AxisTraversal::RangeAggregate { .. }) => { - Ok(VerifiedPathQuery::AxisAggregate { root_hash, value }) - } + ( + AxisWalkResult::Aggregate { value }, + AxisTraversal::AggregateOverValueRange { .. }, + ) => Ok(VerifiedPathQuery::AxisAggregate { root_hash, value }), _ => Err(Error::InvalidProof( path_query.clone(), "the verified axis outcome does not match the query's traversal".to_string(), diff --git a/grovedb/src/query/axis_lowering.rs b/grovedb/src/query/axis_lowering.rs index 3942c1e63..c6a0e1b91 100644 --- a/grovedb/src/query/axis_lowering.rs +++ b/grovedb/src/query/axis_lowering.rs @@ -76,6 +76,7 @@ pub(crate) fn axis_bounded_merk_query(axis_query: &AxisQuery) -> Result>, axis: IndexAxis, lo: i128, hi: i128, + fold: AggregateFold, ) -> Self { Self::new_unsized( path, - Self::axis_read_node(AxisQuery::range_aggregate(axis, lo, hi)), + Self::axis_read_node(AxisQuery::aggregate_over_value_range(axis, lo, hi, fold)), ) } @@ -1652,6 +1657,7 @@ impl<'a> SinglePathSubquery<'a> { #[cfg(feature = "minimal")] #[cfg(test)] mod tests { + use grovedb_merk::proofs::query::AggregateFold; use std::{borrow::Cow, ops::RangeFull}; use bincode::{config::standard, decode_from_slice, encode_to_vec}; diff --git a/grovedb/src/query/shape.rs b/grovedb/src/query/shape.rs index cc1fcd10b..720409a2e 100644 --- a/grovedb/src/query/shape.rs +++ b/grovedb/src/query/shape.rs @@ -392,18 +392,19 @@ impl PathQuery { // A branched read answers with one entry list per // branch, so its terminal must be an // entry-listing traversal. Rank-of-key and - // range-aggregate produce a single scalar about + // aggregate-over-value-range produce a single scalar about // one tree and have no per-branch list to fill; // rejecting them here keeps the reader and the // verifier from having to treat "impossible" // shapes as internal errors. if matches!( axis.traversal, - AxisTraversal::RankOfKey { .. } | AxisTraversal::RangeAggregate { .. } + AxisTraversal::RankOfKey { .. } + | AxisTraversal::AggregateOverValueRange { .. } ) { return Err(Error::InvalidQuery( "a branched axis read serves entry-listing traversals \ - (RankedPage / Bounded) only; rank-of-key and range-aggregate \ + (RankedPage / Bounded) only; rank-of-key and aggregate-over-value-range \ are single-path reads", )); } @@ -448,6 +449,7 @@ pub(crate) fn read_mode_validation_error(e: grovedb_query::error::Error) -> Erro #[cfg(test)] mod tests { + use grovedb_merk::proofs::query::AggregateFold; use grovedb_merk::proofs::{query::query_item::QueryItem, Query}; use super::*; @@ -743,7 +745,13 @@ mod tests { PathQuery::new_axis_top_k(path(), IndexAxis::Count, 5, 0, true), PathQuery::new_axis_bounded(path(), IndexAxis::Sum, -10, 10, 3, false), PathQuery::new_axis_rank_of_key(path(), IndexAxis::Avg, b"alice".to_vec(), true), - PathQuery::new_axis_range_aggregate(path(), IndexAxis::Sum, 0, 100), + PathQuery::new_axis_aggregate_over_value_range( + path(), + IndexAxis::Sum, + 0, + 100, + AggregateFold::Total, + ), ]; for pq in queries { match pq.classify().expect("axis constructor must classify") { @@ -841,7 +849,13 @@ mod tests { PathQuery::new_unsized(path(), q) }), ("range aggregate on the Avg axis", { - PathQuery::new_axis_range_aggregate(path(), IndexAxis::Avg, 0, 10) + PathQuery::new_axis_aggregate_over_value_range( + path(), + IndexAxis::Avg, + 0, + 10, + AggregateFold::Population, + ) }), ("branched: range item selecting branches", { let mut q = Query::new_single_query_item(range_item()); diff --git a/grovedb/src/tests/aggregate_count_query_tests.rs b/grovedb/src/tests/aggregate_count_query_tests.rs index cf59d9e03..93087d4a2 100644 --- a/grovedb/src/tests/aggregate_count_query_tests.rs +++ b/grovedb/src/tests/aggregate_count_query_tests.rs @@ -2086,7 +2086,7 @@ mod tests { } #[test] - fn rejects_nested_carrier_range_range_aggregate_count() { + fn rejects_nested_carrier_aggregate_over_value_range_count() { // Out of scope: a "Range × Range × AggregateCountOnRange" // shape — an outer carrier whose subquery is *itself* another // carrier. This is the `IN × IN`-on-prefix case the spec diff --git a/grovedb/src/tests/axis_descent_proof_tests.rs b/grovedb/src/tests/axis_descent_proof_tests.rs index 49bd4d2ce..b7206514c 100644 --- a/grovedb/src/tests/axis_descent_proof_tests.rs +++ b/grovedb/src/tests/axis_descent_proof_tests.rs @@ -6,7 +6,7 @@ #[cfg(test)] mod tests { - use grovedb_merk::proofs::query::{AxisQuery, IndexAxis}; + use grovedb_merk::proofs::query::{AggregateFold, AxisQuery, IndexAxis}; use grovedb_version::version::{GroveVersion, GROVE_VERSIONS}; use crate::{ @@ -299,15 +299,21 @@ mod tests { } } - // Range aggregate over the sum axis. - let pq = PathQuery::new_axis_range_aggregate(psit_path(), IndexAxis::Sum, 0, 40); + // Aggregate over the value range over the sum axis. + let pq = PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Total, + ); match GroveDb::verify_path_query(&prove(&db, &pq, grove_version), &pq, grove_version) .expect("aggregate verifies") { VerifiedPathQuery::AxisAggregate { root_hash, value } => { assert_eq!(root_hash, root); let direct = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"psit"].as_ref(), 0, 40, @@ -658,8 +664,14 @@ mod tests { other => panic!("expected AxisEntries, got {other:?}"), } - // RangeAggregate. - let pq = PathQuery::new_axis_range_aggregate(psit_path(), IndexAxis::Sum, 0, 100); + // AggregateOverValueRange. + let pq = PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 100, + AggregateFold::Total, + ); match GroveDb::verify_path_query(&prove(&db, &pq, grove_version), &pq, grove_version) .expect("range aggregate over an empty secondary verifies") { @@ -849,16 +861,28 @@ mod tests { let root = root_hash(&db, grove_version); let path = [TEST_LEAF, b"pcit"]; - // Range aggregate on the COUNT axis: a different prover builder + // Aggregate over the value range on the COUNT axis: a different prover builder // and a different verifier decoder from the sum axis. - let pq = PathQuery::new_axis_range_aggregate(pcit_path(), IndexAxis::Count, 2, 10); + let pq = PathQuery::new_axis_aggregate_over_value_range( + pcit_path(), + IndexAxis::Count, + 2, + 10, + AggregateFold::Population, + ); match GroveDb::verify_path_query(&prove(&db, &pq, grove_version), &pq, grove_version) .expect("count range aggregate verifies") { VerifiedPathQuery::AxisAggregate { root_hash, value } => { assert_eq!(root_hash, root); let direct = db - .indexed_count_range_aggregate(path.as_ref(), 2, 10, None, grove_version) + .indexed_count_aggregate_over_value_range( + path.as_ref(), + 2, + 10, + None, + grove_version, + ) .unwrap() .expect("direct count range aggregate"); assert_eq!(value, direct as i128); @@ -1406,4 +1430,313 @@ mod tests { // read. assert_eq!(p(&[TEST_LEAF]), None); } + + // ----------------------------------------------------------------- + // The explicit fold (issue #806): the 2x2 (axis x fold) matrix + // ----------------------------------------------------------------- + + #[test] + fn sum_population_over_value_range_round_trips() { + // The band [0, 40] over PSIT_ENTRIES' sums [40, -10, 25, 40, 5] + // selects 40, 25, 40, 5: Population = 4, Total = 110. The two + // folds are different questions over the same band, and each + // must round-trip to its own answer. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + let root = root_hash(&db, grove_version); + + let population_pq = PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Population, + ); + match GroveDb::verify_path_query( + &prove(&db, &population_pq, grove_version), + &population_pq, + grove_version, + ) + .expect("sum-axis population verifies") + { + VerifiedPathQuery::AxisAggregate { root_hash, value } => { + assert_eq!(root_hash, root); + // alice and dave BOTH sit at 40: population counts + // entries, not distinct values — 4, not 3. + assert_eq!(value, 4); + // ...equal to the trusted read over the same state. + let direct = db + .indexed_sum_population_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + 0, + 40, + None, + grove_version, + ) + .unwrap() + .expect("trusted population read"); + assert_eq!(value, direct as i128); + } + other => panic!("expected AxisAggregate, got {other:?}"), + } + + // The Total fold over the same band answers 110, not 4. + let total_pq = PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Total, + ); + match GroveDb::verify_path_query( + &prove(&db, &total_pq, grove_version), + &total_pq, + grove_version, + ) + .expect("sum-axis total verifies") + { + VerifiedPathQuery::AxisAggregate { value, .. } => assert_eq!(value, 110), + other => panic!("expected AxisAggregate, got {other:?}"), + } + } + + #[test] + fn the_fold_lives_in_the_query_not_the_embedded_proof() { + // On a PCPS secondary both walkers commit the SAME dual + // (count, sum) node flavors — the node hash is + // `node_hash_with_count_and_sum`, so every subtree commitment + // carries both aggregates regardless of which fold the prover + // was asked for. The embedded payload therefore does not (and + // could not meaningfully) assert a fold; the query is the + // verifier's sole source of it, per the query-as-input + // principle every embedded shape follows. + // + // The security property to pin is NOT rejection — it is that + // cross-feeding a proof built for one fold to a query asking + // the other yields that other fold's CORRECT answer, recomputed + // from the hash-bound commitments under the genuine root. A + // prover cannot use fold confusion to make either question + // report a wrong number. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + let root = root_hash(&db, grove_version); + + let population_pq = PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Population, + ); + let total_pq = PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Total, + ); + let population_proof = prove(&db, &population_pq, grove_version); + let total_proof = prove(&db, &total_pq, grove_version); + + // Cross-fed in both directions, each query still gets its own + // correct answer, bound to the genuine root. + match GroveDb::verify_path_query(&population_proof, &total_pq, grove_version) + .expect("a dual-aggregate proof answers the total question too") + { + VerifiedPathQuery::AxisAggregate { root_hash, value } => { + assert_eq!(root_hash, root); + assert_eq!(value, 110, "the TOTAL, not the population"); + } + other => panic!("expected AxisAggregate, got {other:?}"), + } + match GroveDb::verify_path_query(&total_proof, &population_pq, grove_version) + .expect("a dual-aggregate proof answers the population question too") + { + VerifiedPathQuery::AxisAggregate { root_hash, value } => { + assert_eq!(root_hash, root); + assert_eq!(value, 4, "the POPULATION, not the total"); + } + other => panic!("expected AxisAggregate, got {other:?}"), + } + } + + #[test] + fn count_total_is_refused_by_name_until_the_secondary_is_sum_bearing() { + // (Count, Total) is the missing cell of the matrix until issue + // #806's secondary upgrade lands: every surface must refuse it + // by name, not silently answer the population instead. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_pcpsit(&db, grove_version, PSIT_ENTRIES); + let pcpsit_path = vec![TEST_LEAF.to_vec(), b"pcpsit".to_vec()]; + + let pq = PathQuery::new_axis_aggregate_over_value_range( + pcpsit_path, + IndexAxis::Count, + 0, + 10, + AggregateFold::Total, + ); + // The prover refuses... + match db.prove_query(&pq, None, grove_version).unwrap() { + Err(Error::NotSupported(message)) => { + assert!(message.contains("806"), "got: {message}") + } + other => panic!("count+Total proving must be refused, got {other:?}"), + } + // ...the trusted read refuses... + match db + .run_path_query( + &pq, + true, + true, + true, + crate::query_result_type::QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + { + Err(Error::NotSupported(message)) => { + assert!(message.contains("806"), "got: {message}") + } + other => panic!("count+Total reading must be refused, got {other:?}"), + } + // ...and the standalone family refuses on both sides. + db.prove_indexed_axis_aggregate_over_value_range( + [TEST_LEAF, b"pcpsit"].as_ref(), + IndexAxis::Count, + 0, + 10, + AggregateFold::Total, + None, + grove_version, + ) + .unwrap() + .expect_err("standalone count+Total proving must be refused"); + GroveDb::verify_indexed_axis_aggregate_over_value_range( + &[0u8; 4], + &[TEST_LEAF, b"pcpsit"], + IndexAxis::Count, + 0, + 10, + AggregateFold::Total, + grove_version, + ) + .expect_err("standalone count+Total verification must be refused"); + + // The refusal must also fire in the VERIFIERS' own arms, not + // just upstream of them. Embedded: a genuine count+Population + // proof against a count+Total query reaches the descent + // verifier's (Count, Total) arm — the query is where the fold + // lives, so this is the exact shape a confused (or hostile) + // client would produce. + let population_pq = PathQuery::new_axis_aggregate_over_value_range( + vec![TEST_LEAF.to_vec(), b"pcpsit".to_vec()], + IndexAxis::Count, + 0, + 10, + AggregateFold::Population, + ); + let population_proof = prove(&db, &population_pq, grove_version); + match GroveDb::verify_path_query(&population_proof, &pq, grove_version) { + Err(Error::NotSupported(message)) => { + assert!(message.contains("806"), "got: {message}") + } + other => panic!("the descent verifier must refuse count+Total, got {other:?}"), + } + + // Standalone: relabel a genuine count+Population envelope's + // fold echo to Total. The echo check passes (expected == echo), + // so the inner dispatch's (Count, Total) rejection is what must + // stop it. + let standalone = db + .prove_indexed_axis_aggregate_over_value_range( + [TEST_LEAF, b"pcpsit"].as_ref(), + IndexAxis::Count, + 0, + 10, + AggregateFold::Population, + None, + grove_version, + ) + .unwrap() + .expect("standalone count+Population prove"); + let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); + let (mut envelope, _): ( + crate::operations::proof::indexed_axis::IndexedAxisAggregateProof, + usize, + ) = bincode::decode_from_slice(&standalone, config).expect("decode envelope"); + envelope.fold_tag = AggregateFold::Total.tag(); + let relabeled = bincode::encode_to_vec(&envelope, config).expect("re-encode"); + match GroveDb::verify_indexed_axis_aggregate_over_value_range( + &relabeled, + &[TEST_LEAF, b"pcpsit"], + IndexAxis::Count, + 0, + 10, + AggregateFold::Total, + grove_version, + ) { + Err(Error::NotSupported(message)) => { + assert!(message.contains("806"), "got: {message}") + } + other => panic!( + "a relabeled count envelope must hit the inner count+Total refusal: {other:?}" + ), + } + } + + #[test] + fn a_forged_fold_echo_in_the_standalone_envelope_is_rejected() { + // The standalone envelope ECHOES the fold; the verifier must + // authenticate the echo against the caller's expected fold, so a + // relabeled envelope cannot pass one fold's proof off as the + // other's answer. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + + let bytes = db + .prove_indexed_axis_aggregate_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Population, + None, + grove_version, + ) + .unwrap() + .expect("standalone population prove"); + // Honest verification answers the population... + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( + &bytes, + &[TEST_LEAF, b"psit"], + IndexAxis::Sum, + 0, + 40, + AggregateFold::Population, + grove_version, + ) + .expect("honest fold verifies"); + assert_eq!(result.aggregate, 4); + // ...and the same bytes must not answer the Total question. + match GroveDb::verify_indexed_axis_aggregate_over_value_range( + &bytes, + &[TEST_LEAF, b"psit"], + IndexAxis::Sum, + 0, + 40, + AggregateFold::Total, + grove_version, + ) { + Err(Error::CorruptedData(message)) => { + assert!(message.contains("fold mismatch"), "got: {message}") + } + other => panic!("a fold-mismatched envelope must be rejected, got {other:?}"), + } + } } diff --git a/grovedb/src/tests/coverage_round7_tests.rs b/grovedb/src/tests/coverage_round7_tests.rs index 332a7ed25..21979ce56 100644 --- a/grovedb/src/tests/coverage_round7_tests.rs +++ b/grovedb/src/tests/coverage_round7_tests.rs @@ -10,6 +10,7 @@ #[cfg(test)] mod tests { use grovedb_element::indexed::IndexAxis; + use grovedb_merk::proofs::query::AggregateFold; use grovedb_merk::proofs::Query as MerkQuery; use grovedb_version::version::GroveVersion; @@ -591,10 +592,10 @@ mod tests { } /// Aggregate-variant rejection: same as above but via - /// `prove_indexed_axis_range_aggregate` (a different call site at + /// `prove_indexed_axis_aggregate_over_value_range` (a different call site at /// L1316-1321 in the build_indexed_axis_aggregate_proof). #[test] - fn prove_indexed_axis_range_aggregate_rejects_non_indexed_target() { + fn prove_indexed_axis_aggregate_over_value_range_rejects_non_indexed_target() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); db.insert( @@ -609,7 +610,7 @@ mod tests { .expect("create plain"); let path: &[&[u8]] = &[TEST_LEAF, b"plain"]; let result = db - .prove_indexed_count_range_aggregate(path, 0, 100, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 100, None, grove_version) .unwrap(); assert!(matches!(result, Err(Error::InvalidPath(_)))); } @@ -663,10 +664,10 @@ mod tests { } /// Avg axis aggregate rejection at the verify entry point (L1561). - /// `verify_indexed_axis_range_aggregate` rejects the Avg axis + /// `verify_indexed_axis_aggregate_over_value_range` rejects the Avg axis /// because there is no aggregate-avg primitive. #[test] - fn verify_indexed_axis_range_aggregate_rejects_avg_axis() { + fn verify_indexed_axis_aggregate_over_value_range_rejects_avg_axis() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); populate_simple_pcpsit( @@ -677,20 +678,21 @@ mod tests { grove_version, ); // Build a count-axis aggregate proof, then call the verify with - // axis=Avg via the top-level `verify_indexed_axis_range_aggregate`. + // axis=Avg via the top-level `verify_indexed_axis_aggregate_over_value_range`. let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; let proof_bytes = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); - // verify_indexed_axis_range_aggregate with Avg must reject + // verify_indexed_axis_aggregate_over_value_range with Avg must reject // before doing any envelope arithmetic. - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof_bytes, path, IndexAxis::Avg, 0, 10, + AggregateFold::Population, grove_version, ); // Either axis-mismatch (envelope tag=count, expected=avg) or @@ -732,12 +734,17 @@ mod tests { populate_simple_pcit(&db, b"cidx", &[(b"a", 3)], grove_version); let path: &[&[u8]] = &[TEST_LEAF, b"cidx"]; let proof_bytes = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); // Expected hi=99 but envelope carries 10. - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof_bytes, path, 0, 99, grove_version); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof_bytes, + path, + 0, + 99, + grove_version, + ); assert!( matches!(&result, Err(Error::CorruptedData(msg)) if msg.contains("hi")), "expected hi mismatch, got {:?}", @@ -847,11 +854,12 @@ mod tests { secondary_proof: vec![], lo: 0, hi: 10, + fold_tag: AggregateFold::Population.tag(), }; let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); let bytes = bincode::encode_to_vec(&envelope, config).expect("encode"); let path: &[&[u8]] = &[]; - let result = GroveDb::verify_indexed_count_range_aggregate( + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( &bytes, path, 0, @@ -926,7 +934,7 @@ mod tests { populate_simple_pcit(&db, b"cidx", &[(b"a", 1)], grove_version); let path: &[&[u8]] = &[TEST_LEAF, b"cidx"]; let proof_bytes = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); @@ -934,8 +942,13 @@ mod tests { bincode::decode_from_slice(&proof_bytes, config).expect("decode"); envelope.layer_proofs.push(vec![0u8; 16]); let tampered = bincode::encode_to_vec(&envelope, config).expect("re-encode"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&tampered, path, 0, 10, grove_version); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &tampered, + path, + 0, + 10, + grove_version, + ); assert!( matches!(&result, Err(Error::CorruptedData(msg)) if msg.contains("layers")), "expected layer-count mismatch, got {:?}", @@ -944,25 +957,26 @@ mod tests { } /// Aggregate axis envelope axis-mismatch (envelope=count vs - /// expected=sum). Goes through `verify_indexed_axis_range_aggregate` + /// expected=sum). Goes through `verify_indexed_axis_aggregate_over_value_range` /// axis-mismatch path at L1551-1558. #[test] - fn verify_indexed_axis_range_aggregate_axis_mismatch_rejected() { + fn verify_indexed_axis_aggregate_over_value_range_axis_mismatch_rejected() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); populate_simple_pcit(&db, b"cidx", &[(b"a", 1)], grove_version); let path: &[&[u8]] = &[TEST_LEAF, b"cidx"]; let proof_bytes = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); // Call with axis=Sum on a count-axis envelope. - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof_bytes, path, IndexAxis::Sum, 0, 10, + AggregateFold::Total, grove_version, ); assert!( @@ -1093,10 +1107,10 @@ mod tests { populate_simple_pcit(&db, b"cidx", &[(b"a", 1)], grove_version); let path: &[&[u8]] = &[TEST_LEAF, b"cidx"]; let proof_bytes = db - .prove_indexed_count_range_aggregate(path, 5, 20, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 5, 20, None, grove_version) .unwrap() .expect("prove"); - let result = GroveDb::verify_indexed_count_range_aggregate( + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( &proof_bytes, path, 99, @@ -1269,7 +1283,7 @@ mod tests { #[test] fn verify_indexed_axis_aggregate_rejects_truncated_buffer() { let path: &[&[u8]] = &[TEST_LEAF, b"x"]; - let result = GroveDb::verify_indexed_count_range_aggregate( + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( &[0u8; 4], path, 0, @@ -1386,7 +1400,7 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; let proof_bytes = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); @@ -1394,8 +1408,13 @@ mod tests { bincode::decode_from_slice(&proof_bytes, config).expect("decode"); envelope.primary_root_hash[10] ^= 0x55; let tampered = bincode::encode_to_vec(&envelope, config).expect("re-encode"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&tampered, path, 0, 10, grove_version); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &tampered, + path, + 0, + 10, + grove_version, + ); assert!(matches!(result, Err(Error::CorruptedData(_)))); } @@ -2116,12 +2135,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 1, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 1, 10, None, grove_version) .unwrap() .expect("prove count agg"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 1, 10, grove_version) - .expect("verify count agg"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 1, + 10, + grove_version, + ) + .expect("verify count agg"); // Each entry contributes count=1, so range [1,10] returns 3. assert_eq!(result.aggregate, 3); assert_eq!(result.axis, IndexAxis::Count); @@ -2141,12 +2165,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, -100, 100, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, -100, 100, None, grove_version) .unwrap() .expect("prove sum agg"); - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, -100, 100, grove_version) - .expect("verify sum agg"); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + -100, + 100, + grove_version, + ) + .expect("verify sum agg"); // Sum = 5 + 10 - 3 = 12. assert_eq!(result.aggregate, 12); assert_eq!(result.axis, IndexAxis::Sum); diff --git a/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs b/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs index e9c6b0d76..090fb754f 100644 --- a/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs +++ b/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs @@ -16,6 +16,7 @@ #[cfg(test)] mod tests { use grovedb_element::indexed::IndexAxis; + use grovedb_merk::proofs::query::AggregateFold; use grovedb_version::version::GroveVersion; use crate::{ @@ -463,15 +464,24 @@ mod tests { let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Count, -5, 10, None, gv) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Count, + -5, + 10, + AggregateFold::Population, + None, + gv, + ) .unwrap() .expect("prove with a below-domain lower bound"); - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof, path, IndexAxis::Count, -5, 10, + AggregateFold::Population, GroveVersion::latest(), ) .expect("verify with the same bounds"); @@ -487,16 +497,25 @@ mod tests { // Identical to the in-domain request it clamps to. let clamped = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Count, 0, 10, None, gv) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Count, + 0, + 10, + AggregateFold::Population, + None, + gv, + ) .unwrap() .expect("prove [0, 10]"); assert_eq!( - GroveDb::verify_indexed_axis_range_aggregate( + GroveDb::verify_indexed_axis_aggregate_over_value_range( &clamped, path, IndexAxis::Count, 0, 10, + AggregateFold::Population, GroveVersion::latest() ) .expect("verify [0, 10]") @@ -508,16 +527,25 @@ mod tests { // commit zero rather than clamp onto the boundary and count the rows // sitting there. let below = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Count, -20, -1, None, gv) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Count, + -20, + -1, + AggregateFold::Population, + None, + gv, + ) .unwrap() .expect("prove a wholly out-of-domain range"); assert_eq!( - GroveDb::verify_indexed_axis_range_aggregate( + GroveDb::verify_indexed_axis_aggregate_over_value_range( &below, path, IndexAxis::Count, -20, -1, + AggregateFold::Population, GroveVersion::latest() ) .expect("verify") @@ -682,7 +710,15 @@ mod tests { // The prover refuses up front. let prove_err = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Avg, 0, 10, None, gv) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Avg, + 0, + 10, + AggregateFold::Population, + None, + gv, + ) .unwrap() .expect_err("no avg aggregate proof exists"); assert!( @@ -694,7 +730,15 @@ mod tests { // Relabel a count aggregate envelope so the axis-tag echo check passes // and the verifier reaches its own axis-support rule. let proof = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Count, 0, 10, None, gv) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Count, + 0, + 10, + AggregateFold::Population, + None, + gv, + ) .unwrap() .expect("prove count aggregate"); let config = bincode::config::standard(); @@ -703,12 +747,13 @@ mod tests { envelope.axis_tag = IndexAxis::Avg.tag(); let forged = bincode::encode_to_vec(&envelope, config).expect("re-encode"); - let err = GroveDb::verify_indexed_axis_range_aggregate( + let err = GroveDb::verify_indexed_axis_aggregate_over_value_range( &forged, path, IndexAxis::Avg, 0, 10, + AggregateFold::Population, GroveVersion::latest(), ) .expect_err("an avg-tagged aggregate must be refused"); diff --git a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs index 7c8d5a33c..e81988bdf 100644 --- a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs +++ b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs @@ -1029,10 +1029,10 @@ mod tests { ); let mut aggregate = db - .prove_indexed_sum_range_aggregate(path, 0, 100, None, gv) + .prove_indexed_sum_aggregate_over_value_range(path, 0, 100, None, gv) .unwrap() .expect("prove aggregate"); - GroveDb::verify_indexed_sum_range_aggregate( + GroveDb::verify_indexed_sum_aggregate_over_value_range( &aggregate, path, 0, @@ -1042,7 +1042,7 @@ mod tests { .expect("clean aggregate verifies"); aggregate.push(0); assert!( - GroveDb::verify_indexed_sum_range_aggregate( + GroveDb::verify_indexed_sum_aggregate_over_value_range( &aggregate, path, 0, diff --git a/grovedb/src/tests/indexed_axis_proof_tests.rs b/grovedb/src/tests/indexed_axis_proof_tests.rs index d2fa45717..cfbbed8c0 100644 --- a/grovedb/src/tests/indexed_axis_proof_tests.rs +++ b/grovedb/src/tests/indexed_axis_proof_tests.rs @@ -14,6 +14,7 @@ #[cfg(test)] mod tests { use grovedb_element::indexed::IndexAxis; + use grovedb_merk::proofs::query::AggregateFold; use grovedb_merk::proofs::{query::QueryItem as MerkQueryItem, Query as MerkQuery}; use grovedb_version::version::GroveVersion; @@ -233,12 +234,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 5, 15, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 5, 15, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 5, 15, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 5, + 15, + grove_version, + ) + .expect("verify"); // b(5) + c(10) — both in [5,15]; a(1) outside, d(20) outside. assert_eq!(result.aggregate, 2); assert_eq!(result.axis, IndexAxis::Count); @@ -376,12 +382,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, 0, 25, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, 0, 25, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, 0, 25, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + 0, + 25, + grove_version, + ) + .expect("verify"); // In [0,25]: b(5) + c(20) = 25. assert_eq!(result.aggregate, 25); assert_eq!(result.axis, IndexAxis::Sum); @@ -526,12 +537,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 1, 1, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 1, 1, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 1, 1, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 1, + 1, + grove_version, + ) + .expect("verify"); // All 3 entries have count_value=1, so [1,1] captures all 3. assert_eq!(result.aggregate, 3); } @@ -548,12 +564,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, 0, 25, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, 0, 25, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, 0, 25, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + 0, + 25, + grove_version, + ) + .expect("verify"); // In [0,25]: b(10) + c(20) = 30. assert_eq!(result.aggregate, 30); } @@ -697,11 +718,12 @@ mod tests { let db = make_test_grovedb(grove_version); build_pcpsit(&db, grove_version, &[IndexAxis::Avg.tag()], &[(b"a", 1)]); let result = db - .prove_indexed_axis_range_aggregate( + .prove_indexed_axis_aggregate_over_value_range( [TEST_LEAF, b"pcpsit"].as_ref(), IndexAxis::Avg, 0, 100, + AggregateFold::Population, None, grove_version, ) @@ -737,13 +759,18 @@ mod tests { build_psit(&db, grove_version, &[(b"a", 1), (b"b", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let mut proof = db - .prove_indexed_sum_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); let i = proof.len() / 2; proof[i] ^= 0xFF; - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, 0, 10, grove_version); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + 0, + 10, + grove_version, + ); assert!(result.is_err(), "tampered proof should not verify"); } @@ -872,12 +899,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 10, 5, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 10, 5, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 10, 5, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 10, + 5, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 0); } @@ -888,12 +920,17 @@ mod tests { build_psit(&db, grove_version, &[(b"a", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, 10, 5, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, 10, 5, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, 10, 5, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + 10, + 5, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 0); } @@ -1060,14 +1097,19 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let mut proof = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); // Tamper at multiple sites: front and back. let i = proof.len() - 4; proof[i] ^= 0xFF; - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, 10, grove_version); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + 10, + grove_version, + ); assert!( result.is_err(), "tampered count aggregate proof should not verify" @@ -1081,12 +1123,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); // Wrong expected lo on verify. - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 1, 10, grove_version); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 1, + 10, + grove_version, + ); assert!(result.is_err(), "lo mismatch should be rejected"); } @@ -1097,11 +1144,16 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, 11, grove_version); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + 11, + grove_version, + ); assert!(result.is_err(), "hi mismatch should be rejected"); } @@ -1114,18 +1166,19 @@ mod tests { build_psit(&db, grove_version, &[(b"a", 1)]); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); // Tamper the envelope to claim axis=Avg by reading and forging - // bytes is brittle; instead just call verify_indexed_axis_range_aggregate + // bytes is brittle; instead just call verify_indexed_axis_aggregate_over_value_range // expecting axis=Avg — the axis-tag mismatch fires first. - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof, path, IndexAxis::Avg, 0, 10, + AggregateFold::Population, grove_version, ); assert!(matches!(result, Err(Error::CorruptedData(_)))); @@ -1241,7 +1294,7 @@ mod tests { GroveVersion::latest(), ); assert!(matches!(r2, Err(Error::CorruptedData(_)))); - let r3 = GroveDb::verify_indexed_count_range_aggregate( + let r3 = GroveDb::verify_indexed_count_aggregate_over_value_range( &garbage, path, 0, @@ -1351,10 +1404,16 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 1000), (b"c", 100)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 100, u64::MAX, None, grove_version) + .prove_indexed_count_aggregate_over_value_range( + path, + 100, + u64::MAX, + None, + grove_version, + ) .unwrap() .expect("prove"); - let result = GroveDb::verify_indexed_count_range_aggregate( + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( &proof, path, 100, @@ -1373,12 +1432,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 2), (b"c", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, u64::MAX, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, u64::MAX, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, u64::MAX, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + u64::MAX, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 3); } @@ -1389,12 +1453,17 @@ mod tests { build_pcit(&db, grove_version, &[]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, 100, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 100, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, 100, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + 100, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 0); } @@ -1405,12 +1474,17 @@ mod tests { build_psit(&db, grove_version, &[(b"a", i64::MAX), (b"b", 0)]); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, 1, i64::MAX, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, 1, i64::MAX, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, 1, i64::MAX, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + 1, + i64::MAX, + grove_version, + ) + .expect("verify"); // Only a(i64::MAX) is in [1, i64::MAX]. assert_eq!(result.aggregate, i64::MAX as i128); } @@ -1426,12 +1500,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, -100, -1, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, -100, -1, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_sum_range_aggregate(&proof, path, -100, -1, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + path, + -100, + -1, + grove_version, + ) + .expect("verify"); // -10 + -5 = -15 assert_eq!(result.aggregate, -15); } @@ -1444,22 +1523,24 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_axis_range_aggregate( + .prove_indexed_axis_aggregate_over_value_range( path, IndexAxis::Count, -50, -10, + AggregateFold::Population, None, grove_version, ) .unwrap() .expect("prove"); - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof, path, IndexAxis::Count, -50, -10, + AggregateFold::Population, grove_version, ) .expect("verify"); @@ -1661,7 +1742,7 @@ mod tests { let db = make_test_grovedb(grove_version); let empty: &[&[u8]] = &[]; let result = db - .prove_indexed_count_range_aggregate(empty, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(empty, 0, 10, None, grove_version) .unwrap(); assert!(matches!(result, Err(Error::InvalidPath(_)))); } @@ -1708,7 +1789,7 @@ mod tests { .unwrap() .expect("create plain tree"); let result = db - .prove_indexed_count_range_aggregate( + .prove_indexed_count_aggregate_over_value_range( [TEST_LEAF, b"plain"].as_ref(), 0, 10, @@ -1888,16 +1969,17 @@ mod tests { // --- Aggregate query AVG axis at the public API level --- #[test] - fn prove_indexed_axis_range_aggregate_avg_returns_not_supported() { + fn prove_indexed_axis_aggregate_over_value_range_avg_returns_not_supported() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); build_pcpsit(&db, grove_version, &[IndexAxis::Avg.tag()], &[(b"a", 1)]); let result = db - .prove_indexed_axis_range_aggregate( + .prove_indexed_axis_aggregate_over_value_range( [TEST_LEAF, b"pcpsit"].as_ref(), IndexAxis::Avg, 0, 100, + AggregateFold::Population, None, grove_version, ) @@ -1998,11 +2080,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, 10, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 10, None, grove_version) .unwrap() .expect("prove"); let bad: &[&[u8]] = &[TEST_LEAF]; - let r = GroveDb::verify_indexed_count_range_aggregate(&proof, bad, 0, 10, grove_version); + let r = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + bad, + 0, + 10, + grove_version, + ); assert!(matches!(r, Err(Error::CorruptedData(s)) if s.contains("layers"))); } @@ -2013,11 +2101,17 @@ mod tests { build_psit(&db, grove_version, &[(b"a", 1)]); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; let proof = db - .prove_indexed_sum_range_aggregate(path, -10, 10, None, grove_version) + .prove_indexed_sum_aggregate_over_value_range(path, -10, 10, None, grove_version) .unwrap() .expect("prove"); let bad: &[&[u8]] = &[TEST_LEAF, b"psit", b"extra"]; - let r = GroveDb::verify_indexed_sum_range_aggregate(&proof, bad, -10, 10, grove_version); + let r = GroveDb::verify_indexed_sum_aggregate_over_value_range( + &proof, + bad, + -10, + 10, + grove_version, + ); assert!(matches!(r, Err(Error::CorruptedData(s)) if s.contains("layers"))); } @@ -2197,15 +2291,24 @@ mod tests { let lo = u64::MAX as i128 + 5; let hi = u64::MAX as i128 + 10; let proof = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Count, lo, hi, None, v) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Count, + lo, + hi, + AggregateFold::Population, + None, + v, + ) .unwrap() .expect("prove out-of-domain count aggregate"); - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof, path, IndexAxis::Count, lo, hi, + AggregateFold::Population, GroveVersion::latest(), ) .expect("verify out-of-domain count aggregate"); @@ -2227,15 +2330,24 @@ mod tests { let lo = i64::MAX as i128 + 5; let hi = i64::MAX as i128 + 10; let proof = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Sum, lo, hi, None, v) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Sum, + lo, + hi, + AggregateFold::Total, + None, + v, + ) .unwrap() .expect("prove out-of-domain sum aggregate"); - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof, path, IndexAxis::Sum, lo, hi, + AggregateFold::Total, GroveVersion::latest(), ) .expect("verify out-of-domain sum aggregate"); @@ -2256,15 +2368,24 @@ mod tests { let lo = i64::MIN as i128 - 10; let hi = i64::MIN as i128 - 5; let proof = db - .prove_indexed_axis_range_aggregate(path, IndexAxis::Sum, lo, hi, None, v) + .prove_indexed_axis_aggregate_over_value_range( + path, + IndexAxis::Sum, + lo, + hi, + AggregateFold::Total, + None, + v, + ) .unwrap() .expect("prove below-domain sum aggregate"); - let result = GroveDb::verify_indexed_axis_range_aggregate( + let result = GroveDb::verify_indexed_axis_aggregate_over_value_range( &proof, path, IndexAxis::Sum, lo, hi, + AggregateFold::Total, GroveVersion::latest(), ) .expect("verify below-domain sum aggregate"); @@ -2701,12 +2822,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 5), (b"b", 5), (b"c", 10)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 5, 5, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 5, 5, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 5, 5, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 5, + 5, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 2); } @@ -2718,12 +2844,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 2)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 100, 200, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 100, 200, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 100, 200, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 100, + 200, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 0); } @@ -2758,12 +2889,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"outer", b"inner"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, u64::MAX, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, u64::MAX, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, u64::MAX, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + u64::MAX, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 3); assert_eq!(result.root_hash, root_hash(&db, grove_version)); } @@ -2780,12 +2916,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, 100, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 100, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, 100, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + 100, + grove_version, + ) + .expect("verify"); // [0, 100]: a(1), b(50), c(100) = 3. assert_eq!(result.aggregate, 3); assert_eq!(result.root_hash, root_hash(&db, grove_version)); @@ -2799,12 +2940,17 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 0), (b"b", 0), (b"c", 5)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 0, 0, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 0, 0, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 0, 0, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 0, + 0, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 2, "two entries with count=0"); } @@ -2816,10 +2962,16 @@ mod tests { build_pcit(&db, grove_version, &[(b"a", 1), (b"b", 100)]); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, u64::MAX, u64::MAX, None, grove_version) + .prove_indexed_count_aggregate_over_value_range( + path, + u64::MAX, + u64::MAX, + None, + grove_version, + ) .unwrap() .expect("prove"); - let result = GroveDb::verify_indexed_count_range_aggregate( + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( &proof, path, u64::MAX, @@ -2841,12 +2993,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let proof = db - .prove_indexed_count_range_aggregate(path, 42, 42, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 42, 42, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 42, 42, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 42, + 42, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 4); } @@ -3043,16 +3200,21 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; let unproven = db - .indexed_count_range_aggregate(path, 3, 9, None, grove_version) + .indexed_count_aggregate_over_value_range(path, 3, 9, None, grove_version) .unwrap() .expect("unproven"); let proof = db - .prove_indexed_count_range_aggregate(path, 3, 9, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 3, 9, None, grove_version) .unwrap() .expect("prove"); - let proven = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 3, 9, grove_version) - .expect("verify"); + let proven = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 3, + 9, + grove_version, + ) + .expect("verify"); assert_eq!(unproven as i128, proven.aggregate); } @@ -3172,12 +3334,17 @@ mod tests { ); let path: &[&[u8]] = &[TEST_LEAF, b"outer", b"mid", b"inner_cidx"]; let proof = db - .prove_indexed_count_range_aggregate(path, 15, 25, None, grove_version) + .prove_indexed_count_aggregate_over_value_range(path, 15, 25, None, grove_version) .unwrap() .expect("prove"); - let result = - GroveDb::verify_indexed_count_range_aggregate(&proof, path, 15, 25, grove_version) - .expect("verify"); + let result = GroveDb::verify_indexed_count_aggregate_over_value_range( + &proof, + path, + 15, + 25, + grove_version, + ) + .expect("verify"); assert_eq!(result.aggregate, 1, "only b(20) in [15,25]"); assert_eq!(result.root_hash, root_hash(&db, grove_version)); } diff --git a/grovedb/src/tests/merge_versioning_tests.rs b/grovedb/src/tests/merge_versioning_tests.rs index f0e684f2f..8ee12fb8a 100644 --- a/grovedb/src/tests/merge_versioning_tests.rs +++ b/grovedb/src/tests/merge_versioning_tests.rs @@ -9,6 +9,8 @@ #[cfg(test)] mod tests { + #[allow(unused_imports)] + use grovedb_merk::proofs::query::AggregateFold; use grovedb_merk::proofs::{query::query_item::QueryItem, Query}; use grovedb_version::version::{GroveVersion, GROVE_VERSIONS}; @@ -69,7 +71,7 @@ mod tests { #[test] fn query_level_merges_reject_read_modes() { - use grovedb_merk::proofs::query::{AxisQuery, IndexAxis, ReadMode}; + use grovedb_merk::proofs::query::{AggregateFold, AxisQuery, IndexAxis, ReadMode}; let mut axis_query = Query::new(); axis_query.read_mode = Some(Box::new(ReadMode::Axis(AxisQuery::top_k( @@ -208,12 +210,16 @@ mod tests { db.indexed_sum_range(path.as_ref(), 0, 100, true, 10, None, &bad) ); assert_version_rejected!( - "indexed_sum_range_aggregate", - db.indexed_sum_range_aggregate(path.as_ref(), 0, 100, None, &bad) + "indexed_sum_aggregate_over_value_range", + db.indexed_sum_aggregate_over_value_range(path.as_ref(), 0, 100, None, &bad) + ); + assert_version_rejected!( + "indexed_sum_population_over_value_range", + db.indexed_sum_population_over_value_range(path.as_ref(), 0, 100, None, &bad) ); assert_version_rejected!( - "indexed_count_range_aggregate", - db.indexed_count_range_aggregate(path.as_ref(), 0, 100, None, &bad) + "indexed_count_aggregate_over_value_range", + db.indexed_count_aggregate_over_value_range(path.as_ref(), 0, 100, None, &bad) ); // Sanity: the same calls succeed at the real version, so the // rejections above are the gate firing and not a broken fixture. @@ -275,8 +281,8 @@ mod tests { ) ); assert_version_rejected!( - "prove_indexed_count_range_aggregate", - db.prove_indexed_count_range_aggregate(path.as_ref(), 0, 100, None, &bad) + "prove_indexed_count_aggregate_over_value_range", + db.prove_indexed_count_aggregate_over_value_range(path.as_ref(), 0, 100, None, &bad) ); } @@ -343,13 +349,14 @@ mod tests { ) ); assert_version_rejected!( - "verify_indexed_axis_range_aggregate", - crate::GroveDb::verify_indexed_axis_range_aggregate( + "verify_indexed_axis_aggregate_over_value_range", + crate::GroveDb::verify_indexed_axis_aggregate_over_value_range( &garbage, &path, IndexAxis::Count, 0, 100, + AggregateFold::Population, &bad, ) ); @@ -479,12 +486,16 @@ mod tests { // The aggregate readers already gated before their fast path; // pin that so a future refactor cannot reintroduce the same gap. assert_version_rejected!( - "indexed_sum_range_aggregate", - db.indexed_sum_range_aggregate(path.as_ref(), 100, 0, None, &bad) + "indexed_sum_aggregate_over_value_range", + db.indexed_sum_aggregate_over_value_range(path.as_ref(), 100, 0, None, &bad) + ); + assert_version_rejected!( + "indexed_sum_population_over_value_range", + db.indexed_sum_population_over_value_range(path.as_ref(), 100, 0, None, &bad) ); assert_version_rejected!( - "indexed_count_range_aggregate", - db.indexed_count_range_aggregate(path.as_ref(), 100, 0, None, &bad) + "indexed_count_aggregate_over_value_range", + db.indexed_count_aggregate_over_value_range(path.as_ref(), 100, 0, None, &bad) ); // At the real version an inverted range still answers empty diff --git a/grovedb/src/tests/provable_count_indexed_tree_tests.rs b/grovedb/src/tests/provable_count_indexed_tree_tests.rs index 35e7cab92..8622dd777 100644 --- a/grovedb/src/tests/provable_count_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_count_indexed_tree_tests.rs @@ -1221,7 +1221,7 @@ mod tests { } #[test] - fn indexed_count_range_aggregate_counts_in_range() { + fn indexed_count_aggregate_over_value_range_counts_in_range() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); insert_empty_pcit(&db, b"cidx", grove_version); @@ -1229,7 +1229,7 @@ mod tests { // [5, 12]: alice + dave + bob = 3 entries. let count = db - .indexed_count_range_aggregate( + .indexed_count_aggregate_over_value_range( [TEST_LEAF, b"cidx"].as_ref(), 5, 12, @@ -1242,7 +1242,7 @@ mod tests { // [0, u64::MAX]: total count of entries (5). let total = db - .indexed_count_range_aggregate( + .indexed_count_aggregate_over_value_range( [TEST_LEAF, b"cidx"].as_ref(), 0, u64::MAX, @@ -1255,7 +1255,7 @@ mod tests { // [100, 200]: nothing in this range → 0. let none = db - .indexed_count_range_aggregate( + .indexed_count_aggregate_over_value_range( [TEST_LEAF, b"cidx"].as_ref(), 100, 200, @@ -1268,7 +1268,7 @@ mod tests { // lo > hi: 0 (degenerate). let degenerate = db - .indexed_count_range_aggregate( + .indexed_count_aggregate_over_value_range( [TEST_LEAF, b"cidx"].as_ref(), 100, 10, diff --git a/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs b/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs index a7fbd9fa2..5d9eb5bbc 100644 --- a/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs @@ -1110,7 +1110,7 @@ mod tests { // Aggregate count [2, 4]: 3 entries. let agg = db - .indexed_count_range_aggregate( + .indexed_count_aggregate_over_value_range( [TEST_LEAF, b"pcpsit"].as_ref(), 2, 4, @@ -1123,7 +1123,7 @@ mod tests { // Aggregate full scan = 5. let total = db - .indexed_count_range_aggregate( + .indexed_count_aggregate_over_value_range( [TEST_LEAF, b"pcpsit"].as_ref(), 0, u64::MAX, @@ -1217,7 +1217,7 @@ mod tests { // Aggregate sum in [0, 10]: 0 + 9 + 10 = 19. let agg = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"pcpsit"].as_ref(), 0, 10, @@ -1230,7 +1230,7 @@ mod tests { // Aggregate full sum: -25 + 0 + 9 + 10 + 100 = 94. let total = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"pcpsit"].as_ref(), i64::MIN, i64::MAX, diff --git a/grovedb/src/tests/provable_sum_indexed_tree_tests.rs b/grovedb/src/tests/provable_sum_indexed_tree_tests.rs index 0e88d68c7..5d212fade 100644 --- a/grovedb/src/tests/provable_sum_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_sum_indexed_tree_tests.rs @@ -1109,7 +1109,7 @@ mod tests { } #[test] - fn indexed_sum_range_aggregate_sums_in_range() { + fn indexed_sum_aggregate_over_value_range_sums_in_range() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); insert_empty_psit_at_test_leaf(&db, b"psit", grove_version); @@ -1117,14 +1117,20 @@ mod tests { // Sum in [-1, 12]: -1 + 0 + 5 + 12 = 16. let agg = db - .indexed_sum_range_aggregate([TEST_LEAF, b"psit"].as_ref(), -1, 12, None, grove_version) + .indexed_sum_aggregate_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + -1, + 12, + None, + grove_version, + ) .unwrap() .expect("agg"); assert_eq!(agg, 16); // Total sum [i64::MIN, i64::MAX]: -7 + -1 + 0 + 5 + 12 + 100 = 109. let total = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"psit"].as_ref(), i64::MIN, i64::MAX, @@ -1137,7 +1143,7 @@ mod tests { // Empty range [200, 300]: 0. let none = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"psit"].as_ref(), 200, 300, @@ -1150,7 +1156,7 @@ mod tests { // lo > hi: 0. let degenerate = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"psit"].as_ref(), 100, 10, @@ -1163,7 +1169,7 @@ mod tests { // Negative-only [-100, 0]: -7 + -1 + 0 = -8. let neg = db - .indexed_sum_range_aggregate( + .indexed_sum_aggregate_over_value_range( [TEST_LEAF, b"psit"].as_ref(), -100, 0, diff --git a/grovedb/src/tests/run_path_query_tests.rs b/grovedb/src/tests/run_path_query_tests.rs index 5031d4317..d29b8c0e4 100644 --- a/grovedb/src/tests/run_path_query_tests.rs +++ b/grovedb/src/tests/run_path_query_tests.rs @@ -5,7 +5,7 @@ #[cfg(test)] mod tests { use grovedb_merk::proofs::{ - query::{query_item::QueryItem, AggregateSumQuery, AxisQuery, IndexAxis}, + query::{query_item::QueryItem, AggregateFold, AggregateSumQuery, AxisQuery, IndexAxis}, Query, }; use grovedb_version::version::{GroveVersion, GROVE_VERSIONS}; @@ -294,18 +294,30 @@ mod tests { } #[test] - fn axis_range_aggregate_matches_direct_primitive() { + fn axis_aggregate_over_value_range_matches_direct_primitive() { let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); build_psit(&db, grove_version, PSIT_ENTRIES); let direct = db - .indexed_sum_range_aggregate([TEST_LEAF, b"psit"].as_ref(), 0, 40, None, grove_version) + .indexed_sum_aggregate_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + 0, + 40, + None, + grove_version, + ) .unwrap() .expect("direct range aggregate"); let run = db .run_path_query( - &PathQuery::new_axis_range_aggregate(psit_path(), IndexAxis::Sum, 0, 40), + &PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Total, + ), true, true, true, @@ -316,11 +328,84 @@ mod tests { .unwrap() .expect("unified range aggregate"); match run { - PathQueryRun::AxisAggregate(AxisAggregateValue::Sum(sum)) => assert_eq!(sum, direct), + PathQueryRun::AxisAggregate(AxisAggregateValue::Total(sum)) => assert_eq!(sum, direct), other => panic!("expected AxisAggregate(Sum), got {other:?}"), } } + #[test] + fn axis_population_over_value_range_matches_direct_primitive() { + // The Population fold over the same sum band routes to the + // count aggregate of the (PCPS) sum secondary — a different + // dispatch arm and a different trusted reader than Total. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + build_psit(&db, grove_version, PSIT_ENTRIES); + + let direct = db + .indexed_sum_population_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + 0, + 40, + None, + grove_version, + ) + .unwrap() + .expect("direct population over range"); + let run = db + .run_path_query( + &PathQuery::new_axis_aggregate_over_value_range( + psit_path(), + IndexAxis::Sum, + 0, + 40, + AggregateFold::Population, + ), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .expect("unified population over range"); + match run { + PathQueryRun::AxisAggregate(AxisAggregateValue::Population(population)) => { + // [0, 40] over sums [40, -10, 25, 40, 5] selects four. + assert_eq!(population, direct); + assert_eq!(population, 4); + } + other => panic!("expected AxisAggregate(Population), got {other:?}"), + } + + // The reader's own edge shapes, direct: inverted bounds answer + // an empty population, and hi = i64::MAX takes the unbounded + // upper branch. + let inverted = db + .indexed_sum_population_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + 40, + 0, + None, + grove_version, + ) + .unwrap() + .expect("inverted bounds are a valid empty answer"); + assert_eq!(inverted, 0); + let unbounded = db + .indexed_sum_population_over_value_range( + [TEST_LEAF, b"psit"].as_ref(), + i64::MIN, + i64::MAX, + None, + grove_version, + ) + .unwrap() + .expect("full-domain population"); + assert_eq!(unbounded, 5, "every PSIT entry is in the full domain"); + } + // ----------------------------------------------------------------- // Branched axis reads // ----------------------------------------------------------------- @@ -576,7 +661,7 @@ mod tests { // The other two axes // // The dispatch fans out per axis inside `axis_top_k_paginated_entries` - // / `axis_bounded_entries` / the range-aggregate arm, so exercising + // / `axis_bounded_entries` / the aggregate-over-value-range arm, so exercising // only the sum axis leaves two thirds of each fan-out — and the whole // count-bounds clamp — unexecuted. // ----------------------------------------------------------------- @@ -680,14 +765,20 @@ mod tests { .expect("unified count bounded"); assert_eq!(run_entries(run), AxisEntries::Count(direct)); - // Range aggregate on the count axis. + // Aggregate over the value range on the count axis. let direct = db - .indexed_count_range_aggregate(path.as_ref(), 0, 10, None, grove_version) + .indexed_count_aggregate_over_value_range(path.as_ref(), 0, 10, None, grove_version) .unwrap() .expect("direct count range aggregate"); let run = db .run_path_query( - &PathQuery::new_axis_range_aggregate(pcit_path(), IndexAxis::Count, 0, 10), + &PathQuery::new_axis_aggregate_over_value_range( + pcit_path(), + IndexAxis::Count, + 0, + 10, + AggregateFold::Population, + ), true, true, true, @@ -698,7 +789,7 @@ mod tests { .unwrap() .expect("unified count range aggregate"); match run { - PathQueryRun::AxisAggregate(AxisAggregateValue::Count(value)) => { + PathQueryRun::AxisAggregate(AxisAggregateValue::Population(value)) => { assert_eq!(value, direct) } other => panic!("expected AxisAggregate(Count), got {other:?}"), @@ -1359,13 +1450,13 @@ mod tests { #[test] fn branched_non_entry_listing_traversal_is_rejected_at_classification() { - // A branched read whose terminal is rank-of-key or range-aggregate + // A branched read whose terminal is rank-of-key or aggregate-over-value-range // has no per-branch entry list to return. It must be refused as a // malformed query, not reach the dispatch and surface as an // internal CorruptedCodeExecution. for axis_query in [ AxisQuery::rank_of_key(IndexAxis::Sum, b"alice".to_vec(), true), - AxisQuery::range_aggregate(IndexAxis::Sum, 0, 10), + AxisQuery::aggregate_over_value_range(IndexAxis::Sum, 0, 10, AggregateFold::Total), ] { let pq = PathQuery::new_branched_axis( vec![TEST_LEAF.to_vec()],