From b9f362c7483eb436d6ed1b5c58325f08d3bebd5a Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 7 Jul 2026 18:30:24 -0400 Subject: [PATCH] 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 --- mothership/cmd/mothership/main.go | 21 ++- mothership/cmd/mothership/splitlink_test.go | 138 ++++++++++++++++++++ mothership/internal/fusion/fusion.go | 35 ++++- 3 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 mothership/cmd/mothership/splitlink_test.go diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index dae96d6..b0ca64c 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -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 "nodeMACpeerMAC" 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:]} diff --git a/mothership/cmd/mothership/splitlink_test.go b/mothership/cmd/mothership/splitlink_test.go new file mode 100644 index 0000000..ea0ea69 --- /dev/null +++ b/mothership/cmd/mothership/splitlink_test.go @@ -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) + } +} diff --git a/mothership/internal/fusion/fusion.go b/mothership/internal/fusion/fusion.go index 058822a..6d67f1b 100644 --- a/mothership/internal/fusion/fusion.go +++ b/mothership/internal/fusion/fusion.go @@ -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,