diff --git a/alembic/versions/0044_ml_settings_tagger_store_floor.py b/alembic/versions/0044_ml_settings_tagger_store_floor.py new file mode 100644 index 0000000..e019e36 --- /dev/null +++ b/alembic/versions/0044_ml_settings_tagger_store_floor.py @@ -0,0 +1,37 @@ +"""ml_settings.tagger_store_floor + +The ingest confidence floor below which tagger predictions are not stored, +promoted from the TAGGER_STORE_FLOOR env var to a DB-backed, UI-tunable +setting. Default 0.70 (was an env default of 0.05): the suggestion path +already filters at 0.70 and the centroid/learned path covers low-confidence +preferred tags, so the sub-0.70 tail was redundant weight — it had grown +image_record's TOAST to ~100 GB. See plan-task #764. + +Revision ID: 0044 +Revises: 0043 +Create Date: 2026-06-10 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0044" +down_revision: Union[str, None] = "0043" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "ml_settings", + sa.Column( + "tagger_store_floor", sa.Float(), + nullable=False, server_default="0.7", + ), + ) + + +def downgrade() -> None: + op.drop_column("ml_settings", "tagger_store_floor") diff --git a/backend/app/api/ml_admin.py b/backend/app/api/ml_admin.py index 574e6f5..ef3d2fa 100644 --- a/backend/app/api/ml_admin.py +++ b/backend/app/api/ml_admin.py @@ -13,6 +13,7 @@ _EDITABLE = ( "suggestion_threshold_general", "centroid_similarity_threshold", "min_reference_images", + "tagger_store_floor", ) @@ -30,6 +31,7 @@ async def get_settings(): "suggestion_threshold_general": s.suggestion_threshold_general, "centroid_similarity_threshold": s.centroid_similarity_threshold, "min_reference_images": s.min_reference_images, + "tagger_store_floor": s.tagger_store_floor, "tagger_model_version": s.tagger_model_version, "embedder_model_version": s.embedder_model_version, } @@ -47,13 +49,45 @@ async def patch_settings(): s = ( await session.execute(select(MLSettings).where(MLSettings.id == 1)) ).scalar_one() + + # Merge the patch over current values, then validate the result as a + # whole — the store-floor invariant couples three fields, so they + # can't be checked one at a time. + proposed = {f: getattr(s, f) for f in _EDITABLE} for field in _EDITABLE: if field in body: - setattr(s, field, body[field]) + proposed[field] = body[field] + + err = _validate(proposed) + if err is not None: + return jsonify({"error": err}), 400 + + for field in _EDITABLE: + setattr(s, field, proposed[field]) await session.commit() return await get_settings() +def _validate(p: dict) -> str | None: + """Returns an error string if the proposed settings are invalid, else None. + + Invariant (plan-task #764): the per-category suggestion thresholds can't + drop below tagger_store_floor — nothing below the floor is stored, so a + lower threshold would silently surface nothing in that gap. The UI clamps + the sliders to the floor; this is the server-side backstop. + """ + floor = p["tagger_store_floor"] + if not (0.0 <= floor <= 1.0): + return "tagger_store_floor must be between 0 and 1" + for cat in ("character", "general"): + if p[f"suggestion_threshold_{cat}"] < floor: + return ( + f"suggestion_threshold_{cat} cannot be below tagger_store_floor " + f"({floor}) — predictions below the floor are not stored" + ) + return None + + @ml_admin_bp.route("/backfill", methods=["POST"]) async def trigger_backfill(): from ..tasks.ml import backfill diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index 5c3e858..e28686d 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -28,6 +28,15 @@ class MLSettings(Base): centroid_similarity_threshold: Mapped[float] = mapped_column( Float, nullable=False, default=0.55 ) + # Ingest floor: tagger predictions below this confidence are not stored + # (tagger.Tagger.infer). Default 0.70 — the suggestion path already + # filters at 0.70 and the centroid/learned path covers low-confidence + # preferred tags, so the sub-0.70 tail is redundant weight (it had + # bloated image_record's TOAST to ~100 GB; plan-task #764). Operator- + # tunable via Settings → ML; must stay ≤ the suggestion thresholds. + tagger_store_floor: Mapped[float] = mapped_column( + Float, nullable=False, default=0.70 + ) min_reference_images: Mapped[int] = mapped_column( Integer, nullable=False, default=5 ) diff --git a/backend/app/services/ml/tagger.py b/backend/app/services/ml/tagger.py index ddf17b9..6348595 100644 --- a/backend/app/services/ml/tagger.py +++ b/backend/app/services/ml/tagger.py @@ -33,8 +33,13 @@ _MODEL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "camie" _MODEL_FILE = f"{MODEL_NAME}.onnx" _METADATA_FILE = f"{MODEL_NAME}-metadata.json" -# Below this confidence, predictions aren't stored (keeps the JSON compact). -STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05")) +# Ingest floor below which predictions aren't stored (keeps the JSON compact). +# DEFAULT/fallback only — the live value is DB-backed +# (ml_settings.tagger_store_floor) and passed into infer() per call by the ml +# task. 0.70: the suggestion path already filters there and the centroid path +# covers lower-confidence preferred tags, so the sub-0.70 tail is redundant +# (it had bloated image_record's TOAST to ~100 GB; plan-task #764). +DEFAULT_STORE_FLOOR = 0.70 # The categories FC-2b surfaces in the UI. Others (meta/rating/year) are # still stored but the suggestion service filters them out. @@ -145,10 +150,13 @@ class Tagger: arr = arr.transpose(2, 0, 1) # HWC -> CHW return arr[np.newaxis, :, :, :] # NCHW - def infer(self, image_path: Path) -> dict[str, TagPrediction]: + def infer( + self, image_path: Path, *, store_floor: float = DEFAULT_STORE_FLOOR, + ) -> dict[str, TagPrediction]: """Run Camie v2 on one image. Returns {name: TagPrediction} with - confidence >= STORE_FLOOR (across all categories — the suggestion - service does category filtering later). + confidence >= store_floor (across all categories — the suggestion + service does category filtering later). store_floor is the DB-backed + ml_settings.tagger_store_floor, passed in by the ml task. v2 emits multiple outputs; we use the refined predictions (output[1] per onnx_inference.py). Sigmoid is applied to raw @@ -167,7 +175,7 @@ class Tagger: cats = self._tag_categories for idx, score in enumerate(probs): conf = float(score) - if conf < STORE_FLOOR: + if conf < store_floor: continue if idx >= len(names): # Output longer than metadata declared — shouldn't happen but diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 7703447..ff09258 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -127,7 +127,10 @@ def tag_and_embed(self, image_id: int) -> dict: phase = "video_infer" import numpy as np - preds = _maxpool_predictions([tagger.infer(f) for f in frames]) + preds = _maxpool_predictions( + [tagger.infer(f, store_floor=settings.tagger_store_floor) + for f in frames] + ) embedding = np.mean( [embedder.infer(f) for f in frames], axis=0 ).astype("float32") @@ -136,7 +139,7 @@ def tag_and_embed(self, image_id: int) -> dict: else: phase = "tag" t0 = time.monotonic() - raw = tagger.infer(src) + raw = tagger.infer(src, store_floor=settings.tagger_store_floor) log.info( "tag_and_embed tagged in %.1fs (%d tags): %s", time.monotonic() - t0, len(raw), ctx, diff --git a/frontend/src/components/settings/MLThresholdSliders.vue b/frontend/src/components/settings/MLThresholdSliders.vue index 70f9a37..11bc76b 100644 --- a/frontend/src/components/settings/MLThresholdSliders.vue +++ b/frontend/src/components/settings/MLThresholdSliders.vue @@ -6,9 +6,28 @@ + + + + + + + + +
+ Tagger predictions below this confidence aren't stored — raising it + keeps the image library lean. Suggestions can't be shown below the + floor; lower-confidence tags you actually want still surface through + the learned centroid path. +
@@ -24,17 +43,25 @@ import { useMLStore } from '../../stores/ml.js' const store = useMLStore() // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as // suggestion categories; their threshold rows are gone. +// floorMin: the per-category suggestion thresholds can't drop below the +// tagger store floor (nothing below the floor is stored to surface). const fields = [ - { key: 'suggestion_threshold_character', label: 'Character' }, - { key: 'suggestion_threshold_general', label: 'General' }, + { key: 'suggestion_threshold_character', label: 'Character', floorMin: true }, + { key: 'suggestion_threshold_general', label: 'General', floorMin: true }, { key: 'centroid_similarity_threshold', label: 'Centroid similarity' } ] const local = reactive({}) watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true }) async function save() { + // Mirror the server invariant: keep the category thresholds at or above the + // store floor so a raised floor doesn't leave a threshold stranded below it. + const floor = local.tagger_store_floor + local.suggestion_threshold_character = Math.max(local.suggestion_threshold_character, floor) + local.suggestion_threshold_general = Math.max(local.suggestion_threshold_general, floor) const patch = {} for (const f of fields) patch[f.key] = local[f.key] + patch.tagger_store_floor = local.tagger_store_floor try { await store.patchSettings(patch) } catch (e) { toast({ text: e.message, type: 'error' }) } } diff --git a/tests/test_api_ml_admin.py b/tests/test_api_ml_admin.py index 1692743..76b1002 100644 --- a/tests/test_api_ml_admin.py +++ b/tests/test_api_ml_admin.py @@ -34,6 +34,28 @@ async def test_get_and_patch_settings(client): assert (await resp.get_json())["suggestion_threshold_general"] == pytest.approx(0.90) +@pytest.mark.asyncio +async def test_tagger_store_floor_default_and_patch(client): + body = await (await client.get("/api/ml/settings")).get_json() + assert body["tagger_store_floor"] == pytest.approx(0.70) + + resp = await client.patch("/api/ml/settings", json={"tagger_store_floor": 0.6}) + assert resp.status_code == 200 + assert (await resp.get_json())["tagger_store_floor"] == pytest.approx(0.6) + + +@pytest.mark.asyncio +async def test_suggestion_threshold_below_store_floor_rejected(client): + # Invariant (#764): a category threshold can't sit below the store floor — + # nothing below the floor is stored, so the gap would surface nothing. + # Floor defaults to 0.70; pushing general down to 0.50 must 400. + resp = await client.patch( + "/api/ml/settings", json={"suggestion_threshold_general": 0.50} + ) + assert resp.status_code == 400 + assert "tagger_store_floor" in (await resp.get_json())["error"] + + @pytest.mark.asyncio async def test_backfill_and_recompute_trigger(client): r1 = await client.post("/api/ml/backfill") diff --git a/tests/test_ml_tagger.py b/tests/test_ml_tagger.py index c36c64a..369ce62 100644 --- a/tests/test_ml_tagger.py +++ b/tests/test_ml_tagger.py @@ -1,14 +1,14 @@ """Tagger unit tests. The ONNX model isn't available in CI (it's a 1GB -download into /models), so these test the pure-logic surface: STORE_FLOOR -constant, SURFACED_CATEGORIES set, TagPrediction dataclass, and the -load()-missing-file error path. Full inference is exercised by the local -integration suite against a real /models volume. +download into /models), so these test the pure-logic surface: +DEFAULT_STORE_FLOOR constant, SURFACED_CATEGORIES set, TagPrediction +dataclass, and the load()-missing-file error path. Full inference is +exercised by the local integration suite against a real /models volume. """ import pytest from backend.app.services.ml.tagger import ( - STORE_FLOOR, + DEFAULT_STORE_FLOOR, SURFACED_CATEGORIES, Tagger, TagPrediction, @@ -26,8 +26,12 @@ def test_surfaced_categories(): assert "copyright" not in SURFACED_CATEGORIES -def test_store_floor_is_low(): - assert 0 < STORE_FLOOR < 0.2 +def test_default_store_floor(): + # Raised 0.05 → 0.70 (plan-task #764): the suggestion path filters at + # 0.70 and the centroid path covers lower-confidence preferred tags, so + # storing the sub-0.70 tail was redundant (100 GB of TOAST). The live + # value is DB-backed (ml_settings.tagger_store_floor); this is the default. + assert DEFAULT_STORE_FLOOR == 0.70 def test_tag_prediction_dataclass():