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