Skip to content

Commit 59e138e

Browse files
secupclaude
andcommitted
test(snr): OFDMSnrCalibration fading ensemble contract — 16-seed ITU Good, lin-mean +1.4/+1.2 dB at dial 10/20, Jensen ordering gated
Stage-2's authority input (per-group OFDM broadband on fading) is now under a permanent statistical truth gate, not just AWGN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019btwFxDt7D19SZSqPYvZnk
1 parent fdc2d9f commit 59e138e

2 files changed

Lines changed: 73 additions & 10 deletions

File tree

docs/CHANGELOG.md

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

1111
---
1212

13+
## 2026-07-08 — test(snr): OFDMSnrCalibration extended with the FADING ensemble contract (Stage-2 input under gate)
14+
15+
The RX-authority consumes per-group OFDM broadband readings ON FADING, but the
16+
truth-matrix only gated AWGN. New section: 16 seeds through the shared ITU-R
17+
F.1487 Good Watterson model (unit mean tap power, same noise convention) —
18+
each seed samples one fade state; the ENSEMBLE linear-mean must equal the dial
19+
(mean-power definition, ±3.0 dB ≈ 2.7σ of the 16-sample mean) and the dB-mean
20+
must sit below it (Jensen). Measured: lin-mean +1.4/+1.2 dB at dial 10/20
21+
(the residual = LTS-time intra-frame peak-riding, now bounded), dB-mean 10.2/
22+
19.9 ✓. The harness records readings for NON-decoded frames too, so this
23+
ensemble has no survivor bias — unlike the live decoded-frames ring (§5
24+
residual). AWGN section unchanged at the 1.5 dB tolerance.
25+
26+
---
27+
1328
## 2026-07-08 — feat(rate): handoff §6 (first half) — PHYSICAL ENTRY CAP: the dial-equivalent entry estimate can never exceed the physically measured channel
1429

1530
The affine fade basis inflates a single data-aided snapshot (bets it was a

tests/test_ofdm_snr_calibration.cpp

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ double ltsDataSectionRms(const Bytes& serialized, Modulation mod, CodeRate rate)
147147
return rmsOf(probe.encodeFrameLight(serialized));
148148
}
149149

150-
MeasuredSNR measureEstimatedSNR(float snr_db, uint32_t seed, const Bytes& payload) {
150+
MeasuredSNR measureEstimatedSNR(float snr_db, uint32_t seed, const Bytes& payload,
151+
bool fading = false) {
151152
constexpr Modulation kMod = Modulation::QPSK;
152153
constexpr CodeRate kRate = CodeRate::R1_4;
153154
MeasuredSNR out;
@@ -172,15 +173,29 @@ MeasuredSNR measureEstimatedSNR(float snr_db, uint32_t seed, const Bytes& payloa
172173

173174
auto audio = withSilence(samples);
174175

175-
ultra::ota_channel_core::SimulatedChannel channel;
176-
channel.setSeed(seed);
177-
channel.configure(snr_db, ultra::ota_channel_core::ChannelType::AWGN);
178-
// Keep the channel's per-burst TX level policy out of the calibration
179-
// (it would re-normalize the burst and undo the known-truth injection
180-
// above — see file header, decision 2).
181-
channel.setTxBurstNormalizationEnabled(false);
182-
channel.transmitFromA(audio);
183-
audio = channel.receiveForB(audio.size());
176+
if (fading) {
177+
// FADING section (Stage-2 contract): ITU-R F.1487 Good (0.1 Hz/0.5 ms
178+
// Watterson, unit mean tap power) at the same noise convention — the
179+
// per-seed reading samples ONE fade state; the ENSEMBLE across seeds
180+
// is the fade average (ergodicity), and this harness records readings
181+
// even for frames that fail to decode, so unlike the live decoded-
182+
// frames ring it has NO survivor bias.
183+
ultra::ota_channel_core::WattersonChannelModel model(
184+
ultra::ota_channel_core::itu_r_f1487::good(snr_db), seed);
185+
std::vector<float> faded;
186+
model.process(audio, 0, faded);
187+
audio = std::move(faded);
188+
} else {
189+
ultra::ota_channel_core::SimulatedChannel channel;
190+
channel.setSeed(seed);
191+
channel.configure(snr_db, ultra::ota_channel_core::ChannelType::AWGN);
192+
// Keep the channel's per-burst TX level policy out of the calibration
193+
// (it would re-normalize the burst and undo the known-truth injection
194+
// above — see file header, decision 2).
195+
channel.setTxBurstNormalizationEnabled(false);
196+
channel.transmitFromA(audio);
197+
audio = channel.receiveForB(audio.size());
198+
}
184199

185200
StreamingDecoder decoder;
186201
decoder.setLogPrefix("CAL");
@@ -274,6 +289,39 @@ int main() {
274289
std::cout << "\n";
275290
}
276291

292+
// ═══ FADING contract (Stage-2: what the RX-authority consumes) ═══
293+
// Per-frame readings on ITU Good fading sample single fade states; the
294+
// ENSEMBLE linear-mean must equal the dial (mean-power definition) and the
295+
// dB-mean must sit BELOW the linear-mean (Jensen). Tolerance 3.0 dB on the
296+
// ensemble mean: 16 one-frame fade samples, per-sample dB std ~4 ->
297+
// std(mean) ~1.1 dB, so 3.0 is a ~2.7-sigma gate (stable across seeds).
298+
std::cout << "\n fading (ITU Good) | lin-mean bias | dB-mean | n\n";
299+
for (float snr : {10.f, 20.f}) {
300+
double lin_sum = 0.0, db_sum = 0.0;
301+
int n = 0;
302+
for (uint32_t s2 = 0; s2 < 16; ++s2) {
303+
const auto m = measureEstimatedSNR(snr, 0x7000u + s2 * 104729u,
304+
payload, /*fading=*/true);
305+
if (std::isfinite(m.reading)) {
306+
lin_sum += std::pow(10.0, m.reading / 10.0);
307+
db_sum += m.reading;
308+
++n;
309+
}
310+
}
311+
const double lin_mean_db = n ? 10.0 * std::log10(lin_sum / n) : std::nan("");
312+
const double db_mean = n ? db_sum / n : std::nan("");
313+
std::cout << std::setw(10) << (int)snr << " | " << std::fixed
314+
<< std::setprecision(1) << std::setw(7) << lin_mean_db << " "
315+
<< std::showpos << std::setw(5) << (lin_mean_db - snr)
316+
<< std::noshowpos << " | " << std::setw(6) << db_mean
317+
<< " | " << n;
318+
const bool ok = n >= 10 && std::isfinite(lin_mean_db) &&
319+
std::abs(lin_mean_db - snr) <= 3.0 &&
320+
db_mean <= lin_mean_db + 0.3;
321+
if (!ok) { ++failed_points; std::cout << " <- FAIL (fading ensemble)"; }
322+
std::cout << "\n";
323+
}
324+
277325
if (failed_points > 0) {
278326
std::cout << "\nFAIL: " << failed_points << " point(s) outside +/-" << std::fixed
279327
<< std::setprecision(1) << tol

0 commit comments

Comments
 (0)