Drift Detection
Drift for traditional ML models in the model registry: the drift overview, prediction records, baselines, analyses, their results and rows, and each model's drift settings. An analysis compares a model's active version's production predictions with that version's active baseline.
Baselines are built, and actuals recorded, through the Model Registry API. All endpoints require authentication via the X-API-Key header and the scope shown. List endpoints are paged (see Pagination): limit (default 50, max 200), cursor, sort (a field, - for descending) and q. They return the items in data with total, limit, cursor, hasMore and requestId in meta.
Prediction Record Object
Every prediction a registry model makes is recorded once, by whichever caller made it.
{
"predictionId": "pred_7f3a",
"entityId": "customer_456",
"modelId": "model_xyz",
"modelVersion": 2,
"source": "production",
"abTestId": "ab_abc123",
"variantId": "challenger",
"routingReason": "weighted_random",
"features": { "age": 42, "plan": "pro" },
"prediction": "churn",
"probabilities": [0.12, 0.88],
"confidence": 0.88,
"actual": "churn",
"success": true,
"latencyMs": 23,
"timestamp": "2026-09-27T10:30:00Z"
}
| Field | Description |
|---|---|
source | production or test (the model page's Test tab; never counted in results) |
abTestId, variantId, routingReason | Set when an A/B test routed the prediction |
features, output | Present only while the model's Record inputs setting is on |
confidence | The highest class probability (classifiers) |
actual | The prediction's actual, when one has been recorded |
Drift Result Object
{
"_id": "6ab9577009d39c66afbbcba6",
"modelId": "model_xyz",
"baselineId": "6ab8e1c0...",
"jobId": "6ab95740...",
"windowStart": "2026-09-20T00:00:00Z",
"windowEnd": "2026-09-27T00:00:00Z",
"sampleSize": 1250,
"overallStatus": "warning",
"driftScore": 0.14,
"featuresAnalyzed": 12,
"featuresWithDrift": 3,
"predictionDrift": {
"metric": "accuracy",
"higherIsBetter": true,
"current": 0.86,
"baseline": 0.91,
"change": -0.05,
"drop": 0.055,
"status": "warning",
"labelledCount": 420
},
"algorithmsRun": ["psi", "ks", "chi_square", "domain_classifier"],
"algorithmsSkipped": [{ "name": "cbpe", "reason": "Estimating accuracy needs the baseline's calibration ..." }],
"calculatedAt": "2026-09-27T00:05:12Z",
"calculationDurationMs": 5400,
"notification": {
"state": "sent",
"sentAt": "2026-09-27T00:05:14Z",
"inApp": { "sent": ["user_123"] },
"email": { "sent": ["ops@example.com"] },
"slack": { "error": "Not sent: no Slack webhook is set" }
}
}
| Field | Description |
|---|---|
overallStatus | ok, warning, alert, or error when nothing could be scored. It comes from driftScore against the PSI thresholds (without a score, the worst status any algorithm gave), raised to predictionDrift.status when that is worse. |
driftScore | The mean PSI across features: a plain number, not a percentage. null when PSI scored no feature. |
featuresAnalyzed, featuresWithDrift | Features some algorithm scored, and those with drift. The rows themselves are GET /mlops/models/:modelId/drift-results/:id/features. |
predictionDrift | The model's performance on the window's predictions that have actuals, against the baseline's, scored by the model's task: metric is accuracy for a classification or multiclass model, mae (mean absolute error; higherIsBetter false) for a regression model, and review_score (the mean of the reviewers' verdicts recorded as actuals: true 1, false 0, or a 0 to 1 score) for a multilabel, timeseries or other model. current, baseline and change are on that metric; drop is how much worse than the baseline, relative (0.1 is 10% worse, negative is better); status is ok, warning or alert from the model's performanceDropWarning / performanceDropAlert. When it could not be measured, error says why (no actuals yet, a baseline without performance, or a task changed since the baseline was built) and there are no figures or status, except that against a baseline without performance current (with labelledCount) is still measured and only the comparison is missing. |
notification | Only on a result that found drift (warning or alert): how it was announced. state is pending, sending, sent or failed; inApp, email and slack each have sent (user ids, addresses, the webhook host) or error (why not). See the notifications setting below. |
Result Row Object
A result has one row per feature, carrying every per-feature algorithm's metric, and one row per algorithm about the whole dataset.
{
"_id": "6ab95771...",
"resultId": "6ab9577009d39c66afbbcba6",
"modelId": "model_xyz",
"order": 1,
"featureName": "income",
"category": "feature",
"driftType": "data",
"metrics": {
"psi": { "value": 0.41, "threshold": 0.2, "status": "alert", "category": "univariate" },
"ks": { "value": 0.32, "pValue": 0.001, "threshold": 0.2, "status": "alert", "category": "univariate" }
},
"hasDrift": true,
"status": "alert",
"severity": 3,
"score": 0.41
}
| Field | Description |
|---|---|
category | feature for a feature's row; multivariate or performance for a dataset row (named like [multivariate] domain_classifier) |
metrics | By algorithm: value (null when it could not run), threshold, status, pValue, and the algorithm's category |
status, severity | The row's worst metric status, and its severity: alert 3, warning 2, ok 1, error 0 |
score | PSI when it ran, else the first scored metric; null when none scored |
error | Why nothing on the row could be scored |
GET /api/v1/mlops/drift
The drift overview: the registry models you can see, with their latest result's summary and their latest analysis run.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
status | string | No | ok, warning, alert, error, or no-data for models never analyzed |
q | string | No | Name, description or framework |
sort | string | No | -latestDrift.calculatedAt (default), name, latestDrift.driftScore, latestDrift.sampleSize, updatedAt |
Response 200 OK
{
"data": [
{
"_id": "model_xyz",
"name": "churn-classifier",
"framework": "sklearn",
"latestDrift": { "resultId": "6ab95770...", "overallStatus": "warning", "driftScore": 0.14, "sampleSize": 1250, "calculatedAt": "2026-09-27T00:05:12Z" },
"latestAnalysis": {
"jobId": "6ab95740...",
"status": "failed",
"triggeredBy": "scheduler",
"error": "Version 2 has no production predictions from 2026-09-20 00:00 to 2026-09-27 00:00 UTC to compare with its baseline (Test tab predictions are not counted)",
"startedAt": "2026-09-27T00:00:03Z",
"updatedAt": "2026-09-27T00:00:41Z",
"finishedAt": "2026-09-27T00:00:41Z"
},
"driftReadiness": { "state": "analyzed" }
}
],
"meta": {
"total": 1,
"limit": 50,
"nextCursor": null,
"requestId": "req_abc123"
}
}
latestAnalysis.status is pending, deploying, running, completed or failed; triggeredBy is manual, scheduler or baseline. The model record (GET /mlops/models/:id) carries the same two fields.
driftReadiness.state is analyzed when the model has a result, else what it still needs: no-task (the model has no classification or regression task; set problemType with PATCH /api/v1/mlops/models/:id), inputs-off (Record inputs is off, so its predictions have no inputs to compare), no-baseline (its active version, given as version, has no baseline) or not-analyzed (it has a baseline and no result yet; latestAnalysis says why a run failed).
POST /api/v1/mlops/models/:id/predictions
Record a prediction a registry model made outside the platform (a copy served elsewhere), so drift includes it. Predictions the platform serves are recorded automatically.
Scope: mlops:write
| Field | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
features | object | Yes | Input values by feature name |
prediction | any | Yes | The model's output |
probabilities | array | Classification: Yes | Per-class probabilities; the highest is the confidence |
entityId | string | No | Your id for what was predicted; actuals can join on it |
modelVersion | number | No | The version that made the prediction |
latencyMs | number | No | How long it took |
Response 201 Created
The prediction record as stored, source production.
GET /api/v1/mlops/models/:id/predictions
A model's prediction records, each with its actual when one was recorded.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
source | string | No | production or test |
startDate, endDate | string | No | ISO 8601 bounds on the prediction time |
q | string | No | Prediction id, entity id or error, or the prediction exactly |
sort | string | No | -timestamp (default), latencyMs, modelVersion, success, confidence |
GET /api/v1/mlops/models/:id/baselines
A model's baselines, newest first, each with its inputs' names (not their distributions).
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
sort | string | No | -createdAt (default), modelVersion, sampleSize, baselinePerformance.value |
Each item: modelVersion, sampleSize (rows), evaluationKind (classification, regression or reviewed: how the model was scored when the baseline was built), labelledRows, baselinePerformance ({ metric, higherIsBetter, value }, the version's performance on its reference rows; see Performance), columns (which of actual, prediction, confidence the rows had), skippedFeatures (with why), source (the file), isActive, supersededAt, createdAt, createdBy.
GET /api/v1/mlops/models/:id/baselines/active
The active baseline of the model's active version, with its features' distributions and calibration. null when the version has no baseline.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
POST /api/v1/mlops/models/:id/drift-analyses
Start a drift analysis: the active version's production predictions in the window against its active baseline, with the algorithms enabled in the model's drift settings, plus prediction drift from actuals.
Scope: mlops:write
| Field | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
windowDays | number | No | Days of predictions to analyze (default 7) |
windowStart, windowEnd | string | No | An explicit window (ISO 8601) |
Response 200 OK
{
"data": { "jobId": "6ab95740...", "status": "deploying" },
"meta": { "requestId": "req_abc123" }
}
A version without a baseline is refused (400, saying to build one). A window with fewer predictions (with recorded inputs) than the model's minWindowSampleSize is not scored: the run fails saying so. Follow the run with the model's latestAnalysis or the status endpoint below.
GET /api/v1/mlops/models/:modelId/drift-analyses/:id
One analysis run by the jobId it returned: status (pending, deploying, running, completed, failed), its window, and error when it failed.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:modelId (path) | string | Yes | Registry model ID |
GET /api/v1/mlops/models/:id/drift-results/latest
A model's most recent drift result. null when it has never been analyzed.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
GET /api/v1/mlops/models/:id/drift-results
A model's drift results, newest first.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
status | string | No | ok, warning, alert or error |
sort | string | No | -calculatedAt (default), driftScore, sampleSize |
GET /api/v1/mlops/models/:id/drift-analyses
A model's analysis runs, newest first: its history, including runs that could not score anything. Each run has status (pending, running, completed or failed), triggeredBy (manual or scheduler), created_at and duration_ms. A completed run has its result_id and a summary (overallStatus, driftScore, featuresAnalyzed, featuresWithDrift, sampleSize); a failed run has its error, for example no production predictions in its window.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
q | string | No | Status, trigger or error text |
sort | string | No | -created_at (default), duration_ms |
GET /api/v1/mlops/models/:modelId/drift-results/:id/features
A result's rows, most drifted first. Get the resultId from a result's _id or a model's latestDrift.resultId.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:modelId (path) | string | Yes | Registry model ID |
rows | string | No | features (feature rows only) or dataset (dataset rows only) |
q | string | No | Feature name |
sort | string | No | -severity (default), score, featureName, order (the result's own order) |
GET /api/v1/mlops/models/:id/drift-settings
A model's drift settings. null when they were never saved.
Scope: mlops:read
| Parameter | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
{
"data": {
"modelId": "model_xyz",
"enabled": true,
"algorithms": {
"enabled": ["psi", "ks", "chi_square", "jensen_shannon", "wasserstein", "domain_classifier", "pca_reconstruction", "adwin", "cbpe"],
"overrides": { "psi": { "warningThreshold": 0.1, "alertThreshold": 0.2 } }
},
"performanceDropWarning": 0.05,
"performanceDropAlert": 0.1,
"minBaselineSampleSize": 30,
"minWindowSampleSize": 30,
"notifications": { "email": true, "recipients": ["ops@example.com"], "slack": "https://hooks.slack.com/services/..." },
"schedule": {
"enabled": true,
"frequency": "daily",
"timeWindowDays": 7,
"lastRunAt": "2026-09-27T00:00:03Z",
"nextRunAt": "2026-09-28T00:00:03Z",
"lastJobId": "6ab95740..."
}
},
"meta": { "requestId": "req_abc123" }
}
PATCH /api/v1/mlops/models/:id/drift-settings
Save a model's drift settings. The first save stores defaults for the fields not sent; later saves change only the fields sent.
Scope: mlops:write
| Field | Type | Required | Description |
|---|---|---|---|
:id (path) | string | Yes | Registry model ID |
enabled | boolean | No | Drift settings on; required for the schedule to run |
algorithms | object | No | { enabled: [names], overrides: { [name]: { warningThreshold, alertThreshold, params } } }. Built-in names: psi, ks, chi_square, jensen_shannon, wasserstein, domain_classifier, pca_reconstruction, adwin, cbpe; or a custom algorithm's name |
performanceDropWarning, performanceDropAlert | number | No | How much worse than the baseline the model's performance may get before prediction drift is a warning (default 0.05) or an alert (default 0.10), relative: 0.05 is 5% worse (lower accuracy or review score, higher mean absolute error). The warning must be smaller than the alert. A warning or alert makes the analysis's overall status at least that high, so it is announced. |
minBaselineSampleSize | number | No | Fewest reference rows a baseline needs (default 30) |
minWindowSampleSize | number | No | Fewest predictions with recorded inputs an analysis window needs to be scored: a whole number, at least 1 (default 30) |
notifications | object | No | { email: boolean, recipients: [addresses], slack?: Slack incoming webhook URL }. When an analysis finds drift (warning or alert), the model's owner is notified in Strongly; with email, the recipients are emailed (the owner when there are none); with slack, that webhook gets a message. An address that is not one, or a webhook pointing at localhost, an internal host or a private address, is refused with 400. An analysis that could not run sends no notification. |
schedule | object | No | { enabled, frequency: "hourly" | "daily" | "weekly", timeWindowDays } |
A scheduled analysis runs while both enabled and schedule.enabled are true, the first run at the scheduler's next check. Building a baseline with analyzeDaily turns on a daily schedule the same way.
Response 200 OK
The model's drift settings, as GET /api/v1/mlops/models/:id/drift-settings shows them.