Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions charts/promforecast/templates/tests/validate-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{{- /*
Post-install ``helm test`` hook: runs ``promforecast validate
--strict-cardinality`` against the same ConfigMap the live
Deployment mounts. The strict flag flips the projected
cardinality overshoot from an advisory ``WARN`` line into a
non-zero exit code, so an operator can chain ``helm test`` into
a GitOps PostSync gate that rejects configs whose opt-in
emission would blow ``safety.max_total_series``.

Wired as a ``helm test`` hook (not a normal apply) so it costs
nothing during the standard install — operators opt in by
running ``helm test <release>``. CI does not execute the hook
(no kind cluster), but ``helm template`` still renders it and
``kubeconform`` validates the Pod schema.

Set ``tests.validateConfig.enabled=false`` to suppress the
asset entirely for managed clusters that forbid hook resources.
*/}}
{{- if .Values.tests.validateConfig.enabled }}
apiVersion: v1
kind: Pod
metadata:
name: {{ printf "%s-test-validate" (include "promforecast.fullname" .) | trunc 63 | trimSuffix "-" }}
labels: {{- include "promforecast.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
serviceAccountName: {{ include "promforecast.serviceAccountName" . }}
restartPolicy: Never
securityContext: {{- toYaml .Values.podSecurityContext | nindent 4 }}
{{- with .Values.image.pullSecrets }}
imagePullSecrets: {{- toYaml . | nindent 4 }}
{{- end }}
containers:
- name: validate
image: {{ include "promforecast.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext: {{- toYaml .Values.securityContext | nindent 8 }}
args:
- validate
- --config=/etc/promforecast/config.yaml
- --strict-cardinality
resources: {{- toYaml .Values.resources | nindent 8 }}
volumeMounts:
- name: config
mountPath: /etc/promforecast
readOnly: true
volumes:
- name: config
configMap:
name: {{ include "promforecast.configMapName" . }}
{{- end }}
16 changes: 16 additions & 0 deletions charts/promforecast/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,19 @@ podLabels: {}
extraEnv: []
extraVolumes: []
extraVolumeMounts: []

# ``helm test`` hooks. Each hook below renders as a one-shot Pod that
# the operator runs with ``helm test <release>``; nothing is created
# during the normal ``helm install`` apply, so the production install
# does not pay any test-pod resource cost. Disable a hook to suppress
# the corresponding rendered template entirely.
tests:
# ``validateConfig`` runs ``promforecast validate --strict-cardinality``
# against the mounted config. The strict flag flips the projected
# cardinality overshoot from an advisory ``WARN`` line into a hard
# fail, so an operator using a wrapper like ArgoCD's PostSync hook
# can reject changes that would blow the global series cap before the
# forecaster pod ingests them. See docs/operations/cardinality.md for
# the workflow.
validateConfig:
enabled: true
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ class ProphetModel:
"""

name = "Prophet"
# Prophet's ``fit_predict`` actually merges and consumes the
# ``regressors`` frame (see lines below) — so the forecaster's
# conditional-forecast pass needs to refit Prophet with the
# held-constant regressors instead of aliasing the unconditional
# output.
uses_regressors = True

def __init__(self, season_length: int = 288, **params: Any) -> None:
self._season_length = season_length
Expand Down
72 changes: 72 additions & 0 deletions community/promforecast-prophet/tests/test_prophet_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,78 @@ def load(self) -> Any:
return [*real, _StubEP()]


def test_prophet_advertises_uses_regressors_true() -> None:
"""Prophet's ``fit_predict`` actually consumes the ``regressors`` frame
(the wrapper registers each column via ``add_regressor`` and merges
it into the future frame). The forecaster's conditional-emission
path reads the ``uses_regressors`` class attribute to decide whether
to refit with held-constant regressors; Prophet must advertise
``True`` so the refit actually happens.
"""
assert ProphetModel.uses_regressors is True


def test_prophet_uses_regressors_flag_via_registry_helper() -> None:
"""Through the registry helper too — the forecaster reads
``model_registry.uses_regressors(name)`` rather than reaching for
the class attribute directly. Verify the discovery + lookup path
agrees end-to-end.
"""
from promforecast import models as registry # noqa: PLC0415

registry.refresh()
try:
with patch("promforecast.models.entry_points", _patched_entry_points):
registry.refresh()
assert registry.uses_regressors("Prophet") is True
finally:
registry.refresh()


def test_prophet_legitimately_differs_when_regressors_held_constant() -> None:
"""End-to-end: the conditional refit path must produce different bands
when the regressors frame is flat-held vs. the original input.

The fake Prophet uses ``regressors[regressor_name].iloc[-1]`` as a
proxy for "the regressor's current value"; in our flat-held
contract every row of the conditional frame equals the in-sample
final value, so the post-fit predicted yhat tracks that value. We
confirm the wrapper preserves the regressors all the way through —
so the conditional refit really does see the held-constant frame —
by inspecting the per-instance ``add_regressor`` calls and the
fitted dataframe's regressor column.
"""
df = _make_df()
# Two distinct configurations of the same regressor column:
# (a) varying — the "unconditional" input the model would see
# under a real fit.
# (b) flat-held — every row pinned to the last observed value,
# matching what the runner's
# ``_flatten_regressors_to_last_value`` produces.
varying = pd.DataFrame({"ds": df["ds"], "deploys": [float(i % 5) for i in range(len(df))]})
held = pd.DataFrame({"ds": df["ds"], "deploys": [varying["deploys"].iloc[-1]] * len(df)})

ProphetModel().fit_predict(df=df, horizon=3, freq="h", levels=[80], regressors=varying)
unconditional_inst = _FakeProphet.instances[-1]
assert unconditional_inst.fit_df is not None
# The fit frame has the varying regressor merged in.
assert "deploys" in unconditional_inst.fit_df.columns
assert list(unconditional_inst.fit_df["deploys"]) == list(varying["deploys"])

_FakeProphet.instances.clear()
ProphetModel().fit_predict(df=df, horizon=3, freq="h", levels=[80], regressors=held)
conditional_inst = _FakeProphet.instances[-1]
assert conditional_inst.fit_df is not None
# The flat-held frame is what the conditional refit feeds Prophet.
# Every row equals the last value so the regressor effect is
# "frozen at the current state".
deploys_in_fit = list(conditional_inst.fit_df["deploys"])
assert deploys_in_fit == [held["deploys"].iloc[-1]] * len(df)
# Both fits add the regressor — the wrapper isn't accidentally
# silencing the regressor on the conditional pass.
assert conditional_inst.regressors == ["deploys"]


def test_prophet_appears_in_promforecast_registry_via_entry_point() -> None:
"""End-to-end: once the package is installed, ``Prophet`` becomes a
valid model name in promforecast configs without any forecaster-side
Expand Down
77 changes: 77 additions & 0 deletions dashboards/grafana/promforecast-capacity.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,83 @@
},
"overrides": []
}
},
{
"type": "table",
"id": 8,
"title": "Next maintenance windows (lowest predicted load)",
"description": "Top-N predicted-lowest-load windows in the next ``recommended_windows.lookahead`` of the forecast horizon. Lower predicted_load is better; rank=1 is the suggested window. Operators schedule reboots / cert rotations / security patches against the ranked recommendations.",
"datasource": {
"type": "prometheus",
"uid": "${datasource}"
},
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 39
},
"targets": [
{
"expr": "forecast_recommended_maintenance_window{group=~\"$group\",id=~\"$id\"}",
"legendFormat": "",
"format": "table",
"instant": true
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"best_model": true,
"horizon": true,
"model": true,
"model_fallback": true,
"job": true,
"cluster": true,
"replica": true
},
"indexByName": {
"rank": 0,
"id": 1,
"group": 2,
"instance": 3,
"start_timestamp_seconds": 4,
"duration_seconds": 5,
"Value": 6
},
"renameByName": {
"Value": "predicted_load",
"start_timestamp_seconds": "start (unix)",
"duration_seconds": "duration (s)"
}
}
}
],
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"displayMode": "auto"
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "start (unix)"
},
"properties": [
{
"id": "unit",
"value": "dateTimeFromNow"
}
]
}
]
}
}
]
}
Loading