Files
FabledCurator/tests/test_gallery_similar.py
T
bvandeusenandClaude Opus 5 a05cb66635
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 30s
CI / backend-lint-and-test (push) Successful in 1m8s
Build images / build-web (push) Successful in 1m28s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m51s
Build images / build-ml (push) Successful in 2m59s
Build images / promote (push) Skipped
fix: variant artwork was dropped as a near-duplicate even at threshold 0 (4223)
The operator reported a 15-image variant pack landing as 3 records, then
reported variants STILL being dropped with phash_threshold at 0 — the floor
of the dial. No setting could have fixed it: at hash_size=8 a pHash is 64
bits of coarse light/dark layout, so two variants sharing a composition
produce the SAME bits. Distance 0 meant "identical hash", not "identical
image", and the dial was simultaneously too coarse to keep variants and too
tight to catch a re-encoded rescale.

The hash no longer decides a merge on its own. find_similar now runs three
gates, cheapest first: the threshold proposes candidates, aspect ratio
(ASPECT_TOL, matching the tier-1 video path) rejects crops and re-canvases,
and a pixel-level confirm on the two files accepts. Every gate fails closed —
unknown dimensions, an unreadable candidate, a hash of the wrong width all
mean "not a duplicate", because too strict keeps a redundant copy the
operator can see while too loose deletes artwork only a source re-walk
returns.

- utils/phash.py: HASH_SIZE 8 -> 16 (256-bit, what ImageRepo always used);
  aspect_matches, fingerprint/fingerprint_path/fingerprints_match (PIL-only,
  mean drift + changed-pixel fraction), find_similar gains `confirm`.
- importer: _pixel_confirmer supplies gate 3 on both dedup sites, lazily and
  cached, so a non-matching import costs no extra I/O.
- 0098: widens image_record.phash to 64 chars and NULLs every value — a
  stored 64-bit hash cannot be compared to a 256-bit one, and backfill_phash
  is NULL-only, keyset-paginated and now on the daily beat, so the library
  re-hashes itself. Dedup degrades to sha256 until it finishes.
- phash_threshold counts bits and the denominator went 64 -> 256, so the
  setting is reset to the new default of 24 (there is no honest carry-over)
  and the slider is rescaled to 0-64.
- gallery_service dup_threshold 8 -> 32: the same fraction of the hash, so
  the Explore rail keeps the variance the operator tuned in on 2026-07-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 00:16:14 -04:00

230 lines
9.0 KiB
Python

"""Phase-3 visual "more like this" — pgvector cosine ranking over the
precomputed SigLIP image embeddings. No query-time ML inference."""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
from backend.app.services.gallery_service import GalleryService
pytestmark = pytest.mark.integration
def _vec(*head):
"""A 1152-dim embedding with the given leading values, rest zero. Cosine
distance to _vec(1,0) grows as the 2nd component grows, so callers can
order fixtures deterministically by direction."""
v = [0.0] * 1152
for i, x in enumerate(head):
v[i] = float(x)
return v
async def _img(db, n, emb):
rec = ImageRecord(
path=f"/images/sim/{n}.jpg", sha256=f"e{n:063d}",
size_bytes=1, mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
siglip_embedding=emb,
)
base = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
rec.created_at = base - timedelta(minutes=n)
rec.effective_date = rec.created_at
db.add(rec)
await db.flush()
return rec
@pytest.mark.asyncio
async def test_similar_ranks_nearest_first(db):
src = await _img(db, 1, _vec(1, 0))
near = await _img(db, 2, _vec(1, 0.05)) # almost same direction
mid = await _img(db, 3, _vec(1, 1)) # 45°
far = await _img(db, 4, _vec(0, 1)) # orthogonal
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10)
assert [i.id for i in res] == [near.id, mid.id, far.id] # self excluded
@pytest.mark.asyncio
async def test_similar_excludes_null_embeddings(db):
src = await _img(db, 1, _vec(1, 0))
have = await _img(db, 2, _vec(1, 0.1))
await _img(db, 3, None) # un-embedded (e.g. a video) → excluded
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10)
assert [i.id for i in res] == [have.id]
@pytest.mark.asyncio
async def test_similar_source_without_embedding_returns_empty(db):
src = await _img(db, 1, None)
await _img(db, 2, _vec(1, 0))
svc = GalleryService(db)
assert await svc.similar(src.id, limit=10) == []
@pytest.mark.asyncio
async def test_similar_missing_source_returns_none(db):
svc = GalleryService(db)
assert await svc.similar(99999, limit=10) is None
@pytest.mark.asyncio
async def test_similar_excludes_presentation_tagged_images(db):
"""banner / editor-screenshot system tags (#128) hide from similar
RESULTS — they cluster on UI chrome, not content. A banner anchor still
gets results; `wip`-tagged images stay in (real art)."""
src = await _img(db, 1, _vec(1, 0))
bannered = await _img(db, 2, _vec(1, 0.02)) # nearest, but a banner
wipped = await _img(db, 3, _vec(1, 0.3))
plain = await _img(db, 4, _vec(1, 0.6))
banner_tag = (await db.execute(select(Tag).where(
Tag.is_system.is_(True), Tag.name == "banner"))).scalar_one()
wip_tag = (await db.execute(select(Tag).where(
Tag.is_system.is_(True), Tag.name == "wip"))).scalar_one()
await db.execute(image_tag.insert().values(
image_record_id=bannered.id, tag_id=banner_tag.id, source="manual"))
await db.execute(image_tag.insert().values(
image_record_id=wipped.id, tag_id=wip_tag.id, source="manual"))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10)
assert [i.id for i in res] == [wipped.id, plain.id]
# A presentation-tagged ANCHOR still answers — only candidates hide.
res_from_banner = await svc.similar(bannered.id, limit=10)
assert {i.id for i in res_from_banner} == {src.id, wipped.id, plain.id}
@pytest.mark.asyncio
async def test_similar_exclude_wip_drops_wip_neighbors(db):
"""Explore passes exclude_wip=True to also hide work-in-progress from the
rabbit-hole (banner is always hidden; wip only when asked)."""
src = await _img(db, 1, _vec(1, 0))
bannered = await _img(db, 2, _vec(1, 0.02)) # always hidden
wipped = await _img(db, 3, _vec(1, 0.3)) # hidden only with exclude_wip
plain = await _img(db, 4, _vec(1, 0.6))
banner_tag = (await db.execute(select(Tag).where(
Tag.is_system.is_(True), Tag.name == "banner"))).scalar_one()
wip_tag = (await db.execute(select(Tag).where(
Tag.is_system.is_(True), Tag.name == "wip"))).scalar_one()
await db.execute(image_tag.insert().values(
image_record_id=bannered.id, tag_id=banner_tag.id, source="manual"))
await db.execute(image_tag.insert().values(
image_record_id=wipped.id, tag_id=wip_tag.id, source="manual"))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10, exclude_wip=True)
assert [i.id for i in res] == [plain.id] # wip + banner both gone
# Default (gallery "similar" button) still keeps wip (#1274).
res_default = await svc.similar(src.id, limit=10)
assert wipped.id in {i.id for i in res_default}
@pytest.mark.asyncio
async def test_similar_composes_with_tag_filter(db):
src = await _img(db, 1, _vec(1, 0))
await _img(db, 2, _vec(1, 0.02)) # nearest, but untagged
tagged = await _img(db, 3, _vec(1, 0.6)) # farther, but carries the tag
tag = Tag(name="t", kind=TagKind.general)
db.add(tag)
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=tagged.id, tag_id=tag.id, source="manual"))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10, tag_ids=[tag.id])
assert [i.id for i in res] == [tagged.id] # scope AND-narrows the ranked set
@pytest.mark.asyncio
async def test_similar_collapses_near_duplicate_phashes(db):
# The reported failure: a reposted image fills the whole neighbour grid.
# A wall of same-pHash reposts must collapse to at most one, and the
# genuinely distinct images must still come through.
src = await _img(db, 1, _vec(1, 0))
dupes = []
for n in range(2, 7): # 5 near-identical reposts
r = await _img(db, n, _vec(1, 0.01 * n))
r.phash = "f" * 64 # identical perceptual hash
dupes.append(r)
# 64 hex chars = the 256-bit hash utils.phash emits (#4223, migration
# 0098). The widths must match the real ones: _diversify_similar's
# dup_threshold moved 8 -> 32 with the hash, so 64-bit fixtures would
# now read as near-duplicates of each other and collapse the very rows
# this asserts come through.
distinct_a = await _img(db, 7, _vec(1, 1))
distinct_a.phash = "0" * 64 # 256 bits away from the dupes
distinct_b = await _img(db, 8, _vec(0, 1))
distinct_b.phash = "0f" * 32 # 128 bits away
await db.flush()
res = await GalleryService(db).similar(src.id, limit=10)
ids = [i.id for i in res]
assert sum(1 for d in dupes if d.id in ids) <= 1 # repost wall collapsed
assert distinct_a.id in ids and distinct_b.id in ids
@pytest.mark.asyncio
async def test_similar_respects_limit(db):
src = await _img(db, 1, _vec(1, 0))
for n in range(2, 7):
await _img(db, n, _vec(1, 0.1 * n))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=2)
assert len(res) == 2
@pytest.mark.asyncio
async def test_similar_exclude_ids_drops_walked(db):
"""Explore passes its breadcrumb as exclude_ids so already-walked images
aren't re-served as neighbours (#1476). reach>0 runs cleanly too (small pool
→ the sampler passes through)."""
src = await _img(db, 1, _vec(1, 0))
walked = await _img(db, 2, _vec(1, 0.05))
fresh = await _img(db, 3, _vec(1, 0.3))
svc = GalleryService(db)
res = await svc.similar(src.id, limit=10, exclude_ids=[walked.id])
ids = {i.id for i in res}
assert walked.id not in ids
assert fresh.id in ids
res_reach = await svc.similar(src.id, limit=10, reach=1.0)
assert fresh.id in {i.id for i in res_reach}
# --- API ---
@pytest.mark.asyncio
async def test_api_similar_endpoint(client, db):
src = await _img(db, 1, _vec(1, 0))
near = await _img(db, 2, _vec(1, 0.05))
await db.commit()
resp = await client.get(f"/api/gallery/similar?similar_to={src.id}&limit=10")
assert resp.status_code == 200
body = await resp.get_json()
assert [i["id"] for i in body["images"]] == [near.id]
assert body["next_cursor"] is None
@pytest.mark.asyncio
async def test_api_similar_404_when_source_missing(client):
resp = await client.get("/api/gallery/similar?similar_to=99999")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_api_similar_requires_param(client):
resp = await client.get("/api/gallery/similar")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_image_detail_reports_has_embedding(client, db):
embedded = await _img(db, 1, _vec(1, 0))
plain = await _img(db, 2, None)
await db.commit()
e = await (await client.get(f"/api/gallery/image/{embedded.id}")).get_json()
p = await (await client.get(f"/api/gallery/image/{plain.id}")).get_json()
assert e["has_embedding"] is True
assert p["has_embedding"] is False