Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,13 +1042,24 @@ def _on_notify(_channel, payload):
logger.exception("Hyperbolic Explorer cache reload failed")
hyper_success = False

try:
from tasks.hyperbolic_index import load_hyperbolic_index

load_hyperbolic_index(force_reload=True)
logger.info("Reloading Hyperbolic Poincare index...")
hyper_index_success = True
except Exception:
logger.exception("Hyperbolic Poincare index reload failed")
hyper_index_success = False

logger.info(
"In-memory reload complete: IVF OK, Artist OK, Maps OK, CLAP %s, "
"Lyrics %s, SemGrove %s, Hyperbolic %s",
"Lyrics %s, SemGrove %s, Hyperbolic %s, Poincare %s",
'OK' if clap_success else 'X',
'OK' if lyrics_success else 'X',
'OK' if sg_success else 'X',
'OK' if hyper_success else 'X',
'OK' if hyper_index_success else 'X',
)
except Exception:
logger.exception("Error reloading indexes/maps from background listener")
Expand Down Expand Up @@ -1229,6 +1240,20 @@ def _boot_plugins_web():
)
except Exception as e:
logger.debug(f"SemGrove cache not loaded at startup: {e}")
# Load the Hyperbolic Explorer Poincare index directory (band vectors
# stay on disk and are decoded on demand under a memory cap).
try:
from tasks.hyperbolic_index import load_hyperbolic_index

hyper_index_servers = load_hyperbolic_index()
if hyper_index_servers:
logger.info("Hyperbolic Poincare index loaded at startup.")
else:
logger.info(
"Hyperbolic Poincare index not found at startup (run analysis to build it)."
)
except Exception as e:
logger.debug(f"Hyperbolic Poincare index not loaded at startup: {e}")

# Every load above streams a large directory blob out of Postgres and
# discards it once unpacked. Those frees land in the allocator's free
Expand All @@ -1249,6 +1274,7 @@ def _log_startup_index_profile():
from tasks.clap_text_search import get_clap_cache_size
from tasks.lyrics_manager import get_cache_stats as _lyrics_stats
from tasks.sem_grove_manager import get_sem_grove_stats as _sg_stats
from tasks.hyperbolic_index import get_hyperbolic_index_stats

audio = len(_ivf_mgr.id_map) if _ivf_mgr.id_map else 0
artist = len(_artist_mgr.artist_map) if _artist_mgr.artist_map else 0
Expand All @@ -1265,18 +1291,18 @@ def _log_startup_index_profile():
clap = get_clap_cache_size()
lyrics = _lyrics_stats()
sg = _sg_stats()
hyper = get_hyperbolic_index_stats()
logger.info(
"Startup index profile: audio=%d artist=%d map=%d artist_proj=%d clap=%d "
"lyrics=%d (%.2f MB) semgrove=%d (%.2f MB)",
"lyrics=%d semgrove=%d hyper=%d",
audio,
artist,
map_proj,
artist_proj,
clap,
lyrics.get('song_count', 0),
lyrics.get('memory_mb', 0.0),
sg.get('song_count', 0),
sg.get('memory_mb', 0.0),
hyper.get('song_count', 0),
)
except Exception:
logger.exception("Startup index profile logging failed")
Expand Down
202 changes: 197 additions & 5 deletions app_hyperbolic.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@
other indexes), so it is not loaded at Flask startup. The page calls
warmup on load so the tree is ready for the first browse, and it
auto-unloads after HYPERBOLIC_TREE_WARMUP_DURATION idle seconds.
* POST /api/hyperbolic/journey: the Geodesic Journey. Walks the exact Poincare
geodesic between two songs, snapping every waypoint to a real song, and
returns the ordered walk plus the apex (the continuous lowest common
ancestor of the two endpoints) and an exact 2-plane drawing of the path.
Delegates to ``tasks.hyperbolic_journey_manager``; the engine reads the
projected catalogue directly and ranks it by exact Poincare distance, with
no IVF index and no cosine shortcut.
* GET /api/hyperbolic/cache_status: read-only diagnostic for that cache.
* GET /hyperbolic: the two-tab explorer page.
* GET /hyperbolic: the three-tab explorer page.
"""

import logging
Expand Down Expand Up @@ -66,6 +73,10 @@ def hyperbolic_page():
active="hyperbolic",
app_version=APP_VERSION,
hyperbolic_radial_spread_default=min(max(config.HYPERBOLIC_RADIAL_SPREAD, 0.0), 0.99),
hyperbolic_journey_length_default=config.HYPERBOLIC_JOURNEY_DEFAULT_LENGTH,
hyperbolic_journey_dive_default=min(
max(config.HYPERBOLIC_JOURNEY_ANCESTRY_DIVE, 0.0), 0.95
),
)
except Exception:
logger.exception("Error rendering hyperbolic.html")
Expand All @@ -79,9 +90,10 @@ def hyperbolic_similar_api():
---
tags:
- Hyperbolic Explorer
summary: Re-rank candidates by exact Poincare distance. similar re-ranks
raw-space IVF neighbors; roots / niche draw their pool by radius (at
least radial_spread, default HYPERBOLIC_RADIAL_SPREAD and
summary: Rank the projected catalogue by exact Poincare distance. similar
ranks every projected row directly (no IVF index and no cosine
shortcut); roots / niche draw their pool by radius (at least
radial_spread, default HYPERBOLIC_RADIAL_SPREAD and
caller-overridable, of the radial range away from the seed) so they
visibly move inward / outward instead of hugging the seed's radius
band.
Expand Down Expand Up @@ -197,7 +209,11 @@ def hyperbolic_similar_api():
return jsonify({"error": '"radial_spread" must be between 0 and 0.99.'}), 400

canonical_id = app_server_context.resolve_input_item_id(item_id, data)
results = hyperbolic_similar(canonical_id, mode=mode, limit=limit, radial_spread=radial_spread)
server_id = app_server_context.resolve_request_server_id(data)
results = hyperbolic_similar(
canonical_id, mode=mode, limit=limit, radial_spread=radial_spread,
server_id=server_id,
)
_attach_title_author(results)
attach_song_features(results)
results = app_server_context.scope_results(results, id_key="item_id")
Expand Down Expand Up @@ -233,6 +249,182 @@ def _attach_title_author(results):
r["author"] = info.get("author") if info else None


@hyperbolic_bp.route("/api/hyperbolic/journey", methods=["POST"])
def hyperbolic_journey_api():
"""
Geodesic Journey between two songs.
---
tags:
- Hyperbolic Explorer
summary: Walk the exact Poincare geodesic from one song to another and snap
every waypoint to a real song.
description: >-
A geodesic in negatively curved space bows toward the origin, so the walk
descends through the region general enough to contain both endpoints - the
continuous analogue of their lowest common ancestor - and climbs back out
toward the destination, instead of blending them the way a straight line
through raw space does. Steps are evenly spaced in hyperbolic arc length,
so each one covers the same musical distance. Candidate generation reads
the projected catalogue directly and ranks it by exact Poincare distance,
with no IVF index and no cosine shortcut.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [start_item_id, end_item_id]
properties:
start_item_id:
type: string
description: Media server item id the journey starts from.
end_item_id:
type: string
description: Media server item id the journey ends at.
length:
type: integer
minimum: 3
description: >-
Songs in the walk INCLUDING both endpoints. Defaults to
HYPERBOLIC_JOURNEY_DEFAULT_LENGTH.
ancestry_dive:
type: number
format: float
minimum: 0
maximum: 0.95
description: >-
How much deeper than the true geodesic the walk dips toward
the origin, through a bump that is zero at both endpoints. 0
is the exact shortest geodesic; higher values take a longer
detour through more general territory. Defaults to
HYPERBOLIC_JOURNEY_ANCESTRY_DIVE.
responses:
200:
description: The ordered walk, its shared root, and the drawable path.
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
item_id:
type: string
title:
type: string
author:
type: string
album:
type: string
step:
type: integer
t:
type: number
format: float
distance:
type: number
format: float
description: Poincare distance from the ideal waypoint of that step.
hyperbolic_radius:
type: number
format: float
waypoint_radius:
type: number
format: float
plane_angle:
type: number
format: float
description: Angle in the 2-plane the geodesic lives in, for the disk drawing.
is_endpoint:
type: boolean
region:
type: object
nullable: true
description: Nearest genre/subgenre centroid to the song.
count:
type: integer
requested_length:
type: integer
ancestry_dive:
type: number
format: float
geodesic_length:
type: number
format: float
description: Exact Poincare distance between the two endpoints.
start_radius:
type: number
format: float
end_radius:
type: number
format: float
apex:
type: object
description: >-
The point of the geodesic closest to the origin - the
continuous lowest common ancestor of the two songs - with
its radius, its angle in the drawing plane, and the
genre/subgenre region it falls in.
path:
type: array
description: Samples of the ideal geodesic as {t, radius, angle}.
items:
type: object
start_item_id:
type: string
end_item_id:
type: string
400:
description: Missing or identical endpoints, a song without a projection, or a bad parameter.
500:
description: Internal error.
"""
import app_server_context
from app_helper import attach_song_features
from tasks.hyperbolic_journey_manager import build_hyperbolic_journey

try:
data = request.get_json() or {}
start_item_id = (data.get("start_item_id") or "").strip()
end_item_id = (data.get("end_item_id") or "").strip()
if not start_item_id or not end_item_id:
return jsonify({"error": 'Both "start_item_id" and "end_item_id" are required.'}), 400

canonical_start = app_server_context.resolve_input_item_id(start_item_id, data)
canonical_end = app_server_context.resolve_input_item_id(end_item_id, data)
server_id = app_server_context.resolve_request_server_id(data)
journey = build_hyperbolic_journey(
canonical_start,
canonical_end,
length=data.get("length"),
ancestry_dive=data.get("ancestry_dive"),
server_id=server_id,
)
_attach_title_author(journey["results"])
attach_song_features(journey["results"])
# Scoping can drop an interior step whose track is not on the selected
# server; the walk simply gets shorter, it never leaks a canonical id.
journey["results"] = app_server_context.scope_results(
journey["results"], id_key="item_id"
)
journey["count"] = len(journey["results"])
endpoints = app_server_context.translate_ids_for_request(
[canonical_start, canonical_end]
)
journey["start_item_id"] = endpoints.get(canonical_start) or canonical_start
journey["end_item_id"] = endpoints.get(canonical_end) or canonical_end
return jsonify(journey)

except ValueError as exc:
return jsonify({"error": str(exc)}), 400
Comment thread
NeptuneHub marked this conversation as resolved.
Dismissed
except Exception:
logger.exception("Hyperbolic geodesic journey failed")
return jsonify({"error": _INTERNAL_ERROR_MSG}), 500


@hyperbolic_bp.route("/api/hyperbolic/tree", methods=["GET"])
def hyperbolic_tree_api():
"""
Expand Down
46 changes: 42 additions & 4 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ def _compute_headers():
}

# --- General Constants (Read from Environment Variables where applicable) ---
APP_VERSION = "v3.4.0"
APP_VERSION = "v3.5.0"
MAX_DISTANCE = float(os.environ.get("MAX_DISTANCE", "0.5"))
MAX_SONGS_PER_CLUSTER = int(os.environ.get("MAX_SONGS_PER_CLUSTER", "0"))
MAX_SONGS_PER_ARTIST = int(os.getenv("MAX_SONGS_PER_ARTIST", "3")) # Max songs per artist in similarity results and clustering
Expand Down Expand Up @@ -770,7 +770,9 @@ def _compute_headers():
# None means auto-calibrate once and persist the result in app_config.
HYPERBOLIC_RADIUS_SCALE = float(os.environ.get("HYPERBOLIC_RADIUS_SCALE") or "0") or None
HYPERBOLIC_RADIUS_PERCENTILE = float(os.environ.get("HYPERBOLIC_RADIUS_PERCENTILE", "95"))
# Raw-space IVF candidate over-fetch multiplier before hyperbolic re-ranking.
# Candidate over-fetch multiplier applied before the hyperbolic re-ranking and
# de-duplication pass (roots / niche radius windows and the similar mode's
# pre-dedup slice over the projected catalogue).
HYPERBOLIC_CANDIDATE_OVERFETCH = int(os.environ.get("HYPERBOLIC_CANDIDATE_OVERFETCH", "4"))
# Fraction of the radial range that roots/niche modes must move before a
# candidate qualifies, so the two modes visibly differ from plain similar.
Expand Down Expand Up @@ -809,6 +811,38 @@ def _compute_headers():
# and auto-unloads after this idle period to free RAM.
HYPERBOLIC_TREE_WARMUP_DURATION = int(os.environ.get("HYPERBOLIC_TREE_WARMUP_DURATION", "300"))

# Geodesic Journey: the walk along the exact Poincare geodesic between two
# songs. A geodesic in negatively curved space bows toward the origin, so the
# walk descends through the region general enough to contain both endpoints
# (the continuous analogue of their lowest common ancestor) and climbs back
# out - which is what makes it different from the Sonic Path page's straight
# line through raw space.
# Number of songs in the walk INCLUDING both endpoints. Evenly spaced t on a
# Poincare geodesic is evenly spaced hyperbolic arc length, so every step
# covers the same musical distance.
HYPERBOLIC_JOURNEY_DEFAULT_LENGTH = int(os.environ.get("HYPERBOLIC_JOURNEY_DEFAULT_LENGTH", "25"))
# The walk ranks the whole projected catalogue by exact Poincare distance
# directly against the embedding table - no IVF index and no cosine shortcut.
# How much deeper than the true geodesic the walk dips toward the origin,
# through a bump that is zero at both endpoints. 0 is the exact (shortest)
# geodesic; higher values buy a longer detour through more general territory
# without moving where the walk starts or ends.
HYPERBOLIC_JOURNEY_ANCESTRY_DIVE = float(os.environ.get("HYPERBOLIC_JOURNEY_ANCESTRY_DIVE", "0.20"))
# Samples of the ideal geodesic returned for the Poincare disk drawing. The
# whole geodesic lies in the 2-plane spanned by its endpoints, so these are an
# exact picture of it, not an approximation of a higher-dimensional curve.
HYPERBOLIC_JOURNEY_PATH_SAMPLES = int(os.environ.get("HYPERBOLIC_JOURNEY_PATH_SAMPLES", "96"))
# Disk-paged Poincare IVF index for the Hyperbolic Explorer: the projected
# catalogue is partitioned by hyperbolic k-means into 8*sqrt(n) cells (the same
# rule and the same IVF_NLIST_MAX / IVF_TRAIN_POINTS_PER_CELL / IVF_NPROBE knobs
# as the other IVF indexes above), so the cell count grows with the library.
# The cell directory and the coarse centroids live in memory; the cell vectors
# stay in ivf_dir and are decoded on demand, bounded by HYPERBOLIC_INDEX_CACHE_MB
# so the full projected set never sits in RAM.
HYPERBOLIC_INDEX_CACHE_MB = int(os.environ.get("HYPERBOLIC_INDEX_CACHE_MB", "256"))
# Exact nearest candidates per journey waypoint pulled from the Poincare index.
HYPERBOLIC_JOURNEY_CANDIDATES_PER_STEP = int(os.environ.get("HYPERBOLIC_JOURNEY_CANDIDATES_PER_STEP", "60"))

# --- CLAP Model Constants (for text search) ---
CLAP_ENABLED = os.environ.get("CLAP_ENABLED", "true").lower() == "true"
# Lyrics analysis feature toggle. When false, the lyrics step is skipped entirely.
Expand Down Expand Up @@ -1034,8 +1068,12 @@ def _compute_headers():
# query reads only the nearest IVF_NPROBE cells, so the Flask container's resident
# index memory is bounded by IVF_QUERY_CACHE_MB per index instead of growing with
# the library size. Cell vectors are quantized per IVF_STORAGE_DTYPE (coarse
# centroids stay float32, so cell selection / recall is unaffected).
IVF_STORAGE_DTYPE = os.environ.get("IVF_STORAGE_DTYPE", "i8").lower() # Stored cell-vector precision: 'i8' (int8; angular only, euclidean/dot auto-fall to f16), 'f16', or 'f32' (no quantization). Smaller = less RAM/IO; distances are computed directly in that dtype via NumKong with a NumPy fallback. Changing this takes effect on the next index rebuild.
# centroids stay float32, so cell selection / recall is unaffected). The same
# setting also sizes the Hyperbolic Explorer's disk-paged Poincare index bands,
# which take it literally (i8 stays i8 there, no f16 downgrade) and absorb the
# coarser grid by overfetching the band scan and re-ranking those candidates on
# the exact float32 poincare_embedding rows before returning.
IVF_STORAGE_DTYPE = os.environ.get("IVF_STORAGE_DTYPE", "i8").lower() # Stored vector precision for EVERY index: 'i8' (int8; the IVF indexes fall back to f16 for euclidean/dot, the Poincare index keeps i8), 'f16', or 'f32' (no quantization). Smaller = less RAM/IO; distances are computed directly in that dtype via NumKong with a NumPy fallback. Changing this takes effect on the next index rebuild.
IVF_NLIST_MAX = int(os.environ.get("IVF_NLIST_MAX", "8192")) # Upper cap on number of IVF cells (coarse centroids)
IVF_TRAIN_POINTS_PER_CELL = int(os.environ.get("IVF_TRAIN_POINTS_PER_CELL", "50")) # Target training vectors per cell; sample = this x nlist, capped at n_items (FAISS floor ~39)
IVF_MAX_CELL_MB = int(os.environ.get("IVF_MAX_CELL_MB", "12")) # Oversized cells are split so no single cell exceeds this
Expand Down
Loading
Loading