feat(ml): auto-applied tags don't train a head unless confirmed (milestone 139)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m41s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 18:28:25 -04:00
parent 0de726ed48
commit 2d44a26bdf
3 changed files with 108 additions and 6 deletions
+12 -4
View File
@@ -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()
+27 -2
View File
@@ -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()
]