fix(hopscale): gate hop scaling on measured channel congestion (#11826)

* fix(hopscale): gate hop scaling on measured channel congestion

* fix(hopscale): rename test tripping trufflehog and release congestion at the threshold

* test(hopscale): pin the unscaled precondition in the busy-channel gate test

* fix(hopscale): floor infrastructure roles and engage below the polite gate

* test(hopscale): name the symbol under test and reset the gate in clear()

* fix(hopscale): drop the unneeded congestion reset in clear()

* refactor(hopscale): drop the unused utilization accessor and duplicated log fields

* refactor(hopscale): drive the politeness extension from measured congestion (#11831)

* refactor(hopscale): drive the politeness extension from measured congestion

* refactor(airtime): own the smoothed channel utilization (#11832)

* refactor(airtime): own the smoothed channel utilization

* refactor(airtime): cut comments to the two-line limit

* fix(airtime): fold the smoothed utilization once per crossed bucket

* fix(hopscale): compare the congestion thresholds at whole-percent resolution
This commit is contained in:
Thomas Göttgens authored and GitHub committed 2026-09-15 12:06:19 +00:00
1 parent bc2528b005
commit 3468af94aa
7 files changed
+647 -56

No files matched your search

+55 -11
View File
@@ -2,7 +2,9 @@
#include "NodeDB.h"
#include "UptimeClock.h"
#include "configuration.h"
#include <algorithm>
#include <assert.h>
#include <cmath>
#include <string.h>
AirTime *airTime = NULL;
@@ -60,7 +62,7 @@ uint8_t AirTime::Windows::getPeriodUtilHour(const Held &)
return (secSinceBoot / 60) % MINUTES_IN_HOUR;
}
void AirTime::Windows::syncNow(const Held &)
void AirTime::Windows::syncNow(const Held &held)
{
// Monotonic uptime, not RTC/network time: a user, GPS, or NTP clock change must not move
// airtime accounting. Pure read; the main loop publishes the wrap carry it derives from.
@@ -112,13 +114,16 @@ void AirTime::Windows::syncNow(const Held &)
// Channel utilization is a rolling 60-second view split into six 10-second buckets.
// Clear every bucket crossed while asleep so old airtime decays by real elapsed time.
uint32_t elapsedUtilPeriods = (this->secSinceBoot / 10) - (oldSecSinceBoot / 10);
if (elapsedUtilPeriods >= CHANNEL_UTILIZATION_PERIODS) {
memset(this->channelUtilization, 0, sizeof(this->channelUtilization));
} else {
for (uint32_t i = 1; i <= elapsedUtilPeriods; i++) {
this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
}
// Fold one reading per crossed bucket, each before that bucket is cleared, so one delayed sync
// lands where the same number of 10 s syncs would have. Bounded: six clears empty the window.
const uint32_t steppedUtilPeriods = std::min<uint32_t>(elapsedUtilPeriods, CHANNEL_UTILIZATION_PERIODS);
for (uint32_t i = 1; i <= steppedUtilPeriods; i++) {
foldChannelUtil(channelUtilizationPercentRaw(held), 1, held);
this->channelUtilization[((oldSecSinceBoot / 10) + i) % CHANNEL_UTILIZATION_PERIODS] = 0;
}
// Anything past a full window is elapsed time against an already-empty ring, so it folds as
// idle in closed form rather than looping over a sleep that may have lasted days.
foldChannelUtil(0.0f, elapsedUtilPeriods - steppedUtilPeriods, held);
// TX utilization is a rolling 60-minute view used by duty-cycle checks.
uint32_t elapsedUtilTXPeriods = (this->secSinceBoot / 60) - (oldSecSinceBoot / 60);
@@ -154,11 +159,8 @@ bool AirTime::Windows::airtimeReport(reportTypes reportType, uint32_t *out, size
return true;
}
float AirTime::Windows::channelUtilizationPercent(const Held &held)
float AirTime::Windows::channelUtilizationPercentRaw(const Held &)
{
// Gate decisions should see buckets that have decayed across light-sleep time.
syncNow(held);
uint32_t sum = 0;
for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) {
sum += this->channelUtilization[i];
@@ -167,6 +169,42 @@ float AirTime::Windows::channelUtilizationPercent(const Held &held)
return (float(sum) / float(CHANNEL_UTILIZATION_PERIODS * 10 * 1000)) * 100;
}
float AirTime::Windows::channelUtilizationPercent(const Held &held)
{
// Gate decisions should see buckets that have decayed across light-sleep time.
syncNow(held);
return channelUtilizationPercentRaw(held);
}
void AirTime::Windows::foldChannelUtil(float sample, uint32_t steps, const Held &)
{
if (steps == 0)
return;
if (!hasChannelUtilSample) {
// Seed from the first reading, or a node booting onto a busy channel reports it quiet
// for a whole time constant.
channelUtilAvg = sample;
hasChannelUtilSample = true;
steps--;
}
if (steps > 0) {
const float retained = powf(1.0f - 1.0f / float(CHANNEL_UTILIZATION_EMA_DIVISOR), float(steps));
channelUtilAvg = sample + (channelUtilAvg - sample) * retained;
}
}
float AirTime::Windows::smoothedChannelUtilizationPercent(const Held &held)
{
syncNow(held);
// Nothing folded yet before the first bucket crossing, and 0 would read as an idle channel
// rather than as no data.
return hasChannelUtilSample ? channelUtilAvg : channelUtilizationPercentRaw(held);
}
float AirTime::Windows::utilizationTXPercent(const Held &held)
{
// Duty-cycle checks use this value, so keep it current even outside the periodic thread.
@@ -242,6 +280,12 @@ float AirTime::channelUtilizationPercent()
return w.channelUtilizationPercent(held);
}
float AirTime::smoothedChannelUtilizationPercent()
{
Held held(this);
return w.smoothedChannelUtilizationPercent(held);
}
float AirTime::utilizationTXPercent()
{
Held held(this);
+21 -1
View File
@@ -34,6 +34,8 @@
OUTPUTS:
channelUtilizationPercent() % of the last 60s busy, all three types
smoothedChannelUtilizationPercent()
the same, behind a ~21 min EMA folded per bucket
utilizationTXPercent() % of the last hour we transmitted
isTxAllowedChannelUtil() gate on the former, 40% or 25% "polite"
isTxAllowedAirUtil() gate on the latter, at HALF the duty cycle
@@ -77,6 +79,9 @@
*/
#define CHANNEL_UTILIZATION_PERIODS 6
// EMA weight per crossed 10 s bucket: 1/128 is a time constant of about 21 minutes, so one busy
// or quiet minute cannot move the smoothed figure far.
#define CHANNEL_UTILIZATION_EMA_DIVISOR 128
#define SECONDS_PER_PERIOD 3600
#define PERIODS_TO_LOG 8
#define MINUTES_IN_HOUR 60
@@ -130,6 +135,9 @@ class AirTime : private concurrency::OSThread
void logAirtime(reportTypes reportType, uint32_t airtime_ms);
float channelUtilizationPercent();
/// channelUtilizationPercent() behind an EMA advanced by elapsed time, for a caller that must
/// judge load from a trend rather than from one 60-second window.
float smoothedChannelUtilizationPercent();
float utilizationTXPercent();
/// Compatibility shim: no caller in the tree, kept for out-of-tree ones.
@@ -182,7 +190,12 @@ class AirTime : private concurrency::OSThread
// Modular rings: index is absolute phase, (uptime secs / period) % N, never age.
uint32_t channelUtilization[CHANNEL_UTILIZATION_PERIODS] = {0}; // 6 x 10s
uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only
// EMA over channelUtilization, folded in syncNow() once per crossed 10 s bucket so its
// time constant follows elapsed time rather than how often a caller happens to ask.
float channelUtilAvg = 0.0f;
bool hasChannelUtilSample = false;
uint32_t utilizationTX[MINUTES_IN_HOUR] = {0}; // 60 x 60s, our TX only
// Hour crossings rotated but not yet traced. The core cannot log its own rotations: it
// only ever runs under the lock, and DEBUG_PORT.log() blocks on a UART write. runOnce()
@@ -198,6 +211,13 @@ class AirTime : private concurrency::OSThread
void logAirtime(reportTypes reportType, uint32_t airtime_ms, const Held &);
float channelUtilizationPercent(const Held &);
/// The bucket sum alone. syncNow() folds the EMA and cannot reach it through
/// channelUtilizationPercent(), which would re-enter syncNow().
float channelUtilizationPercentRaw(const Held &);
float smoothedChannelUtilizationPercent(const Held &);
/// Fold `steps` readings of `sample` into channelUtilAvg. Closed form, not a loop, so a
/// multi-day sleep decays by the time elapsed at the cost of one powf.
void foldChannelUtil(float sample, uint32_t steps, const Held &);
float utilizationTXPercent(const Held &);
bool airtimeReport(reportTypes reportType, uint32_t *out, size_t count, const Held &);
uint8_t getSilentMinutes(float txPercent, float dutyCycle, const Held &);
+11
View File
@@ -58,6 +58,17 @@ enum class TrafficType { POSITION, TELEMETRY };
#define default_hop_scaling_min_target_nodes_floor 5 // minimum allowed min_target_nodes
#define default_hop_scaling_max_target_nodes_ceiling 512 // maximum allowed max_target_nodes
// Congestion gate: hop scaling only applies while the channel is measurably busy. Engage sits below
// AirTime::polite_channel_util_percent (25) so hops ease back before the polite gate withholds traffic.
#define default_hop_scaling_congestion_engage_pct 20 // smoothed channel utilization that engages scaling
#define default_hop_scaling_congestion_release_pct 12 // smoothed channel utilization that releases it
#define default_hop_scaling_congestion_confirm_runs 3 // consecutive 5-min samples needed to flip either way
// Above this the hop walk's one-hop extension is cut to its strictest setting: the radio is
// already withholding metadata at the polite gate, so an extra relay is the wrong thing to spend.
#define default_hop_scaling_congestion_strict_pct 25 // = AirTime::polite_channel_util_percent
// Hop floor for the infrastructure roles Router.cpp already groups for zero-cost hops.
#define default_hop_scaling_infrastructure_hop_floor 3
#ifdef USERPREFS_RINGTONE_NAG_SECS
#define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS
#else
+75 -37
View File
@@ -7,6 +7,7 @@
#include "FSCommon.h"
#include "NodeDB.h"
#include "SPILock.h"
#include "airtime.h"
#include "concurrency/LockGuard.h"
#include "mesh-pb-constants.h"
#include <algorithm>
@@ -245,23 +246,15 @@ void HopScalingModule::rollHour()
}
lastPerHopCounts = counts;
// 1b. Compute politeness factor from the 0-2 h vs 1-3 h activity ratio.
{
const uint32_t recent = static_cast<uint32_t>(hourlyRaw[0]) + hourlyRaw[1];
const uint32_t older = static_cast<uint32_t>(hourlyRaw[1]) + hourlyRaw[2];
if (older > 1 && recent > 1) {
const uint32_t r = static_cast<uint32_t>(recent) * ACTIVITY_WEIGHT_SCALE;
const uint32_t o = static_cast<uint32_t>(older);
if (r < o * ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER)
lastPoliteNumer = POLITENESS_GENEROUS;
else if (r > o * ACTIVITY_WEIGHT_STRICT_MIN_NUMER)
lastPoliteNumer = POLITENESS_STRICT;
else
lastPoliteNumer = POLITENESS_DEFAULT;
} else {
lastPoliteNumer = POLITENESS_DEFAULT;
}
}
// 1b. Pick the politeness factor from measured channel utilization. How far the walk may
// stretch and whether it is applied at all now read the same signal, so a node cannot be
// told the mesh is filling up by node counts while the channel says it is idle.
if (smoothedUtilPct() >= CONGESTION_STRICT_PCT)
lastPoliteNumer = POLITENESS_STRICT;
else if (smoothedUtilPct() >= CONGESTION_ENGAGE_PCT)
lastPoliteNumer = POLITENESS_DEFAULT;
else
lastPoliteNumer = POLITENESS_GENEROUS;
// 1c. Scale and cache trend stats (denominatorHistory already advanced above).
{
@@ -424,6 +417,34 @@ void HopScalingModule::trimIfNeeded()
}
}
float HopScalingModule::channelUtil()
{
#ifdef PIO_UNIT_TESTING
return s_testChannelUtil;
#else
return airTime ? airTime->smoothedChannelUtilizationPercent() : 0.0f;
#endif
}
void HopScalingModule::updateCongestion()
{
// AirTime folds its own EMA once per 10 s bucket, so this reads a figure that already covers
// the whole interval between ticks rather than only the 60 s before each one.
utilizationAvg = channelUtil();
// Separate engage/release thresholds, each confirmed over several ticks, so a mesh sitting
// near a threshold does not flap the hop limit between rolls.
const uint8_t util = smoothedUtilPct();
const bool wantsFlip = congested ? (util <= CONGESTION_RELEASE_PCT) : (util >= CONGESTION_ENGAGE_PCT);
congestionConfirmRuns = wantsFlip ? static_cast<uint8_t>(congestionConfirmRuns + 1u) : 0u;
if (congestionConfirmRuns >= CONGESTION_CONFIRM_RUNS) {
congested = !congested;
congestionConfirmRuns = 0;
LOG_INFO("[HOPSCALE] Congestion %s at chanUtil=%u%%", congested ? "engaged" : "released",
static_cast<unsigned>(utilizationAvg));
}
}
void HopScalingModule::logStatusReport(bool didHourlyUpdate) const
{
const bool histActive = (histogramRollCount > 0 && count > 0);
@@ -431,10 +452,11 @@ void HopScalingModule::logStatusReport(bool didHourlyUpdate) const
const uint8_t runsRemaining = didHourlyUpdate ? RUNS_PER_HOUR : (RUNS_PER_HOUR - runsSinceLastHourlyUpdate);
const uint8_t minsUntilRollover = runsRemaining * (RUN_INTERVAL_MS / (60 * 1000UL));
LOG_INFO("[HOPSCALE] hop=%u histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u lastCounted=%u polite=%u/4 "
"nextRoll=%umin",
lastRequiredHop, histActive ? 1u : 0u, getFillPercentage(), samplingDenominator, filteringDenominator, count,
histCounts.total, lastPoliteNumer, minsUntilRollover);
LOG_INFO("[HOPSCALE] hop=%u congested=%u chanUtil=%u%% histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u "
"lastCounted=%u polite=%u/4 nextRoll=%umin",
lastRequiredHop, congested ? 1u : 0u, static_cast<unsigned>(utilizationAvg), histActive ? 1u : 0u,
getFillPercentage(), samplingDenominator, filteringDenominator, count, histCounts.total, lastPoliteNumer,
minsUntilRollover);
LOG_INFO("[HOPSCALE] nodes perHop: [%u %u %u %u %u %u %u %u]", histCounts.perHop[0], histCounts.perHop[1],
histCounts.perHop[2], histCounts.perHop[3], histCounts.perHop[4], histCounts.perHop[5], histCounts.perHop[6],
@@ -449,6 +471,9 @@ int32_t HopScalingModule::runOnce()
const bool isFirstRun = !hasCompletedInitialRun;
bool didHourlyUpdate = false;
// Sampled every tick, not only on a roll, so the gate reacts within minutes of a change.
updateCongestion();
if (isFirstRun) {
hasCompletedInitialRun = true;
runsSinceLastHourlyUpdate = 0;
@@ -466,23 +491,36 @@ int32_t HopScalingModule::runOnce()
}
if (didHourlyUpdate) {
uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX;
// Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops,
// SENSOR reaches at least 1, so these reporting roles remain reachable even
// on a dense mesh where the histogram recommends a lower hop count.
uint8_t roleFloor = 0;
switch (config.device.role) {
case meshtastic_Config_DeviceConfig_Role_TRACKER:
case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER:
roleFloor = 2;
break;
case meshtastic_Config_DeviceConfig_Role_SENSOR:
roleFloor = 1;
break;
default:
break;
if (!congested) {
// Density alone is not a reason to throttle. Hand a hop back per roll rather than
// jumping to HOP_MAX, so a mesh that just quietened does not un-throttle all at once.
if (lastRequiredHop < HOP_MAX)
lastRequiredHop++;
} else {
uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX;
// Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops, SENSOR reaches
// at least 1, and the infrastructure roles reach INFRASTRUCTURE_HOP_FLOOR, so these
// reporting roles remain reachable even on a dense mesh recommending fewer hops.
// The infrastructure set matches the one Router.cpp uses for zero-cost hops.
uint8_t roleFloor = 0;
switch (config.device.role) {
case meshtastic_Config_DeviceConfig_Role_ROUTER:
case meshtastic_Config_DeviceConfig_Role_ROUTER_LATE:
case meshtastic_Config_DeviceConfig_Role_CLIENT_BASE:
roleFloor = INFRASTRUCTURE_HOP_FLOOR;
break;
case meshtastic_Config_DeviceConfig_Role_TRACKER:
case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER:
roleFloor = 2;
break;
case meshtastic_Config_DeviceConfig_Role_SENSOR:
roleFloor = 1;
break;
default:
break;
}
lastRequiredHop = std::max(suggested, roleFloor);
}
lastRequiredHop = std::max(suggested, roleFloor);
}
logStatusReport(didHourlyUpdate);
+37 -7
View File
@@ -105,16 +105,21 @@ class HopScalingModule : private concurrency::OSThread
static constexpr uint8_t POLITENESS_DEFAULT = 2u; // 2/4 = 0.50
static constexpr uint8_t POLITENESS_STRICT = 1u; // 1/4 = 0.25
// Activity weight thresholds (ratio of 0-2 h window vs 1-3 h window).
// Cross-multiply form: recent * ACTIVITY_WEIGHT_SCALE vs older * threshold_numer.
// GENEROUS if recent*10 < older*9 (ratio < 0.9); STRICT if recent*10 > older*12 (ratio > 1.2)
static constexpr uint8_t ACTIVITY_WEIGHT_SCALE = 10u;
static constexpr uint8_t ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER = 9u;
static constexpr uint8_t ACTIVITY_WEIGHT_STRICT_MIN_NUMER = 12u;
// Scheduling: number of 5-minute runOnce() ticks that make up one hourly rollover
static constexpr uint8_t RUNS_PER_HOUR = 12;
// Congestion gate. Scaling is applied only while the smoothed channel utilization says the
// channel is busy; below the release threshold the recommendation is not applied at all.
static constexpr uint8_t CONGESTION_ENGAGE_PCT = default_hop_scaling_congestion_engage_pct;
static constexpr uint8_t CONGESTION_RELEASE_PCT = default_hop_scaling_congestion_release_pct;
static constexpr uint8_t CONGESTION_CONFIRM_RUNS = default_hop_scaling_congestion_confirm_runs;
// Upper band for the politeness numerator, which reads the same smoothed utilization as the
// gate: STRICT at or above this, DEFAULT from CONGESTION_ENGAGE_PCT, GENEROUS below it.
static constexpr uint8_t CONGESTION_STRICT_PCT = default_hop_scaling_congestion_strict_pct;
// Hop floor for the infrastructure roles, so a remote site's own telemetry still reaches operators.
static constexpr uint8_t INFRASTRUCTURE_HOP_FLOOR = default_hop_scaling_infrastructure_hop_floor;
// -----------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------
@@ -179,6 +184,7 @@ class HopScalingModule : private concurrency::OSThread
const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; }
uint8_t getLastSuggestedHop() const { return lastSuggestedHop; }
const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; }
bool isCongested() const { return congested; }
// Compatibility accessors used by tests
uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); }
@@ -200,6 +206,8 @@ class HopScalingModule : private concurrency::OSThread
// Writable from tests as HopScalingModule::s_testNowMs; drives nowMs() in PIO_UNIT_TESTING builds.
inline static uint32_t s_testNowMs = 0;
/// Override the per-session hash seed. Use in tests that need a specific sampling distribution.
// Drives channelUtilizationPercent() in PIO_UNIT_TESTING builds, as s_testNowMs drives nowMs().
inline static float s_testChannelUtil = 0.0f;
void setHashSeed(uint16_t seed) { hashSeed = seed; }
uint16_t getHashSeed() const { return hashSeed; }
/// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator.
@@ -223,6 +231,19 @@ class HopScalingModule : private concurrency::OSThread
/// filteringDenominator once toward samplingDenominator per rollHour() call.
/// 6. Shifts all seen bitmaps left by one hour slot.
void rollHour();
/// Cache the smoothed channel utilization and flip the congestion state once the engage or
/// release threshold has held for CONGESTION_CONFIRM_RUNS consecutive runOnce() ticks.
void updateCongestion();
/// Smoothed channel utilization percent, or 0 when AirTime is not up yet.
static float channelUtil();
/// utilizationAvg to the nearest whole percent. The thresholds are whole percents and the
/// underlying 60 s window is far coarser than a float ULP, so comparing at full float
/// precision only creates dead zones: an EMA converging on a threshold from above settles one
/// ULP off it (12.00006103515625 for a sustained 12%) and an inclusive test never fires.
uint8_t smoothedUtilPct() const { return static_cast<uint8_t>(std::min(utilizationAvg + 0.5f, 255.0f)); }
// -----------------------------------------------------------------------
// Persistence
// -----------------------------------------------------------------------
@@ -309,6 +330,15 @@ class HopScalingModule : private concurrency::OSThread
uint8_t lastRequiredHop = HOP_MAX;
uint8_t histogramRollCount = 0;
// -----------------------------------------------------------------------
// Congestion state
// -----------------------------------------------------------------------
// Cached once per runOnce() from AirTime, so the hourly roll and the status log read one
// consistent value without re-taking the AirTime lock.
float utilizationAvg = 0.0f;
bool congested = false;
uint8_t congestionConfirmRuns = 0;
// -----------------------------------------------------------------------
// Scheduler state
// -----------------------------------------------------------------------
+114
View File
@@ -137,6 +137,115 @@ void test_channel_utilization_decays_once_the_60s_window_passes()
TEST_ASSERT_FLOAT_WITHIN(0.01f, 0.0f, a.channelUtilizationPercent());
}
// channelUtilizationPercent() is a 60-second window, so one reading says almost nothing about
// load: a consumer that samples it every few minutes sees only the minute before each sample.
// The smoothed figure folds that window into an EMA once per crossed 10 s bucket, so it advances
// with real elapsed time rather than with how often somebody asks.
void test_smoothed_channel_utilization_starts_from_the_raw_window()
{
Time::setTestMillis(0);
AirTime a;
a.logAirtime(RX_LOG, 30000); // 30 s of a 60 s window
// Nothing has been folded yet. Reporting 0 here would read as an idle channel rather than as
// "no history", so the raw window stands in until the first bucket crossing.
TEST_ASSERT_FLOAT_WITHIN(0.01f, a.channelUtilizationPercent(), a.smoothedChannelUtilizationPercent());
}
void test_smoothed_channel_utilization_lags_a_sudden_spike()
{
Time::setTestMillis(0);
AirTime a;
a.smoothedChannelUtilizationPercent(); // seed from an idle window
Time::advanceTestMillis(10u * 1000u);
Time::serviceMonotonic();
a.logAirtime(RX_LOG, 30000);
Time::advanceTestMillis(10u * 1000u);
Time::serviceMonotonic();
const float raw = a.channelUtilizationPercent();
const float smoothed = a.smoothedChannelUtilizationPercent();
TEST_ASSERT_TRUE_MESSAGE(raw > 40.0f, "a 30 s burst should show up strongly in the 60 s window");
TEST_ASSERT_TRUE_MESSAGE(smoothed < raw, "one busy bucket must not drag the smoothed figure with it");
}
void test_smoothed_channel_utilization_converges_on_a_sustained_level()
{
Time::setTestMillis(0);
AirTime a;
a.smoothedChannelUtilizationPercent();
// Hold the channel at a steady load for well past the ~21 min time constant, refilling each
// 10 s bucket as it is cleared, and the smoothed figure should walk up to meet the window.
for (uint32_t tick = 0; tick < 400; tick++) {
Time::advanceTestMillis(10u * 1000u);
Time::serviceMonotonic();
a.logAirtime(RX_LOG, 5000); // 5 s busy in every 10 s bucket -> 50%
a.smoothedChannelUtilizationPercent();
}
const float raw = a.channelUtilizationPercent();
const float smoothed = a.smoothedChannelUtilizationPercent();
TEST_ASSERT_FLOAT_WITHIN_MESSAGE(5.0f, raw, smoothed, "sustained load should converge on the raw window");
}
// The raw window is already required to be independent of how often the scheduler runs (see
// test_channel_utilization_is_independent_of_scheduler_rate, and the rotation-on-access contract in
// src/airtime.h). The smoothed figure inherits that requirement: folding one reading for a whole
// delayed sync instead of one per crossed bucket would make the EMA a function of call frequency,
// so two identical nodes would disagree purely because one of them slept.
void test_smoothed_channel_utilization_is_independent_of_sync_rate()
{
Time::setTestMillis(0);
AirTime stepped;
AirTime delayed;
// Identical airtime history: one burst, then a full window of silence. Only the rate at which
// each instance is asked for the figure differs.
stepped.logAirtime(RX_LOG, 30000);
delayed.logAirtime(RX_LOG, 30000);
for (uint32_t bucket = 0; bucket < CHANNEL_UTILIZATION_PERIODS; bucket++) {
Time::advanceTestMillis(10u * 1000u);
Time::serviceMonotonic();
stepped.smoothedChannelUtilizationPercent(); // sampled every bucket
}
const float steppedPct = stepped.smoothedChannelUtilizationPercent();
const float delayedPct = delayed.smoothedChannelUtilizationPercent(); // asked once, at the end
TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.01f, steppedPct, delayedPct,
"the smoothed figure must not depend on how often it is sampled");
}
void test_smoothed_channel_utilization_decays_across_a_long_sleep()
{
Time::setTestMillis(0);
AirTime a;
for (uint32_t tick = 0; tick < 400; tick++) {
Time::advanceTestMillis(10u * 1000u);
Time::serviceMonotonic();
a.logAirtime(RX_LOG, 5000);
a.smoothedChannelUtilizationPercent();
}
const float busy = a.smoothedChannelUtilizationPercent();
TEST_ASSERT_TRUE(busy > 10.0f);
// A sleep longer than the whole window clears every bucket. The fold is capped at one window
// of steps, so the figure drops sharply without being reset outright.
Time::advanceTestMillis(10u * 60u * 1000u);
Time::serviceMonotonic();
const float afterSleep = a.smoothedChannelUtilizationPercent();
TEST_ASSERT_EQUAL_FLOAT(0.0f, a.channelUtilizationPercent());
TEST_ASSERT_TRUE_MESSAGE(afterSleep < busy, "a long idle gap must pull the smoothed figure down");
}
void test_isTxAllowedChannelUtil_blocks_once_over_threshold()
{
Time::setTestMillis(0);
@@ -1211,6 +1320,11 @@ void setup()
RUN_TEST(test_period_history_clears_when_asleep_longer_than_the_whole_log);
RUN_TEST(test_channel_utilization_reflects_recent_airtime);
RUN_TEST(test_channel_utilization_decays_once_the_60s_window_passes);
RUN_TEST(test_smoothed_channel_utilization_starts_from_the_raw_window);
RUN_TEST(test_smoothed_channel_utilization_lags_a_sudden_spike);
RUN_TEST(test_smoothed_channel_utilization_converges_on_a_sustained_level);
RUN_TEST(test_smoothed_channel_utilization_is_independent_of_sync_rate);
RUN_TEST(test_smoothed_channel_utilization_decays_across_a_long_sleep);
RUN_TEST(test_isTxAllowedChannelUtil_blocks_once_over_threshold);
RUN_TEST(test_tx_utilization_decays_once_the_60_minute_window_passes);
RUN_TEST(test_syncNow_survives_millis_wrap);
+334
View File
@@ -1,3 +1,23 @@
// Unit tests for HopScalingModule in src/modules/HopScalingModule.{h,cpp} - the sampled hop
// histogram and the hop limit it recommends for this node's own routine broadcasts.
//
// What is pinned:
// - HopScalingModule::rollHour() walks the scaled per-hop buckets and recommends the smallest
// hop limit that still reaches default_hop_scaling_min_target_nodes, extended by at most one
// hop when the politeness envelope allows it.
// - HopScalingModule::runOnce() applies that recommendation only while the congestion gate is
// engaged, floors it by the sending node's role, and hands a hop back per hourly roll once
// congestion clears. Router.cpp reads the result through getLastRequiredHop() and only ever
// lowers a packet below the user's configured hop_limit.
// - The sampling/filtering denominator state machine, which keeps the 128-entry histogram
// bounded while leaving the population estimate invariant.
//
// The regression guarded: before the congestion gate, the recommendation was driven by node
// density alone, so a dense but idle mesh was throttled exactly as hard as a saturated one and
// remote routers on a near-idle MEDIUM_SLOW mesh went silent (meshtastic/firmware#11794). Delete
// or relax the gate assertions and that returns: scaling engages on node count, with no reference
// to whether the channel is actually busy.
#include "MeshTypes.h"
#include "TestUtil.h"
#include <unity.h>
@@ -8,6 +28,7 @@
#include "gps/RTC.h"
#include "mesh/NodeDB.h"
#include "modules/HopScalingModule.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <memory>
@@ -80,6 +101,20 @@ class HopScalingTestShim : public HopScalingModule
}
uint8_t getFilteringDenomHoldRollsRemaining() const { return filteringDenomHoldRollsRemaining; }
/// Put the congestion gate directly into a state, bypassing the confirm counter, and seed the
/// EMA to a value consistent with it so the next runOnce() does not immediately count toward
/// the opposite flip.
void forceCongestion(bool value)
{
congested = value;
congestionConfirmRuns = 0;
utilizationAvg = value ? static_cast<float>(CONGESTION_ENGAGE_PCT) : 0.0f;
}
/// Set the smoothed utilization directly, so a test can sit on a band boundary without
/// pumping the EMA there sample by sample.
void setSmoothedChannelUtilization(float pct) { utilizationAvg = pct; }
/// Insert an entry with an explicit hash, bypassing the sampling filter.
/// Used to fill the histogram to a known state without depending on hashNodeId distribution.
void forceInsertEntry(uint16_t hash, uint8_t hops)
@@ -130,6 +165,11 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const
{
shim.setHistogramDenominator(HopScalingModule::DENOM_MIN);
// The scenario suites below all assert on an applied hop limit, which only happens while the
// congestion gate is engaged. Put the channel at a busy reading and engage it up front.
HopScalingModule::s_testChannelUtil = 45.0f;
shim.forceCongestion(true);
for (uint8_t roll = 0; roll < numRolls; ++roll) {
mockTime += ONE_HOUR_MS;
@@ -145,6 +185,15 @@ static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const
}
}
// Drive N runOnce() ticks with AirTime reporting a fixed smoothed utilization.
// The gate reads it once per tick, so this is how a test moves it through the confirm counter.
static void pumpRuns(HopScalingTestShim &shim, float channelUtilPct, int runs)
{
HopScalingModule::s_testChannelUtil = channelUtilPct;
for (int i = 0; i < runs; i++)
shim.runOnce();
}
static void assertCompactHistogramActive(HopScalingTestShim &shim)
{
TEST_ASSERT_GREATER_THAN_UINT8(0, shim.getCompactHistogramEntryCount());
@@ -494,6 +543,281 @@ void test_startup_blank_state()
hopScalingModule = nullptr;
}
// ---------------------------------------------------------------------------
// Tests - Congestion gate
// ---------------------------------------------------------------------------
// Pins the fix for meshtastic/firmware#11794: hop scaling used to trigger on node density alone,
// so a dense but idle mesh was throttled exactly as hard as a saturated one. The reporter's
// MEDIUM_SLOW mesh sat at 10-15% channel utilization with spikes into the high teens and went
// silent. A node that is not congested must leave hop_limit alone no matter how dense it is.
void test_congestion_gate_idle_channel_does_not_scale()
{
TEST_MESSAGE("=== Congestion gate: dense mesh, idle channel ===");
TEST_MESSAGE("Topology: the dense 110-node mesh that scales to <= 3 hops when the channel is busy.");
TEST_MESSAGE("Expectation: with the channel reading 10%, nothing is applied and hop returns to HOP_MAX.");
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
injectSampleTraffic(*shim, 0x9E000000, distA);
// The histogram recommendation itself is unchanged - it is the application that is gated.
shim->runOnce();
TEST_ASSERT_TRUE(shim->isCongested());
const uint8_t scaledWhileBusy = shim->getLastRequiredHop();
TEST_MSG_FMT("While congested: hop=%u", scaledWhileBusy);
TEST_ASSERT_TRUE(scaledWhileBusy <= 3);
// 10% is inside the band the reporter measured; it must release and stay released.
pumpRuns(*shim, 10.0f, HopScalingModule::RUNS_PER_HOUR * 8);
TEST_MSG_FMT("After idle channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop());
TEST_ASSERT_FALSE(shim->isCongested());
TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop());
TEST_ASSERT_TRUE(shim->getLastSuggestedHop() <= 3); // recommendation still warm, just not applied
hopScalingModule = nullptr;
}
// The complement of the test above: the gate must still engage on a genuinely busy channel, driven
// through the real EMA and confirm counter rather than forced, or the scaler is dead code.
void test_congestion_gate_scales_on_busy_channel()
{
TEST_MESSAGE("=== Congestion gate: dense mesh, busy channel ===");
TEST_MESSAGE("Expectation: a sustained 45% channel reading engages the gate and applies the hop walk.");
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
injectSampleTraffic(*shim, 0x9F000000, distA);
shim->forceCongestion(false);
HopScalingModule::s_testChannelUtil = 0.0f;
TEST_ASSERT_FALSE(shim->isCongested());
// Pin the precondition: injectSampleTraffic() drives rollHour() directly and never runOnce(),
// so nothing has been applied yet. Without this the final assertion could pass vacuously.
TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop());
pumpRuns(*shim, 45.0f, HopScalingModule::RUNS_PER_HOUR * 2);
TEST_MSG_FMT("After busy channel: congested=%u hop=%u", shim->isCongested() ? 1u : 0u, shim->getLastRequiredHop());
TEST_ASSERT_TRUE(shim->isCongested());
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
hopScalingModule = nullptr;
}
// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp.
// A mesh idling near a threshold would otherwise toggle the gate - and therefore hop_limit - on
// every roll. Smoothing lives in AirTime now, so what this pins is the confirm counter alone:
// readings that cross a threshold on alternate ticks never hold it for CONGESTION_CONFIRM_RUNS
// in a row, so the state must not flip in either direction. Delete the counter and it flaps.
void test_congestion_gate_does_not_flap_at_threshold()
{
TEST_MESSAGE("=== Congestion gate: no flapping around the thresholds ===");
TEST_MESSAGE("Phase 1: released gate, samples alternating either side of the engage threshold.");
TEST_MESSAGE("Phase 2: engaged gate, samples alternating either side of the release threshold.");
// Straddle each threshold rather than hard-coding percentages, so the test follows Default.h.
constexpr float kStraddle = 4.0f;
constexpr float kEngage = static_cast<float>(HopScalingModule::CONGESTION_ENGAGE_PCT);
constexpr float kRelease = static_cast<float>(HopScalingModule::CONGESTION_RELEASE_PCT);
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
injectSampleTraffic(*shim, 0xA0000000, distA);
shim->forceCongestion(false);
HopScalingModule::s_testChannelUtil = 0.0f;
for (int i = 0; i < 60; i++) {
HopScalingModule::s_testChannelUtil = (i % 2) ? kEngage - kStraddle : kEngage + kStraddle;
shim->runOnce();
TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "gate engaged on samples whose average stays below the threshold");
}
shim->forceCongestion(true);
for (int i = 0; i < 60; i++) {
HopScalingModule::s_testChannelUtil = (i % 2) ? kRelease - kStraddle : kRelease + kStraddle;
shim->runOnce();
TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "gate released on a dip that never held for the confirm window");
}
hopScalingModule = nullptr;
}
// HopScalingModule::updateCongestion() in src/modules/HopScalingModule.cpp.
// Both threshold tests are inclusive - at exactly CONGESTION_ENGAGE_PCT the gate engages, at
// exactly CONGESTION_RELEASE_PCT it releases - and nothing else pins that. It is worth pinning
// because the comparison is made on a float average: AirTime's EMA converging on a threshold from
// above settles one ULP off it (12.00006103515625 for a sustained 12%), so comparing at full float
// precision left an inclusive test that could never fire and a gate that never released.
// smoothedUtilPct() rounds to the whole percent the thresholds are declared in; drop that rounding
// and a node sitting exactly on the release threshold stays throttled forever.
void test_congestion_gate_thresholds_are_inclusive()
{
TEST_MESSAGE("=== Congestion gate: engage and release thresholds are inclusive ===");
TEST_MESSAGE("Expectation: exactly the engage percent engages; exactly the release percent releases.");
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
injectSampleTraffic(*shim, 0xA5000000, distA);
shim->forceCongestion(false);
pumpRuns(*shim, static_cast<float>(HopScalingModule::CONGESTION_ENGAGE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS);
TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_ENGAGE_PCT, shim->isCongested() ? 1u : 0u);
TEST_ASSERT_TRUE_MESSAGE(shim->isCongested(), "sitting exactly on the engage threshold must engage");
pumpRuns(*shim, static_cast<float>(HopScalingModule::CONGESTION_RELEASE_PCT), HopScalingModule::CONGESTION_CONFIRM_RUNS);
TEST_MSG_FMT("At exactly %u%%: congested=%u", HopScalingModule::CONGESTION_RELEASE_PCT, shim->isCongested() ? 1u : 0u);
TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "sitting exactly on the release threshold must release");
// The dead zone itself: one ULP above the threshold is where AirTime's EMA actually settles
// when it converges on it from above, and a raw float compare reads that as "still congested"
// forever. Rounding to the whole percent is what makes it releasable.
shim->forceCongestion(true);
const float justAbove = std::nextafterf(static_cast<float>(HopScalingModule::CONGESTION_RELEASE_PCT), 100.0f);
pumpRuns(*shim, justAbove, HopScalingModule::CONGESTION_CONFIRM_RUNS);
TEST_ASSERT_FALSE_MESSAGE(shim->isCongested(), "one ULP above the release threshold must still release");
hopScalingModule = nullptr;
}
// Releasing the gate must not hand every node its full hop_limit back in the same roll - that turns
// a mesh that just quietened into a broadcast storm. Recovery is one hop per hourly roll.
void test_congestion_release_ramps_one_hop_per_roll()
{
TEST_MESSAGE("=== Congestion gate: release ramps one hop per hourly roll ===");
TEST_MESSAGE("Expectation: after release, hop rises by exactly 1 per rollover, not straight to HOP_MAX.");
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
injectSampleTraffic(*shim, 0xA1000000, distA);
shim->runOnce();
uint8_t previous = shim->getLastRequiredHop();
TEST_MSG_FMT("Engaged at hop=%u", previous);
TEST_ASSERT_TRUE(previous < HOP_MAX);
shim->forceCongestion(false);
HopScalingModule::s_testChannelUtil = 0.0f;
for (uint8_t roll = 0; roll < 3; roll++) {
for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++)
shim->runOnce();
const uint8_t now = shim->getLastRequiredHop();
TEST_MSG_FMT("Roll %u: hop=%u", roll + 1, now);
TEST_ASSERT_EQUAL_UINT8(previous + 1, now);
previous = now;
}
hopScalingModule = nullptr;
}
// HopScalingModule::runOnce() in src/modules/HopScalingModule.cpp.
// The role floor is keyed on the sending node's own role, so this lets a remote site's telemetry
// travel without loosening anything for client nodes. Operators read that telemetry to know a
// mountain-top site is alive; issue #11794 is a report of exactly those routers going quiet.
//
// The floored set is the same one Router.cpp groups for zero-cost hops: ROUTER, ROUTER_LATE and
// CLIENT_BASE. REPEATER is deliberately absent - it is deprecated and AdminModule demotes it to
// CLIENT on config set, so a floor keyed on it could never fire.
void test_infrastructure_role_floor_applies_when_congested()
{
TEST_MESSAGE("=== Role floor: infrastructure roles keep a minimum hop count ===");
TEST_MESSAGE("Topology: 200 nodes at hop 0, so the hop walk recommends 0 for an unfloored role.");
TEST_MESSAGE("Expectation: CLIENT scales below the floor, ROUTER/ROUTER_LATE/CLIENT_BASE sit on it.");
const uint16_t distLocal[HOP_MAX + 1] = {200, 60, 20, 5, 3, 2, 2, 1};
const meshtastic_Config_DeviceConfig_Role savedRole = config.device.role;
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
uint8_t clientHop = HOP_MAX;
{
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
injectSampleTraffic(*shim, 0xA2000000, distLocal);
shim->runOnce();
clientHop = shim->getLastRequiredHop();
hopScalingModule = nullptr;
}
TEST_MSG_FMT("CLIENT: hop=%u", clientHop);
TEST_ASSERT_TRUE(clientHop < HopScalingModule::INFRASTRUCTURE_HOP_FLOOR);
const meshtastic_Config_DeviceConfig_Role infraRoles[] = {meshtastic_Config_DeviceConfig_Role_ROUTER,
meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE};
for (size_t i = 0; i < sizeof(infraRoles) / sizeof(infraRoles[0]); i++) {
config.device.role = infraRoles[i];
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
injectSampleTraffic(*shim, 0xA3000000 + (static_cast<uint32_t>(i) << 20), distLocal);
shim->runOnce();
TEST_MSG_FMT("Infrastructure role %u: hop=%u", static_cast<unsigned>(infraRoles[i]), shim->getLastRequiredHop());
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::INFRASTRUCTURE_HOP_FLOOR, shim->getLastRequiredHop());
hopScalingModule = nullptr;
}
config.device.role = savedRole;
}
// The one-hop extension used to be graded by a density trend (0-2 h vs 1-3 h node counts) while the
// gate that decides whether the walk applies at all reads measured airtime. A node could therefore
// be told the mesh was filling up by node counts while the channel sat idle, which is the same
// mismatch issue #11794 reports one level up. Both now read the smoothed channel utilization.
//
// The three regimes PR #10176 defined are preserved, read from airtime instead of node counts:
// GENEROUS while the channel is quiet or clearing, DEFAULT once past the gate's engage point, and
// STRICT at the polite gate, where the radio is already withholding metadata traffic.
void test_politeness_tracks_channel_utilization()
{
TEST_MESSAGE("=== Politeness: graded by measured utilization, not by node-count trend ===");
TEST_MESSAGE("Expectation: 4/4 below the engage point, 2/4 from it, 1/4 from the strict point.");
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
hopScalingModule = shim.get();
buildDenseLocalMesh();
const uint16_t distA[HOP_MAX + 1] = {25, 30, 15, 5, 10, 15, 10, 0};
injectSampleTraffic(*shim, 0xA4000000, distA);
struct Band {
float util;
uint8_t numer;
};
// forceCongestion() seeds the EMA, so each band is reached without pumping it there sample by
// sample; rollHour() then reads utilizationAvg directly.
constexpr float kEngage = static_cast<float>(HopScalingModule::CONGESTION_ENGAGE_PCT);
constexpr float kStrict = static_cast<float>(HopScalingModule::CONGESTION_STRICT_PCT);
// Both band edges are inclusive, so each is probed exactly and one below.
const Band bands[] = {
{0.0f, HopScalingModule::POLITENESS_GENEROUS}, {kEngage - 1.0f, HopScalingModule::POLITENESS_GENEROUS},
{kEngage, HopScalingModule::POLITENESS_DEFAULT}, {kStrict - 1.0f, HopScalingModule::POLITENESS_DEFAULT},
{kStrict, HopScalingModule::POLITENESS_STRICT}, {100.0f, HopScalingModule::POLITENESS_STRICT}};
for (size_t i = 0; i < sizeof(bands) / sizeof(bands[0]); i++) {
shim->setSmoothedChannelUtilization(bands[i].util);
shim->rollHourTest();
const float expected = bands[i].numer / static_cast<float>(HopScalingModule::POLITENESS_DENOM);
TEST_MSG_FMT("util=%u%% -> polite=%u/4", static_cast<unsigned>(bands[i].util),
static_cast<unsigned>(shim->getPoliteness() * HopScalingModule::POLITENESS_DENOM));
TEST_ASSERT_EQUAL_FLOAT(expected, shim->getPoliteness());
}
hopScalingModule = nullptr;
}
// ---------------------------------------------------------------------------
// Tests - Denominator state machine
// ---------------------------------------------------------------------------
@@ -761,6 +1085,16 @@ void setup()
RUN_TEST(test_intermediate_status);
RUN_TEST(test_startup_blank_state);
printf("\n=== Congestion gate ===\n");
RUN_TEST(test_congestion_gate_idle_channel_does_not_scale);
RUN_TEST(test_congestion_gate_scales_on_busy_channel);
RUN_TEST(test_congestion_gate_does_not_flap_at_threshold);
RUN_TEST(test_congestion_gate_thresholds_are_inclusive);
RUN_TEST(test_congestion_release_ramps_one_hop_per_roll);
RUN_TEST(test_infrastructure_role_floor_applies_when_congested);
RUN_TEST(test_politeness_tracks_channel_utilization);
printf("\n=== Denominator state machine ===\n");
RUN_TEST(test_denominator_rises_on_overflow);
RUN_TEST(test_filtering_denom_hold_counts_down);