Spaces:
Running on Zero
Running on Zero
File size: 19,744 Bytes
8028640 ac2fa62 8028640 92f614c 8028640 ac20a54 ac2fa62 ac20a54 ac2fa62 8028640 92f614c ac2fa62 ac20a54 ac2fa62 92f614c 8028640 1e94a0b 442b828 8028640 279522c ac2fa62 8028640 92f614c ac2fa62 8028640 fd9dddf 116f6d0 ac2fa62 116f6d0 fd9dddf 116f6d0 8028640 1e94a0b 442b828 1e94a0b 442b828 1e94a0b 442b828 1e94a0b 8028640 | 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 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 | """Running a forecast, and enrolling a model.
This is the layer the UI calls. It owns three things the UI must not:
**Adapter caching.** Loading weights costs seconds; doing it per request would
put every model outside its latency budget. Adapters are cached per
(family, model id, revision) and reused.
**The issue moment.** `issued_ts` is the timestamp of the last bar in the
validated price cache, never the wall clock. That is what makes a live forecast
and a backfilled one structurally the same operation -- the backfill just
supplies an earlier cut. There is no code path where a forecast can see a bar
it should not have.
**Enrollment.** Adding a model is: validate the id, check the family is on the
allow-list, pin the revision to an immutable sha, run a smoke test, write the
registry entry. No user-supplied code is ever imported or executed, and an
unsupported family is rejected with a message that says so.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field, replace
import pandas as pd
from . import config, gpu as gpu_dispatch, trackrecord
from .adapters import (ALLOWED_ADAPTER_FAMILIES, AdapterError, ForecastAdapter,
ForecastResult, ModelNotAllowed, family_for,
get_adapter, validate_model_id)
from .adapters import model_slug as slug_for
from .store import ArenaStore, now_utc
log = logging.getLogger("arena.runtime")
class ForecastUnavailable(RuntimeError):
"""A forecast could not be produced. Carries a UI-renderable reason."""
def __init__(self, message: str, kind: str = "load_failure"):
super().__init__(message)
self.kind = kind
# --------------------------------------------------------------------------
# Adapter cache
# --------------------------------------------------------------------------
_ADAPTERS: dict[tuple, ForecastAdapter] = {}
def _device(tier: str) -> str | None:
"""The device to hand an adapter, or None to let it decide."""
if not gpu_dispatch.HAS_SPACES:
return None
return "cuda" if tier == "gpu" else "cpu"
def warm(model_id: str, revision: str | None) -> None:
"""Pull a model's weights to local disk, outside any GPU call.
Downloading is network and disk, not CUDA, so it is legal anywhere -- and
doing it here means the GPU call spends its 60-second budget on compute
instead of on a 400 MB download it might not finish.
"""
if model_id.startswith("baseline/"):
return
try:
from huggingface_hub import snapshot_download
snapshot_download(model_id, revision=revision,
allow_patterns=["*.json", "*.safetensors", "*.ckpt"])
except Exception as e: # pragma: no cover
# A failed prefetch is not fatal: the loader will fetch what it needs.
log.info("could not prefetch %s: %s", model_id, e)
def adapter_for(family: str, model_id: str, revision: str | None = None,
**kwargs) -> ForecastAdapter:
"""A loaded adapter, cached. Raises `ForecastUnavailable` on load failure."""
key = (family, model_id, revision or "pinned", tuple(sorted(kwargs.items())))
cached = _ADAPTERS.get(key)
if cached is not None:
return cached
try:
adapter = get_adapter(family, model_id, revision=revision, **kwargs)
adapter.load()
except ModelNotAllowed:
raise
except Exception as e:
# Weights that will not download or will not fit are the single most
# likely runtime failure, and the UI has a designed state for it.
raise ForecastUnavailable(
f"{model_id} could not be loaded: {e}", kind="load_failure") from e
_ADAPTERS[key] = adapter
return adapter
def clear_adapter_cache() -> None:
_ADAPTERS.clear()
# The GPU entry point.
#
# Defined at module scope and decorated unconditionally, because ZeroGPU scans
# for `@spaces.GPU` at startup and refuses to boot a Space that declares none.
# It takes plain arguments and resolves the adapter through the cache rather
# than receiving one, so nothing that holds CUDA state crosses the boundary.
#
# Off ZeroGPU the decorator is the identity and this is an ordinary call.
@gpu_dispatch.gpu()
def _predict(family: str, model_id: str, revision: str | None,
context_ohlcv, horizon: int, n_samples: int, seed: int,
issued_ts, tier: str = "gpu") -> ForecastResult:
# Inside the GPU function the device is known, so it is stated rather than
# probed -- see `base.default_device` for why probing is not an option.
adapter = adapter_for(family, model_id, revision=revision,
device=_device(tier))
return adapter.predict(context_ohlcv, horizon=horizon, n_samples=n_samples,
seed=int(seed), issued_ts=issued_ts)
# --------------------------------------------------------------------------
# Forecasting
# --------------------------------------------------------------------------
@dataclass
class ForecastRun:
"""Everything the UI needs about one issued forecast."""
model_slug: str
model_id: str
family: str
asset: str
timeframe: str
horizon: int
issued_ts: pd.Timestamp
target_ts: pd.DatetimeIndex
result: ForecastResult
context: pd.DataFrame
forecast_id: str = ""
archived_rows: int = 0
elapsed_s: float = 0.0
capabilities: dict = field(default_factory=dict)
# True when this was rebuilt from the archive rather than just computed.
# The UI says so: "issued 3h ago" is a different claim from "just run".
from_cache: bool = False
backfilled: bool = False
# Bars that printed *after* the forecast was issued, up to the end of its
# horizon. Only ever populated for a cached run: a forecast issued a moment
# ago has nothing to show yet. This is what turns "here is an old forecast"
# into "here is how that forecast is doing".
realized: pd.DataFrame | None = None
def future_timestamps(context: pd.DataFrame, horizon: int) -> pd.DatetimeIndex:
"""Continue the context's own cadence forward by `horizon` bars."""
ts = pd.to_datetime(context["ts"], utc=True)
deltas = ts.diff().dropna()
if not len(deltas):
raise AdapterError("cannot infer cadence from a single bar")
modal = deltas.mode()
step = modal.iloc[0] if len(modal) else deltas.median()
last = ts.iloc[-1]
return pd.DatetimeIndex([last + step * (i + 1) for i in range(horizon)])
def load_context(store: ArenaStore, asset: str, timeframe: str,
as_of=None, context_len: int = 512) -> pd.DataFrame:
"""The most recent validated bars at or before `as_of`.
Slicing on `as_of` here rather than in the caller is what makes the
no-lookahead guarantee structural: every context the app ever builds goes
through this function, and it cannot return a bar past the cut.
"""
prices = store.get_prices(asset, timeframe)
if not len(prices):
raise ForecastUnavailable(
f"no price history cached for {asset} {timeframe}", kind="no_data")
if as_of is not None:
cut = pd.Timestamp(as_of)
cut = cut.tz_localize("UTC") if cut.tzinfo is None else cut.tz_convert("UTC")
prices = prices[prices["ts"] <= cut]
if len(prices) < 64:
raise ForecastUnavailable(
f"only {len(prices)} bars available for {asset} {timeframe}",
kind="no_data")
return prices.iloc[-context_len:].reset_index(drop=True)
def run_forecast(store: ArenaStore, model_slug: str, asset: str, timeframe: str,
horizon: int | None = None,
n_samples: int = config.DEFAULT_N_SAMPLES,
seed: int = 0, as_of=None, registry: dict | None = None,
archive: bool = True, backfilled: bool = False) -> ForecastRun:
"""Issue one forecast, archive it, and return everything needed to draw it."""
registry = registry if registry is not None else store.get_registry()
entry = registry.get("models", {}).get(model_slug)
if entry is None:
raise ForecastUnavailable(
f"{model_slug} is not enrolled in the Arena", kind="not_enrolled")
horizon = int(horizon or config.DEFAULT_HORIZON.get(timeframe, 24))
max_h = config.MAX_HORIZON.get(timeframe, 168)
if not 1 <= horizon <= max_h:
raise ForecastUnavailable(
f"horizon must be between 1 and {max_h} for {timeframe}",
kind="bad_request")
# Capabilities without loading. Constructing an adapter touches no
# weights and -- since `device` is lazy -- no CUDA, so this is safe to do
# outside the GPU call. Loading here instead would initialise CUDA in a
# context ZeroGPU forbids, which failed even for CPU-tier models.
caps = get_adapter(entry["family"], entry["model_id"],
revision=entry.get("revision")).capabilities()
# The registry's recorded hardware wins over the adapter's declared
# default: it is the one that was measured on real hardware, and a
# demotion recorded there must actually govern who can run the model.
recorded = (entry.get("capabilities") or {}).get("hardware")
if recorded in ("cpu", "gpu") and recorded != caps.hardware:
caps = replace(caps, hardware=recorded)
# The smaller of what the model can take and what the Arena spends. See
# config.DEFAULT_CONTEXT_BARS for why the ceiling is not the model's own.
context = load_context(store, asset, timeframe, as_of=as_of,
context_len=min(caps.max_context,
config.DEFAULT_CONTEXT_BARS))
issued_ts = pd.to_datetime(context["ts"], utc=True).iloc[-1]
targets = future_timestamps(context, horizon)
# A GPU-tier model on hardware that has no GPU cannot be made to work by
# trying: it renders its designed unavailable state instead of holding a
# spinner for minutes.
if caps.hardware == "gpu" and not gpu_dispatch.available():
raise ForecastUnavailable(
f"{model_slug} needs GPU hardware, which this Space does not "
f"currently have. CPU-tier models are unaffected.",
kind="no_gpu")
# Weights land on disk before the GPU clock starts.
warm(entry["model_id"], entry.get("revision"))
started = time.time()
if caps.hardware == "gpu":
# Only GPU-tier models take the GPU path. Routing CPU-tier models
# through it was tried and is wrong: ZeroGPU's anonymous run limit is
# exhausted in a couple of calls, so a visitor who clicked Forecast
# twice on a model that runs in 20ms on CPU got locked out of the GPU
# models they actually needed it for.
result = _predict(entry["family"], entry["model_id"],
entry.get("revision"), context, horizon, n_samples,
seed, issued_ts, caps.hardware)
else:
adapter = adapter_for(entry["family"], entry["model_id"],
revision=entry.get("revision"),
device=_device("cpu"))
result = adapter.predict(context, horizon=horizon, n_samples=n_samples,
seed=int(seed), issued_ts=issued_ts)
elapsed = time.time() - started
run = ForecastRun(
model_slug=model_slug, model_id=entry["model_id"], family=entry["family"],
asset=asset, timeframe=timeframe, horizon=horizon,
issued_ts=issued_ts, target_ts=targets, result=result, context=context,
elapsed_s=elapsed, capabilities=caps.as_dict(),
)
if archive:
fid, written = trackrecord.archive(
store, result, model_slug, asset, timeframe, issued_ts, targets,
backfilled=backfilled)
run.forecast_id, run.archived_rows = fid, written
return run
def cached_run(store: ArenaStore, model_slug: str, asset: str, timeframe: str,
registry: dict | None = None) -> ForecastRun | None:
"""Rebuild the most recently archived forecast for a series, or None.
This is what a visitor sees before pressing anything. It reads the small
latest-forecast cache and the price history around the issue time -- no
model is loaded, so it costs a parquet read rather than an inference.
The forecast returned is the one that was issued: same quantiles, same
seed, same issue timestamp. Nothing is recomputed, because recomputing it
would be a *different* forecast wearing the old one's timestamp.
"""
latest = store.get_latest()
if not len(latest):
return None
rows = latest[(latest["model_slug"] == model_slug)
& (latest["asset"] == asset)
& (latest["timeframe"] == timeframe)]
if not len(rows):
return None
rows = rows.sort_values("step")
registry = registry if registry is not None else store.get_registry()
entry = registry.get("models", {}).get(model_slug, {})
caps = (entry.get("capabilities") or {}).copy()
issued_ts = pd.Timestamp(rows["issued_ts"].iloc[0])
horizon = int(rows["horizon_bars"].iloc[0])
levels = tuple(config.QUANTILE_LEVELS)
quantiles = rows[[f"q{int(round(q * 100)):02d}" for q in levels]] \
.to_numpy(dtype="float64")
paths = None
if caps.get("output") == "ohlcv_paths":
stored = store.get_latest_paths(model_slug, asset, timeframe)
if stored is not None and stored.shape[1] == horizon:
paths = stored
result = ForecastResult(
quantiles=quantiles, levels=levels, horizon=horizon,
context_len=int(rows["context_len"].iloc[0]),
inference_version=str(rows["inference_version"].iloc[0]),
seed=int(rows["seed"].iloc[0]),
n_samples=int(rows["n_samples"].iloc[0]),
paths=paths,
)
try:
context = load_context(store, asset, timeframe, as_of=issued_ts,
context_len=config.DEFAULT_CONTEXT_BARS)
except ForecastUnavailable:
return None
targets = pd.DatetimeIndex(rows["target_ts"])
# What actually happened since. The context is deliberately frozen at the
# issue moment -- the model must be shown what it saw -- but the chart is
# far more useful with the realised path drawn through the frozen fan, and
# a landing page whose candles stopped three days ago just looks broken.
realized = None
try:
bars = store.get_prices(asset, timeframe, start=issued_ts,
end=targets.max())
bars = bars[pd.to_datetime(bars["ts"], utc=True) > issued_ts]
if len(bars):
realized = bars.reset_index(drop=True)
except Exception as e: # pragma: no cover
log.info("no realised bars for %s %s: %s", asset, timeframe, e)
return ForecastRun(
model_slug=model_slug, model_id=entry.get("model_id", model_slug),
family=entry.get("family", ""), asset=asset, timeframe=timeframe,
horizon=horizon, issued_ts=issued_ts,
target_ts=targets, result=result,
context=context, forecast_id=str(rows["forecast_id"].iloc[0]),
archived_rows=0, elapsed_s=0.0, capabilities=caps,
from_cache=True, backfilled=bool(rows["backfilled"].any()),
realized=realized,
)
# --------------------------------------------------------------------------
# Enrollment
# --------------------------------------------------------------------------
@dataclass
class Enrollment:
ok: bool
model_slug: str = ""
message: str = ""
entry: dict | None = None
already: bool = False
def enroll(store: ArenaStore, family: str, model_id: str,
enrolled_by: str = "anonymous", registry: dict | None = None,
smoke_steps: int = config.CAPS.smoke_test_steps) -> Enrollment:
"""Validate, pin, smoke-test and register a model.
Enrolling an (id, revision) that is already registered is a no-op rather
than an error: the flow is idempotent so that a double-submitted form or a
retried job cannot fork the registry.
"""
try:
model_id = validate_model_id(model_id)
except AdapterError as e:
return Enrollment(ok=False, message=str(e))
fam = (family or "").strip().lower()
if fam not in ALLOWED_ADAPTER_FAMILIES:
return Enrollment(ok=False, message=(
f"'{family}' is not a supported adapter family. The Arena runs "
f"models only through vetted loaders, so a model outside "
f"{', '.join(ALLOWED_ADAPTER_FAMILIES)} cannot be enrolled."))
known = family_for(model_id)
if known is not None and known != fam:
return Enrollment(ok=False, message=(
f"{model_id} is a {known} model; enrolling it as {fam} would load "
f"it with the wrong loader."))
registry = registry if registry is not None else store.get_registry()
slug = slug_for(model_id)
try:
adapter = get_adapter(fam, model_id)
revision = adapter.resolve_revision()
except ModelNotAllowed as e:
return Enrollment(ok=False, message=str(e))
except Exception as e:
return Enrollment(ok=False, message=(
f"could not reach {model_id} on the Hub: {e}"))
existing = registry.get("models", {}).get(slug)
if existing and existing.get("revision") == revision:
return Enrollment(ok=True, model_slug=slug, already=True,
entry=existing,
message=f"{model_id} is already enrolled at this revision.")
try:
smoke = _smoke_test(adapter, steps=smoke_steps)
except Exception as e:
return Enrollment(ok=False, message=f"{model_id} failed its smoke test: {e}")
caps = adapter.capabilities()
entry = {
"model_slug": slug,
"model_id": model_id,
"family": fam,
"revision": revision,
"display": model_id.split("/", 1)[1],
"capabilities": caps.as_dict(),
"components": adapter.component_versions(),
"inference_version": adapter.inference_version(),
"enrolled_by": str(enrolled_by or "anonymous")[:64],
"enrolled_ts": now_utc().isoformat(),
"smoke_test": smoke,
}
registry.setdefault("models", {})[slug] = entry
store.put_registry(registry)
return Enrollment(ok=True, model_slug=slug, entry=entry,
message=f"{model_id} enrolled as '{slug}'.")
def _smoke_test(adapter: ForecastAdapter, steps: int) -> dict:
"""Forecast a synthetic series and check the output is usable.
Synthetic rather than real prices so enrollment works before the cache has
any coverage for a new asset, and so the test is identical every time.
"""
import numpy as np
n = max(128, steps)
rng = np.random.default_rng(0)
close = 100.0 * np.exp(np.cumsum(rng.normal(0, 0.01, n)))
frame = pd.DataFrame({
"ts": pd.date_range("2025-01-01", periods=n, freq="1h", tz="UTC"),
"open": close, "high": close * 1.001, "low": close * 0.999,
"close": close, "volume": 1000.0,
})
horizon = min(16, max(1, steps // 8))
started = time.time()
result = adapter.predict(frame, horizon=horizon, n_samples=8, seed=0)
elapsed = time.time() - started
lo, hi = result.band()
if not (lo <= hi).all():
raise AdapterError("smoke test produced a crossed band")
return {
"ok": True,
"steps": int(n),
"horizon": int(horizon),
"elapsed_s": round(elapsed, 3),
"emits_paths": bool(result.paths is not None),
"ran_ts": now_utc().isoformat(),
}
|