"""Heads API + scoring (#114). Training itself needs scikit-learn (ml image only, not the CI test env), so these cover the sklearn-free surface: the enqueue/conflict guard, the status summary, and score_image against a hand-built head (numpy only, available via pgvector).""" import math import pytest from backend.app.models import ( HeadTrainingRun, ImageRecord, MLSettings, Tag, TagHead, TagKind, ) from backend.app.services.ml.heads import score_image from backend.app.services.tag_service import TagService pytestmark = pytest.mark.integration async def _img_with_embedding(db, sha, emb): rec = ImageRecord( path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", width=1, height=1, origin="imported_filesystem", integrity_status="unknown", siglip_embedding=emb, ) db.add(rec) await db.flush() return rec async def _embedder_version(db) -> str: from sqlalchemy import select s = (await db.execute(select(MLSettings).where(MLSettings.id == 1))).scalar_one() return s.embedder_model_version @pytest.mark.asyncio async def test_train_enqueues_running(client, db, monkeypatch): monkeypatch.setattr( "backend.app.tasks.ml.train_heads.delay", lambda *a, **k: None ) resp = await client.post("/api/heads/train", json={}) assert resp.status_code == 202 body = await resp.get_json() assert body["status"] == "running" got = await db.get(HeadTrainingRun, body["run_id"]) assert got is not None and got.status == "running" @pytest.mark.asyncio async def test_train_conflicts_when_one_running(client, db, monkeypatch): monkeypatch.setattr( "backend.app.tasks.ml.train_heads.delay", lambda *a, **k: None ) db.add(HeadTrainingRun(params={}, status="running")) await db.flush() await db.commit() resp = await client.post("/api/heads/train", json={}) assert resp.status_code == 409 body = await resp.get_json() assert body["error"] == "training_already_running" @pytest.mark.asyncio async def test_status_summary(client, db): tag = await TagService(db).find_or_create("glasses", TagKind.general) db.add(TagHead( tag_id=tag.id, embedding_version=await _embedder_version(db), weights=[0.0] * 1152, bias=0.0, suggest_threshold=0.5, auto_apply_threshold=0.9, n_pos=30, n_neg=90, ap=0.88, precision_cv=0.95, recall=0.7, )) await db.commit() resp = await client.get("/api/heads") assert resp.status_code == 200 body = await resp.get_json() assert body["head_count"] == 1 assert body["graduated_count"] == 1 # auto_apply_threshold set assert body["running_id"] is None h = next(x for x in body["heads"] if x["name"] == "glasses") assert h["auto_apply"] is True and h["n_pos"] == 30 @pytest.mark.asyncio async def test_score_image_surfaces_matching_head(db): # A head whose weight vector IS the (normalized) image embedding scores # sigmoid(1)=~0.73 >= 0.5 → surfaced. A second image orthogonal to it isn't. emb = [0.0] * 1152 emb[0] = 3.0 # ||emb|| = 3 → x̂ = e0 img = await _img_with_embedding(db, "a" * 64, emb) other = [0.0] * 1152 other[1] = 5.0 img2 = await _img_with_embedding(db, "b" * 64, other) tag = await TagService(db).find_or_create("cat", TagKind.general) weights = [0.0] * 1152 weights[0] = 1.0 # unit vector along e0 == x̂ of img db.add(TagHead( tag_id=tag.id, embedding_version=await _embedder_version(db), weights=weights, bias=0.0, suggest_threshold=0.5, auto_apply_threshold=None, n_pos=10, n_neg=30, ap=0.8, precision_cv=0.9, recall=0.6, )) await db.commit() hits = await score_image(db, img.id) assert len(hits) == 1 assert hits[0]["tag_id"] == tag.id assert hits[0]["category"] == "general" assert hits[0]["score"] == pytest.approx(1 / (1 + math.exp(-1.0)), abs=1e-3) # Orthogonal image: w·x̂ = 0 → sigmoid(0)=0.5; not > threshold strictly? It's # == 0.5 so it passes >=; assert it's at the boundary rather than surfaced # high. (Kept distinct from img's clear hit.) hits2 = await score_image(db, img2.id) assert all(h["score"] <= 0.5 for h in hits2)