Skip to content

Commit 57aec9d

Browse files
secupclaude
andcommitted
fix(snr): LTS glitch guard — skip meter update when same-frame noise >= signal (synced frame at sub-0 dB = measurement fault; rig F237 meter collapse to -9.2 at dial 20)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019btwFxDt7D19SZSqPYvZnk
1 parent bebdf22 commit 57aec9d

10 files changed

Lines changed: 146 additions & 29 deletions

docs/CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ This log tracks all bug fixes and behavioral changes to prevent re-doing work du
1010

1111
---
1212

13+
## 2026-07-13 — fix(snr): LTS glitch guard — a synced frame at sub-0 dB is a measurement fault, not channel state (rig F237: meter collapsed to −9.2 at a 20 dB channel)
14+
15+
The 07-10 estimation-noise debias floored at 0.01× when a glitched LTS pair
16+
(rig timing jitter on fading) pushed the noise estimate above the signal —
17+
one glitch frame injected a −19.5 dB reading into the meter EMA; two or three
18+
collapsed it to −9.2 at MPG@20 → authority demote churn, ACK-staircase
19+
flapping, ring spread ±13.5 (the pre-fix guard-biased code had masked the same
20+
glitches as spikes UP). Physics: a frame that achieved LTS sync has
21+
processing-gain-proven signal presence — same-frame noise ≥ signal is a fault;
22+
SKIP the meter update (both estimator paths). A genuine deep fade never syncs
23+
and thus never lies either way. Sim gates passed pre-fix because sim timing is
24+
clean — the rig is the glitch amplifier (fidelity lesson: single-seed sim
25+
gates under-sample LTS-glitch behavior).
26+
27+
Verified: OFDMSnrCalibration full matrix unchanged; good@20 PASS 1650 bps with
28+
ZERO negative lts readings; ctest 85/85 (one TNC straggler artifact, clean on
29+
rerun). Forensics: ~/Documents/ultra_forensics/F237_{mac,pi5}.log.
30+
31+
---
32+
1333
## 2026-07-10 — fix(snr): review findings 3/5 — getStats() live overlay (meters no longer freeze during bursts) + session-teardown SNR clear; finding 2 script removed; finding 7 filed
1434

1535
**Finding 3:** the LoopbackStats cache is refreshed only by the per-frame

src/ofdm/channel_equalizer_lts.cpp

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,21 @@ void OFDMDemodulator::Impl::updateLastSNREstimate(float signal_power,
103103
// fades finite (-20 dB per-carrier) instead of log(<=0).
104104
// This term (+10log10(1+1/SNR_c)) GREW under fading — the
105105
// "reads 16.4 while the link can't hold QPSK R1/2" optimism.
106-
const float debiased_signal_power = std::max(
107-
signal_power - corrected_noise_power,
108-
0.01f * corrected_noise_power);
106+
// GLITCH GUARD (rig F237, 2026-07-13): a frame that achieved LTS sync
107+
// has processing-gain-proven signal presence — a same-frame noise
108+
// estimate at/above the signal power is a MEASUREMENT FAULT (timing
109+
// slip / CFO glitch corrupting the LTS pair), not channel state.
110+
// Without this, the debias floors at 0.01x and one glitch frame
111+
// injects a -19.5 dB reading into the EMA (rig meter collapsed to
112+
// -9.2 at a 20 dB channel -> authority demote churn + ACK-staircase
113+
// flapping). Skip the update; the EMA holds and the next clean frame
114+
// measures honestly. A GENUINE deep fade produces no LTS sync and
115+
// therefore no reading — same outcome, no lie either way.
116+
if (signal_power <= 2.0f * corrected_noise_power) {
117+
return;
118+
}
119+
const float debiased_signal_power =
120+
signal_power - corrected_noise_power;
109121
const float snr_per_carrier_db = 10.0f * std::log10(
110122
debiased_signal_power / std::max(corrected_noise_power, 1.0e-12f));
111123
// (b) GEOMETRIC per-carrier -> in-band conversion. The old
@@ -165,9 +177,11 @@ void OFDMDemodulator::Impl::updateLastSNREstimate(float signal_power,
165177
dof_corrected_noise *= static_cast<float>(independent_bins) /
166178
static_cast<float>(independent_bins - 1);
167179
}
168-
const float debiased_signal_power =
169-
std::max(signal_power - dof_corrected_noise,
170-
0.01f * dof_corrected_noise);
180+
// Same glitch guard as the noise_reference_only path above.
181+
if (signal_power <= 2.0f * dof_corrected_noise) {
182+
return;
183+
}
184+
const float debiased_signal_power = signal_power - dof_corrected_noise;
171185
const float snr_per_carrier_db =
172186
10.0f * std::log10(debiased_signal_power / dof_corrected_noise);
173187
const float broadband_snr_db =

src/protocol/connection.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ Connection::Connection(const ConnectionConfig& config)
196196
}
197197

198198
// RX-RATE-CMD Phase 2 (design §5.2, knob ULTRA_RX_RATE_CMD, read once per
199-
// Connection; default OFF = byte-identical — rung_cmd bits stay 0 AND the tone-ACK
200-
// CRC span stays 28 bits, see tone_burst_payload.cpp rungCmdCrcSpanEnabled()).
199+
// Connection; default ON since 2026-07-05). The tone-ACK codec must use the
200+
// widened CRC span whenever this command path or RX authority is enabled.
201201
// SEMANTICS-BREAKING lockstep when ON; the descriptor-committed consume path
202202
// additionally needs ULTRA_DESCRIPTOR_MODE_SWITCH (+ ULTRA_ARQ_MOVE_EPOCH for
203203
// mid-window) — it falls back to the legacy MODE_CHANGE exchange without them.
@@ -2649,6 +2649,10 @@ void Connection::maybeObeyAuthorityCommand(uint8_t cmd_idx) {
26492649
if (cmd_idx == kRungIdxNone || cmd_idx >= kRungIdxCount) return;
26502650
if (state_ != ConnectionState::CONNECTED ||
26512651
negotiated_mode_ != WaveformMode::OFDM_CHIRP) return;
2652+
// The operator pin is the final authority. Receiver authority replaces the
2653+
// adaptive controller, but it must not bypass ULTRA_LOCK_RATE or an explicit
2654+
// ULTRA_RATE_ADAPT=0 fixed-rung measurement.
2655+
if (!rateAdaptationActive()) return;
26522656
if (mode_change_pending_) return; // a move is already in flight — obey later copies
26532657
CoherentPick pick = coherentRungFromIndex(cmd_idx);
26542658
Modulation mod = pick.mod;

src/protocol/waveform_selection.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ inline uint8_t snapRungIndexDownToEnabled(uint8_t idx) {
422422
return kRungIdxNone;
423423
}
424424

425-
// ULTRA_RX_RATE_AUTHORITY (2026-07-05, default OFF): receiver-commanded absolute
425+
// ULTRA_RX_RATE_AUTHORITY (2026-07-05, default ON): receiver-commanded absolute
426426
// rung selection — the receiver maps ITS fresh per-group channel measurements
427427
// through selectCoherentOFDM and commands the sender's next rung on every group
428428
// ACK; the sender OBEYS (descriptor commit) and its own mid-transfer rate drivers

src/waveform/tone_burst_ack/tone_burst_payload.cpp

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,20 +96,25 @@ inline uint32_t crcMessageBits(bool cover_rung_cmd) {
9696

9797
} // namespace
9898

99-
// ULTRA_RX_RATE_CMD process-wide binding for the parameter-less overloads.
99+
// ULTRA_RX_RATE_CMD / ULTRA_RX_RATE_AUTHORITY process-wide binding for the
100+
// parameter-less overloads.
100101
// Read ONCE (static; same pattern as ULTRA_ARQ_MOVE_EPOCH's ctor read) — the
101102
// codec functions themselves stay stateless via the explicit-span overloads.
102103
bool rungCmdCrcSpanEnabled() {
103104
static const bool v = [] {
104105
const char* e = std::getenv("ULTRA_RX_RATE_CMD");
105-
if (e != nullptr && e[0] == '1') return true;
106+
const bool rx_rate_cmd = !(e && e[0] == '0');
106107
// RX-AUTHORITY (2026-07-05) reinterprets [rate_hint|rung_cmd] as a 5-bit
107108
// absolute rung command — all five bits must sit inside the CRC span (a
108109
// corrupted command = a wrong-rate burst). Same lockstep semantics as
109110
// ULTRA_RX_RATE_CMD: a knob-OFF peer CRC-rejects these ACKs (fails safe
110111
// as ack loss).
111112
const char* a = std::getenv("ULTRA_RX_RATE_AUTHORITY");
112-
return a != nullptr && std::atoi(a) != 0;
113+
// Keep this default exactly aligned with rxRateAuthorityEnabled(). Both
114+
// command paths are default-on; the legacy span is valid only when both
115+
// have been explicitly disabled.
116+
const bool rx_authority = a == nullptr || std::atoi(a) != 0;
117+
return rungCmdCrcSpanRequired(rx_rate_cmd, rx_authority);
113118
}();
114119
return v;
115120
}

src/waveform/tone_burst_ack/tone_burst_payload.hpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,23 @@ struct ToneBurstAckPayload {
112112
//
113113
// STATELESSNESS: the CRC span is a PARAMETER of every pack/verify/codec entry
114114
// point (`cover_rung_cmd`). The parameter-less overloads bind it once from the
115-
// ULTRA_RX_RATE_CMD env (read a single time, process-wide — the production
116-
// encoder/detector path); tests pass it explicitly, so no env ordering can
117-
// change a test's meaning.
115+
// ULTRA_RX_RATE_CMD / ULTRA_RX_RATE_AUTHORITY env (read a single time,
116+
// process-wide — the production encoder/detector path); tests pass it
117+
// explicitly, so no env ordering can change a test's meaning.
118118
//
119119
// We use a 12-bit CRC (rather than 16) to keep the packet small: 12 bits at
120120
// ~1 bit/symbol after 4-FSK + (15,11) Hamming means ~3-4 fewer symbols on
121121
// air. CRC-12 still catches all 1-3 bit bursts and >99.9% of random errors
122122
// for our 26-bit message — overkill for ACK semantics.
123123

124124
// Process-wide CRC-span binding for the parameter-less overloads below:
125-
// true iff ULTRA_RX_RATE_CMD=1 (read once on first use).
125+
// true when either default-on receiver command path is enabled (read once on
126+
// first use). The pure predicate is exposed so all four feature states can be
127+
// tested without depending on the process-wide environment latch.
128+
inline constexpr bool rungCmdCrcSpanRequired(bool rx_rate_cmd_enabled,
129+
bool rx_authority_enabled) {
130+
return rx_rate_cmd_enabled || rx_authority_enabled;
131+
}
126132
bool rungCmdCrcSpanEnabled();
127133

128134
// Pack the kPayloadBits (44) raw payload bits (excluding Hamming) into a

tests/test_rx_authority.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,39 @@ static bool test_sender_ignores_none_and_garbage() {
383383
return true;
384384
}
385385

386+
// A fixed-rung probe is an operator command, not an advisory. Receiver authority
387+
// must not move the sender while ULTRA_LOCK_RATE is active.
388+
static bool test_operator_lock_overrides_authority() {
389+
TEST("operator adaptation controls override receiver authority");
390+
391+
{
392+
Connection c;
393+
TA::makeConnectedOFDM(c, CodeRate::R3_4, 20.0f, 0.05f, Modulation::QPSK);
394+
setenv("ULTRA_LOCK_RATE", "1", 1);
395+
TA::obey(c, kRungIdxQam8R23);
396+
unsetenv("ULTRA_LOCK_RATE");
397+
if (TA::modeChangePending(c)) FAIL("authority bypassed ULTRA_LOCK_RATE");
398+
}
399+
{
400+
Connection c;
401+
TA::makeConnectedOFDM(c, CodeRate::R3_4, 20.0f, 0.05f, Modulation::QPSK);
402+
setenv("ULTRA_RATE_ADAPT", "0", 1);
403+
TA::obey(c, kRungIdxQam8R23);
404+
unsetenv("ULTRA_RATE_ADAPT");
405+
if (TA::modeChangePending(c)) FAIL("authority bypassed ULTRA_RATE_ADAPT=0");
406+
}
407+
{
408+
setenv("ULTRA_ADAPTIVE_RATE", "0", 1);
409+
Connection c;
410+
unsetenv("ULTRA_ADAPTIVE_RATE");
411+
TA::makeConnectedOFDM(c, CodeRate::R3_4, 20.0f, 0.05f, Modulation::QPSK);
412+
TA::obey(c, kRungIdxQam8R23);
413+
if (TA::modeChangePending(c)) FAIL("authority bypassed ULTRA_ADAPTIVE_RATE=0");
414+
}
415+
PASS();
416+
return true;
417+
}
418+
386419
int main() {
387420
// MUST precede any Connection construction (env-latched statics).
388421
setenv("ULTRA_RX_RATE_AUTHORITY", "1", 1);
@@ -403,6 +436,7 @@ int main() {
403436
ok &= test_no_observation_no_command();
404437
ok &= test_sender_obeys_and_dedups();
405438
ok &= test_sender_ignores_none_and_garbage();
439+
ok &= test_operator_lock_overrides_authority();
406440
std::cout << tests_passed << "/" << tests_run << " passed\n";
407441
return (ok && tests_passed == tests_run) ? 0 : 1;
408442
}

tests/test_tone_burst_ack_payload.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include "waveform/tone_burst_ack/tone_burst_payload.hpp"
66

77
#include <cassert>
8+
#include <cstdlib>
89
#include <cstdint>
910
#include <cstdio>
1011
#include <random>
@@ -559,6 +560,17 @@ void test_symbol_ms_staircase() {
559560
} // namespace
560561

561562
int main() {
563+
EXPECT(!rungCmdCrcSpanRequired(false, false));
564+
EXPECT(rungCmdCrcSpanRequired(true, false));
565+
EXPECT(rungCmdCrcSpanRequired(false, true));
566+
EXPECT(rungCmdCrcSpanRequired(true, true));
567+
568+
// Receiver authority is default-on, so the parameter-less production codec
569+
// must cover its command bits even when no environment knob is present.
570+
unsetenv("ULTRA_RX_RATE_CMD");
571+
unsetenv("ULTRA_RX_RATE_AUTHORITY");
572+
EXPECT(rungCmdCrcSpanEnabled());
573+
562574
test_constants_sanity();
563575
test_pack_unpack_round_trip();
564576
test_pack_clamps_out_of_range_fields();

tools/gui_qso_scenario.sh

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,16 @@ sum_cw_fail() {
195195
count_deinterleave_fail() { count_pattern 'Frame deinterleave decode FAILED' "$1"; }
196196
count_deinterleave_ok() { count_pattern 'Frame deinterleave decode SUCCESS' "$1"; }
197197

198+
count_unexpected_rate() {
199+
local file="$1"
200+
awk -v expected="$EXPECT_RATE" '
201+
/Data mode set to:/ {
202+
if (index($0, " " expected " ") == 0) ++n
203+
}
204+
END { print n + 0 }
205+
' "$file" 2>/dev/null
206+
}
207+
198208
sum_tx_samples() {
199209
local file="$1"
200210
{ grep -E 'TX(:| Burst:).* -> [0-9]+ samples' "$file" 2>/dev/null || true; } |
@@ -249,11 +259,11 @@ collect_metrics() {
249259
[[ -z "$actual_data_mode" ]] && actual_data_mode="unknown"
250260
alpha_unexpected_modes="$(count_pattern "$unexpected_mode_pattern" "$ALPHA_LOG")"
251261
bravo_unexpected_modes="$(count_pattern "$unexpected_mode_pattern" "$BRAVO_LOG")"
252-
# Forced-rung floor probes (ULTRA_FORCE_DATA_MOD / ULTRA_FORCE_WAVEFORM) PIN the
253-
# mod/waveform — the modem cannot adapt away, so the "unexpected data mode" watchdog is
254-
# meaningless. Worse, the per-EXPECT_MOD pattern's catch-all (e.g. DQPSK -> the '*' case)
255-
# false-matches the NORMAL "Data mode set to:" / "MODE_CHANGE: OFDM" lines, so the live
256-
# poll loop kills the run ~2 s after handshake before any data flows. Disable when forced.
262+
alpha_unexpected_rates="$(count_unexpected_rate "$ALPHA_LOG")"
263+
bravo_unexpected_rates="$(count_unexpected_rate "$BRAVO_LOG")"
264+
# Force knobs select the entry profile; ULTRA_LOCK_RATE is what pins it after
265+
# handshake. Keep the unexpected-mode watchdog active for locked probes so a
266+
# force/authority configuration error cannot be reported as a fixed-rung PASS.
257267
#
258268
# --expect-mod any|coherent (2026-06-12, Phase 1): the adaptive ladder is ALLOWED to
259269
# vary/promote the modulation (e.g. QPSK -> 16QAM under ULTRA_ENABLE_QAM16_LADDER), so a
@@ -264,11 +274,12 @@ collect_metrics() {
264274
# 2026-07-02: with the fade-riding ladder default-ON (ULTRA_LOCK_RATE=0), mid-transfer
265275
# rate/modulation moves are the MECHANISM, not a failure — the pinning watchdog only
266276
# applies when the operator pinned the rate (ULTRA_LOCK_RATE=1).
267-
if [[ -n "${ULTRA_FORCE_DATA_MOD:-}" || -n "${ULTRA_FORCE_WAVEFORM:-}" \
268-
|| "$EXPECT_MOD" == "any" || "$EXPECT_MOD" == "coherent" \
277+
if [[ "$EXPECT_MOD" == "any" || "$EXPECT_MOD" == "coherent" \
269278
|| "${ULTRA_LOCK_RATE:-0}" == "0" ]]; then
270279
alpha_unexpected_modes=0
271280
bravo_unexpected_modes=0
281+
alpha_unexpected_rates=0
282+
bravo_unexpected_rates=0
272283
fi
273284
file_crc_ok="$(count_pattern "\\[FILE\\] Received .*\\(${FILE_BYTES} bytes, CRC ok|FileTransfer: Received OK \\(${FILE_BYTES} bytes|Received OK .*${FILE_BYTES} bytes.*CRC" "$BRAVO_LOG")"
274285
alpha_file_done="$(count_pattern '\[FILE\] Transfer complete|FileTransfer: Transfer complete' "$ALPHA_LOG")"
@@ -317,19 +328,20 @@ scenario_passed() {
317328
# PASS = the file delivered CRC-clean (BRAVO verified the file + ALPHA finalized the
318329
# transfer). DELIVERY is the verdict.
319330
#
320-
# We do NOT require the run to have hit the EXACT expected (mod, rate). The old gate
331+
# Adaptive runs do not have to hit the exact expected (mod, rate). The old gate
321332
# `alpha_mode_count>0 && bravo_mode_count>0` counted the literal string
322333
# "configured for <EXPECT_MOD> <EXPECT_RATE>" and so FALSE-FAILED any delivered transfer
323334
# whose negotiated rate differed from --expect-rate. That is common and correct on a
324335
# fading channel: at `good --snr-db 16` the MEASURED fading varies seed-to-seed and many
325336
# seeds land in the Moderate class (>=0.65), where the ladder rightly picks QPSK R1/4 —
326337
# not the R2/3 a caller guessed. Those runs delivered CRC-clean but were stamped FAIL
327338
# (REASON=process_exit_before_pass), corrupting reliability sweeps. The actual negotiated
328-
# mode is recorded as ACTUAL_DATA_MODE in summary.env for diagnostics; EXPECT_RATE is
329-
# only used to size the timeout budget, not to gate PASS.
339+
# mode is recorded as ACTUAL_DATA_MODE in summary.env for diagnostics. Locked probes
340+
# are different: every reported data-mode rate must match EXPECT_RATE, and the
341+
# modulation watchdog below enforces EXPECT_MOD.
330342
#
331-
# We still reject drift to an UNEXPECTED MODULATION (e.g. QAM16 when probing QPSK) — that
332-
# is a real "wrong rung" signal, distinct from a benign rate change within the same mod.
343+
# We reject drift to an unexpected modulation on locked probes (e.g. QAM16 when
344+
# probing QPSK); rate changes are rejected by the unexpected-rate counters.
333345
#
334346
# We intentionally do NOT gate on disconnect bookkeeping: the disconnect INITIATOR (ALPHA,
335347
# on the payload-drained auto-disconnect) quits during teardown and never logs a
@@ -338,13 +350,16 @@ scenario_passed() {
338350
# verdict; close cleanliness is tracked separately.)
339351
[[ "$alpha_unexpected_modes" -eq 0 ]] &&
340352
[[ "$bravo_unexpected_modes" -eq 0 ]] &&
353+
[[ "$alpha_unexpected_rates" -eq 0 ]] &&
354+
[[ "$bravo_unexpected_rates" -eq 0 ]] &&
341355
[[ "$file_crc_ok" -gt 0 ]] &&
342356
[[ "$alpha_file_done" -gt 0 ]]
343357
}
344358

345359
hard_failure_reason() {
346360
local pattern='max retries exceeded|maximum retries exceeded|transfer failed|Transfer failed|FileTransfer: .*failed|FILE.*failed|SR-ARQ:.*retries exhausted|Connection: Connect failed|Connect failed after|giving up'
347-
if [[ "${alpha_unexpected_modes:-0}" -gt 0 || "${bravo_unexpected_modes:-0}" -gt 0 ]]; then
361+
if [[ "${alpha_unexpected_modes:-0}" -gt 0 || "${bravo_unexpected_modes:-0}" -gt 0 \
362+
|| "${alpha_unexpected_rates:-0}" -gt 0 || "${bravo_unexpected_rates:-0}" -gt 0 ]]; then
348363
echo "unexpected_data_mode"
349364
return
350365
fi
@@ -388,6 +403,8 @@ write_summary() {
388403
echo "BRAVO_MODE_COUNT=$bravo_mode_count"
389404
echo "ALPHA_UNEXPECTED_MODE_COUNT=$alpha_unexpected_modes"
390405
echo "BRAVO_UNEXPECTED_MODE_COUNT=$bravo_unexpected_modes"
406+
echo "ALPHA_UNEXPECTED_RATE_COUNT=$alpha_unexpected_rates"
407+
echo "BRAVO_UNEXPECTED_RATE_COUNT=$bravo_unexpected_rates"
391408
echo "FILE_CRC_OK_COUNT=$file_crc_ok"
392409
echo "ALPHA_FILE_DONE_COUNT=$alpha_file_done"
393410
echo "ALPHA_DISCONNECTED_COUNT=$alpha_disconnected"

tools/qso_sweep.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ while read -r ch snr mod rate fkb _rest || [[ -n "${ch:-}" ]]; do
6565
dir="$OUT_ROOT/$tag"
6666
echo ">>> [$i] $ch @ ${snr} dB $mod $rate ${fkb} KB (seed $sd)" >&2
6767

68+
ULTRA_RX_RATE_AUTHORITY=0 \
69+
ULTRA_RX_RATE_CMD=0 \
70+
ULTRA_ADAPTIVE_RATE=0 \
71+
ULTRA_RATE_ADAPT=0 \
72+
ULTRA_LOCK_RATE=1 \
6873
ULTRA_FORCE_WAVEFORM=OFDM_CHIRP \
6974
ULTRA_FORCE_DATA_MOD="$mod" \
7075
ULTRA_FORCE_DATA_RATE="$rate_us" \

0 commit comments

Comments
 (0)