fix(training): drop the version name from epoch filenames, keep the id

The review left this open as a decision: the ask was `esadribicstyle_krea2`,
what shipped was `esadribicstyle_krea2_v1-1284593_epoch_10.safetensors`.

Settled as keep-the-id, drop-the-name:

  esadribicstyle_krea2_1284593_epoch_10.safetensors

Dropping the scope entirely was rejected. Cumulative epoch numbering only
separates a CONTINUATION from its parent — a fresh retrain of one model on
the same base gets no offset, restarts at epoch 1, and reproduces the exact
collision this feature exists to fix. That case is not exotic; it is what
iterating on settings looks like.

The version NAME went because it carried nothing the epoch number didn't.
For continuations it is auto-generated as `V2 (from epoch 5)` and sanitizes
to `V2__from_epoch_5`, which was the bulk of the ugliness and none of the
uniqueness — versionId is globally unique on its own.

Nothing persists these names: a `link.download` attribute, a
Content-Disposition header, and a transient zip manifest, all generated per
request. No migration, no backfill, and already-downloaded files keep the
names they were given.

`versionName` leaves TrainingRunNameParts entirely, so the two Prisma selects
that widened to `name: true` only to feed it are narrowed back, along with
the two fixtures that were still supplying it.

Three tests went with it rather than being rewritten — they existed to prove
the version-name component was present, truncation-safe, and collision-free,
and all three are vacuous once the component is gone. Suite drops 21526 ->
21523 for exactly that reason. A fourth survives under a new name and is what
now pins the retrain case.

The tracker records this under "Closed", not "Answered from the repo" — the
repo could not settle it, and a heading saying otherwise is how a decision
loses its owner. The feedback doc points at the tracker for the reasoning
rather than restating it, so the two cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
briant
2026-08-24 11:51:01 -06:00
parent ccdad0efbc
commit 474ec50104
9 changed files with 52 additions and 119 deletions
+3 -2
View File
@@ -112,8 +112,9 @@ All three came out of the **Creator Studio article (33297) comments, not the Dis
- [ ] **Option to hide Blue Buzz from the top-right balance** Blue buzz noise obscures sales performance. Default stays combined; opt-in yellow-only display, both still shown in the dropdown.
- [ ] **Mute/unfollow a notification thread** a maintainer, 8/1 3:36 PM, alongside the dedup work.
- [x] **Architecture-name suffix on trained model filenames** Multi-architecture training produces identical filenames; wants `esadribicstyle_krea2` / `esadribicstyle_pdxl`. Prefers a shared standard, default-on, and **sticky** so it isn't re-enabled every run.
- **Shipped** — every epoch download, sample and run zip carries the architecture: `<model>_<architecture>_<version>-<versionId>_epoch_<n>.safetensors`, from the one helper in `src/shared/utils/training-file-names.ts`. Runs predating the field omit the segment.
- **No toggle, so "sticky" does not apply** — it is unconditional, which is the shared standard he asked for one rung further. The segment sits between model and version rather than trailing, because the version scope has to stay adjacent to the epoch number it disambiguates.
- **Shipped** — every epoch download, sample and run zip carries the architecture: `<model>_<architecture>_<versionId>_epoch_<n>.safetensors`, from the one helper in `src/shared/utils/training-file-names.ts`. Runs predating the field omit the segment.
- **No toggle, so "sticky" does not apply** — it is unconditional, which is the shared standard he asked for one rung further.
- The `<versionId>` segment is the one part he did not ask for; the version *name* that sat beside it was dropped 2026-08-24. Why the id stays and the name went: [docs/training-filenames-review-followups.md](training-filenames-review-followups.md), "The version scope cost the requested shape".
- The segment is the specific base-model key (`pdxl`, `krea2`), not the family: `baseModelType` reports `sdxl` for pony, illustrious and sdxl alike, which cannot tell a multi-training batch apart.
- Scope is the pre-publish artifacts. The **published** `ModelFile` still takes the blob id as its stored `name` (`moveAssetFromBlob``<blobId>.safetensors`) — unique, so it never collided, but opaque. What downloaders see is already overridable: `overrideName` shipped in `e5573c88f6` (#2737), editable on the model-wizard version step and in the file list, and `getDownloadFilename()` prefers it over `name`. No architecture segment on that path, which is the only piece of this ask still missing there.
- [ ] **Customizable precision list** (hide unused formats) He flagged this himself as possibly over-scoped.
+17 -9
View File
@@ -7,8 +7,9 @@ Perf came back clean and is not listed: the two widened `trainingDetails` select
4 buffers, the training webhook path gained zero I/O, and the new client modules added no bundle
weight (`~/utils/training` was already a value import in that chunk via `training.store.ts`).
**Status:** everything answerable from the repo is done. What remains needs a decision from a
person — the three under Open questions, plus the dead-v1-auto-label call at the bottom.
**Status:** everything answerable from the repo is done, and the filename shape was settled on
2026-08-24. What remains needs a decision from a person — the two under Open questions, plus the
dead-v1-auto-label call at the bottom.
## Defects
@@ -71,7 +72,7 @@ person — the three under Open questions, plus the dead-v1-auto-label call at t
of MyLoRA v2", matching the existing render, and falls back to the bare epoch when the run
recorded no name.
## Answered from the repo — no longer open
## Closed — no longer open
- [x] **"The settings used" is 10 of ~24 fields; LoRA Type is the notable omission.** LoRA Type
cannot discriminate anything: `loraTypes` in `src/utils/training.ts` has exactly one member,
@@ -87,14 +88,21 @@ person — the three under Open questions, plus the dead-v1-auto-label call at t
prefers it. It carries no architecture segment; that is the only remaining gap on that path.
Record: [docs/features/training-file-rename.md](features/training-file-rename.md).
- [x] **The version scope cost the requested shape.** Settled 2026-08-24: keep the id, drop the
version name. `esadribicstyle_krea2_v1-1284593_epoch_10.safetensors`
`esadribicstyle_krea2_1284593_epoch_10.safetensors`. Dropping the scope entirely was rejected:
cumulative numbering only separates a *continuation* from its parent, so a fresh retrain of one
model on the same base still restarts at epoch 1 and reproduces the collision the feature
exists to fix. The version name went because it carried nothing — for continuations it is
auto-generated as `V2 (from epoch 5)` and sanitizes to `V2__from_epoch_5`. Nothing persists
these names (a `link.download`, a `Content-Disposition`, a transient zip manifest), so the
change needed no migration and stays reversible. Three tests that existed only to protect the
version-name component were deleted rather than rewritten. A fourth, "distinguishes the same
epoch number across two runs of one model", survives under a new name — it is the one that now
pins the retrain case.
## Open questions — need a person, not the repo
- [ ] **The version scope serves neither ticket.** Cumulative numbering alone removes the
continuation collision; the architecture segment alone separates a multi-training batch. The
`v1-1284593` component addresses a third, unreported collision — two same-architecture runs on
one model — and costs the requested shape: the ask was `esadribicstyle_krea2`, what ships is
`esadribicstyle_krea2_v1-1284593_epoch_10.safetensors`. Decide whether to keep it, keep only
the id, or drop it.
- [ ] **The Mage-Flow ticket's symptom is unchanged.** The diagnostics are the correct app-side
response to an orchestrator-side cause, but nothing user-visible moved, and there is no record
in this repo that the orchestrator-side work was filed. The ticket now reads as addressed.
@@ -87,7 +87,6 @@ const EpochRow = ({
canGenerate,
isVideo,
modelName,
versionName,
architecture,
}: {
epoch: TrainingResultsV2['epochs'][number];
@@ -108,7 +107,6 @@ const EpochRow = ({
canGenerate?: boolean;
isVideo: boolean;
modelName: string;
versionName: string;
architecture?: string | null;
}) => {
const currentUser = useCurrentUser();
@@ -123,7 +121,6 @@ const EpochRow = ({
link.href = `/api/download/training/${modelVersionId}?epochNumber=${epoch.epochNumber}`;
link.download = trainingEpochModelFileName({
modelName,
versionName,
versionId: modelVersionId,
architecture,
epochNumber: epoch.epochNumber,
@@ -986,7 +983,6 @@ export default function TrainingSelectFile({
canGenerate={features.privateModels && !!modelVersion.id && canGenerateWithEpochBool}
isVideo={isVideo}
modelName={model.name}
versionName={modelVersion.name}
architecture={architecture}
/>
{epochs.length > 1 && (
@@ -1018,7 +1014,6 @@ export default function TrainingSelectFile({
}
isVideo={isVideo}
modelName={model.name}
versionName={modelVersion.name}
architecture={architecture}
/>
))}
@@ -41,7 +41,6 @@ export default AuthedEndpoint(
where: { id: modelVersionId },
select: {
id: true,
name: true,
trainingDetails: true,
model: { select: { id: true, userId: true, name: true } },
files: {
@@ -138,7 +137,6 @@ export default AuthedEndpoint(
const fileName = trainingEpochModelFileName({
modelName: modelVersion.model.name,
versionName: modelVersion.name,
versionId: modelVersion.id,
architecture: trainingArchitectureKey(
modelVersion.trainingDetails as TrainingDetailsObj | null
@@ -19,7 +19,6 @@ const EPOCH_URL = 'https://orchestration.civitai.com/v2/consumer/blobs/MODEL3.sa
const givenModelVersion = (trainingDetails: unknown) =>
findUnique.mockResolvedValue({
id: 1284593,
name: 'v1',
trainingDetails,
model: { id: 7, userId: OWNER.id, name: 'esadribicstyle' },
files: [
@@ -78,13 +77,13 @@ describe('training epoch download filename', () => {
);
});
it('carries the architecture and the version scope', async () => {
it('carries the architecture and the version id', async () => {
givenModelVersion({ baseModel: 'krea2' });
const res = await call();
expect(res.headers['Content-Disposition']).toBe(
'attachment; filename="esadribicstyle_krea2_v1-1284593_epoch_3.safetensors"'
'attachment; filename="esadribicstyle_krea2_1284593_epoch_3.safetensors"'
);
});
@@ -102,14 +101,13 @@ describe('training epoch download filename', () => {
const res = await call();
expect(res.headers['Content-Disposition']).toBe(
'attachment; filename="esadribicstyle_v1-1284593_epoch_3.safetensors"'
'attachment; filename="esadribicstyle_1284593_epoch_3.safetensors"'
);
});
it('refuses an epoch URL outside the orchestrator hosts', async () => {
findUnique.mockResolvedValue({
id: 1284593,
name: 'v1',
trainingDetails: { baseModel: 'krea2' },
model: { id: 7, userId: OWNER.id, name: 'esadribicstyle' },
files: [
@@ -60,11 +60,11 @@ describe('buildEpochArchiveEntries', () => {
expect(unresolvedCount).toBe(0);
expect(cappedCount).toBe(0);
expect(entries).toEqual([
{ blobId: 'MODEL1.safetensors', fileName: 'My_Cool_Model__V1-77_epoch_1.safetensors' },
{ blobId: 'MODEL2.safetensors', fileName: 'My_Cool_Model__V1-77_epoch_2.safetensors' },
{ blobId: 'E1S1.jpeg', fileName: 'My_Cool_Model__V1-77_epoch_1_sample_1.jpeg' },
{ blobId: 'E2S1.jpeg', fileName: 'My_Cool_Model__V1-77_epoch_2_sample_1.jpeg' },
{ blobId: 'E2S2.mp4', fileName: 'My_Cool_Model__V1-77_epoch_2_sample_2.mp4' },
{ blobId: 'MODEL1.safetensors', fileName: 'My_Cool_Model__77_epoch_1.safetensors' },
{ blobId: 'MODEL2.safetensors', fileName: 'My_Cool_Model__77_epoch_2.safetensors' },
{ blobId: 'E1S1.jpeg', fileName: 'My_Cool_Model__77_epoch_1_sample_1.jpeg' },
{ blobId: 'E2S1.jpeg', fileName: 'My_Cool_Model__77_epoch_2_sample_1.jpeg' },
{ blobId: 'E2S2.mp4', fileName: 'My_Cool_Model__77_epoch_2_sample_2.mp4' },
]);
});
@@ -91,8 +91,8 @@ describe('buildEpochArchiveEntries', () => {
});
expect(entries).toEqual([
{ blobId: 'LEGACY.safetensors', fileName: 'legacy_V1-77_epoch_1.safetensors' },
{ blobId: 'LEGACYS1.jpeg', fileName: 'legacy_V1-77_epoch_1_sample_1.jpeg' },
{ blobId: 'LEGACY.safetensors', fileName: 'legacy_77_epoch_1.safetensors' },
{ blobId: 'LEGACYS1.jpeg', fileName: 'legacy_77_epoch_1_sample_1.jpeg' },
]);
});
@@ -114,9 +114,7 @@ describe('buildEpochArchiveEntries', () => {
versionId: 77,
});
expect(entries).toEqual([
{ blobId: 'OK.jpeg', fileName: 'legacy_V1-77_epoch_1_sample_1.jpeg' },
]);
expect(entries).toEqual([{ blobId: 'OK.jpeg', fileName: 'legacy_77_epoch_1_sample_1.jpeg' }]);
expect(unresolvedCount).toBe(2);
expect(cappedCount).toBe(0);
});
@@ -153,7 +151,6 @@ describe('getTrainingEpochArchive', () => {
const modelVersion = {
id: 1,
name: 'V2 (from epoch 5)',
trainingDetails: { baseModel: 'pony' },
model: { userId: 10, name: 'My Cool Model!' },
files: [{ metadata: { trainingResults: v2Results } }],
@@ -170,14 +167,14 @@ describe('getTrainingEpochArchive', () => {
entries: expect.arrayContaining([
{
blobId: 'MODEL1.safetensors',
fileName: 'My_Cool_Model__pony_V2__from_epoch_5-1_epoch_1.safetensors',
fileName: 'My_Cool_Model__pony_1_epoch_1.safetensors',
},
{
blobId: 'E2S2.mp4',
fileName: 'My_Cool_Model__pony_V2__from_epoch_5-1_epoch_2_sample_2.mp4',
fileName: 'My_Cool_Model__pony_1_epoch_2_sample_2.mp4',
},
]),
archiveName: 'My_Cool_Model__pony_V2__from_epoch_5-1_training.zip',
archiveName: 'My_Cool_Model__pony_1_training.zip',
});
expect(createBlobArchive.mock.calls[0][0].entries).toHaveLength(5);
expect(result.url).toContain('/archive/token');
@@ -57,7 +57,6 @@ export type TrainingEpochArchiveEntries = {
export function buildEpochArchiveEntries({
trainingResults,
modelName,
versionName,
versionId,
architecture,
maxEntries = MAX_BLOB_ARCHIVE_ENTRIES,
@@ -68,7 +67,7 @@ export function buildEpochArchiveEntries({
const epochs = [...normalizeEpochs(trainingResults)].sort(
(a, b) => a.epochNumber - b.epochNumber
);
const run = { modelName, versionName, versionId, architecture };
const run = { modelName, versionId, architecture };
const candidates: Array<{ url: string; fileName: (blobId: string) => string }> = [];
for (const epoch of epochs) {
candidates.push({
@@ -128,7 +127,6 @@ export async function getTrainingEpochArchive({
where: { id: modelVersionId },
select: {
id: true,
name: true,
trainingDetails: true,
model: { select: { userId: true, name: true } },
files: { select: { metadata: true }, where: { type: 'Training Data' } },
@@ -145,7 +143,6 @@ export async function getTrainingEpochArchive({
const run = {
modelName: modelVersion.model.name,
versionName: modelVersion.name,
versionId: modelVersion.id,
architecture: trainingArchitectureKey(
modelVersion.trainingDetails as TrainingDetailsObj | null
@@ -12,44 +12,22 @@ describe('training file names', () => {
expect(
trainingEpochModelFileName({
modelName: MODEL,
versionName: 'V1',
versionId: 401,
architecture: 'sdxl',
epochNumber: 6,
})
).toBe('My_Cool_Model__sdxl_V1-401_epoch_6.safetensors');
).toBe('My_Cool_Model__sdxl_401_epoch_6.safetensors');
});
it('omits the architecture segment for runs that predate it', () => {
expect(
trainingEpochModelFileName({
modelName: MODEL,
versionName: 'V1',
versionId: 401,
epochNumber: 6,
})
).toBe('My_Cool_Model__V1-401_epoch_6.safetensors');
expect(trainingEpochModelFileName({ modelName: MODEL, versionId: 401, epochNumber: 6 })).toBe(
'My_Cool_Model__401_epoch_6.safetensors'
);
});
it('distinguishes the same epoch number across two runs of one model', () => {
const first = trainingEpochModelFileName({
modelName: MODEL,
versionName: 'V1',
versionId: 401,
epochNumber: 6,
});
const second = trainingEpochModelFileName({
modelName: MODEL,
versionName: 'V2 (from epoch 5)',
versionId: 402,
epochNumber: 6,
});
expect(first).not.toBe(second);
});
it('distinguishes two versions that share a name', () => {
const args = { modelName: MODEL, versionName: 'V1', epochNumber: 6 };
// Cumulative numbering only covers continuations; a fresh retrain restarts at epoch 1.
it('keeps two runs of one model distinct at the same epoch and architecture', () => {
const args = { modelName: MODEL, architecture: 'sdxl', epochNumber: 6 };
expect(trainingEpochModelFileName({ ...args, versionId: 401 })).not.toBe(
trainingEpochModelFileName({ ...args, versionId: 402 })
@@ -61,7 +39,6 @@ describe('training file names', () => {
const name = trainingEpochSampleFileName(
{
modelName: 'M'.repeat(664),
versionName: 'V'.repeat(296),
versionId: 4029135,
architecture: 'A'.repeat(64),
epochNumber: 100,
@@ -71,19 +48,7 @@ describe('training file names', () => {
);
expect(name.length).toBeLessThanOrEqual(255);
expect(name).toContain('-4029135_');
});
it('keeps long-named versions of the same model distinct', () => {
const args = {
modelName: 'M'.repeat(664),
versionName: 'V'.repeat(296),
epochNumber: 6,
};
expect(trainingEpochModelFileName({ ...args, versionId: 401 })).not.toBe(
trainingEpochModelFileName({ ...args, versionId: 402 })
);
expect(name).toContain('_4029135_');
});
// These names land in a Content-Disposition header, and `architecture` comes from an unvalidated
@@ -91,7 +56,6 @@ describe('training file names', () => {
it('strips header-breaking characters from every user-controlled part', () => {
const name = trainingEpochModelFileName({
modelName: 'evil"\r\nX-Injected: 1',
versionName: 'v\n1',
versionId: 401,
architecture: 'sd\r\nxl"',
epochNumber: 6,
@@ -100,26 +64,15 @@ describe('training file names', () => {
expect(name).not.toMatch(/[\r\n"]/);
});
it('still produces a usable name when the version name sanitizes away', () => {
expect(
trainingEpochModelFileName({
modelName: MODEL,
versionName: '!!!',
versionId: 402,
epochNumber: 6,
})
).toBe('My_Cool_Model__402_epoch_6.safetensors');
});
it('scopes samples and the run archive the same way as epoch models', () => {
const run = { modelName: MODEL, versionName: 'V1', versionId: 401 };
const run = { modelName: MODEL, versionId: 401 };
expect(trainingEpochModelFileName({ ...run, epochNumber: 6 })).toBe(
'My_Cool_Model__V1-401_epoch_6.safetensors'
'My_Cool_Model__401_epoch_6.safetensors'
);
expect(trainingEpochSampleFileName({ ...run, epochNumber: 6, sampleNumber: 2 }, '.jpeg')).toBe(
'My_Cool_Model__V1-401_epoch_6_sample_2.jpeg'
'My_Cool_Model__401_epoch_6_sample_2.jpeg'
);
expect(trainingRunArchiveName(run)).toBe('My_Cool_Model__V1-401_training.zip');
expect(trainingRunArchiveName(run)).toBe('My_Cool_Model__401_training.zip');
});
});
+5 -19
View File
@@ -1,33 +1,22 @@
/**
* Names are scoped by model VERSION, not model: each training run gets its own version and numbers
* its epochs from its own start, so `<model>_epoch_3` is ambiguous within a model. The version id
* is what makes that a guarantee version names are not unique within a model and the name is
* kept beside it only because it is the half a user recognises ("from epoch 10").
* its epochs from its own start, so `<model>_epoch_3` is ambiguous within a model. The id carries
* that, not the version name version names are not unique within a model.
*/
/**
* Names are user-supplied and unbounded; most filesystems reject a path component over 255 bytes.
* The version id is never truncated it is what makes the name unique, and two versions of one
* model truncate their shared model name identically.
* Model names reach 664 chars in prod; most filesystems reject a path component over 255 bytes.
* The version id is never truncated it is what makes the name unique.
*/
const MODEL_NAME_MAX = 80;
const VERSION_NAME_MAX = 40;
const ARCHITECTURE_MAX = 20;
function sanitize(value: string, maxLength: number) {
return value.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, maxLength);
}
/** `-` survives sanitising, so it reads as a separator between the name and the id rather than
* running into the underscores the name itself decays into. */
function versionScope({ versionName, versionId }: { versionName: string; versionId: number }) {
const name = sanitize(versionName, VERSION_NAME_MAX).replace(/^_+|_+$/g, '');
return name ? `${name}-${versionId}` : `${versionId}`;
}
export type TrainingRunNameParts = {
modelName: string;
versionName: string;
versionId: number;
/** Short base-model key from `trainingArchitectureKey`, e.g. `pony`, `krea2`. Absent on older runs. */
architecture?: string | null;
@@ -35,14 +24,11 @@ export type TrainingRunNameParts = {
export function trainingRunFilePrefix({
modelName,
versionName,
versionId,
architecture,
}: TrainingRunNameParts) {
const arch = architecture ? sanitize(architecture, ARCHITECTURE_MAX).replace(/^_+|_+$/g, '') : '';
return [sanitize(modelName, MODEL_NAME_MAX), arch, versionScope({ versionName, versionId })]
.filter(Boolean)
.join('_');
return [sanitize(modelName, MODEL_NAME_MAX), arch, String(versionId)].filter(Boolean).join('_');
}
export function trainingEpochModelFileName(args: TrainingRunNameParts & { epochNumber: number }) {