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}