Skip to main content

rushdb_query/
aggregation.rs

1//! Budgeted grouping, aggregate evaluation, and result projection.
2
3use super::{
4    aggregate_budget, attach_collects_with_budget, json_value_cmp, normalize, reconstruct,
5    related_specs_from_where, working_set_budget, AggregateRequest, Arc, Budget, Cap, GroupBucket,
6    Json, Lmpg, Map, OutputExpr, PointFields, QueryError, QuerySnapshot, SearchRequest,
7};
8use crate::aggregate_catalog::dictionary_grouped_count;
9use crate::aggregate_rows::{row_bytes, Partial, RowCtx, RowPlan};
10use crate::aggregate_scan::{self, Mode, Rows, Scan};
11use crate::page::serialized_bytes;
12
13/// Branch-grain aggregation over a `BindingRelation` of distinct roots:
14/// one root contributes exactly one binding row, independent branches are
15/// filtered existentially rather than multiplied into flat rows.
16///
17/// # Errors
18///
19/// Propagates storage/engine read errors and predicate evaluation failures as
20/// [`QueryError`].
21pub fn aggregate(lmpg: &Lmpg, req: &AggregateRequest) -> Result<Vec<GroupBucket>, QueryError> {
22    aggregate_with_cancel(lmpg, req, &|| false).map(|(buckets, _)| buckets)
23}
24
25/// Maximum number of distinct buckets a single aggregation may create before
26/// it is refused with [`QueryError::ResourceLimit`].
27pub const MAX_AGGREGATE_BUCKETS: usize = 1_000;
28
29/// Aggregate execution with cooperative cancellation at root and bucket
30/// boundaries. Returns the completed buckets plus the total number of matched
31/// roots (the `total` reported by the data plane, matching a search result's
32/// total).
33///
34/// # Errors
35///
36/// Returns [`QueryError::Cancelled`] when the probe requests a stop, and
37/// [`QueryError::ResourceLimit`] when more than [`MAX_AGGREGATE_BUCKETS`]
38/// distinct groups would be created. Storage/engine and predicate errors are
39/// propagated from the underlying reads.
40///
41/// # Panics
42///
43/// Panics only if a bucket just inserted by `ensure_bucket` cannot be
44/// retrieved, which is unreachable.
45pub fn aggregate_with_cancel(
46    lmpg: &Lmpg,
47    req: &AggregateRequest,
48    is_cancelled: &dyn Fn() -> bool,
49) -> Result<(Vec<GroupBucket>, u64), QueryError> {
50    let snap = Arc::new(lmpg.store.read_view()?);
51    aggregate_with_cancel_in_view(lmpg, req, is_cancelled, snap)
52}
53
54/// Aggregate execution over a caller-owned pinned read view. Embedded
55/// engines use this to keep every phase of a request on one snapshot and to
56/// apply the same deadline/budget context as the transport path.
57///
58/// # Errors
59/// Propagates storage, predicate, cancellation and aggregate resource errors.
60pub fn aggregate_in_view(
61    lmpg: &Lmpg,
62    req: &AggregateRequest,
63    snap: QuerySnapshot,
64) -> Result<(Vec<GroupBucket>, u64), QueryError> {
65    aggregate_with_cancel_in_view(lmpg, req, &|| false, snap)
66}
67
68/// Execute an aggregate on the supplied view.
69///
70/// The working set (candidate ordinals, the row being aggregated, the
71/// buckets) is capped by `query-memory-max`, tightened by the request, the
72/// same cap a records search uses. It is never the request's response byte
73/// budget: that caps only the rendered page
74/// ([`aggregate_to_rows_in_view_with_budget`]). Charging the working set
75/// against the 16 MiB response budget refused every grouped aggregate over
76/// ~1M records (the candidate ordinals alone filled it).
77///
78/// # Errors
79/// Returns storage, predicate, cancellation, or resource errors.
80pub fn aggregate_with_cancel_in_view(
81    lmpg: &Lmpg,
82    req: &AggregateRequest,
83    is_cancelled: &dyn Fn() -> bool,
84    snap: QuerySnapshot,
85) -> Result<(Vec<GroupBucket>, u64), QueryError> {
86    let is_cancelled: &dyn Fn() -> bool = &crate::cancel_probe(lmpg, is_cancelled);
87    if !snap.belongs_to(&lmpg.store) {
88        return Err(rushdb_storage::StorageError::InvalidRead(
89            "aggregate view belongs to another Store".into(),
90        )
91        .into());
92    }
93    if is_cancelled() {
94        return Err(QueryError::Cancelled);
95    }
96    let budget = working_set_budget(lmpg);
97    Budget::WorkingSet.check(budget, std::mem::size_of::<GroupBucket>())?;
98    aggregate_budgeted(lmpg, req, is_cancelled, snap, budget)
99        .map_err(aggregate_budget::resource_error)
100}
101
102#[expect(
103    clippy::too_many_lines,
104    clippy::cast_precision_loss,
105    clippy::needless_pass_by_value,
106    reason = "aggregate accumulation keeps one live budget and pinned snapshot"
107)]
108fn aggregate_budgeted(
109    lmpg: &Lmpg,
110    req: &AggregateRequest,
111    is_cancelled: &dyn Fn() -> bool,
112    snap: QuerySnapshot,
113    budget: Option<usize>,
114) -> Result<(Vec<GroupBucket>, u64), QueryError> {
115    let page_req = SearchRequest {
116        labels: req.labels.clone(),
117        where_: req.where_.clone(),
118        related: req.related.clone(),
119        ..SearchRequest::default()
120    };
121    // Count-only fast path: a single global `$count: "*"` with no grouping or
122    // other metrics needs only the match count. A single unscoped label is
123    // answered straight from the maintained label catalog (O(labels)); other
124    // shapes fall through to the ordinal matcher below, which never
125    // reconstructs records to produce a count.
126    if is_count_only(req) {
127        if let Some(total) = catalog_count_total(lmpg, &page_req, &snap)? {
128            let mut buckets = count_only_bucket(total);
129            apply_aggregate_pagination(&mut buckets, req);
130            return Ok((buckets, total));
131        }
132    }
133    // Grouped counts over one property: the value dictionary holds the
134    // exact owner count per value, so no record is read.
135    if let Some(buckets) = dictionary_grouped_count(lmpg, req, &snap, is_cancelled)? {
136        Budget::WorkingSet.check(
137            budget,
138            buckets.iter().fold(0usize, |sum, b| {
139                sum.saturating_add(aggregate_budget::bucket_bytes(b))
140            }),
141        )?;
142        let total = buckets.iter().map(|b| b.count_roots).sum();
143        return Ok((order_and_page(buckets, req), total));
144    }
145    // Only the properties the request names are read: by point reads when
146    // every one is a scalar (READ-1's `PointFields`), else by folding the
147    // record's occurrences into just those names. Family definitions are
148    // frozen within the pinned snapshot.
149    let plan = RowPlan::new(req, related_specs_from_where(req.where_.as_ref()));
150    let points = PointFields::resolve_in(
151        &snap,
152        lmpg.tenant,
153        &req.labels,
154        plan.names.iter().map(String::as_str),
155    )?;
156    let selected = Rows::selection(&plan);
157    let rows = Rows {
158        points: points.as_ref(),
159        selected: &selected,
160    };
161    let scan = Scan {
162        lmpg,
163        search: &page_req,
164        snap: &snap,
165        budget,
166        plan: &plan,
167    };
168    let mode = if is_count_only(req) {
169        Mode::Count
170    } else if plan.per_row() {
171        Mode::Roots
172    } else {
173        Mode::Groups
174    };
175    let pass = aggregate_scan::run(&scan, &rows, mode, is_cancelled)?;
176    let total_matched = pass.total;
177    let candidate_bytes = pass.candidate_bytes;
178    let mut buckets = match mode {
179        Mode::Count => {
180            let mut buckets = count_only_bucket(total_matched);
181            apply_aggregate_pagination(&mut buckets, req);
182            return Ok((buckets, total_matched));
183        }
184        Mode::Roots => per_root_rows(&scan, &rows, &pass, req, is_cancelled)?,
185        Mode::Groups => pass.groups.finish(&plan),
186    };
187    let retained_bytes = buckets.iter().fold(candidate_bytes, |sum, b| {
188        sum.saturating_add(aggregate_budget::bucket_bytes(b))
189    });
190    Budget::WorkingSet.check(budget, retained_bytes)?;
191
192    // Derived outputs ($ref chains): source sums materialize first, then
193    // refs resolve in declaration order; cycles surface as unresolved refs.
194    // The candidate ordinals are no longer needed; what stays is the buckets
195    // plus one derived value per output and bucket.
196    if !req.outputs.is_empty() || !req.avg_terms.is_empty() || !req.expr_avgs.is_empty() {
197        let output_bytes = req
198            .outputs
199            .iter()
200            .map(|(name, _)| name)
201            .chain(req.avg_terms.iter().map(|(name, _)| name))
202            .chain(req.expr_avgs.iter().map(|(name, _)| name))
203            .fold(0usize, |sum, name| {
204                sum.saturating_add(name.len()).saturating_add(256)
205            })
206            .saturating_mul(buckets.len());
207        Budget::WorkingSet.check(
208            budget,
209            retained_bytes
210                .saturating_sub(candidate_bytes)
211                .saturating_add(output_bytes),
212        )?;
213        for b in &mut buckets {
214            if is_cancelled() {
215                return Err(QueryError::Cancelled);
216            }
217            let mut resolved: std::collections::BTreeMap<String, f64> = b
218                .sums
219                .clone()
220                .into_iter()
221                .filter_map(|(k, v)| v.as_f64().map(|x| (k, x)))
222                .collect();
223            resolved.insert("count_roots".into(), b.count_roots as f64);
224            // Count outputs are also valid `$ref` targets in derived math
225            // (`$count: "*"` → the root count; other counts → occurrence
226            // count, which equals the distinct related-record count because
227            // the traversal already deduplicates).
228            for (out, field) in &req.counts {
229                if req.hidden_outputs.contains(out) {
230                    continue;
231                }
232                let value = if field.as_str() == "*" {
233                    b.count_roots as f64
234                } else {
235                    b.counts
236                        .get(out)
237                        .and_then(serde_json::Value::as_u64)
238                        .map_or(0.0, |n| n as f64)
239                };
240                resolved.insert(out.clone(), value);
241            }
242            // avg aliases: value = sum / non-null count, precision applied
243            for name in req
244                .avg_terms
245                .iter()
246                .map(|(n, _)| n)
247                .chain(req.expr_avgs.iter().map(|(n, _)| n))
248            {
249                let sum = resolved.get(name).copied().unwrap_or(0.0);
250                let n = b
251                    .counts
252                    .get(&format!("__avg_n_{name}"))
253                    .and_then(serde_json::Value::as_u64)
254                    .unwrap_or(0);
255                let avg = if n > 0 { sum / n as f64 } else { 0.0 };
256                let avg = match req.avg_precision.get(name) {
257                    // reference `toInteger(avg(...))`: truncate toward zero
258                    Some(&0) => avg.trunc(),
259                    Some(&p) => {
260                        // Defensive: the parser clamps to `MAX_AVG_PRECISION`,
261                        // but a directly built request must not overflow.
262                        let p = p.min(crate::MAX_AVG_PRECISION);
263                        let m = 10f64.powi(i32::try_from(p).unwrap_or(0));
264                        (avg * m).round() / m
265                    }
266                    None => avg,
267                };
268                resolved.insert(name.clone(), avg);
269                b.sums.insert(name.clone(), serde_json::json!(avg));
270            }
271            for (name, expr) in &req.outputs {
272                eval_output(&normalize(name), expr, &mut resolved)?;
273            }
274            for (k, v) in &resolved {
275                b.derived_outputs.insert(k.clone(), serde_json::json!(v));
276                // mirror derived outputs into sums so orderBy sees them
277                b.sums
278                    .entry(k.clone())
279                    .or_insert_with(|| serde_json::json!(v));
280            }
281        }
282    }
283
284    Ok((order_and_page(buckets, req), total_matched))
285}
286
287/// Per-root rows (a `select` of root fields or collects without
288/// `groupBy`): each matched root is its own row, so rows are bounded by the
289/// paging and the memory budget, not by the group cap; without an `orderBy`
290/// only the requested page is read.
291fn per_root_rows(
292    scan: &Scan<'_>,
293    rows: &Rows<'_>,
294    pass: &aggregate_scan::Pass,
295    req: &AggregateRequest,
296    is_cancelled: &dyn Fn() -> bool,
297) -> Result<Vec<GroupBucket>, QueryError> {
298    let roots = if req.order_by.is_empty() {
299        let page_end = req.skip.saturating_add(req.limit.unwrap_or(u64::MAX));
300        &pass.roots[..usize::try_from(page_end)
301            .unwrap_or(usize::MAX)
302            .min(pass.roots.len())]
303    } else {
304        &pass.roots[..]
305    };
306    let (snap, tenant, budget) = (scan.snap, scan.lmpg.tenant, scan.budget);
307    let mut session = reconstruct::ReconstructSession::new(snap, tenant);
308    let mut partial = Partial::default();
309    let mut row = Vec::new();
310    let mut retained = pass
311        .candidate_bytes
312        .saturating_add(pass.roots.len().saturating_mul(8));
313    for &ord in roots {
314        if is_cancelled() {
315            return Err(QueryError::Cancelled);
316        }
317        if !rows.read(scan.plan, snap, tenant, &mut session, ord, &mut row)? {
318            continue;
319        }
320        let held = retained.saturating_add(row_bytes(&row));
321        Budget::WorkingSet.check(budget, held)?;
322        let ctx = RowCtx {
323            lmpg: scan.lmpg,
324            snap,
325            ord,
326            alias_budget: budget.map(|limit| {
327                limit.saturating_sub(held) / scan.plan.metric_count().saturating_add(1)
328            }),
329        };
330        retained = retained.saturating_add(partial.accumulate(scan.plan, &row, &ctx)?);
331        Budget::WorkingSet.check(budget, retained)?;
332    }
333    Ok(partial.finish(scan.plan))
334}
335
336/// Aggregate, then paginate: complete buckets first, then deterministic
337/// ordering by output name and a single explicit slice domain.
338fn order_and_page(mut buckets: Vec<GroupBucket>, req: &AggregateRequest) -> Vec<GroupBucket> {
339    for (name, desc) in req.order_by.iter().rev() {
340        buckets.sort_by(|a, b| {
341            let va = bucket_metric(a, name, req);
342            let vb = bucket_metric(b, name, req);
343            let ord = compare_json_metric(va.as_ref(), vb.as_ref());
344            if *desc {
345                ord.reverse()
346            } else {
347                ord
348            }
349        });
350    }
351    if buckets.is_empty() && is_global_rollup(req) {
352        // As in SQL, a rollup over no matches is one row: counts are 0.
353        buckets.push(GroupBucket::default());
354    }
355    apply_aggregate_pagination(&mut buckets, req);
356    buckets
357}
358
359/// A metric with no value over a global rollup's matches is `null`, as in
360/// SQL.
361fn fill_absent_metrics(row: &mut Map<String, Json>, req: &AggregateRequest) {
362    if !is_global_rollup(req) {
363        return;
364    }
365    let metrics = req
366        .sums
367        .iter()
368        .chain(&req.avg_terms)
369        .map(|(out, _)| out)
370        .chain(req.expr_sums.iter().map(|(out, _)| out))
371        .chain(req.expr_avgs.iter().map(|(out, _)| out))
372        .chain(req.mins.iter().map(|(out, _)| out))
373        .chain(req.maxs.iter().map(|(out, _)| out))
374        .chain(req.outputs.iter().map(|(out, _)| out));
375    for out in metrics {
376        if !req.hidden_outputs.contains(out) {
377            row.entry(out.clone()).or_insert(Json::Null);
378        }
379    }
380}
381
382/// True when metrics roll up into one global row: some metric, and no
383/// grouping, time buckets, per-root fields or collects. Such a rollup always
384/// has its row.
385fn is_global_rollup(req: &AggregateRequest) -> bool {
386    let metrics = !req.counts.is_empty()
387        || !req.sums.is_empty()
388        || !req.mins.is_empty()
389        || !req.maxs.is_empty()
390        || !req.outputs.is_empty()
391        || !req.expr_sums.is_empty()
392        || !req.expr_avgs.is_empty()
393        || !req.avg_terms.is_empty();
394    metrics
395        && req.group_by.is_empty()
396        && req.time_bucket.is_none()
397        && req.plain_fields.is_empty()
398        && req.collects.is_empty()
399}
400
401/// True when an aggregate is exactly a global `$count: "*"` (no grouping, no
402/// other metrics, no per-root projection/collect). Such a request needs only
403/// the match count, never per-record reconstruction.
404fn is_count_only(req: &AggregateRequest) -> bool {
405    req.group_by.is_empty()
406        && req.plain_fields.is_empty()
407        && req.collects.is_empty()
408        && req.sums.is_empty()
409        && req.mins.is_empty()
410        && req.maxs.is_empty()
411        && req.outputs.is_empty()
412        && req.expr_sums.is_empty()
413        && req.expr_avgs.is_empty()
414        && req.avg_terms.is_empty()
415        && req.time_bucket.is_none()
416        && !req.counts.is_empty()
417        && req.counts.iter().all(|(_, field)| field == "*")
418}
419
420/// One row's worth of `count_roots` (zero matches is a row of 0, as the
421/// general rollup gives).
422fn count_only_bucket(total: u64) -> Vec<GroupBucket> {
423    vec![GroupBucket {
424        count_roots: total,
425        ..GroupBucket::default()
426    }]
427}
428
429fn apply_aggregate_pagination(buckets: &mut Vec<GroupBucket>, req: &AggregateRequest) {
430    if req.skip > 0 || req.limit.is_some() {
431        let start = usize::try_from(req.skip)
432            .unwrap_or(usize::MAX)
433            .min(buckets.len());
434        *buckets = buckets[start..]
435            .iter()
436            .take(usize::try_from(req.limit.unwrap_or(u64::MAX)).unwrap_or(usize::MAX))
437            .cloned()
438            .collect();
439    }
440}
441
442/// O(labels) count for a single unscoped label from the maintained catalog.
443/// Returns `None` when the shape needs the ordinal matcher (predicates,
444/// traversal, multiple labels, or a label absent from the catalog).
445fn catalog_count_total(
446    lmpg: &Lmpg,
447    req: &SearchRequest,
448    snap: &QuerySnapshot,
449) -> Result<Option<u64>, QueryError> {
450    if req.where_.is_some() || !req.related.is_empty() || !req.order_by.is_empty() {
451        return Ok(None);
452    }
453    if req.labels.len() != 1 {
454        return Ok(None);
455    }
456    // The on-disk label counter is exact and snapshot-consistent (DC1/DC12).
457    Ok(Some(rushdb_lmpg::derived::label_count(
458        snap,
459        lmpg.tenant,
460        &req.labels[0],
461    )?))
462}
463
464/// Shape aggregate buckets into flat result rows: group-by dimensions (incl.
465/// timeBucket aliases), count/sum outputs, and derived outputs keyed by their
466/// output name.
467///
468/// # Errors
469///
470/// Propagates storage/engine read errors when opening the snapshot or
471/// attaching per-root `$collect` shapes.
472pub fn aggregate_to_rows(
473    lmpg: &Lmpg,
474    buckets: &[GroupBucket],
475    req: &AggregateRequest,
476) -> Result<Vec<Json>, QueryError> {
477    let snap: QuerySnapshot = Arc::new(lmpg.store.read_view()?);
478    aggregate_to_rows_in_view(lmpg, &snap, buckets, req)
479}
480
481/// Shape aggregate buckets using a caller-owned snapshot.
482///
483/// Keeping the view supplied by the caller prevents `$collect` reconstruction
484/// from observing a later commit than the aggregate scan itself.
485///
486/// # Errors
487///
488/// Propagates storage/engine read errors while attaching per-root collects.
489pub fn aggregate_to_rows_in_view(
490    lmpg: &Lmpg,
491    snap: &QuerySnapshot,
492    buckets: &[GroupBucket],
493    req: &AggregateRequest,
494) -> Result<Vec<Json>, QueryError> {
495    aggregate_to_rows_in_view_with_budget(lmpg, snap, buckets, req, None)
496}
497
498/// Shape aggregate output against the response budget.
499///
500/// The buckets were already admitted under `query-memory-max` while they
501/// accumulated. What rendering adds is the response: each row is charged by
502/// its real serialized bytes, `$collect`s included, as it is rendered,
503/// against `budget` (the response size limit), or `query-memory-max` when
504/// the caller gave none.
505///
506/// # Errors
507/// Returns storage or resource errors while reconstructing collects.
508pub fn aggregate_to_rows_in_view_with_budget(
509    lmpg: &Lmpg,
510    snap: &QuerySnapshot,
511    buckets: &[GroupBucket],
512    req: &AggregateRequest,
513    budget: Option<usize>,
514) -> Result<Vec<Json>, QueryError> {
515    let cap = Cap::for_response(budget, lmpg.limits().query_memory_max_bytes());
516    let snap = cap.map_or_else(
517        || Arc::clone(snap),
518        |cap| snap.with_materialization_limit(cap.limit() / 16),
519    );
520    let mut charged = 0usize;
521    let mut rows = Vec::with_capacity(buckets.len());
522    for b in buckets {
523        let mut row = b.keys.clone();
524        for (out, field) in &req.counts {
525            if field == "*" {
526                row.insert(out.clone(), Json::Number((b.count_roots).into()));
527            } else {
528                let n = map_metric(&b.counts, out).and_then(serde_json::Value::as_u64);
529                row.insert(out.clone(), Json::Number(n.unwrap_or(0).into()));
530            }
531        }
532        for (out, _field) in &req.sums {
533            if req.hidden_outputs.contains(out) {
534                continue;
535            }
536            if let Some(v) = b.sums.get(out) {
537                row.insert(out.clone(), v.clone());
538            }
539        }
540        for out in req
541            .expr_sums
542            .iter()
543            .map(|(out, _)| out)
544            .chain(req.avg_terms.iter().map(|(out, _)| out))
545            .chain(req.expr_avgs.iter().map(|(out, _)| out))
546        {
547            if !req.hidden_outputs.contains(out) {
548                if let Some(v) = b.sums.get(out) {
549                    row.insert(out.clone(), v.clone());
550                }
551            }
552        }
553        for (out, _) in &req.mins {
554            if !req.hidden_outputs.contains(out) {
555                if let Some(v) = b.mins.get(out) {
556                    row.insert(out.clone(), v.clone());
557                }
558            }
559        }
560        for (out, _) in &req.maxs {
561            if !req.hidden_outputs.contains(out) {
562                if let Some(v) = b.maxs.get(out) {
563                    row.insert(out.clone(), v.clone());
564                }
565            }
566        }
567        for (out, _) in &req.outputs {
568            if let Some(v) = b.derived_outputs.get(&normalize(out)) {
569                row.insert(out.clone(), v.clone());
570            }
571        }
572        fill_absent_metrics(&mut row, req);
573        // Attach per-root `$collect` shapes (docs' "aggregation with select"):
574        // the row keeps its aggregate metrics and gains the collected arrays.
575        let mut row_json = Json::Object(row);
576        if let Some(ord) = b.root.filter(|_| !req.collects.is_empty()) {
577            row_json = attach_collects_with_budget(
578                lmpg,
579                &snap,
580                ord,
581                row_json,
582                &req.collects,
583                cap.map(|cap| cap.after(charged)),
584                &|| false,
585            )
586            .map_err(aggregate_budget::resource_error)?;
587        }
588        if cap.is_some() {
589            charged = charged.saturating_add(serialized_bytes(&row_json));
590            Cap::check_opt(cap, charged)?;
591        }
592        rows.push(row_json);
593    }
594    Ok(rows)
595}
596
597fn map_metric<'a>(map: &'a Map<String, Json>, name: &str) -> Option<&'a Json> {
598    let normalized = normalize(name);
599    map.iter()
600        .find(|(key, _)| normalize(key) == normalized)
601        .map(|(_, value)| value)
602}
603
604fn bucket_metric(b: &GroupBucket, name: &str, req: &AggregateRequest) -> Option<Json> {
605    if normalize(name) == "count_roots" {
606        return Some(Json::Number(b.count_roots.into()));
607    }
608    if let Some((output, field)) = req
609        .counts
610        .iter()
611        .find(|(output, _)| normalize(output) == normalize(name))
612    {
613        if field == "*" {
614            return Some(Json::Number(b.count_roots.into()));
615        }
616        return map_metric(&b.counts, output)
617            .map(|values| Json::Number(values.as_u64().unwrap_or(0).into()));
618    }
619    map_metric(&b.sums, name)
620        .or_else(|| map_metric(&b.mins, name))
621        .or_else(|| map_metric(&b.maxs, name))
622        .or_else(|| map_metric(&b.keys, name))
623        .cloned()
624}
625
626fn compare_json_metric(a: Option<&Json>, b: Option<&Json>) -> std::cmp::Ordering {
627    match (a, b) {
628        (None, None) => std::cmp::Ordering::Equal,
629        (None, _) => std::cmp::Ordering::Less,
630        (_, None) => std::cmp::Ordering::Greater,
631        (Some(x), Some(y)) => json_value_cmp(x, y),
632    }
633}
634
635/// Convert a JSON number to the aggregate expression representation without
636/// silently rounding an integer that cannot be represented by `f64`.
637#[expect(
638    clippy::cast_precision_loss,
639    clippy::cast_possible_truncation,
640    reason = "the integer round-trip check rejects lossy widening"
641)]
642pub(crate) fn exact_f64(value: &Json) -> Option<f64> {
643    let number = value.as_number()?;
644    if let Some(integer) = number.as_i64() {
645        let float = integer as f64;
646        return ((float as i128) == i128::from(integer)).then_some(float);
647    }
648    if let Some(integer) = number.as_u64() {
649        let float = integer as f64;
650        return ((float as i128) == i128::from(integer)).then_some(float);
651    }
652    number.as_f64()
653}
654
655fn eval_output(
656    name: &str,
657    expr: &OutputExpr,
658    results: &mut std::collections::BTreeMap<String, f64>,
659) -> Result<f64, QueryError> {
660    let v = match expr {
661        OutputExpr::Literal(l) => *l,
662        OutputExpr::Field(_) => {
663            // Field refs are evaluated per-row in the aggregate loop; at the
664            // bucket stage they carry no value.
665            return Ok(0.0);
666        }
667        OutputExpr::Ref(other) => results
668            .get(other)
669            .copied()
670            .ok_or_else(|| QueryError::InvalidPredicate(format!("unresolved ref `{other}`")))?,
671        OutputExpr::Add(a, b) => eval_output(name, a, results)? + eval_output(name, b, results)?,
672        OutputExpr::Subtract(a, b) => {
673            eval_output(name, a, results)? - eval_output(name, b, results)?
674        }
675        OutputExpr::Multiply(a, b) => {
676            eval_output(name, a, results)? * eval_output(name, b, results)?
677        }
678        OutputExpr::Divide {
679            dividend, divisor, ..
680        } => {
681            let d = eval_output(name, dividend, results)?;
682            let q = eval_output(name, divisor, results)?;
683            if q == 0.0 {
684                return Err(QueryError::InvalidPredicate("divide by zero".into()));
685            }
686            d / q
687        }
688    };
689    results.insert(name.to_string(), v);
690    Ok(v)
691}