feat(repeater): usefulness score — traffic axis (#672) (#1079)

## Summary

Implements the **Traffic axis** of the repeater usefulness score (#672).
Does NOT close #672 — Bridge, Coverage, and Redundancy axes are deferred
to follow-up PRs.

Adds `usefulness_score` (0..1) to repeater/room node API responses
representing what fraction of non-advert traffic passes through this
repeater as a relay hop.

## Why traffic-axis-first

The issue proposes a 4-axis composite (Bridge, Coverage, Traffic,
Redundancy). Bridge/Coverage/Redundancy require betweenness centrality
and neighbor graph infrastructure (#773 Neighbor Graph V2). Traffic axis
can ship independently using existing path-hop data.

## Remaining work for #672

- Bridge axis (betweenness centrality — depends on #773)
- Coverage axis (observer reach comparison)
- Redundancy axis (node-removal simulation — depends on #687)
- Composite score combining all 4 axes

Partial fix for #672.

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbotandmeshcore-bot authored and GitHub committed 2026-05-05 01:34:08 -07:00
1 parent d05e468598
commit f33801ecb4
4 files changed
+181

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
package main
import "strings"
// GetRepeaterUsefulnessScore returns a 0..1 score representing what
// fraction of non-advert traffic in the store passes through this
// repeater as a relay hop. Issue #672 (Traffic axis only — bridge,
// coverage, and redundancy axes are deferred to follow-up work).
//
// Numerator: count of non-advert StoreTx entries indexed under
// pubkey in byPathHop.
// Denominator: total non-advert StoreTx entries in the store
// (sum of byPayloadType for all keys != payloadTypeAdvert).
//
// Returns 0 when there is no non-advert traffic, the pubkey is empty,
// or the repeater never appears as a relay hop. Scores are clamped to
// [0,1] for defensive bounds.
//
// Cost: O(N) over byPayloadType keys (typically <20) plus the per-hop
// slice for pubkey. Cheap relative to the per-request enrichment loop
// in handleNodes; if it ever shows up in profiles, denominator can be
// memoized off store invalidation.
func (s *PacketStore) GetRepeaterUsefulnessScore(pubkey string) float64 {
if pubkey == "" {
return 0
}
key := strings.ToLower(pubkey)
s.mu.RLock()
defer s.mu.RUnlock()
// Denominator: total non-advert packets.
totalNonAdvert := 0
for pt, list := range s.byPayloadType {
if pt == payloadTypeAdvert {
continue
}
totalNonAdvert += len(list)
}
if totalNonAdvert == 0 {
return 0
}
// Numerator: this repeater's non-advert hop appearances.
relayed := 0
for _, tx := range s.byPathHop[key] {
if tx == nil {
continue
}
if tx.PayloadType != nil && *tx.PayloadType == payloadTypeAdvert {
continue
}
relayed++
}
score := float64(relayed) / float64(totalNonAdvert)
if score < 0 {
return 0
}
if score > 1 {
return 1
}
return score
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"testing"
)
// TestRepeaterUsefulness_BasicShare verifies that usefulness_score is
// relay_count_24h / total_non_advert_traffic_24h. With 1 of 4 relayed
// packets going through the repeater, score should be 0.25.
//
// Issue #672. We are intentionally implementing the *traffic share*
// dimension of the composite score from the issue body — bridge,
// coverage, redundancy are deferred to follow-up work. This is the
// "Traffic" axis of the table in #672.
func TestRepeaterUsefulness_BasicShare(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "aabbccdd11223344"
store := NewPacketStore(db, nil)
// 4 non-advert packets total in last hour. The repeater appears in
// the resolved path of exactly one of them.
pt := 1
for i := 0; i < 4; i++ {
tx := &StoreTx{RawHex: "0100", PayloadType: &pt, FirstSeen: recentTS(0)}
// Only first packet has our repeater in its path.
if i == 0 {
store.mu.Lock()
tx.ID = len(store.packets) + 1
tx.Hash = "uf-hit"
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx)
store.mu.Unlock()
} else {
addTestPacket(store, tx)
}
}
score := store.GetRepeaterUsefulnessScore(pubkey)
// 1 relay / 4 total = 0.25
if score < 0.24 || score > 0.26 {
t.Errorf("expected usefulness ~0.25, got %f", score)
}
}
// TestRepeaterUsefulness_NoTraffic verifies score is 0 when there is
// no non-advert traffic to share.
func TestRepeaterUsefulness_NoTraffic(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
store := NewPacketStore(db, nil)
score := store.GetRepeaterUsefulnessScore("deadbeefcafebabe")
if score != 0 {
t.Errorf("expected 0 for empty store, got %f", score)
}
}
// TestRepeaterUsefulness_AdvertsExcluded verifies that ADVERT packets
// (payload_type=4) are excluded from both numerator and denominator —
// adverts don't count as forwarded traffic.
func TestRepeaterUsefulness_AdvertsExcluded(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
pubkey := "11aa22bb33cc44dd"
store := NewPacketStore(db, nil)
// 2 non-advert packets, both with our repeater in path → score = 1.0
pt := 1
for i := 0; i < 2; i++ {
tx := &StoreTx{RawHex: "0100", PayloadType: &pt, FirstSeen: recentTS(0)}
store.mu.Lock()
tx.ID = len(store.packets) + 1
tx.Hash = "uf-non-advert"
if i == 1 {
tx.Hash = "uf-non-advert-2"
}
store.packets = append(store.packets, tx)
store.byHash[tx.Hash] = tx
store.byTxID[tx.ID] = tx
store.byPayloadType[pt] = append(store.byPayloadType[pt], tx)
store.byPathHop[pubkey] = append(store.byPathHop[pubkey], tx)
store.mu.Unlock()
}
// Add 100 adverts — these must be ignored.
advertPT := payloadTypeAdvert
for i := 0; i < 100; i++ {
tx := &StoreTx{RawHex: "0400", PayloadType: &advertPT, FirstSeen: recentTS(0)}
addTestPacket(store, tx)
}
score := store.GetRepeaterUsefulnessScore(pubkey)
if score < 0.99 || score > 1.01 {
t.Errorf("expected usefulness ~1.0 (adverts excluded), got %f", score)
}
}
+2
View File
@@ -1110,6 +1110,7 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
node["relay_active"] = info.RelayActive
node["relay_count_1h"] = info.RelayCount1h
node["relay_count_24h"] = info.RelayCount24h
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pk)
}
}
}
@@ -1217,6 +1218,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
node["relay_window_hours"] = info.WindowHours
node["relay_count_1h"] = info.RelayCount1h
node["relay_count_24h"] = info.RelayCount24h
node["usefulness_score"] = s.store.GetRepeaterUsefulnessScore(pubkey)
}
}
+15
View File
@@ -532,6 +532,21 @@
<tr><td>Status</td><td><span title="${si.statusTooltip}">${statusLabel}</span> <span style="font-size:11px;color:var(--text-muted);margin-left:4px">${statusExplanation}</span></td></tr>
<tr><td>Last Heard</td><td>${renderNodeTimestampHtml(lastHeard || n.last_seen)}</td></tr>
${(n.role === 'repeater' || n.role === 'room') ? `<tr><td title="Last time this repeater appeared as a relay hop in a non-advert packet observed by the network. Distinct from 'Last Heard' (which counts the repeater's own adverts). See issue #662.">Last Relayed</td><td>${n.last_relayed ? renderNodeTimestampHtml(n.last_relayed) + ' ' + (n.relay_active ? '<span style="color:var(--status-green);font-size:11px">🟢 actively relaying</span>' : '<span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>') : '<span style="color:var(--text-muted)">never observed as relay hop</span> <span style="color:var(--status-yellow);font-size:11px">🟡 alive (idle)</span>'}${(n.relay_count_1h != null || n.relay_count_24h != null) ? ` <span style="color:var(--text-muted);font-size:11px;margin-left:4px">(${n.relay_count_1h || 0} relays/hr, ${n.relay_count_24h || 0} relays/24h)</span>` : ''}</td></tr>` : ''}
${(n.role === 'repeater' || n.role === 'room') && n.usefulness_score != null ? (() => {
const s = Number(n.usefulness_score) || 0;
const pct = (s * 100).toFixed(1);
// Visual indicator: width % bar with green→yellow→red color by score.
// Per issue #672 classification table: 0.8+ Critical, 0.6+ Valuable,
// 0.3+ Moderate, 0.1+ Marginal, else Redundant.
let label, color;
if (s >= 0.8) { label = 'Critical'; color = 'var(--status-green, #2ecc71)'; }
else if (s >= 0.6) { label = 'Valuable'; color = 'var(--status-green, #2ecc71)'; }
else if (s >= 0.3) { label = 'Moderate'; color = 'var(--status-yellow, #f1c40f)'; }
else if (s >= 0.1) { label = 'Marginal'; color = 'var(--status-orange, #e67e22)'; }
else { label = 'Redundant'; color = 'var(--status-red, #e74c3c)'; }
const barWidth = Math.max(2, Math.round(s * 100));
return `<tr id="row-usefulness-score" data-usefulness-score="${s.toFixed(4)}"><td title="Fraction of non-advert traffic in the network observed by CoreScope that this repeater carries as a relay hop (Traffic axis of issue #672). Range 0–1; higher = forwards more of the mesh's actual traffic.">Usefulness</td><td><span style="display:inline-block;vertical-align:middle;width:80px;height:8px;background:var(--bg-secondary,#333);border-radius:4px;overflow:hidden;margin-right:6px"><span style="display:block;width:${barWidth}%;height:100%;background:${color}"></span></span><span style="color:${color};font-weight:600">${pct}%</span> <span style="color:var(--text-muted);font-size:11px;margin-left:4px">${label}</span></td></tr>`;
})() : ''}
<tr><td>First Seen</td><td>${renderNodeTimestampHtml(n.first_seen)}</td></tr>
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>