Repository navigation
Conversation
Change map
Requirement: each snapshot, training table and model must carry a self-contained record leading back to its source table versions and engine build. Models need a stable id, snapshots must move between machines, and a block or |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository: NVIDIA/kumo-engine/.coderabbit.yaml
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
27abe888-d440-4212-bd1a-b59a2746c5b0
📒 Files selected for processing: 4
packages/kumo-core/src/kumo_core/sources/object_store_sessions.pypackages/kumo-engine/kumo/engine/_provenance.pypackages/kumo-engine/kumo/engine/graph.pypackages/kumo-engine/test/test_snapshot_manifest.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.
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/graph.py: 1
269-269: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winTrack backfill warnings only after a backfill failure.
If a fresh manifest write fails, Line 269 adds its path to
_warned_backfill. If the index is later reused and its backfill also fails, the backfill failure produces no warning. Add the path to this set only whenfreshis false.Proposed change
if fresh or path not in _warned_backfill: - _warned_backfill.add(path) + if not fresh: + _warned_backfill.add(path) warnings.warn(
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository: NVIDIA/kumo-engine/.coderabbit.yaml
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
fec48a97-da42-43cd-9e74-6708ef97c388
📒 Files selected for processing: 4
packages/kumo-engine/kumo/engine/_manifest.pypackages/kumo-engine/kumo/engine/_provenance.pypackages/kumo-engine/kumo/engine/graph.pypackages/kumo-engine/test/test_snapshot_manifest.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 9 remain after this review.
75f9e80 to
a748d54
Compare
aaron-kumo
left a comment
There was a problem hiding this comment.
The separation between snapshots, model bundles, and explicit scoring inputs looks sound. My main concern is preserving enough information now for future restoration, freshness, and compatibility checks. We can defer those policies, but information omitted from today’s artifacts will be difficult to recover later.
P2 — Record the DiskGraph producer version/build.
environment() records engine, core, Torch, and Python versions, but omits the DiskGraph package that actually writes the index. Format constants identify the declared storage formats, but cannot distinguish implementations with different behavior or fixes under the same format version. Please record the installed DiskGraph version and a build identifier when available. This should be diagnostic provenance; package-version differences should not automatically imply incompatibility.
Discussion — Should the artifact retain the Custom SQL definition?
Could we retain literal SQL alongside its fingerprint, with an explicit option to omit/redact it for distribution? The product already persists literal SQL in table definitions and exposes it through SDK objects and generated scripts. Hash-only storage introduces a more restrictive policy here and loses the ability to inspect transformations, explain changes, or reconstruct the source definition.
SQL can contain sensitive literals or business logic, so an explicit retention policy makes sense. Is there a specific sharing or privacy requirement driving unconditional omission? If not, I’d favor retaining the SQL, dialect, resolution namespace, and referenced tables. The exact-text fingerprint and normalized-query fingerprint should also have distinct purposes; the existing Custom SQL identity already uses normalization.
Discussion — Can source identity and version evidence be structured before publication?
Could we preserve a credential-free source descriptor and typed version evidence rather than only {connector, location, version}? For example, a Snowflake table coordinate needs account context, and an actual source version, a metadata timestamp, and an hour-bucket fallback have different guarantees.
For Custom SQL, the version calculation already gathers dependency versions and folds in a staleness bucket. Could those components be retained alongside the resulting hash? Otherwise future freshness checks cannot explain whether the query, a dependency, or only the bucket changed.
I’m not suggesting implementing every comparison now. A small versioned representation that retains the evidence—including observation time, fallback status, and observed versus reconstructed provenance—would leave those decisions open.
Discussion — Should publication offer a metadata-completeness requirement?
Best-effort manifest writing seems appropriate for preserving existing local snapshot() behavior. Should export/publication optionally require complete provenance, though? Otherwise a newly created snapshot whose manifest write failed can become a durable artifact without the evidence needed for future compatibility checks. Legacy artifacts could remain supported with an explicit incomplete-provenance state.
|
P2 — Record the DiskGraph producer version/build. Discussion — Should the artifact retain the Custom SQL definition? Your two remaining points, both in 60e1ab6:
Discussion — Can source identity and version evidence be structured before publication? Discussion — Should publication offer a metadata-completeness requirement? CodeRabbit: object-store locations keep their percent-encoding, a |
c354971 to
71fcb43
Compare
27996c2 to
f590d71
Compare
f590d71 to
7349aad
Compare
7349aad to
4fbe479
Compare
4fbe479 to
59ea860
Compare
59ea860 to
f62c4ec
Compare
aaron-kumo
left a comment
There was a problem hiding this comment.
Approving with three P2 suggestions below covering provenance redaction, remote export completeness, and preservation of unknown metadata.
Validation: source review and isolated checks with dependency stubs; full integration suites were not rerun.
| filesystem, _ = _location(connector, location) | ||
| return (manifest.get('snapshot_id') == snapshot_id | ||
| and filesystem.get_file_info(f'{root}/{_DONE}').type | ||
| is FileType.File and holds_export(index, manifest)) |
There was a problem hiding this comment.
P2 — Include provenance changes in snapshot export reuse
_holds_copy() compares index identity but ignores _provenance.json. After exporting a snapshot with full provenance, calling graph.snapshot(record_sources=False) and exporting to the same destination reuses the previous generation. The operation succeeds, but the published artifact still contains the original SQL and source locations.
A customer could share that artifact believing those details were removed. SQL can contain sensitive literals or business logic, so redaction must affect publication even when the index data is unchanged.
I suggest comparing provenance bytes as well as index identity before reuse, and publishing a new generation when either differs. A regression could export with full provenance, re-export with record_sources=False, and verify the committed generation contains the redacted manifest.
There was a problem hiding this comment.
Fixed in 8b6b2ca: reuse now also requires the destination generation's _provenance.json to equal the local one byte for byte, so a re-export after record_sources=False commits a new generation with the redacted manifest (test: test_reexport_after_redaction_publishes_the_redacted_manifest).
| root, manifest = read(connector, location) | ||
| filesystem, _ = _location(connector, location) | ||
| return (manifest.get('snapshot_id') == snapshot_id | ||
| and filesystem.get_file_info(f'{root}/{_DONE}').type |
There was a problem hiding this comment.
P2 — Verify remote completeness before skipping snapshot export
A generation’s _DONE marker and matching local index identity do not establish that its remote files are still present. If an exported index file is deleted while the markers remain, _holds_copy() returns true and re-export skips the upload.
This prevents a healthy local snapshot from repairing an incomplete remote copy. The customer sees export succeed, may discard local scratch, and later discovers that a fresh scoring machine cannot restore the snapshot. Rebuilding may be expensive, and exact historical reproduction may be unavailable if the sources have changed.
I suggest verifying the remote inventory before treating the export as complete—at minimum, ensuring every listed index file exists with its expected size—and publishing a replacement generation when incomplete. A regression could delete a remote index file without changing the markers, re-export, and successfully load into empty scratch.
There was a problem hiding this comment.
Fixed in 8b6b2ca: before skipping, one recursive listing of the generation must show _DONE, index/_MANIFEST.json and every index file the local _MANIFEST.json lists at its size; otherwise a replacement generation is published (test: test_reexport_replaces_an_export_missing_a_remote_file, which then loads into empty scratch).
| return | ||
| manifest = replace( | ||
| existing, record_sources=record_sources, | ||
| parents=SnapshotParents(source_tables=source_tables)) |
There was a problem hiding this comment.
P2 — Preserve unknown parent metadata when rewriting source records
Changing record_sources constructs a fresh SnapshotParents, discarding unknown fields retained in existing.parents.extra. This also happens when switching from redacted to full source recording, so the loss is not limited to removing sensitive source details.
In a mixed-version workflow, a newer engine may have written metadata that this reader does not understand. Dropping it during an otherwise routine rewrite undermines the preservation contract and could remove information needed by later lineage, compatibility, or migration checks.
I suggest updating the existing parent record with replace(existing.parents, source_tables=...), handling absent parents separately, so unrelated unknown fields survive. A regression could inject an unknown parent field, change record_sources=False to True, and verify the field remains.
There was a problem hiding this comment.
Fixed in 8b6b2ca: a reused index now gets replace(existing.parents, source_tables=...), so unknown parents fields survive; a manifest without parents still gets a fresh SnapshotParents (test: test_toggling_record_sources_keeps_unknown_parent_fields).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kumo.MLOps stands in for a connector in load_from_connector: snapshots by id, training tables and models by their path under the artifact root. MLOps(store=...) replaces export=. TrainingResult.snapshot_id names the training snapshot. DatabricksConnector without an identity is ambient. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its connector
A query window that leaves no test rows raised an opaque evaluation error
after training; fit now refuses before training and says to shorten the
window or add data. kumo.MLOps('@DB.SCHEMA.STAGE/...') was read as a
local directory; it now asks for the (SnowflakeConnector, path) pair.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The empty-test-split check reads the materialized task's stage counts; the fakes now carry one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scoring tables read from another location (a dev graph scored on prod tables, or a renamed copy) changes the graph id, but predict and GraphSnapshot.load_from_connector only logged that at info level, so with default logging nobody saw it. It is now a UserWarning like the other graph-mismatch cases, saying the schema is the same. Verified: test_graph_mismatch, test_snapshot_export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MLOps((S3Connector(root_dir='s3://b/data'), 's3://b/artifacts')) was accepted, then every store failed with "Output path is outside the selected connector target". Under on_error='warn' those were warnings, so the fit returned with nothing stored and no model registered. kumo.MLOps now checks a (connector, path) root against the connector's root_dir when it is built and raises, naming the root_dir and the connector to pass instead. Verified: test_mlops. 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 artifacts and provenance: every graph snapshot, training table and model bundle carries a self-contained
_provenance.jsonthat leads back to the source table versions it came from, snapshots can be exported and loaded on another machine, andwith kumo.mlops(...)stores each artifact under one artifact root.Update (10-29, recheck): an artifact root outside its connector fails when built
kumo.MLOps((S3Connector(root_dir="s3://<bucket>/data"), "s3://<bucket>/artifacts"))was accepted. Every store then failed withOutput path is outside the selected connector target, only as warnings underon_error="warn", so the fit returned with nothing stored and no registered model.kumo.MLOpschecks a(connector, path)root against the connector'sroot_dirwhen it is built and raises:artifact_root 's3://bucket/kumo' is outside this S3Connector's root_dir s3://bucket/data (...), so nothing could be stored there. Give the root a connector that contains it: (S3Connector(root_dir='s3://bucket/kumo', identity=...), 's3://bucket/kumo').test_mlops: 5 passed.Update (10-29, usability pass 2): same-schema graph changes warn
users,itemsandordersunder other names) changed the graph id but showed nothing: a graph that differs only in table connectors or locations was logged at info level, which default logging hides, while the docs promised a warning.predictandGraphSnapshot.load_from_connector(..., graph=)raise aUserWarningfor it too:Snapshot <id> was taken of graph <key>, but this model was trained on a snapshot of graph <key>. The schema is the same; only table connectors or locations differ.Scores are unchanged.test_graph_mismatch,test_snapshot_export: 42 passed.Update (10-28): UX simplifications and a live-run fix
kumo.MLOps(store=...)replacesexport=(the kinds stored under the root), so "export" again means only a copy elsewhere.kumo.MLOpsstands in for the connector inload_from_connector, with paths relative to the artifact root:GraphSnapshot.load_from_connector(cfg, id=snapshot_id),TrainingTable.load_from_connector(cfg, path=f"training_tables/{id}", ...),Trainer.load_from_connector(cfg, path=f"models/{id}"). No root connector is rebuilt by hand.TrainingResult.snapshot_idnames the training snapshot, read from the bundle's training spec.DatabricksConnector(identity=None)uses the Databricks SDK's ambient auth (notebook, job, profile);from_notebookstays.ValueError: The test split is empty: shorten the query window or add data.before training (it raised an opaque evaluation error after training);kumo.MLOps("@DB.SCHEMA.STAGE/...")as a string raises and asks for the(SnowflakeConnector, path)pair instead of becoming a local directory.docs/mlops.mdgains a quickstart, an id table and a Reference section.Examples further down that show
export=predate this update.Summary
This PR adds artifacts and provenance: every graph snapshot, training table and model bundle carries a self-contained
_provenance.jsonthat leads back to the source table versions it came from, snapshots can be exported and loaded on another machine, andwith kumo.mlops(...)stores each artifact under one artifact root. It also gives models a stable content id and makesTrainer.predict(snapshot)the one scoring call, so a job holding only a stored model and a stored snapshot can score without the training job'sGraphcode.The PRs above build on it:
MLOps(tracking=..., registry=...)logs fits to MLflow or Snowflake and registers models.predictions/<id>/,mlops=onpredict, and drift and realized-performance monitoring.Problem. Teams running Kumo as scheduled jobs need to answer questions like these:
metrics.json, if any, belong to the model that was shipped?Today none of these can be answered: artifacts carry no lineage, a model has no id, an index can only be used on the machine that built it, and scoring needs a
Graph.Cause. Each artifact holds only what the engine needs to run it.
A snapshot is identified only by
graph.id@data_version[:16]. Both parts are SHA-256 hashes: they can confirm a guess but cannot be reversed into tables and versions. The index directory holds DiskGraph's files only:Nothing records each source table's connector, location and version, the engine build or the build time.
_SUCCESSrecords the absolute path the index was built at, so an index copied to another machine is treated as missing and rebuilt.A generated training table's staging directory and its export:
tt_ingest_spec.jsonholds a randomtt_id. Nothing links it to the snapshot, the query or the plan, and a table loaded from rows (TrainingTable.from_source_table) keeps no record of where the rows came from.A model bundle:
It has no id:
verify_model_bundlecomputed a 16-hex digest on load and every caller discarded it. It records neither the training table, the warm-start model, the fit time nor the engine build. One fit can train up to three models from the best trial (fit,refit_trainval,refit_full), and the bundle ships the one trained on the most data, but nothing says which.metrics.jsonholdstest_*keys for thefitmodel andtest_refit_*keys forrefit_trainval.Scoring needed the graph.
PredictiveQueryparsed againstgraph.native_graphand checked snapshots withsnapshot.id.startswith(f'{self.graph.id}@'), andTrainer.predicttook only a prediction table built from that query.Change.
The MLOps switch
Off unless asked for:
artifact_rootis required; an empty one raisesValueError. It is either a URI (s3://,gs://,file://or a local path, including/Volumes/...on a Databricks cluster), mapped to a connector bykumo.engine.connector.connector_for_root, or a(connector, path)pair whose sink holds files atpath(FileStoreSink.holds_files, Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112). A pair whose sink does not hold files raisesValueError.exportnames the kinds stored under the root when they are produced:snapshot,training_table,model(kumo.engine._manifest.KINDS; Record prediction runs and monitor score drift #64 addsprediction). The defaultTruemeans all andFalsenone; an unknown kind raises.mlops=replaces the enclosing block, and an inner block replaces an outer one. The block is a contextvar, so it covers its own thread or asyncio task.kumo.mlops()takes only akumo.MLOps(TypeErrorotherwise).mlops=:Graph.snapshot,PredictiveQuery.generate_training_table,TrainingTable.from_source_tableandTrainer.fit.GraphSnapshot.export(config),TrainingTable.export(config)andfit(export_config=...)behave the same inside or outside a block.With MLOps on, a run on a three-table synthetic graph (
users200 rows,items20,orders1,000) writes this root. Everything under it is new:Each kind is written once per id:
kumo_snapshot.jsonthere has this engine's format and the same snapshot id._SUCCESSis there.Manifests
Every artifact carries
_provenance.json(kumo_core.artifacts.PROVENANCE_FILE, #112). The leading underscore keeps Spark and Hive Parquet readers off it. A manifest is metadata only and self-contained:Any one model manifest therefore leads to source table versions without the scratch directory that built them.
Shared envelope (
kumo.engine._manifest.Manifest):kindissnapshot,training_tableormodel; thepredictionmanifest comes with Record prediction runs and monitor score drift #64.schema_versionis1.idis the artifact's id.created_atis a UTC timestamp.environmentrecords:build_commit, stamped into the wheel bysetup.pyfromKUMO_ENGINE_BUILD_COMMITorgit rev-parse, since dev builds share one version string;kumo_core,torchandpython;__version__andBUILD_COMMIT, stamped bydiskgraph-pybind/build.rs(DISKGRAPH_BUILD_COMMIToverrides it;"unknown"without git).reconstructed: truemarks a manifest rebuilt after the fact or a stub. A stub has onlykind,idandreconstructed, and stands in for a parent with no manifest of its own. A malformed parent also reads as a stub, with a warning, rather than dropping the whole manifest.Python reads every manifest as a frozen dataclass with
to_dict()/from_dict():kumo.engine.SnapshotManifest,TrainingTableManifestandModelManifestare public.stub()andembedded()stay private inkumo.engine._manifest.extraand written back, so a newer file round-trips byte for byte.schema_versionis missing, not an integer or newer than this engine, reads asNonewith a warning.Snapshot manifest
Graph.snapshot()writes it next to the index. From the run above; the hash is shortened, the data directory is<data>, and the engine and kumo-core versions are shown as released-style values (a source checkout records its git revision andkumo_core: null):{ "kind": "snapshot", "schema_version": 1, "id": "f896d098…@2377a8031c90cc98", "parents": { "source_tables": { "items": {"connector": "LOCAL_FILE", "location": "<data>/items.parquet", "version": "1793025958", "version_kind": "timestamp"}, "orders": {"connector": "LOCAL_FILE", "location": "<data>/orders.parquet", "version": "1793025958", "version_kind": "timestamp"}, "users": {"connector": "LOCAL_FILE", "location": "<data>/users.parquet", "version": "1793025958", "version_kind": "timestamp"} } }, "record_sources": true, "environment": {"kumo_engine": "1.0.0.dev0", "build_commit": "ad8ce653", "kumo_core": "0.1.0.dev1", "diskgraph": "0.1.0", "diskgraph_build_commit": "639b1017c63c", "torch": "2.12.0+cu130", "python": "3.14.7"}, "created_at": "2026-10-26T14:45:59+00:00" }idisGraphSnapshot.id. The graph hash and the full data version are in it and in the index directory name, so they are not repeated.parents.source_tableshas one entry per table alias (SourceTableEntry):connectoris the connector type:LOCAL_FILE,S3,GCS,SNOWFLAKE,DATABRICKS,BIGQUERY, ...locationis where the data is. Credentials are never recorded.FileConnectorrecords the resolved absolute path.s3://orgs://URI, including bucket and root, percent-encoded as the store spells it.<account>/"DB"."SCHEMA"."TABLE").sql:<sha256 of the SQL>.table_location.versionis the stamp the engine already uses to decide index reuse:LAST_ALTERED;version_kindsays what that stamp proves. The connector that produced it sets it (kumo-coreSourceVersion, astrsubclass):exact: a real data version (Delta).timestamp: a modification time (single files and objects, Snowflake and BigQuery tables).listing: a sha256 of the paths, sizes and mtimes of a directory's parts.hour_bucket: the hour the source was read in. Views use it, and so does Custom SQL, whose version folds in a 24-hour bucket.unknown: anything else.dependencies: the version of each table the query reads.normalized_sql_fingerprint:custom_sql_<md5>, unchanged by whitespace, comment and keyword-case edits.sql,dialect,namespace: the query text, its dialect, and the[database, schema],[catalog, schema]or[project, dataset]its unqualified names resolve in.connectorandlocationarenulland a warning is logged.record_sourcesis the choice the snapshot was taken with.graph.snapshot(record_sources=False)records eachlocationas the connector's table name only, pluslocation_fingerprint, a SHA-256 of the connector type and full location, sosource_changesstill notices a source moved elsewhere, and Custom SQL as itssql:hash and fingerprint only. That is for deployments that cannot carry source details in artifacts that travel.GraphSnapshot.manifestreturns theSnapshotManifestorNone.GraphSnapshot.record_sources(bool | None, not part of equality) is the choice, orNonefor a hand-built or loaded snapshot.Training-table manifest
Generation writes
_provenance.jsoninto the table's staging directory:{ "kind": "training_table", "schema_version": 1, "id": "tt-3dc0f233…", "parents": {"snapshot": {"kind": "snapshot", "id": "f896d098…@2377a8031c90cc98", "...": "the snapshot manifest above, embedded"}}, "query": "PREDICT COUNT(orders.*, 0, 14, days) > 0 FOR EACH users.USER_ID", "task_type": "binary_classification", "split_seed": null, "generation_plan": {"split": "inferred", "timeframe_step": 14, "train_start_offset": "inferred", "train_end_offset": "inferred", "...": "…"}, "environment": {"...": "as above"}, "created_at": "2026-10-26T14:45:59+00:00" }id(TrainingTable.id) istt-plus 32 hex of a SHA-256 over:Generating the same table twice gives one id (
tt-3dc0f233…both times in the run above). DiskGraph's owntt_id(TrainingTable.tt_id) stays random per generation (tt-0a56e445…andtt-d08e6761…), so two generations never share a staging directory. A loaded source that reports no version gets a random id. A hand-builtTrainingTablehasid=None.parents.snapshotis the manifest of the snapshot the table was generated from, or validated against for loaded rows. It is a stub if that snapshot has none.parents.source_tableis present only forfrom_source_table, in the snapshot'sSourceTableEntryshape. Itsversionis the one the staging read itself returned, so loading makes no extra call. Locations follow the snapshot'srecord_sources, then its manifest's; when neither is known, only connector and table name are recorded. An export loaded back records:query,task_typeandgeneration_planrecord what produced the rows. The plan is the effective one the table carries intofit.split_seedis the seed of aRandomSplit/RandomLinkSplit(DiskGraph's default 0 if unset). It isnullfor temporal and manual splits.TrainingTable.manifestreturns it, orNonefor tables generated before this PR. Split row counts and whether the table was generated or loaded stay in DiskGraph'smanifest.json.TrainingTable.exportto a Parquet directory on a file destination now writes the manifest as a sidecar. It lands after the parts and before_SUCCESS; the parts are byte-identical to before:The sidecar is for whoever receives the export; the engine never reads it. CSV, single-file and warehouse destinations get none. A failed sidecar read or write warns, and the export completes without it. kumo-core's
export_selected_filesandObjectStoreSinkBackend.write_filesgainsidecars: Mapping[str, bytes] = {}to carry it.Model manifest and bundle id
fitwrites_provenance.jsoninto the bundle and lists it inpublication-manifest.jsonwith roleprovenance:{"relative_path": "_provenance.json", "role": "provenance", "sha256": "312fca52…", "size_bytes": 3264}The manifest, from the same run:
{ "kind": "model", "schema_version": 1, "id": "7b604851…", "parents": { "training_table": {"kind": "training_table", "id": "tt-3dc0f233…", "...": "the training-table manifest above, embedded with its snapshot"}, "warm_start": null }, "fit": {"started_at": "2026-10-26T14:45:59+00:00", "finished_at": "2026-10-26T14:46:25+00:00", "seconds": 26.004}, "fit_mode": "refit_trainval", "environment": {"...": "as above"}, "created_at": "2026-10-26T14:46:25+00:00" }idis the bundle id, a 64-hex content hash:[relative_path, sha256]pairs of every file the publication manifest lists, plustraining-spec.jsonif an older manifest left it unlisted._provenance.json) is left out, since its timings change from run to run.model/config.jsoncarry run-specific paths. Equal ids mean the same fitted artifact.verify_model_bundlenow returns this id (it returned a 16-hex digest that was discarded).bundle_id_from_manifest, instead of re-reading the checkpoint.parents.training_tableis the training table's manifest, or a stub with its id.parents.warm_start, forfit(warm_start=...), is the parent model's manifest. Its own model parents are cut to stubs, so a chain of daily warm-start retrains keeps each manifest the same size. A parent bundle without a manifest is a stub with its bundle id.fitis the wall-clock start, end and duration offit().fit_modeis the mode of the shipped checkpoint, from kumo-coreTrainer.published_fit_mode():refit_full, elserefit_trainval, elsefit. Read it withmetrics.json:fit: thetest_*keys describe the shipped model.refit_trainval: thetest_refit_*keys describe it.refit_full: no test keys do, since that model trained on the test split.Reading:
fitreturns a newTrainingResultper call withid(the bundle id),bundle_idandmanifest.Trainer.resultis the latest fit's result, or the loaded bundle's.Trainer.loadandload_from_connectorcompute the id from the files they verify.add_bundle_file(bundle_dir, role, relative_path), which lists a written file in the publication manifest. The training spec uses it too.Moving snapshots between machines
Export.
GraphSnapshot.export(config)writes<path>/<graph id>/<data_version[:16]>/, the two halves ofsnapshot.id, and returns that location:index/only the files listed in DiskGraph's_MANIFEST.json. Machine-local files (_SUCCESS, generated training tables) stay behind._provenance.jsonbyte for byte, so a newer-schema manifest is preserved. An index without one gets a reconstructed manifest with emptysource_tables.kumo_snapshot.jsonlast.kumo_snapshot.jsonfrom the run above (hashes shortened,graph_configand the checksum map trimmed):{ "format": 1, "snapshot_id": "f896d098…@2377a8031c90cc98", "graph_key": "f896d098…", "data_version": "2377a8031c90cc98426d66ff…", "index_manifest": "c6c0fd31…", "graph_config": {"...": "…"}, "environment": {"...": "as in the manifests"}, "range_bytes": 67108864, "crc32": {"_MANIFEST.json": [38525221], "col_stats.json": [2598887258], "diskgraph_config.json": [3756067451], "...": "…"} }generationnames the directory holding this export's files.formatis the export layout version. Load rejects any other value with aValueErrorsaying to load it with the exporting kumo-engine. Unknown extra fields are ignored.snapshot_id,graph_key,data_versionname the snapshot and its place in the local store. Load refuses ids that are not the 64-hex pairs this package produces.index_manifestis the SHA-256 of the uploadedindex/_MANIFEST.json. Load checks it before trusting that file's names and sizes.graph_configmakes the loadedGraphSnapshotcomplete without the sourceGraph.environmentis the engine that ran the export.range_bytes,crc32hold one CRC32 per 64 MiB range of every index file. DiskGraph's newsnapshot_range_checksums(index_path, range_bytes)computes them in Rust with rayon while the files upload.Reuse: exporting again first reads the destination. When
kumo_snapshot.jsonnames this snapshot, the local index holds the export's bytes (the.kumo_exportidentity, or one checksum pass when the index has none), one listing of the generation shows_DONE,index/_MANIFEST.jsonand every listed index file at its size, and its_provenance.jsonequals the local one byte for byte (so a re-export afterrecord_sources=Falsepublishes the redacted manifest), the upload is skipped and the location returned, with an info log. The same rule covers the other path exports: a training-table or prediction directory with_SUCCESSand the same_KUMO_EXPORTidentity, and a model bundle path whose files, sizes and_provenance.jsonmatch (kumo_core.object_store_sink.holds_bundle, which the artifact-root model store now uses too).Transfer and commit:
copiesworker sizing andrun_in_parallel. Retries build a fresh client so credentials renew.open_sized(Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112), so a chunked store keeps files up to one part as plain objects.index/_MANIFEST.jsonand_provenance.json, and commits by writingkumo_snapshot.json, which names the generation. On a local or volume path the marker is written inside the generation and renamed into place; on an object store it is one PUT. Either way a reader sees one whole marker. Until then the previous export stays loadable. Concurrent exports, even of different builds of one snapshot, never write the same file, so the last commit wins whole._DONEand removes, best-effort, every other done generation that the re-read marker does not name, and generations with no file newer than a day (abandoned exports). An export still writing (a file within the last day) is never removed, and directories that are not generations are never touched.NotImplementedErrornaming a file destination.Load.
GraphSnapshot.load_from_connector(connector, *, path, graph=None)downloads ranges in parallel into<target>.loading, after checking free space and pre-allocating the files. It then:verify_snapshot_integrityand the checksum pass;_provenance.jsonas downloaded;Graph.snapshot()would have built it, with_SUCCESSrewritten for this machine.On failure:
ValueError(the artifact-root store re-exports instead).ValueError: the export is corrupt; delete it and export again.On reuse:
graph.snapshot()on that machine reuses the loaded index..kumo_exportnext to the index, and an index without one (built on this machine) is checksummed once and compared. Another build of the same snapshot id is downloaded again rather than served from the cache.Is it still current?
snapshot.source_changes(graph)compares, per alias, the recorded version with what the source reports now, through the connector's metadata-onlyget_data_version. It never stages rows and never raises. From the run, afterorders.parquetwas rewritten:{"users": {"status": "unchanged", "recorded": "1793025958", "current": "1793025958"}, "items": {"status": "unchanged", "recorded": "1793025958", "current": "1793025958"}, "orders": {"status": "changed", "recorded": "1793025958", "current": "1793025992"}}The status is
unknownwhen there is nothing reliable to compare:version_kindishour_bucketorunknown;record_sources=False, compared through the recordedlocation_fingerprint);load_from_connector(..., graph=graph)runs the same check and issues oneUserWarningif any table changed. It also warns whengraphis a different graph. Nothing is refused.Scoring from a snapshot
Trainer.predict(snapshot, *, entities=None, prediction_time=None, output_config=None, num_classes_to_return=None, num_workers=1, binary_classification_threshold=None, prediction_table=None):training-spec.json) to the snapshot and builds the prediction table with the trained generation plan. It then scores the query's population atprediction_time(the latest source time when omitted).entities=scores a source table ofENTITY1(andTIMESTAMP) rows instead; passing it withprediction_timeraisesValueError.RuntimeError.training-spec.jsonrecords the training graph config (graph_config), so the check compares schemas with each table's connector and location left out: a schema difference warns and names it (for exampleSchema changes: events edges.); a graph that differs only in connectors or locations, such as the same schema in dev and prod, warns that the schema is the same (10-29; it was logged at info level). A model saved without a recorded graph keeps the plain warning with a hint.GraphSnapshot.load_from_connector(..., graph=)uses the same comparison. The check says the snapshot has the model's schema, not that its features match the model.predict(prediction_table, snapshot=...)scores a prediction table generated beforehand, as before, positionally or withprediction_table=as a keyword. A prediction table already holds its entities and times, so passing it withentities=orprediction_time=raisesValueError; a snapshot or prediction table given twice raisesTypeError.TrainingResult.predict(...)scores with that result's own model: after a laterfiton the sameTrainer, an earlier result loads its own bundle on first use. A hand-built result raisesRuntimeError.In the run above, a fresh scratch directory loaded the stored snapshot and model and scored this way.
Underneath:
PredictiveQuery.graphwidens toGraph | GraphSnapshot. The privatePredictiveQuery._from_snapshotbinds a snapshot, which parses againstsnapshot.graph_configthrough a cached privateGraphSnapshot._native_graph.GraphSnapshot._graph_key) for both bindings. A snapshot of another graph raisesValueError.Shortcut
One convenience over the calls above:
TrainingTable.load_from_connector(connector, *, path, pq, snapshot)reloads an export: it isfrom_source_table(connector[path], TrainingTableSpec(split_col='SPLIT'), pq=pq, snapshot=snapshot).Boundaries
Public in
kumo.engine:MLOps,mlopsSnapshotManifest,TrainingTableManifest,ModelManifestSnapshotOutputConfigGraphSnapshot.export,load_from_connector,source_changes,manifest,record_sourcesTrainingTable.id,tt_id,manifest,load_from_connectorTrainingResult.id,bundle_id,manifest,predictTrainer.result(trainer.result.id,trainer.result.manifest)connector.connector_for_rootPrivate:
_manifest: records,stub,embedded, reader and writer._mlops:resolve._provenance: environment and source entries._snapshot_io: export generations, load, export identity and store.trainer/_provenance: the bundle manifest.GraphSnapshot._graph_key,_data_version,_native_graph.kumo-core:
io.filesystem.atomic_write_bytesandfsync_directory, now shared by the artifact store, the trainer spec and the manifest writers.artifacts.bundle_id_from_manifest,add_bundle_fileand the 64-hexverify_model_bundle.sources.identity.SourceVersionandversion_kind.ObjectStoreSessions.table_location.sources.object_store.copies, now public.sidecarsexport argument.object_store_sink.holds_exportandholds_bundle: whether a directory holds a complete export with a given identity, or a complete copy of a bundle.Warehouse plugins: the Snowflake, Databricks and BigQuery plugins gain
table_locationand typed version stamps.DiskGraph:
__version__andBUILD_COMMIT.snapshot_range_checksums, which shares_MANIFEST.jsonreading withverify_snapshot_integrityand adds thecrc32fastcrate.The docs are
packages/kumo-engine/docs/mlops.md, linked from the README.Back-compat
This PR only records and exposes information and adds calls. Without a block or
mlops=, every existing call behaves as before, and no new check raises on an existing path._provenance.jsonwritten; the bundle also lists it. Ingest, rows, splits, weights and predictions are unchanged.holdout.parquet(the published model's holdout predictions, the fileholdout_df()reads; capped at 1 GiB in memory when written, usually a few MB on disk: ENTITY, label and score columns per holdout row) andholdout-meta.json(its truncation flag), both listed and hash-checked. So new fits get a different bundle id than they would have before; existing bundles and their ids are unchanged. Registry uploads and the artifact-root copy grow by the file's size.holdout_df()on a loaded or fetched modelFileNotFoundErrorbefore, which broke the Snowflake monitor baseline in a later job); a bundle without the copy still raisesNo holdout dataframe was produced.snapshot()"reconstructed": true. Itsparentsare accurate, because reuse needs every current version to match the index's data version.created_atandenvironmentarenull.record_sourceschoicesource_tablesandrecord_sourcesare rewritten;created_at,environmentand unknownparentsfields are kept.manifestisNone, andtt_idstill reads fromtt_ingest_spec.json. Exporting it writes no sidecar.manifestisNone. Predictions are identical. As a warm-start parent it becomes a stub with its id.provenanceentry's digest like any listed file and does not use it.GraphSnapshot(...)orTrainingResult(...)built by handGraphSnapshot.record_sourcesis also left out of equality).PredictiveQuery(graph, query)trainer.predict(prediction_table, snapshot=...)_provenance.jsonadded before_SUCCESS. Edited or sidecar-less exports load as before.verify_model_bundlereturn valueNone.schema_versionNonewith a warning; a malformed embedded parent reads as a stub.format, or written by a DiskGraph this one cannot openValueErrornaming the format, or aRuntimeErrorcarrying DiskGraph's error and the exporting kumo-engine version.ObjectStoreSinkBackendsubclass overridingwrite_filessidecars=; an override without that argument or**kwargsgets aTypeError. None exists in this repo.Known limitations
hour_bucketversion. Lineage says "read during this hour", andsource_changesreportsunknown.source_changesreports Custom SQL asunknowneven though its dependency versions are recorded.parents.source_tables.build_commitisnullfor a wheel built withoutKUMO_ENGINE_BUILD_COMMITand outside git (for example from an sdist).predictandPredictiveQuerycompares graph keys only. It does not check that a snapshot's features, types or encoders match the model.test_snapshot_export_live.py) and were not run for this tree.Testing Done
Run at this tree, one file at a time, all passing:
test/connector/test_custom_source.pytest_bundle_provenance.pytest_custom_sql.pytest_filestore_sink.pytest_holdout_df.pytest_mlops.pytest_prediction_completion.pytest_predictive_query.pytest_query_from_snapshot.pytest_semantic_carry_forward.pytest_snapshot_export.pytest_snapshot_manifest.pytest_trainer_registry.pytest_training_explanations.pytest_training_table_export.pytest_warm_start.pyWhat the new tests cover:
test_mlops.py: root forms (URI,(connector, path), a non-file location raises); export kinds; a call argument beats nested blocks; threads and asyncio tasks; stubs.test_snapshot_manifest.py:None;null;record_sources=False;test_training_table_export.py:listing;record_sources;test_bundle_provenance.py:test_snapshot_export.py:source_changescases, including a graph rebound to another directory with equal versions (unknownwith and withoutrecord_sources), and the load warning;test_query_from_snapshot.py:predict(snapshot)with and withoutentities, and with a prediction table;entities=orprediction_time=raisesValueError, and a snapshot or prediction table given twice raisesTypeError;Trainer: each result scores exactly as its own reloaded bundle, and the two differ;Graph, scores throughTrainingResult.predictexactly as the Graph-bound path did.Package suites:
_SUCCESS,version_kindon object-store versions, and kept SQL dependency versions.version_kind.The new DiskGraph tests (pybind
test_exception_exports.pyand the Rustrange_checksums_cover_every_listed_file_and_the_manifest) were not part of this run.The examples above were captured from a real run of this tree on synthetic data: snapshot, two generations, a one-epoch CPU fit, a training-table export and reload, a snapshot export, then a load and
predict(snapshot)in a fresh scratch dir, andsource_changesafter rewritingorders.Checklist
docs/mlops.md).🤖 Generated with Claude Code