feat(ml): CCIP references exclude unconfirmed auto character tags + confirm trips detectors (m139)
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:
@@ -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()))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user