@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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 (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  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/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Review gate before canon freeze & self-verification ban (DESIGN-v3 §11.5, §12.6, G11).
3
+ *
4
+ * Enforces:
5
+ * 1. Model-authored canon/prompt drafts are only ever proposed (`status: "draft"`).
6
+ * 2. Freezing or ratifying a draft requires an explicit recorded Director accept
7
+ * (`approver: "human:director"`, non-empty `reason`, and matching `draft_sha256`).
8
+ * 3. A model may never self-approve or be the deciding verifier of a canon
9
+ * entry or prompt it wrote (`SELF_VERIFICATION_FORBIDDEN`).
10
+ */
11
+
12
+ import { computeDraftContentSha256 } from './canon-author.js';
13
+ import { inputError as rawInputError } from './errors.js';
14
+
15
+ function inputError(code, msg) {
16
+ return rawInputError(code, `${code}: ${msg}`);
17
+ }
18
+
19
+ export function normalizeModelFamilyKey(modelId) {
20
+ if (!modelId || typeof modelId !== 'string') return '';
21
+ const lower = modelId
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/^model:/, '')
25
+ .replace(/@sha256:[0-9a-f]{64}$/, '');
26
+ if (!lower) return '';
27
+ if (lower.startsWith('human:') || lower === 'human') return 'human';
28
+ if (lower.startsWith('rule-based:') || lower === 'rule-based') return 'rule-based';
29
+ if (lower.includes('glimmer') || lower.includes('muse-glimmer') || lower.includes('muse_glimmer')) {
30
+ return 'family:muse-glimmer-30b';
31
+ }
32
+ if (lower.includes('qwen')) {
33
+ return 'family:qwen';
34
+ }
35
+ if (lower.includes('gemma')) {
36
+ return 'family:gemma';
37
+ }
38
+ if (lower.includes('internvl') || lower.includes('caprl-internvl')) {
39
+ return 'family:internvl';
40
+ }
41
+ if (lower.includes('siglip') || lower === 'ai-eyes') {
42
+ return 'family:siglip2';
43
+ }
44
+ if (lower.includes('flux') || lower === 'ae.safetensors') {
45
+ return 'family:flux';
46
+ }
47
+ if (lower.includes('sd3') || lower.includes('stable-diffusion-3')) {
48
+ return 'family:sd3';
49
+ }
50
+ if (
51
+ lower.includes('sdxl') ||
52
+ lower.includes('stable-diffusion-xl') ||
53
+ lower.includes('dreamshaper') ||
54
+ lower.includes('juggernaut') ||
55
+ lower.includes('pony') ||
56
+ lower.includes('illustrious') ||
57
+ lower.includes('animagine')
58
+ ) {
59
+ return 'family:sdxl';
60
+ }
61
+ return lower;
62
+ }
63
+
64
+ /**
65
+ * Enforce the §12.6 self-verification ban: a model (such as Muse-Glimmer-30B)
66
+ * may never be the deciding reader/verifier of a canon entry or prompt it wrote.
67
+ *
68
+ * @param {Object} opts
69
+ * @param {string|Object} opts.authorModel
70
+ * @param {string|Object} opts.verifierModel
71
+ * @param {string} [opts.context='artifact']
72
+ * @returns {{ ok: true, authorFamily: string, verifierFamily: string }}
73
+ */
74
+ export function assertNoSelfVerification({ authorModel, verifierModel, context = 'artifact' } = {}) {
75
+ const authorRaw = typeof authorModel === 'object' ? authorModel?.name : authorModel;
76
+ const verifierRaw = typeof verifierModel === 'object' ? verifierModel?.name : verifierModel;
77
+
78
+ const authorFamily = normalizeModelFamilyKey(authorRaw);
79
+ const verifierFamily = normalizeModelFamilyKey(verifierRaw);
80
+
81
+ if (
82
+ authorFamily &&
83
+ verifierFamily &&
84
+ authorFamily !== 'human' &&
85
+ verifierFamily !== 'human' &&
86
+ authorFamily === verifierFamily
87
+ ) {
88
+ throw inputError(
89
+ 'SELF_VERIFICATION_FORBIDDEN',
90
+ `Self-verification forbidden on ${context}: verifier "${verifierRaw}" belongs to the same model family ("${verifierFamily}") as author "${authorRaw}".`
91
+ );
92
+ }
93
+
94
+ return { ok: true, authorFamily, verifierFamily };
95
+ }
96
+
97
+ /**
98
+ * Record an explicit Director ratification on a model-authored canon/prompt draft (G11).
99
+ *
100
+ * @param {Object} opts
101
+ * @param {Object} opts.draft
102
+ * @param {string} opts.approver — must be "human:director" (or "human:<id>")
103
+ * @param {string} opts.reason
104
+ * @param {string} [opts.at]
105
+ * @returns {Object} ratified draft
106
+ */
107
+ export function ratifyCanonDraft({
108
+ draft,
109
+ approver,
110
+ reason,
111
+ at = new Date().toISOString(),
112
+ } = {}) {
113
+ if (!draft || typeof draft !== 'object') {
114
+ throw inputError('CANON_DRAFT_INVALID', 'ratifyCanonDraft requires a draft object.');
115
+ }
116
+ const approverStr = String(approver || '').trim();
117
+ if (approverStr.startsWith('model:') || !approverStr.startsWith('human:')) {
118
+ throw inputError(
119
+ 'CANON_SELF_APPROVAL_FORBIDDEN',
120
+ `Canon draft "${draft.class_id || draft.draft_id}" can only be ratified by a human Director ("human:director"), got "${approverStr}".`
121
+ );
122
+ }
123
+ const reasonStr = String(reason || '').trim();
124
+ if (!reasonStr) {
125
+ throw inputError(
126
+ 'CANON_RATIFICATION_REASON_REQUIRED',
127
+ `Ratifying canon draft "${draft.class_id || draft.draft_id}" requires a non-empty reason.`
128
+ );
129
+ }
130
+
131
+ const contentSha = computeDraftContentSha256(draft);
132
+ return {
133
+ ...draft,
134
+ status: 'ratified',
135
+ judgment: 'accepted',
136
+ draft_sha256: contentSha,
137
+ ratification: {
138
+ status: 'accepted',
139
+ approver: approverStr,
140
+ reason: reasonStr,
141
+ at,
142
+ draft_sha256: contentSha,
143
+ },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Assert that a canon entry or prompt draft has been explicitly ratified by
149
+ * the Director before it can be frozen (G11, §11.5).
150
+ *
151
+ * Refuses:
152
+ * - `status === "draft"` or missing `ratification` on a model-authored draft
153
+ * - `ratification.approver` not starting with `"human:"`
154
+ * - `ratification.draft_sha256` mismatch against the current draft content
155
+ *
156
+ * @param {Object} draftOrFrontmatter
157
+ * @returns {{ ok: true, ratification: Object|null }}
158
+ */
159
+ export function assertDirectorRatifiedForFreeze(draftOrFrontmatter) {
160
+ if (!draftOrFrontmatter || typeof draftOrFrontmatter !== 'object') {
161
+ throw inputError('CANON_DRAFT_INVALID', 'Cannot verify freeze gate on empty draft/frontmatter.');
162
+ }
163
+
164
+ const status = draftOrFrontmatter.status || draftOrFrontmatter.draft_status || null;
165
+ const isModelDraft =
166
+ status === 'draft' ||
167
+ status === 'ratified' ||
168
+ Boolean(draftOrFrontmatter.ratification) ||
169
+ Boolean(draftOrFrontmatter.author_model) ||
170
+ Boolean(draftOrFrontmatter.drafted_by_model);
171
+
172
+ if (!isModelDraft) {
173
+ return { ok: true, ratification: null };
174
+ }
175
+
176
+ const ratification = draftOrFrontmatter.ratification;
177
+ if (
178
+ status === 'draft' ||
179
+ !ratification ||
180
+ ratification.status !== 'accepted' ||
181
+ typeof ratification.approver !== 'string' ||
182
+ !ratification.approver.startsWith('human:') ||
183
+ typeof ratification.reason !== 'string' ||
184
+ !ratification.reason.trim()
185
+ ) {
186
+ throw inputError(
187
+ 'CANON_DRAFT_UNRATIFIED',
188
+ `Refusing to freeze model-authored draft "${draftOrFrontmatter.class_id || draftOrFrontmatter.id || 'unknown'}": ` +
189
+ 'status is still "draft" or lacks an explicit human Director ratification (`approver: "human:director"`, `reason`).'
190
+ );
191
+ }
192
+
193
+ if (draftOrFrontmatter.prompt !== undefined || draftOrFrontmatter.canon_entry_draft !== undefined) {
194
+ const actualSha = computeDraftContentSha256(draftOrFrontmatter);
195
+ if (ratification.draft_sha256 !== actualSha) {
196
+ throw inputError(
197
+ 'CANON_DRAFT_SHA_MISMATCH',
198
+ `Draft content for "${draftOrFrontmatter.class_id || draftOrFrontmatter.id}" changed after Director ratification ` +
199
+ `(got ${actualSha}, ratified ${ratification.draft_sha256}). Re-ratify before freezing.`
200
+ );
201
+ }
202
+ }
203
+
204
+ return { ok: true, ratification };
205
+ }
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 | % |');
@@ -0,0 +1,285 @@
1
+ /**
2
+ * C2 Colab launcher guards (DESIGN-v3 §9.2 item 6 — lessons v4k, v4p, v4q).
3
+ *
4
+ * Implements the four required launcher guards:
5
+ * 1. Independent wall watcher (`evaluateWallGuard`, v4k): honours `wallS` and
6
+ * `graceS` in ALL modes including `stub` mode.
7
+ * 2. Independent cap watcher (`evaluateCapGuard`, v4k): honours `cap` and
8
+ * `margin` in ALL modes including `stub` mode (`spend = max(drop, rate * elapsed_h)`).
9
+ * 3. `vitals.log` parser & `DEAD HARNESS` / `STALL` watcher (`parseVitalsLog`,
10
+ * `evaluateVitalsAndStallGuard`, v4p): trips `DEAD_HARNESS_TRIPPED` when 2
11
+ * consecutive `phase: run` samples have 0 active worker processes, and trips
12
+ * `STALL_GUARD_TRIPPED` when neither rows nor `vitals.log` advance in `stallS`.
13
+ * 4. Host-RAM / cgroup limit pre-load projection check & between-item fetch
14
+ * (`checkHostRamProjection`, `runBetweenItemFetch`, v4q): refuses any step
15
+ * whose projected anonymous host RAM (`projectedMb * 1.05 > limitMb`) would
16
+ * trigger a kernel SIGKILL (`rc=-9`), and copies completed artifacts listed
17
+ * in `current.json` before `current.json` is overwritten for the next item.
18
+ */
19
+
20
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { inputError as rawInputError } from './errors.js';
23
+
24
+ function inputError(code, msg) {
25
+ return rawInputError(code, `${code}: ${msg}`);
26
+ }
27
+
28
+ const WORKER_PROC_RE = /\b(comfy|main\.py|ai-toolkit|run\.py|llama-server|ollama|lane1_render\.py)\b/i;
29
+
30
+ /**
31
+ * 1. Independent wall-clock watcher (v4k).
32
+ * Never disabled in `stub` mode: trips when `elapsedS >= wallS - graceS`.
33
+ *
34
+ * @param {Object} opts
35
+ * @param {string} [opts.mode='live']
36
+ * @param {number} opts.elapsedS
37
+ * @param {number} [opts.wallS=3000]
38
+ * @param {number} [opts.graceS=60]
39
+ * @returns {{ mode: string, tripped: boolean, code: string|null, remainingS: number, thresholdS: number }}
40
+ */
41
+ export function evaluateWallGuard({
42
+ mode = 'live',
43
+ elapsedS,
44
+ wallS = 3000,
45
+ graceS = 60,
46
+ } = {}) {
47
+ const thresholdS = Math.max(0, Number(wallS) - Number(graceS));
48
+ const remainingS = thresholdS - Number(elapsedS);
49
+ const tripped = Number(elapsedS) >= thresholdS;
50
+ return {
51
+ mode,
52
+ tripped,
53
+ code: tripped ? 'WALL_GUARD_TRIPPED' : null,
54
+ remainingS,
55
+ thresholdS,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * 2. Independent CCU spend cap watcher (v4k).
61
+ * Never disabled in `stub` mode: computes `spend = max(startBalance - currentBalance, rateCcuPerHour * elapsedH)`
62
+ * and trips when `spend >= cap - margin`.
63
+ *
64
+ * @param {Object} opts
65
+ * @param {string} [opts.mode='live']
66
+ * @param {number} [opts.cap=0.05]
67
+ * @param {number} [opts.margin=0.01]
68
+ * @param {number|null} [opts.startBalance=null]
69
+ * @param {number|null} [opts.currentBalance=null]
70
+ * @param {number} [opts.rateCcuPerHour=0]
71
+ * @param {number} [opts.elapsedS=0]
72
+ * @returns {{ mode: string, tripped: boolean, code: string|null, spendCcu: number, ceilingCcu: number }}
73
+ */
74
+ export function evaluateCapGuard({
75
+ mode = 'live',
76
+ cap = 0.05,
77
+ margin = 0.01,
78
+ startBalance = null,
79
+ currentBalance = null,
80
+ rateCcuPerHour = 0,
81
+ elapsedS = 0,
82
+ } = {}) {
83
+ const balanceDrop =
84
+ startBalance !== null && currentBalance !== null
85
+ ? Math.max(0, Number(startBalance) - Number(currentBalance))
86
+ : 0;
87
+ const rateEst = Math.max(0, (Number(rateCcuPerHour) * Number(elapsedS)) / 3600);
88
+ const spendCcu = Math.max(balanceDrop, rateEst);
89
+ const ceilingCcu = Math.max(0, Number(cap) - Number(margin));
90
+ const tripped = spendCcu >= ceilingCcu;
91
+ return {
92
+ mode,
93
+ tripped,
94
+ code: tripped ? 'CAP_GUARD_TRIPPED' : null,
95
+ spendCcu: Number(spendCcu.toFixed(6)),
96
+ ceilingCcu: Number(ceilingCcu.toFixed(6)),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Parse `vitals.log` blocks (`=== VITALS <iso> ===`) emitted by the VM vitals sampler (v4p).
102
+ *
103
+ * @param {string} vitalsText
104
+ * @returns {Array<{ timestamp: string, phase: string|null, workerProcCount: number, rawBlock: string }>}
105
+ */
106
+ export function parseVitalsLog(vitalsText = '') {
107
+ const blocks = String(vitalsText)
108
+ .split(/^===\s*VITALS\s+/m)
109
+ .map((b) => b.trim())
110
+ .filter(Boolean);
111
+
112
+ const samples = [];
113
+ for (const block of blocks) {
114
+ const lines = block.split(/\r?\n/);
115
+ const header = lines[0] || '';
116
+ const tsMatch = /^([^\s=]+)/.exec(header);
117
+ const timestamp = tsMatch ? tsMatch[1] : '';
118
+
119
+ let phase = null;
120
+ let workerProcCount = 0;
121
+ let inProc = false;
122
+
123
+ for (let i = 1; i < lines.length; i++) {
124
+ const line = lines[i];
125
+ if (line.startsWith('PROC:')) {
126
+ inProc = true;
127
+ continue;
128
+ }
129
+ if (line.startsWith('HARNESS:')) {
130
+ inProc = false;
131
+ const payload = line.slice('HARNESS:'.length).trim();
132
+ try {
133
+ const parsed = JSON.parse(payload);
134
+ phase = parsed.phase || null;
135
+ } catch {
136
+ const m = /"phase"\s*:\s*"([^"]+)"/.exec(payload) || /phase:\s*([a-z0-9_-]+)/i.exec(payload);
137
+ phase = m ? m[1] : null;
138
+ }
139
+ continue;
140
+ }
141
+ if (inProc && WORKER_PROC_RE.test(line)) {
142
+ workerProcCount++;
143
+ }
144
+ }
145
+
146
+ samples.push({
147
+ timestamp,
148
+ phase,
149
+ workerProcCount,
150
+ rawBlock: block,
151
+ });
152
+ }
153
+ return samples;
154
+ }
155
+
156
+ /**
157
+ * 3. Evaluate the `DEAD HARNESS` and `STALL` watcher over `vitals.log` + idle timer (v4p).
158
+ *
159
+ * Trips `DEAD_HARNESS_TRIPPED` when the last 2 vitals samples both show
160
+ * `phase === 'run'` (or `phase.startsWith('run')`) and `workerProcCount === 0`.
161
+ * Otherwise trips `STALL_GUARD_TRIPPED` when `idleSeconds >= stallS`.
162
+ *
163
+ * @param {Object} opts
164
+ * @param {string} opts.vitalsText
165
+ * @param {number} [opts.idleSeconds=0]
166
+ * @param {number} [opts.stallS=300]
167
+ * @returns {{ tripped: boolean, code: string|null, reason: string|null, samplesCount: number }}
168
+ */
169
+ export function evaluateVitalsAndStallGuard({
170
+ vitalsText = '',
171
+ idleSeconds = 0,
172
+ stallS = 300,
173
+ } = {}) {
174
+ const samples = parseVitalsLog(vitalsText);
175
+ if (samples.length >= 2) {
176
+ const lastTwo = samples.slice(-2);
177
+ const bothDeadInRun = lastTwo.every(
178
+ (s) => typeof s.phase === 'string' && s.phase.startsWith('run') && s.workerProcCount === 0
179
+ );
180
+ if (bothDeadInRun) {
181
+ return {
182
+ tripped: true,
183
+ code: 'DEAD_HARNESS_TRIPPED',
184
+ reason: '2 consecutive vitals samples in phase=run with 0 active worker processes',
185
+ samplesCount: samples.length,
186
+ };
187
+ }
188
+ }
189
+
190
+ if (Number(idleSeconds) >= Number(stallS)) {
191
+ return {
192
+ tripped: true,
193
+ code: 'STALL_GUARD_TRIPPED',
194
+ reason: `No output row or vitals progress for ${idleSeconds}s (stallS=${stallS}s)`,
195
+ samplesCount: samples.length,
196
+ };
197
+ }
198
+
199
+ return {
200
+ tripped: false,
201
+ code: null,
202
+ reason: null,
203
+ samplesCount: samples.length,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * 4a. Host-RAM / cgroup limit pre-load projection check (v4q).
209
+ * Refuses any step whose projected anonymous host RAM (`(currentAnonMb + projectedDeltaMb) * safetyFactor`)
210
+ * exceeds `min(memTotalMb, cgroupMaxMb)`.
211
+ *
212
+ * @param {Object} opts
213
+ * @param {number} opts.currentAnonMb
214
+ * @param {number} opts.projectedDeltaMb
215
+ * @param {number} opts.memTotalMb
216
+ * @param {number|null} [opts.cgroupMaxMb=null]
217
+ * @param {number} [opts.safetyFactor=1.05]
218
+ * @returns {{ ok: true, projectedMb: number, limitMb: number }}
219
+ */
220
+ export function checkHostRamProjection({
221
+ currentAnonMb,
222
+ projectedDeltaMb,
223
+ memTotalMb,
224
+ cgroupMaxMb = null,
225
+ safetyFactor = 1.05,
226
+ }) {
227
+ const limits = [Number(memTotalMb)];
228
+ if (cgroupMaxMb !== null && cgroupMaxMb !== undefined && Number.isFinite(Number(cgroupMaxMb))) {
229
+ limits.push(Number(cgroupMaxMb));
230
+ }
231
+ const limitMb = Math.min(...limits);
232
+ const rawProjected = Number(currentAnonMb) + Number(projectedDeltaMb);
233
+ const projectedWithMargin = rawProjected * Number(safetyFactor);
234
+
235
+ if (projectedWithMargin > limitMb) {
236
+ throw inputError(
237
+ 'HOST_RAM_PROJECTION_EXCEEDED',
238
+ `Projected anonymous host RAM ${projectedWithMargin.toFixed(1)} MB (${rawProjected} MB * ${safetyFactor}) exceeds host/cgroup ceiling ${limitMb} MB (v4q guard against kernel SIGKILL rc=-9).`
239
+ );
240
+ }
241
+
242
+ return {
243
+ ok: true,
244
+ projectedMb: Number(projectedWithMargin.toFixed(1)),
245
+ limitMb,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * 4b. Between-item fetch (v4q): copies every completed file listed in `current.json`
251
+ * from `remoteDir` to `localDir` BEFORE `current.json` is overwritten for `nextItem`.
252
+ *
253
+ * @param {Object} opts
254
+ * @param {string} opts.remoteDir
255
+ * @param {string} opts.localDir
256
+ * @param {Object|null} [opts.nextItem=null]
257
+ * @returns {{ copiedFiles: string[], previousItem: Object|null }}
258
+ */
259
+ export function runBetweenItemFetch({ remoteDir, localDir, nextItem = null }) {
260
+ mkdirSync(localDir, { recursive: true });
261
+ const currentJsonPath = join(remoteDir, 'current.json');
262
+ const copiedFiles = [];
263
+ let previousItem = null;
264
+
265
+ if (existsSync(currentJsonPath)) {
266
+ previousItem = JSON.parse(readFileSync(currentJsonPath, 'utf-8'));
267
+ const files = Array.isArray(previousItem?.files) ? previousItem.files : [];
268
+ for (const relFile of files) {
269
+ const src = join(remoteDir, relFile);
270
+ if (existsSync(src)) {
271
+ const dst = join(localDir, relFile);
272
+ mkdirSync(join(dst, '..'), { recursive: true });
273
+ copyFileSync(src, dst);
274
+ copiedFiles.push(relFile);
275
+ }
276
+ }
277
+ copyFileSync(currentJsonPath, join(localDir, 'current.json'));
278
+ }
279
+
280
+ if (nextItem) {
281
+ writeFileSync(currentJsonPath, JSON.stringify(nextItem, null, 2) + '\n', 'utf-8');
282
+ }
283
+
284
+ return { copiedFiles, previousItem };
285
+ }