drive_proof_verifier/proof/document_split_sum.rs
1//! Verified per-entry sum result.
2//!
3//! Sum-side analog of [`super::document_split_count::DocumentSplitCounts`].
4//! Holds one verified `(in_key, key, sum)` triple per matched group.
5//! Returned by `select=SUM, group_by=[...]` queries; aggregate sums
6//! use [`super::document_sum::DocumentSum`] instead.
7//!
8//! The generic `FromProof<Q>` impl below intentionally rejects
9//! calls (matching [`super::document_split_count::DocumentSplitCounts`]'s
10//! pattern). Real dispatch lives in the
11//! `FromProof<DocumentQuery>` impl in
12//! `rs-sdk/src/platform/documents/document_split_sums.rs`, which
13//! picks the right per-shape verifier (carrier-aggregate /
14//! point-lookup) based on the resolved `DocumentSumMode`.
15
16/// A single verified `(in_key?, key, sum)` entry from a sum query
17/// with `group_by`. Mirrors count's `SplitCountEntry` shape.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SplitSumEntry {
20 /// Outer In-prefix value for compound `(In, range)` queries;
21 /// `None` for flat queries.
22 pub in_key: Option<Vec<u8>>,
23 /// The terminator key value.
24 pub key: Vec<u8>,
25 /// The aggregated sum at that key. `Some(n)` for matched keys;
26 /// `None` for keys proven absent.
27 pub sum: Option<i64>,
28}
29
30/// The full per-entry sum result, verified from proof.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct DocumentSplitSums(pub Vec<SplitSumEntry>);
33
34impl DocumentSplitSums {
35 /// Convenience: collapse compound `(in_key, key)` entries into a
36 /// flat `BTreeMap<key, summed_sum>` by combining each In-fork's
37 /// contribution at the same terminator key. Same shape as
38 /// `DocumentSplitCounts::into_flat_map` on the count side.
39 ///
40 /// Uses [`i64::checked_add`] on each accumulator step and
41 /// surfaces overflow as
42 /// [`Error::RequestError`](crate::Error::RequestError) rather
43 /// than panicking (debug) or wrapping (release). Mirrors the
44 /// SDK's aggregate-side `DocumentSum::fold_sum_entries` so a
45 /// wasm caller that collapses split sums lands at the same
46 /// hardening boundary the SDK aggregate path already enforces.
47 ///
48 /// On overflow, drop back to iterating `DocumentSplitSums.0`
49 /// directly — per-branch entries preserve the verified `i64`
50 /// values, and the caller can fold with its own arithmetic
51 /// (e.g. `i128`).
52 pub fn try_into_flat_map(
53 self,
54 ) -> Result<std::collections::BTreeMap<Vec<u8>, i64>, crate::Error> {
55 let mut out: std::collections::BTreeMap<Vec<u8>, i64> = std::collections::BTreeMap::new();
56 for entry in self.0 {
57 if let Some(sum) = entry.sum {
58 let slot = out.entry(entry.key).or_insert(0i64);
59 *slot =
60 slot.checked_add(sum).ok_or_else(|| {
61 crate::Error::RequestError {
62 error:
63 "DocumentSplitSums::try_into_flat_map: i64 overflow merging per-In-fork \
64 sums at the same terminator key. The verified per-branch entries are \
65 each valid i64, but their fold exceeds the i64 range — iterate \
66 DocumentSplitSums.0 directly to access per-branch sums and fold with \
67 your own arithmetic (e.g. i128)."
68 .to_string(),
69 }
70 })?;
71 }
72 }
73 Ok(out)
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 /// `try_into_flat_map` over a single In-fork must pass the sum
82 /// through unchanged — the baseline check the overflow guard
83 /// has to preserve.
84 #[test]
85 fn try_into_flat_map_passes_through_flat_entries() {
86 let splits = DocumentSplitSums(vec![SplitSumEntry {
87 in_key: None,
88 key: b"key-a".to_vec(),
89 sum: Some(123),
90 }]);
91 let flat = splits.try_into_flat_map().expect("non-overflowing fold");
92 assert_eq!(flat.len(), 1);
93 assert_eq!(flat.get(b"key-a".as_slice()), Some(&123));
94 }
95
96 /// Two In-forks landing at the same terminator key get summed.
97 #[test]
98 fn try_into_flat_map_sums_across_in_forks() {
99 let splits = DocumentSplitSums(vec![
100 SplitSumEntry {
101 in_key: Some(b"alice".to_vec()),
102 key: b"red".to_vec(),
103 sum: Some(10),
104 },
105 SplitSumEntry {
106 in_key: Some(b"bob".to_vec()),
107 key: b"red".to_vec(),
108 sum: Some(32),
109 },
110 SplitSumEntry {
111 in_key: Some(b"alice".to_vec()),
112 key: b"blue".to_vec(),
113 sum: Some(7),
114 },
115 ]);
116 let flat = splits.try_into_flat_map().expect("non-overflowing fold");
117 assert_eq!(flat.get(b"red".as_slice()), Some(&42));
118 assert_eq!(flat.get(b"blue".as_slice()), Some(&7));
119 }
120
121 /// Crossing `i64::MAX` from two valid per-branch i64s must
122 /// surface as `RequestError`, not panic / wrap. This is the
123 /// exact boundary the SDK's aggregate-side fold already
124 /// hardens for `DocumentSum`.
125 #[test]
126 fn try_into_flat_map_overflow_surfaces_request_error() {
127 let splits = DocumentSplitSums(vec![
128 SplitSumEntry {
129 in_key: Some(b"alice".to_vec()),
130 key: b"shared".to_vec(),
131 sum: Some(i64::MAX),
132 },
133 SplitSumEntry {
134 in_key: Some(b"bob".to_vec()),
135 key: b"shared".to_vec(),
136 sum: Some(1),
137 },
138 ]);
139 let err = splits
140 .try_into_flat_map()
141 .expect_err("i64::MAX + 1 must surface as overflow error, not wrap or panic");
142 let msg = format!("{err:?}");
143 assert!(
144 msg.contains("i64 overflow") && msg.contains("DocumentSplitSums"),
145 "error message must name the overflow and the helper: got {msg}"
146 );
147 }
148
149 /// Underflow on the negative end must surface symmetrically.
150 #[test]
151 fn try_into_flat_map_underflow_surfaces_request_error() {
152 let splits = DocumentSplitSums(vec![
153 SplitSumEntry {
154 in_key: Some(b"alice".to_vec()),
155 key: b"shared".to_vec(),
156 sum: Some(i64::MIN),
157 },
158 SplitSumEntry {
159 in_key: Some(b"bob".to_vec()),
160 key: b"shared".to_vec(),
161 sum: Some(-1),
162 },
163 ]);
164 let err = splits
165 .try_into_flat_map()
166 .expect_err("i64::MIN - 1 must surface as overflow error, not wrap or panic");
167 let msg = format!("{err:?}");
168 assert!(msg.contains("i64 overflow"), "got {msg}");
169 }
170
171 /// `None`-sum entries are skipped (the verifier emits them for
172 /// keys proven absent). They must not poison the accumulator
173 /// or trigger a spurious overflow signal.
174 #[test]
175 fn try_into_flat_map_skips_absent_entries() {
176 let splits = DocumentSplitSums(vec![
177 SplitSumEntry {
178 in_key: None,
179 key: b"key-a".to_vec(),
180 sum: None,
181 },
182 SplitSumEntry {
183 in_key: None,
184 key: b"key-b".to_vec(),
185 sum: Some(5),
186 },
187 ]);
188 let flat = splits.try_into_flat_map().expect("absent entries skipped");
189 assert!(!flat.contains_key(b"key-a".as_slice()));
190 assert_eq!(flat.get(b"key-b".as_slice()), Some(&5));
191 }
192}
193
194// No generic `FromProof<Q>` impl — callers reach this through
195// `FromProof<DocumentQuery> for DocumentSplitSums` in
196// `rs-sdk/src/platform/documents/document_split_sums.rs`. Same
197// rationale as `DocumentSum` / `DocumentSplitCounts`: per-mode
198// proof dispatch needs the SDK's `DocumentQuery` shape.