diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 6081afc94..1a75c7378 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -118,6 +118,7 @@ pub struct GroveDBOperationsQueryVersions { pub query_aggregate_sums: FeatureVersion, pub query_aggregate_count_on_range: FeatureVersion, pub query_aggregate_sum_on_range: FeatureVersion, + pub query_aggregate_count_and_sum_on_range: FeatureVersion, pub query_sums: FeatureVersion, pub query_raw: FeatureVersion, pub query_keys_optional: FeatureVersion, diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index 3c6f9ee34..43a58e46f 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -140,6 +140,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { query_aggregate_sums: 0, query_aggregate_count_on_range: 0, query_aggregate_sum_on_range: 0, + query_aggregate_count_and_sum_on_range: 0, query_sums: 0, query_raw: 0, query_keys_optional: 0, diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index ea40d40ed..46351bf7a 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -140,6 +140,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { query_aggregate_sums: 0, query_aggregate_count_on_range: 0, query_aggregate_sum_on_range: 0, + query_aggregate_count_and_sum_on_range: 0, query_sums: 0, query_raw: 0, query_keys_optional: 0, diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index 2624e5acd..039891bd1 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -140,6 +140,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { query_aggregate_sums: 0, query_aggregate_count_on_range: 0, query_aggregate_sum_on_range: 0, + query_aggregate_count_and_sum_on_range: 0, query_sums: 0, query_raw: 0, query_keys_optional: 0, diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index 8512a76d4..635c7764c 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -882,6 +882,104 @@ where { Ok(count).wrap_with_cost(cost) } + /// Execute an `AggregateCountAndSumOnRange` path query without + /// producing a proof, returning the in-range `(count, sum)` pair + /// from a single merk-internal traversal. + /// + /// No-prove sibling of [`Self::query_aggregate_sum`] and + /// [`Self::query_aggregate_count`] for the combined-axis flavor — + /// the same call shape, just yielding both metrics at once. The + /// returned tuple matches what + /// [`GroveDb::verify_aggregate_count_and_sum_query`] extracts from + /// the prove-side equivalent for the same path query, so consumers + /// can swap between the two paths without changing call sites. + /// + /// Internally this runs ONE classification walk over the leaf + /// merk (the same shape the combined prover walks) and accumulates + /// both axes in parallel; it is strictly cheaper than calling + /// `query_aggregate_count` and `query_aggregate_sum` separately. + /// + /// `path_query` must satisfy + /// [`PathQuery::validate_leaf_aggregate_count_and_sum_on_range`] — + /// strictly the **leaf** shape: a single + /// `AggregateCountAndSumOnRange(_)` item, no subqueries, no + /// pagination, and an inner range that isn't `Key`, `RangeFull`, + /// or another aggregate variant. Carrier-shape queries are + /// rejected here because this entry point returns one `(u64, i64)` + /// and has no way to surface per-outer-key carrier results. Any + /// other shape is rejected up front with `Error::InvalidQuery` + /// before any merk reads happen. + /// + /// The subtree at `path_query.path` must be a + /// `ProvableCountProvableSumTree` — the merk-level walk rejects + /// any other tree type with the same `WrongElementType`-shape + /// error the sibling no-prove accumulators return. If the subtree + /// is missing (path does not resolve), this returns the same + /// `PathNotFound` / `PathParentLayerNotFound` errors as other + /// path-based reads. + /// + /// The returned pair is **not** independently verifiable — callers + /// are trusting their own merk read path. For a verifiable + /// `(count, sum)`, use [`Self::prove_query`] + + /// [`GroveDb::verify_aggregate_count_and_sum_query`]. + pub fn query_aggregate_count_and_sum( + &self, + path_query: &PathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult<(u64, i64), Error> { + let version_slot = grove_version + .grovedb_versions + .operations + .query + .query_aggregate_count_and_sum_on_range; + check_grovedb_v0_with_cost!("query_aggregate_count_and_sum", version_slot); + + let mut cost = OperationCost::default(); + + // Up-front shape validation. Strictly the leaf shape — this + // entry point returns a single `(u64, i64)` and has no way to + // surface per-outer-key carrier results. Catches malformed + // leaf combined-aggregate queries (illegal inner range, + // pagination, etc.) AND carrier-shape queries before any + // storage reads. + let inner_range = cost_return_on_error_no_add!( + cost, + path_query + .validate_leaf_aggregate_count_and_sum_on_range() + .cloned() + ); + + let tx = TxRef::new(&self.db, transaction); + + // Open the leaf merk and ask it for the (count, sum). The + // merk-level entry point enforces + // `tree_type == ProvableCountProvableSumTree` and handles the + // empty-merk case (returns (0, 0)). + let path_slices: Vec<&[u8]> = path_query.path.iter().map(|p| p.as_slice()).collect(); + let subtree = cost_return_on_error!( + &mut cost, + self.open_transactional_merk_at_path( + SubtreePath::from(path_slices.as_slice()), + tx.as_ref(), + None, + grove_version, + ) + ); + + let count_and_sum = cost_return_on_error!( + &mut cost, + subtree + .count_and_sum_aggregate_on_range(&inner_range, grove_version) + .map_err(|e| Error::CorruptedData(format!( + "query_aggregate_count_and_sum at path {:?}: {}", + path_slices, e + ))) + ); + + Ok(count_and_sum).wrap_with_cost(cost) + } + /// Executes an `AggregateCountOnRange` query in either the **leaf** or /// **carrier** shape without generating a proof, returning one /// `(outer_key, count)` pair per matched outer key. diff --git a/grovedb/src/tests/aggregate_count_and_sum_query_tests.rs b/grovedb/src/tests/aggregate_count_and_sum_query_tests.rs new file mode 100644 index 000000000..88d568e85 --- /dev/null +++ b/grovedb/src/tests/aggregate_count_and_sum_query_tests.rs @@ -0,0 +1,553 @@ +//! End-to-end GroveDB tests for the **no-proof** +//! `GroveDb::query_aggregate_count_and_sum` entry point. +//! +//! Mirrors the no-proof sections of [`aggregate_sum_query_tests`] and +//! [`aggregate_count_query_tests`] for the combined `(u64, i64)` +//! flavor: every test pattern those files cover (basic walk, empty +//! merk, disjoint range, full-range, boundary nodes, version gating, +//! carrier-shape rejection, etc.) appears here calling the new +//! `query_aggregate_count_and_sum` and asserting both axes match what +//! the verified proof returns. +//! +//! Prove/no-prove equivalence is pinned by every `no_proof_matches_proof` +//! helper call — it cross-checks the direct no-proof result against the +//! `prove_query` + `verify_aggregate_count_and_sum_query` result on the +//! same path query, so the two paths can never silently diverge. + +#[cfg(test)] +mod tests { + use grovedb_merk::proofs::query::QueryItem; + use grovedb_query::Query; + use grovedb_version::version::{v2::GROVE_V2, GroveVersion}; + + use crate::{ + tests::{make_test_grovedb, TEST_LEAF}, + Element, GroveDb, PathQuery, + }; + + /// Insert keys "a".."o" (15 keys) into a `ProvableCountProvableSumTree` + /// rooted at `[TEST_LEAF, "st"]`. Each key carries + /// count = 1 and a value that mixes positive, negative, and zero so + /// the running sum exercises signed arithmetic. Returns the db and + /// the running full-range sum. + fn setup_15_key_pcps(grove_version: &GroveVersion) -> (crate::tests::TempGroveDb, i64) { + let db = make_test_grovedb(grove_version); + db.insert( + [TEST_LEAF].as_ref(), + b"st", + Element::empty_provable_count_provable_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert st"); + let mut full_sum: i64 = 0; + for (i, c) in (b'a'..=b'o').enumerate() { + let value: i64 = match i % 4 { + 0 => -(i as i64) * 3, + 2 => 0, + _ => (i as i64 + 1) * 2, + }; + full_sum += value; + db.insert( + [TEST_LEAF, b"st"].as_ref(), + &[c], + Element::new_sum_item(value), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sum item"); + } + (db, full_sum) + } + + /// Compute the expected sum slice of the `setup_15_key_pcps` fixture + /// for keys with zero-based indices `[lo_idx ..= hi_idx]` (subset of + /// 0..15). Mirror of `expected_pcps_sum_slice` in the merk-side + /// tests. + fn expected_sum_slice(lo_idx: u8, hi_idx: u8) -> i64 { + let mut sum: i64 = 0; + for i in lo_idx..=hi_idx { + let value: i64 = match i % 4 { + 0 => -(i as i64) * 3, + 2 => 0, + _ => (i as i64 + 1) * 2, + }; + sum += value; + } + sum + } + + /// Cross-check helper: build the path-query, call + /// `query_aggregate_count_and_sum`, assert the returned pair matches + /// the expected `(count, sum)` AND matches what the proof round-trip + /// returns. + fn no_proof_matches_proof( + db: &crate::tests::TempGroveDb, + path: Vec>, + inner_range: QueryItem, + expected: (u64, i64), + grove_version: &GroveVersion, + ) { + let path_query = PathQuery::new_aggregate_count_and_sum_on_range(path, inner_range); + + let direct = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, grove_version) + .unwrap() + .expect("query_aggregate_count_and_sum should succeed"); + assert_eq!( + direct, expected, + "no-proof variant returned wrong (count, sum) pair" + ); + + let proof = db + .grove_db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove_query should succeed"); + let (_root, proved_count, proved_sum) = + GroveDb::verify_aggregate_count_and_sum_query(&proof, &path_query, grove_version) + .expect("verify should succeed"); + assert_eq!( + direct, + (proved_count, proved_sum), + "no-proof variant disagrees with proof variant" + ); + } + + // -------- Range-shape sweep on the 15-key PCPS fixture -------- + + #[test] + fn no_proof_combined_pcps_range_inclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + // c..=l → indices 2..=11 → 10 keys + let expected_sum = expected_sum_slice(2, 11); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), + (10, expected_sum), + v, + ); + } + + #[test] + fn no_proof_combined_pcps_range_exclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + // c..l → indices 2..=10 → 9 keys + let expected_sum = expected_sum_slice(2, 10); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::Range(b"c".to_vec()..b"l".to_vec()), + (9, expected_sum), + v, + ); + } + + #[test] + fn no_proof_combined_pcps_range_from() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + // c..o → indices 2..=14 → 13 keys + let expected_sum = expected_sum_slice(2, 14); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeFrom(b"c".to_vec()..), + (13, expected_sum), + v, + ); + } + + #[test] + fn no_proof_combined_pcps_range_after() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + // After "b" → indices 2..=14 → 13 keys + let expected_sum = expected_sum_slice(2, 14); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeAfter(b"b".to_vec()..), + (13, expected_sum), + v, + ); + } + + #[test] + fn no_proof_combined_pcps_range_to_inclusive() { + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + // ..=e → indices 0..=4 → 5 keys + let expected_sum = expected_sum_slice(0, 4); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeToInclusive(..=b"e".to_vec()), + (5, expected_sum), + v, + ); + } + + #[test] + fn no_proof_combined_pcps_disjoint_range() { + // Range below all keys: contributes (0, 0) on the in-range slice. + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeInclusive(vec![0x00]..=vec![0x10]), + (0, 0), + v, + ); + } + + #[test] + fn no_proof_combined_pcps_full_range_returns_full_aggregate() { + // Full range over a..=o: returns the entire stored + // (count = 15, sum = full_sum). + let v = GroveVersion::latest(); + let (db, full_sum) = setup_15_key_pcps(v); + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeInclusive(b"a".to_vec()..=b"o".to_vec()), + (15, full_sum), + v, + ); + } + + #[test] + fn no_proof_combined_empty_pcps_returns_zero_zero() { + // An empty PCPS returns (0, 0) — same as the merk-level + // empty-merk contract. Inserting nothing under the tree + // exercises this path through the full GroveDB stack. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"st", + Element::empty_provable_count_provable_sum_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert st"); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeFrom(b"a".to_vec()..), + ); + let direct = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect("query_aggregate_count_and_sum should succeed on empty"); + assert_eq!(direct, (0u64, 0i64)); + } + + #[test] + fn no_proof_combined_negative_values_matches_proof() { + // PCPS tree with mixed positive and negative sum items: cross-check + // the no-proof and proof paths produce the same `(count, sum)` over + // both a full-range and a subrange. Mirror of the sum-side + // `no_proof_sum_negative_values_matches_proof` test. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"st", + Element::empty_provable_count_provable_sum_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert st"); + let entries: [(u8, i64); 4] = [(b'a', 50), (b'b', -100), (b'c', 30), (b'd', -50)]; + for (k, val) in entries { + db.insert( + [TEST_LEAF, b"st"].as_ref(), + &[k], + Element::new_sum_item(val), + None, + None, + v, + ) + .unwrap() + .expect("insert sum item"); + } + // Full range: count=4, sum = 50 - 100 + 30 - 50 = -70. + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeFrom(b"a".to_vec()..), + (4, -70), + v, + ); + // Subrange "b".."=c": count=2, sum = -100 + 30 = -70. + no_proof_matches_proof( + &db, + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeInclusive(b"b".to_vec()..=b"c".to_vec()), + (2, -70), + v, + ); + } + + // -------- Validation / rejection tests -------- + + #[test] + fn no_proof_combined_invalid_inner_range_rejected_before_storage_reads() { + // The validator runs at the top of query_aggregate_count_and_sum; + // an illegal inner range like `Key(_)` is rejected before any + // merk is opened. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::Key(b"a".to_vec()), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect_err("Key inner must be rejected at validation"); + match err { + crate::Error::InvalidQuery(_) | crate::Error::QueryError(_) => {} + other => panic!("expected InvalidQuery or QueryError, got {:?}", other), + } + } + + #[test] + fn no_proof_combined_empty_path_rejected_at_validation() { + // Mirror of the verify-side empty-path rejection: the no-proof + // entry point must also reject empty-path queries up front, since + // the GroveDB root is always a NormalTree. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + Vec::new(), + QueryItem::RangeFrom(b"a".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect_err("empty path must be rejected"); + match err { + crate::Error::InvalidQuery(_) => {} + other => panic!("expected InvalidQuery, got {:?}", other), + } + } + + #[test] + fn no_proof_combined_rejects_carrier_shape() { + // `query_aggregate_count_and_sum` returns a single `(u64, i64)` + // and has no way to surface per-outer-key carrier results. + // Calling it with a carrier-shape path query must be rejected + // up front by the leaf-only validator, BEFORE any storage reads + // happen — even though the dispatcher-level + // `validate_aggregate_count_and_sum_on_range` would have + // accepted the same query. + let v = GroveVersion::latest(); + let (db, _) = setup_15_key_pcps(v); + + let mut carrier = Query::new(); + carrier.insert_key(b"st".to_vec()); + carrier.set_subquery(Query::new_aggregate_count_and_sum_on_range( + QueryItem::Range(b"a".to_vec()..b"z".to_vec()), + )); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec()], + crate::SizedQuery::new(carrier, None, None), + ); + + // Sanity: the dispatcher-level validator accepts this as a + // valid carrier, so the rejection below is specifically because + // `query_aggregate_count_and_sum` tightens to leaf-only. + assert!(path_query + .validate_aggregate_count_and_sum_on_range() + .is_ok()); + + let err = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect_err("carrier shape must be rejected at the no-proof entry"); + assert!( + matches!( + err, + crate::Error::InvalidQuery(_) | crate::Error::QueryError(_) + ), + "expected InvalidQuery or QueryError, got {:?}", + err + ); + } + + #[test] + fn no_proof_combined_normal_tree_rejected_at_merk() { + // A path that resolves to a NormalTree (not a PCPS) must be + // rejected by the merk-level tree-type gate. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"normal", + Element::empty_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert normal tree"); + // Insert a child so the merk isn't empty (an empty merk would + // short-circuit to (0, 0) before hitting the tree-type check + // on the no-proof side, since + // `Merk::count_and_sum_aggregate_on_range` checks tree_type + // before descending — confirm by inserting something). + db.insert( + [TEST_LEAF, b"normal"].as_ref(), + b"a", + Element::new_item(b"v".to_vec()), + None, + None, + v, + ) + .unwrap() + .expect("insert child"); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"normal".to_vec()], + QueryItem::RangeFrom(b"a".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect_err("NormalTree leaf must be rejected by merk-level gate"); + // The merk-level error gets wrapped with contextual + // `CorruptedData` by `query_aggregate_count_and_sum` + // (callsite-specific path info — see + // `operations/get/query.rs`). + match err { + crate::Error::CorruptedData(_) => {} + other => panic!("expected CorruptedData wrapper, got {:?}", other), + } + } + + #[test] + fn no_proof_combined_single_axis_pcst_rejected_at_merk() { + // Single-axis hosts (ProvableCountSumTree, ProvableCountTree, + // ProvableSumTree) are rejected because their node hashes only + // bind one axis. Sanity-check the PCST arm here — the merk + // primitive's PCPS-only contract bubbles up as CorruptedData + // through the grovedb-level wrapper. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + db.insert( + [TEST_LEAF].as_ref(), + b"pcst", + Element::empty_provable_count_sum_tree(), + None, + None, + v, + ) + .unwrap() + .expect("insert pcst"); + db.insert( + [TEST_LEAF, b"pcst"].as_ref(), + b"a", + Element::new_sum_item(1), + None, + None, + v, + ) + .unwrap() + .expect("insert sum item"); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"pcst".to_vec()], + QueryItem::RangeFrom(b"a".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect_err("PCST leaf must be rejected by merk-level PCPS-only gate"); + match err { + crate::Error::CorruptedData(msg) => { + assert!( + msg.contains("ProvableCountProvableSumTree"), + "expected PCPS-only message, got: {msg}" + ); + } + other => panic!("expected CorruptedData wrapper, got {:?}", other), + } + } + + #[test] + fn no_proof_combined_v0_envelope_accepted_under_v2() { + // GROVE_V2 sets `query_aggregate_count_and_sum_on_range = 0` + // (V0-supported), so the V0 gate accepts it. This test pins + // that the version slot exists and routes to the v0 dispatch + // path — the sibling sum/count entry points are also V0-gated + // and this mirrors that contract. + // + // The call must succeed under GROVE_V2 (the slot is 0), which + // is what proves the routing was set up correctly. + let v: &GroveVersion = &GROVE_V2; + let (db, _) = setup_15_key_pcps(v); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"st".to_vec()], + QueryItem::RangeFrom(b"a".to_vec()..), + ); + let direct = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect("query under GROVE_V2 should succeed (slot is 0)"); + // Spot-check the count axis on the full fixture; sum details + // are covered by the latest-version tests above. + assert_eq!(direct.0, 15); + } + + #[test] + fn no_proof_combined_path_not_found_at_merk_open() { + // Covers the `open_transactional_merk_at_path` error branch in + // `query_aggregate_count_and_sum`: when the path doesn't + // resolve (intermediate subtree missing), the wrapped + // path-lookup error must propagate up the call chain instead + // of producing a spurious `(0, 0)` result. + let v = GroveVersion::latest(); + let db = make_test_grovedb(v); + let path_query = PathQuery::new_aggregate_count_and_sum_on_range( + vec![TEST_LEAF.to_vec(), b"nope".to_vec()], + QueryItem::RangeFrom(b"a".to_vec()..), + ); + let err = db + .grove_db + .query_aggregate_count_and_sum(&path_query, None, v) + .unwrap() + .expect_err("missing intermediate path must surface as an error"); + // Path resolution failures bubble up as `InvalidParentLayerPath` + // / `PathNotFound` / `PathParentLayerNotFound` / `PathKeyNotFound` + // depending on which layer fails. Any non-success outcome from + // a path-not-found shape covers the branch — we assert it's NOT + // a CorruptedData wrap (which would imply the merk was opened) + // and NOT an InvalidQuery (validation passed). + match err { + crate::Error::InvalidParentLayerPath(_) + | crate::Error::PathNotFound(_) + | crate::Error::PathParentLayerNotFound(_) + | crate::Error::PathKeyNotFound(_) => {} + other => panic!("expected a path-resolution error, got {:?}", other), + } + } +} diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 6073d5a31..c87bc51dd 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -7,6 +7,7 @@ mod query_tests; mod sum_tree_tests; mod aggregate_count_and_sum_carrier_query_tests; +mod aggregate_count_and_sum_query_tests; mod aggregate_count_query_tests; mod aggregate_sum_carrier_query_tests; mod aggregate_sum_query_tests; diff --git a/merk/src/merk/get.rs b/merk/src/merk/get.rs index 725751438..41f2aa5b7 100644 --- a/merk/src/merk/get.rs +++ b/merk/src/merk/get.rs @@ -453,6 +453,63 @@ where } }) } + + /// Execute an `AggregateCountAndSumOnRange` query without producing + /// a proof, returning the in-range `(count, sum)` pair from a + /// single merk-internal walk. + /// + /// This is the no-proof counterpart of + /// [`Self::prove_aggregate_count_and_sum_on_range`]. It walks the + /// same classification path the proof emitter does — using each + /// internal node's stored aggregate count and sum to short-circuit + /// Contained / Disjoint subtrees — but skips proof-op emission and + /// serialization. The merk-level cost is O(log n) in the number of + /// distinct keys, the same as the proof variant; consumers that + /// previously issued separate `count_aggregate_on_range` and + /// `sum_aggregate_on_range` calls collapse to one walk here. + /// + /// The merk's `tree_type` must be `ProvableCountProvableSumTree`; + /// any other tree type is rejected with `Error::InvalidProofError` + /// before any walking happens (PCPS is the only tree type whose + /// node hash binds BOTH aggregates, so it is the only valid + /// terminator for this query — single-axis hosts are rejected the + /// same way the proof-side primitive rejects them). On an empty + /// PCPS merk this returns `(0, 0)`. + /// + /// The accumulators carry `(u128, i128)` end-to-end and narrow to + /// `(u64, i64)` at the very last step (parallel to the prover and + /// verifier). An out-of-range result is treated as corruption — a + /// real PCPS tree maintains every aggregate as `(u64, i64)` at + /// every level, so an out-of-range wider-int result implies + /// inconsistent tree state. + /// + /// The returned pair is **not** independently verifiable — + /// callers trust the merk's reads. Use + /// `prove_aggregate_count_and_sum_on_range` + + /// `verify_aggregate_count_and_sum_on_range_proof` for a + /// verifiable result. + pub fn count_and_sum_aggregate_on_range( + &self, + inner_range: &QueryItem, + grove_version: &GroveVersion, + ) -> CostResult<(u64, i64), Error> { + let tree_type = self.tree_type; + if !matches!(tree_type, crate::TreeType::ProvableCountProvableSumTree) { + return Err(Error::InvalidProofError(format!( + "AggregateCountAndSumOnRange is only valid against \ + ProvableCountProvableSumTree, got {:?}", + tree_type + ))) + .wrap_with_cost(Default::default()); + } + self.use_tree_mut(|maybe_tree| match maybe_tree { + None => Ok((0u64, 0i64)).wrap_with_cost(Default::default()), + Some(tree) => { + let mut ref_walker = RefWalker::new(tree, self.source()); + ref_walker.count_and_sum_aggregate_on_range(inner_range, grove_version) + } + }) + } } #[cfg(test)] diff --git a/merk/src/proofs/query/aggregate_count_and_sum/mod.rs b/merk/src/proofs/query/aggregate_count_and_sum/mod.rs index fedb6cea0..d68d55051 100644 --- a/merk/src/proofs/query/aggregate_count_and_sum/mod.rs +++ b/merk/src/proofs/query/aggregate_count_and_sum/mod.rs @@ -57,6 +57,8 @@ mod prove; mod tests; #[cfg(any(feature = "minimal", feature = "verify"))] mod verify; +#[cfg(feature = "minimal")] +mod walk; #[cfg(any(feature = "minimal", feature = "verify"))] pub use verify::verify_aggregate_count_and_sum_on_range_proof; diff --git a/merk/src/proofs/query/aggregate_count_and_sum/prove.rs b/merk/src/proofs/query/aggregate_count_and_sum/prove.rs index a75a2e268..6ba764970 100644 --- a/merk/src/proofs/query/aggregate_count_and_sum/prove.rs +++ b/merk/src/proofs/query/aggregate_count_and_sum/prove.rs @@ -10,7 +10,9 @@ use std::collections::LinkedList; use grovedb_costs::{cost_return_on_error, CostResult, CostsExt, OperationCost}; use grovedb_version::version::GroveVersion; -use super::{emit::emit_count_and_sum_proof, is_provable_count_and_sum_bearing}; +use super::{ + emit::emit_count_and_sum_proof, is_provable_count_and_sum_bearing, walk::walk_count_and_sum, +}; use crate::{ proofs::{query::QueryItem, Op}, tree::{Fetch, RefWalker}, @@ -83,4 +85,75 @@ where Ok((ops, count, sum)).wrap_with_cost(cost) } + + /// Walk the tree for an `AggregateCountAndSumOnRange` query and + /// return the in-range `(count, sum)` pair, **without** producing + /// a proof. + /// + /// This is the no-proof counterpart of + /// [`Self::create_aggregate_count_and_sum_on_range_proof`]. It + /// performs the same classification walk (Contained / Disjoint / + /// Boundary) and reads each node's aggregate `(count, sum)` + /// directly from the merk, so it is O(log n) in the number of + /// distinct keys under the indexed subtree — the same complexity + /// as the proof variant but without the proof-op allocations, + /// hash recomputations, or serialization round-trip. + /// + /// The caller (`Merk::count_and_sum_aggregate_on_range`) is + /// expected to have already validated `tree_type` is + /// `ProvableCountProvableSumTree`; the per-node + /// `provable_count_and_sum_from_aggregate` check inside the walk + /// surfaces any disagreement between the declared tree type and + /// the in-memory aggregate. + /// + /// The accumulators carry `(u128, i128)` end-to-end and narrow to + /// `(u64, i64)` at the very last step, exactly the way the prover + /// and verifier do. Any value outside the narrower ranges is + /// treated as corruption (a real PCPS tree maintains every + /// aggregate as `(u64, i64)` at every level, so the wider path + /// only ever holds an out-of-range value if the tree state is + /// internally inconsistent). + /// + /// The result is **not** independently verifiable: the caller is + /// trusting their own merk read path. Callers that need a + /// verifiable pair must use `prove_aggregate_count_and_sum_on_range` + /// + `verify_aggregate_count_and_sum_on_range_proof`. + pub fn count_and_sum_aggregate_on_range( + &mut self, + inner_range: &QueryItem, + grove_version: &GroveVersion, + ) -> CostResult<(u64, i64), Error> { + let mut cost = OperationCost::default(); + let (count_u128, sum_i128) = cost_return_on_error!( + &mut cost, + walk_count_and_sum(self, inner_range, None, None, grove_version) + ); + narrow_count_and_sum(count_u128, sum_i128).wrap_with_cost(cost) + } +} + +/// Narrow the no-proof walker's `(u128, i128)` accumulator pair to the +/// on-the-wire `(u64, i64)` shape, returning `CorruptedData` if either +/// axis is out of range. Extracted into a free function so the narrowing +/// arms are unit-testable without standing up a corrupted merk. +/// +/// A real PCPS tree maintains every aggregate as `(u64, i64)` at every +/// level, so an honest walk lands inside both narrower ranges. An +/// out-of-range value implies the merk's in-memory state disagrees with +/// its type contract — local invariant failure, so `CorruptedData` per +/// the repo's error-handling convention. +pub(super) fn narrow_count_and_sum(count_u128: u128, sum_i128: i128) -> Result<(u64, i64), Error> { + let count = u64::try_from(count_u128).map_err(|_| { + Error::CorruptedData(format!( + "no-proof aggregate-count-and-sum: in-range count overflowed u64 ({})", + count_u128 + )) + })?; + let sum = i64::try_from(sum_i128).map_err(|_| { + Error::CorruptedData(format!( + "no-proof aggregate-count-and-sum: in-range sum overflowed i64 ({})", + sum_i128 + )) + })?; + Ok((count, sum)) } diff --git a/merk/src/proofs/query/aggregate_count_and_sum/tests.rs b/merk/src/proofs/query/aggregate_count_and_sum/tests.rs index 248056840..635bf20bd 100644 --- a/merk/src/proofs/query/aggregate_count_and_sum/tests.rs +++ b/merk/src/proofs/query/aggregate_count_and_sum/tests.rs @@ -9,7 +9,7 @@ use std::collections::LinkedList; use grovedb_version::version::GroveVersion; -use super::verify_aggregate_count_and_sum_on_range_proof; +use super::{prove::narrow_count_and_sum, verify_aggregate_count_and_sum_on_range_proof}; use crate::{ proofs::{ encode_into, @@ -18,7 +18,7 @@ use crate::{ }, test_utils::TempMerk, tree::{Op, TreeFeatureType::ProvableCountedAndProvableSummedMerkNode}, - Error, TreeType, + Error, Merk, TreeType, }; /// Encode a `LinkedList` into the on-the-wire byte stream. @@ -1118,3 +1118,318 @@ fn combined_verifier_narrow_gate_rejects_i128_overflow_via_crafted_proof() { Err(other) => panic!("unexpected error type: {:?}", other), } } + +// ---------- no-proof variant: count_and_sum_aggregate_on_range ---------- +// +// The no-proof entry point must return exactly the same `(count, sum)` +// pair as the proof path for every range shape, without producing any +// proof ops. These tests cross-check the two paths on the same merk +// and also cover the failure modes unique to the no-proof variant +// (wrong tree type, empty merk, overflow narrowing). + +/// Cross-check: assert `count_and_sum_aggregate_on_range` and the +/// `(count, sum)` returned by `prove_aggregate_count_and_sum_on_range` +/// agree for the given range, and that both equal the expected pair. +fn no_proof_matches_prover( + merk: &Merk>, + inner_range: QueryItem, + expected_count: u64, + expected_sum: i64, + grove_version: &GroveVersion, +) { + let (no_proof_count, no_proof_sum) = merk + .count_and_sum_aggregate_on_range(&inner_range, grove_version) + .unwrap() + .expect("count_and_sum_aggregate_on_range should succeed"); + assert_eq!( + no_proof_count, expected_count, + "no-proof variant returned wrong count for range {:?}", + inner_range + ); + assert_eq!( + no_proof_sum, expected_sum, + "no-proof variant returned wrong sum for range {:?}", + inner_range + ); + let (_ops, prover_count, prover_sum) = merk + .prove_aggregate_count_and_sum_on_range(&inner_range, grove_version) + .unwrap() + .expect("prove should succeed"); + assert_eq!( + no_proof_count, prover_count, + "no-proof count disagrees with prover for range {:?}", + inner_range + ); + assert_eq!( + no_proof_sum, prover_sum, + "no-proof sum disagrees with prover for range {:?}", + inner_range + ); +} + +/// Compute the expected sum of the make_15_key_pcps fixture for keys +/// in `[lo_idx ..= hi_idx]` (zero-based indices into 0..15). +fn expected_pcps_sum_slice(lo_idx: u8, hi_idx: u8) -> i64 { + let mut sum: i64 = 0; + for i in lo_idx..=hi_idx { + let value: i64 = match i % 4 { + 0 => -(i as i64) * 3, + 2 => 0, + _ => (i as i64 + 1) * 2, + }; + sum += value; + } + sum +} + +#[test] +fn no_proof_combined_matches_prover_closed_range_inclusive() { + let v = GroveVersion::latest(); + let (merk, _root, _full) = make_15_key_pcps(v); + // c..=l → indices 2..=11 → 10 keys + let expected_sum = expected_pcps_sum_slice(2, 11); + no_proof_matches_prover( + &merk, + QueryItem::RangeInclusive(b"c".to_vec()..=b"l".to_vec()), + 10, + expected_sum, + v, + ); +} + +#[test] +fn no_proof_combined_matches_prover_closed_range_exclusive() { + let v = GroveVersion::latest(); + let (merk, _root, _full) = make_15_key_pcps(v); + // c..l → indices 2..=10 → 9 keys + let expected_sum = expected_pcps_sum_slice(2, 10); + no_proof_matches_prover( + &merk, + QueryItem::Range(b"c".to_vec()..b"l".to_vec()), + 9, + expected_sum, + v, + ); +} + +#[test] +fn no_proof_combined_matches_prover_open_range_from() { + let v = GroveVersion::latest(); + let (merk, _root, _full) = make_15_key_pcps(v); + // c..o → indices 2..=14 → 13 keys + let expected_sum = expected_pcps_sum_slice(2, 14); + no_proof_matches_prover( + &merk, + QueryItem::RangeFrom(b"c".to_vec()..), + 13, + expected_sum, + v, + ); +} + +#[test] +fn no_proof_combined_matches_prover_range_after() { + // RangeAfter at the root pushes the left boundary exclusive to + // "b", exercising the right-child descent path of walk_count_and_sum. + let v = GroveVersion::latest(); + let (merk, _root, _full) = make_15_key_pcps(v); + // After "b" → indices 2..=14 → 13 keys + let expected_sum = expected_pcps_sum_slice(2, 14); + no_proof_matches_prover( + &merk, + QueryItem::RangeAfter(b"b".to_vec()..), + 13, + expected_sum, + v, + ); +} + +#[test] +fn no_proof_combined_matches_prover_range_to_inclusive() { + let v = GroveVersion::latest(); + let (merk, _root, _full) = make_15_key_pcps(v); + // ..=e → indices 0..=4 → 5 keys + let expected_sum = expected_pcps_sum_slice(0, 4); + no_proof_matches_prover( + &merk, + QueryItem::RangeToInclusive(..=b"e".to_vec()), + 5, + expected_sum, + v, + ); +} + +#[test] +fn no_proof_combined_matches_prover_range_below_all_keys() { + // Disjoint range: contributes (0, 0). + let v = GroveVersion::latest(); + let (merk, _root, _full) = make_15_key_pcps(v); + no_proof_matches_prover( + &merk, + QueryItem::RangeInclusive(vec![0x00]..=vec![0x10]), + 0, + 0, + v, + ); +} + +#[test] +fn no_proof_combined_matches_prover_full_range_returns_full_aggregate() { + // Full range: should return the entire stored count and sum. + let v = GroveVersion::latest(); + let (merk, _root, full_sum) = make_15_key_pcps(v); + no_proof_matches_prover( + &merk, + QueryItem::RangeInclusive(b"a".to_vec()..=b"o".to_vec()), + 15, + full_sum, + v, + ); +} + +#[test] +fn no_proof_combined_empty_merk_returns_zero_zero() { + let v = GroveVersion::latest(); + let merk = TempMerk::new_with_tree_type(v, TreeType::ProvableCountProvableSumTree); + let pair = merk + .count_and_sum_aggregate_on_range(&QueryItem::Range(b"a".to_vec()..b"z".to_vec()), v) + .unwrap() + .expect("count_and_sum_aggregate_on_range on empty merk should succeed"); + assert_eq!(pair, (0u64, 0i64)); +} + +#[test] +fn no_proof_combined_rejected_on_non_pcps_hosts() { + // The no-proof entry point must reject every non-PCPS tree type + // up front, mirroring the prover-side gate. + let v = GroveVersion::latest(); + let inner_range = QueryItem::Range(b"a".to_vec()..b"z".to_vec()); + for tt in [ + TreeType::NormalTree, + TreeType::SumTree, + TreeType::CountTree, + TreeType::CountSumTree, + TreeType::BigSumTree, + TreeType::ProvableSumTree, + TreeType::ProvableCountTree, + TreeType::ProvableCountSumTree, + ] { + let merk = TempMerk::new_with_tree_type(v, tt); + let err = merk + .count_and_sum_aggregate_on_range(&inner_range, v) + .unwrap() + .expect_err("must reject non-PCPS host"); + match err { + Error::InvalidProofError(msg) => { + assert!( + msg.contains("ProvableCountProvableSumTree"), + "expected PCPS-only message, got: {}", + msg + ); + } + other => panic!("expected InvalidProofError for {:?}, got {:?}", tt, other), + } + } +} + +#[test] +fn no_proof_combined_negative_sums_match_prover() { + // A PCPS tree with mixed positive and negative sum items exercises + // the signed own_sum subtraction in walk_count_and_sum (own value + // may be negative even when child structural sums are positive). + let v = GroveVersion::latest(); + let mut merk = TempMerk::new_with_tree_type(v, TreeType::ProvableCountProvableSumTree); + let entries: Vec<(Vec, Op)> = [(b'a', 50i64), (b'b', -100), (b'c', 30), (b'd', -50)] + .iter() + .map(|(k, s)| { + ( + vec![*k], + Op::Put(vec![0], ProvableCountedAndProvableSummedMerkNode(1, *s)), + ) + }) + .collect(); + merk.apply::<_, Vec<_>>(&entries, &[], None, v) + .unwrap() + .expect("apply mixed-sign PCPS entries"); + merk.commit(v); + + // Full range: count = 4, sum = 50 - 100 + 30 - 50 = -70. + no_proof_matches_prover(&merk, QueryItem::RangeFrom(b"a".to_vec()..), 4, -70, v); + // Subrange "b".."=c": count = 2, sum = -100 + 30 = -70. + no_proof_matches_prover( + &merk, + QueryItem::RangeInclusive(b"b".to_vec()..=b"c".to_vec()), + 2, + -70, + v, + ); +} + +// ---------- narrow_count_and_sum unit tests ---------- +// +// The narrowing helper isolates the defense-in-depth `(u128, i128) → +// (u64, i64)` arms so they can be exercised without scaffolding a +// corrupted merk. An honest walker never hits these arms because PCPS +// maintains every aggregate inside `(u64, i64)` at every level — but if +// the merk's in-memory state disagrees with its type contract (i.e., +// local corruption), the narrowing surfaces it as `CorruptedData`. + +#[test] +fn narrow_count_and_sum_happy_path_preserves_values() { + let cases: &[(u128, i64, i64)] = &[ + (0, 0, 0), + (1, 1, 1), + (u64::MAX as u128, i64::MAX, i64::MAX), + (u64::MAX as u128, i64::MIN, i64::MIN), + ]; + for (c_in, s_in, expected_sum) in cases { + let (c, s) = narrow_count_and_sum(*c_in, *s_in as i128).expect("in-range narrow"); + assert_eq!(c as u128, *c_in); + assert_eq!(s, *expected_sum); + } +} + +#[test] +fn narrow_count_and_sum_rejects_count_overflow() { + // count_u128 > u64::MAX → CorruptedData on the count axis. + let result = narrow_count_and_sum(u128::from(u64::MAX) + 1, 0); + match result { + Err(Error::CorruptedData(msg)) => assert!( + msg.contains("count overflowed u64"), + "unexpected error message: {msg}" + ), + other => panic!("expected CorruptedData for count overflow, got {:?}", other), + } +} + +#[test] +fn narrow_count_and_sum_rejects_sum_positive_overflow() { + // sum_i128 > i64::MAX → CorruptedData on the sum axis. + let result = narrow_count_and_sum(0, i128::from(i64::MAX) + 1); + match result { + Err(Error::CorruptedData(msg)) => assert!( + msg.contains("sum overflowed i64"), + "unexpected error message: {msg}" + ), + other => panic!( + "expected CorruptedData for sum positive overflow, got {:?}", + other + ), + } +} + +#[test] +fn narrow_count_and_sum_rejects_sum_negative_overflow() { + // sum_i128 < i64::MIN → CorruptedData on the sum axis. + let result = narrow_count_and_sum(0, i128::from(i64::MIN) - 1); + match result { + Err(Error::CorruptedData(msg)) => assert!( + msg.contains("sum overflowed i64"), + "unexpected error message: {msg}" + ), + other => panic!( + "expected CorruptedData for sum negative overflow, got {:?}", + other + ), + } +} diff --git a/merk/src/proofs/query/aggregate_count_and_sum/walk.rs b/merk/src/proofs/query/aggregate_count_and_sum/walk.rs new file mode 100644 index 000000000..3f1e60079 --- /dev/null +++ b/merk/src/proofs/query/aggregate_count_and_sum/walk.rs @@ -0,0 +1,215 @@ +//! No-proof walker: same classification logic as the proof emitter, but +//! returns the in-range `(count, sum)` pair without allocating proof +//! ops. Dual-axis sibling of +//! [`super::super::aggregate_count::walk::walk_count_only`] and +//! [`super::super::aggregate_sum::walk::walk_sum_only`]. + +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, +}; +use grovedb_version::version::GroveVersion; + +use super::provable_count_and_sum_from_aggregate; +use crate::{ + proofs::query::{ + aggregate_common::{classify_subtree, SubtreeClassification}, + QueryItem, + }, + tree::{kv::ValueDefinedCostType, Fetch, RefWalker}, + Error, +}; + +/// Read the `(count, sum)` provable aggregate off the walker's current +/// tree node. Shared error-mapping helper used by [`walk_count_and_sum`] +/// at both the Contained-leaf and Boundary positions. +fn provable_count_and_sum_from_walker(walker: &RefWalker<'_, S>) -> Result<(u64, i64), Error> +where + S: Fetch + Sized + Clone, +{ + let aggregate = walker + .tree() + .aggregate_data() + .map_err(|e| Error::CorruptedData(format!("aggregate_data: {}", e)))?; + provable_count_and_sum_from_aggregate(aggregate) +} + +/// No-proof variant of [`super::emit::emit_count_and_sum_proof`]: +/// walks the same classification path (Contained / Disjoint / +/// Boundary) but only returns the running in-range `(count, sum)` +/// pair. +/// +/// At entry, `subtree_lo_excl` / `subtree_hi_excl` are the inherited +/// exclusive key bounds for the subtree this walker points at (both +/// `None` at the root call). The walk reads each node's +/// `aggregate_data()` and each child link's +/// `aggregate_data().as_count_u64()` / `as_sum_i64()` exactly the same +/// way the proof emitter does, so the returned pair is identical to +/// the `(count, sum)` tuple `create_aggregate_count_and_sum_on_range_proof` +/// returns. +/// +/// The sum accumulator is `i128` so the no-proof side never overflows +/// mid-walk on adversarial intermediate sums (matching the prover's +/// guarantee). The count accumulator is `u128` for the same reason: +/// node-aggregate counts are `u64` per node, but the sum across +/// adversarial intermediate states could theoretically wrap; widening +/// keeps the walk consistent. Narrowing to `(u64, i64)` happens in the +/// public entry point `Merk::count_and_sum_aggregate_on_range`. +pub(super) fn walk_count_and_sum( + walker: &mut RefWalker<'_, S>, + range: &QueryItem, + subtree_lo_excl: Option<&[u8]>, + subtree_hi_excl: Option<&[u8]>, + grove_version: &GroveVersion, +) -> CostResult<(u128, i128), Error> +where + S: Fetch + Sized + Clone, +{ + let mut cost = OperationCost::default(); + + match classify_subtree(subtree_lo_excl, subtree_hi_excl, range) { + // Disjoint: subtree contributes 0 on both axes. + SubtreeClassification::Disjoint => Ok((0u128, 0i128)).wrap_with_cost(cost), + // Contained: subtree contributes its full stored aggregate + // count and sum. NotSummed / NonCounted wrapper variants are + // rejected as parents of PCPS (see the PCPS parent-shape gate), + // so the only way a Contained PCPS subtree contributes 0 is + // if it actually holds no in-range entries. + SubtreeClassification::Contained => { + let (count, sum) = + cost_return_on_error_no_add!(cost, provable_count_and_sum_from_walker(walker)); + Ok((count as u128, sum as i128)).wrap_with_cost(cost) + } + // Boundary: descend into both children and add own contribution. + SubtreeClassification::Boundary => { + // Snapshot what we need from the current node before walking. + // walk(...) takes &mut self.tree, so we must drop any existing + // borrows on walker.tree() before calling it. + let node_key: Vec = walker.tree().key().to_vec(); + let (node_count, node_sum) = + cost_return_on_error_no_add!(cost, provable_count_and_sum_from_walker(walker)); + let left_link_count: u64 = walker + .tree() + .link(true) + .map(|l| l.aggregate_data().as_count_u64()) + .unwrap_or(0); + let left_link_sum: i64 = walker + .tree() + .link(true) + .map(|l| l.aggregate_data().as_sum_i64()) + .unwrap_or(0); + let right_link_count: u64 = walker + .tree() + .link(false) + .map(|l| l.aggregate_data().as_count_u64()) + .unwrap_or(0); + let right_link_sum: i64 = walker + .tree() + .link(false) + .map(|l| l.aggregate_data().as_sum_i64()) + .unwrap_or(0); + let left_link_present = walker.tree().link(true).is_some(); + let right_link_present = walker.tree().link(false).is_some(); + + let mut total_count: u128 = 0; + let mut total_sum: i128 = 0; + + // LEFT child. If link is Some, walk(true) must yield Some; + // the proof variant has the verifier to catch silent + // inconsistencies, but this no-proof path returns the pair + // straight to the caller — so we fail loudly on impossible + // state rather than silently under-aggregating. + if left_link_present { + let walked = cost_return_on_error!( + &mut cost, + walker.walk( + true, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + ); + let mut left_walker = match walked { + Some(lw) => lw, + None => { + return Err(Error::CorruptedState( + "tree.link(true) was Some but walk(true) returned None", + )) + .wrap_with_cost(cost); + } + }; + let (c, s) = cost_return_on_error!( + &mut cost, + walk_count_and_sum( + &mut left_walker, + range, + subtree_lo_excl, + Some(node_key.as_slice()), + grove_version, + ) + ); + total_count = total_count.saturating_add(c); + total_sum = total_sum.saturating_add(s); + } + + // Current node's own contribution. Both axes derive + // `own = node_aggregate − left_struct − right_struct`. + // For the count axis we use `checked_sub` since children + // claiming more keys than the parent is corrupted state + // (mirrors aggregate_count's walker). For the sum axis the + // arithmetic is signed and the same node can legitimately + // produce a negative own_sum (e.g. positive children plus + // a negative own value); we widen to i128 and use + // `wrapping_sub` purely to satisfy the type checker — the + // hash chain in the verifying variant catches tampering; + // here we trust the merk read path per the API contract. + if range.contains(&node_key) { + let own_count = node_count + .checked_sub(left_link_count) + .and_then(|n| n.checked_sub(right_link_count)) + .ok_or(Error::CorruptedState( + "child structural counts exceed parent's aggregate count", + )); + let own_count = cost_return_on_error_no_add!(cost, own_count); + let own_sum: i128 = (node_sum as i128) + .wrapping_sub(left_link_sum as i128) + .wrapping_sub(right_link_sum as i128); + total_count = total_count.saturating_add(own_count as u128); + total_sum = total_sum.saturating_add(own_sum); + } + + // RIGHT child — same fail-fast pattern as LEFT. + if right_link_present { + let walked = cost_return_on_error!( + &mut cost, + walker.walk( + false, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + ); + let mut right_walker = match walked { + Some(rw) => rw, + None => { + return Err(Error::CorruptedState( + "tree.link(false) was Some but walk(false) returned None", + )) + .wrap_with_cost(cost); + } + }; + let (c, s) = cost_return_on_error!( + &mut cost, + walk_count_and_sum( + &mut right_walker, + range, + Some(node_key.as_slice()), + subtree_hi_excl, + grove_version, + ) + ); + total_count = total_count.saturating_add(c); + total_sum = total_sum.saturating_add(s); + } + + Ok((total_count, total_sum)).wrap_with_cost(cost) + } + } +}