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::{
16            NnsGovernanceRequest, NnsGovernanceSourceProvenance, NnsGovernanceSourceSelection,
17            validate_governance_request, validate_source_provenance,
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: NnsProposalCollectionStatus,
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    /// The projected report failed its shared publication invariants.
289    #[error(transparent)]
290    InvalidReport(#[from] NnsProposalActivityValidationError),
291}
292
293/// Validate every activity-report invariant available without source rows or live host calls.
294pub fn validate_nns_proposal_activity_report(
295    report: &NnsProposalActivityReport,
296) -> Result<(), NnsProposalActivityValidationError> {
297    validate_activity_header(report)?;
298    validate_activity_selection(report)?;
299    validate_topic_counts(report)?;
300    validate_status_counts(report)?;
301    validate_reward_status_counts(report)?;
302    validate_day_counts(report)
303}
304
305fn validate_activity_header(
306    report: &NnsProposalActivityReport,
307) -> Result<(), NnsProposalActivityValidationError> {
308    if report.schema_version != NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION {
309        return Err(invalid_validation(format!(
310            "schema version {} does not equal {}",
311            report.schema_version, NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
312        )));
313    }
314    if report.network != MAINNET_NETWORK {
315        return Err(invalid_validation(format!(
316            "network is {}, expected {MAINNET_NETWORK}",
317            report.network
318        )));
319    }
320    if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
321        return Err(invalid_validation(format!(
322            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
323            report.governance_canister_id
324        )));
325    }
326    if report.collection_page_count == 0 {
327        return Err(invalid_validation(
328            "complete activity report must retain at least one collection page",
329        ));
330    }
331    if report.point_in_time_guaranteed {
332        return Err(invalid_validation(
333            "sequential proposal activity cannot claim a point-in-time snapshot",
334        ));
335    }
336
337    let source = match &report.source {
338        NnsGovernanceSourceProvenance::ReplicaQuery {
339            endpoint,
340            fetched_by,
341        } => NnsGovernanceSourceSelection::ReplicaQuery {
342            endpoint: endpoint.clone(),
343            fetched_by: fetched_by.clone(),
344        },
345        NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => {
346            NnsGovernanceSourceSelection::ReplicatedInterCanisterCall
347        }
348    };
349    let request = NnsGovernanceRequest {
350        network: report.network.clone(),
351        fetched_at: report.collection_started_at.clone(),
352        source,
353    };
354    validate_governance_request(&request)
355        .map_err(|error| invalid_validation(format!("invalid collection source: {error}")))?;
356    validate_source_provenance(&request.source, &report.source)
357        .map_err(|error| invalid_validation(format!("invalid collection provenance: {error}")))
358}
359
360fn validate_activity_selection(
361    report: &NnsProposalActivityReport,
362) -> Result<(), NnsProposalActivityValidationError> {
363    if let (Some(from), Some(until)) = (
364        report.from_proposal_timestamp_seconds,
365        report.until_proposal_timestamp_seconds,
366    ) && from >= until
367    {
368        return Err(invalid_validation(format!(
369            "from proposal timestamp {from} must be below until timestamp {until}"
370        )));
371    }
372    if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
373        return Err(invalid_validation(
374            "excluded_before_from_count must be zero without a lower bound",
375        ));
376    }
377    if report.until_proposal_timestamp_seconds.is_none()
378        && report.excluded_at_or_after_until_count != 0
379    {
380        return Err(invalid_validation(
381            "excluded_at_or_after_until_count must be zero without an upper bound",
382        ));
383    }
384
385    let accounted = report
386        .included_proposal_count
387        .checked_add(report.excluded_before_from_count)
388        .and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
389        .ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
390    if accounted != report.collected_proposal_count {
391        return Err(invalid_validation(format!(
392            "selection accounts for {accounted} proposals, expected {}",
393            report.collected_proposal_count
394        )));
395    }
396    validate_included_range(report)
397}
398
399fn validate_included_range(
400    report: &NnsProposalActivityReport,
401) -> Result<(), NnsProposalActivityValidationError> {
402    let (earliest, latest) = match (
403        report.earliest_included_proposal_timestamp_seconds,
404        report.latest_included_proposal_timestamp_seconds,
405    ) {
406        (None, None) if report.included_proposal_count == 0 => return Ok(()),
407        (Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
408        _ => {
409            return Err(invalid_validation(
410                "included timestamp range presence disagrees with included_proposal_count",
411            ));
412        }
413    };
414    if earliest == 0 || earliest > latest {
415        return Err(invalid_validation(
416            "included proposal timestamps must be nonzero and ascending",
417        ));
418    }
419    if report
420        .from_proposal_timestamp_seconds
421        .is_some_and(|from| earliest < from)
422    {
423        return Err(invalid_validation(
424            "earliest included proposal timestamp precedes the lower bound",
425        ));
426    }
427    if report
428        .until_proposal_timestamp_seconds
429        .is_some_and(|until| latest >= until)
430    {
431        return Err(invalid_validation(
432            "latest included proposal timestamp reaches or exceeds the upper bound",
433        ));
434    }
435    Ok(())
436}
437
438fn validate_topic_counts(
439    report: &NnsProposalActivityReport,
440) -> Result<(), NnsProposalActivityValidationError> {
441    let mut previous = None;
442    let mut total = 0_u64;
443    for row in &report.topic_counts {
444        if previous.is_some_and(|topic| topic >= row.topic) {
445            return Err(invalid_validation(
446                "topic count rows are not strictly raw-code ordered",
447            ));
448        }
449        if row.topic_text != NnsProposalTopic::from_code(row.topic) {
450            return Err(invalid_validation(format!(
451                "topic classification for raw code {} is inconsistent",
452                row.topic
453            )));
454        }
455        total = add_dimension_count(total, row.proposal_count, "topic")?;
456        previous = Some(row.topic);
457    }
458    validate_dimension_total(total, report.included_proposal_count, "topic")
459}
460
461fn validate_status_counts(
462    report: &NnsProposalActivityReport,
463) -> Result<(), NnsProposalActivityValidationError> {
464    let mut previous = None;
465    let mut total = 0_u64;
466    for row in &report.status_counts {
467        if previous.is_some_and(|status| status >= row.status) {
468            return Err(invalid_validation(
469                "status count rows are not strictly raw-code ordered",
470            ));
471        }
472        if row.status_text != NnsProposalStatus::from_code(row.status) {
473            return Err(invalid_validation(format!(
474                "status classification for raw code {} is inconsistent",
475                row.status
476            )));
477        }
478        total = add_dimension_count(total, row.proposal_count, "status")?;
479        previous = Some(row.status);
480    }
481    validate_dimension_total(total, report.included_proposal_count, "status")
482}
483
484fn validate_reward_status_counts(
485    report: &NnsProposalActivityReport,
486) -> Result<(), NnsProposalActivityValidationError> {
487    let mut previous = None;
488    let mut total = 0_u64;
489    for row in &report.reward_status_counts {
490        if previous.is_some_and(|reward_status| reward_status >= row.reward_status) {
491            return Err(invalid_validation(
492                "reward-status count rows are not strictly raw-code ordered",
493            ));
494        }
495        if row.reward_status_text != NnsProposalRewardStatus::from_code(row.reward_status) {
496            return Err(invalid_validation(format!(
497                "reward-status classification for raw code {} is inconsistent",
498                row.reward_status
499            )));
500        }
501        total = add_dimension_count(total, row.proposal_count, "reward-status")?;
502        previous = Some(row.reward_status);
503    }
504    validate_dimension_total(total, report.included_proposal_count, "reward-status")
505}
506
507fn validate_day_counts(
508    report: &NnsProposalActivityReport,
509) -> Result<(), NnsProposalActivityValidationError> {
510    let mut previous = None;
511    let mut total = 0_u64;
512    for row in &report.day_counts {
513        if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
514            return Err(invalid_validation(
515                "day count row is not aligned to 00:00:00 UTC",
516            ));
517        }
518        if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
519            return Err(invalid_validation(
520                "day count rows are not strictly time ordered",
521            ));
522        }
523        total = add_dimension_count(total, row.proposal_count, "day")?;
524        previous = Some(row.day_start_timestamp_seconds);
525    }
526    validate_dimension_total(total, report.included_proposal_count, "day")?;
527    validate_day_range(report)
528}
529
530fn validate_day_range(
531    report: &NnsProposalActivityReport,
532) -> Result<(), NnsProposalActivityValidationError> {
533    if report.included_proposal_count == 0 {
534        return Ok(());
535    }
536    let (Some(earliest), Some(latest)) = (
537        report.earliest_included_proposal_timestamp_seconds,
538        report.latest_included_proposal_timestamp_seconds,
539    ) else {
540        return Err(invalid_validation(
541            "included timestamp range is absent for positive day counts",
542        ));
543    };
544    let expected_first = earliest - (earliest % SECONDS_PER_DAY);
545    let expected_last = latest - (latest % SECONDS_PER_DAY);
546    let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
547        return Err(invalid_validation(
548            "positive included count requires nonempty day counts",
549        ));
550    };
551    let first = first.day_start_timestamp_seconds;
552    let last = last.day_start_timestamp_seconds;
553    if first != expected_first || last != expected_last {
554        return Err(invalid_validation(
555            "day count endpoints do not cover the included timestamp range",
556        ));
557    }
558    Ok(())
559}
560
561fn add_dimension_count(
562    total: u64,
563    count: u64,
564    dimension: &'static str,
565) -> Result<u64, NnsProposalActivityValidationError> {
566    if count == 0 {
567        return Err(invalid_validation(format!(
568            "{dimension} count row must be nonzero"
569        )));
570    }
571    total
572        .checked_add(count)
573        .ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
574}
575
576fn validate_dimension_total(
577    actual: u64,
578    expected: u64,
579    dimension: &'static str,
580) -> Result<(), NnsProposalActivityValidationError> {
581    if actual == expected {
582        Ok(())
583    } else {
584        Err(invalid_validation(format!(
585            "{dimension} counts sum to {actual}, expected {expected}"
586        )))
587    }
588}
589
590fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
591    NnsProposalActivityValidationError {
592        reason: reason.into(),
593    }
594}
595
596/// Build one deterministic activity report from a complete caller-retained proposal collection.
597pub fn build_nns_proposal_activity_report(
598    request: &NnsProposalActivityRequest,
599    collection: &NnsProposalCollectionState,
600    proposals: &[NnsProposalRow],
601) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
602    validate_collection_state(collection).map_err(|error| {
603        NnsProposalActivityError::InvalidCollectionState {
604            reason: error.to_string(),
605        }
606    })?;
607    if !collection.is_complete() {
608        return Err(NnsProposalActivityError::CollectionNotComplete {
609            status: collection.status(),
610        });
611    }
612    validate_time_window(request)?;
613
614    let expected = u64::try_from(collection.proposals_fetched()).map_err(|_| {
615        NnsProposalActivityError::AccountingOverflow {
616            field: "collected_proposal_count",
617        }
618    })?;
619    let actual = u64::try_from(proposals.len()).map_err(|_| {
620        NnsProposalActivityError::AccountingOverflow {
621            field: "supplied_proposal_count",
622        }
623    })?;
624    if actual != expected {
625        return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
626    }
627
628    let mut activity = ActivityAccumulator::with_capacity(proposals.len());
629    for proposal in proposals {
630        activity.observe(request, proposal)?;
631    }
632
633    let source = collection.source().cloned().ok_or_else(|| {
634        NnsProposalActivityError::InvalidCollectionState {
635            reason: "complete collection has no concrete source provenance".to_string(),
636        }
637    })?;
638    let report = activity.into_report(request, collection, expected, source);
639    validate_nns_proposal_activity_report(&report)?;
640    Ok(report)
641}
642
643struct ActivityAccumulator {
644    proposal_ids: HashSet<u64>,
645    topic_counts: BTreeMap<i32, u64>,
646    status_counts: BTreeMap<i32, u64>,
647    reward_status_counts: BTreeMap<i32, u64>,
648    day_counts: BTreeMap<u64, u64>,
649    included_proposal_count: u64,
650    excluded_before_from_count: u64,
651    excluded_at_or_after_until_count: u64,
652    earliest_included_proposal_timestamp_seconds: Option<u64>,
653    latest_included_proposal_timestamp_seconds: Option<u64>,
654}
655
656impl ActivityAccumulator {
657    fn with_capacity(proposal_count: usize) -> Self {
658        Self {
659            proposal_ids: HashSet::with_capacity(proposal_count),
660            topic_counts: BTreeMap::new(),
661            status_counts: BTreeMap::new(),
662            reward_status_counts: BTreeMap::new(),
663            day_counts: BTreeMap::new(),
664            included_proposal_count: 0,
665            excluded_before_from_count: 0,
666            excluded_at_or_after_until_count: 0,
667            earliest_included_proposal_timestamp_seconds: None,
668            latest_included_proposal_timestamp_seconds: None,
669        }
670    }
671
672    fn observe(
673        &mut self,
674        request: &NnsProposalActivityRequest,
675        proposal: &NnsProposalRow,
676    ) -> Result<(), NnsProposalActivityError> {
677        validate_proposal_row(proposal, &mut self.proposal_ids)?;
678        let timestamp = proposal.proposal_timestamp_seconds;
679        if request
680            .from_proposal_timestamp_seconds
681            .is_some_and(|from| timestamp < from)
682        {
683            return increment_count(
684                &mut self.excluded_before_from_count,
685                "excluded_before_from_count",
686            );
687        }
688        if request
689            .until_proposal_timestamp_seconds
690            .is_some_and(|until| timestamp >= until)
691        {
692            return increment_count(
693                &mut self.excluded_at_or_after_until_count,
694                "excluded_at_or_after_until_count",
695            );
696        }
697
698        increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
699        increment_count(
700            self.topic_counts.entry(proposal.topic).or_default(),
701            "topic_count",
702        )?;
703        increment_count(
704            self.status_counts.entry(proposal.status).or_default(),
705            "status_count",
706        )?;
707        increment_count(
708            self.reward_status_counts
709                .entry(proposal.reward_status)
710                .or_default(),
711            "reward_status_count",
712        )?;
713        let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
714        increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
715        self.earliest_included_proposal_timestamp_seconds = Some(
716            self.earliest_included_proposal_timestamp_seconds
717                .map_or(timestamp, |earliest| earliest.min(timestamp)),
718        );
719        self.latest_included_proposal_timestamp_seconds = Some(
720            self.latest_included_proposal_timestamp_seconds
721                .map_or(timestamp, |latest| latest.max(timestamp)),
722        );
723        Ok(())
724    }
725
726    fn into_report(
727        self,
728        request: &NnsProposalActivityRequest,
729        collection: &NnsProposalCollectionState,
730        collected_proposal_count: u64,
731        source: NnsGovernanceSourceProvenance,
732    ) -> NnsProposalActivityReport {
733        NnsProposalActivityReport {
734            schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
735            network: collection.network().to_string(),
736            governance_canister_id: collection.governance_canister_id().to_string(),
737            source,
738            collection_started_at: collection.started_at().to_string(),
739            collection_updated_at: collection.updated_at().to_string(),
740            collection_page_count: collection.pages_fetched(),
741            collected_proposal_count,
742            point_in_time_guaranteed: false,
743            from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
744            until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
745            included_proposal_count: self.included_proposal_count,
746            excluded_before_from_count: self.excluded_before_from_count,
747            excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
748            earliest_included_proposal_timestamp_seconds: self
749                .earliest_included_proposal_timestamp_seconds,
750            latest_included_proposal_timestamp_seconds: self
751                .latest_included_proposal_timestamp_seconds,
752            topic_counts: self
753                .topic_counts
754                .into_iter()
755                .map(|(topic, proposal_count)| NnsProposalTopicCount {
756                    topic,
757                    topic_text: NnsProposalTopic::from_code(topic),
758                    proposal_count,
759                })
760                .collect(),
761            status_counts: self
762                .status_counts
763                .into_iter()
764                .map(|(status, proposal_count)| NnsProposalStatusCount {
765                    status,
766                    status_text: NnsProposalStatus::from_code(status),
767                    proposal_count,
768                })
769                .collect(),
770            reward_status_counts: self
771                .reward_status_counts
772                .into_iter()
773                .map(
774                    |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
775                        reward_status,
776                        reward_status_text: NnsProposalRewardStatus::from_code(reward_status),
777                        proposal_count,
778                    },
779                )
780                .collect(),
781            day_counts: self
782                .day_counts
783                .into_iter()
784                .map(
785                    |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
786                        day_start_timestamp_seconds,
787                        proposal_count,
788                    },
789                )
790                .collect(),
791        }
792    }
793}
794
795const fn validate_time_window(
796    request: &NnsProposalActivityRequest,
797) -> Result<(), NnsProposalActivityError> {
798    if let (Some(from), Some(until)) = (
799        request.from_proposal_timestamp_seconds,
800        request.until_proposal_timestamp_seconds,
801    ) && from >= until
802    {
803        return Err(NnsProposalActivityError::InvalidTimeWindow {
804            from_proposal_timestamp_seconds: from,
805            until_proposal_timestamp_seconds: until,
806        });
807    }
808    Ok(())
809}
810
811fn validate_proposal_row(
812    proposal: &NnsProposalRow,
813    proposal_ids: &mut HashSet<u64>,
814) -> Result<(), NnsProposalActivityError> {
815    let proposal_id = proposal
816        .proposal_id
817        .ok_or(NnsProposalActivityError::MissingProposalId)?;
818    if proposal_id == 0 {
819        return Err(NnsProposalActivityError::ZeroProposalId);
820    }
821    if !proposal_ids.insert(proposal_id) {
822        return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
823    }
824    if proposal.proposal_timestamp_seconds == 0 {
825        return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
826    }
827
828    let expected_topic = NnsProposalTopic::from_code(proposal.topic);
829    if proposal.topic_text != expected_topic {
830        return Err(NnsProposalActivityError::TopicClassificationMismatch {
831            proposal_id,
832            topic: proposal.topic,
833            actual: proposal.topic_text,
834            expected: expected_topic,
835        });
836    }
837    let expected_status = NnsProposalStatus::from_code(proposal.status);
838    if proposal.status_text != expected_status {
839        return Err(NnsProposalActivityError::StatusClassificationMismatch {
840            proposal_id,
841            status: proposal.status,
842            actual: proposal.status_text,
843            expected: expected_status,
844        });
845    }
846    let expected_reward_status = NnsProposalRewardStatus::from_code(proposal.reward_status);
847    if proposal.reward_status_text != expected_reward_status {
848        return Err(
849            NnsProposalActivityError::RewardStatusClassificationMismatch {
850                proposal_id,
851                reward_status: proposal.reward_status,
852                actual: proposal.reward_status_text,
853                expected: expected_reward_status,
854            },
855        );
856    }
857    Ok(())
858}
859
860fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
861    *count = count
862        .checked_add(1)
863        .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
864    Ok(())
865}
866
867#[cfg(test)]
868mod tests;