unsloth/studio/frontend/tests/diffusion-train-deploy.test.ts
oobabooga 0afb352bd4
Studio: fix FLUX.2 Klein LoRA training quality (#8267)
* Fix FLUX.2 Klein LoRA training quality

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

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

* Fix FLUX.2 Klein training review gaps

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

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

* Fix diffusion training mirror preflight

* Drop base_specs on a DiT block so the hardware reason still reaches the UI

A dit_block clears the family-level chip fields (params, qlora_vram_gb, gated,
note) precisely so FamilyFacts falls through to vram_note, which carries the
actionable reason: no CUDA, or no native bf16 on this GPU. base_specs was still
published though, and the per-base entry wins in resolveDiffusionTrainingFacts,
so selecting Klein base-9B on a blocked host put the 9B and 18 GB chips straight
back. FamilyFacts renders vram_note only when there are no chips, so the user
lost the explanation and got a generic refusal plus a size they cannot act on.

Clear base_specs on the same condition. The overlay only ever feeds those chips
(resolveDiffusionTrainingFacts is its sole consumer), so nothing functional
depends on it being present.

Covered by a test that pins a bf16-unsupported host and asserts both halves: the
overlay is empty and vram_note still carries the reason. It also asserts a
per-base overlay exists on an unblocked host first, so it cannot pass vacuously.
Verified it fails with the fix reverted.

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

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

* Keep the mirror out of the gate and out of the resume identity

Two consequences of routing the fetch through prefer_ungated_mirror that
the mirror was not meant to have.

The gated-repo name check ran on the canonical id while the start route
preflighted the fetch repo. For FLUX.1-dev or FLUX.2-dev without a token
that means the route probes the public mirror, answers 200, frees the
resident models, and only then does the child refuse the run by name. It
now checks the repo the run will actually fetch, so the two agree: a
mirror-backed run proceeds, and a genuinely gated fetch still fails in
the route, before anything is evicted.

base_revision was reading the fetch repo too. Which repo that is depends
on local cache state, so the same base records a different rev- value
once the upstream snapshot is evicted or the run moves to another
machine, and mismatch_reason then refuses the checkpoint as a different
base revision though the weights are byte identical. Every checkpoint
written before mirrors existed holds the canonical value, so those would
be refused as well. Both identity_for_config and the post-load re-read go
back to the canonical id; a canonical repo with no local ref records
"unresolved", which is already treated as not comparable.

Tests, both mutation checked: the gate assertion fails when the canonical
id is restored, and the revision assertion fails when the fetch repo is.

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

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

* Pair the recorded base revision with the repo it was read from

A mirror-backed run resolves the revision from the mirror, so recording the canonical
repo's revision instead lost validation entirely: source_revision(canonical) is
"unresolved" on exactly the hosts a mirror is selected for, and mismatch_reason skips
non-comparable revisions, so the check silently never fired. Record both halves and
compare them only when the repos match, which still catches a moved mirror and no
longer refuses a legitimate cross-repo resume. Bundles written before the field exist
keep comparing as they did.

Also take the mirror on a token-less run even when the vendor repo is cached: only
gated repos are in the mirror table, so a mirror existing means the upstream needs
credentials this run does not have. UNSLOTH_DIFFUSION_NO_MIRROR still pins the vendor
repo.

And preselect the training base a family pairs with a loaded distilled checkpoint: the
distilled half is never in base_repos, so opening Train with the 9B model loaded seeded
the 4B base.

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

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

* Only override a cached base when its upstream is actually gated

The mirror table stopped being gated-only when the redistributable bases joined it, so
"a mirror exists" no longer implies "the upstream needs credentials". Klein base-4B is
both a default trainable base and mirrored, and the Hub serves it anonymously, so a
token-less run with a complete local cache was re-pulling it from the mirror and an
offline run failed outright.

Split the table rather than keep a second list in sync: the 12 genuinely gated pairs
stay in _GATED_MIRROR_PAIRS, the redistributable ones move to _UNGATED_MIRROR_PAIRS,
and mirror_repo and canonical_base build from the union so redirection and the
base-keyed table normalisation are unchanged. upstream_is_gated reads the gated half,
and the token-less override now asks it.

* Keep a local clone named like a gated base out of the mirror override

* Carry the local-clone and mirror exceptions through every base gate

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-10 06:04:04 -07:00

111 lines
4.2 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
resolveDiffusionDeployBase,
resolveDiffusionTrainingBase,
} from "../src/features/images/train/diffusion-train-deploy.ts";
const klein = {
name: "flux.2-klein",
label: "FLUX.2 Klein",
default_base: "black-forest-labs/FLUX.2-klein-base-4B",
base_repos: [
"black-forest-labs/FLUX.2-klein-base-4B",
"black-forest-labs/FLUX.2-klein-base-9B",
],
deploy_bases: {
"black-forest-labs/FLUX.2-klein-base-4B":
"black-forest-labs/FLUX.2-klein-4B",
"black-forest-labs/FLUX.2-klein-base-9B":
"black-forest-labs/FLUX.2-klein-9B",
"unsloth/FLUX.2-klein-base-9B": "unsloth/FLUX.2-klein-9B",
},
};
test("deploys each Klein base adapter on its matching distilled checkpoint", () => {
assert.equal(
resolveDiffusionDeployBase(klein, "black-forest-labs/FLUX.2-klein-base-4B"),
"black-forest-labs/FLUX.2-klein-4B",
);
assert.equal(
resolveDiffusionDeployBase(klein, "BLACK-FOREST-LABS/FLUX.2-KLEIN-BASE-9B"),
"black-forest-labs/FLUX.2-klein-9B",
);
assert.equal(
resolveDiffusionDeployBase(klein, "unsloth/FLUX.2-klein-base-9B"),
"unsloth/FLUX.2-klein-9B",
);
});
test("keeps custom bases and the legacy family-wide mapping working", () => {
assert.equal(
resolveDiffusionDeployBase(klein, "/models/custom-klein"),
"/models/custom-klein",
);
assert.equal(
resolveDiffusionDeployBase(
{
...klein,
base_repos: ["krea/Krea-2-Raw"],
deploy_bases: {},
deploy_base: "krea/Krea-2-Turbo",
},
"krea/Krea-2-Raw",
),
"krea/Krea-2-Turbo",
);
});
test("preselects the training base paired with a loaded distilled checkpoint", () => {
assert.equal(
resolveDiffusionTrainingBase(klein, "black-forest-labs/FLUX.2-klein-9B"),
"black-forest-labs/FLUX.2-klein-base-9B",
);
assert.equal(
resolveDiffusionTrainingBase(klein, "BLACK-FOREST-LABS/FLUX.2-KLEIN-4B"),
"black-forest-labs/FLUX.2-klein-base-4B",
);
});
test("returns null rather than inventing a base the backend would refuse", () => {
assert.equal(resolveDiffusionTrainingBase(undefined, "black-forest-labs/FLUX.2-klein-9B"), null);
assert.equal(resolveDiffusionTrainingBase(klein, ""), null);
// Loaded checkpoint the family declares no pairing for.
assert.equal(resolveDiffusionTrainingBase(klein, "krea/Krea-2-Turbo"), null);
// A repo whose name matches nothing the family offers stays null.
assert.equal(resolveDiffusionTrainingBase(klein, "unsloth/FLUX.2-klein-42B"), null);
});
test("a mirror-loaded checkpoint preselects the vendor base it copies", () => {
// Deploy hands a LoRA trained on unsloth/FLUX.2-klein-base-9B the mirror checkpoint
// unsloth/FLUX.2-klein-9B, so that is what /images/status reports afterwards. Its pairing names
// the mirror TRAINING id, which base_repos does not offer, and the panel then fell back to the
// first base: the 4B, seeding a 9B workflow from 4B weights. A mirror keeps the upstream name.
assert.equal(
resolveDiffusionTrainingBase(klein, "unsloth/FLUX.2-klein-9B"),
"black-forest-labs/FLUX.2-klein-base-9B",
);
assert.equal(
resolveDiffusionTrainingBase(klein, "UNSLOTH/FLUX.2-KLEIN-9B"),
"black-forest-labs/FLUX.2-klein-base-9B",
);
});
test("the Train panel preselect actually consults the pairing", async () => {
// The helper on its own changes nothing: the bug was in the preselect chain, which fell from an
// exact base_repos match straight to base_repos[0]. Assert the pairing sits BETWEEN the two.
const source = await readFile(
new URL("../src/features/images/train/diffusion-train-panel.tsx", import.meta.url),
"utf8",
);
const paired = source.indexOf("pairedTrainingBase ??");
const first = source.indexOf("family.base_repos[0]");
assert.ok(paired > 0, "the preselect no longer falls back to the paired training base");
assert.ok(paired < first && first - paired < 40, "base_repos[0] is no longer the last resort");
assert.match(source, /resolveDiffusionTrainingBase\(reportedFamily, loadedBaseRepo\)/);
});