Skip to main content

ic_testkit/pic/
baseline_pool.rs

1use candid::Principal;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    num::NonZeroUsize,
5    ops::Deref,
6    panic::{AssertUnwindSafe, catch_unwind},
7    time::{Duration, Instant},
8};
9
10use crate::timing::saturating_add_optional_duration;
11
12use super::{
13    CachedPocketIcBaseline,
14    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
15};
16
17/// Caller-owned stable identity for one pooled fixture recipe.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct FixtureRecipeId(String);
20
21/// Reset domain whose handling is declared by a pooled baseline recipe.
22#[non_exhaustive]
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub enum ResetDomainKind {
25    /// PocketIC simulated time.
26    PocketIcTime,
27    /// Canisters outside the captured baseline set.
28    ExtraCanisters,
29    /// Pending ingress, timers, or cross-canister messages.
30    PendingMessages,
31    /// Subnet metrics, routing, allocation, or other subnet-global state.
32    SubnetState,
33    /// Files, processes, services, or other caller-owned resources.
34    ExternalResources,
35}
36
37/// Cycle handling required or achieved by one reset.
38#[non_exhaustive]
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum CycleResetPolicy {
41    /// Do not proactively add or remove cycles before snapshot restoration.
42    ///
43    /// PocketIC may still charge cycles while performing the restore.
44    PreserveCurrent,
45    /// Add cycles as needed to reach this minimum immediately before restore,
46    /// without removing excess.
47    TopUpTo(u128),
48    /// Restore the exact balance recorded by the recipe baseline.
49    RestoreExactBaseline,
50    /// Treat any relevant cycle mutation as requiring slot reconstruction.
51    RebuildOnMutation,
52}
53
54/// PocketIC time handling required or achieved by one reset.
55#[non_exhaustive]
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum TimeResetPolicy {
58    /// Preserve the current simulator time rather than claiming it was reset.
59    PreserveCurrent,
60    /// Restore the exact time recorded by the recipe baseline.
61    RestoreBaseline,
62    /// Treat any relevant time mutation as requiring slot reconstruction.
63    RebuildOnMutation,
64}
65
66/// Extra-canister handling required or achieved by one reset.
67#[non_exhaustive]
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum ExtraCanisterPolicy {
70    /// Validate that the baseline canister set is unchanged.
71    RequireBaselineSet,
72    /// Remove canisters explicitly tracked by the recipe.
73    RemoveTracked,
74    /// Treat any extra-canister change as requiring slot reconstruction.
75    RebuildOnChange,
76}
77
78/// Generic handling for reset domains without a more specific policy.
79#[non_exhaustive]
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum StateResetPolicy {
82    /// Reset the domain through caller-owned recipe logic.
83    ResetByRecipe,
84    /// Validate that the domain remained unchanged.
85    ValidateUnchanged,
86    /// Explicitly declare the domain irrelevant to this recipe's guarantees.
87    IrrelevantByRecipeContract,
88    /// Treat any relevant change as requiring slot reconstruction.
89    RebuildOnChange,
90}
91
92/// One reset guarantee required before a baseline may be reused.
93#[non_exhaustive]
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub enum ResetRequirement {
96    /// Apply this time policy.
97    PocketIcTime(TimeResetPolicy),
98    /// Apply this extra-canister policy.
99    ExtraCanisters(ExtraCanisterPolicy),
100    /// Apply this pending-message policy.
101    PendingMessages(StateResetPolicy),
102    /// Apply this subnet-state policy.
103    SubnetState(StateResetPolicy),
104    /// Apply this external-resource policy.
105    ExternalResources(StateResetPolicy),
106}
107
108/// One reset guarantee reported as achieved by a recipe.
109#[non_exhaustive]
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub enum ResetAchievement {
112    /// This time policy was achieved.
113    PocketIcTime(TimeResetPolicy),
114    /// This extra-canister policy was achieved.
115    ExtraCanisters(ExtraCanisterPolicy),
116    /// This pending-message policy was achieved.
117    PendingMessages(StateResetPolicy),
118    /// This subnet-state policy was achieved.
119    SubnetState(StateResetPolicy),
120    /// This external-resource policy was achieved.
121    ExternalResources(StateResetPolicy),
122}
123
124/// Typed reset guarantees required by one fixture recipe.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct ResetRequirements {
127    cycle_policy: CycleResetPolicy,
128    domains: BTreeMap<ResetDomainKind, ResetRequirement>,
129}
130
131/// Typed reset guarantees achieved by one preparation pass.
132#[derive(Clone, Debug, Default, Eq, PartialEq)]
133pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetAchievement>);
134
135/// Receipt for restoring the recipe's captured canister set.
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct CanisterRestoreReceipt {
138    canister_ids: Vec<Principal>,
139    cycle_policy: CycleResetPolicy,
140}
141
142/// Receipt identifying the readiness boundary reached after reset.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct ReadinessReceipt {
145    identity: String,
146}
147
148/// Receipt proving the recipe's final invariant validation ran successfully.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct ValidationReceipt {
151    recipe_id: FixtureRecipeId,
152    invariant_identity: String,
153}
154
155/// Contract failure while constructing or verifying recipe reset evidence.
156#[non_exhaustive]
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub enum BaselinePoolContractError {
159    /// Recipe identity was empty or whitespace-only.
160    EmptyRecipeIdentity,
161    /// A receipt identity was empty or whitespace-only.
162    EmptyReceiptIdentity { receipt: &'static str },
163    /// A reset domain was declared more than once.
164    DuplicateResetDomain { domain: ResetDomainKind },
165    /// A restored canister appeared more than once.
166    DuplicateCanisterId { canister_id: Principal },
167    /// A restore receipt contained no canisters.
168    EmptyCanisterSet,
169    /// The restore receipt did not satisfy the required cycle policy.
170    CyclePolicyMismatch {
171        required: CycleResetPolicy,
172        achieved: CycleResetPolicy,
173    },
174    /// The restored canister receipt did not identify the complete snapshot set.
175    RestoreCanisterSetMismatch {
176        expected: Vec<Principal>,
177        actual: Vec<Principal>,
178    },
179    /// A required reset domain had no matching achievement.
180    MissingResetDomain { domain: ResetDomainKind },
181    /// A reset achievement did not satisfy the required policy.
182    ResetPolicyMismatch {
183        requirement: ResetRequirement,
184        achievement: ResetAchievement,
185    },
186    /// Final validation reported a recipe other than the pool-owned recipe.
187    RecipeIdentityMismatch {
188        expected: FixtureRecipeId,
189        actual: FixtureRecipeId,
190    },
191}
192
193/// Whether validation is observing a newly built or restored baseline.
194#[non_exhaustive]
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub enum PreparedBaseline {
197    /// The recipe just built this baseline.
198    Built,
199    /// The recipe restored and reset an existing baseline.
200    Restored {
201        /// Captured canisters restored by the recipe.
202        canisters: CanisterRestoreReceipt,
203        /// Typed non-snapshot reset receipt.
204        reset: ResetReceipt,
205        /// Readiness boundary reached after reset.
206        readiness: ReadinessReceipt,
207    },
208}
209
210/// Recipe stage associated with a structured preparation failure.
211#[non_exhaustive]
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum BaselinePreparationStage {
214    /// Constructing a new baseline.
215    Build,
216    /// Restoring captured canisters.
217    RestoreCanisters,
218    /// Resetting state outside the snapshots.
219    ResetNonSnapshotState,
220    /// Driving the restored topology to readiness.
221    DriveToReadiness,
222    /// Validating a newly built baseline.
223    ValidateBuilt,
224    /// Validating a restored baseline.
225    ValidateRestored,
226}
227
228/// Why an invalid or failed slot was reconstructed.
229#[non_exhaustive]
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub enum RebuildReason {
232    /// PocketIC transport was no longer reachable.
233    DeadPocketIcTransport,
234    /// Captured snapshot restoration failed.
235    SnapshotRestoreFailure,
236    /// Non-snapshot reset failed.
237    ResetFailure,
238    /// Readiness or quiescence could not be established.
239    ReadinessFailure,
240    /// Required and achieved reset domains did not match.
241    ResetCoverageMismatch,
242    /// Final invariant validation failed.
243    InvariantValidationFailure,
244    /// A caller explicitly invalidated its lease.
245    ExplicitLeaseInvalidation,
246    /// A lease was dropped while its thread was unwinding.
247    UnwindWhileLeased,
248    /// Recipe-specific structured reason.
249    RecipeClassified { code: String },
250}
251
252/// Recipe decision for a failed restored-slot preparation stage.
253#[non_exhaustive]
254#[derive(Clone, Debug, Eq, PartialEq)]
255pub enum FailureDisposition {
256    /// Return the failure without rebuilding during this acquisition.
257    Fatal,
258    /// Invalidate and rebuild the slot once.
259    Rebuild(RebuildReason),
260}
261
262/// Timings for one baseline-pool acquisition.
263#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
264pub struct BaselinePoolTimings {
265    wait: Duration,
266    build: Option<Duration>,
267    restore: Option<Duration>,
268    reset: Option<Duration>,
269    readiness: Option<Duration>,
270    validation: Option<Duration>,
271    stale_teardown: Option<Duration>,
272    total: Duration,
273}
274
275/// Whether a baseline-pool lease was built, restored, or rebuilt.
276#[non_exhaustive]
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub enum BaselinePoolOutcome {
279    /// An empty slot was constructed and validated.
280    Built {
281        /// Diagnostic slot index.
282        slot: usize,
283        /// Acquisition phase timings.
284        timings: BaselinePoolTimings,
285    },
286    /// An existing slot was restored, reset, and validated.
287    Restored {
288        /// Diagnostic slot index.
289        slot: usize,
290        /// Acquisition phase timings.
291        timings: BaselinePoolTimings,
292    },
293    /// An invalid or failed slot was reconstructed and validated.
294    Rebuilt {
295        /// Diagnostic slot index.
296        slot: usize,
297        /// Reason the previous slot could not be reused.
298        reason: RebuildReason,
299        /// Acquisition phase timings.
300        timings: BaselinePoolTimings,
301    },
302}
303
304/// One failed recipe or contract stage while preparing a baseline slot.
305#[non_exhaustive]
306#[derive(Debug)]
307pub enum BaselinePoolPreparationError<E> {
308    /// Caller-owned recipe logic returned an error.
309    Recipe {
310        /// Failed lifecycle stage.
311        stage: BaselinePreparationStage,
312        /// Caller-owned structured source error.
313        source: E,
314    },
315    /// Typed reset or recipe evidence violated the pool contract.
316    Contract(BaselinePoolContractError),
317}
318
319/// Failure to acquire a validated baseline-pool lease.
320#[non_exhaustive]
321#[derive(Debug)]
322pub enum BaselinePoolError<E> {
323    /// Initial construction or a non-rebuilt preparation failed.
324    Preparation {
325        /// Recipe or contract failure that stopped acquisition.
326        error: BaselinePoolPreparationError<E>,
327        /// Phase timings recorded before acquisition failed.
328        timings: Box<BaselinePoolTimings>,
329    },
330    /// Reused-slot preparation failed and its one rebuild attempt also failed.
331    RecoveryFailed {
332        /// Original restore/reset/readiness/validation failure.
333        original: Box<BaselinePoolPreparationError<E>>,
334        /// Failure while rebuilding or validating the replacement.
335        rebuild: Box<BaselinePoolPreparationError<E>>,
336        /// Combined timings for preparation, stale teardown, and rebuilding.
337        timings: Box<BaselinePoolTimings>,
338    },
339}
340
341/// Complete caller-owned lifecycle recipe for one pooled PocketIC baseline.
342pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
343    /// Metadata retained beside every baseline owned by this recipe.
344    type Metadata: Send + 'static;
345    /// Structured caller error shared by recipe lifecycle stages.
346    type Error: std::error::Error + Send + Sync + 'static;
347
348    /// Stable caller-owned recipe identity.
349    fn id(&self) -> &FixtureRecipeId;
350
351    /// Reset guarantees required before an existing slot may be reused.
352    fn reset_requirements(&self) -> &ResetRequirements;
353
354    /// Construct and capture one complete baseline.
355    fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
356
357    /// Restore every captured canister and report the cycle policy applied.
358    fn restore_canisters(
359        &self,
360        baseline: &CachedPocketIcBaseline<Self::Metadata>,
361    ) -> Result<CanisterRestoreReceipt, Self::Error>;
362
363    /// Reset state not covered by canister snapshots.
364    fn reset_non_snapshot_state(
365        &self,
366        baseline: &CachedPocketIcBaseline<Self::Metadata>,
367    ) -> Result<ResetReceipt, Self::Error>;
368
369    /// Drive the topology to the recipe's readiness boundary.
370    fn drive_to_readiness(
371        &self,
372        baseline: &CachedPocketIcBaseline<Self::Metadata>,
373    ) -> Result<ReadinessReceipt, Self::Error>;
374
375    /// Validate the same baseline invariants after build and restore.
376    fn validate(
377        &self,
378        baseline: &CachedPocketIcBaseline<Self::Metadata>,
379        preparation: &PreparedBaseline,
380    ) -> Result<ValidationReceipt, Self::Error>;
381
382    /// Classify a restored-slot recipe failure as fatal or rebuildable.
383    fn classify_failure(
384        &self,
385        stage: BaselinePreparationStage,
386        _error: &Self::Error,
387    ) -> FailureDisposition {
388        FailureDisposition::Rebuild(stage.default_rebuild_reason())
389    }
390}
391
392/// Caller-owned runtime-capacity pool of independently restorable PocketIC baselines.
393///
394/// One pool structurally owns one [`PocketIcBaselineRecipe`]. A warm
395/// acquisition restores the complete captured canister set, applies the
396/// recipe's non-snapshot reset, reaches its readiness boundary, checks typed
397/// reset coverage, and validates final invariants before exposing a lease.
398/// Each capacity slot owns an independent PocketIC instance.
399///
400/// Snapshot reuse is not a complete PocketIC rollback. The recipe must account
401/// for time, extra canisters, pending messages, subnet state, cycles, and
402/// external resources when those domains matter to its tests.
403pub struct CachedPocketIcBaselinePool<R>
404where
405    R: PocketIcBaselineRecipe,
406{
407    recipe: R,
408    slots: BoundedSlotPool<BaselineSlot<R::Metadata>>,
409}
410
411struct BaselineSlot<M> {
412    baseline: CachedPocketIcBaseline<M>,
413    invalidation_reason: Option<RebuildReason>,
414}
415
416/// Exclusive lease of one validated pooled PocketIC baseline.
417pub struct CachedPocketIcBaselinePoolGuard<'a, R>
418where
419    R: PocketIcBaselineRecipe,
420{
421    slot: BoundedSlotLease<'a, BaselineSlot<R::Metadata>>,
422}
423
424impl FixtureRecipeId {
425    /// Construct a nonempty caller-owned stable recipe identity.
426    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
427        let identity = identity.into();
428        if identity.trim().is_empty() {
429            return Err(BaselinePoolContractError::EmptyRecipeIdentity);
430        }
431        Ok(Self(identity))
432    }
433
434    /// Borrow the recipe identity.
435    #[must_use]
436    pub fn as_str(&self) -> &str {
437        &self.0
438    }
439}
440
441impl ResetRequirement {
442    /// Domain governed by this requirement.
443    #[must_use]
444    pub const fn domain(&self) -> ResetDomainKind {
445        match self {
446            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
447            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
448            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
449            Self::SubnetState(_) => ResetDomainKind::SubnetState,
450            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
451        }
452    }
453}
454
455impl ResetAchievement {
456    /// Domain governed by this achievement.
457    #[must_use]
458    pub const fn domain(&self) -> ResetDomainKind {
459        match self {
460            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
461            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
462            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
463            Self::SubnetState(_) => ResetDomainKind::SubnetState,
464            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
465        }
466    }
467
468    fn satisfies(&self, requirement: &ResetRequirement) -> bool {
469        match (requirement, self) {
470            (ResetRequirement::PocketIcTime(left), Self::PocketIcTime(right)) => left == right,
471            (ResetRequirement::ExtraCanisters(left), Self::ExtraCanisters(right)) => left == right,
472            (ResetRequirement::PendingMessages(left), Self::PendingMessages(right))
473            | (ResetRequirement::SubnetState(left), Self::SubnetState(right))
474            | (ResetRequirement::ExternalResources(left), Self::ExternalResources(right)) => {
475                left == right
476            }
477            _ => false,
478        }
479    }
480}
481
482impl ResetRequirements {
483    /// Construct a duplicate-checked reset requirement set.
484    ///
485    /// Every recipe restores its complete snapshot set. The required cycle
486    /// policy is explicit; `requirements` describe only non-snapshot domains.
487    pub fn try_new<I>(
488        cycle_policy: CycleResetPolicy,
489        requirements: I,
490    ) -> Result<Self, BaselinePoolContractError>
491    where
492        I: IntoIterator<Item = ResetRequirement>,
493    {
494        let mut domains = BTreeMap::new();
495        for requirement in requirements {
496            let domain = requirement.domain();
497            if domains.insert(domain, requirement).is_some() {
498                return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
499            }
500        }
501        Ok(Self {
502            cycle_policy,
503            domains,
504        })
505    }
506
507    /// Cycle policy required of the canister restore receipt.
508    #[must_use]
509    pub const fn cycle_policy(&self) -> CycleResetPolicy {
510        self.cycle_policy
511    }
512
513    /// Read the requirement for one domain.
514    #[must_use]
515    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetRequirement> {
516        self.domains.get(&domain)
517    }
518
519    /// Iterate over requirements in deterministic domain order.
520    pub fn iter(&self) -> impl Iterator<Item = &ResetRequirement> {
521        self.domains.values()
522    }
523
524    fn verify(
525        &self,
526        restore: &CanisterRestoreReceipt,
527        receipt: &ResetReceipt,
528    ) -> Result<(), BaselinePoolContractError> {
529        if restore.cycle_policy != self.cycle_policy {
530            return Err(BaselinePoolContractError::CyclePolicyMismatch {
531                required: self.cycle_policy,
532                achieved: restore.cycle_policy,
533            });
534        }
535        for (domain, requirement) in &self.domains {
536            let Some(achievement) = receipt.0.get(domain) else {
537                return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
538            };
539            if !achievement.satisfies(requirement) {
540                return Err(BaselinePoolContractError::ResetPolicyMismatch {
541                    requirement: requirement.clone(),
542                    achievement: achievement.clone(),
543                });
544            }
545        }
546        Ok(())
547    }
548}
549
550impl ResetReceipt {
551    /// Construct a duplicate-checked non-snapshot reset achievement set.
552    ///
553    /// Snapshot restoration and cycle policy are verified separately through
554    /// [`CanisterRestoreReceipt`].
555    pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
556    where
557        I: IntoIterator<Item = ResetAchievement>,
558    {
559        let mut domains = BTreeMap::new();
560        for achievement in achievements {
561            let domain = achievement.domain();
562            if domains.insert(domain, achievement).is_some() {
563                return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
564            }
565        }
566        Ok(Self(domains))
567    }
568
569    /// Create an empty receipt for recipes with no non-snapshot reset achievements.
570    #[must_use]
571    pub const fn empty() -> Self {
572        Self(BTreeMap::new())
573    }
574
575    /// Read the achievement for one domain.
576    #[must_use]
577    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetAchievement> {
578        self.0.get(&domain)
579    }
580
581    /// Iterate over achievements in deterministic domain order.
582    pub fn iter(&self) -> impl Iterator<Item = &ResetAchievement> {
583        self.0.values()
584    }
585}
586
587impl CanisterRestoreReceipt {
588    /// Construct a deterministic, duplicate-checked canister restore receipt.
589    pub fn try_new<I>(
590        canister_ids: I,
591        cycle_policy: CycleResetPolicy,
592    ) -> Result<Self, BaselinePoolContractError>
593    where
594        I: IntoIterator<Item = Principal>,
595    {
596        let mut unique = BTreeSet::new();
597        for canister_id in canister_ids {
598            if !unique.insert(canister_id) {
599                return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
600            }
601        }
602        if unique.is_empty() {
603            return Err(BaselinePoolContractError::EmptyCanisterSet);
604        }
605        Ok(Self {
606            canister_ids: unique.into_iter().collect(),
607            cycle_policy,
608        })
609    }
610
611    /// Construct a restore receipt for the exact canister set captured by a baseline.
612    ///
613    /// This is the preferred constructor after successfully calling
614    /// [`CachedPocketIcBaseline::restore`] or
615    /// [`CachedPocketIcBaseline::restore_with_funding`]. Deriving the set from
616    /// the baseline avoids duplicating canister ids in recipe metadata solely
617    /// to satisfy the pool contract.
618    pub fn try_from_baseline<M>(
619        baseline: &CachedPocketIcBaseline<M>,
620        cycle_policy: CycleResetPolicy,
621    ) -> Result<Self, BaselinePoolContractError> {
622        if baseline.snapshot_count() == 0 {
623            return Err(BaselinePoolContractError::EmptyCanisterSet);
624        }
625        // Capture owns duplicate validation and deterministic ordering. The
626        // immutable baseline can supply that checked set without rebuilding it.
627        Ok(Self {
628            canister_ids: baseline.snapshot_canister_ids().collect(),
629            cycle_policy,
630        })
631    }
632
633    /// Restored canister ids in deterministic order.
634    #[must_use]
635    pub fn canister_ids(&self) -> &[Principal] {
636        &self.canister_ids
637    }
638
639    /// Cycle policy applied while restoring canisters.
640    #[must_use]
641    pub const fn cycle_policy(&self) -> CycleResetPolicy {
642        self.cycle_policy
643    }
644}
645
646impl ReadinessReceipt {
647    /// Construct a nonempty caller-owned readiness identity.
648    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
649        Ok(Self {
650            identity: nonempty_receipt_identity("readiness", identity.into())?,
651        })
652    }
653
654    /// Borrow the readiness identity.
655    #[must_use]
656    pub fn identity(&self) -> &str {
657        &self.identity
658    }
659}
660
661impl ValidationReceipt {
662    /// Construct final validation evidence for one recipe.
663    pub fn try_new(
664        recipe_id: FixtureRecipeId,
665        invariant_identity: impl Into<String>,
666    ) -> Result<Self, BaselinePoolContractError> {
667        Ok(Self {
668            recipe_id,
669            invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
670        })
671    }
672
673    /// Recipe identity validated by this receipt.
674    #[must_use]
675    pub const fn recipe_id(&self) -> &FixtureRecipeId {
676        &self.recipe_id
677    }
678
679    /// Borrow the caller-owned invariant identity.
680    #[must_use]
681    pub fn invariant_identity(&self) -> &str {
682        &self.invariant_identity
683    }
684}
685
686impl BaselinePreparationStage {
687    /// Default rebuild reason used by [`PocketIcBaselineRecipe::classify_failure`].
688    ///
689    /// Recipes that override classification can use this for their fallback
690    /// after handling a more specific error such as dead PocketIC transport.
691    #[must_use]
692    pub fn default_rebuild_reason(self) -> RebuildReason {
693        match self {
694            Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
695            Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
696            Self::DriveToReadiness => RebuildReason::ReadinessFailure,
697            Self::ValidateRestored | Self::ValidateBuilt => {
698                RebuildReason::InvariantValidationFailure
699            }
700            Self::Build => RebuildReason::RecipeClassified {
701                code: "build".to_owned(),
702            },
703        }
704    }
705}
706
707impl BaselinePoolTimings {
708    /// Time spent waiting for a capacity slot.
709    #[must_use]
710    pub const fn wait(self) -> Duration {
711        self.wait
712    }
713
714    /// Time spent constructing a new baseline.
715    #[must_use]
716    pub const fn build(self) -> Option<Duration> {
717        self.build
718    }
719
720    /// Time spent restoring captured canisters.
721    #[must_use]
722    pub const fn restore(self) -> Option<Duration> {
723        self.restore
724    }
725
726    /// Time spent resetting non-snapshot state.
727    #[must_use]
728    pub const fn reset(self) -> Option<Duration> {
729        self.reset
730    }
731
732    /// Time spent driving the topology to readiness.
733    #[must_use]
734    pub const fn readiness(self) -> Option<Duration> {
735        self.readiness
736    }
737
738    /// Time spent validating final baseline invariants.
739    #[must_use]
740    pub const fn validation(self) -> Option<Duration> {
741        self.validation
742    }
743
744    /// Time spent dropping an invalid baseline before rebuilding.
745    #[must_use]
746    pub const fn stale_teardown(self) -> Option<Duration> {
747        self.stale_teardown
748    }
749
750    /// Complete acquisition duration.
751    #[must_use]
752    pub const fn total(self) -> Duration {
753        self.total
754    }
755}
756
757impl BaselinePoolOutcome {
758    /// Diagnostic slot index used by this acquisition.
759    #[must_use]
760    pub const fn slot(&self) -> usize {
761        match self {
762            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
763                *slot
764            }
765        }
766    }
767
768    /// Acquisition phase timings.
769    #[must_use]
770    pub const fn timings(&self) -> BaselinePoolTimings {
771        match self {
772            Self::Built { timings, .. }
773            | Self::Restored { timings, .. }
774            | Self::Rebuilt { timings, .. } => *timings,
775        }
776    }
777}
778
779impl std::fmt::Display for BaselinePoolTimings {
780    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
781        write!(
782            formatter,
783            "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
784            self.total,
785            self.wait,
786            self.build,
787            self.restore,
788            self.reset,
789            self.readiness,
790            self.validation,
791            self.stale_teardown,
792        )
793    }
794}
795
796impl std::fmt::Display for BaselinePoolOutcome {
797    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
798        match self {
799            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
800            Self::Restored { slot, timings } => {
801                write!(formatter, "restored slot={slot} {timings}")
802            }
803            Self::Rebuilt {
804                slot,
805                reason,
806                timings,
807            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
808        }
809    }
810}
811
812impl<E> BaselinePoolError<E> {
813    /// Timings recorded before this acquisition failed.
814    #[must_use]
815    pub const fn timings(&self) -> BaselinePoolTimings {
816        match self {
817            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
818        }
819    }
820}
821
822impl<R> CachedPocketIcBaselinePool<R>
823where
824    R: PocketIcBaselineRecipe,
825{
826    /// Create a runtime-capacity pool that structurally owns one recipe.
827    #[must_use]
828    pub fn new(capacity: NonZeroUsize, recipe: R) -> Self {
829        Self {
830            recipe,
831            slots: BoundedSlotPool::new(capacity),
832        }
833    }
834
835    /// Borrow the caller-owned identity of this pool's only recipe.
836    #[must_use]
837    pub fn recipe_id(&self) -> &FixtureRecipeId {
838        self.recipe.id()
839    }
840
841    /// Maximum number of simultaneously leased PocketIC baselines.
842    #[must_use]
843    pub fn capacity(&self) -> NonZeroUsize {
844        self.slots.capacity()
845    }
846
847    /// Acquire one fully built or restored and validated baseline lease.
848    ///
849    /// A rebuildable warm-preparation failure discards the stale slot and
850    /// performs at most one build attempt. Recipe and caller panics are never
851    /// converted into cache misses; the lease is invalidated while the panic
852    /// continues unwinding.
853    ///
854    /// # Errors
855    ///
856    /// Returns a stage-specific recipe or contract error. If warm preparation
857    /// and its one recovery build both fail, the error preserves both causes.
858    pub fn acquire(
859        &self,
860    ) -> Result<
861        (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
862        BaselinePoolError<R::Error>,
863    > {
864        let total_started = Instant::now();
865        let mut slot = self.slots.acquire();
866        let mut timings = BaselinePoolTimings {
867            wait: slot.wait(),
868            ..BaselinePoolTimings::default()
869        };
870        let slot_index = slot.slot_index();
871
872        if slot.is_reusable() {
873            match self.prepare_reused(&slot, &mut timings) {
874                Ok(()) => {
875                    timings.total = total_started.elapsed();
876                    return Ok((
877                        CachedPocketIcBaselinePoolGuard { slot },
878                        BaselinePoolOutcome::Restored {
879                            slot: slot_index,
880                            timings,
881                        },
882                    ));
883                }
884                Err(original) => {
885                    let disposition = self.failure_disposition(&original);
886                    match disposition {
887                        FailureDisposition::Fatal => {
888                            // A failed restore may have partially changed the
889                            // instance. Discard it, but do not reinterpret a
890                            // caller-declared fatal error as a rebuild reason.
891                            Self::discard_stale_slot(&mut slot, &mut timings);
892                            timings.total = total_started.elapsed();
893                            return Err(BaselinePoolError::Preparation {
894                                error: original,
895                                timings: Box::new(timings),
896                            });
897                        }
898                        FailureDisposition::Rebuild(reason) => {
899                            Self::discard_stale_slot(&mut slot, &mut timings);
900                            if let Err(rebuild) = self.build_slot(&mut slot, &mut timings) {
901                                timings.total = total_started.elapsed();
902                                return Err(BaselinePoolError::RecoveryFailed {
903                                    original: Box::new(original),
904                                    rebuild: Box::new(rebuild),
905                                    timings: Box::new(timings),
906                                });
907                            }
908                            timings.total = total_started.elapsed();
909                            return Ok((
910                                CachedPocketIcBaselinePoolGuard { slot },
911                                BaselinePoolOutcome::Rebuilt {
912                                    slot: slot_index,
913                                    reason,
914                                    timings,
915                                },
916                            ));
917                        }
918                    }
919                }
920            }
921        }
922
923        let rebuild_reason = if slot.invalidated_by_unwind() {
924            Some(RebuildReason::UnwindWhileLeased)
925        } else {
926            slot.get()
927                .and_then(|slot| slot.invalidation_reason.clone())
928                .or_else(|| {
929                    slot.is_populated()
930                        .then_some(RebuildReason::ExplicitLeaseInvalidation)
931                })
932        };
933        if slot.is_populated() {
934            Self::discard_stale_slot(&mut slot, &mut timings);
935        }
936        if let Err(error) = self.build_slot(&mut slot, &mut timings) {
937            timings.total = total_started.elapsed();
938            return Err(BaselinePoolError::Preparation {
939                error,
940                timings: Box::new(timings),
941            });
942        }
943        timings.total = total_started.elapsed();
944
945        let outcome = rebuild_reason.map_or_else(
946            || BaselinePoolOutcome::Built {
947                slot: slot_index,
948                timings,
949            },
950            |reason| BaselinePoolOutcome::Rebuilt {
951                slot: slot_index,
952                reason,
953                timings,
954            },
955        );
956        Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
957    }
958
959    fn prepare_reused(
960        &self,
961        slot: &BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
962        timings: &mut BaselinePoolTimings,
963    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
964        let baseline = &slot
965            .get()
966            .expect("reusable baseline slot must be populated")
967            .baseline;
968
969        let started = Instant::now();
970        let restore = self.recipe.restore_canisters(baseline);
971        add_timing(&mut timings.restore, started.elapsed());
972        let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
973            stage: BaselinePreparationStage::RestoreCanisters,
974            source,
975        })?;
976        if !baseline
977            .snapshot_canister_ids()
978            .eq(canisters.canister_ids().iter().copied())
979        {
980            return Err(BaselinePoolPreparationError::Contract(
981                BaselinePoolContractError::RestoreCanisterSetMismatch {
982                    expected: baseline.snapshot_canister_ids().collect(),
983                    actual: canisters.canister_ids().to_vec(),
984                },
985            ));
986        }
987
988        let started = Instant::now();
989        let reset_result = self.recipe.reset_non_snapshot_state(baseline);
990        add_timing(&mut timings.reset, started.elapsed());
991        let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
992            stage: BaselinePreparationStage::ResetNonSnapshotState,
993            source,
994        })?;
995
996        let started = Instant::now();
997        let readiness_result = self.recipe.drive_to_readiness(baseline);
998        add_timing(&mut timings.readiness, started.elapsed());
999        let readiness =
1000            readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
1001                stage: BaselinePreparationStage::DriveToReadiness,
1002                source,
1003            })?;
1004        self.recipe
1005            .reset_requirements()
1006            .verify(&canisters, &reset)
1007            .map_err(BaselinePoolPreparationError::Contract)?;
1008
1009        let preparation = PreparedBaseline::Restored {
1010            canisters,
1011            reset,
1012            readiness,
1013        };
1014        self.validate_baseline(
1015            baseline,
1016            &preparation,
1017            BaselinePreparationStage::ValidateRestored,
1018            timings,
1019        )?;
1020        Ok(())
1021    }
1022
1023    fn build_slot(
1024        &self,
1025        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1026        timings: &mut BaselinePoolTimings,
1027    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1028        let started = Instant::now();
1029        let build = self.recipe.build();
1030        add_timing(&mut timings.build, started.elapsed());
1031        let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
1032            stage: BaselinePreparationStage::Build,
1033            source,
1034        })?;
1035
1036        if let Err(error) = self.validate_baseline(
1037            &baseline,
1038            &PreparedBaseline::Built,
1039            BaselinePreparationStage::ValidateBuilt,
1040            timings,
1041        ) {
1042            drop_baseline_safely(baseline);
1043            return Err(error);
1044        }
1045        let replaced = slot.replace(BaselineSlot {
1046            baseline,
1047            invalidation_reason: None,
1048        });
1049        debug_assert!(replaced.is_none());
1050        Ok(())
1051    }
1052
1053    fn validate_baseline(
1054        &self,
1055        baseline: &CachedPocketIcBaseline<R::Metadata>,
1056        preparation: &PreparedBaseline,
1057        stage: BaselinePreparationStage,
1058        timings: &mut BaselinePoolTimings,
1059    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1060        let started = Instant::now();
1061        let validation = self.recipe.validate(baseline, preparation);
1062        add_timing(&mut timings.validation, started.elapsed());
1063        let receipt =
1064            validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1065        if receipt.recipe_id() != self.recipe.id() {
1066            return Err(BaselinePoolPreparationError::Contract(
1067                BaselinePoolContractError::RecipeIdentityMismatch {
1068                    expected: self.recipe.id().clone(),
1069                    actual: receipt.recipe_id().clone(),
1070                },
1071            ));
1072        }
1073        Ok(())
1074    }
1075
1076    fn failure_disposition(
1077        &self,
1078        error: &BaselinePoolPreparationError<R::Error>,
1079    ) -> FailureDisposition {
1080        match error {
1081            BaselinePoolPreparationError::Recipe { stage, source } => {
1082                self.recipe.classify_failure(*stage, source)
1083            }
1084            BaselinePoolPreparationError::Contract(
1085                BaselinePoolContractError::RecipeIdentityMismatch { .. },
1086            ) => FailureDisposition::Fatal,
1087            BaselinePoolPreparationError::Contract(_) => {
1088                FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1089            }
1090        }
1091    }
1092
1093    fn discard_stale_slot(
1094        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1095        timings: &mut BaselinePoolTimings,
1096    ) {
1097        let started = Instant::now();
1098        if let Some(stale) = slot.take() {
1099            drop_baseline_safely(stale.baseline);
1100        }
1101        timings.stale_teardown = Some(started.elapsed());
1102    }
1103}
1104
1105impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1106where
1107    R: PocketIcBaselineRecipe,
1108{
1109    /// Diagnostic slot index held by this lease.
1110    #[must_use]
1111    pub const fn slot(&self) -> usize {
1112        self.slot.slot_index()
1113    }
1114
1115    /// Mark this slot non-reusable with a structured rebuild reason.
1116    pub fn invalidate(&mut self, reason: RebuildReason) {
1117        if let Some(slot) = self.slot.get_mut() {
1118            slot.invalidation_reason = Some(reason);
1119        }
1120        self.slot.invalidate();
1121    }
1122}
1123
1124impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1125where
1126    R: PocketIcBaselineRecipe,
1127{
1128    type Target = CachedPocketIcBaseline<R::Metadata>;
1129
1130    fn deref(&self) -> &Self::Target {
1131        &self
1132            .slot
1133            .get()
1134            .expect("leased baseline pool slot must be populated")
1135            .baseline
1136    }
1137}
1138
1139fn nonempty_receipt_identity(
1140    receipt: &'static str,
1141    identity: String,
1142) -> Result<String, BaselinePoolContractError> {
1143    if identity.trim().is_empty() {
1144        return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1145    }
1146    Ok(identity)
1147}
1148
1149const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1150    *total = saturating_add_optional_duration(*total, Some(elapsed));
1151}
1152
1153fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1154    match error {
1155        BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1156        BaselinePoolPreparationError::Contract(
1157            BaselinePoolContractError::MissingResetDomain { .. }
1158            | BaselinePoolContractError::ResetPolicyMismatch { .. }
1159            | BaselinePoolContractError::CyclePolicyMismatch { .. }
1160            | BaselinePoolContractError::DuplicateResetDomain { .. }
1161            | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1162        ) => RebuildReason::ResetCoverageMismatch,
1163        BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1164    }
1165}
1166
1167fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1168    let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1169}
1170
1171impl std::fmt::Display for FixtureRecipeId {
1172    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1173        formatter.write_str(&self.0)
1174    }
1175}
1176
1177impl std::fmt::Display for BaselinePreparationStage {
1178    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1179        formatter.write_str(match self {
1180            Self::Build => "build",
1181            Self::RestoreCanisters => "canister restore",
1182            Self::ResetNonSnapshotState => "non-snapshot reset",
1183            Self::DriveToReadiness => "readiness",
1184            Self::ValidateBuilt => "built-baseline validation",
1185            Self::ValidateRestored => "restored-baseline validation",
1186        })
1187    }
1188}
1189
1190impl std::fmt::Display for BaselinePoolContractError {
1191    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192        match self {
1193            Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1194            Self::EmptyReceiptIdentity { receipt } => {
1195                write!(formatter, "{receipt} receipt identity is empty")
1196            }
1197            Self::DuplicateResetDomain { domain } => {
1198                write!(
1199                    formatter,
1200                    "reset domain {domain:?} was reported more than once"
1201                )
1202            }
1203            Self::DuplicateCanisterId { canister_id } => {
1204                write!(formatter, "restore receipt repeats canister {canister_id}")
1205            }
1206            Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1207            Self::CyclePolicyMismatch { required, achieved } => write!(
1208                formatter,
1209                "restore cycle policy {achieved:?} does not satisfy {required:?}",
1210            ),
1211            Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1212                formatter,
1213                "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1214            ),
1215            Self::MissingResetDomain { domain } => {
1216                write!(
1217                    formatter,
1218                    "required reset domain {domain:?} was not achieved"
1219                )
1220            }
1221            Self::ResetPolicyMismatch {
1222                requirement,
1223                achievement,
1224            } => write!(
1225                formatter,
1226                "reset achievement {achievement:?} does not satisfy {requirement:?}",
1227            ),
1228            Self::RecipeIdentityMismatch { expected, actual } => write!(
1229                formatter,
1230                "validation receipt used recipe `{actual}` instead of `{expected}`",
1231            ),
1232        }
1233    }
1234}
1235
1236impl std::error::Error for BaselinePoolContractError {}
1237
1238impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1239where
1240    E: std::fmt::Display,
1241{
1242    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1243        match self {
1244            Self::Recipe { stage, source } => {
1245                write!(formatter, "baseline {stage} failed: {source}")
1246            }
1247            Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1248        }
1249    }
1250}
1251
1252impl<E> std::error::Error for BaselinePoolPreparationError<E>
1253where
1254    E: std::error::Error + 'static,
1255{
1256    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1257        match self {
1258            Self::Recipe { source, .. } => Some(source),
1259            Self::Contract(error) => Some(error),
1260        }
1261    }
1262}
1263
1264impl<E> std::fmt::Display for BaselinePoolError<E>
1265where
1266    E: std::fmt::Display,
1267{
1268    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1269        match self {
1270            Self::Preparation { error, .. } => error.fmt(formatter),
1271            Self::RecoveryFailed {
1272                original, rebuild, ..
1273            } => write!(
1274                formatter,
1275                "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1276            ),
1277        }
1278    }
1279}
1280
1281impl<E> std::error::Error for BaselinePoolError<E>
1282where
1283    E: std::error::Error + 'static,
1284{
1285    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1286        match self {
1287            Self::Preparation { error, .. } => Some(error),
1288            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1289        }
1290    }
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295    use super::{
1296        BaselinePoolContractError, CanisterRestoreReceipt, CycleResetPolicy, FixtureRecipeId,
1297        ResetAchievement, ResetDomainKind, ResetReceipt, ResetRequirement, ResetRequirements,
1298        TimeResetPolicy,
1299    };
1300    use candid::Principal;
1301
1302    #[test]
1303    fn recipe_identity_must_be_nonempty() {
1304        assert!(matches!(
1305            FixtureRecipeId::try_new("  "),
1306            Err(BaselinePoolContractError::EmptyRecipeIdentity)
1307        ));
1308    }
1309
1310    #[test]
1311    fn caller_restore_receipts_validate_and_order_canister_ids() {
1312        let first = Principal::from_slice(&[1]);
1313        let second = Principal::from_slice(&[2]);
1314        let policy = CycleResetPolicy::TopUpTo(123);
1315        let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1316        assert_eq!(receipt.canister_ids(), [first, second]);
1317        assert_eq!(receipt.cycle_policy(), policy);
1318        assert_eq!(
1319            CanisterRestoreReceipt::try_new([], policy),
1320            Err(BaselinePoolContractError::EmptyCanisterSet),
1321        );
1322        assert_eq!(
1323            CanisterRestoreReceipt::try_new([second, first, second], policy),
1324            Err(BaselinePoolContractError::DuplicateCanisterId {
1325                canister_id: second,
1326            }),
1327        );
1328    }
1329
1330    #[test]
1331    fn reset_requirements_reject_duplicate_domains() {
1332        let result = ResetRequirements::try_new(
1333            CycleResetPolicy::PreserveCurrent,
1334            [
1335                ResetRequirement::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1336                ResetRequirement::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1337            ],
1338        );
1339        assert!(matches!(
1340            result,
1341            Err(BaselinePoolContractError::DuplicateResetDomain {
1342                domain: ResetDomainKind::PocketIcTime,
1343            })
1344        ));
1345    }
1346
1347    #[test]
1348    fn required_policy_must_match_achieved_policy() {
1349        let requirements = ResetRequirements::try_new(
1350            CycleResetPolicy::PreserveCurrent,
1351            [ResetRequirement::PocketIcTime(
1352                TimeResetPolicy::PreserveCurrent,
1353            )],
1354        )
1355        .unwrap();
1356        let restore = CanisterRestoreReceipt::try_new(
1357            [Principal::anonymous()],
1358            CycleResetPolicy::PreserveCurrent,
1359        )
1360        .unwrap();
1361        let receipt = ResetReceipt::try_new([ResetAchievement::PocketIcTime(
1362            TimeResetPolicy::RebuildOnMutation,
1363        )])
1364        .unwrap();
1365        assert!(matches!(
1366            requirements.verify(&restore, &receipt),
1367            Err(BaselinePoolContractError::ResetPolicyMismatch { .. })
1368        ));
1369    }
1370
1371    #[test]
1372    fn restore_cycle_policy_must_match_required_policy() {
1373        let requirements =
1374            ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1375        let restore = CanisterRestoreReceipt::try_new(
1376            [Principal::anonymous()],
1377            CycleResetPolicy::PreserveCurrent,
1378        )
1379        .unwrap();
1380        assert!(matches!(
1381            requirements.verify(&restore, &ResetReceipt::empty()),
1382            Err(BaselinePoolContractError::CyclePolicyMismatch {
1383                required: CycleResetPolicy::RestoreExactBaseline,
1384                achieved: CycleResetPolicy::PreserveCurrent,
1385            })
1386        ));
1387    }
1388}