Skip to content

Commit 9c7d5e1

Browse files
feat: AggregateSumOnRange query + proof + verify for ProvableSumTree
Adds the marquee Phase 5 feature for ProvableSumTree: a query that asks "what's the cryptographically-verifiable signed sum of children with keys in range [a, b]?" against a ProvableSumTree, with proof size O(log n + |boundary|) and a verify path that returns the root hash plus the aggregate i64 sum. Mirrors AggregateCountOnRange line-for-line: - QueryItem::AggregateSumOnRange(Box<QueryItem>) variant (wire tag 11) - Query / SizedQuery / PathQuery::validate_aggregate_sum_on_range with the same nested-rejection, no-subquery, no-pagination, allowed-inner-range rules - merk/src/proofs/query/aggregate_sum.rs (~760 lines) implementing create_aggregate_sum_on_range_proof + verify_aggregate_sum_on_range_proof with the same Disjoint/Contained/Boundary classification, HashWithSum self-verifying compression at fully-inside/outside subtrees, and KVDigestSum at boundaries - grovedb/src/operations/proof/aggregate_sum.rs (~330 lines) for the GroveDB-level multi-layer envelope chain check - prove_query / verify_query dispatch in generate.rs and verify.rs - Tree-type rejection arms in BulkAppendTree, DenseTree, MMR for the new variant Key correctness points handled differently from count: - i128 accumulator throughout the verifier (sum can validly be 0 with non-zero children, so no "if sum == 0" short-circuit; final narrow to i64 with an explicit overflow error) - No checked_sub equivalent for own_sum derivation — signed sums make arithmetic-only corruption detection meaningless; the hash chain binds the values regardless - ProvableSumTree-only at the merk-level gate (Sum/BigSum use different hash dispatches and can't host this proof shape) Tests: 35 new tests total (14 merk-level in aggregate_sum.rs, 21 GroveDB- level in aggregate_sum_query_tests.rs) covering empty trees, single-key ranges, full/sub/boundary ranges, negative sums, mixed-sign extremes including i64::MAX + i64::MIN = -1, tampering rejection, wrong-tree rejection, validation rejection of nested/Key/RangeFull/orthogonal-aggregate inners, multi-layer paths, NotSummed-wrapped subtree exclusion, V0 envelope round-trip. Workspace test count: 2898 → 2938, zero failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40b3c16 commit 9c7d5e1

15 files changed

Lines changed: 2704 additions & 19 deletions

File tree

grovedb-bulk-append-tree/src/proof/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ fn query_to_ranges(query: &Query, total_count: u64) -> Result<Vec<(u64, u64)>, B
142142
.into(),
143143
));
144144
}
145+
QueryItem::AggregateSumOnRange(_) => {
146+
return Err(BulkAppendError::InvalidInput(
147+
"AggregateSumOnRange is only supported on provable sum trees, \
148+
not on BulkAppendTree"
149+
.into(),
150+
));
151+
}
145152
};
146153
ranges.push((start, end));
147154
}

grovedb-dense-fixed-sized-merkle-tree/src/proof/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ pub(crate) fn query_to_positions(query: &Query, count: u16) -> Result<Vec<u16>,
123123
.into(),
124124
));
125125
}
126+
QueryItem::AggregateSumOnRange(_) => {
127+
return Err(DenseMerkleError::InvalidProof(
128+
"AggregateSumOnRange is only supported on provable sum trees, \
129+
not on dense fixed-size merkle trees"
130+
.into(),
131+
));
132+
}
126133
}
127134
}
128135

grovedb-query/src/query.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,22 @@ impl Query {
321321
}
322322
}
323323

324+
/// Creates an aggregate-sum-on-range query that sums the children matched
325+
/// by `range`. Mirrors [`Self::new_aggregate_count_on_range`] for
326+
/// `ProvableSumTree` instead of `ProvableCountTree`.
327+
///
328+
/// `range` must be a true range variant; passing `Key`, `RangeFull`,
329+
/// another `AggregateSumOnRange`, or an `AggregateCountOnRange` is
330+
/// allowed at construction time but will be rejected by
331+
/// [`Self::validate_aggregate_sum_on_range`].
332+
pub fn new_aggregate_sum_on_range(range: QueryItem) -> Self {
333+
Self {
334+
items: vec![QueryItem::AggregateSumOnRange(Box::new(range))],
335+
left_to_right: true,
336+
..Self::default()
337+
}
338+
}
339+
324340
/// If this query contains an `AggregateCountOnRange` item *anywhere* in
325341
/// its `items` vec, returns a reference to the first such item (whether
326342
/// the surrounding query is well-formed or not). Returns `None` only
@@ -339,6 +355,15 @@ impl Query {
339355
.find(|item| item.is_aggregate_count_on_range())
340356
}
341357

358+
/// Mirror of [`Self::aggregate_count_on_range`] for `AggregateSumOnRange`.
359+
/// Returns `Some(...)` for any query containing such an item, regardless
360+
/// of well-formedness.
361+
pub fn aggregate_sum_on_range(&self) -> Option<&QueryItem> {
362+
self.items
363+
.iter()
364+
.find(|item| item.is_aggregate_sum_on_range())
365+
}
366+
342367
/// Returns `true` if any item in this query — including items inside
343368
/// nested subquery branches — is an `AggregateCountOnRange`.
344369
///
@@ -372,6 +397,31 @@ impl Query {
372397
false
373398
}
374399

400+
/// Mirror of [`Self::has_aggregate_count_on_range_anywhere`] for
401+
/// `AggregateSumOnRange`. Used by the prover/verifier to validate at
402+
/// entry — if any ASOR is present anywhere, the query must satisfy
403+
/// [`Self::validate_aggregate_sum_on_range`].
404+
pub fn has_aggregate_sum_on_range_anywhere(&self) -> bool {
405+
if self.aggregate_sum_on_range().is_some() {
406+
return true;
407+
}
408+
if let Some(sub) = self.default_subquery_branch.subquery.as_deref()
409+
&& sub.has_aggregate_sum_on_range_anywhere()
410+
{
411+
return true;
412+
}
413+
if let Some(branches) = &self.conditional_subquery_branches {
414+
for branch in branches.values() {
415+
if let Some(sub) = branch.subquery.as_deref()
416+
&& sub.has_aggregate_sum_on_range_anywhere()
417+
{
418+
return true;
419+
}
420+
}
421+
}
422+
false
423+
}
424+
375425
/// Validates the Query-level constraints that apply when an
376426
/// `AggregateCountOnRange` is present. On success, returns a reference
377427
/// to the inner `QueryItem` describing the range to count.
@@ -427,6 +477,12 @@ impl Query {
427477
"AggregateCountOnRange may not wrap another AggregateCountOnRange",
428478
));
429479
}
480+
QueryItem::AggregateSumOnRange(_) => {
481+
return Err(Error::InvalidOperation(
482+
"AggregateCountOnRange may not wrap AggregateSumOnRange — the two are \
483+
orthogonal aggregate queries",
484+
));
485+
}
430486
_ => {}
431487
}
432488
if self.default_subquery_branch.subquery.is_some()
@@ -446,6 +502,86 @@ impl Query {
446502
Ok(inner)
447503
}
448504

505+
/// Validates the Query-level constraints that apply when an
506+
/// `AggregateSumOnRange` is present. Mirror of
507+
/// [`Self::validate_aggregate_count_on_range`] for `ProvableSumTree`.
508+
///
509+
/// Rules enforced:
510+
///
511+
/// 1. The query must contain exactly one item.
512+
/// 2. That item must be `AggregateSumOnRange(_)`.
513+
/// 3. The inner item must not be `Key` (use `has_raw` / `get_raw` for
514+
/// existence tests).
515+
/// 4. The inner item must not be `RangeFull` (read the parent
516+
/// `Element::ProvableSumTree` bytes directly for the unconditional
517+
/// total).
518+
/// 5. The inner item must not itself be `AggregateSumOnRange`.
519+
/// 6. The inner item must not be `AggregateCountOnRange` (the two
520+
/// aggregate variants are orthogonal).
521+
/// 7. `default_subquery_branch.subquery` and
522+
/// `default_subquery_branch.subquery_path` must both be `None`.
523+
/// 8. `conditional_subquery_branches` must be `None` or empty.
524+
///
525+
/// `SizedQuery::limit` / `SizedQuery::offset` checks live at the
526+
/// `PathQuery` / `SizedQuery` layer.
527+
pub fn validate_aggregate_sum_on_range(&self) -> Result<&QueryItem, Error> {
528+
if self.items.len() != 1 {
529+
return Err(Error::InvalidOperation(
530+
"AggregateSumOnRange must be the only item in the query",
531+
));
532+
}
533+
let inner = match &self.items[0] {
534+
QueryItem::AggregateSumOnRange(inner) => inner.as_ref(),
535+
_ => {
536+
return Err(Error::InvalidOperation(
537+
"validate_aggregate_sum_on_range called on a query without an \
538+
AggregateSumOnRange item",
539+
));
540+
}
541+
};
542+
match inner {
543+
QueryItem::Key(_) => {
544+
return Err(Error::InvalidOperation(
545+
"AggregateSumOnRange may not wrap Key — use has_raw / get_raw for \
546+
existence tests",
547+
));
548+
}
549+
QueryItem::RangeFull(_) => {
550+
return Err(Error::InvalidOperation(
551+
"AggregateSumOnRange may not wrap RangeFull — read the parent \
552+
ProvableSumTree element for the unconditional total",
553+
));
554+
}
555+
QueryItem::AggregateSumOnRange(_) => {
556+
return Err(Error::InvalidOperation(
557+
"AggregateSumOnRange may not wrap another AggregateSumOnRange",
558+
));
559+
}
560+
QueryItem::AggregateCountOnRange(_) => {
561+
return Err(Error::InvalidOperation(
562+
"AggregateSumOnRange may not wrap AggregateCountOnRange — the two are \
563+
orthogonal aggregate queries",
564+
));
565+
}
566+
_ => {}
567+
}
568+
if self.default_subquery_branch.subquery.is_some()
569+
|| self.default_subquery_branch.subquery_path.is_some()
570+
{
571+
return Err(Error::InvalidOperation(
572+
"AggregateSumOnRange queries may not carry a default subquery branch",
573+
));
574+
}
575+
if let Some(branches) = &self.conditional_subquery_branches
576+
&& !branches.is_empty()
577+
{
578+
return Err(Error::InvalidOperation(
579+
"AggregateSumOnRange queries may not carry conditional subquery branches",
580+
));
581+
}
582+
Ok(inner)
583+
}
584+
449585
/// Returns `true` if the given key would trigger a subquery (either via
450586
/// the default subquery branch or a matching conditional branch).
451587
pub fn has_subquery_on_key(&self, key: &[u8], in_path: bool) -> bool {

grovedb-query/src/query_item/intersect.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ impl QueryItem {
613613
end: RangeSetItem::Inclusive(range.end().clone()),
614614
},
615615
QueryItem::AggregateCountOnRange(inner) => inner.to_range_set(),
616+
QueryItem::AggregateSumOnRange(inner) => inner.to_range_set(),
616617
}
617618
}
618619

@@ -662,6 +663,7 @@ impl QueryItem {
662663
end: RangeSetSimpleItemBorrowed::Inclusive(range.end()),
663664
}),
664665
QueryItem::AggregateCountOnRange(inner) => inner.to_range_set_borrowed(),
666+
QueryItem::AggregateSumOnRange(inner) => inner.to_range_set_borrowed(),
665667
}
666668
}
667669

0 commit comments

Comments
 (0)