Skip to main content

amberfork_align/
params.rs

1//! Parameter validation — the single place the engine's tunables are checked.
2//!
3//! The params types themselves stay plain data (`AlignParams`, `ForkParams`, [`DiffParams`]):
4//! construction cannot fail and the engine never asserts. Whoever accepts *user-supplied*
5//! values (the CLI boundary today, `amberfork-bench` sweeps later) calls `validated()` once
6//! and maps [`ParamError`] to its own error surface. Decided at the issue #3 review; the
7//! invariants encode what the engine actually assumes:
8//!
9//! - `tau` is a cost threshold on the `[0, 1]` cost-model scale, so it must live there too.
10//! - `resync_k = 0` would declare every blip recovered-from, i.e. no fork can ever exist.
11//! - Non-positive gap penalties would make the aligner prefer gaps over perfect syncs.
12//! - `gap_ext > gap_open` breaks the affine premise (a detour's later steps must be cheaper
13//!   than opening a new gap, or detours shred into scattered mismatches — spike 001).
14
15use crate::diff::DiffParams;
16use crate::fork::ForkParams;
17use crate::nw::AlignParams;
18use std::fmt;
19
20/// Why a parameter set was rejected. One variant per violated invariant, carrying the value
21/// so the message can be honest about what was seen.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum ParamError {
24    /// `tau` must be finite and within `[0, 1]` (the cost-model scale).
25    TauOutOfRange(f64),
26    /// `resync_k` must be at least 1.
27    ResyncKZero,
28    /// `gap_open` must be finite and positive.
29    GapOpenNotPositive(f64),
30    /// `gap_ext` must be finite and positive.
31    GapExtNotPositive(f64),
32    /// `gap_ext` must not exceed `gap_open` (the affine-gap premise).
33    GapExtExceedsOpen { gap_open: f64, gap_ext: f64 },
34}
35
36impl fmt::Display for ParamError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::TauOutOfRange(tau) => {
40                write!(f, "tau must be within [0, 1], got {tau}")
41            }
42            Self::ResyncKZero => {
43                write!(
44                    f,
45                    "resync_k must be at least 1 (0 would recover every blip)"
46                )
47            }
48            Self::GapOpenNotPositive(v) => {
49                write!(f, "gap_open must be positive and finite, got {v}")
50            }
51            Self::GapExtNotPositive(v) => {
52                write!(f, "gap_ext must be positive and finite, got {v}")
53            }
54            Self::GapExtExceedsOpen { gap_open, gap_ext } => write!(
55                f,
56                "gap_ext ({gap_ext}) must not exceed gap_open ({gap_open}): extending a gap \
57                 costing more than opening one breaks the affine premise"
58            ),
59        }
60    }
61}
62
63impl std::error::Error for ParamError {}
64
65impl AlignParams {
66    /// Check the aligner's invariants, passing `self` through unchanged when they hold.
67    ///
68    /// # Errors
69    /// The first violated invariant, as a [`ParamError`].
70    pub fn validated(self) -> Result<Self, ParamError> {
71        if !self.gap_open.is_finite() || self.gap_open <= 0.0 {
72            return Err(ParamError::GapOpenNotPositive(self.gap_open));
73        }
74        if !self.gap_ext.is_finite() || self.gap_ext <= 0.0 {
75            return Err(ParamError::GapExtNotPositive(self.gap_ext));
76        }
77        if self.gap_ext > self.gap_open {
78            return Err(ParamError::GapExtExceedsOpen {
79                gap_open: self.gap_open,
80                gap_ext: self.gap_ext,
81            });
82        }
83        Ok(self)
84    }
85}
86
87impl ForkParams {
88    /// Check the fork rule's invariants, passing `self` through unchanged when they hold.
89    ///
90    /// # Errors
91    /// The first violated invariant, as a [`ParamError`].
92    pub fn validated(self) -> Result<Self, ParamError> {
93        if !self.tau.is_finite() || !(0.0..=1.0).contains(&self.tau) {
94            return Err(ParamError::TauOutOfRange(self.tau));
95        }
96        if self.resync_k == 0 {
97            return Err(ParamError::ResyncKZero);
98        }
99        Ok(self)
100    }
101}
102
103impl DiffParams {
104    /// Check every engine invariant at once — the one call a boundary makes before handing
105    /// user-supplied values to [`crate::diff`].
106    ///
107    /// # Errors
108    /// The first violated invariant, as a [`ParamError`].
109    pub fn validated(self) -> Result<Self, ParamError> {
110        self.align.validated()?;
111        self.fork.validated()?;
112        Ok(self)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn dev_calibrated_defaults_are_valid() {
122        assert_eq!(
123            DiffParams::default().validated(),
124            Ok(DiffParams::default()),
125            "the shipped defaults must satisfy their own invariants"
126        );
127    }
128
129    #[test]
130    fn tau_outside_the_cost_scale_is_rejected() {
131        for tau in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
132            let params = ForkParams {
133                tau,
134                ..ForkParams::default()
135            };
136            match params.validated() {
137                Err(ParamError::TauOutOfRange(seen)) => {
138                    assert!(seen.is_nan() == tau.is_nan() || seen == tau)
139                }
140                other => panic!("tau {tau} must be rejected, got {other:?}"),
141            }
142        }
143    }
144
145    #[test]
146    fn resync_k_zero_is_rejected() {
147        let params = ForkParams {
148            resync_k: 0,
149            ..ForkParams::default()
150        };
151        assert_eq!(params.validated(), Err(ParamError::ResyncKZero));
152    }
153
154    #[test]
155    fn non_positive_or_non_finite_gaps_are_rejected() {
156        for gap_open in [0.0, -0.5, f64::NAN] {
157            let params = AlignParams {
158                gap_open,
159                ..AlignParams::default()
160            };
161            assert!(
162                matches!(params.validated(), Err(ParamError::GapOpenNotPositive(_))),
163                "gap_open {gap_open} must be rejected"
164            );
165        }
166        for gap_ext in [0.0, -0.5, f64::INFINITY] {
167            let params = AlignParams {
168                gap_ext,
169                ..AlignParams::default()
170            };
171            assert!(
172                matches!(
173                    params.validated(),
174                    Err(ParamError::GapExtNotPositive(_) | ParamError::GapExtExceedsOpen { .. })
175                ),
176                "gap_ext {gap_ext} must be rejected"
177            );
178        }
179    }
180
181    #[test]
182    fn gap_ext_above_gap_open_breaks_the_affine_premise() {
183        let params = AlignParams {
184            gap_open: 0.3,
185            gap_ext: 0.6,
186        };
187        assert_eq!(
188            params.validated(),
189            Err(ParamError::GapExtExceedsOpen {
190                gap_open: 0.3,
191                gap_ext: 0.6
192            })
193        );
194    }
195
196    #[test]
197    fn diff_params_compose_both_validations() {
198        let bad_fork = DiffParams {
199            fork: ForkParams {
200                tau: 2.0,
201                ..ForkParams::default()
202            },
203            ..DiffParams::default()
204        };
205        assert!(matches!(
206            bad_fork.validated(),
207            Err(ParamError::TauOutOfRange(_))
208        ));
209
210        let bad_align = DiffParams {
211            align: AlignParams {
212                gap_open: -1.0,
213                ..AlignParams::default()
214            },
215            ..DiffParams::default()
216        };
217        assert!(matches!(
218            bad_align.validated(),
219            Err(ParamError::GapOpenNotPositive(_))
220        ));
221    }
222
223    #[test]
224    fn error_messages_name_the_offending_value() {
225        let msg = ParamError::TauOutOfRange(1.5).to_string();
226        assert!(msg.contains("1.5"), "got: {msg}");
227        let msg = ParamError::GapExtExceedsOpen {
228            gap_open: 0.3,
229            gap_ext: 0.6,
230        }
231        .to_string();
232        assert!(msg.contains("0.3") && msg.contains("0.6"), "got: {msg}");
233    }
234}