Skip to content

Adaptive FHSS: measure the payload, and complete the sensing matrix (#341, #340) - #345

Merged
josephnef merged 1 commit into
masterfrom
adaptive-fhss-fec-delivery-and-sensing-matrix
Jul 27, 2026
Merged

Adaptive FHSS: measure the payload, and complete the sensing matrix (#341, #340)#345
josephnef merged 1 commit into
masterfrom
adaptive-fhss-fec-delivery-and-sensing-matrix

Conversation

@josephnef

Copy link
Copy Markdown
Collaborator

Closes #341, closes #340.

The adaptive hopset was judged by a proxy: a dwell counted as delivered when a sync marker arrived before the next retune. That is what the receiver's policy scores, so it is the right quantity for the policy — but it is the wrong one for judging whether adaptation helps. A marker is one small frame at a robust rate, and a dwell that decoded one marker out of twenty frames still scores 1.0.

Measure the real thing

txdemo gains DEVOURER_TX_STDIN=1, carrying caller PSDU bodies in streamtx's framing — the authority, the sensing windows and the recovery probes all live in that demo, so the payload has to come to them. tests/fec_metrics.py owns the arithmetic that turns a capture into delivery, throughput and tracked time; jammer_resilience.py now shares it instead of keeping its own copy.

Against a parked narrowband interferer on a 4-channel 2.4 GHz base (25 s fixed, 70 s adaptive, both windows opened only once the receiver was tracking):

measure fixed keyed hopset jammed channel excluded
marker proxy (dwells) 0.762 0.946
FEC delivery (packets) 0.861 0.927
recovered packets/s 1001 986

The proxy claims roughly three times the improvement the payload sees, and the delivered rate does not improve at all. Both follow from the same cause: on a CSMA link the transmitter defers to the interferer rather than transmitting into it, so a fixed hopset spends far fewer than its dwell share of frames there, and the outer code already absorbs what it does spend. Exclusion buys margin, not throughput. Reproduced three times, spread under a point.

Per-policy (60 s rows, 180 s for the failsafe, which cannot arm before its feedback timeout and observation window have both elapsed):

fusion marker FEC delivery frames/s exclusions
rx 0.912 0.940 451 1
veto 0.921 0.932 457 1
either 0.922 0.932 471 1
failsafe (receiver muted) 0.908 0.932 453 1

All four converge: when the receiver is right, the fusion mode decides who may act, not what happens. The receiver's own uplink costs 19 control frames/min, under 5 ms of air per minute, and nothing measurable in delivery — so the heartbeat cadence is not the limiting factor.

Three defects the proxy could not see

Each found on air, each fixed here with a headless test:

  • A corrupt frame's marker reached the follower. The sync marker is a plain vendor IE whose only integrity is the frame FCS. Under DEVOURER_RX_KEEP_CORRUPTED — which sub-block salvage requires, and which the FPV link runs with — a corrupted marker whose seed fingerprint happened to survive decoded as a genuine generation change, and the follower dropped lockstep. 46 s outage.
  • A marker that crossed the activation boundary. Both endpoints swap at the same absolute slot, but a frame stamped just before it is decoded just after, carrying the old generation truthfully. Staleness is now judged by the slot the marker was stamped in.
  • The marker-strip heuristic ate caller payload. Two bytes deep, so on RS symbol data it matched about one body in 65536 and chopped 37 bytes off that shard — which then arrived with a valid FCS, indistinguishable from channel loss in the very metric this change exists to make trustworthy.

The two disagreement scenarios (#340)

They cannot be built on a bench where the adapters sit inches apart: any interferer strong enough to degrade one endpoint is equally audible at the other. DEVOURER_TX_SENSE_INJECT fabricates the transmitter's per-channel view inside the pure layer — hardware read skipped, window timing kept so the duty cycle is unchanged, and every floor, hysteresis and cleaner-alternative rule still applied to it.

  • Hidden node (real interferer, injected clean view): the exclusion passes unvetoed, the applied mask is bit-identical to the receiver's proposal, and the disagreement is recorded rather than acted on.
  • Mirror (dirty injected view on a channel the receiver delivers on): nothing is spent, and the argument is on the record with the target and occupancy that drove it.
  • Herding under an autonomous transmitter (FUSION=failsafe MUTE=1): diversity floor held, one channel per update, exclusion depth 1, no collapse (0.840 → 0.841).

Two observability holes had to be closed first: txdemo recorded a fused decision only when it vetoed or acted, so a pass-through and a mode_forbids_tx_origin hold were both invisible; and a sensor that held logged nothing at all, so "held on a floor", "no evidence" and "not running" were the same silence (new hopset.sense_eval).

Validation

47/47 ctest, including new cases for injected evidence, the mirror record, the in-flight marker and the activation-window bound. On air: parked ×3, sense ×2, hidden ×3, mirror ×2, fusionmatrix, policycost, herding+failsafe, overhead — all pass.

Both levers are test levers in the spirit of DEVOURER_HOP_ADAPTIVE_SCRIPT and DEVOURER_HOP_MUTE, and everything is inert unset.

Known follow-ups, deliberately not in this PR

  • The <u32_le len><PSDU> reader is now a fourth copy across txdemo/streamtx/duplex/svctx; the composed record read belongs in examples/common/stream_stdin.h beside read_exact.
  • The three new harness modes open-code the same seven-step cell that run_cost already is.

🤖 Generated with Claude Code

…341, #340)

The adaptive hopset was judged by a proxy: a dwell counted as delivered when a
sync marker arrived before the next retune. That is what the receiver's policy
scores, so it is the right quantity for the policy, but it is the wrong one for
judging whether adaptation helps -- a marker is one small frame at a robust
rate, and a dwell that decoded one marker out of twenty frames scores 1.0.

Measure the real thing instead. txdemo gains DEVOURER_TX_STDIN=1, carrying
caller PSDU bodies in streamtx's framing, because the authority, the sensing
windows and the recovery probes all live in that demo -- so the payload has to
come to them. tests/fec_metrics.py owns the arithmetic that turns a capture
into delivery, throughput and tracked time, and jammer_resilience.py shares it.

What that instrument says, against a parked narrowband interferer on a
4-channel 2.4 GHz base:

  marker proxy      0.762 -> 0.946
  FEC delivery      0.861 -> 0.927
  recovered pkts/s  1001  -> 986

The proxy claims roughly three times the improvement the payload sees, and the
delivered rate does not improve at all: on a CSMA link the transmitter defers
to the interferer rather than transmitting into it, so a fixed hopset spends
far fewer than its dwell share of frames there, and the outer code already
absorbs what it does spend. Exclusion buys margin, not throughput. All four
fusion modes converge within the run-to-run spread, so the mode decides who may
act rather than what happens; the receiver's own uplink costs 19 frames/min and
nothing measurable in delivery.

Three defects the proxy could not see, each found on air and fixed here:

- The sync marker is a plain vendor IE whose only integrity is the frame FCS.
  Under DEVOURER_RX_KEEP_CORRUPTED -- which sub-block salvage requires -- a
  corrupted marker whose seed fingerprint survived decoded as a genuine
  generation change and the follower dropped lockstep. Gate the marker plane on
  a clean FCS.
- Both endpoints swap generation at the same absolute slot, but a marker
  stamped just before that boundary is decoded just after it, carrying the old
  generation truthfully. Judge staleness by the slot the marker was stamped in.
  Treating one such frame as disagreement cost a 46 s re-acquisition at the
  instant an exclusion took effect.
- In stdin mode the two-byte marker-strip heuristic matched about one caller
  body in 65536 and chopped 37 bytes off that shard, which then arrived with a
  valid FCS -- a corrupted body every couple of minutes, indistinguishable from
  channel loss in the metric this change exists to make trustworthy.

For the two endpoint-disagreement scenarios of #340, which cannot be built on a
bench where the adapters sit inches apart, DEVOURER_TX_SENSE_INJECT fabricates
the transmitter's per-channel view inside the pure layer -- hardware read
skipped, window timing kept, and every floor and hysteresis rule still applied
to it. With a real interferer and an injected clean view the receiver's
exclusion passes unvetoed and the applied mask is bit-identical to its
proposal; with a dirty injected view on a channel the receiver delivers on,
nothing is spent and the disagreement is on the record. Herding under an
autonomous transmitter holds the diversity floor, one channel per update, and
does not collapse.

Two observability holes made those rows possible: txdemo recorded a fused
decision only when it vetoed or acted, so a pass-through and a mode-forbids
hold were both invisible; and a sensor that held logged nothing at all, so
"held on a floor", "no evidence" and "not running" were the same silence.

Validated headless (47/47 ctest, with new cases for injected evidence, the
mirror record, the in-flight marker and the activation-window bound) and on air
across parked, sense, hidden, mirror, fusionmatrix, policycost,
herding+failsafe and overhead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@josephnef
josephnef merged commit 8c603c6 into master Jul 27, 2026
20 checks passed
@josephnef
josephnef deleted the adaptive-fhss-fec-delivery-and-sensing-matrix branch July 27, 2026 12:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant