diff --git a/alembic/versions/0079_character_prototypes.py b/alembic/versions/0079_character_prototypes.py new file mode 100644 index 0000000..8ada2f4 --- /dev/null +++ b/alembic/versions/0079_character_prototypes.py @@ -0,0 +1,77 @@ +"""character prototype store (#1317) — precomputed, incremental CCIP references + +New tables character_prototype + ccip_prototype_state, plus MLSettings columns +ccip_ref_signature (cheap global change gate) + ccip_prototype_cap (per-character +reference cap). The reference set the CCIP matcher uses becomes a precomputed +artifact refreshed incrementally off the request path. See milestone 138 / +backend.app.services.ml.character_prototypes. + +Revision ID: 0079 +Revises: 0078 +Create Date: 2026-07-06 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from pgvector.sqlalchemy import Vector + +revision: str = "0079" +down_revision: Union[str, None] = "0078" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Matches models.image_region.CCIP_DIM (the CCIP figure-embedding width). +_CCIP_DIM = 768 + + +def upgrade() -> None: + op.create_table( + "character_prototype", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "tag_id", sa.Integer(), + sa.ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("ccip_embedding", Vector(_CCIP_DIM), nullable=False), + sa.Column( + "region_id", sa.Integer(), + sa.ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True, + ), + ) + op.create_index( + "ix_character_prototype_tag_id", "character_prototype", ["tag_id"] + ) + op.create_table( + "ccip_prototype_state", + sa.Column( + "tag_id", sa.Integer(), + sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True, + ), + sa.Column("fingerprint", sa.String(64), nullable=False), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now(), + ), + ) + op.add_column( + "ml_settings", + sa.Column("ccip_ref_signature", sa.String(128), nullable=True), + ) + op.add_column( + "ml_settings", + sa.Column( + "ccip_prototype_cap", sa.Integer(), nullable=False, + server_default=sa.text("64"), + ), + ) + + +def downgrade() -> None: + op.drop_column("ml_settings", "ccip_prototype_cap") + op.drop_column("ml_settings", "ccip_ref_signature") + op.drop_table("ccip_prototype_state") + op.drop_index( + "ix_character_prototype_tag_id", table_name="character_prototype" + ) + op.drop_table("character_prototype") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 99ebaa6..0da2555 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from .artist import Artist from .artist_visit import ArtistVisit from .backup_run import BackupRun from .base import Base +from .character_prototype import CcipPrototypeState, CharacterPrototype from .credential import Credential from .download_event import DownloadEvent from .external_link import ExternalLink @@ -79,6 +80,8 @@ __all__ = [ "HeadTrainingRun", "TagAlias", "TagHead", + "CharacterPrototype", + "CcipPrototypeState", "TagPositiveConfirmation", "TagSuggestionRejection", "TaskRun", diff --git a/backend/app/models/character_prototype.py b/backend/app/models/character_prototype.py new file mode 100644 index 0000000..191a29d --- /dev/null +++ b/backend/app/models/character_prototype.py @@ -0,0 +1,62 @@ +"""Precomputed CCIP character prototypes (#1317, milestone 138). + +The live matcher (ccip.match_image) needs each character's reference figure +vectors. Building that on the request path reloaded EVERY figure CCIP vector in +the library on any change (~4s, invalidated by every character accept). These +tables make the references a PRECOMPUTED, INCREMENTAL artifact refreshed off the +request path (services.ml.character_prototypes): + +- CharacterPrototype: a character's reference vectors, capped to + MLSettings.ccip_prototype_cap so MATCH cost doesn't grow with a character's + popularity. The async matcher only READS these. +- CcipPrototypeState: a per-character fingerprint (reference count + max region + id) so a refresh rebuilds ONLY the characters whose references changed, and + its updated_at lets the matcher's cache reload just the advanced characters. +""" + +from datetime import datetime + +from pgvector.sqlalchemy import Vector +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base +from .image_region import CCIP_DIM + + +class CharacterPrototype(Base): + __tablename__ = "character_prototype" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + # The character tag these vectors identify. CASCADE: deleting the tag drops + # its prototypes. + tag_id: Mapped[int] = mapped_column( + ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True + ) + # A reference figure/face CCIP vector (same space as + # ImageRegion.ccip_embedding). + ccip_embedding: Mapped[list[float]] = mapped_column( + Vector(CCIP_DIM), nullable=False + ) + # Provenance: the region this vector was copied from. SET NULL so pruning a + # region doesn't delete the prototype mid-cycle (the next refresh reconciles). + region_id: Mapped[int | None] = mapped_column( + ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True + ) + + +class CcipPrototypeState(Base): + __tablename__ = "ccip_prototype_state" + + # One row per character that currently has prototypes. + tag_id: Mapped[int] = mapped_column( + ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True + ) + # count(reference regions) + max(region id) at last build — the cheap + # per-character change detector that drives incremental rebuilds. + fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) + # Bumped when this character's prototypes are rebuilt; the matcher cache + # reloads only characters whose updated_at advanced. + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index dfb97d5..c03075e 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -155,6 +155,18 @@ class MLSettings(Base): detector_dedupe_iou: Mapped[float] = mapped_column( Float, nullable=False, default=0.85 ) + # -- CCIP character prototypes (#1317) --------------------------------- + # The per-character reference set is precomputed + refreshed INCREMENTALLY + # (services.ml.character_prototypes) instead of rebuilt on the request path. + # ccip_ref_signature is the cheap GLOBAL gate — when it's unchanged the + # refresh no-ops; ccip_prototype_cap bounds the reference vectors kept per + # character so MATCH cost doesn't grow with a character's popularity. + ccip_ref_signature: Mapped[str | None] = mapped_column( + String(128), nullable=True + ) + ccip_prototype_cap: Mapped[int] = mapped_column( + Integer, nullable=False, default=64 + ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() )