@mcptoolshop/style-dataset-lab 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -1
- package/README.es.md +40 -19
- package/README.fr.md +74 -53
- package/README.hi.md +67 -46
- package/README.it.md +42 -21
- package/README.ja.md +44 -23
- package/README.md +25 -4
- package/README.pt-BR.md +42 -21
- package/README.zh.md +66 -45
- package/bin/sdlab.js +51 -4
- package/lib/adapters/ai-toolkit.js +37 -5
- package/lib/adapters/comfyui-runner.js +114 -27
- package/lib/adapters/comfyui-workflows.js +10 -2
- package/lib/adapters/diffusers-lora.js +37 -5
- package/lib/adapters/generic-image-caption.js +43 -5
- package/lib/args.js +16 -1
- package/lib/batch-modes.js +29 -8
- package/lib/batch-runs.js +29 -1
- package/lib/batch-sheet-render.js +13 -3
- package/lib/brief-compiler.js +19 -0
- package/lib/candidate-sheet.js +327 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +82 -3
- package/lib/critique-render.js +45 -0
- package/lib/eligibility.js +121 -9
- package/lib/eval-pack.js +14 -14
- package/lib/eval-runs.js +33 -0
- package/lib/export.js +122 -34
- package/lib/freeze-gate.js +69 -16
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +16 -1
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/measure.js +422 -0
- package/lib/paths.js +196 -7
- package/lib/reingest-selected.js +98 -16
- package/lib/run-manifest.js +394 -0
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +121 -16
- package/lib/split.js +364 -48
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +67 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +3 -2
- package/runtime/comfyui/txt2img-qwen.json +38 -0
- package/runtime/comfyui/txt2img-standard.json +21 -0
- package/scripts/_resume.js +62 -0
- package/scripts/_shared-args.js +44 -0
- package/scripts/brief-compile.js +2 -1
- package/scripts/brief-show.js +2 -1
- package/scripts/canon-bind.js +2 -2
- package/scripts/canon-build.js +27 -20
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +27 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +48 -8
- package/scripts/curate.js +23 -2
- package/scripts/doctor.js +181 -55
- package/scripts/eligibility.js +42 -2
- package/scripts/eval-pack.js +2 -2
- package/scripts/eval-run.js +2 -2
- package/scripts/export.js +2 -2
- package/scripts/generate-controlnet.js +45 -25
- package/scripts/generate-identity.js +53 -23
- package/scripts/generate-ipadapter.js +44 -23
- package/scripts/generate.js +72 -5
- package/scripts/implementation-pack.js +2 -2
- package/scripts/ingest.js +145 -0
- package/scripts/init.js +73 -13
- package/scripts/measure.js +170 -0
- package/scripts/measure_image.py +458 -0
- package/scripts/migrate-records.js +142 -0
- package/scripts/painterly-test.js +4 -3
- package/scripts/painterly.js +7 -3
- package/scripts/qwen_generate.py +334 -13
- package/scripts/reingest.js +2 -2
- package/scripts/run-generate.js +3 -0
- package/scripts/sheet.js +142 -0
- package/scripts/snapshot.js +2 -2
- package/scripts/split.js +2 -2
- package/scripts/training-manifest.js +2 -2
- package/scripts/training-package.js +2 -2
- package/scripts/training-profile.js +2 -2
- package/scripts/workflow-list.js +2 -1
- package/scripts/workflow-show.js +2 -1
package/lib/canon-build/build.js
CHANGED
|
@@ -91,12 +91,34 @@ export async function runBuild(opts) {
|
|
|
91
91
|
const entries = [];
|
|
92
92
|
const schemaChangeTriggersFullRebuild = await detectSchemaChange(projectRoot, schemas, full);
|
|
93
93
|
|
|
94
|
+
// Entity ids must be unique across the ENTIRE canon tree, not just within
|
|
95
|
+
// one schema's subdirectory: they key prompts/<id>.j2, context/<id>.md, and
|
|
96
|
+
// manifest.per_entity_hashes, all of which are flat maps keyed by id alone.
|
|
97
|
+
// Two entries sharing an id (e.g. a forgotten `id:` edit after cloning an
|
|
98
|
+
// existing file) would silently overwrite each other's prompt/context
|
|
99
|
+
// output and collide in the manifest (last write wins), while
|
|
100
|
+
// dataset/all.jsonl would keep rows for both under the one id — so a row
|
|
101
|
+
// permanently points at the wrong entry's content. Catch this at discovery
|
|
102
|
+
// time, before any output is written.
|
|
103
|
+
const seenIds = new Map(); // id -> first-seen filePath
|
|
104
|
+
|
|
94
105
|
for (const [schemaName, subdir] of Object.entries(config.entity_dirs)) {
|
|
95
106
|
const dir = join(config.canon_root, subdir);
|
|
96
107
|
const loaded = await loadCanonEntriesInDir(dir);
|
|
97
108
|
for (const entry of loaded) {
|
|
98
109
|
const id = entryId(entry);
|
|
99
110
|
if (only && !only.includes(id)) continue;
|
|
111
|
+
if (seenIds.has(id)) {
|
|
112
|
+
throw inputError(
|
|
113
|
+
'CANON_DUPLICATE_ENTITY_ID',
|
|
114
|
+
`Duplicate canon entity id "${id}": both ${seenIds.get(id)} and ${entry.filePath} ` +
|
|
115
|
+
`resolve to this id.`,
|
|
116
|
+
`Give one of these entries a unique "id:" field in its frontmatter. IDs must be ` +
|
|
117
|
+
`unique across the whole canon tree — they key prompts/, context/, and ` +
|
|
118
|
+
`manifest.per_entity_hashes, all flat maps with no per-schema namespacing.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
seenIds.set(id, entry.filePath);
|
|
100
122
|
entries.push({ entry, schemaName, id });
|
|
101
123
|
}
|
|
102
124
|
}
|
|
@@ -166,9 +188,17 @@ export async function runBuild(opts) {
|
|
|
166
188
|
|| entry.frontmatter.reference_plate_uri
|
|
167
189
|
|| null;
|
|
168
190
|
|
|
169
|
-
// Build the row
|
|
191
|
+
// Build the row. `id` is the RESOLVED id (frontmatter.id, falling back to
|
|
192
|
+
// the filename stem — see entryId() in load-entry.js), which is what the
|
|
193
|
+
// prompts/<id>.j2 and context/<id>.md files below are actually named
|
|
194
|
+
// with. canonEntryToRow() (lib/rows.js) reads `entry.id` for entity_id —
|
|
195
|
+
// passing the raw entry.frontmatter would leave entity_id undefined for
|
|
196
|
+
// any entry that relies on the filename-stem fallback (no explicit
|
|
197
|
+
// `id:` field), and serializeRow() drops undefined fields entirely, so
|
|
198
|
+
// the row would orphan itself from its own correctly-named prompt/context
|
|
199
|
+
// files. Overlay the resolved id so the row always carries it.
|
|
170
200
|
const row = canonEntryToRow({
|
|
171
|
-
entry: entry.frontmatter,
|
|
201
|
+
entry: { ...entry.frontmatter, id },
|
|
172
202
|
schemaKind: rendered.schemaKind,
|
|
173
203
|
assetPath: refPath,
|
|
174
204
|
lane,
|
|
@@ -212,104 +242,127 @@ export async function runBuild(opts) {
|
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
// Write to staging, then atomic rename.
|
|
245
|
+
//
|
|
246
|
+
// M2: everything from here through the rename is wrapped in try/catch.
|
|
247
|
+
// Before this fix, a throw ANYWHERE in this block (most notably
|
|
248
|
+
// readFreezeStatus() below, which throws CANON_FREEZE_STATUS_INVALID on a
|
|
249
|
+
// malformed freeze.status) left `stagingDir` — by then already populated
|
|
250
|
+
// with dataset/prompts/context — orphaned on disk forever: the only
|
|
251
|
+
// cleanup is the `rm(stagingDir, ...)` at the top of THIS function, and
|
|
252
|
+
// that runs against a NEW `${generatedFromDir}.tmp-${process.pid}` path on
|
|
253
|
+
// the next invocation (a different pid), so it can never match the
|
|
254
|
+
// orphan. The catch below removes the in-progress staging directory
|
|
255
|
+
// before rethrowing, and does not touch anything once rename() has
|
|
256
|
+
// succeeded (stagingDir no longer exists at that point, so the block
|
|
257
|
+
// exits normally and the catch never runs).
|
|
215
258
|
await rm(stagingDir, { recursive: true, force: true });
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
await
|
|
225
|
-
|
|
226
|
-
|
|
259
|
+
// Declared here (not inside the try block) because they're read again
|
|
260
|
+
// after it, in writeLatestSchemaVersions() and the return value below.
|
|
261
|
+
let frozenEntriesHashes = {};
|
|
262
|
+
let drift = [];
|
|
263
|
+
let manifest;
|
|
264
|
+
try {
|
|
265
|
+
await mkdir(join(stagingDir, 'dataset'), { recursive: true });
|
|
266
|
+
await mkdir(join(stagingDir, 'prompts'), { recursive: true });
|
|
267
|
+
await mkdir(join(stagingDir, 'context'), { recursive: true });
|
|
268
|
+
|
|
269
|
+
// Write prompts + context
|
|
270
|
+
for (const { entry } of entries) {
|
|
271
|
+
const r = entry._rendered;
|
|
272
|
+
const id = entry._id;
|
|
273
|
+
await writeFile(join(stagingDir, 'prompts', `${id}.j2`), r.prompt, 'utf-8');
|
|
274
|
+
await writeFile(join(stagingDir, 'context', `${id}.md`), r.context, 'utf-8');
|
|
275
|
+
}
|
|
227
276
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
277
|
+
// Write dataset.jsonl (all + per-lane-per-partition)
|
|
278
|
+
const allLines = rows.map((r) => serializeRow(r)).join('\n') + (rows.length ? '\n' : '');
|
|
279
|
+
await writeFile(join(stagingDir, 'dataset', 'all.jsonl'), allLines, 'utf-8');
|
|
231
280
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
281
|
+
// Group by (lane × partition)
|
|
282
|
+
const byLaneAndPartition = {};
|
|
283
|
+
for (const row of rows) {
|
|
284
|
+
const key = `${row.lane}-${row.partition}`;
|
|
285
|
+
if (!byLaneAndPartition[key]) byLaneAndPartition[key] = [];
|
|
286
|
+
byLaneAndPartition[key].push(row);
|
|
287
|
+
}
|
|
288
|
+
for (const [key, laneRows] of Object.entries(byLaneAndPartition)) {
|
|
289
|
+
const content = laneRows.map((r) => serializeRow(r)).join('\n') + '\n';
|
|
290
|
+
await writeFile(join(stagingDir, 'dataset', `${key}.jsonl`), content, 'utf-8');
|
|
291
|
+
}
|
|
243
292
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
260
|
-
frozenEntriesHashes[id] = {
|
|
261
|
-
status,
|
|
262
|
-
watch_hash: watchHash,
|
|
263
|
-
watch_fields: watchFields,
|
|
264
|
-
locked_at_build: entry.frontmatter.freeze?.locked_at_build || null,
|
|
265
|
-
};
|
|
266
|
-
// Compute drift against prior build's stamp, if any.
|
|
267
|
-
const priorHash = priorManifest?.frozen_entries_hashes?.[id]?.watch_hash;
|
|
268
|
-
if (priorHash && priorHash !== watchHash) {
|
|
269
|
-
drift.push({
|
|
270
|
-
entity_id: id,
|
|
271
|
-
schema_kind: schemaName.replace('.schema.json', ''),
|
|
293
|
+
// Freeze drift pass — stamp frozen_entries_hashes (D1 witness chain).
|
|
294
|
+
// For every entry with a non-auto freeze status, compute the watch-hash
|
|
295
|
+
// of its currently-watched fields. Drift is computed by later builds or
|
|
296
|
+
// the `sdlab canon drift` command by diffing their hash against this.
|
|
297
|
+
const priorManifest = await loadPreviousManifest(projectRoot, outputDir);
|
|
298
|
+
for (const { entry, schemaName, id } of entries) {
|
|
299
|
+
const status = readFreezeStatus(entry.frontmatter);
|
|
300
|
+
if (status === 'auto') continue;
|
|
301
|
+
const watchFields = resolveWatchFields(entry.frontmatter, schemaName, config);
|
|
302
|
+
const watchHash = computeWatchHash(
|
|
303
|
+
entry.frontmatter,
|
|
304
|
+
watchFields,
|
|
305
|
+
schemas[schemaName].version,
|
|
306
|
+
);
|
|
307
|
+
frozenEntriesHashes[id] = {
|
|
272
308
|
status,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
309
|
+
watch_hash: watchHash,
|
|
310
|
+
watch_fields: watchFields,
|
|
311
|
+
locked_at_build: entry.frontmatter.freeze?.locked_at_build || null,
|
|
312
|
+
};
|
|
313
|
+
// Compute drift against prior build's stamp, if any.
|
|
314
|
+
const priorHash = priorManifest?.frozen_entries_hashes?.[id]?.watch_hash;
|
|
315
|
+
if (priorHash && priorHash !== watchHash) {
|
|
316
|
+
drift.push({
|
|
317
|
+
entity_id: id,
|
|
318
|
+
schema_kind: schemaName.replace('.schema.json', ''),
|
|
319
|
+
status,
|
|
320
|
+
prior_hash: priorHash,
|
|
321
|
+
current_hash: watchHash,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
276
324
|
}
|
|
277
|
-
}
|
|
278
325
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
// Write manifest
|
|
327
|
+
manifest = {
|
|
328
|
+
schema_version: BUILD_SCHEMA_VERSION,
|
|
329
|
+
row_schema_version: ROW_SCHEMA_VERSION,
|
|
330
|
+
generated_from: generatedFrom,
|
|
331
|
+
built_at: new Date().toISOString(),
|
|
332
|
+
project_id: config.project_id,
|
|
333
|
+
schema_versions: Object.fromEntries(
|
|
334
|
+
Object.entries(schemas).map(([k, v]) => [k, v.version])
|
|
335
|
+
),
|
|
336
|
+
build_config_hash: buildConfigHash,
|
|
337
|
+
project_config_fingerprint: projectFingerprint,
|
|
338
|
+
per_entity_hashes: Object.fromEntries(entries.map(({ entry, id }) => [id, entry._entryHash])),
|
|
339
|
+
frozen_entries_hashes: frozenEntriesHashes,
|
|
340
|
+
drift_against_prior: drift,
|
|
341
|
+
stats: {
|
|
342
|
+
entities_total: entries.length,
|
|
343
|
+
entities_cached: cached,
|
|
344
|
+
entities_rebuilt: rebuilt,
|
|
345
|
+
dataset_rows: rows.length,
|
|
346
|
+
frozen_entries: Object.keys(frozenEntriesHashes).length,
|
|
347
|
+
drifted_entries: drift.length,
|
|
348
|
+
schema_change_full_rebuild: schemaChangeTriggersFullRebuild,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
await writeFile(
|
|
352
|
+
join(stagingDir, 'manifest.json'),
|
|
353
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
354
|
+
'utf-8'
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Atomic swap: remove old output, rename staging → output.
|
|
358
|
+
await rm(outputDir, { recursive: true, force: true });
|
|
359
|
+
await rename(stagingDir, outputDir);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
try {
|
|
362
|
+
await rm(stagingDir, { recursive: true, force: true });
|
|
363
|
+
} catch { /* best-effort cleanup — surface the original error below regardless */ }
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
313
366
|
|
|
314
367
|
// Update the "latest" schema-version snapshot for change detection next run.
|
|
315
368
|
await writeLatestSchemaVersions(projectRoot, manifest.schema_versions);
|
package/lib/captions.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* language outperforms booru-style tags on Flux
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
import { inputError } from './errors.js';
|
|
35
|
+
|
|
34
36
|
/**
|
|
35
37
|
* Derive the style trigger token from a profile.
|
|
36
38
|
*
|
|
@@ -75,6 +77,9 @@ export function buildCaption(record, lane, group, profile) {
|
|
|
75
77
|
case 'flux-natural-language':
|
|
76
78
|
return buildFluxNaturalLanguageCaption(record, lane, group, profile);
|
|
77
79
|
|
|
80
|
+
case 'subject-natural-language':
|
|
81
|
+
return buildSubjectNaturalLanguageCaption(record, lane, group, profile);
|
|
82
|
+
|
|
78
83
|
case 'legacy':
|
|
79
84
|
default:
|
|
80
85
|
return buildLegacyCaption(record, lane, group, profile);
|
|
@@ -171,6 +176,58 @@ function buildStructuredMetadataCaption(record, lane, group, profile) {
|
|
|
171
176
|
* This strategy is the floor — on-contract Flux captions from the fields
|
|
172
177
|
* we have. Expand the canon schema first, then enrich this strategy.
|
|
173
178
|
*/
|
|
179
|
+
/**
|
|
180
|
+
* Subject natural-language caption.
|
|
181
|
+
*
|
|
182
|
+
* Same trigger-first prose shape as `flux-natural-language`, but the scene
|
|
183
|
+
* text comes from `record.canon.subject` — an authored, style-free content
|
|
184
|
+
* line — instead of `judgment.explanation`.
|
|
185
|
+
*
|
|
186
|
+
* Why this exists: `judgment.explanation` is a CURATION field. On projects
|
|
187
|
+
* where curation notes record rulings ("Director ruling 2026-07-30: pool
|
|
188
|
+
* look approved...") rather than scene descriptions, the two existing clean
|
|
189
|
+
* strategies would bake review metadata into every caption. That is the same
|
|
190
|
+
* class of defect as legacy prompt-bleed, just from a different field.
|
|
191
|
+
*
|
|
192
|
+
* `canon.subject` must carry CONTENT ONLY — no style vocabulary ("painterly
|
|
193
|
+
* gouache", "sixteen-bit palette"), because style must attach to the trigger.
|
|
194
|
+
* Style words in the subject teach the model to depend on the phrase instead
|
|
195
|
+
* of the token.
|
|
196
|
+
*
|
|
197
|
+
* Shape: `<trigger> style, <subject>.`
|
|
198
|
+
*
|
|
199
|
+
* FAILS LOUD when no subject is present — it does NOT fall back to
|
|
200
|
+
* `flux-natural-language`. That fallback used to read `judgment.explanation`
|
|
201
|
+
* for the scene segment, which silently re-introduces exactly the prompt-bleed
|
|
202
|
+
* antipattern this strategy exists to prevent, just sourced from a curation
|
|
203
|
+
* field instead of `provenance.prompt`. A record selected for this strategy
|
|
204
|
+
* with no `canon.subject` is a data-authoring gap, not a degrade-gracefully
|
|
205
|
+
* case: it must be fixed upstream (populate `canon.subject`) or the record
|
|
206
|
+
* excluded, never silently captioned from review metadata.
|
|
207
|
+
*/
|
|
208
|
+
function buildSubjectNaturalLanguageCaption(record, lane, group, profile) {
|
|
209
|
+
const subject = record?.canon?.subject;
|
|
210
|
+
if (typeof subject !== 'string' || subject.trim().length === 0) {
|
|
211
|
+
const id = record?.id || '(unknown id)';
|
|
212
|
+
throw inputError(
|
|
213
|
+
'CAPTION_SUBJECT_MISSING',
|
|
214
|
+
`Record "${id}" uses caption_strategy "subject-natural-language" but has no ` +
|
|
215
|
+
`canon.subject (content-only description). Refusing to caption from another field.`,
|
|
216
|
+
`Populate canon.subject on record "${id}" with a style-free content line (no ` +
|
|
217
|
+
`"painterly"/"gouache"/palette vocabulary — that belongs to the trigger, not the ` +
|
|
218
|
+
`subject). Do not let this silently fall back to judgment.explanation, which is a ` +
|
|
219
|
+
`curation field and may contain review rulings, not scene content.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const segments = [];
|
|
224
|
+
const trigger = deriveStyleTrigger(profile);
|
|
225
|
+
if (trigger) segments.push(`${trigger} style`);
|
|
226
|
+
segments.push(subject.trim().replace(/\.$/, ''));
|
|
227
|
+
|
|
228
|
+
return segments.join(', ');
|
|
229
|
+
}
|
|
230
|
+
|
|
174
231
|
function buildFluxNaturalLanguageCaption(record, lane, group, profile) {
|
|
175
232
|
const segments = [];
|
|
176
233
|
|
package/lib/card.js
CHANGED
|
@@ -24,6 +24,36 @@ export async function generateCard(projectRoot, snapshotId, splitId) {
|
|
|
24
24
|
const audit = await loadSplitAudit(projectRoot, splitId);
|
|
25
25
|
const included = await loadSnapshotIncluded(projectRoot, snapshotId);
|
|
26
26
|
|
|
27
|
+
// SDL-C2b: the split audit may carry a second, independent leakage
|
|
28
|
+
// signal (stem_collision_check) and a family_resolution guess-share
|
|
29
|
+
// counter. Older on-disk audits (pre-SDL-C2b) won't have either field —
|
|
30
|
+
// default them so old splits/exports keep loading, but never let a
|
|
31
|
+
// missing field silently masquerade as "clean".
|
|
32
|
+
//
|
|
33
|
+
// F1: defaulting a MISSING stem_collision_check to {passed:true} is safe
|
|
34
|
+
// for the structural leakageStructurallyClean computation below (it makes
|
|
35
|
+
// the old audit behave as if the check were absent from the AND, not as
|
|
36
|
+
// if it had run and passed) — but that default must never be allowed to
|
|
37
|
+
// read as "the cross-check ran and found nothing". stemCheckRan tracks
|
|
38
|
+
// whether the field was actually present on disk, independent of the
|
|
39
|
+
// defaulted value, so leakageVerified (and the label it drives) can tell
|
|
40
|
+
// "verified clean" apart from "never checked".
|
|
41
|
+
const stemCheckRan = audit.stem_collision_check != null;
|
|
42
|
+
const stemCheck = audit.stem_collision_check || { passed: true, issues: [] };
|
|
43
|
+
const familyResolution = audit.family_resolution || null;
|
|
44
|
+
const meaningfulGuessShare = familyResolution?.meaningful_guess_share === true;
|
|
45
|
+
// Only both structural checks passing AND the guess-share being immaterial
|
|
46
|
+
// earns the "None (verified)" claim — see renderMarkdown's leakageLabel.
|
|
47
|
+
const leakageStructurallyClean = audit.leakage_check.passed && stemCheck.passed;
|
|
48
|
+
// F1: "verified" additionally requires the cross-check to have actually
|
|
49
|
+
// RUN. An audit that predates SDL-C2b (no stem_collision_check key at
|
|
50
|
+
// all — e.g. projects/tallow-fen/splits/split-20260609-201631-4586)
|
|
51
|
+
// never had the cross-check executed; the comment two lines above this
|
|
52
|
+
// one says "never let a missing field silently masquerade as 'clean'" —
|
|
53
|
+
// requiring stemCheckRan here is what actually enforces that, instead of
|
|
54
|
+
// just asserting it.
|
|
55
|
+
const leakageVerified = leakageStructurallyClean && !meaningfulGuessShare && stemCheckRan;
|
|
56
|
+
|
|
27
57
|
const json = {
|
|
28
58
|
dataset_name: `${config.meta.name}-visual`,
|
|
29
59
|
version: config.meta.version || '1.0.0',
|
|
@@ -53,7 +83,20 @@ export async function generateCard(projectRoot, snapshotId, splitId) {
|
|
|
53
83
|
test: split.profile.test_ratio,
|
|
54
84
|
},
|
|
55
85
|
counts: split.counts,
|
|
56
|
-
|
|
86
|
+
// SDL-C2b: leakage_free now reflects BOTH the original leakage_check
|
|
87
|
+
// AND the independent stem_collision_check — a finding on either one
|
|
88
|
+
// means leakage_free is false. leakage_verified additionally requires
|
|
89
|
+
// the guessed-family share to be immaterial; see leakageLabel below
|
|
90
|
+
// for why these are kept distinct rather than collapsed into one flag.
|
|
91
|
+
leakage_free: leakageStructurallyClean,
|
|
92
|
+
leakage_verified: leakageVerified,
|
|
93
|
+
stem_collision_check_passed: stemCheck.passed,
|
|
94
|
+
// F1: distinct from stem_collision_check_passed above — a legacy
|
|
95
|
+
// audit that never ran the cross-check also reads as "passed" (the
|
|
96
|
+
// default), so leakageLabel needs this separate flag to tell "ran and
|
|
97
|
+
// passed" apart from "never ran".
|
|
98
|
+
stem_collision_check_ran: stemCheckRan,
|
|
99
|
+
family_resolution: familyResolution,
|
|
57
100
|
},
|
|
58
101
|
lane_balance: audit.lane_balance,
|
|
59
102
|
constitution: {
|
|
@@ -77,6 +120,35 @@ function pct(numerator, denominator) {
|
|
|
77
120
|
return `${(numerator / denominator * 100).toFixed(1)}%`;
|
|
78
121
|
}
|
|
79
122
|
|
|
123
|
+
/**
|
|
124
|
+
* SDL-C2b / F1: four-state subject-leakage label. The card must never print
|
|
125
|
+
* "None (verified)" when either leakage check found something, when a
|
|
126
|
+
* meaningful share of families were guessed (id-stripping fallback) rather
|
|
127
|
+
* than authored, OR when the subject-stem cross-check never ran at all
|
|
128
|
+
* (a legacy pre-SDL-C2b audit) — printing "verified" in any of those cases
|
|
129
|
+
* would be exactly the silent-guessing / unrun-check-reads-as-passed
|
|
130
|
+
* failure mode SDL-C2 / F1 were found under. Each state renders distinct
|
|
131
|
+
* text so a downstream script grepping for the literal string "verified"
|
|
132
|
+
* cannot match anything but the fully-verified case.
|
|
133
|
+
*/
|
|
134
|
+
function leakageLabel(card) {
|
|
135
|
+
if (!card.split.leakage_free) return 'DETECTED';
|
|
136
|
+
if (!card.split.stem_collision_check_ran) {
|
|
137
|
+
// F1: the split predates the subject-stem cross-check entirely — only
|
|
138
|
+
// the original leakage_check ever ran. This must render distinctly
|
|
139
|
+
// from "None (verified)" even though leakage_check itself passed.
|
|
140
|
+
return 'None (leakage_check only — predates the subject-stem cross-check)';
|
|
141
|
+
}
|
|
142
|
+
if (!card.split.leakage_verified) {
|
|
143
|
+
const fr = card.split.family_resolution;
|
|
144
|
+
const pctGuessed = fr ? Math.round(fr.guessed_family_ratio * 100) : null;
|
|
145
|
+
return `UNVERIFIED — ${pctGuessed != null ? pctGuessed + '% of' : 'some'} subject families were ` +
|
|
146
|
+
`inferred from record ID (no authored identity.subject_name/lineage); confirm manually before ` +
|
|
147
|
+
`treating this split as leakage-free`;
|
|
148
|
+
}
|
|
149
|
+
return 'None (verified)';
|
|
150
|
+
}
|
|
151
|
+
|
|
80
152
|
function renderMarkdown(card, config) {
|
|
81
153
|
const lines = [];
|
|
82
154
|
const add = (s = '') => lines.push(s);
|
|
@@ -112,7 +184,7 @@ function renderMarkdown(card, config) {
|
|
|
112
184
|
add(`- **Split ID:** \`${card.split.id}\``);
|
|
113
185
|
add(`- **Strategy:** ${card.split.strategy}`);
|
|
114
186
|
add(`- **Seed:** ${card.split.seed}`);
|
|
115
|
-
add(`- **Subject leakage:** ${card
|
|
187
|
+
add(`- **Subject leakage:** ${leakageLabel(card)}`);
|
|
116
188
|
add();
|
|
117
189
|
|
|
118
190
|
add('| Partition | Records | % |');
|
package/lib/comfyui.js
CHANGED
|
@@ -16,36 +16,114 @@ const HEARTBEAT_MS = 15_000; // emit a heartbeat at most every 15s
|
|
|
16
16
|
// than hang until MAX_POLL_MS.
|
|
17
17
|
const UNKNOWN_STATUS_BAIL_POLLS = 30; // ~30s of unrecognized shape
|
|
18
18
|
|
|
19
|
+
// SDL-H8: per-REQUEST timeout, distinct from the per-CALL poll budget
|
|
20
|
+
// (timeoutMs/MAX_POLL_MS above, which only bounds the interval *between*
|
|
21
|
+
// requests). Without this, a ComfyUI that accepts the TCP connection and
|
|
22
|
+
// never responds hangs `await fetch()` forever — a hang never rejects, so
|
|
23
|
+
// no retry/catch logic anywhere downstream ever fires. Configurable via env
|
|
24
|
+
// so operators can widen it for a slow/remote ComfyUI without a code change.
|
|
25
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = Number(process.env.SDLAB_COMFY_REQUEST_TIMEOUT_MS) || 30_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* fetch() with a hard per-request timeout via AbortController. Guarantees
|
|
29
|
+
* the returned promise always settles (resolve or reject) within
|
|
30
|
+
* `timeoutMs`, even against a server that accepts the connection and then
|
|
31
|
+
* never responds. On timeout, rejects with a plain Error (not a raw
|
|
32
|
+
* AbortError/DOMException) naming the URL and elapsed time, so it reads
|
|
33
|
+
* like any other network failure to callers' existing catch/retry logic.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} url
|
|
36
|
+
* @param {RequestInit} [init]
|
|
37
|
+
* @param {number} [timeoutMs]
|
|
38
|
+
* @returns {Promise<Response>}
|
|
39
|
+
*/
|
|
40
|
+
async function fetchWithTimeout(url, init = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
try {
|
|
45
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err?.name === 'AbortError') {
|
|
48
|
+
const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
|
|
49
|
+
throw new Error(
|
|
50
|
+
`ComfyUI request timed out after ${elapsedSec}s (limit ${(timeoutMs / 1000).toFixed(1)}s): ${url}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
19
59
|
/**
|
|
20
|
-
*
|
|
60
|
+
* B04: comfyHealth() used to collapse EVERY failure mode into a bare
|
|
61
|
+
* `false` via `catch { return false }` with no bound error. A 503 while a
|
|
62
|
+
* custom node loads, a refused connection, a DNS typo, and a genuine
|
|
63
|
+
* timeout all produced the identical "ComfyUI not reachable — start
|
|
64
|
+
* ComfyUI" message downstream — factually wrong for the 503 (ComfyUI IS
|
|
65
|
+
* reachable and running) and useless for the others (starting ComfyUI does
|
|
66
|
+
* not fix a DNS typo).
|
|
67
|
+
*
|
|
68
|
+
* Check if ComfyUI is reachable, WITH a reason when it isn't.
|
|
69
|
+
*
|
|
21
70
|
* @param {string} [comfyUrl]
|
|
22
|
-
* @
|
|
71
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
72
|
+
* @returns {Promise<{ok: boolean, reason: string|null}>} reason is one of
|
|
73
|
+
* 'timeout' | 'refused' | 'dns' | 'http-<status>' | 'unknown' | null (when ok)
|
|
23
74
|
*/
|
|
24
|
-
export async function
|
|
75
|
+
export async function comfyHealthDetailed(comfyUrl = DEFAULT_URL, opts = {}) {
|
|
25
76
|
try {
|
|
26
|
-
const res = await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
77
|
+
const res = await fetchWithTimeout(`${comfyUrl}/system_stats`, {}, opts.timeoutMs);
|
|
78
|
+
if (res.ok) return { ok: true, reason: null };
|
|
79
|
+
return { ok: false, reason: `http-${res.status}` };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (/timed out/i.test(err?.message || '')) return { ok: false, reason: 'timeout' };
|
|
82
|
+
const code = err?.cause?.code || err?.code;
|
|
83
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return { ok: false, reason: 'dns' };
|
|
84
|
+
if (code === 'ECONNREFUSED' || code === 'ECONNRESET') return { ok: false, reason: 'refused' };
|
|
85
|
+
return { ok: false, reason: 'unknown' };
|
|
30
86
|
}
|
|
31
87
|
}
|
|
32
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Check if ComfyUI is reachable. Thin plain-boolean wrapper over
|
|
91
|
+
* comfyHealthDetailed() — kept as a SEPARATE, unchanged-signature export
|
|
92
|
+
* because 5 scripts (generate.js, generate-identity.js,
|
|
93
|
+
* generate-controlnet.js, generate-ipadapter.js, painterly.js) already call
|
|
94
|
+
* `if (!(await comfyHealth(...)))`; returning a richer object here would
|
|
95
|
+
* make every one of those checks permanently truthy (an object is always
|
|
96
|
+
* truthy) and silently disable their offline handling.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} [comfyUrl]
|
|
99
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
100
|
+
* @returns {Promise<boolean>}
|
|
101
|
+
*/
|
|
102
|
+
export async function comfyHealth(comfyUrl = DEFAULT_URL, opts = {}) {
|
|
103
|
+
const { ok } = await comfyHealthDetailed(comfyUrl, opts);
|
|
104
|
+
return ok;
|
|
105
|
+
}
|
|
106
|
+
|
|
33
107
|
/**
|
|
34
108
|
* Submit a workflow and poll until complete.
|
|
35
109
|
* @param {Object} workflow — ComfyUI prompt graph
|
|
36
110
|
* @param {string} [comfyUrl]
|
|
37
|
-
* @param {{ clientPrefix?: string, timeoutMs?: number }} [opts]
|
|
111
|
+
* @param {{ clientPrefix?: string, timeoutMs?: number, requestTimeoutMs?: number }} [opts]
|
|
112
|
+
* `timeoutMs` bounds the OVERALL poll loop (unchanged, default 10 min).
|
|
113
|
+
* `requestTimeoutMs` (SDL-H8) bounds each INDIVIDUAL fetch — the submit
|
|
114
|
+
* POST and every /history poll — so a server that never responds can't
|
|
115
|
+
* hang the whole call forever.
|
|
38
116
|
* @returns {Promise<Object>} — history entry
|
|
39
117
|
*/
|
|
40
118
|
export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {}) {
|
|
41
|
-
const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS } = opts;
|
|
119
|
+
const { clientPrefix = 'sdl', timeoutMs = MAX_POLL_MS, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = opts;
|
|
42
120
|
const clientId = `${clientPrefix}-${Date.now()}`;
|
|
43
121
|
|
|
44
|
-
const res = await
|
|
122
|
+
const res = await fetchWithTimeout(`${comfyUrl}/prompt`, {
|
|
45
123
|
method: 'POST',
|
|
46
124
|
headers: { 'Content-Type': 'application/json' },
|
|
47
125
|
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
|
48
|
-
});
|
|
126
|
+
}, requestTimeoutMs);
|
|
49
127
|
|
|
50
128
|
if (!res.ok) {
|
|
51
129
|
const text = await res.text();
|
|
@@ -72,7 +150,10 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
|
|
|
72
150
|
await new Promise(r => setTimeout(r, 1000));
|
|
73
151
|
let histRes;
|
|
74
152
|
try {
|
|
75
|
-
|
|
153
|
+
// SDL-H8: bounded so a server that accepts the connection and never
|
|
154
|
+
// responds lands in this catch (as an AbortError → clear timeout
|
|
155
|
+
// Error) instead of hanging `await fetch()` forever.
|
|
156
|
+
histRes = await fetchWithTimeout(`${comfyUrl}/history/${promptId}`, {}, requestTimeoutMs);
|
|
76
157
|
} catch (err) {
|
|
77
158
|
consecutiveHistoryFailures++;
|
|
78
159
|
if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
|
|
@@ -91,8 +172,23 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
|
|
|
91
172
|
}
|
|
92
173
|
continue;
|
|
93
174
|
}
|
|
175
|
+
// SDL-L3: histRes.json() used to sit outside the retry block above, so
|
|
176
|
+
// a 200-OK non-JSON body (e.g. a proxy error page) threw a raw
|
|
177
|
+
// SyntaxError here, uncaught, permanently failing the image the
|
|
178
|
+
// adjacent 5-strike tolerance would otherwise have recovered from.
|
|
179
|
+
let history;
|
|
180
|
+
try {
|
|
181
|
+
history = await histRes.json();
|
|
182
|
+
} catch (err) {
|
|
183
|
+
consecutiveHistoryFailures++;
|
|
184
|
+
if (consecutiveHistoryFailures >= MAX_CONSECUTIVE_HISTORY_FAILURES) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`ComfyUI history endpoint returned an unparseable response ${MAX_CONSECUTIVE_HISTORY_FAILURES} times in a row for prompt ${promptId}: ${err.message}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
94
191
|
consecutiveHistoryFailures = 0;
|
|
95
|
-
const history = await histRes.json();
|
|
96
192
|
const entry = history[promptId];
|
|
97
193
|
|
|
98
194
|
// PB-001: emit heartbeats so the user knows the pipeline isn't hung.
|
|
@@ -159,9 +255,10 @@ export async function submitAndWait(workflow, comfyUrl = DEFAULT_URL, opts = {})
|
|
|
159
255
|
* @param {string} [subfolder]
|
|
160
256
|
* @param {string} [comfyUrl]
|
|
161
257
|
* @param {string} [type='output']
|
|
258
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
162
259
|
* @returns {Promise<Buffer>}
|
|
163
260
|
*/
|
|
164
|
-
export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output') {
|
|
261
|
+
export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL, type = 'output', opts = {}) {
|
|
165
262
|
const ALLOWED_TYPES = ['output', 'input', 'temp'];
|
|
166
263
|
if (!ALLOWED_TYPES.includes(type)) {
|
|
167
264
|
throw new Error(`Invalid ComfyUI image type "${type}" — must be one of: ${ALLOWED_TYPES.join(', ')}`);
|
|
@@ -170,7 +267,7 @@ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL,
|
|
|
170
267
|
throw new Error(`Invalid comfyUrl "${comfyUrl}" — must start with http:// or https://`);
|
|
171
268
|
}
|
|
172
269
|
const url = `${comfyUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder || '')}&type=${encodeURIComponent(type)}`;
|
|
173
|
-
const res = await
|
|
270
|
+
const res = await fetchWithTimeout(url, {}, opts.timeoutMs);
|
|
174
271
|
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
175
272
|
return Buffer.from(await res.arrayBuffer());
|
|
176
273
|
}
|
|
@@ -180,18 +277,19 @@ export async function downloadImage(filename, subfolder, comfyUrl = DEFAULT_URL,
|
|
|
180
277
|
* @param {string} filePath — local file to upload
|
|
181
278
|
* @param {string} filename — name to assign in ComfyUI
|
|
182
279
|
* @param {string} [comfyUrl]
|
|
280
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
183
281
|
* @returns {Promise<string>} — assigned name from ComfyUI
|
|
184
282
|
*/
|
|
185
|
-
export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL) {
|
|
283
|
+
export async function uploadImage(filePath, filename, comfyUrl = DEFAULT_URL, opts = {}) {
|
|
186
284
|
const data = await readFile(filePath);
|
|
187
285
|
const formData = new FormData();
|
|
188
286
|
formData.append('image', new Blob([data], { type: 'image/png' }), filename);
|
|
189
287
|
formData.append('overwrite', 'true');
|
|
190
288
|
|
|
191
|
-
const res = await
|
|
289
|
+
const res = await fetchWithTimeout(`${comfyUrl}/upload/image`, {
|
|
192
290
|
method: 'POST',
|
|
193
291
|
body: formData,
|
|
194
|
-
});
|
|
292
|
+
}, opts.timeoutMs);
|
|
195
293
|
|
|
196
294
|
if (!res.ok) {
|
|
197
295
|
const text = await res.text();
|