fix(bf-21v71): write BLE identity onto /api/blobs served slice (Stage 2b)

The diagnosis (bf-m1ynp, closed) pinned the broken link as match-found: a
blob served by /api/blobs never carried canonical identity because nothing
copied identityMatcher.GetMatch() back onto the served TrackedBlob slice —
the existing GetMatch sites only fed explainability snapshots and the ground-
truth collector, not the slice that SetTrackedBlobs publishes.

Add a Stage 2b write-back right after identityMatcher.UpdateBlobs(): for each
served blob, on a non-nil match copy PersonName/AssignedColor (+ the snake_case
aliases and IdentityConfidence/Source/PersonID) and set IdentityResolved=&true;
unmatched blobs get IdentityResolved=&false. Re-publish via SetTrackedBlobs so
/api/blobs and /api/tracks serve resolved canonical identity whenever the
matcher has a person match.

Update the now-stale comments that claimed the identity fields are left at zero
"until a follow-up bead wires the BLE sidecar" (signal/processor.go,
api/tracks.go) and the superseded bf-2fz8 "SetTrackedBlobs has no callers"
investigation note, to reflect that the live fusion loop now drives both.

Build green: cd mothership && go build ./...
Runtime observation that /api/blobs shows non-empty identity is the next child
(requires the sim fixture: --ble + a registered person), explicitly out of scope
per bf-21v71 acceptance criterion 2 (code-review-verifiable wiring).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-08 08:02:50 -04:00
parent c472b0358e
commit 09bd6e372d
3 changed files with 58 additions and 36 deletions

View file

@ -2125,6 +2125,32 @@ func main() {
}
identityMatcher.UpdateBlobs(matcherBlobs)
// Stage 2b: write resolved BLE identity back onto the served
// TrackedBlob slice (bf-5h1t). UpdateBlobs resolves matches,
// but every runtime surface (/api/blobs, /api/tracks, the
// explainability snapshots below) reads identity off the
// TrackedBlob — so without this write-back live blobs always
// carry empty identity even when a person is matched. Mark
// IdentityResolved tri-state (nil=unattempted when no matcher,
// &true=resolved, &false=attempted-but-unmatched) and re-publish.
identityYes, identityNo := true, false
for i := range blobs {
match := identityMatcher.GetMatch(blobs[i].ID)
if match == nil {
blobs[i].IdentityResolved = &identityNo
continue
}
blobs[i].PersonID = match.PersonID
blobs[i].PersonLabel = match.PersonName
blobs[i].PersonColor = match.PersonColor
blobs[i].IdentityConfidence = match.Confidence
blobs[i].IdentitySource = "ble"
blobs[i].PersonName = match.PersonName
blobs[i].AssignedColor = match.PersonColor
blobs[i].IdentityResolved = &identityYes
}
pm.SetTrackedBlobs(blobs)
// Collect ground truth samples for self-improving localization
if groundTruthCollector != nil {
// Build per-link delta and health maps from motion states

View file

@ -25,9 +25,11 @@ type Track struct {
IdentitySource string `json:"identity_source,omitempty"`
// Canonical identity fields (bf-5151). camelCase JSON keys are the canonical
// API-facing names; the snake_case PersonLabel/PersonColor above are retained
// as deprecated aliases. Propagated from signal.TrackedBlob in listTracks —
// zero today because the source struct is not yet populated (a follow-up bead
// wires the BLE identity sidecar).
// as deprecated aliases. Propagated from signal.TrackedBlob in listTracks;
// the source struct is populated each fusion tick by the Stage 2 identity
// write-back (cmd/mothership/main.go, guarded by `if identityMatcher != nil`)
// from ble.IdentityMatcher.GetMatch, so a served track carries non-empty
// identity whenever the matcher has a person match for that blob ID.
PersonName string `json:"personName,omitempty"`
AssignedColor string `json:"assignedColor,omitempty"`
IdentityResolved *bool `json:"identityResolved,omitempty"` // tri-state: nil=unattempted, &true=resolved, &false=failed

View file

@ -599,9 +599,14 @@ type TrackedBlob struct {
// Canonical identity fields (bf-5151). camelCase JSON keys match the dashboard
// Blob type in dashboard/types/spaxel.d.ts; the snake_case PersonLabel/PersonColor
// above are retained as deprecated aliases for backward compatibility. Left at
// their zero values here (undefined) — a follow-up bead populates them from the
// BLE identity sidecar.
// above are retained as deprecated aliases for backward compatibility. Populated
// by the live 10 Hz fusion loop's identity write-back (cmd/mothership/main.go,
// the "Stage 2b" block guarded by `if identityMatcher != nil`): each tick it
// calls identityMatcher.GetMatch(blobID) and, on a non-nil match, copies
// PersonName/AssignedColor here and sets IdentityResolved=&true, then
// re-publishes the slice via SetTrackedBlobs so /api/blobs and /api/tracks
// serve them. Unmatched blobs get IdentityResolved=&false; the field stays nil
// (unattempted) only when no identityMatcher is configured.
PersonName string `json:"personName,omitempty"`
AssignedColor string `json:"assignedColor,omitempty"`
IdentityResolved *bool `json:"identityResolved,omitempty"` // tri-state: nil=unattempted, &true=resolved, &false=failed
@ -609,43 +614,32 @@ 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
// Live-path callers (the earlier bf-2fz8 "no callers / trackedBlobs always nil"
// finding is resolved by the bf-3f6q + bf-243os fusion-track wiring):
// - Stage 1 of the 10 Hz fusion loop publishes identity-less blobs right after
// blobTracker derives them from the 3D Fresnel engine's peaks
// (cmd/mothership/main.go).
// - The Stage 2 identity write-back re-publishes the SAME slice with BLE
// identity resolved onto each blob (PersonName/AssignedColor/IdentityResolved)
// whenever identityMatcher.GetMatch returns a match, so served blobs carry
// non-empty canonical identity.
//
// 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.
// This setter remains the ONLY writer of pm.trackedBlobs; GetTrackedBlobs is the
// matching reader. Readers include /api/blobs, /api/tracks, the 10 Hz dashboard
// broadcast, anomaly positioning, and fall detection.
func (pm *ProcessorManager) SetTrackedBlobs(blobs []TrackedBlob) {
pm.mu.Lock()
defer pm.mu.Unlock()
pm.trackedBlobs = blobs
}
// 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.
// GetTrackedBlobs returns the latest tracked blobs served to /api/blobs,
// /api/tracks, the 10 Hz dashboard broadcast, anomaly positioning, and fall
// detection. It reflects whatever the most recent SetTrackedBlobs stored —
// i.e. the Stage 2 identity write-back's slice, which carries resolved
// canonical identity (PersonName/AssignedColor/IdentityResolved) whenever a
// matcher is configured and a person matches a blob. Returns nil only before
// the first tracked blob is published.
func (pm *ProcessorManager) GetTrackedBlobs() []TrackedBlob {
pm.mu.RLock()
defer pm.mu.RUnlock()