fix: multi-byte adopters — all node types, role column, advert precedence (#754) (#767)

## Fix: Multi-Byte Adopters Table — Three Bugs (#754)

### Bug 1: Companions in "Unknown"
`computeMultiByteCapability()` was repeater-only. Extended to classify
**all node types** (companions, rooms, sensors). A companion advertising
with 2-byte hash is now correctly "Confirmed".

### Bug 2: No Role Column
Added a **Role** column to the merged Multi-Byte Hash Adopters table,
color-coded using `ROLE_COLORS` from `roles.js`. Users can now
distinguish repeaters from companions without clicking through to node
detail.

### Bug 3: Data Source Disagreement
When adopter data (from `computeAnalyticsHashSizes`) shows `hashSize >=
2` but capability only found path evidence ("Suspected"), the
advert-based adopter data now takes precedence → "Confirmed". The
adopter hash sizes are passed into `computeMultiByteCapability()` as an
additional confirmed evidence source.

### Changes
- `cmd/server/store.go`: Extended capability to all node types, accept
adopter hash sizes, prioritize advert evidence
- `public/analytics.js`: Added Role column with color-coded badges
- `cmd/server/multibyte_capability_test.go`: 3 new tests (companion
confirmed, role populated, adopter precedence)

### Tests
- All 10 multi-byte capability tests pass
- All 544 frontend helper tests pass
- All 62 packet filter tests pass
- All 29 aging tests pass

---------

Co-authored-by: you <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-04-16 00:51:38 -07:00
committed by GitHub
parent 29157742eb
commit 6a648dea11
3 changed files with 151 additions and 23 deletions
+121 -7
View File
@@ -85,7 +85,7 @@ func TestMultiByteCapability_Confirmed(t *testing.T) {
store := NewPacketStore(db, nil)
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
@@ -123,7 +123,7 @@ func TestMultiByteCapability_Suspected(t *testing.T) {
}
addTestPacket(store, pkt)
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
@@ -152,7 +152,7 @@ func TestMultiByteCapability_Unknown(t *testing.T) {
// Advert with 1-byte hash only
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 1))
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
@@ -194,7 +194,7 @@ func TestMultiByteCapability_PrefixCollision(t *testing.T) {
}
addTestPacket(store, pkt)
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 2 {
t.Fatalf("expected 2 entries, got %d", len(caps))
}
@@ -237,7 +237,7 @@ func TestMultiByteCapability_TraceExcluded(t *testing.T) {
}
addTestPacket(store, pkt)
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
@@ -269,7 +269,7 @@ func TestMultiByteCapability_NonTraceStillSuspected(t *testing.T) {
}
addTestPacket(store, pkt)
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
@@ -304,7 +304,7 @@ func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
}
addTestPacket(store, pkt)
caps := store.computeMultiByteCapability()
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
@@ -312,3 +312,117 @@ func TestMultiByteCapability_ConfirmedUnaffectedByTraceExclusion(t *testing.T) {
t.Errorf("expected confirmed (unaffected by TRACE), got %s", caps[0].Status)
}
}
// TestMultiByteCapability_CompanionConfirmed tests that a companion with
// multi-byte advert is classified as "confirmed", not "unknown" (Bug 1, #754).
func TestMultiByteCapability_CompanionConfirmed(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
"aabbccdd11223344", "CompA", "companion", "2026-04-11T00:00:00Z")
store := NewPacketStore(db, nil)
addTestPacket(store, makeTestAdvert("aabbccdd11223344", 2))
caps := store.computeMultiByteCapability(nil)
if len(caps) != 1 {
t.Fatalf("expected 1 entry, got %d", len(caps))
}
if caps[0].Status != "confirmed" {
t.Errorf("expected confirmed for companion, got %s", caps[0].Status)
}
if caps[0].Role != "companion" {
t.Errorf("expected role companion, got %s", caps[0].Role)
}
if caps[0].Evidence != "advert" {
t.Errorf("expected advert evidence, got %s", caps[0].Evidence)
}
}
// TestMultiByteCapability_RoleColumnPopulated tests that the Role field is
// populated for all node types (Bug 2, #754).
func TestMultiByteCapability_RoleColumnPopulated(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
"aabb000000000001", "Rep1", "repeater", "2026-04-11T00:00:00Z")
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
"ccdd000000000002", "Comp1", "companion", "2026-04-11T00:00:00Z")
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
"eeff000000000003", "Room1", "room_server", "2026-04-11T00:00:00Z")
store := NewPacketStore(db, nil)
addTestPacket(store, makeTestAdvert("aabb000000000001", 2))
addTestPacket(store, makeTestAdvert("ccdd000000000002", 2))
addTestPacket(store, makeTestAdvert("eeff000000000003", 1))
caps := store.computeMultiByteCapability(nil)
if len(caps) != 3 {
t.Fatalf("expected 3 entries, got %d", len(caps))
}
roleByName := map[string]string{}
for _, c := range caps {
roleByName[c.Name] = c.Role
}
if roleByName["Rep1"] != "repeater" {
t.Errorf("Rep1 role: expected repeater, got %s", roleByName["Rep1"])
}
if roleByName["Comp1"] != "companion" {
t.Errorf("Comp1 role: expected companion, got %s", roleByName["Comp1"])
}
if roleByName["Room1"] != "room_server" {
t.Errorf("Room1 role: expected room_server, got %s", roleByName["Room1"])
}
}
// TestMultiByteCapability_AdopterEvidenceTakesPrecedence tests that when
// adopter data shows hashSize >= 2 but path evidence says "suspected",
// the node is upgraded to "confirmed" (Bug 3, #754).
func TestMultiByteCapability_AdopterEvidenceTakesPrecedence(t *testing.T) {
db := setupCapabilityTestDB(t)
defer db.conn.Close()
db.conn.Exec("INSERT INTO nodes (public_key, name, role, last_seen) VALUES (?, ?, ?, ?)",
"aabbccdd11223344", "RepAdopter", "repeater", "2026-04-11T00:00:00Z")
store := NewPacketStore(db, nil)
// Only a path-based packet (no advert) — would normally be "suspected"
pathByte := buildPathByte(2, 1)
rawHex := "01" + pathByte + "aabb"
pt := 1
pkt := &StoreTx{
RawHex: rawHex,
PayloadType: &pt,
PathJSON: `["aabb"]`,
FirstSeen: "2026-04-10T00:00:00.000Z",
}
addTestPacket(store, pkt)
// Without adopter data: should be suspected
caps := store.computeMultiByteCapability(nil)
capByName := map[string]MultiByteCapEntry{}
for _, c := range caps {
capByName[c.Name] = c
}
if capByName["RepAdopter"].Status != "suspected" {
t.Errorf("without adopter data: expected suspected, got %s", capByName["RepAdopter"].Status)
}
// With adopter data showing hashSize 2: should be confirmed
adopterHS := map[string]int{"aabbccdd11223344": 2}
caps = store.computeMultiByteCapability(adopterHS)
capByName = map[string]MultiByteCapEntry{}
for _, c := range caps {
capByName[c.Name] = c
}
if capByName["RepAdopter"].Status != "confirmed" {
t.Errorf("with adopter data: expected confirmed, got %s", capByName["RepAdopter"].Status)
}
if capByName["RepAdopter"].Evidence != "advert" {
t.Errorf("with adopter data: expected advert evidence, got %s", capByName["RepAdopter"].Evidence)
}
}
+27 -16
View File
@@ -5105,7 +5105,18 @@ func (s *PacketStore) GetAnalyticsHashSizes(region string) map[string]interface{
// Add multi-byte capability data (only for unfiltered/global view)
if region == "" {
result["multiByteCapability"] = s.computeMultiByteCapability()
// Pass adopter hash sizes so capability can cross-reference
adopterHS := make(map[string]int)
if mbNodes, ok := result["multiByteNodes"].([]map[string]interface{}); ok {
for _, n := range mbNodes {
pk, _ := n["pubkey"].(string)
hs, _ := n["hashSize"].(int)
if pk != "" && hs >= 2 {
adopterHS[pk] = hs
}
}
}
result["multiByteCapability"] = s.computeMultiByteCapability(adopterHS)
}
s.cacheMu.Lock()
@@ -5818,7 +5829,7 @@ func EnrichNodeWithHashSize(node map[string]interface{}, info *hashSizeNodeInfo)
// --- Multi-Byte Capability Inference ---
// MultiByteCapEntry represents a repeater's inferred multi-byte capability.
// MultiByteCapEntry represents a node's inferred multi-byte capability.
type MultiByteCapEntry struct {
PublicKey string `json:"pubkey"`
Name string `json:"name"`
@@ -5830,7 +5841,7 @@ type MultiByteCapEntry struct {
}
// computeMultiByteCapability determines multi-byte capability for each
// repeater using two methods:
// node (repeaters, companions, rooms, sensors) using two methods:
//
// 1. Confirmed: the node has advertised with hash_size >= 2 (from advert
// path byte). This is 100% reliable because the full public key is
@@ -5847,7 +5858,7 @@ type MultiByteCapEntry struct {
// with default (1-byte) settings.
//
// Caller must hold NO locks — this method acquires mu.RLock internally.
func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
func (s *PacketStore) computeMultiByteCapability(adopterHashSizes map[string]int) []MultiByteCapEntry {
// Get hash size info from adverts (has its own locking)
hashInfo := s.GetNodeHashSizeInfo()
@@ -5882,24 +5893,21 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
pubkey string
prefix string
}
repeaterPrefixes := make(map[string][]prefixEntry) // prefix → entries
for pk, n := range nodeByPK {
if !strings.Contains(strings.ToLower(n.Role), "repeater") {
continue
}
nodePrefixes := make(map[string][]prefixEntry) // prefix → entries
for pk := range nodeByPK {
// Generate 1-byte, 2-byte, 3-byte prefixes
pkLower := strings.ToLower(pk)
for byteLen := 1; byteLen <= 3; byteLen++ {
hexLen := byteLen * 2
if len(pkLower) >= hexLen {
pfx := pkLower[:hexLen]
repeaterPrefixes[pfx] = append(repeaterPrefixes[pfx], prefixEntry{pk, pfx})
nodePrefixes[pfx] = append(nodePrefixes[pfx], prefixEntry{pk, pfx})
}
}
}
suspected := make(map[string]int) // pubkey → max hash size from path appearances
for pfx, entries := range repeaterPrefixes {
for pfx, entries := range nodePrefixes {
txList := s.byPathHop[pfx]
for _, tx := range txList {
if tx.RawHex == "" || len(tx.RawHex) < 4 {
@@ -5945,9 +5953,9 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
}
s.mu.RUnlock()
// Build result for all repeaters — fetch last_seen from DB
// Build result for all nodes — fetch last_seen from DB
dbLastSeen := make(map[string]string)
rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes WHERE role LIKE '%repeater%'")
rows, err := s.db.conn.Query("SELECT public_key, last_seen FROM nodes")
if err == nil {
defer rows.Close()
for rows.Next() {
@@ -5962,9 +5970,6 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
var result []MultiByteCapEntry
for pk, n := range nodeByPK {
if !strings.Contains(strings.ToLower(n.Role), "repeater") {
continue
}
entry := MultiByteCapEntry{
PublicKey: pk,
Name: n.Name,
@@ -5977,6 +5982,12 @@ func (s *PacketStore) computeMultiByteCapability() []MultiByteCapEntry {
entry.Status = "confirmed"
entry.Evidence = "advert"
entry.MaxHashSize = maxHS
} else if maxHS, ok := adopterHashSizes[pk]; ok && maxHS >= 2 {
// Adopter data (from computeAnalyticsHashSizes) shows hash_size >= 2
// from advert analysis — this is advert-based evidence, so confirmed.
entry.Status = "confirmed"
entry.Evidence = "advert"
entry.MaxHashSize = maxHS
} else if maxHS, ok := suspected[pk]; ok {
entry.Status = "suspected"
entry.Evidence = "path"
+3
View File
@@ -1000,6 +1000,7 @@
return (filtered.length ? '<table class="analytics-table" id="mbAdoptersTable" style="margin-top:12px">' +
'<thead><tr>' +
'<th scope="col" data-sort="name">Node</th>' +
'<th scope="col" data-sort="role">Role</th>' +
'<th scope="col" data-sort="status">Status</th>' +
'<th scope="col" data-sort="hashSize">Hash Size</th>' +
'<th scope="col" data-sort="packets">Adverts</th>' +
@@ -1007,8 +1008,10 @@
'</tr></thead>' +
'<tbody>' +
filtered.map(function(r) {
var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280';
return '<tr class="clickable-row" data-action="navigate" data-value="#/nodes/' + encodeURIComponent(r.pubkey) + '" tabindex="0" role="row">' +
'<td><strong>' + esc(r.name) + '</strong></td>' +
'<td><span class="badge" style="background:' + roleColor + '20;color:' + roleColor + '">' + esc(r.role || 'unknown') + '</span></td>' +
'<td><span style="color:' + (statusColor[r.status] || statusColor.unknown) + '">' +
(statusIcon[r.status] || '❓') + ' ' + (statusLabel[r.status] || 'Unknown') + '</span></td>' +
'<td><span class="badge badge-hash-' + r.hashSize + '">' + r.hashSize + '-byte</span></td>' +