Skip to content
Sens-O-Matic / unified RLC<->RS auto-switch

Sens-O-Matic / unified RLC<->RS auto-switch

Sens-O-Matic, unified RLC<->RS auto-switch: UnifiedSensSender + UnifiedSensReceiver

Rust Feature Transport Encryption

The Sens-O-Matic protocol carries two erasure codes with opposite strengths: the block Reed-Solomon code recovers a whole block at once (highest throughput and a bounded worst-case latency under heavy loss), and the sliding-window RLC code (sens-rlc ) recovers a loss from the next repair (lowest latency at light loss). The unified endpoint runs both on one port and switches between them mid-stream on the loss the receiver feeds back, so a connection rides the code that wins at its current loss. Types UnifiedSensSender / UnifiedSensReceiver and CodeSwitchController live in sens_unified .

The crossover

The controller runs RLC below the threshold and RS above it. The pinned switch threshold is ~15% loss (CROSSOVER_LOSS_Q8 = 38, loss encoded q8 as loss * 256): below it RLC keeps the stream for its low-loss latency edge (lower TTFD and median, incremental delivery), and above it the cover-parity RS carries the throughput and a bounded tail. The relax-back threshold is ~10% (down_q8 = 26); the gap between them is a hysteresis band so loss hovering near the boundary does not flap the code. An up-switch confirms over 3 feedback windows and a down-switch over 8, so a transient spike or dip does not cross either way on its own. The loss at which RS’s raw throughput overtakes RLC’s is workload-dependent (on an MTU-item link RS leads from low loss); the measured per-code matrix is in TRANSPORT_COMPARISON.md .

The code-agnostic loss estimate

The switch is driven by the raw channel loss read from sent-vs-received datagram counts, not from either code’s own feedback - a code-specific signal collapses the moment that code recovers the loss, which is what made a naive estimate flap. The estimate is size-weighted and decaying: the lost and sent datagram counts each decay at 0.95 per feedback window and the estimate is their ratio, so large windows dominate and a small window with one drop cannot read a spuriously high rate.

It is sampled every SWITCH_SAMPLE_PERIOD (50 ms) and gated three ways so it acts only on a real, settled channel rate: a SWITCH_WARMUP (1 s) while the in-flight window ramps from zero (that ramp reads as loss); a MIN_ACCUM_WINDOWS (6) maturity hold after the warmup, so a start-of-stream retransmit burst cannot spike the cold accumulator across the threshold; and a MIN_LOSS_SAMPLE (30 datagrams) floor per window. A 64-byte-item loopback run measured the estimate at 0.060 / 0.102 / 0.125 / 0.150 against injected 6 / 9 / 12 / 15% loss.

Handover: in order and exactly once, both ways

A switch never drops, duplicates, or reorders an item. The sender keeps a replay ring of recently-sent payloads so a handover resends the un-acked tail over the new code rather than draining the old one slowly:

  • RLC -> RS announces the boundary RLC has delivered to, switches, and resends the tail [boundary, sent) over RS (reliable and fast at any loss, so it never waits on RLC’s slow frontier recovery). The receiver re-bases the RS stream onto the global item index at the boundary and drops the overlap with what RLC already delivered.
  • RS -> RLC drains RS, then re-syncs the RLC stream. RLC’s per-code source id advances only for RLC-phase items, so after an RS stint it has diverged from the global item index; both ends re-base their source-id frontier to the global boundary (skip_to), so the resumed stream is clean and in order instead of stalling on holes RLC never carried or replaying its pre-switch buffer that RS already delivered.

A flow-block escape backstops a genuine RLC deadlock the loss estimate cannot see: if RLC’s delivery frontier stays stuck for 750 ms (extreme loss past its redundancy ceiling, where a stalled sender emits no fresh loss sample), the transport migrates to RS and latches it, since a code that just stalled at this loss must not relax straight back. A latency-priority floor keeps RLC’s repair step and window from relaxing below the configured baseline so the light-loss latency edge is preserved.

Cover-parity on Reed-Solomon

When the switch lands on RS, the parity rate is provisioned to cover the measured loss rather than merely track it: with the loss inflated by a 20% margin (p = 1.2 * loss), r = ceil(p * k / (1 - p)), capped so k + r <= MAX_SHARDS (32, the per-block index space; k + r = MAX_SHARDS is decode-sound over GF(256)). Covering the loss is what makes RS the high-loss throughput code; the block-RS page covers the code itself.

One port, optional TLS 1.3

The endpoint demultiplexes QUIC and Sens-O-Matic on a single UDP port by the first wire byte, and a CODE_SWITCH control frame carries the boundary to the receiver. connect_tls / bind_tls wrap the whole endpoint in one rustls TLS 1.3 handshake whose 1-RTT key seals every datagram of both codes, so the switch is crypto-transparent and adds no extra round trip.

Peer restart across the shared socket

The endpoint’s demux routes by wire byte, and the two path-validation frames sit outside the contiguous data range, so they are matched by name alongside it. That is what carries both address migration and the replacement-session adoption over an endpoint whose codes share one socket.

take_session_changed() and session_adoption_counts() are surfaced here with the same meaning they have on the standalone receiver: one report per admission, and (admitted, unanswered) so a refused forgery is visible as the second rising without the first. Both cover either code, so an endpoint pinned with CodePolicy::ForceRs reports its own sessions rather than an idle RLC decoder’s.

Both codes survive a peer restart, by different means. RLC routes by the connection id it already carries; block-RS carries a session epoch in every data datagram and announces it on the heartbeat. Either way a window opens only on a challenge answer.

One window per peer

poll_from() returns (peer, item) - the RLC connection id, or the block-RS session epoch widened to u64 - so a node receiving from several peers can tell whose item it is holding. poll() is the same drain with the tag dropped, and is unchanged.

Both codes decode a window per peer: RLC by connection id , block-RS by session epoch . The code-switch layer above them does not. The delivery frontier, the switch boundary and the TLS packet number are per endpoint, since a switch is negotiated for one connection.

A mesh node therefore pins a code with CodePolicy::ForceRlc or ForceRs, leaves TLS off, or drives SensOMaticRlcReceiver / ReliableUdpReceiver directly. The block-RS receiver also needs with_multi_peer() to serve more than one peer.

A code switch is not a session change. The connection id belongs to the process, so RLC <-> RS handover leaves it untouched and no window reset is triggered by the switch.

Verify

use subetha_cxc::sens_unified::{CodePolicy, UnifiedConfig, UnifiedSensReceiver, UnifiedSensSender};

const ITEM_BYTES: usize = 64;
const ITEMS: u64 = 5000;

let cfg = UnifiedConfig {
    policy: CodePolicy::default_auto(), // RLC<->RS, switched on measured loss
    symbol_len: ITEM_BYTES + 8,
    k: 16,
    r: 16,
    rlc_flow_window: 4096,
    debug_loss: 8, // 8% injected into both decoders so recovery engages
    seed: 42,
    rlc_step: 4,
    rlc_static: false,
};

// Receiver injects the loss into both decoders so delivery exercises recovery.
let mut recv = UnifiedSensReceiver::bind("127.0.0.1:0", cfg)?;
let addr = recv.local_addr()?;

let rx = std::thread::spawn(move || {
    let mut got = Vec::new();
    while (got.len() as u64) < ITEMS {
        for item in recv.poll().unwrap() {
            got.push(u64::from_le_bytes(item[..8].try_into().unwrap()));
        }
    }
    got
});

let mut send = UnifiedSensSender::connect("0.0.0.0:0", addr, cfg)?;
let mut buf = vec![0u8; ITEM_BYTES];
for i in 0..ITEMS {
    buf[..8].copy_from_slice(&i.to_le_bytes());
    send.send_item(&buf)?;
}
send.finish()?;

// Every item is delivered exactly once, in order, regardless of code.
assert_eq!(rx.join().unwrap(), (0..ITEMS).collect::<Vec<_>>());
# Ok::<(), std::io::Error>(())

A short run stays on RLC (it finishes inside the warmup). The loss-driven switch shows at sustained scale: in bridge_lan (--transport sens --fec auto), a 500k-item run holds RLC at 6% loss (0 switches) and switches once to RS at 9-15% loss, every item in order (integrity-asserted) with no flapping. The bidirectional handover is driven deterministically with --switch-seq 8000:rs,18000:rlc, which forces RLC -> RS -> RLC: every item is delivered in order at both 0% and 5% loss, exercising the RS -> RLC re-sync.

References