pleiades-backend 0.5.2

Backend traits, request/result contracts, capability metadata, policy summaries, and routing helpers for pleiades ephemeris backends.
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
use crate::capabilities::{BackendCapabilities, BackendCapabilitiesValidationError};
use crate::claims::{BodyClaim, BodyClaimTier};
use crate::errors::{format_display_list, EphemerisError, EphemerisErrorKind};
use crate::identity::{AccuracyClass, BackendFamily, BackendId};
use crate::request::EphemerisRequest;
use crate::validation::{validate_non_blank, validate_non_empty_unique, validate_unique_entries};
use core::fmt;
use pleiades_types::{
    CelestialBody, CoordinateFrame, TimeRange, TimeRangeValidationError, TimeScale, ZodiacMode,
};

/// Provenance summary for a backend.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BackendProvenance {
    /// Short human-readable summary of the backend's source material.
    pub summary: String,
    /// External data or reference sources used by the backend.
    pub data_sources: Vec<String>,
}

impl BackendProvenance {
    /// Creates a new provenance summary.
    pub fn new(summary: impl Into<String>) -> Self {
        Self {
            summary: summary.into(),
            data_sources: Vec::new(),
        }
    }

    /// Returns a compact one-line rendering of the provenance summary.
    pub fn summary_line(&self) -> String {
        self.summary.clone()
    }

    /// Returns `Ok(())` when the provenance summary is internally consistent.
    ///
    /// The shared check keeps backend provenance metadata from silently
    /// carrying blank summary text or duplicate/whitespace-padded source
    /// labels. Empty source lists are allowed for synthesized or routing
    /// backends that do not have external data provenance to list.
    pub fn validate(&self) -> Result<(), BackendProvenanceValidationError> {
        validate_non_blank("provenance summary", &self.summary)
            .map_err(|_| BackendProvenanceValidationError::BlankSummary)?;

        for (index, source) in self.data_sources.iter().enumerate() {
            if source.trim().is_empty() || source.trim() != source {
                return Err(BackendProvenanceValidationError::BlankDataSource { index });
            }
        }

        validate_unique_entries("provenance data sources", &self.data_sources).map_err(|error| {
            match error {
                BackendMetadataValidationError::DuplicateEntry { value, .. } => {
                    BackendProvenanceValidationError::DuplicateDataSource { value }
                }
                _ => {
                    unreachable!("duplicate provenance sources should only fail via DuplicateEntry")
                }
            }
        })
    }

    /// Returns the compact provenance summary after validating it.
    pub fn validated_summary_line(&self) -> Result<String, BackendProvenanceValidationError> {
        self.validate()?;
        Ok(self.summary_line())
    }
}

/// Errors returned when backend provenance metadata fails the shared consistency checks.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BackendProvenanceValidationError {
    /// The summary text was blank or whitespace-padded.
    BlankSummary,
    /// A provenance source entry was blank or whitespace-padded.
    BlankDataSource {
        /// Zero-based position of the invalid source entry.
        index: usize,
    },
    /// A provenance source entry appeared more than once.
    DuplicateDataSource {
        /// The duplicated source label.
        value: String,
    },
}

impl BackendProvenanceValidationError {
    /// Returns a compact validation summary string.
    pub fn summary_line(&self) -> String {
        match self {
            Self::BlankSummary => {
                "backend provenance summary must not be blank or whitespace-padded".to_owned()
            }
            Self::BlankDataSource { index } => format!(
                "backend provenance data source at index {index} must not be blank or whitespace-padded"
            ),
            Self::DuplicateDataSource { value } => {
                format!("backend provenance data sources contain duplicate entry `{value}`")
            }
        }
    }
}

impl fmt::Display for BackendProvenanceValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.summary_line())
    }
}

impl std::error::Error for BackendProvenanceValidationError {}

impl fmt::Display for BackendProvenance {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.summary)
    }
}

/// Nominal backend metadata.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq)]
pub struct BackendMetadata {
    /// Stable backend identifier.
    pub id: BackendId,
    /// Human-readable backend version.
    pub version: String,
    /// Backend family.
    pub family: BackendFamily,
    /// Provenance summary.
    pub provenance: BackendProvenance,
    /// Nominal supported time range.
    pub nominal_range: TimeRange,
    /// Time scales the backend can accept.
    pub supported_time_scales: Vec<TimeScale>,
    /// Supported body coverage and per-body release claims.
    pub body_claims: Vec<BodyClaim>,
    /// Supported coordinate frames.
    pub supported_frames: Vec<CoordinateFrame>,
    /// Declared capabilities.
    pub capabilities: BackendCapabilities,
    /// Published accuracy class.
    pub accuracy: AccuracyClass,
    /// Whether repeated queries are deterministic.
    pub deterministic: bool,
    /// Whether the backend runs fully offline.
    pub offline: bool,
}

impl BackendMetadata {
    /// Returns the bodies the backend serves (every tier except `Unsupported`).
    pub fn supported_bodies(&self) -> Vec<CelestialBody> {
        self.body_claims
            .iter()
            .filter(|c| c.tier != BodyClaimTier::Unsupported)
            .map(|c| c.body.clone())
            .collect()
    }

    /// Returns the claim for a body, if declared.
    pub fn claim_for(&self, body: &CelestialBody) -> Option<&BodyClaim> {
        self.body_claims.iter().find(|c| &c.body == body)
    }

    /// Returns the bodies claimed `ReleaseGrade`.
    pub fn release_grade_bodies(&self) -> Vec<CelestialBody> {
        self.body_claims
            .iter()
            .filter(|c| c.tier == BodyClaimTier::ReleaseGrade)
            .map(|c| c.body.clone())
            .collect()
    }

    /// Returns claims at a given tier.
    pub fn claims_by_tier(&self, tier: BodyClaimTier) -> Vec<&BodyClaim> {
        self.body_claims.iter().filter(|c| c.tier == tier).collect()
    }

    /// Returns a compact one-line rendering of the backend metadata posture.
    pub fn summary_line(&self) -> String {
        format!(
            "id={}; version={}; family={}; family posture={}; accuracy={}; deterministic={}; offline={}; nominal range={}; time scales=[{}]; bodies=[{}]; frames=[{}]; capabilities=[{}]; provenance={}",
            self.id,
            self.version,
            self.family,
            self.family.posture_label(),
            self.accuracy,
            self.deterministic,
            self.offline,
            self.nominal_range,
            format_display_list(&self.supported_time_scales),
            self.body_claims
                .iter()
                .map(BodyClaim::summary_line)
                .collect::<Vec<_>>()
                .join(", "),
            format_display_list(&self.supported_frames),
            self.capabilities.summary_line(),
            self.provenance.summary_line(),
        )
    }

    /// Returns the compact backend metadata summary after validating the stored fields.
    pub fn validated_summary_line(&self) -> Result<String, BackendMetadataValidationError> {
        self.validate()?;
        Ok(self.summary_line())
    }

    /// Validates a request shape against this metadata before backend computation.
    ///
    /// Routing backends still defer frame, time-scale, value-mode, and zodiac
    /// checks to the selected provider, but they continue to validate the
    /// request's custom definitions, observer syntax, and body coverage here so
    /// unsupported shapes fail closed before execution.
    pub fn validate_request(&self, req: &EphemerisRequest) -> Result<(), EphemerisError> {
        req.validate_custom_definitions()?;

        if !self.family.is_routing() {
            crate::policy::current::validate_request_policy(
                req,
                self.id.as_str(),
                &self.supported_time_scales,
                &self.supported_frames,
                self.capabilities.mean,
                self.capabilities.apparent,
            )?;

            if !self.capabilities.native_sidereal {
                crate::policy::current::validate_zodiac_policy(
                    req,
                    self.id.as_str(),
                    &[ZodiacMode::Tropical],
                )?;
            }

            crate::policy::current::validate_request_observer_location(req)?;
            crate::policy::current::validate_observer_policy(
                req,
                self.id.as_str(),
                self.capabilities.topocentric,
            )?;
        } else {
            crate::policy::current::validate_request_observer_location(req)?;
        }

        if !self.supported_bodies().contains(&req.body) {
            return Err(EphemerisError::new(
                EphemerisErrorKind::UnsupportedBody,
                format!("{} does not support {}", self.id, req.body),
            ));
        }

        Ok(())
    }
}

impl fmt::Display for BackendMetadata {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.summary_line())
    }
}

/// Errors returned when backend metadata fails the shared consistency checks.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BackendMetadataValidationError {
    /// A required metadata field is blank or whitespace-padded.
    BlankField {
        /// Name of the offending metadata field.
        field: &'static str,
    },
    /// A required list field is empty.
    EmptyField {
        /// Name of the offending metadata list field.
        field: &'static str,
    },
    /// A catalog-style list field contains a duplicate entry.
    DuplicateEntry {
        /// Name of the metadata list field that held the duplicate.
        field: &'static str,
        /// The duplicated entry value.
        value: String,
    },
    /// The nominal range contains a non-finite Julian-day bound.
    NominalRangeNotFinite,
    /// The nominal range bounds use different time scales.
    NominalRangeScaleMismatch,
    /// The nominal range end precedes the start.
    NominalRangeOutOfOrder,
    /// The declared capability flags are internally inconsistent.
    InvalidCapabilities {
        /// The invalid field name.
        field: &'static str,
        /// A short description of the capability mismatch.
        message: &'static str,
    },
}

impl BackendMetadataValidationError {
    /// Returns a compact validation summary string.
    pub fn summary_line(&self) -> String {
        match self {
            Self::BlankField { field } => {
                format!("backend metadata field `{field}` is blank or whitespace-padded")
            }
            Self::EmptyField { field } => {
                format!("backend metadata field `{field}` must not be empty")
            }
            Self::DuplicateEntry { field, value } => {
                format!("backend metadata field `{field}` contains duplicate entry `{value}`")
            }
            Self::NominalRangeNotFinite => {
                "backend metadata nominal range must use finite Julian-day bounds".to_owned()
            }
            Self::NominalRangeScaleMismatch => {
                "backend metadata nominal range bounds must use the same time scale".to_owned()
            }
            Self::NominalRangeOutOfOrder => {
                "backend metadata nominal range end must not precede the start".to_owned()
            }
            Self::InvalidCapabilities { field, message } => {
                format!("backend metadata field `{field}` is invalid: {message}")
            }
        }
    }
}

impl fmt::Display for BackendMetadataValidationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.summary_line())
    }
}

impl std::error::Error for BackendMetadataValidationError {}

impl BackendMetadata {
    /// Returns `Ok(())` when the metadata is internally consistent.
    ///
    /// The shared check keeps the release-facing backend inventory from
    /// silently advertising blank identifiers, duplicate coverage entries, or
    /// an invalid nominal range. It does not attempt to validate source-specific
    /// accuracy claims; those still belong to the backend crate that owns the
    /// data.
    pub fn validate(&self) -> Result<(), BackendMetadataValidationError> {
        validate_non_blank("id", self.id.as_str())?;
        validate_non_blank("version", &self.version)?;
        self.provenance.validate().map_err(|error| match error {
            BackendProvenanceValidationError::BlankSummary => {
                BackendMetadataValidationError::BlankField {
                    field: "provenance summary",
                }
            }
            BackendProvenanceValidationError::BlankDataSource { .. } => {
                BackendMetadataValidationError::BlankField {
                    field: "provenance data sources",
                }
            }
            BackendProvenanceValidationError::DuplicateDataSource { value } => {
                BackendMetadataValidationError::DuplicateEntry {
                    field: "provenance data sources",
                    value,
                }
            }
        })?;
        validate_non_empty_unique("supported time scales", &self.supported_time_scales)?;
        if self.body_claims.is_empty() {
            return Err(BackendMetadataValidationError::EmptyField {
                field: "body claims",
            });
        }
        let mut seen: Vec<CelestialBody> = Vec::new();
        for claim in &self.body_claims {
            if seen.contains(&claim.body) {
                return Err(BackendMetadataValidationError::DuplicateEntry {
                    field: "body claims",
                    value: claim.body.to_string(),
                });
            }
            seen.push(claim.body.clone());
        }
        validate_non_empty_unique("supported frames", &self.supported_frames)?;
        self.capabilities.validate().map_err(|error| match error {
            BackendCapabilitiesValidationError::MissingPositionMode => {
                BackendMetadataValidationError::InvalidCapabilities {
                    field: "capabilities",
                    message: error.summary_line(),
                }
            }
            BackendCapabilitiesValidationError::MissingValueMode => {
                BackendMetadataValidationError::InvalidCapabilities {
                    field: "capabilities",
                    message: error.summary_line(),
                }
            }
        })?;
        self.validate_nominal_range()?;
        Ok(())
    }

    fn validate_nominal_range(&self) -> Result<(), BackendMetadataValidationError> {
        match self.nominal_range.validate() {
            Ok(()) => Ok(()),
            Err(TimeRangeValidationError::NonFiniteBound { .. }) => {
                Err(BackendMetadataValidationError::NominalRangeNotFinite)
            }
            Err(TimeRangeValidationError::ScaleMismatch { .. }) => {
                Err(BackendMetadataValidationError::NominalRangeScaleMismatch)
            }
            Err(TimeRangeValidationError::OutOfOrder { .. }) => {
                Err(BackendMetadataValidationError::NominalRangeOutOfOrder)
            }
        }
    }
}

/// Merges two claim lists, keeping the stronger-ranked tier on body collisions.
pub fn merge_body_claims(a: &[BodyClaim], b: &[BodyClaim]) -> Vec<BodyClaim> {
    let mut out: Vec<BodyClaim> = a.to_vec();
    for claim in b {
        match out.iter_mut().find(|c| c.body == claim.body) {
            Some(existing) => {
                if claim.tier.rank() > existing.tier.rank() {
                    *existing = claim.clone();
                }
            }
            None => out.push(claim.clone()),
        }
    }
    out
}

#[cfg(test)]
#[path = "metadata_tests.rs"]
mod tests;