fix(bf-4qto): propagate canonical identity fields through all blob projections

The three canonical identity fields (PersonName, AssignedColor,
IdentityResolved) had been added to every identity-bearing blob type
(bf-5151/bf-2ibc/bf-5v3q/bf-3wkz) but three projection sites in the
fusion loop were silently dropping them when converting the source
sigproc.TrackedBlob:

  - explainability.BlobSnapshot  (cmd/mothership/main.go)
  - automation.TrackedBlob       (cmd/mothership/main.go)
  - volume.BlobPos               (cmd/mothership/main.go)

The api.Track (tracks.go) and dashboard.blobJSON (hub.go) projections
already propagated them, so this left default-handling inconsistent
across projection contexts. Propagate the three fields from the source
blob at all three sites so the canonical fields flow through every
projection instead of being zeroed.

The fields stay at their Go zero values for existing blobs (identity is
not yet resolved at the signal layer): empty strings and a nil *bool all
carry omitempty, so they serialize as omitted (undefined in JS), which is
the consistent default the acceptance criteria require. The BLE identity
sidecar that actually populates them is a separate follow-up bead.

Also add the companion serialization-consistency suites that were missing
for the four types whose fields landed across the split beads:
tracker.Blob, tracking.Blob, volume.BlobPos, explainability.BlobSnapshot.
Each asserts the three task-required properties: zero-value blobs omit
all three fields (undefined), a resolved identity emits the camelCase
keys matching dashboard/types/spaxel.d.ts, and the *bool tri-state
(nil=omitted, &true=emitted, &false still emits identityResolved:false).

Verified: gofmt clean, go vet ./... clean, go test ./... passes
(incl. the four new suites and the existing api/automation/signal ones).

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-4qto
This commit is contained in:
jedarden 2026-07-06 19:11:29 -04:00
parent d0ac1b27b0
commit ca7a0f21f3
7 changed files with 380 additions and 3 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
1446ccf65fe5366db9a1ac0e1adef608548473d5
520f9fa4f18ac1ce23d4711f3768a46147174e9e

View file

@ -2209,6 +2209,14 @@ func main() {
Y: blob.Y,
Z: blob.Z,
Confidence: blob.Weight,
// Canonical identity fields (bf-4qto): propagate from the source
// sigproc.TrackedBlob so default-handling is consistent with the
// api.Track (tracks.go) and dashboard.blobJSON (hub.go) projections.
// Zero today (serializes as undefined); populated when the BLE
// identity sidecar writes onto the source blob.
PersonName: blob.PersonName,
AssignedColor: blob.AssignedColor,
IdentityResolved: blob.IdentityResolved,
})
}
@ -2309,6 +2317,14 @@ func main() {
VY: b.VY,
VZ: b.VZ,
Confidence: b.Weight,
// Canonical identity fields (bf-4qto): propagate from the source
// sigproc.TrackedBlob so default-handling is consistent with the
// api.Track (tracks.go) and dashboard.blobJSON (hub.go) projections.
// Zero today (serializes as undefined); populated when the BLE
// identity sidecar writes onto the source blob.
PersonName: b.PersonName,
AssignedColor: b.AssignedColor,
IdentityResolved: b.IdentityResolved,
}
}
automationEngine.Evaluate(autoBlobs, func(blobID int) string {
@ -2328,6 +2344,14 @@ func main() {
X: blob.X,
Y: blob.Y,
Z: blob.Z,
// Canonical identity fields (bf-4qto): propagate from the source
// sigproc.TrackedBlob so default-handling is consistent with the
// api.Track (tracks.go) and dashboard.blobJSON (hub.go) projections.
// Zero today (serializes as undefined); populated when the BLE
// identity sidecar writes onto the source blob.
PersonName: blob.PersonName,
AssignedColor: blob.AssignedColor,
IdentityResolved: blob.IdentityResolved,
}
}
volumeTriggersHandler.EvaluateTriggers(volumeBlobs)

View file

@ -0,0 +1,88 @@
package explainability
import (
"encoding/json"
"strings"
"testing"
)
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields every
// identity-bearing blob type must emit (camelCase, matching
// dashboard/types/spaxel.d.ts). BlobSnapshot is the explainability ("Why is this
// here?") view of a blob — the Tier-1 #2 type from bf-1q3m, which previously
// lacked identity fields entirely. Its canonical fields were added in bf-5151
// but left at zero values until a follow-up bead folds in the parallel
// identityMap from the BLE identity sidecar.
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
// TestBlobSnapshot_CanonicalIdentitySerialization guards the three canonical
// identity fields on BlobSnapshot. Per the task scope they are left at their Go
// zero values for existing blobs: with omitempty they must serialize as OMITTED
// (undefined in JS). IdentityResolved is *bool so that a non-nil false
// (resolution attempted, failed) is distinct from nil (unattempted) — omitempty
// only drops the nil case. This locks in default-handling consistency with the
// other identity-bearing blob types (signal.TrackedBlob, automation.TrackedBlob,
// tracker.Blob, tracking.Blob, volume.BlobPos).
func TestBlobSnapshot_CanonicalIdentitySerialization(t *testing.T) {
t.Parallel()
tests := []struct {
name string
blob BlobSnapshot
wantKey map[string]bool // key → expected present in JSON
}{
{
name: "zero-value blob omits all three (undefined)",
blob: BlobSnapshot{ID: 1, X: 1, Y: 2, Z: 3, Confidence: 1},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": false,
},
},
{
name: "resolved identity emits camelCase keys",
blob: BlobSnapshot{
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
IdentityResolved: ptrBoolExplain(true),
},
wantKey: map[string]bool{
"personName": true, "assignedColor": true, "identityResolved": true,
},
},
{
name: "failed resolution still emits identityResolved=false",
blob: BlobSnapshot{ID: 3, IdentityResolved: ptrBoolExplain(false)},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := json.Marshal(tc.blob)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, k := range canonicalIdentityKeys {
got := strings.Contains(body, `"`+k+`"`)
if got != tc.wantKey[k] {
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
}
}
// Verify the tri-state value round-trips when present.
if tc.wantKey["identityResolved"] {
var decoded BlobSnapshot
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.IdentityResolved == nil {
t.Fatalf("identityResolved unexpectedly nil after round-trip (body=%s)", body)
}
}
})
}
}
// ptrBoolExplain is a small helper for the *bool tri-state IdentityResolved field.
func ptrBoolExplain(b bool) *bool { return &b }

View file

@ -0,0 +1,86 @@
package tracker
import (
"encoding/json"
"strings"
"testing"
)
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields every
// identity-bearing blob type must emit (camelCase, matching
// dashboard/types/spaxel.d.ts). tracker.Blob is the 3D identity-bearing tracked
// type; its canonical fields were added in bf-5151 but left at zero values until
// a follow-up bead wires the BLE identity sidecar.
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
// TestBlob_CanonicalIdentitySerialization guards the three bf-5151 canonical
// identity fields on tracker.Blob. Per the task scope they are left at their Go
// zero values for existing blobs: with omitempty they must serialize as OMITTED
// (undefined in JS). IdentityResolved is *bool so that a non-nil false
// (resolution attempted, failed) is distinct from nil (unattempted) — omitempty
// only drops the nil case. This locks in default-handling consistency with the
// other identity-bearing blob types (signal.TrackedBlob, automation.TrackedBlob,
// tracking.Blob, volume.BlobPos, explainability.BlobSnapshot).
func TestBlob_CanonicalIdentitySerialization(t *testing.T) {
t.Parallel()
tests := []struct {
name string
blob Blob
wantKey map[string]bool // key → expected present in JSON
}{
{
name: "zero-value blob omits all three (undefined)",
blob: Blob{ID: 1, X: 1, Y: 2, Z: 3, Weight: 1},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": false,
},
},
{
name: "resolved identity emits camelCase keys",
blob: Blob{
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
IdentityResolved: ptrBoolTracker(true),
},
wantKey: map[string]bool{
"personName": true, "assignedColor": true, "identityResolved": true,
},
},
{
name: "failed resolution still emits identityResolved=false",
blob: Blob{ID: 3, IdentityResolved: ptrBoolTracker(false)},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := json.Marshal(tc.blob)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, k := range canonicalIdentityKeys {
got := strings.Contains(body, `"`+k+`"`)
if got != tc.wantKey[k] {
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
}
}
// Verify the tri-state value round-trips when present.
if tc.wantKey["identityResolved"] {
var decoded Blob
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.IdentityResolved == nil {
t.Fatalf("identityResolved unexpectedly nil after round-trip (body=%s)", body)
}
}
})
}
}
// ptrBoolTracker is a small helper for the *bool tri-state IdentityResolved field.
func ptrBoolTracker(b bool) *bool { return &b }

View file

@ -0,0 +1,86 @@
package tracking
import (
"encoding/json"
"strings"
"testing"
)
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields every
// identity-bearing blob type must emit (camelCase, matching
// dashboard/types/spaxel.d.ts). tracking.Blob is the 2D tracked type that backs
// the dashboard WebSocket wire format; its canonical fields were added in bf-5151
// but left at zero values until a follow-up bead wires the BLE identity sidecar.
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
// TestBlob_CanonicalIdentitySerialization guards the three bf-5151 canonical
// identity fields on tracking.Blob. Per the task scope they are left at their Go
// zero values for existing blobs: with omitempty they must serialize as OMITTED
// (undefined in JS). IdentityResolved is *bool so that a non-nil false
// (resolution attempted, failed) is distinct from nil (unattempted) — omitempty
// only drops the nil case. This locks in default-handling consistency with the
// other identity-bearing blob types (signal.TrackedBlob, automation.TrackedBlob,
// tracker.Blob, volume.BlobPos, explainability.BlobSnapshot).
func TestBlob_CanonicalIdentitySerialization(t *testing.T) {
t.Parallel()
tests := []struct {
name string
blob Blob
wantKey map[string]bool // key → expected present in JSON
}{
{
name: "zero-value blob omits all three (undefined)",
blob: Blob{ID: 1, X: 1, Z: 2, Weight: 1},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": false,
},
},
{
name: "resolved identity emits camelCase keys",
blob: Blob{
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
IdentityResolved: ptrBoolTracking(true),
},
wantKey: map[string]bool{
"personName": true, "assignedColor": true, "identityResolved": true,
},
},
{
name: "failed resolution still emits identityResolved=false",
blob: Blob{ID: 3, IdentityResolved: ptrBoolTracking(false)},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := json.Marshal(tc.blob)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, k := range canonicalIdentityKeys {
got := strings.Contains(body, `"`+k+`"`)
if got != tc.wantKey[k] {
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
}
}
// Verify the tri-state value round-trips when present.
if tc.wantKey["identityResolved"] {
var decoded Blob
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.IdentityResolved == nil {
t.Fatalf("identityResolved unexpectedly nil after round-trip (body=%s)", body)
}
}
})
}
}
// ptrBoolTracking is a small helper for the *bool tri-state IdentityResolved field.
func ptrBoolTracking(b bool) *bool { return &b }

View file

@ -0,0 +1,89 @@
// Package volume provides tests for trigger volume geometry and point-in-volume testing.
package volume
import (
"encoding/json"
"strings"
"testing"
)
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields every
// identity-bearing blob type must emit (camelCase, matching
// dashboard/types/spaxel.d.ts). volume.BlobPos is the per-blob position fed to
// the trigger-volume evaluator; its canonical fields were added in bf-3wkz
// (aligned with dashboard camelCase) but left at zero values until a follow-up
// bead wires the BLE identity sidecar so person-aware volume triggers
// ("when Alice enters…") can fire.
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
// TestBlobPos_CanonicalIdentitySerialization guards the three canonical identity
// fields on volume.BlobPos. Per the task scope they are left at their Go zero
// values for existing blobs: with omitempty they must serialize as OMITTED
// (undefined in JS). IdentityResolved is *bool so that a non-nil false
// (resolution attempted, failed) is distinct from nil (unattempted) — omitempty
// only drops the nil case. This locks in default-handling consistency with the
// other identity-bearing blob types (signal.TrackedBlob, automation.TrackedBlob,
// tracker.Blob, tracking.Blob, explainability.BlobSnapshot).
func TestBlobPos_CanonicalIdentitySerialization(t *testing.T) {
t.Parallel()
tests := []struct {
name string
blob BlobPos
wantKey map[string]bool // key → expected present in JSON
}{
{
name: "zero-value blob omits all three (undefined)",
blob: BlobPos{ID: 1, X: 1, Y: 2, Z: 3},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": false,
},
},
{
name: "resolved identity emits camelCase keys",
blob: BlobPos{
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
IdentityResolved: ptrBoolVolume(true),
},
wantKey: map[string]bool{
"personName": true, "assignedColor": true, "identityResolved": true,
},
},
{
name: "failed resolution still emits identityResolved=false",
blob: BlobPos{ID: 3, IdentityResolved: ptrBoolVolume(false)},
wantKey: map[string]bool{
"personName": false, "assignedColor": false, "identityResolved": true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw, err := json.Marshal(tc.blob)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
for _, k := range canonicalIdentityKeys {
got := strings.Contains(body, `"`+k+`"`)
if got != tc.wantKey[k] {
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
}
}
// Verify the tri-state value round-trips when present.
if tc.wantKey["identityResolved"] {
var decoded BlobPos
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.IdentityResolved == nil {
t.Fatalf("identityResolved unexpectedly nil after round-trip (body=%s)", body)
}
}
})
}
}
// ptrBoolVolume is a small helper for the *bool tri-state IdentityResolved field.
func ptrBoolVolume(b bool) *bool { return &b }