fix(bf-561zr): split colon-joined LinkID so sim CSI reaches Fuse

CSIFrame.LinkID() joins the two MACs with ':' ("AA:BB:CC:DD:EE:FF:11:22:33:44:55:66"),
but splitLinkID scanned only for '-' and returned nil for every such link. Both fusion
paths (3D gatherFusionLinks at main.go:5446 and 2D selfImprovingLocalizer at main.go:2786)
share splitLinkID, so gatherFusionLinks returned an empty slice -> Fuse([]) -> 0 peaks
-> 0 blobs (the bf-20q9o root cause).

Fix with a fixed-width split: each MAC is exactly 17 chars, so the canonical two-MAC
form is 35 chars with the boundary at index 17 regardless of whether the separator is
':' (actual producer format) or '-' (documented format). The '-' scan stays as a
defensive fallback. This is Option A from bf-20q9o (one function, fixes both paths,
no key-format change).

Adds splitlink_test.go (table-driven producer-contract cases + a gatherFusionLinks
function-level proof that the colon-joined producer LinkID yields >=1 link) and a
transient FUSE-DBG tick log in internal/fusion to confirm Fuse receives links>=1 at
runtime (marked for removal once the sim->blob path is verified end-to-end).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-07 18:30:24 -04:00
parent 56a2793d08
commit b9f362c748
3 changed files with 189 additions and 5 deletions

View file

@ -5403,9 +5403,26 @@ func copyFileToPath(src, dst string) error {
return err
}
// splitLinkID parses a link ID in format "nodeMAC-peerMAC" into its components
// splitLinkID parses a directional link ID "nodeMAC<sep>peerMAC" into its two
// MAC components, returning nil for anything that is not two MACs.
//
// The producer (CSIFrame.LinkID in internal/ingestion) joins the two MACs with
// a single separator. Each MAC is a fixed 17-char "XX:XX:XX:XX:XX:XX" string,
// so the canonical two-MAC form is exactly 35 chars with the boundary at index
// 17 — regardless of whether the separator is a colon
// ("AA:BB:CC:DD:EE:FF:11:22:33:44:55:66", the actual producer format) or a dash
// ("AA:BB:CC:DD:EE:FF-11:22:33:44:55:66", the previously documented format).
//
// bf-20q9o: the prior implementation scanned only for '-' and returned nil for
// every colon-joined link, so gatherFusionLinks returned an empty slice and no
// sim/real CSI ever reached Fuse (0 active links -> 0 peaks -> 0 blobs). The
// fixed-width split below handles both real formats; the dash scan is kept only
// as a defensive fallback for a dash-joined form with trailing content.
func splitLinkID(linkID string) []string {
// Link ID format is "aa:bb:cc:dd:ee:ff-11:22:33:44:55:66"
const macLen = 17 // len("XX:XX:XX:XX:XX:XX")
if len(linkID) == macLen*2+1 {
return []string{linkID[:macLen], linkID[macLen+1:]}
}
for i := len(linkID) - 1; i >= 0; i-- {
if linkID[i] == '-' {
return []string{linkID[:i], linkID[i+1:]}

View file

@ -0,0 +1,138 @@
package main
import (
"reflect"
"testing"
"time"
sigproc "github.com/spaxel/mothership/internal/signal"
)
// splitLinkIDTestCases documents the link-ID formats the fusion path must
// accept. The producer (CSIFrame.LinkID in internal/ingestion) emits the
// colon-joined form; the dash-joined form is the legacy documented format. Both
// are exactly 35 chars (17 + 1 separator + 17) with the MAC boundary at index
// 17. See bf-561zr / bf-20q9o.
var splitLinkIDTestCases = []struct {
name string
linkID string
want []string
}{
{
name: "colon-joined producer format (uppercase, LinkID output)",
linkID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66",
want: []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"},
},
{
name: "colon-joined producer format (lowercase, sim/real edge case)",
linkID: "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66",
want: []string{"aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66"},
},
{
name: "dash-joined documented format",
linkID: "AA:BB:CC:DD:EE:FF-11:22:33:44:55:66",
want: []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"},
},
{
// Single MAC (17 chars) is not a valid two-MAC link ID.
name: "single MAC (no separator) -> nil",
linkID: "AA:BB:CC:DD:EE:FF",
want: nil,
},
{
name: "empty string -> nil",
linkID: "",
want: nil,
},
{
// Defensive fallback: a non-canonical dash-joined form still splits
// on the rightmost dash rather than being dropped.
name: "non-canonical dash form falls back to dash scan",
linkID: "AB:CD-EF:GH",
want: []string{"AB:CD", "EF:GH"},
},
}
func TestSplitLinkID(t *testing.T) {
for _, tc := range splitLinkIDTestCases {
t.Run(tc.name, func(t *testing.T) {
got := splitLinkID(tc.linkID)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("splitLinkID(%q) = %v, want %v", tc.linkID, got, tc.want)
}
})
}
}
// TestSplitLinkIDProducerContract verifies the exact contract CSIFrame.LinkID
// relies on: splitting the real producer output recovers the original node and
// peer MACs. This guards against a regression where the in-memory link key
// diverges from what the fusion path can parse (the bf-20q9o root cause:
// 0 active links -> 0 peaks -> 0 blobs).
func TestSplitLinkIDProducerContract(t *testing.T) {
// Mirrors internal/ingestion CSIFrame.LinkID(): "%s:%s" of two
// 17-char uppercase colon-separated MACs.
const nodeMAC = "AA:BB:CC:DD:EE:FF"
const peerMAC = "11:22:33:44:55:66"
linkID := nodeMAC + ":" + peerMAC
parts := splitLinkID(linkID)
if len(parts) != 2 {
t.Fatalf("splitLinkID(producer LinkID %q) returned %d parts, want 2", linkID, len(parts))
}
if parts[0] != nodeMAC {
t.Errorf("parts[0] = %q, want node MAC %q", parts[0], nodeMAC)
}
if parts[1] != peerMAC {
t.Errorf("parts[1] = %q, want peer MAC %q", parts[1], peerMAC)
}
}
// TestGatherFusionLinksProducerLinkID proves acceptance criterion #2 at the
// function level: when the signal pipeline holds a link registered under the
// producer's actual colon-joined LinkID (CSIFrame.LinkID, fed straight through
// by ingestion/server.go pm.Process), gatherFusionLinks returns a non-empty
// links slice with the two MACs split correctly. That slice is exactly what the
// 3D engine's Fuse receives, so a non-empty result here == Fuse receiving
// links>=1 (the [FUSE-DBG] log in internal/fusion would mirror len(links)).
//
// Before the bf-561zr fix, splitLinkID scanned only for '-' and returned nil for
// this colon-joined linkID, so this assertion would have produced 0 links.
func TestGatherFusionLinksProducerLinkID(t *testing.T) {
const nodeMAC = "AA:BB:CC:DD:EE:FF"
const peerMAC = "11:22:33:44:55:66"
linkID := nodeMAC + ":" + peerMAC // colon-joined producer form (35 chars)
const nSub = 64
pm := sigproc.NewProcessorManager(sigproc.ProcessorManagerConfig{
NSub: nSub,
FusionRate: 10,
Tau: 30,
})
// Feed a few synthetic CSI frames (128 int8 = nSub*2 I/Q pairs) on the
// colon-joined link so a processor + motion state is registered, exactly
// as ingestion/server.go does on pm.Process(linkID, ...).
payload := make([]int8, nSub*2)
for i := range payload {
payload[i] = 10
}
base := time.Unix(1_700_000_000, 0)
for i := 0; i < 5; i++ {
if _, err := pm.Process(linkID, payload, -50, nSub, base.Add(time.Duration(i)*100*time.Millisecond)); err != nil {
t.Fatalf("pm.Process(%q): %v", linkID, err)
}
}
links := gatherFusionLinks(pm)
if len(links) < 1 {
t.Fatalf("gatherFusionLinks returned %d links, want >=1 (Fuse would receive an empty slice -> 0 peaks -> 0 blobs)", len(links))
}
got := links[0]
if got.NodeMAC != nodeMAC {
t.Errorf("links[0].NodeMAC = %q, want %q", got.NodeMAC, nodeMAC)
}
if got.PeerMAC != peerMAC {
t.Errorf("links[0].PeerMAC = %q, want %q", got.PeerMAC, peerMAC)
}
}

View file

@ -1,13 +1,23 @@
package fusion
import (
"log"
"math"
"sync"
"sync/atomic"
"time"
"github.com/spaxel/mothership/internal/explainability"
)
// bf-2eub9 DEBUG: transient instrumentation. Counts Fuse ticks and, every
// debugEvery ticks, logs why Fuse did or did not emit peaks (link counts at
// each gate, the DeltaRMS range, nodePos coverage, active links, peak count).
// Remove once the runtime sim->blob path is verified.
var debugFuseTick uint64
const debugEvery = 10 // log every Nth fusion tick (~1s at 10 Hz)
// LinkMotion describes one link's current motion state for 3D fusion.
type LinkMotion struct {
// NodeMAC is the transmitting node's MAC address.
@ -185,21 +195,34 @@ func (e *Engine) Fuse(links []LinkMotion) *Result {
posB NodePosition
}, 0)
// bf-2eub9 DEBUG accumulators (transient).
var dbgMotionOK, dbgDeltaOK, dbgPosOK int
var dbgMinD, dbgMaxD float64
for _, lm := range links {
if !lm.Motion || lm.DeltaRMS < minDelta {
if !lm.Motion {
continue
}
dbgMotionOK++
if lm.DeltaRMS < minDelta {
continue
}
dbgDeltaOK++
if dbgDeltaOK == 1 || lm.DeltaRMS < dbgMinD {
dbgMinD = lm.DeltaRMS
}
if lm.DeltaRMS > dbgMaxD {
dbgMaxD = lm.DeltaRMS
}
posA, okA := nodePos[lm.NodeMAC]
posB, okB := nodePos[lm.PeerMAC]
if !okA || !okB {
continue
}
// Apply health score weighting: default to 1.0 if not set
dbgPosOK++
healthWeight := lm.HealthScore
if healthWeight <= 0 {
healthWeight = 1.0
}
// Weight activation by health score
weightedActivation := lm.DeltaRMS * healthWeight
e.grid.AddLinkInfluence(
posA.X, posA.Y, posA.Z,
@ -229,6 +252,12 @@ func (e *Engine) Fuse(links []LinkMotion) *Result {
})
}
// bf-2eub9 DEBUG: log gate breakdown every debugEvery ticks.
if atomic.AddUint64(&debugFuseTick, 1)%debugEvery == 0 {
log.Printf("[FUSE-DBG] links=%d nodePos=%d motionOK=%d deltaOK=%d(minDelta=%.4f dRange=%.4f..%.4f) posResolved=%d active=%d maxBlobs=%d blobThresh=%.3f",
len(links), len(nodePos), dbgMotionOK, dbgDeltaOK, minDelta, dbgMinD, dbgMaxD, dbgPosOK, activeLinks, e.maxBlobs, e.blobThresh)
}
result := &Result{
Timestamp: time.Now(),
ActiveLinks: activeLinks,