Files
FabledCurator/tests/test_head_metrics.py
T
bvandeusen 48c8811d69
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Failing after 3m25s
feat(heads): auto-apply observability + on by default (#114 auto-apply B)
Auto-apply is now ON by default (operator-asked: opt-OUT, not opt-in) — migration
0059 + model default flipped. The support (>=30) + measured-precision gates keep
it safe and every auto-tag is reversible.

Observability so the operator can tune from real data:
- MISFIRE = an auto-applied (source='head_auto') tag the operator later removes.
  UNDER-FIRE = a tag with a head the operator adds by hand (the head missed it).
  Both captured at correction time in TagService.add_to_image/remove_from_image
  (source is lost on delete) into durable per-tag counters (head_metric), keyed
  by tag so they survive head retrain/prune.
- Daily snapshot_head_metrics writes a per-concept time-series point
  (head_metrics_snapshot): auto-applied volume + cumulative misfires/under-fires
  + head quality; 180-day retention; daily beat.
- GET /api/heads/metrics: per-concept current counts + realized misfire rate +
  head quality, plus the snapshot time-series — the report to tune the precision
  target + support floor.

Migration 0060. Tests: misfire/under-fire counting (and the negatives — manual
removal isn't a misfire, headless manual add isn't an under-fire), snapshot
time-series, metrics API.

What's the autofire threshold? There's no single number — each graduated head
derives its OWN probability cutoff from its PR curve: the operating point that
holds precision >= head_auto_apply_precision (0.97) at max recall. The global
knobs are that target + the >=30 support floor.

NEXT (slice 3): UI — enable toggle, dry-run preview, per-concept trends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-06-29 00:36:58 -04:00

108 lines
3.7 KiB
Python

"""Auto-apply observability (#114): misfire/under-fire counters captured on
operator corrections, the daily snapshot time-series, and the metrics API."""
import pytest
from sqlalchemy import select
from backend.app.models import HeadMetric, HeadMetricsSnapshot, ImageRecord, TagHead, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
async def _img(db, sha) -> ImageRecord:
img = ImageRecord(
path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg",
width=1, height=1, origin="imported_filesystem", integrity_status="unknown",
)
db.add(img)
await db.flush()
return img
def _head(tag_id):
return TagHead(
tag_id=tag_id, embedding_version="siglip-test", weights=[0.0] * 1152,
bias=0.0, suggest_threshold=0.5, auto_apply_threshold=0.6,
n_pos=30, n_neg=90, ap=0.9, precision_cv=0.95, recall=0.7,
)
@pytest.mark.asyncio
async def test_removing_head_auto_tag_counts_misfire(db):
img = await _img(db, "a" * 64)
tag = await TagService(db).find_or_create("misfire", TagKind.general)
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=tag.id, source="head_auto",
))
await db.commit()
await TagService(db).remove_from_image(img.id, tag.id)
await db.commit()
m = await db.get(HeadMetric, tag.id)
assert m is not None and m.n_misfires == 1 and m.n_underfires == 0
@pytest.mark.asyncio
async def test_removing_manual_tag_is_not_a_misfire(db):
img = await _img(db, "b" * 64)
tag = await TagService(db).find_or_create("manualrm", TagKind.general)
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=tag.id, source="manual",
))
await db.commit()
await TagService(db).remove_from_image(img.id, tag.id)
await db.commit()
assert await db.get(HeadMetric, tag.id) is None
@pytest.mark.asyncio
async def test_manual_add_with_head_counts_underfire(db):
img = await _img(db, "c" * 64)
tag = await TagService(db).find_or_create("underfire", TagKind.general)
db.add(_head(tag.id))
await db.commit()
await TagService(db).add_to_image(img.id, tag.id, source="manual")
await db.commit()
m = await db.get(HeadMetric, tag.id)
assert m is not None and m.n_underfires == 1
@pytest.mark.asyncio
async def test_manual_add_without_head_no_underfire(db):
img = await _img(db, "d" * 64)
tag = await TagService(db).find_or_create("nohead", TagKind.general)
await db.commit()
await TagService(db).add_to_image(img.id, tag.id, source="manual")
await db.commit()
assert await db.get(HeadMetric, tag.id) is None
@pytest.mark.asyncio
async def test_snapshot_records_timeseries_point(db):
tag = await TagService(db).find_or_create("snap", TagKind.general)
db.add(_head(tag.id))
await db.commit()
from backend.app.tasks.maintenance import snapshot_head_metrics
n = snapshot_head_metrics() # sync task, own session
assert n >= 1
snaps = (await db.execute(
select(HeadMetricsSnapshot).where(HeadMetricsSnapshot.tag_id == tag.id)
)).scalars().all()
assert len(snaps) == 1
assert snaps[0].name == "snap"
@pytest.mark.asyncio
async def test_metrics_api_returns_concept(client, db):
tag = await TagService(db).find_or_create("apimetric", TagKind.general)
db.add(_head(tag.id))
await db.commit()
resp = await client.get("/api/heads/metrics")
assert resp.status_code == 200
body = await resp.get_json()
c = next(x for x in body["concepts"] if x["name"] == "apimetric")
assert c["auto_apply"] is True
assert c["n_misfires"] == 0
assert "snapshots" in body