← RFC

Deviation-2 fix: a dedicated request slot for PACT-lite

Issue: Loomwave #69 (PACT-lite membership-scaling MAC), spec docs/membership-scaling.md §7 Q2. Sim seam: meshforge/sim/loomwave_sim/membership.py (ReqSlotPactLitePolicy), tests tests/test_hidden_hub_membership.py. Status: sim-validated; firmware port (Rust hub + C++ client) follows.

The problem (deviation-2, as shipped)

PACT-lite decouples membership from per-frame airtime: a rotating pool of P = 3 telemetry slots is round-robined over the member table across a super-frame of R = ceil(K/P) frames, and the coordinator polls who may signal "I have a queued message" collision-free. But as shipped, the demand signal rides on a member's telemetry transmission — the msg-pending bit sits in pt[0] of the record a member sends in its rostered telemetry slot. A non-rostered member that the coordinator names in the poll sweep has no TX opportunity to raise the bit until its slot comes up. So the poll sweep is inert for non-rostered members, and a texting node's first-message latency is bounded by the telemetry rotation period, not by demand discovery:

worst-node first-msg floor ≈ (R-1)·frame + frame/2 + TELEM_W,   R = ceil(K/P)

This is linear in K. With the FLEET (SF9/BW125) frame timing (idle frame ≈ 2117 ms, TELEM_W ≈ 280 ms) it crosses the 30 s alert deadline at K ≈ 43 (analytic_floor_breakpoint_k). The captured Stone Mountain hub is K = 29 and a busy hub could plausibly reach 50–90 — i.e. the breakpoint sits inside the realistic range. Edge (weakest-quartile) on-time delivery under a 100 % burst falls 85 % → 21 % from K = 29 → 150.

Why the obvious fix is wrong

The naive fix — "give every polled member its own demand micro-slot" — couples poll-naming to reserved airtime: each extra polled member adds a micro-slot (REQ_W ≈ 198 ms) to every frame, and that frame inflation cancels the slope gain. Parameter sweep:

demand micro-slots/frame idle frame breakpoint K
3 2710 ms 34 (worse than as-built!)
6 3303 ms 49
8 3698 ms 58

Even at a punishing +1.6 s/frame the breakpoint barely moves. Rejected.

The fix: decouple poll-naming from the request window

Poll-naming is cheap (3 B/name in the beacon); reserved request airtime is not. So name many members for demand each frame (POLL_TOTAL = 16) but reserve only a small fixed request window (REQ_WINDOW = 2 micro-slots). A polled member with a queued message transmits a tiny request in a (randomly chosen) micro-slot; the coordinator grants it a message slot the next frame via the existing on-demand grant path (#63). Crucially the request slot is an additive fast-path, not a replacement — a member is served by whichever fires first:

Structural floor (a lone worst-node texter — the deviation-2 metric, no contention): the request slot is uncontended, so latency is discovery-bound (~ceil(K/POLL_TOTAL)), not rotation-bound. Slope drops from 1/P to 1/POLL_TOTAL.

as-built fix (POLL_TOTAL=16, REQ_WINDOW=2)
breakpoint K (floor > 30 s) 43 145

Note (model-sensitivity). The lifted breakpoint is sensitive to the demand-poll latency model. This table's 145 is the design estimate; the shipped sim/loomwave_sim/membership.py recomputes it at ~188 with the final as-built constants (POLL_TOTAL=16, REQ_WINDOW=2). The exact value varies by model detail, but every estimate is far past the realistic hub max (~29–90 nodes), so the conclusion — the dedicated request slot moves the breakpoint beyond any real hub — is robust. The as-built K≈43 (no fix) is the runnable, authoritative figure (analytic_floor_breakpoint_k). | edge floor p95 @ K=29 | 20.0 s | 7.7 s | | edge floor p95 @ K=150 | 97.5 s | 27.3 s | | idle frame | 2117 ms | 2717 ms (+REQ_WINDOW·REQ_W + larger beacon) | | beacon body @ K=150 | ~135 B | ~163 B (fits) |

Honest limits

load K as-built edge dlv fix edge dlv
7 % burst 29 60 % 100 %
10 % burst 100 16 % 62 %
20 % burst 29 67 % 93 %

Firmware mapping (next step)