Precision sweep of baseline B0
Approved: 2026-10-27; implemented in racing-drone#33 Issue: racing-drone#30, part of the Phase 2 epic racing-drone#9 Related: mixed-precision assessment (H1–H5), B0 plan, SITL plan, cortex ADR 0015
Issues and pull requests
Section titled “Issues and pull requests”| Item | What | State |
|---|---|---|
| racing-drone#30 | This plan | Closed |
| racing-drone#33 | The implementation and results; this document landed with it | Merged |
| racing-drone#31 | The assessment that derives H1–H5 | Merged |
| racing-drone#17 | B0 and gate P1, which the sweep reuses | Closed |
Problem
Section titled “Problem”Every harness builds Composition<double>, so no closed-loop run has used any other scalar type, although the stack, the slots and the message types are templated on T. The assessment predicts which types fly B0 through gate P1 (H1–H4). This plan measures it: B0 runs in each type, with the simulator, the simulated FC, the scorer and the log in double.
What a probe found before planning
Section titled “What a probe found before planning”A throwaway program built B0 in all seven types with gcc and ran two ticks of each (not committed; it shaped the design below):
- All seven types compile.
GroundTruthEstimator<T>,WaypointPlanner<T>andglue::CascadedPid<T>, with reflex’s cascade underneath, need no source change. reflex tests its cascade indoubleandfloatonly, so this is the first instantiation in Universal types. - binary16 fails construction, as H3 predicts. The message is reflex’s generic “does not give valid reflex model parameters”, which doesn’t say which parameter failed. H3’s verdict needs the reason.
posit<16,1>aborts on the course itself.is_unitchecks|‖q‖² − 1| ≤ 1e-6in every type. Gates 6 and 11 have orientation (0.7071, 0, 0, ±0.7071), and inposit<16,1>the cast gives ‖q‖² − 1 = 2.4e-4. bfloat16 and binary16 pass on this course only because their rounding of 0.7071 happens to square to exactly 0.5. A truth attitude in flight is an arbitrary unit quaternion, so every 16-bit type would trip it.
1. Universal (cmake/deps.cmake)
Section titled “1. Universal (cmake/deps.cmake)”- Universal 5.11.0, the pin reflex and cortex use, fetched header-only like reflex’s test-only fetch, and defining
universal::universalonly if no one has yet. - Linked only by the sweep tool and the tests. The SDK, slots and glue never name a Universal type (as in reflex).
2. A unit tolerance derived from T (msg/geometry.hpp)
Section titled “2. A unit tolerance derived from T (msg/geometry.hpp)”is_unit’s default tolerance becomes unit_tolerance<T>() = max(1e-6, 4ε_T), where ε_T is numeric_limits<T>::epsilon().
Derivation, for a quaternion that is unit in double (its ‖q‖² − 1 is a few ε_64, negligible) cast to T and checked in T, with unit roundoff u = ε_T/2 (round to nearest):
- the cast gives q̂ᵢ = qᵢ(1 + δᵢ), |δᵢ| ≤ u, so ‖q̂‖² = Σqᵢ²(1 + δᵢ)² is within 2u + u² of 1;
norm_squaredinTis a 4-term sum of products of non-negatives, within γ₄ = 4u/(1 − 4u) of its exact value;‖q̂‖² − 1is then exact (Sterbenz, since the sum is in [½, 2]);- in total |computed − 1| ≤ (1 + γ₄)(1 + u)² − 1 = 6u + O(u²), under 8u = 4ε_T for u ≤ 2⁻⁸ (every type in the sweep).
Posits have u only for |x| in [1/useed, useed). A component below 1/useed loses a bit per regime step, but its square shrinks by useed per step, so with useed ≥ 4 each such component adds at most u/4 to the bound: 6.75u + O(u²) in the worst case, still under 8u. A test checks the bound on 10,000 random unit quaternions per type.
For double and float, 4ε_T (8.9e-16 and 4.8e-7) is below 1e-6, so the tolerance, and so every existing check and log, is unchanged. For bfloat16 it is 3.1e-2, binary16 3.9e-3, posit<16,1> 9.8e-4. A looser check is the honest one: a format cannot certify unit norm more finely than it can represent it.
3. Conversions at the harness boundary (msg/*.hpp)
Section titled “3. Conversions at the harness boundary (msg/*.hpp)”cast<To>(const Msg<From>&)forImuSample,TruthState,StateEstimate,Reference,ThrustRatesandGateObservations, next to each type, followingCourseMap’s existing cast: element by element, non-scalar fields copied.GateObservationsdoesn’t cross the harness today (the detector feeds the estimator inside the stack). Its cast is added because the issue lists it, for the detector logs of later baselines.
4. The Runner in T (harness/harness.hpp)
Section titled “4. The Runner in T (harness/harness.hpp)”Runner<StackT>takesT = StackT::scalar_type. It casts decoded inputs (double) toTand the tick’s outputs back todoublebefore encoding. The log staysdouble.- After the cast it checks
valid()inTand reports a violation as an error, as it already does for the decodeddouble. Section 2 makes this unreachable for unit inputs; the check keeps a violation from reaching the stack’s asserts. - For
T = doubleevery cast is a copy, so outputs and logs are byte-identical. make_stack<StackT>casts the course toTand returns an error, not an assert, ifvalid(cast<T>(map))fails.
5. A parameter-level reason for construction failures (glue/reflex_params.hpp)
Section titled “5. A parameter-level reason for construction failures (glue/reflex_params.hpp)”When reflex’s validator rejects the parameters, reflex_params_from<T> appends every nonzero vehicle parameter whose conversion to T is zero, subnormal (below numeric_limits<T>::min()) or not finite. For RP-1 in binary16 it reads “rotor.thrust_coefficient = 8.9e-07 is subnormal in this scalar type”. It only explains a rejection: what is accepted is still reflex’s decision, and reflex’s own message is unchanged.
6. One batch runner for sitl_batch and the sweep (sim/batch.hpp)
Section titled “6. One batch runner for sitl_batch and the sweep (sim/batch.hpp)”run_one moves out of apps/sitl_batch/main.cpp into a header, templated on the stack type, with the JSON line it writes and the percentile and failure-grouping helpers. sitl_batch calls it with Composition<double> and its output is unchanged. The sweep calls the same code, so for double its lines are sitl_batch’s by construction, and a test checks it (AC 1).
7. The tool, precision_sweep_b0-gt-pid (apps/precision_sweep/main.cpp)
Section titled “7. The tool, precision_sweep_b0-gt-pid (apps/precision_sweep/main.cpp)”- Built by
racing_add_baseline(b0-gt-pid PRECISION_SWEEP), the only baseline that opts in. --scenario <yaml> --seeds <n> --out-dir <dir> [--first-seed <s>] [--jobs <j>] [--min-success <f>] [--commit <text>] [--timing].--min-success(default 0.99, gate P1) only marks the table’s P1 column.--commitis recorded in the table as given. The tool can’t know the commit it was built from without a build step that goes stale, so the run that produces committed results passes$(git rev-parse HEAD).--timingadds each type’s wall time. It is off by default because it makes the files differ between runs (AC 3).
- Types, a compile-time list (
sim/sweep_types.hpp, shared with the tests) in this order:double,float,cfloat<32,8>,posit<32,2>, bfloat16 (cfloat<16,8>), binary16 (cfloat<16,5>),posit<16,1>. Thecfloats have subnormals, no max-exponent values, no saturation (IEEE behavior). - Per type: construct once. A failure is the type’s result,
construction_failedwith its reason, and the tool moves on (AC 4). Otherwise run the seeds, assitl_batchdoes. - A run that errors (for example a command that doesn’t decode because it is NaN) is that seed’s result,
run_errorwith its reason. It doesn’t stop the tool. - Files in
--out-dir:<type>.jsonl: one line per seed, insitl_batch’s format ({"seed":…,"score":…}), or{"seed":…,"run_error":"…"}.lap_delta.jsonl: per type and seed, each lap’s time minusdouble’s, for seeds where both succeeded.summary.jsonl: one line per type: construction status and reason, success count and rate, failures by reason, lap time p50/p95, true gate error p50/p99, max speed, and the lap-time difference fromdouble(p50/p95 of |Δ|).summary.md: the markdown table, generated from the summary, with the command line (without--jobsand--out-dir, which change no result), the commit and the seed range, then every failure reason.
- Exit 0 once every type has a result. Nonzero only on usage or setup errors.
8. Results
Section titled “8. Results”- Run once with 500
b0-nominalseeds. The generatedsummary.mdis committed asdocs/assessments/data/precision-sweep-b0.md, unedited, withsummary.jsonlandlap_delta.jsonlbeside it. - The assessment page gets a Results section that cites the table and gives H1–H4 each a verdict against its prediction. Numbers in the prose come from the table, by reference.
sweep.b0.smoke, on every PR: the tool on one b0-determinism seed, twice. A CMake script checks that every type has a summary line with a status, that the two runs’ files are byte-identical (AC 3), and that double.jsonl equals sitl_batch_b0-gt-pid --jsonl for the same seed (AC 1).
Model and contracts
Section titled “Model and contracts”Runner<StackT> (stateful)
Section titled “Runner<StackT> (stateful)”- Members:
stack_(StackT&, non-owning),last_tick_(optional). Unchanged. - Invariant I1:
last_tick_never decreases. Unchanged. feed(rec): Pre!is_output(rec.channel). Post: an input that doesn’t decode, or isn’tvalid()after the cast toT, gives an error and leaves the stack unfed; a tick gives the three outputs, eachcast<double>of the stack’s. Preserves I1.
cast<To>(m) (stateless)
Section titled “cast<To>(m) (stateless)”- Post: each scalar field is
To(field); integers, timestamps, enums and bools are copied. ForTo == From,cast(m) == m. - Round trip (AC 2):
|double(To(x)) − x|is no larger than the distance from x to either neighbor ofTo(x)inTo, the definition of round to nearest. The test checks the scalar conversion against the actual neighbors, found withnextafterfor built-in types and the encoding increment for Universal types, so no tolerance is chosen. A second test checks that each message cast is exactly that conversion, field by field. - Posits never round a nonzero value to zero (the posit standard): below minpos the result is ±minpos. There, and only there, the error exceeds ½ ULP; the test applies the posit rule (zero isn’t a candidate).
Acceptance criteria (from the issue, stated before implementation)
Section titled “Acceptance criteria (from the issue, stated before implementation)”- Identity for double: the sweep’s
double.jsonlequalssitl_batch’s--jsonlfor the same seeds.gate.p0.replay_determinism,gate.sitl.determinismandgate.b0.determinismpass unchanged, and their logs are byte-identical to main’s. - Boundary casts:
double → T → doubleis exact forT = double, and within the round-to-nearest bound per field for the other types, on representative values for every type. - Determinism: two runs with the same arguments give byte-identical files for every type.
- Construction failures are results: reported as
construction_failedwith the reason; the tool continues and exits 0. - Build: every type builds with warnings as errors on gcc, clang and MSVC, or the dropped type is listed in the PR with the compiler error.
- No hand-typed numbers: the table is generated, and the assessment cites it.
- Verdicts: H1–H4 each get a verdict from the generated table.
- Debug-build asserts. A 16-bit type can drive a slot to NaN, and the slots’ contract asserts would then abort a Debug build. The Runner’s post-cast checks catch bad inputs, not bad outputs. If the smoke test aborts in a Debug preset, the fix is a checked return in the slot (reported as
run_error), never a weaker assert; any such change is listed in the PR. - Runtime. A
doubleseed ofb0-nominaltakes about 0.1 s (gcc-release, one run). Emulated types are slower by an unmeasured factor; the measured sweep time goes in the results file. - MSVC and Universal. Not yet built on MSVC in this repo. A toolchain failure is a disclosed departure (AC 5).
Implementation notes
Section titled “Implementation notes”- Results:
docs/assessments/data/precision-sweep-b0.md, generated at the implementation commit, with itssummary.jsonlandlap_delta.jsonlbeside it. The per-type<type>.jsonlfiles aren’t committed; the table’s command line regenerates them. The verdicts are in the assessment, §9. - Reproducibility: two runs at that commit gave byte-identical per-type files and
lap_delta.jsonl. The committed run used--timing, so only its wall-time column changes between runs. - Departure from AC 2: posits round values below minpos to ±minpos, never to zero, so there the error exceeds ½ ULP. The test applies the posit rule instead (§ Model and contracts).
- Debug-build risk:
sweep.b0.smokepasses in gcc-debug and clang-debug with every type, so no slot needed a checked return. GateObservations’s cast is tested but unused until a detector’s observations cross the harness.
Out of scope
Section titled “Out of scope”Per-slot scalar types and H5 (the follow-up); the simulator, FC, scorer or log in anything but double; speed or energy on hardware.