feat(ccip): incremental character-prototype builder (#1317, m138 step 2)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m42s

refresh_character_prototypes (sync, celery ml worker):
- Cheap GLOBAL gate (a few COUNTs) → no-op when nothing that affects references
  changed since the last refresh (the operator's "only recompute if something
  was tagged" trigger).
- Else a per-character fingerprint diff (one GROUP BY: ref count + max region id)
  rebuilds ONLY the characters whose references moved — each capped to
  MLSettings.ccip_prototype_cap — and drops characters that lost all refs.
Cost scales with WHAT changed, not library size. Reuses ccip's reference
predicate (single-character, non-hygiene, figure CCIP) so prototypes match the
legacy matcher exactly. The async matcher (next step) will READ the table.

Tests: gate no-op when idle, only-changed-character rebuild, capping,
single-character exclusion, lost-reference cleanup.

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 16:07:07 -04:00
parent f24dc81764
commit 9504870c9a
2 changed files with 338 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
"""Incremental CCIP prototype builder (#1317, m138). Sync (celery ml worker),
so tested directly via db_sync — the global gate, per-character fingerprint diff,
capping, single-character exclusion, and lost-reference cleanup."""
import pytest
from sqlalchemy import func, select
from backend.app.models import (
CcipPrototypeState,
CharacterPrototype,
ImageRecord,
ImageRegion,
MLSettings,
Tag,
TagKind,
)
from backend.app.models.tag import image_tag
from backend.app.services.ml.character_prototypes import (
refresh_character_prototypes,
)
pytestmark = pytest.mark.integration
def _ccip(slot: int = 0) -> list[float]:
v = [0.0] * 768
v[slot] = 1.0
return v
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 _figure(db, image_id: int, ccip=None) -> 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 if ccip is not None else _ccip(),
embedding_version="ccip-test",
))
db.flush()
def _char(db, name: str) -> Tag:
t = Tag(name=name, kind=TagKind.character)
db.add(t)
db.flush()
return t
def _tag(db, image_id: int, tag_id: int) -> None:
db.execute(image_tag.insert().values(
image_record_id=image_id, tag_id=tag_id, source="manual",
))
def _proto_count(db, tag_id: int) -> int:
return db.execute(
select(func.count()).select_from(CharacterPrototype)
.where(CharacterPrototype.tag_id == tag_id)
).scalar_one()
def _set_cap(db, cap: int) -> None:
s = db.execute(select(MLSettings).where(MLSettings.id == 1)).scalar_one()
s.ccip_prototype_cap = cap
db.flush()
def test_refresh_builds_then_gate_no_ops(db_sync):
raven = _char(db_sync, "Raven")
img = _img(db_sync, "a" * 64)
_figure(db_sync, img.id)
_tag(db_sync, img.id, raven.id)
db_sync.commit()
r1 = refresh_character_prototypes(db_sync)
assert r1["skipped"] is False and r1["rebuilt"] == 1
assert _proto_count(db_sync, raven.id) == 1
assert db_sync.get(CcipPrototypeState, raven.id) is not None
# Nothing changed → the global gate short-circuits (no per-character work).
r2 = refresh_character_prototypes(db_sync)
assert r2["skipped"] is True and r2["rebuilt"] == 0
def test_only_changed_character_rebuilt(db_sync):
raven = _char(db_sync, "Raven")
daphne = _char(db_sync, "Daphne")
ri = _img(db_sync, "b" * 64)
_figure(db_sync, ri.id)
_tag(db_sync, ri.id, raven.id)
di = _img(db_sync, "c" * 64)
_figure(db_sync, di.id)
_tag(db_sync, di.id, daphne.id)
db_sync.commit()
assert refresh_character_prototypes(db_sync)["rebuilt"] == 2
# A new figure on Raven's image changes only Raven's fingerprint.
_figure(db_sync, ri.id, _ccip(1))
db_sync.commit()
r = refresh_character_prototypes(db_sync)
assert r["rebuilt"] == 1 # Daphne untouched
assert _proto_count(db_sync, raven.id) == 2
assert _proto_count(db_sync, daphne.id) == 1
def test_cap_bounds_prototypes(db_sync):
_set_cap(db_sync, 2)
raven = _char(db_sync, "Raven")
for i in range(3): # 3 single-character images
img = _img(db_sync, f"{i}" * 64)
_figure(db_sync, img.id)
_tag(db_sync, img.id, raven.id)
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 2 # capped from 3 → 2
def test_multi_character_image_not_referenced(db_sync):
# A figure on a 2-character image is ambiguous (tag is image-level), so it
# must NOT become a reference for either — parity with the legacy matcher.
raven = _char(db_sync, "Raven")
daphne = _char(db_sync, "Daphne")
img = _img(db_sync, "d" * 64)
_figure(db_sync, img.id)
_tag(db_sync, img.id, raven.id)
_tag(db_sync, img.id, daphne.id)
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 0
assert _proto_count(db_sync, daphne.id) == 0
def test_lost_references_are_removed(db_sync):
raven = _char(db_sync, "Raven")
img = _img(db_sync, "e" * 64)
_figure(db_sync, img.id)
_tag(db_sync, img.id, raven.id)
db_sync.commit()
refresh_character_prototypes(db_sync)
assert _proto_count(db_sync, raven.id) == 1
# Untag Raven → the image is no longer a reference → prototypes + state drop.
db_sync.execute(
image_tag.delete()
.where(image_tag.c.image_record_id == img.id)
.where(image_tag.c.tag_id == raven.id)
)
db_sync.commit()
r = refresh_character_prototypes(db_sync)
assert r["removed"] == 1
assert _proto_count(db_sync, raven.id) == 0
assert db_sync.get(CcipPrototypeState, raven.id) is None