Output schemas — the registry tables¶
Every run writes to the same registry tables plus a backtest table, and reads back through five curated views. This page documents the layout of each — what every column collects and how the tiers link — so you can query the results directly, not just through the notebooks.
Two facts hold for all of them:
- Always native BigQuery. The five run-collection tables are native (never Iceberg), so
raw_config/job_telemetry/quantiles/best_params/fit_diagnostics/explanationsare the realJSONcolumn type and a reseed is a cleanWRITE_TRUNCATE. (The input table is the one that ships in both Iceberg and native — see configuration_reference.md.) - Written via the Storage Write API. Engines return data, not RPCs; results are streamed into
these tables in bulk through the BigQuery Storage Write API for high-speed updates, so
throughput is bounded by compute, not a tracking server's QPS. Writes are append-only, and the
views dedupe on read (idempotency) — re-running the same
run_idnever corrupts a table, it just appends rows the views collapse back to one.
The schema below is rendered from a single source of truth,
src/scale_forecasting/registry/ddl.py; the views from
src/scale_forecasting/registry/views.py.
registry.tables.ensure_tables / ensure_views execute exactly what those render.
How the tiers link¶
flowchart LR
subgraph Tables["5 Native Registry Tables (Append-Only via Storage Write API)"]
RR["run_registry<br/>(1 row per run_id)"]
RJ["run_jobs<br/>(1 row per run_id × family)"]
FM["forecast_metadata<br/>(1 row per run_id × ts_id × model_type)"]
FP["forecast_predictions<br/>(N rows per run_id × ts_id × model_type × forecast_date)"]
BO["backtest_oof<br/>(N rows per run_id × ts_id × model_type × fold_id × forecast_date)"]
RR -->|"run_id"| RJ & FM & FP & BO
end
subgraph Views["5 Curated Serving Views (Dedupe-on-Read)"]
VRS["v_run_summary"]
VRJ["v_run_jobs"]
VML["v_model_leaderboard"]
VBC["v_backtest_coverage"]
VMC["v_model_leaderboard_comparable"]
end
RR --> VRS
RJ --> VRJ
FM --> VML & VBC
BO --> VMC
Everything joins on run_id (the config digest — see
configuration_reference.md); the per-series tiers additionally share
ts_id and model_type.
run_registry — one row per run¶
The config is the experiment record. This tier stores the whole config verbatim plus how the run
went. Partitioned by DATE(created_at), clustered by run_id.
| Column | Type | Collects |
|---|---|---|
run_id |
STRING |
The run identity — a digest of the full config (make_run_id). Same config → same id (idempotent). |
created_at |
TIMESTAMP |
When the header row was written. |
snapshot_millis |
INT64 |
The input-data snapshot every job in the run pins its source read to — one epoch-millis instant off the BigQuery clock (registry.header.resolve_snapshot_millis), so a Spark batch, a Ray job and the native models all read the identical source state. NULL on a run that read unpinned. |
user_id |
STRING |
Who/what launched the run (identity of the writer). |
git_sha |
STRING |
The code revision that produced the run (lineage). |
python_runtime |
STRING |
spark, ray, vertex, gce, gke, or vertex_automl — the run-level default runtime for the Python/AutoML model families (a family can override it). |
bq_models |
ARRAY<STRING> |
Which BigQuery-native models ran in parallel (e.g. arima_plus). |
backtest_on |
BOOL |
Whether backtesting was enabled for the run. |
decision_metric |
STRING |
The metric folds were judged on (when backtesting). |
ensemble_strategies |
ARRAY<STRING> |
Which consensus strategies ran (e.g. median, nnls). |
raw_config |
JSON |
The entire validated config, verbatim — the experiment record. |
status |
STRING |
RUNNING → COMPLETED / FAILED. A run that was staged rather than launched opens at STAGED instead — its artifacts are up and its job ids are handed out, but no compute exists yet (why). |
n_series |
INT64 |
Series count actually run. |
n_models |
INT64 |
Model count actually run. |
runtime_seconds |
FLOAT64 |
The header owner's wall-clock. Under main.run (CLI, SDK, Batch container) that is the whole run from header write to finalize — provisioning, every family, the ensemble; under Composer it is the slowest job's wall (airflow_tasks.finalize_run); an engine driven directly in owner mode records its own wall. Compare runs through v_run_summary's job ledger, which means the same thing on every path. |
job_telemetry |
JSON |
Run-level overlay: sizing.<family> (the whole sizing decision per family job), capacity (the shared-cluster attempt ledgers), and the platform echo of whichever family jobs stamped one, merged by key with the last to finish winning — a Dataproc Serverless batch's executor shape, dcu_milli_seconds, runtime_version and total_wall_s; a Ray job's pool sizes and elastic bounds. Unpacked by v_run_summary. |
run_jobs — one row per family job¶
A run resolves into one job per model family (statistical / ml / deep_learning / automl / native),
plus the downstream ensemble node — each launched in parallel under the shared run_id. This tier
records what each of those jobs actually ran on and how it fared, so a run's DAG is queryable as
executed. A --force re-run appends a higher-attempt job under the same (run_id, family).
Partitioned by DATE(created_at), clustered by run_id, family.
| Column | Type | Collects |
|---|---|---|
job_id |
STRING |
The canonical per-family job key (make_job_key) — sf-<run_id>-<family>-a<attempt>. |
run_id |
STRING |
Joins to run_registry. |
family |
STRING |
The model family this job ran (statistical / ml / deep_learning / automl / native, or ensemble). |
attempt |
INT64 |
Attempt number — a --force re-run bumps it so re-runs are distinctly keyed under one run_id. |
runtime |
STRING |
The resolved runtime for this family (spark / ray / vertex / gce / gke / vertex_automl / bigquery). |
spark_mode |
STRING |
The resolved Spark launch mode when runtime=spark (else NULL). |
hardware |
STRING |
The resolved hardware profile for this family (else NULL). |
gpu_type |
STRING |
The GPU type when the family ran on GPUs (e.g. Ray deep-learning), else NULL. |
system_job_id |
STRING |
The platform's own job id (dataproc_job_id / ray_submission_id / vertex_custom_job_id / gce_instance_name / gke_job_name / vertex_pipeline_job_id / bigquery_job_id) — jump straight to the platform console. |
status |
STRING |
RUNNING → COMPLETED / FAILED for this job. Two pre-launch statuses can come first: AWAITING_CAPACITY while a family walks regions looking for machines, and EMITTED for a job id a staged command handed out but this process never launched (why). |
created_at |
TIMESTAMP |
When the job row was written. |
started_at, ended_at |
TIMESTAMP |
The job's wall-clock bracket: started_at is stamped when the launcher writes the row and starts the job, ended_at by lifecycle.run_job at exit. Every term of v_run_summary's time ledger is measured on this bracket. |
runtime_seconds |
FLOAT64 |
The launcher's wall around this job, submit → terminal, cluster stand-up included (lifecycle.run_job's monotonic clock). It differs from the bracket only by the row-write latency. |
failure_reason |
STRING |
Why a FAILED row failed, as a token: CAPACITY_EXHAUSTED when a family ran out of regions to try (capacity), JOB_ID_TAKEN when the platform already held the job id, LAUNCHER_EXCEPTION for anything else that raised — with its type and message under job_telemetry.$.failure. NULL on a job that ended well. |
job_telemetry |
JSON |
Per-job overlay: the family's own capacity region walk, the platform handle (vertex_custom_job / gce_instance / gke_job), platform (provision_s / execute_s / total_wall_s — the platform's own provision→execute split of the job, from one probe read once it is terminal; Dataproc Serverless and Vertex CustomJob families today), cells, settle, retry, cancel, failure, and — for a GPU family — device_use, the verdict on whether the accelerator did anything (below). The platform's DCU and cluster-shape figures are not here; they land on the header. Unpacked by v_run_jobs. |
forecast_metadata — one row per (run, series, model) cell¶
The metrics tier: how each model did on each series, and where its persisted artifact lives.
Partitioned by DATE(created_at), clustered by run_id, model_type.
| Column | Type | Collects |
|---|---|---|
run_id |
STRING |
Joins to run_registry. |
ts_id |
STRING |
The series. |
model_type |
STRING |
The model (e.g. theta, arima_plus, vertex_tide). |
compute_engine |
STRING |
Where the cell ran (spark / ray / vertex / gce / gke / vertex_automl / bigquery). |
model_hash |
STRING |
Content hash of the fitted model (lineage / cache key). |
ensemble_id |
STRING |
NULL for base models; the EnsembleConfig digest for ensemble pseudo-models (so two ensemble configs under one run_id stay distinct). |
fold_id |
INT64 |
NULL for the final (full-fit) row; set for a backtest fold's metrics. |
mae, rmse, mse, mape, smape, wape, mase, rmsse, bias, coverage, pinball, mase_seasonal, maape, interval_score, interval_width, ope, rmsle, msse, msis, r2, cv |
FLOAT64 |
The metric panel — every metric, every run, so choosing a different decision_metric never means re-running. Populated only when a backtest produced out-of-fold predictions to score; otherwise NULL. Definitions in metrics_reference.md and configuration_reference.md. These twenty-one are what ships, not the schema's limit: the column list is generated from the metric registry, so a deployment that adds a metric file gets its column here and the ADD COLUMN IF NOT EXISTS to back-fill it — see adding_a_metric.md. |
fit_seconds |
FLOAT64 |
Wall-clock to fit this cell (per-cell fit time — surfaces the straggler cells). |
best_params |
JSON |
Winning hyperparameters when HPO ran (else NULL). |
model_artifact |
STRING |
GCS ObjectRef to the persisted model (persist_models=true), else NULL. no_artifact_rate=1.0 in the leaderboard = no cell produced an artifact. |
created_at |
TIMESTAMP |
When the row was written. |
worker_id |
STRING |
hostname:pid of the worker that ran the cell — the trace's lane. |
cell_started_at, cell_ended_at |
TIMESTAMP |
The cell's wall-clock bracket (Gantt/waterfall). |
cpu_seconds |
FLOAT64 |
CPU time the fit consumed, summed across threads. With fit_seconds this gives effective_cores — how much parallelism the library actually used. |
process_rss_bytes |
INT64 |
The worker process's absolute memory high-water while the cell ran — not the cell's increment. This is the number that sizes an executor slot. |
peak_gpu_bytes |
INT64 |
Peak device bytes allocated, never zero. NULL is ambiguous on its own — it covers no tensor library, a CPU-only build, no device, and measure="off" alike. Read it beside device_used, which says which. |
intraop_threads |
INT64 |
The native-thread cap in force (OMP_NUM_THREADS). Without it cpu_seconds / fit_seconds is uninterpretable — under a cap the ratio just reports the cap back. |
n_obs |
INT64 |
Rows fed to the fit — the data signature a later run matches against. |
device_requested |
STRING |
What the cell was told to use: auto / cpu / gpu. Set from the job's provisioned hardware and the family's hardware, not from config intent. |
device_available |
STRING |
What the worker could actually see: cuda / cpu / unknown. unknown means no tensor library was importable, so nobody asked — a different fact from "there is no card". |
device_used |
STRING |
Where the fitted weights actually landed, read off a parameter tensor: cuda / cpu. NULL means the model has no device concept (everything but NeuralProphet). |
device_name |
STRING |
The visible device, e.g. Tesla T4. NULL when none is. |
cell_status |
STRING |
How the cell went: ok or error. |
error_class |
STRING |
Which kind of failure it was, from a fixed vocabulary you can GROUP BY (see below). NULL on an ok cell. |
error_detail |
STRING |
The exception itself, as text, truncated at 2,000 characters. NULL on an ok cell. |
interval_source |
STRING |
Where the prediction interval came from as the model produced it: native (the model computed its own) or residual (built from the spread of its in-sample residuals). NULL on ensemble rows, which do not carry an interval. |
point_forecast_source |
STRING |
Which arm the shipped yhat is: raw (the model's own number) or median / mean (that number plus the corresponding residual shift). |
point_forecast_decision |
STRING |
How that arm was picked, which the column above cannot say on its own: configured (the config named it), auto-corrected / auto-raw (output.point_forecast: "auto", and the cell's held-out folds went that way), auto-few-folds / auto-no-backtest (selection was asked for but there was not enough held out to decide, so the fleetwide arm applied), or engine-native (BigQuery-native rows, where only one arm exists). |
interval_calibration |
STRING |
What happened to the band after the model produced it: oof-per-step (re-estimated per horizon step from out-of-fold residuals), oof-flat (one pooled out-of-fold band, too few residuals to resolve per step), in-sample (no backtest ran; the model's own band shipped unchanged), or native (BigQuery-native rows). |
point_forecast_margin |
FLOAT64 |
How much the corrected arm beat the raw one by on this cell, as a fraction of the raw arm's loss in the run's decision_metric. Positive means the correction helped. NULL when there was no backtest to grade it on. |
backtest_status |
STRING |
How the scoring went, which is not how the cell went: full / reduced / unscored / failed. reduced covers any geometry other than the one the config asked for, not just a shorter fold list. NULL means backtesting was never asked for. |
n_folds_achieved |
INT64 |
Folds actually scored (0 on unscored/failed). |
backtest_note |
STRING |
Why the backtest was not full — the shortfall arithmetic, or the short_series policy and what it traded away, or the exception. NULL when it was. |
n_fits |
INT64 |
Fits behind the published forecast: the backtest arms plus the final full-history fit. Measured, not derived — see What the cell paid for for why n_folds_achieved + 1 is wrong on four of six refit paths. |
train_rows_total |
INT64 |
Training observations those fits saw, summed. Not n_fits × n_obs: a fold trains on a prefix. |
n_hpo_fits |
INT64 |
Fits a per-series hyperparameter search burned. None of them ship a forecast, so they are counted apart; total paid for is n_fits + n_hpo_fits. 0 under a fleetwide search, which runs on the driver and belongs to no row. |
fit_diagnostics |
JSON |
Whatever the fitting library said about this fit — plus the orthogonal model taxonomy and lineage keys (base_model, variant_tag, scope_capability, training_mode, cov_profile, is_univariate, covariates_used, model_spec_hash, and borrowed_from_run_id when borrowed via compute.reuse_prior_runs), Tier 1 (feature_attributions) normalized global/series-level feature importance ({"feature_attributions": {"promo_flag": 0.41, ...}}) populated by ml models (xgboost, lightgbm, catboost, random_forest, regression_lags) and automl models (vertex_l2l, vertex_tide, vertex_tft, vertex_seq2seq), and stage_1_tuning_result_artifact_uri / vertex_model_resource_name on vertex_automl. |
Why a cell failed, in a word you can group by¶
A run of a million cells does not fail; some fraction of it does, and the only useful question is
of what. Three thousand rows of error_detail will not answer that — every message is phrased by
whatever library raised it, so counting them counts wording. error_class is the same failure
reduced to one token from a fixed list, which is what makes GROUP BY error_class a report:
error_class |
What it means | Whose problem it is |
|---|---|---|
OOM |
The worker ran out of memory. | Sizing — a bigger slot or fewer cells per slot. |
CAPACITY |
The platform had no room: quota, stockout, a preflight refusal. | Capacity — retry elsewhere or later. |
TRANSIENT_INFRA |
A 5xx, a reset connection, a deadline. | Nobody's — the same cell would likely succeed on a retry. |
SHORT_HISTORY |
The series did not have enough observations. | Ours. A series short enough to defeat a model should have been detected before the fit, not during it. |
CONFIG_REPAIRABLE |
The config asked for something that does not exist, e.g. an unregistered model name. | The config — and it is fixable without touching data or code. |
BAD_DATA |
Duplicate timestamps, a gap, a NaN, a value off the declared frequency. | The source table. |
MODEL_ERROR |
The fit itself failed: no convergence, a singular matrix. | The model/series pairing. |
UNKNOWN |
Nothing in the table matched. | Read error_detail. |
UNKNOWN is a real answer rather than a gap in the table. A classifier that guesses is worse than
one that admits it does not know: a wrong token sends the reader to the wrong fix, and an honest
UNKNOWN with the text beside it sends them to the message. If a class of failure keeps arriving as
UNKNOWN, that is the signal to add a row — not to widen an existing one.
Classification happens in the worker, at the moment the exception is caught, because that is the
last point at which the exception object exists; by the time the row reaches BigQuery it is a
string, and the exception's type is the strongest evidence the table has. The vocabulary lives in
worker.ERROR_CLASSES, the one module both Python runtimes share, so a Spark cell and a Ray cell
classify the same failure identically.
Native (BigQuery) and ensemble rows carry cell_status = 'ok' with both error columns NULL. A row
only exists on those paths because the model was built and forecast — a native failure takes the
whole job down and writes nothing — so there is never a native error row to describe. They are
filled rather than left NULL so that WHERE cell_status = 'ok' does not quietly skip every native
and ensemble model in the run.
Where the prediction interval came from¶
Every model in the suite emits yhat_lower / yhat_upper, but they do not all mean the same thing,
and the two columns look identical either way. Some models compute an interval as part of their own
arithmetic — Theta, SARIMAX, Prophet, UCM, STL-bagging, NeuralProphet, and the BigQuery-native
models all do. The rest have no notion of uncertainty at all, so BaseModel.residual_intervals
manufactures a band for them from the spread of their in-sample residuals.
A residual band is a reasonable fallback and a poor measurement. It is fitted on data the model has
already seen, so it is optimistic; and it is one constant width applied to the whole horizon, so it
cannot widen as the forecast gets further from what the model knows. Comparing its coverage or
interval_score against a model that computed its own interval is not a like-for-like comparison,
and until this column existed there was nothing in the row to tell you which kind you were looking
at. WHERE interval_source = 'native' makes that comparison honest.
BigQuery-native rows say native because ML.FORECAST returns real bounds. Ensemble rows leave it
NULL: combining base-model point forecasts produces no interval, so there is no provenance to
record.
What happened to the band afterwards¶
interval_source describes what the model handed over. interval_calibration describes what the
system did with it, and the two are independent — when a backtest ran, the band that ships is
re-estimated from out-of-fold residuals regardless of which kind the model started with.
That re-estimation is the fix for both weaknesses above at once. The residuals come from held-out
folds rather than from data the model has already seen, so the band is no longer optimistic by
construction; and they are bucketed by horizon_step, so the band is allowed to widen with distance
instead of being one width for the whole horizon.
Measured on ten models × 24 series, scored leave-one-fold-out so nothing is graded on the residuals
it was fitted from: the in-sample band achieved 0.601 coverage against a nominal 0.8, the
recalibrated band 0.790. The average is the smaller half of the result. Per-model coverage ran
from 0.053 to 0.837 before and from 0.755 to 0.821 after — so a column that meant something
different for every model now means the same thing across the leaderboard, which is what makes
coverage usable as a decision_metric at all.
interval_calibration |
What you are looking at |
|---|---|
oof-per-step |
Re-estimated per horizon step from out-of-fold residuals. The band widens with distance because the measured error does. |
oof-flat |
Out-of-fold, but too few residuals to resolve per step, so one pooled band covers the horizon. Honest about being flat rather than dressed up as per-step. |
in-sample |
No backtest ran, so the model's own band shipped untouched — whatever interval_source says it was. |
native |
A BigQuery-native row. ML.FORECAST computes its own bounds and nothing downstream rewrites them. |
Per-step estimation needs enough residuals per step to be worth the name; below that floor the
window over neighbouring steps widens until it has them, and if it swallows the whole horizon the
row says oof-flat rather than claiming a resolution it does not have.
Which number yhat is¶
Every model emits one number per future date, and something has to decide what that number is. Three columns record the decision rather than leaving it implicit:
yhat_raw— the model's own output, untouched.yhat_adjusted— that output plus a residual shift, either the median (minimises absolute error) or the mean (minimises squared error, and drivesbiasto zero by construction).yhat— whichever of the two the run shipped, chosen byoutput.point_forecast— either once for the whole run, or per series and model when that is set toauto.
Both arms are always written, so the choice is never destructive: a run that shipped the corrected
arm can still be scored on the raw one months later without re-fitting anything.
forecast_metadata.point_forecast_source says which arm yhat is,
point_forecast_decision says how that arm was picked, and point_forecast_margin says
what the choice was worth on that cell. The margin is always measured corrected-minus-raw whichever
arm shipped, so averaging it over cells that chose differently still means something. sf.calibration_report(run_id) rolls both up per model
alongside the coverage panel — the win rate matters more than the average margin, because a
correction that helps half the series a lot and hurts the other half a lot is a different
proposition from one that helps everything a little.
On BigQuery-native rows all three columns hold the same number: ML.FORECAST returns one forecast,
there is no second arm, and writing it three times keeps y_true - yhat_raw a valid residual on
every engine so a cross-engine query needs no special case.
A series too short to score still has a forecast¶
Backtesting scores a model; it does not produce the forecast. Those two steps are decoupled so a
series shorter than min_train + horizon + (n_folds−1)·step never loses its forecast.
The fold grid adapts to what the series supports — by default the oldest folds are dropped,
survivors keeping their original fold_id — and the cell fits and forecasts either way. What the
grid does when a series is short is backtest.short_series's to decide; the outcome is recorded rather
than inferred, because through the metric columns alone these are indistinguishable:
| Situation | Metrics | backtest_status |
|---|---|---|
| Backtesting switched off | all NULL | NULL |
| Scored on exactly the grid the config asked for | populated | full |
Scored on an adjusted grid — fewer folds, a narrowed step, or a shrunken min_train |
populated | reduced |
| Too short to score at all, or the policy declined to score it | all NULL | unscored |
| Scoring raised | all NULL | failed |
reduced is the broad one: it means "not the geometry the config asked for", whichever way the
geometry moved. Under short_series: "overlap" or "shrink_train" that produces a pair which is
otherwise impossible — reduced with n_folds_achieved equal to the requested n_folds — and
that pair is precisely the signal that the full fold count was bought with fold independence or
with training history rather than with folds. backtest_note says which.
n_folds_achieved is what makes a leaderboard readable across a ragged panel: two series with the
same WAPE are not comparable if one was scored on five folds and the other on one.
Base-model rows fill these whichever engine wrote them — a Python cell and a BigQuery-native cell
answer the same question the same way. Ensemble rows are the exception: they leave all three
NULL even when the ensemble was scored, because an ensemble is scored on the base models' folds
rather than on folds of its own. That gets its own column, ensemble_scoring, described next.
Was this number earned on a fold the fit had already seen?¶
Two columns answer that, and they exist because a metric column cannot answer it by itself. An
in-sample number and an honest one look identical once they are sitting side by side under
wape.
The rule the run enforces is one sentence: the newest fold — the one with the smallest step-back,
n_folds - 1 — is never used to fit anything. Not stacker weights, not inverse_error weights,
not a hyperparameter search. Everything that learns from the backtest learns from the older
inner folds and is then judged on the newest one. Scoring is unaffected; every fold is still
scored. Only fitting is restricted.
| Column | On which rows | Values |
|---|---|---|
ensemble_scoring |
ensemble_* rows |
holdout, in_sample, or NULL for mean/median, which fit nothing |
hpo_scoring |
base-model rows whose params came from a search | holdout, in_sample, or NULL when no search ran |
in_sample is not an error. There are two ordinary ways the split fails to exist: the run asked
for n_folds: 1, or a short series in a ragged panel achieved only the newest fold. In both cases
the fit falls back to using everything — refusing would cost the run a whole ensemble over a fold
count — and the column records that it did. That is the entire point: the fallback is said, not
inferred.
The run header carries the same claim once for the whole run, on
run_registry.job_telemetry.$.scoring: hpo is off, holdout, or in_sample from the run's
fold geometry, and ensemble is the weakest answer across the strategies that fitted anything
(one in_sample strategy makes the run's ensemble metrics partly in-sample, and a header that
reported the best case would be exactly the reassurance these columns exist to withhold).
Was the accelerator you paid for actually used?¶
Four columns record device placement and utilisation so you never have to guess whether a GPU job
actually used its card. device_requested is the intent,
device_available is the environment, and device_used is the receipt — the three disagree exactly
when something is wrong.
Each family job also gets a one-line verdict on run_jobs.job_telemetry.device_use, drawn from
these columns on the driver once the job's cells are written:
| Verdict | Means |
|---|---|
MISSING_DEVICE |
The family asked for a device and no cell reports having run on one. The run is correct and the accelerator was billed for nothing. |
ENGAGED_IDLE |
Cells ran on the device and barely touched it. A cost finding, not a fault — this is the expected verdict today (NeuralProphet peaks at 50–78 KB on a 17 GB T4 unless n_lags > 0). |
ENGAGED_UTILISED |
Cells ran on the device and used at least 1% of it. |
| absent | The family never asked for a device, so there was nothing to judge. |
The verdict warns and never fails a job. It is filed with the counts it was drawn from
(cells, cells_on_device, cells_no_device, max_peak_gpu_bytes, device_name) so it can be
re-checked later rather than taken on trust.
Two places read it back. v_run_jobs.device_verdict is the word as a column, so a fleet-wide "which
jobs wasted their card" is one WHERE clause; and review.monitor_run carries it on each
FamilyProgress, so plot_progress prints gpu used / gpu idle / no gpu at the end of that
family's bar.
What the cell paid for, in fits¶
Three columns, and the reason there are three is that "how many fits did this cost" turns out to be two different questions.
| Column | Counts |
|---|---|
n_fits |
Every .fit() behind the published forecast — the backtest arms plus the final full-history fit. |
train_rows_total |
The training observations those fits saw, summed. |
n_hpo_fits |
What a per-series hyperparameter search burned. Those fits ship nothing. |
Total paid for is n_fits + n_hpo_fits. They are kept apart so that fit_seconds / n_fits stays a
cost-per-shipped-fit — a search that runs inside the cell is already inside fit_seconds, and
folding its fits into n_fits would make the ratio mean nothing. A fleetwide search contributes
nothing to either column: it runs once on the driver before any cell exists, so its cost belongs to
the run rather than to any one row.
n_fits is measured, not derived, and that is the whole point. The plan-time estimate
(Workload.n_fits, what plan_run prints) assumes a fresh fit per fold. Only two of the six refit
paths actually work that way:
| Scheme | Three folds cost | The estimate says |
|---|---|---|
expanding / sliding |
3 fits + 1 final | 4 — agrees |
…with control_arm: true |
4 + 1 | 4 — one low |
expanding_stale |
1 + 1 | 4 — two high |
expanding_frozen (model can recondition) |
2 + 1 | 4 — one high |
expanding_frozen (model cannot) |
4 + 1 | 4 — one low |
So the difference between the plan and the column is not an error in either: it is what the scheme
cost or saved, and a run that records both can be asked that question directly. Note that
n_folds_achieved + 1 is only equal to n_fits on the first row of that table; using it on a
frozen scheme overstates the fit count.
An error cell reports these too, with 0 meaning the cell died before fitting anything. A cell that
burned forty fits and then failed is an expensive failure, and without the column it looks exactly
like a cheap one.
What the model said about its own fit & Two-Tier Explainability¶
fit_diagnostics is a nullable JSON bag, beside best_params, holding whatever the fitting library
reports about this fit: Tier 1 (feature_attributions) normalized global/series-level driver importance ({"feature_attributions": {feature_name: float}} populated by xgboost, lightgbm, catboost, random_forest, regression_lags, and all 4 vertex_* AutoML models), plus stage_1_tuning_result_artifact_uri and vertex_model_resource_name on vertex_automl.
It is deliberately not in the metric panel. 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 every
model and a leaderboard can rank on it. A diagnostic is whatever a 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. A leaderboard column built on one would look uniform and not be.
The seam is BaseModel.diagnostics() (and BaseModel.feature_attributions()), which defaults to {}. Two guards apply at the worker boundary rather
than being left to model authors: a key named after a metric is dropped (with a warning naming it),
and so is a value json.dumps cannot carry — the Storage Write API rejects a whole append on one
bad row, which in a Spark or Ray worker kills the task and cascades to the run.
Columns that exist but are not filled yet¶
SELECT * on this table also returns
achieved_step, achieved_min_train, first_val_date, and last_val_date. They are all NULL
today. They are
declared ahead of the code that writes them because adding a column to a deployed table is a
migration every deployment has to run, and doing that once is better than doing it five times.
Don't build a reader on them yet — NULL here means "not recorded", not "no".
Sizing a future run from a past one¶
The last five columns are the compute harvest. Every cell records what it cost, on the
hardware it really ran on, so a completed run_id doubles as a measured cost model: point
profiling.cost.harvest_profile at those rows and it aggregates them into the same ComputeProfile the
deliberate pre-pass produces, which the fleet translators then size from. Nothing extra is stored
and nothing extra is versioned — the profile is a query over a run.
Harvest is on by default (compute.profile.measure, see
configuration_reference.md); it costs three cheap probes per fit.
All five read NULL when it is off — which is also how rows written before these columns existed read
back, so both mean "no evidence" rather than "zero". A cell that errored never reached the probes,
so its cpu_seconds is NULL and the harvest reader skips it on that alone; cell_status = 'error'
is the readable version of the same fact, and the two must not be allowed to disagree — an error
row counted into a cost model would size slots off fits that never happened.
Consumption is on by default too: compute.profile.source = "auto" makes the next run look for the
newest harvest matching its data signature and size itself from it, stamping the run_id it chose
into the staged config so the choice is on the record. Set source to a specific run_id to pin
one, or to "none" to ignore the harvest entirely.
forecast_predictions — the forecast values¶
The values tier: one row per (run, series, model, date) over the horizon. Partitioned by
forecast_date, clustered by run_id, ts_id.
| Column | Type | Collects |
|---|---|---|
run_id |
STRING |
Joins to run_registry. |
ts_id |
STRING |
The series. |
model_type |
STRING |
The model that produced this point (base model name or ensemble_<strategy>). |
compute_engine |
STRING |
Where it ran (spark / ray / vertex / gce / gke / vertex_automl / bigquery). |
ensemble_id |
STRING |
NULL for base models; the ensemble digest for ensemble rows. |
forecast_date |
DATE |
The future date this point forecasts (partition key). |
yhat |
FLOAT64 |
The point forecast that shipped — whichever arm output.point_forecast selected. |
yhat_raw |
FLOAT64 |
The model's own output, before any residual correction. |
yhat_adjusted |
FLOAT64 |
The corrected arm: yhat_raw plus the residual shift. Equal to yhat_raw on BigQuery-native rows, which have only one arm. |
yhat_lower |
FLOAT64 |
Lower prediction-interval bound. |
yhat_upper |
FLOAT64 |
Upper prediction-interval bound. |
quantiles |
JSON |
Full quantile forecast when a model emits one (e.g. {"0.1": ..., "0.9": ...}), else NULL. |
explanations |
JSON |
Tier 2 per-horizon-step local feature attributions ({"baseline_score": float, "attributions": {feature_name: float}}) populated by Vertex AI AutoML models (vertex_l2l, vertex_tide, vertex_tft, vertex_seq2seq when generate_explanation=true) and Python ML models (xgboost, lightgbm, catboost via native C++ TreeSHAP; regression_lags via exact linear attribution; random_forest via importance-weighted deviation), else NULL. Read and plotted via Forecaster.attributions_df() and Forecaster.plot_attributions(). |
created_at |
TIMESTAMP |
Declared, not yet written — NULL today. Reserved for telling two generations of rows apart under one run_id; see the note under forecast_metadata. |
backtest_oof — out-of-fold predictions (learned ensembling)¶
The evidence learned ensembles (nnls/ridge/xgb) train on: the held-out truth vs. each base
model's prediction, per fold. Written only when backtest.enabled. Partitioned by forecast_date,
clustered by run_id, ts_id.
| Column | Type | Collects |
|---|---|---|
run_id |
STRING |
Joins to run_registry. |
ts_id |
STRING |
The series. |
model_type |
STRING |
The base model whose OOF prediction this is. |
fold_id |
INT64 |
Which backtest fold. |
forecast_date |
DATE |
The held-out date. |
y_true |
FLOAT64 |
The actual value (held out of training that fold). |
yhat |
FLOAT64 |
The base model's prediction for it, on the arm the run shipped. |
yhat_raw |
FLOAT64 |
The model's own output for that date, uncorrected. This is the column the calibration reads: y_true - yhat_raw is the residual that both the point-forecast shift and the per-step band are estimated from. |
yhat_adjusted |
FLOAT64 |
The corrected arm for that date. |
yhat_lower |
FLOAT64 |
Lower bound of the prediction interval for that held-out date. |
yhat_upper |
FLOAT64 |
Upper bound of the same interval. |
cutoff_date |
DATE |
The last date the model was allowed to see when it made this prediction — the fold's origin, and the column that identifies a fold across engines. |
horizon_step |
INT64 |
How far ahead of cutoff_date this row is, counting from 1. |
created_at |
TIMESTAMP |
Declared, not yet written — NULL today. The write timestamp; same reasoning as the note under forecast_metadata. |
ensemble_id |
STRING |
NULL on a base model's row; the EnsembleConfig digest on a blended row. Two ensemble configs scored under one run_id stay distinct instead of overwriting each other. |
Ensembles land here too¶
An ensemble is a model like any other, so it writes its blended out-of-fold predictions back into
this same table — one row per (ts_id, fold_id, forecast_date) under model_type =
'ensemble_<strategy>', with ensemble_id set. Only the blended columns are filled: y_true,
yhat, cutoff_date and horizon_step carry through from the base rows the blend was computed
from, and yhat_raw / yhat_adjusted / the two bounds stay NULL, because a weighted combination of
corrected predictions has no separate "uncorrected arm" to report.
The reason this matters is comparability. v_model_leaderboard_comparable pools error over rows of
this table, so writing blended OOF rows here puts base models and ensembles on one fold, one
pooling rule, and one column.
Two consequences worth knowing. The reads that feed the ensembler filter to the run's base models
(model_type IN (…)), so a re-ensemble or a microbatch drain never blends a previous consensus back
into the next one. And every per-horizon query — anything grouping by horizon_step — now sees
ensembles alongside the models they were built from.
Error by how far ahead you asked¶
horizon_step exists so that "how fast does this model decay?" is a GROUP BY, not a second run.
A model that is excellent one day out and useless four weeks out has the same wape in the
leaderboard as one that is mediocre throughout, because the leaderboard averages the whole horizon.
Grouping the OOF rows by horizon_step separates them.
It is also the column the interval calibration is built on. A band estimated once for the whole
horizon is the same width at every step — as wide one day out as twenty-eight days out. Real
uncertainty grows with distance, so such a band is too wide early and too narrow late, and the late
under-coverage is invisible in a single averaged coverage number. Bucketing the out-of-fold
residuals by horizon_step is what lets the shipped band widen with distance instead; a run whose
interval_calibration says oof-per-step has had that done to it. Where it says in-sample or
oof-flat, the flat-band weakness is still there, and grouping by horizon_step shows it
immediately.
Which fold is this, really¶
fold_id is an ordinal within one series' own backtest plan, and the two engines number from
different anchors. The Python engines count folds back from each series' own last observation;
the BigQuery-native path counts back from one global MAX(ds) across the whole panel. On a
panel where every series ends on the same date those agree exactly. On a ragged panel they do not,
and the disagreement is silent — the same fold_id names a different training window on each
engine, so a join on the ordinal pairs nothing and an ensemble quietly blends fewer models than it
claims to.
cutoff_date is the fix and the reason both engines now write it: two rows describe comparable
forecasts when the models saw the same history and predicted the same date, which is what the
cutoff says and the ordinal does not. The ensemble joins on (ts_id, cutoff_date, forecast_date).
fold_id is still written — it is the ordinal a reader recognises, and it is what
n_folds_achieved counts — it is simply not what anything joins on.
The read surface — five views¶
You rarely query the raw tables. Five CREATE OR REPLACE VIEWs are the curated read surface (and they
apply the dedupe-on-read). Full operator loop in
running_and_reviewing.md.
v_run_summary — how did each run go, and how efficiently?¶
One row per run: the scaling knobs (n_series, n_models, python_runtime), the header's own
runtime_seconds, and a time ledger rolled up from the run's run_jobs rows:
| Column | Meaning |
|---|---|
n_jobs |
Job rows in the ledger — one per family that launched, plus ensemble when it ran. |
longest_job_seconds |
The critical-path job: the widest started_at → ended_at bracket. |
jobs_seconds |
Every job's bracket added up. Families run in parallel, so this exceeds the run's own wall whenever the DAG overlapped — the "compute-seconds" view of the run. |
jobs_span_seconds |
First job start to last job end — the run's job window. |
overhead_seconds |
jobs_span_seconds − longest_job_seconds: the wall inside the job window that the slowest family did not account for — the barrier ensemble after the join, the native BigQuery job, family start skew, and under Composer the scheduler's gaps between tasks. |
overhead_fraction |
overhead_seconds / jobs_span_seconds. |
The ledger means the same thing whichever tier finalized the header, which is why it is derived
from the job rows rather than from header columns: the header's runtime_seconds is the launcher's
wall under main.run but the slowest job's under Composer, and the platform wall a Serverless batch
stamps on the header is one family's, not the run's. Three rules keep it exact. Every term is
measured on the job rows' own started_at / ended_at timestamps — not on runtime_seconds,
which the launcher reads off a monotonic clock — so a span always contains its longest member and
overhead_seconds is ≥ 0 by arithmetic. The rows are the current attempt per family (the
highest-attempt rule v_run_jobs applies) written after the header row, so a run resumed
hours later is not charged the idle time between its launches. And repair rows are excluded
(family ending in _repair): a repair is filed later under its own token and writes no header,
so counting it would stretch the span across the gap. A header with no job row in its launch — a
run that failed before its first family launched, or one written by an older release — carries NULL
in the ledger and still renders.
Beside the ledger sit the Dataproc Serverless scalars unpacked from the header's job_telemetry —
executor_instances / executor_cores / max_executors / executor_memory /
executor_memory_overhead, dcu_milli_seconds, runtime_version — and the raw sizing and
capacity records. The executor columns are the shape the platform was told (echoed back off
the submitted batch); sizing is why it was that shape. The scalars are written by the run's
Serverless batches, last to finish wins: exact for a single-family Serverless run, one family's
answer for a mixed run, NULL for a run that submitted no Serverless batch. The per-family
runtime/hardware breakdown that composes each run lives in v_run_jobs.
Each family job of a run writes its own sizing entry, so the column holds one object per family:
SELECT
JSON_VALUE(sizing, '$.deep_learning.plans[0].slot.memory_bytes') AS dl_slot_memory,
JSON_VALUE(sizing, '$.deep_learning.profile.provenance.run_id') AS sized_off_run,
JSON_VALUE(sizing, '$.statistical.translation.executor_cores') AS stat_cores
FROM `PROJECT.DATASET.v_run_summary`
WHERE run_id = 'RUN_ID';
Three parts per entry: plans (the fleet the arithmetic asked for — one per fleet, so a Ray job
records its CPU and GPU pools), translation (what that became in platform settings, and the
ideals it snapped from — null on Ray, which sets task options rather than properties), and
profile (the measurements it was sized off, with the provenance naming whose run they came from —
null when the run sized from declared config alone). Absent for a run submitted before this
existed, or one that left the platform's own defaults standing.
v_run_jobs — what jobs ran, on what runtime/hardware, and how did each fare?¶
One row per (run_id, family) = the run's DAG as executed: the deterministic job_id, the
attempt, the resolved runtime / spark_mode / hardware / gpu_type, the platform's own
system_job_id, the per-job status, its started_at / ended_at bracket and runtime_seconds
(the launcher's wall around the platform job, submit to terminal — the bracket is what
v_run_summary's ledger is built from), provision_seconds / execute_seconds (the platform's own
split of that wall: how long it took to find and start capacity before any of our code ran, and how
long the work ran once it had — populated for Dataproc Serverless and Vertex CustomJob families,
whose APIs expose the start transition, NULL for the rest; a long provision_seconds beside a short
execute_seconds is the signature of a family that should move runtime, region, or machine shape),
and failure_reason for a row that failed. Out of the
per-job job_telemetry it unpacks platform (the three timings above, from one probe read of the
finished job), probe_handle (the platform handle the launcher kept) and
capacity (this family's own region walk, NULL for a job that never had to wait — nearly all of
them). device_verdict is the one word saying whether this row's accelerator did any work, with the
counts behind it in device_use beside it — both NULL for a CPU family, so WHERE device_verdict
!= 'ENGAGED_UTILISED' is the "what did I pay for and not use" query
(below). The platform's DCU and cluster-shape
figures are not on this row: Serverless telemetry lands on the header, so they read from
v_run_summary. A --force re-run appends a higher-attempt job under the same (run_id,
family); the view keeps only the current one (QUALIFY ROW_NUMBER() … ORDER BY attempt DESC = 1),
so the run_id → current job map is one row per family.
v_model_leaderboard — which model won, per run?¶
One row per (run_id, model_type, ensemble_id): base_model, scope_capability, training_mode,
cov_profile, is_univariate, covariates_used, model_spec_hash, borrowed_from_run_id,
n_borrowed, n_cells, n_no_artifact / no_artifact_rate (a model failing every cell shows as
no_artifact_rate = 1.0), median_fit_seconds, and mean_wape / mean_mae (plus every registered
metric) where a backtest populated them. The entry point for "is this model or <base_model>@<tag>
variant worth keeping" before ensembling, and for auditing which models were borrowed from a prior
snapshot-verified run (n_borrowed > 0). Reads only the final rows (fold_id IS NULL), so per-fold
metrics don't double-count.
v_backtest_coverage — how much of the panel did each model get scored on?¶
The question the leaderboard cannot answer, and the one that decides whether its ranking means
anything. A ragged panel does not give every series the same number of folds: backtest.make_folds
drops the oldest folds a short series cannot afford. So one model's mean_wape might be an average
over ten folds of two thousand series while its neighbour's is an average over one fold of two
hundred — two different questions, printed in the same column.
One row per (run_id, model_type, ensemble_id, backtest_status, n_folds_achieved), with n_series
and series_share (that cohort's fraction of the model's panel). backtest_status is full,
reduced, unscored or failed, or NULL where the run never asked for a backtest at all. The
shape is long rather than one wide row per model because the achieved-fold histogram has no fixed
width, and because a wide shape would need a self-join that breaks on the NULL ensemble_id every
base model carries.
Read it beside the leaderboard. A model whose panel is mostly reduced won on an easier question.
v_model_leaderboard_comparable — which model won, holding the question fixed?¶
The same ranking as v_model_leaderboard, rebuilt so the numbers are comparable across models
rather than merely present. Two differences, and both are the point.
First, it is restricted to the holdout fold — the newest one, MAX(fold_id) OVER (PARTITION BY
run_id). Every series that achieved any fold at all achieved that one, because make_folds keeps a
survivor's original fold_id and drops from the oldest end. The fold is derived from the rows
rather than read from the config on purpose: a view has no config to read.
Second, the error is pooled, not averaged: SUM(|y_true − yhat|) / SUM(|y_true|) over every
series at once. That gives one WAPE for the whole panel, instead of the mean of per-series WAPEs
where a single near-zero series can dominate the average.
Columns: base_model, scope_capability, training_mode, cov_profile, is_univariate,
covariates_used, model_spec_hash, borrowed_from_run_id, n_borrowed, holdout_fold_id,
n_series, n_points, pooled_wape, pooled_mae, and the first_forecast_date /
last_forecast_date bounding the window that was scored. Carry n_series into any comparison you
draw — equal n_series across the rows is the evidence that the models answered the same question,
and unequal n_series is itself a finding.
It reads backtest_oof rather than forecast_metadata because that is the only table holding
per-fold truth; forecast_metadata stores one rolled-up row per cell and cannot be restricted to a
fold after the fact. Ensembles appear here beside the base models, because they write their blended
out-of-fold rows into that same table.
See also: configuration_reference.md (the run is the config that
lands in raw_config) · running_and_reviewing.md (submit, watch, and
review through these views) · registry/ddl.py /
registry/views.py (the source of truth).