Skip to content

Credit routing

Status: host decision layer landed, wire-free. This document is normative for core/include/lrc/credit.h. The design rationale lives in research/routing-redesign/CREDIT_ROUTING.md (read that for the why); this page is the what, kept in sync with the header as code moves. The AC wire byte that replaces TH is a separate, not-yet-landed LPP v2 wire event — everything below is pure host logic that runs identically before and after that byte exists.

LPP v1’s TH byte packs TTL (4 bits) and hops-taken (4 bits) into one field asked to do three jobs at once: loop guard, flood-radius bound, and resource bound. Credit routing splits those into three independent mechanisms, only one of which is this document’s subject:

Job Mechanism Where
Loop guard (SRC, MSGID) dedup ring dedup.h (unchanged)
Uplink shape / second half of the loop guard gradient descent over router-distance credit.h’s credit_makes_progress()
Flood width contention window + suppression routing_policy.h (unchanged)
Resource bound airtime credit credit.h, this document

Pure functions and structs: no packets, no I/O, no globals, no heap. Safe to call per-received-packet on a radio-adjacent path (ESP32 relay loop or lrcd).

  • credit_debit_units(preset_id, payload_bytes, unit_ms = kCreditUnitMs) — the airtime debit for retransmitting a frame at a given preset, in unit_ms-sized units. Calls lrc::airtime_ms() (presets.h) rather than re-deriving time-on-air, so this number can never disagree with what AirtimeLedger charges the same transmission against the local duty-cycle budget.
  • credit_makes_progress(rdist_self, rprog_in) — the gradient-descent forwarding predicate in isolation: true iff relaying strictly lowers router-distance. This, not a hop count, is what makes a forwarding loop provably impossible (see Invariants below).
  • credit_forward(CreditForwardInput) -> CreditForwardResult — the full per-hop decision: gradient predicate first, then the debit against remaining credit. CreditDecision is one of Forward, DropNoProgress, DropExhausted.
  • credit_initial_budget(CreditClass) — the origin credit budget for a traffic class (see Class budgets below).
  • credit_reorigination_budget(CreditClass, CreditReoriginationInput) -> CreditReoriginationResult — the fresh budget a router stamps when forwarding across a scope boundary, coupled to the target cell’s live regulatory state through WS-REGION’s real interfaces (see Re-origination below). The result carries a CreditReoriginationLimiter naming which signal governed, so the caller bumps the matching telemetry counter.
  • credit_lbt_admit_frac_bp(CreditLbtPressure) — the smoothed clear-channel fraction (basis points) behind the LBT coupling, exposed separately so its monotonicity and fail-closed zero-sample behavior are testable in isolation.

Origin credit is a function of traffic class, not a single global constant — a DISCOVERY probe and a CHAT message should not get the same reach. Values are frozen by the v2 sweep — evidence and per-constant rationale in research/traces/credit-freeze/, frozen-constants table in PROTOCOL.md §AC, real-traffic validation CI-gated by tests/scenarios/credit_depth.scn. Each class constant remains independently retunable in a future deliberate protocol event.

Class Constant Initial credit ≈ airtime (50 ms unit) Rationale
Discovery kCreditBudgetDiscovery 100 5 s Cross one unknown cell to find a router/relay; never flood the whole federation.
Chat kCreditBudgetChat 120 6 s The workhorse — the budget the worked hop table below is built around.
Control kCreditBudgetControl 60 3 s ACK/RACK/LANEGRANT answer something already in flight; less reach needed than an origin flood.
Bulk kCreditBudgetBulk 0 (n/a) n/a DCCDATA burst lanes are scheduled via the lease’s airtime budget, not flood-credited; credit_initial_budget() returning 0 is a signal to route through the lease path instead.

Before retransmitting, a relay computes the airtime the frame will actually cost at the preset it is about to send on and debits max(kCreditMinDebitUnits, ceil(toa_ms / kCreditUnitMs)) from the frame’s remaining credit. If the debit exceeds what remains, the frame is dropped (Counter::credit_drop_exhausted) rather than forwarded partially. The minimum debit floor (kCreditMinDebitUnits, currently 1) bounds even sub-unit presets to at most 255 relays along one path — the floor is what keeps the resource bound meaningful even on the cheapest lane.

What that buys at each rung (≈55-byte frame, 50 ms unit, 120-credit CHAT budget):

Preset TOA debit (units) max hops on 120 credit
P0 ANCHOR (SF12/125) ~2.5 s 50 2
P2 (SF10/250) ~450 ms 9 13
P3 default client (SF9/250) ~150 ms 3 40
P5 (SF7/250) ~45 ms 1 120
P7 (SF5/500) ~5 ms 1 120

Hops are abundant where they’re cheap and scarce where they’re ruinous — the opposite of v1’s TTL=7, which priced a 2.5 s SF12 hop and a 45 ms SF7 hop identically.

credit_forward() checks credit_makes_progress(rdist_self, rprog_in) before pricing the frame: relaying is only ever considered when it would strictly decrease the node’s distance to a router (same rdist/rprog sense routing_policy.h’s routing_progress_gain() already uses for contention-window sizing — credit routing reuses the comparison but turns it into a hard forward/drop boundary rather than a delay input). A frame that would not make progress is dropped (Counter::credit_drop_progress) before any airtime is charged against it.

This is the loop guard’s second half (the first half is the dedup ring, unchanged): router-distance is a potential function bounded below by zero, strictly decreasing along any sequence of legal relays, so no sequence of legal relays can revisit a node — that would require the potential to decrease and later increase back, which no single hop is allowed to do. Loop freedom therefore does not depend on counting hops at all.

A router forwarding traffic across a scope boundary (edge→backbone or backbone→edge) is the path authority and stamps a fresh budget via credit_reorigination_budget() rather than carrying the inbound remainder forward. Each cell’s spectrum is a separate resource domain, so end-to-end reach across a federation is routers × per-cell budget — bounded per-cell cost, unbounded network diameter.

The fresh budget is coupled to the cell’s live legal spectrum state through WS-REGION’s real interfaces (region.h, airtime.h), along two axes; when both apply, the smaller result governs (credit is a congestion heuristic, not a compliance ruling, so the most pessimistic live signal wins):

  • Live duty headroom (sub-bands with a duty compliance path, sub_band_has_duty_path()): the class budget scales linearly with AirtimeLedger::remaining_duty_bp() — the basis points of legal budget remaining in the sub-band’s rolling window right now, not the static duty_bp ceiling. A sub-band sitting fully spent at its cap grants the floor until the window rolls, and recovers with it; a caller that scaled off the static cap would keep granting full budgets against an exhausted cell. On an unlimited sub-band (US915) the same term doubles as a channel-occupancy signal: a router spending half its wall-clock on air hands out half-size budgets. Note the scale is absolute spectral headroom (fraction of the window, reference 10000 bp), not the fraction of the region’s own cap remaining — an idle EU868 1%-sub-band cell hands out leaner credit than an idle US915 cell by design (the research doc’s stated intent), which pins low-duty sub-bands at the floor even when idle.
  • LBT deferral pressure (regions with RegionPlan::requires_lbt — KR920, CN470, JP923): duty is not the limiting mechanism there (KR920’s sub-bands carry duty_bp = 10000, CN470’s carries 0 — region.h: a 0-duty row is not “blocked forever”, LBT is the mechanism), so the budget scales with credit_lbt_admit_frac_bp() instead: the fraction of recent listen-before-talk attempts that found the channel clear, from caller-differenced deltas of region.lbt_admit / region.lbt_deferred (CreditLbtPressure). The fraction is smoothed by a pseudo-deferral prior (kCreditLbtPriorDeferrals): zero samples yield fraction 0 — a region that legally requires LBT but has no listen evidence earns the floor, mirroring lbt_admit()’s fail-closed DeferUnverifiedParams — thin evidence is discounted, and large honest samples converge to the true ratio.

Both axes are floored at kCreditReoriginationFloorUnits so a starved cell still re-originates a minimum viable budget instead of silently black-holing every frame that crosses into it, and capped at the class’s own budget so scaling only ever shrinks — re-origination can never mint more credit than the class table allows, regardless of region or live state. Absent evidence also earns the floor, never the full budget: a null ledger (no headroom information) or an empty LBT snapshot fails toward less credit. The one deliberate zero is an out-of-band frequency (or missing region): region.h says an out-of-band TX must be refused outright, so credit refuses too — the floor exists to keep legal cells alive, not illegal ones.

The sub-band is resolved inside credit_reorigination_budget() from the frequency the caller already knows, via WS-REGION’s region_sub_band_index_for_freq() — credit code never tracks its own sub-band id, so the ledger key and the legal table cannot drift apart.

For any single injected frame, credit routing (combined with the mechanisms in the table above) provides:

  • Depth bound: cumulative airtime along any path ≤ credit_initial_budget(cls) × kCreditUnitMs, enforced hop-by-hop by the debit rule in credit_forward().
  • Progress monotonicity: every relay accepted by credit_makes_progress() strictly lowers router-distance, so no path can revisit a node — proven above, not tested by exhaustive search.
  • Re-origination cannot mint reach: credit_reorigination_budget()’s output is always ≤ credit_initial_budget(cls), under every combination of live duty state and LBT pressure (test-swept across every region row); a chain of routers each re-originating still bounds per-cell cost even though total federation reach is unbounded.
  • Absent evidence earns the minimum: no ledger, no LBT samples, or an unsourced regime never yields more credit than the floor — the same fail-closed direction as WS-REGION’s LBT handling.
  • Local ceiling unaffected: nothing in this file changes or bypasses AirtimeLedger — a forged or inflated credit value can make a node want to relay more, but the local duty-cycle admission check is the hard cap on what any node actually transmits, exactly as it was before credit routing existed.

What’s not here (Wave 2 / other workstreams)

Section titled “What’s not here (Wave 2 / other workstreams)”
  • The AC wire byte, its position in the LPP v2 header, and its inclusion in flags::kMutableMask — a single-owner wire event on packet.h / PROTOCOL.md with new golden-byte vectors. credit.h’s doc comments record the intended mapping so that change is a thin wire-up.
  • Final class-budget values, the kCreditUnitMs unit choice, and the kCreditLbtPriorDeferrals smoothing prior — frozen only after the scenario-testbed sweeps in research/routing-redesign/CREDIT_ROUTING.md §7.
  • The node glue that snapshots the LBT counters, picks the sampling window, and bumps the credit counters from CreditReoriginationResult::limiter — the decision layer stays pure; wiring it into the router forward path is the same integration step as the AC byte.
  • The region rows, ledger accounting, and LBT admission themselves — WS-REGION’s scope (region.h, region_table_gen.h, airtime.h); credit only reads their exported state.