Automatic variable hop limits based on mesh activity and size estimation (#10176)

* asdf

* Implement SphereOfInfluenceModule for traffic management and eviction tracking

* Implement Sphere of Influence module for dynamic hop limit adjustment and role-based floor

* Update SAMPLING_DENOMINATOR to improve mesh size estimation accuracy

* Add debug logging for scale factor estimation and per-hop node counts in SphereOfInfluenceModule

* Enable variable hop limits and role-based hop floors in Sphere of Influence module

* Respond to copilot review

* Disable variable hop limits and role-based hop floors in Sphere of Influence module

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Implement adaptive sampling for unique node ID tracking in Sphere of Influence module

* Add state persistence for Sphere of Influence module

* Enhance Sphere of Influence module with state management and adaptive sampling adjustments

* Refactor hop scaling functionality: remove SphereOfInfluenceModule and introduce HopScalingModule

- Deleted SphereOfInfluenceModule.h, consolidating its responsibilities into the new HopScalingModule.
- Added HopScalingModule.h and HopScalingModule.cpp to manage hop scaling logic, including eviction tracking and sampling-based mesh size estimation.
- Implemented methods for recording evictions and packet senders, estimating scale factors, and computing required hops based on node activity.
- Introduced state persistence for hop scaling parameters to maintain continuity across reboots.
- Enhanced thread safety and modularity by utilizing concurrency features.

* Guard out STM32. Sowwy.

* Refactor HopScalingModule: enhance sampling logic and improve state management

* Add unit tests for HopScalingModule: implement mock database and various test scenarios

* Refactor test output in HopScalingModule tests: replace printf with TEST_MESSAGE for better integration with Unity

* Refactor HopScalingModule logging: replace lastStatusMode with descriptive mode names for improved readability

* Refactor test_main.cpp: change NodeNum variable to static and improve comments for clarity

* Remove unnecessary delay in setup function for improved test performance

* Add missing include for MeshTypes in test_main.cpp

* Refactor HopScalingModule tests: enhance mesh topology scenarios and improve test clarity

* Update HopScalingModule tests: flesh out node scenarios and improve clarity for dense and sparse mesh cases

* Fix politeness factor calculations in HopScalingModule and update related test scenarios for clarity. Remove outdated design doc.

* Enhance HopScalingModule: add sampled estimate for scaling decisions and refactor initial run state management

* Add sample traffic injection for HopScaling tests to enhance sampledEst visibility

* Enhance HopScalingModule: adjust windowFraction calculation for early triggers and improve test output formatting

* Enhance HopScalingModule: add jitter functionality to sampling denominator and update tests for consistent behavior

* Enhance HopScalingModule: implement adaptive sampling denominator adjustment and add reset functionality for tests

* Enhance HopScalingTestShim: add test-only clock and window helpers, update injectSampleTraffic for adaptive sampling, and improve scenario summary output

* Enhance HopScalingModule: add detailed documentation for functions, improve clarity of jitter and sampling logic, and reset functionality in tests

* Enhance HopScalingModule: add evictionEstimate parameter to estimateScaleFactor and update related logging for improved mesh size estimation

* Enhance HopScalingModule: adjust effective rolls calculation for improved accuracy, add eviction estimate logic, responding to all copilot review points

* Implement CompactHistogram for parallel hop scaling sampling

- Added CompactHistogram class to track node hop distances with bitwise sampling.
- Integrated CompactHistogram into HopScalingModule for independent packet sampling.
- Updated NodeDB to feed both the hop scaling module and the new histogram sampler.
- Enhanced HopScalingModule with methods to sample packets for the histogram and retrieve hop distribution statistics.
- Implemented tests for CompactHistogram functionality, including sampling, window rolling, and adaptive denominator scaling.
- Updated existing tests to validate the integration of the new histogram sampling mechanism.

* Enhance CompactHistogram and HopScalingModule: add per-hop distribution functionality, improve time handling for unit tests, and refine test setup for deterministic behavior

* CompactHistogram: add mesh size estimation, improve entry replacement logic, and update logging for per-hop distribution

* Refactor HopScalingModule and CompactHistogram integration

- Removed the suggestedHopFromCompactHistogram function to streamline hop suggestion logic.
- Updated HopScalingModule to directly utilize CompactHistogram's internal methods for hop suggestions and sampling.
- Enhanced logging in HopScalingModule to provide detailed histogram statistics.
- Modified test cases to ensure comprehensive coverage of new histogram behaviors and sampling logic.
- Improved node ID distribution in tests to better exercise sampling mechanisms.
- Ensured that filtering denominators are held for 12 hours before dropping, enhancing stability in sampling.

* Refactor CompactHistogram to support 13-hour activity tracking and introduce politeness regimes

- Updated the bitfield structure to accommodate 13-hour seen tracking.
- Changed the logic in rollHour() to analyze activity over the last 0-2 hours vs. 1-3 hours for politeness factor calculation.
- Introduced three politeness levels: GENEROUS, DEFAULT, and STRICT based on recent activity ratios.
- Adjusted filtering and sampling logic to reflect the new 13-hour tracking period.
- Updated unit tests to validate new behavior and ensure proper functionality of politeness regimes.

* Enhance CompactHistogram and HopScalingModule for improved sampling and decision-making

- Introduced a session-specific hash seed in CompactHistogram to reduce bias in node ID sampling.
- Updated sampling logic to use hashed node IDs instead of raw IDs for filtering and entry management.
- Added histogram rollover tracking in HopScalingModule to ensure proper decision-making after initial data collection.
- Adjusted logging to reflect the active state of the histogram and its comparison with NodeDB advisory hops.
- Enhanced unit tests to validate new sampling logic and memory layout changes.

* Expose hashNodeId for testing in CompactHistogram

* Add mesh trend statistics to CompactHistogram for enhanced activity tracking

* Implement histogram state persistence in CompactHistogram with save and load functions

* Refactor CompactHistogram to improve entry management and enhance rollHour logging

* feat: add HopScalingModule for adaptive hop limit recommendations

Introduces HopScalingModule, a sampled hop-distance histogram that
recommends the minimum hop limit needed to reach ~40 nodes, and
automatically reducing the hops as the mesh grows.

Key design:
- 512-byte packed histogram (128 × 4-byte Record entries) embedded
   in a new HopScalingModule.
- Each Record: 16-bit node hash, 3-bit hop distance, 13-bit seen bitmap
- Sampling filter: only nodes where (hash & (denom-1)) == 0 are kept;
  denominator doubles on overflow and halves when utilisation is low
- Hourly rollHour(): tallies per-hop counts, walks scaled buckets to
  find the minimum hop satisfying TARGET_AFFECTED_NODES (40), applies a
  politeness extension based on recent/older activity ratio, shifts all
  seen bitmaps, and persists state to /prefs/hopScalingState.bin
- Hop recommendation gated by bootstrap (requires >=1 rollHour before
  overriding HOP_MAX)
- NodeDB calls samplePacketForHistogram() on every non-MQTT rx packet
- Module also estimates total mesh size and logs useful information about
  mesh characteristics.

Changes:
- src/modules/HopScalingModule.h/.cpp: new module
- src/mesh/NodeDB.cpp: wire up samplePacketForHistogram
- src/mesh/Router.cpp: consume getLastRequiredHop()
- test/test_hop_scaling/: 12-test suite covering all mesh topologies and
  anticipated operational requirements

* test: increase run iterations in sparse to dense transition test

* feat: refactor HopScalingModule to use RUNS_PER_HOUR constant and improve logging

* feat: enhance HopScalingModule with filtering denominator management and add tests for state transitions

* refactor: remove CompactHistogram module and related files

* address copilot review comments

* Tweak: packet sampling only lora

* ove role-based hop floor logic and related definitions into the module - keep it in one place.

* Refactor MockNodeDB to use nodeInfoLiteSetBit for MQTT flag setting

* Refactor hop scaling parameters and logic to integer maths and put default values in defaults.h. Small flash size reduction, no functional impact.

* Update unit test preprocessor directives to PIO_UNIT_TESTING for consistency

* refactor: improve test output organization and clarity in hop scaling tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tom
2026-06-04 05:59:49 -05:00
committed by GitHub
co-authored by GitHub Copilot
parent ef51c7ec11
commit de345939af
12 changed files with 1672 additions and 10 deletions
+6 -2
View File
@@ -191,10 +191,12 @@ echo
# PASS/FAIL — every hardware test would SKIP with "role not present". We
# narrow to tests/unit explicitly so the summary reads as "no hardware,
# unit suite only" instead of "big skip count looks suspicious".
# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print
# each skipped test in full; skip counts still appear in pytest's summary.
if [[ -z $DETECTED && $# -eq 0 ]]; then
echo "[pre-flight] no supported devices detected; running unit tier only."
echo
exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl
exec "$VENV_PY" -m pytest tests/unit -q -r fE --report-log=tests/reportlog.jsonl
fi
# Default pytest args when the user passed none. Power users can invoke
@@ -210,11 +212,13 @@ fi
# skipping half the hardware tests with "not baked with session profile"
# errors. Power users who know their hardware is current and want to shave
# those seconds can pass `--assume-baked` explicitly.
# Defaults also use condensed reporting (`-q -r fE`) to avoid listing every
# skipped test verbatim while still surfacing failures/errors and summary data.
if [[ $# -eq 0 ]]; then
set -- tests/ \
--html=tests/report.html --self-contained-html \
--junitxml=tests/junit.xml \
-v --tb=short
-q -r fE --tb=short
fi
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
+6
View File
@@ -37,6 +37,12 @@ enum class TrafficType { POSITION, TELEMETRY };
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
// Hop scaling defaults
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
#define default_hop_scaling_max_target_nodes 80 // generous extension ceiling (2 × min)
#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
#ifdef USERPREFS_RINGTONE_NAG_SECS
#define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS
#else
+11
View File
@@ -25,6 +25,9 @@
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
#include "meshUtils.h"
#include "modules/NeighborInfoModule.h"
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#include "xmodem.h"
#include <ErriezCRC32.h>
#include <algorithm>
@@ -2538,6 +2541,14 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK,
mp.via_mqtt); // Store if we received this packet via MQTT
#if HAS_VARIABLE_HOPS
// Only sample packets that arrived over LoRa.
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && hopScalingModule) {
uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
}
#endif
// If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
const int8_t hopsAway = getHopsAway(mp);
if (hopsAway >= 0) {
+27
View File
@@ -15,6 +15,9 @@
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h"
#endif
@@ -355,6 +358,30 @@ ErrorCode Router::send(meshtastic_MeshPacket *p)
p->from = getFrom(p);
p->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); // set the relayer to us
#if HAS_VARIABLE_HOPS
// Apply HopScaling hop recommendation to routine outgoing broadcasts
if (isFromUs(p) && isBroadcast(p->to) && hopScalingModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) {
switch (p->decoded.portnum) {
case meshtastic_PortNum_POSITION_APP:
case meshtastic_PortNum_TELEMETRY_APP:
case meshtastic_PortNum_NODEINFO_APP:
case meshtastic_PortNum_NEIGHBORINFO_APP: {
uint8_t variableHopLimit = hopScalingModule->getLastRequiredHop();
// Never exceed user-configured hop_limit
if (variableHopLimit < p->hop_limit) {
LOG_DEBUG("[HOPSCALE] hop_limit %u -> %u for portnum %u", p->hop_limit, variableHopLimit, p->decoded.portnum);
p->hop_limit = variableHopLimit;
}
break;
}
default:
break;
}
}
#endif
// If we are the original transmitter, set the hop limit with which we start
if (isFromUs(p))
p->hop_start = p->hop_limit;
+9
View File
@@ -109,6 +109,15 @@ static inline int get_max_num_nodes()
#define HAS_TRAFFIC_MANAGEMENT 0
#endif
// HopScalingModule - variable hop module: dynamically adjusts broadcast hop_limit based on mesh density
// Enable per-variant by defining HAS_VARIABLE_HOPS=1 in variant.h
#ifdef ARCH_STM32WL
#define HAS_VARIABLE_HOPS 0
#endif
#ifndef HAS_VARIABLE_HOPS
#define HAS_VARIABLE_HOPS 1
#endif
// Cache size for traffic management (number of nodes to track)
// Can be overridden per-variant based on available memory
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
+6
View File
@@ -66,4 +66,10 @@ inline uint32_t pow_of_2(uint32_t n)
return 1 << n;
}
/// Returns true if n is a power of two (n >= 1).
template <typename T> constexpr bool is_pow_of_2(T n)
{
return n >= T(1) && (n & (n - T(1))) == T(0);
}
#define IS_ONE_OF(item, ...) isOneOf(item, sizeof((int[]){__VA_ARGS__}) / sizeof(int), __VA_ARGS__)
+493
View File
@@ -0,0 +1,493 @@
#include "HopScalingModule.h"
#include "SafeFile.h"
#include "meshUtils.h"
#if HAS_VARIABLE_HOPS
#include "FSCommon.h"
#include "NodeDB.h"
#include "SPILock.h"
#include "concurrency/LockGuard.h"
#include "mesh-pb-constants.h"
#include <algorithm>
#include <cmath>
#include <cstring>
namespace
{
// Module scheduling
constexpr uint32_t INITIAL_DELAY_MS = 30 * 1000UL; // Startup grace period before first run
constexpr uint32_t RUN_INTERVAL_MS = 5 * 60 * 1000UL; // Emit micro-summary every 5 minutes
// RUNS_PER_HOUR is a public class constant in HopScalingModule.h
// Persistence
// Note: this only needs incrementing if the published arrangement changes. For testing purposes, or prior to widespread release,
// it can stay the same even if the internal layout changes.
constexpr uint32_t HISTOGRAM_STATE_MAGIC = 0x48535432; // 'HST2' — layout v2
constexpr uint8_t HISTOGRAM_STATE_VERSION = 1;
constexpr const char *HISTOGRAM_STATE_FILE = "/prefs/hopScalingState.bin";
#pragma pack(push, 1)
struct PersistedHistogram {
uint32_t magic;
uint8_t version;
uint8_t samplingDenominator;
uint8_t filteringDenominator;
uint8_t filterDenomHoldRollsRemaining; // rollHour() calls remaining in the hold; 0 when expired/not active
uint16_t hashSeed;
Record entries[HopScalingModule::CAPACITY]; // full 512-byte array; count derived on load
};
#pragma pack(pop)
} // namespace
HopScalingModule *hopScalingModule;
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
HopScalingModule::HopScalingModule() : concurrency::OSThread("HopScaling")
{
clear();
loadFromDisk();
setIntervalFromNow(INITIAL_DELAY_MS);
}
void HopScalingModule::clear()
{
memset(entries, 0, sizeof(entries));
count = 0;
samplingDenominator = DENOM_MIN;
filteringDenominator = DENOM_MIN;
filteringDenomHoldRollsRemaining = 0;
lastPerHopCounts = {};
lastSuggestedHop = MAX_HOP;
lastPoliteNumer = POLITENESS_DEFAULT;
lastTrendStats = {};
memset(denominatorHistory, DENOM_MIN, sizeof(denominatorHistory));
#ifndef PIO_UNIT_TESTING
hashSeed = static_cast<uint16_t>(random());
#else
hashSeed = 0; // deterministic in unit tests
#endif
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
void HopScalingModule::saveToDisk() const
{
#ifdef FSCom
FSCom.mkdir("/prefs");
PersistedHistogram state{};
state.magic = HISTOGRAM_STATE_MAGIC;
state.version = HISTOGRAM_STATE_VERSION;
state.samplingDenominator = samplingDenominator;
state.filteringDenominator = filteringDenominator;
state.filterDenomHoldRollsRemaining = filteringDenomHoldRollsRemaining;
state.hashSeed = hashSeed;
// Save all CAPACITY slots; count is reconstructed on load by scanning seenHoursAgo.
memcpy(state.entries, entries, sizeof(state.entries));
auto file = SafeFile(HISTOGRAM_STATE_FILE, true);
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&state), sizeof(state));
if (file.close() && written == sizeof(state)) {
LOG_DEBUG("[HOPSCALE] Saved: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
filteringDenominator, state.filterDenomHoldRollsRemaining);
} else {
LOG_WARN("[HOPSCALE] Failed to write %s (%u of %u bytes)", HISTOGRAM_STATE_FILE, static_cast<unsigned>(written),
static_cast<unsigned>(sizeof(state)));
}
#endif
}
void HopScalingModule::loadFromDisk()
{
#ifdef FSCom
concurrency::LockGuard g(spiLock);
auto file = FSCom.open(HISTOGRAM_STATE_FILE, FILE_O_READ);
if (!file)
return;
PersistedHistogram state{};
const bool readOk = (file.read(reinterpret_cast<uint8_t *>(&state), sizeof(state)) == sizeof(state));
file.close();
// Validate magic, version, denom range, denom power-of-two invariant, and hold counter.
if (!readOk || state.magic != HISTOGRAM_STATE_MAGIC || state.version != HISTOGRAM_STATE_VERSION ||
state.samplingDenominator < DENOM_MIN || state.samplingDenominator > DENOM_MAX ||
state.filteringDenominator < state.samplingDenominator || state.filteringDenominator > DENOM_MAX ||
!is_pow_of_2(state.samplingDenominator) || !is_pow_of_2(state.filteringDenominator) ||
state.filterDenomHoldRollsRemaining > FILTER_DENOM_HOLD_ROLLS) {
LOG_DEBUG("[HOPSCALE] No valid persisted state (magic=%08x ver=%u samp=%u filt=%u hold=%u), starting fresh", state.magic,
state.version, state.samplingDenominator, state.filteringDenominator, state.filterDenomHoldRollsRemaining);
return;
}
// Derive count by scanning: active entries have seenHoursAgo != 0; pack them to the front.
uint8_t restored = 0;
for (uint8_t i = 0; i < CAPACITY && restored < CAPACITY; i++) {
if (state.entries[i].seenHoursAgo != 0u) {
entries[restored++] = state.entries[i];
}
}
count = restored;
samplingDenominator = state.samplingDenominator;
filteringDenominator = state.filteringDenominator;
filteringDenomHoldRollsRemaining = state.filterDenomHoldRollsRemaining;
// denominatorHistory can't be recovered; initialise all slots to filteringDenominator so
// the first few post-reboot scaledPerHour values use a safe (slightly conservative) multiplier.
memset(denominatorHistory, filteringDenominator, sizeof(denominatorHistory));
hashSeed = state.hashSeed;
LOG_INFO("[HOPSCALE] Restored: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
filteringDenominator, state.filterDenomHoldRollsRemaining);
#endif
}
// ---------------------------------------------------------------------------
// Core API
// ---------------------------------------------------------------------------
void HopScalingModule::samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount)
{
const uint16_t hash = hashNodeId(nodeId);
if (!passesFilter(hash, samplingDenominator))
return;
hopCount = std::min(hopCount, MAX_HOP);
// Update an existing entry
Record *entry = nullptr;
for (uint8_t i = 0; i < count; i++) {
if (entries[i].nodeHash == hash) {
entry = &entries[i];
break;
}
}
if (entry) {
entry->hops_away = hopCount;
markCurrentHour(*entry);
return;
}
// New node: trim if necessary before allocating a slot
if (getFillPercentage() >= FILL_HIGH_PCT) {
trimIfNeeded();
}
if (count < CAPACITY) {
entries[count].nodeHash = hash;
entries[count].hops_away = hopCount;
entries[count].seenHoursAgo = 1u; // mark current hour
count++;
} else {
LOG_WARN("[HOPSCALE] Histogram full at samp=1/%u (DENOM_MAX=%u); dropping node hash=0x%04x; hop recommendation may be "
"skewed!!!",
samplingDenominator, DENOM_MAX, hash);
}
}
void HopScalingModule::rollHour()
{
// Advance denominatorHistory before the tally so each slot h holds the filteringDenominator
// that was active when seenHoursAgo bit h was set. hourlyRaw[h] is then gated per-slot by
// denominatorHistory[h], giving a correct population estimate for each historical hour even
// when filteringDenominator changes between rolls. Scale-up backfills the entire array so
// the invariant holds retroactively (see trimIfNeeded()).
for (uint8_t h = 12; h > 0; h--)
denominatorHistory[h] = denominatorHistory[h - 1];
denominatorHistory[0] = filteringDenominator;
// 1. Tally per-hop counts and per-slot hourly activity in one pass.
// hourlyRaw[h]: gated per-slot by denominatorHistory[h] so the raw count and its
// multiplier are always consistent, even across filteringDenominator transitions.
// counts.*: gated uniformly by the current filteringDenominator for a consistent
// population estimate used by the hop-walk recommendation (step 2).
PerHopCounts counts{};
uint16_t hourlyRaw[13] = {};
uint16_t trendNewThisHour = 0;
uint16_t trendReturning = 0;
uint16_t trendLapsed = 0;
uint16_t trendOlderThan4h = 0;
uint16_t trendAgingOut = 0;
for (uint8_t i = 0; i < count; i++) {
const uint16_t hash = entries[i].nodeHash;
const uint32_t seen = entries[i].seenHoursAgo;
// Per-slot hourly activity: gate each slot by its own denominator.
for (uint8_t h = 0; h < 13; h++) {
if ((seen & (1u << h)) && passesFilter(hash, denominatorHistory[h]))
hourlyRaw[h]++;
}
// Hop counts and trend stats: uniform current-denominator gate.
if (!passesFilter(hash, filteringDenominator))
continue;
if (seenInLast13h(entries[i])) {
counts.perHop[entries[i].hops_away]++;
counts.total++;
}
const bool heardThisHour = (seen & 1u) != 0u;
const bool heardLastHour = (seen & 2u) != 0u;
const bool hasOlderHistory = (seen >> 1u) != 0u;
const bool recentlySilent = (seen & 0xFu) == 0u;
if (heardThisHour && !hasOlderHistory)
trendNewThisHour++;
else if (heardThisHour && hasOlderHistory)
trendReturning++;
if (!heardThisHour && heardLastHour)
trendLapsed++;
if (recentlySilent && (seen & 0x1FF0u) != 0u)
trendOlderThan4h++;
if (seen == (1u << 12u))
trendAgingOut++;
}
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;
}
}
// 1c. Scale and cache trend stats (denominatorHistory already advanced above).
{
MeshTrendStats t{};
for (uint8_t h = 0; h < 13; h++) {
const uint32_t s = static_cast<uint32_t>(hourlyRaw[h]) * denominatorHistory[h];
t.scaledPerHour[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
}
auto scale = [&](uint16_t raw) -> uint16_t {
return static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(raw) * filteringDenominator, UINT16_MAX));
};
t.newThisHour = scale(trendNewThisHour);
t.returningThisHour = scale(trendReturning);
t.lapsedSinceLastHour = scale(trendLapsed);
t.olderThan4h = scale(trendOlderThan4h);
t.agingOut = scale(trendAgingOut);
lastTrendStats = t;
}
// 2. Walk scaled hop buckets to produce a hop-limit recommendation.
// effectiveMin: walk threshold — first hop whose cumulative count reaches this.
// effectiveMax: ceiling on the one-hop extension check with GENEROUS politeness.
const uint16_t effectiveMin = TARGET_AFFECTED_NODES;
const uint16_t effectiveMax = MAX_TARGET_NODES;
uint8_t suggested = MAX_HOP;
if (counts.total > 0) {
uint32_t cumulative = 0;
for (uint8_t hop = 0; hop <= MAX_HOP; hop++) {
cumulative += static_cast<uint32_t>(counts.perHop[hop]) * filteringDenominator;
if (cumulative >= effectiveMin) {
suggested = hop;
break;
}
}
if (suggested < MAX_HOP) {
const uint32_t atNext = static_cast<uint32_t>(counts.perHop[suggested + 1]) * filteringDenominator;
// politeLimit = effectiveMin + gap * politeNumer / POLITENESS_DENOM
// Multiply both sides by POLITENESS_DENOM to stay in integers.
const uint32_t gap = static_cast<uint32_t>(effectiveMax) - static_cast<uint32_t>(effectiveMin);
if ((cumulative + atNext) * POLITENESS_DENOM <=
static_cast<uint32_t>(effectiveMin) * POLITENESS_DENOM + gap * lastPoliteNumer) {
suggested++;
}
}
}
lastSuggestedHop = suggested;
// 3. Log scaled per-hop counts and recommendation.
{
uint16_t scaled[MAX_HOP + 1];
for (uint8_t h = 0; h <= MAX_HOP; h++) {
const uint32_t s = static_cast<uint32_t>(counts.perHop[h]) * filteringDenominator;
scaled[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
}
const uint32_t scaledTotal = static_cast<uint32_t>(counts.total) * filteringDenominator;
memcpy(lastScaledPerHop, scaled, sizeof(lastScaledPerHop));
LOG_INFO("[HOPSCALE] rollHour: entries=%u/128 samp=1/%u filt=1/%u counted=%u est=%u suggestedHop=%u polite=%u/4", count,
samplingDenominator, filteringDenominator, counts.total, static_cast<unsigned>(scaledTotal), suggested,
lastPoliteNumer);
const auto &ts = lastTrendStats;
LOG_INFO("[HOPSCALE] scaledSeenPerHour (h0=now): [%u %u %u %u %u %u %u %u %u %u %u %u %u]", ts.scaledPerHour[0],
ts.scaledPerHour[1], ts.scaledPerHour[2], ts.scaledPerHour[3], ts.scaledPerHour[4], ts.scaledPerHour[5],
ts.scaledPerHour[6], ts.scaledPerHour[7], ts.scaledPerHour[8], ts.scaledPerHour[9], ts.scaledPerHour[10],
ts.scaledPerHour[11], ts.scaledPerHour[12]);
LOG_INFO("[HOPSCALE] trend: new=%u returning=%u lapsed=%u olderThan4h=%u agingOut=%u", ts.newThisHour,
ts.returningThisHour, ts.lapsedSinceLastHour, ts.olderThan4h, ts.agingOut);
}
// 4. Scale-down check: if fewer than FILL_LOW_PCT% of capacity pass the filteringDenominator
// gate and are active, halve samplingDenominator to admit more nodes.
// Note: during a filteringDenominator hold period, lowering samplingDenominator does not
// immediately improve counts.total (new admissions don't pass the elevated
// filteringDenominator). On a genuinely quieting mesh this check can therefore fire on
// consecutive hours, cascading samplingDenominator toward DENOM_MIN. This is intentional:
// rapid re-admission allows quick recovery if the mesh returns. The hop recommendation
// stays conservative (MAX_HOP) throughout because filteringDenominator remains elevated;
// step 5 below re-synchronises the denominators once the hold expires.
if (counts.total * 100u < static_cast<uint32_t>(CAPACITY) * FILL_LOW_PCT) {
if (samplingDenominator > DENOM_MIN) {
samplingDenominator = static_cast<uint8_t>(samplingDenominator / 2u);
LOG_INFO("[HOPSCALE] Scale-down: sampling denom halved to %u (filter denom=%u)", samplingDenominator,
filteringDenominator);
}
}
// 5. Tick down the hold counter; once it reaches zero, halve filteringDenominator toward
// samplingDenominator once per rollHour() (= once per hour) rather than a single jump:
// avoids a sudden large change in the hop-walk count when samplingDenominator cascaded
// down significantly during the hold period. No new hold is placed on each step — the
// 13-roll hold already guaranteed that re-admitted nodes have full seenHoursAgo history;
// further pacing is provided naturally by the 1-step-per-hour rate. denominatorHistory
// is updated automatically by the shift at the top of rollHour(), so no backfill here.
if (filteringDenominator > samplingDenominator) {
if (filteringDenomHoldRollsRemaining > 0)
filteringDenomHoldRollsRemaining--;
if (filteringDenomHoldRollsRemaining == 0) {
const uint8_t stepped = static_cast<uint8_t>(filteringDenominator / 2u);
filteringDenominator = (stepped > samplingDenominator) ? stepped : samplingDenominator;
LOG_INFO("[HOPSCALE] Filter denom stepped to %u (samp=1/%u)", filteringDenominator, samplingDenominator);
}
}
// 6. Shift all seen bitmaps left by one slot (opens a fresh slot for the new hour).
for (uint8_t i = 0; i < count; i++) {
rollSeenBits(entries[i]);
}
if (histogramRollCount < 255)
histogramRollCount++;
saveToDisk();
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
void HopScalingModule::trimIfNeeded()
{
// Step 1: evict stale entries (not seen in any of the past 13 hours).
uint8_t newCount = 0;
for (uint8_t i = 0; i < count; i++) {
if (seenInLast13h(entries[i])) {
if (i != newCount) {
entries[newCount] = entries[i];
}
newCount++;
}
}
count = newCount;
// Step 2: if still too full, double the sampling denominator and remove non-matching entries.
if (getFillPercentage() >= FILL_HIGH_PCT && samplingDenominator < DENOM_MAX) {
samplingDenominator = static_cast<uint8_t>(
std::min<uint16_t>(static_cast<uint16_t>(samplingDenominator) * 2u, static_cast<uint16_t>(DENOM_MAX)));
filteringDenominator = std::max(filteringDenominator, samplingDenominator);
filteringDenomHoldRollsRemaining = FILTER_DENOM_HOLD_ROLLS;
// Raise any denominatorHistory slot that is below the new filteringDenominator.
// Slots already above it (recorded during a prior scale-up that hasn't fully stepped
// down yet) are left untouched: eviction at samplingDenominator retains exactly those
// entries, so the old higher gate remains accurate for those historical hours.
// Slots below the new value must be raised because the eviction removed entries that
// had been admitted at the looser old gate — the remaining entries represent a 1/N
// subsample where N is the new filteringDenominator, not the old smaller value.
for (uint8_t h = 0; h < 13; h++)
denominatorHistory[h] = std::max(denominatorHistory[h], filteringDenominator);
LOG_INFO("[HOPSCALE] Scale-up: samp denom doubled to %u (filt=%u)", samplingDenominator, filteringDenominator);
newCount = 0;
for (uint8_t i = 0; i < count; i++) {
if (passesFilter(entries[i].nodeHash, samplingDenominator)) {
if (i != newCount) {
entries[newCount] = entries[i];
}
newCount++;
}
}
count = newCount;
}
}
void HopScalingModule::logStatusReport(bool didHourlyUpdate) const
{
const bool histActive = (histogramRollCount > 0 && count > 0);
const auto &histCounts = lastPerHopCounts;
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] 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],
histCounts.perHop[7]);
LOG_INFO("[HOPSCALE] last scaled perHop: [%u %u %u %u %u %u %u %u]", lastScaledPerHop[0], lastScaledPerHop[1],
lastScaledPerHop[2], lastScaledPerHop[3], lastScaledPerHop[4], lastScaledPerHop[5], lastScaledPerHop[6],
lastScaledPerHop[7]);
}
int32_t HopScalingModule::runOnce()
{
const bool isFirstRun = !hasCompletedInitialRun;
bool didHourlyUpdate = false;
if (isFirstRun) {
hasCompletedInitialRun = true;
runsSinceLastHourlyUpdate = 0;
didHourlyUpdate = true;
} else {
runsSinceLastHourlyUpdate++;
if (runsSinceLastHourlyUpdate >= RUNS_PER_HOUR) {
runsSinceLastHourlyUpdate = 0;
didHourlyUpdate = true;
}
}
if (didHourlyUpdate && !isFirstRun) {
rollHour();
}
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;
}
lastRequiredHop = std::max(suggested, roleFloor);
}
logStatusReport(didHourlyUpdate);
return RUN_INTERVAL_MS;
}
#endif
+351
View File
@@ -0,0 +1,351 @@
#pragma once
#include "MeshTypes.h"
#include "concurrency/OSThread.h"
#include "configuration.h"
#include "mesh/Default.h"
#include "mesh/mesh-pb-constants.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
#if HAS_VARIABLE_HOPS
/**
* HopScalingModule: Sampled hop-distance histogram for mesh-aware hop limit recommendations.
*
* Memory layout: 512 bytes total (128 entries × 4 bytes/entry, no padding)
* - 16-bit XOR-fold hash of node ID
* - 3-bit hops away (07)
* - 13-bit hourly seen bitmap
* All three fields are packed into a single 32-bit Record; sizeof(Record) == 4.
*
* Sampling:
* - A node is added only when passesFilter(hashNodeId(nodeId), samplingDenominator),
* i.e. (hash16(nodeId) & (samplingDenominator 1)) == 0 (hash-space subsample, not raw ID)
* - samplingDenominator starts at 1 (sample all), doubles when the list exceeds FILL_HIGH_PCT
* - filteringDenominator tracks samplingDenominator upward immediately but does not drop back
* down until FILTER_DENOM_HOLD_MS (13 h) have elapsed since the last scale-up
*
* Hourly rollover (rollHour()):
* - Summarises per-hop node counts for entries matching filteringDenominator and seen in the
* last 13 hours
* - Scales each hop bucket by filteringDenominator and walks the buckets to recommend a hop
* limit, matching the same algorithm used in HopScalingModule
* - Shifts the 13-bit seen bitmap left by one slot to open a fresh slot for the new hour;
* nodes not seen in 13 consecutive hours have all seen bits cleared (stale)
* - Checks for scale-down: if fewer than FILL_LOW_PCT of capacity pass filteringDenominator,
* samplingDenominator is halved (filteringDenominator is held until the 13-h lock expires)
*
* Thread-safety: all access is single-threaded via the main loop cooperative scheduler.
*/
struct Record {
uint32_t nodeHash : 16;
uint32_t hops_away : 3;
uint32_t seenHoursAgo : 13;
};
static_assert(sizeof(Record) == 4);
class HopScalingModule : private concurrency::OSThread
{
public:
// -----------------------------------------------------------------------
// Capacity and memory layout
// -----------------------------------------------------------------------
static constexpr size_t CAPACITY = 128;
static constexpr size_t ENTRY_BYTES = sizeof(Record);
static constexpr size_t TOTAL_BYTES = CAPACITY * ENTRY_BYTES;
// Denominator limits (must be powers of 2)
static constexpr uint8_t DENOM_MIN = 1;
static constexpr uint8_t DENOM_MAX = 128;
static constexpr uint8_t MAX_HOP = 7;
// Fill-level thresholds (percent of CAPACITY)
static constexpr uint8_t FILL_HIGH_PCT = 80;
static constexpr uint8_t FILL_LOW_PCT = 20;
// How long filteringDenominator is held at an elevated level before it may drop.
//
// This value is deliberately equal to the seenHoursAgo window (13 hours / 13 bits).
// Invariant: every entry that existed when a scale-up fired had seenHoursAgo != 0 at
// that moment (trimIfNeeded() evicts stale entries before doubling the denominator),
// so it remains seenInLast13h for at most 13 more rollHour() calls — exactly the
// hold duration. That means entries from the scale-up event keep counts.total above
// the scale-down threshold for the entire hold period under normal (active) mesh
// conditions. On a genuinely quieting mesh the scale-down CAN fire before the hold
// expires — each firing halves samplingDenominator but filteringDenominator stays
// elevated, so the hop recommendation correctly stays conservative (MAX_HOP) while
// the cascade runs. The cascade is bounded at DENOM_MIN (7 halvings from DENOM_MAX);
// when the hold finally expires, step 5 of rollHour() halves filteringDenominator
// once per hour (rather than jumping directly to samplingDenominator) until the two
// converge, giving the hop-walk a gradual, 1-step-per-hour descent.
static constexpr uint32_t FILTER_DENOM_HOLD_MS = 13UL * 60UL * 60UL * 1000UL; // 13 h (documentation only)
// Number of rollHour() calls the hold spans — equals the seenHoursAgo window width.
// filteringDenomHoldRollsRemaining is initialised to this value on scale-up and
// decremented once per rollHour(); step-down begins when it reaches zero.
static constexpr uint8_t FILTER_DENOM_HOLD_ROLLS = 13u;
// Hop-walk: target cumulative affected-node count when choosing a hop limit
static constexpr uint16_t TARGET_AFFECTED_NODES = default_hop_scaling_min_target_nodes;
// Clamp bounds enforced on min_target_nodes / max_target_nodes
static constexpr uint16_t MIN_TARGET_NODES_FLOOR = default_hop_scaling_min_target_nodes_floor;
static constexpr uint16_t MAX_TARGET_NODES_CEILING = default_hop_scaling_max_target_nodes_ceiling;
static constexpr uint16_t MAX_TARGET_NODES = default_hop_scaling_max_target_nodes;
// Politeness factors for the one-hop extension check in the hop walk.
// Stored as integer numerators over POLITENESS_DENOM (4):
// politeLimit = min + gap * politeNumer / POLITENESS_DENOM
// STRICT → min + 25% of gap; DEFAULT → midpoint; GENEROUS → max
static constexpr uint8_t POLITENESS_DENOM = 4u;
static constexpr uint8_t POLITENESS_GENEROUS = 4u; // 4/4 = 1.00
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;
// -----------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------
/// Per-hop node counts produced at each hourly rollover.
struct PerHopCounts {
uint16_t perHop[MAX_HOP + 1] = {};
uint16_t total = 0;
};
/// Mesh activity trend stats produced at each hourly rollover.
/// All counts are scaled by filteringDenominator (i.e. estimated full-mesh population).
///
/// Bitmap interpretation (before the hourly shift): bit 0 = just-completed hour, bit 12 = 12 h ago.
struct MeshTrendStats {
/// Estimated node count per hour slot (h=0 is the just-completed hour, h=12 is 12 h ago).
uint16_t scaledPerHour[13] = {};
/// Nodes heard only this hour with no prior bitmap history — indicates new arrivals.
uint16_t newThisHour = 0;
/// Nodes heard this hour that also appeared in at least one older hour — stable regulars.
uint16_t returningThisHour = 0;
/// Nodes heard last hour but silent this hour — potential departures.
uint16_t lapsedSinceLastHour = 0;
/// Nodes absent from the last 4 hours but still present in some older hour (513 h) — quieting down.
uint16_t olderThan4h = 0;
/// Nodes whose only remaining history is the 13th hour (bit 12 only) — about to age out entirely.
uint16_t agingOut = 0;
};
// -----------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------
HopScalingModule();
~HopScalingModule() = default;
/// Reset all entries and state.
void clear();
// -----------------------------------------------------------------------
// Core API
// -----------------------------------------------------------------------
/// Record a received packet.
/// Adds or updates an entry when passesFilter(hashNodeId(nodeId), samplingDenominator),
/// i.e. when the 16-bit XOR-fold hash of the node ID falls in the 1/samplingDenominator
/// subsample of the hash space. This is NOT a raw nodeId modulo check.
/// Marks the current hour as seen and updates the stored hop count to the last observed value.
/// Triggers a trim pass if the list exceeds FILL_HIGH_PCT after the insertion.
void samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount);
// -----------------------------------------------------------------------
// Accessors
// -----------------------------------------------------------------------
uint8_t getLastRequiredHop() const { return lastRequiredHop; }
uint8_t getEntryCount() const { return count; }
uint8_t getFillPercentage() const { return static_cast<uint8_t>((static_cast<uint16_t>(count) * 100u) / CAPACITY); }
uint8_t getSamplingDenominator() const { return samplingDenominator; }
uint8_t getFilteringDenominator() const { return filteringDenominator; }
float getPoliteness() const { return lastPoliteNumer / static_cast<float>(POLITENESS_DENOM); }
const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; }
uint8_t getLastSuggestedHop() const { return lastSuggestedHop; }
const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; }
// Compatibility accessors used by tests
uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); }
uint8_t getCompactHistogramDenominator() const { return getSamplingDenominator(); }
uint8_t getCompactHistogramFilterDenominator() const { return getFilteringDenominator(); }
uint8_t getCompactHistogramSuggestedHop() const { return getLastSuggestedHop(); }
size_t getCompactHistogramAllSampleCount() const { return getEntryCount(); }
/// Force both sampling and filtering denominators to a specific value.
/// Intended for unit tests that need a deterministic starting denominator.
void setSamplingDenominator(uint8_t d)
{
samplingDenominator = (d < DENOM_MIN) ? DENOM_MIN : (d > DENOM_MAX ? DENOM_MAX : d);
filteringDenominator = samplingDenominator;
filteringDenomHoldRollsRemaining = 0;
}
#ifdef PIO_UNIT_TESTING
// 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.
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.
uint16_t hashNodeIdPublic(uint32_t nodeId) const { return hashNodeId(nodeId); }
#endif
protected:
int32_t runOnce() override;
private:
#ifdef PIO_UNIT_TESTING
friend class HopScalingTestShim;
#endif
/// Perform hourly rollover.
/// 1. Tallies per-hop counts for entries matching filteringDenominator and seen in 13 h.
/// 2. Walks the scaled hop buckets and returns the recommended hop limit.
/// 3. Logs scaled per-hop counts and recommendation.
/// 4. Checks for scale-down (< FILL_LOW_PCT of capacity pass filteringDenominator).
/// 5. Decrements filteringDenomHoldRollsRemaining (if > 0); once it reaches zero, halves
/// filteringDenominator once toward samplingDenominator per rollHour() call.
/// 6. Shifts all seen bitmaps left by one hour slot.
void rollHour();
// -----------------------------------------------------------------------
// Persistence
// -----------------------------------------------------------------------
/// Persist the histogram state (entries, denominators, hold-timer) to flash.
/// No-op on platforms without a filesystem. Performs a full delete-and-rewrite of
/// the state file on each call; avoid calling more frequently than once per rollHour().
void saveToDisk() const;
/// Restore histogram state from flash. Safe to call even when no file exists.
/// Call once after construction, before the first rollHour(), to warm-start the
/// histogram across reboots without waiting 13 hours for data to re-accumulate.
/// The restored entries are available immediately for sampling, but the first
/// rollHour() (triggered by the second runOnce() tick) is needed before a warm-start
/// recommendation replaces the HOP_MAX boot default.
void loadFromDisk();
/// Remove stale entries (seen-bits all zero) and, if the list is still crowded,
/// double samplingDenominator and filteringDenominator and remove non-matching entries.
void trimIfNeeded();
void logStatusReport(bool didHourlyUpdate) const;
// -----------------------------------------------------------------------
// Histogram storage
// -----------------------------------------------------------------------
Record entries[CAPACITY] = {};
uint8_t count = 0;
// -----------------------------------------------------------------------
// Denominator state
//
// Two separate denominators control two distinct gates:
//
// samplingDenominator — admission gate. A node is added/updated only when
// passesFilter(hash, samplingDenominator). Lower value = more permissive =
// more nodes enter = represents recent mesh state.
//
// filteringDenominator — counting gate. The hop-walk tally in rollHour() only
// counts entries that pass passesFilter(hash, filteringDenominator). It moves
// up with samplingDenominator immediately (scale-up) but is held at the
// elevated value for FILTER_DENOM_HOLD_MS (13 h) after any scale-up before it
// may drop back down (scale-down).
//
// Why the estimate is invariant: passesFilter uses a hash-based uniform subsample.
// For any two powers-of-two denominators D ≤ F, the fraction of D-sampled entries
// that also pass F is exactly D/F. Therefore:
// raw_count × F = (total × D/F) × F = total × D
// The population estimate is the same whether we count with D or with F.
// The hold period is not about accuracy — it is about stability: it prevents the
// hop recommendation from reacting to recently-admitted nodes that have not yet
// accumulated enough seenHoursAgo history to be statistically reliable.
//
// denominatorHistory[h] — the filteringDenominator used to both gate and scale
// hourlyRaw[h]. Invariant: denominatorHistory[h] always equals the
// filteringDenominator that was active when seenHoursAgo bit h was set.
// rollHour() advances the array at the very start (before the tally loop), then
// gates hourlyRaw[h] per-slot by denominatorHistory[h] — each slot's raw count
// and multiplier are therefore always consistent, even when filteringDenominator
// changes between rolls (e.g. hold expiry). On scale-up (trimIfNeeded()), the
// entire array is backfilled uniformly with the new filteringDenominator to
// preserve the invariant retroactively for all 13 slots. Initialised to
// DENOM_MIN (1); scaledPerHour slots that draw from a 1 entry are unscaled —
// correct for a fresh instance with no prior history.
// -----------------------------------------------------------------------
uint8_t samplingDenominator = DENOM_MIN;
uint8_t filteringDenominator = DENOM_MIN;
uint8_t filteringDenomHoldRollsRemaining = 0; // counts down from FILTER_DENOM_HOLD_ROLLS to 0; step-down fires at 0
uint8_t denominatorHistory[13] = {};
uint16_t hashSeed = 0;
// -----------------------------------------------------------------------
// Cached hourly results
// -----------------------------------------------------------------------
PerHopCounts lastPerHopCounts = {};
uint16_t lastScaledPerHop[MAX_HOP + 1] = {};
uint8_t lastSuggestedHop = MAX_HOP;
uint8_t lastPoliteNumer = POLITENESS_DEFAULT;
MeshTrendStats lastTrendStats = {};
// -----------------------------------------------------------------------
// Hop recommendation state
// -----------------------------------------------------------------------
uint8_t lastRequiredHop = HOP_MAX;
uint8_t histogramRollCount = 0;
// -----------------------------------------------------------------------
// Scheduler state
// -----------------------------------------------------------------------
bool hasCompletedInitialRun = false;
uint8_t runsSinceLastHourlyUpdate = 0;
// -----------------------------------------------------------------------
// Inline record helpers
// -----------------------------------------------------------------------
// Record field semantics:
// nodeHash → XOR-fold of full 32-bit node ID to 16 bits
// hops_away → hop distance (07)
// seenHoursAgo → 13-bit per-hour seen bitmap
// bit 0 = seen in the current / most-recent hour
// bit 12 = seen 12 hours ago
// Shifts left on each rollHour(); 0 means not seen in 13 h.
/// XOR-fold + golden-ratio hash of a 32-bit node ID to 16 bits, mixed with the session seed.
/// Multiplying by floor(2^32 / φ) gives uniform avalanche; XORing the seed ensures different
/// devices (or the same device after a clear()) sample a different subset of node IDs.
/// For seed=0 the function is deterministic, which is used in PIO_UNIT_TESTING builds.
uint16_t hashNodeId(uint32_t nodeId) const { return static_cast<uint16_t>((nodeId * 2654435761u) >> 16) ^ hashSeed; }
static bool seenInLast13h(const Record &r) { return r.seenHoursAgo != 0u; }
static void markCurrentHour(Record &r) { r.seenHoursAgo |= 1u; }
static void rollSeenBits(Record &r) { r.seenHoursAgo = (r.seenHoursAgo << 1u) & 0x1FFFu; }
static bool passesFilter(uint16_t nodeHash, uint8_t denom) { return (nodeHash & static_cast<uint16_t>(denom - 1u)) == 0u; }
public:
// Clock — public so tests can share the same timebase via HopScalingModule::s_testNowMs
#ifdef PIO_UNIT_TESTING
static uint32_t nowMs() { return s_testNowMs; }
#else
static uint32_t nowMs() { return millis(); }
#endif
};
extern HopScalingModule *hopScalingModule;
#endif
+7
View File
@@ -41,6 +41,9 @@
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#include "modules/TextMessageModule.h"
#if !MESHTASTIC_EXCLUDE_TRACEROUTE
#include "modules/TraceRouteModule.h"
@@ -131,6 +134,10 @@ void setupModules()
}
#endif
#if HAS_VARIABLE_HOPS
hopScalingModule = new HopScalingModule();
#endif
#if !MESHTASTIC_EXCLUDE_ADMIN
adminModule = new AdminModule();
#endif
+15 -8
View File
@@ -200,7 +200,7 @@ class MockNodeDB : public NodeDB
node.num = num;
node.has_hops_away = hasHops;
node.hops_away = hopsAway;
node.via_mqtt = viaMqtt;
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_VIA_MQTT_MASK, viaMqtt);
node.last_heard = getTime() - ageSecs;
testNodes.push_back(node);
meshNodes = &testNodes;
@@ -358,6 +358,7 @@ PlatformIO defines `PIO_UNIT_TESTING` during `pio test` builds. Several producti
- [ ] Set `nodeDB = mockNodeDB`
- [ ] Delete persisted state files (`FSCom.remove(...)`)
- [ ] Reset file-scope mutable globals
- [ ] Reset mock clock to a safe base value (e.g. `mockTime = ONE_HOUR_MS`) — prevents unsigned subtraction underflow in time-dependent logic
- [ ] Disable randomness/jitter flags
- [ ] In `tearDown`: null the global singleton pointer, restore flags
@@ -375,15 +376,21 @@ A well-structured test suite follows this pattern:
| Suite | Module Under Test |
| ---------------------------- | ----------------------------- |
| `test_admin_radio` | Admin + LoRa region config |
| `test_atak` | ATAK integration |
| `test_crypto` | CryptoEngine |
| `test_mqtt` | MQTT integration |
| `test_radio` | Radio interface |
| `test_default` | Default configuration helpers |
| `test_hop_scaling` | Hop scaling algorithm |
| `test_http_content_handler` | HTTP handling |
| `test_mac_from_string` | MAC address parsing |
| `test_mesh_module` | Module framework |
| `test_meshpacket_serializer` | Packet serialization |
| `test_transmit_history` | Retransmission tracking |
| `test_atak` | ATAK integration |
| `test_default` | Default configuration helpers |
| `test_http_content_handler` | HTTP handling |
| `test_mqtt` | MQTT integration |
| `test_packet_history` | Packet history tracking |
| `test_position_precision` | Position precision helpers |
| `test_radio` | Radio interface |
| `test_serial` | Serial communication |
| `test_hop_scaling` | Hop scaling algorithm |
| `test_traffic_management` | Traffic management |
| `test_transmit_history` | Retransmission tracking |
| `test_type_conversions` | NodeDB v25 type conversions |
| `test_utf8` | UTF-8 utilities |
+738
View File
@@ -0,0 +1,738 @@
#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 <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; }
/// 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);
}
// ---------------------------------------------------------------------------
// 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);
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();
}
}
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 02.
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 07.
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 — 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
// ---------------------------------------------------------------------------
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=== 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=== 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
+3
View File
@@ -13,6 +13,9 @@
#ifndef HAS_TRAFFIC_MANAGEMENT
#define HAS_TRAFFIC_MANAGEMENT 1
#endif
#ifndef HAS_VARIABLE_HOPS
#define HAS_VARIABLE_HOPS 1
#endif
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048
#endif