CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 30s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m44s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
tests/factories.py holds image_row/make_image/make_image_async/make_tag. The 17 byte-identical _img/_tag helpers (15 modules) now import them under their old names, so no call site changed. frontend/test/support/stubFetch.js replaces 15 copies that differed only in formatting. Copies whose bodies differ (other defaults, other columns, a url-only stub) are left as they are; folding those needs a look at each caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
121 lines
3.8 KiB
Python
121 lines
3.8 KiB
Python
"""Incremental head retraining (#1317 phase 2). The refit-decision + fingerprint
|
|
are split out sklearn-free (train_head itself needs scikit-learn), so they're
|
|
tested directly via db_sync."""
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models import (
|
|
ImageRecord,
|
|
MLSettings,
|
|
Tag,
|
|
TagHead,
|
|
TagKind,
|
|
TagSuggestionRejection,
|
|
)
|
|
from backend.app.models.tag import image_tag
|
|
from backend.app.services.ml.heads import (
|
|
_head_fingerprints,
|
|
_heads_needing_retrain,
|
|
)
|
|
from tests.factories import make_image as _img
|
|
from tests.factories import make_tag as _tag
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
def _apply(db, image_id: int, tag_id: int) -> None:
|
|
db.execute(image_tag.insert().values(
|
|
image_record_id=image_id, tag_id=tag_id, source="manual",
|
|
))
|
|
|
|
|
|
def _reject(db, image_id: int, tag_id: int) -> None:
|
|
db.add(TagSuggestionRejection(image_record_id=image_id, tag_id=tag_id))
|
|
db.flush()
|
|
|
|
|
|
def _version(db) -> str:
|
|
return db.execute(
|
|
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
|
|
).scalar_one()
|
|
|
|
|
|
def _head(db, tag_id: int, fp: str | None, version: str) -> None:
|
|
db.add(TagHead(
|
|
tag_id=tag_id, embedding_version=version,
|
|
weights=[0.0] * 1152, 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, train_fingerprint=fp,
|
|
))
|
|
db.flush()
|
|
|
|
|
|
def test_fingerprint_changes_on_new_positive(db_sync):
|
|
tag = _tag(db_sync, "glasses")
|
|
i1 = _img(db_sync, "a" * 64)
|
|
_apply(db_sync, i1.id, tag.id)
|
|
db_sync.commit()
|
|
fp1 = _head_fingerprints(db_sync, [tag.id])[tag.id]
|
|
|
|
i2 = _img(db_sync, "b" * 64)
|
|
_apply(db_sync, i2.id, tag.id)
|
|
db_sync.commit()
|
|
assert _head_fingerprints(db_sync, [tag.id])[tag.id] != fp1
|
|
|
|
|
|
def test_fingerprint_changes_on_new_rejection(db_sync):
|
|
tag = _tag(db_sync, "glasses")
|
|
i1 = _img(db_sync, "c" * 64)
|
|
_apply(db_sync, i1.id, tag.id)
|
|
db_sync.commit()
|
|
fp1 = _head_fingerprints(db_sync, [tag.id])[tag.id]
|
|
|
|
_reject(db_sync, i1.id, tag.id)
|
|
db_sync.commit()
|
|
assert _head_fingerprints(db_sync, [tag.id])[tag.id] != fp1
|
|
|
|
|
|
def test_needing_retrain_selects_only_changed(db_sync):
|
|
ver = _version(db_sync)
|
|
a = _tag(db_sync, "A")
|
|
_apply(db_sync, _img(db_sync, "d" * 64).id, a.id)
|
|
b = _tag(db_sync, "B")
|
|
_apply(db_sync, _img(db_sync, "e" * 64).id, b.id)
|
|
c = _tag(db_sync, "C")
|
|
_apply(db_sync, _img(db_sync, "f" * 64).id, c.id)
|
|
db_sync.commit()
|
|
|
|
ids = [a.id, b.id, c.id]
|
|
fps = _head_fingerprints(db_sync, ids)
|
|
_head(db_sync, a.id, fps[a.id], ver) # A: head with CURRENT fp → skip
|
|
_head(db_sync, b.id, "stale", ver) # B: head with STALE fp → retrain
|
|
db_sync.commit() # C: no head → retrain
|
|
|
|
need = set(_heads_needing_retrain(db_sync, ids, ver, fps, full=False))
|
|
assert a.id not in need
|
|
assert {b.id, c.id} <= need
|
|
|
|
|
|
def test_stale_embedding_version_forces_retrain(db_sync):
|
|
ver = _version(db_sync)
|
|
a = _tag(db_sync, "A")
|
|
_apply(db_sync, _img(db_sync, "g" * 64).id, a.id)
|
|
db_sync.commit()
|
|
fps = _head_fingerprints(db_sync, [a.id])
|
|
# Matching fingerprint but a DIFFERENT embedding space → must refit.
|
|
_head(db_sync, a.id, fps[a.id], "old-model-v0")
|
|
db_sync.commit()
|
|
assert a.id in set(_heads_needing_retrain(db_sync, [a.id], ver, fps, full=False))
|
|
|
|
|
|
def test_full_forces_all(db_sync):
|
|
ver = _version(db_sync)
|
|
a = _tag(db_sync, "A")
|
|
_apply(db_sync, _img(db_sync, "h" * 64).id, a.id)
|
|
db_sync.commit()
|
|
fps = _head_fingerprints(db_sync, [a.id])
|
|
_head(db_sync, a.id, fps[a.id], ver) # current fp → would be skipped
|
|
db_sync.commit()
|
|
# full=True ignores the fingerprint (nightly reconcile).
|
|
assert a.id in set(_heads_needing_retrain(db_sync, [a.id], ver, fps, full=True))
|