Spaces:
Running on Zero
Running on Zero
File size: 16,119 Bytes
46f1a78 27c0524 46f1a78 389e1f7 | 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 | """Phase 2 acceptance: adapter contract, checkpoint resume, calibration maths.
The real-model smoke test is marked `slow` and skipped unless `chronos` is
importable, so the default suite stays fast and offline.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src import config
from src.adapters import (
AdapterError,
Forecast,
ModelNotAllowed,
PlaceholderAdapter,
build_windows,
get_adapter,
validate_model_id,
)
from src.metrics import calibration_coverage, calibration_error, directional_accuracy
from src.store import SignalStore, validate_signal_frame
from scripts.seed_store import Checkpoint, SeedTarget, plan_v1, seed_target
def series(n=600, seed=0, start="2023-01-01"):
rng = np.random.default_rng(seed)
return pd.Series(
100 * np.exp(np.cumsum(rng.normal(0.0005, 0.02, n))),
index=pd.date_range(start, periods=n, freq="D", tz="UTC"),
)
def price_frame(n=600, seed=0):
close = series(n, seed)
open_ = close.shift(1).fillna(close.iloc[0] * 0.999)
return pd.DataFrame({
"open": open_, "high": pd.concat([open_, close], axis=1).max(axis=1) * 1.004,
"low": pd.concat([open_, close], axis=1).min(axis=1) * 0.996,
"close": close, "volume": 1000.0, "source": "synthetic",
})
# --------------------------------------------------------------------------
# Allow-list: no arbitrary code execution
# --------------------------------------------------------------------------
def test_unknown_adapter_family_is_refused():
with pytest.raises(ModelNotAllowed, match="not allowed"):
get_adapter("evil-custom", "someone/backdoor")
def test_allowed_families_are_exactly_the_configured_set():
assert set(config.ALLOWED_ADAPTER_FAMILIES) == {"chronos", "timesfm", "baseline"}
@pytest.mark.parametrize("bad", [
"", "no-slash", "a/b/c", "../../etc/passwd", "owner/../name",
"owner/name;rm -rf /", "owner/na me", "owner/$(whoami)",
])
def test_malformed_model_ids_are_rejected(bad):
with pytest.raises(AdapterError):
validate_model_id(bad)
@pytest.mark.parametrize("good", [
"amazon/chronos-bolt-small", "google/timesfm-2.0-500m-pytorch", "org/model_v1.2",
])
def test_well_formed_model_ids_pass(good):
assert validate_model_id(good) == good
# --------------------------------------------------------------------------
# Windowing is causal
# --------------------------------------------------------------------------
def test_window_ends_at_its_own_timestamp():
s = series(n=100)
stamps, wins = build_windows(s, context_len=30)
assert wins.shape == (len(stamps), 30)
# The window stored at `t` must end with the value observed at `t`.
for i, ts in enumerate(list(stamps)[:5]):
assert wins[i, -1] == pytest.approx(float(s.loc[ts]))
def test_windows_never_include_future_values():
s = series(n=200)
stamps, wins = build_windows(s, context_len=50)
pos = {ts: i for i, ts in enumerate(s.index)}
for i, ts in enumerate(list(stamps)[:10]):
expected = s.to_numpy()[pos[ts] - 49: pos[ts] + 1]
assert np.allclose(wins[i], expected)
def test_too_short_series_yields_no_windows():
stamps, wins = build_windows(series(n=20), context_len=50)
assert len(stamps) == 0 and wins.shape[0] == 0
# --------------------------------------------------------------------------
# Placeholder adapter
# --------------------------------------------------------------------------
def test_placeholder_is_labelled_and_deterministic():
a = PlaceholderAdapter("synthetic/placeholder").load()
assert a.inference_version() == config.PLACEHOLDER_VERSION
_, wins = build_windows(series(n=300), 100)
first, second = a.predict(wins[:20]), a.predict(wins[:20])
assert np.allclose(first.q50, second.q50)
assert np.allclose(first.q10, second.q10)
def test_placeholder_output_is_schema_valid():
a = PlaceholderAdapter("synthetic/placeholder").load()
stamps, wins = build_windows(series(n=300), 100)
df = a.predict(wins[:50]).as_frame(stamps[:50], a.inference_version())
out = validate_signal_frame(df)
assert len(out) == 50
assert (out["inference_version"] == config.PLACEHOLDER_VERSION).all()
assert ((out["q10"] <= out["q50"]) & (out["q50"] <= out["q90"])).all()
def test_forecast_frame_sorts_crossed_quantiles():
f = Forecast(q10=np.array([5.0]), q50=np.array([1.0]), q90=np.array([3.0]),
context_len=10)
df = f.as_frame(pd.DatetimeIndex(["2024-01-01"], tz="UTC"), "v1")
assert df["q10"].iloc[0] <= df["q50"].iloc[0] <= df["q90"].iloc[0]
def test_forecast_rejects_mismatched_timestamp_count():
f = Forecast(q10=np.zeros(3), q50=np.zeros(3), q90=np.zeros(3), context_len=10)
with pytest.raises(AdapterError, match="!="):
f.as_frame(pd.DatetimeIndex(["2024-01-01"], tz="UTC"), "v1")
def test_inference_version_pins_model_and_revision():
a = get_adapter("chronos", "amazon/chronos-bolt-small", revision="abc123")
a._resolved_revision = "abc123"
b = get_adapter("chronos", "amazon/chronos-bolt-small", revision="def456")
b._resolved_revision = "def456"
assert a.inference_version() != b.inference_version()
assert a.inference_version() == a.inference_version()
# --------------------------------------------------------------------------
# Checkpoint resume
# --------------------------------------------------------------------------
def test_checkpoint_round_trips(tmp_path):
p = tmp_path / "ckpt.json"
c = Checkpoint.load(p)
c.mark("m|BTC-USD|1d", pd.Timestamp("2024-06-01", tz="UTC"))
again = Checkpoint.load(p)
assert again.last_ts("m|BTC-USD|1d") == pd.Timestamp("2024-06-01", tz="UTC")
def test_corrupt_checkpoint_starts_fresh_instead_of_crashing(tmp_path):
p = tmp_path / "ckpt.json"
p.write_text("{not json")
assert Checkpoint.load(p).done == {}
def test_checkpoint_records_failures_separately(tmp_path):
c = Checkpoint.load(tmp_path / "c.json")
c.mark_failed("m|ETH-USD|1h", "boom")
assert Checkpoint.load(tmp_path / "c.json").failed["m|ETH-USD|1h"] == "boom"
def test_seed_resumes_from_checkpoint_and_skips_finished_work(tmp_path, monkeypatch):
"""An interrupted seed must not redo inference it already paid for."""
store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
px = price_frame(n=400)
store.write_prices("BTC-USD", "1d", px.reset_index(names="ts"))
monkeypatch.setitem(config.SEED_MODELS, "test-model", config.ModelSpec(
slug="test-model", model_id="test/model", family="placeholder",
display="Test", context_len=100,
))
target = SeedTarget("test-model", "BTC-USD", "1d", 3.0, placeholder=True)
ckpt = Checkpoint.load(tmp_path / "ckpt.json")
msg = seed_target(store, target, ckpt, batch_size=64, force_placeholder=True)
assert msg.startswith("OK")
first_rows = len(store.get_signals("test-model", "BTC-USD", "1d"))
assert first_rows > 0
assert ckpt.last_ts(target.key) is not None
# Second call: the checkpoint says complete, so nothing more is computed.
msg2 = seed_target(store, target, ckpt, batch_size=64, force_placeholder=True)
assert "SKIP" in msg2
assert len(store.get_signals("test-model", "BTC-USD", "1d")) == first_rows
def test_seed_skips_targets_already_in_the_manifest(tmp_path, monkeypatch):
store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
store.write_prices("BTC-USD", "1d", price_frame(n=400).reset_index(names="ts"))
monkeypatch.setitem(config.SEED_MODELS, "test-model", config.ModelSpec(
slug="test-model", model_id="test/model", family="placeholder",
display="Test", context_len=100,
))
target = SeedTarget("test-model", "BTC-USD", "1d", 3.0, placeholder=True)
seed_target(store, target, Checkpoint.load(tmp_path / "a.json"),
batch_size=64, force_placeholder=True)
# Fresh checkpoint, but the manifest already covers the range.
msg = seed_target(store, target, Checkpoint.load(tmp_path / "b.json"),
batch_size=64, force_placeholder=True)
assert "already covered" in msg
def test_seed_skips_when_there_is_no_price_coverage(tmp_path, monkeypatch):
store = SignalStore(repo_id=None, local_root=tmp_path / "store", offline=True)
monkeypatch.setitem(config.SEED_MODELS, "test-model", config.ModelSpec(
slug="test-model", model_id="test/model", family="placeholder",
display="Test", context_len=100,
))
msg = seed_target(store, SeedTarget("test-model", "ETH-USD", "1d", 1.0),
Checkpoint.load(tmp_path / "c.json"), force_placeholder=True)
assert "no price coverage" in msg
def test_v1_plan_covers_the_specified_universe():
targets = plan_v1()
assets = {t.asset for t in targets}
assert {"BTC-USD", "ETH-USD", "SOL-USD"} <= assets
assert {"SPY", "QQQ", "NVDA"} <= assets
assert {t.timeframe for t in targets} == {"1d", "1h", "15m"}
# The v1 seed is entirely real: batched inference was cheap enough that no
# slice needs a synthetic placeholder.
assert not any(t.placeholder for t in targets)
assert len({t.model_slug for t in targets}) >= 2
# --------------------------------------------------------------------------
# Calibration maths against a synthetic series of KNOWN coverage
# --------------------------------------------------------------------------
def test_calibration_recovers_a_known_80_percent_coverage():
"""Construct a series where exactly 80% of actuals sit inside the band."""
n = 1000
idx = pd.DatetimeIndex(pd.date_range("2024-01-01", periods=n, freq="D", tz="UTC"))
lower = pd.Series(90.0, index=idx)
upper = pd.Series(110.0, index=idx)
actual = pd.Series(100.0, index=idx) # inside
actual.iloc[:200] = 500.0 # 20% outside, by construction
cov = calibration_coverage(actual, lower, upper)
assert cov == pytest.approx(0.80, abs=1e-12)
assert calibration_error(cov, 0.80) == pytest.approx(0.0, abs=1e-12)
@pytest.mark.parametrize("frac", [0.0, 0.25, 0.5, 0.9, 1.0])
def test_calibration_recovers_any_known_coverage(frac):
n = 200
idx = pd.date_range("2024-01-01", periods=n, freq="D", tz="UTC")
lower, upper = pd.Series(0.0, index=idx), pd.Series(1.0, index=idx)
actual = pd.Series(5.0, index=idx)
inside = int(round(n * frac))
actual.iloc[:inside] = 0.5
assert calibration_coverage(actual, lower, upper) == pytest.approx(frac, abs=1e-12)
def test_overconfident_band_reads_as_undercoverage():
n = 500
idx = pd.date_range("2024-01-01", periods=n, freq="D", tz="UTC")
rng = np.random.default_rng(3)
actual = pd.Series(rng.normal(0, 1, n), index=idx)
# A band far narrower than the true spread must score well under 0.80.
lower, upper = pd.Series(-0.05, index=idx), pd.Series(0.05, index=idx)
cov = calibration_coverage(actual, lower, upper)
assert cov < 0.20
assert calibration_error(cov, 0.80) < -0.5
def test_calibration_of_a_correctly_specified_normal_band():
"""A true 10th/90th percentile band on normal data covers ~80%."""
n = 20_000
idx = pd.date_range("2000-01-01", periods=n, freq="D", tz="UTC")
rng = np.random.default_rng(11)
actual = pd.Series(rng.normal(0, 1, n), index=idx)
lower = pd.Series(-1.2815515655446004, index=idx)
upper = pd.Series(1.2815515655446004, index=idx)
assert calibration_coverage(actual, lower, upper) == pytest.approx(0.80, abs=0.02)
def test_directional_accuracy_is_perfect_for_a_perfect_forecast():
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
ref = pd.Series(np.linspace(100, 200, 100), index=idx)
actual_next = ref.shift(-1).ffill()
assert directional_accuracy(actual_next, actual_next, ref) == pytest.approx(1.0)
def test_directional_accuracy_is_zero_for_a_perfectly_wrong_forecast():
idx = pd.date_range("2024-01-01", periods=100, freq="D", tz="UTC")
ref = pd.Series(np.linspace(100, 200, 100), index=idx)
actual_next = ref.shift(-1).ffill()
inverted = ref - (actual_next - ref)
assert directional_accuracy(actual_next, inverted, ref) == pytest.approx(0.0)
# --------------------------------------------------------------------------
# Real-model smoke test
# --------------------------------------------------------------------------
def _chronos_available() -> bool:
try:
import chronos # noqa: F401
return True
except Exception:
return False
@pytest.mark.slow
@pytest.mark.skipif(not _chronos_available(), reason="chronos-forecasting not installed")
def test_chronos_100_step_run_is_schema_valid():
"""Phase 2 acceptance: 100 steps on the real model, output schema-valid."""
a = get_adapter("chronos", "amazon/chronos-bolt-small", context_len=256).load()
assert a.resolved_revision not in ("", "unpinned")
stamps, wins = build_windows(series(n=600), 256)
stamps, wins = stamps[:100], wins[:100]
df = a.predict(wins).as_frame(stamps, a.inference_version())
out = validate_signal_frame(df)
assert len(out) == 100
assert ((out["q10"] <= out["q50"]) & (out["q50"] <= out["q90"])).all()
assert (out["context_len"] == 256).all()
assert out["inference_version"].nunique() == 1
assert out["inference_version"].iloc[0] != config.PLACEHOLDER_VERSION
assert np.isfinite(out[["q10", "q50", "q90"]].to_numpy()).all()
# --------------------------------------------------------------------------
# Chronos-2
# --------------------------------------------------------------------------
#
# `predict_quantiles` returns a different shape per pipeline, and the
# difference is not cosmetic:
#
# Bolt / T5 one stacked tensor, (batch, horizon, quantiles)
# Chronos-2 a LIST of per-item tensors, (n_variates, horizon, quantiles)
#
# Calling .float() on the list raises AttributeError, which is what
# amazon/chronos-2 did before `_to_array` existed.
def test_bolt_style_tensor_is_normalised():
import numpy as np
from src.adapters import ChronosAdapter
stacked = np.zeros((4, 1, 3), dtype="float32")
stacked[:, 0, 1] = 5.0 # q50 for every row
out = ChronosAdapter._to_array(stacked)
assert out.shape == (4, 3)
assert np.allclose(out[:, 1], 5.0)
def test_chronos2_list_of_tensors_is_normalised():
import numpy as np
from src.adapters import ChronosAdapter
# Four items, each (n_variates=1, horizon=1, quantiles=3).
listed = [np.array([[[1.0, 2.0, 3.0]]], dtype="float32") for _ in range(4)]
out = ChronosAdapter._to_array(listed)
assert out.shape == (4, 3), "Chronos-2's list shape was not handled"
assert np.allclose(out[0], [1.0, 2.0, 3.0])
def test_an_unexpected_result_type_raises_a_named_error():
from src.adapters import AdapterError, ChronosAdapter
with pytest.raises(AdapterError):
ChronosAdapter._to_array({"not": "a tensor"})
@pytest.mark.parametrize("model_id,is_c2,chunk_attr", [
("amazon/chronos-2", True, "CHRONOS2_CHUNK"),
("amazon/chronos-bolt-base", False, "BOLT_CHUNK"),
("amazon/chronos-t5-small", False, "T5_CHUNK"),
])
def test_chunk_size_per_variant(model_id, is_c2, chunk_attr):
from src.adapters import ChronosAdapter
chunk = getattr(ChronosAdapter, chunk_attr)
adapter = ChronosAdapter.__new__(ChronosAdapter)
adapter.model_id = model_id
assert adapter._is_chronos2 is is_c2
assert adapter.chunk_size == chunk
def test_chronos2_does_not_ask_for_samples():
"""Only the original T5 Chronos samples paths; asking Chronos-2 for
`num_samples` is an unexpected keyword."""
from src.adapters import ChronosAdapter
adapter = ChronosAdapter.__new__(ChronosAdapter)
adapter.model_id = "amazon/chronos-2"
assert adapter._is_bolt is False and adapter._is_chronos2 is True
|