Operator dashboard¶
grounded-weather-forecast report writes reports/dashboard.html on every
run — a fully offline, self-contained operator console. All CSS, the
vendored Chart.js library, and the data payload are inlined, so the file
renders from file:// with no network access. There is no server and no
daemon: the dashboard is a read-only projection of the artifacts the
pipeline already writes to disk, regenerated each report run, exactly like
the markdown reports beside it.
The page answers six operator questions, split across nine zones:
| Question | Zones |
|---|---|
| Is fresh, trustworthy data flowing in? | A liveness · B data trust |
| Can the system even learn yet? | C learning readiness |
| Are the models valid, and which is winning and why? | D evaluation · E model internals |
| Is what we actually served any good, and why that number? | F serving · G explainability |
| Is the system getting better or worse over time? | H quality over time |
| Is the machinery around the models healthy? | I operations |
Every panel carries its own explanatory prose (what it shows, why an operator cares, and where its thresholds come from) in a collapsible about this panel block, so the reference below stays brief.
Zone reference¶
A — Liveness & freshness. The latest served document's status
(ready/degraded with the exact status_reason), station observation lag
against the 30-minute serve staleness cap, and per-provider fetch ages
against [forecasts].max_forecast_age_hours. A provider that ages past the
cap silently drops out of snapshots — it is drawn grey, not omitted.
B — Ingestion & data trust. Station QC flag counts per channel
(out-of-bounds / spike / flatline; flagged samples are nulled, never
corrected), a daily truth-coverage calendar against
[dataset].min_hour_coverage, per-provider forecast null shares, and the
live/synthetic provenance wall asserted as a badge. The panel verdict reads
both the current flatline state and the overall flagged share (amber past
5%, red past 25%), so a channel that is wholly out-of-bounds cannot render
green on the strength of having no stuck sensor.
C — Learning readiness. Archive issue-time span versus the
initial_train_days + step_days a first rolling-origin fold needs (a
progress bar mirroring the backtest's own "no folds" arithmetic), snapshots
collected per day, synthetic-backfill coverage, and the truth-semantics
alignment study with defaulted (not-yet-data-backed) variables flagged.
D — Evaluation. Per-slice leaderboards with n first and bias cells
color-classed against the per-variable consumer tolerances, slice winners
after the promotion gate ([promotion].rule at its alpha), the baseline
floor sanity check, the provider error-correlation matrix with the derived
effective ensemble size k_eff, and the calibration views the CLI computes
but never surfaces: CRPS/pinball/interval-coverage columns, a PIT histogram
rebuilt from the persisted quantile grids, and the PoP reliability diagram.
E — Model internals. The glass box, read from the observability
snapshots each predict run writes to [artifacts].dir/observability/:
grounding coefficients per provider × lead bucket (IDENTITY fallbacks
greyed), online-expert weights (a trajectory line once two or more
snapshots span the window — the provider-backend-swap detector), GBM feature
importances (with a loud red state when lightgbm is missing and the method
silently absent from the registry), the fitted anchoring decay timescale and
its weight curve, and best_provider's per-bucket source rankings.
F — Serving & self-verification. Served-vs-realized MAE per slice with
the mae_gap against the backtest's promise (red past
[promotion].live_gap_factor), served slices per day stacked by selection
reason, and the release lineage table — dataset fingerprint → evaluations →
release → served documents — with stale fingerprints flagged. The degraded
share is judged on the trailing day (amber past 10%, red past 50%) with the
lifetime share shown beside it: a lifetime figure alone is diluted by every
healthy row ever served, so a currently-degraded system would read green once
enough history accumulated. When a live score file exists but cannot be read
or scored, the verification panel says so in red rather than reporting the
young-archive "not enough realized forecasts" message.
G — Explainability. Pick any point/variable of the latest served
document and see the method that produced it, its selection reason, release
ids, quantiles, and anchor observation. Hourly and daily provider inputs are
drawn from the newest provider snapshot visible at the served issue and matched
to the exact served point. Minutely rows are selectable, but the dataset
contract does not expose their raw provider-input matrix.
The same document is replayable byte-for-byte with predict --now <issue>.
H — Quality over time. Trend views over the append-only ledgers under
[artifacts].dir/history/ that every nightly report maintains, so
movement survives fingerprint resets instead of living only in tonight's
report: recent-window backtest MAE per variable, selection churn between
consecutive promoted releases, served MAE against its backtest promise, A/B
verdict shares over time (quantile-recalibration wins and promotion-gate
agreement), e-process wealth trajectories against the promotion threshold,
and recent-window interval coverage against its nominal target. An empty
ledger renders a "young history" placeholder, never an alarm.
I — Operations. The pipeline's edges, where the two historical
week-long silent failures lived (a dead station logger; a predict job
failing argparse): end-to-end freshness ages for the newest station
observation, collector run, served-history row, and published document — a
non-empty alarm string here is a live fault, the one exception to "empty
ledgers are young, not broken" — plus per-provider collector health with
the lead-contraction note (the plan-downgrade detector), stage runtimes
from the runs ledger, the collector → long-frame → matrix build funnel, the
scores-file evidence footprint that prune-scores manages, and every
config-fingerprint/code-identity transition (secrets redacted).
Alerts and their thresholds¶
The alert strip at the top is computed at generation time by
reports/alerts.py. Every threshold names an existing config knob or module
constant — the alerting invents no policy:
| Alert | Threshold source |
|---|---|
| ingestion stalled / anchor lost | serve/predict.py::OBS_STALENESS (30 min); [forecasts].max_forecast_age_hours |
| provider dropped / aged out | [forecasts].max_forecast_age_hours; manifest.sources |
| serving refused | NoForecastDataError via the runs ledger |
| serving degraded | Forecast.status_reason (no_evidence_reason) |
| truth thinning | [dataset].min_hour_coverage / min_day_coverage |
| truth coverage unusable | coverage columns present but wholly null/NaN (red) |
| stuck sensor | QC_FLATLINE bit; [qc].flatline_minutes |
| provider drifting | artifacts/drift.json (consensus/residual tiers) |
| grounding bias | reports/leaderboard.py::CONSUMER_TOLERANCES |
| baseline implausible | structural heuristic (labeled as such — no knob) |
| backend swap | leading-expert flip held 0.5d+ within the 3-day drift window |
| serving diverged | [promotion].live_gap_factor, min_live_n |
| artifacts stale | manifest vs release fingerprints |
| silent-empty states | manifest sources/snapshots/rows; LOCATION_TOLERANCE |
Families that cannot be evaluated yet return a single not evaluable yet info chip instead of silence or a false alarm.
Evidence that is present but degenerate is treated as a failure, never as
health. Every numeric guard goes through contracts.finite_number, because
isinstance(x, (int, float)) admits NaN and every NaN comparison is
False — so a raw check silently renders a dead provider, an empty coverage
column, or a corrupt archive location as passing. k_eff likewise reports
not evaluable rather than clamping a NaN mean to 1.0, which would
claim "no independence" — the most alarming possible reading — from an
absence of evidence.
The converse also holds: evidence that is present but noisy must not read
as a failure. The backend-swap detector compares the leading expert across
consecutive trajectory samples, and at the 10-minute predict cadence a
3-day window holds several hundred of them — so a near-tie between two
experts crosses back and forth on arithmetic alone. A new leader has to hold
half a day before the flip counts as a regime change, and an alert that did
fire reports how many flips held, so genuine flapping stays distinguishable
from a single swap.
What a young deployment looks like¶
On day one most of the dashboard is supposed to be grey or amber:
zero live folds is correct behaviour (zone C says how far away the first
fold is), no releases means zone F reports "no promotion has ever occurred",
and zone E stays in "not yet" states until a predict run persists its
first observability snapshots. The real red flags on a young deployment are
the silent-empty states — zero sources, zero snapshots, zero-row files, or
an archive/station location mismatch — because those look like health when
nothing is actually flowing.
New on-disk signals¶
[dataset].dir/runs.parquet— rolling ledger of every command whose configuration loads successfully: command, args, start/end, duration, exit code or exception name, dataset/config fingerprints, code version. Parser and configuration-loading failures cannot be recorded because that configuration supplies the ledger destination. Telemetry writes never fail a command (5-second lock timeout, errors swallowed). The file is rewritten in full under that lock, so it is pruned on every append to the last 90 days and 50,000 rows — without a bound it would grow forever at the 10-minutepredictcadence in Scheduling.[artifacts].dir/observability/— per-(method, product, variable) latest-state snapshots (ArtifactStorelayout) plushistory.parquet, an ewa/boa-only weight trajectory pruned to[backtest].rolling_window_days. Write-only: serving output is identical whether snapshots land or not. The dataset fingerprint changes on everybuild-dataset, so snapshot trees no longer referenced bylatest.jsonare deleted after each successful write. Every state read reaches a slot through alatest.jsonpointer, so an unreferenced tree is unreachable rather than merely old. Reclamation runs inside the same lock as the write that triggers it, so it cannot delete a tree a concurrentpredictis still writing into.
Updating the vendored Chart.js¶
The chart library is pinned and committed at
src/grounded_weather_forecast/dashboard/assets/chart.umd.min.js
(Chart.js 4.4.9, MIT). To upgrade: download the new
dist/chart.umd.js from the official release, replace the file (keep the
license header), update the version asserted in
tests/dashboard/test_assets_packaging.py, and re-run the suite. Lizard is
configured to skip */dashboard/assets/* in CI — keep that exclusion in
step with the path.