Repository navigation
Conversation
Change map
Requirement: every prediction run must be traceable to its model, snapshot and source versions, carry score statistics comparable with the model's holdout, be measurable against labels that arrive later from any job, and appear in the experiment tracker next to the model's training runs. Existing behaviour: snapshot, training-table and model manifests, the artifact root and fit tracking exist (#50, #60), but a prediction run leaves only scorer files, bundles carry no score baseline, labels are generated only for training, and trackers record only fits. Smallest change: one DiskGraph statistics call with options and one DiskGraph join for scale; a typed prediction manifest written inside |
e73a404 to
704dcde
Compare
704dcde to
f57ba5f
Compare
f57ba5f to
2c47c90
Compare
2c47c90 to
ad1936e
Compare
ad1936e to
2b2748e
Compare
2b2748e to
0bb99c3
Compare
0bb99c3 to
a2defce
Compare
a2defce to
fe540e0
Compare
d872422 to
311ebb9
Compare
A prediction run could not be checked against what later happened, nor its score distribution against the model's holdout or an earlier run. Trainer.evaluate(run, snapshot=newer) regenerates the query's labels at the run's anchors on a newer snapshot, joins them with the run's predictions in DiskGraph (evaluate_prediction_parquet: both sides streamed from Parquet and hash-partitioned) and returns an EvaluationResult with fit-time-equivalent metrics, labelled fraction, maturity and an actuals Parquet directory. Trainer.score_drift returns a DriftReport: PSI against the bundle's holdout statistics (#64) and a previous run, and row/edge count changes between snapshots. PredictionResult.load_from_connector loads an exported Parquet directory for a later monitoring job: it requires the export's _SUCCESS marker, fetches only the run manifest up front, downloads parts on first read into a directory removed with the result, and records the destination without resolving it. Squashes the earlier review rounds of this PR onto #67. Verified: test_monitoring.py, test_prediction_manifest.py, test_query_from_snapshot.py, test_predictive_query.py (83 passed), kumo-core test_pquery.py + test_train_table.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311ebb9 to
5711ac2
Compare
A prediction run could not be checked against what later happened, nor its score distribution against the model's holdout or an earlier run. Trainer.evaluate(run, snapshot=newer) regenerates the query's labels at the run's anchors on a newer snapshot, joins them with the run's predictions in DiskGraph (evaluate_prediction_parquet: both sides streamed from Parquet and hash-partitioned) and returns an EvaluationResult with fit-time-equivalent metrics, labelled fraction, maturity and an actuals Parquet directory. Trainer.score_drift returns a DriftReport: PSI against the bundle's holdout statistics (#64) and a previous run, and row/edge count changes between snapshots. PredictionResult.load_from_connector loads an exported Parquet directory for a later monitoring job: it requires the export's _SUCCESS marker, fetches only the run manifest up front, downloads parts on first read into a directory removed with the result, and records the destination without resolving it. Squashes the earlier review rounds of this PR onto #67. Verified: test_monitoring.py, test_prediction_manifest.py, test_query_from_snapshot.py, test_predictive_query.py (83 passed), kumo-core test_pquery.py + test_train_table.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5711ac2 to
fc55422
Compare
A prediction run could not be checked against what later happened, nor its score distribution against the model's holdout or an earlier run. Trainer.evaluate(run, snapshot=newer) regenerates the query's labels at the run's anchors on a newer snapshot, joins them with the run's predictions in DiskGraph (evaluate_prediction_parquet: both sides streamed from Parquet and hash-partitioned) and returns an EvaluationResult with fit-time-equivalent metrics, labelled fraction, maturity and an actuals Parquet directory. Trainer.score_drift returns a DriftReport: PSI against the bundle's holdout statistics (#64) and a previous run, and row/edge count changes between snapshots. PredictionResult.load_from_connector loads an exported Parquet directory for a later monitoring job: it requires the export's _SUCCESS marker, fetches only the run manifest up front, downloads parts on first read into a directory removed with the result, and records the destination without resolving it. Squashes the earlier review rounds of this PR onto #67. Verified: test_monitoring.py, test_prediction_manifest.py, test_query_from_snapshot.py, test_predictive_query.py (83 passed), kumo-core test_pquery.py + test_train_table.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fc55422 to
1518bd8
Compare
A prediction run could not be checked against what later happened, nor its score distribution against the model's holdout or an earlier run. Trainer.evaluate(run, snapshot=newer) regenerates the query's labels at the run's anchors on a newer snapshot, joins them with the run's predictions in DiskGraph (evaluate_prediction_parquet: both sides streamed from Parquet and hash-partitioned) and returns an EvaluationResult with fit-time-equivalent metrics, labelled fraction, maturity and an actuals Parquet directory. Trainer.score_drift returns a DriftReport: PSI against the bundle's holdout statistics (#64) and a previous run, and row/edge count changes between snapshots. PredictionResult.load_from_connector loads an exported Parquet directory for a later monitoring job: it requires the export's _SUCCESS marker, fetches only the run manifest up front, downloads parts on first read into a directory removed with the result, and records the destination without resolving it. Squashes the earlier review rounds of this PR onto #67. Verified: test_monitoring.py, test_prediction_manifest.py, test_query_from_snapshot.py, test_predictive_query.py (83 passed), kumo-core test_pquery.py + test_train_table.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1518bd8 to
d081744
Compare
A prediction run could not be checked against what later happened, nor its score distribution against the model's holdout or an earlier run. Trainer.evaluate(run, snapshot=newer) regenerates the query's labels at the run's anchors on a newer snapshot, joins them with the run's predictions in DiskGraph (evaluate_prediction_parquet: both sides streamed from Parquet and hash-partitioned) and returns an EvaluationResult with fit-time-equivalent metrics, labelled fraction, maturity and an actuals Parquet directory. Trainer.score_drift returns a DriftReport: PSI against the bundle's holdout statistics (#64) and a previous run, and row/edge count changes between snapshots. PredictionResult.load_from_connector loads an exported Parquet directory for a later monitoring job: it requires the export's _SUCCESS marker, fetches only the run manifest up front, downloads parts on first read into a directory removed with the result, and records the destination without resolving it. Squashes the earlier review rounds of this PR onto #67. Verified: test_monitoring.py, test_prediction_manifest.py, test_query_from_snapshot.py, test_predictive_query.py (83 passed), kumo-core test_pquery.py + test_train_table.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Note
Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 5
packages/kumo-engine/kumo/engine/graph.py: 1
438-439:Minor · Stability & Availability · Quick win Validate nested ingest-stat values before conversion.
If a persisted stats file contains
nullor a scalar for either field,dict(...)raises instead of using the empty-mapping fallback. Normalize non-dictionary values to{}before conversion.Suggested fix
- 'rows': dict(stats.get('source_rows_by_table', {})), - 'edges': dict(stats.get('edge_counts', {})), + 'rows': (dict(stats.get('source_rows_by_table')) + if isinstance(stats.get('source_rows_by_table'), dict) + else {}), + 'edges': (dict(stats.get('edge_counts')) + if isinstance(stats.get('edge_counts'), dict) else {}),packages/kumo-engine/kumo/engine/_tracking.py: 1
501-527:Minor · Stability & Availability · Quick win Do not start a second standalone tracking thread while one is still running on the same tracker.
log_runstops waiting afterFINISHED_SECONDSand leaves the worker thread running in the background. The nextpredict,evaluateorscore_driftcall in the same process starts a new worker on the same tracker instance.MlflowTracking.startcalls_reset()and reassigns_parent,_client,_experiment_idand_guard.finishreads those same fields and resets them again.Two workers on one
MlflowTrackinginstance can therefore interleave:
- The metrics and tags of one run can go to the other run.
- A run can be terminated with the wrong status.
- One worker can close the other worker's HTTP-limit guard.
This can happen in a scheduled loop of predictions when the tracking server is slow. MLflow HTTP calls use a 30 s timeout with 2 retries, so one call can take longer than the 30 s wait. Predictions are not affected, but the recorded runs are incorrect.
Track the live worker per tracker. Skip with a warning, or wait, while that worker is still alive.
🔒️ Proposed fix
+_LIVE: 'weakref.WeakKeyDictionary[Any, threading.Thread]' = weakref.WeakKeyDictionary() +_LIVE_LOCK = threading.Lock() + def log_run(tracking: Any, hook: str, record: RunRecord) -> None: ... method = getattr(tracking, hook, None) if method is None or _rank() != 0: return + with _LIVE_LOCK: + busy = _LIVE.get(tracking) + if busy is not None and busy.is_alive(): + logger.warning('Experiment tracking is still recording an ' + 'earlier run; the %s run is not tracked', + record.tags.get('kumo.kind')) + return ... worker = threading.Thread(target=log, name='kumo-tracking', daemon=True) + with _LIVE_LOCK: + _LIVE[tracking] = worker worker.start()If a tracker class cannot hold weak references, key the registry on
id(tracking).packages/kumo-engine/kumo/engine/_monitoring.py: 1
179-187:Minor · Data Integrity & Integration · Quick win Give each evaluation its own
actualsdirectory.
_actuals_dirbuilds the directory from the run and the snapshot id only. Everyevaluate(run, snapshot=s)call therefore writes to the same path. Line 187 deletes that path before the join. This causes two problems:
- An earlier
EvaluationResult.actualssilently changes to the rows of the later call, with a differentEVALUATED_AT. If the later join fails after thermtree, the earlier result's directory is gone or partial, soexport()on the earlier result writes the wrong rows or fails.EvaluationResult.exportusesPath(self.actuals).nameinexport_id. Two different evaluations of the same run and snapshot therefore get the same export id.Two concurrent evaluations of one run against one snapshot also write into the same directory at the same time.
Add a unique per-evaluation suffix. The existing
uuid4import is enough.🐛 Proposed fix
- return root / 'actuals' / str(snapshot.id).replace('/', '_') + return (root / 'actuals' / str(snapshot.id).replace('/', '_') / + uuid4().hex)- shutil.rmtree(actuals, ignore_errors=True) + actuals.parent.mkdir(parents=True, exist_ok=True)In
EvaluationResult.export, the existingexport_idthen becomes unique per evaluation:- export_id=f'{self.prediction_id}-evaluation-' - f'{Path(self.actuals).name}') + export_id=f'{self.prediction_id}-evaluation-' + f'{Path(self.actuals).name}')
spillis derived fromactuals.parent. Keep it outside the new unique directory, or derive it fromactualsitself.packages/diskgraph/diskgraph-pybind/src/bp_evaluate.rs: 2
548-550:Minor · Data Integrity & Integration · Quick win Clear reused
actuals_dirin the public evaluator.The monitoring workflow clears its run-scoped directory before calling
diskgraph.evaluate_prediction_parquet, but the evaluator is also publicly exposed and accepts a caller-suppliedactuals_dir. It creates that directory without removing existingpart-*.parquetfiles. A reused directory can therefore retain stale rows when a later run has fewer partitions or no matches for a prior partition.Use a fresh directory, remove existing partition files before writing, or write to a staging directory and rename it after completion.
547-547:Minor · Data Integrity & Integration · Quick win Use a private spill subdirectory and clean it on every exit path.
evaluate_prediction_parquetwrites directly underspec.spill_dirand removes that directory only after successful evaluation. Calls that share a directory can overwrite each other’s files, and direct callers can lose pre-existing files. Errors can leave spill files behind at the Rust API boundary.The engine monitoring caller currently supplies a UUID-based directory and removes it in
finally, so this does not create a collision for that caller. The Rust API should still isolate its own files.🛡️ Suggested fix
std::fs::create_dir_all(&spec.spill_dir)?; + let spill = tempfile::Builder::new() + .prefix("evaluate-") + .tempdir_in(&spec.spill_dir)?; if let Some(dir) = &spec.actuals_dir { std::fs::create_dir_all(dir)?; } ... - spec.spill_dir.join(format!("{side}-{p:05}.arrow")), + spill.path().join(format!("{side}-{p:05}.arrow")), ... - let _ = std::fs::remove_dir_all(&spec.spill_dir); + drop(spill);Move
tempfilefrom[dev-dependencies]to[dependencies], or use an equivalent drop guard.
🧹 Nitpick comments: 1
packages/diskgraph/diskgraph-pybind/src/bp_col_stats.rs: 1
3076-3091:Trivial · Maintainability & Code Quality · Quick win The default-equivalence test compares one call with an identical call.
defaultandunsetboth callcompute_parquet_col_stats_with_optionswithColStatsOptions::default(). The assertion at Line 3091 therefore only proves that the function is deterministic. It does not prove the claim in the test name: that unset options produce the same output as the old code. The legacy entry point is gone, so a change to a default (for examplehistogramsorsummary_stats_by_class) would still pass this test.Pin the default output explicitly. One option is to compare it with the legacy-output test helper (Lines 1755-1765). Another option is to assert that the default-only keys are present, such as
HISTOGRAMandSUMMARY_STATS_BY_CLASS, and that noPERCENTILESorCATEGORY_COUNTS_CAPPEDkeys appear.♻️ Proposed assertion on the default key set
- let unset = compute_parquet_col_stats_with_options( - &paths, - &columns, - "v1", - MAX_CATEGORIES, - &ColStatsOptions::default(), - ) - .unwrap(); - assert_eq!((default.clone(), default_min.clone()), unset); + let default_keys = parse(&default); + assert!(default_keys.contains_key("SCORE__kumo.numerical__HISTOGRAM__False")); + assert!(default_keys.contains_key("SCORE__kumo.numerical__SUMMARY_STATS_BY_CLASS__False")); + assert!(!default_keys.keys().any(|k| k.contains("PERCENTILES") || k.contains("CATEGORY_COUNTS_CAPPED")));

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-mlflow-tracking
- File: .coderabbit.yaml
- Commit: 19e40fe
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
579be13d-96a4-4382-884d-69ff62bb9c51
Files selected for processing: 40-
examples/mlops/README.mdexamples/mlops/databricks/README.mdexamples/mlops/databricks/databricks.ymlexamples/mlops/fit_job.pyexamples/mlops/jobs.pyexamples/mlops/sagemaker/README.mdexamples/mlops/sagemaker/pipeline.pyexamples/mlops/score_job.pyexamples/mlops/snowflake/README.mdexamples/mlops/snowflake/register_jobs.pypackages/diskgraph/diskgraph-pybind/python/diskgraph/__init__.pypackages/diskgraph/diskgraph-pybind/src/bp_col_stats.rspackages/diskgraph/diskgraph-pybind/src/bp_evaluate.rspackages/diskgraph/diskgraph-pybind/src/lib.rspackages/kumo-core/src/kumo_core/pquery.pypackages/kumo-core/src/kumo_core/training/table.pypackages/kumo-core/tests/test_pquery.pypackages/kumo-core/tests/test_train_table.pypackages/kumo-engine/docs/mlops.mdpackages/kumo-engine/kumo/engine/__init__.pypackages/kumo-engine/kumo/engine/_manifest.pypackages/kumo-engine/kumo/engine/_mlops.pypackages/kumo-engine/kumo/engine/_monitoring.pypackages/kumo-engine/kumo/engine/_tracking.pypackages/kumo-engine/kumo/engine/graph.pypackages/kumo-engine/kumo/engine/integrations/mlflow.pypackages/kumo-engine/kumo/engine/integrations/snowflake.pypackages/kumo-engine/kumo/engine/pquery/prediction_table.pypackages/kumo-engine/kumo/engine/pquery/predictive_query.pypackages/kumo-engine/kumo/engine/trainer/_run_manifest.pypackages/kumo-engine/kumo/engine/trainer/trainer.pypackages/kumo-engine/test/test_job_templates.pypackages/kumo-engine/test/test_mlflow_tracking.pypackages/kumo-engine/test/test_mlops.pypackages/kumo-engine/test/test_monitoring.pypackages/kumo-engine/test/test_prediction_manifest.pypackages/kumo-engine/test/test_predictive_query.pypackages/kumo-engine/test/test_snapshot_manifest.pypackages/kumo-engine/test/test_snowflake_tracking.pypackages/kumo-engine/test/test_tracking_contract.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.
-
| arguments=[ | ||
| *flags, '--scratch-dir', '/opt/ml/processing/scratch' | ||
| ], | ||
| ), | ||
| depends_on=[fit], |
There was a problem hiding this comment.
Pass the fitted model’s identity to Predict.
If two pipeline executions overlap, a later Fit can move champion before the earlier execution starts Predict. depends_on=[fit] orders steps within one execution, but Predict loads the shared alias through score_job.py; it does not load that execution’s fitted model. The earlier execution can therefore score with the wrong model. Pass an immutable model version or bundle path from Fit to Predict. (docs.aws.amazon.com)
|
|
||
| def task_sql(*, task_name: str, warehouse: str, schedule: str, | ||
| timeout_ms: int, execute_ml_job_sql: str) -> str: | ||
| return (f"CREATE OR REPLACE TASK {task_name}\n" |
There was a problem hiding this comment.
Preserve the running state when re-registering tasks.
If an operator runs register_jobs.py again after resuming the tasks, CREATE OR REPLACE TASK replaces both tasks in a suspended state. Scheduled training and scoring then stop until an operator resumes them again. Record each existing task’s state and restore resumed tasks after replacement, while leaving new tasks suspended. (docs.snowflake.com)
There was a problem hiding this comment.
Actionable comments posted: 2

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-score-sketch
- File: .coderabbit.yaml
- Commit: 9390153
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
9127c09b-8bca-43b2-b8d5-89363bf581e9
Files selected for processing: 1-
examples/mlops/databricks/README.md
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.
-
| - `USE CATALOG`, `USE SCHEMA` and `SELECT` on the three tables; `CREATE TABLE` | ||
| and `MODIFY` on the schema for predictions. |
There was a problem hiding this comment.
Avoid schema-wide MODIFY for prediction output.
MODIFY on the schema is inherited by child tables. This gives the run-as principal write access to the three source tables, not only users_churn_predictions. Grant CREATE TABLE on the schema and MODIFY only on the prediction table after creation, or isolate predictions in a separate schema. (docs.databricks.com)
| - `CREATE MODEL` on the schema and `EXECUTE` on the model; moving an alias | ||
| needs rights on the model itself. |
There was a problem hiding this comment.
Document the privileges required for repeated model registration and alias updates.
kumo_train registers a new model version on each fit and updates champion, but this list grants only CREATE MODEL and EXECUTE. Unity Catalog requires model ownership or CREATE MODEL VERSION for new versions. Alias updates require model ownership plus USE SCHEMA and USE CATALOG. State these exact requirements, or require the run-as principal to own the model. (docs.databricks.com)
There was a problem hiding this comment.
Note
Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 1
packages/kumo-engine/kumo/engine/trainer/_run_manifest.py: 1
58-68:Minor · Data Integrity & Integration · Quick win The export sidecar records the run before the export result is known.
finishpassesreplace(manifest, outputs=records)toresult._exportas the sidecar. The sidecar written next to the exported Parquet parts therefore has these values:
status=None.timings.finished_at=None.outputsthat list every planned destination, including destinations that were never written.After the export,
finishwrites the final manifest to the local_manifest_path. The artifact-root copy made bystorealso uses that final manifest.The documented later-job workflow loads the run from the user's export with
PredictionResult.load_from_connector. In that workflow,loaded.manifest.statusisNone. Assumeembs/fails afterpreds/was written. Then thepreds/_provenance.jsonfile still lists both outputs as destinations. The prediction-runs section ofdocs/mlops.mdsays the manifest recordsstatus, timings, and the outputs as written. That description is not true for the copy that later jobs read.Fix it in one of these ways:
- Document that sidecars in
output_configdestinations are the pre-export record. Only the local manifest and the artifact-root manifest are final.- Rewrite each written destination's sidecar after the loop finishes. This requires a sink write for one file.
🧹 Nitpick comments: 1
packages/kumo-engine/kumo/engine/trainer/trainer.py: 1
335-339:Trivial · Performance & Scalability · Quick win
num_rowsdownloads every Parquet part of a loaded export.For a result from
load_from_connector,num_rowsiteratesself.files. That call runs_Export.files(), which copies all parts into local scratch. The class docstring saysnum_rowsreads bounded producer metadata.load_from_connectoralso says it fetches parts only whenfilesis first read. A later job that only checks the row count of a large export downloads the whole export.Read the footers on the remote filesystem instead.
♻️ Proposed change
if self._exported is not None: from pyarrow import parquet - return sum( - parquet.ParquetFile(path).metadata.num_rows - for path in self.files) + return self._exported.num_rows()# In _Export: def num_rows(self) -> int: from pyarrow import parquet from pyarrow.fs import FileSelector, FileType total = 0 for info in self.filesystem.get_file_info(FileSelector(self.root)): if info.type == FileType.File and info.path.endswith('.parquet'): with self.filesystem.open_input_file(info.path) as handle: total += parquet.ParquetFile(handle).metadata.num_rows return total

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-score-sketch
- File: .coderabbit.yaml
- Commit: 20b170a
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
fda43b38-5ba8-4b43-bba2-be1b36b518cb
Files selected for processing: 15-
packages/diskgraph/diskgraph-pybind/python/diskgraph/tt_artifacts.pypackages/diskgraph/diskgraph-pybind/python/tests/test_tt_artifacts_row_warnings.pypackages/kumo-core/src/kumo_core/prediction.pypackages/kumo-engine/docs/mlops.mdpackages/kumo-engine/kumo/engine/_manifest.pypackages/kumo-engine/kumo/engine/_monitoring.pypackages/kumo-engine/kumo/engine/_tracking.pypackages/kumo-engine/kumo/engine/pquery/prediction_table.pypackages/kumo-engine/kumo/engine/trainer/_run_manifest.pypackages/kumo-engine/kumo/engine/trainer/trainer.pypackages/kumo-engine/test/test_mlflow_model.pypackages/kumo-engine/test/test_monitoring.pypackages/kumo-engine/test/test_prediction_manifest.pypackages/kumo-engine/test/test_predictive_query.pypackages/kumo-engine/test/test_query_from_snapshot.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.
-
There was a problem hiding this comment.
Note
Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 3
packages/kumo-engine/kumo/engine/pquery/prediction_table.py: 2
314-317:Minor · Functional Correctness · Quick win Read the entity column for skipped-entity samples.
When DiskGraph reports a
null_timestampfailure onTIMESTAMP, this code readsTIMESTAMPintoskipped_sample. The sample then contains a null timestamp instead of the affected entity ID. ReadENTITY1at the reported row location for every condition so the manifest can identify that row. DiskGraph reports both the failure column and sample locations. (raw.githubusercontent.com)
312-317:Minor · Functional Correctness · Quick win Read the reported row as a row-group-relative index.
The sample format is
<part> row group <n> row <m>, so<m>is the row index inside row group<n>._sample_idssubtracts the preceding row-group row counts wheneverrow >= start. For a group-relative index, that subtraction reads the wrong row. In the example above, row 6 of group 1 (after a five-row group 0) reads row 1 instead of row 6. The recordedskipped_sampleID is then wrong.- start = sum(reader.metadata.row_group(index).num_rows - for index in range(group)) column = reader.read_row_group( group, columns=[entry['column']]).column(0) - ids.append(column[row - start if row >= start else row] - .as_py()) + ids.append(column[row].as_py())packages/kumo-engine/docs/mlops.md: 1
133-133:Minor · Functional Correctness · Quick win Document the all-skipped failure case.
If none of the supplied entities survives validation, custom prediction-table generation fails instead of returning a warning and an empty prediction result. Replace “never refused” with a statement that distinguishes partial skips from an all-skipped input. The prediction-table path rejects a zero-row result. (raw.githubusercontent.com)

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-score-sketch
- File: .coderabbit.yaml
- Commit: cfdd76f
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
0b03304f-1351-471f-92d0-9357ec6c57a6
Files selected for processing: 2-
packages/kumo-engine/docs/mlops.mdpackages/kumo-engine/kumo/engine/pquery/prediction_table.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.
-
On a live Snowflake account, tracking a prediction run failed with 'Run "<prediction id>" does not exist in Experiment' once the run logged its _provenance.json artifact. SnowflakeTracking named the run with the quoted prediction id, and snowflake-ml puts the run name unquoted into the snow://experiment/.../versions/<run> artifact URI, where a quoted name is not found. Prediction and monitoring runs are now named by the record name as an unquoted identifier: characters other than letters, digits and _ become _, with a leading _ when it starts with a digit (kumo.run_id still holds the exact id). The experiment fake refuses artifacts for a run whose name needs quoting, and the contract compares names in the adapter's form. The Model Monitor recipe quotes the mixed-case True_PROB column, which Snowflake otherwise resolves as TRUE_PROB and reports not found. Verified live: fit, prediction, drift and evaluation runs recorded in Snowflake Experiment Tracking; a Model Monitor created with the quoted recipe refreshed and returned ROC_AUC, COUNT and drift metrics. Tracking tests pass (27 passed, 8 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Series: kumo-engine MLOps series: every snapshot, training table, model and prediction run carries a self-contained _provenance.json;
with kumo.mlops(...)stores each artifact under one artifact root, tracks fits and prediction runs in MLflow or Snowflake and registers models; volumes and stages can hold files; prediction runs are monitored for drift and realized performance.Stack: #53 → #112 → #50 → #60 → #64; #114 on #112; #115 on #64
Merge order: one linear stack; merge bottom to top (#53 first); #114 sits on #112 and merges any time after it; #115 (draft) sits on #64 and merges after it
This PR: Adds records and monitoring for prediction runs: a manifest with output score statistics for every
Trainer.predict, a holdout score baseline in every model manifest,Trainer.evaluateandTrainer.score_driftfor realized performance, score drift and input-feature drift (from the snapshots' build-time column statistics), a record of the entities scoring skipped, and tracking of prediction and monitoring runs in MLflow or Snowflake.Summary
This PR adds records and monitoring for prediction runs: a manifest with output score statistics for every
Trainer.predict, a holdout score baseline in every model manifest,Trainer.evaluateandTrainer.score_driftfor realized performance, score drift and input-feature drift (from the snapshots' build-time column statistics), a record of the entities scoring skipped, and tracking of prediction and monitoring runs in MLflow or Snowflake. It also ships job templates (examples/mlops) that run fit and scoring on Databricks Jobs, SageMaker Pipelines and Snowflake Tasks.It is the top of the stack and builds on two PRs below it: #50 (snapshot, training-table and model manifests,
kumo.MLOpsand the artifact root) and #60 (theTrackingprotocol, the MLflow and Snowflake trackers, model registration). No PR builds on this one.Problem. Teams running batch scoring in production need to answer questions like these:
Today none of these can be answered with kumo-engine without hand-written SQL that re-implements the query.
Cause. A prediction run leaves only the scorer's selection and attempt files in local scratch, and an export directory holds only Parquet parts and the export markers:
None of these record the model, the snapshot, the scoring options or where they came from, the destinations, timings, or any statistic of the scores. The model bundle has no baseline either: its holdout predictions stay in the trainer's scratch, outside the bundle, and
metrics.jsonholds aggregate quality, not the score distribution. Labels can be generated only for training, and generation refuses outcome tables that could not train a model (one class, fewer classes thanTOP K). TheTrackingprotocol (#60) records fits only.Change.
The examples below come from one real CPU run: a synthetic
users(500) /ordersgraph andPREDICT COUNT(orders.*, 0, 7, days) > 0 FOR EACH users.ID. The model was fitted on a snapshot whose orders stop at 2024-03-31 (2,984 orders) insidekumo.mlops(MLOps(artifact_root=..., tracking=MlflowTracking(experiment_name="users-churn"))). The run was scored on that snapshot, exported to<out>/preds, loaded back withload_from_connector, and evaluated on a later snapshot with all 4,000 orders. A second run scored the later snapshot. Ids are shortened, percentiles elided, and paths replaced. The model was trained for a few steps, so the numbers are real but say nothing about model quality.1. Holdout baseline in the model manifest
After fit,
_provenance.json(ModelManifest, #50) gainsholdout: statistics of the scores the published model gave its own holdout rows.fitorrefit_trainvalmodel named byfit_mode. Arefit_fullmodel has no holdout of its own and gets noholdoutkey; another model's scores are never recorded in its place. Link prediction writes no holdout frame, so it has none either.truncated: true when holdout rows were dropped to fit the holdout's 1 GiB in-memory cap; null when that could not be read.rows: the number of holdout predictions (entities for multiclass and multilabel).columns: one summary per score column.countcounts finite values only (null, NaN and infinite scores are not counted).mean,min,maxandpercentiles(99 values, q01..q99, approximate, from DiskGraph's t-digest) are all over finite values. Which column depends on the task:True_PROB, pluslabel_positive_rate, the share of labelled holdout rows with a positive label;TARGET_PREDandTARGET;top_score, each prediction's highest score. A prediction is a run of consecutive rows with equal non-class columns (entity, and anchor when present), with its rows in any score order.result.manifest.holdout(ortrainer.result.manifest.holdout), aHoldoutStats(truncated,rows,columns,label_positive_rate).holdoutis inModelManifest.EMBED_OMITS, so manifests that embed the model leave it out. A model derived withwith_scoring_defaultsscores identically and keeps its parent'sholdout.2. The prediction manifest
Every
Trainer.predictwrites_provenance.json(PredictionManifest, public askumo.engine.PredictionManifest) into the run directory. Exports to Parquet directories get a copy next to the parts (the leading underscore keeps Spark and Hive readers off it). The manifest of the first run:{ "kind": "prediction", "schema_version": 1, "id": "5a549ffa…-predict-da72a70e…", "created_at": "2026-10-26T14:50:15+00:00", "environment": {"kumo_engine": "…", "build_commit": "8ed78eee", "diskgraph": "0.1.0", "diskgraph_build_commit": "26cc3192490d", "torch": "2.12.0+cu130", "python": "3.14.7"}, "parents": { "model": { "kind": "model", "id": "8c0c3459…", "fit_mode": "refit_trainval", "fit": {"started_at": "2026-10-26T14:47:58+00:00", "finished_at": "2026-10-26T14:50:11+00:00", "seconds": 133.489}, "parents": {"training_table": {"kind": "training_table", "id": "tt-aba48cfa…", "query": "PREDICT COUNT(orders.*, 0, 7, days) > 0 FOR EACH users.ID", "task_type": "binary_classification", "split_seed": null, "generation_plan": {"…": "…"}, "parents": {"snapshot": {"kind": "snapshot", "id": "9b04d531…@83364c76319c05b3", "…": "…"}}}}}, "snapshot": { "kind": "snapshot", "id": "9b04d531…@83364c76319c05b3", "record_sources": true, "parents": {"source_tables": { "orders": {"connector": "LOCAL_FILE", "location": "<data>/orders.parquet", "version": "1793022475", "version_kind": "timestamp"}, "users": {"connector": "LOCAL_FILE", "location": "<data>/users.parquet", "version": "1793022475", "version_kind": "timestamp"}}}}}, "prediction_table": {"query_sha256": "69dab700…", "source": "pql", "anchors": ["2024-03-30T00:00:00+00:00"], "rows": 500, "requested_rows": 500, "scored_rows": 500, "skipped_rows": 0, "skipped_sample": []}, "scoring": {"binary_classification_threshold": 0.5, "threshold_source": "argument", "num_classes_to_return": null, "count_source": "none", "num_workers": 1}, "outputs": [{"artifact": "predictions", "destination": {"connector": "LOCAL_FILE", "location": "<out>/preds"}}], "status": "succeeded", "timings": {"started_at": "2026-10-26T14:50:11+00:00", "finished_at": "2026-10-26T14:50:15+00:00"}, "stats": {"predictions": {"rows": 500, "predicted_positive_rate": 0.0, "columns": {"True_PROB": {"count": 500, "max": 0.35972, "mean": 0.33446, "min": 0.30316, "percentiles": [0.30790, 0.30984, "…", 0.35029]}}}} }id: the prediction id,PredictionResult.id; run directories and<root>/predictions/<id>/are per id.parentsembeds two manifests without their statistics and later-changing fields:model: the model manifest withoutholdoutandtracking, with its training table and that table's snapshot embedded. Its own warm-start or derived-from models are id stubs, so size stays bounded across retrains.snapshot: the scoring snapshot's manifest, with each source table's connector, location and version. When it equals the training snapshot, both copies are kept.{kind, id, reconstructed: true}.PredictionResult.bundle_idand.snapshot_idread the two ids from here.prediction_table: the query's SHA-256 (the text is in the embedded training table),source(pqlwhen generated from the query,customwhen built from a source table), the anchor times in ISO UTC, and the row count.entities=, a suppliedprediction_table=, the MLflow pyfunc model) whose entity is not in the scoring snapshot used to be dropped with only a DiskGraph log line that spoke of training rows. They are still skipped, never refused, and now recorded:prediction_table.requested_rows,scored_rows,skipped_rows(0 for a generated population, never missing),skipped_sample(up to 5 skipped entity ids per reason, read from the source rows DiskGraph names) andskip_reason:not in snapshot, ornull entity id,null anchor time,not a canonical node in the snapshot(DiskGraph's other predict-path skip conditions), joined with counts when several apply;PredictionResult.requested_rows,.scored_rows,.skipped_rows;UserWarning,Prediction skipped 4 of 14 requested entities (28.6%): not in snapshot, which replaces DiskGraph's log line on the predict path;predictions.skipped_rowsandpredictions.skipped_fractionon the prediction run.predictreturns a frame, and each call's scratch, manifest included, is removed when it returns).scoring: the effective options and where each came from:argument(passed by the caller),bundle(the model's scoring defaults),default(filled in by the engine),query(from the PQL), ornone.outputs: the destinations ofpredict(output_config=...)as{connector, location}, with the same location rules as source tables (absolute path, full object-store URI, a volume or stage path as given, or a fully qualified warehouse table). A destination is recorded once its export completes, so a failing export leaves only the completed ones;nullmeans the outputs could not be described. A laterresult.export(...)copies the files and the manifest as they are and records nothing new.stats: the row count per artifact and, for predictions, the same score summary as the holdout baseline, computed by DiskGraph over the output Parquet:True_PROB, pluspredicted_positive_rateat the threshold;TARGET_PRED;top_score, the highest score per entity and anchor.status:succeeded, orfailedwhen theoutput_configexport raised; the manifest is written either way andpredictre-raises.timings: start and finish of the call.PredictionResult.manifestreturns it (run.manifest.stats['predictions'].columns), withPredictionParents,PredictionTableRecord,RunScoring,PredictionOutput,OutputDestination,RunTimingsandArtifactStatsinkumo.engine._manifest.PredictionManifest.EMBED_OMITSisoutputs,stats,status,timings.Order inside
predict: score, build the manifest, run theoutput_configexport (recordingoutputs), write the manifest, then with MLOps on store<root>/predictions/<id>/(whose manifest includes thoseoutputs), then track the run. The store is skipped if the export raised, and a failed store only warns.3. Realized performance:
Trainer.evaluateevaluateregenerates the query's labels at the run's anchors (read from its manifest) on the given snapshot with DiskGraph training-table generation, then joins them with the run's prediction Parquet in DiskGraph. The newdiskgraph.evaluate_prediction_parquetstreams both sides by row group and hash-partitions them onENTITY(andANCHOR_TIMESTAMPwhen the run has several anchors). It joins at most 8 partitions of about 1M rows at a time, each spilled to Arrow IPC first, and accumulates the metrics; nothing is collected into pandas. Metrics use the fit-timetest_*definitions:aurocandapoverTrue_PROB, exact (torchmetrics definitions, ties counted half, scores compared at float32);maeandrmseoverTARGET_PRED;accof the top-scoredCLASSper entity and anchor;map@kandprecision@kas PyG'sLinkPredMAP/LinkPredPrecision, wherekis the number of classes returned per entity and anchor, over predictions with at least one target (logged to MLflow asmap_at_kandprecision_at_k);EvaluationResultof the first run on the later snapshot (dataclasses.asdict):{ "prediction_id": "5a549ffa…-predict-da72a70e…", "bundle_id": "8c0c3459…", "snapshot_id": "9b04d531…@e27d964a25bec319", "task_type": "binary_classification", "anchors": ["2024-03-30T00:00:00+00:00"], "metrics": {"ap": 0.36784, "auroc": 0.51919}, "rows": 500, "labelled": 500, "labelled_fraction": 1.0, "matured": true, "bundle_matches": true, "reasons": {}, "actuals": "<scratch>/evaluations/5a549ffa…-predict-da72a70e…/actuals/9b04d531…@e27d964a25bec319" }actualsholds 500 rows such as{"ENTITY": 1, "ANCHOR_TIMESTAMP": "2024-03-30 00:00:00", "TARGET": true, "EVALUATED_AT": "2026-10-26 14:50:22.363663571+00:00", "True_PROB": 0.33915, "TARGET_PRED": false}.Nothing is refused; the fields say how far to trust a number:
snapshot_id: the snapshot the labels were read from.prediction_idandbundle_idare the run's own.matured:Falsewhile the label window ends after the earliest of the target tables' latest event times, a conservative proxy for label completeness. An immature run still returns its metrics, over the outcomes observed so far, and warns (UserWarning) that its labels are not mature yet. A static query reads labels as of the snapshot:anchorsis empty,maturedisNone, and the evaluated rows carry the run's prediction time asANCHOR_TIMESTAMP.bundle_matches: whether the run's model is the trainer's. Evaluating a run with a different model is allowed and flagged.labelled_fraction: labelled predictions / run predictions. For link prediction, the share of entities with any future link.actuals: a Parquet directory of the evaluated rows, next to the run (under<scratch>/evaluations/<id>/for a run loaded from an export):ENTITY,ANCHOR_TIMESTAMP,TARGET, the matched prediction (binaryTrue_PROBand the predicted labelTARGET_PRED; regressionTARGET_PRED; multiclass the topCLASSand itsSCORE; link prediction none), andEVALUATED_AT, one UTC timestamp for the whole call.Nonewhen no labels were joined.reasons: when a step cannot be computed (no anchors, no labels, unsupported task, a failed step), the affected field isNoneor empty andreasonssays why.EVALUATED_ATexists because warehouse monitors profile only rows whose timestamp falls in a recent lookback window, and a run's anchors are older than that by at least the label window. On a live Databricks Data Quality monitor over evaluated rows, keying onANCHOR_TIMESTAMP(59 days old) left the metric tables empty; rows timestamped 1–5 days old produced 60 profile rows and 48 drift rows. Both warehouse recipes key onEVALUATED_AT.EvaluationResult.export(PredictionOutputConfig(...))copies the evaluated rows to the config'spredictionsdestination, a table or a directory. A Parquet directory also gets_evaluation.json(every other field), and the newEvaluationResult.load_from_connector(connector, path=...)loads the evaluation back with its rows, so every artifact has oneexport(<Kind>OutputConfig)and oneload_from_connector;docs/mlops.mdtabulates them. Exporting a prediction run or an evaluation again to a directory that already holds it complete rewrites nothing (anOVERWRITEdestination is no longer emptied first in that case; #50 skips the upload).4. Score drift:
Trainer.score_driftscore_driftcompares a run's score summary with the model's holdout and, optionally, with a previous run, with no pass over the data. Each side storesmin,q01..q99andmax: 101 monotone knots of a piecewise-linear CDF.psikswassersteinwassersteinis in score units: comparable over time for probabilities, scale-dependent for regression. With both snapshots passed,input_changesholds per-table row and per-edge count changes between the training and the scoring snapshot (reverse edges skipped).DriftReportof the second run (scored on the later snapshot), against the holdout and against the first run:{ "prediction_id": "5a549ffa…-predict-2633f84b…", "bundle_id": "8c0c3459…", "score": "True_PROB", "psi": 0.26605, "ks": 0.18113, "wasserstein": 0.00385, "holdout_truncated": false, "bundle_matches": true, "previous_prediction_id": "5a549ffa…-predict-da72a70e…", "previous_psi": 0.17006, "previous_ks": 0.16781, "previous_wasserstein": 0.00323, "input_changes": {"tables.orders.rows": [2984, 4000], "edges.orders_USER_ID_to_users_ID": [2984, 4000]}, "reasons": {} }Missing records (a run without a manifest, a model without
holdout, snapshots not passed) leave the affected fieldsNonewith areasonsentry.holdout_truncatedrepeats the holdout'struncatedflag.Input features.
DriftReport.featurescompares, for each table and column the model encodes (the bundle's encoders), the column statistics DiskGraph already wrote into each snapshot'scol_stats.jsonwhen it was built: no new pass over data and no Rust change. The training snapshot is the one passed, or the one named in the model manifest's embedded training table, found in the local snapshot store; the scoring snapshot likewise from the run's manifest. From a toyuserstable whoseAGEmean moved from 40 to 55 and whosePLANmix moved from 60/30/10 to 10/30/60 (values rounded;training,scoringandrelativeomitted):{"users": { "AGE": {"type": "numerical", "standardized_mean_shift": 3.361, "delta": {"mean": 15.311, "std": 0.535, "min": 14.031, "max": 18.258, "q25": 14.942, "q50": 15.108, "q75": 15.849, "skewness": -0.041, "kurtosis": -0.268, "null_rate": 0.0, "num_unique": -3.0}}, "PLAN": {"type": "categorical", "psi": 1.522, "js": 0.248, "category_counts_capped": false, "delta": {"null_rate": 0.0, "num_unique": 0.0}}}}trainingandscoringvalues,deltaandrelative(to the training value) for mean, std, min, max, q25, q50, q75, skewness, kurtosis, null rate and distinct count, plusstandardized_mean_shift(mean change over the training std;Nonewhen that std is 0).category_counts_cappedwhen either side's counts were truncated.min_secs,max_secs).featurespartial or empty with areasons['features']entry; nothing raises.features.shift.<table>.<column>,features.psi.<table>.<column>), the largest of each per table for up to 10 tables, at most 40 metrics with names reduced to letters, digits,_,-and.; the full comparison is the run'sfeature_drift.jsonartifact (MLflow).5. Monitoring from a later job
PredictionResult.load_from_connector(connector, path=...)takes any connector whose sink holds files at the path (S3, GCS, local, or a volume or stage). It requires the export's_SUCCESSmarker and fetches only its_provenance.json. The Parquet parts are downloaded in parallel whenfilesis first read (byevaluate, for example), into a scratch directory removed when the result is garbage-collected, soscore_driftfrom another job fetches nothing else.6. Tracking prediction and monitoring runs
With
kumo.MLOps(tracking=...)(a block ormlops=), eachpredictis recorded as one run in the tracker's experiment, and eachevaluateandscore_driftcall as a new monitoring run. Both go through two optionalTrackinghooks,log_prediction_run(record)andlog_monitoring(record). They run on a tracking thread, only on global rank 0, for up to 30 s; errors are logged, never raised. The MLflow and Snowflake trackers implement both hooks as their ownstart(record)thenfinish(record, error), so params, metrics, tags, inputs and artifacts go through the same code as a fit run._tracking.prediction_record(manifest, error=None)is the one mapping from a prediction manifest to aRunRecord:scoringoptions plusanchors;Noneoptions are left outprediction_table.rows,<artifact>.rows,predictions.predicted_positive_rate(binary), and per score columncount,mean,min,maxand seven percentile levelsq01,q05,q25,q50,q75,q95,q99(the manifest keeps all 99)kumo.kind=prediction,kumo.run_id(the prediction id),kumo.bundle_id, andkumo.errorwhen the export failed_provenance.json, the full manifestIf the
output_configexport raises, the run is still recorded and ended as failed withkumo.error, andpredictraises the export's error. A monitoring run is namedevaluate-<prediction id>ordrift-<prediction id>, taggedkumo.kind=monitoring,kumo.prediction_run=<prediction id>andkumo.bundle_id, and logs the metrics, the drift statistics and the input count changes (input.<key>.before/.after). A new run is used because Snowflake cannot reliably reopen a finished run. MLflow also tags the prediction run withkumo.monitored=<monitoring run id>.The two runs above, read back with
MlflowClient.search_runsfrom a local store (rounded):{"run_name": "5a549ffa…-predict-2633f84b…", "status": "FINISHED", "tags": {"kumo.kind": "prediction", "kumo.run_id": "5a549ffa…-predict-2633f84b…", "kumo.bundle_id": "8c0c3459…", "kumo.monitored": "41a503bf…"}, "params": {"binary_classification_threshold": "0.5", "threshold_source": "argument", "count_source": "none", "num_workers": "1", "anchors": "[\"2024-04-29T00:00:00+00:00\"]"}, "metrics": {"prediction_table.rows": 500.0, "predictions.rows": 500.0, "predictions.predicted_positive_rate": 0.0, "predictions.True_PROB.count": 500.0, "predictions.True_PROB.mean": 0.33763, "predictions.True_PROB.min": 0.30943, "predictions.True_PROB.max": 0.35574, "predictions.True_PROB.q01": 0.31291, "predictions.True_PROB.q05": 0.32228, "predictions.True_PROB.q25": 0.33396, "predictions.True_PROB.q50": 0.33902, "predictions.True_PROB.q75": 0.34256, "predictions.True_PROB.q95": 0.34757, "predictions.True_PROB.q99": 0.34999}} {"run_name": "monitor-5a549ffa…-predict-2633f84b…", "status": "FINISHED", "tags": {"kumo.kind": "monitoring", "kumo.run_id": "5a549ffa…-predict-2633f84b…-monitor-f8d5f68f…", "kumo.prediction_run": "5a549ffa…-predict-2633f84b…", "kumo.bundle_id": "8c0c3459…"}, "params": {"prediction_id": "5a549ffa…-predict-2633f84b…", "bundle_id": "8c0c3459…", "score": "True_PROB", "previous_prediction_id": "5a549ffa…-predict-da72a70e…"}, "metrics": {"psi": 0.26605, "ks": 0.18113, "wasserstein": 0.00385, "previous_psi": 0.17006, "previous_ks": 0.16781, "previous_wasserstein": 0.00323, "input.tables.orders.rows.before": 2984.0, "input.tables.orders.rows.after": 4000.0, "input.edges.orders_USER_ID_to_users_ID.before": 2984.0, "input.edges.orders_USER_ID_to_users_ID.after": 4000.0}}The fit run in the same experiment carries the same
kumo.bundle_id, so filtering on it returns the training run, every prediction run and every monitoring run of that model.kumo.kindseparates them. Every run of a model has the same metric names, sopredictions.True_PROB.q50orpredictions.predicted_positive_ratecan be charted across runs._become_; e.g.EVALUATE_<ID>/DRIFT_<ID>). A quoted name broke snowflake-ml's artifact URI (Run "<id>" does not existon a live account), sokumo.run_idkeeps the exact id.kumo.*tags become params, as for fits. An existing run is never resumed: a taken name gets a suffix. If the process'sExperimentTrackinginstance is in use, the run is not recorded there, with a warning.run_nameandtagsapply to fit runs only. The tracker'srun_idattribute is the most recent tracked run, fit or prediction.7. Warehouse model monitors
docs/mlops.md("Monitoring prediction runs") gives two recipes over the evaluated rows, exported to a warehouse table withevaluation.export(...). Neither platform monitors link prediction or ranking outputs.model_idcolumn with the bundle id, thenw.data_quality.create_monitor(Monitor(..., DataProfilingConfig(..., inference_log=InferenceLogConfig(problem_type, prediction_column="TARGET_PRED", label_column="TARGET", model_id_column="model_id", timestamp_column="EVALUATED_AT", granularities=[1 day]))))andw.data_quality.create_refresh(...). The inference log has no probability column, soTrue_PROBis profiled as an ordinary column.CREATE MODEL MONITOR ... FUNCTION=predict SOURCE=<evaluated rows> BASELINE=<holdout evaluated rows> TIMESTAMP_COLUMN=EVALUATED_AT ID_COLUMNS=(ENTITY) PREDICTION_SCORE_COLUMNS=("True_PROB") ACTUAL_CLASS_COLUMNS=(TARGET), with the per-task column choices in the doc. The model must be a Snowflake Model Registry version (kumo.MLOps(registry=ModelOutputConfig(sf, location=...))). The baseline isholdout_df()with anEVALUATED_ATcolumn added; the monitor needsTIMESTAMP_NTZ, so the source table declaresEVALUATED_AT TIMESTAMP_NTZ(the export writes UTC). Verified live: on a binary classification registry version the monitor was created, refreshed and returnedROC_AUC,COUNTand drift metrics;True_PROBmust be double-quoted (unquoted, Snowflake looks forTRUE_PROB).8. Job templates
fittask:fit_job.pyon a single-node job cluster (Databricks Runtime 17.3 LTS ML or later, GPU or CPU flavour; the runtime's torch, pyarrow (16 through 24 accepted) and MLflow are kept)scoretask: dailyscore_job.pyinto a UC table (schedule created paused)--registry mlflow:MLOps(tracking=MlflowTracking(), registry=ModelRegistryLocation("<catalog>.<schema>.users_churn", alias="champion")); scoring loadschampionTrainingStepProcessingStep--registry mlflowagainst SageMaker managed MLflow--registry snowflake:MLOps(tracking=SnowflakeTracking(connector.session(), ...), registry=ModelOutputConfig(...))python fit_job.pypython score_job.pymodel: models/<bundle id>; scoring loads--model-path models/<bundle id>with kumo.mlops(cfg), so the snapshot, training table, model and each prediction run are stored under--artifact-root. Settings are flags (--artifact-root,--source,--catalog/--schema/--warehouse,--registry,--model-name,--model-path,--snapshot,--predictions-table,--table-prefix,--query,--num-experiments,--scratch-dir,--on-error); credentials come from the platform identity, and only MLflow's own settings (MLFLOW_TRACKING_URI, ...) stay environment variables.kumo.MLOps(on_error="raise")unless--on-error warnis passed, so a fit whose model is stored but not registered (or a run that is not tracked) fails the job instead of leaving the score step on the previous version. The Databricks bundle, the SageMaker pipeline and the Snowflake registration take the model name, the predictions table, a source-table prefix andon_erroras variables or flags with defaults; the bundle's defaultroot_pathis under the deploying user's folder. Every score passes a 0.5 binary threshold unless the model carries scoring defaults, because the template query is binary.champion, and each score loaded it. The Databricks fit trained on a GPU (a GPU ML runtime on ag5.xlarge).test_job_templates.pychecks the scripts locally. The Snowflake templates are not run.spark_python_taskwhoseparametersare the flags. Aspark_python_taskruns a script without__file__, so the scripts find their directory fromsys.argv[0].Supporting changes
DiskGraph
compute_col_stats_for_parquet(the existing Rust batch-prediction statistics call) gains keyword options whose defaults keep today's output byte-identical:percentiles=TrueemitsPERCENTILES(q01..q99) from each numerical column's t-digest.summary_stats_by_class=Falseskips the per-class map, which dominates cost with millions of classes.histograms=Falseskips the second read that builds histograms.category_counts_limit=Nkeeps the top NCATEGORY_COUNTSand recordsCATEGORY_COUNTS_CAPPED.max_per=[keys]computes statistics over each column's maximum within each run of equal key values. Runs are reduced while streaming each row group, and a run that crosses row groups or files is joined in order afterwards, so the result is exact. It needs numerical columns,histograms=Falseandsummary_stats_by_class=False.Row-group partial statistics are merged in fixed chunks in file and row-group order, so the output does not depend on thread count or scheduling. The Rust
compute_parquet_col_statsis replaced bycompute_parquet_col_stats_with_options(ColStatsOptions::default()gives the same output).DiskGraph
evaluate_prediction_parquet(prediction_paths, label_paths, spec_json)(newbp_evaluate.rs): the streaming join and metrics above. The spec'sprediction_columnslists prediction columns copied next to each matched label (for grouped tasks, from the top-scored row), andconstant_timestampsadds constant UTC timestamp columns (EVALUATED_AT, and a static query's prediction time asANCHOR_TIMESTAMP). It reuses the projected row-group reader of the statistics call.Engine:
kumo.engine._score_statsowns the score summaries for both the holdout and prediction runs;kumo.engine._monitoringownsEvaluationResult,DriftReport,evaluate,score_driftand monitoring-run records;kumo.engine.trainer._run_manifestowns building, writing, storing and tracking the prediction manifest._tracking.log_run(tracking, hook, record)logs one standalone run through an optional hook.PredictionTable.source(pqlorcustom) is new.Artifact root:
predictionjoins theexportkinds (kumo.MLOps(export=...)), so each run is stored under<root>/predictions/<id>/.GraphSnapshot._countsreads row and edge counts from DiskGraph'singest_stats.jsonforinput_changes.kumo-core:
generate_train_table(..., validate_targets=True);Falsekeeps outcome tables that could not train a model, so labels can be read.pquery._target_maximum(query, index_path, min|max)sits behindlatest_temporal_timestamp;evaluateuses its minimum formatured.PredictiveQuery: one private_generatehelper for training-table and label generation, and a private_label_partscontext manager that keeps label parts on disk for the join.Docs:
docs/mlops.mddescribes the holdout record (Models), prediction runs, prediction and monitoring tags (Experiment tracking), and a new "Monitoring prediction runs" section.Table outputs, scheduled jobs and docs
PredictionOutputConfig.write_modedefaults to append when a destination is a warehouse table and to overwrite for file destinations; an explicitWriteModeis kept. A daily scoring job therefore keeps every day's rows instead of replacing the table. Table outputs (andevaluation.exportto a table) carryPREDICTION_ID(the run id) andBUNDLE_ID(the model), so rows tie to a run and a model; the Databricks monitor recipe usesmodel_id_column="BUNDLE_ID"and no longer adds a column by hand. A table created before these columns existed takes oneWriteMode.OVERWRITE(or anALTER TABLE ... ADD COLUMNS) first.on_error="raise"(from Record fits in experiment tracking and publish models to registries #60) also covers prediction-run tracking, after the outputs and manifest are written, and monitoring-run tracking.UserWarningwith the full error, pointing at thepredict,evaluateorscore_driftcall; with"raise", oneRuntimeErrorand no warning before it. Both hooks run through the tracker's client and credentials (MlflowTracking(target=...), Record fits in experiment tracking and publish models to registries #60).docs/mlops.mdopens with a quickstart that runs as is on CPU against a local MLflow store (install, snapshot, tracked and registered fit, promotion, batch prediction, drift, evaluation), and "Monitoring in a later job" is a recipe that finds the champion's prediction runs by tag, loads them and their snapshots from the artifact root (with thestoreper root: local,s3://,gs://,/Volumes/...) and passes both snapshots toscore_drift. Both scripts were run against this tree.File outputs, drift from the artifact root, SageMaker template
PredictionOutputConfig(S3, GCS, local) with the defaultwrite_modenow writes each run under<path>/<prediction id>/, so a daily job's second run no longer fails;WriteMode.OVERWRITEreplaces<path>(on object stores a reader in between sees no_SUCCESS); an explicitAPPENDto files stays unsupported. One story with tables: tables append withPREDICTION_ID/BUNDLE_ID, files get a per-run directory,OVERWRITEreplaces.s3://…).score_driftreads the training and scoring snapshots'col_stats.jsonandingest_stats.jsonfrom<root>/snapshots/…when the snapshots are not in the local store, so a monitoring job on another machine gets feature and input drift without loading snapshots; otherwise thereasonsentry names the argument to pass.examples/mlops/sagemaker/Dockerfile(cu126 GPU, or--build-arg TORCH=cpu), separate--fit-instance-type/--score-instance-type, and--aliasinstead of a fixedchampion; the README notes that the GPU Predict step is a Processing job needing a GPU processing quota, and that pipeline names may not contain-aws-. The new flags and this Dockerfile have not yet run on SageMaker.Back-compat
This PR only records and exposes information. It adds no new checks or errors on any existing path.
_provenance.jsongainsholdout. Every other bundle file is unchanged, and_provenance.jsonstays out of the bundle id.refit_full, or a link-prediction fitholdoutrecord.holdout. The fit is unaffected.holdout, and a prediction run embeds it as is (or as an id stub if it has no manifest).<root>/predictions/<id>/after theoutput_configexport (skipped withexportlackingprediction)._SUCCESS. Data files are unchanged.stats.outputsisnull. Exports go ahead.output_configexport raisesstatus: failedand only the completed exports, the run is tracked as failed, and nothing is stored in the root.schema_versionmanifestreads asNone, with a warning.PredictionResultfrom a run before this PRmanifest,bundle_idandsnapshot_idareNone;idis the run id.export()works as before.PredictionResult(selection, artifact_type)still constructs positionally.predict()with tracking onlog_prediction_run; userTrackingimplementations with fit hooks only still satisfy the protocol and get no call. Fit runs are unchanged.evaluate/score_drift(new)None(ormetricsempty) with a reason.evaluateraises only on a trainer that was never fitted or loaded; an immature run returns its metrics withmatured=Falseand aUserWarning.EvaluationResult.exportwithout evaluated rowsValueErrorwith the reasons.EvaluationResult.load_from_connector(new)TypeErrorfor a table destination;FileNotFoundErrorwithout_evaluation.json(e.g. an export to a table or a CSV) or_SUCCESS.PredictionResult.load_from_connector(new)TypeErrorfor a connector whose sink does not hold files at the path;FileNotFoundErrorwithout_SUCCESS. A completed directory without a manifest loads, with no ids or anchors.diskgraph.compute_col_stats_for_parquetgenerate_train_tablecallersvalidate_targetsdefaults toTrue, today's behaviour.Performance
Measured on CPU with 8 rayon threads, synthetic Parquet on local NVMe, three runs each in a fresh process; peak RSS is the increase over the process after imports. The box was shared, so times are upper bounds.
Score statistics (
compute_col_stats_for_parquetwith the options above):True_PROB,TARGET), 1 fileTARGET_PRED,TARGET), 1 filetop_score(×10 classes, unsorted, predictions split across row groups)top_score(×10, unsorted), 8 filesMemory stays flat with row count (about one row group of the needed columns per thread). The holdout is capped at 1 GiB in memory (about 30M binary rows), so 50M is beyond what a fit writes. Inside a full
Trainer.predict(A10G GPU scoring, export to a local Parquet directory), the statistics took 0.16–0.18% of 234–275 s for 10M binary rows and 0.16–0.24% of 68–74 s for 10M link-prediction rows; the rest of the manifest adds at most 0.01 s.evaluate's join and metrics, against a pandas baseline (concat of label parts,merge, per-row Python ranking); one anchor, 90% of predictions labelled, link prediction with k = 10 and 1–5 targets per entity; label generation not included. Measured without the copied prediction and timestamp columns, which add one column copy per matched row.Metrics agree between the two (AUROC/AP to 1e-8, MAP@10 and precision@10 to 1e-12). Memory is bounded by the partitions in flight plus 8 bytes per labelled row for the exact binary ranking.
score_driftworks on 101 knots per side from manifest metadata.Known limitations
maturedis a proxy: it uses the earliest latest-event time across the query's target tables.top_scoretreats each run of consecutive rows with equal entity and anchor as one prediction; the scorer writes each prediction's rows together.test_job_templates.py; the platform wiring (Databricks bundle, SageMaker pipeline, Snowflake tasks) is not run in CI. The Snowflake template is experimental (preview APIs, never run), andSYSTEM$EXECUTE_ML_JOB, whichMLJobDefinition.to_sqlgenerates, is not a documented public API. Use a standard (non-ML) Databricks runtime: the ML runtime's preinstalled torch, torchvision and pyOpenSSL conflict with kumo-engine's pins.Testing Done
SnowflakeTracking(connector=...)recorded fit, prediction, drift and evaluation runs in Snowflake Experiment Tracking (after the run-name fix); the Model Monitor recipe above refreshed and returned metrics.test_snowflake_trackingandtest_tracking_contract: 27 passed, 8 skipped; the experiment fake now refuses artifacts for a run whose name needs quoting.Run at this PR's head, one file at a time, with the DiskGraph extension rebuilt from it (CPU):
docs/development/testing.mdnow says engine and core tests need DiskGraph (_c.abi3.soandbin/ingest) built from the same tree, and that a stale build fails with missingprojections=/percentiles=arguments; rebuild withmake install-diskgraph.After the rebase onto main (Push down feature projections through DiskGraph and Kumo loaders #46, Accept pyarrow 16 through 24 in DiskGraph; explain custom S3 CA bundles below pyarrow 21 #124) and Record fits in experiment tracking and publish models to registries #60's credentials change, rerun at this head with DiskGraph rebuilt from it:
test_mlflow_target13 (2 skipped:sagemaker-mlflownot installed),test_mlflow_tracking18,test_mlflow_model18,test_snowflake_tracking14 (8 skipped),test_tracking_contract13,test_trainer_registry37,test_monitoring16,test_semantic_carry_forward20,test_holdout_df11,test_bundle_provenance8.test_custom_sourcetest_bundle_provenancetest_custom_sqltest_filestore_sinktest_fit_eventstest_holdout_dftest_job_templatestest_mlflow_modeltest_mlflow_trackingtest_mlopstest_monitoringtest_optional_importstest_prediction_completiontest_prediction_manifesttest_prediction_workerstest_predictive_querytest_query_from_snapshottest_score_statstest_scoring_defaultstest_semantic_carry_forwardtest_snapshot_exporttest_snapshot_manifesttest_snowflake_connectortest_snowflake_trackingtest_tracking_contracttest_trainer_registrytest_training_explanationstest_training_table_exporttest_warm_startbp_evaluatetests (3 passed): binary AUROC equals a brute-force pair count in one and several spilled partitions, and each evaluated row carries its own entity's score; the AP step definition; regression with a constant timestamp, multiclass top class and score, link-prediction metrics.bp_col_statstests cover default output unchanged, each option additive, percentile rank error, the category cap flag,max_peracross row groups and files, and identical output on 1, 2, 5 and 16 threads.cargo clippyclean.test_score_stats,test_holdout_df,test_bundle_provenance,test_scoring_defaults: top scores of unsorted multiclass/multilabel holdouts across row groups and files; non-finite scores not counted; the holdout record matches the holdout frame and is left out when embedded;refit_fullhas none; a statistics failure keeps the rest of the manifest; a derived model keeps its parent's record.test_prediction_manifest: every manifest field of a run exported to two directories; sidecars equal the run's manifest; percentiles near numpy's; stub parents; the artifact-root store after the export; no sidecar for warehouse or CSV destinations; scoring sources; failures that only warn and leave exported bytes identical; a failing export that still writes and tracks a failed run; a tracked run whose frame equals an untracked run's.test_mlflow_tracking: a link-prediction monitoring run logsmap@10/precision@10asmap_at_10/precision_at_10.test_monitoring(feature drift): a toy graph with a numeric mean shift (standardized shift > 2) and a categorical mix shift (PSI > 0.5) is detected, identical snapshots give 0, a missing column and absent statistics leave reasons, features are found through the manifests, and a 1,200-column report logs at most 40 feature metrics (under 100 in total) plusfeature_drift.json.test_query_from_snapshot,test_mlflow_model(skipped entities): 4 of 14 requested ids absent from the snapshot give counts 14/10/4, the four sample ids,not in snapshot, the warning text and the two tracking metrics; a generated population records 0; the pyfunc model warnsskipped 1 of 2.test_monitoring: a matured binary run's labels against a pandas recomputation, emitted scores equal to the run's, AUROC against scikit-learn, oneEVALUATED_ATwithin the call; an immature older snapshot; a run with no labelled row (actualsisNone, export raises); several anchors; regression and static queries;EvaluationResult.export; metrics equal kumo-coreBinaryAUROC/BinaryAveragePrecisionand PyGLinkPredMAP/LinkPredPrecision; PSI, KS within 0.02 and Wasserstein within 2% of exact on 100k-sample normals; drift against holdout, previous run and snapshot counts; tracked monitoring runs; a run loaded from its export (no parts fetched byscore_drift, same results, parts removed with the result, no_SUCCESSrefused).test_tracking_contract,test_mlflow_tracking,test_snowflake_tracking: the prediction record's params, metrics, tags, inputs and artifact, fed to both trackers and read back; nesting under an active MLflow run; a monitoring run tags its prediction runkumo.monitored; Snowflake never resumes a run and skips an in-use instance.test_job_templates:fit_job.pythenscore_job.pyend to end on local Parquet, storingpredictions/<id>/with the fitted model's id; both scripts import without__file__; every example file compiles.ANCHOR_TIMESTAMP(59 days old), and 60 profile rows and 48 drift rows on rows timestamped 1–5 days old.Checklist
docs/mlops.md,examples/mlops).🤖 Generated with Claude Code