Skip to main content

hpr_validate/
case.rs

1//! Case files: what to fly, and which metrics to compare against which reference.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8/// One validation case, read from a TOML file under `validation/cases/`.
9///
10/// Every metric it reports has to name a tolerance ([Loft lesson L79][l79]) or say in writing why
11/// it is not scored, and the reference it compares against has to carry provenance ([L77][l77]).
12/// The harness checks both before it flies anything.
13///
14/// [l77]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l77
15/// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Case {
19    /// The case's id, which is also its file name without the extension.
20    pub id: String,
21    /// What the case is for, in one line.
22    pub title: String,
23    /// What is flown and how ([`Flight`]).
24    pub flight: Flight,
25    /// The reference to compare against, relative to the repository root.
26    pub reference: PathBuf,
27    /// Which case of that reference file, when it holds several.
28    #[serde(default)]
29    pub reference_case: Option<String>,
30    /// The metrics to compare, each with its tolerance.
31    pub metrics: BTreeMap<String, Metric>,
32    /// A limit of hpr's that this case is known to reach, in writing. The case still runs, and
33    /// its metrics keep the tolerances they will be held to once the limit is lifted, but nothing
34    /// is scored: the report shows the case as a gap, with this reason and hpr's own refusal.
35    ///
36    /// The one gap the harness accepts is hpr's refusal of a Mach number past its models' range,
37    /// which ends at Mach 5 for the normal force and the drag buildup alike (until
38    /// [M1.8b1][m1-8b1] the buildup stopped at Mach 1, and Prometheus on its own drag was such a
39    /// gap). It is checked, not trusted: the reference must itself reach that Mach number, and hpr
40    /// must refuse the flight with exactly that error. A gap that starts flying fails the run, so
41    /// it cannot stay excused after it is fixed ([Loft lesson L85][l85]). No committed case
42    /// declares one.
43    ///
44    /// [m1-8b1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8b1
45    /// [l85]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l85
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub known_gap: Option<String>,
48}
49
50/// What a case flies.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields, rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum Flight {
55    /// A descent from a state the reference declares, under the devices the reference declares:
56    /// the recovery comparison of [M1.7a][m1-7a] (parachutes, and the descent under them), run
57    /// through the harness.
58    ///
59    /// Everything about the flight comes from the reference's own case (the site, the wind, the
60    /// devices, the state at the first deployment), which is the point: the oracle's inputs are
61    /// the case's, not hpr's output ([Loft lesson L75][l75]).
62    ///
63    /// [m1-7a]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-7a
64    /// [l75]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l75
65    RecoveryDescent {
66        /// The design to fly, by file name under `validation/designs/`.
67        design: String,
68        /// Its configuration id.
69        configuration: String,
70    },
71    /// A flight from the pad to the ground, in either mode of [M2.1][m2-1] ([`DragMode`]). In
72    /// same-drag mode hpr flies the `C_D0(M)` table the reference declares, through
73    /// [`hpr_sim::Simulation::with_drag_table`], so a difference is in the equations of motion,
74    /// the environment or the motor, not in the drag. In predicted mode it flies the design's own
75    /// aerodynamics against a reference that flew the example's own drag.
76    ///
77    /// The site, the wind, the rail, the drag table and its reference area, and the recovery
78    /// devices all come from the reference's own record of what the oracle flew
79    /// ([Loft lesson L75][l75]). The harness checks the rest rather than trusting it: the design
80    /// must be the one the reference names, with the dry mass and reference area it recorded.
81    ///
82    /// [m2-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1
83    /// [l75]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l75
84    WholeFlight {
85        /// The design to fly, by file name under `validation/designs/`.
86        design: String,
87        /// Its configuration id.
88        configuration: String,
89        /// Whose drag hpr flies: the reference's declared table (the default), or its own.
90        #[serde(default, skip_serializing_if = "DragMode::is_same_drag")]
91        mode: DragMode,
92    },
93}
94
95/// Whose drag a whole flight flies, the two modes of [M2.1][m2-1].
96///
97/// [m2-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum DragMode {
102    /// hpr flies the `C_D0(M)` table the reference declares, so a difference is in the equations
103    /// of motion, the environment or the motor. Its metrics are gated.
104    #[default]
105    SameDrag,
106    /// hpr flies its own drag against a reference in which RocketPy flies the example's own drag,
107    /// so a difference is mostly the two drags ([M2.1c2][m2-1c2]). hpr's normal force is its own
108    /// in both modes; only the zero-lift drag differs. Its metrics are
109    /// held to a target and reported, never gated ([`crate::Verdict::WithinTarget`]).
110    ///
111    /// [m2-1c2]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1c2
112    Predicted,
113}
114
115impl DragMode {
116    /// Whether this is the default, same-drag mode.
117    #[must_use]
118    pub fn is_same_drag(&self) -> bool {
119        *self == Self::SameDrag
120    }
121}
122
123impl Flight {
124    /// The design it flies, by file name under `validation/designs/`.
125    #[must_use]
126    pub fn design(&self) -> &str {
127        match self {
128            Self::RecoveryDescent { design, .. } | Self::WholeFlight { design, .. } => design,
129        }
130    }
131}
132
133/// One metric of a case: how far hpr may be from the reference, or why it is not scored.
134///
135/// In a case file that is a table of one or both bounds:
136///
137/// ```toml
138/// [metrics.descent_time_s]
139/// relative = 0.03
140///
141/// [metrics.drift_north_m]
142/// relative = 0.03
143/// absolute = 0.002    # this component passes through zero, so a fraction alone means nothing
144/// ```
145///
146/// A metric whose table sets neither is refused, because that is the "ungated metric" of
147/// [Loft lesson L79][l79] by another name.
148///
149/// The one alternative to a gate is to say, in the case file, that the metric is not scored and
150/// why:
151///
152/// ```toml
153/// [metrics.drift_north_m]
154/// not_scored = "hpr and RocketPy differ by 28x on 0.5 mm and the cause is not established (#27)"
155/// ```
156///
157/// Such a metric is still measured and still printed, with both numbers, the difference and the
158/// reason: it is a gap on the face of the report, not a quiet omission and not a pass. Loft
159/// excused its two largest misses as "no single target" ([Loft lesson L82][l82]), so the reason
160/// has to be written down, and
161/// `hpr_validate::tests::the_metrics_that_are_not_scored_are_these_and_no_others` pins the whole
162/// set: a new excuse has to be argued in a test whose name says what it is.
163///
164/// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
165/// [l82]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l82
166#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct Metric {
169    /// How far hpr may be from the reference, as a fraction of it (`0.03` for 3%).
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub relative: Option<f64>,
172    /// How far hpr may be from the reference, in the metric's own unit.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub absolute: Option<f64>,
175    /// Why the metric is measured and reported but not scored. Mutually exclusive with a bound.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub not_scored: Option<String>,
178}
179
180impl Metric {
181    /// A relative bound alone.
182    #[must_use]
183    pub fn relative(relative: f64) -> Self {
184        Self {
185            relative: Some(relative),
186            ..Self::default()
187        }
188    }
189
190    /// The bounds it holds hpr to.
191    #[must_use]
192    pub fn tolerance(&self) -> Tolerance {
193        Tolerance {
194            relative: self.relative,
195            absolute: self.absolute,
196        }
197    }
198
199    /// The reason it is not scored, if it says one.
200    #[must_use]
201    pub fn reason(&self) -> Option<&str> {
202        self.not_scored
203            .as_deref()
204            .map(str::trim)
205            .filter(|reason| !reason.is_empty())
206    }
207
208    /// Checks that the metric is either gated or declared, and that its bounds are real numbers
209    /// that bound something.
210    ///
211    /// # Errors
212    ///
213    /// A sentence naming what is wrong, for [`crate::run::ValidateError::Case`].
214    pub fn check(&self, case: &str, name: &str) -> Result<(), String> {
215        let gated = self.tolerance().is_set();
216        match (gated, self.reason()) {
217            (false, None) => {
218                // Loft lesson L79: a tolerance that bounds nothing gates nothing. A metric is
219                // either held to a number or declared, in writing, not to be.
220                Err(format!(
221                    "case {case}: {name} has a tolerance that bounds nothing, and no written \
222                     reason for not scoring it"
223                ))
224            }
225            (true, Some(_)) => Err(format!(
226                "case {case}: {name} is both gated and declared not scored; it is one or the other"
227            )),
228            (false, Some(_)) => Ok(()),
229            (true, None) => {
230                for (label, bound) in [("relative", self.relative), ("absolute", self.absolute)] {
231                    if let Some(bound) = bound
232                        && !(bound.is_finite() && bound > 0.0)
233                    {
234                        // An infinite or negative bound is a gate that cannot fail.
235                        return Err(format!(
236                            "case {case}: {name}'s {label} bound is {bound}, which bounds nothing"
237                        ));
238                    }
239                }
240                Ok(())
241            }
242        }
243    }
244}
245
246/// How far a measured value may be from its reference: a fraction of it, an absolute difference,
247/// or whichever of the two is larger.
248#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Tolerance {
251    /// A fraction of the reference value, as `0.03` for 3%.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub relative: Option<f64>,
254    /// An absolute difference, in the metric's own unit.
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub absolute: Option<f64>,
257}
258
259impl Tolerance {
260    /// A relative bound alone.
261    #[must_use]
262    pub const fn relative(relative: f64) -> Self {
263        Self {
264            relative: Some(relative),
265            absolute: None,
266        }
267    }
268
269    /// Whether this bounds anything at all. A bound that is not a positive, finite number gates
270    /// nothing, the "ungated metric" of [Loft lesson L79][l79], so it does not count as set.
271    ///
272    /// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
273    #[must_use]
274    pub fn is_set(self) -> bool {
275        [self.relative, self.absolute]
276            .into_iter()
277            .flatten()
278            .any(|bound| bound.is_finite() && bound > 0.0)
279    }
280
281    /// How much `reference` may move under this tolerance, in the metric's own unit.
282    #[must_use]
283    pub fn allowed(self, reference: f64) -> f64 {
284        let relative = self
285            .relative
286            .filter(|bound| bound.is_finite() && *bound > 0.0)
287            .map_or(0.0, |relative| relative * reference.abs());
288        let absolute = self
289            .absolute
290            .filter(|bound| bound.is_finite() && *bound > 0.0)
291            .unwrap_or(0.0);
292        relative.max(absolute)
293    }
294
295    /// Whether `measured` is within this tolerance of `reference`: inside either bound that is
296    /// given. A tolerance with neither accepts nothing, so an unset one fails rather than passing
297    /// silently, and a value that is not a real number never passes.
298    #[must_use]
299    pub fn accepts(self, measured: f64, reference: f64) -> bool {
300        self.is_set()
301            && measured.is_finite()
302            && reference.is_finite()
303            && (measured - reference).abs() <= self.allowed(reference)
304    }
305
306    /// How the tolerance reads in a report.
307    #[must_use]
308    pub fn describe(self) -> String {
309        match (self.relative, self.absolute) {
310            (Some(relative), Some(absolute)) => format!("{:.3}% or {absolute}", 100.0 * relative),
311            (Some(relative), None) => format!("{:.3}%", 100.0 * relative),
312            (None, Some(absolute)) => format!("{absolute}"),
313            (None, None) => "none".to_owned(),
314        }
315    }
316}
317
318/// The cases a run must cover, read from `validation/cases/lock.toml`.
319///
320/// [Loft lesson L78][l78]: a suite that quietly skips a case and reports green is worse than a
321/// red one, so the lock names every case that has to run and the harness fails if one is missing.
322///
323/// [l78]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l78
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct CaseLock {
327    /// The case ids that must run, in report order.
328    pub cases: Vec<String>,
329    /// The ids `--fast` may leave out, which must all be in `cases`.
330    #[serde(default)]
331    pub slow: Vec<String>,
332}
333
334impl CaseLock {
335    /// The ids a run covers: every locked case, or the ones that are not slow.
336    #[must_use]
337    pub fn wanted(&self, fast: bool) -> Vec<String> {
338        self.cases
339            .iter()
340            .filter(|id| !(fast && self.slow.contains(id)))
341            .cloned()
342            .collect()
343    }
344
345    /// The ids a run leaves out, which is empty unless it is a fast one.
346    #[must_use]
347    pub fn skipped(&self, fast: bool) -> Vec<String> {
348        self.cases
349            .iter()
350            .filter(|id| fast && self.slow.contains(id))
351            .cloned()
352            .collect()
353    }
354
355    /// The ids named as slow that are not cases at all.
356    #[must_use]
357    pub fn unknown_slow(&self) -> Vec<String> {
358        self.slow
359            .iter()
360            .filter(|id| !self.cases.contains(id))
361            .cloned()
362            .collect()
363    }
364}
365
366/// Where the cases and their lock live, relative to the repository root.
367#[must_use]
368pub fn cases_dir(root: &Path) -> PathBuf {
369    root.join("validation/cases")
370}
371
372/// Every case id committed under `validation/cases/`, sorted.
373///
374/// A case that is committed but not locked would never run, which is the other half of
375/// [Loft lesson L78][l78]: a suite must not quietly skip a case.
376///
377/// [l78]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l78
378///
379/// # Errors
380///
381/// The directory's own error, as a sentence.
382pub fn committed_cases(root: &Path) -> Result<Vec<String>, String> {
383    let directory = cases_dir(root);
384    let mut ids = Vec::new();
385    for entry in std::fs::read_dir(&directory)
386        .map_err(|error| format!("reading {}: {error}", directory.display()))?
387    {
388        let path = entry
389            .map_err(|error| format!("reading {}: {error}", directory.display()))?
390            .path();
391        if path
392            .extension()
393            .is_some_and(|extension| extension == "toml")
394            && let Some(stem) = path
395                .file_stem()
396                .map(|stem| stem.to_string_lossy().into_owned())
397            && stem != "lock"
398        {
399            ids.push(stem);
400        }
401    }
402    ids.sort();
403    Ok(ids)
404}