Skip to main content

drive_proof_verifier/proof/
document_ranked.rs

1//! Verified **ranked** (`GROUP BY … ORDER BY <aggregate> LIMIT n`)
2//! document results.
3//!
4//! A ranked query answers "which `n` groups score highest (or lowest)
5//! on an aggregate?" — `SELECT AVG(grade) GROUP BY restaurantId
6//! ORDER BY grade DESC LIMIT 5`. The answer is read straight out of
7//! the per-axis *secondary* Merk of an indexed tree (grovedb PR #657),
8//! so it costs `O(log n + k)` and comes with a proof that commits to
9//! exactly the `k` returned `(aggregate, group key)` pairs — plus the
10//! `OFFSET`, which grovedb counts from the subtree aggregates rather
11//! than by walking the skipped region — and additionally attests, on
12//! this proved path — so a deep page costs `O(log n + k)` like any
13//! other rather than growing with the offset.
14//!
15//! This module holds the client-facing result type
16//! ([`DocumentRankedEntries`]), the tenderdash-composition wrapper
17//! around rs-drive's merk-level verifier
18//! ([`verify_ranked_top_k_proof`]), and the decoder for the unproven
19//! `ResultData.ranked` wire payload
20//! ([`DocumentRankedEntries::from_unproved_response`]).
21//!
22//! Per-shape routing (which index covers the axis, which
23//! `(axis, descending, k, offset)` tuple the request resolves to) lives
24//! in rs-sdk's `ranked_proof_helpers`, exactly as count's four-way
25//! dispatch lives in `count_proof_helpers` — it needs the data
26//! contract, which this crate does not carry.
27
28use crate::error::MapGroveDbError;
29use crate::verify::{supported_grovedb_proof_bytes, verify_tenderdash_proof};
30use crate::{ContextProvider, Error, FromProof};
31use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
32    ranked_entry, result_data, RankedEntry as ProtoRankedEntry, ResultData,
33};
34use dapi_grpc::platform::v0::get_documents_response::{
35    get_documents_response_v1, Version as ResponseVersion,
36};
37use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata};
38use dpp::dashcore::Network;
39use dpp::version::PlatformVersion;
40use drive::query::{
41    DriveDocumentQuery, DriveDocumentRankedQuery, RankedEntry, RankedEntryValue, RankedPage,
42    RANKED_AVG_SCALE,
43};
44use drive::verify::RootHash;
45
46/// One page of a `GROUP BY … ORDER BY <aggregate> LIMIT n [OFFSET m]`
47/// query: the ranked groups, plus the rank the page starts at.
48///
49/// **Entry order is the ranking order** — best-first for `DESC`,
50/// worst-first for `ASC`. Callers must not re-sort; ties (groups with
51/// equal aggregates) come back in group-key order *in the direction of
52/// the walk*, which is descending group-key order for `DESC`.
53///
54/// Fewer than `n` entries is normal — the index simply holds fewer
55/// groups than were asked for — and is not an error.
56///
57/// Each [`RankedEntry`]'s `key` is the raw index-key bytes of the
58/// `GROUP BY` property's value (for a `string` property, its UTF-8
59/// bytes); its `value` is the aggregate, one of
60/// [`RankedEntryValue::Count`] / [`RankedEntryValue::Sum`] /
61/// [`RankedEntryValue::AvgFixedPoint`]. Averages are fixed-point
62/// integers scaled by [`crate::RANKED_AVG_SCALE`]; divide by it (or
63/// call [`RankedEntryValue::as_f64`]) to render one.
64///
65/// The fixed point is **exact on the proved path only**. A page built
66/// by [`Self::from_verified`] carries the very integer the proof
67/// commits to; one built by [`Self::from_unproved_response`] carries a
68/// best-effort reconstruction from the wire's `double` — see that
69/// method for what that costs.
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct DocumentRankedEntries {
72    /// The 0-based rank of `entries[0]` — the query's `OFFSET`, as
73    /// actually honoured.
74    ///
75    /// This is what turns a page back into a *ranking*: entry `i` is
76    /// the group at rank `starting_rank + i`. Without it a caller who
77    /// asked for `ORDER BY avg(grade) DESC LIMIT 1 OFFSET 4` receives
78    /// one entry and has no way to tell it really is the 5th-best
79    /// group rather than the best.
80    ///
81    /// On the **proved** path this is grovedb's cryptographically
82    /// attested count, re-derived by the verifier from the counted
83    /// subtree commitments in the proof bytes rather than trusted from
84    /// the response. It equals the requested offset unless the walk ran
85    /// out of groups first, in which case `entries` is empty and this
86    /// is a *proof* that the ranking holds exactly this many groups in
87    /// total — an offset past the end is a positive answer, not an
88    /// error.
89    ///
90    /// On the **unproven** decode it is whatever the node put on the
91    /// wire (`0` when the field is absent), and carries no more weight
92    /// than the entries beside it.
93    pub starting_rank: u64,
94    /// The groups on this page, **in ranking order**.
95    pub entries: Vec<RankedEntry>,
96}
97
98impl DocumentRankedEntries {
99    /// Build a [`DocumentRankedEntries`] from a verifier-side
100    /// [`RankedPage`] — the shape rs-drive's merk-level verifier
101    /// returns, carrying the attested skip alongside the entries.
102    ///
103    /// Mirrors
104    /// [`DocumentSplitCounts::from_verified`](crate::DocumentSplitCounts::from_verified),
105    /// except that it is not the identity: `RankedPage` is rs-drive's
106    /// internal type and this is the client-facing one, so the rename
107    /// of `skipped` → `starting_rank` happens here, where the value
108    /// stops being "how far the walk skipped" and starts being "which
109    /// rank you are looking at".
110    pub fn from_verified(page: RankedPage) -> Self {
111        DocumentRankedEntries {
112            starting_rank: page.skipped,
113            entries: page.entries,
114        }
115    }
116
117    /// Decode the **unproven** ranked payload of a `getDocuments`
118    /// response — the `ResultData.ranked` variant a node returns for a
119    /// ranked request sent with `prove = false`.
120    ///
121    /// Order is preserved verbatim: the server emits entries in
122    /// ranking order and this decoder never re-sorts.
123    ///
124    /// This is a plain wire decode with **no cryptographic guarantee
125    /// whatsoever** — it is the "trust the node" path, and that applies
126    /// to [`Self::starting_rank`] every bit as much as to the entries:
127    /// an unproven page claiming to start at rank 4 is a claim, not a
128    /// fact. Prefer [`verify_ranked_top_k_proof`] (via rs-sdk's
129    /// `DocumentRankedEntries::fetch`) unless you are deliberately
130    /// reading from a node you already trust.
131    ///
132    /// A node that predates the wire `skipped` field leaves it unset;
133    /// that decodes to `starting_rank == 0`, which is the right answer
134    /// for the offset-less queries such a node could serve at all.
135    ///
136    /// ## Averages come back approximate here
137    ///
138    /// The wire's `avg` is a `double`, deliberately: these entries only
139    /// exist on this path, and a proof-verifying client reconstructs
140    /// the exact fixed point from the proof instead. To keep one
141    /// [`RankedEntryValue`] type across both paths this decoder
142    /// multiplies the double back up by [`crate::RANKED_AVG_SCALE`] and
143    /// rounds, so the [`RankedEntryValue::AvgFixedPoint`] it yields is a
144    /// **best-effort reconstruction, not the committed integer** — its
145    /// low digits are noise beyond `f64`'s ~15–16 significant decimal
146    /// digits. Render it, compare it loosely, but do not treat it as the
147    /// value grovedb ranked on; ask for the proof if you need that.
148    ///
149    /// # Errors
150    ///
151    /// - [`Error::EmptyVersion`] when the response carries no version.
152    /// - [`Error::ResponseDecodeError`] when the response is a V0
153    ///   response (which has no ranked shape), carries a proof rather
154    ///   than data, or carries a non-ranked `ResultData` variant.
155    /// - [`Error::ResponseDecodeError`] when an entry's `value` oneof
156    ///   is unset, or its `avg` is not a finite double that scales into
157    ///   `i128` range.
158    pub fn from_unproved_response(
159        response: &GetDocumentsResponse,
160    ) -> Result<(Self, ResponseMetadata), Error> {
161        let version = response.version.as_ref().ok_or(Error::EmptyVersion)?;
162        let ResponseVersion::V1(v1) = version else {
163            return Err(Error::ResponseDecodeError {
164                error: "ranked results are a V1-only response shape; got a V0 getDocuments \
165                        response. Ranked queries require protocol version 14+."
166                    .to_string(),
167            });
168        };
169        let metadata = v1.metadata.clone().ok_or(Error::EmptyResponseMetadata)?;
170        let (starting_rank, entries) = match v1.result.as_ref() {
171            Some(get_documents_response_v1::Result::Data(ResultData {
172                variant: Some(result_data::Variant::Ranked(ranked)),
173            })) => (
174                ranked.skipped.unwrap_or(0),
175                ranked
176                    .entries
177                    .iter()
178                    .map(ranked_entry_from_proto)
179                    .collect::<Result<Vec<_>, _>>()?,
180            ),
181            Some(get_documents_response_v1::Result::Proof(_)) => {
182                return Err(Error::ResponseDecodeError {
183                    error: "the response carries a proof, not unproven ranked entries; verify \
184                            it with `verify_ranked_top_k_proof` instead of decoding it"
185                        .to_string(),
186                });
187            }
188            other => {
189                return Err(Error::ResponseDecodeError {
190                    error: format!(
191                        "expected a `ResultData.ranked` payload for a ranked request, got \
192                         {}. A response on another variant means the node routed the \
193                         request to a different executor — check that the request carries a \
194                         `group_by` and a single `order_by` naming the single `select`'s \
195                         aggregate (`$count` for `COUNT(*)`).",
196                        result_variant_name(other)
197                    ),
198                });
199            }
200        };
201        Ok((
202            DocumentRankedEntries {
203                starting_rank,
204                entries,
205            },
206            metadata,
207        ))
208    }
209}
210
211/// The received-but-unexpected shape of a `getDocuments` V1 result, by
212/// **name only** — never the payload. Interpolating the payload into an
213/// error would make the message (and any log line carrying it) grow
214/// with an untrusted response, and could copy returned document bytes
215/// into logs. Shared by the ranked and having-range decoders.
216pub(crate) fn result_variant_name(
217    result: Option<&get_documents_response_v1::Result>,
218) -> &'static str {
219    match result {
220        None => "an absent result",
221        Some(get_documents_response_v1::Result::Proof(_)) => "a proof",
222        Some(get_documents_response_v1::Result::Data(ResultData { variant })) => match variant {
223            None => "a ResultData with no variant",
224            Some(result_data::Variant::Documents(_)) => "a ResultData.documents payload",
225            Some(result_data::Variant::Counts(_)) => "a ResultData.counts payload",
226            Some(result_data::Variant::Sums(_)) => "a ResultData.sums payload",
227            Some(result_data::Variant::Averages(_)) => "a ResultData.averages payload",
228            Some(result_data::Variant::Ranked(_)) => "a ResultData.ranked payload",
229            Some(result_data::Variant::Chained(_)) => "a ResultData.chained payload",
230            Some(result_data::Variant::Composite(_)) => "a ResultData.composite payload",
231        },
232    }
233}
234
235/// Decode one wire [`ProtoRankedEntry`] into rs-drive's
236/// [`RankedEntry`].
237///
238/// The `avg` arm re-scales the wire's `double` into the fixed-point
239/// `i128` [`RankedEntryValue`] carries, so callers see one type
240/// regardless of which path produced the page. The round trip is lossy
241/// in one direction only — the server divided an exact integer by
242/// [`RANKED_AVG_SCALE`], we multiply back and round — so the result is
243/// the closest fixed point to what the node reported, not necessarily
244/// the one it committed to. `from_unproved_response` documents that;
245/// exactness lives on the proof path.
246///
247/// A non-finite `avg`, or one that scales past `i128`, is rejected
248/// rather than saturated: `as` casts would silently turn `NaN` into
249/// `0` (an average of zero — a plausible-looking lie) and an
250/// out-of-range double into `i128::MIN`/`MAX`. Every legitimate value
251/// fits comfortably, since `|sum| ≤ i64::MAX` bounds the true fixed
252/// point at `i64::MAX * 10^19 ≈ 9.2e37 < i128::MAX`.
253pub(crate) fn ranked_entry_from_proto(entry: &ProtoRankedEntry) -> Result<RankedEntry, Error> {
254    let value = match entry.value.as_ref() {
255        Some(ranked_entry::Value::Count(count)) => RankedEntryValue::Count(*count),
256        Some(ranked_entry::Value::Sum(sum)) => RankedEntryValue::Sum(*sum),
257        Some(ranked_entry::Value::Avg(avg)) => {
258            let scaled = avg * (RANKED_AVG_SCALE as f64);
259            // `i128::MIN as f64` is exactly −2^127; `i128::MAX as f64`
260            // rounds *up* to 2^127, hence the asymmetric comparisons.
261            if !scaled.is_finite() || scaled < (i128::MIN as f64) || scaled >= -(i128::MIN as f64) {
262                return Err(Error::ResponseDecodeError {
263                    error: format!(
264                        "`avg` must be a finite double that scales into i128 range when \
265                         multiplied by {RANKED_AVG_SCALE}, got {avg}"
266                    ),
267                });
268            }
269            RankedEntryValue::AvgFixedPoint(scaled.round() as i128)
270        }
271        None => {
272            return Err(Error::ResponseDecodeError {
273                error: "ranked entry carries no `value`; the server always sets exactly one \
274                        of `count` / `sum` / `avg`"
275                    .to_string(),
276            });
277        }
278    };
279    Ok(RankedEntry {
280        // Present exactly on `IN`-pinned responses; the wire's absent
281        // state maps to the drive type's `None` untouched.
282        in_key: entry.in_key.clone(),
283        key: entry.key.clone(),
284        value,
285    })
286}
287
288/// Verify a grovedb indexed-axis top-k proof **and the surrounding
289/// tenderdash commit**, returning the reconstructed root hash and the
290/// [`RankedPage`] it commits to.
291///
292/// The page is returned whole rather than as a bare entry list because
293/// [`RankedPage::skipped`] is verified evidence in its own right: it is
294/// re-derived from the counted subtree commitments in the proof bytes,
295/// so it pins each entry to an absolute rank, and on a page past the
296/// end of the ranking it is the *only* payload — an attested total
297/// population under an empty entry list.
298///
299/// Thin tenderdash-composition wrapper over
300/// [`DriveDocumentRankedQuery::verify_ranked_top_k_proof`] in rs-drive
301/// (which does the merk-level verification). Both sides derive the
302/// proved subtree from the same
303/// `DriveDocumentRankedQuery::indexed_property_name_tree_path`, so
304/// prover and verifier cannot drift on *which* ranking is being
305/// checked, and grovedb re-executes the proof against the
306/// `(axis, k, offset, descending)` traversal rebuilt from the request —
307/// a proof of one ranking does not cover another.
308///
309/// ## The root hash is the whole point
310///
311/// The merk-level verifier returning `Ok` is **not** by itself
312/// evidence of anything. Sweeping every bit of a real ranked envelope
313/// shows why: most flips do error out, but roughly 9% of them (bytes
314/// of sibling-subtree hashes inside the ancestor layer proofs) verify
315/// cleanly and return the correct entries — under a *different*
316/// reconstructed root hash. What rejects those is the
317/// [`verify_tenderdash_proof`] call below, which checks the
318/// reconstructed root against the quorum-signed app hash for the
319/// response's block. This function exists so that composition can
320/// never be skipped by accident: there is no way to obtain the entries
321/// from it without the binding having run.
322///
323/// The `RootHash` is returned as well, already bound, so callers can
324/// log or cross-check it (e.g. against a root hash they verified for a
325/// different query at the same height). Callers must not treat it as
326/// something they still have to check.
327pub fn verify_ranked_top_k_proof(
328    query: &DriveDocumentRankedQuery,
329    proof: &Proof,
330    mtd: &ResponseMetadata,
331    platform_version: &PlatformVersion,
332    provider: &dyn ContextProvider,
333) -> Result<(RootHash, RankedPage), Error> {
334    let (root_hash, page) = query
335        .verify_ranked_top_k_proof(supported_grovedb_proof_bytes(proof)?, platform_version)
336        .map_drive_error(proof, mtd)?;
337
338    verify_tenderdash_proof(proof, mtd, &root_hash, provider)?;
339
340    Ok((root_hash, page))
341}
342
343/// Reject the generic [`FromProof`] entry point for
344/// [`DocumentRankedEntries`].
345///
346/// `DocumentRankedEntries` is reached from rs-sdk via the
347/// `FromProof<DocumentQuery>` impl defined alongside the SDK's
348/// `DocumentQuery` type (see
349/// `rs-sdk/src/platform/documents/document_ranked_entries.rs`), which
350/// resolves the `(axis, descending, k, offset)` tuple and the covering
351/// index from the request's `(select, group_by, order_by, limit,
352/// offset)` shape plus the data contract. The generic
353/// `FromProof<Q: TryInto<DriveDocumentQuery>>` path carries neither —
354/// `DriveDocumentQuery` has no notion of a ranking — so it errors out
355/// explicitly rather than verifying the wrong thing; calling this impl
356/// directly is a programmer mistake.
357impl<'dq, Q> FromProof<Q> for DocumentRankedEntries
358where
359    Q: TryInto<DriveDocumentQuery<'dq>> + Clone + 'dq,
360    Q::Error: std::fmt::Display,
361{
362    type Request = Q;
363    type Response = GetDocumentsResponse;
364
365    fn maybe_from_proof_with_metadata<'a, I: Into<Self::Request>, O: Into<Self::Response>>(
366        _request: I,
367        _response: O,
368        _network: Network,
369        _platform_version: &PlatformVersion,
370        _provider: &'a dyn ContextProvider,
371    ) -> Result<(Option<Self>, ResponseMetadata, Proof), Error>
372    where
373        Self: 'a,
374    {
375        Err(Error::RequestError {
376            error: "DocumentRankedEntries can't be verified via the generic FromProof path; \
377                 call DocumentRankedEntries::fetch on a DocumentQuery carrying \
378                 .with_select(<aggregate>), .with_group_by(<property>), \
379                 .order_by_selected_aggregate(<direction>) and .with_limit(n), which \
380                 resolves the ranking axis and the covering index from the data contract"
381                .to_string(),
382        })
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    //! Offline tests for the parts of the ranked surface that need
389    //! neither a grovedb proof nor a populated Drive:
390    //!
391    //! - the unproven `ResultData.ranked` decode, including order
392    //!   preservation, negative fixed-point averages, and the
393    //!   malformed-length rejection;
394    //! - the response-shape rejections (proof instead of data, wrong
395    //!   `ResultData` variant, V0 response);
396    //! - the generic `FromProof<Q>` impl that intentionally errors to
397    //!   prevent a silently-wrong verification.
398    //!
399    //! Proof verification itself is exercised end-to-end by rs-drive's
400    //! `drive_document_ranked_query::tests` (prover and verifier run
401    //! against a real Drive, with a bit-flip sweep asserting no tamper
402    //! survives with the honest root hash) and by rs-drive-abci's
403    //! `ranked_tests` (wire encoding of the same values). Reproducing
404    //! it here would need a populated Drive, which is outside this
405    //! crate's feature surface.
406    use super::*;
407    use dapi_grpc::platform::v0::get_documents_response::get_documents_response_v1::{
408        Documents, RankedEntries,
409    };
410    use dapi_grpc::platform::v0::get_documents_response::GetDocumentsResponseV1;
411    use drive::query::RANKED_AVG_SCALE;
412
413    fn count_entry(key: &str, count: u64) -> ProtoRankedEntry {
414        ProtoRankedEntry {
415            in_key: None,
416            key: key.as_bytes().to_vec(),
417            value: Some(ranked_entry::Value::Count(count)),
418        }
419    }
420
421    /// An entry as the *server* would emit it for a group whose exact
422    /// fixed-point average is `fixed_point`: the wire carries
423    /// `fixed_point as f64 / RANKED_AVG_SCALE as f64`, the same
424    /// conversion `RankedEntryValue::as_f64` performs in rs-drive-abci.
425    fn avg_entry(key: &str, fixed_point: i128) -> ProtoRankedEntry {
426        avg_entry_raw(key, (fixed_point as f64) / (RANKED_AVG_SCALE as f64))
427    }
428
429    /// An entry carrying an arbitrary double, for the malformed cases
430    /// that no fixed point maps to.
431    fn avg_entry_raw(key: &str, avg: f64) -> ProtoRankedEntry {
432        ProtoRankedEntry {
433            in_key: None,
434            key: key.as_bytes().to_vec(),
435            value: Some(ranked_entry::Value::Avg(avg)),
436        }
437    }
438
439    fn response_with(result: get_documents_response_v1::Result) -> GetDocumentsResponse {
440        GetDocumentsResponse {
441            version: Some(ResponseVersion::V1(GetDocumentsResponseV1 {
442                result: Some(result),
443                metadata: Some(ResponseMetadata {
444                    height: 42,
445                    ..Default::default()
446                }),
447            })),
448        }
449    }
450
451    fn ranked_response(entries: Vec<ProtoRankedEntry>) -> GetDocumentsResponse {
452        paged_ranked_response(entries, None)
453    }
454
455    fn paged_ranked_response(
456        entries: Vec<ProtoRankedEntry>,
457        skipped: Option<u64>,
458    ) -> GetDocumentsResponse {
459        response_with(get_documents_response_v1::Result::Data(ResultData {
460            variant: Some(result_data::Variant::Ranked(RankedEntries {
461                entries,
462                skipped,
463            })),
464        }))
465    }
466
467    /// The headline decode: `SELECT AVG(grade) … GROUP BY restaurantId
468    /// ORDER BY grade DESC LIMIT 3`. Entry order is the ranking order
469    /// and must survive the decode verbatim, and each double must
470    /// re-scale into the fixed point it was rendered from.
471    ///
472    /// These particular averages survive the double round trip *bit for
473    /// bit* — `95`, `85` and `10.5` times `10^19` all fit in `f64`'s 53
474    /// significand bits — so the assertion can be exact. That is a
475    /// property of the fixtures, not a guarantee of the wire format:
476    /// `from_unproved_response` reconstructs a best-effort fixed point,
477    /// and an average with more significant digits than `f64` holds
478    /// would come back slightly off. Exactness lives on the proof path.
479    #[test]
480    fn decodes_avg_entries_preserving_ranking_order() {
481        let response = ranked_response(vec![
482            avg_entry("gamma", 95 * RANKED_AVG_SCALE),
483            avg_entry("alpha", 85 * RANKED_AVG_SCALE),
484            // 21/2 = 10.5 — a non-integral average, so the fixed-point
485            // floor is actually exercised rather than a round multiple.
486            avg_entry("epsilon", (21 * RANKED_AVG_SCALE).div_euclid(2)),
487        ]);
488
489        let (decoded, metadata) = DocumentRankedEntries::from_unproved_response(&response)
490            .expect("a well-formed ranked payload decodes");
491
492        assert_eq!(metadata.height, 42, "metadata rides along with the entries");
493        let keys: Vec<&[u8]> = decoded.entries.iter().map(|e| e.key.as_slice()).collect();
494        assert_eq!(
495            keys,
496            vec![
497                b"gamma".as_slice(),
498                b"alpha".as_slice(),
499                b"epsilon".as_slice()
500            ],
501            "entry order is the ranking order and must not be re-sorted"
502        );
503        assert_eq!(
504            decoded.entries[2].value,
505            RankedEntryValue::AvgFixedPoint((21 * RANKED_AVG_SCALE).div_euclid(2))
506        );
507        assert_eq!(
508            decoded.entries[2].value.as_f64(),
509            10.5,
510            "dividing by RANKED_AVG_SCALE recovers the average a caller renders"
511        );
512    }
513
514    /// Averages are signed: a group whose summable property is
515    /// negative ranks below zero, and the sign must survive the double
516    /// round trip. A decoder that mishandled it would turn `-0.5` into
517    /// a positive number and silently invert the ranking's meaning.
518    ///
519    /// The last entry is the largest magnitude the axis can actually
520    /// produce — `sum = i64::MIN` over a single document — which pins
521    /// that the `i128`-range guard rejects only genuinely impossible
522    /// doubles, not legitimate extremes.
523    #[test]
524    fn decodes_negative_averages() {
525        let negative_half = (-RANKED_AVG_SCALE).div_euclid(2);
526        let extreme = (i64::MIN as i128) * RANKED_AVG_SCALE;
527        let response = ranked_response(vec![
528            avg_entry("above", RANKED_AVG_SCALE),
529            avg_entry("below", negative_half),
530            avg_entry("floor", extreme),
531        ]);
532
533        let (decoded, _) = DocumentRankedEntries::from_unproved_response(&response)
534            .expect("negative averages are well-formed");
535
536        assert_eq!(
537            decoded.entries[1].value,
538            RankedEntryValue::AvgFixedPoint(negative_half)
539        );
540        assert_eq!(decoded.entries[1].value.as_f64(), -0.5);
541        assert_eq!(
542            decoded.entries[2].value,
543            RankedEntryValue::AvgFixedPoint(extreme),
544            "the most negative average the axis can hold is decoded, not rejected"
545        );
546    }
547
548    /// Count entries decode to `u64` untouched — including values
549    /// above 2^53, which is why the wire field is `jstype = JS_STRING`.
550    #[test]
551    fn decodes_count_entries() {
552        let response = ranked_response(vec![
553            count_entry("delta", 4),
554            count_entry("beta", 3),
555            count_entry("huge", u64::MAX),
556        ]);
557
558        let (decoded, _) = DocumentRankedEntries::from_unproved_response(&response)
559            .expect("count entries are well-formed");
560
561        assert_eq!(
562            decoded.entries.iter().map(|e| e.value).collect::<Vec<_>>(),
563            vec![
564                RankedEntryValue::Count(4),
565                RankedEntryValue::Count(3),
566                RankedEntryValue::Count(u64::MAX)
567            ]
568        );
569    }
570
571    /// Sums are signed `sint64` on the wire, so a negative running
572    /// total (a group of refunds) must decode as-is.
573    #[test]
574    fn decodes_signed_sum_entries() {
575        let response = ranked_response(vec![ProtoRankedEntry {
576            in_key: None,
577            key: b"refunds".to_vec(),
578            value: Some(ranked_entry::Value::Sum(-1_000)),
579        }]);
580
581        let (decoded, _) = DocumentRankedEntries::from_unproved_response(&response)
582            .expect("signed sums are well-formed");
583
584        assert_eq!(decoded.entries[0].value, RankedEntryValue::Sum(-1_000));
585    }
586
587    /// An empty ranking is a legitimate answer, not an error: the
588    /// index simply has no groups yet.
589    #[test]
590    fn decodes_an_empty_ranking() {
591        let (decoded, _) = DocumentRankedEntries::from_unproved_response(&ranked_response(vec![]))
592            .expect("an empty ranking is well-formed");
593        assert!(decoded.entries.is_empty());
594        assert_eq!(decoded.starting_rank, 0);
595    }
596
597    /// `skipped` is what makes a page a ranking rather than a list.
598    /// A single entry at rank 4 is the *5th* best group, and the decode
599    /// must carry that through — dropping it would leave the caller
600    /// unable to distinguish it from the winner.
601    #[test]
602    fn decodes_the_starting_rank_of_a_page() {
603        let (decoded, _) = DocumentRankedEntries::from_unproved_response(&paged_ranked_response(
604            vec![avg_entry("epsilon", 10 * RANKED_AVG_SCALE)],
605            Some(4),
606        ))
607        .expect("a paged ranked payload decodes");
608        assert_eq!(decoded.starting_rank, 4);
609        assert_eq!(decoded.entries.len(), 1);
610    }
611
612    /// An offset past the end: no entries, but `skipped` still carries
613    /// the population. Empty entries plus a positive rank is a
614    /// meaningful answer ("there are only 12 groups"), not a
615    /// contradiction to be normalized away.
616    #[test]
617    fn decodes_a_page_past_the_end_of_the_ranking() {
618        let (decoded, _) =
619            DocumentRankedEntries::from_unproved_response(&paged_ranked_response(vec![], Some(12)))
620                .expect("a past-the-end page decodes");
621        assert!(decoded.entries.is_empty());
622        assert_eq!(decoded.starting_rank, 12);
623    }
624
625    /// A node that predates the wire field leaves `skipped` unset.
626    /// That must read as rank 0 — the right answer for the offset-less
627    /// queries such a node could serve at all — rather than as a decode
628    /// failure.
629    #[test]
630    fn an_absent_skipped_field_decodes_as_rank_zero() {
631        let (decoded, _) = DocumentRankedEntries::from_unproved_response(&paged_ranked_response(
632            vec![count_entry("delta", 4)],
633            None,
634        ))
635        .expect("an absent `skipped` is not a decode failure");
636        assert_eq!(decoded.starting_rank, 0);
637    }
638
639    /// An `avg` that cannot be a scaled fixed point is rejected rather
640    /// than cast. `as` casts on `f64 -> i128` saturate and map `NaN` to
641    /// `0`, so a malformed wire value would otherwise decode as a
642    /// confident average of zero (or of `i128::MIN`) — a
643    /// plausible-looking lie is far worse than a loud failure.
644    ///
645    /// This is the double-shaped replacement for the old
646    /// exactly-16-bytes length check: a `double` field has no length to
647    /// validate, but it does have values no legitimate average maps to.
648    #[test]
649    fn rejects_an_avg_that_is_not_a_scaled_fixed_point() {
650        for avg in [
651            f64::NAN,
652            f64::INFINITY,
653            f64::NEG_INFINITY,
654            // Past i128 range once multiplied by 10^19.
655            1e30,
656            -1e30,
657        ] {
658            let response = ranked_response(vec![avg_entry_raw("alpha", avg)]);
659            let err = match DocumentRankedEntries::from_unproved_response(&response) {
660                Err(err) => err,
661                Ok(decoded) => panic!("an `avg` of {avg} must be rejected, decoded {decoded:?}"),
662            };
663            let message = format!("{err}");
664            assert!(
665                message.contains("finite double") && message.contains("i128 range"),
666                "the rejection must name the contract it violates; got {message}"
667            );
668        }
669    }
670
671    /// A `RankedEntry` with no `value` set means the server (or a
672    /// middlebox) produced a message this client cannot interpret.
673    /// Defaulting it to zero would fabricate a ranking position.
674    #[test]
675    fn rejects_an_entry_with_no_value() {
676        let response = ranked_response(vec![ProtoRankedEntry {
677            in_key: None,
678            key: b"alpha".to_vec(),
679            value: None,
680        }]);
681        let err = DocumentRankedEntries::from_unproved_response(&response)
682            .expect_err("an entry with no value must be rejected");
683        assert!(format!("{err}").contains("no `value`"));
684    }
685
686    /// A proved response reaching the unproven decoder is a caller
687    /// mistake with a security consequence — silently returning
688    /// nothing (or erroring vaguely) would let a caller believe an
689    /// unverified path was the verified one. Name the right entry
690    /// point in the error.
691    #[test]
692    fn rejects_a_proof_response() {
693        let response = response_with(get_documents_response_v1::Result::Proof(Proof::default()));
694        let err = DocumentRankedEntries::from_unproved_response(&response)
695            .expect_err("a proof is not an unproven ranked payload");
696        assert!(format!("{err}").contains("verify_ranked_top_k_proof"));
697    }
698
699    /// A response on another `ResultData` variant means the node
700    /// routed the request somewhere else entirely (no ranking operand
701    /// reached it, or it landed on the count / sum / average
702    /// executor). Report the shape rather than an empty list.
703    #[test]
704    fn rejects_a_non_ranked_result_variant() {
705        let response = response_with(get_documents_response_v1::Result::Data(ResultData {
706            variant: Some(result_data::Variant::Documents(Documents {
707                documents: Vec::new(),
708            })),
709        }));
710        let err = DocumentRankedEntries::from_unproved_response(&response)
711            .expect_err("a documents payload is not a ranked one");
712        assert!(format!("{err}").contains("ResultData.ranked"));
713    }
714
715    /// The V0 `getDocuments` response predates the whole SQL-shaped
716    /// surface and has no ranked variant; a node answering V0 is a
717    /// node that cannot serve this query at all.
718    #[test]
719    fn rejects_a_v0_response() {
720        let response = GetDocumentsResponse {
721            version: Some(ResponseVersion::V0(Default::default())),
722        };
723        let err = DocumentRankedEntries::from_unproved_response(&response)
724            .expect_err("V0 has no ranked shape");
725        assert!(format!("{err}").contains("V1-only"));
726    }
727
728    /// A versionless response is a decode failure, not an empty
729    /// ranking.
730    #[test]
731    fn rejects_a_versionless_response() {
732        let err =
733            DocumentRankedEntries::from_unproved_response(&GetDocumentsResponse { version: None })
734                .expect_err("a versionless response must be rejected");
735        assert!(matches!(err, Error::EmptyVersion));
736    }
737
738    /// `from_verified` is what the SDK's proof path wraps a verified
739    /// [`RankedPage`] with. Both halves must survive: dropping
740    /// `skipped` here would silently turn every proved page into a
741    /// claim about rank 0.
742    #[test]
743    fn from_verified_carries_both_halves_of_the_page() {
744        let entries = vec![
745            RankedEntry {
746                in_key: None,
747                key: b"gamma".to_vec(),
748                value: RankedEntryValue::Count(9),
749            },
750            RankedEntry {
751                in_key: None,
752                key: b"alpha".to_vec(),
753                value: RankedEntryValue::Count(2),
754            },
755        ];
756        let wrapped = DocumentRankedEntries::from_verified(RankedPage {
757            skipped: 7,
758            entries: entries.clone(),
759        });
760        assert_eq!(wrapped.entries, entries);
761        assert_eq!(
762            wrapped.starting_rank, 7,
763            "the attested skip is the page's rank base and must not be dropped"
764        );
765    }
766
767    // The generic `FromProof<Q>` rejection is covered by the SDK's
768    // tests, which can construct a valid `DriveDocumentQuery` via
769    // dpp's `fixtures-and-mocks` feature. drive-proof-verifier itself
770    // doesn't depend on `dpp/fixtures-and-mocks` outside dev-deps for
771    // the contract fixtures that path needs.
772}