-
Notifications
You must be signed in to change notification settings - Fork 0
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:
-
astrodyninternal 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.
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.
┌──────────────────────────────────────────────────────────┐
│ 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_quantitiessits at the bottom of the workspace dependency graph. Every otherastrodyn_*crate depends on it. It defines the typed primitives:-
Qty3<D, F>— componentwise 3-vector withuomdimensionDand phantom frame tagF. Aliases:Position<F>,Velocity<F>,Acceleration<F>,Force<F>,Torque<F>, etc. -
SecondsSince<S>— auomTimecarrying a phantom time-scale tagS. -
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 write400.0.km()instead ofLength::new::<kilometer>(400.0).
-
-
Typed
astrodyn_*siblings — every public physics function inastrodyn_*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. -
Facade —
astrodyn_bevy::preludeandastrodyn::recipesre-export concrete typed Components and recipe functions so mission code never seesPhantomDataoruom::si::*paths. The typestateVehicleBuilder(NeedsState → NeedsMass → HasIntegrator → Ready) gates construction ordering at compile time.
| 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.
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).
| 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>.
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 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
Componentfield types undercrates/astrodyn_bevy/src/components/(e.g.RotationalStateC,MassPropertiesC,TotalForceC,FlatPlateConfigC,GravityTorqueC). - Bevy
Messagetypes and the canonical<SelfRef, SelfRef>adapter registration incrates/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, andPlanetConfig::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.
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.
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.
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:
- Re-export from
prelude.rs(and fromastrodyn::lib.rsso the rootastrodyn_bevy::preludepicks it up viaastrodyn's re-export chain). - Add a
FrameTransform<MyFrame, Existing>(and inverse) constructor where appropriate — typically in the crate that owns the physics translating between the frames. - 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.
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:
- Define
TimeConverter::<MyScale, From>and the inverse with the actual physics inastrodyn_time::time_<myscale>. - Re-export from
prelude.rs. - Add tier-1 round-trip + tier-2 reference-vector tests.
- If
uomalready has the dimension (most common), add a type alias tocrates/astrodyn_quantities/src/aliases.rsand aQty3alias for the 3-vector form:pub type AngularMomentum<F> = Qty3<dims::AngularMomentum, F>;
- If the dimension is new, add it to
crates/astrodyn_quantities/src/dims.rsusinguom's dimension macros. - Add
F64Extconstructor methods (e.g.,.kg_m2_per_s()). - Re-export from
prelude.rs.
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.
| 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 |
ScalarFirst ↔ ScalarLast confusion |
distinct phantom-tagged types on Quat<L, T>
|
| Quat transform convention |
LeftTransform ↔ RightTransform 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.
let a: Position<RootInertial> = ...;
let b: Position<Ecef> = ...;
let _ = a + b; // ← errorerror: 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
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 errorThe 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.
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 errorSecondsSince<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();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 QuatQuat<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).
fn gravity_pull(r_root: Position<RootInertial>) -> Acceleration<RootInertial> { ... }
let r_integ: Position<IntegrationFrame> = ...;
let _bad = gravity_pull(r_integ); // ← compile errorIntegrationFrame 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.
let r: Position<RootInertial> = ...;
let v: Velocity<RootInertial> = ...;
let _ = r * v; // ← compile errorThere 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).
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 errorerror: 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>().
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 tookimpl IntoLengthinstead ofLength. Today, passing a baref64where aLengthis expected produces uom's stock "mismatched types" error. (IntoGravParam<P>is wired — see §5.1.) -
CompatibleTimeScales—SecondsSince<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, _>andQuat<ScalarLast, _>are distinct types, so cross-layout operations are rejected by type identity rather than by a marker bound. -
RequiresNormalizedQuat— APIs takeNormalizedQuatdirectly (per §5.5), so the bound never fires. -
InertialOnly— earmarked for "this op needs root-inertial input" diagnostics. -
NoVectorVectorMul— earmarked for ther * vcase 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.
The type system has two documented escape hatches. Both are deliberate; both require justification in the PR description that introduces a use.
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 rawglam::DVec3interpreted in SI base units. There is no separate_uncheckedvariant — 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 /glamboundary code (e.g., reading the t=0 row of a reference CSV). -
Genuine
_uncheckedskip:InertiaTensor::from_dmat3_unchecked(DMat3)bypasses any symmetry check (the validating constructors arefrom_principalandfrom_components, both of which build a symmetric tensor from scalar entries by construction). Use the_uncheckedform 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 · Rpreserves 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_validatedreturns aResult<_, 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.
The escape-hatch CI guard (scripts/check_no_escape_hatches.sh) polices
two categories of bypass:
-
Marker-based (
#[doc(hidden)]and thetag_as_inertial!macro) are banned acrosscrates/andsrc/. Thetag_as_inertial!macro does not currently exist — the script greps defensively to keep the door closed against future re-introduction. -
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.rspluscrates/astrodyn_bevy/src/components/andcrates/astrodyn_bevy/src/lib.rs::spawn_bevyfor the Bevy adapter, the runner construction-boundary modules undercrates/astrodyn_runner/src/simulation/for the runner, and#[cfg(test)]modules undersrc/**. Insidecrates/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.
-
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.rs—Qty3<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 enforcingTS.01(<SelfRef>/<SelfPlanet>allow-list). -
docs/JEOD_invariants.mdrowTS.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); typedAtmosphereState<P>(#302); typedGravParam<P>+OrbitalElements<P>closing the μ-vs-frame phantom gap (#303, #306); Section-B audit (LightingBody, FlatPlate, LvlhRelativeState, DetachedSubtreeState, AttachEvent) (#304);Vehiclephantom kind +<SelfRef>wildcard tightening on FlatPlate / LvlhRelativeState / AttachEvent.offset (#332); typedAttachEvent.t_parent_childwith paired<VParent, VChild>phantoms (#343); typedRelativeTranslation/RelativeStatewith paired<Subject, Reference>vehicle phantoms (#344);#[diagnostic::on_unimplemented]on Act-5 phantom-wrapped types (#353);<SelfRef>/<SelfPlanet>wildcard discipline catalogued and linted asTS.01(#356);SimBody.atmospheric_statemigrated 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_massreturns the typed sibling (#408 / #409); andKinematicNodeState.transis 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_stephook), #157 (EvaluationCaseshape).