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