Files
Behavision/tests/test_api_merge.py
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
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
2026-09-04 11:14:18 +05:30

177 lines
6.1 KiB
Python

"""Merge endpoints over HTTP against a real gallery on a temp database.
The gallery here is real (SQLite + numpy index, no models) because the point
of these tests is the refusal contract the UI depends on: a refused merge must
come back as a 409 carrying the measured similarity, so the operator can be
shown what they are being asked to override.
"""
import threading
import numpy as np
import pytest
pytest.importorskip("httpx")
from fastapi.testclient import TestClient # noqa: E402
from behavision.api import create_app # noqa: E402
from behavision.cameras import CameraStore # noqa: E402
from behavision.config import Config # noqa: E402
from behavision.engine import Engine # noqa: E402
from behavision.events import EventBus # noqa: E402
from behavision.gallery import Gallery, IdentityStore, VectorIndex # noqa: E402
DIM = 16
def _unit(seed):
rng = np.random.default_rng(seed)
v = rng.normal(size=DIM).astype(np.float32)
return v / np.linalg.norm(v)
def _at_similarity(base, target, seed=99):
other = _unit(seed)
other -= (other @ base) * base
other /= np.linalg.norm(other)
v = target * base + np.sqrt(1 - target ** 2) * other
return (v / np.linalg.norm(v)).astype(np.float32)
@pytest.fixture
def client(tmp_path):
eng = object.__new__(Engine)
eng.cfg = Config()
eng.cfg.app.data_dir = tmp_path
eng.cfg.api.username = eng.cfg.api.password = ""
eng.cfg.recognition.sighting_cooldown_seconds = 0.0
eng.store = IdentityStore(tmp_path / "api.db")
eng.gallery = Gallery(eng.store, VectorIndex(DIM), eng.cfg.recognition)
eng.bus = EventBus()
eng.encoder = eng.attributes = None
eng._lock = threading.RLock()
eng.workers, eng.detectors = {}, {}
eng.started_at, eng._running = 1.0, True
eng.camera_store = CameraStore(tmp_path / "cameras.json")
yield TestClient(create_app(eng)), eng
eng.store.close()
def test_merge_succeeds_and_emits_an_audit_event(client):
c, eng = client
base = _unit(1)
a = eng.gallery.enroll("Alice", [base])
b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)])
r = c.post(f"/api/identities/{b}/merge", json={"into": a})
assert r.status_code == 200
assert r.json()["ok"] is True
assert r.json()["similarity"] == pytest.approx(0.50, abs=0.01)
assert len(c.get("/api/identities").json()) == 1
# Merging is destructive and irreversible; it has to leave a trace.
types = [e["type"] for e in eng.bus.recent]
assert "identity.merged" in types
def test_refusal_is_409_and_reports_the_similarity(client):
"""The UI needs the number, not just a failure — it is what justifies
offering the force override to a human."""
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)])
r = c.post(f"/api/identities/{b}/merge", json={"into": a})
assert r.status_code == 409
detail = r.json()["detail"]
assert detail["ok"] is False
assert detail["similarity"] == pytest.approx(0.05, abs=0.01)
assert detail["threshold"] == eng.cfg.recognition.enroll_threshold
assert len(c.get("/api/identities").json()) == 2
def test_force_is_not_the_default(client):
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)])
assert c.post(f"/api/identities/{b}/merge",
json={"into": a}).status_code == 409
r = c.post(f"/api/identities/{b}/merge", json={"into": a, "force": True})
assert r.status_code == 200
assert r.json()["forced"] is True
def test_missing_identity_is_404(client):
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
r = c.post(f"/api/identities/9999/merge", json={"into": a})
assert r.status_code == 404
def test_self_merge_is_409(client):
c, eng = client
a = eng.gallery.enroll("Alice", [_unit(1)])
assert c.post(f"/api/identities/{a}/merge",
json={"into": a}).status_code == 409
def test_duplicates_endpoint_lists_candidates(client):
c, eng = client
base = _unit(1)
a = eng.gallery.enroll("Alice", [base])
b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)])
pairs = c.get("/api/identities/duplicates").json()
assert len(pairs) == 1
assert {pairs[0]["a"]["id"], pairs[0]["b"]["id"]} == {a, b}
assert pairs[0]["confident"] is True # 0.50 clears match_threshold
def test_duplicates_route_is_not_shadowed_by_the_id_routes(client):
"""`/api/identities/duplicates` sits under a path whose siblings take an
int id; if it ever gets matched by one of those it returns 422, not a
list."""
c, _ = client
r = c.get("/api/identities/duplicates")
assert r.status_code == 200
assert isinstance(r.json(), list)
def test_embedding_endpoint_returns_the_best_view(client):
"""The server needs a vector to match across sites, and the event bus
deliberately does not carry one."""
c, eng = client
base = _unit(1)
poor = _at_similarity(base, 0.60, seed=5)
# enroll() defaults to quality 1.0, which would win regardless and make
# the assertion below vacuous.
ident = eng.gallery.enroll("Alice", [base], quality=0.50)
eng.store.add_embedding(ident, poor, 0.20, eng.gallery.model_name)
eng.store.add_embedding(ident, base, 0.91, eng.gallery.model_name)
r = c.get(f"/api/identities/{ident}/embedding")
assert r.status_code == 200
body = r.json()
assert body["quality"] == 0.91, "did not pick the best view"
assert len(body["embedding"]) == DIM
assert body["model"] == eng.gallery.model_name
def test_embedding_endpoint_404s_for_an_unknown_identity(client):
c, _ = client
assert c.get("/api/identities/9999/embedding").status_code == 404
def test_embedding_endpoint_404s_when_the_model_differs(client):
"""Vectors from another encoder are not comparable, so returning one would
hand the server a template it must not use."""
c, eng = client
ident = eng.store.create_identity("Bob")
eng.store.add_embedding(ident, _unit(3), 0.9, "some_other_model")
assert c.get(f"/api/identities/{ident}/embedding").status_code == 404