Skip to main content

vortex_array/aggregate_fn/
accumulator.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::any::Any;
5
6use vortex_error::VortexExpect;
7use vortex_error::VortexResult;
8use vortex_error::vortex_bail;
9use vortex_error::vortex_ensure;
10
11use crate::ArrayRef;
12use crate::Columnar;
13use crate::ExecutionCtx;
14use crate::aggregate_fn::AggregateDTypes;
15use crate::aggregate_fn::AggregateFn;
16use crate::aggregate_fn::AggregateFnRef;
17use crate::aggregate_fn::AggregateFnVTable;
18use crate::aggregate_fn::session::AggregateFnSessionExt;
19use crate::columnar::AnyColumnar;
20use crate::dtype::DType;
21use crate::executor::max_iterations;
22use crate::expr::stats::Precision;
23use crate::expr::stats::Stat;
24use crate::expr::stats::StatsProvider;
25use crate::scalar::Scalar;
26
27/// Reference-counted type-erased accumulator.
28pub type AccumulatorRef = Box<dyn DynAccumulator>;
29
30/// An accumulator used for computing aggregates over an entire stream of arrays.
31pub struct Accumulator<V: AggregateFnVTable> {
32    /// The vtable of the aggregate function.
33    vtable: V,
34    /// The options of the aggregate function.
35    options: V::Options,
36    /// Type-erased aggregate function used for kernel dispatch.
37    aggregate_fn: AggregateFnRef,
38    /// The input, partial, and result dtypes lent to every vtable call.
39    dtypes: AggregateDTypes,
40    /// The partial state of the accumulator, with `None` as the empty group.
41    partial: Option<V::Partial>,
42}
43
44impl<V: AggregateFnVTable> Accumulator<V> {
45    pub fn try_new(vtable: V, options: V::Options, dtype: DType) -> VortexResult<Self> {
46        let dtypes = AggregateDTypes::try_new(&vtable, &options, dtype)?;
47
48        Ok(Self::from_dtypes(vtable, options, dtypes))
49    }
50
51    /// Build an accumulator over dtypes that are already resolved.
52    ///
53    /// A nested accumulator takes the dtypes its parent already derived, instead of resolving
54    /// them again for every batch.
55    pub fn from_dtypes(vtable: V, options: V::Options, dtypes: AggregateDTypes) -> Self {
56        let aggregate_fn = AggregateFn::new(vtable.clone(), options.clone()).erased();
57
58        Self {
59            vtable,
60            options,
61            aggregate_fn,
62            dtypes,
63            partial: None,
64        }
65    }
66
67    /// The state of a group with no accumulated values.
68    pub(crate) fn empty_partial(&self) -> VortexResult<V::Partial> {
69        self.vtable.empty_partial(self.dtypes.args(&self.options))
70    }
71
72    fn ensure_partial(&mut self) -> VortexResult<()> {
73        if self.partial.is_none() {
74            self.partial = Some(self.empty_partial()?);
75        }
76        Ok(())
77    }
78
79    pub(crate) fn fold_partial(&mut self, other: V::Partial) -> VortexResult<()> {
80        self.partial = Some(match self.partial.take() {
81            // The empty state is the identity of the merge.
82            None => other,
83            Some(current) => {
84                self.vtable
85                    .merge_partials(self.dtypes.args(&self.options), current, other)?
86            }
87        });
88        Ok(())
89    }
90
91    fn fold_partial_scalar(&mut self, scalar: Scalar) -> VortexResult<()> {
92        let other = self
93            .vtable
94            .partial_from_scalar(self.dtypes.args(&self.options), scalar)?;
95        self.fold_partial(other)
96    }
97}
98
99/// A trait object for type-erased accumulators, used for dynamic dispatch when the aggregate
100/// function is not known at compile time.
101pub trait DynAccumulator: 'static + Send {
102    /// Accumulate a new array into the accumulator's state.
103    fn accumulate(&mut self, batch: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()>;
104
105    /// Drain another accumulator's state into this one, resetting `other`.
106    ///
107    /// The other accumulator must have been constructed for the same aggregate function,
108    /// options, and input dtype as this one.
109    fn merge_from(&mut self, other: &mut dyn DynAccumulator) -> VortexResult<()>;
110
111    /// Parse a partial scalar and merge it into this accumulator's state.
112    ///
113    /// The scalar must have the dtype reported by the vtable's `partial_dtype`, and represents
114    /// input following the input already accumulated.
115    fn combine_partials(&mut self, partial: Scalar) -> VortexResult<()>;
116
117    /// Whether the accumulator's result is fully determined.
118    fn is_saturated(&self) -> bool;
119
120    /// Reset the accumulator's state to the empty group.
121    fn reset(&mut self);
122
123    /// Read the current partial state as a scalar without resetting it.
124    ///
125    /// The returned scalar has the dtype reported by the vtable's `partial_dtype`.
126    fn partial_scalar(&self) -> VortexResult<Scalar>;
127
128    /// Compute the final aggregate result as a scalar without resetting state.
129    fn final_scalar(&self) -> VortexResult<Scalar>;
130
131    /// Flush the accumulation state and return the partial aggregate result as a scalar.
132    ///
133    /// Resets the accumulator state back to the initial state.
134    fn flush(&mut self) -> VortexResult<Scalar>;
135
136    /// Finish the accumulation and return the final aggregate result as a scalar.
137    ///
138    /// Resets the accumulator state back to the initial state.
139    fn finish(&mut self) -> VortexResult<Scalar>;
140
141    /// Access the accumulator as [`Any`], so it can be downcast to a typed [`Accumulator`].
142    fn as_any_mut(&mut self) -> &mut dyn Any;
143}
144
145impl dyn DynAccumulator {
146    /// Downcast to the typed [`Accumulator`] of the aggregate vtable `V`.
147    pub fn downcast_mut<V: AggregateFnVTable>(&mut self) -> Option<&mut Accumulator<V>> {
148        self.as_any_mut().downcast_mut()
149    }
150}
151
152impl<V: AggregateFnVTable> DynAccumulator for Accumulator<V> {
153    fn accumulate(&mut self, batch: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<()> {
154        if self.is_saturated() {
155            return Ok(());
156        }
157
158        vortex_ensure!(
159            batch.dtype() == &self.dtypes.dtype,
160            "Input DType mismatch: expected {}, got {}",
161            self.dtypes.dtype,
162            batch.dtype()
163        );
164
165        // 0. Legacy stats bridge: if this aggregate is still cached under a legacy Stat slot,
166        //    consume that exact stat before kernel dispatch or decode.
167        if let Some(stat) = Stat::from_aggregate_fn(&self.aggregate_fn)
168            && let Precision::Exact(partial) = batch.statistics().get(stat)
169        {
170            let partial = if partial.dtype() == &self.dtypes.partial_dtype {
171                partial
172            } else {
173                vortex_ensure!(
174                    partial
175                        .dtype()
176                        .eq_ignore_nullability(&self.dtypes.partial_dtype),
177                    "Aggregate {} read legacy stat {} with dtype {}, expected {}",
178                    self.aggregate_fn,
179                    stat,
180                    partial.dtype(),
181                    self.dtypes.partial_dtype,
182                );
183                partial.cast(&self.dtypes.partial_dtype)?
184            };
185            self.fold_partial_scalar(partial)?;
186            return Ok(());
187        }
188
189        let session = ctx.session().clone();
190
191        // 1. Kernel registry first: a registered `(encoding, aggregate_fn)` kernel is strictly
192        //    more specific than the vtable's `try_accumulate` short-circuit. Checking the
193        //    registry first gives kernels for `Combined<V>` aggregates a chance to fire —
194        //    `Combined::try_accumulate` always returns true, so a later kernel check would be
195        //    unreachable.
196        {
197            let kernel = session
198                .aggregate_fns()
199                .find_aggregate_kernel(batch.encoding_id(), self.aggregate_fn.id());
200            if let Some(kernel) = kernel
201                && let Some(result) = kernel.aggregate(&self.aggregate_fn, batch, ctx)?
202            {
203                vortex_ensure!(
204                    result.dtype() == &self.dtypes.partial_dtype,
205                    "Aggregate kernel returned {}, expected {}",
206                    result.dtype(),
207                    self.dtypes.partial_dtype,
208                );
209                self.fold_partial_scalar(result)?;
210                return Ok(());
211            }
212        }
213
214        // 2. Allow the vtable to short-circuit on the raw array before decompression.
215        self.ensure_partial()?;
216        let partial = self.partial.as_mut().vortex_expect("partial materialized");
217        if self
218            .vtable
219            .try_accumulate(self.dtypes.args(&self.options), partial, batch, ctx)?
220        {
221            return Ok(());
222        }
223
224        // 3. Iteratively check the registry against each intermediate encoding, executing one
225        //    step between checks. Mirrors the loop in `GroupedAccumulator::accumulate_list_view`.
226        //    Iteration 0 re-checks the initial encoding — a redundant HashMap miss, the price of
227        //    keeping the loop body uniform. Terminates on `AnyColumnar` (Canonical or Constant)
228        //    since the vtable's `accumulate(&Columnar)` handles both cases directly.
229        let mut batch = batch.clone();
230        for _ in 0..max_iterations() {
231            if batch.is::<AnyColumnar>() {
232                break;
233            }
234
235            if let Some(kernel) = session
236                .aggregate_fns()
237                .find_aggregate_kernel(batch.encoding_id(), self.aggregate_fn.id())
238                && let Some(result) = kernel.aggregate(&self.aggregate_fn, &batch, ctx)?
239            {
240                vortex_ensure!(
241                    result.dtype() == &self.dtypes.partial_dtype,
242                    "Aggregate kernel returned {}, expected {}",
243                    result.dtype(),
244                    self.dtypes.partial_dtype,
245                );
246                self.fold_partial_scalar(result)?;
247                return Ok(());
248            }
249
250            batch = batch.execute(ctx)?;
251        }
252
253        // 4. Otherwise, execute the batch until it is columnar and accumulate it into the state.
254        let columnar = batch.execute::<Columnar>(ctx)?;
255
256        self.ensure_partial()?;
257        let partial = self.partial.as_mut().vortex_expect("partial materialized");
258        self.vtable
259            .accumulate(self.dtypes.args(&self.options), partial, &columnar, ctx)
260    }
261
262    fn merge_from(&mut self, other: &mut dyn DynAccumulator) -> VortexResult<()> {
263        let Some(other) = other.downcast_mut::<V>() else {
264            vortex_bail!(
265                "Cannot merge into a {} accumulator from an accumulator of a different aggregate",
266                self.aggregate_fn,
267            );
268        };
269        vortex_ensure!(
270            other.options == self.options && other.dtypes.dtype == self.dtypes.dtype,
271            "Cannot merge {} accumulators with different options or input dtypes",
272            self.aggregate_fn,
273        );
274        match other.partial.take() {
275            Some(partial) => self.fold_partial(partial),
276            None => Ok(()),
277        }
278    }
279
280    fn combine_partials(&mut self, partial: Scalar) -> VortexResult<()> {
281        vortex_ensure!(
282            partial.dtype() == &self.dtypes.partial_dtype,
283            "Partial DType mismatch for {}: expected {}, got {}",
284            self.aggregate_fn,
285            self.dtypes.partial_dtype,
286            partial.dtype(),
287        );
288        self.fold_partial_scalar(partial)
289    }
290
291    fn as_any_mut(&mut self) -> &mut dyn Any {
292        self
293    }
294
295    fn is_saturated(&self) -> bool {
296        self.partial.as_ref().is_some_and(|partial| {
297            self.vtable
298                .is_saturated(self.dtypes.args(&self.options), partial)
299        })
300    }
301
302    fn reset(&mut self) {
303        self.partial = None;
304    }
305
306    fn partial_scalar(&self) -> VortexResult<Scalar> {
307        let args = self.dtypes.args(&self.options);
308        let partial = match &self.partial {
309            Some(partial) => self.vtable.to_scalar(args, partial)?,
310            None => self.vtable.to_scalar(args, &self.empty_partial()?)?,
311        };
312
313        #[cfg(debug_assertions)]
314        {
315            vortex_ensure!(
316                partial.dtype() == args.partial_dtype,
317                "Aggregate returned incorrect DType on partial_scalar: expected {}, got {}",
318                args.partial_dtype,
319                partial.dtype(),
320            );
321        }
322
323        Ok(partial)
324    }
325
326    fn final_scalar(&self) -> VortexResult<Scalar> {
327        let args = self.dtypes.args(&self.options);
328        let result = match &self.partial {
329            Some(partial) => self.vtable.finalize_scalar(args, partial)?,
330            None => self.vtable.finalize_scalar(args, &self.empty_partial()?)?,
331        };
332
333        vortex_ensure!(
334            result.dtype() == args.return_dtype,
335            "Aggregate returned incorrect DType on final_scalar: expected {}, got {}",
336            args.return_dtype,
337            result.dtype(),
338        );
339
340        Ok(result)
341    }
342
343    fn flush(&mut self) -> VortexResult<Scalar> {
344        let partial = self.partial_scalar()?;
345        self.reset();
346        Ok(partial)
347    }
348
349    fn finish(&mut self) -> VortexResult<Scalar> {
350        let result = self.final_scalar()?;
351        self.reset();
352        Ok(result)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use vortex_buffer::buffer;
359    use vortex_error::VortexResult;
360    use vortex_session::SessionExt;
361    use vortex_session::VortexSession;
362
363    use crate::ArrayRef;
364    use crate::ExecutionCtx;
365    use crate::IntoArray;
366    use crate::VortexSessionExecute;
367    use crate::aggregate_fn::Accumulator;
368    use crate::aggregate_fn::AccumulatorRef;
369    use crate::aggregate_fn::AggregateFnRef;
370    use crate::aggregate_fn::AggregateFnVTable;
371    use crate::aggregate_fn::DynAccumulator;
372    use crate::aggregate_fn::NumericalAggregateOpts;
373    use crate::aggregate_fn::combined::Combined;
374    use crate::aggregate_fn::combined::PairOptions;
375    use crate::aggregate_fn::fns::mean::Mean;
376    use crate::aggregate_fn::fns::min::Min;
377    use crate::aggregate_fn::fns::sum::Sum;
378    use crate::aggregate_fn::kernels::DynAggregateKernel;
379    use crate::aggregate_fn::session::AggregateFnSession;
380    use crate::array::VTable;
381    use crate::arrays::Dict;
382    use crate::arrays::DictArray;
383    use crate::dtype::DType;
384    use crate::dtype::Nullability;
385    use crate::dtype::PType;
386    use crate::expr::stats::Precision;
387    use crate::expr::stats::Stat;
388    use crate::scalar::Scalar;
389    use crate::scalar::ScalarValue;
390
391    /// Mean partial sentinel `{sum: 42.0, count: 1}` — distinguishable from the
392    /// natural fan-out result `{sum: 7.0, count: 1}` that `Combined::try_accumulate`
393    /// would produce for `dict_of_seven()`.
394    #[derive(Debug)]
395    struct SentinelMeanPartialKernel;
396    impl DynAggregateKernel for SentinelMeanPartialKernel {
397        fn aggregate(
398            &self,
399            _aggregate_fn: &AggregateFnRef,
400            _batch: &ArrayRef,
401            _ctx: &mut ExecutionCtx,
402        ) -> VortexResult<Option<Scalar>> {
403            Ok(Some(sentinel_partial()))
404        }
405    }
406
407    /// Returns `Ok(None)` => kernel declined, dispatch falls through.
408    #[derive(Debug)]
409    struct DeclineKernel;
410    impl DynAggregateKernel for DeclineKernel {
411        fn aggregate(
412            &self,
413            _aggregate_fn: &AggregateFnRef,
414            _batch: &ArrayRef,
415            _ctx: &mut ExecutionCtx,
416        ) -> VortexResult<Option<Scalar>> {
417            Ok(None)
418        }
419    }
420
421    /// A sentinel sum of `42.0`, distinguishable from `dict_of_seven()`'s natural sum of `7.0`.
422    #[derive(Debug)]
423    struct SentinelSumPartialKernel;
424    impl DynAggregateKernel for SentinelSumPartialKernel {
425        fn aggregate(
426            &self,
427            _aggregate_fn: &AggregateFnRef,
428            _batch: &ArrayRef,
429            _ctx: &mut ExecutionCtx,
430        ) -> VortexResult<Option<Scalar>> {
431            Ok(Some(Scalar::primitive(42.0f64, Nullability::Nullable)))
432        }
433    }
434
435    fn fresh_session() -> VortexSession {
436        crate::array_session()
437    }
438
439    fn dict_of_seven() -> ArrayRef {
440        DictArray::try_new(buffer![0u32].into_array(), buffer![7.0f64].into_array())
441            .expect("valid dictionary")
442            .into_array()
443    }
444
445    fn mean_f64_accumulator() -> VortexResult<Accumulator<Combined<Mean>>> {
446        let dtype = DType::Primitive(PType::F64, Nullability::NonNullable);
447        Accumulator::try_new(
448            Mean::combined(),
449            PairOptions(
450                NumericalAggregateOpts::default(),
451                NumericalAggregateOpts::default(),
452            ),
453            dtype,
454        )
455    }
456
457    fn sentinel_partial() -> Scalar {
458        let acc = mean_f64_accumulator().expect("build accumulator");
459        let sum = Scalar::primitive(42.0f64, Nullability::Nullable);
460        let count = Scalar::primitive(1u64, Nullability::NonNullable);
461        Scalar::struct_(acc.dtypes.partial_dtype, vec![sum, count])
462    }
463
464    /// Kernel registered for `(Dict, Combined<Mean>)` fires in preference to
465    /// `Combined::try_accumulate`'s fan-out path — proves the dispatch reorder.
466    #[test]
467    fn combined_kernel_fires() -> VortexResult<()> {
468        static KERNEL: SentinelMeanPartialKernel = SentinelMeanPartialKernel;
469        let session = fresh_session();
470        session
471            .get::<AggregateFnSession>()
472            .register_aggregate_kernel(Dict.id(), Some(Mean::combined().id()), &KERNEL);
473        let mut ctx = session.create_execution_ctx();
474
475        let mut acc = mean_f64_accumulator()?;
476        acc.accumulate(&dict_of_seven(), &mut ctx)?;
477        let partial = acc.flush()?;
478
479        let s = partial.as_struct();
480        assert_eq!(
481            s.field("sum").unwrap().as_primitive().as_::<f64>(),
482            Some(42.0)
483        );
484        assert_eq!(
485            s.field("count").unwrap().as_primitive().as_::<u64>(),
486            Some(1)
487        );
488        Ok(())
489    }
490
491    /// Kernel returns `Ok(None)` => dispatch falls through to `Combined::try_accumulate`'s
492    /// natural fan-out. The natural partial is `{sum: 7.0, count: 1}`.
493    #[test]
494    fn fallback_when_kernel_declines() -> VortexResult<()> {
495        static KERNEL: DeclineKernel = DeclineKernel;
496        let session = fresh_session();
497        session
498            .get::<AggregateFnSession>()
499            .register_aggregate_kernel(Dict.id(), Some(Mean::combined().id()), &KERNEL);
500        let mut ctx = session.create_execution_ctx();
501
502        let mut acc = mean_f64_accumulator()?;
503        acc.accumulate(&dict_of_seven(), &mut ctx)?;
504        let partial = acc.flush()?;
505
506        let s = partial.as_struct();
507        assert_eq!(
508            s.field("sum").unwrap().as_primitive().as_::<f64>(),
509            Some(7.0)
510        );
511        assert_eq!(
512            s.field("count").unwrap().as_primitive().as_::<u64>(),
513            Some(1)
514        );
515        Ok(())
516    }
517
518    /// A kernel registered for the inner `(Dict, Sum)` child fires when accumulating a
519    /// Dict batch through `Combined<Mean>`. This is the reusable-primitive case the
520    /// refactor enables: no `(Dict, Combined<Mean>)` kernel is needed.
521    #[test]
522    fn child_kernel_fires_through_combined() -> VortexResult<()> {
523        static KERNEL: SentinelSumPartialKernel = SentinelSumPartialKernel;
524        let session = fresh_session();
525        session
526            .get::<AggregateFnSession>()
527            .register_aggregate_kernel(Dict.id(), Some(Sum.id()), &KERNEL);
528        let mut ctx = session.create_execution_ctx();
529
530        let mut acc = mean_f64_accumulator()?;
531        acc.accumulate(&dict_of_seven(), &mut ctx)?;
532        let partial = acc.flush()?;
533
534        let s = partial.as_struct();
535        // `Sum` child returned the sentinel 42.0 — proves the (Dict, Sum) kernel fired
536        // via `Combined<Mean>`'s fan-out. `Count`'s native `try_accumulate` reads the
537        // batch's valid_count, so count is the real 1.
538        assert_eq!(
539            s.field("sum").unwrap().as_primitive().as_::<f64>(),
540            Some(42.0)
541        );
542        assert_eq!(
543            s.field("count").unwrap().as_primitive().as_::<u64>(),
544            Some(1)
545        );
546        Ok(())
547    }
548
549    #[test]
550    fn cached_sum_precedes_encoding_kernel() -> VortexResult<()> {
551        static KERNEL: SentinelSumPartialKernel = SentinelSumPartialKernel;
552        let session = fresh_session();
553        session
554            .get::<AggregateFnSession>()
555            .register_aggregate_kernel(Dict.id(), Some(Sum.id()), &KERNEL);
556        let mut ctx = session.create_execution_ctx();
557
558        let batch = dict_of_seven();
559        batch
560            .statistics()
561            .set(Stat::Sum, Precision::Exact(ScalarValue::from(11.0f64)));
562
563        let dtype = DType::Primitive(PType::F64, Nullability::NonNullable);
564        let mut acc = Accumulator::try_new(Sum, NumericalAggregateOpts::default(), dtype)?;
565        acc.accumulate(&batch, &mut ctx)?;
566
567        assert_eq!(acc.finish()?.as_primitive().as_::<f64>(), Some(11.0));
568        Ok(())
569    }
570
571    fn sum_i32_accumulator(options: NumericalAggregateOpts) -> VortexResult<AccumulatorRef> {
572        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
573        Ok(Box::new(Accumulator::try_new(Sum, options, dtype)?))
574    }
575
576    #[test]
577    fn merge_from_drains_other_accumulator() -> VortexResult<()> {
578        let mut ctx = fresh_session().create_execution_ctx();
579        let mut global = sum_i32_accumulator(NumericalAggregateOpts::default())?;
580        let mut local = sum_i32_accumulator(NumericalAggregateOpts::default())?;
581
582        global.accumulate(&buffer![10i32, 20].into_array(), &mut ctx)?;
583        local.accumulate(&buffer![5i32].into_array(), &mut ctx)?;
584        global.merge_from(local.as_mut())?;
585
586        assert_eq!(
587            global.finish()?,
588            Scalar::primitive(35i64, Nullability::Nullable)
589        );
590        // The merged-from accumulator is reset back to the empty state.
591        assert_eq!(
592            local.finish()?,
593            Scalar::primitive(0i64, Nullability::Nullable)
594        );
595        Ok(())
596    }
597
598    #[test]
599    fn merge_from_rejects_a_different_aggregate() -> VortexResult<()> {
600        let dtype = DType::Primitive(PType::I32, Nullability::NonNullable);
601        let mut sum = sum_i32_accumulator(NumericalAggregateOpts::default())?;
602        let mut min: AccumulatorRef = Box::new(Accumulator::try_new(
603            Min,
604            NumericalAggregateOpts::default(),
605            dtype,
606        )?);
607
608        assert!(sum.merge_from(min.as_mut()).is_err());
609        Ok(())
610    }
611
612    #[test]
613    fn merge_from_rejects_mismatched_options() -> VortexResult<()> {
614        let mut skipping = sum_i32_accumulator(NumericalAggregateOpts::skip_nans())?;
615        let mut including = sum_i32_accumulator(NumericalAggregateOpts::include_nans())?;
616
617        assert!(skipping.merge_from(including.as_mut()).is_err());
618        Ok(())
619    }
620
621    #[test]
622    fn merge_from_combines_child_accumulators() -> VortexResult<()> {
623        let mut ctx = fresh_session().create_execution_ctx();
624        let mut global: AccumulatorRef = Box::new(mean_f64_accumulator()?);
625        let mut local: AccumulatorRef = Box::new(mean_f64_accumulator()?);
626
627        global.accumulate(&buffer![1.0f64, 2.0].into_array(), &mut ctx)?;
628        local.accumulate(&buffer![6.0f64].into_array(), &mut ctx)?;
629        global.merge_from(local.as_mut())?;
630
631        assert_eq!(global.finish()?.as_primitive().as_::<f64>(), Some(3.0));
632        Ok(())
633    }
634}