Repository navigation
Conversation
|
Superseded by the change map below. |
Change map
|
…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>
There was a problem hiding this comment.
Actionable comments posted: 2
Note
Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 2
packages/kumo-core/src/kumo_core/chunked_fs.py: 2
104-114:Minor · Stability & Availability · Quick win Fix the stale-layout race between
_layoutand_forget.
_layoutcomputes a layout without a lock and then stores it in the cache. A writer can finish and call_forget(path)between the compute step and the store step. The reader then stores the old layout after the invalidation.Nothing removes that entry afterward. The
_Readeruses the cached sizes. If the new file is larger, a read returns truncated data without an error. If the sizes differ in another way, the read raisesOSError.The race needs a reader and a writer on the same path in one process.
test_layout_cache_survives_concurrent_opens_and_writesdoes not cover it. In that test, each thread reads and writes only its own file.Use an invalidation counter. Store the layout only if no
_forgetcall ran during the compute step.Proposed fix
def _layout(self, path: str) -> Optional[List[Tuple[str, int]]]: cache = self.__dict__.setdefault('_layouts', {}) layout = cache.get(path) if layout is not None: return layout + epoch = self.__dict__.get('_epoch', 0) size = self._size(path) layout = ([(path, size)] if size is not None else _logical_parts( self._list(path + CHUNKS), path + CHUNKS)) - if layout is not None: + if layout is not None and self.__dict__.get('_epoch', 0) == epoch: cache[path] = layout return layoutIn
_forget, increment the counter before it removes entries:self.__dict__['_epoch'] = self.__dict__.get('_epoch', 0) + 1If you need exact atomicity across threads, guard the counter update and the cache store with one
threading.Lock.Add a test in which one thread writes a path while other threads read the same path.
321-341:Minor · Data Integrity & Integration · Quick win Guard failed
_Writerconstruction from finalization.When
_open_part()fails after_clear(),_Writerremains partially initialized.io.RawIOBasefinalization calls_Writer.close(), whose final branch uploads the empty spool as the plain object._clear()has already removed the old chunked representation.
super().close()directly calls the inherited close method and does not re-enter_Writer.close(). Keep the resource setup inside the guard:Suggested fix
- self._dir = tempfile.mkdtemp(prefix='kumo-chunked-', - dir=spool_root(handler)) - self._spool = open(os.path.join(self._dir, 'spool'), 'wb') - self._filled = 0 - self._parts = 0 - self._written = 0 - self._part: Optional[BinaryIO] = None - self._streams = type(handler)._stream is not ChunkedFileHandler._stream - self._limit = (min(handler.plain_bytes, handler.part_bytes) - if self._streams else handler.part_bytes) - self._plain: Optional[BinaryIO] = None - if self._streams and size is not None: - if size <= handler.part_bytes: - self._plain = handler._stream(path) - else: - self._clear() - self._part = self._open_part() + directory: Optional[str] = None + spool: Optional[BinaryIO] = None + try: + directory = tempfile.mkdtemp(prefix='kumo-chunked-', + dir=spool_root(handler)) + self._dir = directory + spool = open(os.path.join(directory, 'spool'), 'wb') + self._spool = spool + self._filled = 0 + self._parts = 0 + self._written = 0 + self._part: Optional[BinaryIO] = None + self._streams = type(handler)._stream is not ChunkedFileHandler._stream + self._limit = (min(handler.plain_bytes, handler.part_bytes) + if self._streams else handler.part_bytes) + self._plain: Optional[BinaryIO] = None + if self._streams and size is not None: + if size <= handler.part_bytes: + self._plain = handler._stream(path) + else: + self._clear() + self._part = self._open_part() + except BaseException: + if spool is not None: + spool.close() + if directory is not None: + shutil.rmtree(directory, ignore_errors=True) + super().close() + raiseThis prevents the empty-object replacement. It does not restore data already removed by
_clear().

Review info
-
Run configuration-
- Source mode: Auto
- Repository: NVIDIA/kumo-engine
- Branch: vedaant/mlops-holdout-fit-mode
- File: .coderabbit.yaml
- Commit: 893be81
- Layers: Repository YAML
- Review profile: QUIET
- Plan: Enterprise
- Run ID:
aca98211-94a5-45c1-8517-a0cd53672083
Files selected for processing: 10-
packages/kumo-api/kumoapi/plugin.pypackages/kumo-core/src/kumo_core/artifacts.pypackages/kumo-core/src/kumo_core/chunked_fs.pypackages/kumo-core/src/kumo_core/object_store_sink.pypackages/kumo-core/tests/test_chunked_fs.pypackages/kumo-engine/kumo/engine/_scratch.pypackages/kumo-engine/kumo/engine/connector/__init__.pypackages/kumo-engine/kumo/engine/trainer/output_config.pypackages/kumo-engine/test/test_filestore_sink.pypackages/kumo-engine/test/test_prediction_completion.py
Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 6 remain after this review.
-
d2153fe to
db52381
Compare
db52381 to
db74c6c
Compare
fit() read test_df.parquet from best_trainer.log_dir, which is whichever refit stage ran last, not necessarily the model the bundle publishes. With refit_full the published model has seen the test split, so its holdout is meaningless. Read the holdout from the published model's log dir (Trainer.published_fit_mode, also used to pick the prediction model) and return none for refit_full. get_log_dir takes an optional fit_mode override. Verified: test_holdout_df (fit / refit_trainval with explain / refit_full), test_training_explanations, test_semantic_carry_forward: 35 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Snapshot exports, model bundles and export manifests could only go to object stores: the engine checked for ObjectStoreSinkBackend, and output configs accepted output_paths only for S3, GCS, ADLS and local connectors. A store that is not an object store (a Databricks volume, a Snowflake stage) had no way in, even with a pyarrow filesystem to offer. kumoapi.plugin.FileStoreSink is an optional sink capability: holds_files(connector, path) says from the path alone whether it is a file location rather than a table, and filesystem(connector, path) returns the pyarrow filesystem. ObjectStoreSinkBackend implements it. kumo.engine.connector.is_file_destination/file_store are the engine's one file-vs-table gate; PredictionOutputConfig(output_paths=) and the TrainingTableOutputConfig directory slash use it, so object stores behave as before. kumo_core gains FilesystemSink (the object-store file writers over any filesystem()), upload_bundle/download_bundle (the manifest file last), open_sized (tells a chunked filesystem the size) and PROVENANCE_FILE. The chunked filesystem streams a sized file of up to one part as one plain object, writes frames through a local spool so they stay plain too, spools under the engine scratch directory read at each write, and reads parts into the caller's buffer with one copy. Verified: test_chunked_fs.py (26), test_filestore_sink.py (protocol, output configs, bundles and Parquet over a filesystem-only sink), file connector, completion, preflight, registry and kumo-core export suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_artifacts.py's fake trainer borrows Trainer methods; select_fit_mode now calls published_fit_mode, so the fake needs it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
db74c6c to
fc4d709
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refit_full case now also explains, as in the upper PRs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation The preflight moves from FilesystemSink to ObjectStoreSinkBackend, so S3, GCS and ABFS outputs are refused before scoring too, and a conflict found while writing names the same full location. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd predict 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 → #114 → #50 → #60 → #64 → #115. **Merge order:** one linear stack; merge bottom to top (#53 first) ## Summary This PR makes **`TrainingResult.holdout_path` the holdout of the model that was published, or `None` when that model has no holdout**. The answer no longer depends on whether explanations are on. The holdout is the test-split predictions `fit()` writes to `test_df.parquet`. `holdout_path` points at that file and `holdout_df()` reads it. Teams use it to check the calibration and score distribution of the model they are about to ship. One fit can train up to three models from the best trial, and the bundle ships the one trained on the most data: | Fit mode | Trained on | Has a holdout | |---|---|---| | `fit` | train | yes | | `refit_trainval` (`training_job.refit_trainval`) | train + val | yes | | `refit_full` (`training_job.refit_full`) | train + val + test | no: it saw the test split | ## Usage ```python plan = ModelPlan() plan.training_job.refit_trainval = True result = kumo.Trainer(model_plan=plan).fit(training_table, snapshot=snapshot) result.holdout_path # .../version_1_refit_trainval/test_df.parquet: the shipped model's result.holdout_df() # its test-split predictions ``` With `refit_full` on, `holdout_path` is `None`, `holdout_df()` raises `FileNotFoundError`, and the log says: `The published model is refit on the full data, including the test split, so it has no holdout predictions.` ## Production questions answered - Are the holdout predictions I am checking from the model that shipped? Yes, always. - Does turning explanations on or off change which holdout I get? No. - Why is there no holdout for my `refit_full` model? It trained on the test split; the log says so. ## How it works `fit()` asks kumo-core which fit mode the bundle publishes (`Trainer.published_fit_mode()`: `refit_full`, else `refit_trainval`, else `fit`) and reads `test_df.parquet` only from that model's run directory. For `refit_full` it returns `None`. Prediction and the holdout lookup share this one definition of the published model. Run directory of a CPU fit on a small synthetic graph (`users`, `items`, `orders`) with both refits on; the best trial is `version_1`: ``` <scratch>/trainers/<trainer>/runs/<run>/ published-model/ # the bundle; holds the refit_full model models/<run>/automl/ version_0/ version_2/ version_3/ # other trials version_1/ # best trial, fit mode 'fit' test_df.parquet test_df_meta.json test_result.csv checkpoint/ ... version_1_refit_trainval/ # train + val, evaluated on test test_df.parquet test_df_meta.json test_result.csv checkpoint/ ... version_1_refit_full/ # train + val + test, never evaluated checkpoint/ tensorboard/ train_progress.json train_result.json ``` Which file `holdout_path` returns, before and after: | refit_trainval | refit_full | explain | before | after | |---|---|---|---|---| | no | no | off/on | `version_N/` | `version_N/` | | yes | no | off/on | `version_N_refit_trainval/` | `version_N_refit_trainval/` | | no | yes | off | `None` | `None` | | no | yes | on | `version_N/` (not the shipped model) | `None` | | yes | yes | off | `None` | `None` | | yes | yes | on | `version_N_refit_trainval/` (not the shipped model) | `None` | Before, the lookup followed whichever fit mode the trainer object was last left in, and `explain()` changed that mode as a side effect. kumo-core gains `Trainer.published_fit_mode()` and an optional `fit_mode` argument on `Trainer.get_log_dir`, so the lookup reads another mode's directory without changing the trainer or creating empty directories. ## Back-compat Only the choice of file changes. No fields, checks or errors are added, and the run directory layout is unchanged. | Situation | Behaviour | |---|---| | No refit, or `refit_trainval` only | Same `holdout_path` as before, explanations on or off. | | `refit_full`, explanations off | `None`, as before. | | `refit_full`, explanations on | `None`. It used to return an earlier model's holdout without saying so. | | No holdout written (e.g. ranking tasks) | `None`, as before. | | Result of `Trainer.load()` | `None`, as before. | ## Testing - `test_holdout_df.py`: real CPU fits with no refit (file under `version_N/`), `refit_trainval` with explanations on (under `version_N_refit_trainval/`), and both refits (`None`); the holdout is never inside the bundle. - `test_training_explanations.py`, `test_semantic_carry_forward.py`: fake trainers answer `published_fit_mode()` and `get_log_dir(fit_mode=)`. - kumo-core `test_artifacts.py`, training and checkpoint suites pass with the new `get_log_dir` argument. - The three engine files above: 35 passed on CPU. ## Out of scope - A holdout for a `refit_full` model. The `fit` and `refit_trainval` test predictions stay in the run directory shown above; they are not returned as the shipped model's. Change map: see the `kumo-pr-change-map` comment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Victsm
left a comment
There was a problem hiding this comment.
Two remaining data-integrity blockers. The chunked-filesystem implementation is unchanged from the independently reproduced version; the latest ADLS test does not cover these cases.
- Failed constructor: start with
a.bin = b"preserve-me"; make_stream()raise while constructing_Writer(..., size=1). Finalization leavesa.bin = b"", because_failedis false andclose()publishes the empty spool. Keep construction failed until fallible setup succeeds, clean up on failure, and test that finalization preserves the old bytes. - Overlapping writers: with 16-byte parts, B writes 19 bytes; A commits the same destination and deletes B's unfinished first part; B writes 18 more bytes and commits. B's intended 37-byte file reads as 21 bytes despite
_SUCCESS. Cleanup must preserve other unfinished/newer generations; readers should reject a committed part layout with gaps. Add that exact same-path regression.
These corroborate the two open bot findings: #112 (comment) and #112 (comment) . No new transactional filesystem subsystem is requested.
Approval prerequisites: fix both cases, pass the focused regressions plus the existing chunked-filesystem suite, propagate the correction through the stack, and complete required CI. The filesystem-only model/prediction adapter issues are already addressed; those are not additional open blockers.
Reviewed head: 3d3c9d896b8d3a0490f19864419457828c15dd91.
…er publishes an empty file Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…parts are complete Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # packages/kumo-engine/docs/mlops.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # packages/kumo-engine/docs/mlops.md
…tem chart into #115 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stack: main (#53 merged) → #112 → #114 → #50 → #60 → #64 → #115. Merge bottom to top.
Summary
Prediction exports and model bundles can only go to S3, GCS, ADLS or local paths: the engine checks the connector type. Databricks volumes and Snowflake stages hold files too, but one upload is capped (5 GiB on volumes) or has no multipart. This PR lets any sink say which of its paths are files, and gives such stores one shared filesystem:
kumoapi.plugin.FileStoreSink: an optional protocol,holds_files(connector, path)(from the path alone) andfilesystem(connector, path)(apyarrow.fs.FileSystem).kumo_core.chunked_fs.ChunkedFileHandler: a pyarrow filesystem over five store calls (size, list, put, ranged read, remove). A file up topart_bytesis one plain object; a larger one is stored as parts under<name>.chunks/<generation>/and committed by an empty_SUCCESSwritten last.kumo_core.object_store_sink.FilesystemSink: the existing object-store writers over any sink'sfilesystem(). Bundles upload_provenance.jsonlast.kumo_core.object_store_sink.FileStoreRouting: a mixin that sends a sink's file paths toFilesystemSinkand its other paths to its own table methods.ObjectStoreSinkBackend.preflight_write_files: an export into an occupied output directory is refused before scoring, on every object store.No volume or stage sink ships here; #114 and #115 add them.
Usage
Back-compat
ObjectStoreSinkBackendholds every path).output_pathson a sink that does not hold the pathValueError, as before; the message now mentions volume and stage paths.Output directory ... already contains data from another exporterror that was raised after scoring.FileStoreSink_SUCCESSlands.abortis aborted; the previous file stays readable; the error is raised..chunks/file read by Spark or an older enginepart_bytesare plain objects.Testing
kumo-core/tests/test_chunked_fs.py: empty, sub-part, one-part and multi-part files; reads across part boundaries; newest committed generation wins; failed writes keep the old file and abort the stream; listings, deletes, streamed writes, spool location; concurrent rewrites.kumo-engine/test/test_filestore_sink.py: output configs, a bundle round trip, trainer publish and load, and prediction export, the occupied-directory refusal before scoring, and a file-only sink refusing a table through a filesystem-only sink over an in-memory store.Change map: see the
kumo-pr-change-mapcomment.🤖 Generated with Claude Code