pleiades-data 0.5.4

Packaged offline ephemeris data (precomputed Sun, Moon, Mercury through Pluto and 433 Eros positions, derived from JPL public-domain ephemerides) and its EphemerisBackend for the pleiades astrology workspace.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
//! Per-body accuracy baseline: decoded committed artifact vs hold-out corpus.
//!
//! Compares every hold-out row against the committed packaged artifact at the same
//! epoch and body, accumulating per-body max and RMS errors in longitude (arcsec),
//! latitude (arcsec), and distance (km).  These numbers are the SP1 deliverable
//! that scopes SP2 accuracy targets.

use std::collections::HashMap;

use pleiades_backend::{Angle, CelestialBody, CustomBodyId};
use pleiades_compression::{cartesian_state_to_spherical, CartesianState, CompressedArtifact};
use pleiades_jpl::{production_holdout_corpus, reference_snapshot, SnapshotEntry};

use crate::regenerate::{build_packaged_artifact, coordinates, normalize_lookup_instant};
use crate::AU_IN_KM;

/// Per-body accuracy summary comparing the artifact to an independent hold-out corpus.
#[derive(Clone, Debug)]
pub struct BodyChannelError {
    /// Body these errors apply to.
    pub body: CelestialBody,
    /// Number of hold-out rows that were successfully compared for this body.
    /// A value of zero would indicate a vacuous baseline (no rows matched), but
    /// `accuracy_baseline_against` excludes bodies with zero comparisons entirely.
    pub comparison_count: usize,
    /// Maximum absolute longitude error across all hold-out rows for this body (arcseconds).
    pub max_longitude_arcsec: f64,
    /// Root-mean-square longitude error across all hold-out rows for this body (arcseconds).
    pub rms_longitude_arcsec: f64,
    /// Maximum absolute latitude error across all hold-out rows for this body (arcseconds).
    pub max_latitude_arcsec: f64,
    /// Root-mean-square latitude error across all hold-out rows for this body (arcseconds).
    pub rms_latitude_arcsec: f64,
    /// Maximum absolute distance error across all hold-out rows for this body (km).
    pub max_distance_km: f64,
    /// Root-mean-square distance error across all hold-out rows for this body (km).
    pub rms_distance_km: f64,
    /// Maximum absolute longitude speed error across hold-out rows that have velocity truth (arcsec/day).
    /// Zero when no velocity-bearing rows were compared for this body.
    pub max_lon_speed_arcsec_per_day: f64,
    /// Maximum absolute latitude speed error across hold-out rows that have velocity truth (arcsec/day).
    /// Zero when no velocity-bearing rows were compared for this body.
    pub max_lat_speed_arcsec_per_day: f64,
    /// Maximum absolute radial speed error across hold-out rows that have velocity truth (AU/day).
    /// Zero when no velocity-bearing rows were compared for this body.
    pub max_radial_speed_au_per_day: f64,
}

impl BodyChannelError {
    fn label(&self) -> String {
        format!("{:?}", self.body)
    }

    /// Returns a compact one-line summary for this body's errors.
    pub fn summary_line(&self) -> String {
        format!(
            "{}: 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",
            self.label(),
            self.comparison_count,
            self.max_longitude_arcsec,
            self.rms_longitude_arcsec,
            self.max_latitude_arcsec,
            self.rms_latitude_arcsec,
            self.max_distance_km,
            self.rms_distance_km,
            self.max_lon_speed_arcsec_per_day,
            self.max_lat_speed_arcsec_per_day,
            self.max_radial_speed_au_per_day,
        )
    }
}

struct BodyAccumulator {
    body: CelestialBody,
    count: usize,
    max_lon_arcsec: f64,
    sum_sq_lon_arcsec: f64,
    max_lat_arcsec: f64,
    sum_sq_lat_arcsec: f64,
    max_dist_km: f64,
    sum_sq_dist_km: f64,
    max_lon_speed_arcsec_per_day: f64,
    max_lat_speed_arcsec_per_day: f64,
    max_radial_speed_au_per_day: f64,
}

impl BodyAccumulator {
    fn new(body: CelestialBody) -> Self {
        Self {
            body,
            count: 0,
            max_lon_arcsec: 0.0,
            sum_sq_lon_arcsec: 0.0,
            max_lat_arcsec: 0.0,
            sum_sq_lat_arcsec: 0.0,
            max_dist_km: 0.0,
            sum_sq_dist_km: 0.0,
            max_lon_speed_arcsec_per_day: 0.0,
            max_lat_speed_arcsec_per_day: 0.0,
            max_radial_speed_au_per_day: 0.0,
        }
    }

    fn accumulate(&mut self, lon_arcsec: f64, lat_arcsec: f64, dist_km: Option<f64>) {
        self.count += 1;
        self.max_lon_arcsec = self.max_lon_arcsec.max(lon_arcsec);
        self.sum_sq_lon_arcsec += lon_arcsec * lon_arcsec;
        self.max_lat_arcsec = self.max_lat_arcsec.max(lat_arcsec);
        self.sum_sq_lat_arcsec += lat_arcsec * lat_arcsec;
        if let Some(d) = dist_km {
            self.max_dist_km = self.max_dist_km.max(d);
            self.sum_sq_dist_km += d * d;
        }
    }

    fn accumulate_speed(
        &mut self,
        lon_speed_arcsec_per_day: f64,
        lat_speed_arcsec_per_day: f64,
        radial_speed_au_per_day: f64,
    ) {
        self.max_lon_speed_arcsec_per_day = self
            .max_lon_speed_arcsec_per_day
            .max(lon_speed_arcsec_per_day);
        self.max_lat_speed_arcsec_per_day = self
            .max_lat_speed_arcsec_per_day
            .max(lat_speed_arcsec_per_day);
        self.max_radial_speed_au_per_day = self
            .max_radial_speed_au_per_day
            .max(radial_speed_au_per_day);
    }

    fn finish(self) -> BodyChannelError {
        let n = self.count as f64;
        let rms = |sum_sq: f64| if n > 0.0 { (sum_sq / n).sqrt() } else { 0.0 };
        BodyChannelError {
            body: self.body,
            comparison_count: self.count,
            max_longitude_arcsec: self.max_lon_arcsec,
            rms_longitude_arcsec: rms(self.sum_sq_lon_arcsec),
            max_latitude_arcsec: self.max_lat_arcsec,
            rms_latitude_arcsec: rms(self.sum_sq_lat_arcsec),
            max_distance_km: self.max_dist_km,
            rms_distance_km: rms(self.sum_sq_dist_km),
            max_lon_speed_arcsec_per_day: self.max_lon_speed_arcsec_per_day,
            max_lat_speed_arcsec_per_day: self.max_lat_speed_arcsec_per_day,
            max_radial_speed_au_per_day: self.max_radial_speed_au_per_day,
        }
    }
}

/// Computes per-body accuracy errors between a hold-out slice and an artifact.
///
/// For each hold-out row the artifact is queried at the same epoch and body.
/// Rows where the artifact lookup fails (body missing or out of range) are skipped
/// silently — but if ALL rows for a body are skipped, that body is excluded from the
/// result rather than silently reporting zero error.  A caller asserting non-zero
/// error on a specific body will detect a vacuous baseline.
///
/// The lookup instant is normalized to `TimeScale::Tt` (same julian_day, Tdb
/// relabelled Tt) to match the committed artifact's segment tag convention.
/// `normalize_lookup_instant` mirrors the runtime packaged-lookup path and
/// `Segment::contains` requires scale equality.
///
/// Longitude differences are wrapped to ±180° before conversion to arcseconds.
/// Latitude differences are taken directly.  Distance differences are converted
/// from AU to km.
pub fn accuracy_baseline_against(
    holdout: &[SnapshotEntry],
    artifact: &CompressedArtifact,
) -> Vec<BodyChannelError> {
    let mut accumulators: HashMap<String, BodyAccumulator> = HashMap::new();

    for entry in holdout {
        let key = format!("{:?}", entry.body);
        let acc = accumulators
            .entry(key)
            .or_insert_with(|| BodyAccumulator::new(entry.body.clone()));

        // Normalize to Tt (same julian_day) — the committed artifact's segment
        // boundaries are Tt-tagged; Segment::contains requires scale equality.
        let lookup_instant = normalize_lookup_instant(entry.epoch);

        let artifact_result = artifact.lookup_ecliptic(&entry.body, lookup_instant);
        let artifact_coords = match artifact_result {
            Ok(coords) => coords,
            Err(_) => continue, // body missing or out of range — skip this row
        };

        let holdout_coords = coordinates(entry);

        // Longitude diff wrapped to ±180°, then to arcseconds.
        let lon_diff_deg = Angle::from_degrees(
            artifact_coords.longitude.degrees() - holdout_coords.longitude.degrees(),
        )
        .normalized_signed()
        .degrees();
        let lon_arcsec = lon_diff_deg.abs() * 3600.0;

        // Latitude diff (no wrapping needed, bounded ±90°).
        let lat_arcsec =
            (artifact_coords.latitude.degrees() - holdout_coords.latitude.degrees()).abs() * 3600.0;

        // Distance diff in km; None when either side has no distance.
        // Skip the distance contribution for this row rather than silently
        // accumulating 0.0 (which would mask a future regression).
        let dist_km = match (artifact_coords.distance_au, holdout_coords.distance_au) {
            (Some(a), Some(h)) => Some((a - h).abs() * AU_IN_KM),
            _ => None,
        };

        acc.accumulate(lon_arcsec, lat_arcsec, dist_km);

        // Speed error: only for rows that carry velocity truth.
        if let (Some(vx), Some(vy), Some(vz)) = (entry.vx_km_s, entry.vy_km_s, entry.vz_km_s) {
            // Convert hold-out Cartesian position (km) + velocity (km/s) to AU and AU/day.
            let pos_au = [
                entry.x_km / AU_IN_KM,
                entry.y_km / AU_IN_KM,
                entry.z_km / AU_IN_KM,
            ];
            let vel_au_per_day = [
                vx * 86400.0 / AU_IN_KM,
                vy * 86400.0 / AU_IN_KM,
                vz * 86400.0 / AU_IN_KM,
            ];
            let truth_spherical = cartesian_state_to_spherical(CartesianState {
                pos_au,
                vel_au_per_day,
            });

            // truth rates are in rad/day (lon/lat) and AU/day (radial) — convert lon/lat to deg/day.
            let truth_lon_deg_per_day = truth_spherical.lon_rate_rad_per_day.to_degrees();
            let truth_lat_deg_per_day = truth_spherical.lat_rate_rad_per_day.to_degrees();
            let truth_radial_au_per_day = truth_spherical.dist_rate_au_per_day;

            if let Ok(motion) = artifact.lookup_motion(&entry.body, lookup_instant) {
                let art_lon = motion.longitude_deg_per_day.unwrap_or(0.0);
                let art_lat = motion.latitude_deg_per_day.unwrap_or(0.0);
                let art_radial = motion.distance_au_per_day.unwrap_or(0.0);

                let lon_speed_arcsec = (art_lon - truth_lon_deg_per_day).abs() * 3600.0;
                let lat_speed_arcsec = (art_lat - truth_lat_deg_per_day).abs() * 3600.0;
                let radial_speed_au = (art_radial - truth_radial_au_per_day).abs();

                acc.accumulate_speed(lon_speed_arcsec, lat_speed_arcsec, radial_speed_au);
            }
        }
    }

    // Vacuity guard: only emit bodies where at least one row was successfully compared.
    // Bodies whose every lookup failed (e.g. due to a scale mismatch) are excluded rather
    // than silently emitted as all-zero error — zero here means "not measured", not "perfect".
    let mut results: Vec<BodyChannelError> = accumulators
        .into_values()
        .filter(|acc| acc.count > 0)
        .map(|acc| acc.finish())
        .collect();
    results.sort_by_key(|a| a.label());
    results
}

/// Computes per-body accuracy errors for the committed packaged artifact against
/// the production hold-out corpus.
///
/// This is the SP1 baseline deliverable.
pub fn packaged_artifact_accuracy_baseline() -> Vec<BodyChannelError> {
    let holdout = production_holdout_corpus();
    let artifact = build_packaged_artifact();
    accuracy_baseline_against(holdout, &artifact)
}

/// Computes the maximum absolute longitude error (arcsec) between the committed
/// packaged artifact and the Eros reference-snapshot rows it was fit from.
///
/// Eros has no independent hold-out truth (its curated 1900–2100 corpus is the
/// only source — it is not in de440). This helper is therefore a SELF-CONSISTENCY
/// check: it measures how faithfully the artifact reproduces the snapshot it was
/// derived from. A large value here would indicate a fitting regression, not a
/// mismatch against external truth.
///
/// The longitude difference is wrapped to ±180° before conversion to arcseconds,
/// exactly as in [`accuracy_baseline_against`].
#[allow(dead_code)]
pub(crate) fn eros_self_consistency_max_longitude_arcsec() -> f64 {
    let eros_body = CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros"));
    let artifact = build_packaged_artifact();
    let mut max_lon_arcsec: f64 = 0.0;

    for entry in reference_snapshot() {
        if entry.body != eros_body {
            continue;
        }
        let lookup_instant = normalize_lookup_instant(entry.epoch);
        let artifact_coords = match artifact.lookup_ecliptic(&eros_body, lookup_instant) {
            Ok(c) => c,
            Err(_) => continue,
        };
        let snapshot_coords = coordinates(entry);

        // Longitude diff wrapped to ±180°, then to arcseconds — reuses the same
        // idiom as accuracy_baseline_against.
        let lon_diff_deg = Angle::from_degrees(
            artifact_coords.longitude.degrees() - snapshot_coords.longitude.degrees(),
        )
        .normalized_signed()
        .degrees();
        let lon_arcsec = lon_diff_deg.abs() * 3600.0;
        max_lon_arcsec = max_lon_arcsec.max(lon_arcsec);
    }

    max_lon_arcsec
}

#[cfg(test)]
mod tests {
    use pleiades_backend::{Instant, JulianDay, TimeScale};
    use pleiades_compression::{
        ArtifactHeader, BodyArtifact, ChannelKind, CompressedArtifact, PolynomialChannel, Segment,
    };

    use super::*;

    /// Synthetic holdout with a known ecliptic velocity for the Sun at J2000.
    ///
    /// Position: Sun at (1 AU, 0, 0) → lon=0°, lat=0°, dist=1 AU.
    /// Velocity: (0, vy_km_s, 0) in km/s where vy_km_s = 0.01 AU/day in km/s.
    ///
    /// At this position (x=1, y=0, z=0):
    ///   dλ/dt = (x·vy − y·vx) / (x²+y²) = vy = 0.01 AU/day in rad/day
    ///   dβ/dt = (ρ·vz − z·ρ̇) / r²       = 0 rad/day
    ///   dr/dt = (x·vx + y·vy + z·vz) / r  = 0 AU/day
    fn synthetic_holdout_with_velocity() -> Vec<SnapshotEntry> {
        let au = AU_IN_KM;
        // vy = 0.01 AU/day converted to km/s: 0.01 * AU_IN_KM / 86400
        let vy_km_s = 0.01 * au / 86400.0;
        vec![SnapshotEntry {
            body: CelestialBody::Sun,
            epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
            x_km: au,
            y_km: 0.0,
            z_km: 0.0,
            vx_km_s: Some(0.0),
            vy_km_s: Some(vy_km_s),
            vz_km_s: Some(0.0),
        }]
    }

    /// Synthetic artifact with a linear longitude segment matching the known velocity.
    ///
    /// The Sun longitude increases linearly over 10 days from 0° to 0.01.to_degrees()*10°,
    /// so the analytic derivative dλ/dt = 0.01.to_degrees() deg/day = 0.01 rad/day ✓.
    /// Latitude and distance are constant, matching dβ/dt=0 and dr/dt=0.
    fn synthetic_artifact_linear() -> CompressedArtifact {
        let jd0 = 2_451_545.0_f64;
        let span_days = 10.0_f64;
        let start = Instant::new(JulianDay::from_days(jd0), TimeScale::Tt);
        let end = Instant::new(JulianDay::from_days(jd0 + span_days), TimeScale::Tt);

        // lon rate = 0.01 rad/day → deg/day = 0.01 * (180/π)
        // over 10 days: lon goes from 0° to 0.01 * (180/π) * 10°
        let lon_rate_deg_per_day = 0.01_f64.to_degrees();
        let lon_end = lon_rate_deg_per_day * span_days;

        let segment = Segment::new(
            start,
            end,
            vec![
                PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, lon_end),
                PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
            ],
        );
        CompressedArtifact::new(
            ArtifactHeader::new(
                "synthetic-linear-test",
                "synthetic linear velocity test source",
            ),
            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
        )
    }

    fn synthetic_holdout() -> Vec<SnapshotEntry> {
        // Single Sun entry at J2000.0 in ecliptic Cartesian: place Sun at 1 AU along x-axis,
        // giving lon=0°, lat=0°, dist=1 AU.
        let au = AU_IN_KM;
        vec![SnapshotEntry {
            body: CelestialBody::Sun,
            epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
            x_km: au,
            y_km: 0.0,
            z_km: 0.0,
            vx_km_s: None,
            vy_km_s: None,
            vz_km_s: None,
        }]
    }

    fn synthetic_artifact() -> CompressedArtifact {
        // Build an artifact whose Sun segment reproduces the same lon/lat/dist exactly.
        let jd = JulianDay::from_days(2_451_545.0);
        let instant = Instant::new(jd, TimeScale::Tt);
        // lon=0°, lat=0°, dist_au=1.0 — constant segment.
        let segment = Segment::new(
            instant,
            instant,
            vec![
                PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 0.0),
                PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
            ],
        );
        CompressedArtifact::new(
            ArtifactHeader::new("synthetic-test", "synthetic test source"),
            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
        )
    }

    #[test]
    fn baseline_reports_speed_error_fields() {
        let errors = accuracy_baseline_against(
            &synthetic_holdout_with_velocity(),
            &synthetic_artifact_linear(),
        );
        assert_eq!(
            errors.len(),
            1,
            "expected exactly 1 body in synthetic speed baseline"
        );
        let sun = &errors[0];
        assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
        // Speed error must be near-zero (artifact derivative exactly matches truth velocity).
        assert!(
            sun.max_lon_speed_arcsec_per_day < 1e-3,
            "Sun max longitude speed error too large: {} arcsec/day",
            sun.max_lon_speed_arcsec_per_day
        );
        assert!(
            sun.max_lat_speed_arcsec_per_day < 1e-3,
            "Sun max latitude speed error too large: {} arcsec/day",
            sun.max_lat_speed_arcsec_per_day
        );
        assert!(
            sun.max_radial_speed_au_per_day < 1e-6,
            "Sun max radial speed error too large: {} AU/day",
            sun.max_radial_speed_au_per_day
        );
    }

    #[test]
    fn baseline_reports_zero_error_for_an_artifact_that_matches_holdout() {
        let errors = accuracy_baseline_against(&synthetic_holdout(), &synthetic_artifact());
        // Should have exactly one body (Sun) with ~zero errors.
        assert_eq!(
            errors.len(),
            1,
            "expected exactly 1 body in synthetic baseline"
        );
        let sun = &errors[0];
        assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
        assert!(
            sun.max_longitude_arcsec < 1e-3,
            "Sun max longitude error too large: {} arcsec",
            sun.max_longitude_arcsec
        );
        assert!(
            sun.max_latitude_arcsec < 1e-3,
            "Sun max latitude error too large: {} arcsec",
            sun.max_latitude_arcsec
        );
        assert!(
            sun.max_distance_km < 1.0,
            "Sun max distance error too large: {} km",
            sun.max_distance_km
        );
    }

    #[test]
    fn baseline_excludes_body_when_all_lookups_fail() {
        // Vacuity guard: if the artifact has NO segment for the holdout body, the body
        // must be absent from results rather than silently appearing as zero error.
        let holdout = synthetic_holdout(); // Sun at J2000.0
                                           // Empty artifact — lookup_ecliptic will fail with MissingBody for Sun.
        let empty_artifact = CompressedArtifact::new(
            ArtifactHeader::new("empty-test", "empty test source"),
            vec![],
        );
        let errors = accuracy_baseline_against(&holdout, &empty_artifact);
        assert!(
            errors.is_empty(),
            "vacuity guard must exclude bodies with zero successful comparisons; got {} entries",
            errors.len()
        );
    }

    // Runtime: ~1.7 s (decodes 47.5 MB artifact + 500-row hold-out). Not ignored.
    #[test]
    fn packaged_artifact_baseline_is_non_vacuous() {
        // Regression guard against the Tdb/Tt scale-mismatch vacuity bug.
        // All 10 base bodies must be present with count>0; inner bodies and luminaries
        // must be sub-arcsec; at least one outer planet must show a clearly non-zero
        // longitude error (proving the baseline is not vacuous).
        let errors = packaged_artifact_accuracy_baseline();

        // (a) All 10 base bodies present.
        let expected_bodies = [
            CelestialBody::Sun,
            CelestialBody::Moon,
            CelestialBody::Mercury,
            CelestialBody::Venus,
            CelestialBody::Mars,
            CelestialBody::Jupiter,
            CelestialBody::Saturn,
            CelestialBody::Uranus,
            CelestialBody::Neptune,
            CelestialBody::Pluto,
        ];
        assert_eq!(
            errors.len(),
            10,
            "expected 10 base bodies in packaged baseline; got {}: {:?}",
            errors.len(),
            errors
                .iter()
                .map(|e| format!("{:?}", e.body))
                .collect::<Vec<_>>()
        );
        for body in &expected_bodies {
            let entry = errors
                .iter()
                .find(|e| &e.body == body)
                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
            assert!(
                entry.comparison_count > 0,
                "{body:?} must have at least one successful comparison (got 0 — vacuous baseline)"
            );
        }

        // (b) Inner bodies + luminaries must be sub-arcsec in longitude.
        let sub_arcsec_bodies = [
            CelestialBody::Sun,
            CelestialBody::Moon,
            CelestialBody::Mercury,
            CelestialBody::Venus,
            CelestialBody::Mars,
        ];
        for body in &sub_arcsec_bodies {
            let entry = errors.iter().find(|e| &e.body == body).unwrap();
            assert!(
                entry.max_longitude_arcsec < 1.0,
                "{body:?} max longitude error must be <1 arcsec (got {:.6}\")",
                entry.max_longitude_arcsec
            );
        }

        // (b2) SP2: outer planets stored heliocentrically — all must also be sub-arcsec.
        let outer_bodies = [
            CelestialBody::Jupiter,
            CelestialBody::Saturn,
            CelestialBody::Uranus,
            CelestialBody::Neptune,
            CelestialBody::Pluto,
        ];
        for body in &outer_bodies {
            let entry = errors.iter().find(|e| &e.body == body).unwrap();
            assert!(
                entry.max_longitude_arcsec < 1.0,
                "{body:?} max longitude error must be <1 arcsec after SP2 heliocentric reframe (got {:.6}\")",
                entry.max_longitude_arcsec
            );
        }

        // (c) Non-vacuity anchor: Uranus must be non-zero (baseline is not vacuous)
        // and sub-arcsec (heliocentric reframe is active).
        let uranus = errors
            .iter()
            .find(|e| e.body == CelestialBody::Uranus)
            .expect("Uranus must appear in the packaged baseline");
        assert!(
            uranus.max_longitude_arcsec > 0.0001,
            "Uranus max longitude error must be >0.0001\" (got {:.6}\" — baseline may be vacuous)",
            uranus.max_longitude_arcsec
        );
        assert!(
            uranus.max_longitude_arcsec < 1.0,
            "Uranus max longitude error must be <1\" after SP2 heliocentric reframe (got {:.4}\" — reframe may be broken)",
            uranus.max_longitude_arcsec
        );
    }

    // Astrology-grade longitude envelope gate (SP2).
    // Outer planets are allowed up to 5.0″; inner bodies/luminaries up to 1.0″.
    // With the v7 heliocentric artifact all bodies are sub-arcsec, so this
    // passes with large margin — the 5.0″ ceiling guards against a future
    // regression, not against the current state.
    // Note: (b2) in packaged_artifact_baseline_is_non_vacuous already asserts
    // outer planets < 1.0″, which is STRICTER than this gate's 5.0″ ceiling.
    // The two tests are complementary, not redundant: this test encodes the
    // published astrology-grade specification, while (b2) is an operational
    // regression guard at the tighter SP2-achieved level.
    #[test]
    fn outer_planet_longitude_meets_astrology_grade_envelope() {
        let baseline = crate::accuracy_baseline::packaged_artifact_accuracy_baseline();
        // Astrology-grade longitude ceilings drawn from the published SSOT.
        // accuracy_ceiling returns 1.0" for Luminary/InnerPlanet, 5.0" for
        // OuterPlanet, and 30.0" for Asteroid — identical to the old inline
        // match, so no threshold is loosened.
        for body_error in &baseline {
            let c = crate::thresholds::accuracy_ceiling(&body_error.body).lon_arcsec;
            assert!(
                body_error.max_longitude_arcsec <= c,
                "{:?} longitude {:.3}\" exceeds ceiling {:.1}\"",
                body_error.body,
                body_error.max_longitude_arcsec,
                c
            );
        }
    }

    // Hard accuracy-ceiling gate (SP3, Task 11): all 6 channels for every body in
    // the baseline must be within their published ceilings from thresholds.rs.
    // Currently passing with large margin (measured << ceiling), so this guards
    // against future regressions rather than reflecting the current tight state.
    #[test]
    fn all_channels_within_published_ceilings_for_major_bodies() {
        let baseline = packaged_artifact_accuracy_baseline();
        for e in &baseline {
            let c = crate::thresholds::accuracy_ceiling(&e.body);
            assert!(
                e.max_longitude_arcsec <= c.lon_arcsec,
                "{:?} lon {:.4}\" exceeds ceiling {:.1}\"",
                e.body,
                e.max_longitude_arcsec,
                c.lon_arcsec
            );
            assert!(
                e.max_latitude_arcsec <= c.lat_arcsec,
                "{:?} lat {:.4}\" exceeds ceiling {:.1}\"",
                e.body,
                e.max_latitude_arcsec,
                c.lat_arcsec
            );
            assert!(
                e.max_distance_km <= c.dist_km,
                "{:?} dist {:.3} km exceeds ceiling {:.0} km",
                e.body,
                e.max_distance_km,
                c.dist_km
            );
            assert!(
                e.max_lon_speed_arcsec_per_day <= c.lon_speed_arcsec_per_day,
                "{:?} lon speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
                e.body,
                e.max_lon_speed_arcsec_per_day,
                c.lon_speed_arcsec_per_day
            );
            assert!(
                e.max_lat_speed_arcsec_per_day <= c.lat_speed_arcsec_per_day,
                "{:?} lat speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
                e.body,
                e.max_lat_speed_arcsec_per_day,
                c.lat_speed_arcsec_per_day
            );
            assert!(
                e.max_radial_speed_au_per_day <= c.radial_speed_au_per_day,
                "{:?} radial speed {:.6} AU/day exceeds ceiling {:.2e} AU/day",
                e.body,
                e.max_radial_speed_au_per_day,
                c.radial_speed_au_per_day
            );
        }
    }

    // Size-budget gate (SP3, Task 11): the committed packaged artifact must not exceed
    // the published encoded-bytes budget from PACKAGED_BUDGETS.
    #[test]
    fn encoded_artifact_within_size_budget() {
        let bytes_len = crate::data::packaged_artifact_bytes().len();
        assert!(
            bytes_len <= crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes,
            "encoded artifact {} bytes exceeds budget {} bytes",
            bytes_len,
            crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes
        );
    }

    // Non-vacuity guard for speed channels (SP3, Task 11).
    //
    // The speed accumulator has no velocity-row counter.  A body whose velocity
    // rows were all skipped (e.g. because lookup_motion returned Err or the corpus
    // lacked velocity data) would keep max_*_speed = 0.0 and pass the ceiling
    // gate VACUOUSLY.  This guard catches that: every major planet (excluding the
    // Moon, whose min non-zero would be below our threshold on some corpora) must
    // show a STRICTLY POSITIVE lon-speed error magnitude above a small but
    // non-zero floor (0.0001 arcsec/day), well below the smallest observed real
    // error (~0.0002 arcsec/day for Jupiter/Saturn/Neptune) but clearly above
    // exact zero (which would only arise from a silently-all-skipped velocity
    // channel).
    //
    // Moon is guarded separately with a higher floor (0.005 arcsec/day) because
    // its faster apparent motion means any real measurement would be >>0.005.
    #[test]
    fn speed_channels_are_non_vacuous_for_major_bodies() {
        let errors = packaged_artifact_accuracy_baseline();

        // Inner planets + luminaries: lon-speed must be > 0.0001 arcsec/day.
        let inner_bodies = [
            CelestialBody::Sun,
            CelestialBody::Mercury,
            CelestialBody::Venus,
            CelestialBody::Mars,
        ];
        for body in &inner_bodies {
            let e = errors
                .iter()
                .find(|e| &e.body == body)
                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
            assert!(
                e.max_lon_speed_arcsec_per_day > 0.0001,
                "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
                e.max_lon_speed_arcsec_per_day
            );
        }

        // Moon: higher floor (real motion >> 0.005 arcsec/day).
        let moon = errors
            .iter()
            .find(|e| e.body == CelestialBody::Moon)
            .expect("Moon must appear in the packaged baseline");
        assert!(
            moon.max_lon_speed_arcsec_per_day > 0.005,
            "Moon max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
            moon.max_lon_speed_arcsec_per_day
        );

        // Outer planets: lon-speed must be > 0.0001 arcsec/day.
        let outer_bodies = [
            CelestialBody::Jupiter,
            CelestialBody::Saturn,
            CelestialBody::Uranus,
            CelestialBody::Neptune,
            CelestialBody::Pluto,
        ];
        for body in &outer_bodies {
            let e = errors
                .iter()
                .find(|e| &e.body == body)
                .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
            assert!(
                e.max_lon_speed_arcsec_per_day > 0.0001,
                "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
                e.max_lon_speed_arcsec_per_day
            );
        }
    }

    #[test]
    #[ignore = "maintainer helper: prints the Eros self-consistency max longitude error"]
    fn print_eros_self_consistency_max_longitude_arcsec() {
        let v = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
        eprintln!("EROS_SELF_CONSISTENCY_MAX_LON_ARCSEC = {v:.6}\"");
    }

    /// Eros self-consistency gate (SP3, Task 12).
    ///
    /// Eros is re-derived from the committed reference snapshot (no independent truth —
    /// it is absent from de440). This is a SELF-CONSISTENCY check, NOT an independent-truth
    /// gate. It verifies the artifact faithfully reproduces the snapshot it was fit from,
    /// within the published Asteroid-class longitude ceiling (30″).
    #[test]
    fn eros_round_trips_against_its_reference_snapshot_within_documented_target() {
        let eros_body = pleiades_backend::CelestialBody::Custom(
            pleiades_backend::CustomBodyId::new("asteroid", "433-Eros"),
        );
        // Non-vacuity guard: the reference snapshot must actually contain Eros rows,
        // otherwise the helper iterates zero rows and trivially returns 0.0 — a pass
        // that reveals nothing. A missing Eros corpus is a configuration error, not
        // "perfect accuracy".
        let eros_row_count = pleiades_jpl::reference_snapshot()
            .iter()
            .filter(|e| e.body == eros_body)
            .count();
        assert!(
            eros_row_count > 0,
            "reference snapshot contains no Eros rows — self-consistency check would be vacuous (iterate zero rows → max=0.0 → trivially passes)"
        );

        let ceiling = crate::thresholds::accuracy_ceiling(&eros_body);
        let max_lon_arcsec = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
        assert!(
            max_lon_arcsec <= ceiling.lon_arcsec,
            "Eros self-consistency {max_lon_arcsec:.4}\" > {:.1}\" (artifact does not reproduce the reference snapshot it was fit from within the Asteroid-class ceiling)",
            ceiling.lon_arcsec
        );
    }
}