id: mlflow-model-production-readiness-gate
namespace: company.team
inputs:
- id: mlflow_tracking_uri
type: STRING
displayName: MLflow Tracking URI
description: "Base URL of the MLflow tracking server, e.g. http://mlflow:5000"
defaults: "http://mlflow:5000"
- id: model_name
type: STRING
displayName: Registered model name
description: Name of the model in the MLflow Model Registry to promote
defaults: "fraud-detector"
- id: candidate_version
type: STRING
displayName: Candidate model version
description: Version number in the registry to evaluate for production promotion
defaults: "3"
- id: reference_dataset_uri
type: STRING
displayName: Reference dataset URI (training baseline)
description: Internal-storage URI of the Parquet file with training-time feature
statistics (output of the registration pipeline)
defaults: "kestra:///company/team/executions/mlflow-register/latest/reference_s\
tats.parquet"
- id: production_dataset_uri
type: STRING
displayName: Production sample URI
description: Internal-storage URI of a recent Parquet file sampled from the live
serving population
defaults: "kestra:///company/team/executions/mlflow-serve-sample/latest/product\
ion_sample.parquet"
- id: psi_threshold
type: FLOAT
displayName: PSI drift threshold
description: "Population Stability Index above which a feature is flagged as
drifted. 0.2 is the standard 'significant change' cutoff."
defaults: 0.2
- id: ks_alpha
type: FLOAT
displayName: KS test significance level (alpha)
description: "p-value floor for the Kolmogorov-Smirnov test. Features with p <
alpha are flagged. Default 0.05."
defaults: 0.05
- id: max_drifted_features
type: INT
displayName: Max drifted features before blocking
description: Promotion is blocked if more than this many features fail either drift test
defaults: 2
- id: target_stage
type: STRING
displayName: Target MLflow stage
description: "Registry stage to transition the model to on pass: Production or Staging"
defaults: "Production"
- id: notify_slack
type: BOOL
displayName: Notify Slack
description: Send a Slack alert whether the gate passes or blocks
defaults: false
- id: dry_run
type: BOOL
displayName: Dry run
description: "Run the full gate but skip the actual registry transition on pass.
Useful in CI."
defaults: false
variables:
kv_prefix: "mlflow_gate_{{ inputs.model_name }}_v{{ inputs.candidate_version }}"
gate_result_key: "{{ vars.kv_prefix }}_result"
run_ts: "{{ execution.startDate | date('yyyy-MM-dd HH:mm:ss') }}"
tasks:
# ─────────────────────────────────────────────────────────────────
# 1. FETCH MODEL METADATA FROM MLFLOW REGISTRY
# ─────────────────────────────────────────────────────────────────
- id: fetch_model_version
type: io.kestra.plugin.core.http.Request
description: "Pull the candidate version metadata from the MLflow registry REST API."
uri: "{{ inputs.mlflow_tracking_uri }}/api/2.0/mlflow/model-versions/get?name={{
inputs.model_name }}&version={{ inputs.candidate_version }}"
method: GET
- id: fetch_run_metrics
type: io.kestra.plugin.core.http.Request
description: "Fetch the training run that produced this model version to get its
logged metrics."
uri: "{{ inputs.mlflow_tracking_uri }}/api/2.0/mlflow/runs/get?run_id={{
(outputs.fetch_model_version.body | fromJson).model_version.run_id }}"
method: GET
- id: parse_model_info
type: io.kestra.plugin.core.output.OutputValues
description: "Flatten the fields we need downstream into named outputs."
values:
model_name: "{{ (outputs.fetch_model_version.body | fromJson).model_version.name }}"
model_version: "{{ (outputs.fetch_model_version.body |
fromJson).model_version.version }}"
current_stage: "{{ (outputs.fetch_model_version.body |
fromJson).model_version.current_stage }}"
run_id: "{{ (outputs.fetch_model_version.body | fromJson).model_version.run_id
}}"
source_path: "{{ (outputs.fetch_model_version.body |
fromJson).model_version.source }}"
creation_timestamp: "{{ (outputs.fetch_model_version.body |
fromJson).model_version.creation_timestamp }}"
metrics_json: "{{ (outputs.fetch_run_metrics.body | fromJson).run.data.metrics |
toJson }}"
tags_json: "{{ (outputs.fetch_run_metrics.body | fromJson).run.data.tags |
toJson }}"
# ─────────────────────────────────────────────────────────────────
# 2. STATISTICAL FEATURE DRIFT DETECTION
# ─────────────────────────────────────────────────────────────────
- id: compute_drift
type: io.kestra.plugin.scripts.python.Script
description: >-
Compare training-time reference statistics against a recent production
sample using PSI (Population Stability Index) for all numeric features and
the KS test for continuous ones. Any feature that exceeds the PSI
threshold OR falls below the KS p-value floor is flagged as drifted.
containerImage: python:3.12-slim
beforeCommands:
- pip install --quiet pandas pyarrow scipy numpy 2>&1 | tail -1
inputFiles:
reference.parquet: "{{ inputs.reference_dataset_uri }}"
production.parquet: "{{ inputs.production_dataset_uri }}"
outputFiles:
- drift_report.json
script: |
import json, math, sys
import numpy as np
import pandas as pd
from scipy import stats
PSI_THRESHOLD = {{ inputs.psi_threshold }}
KS_ALPHA = {{ inputs.ks_alpha }}
ref = pd.read_parquet("reference.parquet")
prod = pd.read_parquet("production.parquet")
# Keep only shared numeric columns; skip label/target columns by convention
# (columns whose name ends with _label, _target, or equals 'y').
SKIP = {"y", "label", "target"}
shared = [
c for c in ref.columns
if c in prod.columns
and pd.api.types.is_numeric_dtype(ref[c])
and c.lower() not in SKIP
and not c.lower().endswith(("_label", "_target"))
]
def psi(expected: np.ndarray, actual: np.ndarray, buckets: int = 10) -> float:
"""Population Stability Index. Returns float('inf') on degenerate input."""
breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1))
breakpoints[0] = -np.inf
breakpoints[-1] = np.inf
e = np.histogram(expected, bins=breakpoints)[0] / len(expected)
a = np.histogram(actual, bins=breakpoints)[0] / len(actual)
# Clip to avoid log(0)
e = np.clip(e, 1e-6, None)
a = np.clip(a, 1e-6, None)
return float(np.sum((a - e) * np.log(a / e)))
results = []
for col in shared:
r = ref[col].dropna().values.astype(float)
p = prod[col].dropna().values.astype(float)
if len(r) < 10 or len(p) < 10:
results.append({
"feature": col,
"status": "SKIP",
"reason": f"too few rows (ref={len(r)}, prod={len(p)})",
"psi": None,
"ks_stat": None,
"ks_p": None,
})
continue
psi_val = psi(r, p)
ks_stat, ks_p = stats.ks_2samp(r, p)
psi_flag = psi_val >= PSI_THRESHOLD
ks_flag = ks_p < KS_ALPHA
if psi_flag and ks_flag:
status = "DRIFTED"
elif psi_flag or ks_flag:
status = "WARNING"
else:
status = "STABLE"
results.append({
"feature": col,
"status": status,
"psi": round(psi_val, 4),
"ks_stat": round(float(ks_stat), 4),
"ks_p": round(float(ks_p), 4),
})
drifted = [r for r in results if r["status"] == "DRIFTED"]
warnings = [r for r in results if r["status"] == "WARNING"]
stable = [r for r in results if r["status"] == "STABLE"]
skipped = [r for r in results if r["status"] == "SKIP"]
report = {
"total_features": len(results),
"drifted_count": len(drifted),
"warning_count": len(warnings),
"stable_count": len(stable),
"skipped_count": len(skipped),
"psi_threshold": PSI_THRESHOLD,
"ks_alpha": KS_ALPHA,
"drifted_features": drifted,
"warning_features": warnings,
"stable_features": stable,
"skipped_features": skipped,
"ref_rows": int(len(ref)),
"prod_rows": int(len(prod)),
}
with open("drift_report.json", "w") as f:
json.dump(report, f, indent=2)
print(json.dumps({
"drifted_count": len(drifted),
"warning_count": len(warnings),
"stable_count": len(stable),
"top_drifted": [f["feature"] for f in sorted(drifted, key=lambda x: x["psi"] or 0, reverse=True)[:5]],
}))
- id: parse_drift_verdict
type: io.kestra.plugin.core.output.OutputValues
description: "Surface drift counts and the gate decision as named outputs."
values:
drifted_count: "{{ (outputs.compute_drift.outputFiles['drift_report.json'] |
fromJson).drifted_count }}"
warning_count: "{{ (outputs.compute_drift.outputFiles['drift_report.json'] |
fromJson).warning_count }}"
total_features: "{{ (outputs.compute_drift.outputFiles['drift_report.json'] |
fromJson).total_features }}"
gate_pass: "{{ (outputs.compute_drift.outputFiles['drift_report.json'] |
fromJson).drifted_count <= inputs.max_drifted_features }}"
# ─────────────────────────────────────────────────────────────────
# 3. TRAINING METRICS REGRESSION CHECK
# ─────────────────────────────────────────────────────────────────
- id: check_metric_regression
type: io.kestra.plugin.scripts.python.Script
description: >-
Compare the candidate's logged training metrics (e.g. f1, precision,
recall, auc) against the metrics stored in KV for the currently promoted
version. If no KV baseline exists the check passes unconditionally (first
deploy).
containerImage: python:3.12-slim
env:
MLFLOW_URI: "{{ inputs.mlflow_tracking_uri }}"
MODEL_NAME: "{{ inputs.model_name }}"
METRICS_JSON: "{{ outputs.parse_model_info.values.metrics_json }}"
KV_BASELINE: "{{ kv('mlflow_baseline_metrics_' + inputs.model_name) |
default('{}') }}"
script: |
import json, os, sys
candidate = {m["key"]: m["value"] for m in json.loads(os.environ["METRICS_JSON"])}
baseline = json.loads(os.environ.get("KV_BASELINE", "{}"))
HIGHER_BETTER = {"f1", "precision", "recall", "roc_auc", "pr_auc", "accuracy", "auc"}
LOWER_BETTER = {"log_loss", "logloss", "rmse", "mae", "mse", "error_rate"}
TOLERANCE = 0.01 # allow up to 1 pp regression before flagging
regressions = []
for k, baseline_val in baseline.items():
if k not in candidate:
continue
cand_val = candidate[k]
if k.lower() in HIGHER_BETTER:
if baseline_val - cand_val > TOLERANCE:
regressions.append({"metric": k, "baseline": baseline_val, "candidate": cand_val, "delta": round(cand_val - baseline_val, 4)})
elif k.lower() in LOWER_BETTER:
if cand_val - baseline_val > TOLERANCE:
regressions.append({"metric": k, "baseline": baseline_val, "candidate": cand_val, "delta": round(cand_val - baseline_val, 4)})
result = {
"has_baseline": bool(baseline),
"candidate_metrics": candidate,
"regressions": regressions,
"regression_count": len(regressions),
"pass": len(regressions) == 0,
}
print(json.dumps(result))
- id: parse_metric_verdict
type: io.kestra.plugin.core.output.OutputValues
values:
regression_count: "{{ (outputs.check_metric_regression.vars |
fromJson).regression_count }}"
metrics_pass: "{{ (outputs.check_metric_regression.vars | fromJson).pass }}"
# ─────────────────────────────────────────────────────────────────
# 4. COMBINED GATE DECISION
# ─────────────────────────────────────────────────────────────────
- id: gate_decision
type: io.kestra.plugin.core.flow.If
description: "Branch on the combined drift + metric verdict."
condition: "{{ outputs.parse_drift_verdict.values.gate_pass == 'true' and
outputs.parse_metric_verdict.values.metrics_pass == 'true' }}"
then:
# ── PASS BRANCH ──────────────────────────────────────────────
- id: log_gate_pass
type: io.kestra.plugin.core.log.Log
message: >-
Gate PASSED: {{ outputs.parse_drift_verdict.values.drifted_count }}
drifted features (threshold {{ inputs.max_drifted_features }}), {{
outputs.parse_metric_verdict.values.regression_count }} metric
regressions. Promoting {{ inputs.model_name }} v{{
inputs.candidate_version }} → {{ inputs.target_stage }}.
- id: promote_model
type: io.kestra.plugin.core.http.Request
description: "Transition the model version to the target stage in the MLflow
registry."
runIf: "{{ inputs.dry_run == false }}"
uri: "{{ inputs.mlflow_tracking_uri
}}/api/2.0/mlflow/model-versions/transition-stage"
method: POST
contentType: application/json
body: |
{
"name": "{{ inputs.model_name }}",
"version": "{{ inputs.candidate_version }}",
"stage": "{{ inputs.target_stage }}",
"archive_existing_versions": true
}
- id: store_new_baseline_metrics
type: io.kestra.plugin.core.kv.Set
description: "Record the newly promoted model's metrics as the baseline for
future gate runs."
runIf: "{{ inputs.dry_run == false }}"
key: "mlflow_baseline_metrics_{{ inputs.model_name }}"
value: "{{ outputs.parse_model_info.values.metrics_json }}"
overwrite: true
- id: store_gate_result_pass
type: io.kestra.plugin.core.kv.Set
key: "{{ vars.gate_result_key }}"
value: |
{
"result": "PASS",
"timestamp": "{{ vars.run_ts }}",
"drifted_count": {{ outputs.parse_drift_verdict.values.drifted_count }},
"regression_count": {{ outputs.parse_metric_verdict.values.regression_count }},
"promoted_to": "{{ inputs.target_stage }}",
"dry_run": {{ inputs.dry_run }}
}
overwrite: true
- id: slack_pass
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Notify on successful promotion."
runIf: "{{ inputs.notify_slack }}"
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":white_check_mark: *MLflow gate PASSED* — `{{ inputs.model_name }} v{{ inputs.candidate_version }}` promoted to *{{ inputs.target_stage }}*.\n• Drifted features: {{ outputs.parse_drift_verdict.values.drifted_count }} / {{ outputs.parse_drift_verdict.values.total_features }}\n• Metric regressions: {{ outputs.parse_metric_verdict.values.regression_count }}\n• Execution: `{{ execution.id }}`{{ inputs.dry_run ? ' _(dry run — no actual transition)_' : '' }}"
}
else:
# ── BLOCK BRANCH ─────────────────────────────────────────────
- id: log_gate_block
type: io.kestra.plugin.core.log.Log
level: WARN
message: >-
Gate BLOCKED: {{ outputs.parse_drift_verdict.values.drifted_count }}
drifted features (max {{ inputs.max_drifted_features }}) and {{
outputs.parse_metric_verdict.values.regression_count }} metric
regressions. {{ inputs.model_name }} v{{ inputs.candidate_version }}
will NOT be promoted.
- id: store_gate_result_block
type: io.kestra.plugin.core.kv.Set
key: "{{ vars.gate_result_key }}"
value: |
{
"result": "BLOCK",
"timestamp": "{{ vars.run_ts }}",
"drifted_count": {{ outputs.parse_drift_verdict.values.drifted_count }},
"regression_count": {{ outputs.parse_metric_verdict.values.regression_count }},
"promoted_to": null
}
overwrite: true
- id: slack_block
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: "Notify team that promotion was blocked with actionable detail."
runIf: "{{ inputs.notify_slack }}"
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":no_entry: *MLflow gate BLOCKED* — `{{ inputs.model_name }} v{{ inputs.candidate_version }}` will NOT be promoted to *{{ inputs.target_stage }}*.\n• Drifted features: *{{ outputs.parse_drift_verdict.values.drifted_count }}* / {{ outputs.parse_drift_verdict.values.total_features }} (threshold: {{ inputs.max_drifted_features }})\n• Metric regressions: *{{ outputs.parse_metric_verdict.values.regression_count }}*\n• Execution: `{{ execution.id }}`\nReview the drift report attached to this execution."
}
- id: fail_execution
type: io.kestra.plugin.core.execution.Fail
errorMessage: >-
MLflow gate blocked {{ inputs.model_name }} v{{
inputs.candidate_version }}: {{
outputs.parse_drift_verdict.values.drifted_count }} features drifted
(max {{ inputs.max_drifted_features }}), {{
outputs.parse_metric_verdict.values.regression_count }} metric
regressions.
errors:
- id: alert_gate_error
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
runIf: "{{ inputs.notify_slack }}"
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":x: *MLflow gate errored* — `{{ inputs.model_name }} v{{ inputs.candidate_version }}` gate did not complete. Execution `{{ execution.id }}` failed at task `{{ errorLogs() | length > 0 ? errorLogs()[0].taskId : 'unknown' }}`: {{ errorLogs() | length > 0 ? errorLogs()[0].message : '' }}"
}
triggers:
- id: on_new_candidate
type: io.kestra.plugin.core.trigger.Webhook
description: >-
CI / the model registration pipeline POSTs here immediately after pushing
a new version to the registry. The payload should contain model_name and
candidate_version at minimum.
key: "{{ secret('MLFLOW_GATE_WEBHOOK_KEY') }}"
disabled: true