Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav #143

Merged
bvandeusen merged 8 commits from dev into main 2026-06-29 07:30:41 -04:00
11 changed files with 493 additions and 7 deletions
Showing only changes of commit 48c8811d69 - Show all commits
+1 -1
View File
@@ -49,7 +49,7 @@ def upgrade() -> None:
"ml_settings",
sa.Column(
"head_auto_apply_enabled", sa.Boolean(), nullable=False,
server_default=sa.false(),
server_default=sa.true(), # opt-out: on by default (operator-asked)
),
)
op.add_column(
+74
View File
@@ -0,0 +1,74 @@
"""head_metric + head_metrics_snapshot: auto-apply observability (#114)
Running misfire/under-fire counters per concept (captured at correction time,
since image_tag.source is lost on delete) + a daily per-concept time-series so
the operator can tune the precision target + support floor from real data.
Revision ID: 0060
Revises: 0059
Create Date: 2026-06-29
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0060"
down_revision: Union[str, None] = "0059"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"head_metric",
sa.Column(
"tag_id", sa.Integer(),
sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("n_misfires", sa.Integer(), nullable=False, server_default="0"),
sa.Column("n_underfires", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
)
op.create_table(
"head_metrics_snapshot",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"tag_id", sa.Integer(),
sa.ForeignKey("tag.id", ondelete="CASCADE"),
),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column(
"snapshot_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.Column("n_auto_applied", sa.Integer(), nullable=False, server_default="0"),
sa.Column("n_misfires", sa.Integer(), nullable=False, server_default="0"),
sa.Column("n_underfires", sa.Integer(), nullable=False, server_default="0"),
sa.Column("ap", sa.Float(), nullable=True),
sa.Column("precision_cv", sa.Float(), nullable=True),
sa.Column("recall", sa.Float(), nullable=True),
sa.Column("n_pos", sa.Integer(), nullable=True),
)
op.create_index(
"ix_head_metrics_snapshot_tag_id", "head_metrics_snapshot", ["tag_id"],
)
op.create_index(
"ix_head_metrics_snapshot_snapshot_at", "head_metrics_snapshot",
["snapshot_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_head_metrics_snapshot_snapshot_at", table_name="head_metrics_snapshot"
)
op.drop_index(
"ix_head_metrics_snapshot_tag_id", table_name="head_metrics_snapshot"
)
op.drop_table("head_metrics_snapshot")
op.drop_table("head_metric")
+103 -1
View File
@@ -12,7 +12,15 @@ from quart import Blueprint, jsonify, request
from sqlalchemy import desc, func, select
from ..extensions import get_session
from ..models import HeadAutoApplyRun, HeadTrainingRun, Tag, TagHead
from ..models import (
HeadAutoApplyRun,
HeadMetric,
HeadMetricsSnapshot,
HeadTrainingRun,
Tag,
TagHead,
)
from ..models.tag import image_tag
from ..services.ml.heads import (
HeadAutoApplyAlreadyRunning,
HeadAutoApplyDisabled,
@@ -181,3 +189,97 @@ async def auto_apply_status():
"running_id": running,
"runs": [_serialize_apply_run(r) for r in runs],
})
@heads_bp.route("/metrics", methods=["GET"])
async def metrics():
"""Auto-apply observability: per-concept current counts (volume, misfires,
under-fires, realized misfire rate, head quality) + the daily time-series so
the operator can tune the precision target + support floor from real data."""
async with get_session() as session:
head_rows = (
await session.execute(
select(
TagHead.tag_id, Tag.name, TagHead.ap, TagHead.precision_cv,
TagHead.recall, TagHead.auto_apply_threshold, TagHead.n_pos,
).join(Tag, Tag.id == TagHead.tag_id)
)
).all()
heads = {r.tag_id: r for r in head_rows}
metric_rows = (
await session.execute(
select(
HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires
)
)
).all()
mets = {r.tag_id: r for r in metric_rows}
applied = dict(
(
await session.execute(
select(image_tag.c.tag_id, func.count())
.where(image_tag.c.source == "head_auto")
.group_by(image_tag.c.tag_id)
)
).all()
)
names = {r.tag_id: r.name for r in head_rows}
# Names for metric-only tags (head pruned but corrections recorded).
missing = [t for t in mets if t not in names]
if missing:
for tid, nm in (
await session.execute(
select(Tag.id, Tag.name).where(Tag.id.in_(missing))
)
).all():
names[tid] = nm
concepts = []
for tid in set(heads) | set(mets):
h = heads.get(tid)
m = mets.get(tid)
n_applied = applied.get(tid, 0)
n_mis = m.n_misfires if m else 0
denom = n_applied + n_mis
concepts.append({
"tag_id": tid,
"name": names.get(tid, str(tid)),
"n_auto_applied": n_applied,
"n_misfires": n_mis,
"n_underfires": m.n_underfires if m else 0,
# Of everything this head ever auto-applied, the fraction you
# removed — the misfire rate (null until something fired).
"misfire_rate": round(n_mis / denom, 4) if denom else None,
"ap": h.ap if h else None,
"precision_cv": h.precision_cv if h else None,
"recall": h.recall if h else None,
"auto_apply": bool(h and h.auto_apply_threshold is not None),
"n_pos": h.n_pos if h else None,
})
concepts.sort(key=lambda c: (c["n_misfires"], c["n_auto_applied"]), reverse=True)
snaps = (
await session.execute(
select(HeadMetricsSnapshot)
.order_by(HeadMetricsSnapshot.snapshot_at.desc())
.limit(1000)
)
).scalars().all()
return jsonify({
"concepts": concepts,
"snapshots": [
{
"tag_id": s.tag_id,
"name": s.name,
"snapshot_at": s.snapshot_at.isoformat() if s.snapshot_at else None,
"n_auto_applied": s.n_auto_applied,
"n_misfires": s.n_misfires,
"n_underfires": s.n_underfires,
"ap": s.ap,
"precision_cv": s.precision_cv,
"recall": s.recall,
"n_pos": s.n_pos,
}
for s in snaps
],
})
+4
View File
@@ -117,6 +117,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.ml.scheduled_apply_head_tags",
"schedule": 86400.0, # no-op unless head_auto_apply_enabled
},
"snapshot-head-metrics-daily": {
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"schedule": 86400.0,
},
"integrity-verify-weekly": {
"task": "backend.app.tasks.maintenance.verify_integrity",
"schedule": 604800.0, # weekly
+4
View File
@@ -9,6 +9,8 @@ from .credential import Credential
from .download_event import DownloadEvent
from .external_link import ExternalLink
from .head_auto_apply_run import HeadAutoApplyRun
from .head_metric import HeadMetric
from .head_metrics_snapshot import HeadMetricsSnapshot
from .head_training_run import HeadTrainingRun
from .image_prediction import ImagePrediction
from .image_provenance import ImageProvenance
@@ -69,6 +71,8 @@ __all__ = [
"LibraryAuditRun",
"MLSettings",
"HeadAutoApplyRun",
"HeadMetric",
"HeadMetricsSnapshot",
"HeadTrainingRun",
"TagAlias",
"TagAllowlist",
+32
View File
@@ -0,0 +1,32 @@
"""HeadMetric — running correction counters per concept (#114 observability).
Earned auto-apply fires graduated heads; to TUNE it we need to know how often a
head's auto-applied tag was wrong (the operator removed it = a MISFIRE) and how
often the operator had to add a tag a head exists for by hand (an UNDER-FIRE,
the head missed it). image_tag.source is lost when a row is deleted, so these
are captured as durable cumulative counters at correction time — they survive
head retrain/prune (keyed by tag, not by the head row). The daily snapshot reads
them into the time-series.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class HeadMetric(Base):
__tablename__ = "head_metric"
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
)
# An auto-applied (source='head_auto') tag the operator later REMOVED.
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# A tag with a head that the operator added by HAND (the head missed it).
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -0,0 +1,38 @@
"""HeadMetricsSnapshot — a daily per-concept time-series point (#114).
The "amount of change over time" reporting the operator asked for: once a day,
record each concept's auto-applied VOLUME (current head_auto tags), cumulative
misfires/under-fires, and the head's measured quality. Plotting these rows over
time shows whether auto-apply is landing better/worse and whether tagging more is
sharpening a concept — the signal for tuning the precision target + support floor.
"""
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class HeadMetricsSnapshot(Base):
__tablename__ = "head_metrics_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), index=True
)
# Denormalized so a snapshot stays readable even if the tag is later renamed.
name: Mapped[str] = mapped_column(String(255), nullable=False)
snapshot_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
# Current count of source='head_auto' applications still standing.
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# The head's measured quality at snapshot time (null if no head exists).
ap: Mapped[float | None] = mapped_column(Float, nullable=True)
precision_cv: Mapped[float | None] = mapped_column(Float, nullable=True)
recall: Mapped[float | None] = mapped_column(Float, nullable=True)
n_pos: Mapped[int | None] = mapped_column(Integer, nullable=True)
+5 -4
View File
@@ -75,12 +75,13 @@ class MLSettings(Base):
Float, nullable=False, default=0.97
)
# Earned auto-apply (#114). A graduated head fires (tags images without a
# human) ONLY when this master switch is on AND the head has at least
# human) when this master switch is on AND the head has at least
# head_auto_apply_min_positives clean labels — so a precise-looking but
# under-supported low-N head can't spray tags across the library. Off by
# default; the operator enables after previewing. Operator-tunable.
# under-supported low-N head can't spray tags across the library. ON by
# default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support +
# measured-precision gates keep it safe, and every auto-tag is reversible.
head_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
Boolean, nullable=False, default=True
)
head_auto_apply_min_positives: Mapped[int] = mapped_column(
Integer, nullable=False, default=30
+52 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import and_, case, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Tag, TagKind, image_tag
from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag
from ..models.tag_allowlist import TagAllowlist
from ..models.tag_reference_embedding import TagReferenceEmbedding
from .db_helpers import get_or_create
@@ -215,6 +215,18 @@ class TagService:
async def add_to_image(self, image_id: int, tag_id: int, source: str = "manual") -> None:
"""Idempotent: re-adding an existing tag does nothing."""
# A genuinely-new MANUAL add of a tag that already has a head is an
# UNDER-FIRE signal — the auto-system should have caught it (#114 obs).
is_new = source == "manual" and (
await self.session.execute(
select(image_tag.c.tag_id).where(
and_(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)
)
).first() is None
stmt = pg_insert(image_tag).values(
image_record_id=image_id, tag_id=tag_id, source=source
)
@@ -222,8 +234,22 @@ class TagService:
index_elements=["image_record_id", "tag_id"]
)
await self.session.execute(stmt)
if is_new:
await self._note_under_fire(tag_id)
async def remove_from_image(self, image_id: int, tag_id: int) -> None:
# Removing an auto-applied (source='head_auto') tag is a MISFIRE — read
# the source BEFORE deleting, since it's lost with the row (#114 obs).
src = (
await self.session.execute(
select(image_tag.c.source).where(
and_(
image_tag.c.image_record_id == image_id,
image_tag.c.tag_id == tag_id,
)
)
)
).scalar_one_or_none()
await self.session.execute(
image_tag.delete().where(
and_(
@@ -232,6 +258,31 @@ class TagService:
)
)
)
if src == "head_auto":
await self._bump_metric(tag_id, "n_misfires")
async def _note_under_fire(self, tag_id: int) -> None:
"""Count an under-fire only when the tag actually has a head."""
has_head = (
await self.session.execute(
select(TagHead.tag_id).where(TagHead.tag_id == tag_id)
)
).first() is not None
if has_head:
await self._bump_metric(tag_id, "n_underfires")
async def _bump_metric(self, tag_id: int, column: str) -> None:
"""Increment a HeadMetric counter (upsert), keyed by tag so it survives
head retrain/prune."""
col = HeadMetric.__table__.c[column]
await self.session.execute(
pg_insert(HeadMetric)
.values(tag_id=tag_id, **{column: 1})
.on_conflict_do_update(
index_elements=["tag_id"],
set_={column: col + 1, "updated_at": func.now()},
)
)
async def list_for_image(self, image_id: int) -> Sequence:
"""Tags on an image, ordered (kind, name). Each row carries the fandom's
+73
View File
@@ -846,6 +846,79 @@ def recover_stalled_head_auto_apply_runs() -> int:
return recovered
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
HEAD_METRICS_SNAPSHOT_RETENTION_DAYS = 180
@celery.task(name="backend.app.tasks.maintenance.snapshot_head_metrics")
def snapshot_head_metrics() -> int:
"""Daily per-concept observability point (#114): record each head-bearing
concept's auto-applied volume, cumulative misfires/under-fires, and the
head's measured quality — the time-series the operator tunes from. Prunes
points older than the retention window."""
from ..models import (
HeadMetric,
HeadMetricsSnapshot,
Tag,
TagHead,
)
from ..models.tag import image_tag
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
with SessionLocal() as session:
heads = {
r.tag_id: r for r in session.execute(
select(
TagHead.tag_id, TagHead.ap, TagHead.precision_cv,
TagHead.recall, TagHead.n_pos,
)
)
}
metrics = {
r.tag_id: r for r in session.execute(
select(
HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires
)
)
}
applied = dict(
session.execute(
select(image_tag.c.tag_id, func.count())
.where(image_tag.c.source == "head_auto")
.group_by(image_tag.c.tag_id)
)
)
tag_ids = set(heads) | set(metrics)
if not tag_ids:
return 0
names = dict(
session.execute(select(Tag.id, Tag.name).where(Tag.id.in_(tag_ids)))
)
for tid in tag_ids:
h = heads.get(tid)
m = metrics.get(tid)
session.add(HeadMetricsSnapshot(
tag_id=tid, name=names.get(tid, str(tid)),
snapshot_at=now,
n_auto_applied=applied.get(tid, 0),
n_misfires=m.n_misfires if m else 0,
n_underfires=m.n_underfires if m else 0,
ap=h.ap if h else None,
precision_cv=h.precision_cv if h else None,
recall=h.recall if h else None,
n_pos=h.n_pos if h else None,
))
session.execute(
delete(HeadMetricsSnapshot).where(
HeadMetricsSnapshot.snapshot_at
< now - timedelta(days=HEAD_METRICS_SNAPSHOT_RETENTION_DAYS)
)
)
session.commit()
return len(tag_ids)
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_import_batches")
def recover_stalled_import_batches() -> int:
"""Finalize ImportBatch rows stuck in running past the hard limit
+107
View File
@@ -0,0 +1,107 @@
"""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