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::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, Eq, PartialEq, Serialize)]
26pub struct NnsNeuronStateDistribution {
27    /// Raw native Governance state code.
28    pub state: i32,
29    /// Number of collected neurons with this state code.
30    pub neuron_count: u64,
31    /// Sum of public effective stake for neurons with this state code.
32    pub effective_stake_e8s: u64,
33}
34
35///
36/// NnsNeuronVisibilityDistribution
37///
38/// Public neuron count and effective stake for one optional Governance visibility code.
39///
40
41#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct NnsNeuronVisibilityDistribution {
43    /// Raw optional native Governance visibility code.
44    pub visibility: Option<i32>,
45    /// Number of collected neurons with this visibility code or absence.
46    pub neuron_count: u64,
47    /// Sum of public effective stake for neurons with this visibility code or absence.
48    pub effective_stake_e8s: u64,
49}
50
51///
52/// NnsNeuronTypeDistribution
53///
54/// Public neuron count and effective stake for one optional Governance neuron-type code.
55///
56
57#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
58pub struct NnsNeuronTypeDistribution {
59    /// Raw optional native Governance neuron-type code.
60    pub neuron_type: Option<i32>,
61    /// Number of collected neurons with this type code or absence.
62    pub neuron_count: u64,
63    /// Sum of public effective stake for neurons with this type code or absence.
64    pub effective_stake_e8s: u64,
65}
66
67///
68/// NnsNeuronDistributionReport
69///
70/// Deterministic local distribution over one complete public NNS neuron collection.
71///
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
74pub struct NnsNeuronDistributionReport {
75    /// Shared record, flattened in JSON.
76    #[serde(flatten)]
77    pub context: crate::nns::governance::NnsGovernanceAnalyticsContext,
78    /// Number of admitted public neuron rows in the complete collection.
79    pub collected_neuron_count: u64,
80    /// Earliest raw Governance retrieval timestamp among collected rows.
81    pub earliest_retrieved_at_timestamp_seconds: Option<u64>,
82    /// Latest raw Governance retrieval timestamp among collected rows.
83    pub latest_retrieved_at_timestamp_seconds: Option<u64>,
84    /// Sum of public effective stake across every collected neuron.
85    pub total_effective_stake_e8s: u64,
86    /// Rows carrying a public staked-maturity value.
87    pub reported_staked_maturity_neuron_count: u64,
88    /// Rows without a public staked-maturity value.
89    pub unreported_staked_maturity_neuron_count: u64,
90    /// Sum of reported staked maturity values only.
91    pub total_reported_staked_maturity_e8s_equivalent: u64,
92    /// Rows carrying current deciding voting power.
93    pub reported_deciding_voting_power_neuron_count: u64,
94    /// Rows without current deciding voting power.
95    pub unreported_deciding_voting_power_neuron_count: u64,
96    /// Sum of reported current deciding voting power values only.
97    pub total_reported_deciding_voting_power: u64,
98    /// Rows carrying current potential voting power.
99    pub reported_potential_voting_power_neuron_count: u64,
100    /// Rows without current potential voting power.
101    pub unreported_potential_voting_power_neuron_count: u64,
102    /// Sum of reported current potential voting power values only.
103    pub total_reported_potential_voting_power: u64,
104    /// Rows carrying registered known-neuron metadata.
105    pub known_neuron_metadata_count: u64,
106    /// Rows carrying a public Neurons' Fund join timestamp.
107    pub neurons_fund_join_timestamp_present_count: u64,
108    /// Canonically raw-code-ordered distribution by neuron state.
109    pub state_distribution: Vec<NnsNeuronStateDistribution>,
110    /// Canonically optional-raw-code-ordered distribution by visibility.
111    pub visibility_distribution: Vec<NnsNeuronVisibilityDistribution>,
112    /// Canonically optional-raw-code-ordered distribution by neuron type.
113    pub neuron_type_distribution: Vec<NnsNeuronTypeDistribution>,
114}
115
116///
117/// NnsNeuronDistributionError
118///
119/// Deterministic validation or accounting failure from local neuron distribution projection.
120///
121
122#[derive(Debug, ThisError)]
123pub enum NnsNeuronDistributionError {
124    /// The supplied collection state failed its shared continuation invariants.
125    #[error("invalid NNS neuron collection state for distribution projection: {reason}")]
126    InvalidCollectionState {
127        /// Deterministic collection invariant failure.
128        reason: String,
129    },
130
131    /// The collection stopped without observing Governance API exhaustion.
132    #[error("NNS neuron distribution requires a complete collection; state is {status}")]
133    CollectionNotComplete {
134        /// Current lifecycle of the otherwise valid collection state.
135        status: NnsGovernanceCollectionStatus,
136    },
137
138    /// The supplied rows do not match the collection's admitted-row accounting.
139    #[error(
140        "NNS neuron distribution received {actual} rows; complete collection accounts for {expected}"
141    )]
142    NeuronCountMismatch {
143        /// Neuron rows accounted for by the collection state.
144        expected: u64,
145        /// Neuron rows supplied to the builder.
146        actual: u64,
147    },
148
149    /// Supplied rows failed the shared public-neuron response contract.
150    #[error("invalid NNS neuron rows for distribution projection: {reason}")]
151    InvalidNeuronRows {
152        /// Deterministic row invariant failure.
153        reason: String,
154    },
155
156    /// A count conversion, increment, or numeric sum exceeded `u64`.
157    #[error("NNS neuron distribution accounting overflow while updating {field}")]
158    AccountingOverflow {
159        /// Count or sum that exceeded its representation.
160        field: &'static str,
161    },
162}
163
164/// Build one deterministic distribution from a complete caller-retained public-neuron collection.
165pub fn build_nns_neuron_distribution_report(
166    collection: &NnsNeuronCollectionState,
167    neurons: &[NnsNeuronRow],
168) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
169    validate_collection_state(collection).map_err(|error| {
170        NnsNeuronDistributionError::InvalidCollectionState {
171            reason: error.to_string(),
172        }
173    })?;
174    if !collection.is_complete() {
175        return Err(NnsNeuronDistributionError::CollectionNotComplete {
176            status: collection.status(),
177        });
178    }
179    let source = collection.source().cloned().ok_or_else(|| {
180        NnsNeuronDistributionError::InvalidCollectionState {
181            reason: "complete collection has no concrete source provenance".to_string(),
182        }
183    })?;
184
185    let expected = collection.neurons_fetched();
186    let actual = u64::try_from(neurons.len()).map_err(|_| {
187        NnsNeuronDistributionError::AccountingOverflow {
188            field: "supplied_neuron_count",
189        }
190    })?;
191    if actual != expected {
192        return Err(NnsNeuronDistributionError::NeuronCountMismatch { expected, actual });
193    }
194    validate_neuron_rows(neurons).map_err(|error| {
195        NnsNeuronDistributionError::InvalidNeuronRows {
196            reason: error.to_string(),
197        }
198    })?;
199
200    aggregate_distribution(collection, neurons, source)
201}
202
203#[cfg(feature = "nns-host")]
204pub(super) fn build_distribution_from_cache(
205    cache: &super::cache::AdmittedNeuronCache,
206) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
207    let collection = cache.collection_state();
208    let source = collection.source().cloned().ok_or_else(|| {
209        NnsNeuronDistributionError::InvalidCollectionState {
210            reason: "complete collection has no concrete source provenance".to_string(),
211        }
212    })?;
213    aggregate_distribution(collection, cache.rows(), source)
214}
215
216fn aggregate_distribution(
217    collection: &NnsNeuronCollectionState,
218    neurons: &[NnsNeuronRow],
219    source: NnsGovernanceSourceProvenance,
220) -> Result<NnsNeuronDistributionReport, NnsNeuronDistributionError> {
221    let mut distribution = DistributionAccumulator::default();
222    for neuron in neurons {
223        distribution.observe(neuron)?;
224    }
225    Ok(distribution.into_report(collection, source))
226}
227
228#[derive(Default)]
229struct DistributionTotals {
230    count: u64,
231    total: u64,
232}
233
234#[derive(Default)]
235struct DistributionAccumulator {
236    states: BTreeMap<i32, DistributionTotals>,
237    visibilities: BTreeMap<Option<i32>, DistributionTotals>,
238    neuron_types: BTreeMap<Option<i32>, DistributionTotals>,
239    total_effective_stake: DistributionTotals,
240    staked_maturity: DistributionTotals,
241    deciding_voting_power: DistributionTotals,
242    potential_voting_power: DistributionTotals,
243    known_neuron_metadata_count: u64,
244    neurons_fund_join_timestamp_present_count: u64,
245    retrieved_timestamp_bounds: Option<(u64, u64)>,
246}
247
248impl DistributionAccumulator {
249    fn observe(&mut self, neuron: &NnsNeuronRow) -> Result<(), NnsNeuronDistributionError> {
250        record_distribution(
251            &mut self.total_effective_stake,
252            Some(neuron.stake_e8s),
253            "collected_neuron_count",
254            "total_effective_stake_e8s",
255        )?;
256        record_distribution(
257            self.states.entry(neuron.state).or_default(),
258            Some(neuron.stake_e8s),
259            "state_neuron_count",
260            "state_effective_stake_e8s",
261        )?;
262        record_distribution(
263            self.visibilities.entry(neuron.visibility).or_default(),
264            Some(neuron.stake_e8s),
265            "visibility_neuron_count",
266            "visibility_effective_stake_e8s",
267        )?;
268        record_distribution(
269            self.neuron_types.entry(neuron.neuron_type).or_default(),
270            Some(neuron.stake_e8s),
271            "neuron_type_neuron_count",
272            "neuron_type_effective_stake_e8s",
273        )?;
274        record_distribution(
275            &mut self.staked_maturity,
276            neuron.staked_maturity_e8s_equivalent,
277            "reported_staked_maturity_neuron_count",
278            "total_reported_staked_maturity_e8s_equivalent",
279        )?;
280        record_distribution(
281            &mut self.deciding_voting_power,
282            neuron.deciding_voting_power,
283            "reported_deciding_voting_power_neuron_count",
284            "total_reported_deciding_voting_power",
285        )?;
286        record_distribution(
287            &mut self.potential_voting_power,
288            neuron.potential_voting_power,
289            "reported_potential_voting_power_neuron_count",
290            "total_reported_potential_voting_power",
291        )?;
292        if neuron.known_neuron_data.is_some() {
293            increment(
294                &mut self.known_neuron_metadata_count,
295                "known_neuron_metadata_count",
296            )?;
297        }
298        if neuron.joined_community_fund_timestamp_seconds.is_some() {
299            increment(
300                &mut self.neurons_fund_join_timestamp_present_count,
301                "neurons_fund_join_timestamp_present_count",
302            )?;
303        }
304        let retrieved_at = neuron.retrieved_at_timestamp_seconds;
305        self.retrieved_timestamp_bounds = Some(match self.retrieved_timestamp_bounds {
306            Some((earliest, latest)) => (earliest.min(retrieved_at), latest.max(retrieved_at)),
307            None => (retrieved_at, retrieved_at),
308        });
309        Ok(())
310    }
311
312    fn into_report(
313        self,
314        collection: &NnsNeuronCollectionState,
315        source: NnsGovernanceSourceProvenance,
316    ) -> NnsNeuronDistributionReport {
317        let collected_neuron_count = collection.neurons_fetched();
318        NnsNeuronDistributionReport {
319            context: crate::nns::governance::NnsGovernanceAnalyticsContext {
320                schema_version: NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION,
321                network: collection.network().to_string(),
322                governance_canister_id: collection.governance_canister_id().to_string(),
323                source,
324                collection_started_at: collection.started_at().to_string(),
325                collection_updated_at: collection.updated_at().to_string(),
326                collection_page_count: collection.pages_fetched(),
327                point_in_time_guaranteed: false,
328            },
329            collected_neuron_count,
330            earliest_retrieved_at_timestamp_seconds: self
331                .retrieved_timestamp_bounds
332                .map(|bounds| bounds.0),
333            latest_retrieved_at_timestamp_seconds: self
334                .retrieved_timestamp_bounds
335                .map(|bounds| bounds.1),
336            total_effective_stake_e8s: self.total_effective_stake.total,
337            reported_staked_maturity_neuron_count: self.staked_maturity.count,
338            unreported_staked_maturity_neuron_count: collected_neuron_count
339                - self.staked_maturity.count,
340            total_reported_staked_maturity_e8s_equivalent: self.staked_maturity.total,
341            reported_deciding_voting_power_neuron_count: self.deciding_voting_power.count,
342            unreported_deciding_voting_power_neuron_count: collected_neuron_count
343                - self.deciding_voting_power.count,
344            total_reported_deciding_voting_power: self.deciding_voting_power.total,
345            reported_potential_voting_power_neuron_count: self.potential_voting_power.count,
346            unreported_potential_voting_power_neuron_count: collected_neuron_count
347                - self.potential_voting_power.count,
348            total_reported_potential_voting_power: self.potential_voting_power.total,
349            known_neuron_metadata_count: self.known_neuron_metadata_count,
350            neurons_fund_join_timestamp_present_count: self
351                .neurons_fund_join_timestamp_present_count,
352            state_distribution: self
353                .states
354                .into_iter()
355                .map(|(state, distribution)| NnsNeuronStateDistribution {
356                    state,
357
358                    neuron_count: distribution.count,
359                    effective_stake_e8s: distribution.total,
360                })
361                .collect(),
362            visibility_distribution: self
363                .visibilities
364                .into_iter()
365                .map(
366                    |(visibility, distribution)| NnsNeuronVisibilityDistribution {
367                        visibility,
368
369                        neuron_count: distribution.count,
370                        effective_stake_e8s: distribution.total,
371                    },
372                )
373                .collect(),
374            neuron_type_distribution: self
375                .neuron_types
376                .into_iter()
377                .map(|(neuron_type, distribution)| NnsNeuronTypeDistribution {
378                    neuron_type,
379
380                    neuron_count: distribution.count,
381                    effective_stake_e8s: distribution.total,
382                })
383                .collect(),
384        }
385    }
386}
387
388fn increment(value: &mut u64, field: &'static str) -> Result<(), NnsNeuronDistributionError> {
389    *value = value
390        .checked_add(1)
391        .ok_or(NnsNeuronDistributionError::AccountingOverflow { field })?;
392    Ok(())
393}
394
395fn record_distribution(
396    values: &mut DistributionTotals,
397    value: Option<u64>,
398    count_field: &'static str,
399    total_field: &'static str,
400) -> Result<(), NnsNeuronDistributionError> {
401    if let Some(value) = value {
402        let count = values
403            .count
404            .checked_add(1)
405            .ok_or(NnsNeuronDistributionError::AccountingOverflow { field: count_field })?;
406        let total = values
407            .total
408            .checked_add(value)
409            .ok_or(NnsNeuronDistributionError::AccountingOverflow { field: total_field })?;
410        *values = DistributionTotals { count, total };
411    }
412    Ok(())
413}
414
415#[cfg(test)]
416mod tests;