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:
@@ -148,6 +148,17 @@ async def similar():
|
||||
# Explore passes exclude_wip=1 to also drop work-in-progress from the
|
||||
# rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
|
||||
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
|
||||
# Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
|
||||
# distance bands so the walk can escape a dense cluster. exclude_ids = the
|
||||
# breadcrumb, so already-walked images aren't re-served as neighbours.
|
||||
try:
|
||||
reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
|
||||
except ValueError:
|
||||
reach = 0.0
|
||||
exclude_ids = [
|
||||
int(x) for x in request.args.get("exclude_ids", "").split(",")
|
||||
if x.strip().isdigit()
|
||||
] or None
|
||||
# post_id is the exclusive post-detail view — not a similarity scope.
|
||||
# include_hidden is a gallery-browse flag; similar() has its OWN presentation
|
||||
# exclusion (a similarity-quality concern, #1274), so drop it here (#141).
|
||||
@@ -158,7 +169,8 @@ async def similar():
|
||||
svc = GalleryService(session)
|
||||
try:
|
||||
images = await svc.similar(
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip, **scope)
|
||||
image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
|
||||
reach=reach, exclude_ids=exclude_ids, **scope)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if images is None:
|
||||
|
||||
@@ -396,6 +396,25 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
|
||||
return [kept[i] for i in order]
|
||||
|
||||
|
||||
def _reach_sample(rows, limit, reach):
|
||||
"""From a distance-sorted candidate pool (nearest first), pick a spread of ranks
|
||||
that MIXES near (tag the current cluster) and mid-far (escape it) BEFORE dedup +
|
||||
MMR — the Explore "reach" dial (#1476).
|
||||
|
||||
reach in (0, 1]: the sampled span grows outward from the anchor (0.25→1.0 of the
|
||||
pool), evenly strided from rank 0 so the nearest are still represented. In a
|
||||
dense signature the nearest ranks are near-identical, so reaching farther is the
|
||||
only way to hand MMR genuinely different content — MMR alone can't escape a pool
|
||||
that's already all-near. reach<=0 or a small pool passes through unchanged."""
|
||||
n = len(rows)
|
||||
want = max(limit * 8, 100)
|
||||
if reach <= 0 or n <= want:
|
||||
return rows
|
||||
span = int(min(1.0, 0.25 + 0.75 * reach) * n)
|
||||
idx = sorted({min(int(i * span / want), n - 1) for i in range(want)})
|
||||
return [rows[i] for i in idx]
|
||||
|
||||
|
||||
async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
|
||||
"""Map image_id -> {"name","slug"} via the canonical
|
||||
image_record.artist_id (FC-2d-vii-c). Bounded by page size."""
|
||||
@@ -717,6 +736,7 @@ class GalleryService:
|
||||
untagged: bool = False, no_artist: bool = False,
|
||||
date_from: datetime | None = None, date_to: datetime | None = None,
|
||||
exclude_wip: bool = False,
|
||||
reach: float = 0.0, exclude_ids: list[int] | None = None,
|
||||
) -> list[GalleryImage] | None:
|
||||
"""Visual "more like this": images near `image_id`'s SigLIP embedding
|
||||
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result
|
||||
@@ -745,7 +765,13 @@ class GalleryService:
|
||||
# wide pool there's nothing but the near-dupes to choose from. Widened
|
||||
# (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
|
||||
# neighbourhoods to reach into for more variance (operator, 2026-07-01).
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
# Explore's reach>0 (#1476) widens it a LOT more: in a dense signature the
|
||||
# nearest few hundred are all near-identical, so far-enough candidates only
|
||||
# exist deeper in the ranked pool. _reach_sample then strides across them.
|
||||
if reach > 0:
|
||||
pool_n = min(1000, max(limit * 25, 100))
|
||||
else:
|
||||
pool_n = min(400, max(limit * 8, 100))
|
||||
distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
|
||||
eff = _effective_date_col()
|
||||
stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
|
||||
@@ -773,6 +799,10 @@ class GalleryService:
|
||||
ImageRecord.id != image_id,
|
||||
ImageRecord.id.not_in(presentation),
|
||||
)
|
||||
# Anti-revisit (#1476): the Explore walk passes its breadcrumb so already-
|
||||
# walked images aren't re-served as neighbours — → can't loop you back in.
|
||||
if exclude_ids:
|
||||
stmt = stmt.where(ImageRecord.id.not_in(exclude_ids))
|
||||
stmt = _apply_scope(
|
||||
stmt, tag_ids=tag_ids, post_id=None,
|
||||
artist_id=artist_id, media_type=media_type,
|
||||
@@ -782,6 +812,10 @@ class GalleryService:
|
||||
)
|
||||
stmt = stmt.order_by(distance.asc()).limit(pool_n)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
# Explore reach: stride across an outward-growing distance span so the pool
|
||||
# handed to MMR spans near→mid-far, not just the tight cluster (#1476).
|
||||
if reach > 0:
|
||||
rows = _reach_sample(rows, limit, reach)
|
||||
rows = _diversify_similar(src, rows, limit)
|
||||
artists = await _artists_for(self.session, [r[0].id for r in rows])
|
||||
return _gallery_images(rows, artists)
|
||||
|
||||
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
const cursor = ref(-1)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
// Reach (#1476): how far the walk reaches past the anchor's immediate cluster.
|
||||
// 0 = nearest (can get stuck in a dense signature); ~0.4 default mixes in
|
||||
// mid-far escape routes so the walk diversifies without hitting "Random image".
|
||||
const reach = ref(0.4)
|
||||
|
||||
const inflight = useInflightToken()
|
||||
|
||||
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
const body = await api.get('/api/gallery/similar', {
|
||||
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole
|
||||
// (the gallery's own "similar" button still shows it) — operator 2026-07-08.
|
||||
params: { similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1 },
|
||||
// reach + exclude_ids (#1476): reach past the dense cluster + never re-serve
|
||||
// an already-walked image, so the walk keeps moving instead of getting stuck.
|
||||
params: {
|
||||
similar_to: numId, limit: NEIGHBOR_LIMIT, exclude_wip: 1,
|
||||
reach: reach.value,
|
||||
exclude_ids: breadcrumb.value.map((c) => c.id).join(','),
|
||||
},
|
||||
})
|
||||
if (!t.isCurrent()) return
|
||||
neighbors.value = body.images || []
|
||||
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// Change how far the walk reaches and re-fetch the current anchor's neighbours
|
||||
// with the new setting (the anchor + trail are unchanged — only the grid varies).
|
||||
function setReach (v) {
|
||||
reach.value = Math.max(0, Math.min(1, Number(v)))
|
||||
const id = anchor.value?.id
|
||||
if (id != null) anchorOn(id)
|
||||
}
|
||||
|
||||
// --- TagPanel "host" surface ---------------------------------------------
|
||||
// The anchor IS the current image (same /api/gallery/image/<id> payload the
|
||||
// modal uses), so these mirror the modal store's tag-CRUD, targeting the
|
||||
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
|
||||
function close () {}
|
||||
|
||||
return {
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT,
|
||||
anchorOn, reset, backTarget, forwardTarget,
|
||||
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
|
||||
anchorOn, reset, backTarget, forwardTarget, setReach,
|
||||
// host surface
|
||||
current, currentImageId,
|
||||
reloadTags, addExistingTag, removeTag, createAndAdd, close,
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
<img :src="c.thumbnail_url" alt="" loading="lazy" />
|
||||
</button>
|
||||
<div class="fc-ex__trail-actions">
|
||||
<!-- Reach (#1476): how far each step reaches past the anchor's cluster.
|
||||
Raise it to break out of a dense signature without hitting Random. -->
|
||||
<div
|
||||
class="fc-ex__reach"
|
||||
title="How far each step reaches — raise it to escape a dense cluster without going fully random"
|
||||
>
|
||||
<v-icon size="16" color="accent">mdi-map-marker-distance</v-icon>
|
||||
<v-slider
|
||||
:model-value="store.reach" @end="store.setReach"
|
||||
:min="0" :max="1" :step="0.2" hide-details density="compact"
|
||||
color="accent" class="fc-ex__reach-slider"
|
||||
/>
|
||||
<span class="fc-muted fc-ex__reach-label">{{ reachLabel }}</span>
|
||||
</div>
|
||||
<!-- Active retrain right where you tag: fold the +/- you just gave
|
||||
into the heads without a trip to Settings (the nightly beat is the
|
||||
passive cadence). -->
|
||||
@@ -153,6 +167,12 @@ const modal = useModalStore()
|
||||
|
||||
const anchorId = computed(() => route.params.imageId || null)
|
||||
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
|
||||
const reachLabel = computed(() => {
|
||||
const r = store.reach
|
||||
if (r <= 0.15) return 'Near'
|
||||
if (r <= 0.55) return 'Varied'
|
||||
return 'Far'
|
||||
})
|
||||
|
||||
// #1206: hovering a suggestion in the rail highlights the crop it came from on
|
||||
// the anchor image (same provide/inject as the modal viewer).
|
||||
@@ -296,6 +316,14 @@ onUnmounted(() => {
|
||||
.fc-ex__trail-actions {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto;
|
||||
}
|
||||
.fc-ex__reach {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.fc-ex__reach-slider { width: 96px; }
|
||||
.fc-ex__reach-label {
|
||||
font-size: 12px; min-width: 44px; text-align: left;
|
||||
}
|
||||
|
||||
/* The three panes fill the remaining height; each scrolls on its own.
|
||||
grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
|
||||
|
||||
@@ -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