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