fix(calibration): bind the grid the radio keeps emitting, not the sparse minority

`accept_grid` locks each node onto the densest grid it has seen and rejects
sparser frames from the feature path -- on an ESP32-C6 the ~16% HT 64-bin
minority that arrives alongside HE-SU 256-bin. `select_calibration_grid`
ordered candidates by `max_gap_s` first, so it preferred whichever grid
looked temporally smoothest. A sparse trickle at a metronomic cadence has a
smaller worst-case gap than a busy stream with one scheduling hiccup, so
selection kept choosing the grid admission is designed to discard.

Measured on four ESP32-C6 nodes: a capture bound 64sc on node 11, every node
then locked onto 256sc, the bound grid went stale with no frame for five
minutes, frame_count froze at 10,997 and `calibration_grid_is_fresh` failed.
The capture could never finalize -- `collecting` for 858 s with both minimums
long since met, no error surfaced, and a flickering UI as the client retried.
The same rig bound 256sc on a different capture and finalized with five
baseline eigenvalues.

Order by subcarrier count first so selection agrees with admission, then fall
back to the previous gap/rate/ppdu ordering to choose among equally dense
candidates.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
This commit is contained in:
Dragan SpiridonovandRuflo & AQE committed 2026-09-15 14:48:08 +02:00
1 parent cdad032296
commit dbf4788628
1 file changed
+86 -3
@@ -1496,11 +1496,26 @@ impl NodeState {
&& evidence.max_gap_s < CALIBRATION_GRID_MAX_GAP_S
&& evidence.latest_age_s < CALIBRATION_GRID_MAX_GAP_S
})
// Density first, to agree with `accept_grid`. That gate locks each
// node onto the densest grid it has seen and rejects sparser
// frames from the feature path -- on an ESP32-C6 the ~16% HT
// 64-bin minority alongside HE-SU 256-bin. Ordering selection by
// gap first picked exactly that minority: it is sparse, so its
// arrivals look smooth, while the grid the node actually keeps
// using scores worse on gap.
//
// Measured: a capture bound 64sc on node 11, every node then
// locked onto 256sc, the bound grid went stale with no frame for
// five minutes, frame_count froze at 10,997 and the capture could
// never finalize -- `collecting` forever with no error surfaced.
// A wider grid that the node will keep emitting beats a narrower
// one that admission is designed to discard.
.min_by(|(left_grid, left), (right_grid, right)| {
left.max_gap_s
.total_cmp(&right.max_gap_s)
right_grid
.n_subcarriers
.cmp(&left_grid.n_subcarriers)
.then_with(|| left.max_gap_s.total_cmp(&right.max_gap_s))
.then_with(|| right.rate_hz.total_cmp(&left.rate_hz))
.then_with(|| right_grid.n_subcarriers.cmp(&left_grid.n_subcarriers))
.then_with(|| left_grid.ppdu_type.cmp(&right_grid.ppdu_type))
})
}
@@ -7323,6 +7338,74 @@ mod bootstrap_vital_publication_tests {
}
/// `accept_grid` locks a node onto the densest grid it has seen and
/// rejects sparser frames from the feature path -- on an ESP32-C6 the ~16%
/// HT 64-bin minority alongside HE-SU 256-bin. Selection must agree, or it
/// binds a grid the node is about to stop emitting. Measured consequence:
/// a capture bound 64sc, every node locked onto 256sc, the bound grid went
/// stale and the capture hung in `collecting` with frame_count frozen.
#[test]
fn calibration_grid_selection_prefers_the_grid_the_node_keeps_emitting() {
let mut node = NodeState::new();
let start = std::time::Instant::now() - std::time::Duration::from_secs(20);
let dense = CsiGridKey { n_subcarriers: 256, ppdu_type: 0 };
let sparse = CsiGridKey { n_subcarriers: 64, ppdu_type: 0 };
// The sparse minority trickles in on a perfectly regular cadence, so
// its worst-case gap is SMALLER than the dense grid's. The dense grid
// is what the radio actually streams, but a single scheduling hiccup
// gives it the larger gap. Gap-first ordering therefore picks the
// sparse grid -- the one `accept_grid` discards.
for i in 0..200_u32 {
node.observe_raw_grid(
dense,
start + std::time::Duration::from_millis(u64::from(i) * 50),
);
}
// one hiccup on the dense stream: a 2 s gap, then it resumes
for i in 0..100_u32 {
node.observe_raw_grid(
dense,
start
+ std::time::Duration::from_millis(12_000 + u64::from(i) * 50),
);
}
// sparse minority: metronomic 400 ms, never a gap worse than that
for i in 0..50_u32 {
node.observe_raw_grid(
sparse,
start + std::time::Duration::from_millis(u64::from(i) * 400),
);
}
node.observe_raw_grid(dense, std::time::Instant::now());
node.observe_raw_grid(sparse, std::time::Instant::now());
// Precondition: the sparse grid really does look smoother, so this
// test fails against gap-first ordering rather than passing by luck.
let candidates = node.calibration_grid_candidates(std::time::Instant::now());
let gap_of = |want: CsiGridKey| {
candidates
.iter()
.find(|(g, _)| *g == want)
.map(|(_, e)| e.max_gap_s)
.expect("candidate present")
};
assert!(
gap_of(sparse) < gap_of(dense),
"test data must make the sparse grid look smoother: sparse={} dense={}",
gap_of(sparse),
gap_of(dense)
);
let (grid, _) = node
.select_calibration_grid(std::time::Instant::now())
.expect("a qualifying grid");
assert_eq!(
grid.n_subcarriers, 256,
"selection must bind the grid the node keeps emitting, not the sparse minority admission rejects"
);
}
#[test]
fn non_bound_grid_never_enters_field_model_history() {
let mut state = AppStateInner::minimal();