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