Repository navigation
Conversation
Change map
Requirement: every prediction run records its lineage, options, destinations and output score statistics, and with MLOps on is stored under the artifact root (final MLOps design, sections 1 and 3). Existing: the scorer's selection manifest, the sinks' export markers, the embedded-manifest helpers (#50/#56), and DiskGraph's batch-prediction statistics (#64 options). Smallest: one best-effort record per run, copied beside Parquet directory exports and to the root, never changing output bytes. Stopping condition: the record links run → model → training table → snapshot → source versions and carries the same score summary as the holdout baseline; every failure only warns. |
|
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:
|
704dcde to
f57ba5f
Compare
dbedaed to
6fb1ddc
Compare
f57ba5f to
2c47c90
Compare
6fb1ddc to
2306723
Compare
2c47c90 to
ad1936e
Compare
2306723 to
5d9e5c7
Compare
ad1936e to
2b2748e
Compare
5d9e5c7 to
08b464b
Compare
2b2748e to
0bb99c3
Compare
08b464b to
8aa54c7
Compare
0bb99c3 to
a2defce
Compare
8aa54c7 to
aaa1137
Compare
a2defce to
fe540e0
Compare
aaa1137 to
756ee76
Compare
Export passed allow_delayed_open and background_writes to S3 (with a pyarrow capability probe) and rebuilt every anonymous S3 source read with an explicit retry strategy. Neither was measured to help export, and the anonymous change touched every S3 read, so both go: ObjectStoreSinkBackend.filesystem() returns the sink's own filesystem. Verified with kumo-s3 tests and test_snapshot_export.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two exports to one location could interleave so that one committed kumo_snapshot.json while the other had just wiped the index; if the second export then died, the location claimed a complete export whose files were gone. Export now deletes the marker again after its wipe, and after committing lists the index: files another export removed are uploaded again and the marker rewritten, so a committed marker always covers complete files with their checksums. Re-export still rewrites everything. Also follows #50's record_sources rename. Verified with test_snapshot_export.py, including a wipe injected just before the commit (fails without the repair). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#50 types a Parquet or CSV directory's version as "listing" (a fingerprint of its parts' paths, sizes and mtimes). source_changes treats it as comparable, like a file's mtime, instead of reporting such tables as unknown. Verified with test_snapshot_export.py (new directory case). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the kumo-s3 sink test, removed with the S3 upload tuning, needed kumo_core/object_store_sink.py in the public connector image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…estination Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The snapshot manifest is now a SnapshotManifest, so source_changes reads its source tables by attribute, the reconstructed manifest an export ships is built from the dataclass, and the export manifest stores the environment as its dict. The exported files are unchanged. Verified with test_snapshot_export.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cope Snapshot export took (connector, path), gated on object-store sinks, and was the only place a snapshot reached durable storage. - GraphSnapshot.export(SnapshotOutputConfig(connector, path)), in the style of the other output configs; any sink that holds files at the path (FileStoreSink, #112) can take it, and every upload tells a chunked store its size so files up to one part stay plain. - Graph.snapshot(mlops=): with MLOps on, the snapshot is exported under <root>/snapshots/ unless a complete export of the same format and id is there; failures warn. - ConcurrentExportError is a RuntimeError (no retry role). GraphSnapshot.graph_key/data_version are private. The README section moves to docs/mlops.md. Verified: test_snapshot_export.py (30: config form, a filesystem-only chunked sink round trip, stored once in a block, existing export, retry and repair cases), snapshot manifest and training-table suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A job that loads a model bundle and a stored snapshot has no Python Graph, but PredictiveQuery required one, so it could not compile a prediction table. Prediction-table compilation only needs the snapshot's graph config. PredictiveQuery.from_snapshot(snapshot, query) binds the query to a GraphSnapshot: parsing uses the snapshot's graph_config and the snapshot match check uses the graph id embedded in snapshot.id, so a snapshot of a different graph is still refused. PredictiveQuery(graph, query) is unchanged. Verified: a snapshot-bound query generates prediction tables (generated and supplied rows) that Trainer.predict scores identically to the Graph-bound path; existing predictive-query and custom-source tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A job scoring a loaded model had no public way to get the query it was trained on, so binding it with PredictiveQuery.from_snapshot meant copying the text out of training-spec.json. Trainer.training_query now returns it. The snapshot check accepted a snapshot whose id equals a Graph's bare id; equality now applies only to a snapshot-bound query, so a Graph-bound query keeps the original prefix check. Verified with test_query_from_snapshot.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The from_snapshot change carried docstring prose that restated the code, and its graph-check test called a private helper. Shorten the module note and training_query docstring, drop the restating _parsed paragraph, and exercise the snapshot check through the public suggest_model_plan instead of _require_matching_snapshot. test_query_from_snapshot.py: 2 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PredictiveQuery kept its own normalized-graph cache and parsed the graph id out of snapshot ids. GraphSnapshot now offers native_graph (cached, like Graph.native_graph), and the snapshot check compares graph_key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A graph-free scoring job had to bind the saved query to the snapshot, pass the saved generation plan to generate_prediction_table and then call predict; each caller (MLflow model, job scripts) repeated that. Trainer.predict_snapshot(snapshot, *, entities=None, prediction_time=None, ...) does it in one call with predict's options. The docs now say the query's graph check only ties a snapshot to the query's graph, not to a trained model. Verified with the query-from-snapshot and predictive-query tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exports the fitted snapshot, loads it into a fresh scratch directory without a Graph, reloads the model from its bundle and scores with predict_snapshot and with PredictiveQuery.from_snapshot; both match the graph-bound scores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
predict_snapshot bound the saved query to whatever snapshot it was given, so scoring a snapshot of a different graph than the model was trained on went through silently. It now warns (and still scores) when the snapshot's graph_key differs from the graph key of the snapshot recorded at fit/load. Verified with test_query_from_snapshot.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
predict_snapshot sat next to predict, so scoring had two entry points with overlapping arguments. - Trainer.predict(snapshot, *, table=, entities=, prediction_time=, ...) scores a snapshot with the trained query and generation plan (entities or a generated table instead when given) and warns when the snapshot is of another graph. main's predict(prediction_table, snapshot=...) still works, positionally or by keyword. Argument checks run first, as before. - TrainingResult.predict(...) scores with the model that produced or loaded it (TrainingResult._trainer, set by fit and load). - predict_snapshot is gone (never released); Trainer.training_query and GraphSnapshot.native_graph are private. Docs move to docs/mlops.md. Verified: test_query_from_snapshot.py (snapshot, entities, table and legacy forms, other-graph warning, a fresh scratch scoring an exported snapshot through a loaded result), prediction workers, output columns, completion, preflight, registry, warm start, embedding and file connector suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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: 3
packages/kumo-engine/kumo/engine/trainer/_run_manifest.py: 2
61-63:Minor · Functional Correctness · Quick win The sidecar next to exported Parquet files always has
status: nullandfinished_at: null.
finishbuilds the sidecar fromreplace(manifest, outputs=records). At that pointmanifest.statusisNoneandtimings.finished_atisNone.statushas noomit_none(), soto_dictwrites"status": null. The sidecar is written together with the Parquet parts, before the run ends. The final status and finish time reach only the scratch manifest and the artifact-root copy.
docs/mlops.mdLine 94 and Lines 100-102 say that the manifest next to the outputs recordsstatusand timings. The test at Line 139 oftest_prediction_manifest.pycompares onlyoutputs, so it does not detect the difference.Do one of the following:
- Document that sidecars written during
predictcarry no finalstatusorfinished_at.- Rewrite the sidecars after all exports succeed.
95-98:Minor · Data Integrity & Integration · Quick win Remove partial artifact-root copies after a failed store.
storewrites each selected artifact independently underpredictions/<id>/<artifact>/. If a later write fails, earlier artifacts and their sidecars remain, whilestoreonly warns. The copy has no commit marker or partial status.Compute
basebefore thetryblock and delete that run directory when storage fails.🧹 Suggested cleanup
+ base = f'predictions/{result.id}' try: selection = load_prediction_selection(result.selection, validate_objects=False) names = [item.artifact_type.value.lower() for item in selection.artifacts] - base = f'predictions/{result.id}' result._export( PredictionOutputConfig(settings.root.connector, output_paths={ name: settings.root.location(f'{base}/{name}/') for name in names }), manifest) except Exception as error: + try: + from kumo.engine.connector import file_store + filesystem, root = file_store(settings.root.connector, + settings.root.location(base)) + filesystem.delete_dir_contents(root, missing_dir_ok=True) + except Exception: + pass warnings.warn(packages/kumo-engine/kumo/engine/trainer/trainer.py: 1
279-284:Minor · Stability & Availability · Quick win Add
sidecarsto the file-sink contract or make it optional at the call site.
Trainer._exportpassessidecars=when_holds_parquetaccepts a destination.FileArtifactSink.write_filesdoes not declare that keyword, whileis_file_destinationaccepts sinks through the separateFileStoreSinkprotocol. A strict-signature sink can satisfy both contracts, reach this branch, and raiseTypeErrorbefore writing. Keep provenance best-effort by defining sidecar support in the protocol and implementations, or by skipping the sidecar when a sink does not support it.

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-score-sketch
- File: .coderabbit.yaml
- Commit: cb72a69
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
6916cc1d-61d7-46fb-a576-e6ea8792eae1
Files selected for processing: 8-
packages/kumo-engine/docs/mlops.mdpackages/kumo-engine/kumo/engine/__init__.pypackages/kumo-engine/kumo/engine/_manifest.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_job_templates.pypackages/kumo-engine/test/test_prediction_manifest.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 0 remain after this review.
-
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fit(callbacks=[...]) receives each kumo.engine.tracking.Event: fit_started, trial_started, epoch_ended, trial_ended and fit_ended, with status and a short error on ended events. A callback that raises is logged and disabled; it never fails the fit. fit resolves its MLOps settings once on the caller's thread and passes them down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kumo.MLOps(tracking=MlflowTracking(...)) records each fit in MLflow: one run per fit and one child run per search-trial attempt, with params, per-epoch metrics, the model's _provenance.json and the source tables as native MLflow inputs read from the training table's embedded parents. Runs carry pointer tags (kumo.kind, kumo.run_id, kumo.bundle_id); the model manifest records the tracked run in tracking.run_id. Tracking runs on a background thread and never fails a fit. Verified with test_tracking_contract.py, test_mlflow_tracking.py, test_optional_imports.py and test_bundle_provenance.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kumo.MLOps(tracking=SnowflakeTracking(session, experiment_name=...)) records each fit as one Snowflake run: trial attempts as prefixed params and metrics, tags as params, the model's _provenance.json as an artifact, Snowflake's per-run limits counted in kumo.dropped_*. The user's Snowpark session is not altered and an existing run is never resumed. snowflake-ml-python is capped below 3 for the internals read. Verified with test_snowflake_tracking.py and test_tracking_contract.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A threshold tuned on the holdout after fit has nowhere to live: every scheduled score job must pass it again, and a registry version cannot say which threshold it was validated with. TrainingResult.with_scoring_defaults(ScoringDefaults(...)) copies the bundle, adds scoring-defaults.json and returns a TrainingResult for the copy (its own bundle id). Its _provenance.json embeds the parent's ModelManifest in parents.derived_from (the parent's own model parents cut to ids). predict falls back to TrainingResult.scoring_defaults when a call omits the threshold or class count. Trainer.export(config) publishes a fitted or loaded bundle. Verified with test_scoring_defaults.py and kumo-core test_artifacts.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scheduled score job cannot follow a promoted model: registries only hand out exact versions, and nothing ties a registry version back to the bundle it holds. ModelRegistryLocation gains `alias` (set after publishing, fetch by alias). Snowflake and Databricks registries implement ModelAliasingSink (a failed Snowflake move puts the alias back) and record the version's kumo_bundle_id; load_from_connector warns when the fetched bundle differs. kumo.MLOps(registry=ModelOutputConfig(connector, location=)) registers each fit's model after it is stored and tracked, returned as TrainingResult.registered_model (a failure warns). Aliases are checked before training. Databricks fetch accepts trailing-slash directories. Verified with test_trainer_registry.py, test_scoring_defaults.py, kumo-api test_plugin_registry.py and the Snowflake/Databricks registry tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A model fitted with MLflow tracking cannot be registered in Unity Catalog or SageMaker managed MLflow, nor served by MLflow: the fit run records metrics only. kumo.MLOps(tracking=MlflowTracking(), registry=ModelRegistryLocation( name, alias=...)) logs the fit's bundle as a pyfunc model on the fit run and registers it (one alias), returned as TrainingResult.registered_model. The fit waits for upload and registration (up to an hour, 600 s HTTP timeout for the upload); a failure warns and ends the run FAILED. MLOps construction refuses a bare location without a tracker that has the log_model hook, a fixed version, or an invalid alias. log_model()/load_model() log any fitted or loaded model and restore it; the pyfunc model scores a snapshot URI param or a serving_snapshot loaded at startup, each predict in its own scratch dir. kumo-core gains prediction_output_columns for the signature. Verified with test_mlflow_model.py, test_mlops.py, test_mlflow_tracking.py, test_tracking_contract.py and kumo-core test_prediction_output_columns.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Teams want to run kumo-engine fits and scoring on the scheduler they already have, but there is no runnable example of wiring the engine, MLOps settings and a registry into a scheduled job. examples/mlops holds two plain Python job scripts (fit_job.py, score_job.py) and a shared jobs.py. They take CLI flags (--artifact-root, --source, --registry, --model-name, --model-path, --predictions-table, ...), build kumo.MLOps from them and run inside `with kumo.mlops(cfg)`, so snapshots, training tables and models are stored under the artifact root (models/<bundle id>/). With a registry the fit registers the model and moves `champion`; scoring loads it, or --model-path from the root. Platform wiring: a Databricks Asset Bundle, a SageMaker Pipeline and (experimental) Snowflake ML Jobs + Tasks, each passing the flags. SnowflakeConnector.session() exposes the connector's Snowpark session for SnowflakeTracking. Verified with test_job_templates.py (local fit then score) and test_snowflake_connector.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A model bundle recorded nothing about the scores its model gave the holdout, so drift in later prediction runs had no baseline. The model manifest (_provenance.json) gains holdout: row count, truncation, and per score column count, mean, min, max and percentiles q01..q99 (True_PROB; TARGET_PRED and TARGET; top_score, the real per-prediction maximum, for ranked outputs), plus label_positive_rate for binary tasks. Counts exclude non-finite scores. DiskGraph's compute_col_stats_for_parquet gains opt-in percentiles, a per-run maximum, a category-count limit with a capped flag, and switches to skip by-class summaries and histograms; its default output is unchanged. holdout is left out where the model is embedded in a child manifest; a model derived with other scoring defaults keeps its parent's. A statistics failure only drops holdout. Verified: test_score_stats, test_bundle_provenance, test_holdout_df, test_scoring_defaults (28 passed); col-stats Rust and pybind tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prediction run left no record of which model and snapshot produced it, with which options, where its outputs went, or what its scores looked like. Every predict writes _provenance.json (PredictionManifest) beside its outputs: the id (PredictionResult.id), the model and scoring snapshot manifests embedded as parents (a stub when a parent has none), the prediction table's query hash, source, anchors and rows, the scoring options and their sources, the output_config destinations (recorded even when that export fails), status, timings and per-output score statistics computed by DiskGraph. PredictionResult.bundle_id and .snapshot_id read the manifest. File exports carry it as a sidecar (not CSV); a later plain export records nothing. predict(mlops=) or a kumo.mlops block stores the run under <root>/predictions/<id>/ after the output_config export. Manifest failures warn and leave outputs unchanged. Verified: test_prediction_manifest, test_prediction_completion, test_semantic_carry_forward, test_query_from_snapshot, test_prediction_output_columns, test_prediction_output_preflight. 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 a self-contained manifest (
_provenance.json) and output score statistics for every prediction run, embedding the model and scoring snapshot manifests; with MLOps on, the run is stored under<root>/predictions/<id>/.Summary
This PR adds a self-contained manifest and output statistics for every prediction run. Every
Trainer.predictcall writes_provenance.json: the model and scoring snapshot it used (their manifests embedded), how it scored, where its outputs went, and what the scores looked like. With MLOps on, the run is also stored under the artifact root. In the series, it is the prediction-side link of the chain: snapshot (#50) → training table (#55) → model (#56, holdout statistics #64) → prediction run.Problem. Teams running batch scoring in production need to answer questions like these:
Today none of these can be answered from a prediction run's outputs.
Cause. A prediction run leaves only the scorer's selection and attempt files in local scratch. An export directory holds only Parquet parts and the export markers:
None of these record the model, the snapshot, the source table versions, the scoring options and where they came from, the destinations, timings, or any statistic of the scores.
Change.
Trainer.predictwrites_provenance.json(aPredictionManifest) 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). With MLOps on (predict(..., mlops=cfg)or akumo.mlopsblock), after theoutput_configexport the run's outputs and manifest are stored under the artifact root:_provenance.jsonfrom a binary-classification run (PREDICT COUNT(orders.*, 0, 7, days) > 0 FOR EACH users.ID) on a small synthetic users/orders graph (500 users), fitted and scored inside akumo.mlopsblock and exported topreds/(ids shortened, percentiles elided, repeatedenvironmentblocks and the embedded generation plan elided, local paths replaced by<data>/<out>):{ "kind": "prediction", "schema_version": 1, "id": "7fe8ba6a…-predict-51689c3e…", "created_at": "2026-10-24T19:00:12+00:00", "environment": {"kumo_engine": "…", "build_commit": "8b211b2a", "diskgraph": "0.1.0", "torch": "2.12.0+cu130", "python": "3.14.7"}, "parents": { "model": { "kind": "model", "id": "d6cd223f…", "fit_mode": "refit_trainval", "fit": {"started_at": "2026-10-24T18:59:53+00:00", "finished_at": "2026-10-24T19:00:11+00:00", "seconds": 17.631}, "parents": { "training_table": { "kind": "training_table", "id": "tt-086b769a…", "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": "c1d54323…@3abe76f26b657db7", "…": "…"}}}, "warm_start": null}}, "snapshot": { "kind": "snapshot", "id": "c1d54323…@3abe76f26b657db7", "record_sources": true, "parents": {"source_tables": { "orders": {"connector": "LOCAL_FILE", "location": "<data>/orders.parquet", "version": "1792868392", "version_kind": "timestamp"}, "users": {"connector": "LOCAL_FILE", "location": "<data>/users.parquet", "version": "1792868392", "version_kind": "timestamp"}}}}}, "prediction_table": {"query_sha256": "69dab700…", "source": "pql", "anchors": ["2024-04-29T00:00:00+00:00"], "rows": 500}, "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-24T19:00:11+00:00", "finished_at": "2026-10-24T19:00:12+00:00"}, "stats": {"predictions": {"rows": 500, "predicted_positive_rate": 0.0, "columns": {"True_PROB": {"count": 500, "mean": 0.34659, "min": 0.33529, "max": 0.34877, "percentiles": [0.33975, "…", 0.34702, "…", 0.34844]}}}} }What each field records:
idis the prediction id,PredictionResult.id. Run directories and<root>/predictions/<id>/are per id; run ids keep their format.parentsembeds two manifests, each without its statistics and later-changing fields (_manifest.embedded):model: the model manifest (Record bundle_id and fit provenance in every model bundle #56) 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 (Record provenance for every artifact under one MLOps artifact root #50), 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_tableholds the query's SHA-256 (the text is in the embedded training table), whether the table came from PQL or a custom source, the anchor times in ISO UTC, and the row count.scoringholds the effective options and where each came from:argument(passed by the caller),bundle(the model's scoring defaults, Add TrainingResult.with_scoring_defaults and Trainer.export #57),default(filled in by the engine),query(from the PQL), ornone.outputslists the destinations ofpredict(output_config=...)as{connector, location}, in the same shape and 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 later plainresult.export(...)copies the files and the manifest as they are and records nothing new.statsholds the row count per artifact and, for predictions, the same score summary as the holdout baseline (Record prediction runs and monitor score drift #64): per score columncount,mean,min,maxandpercentiles(q01..q99, approximate), from DiskGraph'scompute_col_stats_for_parquetover the output Parquet (Rust, row groups in parallel, by-class statistics and histograms off):True_PROB, pluspredicted_positive_rateat the threshold;TARGET_PRED;top_score, the highest score per entity and anchor (DiskGraphmax_per, exact across row groups and files, rows in any order). Link prediction records no per-class counts: counting 2M recommended items cost 1.1 s and ~290 MB of the 1.3 s total, and nothing in the series reads it.statusissucceeded, orfailedwhen theoutput_configexport raised; the manifest is written either way andpredictre-raises.timingsholds the start and finish of the call;environmentfollows the snapshot manifest (Record provenance for every artifact under one MLOps artifact root #50).PredictionResult.manifestis aPredictionManifest(public,kumo.PredictionManifest) withPredictionParents,PredictionTableRecord,RunScoring,PredictionOutput,RunTimingsandArtifactStats; for examplerun.manifest.stats['predictions'].columns. A newer file round-trips byte for byte.PredictionManifest.EMBED_OMITSisoutputs,stats,status,timings.Order inside
predict: score → build the manifest → theoutput_configexport (recordingoutputs) → write the manifest → with MLOps on, store<root>/predictions/<id>/(whose manifest includes thoseoutputs). Tracking the run comes last, in #73. The store is skipped if the export raised, and a failed store only warns. The example job test (#71'sscore_job.pyinside akumo.mlopsblock) now checks thepredictions/<id>/copy and that its manifest names the fitted model.docs/mlops.mdhas a new "Prediction runs" section.Back-compat
This PR only records and exposes information. It adds no new checks or errors on any existing path.
<root>/predictions/<id>/after theoutput_configexport._SUCCESS. Data files are unchanged.predict(output_config=...)run's manifest still lists the destination.PredictionResultfrom a run before this PRmanifest,bundle_idandsnapshot_idareNone;idis the run id it already had.export()works as before.bundle_id/snapshot_idstill read.stats.outputsisnull. Exports go ahead.output_configexport raisesstatus: failedand only the completed exports; nothing is stored in the root.schema_versionmanifestreads asNone, with a warning.Known limitations
PredictionResult.manifest). On an ephemeral machine, turn MLOps on to keep a copy under the artifact root.top_scoretreats each run of consecutive rows with equal entity and anchor as one prediction. The scorer writes each prediction's rows together.Cost
Output statistics stay always on (no flag). They run once after scoring, in Rust (DiskGraph
compute_col_stats_for_parquet, 8 threads). Measured inside a fullTrainer.predict(A10G GPU scoring, 8 CPU cores, export to a local Parquet directory, two predicts per case):predict(end to end)The rest of the manifest adds at most 0.01 s. On their own, the statistics take 0.42–0.52 s / +27 MB for 10M binary rows and 1.48–1.72 s / +37 MB for 50M; link-prediction top scores take 0.12–0.18 s / +36 MB for 10M rows and 0.42–0.53 s / +45 MB for 50M (see #64). The artifact-root copy is one more copy of the output files, made only with MLOps on.
Testing Done
test_prediction_manifest.py(10 passed, CPU):holdout) and scoring snapshot,bundle_id/snapshot_id, prediction table, scoring sources,{connector, location}outputs, timings, status;predicted_positive_rateat the threshold;<root>/predictions/<id>/predictions/holds the run's files and a manifest that lists theoutput_configdestination;export={"model"}stores nothing;export()records nothing; no sidecar for a warehouse or CSV destination;bundle; an explicit argument recordsargument;status: failedand the first destination; a newer manifest round-trips byte for byte.test_job_templates.py(12 passed): fit then score locally storespredictions/<id>/with the model's id.test_prediction_completion,test_query_from_snapshot,test_scoring_defaults.Checklist
🤖 Generated with Claude Code