feat(explore): reach dial to escape dense clusters + anti-revisit (#1476)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m50s

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:
2026-07-13 12:06:22 -04:00
parent af0d39ed52
commit fac5ae6ce5
6 changed files with 146 additions and 5 deletions
+13 -1
View File
@@ -148,6 +148,17 @@ async def similar():
# Explore passes exclude_wip=1 to also drop work-in-progress from the # 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). # rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True") 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. # post_id is the exclusive post-detail view — not a similarity scope.
# include_hidden is a gallery-browse flag; similar() has its OWN presentation # include_hidden is a gallery-browse flag; similar() has its OWN presentation
# exclusion (a similarity-quality concern, #1274), so drop it here (#141). # exclusion (a similarity-quality concern, #1274), so drop it here (#141).
@@ -158,7 +169,8 @@ async def similar():
svc = GalleryService(session) svc = GalleryService(session)
try: try:
images = await svc.similar( 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: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
if images is None: if images is None:
+35 -1
View File
@@ -396,6 +396,25 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40):
return [kept[i] for i in order] 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]: async def _artists_for(session, image_ids: list[int]) -> dict[int, dict]:
"""Map image_id -> {"name","slug"} via the canonical """Map image_id -> {"name","slug"} via the canonical
image_record.artist_id (FC-2d-vii-c). Bounded by page size.""" 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, untagged: bool = False, no_artist: bool = False,
date_from: datetime | None = None, date_to: datetime | None = None, date_from: datetime | None = None, date_to: datetime | None = None,
exclude_wip: bool = False, exclude_wip: bool = False,
reach: float = 0.0, exclude_ids: list[int] | None = None,
) -> list[GalleryImage] | None: ) -> list[GalleryImage] | None:
"""Visual "more like this": images near `image_id`'s SigLIP embedding """Visual "more like this": images near `image_id`'s SigLIP embedding
(pgvector, HNSW-indexed — alembic 0036), then DIVERSIFIED so the result (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 # 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 # (5×→8×, cap 200→400) so the stronger MMR has genuinely distinct
# neighbourhoods to reach into for more variance (operator, 2026-07-01). # 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) distance = ImageRecord.siglip_embedding.cosine_distance(src.siglip_embedding)
eff = _effective_date_col() eff = _effective_date_col()
stmt = select(ImageRecord, Post.post_date, eff.label("eff")) stmt = select(ImageRecord, Post.post_date, eff.label("eff"))
@@ -773,6 +799,10 @@ class GalleryService:
ImageRecord.id != image_id, ImageRecord.id != image_id,
ImageRecord.id.not_in(presentation), 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 = _apply_scope(
stmt, tag_ids=tag_ids, post_id=None, stmt, tag_ids=tag_ids, post_id=None,
artist_id=artist_id, media_type=media_type, artist_id=artist_id, media_type=media_type,
@@ -782,6 +812,10 @@ class GalleryService:
) )
stmt = stmt.order_by(distance.asc()).limit(pool_n) stmt = stmt.order_by(distance.asc()).limit(pool_n)
rows = (await self.session.execute(stmt)).all() 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) rows = _diversify_similar(src, rows, limit)
artists = await _artists_for(self.session, [r[0].id for r in rows]) artists = await _artists_for(self.session, [r[0].id for r in rows])
return _gallery_images(rows, artists) return _gallery_images(rows, artists)
+21 -3
View File
@@ -25,6 +25,10 @@ export const useExploreStore = defineStore('explore', () => {
const cursor = ref(-1) const cursor = ref(-1)
const loading = ref(false) const loading = ref(false)
const error = ref(null) 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() const inflight = useInflightToken()
@@ -46,7 +50,13 @@ export const useExploreStore = defineStore('explore', () => {
const body = await api.get('/api/gallery/similar', { const body = await api.get('/api/gallery/similar', {
// exclude_wip: keep work-in-progress out of the Explore rabbit-hole // 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. // (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 if (!t.isCurrent()) return
neighbors.value = body.images || [] neighbors.value = body.images || []
@@ -113,6 +123,14 @@ export const useExploreStore = defineStore('explore', () => {
loading.value = false 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 --------------------------------------------- // --- TagPanel "host" surface ---------------------------------------------
// The anchor IS the current image (same /api/gallery/image/<id> payload the // 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 // modal uses), so these mirror the modal store's tag-CRUD, targeting the
@@ -189,8 +207,8 @@ export const useExploreStore = defineStore('explore', () => {
function close () {} function close () {}
return { return {
anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, anchor, neighbors, breadcrumb, cursor, loading, error, NEIGHBOR_LIMIT, reach,
anchorOn, reset, backTarget, forwardTarget, anchorOn, reset, backTarget, forwardTarget, setReach,
// host surface // host surface
current, currentImageId, current, currentImageId,
reloadTags, addExistingTag, removeTag, createAndAdd, close, reloadTags, addExistingTag, removeTag, createAndAdd, close,
+28
View File
@@ -31,6 +31,20 @@
<img :src="c.thumbnail_url" alt="" loading="lazy" /> <img :src="c.thumbnail_url" alt="" loading="lazy" />
</button> </button>
<div class="fc-ex__trail-actions"> <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 <!-- Active retrain right where you tag: fold the +/- you just gave
into the heads without a trip to Settings (the nightly beat is the into the heads without a trip to Settings (the nightly beat is the
passive cadence). --> passive cadence). -->
@@ -153,6 +167,12 @@ const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null) const anchorId = computed(() => route.params.imageId || null)
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/')) 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 // #1206: hovering a suggestion in the rail highlights the crop it came from on
// the anchor image (same provide/inject as the modal viewer). // the anchor image (same provide/inject as the modal viewer).
@@ -296,6 +316,14 @@ onUnmounted(() => {
.fc-ex__trail-actions { .fc-ex__trail-actions {
margin-left: auto; display: flex; align-items: center; gap: 4px; flex: 0 0 auto; 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. /* 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 grid-template-rows: minmax(0, 1fr) BOUNDS the single row to the container
+17
View File
@@ -170,6 +170,23 @@ async def test_similar_respects_limit(db):
assert len(res) == 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 --- # --- API ---
@pytest.mark.asyncio @pytest.mark.asyncio
+32
View File
@@ -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