Skip to main content

ic_query/nns/proposals/report/activity/
mod.rs

1//! Module: nns::proposals::report::activity
2//!
3//! Responsibility: validate and aggregate complete caller-retained NNS proposal collections.
4//! Does not own: collection transport, persistence, cache policy, or process output.
5//! Boundary: projects complete proposal evidence into deterministic portable activity reports.
6
7use super::{
8    NnsProposalCollectionState, NnsProposalCollectionStatus,
9    collection::validate_collection_state,
10    model::{NnsProposalRewardStatus, NnsProposalRow, NnsProposalStatus, NnsProposalTopic},
11};
12use crate::{
13    nns::{
14        MAINNET_GOVERNANCE_CANISTER_ID,
15        governance::{NnsGovernanceSourceProvenance, validate_governance_report_source},
16    },
17    subnet_catalog::MAINNET_NETWORK,
18};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, HashSet};
21use thiserror::Error as ThisError;
22
23/// Version of the portable NNS proposal activity report schema.
24pub const NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION: u32 = 1;
25
26const SECONDS_PER_DAY: u64 = 86_400;
27
28///
29/// NnsProposalActivityRequest
30///
31/// Optional half-open proposal-creation time window for one local activity projection.
32///
33
34#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
35pub struct NnsProposalActivityRequest {
36    /// Inclusive lower proposal-creation timestamp bound.
37    pub from_proposal_timestamp_seconds: Option<u64>,
38    /// Exclusive upper proposal-creation timestamp bound.
39    pub until_proposal_timestamp_seconds: Option<u64>,
40}
41
42///
43/// NnsProposalTopicCount
44///
45/// Proposal count for one raw native Governance topic code.
46///
47
48#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
49pub struct NnsProposalTopicCount {
50    /// Raw native Governance topic code.
51    pub topic: i32,
52    /// Classification derived from the raw topic code.
53    pub topic_text: NnsProposalTopic,
54    /// Number of included proposals with this topic code.
55    pub proposal_count: u64,
56}
57
58///
59/// NnsProposalStatusCount
60///
61/// Proposal count for one raw native Governance decision-status code.
62///
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
65pub struct NnsProposalStatusCount {
66    /// Raw native Governance decision-status code.
67    pub status: i32,
68    /// Classification derived from the raw status code.
69    pub status_text: NnsProposalStatus,
70    /// Number of included proposals with this status code.
71    pub proposal_count: u64,
72}
73
74///
75/// NnsProposalRewardStatusCount
76///
77/// Proposal count for one raw native Governance reward-status code.
78///
79
80#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub struct NnsProposalRewardStatusCount {
82    /// Raw native Governance reward-status code.
83    pub reward_status: i32,
84    /// Classification derived from the raw reward-status code.
85    pub reward_status_text: NnsProposalRewardStatus,
86    /// Number of included proposals with this reward-status code.
87    pub proposal_count: u64,
88}
89
90///
91/// NnsProposalDayCount
92///
93/// Proposal count for one UTC proposal-creation day.
94///
95
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97pub struct NnsProposalDayCount {
98    /// Unix timestamp at 00:00:00 UTC for the represented day.
99    pub day_start_timestamp_seconds: u64,
100    /// Number of included proposals created during this UTC day.
101    pub proposal_count: u64,
102}
103
104///
105/// NnsProposalActivityReport
106///
107/// Deterministic local activity projection over one complete NNS proposal collection.
108///
109
110#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111pub struct NnsProposalActivityReport {
112    /// Report schema version.
113    pub schema_version: u32,
114    /// Network identity retained by the complete collection.
115    pub network: String,
116    /// Fixed NNS Governance canister principal retained by the collection.
117    pub governance_canister_id: String,
118    /// Concrete transport and collector provenance retained by the collection.
119    pub source: NnsGovernanceSourceProvenance,
120    /// Caller-supplied time attached to collection creation.
121    pub collection_started_at: String,
122    /// Caller-supplied time attached to the final admitted page.
123    pub collection_updated_at: String,
124    /// Number of admitted source pages in the complete collection.
125    pub collection_page_count: u32,
126    /// Number of admitted proposal rows in the complete collection.
127    pub collected_proposal_count: u64,
128    /// Whether the sequential collection is guaranteed to represent one point in time.
129    pub point_in_time_guaranteed: bool,
130    /// Inclusive lower proposal-creation timestamp bound requested by the caller.
131    pub from_proposal_timestamp_seconds: Option<u64>,
132    /// Exclusive upper proposal-creation timestamp bound requested by the caller.
133    pub until_proposal_timestamp_seconds: Option<u64>,
134    /// Number of proposals included by the local time window.
135    pub included_proposal_count: u64,
136    /// Number of proposals excluded before the inclusive lower bound.
137    pub excluded_before_from_count: u64,
138    /// Number of proposals excluded at or after the exclusive upper bound.
139    pub excluded_at_or_after_until_count: u64,
140    /// Earliest creation timestamp among included proposals.
141    pub earliest_included_proposal_timestamp_seconds: Option<u64>,
142    /// Latest creation timestamp among included proposals.
143    pub latest_included_proposal_timestamp_seconds: Option<u64>,
144    /// Canonically raw-code-ordered proposal counts by topic.
145    pub topic_counts: Vec<NnsProposalTopicCount>,
146    /// Canonically raw-code-ordered proposal counts by decision status.
147    pub status_counts: Vec<NnsProposalStatusCount>,
148    /// Canonically raw-code-ordered proposal counts by reward status.
149    pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
150    /// Canonically time-ordered proposal counts by UTC creation day.
151    pub day_counts: Vec<NnsProposalDayCount>,
152}
153
154///
155/// NnsProposalActivityValidationError
156///
157/// Pure validation failure for an untrusted serialized or in-memory activity report.
158///
159
160#[derive(Debug, Eq, PartialEq, ThisError)]
161#[error("invalid NNS proposal activity report: {reason}")]
162pub struct NnsProposalActivityValidationError {
163    /// Deterministic invariant failure.
164    pub reason: String,
165}
166
167///
168/// NnsProposalActivityError
169///
170/// Deterministic validation or accounting failure from local proposal activity projection.
171///
172
173#[derive(Debug, ThisError)]
174pub enum NnsProposalActivityError {
175    /// The supplied collection state failed its shared continuation invariants.
176    #[error("invalid NNS proposal collection state for activity projection: {reason}")]
177    InvalidCollectionState {
178        /// Deterministic collection invariant failure.
179        reason: String,
180    },
181
182    /// The collection stopped without observing Governance API exhaustion.
183    #[error("NNS proposal activity requires a complete collection; state is {status}")]
184    CollectionNotComplete {
185        /// Current lifecycle of the otherwise valid collection state.
186        status: NnsProposalCollectionStatus,
187    },
188
189    /// The requested half-open proposal time window is empty or reversed.
190    #[error(
191        "invalid NNS proposal activity time window: from {from_proposal_timestamp_seconds} must be below until {until_proposal_timestamp_seconds}"
192    )]
193    InvalidTimeWindow {
194        /// Inclusive lower proposal-creation timestamp bound.
195        from_proposal_timestamp_seconds: u64,
196        /// Exclusive upper proposal-creation timestamp bound.
197        until_proposal_timestamp_seconds: u64,
198    },
199
200    /// The supplied rows do not match the collection's admitted-row accounting.
201    #[error(
202        "NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
203    )]
204    ProposalCountMismatch {
205        /// Proposal rows accounted for by the collection state.
206        expected: u64,
207        /// Proposal rows supplied to the builder.
208        actual: u64,
209    },
210
211    /// A supplied row has no proposal identifier.
212    #[error("NNS proposal activity received a row without a proposal id")]
213    MissingProposalId,
214
215    /// A supplied row uses the reserved zero proposal identifier.
216    #[error("NNS proposal activity received proposal id zero")]
217    ZeroProposalId,
218
219    /// A supplied proposal identifier occurs more than once.
220    #[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
221    DuplicateProposalId {
222        /// Repeated proposal identifier.
223        proposal_id: u64,
224    },
225
226    /// A supplied proposal has no meaningful creation timestamp.
227    #[error("NNS proposal {proposal_id} has proposal timestamp zero")]
228    ZeroProposalTimestamp {
229        /// Proposal identifier attached to the zero timestamp.
230        proposal_id: u64,
231    },
232
233    /// A typed topic classification disagrees with its raw code.
234    #[error(
235        "NNS proposal {proposal_id} topic classification {actual:?} does not match raw code {topic} ({expected:?})"
236    )]
237    TopicClassificationMismatch {
238        /// Proposal identifier carrying the mismatch.
239        proposal_id: u64,
240        /// Raw native topic code.
241        topic: i32,
242        /// Classification supplied by the row.
243        actual: NnsProposalTopic,
244        /// Classification derived from the raw code.
245        expected: NnsProposalTopic,
246    },
247
248    /// A typed decision-status classification disagrees with its raw code.
249    #[error(
250        "NNS proposal {proposal_id} status classification {actual:?} does not match raw code {status} ({expected:?})"
251    )]
252    StatusClassificationMismatch {
253        /// Proposal identifier carrying the mismatch.
254        proposal_id: u64,
255        /// Raw native decision-status code.
256        status: i32,
257        /// Classification supplied by the row.
258        actual: NnsProposalStatus,
259        /// Classification derived from the raw code.
260        expected: NnsProposalStatus,
261    },
262
263    /// A typed reward-status classification disagrees with its raw code.
264    #[error(
265        "NNS proposal {proposal_id} reward-status classification {actual:?} does not match raw code {reward_status} ({expected:?})"
266    )]
267    RewardStatusClassificationMismatch {
268        /// Proposal identifier carrying the mismatch.
269        proposal_id: u64,
270        /// Raw native reward-status code.
271        reward_status: i32,
272        /// Classification supplied by the row.
273        actual: NnsProposalRewardStatus,
274        /// Classification derived from the raw code.
275        expected: NnsProposalRewardStatus,
276    },
277
278    /// A row-count conversion or aggregate increment exceeded `u64`.
279    #[error("NNS proposal activity accounting overflow while updating {field}")]
280    AccountingOverflow {
281        /// Count or conversion that exceeded its representation.
282        field: &'static str,
283    },
284
285    /// The projected report failed its shared publication invariants.
286    #[error(transparent)]
287    InvalidReport(#[from] NnsProposalActivityValidationError),
288}
289
290/// Validate every activity-report invariant available without source rows or live host calls.
291pub fn validate_nns_proposal_activity_report(
292    report: &NnsProposalActivityReport,
293) -> Result<(), NnsProposalActivityValidationError> {
294    validate_activity_header(report)?;
295    validate_activity_selection(report)?;
296    validate_topic_counts(report)?;
297    validate_status_counts(report)?;
298    validate_reward_status_counts(report)?;
299    validate_day_counts(report)
300}
301
302fn validate_activity_header(
303    report: &NnsProposalActivityReport,
304) -> Result<(), NnsProposalActivityValidationError> {
305    if report.schema_version != NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION {
306        return Err(invalid_validation(format!(
307            "schema version {} does not equal {}",
308            report.schema_version, NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
309        )));
310    }
311    if report.network != MAINNET_NETWORK {
312        return Err(invalid_validation(format!(
313            "network is {}, expected {MAINNET_NETWORK}",
314            report.network
315        )));
316    }
317    if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
318        return Err(invalid_validation(format!(
319            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
320            report.governance_canister_id
321        )));
322    }
323    if report.collection_page_count == 0 {
324        return Err(invalid_validation(
325            "complete activity report must retain at least one collection page",
326        ));
327    }
328    if report.point_in_time_guaranteed {
329        return Err(invalid_validation(
330            "sequential proposal activity cannot claim a point-in-time snapshot",
331        ));
332    }
333
334    validate_governance_report_source(&report.network, &report.source).map_err(|error| {
335        let context = match &report.source {
336            NnsGovernanceSourceProvenance::ReplicaQuery { .. } => "source",
337            NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => "provenance",
338        };
339        invalid_validation(format!("invalid collection {context}: {error}"))
340    })
341}
342
343fn validate_activity_selection(
344    report: &NnsProposalActivityReport,
345) -> Result<(), NnsProposalActivityValidationError> {
346    if let (Some(from), Some(until)) = (
347        report.from_proposal_timestamp_seconds,
348        report.until_proposal_timestamp_seconds,
349    ) && from >= until
350    {
351        return Err(invalid_validation(format!(
352            "from proposal timestamp {from} must be below until timestamp {until}"
353        )));
354    }
355    if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
356        return Err(invalid_validation(
357            "excluded_before_from_count must be zero without a lower bound",
358        ));
359    }
360    if report.until_proposal_timestamp_seconds.is_none()
361        && report.excluded_at_or_after_until_count != 0
362    {
363        return Err(invalid_validation(
364            "excluded_at_or_after_until_count must be zero without an upper bound",
365        ));
366    }
367
368    let accounted = report
369        .included_proposal_count
370        .checked_add(report.excluded_before_from_count)
371        .and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
372        .ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
373    if accounted != report.collected_proposal_count {
374        return Err(invalid_validation(format!(
375            "selection accounts for {accounted} proposals, expected {}",
376            report.collected_proposal_count
377        )));
378    }
379    validate_included_range(report)
380}
381
382fn validate_included_range(
383    report: &NnsProposalActivityReport,
384) -> Result<(), NnsProposalActivityValidationError> {
385    let (earliest, latest) = match (
386        report.earliest_included_proposal_timestamp_seconds,
387        report.latest_included_proposal_timestamp_seconds,
388    ) {
389        (None, None) if report.included_proposal_count == 0 => return Ok(()),
390        (Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
391        _ => {
392            return Err(invalid_validation(
393                "included timestamp range presence disagrees with included_proposal_count",
394            ));
395        }
396    };
397    if earliest == 0 || earliest > latest {
398        return Err(invalid_validation(
399            "included proposal timestamps must be nonzero and ascending",
400        ));
401    }
402    if report
403        .from_proposal_timestamp_seconds
404        .is_some_and(|from| earliest < from)
405    {
406        return Err(invalid_validation(
407            "earliest included proposal timestamp precedes the lower bound",
408        ));
409    }
410    if report
411        .until_proposal_timestamp_seconds
412        .is_some_and(|until| latest >= until)
413    {
414        return Err(invalid_validation(
415            "latest included proposal timestamp reaches or exceeds the upper bound",
416        ));
417    }
418    Ok(())
419}
420
421fn validate_topic_counts(
422    report: &NnsProposalActivityReport,
423) -> Result<(), NnsProposalActivityValidationError> {
424    let mut previous = None;
425    let mut total = 0_u64;
426    for row in &report.topic_counts {
427        if previous.is_some_and(|topic| topic >= row.topic) {
428            return Err(invalid_validation(
429                "topic count rows are not strictly raw-code ordered",
430            ));
431        }
432        if row.topic_text != NnsProposalTopic::from_code(row.topic) {
433            return Err(invalid_validation(format!(
434                "topic classification for raw code {} is inconsistent",
435                row.topic
436            )));
437        }
438        total = add_dimension_count(total, row.proposal_count, "topic")?;
439        previous = Some(row.topic);
440    }
441    validate_dimension_total(total, report.included_proposal_count, "topic")
442}
443
444fn validate_status_counts(
445    report: &NnsProposalActivityReport,
446) -> Result<(), NnsProposalActivityValidationError> {
447    let mut previous = None;
448    let mut total = 0_u64;
449    for row in &report.status_counts {
450        if previous.is_some_and(|status| status >= row.status) {
451            return Err(invalid_validation(
452                "status count rows are not strictly raw-code ordered",
453            ));
454        }
455        if row.status_text != NnsProposalStatus::from_code(row.status) {
456            return Err(invalid_validation(format!(
457                "status classification for raw code {} is inconsistent",
458                row.status
459            )));
460        }
461        total = add_dimension_count(total, row.proposal_count, "status")?;
462        previous = Some(row.status);
463    }
464    validate_dimension_total(total, report.included_proposal_count, "status")
465}
466
467fn validate_reward_status_counts(
468    report: &NnsProposalActivityReport,
469) -> Result<(), NnsProposalActivityValidationError> {
470    let mut previous = None;
471    let mut total = 0_u64;
472    for row in &report.reward_status_counts {
473        if previous.is_some_and(|reward_status| reward_status >= row.reward_status) {
474            return Err(invalid_validation(
475                "reward-status count rows are not strictly raw-code ordered",
476            ));
477        }
478        if row.reward_status_text != NnsProposalRewardStatus::from_code(row.reward_status) {
479            return Err(invalid_validation(format!(
480                "reward-status classification for raw code {} is inconsistent",
481                row.reward_status
482            )));
483        }
484        total = add_dimension_count(total, row.proposal_count, "reward-status")?;
485        previous = Some(row.reward_status);
486    }
487    validate_dimension_total(total, report.included_proposal_count, "reward-status")
488}
489
490fn validate_day_counts(
491    report: &NnsProposalActivityReport,
492) -> Result<(), NnsProposalActivityValidationError> {
493    let mut previous = None;
494    let mut total = 0_u64;
495    for row in &report.day_counts {
496        if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
497            return Err(invalid_validation(
498                "day count row is not aligned to 00:00:00 UTC",
499            ));
500        }
501        if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
502            return Err(invalid_validation(
503                "day count rows are not strictly time ordered",
504            ));
505        }
506        total = add_dimension_count(total, row.proposal_count, "day")?;
507        previous = Some(row.day_start_timestamp_seconds);
508    }
509    validate_dimension_total(total, report.included_proposal_count, "day")?;
510    validate_day_range(report)
511}
512
513fn validate_day_range(
514    report: &NnsProposalActivityReport,
515) -> Result<(), NnsProposalActivityValidationError> {
516    if report.included_proposal_count == 0 {
517        return Ok(());
518    }
519    let (Some(earliest), Some(latest)) = (
520        report.earliest_included_proposal_timestamp_seconds,
521        report.latest_included_proposal_timestamp_seconds,
522    ) else {
523        return Err(invalid_validation(
524            "included timestamp range is absent for positive day counts",
525        ));
526    };
527    let expected_first = earliest - (earliest % SECONDS_PER_DAY);
528    let expected_last = latest - (latest % SECONDS_PER_DAY);
529    let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
530        return Err(invalid_validation(
531            "positive included count requires nonempty day counts",
532        ));
533    };
534    let first = first.day_start_timestamp_seconds;
535    let last = last.day_start_timestamp_seconds;
536    if first != expected_first || last != expected_last {
537        return Err(invalid_validation(
538            "day count endpoints do not cover the included timestamp range",
539        ));
540    }
541    Ok(())
542}
543
544fn add_dimension_count(
545    total: u64,
546    count: u64,
547    dimension: &'static str,
548) -> Result<u64, NnsProposalActivityValidationError> {
549    if count == 0 {
550        return Err(invalid_validation(format!(
551            "{dimension} count row must be nonzero"
552        )));
553    }
554    total
555        .checked_add(count)
556        .ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
557}
558
559fn validate_dimension_total(
560    actual: u64,
561    expected: u64,
562    dimension: &'static str,
563) -> Result<(), NnsProposalActivityValidationError> {
564    if actual == expected {
565        Ok(())
566    } else {
567        Err(invalid_validation(format!(
568            "{dimension} counts sum to {actual}, expected {expected}"
569        )))
570    }
571}
572
573fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
574    NnsProposalActivityValidationError {
575        reason: reason.into(),
576    }
577}
578
579/// Build one deterministic activity report from a complete caller-retained proposal collection.
580pub fn build_nns_proposal_activity_report(
581    request: &NnsProposalActivityRequest,
582    collection: &NnsProposalCollectionState,
583    proposals: &[NnsProposalRow],
584) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
585    validate_collection_state(collection).map_err(|error| {
586        NnsProposalActivityError::InvalidCollectionState {
587            reason: error.to_string(),
588        }
589    })?;
590    if !collection.is_complete() {
591        return Err(NnsProposalActivityError::CollectionNotComplete {
592            status: collection.status(),
593        });
594    }
595    validate_time_window(request)?;
596
597    let expected = collection.proposals_fetched();
598    let actual = u64::try_from(proposals.len()).map_err(|_| {
599        NnsProposalActivityError::AccountingOverflow {
600            field: "supplied_proposal_count",
601        }
602    })?;
603    if actual != expected {
604        return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
605    }
606
607    let mut activity = ActivityAccumulator::with_capacity(proposals.len());
608    for proposal in proposals {
609        activity.observe(request, proposal)?;
610    }
611
612    let source = collection.source().cloned().ok_or_else(|| {
613        NnsProposalActivityError::InvalidCollectionState {
614            reason: "complete collection has no concrete source provenance".to_string(),
615        }
616    })?;
617    let report = activity.into_report(request, collection, expected, source);
618    validate_nns_proposal_activity_report(&report)?;
619    Ok(report)
620}
621
622struct ActivityAccumulator {
623    proposal_ids: HashSet<u64>,
624    topic_counts: BTreeMap<i32, u64>,
625    status_counts: BTreeMap<i32, u64>,
626    reward_status_counts: BTreeMap<i32, u64>,
627    day_counts: BTreeMap<u64, u64>,
628    included_proposal_count: u64,
629    excluded_before_from_count: u64,
630    excluded_at_or_after_until_count: u64,
631    earliest_included_proposal_timestamp_seconds: Option<u64>,
632    latest_included_proposal_timestamp_seconds: Option<u64>,
633}
634
635impl ActivityAccumulator {
636    fn with_capacity(proposal_count: usize) -> Self {
637        Self {
638            proposal_ids: HashSet::with_capacity(proposal_count),
639            topic_counts: BTreeMap::new(),
640            status_counts: BTreeMap::new(),
641            reward_status_counts: BTreeMap::new(),
642            day_counts: BTreeMap::new(),
643            included_proposal_count: 0,
644            excluded_before_from_count: 0,
645            excluded_at_or_after_until_count: 0,
646            earliest_included_proposal_timestamp_seconds: None,
647            latest_included_proposal_timestamp_seconds: None,
648        }
649    }
650
651    fn observe(
652        &mut self,
653        request: &NnsProposalActivityRequest,
654        proposal: &NnsProposalRow,
655    ) -> Result<(), NnsProposalActivityError> {
656        validate_proposal_row(proposal, &mut self.proposal_ids)?;
657        let timestamp = proposal.proposal_timestamp_seconds;
658        if request
659            .from_proposal_timestamp_seconds
660            .is_some_and(|from| timestamp < from)
661        {
662            return increment_count(
663                &mut self.excluded_before_from_count,
664                "excluded_before_from_count",
665            );
666        }
667        if request
668            .until_proposal_timestamp_seconds
669            .is_some_and(|until| timestamp >= until)
670        {
671            return increment_count(
672                &mut self.excluded_at_or_after_until_count,
673                "excluded_at_or_after_until_count",
674            );
675        }
676
677        increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
678        increment_count(
679            self.topic_counts.entry(proposal.topic).or_default(),
680            "topic_count",
681        )?;
682        increment_count(
683            self.status_counts.entry(proposal.status).or_default(),
684            "status_count",
685        )?;
686        increment_count(
687            self.reward_status_counts
688                .entry(proposal.reward_status)
689                .or_default(),
690            "reward_status_count",
691        )?;
692        let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
693        increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
694        self.earliest_included_proposal_timestamp_seconds = Some(
695            self.earliest_included_proposal_timestamp_seconds
696                .map_or(timestamp, |earliest| earliest.min(timestamp)),
697        );
698        self.latest_included_proposal_timestamp_seconds = Some(
699            self.latest_included_proposal_timestamp_seconds
700                .map_or(timestamp, |latest| latest.max(timestamp)),
701        );
702        Ok(())
703    }
704
705    fn into_report(
706        self,
707        request: &NnsProposalActivityRequest,
708        collection: &NnsProposalCollectionState,
709        collected_proposal_count: u64,
710        source: NnsGovernanceSourceProvenance,
711    ) -> NnsProposalActivityReport {
712        NnsProposalActivityReport {
713            schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
714            network: collection.network().to_string(),
715            governance_canister_id: collection.governance_canister_id().to_string(),
716            source,
717            collection_started_at: collection.started_at().to_string(),
718            collection_updated_at: collection.updated_at().to_string(),
719            collection_page_count: collection.pages_fetched(),
720            collected_proposal_count,
721            point_in_time_guaranteed: false,
722            from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
723            until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
724            included_proposal_count: self.included_proposal_count,
725            excluded_before_from_count: self.excluded_before_from_count,
726            excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
727            earliest_included_proposal_timestamp_seconds: self
728                .earliest_included_proposal_timestamp_seconds,
729            latest_included_proposal_timestamp_seconds: self
730                .latest_included_proposal_timestamp_seconds,
731            topic_counts: self
732                .topic_counts
733                .into_iter()
734                .map(|(topic, proposal_count)| NnsProposalTopicCount {
735                    topic,
736                    topic_text: NnsProposalTopic::from_code(topic),
737                    proposal_count,
738                })
739                .collect(),
740            status_counts: self
741                .status_counts
742                .into_iter()
743                .map(|(status, proposal_count)| NnsProposalStatusCount {
744                    status,
745                    status_text: NnsProposalStatus::from_code(status),
746                    proposal_count,
747                })
748                .collect(),
749            reward_status_counts: self
750                .reward_status_counts
751                .into_iter()
752                .map(
753                    |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
754                        reward_status,
755                        reward_status_text: NnsProposalRewardStatus::from_code(reward_status),
756                        proposal_count,
757                    },
758                )
759                .collect(),
760            day_counts: self
761                .day_counts
762                .into_iter()
763                .map(
764                    |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
765                        day_start_timestamp_seconds,
766                        proposal_count,
767                    },
768                )
769                .collect(),
770        }
771    }
772}
773
774const fn validate_time_window(
775    request: &NnsProposalActivityRequest,
776) -> Result<(), NnsProposalActivityError> {
777    if let (Some(from), Some(until)) = (
778        request.from_proposal_timestamp_seconds,
779        request.until_proposal_timestamp_seconds,
780    ) && from >= until
781    {
782        return Err(NnsProposalActivityError::InvalidTimeWindow {
783            from_proposal_timestamp_seconds: from,
784            until_proposal_timestamp_seconds: until,
785        });
786    }
787    Ok(())
788}
789
790fn validate_proposal_row(
791    proposal: &NnsProposalRow,
792    proposal_ids: &mut HashSet<u64>,
793) -> Result<(), NnsProposalActivityError> {
794    let proposal_id = proposal
795        .proposal_id
796        .ok_or(NnsProposalActivityError::MissingProposalId)?;
797    if proposal_id == 0 {
798        return Err(NnsProposalActivityError::ZeroProposalId);
799    }
800    if !proposal_ids.insert(proposal_id) {
801        return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
802    }
803    if proposal.proposal_timestamp_seconds == 0 {
804        return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
805    }
806
807    let expected_topic = NnsProposalTopic::from_code(proposal.topic);
808    if proposal.topic_text != expected_topic {
809        return Err(NnsProposalActivityError::TopicClassificationMismatch {
810            proposal_id,
811            topic: proposal.topic,
812            actual: proposal.topic_text,
813            expected: expected_topic,
814        });
815    }
816    let expected_status = NnsProposalStatus::from_code(proposal.status);
817    if proposal.status_text != expected_status {
818        return Err(NnsProposalActivityError::StatusClassificationMismatch {
819            proposal_id,
820            status: proposal.status,
821            actual: proposal.status_text,
822            expected: expected_status,
823        });
824    }
825    let expected_reward_status = NnsProposalRewardStatus::from_code(proposal.reward_status);
826    if proposal.reward_status_text != expected_reward_status {
827        return Err(
828            NnsProposalActivityError::RewardStatusClassificationMismatch {
829                proposal_id,
830                reward_status: proposal.reward_status,
831                actual: proposal.reward_status_text,
832                expected: expected_reward_status,
833            },
834        );
835    }
836    Ok(())
837}
838
839fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
840    *count = count
841        .checked_add(1)
842        .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
843    Ok(())
844}
845
846#[cfg(test)]
847mod tests;