Run DAG — families → jobs¶
dag
¶
Resolve a run into its execution DAG: one job per model family, plus the ensemble node.
A run's cfg.models spans up to four families — statistical / ml / deep_learning /
native. Each family that is present becomes an independent job under one shared run_id:
the three Python families each run on their own resolved runtime (Spark xor Ray, chosen per
family via config.RunConfig.resolve_family_compute), while native runs in BigQuery. When
ensembling is enabled a downstream ensemble node depends on all of them. This module owns the pure,
offline plan; main.run executes it and launch_plan.plan_run / stage_run emit its commands.
The families run in parallel, so a run's wall-clock is the slowest family's job, not the sum —
adding a BigQuery-native model to a Spark run costs max(spark, bq), not spark + bq. The DAG
is a pure function of the config (no clocks, no GCP), so the same config always plans the same DAG.
DagNode
dataclass
¶
One node in a run's execution DAG — a family job or the ensemble — with its identity, resolved placement, and upstream dependencies.
job_key is the node's canonical cross-system id (registry.ids.make_job_key, attempt 1 —
the planned identity): the one name the platform stamps as its own job id and a trace keys on.
runtime is where it runs (spark / ray / bigquery); hardware / gpu_type /
spark_mode are the resolved compute, None for the native and ensemble nodes
(both run in BigQuery, taking no runtime choice). depends_on lists the job_key of every
node that must finish first: the ensemble depends on all family jobs; a family job depends on
nothing.
FamilyJob
dataclass
¶
One family's job in the run DAG: which models it runs, on what resolved compute.
compute is the fully-resolved per-family compute (config.RunConfig.resolve_family_compute)
for a Python family, or None for native — native models always run in BigQuery and take
no runtime choice. models are the config's models of this family, in config order.
runtime
property
¶
Where this job runs: "bigquery" for native, else the resolved family runtime.
RunDag
dataclass
¶
A run resolved to its DAG: the shared run_id and one job per present family.
jobs holds one FamilyJob per family present in the config, ordered by _FAMILY_ORDER.
ensemble_enabled records whether the downstream ensemble node runs after the base jobs.
Pure product of the config (plan_dag) — no GCP, no clocks.
python_jobs
property
¶
The jobs that run on a family submitter (everything but native).
Asks registry.ids.base_family, so a repair DAG's native_repair job routes to BigQuery
like the family it repairs. Splitting them here — the one place the two launchers are
chosen — is what keeps the repair token an identity concern and nothing more.
check_covariate_support
¶
Enforce features.on_unsupported_covariates across cfg.models (pure).
Source code in src/scale_forecasting/dag.py
check_hardware_coherence
¶
Refuse a plan that would buy a device nothing will route to. Raises errors.ConfigError.
This is expected to be silent, and that is the point. Provisioning and routing were made to
read one function (config.RunConfig.resolve_family_compute, via
engines.ray_io.resolve_job_gpu) precisely so they cannot disagree, and ComputeConfig already
refuses hardware: "gpu" on a family that is not deep_learning. Every incoherence we know
about is therefore closed upstream. What this adds is a standing tripwire: it recomputes the
routing answer down the engine's own path and compares it against the hardware the DAG planned
— two call paths, one expected answer — so the day an edit reintroduces a flat-field read the
plan refuses instead of quietly paying for idle accelerators for a whole fleet-hour. That defect
shipped once and nothing reported it; the cost of the check is a pure function call.
No config field and no escape hatch. Incoherence has exactly one correct answer and the remedy is always a config edit, so an override would only let a run buy hardware it cannot use.
Called from the spend paths only (via preflight) — main.run, launch_plan.stage_run,
airflow_tasks.begin_run — never from plan_dag, which stays total.
Source code in src/scale_forecasting/dag.py
check_model_params
¶
Validate cfg.model_params against the model registry. Raises errors.ConfigError.
Two checks, both of which need the registry loaded and therefore cannot live in config.py
(eager model-stack imports on the submit path have broken a live run before):
- a block keyed by a name no model is registered under is a typo, and a typo here is silent — the params simply never reach a model;
- each selected model gets to refuse a block it cannot honour, through
models.base_model.BaseModel.validate_params. The model is told the longest horizon the run will ask for, which is the forward horizon or the backtest horizon, whichever is larger.
Deliberately not called from plan_dag, which stays total so pure inspection — the SDK's
dag, a dry run, a test — never raises. Called from the paths that are about to spend.
Source code in src/scale_forecasting/dag.py
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 | |
covariate_support_report
¶
Report any selected models that do not support configured covariate tiers (pure).
Source code in src/scale_forecasting/dag.py
dag_nodes
¶
Resolve a run's DAG into its nodes with deterministic ids and dependencies (pure, offline).
One node per family job (RunDag.jobs) carrying its resolved runtime/hardware and its planned
job_key (registry.ids.make_job_key, attempt 1), plus — when ensembling is enabled — a
downstream ensemble node that depends on every family job. This is the offline "given a config,
which jobs will run and under what ids" surface: the same job_key\ s the executor stamps
onto each platform job and its run_jobs row, so a run's cross-system trace can be derived
from the config alone, before anything runs.
A retry node is never one of these, and cannot be. An emitted Airflow DAG may carry one
(airflow_emit.emit_airflow_dag with with_retry), but a repair's identity is not knowable
from the config: which families it touches depends on what failed, and its attempt number is
whatever registry.jobs.next_job_attempt hands out at the time — while every key here is
attempt 1 by construction, because this function is pure and a planned identity has no history
to count. The repair resolves its own job_key inside airflow_tasks.retry_families, at the
moment it has the run in front of it.
Source code in src/scale_forecasting/dag.py
gpu_usefulness_report
¶
Warnings about a device that will be paid for and barely used. Never refuses.
Returns human-readable lines for the caller to log; an empty list means nothing to say. The
separation from check_hardware_coherence is deliberate and is the difference between "this
plan is wrong" and "this plan is probably wasteful": a GPU that no model can use is a mistake
with one correct answer, while a GPU a model can use but will barely touch is a judgement
call about cost, and the only configuration that would satisfy a hard check
(model_params.neuralprophet.n_lags > 0) has never been run at scale here. Refusing on
usefulness would break the shipped smokes and demo configs on the day it landed, in favour of a
setting with no green run behind it. So: warn, loudly, with both remedies and which one is
proven.
Surfaced wherever a plan is shown or submitted — a dry run, the quota preflight, the SDK's
dag, and the submit log.
Source code in src/scale_forecasting/dag.py
400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 | |
group_models_by_family
¶
Group cfg.models by each model's registered family (pure; deterministic order).
Families are returned in _FAMILY_ORDER and models keep their config order within a family.
Unknown model names raise errors.ModelError (via models.get_model) rather than being
silently dropped — the same validation the engines rely on, surfaced once up front.
Source code in src/scale_forecasting/dag.py
narrow_to_models
¶
The same DAG with every job narrowed to models, dropping jobs left with nothing (pure).
This is how a repair submits less than a run: job_launch.submit_retry walks the narrowed DAG
and each surviving job carries a shorter FamilyJob.models, which reaches the driver as
--models (commands.build_driver_args) — the subset seam that already existed for
per-family execution, reused rather than reinvented. Resolved compute rides along untouched, so
a repaired family lands on the same runtime and hardware the original attempt chose.
Each surviving job is re-stamped with its repair family token (registry.ids.repair_family
— statistical becomes statistical_repair), which is how the repair gets a run_jobs
row of its own instead of overwriting the row of the attempt it repairs. v_run_jobs keeps the
highest attempt per (run_id, family), so a forty-cell repair filed under statistical would
become the only statistical row the registry shows and report the whole family COMPLETED.
Nothing downstream has to know: every routing decision asks registry.ids.base_family, so the
job runs on the runtime, hardware, and launcher the original attempt chose.
ensemble_enabled is always False on the result. A repair re-runs base models; whether
the ensemble is recomputed afterwards is a question about the run, answered by the node
ordering in airflow_emit rather than by a narrowed job list, and a DAG that advertised an
ensemble node nobody was going to run would be a lie in the one structure the trace reads from.
Raises errors.ConfigError if models names anything the DAG does not plan — a repair can
only re-ask a question this run already asked, and quietly ignoring the name would submit a
smaller job than the caller believes they asked for.
Source code in src/scale_forecasting/dag.py
plan_dag
¶
Resolve a config into its execution DAG (pure, offline — the single planner main.run uses).
Computes the shared run_id (registry.ids.make_run_id), groups the models by family
(group_models_by_family), and resolves each Python family's compute
(config.RunConfig.resolve_family_compute) — native carries no compute (it runs in
BigQuery). The result is the full set of parallel jobs the run schedules under one run_id,
plus whether the downstream ensemble node runs. No shape is rejected here: every family that has
models gets a job, and per-family compute was already validated at config load.
Source code in src/scale_forecasting/dag.py
planned_families
¶
Every family this config expects to see a run_jobs row for, in DAG order (pure).
group_models_by_family answers "which families have models", which is the same question right
up until ensembling is on: the ensemble node writes a job row of its own under the family
ensemble, and a reader comparing rows against a plan has to know to expect it. That reader
is registry.ops.roll_up_against_plan, which closes an abandoned header — and the whole reason
it needs a plan is that a family which never submitted leaves no row to notice its absence by.
Repair families are deliberately absent. A repair only exists because a first pass left cells missing, so it is never planned — it is a row that may or may not appear, and treating it as expected work would make every un-repaired run look incomplete.
Source code in src/scale_forecasting/dag.py
preflight
¶
Everything a run is refused or warned about before it provisions anything. Returns the DAG.
One call so the spend paths cannot drift apart on which checks they run: an authored
model_params block no model can honour (check_model_params), unsupported covariate tiers
(check_covariate_support / covariate_support_report), a plan that would buy a device nothing
routes to (check_hardware_coherence), and — logged, never fatal — a device that will be billed
and barely used (gpu_usefulness_report).
Separate from plan_dag on purpose. Planning is pure inspection and must stay total: the SDK's
dag, a notebook, and several hundred tests call it and none of them are spending anything.
Refusing is a different act, so it is a different function, and only main.run,
launch_plan.stage_run and airflow_tasks.begin_run perform it.