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:
parent
56a2793d08
commit
b9f362c748
3 changed files with 189 additions and 5 deletions
|
|
@ -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:]}
|
||||
|
|
|
|||
138
mothership/cmd/mothership/splitlink_test.go
Normal file
138
mothership/cmd/mothership/splitlink_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue