diff --git a/cmd/server/repeater_usefulness.go b/cmd/server/repeater_usefulness.go new file mode 100644 index 00000000..940bc762 --- /dev/null +++ b/cmd/server/repeater_usefulness.go @@ -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 +} diff --git a/cmd/server/repeater_usefulness_test.go b/cmd/server/repeater_usefulness_test.go new file mode 100644 index 00000000..ed5d7f20 --- /dev/null +++ b/cmd/server/repeater_usefulness_test.go @@ -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) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 57aa04d5..7337cc65 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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) } } diff --git a/public/nodes.js b/public/nodes.js index 4c13fd4c..fe99c276 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -532,6 +532,21 @@