Training fault tolerance: stage-based resume, run manifest, DB supersede semantics#122
Training fault tolerance: stage-based resume, run manifest, DB supersede semantics#122zachtheyek wants to merge 5 commits into
Conversation
TrainingRunState lives at {output_path}/run_state_{save_tag}.json and
carries everything a retry (in-process or a relaunch of the identical
command) needs to resume where the previous attempt died: run_start_time
(wall clock of attempt 1, used by all DB queries/plots), the attempt
counter, completed_rounds, and stages_done / stages_failed for the
training stage machine.
Writes are atomic (tmp -> os.replace, mirroring round_data's .done
protocol) so a crash mid-write leaves either the previous manifest or
the new one, never a truncated file. A corrupt manifest downgrades to a
fresh run instead of blocking training.
Part of the training fault-tolerance overhaul (#119).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stale rows from failed training attempts used to be re-written by the retry, duplicating epochs and corrupting the loss-curve plots. Rows are now flagged instead of deleted: - New superseded INTEGER DEFAULT 0 column on training_stats, injection_stats, latent_snapshots, and inference_results (PR-08 will reuse it for inference resume). - Minimal migration mechanism in _init_database gated on PRAGMA user_version, with idempotent ALTER TABLE ... ADD COLUMN steps — resolves the schema-evolution TODO minimally. - Database.mark_superseded(table, tag, *, round_ge, npy_path) executes through the writer-thread queue as a command tuple (like the flush sentinel): buffered rows are flushed first, then the UPDATE runs, so queue FIFO ordering guarantees rows written before the call are marked while later writes stay live. Runs inline when no writer is running. - All query_* methods on those tables gain include_superseded (default False) and filter superseded = 0. Part of the training fault-tolerance overhaul (#119). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
run_training_pipeline() is now an ordered stage machine (vae_rounds ->
vae_plots -> rf_train -> rf_plots -> final_save) with skip-if-done and
persist-on-success semantics driven by the persisted run manifest:
- TrainingPipeline.__init__ loads/creates the manifest and sets
self.start_time from its run_start_time unconditionally — fixing the
documented BUG where train_beta_vae()'s already-trained early return
left start_time unset and every plot crashed with AttributeError,
masking the real failure. Plots now query the whole run, not just the
current attempt.
- Round resume is manifest-driven (completed_rounds -> reload the last
completed round's checkpoint); each round records itself in the
manifest right after its checkpoint saves. Explicit --load-tag/
--load-dir flags still win as an escape hatch.
- On resume of round k, stale (tag, round >= k) rows in training_stats /
injection_stats / latent_snapshots are marked superseded before the
rerun re-writes them — no more duplicated epochs in loss curves.
- vae_plots is downgraded from raise-and-retry to record-and-continue
(a plot bug no longer costs a data regeneration), but failures are
recorded in stages_failed and surfaced as a nonzero exit at the end;
rf_plots failures are recorded the same way instead of being silently
swallowed by _safe_call.
- train_random_forest persists random_forest_{tag}.joblib as soon as
training completes and, on resume, reloads it together with the eval
artifacts instead of regenerating ~num_samples_rf cadences and
retraining.
- The config JSON dump moves into the final_save stage so it is covered
by the retry machinery.
Part of the training fault-tolerance overhaul (#119).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retry handler no longer hunts checkpoints with get_latest_tag() or
mutates config.checkpoint.{load_dir,load_tag,start_round} — rebuilding
the pipeline is enough, since the persisted run manifest tells it which
rounds and stages to skip (and works identically across full process
relaunches). KeyboardInterrupt passthrough and max-retries accounting
are unchanged.
The training-config JSON dump moved into the pipeline's final_save
stage, and train_command now exits nonzero (after logging loudly) when
the manifest records permanently failed plot stages, so silently missing
artifacts can't look like a green run. The resolved fault-tolerance NOTE
block is removed.
Part of the training fault-tolerance overhaul (#119).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Claude finished @zachtheyek's task in 1m 41s —— View job Code Review: Training fault tolerance overhaul
SummaryThis is a well-designed fault-tolerance overhaul. The architecture — a persisted run manifest driving an explicit stage machine with skip-if-done semantics — cleanly separates concerns and replaces fragile checkpoint-hunting with deterministic resume logic. The cluster kill/resume matrix is impressively thorough testing. Overall: looks good to merge. A few observations below, categorized by severity. Issues1. Minor:
|
| return False | ||
|
|
||
| try: | ||
| if self.rf_model is None: |
There was a problem hiding this comment.
Nit: the method checks that rf_eval_artifacts_{tag}.joblib exists (and won't return True without it), but doesn't actually load the artifacts into memory — that's left to plot_rf_diagnostics() → _load_rf_eval_artifacts(). The existence check is correct (it gates "is resume viable?"), but a one-line comment explaining this asymmetry would help future readers understand why we check-but-don't-load:
| if self.rf_model is None: | |
| def try_load_rf_for_resume(self) -> bool: | |
| """ | |
| Attempt to restore the trained Random Forest persisted by a previous attempt of this | |
| run: both random_forest_{tag}.joblib and rf_eval_artifacts_{tag}.joblib must exist | |
| under model_path (the artifacts joblib is what every RF plot consumes, so resuming | |
| into rf_plots without it would be pointless — but it is loaded lazily by | |
| _load_rf_eval_artifacts(), not here). Returns True when the model is loaded | |
| and ready; False falls back to full RF training. | |
| """ |
There was a problem hiding this comment.
Applied in e836642 — took the suggested wording: the docstring now states the eval-artifacts joblib is existence-checked here (gating resume viability) but loaded lazily by _load_rf_eval_artifacts().
| if stage in self.stages_failed: | ||
| self.stages_failed.remove(stage) | ||
|
|
||
| def record_stage_failure(self, stage: str) -> None: |
There was a problem hiding this comment.
Ultra-minor style nit: the docstring for record_stage_failure below starts on the same line as def then wraps — elsewhere in this module (e.g. mark_stage_done) single-line docstrings are used. No functional impact, just a cosmetic inconsistency.
There was a problem hiding this comment.
Fixed in e836642 — collapsed to a single-line docstring matching mark_stage_done's style.
…eyError handling - try_load_rf_for_resume: document the check-but-don't-load asymmetry (eval artifacts are loaded lazily by _load_rf_eval_artifacts, not here) - _execute_training_stages: make the caches-cleared-only-in-rf_plots-branch invariant explicit (the skip path never populates them) - record_stage_failure: single-line docstring per module convention - load_run_state: dedicated KeyError branch naming the missing required field - _writer_loop: capture the buffer size before clear() so the shutdown flush log no longer always reports 0 (pre-existing; function touched by this PR) Review notes on #122. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the review — all actionable notes addressed in e836642:
Affected unit tests re-run (89 passed) and all pre-commit hooks green. |
|
@claude please perform a second-pass code review of this PR. Re-review the current diff for any remaining correctness bugs, security issues, or significant quality problems, and post your findings. If the diff is clean, please say so explicitly. (Final verification round before merge.) |
Second-pass code review — PR #122I've done a thorough re-review of the current diff across all four production files ( Areas examined
Additional observations
VerdictThe diff is clean — no correctness bugs, security issues, or significant quality problems found. Ready to merge (after #117). Note: the |
Description
Training fault-tolerance overhaul: a persisted run manifest + explicit stage machine replace the checkpoint-hunting retry loop, and stale DB rows from failed attempts are flagged (never deleted) so plots stay clean across retries.
Stacked PR: base branch is
feature/training-data-pipeline(#117). This PR contains only the fault-tolerance commits; review after #117 and rebase ontomasteronce #117 merges.Closes #119
What changed
src/aetherscan/run_state.py—TrainingRunStatemanifest persisted at{output_path}/run_state_{save_tag}.json(atomic tmp ->os.replace, mirroringround_data's.doneprotocol):run_start_time(wall clock of attempt 1), attempt counter,completed_rounds,stages_done,stages_failed. A corrupt manifest downgrades to a fresh run.train.py—run_training_pipeline()is now an explicit ordered stage machine (vae_rounds->vae_plots->rf_train->rf_plots->final_save) with skip-if-done + persist-on-success semantics (_execute_training_stages, duck-typed for unit testing):TrainingPipeline.__init__setsself.start_timefrom the manifest'srun_start_timeunconditionally — fixes the documented# BUG:at the oldtrain_beta_vae()early return (unsetstart_time->AttributeErrorin every plot, masking the real error). All DB queries/plots now span the whole run, not just the current attempt.completed_rounds-> reload theround_{k:02d}checkpoint; each round records itself right after its checkpoint saves. Explicit--load-tag/--load-dirremain as an escape hatch (they override the manifest and clear downstream stage records).vae_plotsdowngraded from raise-and-retry to record-and-continue: all four plots are attempted, failures land instages_failed, andmain.pyexits nonzero at the very end if they never recover — a plot bug no longer costs a data regeneration, but lost artifacts stay loud.rf_plotsfailures are recorded the same way instead of being silently swallowed.train_random_forest()persistsrandom_forest_{tag}.joblibas soon as training completes; on resume it reloads model + eval artifacts instead of regenerating ~num_samples_rfcadences and retraining (gated on an actual resume so a fresh run reusing an old tag never skips training on stale artifacts).train_commandinto thefinal_savestage (covered by retry).db/db.py— supersede semantics + minimal schema migration:superseded INTEGER DEFAULT 0ontraining_stats,injection_stats,latent_snapshots,inference_results(PR-08 in the v1 plan reuses this for inference resume)._init_databasegated onPRAGMA user_versionwith idempotentALTER TABLE ... ADD COLUMNsteps — minimally resolves the schema-evolution TODO.Database.mark_superseded(table, tag, *, round_ge=None, npy_path=None)runs on the writer thread via a command tuple through the write queue (like the flush sentinel): buffered rows flush first, then the UPDATE — FIFO ordering guarantees rows written before the call are marked while later writes stay live. Runs inline when no writer thread exists.query_*methods on those tables gaininclude_superseded: bool = Falseand filtersuperseded = 0by default.(tag, round >= k)rows in the three training tables are marked before the rerun re-writes them — no duplicated epochs in loss curves.main.py—train_commandretry loop simplified: no moreget_latest_tag()checkpoint hunting orconfig.checkpoint.{load_dir,load_tag,start_round}mutation (the resolved# NOTE:block is removed); KeyboardInterrupt passthrough and max-retries accounting unchanged; exits nonzero when the manifest records permanently failed plot stages.No CLI/config surface changed (no README CLI regeneration needed).
preprocessing.py/inference.pyuntouched apart from theinference_resultscolumn + query filter (PR-06/08 own inference).Deliberate deviations from the plan spec
get_latest_tag()as a retry-loop fallback when no manifest exists. The manifest always exists on retries (attempt 1 writes it in__init__), and hunting checkpoints without one risks silently resuming another run'sround_XXcheckpoints on a fresh tag — so the manifest is the single resume driver;get_latest_tag()remains asload_models()'s internal fallback and intrain_random_forest's weight recovery.rf_trainadditionally persistsrandom_forest_{tag}.joblibat stage end — required for the spec's own "load instead of retrain" resume path to have something to load beforefinal_saveruns.Known limitation (deferred)
round_number=NULL, whichround_gemarking can't target; anrf_trainretry that regenerates the RF dataset can leave stale RF-generation rows live. Bounded (partial-generation rows only); PR-08/PR-10 own the follow-up.Type of change
How has this been tested?
Unit tests (local, arm64 venv w/ TF 2.17.1)
PYTHONPATH=src pytest -m "not gpu and not cluster" -q-> 269 passed, 3 skipped. New coverage:tests/unit/test_run_state.py— manifest round-trip, atomic tmp->replace persistence (failed write leaves the previous manifest intact, no.tmplitter), corrupt-manifest downgrade, stage/round bookkeeping.tests/unit/test_db.py— supersede marking (round_ge,npy_path, tag scoping), queue-FIFO semantics (queued-before rows get marked, written-after rows stay live), default query filtering incl. aggregation queries, inline execution without a writer thread, filter validation; migration: old-schema (v0) DB fixture gains the column +user_versionstamp, idempotent across reopens, fresh DBs created at current version.tests/unit/test_train_utils.py— stage machine against a stub pipeline: full fresh-run order, skip-if-done (incl. RF reload on skip), plot-failure record-and-continue, rf_train failure propagation + best-effort VAE save, reload-failure fallback to retrain, relaunch-retries-only-failed-stage.Cluster kill/resume matrix (blpc3, 5 GPU, singularity)
Smoke config:
--per-replica-batch-size 4 --per-replica-val-batch-size 4 --effective-batch-size 20 --num-samples-beta-vae 200 --num-samples-rf 200 --num-training-rounds 2 --epochs-per-round 2 --latent-viz-num-cadences-per-type 5, tagtest_v23(one continuous chain, relaunching the identical command after each SIGKILL); in-process retry run on tagtest_v24.completed_rounds [1]->Resuming from checkpoint round_01 (per run manifest), loop starts atROUND 2/2;round_ge=2marked 14432 injection + 20 snapshot rows stale (the 21 round-2 training rows died unbuffered in the write queue — nothing to mark); staleround_02data dir deleted + regeneratedvae_roundsskipped via manifest (stages_done ['vae_rounds'], vae_plots correctly unmarked), checkpointround_02reloaded, all four vae_plots re-ran to completion;round_ge=3marked 0 rows (nothing stale)vae_rounds+vae_plotsboth skipped; no RF artifacts had been persisted, so rf_train correctly re-ran from scratch; supersede no-oprandom_forest_{tag}.joblib+rf_eval_artifacts_{tag}.joblibat stage end)vae_rounds/vae_plotsskipped,Stage 'rf_train' already complete — loaded persisted RF model(no RF data regeneration), all ten rf_plots re-ran to completion,final_savewrote encoder/decoder/RF +config_{tag}.json, exit 0 withTraining completed successfully!already complete — skipping(rf_train via RF reload), clean exit 0plots/checkpointschmod 555 during round 1, restored during the 60 s retry sleep (--max-retries 3, tagtest_v24)PermissionError: .../plots/checkpoints/beta_vae_loss_curves_round_01.png(20:11:36); permissions restored during the retry sleep;Training attempt: 2/3at 20:12:37 resumed via the manifest, marked 42 training / 14432 injection / 40 snapshot rows superseded atround_ge=1, re-ran everything,Training completed successfully!at 20:45 — exercising the in-process retry + per-attempt producer teardown end to endFinal-state assertions (both tags): manifest shows all five stages done with
run_start_timeunchanged since attempt 1 (spans every attempt —test_v23's covered 5 attempts over ~1 h 45 m); exactly one livetotal_lossrow per (round, epoch) with all stale rows (superseded=1) filtered by default queries — the duplicated-epoch corruption is gone; encoder/decoder/RF/eval-artifacts/config all present; 56 final plots per tag;/dev/shmclean after every SIGKILL + relaunch cycle.Bonus real-world validation: the cluster's live
aetherscan.db(pre-existing schema, prior tags) was migrated in place on first open —user_version 0 -> 1,supersededadded to all four tables, old rows visible withsuperseded=0, and every later launch skipped the migration cleanly.Observed pre-existing quirk (not a regression, noted for PR-10): at smoke scale,
train_random_forest'scheck_encoder_trainedfallback loadedtest_v21weights viaget_latest_tag— the already-documented# BUG:block about tag reuse; this PR's tag-scoped RF resume path is unaffected.Deferred validation (bla0 unreachable this session)
--num-samples-beta-vae 3072 --num-samples-rf 960, apptainer/conda runtime).aetherscan.db(blpc3's live DB was migrated in place during this matrix — see results).Checklist
AI disclosure
This PR was implemented end-to-end with Claude Code: implementation, unit tests, docs/comments, cluster kill/resume verification, and this PR text (design from the maintainer's v1 plan). All cluster runs and assertions above were executed and checked as part of this session.