Spaces:
Running on Zero
Running on Zero
File size: 15,625 Bytes
46f1a78 27c0524 46f1a78 27c0524 46f1a78 27c0524 46f1a78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 | """User-funded coverage extension and add-model, on ZeroGPU.
Inference runs inside a `@spaces.GPU` function so it draws on the *signed-in
user's* ZeroGPU quota, not the Space owner's. Anonymous visitors keep full read
and backtest access; only extension is gated.
Safety properties enforced here:
* **Dedup** β a range the manifest already covers is never recomputed.
* **Caps** β per-request range limits keep one user from monopolising the queue.
* **Single writer** β a process-wide lock serialises commits, so concurrent
extensions cannot interleave and corrupt the manifest.
* **Allow-list** β only vetted adapter families load, and model ids are
validated before they reach the Hub.
"""
from __future__ import annotations
import logging
import os
import threading
from dataclasses import dataclass
import gradio as gr
import pandas as pd
from . import catalog, comparisons, config, runtime
from .adapters import AdapterError, ModelNotAllowed, build_windows, get_adapter, validate_model_id
from .store import _utc
log = logging.getLogger("bit.extension")
# One writer at a time. Commits to the store are serialised process-wide so a
# second extension cannot land between another's data write and its manifest
# update.
_WRITE_LOCK = threading.Lock()
try:
import spaces # provided by the ZeroGPU runtime
HAS_SPACES = True
except Exception: # running locally or on CPU-only hardware
spaces = None
HAS_SPACES = False
def _gpu(duration=120):
"""Apply @spaces.GPU when the runtime offers it, otherwise run on CPU."""
def deco(fn):
if HAS_SPACES:
return spaces.GPU(duration=duration)(fn)
return fn
return deco
class ExtensionError(RuntimeError):
pass
class QuotaExhausted(ExtensionError):
pass
# --------------------------------------------------------------------------
# Estimation & guardrails
# --------------------------------------------------------------------------
@dataclass
class Estimate:
model_slug: str
asset: str
timeframe: str
start: pd.Timestamp
end: pd.Timestamp
steps: int
already_covered: bool
capped: bool
cap_days: int
note: str = ""
def estimate(model_slug: str, asset: str, timeframe: str, start, end) -> Estimate:
if model_slug not in config.SEED_MODELS:
raise ExtensionError(f"unknown model {model_slug!r}")
if asset not in config.ASSETS:
raise ExtensionError(f"unknown asset {asset!r}")
if timeframe not in config.TIMEFRAMES:
raise ExtensionError(f"unknown timeframe {timeframe!r}")
try:
s, e = _utc(start), _utc(end)
except Exception as exc:
raise ExtensionError(f"could not parse the date range: {exc}") from exc
if s >= e:
raise ExtensionError("start must be before end")
cap_days = config.CAPS.max_days.get(timeframe, 365)
capped = (e - s).days > cap_days
if capped:
s = e - pd.Timedelta(days=cap_days)
store = runtime.get_store()
prices = store.get_prices(asset, timeframe, s, e)
if prices.empty:
raise ExtensionError(
f"No cached prices for {asset} {timeframe} in that range. "
"Price coverage has to exist before signals can be generated."
)
spec = config.SEED_MODELS[model_slug]
ctx = min(spec.context_len, max(64, len(prices) // 3))
steps = max(0, len(prices) - ctx)
if steps > config.CAPS.max_steps_per_run:
steps = config.CAPS.max_steps_per_run
# Dedup must compare against the range this request would actually
# *produce*, not the range the user typed. A forecast needs a full trailing
# context window, so the first producible timestamp sits `ctx` bars after
# the start of the price slice. Comparing the typed range instead would
# report an already-covered slice as uncovered and pay for it twice.
stamps, _ = build_windows(prices["close"], ctx)
if len(stamps) == 0:
produced_start, produced_end = s, e
steps = 0
else:
produced_start = _utc(stamps[0])
produced_end = _utc(stamps[min(steps, len(stamps)) - 1]) if steps else produced_start
# Revision is resolved lazily to avoid a Hub round-trip on every keystroke,
# so this matches any revision of the model.
covered = any(
ent.model_slug == model_slug and ent.asset == asset
and ent.timeframe == timeframe
and _utc(ent.start_ts) <= produced_start and _utc(ent.end_ts) >= produced_end
for ent in store.load_manifest().signals.values()
)
note = ""
if capped:
note = (f"Range trimmed to the {cap_days}-day cap for {timeframe} bars.")
return Estimate(model_slug, asset, timeframe, s, e, steps, covered,
capped, cap_days, note)
# --------------------------------------------------------------------------
# GPU inference
# --------------------------------------------------------------------------
@_gpu(duration=180)
def run_inference(model_id: str, family: str, values: list, ctx_len: int) -> dict:
"""Inference on the caller's ZeroGPU allocation.
Kept deliberately small and picklable: it takes plain values and returns
plain lists, so nothing in the store or the app leaks into the GPU worker.
"""
import numpy as np
adapter = get_adapter(family, model_id, context_len=ctx_len)
adapter.load()
windows = np.asarray(values, dtype="float32")
forecast = adapter.predict(windows)
return {
"q10": forecast.q10.tolist(),
"q50": forecast.q50.tolist(),
"q90": forecast.q90.tolist(),
"context_len": int(forecast.context_len),
"revision": adapter.resolved_revision,
"inference_version": adapter.inference_version(),
}
def _is_quota_error(exc: Exception) -> bool:
text = f"{type(exc).__name__} {exc}".lower()
return any(k in text for k in ("quota", "gpu task aborted", "exceeded",
"no gpu available", "zerogpu"))
QUOTA_FALLBACK = (
'<div class="bit-note bit-note-danger">'
"Your ZeroGPU quota is exhausted, so this extension could not run. "
"The quota refills over time. If you need to run a large batch now, "
f'<a href="https://huggingface.co/spaces/{config.SPACE_REPO}?duplicate=true" '
'target="_blank" rel="noopener">duplicate this Space</a> and run it on your '
"own hardware β the signal store is public, so a duplicate reads the same data."
"</div>"
)
# --------------------------------------------------------------------------
# The extend flow
# --------------------------------------------------------------------------
def extend_coverage(model_slug: str, asset: str, timeframe: str, start, end,
username: str = "anonymous", progress=None) -> str:
"""Dedup, estimate, run inference, commit, regenerate comparisons."""
est = estimate(model_slug, asset, timeframe, start, end)
if est.already_covered:
return ('<div class="bit-note">That range is already covered β nothing was '
"recomputed. Coverage is deduplicated against the manifest.</div>")
if est.steps <= 0:
return ('<div class="bit-note bit-note-danger">Not enough cached price '
"history in that range to build a single context window.</div>")
spec = config.SEED_MODELS[model_slug]
store = runtime.get_store()
prices = store.get_prices(asset, timeframe, est.start, est.end)
close = prices["close"]
ctx = min(spec.context_len, max(64, len(prices) // 3))
if progress:
progress(0.15, desc=f"Preparing {est.steps} context windows")
stamps, windows = build_windows(close, ctx)
if len(stamps) == 0:
return ('<div class="bit-note bit-note-danger">Not enough bars for a '
"context window.</div>")
stamps, windows = stamps[:est.steps], windows[:est.steps]
if progress:
progress(0.35, desc=f"Running {spec.display} on your GPU quota")
try:
out = run_inference(spec.model_id, spec.family, windows.tolist(), ctx)
except Exception as exc:
if _is_quota_error(exc):
log.warning("ZeroGPU quota exhausted for %s: %s", username, exc)
return QUOTA_FALLBACK
log.exception("extension inference failed")
return (f'<div class="bit-note bit-note-danger">Inference failed: '
f"{type(exc).__name__}: {exc}</div>")
frame = pd.DataFrame({
"ts": stamps, "q10": out["q10"], "q50": out["q50"], "q90": out["q90"],
"context_len": out["context_len"], "inference_version": out["inference_version"],
})
if progress:
progress(0.75, desc="Committing to the signal store")
with _WRITE_LOCK:
entry = store.write_signals(
model_slug, spec.model_id, out["revision"], asset, timeframe, frame,
inference_version=out["inference_version"],
contributed_by=username,
)
if progress:
progress(0.9, desc="Regenerating comparison tables")
# Coverage just changed, so every derived view is now stale. Both are
# rebuilt inside the same write lock, before the commit, so readers
# never see new signals alongside an old leaderboard.
try:
comparisons.regenerate(store, assets=[asset])
except Exception:
log.exception("comparison regeneration failed (coverage still written)")
try:
catalog.build(store)
except Exception:
log.exception("catalog rebuild failed (coverage still written)")
oid = store.flush(f"Extend {model_slug}/{asset}/{timeframe} by @{username}")
runtime.cache_clear()
return (f'<div class="bit-note">Coverage extended by <b>@{username}</b> β '
f"{len(frame):,} new steps for {model_slug} on {asset} {timeframe} "
f"({entry.start_ts[:10]} β {entry.end_ts[:10]})."
+ (f" Commit <code>{str(oid)[:8]}</code>." if oid else "")
+ "</div>")
def add_model(family: str, model_id: str, username: str = "anonymous") -> str:
"""Smoke-test a user-supplied model, then register it if it passes."""
try:
model_id = validate_model_id(model_id)
except AdapterError as e:
return f'<div class="bit-note bit-note-danger">{e}</div>'
if family not in config.ALLOWED_ADAPTER_FAMILIES:
return (f'<div class="bit-note bit-note-danger">Adapter family {family!r} '
"is not on the allow-list.</div>")
store = runtime.get_store()
asset, timeframe = "BTC-USD", "1d"
prices = store.get_prices(asset, timeframe)
if prices.empty:
return ('<div class="bit-note bit-note-danger">No cached prices to smoke '
"test against.</div>")
n = config.CAPS.smoke_test_steps
close = prices["close"]
ctx = min(512, max(64, len(close) // 3))
stamps, windows = build_windows(close, ctx)
if len(stamps) < n:
return ('<div class="bit-note bit-note-danger">Not enough history for a '
f"{n}-step smoke test.</div>")
stamps, windows = stamps[-n:], windows[-n:]
try:
out = run_inference(model_id, family, windows.tolist(), ctx)
except ModelNotAllowed as e:
return f'<div class="bit-note bit-note-danger">{e}</div>'
except Exception as exc:
if _is_quota_error(exc):
return QUOTA_FALLBACK
return (f'<div class="bit-note bit-note-danger">Smoke test failed: '
f"{type(exc).__name__}: {exc}</div>")
slug = model_id.split("/")[-1].lower()
frame = pd.DataFrame({
"ts": stamps, "q10": out["q10"], "q50": out["q50"], "q90": out["q90"],
"context_len": out["context_len"], "inference_version": out["inference_version"],
})
with _WRITE_LOCK:
store.write_signals(slug, model_id, out["revision"], asset, timeframe, frame,
inference_version=out["inference_version"],
contributed_by=username)
store.flush(f"Add model {model_id} (smoke test) by @{username}")
runtime.cache_clear()
return (f'<div class="bit-note">Smoke test passed: <b>{model_id}</b> '
f"(revision <code>{str(out['revision'])[:8]}</code>) produced {n} "
f"schema-valid steps and now appears in the coverage map as "
f"<code>{slug}</code>.</div>")
# --------------------------------------------------------------------------
# Gradio bindings
# --------------------------------------------------------------------------
def status_html() -> str:
if HAS_SPACES:
return ('<div class="bit-micro">ZEROGPU AVAILABLE Β· EXTENSION RUNS ON '
"YOUR OWN QUOTA WHEN SIGNED IN</div>")
return ('<div class="bit-note">This Space is running on CPU, so coverage '
"extension is disabled. Reading and backtesting the existing store "
"works normally.</div>")
def _username(profile) -> str | None:
if profile is None:
return None
return getattr(profile, "username", None) or getattr(profile, "name", None)
def estimate_ui(model_slug, asset, timeframe, start, end):
if not (model_slug and asset and timeframe and start and end):
return '<div class="bit-micro">PICK A MODEL, ASSET, TIMEFRAME AND RANGE</div>'
try:
est = estimate(model_slug, asset, timeframe, start, end)
except ExtensionError as e:
return f'<div class="bit-note bit-note-danger">{e}</div>'
if est.already_covered:
return ('<div class="bit-note">Already covered β running this would '
"recompute nothing. Pick a wider range.</div>")
return (f'<div class="bit-note">About <b>{est.steps:,}</b> inference steps for '
f"{est.start.date()} β {est.end.date()}. "
+ (est.note + " " if est.note else "")
+ "This runs on your ZeroGPU quota once you sign in.</div>")
def extend_ui(model_slug, asset, timeframe, start, end,
profile: gr.OAuthProfile | None = None, progress=None):
user = _username(profile)
if user is None:
return ('<div class="bit-note bit-note-danger">Sign in with Hugging Face to '
"extend coverage. Reading and backtesting stay open to everyone; "
"extension spends your own GPU quota, so it needs an account.</div>",
runtime.coverage_frame())
if not HAS_SPACES:
return (status_html(), runtime.coverage_frame())
try:
html = extend_coverage(model_slug, asset, timeframe, start, end,
username=user, progress=progress)
except ExtensionError as e:
html = f'<div class="bit-note bit-note-danger">{e}</div>'
except Exception as e:
log.exception("extend failed")
html = f'<div class="bit-note bit-note-danger">{type(e).__name__}: {e}</div>'
return html, runtime.coverage_frame()
def add_model_ui(family, model_id, profile: gr.OAuthProfile | None = None):
user = _username(profile)
if user is None:
return ('<div class="bit-note bit-note-danger">Sign in with Hugging Face to '
"add a model β the smoke test runs on your GPU quota.</div>",
runtime.coverage_frame())
if not HAS_SPACES:
return (status_html(), runtime.coverage_frame())
try:
html = add_model(family, model_id, username=user)
except Exception as e:
log.exception("add model failed")
html = f'<div class="bit-note bit-note-danger">{type(e).__name__}: {e}</div>'
return html, runtime.coverage_frame()
|