feat(ml): CCIP references exclude unconfirmed auto character tags + confirm trips detectors (m139)
CI / lint (push) Failing after 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Failing after 42s
CI / integration (push) Successful in 3m46s

Completes "no self-training": unconfirmed auto-applied character tags no longer
seed CCIP references — character_references + the prototype builder
(_current_fingerprints/_rebuild_one) gain a shared _positive_char_tag filter
(human-applied OR operator-confirmed), mirroring the head-positive exclusion.

Confirming a tag also has to move the change-detectors, or an incremental
refresh/Retrain right after a confirm wouldn't fold the tag in (only the nightly
full pass would): the CCIP global gate now counts character confirmations, and
the head training fingerprint counts confirmations. Test for the CCIP path.

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:59:00 -04:00
parent 775941609d
commit bae077e323
4 changed files with 60 additions and 3 deletions
+15 -1
View File
@@ -13,7 +13,7 @@ exact CCIP difference metric/threshold gets validated against the model during
the hands-on eval. numpy is imported lazily (API worker has it via pgvector). the hands-on eval. numpy is imported lazily (API worker has it via pgvector).
""" """
from sqlalchemy import func, select from sqlalchemy import exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ( from ...models import (
@@ -23,8 +23,10 @@ from ...models import (
MLSettings, MLSettings,
Tag, Tag,
TagKind, TagKind,
TagPositiveConfirmation,
) )
from ...models.tag import image_tag from ...models.tag import image_tag
from .training_data import _AUTO_SOURCES
# Cosine-similarity floor to call a figure the same character. The live setting # Cosine-similarity floor to call a figure the same character. The live setting
# (ml_settings.ccip_match_threshold) drives it; this is only the fallback when no # (ml_settings.ccip_match_threshold) drives it; this is only the fallback when no
@@ -111,6 +113,17 @@ async def _ref_signature(session: AsyncSession) -> tuple:
return (n_tags, n_regs, max_id, n_hygiene) return (n_tags, n_regs, max_id, n_hygiene)
def _positive_char_tag():
"""Condition on the joined character image_tag: HUMAN-applied or operator-
confirmed — NOT an unconfirmed auto-apply. Keeps an auto-tagged character from
self-seeding CCIP references, so a ccip_auto misfire can't reinforce itself
(milestone 139) — mirrors the head-training positive exclusion."""
return image_tag.c.source.not_in(_AUTO_SOURCES) | exists().where(
TagPositiveConfirmation.image_record_id == image_tag.c.image_record_id,
TagPositiveConfirmation.tag_id == image_tag.c.tag_id,
)
async def character_references(session: AsyncSession) -> dict[int, list]: async def character_references(session: AsyncSession) -> dict[int, list]:
"""Per character-tag CCIP reference vectors: figure/face-region CCIP """Per character-tag CCIP reference vectors: figure/face-region CCIP
embeddings on UNAMBIGUOUS (single-character) images carrying that tag. embeddings on UNAMBIGUOUS (single-character) images carrying that tag.
@@ -128,6 +141,7 @@ async def character_references(session: AsyncSession) -> dict[int, list]:
) )
.join(Tag, Tag.id == image_tag.c.tag_id) .join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character) .where(Tag.kind == TagKind.character)
.where(_positive_char_tag())
.where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(_single_character_images())) .where(ImageRegion.image_record_id.in_(_single_character_images()))
@@ -38,6 +38,7 @@ from .ccip import (
_FIGURE_KINDS, _FIGURE_KINDS,
_hygiene_tagged_images, _hygiene_tagged_images,
_l2norm, _l2norm,
_positive_char_tag,
_single_character_images, _single_character_images,
) )
@@ -69,7 +70,16 @@ def _global_signature(session: Session) -> str:
.join(Tag, Tag.id == image_tag.c.tag_id) .join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.is_system.is_(True)) .where(Tag.is_system.is_(True))
).scalar_one() ).scalar_one()
return f"{n_tags}:{n_regs}:{max_id or 0}:{n_hygiene}" # Character confirmations affect the reference set now that auto-tags only
# seed references once confirmed (milestone 139) — so a confirm must trip the
# gate, or the per-character diff (which reflects it) never runs.
n_conf = session.execute(
select(func.count())
.select_from(TagPositiveConfirmation)
.join(Tag, Tag.id == TagPositiveConfirmation.tag_id)
.where(Tag.kind == TagKind.character)
).scalar_one()
return f"{n_tags}:{n_regs}:{max_id or 0}:{n_hygiene}:{n_conf}"
def _current_fingerprints(session: Session) -> dict[int, str]: def _current_fingerprints(session: Session) -> dict[int, str]:
@@ -90,6 +100,7 @@ def _current_fingerprints(session: Session) -> dict[int, str]:
) )
.join(Tag, Tag.id == image_tag.c.tag_id) .join(Tag, Tag.id == image_tag.c.tag_id)
.where(Tag.kind == TagKind.character) .where(Tag.kind == TagKind.character)
.where(_positive_char_tag())
.where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(_single_character_images())) .where(ImageRegion.image_record_id.in_(_single_character_images()))
@@ -110,6 +121,7 @@ def _rebuild_one(session: Session, tag_id: int, cap: int) -> int:
image_tag.c.image_record_id == ImageRegion.image_record_id, image_tag.c.image_record_id == ImageRegion.image_record_id,
) )
.where(image_tag.c.tag_id == tag_id) .where(image_tag.c.tag_id == tag_id)
.where(_positive_char_tag())
.where(ImageRegion.kind.in_(_FIGURE_KINDS)) .where(ImageRegion.kind.in_(_FIGURE_KINDS))
.where(ImageRegion.ccip_embedding.is_not(None)) .where(ImageRegion.ccip_embedding.is_not(None))
.where(ImageRegion.image_record_id.in_(_single_character_images())) .where(ImageRegion.image_record_id.in_(_single_character_images()))
+10 -1
View File
@@ -189,11 +189,20 @@ def _head_fingerprints(session: Session, tag_ids: list[int]) -> dict[int, str]:
.group_by(TagSuggestionRejection.tag_id) .group_by(TagSuggestionRejection.tag_id)
).all() ).all()
rej_map = {t: (c, m) for t, c, m in rej} rej_map = {t: (c, m) for t, c, m in rej}
# Confirmations promote an auto-applied tag to a positive (milestone 139), so
# a confirm must move the fingerprint too — else a manual Retrain right after
# confirming wouldn't fold the tag in (the nightly full run would).
conf = session.execute(
select(TagPositiveConfirmation.tag_id, func.count())
.where(TagPositiveConfirmation.tag_id.in_(tag_ids))
.group_by(TagPositiveConfirmation.tag_id)
).all()
conf_map = {t: c for t, c in conf}
out = {} out = {}
for t in tag_ids: for t in tag_ids:
pc, pm = pos_map.get(t, (0, None)) pc, pm = pos_map.get(t, (0, None))
rc, rm = rej_map.get(t, (0, None)) rc, rm = rej_map.get(t, (0, None))
out[t] = f"{pc}:{pm}:{rc}:{rm}" out[t] = f"{pc}:{pm}:{rc}:{rm}:{conf_map.get(t, 0)}"
return out return out
+22
View File
@@ -12,6 +12,7 @@ from backend.app.models import (
MLSettings, MLSettings,
Tag, Tag,
TagKind, TagKind,
TagPositiveConfirmation,
) )
from backend.app.models.tag import image_tag from backend.app.models.tag import image_tag
from backend.app.services.ml.character_prototypes import ( from backend.app.services.ml.character_prototypes import (
@@ -141,6 +142,27 @@ def test_multi_character_image_not_referenced(db_sync):
assert _proto_count(db_sync, daphne.id) == 0 assert _proto_count(db_sync, daphne.id) == 0
def test_unconfirmed_auto_char_tag_not_referenced(db_sync):
# A single-character image whose ONLY character tag was AUTO-applied
# (unconfirmed) must NOT seed a prototype — else CCIP self-trains on its own
# guess (milestone 139). Confirming it (which trips the global gate) makes it
# a reference.
raven = _char(db_sync, "Raven")
img = _img(db_sync, "z" * 64)
_figure(db_sync, img.id)
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=raven.id, source="ccip_auto",
))
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 0
db_sync.add(TagPositiveConfirmation(image_record_id=img.id, tag_id=raven.id))
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 1
def test_lost_references_are_removed(db_sync): def test_lost_references_are_removed(db_sync):
raven = _char(db_sync, "Raven") raven = _char(db_sync, "Raven")
img = _img(db_sync, "e" * 64) img = _img(db_sync, "e" * 64)