| """custom_miner β a diverse, deterministic synthetic time-series generator. |
| |
| This is the artifact a cascade miner actually competes with: a subclass of |
| ``cascade.interface.DataGenerator`` that turns a single integer ``seed`` into a |
| corpus of univariate float series. The subnet holds the model, seeds, and |
| compute budget byte-identical between the king and every challenger, so the |
| *only* thing that moves the forecast score is the distribution this file emits. |
| The competitive lever is therefore **prior diversity + realism**: a corpus that |
| covers more of the shapes a real forecaster must handle (trend, multi-seasonal, |
| regime shifts, integrated/near-unit-root dynamics, smooth GP-like curves, |
| nonlinear/chaotic recurrences, intermittent demand, outliers) trains a stronger |
| zero-shot model than the reference generator's trend+seasonal+AR(1) mix. |
| |
| Design constraints this file respects (all from the contract in |
| ``cascade.interface``): |
| |
| * **Determinism is load-bearing.** Every value is drawn from one |
| ``np.random.default_rng(seed)`` in a fixed draw order, so two runs at the same |
| seed produce byte-identical corpora β the property ``cascade verify`` audits |
| by building the corpus twice and comparing digests. |
| * **Code-only.** No shipped weights, no network, no clock, no un-seeded RNG. |
| Imports stay on the dependency allowlist (numpy only here) and clear of the |
| static-guard blocklist. |
| * **Bounded + finite.** Each series is 1-D ``(L,)`` float64, length in |
| ``[min_length, max_length]``, finite (no NaN/inf). ``_sanitize`` is the last |
| gate so a numerically unlucky draw can never poison a training run. |
| |
| Everything is **vectorised per family** (a batched time-axis recurrence, never a |
| per-series Python loop over time), so draining the full ``corpus_n_series`` |
| (16384 on mainnet) is fast enough to stay well under ``max_generate_seconds``. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from collections.abc import Iterator |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| from cascade.interface import DataGenerator |
|
|
| |
| |
| |
| |
| _CHUNK = 256 |
|
|
| |
| |
| |
| |
| |
| _FAMILIES: tuple[str, ...] = ( |
| "trend_seasonal_ar", |
| "regime_shift", |
| "multiplicative", |
| "ar2", |
| "integrated", |
| "threshold_ar", |
| "chaotic", |
| "rff_gp", |
| "intermittent", |
| "pulse_outlier", |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _DEFAULT_WEIGHTS: dict[str, float] = { |
| "trend_seasonal_ar": 0.26, |
| "regime_shift": 0.10, |
| "multiplicative": 0.16, |
| "ar2": 0.10, |
| "integrated": 0.08, |
| "threshold_ar": 0.06, |
| "chaotic": 0.05, |
| "rff_gp": 0.07, |
| "intermittent": 0.03, |
| "pulse_outlier": 0.03, |
| } |
|
|
|
|
| class Generator(DataGenerator): |
| """A mixture-of-priors generator. Submit as ``generator.Generator``.""" |
|
|
| def __init__(self, config_dir: str, *, seed: int) -> None: |
| cfg_path = Path(config_dir) / "config.json" |
| cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.is_file() else {} |
| self._cfg = cfg |
| self._seed = int(seed) |
| self._min_len = int(cfg.get("min_length", 64)) |
| self._max_len = int(cfg.get("max_length", 2048)) |
| if self._min_len < 1 or self._max_len < self._min_len: |
| raise ValueError(f"invalid length band [{self._min_len}, {self._max_len}]") |
| weights = dict(_DEFAULT_WEIGHTS) |
| for k, v in dict(cfg.get("family_weights", {})).items(): |
| if k in weights: |
| weights[k] = float(v) |
| w = np.asarray([weights[f] for f in _FAMILIES], dtype=np.float64) |
| if not np.all(np.isfinite(w)) or w.min() < 0 or w.sum() <= 0: |
| raise ValueError("family_weights must be finite, non-negative, and not all zero") |
| self._weights = w / w.sum() |
|
|
| @property |
| def name(self) -> str: |
| return str(self._cfg.get("name", "custom-mixture-of-priors-v1")) |
|
|
| def generate(self, n_series: int) -> Iterator[np.ndarray]: |
| |
| |
| |
| |
| |
| |
| |
| |
| if n_series <= 0: |
| return |
| rng = np.random.default_rng(self._seed) |
| max_len = self._max_len |
| builders = ( |
| _trend_seasonal_ar, _regime_shift, _multiplicative, _ar2, |
| _integrated, _threshold_ar, _chaotic, _rff_gp, |
| _intermittent, _pulse_outlier, |
| ) |
| produced = 0 |
| while produced < n_series: |
| |
| |
| |
| |
| |
| |
| lengths = rng.integers(self._min_len, max_len + 1, size=_CHUNK) |
| fam_ids = rng.choice(len(_FAMILIES), size=_CHUNK, p=self._weights) |
| chunk: list[np.ndarray | None] = [None] * _CHUNK |
| for fam in range(len(_FAMILIES)): |
| idx = np.nonzero(fam_ids == fam)[0] |
| if idx.size == 0: |
| continue |
| block = _sanitize(builders[fam](rng, int(idx.size), max_len)) |
| for row, series_i in enumerate(idx): |
| L = int(lengths[series_i]) |
| chunk[series_i] = np.ascontiguousarray(block[row, :L], dtype=np.float64) |
| take = min(_CHUNK, n_series - produced) |
| for arr in chunk[:take]: |
| |
| |
| if arr is None: |
| raise RuntimeError("internal: unfilled series slot") |
| yield arr |
| produced += take |
|
|
|
|
| |
|
|
|
|
| def _ar1_batch(innov: np.ndarray, phi: np.ndarray) -> np.ndarray: |
| """AR(1) filter applied along the time axis of a (n, L) innovation block. |
| |
| ``x[:, t] = phi * x[:, t-1] + innov[:, t]``. The loop is over time (L |
| iterations, vectorised across the batch), never over the n series. |
| """ |
| n, L = innov.shape |
| x = np.empty((n, L), dtype=np.float64) |
| x[:, 0] = innov[:, 0] |
| p = phi.reshape(n) |
| for t in range(1, L): |
| x[:, t] = p * x[:, t - 1] + innov[:, t] |
| return x |
|
|
|
|
| def _ar2_batch(innov: np.ndarray, a1: np.ndarray, a2: np.ndarray) -> np.ndarray: |
| """AR(2) filter: ``x_t = a1 x_{t-1} + a2 x_{t-2} + e_t`` (batched over n).""" |
| n, L = innov.shape |
| x = np.empty((n, L), dtype=np.float64) |
| x[:, 0] = innov[:, 0] |
| if L > 1: |
| x[:, 1] = a1 * x[:, 0] + innov[:, 1] |
| for t in range(2, L): |
| x[:, t] = a1 * x[:, t - 1] + a2 * x[:, t - 2] + innov[:, t] |
| return x |
|
|
|
|
| def _seasonal(rng: np.random.Generator, n: int, L: int, k_max: int = 3) -> np.ndarray: |
| """Sum of 1..k_max sinusoids with per-series random period/amp/phase.""" |
| t = np.arange(L, dtype=np.float64)[None, :] |
| periods = np.array([4, 7, 12, 24, 30, 52, 96, 144, 168, 336], dtype=np.float64) |
| k = rng.integers(1, k_max + 1, size=n) |
| out = np.zeros((n, L), dtype=np.float64) |
| for j in range(k_max): |
| active = (k > j).astype(np.float64)[:, None] |
| per = rng.choice(periods, size=n)[:, None] |
| amp = rng.uniform(0.2, 2.0, size=n)[:, None] |
| phase = rng.uniform(0.0, 2.0 * np.pi, size=n)[:, None] |
| out += active * amp * np.sin(2.0 * np.pi * t / per + phase) |
| return out |
|
|
|
|
| def _sparse_jumps(rng: np.random.Generator, n: int, L: int, rate: float, scale) -> np.ndarray: |
| """A (n, L) block of mostly-zero values with occasional N(0, scale) jumps. |
| |
| ``cumsum`` over this yields a piecewise-constant level; ``exp(cumsum)`` of a |
| scaled version yields a piecewise-constant positive multiplier. |
| """ |
| mask = rng.random((n, L)) < rate |
| mag = rng.normal(0.0, 1.0, size=(n, L)) |
| s = np.asarray(scale, dtype=np.float64) |
| if s.ndim == 1: |
| s = s[:, None] |
| jumps = mask * mag * s |
| jumps[:, 0] = 0.0 |
| return jumps |
|
|
|
|
| |
|
|
|
|
| def _trend_seasonal_ar(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| t = np.arange(L, dtype=np.float64)[None, :] |
| level = rng.normal(0.0, 1.0, size=(n, 1)) |
| slope = rng.normal(0.0, 0.01, size=(n, 1)) |
| series = level + slope * t + _seasonal(rng, n, L) |
| phi = rng.uniform(0.0, 0.85, size=n) |
| sigma = rng.uniform(0.1, 0.6, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| return series + _ar1_batch(innov, phi) |
|
|
|
|
| def _regime_shift(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| level = np.cumsum(_sparse_jumps(rng, n, L, rate=3.0 / L, scale=2.0), axis=1) |
| log_vol = np.cumsum(_sparse_jumps(rng, n, L, rate=3.0 / L, scale=0.5), axis=1) |
| vol = np.exp(np.clip(log_vol, -3.0, 3.0)) * rng.uniform(0.1, 0.5, size=(n, 1)) |
| noise = rng.normal(0.0, 1.0, size=(n, L)) * vol |
| seas = _seasonal(rng, n, L, k_max=2) * rng.uniform(0.0, 1.0, size=(n, 1)) |
| return level + seas + noise |
|
|
|
|
| def _multiplicative(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| t = np.arange(L, dtype=np.float64)[None, :] |
| growth = rng.normal(0.0, 0.003, size=(n, 1)) |
| base_level = np.exp(growth * t + rng.normal(0.0, 0.3, size=(n, 1))) |
| amp = rng.uniform(0.1, 0.6, size=(n, 1)) |
| seas = 1.0 + amp * np.sin( |
| 2.0 * np.pi * t / rng.choice([7.0, 12.0, 24.0, 52.0], size=n)[:, None] |
| + rng.uniform(0.0, 2 * np.pi, size=(n, 1)) |
| ) |
| noise = 1.0 + rng.normal(0.0, 1.0, size=(n, L)) * rng.uniform(0.02, 0.15, size=(n, 1)) |
| scale = rng.uniform(1.0, 50.0, size=(n, 1)) |
| return scale * base_level * np.clip(seas, 0.05, None) * np.clip(noise, 0.05, None) |
|
|
|
|
| def _ar2(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| |
| p1 = rng.uniform(0.3, 0.98, size=n) |
| p2 = rng.uniform(-0.6, 0.6, size=n) |
| a2 = p2 |
| a1 = p1 * (1.0 - p2) |
| sigma = rng.uniform(0.2, 0.8, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| x = _ar2_batch(innov, a1, a2) |
| drift = rng.normal(0.0, 0.005, size=(n, 1)) * np.arange(L, dtype=np.float64)[None, :] |
| return x + drift |
|
|
|
|
| def _integrated(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| order2 = rng.random(n) < 0.35 |
| drift = rng.normal(0.0, 0.02, size=(n, 1)) |
| sigma = rng.uniform(0.2, 1.0, size=(n, 1)) |
| steps = rng.normal(0.0, 1.0, size=(n, L)) * sigma + drift |
| walk = np.cumsum(steps, axis=1) |
| walk2 = np.cumsum(walk, axis=1) |
| o2 = order2[:, None] |
| |
| return np.where(o2, walk2 / max(L, 1) ** 0.5, walk) |
|
|
|
|
| def _threshold_ar(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| phi_hi = rng.uniform(0.3, 0.9, size=n) |
| phi_lo = rng.uniform(-0.9, 0.3, size=n) |
| const_hi = rng.normal(0.0, 0.3, size=n) |
| const_lo = rng.normal(0.0, 0.3, size=n) |
| sigma = rng.uniform(0.2, 0.7, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| x = np.empty((n, L), dtype=np.float64) |
| x[:, 0] = innov[:, 0] |
| for t in range(1, L): |
| prev = x[:, t - 1] |
| hi = prev >= 0.0 |
| phi = np.where(hi, phi_hi, phi_lo) |
| const = np.where(hi, const_hi, const_lo) |
| x[:, t] = np.clip(const + phi * prev + innov[:, t], -1e6, 1e6) |
| return x |
|
|
|
|
| def _chaotic(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| |
| use_sine = rng.random(n) < 0.5 |
| r_log = rng.uniform(3.6, 4.0, size=n) |
| r_sin = rng.uniform(0.85, 1.0, size=n) |
| x0 = rng.uniform(0.05, 0.95, size=n) |
| x = np.empty((n, L), dtype=np.float64) |
| cur = x0.copy() |
| x[:, 0] = cur |
| for t in range(1, L): |
| nxt_log = r_log * cur * (1.0 - cur) |
| nxt_sin = r_sin * np.sin(np.pi * cur) |
| cur = np.where(use_sine, nxt_sin, nxt_log) |
| cur = np.clip(cur, 0.0, 1.0) |
| x[:, t] = cur |
| return x |
|
|
|
|
| def _rff_gp(rng: np.random.Generator, n: int, L: int, K: int = 48) -> np.ndarray: |
| |
| |
| |
| |
| t = np.arange(L, dtype=np.float64)[None, :] |
| lengthscale = rng.uniform(20.0, 200.0, size=(n, 1)) |
| acc = np.zeros((n, L), dtype=np.float64) |
| for _ in range(K): |
| w = rng.normal(0.0, 1.0, size=(n, 1)) / lengthscale |
| b = rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| acc += np.cos(w * t + b) |
| return np.sqrt(2.0 / K) * acc |
|
|
|
|
| def _intermittent(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| p = rng.uniform(0.05, 0.4, size=(n, 1)) |
| occur = (rng.random((n, L)) < p).astype(np.float64) |
| magnitude = rng.gamma(shape=2.0, scale=1.0, size=(n, L)) * rng.uniform(1.0, 10.0, size=(n, 1)) |
| baseline = rng.uniform(0.0, 0.5, size=(n, 1)) |
| return baseline + occur * magnitude |
|
|
|
|
| def _pulse_outlier(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| base = _rff_gp(rng, n, L, K=24) * rng.uniform(0.5, 2.0, size=(n, 1)) |
| base += _seasonal(rng, n, L, k_max=1) * rng.uniform(0.0, 1.0, size=(n, 1)) |
| pulses = _sparse_jumps(rng, n, L, rate=5.0 / L, scale=rng.uniform(3.0, 8.0, size=n)) |
| series = base + pulses |
| |
| hold = rng.random((n, L)) < (2.0 / L) |
| hold[:, 0] = False |
| for t in range(1, L): |
| m = hold[:, t] |
| series[m, t] = series[m, t - 1] |
| return series |
|
|
|
|
| |
|
|
|
|
| def _sanitize(block: np.ndarray) -> np.ndarray: |
| """Guarantee the contract: finite float64, no NaN/inf, bounded magnitude. |
| |
| The trainer's ``check_series`` rejects any non-finite value, which would |
| fail the whole run β so this is the hard backstop after every family |
| builder. Replaces non-finite values and clips to a generous bound. |
| """ |
| x = np.asarray(block, dtype=np.float64) |
| x = np.nan_to_num(x, nan=0.0, posinf=1e6, neginf=-1e6) |
| return np.clip(x, -1e6, 1e6) |
|
|