@mcptoolshop/style-dataset-lab 3.3.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
@@ -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
- await mkdir(join(stagingDir, 'dataset'), { recursive: true });
217
- await mkdir(join(stagingDir, 'prompts'), { recursive: true });
218
- await mkdir(join(stagingDir, 'context'), { recursive: true });
219
-
220
- // Write prompts + context
221
- for (const { entry } of entries) {
222
- const r = entry._rendered;
223
- const id = entry._id;
224
- await writeFile(join(stagingDir, 'prompts', `${id}.j2`), r.prompt, 'utf-8');
225
- await writeFile(join(stagingDir, 'context', `${id}.md`), r.context, 'utf-8');
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
- // Write dataset.jsonl (all + per-lane-per-partition)
229
- const allLines = rows.map((r) => serializeRow(r)).join('\n') + (rows.length ? '\n' : '');
230
- await writeFile(join(stagingDir, 'dataset', 'all.jsonl'), allLines, 'utf-8');
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
- // Group by (lane × partition)
233
- const byLaneAndPartition = {};
234
- for (const row of rows) {
235
- const key = `${row.lane}-${row.partition}`;
236
- if (!byLaneAndPartition[key]) byLaneAndPartition[key] = [];
237
- byLaneAndPartition[key].push(row);
238
- }
239
- for (const [key, laneRows] of Object.entries(byLaneAndPartition)) {
240
- const content = laneRows.map((r) => serializeRow(r)).join('\n') + '\n';
241
- await writeFile(join(stagingDir, 'dataset', `${key}.jsonl`), content, 'utf-8');
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
- // Freeze drift pass — stamp frozen_entries_hashes (D1 witness chain).
245
- // For every entry with a non-auto freeze status, compute the watch-hash
246
- // of its currently-watched fields. Drift is computed by later builds or
247
- // the `sdlab canon drift` command by diffing their hash against this.
248
- const frozenEntriesHashes = {};
249
- const drift = [];
250
- const priorManifest = await loadPreviousManifest(projectRoot, outputDir);
251
- for (const { entry, schemaName, id } of entries) {
252
- const status = readFreezeStatus(entry.frontmatter);
253
- if (status === 'auto') continue;
254
- const watchFields = resolveWatchFields(entry.frontmatter, schemaName, config);
255
- const watchHash = computeWatchHash(
256
- entry.frontmatter,
257
- watchFields,
258
- schemas[schemaName].version,
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
- prior_hash: priorHash,
274
- current_hash: watchHash,
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
- // Write manifest
280
- const manifest = {
281
- schema_version: BUILD_SCHEMA_VERSION,
282
- row_schema_version: ROW_SCHEMA_VERSION,
283
- generated_from: generatedFrom,
284
- built_at: new Date().toISOString(),
285
- project_id: config.project_id,
286
- schema_versions: Object.fromEntries(
287
- Object.entries(schemas).map(([k, v]) => [k, v.version])
288
- ),
289
- build_config_hash: buildConfigHash,
290
- project_config_fingerprint: projectFingerprint,
291
- per_entity_hashes: Object.fromEntries(entries.map(({ entry, id }) => [id, entry._entryHash])),
292
- frozen_entries_hashes: frozenEntriesHashes,
293
- drift_against_prior: drift,
294
- stats: {
295
- entities_total: entries.length,
296
- entities_cached: cached,
297
- entities_rebuilt: rebuilt,
298
- dataset_rows: rows.length,
299
- frozen_entries: Object.keys(frozenEntriesHashes).length,
300
- drifted_entries: drift.length,
301
- schema_change_full_rebuild: schemaChangeTriggersFullRebuild,
302
- },
303
- };
304
- await writeFile(
305
- join(stagingDir, 'manifest.json'),
306
- JSON.stringify(manifest, null, 2) + '\n',
307
- 'utf-8'
308
- );
309
-
310
- // Atomic swap: remove old output, rename staging → output.
311
- await rm(outputDir, { recursive: true, force: true });
312
- await rename(stagingDir, outputDir);
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
- leakage_free: audit.leakage_check.passed,
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.split.leakage_free ? 'None (verified)' : 'DETECTED'}`);
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
- * Check if ComfyUI is reachable.
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
- * @returns {Promise<boolean>}
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 comfyHealth(comfyUrl = DEFAULT_URL) {
75
+ export async function comfyHealthDetailed(comfyUrl = DEFAULT_URL, opts = {}) {
25
76
  try {
26
- const res = await fetch(`${comfyUrl}/system_stats`);
27
- return res.ok;
28
- } catch {
29
- return false;
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 fetch(`${comfyUrl}/prompt`, {
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
- histRes = await fetch(`${comfyUrl}/history/${promptId}`);
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 fetch(url);
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 fetch(`${comfyUrl}/upload/image`, {
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();