Repository navigation
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
Change map
Requirement: a |
5b51082 to
1d3acd6
Compare
86d7e79 to
b9ba641
Compare
42acd3d to
28cdf93
Compare
d2153fe to
db52381
Compare
28cdf93 to
0a24e45
Compare
0a24e45 to
348c337
Compare
db52381 to
db74c6c
Compare
348c337 to
ff5a9c6
Compare
85a555a to
8dd452e
Compare
0fc8c34 to
189d378
Compare
8dd452e to
9929562
Compare
9929562 to
3dfffae
Compare
189d378 to
4e16e8e
Compare
3dfffae to
4638203
Compare
4638203 to
091bbb7
Compare
091bbb7 to
bc70d42
Compare
4e16e8e to
addec89
Compare
bc70d42 to
1aff0a9
Compare
addec89 to
62f5c59
Compare
1aff0a9 to
66710cf
Compare
62f5c59 to
0adda85
Compare
66710cf to
9d91b0a
Compare
SnowflakeSinkBackend now serves named internal stages (@DB.SCHEMA.STAGE/ prefix) as a FileStoreSink: a chunked pyarrow filesystem over PUT, LIST, REMOVE, GET and presigned-URL range reads, so snapshots, training tables, models, prediction exports and the MLOps artifact root can live on a stage. Live run on a Snowflake account fixed two things the in-memory stage missed. REMOVE and GET match PATTERN against the stage's internal storage path, two segments longer than LIST shows, so the exact-file pattern anchors only the stage-relative suffix (.*/<path>); the fake stage now matches against such a path. A SNOWFLAKE_FULL stage lists padded ciphertext sizes, so its files cannot be read back exactly: the one-time probe detects that from the probe's listed size and reads raise, naming SNOWFLAKE_SSE, instead of failing a size check. Verified live: writes, exact deletes with LIST checks, ranged reads on an SSE stage, the GET fallback on SSE, the FULL refusal, and no warehouse needed for PUT/LIST/GET/REMOVE/GET_PRESIGNED_URL; an MLOps fit, predict and model load with the artifact root on a stage. Unit tests: the kumo-snowflake non-live suite passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Kumo model registered in the Snowflake Model Registry was a carrier whose predict raised, so Snowflake ML batch inference jobs could not run it, and re-registering the same bundle logged a duplicate version. kumo-engine now registers, under the kumo_snowflake.registry_models entry point, a builder that kumo_snowflake.registry uses when present: the version is a CustomModel that loads the bundle and an exported stage snapshot once per process and scores an ENTITY column in Snowpark Container Services. ModelOutputConfig gains serving_snapshot and serving_options: cuda_version (default 12.6), python_version (the registering Python), function_type (TABLE_FUNCTION) and pip_repository. Without the builder, or if it fails, the carrier is registered as before. A bundle whose kumo_bundle_id is already a Kumo version of the model returns that version. The scoring helpers shared with the MLflow pyfunc move to integrations/_serving.py. kumo's wheels are not on PyPI, so the version embeds kumo's packages (and torchmetrics, whose pin the CUDA index the image build prefers does not carry) as code_paths, with their third-party requirements in a form the image build accepts (no ==X.* pins, torch at this installation's minor version, faiss-cpu, and kumo-glove-6b-50d for text models). pip_repository makes those requirements install from a Snowflake pip artifact repository instead of PyPI. snowflake-ml pickles the model class by value, so the container must run the registering Python: python_version defaults to it, and another value raises (the version is then registered as a carrier, with a warning). Verified live on a Snowflake account, as a role holding only the documented grants (CREATE MODEL and the Model Monitor grants added to the docs, CREATE IMAGE REPOSITORY removed as unneeded): binary and regression versions registered through kumo.MLOps scored 300 entities with run_batch on a GPU_NV_S pool, matching local scores; the regression version installed its requirements from Snowflake's shared PyPI repository; Model Monitors on both refreshed with holdout_df() of the registry-loaded model as the baseline. Unit tests: snowflake-ml packager round-trip and registry fakes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run_batch model loads the predictor that the bundle's manifest names, reads the bundle's assets from their stage with the container's identity, and takes its output columns from the serving record, so any bundle with a predictor scores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed assets anywhere A version kumo.MLOps registers with a stage artifact root scores against the fit's snapshot stored there, so no separate snapshot export is needed; SnowflakeTracking(serving_options=) sets how it runs. ModelOutputConfig(serving_snapshot=) also takes a GraphSnapshot or a snapshot id stored under the block's root. Trainer.load_from_connector reads a model's stage assets through the loading connector, so a registered model loads outside a kumo.mlops block; a stage location is never read as a local path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0adda85 to
8132dbc
Compare
9d91b0a to
feb41bb
Compare
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 Snowflake named internal stages as a file destination of
SnowflakeConnector, and a Snowflake Model Registry version that scores in Snowflake.Update (10-28): registry through the tracker, snapshots from the stage root, live-run fix
kumo.MLOps((sf, "@DB.SCHEMA.STAGE/kumo"), tracking=SnowflakeTracking(sf, experiment_name=...), registry=ModelRegistryLocation(name, alias=...))registers the runnable version through the tracker's connector (noregistry=ModelOutputConfig(...)).<root>/snapshots/; no separatesnapshot.exportis needed.run_batch'ssnapshotparam overrides it per job.SnowflakeTracking(serving_options={...})setscuda_version,python_version,function_type,pip_repository.ModelOutputConfig(serving_snapshot=...)also takes aGraphSnapshotor snapshot id stored under the block's stage root.Trainer.load_from_connector(sf, location=...)outside akumo.mlopsblock raisedMissingDependencyErrorfor stage-stored assets; it now reads them through the loading connector, and a stage location is never read as a local path.docs/mlops.md: the Snowflake quickstart uses a stage root; run_batch details move to Reference > Snowflake serving.Examples further down that show
registry=ModelOutputConfig(sf, ..., serving_snapshot=location)predate this update.Summary
This PR adds Snowflake named internal stages as a file destination of
SnowflakeConnector, and a Snowflake Model Registry version that scores in Snowflake. Part A: model bundles, snapshot exports, prediction and training-table exports and the MLOps artifact root (#50) can live on a stage, next to the tables the job reads; it uses theFileStoreSinkprotocol and chunked filesystem from #112, as #114 does for Databricks volumes. Part B: a model registered through aSnowflakeConnector(#50/#60) becomes a runnable custom model that Snowflake ML batch inference jobs run on a Snowpark Container Services GPU pool, loading its snapshot from a stage through Part A. The PR sits on #64 because Part B builds on the series' registry and scoring code.Problem. Teams whose data lives in Snowflake and who have no S3/GCS bucket of their own ask:
COPY INTO?mv.run_batch(...)), on Snowflake compute, without a separate scoring job?Today none of this works. The Snowflake sink writes tables and registry models only. Any file export to a Snowflake connector raises, and so does an MLOps root on a stage. A registered version is a carrier whose
predictraisesNotImplementedError, and registering the same bundle twice logs a second version.Cause.
SnowflakeSinkBackenddoes not implementFileStoreSink, sois_file_destination(snowflake, "@DB.SCHEMA.STAGE/...")is False, and the engine treats the path as a table name. The package already had the stage transport (PUT/GET/LIST/REMOVEinartifacts._StageFiles,GET_PRESIGNED_URLminting instaging.py), but nothing exposed it as a filesystem.Change.
kumo_snowflake.stage_fs.StageFileSystemHandleris achunked_fs.ChunkedFileHandler(Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112) over the connector's Snowpark session. It reuses the existing transport. The Snowflake calls it uses:session.file.put(local, '@stage/dir', auto_compress=False, source_compression='NONE', overwrite=True): no.gzsuffix, bytes unchangedLIST @stage/pathwith sizes. LIST matches by prefix, so results are filtered to exact namesREMOVE @stage/dir/REMOVE @stage/parent/ PATTERN = '.*/<path>'(Snowflake matchesPATTERNagainst an internal storage path longer thanLISTshows, so only the stage-relative suffix is anchored), then aLISTcheck that raisesOSErrorif the file is still there. A plainREMOVE @stage/a/part-1would also deletepart-10GET_PRESIGNED_URL(existingpresigned_urls, 24 h) plus HTTPRange: bytes=a-b. The response must be a 206 whoseContent-Rangestarts atasession.file.get('@stage/parent/', dir, pattern=<exact name>)once per file per filesystem, then local readsRanged reads need plaintext over presigned URLs. A stage created with
ENCRYPTION = (TYPE = 'SNOWFLAKE_SSE')serves plaintext; the defaultSNOWFLAKE_FULLserves client-side ciphertext. Once per (account, stage), a 23-byte probe is PUT under@stage/.kumo-preflight/<uuid>/and bytes 1–4 are fetched by Range, then the probe is removed. A 206 with the right bytes enables ranged reads. If the probe's listed size differs from its 23 bytes, the stage isSNOWFLAKE_FULL:LISTreports padded ciphertext sizes there, so files cannot be read back exactly, and reads raiseOSErrornamingSNOWFLAKE_SSE. Any other failure falls back toGET, with one warning: "create the stage with SNOWFLAKE_SSE", or "could not verify (the role may lack WRITE)" when the probe could not be written. Errors carry a type and status code only, never a signed URL.PUThas no small size cap, so a file up to 4 GiB (the same bound as Hold MLOps artifacts on Unity Catalog volumes through the Files API #114) is one staged object, andCOPY INTOreads exported Parquet directly. A larger file becomes<name>.chunks/00000…parts plus_SUCCESS(Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112's layout). Writes spool through local disk under the engine scratch directory, at most one part per open writer, becausePUTtakes a local file.Layout on the stage, captured with the full series on top (snapshot exports, training tables and
_provenance.jsoncome from Record provenance for every artifact under one MLOps artifact root #50). In Record provenance for every artifact under one MLOps artifact root #50's current layout each snapshot export sits one level deeper, in a generation directory next tokumo_snapshot.json; this PR's code is the same either way. This is an MLOps fit on synthetic users/events data against the in-memory stage, with the part size lowered to 1 MiB for the run so the model checkpoint splits. Hashes are shortened, and index node files are omitted:With the default 4 GiB part size, every file in that run is one plain object.
Sink routing.
holds_files(connector, path)is True only for a fully qualified named stage. It is decided from the path alone, with no I/O. Table names, user stages (@~) and table stages (@%t) keep today's code. For stage paths,write_table,write_files,publish_model(path=)andfetch_model(path=)go through Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112'sFilesystemSink, so no second copy of the export or bundle logic is added. Write options the sink does not use itself (such as the manifestsidecarsthat Record provenance for every artifact under one MLOps artifact root #50's training-table export and Record prediction runs and monitor score drift #64's prediction export pass; the table path does not use them) are forwarded unchanged. A non-stagepath=still reaches the registry's own refusal.Usage (documented in the kumo-snowflake README, which points to
docs/mlops.mdfor MLOps roots):Grants:
USAGEon the database and schema,READandWRITEon the stage. No warehouse runs: on the live accountPUT,LIST,GET,REMOVEandGET_PRESIGNED_URLcompleted while the session's warehouse stayed suspended.Destination check before scoring. The sink's
preflight_write_files, whichTrainer.predictcalls before any scorer starts, refuses an occupied stage directory unlesswrite_mode=WriteMode.OVERWRITE; table names are not checked. With #64's default of one directory per run (<path>/<prediction id>/), a daily job's second run writes to a new directory and passes.Part B: a runnable Snowflake Model Registry version
Cause.
kumo_snowflake.registrylogged a_BundleModelcarrier with a one-column signature (entity→score) whosepredictraised. Scoring needs the bundle, a graph snapshot and the engine, and the registry version had no way to reach a snapshot or install the engine.Change.
kumo.engine.integrations._snowflake_model.KumoModelis asnowflake.ml.model.custom_model.CustomModel.predict(self, df, *, snapshot='', prediction_time='', binary_classification_threshold=-1.0, num_classes_to_return=0)takes anENTITYcolumn; the keyword parameters are whatInputSpec(params=...)passes. snowflake-ml pickles the class by value and rebuilds it in the container withModelClass(ModelContext(artifacts=...)), so the module imports no kumo code at module level, and the firstpredictloads the bundle (Trainer.load) and the snapshot and caches both per process (_serving._trainers,_serving._snapshots). An@snapshot is read withSnowflakeConnector.from_spcs_env(...), the container's own identity, through Part A's stage filesystem. Ids are cast to the snapshot's primary-key type;ENTITYandCLASScome back as strings.Registration (
kumo_snowflake.registry.publish_model). kumo-snowflake may not import kumo-engine, so kumo-engine registers its builder under a new entry-point group,kumo_snowflake.registry_models(kumo.engine.integrations._snowflake_model:log_model_args). With no builder (a hosted export), or if the builder fails, the carrier is registered as before, with a warning. The second artifact,serving.json, records the snapshot and the registering connector's warehouse, database and schema:{"snapshot": "@ML.MODELS.KUMO/snapshots/3c87be5e…", "warehouse": "WH", "database": "ML", "schema": "MODELS"}ModelOutputConfig(..., serving_snapshot=..., serving_options={...})._publishpasses them to the sink as metadata (kumo.serving_snapshot,kumo.serving.<option>). Options and defaults:cuda_version12.6options={"cuda_version": ...}python_versionpython_version=...; another value raises (see below)function_typeTABLE_FUNCTIONoptions={"function_type": ...}; a table function may return a different number of rows than it gets (multiclass, ranking, entities missing from the snapshot)pip_repositoryartifact_repository_map={"pip": ...}: the index for the third-party requirementsAn unknown option raises when the config is built.
Dedup. Registering a bundle whose
kumo_bundle_idis already a Kumo version of the model (kumo_engine=1and the same id in the version metrics) returns that version and logs<model> version <v> already holds bundle <id>; reused it, the same shape as the MLflow (Record fits in experiment tracking and publish models to registries #60) and Unity Catalog (Record prediction runs and monitor score drift #64) registries.Shared scoring.
predict,_require_inputs,_in_input_orderandsnapshot_locationmove from_mlflow_model.pytointegrations/_serving.py, unchanged, so the MLflow pyfunc andKumoModelscore the same way and the Snowflake model does not import mlflow.Usage (
docs/mlops.md, "Batch inference in Snowflake"):The doc lists the GPU pool (
GPU_NV_S/GPU_NV_M,GPU_NV_SMon Azure), the grants (USAGEon pool, database, schema, warehouse and model;CREATE SERVICE; stageREAD/WRITE; an image repository) and the output layout (<stage_location>/<job name>/Parquet). The Model Monitor recipe now notes that itsSOURCEcan be built from a batch job's output; the monitor still never calls the model.Wheel delivery. Snowflake builds the inference image from the version (pip requirements plus
code_paths), andrun_batchtakes neither a custom image nor an external access integration for the build. kumo's wheels are not on PyPI, so by default the version embeds kumo-engine, kumo-core, kumo-api, kumo-pql, kumo-snowflake and DiskGraph as code (about 100 MB), plus torchmetrics: the build runsuv pip install --extra-index-url https://download.pytorch.org/whl/cu<ver>and uv takes a package only from the first index that has it, wheretorchmetricsis 1.0.3 (andrequests2.28.1). The pip requirements are the third-party requirements of whichever installed distribution provideskumo_coreandkumopql(kumo-core and kumo-pql in a workspace install, kumo-engine in the bundled wheel), merged to one line per package, with==X.*pins rewritten as ranges (Snowflake rejects them), torch at this installation's minor version, and any faiss pin replaced byfaiss-cpuat the same version (kumo's faiss-gpu wheel is not on PyPI; the container only imports faiss). Models with text embeddings also requirekumo-glove-6b-50d.serving_options={"pip_repository": ...}makes these third-party requirements install from a Snowflake pip artifact repository instead of PyPI (artifact_repository_map={"pip": ...}); kumo itself stays embedded, because installing kumo-engine from a repository would need an index that holds kumo's wheels.Python version. snowflake-ml pickles the model class by value (
custom.py: cloudpickle.register_pickle_by_value), so the container must run the registering Python: on the live account a 3.14-registered model segfaulted inpredicton the 3.12 image.python_versiondefaults to the registering Python (the image has 3.12 and 3.14), and another value raises; the registry then registers the carrier, with a warning. This replaces the planned 3.12 default.Any bundle's predictor
score_registeredscores with the predictor the bundle's manifest names (#60), and takes its output columns from the serving record, which registration now writes. It castsENTITYonly for the built-in Trainer predictor. When the model was fitted with a stage as its artifact root, its assets (such as word vectors) are read from<root>/assets/on that stage, with the container's identity, through_deps.asset_locations.test_model_agnostic_mlops.pyscores a toy bundle through the Snowflake model and checks that staged asset roots reach the resolver.Back-compat
Record and expose: nothing that works today changes behaviour.
SnowflakeConnectorlocation=)@DB.SCHEMA.STAGE/...path on aSnowflakeConnectorStageArtifactLocation(existing snapshot publication)artifacts.pymakes one import lazy.COPY INTO.chunks/parts, which only kumo reads.SNOWFLAKE_FULLOSErrornamingSNOWFLAKE_SSE(encrypted sizes cannot be read back).OSErrorinstead of letting a stale file shadow the new one.A-Z a-z 0-9 _ $ = + . , / -ValueErrorat write time.KumoModelversion with pip requirements, SPCS target and the options above; same name, version, alias, metrics and task as before.Trainer.load_from_connector(sf, location=...)on either kind of versionserving.jsonsits beside it.ModelOutputConfig(...)callsserving_snapshotandserving_optionsdefault toNone._serving.py.Known limitations
GETfallback keeps each downloaded file under the scratch directory until the filesystem is released. A snapshot load by a read-only role needs the snapshot's size again in scratch.PUTneeds a local file.examples/mlops/snowflake/run_batch_smoke.pyitself (the same steps ran from scripts). The Model Monitor checks used synthetic actuals for the scored window.Trainer.load_from_connectoralso downloads (about 110 s on the live account).prediction_timeparam); per-row timestamps are not part of the declared input.withstill commits what was written (Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112's behaviour, as for S3). Readers are guarded by_SUCCESS, provenance-last and per-range CRCs.Testing Done
New
packages/kumo-snowflake/tests/test_stage_fs.pyruns against an in-memory stage (FakeStageintests/fakes.py). LIST and REMOVE match by prefix and report names under the stage name, as Snowflake does. It covers:holds_files(named stages only);write_filesandwrite_table;part-10,part-1.xand nested names, and a REMOVE that matches nothing raising;path=is not routed to the stage.On Let any sink hold files: FileStoreSink protocol and a chunked filesystem #112 alone: the kumo-snowflake suite passes (191 passed, 1 skipped). flake8 and isort are clean.
On Record prediction runs and monitor score drift #64 with this PR and Hold MLOps artifacts on Unity Catalog volumes through the Files API #114 applied: the merge is clean, and the stage tests also pass with the
_provenance.jsonsidecar passed throughwrite_files.End-to-end on the in-memory stage, on an earlier head that sat on the full series (same stage code), under
kumo.mlopswith a(connector, "@…")root, in both read modes:_provenance.json;output_pathswas read back withPredictionResult.load_from_connector.Part B, on Record prediction runs and monitor score drift #64 at
779f9339with Part A:packages/kumo-engine/test/test_snowflake_model.py(5 tests): a fitted model is logged withlog_model_argsand round-tripped through snowflake-ml 2.4.0's ownModelPackager.save/load(no session), which exercises by-value pickling, artifact remapping and the container constructor; itspredicton string ids equalsTrainer.predict, the snapshot is cached, an empty batch returns the declared columns; defaults (12.6,3.12,TABLE_FUNCTION, no repository) and every override reachlog_model; a version without a snapshot namesserving_snapshot=; an@snapshot is read throughfrom_spcs_envwith the recorded target; the entry point resolves to the builder;_publishpasses the serving metadata and an unknown option raises.packages/kumo-snowflake/tests/test_registry.py: dedup (no model, other bundle, a non-Kumo version, a match), the builder's arguments reachinglog_modelwith the connector's target, and the carrier fallback when no builder is installed or it raises.test_snowflake_model,test_trainer_registry,test_mlflow_model,test_mlops: 66 passed. flake8 and isort are clean.Live, round 1 (administrator role):
LISTsizes, exact delete (part-1removed;part-10,part-1.x,sub/part-1kept), ranged reads on SSE,GETfallback on SSE, FULL refused on read; MLOps fit/predict/model load with the artifact root and export paths on a stagePUT,LIST,GET,REMOVE,GET_PRESIGNED_URLwith the warehouse suspendedload_from_connector(alias=...)returns the same bundle, re-registering the bundle returns the existing version (3 versions for 3 bundles)run_batchkumo.MLOpson an existingGPU_NV_Spool, GPU used; 300/300 rows, labels identical to localpredict, max probability difference 4.5e-5; 690 s including the image buildROC_AUC,COUNTand drift metrics returnedFinal wheels (bundled kumo-engine, Python 3.12, cu126 torch) on a local A10G: fit tracked, registered and loaded back with
holdout_df(); the first run fell back to the carrier because requirements were read from kumo-core's own metadata, which the bundled wheel does not have — fixed in this head and re-run with the fixed module (registered runnable).Live, final round, as
KUMO_MLOPS_VALIDATION_ROLEholding only the grants in the docs, on a dedicatedGPU_NV_Spool, at this head:must have CREATE MODEL granted on SCHEMA,... CREATE MODEL MONITOR ...,... CREATE TABLE ...): added.CREATE IMAGE REPOSITORYrevoked: a forced image rebuild still ran (images go to the account default repository), so removed from the docsrun_batch, binaryrun_batch, regression,pip_repository=SNOWFLAKE.SNOWPARK.PYPI_SHARED_REPOSITORYholdout_df()of the registry-loaded model (Record prediction runs and monitor score drift #64);ROC_AUC/RMSE,COUNTand drift returned;PREDICTION_SCORE_COLUMNS=(TARGET_PRED) ACTUAL_SCORE_COLUMNS=(TARGET)for regressionGPU available: True (cuda), used: True) with a key-pairSnowflakeIdentityin memory, noSNOWFLAKE_*variables orconnections.toml; tracked, registered, loaded backThe live runs found the
PATTERN, FULL-size, delivery, Python-version and grant issues fixed above; the in-memory stage now reproduces the first two. Unit tests at this head (rebased on Record prediction runs and monitor score drift #64 with the bundled holdout): kumo-snowflake non-live suite 201 passed, 1 skipped; kumo-enginetest_snowflake_model,test_trainer_registry,test_mlflow_model,test_mlops,test_snowflake_tracking,test_tracking_contract,test_holdout_df113 passed; after the final-round changestest_snowflake_model,test_trainer_registry,test_mlflow_model62 passed and kumo-snowflake 201 passed.Checklist
pip_repositorylive.🤖 Generated with Claude Code