mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 09:32:26 +00:00
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.
327 lines
9.8 KiB
Go
327 lines
9.8 KiB
Go
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
|
||
}
|