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