@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.
- package/CHANGELOG.md +60 -0
- package/README.es.md +117 -55
- package/README.fr.md +125 -65
- package/README.hi.md +117 -68
- package/README.it.md +119 -58
- package/README.ja.md +124 -74
- package/README.md +73 -7
- package/README.pt-BR.md +128 -63
- package/README.zh.md +116 -65
- package/bin/sdlab.js +100 -4
- package/lib/adapters/ai-toolkit.js +224 -35
- package/lib/adapters/comfyui-runner.js +79 -26
- package/lib/adapters/comfyui-workflows.js +94 -11
- 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/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- 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-author.js +350 -0
- package/lib/canon-build/build.js +146 -93
- package/lib/canon-review-gate.js +205 -0
- package/lib/captions.js +57 -0
- package/lib/card.js +74 -2
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/comfyui.js +116 -18
- package/lib/config.js +116 -12
- package/lib/critique-engine.js +36 -2
- 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 +16 -3
- package/lib/freeze-stamp.js +28 -3
- package/lib/generated-provenance.js +194 -6
- package/lib/implementation-packs.js +95 -17
- package/lib/ingest.js +396 -0
- package/lib/judgment-provenance.js +220 -0
- package/lib/lane1-render.js +668 -0
- package/lib/measure.js +422 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/paths.js +196 -7
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +113 -17
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +159 -25
- package/lib/run-summary.js +12 -6
- package/lib/runtime-runs.js +45 -4
- package/lib/snapshot.js +114 -15
- package/lib/split.js +364 -48
- package/lib/train-runs.js +214 -0
- package/lib/training-manifests.js +114 -21
- package/lib/training-packages.js +147 -15
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/lib/workflow-profiles.js +24 -3
- package/package.json +4 -3
- 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/asset-ingest.js +160 -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-draft.js +82 -0
- package/scripts/canon-drift.js +16 -7
- package/scripts/canon-freeze-status.js +14 -6
- package/scripts/canon-freeze.js +31 -11
- package/scripts/canon-unfreeze.js +17 -9
- package/scripts/card.js +2 -2
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/compare.js +2 -2
- package/scripts/critique.js +16 -1
- package/scripts/curate.js +2 -2
- package/scripts/doctor.js +182 -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 +86 -17
- 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 +138 -20
- package/scripts/reingest.js +2 -2
- 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/verify.js +67 -0
- package/scripts/workflow-list.js +2 -1
- 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
|
-
|
|
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 | % |');
|
|
@@ -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
|
+
}
|