refactor(ml): remove the dead per-tag centroid subsystem (#1189)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m32s

The v2 pivot replaced per-tag SigLIP centroids with learned heads + CCIP.
Centroids were still recomputed (on every tag merge + a daily beat) but NOTHING
read them — suggestions come from heads+CCIP and apply_allowlist_tags applies
via Camie predictions, not centroids. Pure dead wiring; remove it.

Removed: CentroidService, recompute_centroid/recompute_centroids tasks, the
daily beat, POST /api/ml/recompute-centroids, the recompute-on-merge trigger,
the tag_reference_embedding table + model, the centroid_similarity_threshold +
min_reference_images settings (migration 0066), the CentroidRecomputeCard +
its store action + MaintenancePanel tile, and the centroid slider in
MLThresholdSliders. _keep_as_alias drops its vestigial has-centroid branch (the
allowlist branch already covers "could re-emit"); tag merge no longer clears a
table that no longer exists.

NOT touched (still live, parallel to heads): the Camie tagger, ImagePrediction,
and the allowlist bulk-apply — accepting a suggestion still allowlists + applies
it across the library. The tag-eval "centroid" baseline metric is unrelated
(in-memory) and stays. (image_record.centroid_scores JSON column also remains —
separate legacy field, its own micro-cleanup.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-30 11:48:09 -04:00
parent 4daa3f2790
commit 3d77a38a25
19 changed files with 78 additions and 508 deletions
+1 -3
View File
@@ -107,11 +107,9 @@ async def test_video_min_tag_frames_above_max_rejected(client):
@pytest.mark.asyncio
async def test_backfill_and_recompute_trigger(client):
async def test_backfill_trigger(client):
r1 = await client.post("/api/ml/backfill")
assert r1.status_code == 202
r2 = await client.post("/api/ml/recompute-centroids")
assert r2.status_code == 202
@pytest.mark.asyncio
-8
View File
@@ -6,7 +6,6 @@ from backend.app.models import (
MLSettings,
TagAlias,
TagAllowlist,
TagReferenceEmbedding,
TagSuggestionRejection,
)
@@ -16,7 +15,6 @@ def test_new_tables_registered():
"tag_allowlist",
"tag_suggestion_rejection",
"tag_alias",
"tag_reference_embedding",
"ml_settings",
}
assert expected.issubset(Base.metadata.tables.keys())
@@ -42,12 +40,6 @@ def test_ml_settings_singleton_constraint():
assert "ck_ml_settings_singleton" in names
def test_tag_reference_embedding_has_vector():
cols = {c.name for c in TagReferenceEmbedding.__table__.columns}
assert "embedding" in cols
assert "reference_count" in cols
def test_tag_allowlist_confidence_check():
names = {c.name for c in TagAllowlist.__table__.constraints}
assert "ck_tag_allowlist_confidence_range" in names
-6
View File
@@ -8,12 +8,6 @@ def test_artist_not_surfaced():
assert "artist" not in SURFACED_CATEGORIES
def test_artist_not_centroid_eligible():
from backend.app.models import TagKind
from backend.app.services.ml.centroids import ELIGIBLE_KINDS
assert TagKind.artist not in ELIGIBLE_KINDS
def test_artist_not_head_eligible():
# Tagging-v2: suggestions come from heads, and heads are only trained for
# general/character concepts — so 'artist' (and any other kind) can't surface.
-112
View File
@@ -1,112 +0,0 @@
import numpy as np
import pytest
from backend.app.models import ImageRecord, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.ml.centroids import CentroidService
from backend.app.services.tag_service import TagService
pytestmark = pytest.mark.integration
def _img(sha: str, embedding: list[float] | None) -> ImageRecord:
return 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=embedding,
)
async def _attach(db, image_id: int, tag_id: int):
await db.execute(
image_tag.insert().values(
image_record_id=image_id, tag_id=tag_id, source="manual"
)
)
@pytest.mark.asyncio
async def test_recompute_skips_too_few_members(db):
tags = TagService(db)
tag = await tags.find_or_create("Lonely", TagKind.character)
img = _img("a" * 64, [0.1] * 1152)
db.add(img)
await db.flush()
await _attach(db, img.id, tag.id)
svc = CentroidService(db)
assert await svc.recompute_for_tag(tag.id) is False
@pytest.mark.asyncio
async def test_recompute_writes_centroid(db):
tags = TagService(db)
tag = await tags.find_or_create("Popular", TagKind.character)
for i in range(5):
img = _img(f"{i:064d}", [float(i)] * 1152)
db.add(img)
await db.flush()
await _attach(db, img.id, tag.id)
svc = CentroidService(db)
assert await svc.recompute_for_tag(tag.id) is True
from backend.app.models import TagReferenceEmbedding
cen = await db.get(TagReferenceEmbedding, tag.id)
assert cen is not None
assert cen.reference_count == 5
assert abs(np.array(cen.embedding)[0] - 2.0) < 1e-4
@pytest.mark.asyncio
async def test_recompute_skips_ineligible_kind(db):
tags = TagService(db)
tag = await tags.find_or_create("somearchive", TagKind.archive)
for i in range(5):
img = _img(f"arch{i:060d}", [1.0] * 1152)
db.add(img)
await db.flush()
await _attach(db, img.id, tag.id)
svc = CentroidService(db)
assert await svc.recompute_for_tag(tag.id) is False
@pytest.mark.asyncio
async def test_list_drifted_includes_uncomputed(db):
tags = TagService(db)
tag = await tags.find_or_create("Drifty", TagKind.character)
for i in range(5):
img = _img(f"d{i:063d}", [0.5] * 1152)
db.add(img)
await db.flush()
await _attach(db, img.id, tag.id)
svc = CentroidService(db)
drifted = await svc.list_drifted()
assert tag.id in drifted
@pytest.mark.asyncio
async def test_find_similar_tags(db):
tags = TagService(db)
tag = await tags.find_or_create("SimTag", TagKind.character)
for i in range(5):
img = _img(f"s{i:063d}", [1.0] * 1152)
db.add(img)
await db.flush()
await _attach(db, img.id, tag.id)
svc = CentroidService(db)
await svc.recompute_for_tag(tag.id)
query_img = _img("q" * 64, [1.0] * 1152)
db.add(query_img)
await db.flush()
hits = await svc.find_similar_tags(query_img.id, limit=10)
assert any(h.tag_id == tag.id for h in hits)
sim = next(h.similarity for h in hits if h.tag_id == tag.id)
assert sim > 0.99
-28
View File
@@ -279,34 +279,6 @@ async def test_merge_allowlist_source_only_moves_to_target(db):
assert rows[0].min_confidence == 0.42
@pytest.mark.asyncio
async def test_merge_deletes_source_embedding(db):
from backend.app.models.tag_reference_embedding import (
TagReferenceEmbedding,
)
svc = TagService(db)
a = await svc.find_or_create("SrcEmb", TagKind.general)
b = await svc.find_or_create("TgtEmb", TagKind.general)
db.add(
TagReferenceEmbedding(
tag_id=a.id,
embedding=[0.0] * 1152,
reference_count=1,
model_version="v",
)
)
await db.flush()
await svc.merge(a.id, b.id)
db.expire_all() # merge() uses Core DML; drop stale identity-map state
remaining = await db.scalar(
select(func.count())
.select_from(TagReferenceEmbedding)
.where(TagReferenceEmbedding.tag_id == a.id)
)
assert remaining == 0
@pytest.mark.asyncio
async def test_merge_repoints_existing_aliases(db):
from backend.app.models.tag_alias import TagAlias