Spaces:
Running on Zero
Running on Zero
| """Central configuration: paths, Paris bounds, travel constants, defaults. | |
| Everything tunable lives here so behaviour is inspectable, not scattered. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| from pathlib import Path | |
| # --- Paths ------------------------------------------------------------------- | |
| PKG_ROOT = Path(__file__).resolve().parent | |
| PROJECT_ROOT = PKG_ROOT.parent.parent | |
| CACHE_DIR = PROJECT_ROOT / "cache" | |
| DATA_DIR = PROJECT_ROOT / "data" | |
| CACHE_DIR.mkdir(exist_ok=True) | |
| DATA_DIR.mkdir(exist_ok=True) | |
| # Cached offline artifacts (committed for the Space; built by data/build_graph.py). | |
| GRAPH_WALK_PATH = DATA_DIR / "paris_walk.graphml" | |
| POIS_PATH = DATA_DIR / "paris_pois.parquet" | |
| # --- Data provenance / freshness -------------------------------------------- | |
| # The app runs on a static OSM snapshot; this manifest (written by build_pois.py) | |
| # records when it was built so the UI can show an honest "as of <date>" line. | |
| DATA_MANIFEST_PATH = DATA_DIR / "build_manifest.json" | |
| def _load_manifest() -> dict: | |
| try: | |
| return json.loads(DATA_MANIFEST_PATH.read_text(encoding="utf-8")) | |
| except Exception: # noqa: BLE001 - missing/invalid manifest is non-fatal | |
| return {} | |
| DATA_MANIFEST = _load_manifest() | |
| DATA_BUILD_DATE = DATA_MANIFEST.get("build_date", "") # ISO 'YYYY-MM-DD' or '' | |
| # --- Geographic scope -------------------------------------------------------- | |
| # Paris proper (the 20 arrondissements). Used to bound the OSM download and to | |
| # reject out-of-area requests. | |
| PARIS_PLACE = "Paris, Île-de-France, France" | |
| # Bounding box (south, west, north, east) — a coarse rejection gate for inputs. | |
| # Slightly padded beyond the périphérique. | |
| PARIS_BBOX = (48.8156, 2.2241, 48.9022, 2.4699) # (lat_min, lon_min, lat_max, lon_max) | |
| PARIS_CENTER = (48.8566, 2.3522) | |
| # --- Offline mode -------------------------------------------------------------- | |
| # When this env var is "1", geocoding never falls back to Nominatim (network): | |
| # only the local POI-name index and 'lat, lon' inputs are accepted. | |
| OFFLINE_ENV_VAR = "DISCOVERROUTE_OFFLINE" | |
| # --- Travel model ------------------------------------------------------------ | |
| # Used to convert edge length (metres) into travel time (seconds). | |
| TRAVEL_SPEEDS_KMH = { | |
| "walk": 4.8, | |
| "bike": 15.0, | |
| } | |
| DEFAULT_MODE = "walk" | |
| # --- Detour budget defaults -------------------------------------------------- | |
| # budget is a fraction of direct-route time the user is willing to add. | |
| # 0.0 => route equals the plain route. 1.0 => allow up to 2x the direct time. | |
| DEFAULT_BUDGET = 0.5 | |
| MAX_BUDGET = 2.0 | |
| # --- Corridor (candidate gathering) ------------------------------------------ | |
| # Half-width of the search corridor around the direct route, in metres. Grows | |
| # with the detour budget: more budget => look further off the direct line. | |
| # (Heuristic for spec open-question §12; tuned in Brick 2.) | |
| CORRIDOR_BASE_M = 250.0 | |
| CORRIDOR_BUDGET_M = 500.0 # added per unit of budget | |
| MAX_CANDIDATES = 600 # corridor cap (keep nearest-to-route; scoring is cheap) | |
| SOLVER_CANDIDATES = 40 # shortlist (top-scoring) for the real travel matrix | |
| MAX_DETOUR_STOPS = 12 # max POIs the orienteering route may include | |
| def corridor_halfwidth_m(budget: float) -> float: | |
| return CORRIDOR_BASE_M + CORRIDOR_BUDGET_M * max(0.0, budget) | |
| # --- Pre-baked extra cities (offline, keeps "Off the Grid") ------------------ | |
| # Paris ships full-city (above). These additional cities are baked as a bounded | |
| # walkable core (centre + radius) by data/build_city.py and committed, so they | |
| # route fully offline — no live OSM at request time. Add a city here, run | |
| # `python -m discoverroute.data.build_city <slug>`, commit the data. | |
| CITY_DATA_DIR = DATA_DIR / "cities" | |
| CITIES_MANIFEST_PATH = CITY_DATA_DIR / "cities_manifest.json" | |
| CITIES = { | |
| "london": {"label": "London", "center": (51.5118, -0.1230), "radius_m": 3200, "tz": "Europe/London"}, | |
| "barcelona": {"label": "Barcelona", "center": (41.3870, 2.1700), "radius_m": 3200, "tz": "Europe/Madrid"}, | |
| "newyork": {"label": "New York", "center": (40.7560, -73.9845), "radius_m": 3200, "tz": "America/New_York"}, | |
| "sanfrancisco": {"label": "San Francisco", "center": (37.7880, -122.4075), "radius_m": 3200, "tz": "America/Los_Angeles"}, | |
| "tokyo": {"label": "Tokyo", "center": (35.6762, 139.7653), "radius_m": 3200, "tz": "Asia/Tokyo"}, | |
| "mumbai": {"label": "Mumbai", "center": (18.9220, 72.8347), "radius_m": 3200, "tz": "Asia/Kolkata"}, | |
| "shanghai": {"label": "Shanghai", "center": (31.2340, 121.4810), "radius_m": 3200, "tz": "Asia/Shanghai"}, | |
| "berlin": {"label": "Berlin", "center": (52.5170, 13.3889), "radius_m": 3200, "tz": "Europe/Berlin"}, | |
| } | |
| # Secondary city cores are hosted as a HF *dataset* (just a folder of files), not | |
| # committed into this repo — so the Space/app image stays lean and scales past a | |
| # handful of cities. Each `<slug>_walk.graphml` + `<slug>_pois.parquet` is pulled | |
| # on demand (public repo => no token needed) and cached into CITY_DATA_DIR, after | |
| # which the normal on-disk path (city_graph_path/city_pois_path) just works. | |
| CITIES_DATASET_REPO = os.environ.get( | |
| "DISCOVERROUTE_CITIES_REPO", "build-small-hackathon/discoverroute-cities" | |
| ) | |
| # Cities to download + load into memory at boot ("pre-warm") so the first user to | |
| # pick one waits 0 s. Default: every configured city. Boot cost is paid once, | |
| # before any request, and keeps request-time fully offline (files already local). | |
| # Override with a comma-separated slug list, e.g. "london,newyork,tokyo". | |
| PREWARM_CITIES = [ | |
| s.strip() for s in os.environ.get( | |
| "DISCOVERROUTE_PREWARM_CITIES", ",".join(CITIES) | |
| ).split(",") if s.strip() in CITIES | |
| ] | |
| def city_graph_path(slug: str) -> Path: | |
| return CITY_DATA_DIR / f"{slug}_walk.graphml" | |
| def city_pois_path(slug: str) -> Path: | |
| return CITY_DATA_DIR / f"{slug}_pois.parquet" | |
| # --- Other cities (on-demand) ------------------------------------------------ | |
| # Paris ships pre-baked (instant, offline). Any other city is fetched live from | |
| # OpenStreetMap at request time: we download only the bounding box spanning the | |
| # two endpoints (plus a margin), not the whole metropolis — turning a multi-GB | |
| # city download into a few-MB box that builds in seconds. | |
| ON_DEMAND_MARGIN_M = 900.0 # padding added around the A→B bbox (corridor room) | |
| # Reject on-demand requests whose endpoints are absurdly far apart: a giant bbox | |
| # would overrun the public OSM servers and the worker's memory. Paris (cached) | |
| # is exempt from this cap. | |
| MAX_ENDPOINT_DISTANCE_M = 25_000.0 | |
| AREA_CACHE_SIZE = 4 # how many on-demand city areas to keep in memory | |
| # Time budget for a single on-demand OSM fetch (graph or one feature key). | |
| ON_DEMAND_FETCH_TIMEOUT = 60 | |
| # --- Models (Brick 4 / 6) ---------------------------------------------------- | |
| # Small text encoder for vibe -> category affinity (CPU-friendly, offline). | |
| EMBED_MODEL = "BAAI/bge-small-en-v1.5" | |
| # bge-v1.5 retrieval instruction, prepended to the query (the vibe) only. | |
| EMBED_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: " | |
| # Generative model for vibe→weights extraction + narration. A 1B in-Space model | |
| # (Tiny Titan ≤4B; weights pulled from the Hub and run on ZeroGPU). Standard | |
| # LlamaForCausalLM architecture — no custom kernels. | |
| LLM_MODEL = "openbmb/MiniCPM5-1B" | |
| # A/B toggle for Call 1 (vibe→weights): run the model's REASONING pass | |
| # (enable_thinking, MiniCPM5-1B is hybrid-reasoning) or the fast no-think path. | |
| # DEFAULT no-think: the live A/B was decisive — thinking ran ~26s (3× no-think's | |
| # ~9s, 3× the ZeroGPU slice) and *corrupted* the JSON (zeroed the matching | |
| # categories), while no-think returned clean output. Reasoning did not help this | |
| # short scoring task. Flip DISCOVERROUTE_VIBE_THINKING=1 to re-run the comparison; | |
| # the chosen mode is recorded on every trace row. | |
| VIBE_THINKING = os.environ.get( | |
| "DISCOVERROUTE_VIBE_THINKING", "0").lower() in ("1", "true", "on") | |
| # --- Trace logging (Open Trace) ---------------------------------------------- | |
| # Every inference call logs a row locally to logs/traces.jsonl; when a write | |
| # token is present, rows are ALSO pushed (async, non-blocking) to TRACE_REPO. | |
| # No token => local-only (graceful stub; nothing blocks). | |
| HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") | |
| TRACE_REPO = os.environ.get( | |
| "DISCOVERROUTE_TRACE_REPO", "build-small-hackathon/discoverroute-traces" | |
| ) | |
| # Affinity floor: the least-matching category still keeps this much interest so | |
| # the route can explore a little; the best-matching category maps to 1.0. | |
| AFFINITY_FLOOR = 0.15 | |
| # Only the top-N matched categories drive a vibe route; the rest are zeroed so | |
| # the long tail (ranks N+1..17) can't silently backfill stops with off-vibe | |
| # filler (the adversarial review found the same statues/churches bleeding into | |
| # 10+ unrelated routes via the floor). Sparse routes then end honestly short. | |
| TOP_AFFINITY_CATEGORIES = 6 | |
| # A vibe whose BEST raw cosine to any category gloss is below this is a weak/ | |
| # out-of-vocabulary match (measured: real vibes peak 0.66-0.85; "brutalist | |
| # architecture" 0.51, nonsense ~0.49). We still route, but the narration says so | |
| # honestly instead of claiming "a match for your vibe". | |
| WEAK_MATCH_SIMILARITY = 0.55 | |
| # For "hidden gems"-style vibes, exclude well-documented (famous) POIs: a place | |
| # this richly tagged isn't off the beaten path. Confidence is the tag-richness | |
| # proxy; Notre Dame etc. sit at ~1.0. (Only applied when a discovery cue fires.) | |
| FAMOUS_CONFIDENCE = 0.85 | |
| # Below this cosine-similarity span across categories, a vibe is treated as | |
| # off-domain/neutral rather than amplified into false preferences. Measured | |
| # (bge-small, 16-vibe battery): gibberish "asdfqwer" spans 0.081; the LOWEST | |
| # real vibe ("romantic evening stroll") spans 0.143; "take me somewhere | |
| # beautiful" 0.152, "brutalist architecture" 0.148. So 0.18 (the prior value) | |
| # wrongly neutralised genuine evocative vibes — collapsing them to an identical | |
| # generic grab-bag. 0.10 sits just above gibberish, rescuing real vibes while | |
| # still catching nonsense. (Abstract vibes have NO clean separation from | |
| # nonsense by span alone — "quantum physics lecture" also spans 0.143 — but a | |
| # weakly-themed route for them beats a deceptive default route.) | |
| MIN_AFFINITY_SPAN = 0.10 | |
| # --- Adventurousness --------------------------------------------------------- | |
| # 0.0 => only high-confidence, well-documented POIs. | |
| # 1.0 => admit low-confidence / under-documented POIs (serendipity). | |
| DEFAULT_ADVENTUROUSNESS = 0.3 | |
| def speed_ms(mode: str) -> float: | |
| """Travel speed in metres/second for the given mode.""" | |
| kmh = TRAVEL_SPEEDS_KMH.get(mode, TRAVEL_SPEEDS_KMH[DEFAULT_MODE]) | |
| return kmh * 1000.0 / 3600.0 | |
| def in_paris(lat: float, lon: float) -> bool: | |
| """True if a point falls inside the padded Paris bounding box.""" | |
| lat_min, lon_min, lat_max, lon_max = PARIS_BBOX | |
| return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max | |