Skip to content

Type System

Claude edited this page May 10, 2026 · 5 revisions

The astrodyn Type System

Last updated 2026-05-10 (covers type-system follow-on PRs through #417, including the Vehicle phantom kind, <SelfRef>/<SelfPlanet> discipline (TS.01), the typed GravParam<P> / OrbitalElements<P> / AtmosphereState<P> wave, untyped-construction discontinuation at the public surface (#397), round-trip property tests for every typed sibling (#398), typed Simulation::body_mass (#408), and mass-tree wildcard typing on KinematicNodeState.trans (#396).)

This document is the contributor primer for the typed quantity layer added by the type-system refactor (#101, Phases 0–11). It serves two audiences:

  • astrodyn internal contributors adding a new frame, time scale, dimension, or recipe.
  • Mission-crate authors decoding compiler errors and understanding the conventions encoded in the type system.

If you are writing mission code (downstream of astrodyn), start with crates/astrodyn_bevy/examples/typed_mission.rs and the ## Building a Mission Crate section in CLAUDE.md. Use this document as the reference when you need to know why the compiler refused something.

1. Why a typed layer

astrodyn reimplements NASA JEOD orbital mechanics. JEOD's C++ API uses naked doubles and double[3]s carrying conventions in field names and comments — sign conventions, frame conventions, time scales, quaternion layouts. Those conventions are not compile-checked; getting one wrong produces code that compiles, passes trivial tests, and silently gives wrong answers at scale.

The motivating incident (catalogued in CLAUDE.md "JEOD Convention Rule"): an agent guessed M = 2π − n·t for the JEOD time_periapsis → mean anomaly formula. The correct convention is M = n·t. The bug produced 11,668 km error against NASA flight data and was hidden for multiple commits because a broken test path silently skipped the validation. Reading models/dynamics/body_action/src/dyn_body_init_orbit.cc would have given the correct formula immediately, but no compile-time check could fire.

The type-system refactor moves this class of bug from runtime/discipline to compile time. Frame mismatches, time-scale mismatches, scalar-vs-vector quaternion confusion, and unit-dimensional errors are now compile errors — in physics language, via custom #[diagnostic::on_unimplemented] messages.

2. The three-layer facade

┌──────────────────────────────────────────────────────────┐
│ Facade  (astrodyn_bevy::prelude, astrodyn::recipes)          │
│   F64Ext: 400.0.km(), 51.6.deg(), 420_000.0.kg()         │
│   Concrete Component wrappers (no visible generics)      │
│   Custom #[diagnostic::on_unimplemented] messages        │
├──────────────────────────────────────────────────────────┤
│ Typed astrodyn_* siblings                                     │
│   Position<F: Frame>, SecondsSince<S: TimeScale>,        │
│   Quat<L, T>, NormalizedQuat, FrameTransform<From, To>   │
├──────────────────────────────────────────────────────────┤
│ astrodyn_quantities  (bottom of dep graph)                   │
│   uom re-exports, Qty3<D, F>, phantom frames/scales,     │
│   F64Ext / Vec3Ext / Array3Ext                           │
└──────────────────────────────────────────────────────────┘
  • astrodyn_quantities sits at the bottom of the workspace dependency graph. Every other astrodyn_* crate depends on it. It defines the typed primitives:

    • Qty3<D, F> — componentwise 3-vector with uom dimension D and phantom frame tag F. Aliases: Position<F>, Velocity<F>, Acceleration<F>, Force<F>, Torque<F>, etc.
    • SecondsSince<S> — a uom Time carrying a phantom time-scale tag S.
    • Quat<L, T> — quaternion carrying phantom layout (ScalarFirst/ScalarLast) and transform-convention (LeftTransform/RightTransform) tags. NormalizedQuat<L, T> is a constructor-gated witness.
    • FrameTransform<From, To> — typed transform that composes only when inner frames match.
    • F64Ext — the facade trait that lets mission code write 400.0.km() instead of Length::new::<kilometer>(400.0).
  • Typed astrodyn_* siblings — every public physics function in astrodyn_* has a typed entry point (the f64 forms were deleted in Phase 10). Each typed function takes typed inputs, calls .raw_si() to drop into the shared kernel for arithmetic density, and re-wraps on exit.

  • Facadeastrodyn_bevy::prelude and astrodyn::recipes re-export concrete typed Components and recipe functions so mission code never sees PhantomData or uom::si::* paths. The typestate VehicleBuilder (NeedsState → NeedsMass → HasIntegrator → Ready) gates construction ordering at compile time.

3. Phantom tags catalog

Frames (crates/astrodyn_quantities/src/frame.rs)

Tag Meaning
RootInertial The simulation's unique root inertial frame
PlanetInertial<P> A particular planet's inertial frame (centered on P's CoM, non-rotating)
IntegrationFrame A body's integration frame; only convertible to RootInertial via the IntegOrigin shift API (issue #255 / RF.10)
Ecef Earth-centered Earth-fixed (rotates with Earth)
PlanetFixed<P> Generic planet-fixed frame parameterized by planet P (Earth, Moon, Mars, Sun, …)
BodyFrame<V> Body-fixed frame of a vehicle V
StructuralFrame<V> Structural reference frame of a vehicle V (used for sensor placement, attachment)
Lvlh<Chief> Local Vertical / Local Horizontal frame relative to a chief body
Ned<Chief> North-East-Down topocentric frame relative to a chief body
SelfRef "Same vehicle as the carrier" — Vehicle-side wildcard for runtime-resolved per-entity storage. See "SelfRef / SelfPlanet storage-boundary discipline" below (TS.01).
SelfPlanet "Same planet as the carrier" — Planet-side wildcard for runtime-resolved per-entity storage. See "SelfRef / SelfPlanet storage-boundary discipline" below (TS.01).

RootInertial, PlanetInertial<P>, and IntegrationFrame are deliberately kind-distinct phantoms (issue #255 / RF.10). Body integration-frame state cannot silently flow into root-inertial-only consumers (gravity, SRP, relativistic corrections); the only safe transition is via IntegOrigin::shift. See docs/JEOD_invariants.md row RF.10 for which call sites are structurally guarded vs convention-only.

Frame phantoms only exist at the type level; they have no runtime representation.

Time scales (crates/astrodyn_quantities/src/time_scale.rs)

TAI, UTC, UT1, TT, TDB, GPS, GMST. SecondsSince<S> carries the phantom; TimeConverter<From, To> is the explicit conversion entry point (e.g., TAI_TO_TT, GPS_TO_TAI).

Quaternion conventions (crates/astrodyn_quantities/src/quat.rs)

Axis Tags
Layout ScalarFirst (JEOD's convention: [q0, q1, q2, q3]), ScalarLast (glam's: [x, y, z, w])
Transform LeftTransform (JEOD: r' = q r q⁻¹), RightTransform
Normalization Quat<L, T> (raw), NormalizedQuat<L, T> (witness; constructed via NormalizedQuat::new(q)? or NormalizedQuat::renormalize(q))

JEOD-internal physics uses Quat<ScalarFirst, LeftTransform>. Conversion to glam::DQuat (which is ScalarLast) happens at module boundaries. JeodQuat is a type alias for Quat<ScalarFirst, LeftTransform>.

Vehicle and Planet phantoms

Planet and Vehicle are the two domains that downstream crates can extend (via define_planet! / define_vehicle! — see §4). Every other public marker trait is type-system-sealed.

Domain In-crate markers Used by
Planet Earth, Moon, Sun, Mars, SelfPlanet PlanetInertial<P>, PlanetFixed<P>, GravParam<P>, OrbitalElements<P>, AtmosphereState<P>, OrbitalElementsC<P>
Vehicle SelfRef, plus TestVehicle (test-utils feature) BodyFrame<V>, StructuralFrame<V>, Lvlh<V>, Ned<V>, FlatPlate<V>, FlatPlateState<V>, RelativeState<Subject, Reference>, RelativeTranslation<Reference>, LvlhRelativeState<Chief>, AttachEvent<VParent, VChild>

The Vehicle-parameterized types in the right column above are the Act-5 phantom-wrapped types. Each exposes a zero-cost witness method — assert_vehicle::<W>() for single-vehicle types, assert_pair::<S, R>() for paired types — whose where bound resolves only when the caller's turbofish matches the value's vehicle phantom. On mismatch, the CompatibleVehicles / CompatibleVehiclePair diagnostic fires in physics language instead of a PhantomData<…> wall. Mission code reaches for the witness at the boundary that hands the value to a slot of a specific identity (e.g. plate.assert_vehicle::<Iss>()).

SelfRef / SelfPlanet storage-boundary discipline (TS.01)

SelfRef and SelfPlanet are wildcard markers for slots whose vehicle or planet identity is resolved at runtime by the entity / runner-state slot itself — the type system cannot statically know which mission's vehicle is in any given Bevy Entity. They appear only at per-entity storage boundaries:

  • Bevy Component field types under crates/astrodyn_bevy/src/components/ (e.g. RotationalStateC, MassPropertiesC, TotalForceC, FlatPlateConfigC, GravityTorqueC).
  • Bevy Message types and the canonical <SelfRef, SelfRef> adapter registration in crates/astrodyn_bevy/src/lib.rs.
  • Runner state slots in crates/astrodyn_runner/src/simulation/types.rs (e.g. flat_plate_state: Option<FlatPlateState<SelfRef>>, atmospheric_state: Option<AtmosphereState<SelfPlanet>>, orbital_elements: Option<OrbitalElements<SelfPlanet>>).
  • Dynamic-registry-erased return types from compute_orbital_elements, evaluate_atmosphere, and PlanetConfig::mu_typed.

Everything else uses concrete or generic-bounded phantoms (<P: Planet>, <V: Vehicle>). A drive-by let foo: RelativeState<SelfRef, SelfRef> = … inside a system body is forbidden — that path must take <S: Vehicle, R: Vehicle> from the call site. The tests/self_ref_self_planet_discipline.rs lint enforces the bidirectional invariant: every new SelfRef / SelfPlanet token outside the catalogued allow-list fails CI, and every allow-listed site must carry a // JEOD_INV: TS.01 marker (inline, immediately preceding, or file-level //!). The full catalogue and rationale live in row TS.01 of docs/JEOD_invariants.md.

The companion rule is "no default type parameters on Planet-aware types" (no <P: Planet = Earth>): defaults silently relax to <SelfPlanet> when inference has no constraint, hiding a missing pinning decision.

4. Adding a new frame / time scale / quantity

The seal is per-domain, not a single shared Sealed. Each public marker trait has its own seal trait private to astrodyn_quantities:

Public trait Seal trait Re-exported via __macro_support?
Frame FrameSealed No — closed at the type-system level
TimeScale TimeScaleSealed No — closed at the type-system level
Layout, Transform QuatSealed No — closed at the type-system level
Vehicle VehicleSealed Yes — so define_vehicle! works downstream
Planet PlanetSealed Yes — so define_planet! works downstream

This split intentionally opens the Vehicle / Planet catalogs to downstream extension via the define_*! macros, while keeping Frame, TimeScale, Layout, and Transform fully closed — downstream code cannot impl them at all (the seal trait is unreachable). All five public traits require const NAME: &'static str.

A new vehicle or planet marker (downstream extensible)

Mission crates that model multiple vehicles (e.g., a chief + deputy formation, or the ISS plus a visiting Soyuz) need distinct compile-time Vehicle markers so Position<BodyFrame<Iss>> and Position<BodyFrame<Soyuz>> are type-distinct. Use the define_vehicle! / define_planet! macros, which are the only way to extend the Vehicle / Planet catalog from outside astrodyn_quantities:

use astrodyn_bevy::prelude::*;

define_vehicle!(Iss);
define_vehicle!(Soyuz);
define_planet!(Pluto);

// Each generates a zero-sized marker type with a sealed `Vehicle`
// (or `Planet`) impl.
let _iss_pos: Position<BodyFrame<Iss>> = Qty3::zero();
let _soyuz_pos: Position<BodyFrame<Soyuz>> = Qty3::zero();
// `Position<BodyFrame<Iss>> + Position<BodyFrame<Soyuz>>` is a
// compile error with the standard frame-mismatch diagnostic.

The macros generate pub struct $name;, the per-domain sealed impl (VehicleSealed or PlanetSealed), and the Vehicle / Planet impl with const NAME: &'static str = stringify!($name). The seal traits for these two domains are re-exported via astrodyn_quantities::__macro_support so the macros can satisfy them at downstream call sites. Direct impl Vehicle for X {} outside the macro is technically possible but unsupported — use the macro.

Frame::NAME is still the kind ("BodyFrame", "PlanetFixed"), not the per-vehicle identifier — const &'static str cannot splice V::NAME at compile time. For diagnostics that need the fully-qualified name (e.g., Iss rather than BodyFrame), use std::any::type_name::<F>(), which Qty3's Debug impl already does.

A new frame kind (in-crate only)

Adding a new frame kind (something on par with Inertial / Ecef / BodyFrame, not a per-vehicle parameter) requires editing crates/astrodyn_quantities/src/frame.rs:

// Inside crate astrodyn_quantities:
use crate::sealed::FrameSealed;

#[derive(Debug, Clone, Copy)]
pub struct MyFrame;
impl FrameSealed for MyFrame {}
impl Frame for MyFrame {
    const NAME: &'static str = "MyFrame";
}

Then:

  1. Re-export from prelude.rs (and from astrodyn::lib.rs so the root astrodyn_bevy::prelude picks it up via astrodyn's re-export chain).
  2. Add a FrameTransform<MyFrame, Existing> (and inverse) constructor where appropriate — typically in the crate that owns the physics translating between the frames.
  3. Add a tier-1 unit test verifying the transform round-trips.

For a parametric frame kind (planet- or vehicle-tagged), use the PlanetFixed<P> / BodyFrame<V> patterns already in frame.rs as templates — they wrap a PhantomData<P> and impl FrameSealed / Frame with a generic bound.

A new time scale

Add to crates/astrodyn_quantities/src/time_scale.rs:

use crate::sealed::TimeScaleSealed;

#[derive(Debug, Clone, Copy)]
pub struct MyScale;
impl TimeScaleSealed for MyScale {}
impl TimeScale for MyScale {
    const NAME: &'static str = "MyScale";
}

Then:

  1. Define TimeConverter::<MyScale, From> and the inverse with the actual physics in astrodyn_time::time_<myscale>.
  2. Re-export from prelude.rs.
  3. Add tier-1 round-trip + tier-2 reference-vector tests.

A new dimensional quantity

  1. If uom already has the dimension (most common), add a type alias to crates/astrodyn_quantities/src/aliases.rs and a Qty3 alias for the 3-vector form:
    pub type AngularMomentum<F> = Qty3<dims::AngularMomentum, F>;
  2. If the dimension is new, add it to crates/astrodyn_quantities/src/dims.rs using uom's dimension macros.
  3. Add F64Ext constructor methods (e.g., .kg_m2_per_s()).
  4. Re-export from prelude.rs.

5. Reading compiler errors

The custom diagnostics in crates/astrodyn_quantities/src/diagnostics.rs are zero-cost marker traits whose only purpose is to carry a tailored #[diagnostic::on_unimplemented] message that fires when the marker bound fails. The error then renders in physics language instead of as a generic "trait not implemented" wall.

5.1 Cheat sheet: what the type system catches

Guard What it catches Mechanism
Dimensional mismatch Position + Mass etc. Qty3's Add/Sub requires matching dimension D (uom-native)
Frame mismatch Position<Ecef> + Position<RootInertial> CompatibleFrames<Fl, Fr> bound on Add/Sub/+=/-= — ✓ custom diagnostic
Time-scale separation SecondsSince<TAI> - SecondsSince<TT> SecondsSince<S> has no Add/Sub impl (structural)
Quaternion layout ScalarFirstScalarLast confusion distinct phantom-tagged types on Quat<L, T>
Quat transform convention LeftTransformRightTransform confusion distinct types
Normalization witness Raw Quat where unit required NormalizedQuat<L, T> is a separate type from Quat<L, T>
FrameTransform composition A→B ∘ C→D with B≠C type identity (only composes when inner frames align)
Cross-dimension * / / Output dim must unify with target typenum::Sum / Diff on Mul<Quantity> / Div<Quantity> impl bounds
Inertial-flavor distinction Body-integration state into root-inertial consumer RootInertial / PlanetInertial<P> / IntegrationFrame are kind-distinct phantoms (RF.10)
GravParam<P> from bare f64 from_cartesian_typed::<Earth>(0.0, …) IntoGravParam<P> bound — ✓ custom diagnostic naming P
GravParam source-body mismatch μ_Sun passed where GravParam<Earth> expected CompatibleGravParam<PE, PF> bound — ✓ custom diagnostic
Vehicle phantom mismatch single-vehicle type tagged <Iss> into a <Soyuz> slot CompatibleVehicles<VL, VR> bound (witness method assert_vehicle) — ✓ custom diagnostic
Paired-vehicle mismatch RelativeState<Iss, Soyuz> into a <Iss, Cygnus> slot CompatibleVehiclePair<S1, R1, S2, R2> bound (witness method assert_pair) — ✓ custom diagnostic

Rows marked "✓ custom diagnostic" carry an active #[diagnostic::on_unimplemented] message that fires when the marker bound fails. The other rows are enforced structurally — the type identities themselves refuse the operation, and the resulting "trait not implemented" or "mismatched types" error is already informative without further decoration.

5.2 Frame mismatch on + / -

let a: Position<RootInertial> = ...;
let b: Position<Ecef> = ...;
let _ = a + b; // ← error
error: cannot combine values in frame `RootInertial` with values in frame `Ecef`
   = note: apply a `FrameTransform<Ecef, RootInertial>` (or its inverse) to bring
           both operands into the same frame before combining

5.3 FrameTransform composition mismatch

let r_eci_to_ecef:  FrameTransform<RootInertial, Ecef>        = ...;
let r_lvlh_to_body: FrameTransform<Lvlh<Iss>, BodyFrame<Iss>> = ...;
let _bad = r_eci_to_ecef * r_lvlh_to_body;  // ← compile error

The Mul impl on FrameTransform<A, B> only typechecks against FrameTransform<B, C>, producing FrameTransform<A, C>. Composing <RootInertial, Ecef> with <Lvlh<Iss>, BodyFrame<Iss>> is rejected because Ecef ≠ Lvlh<Iss>. Likewise, FrameTransform::<A, B>::identity() is only defined for A = B.

5.4 Time-scale separation

let t_tai: SecondsSince<TAI> = SecondsSince::from_seconds(0.0);
let t_tt:  SecondsSince<TT>  = SecondsSince::from_seconds(0.0);
let _bad = t_tai - t_tt;  // ← compile error

SecondsSince<S> deliberately has no Add/Sub impl across distinct scales. To combine scales, route through a TimeConverter:

let t_tt_converted = TimeConverter::TAI_TO_TT.apply(t_tai);
let dt = t_tt_converted.as_seconds() - t_tt.as_seconds();

5.5 Normalization witness

fn apply_rotation(q: NormalizedQuat<ScalarFirst, LeftTransform>) { ... }

let q: JeodQuat = JeodQuat::from_array([1.0, 0.5, 0.0, 0.0]);  // not unit-norm
apply_rotation(q);  // ← compile error: expected NormalizedQuat, found Quat

Quat<L, T> and NormalizedQuat<L, T> are distinct types. APIs that need a unit quaternion take NormalizedQuat<L, T>; callers must construct via NormalizedQuat::new(q)? (validates |q| − 1 < 1e-12) or NormalizedQuat::renormalize(q) (forces |q| = 1).

5.6 Inertial-flavor distinction

fn gravity_pull(r_root: Position<RootInertial>) -> Acceleration<RootInertial> { ... }

let r_integ: Position<IntegrationFrame> = ...;
let _bad = gravity_pull(r_integ);  // ← compile error

IntegrationFrame is a kind-distinct phantom from RootInertial. The only safe transition is body.trans.to_inertial(&integ_origin), the documented IntegOrigin shift API applied at shift sites only. See RF.10 in docs/JEOD_invariants.md for which call sites are structurally guarded vs convention-only and why each consumer falls into one or the other.

5.7 Vector × Vector ambiguity

let r: Position<RootInertial> = ...;
let v: Velocity<RootInertial> = ...;
let _ = r * v; // ← compile error

There is no Mul<Qty3<D2, F>> impl for Qty3<D1, F>, so the compiler rejects the multiply. Use .dot(other) for the scalar product (Position · Velocity → Quantity<m²/s>) or .cross(other) for the vector product. Today this surfaces as the default "no Mul impl" message; the NoVectorVectorMul diagnostic in diagnostics.rs is scaffolding for a future tailored hint (see §5.9).

5.8 Vehicle-phantom mismatch via witness method

The Act-5 phantom-wrapped types each expose a zero-cost assert_* witness method whose where bound is (): CompatibleVehicles<…> (or CompatibleVehiclePair<…> for paired types). The bound only resolves when the caller's turbofish matches the value's vehicle phantom:

let plate: FlatPlate<Iss> = ...;
let _ = plate.assert_vehicle::<Soyuz>(); // ← compile error
error: vehicle mismatch: cannot combine values tagged `Iss` with values tagged `Soyuz`
   = note: the two vehicle phantoms must agree — pin the same `Vehicle` marker on
           both sides (e.g. via `define_vehicle!`), or rebuild the value for the
           right vehicle if it was constructed for a different one

Witness methods today: FlatPlate<V>::assert_vehicle::<W>(), AttachEvent<VParent, VChild>::assert_pair::<P, C>(), and RelativeState<Subject, Reference>::assert_pair::<S, R>().

5.9 Scaffolded but not currently wired

For each of the following, the #[diagnostic::on_unimplemented] message in diagnostics.rs is in place but no impl in the workspace currently uses the trait as a where bound. Today these operations either compile (because no enforcing impl exists) or produce the stock "mismatched types" / "trait not implemented" error rather than the tailored hint:

  • IntoLength, IntoAngle — would activate if a function signature took impl IntoLength instead of Length. Today, passing a bare f64 where a Length is expected produces uom's stock "mismatched types" error. (IntoGravParam<P> is wired — see §5.1.)
  • CompatibleTimeScalesSecondsSince<S> has no cross-scale arithmetic to constrain (the time-scale guard is structural today, per §5.4).
  • CompatibleQuatLayouts, CompatibleQuatTransforms — the layout/transform guards are structural too: Quat<ScalarFirst, _> and Quat<ScalarLast, _> are distinct types, so cross-layout operations are rejected by type identity rather than by a marker bound.
  • RequiresNormalizedQuat — APIs take NormalizedQuat directly (per §5.5), so the bound never fires.
  • InertialOnly — earmarked for "this op needs root-inertial input" diagnostics.
  • NoVectorVectorMul — earmarked for the r * v case in §5.7.

These scaffolds let a future contributor flip on an active guard without touching call sites — the diagnostic message is already in place. New scaffolds should follow the same pattern: zero-cost marker trait, #[diagnostic::on_unimplemented] message in physics language, the note: suggesting the corrective API.

6. Runtime escape hatches

The type system has two documented escape hatches. Both are deliberate; both require justification in the PR description that introduces a use.

Raw / _unchecked constructors

The crate provides constructors that bypass invariant validation when the caller has external proof that the invariant holds. They follow two conventions depending on the invariant:

  • Trusted SI-unit boundary: Qty3::from_raw_si(DVec3) accepts a raw glam::DVec3 interpreted in SI base units. There is no separate _unchecked variant — the choice of which frame phantom to attach is the caller's responsibility, and the only "unchecked" aspect is that the SI interpretation is taken on faith. Use at JEOD-CSV / glam boundary code (e.g., reading the t=0 row of a reference CSV).
  • Genuine _unchecked skip: InertiaTensor::from_dmat3_unchecked(DMat3) bypasses any symmetry check (the validating constructors are from_principal and from_components, both of which build a symmetric tensor from scalar entries by construction). Use the _unchecked form only when the symmetry of the source matrix is guaranteed externally (e.g. rotating a verified-symmetric tensor through an orthogonal change-of-basis: R^T · I · R preserves symmetry up to floating-point noise).
  • Validated transform boundary: FrameTransform::from_matrix(DMat3) panics in debug builds if the matrix is not orthonormal but does not re-validate in release; FrameTransform::from_matrix_validated returns a Result<_, FrameTransformError> and is the right choice for unvalidated inputs (DE421 / RNP / Trick CSV kernels).

For quaternion validity, use NormalizedQuat::new(q)? (which validates the norm against NormalizedQuat::DEFAULT_TOLERANCE = 1e-12 and returns Err(NotNormalized) if it fails) or NormalizedQuat::renormalize(q) (which forces |q| = 1 and returns Option). There is no from_raw_unchecked variant; callers that need the witness without a runtime check should renormalize.

// allowed: comments

The escape-hatch CI guard (scripts/check_no_escape_hatches.sh) polices two categories of bypass:

  1. Marker-based (#[doc(hidden)] and the tag_as_inertial! macro) are banned across crates/ and src/. The tag_as_inertial! macro does not currently exist — the script greps defensively to keep the door closed against future re-introduction.

  2. Typed-quantity bypass constructors (from_untyped_unchecked, from_dmat3_unchecked, from_raw_si, SecondsSince::from_seconds, (JeodQuat|Quat)::from_array, FrameTransform::from_matrix() are banned in the gateway (src/**), the Bevy adapter (crates/astrodyn_bevy/src/**), and the Bevy adapter integration tests (crates/astrodyn_bevy/tests/**). Each of these constructors mints a typed value from primitive storage without checking the caller's phantom tags / conventions / normalization invariants — per-step uses in the production path defeat the typed-quantity facade. Canonical boundary modules are exempt: crates/astrodyn_bevy/src/components.rs plus crates/astrodyn_bevy/src/components/ and crates/astrodyn_bevy/src/lib.rs::spawn_bevy for the Bevy adapter, the runner construction-boundary modules under crates/astrodyn_runner/src/simulation/ for the runner, and #[cfg(test)] modules under src/**. Inside crates/astrodyn_*/src/ the bypass is part of the typed-sibling boundary by construction and is not policed.

Both categories are exempted line-by-line via // allowed: <reason>, either inline or on a pure-comment line immediately preceding the bypass. Each exemption is reviewed at PR time.

If a future contributor needs to bypass the type system at a public surface, the answer is almost always to extend the type system to express the missing case — not to widen the escape hatches. The escape hatches exist for legacy boundaries (JEOD CSV ingestion, glam::DQuat interop, integrator-loop internals) and should not grow.

7. References

  • Source:

    • crates/astrodyn_quantities/src/lib.rs — crate root with module-level docs.
    • crates/astrodyn_quantities/src/diagnostics.rs — full custom-diagnostic catalog.
    • crates/astrodyn_quantities/src/frame.rs, time_scale.rs, quat.rs — phantom-tag definitions.
    • crates/astrodyn_quantities/src/qty3.rsQty3<D, F> and its operations.
  • Worked examples:

    • crates/astrodyn_bevy/examples/typed_mission.rs — canonical Bevy mission-crate composition.
    • crates/astrodyn_bevy/examples/kepler_orbit.rs — minimal orbit propagator.
    • crates/astrodyn_runner/examples/{apollo,leo_drag,batch_propagation}.rs — runner-side scenario demonstrations (non-Bevy harness).
    • crates/astrodyn_verif_jeod/examples/{earth_moon,mars_orbit}.rs — multi-body / interplanetary scenarios driven by the verification harness.
  • Discipline lint:

    • tests/self_ref_self_planet_discipline.rs — bidirectional CI lint enforcing TS.01 (<SelfRef> / <SelfPlanet> allow-list).
    • docs/JEOD_invariants.md row TS.01 — the catalogued allow-list and rationale.
  • Architecture:

    • Strategy §8 "Phase 8: Type-System Refactor (#101)".
    • CLAUDE.md "Precision" and "Building a Mission Crate" sections.
  • Phase issues (closed): #102, #103, #104, #105, #106, #107, #108, #109, #110, #111, #112, #113. Parent: #101.

  • Follow-on PRs landed since Phase 11 (this page is up to date through these): typed TranslationalStateC<P: Planet> end-to-end (#263); frame_switch_system rewrite (#280); Reflect derives dropped, Planet-flavored components promoted to <P: Planet> (#300); typed AtmosphereState<P> (#302); typed GravParam<P> + OrbitalElements<P> closing the μ-vs-frame phantom gap (#303, #306); Section-B audit (LightingBody, FlatPlate, LvlhRelativeState, DetachedSubtreeState, AttachEvent) (#304); Vehicle phantom kind + <SelfRef> wildcard tightening on FlatPlate / LvlhRelativeState / AttachEvent.offset (#332); typed AttachEvent.t_parent_child with paired <VParent, VChild> phantoms (#343); typed RelativeTranslation / RelativeState with paired <Subject, Reference> vehicle phantoms (#344); #[diagnostic::on_unimplemented] on Act-5 phantom-wrapped types (#353); <SelfRef> / <SelfPlanet> wildcard discipline catalogued and linted as TS.01 (#356); SimBody.atmospheric_state migrated to typed sibling (#380); typed-quantity bypass constructors driven out of the gateway (#388); untyped construction discontinued across the public surface (#397); round-trip property tests landed for every typed sibling (#398 / #410); Simulation::body_mass returns the typed sibling (#408 / #409); and KinematicNodeState.trans is now typed via the mass-tree wildcard phantom (#396 / #417).

  • Future work (deferred trackers filed at Phase 11 close-out): #150 (session types), #151 (capability tokens), #152 (branded simulation lifetimes), #153 (Docker CSV pipeline), #154 (Bevy Reflect), #155 (FrameTransform Component erasure), #156 (pre_step hook), #157 (EvaluationCase shape).

Clone this wiki locally