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