Skip to main content

pleiades_data/
accuracy_baseline.rs

1//! Per-body accuracy baseline: decoded committed artifact vs hold-out corpus.
2//!
3//! Compares every hold-out row against the committed packaged artifact at the same
4//! epoch and body, accumulating per-body max and RMS errors in longitude (arcsec),
5//! latitude (arcsec), and distance (km).  These numbers are the SP1 deliverable
6//! that scopes SP2 accuracy targets.
7
8use std::collections::HashMap;
9
10use pleiades_backend::{Angle, CelestialBody, CustomBodyId};
11use pleiades_compression::{cartesian_state_to_spherical, CartesianState, CompressedArtifact};
12use pleiades_jpl::{production_holdout_corpus, reference_snapshot, SnapshotEntry};
13
14use crate::regenerate::{build_packaged_artifact, coordinates, normalize_lookup_instant};
15use crate::AU_IN_KM;
16
17/// Per-body accuracy summary comparing the artifact to an independent hold-out corpus.
18#[derive(Clone, Debug)]
19pub struct BodyChannelError {
20    /// Body these errors apply to.
21    pub body: CelestialBody,
22    /// Number of hold-out rows that were successfully compared for this body.
23    /// A value of zero would indicate a vacuous baseline (no rows matched), but
24    /// `accuracy_baseline_against` excludes bodies with zero comparisons entirely.
25    pub comparison_count: usize,
26    /// Maximum absolute longitude error across all hold-out rows for this body (arcseconds).
27    pub max_longitude_arcsec: f64,
28    /// Root-mean-square longitude error across all hold-out rows for this body (arcseconds).
29    pub rms_longitude_arcsec: f64,
30    /// Maximum absolute latitude error across all hold-out rows for this body (arcseconds).
31    pub max_latitude_arcsec: f64,
32    /// Root-mean-square latitude error across all hold-out rows for this body (arcseconds).
33    pub rms_latitude_arcsec: f64,
34    /// Maximum absolute distance error across all hold-out rows for this body (km).
35    pub max_distance_km: f64,
36    /// Root-mean-square distance error across all hold-out rows for this body (km).
37    pub rms_distance_km: f64,
38    /// Maximum absolute longitude speed error across hold-out rows that have velocity truth (arcsec/day).
39    /// Zero when no velocity-bearing rows were compared for this body.
40    pub max_lon_speed_arcsec_per_day: f64,
41    /// Maximum absolute latitude speed error across hold-out rows that have velocity truth (arcsec/day).
42    /// Zero when no velocity-bearing rows were compared for this body.
43    pub max_lat_speed_arcsec_per_day: f64,
44    /// Maximum absolute radial speed error across hold-out rows that have velocity truth (AU/day).
45    /// Zero when no velocity-bearing rows were compared for this body.
46    pub max_radial_speed_au_per_day: f64,
47}
48
49impl BodyChannelError {
50    fn label(&self) -> String {
51        format!("{:?}", self.body)
52    }
53
54    /// Returns a compact one-line summary for this body's errors.
55    pub fn summary_line(&self) -> String {
56        format!(
57            "{}: n={} max_lon={:.4} arcsec  rms_lon={:.4} arcsec  max_lat={:.4} arcsec  rms_lat={:.4} arcsec  max_dist={:.3} km  rms_dist={:.3} km  max_lon_speed={:.4} arcsec/day  max_lat_speed={:.4} arcsec/day  max_radial_speed={:.6} AU/day",
58            self.label(),
59            self.comparison_count,
60            self.max_longitude_arcsec,
61            self.rms_longitude_arcsec,
62            self.max_latitude_arcsec,
63            self.rms_latitude_arcsec,
64            self.max_distance_km,
65            self.rms_distance_km,
66            self.max_lon_speed_arcsec_per_day,
67            self.max_lat_speed_arcsec_per_day,
68            self.max_radial_speed_au_per_day,
69        )
70    }
71}
72
73struct BodyAccumulator {
74    body: CelestialBody,
75    count: usize,
76    max_lon_arcsec: f64,
77    sum_sq_lon_arcsec: f64,
78    max_lat_arcsec: f64,
79    sum_sq_lat_arcsec: f64,
80    max_dist_km: f64,
81    sum_sq_dist_km: f64,
82    max_lon_speed_arcsec_per_day: f64,
83    max_lat_speed_arcsec_per_day: f64,
84    max_radial_speed_au_per_day: f64,
85}
86
87impl BodyAccumulator {
88    fn new(body: CelestialBody) -> Self {
89        Self {
90            body,
91            count: 0,
92            max_lon_arcsec: 0.0,
93            sum_sq_lon_arcsec: 0.0,
94            max_lat_arcsec: 0.0,
95            sum_sq_lat_arcsec: 0.0,
96            max_dist_km: 0.0,
97            sum_sq_dist_km: 0.0,
98            max_lon_speed_arcsec_per_day: 0.0,
99            max_lat_speed_arcsec_per_day: 0.0,
100            max_radial_speed_au_per_day: 0.0,
101        }
102    }
103
104    fn accumulate(&mut self, lon_arcsec: f64, lat_arcsec: f64, dist_km: Option<f64>) {
105        self.count += 1;
106        self.max_lon_arcsec = self.max_lon_arcsec.max(lon_arcsec);
107        self.sum_sq_lon_arcsec += lon_arcsec * lon_arcsec;
108        self.max_lat_arcsec = self.max_lat_arcsec.max(lat_arcsec);
109        self.sum_sq_lat_arcsec += lat_arcsec * lat_arcsec;
110        if let Some(d) = dist_km {
111            self.max_dist_km = self.max_dist_km.max(d);
112            self.sum_sq_dist_km += d * d;
113        }
114    }
115
116    fn accumulate_speed(
117        &mut self,
118        lon_speed_arcsec_per_day: f64,
119        lat_speed_arcsec_per_day: f64,
120        radial_speed_au_per_day: f64,
121    ) {
122        self.max_lon_speed_arcsec_per_day = self
123            .max_lon_speed_arcsec_per_day
124            .max(lon_speed_arcsec_per_day);
125        self.max_lat_speed_arcsec_per_day = self
126            .max_lat_speed_arcsec_per_day
127            .max(lat_speed_arcsec_per_day);
128        self.max_radial_speed_au_per_day = self
129            .max_radial_speed_au_per_day
130            .max(radial_speed_au_per_day);
131    }
132
133    fn finish(self) -> BodyChannelError {
134        let n = self.count as f64;
135        let rms = |sum_sq: f64| if n > 0.0 { (sum_sq / n).sqrt() } else { 0.0 };
136        BodyChannelError {
137            body: self.body,
138            comparison_count: self.count,
139            max_longitude_arcsec: self.max_lon_arcsec,
140            rms_longitude_arcsec: rms(self.sum_sq_lon_arcsec),
141            max_latitude_arcsec: self.max_lat_arcsec,
142            rms_latitude_arcsec: rms(self.sum_sq_lat_arcsec),
143            max_distance_km: self.max_dist_km,
144            rms_distance_km: rms(self.sum_sq_dist_km),
145            max_lon_speed_arcsec_per_day: self.max_lon_speed_arcsec_per_day,
146            max_lat_speed_arcsec_per_day: self.max_lat_speed_arcsec_per_day,
147            max_radial_speed_au_per_day: self.max_radial_speed_au_per_day,
148        }
149    }
150}
151
152/// Computes per-body accuracy errors between a hold-out slice and an artifact.
153///
154/// For each hold-out row the artifact is queried at the same epoch and body.
155/// Rows where the artifact lookup fails (body missing or out of range) are skipped
156/// silently — but if ALL rows for a body are skipped, that body is excluded from the
157/// result rather than silently reporting zero error.  A caller asserting non-zero
158/// error on a specific body will detect a vacuous baseline.
159///
160/// The lookup instant is normalized to `TimeScale::Tt` (same julian_day, Tdb
161/// relabelled Tt) to match the committed artifact's segment tag convention.
162/// `normalize_lookup_instant` mirrors the runtime packaged-lookup path and
163/// `Segment::contains` requires scale equality.
164///
165/// Longitude differences are wrapped to ±180° before conversion to arcseconds.
166/// Latitude differences are taken directly.  Distance differences are converted
167/// from AU to km.
168pub fn accuracy_baseline_against(
169    holdout: &[SnapshotEntry],
170    artifact: &CompressedArtifact,
171) -> Vec<BodyChannelError> {
172    let mut accumulators: HashMap<String, BodyAccumulator> = HashMap::new();
173
174    for entry in holdout {
175        let key = format!("{:?}", entry.body);
176        let acc = accumulators
177            .entry(key)
178            .or_insert_with(|| BodyAccumulator::new(entry.body.clone()));
179
180        // Normalize to Tt (same julian_day) — the committed artifact's segment
181        // boundaries are Tt-tagged; Segment::contains requires scale equality.
182        let lookup_instant = normalize_lookup_instant(entry.epoch);
183
184        let artifact_result = artifact.lookup_ecliptic(&entry.body, lookup_instant);
185        let artifact_coords = match artifact_result {
186            Ok(coords) => coords,
187            Err(_) => continue, // body missing or out of range — skip this row
188        };
189
190        let holdout_coords = coordinates(entry);
191
192        // Longitude diff wrapped to ±180°, then to arcseconds.
193        let lon_diff_deg = Angle::from_degrees(
194            artifact_coords.longitude.degrees() - holdout_coords.longitude.degrees(),
195        )
196        .normalized_signed()
197        .degrees();
198        let lon_arcsec = lon_diff_deg.abs() * 3600.0;
199
200        // Latitude diff (no wrapping needed, bounded ±90°).
201        let lat_arcsec =
202            (artifact_coords.latitude.degrees() - holdout_coords.latitude.degrees()).abs() * 3600.0;
203
204        // Distance diff in km; None when either side has no distance.
205        // Skip the distance contribution for this row rather than silently
206        // accumulating 0.0 (which would mask a future regression).
207        let dist_km = match (artifact_coords.distance_au, holdout_coords.distance_au) {
208            (Some(a), Some(h)) => Some((a - h).abs() * AU_IN_KM),
209            _ => None,
210        };
211
212        acc.accumulate(lon_arcsec, lat_arcsec, dist_km);
213
214        // Speed error: only for rows that carry velocity truth.
215        if let (Some(vx), Some(vy), Some(vz)) = (entry.vx_km_s, entry.vy_km_s, entry.vz_km_s) {
216            // Convert hold-out Cartesian position (km) + velocity (km/s) to AU and AU/day.
217            let pos_au = [
218                entry.x_km / AU_IN_KM,
219                entry.y_km / AU_IN_KM,
220                entry.z_km / AU_IN_KM,
221            ];
222            let vel_au_per_day = [
223                vx * 86400.0 / AU_IN_KM,
224                vy * 86400.0 / AU_IN_KM,
225                vz * 86400.0 / AU_IN_KM,
226            ];
227            let truth_spherical = cartesian_state_to_spherical(CartesianState {
228                pos_au,
229                vel_au_per_day,
230            });
231
232            // truth rates are in rad/day (lon/lat) and AU/day (radial) — convert lon/lat to deg/day.
233            let truth_lon_deg_per_day = truth_spherical.lon_rate_rad_per_day.to_degrees();
234            let truth_lat_deg_per_day = truth_spherical.lat_rate_rad_per_day.to_degrees();
235            let truth_radial_au_per_day = truth_spherical.dist_rate_au_per_day;
236
237            if let Ok(motion) = artifact.lookup_motion(&entry.body, lookup_instant) {
238                let art_lon = motion.longitude_deg_per_day.unwrap_or(0.0);
239                let art_lat = motion.latitude_deg_per_day.unwrap_or(0.0);
240                let art_radial = motion.distance_au_per_day.unwrap_or(0.0);
241
242                let lon_speed_arcsec = (art_lon - truth_lon_deg_per_day).abs() * 3600.0;
243                let lat_speed_arcsec = (art_lat - truth_lat_deg_per_day).abs() * 3600.0;
244                let radial_speed_au = (art_radial - truth_radial_au_per_day).abs();
245
246                acc.accumulate_speed(lon_speed_arcsec, lat_speed_arcsec, radial_speed_au);
247            }
248        }
249    }
250
251    // Vacuity guard: only emit bodies where at least one row was successfully compared.
252    // Bodies whose every lookup failed (e.g. due to a scale mismatch) are excluded rather
253    // than silently emitted as all-zero error — zero here means "not measured", not "perfect".
254    let mut results: Vec<BodyChannelError> = accumulators
255        .into_values()
256        .filter(|acc| acc.count > 0)
257        .map(|acc| acc.finish())
258        .collect();
259    results.sort_by_key(|a| a.label());
260    results
261}
262
263/// Computes per-body accuracy errors for the committed packaged artifact against
264/// the production hold-out corpus.
265///
266/// This is the SP1 baseline deliverable.
267pub fn packaged_artifact_accuracy_baseline() -> Vec<BodyChannelError> {
268    let holdout = production_holdout_corpus();
269    let artifact = build_packaged_artifact();
270    accuracy_baseline_against(holdout, &artifact)
271}
272
273/// Returns a deterministic one-line-per-body summary of the packaged-artifact accuracy baseline.
274///
275/// The string is recomputed on every call; the drift-gate test
276/// `packaged_artifact_baseline_summary_matches_committed_golden` compares it
277/// against the committed golden buckets.
278pub fn packaged_artifact_accuracy_baseline_summary_for_report() -> String {
279    let errors = packaged_artifact_accuracy_baseline();
280    if errors.is_empty() {
281        return "Packaged-artifact accuracy baseline: no hold-out rows matched".to_string();
282    }
283    let lines: Vec<String> = errors.iter().map(|e| e.summary_line()).collect();
284    format!(
285        "Packaged-artifact accuracy baseline ({} bodies):\n{}",
286        errors.len(),
287        lines.join("\n")
288    )
289}
290
291/// Computes the maximum absolute longitude error (arcsec) between the committed
292/// packaged artifact and the Eros reference-snapshot rows it was fit from.
293///
294/// Eros has no independent hold-out truth (its curated 1900–2100 corpus is the
295/// only source — it is not in de440). This helper is therefore a SELF-CONSISTENCY
296/// check: it measures how faithfully the artifact reproduces the snapshot it was
297/// derived from. A large value here would indicate a fitting regression, not a
298/// mismatch against external truth.
299///
300/// The longitude difference is wrapped to ±180° before conversion to arcseconds,
301/// exactly as in [`accuracy_baseline_against`].
302pub fn eros_self_consistency_max_longitude_arcsec() -> f64 {
303    let eros_body = CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros"));
304    let artifact = build_packaged_artifact();
305    let mut max_lon_arcsec: f64 = 0.0;
306
307    for entry in reference_snapshot() {
308        if entry.body != eros_body {
309            continue;
310        }
311        let lookup_instant = normalize_lookup_instant(entry.epoch);
312        let artifact_coords = match artifact.lookup_ecliptic(&eros_body, lookup_instant) {
313            Ok(c) => c,
314            Err(_) => continue,
315        };
316        let snapshot_coords = coordinates(entry);
317
318        // Longitude diff wrapped to ±180°, then to arcseconds — reuses the same
319        // idiom as accuracy_baseline_against.
320        let lon_diff_deg = Angle::from_degrees(
321            artifact_coords.longitude.degrees() - snapshot_coords.longitude.degrees(),
322        )
323        .normalized_signed()
324        .degrees();
325        let lon_arcsec = lon_diff_deg.abs() * 3600.0;
326        max_lon_arcsec = max_lon_arcsec.max(lon_arcsec);
327    }
328
329    max_lon_arcsec
330}
331
332#[cfg(test)]
333mod tests {
334    use pleiades_backend::{Instant, JulianDay, TimeScale};
335    use pleiades_compression::{
336        ArtifactHeader, BodyArtifact, ChannelKind, CompressedArtifact, PolynomialChannel, Segment,
337    };
338
339    use super::*;
340
341    /// Synthetic holdout with a known ecliptic velocity for the Sun at J2000.
342    ///
343    /// Position: Sun at (1 AU, 0, 0) → lon=0°, lat=0°, dist=1 AU.
344    /// Velocity: (0, vy_km_s, 0) in km/s where vy_km_s = 0.01 AU/day in km/s.
345    ///
346    /// At this position (x=1, y=0, z=0):
347    ///   dλ/dt = (x·vy − y·vx) / (x²+y²) = vy = 0.01 AU/day in rad/day
348    ///   dβ/dt = (ρ·vz − z·ρ̇) / r²       = 0 rad/day
349    ///   dr/dt = (x·vx + y·vy + z·vz) / r  = 0 AU/day
350    fn synthetic_holdout_with_velocity() -> Vec<SnapshotEntry> {
351        let au = AU_IN_KM;
352        // vy = 0.01 AU/day converted to km/s: 0.01 * AU_IN_KM / 86400
353        let vy_km_s = 0.01 * au / 86400.0;
354        vec![SnapshotEntry {
355            body: CelestialBody::Sun,
356            epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
357            x_km: au,
358            y_km: 0.0,
359            z_km: 0.0,
360            vx_km_s: Some(0.0),
361            vy_km_s: Some(vy_km_s),
362            vz_km_s: Some(0.0),
363        }]
364    }
365
366    /// Synthetic artifact with a linear longitude segment matching the known velocity.
367    ///
368    /// The Sun longitude increases linearly over 10 days from 0° to 0.01.to_degrees()*10°,
369    /// so the analytic derivative dλ/dt = 0.01.to_degrees() deg/day = 0.01 rad/day ✓.
370    /// Latitude and distance are constant, matching dβ/dt=0 and dr/dt=0.
371    fn synthetic_artifact_linear() -> CompressedArtifact {
372        let jd0 = 2_451_545.0_f64;
373        let span_days = 10.0_f64;
374        let start = Instant::new(JulianDay::from_days(jd0), TimeScale::Tt);
375        let end = Instant::new(JulianDay::from_days(jd0 + span_days), TimeScale::Tt);
376
377        // lon rate = 0.01 rad/day → deg/day = 0.01 * (180/π)
378        // over 10 days: lon goes from 0° to 0.01 * (180/π) * 10°
379        let lon_rate_deg_per_day = 0.01_f64.to_degrees();
380        let lon_end = lon_rate_deg_per_day * span_days;
381
382        let segment = Segment::new(
383            start,
384            end,
385            vec![
386                PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, lon_end),
387                PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
388                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
389            ],
390        );
391        CompressedArtifact::new(
392            ArtifactHeader::new(
393                "synthetic-linear-test",
394                "synthetic linear velocity test source",
395            ),
396            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
397        )
398    }
399
400    fn synthetic_holdout() -> Vec<SnapshotEntry> {
401        // Single Sun entry at J2000.0 in ecliptic Cartesian: place Sun at 1 AU along x-axis,
402        // giving lon=0°, lat=0°, dist=1 AU.
403        let au = AU_IN_KM;
404        vec![SnapshotEntry {
405            body: CelestialBody::Sun,
406            epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
407            x_km: au,
408            y_km: 0.0,
409            z_km: 0.0,
410            vx_km_s: None,
411            vy_km_s: None,
412            vz_km_s: None,
413        }]
414    }
415
416    fn synthetic_artifact() -> CompressedArtifact {
417        // Build an artifact whose Sun segment reproduces the same lon/lat/dist exactly.
418        let jd = JulianDay::from_days(2_451_545.0);
419        let instant = Instant::new(jd, TimeScale::Tt);
420        // lon=0°, lat=0°, dist_au=1.0 — constant segment.
421        let segment = Segment::new(
422            instant,
423            instant,
424            vec![
425                PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 0.0),
426                PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
427                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
428            ],
429        );
430        CompressedArtifact::new(
431            ArtifactHeader::new("synthetic-test", "synthetic test source"),
432            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
433        )
434    }
435
436    #[test]
437    fn baseline_reports_speed_error_fields() {
438        let errors = accuracy_baseline_against(
439            &synthetic_holdout_with_velocity(),
440            &synthetic_artifact_linear(),
441        );
442        assert_eq!(
443            errors.len(),
444            1,
445            "expected exactly 1 body in synthetic speed baseline"
446        );
447        let sun = &errors[0];
448        assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
449        // Speed error must be near-zero (artifact derivative exactly matches truth velocity).
450        assert!(
451            sun.max_lon_speed_arcsec_per_day < 1e-3,
452            "Sun max longitude speed error too large: {} arcsec/day",
453            sun.max_lon_speed_arcsec_per_day
454        );
455        assert!(
456            sun.max_lat_speed_arcsec_per_day < 1e-3,
457            "Sun max latitude speed error too large: {} arcsec/day",
458            sun.max_lat_speed_arcsec_per_day
459        );
460        assert!(
461            sun.max_radial_speed_au_per_day < 1e-6,
462            "Sun max radial speed error too large: {} AU/day",
463            sun.max_radial_speed_au_per_day
464        );
465    }
466
467    #[test]
468    fn baseline_reports_zero_error_for_an_artifact_that_matches_holdout() {
469        let errors = accuracy_baseline_against(&synthetic_holdout(), &synthetic_artifact());
470        // Should have exactly one body (Sun) with ~zero errors.
471        assert_eq!(
472            errors.len(),
473            1,
474            "expected exactly 1 body in synthetic baseline"
475        );
476        let sun = &errors[0];
477        assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
478        assert!(
479            sun.max_longitude_arcsec < 1e-3,
480            "Sun max longitude error too large: {} arcsec",
481            sun.max_longitude_arcsec
482        );
483        assert!(
484            sun.max_latitude_arcsec < 1e-3,
485            "Sun max latitude error too large: {} arcsec",
486            sun.max_latitude_arcsec
487        );
488        assert!(
489            sun.max_distance_km < 1.0,
490            "Sun max distance error too large: {} km",
491            sun.max_distance_km
492        );
493    }
494
495    #[test]
496    fn baseline_excludes_body_when_all_lookups_fail() {
497        // Vacuity guard: if the artifact has NO segment for the holdout body, the body
498        // must be absent from results rather than silently appearing as zero error.
499        let holdout = synthetic_holdout(); // Sun at J2000.0
500                                           // Empty artifact — lookup_ecliptic will fail with MissingBody for Sun.
501        let empty_artifact = CompressedArtifact::new(
502            ArtifactHeader::new("empty-test", "empty test source"),
503            vec![],
504        );
505        let errors = accuracy_baseline_against(&holdout, &empty_artifact);
506        assert!(
507            errors.is_empty(),
508            "vacuity guard must exclude bodies with zero successful comparisons; got {} entries",
509            errors.len()
510        );
511    }
512
513    // Runtime: ~1.7 s (decodes 47.5 MB artifact + 500-row hold-out). Not ignored.
514    #[test]
515    fn packaged_artifact_baseline_is_non_vacuous() {
516        // Regression guard against the Tdb/Tt scale-mismatch vacuity bug.
517        // All 10 base bodies must be present with count>0; inner bodies and luminaries
518        // must be sub-arcsec; at least one outer planet must show a clearly non-zero
519        // longitude error (proving the baseline is not vacuous).
520        let errors = packaged_artifact_accuracy_baseline();
521
522        // (a) All 10 base bodies present.
523        let expected_bodies = [
524            CelestialBody::Sun,
525            CelestialBody::Moon,
526            CelestialBody::Mercury,
527            CelestialBody::Venus,
528            CelestialBody::Mars,
529            CelestialBody::Jupiter,
530            CelestialBody::Saturn,
531            CelestialBody::Uranus,
532            CelestialBody::Neptune,
533            CelestialBody::Pluto,
534        ];
535        assert_eq!(
536            errors.len(),
537            10,
538            "expected 10 base bodies in packaged baseline; got {}: {:?}",
539            errors.len(),
540            errors
541                .iter()
542                .map(|e| format!("{:?}", e.body))
543                .collect::<Vec<_>>()
544        );
545        for body in &expected_bodies {
546            let entry = errors
547                .iter()
548                .find(|e| &e.body == body)
549                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
550            assert!(
551                entry.comparison_count > 0,
552                "{body:?} must have at least one successful comparison (got 0 — vacuous baseline)"
553            );
554        }
555
556        // (b) Inner bodies + luminaries must be sub-arcsec in longitude.
557        let sub_arcsec_bodies = [
558            CelestialBody::Sun,
559            CelestialBody::Moon,
560            CelestialBody::Mercury,
561            CelestialBody::Venus,
562            CelestialBody::Mars,
563        ];
564        for body in &sub_arcsec_bodies {
565            let entry = errors.iter().find(|e| &e.body == body).unwrap();
566            assert!(
567                entry.max_longitude_arcsec < 1.0,
568                "{body:?} max longitude error must be <1 arcsec (got {:.6}\")",
569                entry.max_longitude_arcsec
570            );
571        }
572
573        // (b2) SP2: outer planets stored heliocentrically — all must also be sub-arcsec.
574        let outer_bodies = [
575            CelestialBody::Jupiter,
576            CelestialBody::Saturn,
577            CelestialBody::Uranus,
578            CelestialBody::Neptune,
579            CelestialBody::Pluto,
580        ];
581        for body in &outer_bodies {
582            let entry = errors.iter().find(|e| &e.body == body).unwrap();
583            assert!(
584                entry.max_longitude_arcsec < 1.0,
585                "{body:?} max longitude error must be <1 arcsec after SP2 heliocentric reframe (got {:.6}\")",
586                entry.max_longitude_arcsec
587            );
588        }
589
590        // (c) Non-vacuity anchor: Uranus must be non-zero (baseline is not vacuous)
591        // and sub-arcsec (heliocentric reframe is active).
592        let uranus = errors
593            .iter()
594            .find(|e| e.body == CelestialBody::Uranus)
595            .expect("Uranus must appear in the packaged baseline");
596        assert!(
597            uranus.max_longitude_arcsec > 0.0001,
598            "Uranus max longitude error must be >0.0001\" (got {:.6}\" — baseline may be vacuous)",
599            uranus.max_longitude_arcsec
600        );
601        assert!(
602            uranus.max_longitude_arcsec < 1.0,
603            "Uranus max longitude error must be <1\" after SP2 heliocentric reframe (got {:.4}\" — reframe may be broken)",
604            uranus.max_longitude_arcsec
605        );
606    }
607
608    // Astrology-grade longitude envelope gate (SP2).
609    // Outer planets are allowed up to 5.0″; inner bodies/luminaries up to 1.0″.
610    // With the v7 heliocentric artifact all bodies are sub-arcsec, so this
611    // passes with large margin — the 5.0″ ceiling guards against a future
612    // regression, not against the current state.
613    // Note: (b2) in packaged_artifact_baseline_is_non_vacuous already asserts
614    // outer planets < 1.0″, which is STRICTER than this gate's 5.0″ ceiling.
615    // The two tests are complementary, not redundant: this test encodes the
616    // published astrology-grade specification, while (b2) is an operational
617    // regression guard at the tighter SP2-achieved level.
618    #[test]
619    fn outer_planet_longitude_meets_astrology_grade_envelope() {
620        let baseline = crate::accuracy_baseline::packaged_artifact_accuracy_baseline();
621        // Astrology-grade longitude ceilings drawn from the published SSOT.
622        // accuracy_ceiling returns 1.0" for Luminary/InnerPlanet, 5.0" for
623        // OuterPlanet, and 30.0" for Asteroid — identical to the old inline
624        // match, so no threshold is loosened.
625        for body_error in &baseline {
626            let c = crate::thresholds::accuracy_ceiling(&body_error.body).lon_arcsec;
627            assert!(
628                body_error.max_longitude_arcsec <= c,
629                "{:?} longitude {:.3}\" exceeds ceiling {:.1}\"",
630                body_error.body,
631                body_error.max_longitude_arcsec,
632                c
633            );
634        }
635    }
636
637    // Hard accuracy-ceiling gate (SP3, Task 11): all 6 channels for every body in
638    // the baseline must be within their published ceilings from thresholds.rs.
639    // Currently passing with large margin (measured << ceiling), so this guards
640    // against future regressions rather than reflecting the current tight state.
641    #[test]
642    fn all_channels_within_published_ceilings_for_major_bodies() {
643        let baseline = packaged_artifact_accuracy_baseline();
644        for e in &baseline {
645            let c = crate::thresholds::accuracy_ceiling(&e.body);
646            assert!(
647                e.max_longitude_arcsec <= c.lon_arcsec,
648                "{:?} lon {:.4}\" exceeds ceiling {:.1}\"",
649                e.body,
650                e.max_longitude_arcsec,
651                c.lon_arcsec
652            );
653            assert!(
654                e.max_latitude_arcsec <= c.lat_arcsec,
655                "{:?} lat {:.4}\" exceeds ceiling {:.1}\"",
656                e.body,
657                e.max_latitude_arcsec,
658                c.lat_arcsec
659            );
660            assert!(
661                e.max_distance_km <= c.dist_km,
662                "{:?} dist {:.3} km exceeds ceiling {:.0} km",
663                e.body,
664                e.max_distance_km,
665                c.dist_km
666            );
667            assert!(
668                e.max_lon_speed_arcsec_per_day <= c.lon_speed_arcsec_per_day,
669                "{:?} lon speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
670                e.body,
671                e.max_lon_speed_arcsec_per_day,
672                c.lon_speed_arcsec_per_day
673            );
674            assert!(
675                e.max_lat_speed_arcsec_per_day <= c.lat_speed_arcsec_per_day,
676                "{:?} lat speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
677                e.body,
678                e.max_lat_speed_arcsec_per_day,
679                c.lat_speed_arcsec_per_day
680            );
681            assert!(
682                e.max_radial_speed_au_per_day <= c.radial_speed_au_per_day,
683                "{:?} radial speed {:.6} AU/day exceeds ceiling {:.2e} AU/day",
684                e.body,
685                e.max_radial_speed_au_per_day,
686                c.radial_speed_au_per_day
687            );
688        }
689    }
690
691    // Size-budget gate (SP3, Task 11): the committed packaged artifact must not exceed
692    // the published encoded-bytes budget from PACKAGED_BUDGETS.
693    #[test]
694    fn encoded_artifact_within_size_budget() {
695        let bytes_len = crate::data::packaged_artifact_bytes().len();
696        assert!(
697            bytes_len <= crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes,
698            "encoded artifact {} bytes exceeds budget {} bytes",
699            bytes_len,
700            crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes
701        );
702    }
703
704    #[test]
705    #[ignore = "maintainer helper: prints the accuracy baseline summary to regenerate the golden"]
706    fn print_packaged_artifact_baseline_summary() {
707        eprintln!(
708            "{}",
709            packaged_artifact_accuracy_baseline_summary_for_report()
710        );
711    }
712
713    // Drift gate: the committed per-body summary must match the live baseline.
714    // Generated from actual output (2026-06-20, SP3 heliocentric-planet artifact);
715    // fails if errors silently go all-zero or if artifact/hold-out changes shift
716    // any body's error bucket. SP2 outer-planet errors are sub-arcsec after the
717    // heliocentric reframe; compare with SP1 (pre-reframe) goldens in git history.
718    // SP3 (Task 11): golden now anchors speed channels (first 3 significant digits)
719    // in addition to the position channels. Speed vacuity: if a body's speed rows
720    // were all skipped, max_lon_speed stays 0.0000 and the non-vacuity check below
721    // catches it via the "strictly-positive lon-speed" guard.
722    #[test]
723    fn packaged_artifact_baseline_summary_matches_committed_golden() {
724        let report = packaged_artifact_accuracy_baseline_summary_for_report();
725
726        // Header: 10 bodies
727        assert!(
728            report.contains("Packaged-artifact accuracy baseline (10 bodies)"),
729            "baseline report header drift: {report}"
730        );
731
732        // All bodies: sub-arcsec — anchored to first 3 significant digits.
733        // SP2: outer planets now stored heliocentrically, so all errors are sub-arcsec.
734        assert!(
735            report.contains("Sun: n=50 max_lon=0.000"),
736            "Sun max_lon bucket drift (expected ~0.0009\"): {report}"
737        );
738        assert!(
739            report.contains("Moon: n=50 max_lon=0.000"),
740            "Moon max_lon bucket drift (expected ~0.0001\"): {report}"
741        );
742        assert!(
743            report.contains("Mercury: n=50 max_lon=0.000"),
744            "Mercury max_lon bucket drift (expected ~0.0009\"): {report}"
745        );
746        assert!(
747            report.contains("Venus: n=50 max_lon=0.001"),
748            "Venus max_lon bucket drift (expected ~0.0011\"): {report}"
749        );
750        assert!(
751            report.contains("Mars: n=50 max_lon=0.000"),
752            "Mars max_lon bucket drift (expected ~0.0005\"): {report}"
753        );
754        // Outer planets: SP2 heliocentric-reframe — all sub-arcsec, non-zero.
755        assert!(
756            report.contains("Jupiter: n=50 max_lon=0.000"),
757            "Jupiter max_lon bucket drift (expected ~0.0004\"): {report}"
758        );
759        assert!(
760            report.contains("Saturn: n=50 max_lon=0.000"),
761            "Saturn max_lon bucket drift (expected ~0.0009\"): {report}"
762        );
763        assert!(
764            report.contains("Uranus: n=50 max_lon=0.003"),
765            "Uranus max_lon bucket drift (expected ~0.0036\"): {report}"
766        );
767        assert!(
768            report.contains("Neptune: n=50 max_lon=0.002"),
769            "Neptune max_lon bucket drift (expected ~0.0020\"): {report}"
770        );
771        assert!(
772            report.contains("Pluto: n=50 max_lon=0.001"),
773            "Pluto max_lon bucket drift (expected ~0.0018\"): {report}"
774        );
775
776        // SP3 Task 11: speed channel golden anchors (first 3 significant digits).
777        // Moon has the highest lon/lat speed error (~0.030/~0.023 arcsec/day) due to
778        // fast apparent motion; outer planets are an order of magnitude slower.
779        // Radial speed errors are sub-1e-6 AU/day for all bodies; anchored to "0.000000".
780        assert!(
781            report.contains("Moon: n=50") && report.contains("max_lon_speed=0.023"),
782            "Moon max_lon_speed bucket drift (expected ~0.0230 arcsec/day): {report}"
783        );
784        assert!(
785            report.contains("Moon: n=50") && report.contains("max_lat_speed=0.025"),
786            "Moon max_lat_speed bucket drift (expected ~0.0253 arcsec/day): {report}"
787        );
788        assert!(
789            report.contains("Sun: n=50") && report.contains("max_lon_speed=0.001"),
790            "Sun max_lon_speed bucket drift (expected ~0.0013 arcsec/day): {report}"
791        );
792        assert!(
793            report.contains("Mercury: n=50") && report.contains("max_lon_speed=0.001"),
794            "Mercury max_lon_speed bucket drift (expected ~0.0013 arcsec/day): {report}"
795        );
796        assert!(
797            report.contains("Venus: n=50") && report.contains("max_lon_speed=0.001"),
798            "Venus max_lon_speed bucket drift (expected ~0.0014 arcsec/day): {report}"
799        );
800        assert!(
801            report.contains("Mars: n=50") && report.contains("max_lon_speed=0.001"),
802            "Mars max_lon_speed bucket drift (expected ~0.0011 arcsec/day): {report}"
803        );
804        // Outer planets: lon_speed in the 0.000X range.
805        assert!(
806            report.contains("Jupiter: n=50") && report.contains("max_lon_speed=0.000"),
807            "Jupiter max_lon_speed bucket drift (expected ~0.0002 arcsec/day): {report}"
808        );
809        assert!(
810            report.contains("Saturn: n=50") && report.contains("max_lon_speed=0.000"),
811            "Saturn max_lon_speed bucket drift (expected ~0.0002 arcsec/day): {report}"
812        );
813        assert!(
814            report.contains("Uranus: n=50") && report.contains("max_lon_speed=0.000"),
815            "Uranus max_lon_speed bucket drift (expected ~0.0007 arcsec/day): {report}"
816        );
817        assert!(
818            report.contains("Neptune: n=50") && report.contains("max_lon_speed=0.000"),
819            "Neptune max_lon_speed bucket drift (expected ~0.0002 arcsec/day): {report}"
820        );
821        assert!(
822            report.contains("Pluto: n=50") && report.contains("max_lon_speed=0.000"),
823            "Pluto max_lon_speed bucket drift (expected ~0.0005 arcsec/day): {report}"
824        );
825        // Radial speed: all bodies sub-1e-6 AU/day — anchor on "0.000000".
826        for body_name in &[
827            "Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune",
828            "Pluto",
829        ] {
830            assert!(
831                report.contains(&format!("{body_name}: n=50"))
832                    && report.contains("max_radial_speed=0.000000"),
833                "{body_name} max_radial_speed not anchored at 0.000000 AU/day: {report}"
834            );
835        }
836    }
837
838    // Non-vacuity guard for speed channels (SP3, Task 11).
839    //
840    // The speed accumulator has no velocity-row counter.  A body whose velocity
841    // rows were all skipped (e.g. because lookup_motion returned Err or the corpus
842    // lacked velocity data) would keep max_*_speed = 0.0 and pass the ceiling
843    // gate VACUOUSLY.  This guard catches that: every major planet (excluding the
844    // Moon, whose min non-zero would be below our threshold on some corpora) must
845    // show a STRICTLY POSITIVE lon-speed error magnitude above a small but
846    // non-zero floor (0.0001 arcsec/day), well below the smallest observed real
847    // error (~0.0002 arcsec/day for Jupiter/Saturn/Neptune) but clearly above
848    // exact zero (which would only arise from a silently-all-skipped velocity
849    // channel).
850    //
851    // Moon is guarded separately with a higher floor (0.005 arcsec/day) because
852    // its faster apparent motion means any real measurement would be >>0.005.
853    #[test]
854    fn speed_channels_are_non_vacuous_for_major_bodies() {
855        let errors = packaged_artifact_accuracy_baseline();
856
857        // Inner planets + luminaries: lon-speed must be > 0.0001 arcsec/day.
858        let inner_bodies = [
859            CelestialBody::Sun,
860            CelestialBody::Mercury,
861            CelestialBody::Venus,
862            CelestialBody::Mars,
863        ];
864        for body in &inner_bodies {
865            let e = errors
866                .iter()
867                .find(|e| &e.body == body)
868                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
869            assert!(
870                e.max_lon_speed_arcsec_per_day > 0.0001,
871                "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
872                e.max_lon_speed_arcsec_per_day
873            );
874        }
875
876        // Moon: higher floor (real motion >> 0.005 arcsec/day).
877        let moon = errors
878            .iter()
879            .find(|e| e.body == CelestialBody::Moon)
880            .expect("Moon must appear in the packaged baseline");
881        assert!(
882            moon.max_lon_speed_arcsec_per_day > 0.005,
883            "Moon max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
884            moon.max_lon_speed_arcsec_per_day
885        );
886
887        // Outer planets: lon-speed must be > 0.0001 arcsec/day.
888        let outer_bodies = [
889            CelestialBody::Jupiter,
890            CelestialBody::Saturn,
891            CelestialBody::Uranus,
892            CelestialBody::Neptune,
893            CelestialBody::Pluto,
894        ];
895        for body in &outer_bodies {
896            let e = errors
897                .iter()
898                .find(|e| &e.body == body)
899                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
900            assert!(
901                e.max_lon_speed_arcsec_per_day > 0.0001,
902                "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
903                e.max_lon_speed_arcsec_per_day
904            );
905        }
906    }
907
908    #[test]
909    #[ignore = "maintainer helper: prints the Eros self-consistency max longitude error"]
910    fn print_eros_self_consistency_max_longitude_arcsec() {
911        let v = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
912        eprintln!("EROS_SELF_CONSISTENCY_MAX_LON_ARCSEC = {v:.6}\"");
913    }
914
915    /// Eros self-consistency gate (SP3, Task 12).
916    ///
917    /// Eros is re-derived from the committed reference snapshot (no independent truth —
918    /// it is absent from de440). This is a SELF-CONSISTENCY check, NOT an independent-truth
919    /// gate. It verifies the artifact faithfully reproduces the snapshot it was fit from,
920    /// within the published Asteroid-class longitude ceiling (30″).
921    #[test]
922    fn eros_round_trips_against_its_reference_snapshot_within_documented_target() {
923        let eros_body = pleiades_backend::CelestialBody::Custom(
924            pleiades_backend::CustomBodyId::new("asteroid", "433-Eros"),
925        );
926        // Non-vacuity guard: the reference snapshot must actually contain Eros rows,
927        // otherwise the helper iterates zero rows and trivially returns 0.0 — a pass
928        // that reveals nothing. A missing Eros corpus is a configuration error, not
929        // "perfect accuracy".
930        let eros_row_count = pleiades_jpl::reference_snapshot()
931            .iter()
932            .filter(|e| e.body == eros_body)
933            .count();
934        assert!(
935            eros_row_count > 0,
936            "reference snapshot contains no Eros rows — self-consistency check would be vacuous (iterate zero rows → max=0.0 → trivially passes)"
937        );
938
939        let ceiling = crate::thresholds::accuracy_ceiling(&eros_body);
940        let max_lon_arcsec = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
941        assert!(
942            max_lon_arcsec <= ceiling.lon_arcsec,
943            "Eros self-consistency {max_lon_arcsec:.4}\" > {:.1}\" (artifact does not reproduce the reference snapshot it was fit from within the Asteroid-class ceiling)",
944            ceiling.lon_arcsec
945        );
946    }
947}