Credit the oldest published match for a shared hash, and pick up the parser fix

Three things, all downstream of the A1111 "Lora hashes" regression.

Take @civitai/generation-metadata 0.3.0. A `Lora hashes` block whose first
entry carried a bracket or non-Latin character was iterated character by
character, so A1111 and Forge uploads have attached no LoRAs since 2026-08-31.
0.3.0 decides nested hash blocks by key rather than by the shape of the
creator's filename. This is a minor bump, so the caret on ^0.2.0 would not have
crossed it on its own.

Add /api/testing/backfill-lora-hashes for the 13,596 images already affected.
They repair without their original files: the `lora:0`, `lora:1` ... keys ARE
the block, in order, so the endpoint reassembles it, re-parses through
parsePromptMetadata (the shipped parser, deliberately -- the backfill and the
upload path must not be able to disagree about what a block means), rewrites
meta.hashes/meta.resources and re-runs detection. Dry run unless apply=true.
Paging is keyset on (createdAt, id) to match the index the filter walks;
ordering by id alone re-scans the window every batch, measured at 403k rows
filtered to find the first 100. Measured on 300 sampled images: 86% of what it
would write is genuinely absent today, and 298 of 300 gain at least one
resource.

Fix resolveImageMeta's shared-hash tie-break, which read `>` where
get_image_resources.sql reads ascending. One hash can sit on files owned by
several people -- in practice a re-upload of someone else's weights -- so the
ordering decides who gets credited. The SQL took the earliest published copy,
the closest proxy for the original uploader; the generator took the most
recent, which is the re-uploader by definition. On nine such hashes found in a
300-image sample, the two implementations disagreed on every one: the image
page credited @Zavy, @GZees, @Jedas and friends while the generator would have
credited whoever posted the copy later.

The comparison was inline and needed a database to exercise, which is how it
drifted. Extracted as prefersHashMatch and pinned from both sides: a unit test
for the direction, the precedence and the strictness, and an assertion in the
existing source gate that the SQL's ORDER BY stays ascending. Both verified by
reverting them -- flipping the TypeScript fails 3 tests, flipping the SQL fails
the gate. Nothing compared the two implementations before; the gate's own
header says it can assert the filters exist in both but not that they agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
briant
2026-09-15 16:56:59 -06:00
parent d381b4967e
commit 390d036b32
6 changed files with 355 additions and 19 deletions
+1 -1
View File
@@ -137,7 +137,7 @@
"@civitai/db-queries": "workspace:*",
"@civitai/db-schema": "workspace:*",
"@civitai/flipt": "workspace:*",
"@civitai/generation-metadata": "^0.2.0",
"@civitai/generation-metadata": "^0.3.0",
"@civitai/moderation": "workspace:*",
"@civitai/next-axiom": "^0.17.0",
"@civitai/orchestration-client": "0.2.0-beta.104",
+5 -5
View File
@@ -62,8 +62,8 @@ importers:
specifier: workspace:*
version: link:packages/civitai-flipt
'@civitai/generation-metadata':
specifier: ^0.2.0
version: 0.2.0
specifier: ^0.3.0
version: 0.3.0
'@civitai/moderation':
specifier: workspace:*
version: link:packages/civitai-moderation
@@ -2215,8 +2215,8 @@ packages:
resolution: {integrity: sha512-j+aIZy+d3mvPhE93lrx9d3k08PABFeH025DIYuR1T5LWqJ9xnAHTuFXdhLCeIDZESdOg95fNJcVqhOvAcCpddw==}
engines: {node: '>=22.18.0', npm: '>=10', pnpm: '>=10'}
'@civitai/generation-metadata@0.2.0':
resolution: {integrity: sha512-QAevrDK/o4wU33zCsrGbF2zED01Pyg7z/E5nnBAV5nvEInlw6mzYqWZ3mU6rbz87ej4epe8Knj6WZj7USjvhDg==}
'@civitai/generation-metadata@0.3.0':
resolution: {integrity: sha512-+mnPE4cz+1qrIquvybz97mylvxXsBv/w1p3lR5lsLQwL5FgJHb0EkIl33fo8mEy0IzyJadol6fd2NmLayzIY+A==}
engines: {node: '>=20'}
'@civitai/next-axiom@0.17.0':
@@ -13440,7 +13440,7 @@ snapshots:
dependencies:
fast-xml-parser: 5.10.1
'@civitai/generation-metadata@0.2.0':
'@civitai/generation-metadata@0.3.0':
dependencies:
exifreader: 4.41.3
zod: 4.4.3
@@ -0,0 +1,225 @@
/**
* Backfill for the A1111 "Lora hashes" regression (ClickUp 868m53cpr).
*
* Between 2026-08-31 and the @civitai/generation-metadata 0.3.0 upgrade, a `Lora
* hashes` block whose FIRST entry carried an awkward character (brackets, non-Latin
* script) failed the parser's shape test, unquoted to a plain string, and was then
* iterated character by character — so `meta.hashes` gained one `lora:<n>` key per
* CHARACTER and the whole block's LoRAs went undetected.
*
* Those rows repair in place: the `lora:0`, `lora:1` … values ARE the original block,
* in order. This reassembles it, re-parses it with the shipped (fixed) parser, rewrites
* `meta.hashes` / `meta.resources`, and re-runs detection.
*
* GET /api/testing/backfill-lora-hashes?token=$WEBHOOK_TOKEN
* days=14 how far back to scan (createdAt >= now() - days). Default 14.
* batchSize=100 images per batch, max 500.
* maxBatches=0 0 = keep going until the window is exhausted or maxMs runs out.
* maxMs=50000 stop cleanly before the platform's request timeout and hand back a
* cursor. Resume with after=<nextCursor> from the response.
* after= resume point, `<iso createdAt>|<id>` from a previous response.
* apply=false DRY RUN unless apply=true. Nothing is written without it.
* userId=<id> optional: restrict to one uploader.
* verbose=false include a per-image sample of what would change.
*
* Reads the fixed parser through `parsePromptMetadata`, deliberately: the backfill and
* the upload path must not be able to disagree about what a block means. That also means
* this endpoint is only correct once the app is on a build with the fix.
*
* Paging is keyset on (createdAt, id) because that is the index the filter walks
* (`Image_createdAt_id`). Ordering by id alone makes every batch re-scan the window from
* the start — measured at 403k rows filtered to find the first 100, and quadratic from
* there. There is no index that can serve the `meta` LIKE, so the scan is the cost; the
* point of the keyset is that it is paid once, not once per batch.
*/
import type { NextApiRequest, NextApiResponse } from 'next';
import { Prisma } from '@prisma/client';
import { dbRead, dbWrite } from '~/server/db/client';
import { refreshImageResources } from '~/server/services/image.service';
import { parsePromptMetadata } from '~/utils/metadata';
import { WebhookEndpoint } from '~/server/utils/endpoint-helpers';
const CHAR_SPLIT_KEY = /^lora:\d+$/;
const MAX_BATCH = 500;
type ImageRow = { id: number; createdAt: Date; meta: Record<string, any> | null };
/** The `lora:<n>` values are the original block's characters, in index order. */
function reassembleBlock(hashes: Record<string, unknown>): string {
const chars: string[] = [];
for (const [key, value] of Object.entries(hashes)) {
const match = /^lora:(\d+)$/.exec(key);
if (match && typeof value === 'string') chars[Number(match[1])] = value;
}
// A hole means the row is not what we think it is; refuse rather than guess.
return chars.length && [...chars].every((c) => typeof c === 'string') ? chars.join('') : '';
}
function recoverLoraHashes(block: string): Record<string, string> | null {
// The block sits inside a quoted A1111 value, so an embedded quote is not something
// this format can represent — treat it as unrecognized input rather than repair it.
if (!block || block.includes('"')) return null;
const meta = parsePromptMetadata(`backfill\nSteps: 1, Lora hashes: "${block}"`);
const hashes = (meta?.hashes ?? {}) as Record<string, string>;
const recovered: Record<string, string> = {};
for (const [key, value] of Object.entries(hashes)) {
if (key.startsWith('lora:') && !CHAR_SPLIT_KEY.test(key)) recovered[key] = value;
}
return Object.keys(recovered).length ? recovered : null;
}
/** `{type:'lora', name:'17', hash:'e'}` — one bogus resource per character. */
function isCharSplitResource(resource: unknown): boolean {
const r = resource as { name?: unknown; hash?: unknown };
return (
typeof r?.name === 'string' &&
/^\d+$/.test(r.name) &&
typeof r?.hash === 'string' &&
r.hash.length === 1
);
}
function parseAfter(value: unknown): { createdAt: Date; id: number } | null {
if (typeof value !== 'string' || !value.includes('|')) return null;
const [iso, id] = value.split('|');
const createdAt = new Date(iso);
if (Number.isNaN(createdAt.getTime()) || !Number(id)) return null;
return { createdAt, id: Number(id) };
}
export default WebhookEndpoint(async function handler(req: NextApiRequest, res: NextApiResponse) {
const days = Math.min(Number(req.query.days ?? 14) || 14, 120);
const batchSize = Math.min(Number(req.query.batchSize ?? 100) || 100, MAX_BATCH);
const maxBatches = Number(req.query.maxBatches ?? 0) || 0;
const maxMs = Math.min(Number(req.query.maxMs ?? 50_000) || 50_000, 280_000);
const apply = req.query.apply === 'true';
const userId = req.query.userId ? Number(req.query.userId) : undefined;
const verbose = req.query.verbose === 'true';
const startedAt = Date.now();
// Computed here, not as make_interval(days => ${days}): Prisma binds a JS number as
// int8 and make_interval takes int4, so the parameterised form fails at runtime while
// the same SQL with a literal works. A Date binds as timestamptz and matches the column.
const since = new Date(startedAt - days * 24 * 60 * 60 * 1000);
const samples: unknown[] = [];
let after = parseAfter(req.query.after);
let batches = 0;
let scanned = 0;
let repaired = 0;
let loraHashesRecovered = 0;
let skippedUnrecoverable = 0;
let exhausted = false;
let stoppedOn: 'exhausted' | 'maxBatches' | 'maxMs' = 'exhausted';
const failures: { id: number; error: string }[] = [];
try {
for (;;) {
if (maxBatches && batches >= maxBatches) {
stoppedOn = 'maxBatches';
break;
}
if (Date.now() - startedAt > maxMs) {
stoppedOn = 'maxMs';
break;
}
const rows = await dbRead.$queryRaw<ImageRow[]>`
SELECT i.id, i."createdAt", i.meta
FROM "Image" i
WHERE i."createdAt" >= ${since}
${
after
? Prisma.sql`AND (i."createdAt", i.id) > (${after.createdAt}::timestamptz, ${after.id}::int)`
: Prisma.empty
}
AND i.meta->>'hashes' LIKE '%"lora:0":%'
${userId ? Prisma.sql`AND i."userId" = ${userId}` : Prisma.empty}
ORDER BY i."createdAt", i.id
LIMIT ${batchSize}
`;
if (!rows.length) {
exhausted = true;
break;
}
batches++;
const last = rows[rows.length - 1];
after = { createdAt: last.createdAt, id: last.id };
for (const row of rows) {
scanned++;
const meta = (row.meta ?? {}) as Record<string, any>;
const hashes = (meta.hashes ?? {}) as Record<string, string>;
if (!Object.keys(hashes).some((k) => CHAR_SPLIT_KEY.test(k))) continue;
const recovered = recoverLoraHashes(reassembleBlock(hashes));
if (!recovered) {
skippedUnrecoverable++;
continue;
}
const cleanedHashes: Record<string, string> = {};
for (const [key, value] of Object.entries(hashes)) {
if (!CHAR_SPLIT_KEY.test(key)) cleanedHashes[key] = value;
}
const nextHashes = { ...cleanedHashes, ...recovered };
const nextResources = Array.isArray(meta.resources)
? meta.resources.filter((r: unknown) => !isCharSplitResource(r))
: meta.resources;
if (verbose && samples.length < 10) {
samples.push({
id: row.id,
charKeysRemoved: Object.keys(hashes).length - Object.keys(cleanedHashes).length,
recovered: Object.keys(recovered),
});
}
repaired++;
loraHashesRecovered += Object.keys(recovered).length;
if (!apply) continue;
try {
await dbWrite.image.update({
where: { id: row.id },
data: { meta: { ...meta, hashes: nextHashes, resources: nextResources } },
});
// Drops the stale detected rows (including the ones the garbage produced) and
// re-derives them from the repaired meta.
await refreshImageResources(row.id);
} catch (e) {
failures.push({ id: row.id, error: (e as Error).message });
}
}
}
return res.status(200).json({
mode: apply ? 'APPLIED' : 'DRY RUN (pass apply=true to write)',
window: `${days} days`,
userId: userId ?? 'all',
stoppedOn,
exhausted,
nextCursor: exhausted || !after ? null : `${after.createdAt.toISOString()}|${after.id}`,
batches,
batchSize,
scanned,
repaired,
loraHashesRecovered,
skippedUnrecoverable,
failureCount: failures.length,
failures: failures.slice(0, 20),
elapsedMs: Date.now() - startedAt,
...(verbose ? { samples } : {}),
});
} catch (error) {
return res.status(500).json({
error: (error as Error).message,
progress: {
batches,
scanned,
repaired,
resumeFrom: after ? `${after.createdAt.toISOString()}|${after.id}` : null,
},
});
}
});
@@ -49,6 +49,20 @@ describe('get_image_resources.sql', () => {
expect(sql, 'never filters on mf.type').toMatch(/mf\.type\s+NOT\s+IN/i);
});
it('breaks a shared-hash tie toward the earliest published version', () => {
// A hash on files owned by several people is in practice a re-upload, so the ordering
// decides whether the original creator or the re-uploader gets the image. ASCENDING
// version_date is what picks the original. The TypeScript mirror read this backwards
// until 2026-09-15 — same rule, two languages, opposite winners — so pin the direction
// here and the mirror's in generation/__tests__/prefers-hash-match.
const ordering = sql.match(/ORDER BY IIF\(irh\.detected[^)]*\)[^\n]*/)?.[0] ?? '';
expect(ordering, 'no shared-hash tie-break ordering found').toContain('version_published');
expect(ordering, 'version_date is no longer ascending, so the LATEST upload now wins').toMatch(
/version_date,\s*file_id/
);
expect(ordering, 'version_date was made descending').not.toMatch(/version_date\s+DESC/i);
});
it('survives the db:program splitter', () => {
// scripts/prisma-prepare-programmability.mjs splits every file on the literal `---` and runs
// each part as its own statement, so one in a comment cuts the function in half at deploy time.
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { prefersHashMatch } from '~/server/services/generation/generation.service';
/**
* One hash can sit on files owned by several people — in practice because someone
* re-uploaded another creator's weights. `prefersHashMatch` decides who gets credited,
* and it has to agree with get_image_resources.sql's
* `ORDER BY IIF(version_published,0,1), version_date, file_id`, because that function
* credits the image page while this credits the generator. They disagreed until
* 2026-09-15: the SQL took the oldest, this took the newest, so the same file credited
* the original creator in one place and the re-uploader in the other. Nothing compares
* the two implementations, so the direction is pinned here.
*/
const match = (over: Partial<Parameters<typeof prefersHashMatch>[0]> = {}) => ({
versionPublished: true,
versionDate: new Date('2025-01-01'),
fileId: 100,
...over,
});
describe('prefersHashMatch', () => {
it('takes any candidate when there is nothing to compare against', () => {
expect(prefersHashMatch(match(), undefined)).toBe(true);
});
it('prefers a published version over an unpublished one, whatever the dates say', () => {
const published = match({ versionPublished: true, versionDate: new Date('2026-01-01') });
const unpublished = match({ versionPublished: false, versionDate: new Date('2020-01-01') });
expect(prefersHashMatch(published, unpublished)).toBe(true);
expect(prefersHashMatch(unpublished, published)).toBe(false);
});
it('prefers the OLDEST of two published versions, not the newest', () => {
// The whole point: the later upload is the re-upload.
const original = match({ versionDate: new Date('2024-02-06') });
const reupload = match({ versionDate: new Date('2025-06-22') });
expect(prefersHashMatch(original, reupload)).toBe(true);
expect(prefersHashMatch(reupload, original)).toBe(false);
});
it('prefers the oldest among unpublished versions too', () => {
const older = match({ versionPublished: false, versionDate: new Date('2024-01-01') });
const newer = match({ versionPublished: false, versionDate: new Date('2026-01-01') });
expect(prefersHashMatch(older, newer)).toBe(true);
expect(prefersHashMatch(newer, older)).toBe(false);
});
it('falls back to the lowest file id when published and date are identical', () => {
const date = new Date('2025-01-01');
expect(
prefersHashMatch(
match({ fileId: 5, versionDate: date }),
match({ fileId: 9, versionDate: date })
)
).toBe(true);
expect(
prefersHashMatch(
match({ fileId: 9, versionDate: date }),
match({ fileId: 5, versionDate: date })
)
).toBe(false);
});
it('is a strict preference: an identical candidate does not displace the incumbent', () => {
// Otherwise the last row the query happens to return wins, and the winner depends
// on scan order rather than on the rule.
expect(prefersHashMatch(match(), match())).toBe(false);
});
it('reproduces the production duplicates it was written for', () => {
// Real shared hashes: the earliest published copy is the original creator in each.
const cases = [
{ original: new Date('2024-02-06'), reupload: new Date('2025-06-22') }, // 1ac0c6cd4e92
{ original: new Date('2025-05-13'), reupload: new Date('2025-06-30') }, // 61d7ee6c08bd
{ original: new Date('2025-02-25'), reupload: new Date('2026-05-21') }, // 70a66c1a0734
{ original: new Date('2026-07-01'), reupload: new Date('2026-08-02') }, // d85cf97b20d8
];
for (const { original, reupload } of cases) {
expect(
prefersHashMatch(match({ versionDate: original }), match({ versionDate: reupload })),
`expected the ${original.toISOString().slice(0, 10)} upload to win`
).toBe(true);
}
});
});
@@ -1659,6 +1659,29 @@ export function extractHashCandidates(
*
* Returns { resources, params } where params are ready for the generation graph.
*/
type HashMatch = { versionPublished: boolean; versionDate: Date; fileId: number };
/**
* Which of several files sharing one hash gets the credit. Mirrors
* get_image_resources.sql's `ORDER BY IIF(version_published,0,1), version_date, file_id`:
* published first, then OLDEST, then lowest file id.
*
* Oldest, not newest. A hash shared across owners is in practice a re-upload of someone
* else's weights, so the earliest published copy is the closest thing to the original
* uploader; preferring the most recent hands every duplicated model to whoever posted it
* last. This read `>` until 2026-09-15, which meant the image page credited the original
* and the generator credited the re-uploader for the same file — the two are the same
* rule in two languages, and nothing compares them.
*/
export function prefersHashMatch(candidate: HashMatch, existing: HashMatch | undefined): boolean {
if (!existing) return true;
if (existing.versionPublished !== candidate.versionPublished) return candidate.versionPublished;
const existingDate = existing.versionDate.valueOf();
const candidateDate = candidate.versionDate.valueOf();
if (existingDate !== candidateDate) return candidateDate < existingDate;
return candidate.fileId < existing.fileId;
}
export async function resolveImageMeta({
input,
user,
@@ -1706,22 +1729,10 @@ export async function resolveImageMeta({
`;
// Build a map of hash → best matching modelVersionId
// When multiple files match the same hash, prefer published > recent > lowest fileId
const bestByHash = new Map<string, (typeof hashResults)[0]>();
for (const row of hashResults) {
if (row.excludeFromAutoDetection) continue;
const existing = bestByHash.get(row.hash);
if (
!existing ||
(!existing.versionPublished && row.versionPublished) ||
(existing.versionPublished === row.versionPublished &&
row.versionDate > existing.versionDate) ||
(existing.versionPublished === row.versionPublished &&
existing.versionDate === row.versionDate &&
row.fileId < existing.fileId)
) {
bestByHash.set(row.hash, row);
}
if (prefersHashMatch(row, bestByHash.get(row.hash))) bestByHash.set(row.hash, row);
}
// Match hash candidates to resolved version IDs