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.

Where rdist comes from (gradient learning)

Section titled “Where rdist comes from (gradient learning)”

Node::effective_rdist() resolves the predicate’s rdist_self with a fixed precedence (readiness finding A4, core/include/lrc/rdist_learn.h):

  1. Explicit pinset_routing_distance_context(). Routers pin 0 (lrcd at startup; firmware when role=router): a router IS the gradient floor and must never let overheard traffic drift it. The sim pins declared rdist= ladders the same way; pinned nodes do not learn.
  2. Learned evidence — the passive RdistLearner, fed from traffic the node already decodes: an accepted same-region router BEACON or an SK-verified RACK is direct router contact (rdist 1); any decoded ERB frame stamped rprog = p proves its transmitting neighbor sits at distance p, so this node is at most p+1 through it. Evidence only ever lowers the estimate immediately; worse candidates are ignored; equal candidates refresh; silence relaxes the estimate one step per stale window (default 5 min) toward 15 — mobility-safe and the self-heal for poisoned short estimates. Adoption bumps rdist.adopt.
  3. No evidence — the historical role-agnostic default (rdist 0, “never wrong to relay from”). Learning runs after the relay decision, so a node’s very first ERB contact still gets this bootstrap forward; every decision from the next frame on uses evidence. The no-evidence radius self-limits to one hop (node_erb_relay_default_rdist_self_terminates_after_one_hop); the learned multi-ring behavior is pinned by node_erb_relay_learned_gradient_replaces_blind_default.

Threat class: rprog is mutable and unauthenticated (the ERB’s trust contract), so a forged low rprog can attract traffic exactly like a TRAIL poison — contained the same way (decay self-heals, no trust decision reads the gradient, per-relay AirtimeLedger caps actual spend) and accepted under the same routing residuals (THREAT R-3 class). What origination stamps (an origin’s own rprog) remains part of the RF-gated v2 TX work — Node still only relays ERB frames, it does not originate them.

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 benign 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.