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.
What it replaces
Section titled “What it replaces”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 |
API surface (core/include/lrc/credit.h)
Section titled “API surface (core/include/lrc/credit.h)”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, inunit_ms-sized units. Callslrc::airtime_ms()(presets.h) rather than re-deriving time-on-air, so this number can never disagree with whatAirtimeLedgercharges 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.CreditDecisionis one ofForward,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 aCreditReoriginationLimiternaming 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.
Class budgets
Section titled “Class budgets”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. |
The debit rule
Section titled “The debit rule”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.
The gradient-descent forwarding predicate
Section titled “The gradient-descent forwarding predicate”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.
Router re-origination
Section titled “Router re-origination”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 withAirtimeLedger::remaining_duty_bp()— the basis points of legal budget remaining in the sub-band’s rolling window right now, not the staticduty_bpceiling. 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 carryduty_bp = 10000, CN470’s carries 0 — region.h: a 0-duty row is not “blocked forever”, LBT is the mechanism), so the budget scales withcredit_lbt_admit_frac_bp()instead: the fraction of recent listen-before-talk attempts that found the channel clear, from caller-differenced deltas ofregion.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, mirroringlbt_admit()’s fail-closedDeferUnverifiedParams— 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.
Invariants
Section titled “Invariants”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 incredit_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
ACwire byte, its position in the LPP v2 header, and its inclusion inflags::kMutableMask— a single-owner wire event onpacket.h/PROTOCOL.mdwith 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
kCreditUnitMsunit choice, and thekCreditLbtPriorDeferralssmoothing 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 theACbyte. - 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.