Skip to main content

ic_query/nns/neuron/report/distribution/
mod.rs

1//! Module: nns::neuron::report::distribution
2//!
3//! Responsibility: validate and aggregate complete caller-retained public NNS neuron collections.
4//! Does not own: collection transport, persistence, cache policy, or process output.
5//! Boundary: projects complete public `NeuronInfo` evidence into portable distributions.
6
7use super::{
8    NnsNeuronCollectionState, collection::validate_collection_state, model::NnsNeuronRow,
9    source::validate_neuron_rows,
10};
11use crate::nns::governance::{NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance};
12use ic_metrics::{CheckedMeasurementAggregate, MeasurementRecordError};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use thiserror::Error as ThisError;
16
17/// Version of the portable NNS public-neuron distribution report schema.
18pub const NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION: u32 = 1;
19
20///
21/// NnsNeuronStateDistribution
22///
23/// Public neuron count and effective stake for one raw Governance state code.
24///
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct NnsNeuronStateDistribution {
29    /// Raw native Governance state code.
30    pub state: i32,
31    /// Number of collected neurons with this state code.
32    pub neuron_count: u64,
33    /// Sum of public effective stake for neurons with this state code.
34    pub effective_stake_e8s: u64,
35}
36
37///
38/// NnsNeuronVisibilityDistribution
39///
40/// Public neuron count and effective stake for one optional Governance visibility code.
41///
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct NnsNeuronVisibilityDistribution {
46    /// Raw optional native Governance visibility code.
47    pub visibility: Option<i32>,
48    /// Number of collected neurons with this visibility code or absence.
49    pub neuron_count: u64,
50    /// Sum of public effective stake for neurons with this visibility code or absence.
51    pub effective_stake_e8s: u64,
52}
53
54///
55/// NnsNeuronTypeDistribution
56///
57/// Public neuron count and effective stake for one optional Governance neuron-type code.
58///
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct NnsNeuronTypeDistribution {
63    /// Raw optional native Governance neuron-type code.
64    pub neuron_type: Option<i32>,
65    /// Number of collected neurons with this type code or absence.
66    pub neuron_count: u64,
67    /// Sum of public effective stake for neurons with this type code or absence.
68    pub effective_stake_e8s: u64,
69}
70
71///
72/// NnsNeuronDistributionReport
73///
74/// Deterministic local distribution over one complete public NNS neuron collection.
75///
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct NnsNeuronDistributionReport {
79    /// Report schema version.
80    pub schema_version: u32,
81    /// Network identity retained by the complete collection.
82    pub network: String,
83    /// Fixed NNS Governance canister principal retained by the collection.
84    pub governance_canister_id: String,
85    /// Concrete transport and collector provenance retained by the collection.
86    pub source: NnsGovernanceSourceProvenance,
87    /// Caller-supplied time attached to collection creation.
88    pub collection_started_at: String,
89    /// Caller-supplied time attached to the final admitted page.
90    pub collection_updated_at: String,
91    /// Number of admitted source pages in the complete collection.
92    pub collection_page_count: u32,
93    /// Number of admitted public neuron rows in the complete collection.
94    pub collected_neuron_count: u64,
95    /// Whether the sequential collection is guaranteed to represent one point in time.
96    pub point_in_time_guaranteed: bool,
97    /// Earliest raw Governance retrieval timestamp among collected rows.
98    pub earliest_retrieved_at_timestamp_seconds: Option<u64>,
99    /// Latest raw Governance retrieval timestamp among collected rows.
100    pub latest_retrieved_at_timestamp_seconds: Option<u64>,
101    /// Sum of public effective stake across every collected neuron.
102    pub total_effective_stake_e8s: u64,
103    /// Rows carrying a public staked-maturity value.
104    pub reported_staked_maturity_neuron_count: u64,
105    /// Rows without a public staked-maturity value.
106    pub unreported_staked_maturity_neuron_count: u64,
107    /// Sum of reported staked maturity values only.
108    pub total_reported_staked_maturity_e8s_equivalent: u64,
109    /// Rows carrying current deciding voting power.
110    pub reported_deciding_voting_power_neuron_count: u64,
111    /// Rows without current deciding voting power.
112    pub unreported_deciding_voting_power_neuron_count: u64,
113    /// Sum of reported current deciding voting power values only.
114    pub total_reported_deciding_voting_power: u64,
115    /// Rows carrying current potential voting power.
116    pub reported_potential_voting_power_neuron_count: u64,
117    /// Rows without current potential voting power.
118    pub unreported_potential_voting_power_neuron_count: u64,
119    /// Sum of reported current potential voting power values only.
120    pub total_reported_potential_voting_power: u64,
121    /// Rows carrying registered known-neuron metadata.
122    pub known_neuron_metadata_count: u64,
123    /// Rows carrying a public Neurons' Fund join timestamp.
124    pub neurons_fund_join_timestamp_present_count: u64,
125    /// Canonically raw-code-ordered distribution by neuron state.
126    pub state_distribution: Vec<NnsNeuronStateDistribution>,
127    /// Canonically optional-raw-code-ordered distribution by visibility.
128    pub visibility_distribution: Vec<NnsNeuronVisibilityDistribution>,
129    /// Canonically optional-raw-code-ordered distribution by neuron type.
130    pub neuron_type_distribution: Vec<NnsNeuronTypeDistribution>,
131}
132
133///
134/// NnsNeuronDistributionValidationError
135///
136/// Pure validation failure for an untrusted serialized or in-memory distribution report.
137///
138
139#[derive(Debug, Eq, PartialEq, ThisError)]
140#[error("invalid NNS neuron distribution report: {reason}")]
141pub struct NnsNeuronDistributionValidationError {
142    /// Deterministic invariant failure.
143    pub reason: String,
144}
145
146///
147/// NnsNeuronDistributionError
148///
149/// Deterministic validation or accounting failure from local neuron distribution projection.
150///
151
152#[derive(Debug, ThisError)]
153pub enum NnsNeuronDistributionError {
154    /// The supplied collection state failed its shared continuation invariants.
155    #[error("invalid NNS neuron collection state for distribution projection: {reason}")]
156    InvalidCollectionState {
157        /// Deterministic collection invariant failure.
158        reason: String,
159    },
160
161    /// The collection stopped without observing Governance API exhaustion.
162    #[error("NNS neuron distribution requires a complete collection; state is {status}")]
163    CollectionNotComplete {
164        /// Current lifecycle of the otherwise valid collection state.
165        status: NnsGovernanceCollectionStatus,
166    },
167
168    /// The supplied rows do not match the collection's admitted-row accounting.
169    #[error(
170        "NNS neuron distribution received {actual} rows; complete collection accounts for {expected}"
171    )]
172    NeuronCountMismatch {
173        /// Neuron rows accounted for by the collection state.
174        expected: u64,
175        /// Neuron rows supplied to the builder.
176        actual: u64,
177    },
178
179    /// Supplied rows failed the shared public-neuron response contract.
180    #[error("invalid NNS neuron rows for distribution projection: {reason}")]
181    InvalidNeuronRows {
182        /// Deterministic row invariant failure.
183        reason: String,
184    },
185
186    /// A count conversion, increment, or numeric sum exceeded `u64`.
187    #[error("NNS neuron distribution accounting overflow while updating {field}")]
188    AccountingOverflow {
189        /// Count or sum that exceeded its representation.
190        field: &'static str,
191    },
192}
193
194/// Build one deterministic distribution from a complete caller-retained public-neuron collection.
195pub fn build_nns_neuron_distribution_report(
196    collection: &NnsNeuronCollectionState,
197    neurons: &[NnsNeuronRow],
198) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
199    validate_collection_state(collection).map_err(|error| {
200        NnsNeuronDistributionError::InvalidCollectionState {
201            reason: error.to_string(),
202        }
203    })?;
204    if !collection.is_complete() {
205        return Err(NnsNeuronDistributionError::CollectionNotComplete {
206            status: collection.status(),
207        });
208    }
209    let source = collection.source().cloned().ok_or_else(|| {
210        NnsNeuronDistributionError::InvalidCollectionState {
211            reason: "complete collection has no concrete source provenance".to_string(),
212        }
213    })?;
214
215    let expected = collection.neurons_fetched();
216    let actual = u64::try_from(neurons.len()).map_err(|_| {
217        NnsNeuronDistributionError::AccountingOverflow {
218            field: "supplied_neuron_count",
219        }
220    })?;
221    if actual != expected {
222        return Err(NnsNeuronDistributionError::NeuronCountMismatch { expected, actual });
223    }
224    validate_neuron_rows(neurons).map_err(|error| {
225        NnsNeuronDistributionError::InvalidNeuronRows {
226            reason: error.to_string(),
227        }
228    })?;
229
230    aggregate_distribution(collection, neurons, source)
231}
232
233#[cfg(feature = "nns-host")]
234pub(super) fn build_distribution_from_cache(
235    cache: &super::cache::AdmittedNeuronCache,
236) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
237    let collection = cache.collection_state();
238    let source = collection.source().cloned().ok_or_else(|| {
239        NnsNeuronDistributionError::InvalidCollectionState {
240            reason: "complete collection has no concrete source provenance".to_string(),
241        }
242    })?;
243    aggregate_distribution(collection, cache.rows(), source)
244}
245
246fn aggregate_distribution(
247    collection: &NnsNeuronCollectionState,
248    neurons: &[NnsNeuronRow],
249    source: NnsGovernanceSourceProvenance,
250) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
251    let mut distribution = DistributionAccumulator::default();
252    for neuron in neurons {
253        distribution.observe(neuron)?;
254    }
255    Ok(distribution.into_report(collection, source))
256}
257
258/// Validate every distribution-report invariant available without source rows or live host calls.
259pub fn validate_nns_neuron_distribution_report(
260    report: &NnsNeuronDistributionReport,
261) -> Result<(), NnsNeuronDistributionValidationError> {
262    validate_distribution_header(report)?;
263    validate_distribution_summary(report)?;
264    validate_distribution(
265        report,
266        "state",
267        "raw-code",
268        report
269            .state_distribution
270            .iter()
271            .map(|row| (row.state, row.neuron_count, row.effective_stake_e8s)),
272    )?;
273    validate_distribution(
274        report,
275        "visibility",
276        "optional-raw-code",
277        report
278            .visibility_distribution
279            .iter()
280            .map(|row| (row.visibility, row.neuron_count, row.effective_stake_e8s)),
281    )?;
282    validate_distribution(
283        report,
284        "neuron-type",
285        "optional-raw-code",
286        report
287            .neuron_type_distribution
288            .iter()
289            .map(|row| (row.neuron_type, row.neuron_count, row.effective_stake_e8s)),
290    )
291}
292
293fn validate_distribution_header(
294    report: &NnsNeuronDistributionReport,
295) -> Result<(), NnsNeuronDistributionValidationError> {
296    crate::nns::governance::GovernanceAnalyticsHeader {
297        schema_version: report.schema_version,
298        network: &report.network,
299        governance_canister_id: &report.governance_canister_id,
300        source: &report.source,
301        collection_started_at: &report.collection_started_at,
302        collection_updated_at: &report.collection_updated_at,
303        collection_page_count: report.collection_page_count,
304        collected_row_count: report.collected_neuron_count,
305        point_in_time_guaranteed: report.point_in_time_guaranteed,
306    }
307    .validate(
308        NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION,
309        ("distribution", "neurons"),
310        "sequential public-neuron collection cannot claim a point-in-time snapshot",
311    )
312    .map_err(invalid_validation)?;
313    validate_retrieval_range(report)
314}
315
316fn validate_retrieval_range(
317    report: &NnsNeuronDistributionReport,
318) -> Result<(), NnsNeuronDistributionValidationError> {
319    match (
320        report.earliest_retrieved_at_timestamp_seconds,
321        report.latest_retrieved_at_timestamp_seconds,
322    ) {
323        (None, None) if report.collected_neuron_count == 0 => Ok(()),
324        (Some(earliest), Some(latest))
325            if report.collected_neuron_count > 0 && earliest <= latest =>
326        {
327            Ok(())
328        }
329        _ => Err(invalid_validation(
330            "retrieval timestamp range disagrees with collected_neuron_count or is reversed",
331        )),
332    }
333}
334
335fn validate_distribution_summary(
336    report: &NnsNeuronDistributionReport,
337) -> Result<(), NnsNeuronDistributionValidationError> {
338    validate_optional_summary(
339        report.reported_staked_maturity_neuron_count,
340        report.unreported_staked_maturity_neuron_count,
341        report.total_reported_staked_maturity_e8s_equivalent,
342        report.collected_neuron_count,
343        "staked maturity",
344    )?;
345    validate_optional_summary(
346        report.reported_deciding_voting_power_neuron_count,
347        report.unreported_deciding_voting_power_neuron_count,
348        report.total_reported_deciding_voting_power,
349        report.collected_neuron_count,
350        "deciding voting power",
351    )?;
352    validate_optional_summary(
353        report.reported_potential_voting_power_neuron_count,
354        report.unreported_potential_voting_power_neuron_count,
355        report.total_reported_potential_voting_power,
356        report.collected_neuron_count,
357        "potential voting power",
358    )?;
359    for (field, count) in [
360        (
361            "known_neuron_metadata_count",
362            report.known_neuron_metadata_count,
363        ),
364        (
365            "neurons_fund_join_timestamp_present_count",
366            report.neurons_fund_join_timestamp_present_count,
367        ),
368    ] {
369        if count > report.collected_neuron_count {
370            return Err(invalid_validation(format!(
371                "{field} {count} exceeds collected_neuron_count {}",
372                report.collected_neuron_count
373            )));
374        }
375    }
376    Ok(())
377}
378
379fn validate_optional_summary(
380    reported: u64,
381    unreported: u64,
382    total: u64,
383    collected: u64,
384    field: &'static str,
385) -> Result<(), NnsNeuronDistributionValidationError> {
386    let accounted = reported
387        .checked_add(unreported)
388        .ok_or_else(|| invalid_validation(format!("{field} coverage count overflow")))?;
389    if accounted != collected {
390        return Err(invalid_validation(format!(
391            "{field} coverage accounts for {accounted} neurons, expected {collected}"
392        )));
393    }
394    if reported == 0 && total != 0 {
395        return Err(invalid_validation(format!(
396            "{field} total must be zero when no rows report the field"
397        )));
398    }
399    Ok(())
400}
401
402fn validate_distribution<Key: Copy + Ord + std::fmt::Debug>(
403    report: &NnsNeuronDistributionReport,
404    dimension: &'static str,
405    order: &'static str,
406    rows: impl Iterator<Item = (Key, u64, u64)>,
407) -> Result<(), NnsNeuronDistributionValidationError> {
408    let mut previous = None;
409    let mut neuron_count = 0_u64;
410    let mut stake = 0_u64;
411    for (key, count, effective_stake) in rows {
412        if previous.is_some_and(|previous| previous >= key) {
413            return Err(invalid_validation(format!(
414                "{dimension} distribution is not strictly {order} ordered"
415            )));
416        }
417        neuron_count = add_distribution_count(neuron_count, count, dimension)?;
418        stake = stake
419            .checked_add(effective_stake)
420            .ok_or_else(|| invalid_validation(format!("{dimension} stake total overflow")))?;
421        previous = Some(key);
422    }
423    validate_distribution_totals(report, neuron_count, stake, dimension)
424}
425
426fn add_distribution_count(
427    total: u64,
428    count: u64,
429    dimension: &'static str,
430) -> Result<u64, NnsNeuronDistributionValidationError> {
431    if count == 0 {
432        return Err(invalid_validation(format!(
433            "{dimension} distribution row must contain at least one neuron"
434        )));
435    }
436    add_validation_total(total, count, dimension)
437}
438
439fn add_validation_total(
440    total: u64,
441    value: u64,
442    field: &'static str,
443) -> Result<u64, NnsNeuronDistributionValidationError> {
444    total
445        .checked_add(value)
446        .ok_or_else(|| invalid_validation(format!("{field} total overflow")))
447}
448
449fn validate_distribution_totals(
450    report: &NnsNeuronDistributionReport,
451    neuron_count: u64,
452    stake: u64,
453    dimension: &'static str,
454) -> Result<(), NnsNeuronDistributionValidationError> {
455    if neuron_count != report.collected_neuron_count {
456        return Err(invalid_validation(format!(
457            "{dimension} neuron counts sum to {neuron_count}, expected {}",
458            report.collected_neuron_count
459        )));
460    }
461    if stake != report.total_effective_stake_e8s {
462        return Err(invalid_validation(format!(
463            "{dimension} effective stake sums to {stake}, expected {}",
464            report.total_effective_stake_e8s
465        )));
466    }
467    Ok(())
468}
469
470fn invalid_validation(reason: impl Into<String>) -> NnsNeuronDistributionValidationError {
471    NnsNeuronDistributionValidationError {
472        reason: reason.into(),
473    }
474}
475
476#[derive(Default)]
477struct DistributionAccumulator {
478    states: BTreeMap<i32, CheckedMeasurementAggregate>,
479    visibilities: BTreeMap<Option<i32>, CheckedMeasurementAggregate>,
480    neuron_types: BTreeMap<Option<i32>, CheckedMeasurementAggregate>,
481    total_effective_stake: CheckedMeasurementAggregate,
482    staked_maturity: CheckedMeasurementAggregate,
483    deciding_voting_power: CheckedMeasurementAggregate,
484    potential_voting_power: CheckedMeasurementAggregate,
485    known_neuron_metadata_count: u64,
486    neurons_fund_join_timestamp_present_count: u64,
487    retrieved_timestamp_bounds: Option<(u64, u64)>,
488}
489
490impl DistributionAccumulator {
491    fn observe(&mut self, neuron: &NnsNeuronRow) -> Result<(), NnsNeuronDistributionError> {
492        record_distribution(
493            &mut self.total_effective_stake,
494            Some(neuron.stake_e8s),
495            "collected_neuron_count",
496            "total_effective_stake_e8s",
497        )?;
498        record_distribution(
499            self.states.entry(neuron.state).or_default(),
500            Some(neuron.stake_e8s),
501            "state_neuron_count",
502            "state_effective_stake_e8s",
503        )?;
504        record_distribution(
505            self.visibilities.entry(neuron.visibility).or_default(),
506            Some(neuron.stake_e8s),
507            "visibility_neuron_count",
508            "visibility_effective_stake_e8s",
509        )?;
510        record_distribution(
511            self.neuron_types.entry(neuron.neuron_type).or_default(),
512            Some(neuron.stake_e8s),
513            "neuron_type_neuron_count",
514            "neuron_type_effective_stake_e8s",
515        )?;
516        record_distribution(
517            &mut self.staked_maturity,
518            neuron.staked_maturity_e8s_equivalent,
519            "reported_staked_maturity_neuron_count",
520            "total_reported_staked_maturity_e8s_equivalent",
521        )?;
522        record_distribution(
523            &mut self.deciding_voting_power,
524            neuron.deciding_voting_power,
525            "reported_deciding_voting_power_neuron_count",
526            "total_reported_deciding_voting_power",
527        )?;
528        record_distribution(
529            &mut self.potential_voting_power,
530            neuron.potential_voting_power,
531            "reported_potential_voting_power_neuron_count",
532            "total_reported_potential_voting_power",
533        )?;
534        if neuron.known_neuron_data.is_some() {
535            increment(
536                &mut self.known_neuron_metadata_count,
537                "known_neuron_metadata_count",
538            )?;
539        }
540        if neuron.joined_community_fund_timestamp_seconds.is_some() {
541            increment(
542                &mut self.neurons_fund_join_timestamp_present_count,
543                "neurons_fund_join_timestamp_present_count",
544            )?;
545        }
546        let retrieved_at = neuron.retrieved_at_timestamp_seconds;
547        self.retrieved_timestamp_bounds = Some(match self.retrieved_timestamp_bounds {
548            Some((earliest, latest)) => (earliest.min(retrieved_at), latest.max(retrieved_at)),
549            None => (retrieved_at, retrieved_at),
550        });
551        Ok(())
552    }
553
554    fn into_report(
555        self,
556        collection: &NnsNeuronCollectionState,
557        source: NnsGovernanceSourceProvenance,
558    ) -> NnsNeuronDistributionReport {
559        let collected_neuron_count = collection.neurons_fetched();
560        NnsNeuronDistributionReport {
561            schema_version: NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION,
562            network: collection.network().to_string(),
563            governance_canister_id: collection.governance_canister_id().to_string(),
564            source,
565            collection_started_at: collection.started_at().to_string(),
566            collection_updated_at: collection.updated_at().to_string(),
567            collection_page_count: collection.pages_fetched(),
568            collected_neuron_count,
569            point_in_time_guaranteed: false,
570            earliest_retrieved_at_timestamp_seconds: self
571                .retrieved_timestamp_bounds
572                .map(|bounds| bounds.0),
573            latest_retrieved_at_timestamp_seconds: self
574                .retrieved_timestamp_bounds
575                .map(|bounds| bounds.1),
576            total_effective_stake_e8s: distribution_total(self.total_effective_stake),
577            reported_staked_maturity_neuron_count: self.staked_maturity.samples(),
578            unreported_staked_maturity_neuron_count: collected_neuron_count
579                - self.staked_maturity.samples(),
580            total_reported_staked_maturity_e8s_equivalent: distribution_total(self.staked_maturity),
581            reported_deciding_voting_power_neuron_count: self.deciding_voting_power.samples(),
582            unreported_deciding_voting_power_neuron_count: collected_neuron_count
583                - self.deciding_voting_power.samples(),
584            total_reported_deciding_voting_power: distribution_total(self.deciding_voting_power),
585            reported_potential_voting_power_neuron_count: self.potential_voting_power.samples(),
586            unreported_potential_voting_power_neuron_count: collected_neuron_count
587                - self.potential_voting_power.samples(),
588            total_reported_potential_voting_power: distribution_total(self.potential_voting_power),
589            known_neuron_metadata_count: self.known_neuron_metadata_count,
590            neurons_fund_join_timestamp_present_count: self
591                .neurons_fund_join_timestamp_present_count,
592            state_distribution: self
593                .states
594                .into_iter()
595                .map(|(state, distribution)| NnsNeuronStateDistribution {
596                    state,
597
598                    neuron_count: distribution.samples(),
599                    effective_stake_e8s: distribution_total(distribution),
600                })
601                .collect(),
602            visibility_distribution: self
603                .visibilities
604                .into_iter()
605                .map(
606                    |(visibility, distribution)| NnsNeuronVisibilityDistribution {
607                        visibility,
608
609                        neuron_count: distribution.samples(),
610                        effective_stake_e8s: distribution_total(distribution),
611                    },
612                )
613                .collect(),
614            neuron_type_distribution: self
615                .neuron_types
616                .into_iter()
617                .map(|(neuron_type, distribution)| NnsNeuronTypeDistribution {
618                    neuron_type,
619
620                    neuron_count: distribution.samples(),
621                    effective_stake_e8s: distribution_total(distribution),
622                })
623                .collect(),
624        }
625    }
626}
627
628fn increment(value: &mut u64, field: &'static str) -> Result<(), NnsNeuronDistributionError> {
629    *value = value
630        .checked_add(1)
631        .ok_or(NnsNeuronDistributionError::AccountingOverflow { field })?;
632    Ok(())
633}
634
635fn record_distribution(
636    values: &mut CheckedMeasurementAggregate,
637    value: Option<u64>,
638    count_field: &'static str,
639    total_field: &'static str,
640) -> Result<(), NnsNeuronDistributionError> {
641    if let Some(value) = value {
642        values.try_record(u128::from(value)).map_err(|error| {
643            NnsNeuronDistributionError::AccountingOverflow {
644                field: match error {
645                    MeasurementRecordError::Samples => count_field,
646                    MeasurementRecordError::Total => total_field,
647                },
648            }
649        })?;
650        u64::try_from(values.total())
651            .map_err(|_| NnsNeuronDistributionError::AccountingOverflow { field: total_field })?;
652    }
653    Ok(())
654}
655
656fn distribution_total(values: CheckedMeasurementAggregate) -> u64 {
657    u64::try_from(values.total()).expect("distribution recording admits only native u64 totals")
658}
659
660#[cfg(test)]
661mod tests;