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