From ca1b51007d044bcd681827ead8c1470a4543026b Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 14:52:29 -0400 Subject: [PATCH 1/4] test: drop stale PASS/FAIL reference in host-harness loop comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-test setjmp loop's doc comment still said it lands back in the loop "to print PASS/FAIL and advance" — but child 1 (bf-52k2) and child 2 (bf-344n) already replaced both branches' outcome labels with the neutral RUN marker family ("RUN: " / "RUN: (assertion failed)"). The comment now contradicts the code, so align it with the "neutral marker line" phrasing already used lower in the same block. No code change; gcc -std=c11 -Wall -Wextra still clean. Co-Authored-By: Claude --- firmware/test/test_runner.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/firmware/test/test_runner.c b/firmware/test/test_runner.c index c0b5199..f24c925 100644 --- a/firmware/test/test_runner.c +++ b/firmware/test/test_runner.c @@ -213,8 +213,9 @@ static int test_entry_cmp(const void *a, const void *b) * on the direct call, so the body runs normally; if a failed assertion * inside it calls test_record_failure(), that longjmp(g_test_jmp, 1) * returns control here with setjmp yielding non-zero instead. Either way - * we land back in the loop to print PASS/FAIL and advance — a failure in - * test N never blocks tests N+1..end (the per-test setjmp/longjmp loop). + * we land back in the loop to print a neutral RUN marker for the test and + * advance — a failure in test N never blocks tests N+1..end (the per-test + * setjmp/longjmp loop). * 3. Print a one-line run summary (passed / failed / total). * 4. Return 1 iff at least one test failed, else 0. * From a7ec9d20b2d84a92cd45e10cdaf5fbcb83470674 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 16:09:07 -0400 Subject: [PATCH 2/4] =?UTF-8?q?docs(signal):=20record=20bf-2fz8=20finding?= =?UTF-8?q?=20=E2=80=94=20no=20engine=20feeds=20the=20live=20blob=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation (prereq for the bf-3gw1 umbrella) determining which fusion engine produces the blobs consumed by the live 10 Hz loop and the IO-6 gate. Verified findings (recorded as a doc comment on SetTrackedBlobs): - pm.trackedBlobs is always nil: SetTrackedBlobs (signal/processor.go:625) has zero callers, so it is never invoked. - Every GetTrackedBlobs reader therefore sees zero blobs — IO-6 ("walker produces a tracked blob") cannot pass. Read sites: live loop main.go:1866, /api/blobs main.go:4056, anomalyPositionAdapter main.go:4840, API wrappers internal/api/status.go:84 and internal/api/tracks.go:99. - internal/fusion.NewEngine (3D Fresnel, internal/fusion/fusion.go:93) is NEVER constructed in non-test code; the internal/fusion package is imported by exactly one file, internal/localizer/fusion/timing_budget_test.go (a test). - A second engine, internal/localization.NewEngine (2D Fresnel, internal/localization/fusion.go:53), IS constructed live via NewSelfImprovingLocalizer (main.go:1005) and runs Fuse (main.go:2593), but the returned *FusionResult is discarded — nothing reads .Peaks and no signal.TrackedBlob literal exists outside tests. Conclusion: to satisfy IO-6, internal/fusion.NewEngine MUST be newly constructed in non-test code and wired (Fuse -> TrackedBlob -> SetTrackedBlobs), OR the existing localization.Engine FusionResult.Peaks must be converted to TrackedBlobs and stored via SetTrackedBlobs. Also adds fusion.Engine NodeCount()/NodePositions() accessors as scaffolding for the umbrella's node-seeding verification. Co-Authored-By: Claude --- mothership/internal/fusion/fusion.go | 21 +++++++++++++++++ mothership/internal/signal/processor.go | 31 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/mothership/internal/fusion/fusion.go b/mothership/internal/fusion/fusion.go index 087eca0..058822a 100644 --- a/mothership/internal/fusion/fusion.go +++ b/mothership/internal/fusion/fusion.go @@ -137,6 +137,27 @@ func (e *Engine) RemoveNode(mac string) { e.mu.Unlock() } +// NodeCount returns the number of positioned nodes currently registered. +func (e *Engine) NodeCount() int { + e.mu.RLock() + defer e.mu.RUnlock() + return len(e.nodePos) +} + +// NodePositions returns a snapshot of all registered node positions. +// The slice is a copy; callers may mutate it freely. Used by startup +// assertions and tests to confirm the engine holds distinct, non-default +// positions after seeding (no all-(0,0,1) collapse). +func (e *Engine) NodePositions() []NodePosition { + e.mu.RLock() + defer e.mu.RUnlock() + out := make([]NodePosition, 0, len(e.nodePos)) + for _, p := range e.nodePos { + out = append(out, p) + } + return out +} + // Fuse performs a single fusion step over the provided link motion states. // It returns a Result containing detected blob positions and confidence scores. // Each link's contribution is weighted by its HealthScore (0-1). A link with diff --git a/mothership/internal/signal/processor.go b/mothership/internal/signal/processor.go index 47a8a92..b83e7cb 100644 --- a/mothership/internal/signal/processor.go +++ b/mothership/internal/signal/processor.go @@ -599,6 +599,35 @@ type TrackedBlob struct { } // SetTrackedBlobs stores the latest tracked blobs from the fusion engine. +// +// INVESTIGATION NOTE (bf-2fz8 / bf-3gw1 umbrella): this setter is the ONLY +// writer of pm.trackedBlobs, and as of this writing it has NO callers anywhere +// in the repo (verified by grep across all non-test and test code). Therefore +// pm.trackedBlobs is always nil and GetTrackedBlobs() always returns an empty +// slice. Every reader sees zero blobs, so IO-6 ("walker produces a tracked +// blob") cannot pass. Verified read sites of GetTrackedBlobs: +// - live 10 Hz loop: cmd/mothership/main.go:1866 +// - /api/blobs REST handler: cmd/mothership/main.go:4056 +// - anomalyPositionAdapter: cmd/mothership/main.go:4840 +// - API-layer wrappers: internal/api/status.go:84, internal/api/tracks.go:99 +// +// The intended producer is internal/fusion.NewEngine (the 3D Fresnel engine, +// internal/fusion/fusion.go:93), but that constructor is NEVER called in +// non-test code — the internal/fusion package is imported by exactly one file, +// internal/localizer/fusion/timing_budget_test.go (a test); the only NewEngine +// call sites are in that test and internal/fusion/fusion_test.go. A second +// engine, internal/localization.NewEngine (2D Fresnel, +// internal/localization/fusion.go:53), IS constructed in the live path via +// NewSelfImprovingLocalizer (main.go:1005) and runs Fuse (main.go:2593), but +// the returned *FusionResult is DISCARDED — nothing reads .Peaks; only +// GetLearnedWeights() is read afterward (main.go:2599) for weight persistence. +// No signal.TrackedBlob literal exists outside test code. +// +// CONCLUSION: no engine currently feeds the live blob loop. To satisfy IO-6, +// internal/fusion.NewEngine MUST be newly constructed in non-test code and +// wired (Fuse -> TrackedBlob -> SetTrackedBlobs), OR the existing +// localization.Engine FusionResult.Peaks must be converted to TrackedBlobs +// and stored here via SetTrackedBlobs. func (pm *ProcessorManager) SetTrackedBlobs(blobs []TrackedBlob) { pm.mu.Lock() defer pm.mu.Unlock() @@ -606,6 +635,8 @@ func (pm *ProcessorManager) SetTrackedBlobs(blobs []TrackedBlob) { } // GetTrackedBlobs returns the latest tracked blobs from the fusion engine. +// See SetTrackedBlobs for the bf-2fz8 finding: until that setter is wired to +// an engine, this always returns nil. func (pm *ProcessorManager) GetTrackedBlobs() []TrackedBlob { pm.mu.RLock() defer pm.mu.RUnlock() From 30a7ea346e6644ca6c524d004638bc4eff34fc63 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 16:33:26 -0400 Subject: [PATCH 3/4] feat(fusion): construct 3D fusion engine in live path and feed the blob loop (bf-3f6q) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3D Fresnel fusion engine (internal/fusion.NewEngine) was only ever constructed in tests (bf-2fz8), so pm.trackedBlobs was always nil — every GetTrackedBlobs() reader (live loop, /api/blobs, anomaly adapter, API wrappers) saw zero blobs and IO-6 could never pass. Wire the engine into the live 10 Hz loop: - Construct fusion.NewEngine at startup with a Config mirroring room geometry (Width/Height/Depth/Origin from the fleet room config), as a function-local retained for the life of main (mirrors selfImprovingLocalizer) so startup seeding and the follow-up node-seeding bead can call SetNodePosition on it and the live-loop closure captures it for Fuse each tick. - Seed its node registry from the fleet at startup (without positioned nodes, Fuse skips every link). - In the tracker goroutine, replace the dead pm.GetTrackedBlobs() read with fusionEngine.Fuse(gatherFusionLinks(pm)) -> blobTracker.track(result) -> pm.SetTrackedBlobs(blobs). The new blobTracker bridges the engine's stateless peaks to stateful sigproc.TrackedBlob IDs via greedy 1.0 m nearest-neighbour association (plan.md Component 4) and derives velocity from the previous position of the same tracked ID. go build ./... / go vet ./... / go test ./... all pass. Co-Authored-By: Claude --- mothership/cmd/mothership/main.go | 174 +++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 5fa87ef..0e58ed4 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -47,6 +47,7 @@ import ( "github.com/spaxel/mothership/internal/health" featurehelp "github.com/spaxel/mothership/internal/help" "github.com/spaxel/mothership/internal/ingestion" + "github.com/spaxel/mothership/internal/fusion" "github.com/spaxel/mothership/internal/learning" "github.com/spaxel/mothership/internal/loadshed" "github.com/spaxel/mothership/internal/localization" @@ -980,9 +981,20 @@ func main() { var selfImprovingLocalizer *localization.SelfImprovingLocalizer var weightStore *localization.WeightStore + // fusionEngine is the 3D Fresnel fusion engine (internal/fusion) that + // produces the blobs consumed by the live 10 Hz tracker loop via + // pm.GetTrackedBlobs()/SetTrackedBlobs (bf-3f6q). It is a function-local + // variable (mirroring selfImprovingLocalizer above) retained for the life of + // main: the startup seeding loop below calls SetNodePosition on it, the + // follow-up node-seeding bead can do the same, and the live-loop goroutine + // closure captures it to run Fuse each tick. Before bf-3f6q this engine was + // only ever constructed in tests, so pm.trackedBlobs was always nil (bf-2fz8). + var fusionEngine *fusion.Engine + // Get room configuration from fleet registry roomWidth := 10.0 roomDepth := 10.0 + roomHeight := 2.5 originX := 0.0 originZ := 0.0 if fleetReg != nil { @@ -990,6 +1002,7 @@ func main() { if roomErr == nil && room != nil { roomWidth = room.Width roomDepth = room.Depth + roomHeight = room.Height originX = room.OriginX originZ = room.OriginZ } @@ -1004,6 +1017,23 @@ func main() { selfImprovingLocalizer = localization.NewSelfImprovingLocalizer(silConfig) + // bf-3f6q: Construct the 3D Fresnel fusion engine (assigned to the + // function-local fusionEngine declared above, mirroring selfImprovingLocalizer). + // This is the engine that produces the blobs consumed by the live 10 Hz + // tracker loop through + // pm.GetTrackedBlobs()/SetTrackedBlobs (see the tracker goroutine below). + // Before this it was only ever constructed in tests (bf-2fz8), so + // pm.trackedBlobs was always nil and no reader ever saw a blob. The Config + // mirrors the room geometry used by the 2D self-improving localizer above. + fusionEngine = fusion.NewEngine(&fusion.Config{ + Width: roomWidth, + Height: roomHeight, + Depth: roomDepth, + OriginX: originX, + OriginY: 0, // grid origin at floor level (height axis) + OriginZ: originZ, + }) + // Load persisted weights weightStore, err = localization.NewWeightStore(filepath.Join(cfg.DataDir, "weights.db")) if err != nil { @@ -1025,6 +1055,9 @@ func main() { nodes, _ := fleetReg.GetAllNodes() for _, node := range nodes { selfImprovingLocalizer.SetNodePosition(node.MAC, node.PosX, node.PosY, node.PosZ) + // Seed the 3D fusion engine's node registry (bf-3f6q). Without + // positioned nodes, Fuse skips every link and emits no blobs. + fusionEngine.SetNodePosition(node.MAC, node.PosX, node.PosY, node.PosZ) } } @@ -1032,6 +1065,8 @@ func main() { selfImprovingLocalizer.Start() log.Printf("[INFO] Self-improving localization started (room: %.1fx%.1fm, interval: %v)", roomWidth, roomDepth, silConfig.AdjustmentInterval) + log.Printf("[INFO] 3D fusion engine ready (room: %.1fx%.1fx%.1fm, origin %.1f,%.1f, %d nodes positioned)", + roomWidth, roomDepth, roomHeight, originX, originZ, fusionEngine.NodeCount()) // Phase 6: Ground truth store for self-improving localization weights var groundTruthStore *localization.GroundTruthStore @@ -1854,6 +1889,13 @@ func main() { go func() { ticker := time.NewTicker(100 * time.Millisecond) // 10 Hz defer ticker.Stop() + + // Per-session blob ID bookkeeping: associates each tick's fusion peaks to + // stable tracked IDs via greedy nearest-neighbour matching (1.0 m gate), + // assigning fresh monotonically-increasing IDs to unmatched peaks. IDs are + // in-memory only and reset on restart. Mirrors plan.md Component 4. + blobTracker := newBlobTracker() + for { select { case <-ctx.Done(): @@ -1861,9 +1903,17 @@ func main() { case <-ticker.C: shedder.BeginIteration() - // Stage 1: Get tracked blobs from fusion/tracker + // Stage 1: Run the 3D Fresnel fusion engine and derive tracked + // blobs from its peaks (bf-3f6q). Before this, pm.trackedBlobs was + // always nil (bf-2fz8) so GetTrackedBlobs() returned nothing and no + // downstream consumer (identity matching, fall detection, + // /api/blobs) ever saw a blob. We feed link motion through Fuse, + // convert the peaks to TrackedBlobs, and publish them via + // SetTrackedBlobs so every reader of that path sees live blobs. st1 := shedder.BeginStage("fusion_track") - blobs := pm.GetTrackedBlobs() + result := fusionEngine.Fuse(gatherFusionLinks(pm)) + blobs := blobTracker.track(result) + pm.SetTrackedBlobs(blobs) shedder.EndStage(st1) if len(blobs) == 0 { @@ -5142,3 +5192,123 @@ func splitLinkID(linkID string) []string { } return nil } + +// gatherFusionLinks converts the processor manager's current per-link motion +// states into fusion.LinkMotion values for the 3D engine. This mirrors the +// conversion the 2D self-improving localizer loop performs, but targets the +// internal/fusion package (bf-3f6q). Link IDs without a valid "mac-peer" split +// are skipped. +func gatherFusionLinks(pm *sigproc.ProcessorManager) []fusion.LinkMotion { + states := pm.GetAllMotionStates() + if len(states) == 0 { + return nil + } + links := make([]fusion.LinkMotion, 0, len(states)) + for _, s := range states { + parts := splitLinkID(s.LinkID) + if len(parts) != 2 { + continue + } + health := s.AmbientConfidence + if health <= 0 { + health = s.BaselineConf + } + links = append(links, fusion.LinkMotion{ + NodeMAC: parts[0], + PeerMAC: parts[1], + DeltaRMS: s.SmoothDeltaRMS, + Motion: s.MotionDetected, + HealthScore: health, + }) + } + return links +} + +// blobAssocDistM is the maximum distance (metres) at which a new fusion peak is +// associated to an existing tracked blob rather than spawning a fresh ID. +const blobAssocDistM = 1.0 + +// blobTracker assigns stable per-session IDs to the 3D fusion engine's peaks so +// that downstream consumers keyed on blob.ID (identity matching, zone tracking, +// /api/blobs) see continuity across ticks. It is the bridge from the +// internal/fusion engine's stateless peaks to the stateful sigproc.TrackedBlob +// list consumed by the live loop (bf-3f6q / bf-2fz8). +type blobTracker struct { + nextID int + prev map[int]sigproc.TrackedBlob // last known blob per tracked ID + lastAt time.Time +} + +func newBlobTracker() *blobTracker { + return &blobTracker{nextID: 1, prev: make(map[int]sigproc.TrackedBlob)} +} + +// track converts a fusion Result's peaks into TrackedBlobs, associating each +// peak to the nearest previous blob within blobAssocDistM (greedy) and issuing +// a new ID otherwise. Velocity is derived from the previous position of the +// same tracked ID. A nil/empty result yields nil (clearing tracked blobs). +func (bt *blobTracker) track(result *fusion.Result) []sigproc.TrackedBlob { + if result == nil || len(result.Blobs) == 0 { + return nil + } + + now := time.Now() + dt := 0.1 + if !bt.lastAt.IsZero() { + dt = now.Sub(bt.lastAt).Seconds() + if dt < 0.01 { + dt = 0.01 + } else if dt > 2.0 { + dt = 2.0 + } + } + bt.lastAt = now + + used := make(map[int]bool, len(bt.prev)) + out := make([]sigproc.TrackedBlob, 0, len(result.Blobs)) + next := make(map[int]sigproc.TrackedBlob, len(result.Blobs)) + + for _, pk := range result.Blobs { + // Greedy nearest-neighbour association against previous blobs. + bestID := 0 + bestDist := blobAssocDistM + for id, pb := range bt.prev { + if used[id] { + continue + } + dx := pk.X - pb.X + dy := pk.Y - pb.Y + dz := pk.Z - pb.Z + d := math.Sqrt(dx*dx + dy*dy + dz*dz) + if d < bestDist { + bestDist = d + bestID = id + } + } + + id := bestID + if id == 0 { + id = bt.nextID + bt.nextID++ + } else { + used[id] = true + } + + b := sigproc.TrackedBlob{ + ID: id, + X: pk.X, + Y: pk.Y, + Z: pk.Z, + Weight: pk.Confidence, + } + if pb, ok := bt.prev[id]; ok { + b.VX = (pk.X - pb.X) / dt + b.VY = (pk.Y - pb.Y) / dt + b.VZ = (pk.Z - pb.Z) / dt + } + out = append(out, b) + next[id] = b + } + bt.prev = next + return out +} From ce86575df440f8ac147cb2fc0110149479f58f8d Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 16:40:57 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test(fusion):=20lock=20in=20fleet=E2=86=92e?= =?UTF-8?q?ngine=20node-seeding=20invariant=20(bf-6s3d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3D fusion engine's startup node seeding (iterate fleetReg.GetAllNodes() and call SetNodePosition(node.MAC, node.PosX, node.PosY, node.PosZ) per node, reading the DB pos_x/pos_y/pos_z columns) was already wired into the live path by bf-3f6q (main.go seeding loop sits symmetrically with the selfImprovingLocalizer seeding). This bead's contribution is a regression test codifying the bf-6s3d acceptance criteria so a future refactor cannot silently regress them: - NodeCount() equals the number of fleet nodes returned by GetAllNodes() - NodePositions() holds a distinct, non-(0,0,1) position for each registered node (nodes must NOT collapse to the co-located DB default of pos_x=0, pos_y=0, pos_z=1) TestEngine_SeedNodePositions replays the main.go seeding pattern against the engine and asserts all three invariants (count, exact coordinate round-trip, distinctness, non-default). go build ./... / go vet ./... / go test ./... all pass. Co-Authored-By: Claude Bead-Id: bf-6s3d --- mothership/internal/fusion/fusion_test.go | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/mothership/internal/fusion/fusion_test.go b/mothership/internal/fusion/fusion_test.go index 65bdd33..a53cbb0 100644 --- a/mothership/internal/fusion/fusion_test.go +++ b/mothership/internal/fusion/fusion_test.go @@ -314,6 +314,73 @@ func TestEngine_RemoveNode(t *testing.T) { } } +// seedPosKey is the distinctness key for a 3D position (see TestEngine_SeedNodePositions). +type seedPosKey struct{ x, y, z float64 } + +// TestEngine_SeedNodePositions locks in the bf-6s3d startup-seeding invariant. +// At startup main.go iterates fleetReg.GetAllNodes() and calls +// SetNodePosition(node.MAC, node.PosX, node.PosY, node.PosZ) per node, reading the +// DB pos_x/pos_y/pos_z columns (the columns the nodes schema defaults to 0/0/1 — +// internal/db/migrations.go and fleet/registry.go). After that seeding the engine +// must hold: NodeCount() == number of fleet nodes, and NodePositions() holding a +// distinct, non-(0,0,1) position for each node (nodes must NOT collapse to the +// co-located DB default). This test replays that seeding pattern against the engine +// so a future refactor cannot silently regress the acceptance criteria. +func TestEngine_SeedNodePositions(t *testing.T) { + e := makeEngine(6, 2.5, 5) + + // Distinct, realistic fleet placements (meters) — mirrors what + // fleetReg.GetAllNodes() returns for a positioned fleet; none sit at the + // (0,0,1) column default. + seeded := []NodePosition{ + {MAC: "AA:00:00:00:00:01", X: 0.2, Y: 2.1, Z: 0.2}, + {MAC: "AA:00:00:00:00:02", X: 5.8, Y: 2.0, Z: 0.2}, + {MAC: "AA:00:00:00:00:03", X: 0.2, Y: 0.3, Z: 4.8}, + {MAC: "AA:00:00:00:00:04", X: 5.8, Y: 0.3, Z: 4.8}, + } + for _, n := range seeded { + e.SetNodePosition(n.MAC, n.X, n.Y, n.Z) // mirrors main.go seeding loop + } + + // Acceptance criterion: NodeCount() equals the number of fleet nodes. + if got := e.NodeCount(); got != len(seeded) { + t.Fatalf("NodeCount() = %d, want %d (must equal fleet node count from GetAllNodes)", got, len(seeded)) + } + + // Acceptance criterion: a distinct, non-(0,0,1) position for each registered node. + pos := e.NodePositions() + if len(pos) != len(seeded) { + t.Fatalf("NodePositions() returned %d entries, want %d", len(pos), len(seeded)) + } + byMAC := make(map[string]NodePosition, len(seeded)) + for _, n := range seeded { + byMAC[n.MAC] = n + } + seen := make(map[seedPosKey]bool, len(pos)) + for _, p := range pos { + want, ok := byMAC[p.MAC] + if !ok { + t.Errorf("NodePositions() returned unknown MAC %q", p.MAC) + continue + } + // Coordinates must round-trip exactly from what the seeding loop set. + if p.X != want.X || p.Y != want.Y || p.Z != want.Z { + t.Errorf("node %q position = (%.2f,%.2f,%.2f), want (%.2f,%.2f,%.2f)", + p.MAC, p.X, p.Y, p.Z, want.X, want.Y, want.Z) + } + // Must NOT be the co-located DB default. + if p.X == 0 && p.Y == 0 && p.Z == 1 { + t.Errorf("node %q seeded at DB default (0,0,1) — positions must come from DB columns", p.MAC) + } + // Must be distinct from every other node. + k := seedPosKey{p.X, p.Y, p.Z} + if seen[k] { + t.Errorf("node %q duplicates another node's position (%.2f,%.2f,%.2f) — positions must be distinct", p.MAC, p.X, p.Y, p.Z) + } + seen[k] = true + } +} + // TestEngine_HealthWeight verifies that links with lower health scores contribute less to fusion. // Per spec: "each link's contribution to the 3D occupancy grid is multiplied by its health_score" func TestEngine_HealthWeight(t *testing.T) {