unsloth/studio/backend/tests/test_training_progress_callback.py
Eyera b52d3b56ed
feat(studio): rework train page setup flow (#7633)
* feat(studio): rework training setup and cache handling

* Rework train page resource selection

* Improve train page workflows and resource selection

* Fix train page picker behavior

* Fix stale training errors after token updates

* Fix training eval steps regression

* Fix shared token use when resuming training

* Harden train page resource workflows

Unify model and dataset picker behavior, training start guards, and token handling.

Validate cache provenance for local models, downloaded resources, and processed datasets. Prevent stale preview and history state while keeping the train page modular.

* Fix training method race and test isolation

* Restore training upload limit exports

* Fix training config formatting contract

* fix(studio): harden training model selection and preflight

* fix training resume, cache, and lifecycle reliability

* Fix training stop watchdog path stub

* Fix training start state and train page UI

* Unify train page selection controls

* Fix train page validation, localization, and paging

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Clean up train locales and restore recipes link

* fix(studio): correct training model selection and preflight

Preserve freeform local model paths, reject remote GGUF-only repositories, and correct the training start snapshot contract.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): harden training resource selection

Preserve local dataset path intent, localize setup-change errors, and remove unused picker tour hooks. Refresh model picker and PDF recipe contracts for the refactored training flow.

* fix(studio): remove train tab scrolling

* fix(studio): harden training resource preflight

Reject missing local models and binary adapter artifacts. Preserve selected model and dataset cache pins through preflight and QLoRA loading. Detect cache path changes before training starts.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix train page validation and cache handling

Keep transient model checks nonblocking and bind cached models and datasets to their selected snapshots.

Improve Hub auth errors, retries, task search, streaming consistency, locale formatting, reset handling, payload mapping, partial inventory filtering, theme accents, and regression coverage.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): stabilize train page pickers

Unify Hub validation, local inventory, picker styling, and dataset names.

Localize training feedback and explain streaming modality changes.

Add unit, contract, and cross-browser picker coverage.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): align cached scans and variant state purge

Scan selected cached snapshots during training security preflight.

Purge manifests and cancel markers using their stored GGUF variant.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): harden train cache, consent, and UI

Fix cache cleanup and cross-platform model path handling.

Align resume and adapter consent scans with pinned load targets.

Localize dataset flows and refine navigation, state cleanup, and theming.

* fix(studio): harden training resource selection

Preserve cached model configuration HTTP errors so stale cache references return their intended 404 responses.

Wait for device inventory settlement before locking inferred picker tabs, while keeping known device items visible during scans and retries.

Apply modality name heuristics only to the final model path component across supported platforms.

Expose full model and dataset identities on truncated picker triggers.

Add regression coverage for picker settlement, retry behavior, and modality inference.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix train picker flow and resume safety

Scan the exact cached model snapshot before resume consent and preserve the actual repository identity.

Keep cold pickers on Device until inventory settles, cap automatic Hub pagination, and provide a stable Load more action.

Improve train control and history card accessibility, remove duplicate token labeling and dead styling, and correct picker spacing.

Normalize Windows relative model paths and add coverage for resume pins, picker policy, pagination, path identity, and accessibility contracts.

Fix the reported formatting and import ordering issues.

* Fix cross-platform train dataset path detection

Use the shared local path detector so persisted Windows, Unix, UNC, and home-relative dataset references do not render Hugging Face-only controls.

Recognize Arrow dataset files and add regression coverage for supported local path formats and Hub repository identifiers.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix training start recovery and dataset selection

Reconcile failed fresh and resumed training starts with backend status before reporting an error.

Treat the selected dataset source as authoritative so filename-like Hub repositories retain subset and split controls.

Align dataset picker sorting and remove unused advisor template state with a persisted-state migration.

Add regression coverage for transport recovery and Hub dataset selection.

* Fix train picker validation and start recovery

Update the resume training contract test for the asynchronous failure handler.

Filter invalid Hugging Face search results and block invalid saved model and dataset selections.

Restrict local dataset selection to inventoried application paths.

Load training YAML through a bounded native file picker while keeping the browser fallback.

Only recover uncertain training starts after network or response parsing failures, and match the active job to its request identifier.

Treat cached full precision models as ready for QLoRA without showing a false download warning.

Add regression coverage for the picker, config import, and training recovery behavior.

* Harden training starts and config imports

Track training start request IDs from preflight through pending, accepted, and rejected states so retries remain idempotent.

Disable automatic retries for start posts and reconcile ambiguous client responses with server request status.

Clear terminal training status on reset so later runs do not inherit stale state.

Enforce the 1 MiB YAML limit for browser imports and keep read errors specific to the selected file label.

Remove unused dataset picker translation entries across all locales.

Add focused tests for request idempotency, start recovery, config limits, and picker contracts.

* Harden train picker feedback and start handling

Show explicit empty states for device dataset searches and invalid Hub model or dataset queries while keeping pagination sentinels mounted.

Run the train picker Playwright suite across Chromium, Firefox, and WebKit in Windows CI.

Keep training start outcomes owned by backend spawn completion so cancelled handlers cannot mark active jobs as rejected.

Correct the Train section heading hierarchy and format the branch-owned native file dialog test.

Add picker contracts, browser assertions, and cancellation race coverage for the updated behavior.

* Fix training setup races and configuration state

Keep hardware-based method selection within model-default loading so starts cannot race changing defaults.

Clear start errors only after deliberate configuration or token edits while background reconciliation stays silent.

Generate training request IDs safely for LAN HTTP access before acquiring the runtime lease.

Filter persisted state, sanitize invalid methods, and guard preview metadata.

Localize config size failures, use the toast wrapper, and remove obsolete training files.

Add regression coverage for the updated start, persistence, and import behavior.

* Refine training configuration and picker workflows

Split the training configuration store and parameter panel into focused persistence, policy, LoRA, hyperparameter, memory, and MLX modules.

Run picker coverage on platform-appropriate browser engines and parameterize the unmanaged dataset path.

Surface configuration changes when streaming is disabled and clear stale dataset modality state after failed probes.

Consolidate dataset format and AI-assisted mapping on hub routes with header-based Hugging Face tokens.

Hold local path actions until model inventory loading settles.

* Fix train picker state and preflight regressions

Open online pickers on the Hub before inventory settles and keep the inferred tab stable for the session.

Preserve parameter and VRAM metadata for pinned selected models.

Reject failed backend starts once and cancel automatic modality corrections without showing a destructive setup error.

Update persistence contracts to follow the extracted training configuration module.

Move shared parameter option styling out of the React component module to preserve Fast Refresh.

Add regression coverage for picker tab behavior, pinned model metadata, persistence contracts, and start rejection handling.

* fix(train): harden model selection and start feedback

Preserve cache metadata and model capabilities when freeform local paths resolve to discovered models.

Return stable training start error codes through direct and recovered requests, then localize Hugging Face preflight failures across every supported locale.

Show whether submitted advanced settings are default or non-default in Simple mode, and migrate the parameter mode preference to the standard storage key.

Restore absent-key migration semantics and update the embedding security test for the extracted gate helper.

Add focused coverage for structured errors, advanced setting summaries, start recovery, and security gates.

* fix(train): harden picker validation and focus

Align Hub ID validation with Hugging Face rules while accepting valid underscore boundaries.

Reject persisted path-shaped datasets from Hugging Face-specific controls.

Reuse model identity normalization for cache path comparisons.

Move picker search focus into the popover focus lifecycle.

Update source contract tests for extracted model selection and structured resume errors.

* fix(train): refine picker behavior and dataset structure

Wait for device inventory before locking inferred picker tabs while preserving explicit user choices.

Align Hub resource ID validation with backend rules for leading and trailing underscores.

Support ArrowDown navigation from picker tabs into available options.

Split dataset selection, uploads, streaming settings, and inventory refresh logic into focused modules.

* fix(studio): stabilize train picker contracts and state

Update dataset contracts to follow the extracted selection, upload, and inventory modules.

Keep the inferred picker tab stable while device inventory settles.

Use the shared TrainingMethod type for VRAM estimation and remove redundant casts.

* fix(train): align Hugging Face repo validation

Allow underscores at repository segment boundaries to match Hugging Face repo ID rules.

Keep model and dataset search queries unvalidated while preserving selection safeguards.

Add regression coverage for accepted IDs and unrestricted picker searches.

* Fix training resource compatibility and cache safety

Restore legacy dataset format and mapping routes while moving training uploads and checks to the canonical Hub endpoints.

Enforce configured upload limits, clean partial files after failures or cancellation, and support both upload route families in middleware.

Align Hugging Face repository validation with accepted underscore boundaries and skip PyTorch dependent tests when the dependency is unavailable.

Keep model format and cache metadata correct when the same repository is selected from another source.

Only promote complete runnable model weights as cached training resources, align download notices with picker capabilities, and reject weightless local snapshots during start preflight.

Cancel stale dataset mapping assistant requests before they can overwrite a newer selection.

Preserve touch selection for training methods by separating tooltip toggling from Select item activation.

Add focused backend, frontend, and picker regression coverage for these paths.

* fix(studio): finish train page and onboarding rework

Ignore cache-only fields when comparing training start inputs so reconciliation does not cancel valid runs.

Route native dataset drops through Tauri path validation and lease-backed managed uploads.

Use the production model and dataset selectors in onboarding and replace placeholder uploads with real imports.

Require positive learning rates while keeping learning rate, epoch, and step inputs editable.

Normalize model identities, improve dataset summary order, and guard persisted selection state.

Recognize Windows rooted and drive-relative paths consistently in picker logic.

Add MLX optimizer help and learning rate validation messages to every supported locale.

Add frontend, backend, and native policy regression coverage for the updated behavior.

* chore(ci): remove train picker smoke workflow changes

Remove the train picker Playwright step from the macOS Studio smoke checks.

Remove the train picker Playwright step from the Linux Studio smoke checks.

Remove the train picker Playwright step from the Windows Studio smoke checks.

Drop the matching artifact upload paths so the workflow files match upstream main.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(studio): harden train picker behavior and boundaries

Expose dataset drop helpers through the training public API and move local path detection into shared code.

Block freeform Hub choices while offline for pointer and keyboard selection.

Make native dataset drop handling safe for committed React renders.

Keep recipe navigation working when session storage is unavailable.

Preserve Windows drive root identities and cover the distinction from drive relative paths.

Update picker and training input contracts to match the extracted implementations.

Remove orphaned translations and align the train style block with the existing indentation.

* Fix train picker cache and upload behavior

Keep selected model cache paths strict and preserve fallback discovery only when no path is supplied.

Offload multipart writes and native dataset copies from the backend event loop.

Prewarm train picker inventories so device-first tab inference can settle before the first open.

Use shared picker tab constants across the model and dataset selectors.

Update picker contracts and add regression coverage for cache resolution and dataset uploads.

* Refine train picker controls and component structure

Split picker retry, error, and pagination UI into focused components.

Add a shared segmented control and use native radio semantics for dataset source selection.

Centralize segmented indicator positioning with RTL-aware transforms.

Separate dataset advanced settings state wiring from its presentation and source toggle.

Make picker arrow navigation move symmetrically from tabs through search and options.

* Fix train page reconciliation and picker contracts

Preserve tuned training parameters when model cache references change, while refreshing defaults when no user edits occurred.

Keep streaming modality corrections visible through the Start CTA after preflight stops.

Localize the new training parameter and dataset mapping text across all supported locales.

Refresh picker structural contracts after the component split and apply Biome formatting to the changed files.

* Fix train upload tests and desktop drop handling

Pass an explicit empty native path lease in legacy upload limit tests so direct route calls match FastAPI request behavior.

Track Tauri scale factor changes during dataset drag and drop, clean up both listeners safely, and cover runtime updates.

Replace the run preview card's important border utility with an explicit dark theme card modifier.

* fix(studio): harden training setup and history state

Restore Hugging Face token entry and validation in the onboarding model and dataset steps.

Apply the configured upload size preflight before onboarding dataset uploads.

Update moved model and dataset cache references without showing false missing cache warnings.

Parse SSE frame delimiters by their matched length so mixed line endings preserve event data.

Match sensitive pid paths by exact segment while retaining database state protections.

Clear deleted Data Recipe selections after inventory settlement while preserving direct uploads.

Show the files deleted status only when a run previously recorded an output directory.

Handle Data Recipe navigation failures and keep the native drop listener stable during uploads.

Replace formatting-specific contract checks and add focused behavioral regression coverage.

* Harden training start and model picker flows

Split the train model picker into its own entry point to keep it out of shared route bundles.

Preserve pending idempotent start reservations and reconcile them before accepting a run.

Keep unconfirmed starts in a polling state with accurate localized feedback.

Continue starts after automatically disabling unsupported multimodal dataset streaming.

Localize the updated model picker controls across supported languages.

Lock dataset uploads before asynchronous preflight to prevent concurrent selections.

Add regression coverage for reservation states and unconfirmed runtime starts.

* fix(studio): harden training picker edge cases

Preserve POSIX model path identity while retaining Windows relative path normalization, and clarify the override migration behavior.

Localize the onboarding model and dataset controls across every supported locale.

Truncate native dataset filenames by Unicode code point so generated labels cannot contain lone surrogates.

Cancel training after preflight disables streaming for image or audio datasets so users can review the changed setting before restarting.

Update runtime contract assertions for the setStartPending rename and add regression coverage for path identity and Unicode truncation.

* fix(studio): align training picker controls

Restore full-height Browse and Amazon S3 selection with an accessible radiogroup that avoids fieldset sizing behavior.

Align model and dataset picker tabs with the shared 36 px segmented control geometry and typography.

Remove active borders and focus rings from picker search inputs while preserving keyboard focus indicators on other controls.

Add contract coverage for segmented control sizing and picker search focus styling.

* fix(studio): harden onboarding picker flows

Classify native dataset drops from the full path before shortening display names.

Keep onboarding model choices within the selected training type across Hub, device, and freeform selections.

Retry model default loading when onboarding restores an interrupted model selection.

Localize the Hugging Face token field across supported languages.

Reset picker result scrolling when switching between Device and Hugging Face tabs.

Add regression coverage for native filenames, model constraints, hydration, and picker scrolling.

* fix(studio): reconcile multimodal streaming before training

Disable streaming when dataset checks detect image or audio data, then recheck cached selections in non-streaming mode.

Keep start-time modality detection as a safe fallback that updates the configuration and continues without an error or a second click.

Build onboarding training method options from the shared method order and metadata so onboarding stays aligned with the train page.

Add contract coverage for the streaming reconciliation and shared training method list.

* Fix train picker contracts and dataset recovery

Allow local models without reliable modality metadata to pass onboarding constraints while retaining explicit mismatch checks.

Reconcile GPU selection without effect-driven state updates and mark intentional deep Hub imports for lint.

Return and consume stable local dataset cache miss codes, and send the canonical train_split field.

Move train-specific picker code into its owning feature and update the related contract coverage.

Use the canonical Hub dataset progress route from Chat.

* Harden training resource selection and offline handling

Return an actionable preflight error when offline mode is enabled and the selected model is not cached.

Validate evaluation dataset uploads with the shared extension policy and reuse the centralized accept list.

Disable streaming for explicit on-device dataset selections while preserving Hugging Face selection intent.

Keep dataset identifiers safety checked without blocking benign values before Hugging Face handles repository validation.

Remove the branch-added source assertions and cover the new selection policy with behavioral tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix training setup validation and async state races

Validate Hugging Face dataset IDs and repository access before training starts while keeping local paths on their filesystem validation flow.

Scope model default race protection to fields that defaults can overwrite so unrelated dataset edits no longer suppress model initialization.

Stop Hugging Face token edits from invalidating token-independent device inventory scans on every keystroke.

Add regression coverage for dataset source validation, cached and remote Hub preflight, model default application, and token inventory behavior.

* Tidy up train page layout, tooltips and dark mode

Layout
- Move the Browse / Amazon S3 toggle into the Dataset section header
- Put Subset, Train Split and Evaluation Split on one row
- Put Target Format, Train Split Start and Train Split End on one row
- Pair Project Name with Max Steps, and Context Length with Learning Rate
- Centre the hyperparameter tabs and give them a fixed width so the
  sliding indicator lines up with its segment
- Give parameter rows a min height so slider and select rows share one
  vertical rhythm
- Slightly more spacing below section headings and at card bottoms

Tooltips
- Add hints for Model, Method, Dataset and Project Name
- Move field descriptions into the tooltips and drop the inline copy
- Upload field keeps the accepted file types inline, with size limit and
  Learning Recipes note in the tooltip

HF token
- Show a masked preview of a saved token instead of a generic label
- Read Not set when no token is stored

Dark mode
- Drop borders on the LoRA option cards and target module chips, and
  separate states with background fill instead
- Use a lighter, less saturated green for the selected state

Other
- Expand LoRA Settings and Training Hyperparameters when Advanced opens
- Shorten the upload label to Drop file or click to upload
- Localise the new wizard tooltips across all locales

* Fix training defaults, MLX validation, and dataset preflight

Prevent late hardware recommendations from replacing configuration values loaded after model selection.

Block unsupported MLX methods and embedding runs in the UI and backend before training is queued.

Require live Hub access for streaming dataset preflight instead of accepting unrelated cached data.

Compare advanced settings against applied model defaults and cover the updated behavior with focused tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix training picker validation and platform safeguards

Update the Tauri YAML contract test to verify save filter behavior without depending on the Rust vector implementation.

Align frontend Hub resource validation and training provenance normalization with the shared repository ID rules, including valid underscore and maximum length identifiers.

Reuse one frontend training method policy so Apple Silicon onboarding and the Train page consistently disable CPT and prevent unsupported onboarding completion.

Add regression coverage for rejected Hub IDs and exact resume provenance.

* Fix MLX training capability validation

Detect hardware before platform validation so unsupported MLX configurations fail before a job is queued.

Reject audio dataset training during server preflight while preserving the worker guard.

Share Apple Silicon capability checks across model selection, onboarding, readiness, submission, and LoRA controls.

Cover audio, CPT, embedding, LoftQ, DoRA, and warm-start detection with regression tests.

* Fix training quit protection, translations, and preference reset

Protect desktop quits while a training start is pending or a run is active, and keep the warning text accurate.

Localize dataset upload details across every supported locale and require those keys in parity checks.

Centralize the training picker and parameter mode storage keys so resetting local preferences clears current and legacy values.

Add regression coverage for training activity transitions and preference reset ownership.

* Preserve multimodal training support

Keep upstream training eligibility authoritative while preserving separate vision, audio, and embedding capabilities.

Classify dual audio and vision models for vision training without changing inference support or enabling pure audio training on MLX.

Add coverage for model type constraints, backend type resolution, and MLX validation.

Apply the pending Biome formatting fixes to the dataset selector and training wizard.

* Fix training defaults retry and clean stale translations

Allow model defaults retries after failed cache reconciliation without overwriting user-edited settings. Ignore stale fallback vision checks once a newer defaults request starts. Remove unused parameter description keys from every locale and cover the retry behavior in the training contract tests.

* Document direct model identity imports

Explain why the model identity helpers bypass the Hub barrel beside each lint suppression.

* Preserve training settings across reloads

Persist applied model defaults and their advanced settings baseline while refreshing transient model metadata without overwriting tuned hyperparameters.

Keep remote format probe tests runnable without an installed huggingface_hub package by supplying a fake module.

Normalize tilde-prefixed path separators while preserving case-sensitive identities.

Remove unmatched model and dataset tour anchors.

Add regression coverage for persistence migration and model identity normalization.

* Harden train page validation and persistence

Validate CPT embedding learning rates against backend bounds and add localized client feedback.

Keep invalid learning rate drafts out of persisted state and YAML exports.

Notify users when multimodal model selection restores a non-S3 dataset source.

Remove W&B tokens from training config persistence and migrate stored secrets safely.

Update the model defaults contract test to cover metadata refresh without requiring the removed early return.

Move segmented control styles into a non-component module to preserve Fast Refresh behavior.

Add validation and persistence coverage for the new contracts.

* Fix training model IDs, cache state, and localization

Preserve root-level Hugging Face model IDs instead of rewriting them into the Unsloth namespace.

Debounce token-scoped inventory reconciliation so token and inventory updates use stable request keys.

Keep the selected training history run when the Studio view remounts.

Localize dataset subset and split selectors across every supported locale.

Complete Italian picker and training translations and enforce Studio-wide locale parity.

* Fix training history errors and stale stop state

Return structured artifact deletion errors and show localized messages that distinguish active training output from filesystem failures while keeping history intact.

Clear superseded stop requests only when they belong to the current runtime generation, and cover start invalidation and stale stop handling with regression tests.

Clarify that shared picker styles apply outside the Hub while Hub-only rules remain scoped.

* Fix training method persistence and settings summaries

Persist training method provenance so manual learning rates, model adapter rates, and pre-CPT dataset formats survive reloads and method changes.

Deduplicate imported target modules and compare advanced-setting arrays by value counts so duplicate entries cannot hide non-default settings.

Add migration and regression coverage for legacy state, rehydrated method transitions, restored learning rates, and duplicate target modules.

* Fix persisted completion defaults and inventory retries

Persist trainOnCompletions across reloads so model defaults and advanced settings summaries remain accurate.

Defer missing legacy values until model metadata loads, preserving tuned settings while respecting streaming, raw text, CPT, embedding models, and explicit user changes.

Settle partial inventory failures when usable rows exist so manual local model paths and Enter submission remain available.

Add retry actions for partially failed model and dataset scans, including when filtering leaves no visible results.

Add regression coverage for persistence migration, constrained completion defaults, inventory settlement, and picker empty states.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix training validation and route state regressions

Retry newly detected image datasets with vision-aware validation, clear stale dataset check failures, and cover the failed background classification case.

Restore Train history cleanup and centralize the learning recipes navigation intent key.

Repair backend route stubs for canonical model IDs and path normalization, and isolate consent tests from shared package import state.

* Preserve training cache pins during preflight

Keep model and dataset cache flags and local paths in the pending start comparison so reconciliation cannot submit a stale snapshot.

Cover changed cached copies and uncached transitions with focused regression assertions.

* Preserve Hub identity for cached training exports

Keep exact cached snapshots for training while restoring the standard Hub repository identity before PEFT saves.

Recover repository identity in memory for legacy adapters so imatrix exports work without rewriting adapter configs.

Cover repository mismatches, local models, and Windows cache paths with focused regressions.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix cached training, evaluation, and MLX resume behavior

Limit cached dataset slice loads so small training ranges do not materialize full duplicate caches.

Restore Hub model identity for cached Torch and MLX runs so saved adapters remain portable.

Attest MLX model provenance across runtime and prequantized 4-bit formats so valid checkpoints can resume.

Recognize missing cached eval splits, reload train and eval together, treat explicit eval split errors as fatal, and use deterministic held-out data for automatic fallback.

Retain non-fatal evaluation warnings in training status and render them in the train UI.

Apply pin and model format validation to every MLX worker entry point and keep the worker test fixture aligned with its imports.

* Stabilize training progress updates across job handoffs

Keep active and pending training ownership separate so status polling cannot switch identities during a running job.

Scope metrics, progress streams, stop, and reset operations to the expected job and recheck ownership during handoffs.

Make polling, stream parsing, and start reconciliation abortable, monotonic, and safe against stale overlapping requests.

Preserve same-run interface state so live updates do not remount progress controls or dismiss the stop confirmation.

Add backend and frontend regression coverage for ownership changes, stale events, request races, and stream cleanup.

* Fix cached training fallback and dataset reconciliation

Recognize incomplete SentencePiece tokenizer caches and retry online model loads through the Hub while preserving strict offline and resume pins.

Prevent rejected dataset snapshots from being promoted again until the selection or inventory changes.

Forward cancellation signals to dataset format requests so superseded checks stop at the transport layer.

Add focused backend and frontend regression coverage for these cache paths.

* Harden offline training and cache fallback behavior

Validate cached model snapshots for tokenizer and processor support, then fall back to the pinned Hub revision when the cache is incomplete.

Remember rejected dataset cache entries and cancel superseded checks so metadata-only snapshots cannot trigger request loops.

Reject deleted local datasets before any Hub access and clear only the matching stale selection in background, preview, and start flows.

Reserve training starts before validation so overlapping requests and GPU consumers cannot race the spawn window.

Keep GGUF fallback metadata tied to the cache that supplied variants when Hub access fails.

Filter DSpark and DFlash drafter companions across picker, loading, inventory, and deletion paths while preserving MTP behavior.

* Harden training setup and picker behavior

Keep training starts blocked while a stop request is pending and preserve the stop latch through backend failures.

Skip malformed training progress events while validating SSE payloads and releasing stream readers safely.

Enforce non-streaming state for upload and S3 datasets across source changes, persistence, and rehydration.

Route Markdown dataset drops to Data Recipes across browser and native path formats.

Give model and dataset source tablists localized accessible names.

Refresh training start contracts and add focused regression coverage for the corrected behavior.

* Fix offline dataset cache selection and token status

Prefer finalized processed dataset caches for offline training while keeping raw cache paths available for scoped management.

Treat malformed Hugging Face tokens as unset in Train and surface local validation feedback without unnecessary requests.

Add regression coverage for cache coexistence, interrupted cache builds, path propagation, and malformed token presentation.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Preserve sidecar Hub version during dataset cache checks

Keep processed dataset cache discovery free of eager datasets imports so training does not preload the base Hugging Face Hub package.

Preserve cache root discovery through HF_DATASETS_CACHE, HF_HOME, and XDG_CACHE_HOME before Transformers sidecar activation.

* Add the AGPL-3.0 header to the two new studio training contract tests

* Scan the fallback target and pin cached snapshots that hold weights

Cache fallback scanned the pinned snapshot it was about to discard, then
loaded the Hub repo unscanned. Scan after the pin is dropped instead, in
all four training paths.

Preflight probed the literal model name, so the registry bicodec alias
Spark-TTS-0.5B/LLM 404'd and rejected a supported model. Probe the repo
the trainer downloads and treat its load subdir as the weight root.

An unpinned start still downloads its dataset, so a stray cached copy no
longer skips Hub verification. Offline keeps accepting the cache.

refs/main can point at a metadata-only revision, so the model pin now
prefers a snapshot that carries weights before falling back.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let the capability-cache guard see through the key type alias

model_config.py names the cache key _CapabilityCacheKey, so matching the
literal Dict[Tuple no longer finds it and the guard fails on a cache that
is still correctly tuple-keyed. Resolve module-level aliases first.

Checked by mutation: reverting a cache to Dict[str, ...] is still caught.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: point the extra-UI tour anchor check at studio-model-picker

The train page rework renamed the model tour anchor from studio-model to
studio-model-picker and updated tour/steps/base-model.tsx accordingly, but
tests/studio/playwright_extra_ui.py still looked for the old anchor, so the
Chat UI Tests job failed with "[data-tour='studio-model'] not found".

Verified against two live Studio instances: the anchor is studio-model on
main and studio-model-picker at this head; studio-dataset and studio-params
are unchanged on both.

* Studio: require metadata and weights together when pinning a model snapshot

Pass 1 of _resolve_model_snapshot matched on weights alone, so two cases still
selected a snapshot that /training/start then rejects with "does not contain
trainable weights":

- a newer weights-only fetch (interrupted download, or an allow_patterns pull
  that never took config.json) displaced an older complete sibling. That is a
  regression against the previous metadata-first ordering, which started the
  run; reproduced with a two-snapshot cache where the complete one is older.
- consolidated.safetensors counted as weights for selection but is absent from
  _MODEL_WEIGHT_CANDIDATES in routes/training.py, and transformers 4.57.6 has
  no loader path for it (zero references in the package), so a config plus
  consolidated snapshot won over a loadable sibling.

Pass 1 now demands metadata AND weights via a required_groups argument on
latest_snapshot_from_cache_path, and consolidated.safetensors is dropped from
the selection tuple so it matches what the route accepts. Pass 2 keeps the
metadata-only fallback unchanged, so caches that never held weights resolve
exactly as before.

Both new tests in test_model_cache_snapshot.py fail without this change and
pass with it; 401 tests across the cache, preflight, provenance and identity
suites pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: make the new cache tests pass on Windows

Four of the PR's new tests fail on windows-latest while passing on ubuntu and
macos. All four are defects in the tests, not in the production code.

The three blob-symlink tests hardcode a POSIX relative target
("../../blobs/x"). Windows stores the reparse-point substitute name verbatim
and resolves it in the object namespace, where / is not a separator, so the
link is created but dangles: Path.resolve(strict=True) raises and the
provenance and dataset-cache helpers correctly return None. huggingface_hub
builds these targets with os.path.relpath (file_download.py _create_symlink),
which yields ..\..\blobs\x on Windows, so a real cache never has this shape
and no Windows user is affected. Building the target with os.path.relpath /
os.path.join matches what huggingface_hub writes.

The cross-snapshot rejection tests had the same POSIX targets, so on Windows
they were passing for the wrong reason (dangling link rather than the escape
check). They now use native separators too, so the rejection logic is actually
exercised there.

test_runtime_4bit_resume_reaches_worker_with_source_resource_pins compared
model_local_path against str(Path); the route posix-normalizes that field via
normalize_path, so the assertion now uses Path.as_posix(), which is a no-op on
POSIX.

324 tests across the three files pass on Linux.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: bound the cached dataset re-check and stop adopting unconfirmed starts

Two defects introduced by this PR's new modules.

1. Unbounded dataset re-check (#7853). training-config-store.ts re-runs the
   cached format check whenever rejectValidation() reports the in-flight token
   as stale, with no counter and no backoff. DatasetCacheRejectionTracker
   advances its generation on any inventory-fingerprint change, and the
   fingerprint includes sizeBytes, so a dataset that is still downloading
   invalidates every check in flight and the pair never converges. Measured at
   480 requests in 60s; a probe driving the real tracker ran 500 iterations
   without settling while a stable inventory settles in 1.

   The retry now draws from a per-selection budget (dataset-recheck-budget.ts)
   and falls through to the uncached check once spent, so the answer still
   refreshes when the inventory genuinely changes but a churning one cannot
   spin. The budget resets when the dataset or split changes.

   Fixing the tracker fingerprint instead was rejected: existing tests pin the
   behaviour that a sizeBytes change makes a rejected cache retryable.

2. Unconfirmed starts adopted as recovered. reconcileTrainingStartTransportFailure
   ended with adoptAcceptedTrainingStart(pending.jobId, ...) and returned
   "recovered". The backend reserves the request id and job id before the heavy
   preflight, so a start still pending when the 30s window closes may yet be
   rejected. Adopting there reported success and pinned a job id that never
   became current_job_id: acknowledgeTrainingStartRequest was never sent, and
   dismissTrainingRun's expectedJobId check then returned "superseded", leaving
   the rejected state unclearable until a new start re-reserved it.

   It now calls the module's existing settleUnconfirmedTrainingStart and returns
   "unknown", which both callers already handle by warning startUnconfirmed and
   settling unconfirmed, and which preserves start_request_id for acknowledgement.

New tests fail without each change and pass with it; removing the bound makes
them fail rather than hang. typecheck, 598 frontend tests, build, and the 154
studio contract tests all pass.

* Studio: retry tokenizer-less pinned snapshots, and skip Hub preflight when offline

Two more defects introduced by this rework. Neither mechanism exists on main:
worker.py there has 0 occurrences of local_files_only / model_snapshot_path /
cache_artifact, and routes/training.py has 0 of model_info / dataset_info.

1. Tokenizer-less pinned snapshot is terminal (#7845). The pin only requires
   config.json plus a weights file, so a snapshot with no tokenizer pins clean,
   local_files_only is set, and AutoTokenizer then fails. Recovery is gated on
   _is_model_cache_artifact_error, whose marker list matches only three of the
   message shapes transformers actually emits. Sweeping all 159 tokenizer
   classes against a tokenizer-less snapshot: 37 failures were classified not
   retryable, of which 26 are genuine cache problems that got zero Hub retry,
   including XLMRoberta (BGE-M3, multilingual-e5, LaBSE), MBart, NLLB, Bloom,
   GPTNeoX, Cohere, Marian and the generic PreTrainedTokenizerFast.

   SentencePiece and BPE families resolve a missing vocab path to None and then
   dereference it, so the failure arrives as a bare AttributeError with no
   cache-specific text. Adding those four shapes takes the sweep from 37 misses
   to 11, and all 11 remaining are correctly fatal (missing optional Python
   dependency, or an unsupported tokenizer class), which no Hub retry can fix.

   Widening the classifier rather than validating tokenizers before pinning:
   the vocab filename space is as open-ended as the exception space across 159
   classes, requiring a tokenizer would break the deliberate adapter_config.json
   pin path, and a real loadability check means loading a tokenizer inside the
   start request. The recovery path is already gated on offline and
   require_exact, so a false positive costs one Hub attempt while the current
   false negative fails the run.

2. Blocking Hub preflight with no reachability guard. Both preflight legs retry
   metadata at 5s then 10s with no reachability check, and requests applies the
   timeout per resolved address. Measured 30.0s added to a single
   POST /training/start with the Hub black-holed, scaling with the number of
   addresses, surfacing as 503. utils.utils already provides a bounded, memoised
   hf_unreachable/hf_dns_dead that the training worker subprocess uses one layer
   down; the route that spawns it did not consult it.

   The model leg raises inside the existing try, so the except HTTPException
   handler still runs _resolve_model_snapshot and a cached snapshot pins exactly
   as before, just without first burning the remote budget. The guard fails
   open, so an online start is unchanged.

New tests fail without each change and pass with it, including a wiring
contract that fails if the guard stops being consulted. 443 tests across the
preflight, cached-start, provenance, snapshot and streaming suites pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Make the import-hoist lint honour __all__ re-exports

Source lint failed on studio/backend/utils/security/__init__.py:

  [BLOCKER] HOISTED-IMPORT-UNUSED 'load_scan_target'
  (['from:utils.security.file_security:load_scan_target']) added but unused

That file is a barrel __init__: every name it imports carries "# noqa: F401"
and is listed in __all__, and routes/training.py imports load_scan_target from
the package rather than the submodule, so the re-export is load-bearing.

HOISTED-IMPORT-UNUSED only excluded re-exports that already existed before the
change, so adding any new name to an existing barrel was an automatic blocker.
The script's own docstring already records this shape as a known false positive
for NEW-UNUSED-IMPORT. Skip module-level imports whose bound name appears in
__all__, since those are loaded by importers rather than by the module itself.

The rule keeps its teeth: a newly added import whose name is not in __all__
still blocks, and a dangling alias still trips UNRESOLVED-NEW. Verified with a
four-case mutation matrix plus --self-test, and the full 79-file changed-set
lint now reports OVERALL: PASS.

* Keep studio off evaluated PEP 604 unions on the 3.9 floor

tests/test_python39_compatibility.py failed on Core (HF=4.57.6, default and
latest) and Repo tests (CPU):

  test_no_pep604_unions_are_evaluated_on_the_declared_floor
    model_config.py:937: Tuple[...] | Tuple[...] (type alias)
  test_studio_evaluated_unions_do_not_grow
    36 studio files now evaluate PEP 604 unions, up from 35

Both offenders came from this branch. model_config.py:937 is a module-level
type alias, so it is evaluated at import time and "from __future__ import
annotations" cannot defer it; it needs typing.Union, which is what the test
message prescribes. hub/utils/dataset_cache.py is the one file that pushed the
count to 36, and all five of its unions are function annotations, so the future
import is enough there.

No behaviour change: the alias is only a cache-key type, and the annotations
are unevaluated either way.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Key the dataset re-check budget on the whole selection

The budget added for #7853 keyed on dataset + split only, but a dataset cache
usability identity has four user-chosen dimensions: dataset, subset, split and
streaming. Changing subset or toggling streaming therefore kept the same key,
so a genuinely different selection inherited an exhausted budget, skipped the
cache-preferring re-check and dropped straight to a remote resolution with
datasetKnownCached cleared.

Key on all four instead, JSON-encoded so no delimiter can collide with a name
containing it and null stays distinguishable from the string "null".

cachePath is deliberately still excluded even though the usability identity
carries it. It is derived state that moves as a download populates the cache,
so feeding it into the key would mint a fresh budget on every poll and re-arm
the exact non-terminating loop this module exists to bound.

Tests: two new cases cover subset and streaming, and an inverse case pins that
nothing outside the selection can refresh the budget. Reverting the key to
dataset::split fails exactly the two new cases and no others.

* Stop the pinned snapshot path reaching PEFT as the base model name

A completed run wrote a machine-local path as the adapter's base model:

  base_model_name_or_path = '/home/user/.cache/huggingface/hub/
                             models--unsloth--Llama-3.2-1B-Instruct/snapshots/0123…'

where main writes the Hub id. It lands in adapter_config.json, every
checkpoint-*/adapter_config.json, the run card, export_metadata.json for merged
and GGUF exports, and the model card push_to_hub uploads, none of which resolve
on another machine.

restore_hf_cache_repo_identity runs in UnslothTrainer.load_model before
get_peft_model, so its peft_config branch has nothing to repair yet, and PEFT
then derives the name itself:

  # peft/mapping_func.py
  new_name = model.__dict__.get("name_or_path", None)
  peft_config.base_model_name_or_path = new_name

PreTrainedModel.__init__ copies config.name_or_path onto the instance, so
restoring only config._name_or_path leaves that slot holding the snapshot path.
Restore the instance attribute too. It goes through the same guard as the other
fields, so an ordinary local model, an unrelated Hub id and a repo mismatch are
all still left alone.

Tests are behavioural: the existing model identity suite asserts the call site
via AST and stays green with this bug present, which is why it was missed.
Removing the new line fails 3 of the 6 added tests and none of the other 11.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Resolve cached snapshots that load from a subdirectory

unsloth/Spark-TTS-0.5B keeps everything trainable under LLM/: its snapshot root
carries only README.md and config.yaml, no config.json and no weights.
_resolve_model_snapshot only looked at the root, so a cached copy resolved to
None. _apply_model_cache_pin then warned "Cached copy not found on disk;
downloading", and offline the start route turned the same None into a 409
hf_model_not_cached_offline for a model that was sitting in the cache.

The remote preflight already handles this: it expands load roots through
load_scan_target. Only the cached path disagreed. Reuse security_load_subdirs,
which already reports ("LLM",) for BiCodec, so both paths share one source of
truth, and apply it to the resume and preflight pin lookups too rather than
just the fresh-start one.

Detection can raise offline or for a gated repo, so a failure degrades to
root-only rather than propagating.

Tests build the real models--org--name/snapshots/<rev> layout. Making the
helper a no-op fails 3 of the 6, and a snapshot with nothing loadable in either
place still resolves to None, so the widening cannot mask an empty cache.

* Use Optional in the deprecated dataset alias signatures

The rewritten alias module annotates two parameters as UploadFile | None and
str | None with no postponed annotations, so they evaluate at import on the
declared 3.9 floor while the rest of the file already uses Optional[...].

The file was an evaluated-union offender before this change too, so this is not
a new break, just keeping the new code consistent with its own convention and
off the debt list.

* Apply the repo kwarg-spacing formatter to the new code

pre-commit.ci flagged the ruff-format-with-kwargs hook on this branch. Running
scripts/run_ruff_format.py locally keeps the fix in the authoring commits
instead of trailing a separate bot commit.

* Say why a resume is refused instead of blaming the checkpoint

can_resume_run gained a provenance clause in this rework, so it now returns
False for runs whose checkpoint is entirely intact, most realistically once the
pinned model snapshot is evicted from the HF cache. The start route answered
every False with

  "Resume checkpoint must belong to a stopped or errored run with complete
   saved trainer state."

pointing the user at trainer state that is fine, while the real cause was the
resource gate. exact_resume_resource_requirements already raises with a precise
explanation and resource_provenance_allows_resume was discarding it.

Add resource_provenance_resume_blocker, which returns that explanation (or None
when the run is resumable), define allows_resume in terms of it so the two
cannot drift, and use it in the start route when it is the actual cause. The
gate itself is unchanged: refusing an unattested resume is deliberate, only the
diagnosis was wrong.

Tests are behavioural, with one narrow wiring contract for the branch that
regressed. Replacing the precise reason with a generic string fails the message
test and nothing else.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honour load subdirs everywhere a cached snapshot is probed

Follow-up to 590ac9f22. Resolving cached Spark-TTS/BiCodec snapshots from their
LLM/ load root got a start past _resolve_model_snapshot, but three later probes
still asked for root-level config.json only, so the same snapshot was accepted
in one place and rejected in the next:

  - provenance.py attested incomplete model provenance, after which
    exact_resume_resource_requirements refused resume for a snapshot still
    sitting on disk;
  - the start preflight reported a valid cached model as having no trainable
    weights;
  - the worker cleared model_snapshot_path before loading, falling back to the
    Hub instead of the selected snapshot.

Promote the helper to hub.utils.hf_cache_state.with_load_subdirs so there is
one definition rather than four, and use it at every site.
_has_trainable_local_weights takes subdirectory roots instead, since it probes
directories rather than a filename list.

Unchanged for ordinary models: with no load subdirs the helper returns its
input, and detection failures still degrade to root-only.

* Let the model routes see the same cached snapshots training does

Two ways /api/models disagreed with the training resolver, both reachable from
resume:

_model_config_inspection_target probed only the snapshot root, so a cached
Spark-TTS/BiCodec copy answered "Selected cached model is no longer available"
for a cache the training resolver accepts, and the exact-snapshot remote-code
scan could fail with it. It now uses the shared with_load_subdirs helper.

The model_snapshot_repo_id guard used the owner/repo-only regex, so resuming or
scanning a namespace-less Hub model such as gpt2 or bert-base-uncased returned
400 before the snapshot could be inspected, even though hub.utils.paths
.is_valid_repo_id and the picker both allow the one-segment form. That call site
now uses the shared validator. The other five uses of the local regex predate
this branch and are left alone.

Reverting either fix fails one of the new tests and nothing else.

* Get the resume refusal reason all the way to the user

300fe6321 fixed the diagnosis on POST /api/train/start, but the History UI never
reaches it. can_resume: false hides the Resume button outright, and in the one
window where the server could answer, resume-training-run.ts throws its own
"Only stopped or errored runs with a saved checkpoint can be resumed" before
sending any request, so no start call is ever made. For a run whose checkpoint
is intact and whose pinned snapshot was evicted, that sentence is the wrong
diagnosis, which is what 300fe6321 removed server-side.

Carry the reason on TrainingRunSummary and prefer it in that guard. The field is
optional and defaults to None, so old clients are unaffected, and it is only
computed for rows already known unresumable. A checkpoint problem still reports
None, leaving the client's existing wording for that case, and a failure inside
the gate is swallowed so History still renders.

Reverting either half fails one of the new tests: the summary stops carrying the
reason, or the client stops preferring it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Honour load subdirs on the resume pin and keep the selected model cache copy

Two follow-ups to the cached-snapshot work.

The resume branch of _reject_untrainable_model_request still probed the pinned
snapshot with a hardcoded (config.json, adapter_config.json) tuple, so a repo
that loads from a subdirectory (unsloth/Spark-TTS-0.5B keeps everything
trainable under LLM/) resolved to None. Offline that became a 409
hf_model_not_cached_offline for a snapshot present on disk; online it fell
through to the remote metadata round trip the pin exists to avoid. It now uses
with_load_subdirs like the other cached-snapshot probes.

The cache reconciliation effect took the first usable inventory row for the
selected repo. A repo can be present under more than one HF cache root, so that
silently retargeted an explicit selection at a different copy on the next
inventory tick. The selection logic moved to a pure module and now prefers a row
whose path matches the current selection, falling back to the first usable row
when there is no selection or the selected copy has gone.

Tests: test_resume_pin_load_subdirs.py (5) and
model-cache-reference-selection.test.ts (8); reverting either fix fails them.

* Revert the model cache reference preference; it cannot fire

The frontend half of 2c2a95309 assumed the model inventory can return more
than one usable row for a repo. It cannot.

hub/services/models/cache_inventory.py::_scan_cached_models collects into
seen_lower keyed by repo_id.lower() and resolves collisions with
_prefer_cache_row, and _dedupe_local_models keys hf_cache rows by
(model_id, model_format, format_variant). Both return one row per repo, and a
live check with the same repo under four HF cache roots got exactly one row from
/api/hub/cached-models and one from /api/hub/local. With a single usable row
usable.find(pred) ?? usable[0] is identically usable[0], so the change was a
no-op and its tests asserted an array shape the API cannot emit.

The resume pin fix in the same commit stands: that one was reproduced against
the real unsloth/Spark-TTS-0.5B cache and reverting its hunk restores the 409.

* Report the resume refusal that actually happened

300fe6321 and a98e721f4 set out to stop a provenance refusal being reported as a
checkpoint problem. They overshot. Both sites asked
resource_provenance_resume_blocker whenever can_resume_run said no, but that
function refuses for several reasons and the blocker is computed independently
of which one fired. initialize_resource_provenance writes {version: 1, status:
pending} at the start of every run, so the blocker answers "The model revision
used by this run was not attested." for any Hub-model run, including one whose
checkpoint is simply missing. That is the more common way to be unresumable, so
the change traded one misdiagnosis for another and the new one covered the
larger population.

Both sites now gate on has_resume_state, the same discriminator can_resume_run
short-circuits on: no saved trainer state means the checkpoint is the cause and
the client's own wording is correct; an intact checkpoint means a refusal really
is provenance's doing and the specific reason is worth surfacing.

test_resume_reason_matches_cause.py pins it; reverting either guard fails it.
The route contract assertion is over the AST rather than the source text,
because a substring search is satisfied by the explanatory comment beside the
code and passes with the guard deleted.

Two assertions in test_resume_blocked_reason_surfaces.py encoded the old
behaviour and are corrected, including the docstring claiming History survives a
raising gate: can_resume_run calls the same gate unguarded one line earlier, so
that only holds when it short-circuits first.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Scope the import-hoist __all__ skip to package __init__.py

68bce3934 skipped any name listed in __all__. The comment said "the whole point
of a package __init__", but compare() never consulted the path, so the skip
applied to every module defining __all__ -- 27 non-package modules exempting 224
names, unsloth/models/_utils.py alone 74.

The cost is not just breadth. lint-ci.yml names rename-clash as one of the two
bugs this tool exists to catch because ruff and pyflakes miss it, and the skip
disabled that detection for any name in __all__. Adding a name there became a
one-line, reviewer-invisible way to switch the check off.

The self-test I cited did not cover this: --self-test reported ALL PASS with the
skip hunk reverted, because every case ran through the "<name>" path
placeholder and none defined __all__. Cases may now carry their own path, and
three new ones pin the behaviour from both sides -- a re-export in a package
__init__ is allowed, the same shape in an ordinary module is still blocked, and
a new import absent from __all__ is still blocked in an __init__ too. Deleting
the skip fails the first; widening it back fails the second.

Removes two genuinely unused imports the correctly-scoped check then found in
test_resume_blocked_reason_surfaces.py.

* Make deleting a run's artifacts reversible until the row is gone

DELETE /runs/{run_id}?delete_artifacts=true is new in this PR; on b41b819a4 the
endpoint only calls delete_run and never touches the filesystem. As written it
rmtree'd the output directory and then deleted the row, so a failing row delete
left the artifacts destroyed and the row alive with output_dir still populated.
storage/studio_db.py opens SQLite with Python's default 5s busy timeout and sets
no busy_timeout pragma, so a writer holding the database longer than that is
enough.

The state that leaves is the real problem: a row whose artifacts are silently
gone is indistinguishable from the legitimate keep-history outcome the same
endpoint produces for a shared output directory, so the user cannot tell which
happened. A retry does recover, but only because a missing directory is treated
as success, which is incidental rather than designed.

_delete_run_output_dir now performs a same-parent rename to
.<name>.deleting-<uuid> and returns the staged path. The run is logically gone
the moment that succeeds, but the bytes survive until delete_run commits; a
failure restores the rename and the operation rolls back whole. The active and
shared guards still run first, inside the same lifecycle guard, so neither can
be raced.

Three assertions in the author's test_training_history_delete.py move to the new
return shape; their subjects, the lifecycle guard and the shared-output recheck,
are unchanged and still pass.

* Keep cached-snapshot resolution off the network

590ac9f22 routed the cached-snapshot resolvers through security_load_subdirs,
which calls detect_audio_type. That function only skips its remote tokenizer
fetch when local_files_only is set, and the new callers did not set it, so
_resolve_model_snapshot and the two cache-pin sites -- all pure filesystem work
before -- gained a hub round trip with no timeout in front of them. On a slow or
hung hub, asking whether a snapshot is already on disk could stall.

security_load_subdirs gains an opt-in local_files_only, default unchanged so the
security scanner keeps the remote answer it wants. with_load_subdirs passes it:
the subdir layout is a property of the snapshot on disk, so the local answer is
also the correct one there.

A side effect worth noting: a network failure previously raised straight past
the YAML registry fallback, because security_load_subdirs wraps both branches in
one try. Asked offline, detection reports nothing instead of raising, so the
fallback now gets its turn. That comment-versus-code mismatch is byte-identical
on b41b819a4 and is left alone, but it is pinned by a test so it stays a
decision rather than a surprise.

Three existing fixtures stubbed security_load_subdirs with a two-argument
lambda and silently lost the expansion through the helper's except; they now
carry the new keyword.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Pin the two load-subdir sites nothing was guarding

An audit ran the full backend suite against ca7c72e75's three sites reverted one
at a time. Reverting core/training/provenance.py or core/training/worker.py left
17204 passing tests green with a byte-identical failure set: both shipped
undetectable. Only routes/training.py was caught, and only retroactively, by a
file added in a later commit.

Neither is decorative. For a subdirectory-loading repo the provenance site turns
a snapshot present on disk into "The exact model snapshot for this run is no
longer available." and refuses the resume; the worker site either errors with
"The cached model snapshot selected during preflight is no longer available."
under strict resume or, without it, silently drops the pin so the load goes back
to the Hub.

test_subdir_pins_are_guarded.py covers both, at the helper and at the
user-visible gate. Reverting the provenance expansion fails 3 of the 6;
reverting the worker expansion fails the non-strict pin test. Empty and
wrong-subdir snapshots are still rejected, so the widening cannot mask an
unusable cache.

Also adds the debug line the shared except never had. Degrading to root-only is
fail-closed everywhere, but a genuine cache permission or corruption fault
reaches the user as "your cached model isn't cached" with no diagnostic, and
four sites now share that handler.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Bind has_resume_state in the alternative-layout import fallback

routes/training.py imports its helpers in a try and repeats the block under
except ImportError for an alternative on-disk layout. The resume diagnosis fix
added has_resume_state to the primary list only, so wherever the fallback runs
the name is undefined and a resume request with intact checkpoint state and a
failed provenance check raises NameError -- a 500 in place of the refusal reason
that change existed to produce.

test_route_import_fallbacks_agree.py compares the two blocks across every module
under routes/, so the class is covered rather than this one instance: any name
imported from the same module in the primary branch has to appear in the
fallback too. Removing has_resume_state from the fallback alone fails it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let provenance read metadata off an MLX model

_object_value tried the mapping protocol before attribute access. mlx.nn.Module
subclasses dict (MRO: Module -> dict -> object), so every probe on a real MLX
model took the dict branch and answered None, and both MLX paths added for
attestation were unreachable:

  getattr(model, "_unsloth_quantized_source")       -> 'runtime'
  _object_value(model, "_unsloth_quantized_source") -> None
  _loaded_model_is_4bit(model)                      -> False
  _loaded_model_refs(model)                         -> set()

unsloth_zoo does record the metadata (mlx/loader.py sets _unsloth_quantized_source
at 2748 and _hf_repo / _unsloth_base_commit_hash at 4047-4050); provenance just
could not see it. Net effect on Apple Silicon: a runtime-quantized run -- the
Studio default of a 16-bit repo with the 4-bit toggle on -- never attested, with
or without a cache pin, so its checkpoints were never resumable.

Read the attribute first and keep the mapping lookup as the fallback, which the
plain dicts flowing through here (quantization_config, _unsloth_quantization_policy)
still need.

Verified with real MLX LoRA runs, provenance status before -> after:

  SmolLM-135M-4bit  unpinned   incomplete -> attested / prequantized_4bit
  Qwen3-0.6B  4bit  pinned     incomplete -> attested / runtime_4bit
  Qwen3-0.6B  4bit  unpinned   incomplete -> attested / runtime_4bit
  Qwen3-0.6B 16bit  unpinned   incomplete -> attested / unquantized

resource_provenance_allows_resume returns True for all four afterwards, and
_loaded_model_refs now resolves the repo/commit pair it previously missed.

The mlx doubles in test_training_provenance.py are SimpleNamespace, which is not
a dict subclass, so they exercised a branch no MLX model reaches. The new test
parametrizes over a plain object, a dict subclass, and a real mlx.nn.Module; the
dict-subclass case reproduces the bug without MLX installed, so it guards on CI
too, and the MLX case importorskips off Apple Silicon.

Prequantized snapshots that are not uniformly 4-bit (an 8-bit mlx-community
conversion, mixed per-layer widths) still do not attest. That is a separate
cause in _snapshot_declares_quantization and is left alone here.

(cherry picked from commit 8af94dc1fd225905aa031d08b59ad752b20112f0)

* Tighten comments across the train-page rework

Collapse multi-line comment blocks to their essential reasoning, drop
comments that restate self-explanatory code, and shorten section banners.
Comments recording a non-obvious why (reproduced bugs, upstream quirks,
deliberate tradeoffs) are kept, just stated in fewer lines.

Comments and docstrings only: verified with an AST comparison against the
previous tree, so no code changed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten comments across the remaining studio files

Collapse multi-line explanations to a single line and drop a redundant
section marker. Comments only, no code changes.

* Fix offline dataset selection and training review regressions

Resolve cached dataset configs and splits from trusted local metadata with a localized validated manual fallback.

Require an explicit split for cached offline training while preserving remote and streaming format checks.

Refresh the shared dataset inventory after full recipe completion so new artifacts appear on Train.

Keep shared output deletion atomic and restore legacy dataset format column ordering.

Keep dataset completion attestations on v2 while writing ordinary manifests in downgrade-compatible v1 and migrating prior ordinary v2 records at startup.

Return stable codes for rejected training model selections.

* Adapt the grad-norm payload test to this branch's config shape

#7917 added tests/training-start-payload-grad-norm.test.ts against main's
TrainingConfigState. This branch moved hfToken out of that state into a second
parameter on buildTrainingStartPayload, dropped datasetUserTemplate and
datasetAssistantTemplate, and added 11 required fields, so the merged file did
not typecheck. Staging CI caught it; the local run had not covered the second
merge yet.

The literal now spreads initialTrainingConfigState and keeps only the fields the
test actually varies, so a future field addition does not break it again.

Still non-vacuous: reintroducing max_grad_norm: 0.0 in the mapper fails it with
"max_grad_norm must be absent, not null and not 0", which is the regression the
file exists to catch.

typecheck clean, 634 frontend tests pass.

* Scope pending training cancellation to its start request

Keep the request identity until cancellation or exact status reconciliation completes.

Reject cancelled starts before worker spawn and stop or reset only the job owned by that request.

Fence the process-start race, clean up adoption failures, and keep unrelated jobs untouched.

Remove explanatory comments added with the earlier dataset and manifest fixes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update the training-start contracts to the scoped cancellation shape

Four assertions in tests/studio/test_hf_token_validation_tick_contract.py still
described the pre-scoping implementation, so the Studio contract suite was red at
8443285df.

isTrainingStartPending gained a startRequestId disjunct. Asserted per term rather
than as one exact string, so the expression's formatting is not the contract.

stopTrainingRun and dismissTrainingRun replaced the expectedJobId locals with
trainingStopScope / runtimeMatchesStopScope, and the request now sits inside a
try, so the transport slice cut at the scope === null early return instead of the
call. Re-sliced on the try/catch and renamed the reads.

The blanket "setStopRequested(false) not in stop" no longer holds: the start
branch clears the latch on purpose so a pending-start cancel stays retryable.
Split per branch instead, which is a stronger contract than before -- the job
branch must still keep the latch, and the start branch must still clear it.

Non-vacuous, checked both ways: dropping the startRequestId disjunct fails the
pending test, and adding setStopRequested(false) to the job failure branch fails
the cancel-lease test.

154 tests across the four contract files pass; tests/studio 2561 passed.

* Offer the cached dataset options the start request will accept

local_options.py starts the subset and split options the picker shows, but it was
written with a different grammar from TrainingStartRequest, so it disagreed in
both directions.

_SPLIT_RE was \w+(?:\.\w+)* and dropped the hyphen, so a real split name such as
train-clean (LibriSpeech) never reached the picker even though the start request
accepts it. An offline user had to type it by hand.

\w is Unicode-aware in Python, so trein with an accent was offered and then
rejected by the ASCII-only split validator. _CONFIG_RE excluded only
filesystem-hostile characters, so a config name containing a space was offered
and then rejected by the subset validator. Both turn a click on an offered option
into a 422.

Both patterns now mirror _check_subset and _check_split_name, which are the
authority, and _valid_option rejects ".." anywhere rather than only as a whole
segment, matching the split validator.

New test drives the real pydantic validators rather than restating their regexes,
and asserts over the normalized value, since _valid_option strips and it is the
stripped string that gets offered. Reverting either pattern fails 6 of its 14.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Report a failed artifact purge instead of claiming the delete succeeded

Deleting a run with its artifacts stages the output directory with a same-parent rename and
purges it once the database row is gone. The purge swallowed OSError while the response still
said artifacts_deleted=true, so a failed rmtree stranded every byte under the hidden randomized
.{name}.deleting-<hex> name: the row is gone by then, so nothing points at it and no retry can
rediscover it.

The purge now reports whether the bytes are actually gone, and puts the directory back under its
own name when they are not. The response says the artifacts were kept, with a purge_failed
reason, and the history grid's existing artifacts_deleted check surfaces it.

Same window, other half: when the row delete and the restoring rename both fail, the response now
names the staged path instead of raising a bare 500 that leaves the artifacts unreachable.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep offering cached subsets whose only oddity is a dotted name

Aligning the picker's grammar with TrainingStartRequest went one step too far: _valid_option
started refusing ".." anywhere, for both fields. _check_split_name does reject ".." outright, so
that is right for splits, but _check_subset only constrains the charset, which means a config
directory named v1..v2 is perfectly startable and the picker was hiding it. That is the same bug
as offering an option the start request rejects, just pointing the other way.

The ".." refusal is now scoped to split names. Neither charset admits a separator, so the bare
"." and ".." names remain the only traversal shapes left to refuse for either field.

test_processed_cache_options_are_local_deduplicated_and_non_train_capable pinned the pre-change
behaviour for both names in its fixture. Its "config with spaces" entries stay dropped, since
_check_subset rejects the space and offering it turns a click into a 422, and "v1..v2" comes back.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: shimmyshimmer <michaelhan2050@gmail.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: danielhanchen <unslothai@gmail.com>
Co-authored-by: Bardia Koopah <bkoop2003@gmail.com>
2026-08-06 04:26:05 -07:00

292 lines
10 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Training progress callbacks must report an active status once training starts.
Both training paths used to leave the parent on the pre-train "Starting ..." status
for the whole run, so /api/train/status and the progress card read "Starting
training..." while the loss was already moving: the callbacks report an empty status
on every log and the parent only overwrites a non-empty one. These tests drive the
real callbacks, worker emit rule and parent handler. Fakes only; no GPU, no model.
"""
from __future__ import annotations
import importlib
import queue as _queue
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# core/training/trainer.py imports unsloth and trl at module level (heavy, GPU init). Stub
# whichever are missing just long enough to import it, then restore.
_STUBS = {
"unsloth": ("FastLanguageModel", "FastVisionModel", "is_bfloat16_supported"),
"unsloth.chat_templates": ("get_chat_template",),
"trl": ("SFTTrainer", "SFTConfig"),
}
_STUBBED: list[str] = []
_TRAINER_PRE_IMPORTED = "core.training.trainer" in sys.modules
def _stub_if_missing(name, attrs):
"""Stub ``name`` unless the real package is installed."""
if name in sys.modules:
return
try:
importlib.import_module(name)
return
except Exception:
pass
_STUBBED.append(name)
module = types.ModuleType(name)
# A spec-less module reads as "no namespace shadow" to ensure_real_packages.
module.__spec__ = None
for attr in attrs:
setattr(module, attr, MagicMock())
sys.modules[name] = module
parent, _, child = name.rpartition(".")
if parent and parent in sys.modules:
setattr(sys.modules[parent], child, module)
if not _TRAINER_PRE_IMPORTED:
for _name, _attrs in _STUBS.items():
_stub_if_missing(_name, _attrs)
from core.training.trainer import UnslothTrainer # noqa: E402
from core.training.training import TrainingBackend, _MLXTrainerAdapter # noqa: E402
from core.training.worker import ( # noqa: E402
_create_embedding_progress_callback,
_create_trainer_progress_callback,
)
if not _TRAINER_PRE_IMPORTED:
for _name in _STUBBED:
sys.modules.pop(_name, None)
# Drop the stub-bound module and its parent package so a later test re-imports it against the
# real packages; the UnslothTrainer class held above stays usable.
sys.modules.pop("core.training.trainer", None)
sys.modules.pop("core.training", None)
ACTIVE = "Training in progress..."
class _FakeQueue:
"""Stands in for the mp.Queue the worker sends events on."""
def __init__(self):
self.events: list[dict] = []
def put(self, event, *args, **kwargs):
self.events.append(event)
def _state():
return SimpleNamespace(global_step = 0, epoch = 0.0, num_input_tokens_seen = 0)
def _drive(
callback,
steps = 3,
control = None,
on_step = None,
):
"""Run the HuggingFace callback lifecycle the way Trainer.train() does."""
state = _state()
control = control if control is not None else SimpleNamespace(should_training_stop = False)
callback.on_train_begin(None, state, control)
for step in range(1, steps + 1):
state.global_step = step
state.epoch = round(0.5 * step, 2)
state.num_input_tokens_seen = 128 * step
callback.on_log(None, state, control, logs = {"loss": 1.0 / step, "learning_rate": 1e-4})
callback.on_step_end(None, state, control)
if on_step is not None:
on_step(step)
# Once at the end: HuggingFace calls on_epoch_end per epoch, not per step.
callback.on_epoch_end(None, state, control)
return state, control
# --- LLM/VLM/audio path: UnslothTrainer._create_progress_callback ->
# worker._create_trainer_progress_callback ---
def _make_owner():
# __new__ dispatches to the MLX adapter on Apple hardware, which has no
# _create_progress_callback; go straight to the class under test.
owner = object.__new__(UnslothTrainer)
UnslothTrainer.__init__(owner)
owner._update_progress(is_training = True, total_steps = 4, status_message = "Starting training...")
return owner
def test_train_begin_reports_active_status():
owner = _make_owner()
callback = owner._create_progress_callback()
callback.on_train_begin(None, _state(), SimpleNamespace())
assert owner.training_progress.status_message == ACTIVE
def test_logging_reports_an_empty_status_so_the_active_one_is_sent_once():
# The parent keeps the last non-empty status, so a run costs one status event.
owner = _make_owner()
reported: list[str] = []
owner.add_progress_callback(lambda progress: reported.append(progress.status_message))
_drive(owner._create_progress_callback(), steps = 3)
assert owner.training_progress.status_message == ""
assert [status for status in reported if status] == [ACTIVE]
assert owner.training_progress.step == 3
assert owner.training_progress.loss == pytest.approx(1 / 3)
assert owner.training_progress.num_tokens == 384
def test_parent_status_advances_over_the_whole_chain():
owner = _make_owner()
backend = TrainingBackend()
event_queue = _FakeQueue()
owner.add_progress_callback(_create_trainer_progress_callback(event_queue))
# The worker sends this right before trainer.train().
event_queue.put({"type": "status", "message": "Starting training...", "ts": 0.0})
_drive(owner._create_progress_callback(), steps = 3)
for event in event_queue.events:
backend._handle_event(event)
assert backend._progress.status_message == ACTIVE
assert backend._progress.step == 3
assert backend._progress.is_training is True
def test_training_warning_is_emitted_once_and_survives_later_status_updates():
owner = _make_owner()
backend = TrainingBackend()
event_queue = _FakeQueue()
owner.add_progress_callback(_create_trainer_progress_callback(event_queue))
owner._record_warning("Evaluation fell back to a held-out training split.")
owner._record_warning("Evaluation fell back to a held-out training split.")
owner._update_progress(status_message = ACTIVE)
for event in event_queue.events:
backend._handle_event(event)
warning_events = [event for event in event_queue.events if event["type"] == "warning"]
assert [event["message"] for event in warning_events] == [
"Evaluation fell back to a held-out training split."
]
assert backend._progress.warnings == ["Evaluation fell back to a held-out training split."]
assert backend._progress.status_message == ACTIVE
def test_mlx_adapter_deduplicates_warning_events():
adapter = _MLXTrainerAdapter()
adapter._handle_event({"type": "warning", "message": "Evaluation was disabled."})
adapter._handle_event({"type": "warning", "message": "Evaluation was disabled."})
adapter._handle_event({"type": "warning", "message": " "})
assert adapter.training_progress.warnings == ["Evaluation was disabled."]
@pytest.mark.parametrize(
"stop_status",
[
"Stopping training and saving checkpoint...",
"Cancelling training...",
],
)
def test_stop_status_is_never_replaced_by_the_active_one(stop_status):
owner = _make_owner()
backend = TrainingBackend()
event_queue = _FakeQueue()
owner.add_progress_callback(_create_trainer_progress_callback(event_queue))
callback = owner._create_progress_callback()
def _stop_after_first_step(step):
if step == 1:
owner.should_stop = True
owner._update_progress(status_message = stop_status)
_, control = _drive(callback, steps = 2, on_step = _stop_after_first_step)
# A resumed run re-enters on_train_begin; an already requested stop must survive.
callback.on_train_begin(None, _state(), SimpleNamespace())
for event in event_queue.events:
backend._handle_event(event)
assert [e["message"] for e in event_queue.events if e["type"] == "status"] == [
ACTIVE,
stop_status,
]
assert backend._progress.status_message == stop_status
assert control.should_training_stop is True
# ---------------------------------------------------------------------------
# Embedding path: worker._create_embedding_progress_callback
# ---------------------------------------------------------------------------
def _make_embedding_callback(event_queue, should_stop = lambda: False):
return _create_embedding_progress_callback(
event_queue,
total_steps = 4,
training_start_time = 0.0,
should_stop = should_stop,
)
def test_embedding_parent_status_advances_over_the_whole_chain():
event_queue = _FakeQueue()
backend = TrainingBackend()
# The worker sends this right before trainer.train().
event_queue.put({"type": "status", "message": "Starting embedding training...", "ts": 0.0})
_drive(_make_embedding_callback(event_queue), steps = 3)
for event in event_queue.events:
backend._handle_event(event)
assert [e["message"] for e in event_queue.events if e["type"] == "status"] == [
"Starting embedding training...",
ACTIVE,
]
assert backend._progress.status_message == ACTIVE
assert backend._progress.step == 3
assert backend._progress.loss == pytest.approx(1 / 3)
assert backend._progress.total_steps == 4
def test_embedding_train_begin_reports_nothing_once_a_stop_was_requested():
event_queue = _FakeQueue()
control = SimpleNamespace(should_training_stop = False)
_drive(
_make_embedding_callback(event_queue, should_stop = lambda: True), steps = 1, control = control
)
assert [e for e in event_queue.events if e["type"] == "status"] == []
assert control.should_training_stop is True
def test_embedding_callback_survives_a_real_queue():
# The worker's queue is an mp.Queue; nothing put on it may be unpicklable.
import pickle
event_queue = _queue.Queue()
_drive(_make_embedding_callback(event_queue), steps = 1)
events = [event_queue.get_nowait() for _ in range(event_queue.qsize())]
assert [e["type"] for e in events] == ["status", "progress"]
assert pickle.loads(pickle.dumps(events)) == events