Repository navigation
Conversation
Change map
Requirement: realized performance and drift for prediction runs that scale with output size, logged through the tracker and feeding warehouse monitors (final MLOps design, section 9). Existing: the run manifest and its score statistics (#67), the holdout statistics (#64), snapshot counts, DiskGraph training-table generation for labels, the tracking adapters (#60/#68). Smallest: two Trainer methods, one loader, the join and metrics in one DiskGraph function, statistics on stored curves, one optional tracker hook. Stopping condition: metrics, drift and input changes computable in-process or from an exported run at 100M rows within bounded memory, with every missing input explained; recipes documented, live checks pending. |
|
Warning Review limit reachedEnable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Next included review available in 24 minutes. View limit details
Walkthrough
Priority: Merge Risk:
|
6fb1ddc to
2306723
Compare
7ea6a29 to
e59138f
Compare
2306723 to
5d9e5c7
Compare
e59138f to
64e66f5
Compare
5d9e5c7 to
08b464b
Compare
64e66f5 to
44d3125
Compare
08b464b to
8aa54c7
Compare
44d3125 to
9ad3286
Compare
8aa54c7 to
aaa1137
Compare
9ad3286 to
0a4907c
Compare
aaa1137 to
756ee76
Compare
0a4907c to
35f3177
Compare
PredictionResult built the manifest path from the selection in two places and keyed exports on selection.job_id. One _manifest_path property and run_id now serve both, so a result loaded from an export (#69) can reuse the export and manifest code unchanged. Verified: test_prediction_manifest.py + test_prediction_output_columns.py (32 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9bcea12 to
cbd19e3
Compare
b85014d to
e551084
Compare
PredictionResult built the manifest path from the selection in two places and keyed exports on selection.job_id. One _manifest_path property and run_id now serve both, so a result loaded from an export (#69) can reuse the export and manifest code unchanged. Verified: test_prediction_manifest.py + test_prediction_output_columns.py (32 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cbd19e3 to
ed1eb02
Compare
e551084 to
61399bf
Compare
PredictionResult built the manifest path from the selection in two places and keyed exports on selection.job_id. One _manifest_path property and run_id now serve both, so a result loaded from an export (#69) can reuse the export and manifest code unchanged. Verified: test_prediction_manifest.py + test_prediction_output_columns.py (32 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ed1eb02 to
889e051
Compare
61399bf to
9426af9
Compare
PredictionResult built the manifest path from the selection in two places and keyed exports on selection.job_id. One _manifest_path property and run_id now serve both, so a result loaded from an export (#69) can reuse the export and manifest code unchanged. Verified: test_prediction_manifest.py + test_prediction_output_columns.py (32 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
889e051 to
cb1a7c1
Compare
9426af9 to
7c140cb
Compare
PredictionResult built the manifest path from the selection in two places and keyed exports on selection.job_id. One _manifest_path property and run_id now serve both, so a result loaded from an export (#69) can reuse the export and manifest code unchanged. Verified: test_prediction_manifest.py + test_prediction_output_columns.py (32 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cb1a7c1 to
5d3e2da
Compare
7c140cb to
332472d
Compare
PredictionResult built the manifest path from the selection in two places and keyed exports on selection.job_id. One _manifest_path property and run_id now serve both, so a result loaded from an export (#69) can reuse the export and manifest code unchanged. Verified: test_prediction_manifest.py + test_prediction_output_columns.py (32 passed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5d3e2da to
8bca1fe
Compare
332472d to
2e57b10
Compare
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: 4
packages/kumo-engine/kumo/engine/integrations/snowflake.py: 1
119-122:Minor · Functional Correctness · Quick win Restore the fit run's
run_idafter logging a monitoring run.
SnowflakeTrackinghas the samerun_idproblem asMlflowTracking.startsetsself.run_idto the monitoring run's name, andfinishdoes not restore it. The class docstring saysrun_id"is the latest fit run's name". After a trackedevaluateorscore_driftcall,run_idnames the monitoring run.🐛 Proposed fix
def log_monitoring(self, record: RunRecord) -> None: + fit_run_id = self.run_id self.start(record) self.finish(record, None) + self.run_id = fit_run_idpackages/diskgraph/diskgraph-pybind/src/bp_evaluate.rs: 1
600-600:Minor · Stability & Availability · Quick win Remove
spill_diron every exit path, not only on success.
evaluate_prediction_parquetremovesspill_dironly after bothscattercalls and all joins succeed. Several steps can fail with?before Line 600:
scatter(for example, a column is missing or a cast fails).unspill.join.ArrowWritercreation.When any of them fails, the Arrow IPC partitions stay on disk. These partitions hold the projected prediction and label columns for the whole run. The Python caller puts
spill_dirunder the run's directory as.spill-<uuid>. Each failedevaluatecall therefore leaves a new full-size copy behind. At the 100M-row target, this can fill the disk.Use a drop guard so the directory is removed on both success and error.
♻️ Proposed fix
std::fs::create_dir_all(&spec.spill_dir)?; + struct RemoveOnDrop<'a>(&'a Path); + impl Drop for RemoveOnDrop<'_> { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(self.0); + } + } + let _spill_guard = RemoveOnDrop(&spec.spill_dir); ... - let _ = std::fs::remove_dir_all(&spec.spill_dir); let (rows, labelled) = (acc.rows, acc.labelled);packages/kumo-engine/kumo/engine/integrations/mlflow.py: 1
156-167:Minor · Functional Correctness · Quick win Keep
tracking.run_idon the fit run, and stop requiring thekumo.prediction_runtag.
log_monitoringreusesstart, andstartsetsself.run_idto the new monitoring run.finish→_resetdoes not restore it. The class docstring andmlops.mdstate thatrun_idis "the MLflow id of the latest fit run". Aftertrainer.evaluate(...)ortrainer.score_drift(...)runs with tracking on,tracking.run_idnames the monitoring run. Code that readstracking.run_idafter a fit to find the fit run then gets the wrong run.
record.tags['kumo.prediction_run']can also be missing._monitoring.trackleaves the tag out whenrun.idisNone, for example on a loaded export without_provenance.json. The lookup then raisesKeyErrorafter the monitoring run was already logged.log_runreports the run as "not tracked", which is not true.🐛 Proposed fix
def log_monitoring(self, record: RunRecord) -> None: + fit_run_id = self.run_id self.start(record) experiment_id, run_id = self._experiment_id, self.run_id self.finish(record, None) + self.run_id = fit_run_id + prediction_run = record.tags.get('kumo.prediction_run') + if prediction_run is None: + return client = self._mlflow.MlflowClient() for run in client.search_runs( [experiment_id], filter_string=( "tags.`kumo.kind` = 'prediction' AND tags.`kumo.run_id` " - f"= '{record.tags['kumo.prediction_run']}'"), + f"= '{prediction_run}'"), max_results=1):packages/kumo-engine/docs/mlops.md: 1
275-275:Minor · Functional Correctness · Quick win Fix the link-prediction
labelled_fractiondescription.The docs say that for link prediction,
labelled_fractionis "the share of entities with any future link". The code counts more than that.In
bp_evaluate.rsjoin, a label row with an empty list still givesSome(vec![]). The row then setsmatured[row] = true, so it counts towardlabelled. Only the metric accumulation skips empty label lists.Two tests show this behavior:
- In
test_realized_metrics_match_fit_time_definitions, some entities get zero true items, yet the test assertsjoined['labelled'] == entities.- The Rust test comment says entity (1, 1) "has no labels and is skipped". That entity is skipped for the metric, but it is still counted as labelled.
For link tasks,
labelled_fractionis therefore the share of predicted entities and anchors that have a label row. Update the docs to say this. If the documented meaning is the intended one, changejoininstead so that empty lists do not setmatched.📝 Proposed doc fix
-- `rows`, `labelled` and `labelled_fraction` (for link prediction, the share of entities with any future link); +- `rows`, `labelled` and `labelled_fraction` (the share of predicted entities, and anchors, that have a label row; for link prediction this includes entities with no future link, which the metrics skip);

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-prediction-manifest
- File: .coderabbit.yaml
- Commit: a8e1884
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
711748bf-64b6-4811-a6c2-c82e8d67dc79
Files selected for processing: 20-
packages/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/_monitoring.pypackages/kumo-engine/kumo/engine/_tracking.pypackages/kumo-engine/kumo/engine/integrations/mlflow.pypackages/kumo-engine/kumo/engine/integrations/snowflake.pypackages/kumo-engine/kumo/engine/pquery/predictive_query.pypackages/kumo-engine/kumo/engine/trainer/trainer.pypackages/kumo-engine/test/test_mlflow_tracking.pypackages/kumo-engine/test/test_monitoring.pypackages/kumo-engine/test/test_predictive_query.pypackages/kumo-engine/test/test_snowflake_tracking.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 4 remain after this review.
-
A prediction run could not be checked against what happened later, nor compared with the model's holdout or an earlier run, without custom jobs, and a warehouse model monitor had no table to read. Trainer.evaluate(run, snapshot=) (and TrainingResult.evaluate) regenerates the query's labels at the run's anchors on a newer snapshot and joins them with the predictions in DiskGraph (evaluate_prediction_parquet: hash-partitioned, spilled, bounded memory). It returns fit-time metrics plus labelled share, maturity and model-match flags, and the evaluated rows (entity, anchor or the run's prediction time, prediction columns, label), which EvaluationResult.export(output_config) copies to a table or directory. Trainer.score_drift(run, previous=, snapshot=, training_snapshot=) reports PSI, KS and Wasserstein-1 against the holdout and a previous run, read off the recorded percentile curves, and input count changes. PredictionResult.load_from_connector(connector, path=) loads a complete export (it needs _SUCCESS) for a later job. With MLOps tracking on, each call logs a new monitoring run through the optional Tracking.log_monitoring hook, which the MLflow adapter (also tagging the prediction run kumo.monitored) and the Snowflake adapter implement; adapters name a run after its record. docs/mlops.md documents both, plus Databricks inference-log and Snowflake Model Monitor recipes. Verified: cargo test bp_evaluate, clippy -D warnings; test_monitoring (KS/W1 against brute force), test_mlflow_tracking, test_snowflake_tracking, test_tracking_contract, kumo-core test_pquery/test_train_table, test_predictive_query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Folded into #64 (prediction runs & monitoring), which now contains this PR's final code: the consolidated stack shows only the end state. Review continues there. |
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: Prediction runs and monitoring: #64 → #67 → #69 → #73
Merge order: one linear stack; merge bottom to top (#53 first)
This PR: Adds realized performance and score drift for prediction runs.
Summary
This PR adds realized performance and score drift for prediction runs.
Trainer.evaluatescores a past run against labels that have since arrived and writes the evaluated rows that warehouse model monitors read.Trainer.score_driftcompares a run's scores with the model's holdout and with a previous run.PredictionResult.load_from_connectorlets a later job do both from an exported run. With MLOps tracking on, each call is logged as a monitoring run. It builds on the prediction-run manifest (#67), the holdout score statistics (#64) and the snapshot manifest (#50).Problem. Teams running batch scoring in production need to answer questions like these:
Today none of these can be answered with kumo-engine. Each one needs hand-written label SQL that re-implements the query.
Cause. The engine can generate labels only for training. Scoring a past run against outcomes means regenerating the query's labels at that run's anchor times on a newer snapshot, and nothing exposes that. Label generation also refuses tables that could not train a model (a single class, fewer classes than
TOP K), even though those are valid outcomes to read. The run's score statistics (#67), the model's holdout statistics (#64) and the snapshot manifests (#50) were recorded, but nothing compared them, and aPredictionResultexisted only inside the process that ranpredict.Before:
Change. After:
Trainer.evaluateregenerates 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, hash-partitions them onENTITY(andANCHOR_TIMESTAMPwith several anchors), 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. The evaluated rows are written next to the run as a Parquet directory (EvaluationResult.actuals): the label columns plus the matched prediction's columns. Metrics use the fit-timetest_*definitions:auroc,apoverTrue_PROB, exact (torchmetrics definitions, ties counted half; scores compared at float32);mae,rmseoverTARGET_PRED;accof the top-scoredCLASSper entity and anchor;map@k,precision@kas PyG'sLinkPredMAP/LinkPredPrecision, wherekis the number of classes returned per entity and anchor, over predictions with at least one target;Trainer.score_driftcompares the run's score summary with the model's holdout (#64) and with a previous run (#67), 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. The report also has per-table row and per-edge count changes between the training and scoring snapshots (reverse edges skipped).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 (FileNotFoundErrorotherwise), then reads only its_provenance.json. The Parquet parts are downloaded in parallel the first timefilesis read, into a private scratch directory removed when the result is garbage-collected, soscore_driftfrom another job fetches nothing else.PredictionResult.filesstays as on main.Example from a binary query (
PREDICT COUNT(orders.*, 0, 7, days) > 0 FOR EACH users.ID) on a synthetic users/orders graph (60 users). The model was trained and run on a snapshot whose orders stop at 2024-03-31, exported topredictions/2024-03-30/, loaded back withload_from_connector, then evaluated on a later snapshot with 410 more orders (ids shortened;dataclasses.asdictof the frozen results).EvaluationResult:{ "prediction_id": "1bb6e3b1…-predict-9c94ade2…", "bundle_id": "02f5a396…", "snapshot_id": "66570cbc…@c85adb16754ce481", "task_type": "binary_classification", "anchors": ["2024-03-30T00:00:00+00:00"], "metrics": {"auroc": 0.4270, "ap": 0.7665}, "rows": 60, "labelled": 60, "labelled_fraction": 1.0, "matured": true, "bundle_matches": true, "reasons": {}, "actuals": "<scratch>/evaluations/1bb6e3b1…/actuals/66570cbc…@c85adb16754ce481" }actualsholds 60 rows such as{"ENTITY": 0, "ANCHOR_TIMESTAMP": "2024-03-30", "TARGET": true, "True_PROB": 0.66656, "TARGET_PRED": true}.DriftReport(withpreviousset to the same run, so theprevious_*statistics are 0):{ "prediction_id": "1bb6e3b1…-predict-9c94ade2…", "bundle_id": "02f5a396…", "score": "True_PROB", "psi": 0.5288, "ks": 0.2407, "wasserstein": 0.00087, "holdout_truncated": false, "bundle_matches": true, "previous_prediction_id": "1bb6e3b1…-predict-9c94ade2…", "previous_psi": 0.0, "previous_ks": 0.0, "previous_wasserstein": 0.0, "input_changes": {"tables.orders.rows": [1090, 1500], "edges.orders_USER_ID_to_users_ID": [1090, 1500]}, "reasons": {} }(The tiny model was trained on two steps; the numbers are real but say nothing about the model.)
What the fields mean. Nothing is refused; the flags say how far to trust a number:
prediction_idandbundle_idare the run's own ids (from its manifest);snapshot_idis the snapshot labels were read from;previous_prediction_idis the comparison run.maturedisFalsewhile the label window ends after the earliest latest-event time among the query's target tables (a conservative proxy for label completeness). A static query reads labels as of the snapshot, soanchorsis empty,maturedisNone, and the evaluated rows carry the run's prediction time asANCHOR_TIMESTAMP.bundle_matchescompares the run's model with the trainer's. Evaluating a run with a different model is allowed and flagged.labelled_fractionis labelled predictions / run predictions. For link prediction it is the share of entities with any future link.holdout_truncatedrepeats the holdout record'struncatedflag (Record prediction runs and monitor score drift #64).actuals:ENTITY,ANCHOR_TIMESTAMP,TARGET, and the prediction (binaryTrue_PROBand the predicted labelTARGET_PRED; regressionTARGET_PRED; multiclass the topCLASSand itsSCORE; link prediction none).Nonewhen no labels were joined.reasons: when a step cannot be computed (no anchors, no holdout statistics, no manifest, unsupported task), the field isNoneor empty andreasonssays why, instead of an exception.Tracking. With
kumo.MLOps(tracking=...)(a block ormlops=), eachevaluateandscore_driftcall opens a new run in the tracker's experiment, namedmonitor-<prediction id>and taggedkumo.kind=monitoring,kumo.prediction_run=<prediction id>,kumo.bundle_id; the metrics, the drift statistics (incl.previous_*) and the input count changes (input.<key>.before/after) are its metrics. A new run is used because Snowflake cannot reliably reopen a finished run. MLflow also tags the prediction run (#73) withkumo.monitored=<monitoring run id>. The hook isTracking.log_monitoring(record), optional and best-effort (logged on a tracking thread for up to 30 s, never raises); the MLflow and Snowflake adapters implement it, and both now name a run after its record when it has a name (fit records have none, so fit runs are unchanged).Warehouse model monitors.
docs/mlops.md("Monitoring prediction runs") gives two recipes over the evaluated rows, exported withevaluation.export(...):model_idcolumn with the bundle id, look up the table and schema UUIDs (w.tables.get(...).table_id,w.schemas.get(...).schema_id), then onew.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="ANCHOR_TIMESTAMP", granularities=[1 day]))))andw.data_quality.create_refresh(...). The inference log has no probability column.CREATE MODEL MONITOR ... MODEL=<m> VERSION=<v> FUNCTION=predict SOURCE=<evaluated rows> BASELINE=<holdout evaluated rows> ... TIMESTAMP_COLUMN=ANCHOR_TIMESTAMP PREDICTION_SCORE_COLUMNS=(True_PROB) ACTUAL_CLASS_COLUMNS=(TARGET), with the per-task column choices. The model must be a Snowflake Model Registry version (kumo.MLOps(registry=ModelOutputConfig(sf, location=...))).Both live checks are pending (to run on the test accounts): creating the Databricks monitor on a table written by
evaluation.export, and whether Snowflake acceptsFUNCTION=predicton a Kumo registry version, whosepredicthas a placeholder signature (if not, a real predict signature must be logged at registration). CI verifies the evaluated-rows columns per task.Follow-up (not in this PR): input-feature drift. This PR compares scores and table and edge counts; per-feature distributions of the scoring snapshot against the training snapshot need column statistics on both snapshots and are a separate change.
Supporting changes:
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, byarrow::compute::interleaveover the partition's kept batches), andconstant_timestampadds the static query's prediction time. It reuses the projected row-group reader of the batch-prediction statistics (bp_col_stats::row_group_batches, Record prediction runs and monitor score drift #64) and arrow-rs building blocks (RowConverterfor join keys and class identity,take_record_batchto partition, Arrow IPC to spill).generate_train_table(..., validate_targets=True):Falsekeeps rows that could not train a model, so outcomes can be read. The default is unchanged.pquery: one private_target_maximum(query, index_path, min|max)behindlatest_temporal_timestamp;evaluateuses its minimum formatured.PredictiveQuery: one_generatehelper for training-table and label generation, and a private_label_partscontext manager that keeps label parts on disk for the join.kumo.engineexportsEvaluationResultandDriftReport._tracking.log_run(tracking, hook, record)logs one standalone run through an optional tracker hook (Log prediction runs to MLflow and Snowflake through the tracking adapters #73 reuses it for prediction runs).Back-compat
This PR only records and exposes information. It adds no new checks or errors on any existing path.
fit,predict,export, training-table generationvalidate_targetsdefaults toTrue, which is today's behaviour.run_nameapplies as before. A taken Snowflake name now logs "recording this run as" instead of "this fit as".evaluate/score_driftNone(ormetricsempty) with a reason.evaluateraises only on a trainer that was never fitted or loaded.mlops=/ akumo.mlopsblock with trackingPredictionResult.load_from_connectorTypeErrorotherwise) and a completed export (_SUCCESS;FileNotFoundErrorotherwise). A completed directory without a manifest loads, with no ids or anchors. Downloaded parts are removed with the result.EvaluationResult.exportwithout evaluated rowsValueErrorwith the reasons (new call).score_driftreportsNonestatistics with reasons;evaluateof a temporal query has no anchors, sometricsis empty with a reason.refit_full, link prediction)Nonewith a reason.input_changesisNonewith a reason.metricsis empty, with a reason.Performance
evaluate's join and metrics, before (pandas: concat of label parts,run.frame,merge, per-row Python ranking) and after (DiskGraph). Synthetic predictions and label parts on local NVMe, one anchor, 90% of predictions labelled, LP with k = 10 and 1–5 targets per entity; 8 threads; wall time and peak RSS of the process. Label generation (DiskGraph, unchanged) is not included. Measured before the join emitted prediction columns, which adds 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 (8 × about 1M rows of predictions; labels stream), plus 8 bytes per labelled row for the exact binary ranking.
score_drift's statistics are O(101) on manifest metadata.Known limitations
maturedis a proxy: it uses the earliest latest-event time across target tables.actualsand the tracked monitoring run).Testing Done
cargo test -p diskgraph-pybind --lib bp_, 34 passed incl. 3bp_evaluate; clippy-D warningsand rustfmt clean): binary AUROC equals a brute-force pair count in one and five spilled partitions, and each evaluated row carries its own entity's score; the AP step definition; regression with a constant timestamp; multiclass emits the top-scored class and score per entity and anchor; link-prediction metrics.test_monitoring.py(10 passed; one real CPU fit on a two-snapshot users/orders fixture, other cases on stub runs):actualslabels against a pandas recomputation, the emittedTrue_PROB/TARGET_PREDequal the run's, AUROC against scikit-learn; the older snapshot is flagged immature;EvaluationResult.exportwrites the evaluated rows;BinaryAUROC/BinaryAveragePrecisionand PyGLinkPredMAP/LinkPredPrecision;Nonewithout curves;score_driftandevaluateeach log a monitoring run with the right tags and metrics;score_driftfetches no parts,evaluateandscore_driftmatch the original, the downloaded parts are removed with the result, an export without_SUCCESSis refused.test_mlflow_tracking.py: a monitoring run is a new finished run with the record's metrics, and the prediction run is taggedkumo.monitored;test_snowflake_tracking.py,test_tracking_contract.py: fit runs unchanged.test_pquery.py+test_train_table.py: 287 passed;test_predictive_query.pypassed.Checklist
docs/mlops.md).🤖 Generated with Claude Code