Skip to main content

sidereon_core/estimation/
recipe.rs

1//! Named operation-order recipes for the GNSS estimation substrate.
2//!
3//! Phase-2 collapses the three thick estimator stacks (`spp`, `rtk`/`rtk_filter`,
4//! `precise_positioning`) onto one shared substrate plus thin, runtime-selectable
5//! strategies. The single hard constraint is that each external reference's
6//! bit-exactness (Skyfield for SPP, RTKLIB for RTK, the PPP oracle for PPP) must
7//! be preserved to 0 ULP. Different references need different floating-point
8//! operation orders for the *same* physical quantity, so the substrate never
9//! "simplifies" a parity-sensitive formula into one shared form. Instead every
10//! such choice is a NAMED variant: a strategy selects the op-order it needs by
11//! enum value rather than by owning a copy of the helper.
12//!
13//! This module *names* the recipes; the substrate and strategies route every
14//! caller through them. Each reference-faithful strategy resolves to the single
15//! op-order it was already using, so threading the recipe through the shared
16//! spine reproduces the prior code path bit-for-bit and leaves every existing
17//! 0-ULP golden unchanged.
18//!
19//! The `Canonical*` variants belong to the single consistent IERS-rigorous
20//! model (the bounded-tolerance canonical strategy, P6). They are NOT used by
21//! any reference-faithful strategy; canonical is an additional selectable
22//! strategy that changes nothing about the references. The SPP canonical range
23//! and frame variants ([`RangeRecipe::CanonicalLightTimeClosedFormSagnac`],
24//! [`FrameRecipe::CanonicalWgs84`]) are implemented and driven by
25//! [`EstimationRecipe::canonical_spp`]; the RTK and PPP canonical square-root
26//! solve ([`NormalRecipe::CanonicalSquareRoot`] on
27//! [`SolverRecipe::OwnedDeterministicCholesky`]) by
28//! [`EstimationRecipe::canonical_rtk`] and [`EstimationRecipe::canonical_ppp`].
29//! Canonical SPP, RTK, and PPP are all wired.
30
31/// Estimation technique: which physical observation model and parameter set a
32/// strategy estimates.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum Technique {
35    /// Single-point positioning: undifferenced pseudorange PVT.
36    #[default]
37    Spp,
38    /// Real-time kinematic: double-differenced code/phase baseline.
39    Rtk,
40    /// Precise point positioning: undifferenced ionosphere-free code/phase.
41    Ppp,
42}
43
44/// The reference a reference-faithful strategy is bit-exact against. The
45/// external oracles (Skyfield, RTKLIB, the PPP oracle) are CI validation targets
46/// whose goldens stay 0-ULP unchanged through P0-P5; [`Self::OwnedDeterministic`]
47/// is instead pinned to the owned solver's own frozen-bits golden (P5).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
49pub enum ReferenceTarget {
50    /// Skyfield (the SPP geometry/clock/Sagnac reference).
51    #[default]
52    Skyfield,
53    /// RTKLIB (the RTK double-difference baseline reference).
54    Rtklib,
55    /// scipy least-squares host solve (the SPP solver-agreement reference).
56    /// Named for the `trust-region-least-squares` host-LAPACK fingerprint study;
57    /// not a runtime
58    /// estimation strategy (it is not wired into the SPP solve path), so it is
59    /// not a valid [`StrategyId`] target.
60    Scipy,
61    /// The PPP float/fixed oracle arc.
62    PppOracle,
63    /// The SPP owned deterministic trust-region solver
64    /// ([`SolverRecipe::OwnedDeterministicTrf`]): a fixed-reduction-order dense
65    /// subproblem factorization with no nalgebra LU and no black-box BLAS in that
66    /// solve. It is pinned to its own frozen-bits golden rather than to an
67    /// external library, and is selectable only for [`Technique::Spp`]. The owned
68    /// kernel owns only the subproblem factorization (the surrounding
69    /// normal-matrix / gradient / norm reductions stay on nalgebra), so its
70    /// cross-platform bit guarantee is scoped to the factorization; see
71    /// [`SolverRecipe::OwnedDeterministicTrf`] for the precise scope.
72    OwnedDeterministic,
73}
74
75/// Runtime-selectable strategy identity. `Reference` strategies are 0-ULP
76/// bit-exact to an external reference and remain the validation oracles;
77/// `Canonical` is the single bounded-tolerance "best" model (P6). Canonical SPP,
78/// RTK, and PPP are all wired.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum StrategyId {
81    /// A reference-faithful strategy: 0-ULP to `target` for `technique`.
82    Reference {
83        technique: Technique,
84        target: ReferenceTarget,
85    },
86    /// The canonical strategy for `technique` (bounded-tolerance, truth-gated).
87    Canonical { technique: Technique },
88}
89
90impl Default for StrategyId {
91    fn default() -> Self {
92        Self::Reference {
93            technique: Technique::Spp,
94            target: ReferenceTarget::Skyfield,
95        }
96    }
97}
98
99impl StrategyId {
100    /// SPP, bit-exact to Skyfield (`spp::solve`).
101    pub const fn spp_reference() -> Self {
102        Self::Reference {
103            technique: Technique::Spp,
104            target: ReferenceTarget::Skyfield,
105        }
106    }
107
108    /// RTK, bit-exact to RTKLIB (`rtk` / `rtk_filter`).
109    pub const fn rtk_reference() -> Self {
110        Self::Reference {
111            technique: Technique::Rtk,
112            target: ReferenceTarget::Rtklib,
113        }
114    }
115
116    /// PPP, bit-exact to the PPP oracle arc (`precise_positioning`).
117    pub const fn ppp_reference() -> Self {
118        Self::Reference {
119            technique: Technique::Ppp,
120            target: ReferenceTarget::PppOracle,
121        }
122    }
123
124    /// SPP via the owned deterministic trust-region solver
125    /// ([`SolverRecipe::OwnedDeterministicTrf`]): the owned dense subproblem
126    /// factorization, pinned to its own frozen-bits golden (its cross-platform
127    /// bit guarantee is scoped to the factorization). Selecting this through
128    /// [`crate::estimation::strategies::estimate`] drives the owned solver
129    /// rather than the legacy nalgebra LU path.
130    pub const fn spp_owned_deterministic() -> Self {
131        Self::Reference {
132            technique: Technique::Spp,
133            target: ReferenceTarget::OwnedDeterministic,
134        }
135    }
136}
137
138/// Geometric range / light-time / transmit-time operation order. Each variant
139/// names an existing range model; the substrate selects the op-order rather
140/// than copying the helper.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
142pub enum RangeRecipe {
143    /// SPP closed-form light-time with a fixed transmit-time iteration count and
144    /// a measured-pseudorange seed (`spp/mod.rs` `sat_model`).
145    #[default]
146    SppMeasuredPseudorangeFixedIter,
147    /// `observables::predict` rounded-microsecond transmit time with a fixed
148    /// light-time iteration count (PPP / forward-prediction model).
149    ObservableRoundedMicrosecondFixedIter,
150    /// RTK provided-transmit-position range with the RTKLIB first-order Sagnac
151    /// scalar (`rtk_filter::model` line-of-sight / geometric range).
152    RtkProvidedTxFirstOrderSagnac,
153    /// Canonical: full iterative light-time (iterated to convergence, not a
154    /// fixed truncation) with the closed-form Sagnac Z-rotation, never a
155    /// first-order scalar Sagnac. Driven by [`EstimationRecipe::canonical_spp`]
156    /// in the SPP measurement model; not used by any reference strategy.
157    CanonicalLightTimeClosedFormSagnac,
158}
159
160/// Earth-rotation (Sagnac) correction operation order.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
162pub enum SagnacRecipe {
163    /// Closed-form z-axis rotation of the satellite ECEF position by
164    /// `OMEGA_E_DOT * tau` (SPP / observables).
165    #[default]
166    ClosedFormZRotation,
167    /// RTKLIB first-order scalar Sagnac term added to the range
168    /// (`rtk_filter::model`).
169    RtklibFirstOrderScalar,
170    /// No Sagnac correction (synthetic / ECI-consistent inputs).
171    Off,
172}
173
174/// Local-frame / ENU / az-el basis construction operation order.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
176pub enum FrameRecipe {
177    /// SPP Skyfield-parity ECEF->geodetic with the three-iteration AU-scaled
178    /// latitude solve (`spp` geodetic conversion).
179    #[default]
180    SppSkyfieldAuThreeIter,
181    /// Geocentric-up local frame used by the RTK elevation reference
182    /// (`rtk_filter` elevation mask / antenna projection).
183    GeocentricUpRtkReference,
184    /// Geodetic NEU basis built from the cross-product convention
185    /// (`precise_positioning::model` troposphere geometry).
186    GeodeticNeuCrossProduct,
187    /// DOP ENU rotation basis (`dop`).
188    DopEnuRotation,
189    /// Canonical: one consistent meters-native WGS84/ITRF geodetic basis under
190    /// IERS conventions (the core PROJ-pinned closed-form solve), never a
191    /// reference-specific AU-scaled path. Driven by
192    /// [`EstimationRecipe::canonical_spp`]; not used by any reference strategy.
193    CanonicalWgs84,
194}
195
196/// Normal-equation assembly tie-breaking / fold order. The tie order is the
197/// pivot/elimination convention that fixes the bit pattern of the reduced
198/// system.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
200pub enum NormalRecipe {
201    /// SPP weighted-residual rows with a finite-difference design matrix
202    /// (`spp` least-squares).
203    #[default]
204    SppWeightedResidualFiniteDifference,
205    /// RTK double-difference block assembly with the first-tie covariance fold
206    /// (`rtk_filter::normal` first-tie block).
207    RtkDoubleDifferenceBlockFirstTie,
208    /// PPP dense normal equations with the last-tie solve
209    /// (`precise_positioning::normal` `*_last_tie`).
210    PppDenseLastTie,
211    /// Canonical square-root-information solve, shared by canonical RTK and
212    /// canonical PPP: the SPD normal system is solved by the owned deterministic
213    /// Cholesky factorization `Λ = L Lᵀ` plus forward/back substitution, where
214    /// `L` is the information-matrix square root. For RTK this is the
215    /// double-difference information system `Λ x = η` assembled by the same shared
216    /// block fold the RTK reference uses; for PPP it is the dense weighted normal
217    /// system `AᵀWA x = AᵀWy` assembled from the same undifferenced rows the PPP
218    /// reference uses. This is the numerically rigorous op-order for an SPD normal
219    /// matrix (no pivoting; exploits symmetry), distinct from the reference RTK
220    /// general first-tie Gaussian elimination
221    /// ([`Self::RtkDoubleDifferenceBlockFirstTie`]) and the reference PPP last-tie
222    /// Gaussian elimination ([`Self::PppDenseLastTie`]). Driven by
223    /// [`EstimationRecipe::canonical_rtk`] and [`EstimationRecipe::canonical_ppp`]
224    /// on the owned [`SolverRecipe::OwnedDeterministicCholesky`] kernel; not used
225    /// by any reference strategy.
226    CanonicalSquareRoot,
227}
228
229/// Linear-solve / factorization operation order. Determinism note: the legacy
230/// SPP path is nalgebra LU (not bit-portable end-to-end), preserved as a named
231/// variant; the owned deterministic kernel (P5) owns the dense subproblem
232/// factorization with its own goldens. Its determinism scope is the
233/// factorization, not the surrounding nalgebra reductions that build the
234/// subproblem -- see [`Self::OwnedDeterministicTrf`].
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
236pub enum SolverRecipe {
237    /// nalgebra trust-region least squares, the current SPP solver
238    /// (`spp` / `crate::astro::math::least_squares`). Existing SPP goldens use
239    /// this; kept unchanged.
240    #[default]
241    NalgebraTrfLegacy,
242    /// Flat first-tie Gaussian elimination (RTK baseline/filter solve).
243    FlatGaussianFirstTie,
244    /// Dense last-tie Gaussian elimination (PPP solve,
245    /// `crate::astro::math::linear::solve_linear_last_tie`).
246    DenseGaussianLastTie,
247    /// scipy host LAPACK reference solve (machine-dependent; only as a
248    /// fingerprinted CI reference, never canonical).
249    ScipyHostLapackReference,
250    /// Owned deterministic Cholesky (square-root) linear solve, the canonical RTK
251    /// (P6 increment 2) and canonical PPP (P6 increment 3) solver: the SPD normal
252    /// system is factored `Λ = L Lᵀ` and solved by forward/back substitution
253    /// through the owned
254    /// [`crate::astro::math::linear::solve_flat_normal_square_root_into`] kernel,
255    /// with no nalgebra LU and no black-box BLAS. Paired with
256    /// [`NormalRecipe::CanonicalSquareRoot`]. Both the RTK and PPP canonical paths
257    /// are owned scalar arithmetic and f64 sqrt is IEEE-754 correctly rounded, so
258    /// unlike [`Self::OwnedDeterministicTrf`] (whose surrounding reductions ride
259    /// nalgebra) its bit guarantee covers the full solve and is portable across
260    /// platforms.
261    OwnedDeterministicCholesky,
262    /// Owned deterministic trust-region subproblem solve added in P5: a
263    /// fixed-reduction-order dense Gaussian elimination (the
264    /// `OwnedGaussianFirstTie` kernel) with no nalgebra LU and no black-box BLAS
265    /// in the factorization, pinned to its OWN frozen-bits goldens. Scope: it
266    /// owns ONLY the subproblem factorization; the normal-matrix / gradient /
267    /// norm reductions that build the subproblem still flow through nalgebra's
268    /// CPU-dispatched dense algebra, so the cross-platform bit guarantee is
269    /// scoped to the factorization, not the full solve.
270    OwnedDeterministicTrf,
271}
272
273/// The full operation-order recipe a strategy composes: one variant per stage.
274/// `Default` and the named constructors reproduce the CURRENT behavior of each
275/// existing strategy, so selecting a recipe never changes a reference golden.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
277pub struct EstimationRecipe {
278    pub range: RangeRecipe,
279    pub sagnac: SagnacRecipe,
280    pub frame: FrameRecipe,
281    pub normal: NormalRecipe,
282    pub solver: SolverRecipe,
283}
284
285impl EstimationRecipe {
286    /// The current SPP reference recipe (`spp::solve`, Skyfield-parity).
287    pub const fn spp() -> Self {
288        Self {
289            range: RangeRecipe::SppMeasuredPseudorangeFixedIter,
290            sagnac: SagnacRecipe::ClosedFormZRotation,
291            frame: FrameRecipe::SppSkyfieldAuThreeIter,
292            normal: NormalRecipe::SppWeightedResidualFiniteDifference,
293            solver: SolverRecipe::NalgebraTrfLegacy,
294        }
295    }
296
297    /// The current RTK reference recipe (`rtk` / `rtk_filter`, RTKLIB-parity).
298    pub const fn rtk() -> Self {
299        Self {
300            range: RangeRecipe::RtkProvidedTxFirstOrderSagnac,
301            sagnac: SagnacRecipe::RtklibFirstOrderScalar,
302            frame: FrameRecipe::GeocentricUpRtkReference,
303            normal: NormalRecipe::RtkDoubleDifferenceBlockFirstTie,
304            solver: SolverRecipe::FlatGaussianFirstTie,
305        }
306    }
307
308    /// The current PPP reference recipe (`precise_positioning`, oracle-parity).
309    pub const fn ppp() -> Self {
310        Self {
311            range: RangeRecipe::ObservableRoundedMicrosecondFixedIter,
312            sagnac: SagnacRecipe::ClosedFormZRotation,
313            frame: FrameRecipe::GeodeticNeuCrossProduct,
314            normal: NormalRecipe::PppDenseLastTie,
315            solver: SolverRecipe::DenseGaussianLastTie,
316        }
317    }
318
319    /// The SPP recipe driving the owned deterministic trust-region solver: the
320    /// SPP reference model with [`SolverRecipe::OwnedDeterministicTrf`] swapped
321    /// in for the legacy nalgebra LU linear-solve stage. Every other stage is the
322    /// SPP reference op-order, so only the factorization changes.
323    pub const fn spp_owned_deterministic() -> Self {
324        let mut recipe = Self::spp();
325        recipe.solver = SolverRecipe::OwnedDeterministicTrf;
326        recipe
327    }
328
329    /// The canonical SPP recipe: the single consistent IERS-rigorous SPP
330    /// measurement model. It diverges from [`Self::spp`] (the Skyfield-faithful
331    /// reference) only where the physics says to:
332    /// - range: [`RangeRecipe::CanonicalLightTimeClosedFormSagnac`] iterates the
333    ///   light-time loop to convergence (vs the reference's fixed
334    ///   transmit-time truncation), with the closed-form Sagnac Z-rotation (never
335    ///   a first-order scalar Sagnac).
336    /// - frame: [`FrameRecipe::CanonicalWgs84`] solves ECEF->geodetic directly in
337    ///   meters on the WGS84 ellipsoid (vs the reference's Skyfield AU-scaled
338    ///   three-iteration latitude loop).
339    /// - solver: [`SolverRecipe::OwnedDeterministicTrf`] owns the trust-region
340    ///   subproblem factorization so canonical is deterministic run-to-run on a
341    ///   pinned build (its cross-platform bit guarantee is scoped to the
342    ///   factorization; the surrounding reductions ride nalgebra).
343    ///
344    /// The Sagnac stage is the closed-form Z-rotation the SPP reference already
345    /// uses (the rigorous form), and the normal stage is the SPP
346    /// weighted-residual finite-difference assembly the trust-region solver
347    /// consumes; neither needs a separate canonical variant for SPP.
348    pub const fn canonical_spp() -> Self {
349        Self {
350            range: RangeRecipe::CanonicalLightTimeClosedFormSagnac,
351            sagnac: SagnacRecipe::ClosedFormZRotation,
352            frame: FrameRecipe::CanonicalWgs84,
353            normal: NormalRecipe::SppWeightedResidualFiniteDifference,
354            solver: SolverRecipe::OwnedDeterministicTrf,
355        }
356    }
357
358    /// The canonical RTK recipe: the double-difference baseline under the
359    /// numerically rigorous square-root-information solve. It keeps the RTK
360    /// reference's double-difference measurement physics (the provided-transmit
361    /// range with the RTKLIB first-order Sagnac scalar, the geocentric-up
362    /// elevation frame), because the canonical RTK divergence the physics calls
363    /// for is in the linear algebra, not the observation model: the same SPD
364    /// information system the reference assembles is solved by the owned
365    /// deterministic Cholesky square-root factorization
366    /// ([`NormalRecipe::CanonicalSquareRoot`] on
367    /// [`SolverRecipe::OwnedDeterministicCholesky`]) instead of the reference's
368    /// general first-tie Gaussian elimination. The square-root solve needs no
369    /// pivoting, exploits the symmetry of the SPD normal matrix, and is entirely
370    /// owned scalar arithmetic (no nalgebra, no BLAS), so canonical RTK is
371    /// well-conditioned and bit-reproducible across platforms.
372    pub const fn canonical_rtk() -> Self {
373        Self {
374            range: RangeRecipe::RtkProvidedTxFirstOrderSagnac,
375            sagnac: SagnacRecipe::RtklibFirstOrderScalar,
376            frame: FrameRecipe::GeocentricUpRtkReference,
377            normal: NormalRecipe::CanonicalSquareRoot,
378            solver: SolverRecipe::OwnedDeterministicCholesky,
379        }
380    }
381
382    /// The canonical PPP recipe: the undifferenced ionosphere-free PPP arc under
383    /// the numerically rigorous square-root-information solve. Like
384    /// [`Self::canonical_rtk`] it keeps the PPP reference's measurement physics
385    /// (the rounded-microsecond fixed-iteration light-time with the rigorous
386    /// closed-form Sagnac Z-rotation, and the geodetic NEU antenna frame), because
387    /// the canonical PPP divergence the physics calls for is in the linear
388    /// algebra, not the observation model: the same dense SPD weighted normal
389    /// system `AᵀWA x = AᵀWy` the reference assembles from the undifferenced rows
390    /// is solved by the owned deterministic Cholesky square-root factorization
391    /// ([`NormalRecipe::CanonicalSquareRoot`] on
392    /// [`SolverRecipe::OwnedDeterministicCholesky`]) instead of the reference's
393    /// dense last-tie Gaussian elimination ([`SolverRecipe::DenseGaussianLastTie`]).
394    /// The square-root solve needs no pivoting, exploits the symmetry of the SPD
395    /// normal matrix, and is entirely owned scalar arithmetic (no nalgebra, no
396    /// BLAS), so it is well-conditioned and the solve itself is bit-portable.
397    /// Determinism scope (calibrated, not overstated): unlike canonical RTK, the PPP
398    /// measurement model that builds the rows evaluates troposphere mapping,
399    /// antenna, and geodetic-frame transcendentals through the platform math
400    /// library, so canonical PPP's overall output is bit-reproducible run-to-run on
401    /// a pinned build but is not claimed bit-portable across platforms; only the
402    /// owned Cholesky solve carries the cross-platform guarantee.
403    pub const fn canonical_ppp() -> Self {
404        Self {
405            range: RangeRecipe::ObservableRoundedMicrosecondFixedIter,
406            sagnac: SagnacRecipe::ClosedFormZRotation,
407            frame: FrameRecipe::GeodeticNeuCrossProduct,
408            normal: NormalRecipe::CanonicalSquareRoot,
409            solver: SolverRecipe::OwnedDeterministicCholesky,
410        }
411    }
412
413    /// The canonical recipe for a `technique`. Canonical SPP (P6 increment 1),
414    /// canonical RTK (P6 increment 2), and canonical PPP (P6 increment 3) are all
415    /// wired, so every technique has a canonical strategy. Returns `Option` to keep
416    /// the resolver's "not yet implemented" surface stable.
417    pub const fn for_canonical(technique: Technique) -> Option<Self> {
418        match technique {
419            Technique::Spp => Some(Self::canonical_spp()),
420            Technique::Rtk => Some(Self::canonical_rtk()),
421            Technique::Ppp => Some(Self::canonical_ppp()),
422        }
423    }
424
425    /// The reference recipe for an explicit `(technique, target)` pair, or `None`
426    /// if the pair is not a supported reference strategy. This is the single
427    /// source of truth for which targets each technique can run: only the wired
428    /// reference oracles (Skyfield for SPP, RTKLIB for RTK, the PPP oracle for
429    /// PPP) and the SPP owned deterministic solver are valid. Every other pair
430    /// (a cross-technique oracle, or the unwired scipy host-LAPACK reference) is
431    /// rejected so an impossible strategy can never silently run a mismatched
432    /// recipe.
433    pub const fn for_reference(technique: Technique, target: ReferenceTarget) -> Option<Self> {
434        match (technique, target) {
435            (Technique::Spp, ReferenceTarget::Skyfield) => Some(Self::spp()),
436            (Technique::Spp, ReferenceTarget::OwnedDeterministic) => {
437                Some(Self::spp_owned_deterministic())
438            }
439            (Technique::Rtk, ReferenceTarget::Rtklib) => Some(Self::rtk()),
440            (Technique::Ppp, ReferenceTarget::PppOracle) => Some(Self::ppp()),
441            _ => None,
442        }
443    }
444}
445
446/// How a strategy forms its integer-ambiguity identifiers, and against what they
447/// are referenced. Naming this lets the RTK and PPP fixed solvers share one
448/// LAMBDA resolution kernel
449/// ([`crate::estimation::substrate::ambiguity::resolve_integer_lattice`]) and
450/// differ only in DATA rather than in separate algorithm trees.
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
452pub enum DifferencingMode {
453    /// Double-differenced ambiguities, one reference satellite per constellation
454    /// (the RTK baseline / sequential-filter convention: each non-reference
455    /// satellite is differenced against its own system's reference).
456    DoubleDifferencePerSystemReference,
457    /// Undifferenced ambiguities, one per satellite per receiver (the PPP
458    /// convention: no reference satellite, all satellites carry their own
459    /// ionosphere-free ambiguity).
460    Undifferenced,
461}
462
463/// Whether partial ambiguity resolution is attempted when the full-set integer
464/// fix fails its ratio test, and with what floor on the retained subset size.
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
466pub enum PartialResolution {
467    /// Full-set only: a failed ratio test means "not fixed" (PPP, and the RTK
468    /// sequential filter, both take the full set or nothing).
469    Disabled,
470    /// Confidence-ranked then exhaustive subset fallback down to
471    /// `min_ambiguities` retained (the RTK static fixed solver,
472    /// `rtk_filter::search::search_partial_fixed_ambiguities`).
473    Exhaustive { min_ambiguities: usize },
474}
475
476/// The integer-ambiguity identity/eligibility policy a strategy resolves under:
477/// the strategy DATA that replaces the RTK-vs-PPP algorithm-tree split. The
478/// LAMBDA resolution kernel is common; only these fields differ between the
479/// reference strategies. Named in P3; consumed by the runtime selector in P4.
480#[derive(Debug, Clone, Copy, PartialEq)]
481pub struct AmbiguityIdPolicy {
482    pub differencing: DifferencingMode,
483    /// Exclude float-only constellations from the integer search set.
484    pub float_only_gating: bool,
485    pub partial: PartialResolution,
486    /// Ratio-test acceptance threshold passed to the LAMBDA kernel.
487    pub ratio_threshold: f64,
488}
489
490impl AmbiguityIdPolicy {
491    /// The static RTK fixed-baseline policy (`rtk_filter::fixed`): per-system
492    /// double differences, float-only constellations excluded from the search,
493    /// partial resolution down to `partial_min_ambiguities`.
494    pub const fn rtk_static(ratio_threshold: f64, partial_min_ambiguities: usize) -> Self {
495        Self {
496            differencing: DifferencingMode::DoubleDifferencePerSystemReference,
497            float_only_gating: true,
498            partial: PartialResolution::Exhaustive {
499                min_ambiguities: partial_min_ambiguities,
500            },
501            ratio_threshold,
502        }
503    }
504
505    /// The sequential RTK filter policy (`rtk_filter::update`): per-system double
506    /// differences, float-only constellations excluded, full set or nothing.
507    pub const fn rtk_sequential(ratio_threshold: f64) -> Self {
508        Self {
509            differencing: DifferencingMode::DoubleDifferencePerSystemReference,
510            float_only_gating: true,
511            partial: PartialResolution::Disabled,
512            ratio_threshold,
513        }
514    }
515
516    /// The static PPP fixed policy (`precise_positioning::fixed`): undifferenced
517    /// per-satellite ambiguities, no constellation gating, full set or nothing.
518    pub const fn ppp(ratio_threshold: f64) -> Self {
519        Self {
520            differencing: DifferencingMode::Undifferenced,
521            float_only_gating: false,
522            partial: PartialResolution::Disabled,
523            ratio_threshold,
524        }
525    }
526}
527
528/// The operation order used to normalize one residual against its weight before
529/// the sigma comparison in a per-residual screen. Naming the order keeps each
530/// screen bit-identical while the formula lives in exactly one place
531/// ([`crate::estimation::substrate::qc::normalized_residual`]).
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
533pub enum ResidualNormRecipe {
534    /// `value · weight` where `weight` is an inverse double-difference *variance*
535    /// (`1/(sigma_sat^2 + sigma_ref^2)`), so the normalized innovation is
536    /// `value / sigma^2`. The RTK sequential information filter, whose DD rows
537    /// weight by inverse variance, screens its predicted innovations this way.
538    RtkInverseVarianceInnovation,
539    /// `value · weight` where `weight` is an inverse *sigma*
540    /// (`1/sqrt(sigma_sat^2 + sigma_ref^2)`), so the normalized residual is
541    /// `value / sigma`. The RTK static float/fixed least-squares baselines, whose
542    /// DD rows weight by inverse sigma, screen their post-fit residuals this way.
543    RtkInverseSigmaResidual,
544    /// `|value| · sqrt(weight)` where `weight` is an inverse *sigma* (`1/sigma`):
545    /// the residual magnitude times the square root of the inverse-sigma weight.
546    /// The PPP float leave-one-out screen (PPP rows weight by inverse sigma, as
547    /// `MeasurementWeights` documents).
548    PppInverseSigmaMagnitude,
549}
550
551/// The residual-screen family a strategy applies after (or, for the filter,
552/// before) a solve. Strategy DATA for the P4 selector; the chi-square variant is
553/// the SPP RAIM aggregate test, the rest are per-residual sigma screens that
554/// share [`crate::estimation::substrate::qc::normalized_residual`].
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
556pub enum ScreenKind {
557    /// SPP RAIM: aggregate chi-square on the weighted residual sum, then FDE
558    /// leave-one-out exclusion (`quality::raim` / `quality::fde`).
559    RaimChiSquare,
560    /// RTK static fixed: worst information-weighted residual vs a sigma gate,
561    /// excluding the worst satellite within a budget
562    /// (`rtk_filter::fixed::solve_fixed_baseline_validated`).
563    RtkFixedResidualValidation,
564    /// RTK sequential filter: information-weighted innovation screen on predicted
565    /// DD rows, masking rejected rows and coasting (`rtk_filter::update`).
566    RtkSequentialInnovation,
567    /// PPP float: worst studentized residual vs a sigma gate, leave-one-out prune
568    /// and re-solve while WRMS improves (`precise_positioning::float`).
569    PppFloatLeaveOneOut,
570}
571
572impl ScreenKind {
573    /// The per-residual normalization op-order this screen uses, or `None` for
574    /// the aggregate chi-square RAIM screen (which scores the weighted residual
575    /// sum, not individual residuals).
576    pub const fn residual_norm(self) -> Option<ResidualNormRecipe> {
577        match self {
578            Self::RaimChiSquare => None,
579            Self::RtkFixedResidualValidation => Some(ResidualNormRecipe::RtkInverseSigmaResidual),
580            Self::RtkSequentialInnovation => Some(ResidualNormRecipe::RtkInverseVarianceInnovation),
581            Self::PppFloatLeaveOneOut => Some(ResidualNormRecipe::PppInverseSigmaMagnitude),
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn defaults_name_current_spp_behavior() {
592        // The per-stage defaults are the SPP reference op-orders, so an
593        // unspecified recipe reproduces the current SPP path.
594        assert_eq!(EstimationRecipe::default(), EstimationRecipe::spp());
595        assert_eq!(
596            RangeRecipe::default(),
597            RangeRecipe::SppMeasuredPseudorangeFixedIter
598        );
599        assert_eq!(SagnacRecipe::default(), SagnacRecipe::ClosedFormZRotation);
600        assert_eq!(FrameRecipe::default(), FrameRecipe::SppSkyfieldAuThreeIter);
601        assert_eq!(
602            NormalRecipe::default(),
603            NormalRecipe::SppWeightedResidualFiniteDifference
604        );
605        assert_eq!(SolverRecipe::default(), SolverRecipe::NalgebraTrfLegacy);
606        assert_eq!(StrategyId::default(), StrategyId::spp_reference());
607    }
608
609    #[test]
610    fn strategy_constructors_match_reference_targets() {
611        assert_eq!(
612            StrategyId::spp_reference(),
613            StrategyId::Reference {
614                technique: Technique::Spp,
615                target: ReferenceTarget::Skyfield,
616            }
617        );
618        assert_eq!(
619            StrategyId::rtk_reference(),
620            StrategyId::Reference {
621                technique: Technique::Rtk,
622                target: ReferenceTarget::Rtklib,
623            }
624        );
625        assert_eq!(
626            StrategyId::ppp_reference(),
627            StrategyId::Reference {
628                technique: Technique::Ppp,
629                target: ReferenceTarget::PppOracle,
630            }
631        );
632    }
633
634    #[test]
635    fn for_reference_selects_each_supported_pairs_recipe() {
636        assert_eq!(
637            EstimationRecipe::for_reference(Technique::Spp, ReferenceTarget::Skyfield),
638            Some(EstimationRecipe::spp())
639        );
640        assert_eq!(
641            EstimationRecipe::for_reference(Technique::Rtk, ReferenceTarget::Rtklib),
642            Some(EstimationRecipe::rtk())
643        );
644        assert_eq!(
645            EstimationRecipe::for_reference(Technique::Ppp, ReferenceTarget::PppOracle),
646            Some(EstimationRecipe::ppp())
647        );
648    }
649
650    #[test]
651    fn owned_deterministic_recipe_swaps_only_the_solver() {
652        let owned = EstimationRecipe::spp_owned_deterministic();
653        assert_eq!(owned.solver, SolverRecipe::OwnedDeterministicTrf);
654        // Every non-solver stage is the SPP reference op-order.
655        assert_eq!(
656            EstimationRecipe {
657                solver: SolverRecipe::NalgebraTrfLegacy,
658                ..owned
659            },
660            EstimationRecipe::spp()
661        );
662        assert_eq!(
663            EstimationRecipe::for_reference(Technique::Spp, ReferenceTarget::OwnedDeterministic),
664            Some(owned)
665        );
666    }
667
668    #[test]
669    fn canonical_spp_recipe_uses_the_rigorous_op_orders() {
670        let canonical = EstimationRecipe::canonical_spp();
671        // Range: full iterative light-time with closed-form Sagnac, not the SPP
672        // reference's fixed-iteration measured-pseudorange recipe.
673        assert_eq!(
674            canonical.range,
675            RangeRecipe::CanonicalLightTimeClosedFormSagnac
676        );
677        assert_ne!(canonical.range, EstimationRecipe::spp().range);
678        // Frame: one consistent meters-native WGS84 basis, not the Skyfield AU
679        // path.
680        assert_eq!(canonical.frame, FrameRecipe::CanonicalWgs84);
681        assert_ne!(canonical.frame, EstimationRecipe::spp().frame);
682        // Sagnac stays the closed-form Z-rotation (the rigorous form the SPP
683        // reference already uses); the canonical divergence is never a
684        // first-order scalar Sagnac.
685        assert_eq!(canonical.sagnac, SagnacRecipe::ClosedFormZRotation);
686        assert_ne!(canonical.sagnac, SagnacRecipe::RtklibFirstOrderScalar);
687        // Solver: the owned deterministic factorization, for run-to-run
688        // determinism on a pinned build.
689        assert_eq!(canonical.solver, SolverRecipe::OwnedDeterministicTrf);
690        assert_eq!(
691            EstimationRecipe::for_canonical(Technique::Spp),
692            Some(canonical)
693        );
694    }
695
696    #[test]
697    fn canonical_rtk_recipe_uses_the_square_root_solve() {
698        let canonical = EstimationRecipe::canonical_rtk();
699        // Normal + solver: the owned Cholesky square-root information solve, not
700        // the reference RTK first-tie Gaussian elimination.
701        assert_eq!(canonical.normal, NormalRecipe::CanonicalSquareRoot);
702        assert_eq!(canonical.solver, SolverRecipe::OwnedDeterministicCholesky);
703        assert_ne!(canonical.normal, EstimationRecipe::rtk().normal);
704        assert_ne!(canonical.solver, EstimationRecipe::rtk().solver);
705        // Measurement physics stays the RTK reference double-difference model: the
706        // canonical RTK divergence is in the linear algebra, not the observation
707        // model, so range/sagnac/frame match the reference.
708        assert_eq!(canonical.range, EstimationRecipe::rtk().range);
709        assert_eq!(canonical.sagnac, EstimationRecipe::rtk().sagnac);
710        assert_eq!(canonical.frame, EstimationRecipe::rtk().frame);
711        assert_eq!(
712            EstimationRecipe::for_canonical(Technique::Rtk),
713            Some(canonical)
714        );
715    }
716
717    #[test]
718    fn canonical_ppp_recipe_uses_the_square_root_solve() {
719        let canonical = EstimationRecipe::canonical_ppp();
720        // Normal + solver: the owned Cholesky square-root information solve, not
721        // the reference PPP dense last-tie Gaussian elimination.
722        assert_eq!(canonical.normal, NormalRecipe::CanonicalSquareRoot);
723        assert_eq!(canonical.solver, SolverRecipe::OwnedDeterministicCholesky);
724        assert_ne!(canonical.normal, EstimationRecipe::ppp().normal);
725        assert_ne!(canonical.solver, EstimationRecipe::ppp().solver);
726        // Measurement physics stays the PPP reference undifferenced model: the
727        // canonical PPP divergence is in the linear algebra, not the observation
728        // model, so range/sagnac/frame match the reference.
729        assert_eq!(canonical.range, EstimationRecipe::ppp().range);
730        assert_eq!(canonical.sagnac, EstimationRecipe::ppp().sagnac);
731        assert_eq!(canonical.frame, EstimationRecipe::ppp().frame);
732        // Canonical RTK and PPP share the square-root normal + owned Cholesky
733        // solver (the same numerically rigorous SPD op-order).
734        assert_eq!(canonical.normal, EstimationRecipe::canonical_rtk().normal);
735        assert_eq!(canonical.solver, EstimationRecipe::canonical_rtk().solver);
736        assert_eq!(
737            EstimationRecipe::for_canonical(Technique::Ppp),
738            Some(canonical)
739        );
740    }
741
742    #[test]
743    fn for_canonical_wires_all_three_techniques() {
744        assert_eq!(
745            EstimationRecipe::for_canonical(Technique::Spp),
746            Some(EstimationRecipe::canonical_spp())
747        );
748        assert_eq!(
749            EstimationRecipe::for_canonical(Technique::Rtk),
750            Some(EstimationRecipe::canonical_rtk())
751        );
752        assert_eq!(
753            EstimationRecipe::for_canonical(Technique::Ppp),
754            Some(EstimationRecipe::canonical_ppp())
755        );
756    }
757
758    #[test]
759    fn for_reference_rejects_impossible_pairs() {
760        // Cross-technique oracles and the unwired scipy reference are not
761        // supported reference strategies.
762        for (technique, target) in [
763            (Technique::Spp, ReferenceTarget::Rtklib),
764            (Technique::Spp, ReferenceTarget::PppOracle),
765            (Technique::Spp, ReferenceTarget::Scipy),
766            (Technique::Rtk, ReferenceTarget::Skyfield),
767            (Technique::Rtk, ReferenceTarget::OwnedDeterministic),
768            (Technique::Rtk, ReferenceTarget::PppOracle),
769            (Technique::Ppp, ReferenceTarget::Skyfield),
770            (Technique::Ppp, ReferenceTarget::OwnedDeterministic),
771        ] {
772            assert_eq!(
773                EstimationRecipe::for_reference(technique, target),
774                None,
775                "{technique:?} + {target:?} must be rejected"
776            );
777        }
778    }
779
780    #[test]
781    fn reference_ambiguity_policies_name_current_behavior() {
782        let rtk_static = AmbiguityIdPolicy::rtk_static(3.0, 4);
783        assert_eq!(
784            rtk_static.differencing,
785            DifferencingMode::DoubleDifferencePerSystemReference
786        );
787        assert!(rtk_static.float_only_gating);
788        assert_eq!(
789            rtk_static.partial,
790            PartialResolution::Exhaustive { min_ambiguities: 4 }
791        );
792
793        let rtk_seq = AmbiguityIdPolicy::rtk_sequential(3.0);
794        assert_eq!(
795            rtk_seq.differencing,
796            DifferencingMode::DoubleDifferencePerSystemReference
797        );
798        assert!(rtk_seq.float_only_gating);
799        assert_eq!(rtk_seq.partial, PartialResolution::Disabled);
800
801        let ppp = AmbiguityIdPolicy::ppp(2.5);
802        assert_eq!(ppp.differencing, DifferencingMode::Undifferenced);
803        assert!(!ppp.float_only_gating);
804        assert_eq!(ppp.partial, PartialResolution::Disabled);
805    }
806
807    #[test]
808    fn rtk_and_ppp_id_policies_differ_only_in_data() {
809        // Same LAMBDA kernel, different identity/eligibility data: the two stacks
810        // are no longer separate algorithm trees, only different policy values.
811        let rtk = AmbiguityIdPolicy::rtk_static(3.0, 1);
812        let ppp = AmbiguityIdPolicy::ppp(3.0);
813        assert_ne!(rtk.differencing, ppp.differencing);
814        assert_ne!(rtk.float_only_gating, ppp.float_only_gating);
815        assert_ne!(rtk.partial, ppp.partial);
816    }
817
818    #[test]
819    fn screen_kinds_select_their_normalization_order() {
820        assert_eq!(ScreenKind::RaimChiSquare.residual_norm(), None);
821        assert_eq!(
822            ScreenKind::RtkFixedResidualValidation.residual_norm(),
823            Some(ResidualNormRecipe::RtkInverseSigmaResidual)
824        );
825        assert_eq!(
826            ScreenKind::RtkSequentialInnovation.residual_norm(),
827            Some(ResidualNormRecipe::RtkInverseVarianceInnovation)
828        );
829        assert_eq!(
830            ScreenKind::PppFloatLeaveOneOut.residual_norm(),
831            Some(ResidualNormRecipe::PppInverseSigmaMagnitude)
832        );
833    }
834
835    #[test]
836    fn each_strategy_selects_a_distinct_solver_order() {
837        // The three reference strategies must not collapse onto one solver
838        // op-order; that distinction is what preserves their separate goldens.
839        assert_ne!(
840            EstimationRecipe::spp().solver,
841            EstimationRecipe::rtk().solver
842        );
843        assert_ne!(
844            EstimationRecipe::rtk().solver,
845            EstimationRecipe::ppp().solver
846        );
847        assert_ne!(
848            EstimationRecipe::spp().solver,
849            EstimationRecipe::ppp().solver
850        );
851    }
852}