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 #[serde(flatten)]
81 pub context: crate::nns::governance::NnsGovernanceAnalyticsContext,
82 pub collected_neuron_count: u64,
84 pub earliest_retrieved_at_timestamp_seconds: Option<u64>,
86 pub latest_retrieved_at_timestamp_seconds: Option<u64>,
88 pub total_effective_stake_e8s: u64,
90 pub reported_staked_maturity_neuron_count: u64,
92 pub unreported_staked_maturity_neuron_count: u64,
94 pub total_reported_staked_maturity_e8s_equivalent: u64,
96 pub reported_deciding_voting_power_neuron_count: u64,
98 pub unreported_deciding_voting_power_neuron_count: u64,
100 pub total_reported_deciding_voting_power: u64,
102 pub reported_potential_voting_power_neuron_count: u64,
104 pub unreported_potential_voting_power_neuron_count: u64,
106 pub total_reported_potential_voting_power: u64,
108 pub known_neuron_metadata_count: u64,
110 pub neurons_fund_join_timestamp_present_count: u64,
112 pub state_distribution: Vec<NnsNeuronStateDistribution>,
114 pub visibility_distribution: Vec<NnsNeuronVisibilityDistribution>,
116 pub neuron_type_distribution: Vec<NnsNeuronTypeDistribution>,
118}
119
120#[derive(Debug, Eq, PartialEq, ThisError)]
127#[error("invalid NNS neuron distribution report: {reason}")]
128pub struct NnsNeuronDistributionValidationError {
129 pub reason: String,
131}
132
133#[derive(Debug, ThisError)]
140pub enum NnsNeuronDistributionError {
141 #[error("invalid NNS neuron collection state for distribution projection: {reason}")]
143 InvalidCollectionState {
144 reason: String,
146 },
147
148 #[error("NNS neuron distribution requires a complete collection; state is {status}")]
150 CollectionNotComplete {
151 status: NnsGovernanceCollectionStatus,
153 },
154
155 #[error(
157 "NNS neuron distribution received {actual} rows; complete collection accounts for {expected}"
158 )]
159 NeuronCountMismatch {
160 expected: u64,
162 actual: u64,
164 },
165
166 #[error("invalid NNS neuron rows for distribution projection: {reason}")]
168 InvalidNeuronRows {
169 reason: String,
171 },
172
173 #[error("NNS neuron distribution accounting overflow while updating {field}")]
175 AccountingOverflow {
176 field: &'static str,
178 },
179}
180
181pub 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
245pub 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;