Skip to main content

causal_triangulations/cdt/metropolis/
adapter.rs

1#![forbid(unsafe_code)]
2
3//! Adapter boundary between CDT state and `markov-chain-monte-carlo`.
4
5use crate::cdt::action::ActionConfig;
6use crate::cdt::ergodic_moves::{ErgodicsSystem, MoveResult, MoveType, proposal_site_count};
7use crate::errors::{CdtError, CdtResult, MetropolisMoveApplicationFailure};
8use crate::geometry::CdtTriangulation2D;
9use markov_chain_monte_carlo::{
10    Chain, ChainCheckpoint, DelayedProposal, DiscreteProposalRatio, McmcError, Target,
11};
12use rand::Rng;
13use std::error::Error;
14use std::fmt;
15use std::hint::cold_path;
16
17use super::helpers::{action_for, proposed_delta_action, simplex_counts, validate_temperature};
18use super::telemetry::{CdtProposalSiteRejection, ProposalStatistics};
19
20/// Target distribution for CDT: log-probability from the Regge action.
21///
22/// Computes `log_prob = -S / T` where `S` is the discrete Regge action
23/// and `T` is the temperature.
24pub struct CdtTarget {
25    action_config: ActionConfig,
26    temperature: f64,
27}
28
29impl CdtTarget {
30    /// Creates a new CDT target distribution.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`CdtError::InvalidConfiguration`] if the action couplings are
35    /// non-finite, or [`CdtError::InvalidSimulationConfiguration`] if
36    /// `temperature` is not finite and positive.
37    ///
38    /// # Examples
39    ///
40    /// ```
41    /// use causal_triangulations::prelude::simulation::{ActionConfig, CdtTarget};
42    ///
43    /// let _target = CdtTarget::new(ActionConfig::default(), 1.0)?;
44    /// # Ok::<(), causal_triangulations::CdtError>(())
45    /// ```
46    pub fn new(action_config: ActionConfig, temperature: f64) -> CdtResult<Self> {
47        action_config.validate();
48        validate_temperature(temperature)?;
49        Ok(Self {
50            action_config,
51            temperature,
52        })
53    }
54}
55
56impl Target<CdtTriangulation2D> for CdtTarget {
57    fn log_prob(&self, state: &CdtTriangulation2D) -> f64 {
58        let counts = simplex_counts(state);
59        let action =
60            self.action_config
61                .calculate_action(counts.vertices, counts.edges, counts.triangles);
62        -action / self.temperature
63    }
64}
65
66/// Concrete CDT proposal plan selected before committing live state.
67///
68/// A plan records the selected [`MoveType`], the action before and after the
69/// move, and a cloned triangulation containing the proposed mutation. Planning
70/// may mutate that clone to realize a concrete local site, but it never mutates
71/// the live simulation state. The sampler scores this plan with the
72/// Metropolis-Hastings forward/reverse proposal-site ratio, then commits the
73/// cloned state only if the Metropolis step accepts it. This planning boundary
74/// is the natural hook for future adaptive or self-learning proposal selection.
75///
76/// # Examples
77///
78/// ```
79/// use causal_triangulations::prelude::simulation::{
80///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal, MoveType,
81/// };
82/// use rand::{SeedableRng, rngs::StdRng};
83/// use std::assert_matches;
84///
85/// # fn main() -> CdtResult<()> {
86/// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
87/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7);
88/// let mut rng = StdRng::seed_from_u64(11);
89///
90/// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else {
91///     return Ok(());
92/// };
93/// assert_matches!(
94///     plan.move_type(),
95///     MoveType::Move22 | MoveType::Move13Add | MoveType::Move31Remove | MoveType::EdgeFlip
96/// );
97/// assert!(plan.action_before().is_finite());
98/// if let (Some(delta), Some(action_after)) = (plan.delta_action(), plan.action_after()) {
99///     approx::assert_relative_eq!(
100///         action_after,
101///         plan.action_before() + delta,
102///         epsilon = 1e-12
103///     );
104/// }
105/// # Ok(())
106/// # }
107/// ```
108#[derive(Debug, Clone)]
109pub struct CdtProposalPlan {
110    pub(crate) move_type: MoveType,
111    pub(crate) action_before: f64,
112    pub(crate) action_after: Option<f64>,
113    pub(crate) delta_action: Option<f64>,
114    pub(crate) forward_site_count: usize,
115    /// Reverse proposal-site denominator for the realized proposed state.
116    ///
117    /// This is the number of valid inverse-move local sites used to normalize
118    /// the reverse proposal probability in the Metropolis-Hastings site-count
119    /// ratio. It is a count of sites and must be greater than zero for a
120    /// realized proposal to have finite reverse weight.
121    pub(crate) reverse_site_count: usize,
122    pub(crate) proposed_state: CdtTriangulation2D,
123}
124
125impl CdtProposalPlan {
126    /// Returns the proposed move type.
127    ///
128    /// # Examples
129    ///
130    /// ```no_run
131    /// use causal_triangulations::prelude::simulation::{
132    ///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal, MoveType,
133    /// };
134    /// use rand::{SeedableRng, rngs::StdRng};
135    /// use std::assert_matches;
136    ///
137    /// # fn main() -> CdtResult<()> {
138    /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
139    /// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7);
140    /// let mut rng = StdRng::seed_from_u64(11);
141    /// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else {
142    ///     return Ok(());
143    /// };
144    /// assert_matches!(
145    ///     plan.move_type(),
146    ///     MoveType::Move22 | MoveType::Move13Add | MoveType::Move31Remove | MoveType::EdgeFlip
147    /// );
148    /// # Ok(())
149    /// # }
150    /// ```
151    #[must_use]
152    pub const fn move_type(&self) -> MoveType {
153        self.move_type
154    }
155
156    /// Returns the current action used to score this proposal.
157    ///
158    /// # Examples
159    ///
160    /// ```no_run
161    /// use causal_triangulations::prelude::simulation::{
162    ///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal,
163    /// };
164    /// use rand::{SeedableRng, rngs::StdRng};
165    ///
166    /// # fn main() -> CdtResult<()> {
167    /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
168    /// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7);
169    /// let mut rng = StdRng::seed_from_u64(11);
170    /// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else {
171    ///     return Ok(());
172    /// };
173    /// assert!(plan.action_before().is_finite());
174    /// # Ok(())
175    /// # }
176    /// ```
177    #[must_use]
178    pub const fn action_before(&self) -> f64 {
179        self.action_before
180    }
181
182    /// Returns the action of the concrete proposed state, if one was realized.
183    ///
184    /// A value of `None` means the selected move could not be scored or
185    /// realized, so [`DelayedProposal::proposed_log_prob`] treats the plan as
186    /// impossible.
187    ///
188    /// # Examples
189    ///
190    /// ```no_run
191    /// use causal_triangulations::prelude::simulation::{
192    ///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal,
193    /// };
194    /// use rand::{SeedableRng, rngs::StdRng};
195    ///
196    /// # fn main() -> CdtResult<()> {
197    /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
198    /// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7);
199    /// let mut rng = StdRng::seed_from_u64(11);
200    /// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else {
201    ///     return Ok(());
202    /// };
203    /// assert_eq!(plan.action_after().is_some(), plan.delta_action().is_some());
204    /// # Ok(())
205    /// # }
206    /// ```
207    #[must_use]
208    pub const fn action_after(&self) -> Option<f64> {
209        self.action_after
210    }
211
212    /// Returns the concrete proposal action change, if it can be evaluated.
213    ///
214    /// # Examples
215    ///
216    /// ```no_run
217    /// use causal_triangulations::prelude::simulation::{
218    ///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal,
219    /// };
220    /// use rand::{SeedableRng, rngs::StdRng};
221    ///
222    /// # fn main() -> CdtResult<()> {
223    /// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
224    /// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7);
225    /// let mut rng = StdRng::seed_from_u64(11);
226    /// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else {
227    ///     return Ok(());
228    /// };
229    /// if let (Some(delta), Some(action_after)) = (plan.delta_action(), plan.action_after()) {
230    ///     approx::assert_relative_eq!(
231    ///         action_after,
232    ///         plan.action_before() + delta,
233    ///         epsilon = 1e-12
234    ///     );
235    /// }
236    /// # Ok(())
237    /// # }
238    /// ```
239    #[must_use]
240    pub const fn delta_action(&self) -> Option<f64> {
241        self.delta_action
242    }
243}
244
245/// Telemetry returned by planned CDT proposal steps.
246///
247/// The sampler receives this compact record after a plan has been scored. It is
248/// intended for diagnostics and measurement backends that need to report which
249/// move family was proposed without exposing the private plan fields.
250///
251/// # Examples
252///
253/// ```
254/// use causal_triangulations::prelude::simulation::{
255///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal,
256/// };
257/// use rand::{SeedableRng, rngs::StdRng};
258///
259/// # fn main() -> CdtResult<()> {
260/// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
261/// let mut proposal = CdtProposal::with_seed(ActionConfig::default(), 7);
262/// let mut rng = StdRng::seed_from_u64(11);
263/// let Some(plan) = proposal.propose_plan(&tri, &mut rng)? else {
264///     return Ok(());
265/// };
266///
267/// let info = proposal.info(&plan);
268/// assert_eq!(info.move_type, plan.move_type());
269/// assert_eq!(info.delta_action.is_some(), plan.delta_action().is_some());
270/// if let (Some(info_delta), Some(plan_delta)) = (info.delta_action, plan.delta_action()) {
271///     approx::assert_relative_eq!(info_delta, plan_delta, epsilon = 1e-12);
272/// }
273/// # Ok(())
274/// # }
275/// ```
276#[derive(Debug, Clone, Copy, PartialEq)]
277pub struct CdtProposalInfo {
278    /// Move type selected for the proposal.
279    pub move_type: MoveType,
280    /// Action before the proposal.
281    pub action_before: f64,
282    /// Action after the proposal if the count-level delta is valid.
283    pub action_after: Option<f64>,
284    /// Proposed action change.
285    pub delta_action: Option<f64>,
286}
287
288/// Error reported by planned CDT proposal planning or commit.
289///
290/// No-site outcomes are ordinary proposal absence and are reported from
291/// [`DelayedProposal::propose_plan`] as `Ok(None)`, matching the upstream
292/// plan-before-commit contract. `ApplicationFailed` represents a hard backend or
293/// invariant failure while constructing or committing a concrete proposal, and
294/// preserves the typed [`CdtError`] that caused the failed application.
295///
296/// # Examples
297///
298/// ```
299/// use causal_triangulations::prelude::errors::{BackendMutationOperation, CdtError};
300/// use causal_triangulations::prelude::simulation::{CdtProposalError, MoveType};
301///
302/// let err = CdtProposalError::ApplicationFailed {
303///     move_type: MoveType::Move13Add,
304///     attempt: 2,
305///     source: CdtError::BackendMutationFailed {
306///         operation: BackendMutationOperation::SetVertexDataByKey,
307///         target: "vertex VertexKey(7)".to_string(),
308///         detail: "backend rejected mutation".to_string(),
309///     },
310/// };
311/// assert!(err.to_string().contains("Move13Add"));
312/// ```
313#[derive(Debug, Clone, PartialEq)]
314#[non_exhaustive]
315pub enum CdtProposalError {
316    /// Constructing or applying a concrete proposal hit a hard backend or invariant failure.
317    ApplicationFailed {
318        /// Move type whose concrete application failed.
319        move_type: MoveType,
320        /// Local-site attempt that hit the hard failure.
321        attempt: usize,
322        /// Typed lower-level failure observed while committing the accepted move.
323        source: CdtError,
324    },
325}
326
327impl fmt::Display for CdtProposalError {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        match self {
330            Self::ApplicationFailed {
331                move_type,
332                attempt,
333                source,
334            } => write!(
335                f,
336                "failed to apply {move_type:?} on attempt {attempt}: {source}"
337            ),
338        }
339    }
340}
341
342impl Error for CdtProposalError {
343    fn source(&self) -> Option<&(dyn Error + 'static)> {
344        match self {
345            Self::ApplicationFailed { source, .. } => Some(source),
346        }
347    }
348}
349
350impl From<CdtProposalError> for CdtError {
351    fn from(error: CdtProposalError) -> Self {
352        match error {
353            CdtProposalError::ApplicationFailed {
354                move_type,
355                attempt,
356                source,
357            } => Self::ProposalApplicationFailed {
358                move_type,
359                attempt,
360                source: MetropolisMoveApplicationFailure::from(source),
361            },
362        }
363    }
364}
365
366/// Planned CDT proposal distribution.
367///
368/// This adapter exposes CDT's clone-plan-score-commit move ordering through the
369/// upstream [`DelayedProposal`] API. It plans a concrete local move on a cloned
370/// triangulation, scores the proposed state with the same [`ActionConfig`] as
371/// the matching [`CdtTarget`] or [`MetropolisAlgorithm`](super::MetropolisAlgorithm), corrects for
372/// forward/reverse proposal-site counts, and commits the clone only after
373/// acceptance. Future self-learning Metropolis-Hastings proposal policies
374/// should plug into this planner boundary instead of mutating the live chain
375/// before acceptance.
376///
377/// # Examples
378///
379/// ```
380/// use causal_triangulations::prelude::simulation::{
381///     ActionConfig, CdtProposal, CdtResult, CdtTriangulation, DelayedProposal,
382/// };
383/// use rand::{SeedableRng, rngs::StdRng};
384///
385/// # fn main() -> CdtResult<()> {
386/// let tri = CdtTriangulation::from_cdt_strip(4, 3)?;
387/// let mut proposal = CdtProposal::new(ActionConfig::default());
388/// let mut rng = StdRng::seed_from_u64(7);
389///
390/// let plan = proposal.propose_plan(&tri, &mut rng)?;
391/// if let Some(plan) = plan {
392///     assert!(plan.action_before().is_finite());
393/// }
394/// # Ok(())
395/// # }
396/// ```
397pub struct CdtProposal {
398    action_config: ActionConfig,
399    moves: ErgodicsSystem,
400    last_step_info: Option<CdtProposalInfo>,
401    last_no_plan_info: Option<CdtProposalInfo>,
402    last_proposal_stats: ProposalStatistics,
403}
404
405impl CdtProposal {
406    /// Creates a new unseeded CDT proposal planner.
407    ///
408    /// Proposed-state scoring is delegated to the target passed to
409    /// [`DelayedProposal::proposed_log_prob`].
410    ///
411    /// # Examples
412    ///
413    /// ```
414    /// use causal_triangulations::prelude::simulation::{ActionConfig, CdtProposal};
415    ///
416    /// let _proposal = CdtProposal::new(ActionConfig::default());
417    /// ```
418    #[must_use]
419    pub fn new(action_config: ActionConfig) -> Self {
420        action_config.validate();
421        Self {
422            action_config,
423            moves: ErgodicsSystem::new(),
424            last_step_info: None,
425            last_no_plan_info: None,
426            last_proposal_stats: ProposalStatistics::new(),
427        }
428    }
429
430    /// Creates a seeded CDT proposal planner.
431    ///
432    /// The seed controls the internal move-family selector. The `rng` passed to
433    /// [`DelayedProposal::propose_plan`] is still accepted for compatibility
434    /// with generic MCMC drivers.
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// use causal_triangulations::prelude::simulation::{ActionConfig, CdtProposal};
440    ///
441    /// let _proposal = CdtProposal::with_seed(ActionConfig::default(), 42);
442    /// ```
443    #[must_use]
444    pub fn with_seed(action_config: ActionConfig, seed: u64) -> Self {
445        action_config.validate();
446        Self {
447            action_config,
448            moves: ErgodicsSystem::with_seed(seed),
449            last_step_info: None,
450            last_no_plan_info: None,
451            last_proposal_stats: ProposalStatistics::new(),
452        }
453    }
454
455    /// Rebuilds a proposal planner from checkpointed ergodic-move state.
456    ///
457    /// Resumed simulations use this to hand the upstream sampler the exact
458    /// proposal RNG stream stored in a CDT checkpoint while resetting
459    /// per-step telemetry caches.
460    pub(crate) fn from_ergodics(action_config: ActionConfig, moves: ErgodicsSystem) -> Self {
461        action_config.validate();
462        Self {
463            action_config,
464            moves,
465            last_step_info: None,
466            last_no_plan_info: None,
467            last_proposal_stats: ProposalStatistics::new(),
468        }
469    }
470
471    /// Extracts the ergodic-move state after upstream sampler execution.
472    ///
473    /// The caller writes the returned state back into the CDT checkpoint/run
474    /// state so later chunks continue from the same proposal RNG stream.
475    pub(crate) fn into_ergodics(self) -> ErgodicsSystem {
476        self.moves
477    }
478
479    /// Returns telemetry recorded by the most recent planned proposal attempt.
480    ///
481    /// [`MetropolisAlgorithm`](super::runner::MetropolisAlgorithm) merges this
482    /// snapshot into CDT-owned proposal counters after the upstream sampler
483    /// reports the planned-step outcome.
484    pub(crate) const fn last_proposal_stats(&self) -> &ProposalStatistics {
485        &self.last_proposal_stats
486    }
487
488    /// Returns proposal metadata recorded by the most recent planned sampler step.
489    ///
490    /// CDT step history and move statistics depend on this metadata even for
491    /// self-loop proposals, so missing values are translated into an explicit
492    /// telemetry error during runner bookkeeping.
493    pub(crate) const fn last_step_info(&self) -> Option<CdtProposalInfo> {
494        self.last_step_info
495    }
496}
497
498impl DelayedProposal<CdtTriangulation2D> for CdtProposal {
499    type Plan = CdtProposalPlan;
500    type Info = CdtProposalInfo;
501    type Error = CdtProposalError;
502
503    fn propose_plan<R: Rng + ?Sized>(
504        &mut self,
505        state: &CdtTriangulation2D,
506        _rng: &mut R,
507    ) -> Result<Option<Self::Plan>, Self::Error> {
508        let move_type = self.moves.select_random_move();
509        let action_before = action_for(&self.action_config, state);
510        let no_plan_info = CdtProposalInfo {
511            move_type,
512            action_before,
513            action_after: None,
514            delta_action: None,
515        };
516        let mut proposal_stats = ProposalStatistics::new();
517        let plan = match propose_concrete_plan(
518            state,
519            &mut self.moves,
520            &mut proposal_stats,
521            &self.action_config,
522            move_type,
523            action_before,
524        ) {
525            Ok(Some(plan)) => plan,
526            Ok(None) => {
527                self.last_step_info = Some(no_plan_info);
528                self.last_no_plan_info = Some(no_plan_info);
529                self.last_proposal_stats = proposal_stats;
530                cold_path();
531                return Ok(None);
532            }
533            Err(err) => {
534                self.last_step_info = Some(no_plan_info);
535                self.last_no_plan_info = None;
536                proposal_stats.record_hard_failure();
537                self.last_proposal_stats = proposal_stats;
538                cold_path();
539                return Err(CdtProposalError::ApplicationFailed {
540                    move_type,
541                    attempt: err.attempt,
542                    source: err.source,
543                });
544            }
545        };
546        self.last_step_info = Some(CdtProposalInfo {
547            move_type: plan.move_type,
548            action_before: plan.action_before,
549            action_after: plan.action_after,
550            delta_action: plan.delta_action,
551        });
552        self.last_no_plan_info = None;
553        self.last_proposal_stats = proposal_stats;
554        Ok(Some(plan))
555    }
556
557    fn no_plan_info(&mut self) -> Option<Self::Info> {
558        self.last_no_plan_info.take()
559    }
560
561    fn proposed_log_prob<T: Target<CdtTriangulation2D>>(
562        &self,
563        _state: &CdtTriangulation2D,
564        plan: &Self::Plan,
565        target: &T,
566    ) -> Result<f64, Self::Error> {
567        Ok(plan
568            .action_after
569            .map_or(f64::NEG_INFINITY, |_| target.log_prob(&plan.proposed_state)))
570    }
571
572    fn log_q_ratio(
573        &self,
574        state: &CdtTriangulation2D,
575        plan: &Self::Plan,
576    ) -> Result<f64, Self::Error> {
577        Ok(concrete_log_q_ratio(state, plan))
578    }
579
580    fn info(&self, plan: &Self::Plan) -> Self::Info {
581        CdtProposalInfo {
582            move_type: plan.move_type,
583            action_before: plan.action_before,
584            action_after: plan.action_after,
585            delta_action: plan.delta_action,
586        }
587    }
588
589    fn commit<R: Rng + ?Sized>(
590        &mut self,
591        state: &mut CdtTriangulation2D,
592        plan: Self::Plan,
593        _rng: &mut R,
594    ) -> Result<(), Self::Error> {
595        *state = plan.proposed_state;
596        Ok(())
597    }
598}
599
600#[derive(Debug, Clone, PartialEq)]
601pub(crate) struct MoveApplicationError {
602    pub(crate) attempt: usize,
603    pub(crate) source: CdtError,
604}
605
606/// Plans one concrete CDT proposal without mutating the live chain state.
607///
608/// The helper samples a local site, applies it to a cloned triangulation, and
609/// records the forward and reverse proposal-site counts needed for the
610/// Hastings correction. Ordinary no-site, causality, geometry, and recoverable
611/// backend rejections return `Ok(None)` so the public planned-proposal API can
612/// expose them as self-loop proposals.
613///
614/// # Errors
615///
616/// Returns [`MoveApplicationError`] only for hard backend or invariant failures
617/// that must surface through [`CdtProposalError::ApplicationFailed`].
618pub(crate) fn propose_concrete_plan(
619    state: &CdtTriangulation2D,
620    moves: &mut ErgodicsSystem,
621    proposal_stats: &mut ProposalStatistics,
622    action_config: &ActionConfig,
623    move_type: MoveType,
624    action_before: f64,
625) -> Result<Option<CdtProposalPlan>, MoveApplicationError> {
626    if proposed_delta_action(action_config, simplex_counts(state), move_type).is_none() {
627        proposal_stats.record_move_family(0);
628        proposal_stats.record_no_site();
629        return Ok(None);
630    }
631    let selection = moves.select_proposal_site(state, move_type);
632    let forward_site_count = selection.site_count;
633    proposal_stats.record_move_family(forward_site_count);
634    let Some(site) = selection.site else {
635        proposal_stats.record_no_site();
636        return Ok(None);
637    };
638
639    let mut proposed_state = state.clone();
640    let move_stats_before = moves.stats().clone();
641    let result = moves.apply_proposal_site(&mut proposed_state, move_type, site);
642    moves.replace_stats(move_stats_before);
643    let action_after = match result {
644        MoveResult::Success => action_for(action_config, &proposed_state),
645        MoveResult::HardFailure(err) => {
646            return Err(MoveApplicationError {
647                attempt: 1,
648                source: err,
649            });
650        }
651        MoveResult::CausalityViolation => {
652            proposal_stats.record_site_rejection(&CdtProposalSiteRejection::CausalityViolation);
653            return Ok(None);
654        }
655        MoveResult::GeometricViolation => {
656            proposal_stats.record_site_rejection(&CdtProposalSiteRejection::GeometricViolation);
657            return Ok(None);
658        }
659        MoveResult::Rejected(err) => {
660            proposal_stats.record_site_rejection(&CdtProposalSiteRejection::Kernel(err));
661            return Ok(None);
662        }
663    };
664    let delta_action = action_after - action_before;
665    let reverse_site_count = proposal_site_count(&proposed_state, reverse_move_type(move_type));
666
667    Ok(Some(CdtProposalPlan {
668        move_type,
669        action_before,
670        action_after: Some(action_after),
671        delta_action: Some(delta_action),
672        forward_site_count,
673        reverse_site_count,
674        proposed_state,
675    }))
676}
677
678/// Computes the Hastings proposal-density correction for a concrete plan.
679///
680/// The ratio uses the instantaneous forward and reverse local-site counts from
681/// the selected move family. Zero denominators represent impossible proposal
682/// weights and are scored as negative infinity rather than panicking.
683pub(crate) fn concrete_log_q_ratio(_state: &CdtTriangulation2D, plan: &CdtProposalPlan) -> f64 {
684    DiscreteProposalRatio::from_counts(plan.forward_site_count, plan.reverse_site_count)
685        .map_or(f64::NEG_INFINITY, DiscreteProposalRatio::log_q_ratio)
686}
687
688/// Restores a checkpointed triangulation through the upstream MCMC chain type.
689///
690/// The conversion reuses `markov-chain-monte-carlo` target validation before
691/// CDT resume logic rebuilds domain-specific run state.
692///
693/// # Errors
694///
695/// Returns an upstream checkpoint error when the checkpointed state is
696/// incompatible with the supplied [`CdtTarget`].
697pub(crate) fn restore_checkpoint_state(
698    checkpoint: ChainCheckpoint<CdtTriangulation2D>,
699    target: &CdtTarget,
700) -> Result<CdtTriangulation2D, McmcError> {
701    Chain::from_checkpoint(checkpoint, target).map(Chain::into_state)
702}
703
704/// Returns the inverse CDT move family used for reverse proposal accounting.
705const fn reverse_move_type(move_type: MoveType) -> MoveType {
706    match move_type {
707        MoveType::Move22 => MoveType::Move22,
708        MoveType::Move13Add => MoveType::Move31Remove,
709        MoveType::Move31Remove => MoveType::Move13Add,
710        MoveType::EdgeFlip => MoveType::EdgeFlip,
711    }
712}