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