mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-20 13:32:56 -04:00
* 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
1130 lines
49 KiB
C++
1130 lines
49 KiB
C++
// 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>
|
|
|
|
#if HAS_VARIABLE_HOPS
|
|
|
|
#include "FSCommon.h"
|
|
#include "gps/RTC.h"
|
|
#include "mesh/NodeDB.h"
|
|
#include "modules/HopScalingModule.h"
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <memory>
|
|
|
|
// Unity only shows TEST_MESSAGE output. printf goes to stdout which the runner swallows.
|
|
#define MSG_BUF_LEN 200
|
|
#define TEST_MSG_FMT(fmt, ...) \
|
|
do { \
|
|
char _buf[MSG_BUF_LEN]; \
|
|
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
|
|
TEST_MESSAGE(_buf); \
|
|
} while (0)
|
|
|
|
static constexpr NodeNum kLocalNode = 0x11111111;
|
|
|
|
// Shared mock clock - drives HopScalingModule::nowMs()
|
|
static uint32_t &mockTime = HopScalingModule::s_testNowMs;
|
|
static constexpr uint32_t ONE_HOUR_MS = 3600UL * 1000UL;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MockNodeDB - not used for hop decisions any more, kept for completeness
|
|
// ---------------------------------------------------------------------------
|
|
class MockNodeDB : public NodeDB
|
|
{
|
|
public:
|
|
void clearTestNodes()
|
|
{
|
|
testNodes.clear();
|
|
numMeshNodes = 0;
|
|
}
|
|
|
|
void addTestNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs, bool viaMqtt = false)
|
|
{
|
|
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
|
node.num = num;
|
|
node.has_hops_away = hasHops;
|
|
node.hops_away = hopsAway;
|
|
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
|
|
node.last_heard = getTime() - ageSecs;
|
|
testNodes.push_back(node);
|
|
meshNodes = &testNodes;
|
|
numMeshNodes = testNodes.size();
|
|
}
|
|
|
|
std::vector<meshtastic_NodeInfoLite> testNodes;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test shim - expose protected/private members for direct invocation
|
|
// ---------------------------------------------------------------------------
|
|
class HopScalingTestShim : public HopScalingModule
|
|
{
|
|
public:
|
|
using HopScalingModule::runOnce;
|
|
using HopScalingModule::samplePacketForHistogram;
|
|
|
|
using HopScalingModule::getLastRequiredHop;
|
|
|
|
// Test-only helpers (require UNIT_TEST friend access)
|
|
void rollHourTest() { rollHour(); }
|
|
void setHistogramDenominator(uint8_t d) { setSamplingDenominator(d); }
|
|
|
|
/// Directly set denominator state, bypassing any scale-up/down logic.
|
|
/// Used by tests that need a specific pre-condition without triggering trim.
|
|
void forceFilterDenomState(uint8_t samp, uint8_t filt, uint8_t holdRolls)
|
|
{
|
|
samplingDenominator = samp;
|
|
filteringDenominator = filt;
|
|
filteringDenomHoldRollsRemaining = holdRolls;
|
|
}
|
|
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)
|
|
{
|
|
if (count < CAPACITY) {
|
|
entries[count].nodeHash = hash;
|
|
entries[count].hops_away = hops;
|
|
entries[count].seenHoursAgo = 1u;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
// Size introspection for test_memory_layout
|
|
static constexpr size_t sizeofSelf() { return sizeof(HopScalingModule); }
|
|
};
|
|
|
|
static MockNodeDB *mockNodeDB = nullptr;
|
|
|
|
// Create deterministic IDs that produce a broad spread of 16-bit hashes.
|
|
// HopScalingModule admission uses passesFilter(hashNodeId(nodeId), denom), NOT a raw nodeId
|
|
// modulo check - do not assume (nodeId & (denom-1)) == 0 determines whether a node is admitted.
|
|
static uint32_t makeDistributedNodeId(uint32_t baseId, uint32_t ordinal, uint32_t salt = 0)
|
|
{
|
|
return baseId + salt + (ordinal * 33u);
|
|
}
|
|
|
|
// Deterministic RNG (rngSeed/rngNext/rngRange) - shared seeded LCG.
|
|
#include "support/DeterministicRng.h"
|
|
static constexpr uint64_t FUZZ_SEED = 0x00F0B5CA1EULL;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers - mesh topology builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Helper: add N nodes at a given hop with ages spread across a time range.
|
|
static void addNodesAtHop(uint32_t baseId, uint8_t hop, uint32_t count, uint32_t ageSecs, uint32_t stride = 10)
|
|
{
|
|
for (uint32_t i = 0; i < count; i++) {
|
|
const uint32_t nodeId = makeDistributedNodeId(baseId, i, static_cast<uint32_t>(hop) << 8);
|
|
mockNodeDB->addTestNode(nodeId, hop, true, ageSecs + i * stride);
|
|
}
|
|
}
|
|
|
|
// Feed sampled traffic into the histogram.
|
|
// Advances mock clock by one hour per roll and calls rollHour() so each roll produces data.
|
|
static void injectSampleTraffic(HopScalingTestShim &shim, uint32_t baseId, const uint16_t hopDist[HOP_MAX + 1],
|
|
uint8_t numRolls = 16)
|
|
{
|
|
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;
|
|
|
|
uint16_t ordinal = 0;
|
|
for (uint8_t hop = 0; hop <= HOP_MAX; ++hop) {
|
|
for (uint16_t n = 0; n < hopDist[hop]; ++n) {
|
|
const uint32_t nodeId = makeDistributedNodeId(baseId, ordinal);
|
|
shim.samplePacketForHistogram(nodeId, hop);
|
|
++ordinal;
|
|
}
|
|
}
|
|
shim.rollHourTest();
|
|
}
|
|
}
|
|
|
|
// 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());
|
|
TEST_ASSERT_TRUE(shim.getCompactHistogramAllSampleCount() > 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Topology builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Scenario A: Dense local mesh - 110 nodes, heavy at hops 0-2.
|
|
static void buildDenseLocalMesh()
|
|
{
|
|
mockNodeDB->clearTestNodes();
|
|
addNodesAtHop(0x1000, 0, 25, 120);
|
|
addNodesAtHop(0x2000, 1, 30, 300);
|
|
addNodesAtHop(0x3000, 2, 15, 600);
|
|
addNodesAtHop(0x4000, 3, 5, 1200);
|
|
addNodesAtHop(0x5000, 4, 10, 1800);
|
|
addNodesAtHop(0x6000, 5, 15, 2400);
|
|
addNodesAtHop(0x7000, 6, 10, 3000);
|
|
}
|
|
|
|
// Scenario B: Spread sparse mesh - 76 nodes across hops 0-7.
|
|
static void buildSpreadSparseMesh()
|
|
{
|
|
mockNodeDB->clearTestNodes();
|
|
addNodesAtHop(0x1000, 0, 5, 120);
|
|
addNodesAtHop(0x2000, 1, 8, 300);
|
|
addNodesAtHop(0x3000, 2, 12, 600);
|
|
addNodesAtHop(0x4000, 3, 15, 900);
|
|
addNodesAtHop(0x5000, 4, 10, 1200);
|
|
addNodesAtHop(0x6000, 5, 6, 1800);
|
|
addNodesAtHop(0x7000, 6, 10, 3000);
|
|
addNodesAtHop(0x8000, 7, 10, 3600);
|
|
}
|
|
|
|
// Scenario C: Deep linear chain - 22 thin nodes, never reaches 40.
|
|
static void buildDeepLinearChain()
|
|
{
|
|
mockNodeDB->clearTestNodes();
|
|
addNodesAtHop(0x1000, 0, 2, 120);
|
|
addNodesAtHop(0x2000, 1, 3, 300);
|
|
addNodesAtHop(0x3000, 2, 3, 600);
|
|
addNodesAtHop(0x4000, 3, 4, 900);
|
|
addNodesAtHop(0x5000, 4, 3, 1200);
|
|
addNodesAtHop(0x6000, 5, 2, 1800);
|
|
addNodesAtHop(0x7000, 6, 2, 2400);
|
|
addNodesAtHop(0x8000, 7, 3, 3600);
|
|
}
|
|
|
|
// Scenario D: Router cluster - 71 nodes, 45 at hop 2.
|
|
static void buildRouterCluster()
|
|
{
|
|
mockNodeDB->clearTestNodes();
|
|
addNodesAtHop(0x1000, 0, 3, 120);
|
|
addNodesAtHop(0x2000, 1, 5, 300);
|
|
addNodesAtHop(0x3000, 2, 45, 600);
|
|
addNodesAtHop(0x4000, 3, 8, 1200);
|
|
addNodesAtHop(0x5000, 4, 3, 1200);
|
|
addNodesAtHop(0x6000, 5, 2, 1800);
|
|
addNodesAtHop(0x7000, 6, 2, 2400);
|
|
addNodesAtHop(0x8000, 7, 3, 3600);
|
|
}
|
|
|
|
// Scenario E: Megamesh - 199 nodes (DB near capacity).
|
|
static void buildMegamesh()
|
|
{
|
|
mockNodeDB->clearTestNodes();
|
|
addNodesAtHop(0x01000, 0, 30, 120);
|
|
addNodesAtHop(0x02000, 1, 40, 300);
|
|
addNodesAtHop(0x03000, 2, 35, 600);
|
|
addNodesAtHop(0x04000, 3, 30, 900);
|
|
addNodesAtHop(0x05000, 4, 20, 1200);
|
|
addNodesAtHop(0x06000, 5, 15, 1800);
|
|
addNodesAtHop(0x07000, 6, 14, 2400);
|
|
addNodesAtHop(0x08000, 7, 15, 3600);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests - Topology-driven hop reduction scenarios
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_dense_local_telemetry()
|
|
{
|
|
TEST_MESSAGE("=== Dense local mesh: telemetry broadcast ===");
|
|
TEST_MESSAGE("Topology: 110 nodes with 25/30/15 nodes at hops 0/1/2 and a thinner tail to hop 6.");
|
|
TEST_MESSAGE("Expectation: cumulative reaches 55 nodes by hop 1, result stays tightly constrained.");
|
|
|
|
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, 0x91000000, distA);
|
|
shim->runOnce();
|
|
|
|
TEST_MSG_FMT("Dense local: hop=%u", shim->getLastRequiredHop());
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 1);
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_spread_sparse_position()
|
|
{
|
|
TEST_MESSAGE("=== Spread sparse mesh: position broadcast ===");
|
|
TEST_MESSAGE("Topology: 76 nodes spread across all hops, reaching 40 nodes only when hop 3 is included.");
|
|
TEST_MESSAGE("Expectation: hop settles in the 3-5 range.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildSpreadSparseMesh();
|
|
const uint16_t distB[HOP_MAX + 1] = {5, 8, 12, 15, 10, 6, 10, 10};
|
|
injectSampleTraffic(*shim, 0x92000000, distB);
|
|
shim->runOnce();
|
|
|
|
TEST_MSG_FMT("Spread sparse: hop=%u", shim->getLastRequiredHop());
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 3);
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 5);
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_deep_chain_position()
|
|
{
|
|
TEST_MESSAGE("=== Deep linear chain: position broadcast ===");
|
|
TEST_MESSAGE("Topology: 22 nodes spread thinly across hops 0-7, never reaching the 40-node floor.");
|
|
TEST_MESSAGE("Expectation: module must keep HOP_MAX.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildDeepLinearChain();
|
|
const uint16_t distC[HOP_MAX + 1] = {2, 3, 3, 4, 3, 2, 2, 3};
|
|
injectSampleTraffic(*shim, 0x93000000, distC);
|
|
shim->runOnce();
|
|
|
|
TEST_MSG_FMT("Deep chain: hop=%u", shim->getLastRequiredHop());
|
|
TEST_ASSERT_EQUAL_UINT8(HOP_MAX, shim->getLastRequiredHop());
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_router_cluster_telemetry()
|
|
{
|
|
TEST_MESSAGE("=== Router cluster: telemetry broadcast ===");
|
|
TEST_MESSAGE("Topology: 71 nodes with a concentrated 45-node cluster at hop 2.");
|
|
TEST_MESSAGE("Expectation: result stays in the 2-4 range.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildRouterCluster();
|
|
const uint16_t distD[HOP_MAX + 1] = {3, 5, 45, 8, 3, 2, 2, 3};
|
|
injectSampleTraffic(*shim, 0x94000000, distD);
|
|
shim->runOnce();
|
|
|
|
TEST_MSG_FMT("Router cluster: hop=%u", shim->getLastRequiredHop());
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() >= 2);
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 4);
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_megamesh_eviction_scaling()
|
|
{
|
|
TEST_MESSAGE("=== Megamesh with eviction scaling ===");
|
|
TEST_MESSAGE("Topology: NodeDB at capacity (199 nodes), ~2000-node mesh with sustained eviction pressure.");
|
|
TEST_MESSAGE("Expectation: sustained evictions tracked in rolling average, hop stays well below HOP_MAX.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildMegamesh();
|
|
|
|
const uint16_t distE[HOP_MAX + 1] = {301, 402, 352, 301, 201, 151, 141, 151};
|
|
injectSampleTraffic(*shim, 0x9B000000, distE);
|
|
|
|
shim->runOnce();
|
|
uint8_t hopBefore = shim->getLastRequiredHop();
|
|
TEST_MSG_FMT("Megamesh initial: hop=%u", hopBefore);
|
|
|
|
for (int hour = 0; hour < 3; hour++) {
|
|
mockTime += ONE_HOUR_MS;
|
|
{
|
|
const uint16_t megaDist[HOP_MAX + 1] = {301, 402, 352, 301, 201, 151, 141, 151};
|
|
uint16_t ordinal = 0;
|
|
for (uint8_t hop = 0; hop <= HOP_MAX; ++hop) {
|
|
for (uint16_t n = 0; n < megaDist[hop]; ++n) {
|
|
const uint32_t nodeId = makeDistributedNodeId(0x9C000000u, ordinal, static_cast<uint32_t>(hour) * 0x10000u);
|
|
shim->samplePacketForHistogram(nodeId, hop);
|
|
++ordinal;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int run = 0; run < 7; run++)
|
|
shim->runOnce();
|
|
|
|
TEST_MSG_FMT("Megamesh hour %d: hop=%u", hour + 1, shim->getLastRequiredHop());
|
|
}
|
|
|
|
TEST_MESSAGE("Assertion: hop stays well below HOP_MAX on a large-distribution mesh.");
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= 3);
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_sparse_to_dense_transition()
|
|
{
|
|
TEST_MESSAGE("=== Sparse-to-dense transition ===");
|
|
TEST_MESSAGE("Topology change: start with a 22-node deep chain, then inject 50 new neighbors at hops 0-1.");
|
|
TEST_MESSAGE("Expectation: hop drops sharply once the local neighborhood becomes dense.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
|
|
buildDeepLinearChain();
|
|
const uint16_t distC2[HOP_MAX + 1] = {2, 3, 3, 4, 3, 2, 2, 3};
|
|
injectSampleTraffic(*shim, 0x95000000, distC2);
|
|
shim->runOnce();
|
|
uint8_t hopSparse = shim->getLastRequiredHop();
|
|
TEST_MSG_FMT("Phase 1 sparse: hop=%u (expect %u)", hopSparse, HOP_MAX);
|
|
TEST_ASSERT_EQUAL_UINT8(HOP_MAX, hopSparse);
|
|
|
|
addNodesAtHop(0xA000, 0, 25, 120);
|
|
addNodesAtHop(0xB000, 1, 25, 300);
|
|
|
|
for (uint32_t i = 0; i < 25; ++i)
|
|
shim->samplePacketForHistogram(makeDistributedNodeId(0xA000, i, static_cast<uint32_t>(0) << 8), 0);
|
|
for (uint32_t i = 0; i < 25; ++i)
|
|
shim->samplePacketForHistogram(makeDistributedNodeId(0xB000, i, static_cast<uint32_t>(1) << 8), 1);
|
|
|
|
for (int run = 0; run < HopScalingModule::RUNS_PER_HOUR; run++)
|
|
shim->runOnce();
|
|
|
|
uint8_t hopDense = shim->getLastRequiredHop();
|
|
TEST_MSG_FMT("Phase 2 dense: hop=%u (expect <= 3)", hopDense);
|
|
|
|
TEST_ASSERT_TRUE(hopDense < hopSparse);
|
|
TEST_ASSERT_TRUE(hopDense <= 3);
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_state_persistence()
|
|
{
|
|
TEST_MESSAGE("=== State persistence across restart ===");
|
|
TEST_MESSAGE("Expectation: histogram entries survive instance teardown and reload.");
|
|
|
|
{
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
|
|
const uint16_t dist[HOP_MAX + 1] = {5, 8, 12, 10, 5, 3, 2, 1};
|
|
injectSampleTraffic(*shim, 0x9D000000, dist, 2);
|
|
|
|
TEST_MSG_FMT("Phase 1: entries=%u hop=%u", shim->getEntryCount(), shim->getLastRequiredHop());
|
|
TEST_ASSERT_GREATER_THAN_UINT8(0, shim->getEntryCount());
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
{
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
shim->runOnce();
|
|
|
|
TEST_MSG_FMT("Phase 2 restored: entries=%u hop=%u", shim->getEntryCount(), shim->getLastRequiredHop());
|
|
TEST_ASSERT_GREATER_THAN_UINT8(0, shim->getEntryCount());
|
|
hopScalingModule = nullptr;
|
|
}
|
|
}
|
|
|
|
void test_hourly_roll()
|
|
{
|
|
TEST_MESSAGE("=== Hourly roll cycle ===");
|
|
TEST_MESSAGE("Expectation: histogram accumulates data and provides valid hop recommendation after multiple rolls.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildSpreadSparseMesh();
|
|
shim->setHistogramDenominator(HopScalingModule::DENOM_MIN);
|
|
|
|
for (uint32_t i = 1; i <= 30; i++) {
|
|
const uint32_t nodeId = makeDistributedNodeId(0x97000000, i, 0xAAu);
|
|
shim->samplePacketForHistogram(nodeId, static_cast<uint8_t>(i % (HOP_MAX + 1)));
|
|
}
|
|
|
|
for (int run = 0; run < 13; run++) {
|
|
int32_t interval = shim->runOnce();
|
|
TEST_ASSERT_GREATER_THAN(0, interval);
|
|
}
|
|
|
|
TEST_MSG_FMT("Hourly roll: hop=%u entries=%u", shim->getLastRequiredHop(), shim->getEntryCount());
|
|
assertCompactHistogramActive(*shim);
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_intermediate_status()
|
|
{
|
|
TEST_MESSAGE("=== Intermediate status (no recomputation) ===");
|
|
TEST_MESSAGE("Expectation: runs between hourly updates leave hop unchanged.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildRouterCluster();
|
|
const uint16_t distD[HOP_MAX + 1] = {3, 5, 45, 8, 3, 2, 2, 3};
|
|
injectSampleTraffic(*shim, 0x98000000, distD);
|
|
|
|
shim->runOnce();
|
|
uint8_t hopAfterInitial = shim->getLastRequiredHop();
|
|
TEST_MSG_FMT("Initial: hop=%u", hopAfterInitial);
|
|
|
|
for (int run = 0; run < 3; run++) {
|
|
shim->runOnce();
|
|
TEST_ASSERT_EQUAL_UINT8(hopAfterInitial, shim->getLastRequiredHop());
|
|
}
|
|
|
|
TEST_MSG_FMT("After 3 intermediate runs: hop=%u (unchanged)", shim->getLastRequiredHop());
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_startup_blank_state()
|
|
{
|
|
TEST_MESSAGE("=== Startup with blank state ===");
|
|
TEST_MESSAGE("Expectation: fresh instance starts with zeroed rolling averages and a valid hop result.");
|
|
|
|
#ifdef FSCom
|
|
FSCom.remove("/prefs/hopScalingState.bin");
|
|
#endif
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
buildDeepLinearChain();
|
|
|
|
int32_t interval = shim->runOnce();
|
|
|
|
TEST_ASSERT_GREATER_THAN(0, interval);
|
|
TEST_ASSERT_TRUE(shim->getLastRequiredHop() <= HOP_MAX);
|
|
|
|
TEST_MSG_FMT("Startup blank: hop=%u", shim->getLastRequiredHop());
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void test_denominator_rises_on_overflow()
|
|
{
|
|
TEST_MESSAGE("=== samplingDenominator doubles when histogram overflows ===");
|
|
TEST_MESSAGE("Fill to > FILL_HIGH_PCT with forceInsertEntry, then trigger via samplePacketForHistogram.");
|
|
TEST_MESSAGE("Expectation: samp/filt both double to 2, hold set to FILTER_DENOM_HOLD_ROLLS.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
|
|
// Insert 103 entries with hashes 1..103 (all distinct, no sampling-filter skew).
|
|
// 103 / 128 = 80.4% fill, which meets FILL_HIGH_PCT=80.
|
|
// Odd hashes (1,3,...,103) will be evicted when denom doubles to 2; even ones survive.
|
|
static constexpr uint8_t FILL_COUNT = 103u;
|
|
for (uint8_t i = 1; i <= FILL_COUNT; i++)
|
|
shim->forceInsertEntry(i, 2u);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getSamplingDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getFilteringDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(0u, shim->getFilteringDenomHoldRollsRemaining());
|
|
TEST_ASSERT_EQUAL_UINT8(FILL_COUNT, shim->getEntryCount());
|
|
|
|
// A new node passes the denom=1 admission gate; fill ≥ 80% triggers trimIfNeeded → doubling.
|
|
shim->samplePacketForHistogram(0xB0000000u, 1u);
|
|
|
|
TEST_MSG_FMT("After scale-up: samp=1/%u filt=1/%u holdRolls=%u entries=%u", shim->getSamplingDenominator(),
|
|
shim->getFilteringDenominator(), shim->getFilteringDenomHoldRollsRemaining(), shim->getEntryCount());
|
|
|
|
TEST_ASSERT_EQUAL_UINT8(2u, shim->getSamplingDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::FILTER_DENOM_HOLD_ROLLS, shim->getFilteringDenomHoldRollsRemaining());
|
|
// After evicting entries with (hash & 1) != 0, roughly half the entries remain.
|
|
TEST_ASSERT_LESS_THAN_UINT8(FILL_COUNT, shim->getEntryCount());
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_filtering_denom_hold_counts_down()
|
|
{
|
|
TEST_MESSAGE("=== filteringDenominator held while hold counter > 0 ===");
|
|
TEST_MESSAGE("Force filt=4 samp=1 hold=3; verify no step for 2 rolls, then step fires on roll 3.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
|
|
// samp=DENOM_MIN so scale-down in step 4 can't go lower; hold=3 for a short, fast test.
|
|
shim->forceFilterDenomState(HopScalingModule::DENOM_MIN, 4u, 3u);
|
|
|
|
shim->rollHourTest(); // hold 3→2, no step
|
|
TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenomHoldRollsRemaining());
|
|
|
|
shim->rollHourTest(); // hold 2→1, no step
|
|
TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenomHoldRollsRemaining());
|
|
|
|
// Roll 3: hold 1→0, step fires - filteringDenominator halves to max(2, samp=1) = 2.
|
|
shim->rollHourTest();
|
|
TEST_MSG_FMT("After hold expires: filt=1/%u samp=1/%u holdRolls=%u", shim->getFilteringDenominator(),
|
|
shim->getSamplingDenominator(), shim->getFilteringDenomHoldRollsRemaining());
|
|
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(0u, shim->getFilteringDenomHoldRollsRemaining());
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_filtering_denom_steps_down_gradually()
|
|
{
|
|
TEST_MESSAGE("=== filteringDenominator descends one halving per rollHour() after hold expires ===");
|
|
TEST_MESSAGE("Force filt=8 samp=1 hold=1; expect 8→4→2→1 over 3 rolls, then stable.");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
|
|
shim->forceFilterDenomState(HopScalingModule::DENOM_MIN, 8u, 1u);
|
|
|
|
shim->rollHourTest(); // hold 1→0, step: 8/2=4 > 1, filt=4
|
|
TEST_ASSERT_EQUAL_UINT8(4u, shim->getFilteringDenominator());
|
|
|
|
shim->rollHourTest(); // hold=0 (no decrement), step: 4/2=2 > 1, filt=2
|
|
TEST_ASSERT_EQUAL_UINT8(2u, shim->getFilteringDenominator());
|
|
|
|
shim->rollHourTest(); // step: 2/2=1, not > samp=1, filt=samp=1 - converged
|
|
TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenominator());
|
|
|
|
shim->rollHourTest(); // filt==samp, outer if is false - no further change
|
|
TEST_ASSERT_EQUAL_UINT8(1u, shim->getFilteringDenominator());
|
|
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::DENOM_MIN, shim->getSamplingDenominator());
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_full_at_denom_max_drops_entry()
|
|
{
|
|
TEST_MESSAGE("=== Full histogram at DENOM_MAX drops new entries ===");
|
|
TEST_MESSAGE("Fill CAPACITY entries, force samp=DENOM_MAX, sample admissible node.");
|
|
TEST_MESSAGE("Expectation: entry count stays at CAPACITY (LOG_WARN fires; visible in test output).");
|
|
|
|
auto shim = std::unique_ptr<HopScalingTestShim>(new HopScalingTestShim());
|
|
hopScalingModule = shim.get();
|
|
shim->setHashSeed(0); // deterministic hash for admissible-ID search
|
|
|
|
shim->forceFilterDenomState(HopScalingModule::DENOM_MAX, HopScalingModule::DENOM_MAX, 0u);
|
|
|
|
// Fill with odd hashes 1,3,5,...,(2*CAPACITY-1). None are multiples of 128, so none
|
|
// collide with the admissible node's hash (which must be a multiple of 128).
|
|
for (uint16_t i = 0; i < HopScalingModule::CAPACITY; i++)
|
|
shim->forceInsertEntry(static_cast<uint16_t>(2u * i + 1u), 1u);
|
|
|
|
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::CAPACITY, shim->getEntryCount());
|
|
|
|
// Find a node ID whose hash passes DENOM_MAX, i.e. (hash & 127) == 0.
|
|
uint32_t admissibleId = 0;
|
|
for (uint32_t id = 1u; id < 0x10000u; id++) {
|
|
if ((shim->hashNodeIdPublic(id) & (HopScalingModule::DENOM_MAX - 1u)) == 0u) {
|
|
admissibleId = id;
|
|
break;
|
|
}
|
|
}
|
|
TEST_ASSERT_NOT_EQUAL(0u, admissibleId); // sanity: the hash space is dense enough to find one quickly
|
|
|
|
shim->samplePacketForHistogram(admissibleId, 3u);
|
|
|
|
TEST_MSG_FMT("After drop attempt: entries=%u CAPACITY=%u admissibleId=0x%08x hash=0x%04x", shim->getEntryCount(),
|
|
static_cast<unsigned>(HopScalingModule::CAPACITY), admissibleId,
|
|
static_cast<unsigned>(shim->hashNodeIdPublic(admissibleId)));
|
|
TEST_ASSERT_EQUAL_UINT8(HopScalingModule::CAPACITY, shim->getEntryCount());
|
|
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void test_scenario_summary_output()
|
|
{
|
|
TEST_MESSAGE("=== Scenario summary ===");
|
|
TEST_MESSAGE("Scenario | Nodes | Distribution | Hop | Why");
|
|
TEST_MESSAGE("A: Dense local | 110 | 25/30/15/5/10/15/10 h0-6 | 1-2 | 55 nodes at h1 >> 40");
|
|
TEST_MESSAGE("B: Spread | 76 | 5/8/12/15/10/6/10/10 h0-7 | 3-4 | Need h3 to reach 40");
|
|
TEST_MESSAGE("C: Deep chain | 22 | 2/3/3/4/3/2/2/3 h0-7 | 7 | Never reaches 40");
|
|
TEST_MESSAGE("D: Router | 71 | 3/5/45/8/3/2/2/3 h0-7 | 2-3 | 45-node hop-2 cluster");
|
|
TEST_MESSAGE("E: Megamesh | 199 | 30/40/35/30/20/15/14/15 h0-7 | 0-1 | Dense low-hop histogram");
|
|
TEST_MESSAGE("F: Transition | 22->72 | Chain -> dense local | 7-><=3 | Adapts to new neighbors");
|
|
TEST_MESSAGE("G: Persistence | -- | -- | -- | Eviction avg survives reboot");
|
|
TEST_MESSAGE("");
|
|
TEST_MESSAGE("=== Denominator state machine summary ===");
|
|
TEST_MESSAGE("Test | Pre-condition | Expectation");
|
|
TEST_MESSAGE("H: Rises on overflow | 103 entries forced, denom=1 | samp/filt→2, holdRolls=13");
|
|
TEST_MESSAGE(
|
|
"I: Hold counts down | filt=4 samp=1 hold=3 | no step for 2 rolls, step on roll 3: filt→2");
|
|
TEST_MESSAGE("J: Steps down gradually | filt=8 samp=1 hold=1 | 8→4→2→1 over 3 rolls, stable on 4th");
|
|
TEST_MESSAGE("K: Full at DENOM_MAX drops entry | 128 entries, samp=filt=128 | count stays 128, LOG_WARN emitted");
|
|
}
|
|
|
|
static void test_memory_layout()
|
|
{
|
|
TEST_MSG_FMT("%-35s %6s %s", "Type", "bytes", "Notes");
|
|
TEST_MSG_FMT("%-35s %6zu %s", "Record", sizeof(Record), "nodeHash:16 + hops:3 + seen:13 (32-bit packed)");
|
|
TEST_MSG_FMT("%-35s %6zu %s", "HopScalingModule::PerHopCounts", sizeof(HopScalingModule::PerHopCounts),
|
|
"perHop[8](16) + total(2)");
|
|
TEST_MSG_FMT("%-35s %6zu %s", "HopScalingModule (instance)", HopScalingTestShim::sizeofSelf(),
|
|
"entries[128](512) + denom state + cached results + OSThread overhead");
|
|
|
|
TEST_PASS();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unity setup / teardown / main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fuzz - crafted-nodenum blitz of the hop histogram
|
|
// ---------------------------------------------------------------------------
|
|
// The scenario tests above check the scaling *math* with realistic topologies. This one is adversarial:
|
|
// it floods samplePacketForHistogram with node numbers chosen to maximize table churn (a tiny colliding
|
|
// pool, boundary values, broad random) plus denominator swings and hourly rolls, and asserts the fixed
|
|
// 128-entry table's invariants hold on every step. hopCount is kept in 0..MAX_HOP: the wire caps hops at
|
|
// 7 (3-bit protobuf field), so out-of-range hops are not a reachable input. Runs under ASan/LSan.
|
|
void test_fuzz_nodenum_blitz(void)
|
|
{
|
|
TEST_MSG_FMT("=== Fuzz: crafted-nodenum histogram blitz (seed=0x%llx) ===", (unsigned long long)FUZZ_SEED);
|
|
rngSeed(FUZZ_SEED);
|
|
|
|
static HopScalingTestShim shim; // static: OSThread-derived (Unity longjmp skips dtors on a failed assert)
|
|
shim.clear();
|
|
shim.setHistogramDenominator(HopScalingModule::DENOM_MIN); // sample-all: maximum admission / churn
|
|
|
|
for (unsigned k = 0; k < 40000; k++) {
|
|
NodeNum id;
|
|
switch (rngRange(4)) {
|
|
case 0:
|
|
id = rngEdgeNodeNum(&kLocalNode, 1);
|
|
break; // shared boundary pool (0/1/broadcast) + local node
|
|
case 1:
|
|
id = rngNext() & 0xFFu;
|
|
break; // tiny pool: forces hash reuse (update path) and collisions
|
|
default:
|
|
id = ((NodeNum)rngNext() << 1) ^ rngNext();
|
|
break; // broad random
|
|
}
|
|
uint8_t hop = (uint8_t)rngRange(HopScalingModule::MAX_HOP + 1); // 0..7, wire-bounded
|
|
|
|
shim.samplePacketForHistogram(id, hop);
|
|
|
|
// Occasionally swing the sampling denominator and roll the hour to drive trim / eviction /
|
|
// the hourly rolling + scaling under sustained churn. Denominators stay powers of two (1..128) -
|
|
// the only states the module actually produces (its passesFilter uses hash & (denom-1)).
|
|
if (rngRange(256) == 0)
|
|
shim.setHistogramDenominator((uint8_t)(1u << rngRange(8))); // 1, 2, 4, ... 128
|
|
if (rngRange(512) == 0) {
|
|
mockTime += ONE_HOUR_MS;
|
|
shim.rollHourTest();
|
|
}
|
|
|
|
// Invariants that must hold on every step regardless of input.
|
|
TEST_ASSERT_TRUE_MESSAGE(shim.getEntryCount() <= HopScalingModule::CAPACITY, "histogram overran CAPACITY");
|
|
TEST_ASSERT_TRUE_MESSAGE(shim.getFillPercentage() <= 100, "fill percentage exceeded 100");
|
|
TEST_ASSERT_TRUE_MESSAGE(shim.getLastRequiredHop() <= HOP_MAX, "required-hop recommendation exceeded HOP_MAX");
|
|
}
|
|
}
|
|
|
|
void setUp(void)
|
|
{
|
|
if (!mockNodeDB)
|
|
mockNodeDB = new MockNodeDB();
|
|
mockNodeDB->clearTestNodes();
|
|
|
|
config = meshtastic_LocalConfig_init_zero;
|
|
moduleConfig = meshtastic_LocalModuleConfig_init_zero;
|
|
myNodeInfo.my_node_num = kLocalNode;
|
|
nodeDB = mockNodeDB;
|
|
|
|
#ifdef FSCom
|
|
FSCom.remove("/prefs/hopScalingState.bin");
|
|
#endif
|
|
|
|
// Reset mock clock to a known base (1 hour in so subtraction never underflows)
|
|
mockTime = ONE_HOUR_MS;
|
|
}
|
|
|
|
void tearDown(void)
|
|
{
|
|
hopScalingModule = nullptr;
|
|
}
|
|
|
|
void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
nodeDB = mockNodeDB;
|
|
|
|
UNITY_BEGIN();
|
|
|
|
printf("\n=== Topology-driven hop reduction ===\n");
|
|
RUN_TEST(test_dense_local_telemetry);
|
|
RUN_TEST(test_spread_sparse_position);
|
|
RUN_TEST(test_deep_chain_position);
|
|
RUN_TEST(test_router_cluster_telemetry);
|
|
RUN_TEST(test_megamesh_eviction_scaling);
|
|
RUN_TEST(test_sparse_to_dense_transition);
|
|
|
|
printf("\n=== Lifecycle ===\n");
|
|
RUN_TEST(test_state_persistence);
|
|
RUN_TEST(test_hourly_roll);
|
|
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);
|
|
RUN_TEST(test_filtering_denom_steps_down_gradually);
|
|
RUN_TEST(test_full_at_denom_max_drops_entry);
|
|
|
|
printf("\n=== Fuzz ===\n");
|
|
RUN_TEST(test_fuzz_nodenum_blitz);
|
|
|
|
printf("\n=== Summary ===\n");
|
|
RUN_TEST(test_memory_layout);
|
|
RUN_TEST(test_scenario_summary_output);
|
|
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|
|
|
|
#else // !HAS_VARIABLE_HOPS
|
|
|
|
void setUp(void) {}
|
|
void tearDown(void) {}
|
|
|
|
void setup()
|
|
{
|
|
initializeTestEnvironment();
|
|
UNITY_BEGIN();
|
|
exit(UNITY_END());
|
|
}
|
|
|
|
void loop() {}
|
|
|
|
#endif |