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.
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.
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.
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:
ceil(K/POLL_TOTAL) frames, granted the next frame;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.pyrecomputes 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) |
REQ_WINDOW, so
under a full burst the window saturates (slotted-ALOHA among the polled cohort) and the
rotation fallback dominates. The reserved request airtime slightly inflates the frame, so the
fix's full-burst edge delivery is ~10 pp below the as-built (e.g. 85 % → 75 % at K=29). This
is acceptable: a 100 % correlated burst is airtime-bound for every policy (K·MSG_W alone is
131 s at K=150), and it is not the regime deviation-2 hurts.| load | K | as-built edge dlv | fix edge dlv |
|---|---|---|---|
| 7 % burst | 29 | 60 % | 100 % |
| 10 % burst | 100 | 16 % | 62 % |
| 20 % burst | 29 | 67 % | 93 % |
Beacon.grants (#63). The request
window geometry is derivable from the beacon (poll list + fixed REQ_WINDOW/REQ_W
constants compiled into both sides).infrad cell() + pact.rs): bump the poll to POLL_TOTAL; after the
telemetry region, open a REQ_WINDOW-slot request RX window; a decoded request from a polled
member feeds the existing pending → grants path (granted next frame).main.cpp): when named in beacon.poll, not rostered, and holding a queued
message, transmit a request micro-frame in a random REQ_WINDOW slot; the existing grant-RX +
message-TX path already handles the rest.POLL_TOTAL = 16, REQ_WINDOW = 2, request micro-frame ≈ 16 B.