Files
meshcore-analyzer/cmd/server/relay_airtime_share.go
T
n30nex b9fb3d244c feat(analytics): split adverts using recorded route evidence (#2088)
Red commits: `dc7dcbe`, `76e3217` ([assertion
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37059977385)).

Fixes #2041; supplies #2085's backend contract.

Records at most two route bits per retained advert. Node flood counts
now use the same evidence, including mixed and transport-flood adverts.
Analytics failures log/count errors while core observation, path and
liveness writes continue. The unrelated recent-advert limit clamp is
removed.

Direct adverts (empty path) describes the observed remaining path across
valid hash widths. Firmware can forward an advert down to an empty path;
this cannot establish original send mode or RF distance. The `zero_hop`
API spelling remains compatible.

Successful post-upgrade evidence writes survive observation replacement
and restart. Older overwritten frames and failed writes can leave gaps.
The UI explains automatic background backfill.

## Measured validation

Synthetic Windows workloads; timings are not production guarantees:

- 300K retained adverts, one-hour window: median 64.35→39.84 ms; 13.98
MB→145 KB allocated. Seven-day allocations unchanged; five paired
samples.
- 1M transmissions/11M observations: backfill 5m43s. Simple 10 Hz
writer: all 3,423 observations persisted; p99 28 ms, maximum 323 ms.
- Simulated pre-backfill cursor: retained evidence ready 4m21s; full
catch-up 6m41s, 122 cache invalidations. Normal completed-migration
startup bulk-loads current evidence.

Portable opt-in scale tests and window benchmarks are included. All 183
frontend suites, full server tests, targeted races, lint and real-API
desktop/mobile checks passed. Full [Linux
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37063210229)
passed at `36def9c`; three independent reviews are clear. Local Windows
testing hit the unchanged symlink-privilege test. No configuration
changes.

Preflight overrides: external script/OpenClaw profile unavailable;
repository checks and Chromium used.
2026-10-03 12:16:55 +02:00

327 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"log"
"sort"
"time"
"github.com/meshcore-analyzer/lora"
"github.com/meshcore-analyzer/packetpath"
)
// relay_airtime_share.go — issues #1359 + #1768
//
// Implements the "Relay Airtime Share" analytics metric:
// score(packet) = TimeOnAir(payload_bytes, preset)
// × COUNT(DISTINCT repeater_pubkey across observations)
//
// #1768 swapped the original byte-only proxy (`bytes × relays`) for
// closed-form LoRa Time-on-Air. The byte proxy underweighted small
// frames by ~3-4× because the additive preamble + fixed-symbol
// intercept does NOT cancel under per-type normalization. ToA fixes
// the headline divergence the dumbbell chart is supposed to show.
//
// The PHY preset is config-driven (analytics.loraPreset in
// config.example.json); defaults match the actual deployment
// preset 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, with the
// SF-dependent preamble pulled from internal/lora.PreambleForSF.
//
// Aggregated by payload_type and ADVERT routing kind. Originator TX is excluded — a
// never-relayed direct message scores 0, which is the correct framing for a
// "relay amplification" metric. In-memory only; no SQL, no new index.
// defaultLoRaPreset is the canonical fallback when config is absent.
// Matches the reporter's `get radio` output `869.6179809, 62.5, 8, 5`.
func defaultLoRaPreset() lora.Preset {
return lora.Preset{
FreqHz: 869.6e6,
BWkHz: 62.5,
SF: 8,
CR: 5,
Preamble: lora.PreambleForSF(8),
}
}
// resolveLoRaPreset returns the effective preset, falling back to
// defaults for any unset / zero / out-of-range field.
//
// Out-of-range SF / CR are NOT silently clamped on a per-field basis
// (the prior behaviour produced a confusing hybrid preset, partially
// operator-supplied and partially defaulted). Instead we keep the
// default for the offending field AND log a single WARN at resolve time
// naming the field plus the actual vs. effective value. There is no
// startup-time analytics-config validation gate today, so refusal-to-
// start is not an option — the WARN is the gate. Zero / unset fields
// fall back silently as before (the operator opted out of overriding
// that param).
func (s *PacketStore) resolveLoRaPreset() lora.Preset {
p := defaultLoRaPreset()
if s == nil || s.config == nil || s.config.Analytics == nil || s.config.Analytics.LoRaPreset == nil {
return p
}
cfg := s.config.Analytics.LoRaPreset
if cfg.FreqHz > 0 {
p.FreqHz = cfg.FreqHz
}
if cfg.BWkHz > 0 {
p.BWkHz = cfg.BWkHz
}
if cfg.SF != 0 {
if cfg.SF >= 6 && cfg.SF <= 12 {
p.SF = cfg.SF
p.Preamble = lora.PreambleForSF(cfg.SF)
} else {
log.Printf("[analytics.loraPreset] WARN: sf=%d out of range [6,12], using default sf=%d", cfg.SF, p.SF)
}
}
if cfg.CR != 0 {
if cfg.CR >= 5 && cfg.CR <= 8 {
p.CR = cfg.CR
} else {
log.Printf("[analytics.loraPreset] WARN: cr=%d out of range [5,8], using default cr=%d", cfg.CR, p.CR)
}
}
return p
}
// presetResponse shapes the preset for the API response and the
// analytics caption (issue #1768 — operators can't interpret an
// "Airtime %" headline without knowing what PHY assumptions it bakes
// in). All four free params plus the derived preamble are surfaced.
type presetResponse struct {
FreqHz float64 `json:"freq_hz"`
BWkHz float64 `json:"bw_khz"`
SF int `json:"sf"`
CR int `json:"cr"`
Preamble int `json:"preamble"`
}
func presetJSON(p lora.Preset) presetResponse {
return presetResponse{
FreqHz: p.FreqHz,
BWkHz: p.BWkHz,
SF: p.SF,
CR: p.CR,
Preamble: p.Preamble,
}
}
// distinctRelayCount returns the number of distinct repeater pubkeys that
// forwarded `tx`, unioned across ALL observations of that transmission_id.
//
// Source: the resolved-pubkey reverse index — populated by
// indexResolvedPathHops / addToResolvedPubkeyIndex from every observation's
// resolved_path. Each entry is one distinct pubkey hash for THIS tx (the
// indexer dedups (hash, txID) pairs before appending).
//
// Caller MUST hold s.mu at least RLock.
func (s *PacketStore) distinctRelayCount(tx *StoreTx) int {
if tx == nil || !s.useResolvedPathIndex {
return 0
}
return len(s.resolvedPubkeyReverse[tx.ID])
}
// computeRelayAirtimeShare aggregates by payload_type, splitting only ADVERT
// by recorded routing. Other payload-mix analytics retain their usual grouping.
//
// Returns:
//
// {
// "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc,
// "total_count": int,
// "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets),
// "window": window label,
// "cached": false (overwritten by cached wrapper),
// }
func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
ptNames := payloadTypeNames
preset := s.resolveLoRaPreset()
type bucket struct {
count int
score int64 // sum of ToA(payload) × relays, in nanoseconds
}
// The additional key has four possible values, all for ADVERT.
type bucketKey struct {
payloadType int
advertKind string
}
buckets := make(map[bucketKey]*bucket)
// Filter before allocating the hash map. Reuse the packet slice when every
// record is eligible; otherwise copy only the selected pointers. This keeps
// short-window scratch space small without a full-window allocation penalty.
selected := s.packets
filtered := false
for i, tx := range s.packets {
if tx == nil || tx.PayloadType == nil || !window.Includes(tx.FirstSeen) {
if !filtered {
selected = append([]*StoreTx(nil), s.packets[:i]...)
filtered = true
}
continue
}
if filtered {
selected = append(selected, tx)
}
}
// Low bits union known evidence; the high bit deduplicates scores below.
seenHash := make(map[string]uint8, len(selected))
for _, tx := range selected {
if *tx.PayloadType == PayloadADVERT && tx.Hash != "" {
seenHash[tx.Hash] |= tx.AdvertRouteEvidence
}
}
if filtered {
// An eligible hash keeps its known older evidence, but neither hashes
// found only outside the window nor their scores enter the result.
for _, tx := range s.packets {
if tx != nil && tx.PayloadType != nil && *tx.PayloadType == PayloadADVERT && tx.Hash != "" {
if mask, eligible := seenHash[tx.Hash]; eligible {
seenHash[tx.Hash] = mask | tx.AdvertRouteEvidence
}
}
}
}
totalCount := 0
var totalScore int64
for _, tx := range selected {
if tx.Hash != "" {
if seenHash[tx.Hash]&128 != 0 {
continue
}
seenHash[tx.Hash] |= 128
}
pt := *tx.PayloadType
key := bucketKey{payloadType: pt}
if pt == PayloadADVERT {
mask := tx.AdvertRouteEvidence
if tx.Hash != "" {
mask = seenHash[tx.Hash] & 3
}
key.advertKind = packetpath.AdvertKind(mask)
}
b := buckets[key]
if b == nil {
b = &bucket{}
buckets[key] = b
}
b.count++
totalCount++
// payload bytes from RawHex (2 hex chars per byte). Score is
// LoRa Time-on-Air (nanoseconds) × distinct relays — see
// resolveLoRaPreset for the assumed PHY block (issue #1768).
payloadBytes := len(tx.RawHex) / 2
relays := s.distinctRelayCount(tx)
toa := lora.TimeOnAir(payloadBytes, preset)
score := int64(toa) * int64(relays)
b.score += score
totalScore += score
}
rows := make([]map[string]interface{}, 0, len(buckets))
for key, b := range buckets {
pt := key.payloadType
name := ptNames[pt]
if name == "" {
name = "UNK"
}
var countPct, airtimePct float64
if totalCount > 0 {
countPct = float64(b.count) / float64(totalCount) * 100.0
}
if totalScore > 0 {
airtimePct = float64(b.score) / float64(totalScore) * 100.0
}
row := map[string]interface{}{
"payload_type": name,
"type": pt,
"count": b.count,
"count_pct": countPct,
"score": b.score,
"airtime_pct": airtimePct,
}
if key.advertKind != "" {
row["advert_kind"] = key.advertKind
}
rows = append(rows, row)
}
// Sort descending by airtime_pct; tiebreak count desc, then name/kind asc
// for deterministic ordering.
sort.SliceStable(rows, func(i, j int) bool {
ai, _ := rows[i]["airtime_pct"].(float64)
aj, _ := rows[j]["airtime_pct"].(float64)
if ai != aj {
return ai > aj
}
ci, _ := rows[i]["count"].(int)
cj, _ := rows[j]["count"].(int)
if ci != cj {
return ci > cj
}
ni, _ := rows[i]["payload_type"].(string)
nj, _ := rows[j]["payload_type"].(string)
if ni != nj {
return ni < nj
}
ki, _ := rows[i]["advert_kind"].(string)
kj, _ := rows[j]["advert_kind"].(string)
return ki < kj
})
label := ""
if !window.IsZero() {
label = window.Label
}
return map[string]interface{}{
"rows": rows,
"total_count": totalCount,
"total_score": totalScore,
"preset": presetJSON(preset),
"window": label,
"cached": false,
}
}
// GetRelayAirtimeShareWithWindow is the cached wrapper around
// computeRelayAirtimeShare. Reuses the existing rfCache + rfCacheTTL pool
// (shared with RF / topology / distance analytics — no new cache layer per
// #1359 spec).
func (s *PacketStore) GetRelayAirtimeShareWithWindow(window TimeWindow) map[string]interface{} {
cacheKey := "relay-airtime-share|"
if !window.IsZero() {
cacheKey += window.CacheKey()
}
s.cacheMu.Lock()
if cached, ok := s.rfCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
s.cacheHits++
s.cacheMu.Unlock()
// Shallow copy with cached=true so the JSON client can tell.
m := cached.data
out := make(map[string]interface{}, len(m)+1)
for k, v := range m {
out[k] = v
}
out["cached"] = true
return out
}
s.cacheMisses++
revision := s.advertEvidenceRevision
s.cacheMu.Unlock()
result := s.computeRelayAirtimeShare(window)
s.cacheMu.Lock()
if revision == s.advertEvidenceRevision {
s.rfCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
}
s.cacheMu.Unlock()
return result
}