refactor(ml): remove the dead per-tag centroid subsystem (#1189)
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user