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