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    /// A run is longer than `max_steps`, the guard on the aligner's O(n·m) allocation
35    /// (issue #23). Raised by the engine at `align()` time, not at validation time, because
36    /// it depends on the input, not the params alone.
37    StepsExceedMax { steps: usize, max: usize },
38}
39
40impl fmt::Display for ParamError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::TauOutOfRange(tau) => {
44                write!(f, "tau must be within [0, 1], got {tau}")
45            }
46            Self::ResyncKZero => {
47                write!(
48                    f,
49                    "resync_k must be at least 1 (0 would recover every blip)"
50                )
51            }
52            Self::GapOpenNotPositive(v) => {
53                write!(f, "gap_open must be positive and finite, got {v}")
54            }
55            Self::GapExtNotPositive(v) => {
56                write!(f, "gap_ext must be positive and finite, got {v}")
57            }
58            Self::GapExtExceedsOpen { gap_open, gap_ext } => write!(
59                f,
60                "gap_ext ({gap_ext}) must not exceed gap_open ({gap_open}): extending a gap \
61                 costing more than opening one breaks the affine premise"
62            ),
63            Self::StepsExceedMax { steps, max } => write!(
64                f,
65                "trace has {steps} steps, over the {max}-step alignment guard (memory and \
66                 time grow with steps²); pass --max-steps {steps} to proceed anyway"
67            ),
68        }
69    }
70}
71
72impl std::error::Error for ParamError {}
73
74impl AlignParams {
75    /// Check the aligner's invariants, passing `self` through unchanged when they hold.
76    ///
77    /// # Errors
78    /// The first violated invariant, as a [`ParamError`].
79    pub fn validated(self) -> Result<Self, ParamError> {
80        if !self.gap_open.is_finite() || self.gap_open <= 0.0 {
81            return Err(ParamError::GapOpenNotPositive(self.gap_open));
82        }
83        if !self.gap_ext.is_finite() || self.gap_ext <= 0.0 {
84            return Err(ParamError::GapExtNotPositive(self.gap_ext));
85        }
86        if self.gap_ext > self.gap_open {
87            return Err(ParamError::GapExtExceedsOpen {
88                gap_open: self.gap_open,
89                gap_ext: self.gap_ext,
90            });
91        }
92        Ok(self)
93    }
94}
95
96impl ForkParams {
97    /// Check the fork rule's invariants, passing `self` through unchanged when they hold.
98    ///
99    /// # Errors
100    /// The first violated invariant, as a [`ParamError`].
101    pub fn validated(self) -> Result<Self, ParamError> {
102        if !self.tau.is_finite() || !(0.0..=1.0).contains(&self.tau) {
103            return Err(ParamError::TauOutOfRange(self.tau));
104        }
105        if self.resync_k == 0 {
106            return Err(ParamError::ResyncKZero);
107        }
108        Ok(self)
109    }
110}
111
112impl DiffParams {
113    /// Check every engine invariant at once — the one call a boundary makes before handing
114    /// user-supplied values to [`crate::diff`].
115    ///
116    /// # Errors
117    /// The first violated invariant, as a [`ParamError`].
118    pub fn validated(self) -> Result<Self, ParamError> {
119        self.align.validated()?;
120        self.fork.validated()?;
121        Ok(self)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn dev_calibrated_defaults_are_valid() {
131        assert_eq!(
132            DiffParams::default().validated(),
133            Ok(DiffParams::default()),
134            "the shipped defaults must satisfy their own invariants"
135        );
136    }
137
138    #[test]
139    fn tau_outside_the_cost_scale_is_rejected() {
140        for tau in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
141            let params = ForkParams {
142                tau,
143                ..ForkParams::default()
144            };
145            match params.validated() {
146                Err(ParamError::TauOutOfRange(seen)) => {
147                    assert!(seen.is_nan() == tau.is_nan() || seen == tau)
148                }
149                other => panic!("tau {tau} must be rejected, got {other:?}"),
150            }
151        }
152    }
153
154    #[test]
155    fn resync_k_zero_is_rejected() {
156        let params = ForkParams {
157            resync_k: 0,
158            ..ForkParams::default()
159        };
160        assert_eq!(params.validated(), Err(ParamError::ResyncKZero));
161    }
162
163    #[test]
164    fn non_positive_or_non_finite_gaps_are_rejected() {
165        for gap_open in [0.0, -0.5, f64::NAN] {
166            let params = AlignParams {
167                gap_open,
168                ..AlignParams::default()
169            };
170            assert!(
171                matches!(params.validated(), Err(ParamError::GapOpenNotPositive(_))),
172                "gap_open {gap_open} must be rejected"
173            );
174        }
175        for gap_ext in [0.0, -0.5, f64::INFINITY] {
176            let params = AlignParams {
177                gap_ext,
178                ..AlignParams::default()
179            };
180            assert!(
181                matches!(
182                    params.validated(),
183                    Err(ParamError::GapExtNotPositive(_) | ParamError::GapExtExceedsOpen { .. })
184                ),
185                "gap_ext {gap_ext} must be rejected"
186            );
187        }
188    }
189
190    #[test]
191    fn gap_ext_above_gap_open_breaks_the_affine_premise() {
192        let params = AlignParams {
193            gap_open: 0.3,
194            gap_ext: 0.6,
195            ..AlignParams::default()
196        };
197        assert_eq!(
198            params.validated(),
199            Err(ParamError::GapExtExceedsOpen {
200                gap_open: 0.3,
201                gap_ext: 0.6
202            })
203        );
204    }
205
206    #[test]
207    fn diff_params_compose_both_validations() {
208        let bad_fork = DiffParams {
209            fork: ForkParams {
210                tau: 2.0,
211                ..ForkParams::default()
212            },
213            ..DiffParams::default()
214        };
215        assert!(matches!(
216            bad_fork.validated(),
217            Err(ParamError::TauOutOfRange(_))
218        ));
219
220        let bad_align = DiffParams {
221            align: AlignParams {
222                gap_open: -1.0,
223                ..AlignParams::default()
224            },
225            ..DiffParams::default()
226        };
227        assert!(matches!(
228            bad_align.validated(),
229            Err(ParamError::GapOpenNotPositive(_))
230        ));
231    }
232
233    #[test]
234    fn size_guard_error_names_the_override_flag() {
235        let msg = ParamError::StepsExceedMax {
236            steps: 6000,
237            max: 2000,
238        }
239        .to_string();
240        assert!(msg.contains("6000") && msg.contains("2000"), "got: {msg}");
241        assert!(
242            msg.contains("--max-steps"),
243            "the escape hatch must be discoverable from the refusal itself: {msg}"
244        );
245    }
246
247    #[test]
248    fn error_messages_name_the_offending_value() {
249        let msg = ParamError::TauOutOfRange(1.5).to_string();
250        assert!(msg.contains("1.5"), "got: {msg}");
251        let msg = ParamError::GapExtExceedsOpen {
252            gap_open: 0.3,
253            gap_ext: 0.6,
254        }
255        .to_string();
256        assert!(msg.contains("0.3") && msg.contains("0.6"), "got: {msg}");
257    }
258}