Repository navigation
Conversation
Change map
Requirement: Snowflake-only teams see fits in Snowflake Experiment Tracking with the same names and shapes as MLflow, through #60's |
|
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:
|
5a5caf0 to
d50d824
Compare
90ddb38 to
50dda4a
Compare
d50d824 to
71d6fa8
Compare
50dda4a to
11f23bd
Compare
71d6fa8 to
607e8bb
Compare
11f23bd to
ec7ea6e
Compare
607e8bb to
0e125df
Compare
ec7ea6e to
be04f00
Compare
0e125df to
0ca651b
Compare
be04f00 to
a1c815b
Compare
0ca651b to
e415fc1
Compare
…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>
6c1943c to
47cb59d
Compare
f5eb73f to
0d3f388
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
47cb59d to
e625375
Compare
0d3f388 to
61b7ba6
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.
Caution
Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

Minor · Release the Snowflake run before resetting adapter state. · snowflake.py:154-162
packages/kumo-engine/kumo/engine/integrations/snowflake.py:154-162
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winRelease the Snowflake run before resetting adapter state.
When
end_runraises,finishresets onlySnowflakeTrackingstate. The process-wide singleton can still hold the active run.TrackingSessioncontains the exception, so the fit can succeed. A laterTrainer.fitthen sees the active singleton and skips Snowflake tracking.Ensure cleanup releases the active SDK run before
_reset(), and add a regression case that performs a second fit after anend_runfailure.

Other comments: 2
packages/kumo-engine/test/test_snowflake_tracking.py: 1
31-32:Minor · Functional Correctness · Quick win Limit signature checks to the supported API surface.
The SDK constraint permits Snowflake ML Python 2.0, but
FakeExperimentTracking.__init__includeswarnings_as_errors, which Snowflake added in 2.4.0. This exact comparison fails on supported older versions even though the adapter does not pass that option. Compare only the parameters the adapter uses, or make the check version-aware. (docs.snowflake.com)packages/kumo-engine/docs/mlops.md: 1
139-139:Minor · Data Integrity & Integration · Quick win Qualify which tags Snowflake records as params.
Line 139 says all tags become params of the same name. That holds for the fit run's record tags, including
kumo.*run tags such askumo.kindandkumo.run_id. It does not hold for user-suppliedtags=entries. Those entries that start withkumo.or match a param name are dropped before the Snowflake adapter receives them, with a warning. Document that exclusion here, because the Snowflake section is where users look for the tag mapping.📝 Proposed documentation fix
-- Snowflake has no child runs or tags, so trial attempts are logged in the fit run as `trial-1-attempt-1/<name>` params and metrics, and tags are params of the same name. +- Snowflake has no child runs or tags, so trial attempts are logged in the fit run as `trial-1-attempt-1/<name>` params and metrics. The fit run's tags are logged as params of the same name. A `tags=` entry that starts with `kumo.` or matches a param name is not recorded.

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-mlflow-tracking
- File: .coderabbit.yaml
- Commit: e625375
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
60582b8c-c009-482a-974e-c3227dcb7e12
Files ignored due to path filters: 1-
uv.lockis excluded by!**/*.lock,!**/*.lock
Files selected for processing: 3-
packages/kumo-engine/docs/mlops.mdpackages/kumo-engine/test/test_snowflake_tracking.pypackages/kumo-engine/test/test_tracking_contract.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 2 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>
e625375 to
d97b285
Compare
61b7ba6 to
ef5ed4b
Compare
|
Folded into #60 (tracking & registry), 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: Experiment tracking: #52 → #60 → #68
Merge order: one linear stack; merge bottom to top (#53 first)
This PR: Adds native Snowflake Experiment Tracking for
Trainer.fit:kumo.engine.integrations.snowflake.SnowflakeTracking, the Snowflake counterpart of #60'sMlflowTracking.Summary
This PR adds native Snowflake Experiment Tracking for
Trainer.fit:kumo.engine.integrations.snowflake.SnowflakeTracking, the Snowflake counterpart of #60'sMlflowTracking. It is a thin adapter over #60's run records (run_record/trial_record) and implements the sameTrackingprotocol, so fits show up in Snowsight under AI & ML » Experiments without MLflow.Problem. Teams that work only in Snowflake need to answer questions like these:
kumo.bundle_id, and this PR also links them with Snowflake's native lineage when asked.)Today none of these can be answered in Snowflake.
kumo.MLOps(tracking=...)(#60) only has an MLflow adapter, and Snowflake ML Experiments is not MLflow.Cause. #60 records a fit as a run tree: one MLflow run per fit and one child run per trial attempt. Snowflake Experiment Tracking (GA since 2026-02-19,
snowflake-ml-python) has a different shape. These facts were checked in the docs and in the installedsnowflake-ml-python2.4.0 source (snowflake/ml/experiment/experiment_tracking.py):ExperimentTracking(session, *, database_name, schema_name, ...)is a process singleton. A second construction ignores the database and schema it is given, so the location must be passed again toset_experiment.start_runraisesRuntimeError("A run is already active ..."). There are no nested runs, no parent-run field and no tags.start_run(name)looks the name up as a SQLLIKEpattern and resumes a matching run that is still running, or raises if it has ended.log_params(dict)converts values to strings.log_metrics(dict, step=0)takes one step per call. Both are sent as JSON inside$$…$$SQL.log_artifact(local_path)takes files only.end_run(status='FINISHED'|'FAILED')exists only from 2.0.0. In 1.19 to 1.29 a run cannot be marked FAILED.Runis open as a context manager, snowflake-ml patches the registry'sModelManager.log_modelso the model version records the experiment and run as a lineage source.ExperimentTracking.list_model_versions(run)and Snowsight lineage read it.Change.
As for MLflow, tracking is on only through MLOps settings; there is no per-call
tracking=argument.One run per fit, with the trials inside it. Snowflake cannot hold a child run open while the fit run is active, so each trial attempt goes inside the fit run. Its record name prefixes everything it records:
trial-1-attempt-1/<name>, the same nametrial_recordgives the MLflow child run. The attempt is part of the prefix, so an out-of-memory retry with a different batch size does not overwrite the first attempt. All names come from the core records; the adapter only adds prefixes.A new run every fit. An existing run is never resumed. Before
start_run, the adapter lists the experiment's runs; ifrun_nameis taken (or matches an existing run as aLIKEpattern, since_is a wildcard), the fit is recorded as<run_name>_<first 8 hex of the kumo fit run id>with a warning, so a scheduled job that reusesrun_name="fit_1"every night gets one run per night instead of an error or a resumed run.Example
What Snowflake receives from two real CPU fits with two search trials of two epochs each (
PREDICT users.spend_next_30d FOR EACH users.user_idon a one-table synthetic 400-rowusersgraph), both with theSnowflakeTracking(...)call above. The fits ran for real; no Snowflake account was used: the Snowflake side is the test fake (test/_snowflake_experiment_fake.py) installed in place ofsnowflake.ml.experiment, which records each call and payload and applies the library's run, naming and identifier rules (see Testing Done). The tree below is that fake's recorded state. Ids are shortened and values rounded:Recorded call sequence of the first fit:
ExperimentTracking(session)→set_experiment("users_spend", database_name="ML", schema_name="EXPERIMENTS")→show_runs_in_experiment("USERS_SPEND")→start_run("fit_1")(entered as a context only withnative_model_lineage=True) →log_params(71) →log_artifact×2 → for each trial:log_params(12),log_metrics(step=1)(8),log_metrics(step=2)(8),log_metrics(2, summary),log_params(1,kumo.status) →log_metrics(17, final) →end_run(status="FINISHED"). The second fit reuses the process's instance (no construction), findsFIT_1inshow_runs_in_experiment, warnsSnowflake run fit_1 already exists in experiment users_spend; recording this fit as fit_1_f209e8ba, and startsfit_1_f209e8ba.What each part records:
kumo.kind,kumo.run_id(the engine's fit run id) and, at finish,kumo.bundle_id(the model's id). Lineage (snapshot, training table, source versions) is not copied onto the run: it lives in the model's_provenance.jsonand the manifests, which record the run back astracking.run_id(Record fits in experiment tracking and publish models to registries #60), and the run only points to the fit and the model. Usertagsare params under their given names too (core drops a user tag namedkumo.*or like a param, with a warning). The full plan is in themodel_plan.jsonartifact and the query inquery.pql; the model's_provenance.jsonis logged too, at finish.attempt, and itskumo.kind,kumo.run_idandkumo.parent_runtags, all under the attempt's prefix. When the attempt ends, itskumo.status(andkumo.errorfor a failed attempt) follow under the same prefix, as MLflow logs them as child-run tags.epoch_endedevent's metrics (the values the trial record keeps insteps), logged with step = epoch as the events arrive, so Snowsight's metric-history chart plots them per trial. Trial summaries (best_<tune metric>,seconds) use step 0. The final metrics are the fit record's metrics, unprefixed:metrics.jsonplusseconds.kumo.errorparam (error type and message). The fit's exception is re-raised unchanged.native_model_lineage=Truethe run is held open as a context for the whole fit, and a model version published meanwhile is linked to the run through Snowflake lineage, with no extra call. This is opt-in because it puts the run into the model manifest that the existing publish path writes, which has not been verified live (see Known limitations), and while the context is open snowflake-ml applies it to every model the process registers. At finish the adapter also logs every tag of the finish-time record that was not logged at the start (kumo.bundle_id, andkumo.erroron a failed fit) as params with the same names, and any artifact it has not logged yet (_provenance.json).Limits:
kumo.dropped_metricsandkumo.dropped_paramsparams (names from core; MLflow recordskumo.dropped_paramsthe same way). Neither is logged when nothing was dropped.$$are skipped and counted, because they would end the SQL literal.Edge cases:
ExperimentTracking(snowflake-ml keeps one per process): it is reused, never re-constructed, because re-constructing ignores the new session and resets the user'scapture_source_info/warnings_as_errors. If it holds an active run (for example the user's ownstart_runin a notebook) or belongs to another Snowpark session, the fit is not tracked in Snowflake, a warning says so, and nothing on it is touched: no run is ended or detached, no setting changes. The library has no public "is a run active" or "list runs" call, so the adapter reads the private_instance,_run,_sessionand_sql_client.show_runs_in_experiment. It fails closed if_runis missing, and (whenrun_nameis set) a missing_sql_clientturns tracking off with one warning. A probe test pins these on the installed class.database/schemaleft asNoneresolve to the session's current database and schema (PUBLICwhen onlydatabaseis given or the session has none). They are passed explicitly every time, so a later fit never inherits the location of an earlier one.ALTER SESSIONand sets no session parameter. Snowflake calls run on Record fits in experiment tracking and publish models to registries #60's background tracking thread, so training is not blocked: per-epoch cost on the training thread measured with the recording fake went from 0.08 ms to 0.012 ms, and with a 0.5 s delay per Snowflake call from 500 ms to 0.016 ms. A hung Snowflake call stalls a replayed fit for under 1.5 s (the fit waits at most 30 s at the end for tracking), and the run still ends FINISHED once the call returns. snowflake-ml-python offers no per-statement timeout for its calls, so a stuck statement keeps only the background thread busy.Packaging.
kumo-engine[snowflake]already installskumo-snowflake[sdk], which already depends onsnowflake-ml-pythonfor the model registry. This PR sets it to>=2.0,<3inkumo-snowflake[sdk]: 2.0 forend_run(status=), and below 3 because the adapter readsExperimentTrackinginternals (present unchanged from 2.0.0 to 2.6.0, the latest release; checked in both wheels). No new extra.uv.lockchanges only that specifier; the locked version stays 2.1.0.snowflake.mlis imported lazily throughimport_provider, soimport kumo.enginedoes not load it.How the two backends relate
Both adapters translate the same
RunRecords fromrun_record/trial_record. The parameter, metric and tag names and values are identical; only where they live differs:paramstags(kumo.kind,kumo.run_id,kumo.bundle_id,kumo.error)trial-1-attempt-1trial-1-attempt-1/<name>params and metrics in the fit runkumo.kind,kumo.status,kumo.error, …)trial-1-attempt-1/kumo.*paramssteps(step = epoch)log_metrics(step=epoch)artifacts(query.pql,model_plan.json,_provenance.json)log_textlog_artifactof temp filesinputs(source tables)native_model_lineage=True)#60's contract test (
test_tracking_contract.py) now replays the same recorded fake fit into both adapters, reads each one back and compares it with the records. What each adapter records beyond params, metrics and tags (nested runs: MLflow only; per-epoch steps and artifacts: both) is per-adapter test data in that test.Back-compat
This PR only adds an opt-in adapter; nothing changes for a fit that does not pass it.
fit()with MLOps off, or withMlflowTrackingMLOps(tracking=SnowflakeTracking(...))returnstracking.run_idis that fit run's name (Snowflake identifies runs by name; it includes any_<8 hex>suffix added for a takenrun_name), set when the run starts and kept afterfitreturns, for job summaries.MLOps(tracking=SnowflakeTracking(session, experiment_name=...))_provenance.jsongainstracking.run_id(#60).run_nameas an existing run in the experiment<run_name>_<8 hex>with a warning.fit(export_config=...)withSnowflakeTracking(...)on (default)native_model_lineage=TrueExperimentTrackingwas created with another sessionALTER SESSION, no parameter set.kumo.error; the same exception is raised.snowflakeextraimport kumo.engineworks;SnowflakeTracking(...)raises an ImportError namingkumo-engine[snowflake].snowflake-ml-python< 2.0 or >= 3 already installedkumo-snowflake[sdk]now asks for>=2.0,<3; the existing registry publish works on 2.x (registry tests pass on 2.4.0).Known limitations
snowflake-ml-python2.4.0; behaviour against a real account (privileges, the undocumented byte limit below, name lookup) has not been exercised.native_model_lineage=True, while the fit's run is open, snowflake-ml links every model version the process registers to that run. A second Snowpark session in the same process is not tracked: snowflake-ml's singleExperimentTrackingstays bound to the first one.native_model_lineage=True,Registry.log_modelwrites the experiment run into the model manifest as a lineage source. This was not tested against a registry whose role has no privileges on the experiment, or that lives in another database. If Snowflake rejected such a manifest, a fit that both tracks and publishes would fail at publish. That is why it is off by default: the default never touches the publish path.start_runare two calls; a run created with the same name in between, by another process, would be resumed or refused by the library (a refusal turns tracking off with a warning).fit_1; unquoted names are upper-cased, sofit-1is refused and tracking turns off with a warning).Testing Done
CPU only, no Snowflake account.
snowflake-ml-python2.4.0 is installed in the dev venv; the CI environment does not install it, so there the signature and probe checks skip and the other tests use the fake.test/_snowflake_experiment_fake.pyreproduces the library's observable rules: a singleton whose re-construction resets settings and keeps the first session, one active run,set_experimentto another experiment or location dropping the run, auto-start onlog_*, identifier upper-casing and refusal,start_run(name)resuming or refusing aLIKE-matching run, FINISHED/FAILED only, and a lineage stack while aRuncontext is open. Its method signatures are checked to equal the installedExperimentTracking's (7 checks). A probe of the real class with a mock session pins the_instance,_run,_sessionand_sql_client.show_runs_in_experimentmembers the adapter reads.test_tracking_contract.pyruns the recorded two-trial fake fit (with one out-of-memory attempt) through both MLflow and Snowflake: runs, names, params, metric names and values,(step, value)histories,kumo.*and user tags (including trialkumo.status/kumo.errorand the failed fit'skumo.error) and artifacts, including a_provenance.jsonand thekumo.bundle_idtag that first appear in the finish record, all match the core records, read back from Snowflake's prefixed params under their core names. A tracker that raises is ended once and not called again.native_model_lineage=Trueputs the run on the lineage stack for the whole fit (where a publish would happen) and takes it off at the end;set_experimentgets the given database and schema._wildcard, each give a new<run_name>_<8 hex>run with a warning, andtracking.run_idis that new name; the existing run gets no params.log_metricsfailing gives one warning, and the run is still ended FINISHED. Instance in use: with an active user run or another session, no call reaches the instance, the user's run stays RUNNING and its experiment andwarnings_as_errorsare untouched. Location: a fit withoutdatabaseafter one with it uses the session's current database and schema. Failed fit: run ended FAILED with thekumo.errorparam. Limits: tier order (final, then summaries, then per-epoch), drop counts,$$params and NaN metrics skipped, non-string tag values converted, finish-time tags logged at the end. Session: no statement is issued on the user's session; a hung call stalls a replayed fit for under 1.5 s. Bare install:SnowflakeTracking(...)nameskumo-engine[snowflake].test_snowflake_tracking.py,test_tracking_contract.py,test_optional_imports.py: 73 passed.uv lock --checkpasses. kumo-snowflake registry tests were not re-run; the kumo-snowflake change is only thesnowflake-ml-python>=2.0,<3specifier.kumo.MLOpsand thekumo.bundle_idtag); Snowflake receives the same calls and names, plus the finish-timekumo.bundle_idparam and_provenance.jsonartifact.Checklist
🤖 Generated with Claude Code