Skip to main content

phasesmith_workflows/
runtime.rs

1//! Method-independent bounded runtime, cancellation, and structured events.
2
3use std::collections::BTreeSet;
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10/// Stable refinement termination categories shared by every method.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum TerminationReason {
13    /// Convergence criterion was met.
14    Converged,
15    /// Iteration budget was exhausted.
16    MaxIterations,
17    /// No included observations remain.
18    NoObservations,
19    /// Numerical evaluation or solve failed.
20    NumericalFailure,
21    /// Cooperative cancellation was requested.
22    Cancelled,
23    /// Wall-clock budget was exhausted.
24    MaxRuntime,
25    /// Model-evaluation budget was exhausted.
26    MaxEvaluations,
27    /// No improving progress could be made.
28    Stagnated,
29    /// Objective diverged under the method policy.
30    Diverged,
31    /// Consecutive rejected-step budget was exhausted.
32    ///
33    /// Solvers return the last accepted state for this normal bounded stop. It
34    /// is not convergence: staged workflows must opt in before promoting that
35    /// state, and validation policies may require evidence of prior progress.
36    RepeatedRejections,
37}
38
39impl TerminationReason {
40    /// Return the stable scripting/wire label.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::Converged => "converged",
45            Self::MaxIterations => "max_iterations",
46            Self::NoObservations => "no_observations",
47            Self::NumericalFailure => "numerical_failure",
48            Self::Cancelled => "cancelled",
49            Self::MaxRuntime => "max_runtime",
50            Self::MaxEvaluations => "max_evaluations",
51            Self::Stagnated => "stagnated",
52            Self::Diverged => "diverged",
53            Self::RepeatedRejections => "repeated_rejections",
54        }
55    }
56}
57
58/// Stable machine-readable event categories.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum RefinementEventKind {
61    /// Workflow started.
62    Start,
63    /// One candidate state was evaluated.
64    Trial,
65    /// Candidate was accepted.
66    StepAccepted,
67    /// Candidate was rejected.
68    StepRejected,
69    /// Iteration boundary completed.
70    Iteration,
71    /// Accepted-state checkpoint completed.
72    Checkpoint,
73    /// Recoverable warning.
74    Warning,
75    /// Normal bounded termination.
76    Termination,
77    /// Unexpected workflow failure.
78    Failure,
79}
80
81impl RefinementEventKind {
82    /// Return the stable scripting/wire label.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::Start => "start",
87            Self::Trial => "trial",
88            Self::StepAccepted => "step_accepted",
89            Self::StepRejected => "step_rejected",
90            Self::Iteration => "iteration",
91            Self::Checkpoint => "checkpoint",
92            Self::Warning => "warning",
93            Self::Termination => "termination",
94            Self::Failure => "failure",
95        }
96    }
97}
98
99/// Finite JSON-scalar diagnostic value.
100#[derive(Clone, Debug, PartialEq)]
101pub enum DiagnosticValue {
102    /// UTF-8 text.
103    String(String),
104    /// Boolean flag.
105    Bool(bool),
106    /// Signed integer.
107    Integer(i64),
108    /// Unsigned integer.
109    Unsigned(u64),
110    /// Finite floating-point value.
111    Float(f64),
112    /// JSON null.
113    Null,
114}
115
116impl DiagnosticValue {
117    fn validate(&self) -> Result<(), RuntimeError> {
118        if matches!(self, Self::Float(value) if !value.is_finite()) {
119            return Err(RuntimeError::InvalidEvent {
120                message: "floating-point diagnostics must be finite".to_owned(),
121            });
122        }
123        Ok(())
124    }
125}
126
127/// One immutable orchestration-boundary event.
128#[derive(Clone, Debug, PartialEq)]
129pub struct RefinementEvent {
130    kind: RefinementEventKind,
131    stage: String,
132    attempted_iteration: usize,
133    accepted_iterations: usize,
134    evaluations: usize,
135    elapsed_seconds: f64,
136    message: String,
137    diagnostics: Vec<(String, DiagnosticValue)>,
138}
139
140impl RefinementEvent {
141    /// Validate and construct a finite machine-readable event.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`RuntimeError::InvalidEvent`] for invalid labels, counters,
146    /// elapsed time, duplicate/empty diagnostic keys, or non-finite floats.
147    #[allow(clippy::too_many_arguments)]
148    pub fn new(
149        kind: RefinementEventKind,
150        stage: impl Into<String>,
151        attempted_iteration: usize,
152        accepted_iterations: usize,
153        evaluations: usize,
154        elapsed_seconds: f64,
155        message: impl Into<String>,
156        diagnostics: Vec<(String, DiagnosticValue)>,
157    ) -> Result<Self, RuntimeError> {
158        let stage = stage.into();
159        let message = message.into();
160        if stage.trim().is_empty() {
161            return Err(invalid_event("event stage must be non-empty"));
162        }
163        if accepted_iterations > attempted_iteration {
164            return Err(invalid_event(
165                "accepted iterations cannot exceed attempted iterations",
166            ));
167        }
168        if !elapsed_seconds.is_finite() || elapsed_seconds < 0.0 {
169            return Err(invalid_event(
170                "event elapsed time must be non-negative and finite",
171            ));
172        }
173        if message.is_empty() {
174            return Err(invalid_event("event message must be non-empty"));
175        }
176        let mut keys = BTreeSet::new();
177        for (key, value) in &diagnostics {
178            if key.is_empty() {
179                return Err(invalid_event("diagnostic keys must be non-empty"));
180            }
181            if !keys.insert(key.clone()) {
182                return Err(RuntimeError::InvalidEvent {
183                    message: format!("duplicate diagnostic key {key:?}"),
184                });
185            }
186            value.validate()?;
187        }
188        Ok(Self {
189            kind,
190            stage,
191            attempted_iteration,
192            accepted_iterations,
193            evaluations,
194            elapsed_seconds,
195            message,
196            diagnostics,
197        })
198    }
199
200    /// Return the event category.
201    #[must_use]
202    pub const fn kind(&self) -> RefinementEventKind {
203        self.kind
204    }
205
206    /// Borrow the stage label.
207    #[must_use]
208    pub fn stage(&self) -> &str {
209        &self.stage
210    }
211
212    /// Return the attempted iteration counter.
213    #[must_use]
214    pub const fn attempted_iteration(&self) -> usize {
215        self.attempted_iteration
216    }
217
218    /// Return the accepted iteration counter.
219    #[must_use]
220    pub const fn accepted_iterations(&self) -> usize {
221        self.accepted_iterations
222    }
223
224    /// Return the evaluation counter.
225    #[must_use]
226    pub const fn evaluations(&self) -> usize {
227        self.evaluations
228    }
229
230    /// Return elapsed monotonic seconds.
231    #[must_use]
232    pub const fn elapsed_seconds(&self) -> f64 {
233        self.elapsed_seconds
234    }
235
236    /// Borrow the human-readable message.
237    #[must_use]
238    pub fn message(&self) -> &str {
239        &self.message
240    }
241
242    /// Borrow diagnostics in stable insertion order.
243    #[must_use]
244    pub fn diagnostics(&self) -> &[(String, DiagnosticValue)] {
245        &self.diagnostics
246    }
247}
248
249#[derive(Debug, Default)]
250struct CancellationState {
251    requested: AtomicBool,
252    reason: Mutex<Option<String>>,
253}
254
255/// Thread-safe first-request-wins cooperative cancellation token.
256#[derive(Clone, Debug, Default)]
257pub struct CancellationToken {
258    state: Arc<CancellationState>,
259}
260
261impl CancellationToken {
262    /// Request cancellation and return true only for the first request.
263    ///
264    /// The reason is stored before the atomic flag becomes visible, so another
265    /// thread cannot observe cancellation without its first reason.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`CancellationError`] for an empty reason or poisoned state lock.
270    pub fn request(&self, reason: impl Into<String>) -> Result<bool, CancellationError> {
271        let reason = reason.into();
272        if reason.trim().is_empty() {
273            return Err(CancellationError::InvalidReason);
274        }
275        let mut stored = self
276            .state
277            .reason
278            .lock()
279            .map_err(|_| CancellationError::Poisoned)?;
280        if stored.is_some() {
281            return Ok(false);
282        }
283        *stored = Some(reason);
284        self.state.requested.store(true, Ordering::Release);
285        Ok(true)
286    }
287
288    /// Return whether cancellation was requested.
289    #[must_use]
290    pub fn is_requested(&self) -> bool {
291        self.state.requested.load(Ordering::Acquire)
292    }
293
294    /// Return the first cancellation reason.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`CancellationError::Poisoned`] if the internal lock is poisoned.
299    pub fn reason(&self) -> Result<Option<String>, CancellationError> {
300        self.state
301            .reason
302            .lock()
303            .map(|reason| reason.clone())
304            .map_err(|_| CancellationError::Poisoned)
305    }
306}
307
308/// Invalid cancellation token operation.
309#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310pub enum CancellationError {
311    /// Cancellation reason is empty or whitespace-only.
312    InvalidReason,
313    /// Internal reason lock was poisoned by a panicking thread.
314    Poisoned,
315}
316
317impl Display for CancellationError {
318    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
319        match self {
320            Self::InvalidReason => formatter.write_str("cancellation reason must be non-empty"),
321            Self::Poisoned => formatter.write_str("cancellation reason lock is poisoned"),
322        }
323    }
324}
325
326impl Error for CancellationError {}
327
328/// Hard upper bounds independent of convergence criteria.
329#[derive(Clone, Copy, Debug, PartialEq)]
330// Field names intentionally mirror the stable Python and persistence contract.
331#[allow(clippy::struct_field_names)]
332pub struct RefinementLimits {
333    max_iterations: usize,
334    max_evaluations: usize,
335    max_runtime_seconds: Option<f64>,
336    max_consecutive_rejections: usize,
337}
338
339impl RefinementLimits {
340    /// Validate strictly positive counters and optional positive finite time.
341    ///
342    /// # Errors
343    ///
344    /// Returns [`RuntimeError::InvalidLimits`] for zero counters or invalid time.
345    pub fn new(
346        max_iterations: usize,
347        max_evaluations: usize,
348        max_runtime_seconds: Option<f64>,
349        max_consecutive_rejections: usize,
350    ) -> Result<Self, RuntimeError> {
351        if max_iterations == 0 || max_evaluations == 0 || max_consecutive_rejections == 0 {
352            return Err(RuntimeError::InvalidLimits);
353        }
354        if max_runtime_seconds.is_some_and(|seconds| !seconds.is_finite() || seconds <= 0.0) {
355            return Err(RuntimeError::InvalidLimits);
356        }
357        Ok(Self {
358            max_iterations,
359            max_evaluations,
360            max_runtime_seconds,
361            max_consecutive_rejections,
362        })
363    }
364
365    /// Return the attempted-iteration ceiling.
366    #[must_use]
367    pub const fn max_iterations(self) -> usize {
368        self.max_iterations
369    }
370
371    /// Return the model-evaluation ceiling.
372    #[must_use]
373    pub const fn max_evaluations(self) -> usize {
374        self.max_evaluations
375    }
376
377    /// Return the optional elapsed-time ceiling.
378    #[must_use]
379    pub const fn max_runtime_seconds(self) -> Option<f64> {
380        self.max_runtime_seconds
381    }
382
383    /// Return the consecutive-rejection ceiling.
384    #[must_use]
385    pub const fn max_consecutive_rejections(self) -> usize {
386        self.max_consecutive_rejections
387    }
388}
389
390impl Default for RefinementLimits {
391    fn default() -> Self {
392        Self {
393            max_iterations: 100,
394            max_evaluations: 1_000,
395            max_runtime_seconds: None,
396            max_consecutive_rejections: 20,
397        }
398    }
399}
400
401/// Monotonic second source injectable for deterministic tests.
402pub trait RuntimeClock: Send + Sync {
403    /// Return a finite nondecreasing timestamp in seconds.
404    fn now_seconds(&self) -> f64;
405}
406
407/// Process-local monotonic clock backed by [`Instant`].
408#[derive(Debug)]
409pub struct MonotonicClock {
410    origin: Instant,
411}
412
413impl MonotonicClock {
414    /// Start a new monotonic clock origin.
415    #[must_use]
416    pub fn new() -> Self {
417        Self {
418            origin: Instant::now(),
419        }
420    }
421}
422
423impl Default for MonotonicClock {
424    fn default() -> Self {
425        Self::new()
426    }
427}
428
429impl RuntimeClock for MonotonicClock {
430    fn now_seconds(&self) -> f64 {
431        self.origin.elapsed().as_secs_f64()
432    }
433}
434
435/// Synchronous event consumer called only at orchestration boundaries.
436pub trait RefinementEventSink: Send {
437    /// Consume one immutable event or return a host-facing failure message.
438    ///
439    /// # Errors
440    ///
441    /// Returns a message when the host cannot consume the event. The runtime
442    /// isolates the failure and detaches this sink.
443    fn emit(&mut self, event: &RefinementEvent) -> Result<(), String>;
444}
445
446impl<F> RefinementEventSink for F
447where
448    F: FnMut(&RefinementEvent) -> Result<(), String> + Send,
449{
450    fn emit(&mut self, event: &RefinementEvent) -> Result<(), String> {
451        self(event)
452    }
453}
454
455/// Application-owned durable sink for one typed accepted-state checkpoint.
456pub trait CheckpointSink<C>: Send {
457    /// Persist one complete checkpoint or return a host-facing failure message.
458    ///
459    /// # Errors
460    ///
461    /// Returns a message when durable checkpoint delivery fails.
462    fn checkpoint(&mut self, checkpoint: &C) -> Result<(), String>;
463}
464
465impl<C, F> CheckpointSink<C> for F
466where
467    F: FnMut(&C) -> Result<(), String> + Send,
468{
469    fn checkpoint(&mut self, checkpoint: &C) -> Result<(), String> {
470        self(checkpoint)
471    }
472}
473
474/// Normal cooperative/budget termination returned at a safe boundary.
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct RefinementStop {
477    /// Stable termination category.
478    pub reason: TerminationReason,
479    /// Human-readable boundary message.
480    pub message: String,
481}
482
483impl Display for RefinementStop {
484    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
485        formatter.write_str(&self.message)
486    }
487}
488
489impl Error for RefinementStop {}
490
491/// Stateful method-independent refinement boundary guard.
492pub struct RefinementRuntime<C = ()> {
493    limits: RefinementLimits,
494    cancellation: Option<CancellationToken>,
495    event_sink: Option<Box<dyn RefinementEventSink>>,
496    checkpoint_sink: Option<Box<dyn CheckpointSink<C>>>,
497    clock: Arc<dyn RuntimeClock>,
498    started_at: f64,
499    attempted_iteration: usize,
500    accepted_iterations: usize,
501    evaluations: usize,
502    consecutive_rejections: usize,
503    event_sink_error: Option<String>,
504}
505
506impl<C> RefinementRuntime<C> {
507    /// Construct a runtime with a process-local monotonic clock.
508    ///
509    /// # Errors
510    ///
511    /// Returns [`RuntimeError::InvalidClock`] if the clock origin is invalid.
512    pub fn new(
513        limits: RefinementLimits,
514        cancellation: Option<CancellationToken>,
515    ) -> Result<Self, RuntimeError> {
516        Self::with_clock(limits, cancellation, Arc::new(MonotonicClock::new()))
517    }
518
519    /// Construct a runtime with an injectable thread-safe clock.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`RuntimeError::InvalidClock`] if the first timestamp is non-finite.
524    pub fn with_clock(
525        limits: RefinementLimits,
526        cancellation: Option<CancellationToken>,
527        clock: Arc<dyn RuntimeClock>,
528    ) -> Result<Self, RuntimeError> {
529        let started_at = clock.now_seconds();
530        if !started_at.is_finite() {
531            return Err(RuntimeError::InvalidClock);
532        }
533        Ok(Self {
534            limits,
535            cancellation,
536            event_sink: None,
537            checkpoint_sink: None,
538            clock,
539            started_at,
540            attempted_iteration: 0,
541            accepted_iterations: 0,
542            evaluations: 0,
543            consecutive_rejections: 0,
544            event_sink_error: None,
545        })
546    }
547
548    /// Attach a synchronous event sink. Sink failures are isolated and recorded.
549    pub fn set_event_sink(&mut self, sink: impl RefinementEventSink + 'static) {
550        self.event_sink = Some(Box::new(sink));
551        self.event_sink_error = None;
552    }
553
554    /// Attach an application-owned typed checkpoint sink.
555    pub fn set_checkpoint_sink(&mut self, sink: impl CheckpointSink<C> + 'static) {
556        self.checkpoint_sink = Some(Box::new(sink));
557    }
558
559    /// Restore accepted/attempted counters from a validated checkpoint.
560    ///
561    /// Evaluation and rejection budgets restart for the continuation call.
562    ///
563    /// # Errors
564    ///
565    /// Returns [`RuntimeError::InvalidResume`] if work already began or the
566    /// completed count exceeds the iteration budget.
567    pub fn resume_accepted(&mut self, completed_iterations: usize) -> Result<(), RuntimeError> {
568        if self.attempted_iteration != 0
569            || self.accepted_iterations != 0
570            || self.evaluations != 0
571            || completed_iterations > self.limits.max_iterations
572        {
573            return Err(RuntimeError::InvalidResume);
574        }
575        self.attempted_iteration = completed_iterations;
576        self.accepted_iterations = completed_iterations;
577        Ok(())
578    }
579
580    /// Return finite non-negative elapsed seconds.
581    ///
582    /// # Errors
583    ///
584    /// Returns [`RuntimeError::InvalidClock`] for non-finite or backward time.
585    pub fn elapsed_seconds(&self) -> Result<f64, RuntimeError> {
586        let elapsed = self.clock.now_seconds() - self.started_at;
587        if !elapsed.is_finite() || elapsed < 0.0 {
588            return Err(RuntimeError::InvalidClock);
589        }
590        Ok(elapsed)
591    }
592
593    /// Emit one validated event and isolate a failing event sink.
594    ///
595    /// # Errors
596    ///
597    /// Returns [`RuntimeError`] for clock or event validation failures. A sink
598    /// failure does not invalidate numerical state and is stored separately.
599    pub fn emit(
600        &mut self,
601        kind: RefinementEventKind,
602        stage: impl Into<String>,
603        message: impl Into<String>,
604        diagnostics: Vec<(String, DiagnosticValue)>,
605    ) -> Result<RefinementEvent, RuntimeError> {
606        let event = RefinementEvent::new(
607            kind,
608            stage,
609            self.attempted_iteration,
610            self.accepted_iterations,
611            self.evaluations,
612            self.elapsed_seconds()?,
613            message,
614            diagnostics,
615        )?;
616        let sink_result = self.event_sink.as_mut().map(|sink| sink.emit(&event));
617        if let Some(Err(message)) = sink_result {
618            self.event_sink_error = Some(message);
619            self.event_sink = None;
620        }
621        Ok(event)
622    }
623
624    /// Stop at a safe boundary for cancellation, time, or evaluation budget.
625    ///
626    /// # Errors
627    ///
628    /// Returns [`RuntimeError::Stopped`] for a normal bounded stop, or a clock/
629    /// cancellation-state error.
630    pub fn check_boundary(&self) -> Result<(), RuntimeError> {
631        if let Some(token) = &self.cancellation
632            && token.is_requested()
633        {
634            let message = token
635                .reason()
636                .map_err(RuntimeError::Cancellation)?
637                .unwrap_or_else(|| "user requested cancellation".to_owned());
638            return Err(RuntimeError::Stopped(RefinementStop {
639                reason: TerminationReason::Cancelled,
640                message,
641            }));
642        }
643        if let Some(limit) = self.limits.max_runtime_seconds {
644            let elapsed = self.elapsed_seconds()?;
645            if elapsed >= limit {
646                return Err(RuntimeError::Stopped(RefinementStop {
647                    reason: TerminationReason::MaxRuntime,
648                    message: "runtime limit reached".to_owned(),
649                }));
650            }
651        }
652        if self.evaluations >= self.limits.max_evaluations {
653            return Err(RuntimeError::Stopped(RefinementStop {
654                reason: TerminationReason::MaxEvaluations,
655                message: "model-evaluation limit reached".to_owned(),
656            }));
657        }
658        Ok(())
659    }
660
661    /// Begin one strictly increasing attempted iteration.
662    ///
663    /// # Errors
664    ///
665    /// Returns [`RuntimeError`] for order violations or a normal bounded stop.
666    pub fn begin_iteration(&mut self, attempted_iteration: usize) -> Result<(), RuntimeError> {
667        if attempted_iteration <= self.attempted_iteration {
668            return Err(RuntimeError::InvalidIterationOrder);
669        }
670        if attempted_iteration > self.limits.max_iterations {
671            return Err(RuntimeError::Stopped(RefinementStop {
672                reason: TerminationReason::MaxIterations,
673                message: "iteration limit reached".to_owned(),
674            }));
675        }
676        self.attempted_iteration = attempted_iteration;
677        self.check_boundary()
678    }
679
680    /// Reserve one model evaluation after checking every safe-boundary budget.
681    ///
682    /// # Errors
683    ///
684    /// Returns [`RuntimeError`] for a normal bounded stop or invalid clock/token.
685    pub fn begin_evaluation(&mut self) -> Result<(), RuntimeError> {
686        self.check_boundary()?;
687        self.evaluations = self
688            .evaluations
689            .checked_add(1)
690            .ok_or(RuntimeError::CounterOverflow)?;
691        Ok(())
692    }
693
694    /// Accept at most one step in the current attempted iteration.
695    ///
696    /// State becomes accepted before the optional checkpoint sink is called, so
697    /// a sink failure never rolls numerical state backward.
698    ///
699    /// # Errors
700    ///
701    /// Returns [`RuntimeError`] for acceptance-order, counter, checkpoint, event,
702    /// or clock failures.
703    pub fn accept_step(&mut self, checkpoint: Option<&C>) -> Result<(), RuntimeError> {
704        if self.accepted_iterations >= self.attempted_iteration {
705            return Err(RuntimeError::DuplicateAcceptance);
706        }
707        self.accepted_iterations = self
708            .accepted_iterations
709            .checked_add(1)
710            .ok_or(RuntimeError::CounterOverflow)?;
711        self.consecutive_rejections = 0;
712        if let Some(checkpoint) = checkpoint
713            && let Some(sink) = self.checkpoint_sink.as_mut()
714        {
715            sink.checkpoint(checkpoint)
716                .map_err(|message| RuntimeError::CheckpointSink { message })?;
717            self.emit(
718                RefinementEventKind::Checkpoint,
719                "checkpoint",
720                "accepted-state checkpoint completed",
721                Vec::new(),
722            )?;
723        }
724        Ok(())
725    }
726
727    /// Record a rejection and enforce its consecutive budget.
728    ///
729    /// # Errors
730    ///
731    /// Returns [`RuntimeError::Stopped`] when the rejection limit is reached.
732    pub fn reject_step(&mut self) -> Result<(), RuntimeError> {
733        self.consecutive_rejections = self
734            .consecutive_rejections
735            .checked_add(1)
736            .ok_or(RuntimeError::CounterOverflow)?;
737        if self.consecutive_rejections >= self.limits.max_consecutive_rejections {
738            return Err(RuntimeError::Stopped(RefinementStop {
739                reason: TerminationReason::RepeatedRejections,
740                message: "consecutive rejected-step limit reached".to_owned(),
741            }));
742        }
743        Ok(())
744    }
745
746    /// Return the attempted iteration counter.
747    #[must_use]
748    pub const fn attempted_iteration(&self) -> usize {
749        self.attempted_iteration
750    }
751
752    /// Return the accepted iteration counter.
753    #[must_use]
754    pub const fn accepted_iterations(&self) -> usize {
755        self.accepted_iterations
756    }
757
758    /// Return the model-evaluation counter.
759    #[must_use]
760    pub const fn evaluations(&self) -> usize {
761        self.evaluations
762    }
763
764    /// Return consecutive rejected steps since the last acceptance.
765    #[must_use]
766    pub const fn consecutive_rejections(&self) -> usize {
767        self.consecutive_rejections
768    }
769
770    /// Borrow the isolated event-sink error, if any.
771    #[must_use]
772    pub fn event_sink_error(&self) -> Option<&str> {
773        self.event_sink_error.as_deref()
774    }
775
776    /// Return whether an event sink remains attached.
777    #[must_use]
778    pub const fn has_event_sink(&self) -> bool {
779        self.event_sink.is_some()
780    }
781}
782
783/// Invalid runtime configuration, event, control order, or host callback.
784#[derive(Debug)]
785pub enum RuntimeError {
786    /// Limits are not strictly positive/finite.
787    InvalidLimits,
788    /// Clock is non-finite or moved backward.
789    InvalidClock,
790    /// Event record is invalid.
791    InvalidEvent {
792        /// Stable explanation.
793        message: String,
794    },
795    /// Checkpoint counters cannot be restored in the current state.
796    InvalidResume,
797    /// Attempted iterations did not increase strictly.
798    InvalidIterationOrder,
799    /// More than one accepted step was recorded for an attempted iteration.
800    DuplicateAcceptance,
801    /// A runtime counter overflowed.
802    CounterOverflow,
803    /// Cancellation token state failed.
804    Cancellation(CancellationError),
805    /// A normal cancellation or hard-budget stop.
806    Stopped(RefinementStop),
807    /// Application checkpoint sink failed after state acceptance.
808    CheckpointSink {
809        /// Sink-provided failure message.
810        message: String,
811    },
812}
813
814impl Display for RuntimeError {
815    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
816        match self {
817            Self::InvalidLimits => {
818                formatter.write_str("refinement limits must be positive and finite")
819            }
820            Self::InvalidClock => {
821                formatter.write_str("refinement clock must be finite and monotonic")
822            }
823            Self::InvalidEvent { message } | Self::CheckpointSink { message } => {
824                formatter.write_str(message)
825            }
826            Self::InvalidResume => {
827                formatter.write_str("refinement counters cannot be resumed in this state")
828            }
829            Self::InvalidIterationOrder => {
830                formatter.write_str("attempted iterations must increase strictly")
831            }
832            Self::DuplicateAcceptance => {
833                formatter.write_str("at most one step may be accepted per attempted iteration")
834            }
835            Self::CounterOverflow => formatter.write_str("refinement runtime counter overflow"),
836            Self::Cancellation(error) => Display::fmt(error, formatter),
837            Self::Stopped(stop) => Display::fmt(stop, formatter),
838        }
839    }
840}
841
842impl Error for RuntimeError {
843    fn source(&self) -> Option<&(dyn Error + 'static)> {
844        match self {
845            Self::Cancellation(error) => Some(error),
846            Self::Stopped(stop) => Some(stop),
847            _ => None,
848        }
849    }
850}
851
852fn invalid_event(message: &str) -> RuntimeError {
853    RuntimeError::InvalidEvent {
854        message: message.to_owned(),
855    }
856}