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