Planner reference in the log
Approved: 2026-10-26; implemented in racing-drone#26 Issue: racing-drone#25, part of the Phase 1 epic racing-drone#8 Related: test plan §9, architecture, B0 plan, ADR 0006
This plan records the planner’s Reference as a log channel, so a run shows what the stack was trying to do, not only what it believed and commanded.
Issues and pull requests
Section titled “Issues and pull requests”| Item | What | State |
|---|---|---|
| racing-drone#25 | This plan | Closed |
| racing-drone#26 | The implementation; this document landed with it | Merged |
| racing-drone#23 | log_dump, which gains reference.csv here | Merged |
Problem
Section titled “Problem”A log records the stack’s state estimate (state, channel 16) and its command (command, channel 17), but not the planner’s reference: the target the controller was asked to track. Stack::on_tick builds the reference and passes it to the controller (sdk/include/branes/racing/compose/stack.hpp, Stack::on_tick), but TickOutput carries only {state, command}, so the reference never reaches the log.
What this costs:
- A run can’t be split into “the controller didn’t track the reference” and “the planner aimed badly”. Plotting reference against true position is the first diagnostic for tuning B0, and for comparing B1 with it.
- It is a gap against test plan §9 (“timestamped channels for all slot inputs and outputs”) and against SYS-08.
replay --verifychecks the planner only indirectly, through the command. A non-deterministic planner whose effect the controller happens to mask would go unnoticed.
It follows racing-drone#23 (log_dump), which can’t show the reference for this reason.
1. Message and codec (sdk/include/branes/racing/msg/codec.hpp)
Section titled “1. Message and codec (sdk/include/branes/racing/msg/codec.hpp)”encode(const Reference<double>&)anddecode_reference(span).- Wire format:
countu32, then only thecountused samples. Each sample isti64 followed by 11 f64: position, velocity and acceleration (3 each), yaw and progress. That is 8 + 11 × 8 = 96 bytes per sample. - Decoding rejects:
count > kMaxReferenceSamples;- a non-finite value;
- trailing bytes;
- anything failing
valid(Reference): I2 (strictly increasing sample times) or I3 (non-decreasing progress).
- This matches how the other decoders treat a log as untrusted input.
2. Log channel (sdk/include/branes/racing/log/log.hpp)
Section titled “2. Log channel (sdk/include/branes/racing/log/log.hpp)”- New output channel
reference = 18, andis_known()accepts it. kLogVersion1 → 2. The reader already rejects any version it doesn’t know, so a v1 log gets “unsupported log version 1”.- The repo stores no
.brlogfixtures (checked), and every gate re-records its logs, so the only cost is re-recording local logs. - Rejected alternative: keep v1 readable and have
--verifyskip channels absent from the recording. That weakens the verification for every log: a log that lost a channel would still verify.
- The repo stores no
3. Stack and harness
Section titled “3. Stack and harness”TickOutput<T>becomes{state, reference, command}, in pipeline order, andon_tickreturns the reference it computed.Runner::feedemits three outputs per tick, in the order state, reference, command. Order matters because the gates compare logs byte for byte.replay --verifyneeds no change: it compares every output record, so it now also checks the reference.- Call sites that build or read
TickOutput:tests/sdk/stack.cpp,tests/baselines/composition_hash.cpp,apps/flight_stack/main.cpp(which discards it).
4. log_dump
Section titled “4. log_dump”- Writes
reference.csv, one row per sample, with these columns:t_ns,t_s,count,index,sample_t_nsp_x..p_z,v_x..v_z,a_x..a_z,yaw,progress
- A tick with
count == 0contributes no row, as inoracle_gates.csv.
5. Docs
Section titled “5. Docs”- This plan document, landing with the implementation PR.
- The architecture doc gets a sentence that the log carries the reference.
- The
log_dumpheader comment lists the new file.
Size (derived from the format, not measured)
Section titled “Size (derived from the format, not measured)”- A record header is 14 bytes (u16 + u32 + i64).
- One sample costs 4 + 96 = 100 payload bytes, so 114 bytes per tick. Every current planner (null, scripted-hop, B0’s
WaypointPlanner) emits exactly one sample. - At B0’s 250 Hz that is 28.5 kB/s.
- A planner filling all 64 samples would write 4 + 64 × 96 + 14 = 6,162 B per tick, or 1.54 MB/s at 250 Hz. That fits SITL. For flight it goes into the ADR 0006 ring sizing when such a planner exists. Decimating the reference is a later, separate decision.
Acceptance criteria (stated before implementation)
Section titled “Acceptance criteria (stated before implementation)”- Codec round-trip:
decode_reference(encode(r)) == rforcount0, 1 and 64. Decoding rejects:count = 65;- a NaN in any field;
- decreasing sample times;
- decreasing progress;
- one trailing byte;
- a payload truncated by one byte.
- Stack: the
referencereturned byon_tickequalsplanner().plan(course(), state)for the same tick. - Runner: each tick yields exactly three outputs, with channels state, reference and command in that order, all stamped with the tick time.
- Record count: a
b0-determinismSITL log has exactlyticksmore records than before. Before: 2000 ticks and 23,442 records. Expected after: 25,442. This is checked intools.log_dumpby reading SITL’s printed counts:records_after = records_before + ticks. - Existing gates pass unchanged:
gate.p0.replay_determinism,gate.sitl.determinism,gate.b0.determinismandgate.p1.smoke. The determinism gates now cover the reference, because they compare whole logs. log_dump:reference.csvhas one row per tick on the B0 log, and its rows match its header.- Old logs: opening a version-1 log fails with “unsupported log version 1”.
- Linux gcc and clang (Debug and Release) and MSVC (Debug and Release) build with warnings as errors, and all tests pass.
Out of scope (possible follow-ups)
Section titled “Out of scope (possible follow-ups)”SYS-08 and test-plan §9 ask for every slot output. After this change, these remain unlogged:
- the gate detector’s
GateObservations(only the simulator’s oracle gates are logged); - the controller’s command before the safety filter (the logged
commandis after it); - the DAA conflict set;
- estimator health.
Each is a separate channel with its own size trade-off, and can be its own issue.
Implementation notes
Section titled “Implementation notes”- Criterion 4 is checked differently from the issue’s wording. The issue said
tools.log_dumpwould checkrecords_after = records_before + ticks. A test can’t run the pre-change binary, so it checks the equivalent property that a test can see: thereferencechannel holds exactlyticksrecords, and it is the only new channel. By hand, onb0-determinismwith seed 1700, the SITL record count went from 23,442 on main to 25,442 with this change, which is 2,000 ticks more. - Criterion 2 is tested on two planners.
NullPlanner: itsplan()isconst.- B0’s stateful
WaypointPlanner: the test copies the planner just before the second tick, and checks that the copy’splan()on the same state gives the reference the tick returned.
- Decoding is canonical.
decode_referencevalue-initializes the unused samples.Reference’soperator==compares all 64 slots, so a reference with leftover data pastcountwould not compare equal after a round trip. Every planner in the repo builds its reference fromReference<T>{}, so none has leftover data. TickOutputgrows bysizeof(Reference<double>)= 8 + 64 × 96 = 6,152 B (thecountplus padding, then the samples), returned by value on each tick. The planner already builds this value on the stack, and the copy happens off the FC path. If a profile ever shows it, the fix is to return the planner’s output by reference.- The size figures were corrected in review. The approved plan said a sample holds 12 doubles (104 B). The fields are 3 + 3 + 3 + 1 + 1 = 11 doubles, so a sample is 96 B. The code and its test (
codec: Reference round trip) always used 96 B. The size section above now shows the corrected figures, and so does the #25 body. log_dump’s one-row-per-sample rule has its own test. Every current planner emits one sample, so a SITL log can’t tell one row per sample from one row per record.tests/tools/reference_log_fixture.cppwrites references of 0, 1 and 3 samples.tools.log_dumprequires 3 records, 4 rows, and the (count, index) pairs (1,0), (3,0), (3,1), (3,2). It fails against an exporter that writes one row per record (checked).