diff --git a/backend/app/api/gallery.py b/backend/app/api/gallery.py index 7ef43f1..b5f8d1c 100644 --- a/backend/app/api/gallery.py +++ b/backend/app/api/gallery.py @@ -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: diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 7f365a3..3f124db 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -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) diff --git a/frontend/src/stores/explore.js b/frontend/src/stores/explore.js index 5adb26b..1c44628 100644 --- a/frontend/src/stores/explore.js +++ b/frontend/src/stores/explore.js @@ -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/ 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, diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue index bb893d5..74a2650 100644 --- a/frontend/src/views/ExploreView.vue +++ b/frontend/src/views/ExploreView.vue @@ -31,6 +31,20 @@
+ +
+ mdi-map-marker-distance + + {{ reachLabel }} +
@@ -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 diff --git a/tests/test_gallery_similar.py b/tests/test_gallery_similar.py index a54ac1e..2bd5e90 100644 --- a/tests/test_gallery_similar.py +++ b/tests/test_gallery_similar.py @@ -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 diff --git a/tests/test_reach_sample.py b/tests/test_reach_sample.py new file mode 100644 index 0000000..5c66d20 --- /dev/null +++ b/tests/test_reach_sample.py @@ -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