diff --git a/grovedb/src/operations/axis_path_query.rs b/grovedb/src/operations/axis_path_query.rs new file mode 100644 index 000000000..bcb78d512 --- /dev/null +++ b/grovedb/src/operations/axis_path_query.rs @@ -0,0 +1,250 @@ +//! The three entry points for [`AxisPathQuery`]: read it, prove it, +//! verify it. +//! +//! This is dispatch, not new proof machinery. Each entry point +//! validates the query once, then routes to the indexed-axis primitive +//! that already serves that shape — so an axis path query produces the +//! same envelope, byte for byte, as the hand-rolled call it replaces. +//! What the caller gains is one vocabulary instead of a dozen bespoke +//! argument lists, and one place where the bounds-to-Merk-query +//! lowering lives (see [`AxisQuery::merk_query`]) rather than a copy on +//! each side of the prover/verifier boundary. + +#[cfg(feature = "minimal")] +use grovedb_costs::{cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt}; +use grovedb_element::indexed::IndexAxis; +#[cfg(feature = "minimal")] +use grovedb_version::version::GroveVersion; + +#[cfg(feature = "minimal")] +use crate::TransactionArg; +use grovedb_merk::tree::CryptoHash; + +use crate::{ + operations::proof::indexed_axis::AxisEntries, + query::{AxisPathQuery, AxisTraversal}, + Error, GroveDb, +}; + +/// The verified answer to an [`AxisPathQuery`]. +#[derive(Debug)] +pub struct VerifiedAxisPathQuery { + /// GroveDB root hash the proof reconstructs. Compare it against the + /// root you trust — verification alone proves internal consistency, + /// not that the proof is about the state you meant. + pub root_hash: CryptoHash, + /// The entries, in the query's walk direction. + pub entries: AxisEntries, + /// Entries the walk attested as skipped before the returned page. + /// + /// Equals the requested `offset` on a full [`AxisTraversal::TopK`] + /// page, and is smaller when the walk ran out during the skip — in + /// which case `entries` is empty and the pair proves the secondary + /// holds exactly `skipped` entries. Always `0` for + /// [`AxisTraversal::Bounded`], which does not skip. + pub skipped: u64, +} + +#[cfg(feature = "minimal")] +impl GroveDb { + /// Read an [`AxisPathQuery`] directly, without a proof. + /// + /// A missing path is an error rather than an empty result: the + /// indexed tree is created before anything can be inserted into it, + /// so its absence means the state is not what the query claims. An + /// indexed tree that exists but holds nothing yields an empty + /// entry list. + pub fn query_axis_path_query( + &self, + query: &AxisPathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + cost_return_on_error_no_add!(cost, query.validate()); + let path = query.path_refs(); + let descending = query.query.descending; + + let entries = match (query.query.axis, query.query.traversal) { + (IndexAxis::Count, AxisTraversal::TopK { k, offset }) => { + AxisEntries::Count(cost_return_on_error!( + &mut cost, + self.indexed_count_top_k_paginated( + path.as_slice(), + k, + offset, + descending, + transaction, + grove_version + ) + )) + } + (IndexAxis::Count, AxisTraversal::Bounded { lo, hi, limit }) => { + AxisEntries::Count(cost_return_on_error!( + &mut cost, + self.indexed_count_range( + path.as_slice(), + lo.max(0) as u64, + hi.min(u64::MAX as i128) as u64, + descending, + limit, + transaction, + grove_version + ) + )) + } + (IndexAxis::Sum, AxisTraversal::TopK { k, offset }) => { + AxisEntries::Sum(cost_return_on_error!( + &mut cost, + self.indexed_sum_top_k_paginated( + path.as_slice(), + k, + offset, + descending, + transaction, + grove_version + ) + )) + } + (IndexAxis::Sum, AxisTraversal::Bounded { lo, hi, limit }) => { + AxisEntries::Sum(cost_return_on_error!( + &mut cost, + self.indexed_sum_range( + path.as_slice(), + lo.max(i64::MIN as i128) as i64, + hi.min(i64::MAX as i128) as i64, + descending, + limit, + transaction, + grove_version + ) + )) + } + (IndexAxis::Avg, AxisTraversal::TopK { k, offset }) => { + AxisEntries::Avg(cost_return_on_error!( + &mut cost, + self.indexed_avg_top_k_paginated( + path.as_slice(), + k, + offset, + descending, + transaction, + grove_version + ) + )) + } + (IndexAxis::Avg, AxisTraversal::Bounded { lo, hi, limit }) => { + AxisEntries::Avg(cost_return_on_error!( + &mut cost, + self.indexed_avg_range( + path.as_slice(), + lo, + hi, + descending, + limit, + transaction, + grove_version + ) + )) + } + }; + Ok(entries).wrap_with_cost(cost) + } + + /// Prove an [`AxisPathQuery`]. + /// + /// Emits the existing indexed-axis envelope for the shape the + /// traversal names — the paginated envelope for + /// [`AxisTraversal::TopK`], the range envelope for + /// [`AxisTraversal::Bounded`] — so this changes no proof bytes and + /// no verification rule. Verify with + /// [`GroveDb::verify_axis_path_query`]. + pub fn prove_axis_path_query( + &self, + query: &AxisPathQuery, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult, Error> { + let mut cost = Default::default(); + cost_return_on_error_no_add!(cost, query.validate()); + let path = query.path_refs(); + + match query.query.traversal { + AxisTraversal::TopK { k, offset } => self.prove_indexed_axis_top_k_paginated( + path.as_slice(), + query.query.axis, + k, + offset, + query.query.descending, + transaction, + grove_version, + ), + AxisTraversal::Bounded { limit, .. } => { + let secondary_query = cost_return_on_error_no_add!(cost, query.query.merk_query()); + self.prove_indexed_axis_query( + path.as_slice(), + query.query.axis, + secondary_query, + Some(limit), + transaction, + grove_version, + ) + } + } + .add_cost(cost) + } +} + +impl GroveDb { + /// Verify a proof produced by [`GroveDb::prove_axis_path_query`] + /// against the same query. + /// + /// The query is the verifier's own — it rebuilds the path, the + /// axis, the walk parameters and (for a bounded traversal) the + /// secondary Merk query from it, and grovedb re-checks those + /// against the values echoed in the envelope. A proof generated for + /// a different page, direction, axis or bound therefore fails + /// rather than being reinterpreted. + /// + /// Available in verifier-only builds: nothing here touches storage. + pub fn verify_axis_path_query( + proof: &[u8], + query: &AxisPathQuery, + ) -> Result { + query.validate()?; + let path = query.path_refs(); + + match query.query.traversal { + AxisTraversal::TopK { k, offset } => { + let result = Self::verify_indexed_axis_top_k_paginated( + proof, + path.as_slice(), + query.query.axis, + k, + offset, + query.query.descending, + )?; + Ok(VerifiedAxisPathQuery { + root_hash: result.root_hash, + entries: result.entries, + skipped: result.skipped, + }) + } + AxisTraversal::Bounded { limit, .. } => { + let secondary_query = query.query.merk_query()?; + let result = Self::verify_indexed_axis_query( + proof, + path.as_slice(), + query.query.axis, + secondary_query, + Some(limit), + )?; + Ok(VerifiedAxisPathQuery { + root_hash: result.root_hash, + entries: result.entries, + skipped: 0, + }) + } + } + } +} diff --git a/grovedb/src/operations/mod.rs b/grovedb/src/operations/mod.rs index 8e556f3ad..6b2bea7e2 100644 --- a/grovedb/src/operations/mod.rs +++ b/grovedb/src/operations/mod.rs @@ -35,5 +35,9 @@ pub mod replace_subtree_root; #[cfg(feature = "minimal")] pub mod indexed_tree; +/// The axis path query front door: read, prove and verify +/// axis-ordered reads of an indexed tree. +pub mod axis_path_query; + #[cfg(feature = "minimal")] pub use get::{QueryItemOrSumReturnType, MAX_REFERENCE_HOPS}; diff --git a/grovedb/src/query/axis_path_query.rs b/grovedb/src/query/axis_path_query.rs new file mode 100644 index 000000000..3758f9025 --- /dev/null +++ b/grovedb/src/query/axis_path_query.rs @@ -0,0 +1,392 @@ +//! Query vocabulary for **axis-ordered** reads of an indexed tree. +//! +//! An ordinary [`PathQuery`](crate::PathQuery) selects keys: its items +//! name keys or key ranges in the Merk a path points at. That vocabulary +//! cannot describe the other thing an indexed tree can answer — "the +//! best `k` groups by aggregate" — because that ordering lives in the +//! tree's per-axis **secondary**, which is keyed by +//! `sort_key ‖ original_key` and is not a path-addressable subtree. It +//! is an internal structure of the element, with its own storage +//! prefix, so no path (and therefore no `PathQuery`, merged or not) +//! names it. +//! +//! The consequence, before this module existed, was that a caller +//! wanting an axis-ordered answer left the query language entirely and +//! called one of a dozen bespoke `indexed_{count,sum,avg}_*` methods, +//! each with its own argument list, and hand-built the secondary's Merk +//! query when it wanted bounds. [`AxisPathQuery`] gives that capability +//! the same shape everything else has: a path plus a description of +//! what to read, one entry point to execute it, one to prove it, one to +//! verify it. +//! +//! ## What it does not (yet) do +//! +//! This is vocabulary and dispatch, not a new proof shape. Executing an +//! [`AxisPathQuery`] routes to the existing indexed-axis primitives and +//! produces the existing envelopes, byte for byte. Two follow-ups are +//! deliberately out of scope: +//! +//! - **Merging sibling axis queries.** N axis path queries whose paths +//! differ at one segment are exactly the branched-proof case; merging +//! them into one envelope belongs with that shape. +//! - **Embedding axis queries inside a `PathQuery`'s subquery +//! branches**, so one proof could mix key-selected and axis-ordered +//! layers. That needs the general proof generator to carry a +//! secondary proof where it currently carries only a secondary root +//! attestation. + +use std::fmt; + +use bincode::{Decode, Encode}; +use grovedb_element::indexed::{ + encode_avg_sort_key, encode_count_sort_key, encode_sum_sort_key, IndexAxis, +}; +use grovedb_merk::proofs::{query::QueryItem as MerkQueryItem, Query as MerkQuery}; + +use crate::{operations::proof::util::hex_to_ascii, Error}; + +/// How an [`AxisQuery`] walks the secondary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AxisTraversal { + /// The `k` entries starting at rank `offset` in the walk direction — + /// the "top-k" reading (`offset = 0` for the first page). + /// + /// Skipping is attested from the secondary's counted subtree + /// commitments rather than walked, so a large `offset` costs the + /// same as a small one. + TopK { + /// Number of entries to return. + k: u16, + /// Rank the returned page starts at. + offset: u64, + }, + /// Every entry whose aggregate falls in the **inclusive** range + /// `[lo, hi]`, up to `limit` entries, in the walk direction. + /// + /// Bounds are carried as `i128` for every axis — the same + /// convention the aggregate-range entry points use — and are + /// validated against the axis's own domain. + Bounded { + /// Inclusive lower bound on the aggregate. + lo: i128, + /// Inclusive upper bound on the aggregate. + hi: i128, + /// Maximum entries to return. + limit: u16, + }, +} + +/// 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 [`Query`](crate::Query). +/// +/// `bincode` is implemented by hand rather than derived because +/// [`IndexAxis`] is a plain element-crate enum with no codec derives: +/// the axis travels as its canonical tag byte (the same byte the +/// indexed-tree element and every proof envelope use), so the encoding +/// stays stable if the enum ever gains variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AxisQuery { + /// Which per-axis secondary to read. The indexed tree must carry + /// this axis; it is authenticated in the proof, so a query naming + /// an axis the element does not configure fails rather than + /// silently reading another one. + pub axis: IndexAxis, + /// How to walk it. + pub traversal: AxisTraversal, + /// `true` walks from the largest aggregate down ("best first"); + /// `false` from the smallest up. Ties break by the entry's original + /// key **in the direction of the walk** — a property of the + /// directional scan over `sort_key ‖ original_key`, not a separate + /// rule. + pub descending: bool, +} + +/// A path to an indexed tree plus the axis-ordered read to perform on +/// it — the axis counterpart of [`PathQuery`](crate::PathQuery). +/// +/// The path's last segment must be an indexed-tree element; every +/// entry point fails with a typed error otherwise, rather than +/// answering from some other structure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AxisPathQuery { + /// Path to the indexed tree. + pub path: Vec>, + /// The axis-ordered read. + pub query: AxisQuery, +} + +impl Encode for AxisQuery { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + self.axis.tag().encode(encoder)?; + self.traversal.encode(encoder)?; + self.descending.encode(encoder) + } +} + +impl Decode for AxisQuery { + fn decode>( + decoder: &mut D, + ) -> Result { + let tag = u8::decode(decoder)?; + let axis = IndexAxis::try_from_tag(tag).map_err(|_| { + bincode::error::DecodeError::OtherString(format!("unknown index axis tag {tag}")) + })?; + Ok(Self { + axis, + traversal: AxisTraversal::decode(decoder)?, + descending: bool::decode(decoder)?, + }) + } +} + +impl<'de, Context> bincode::BorrowDecode<'de, Context> for AxisQuery { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + Self::decode(decoder) + } +} + +impl fmt::Display for AxisTraversal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AxisTraversal::TopK { k, offset } => { + write!(f, "TopK {{ k: {k}, offset: {offset} }}") + } + AxisTraversal::Bounded { lo, hi, limit } => { + write!(f, "Bounded {{ lo: {lo}, hi: {hi}, limit: {limit} }}") + } + } + } +} + +impl fmt::Display for AxisQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "AxisQuery {{ axis: {:?}, {}, {} }}", + self.axis, + self.traversal, + if self.descending { + "descending" + } else { + "ascending" + } + ) + } +} + +impl fmt::Display for AxisPathQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "AxisPathQuery {{ path: [")?; + for (i, path_element) in self.path.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", hex_to_ascii(path_element))?; + } + write!(f, "], query: {} }}", self.query) + } +} + +impl AxisQuery { + /// The `k` best entries on `axis`, starting at rank `offset`. + pub const fn top_k(axis: IndexAxis, k: u16, offset: u64, descending: bool) -> Self { + Self { + axis, + traversal: AxisTraversal::TopK { k, offset }, + descending, + } + } + + /// Every entry whose aggregate is in `[lo, hi]`, up to `limit`. + pub const fn bounded( + axis: IndexAxis, + lo: i128, + hi: i128, + limit: u16, + descending: bool, + ) -> Self { + Self { + axis, + traversal: AxisTraversal::Bounded { lo, hi, limit }, + descending, + } + } + + /// Reject a query that cannot describe any answer, so a caller + /// error surfaces as a query error rather than an empty result that + /// looks like real absence. + /// + /// Checked here rather than at each entry point so that the read, + /// prove, and verify paths cannot disagree on what is well-formed — + /// the same reason the bounds lowering below is shared. + pub fn validate(&self) -> Result<(), Error> { + match self.traversal { + AxisTraversal::TopK { k, .. } => { + if k == 0 { + return Err(Error::InvalidInput( + "axis query: `k` must be at least 1; a zero-length page selects nothing", + )); + } + } + AxisTraversal::Bounded { lo, hi, limit } => { + if limit == 0 { + return Err(Error::InvalidInput( + "axis query: `limit` must be at least 1; a zero-length page selects \ + nothing", + )); + } + if lo > hi { + return Err(Error::InvalidInput( + "axis query: the bounds are inverted (`lo > hi`), so they can match \ + nothing", + )); + } + if self.bounds_out_of_domain(lo, hi) { + return Err(Error::InvalidInput( + "axis query: the bounds fall entirely outside the axis's value \ + domain, so they can match nothing", + )); + } + } + } + Ok(()) + } + + /// Whether `[lo, hi]` lies wholly outside what this axis can hold. + /// A partial overlap is fine — it clamps in [`Self::merk_query`]. + fn bounds_out_of_domain(&self, lo: i128, hi: i128) -> bool { + match self.axis { + IndexAxis::Count => hi < 0 || lo > u64::MAX as i128, + IndexAxis::Sum => hi < i64::MIN as i128 || lo > i64::MAX as i128, + // The avg axis is ordered by the fixed-point average, whose + // domain is the whole i128 range; nothing is out of domain. + IndexAxis::Avg => false, + } + } + + /// Lower a [`AxisTraversal::Bounded`] query into the secondary's own + /// Merk query. + /// + /// This is prover/verifier agreement material: both sides build the + /// range from the request through *this* function, so they cannot + /// drift on which secondary entries the proof is about. (Before this + /// module, each caller carried its own copy of this lowering — + /// exactly the kind of duplication that makes a proof format + /// disagree with itself.) + /// + /// The secondary's keys are `sort_key ‖ original_key`, so an + /// inclusive bound on the aggregate becomes a byte range that + /// brackets every key-suffix at the boundary sort key: inclusive at + /// `lo`, exclusive at the *successor* of `hi`. When `hi` is already + /// the axis maximum there is no successor, and the range is + /// open-ended instead. + pub fn merk_query(&self) -> Result { + let AxisTraversal::Bounded { lo, hi, .. } = self.traversal else { + return Err(Error::InvalidInput( + "axis query: only a bounded traversal lowers to a secondary Merk query; a \ + top-k traversal is served by the paginated primitives", + )); + }; + self.validate()?; + + let (lo_bytes, hi_exclusive) = match self.axis { + IndexAxis::Count => { + let lo = lo.max(0) as u64; + let hi = hi.min(u64::MAX as i128) as u64; + ( + encode_count_sort_key(lo).to_vec(), + hi.checked_add(1).map(|h| encode_count_sort_key(h).to_vec()), + ) + } + IndexAxis::Sum => { + let lo = lo.max(i64::MIN as i128) as i64; + let hi = hi.min(i64::MAX as i128) as i64; + ( + encode_sum_sort_key(lo).to_vec(), + hi.checked_add(1).map(|h| encode_sum_sort_key(h).to_vec()), + ) + } + IndexAxis::Avg => ( + encode_avg_sort_key(lo).to_vec(), + hi.checked_add(1).map(|h| encode_avg_sort_key(h).to_vec()), + ), + }; + + let mut query = MerkQuery::new(); + match hi_exclusive { + Some(hi_bytes) => query.insert_item(MerkQueryItem::Range(lo_bytes..hi_bytes)), + None => query.insert_item(MerkQueryItem::RangeFrom(lo_bytes..)), + } + query.left_to_right = !self.descending; + Ok(query) + } + + /// The entry cap this query asks for, whichever traversal it is. + pub const fn limit(&self) -> u16 { + match self.traversal { + AxisTraversal::TopK { k, .. } => k, + AxisTraversal::Bounded { limit, .. } => limit, + } + } +} + +impl AxisPathQuery { + /// A path plus an axis-ordered read. + pub const fn new(path: Vec>, query: AxisQuery) -> Self { + Self { path, query } + } + + /// The `k` best entries on `axis` at `path`, starting at rank + /// `offset`. + pub const fn top_k( + path: Vec>, + axis: IndexAxis, + k: u16, + offset: u64, + descending: bool, + ) -> Self { + Self::new(path, AxisQuery::top_k(axis, k, offset, descending)) + } + + /// Every entry at `path` whose `axis` aggregate is in `[lo, hi]`, + /// up to `limit`. + pub const fn bounded( + path: Vec>, + axis: IndexAxis, + lo: i128, + hi: i128, + limit: u16, + descending: bool, + ) -> Self { + Self::new(path, AxisQuery::bounded(axis, lo, hi, limit, descending)) + } + + /// Borrowed path segments, the shape the execution entry points + /// take. + pub fn path_refs(&self) -> Vec<&[u8]> { + self.path.iter().map(|segment| segment.as_slice()).collect() + } + + /// See [`AxisQuery::validate`]; additionally rejects an empty path, + /// which cannot name an indexed tree. + pub fn validate(&self) -> Result<(), Error> { + if self.path.is_empty() { + return Err(Error::InvalidPath( + "an axis path query's path cannot be empty: its last segment must be an \ + indexed-tree element" + .to_string(), + )); + } + self.query.validate() + } +} diff --git a/grovedb/src/query/mod.rs b/grovedb/src/query/mod.rs index 7d102b581..051562361 100644 --- a/grovedb/src/query/mod.rs +++ b/grovedb/src/query/mod.rs @@ -1,6 +1,7 @@ //! Queries pub mod aggregate_sum_path_query; +mod axis_path_query; mod grove_branch_query_result; mod grove_trunk_query_result; mod path_branch_chunk_query; @@ -12,8 +13,9 @@ use std::{ fmt, }; -use bincode::{Decode, Encode}; #[cfg(any(feature = "minimal", feature = "verify"))] +pub use axis_path_query::{AxisPathQuery, AxisQuery, AxisTraversal}; +use bincode::{Decode, Encode}; pub use grove_branch_query_result::GroveBranchQueryResult; #[cfg(any(feature = "minimal", feature = "verify"))] pub use grove_trunk_query_result::{GroveTrunkQueryResult, LeafInfo}; diff --git a/grovedb/src/tests/axis_path_query_tests.rs b/grovedb/src/tests/axis_path_query_tests.rs new file mode 100644 index 000000000..8ff1c260f --- /dev/null +++ b/grovedb/src/tests/axis_path_query_tests.rs @@ -0,0 +1,398 @@ +//! Tests for the [`AxisPathQuery`] vocabulary: read / prove / verify +//! round trips on every axis and traversal, the validation surface, and +//! the property that makes this a safe refactor — the proofs it emits +//! are byte-identical to the hand-rolled primitive calls it dispatches +//! to. + +#[cfg(test)] +mod tests { + use grovedb_element::indexed::IndexAxis; + use grovedb_merk::proofs::{query::QueryItem as MerkQueryItem, Query as MerkQuery}; + use grovedb_version::version::GroveVersion; + + use crate::{ + operations::proof::indexed_axis::AxisEntries, + query::{AxisPathQuery, AxisQuery, AxisTraversal}, + tests::{make_test_grovedb, TEST_LEAF}, + Element, GroveDb, + }; + + const PCIT: &[u8] = b"pcit"; + const PCPSIT: &[u8] = b"pcpsit"; + + /// A count-indexed tree at `[TEST_LEAF, "pcit"]` whose entries carry + /// the given counts (counts are derived from child population). + fn build_count_fixture(db: &GroveDb, gv: &GroveVersion, entries: &[(&[u8], u64)]) { + db.insert( + [TEST_LEAF].as_ref(), + PCIT, + Element::empty_provable_count_indexed_tree(), + None, + None, + gv, + ) + .unwrap() + .expect("create pcit"); + for (key, count) in entries { + db.insert_into_count_indexed_tree( + [TEST_LEAF, PCIT].as_ref(), + key, + Element::empty_provable_count_tree(), + None, + gv, + ) + .unwrap() + .expect("insert group"); + for i in 0..*count { + db.insert( + [TEST_LEAF, PCIT, key].as_ref(), + &i.to_be_bytes(), + Element::new_item(b"v".to_vec()), + None, + None, + gv, + ) + .unwrap() + .expect("insert doc"); + } + } + } + + /// A dual-axis tree at `[TEST_LEAF, "pcpsit"]` carrying count, sum + /// and avg axes over `(key, sum_value)` entries. + fn build_multi_axis_fixture(db: &GroveDb, gv: &GroveVersion, entries: &[(&[u8], i64)]) { + let axes: Vec<(u8, Option>)> = vec![(0, None), (1, None), (2, 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"); + } + } + + fn count_path() -> Vec> { + vec![TEST_LEAF.to_vec(), PCIT.to_vec()] + } + + fn multi_axis_path() -> Vec> { + vec![TEST_LEAF.to_vec(), PCPSIT.to_vec()] + } + + fn counts(entries: &AxisEntries) -> Vec<(u64, Vec)> { + match entries { + AxisEntries::Count(v) => v.clone(), + other => panic!("expected count entries, got {other:?}"), + } + } + + /// Read, prove and verify agree, and the verified root hash is the + /// live one — the basic contract, exercised through the vocabulary + /// rather than the bespoke methods. + #[test] + fn top_k_round_trips_through_the_vocabulary() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_count_fixture(&db, gv, &[(b"a", 3), (b"b", 7), (b"c", 5)]); + + let query = AxisPathQuery::top_k(count_path(), IndexAxis::Count, 2, 0, true); + + let read = db + .query_axis_path_query(&query, None, gv) + .unwrap() + .expect("read succeeds"); + assert_eq!( + counts(&read), + vec![(7, b"b".to_vec()), (5, b"c".to_vec())], + "top 2 by count, descending" + ); + + let proof = db + .prove_axis_path_query(&query, None, gv) + .unwrap() + .expect("prove succeeds"); + let verified = GroveDb::verify_axis_path_query(&proof, &query).expect("verify succeeds"); + assert_eq!(counts(&verified.entries), counts(&read)); + assert_eq!(verified.skipped, 0); + assert_eq!( + verified.root_hash, + db.root_hash(None, gv).unwrap().expect("root hash") + ); + } + + /// The bounded traversal's lowering — inclusive on both ends, + /// bracketing every key-suffix at the boundary sort key. + #[test] + fn bounded_traversal_is_inclusive_on_both_ends() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_count_fixture(&db, gv, &[(b"a", 3), (b"b", 7), (b"c", 5), (b"d", 9)]); + + let query = AxisPathQuery::bounded(count_path(), IndexAxis::Count, 5, 7, 10, false); + let read = db + .query_axis_path_query(&query, None, gv) + .unwrap() + .expect("read succeeds"); + assert_eq!( + counts(&read), + vec![(5, b"c".to_vec()), (7, b"b".to_vec())], + "both bounds inclusive: 5 and 7 in, 3 and 9 out" + ); + + let proof = db + .prove_axis_path_query(&query, None, gv) + .unwrap() + .expect("prove succeeds"); + let verified = GroveDb::verify_axis_path_query(&proof, &query).expect("verify succeeds"); + assert_eq!(counts(&verified.entries), counts(&read)); + assert_eq!( + verified.root_hash, + db.root_hash(None, gv).unwrap().expect("root hash") + ); + } + + /// Every axis is reachable through the same vocabulary, and each + /// answers on its own ordering. + #[test] + fn all_three_axes_round_trip() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_multi_axis_fixture(&db, gv, &[(b"x", 10), (b"y", 30), (b"z", 20)]); + + for axis in [IndexAxis::Count, IndexAxis::Sum, IndexAxis::Avg] { + let query = AxisPathQuery::top_k(multi_axis_path(), axis, 3, 0, true); + let read = db + .query_axis_path_query(&query, None, gv) + .unwrap() + .unwrap_or_else(|e| panic!("{axis:?} read: {e}")); + let proof = db + .prove_axis_path_query(&query, None, gv) + .unwrap() + .unwrap_or_else(|e| panic!("{axis:?} prove: {e}")); + let verified = GroveDb::verify_axis_path_query(&proof, &query) + .unwrap_or_else(|e| panic!("{axis:?} verify: {e}")); + assert_eq!( + verified.entries.len(), + read.len(), + "{axis:?}: read and verified entry counts must agree" + ); + assert_eq!( + verified.root_hash, + db.root_hash(None, gv).unwrap().expect("root hash"), + "{axis:?}: root hash" + ); + } + } + + /// **The refactor-safety property.** A proof produced through the + /// vocabulary is byte-identical to one produced by calling the + /// underlying primitive directly, for both traversals — so this is + /// dispatch, not a new proof shape, and existing verifiers are + /// unaffected. + #[test] + fn emitted_proofs_are_byte_identical_to_the_primitives() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_count_fixture(&db, gv, &[(b"a", 3), (b"b", 7), (b"c", 5)]); + let path: Vec<&[u8]> = vec![TEST_LEAF, PCIT]; + + // TopK ↔ prove_indexed_axis_top_k_paginated + let via_vocabulary = db + .prove_axis_path_query( + &AxisPathQuery::top_k(count_path(), IndexAxis::Count, 2, 1, true), + None, + gv, + ) + .unwrap() + .expect("vocabulary prove"); + let via_primitive = db + .prove_indexed_axis_top_k_paginated( + path.as_slice(), + IndexAxis::Count, + 2, + 1, + true, + None, + gv, + ) + .unwrap() + .expect("primitive prove"); + assert_eq!( + via_vocabulary, via_primitive, + "the top-k vocabulary must emit the primitive's exact bytes" + ); + + // Bounded ↔ prove_indexed_axis_query, with the same lowering + // the vocabulary performs. + let bounded = AxisQuery::bounded(IndexAxis::Count, 5, 7, 10, false); + let via_vocabulary = db + .prove_axis_path_query(&AxisPathQuery::new(count_path(), bounded), None, gv) + .unwrap() + .expect("vocabulary prove"); + let via_primitive = db + .prove_indexed_axis_query( + path.as_slice(), + IndexAxis::Count, + bounded.merk_query().expect("lowering"), + Some(10), + None, + gv, + ) + .unwrap() + .expect("primitive prove"); + assert_eq!( + via_vocabulary, via_primitive, + "the bounded vocabulary must emit the primitive's exact bytes" + ); + } + + /// The lowering the vocabulary owns produces the same secondary + /// query a caller would hand-build — the property that lets both + /// sides of the prover/verifier boundary share it. + #[test] + fn bounds_lowering_matches_a_hand_built_secondary_query() { + let query = AxisQuery::bounded(IndexAxis::Count, 5, 7, 10, false); + let lowered = query.merk_query().expect("lowering"); + + let mut expected = MerkQuery::new(); + expected.insert_item(MerkQueryItem::Range( + 5u64.to_be_bytes().to_vec()..8u64.to_be_bytes().to_vec(), + )); + expected.left_to_right = true; + assert_eq!(lowered.items, expected.items); + assert_eq!(lowered.left_to_right, expected.left_to_right); + + // At the axis maximum there is no successor, so the range is + // open-ended rather than wrapping. + let open = AxisQuery::bounded(IndexAxis::Count, 0, u64::MAX as i128, 10, false) + .merk_query() + .expect("lowering"); + assert!( + matches!(open.items.first(), Some(MerkQueryItem::RangeFrom(_))), + "an upper bound at the axis maximum lowers to an open range, got {:?}", + open.items.first() + ); + } + + /// A query that cannot describe any answer is rejected rather than + /// returning an empty page that looks like real absence. + #[test] + fn degenerate_queries_are_rejected() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_count_fixture(&db, gv, &[(b"a", 3)]); + + let cases = vec![ + ( + "zero k", + AxisPathQuery::top_k(count_path(), IndexAxis::Count, 0, 0, true), + ), + ( + "zero limit", + AxisPathQuery::bounded(count_path(), IndexAxis::Count, 0, 10, 0, true), + ), + ( + "inverted bounds", + AxisPathQuery::bounded(count_path(), IndexAxis::Count, 10, 5, 10, true), + ), + ( + "bounds below the count domain", + AxisPathQuery::bounded(count_path(), IndexAxis::Count, -100, -1, 10, true), + ), + ( + "empty path", + AxisPathQuery::top_k(vec![], IndexAxis::Count, 2, 0, true), + ), + ]; + for (label, query) in cases { + assert!( + db.query_axis_path_query(&query, None, gv).unwrap().is_err(), + "{label}: read must reject" + ); + assert!( + db.prove_axis_path_query(&query, None, gv).unwrap().is_err(), + "{label}: prove must reject" + ); + } + } + + /// The verifier's own query is what the envelope is checked + /// against, so a proof does not verify under a different one. + #[test] + fn a_proof_does_not_verify_under_a_different_query() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_count_fixture(&db, gv, &[(b"a", 3), (b"b", 7), (b"c", 5)]); + + let query = AxisPathQuery::top_k(count_path(), IndexAxis::Count, 2, 0, true); + let proof = db + .prove_axis_path_query(&query, None, gv) + .unwrap() + .expect("prove"); + + for (label, other) in [ + ( + "different k", + AxisPathQuery::top_k(count_path(), IndexAxis::Count, 3, 0, true), + ), + ( + "different offset", + AxisPathQuery::top_k(count_path(), IndexAxis::Count, 2, 1, true), + ), + ( + "different direction", + AxisPathQuery::top_k(count_path(), IndexAxis::Count, 2, 0, false), + ), + ( + "different traversal", + AxisPathQuery::bounded(count_path(), IndexAxis::Count, 0, 10, 2, true), + ), + ] { + assert!( + GroveDb::verify_axis_path_query(&proof, &other).is_err(), + "{label}: must not verify" + ); + } + } + + /// The vocabulary survives a bincode round trip, including the + /// hand-written axis-tag codec. + #[test] + fn the_vocabulary_round_trips_through_bincode() { + let config = bincode::config::standard(); + for query in [ + AxisPathQuery::top_k(count_path(), IndexAxis::Avg, 5, 12, true), + AxisPathQuery::bounded(count_path(), IndexAxis::Sum, -50, 50, 7, false), + ] { + let bytes = bincode::encode_to_vec(&query, config).expect("encode"); + let (decoded, _): (AxisPathQuery, _) = + bincode::decode_from_slice(&bytes, config).expect("decode"); + assert_eq!(decoded, query); + } + + // An unknown axis tag is rejected rather than silently + // becoming a valid axis. + let mut bytes = + bincode::encode_to_vec(AxisQuery::top_k(IndexAxis::Count, 1, 0, true), config) + .expect("encode"); + bytes[0] = 99; + assert!( + bincode::decode_from_slice::(&bytes, config).is_err(), + "an unknown axis tag must not decode" + ); + } +} diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 7f130cb0a..e1fa278b6 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -36,6 +36,7 @@ mod coverage_round7_tests; // and `provable_count_provable_sum_indexed_tree_tests`; the generic-write // rejection cases it uniquely held are ported to // `generic_writes_against_pcit_primary_are_rejected`. +mod axis_path_query_tests; mod count_offset_paginated_tests; mod count_sum_tree_tests; mod count_tree_tests;