forked from meshtastic/firmware
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHopScalingModule.h
More file actions
351 lines (306 loc) · 18.1 KB
/
Copy pathHopScalingModule.h
File metadata and controls
351 lines (306 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
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 (0-7)
* - 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 (5-13 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 (0-7)
// 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