Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
127 lines
3.6 KiB
Go
127 lines
3.6 KiB
Go
package contract
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func validVisit() Visit {
|
|
return Visit{EventID: "evt-1", OccurredAt: time.Now(), CameraID: "entrance"}
|
|
}
|
|
|
|
func TestTopicMustCarryClientAndSite(t *testing.T) {
|
|
got, err := ParseTopic("bv/acme.store1/visit")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Client != "acme" || got.Site != "store1" || got.Kind != "visit" {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
if got.Username != "acme.store1" {
|
|
t.Fatalf("username %q must match what the broker authenticated", got.Username)
|
|
}
|
|
}
|
|
|
|
func TestTopicWithExtraDotsIsRejected(t *testing.T) {
|
|
// Splitting on the FIRST dot would read "acme.store.1" as client "acme",
|
|
// site "store.1" - a different site than the broker authenticated, and a
|
|
// way to write rows against a tenant you do not own.
|
|
for _, topic := range []string{
|
|
"bv/acme.store.1/visit",
|
|
"bv/acme/visit",
|
|
"bv/.store1/visit",
|
|
"bv/acme./visit",
|
|
"other/acme.store1/visit",
|
|
"bv/acme.store1",
|
|
} {
|
|
if _, err := ParseTopic(topic); err == nil {
|
|
t.Errorf("%q was accepted", topic)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCommandSubtopicSurvivesParsing(t *testing.T) {
|
|
got, err := ParseTopic("bv/acme.store1/cmd/reload/now")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Kind != "cmd" || got.Rest != "reload/now" {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestEventIDIsRequired(t *testing.T) {
|
|
// It is the idempotency key. Without it an at-least-once redelivery
|
|
// silently doubles a store's footfall - the one number they pay for.
|
|
v := validVisit()
|
|
v.EventID = " "
|
|
assertPermanent(t, v.Validate(), "event_id")
|
|
}
|
|
|
|
func TestAFutureTimestampIsRejectedNotClamped(t *testing.T) {
|
|
// A site with a skewed clock would otherwise park a visit at the top of
|
|
// every "recent" report forever. Clamping it silently makes the report a
|
|
// lie that looks fine.
|
|
v := validVisit()
|
|
v.OccurredAt = time.Now().Add(72 * time.Hour)
|
|
assertPermanent(t, v.Validate(), "clock")
|
|
}
|
|
|
|
func TestSlightlyFutureIsToleratedForClockSkew(t *testing.T) {
|
|
v := validVisit()
|
|
v.OccurredAt = time.Now().Add(30 * time.Minute)
|
|
if err := v.Validate(); err != nil {
|
|
t.Fatalf("ordinary clock skew rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWrongLengthEmbeddingIsRejected(t *testing.T) {
|
|
// A wrong-length vector cannot be compared with anything and would sit in
|
|
// the gallery poisoning every future search.
|
|
v := validVisit()
|
|
v.Model = "w600k_r50.onnx"
|
|
v.Embedding = make([]float32, 128)
|
|
assertPermanent(t, v.Validate(), "dimensions")
|
|
}
|
|
|
|
func TestEmbeddingWithoutAModelTagIsRejected(t *testing.T) {
|
|
// Vectors from different encoders occupy different spaces. An untagged one
|
|
// cannot be stored safely because nothing later can tell what it is.
|
|
v := validVisit()
|
|
v.Embedding = make([]float32, EmbeddingDim)
|
|
assertPermanent(t, v.Validate(), "model tag")
|
|
}
|
|
|
|
func TestAVisitWithNoEmbeddingIsValid(t *testing.T) {
|
|
// A site may be configured to keep templates local and send only counts.
|
|
v := validVisit()
|
|
if err := v.Validate(); err != nil {
|
|
t.Fatalf("counts-only visit rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidationFailuresArePermanentSoTheQueueDrains(t *testing.T) {
|
|
// If a malformed message were retried forever, one bad payload at the head
|
|
// would stop every good one behind it - the same failure the agent's spool
|
|
// quarantine exists to prevent.
|
|
v := Visit{}
|
|
if !errors.Is(v.Validate(), ErrPermanent) {
|
|
t.Fatal("a malformed visit was not marked permanent")
|
|
}
|
|
}
|
|
|
|
func assertPermanent(t *testing.T, err error, want string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("expected an error mentioning %q", want)
|
|
}
|
|
if !errors.Is(err, ErrPermanent) {
|
|
t.Fatalf("error is not permanent: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("error %q does not mention %q", err, want)
|
|
}
|
|
}
|