zenfro_v3 / generator.py
tope1129's picture
cascade generator submission: zenfro_v3
9ffa689 verified
Raw
History Blame Contribute Delete
40.7 kB
"""zenfro_v3 β€” Grammar-of-Time (GoT) mixture-of-priors generator.
Built on the / fullctx-spectral-v12 infrastructure (prefetch chunk
producer, SciPy AR filters, FFT spectral stems, measurement artifacts), with a
new competitive prior: **Grammar of Time**.
Why a grammar, not another single-family mix
--------------------------------------------
Cascade scores a Toto2 backbone trained from scratch on your corpus. The
winning synthetic priors in the literature (Chronos-2, TempoPFN, ForecastPFN,
CauKer) are not single ARIMA draws β€” they are *compositions* of temporal
primitives (trend Γ— season Γ— noise Γ— events Γ— causal lags). already
covers many primitives as mutually exclusive families. GoT goes one step
further: a substantial share of series is produced by sampling a short
**production** over stems and operators, so the model sees the same structural
atoms *combined* the way real series combine them.
Temporal grammar (informal CFG)::
Series β†’ Production | Stem
Production β†’ Compose | Splice | Nested | Causal
Compose β†’ Stem βŠ• Stem [βŠ• Stem] # additive / multiplicative phrase
Splice β†’ Stem β€– Stem [β€– Stem] # clause boundary at breakpoints
Nested β†’ Envelope ⋉ Carrier # slow scale modulates fast scale
Causal β†’ Driver β–· Response # lagged temporal DAG (1β†’1)
Stem β†’ {trend_seasonal, ar2, integrated, spectral, ou, long_memory, …}
Affix β†’ jump | hold | pulse | artifact # applied after production
Determinism, code-only, bounded+finite contracts are unchanged: one
``np.random.default_rng(seed)``, allowlisted NumPy/SciPy, ``_sanitize`` gate.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from functools import lru_cache, partial
from pathlib import Path
from queue import Full, Queue
from threading import Event, Thread
import numpy as np
from scipy.signal import lfilter
from cascade.interface import DataGenerator
# Prefetch chunk size β€” measured 2048 as the local sweet spot.
_CHUNK = 2048
_SEASONAL_PERIODS = np.array(
[4, 7, 12, 24, 30, 48, 52, 90, 96, 144, 168, 183, 288, 336, 365, 672, 730],
dtype=np.float64,
)
_SEASONAL_PROBS = np.array(
[0.04, 0.12, 0.04, 0.16, 0.05, 0.06, 0.04, 0.03, 0.07, 0.03,
0.13, 0.04, 0.04, 0.06, 0.07, 0.04, 0.05],
dtype=np.float64,
)
_SEASONAL_PROBS /= _SEASONAL_PROBS.sum()
# ── family mixture ──────────────────────────────────────────────────────────
# dynamics-heavy core retained; four GoT productions added. Default
# mass puts ~22% on compositional grammar so the model learns combination
# structure without drowning the proven single-stem families.
_FAMILIES: tuple[str, ...] = (
"trend_seasonal_ar",
"regime_shift",
"multiplicative",
"ar2",
"integrated",
"threshold_ar",
"chaotic",
"spectral_gp",
"long_memory",
"ou_stochastic_vol",
"physical_sensors",
"seasonal_counts",
"intermittent",
"pulse_outlier",
"got_compose", # Grammar: Stem βŠ• Stem [βŠ• Stem]
"got_splice", # Grammar: Stem β€– Stem at clause boundaries
"got_nested", # Grammar: Envelope ⋉ Carrier (multi-scale)
"got_causal", # Grammar: Driver β–· lagged Response
)
_DEFAULT_WEIGHTS: dict[str, float] = {
"trend_seasonal_ar": 0.10,
"regime_shift": 0.10,
"multiplicative": 0.06,
"ar2": 0.12,
"integrated": 0.10,
"threshold_ar": 0.06,
"chaotic": 0.03,
"spectral_gp": 0.05,
"long_memory": 0.05,
"ou_stochastic_vol": 0.08,
"physical_sensors": 0.015,
"seasonal_counts": 0.015,
"intermittent": 0.01,
"pulse_outlier": 0.01,
"got_compose": 0.08,
"got_splice": 0.06,
"got_nested": 0.05,
"got_causal": 0.03,
}
class Generator(DataGenerator):
"""Grammar-of-Time mixture 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", 4096))
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()
self._tr_hi_frac = float(cfg.get("tr_hi_frac", 0.25))
self._tr_exc_lo = float(cfg.get("tr_exc_lo", 0.4))
self._tr_exc_hi = float(cfg.get("tr_exc_hi", 2.5))
self._gr_exc_lo = float(cfg.get("gr_exc_lo", 0.3))
self._gr_exc_hi = float(cfg.get("gr_exc_hi", 1.5))
self._sa_clean_frac = float(cfg.get("sa_clean_frac", 0.4))
self._sa_clean_lo = float(cfg.get("sa_clean_lo", 0.02))
self._sa_clean_hi = float(cfg.get("sa_clean_hi", 0.12))
# GoT knobs β€” composition depth, splice density, nest scale span.
self._got_depth = int(cfg.get("got_depth", 3)) # stems in a compose (2..4)
self._got_mul_frac = float(cfg.get("got_mul_frac", 0.35))
self._got_splice_cuts = int(cfg.get("got_splice_cuts", 2))
self._got_nest_ratio = float(cfg.get("got_nest_ratio", 6.0))
self._got_causal_lag_frac = float(cfg.get("got_causal_lag_frac", 0.08))
self._artifact_scale = float(cfg.get("artifact_scale", 1.0))
@property
def name(self) -> str:
return str(self._cfg.get("name", "zenfro-v3-grammar-of-time"))
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 = (
partial(_trend_seasonal_ar, hi_frac=self._tr_hi_frac,
exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi,
clean_frac=self._sa_clean_frac,
clean_lo=self._sa_clean_lo, clean_hi=self._sa_clean_hi),
_regime_shift,
partial(_multiplicative, hi_frac=self._tr_hi_frac,
exc_lo=self._gr_exc_lo, exc_hi=self._gr_exc_hi),
_ar2, _integrated, _threshold_ar, _chaotic, _spectral_gp,
_long_memory, _ou_stochastic_vol, _physical_sensors,
_seasonal_counts, _intermittent, _pulse_outlier,
partial(_got_compose, depth=self._got_depth, mul_frac=self._got_mul_frac,
hi_frac=self._tr_hi_frac, exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi),
partial(_got_splice, n_cuts=self._got_splice_cuts,
hi_frac=self._tr_hi_frac, exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi),
partial(_got_nested, nest_ratio=self._got_nest_ratio),
partial(_got_causal, lag_frac=self._got_causal_lag_frac),
)
queue: Queue[object] = Queue(maxsize=1)
stop = Event()
done = object()
art_scale = self._artifact_scale
def put(item: object) -> bool:
while not stop.is_set():
try:
queue.put(item, timeout=0.1)
return True
except Full:
continue
return False
def produce() -> None:
try:
produced = 0
while produced < n_series and not stop.is_set():
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 = builders[fam](rng, int(idx.size), max_len)
preserve_nonnegative = fam in (2, 10, 11, 12)
block = _sanitize(
_measurement_artifacts(
rng,
block,
preserve_nonnegative=preserve_nonnegative,
rate_scale=art_scale,
)
)
for row, series_i in enumerate(idx):
length = int(lengths[series_i])
chunk[series_i] = np.ascontiguousarray(
block[row, :length], dtype=np.float64
)
take = min(_CHUNK, n_series - produced)
if not put((chunk, take)):
return
produced += take
except BaseException as exc:
put(exc)
finally:
put(done)
producer = Thread(target=produce, name="zenfro-v3-generator", daemon=True)
producer.start()
try:
while True:
item = queue.get()
if item is done:
break
if isinstance(item, BaseException):
raise item
chunk, take = item
for arr in chunk[:take]:
if arr is None: # pragma: no cover
raise RuntimeError("internal: unfilled series slot")
yield arr
finally:
stop.set()
producer.join(timeout=1.0)
# ── shared vectorised primitives ────────────────────────────────────────────
def _ar1_batch(innov: np.ndarray, phi: np.ndarray) -> np.ndarray:
n, L = innov.shape
x = np.empty((n, L), dtype=np.float64)
p = phi.reshape(n)
for i in range(n):
x[i] = lfilter([1.0], [1.0, -float(p[i])], innov[i])
return x
def _ar2_batch(innov: np.ndarray, a1: np.ndarray, a2: np.ndarray) -> np.ndarray:
n, L = innov.shape
x = np.empty((n, L), dtype=np.float64)
for i in range(n):
x[i] = lfilter(
[1.0], [1.0, -float(a1[i]), -float(a2[i])], innov[i]
)
return x
@lru_cache(maxsize=4)
def _seasonal_basis(L: int) -> tuple[np.ndarray, np.ndarray]:
angle = (
2.0
* np.pi
* np.arange(L, dtype=np.float64)[None, :]
/ _SEASONAL_PERIODS[:, None]
)
return np.sin(angle), np.cos(angle)
def _seasonal(rng: np.random.Generator, n: int, L: int, k_max: int = 3) -> np.ndarray:
t = np.arange(L, dtype=np.float64)[None, :]
sin_basis, cos_basis = _seasonal_basis(L)
k = rng.integers(1, k_max + 1, size=n)
out = np.zeros((n, L), dtype=np.float64)
for j in range(k_max):
active = np.nonzero(k > j)[0]
per = rng.choice(_SEASONAL_PERIODS, size=n, p=_SEASONAL_PROBS)[:, None]
amp = rng.uniform(0.2, 2.0, size=n)[:, None]
phase = rng.uniform(0.0, 2.0 * np.pi, size=n)[:, None]
basis_idx = np.searchsorted(_SEASONAL_PERIODS, per[active, 0])
component = amp[active] * (
sin_basis[basis_idx] * np.cos(phase[active])
+ cos_basis[basis_idx] * np.sin(phase[active])
)
modulated = np.nonzero((k > j) & (rng.random(n) < 0.35))[0]
if modulated.size:
modulated_local = np.searchsorted(active, modulated)
modulated_arg = (
2.0 * np.pi * t / per[modulated] + phase[modulated]
)
m_per = np.clip(
per[modulated] * rng.uniform(
4.0, 12.0, size=(modulated.size, 1)
),
32.0,
2.0 * L,
)
m_phase = rng.uniform(
0.0, 2.0 * np.pi, size=(modulated.size, 1)
)
slow = np.sin(2.0 * np.pi * t / m_per + m_phase)
amp_mod = 1.0 + rng.uniform(
0.05, 0.45, size=(modulated.size, 1)
) * slow
phase_mod = rng.uniform(
0.05, 0.75, size=(modulated.size, 1)
) * np.sin(2.0 * np.pi * t / (1.7 * m_per) - m_phase)
component[modulated_local] = (
amp[modulated]
* amp_mod
* np.sin(modulated_arg + phase_mod)
)
out[active] += component
return out
def _sparse_jumps(rng: np.random.Generator, n: int, L: int, rate: float, scale) -> np.ndarray:
mask = rng.random((n, L)) < rate
mask[:, 0] = False
rows, cols = np.nonzero(mask)
jumps = np.zeros((n, L), dtype=np.float64)
if rows.size == 0:
return jumps
s = np.asarray(scale, dtype=np.float64)
event_scale = s if s.ndim == 0 else s.reshape(n)[rows]
jumps[rows, cols] = rng.normal(0.0, 1.0, size=rows.size) * event_scale
return jumps
def _row_standardize(x: np.ndarray) -> np.ndarray:
x = x - x.mean(axis=1, keepdims=True)
sd = x.std(axis=1, keepdims=True)
return x / np.where(sd < 1e-12, 1.0, sd)
def _measurement_artifacts(
rng: np.random.Generator,
block: np.ndarray,
*,
preserve_nonnegative: bool,
rate_scale: float = 1.0,
) -> np.ndarray:
"""Sparse real-measurement effects; ``rate_scale`` multiplies base rates."""
original = np.asarray(block, dtype=np.float64)
out = original.copy()
n, L = out.shape
rs = float(np.clip(rate_scale, 0.0, 3.0))
reverse = rng.random(n) < (0.06 * rs)
out[reverse] = out[reverse, ::-1]
if not preserve_nonnegative:
invert = rng.random(n) < (0.04 * rs)
out[invert] *= -1.0
for row in np.nonzero(rng.random(n) < (0.06 * rs))[0]:
q = float(rng.uniform(0.03, 0.18))
if rng.random() < 0.5:
out[row] = np.minimum(out[row], np.quantile(out[row], 1.0 - q))
else:
out[row] = np.maximum(out[row], np.quantile(out[row], q))
quantized = np.nonzero(rng.random(n) < (0.07 * rs))[0]
if quantized.size:
x = out[quantized]
lo = x.min(axis=1, keepdims=True)
hi = x.max(axis=1, keepdims=True)
levels = rng.integers(16, 257, size=(quantized.size, 1))
step = (hi - lo) / np.maximum(levels - 1, 1)
safe_step = np.where(step < 1e-12, 1.0, step)
out[quantized] = lo + np.rint((x - lo) / safe_step) * safe_step
held = np.nonzero(rng.random(n) < (0.04 * rs))[0]
if held.size:
factors = rng.choice([2, 4, 8], size=held.size, p=[0.55, 0.30, 0.15])
for factor in (2, 4, 8):
rows = held[factors == factor]
if rows.size:
out[rows] = np.repeat(
out[rows, ::factor], factor, axis=1
)[:, :L]
degenerate = out.std(axis=1) < 1e-9
out[degenerate] = original[degenerate]
return out
# ── stem builders ( lineage) ────────────────────────────────────────
def _trend_seasonal_ar(rng: np.random.Generator, n: int, L: int, *,
hi_frac: float = 0.25, exc_lo: float = 0.4,
exc_hi: float = 3.0, clean_frac: float = 0.4,
clean_lo: float = 0.02, clean_hi: float = 0.12) -> np.ndarray:
t = np.arange(L, dtype=np.float64)[None, :]
level = rng.normal(0.0, 1.0, size=(n, 1))
_hi = rng.random((n, 1)) < hi_frac
exc = np.where(_hi, rng.normal(0.0, exc_hi, size=(n, 1)),
rng.normal(0.0, exc_lo, size=(n, 1)))
tn = t / max(L - 1, 1)
series = level + exc * tn + _seasonal(rng, n, L)
phi = rng.uniform(0.0, 0.85, size=n)
clean = rng.random((n, 1)) < clean_frac
sigma = np.where(
clean,
rng.uniform(clean_lo, clean_hi, size=(n, 1)),
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))
slope = rng.normal(0.0, 1.0 / L, size=(n, 1)) + np.cumsum(
_sparse_jumps(rng, n, L, rate=2.0 / L, scale=4.0 / L), axis=1
)
piecewise_trend = np.cumsum(slope, axis=1)
return level + piecewise_trend + seas + noise
def _multiplicative(rng: np.random.Generator, n: int, L: int, *,
hi_frac: float = 0.25, exc_lo: float = 0.3, exc_hi: float = 2.0) -> np.ndarray:
t = np.arange(L, dtype=np.float64)[None, :]
_hg = rng.random((n, 1)) < hi_frac
gexc = np.where(_hg, rng.normal(0.0, exc_hi, size=(n, 1)),
rng.normal(0.0, exc_lo, size=(n, 1)))
tn = t / max(L - 1, 1)
base_level = np.exp(gexc * tn + rng.normal(0.0, 0.3, size=(n, 1)))
amp = rng.uniform(0.1, 0.6, size=(n, 1))
seasonal_shape = _seasonal(rng, n, L, k_max=1)
seasonal_sd = seasonal_shape.std(axis=1, keepdims=True)
seasonal_shape /= np.where(seasonal_sd < 1e-12, 1.0, seasonal_sd)
seas = 1.0 + amp * seasonal_shape
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 _spectral_gp(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
f = np.fft.rfftfreq(L)[None, :]
lengthscale = np.exp(rng.uniform(np.log(8.0), np.log(256.0), size=(n, 1)))
envelope = np.exp(-0.5 * (2.0 * np.pi * lengthscale * f) ** 2)
z = rng.standard_normal((n, f.shape[1])) + 1j * rng.standard_normal((n, f.shape[1]))
z[:, 0] = 0.0
x = np.fft.irfft(z * np.sqrt(envelope), n=L, axis=1)
sd = x.std(axis=1, keepdims=True)
return x / np.where(sd < 1e-12, 1.0, sd)
def _long_memory(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
f = np.fft.rfftfreq(L)
safe_f = np.maximum(f, 1.0 / L)[None, :]
beta = rng.uniform(-0.6, 2.4, size=(n, 1))
amp = safe_f ** (-0.5 * beta)
multiscale = rng.random((n, 1)) < 0.4
split_idx = rng.integers(8, max(9, f.size // 3), size=(n, 1))
split_f = np.maximum(split_idx / L, 1.0 / L)
beta_hi = rng.uniform(-0.6, 2.8, size=(n, 1))
above = np.arange(f.size)[None, :] > split_idx
amp_hi = split_f ** (-0.5 * beta) \
* (safe_f / split_f) ** (-0.5 * beta_hi)
amp = np.where(multiscale & above, amp_hi, amp)
amp[:, 0] = 0.0
z = rng.standard_normal((n, f.size)) + 1j * rng.standard_normal((n, f.size))
x = np.fft.irfft(z * amp, n=L, axis=1)
integrate = rng.random(n) < 0.25
if integrate.any():
x[integrate] = np.cumsum(x[integrate], axis=1)
x -= x.mean(axis=1, keepdims=True)
sd = x.std(axis=1, keepdims=True)
return x / np.where(sd < 1e-12, 1.0, sd)
def _ou_stochastic_vol(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
switch_rate = np.exp(rng.uniform(np.log(0.001), np.log(0.15), size=(n, 1)))
switches = rng.random((n, L)) < switch_rate
switches[:, 0] = rng.random(n) < 0.5
regime = np.bitwise_and(np.cumsum(switches, axis=1), 1).astype(np.int8)
slow = rng.random((n, 1)) < 0.5
phi = np.where(
slow,
rng.uniform(0.995, 0.9995, size=(n, 1)),
rng.uniform(0.90, 0.99, size=(n, 1)),
)
mu0 = rng.normal(-2.0, 1.0, size=(n, 1))
mu1 = rng.normal(2.0, 1.0, size=(n, 1))
mean = np.where(regime == 0, mu0, mu1)
seasonal_on = rng.random((n, 1)) < 0.6
mean += seasonal_on * _seasonal(rng, n, L, k_max=3) \
* rng.uniform(0.5, 3.0, size=(n, 1))
sigma0 = rng.lognormal(np.log(0.3), 0.3, size=(n, 1))
sigma1 = rng.lognormal(np.log(1.5), 0.5, size=(n, 1))
base_sigma = np.where(regime == 0, sigma0, sigma1)
log_vol = np.cumsum(
_sparse_jumps(rng, n, L, rate=8.0 / L, scale=0.35), axis=1
)
log_vol -= log_vol.mean(axis=1, keepdims=True)
vol = base_sigma * np.exp(np.clip(log_vol, -1.5, 1.5))
eps = rng.standard_normal((n, L))
heavy = np.nonzero(rng.random(n) < 0.35)[0]
if heavy.size:
eps[heavy] = (
rng.standard_t(4.0, size=(heavy.size, L)) / np.sqrt(2.0)
)
shocks = rng.random((n, L)) < (3.0 / L)
shock_rows, shock_cols = np.nonzero(shocks)
eps[shock_rows, shock_cols] += rng.normal(
0.0, 5.0, size=shock_rows.size
)
innovation_scale = np.sqrt(np.maximum(1.0 - phi * phi, 1e-6))
drive = (1.0 - phi) * mean + innovation_scale * vol * eps
out = np.empty((n, L), dtype=np.float64)
out[:, 0] = mean[:, 0] + vol[:, 0] * eps[:, 0]
for i in range(n):
p = float(phi[i, 0])
out[i, 1:] = lfilter(
[1.0], [1.0, -p], drive[i, 1:], zi=[p * out[i, 0]]
)[0]
scale = np.exp(rng.uniform(np.log(0.1), np.log(50.0), size=(n, 1)))
shift = rng.uniform(-100.0, 100.0, size=(n, 1))
return out * scale + shift
def _physical_sensors(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
seasonal = _seasonal(rng, n, L, k_max=2)
smooth = _spectral_gp(rng, n, L)
fronts = np.cumsum(
_sparse_jumps(rng, n, L, rate=5.0 / L, scale=1.0), axis=1
)
base = (
seasonal * rng.uniform(0.3, 2.0, size=(n, 1))
+ smooth * rng.uniform(0.2, 1.2, size=(n, 1))
+ fronts * rng.uniform(0.2, 1.0, size=(n, 1))
)
kind = rng.integers(0, 4, size=n)
out = base.copy()
bounded = kind == 1
if bounded.any():
gain = rng.uniform(0.8, 3.5, size=(int(bounded.sum()), 1))
midpoint = rng.uniform(-0.8, 0.8, size=(int(bounded.sum()), 1))
out[bounded] = 100.0 / (1.0 + np.exp(-gain * (base[bounded] - midpoint)))
pressure = kind == 2
if pressure.any():
count = int(pressure.sum())
walk = np.cumsum(rng.standard_normal((count, L)), axis=1) / np.sqrt(L)
level = rng.uniform(900.0, 1100.0, size=(count, 1))
out[pressure] = level + rng.uniform(2.0, 15.0, size=(count, 1)) * walk \
+ 2.0 * fronts[pressure] + 0.5 * seasonal[pressure]
magnitude = kind == 3
if magnitude.any():
count = int(magnitude.sum())
gusts = (rng.random((count, L)) < (8.0 / L)) \
* rng.lognormal(0.0, 0.8, size=(count, L))
power = rng.uniform(1.0, 1.6, size=(count, 1))
out[magnitude] = np.abs(base[magnitude]) ** power + gusts
return out
def _seasonal_counts(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
t = np.arange(L, dtype=np.float64)[None, :]
period = rng.choice(
_SEASONAL_PERIODS, size=(n, 1), p=_SEASONAL_PROBS
)
phase = rng.uniform(0.0, 2.0 * np.pi, size=(n, 1))
amp = rng.uniform(0.15, 0.8, size=(n, 1))
log_rate = amp * np.sin(2.0 * np.pi * t / period + phase)
second = rng.random((n, 1)) < 0.55
log_rate += second * (0.5 * amp) * np.sin(
4.0 * np.pi * t / period + rng.uniform(0.0, 2.0 * np.pi, size=(n, 1))
)
calendar = rng.random((n, 1)) < 0.35
day_period = rng.choice([24, 48, 96, 144], size=(n, 1))
day_idx = (np.floor_divide(np.arange(L)[None, :], day_period) % 7).astype(np.int64)
day_factors = rng.normal(0.0, 0.12, size=(n, 7))
day_factors[:, 5:] += rng.uniform(-0.8, 0.3, size=(n, 1))
calendar_effect = np.take_along_axis(day_factors, day_idx, axis=1)
log_rate += calendar * calendar_effect
excursion = rng.uniform(-0.5, 0.5, size=(n, 1))
log_rate += excursion * t / max(L - 1, 1)
impulses = (
(rng.random((n, L)) < (2.0 / L))
* rng.uniform(1.0, 10.0, size=(n, L))
)
burst = _ar1_batch(impulses, rng.uniform(0.85, 0.995, size=(n, 1)))
base = np.exp(rng.uniform(np.log(3.0), np.log(3000.0), size=(n, 1)))
lam = base * np.exp(np.clip(log_rate, -5.0, 5.0)) * (1.0 + burst)
np.clip(lam, 0.0, 1.0e7, out=lam)
overdispersed = rng.random((n, 1)) < 0.5
shape = rng.uniform(0.5, 4.0, size=(n, 1))
mixed = lam * rng.gamma(shape, 1.0 / shape, size=(n, L))
return rng.poisson(np.where(overdispersed, mixed, lam)).astype(np.float64)
def _intermittent(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
t = np.arange(L, dtype=np.float64)[None, :]
base_p = rng.uniform(0.03, 0.35, size=(n, 1))
period = rng.choice([7.0, 12.0, 24.0, 48.0, 168.0], size=(n, 1))
season = rng.uniform(0.2, 1.2, size=(n, 1)) * np.sin(
2.0 * np.pi * t / period + rng.uniform(0.0, 2.0 * np.pi, size=(n, 1))
)
logit = np.log(base_p / (1.0 - base_p)) + season
p = 1.0 / (1.0 + np.exp(-logit))
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))
* np.exp(0.25 * season)
)
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 = _spectral_gp(rng, n, L) * 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))
sharp = _sparse_jumps(
rng, n, L, rate=3.0 / L, scale=rng.uniform(3.0, 8.0, size=n)
)
impulses = _sparse_jumps(
rng, n, L, rate=2.0 / L, scale=rng.uniform(2.0, 7.0, size=n)
)
recovery = _ar1_batch(impulses, rng.uniform(0.75, 0.995, size=n))
series = base + sharp + recovery
starts = rng.random((n, L)) < (2.0 / L)
starts[:, 0] = False
for row in range(n):
for start in np.nonzero(starts[row])[0]:
run = int(rng.integers(3, 65))
end = min(int(start) + run, L)
series[row, start:end] = series[row, start - 1]
return series
# ── Grammar-of-Time productions ─────────────────────────────────────────────
# Stem pool for compositions. Order is fixed so RNG draw sequences stay stable
# across config-only weight changes to non-GoT families.
# Lightweight stem pool for GoT productions. Full builders stay as
# top-level families; compositions need many stems per row, so these stay
# FFT/AR/seasonal only β€” no Python-over-t recurrences.
def _stem_ar_seasonal(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
seas = _seasonal(rng, n, L, k_max=2)
phi = rng.uniform(0.1, 0.9, size=n)
sigma = rng.uniform(0.15, 0.7, size=(n, 1))
innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma
return _row_standardize(seas * rng.uniform(0.3, 1.2, size=(n, 1)) + _ar1_batch(innov, phi))
def _stem_integrated(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
sigma = rng.uniform(0.2, 1.0, size=(n, 1))
walk = np.cumsum(rng.normal(0.0, 1.0, size=(n, L)) * sigma, axis=1)
return _row_standardize(walk)
def _sample_stem(rng: np.random.Generator, n: int, L: int) -> np.ndarray:
"""Draw one cheap stem family per row and fill a (n, L) block."""
builders = (
_stem_ar_seasonal,
_spectral_gp,
_long_memory,
_stem_integrated,
_ar2,
)
fam = rng.integers(0, len(builders), size=n)
out = np.empty((n, L), dtype=np.float64)
for k, builder in enumerate(builders):
idx = np.nonzero(fam == k)[0]
if idx.size == 0:
continue
out[idx] = _row_standardize(builder(rng, int(idx.size), L))
return out
def _got_compose(rng: np.random.Generator, n: int, L: int, *,
depth: int = 3, mul_frac: float = 0.35,
hi_frac: float = 0.25, exc_lo: float = 0.4,
exc_hi: float = 2.5) -> np.ndarray:
"""Production: Stem βŠ• Stem [βŠ• Stem] β€” additive or multiplicative phrase.
Each row stacks ``depth`` standardised stems. A minority use multiplicative
agreement (level Γ— seasonal-like factor), matching TempoPFN-style compound
structure rather than a single process family.
"""
depth = int(np.clip(depth, 2, 4))
# Always draw ``depth`` stems so the RNG stream is depth-stable.
stems = [_sample_stem(rng, n, L) for _ in range(depth)]
n_active = rng.integers(2, depth + 1, size=n)
out = np.zeros((n, L), dtype=np.float64)
use_mul = rng.random(n) < mul_frac
for j, stem in enumerate(stems):
active = (n_active > j)[:, None]
w = rng.uniform(0.4, 1.6, size=(n, 1))
# Additive branch.
add_mask = active & (~use_mul[:, None])
out = np.where(add_mask, out + w * stem, out)
# Multiplicative branch: first stem is the carrier; later stems modulate.
if j == 0:
out = np.where(use_mul[:, None], stem, out)
else:
factor = 1.0 + 0.35 * w * stem
out = np.where(active & use_mul[:, None], out * factor, out)
# Mild length-normalised trend affix so compose rows still carry forecastable
# drift without exploding scale.
t = np.arange(L, dtype=np.float64)[None, :] / max(L - 1, 1)
_hi = rng.random((n, 1)) < hi_frac
exc = np.where(_hi, rng.normal(0.0, exc_hi * 0.5, size=(n, 1)),
rng.normal(0.0, exc_lo * 0.5, size=(n, 1)))
out = out + exc * t
# Sparse punctuation affix (jumps) on a minority of rows.
punct = rng.random(n) < 0.4
if punct.any():
jumps = np.cumsum(
_sparse_jumps(rng, n, L, rate=2.5 / L, scale=1.5), axis=1
)
out[punct] = out[punct] + jumps[punct]
scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1)))
shift = rng.uniform(-50.0, 50.0, size=(n, 1))
return out * scale + shift
def _got_splice(rng: np.random.Generator, n: int, L: int, *,
n_cuts: int = 2, hi_frac: float = 0.25,
exc_lo: float = 0.4, exc_hi: float = 2.5) -> np.ndarray:
"""Production: Stem β€– Stem β€” clause boundaries splice different dynamics.
Unlike 's piecewise level jumps inside one process, each clause is
an independent stem; breakpoints teach structural change of *generating law*.
"""
n_cuts = int(np.clip(n_cuts, 1, 4))
n_clauses = n_cuts + 1
clauses = [_sample_stem(rng, n, L) for _ in range(n_clauses)]
# Cut positions in (0.15L, 0.85L), sorted per row.
cuts = np.sort(
rng.integers(max(1, L // 8), max(2, (7 * L) // 8), size=(n, n_cuts)),
axis=1,
)
# Enforce strictly increasing cuts with a small gap.
for c in range(1, n_cuts):
cuts[:, c] = np.maximum(cuts[:, c], cuts[:, c - 1] + max(8, L // 32))
cuts = np.clip(cuts, 1, L - 2)
out = clauses[0].copy()
t_idx = np.arange(L)[None, :]
for c in range(n_cuts):
after = t_idx >= cuts[:, c:c + 1]
out = np.where(after, clauses[c + 1], out)
# Soft blend near each cut so the splice is a transition, not a hard glitch
# (real regime changes often ramp over a few steps).
blend_w = max(4, L // 128)
for c in range(n_cuts):
cut = cuts[:, c:c + 1]
dist = (t_idx - cut).astype(np.float64)
gate = np.clip(0.5 + dist / (2.0 * blend_w), 0.0, 1.0)
left = clauses[c]
right = clauses[c + 1]
near = np.abs(dist) <= blend_w
blended = (1.0 - gate) * left + gate * right
out = np.where(near, blended, out)
# Optional level offset between clauses (structural break magnitude).
level_jump = rng.normal(0.0, 1.5, size=(n, n_cuts))
for c in range(n_cuts):
after = t_idx >= cuts[:, c:c + 1]
out = np.where(after, out + level_jump[:, c:c + 1], out)
t = np.arange(L, dtype=np.float64)[None, :] / max(L - 1, 1)
_hi = rng.random((n, 1)) < hi_frac
exc = np.where(_hi, rng.normal(0.0, exc_hi * 0.4, size=(n, 1)),
rng.normal(0.0, exc_lo * 0.4, size=(n, 1)))
out = out + exc * t
scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1)))
shift = rng.uniform(-50.0, 50.0, size=(n, 1))
return out * scale + shift
def _got_nested(rng: np.random.Generator, n: int, L: int, *,
nest_ratio: float = 6.0) -> np.ndarray:
"""Production: Envelope ⋉ Carrier β€” slow scale nests a fast carrier.
Hierarchical seasonality / synoptic weather / business-cycle nesting: a
smooth long-scale envelope modulates amplitude (and sometimes phase) of a
faster seasonal or AR carrier. This is the multi-scale grammar Chronos-style
priors emphasise but only touches via modulated seasonality.
"""
nest_ratio = float(np.clip(nest_ratio, 2.0, 24.0))
t = np.arange(L, dtype=np.float64)[None, :]
# Slow envelope: spectral GP with long lengthscale, or long sinusoid.
use_gp_env = rng.random(n) < 0.55
env = np.empty((n, L), dtype=np.float64)
gp_rows = np.nonzero(use_gp_env)[0]
sin_rows = np.nonzero(~use_gp_env)[0]
if gp_rows.size:
# Force long lengthscales for the envelope.
f = np.fft.rfftfreq(L)[None, :]
lengthscale = np.exp(
rng.uniform(np.log(64.0), np.log(min(512.0, L / 2.0)), size=(gp_rows.size, 1))
)
envelope = np.exp(-0.5 * (2.0 * np.pi * lengthscale * f) ** 2)
z = rng.standard_normal((gp_rows.size, f.shape[1])) + 1j * rng.standard_normal(
(gp_rows.size, f.shape[1])
)
z[:, 0] = 0.0
g = np.fft.irfft(z * np.sqrt(envelope), n=L, axis=1)
env[gp_rows] = _row_standardize(g)
if sin_rows.size:
per = rng.uniform(L / nest_ratio, L / 1.5, size=(sin_rows.size, 1))
phase = rng.uniform(0.0, 2.0 * np.pi, size=(sin_rows.size, 1))
env[sin_rows] = np.sin(2.0 * np.pi * t / per + phase)
# Fast carrier: seasonal bank and/or AR(2).
carrier = _seasonal(rng, n, L, k_max=3)
carrier = _row_standardize(carrier)
mix_ar = rng.random(n) < 0.45
if mix_ar.any():
ar = _row_standardize(_ar2(rng, n, L))
w = rng.uniform(0.3, 0.7, size=(n, 1))
carrier = np.where(mix_ar[:, None], w * carrier + (1.0 - w) * ar, carrier)
amp = 1.0 + rng.uniform(0.3, 1.4, size=(n, 1)) * env
# Phase wobble as a small quadrature mix with a lagged carrier β€” vectorised,
# no per-row roll. Equivalent spirit: slow envelope nudges fast phase.
carrier_lag = np.empty_like(carrier)
carrier_lag[:, 0] = carrier[:, 0]
carrier_lag[:, 1:] = carrier[:, :-1]
wobble = rng.uniform(0.0, 0.35, size=(n, 1)) * env
out = amp * (carrier + wobble * carrier_lag)
# Residual noise scaled by envelope intensity (prosody).
sigma = rng.uniform(0.05, 0.35, size=(n, 1)) * (0.5 + 0.5 * np.abs(env))
phi = rng.uniform(0.0, 0.8, size=n)
innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma
out = out + _ar1_batch(innov, phi)
scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1)))
shift = rng.uniform(-50.0, 50.0, size=(n, 1))
return out * scale + shift
def _delay_batch(x: np.ndarray, lags: np.ndarray) -> np.ndarray:
"""Causal delay with edge hold, vectorised over a small lag vocabulary.
Rows sharing a lag are shifted in one slice copy β€” O(#unique_lags) passes
instead of a Python loop over n.
"""
n, L = x.shape
out = np.empty_like(x)
# Hold initial value for the lag prefix.
for lag in np.unique(lags):
rows = np.nonzero(lags == lag)[0]
if rows.size == 0:
continue
lag_i = int(lag)
block = x[rows]
delayed = np.empty_like(block)
delayed[:, :lag_i] = block[:, :1]
delayed[:, lag_i:] = block[:, :-lag_i]
out[rows] = delayed
return out
def _got_causal(rng: np.random.Generator, n: int, L: int, *,
lag_frac: float = 0.08) -> np.ndarray:
"""Production: Driver β–· Response β€” lagged temporal causal chain.
Inspired by Chronos-2 / CauKer temporal causal graphs, specialised to a
univariate observable: the emitted series is a response driven by a latent
driver with a drawn lag and FIR-like coupling, plus its own AR residual.
Teaches lead-lag structure that pure mixture families never emit.
"""
lag_frac = float(np.clip(lag_frac, 0.01, 0.25))
# Discrete lag menu keeps _delay_batch on a handful of unique values.
lag_menu = np.unique(
np.clip(
(np.array([0.01, 0.02, 0.04, 0.06, 0.08, 0.12, 0.16, 0.20]) * L).astype(np.int64),
1,
max(1, int(L * lag_frac)),
)
)
lags = rng.choice(lag_menu, size=n)
driver = _sample_stem(rng, n, L)
use_parent2 = rng.random(n) < 0.4
parent2 = _sample_stem(rng, n, L)
a0 = rng.uniform(0.2, 1.2, size=(n, 1))
a1 = rng.uniform(0.3, 1.5, size=(n, 1))
b = rng.uniform(0.2, 1.0, size=(n, 1))
lags2 = rng.choice(lag_menu, size=n)
delayed = _delay_batch(driver, lags)
resp = a0 * driver + a1 * delayed
if use_parent2.any():
delayed2 = _delay_batch(parent2, lags2)
resp = np.where(use_parent2[:, None], resp + b * delayed2, resp)
phi = rng.uniform(0.2, 0.9, size=n)
sigma = rng.uniform(0.1, 0.5, size=(n, 1))
innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma
resp = resp + _ar1_batch(innov, phi)
seas_on = rng.random((n, 1)) < 0.5
resp = resp + seas_on * _seasonal(rng, n, L, k_max=2) * rng.uniform(
0.1, 0.8, size=(n, 1)
)
scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1)))
shift = rng.uniform(-50.0, 50.0, size=(n, 1))
return resp * scale + shift
# ── final safety gate ───────────────────────────────────────────────────────
def _sanitize(block: np.ndarray) -> np.ndarray:
x = np.asarray(block, dtype=np.float64)
np.nan_to_num(x, copy=False, nan=0.0, posinf=1e6, neginf=-1e6)
np.clip(x, -1e6, 1e6, out=x)
return x