feat(explore): reach dial to escape dense clusters + anti-revisit (#1476)
The Explore walk got stuck in dense signatures — neighbours all too similar, so forward-arrow couldn't escape and Random was the only exit. Root cause: MMR only diversifies WITHIN the nearest ~400 pool; in a dense cluster that whole pool is near-identical, so there's no escape route in it. - gallery_service.similar(reach=0.0, exclude_ids=None): reach>0 widens the pool (cap 400→1000) and _reach_sample strides across an outward-growing distance span so the set handed to MMR spans near→mid-far (guaranteed escape routes), not just the tight cluster. exclude_ids drops already-walked images. Gallery 'more like this' (reach=0) is unchanged. - api/gallery similar: parse reach + exclude_ids. - explore store: default reach 0.4 (auto-diversifies without touching the dial), pass the breadcrumb as exclude_ids, setReach action. - ExploreView: a Near↔Far reach slider in the trail. - tests: _reach_sample math (deeper ranks with higher reach, near kept); similar exclude_ids drops walked + reach path runs clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
|
||||
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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Explore reach sampler (#1476) — pure unit tests for `_reach_sample`, which picks
|
||||
a distance-rank spread so the neighbour pool handed to MMR spans near→mid-far and
|
||||
the walk can escape a dense cluster. No DB. `_reach_sample` only indexes rows, so
|
||||
plain ints stand in for the (ImageRecord, ...) tuples."""
|
||||
from backend.app.services.gallery_service import _reach_sample
|
||||
|
||||
|
||||
def test_reach_zero_or_negative_passes_through():
|
||||
rows = list(range(1000))
|
||||
assert _reach_sample(rows, 40, 0.0) is rows
|
||||
assert _reach_sample(rows, 40, -1.0) is rows
|
||||
|
||||
|
||||
def test_small_pool_passes_through():
|
||||
# n <= want (limit*8 = 320) → nothing to reach into.
|
||||
rows = list(range(50))
|
||||
assert _reach_sample(rows, 40, 1.0) is rows
|
||||
|
||||
|
||||
def test_higher_reach_reaches_deeper_ranks():
|
||||
rows = list(range(1000))
|
||||
near = _reach_sample(rows, 40, 0.2)
|
||||
far = _reach_sample(rows, 40, 1.0)
|
||||
# Both keep the nearest rank (stride starts at 0) so you can still tag the cluster.
|
||||
assert near[0] == 0
|
||||
assert far[0] == 0
|
||||
# But higher reach samples genuinely farther ranks.
|
||||
assert max(far) > max(near)
|
||||
assert max(far) >= 900 # reach=1 spans (almost) the whole pool
|
||||
assert max(near) <= 550 # reach=0.2 stays in the near half
|
||||
# Never runs past the pool.
|
||||
assert max(far) <= len(rows) - 1
|
||||
Reference in New Issue
Block a user