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/// Computes the maximum absolute longitude error (arcsec) between the committed
274/// packaged artifact and the Eros reference-snapshot rows it was fit from.
275///
276/// Eros has no independent hold-out truth (its curated 1900–2100 corpus is the
277/// only source — it is not in de440). This helper is therefore a SELF-CONSISTENCY
278/// check: it measures how faithfully the artifact reproduces the snapshot it was
279/// derived from. A large value here would indicate a fitting regression, not a
280/// mismatch against external truth.
281///
282/// The longitude difference is wrapped to ±180° before conversion to arcseconds,
283/// exactly as in [`accuracy_baseline_against`].
284#[allow(dead_code)]
285pub(crate) fn eros_self_consistency_max_longitude_arcsec() -> f64 {
286    let eros_body = CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros"));
287    let artifact = build_packaged_artifact();
288    let mut max_lon_arcsec: f64 = 0.0;
289
290    for entry in reference_snapshot() {
291        if entry.body != eros_body {
292            continue;
293        }
294        let lookup_instant = normalize_lookup_instant(entry.epoch);
295        let artifact_coords = match artifact.lookup_ecliptic(&eros_body, lookup_instant) {
296            Ok(c) => c,
297            Err(_) => continue,
298        };
299        let snapshot_coords = coordinates(entry);
300
301        // Longitude diff wrapped to ±180°, then to arcseconds — reuses the same
302        // idiom as accuracy_baseline_against.
303        let lon_diff_deg = Angle::from_degrees(
304            artifact_coords.longitude.degrees() - snapshot_coords.longitude.degrees(),
305        )
306        .normalized_signed()
307        .degrees();
308        let lon_arcsec = lon_diff_deg.abs() * 3600.0;
309        max_lon_arcsec = max_lon_arcsec.max(lon_arcsec);
310    }
311
312    max_lon_arcsec
313}
314
315#[cfg(test)]
316mod tests {
317    use pleiades_backend::{Instant, JulianDay, TimeScale};
318    use pleiades_compression::{
319        ArtifactHeader, BodyArtifact, ChannelKind, CompressedArtifact, PolynomialChannel, Segment,
320    };
321
322    use super::*;
323
324    /// Synthetic holdout with a known ecliptic velocity for the Sun at J2000.
325    ///
326    /// Position: Sun at (1 AU, 0, 0) → lon=0°, lat=0°, dist=1 AU.
327    /// Velocity: (0, vy_km_s, 0) in km/s where vy_km_s = 0.01 AU/day in km/s.
328    ///
329    /// At this position (x=1, y=0, z=0):
330    ///   dλ/dt = (x·vy − y·vx) / (x²+y²) = vy = 0.01 AU/day in rad/day
331    ///   dβ/dt = (ρ·vz − z·ρ̇) / r²       = 0 rad/day
332    ///   dr/dt = (x·vx + y·vy + z·vz) / r  = 0 AU/day
333    fn synthetic_holdout_with_velocity() -> Vec<SnapshotEntry> {
334        let au = AU_IN_KM;
335        // vy = 0.01 AU/day converted to km/s: 0.01 * AU_IN_KM / 86400
336        let vy_km_s = 0.01 * au / 86400.0;
337        vec![SnapshotEntry {
338            body: CelestialBody::Sun,
339            epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
340            x_km: au,
341            y_km: 0.0,
342            z_km: 0.0,
343            vx_km_s: Some(0.0),
344            vy_km_s: Some(vy_km_s),
345            vz_km_s: Some(0.0),
346        }]
347    }
348
349    /// Synthetic artifact with a linear longitude segment matching the known velocity.
350    ///
351    /// The Sun longitude increases linearly over 10 days from 0° to 0.01.to_degrees()*10°,
352    /// so the analytic derivative dλ/dt = 0.01.to_degrees() deg/day = 0.01 rad/day ✓.
353    /// Latitude and distance are constant, matching dβ/dt=0 and dr/dt=0.
354    fn synthetic_artifact_linear() -> CompressedArtifact {
355        let jd0 = 2_451_545.0_f64;
356        let span_days = 10.0_f64;
357        let start = Instant::new(JulianDay::from_days(jd0), TimeScale::Tt);
358        let end = Instant::new(JulianDay::from_days(jd0 + span_days), TimeScale::Tt);
359
360        // lon rate = 0.01 rad/day → deg/day = 0.01 * (180/π)
361        // over 10 days: lon goes from 0° to 0.01 * (180/π) * 10°
362        let lon_rate_deg_per_day = 0.01_f64.to_degrees();
363        let lon_end = lon_rate_deg_per_day * span_days;
364
365        let segment = Segment::new(
366            start,
367            end,
368            vec![
369                PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, lon_end),
370                PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
371                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
372            ],
373        );
374        CompressedArtifact::new(
375            ArtifactHeader::new(
376                "synthetic-linear-test",
377                "synthetic linear velocity test source",
378            ),
379            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
380        )
381    }
382
383    fn synthetic_holdout() -> Vec<SnapshotEntry> {
384        // Single Sun entry at J2000.0 in ecliptic Cartesian: place Sun at 1 AU along x-axis,
385        // giving lon=0°, lat=0°, dist=1 AU.
386        let au = AU_IN_KM;
387        vec![SnapshotEntry {
388            body: CelestialBody::Sun,
389            epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
390            x_km: au,
391            y_km: 0.0,
392            z_km: 0.0,
393            vx_km_s: None,
394            vy_km_s: None,
395            vz_km_s: None,
396        }]
397    }
398
399    fn synthetic_artifact() -> CompressedArtifact {
400        // Build an artifact whose Sun segment reproduces the same lon/lat/dist exactly.
401        let jd = JulianDay::from_days(2_451_545.0);
402        let instant = Instant::new(jd, TimeScale::Tt);
403        // lon=0°, lat=0°, dist_au=1.0 — constant segment.
404        let segment = Segment::new(
405            instant,
406            instant,
407            vec![
408                PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 0.0),
409                PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
410                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
411            ],
412        );
413        CompressedArtifact::new(
414            ArtifactHeader::new("synthetic-test", "synthetic test source"),
415            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
416        )
417    }
418
419    #[test]
420    fn baseline_reports_speed_error_fields() {
421        let errors = accuracy_baseline_against(
422            &synthetic_holdout_with_velocity(),
423            &synthetic_artifact_linear(),
424        );
425        assert_eq!(
426            errors.len(),
427            1,
428            "expected exactly 1 body in synthetic speed baseline"
429        );
430        let sun = &errors[0];
431        assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
432        // Speed error must be near-zero (artifact derivative exactly matches truth velocity).
433        assert!(
434            sun.max_lon_speed_arcsec_per_day < 1e-3,
435            "Sun max longitude speed error too large: {} arcsec/day",
436            sun.max_lon_speed_arcsec_per_day
437        );
438        assert!(
439            sun.max_lat_speed_arcsec_per_day < 1e-3,
440            "Sun max latitude speed error too large: {} arcsec/day",
441            sun.max_lat_speed_arcsec_per_day
442        );
443        assert!(
444            sun.max_radial_speed_au_per_day < 1e-6,
445            "Sun max radial speed error too large: {} AU/day",
446            sun.max_radial_speed_au_per_day
447        );
448    }
449
450    #[test]
451    fn baseline_reports_zero_error_for_an_artifact_that_matches_holdout() {
452        let errors = accuracy_baseline_against(&synthetic_holdout(), &synthetic_artifact());
453        // Should have exactly one body (Sun) with ~zero errors.
454        assert_eq!(
455            errors.len(),
456            1,
457            "expected exactly 1 body in synthetic baseline"
458        );
459        let sun = &errors[0];
460        assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
461        assert!(
462            sun.max_longitude_arcsec < 1e-3,
463            "Sun max longitude error too large: {} arcsec",
464            sun.max_longitude_arcsec
465        );
466        assert!(
467            sun.max_latitude_arcsec < 1e-3,
468            "Sun max latitude error too large: {} arcsec",
469            sun.max_latitude_arcsec
470        );
471        assert!(
472            sun.max_distance_km < 1.0,
473            "Sun max distance error too large: {} km",
474            sun.max_distance_km
475        );
476    }
477
478    #[test]
479    fn baseline_excludes_body_when_all_lookups_fail() {
480        // Vacuity guard: if the artifact has NO segment for the holdout body, the body
481        // must be absent from results rather than silently appearing as zero error.
482        let holdout = synthetic_holdout(); // Sun at J2000.0
483                                           // Empty artifact — lookup_ecliptic will fail with MissingBody for Sun.
484        let empty_artifact = CompressedArtifact::new(
485            ArtifactHeader::new("empty-test", "empty test source"),
486            vec![],
487        );
488        let errors = accuracy_baseline_against(&holdout, &empty_artifact);
489        assert!(
490            errors.is_empty(),
491            "vacuity guard must exclude bodies with zero successful comparisons; got {} entries",
492            errors.len()
493        );
494    }
495
496    // Runtime: ~1.7 s (decodes 47.5 MB artifact + 500-row hold-out). Not ignored.
497    #[test]
498    fn packaged_artifact_baseline_is_non_vacuous() {
499        // Regression guard against the Tdb/Tt scale-mismatch vacuity bug.
500        // All 10 base bodies must be present with count>0; inner bodies and luminaries
501        // must be sub-arcsec; at least one outer planet must show a clearly non-zero
502        // longitude error (proving the baseline is not vacuous).
503        let errors = packaged_artifact_accuracy_baseline();
504
505        // (a) All 10 base bodies present.
506        let expected_bodies = [
507            CelestialBody::Sun,
508            CelestialBody::Moon,
509            CelestialBody::Mercury,
510            CelestialBody::Venus,
511            CelestialBody::Mars,
512            CelestialBody::Jupiter,
513            CelestialBody::Saturn,
514            CelestialBody::Uranus,
515            CelestialBody::Neptune,
516            CelestialBody::Pluto,
517        ];
518        assert_eq!(
519            errors.len(),
520            10,
521            "expected 10 base bodies in packaged baseline; got {}: {:?}",
522            errors.len(),
523            errors
524                .iter()
525                .map(|e| format!("{:?}", e.body))
526                .collect::<Vec<_>>()
527        );
528        for body in &expected_bodies {
529            let entry = errors
530                .iter()
531                .find(|e| &e.body == body)
532                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
533            assert!(
534                entry.comparison_count > 0,
535                "{body:?} must have at least one successful comparison (got 0 — vacuous baseline)"
536            );
537        }
538
539        // (b) Inner bodies + luminaries must be sub-arcsec in longitude.
540        let sub_arcsec_bodies = [
541            CelestialBody::Sun,
542            CelestialBody::Moon,
543            CelestialBody::Mercury,
544            CelestialBody::Venus,
545            CelestialBody::Mars,
546        ];
547        for body in &sub_arcsec_bodies {
548            let entry = errors.iter().find(|e| &e.body == body).unwrap();
549            assert!(
550                entry.max_longitude_arcsec < 1.0,
551                "{body:?} max longitude error must be <1 arcsec (got {:.6}\")",
552                entry.max_longitude_arcsec
553            );
554        }
555
556        // (b2) SP2: outer planets stored heliocentrically — all must also be sub-arcsec.
557        let outer_bodies = [
558            CelestialBody::Jupiter,
559            CelestialBody::Saturn,
560            CelestialBody::Uranus,
561            CelestialBody::Neptune,
562            CelestialBody::Pluto,
563        ];
564        for body in &outer_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 after SP2 heliocentric reframe (got {:.6}\")",
569                entry.max_longitude_arcsec
570            );
571        }
572
573        // (c) Non-vacuity anchor: Uranus must be non-zero (baseline is not vacuous)
574        // and sub-arcsec (heliocentric reframe is active).
575        let uranus = errors
576            .iter()
577            .find(|e| e.body == CelestialBody::Uranus)
578            .expect("Uranus must appear in the packaged baseline");
579        assert!(
580            uranus.max_longitude_arcsec > 0.0001,
581            "Uranus max longitude error must be >0.0001\" (got {:.6}\" — baseline may be vacuous)",
582            uranus.max_longitude_arcsec
583        );
584        assert!(
585            uranus.max_longitude_arcsec < 1.0,
586            "Uranus max longitude error must be <1\" after SP2 heliocentric reframe (got {:.4}\" — reframe may be broken)",
587            uranus.max_longitude_arcsec
588        );
589    }
590
591    // Astrology-grade longitude envelope gate (SP2).
592    // Outer planets are allowed up to 5.0″; inner bodies/luminaries up to 1.0″.
593    // With the v7 heliocentric artifact all bodies are sub-arcsec, so this
594    // passes with large margin — the 5.0″ ceiling guards against a future
595    // regression, not against the current state.
596    // Note: (b2) in packaged_artifact_baseline_is_non_vacuous already asserts
597    // outer planets < 1.0″, which is STRICTER than this gate's 5.0″ ceiling.
598    // The two tests are complementary, not redundant: this test encodes the
599    // published astrology-grade specification, while (b2) is an operational
600    // regression guard at the tighter SP2-achieved level.
601    #[test]
602    fn outer_planet_longitude_meets_astrology_grade_envelope() {
603        let baseline = crate::accuracy_baseline::packaged_artifact_accuracy_baseline();
604        // Astrology-grade longitude ceilings drawn from the published SSOT.
605        // accuracy_ceiling returns 1.0" for Luminary/InnerPlanet, 5.0" for
606        // OuterPlanet, and 30.0" for Asteroid — identical to the old inline
607        // match, so no threshold is loosened.
608        for body_error in &baseline {
609            let c = crate::thresholds::accuracy_ceiling(&body_error.body).lon_arcsec;
610            assert!(
611                body_error.max_longitude_arcsec <= c,
612                "{:?} longitude {:.3}\" exceeds ceiling {:.1}\"",
613                body_error.body,
614                body_error.max_longitude_arcsec,
615                c
616            );
617        }
618    }
619
620    // Hard accuracy-ceiling gate (SP3, Task 11): all 6 channels for every body in
621    // the baseline must be within their published ceilings from thresholds.rs.
622    // Currently passing with large margin (measured << ceiling), so this guards
623    // against future regressions rather than reflecting the current tight state.
624    #[test]
625    fn all_channels_within_published_ceilings_for_major_bodies() {
626        let baseline = packaged_artifact_accuracy_baseline();
627        for e in &baseline {
628            let c = crate::thresholds::accuracy_ceiling(&e.body);
629            assert!(
630                e.max_longitude_arcsec <= c.lon_arcsec,
631                "{:?} lon {:.4}\" exceeds ceiling {:.1}\"",
632                e.body,
633                e.max_longitude_arcsec,
634                c.lon_arcsec
635            );
636            assert!(
637                e.max_latitude_arcsec <= c.lat_arcsec,
638                "{:?} lat {:.4}\" exceeds ceiling {:.1}\"",
639                e.body,
640                e.max_latitude_arcsec,
641                c.lat_arcsec
642            );
643            assert!(
644                e.max_distance_km <= c.dist_km,
645                "{:?} dist {:.3} km exceeds ceiling {:.0} km",
646                e.body,
647                e.max_distance_km,
648                c.dist_km
649            );
650            assert!(
651                e.max_lon_speed_arcsec_per_day <= c.lon_speed_arcsec_per_day,
652                "{:?} lon speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
653                e.body,
654                e.max_lon_speed_arcsec_per_day,
655                c.lon_speed_arcsec_per_day
656            );
657            assert!(
658                e.max_lat_speed_arcsec_per_day <= c.lat_speed_arcsec_per_day,
659                "{:?} lat speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
660                e.body,
661                e.max_lat_speed_arcsec_per_day,
662                c.lat_speed_arcsec_per_day
663            );
664            assert!(
665                e.max_radial_speed_au_per_day <= c.radial_speed_au_per_day,
666                "{:?} radial speed {:.6} AU/day exceeds ceiling {:.2e} AU/day",
667                e.body,
668                e.max_radial_speed_au_per_day,
669                c.radial_speed_au_per_day
670            );
671        }
672    }
673
674    // Size-budget gate (SP3, Task 11): the committed packaged artifact must not exceed
675    // the published encoded-bytes budget from PACKAGED_BUDGETS.
676    #[test]
677    fn encoded_artifact_within_size_budget() {
678        let bytes_len = crate::data::packaged_artifact_bytes().len();
679        assert!(
680            bytes_len <= crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes,
681            "encoded artifact {} bytes exceeds budget {} bytes",
682            bytes_len,
683            crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes
684        );
685    }
686
687    // Non-vacuity guard for speed channels (SP3, Task 11).
688    //
689    // The speed accumulator has no velocity-row counter.  A body whose velocity
690    // rows were all skipped (e.g. because lookup_motion returned Err or the corpus
691    // lacked velocity data) would keep max_*_speed = 0.0 and pass the ceiling
692    // gate VACUOUSLY.  This guard catches that: every major planet (excluding the
693    // Moon, whose min non-zero would be below our threshold on some corpora) must
694    // show a STRICTLY POSITIVE lon-speed error magnitude above a small but
695    // non-zero floor (0.0001 arcsec/day), well below the smallest observed real
696    // error (~0.0002 arcsec/day for Jupiter/Saturn/Neptune) but clearly above
697    // exact zero (which would only arise from a silently-all-skipped velocity
698    // channel).
699    //
700    // Moon is guarded separately with a higher floor (0.005 arcsec/day) because
701    // its faster apparent motion means any real measurement would be >>0.005.
702    #[test]
703    fn speed_channels_are_non_vacuous_for_major_bodies() {
704        let errors = packaged_artifact_accuracy_baseline();
705
706        // Inner planets + luminaries: lon-speed must be > 0.0001 arcsec/day.
707        let inner_bodies = [
708            CelestialBody::Sun,
709            CelestialBody::Mercury,
710            CelestialBody::Venus,
711            CelestialBody::Mars,
712        ];
713        for body in &inner_bodies {
714            let e = errors
715                .iter()
716                .find(|e| &e.body == body)
717                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
718            assert!(
719                e.max_lon_speed_arcsec_per_day > 0.0001,
720                "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
721                e.max_lon_speed_arcsec_per_day
722            );
723        }
724
725        // Moon: higher floor (real motion >> 0.005 arcsec/day).
726        let moon = errors
727            .iter()
728            .find(|e| e.body == CelestialBody::Moon)
729            .expect("Moon must appear in the packaged baseline");
730        assert!(
731            moon.max_lon_speed_arcsec_per_day > 0.005,
732            "Moon max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
733            moon.max_lon_speed_arcsec_per_day
734        );
735
736        // Outer planets: lon-speed must be > 0.0001 arcsec/day.
737        let outer_bodies = [
738            CelestialBody::Jupiter,
739            CelestialBody::Saturn,
740            CelestialBody::Uranus,
741            CelestialBody::Neptune,
742            CelestialBody::Pluto,
743        ];
744        for body in &outer_bodies {
745            let e = errors
746                .iter()
747                .find(|e| &e.body == body)
748                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
749            assert!(
750                e.max_lon_speed_arcsec_per_day > 0.0001,
751                "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
752                e.max_lon_speed_arcsec_per_day
753            );
754        }
755    }
756
757    #[test]
758    #[ignore = "maintainer helper: prints the Eros self-consistency max longitude error"]
759    fn print_eros_self_consistency_max_longitude_arcsec() {
760        let v = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
761        eprintln!("EROS_SELF_CONSISTENCY_MAX_LON_ARCSEC = {v:.6}\"");
762    }
763
764    /// Eros self-consistency gate (SP3, Task 12).
765    ///
766    /// Eros is re-derived from the committed reference snapshot (no independent truth —
767    /// it is absent from de440). This is a SELF-CONSISTENCY check, NOT an independent-truth
768    /// gate. It verifies the artifact faithfully reproduces the snapshot it was fit from,
769    /// within the published Asteroid-class longitude ceiling (30″).
770    #[test]
771    fn eros_round_trips_against_its_reference_snapshot_within_documented_target() {
772        let eros_body = pleiades_backend::CelestialBody::Custom(
773            pleiades_backend::CustomBodyId::new("asteroid", "433-Eros"),
774        );
775        // Non-vacuity guard: the reference snapshot must actually contain Eros rows,
776        // otherwise the helper iterates zero rows and trivially returns 0.0 — a pass
777        // that reveals nothing. A missing Eros corpus is a configuration error, not
778        // "perfect accuracy".
779        let eros_row_count = pleiades_jpl::reference_snapshot()
780            .iter()
781            .filter(|e| e.body == eros_body)
782            .count();
783        assert!(
784            eros_row_count > 0,
785            "reference snapshot contains no Eros rows — self-consistency check would be vacuous (iterate zero rows → max=0.0 → trivially passes)"
786        );
787
788        let ceiling = crate::thresholds::accuracy_ceiling(&eros_body);
789        let max_lon_arcsec = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
790        assert!(
791            max_lon_arcsec <= ceiling.lon_arcsec,
792            "Eros self-consistency {max_lon_arcsec:.4}\" > {:.1}\" (artifact does not reproduce the reference snapshot it was fit from within the Asteroid-class ceiling)",
793            ceiling.lon_arcsec
794        );
795    }
796}