Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,17 @@ target_link_libraries(AdapterCapsSelftest PRIVATE devourer)

add_test(NAME adapter_caps_derive COMMAND AdapterCapsSelftest)

# Headless guard for the per-UE RX attribution seed (src/cell/UeRxAttribution.h)
# — TA extraction over the addr2-carrying frame types, the per-TA windowed
# aggregate folding, drain semantics, and the bounded-table eviction accounting
# the M1 UeRegistry will build on.
add_executable(UeRxAttributionSelftest
tests/ue_rx_attribution_selftest.cpp
)
target_link_libraries(UeRxAttributionSelftest PRIVATE devourer)

add_test(NAME ue_rx_attribution_derive COMMAND UeRxAttributionSelftest)

# Headless guard for the TsfSync one-way time-distribution fit (src/TsfSync.h):
# skew/offset recovery from beacon {egress, arrival} TSF pairs, clock
# translation, the local 32-bit TSF wrap, and the Packet::TxEgressTsf pick.
Expand Down
64 changes: 64 additions & 0 deletions docs/ack-txreport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Unicast ACK + TxReport capability matrix (M0 contract 3)

A scheduled MAC's reliability layer (per-UE delivery detection, HARQ-style
retransmission, link adaptation) rests on two capabilities per generation:
an **injected unicast descriptor solicits a hardware ACK** (with autonomous
MAC retransmission until it arrives), and **`TxReport` reports the per-frame
ACK / no-ACK outcome** to the host. This measures both, per generation, plus
the report delivery rate — `tests/ack_txreport_matrix.sh` /
`tests/ack_txreport_analyze.py` (`--selftest` covers the verdict logic).

## Method

Fixed hardware-ACK responder (`SetAckResponder` on a second adapter); per TX
generation three phases: **on** (responder armed with MAC1, unicast QoS-Data
to MAC1 → expect ~100% `tx.report ok`, retries ~0), **retarget** (responder
re-armed to a different MAC2, TX to MAC2 → proves RA and responder MAC are
arbitrary), **off** (no responder → expect 0% ok, retries pinned at the
descriptor limit: the no-ACK outcome must be *visible*, per frame).
`report_coverage` = reports / frames sent (`tx.stats.submitted`); HalMAC adds
SW_DEFINE tag-echo gap counting.

TX sessions run `DEVOURER_TX_WITH_RX=thread`: CCX reports arrive on the C2H
RX path, so J1/J2 TX-only sessions never see them (measured: J2 TX-only = 0
reports; only J3 drains C2H off its coex runtime without an RX loop). A
scheduled MAC runs TX+RX anyway, so this is the relevant session shape.

## Measured (ch36, MCS3, unicast TA, 8814AU responder, ~8 s/phase)

| TX generation | on: ACK rate / mean retries | retarget | off: retries pinned | report coverage | tag gaps |
|---|---|---|---|---|---|
| Jaguar1 8812AU | 1.00 / 0.34 | 1.00 / 0.25 | yes (12) | 1.00 | n/a (8812 fmt) |
| Jaguar2 8812BU | 0.91 / 2.1 (run-to-run 0.12–0.91) | 0.64 / 5.3 | yes (12) | 0.86 | 0 |
| Jaguar3 8822CU | 1.00 / 0.24 | 1.00 / 0.13 | yes (12) | 0.96 | 0 |

Responder-side capability (same bench, J3 TX as the reference soliciting
station): **8814AU** closes the loop at retries ~0.1 (the bench responder of
choice); **8812AU** works but degraded (97% delivery at ~7 mean retries —
its SIFS ACKs only land intermittently); **8821AU never closed the loop**
(TX retries stayed pinned with it armed); the 8812BU responder was
previously proven (`tests/ack_responder_check.sh`).

## The contract

1. **Per-frame delivery detection is GO on all three generations**: the OFF
phase pins retries at the descriptor limit with `state=1` on every report —
a no-ACK outcome is unambiguously visible per frame, which is all M2's
software retransmission logic needs. Report coverage 86–100% with zero
HalMAC tag gaps (interior losses); the reliability layer must tolerate a
~5–15% report-less frame tail (treat missing report as "unknown", not
"delivered").
2. **Closed-loop hardware ACK + autonomous retry is GO on Jaguar1 and
Jaguar3** (100% delivery, retries ≈ 0.2–0.3) including retargeting an
arbitrary UE MAC mid-session (re-arm `SetAckResponder`, change the
descriptor RA — both fully dynamic).
3. **Jaguar2 as the soliciting TX is MARGINAL on this bench**: ACK closure
varied 12–91% across identical runs (mean retries 2–11) against both
8814AU and 8812AU responders, and its TX pace in the TX+RX-thread shape is
~24 ms/frame regardless of the requested gap (~37 fps vs J1/J3's ~150).
As a *responder* J2 is proven good. An M1 cell should prefer J1/J3 (or the
8821CE) for the DU role until the J2 TX anomaly is root-caused; treat it
as open, not as silicon folklore.
4. Bench quirk recorded: the J3 report's `missed` field reads a constant 4×
the report count while tag continuity shows zero loss — the 8822C
`missed_rpt` offset likely decodes something else; trust `tag` gaps on J3.
62 changes: 62 additions & 0 deletions docs/beacon-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Dynamic beacon-content delivery (M0 contract 2)

A scheduled MAC (5G-NR RAN epic) carries its DCI-style grant map in the beacon
body, so the DU must be able to **change the airing beacon's content** without
missing, duplicating or tearing beacons. The primitive is
`IRtlDevice::UpdateBeaconPayload(beacon, len)` — an in-place content swap for
an active `StartBeacon` (same buffer contract; interval, TBTT phase and port
identity untouched) riding the same reserved-page re-download the TBTT steers
use. Its companion `StopBeacon()` silences the beacon function: the chip
beacons **autonomously**, so a beaconing session that ends without a device
power-cycle must call it — a killed process leaves the beacon airing
indefinitely (bench-bitten: a stale beacon with the same SA contaminated the
next test's witness).

Per generation: Jaguar2 replaces the retained `_bcn_mpdu` and re-downloads via
the steer path (the J2 engine loses the bcn-valid latch on re-latch, so the
download is also the re-arm); Jaguar1 is a fresh BCNQ-boundary store bracket
(no re-ignite needed — the port stays configured); Jaguar3 is a fresh HalMAC
`download_beacon_page` (its latch is stable — no steer machinery involved).

## Measured (ch36, 100 TU interval, 30 updates/gen every ~1 s, 8821AU witness)

`tests/beacon_update_check.sh` — the probe beacons a versioned vendor IE
(u32 version + version-derived 32-byte pattern + CRC16, so one frame proves a
torn swap) and calls `UpdateBeaconPayload` every 10 intervals; the witness
records `(hw seq, tsfl, version, crc_ok)` per beacon and
`tests/beacon_update_analyze.py` reconstructs the TBTT grid from `tsfl`,
separating update-caused missing slots from background witness loss.

| generation | updates aired | excess skips/update | torn | version regress | update→air p50 | p99 |
|---|---|---|---|---|---|---|
| Jaguar1 8812AU | 30/30 | 0.0 | 0 | 0 | 40 ms | 67 ms |
| Jaguar2 8812BU | 30/30 | 0.0 | 0 | 0 | 63 ms | 174 ms |
| Jaguar3 8822CU | 30/30 | 0.0 | 0 | 0 | 72 ms | 99 ms |

## The contract

1. **Dynamic beacon grants are GO on all three generations.** Every update
aired; the background-corrected skip cost was 0 in this bench (the ≤ 1
skipped beacon per re-download that TBTT steers pay was not even resolvable
above witness loss at this cadence). No torn frames — the swap is
frame-atomic as observed on air (the CRC never caught a half-old,
half-new body). No old content re-airing after the new version's first
appearance.
2. **Update→air latency is TBTT-quantized**: content lands on the next (or
next-but-one) beacon — p50 ≈ half a period to a period, p99 ≤ ~2 periods at
100 TU. A grant map published via the beacon is therefore *effective* one
to two beacon intervals after the scheduler decides it. M1's grant timing
must budget that pipeline (grants for slot epoch N+2, decided at epoch N).
3. **Not atomic versus TBTT by design**: a beacon airing during the download
may still carry the previous content, and the API guarantees only
whole-version frames (measured, via the CRC), not a bounded switchover
instant. The `effective_tbtt` discipline lives in the grant-map payload
(epoch field), not in the radio primitive.
4. The M0 fallback (static beacon grant + scheduled unicast deltas) is NOT
needed. Kept as the escape hatch if a future generation/transport shows a
real per-update skip cost.

Tooling: `tests/beacon_update_probe.cpp` (TX + witness modes; build line in
header), `tests/beacon_update_analyze.py` (`--selftest` covers the
skip/dup/late/stale/torn classifier on synthetic streams),
`tests/beacon_update_check.sh` (per-generation orchestration).
65 changes: 65 additions & 0 deletions docs/dl-departure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# DL departure: the send_packet→air guard-time contract (M0 contract 1)

A scheduled MAC (5G-NR RAN epic) promises collision-free downlink slots, which
requires knowing how long after the host calls `send_packet` the frame is
actually on the air — per transport, as a **distribution**, not an average.
This is that measurement and its per-transport contract.

## Method

One witness receiver captures, from the SAME frame, the transmitter's embedded
submit stamps and its own hardware RX timestamp (`RxAtrib.tsfl`, MAC-latched):

- **TD v2 tag** (`examples/tdma/tdma.h`, version byte 2): `tx_tsf` = ReadTsf
near send (TX hardware clock) + `host_ns` = `steady_clock` immediately
before the `send_packet` call (the clock a host-side slot scheduler actually
controls).
- `tests/txegress_analyze.py` least-squares fits `rx_tsfl` against each stamp;
the line absorbs clock offset, crystal skew and propagation, so the residual
is per-frame submit→air jitter. Robust (3×MAD) rejection separates the
**floor** (frames that aired immediately = transport + MAC-pipeline jitter)
from the **tail** (channel deferral — which a scheduler must budget too).
- The contract number: `guard_us` = p99.9 of all host-clock residuals above
the median, emitted as a machine-checkable `txeg.verdict` JSONL line with
p50/p90/p99/p99.9/max and a `fine_dl_slots` go/no-go against `--slot-us`.

Probes: `tests/dl_departure_tx.cpp` (any USB adapter),
`tests/pcie_txegress_tx.cpp` (8821CE over vfio). Orchestration:
`tests/dl_departure_matrix.sh` (fixed witness, TX swept over the transports;
the PCIe cell ships the probe to the remote rig, vfio-binds, runs, restores).

## Measured (ch36, 6M legacy, ~2000 frames/cell, 8821AU witness)

| transport | floor RMS | p90 | p99 | p99.9 (=guard) | max |
|---|---|---|---|---|---|
| Jaguar1 8812AU (async USB2) | 22 µs | 28 µs | 101 µs | 0.76 ms | 2.1 ms |
| Jaguar2 8812BU (sync USB3) | 14 µs | 64 µs | 1.7 ms | 3.1 ms | 3.3 ms |
| Jaguar3 8822CU (sync USB3) | 16 µs | 61 µs | 2.2 ms | 3.2 ms | 3.3 ms |
| 8821CE (PCIe, vfio) | 11 µs | 54 µs | 2.4 ms | 3.2 ms | 3.3 ms |

Crystal ppm sane (−17 ppm, same reference witness) on every cell — the robust
fits locked. The tail is run-to-run ambient-dependent: the same J1 cell has
measured p99.9 between ~0.8 ms and ~3.2 ms across runs on the same channel.

## The contract

1. **The transport floor is NOT the bottleneck.** All four transports place
the bulk of frames within tens of µs of nominal (floor RMS 11–26 µs; p90
≤ 64 µs). PCIe is the tightest (11 µs) but the USB floors are the same
order — transport choice does not gate slot design at ≥ ms slot sizes.
2. **The tail is channel deferral, and it does not go away.** Even at 5 GHz on
a mostly-idle channel, ~1% of frames air 0.1–2.4 ms late (ambient beacons +
CSMA — `SetCcaMode` relaxes energy-CCA, not preamble deferral). p99.9 sits
at ~1–3 ms on every transport.
3. **Go/no-go: fine (sub-ms) DL slots are REFUTED on all transports** for a
p99.9-grade deadline on a real channel. The M1 design consequence is the
fallback the epic anticipated: a **submission-ahead scheduler** — submit a
slot's frame `guard_us` before the slot boundary and size slots ≥ ~2× the
measured guard (i.e. multi-ms slots), OR accept a bounded deadline-miss
ratio (~1% at a 1 ms guard, per the p99 row) and let HARQ absorb it.
Re-measure `guard_us` per deployment environment; the verdict line exists
so that check is one script run.

The hardware-beacon path (MAC-timed TBTT, `PinBeaconTbtt`) is unaffected by
any of this — beacons depart on the TBTT grid below the CSMA/queueing layer,
which is why scheduled **UL** rides beacon-steered timing, not `send_packet`.
52 changes: 52 additions & 0 deletions docs/ue-rx-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Per-UE RX attribution (M0 contract 4)

`GetRxQuality()` is device-wide by design: one draining accumulator fed by
every decoded frame, whoever sent it. A cell scheduler adapting per-UE rate and
power needs the same windowed statistics **attributed to each transmitter** —
that is `devourer::cell::UeRxAttribution` (`src/cell/UeRxAttribution.h`), the
seed of the scheduled-MAC `UeRegistry` (5G-NR RAN epic, `src/cell/` = the
per-cell DU library).

## The contract

Pure caller-side logic — the device RX loops are untouched and `GetRxQuality`
stays the radio-wide diagnostic. Everything needed is already per-frame in the
`Packet` callback:

- **key** — the transmitter address (802.11 addr2/TA), extracted by
`cell::extract_ta`: bytes [10..16) of the MPDU for every frame type except
the two control subtypes that end at addr1 (CTS, ACK).
- **values** — `rx_pkt_attrib`'s path-A RSSI/SNR/EVM plus the hardware RX
timestamp `tsfl`, folded with the exact `RxQualityAccumulator` conventions
(`rssi_raw <= 0` is not a sample; SNR/EVM folded only when present; passive
noise floor = `(rssi_raw − 110) − snr_raw/2`).

`add()` (or `add_mpdu()`, which extracts the TA itself) per frame;
`snapshot()` drains the whole table into one `UeRxWindow` per TA (delta
semantics, like `GetRxQuality`) with converted units, window mean/extremes and
`last_tsfl` for staleness. The table is bounded (default 64 TAs per window);
overflow frames are counted in `evicted_frames`, never silently lost.

## Measured (bench)

`tests/ue_rx_attribution_check.sh`: two transmitters with distinct unicast SAs
(8812AU at a 2 ms inter-frame gap, 8822CU at 8 ms) against one `ue_rx_probe`
witness (8812BU), 12 s. The probe attributed the streams separately —
TX1 2955 frames at −51 dBm mean, TX2 741 frames at −44 dBm mean, a 4.0×
count ratio exactly matching the 4× cadence ratio — confirming per-UE frame
counts and per-UE signal statistics don't bleed between transmitters.

## Tooling

- `tests/ue_rx_probe.cpp` — on-air probe: feeds every decoded frame into a
`UeRxAttribution`, drains once a second, emits one `ue.rx` JSONL event per
UE per window (`ta`, `frames`, `rssi_dbm`, `rssi_max_dbm`, `snr_db`,
`snr_min_db`, `evm_db`, `nf_dbm`, `last_tsfl`) plus `ue.rx.evicted` when the
cap was hit. Build line in the header.
- `tests/ue_rx_attribution_selftest.cpp` — headless ctest guard
(`ue_rx_attribution_derive`): TA extraction over frame types, folding
conventions, drain semantics, eviction accounting.
- `tests/ue_rx_attribution_check.sh` — the two-TX on-air validation above.

M1 wraps this into `UeRegistry` (association state, timing advance, and the
per-UE RX window as the link-adaptation input).
25 changes: 20 additions & 5 deletions examples/tdma/tdma.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,40 +49,51 @@ static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
static constexpr size_t kHdrLen = 24; // 802.11 header up to the body
// 'T''D' ver cls seq[4] burst[4] tx_tsf[8]. The 8-byte TX-TSF stamp lets the
// TSF-sync RX measure the TX↔RX crystal drift (0 when the TX can't read TSF).
// v2 appends host_ns[8] — the transmitter's steady_clock at the send_packet
// call, so a witness can fit air-arrival directly against the HOST clock (the
// quantity a host-side slot scheduler actually controls), uncontaminated by
// the ReadTsf control-read round-trip. v1 frames stay parseable.
static constexpr size_t kTagLen = 20;
static constexpr size_t kTagLenV2 = 28;
static constexpr size_t kMinData = kHdrLen + kTagLen;

// Build a full TX buffer: [radiotap for this class][802.11 header][TD tag].
// A nonzero host_ns selects the v2 tag.
inline std::vector<uint8_t> build_frame(const std::vector<uint8_t>& radiotap,
Class cls, uint32_t seq, uint32_t burst,
uint64_t tx_tsf = 0) {
uint64_t tx_tsf = 0,
uint64_t host_ns = 0) {
static const uint8_t hdr[kHdrLen] = {
0x40, 0x00, 0x00, 0x00, // FC + duration
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 broadcast
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr2 = SA
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr3
0x80, 0x00}; // seq ctl
const size_t tag_len = host_ns ? kTagLenV2 : kTagLen;
std::vector<uint8_t> f;
f.reserve(radiotap.size() + kHdrLen + kTagLen);
f.reserve(radiotap.size() + kHdrLen + tag_len);
f.insert(f.end(), radiotap.begin(), radiotap.end());
f.insert(f.end(), hdr, hdr + kHdrLen);
uint8_t tag[kTagLen] = {
'T', 'D', 1, static_cast<uint8_t>(cls),
uint8_t tag[kTagLenV2] = {
'T', 'D', static_cast<uint8_t>(host_ns ? 2 : 1), static_cast<uint8_t>(cls),
static_cast<uint8_t>(seq), static_cast<uint8_t>(seq >> 8),
static_cast<uint8_t>(seq >> 16), static_cast<uint8_t>(seq >> 24),
static_cast<uint8_t>(burst), static_cast<uint8_t>(burst >> 8),
static_cast<uint8_t>(burst >> 16),static_cast<uint8_t>(burst >> 24)};
for (int i = 0; i < 8; ++i) tag[12 + i] = static_cast<uint8_t>(tx_tsf >> (8 * i));
f.insert(f.end(), tag, tag + kTagLen);
for (int i = 0; i < 8; ++i) tag[20 + i] = static_cast<uint8_t>(host_ns >> (8 * i));
f.insert(f.end(), tag, tag + tag_len);
return f;
}

struct Parsed {
bool ok = false;
uint8_t ver = 0;
Class cls = Class::Bulk;
uint32_t seq = 0;
uint32_t burst = 0;
uint64_t tx_tsf = 0;
uint64_t host_ns = 0; // v2 only: TX host steady_clock at send (0 on v1)
};

// Parse an RX Packet.Data span (802.11 MPDU) into a TD tag, if it is one of ours.
Expand All @@ -92,13 +103,17 @@ inline Parsed parse_frame(const uint8_t* data, size_t len) {
if (std::memcmp(data + 10, kSa, 6) != 0) return p; // not our SA
const uint8_t* t = data + kHdrLen;
if (t[0] != 'T' || t[1] != 'D') return p;
p.ver = t[2];
p.cls = static_cast<Class>(t[3]);
p.seq = static_cast<uint32_t>(t[4]) | (static_cast<uint32_t>(t[5]) << 8) |
(static_cast<uint32_t>(t[6]) << 16) | (static_cast<uint32_t>(t[7]) << 24);
p.burst = static_cast<uint32_t>(t[8]) | (static_cast<uint32_t>(t[9]) << 8) |
(static_cast<uint32_t>(t[10]) << 16) | (static_cast<uint32_t>(t[11]) << 24);
for (int i = 0; i < 8; ++i)
p.tx_tsf |= static_cast<uint64_t>(t[12 + i]) << (8 * i);
if (p.ver >= 2 && len >= kHdrLen + kTagLenV2)
for (int i = 0; i < 8; ++i)
p.host_ns |= static_cast<uint64_t>(t[20 + i]) << (8 * i);
p.ok = true;
return p;
}
Expand Down
13 changes: 13 additions & 0 deletions examples/tx/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,19 @@ int main(int argc, char **argv) {
if (th.joinable())
th.join();

{ /* Final TX-submission tally — the periodic tx.stats above emits only
* every 500 frames, so a short run would otherwise never report its true
* count (test scripts read the last tx.stats as the frames-sent
* denominator). */
auto ts = rtlDevice->GetTxStats();
devourer::Ev(*g_ev, "tx.stats")
.f("submitted", (unsigned long long)ts.submitted)
.f("failed", (unsigned long long)ts.failed)
.f("was_timeout", ts.last_was_timeout ? 1 : 0)
.f("last_rc", ts.last_error_rc)
.f("final", 1);
}

/* Bounded hop mode (DEVOURER_HOP_ROUNDS>0) reaches here when its rounds
* complete; the signal and back-off paths also fall through. */
if (!hop_channels.empty()) {
Expand Down
Loading
Loading