1use 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
16pub const NNS_NEURON_DISTRIBUTION_REPORT_SCHEMA_VERSION: u32 = 1;
18
19#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
26pub struct NnsNeuronStateDistribution {
27 pub state: i32,
29 pub neuron_count: u64,
31 pub effective_stake_e8s: u64,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
42pub struct NnsNeuronVisibilityDistribution {
43 pub visibility: Option<i32>,
45 pub neuron_count: u64,
47 pub effective_stake_e8s: u64,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
58pub struct NnsNeuronTypeDistribution {
59 pub neuron_type: Option<i32>,
61 pub neuron_count: u64,
63 pub effective_stake_e8s: u64,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
74pub struct NnsNeuronDistributionReport {
75 #[serde(flatten)]
77 pub context: crate::nns::governance::NnsGovernanceAnalyticsContext,
78 pub collected_neuron_count: u64,
80 pub earliest_retrieved_at_timestamp_seconds: Option<u64>,
82 pub latest_retrieved_at_timestamp_seconds: Option<u64>,
84 pub total_effective_stake_e8s: u64,
86 pub reported_staked_maturity_neuron_count: u64,
88 pub unreported_staked_maturity_neuron_count: u64,
90 pub total_reported_staked_maturity_e8s_equivalent: u64,
92 pub reported_deciding_voting_power_neuron_count: u64,
94 pub unreported_deciding_voting_power_neuron_count: u64,
96 pub total_reported_deciding_voting_power: u64,
98 pub reported_potential_voting_power_neuron_count: u64,
100 pub unreported_potential_voting_power_neuron_count: u64,
102 pub total_reported_potential_voting_power: u64,
104 pub known_neuron_metadata_count: u64,
106 pub neurons_fund_join_timestamp_present_count: u64,
108 pub state_distribution: Vec<NnsNeuronStateDistribution>,
110 pub visibility_distribution: Vec<NnsNeuronVisibilityDistribution>,
112 pub neuron_type_distribution: Vec<NnsNeuronTypeDistribution>,
114}
115
116#[derive(Debug, ThisError)]
123pub enum NnsNeuronDistributionError {
124 #[error("invalid NNS neuron collection state for distribution projection: {reason}")]
126 InvalidCollectionState {
127 reason: String,
129 },
130
131 #[error("NNS neuron distribution requires a complete collection; state is {status}")]
133 CollectionNotComplete {
134 status: NnsGovernanceCollectionStatus,
136 },
137
138 #[error(
140 "NNS neuron distribution received {actual} rows; complete collection accounts for {expected}"
141 )]
142 NeuronCountMismatch {
143 expected: u64,
145 actual: u64,
147 },
148
149 #[error("invalid NNS neuron rows for distribution projection: {reason}")]
151 InvalidNeuronRows {
152 reason: String,
154 },
155
156 #[error("NNS neuron distribution accounting overflow while updating {field}")]
158 AccountingOverflow {
159 field: &'static str,
161 },
162}
163
164pub 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;