From 7d36edc0cea84b379d8e041f062913a5976cf223 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 22 Aug 2026 15:00:48 +0700 Subject: [PATCH] feat: keys-only projection for axis reads on the unified PathQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_path_query served every ReadMode::Axis read through the resolving indexed-axis reads, so the unified path had no way to ask for the ranking pairs alone: a caller that only ranks paid up to k primary point reads per page — through its own transaction, after the pinned secondary page was collected, so outside the iterator's view when that transaction is None — for values it discards. #824 gave the standalone API a keys-only shape; this gives the PathQuery vocabulary the same. AxisQuery gains `projection: AxisProjection { Entries (default), Keys }`, encoded as a trailing frozen tag byte; `validate` rejects Keys on the traversals that list no entries (rank-of-key, value-range aggregates). run_path_query routes a Keys read through the _keys reads and returns AxisKeys / BranchedAxisKeys (absent branches None, as for entries); AxisEntries::to_keys is the projection. The projection is an unproved-read choice: a proof always carries the values and verification yields entries, so prover and verifier treat Keys exactly as Entries — a keys read is a strict projection of the verified page, which a test pins. PathQuery::new_axis(path, axis_query) builds a read from an already-configured AxisQuery. Tests: keys == entries projected on every axis, both directions, with and without an offset, for ranked-page and bounded traversals; branched keys == branched entries projected including absent branches; keys == verified entries projected; fewer seeks and loaded bytes; rejection on non-listing traversals; projection round-trips with a frozen tag and an unknown tag is rejected. Co-Authored-By: Claude Fable 5 --- docs/book/src/unified-path-query.md | 14 + grovedb-query/src/axis_query.rs | 153 ++++++++- grovedb-query/src/lib.rs | 4 +- grovedb/src/lib.rs | 2 +- grovedb/src/operations/get/run_path_query.rs | 196 ++++++++++- .../operations/proof/indexed_axis/envelope.rs | 12 +- grovedb/src/query/mod.rs | 8 + grovedb/src/query_result_type.rs | 31 ++ .../src/tests/axis_read_projection_tests.rs | 320 ++++++++++++++++++ grovedb/src/tests/mod.rs | 1 + 10 files changed, 723 insertions(+), 18 deletions(-) create mode 100644 grovedb/src/tests/axis_read_projection_tests.rs diff --git a/docs/book/src/unified-path-query.md b/docs/book/src/unified-path-query.md index 2ea9e878d..b037236cc 100644 --- a/docs/book/src/unified-path-query.md +++ b/docs/book/src/unified-path-query.md @@ -49,6 +49,7 @@ pub struct AxisQuery { pub axis: IndexAxis, // Count = 0 | Sum = 1 | Avg = 2 pub traversal: AxisTraversal, pub descending: bool, + pub projection: AxisProjection, // Entries = 0 (default) | Keys = 1 } pub enum AxisTraversal { @@ -66,6 +67,19 @@ pub enum AxisTraversal { `RankedPage` is directional: `descending: true` reads it as top-k, `false` as bottom-k — one wire shape, both leaderboard ends. +`projection` is an **unproved-read** choice for the two entry-listing +traversals. `Entries` (the default) returns each entry with its +resolved primary value; `Keys` returns the `(ordering_value, +original_key)` pairs straight from the pinned secondary view and never +opens the primary — no primary point reads after the page was +collected (which, through a caller-supplied `None` transaction, would +sit outside the iterator's view), and no reads for values a caller +that only ranks would discard. A proof always carries the values, and +verification yields entries; keys are a strict projection of them, so +the prover and verifier treat a `Keys` query exactly as `Entries`. +`run_path_query` returns `AxisKeys` / `BranchedAxisKeys` for a `Keys` +read. + `AggregateOverValueRange` makes the caller SAY which scalar they mean, because both readings are meaningful on both axes and the "obvious" one flips per axis. `[lo, hi]` selects entries by their own axis diff --git a/grovedb-query/src/axis_query.rs b/grovedb-query/src/axis_query.rs index b89a11f7b..4e9a0689a 100644 --- a/grovedb-query/src/axis_query.rs +++ b/grovedb-query/src/axis_query.rs @@ -340,6 +340,59 @@ impl<'de, Context> BorrowDecode<'de, Context> for AxisTraversal { } } +/// What an entry-listing axis read ([`AxisTraversal::RankedPage`], +/// [`AxisTraversal::Bounded`]) returns for each entry. +/// +/// This is an **unproved-read** choice. A proof always carries the +/// referenced primary values (the axis descent emits reference-aware +/// nodes and the verifier authenticates them), and verification yields +/// entries; keys are a strict projection of those, so a prover and a +/// verifier handed a `Keys` query behave exactly as for `Entries`. What +/// the projection changes is `run_path_query`: a `Keys` read returns the +/// ranking pairs straight from the pinned secondary view and never opens +/// the primary — no primary point reads after the page was collected +/// (which, through a caller-supplied `None` transaction, would sit +/// outside the iterator's view), and no reads for values the caller was +/// going to discard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AxisProjection { + /// The ranking pair **and** the resolved primary value per entry + /// (`IndexedAxisEntry`). The default. + #[default] + Entries, + /// The ranking pair only: `(ordering_value, original_key)`. + Keys, +} + +impl AxisProjection { + /// Frozen wire tag. + pub const fn tag(self) -> u8 { + match self { + AxisProjection::Entries => 0, + AxisProjection::Keys => 1, + } + } + + /// Decode a wire tag; `Err` carries the unknown byte. + pub const fn try_from_tag(b: u8) -> Result { + match b { + 0 => Ok(AxisProjection::Entries), + 1 => Ok(AxisProjection::Keys), + other => Err(other), + } + } +} + +impl fmt::Display for AxisProjection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AxisProjection::Entries => write!(f, "entries"), + AxisProjection::Keys => write!(f, "keys"), + } + } +} + /// What to read from one indexed tree's per-axis secondary: which axis, /// how to walk it, and in which direction. The axis-ordered counterpart /// of a key-selecting [`Query`](crate::Query). @@ -364,13 +417,17 @@ pub struct AxisQuery { /// directional scan over `sort_key ‖ original_key`, not a separate /// rule. pub descending: bool, + /// Whether an entry-listing read returns entries (with primary + /// values) or ranking pairs only. See [`AxisProjection`]. + pub projection: AxisProjection, } impl Encode for AxisQuery { fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { self.axis.tag().encode(encoder)?; self.traversal.encode(encoder)?; - self.descending.encode(encoder) + self.descending.encode(encoder)?; + self.projection.tag().encode(encoder) } } @@ -379,10 +436,15 @@ impl Decode for AxisQuery { let tag = u8::decode(decoder)?; let axis = IndexAxis::try_from_tag(tag) .map_err(|_| DecodeError::Other("unknown index axis tag"))?; + let traversal = AxisTraversal::decode(decoder)?; + let descending = bool::decode(decoder)?; + let projection = AxisProjection::try_from_tag(u8::decode(decoder)?) + .map_err(|_| DecodeError::Other("unknown axis projection tag"))?; Ok(Self { axis, - traversal: AxisTraversal::decode(decoder)?, - descending: bool::decode(decoder)?, + traversal, + descending, + projection, }) } } @@ -407,6 +469,7 @@ impl AxisQuery { axis, traversal: AxisTraversal::RankedPage { k, offset }, descending, + projection: AxisProjection::Entries, } } @@ -423,6 +486,7 @@ impl AxisQuery { axis, traversal: AxisTraversal::RankedPage { k, offset }, descending: false, + projection: AxisProjection::Entries, } } @@ -438,6 +502,7 @@ impl AxisQuery { axis, traversal: AxisTraversal::Bounded { lo, hi, limit }, descending, + projection: AxisProjection::Entries, } } @@ -447,6 +512,7 @@ impl AxisQuery { axis, traversal: AxisTraversal::RankOfKey { key }, descending, + projection: AxisProjection::Entries, } } @@ -465,6 +531,7 @@ impl AxisQuery { axis, traversal: AxisTraversal::AggregateOverValueRange { lo, hi, fold }, descending: false, + projection: AxisProjection::Entries, } } @@ -519,6 +586,18 @@ impl AxisQuery { self.validate_bounds(*lo, *hi)?; } } + if self.projection == AxisProjection::Keys + && !matches!( + self.traversal, + AxisTraversal::RankedPage { .. } | AxisTraversal::Bounded { .. } + ) + { + return Err(Error::InvalidOperation( + "axis query: the keys projection applies to entry-listing traversals (ranked \ + page, bounded); rank-of-key and value-range aggregates return no entries to \ + project", + )); + } Ok(()) } @@ -551,6 +630,18 @@ impl AxisQuery { } } + /// The same read with the given [`AxisProjection`]. + pub const fn with_projection(mut self, projection: AxisProjection) -> Self { + self.projection = projection; + self + } + + /// The same read returning ranking pairs only — see + /// [`AxisProjection::Keys`]. + pub const fn keys_only(self) -> Self { + self.with_projection(AxisProjection::Keys) + } + /// The number of entries this query can return, when that is a /// fixed property of the traversal (`None` for /// [`AxisTraversal::AggregateOverValueRange`], which returns one scalar, not @@ -591,14 +682,15 @@ impl fmt::Display for AxisQuery { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "AxisQuery {{ axis: {:?}, {}, {} }}", + "AxisQuery {{ axis: {:?}, {}, {}, {} }}", self.axis, self.traversal, if self.descending { "descending" } else { "ascending" - } + }, + self.projection ) } } @@ -662,6 +754,7 @@ mod tests { axis, traversal: traversal.clone(), descending, + projection: AxisProjection::Entries, }; let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); let (decoded, consumed): (AxisQuery, usize) = @@ -687,6 +780,56 @@ mod tests { assert_eq!(bytes[0], 2); } + #[test] + fn projection_round_trips_and_its_tag_is_frozen() { + let entries = AxisQuery::top_k(IndexAxis::Count, 3, 0, true); + let keys = entries.clone().keys_only(); + assert_eq!(entries.projection, AxisProjection::Entries); + assert_eq!(keys.projection, AxisProjection::Keys); + for q in [entries, keys] { + let bytes = bincode::encode_to_vec(&q, config::standard()).unwrap(); + // The projection tag is the last byte of the encoding. + assert_eq!(*bytes.last().unwrap(), q.projection.tag()); + let (decoded, consumed): (AxisQuery, usize) = + bincode::decode_from_slice(&bytes, config::standard()).unwrap(); + assert_eq!(consumed, bytes.len()); + assert_eq!(decoded, q); + } + assert_eq!(AxisProjection::Entries.tag(), 0); + assert_eq!(AxisProjection::Keys.tag(), 1); + assert_eq!(AxisProjection::try_from_tag(2), Err(2)); + // An unknown projection byte is rejected, not defaulted. + let mut bytes = bincode::encode_to_vec( + AxisQuery::top_k(IndexAxis::Count, 1, 0, true), + config::standard(), + ) + .unwrap(); + *bytes.last_mut().unwrap() = 7; + assert!(bincode::decode_from_slice::(&bytes, config::standard()).is_err()); + } + + #[test] + fn keys_projection_is_rejected_on_non_listing_traversals() { + assert!(AxisQuery::top_k(IndexAxis::Sum, 2, 0, true) + .keys_only() + .validate() + .is_ok()); + assert!(AxisQuery::bounded(IndexAxis::Sum, 0, 10, 5, false) + .keys_only() + .validate() + .is_ok()); + assert!(AxisQuery::rank_of_key(IndexAxis::Sum, vec![1], true) + .keys_only() + .validate() + .is_err()); + assert!( + AxisQuery::aggregate_over_value_range(IndexAxis::Sum, 0, 10, AggregateFold::Total) + .keys_only() + .validate() + .is_err() + ); + } + #[test] fn decode_rejects_unknown_tags_and_oversized_rank_key() { // Unknown traversal tag. diff --git a/grovedb-query/src/lib.rs b/grovedb-query/src/lib.rs index 89632c1e7..c57e475f2 100644 --- a/grovedb-query/src/lib.rs +++ b/grovedb-query/src/lib.rs @@ -57,7 +57,9 @@ mod subquery_branch; mod terminal_keys; pub use aggregate_sum_query::AggregateSumQuery; -pub use axis_query::{AggregateFold, AxisQuery, AxisTraversal, IndexAxis, UnknownAxisTag}; +pub use axis_query::{ + AggregateFold, AxisProjection, AxisQuery, AxisTraversal, IndexAxis, UnknownAxisTag, +}; pub use proof_items::ProofItems; pub use proof_status::ProofStatus; pub use query::Query; diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index 297b894f5..f6a9fb501 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -260,7 +260,7 @@ pub use query::{ PathTrunkChunkQuery, SizedQuery, }; #[cfg(any(feature = "minimal", feature = "verify"))] -pub use query_result_type::{IndexedAxisEntry, IndexedAxisEntrySliceExt}; +pub use query_result_type::{AxisKeys, IndexedAxisEntry, IndexedAxisEntrySliceExt}; #[cfg(feature = "minimal")] use reference_path::path_from_reference_path_type; #[cfg(feature = "grovedbg")] diff --git a/grovedb/src/operations/get/run_path_query.rs b/grovedb/src/operations/get/run_path_query.rs index ae9b56a12..eba1561c4 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::{AggregateFold, AxisTraversal, IndexAxis}; +use grovedb_merk::proofs::query::{AggregateFold, AxisProjection, AxisTraversal, IndexAxis}; use grovedb_path::SubtreePath; use grovedb_version::{ check_grovedb_v0_with_cost, error::GroveVersionError, version::GroveVersion, @@ -37,6 +37,7 @@ use crate::{ element::aggregate_sum_query::AggregateSumQueryResult, operations::proof::indexed_axis::AxisEntries, query::{AggregateKind, PathQueryShape}, + query_result_type::AxisKeys, query_result_type::{QueryResultElements, QueryResultType}, AggregateSumPathQuery, Error, GroveDb, PathQuery, TransactionArg, }; @@ -91,6 +92,14 @@ pub enum PathQueryRun { /// level (mirroring the branched proof's authenticated-absence /// slots, minus the authentication). BranchedAxisEntries(Vec<(Vec, Option)>), + /// Single-path axis read with `AxisProjection::Keys`: the ranking + /// pairs in walk order, read straight from the pinned secondary + /// view; no primary value resolved. + AxisKeys(AxisKeys), + /// Branched axis read with `AxisProjection::Keys`: per branch key, + /// in query order, the ranking pairs — or `None` for an absent + /// branch, exactly as [`Self::BranchedAxisEntries`]. + BranchedAxisKeys(Vec<(Vec, Option)>), /// `RankOfKey` traversal: the item's 0-based rank in the walk. AxisRank(u64), /// `AggregateOverValueRange` traversal: one scalar over the value range. @@ -252,7 +261,9 @@ impl GroveDb { .iter() .map(|segment| segment.as_slice()) .collect(); + let keys_projection = axis.projection == AxisProjection::Keys; let mut branches = Vec::with_capacity(branch_items.len()); + let mut key_branches = Vec::with_capacity(branch_items.len()); for item in branch_items { let grovedb_merk::proofs::query::query_item::QueryItem::Key(branch_key) = item else { @@ -293,7 +304,11 @@ impl GroveDb { resolved.push(segment); } if chain_broken { - branches.push((branch_key.clone(), None)); + if keys_projection { + key_branches.push((branch_key.clone(), None)); + } else { + branches.push((branch_key.clone(), None)); + } continue; } let full_path = resolved; @@ -301,15 +316,26 @@ impl GroveDb { &mut cost, self.run_axis_read(full_path.as_slice(), axis, transaction, grove_version) ); - let PathQueryRun::AxisEntries(entries) = run else { - return Err(Error::CorruptedCodeExecution( - "branched axis read requires an entry-listing traversal", - )) - .wrap_with_cost(cost); - }; - branches.push((branch_key.clone(), Some(entries))); + match run { + PathQueryRun::AxisEntries(entries) if !keys_projection => { + branches.push((branch_key.clone(), Some(entries))); + } + PathQueryRun::AxisKeys(keys) if keys_projection => { + key_branches.push((branch_key.clone(), Some(keys))); + } + _ => { + return Err(Error::CorruptedCodeExecution( + "branched axis read requires an entry-listing traversal", + )) + .wrap_with_cost(cost); + } + } + } + if keys_projection { + Ok(PathQueryRun::BranchedAxisKeys(key_branches)).wrap_with_cost(cost) + } else { + Ok(PathQueryRun::BranchedAxisEntries(branches)).wrap_with_cost(cost) } - Ok(PathQueryRun::BranchedAxisEntries(branches)).wrap_with_cost(cost) } PathQueryShape::SumBudget { budget, items } => { use grovedb_merk::proofs::query::AggregateSumQuery; @@ -359,9 +385,25 @@ impl GroveDb { let mut cost = Default::default(); let axis = axis_query.axis; let descending = axis_query.descending; + let keys_only = axis_query.projection == AxisProjection::Keys; match &axis_query.traversal { AxisTraversal::RankedPage { k, offset } => { + if keys_only { + let keys = cost_return_on_error!( + &mut cost, + self.axis_top_k_paginated_keys( + path, + axis, + *k, + *offset, + descending, + transaction, + grove_version + ) + ); + return Ok(PathQueryRun::AxisKeys(keys)).wrap_with_cost(cost); + } let entries = cost_return_on_error!( &mut cost, self.axis_top_k_paginated_entries( @@ -377,6 +419,22 @@ impl GroveDb { Ok(PathQueryRun::AxisEntries(entries)).wrap_with_cost(cost) } AxisTraversal::Bounded { lo, hi, limit } => { + if keys_only { + let keys = cost_return_on_error!( + &mut cost, + self.axis_bounded_keys( + path, + axis, + *lo, + *hi, + *limit, + descending, + transaction, + grove_version + ) + ); + return Ok(PathQueryRun::AxisKeys(keys)).wrap_with_cost(cost); + } let entries = cost_return_on_error!( &mut cost, self.axis_bounded_entries( @@ -595,6 +653,124 @@ impl GroveDb { } } +impl GroveDb { + /// TopK dispatch across the three axes for the keys projection — + /// the `_keys` reads, which never open the primary. + #[allow(clippy::too_many_arguments)] + fn axis_top_k_paginated_keys( + &self, + path: &[&[u8]], + axis: IndexAxis, + k: u16, + offset: u64, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + let keys = match axis { + IndexAxis::Count => AxisKeys::Count(cost_return_on_error!( + &mut cost, + self.indexed_count_top_k_paginated_keys( + path, + k, + offset, + descending, + transaction, + grove_version + ) + .map_ok(|page| page.entries) + )), + IndexAxis::Sum => AxisKeys::Sum(cost_return_on_error!( + &mut cost, + self.indexed_sum_top_k_paginated_keys( + path, + k, + offset, + descending, + transaction, + grove_version + ) + .map_ok(|page| page.entries) + )), + IndexAxis::Avg => AxisKeys::Avg(cost_return_on_error!( + &mut cost, + self.indexed_avg_top_k_paginated_keys( + path, + k, + offset, + descending, + transaction, + grove_version + ) + .map_ok(|page| page.entries) + )), + }; + Ok(keys).wrap_with_cost(cost) + } + + /// Bounded dispatch across the three axes for the keys projection. + #[allow(clippy::too_many_arguments)] + fn axis_bounded_keys( + &self, + path: &[&[u8]], + axis: IndexAxis, + lo: i128, + hi: i128, + limit: u16, + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + let keys = match axis { + IndexAxis::Count => { + let (lo_count, hi_count) = clamp_count_bounds(lo, hi); + AxisKeys::Count(cost_return_on_error!( + &mut cost, + self.indexed_count_range_keys( + path, + lo_count, + hi_count, + descending, + limit, + transaction, + grove_version + ) + )) + } + IndexAxis::Sum => { + let (lo_sum, hi_sum) = clamp_sum_bounds(lo, hi); + AxisKeys::Sum(cost_return_on_error!( + &mut cost, + self.indexed_sum_range_keys( + path, + lo_sum, + hi_sum, + descending, + limit, + transaction, + grove_version + ) + )) + } + IndexAxis::Avg => AxisKeys::Avg(cost_return_on_error!( + &mut cost, + self.indexed_avg_range_keys( + path, + lo, + hi, + descending, + limit, + transaction, + grove_version + ) + )), + }; + Ok(keys).wrap_with_cost(cost) + } +} + /// Clamp inclusive `i128` bounds into the count axis's `u64` domain. /// Callers have already rejected wholly-out-of-domain ranges, so the /// clamped pair still satisfies `lo <= hi`. diff --git a/grovedb/src/operations/proof/indexed_axis/envelope.rs b/grovedb/src/operations/proof/indexed_axis/envelope.rs index 2227094bc..ab032b36f 100644 --- a/grovedb/src/operations/proof/indexed_axis/envelope.rs +++ b/grovedb/src/operations/proof/indexed_axis/envelope.rs @@ -275,7 +275,6 @@ impl AxisEntries { } } - /// Whether the result list is empty. /// An empty entry list of the right variant for `axis`. pub fn empty_for_axis(axis: grovedb_element::indexed::IndexAxis) -> Self { match axis { @@ -299,6 +298,17 @@ impl AxisEntries { pub fn is_empty(&self) -> bool { self.len() == 0 } + + /// The keys-only projection of these entries — what an + /// `AxisProjection::Keys` read of the same page returns. + pub fn to_keys(&self) -> crate::query_result_type::AxisKeys { + use crate::query_result_type::{AxisKeys, IndexedAxisEntrySliceExt}; + match self { + AxisEntries::Count(v) => AxisKeys::Count(v.key_pairs()), + AxisEntries::Sum(v) => AxisKeys::Sum(v.key_pairs()), + AxisEntries::Avg(v) => AxisKeys::Avg(v.key_pairs()), + } + } } /// Verified result of a range / top-k / arbitrary-query proof. diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index 1bdfcc43c..0585ab8e1 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -568,6 +568,14 @@ impl PathQuery { } } + /// An axis-ordered read of the indexed tree at `path`, from an + /// already-built [`AxisQuery`] — use it to set a non-default + /// projection (`AxisQuery::keys_only`); the typed constructors below + /// cover the default cases. + pub fn new_axis(path: Vec>, axis_query: AxisQuery) -> Self { + Self::new_unsized(path, Self::axis_read_node(axis_query)) + } + /// An axis-ordered read of the indexed tree at `path`: a page of /// `k` entries on `axis`, starting at rank `offset` (0 = first /// page). diff --git a/grovedb/src/query_result_type.rs b/grovedb/src/query_result_type.rs index f63ffcc56..ef872fa23 100644 --- a/grovedb/src/query_result_type.rs +++ b/grovedb/src/query_result_type.rs @@ -64,6 +64,37 @@ impl IndexedAxisEntrySliceExt for [IndexedAxisEntry] { } } +/// The keys-only projection of an entry-listing axis read: the +/// `(ordering_value, original_key)` pairs of one axis, in walk order, +/// with no primary value resolved. What `run_path_query` returns for an +/// `AxisProjection::Keys` read; a strict projection of the matching +/// entries (`AxisEntries::to_keys`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AxisKeys { + /// Count axis: `(count, original_key)` pairs. + Count(Vec<(u64, Vec)>), + /// Sum axis: `(sum, original_key)` pairs. + Sum(Vec<(i64, Vec)>), + /// Avg axis: `(fixed-point average, original_key)` pairs. + Avg(Vec<(i128, Vec)>), +} + +impl AxisKeys { + /// Number of pairs. + pub fn len(&self) -> usize { + match self { + AxisKeys::Count(v) => v.len(), + AxisKeys::Sum(v) => v.len(), + AxisKeys::Avg(v) => v.len(), + } + } + + /// Whether the page is empty. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + use grovedb_version::{version::GroveVersion, TryFromVersioned}; use crate::element::SumValue; diff --git a/grovedb/src/tests/axis_read_projection_tests.rs b/grovedb/src/tests/axis_read_projection_tests.rs new file mode 100644 index 000000000..e729a819b --- /dev/null +++ b/grovedb/src/tests/axis_read_projection_tests.rs @@ -0,0 +1,320 @@ +//! `AxisProjection::Keys` on the unified PathQuery: `run_path_query` +//! returns the ranking pairs straight from the pinned secondary view +//! (no primary value resolved), as a strict projection of what the +//! `Entries` read — and the proof — return. + +#[cfg(test)] +mod tests { + use grovedb_merk::proofs::query::{AggregateFold, AxisProjection, AxisQuery, IndexAxis}; + use grovedb_version::version::GroveVersion; + + use crate::{ + operations::{ + get::PathQueryRun, + proof::{indexed_axis::AxisEntries, VerifiedPathQuery}, + }, + query_result_type::{AxisKeys, QueryResultType}, + tests::{make_test_grovedb, TEST_LEAF}, + Element, GroveDb, PathQuery, + }; + + const PCPSIT: &[u8] = b"pcpsit"; + + /// A three-axis (count, sum, avg) indexed tree with `(key, sum)` + /// entries. + fn build(db: &GroveDb, gv: &GroveVersion, entries: &[(&[u8], i64)]) { + let axes: Vec<(u8, Option>)> = vec![ + (IndexAxis::Count.tag(), None), + (IndexAxis::Sum.tag(), None), + (IndexAxis::Avg.tag(), None), + ]; + db.insert( + [TEST_LEAF].as_ref(), + PCPSIT, + Element::empty_provable_count_provable_sum_indexed_tree(axes).expect("canonical axes"), + None, + None, + gv, + ) + .unwrap() + .expect("create pcpsit"); + for (key, sum) in entries { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, PCPSIT].as_ref(), + key, + Element::new_item_with_sum_item(b"v".to_vec(), *sum), + None, + gv, + ) + .unwrap() + .expect("insert entry"); + } + } + + /// Two branch trees `[TEST_LEAF, branch, "scores"]` (PSITs); a third + /// branch key is left absent. + fn build_branched(db: &GroveDb, gv: &GroveVersion, branches: &[(&[u8], &[(&[u8], i64)])]) { + for (branch, entries) in branches { + db.insert( + [TEST_LEAF].as_ref(), + branch, + Element::empty_tree(), + None, + None, + gv, + ) + .unwrap() + .expect("create branch tree"); + db.insert( + [TEST_LEAF, branch].as_ref(), + b"scores", + Element::empty_provable_sum_indexed_tree(), + None, + None, + gv, + ) + .unwrap() + .expect("create branch PSIT"); + for (k, s) in *entries { + db.insert_into_provable_sum_indexed_tree( + [TEST_LEAF, branch, b"scores".as_slice()].as_ref(), + k, + Element::new_sum_item(*s), + None, + gv, + ) + .unwrap() + .expect("insert branch entry"); + } + } + } + + fn path() -> Vec> { + vec![TEST_LEAF.to_vec(), PCPSIT.to_vec()] + } + + fn run(db: &GroveDb, pq: &PathQuery, gv: &GroveVersion) -> PathQueryRun { + db.run_path_query( + pq, + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + gv, + ) + .unwrap() + .expect("run_path_query") + } + + fn entries_of(run: PathQueryRun) -> AxisEntries { + match run { + PathQueryRun::AxisEntries(e) => e, + other => panic!("expected AxisEntries, got {other:?}"), + } + } + + fn keys_of(run: PathQueryRun) -> AxisKeys { + match run { + PathQueryRun::AxisKeys(k) => k, + other => panic!("expected AxisKeys, got {other:?}"), + } + } + + /// Single-path: the keys read equals the entries read projected, on + /// every axis, both directions, with and without an offset, for + /// both entry-listing traversals. + #[test] + fn keys_projection_equals_entries_projected_on_every_axis() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build( + &db, + gv, + &[(b"a", 50), (b"b", 10), (b"c", 30), (b"d", 20), (b"e", 40)], + ); + + for axis in [IndexAxis::Count, IndexAxis::Sum, IndexAxis::Avg] { + for descending in [true, false] { + for offset in [0u64, 2] { + let entries_q = AxisQuery::top_k(axis, 2, offset, descending); + let keys_q = entries_q.clone().keys_only(); + let entries = entries_of(run(&db, &PathQuery::new_axis(path(), entries_q), gv)); + let keys = keys_of(run(&db, &PathQuery::new_axis(path(), keys_q), gv)); + assert_eq!( + keys, + entries.to_keys(), + "{axis:?} descending={descending} offset={offset}" + ); + assert_eq!(keys.len(), entries.len()); + } + let entries_q = AxisQuery::bounded(axis, i128::MIN, i128::MAX, 3, descending); + let keys_q = entries_q.clone().keys_only(); + let entries = entries_of(run(&db, &PathQuery::new_axis(path(), entries_q), gv)); + let keys = keys_of(run(&db, &PathQuery::new_axis(path(), keys_q), gv)); + assert_eq!( + keys, + entries.to_keys(), + "{axis:?} bounded descending={descending}" + ); + } + } + } + + /// Keys is a strict projection of what the proof authenticates: the + /// prover and verifier treat a `Keys` query as `Entries`, and the + /// unproved keys read equals the verified entries projected. + #[test] + fn keys_projection_agrees_with_the_verified_entries() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build(&db, gv, &[(b"a", 50), (b"b", 10), (b"c", 30)]); + let keys_pq = PathQuery::new_axis( + path(), + AxisQuery::top_k(IndexAxis::Sum, 2, 0, true).keys_only(), + ); + + let keys = keys_of(run(&db, &keys_pq, gv)); + let proof = db.prove_query(&keys_pq, None, gv).unwrap().expect("prove"); + let VerifiedPathQuery::AxisEntries { + root_hash, entries, .. + } = GroveDb::verify_path_query(&proof, &keys_pq, gv).expect("verify") + else { + panic!("expected AxisEntries from verification"); + }; + assert_eq!(keys, entries.to_keys()); + assert_eq!( + root_hash, + db.root_hash(None, gv).unwrap().expect("root hash"), + "a keys-projected query proves and verifies like an entries query" + ); + } + + /// Branched: per branch, the keys read equals the entries read + /// projected, and an absent branch is `None` in both. + #[test] + fn branched_keys_projection_mirrors_branched_entries_including_absence() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_branched( + &db, + gv, + &[ + (b"alice", &[(b"m1", 10), (b"m2", 30)]), + (b"carol", &[(b"m1", 7)]), + ], + ); + let branch_keys = vec![b"alice".to_vec(), b"bob".to_vec(), b"carol".to_vec()]; + let entries_pq = PathQuery::new_branched_axis( + vec![TEST_LEAF.to_vec()], + branch_keys.clone(), + vec![b"scores".to_vec()], + AxisQuery::top_k(IndexAxis::Sum, 2, 0, true), + ); + let keys_pq = PathQuery::new_branched_axis( + vec![TEST_LEAF.to_vec()], + branch_keys.clone(), + vec![b"scores".to_vec()], + AxisQuery::top_k(IndexAxis::Sum, 2, 0, true).keys_only(), + ); + let PathQueryRun::BranchedAxisEntries(entry_branches) = run(&db, &entries_pq, gv) else { + panic!("expected BranchedAxisEntries"); + }; + let PathQueryRun::BranchedAxisKeys(key_branches) = run(&db, &keys_pq, gv) else { + panic!("expected BranchedAxisKeys"); + }; + assert_eq!(key_branches.len(), 3); + for ((ek, e), (kk, k)) in entry_branches.iter().zip(key_branches.iter()) { + assert_eq!(ek, kk); + assert_eq!( + k.as_ref(), + e.as_ref().map(AxisEntries::to_keys).as_ref(), + "branch {:?}", + ek + ); + } + let bob = key_branches + .iter() + .find(|(k, _)| k == b"bob") + .expect("bob listed"); + assert!( + bob.1.is_none(), + "an absent branch is None under the keys projection too" + ); + } + + /// The keys projection never opens the primary: fewer seeks and + /// loaded bytes than the entries read of the same page. + #[test] + fn keys_projection_does_not_read_primaries() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build( + &db, + gv, + &[(b"a", 50), (b"b", 10), (b"c", 30), (b"d", 20), (b"e", 40)], + ); + let entries_q = AxisQuery::top_k(IndexAxis::Sum, 3, 1, true); + let keys_q = entries_q.clone().keys_only(); + let entries_cost = db + .run_path_query( + &PathQuery::new_axis(path(), entries_q), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + gv, + ) + .cost; + let keys_cost = db + .run_path_query( + &PathQuery::new_axis(path(), keys_q), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + gv, + ) + .cost; + assert!( + keys_cost.seek_count < entries_cost.seek_count, + "keys {} vs entries {}", + keys_cost.seek_count, + entries_cost.seek_count + ); + assert!(keys_cost.storage_loaded_bytes < entries_cost.storage_loaded_bytes); + } + + /// A keys projection on a traversal that lists no entries is rejected + /// at the query boundary rather than silently ignored. + #[test] + fn keys_projection_is_rejected_for_non_listing_traversals() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build(&db, gv, &[(b"a", 50), (b"b", 10)]); + for q in [ + AxisQuery::rank_of_key(IndexAxis::Sum, b"a".to_vec(), true).keys_only(), + AxisQuery::aggregate_over_value_range(IndexAxis::Sum, 0, 100, AggregateFold::Total) + .keys_only(), + ] { + assert_eq!(q.projection, AxisProjection::Keys); + let result = db + .run_path_query( + &PathQuery::new_axis(path(), q), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + gv, + ) + .unwrap(); + assert!( + result.is_err(), + "keys projection must be rejected on a non-listing traversal" + ); + } + } +} diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 297fae59e..eb89a8c80 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -40,6 +40,7 @@ mod private_document_store_tests; // rejection cases it uniquely held are ported to // `generic_writes_against_pcit_primary_are_rejected`. mod axis_descent_proof_tests; +mod axis_read_projection_tests; mod count_offset_paginated_tests; mod count_sum_tree_tests; mod count_tree_tests;