"""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 = (
'
'
"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'
duplicate this Space and run it on your '
"own hardware — the signal store is public, so a duplicate reads the same data."
"
"
)
# --------------------------------------------------------------------------
# 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 ('That range is already covered — nothing was '
"recomputed. Coverage is deduplicated against the manifest.
")
if est.steps <= 0:
return ('Not enough cached price '
"history in that range to build a single context window.
")
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 ('Not enough bars for a '
"context window.
")
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'Inference failed: '
f"{type(exc).__name__}: {exc}
")
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'Coverage extended by @{username} — '
f"{len(frame):,} new steps for {model_slug} on {asset} {timeframe} "
f"({entry.start_ts[:10]} → {entry.end_ts[:10]})."
+ (f" Commit {str(oid)[:8]}." if oid else "")
+ "
")
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'{e}
'
if family not in config.ALLOWED_ADAPTER_FAMILIES:
return (f'Adapter family {family!r} '
"is not on the allow-list.
")
store = runtime.get_store()
asset, timeframe = "BTC-USD", "1d"
prices = store.get_prices(asset, timeframe)
if prices.empty:
return ('No cached prices to smoke '
"test against.
")
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 ('Not enough history for a '
f"{n}-step smoke test.
")
stamps, windows = stamps[-n:], windows[-n:]
try:
out = run_inference(model_id, family, windows.tolist(), ctx)
except ModelNotAllowed as e:
return f'{e}
'
except Exception as exc:
if _is_quota_error(exc):
return QUOTA_FALLBACK
return (f'Smoke test failed: '
f"{type(exc).__name__}: {exc}
")
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'Smoke test passed: {model_id} '
f"(revision {str(out['revision'])[:8]}) produced {n} "
f"schema-valid steps and now appears in the coverage map as "
f"{slug}.
")
# --------------------------------------------------------------------------
# Gradio bindings
# --------------------------------------------------------------------------
def status_html() -> str:
if HAS_SPACES:
return ('ZEROGPU AVAILABLE · EXTENSION RUNS ON '
"YOUR OWN QUOTA WHEN SIGNED IN
")
return ('This Space is running on CPU, so coverage '
"extension is disabled. Reading and backtesting the existing store "
"works normally.
")
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 'PICK A MODEL, ASSET, TIMEFRAME AND RANGE
'
try:
est = estimate(model_slug, asset, timeframe, start, end)
except ExtensionError as e:
return f'{e}
'
if est.already_covered:
return ('Already covered — running this would '
"recompute nothing. Pick a wider range.
")
return (f'About {est.steps:,} 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.
")
def extend_ui(model_slug, asset, timeframe, start, end,
profile: gr.OAuthProfile | None = None, progress=None):
user = _username(profile)
if user is None:
return ('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.
",
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'{e}
'
except Exception as e:
log.exception("extend failed")
html = f'{type(e).__name__}: {e}
'
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 ('Sign in with Hugging Face to '
"add a model — the smoke test runs on your GPU quota.
",
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'{type(e).__name__}: {e}
'
return html, runtime.coverage_frame()