Skip to content

BaseModel

base_model

The model interface every model file implements.

This is the linchpin of the one-model-one-file rule and the factory. Every Python model is a subclass of BaseModel living in its own file that ends with register(...); the factory (models/__init__.py) builds {name: class} at import. Adding a model is a new file plus one register call — no edits anywhere else.

Models never read global config: everything they need at fit/predict time arrives through ModelContext. Models that don't emit their own prediction intervals (supports_native_intervals = False) call BaseModel.residual_intervals so every model still returns the canonical frame with ordered bounds.

Public surface: BaseModel, ModelContext, register (plus the _REGISTRY the factory reads, and the Runtime/Family type aliases).

BaseModel

BaseModel(params, ctx)

Bases: ABC

Base class for every forecasting model.

Source code in src/scale_forecasting/models/base_model.py
def __init__(self, params: dict[str, Any], ctx: ModelContext) -> None:
    self.params = dict(params)
    self.ctx = ctx
    # Residuals stashed by fit() for models that lean on the residual-interval helper.
    self._residuals: np.ndarray | None = None
    # How far past the fit's last observation this model now forecasts from — see
    # `advance_origin`. Zero for every ordinary fit-then-predict cell, which is what makes the
    # offset arithmetic in each model's predict() a no-op on the default path.
    self._origin_offset: int = 0
    # Exog covering the skipped span, for the models that forecast by recursion and so have to
    # be rolled through it. None everywhere else, and None whenever the offset is 0.
    self._gap_exog: pd.DataFrame | None = None

advance_origin

advance_origin(periods, X_gap=None)

Move the forecast origin periods steps forward blind — no new actuals.

The seam behind backtest.scheme="expanding_stale": the model's already-fitted curve is evaluated at later dates and never told what actually happened in between, so its score measures staleness, not skill. It cannot notice a level shift. That is a real question ("how fast does a fitted model decay if nobody touches it?") and a different one from recondition's, which is why the two never share a leaderboard slice.

periods is absolute — counted from the last observation the fit saw, not from wherever the origin currently is. So the driver sets it per fold instead of accumulating, and setting it twice is idempotent. 0 restores the as-fitted origin.

X_gap is exog over the skipped span, for the models whose forecast is a recursion that has to be rolled through it. Handing it over is not leakage: exog is known-in-advance by construction here — features.build_future_features fabricates it for the forward forecast too. Only the target is withheld.

Unlike recondition, this one has a real default: it records the offset and the gap exog, and each model honours them in predict (the base helper _forecast_index does the date half). There is nothing model-specific left to override, so a model opts in by setting supports_extrapolate rather than by reimplementing the same four lines sixteen times.

Source code in src/scale_forecasting/models/base_model.py
def advance_origin(self, periods: int, X_gap: pd.DataFrame | None = None) -> None:
    """Move the forecast origin ``periods`` steps forward *blind* — no new actuals.

    The seam behind ``backtest.scheme="expanding_stale"``: the model's already-fitted curve is
    evaluated at later dates and never told what actually happened in between, so its score
    measures **staleness, not skill**. It cannot notice a level shift. That is a real question
    ("how fast does a fitted model decay if nobody touches it?") and a different one from
    `recondition`'s, which is why the two never share a leaderboard slice.

    ``periods`` is **absolute** — counted from the last observation the fit saw, not from
    wherever the origin currently is. So the driver sets it per fold instead of accumulating,
    and setting it twice is idempotent. ``0`` restores the as-fitted origin.

    ``X_gap`` is exog over the skipped span, for the models whose forecast is a recursion that
    has to be rolled through it. Handing it over is not leakage: exog is known-in-advance by
    construction here — `features.build_future_features` fabricates it for the forward forecast
    too. Only the *target* is withheld.

    Unlike `recondition`, this one has a real default: it records the offset and the gap exog,
    and each model honours them in ``predict`` (the base helper `_forecast_index` does the date
    half). There is nothing model-specific left to override, so a model opts in by setting
    `supports_extrapolate` rather than by reimplementing the same four lines sixteen times.
    """
    if not self.supports_extrapolate:
        raise ModelError(f"{type(self).name} cannot advance its forecast origin")
    if periods < 0:
        raise ModelError(f"advance_origin: periods must be >= 0, got {periods}")
    self._origin_offset = int(periods)
    self._gap_exog = X_gap

device_used

device_used()

After fit, where did the parameters actually end up — "cuda", "cpu", None?

The evidence half of the GPU contract, and it has to come from the fitted object rather than from what the run asked for. ModelContext.device records the request; a request is not a receipt, and the whole reason this contract exists is that for twenty-one jobs the two silently disagreed.

None is the default and the honest answer for every model with no tensor library under it: a statsmodels fit does not run "on the CPU" in any sense worth recording, it simply has no device concept. Do not return "cpu" to mean "not applicable" — a reader auditing a GPU family needs to tell "this model looked and found no device" from "this model cannot answer". Override only where the library can be asked; see gpu_capable.

Called once per cell after the final fit, and it must never raise — a probe that sank a good forecast would be worse than no probe.

Source code in src/scale_forecasting/models/base_model.py
def device_used(self) -> str | None:
    """After ``fit``, where did the parameters actually end up — ``"cuda"``, ``"cpu"``, None?

    The evidence half of the GPU contract, and it has to come from the fitted object rather
    than from what the run asked for. ``ModelContext.device`` records the *request*; a request
    is not a receipt, and the whole reason this contract exists is that for twenty-one jobs
    the two silently disagreed.

    ``None`` is the default and the honest answer for every model with no tensor library under
    it: a statsmodels fit does not run "on the CPU" in any sense worth recording, it simply has
    no device concept. Do not return ``"cpu"`` to mean "not applicable" — a reader auditing a
    GPU family needs to tell "this model looked and found no device" from "this model cannot
    answer". Override only where the library can be asked; see `gpu_capable`.

    Called once per cell after the final fit, and it must never raise — a probe that sank a
    good forecast would be worse than no probe.
    """
    return None

diagnostics

diagnostics()

Whatever the fitting library reports about this fit. Default: nothing.

Called once per cell after the final full-history fit, and written to forecast_metadata.fit_diagnostics. Examples of what belongs here: AIC or log-likelihood, the (p,d,q) an auto_arima selected, XGBoost's best_iteration, a changepoint count, the number of epochs an early stop actually ran.

Why these are not metrics, and why that distinction is load-bearing. A scored metric is a pure function of (y_true, yhat, y_train, bounds) that the framework computes, so it means the same thing for all sixteen models and a leaderboard can rank on it. A diagnostic is whatever the library happened to expose: it exists for some models and not others, and where two models both report an "AIC" the two numbers are not on a comparable scale. Put one in the metric panel and you get a leaderboard column that looks uniform and is not — which is exactly the failure the framework-computes-metrics rule exists to prevent. So this is a bag in its own JSON column, next to best_params, which is already the precedent for per-model numbers nobody aggregates across models. Nothing here reaches the panel, the leaderboard projection, or decision_metric.

Key names may not collide with a metric name. best_params and the metric panel are read off one forecast_metadata row, and a diagnostic called mase would make "which mase is this" a real question the first time anything flattens the JSON beside the columns. worker drops a colliding key and logs it rather than failing the cell — see worker._collect_diagnostics.

Best-effort, never fatal. Anything raised here is caught and the cell simply reports no diagnostics, on the same reasoning as serialize: a forecast that was produced must not be thrown away because a library could not describe how it was produced. Return plain JSON-serializable scalars — the bag is written with json.dumps into a BigQuery JSON column, so a numpy scalar or a fitted object is dropped (with a warning) rather than allowed to fail the append for every other cell in the same batch. Cast to float/int/str here and the value survives.

Source code in src/scale_forecasting/models/base_model.py
def diagnostics(self) -> dict[str, Any]:
    """Whatever the fitting library reports about *this* fit. Default: nothing.

    Called once per cell after the final full-history fit, and written to
    ``forecast_metadata.fit_diagnostics``. Examples of what belongs here: AIC or
    log-likelihood, the ``(p,d,q)`` an ``auto_arima`` selected, XGBoost's ``best_iteration``,
    a changepoint count, the number of epochs an early stop actually ran.

    **Why these are not metrics, and why that distinction is load-bearing.** A scored metric is
    a pure function of ``(y_true, yhat, y_train, bounds)`` that the *framework* computes, so it
    means the same thing for all sixteen models and a leaderboard can rank on it. A diagnostic
    is whatever the library happened to expose: it exists for some models and not others, and
    where two models both report an "AIC" the two numbers are not on a comparable scale. Put
    one in the metric panel and you get a leaderboard column that looks uniform and is not —
    which is exactly the failure the framework-computes-metrics rule exists to prevent. So this
    is a bag in its own JSON column, next to ``best_params``, which is already the precedent
    for per-model numbers nobody aggregates across models. Nothing here reaches the panel, the
    leaderboard projection, or ``decision_metric``.

    **Key names may not collide with a metric name.** ``best_params`` and the metric panel are
    read off one ``forecast_metadata`` row, and a diagnostic called ``mase`` would make "which
    mase is this" a real question the first time anything flattens the JSON beside the columns.
    `worker` drops a colliding key and logs it rather than failing the cell — see
    `worker._collect_diagnostics`.

    **Best-effort, never fatal.** Anything raised here is caught and the cell simply reports no
    diagnostics, on the same reasoning as `serialize`: a forecast that was produced must not be
    thrown away because a library could not describe how it was produced. Return plain
    JSON-serializable scalars — the bag is written with ``json.dumps`` into a BigQuery ``JSON``
    column, so a numpy scalar or a fitted object is dropped (with a warning) rather than
    allowed to fail the append for every other cell in the same batch. Cast to
    ``float``/``int``/``str`` here and the value survives.
    """
    return {}

explain

explain(horizon, X=None)

Per-horizon-step local feature attributions ({"baseline_score": ..., ...}).

Optional Tier-2 explainability hook: returns a list of horizon JSON-serializable dictionaries (one per forecast step), or None when the model does not emit per-step local attributions. Written to forecast_predictions.explanations. Series/model-level global feature importances live in diagnostics()["feature_attributions"] (Tier 1, written to forecast_metadata.fit_diagnostics).

Source code in src/scale_forecasting/models/base_model.py
def explain(
    self, horizon: int, X: pd.DataFrame | None = None
) -> list[dict[str, Any] | None] | None:
    """Per-horizon-step local feature attributions (`{"baseline_score": ..., ...}`).

    Optional Tier-2 explainability hook: returns a list of ``horizon`` JSON-serializable
    dictionaries (one per forecast step), or ``None`` when the model does not emit per-step
    local attributions. Written to ``forecast_predictions.explanations``. Series/model-level
    global feature importances live in ``diagnostics()["feature_attributions"]`` (Tier 1,
    written to ``forecast_metadata.fit_diagnostics``).
    """
    return None

fit abstractmethod

fit(y, X=None)

Fit on one series. y is indexed by ds (datetime64); X is aligned exog or None.

Source code in src/scale_forecasting/models/base_model.py
@abstractmethod
def fit(self, y: pd.Series, X: pd.DataFrame | None = None) -> None:
    """Fit on one series. ``y`` is indexed by ds (datetime64); ``X`` is aligned exog or None."""

fit_panel

fit_panel(series_map, static_map=None)

Fit a single global or hybrid model across a panel of {ts_id: (y, X)}.

Source code in src/scale_forecasting/models/base_model.py
def fit_panel(
    self,
    series_map: Mapping[str, tuple[pd.Series, pd.DataFrame | None]],
    static_map: Mapping[str, dict[str, Any]] | None = None,
) -> None:
    """Fit a single global or hybrid model across a panel of ``{ts_id: (y, X)}``."""
    raise ModelError(f"{type(self).name} does not support panel-wide fit")

get_params

get_params()

Resolved params actually used (post-HPO). Logged to forecast_metadata.best_params.

Source code in src/scale_forecasting/models/base_model.py
def get_params(self) -> dict[str, Any]:
    """Resolved params actually used (post-HPO). Logged to ``forecast_metadata.best_params``."""
    return dict(self.params)

gpu_useful classmethod

gpu_useful(params)

Would a device do meaningful work at these authored hyperparameters? Default: no.

gpu_capable says a device can be used; this says it would be worth paying for. The two came apart when they were measured: across 31,356 NeuralProphet fits on live T4s, peak device memory was 50–78 KB against a card holding 17 GB and cpu_seconds / fit_seconds sat at 0.93–0.996. The model was on the device the whole time and the device was doing essentially nothing, because at the shipped defaults the network is a few hundred trend and Fourier parameters. Every GPU run in the ledger was, in substance, a CPU run.

Authored hyperparameters only — no ModelContext, no config. Building a plan-time context would duplicate worker._model_context (which does real work), and no model's device use depends on the frequency or the holiday frame. Widen the signature later if a model genuinely needs the horizon; that is an internal change, not a config change.

This never feeds routing or provisioning. Consuming it at engines.ray_io.split_gpu_cpu_models would empty the GPU pool on every config shipped today while the submitter still bought the cards — manufacturing the exact provisioned-but-unrouted incoherence dag.check_hardware_coherence exists to refuse. It feeds one thing: a warning (dag.gpu_usefulness_report).

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def gpu_useful(cls, params: Mapping[str, Any]) -> bool:
    """Would a device do meaningful work at these **authored** hyperparameters? Default: no.

    `gpu_capable` says a device *can* be used; this says it would be *worth paying for*. The
    two came apart when they were measured: across 31,356 NeuralProphet fits on live T4s, peak
    device memory was 50–78 KB against a card holding 17 GB and ``cpu_seconds / fit_seconds``
    sat at 0.93–0.996. The model was on the device the whole time and the device was doing
    essentially nothing, because at the shipped defaults the network is a few hundred trend and
    Fourier parameters. Every GPU run in the ledger was, in substance, a CPU run.

    **Authored hyperparameters only** — no `ModelContext`, no config. Building a plan-time
    context would duplicate `worker._model_context` (which does real work), and no model's
    device use depends on the frequency or the holiday frame. Widen the signature later if a
    model genuinely needs the horizon; that is an internal change, not a config change.

    **This never feeds routing or provisioning.** Consuming it at
    `engines.ray_io.split_gpu_cpu_models` would empty the GPU pool on every config shipped
    today while the submitter still bought the cards — manufacturing the exact
    provisioned-but-unrouted incoherence `dag.check_hardware_coherence` exists to refuse. It
    feeds one thing: a warning (`dag.gpu_usefulness_report`).
    """
    return False

is_available classmethod

is_available()

Whether this model's upstream Python package is installed in the current environment.

Probed without importing it (errors.is_importable), so listing 37 models costs nothing and a dotted name whose parent is absent (google.cloud.bigquery on a core-only install) reads as "not installed" rather than raising.

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def is_available(cls) -> bool:
    """Whether this model's upstream Python package is installed in the current environment.

    Probed without importing it (`errors.is_importable`), so listing 37 models costs nothing and
    a dotted name whose parent is absent (``google.cloud.bigquery`` on a core-only install)
    reads as "not installed" rather than raising.
    """
    if cls.optional_import is None:
        return True
    return is_importable(cls.optional_import)

predict abstractmethod

predict(horizon, X=None, quantiles=DEFAULT_QUANTILES)

Return the canonical prediction frame in original units.

Source code in src/scale_forecasting/models/base_model.py
@abstractmethod
def predict(
    self,
    horizon: int,
    X: pd.DataFrame | None = None,
    quantiles: tuple[float, ...] = DEFAULT_QUANTILES,
) -> pd.DataFrame:
    """Return the canonical prediction frame in original units."""

predict_panel

predict_panel(
    horizon,
    future_exog_map=None,
    quantiles=DEFAULT_QUANTILES,
    transform_lambdas=None,
)

Return {ts_id: canonical_prediction_frame} in original units for a fitted panel.

Source code in src/scale_forecasting/models/base_model.py
def predict_panel(
    self,
    horizon: int,
    future_exog_map: Mapping[str, pd.DataFrame | None] | None = None,
    quantiles: tuple[float, ...] = DEFAULT_QUANTILES,
    transform_lambdas: Mapping[str, float | None] | None = None,
) -> dict[str, pd.DataFrame]:
    """Return ``{ts_id: canonical_prediction_frame}`` in original units for a fitted panel."""
    raise ModelError(f"{type(self).name} does not support panel-wide predict")

recondition

recondition(y_new, X_new=None)

Take in observations that arrived after the current origin, without re-estimating.

This is the seam behind backtest.scheme="expanding_frozen": the model is fit once on the oldest fold's window and then walked forward, so each fold's score answers "what does refitting less often cost me?" rather than "how good is this model freshly trained?". Parameters stay exactly as fitted; only what the model is conditioned on moves.

y_new is the new observations only — not the whole window. That is statsmodels' own append contract, and it is the contract worth matching because getting it wrong there is silent: appending a span twice corrupts the state filter and raises nothing. Models that would rather hold the whole history (the naives, croston, the lag models) accumulate it themselves in one line, and those are exactly the models where the operation is cheap and hard to get wrong.

Default: refuse. A model that has no way to absorb an observation without re-estimating should say so rather than approximate, and supports_recondition is how the driver knows in advance.

Source code in src/scale_forecasting/models/base_model.py
def recondition(self, y_new: pd.Series, X_new: pd.DataFrame | None = None) -> None:
    """Take in observations that arrived *after* the current origin, without re-estimating.

    This is the seam behind ``backtest.scheme="expanding_frozen"``: the model is fit once on the
    oldest fold's window and then walked forward, so each fold's score answers "what does
    refitting less often cost me?" rather than "how good is this model freshly trained?".
    Parameters stay exactly as fitted; only what the model is *conditioned on* moves.

    ``y_new`` is the **new observations only** — not the whole window. That is
    ``statsmodels``' own ``append`` contract, and it is the contract worth matching because
    getting it wrong there is silent: appending a span twice corrupts the state filter and
    raises nothing. Models that would rather hold the whole history (the naives, ``croston``,
    the lag models) accumulate it themselves in one line, and those are exactly the models where
    the operation is cheap and hard to get wrong.

    Default: refuse. A model that has no way to absorb an observation without re-estimating
    should say so rather than approximate, and `supports_recondition` is how the driver knows
    in advance.
    """
    raise ModelError(f"{type(self).name} cannot re-condition; it must be refit")

require_available classmethod

require_available()

Raise an actionable ModelError if this model's optional package is not installed.

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def require_available(cls) -> None:
    """Raise an actionable `ModelError` if this model's optional package is not installed."""
    if cls.is_available():
        return
    extra_hint = f"scale-forecasting[{cls.optional_extra}] or " if cls.optional_extra else ""
    raise ModelError(
        f"Model '{cls.name}' requires optional package '{cls.package}' "
        f"(import '{cls.optional_import}'), which is not installed in this environment. "
        f"Install it with `pip install {extra_hint}scale-forecasting[models]` "
        f"or omit '{cls.name}' from the run's models list."
    )

residual_intervals

residual_intervals(yhat, quantiles=DEFAULT_QUANTILES)

Empirical residual-quantile prediction intervals.

For models without native intervals: fit() records residuals via _set_residuals, and this adds their empirical quantiles to the point forecast so bounds are naturally ordered (lower ≤ yhat ≤ upper) for any monotone quantile set. Falls back to a point-mass band (bounds == yhat) if no residuals were recorded.

Source code in src/scale_forecasting/models/base_model.py
def residual_intervals(
    self, yhat: np.ndarray, quantiles: tuple[float, ...] = DEFAULT_QUANTILES
) -> dict[float, np.ndarray]:
    """Empirical residual-quantile prediction intervals.

    For models without native intervals: fit() records residuals via
    `_set_residuals`, and this adds their empirical quantiles to the point
    forecast so bounds are naturally ordered (lower ≤ yhat ≤ upper) for any monotone
    quantile set. Falls back to a point-mass band (bounds == yhat) if no residuals
    were recorded.
    """
    yhat = np.asarray(yhat, dtype=float)
    if self._residuals is None or self._residuals.size == 0:
        return {q: yhat.copy() for q in quantiles}
    return {q: yhat + float(np.quantile(self._residuals, q)) for q in quantiles}

scope_capability classmethod

scope_capability()

Classify this model's cross-series training scope capability.

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def scope_capability(cls) -> Literal["local_only", "multi_scope", "global_only"]:
    """Classify this model's cross-series training scope capability."""
    modes = cls.supported_training_modes()
    if modes == ("global",):
        return "global_only"
    if "global" in modes or "hybrid" in modes:
        return "multi_scope"
    return "local_only"

search_space classmethod

search_space(trial)

HPO search space (optional; used only when hpo.enabled). Default: no search.

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def search_space(cls, trial: optuna.Trial) -> dict[str, Any]:
    """HPO search space (optional; used only when ``hpo.enabled``). Default: no search."""
    return {}

serialize

serialize()

Serialize the fitted model for artifact persistence.

Returns the bytes to store as the cell's model_artifact — the registry writer uploads them under <warehouse>/artifacts/<run_id>/ and stamps the GCS ObjectRef onto the forecast_metadata row (lineage). Default: pickle the instance. Override to use a model-native format (e.g. Booster.save_model) or return None to opt out for models cheap enough to refit. Called only when the run sets persist_models; a None return (or a raised error, which the worker catches) simply means no artifact for that cell — persistence never sinks a forecast.

Source code in src/scale_forecasting/models/base_model.py
def serialize(self) -> bytes | None:
    """Serialize the fitted model for artifact persistence.

    Returns the bytes to store as the cell's ``model_artifact`` — the registry writer
    uploads them under ``<warehouse>/artifacts/<run_id>/`` and stamps the GCS ObjectRef
    onto the ``forecast_metadata`` row (lineage). Default: pickle the instance.
    Override to use a model-native format (e.g. ``Booster.save_model``) or return
    ``None`` to opt out for models cheap enough to refit. Called only when the run sets
    ``persist_models``; a ``None`` return (or a raised error, which the worker catches)
    simply means no artifact for that cell — persistence never sinks a forecast.
    """
    import pickle

    return pickle.dumps(self)

supported_training_modes classmethod

supported_training_modes()

Allowed training_mode values ("local", "global", "hybrid").

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def supported_training_modes(cls) -> tuple[str, ...]:
    """Allowed ``training_mode`` values (``"local"``, ``"global"``, ``"hybrid"``)."""
    if cls.runtime == "vertex_automl":
        return ("global",)
    modes: list[str] = ["local"]
    if cls.supports_global:
        modes.append("global")
    if cls.supports_hybrid:
        modes.append("hybrid")
    return tuple(modes)

unsupported_covariate_tiers classmethod

unsupported_covariate_tiers(features)

Which covariate tiers configured in features this model does not support.

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def unsupported_covariate_tiers(cls, features: Any) -> tuple[str, ...]:
    """Which covariate tiers configured in ``features`` this model does not support."""
    unsupported: list[str] = []
    if (features.future_covariates or features.exog) and not cls.supports_future_covariates:
        unsupported.append("future_covariates")
    if features.past_covariates and not cls.supports_past_covariates:
        unsupported.append("past_covariates")
    if features.static_covariates and not cls.supports_static_covariates:
        unsupported.append("static_covariates")
    return tuple(unsupported)

validate_params classmethod

validate_params(params, *, max_horizon)

Refuse an authored model_params block this model cannot honour. Default: accept all.

Called at plan time, before anything is provisioned, so a parameter combination that would produce a horizon of NaNs costs nothing instead of a fleet-hour. Override when a model has a constraint that is only knowable from its own library's behaviour — the point of the seam is that such knowledge stays in the model's file rather than leaking into the config layer.

max_horizon is the largest horizon this run will ask predict for: the forward horizon and the backtest horizon, whichever is greater. Raise ConfigError, not ModelError — nothing has been fitted, the config is what is wrong.

Source code in src/scale_forecasting/models/base_model.py
@classmethod
def validate_params(cls, params: dict[str, Any], *, max_horizon: int) -> None:
    """Refuse an authored ``model_params`` block this model cannot honour. Default: accept all.

    Called at plan time, before anything is provisioned, so a parameter combination that would
    produce a horizon of NaNs costs nothing instead of a fleet-hour. Override when a model has
    a constraint that is only knowable from its own library's behaviour — the point of the seam
    is that such knowledge stays in the model's file rather than leaking into the config layer.

    ``max_horizon`` is the largest horizon this run will ask ``predict`` for: the forward
    horizon and the backtest horizon, whichever is greater. Raise `ConfigError`, not
    `ModelError` — nothing has been fitted, the config is what is wrong.
    """
    mode = params.get("training_mode")
    if mode is not None:
        from ..errors import ConfigError

        allowed = list(cls.supported_training_modes())
        if mode not in allowed:
            raise ConfigError(
                f"model_params.{cls.name}.training_mode={mode!r} is not supported by "
                f"'{cls.name}'; supported modes: {allowed}."
            )

ModelContext dataclass

ModelContext(
    freq,
    horizon,
    seed=0,
    holidays=None,
    transform="none",
    transform_lambda=None,
    device="auto",
    past_covariates=(),
    static_covariates=None,
)

Per-run context handed to every model so it never reads global config.

register

register(model_cls)

Register a model class under its name. Returns the class so it doubles as a decorator. Raises on a missing or duplicate name.

Source code in src/scale_forecasting/models/base_model.py
def register(model_cls: type[BaseModel]) -> type[BaseModel]:
    """Register a model class under its ``name``. Returns the class so it
    doubles as a decorator. Raises on a missing or duplicate name.
    """
    name = getattr(model_cls, "name", None)
    if not name:
        raise ModelError(f"{model_cls.__name__} must set a class-level 'name' before register()")
    existing = _REGISTRY.get(name)
    if existing is not None and existing is not model_cls:
        raise ModelError(
            f"duplicate model name '{name}': {existing.__name__} vs {model_cls.__name__}"
        )
    _REGISTRY[name] = model_cls
    return model_cls