From cbc3e11a53860d9d12d5a674c741ec1024c0309d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 18:08:54 -0400 Subject: [PATCH 01/12] feat(ml): stricter auto-apply defaults to cut misfires (milestone 139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit head_auto_apply_precision 0.97→0.98, head_auto_apply_min_positives 30→50, ccip_auto_apply_threshold 0.92→0.95 (operator-asked). Model defaults change for fresh installs; migration 0081 bumps the existing singleton row IFF still at the old default (won't clobber a deliberate operator change). ml_admin bounds already permit these. Fixed a stale comment in the auto-apply test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- .../0081_stricter_auto_apply_defaults.py | 50 +++++++++++++++++++ backend/app/models/ml_settings.py | 13 +++-- tests/test_head_auto_apply.py | 2 +- 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/0081_stricter_auto_apply_defaults.py diff --git a/alembic/versions/0081_stricter_auto_apply_defaults.py b/alembic/versions/0081_stricter_auto_apply_defaults.py new file mode 100644 index 0000000..0a7f893 --- /dev/null +++ b/alembic/versions/0081_stricter_auto_apply_defaults.py @@ -0,0 +1,50 @@ +"""stricter auto-apply defaults (milestone 139) — cut graduate/auto-apply misfires + +head_auto_apply_precision 0.97→0.98, head_auto_apply_min_positives 30→50, +ccip_auto_apply_threshold 0.92→0.95 (operator-asked 2026-07-06). The model +defaults change for fresh installs; here we bump the existing singleton row IFF +it is still at the previous default, so a deliberate operator change is NOT +clobbered. + +Revision ID: 0081 +Revises: 0080 +Create Date: 2026-07-06 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0081" +down_revision: Union[str, None] = "0080" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "UPDATE ml_settings SET head_auto_apply_precision = 0.98 " + "WHERE head_auto_apply_precision = 0.97" + ) + op.execute( + "UPDATE ml_settings SET head_auto_apply_min_positives = 50 " + "WHERE head_auto_apply_min_positives = 30" + ) + op.execute( + "UPDATE ml_settings SET ccip_auto_apply_threshold = 0.95 " + "WHERE ccip_auto_apply_threshold = 0.92" + ) + + +def downgrade() -> None: + op.execute( + "UPDATE ml_settings SET head_auto_apply_precision = 0.97 " + "WHERE head_auto_apply_precision = 0.98" + ) + op.execute( + "UPDATE ml_settings SET head_auto_apply_min_positives = 30 " + "WHERE head_auto_apply_min_positives = 50" + ) + op.execute( + "UPDATE ml_settings SET ccip_auto_apply_threshold = 0.92 " + "WHERE ccip_auto_apply_threshold = 0.95" + ) diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index c03075e..3c60497 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -51,7 +51,10 @@ class MLSettings(Base): Integer, nullable=False, default=8 ) head_auto_apply_precision: Mapped[float] = mapped_column( - Float, nullable=False, default=0.97 + # Stricter graduation bar (was 0.97) to cut auto-apply misfires + # (operator-asked 2026-07-06): a higher precision target → fewer heads + # graduate and those that do get a higher per-head auto_apply_threshold. + Float, nullable=False, default=0.98 ) # Earned auto-apply (#114). A graduated head fires (tags images without a # human) when this master switch is on AND the head has at least @@ -63,7 +66,9 @@ class MLSettings(Base): Boolean, nullable=False, default=True ) head_auto_apply_min_positives: Mapped[int] = mapped_column( - Integer, nullable=False, default=30 + # Support floor raised 30→50 (operator-asked 2026-07-06): a head needs + # more human labels before it may fire without a human. + Integer, nullable=False, default=50 ) # CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75 # over-fired (high-reference characters matched a scatter of images); 0.85 @@ -78,7 +83,9 @@ class MLSettings(Base): Boolean, nullable=False, default=True ) ccip_auto_apply_threshold: Mapped[float] = mapped_column( - Float, nullable=False, default=0.92 + # Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident + # character matches auto-tag. + Float, nullable=False, default=0.95 ) # Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069); # existing libraries keep their stored value until the operator re-embeds. diff --git a/tests/test_head_auto_apply.py b/tests/test_head_auto_apply.py index c289ca0..91314ce 100644 --- a/tests/test_head_auto_apply.py +++ b/tests/test_head_auto_apply.py @@ -88,7 +88,7 @@ def test_sweep_dry_run_counts_but_writes_nothing(db_sync): def test_sweep_skips_under_supported_head(db_sync): - # n_pos below head_auto_apply_min_positives (default 30) → a precise-looking + # n_pos below head_auto_apply_min_positives (default 50) → a precise-looking # but under-supported head never fires. img = _img(db_sync, "c" * 64, _emb(0)) tag = Tag(name="weaktag", kind=TagKind.general) -- 2.52.0 From 3006e84cc010902698a1c7826d37dd3668d0a75e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 18:13:37 -0400 Subject: [PATCH 02/12] =?UTF-8?q?feat(ml):=20soft=20auto-apply=20=E2=80=94?= =?UTF-8?q?=20retract=20auto-tags=20now=20below=20threshold=20(milestone?= =?UTF-8?q?=20139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily scheduled_retract_auto_tags re-scores standing auto-applied tags and drops the ones the model no longer supports: - retract_auto_applied_heads: per graduated head, re-score its source='head_auto' images (bounded — only the images already carrying the auto-tag, not the whole library) and remove ones now < auto_apply_threshold. - retract_auto_applied_ccip: per source='ccip_auto' character tag, max-cosine the image's figure vectors vs that character's prototypes; remove ones now below the ccip auto-apply threshold. Both SKIP operator-confirmed tags (TagPositiveConfirmation) and are SILENT — a low score isn't proof the tag was wrong, so no hard negative is recorded (that's reserved for an operator removal). No-op unless the relevant auto-apply switch is on. New daily beat. sklearn-free tests for both paths + the disabled no-op. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/celery_app.py | 5 + .../app/services/ml/character_prototypes.py | 81 +++++++++- backend/app/services/ml/heads.py | 62 +++++++ backend/app/tasks/ml.py | 20 +++ tests/test_retract_auto_apply.py | 153 ++++++++++++++++++ 5 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 tests/test_retract_auto_apply.py diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index f42db3b..ed6e9a8 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -147,6 +147,11 @@ def make_celery() -> Celery: "task": "backend.app.tasks.ml.scheduled_ccip_auto_apply", "schedule": 86400.0, # no-op unless ccip_auto_apply_enabled }, + "retract-auto-tags-daily": { + "task": "backend.app.tasks.ml.scheduled_retract_auto_tags", + "schedule": 86400.0, # soft auto-apply: drop auto-tags now below + # their threshold (m139); no-op unless the auto-apply switch is on + }, "snapshot-head-metrics-daily": { "task": "backend.app.tasks.maintenance.snapshot_head_metrics", "schedule": 86400.0, diff --git a/backend/app/services/ml/character_prototypes.py b/backend/app/services/ml/character_prototypes.py index 8f8fb72..602d4ec 100644 --- a/backend/app/services/ml/character_prototypes.py +++ b/backend/app/services/ml/character_prototypes.py @@ -31,9 +31,15 @@ from ...models import ( MLSettings, Tag, TagKind, + TagPositiveConfirmation, ) from ...models.tag import image_tag -from .ccip import _FIGURE_KINDS, _hygiene_tagged_images, _single_character_images +from .ccip import ( + _FIGURE_KINDS, + _hygiene_tagged_images, + _l2norm, + _single_character_images, +) # Deterministic per-tag capping so a rebuild of an UNCHANGED reference set # resamples identically (stable prototypes, no churn between refreshes). @@ -173,3 +179,76 @@ def refresh_character_prototypes( settings.ccip_ref_signature = sig session.commit() return {"skipped": False, "rebuilt": rebuilt, "removed": removed} + + +def retract_auto_applied_ccip(session: Session) -> int: + """Soft auto-apply for CCIP character tags (milestone 139): re-score every + standing source='ccip_auto' character tag against that character's prototypes + and REMOVE the ones whose best figure match is now BELOW + ccip_auto_apply_threshold. Skips operator-confirmed tags. SILENT — a low score + isn't proof the tag was wrong (that's reserved for an operator removal). No-op + unless ccip_auto_apply_enabled. A character with no prototypes yet, or an image + with no figure vectors, is left alone (can't judge → keep). Returns + n_retracted.""" + import numpy as np + + settings = session.execute( + select(MLSettings).where(MLSettings.id == 1) + ).scalar_one() + if not settings.ccip_auto_apply_enabled: + return 0 + thr = float(settings.ccip_auto_apply_threshold) + pairs = session.execute( + select(image_tag.c.image_record_id, image_tag.c.tag_id) + .where(image_tag.c.source == "ccip_auto") + ).all() + if not pairs: + return 0 + confirmed = { + (iid, tid) for iid, tid in session.execute( + select( + TagPositiveConfirmation.image_record_id, + TagPositiveConfirmation.tag_id, + ) + ).all() + } + # Each involved character's normalized prototype matrix, loaded once. + proto: dict[int, object] = {} + for tid in {tid for _iid, tid in pairs}: + vecs = [ + v for (v,) in session.execute( + select(CharacterPrototype.ccip_embedding) + .where(CharacterPrototype.tag_id == tid) + ) + ] + if vecs: + proto[tid] = _l2norm( + np.vstack([np.asarray(v, dtype=np.float32) for v in vecs]), np + ) + retracted = 0 + for iid, tid in pairs: + if (iid, tid) in confirmed or tid not in proto: + continue # confirmed / no prototypes + qvecs = [ + v for (v,) in session.execute( + select(ImageRegion.ccip_embedding) + .where(ImageRegion.image_record_id == iid) + .where(ImageRegion.kind.in_(_FIGURE_KINDS)) + .where(ImageRegion.ccip_embedding.is_not(None)) + ) + ] + if not qvecs: + continue # no figure vectors → keep + Q = _l2norm( + np.vstack([np.asarray(v, dtype=np.float32) for v in qvecs]), np + ) + if float((Q @ proto[tid].T).max()) < thr: + session.execute( + image_tag.delete() + .where(image_tag.c.image_record_id == iid) + .where(image_tag.c.tag_id == tid) + .where(image_tag.c.source == "ccip_auto") + ) + retracted += 1 + session.commit() + return retracted diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index 41059cd..c01fd7f 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -35,6 +35,7 @@ from ...models import ( Tag, TagHead, TagKind, + TagPositiveConfirmation, TagSuggestionRejection, ) from ...models.tag import image_tag @@ -723,3 +724,64 @@ def auto_apply_sweep( for h in range(len(rows)) ] return {"n_applied": sum(applied), "concepts": concepts} + + +def retract_auto_applied_heads(session: Session) -> int: + """Soft auto-apply (milestone 139): re-score every standing source='head_auto' + tag against its CURRENT head and REMOVE the ones now BELOW the head's + auto_apply_threshold — i.e. the head sharpened (or the operator raised the bar) + and no longer supports them. Skips operator-confirmed tags + (TagPositiveConfirmation). SILENT: a low score isn't proof the tag was wrong, + so no hard negative is recorded — that's reserved for an operator removal. + No-op unless head_auto_apply_enabled. Only re-scores the images that ALREADY + carry the auto-tag (bounded), never the whole library. Returns n_retracted.""" + import numpy as np + + settings = _settings(session) + if not settings.head_auto_apply_enabled: + return 0 + heads = session.execute( + select( + TagHead.tag_id, TagHead.weights, TagHead.bias, + TagHead.auto_apply_threshold, + ) + .where(TagHead.embedding_version == settings.embedder_model_version) + .where(TagHead.auto_apply_threshold.is_not(None)) + ).all() + retracted = 0 + for tag_id, weights, bias, thr in heads: + auto_ids = [ + iid for (iid,) in session.execute( + select(image_tag.c.image_record_id) + .where(image_tag.c.tag_id == tag_id) + .where(image_tag.c.source == "head_auto") + ) + ] + if not auto_ids: + continue + confirmed = { + iid for (iid,) in session.execute( + select(TagPositiveConfirmation.image_record_id) + .where(TagPositiveConfirmation.tag_id == tag_id) + .where(TagPositiveConfirmation.image_record_id.in_(auto_ids)) + ) + } + candidates = [i for i in auto_ids if i not in confirmed] + emb = _load_embeddings(session, candidates) + cids = [i for i in candidates if i in emb] + if not cids: + continue + Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np) + w = np.asarray(weights, dtype=np.float32) + probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias)))) + below = [cids[k] for k in np.where(probs < float(thr))[0]] + for iid in below: + session.execute( + image_tag.delete() + .where(image_tag.c.image_record_id == iid) + .where(image_tag.c.tag_id == tag_id) + .where(image_tag.c.source == "head_auto") + ) + retracted += 1 + session.commit() + return retracted diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index 4959099..2172e71 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -592,3 +592,23 @@ def scheduled_ccip_auto_apply() -> str: applied += 1 session.commit() return f"applied={applied}" + + +@celery.task( + name="backend.app.tasks.ml.scheduled_retract_auto_tags", + soft_time_limit=1800, time_limit=2100, +) +def scheduled_retract_auto_tags() -> str: + """Soft auto-apply (milestone 139): retract standing head_auto/ccip_auto tags + the model no longer supports (score now below the auto-apply threshold), + skipping operator-confirmed ones. Silent (no hard negative). No-op unless the + respective auto-apply switch is on. Returns 'head=N ccip=M'.""" + from ..services.ml.character_prototypes import retract_auto_applied_ccip + from ..services.ml.heads import retract_auto_applied_heads + + SessionLocal = _sync_session_factory() + with SessionLocal() as session: + n_head = retract_auto_applied_heads(session) + with SessionLocal() as session: + n_ccip = retract_auto_applied_ccip(session) + return f"head={n_head} ccip={n_ccip}" diff --git a/tests/test_retract_auto_apply.py b/tests/test_retract_auto_apply.py new file mode 100644 index 0000000..1f93a91 --- /dev/null +++ b/tests/test_retract_auto_apply.py @@ -0,0 +1,153 @@ +"""Soft auto-apply (milestone 139): the retraction sweeps drop standing +head_auto/ccip_auto tags now below their threshold, keep the ones still above, +and never touch manual or operator-confirmed tags. Sync + sklearn-free (they +score with STORED weights/vectors), so tested directly via db_sync.""" +import pytest +from sqlalchemy import select + +from backend.app.models import ( + CharacterPrototype, + ImageRecord, + ImageRegion, + MLSettings, + Tag, + TagHead, + TagKind, + TagPositiveConfirmation, +) +from backend.app.models.tag import image_tag +from backend.app.services.ml.character_prototypes import retract_auto_applied_ccip +from backend.app.services.ml.heads import retract_auto_applied_heads + +pytestmark = pytest.mark.integration + + +def _emb(slot: int) -> list[float]: + v = [0.0] * 1152 + v[slot] = 3.0 + return v + + +def _ccip(slot: int) -> list[float]: + v = [0.0] * 768 + v[slot] = 1.0 + return v + + +def _img(db, sha: str, emb=None) -> 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", siglip_embedding=emb, + ) + db.add(img) + db.flush() + return img + + +def _figure(db, image_id: int, ccip) -> None: + db.add(ImageRegion( + image_record_id=image_id, kind="figure", + rx=0.0, ry=0.0, rw=1.0, rh=1.0, + ccip_embedding=ccip, embedding_version="ccip-test", + )) + db.flush() + + +def _tag(db, name: str, kind: TagKind) -> Tag: + t = Tag(name=name, kind=kind) + db.add(t) + db.flush() + return t + + +def _apply(db, image_id: int, tag_id: int, source: str) -> None: + db.execute(image_tag.insert().values( + image_record_id=image_id, tag_id=tag_id, source=source, + )) + + +def _version(db) -> str: + return db.execute( + select(MLSettings.embedder_model_version).where(MLSettings.id == 1) + ).scalar_one() + + +def _head(db, tag_id: int, slot: int, threshold: float, version: str) -> None: + w = [0.0] * 1152 + w[slot] = 1.0 + db.add(TagHead( + tag_id=tag_id, embedding_version=version, weights=w, bias=0.0, + suggest_threshold=0.5, auto_apply_threshold=threshold, + n_pos=60, n_neg=180, ap=0.9, precision_cv=0.98, recall=0.7, + )) + db.flush() + + +def _has_tag(db, image_id: int, tag_id: int) -> bool: + return db.execute( + select(image_tag.c.tag_id) + .where(image_tag.c.image_record_id == image_id) + .where(image_tag.c.tag_id == tag_id) + ).first() is not None + + +def test_retract_head_auto(db_sync): + ver = _version(db_sync) + tag = _tag(db_sync, "glasses", TagKind.general) + _head(db_sync, tag.id, slot=0, threshold=0.7, version=ver) + hi = _img(db_sync, "a" * 64, _emb(0)) # aligned → ~0.73 ≥ 0.7 → keep + lo = _img(db_sync, "b" * 64, _emb(5)) # orthogonal → 0.5 < 0.7 → retract + man = _img(db_sync, "c" * 64, _emb(5)) # low score but manual → keep + conf = _img(db_sync, "d" * 64, _emb(5)) # low score, head_auto, CONFIRMED → keep + _apply(db_sync, hi.id, tag.id, "head_auto") + _apply(db_sync, lo.id, tag.id, "head_auto") + _apply(db_sync, man.id, tag.id, "manual") + _apply(db_sync, conf.id, tag.id, "head_auto") + db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=tag.id)) + db_sync.commit() + + assert retract_auto_applied_heads(db_sync) == 1 + assert not _has_tag(db_sync, lo.id, tag.id) # retracted (below threshold) + assert _has_tag(db_sync, hi.id, tag.id) # kept (still above) + assert _has_tag(db_sync, man.id, tag.id) # kept (manual, not auto) + assert _has_tag(db_sync, conf.id, tag.id) # kept (operator-confirmed) + + +def test_retract_head_auto_noop_when_disabled(db_sync): + s = db_sync.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one() + s.head_auto_apply_enabled = False + ver = _version(db_sync) + tag = _tag(db_sync, "glasses", TagKind.general) + _head(db_sync, tag.id, slot=0, threshold=0.7, version=ver) + lo = _img(db_sync, "e" * 64, _emb(5)) # would be below threshold + _apply(db_sync, lo.id, tag.id, "head_auto") + db_sync.commit() + + assert retract_auto_applied_heads(db_sync) == 0 + assert _has_tag(db_sync, lo.id, tag.id) # switch off → nothing retracted + + +def test_retract_ccip_auto(db_sync): + char = _tag(db_sync, "Raven", TagKind.character) + db_sync.add(CharacterPrototype(tag_id=char.id, ccip_embedding=_ccip(0))) + hi = _img(db_sync, "f" * 64) # figure matches prototype → keep + lo = _img(db_sync, "g" * 64) # figure orthogonal → retract + conf = _img(db_sync, "h" * 64) # orthogonal, CONFIRMED → keep + man = _img(db_sync, "i" * 64) # orthogonal, manual → keep + _figure(db_sync, hi.id, _ccip(0)) + _figure(db_sync, lo.id, _ccip(5)) + _figure(db_sync, conf.id, _ccip(5)) + _figure(db_sync, man.id, _ccip(5)) + _apply(db_sync, hi.id, char.id, "ccip_auto") + _apply(db_sync, lo.id, char.id, "ccip_auto") + _apply(db_sync, conf.id, char.id, "ccip_auto") + _apply(db_sync, man.id, char.id, "manual") + db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=char.id)) + db_sync.commit() + + assert retract_auto_applied_ccip(db_sync) == 1 + assert not _has_tag(db_sync, lo.id, char.id) # retracted (below threshold) + assert _has_tag(db_sync, hi.id, char.id) # kept (match ≥ threshold) + assert _has_tag(db_sync, conf.id, char.id) # kept (operator-confirmed) + assert _has_tag(db_sync, man.id, char.id) # kept (manual, not auto) -- 2.52.0 From 0de726ed481b7b99f6d6cdf8d1a3c476bf39d8ec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 18:18:46 -0400 Subject: [PATCH 03/12] =?UTF-8?q?test(ml):=20bump=20auto-apply=20test=20he?= =?UTF-8?q?ad=20n=5Fpos=20default=2030=E2=86=9260=20past=20the=20new=20flo?= =?UTF-8?q?or=20(m139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stricter head_auto_apply_min_positives (30→50, migration 0081) dropped the _head helper's default n_pos=30 below the support floor, so the "supported head" sweep tests saw the head as ineligible (n_applied 0). Move the default to 60; the explicit n_pos=5 under-supported test stays correct. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- tests/test_head_auto_apply.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_head_auto_apply.py b/tests/test_head_auto_apply.py index 91314ce..0d345e0 100644 --- a/tests/test_head_auto_apply.py +++ b/tests/test_head_auto_apply.py @@ -35,7 +35,7 @@ def _img(db, sha: str, emb) -> ImageRecord: return img -def _head(db, tag_id: int, slot: int, *, threshold=0.5, n_pos=30): +def _head(db, tag_id: int, slot: int, *, threshold=0.5, n_pos=60): s = db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one() w = [0.0] * 1152 w[slot] = 1.0 -- 2.52.0 From 2d44a26bdf5536161637c9fffaf4a66d13604d71 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 18:28:25 -0400 Subject: [PATCH 04/12] feat(ml): auto-applied tags don't train a head unless confirmed (milestone 139) Makes auto-apply truly "soft" for heads: _ids_with_tag (head positives) and _eligible_tag_ids (graduation count) now count human-applied + operator-confirmed tags only, via a shared _AUTO_SOURCES (head_auto/ccip_auto/ml_auto) exclusion. Unconfirmed auto-applied tags no longer train the head that judges them, so a misfire can't reinforce itself and the retraction sweep can actually drop it. Confirming a tag (TagPositiveConfirmation) promotes it to a positive AND protects it from retraction. sklearn-free tests. CCIP reference exclusion is the companion piece, next. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- backend/app/services/ml/heads.py | 16 ++++-- backend/app/services/ml/training_data.py | 29 +++++++++- tests/test_head_positive_sources.py | 69 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 tests/test_head_positive_sources.py diff --git a/backend/app/services/ml/heads.py b/backend/app/services/ml/heads.py index c01fd7f..592a471 100644 --- a/backend/app/services/ml/heads.py +++ b/backend/app/services/ml/heads.py @@ -22,7 +22,7 @@ import logging from datetime import UTC, datetime from typing import Any -from sqlalchemy import delete, func, select +from sqlalchemy import delete, exists, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -40,6 +40,7 @@ from ...models import ( ) from ...models.tag import image_tag from .training_data import ( + _AUTO_SOURCES, _auto_apply_point, _hygiene_excluded_ids, _ids_with_tag, @@ -138,13 +139,20 @@ def _embedder_version(session: Session) -> str: def _eligible_tag_ids(session: Session, min_pos: int) -> list[int]: - """Concept tags (general/character) with >= min_pos labelled images — the - set that gets a head. Counts all sources; source-aware filtering (#1133) is - a separate, optional refinement.""" + """Concept tags (general/character) with >= min_pos POSITIVE images — the set + that gets a head. Counts human-applied + operator-confirmed tags only; + unconfirmed auto-applied predictions do NOT count toward eligibility (they + don't train the head — milestone 139), so a concept can't graduate on its own + guesses.""" + confirmed = exists().where( + TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id, + TagPositiveConfirmation.tag_id == image_tag.c.tag_id, + ) rows = session.execute( select(Tag.id) .join(image_tag, image_tag.c.tag_id == Tag.id) .where(Tag.kind.in_(_HEAD_KINDS)) + .where(image_tag.c.source.not_in(_AUTO_SOURCES) | confirmed) .group_by(Tag.id) .having(func.count(image_tag.c.image_record_id) >= min_pos) ).all() diff --git a/backend/app/services/ml/training_data.py b/backend/app/services/ml/training_data.py index 5e2963a..6c9802a 100644 --- a/backend/app/services/ml/training_data.py +++ b/backend/app/services/ml/training_data.py @@ -17,9 +17,20 @@ from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session -from ...models import ImageRecord, Tag, TagSuggestionRejection +from ...models import ( + ImageRecord, + Tag, + TagPositiveConfirmation, + TagSuggestionRejection, +) from ...models.tag import image_tag +# Auto-apply sources whose tags are PROVISIONAL: they never train a head (or seed +# a CCIP reference) unless the operator confirms them (milestone 139). Keeping +# auto-applied predictions out of training is what makes them "soft" — a misfire +# can't reinforce itself, so the retraction sweep can actually drop it. +_AUTO_SOURCES = ("head_auto", "ccip_auto", "ml_auto") + def _hygiene_excluded_ids(session: Session) -> set[int]: """Ids of images carrying ANY system tag (wip / banner / editor @@ -45,9 +56,23 @@ def _hygiene_excluded_ids(session: Session) -> set[int]: def _ids_with_tag(session: Session, tag_id: int) -> list[int]: + """Image ids that count as POSITIVES for this tag's head: human-applied + (manual / accepted) tags PLUS any auto-applied tag the operator explicitly + confirmed (TagPositiveConfirmation). Unconfirmed auto-applied tags are + EXCLUDED — they are provisional and must not train the head that judges + them (milestone 139).""" + confirmed = ( + select(TagPositiveConfirmation.image_record_id) + .where(TagPositiveConfirmation.tag_id == tag_id) + ) return [ r[0] for r in session.execute( - select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tag_id) + select(image_tag.c.image_record_id) + .where(image_tag.c.tag_id == tag_id) + .where( + image_tag.c.source.not_in(_AUTO_SOURCES) + | image_tag.c.image_record_id.in_(confirmed) + ) ).all() ] diff --git a/tests/test_head_positive_sources.py b/tests/test_head_positive_sources.py new file mode 100644 index 0000000..c26039e --- /dev/null +++ b/tests/test_head_positive_sources.py @@ -0,0 +1,69 @@ +"""Soft auto-apply (milestone 139): unconfirmed auto-applied tags do NOT train a +head. _ids_with_tag (positives) + _eligible_tag_ids (graduation count) count +human-applied + operator-confirmed tags only. Sklearn-free, so tested via +db_sync.""" +import pytest + +from backend.app.models import ImageRecord, Tag, TagKind, TagPositiveConfirmation +from backend.app.models.tag import image_tag +from backend.app.services.ml.heads import _eligible_tag_ids +from backend.app.services.ml.training_data import _ids_with_tag + +pytestmark = pytest.mark.integration + + +def _img(db, sha: str) -> 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) + db.flush() + return img + + +def _tag(db, name: str) -> Tag: + t = Tag(name=name, kind=TagKind.general) + db.add(t) + db.flush() + return t + + +def _apply(db, image_id: int, tag_id: int, source: str) -> None: + db.execute(image_tag.insert().values( + image_record_id=image_id, tag_id=tag_id, source=source, + )) + + +def test_positives_exclude_unconfirmed_auto(db_sync): + tag = _tag(db_sync, "glasses") + man = _img(db_sync, "a" * 64) + auto = _img(db_sync, "b" * 64) + conf = _img(db_sync, "c" * 64) + acc = _img(db_sync, "d" * 64) + _apply(db_sync, man.id, tag.id, "manual") + _apply(db_sync, auto.id, tag.id, "head_auto") # unconfirmed → excluded + _apply(db_sync, conf.id, tag.id, "head_auto") # confirmed → included + _apply(db_sync, acc.id, tag.id, "ml_accepted") + db_sync.add(TagPositiveConfirmation(image_record_id=conf.id, tag_id=tag.id)) + db_sync.commit() + + pos = set(_ids_with_tag(db_sync, tag.id)) + assert pos == {man.id, conf.id, acc.id} + assert auto.id not in pos + + +def test_eligibility_counts_positives_only(db_sync): + # A concept whose only tags are unconfirmed auto-applies does NOT graduate. + tag = _tag(db_sync, "autotag") + for i in range(3): + _apply(db_sync, _img(db_sync, f"e{i}" * 32).id, tag.id, "head_auto") + db_sync.commit() + assert tag.id not in _eligible_tag_ids(db_sync, min_pos=2) + + # Two human positives → now eligible at min_pos=2. + for i in range(2): + _apply(db_sync, _img(db_sync, f"h{i}" * 32).id, tag.id, "manual") + db_sync.commit() + assert tag.id in _eligible_tag_ids(db_sync, min_pos=2) -- 2.52.0 From 66849075777ca8a6d95d39f8531e93e35e8a29b8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 6 Jul 2026 18:42:08 -0400 Subject: [PATCH 05/12] =?UTF-8?q?feat(ui):=20"Reject=20rest"=20per=20sugge?= =?UTF-8?q?stion=20category=20=E2=80=94=20confirm=20the=20good,=20reject?= =?UTF-8?q?=20the=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each category section header gets a subtle "Reject rest" action that dismisses every still-unhandled suggestion in it at once (store.dismissRemaining, parallel dispatch). Canonical tags persist a rejection and stay flagged (reversible, one-click un-reject); raw creates-new-tag rows drop client-side. Shows only when the section has unhandled items. No confirm dialog — it's fully reversible. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- .../modal/SuggestionsCategoryGroup.vue | 58 ++++++++++++++----- .../src/components/modal/SuggestionsPanel.vue | 13 +++++ frontend/src/stores/suggestions.js | 27 ++++++++- 3 files changed, 83 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/modal/SuggestionsCategoryGroup.vue b/frontend/src/components/modal/SuggestionsCategoryGroup.vue index 4a317f6..613af70 100644 --- a/frontend/src/components/modal/SuggestionsCategoryGroup.vue +++ b/frontend/src/components/modal/SuggestionsCategoryGroup.vue @@ -1,14 +1,25 @@