@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,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trained-LoRA provenance record (`train-run.json`) contract (DESIGN-v3 §7, G13).
|
|
3
|
+
*
|
|
4
|
+
* Links every trained LoRA checkpoint back to:
|
|
5
|
+
* - dataset_package_id + dataset_manifest_sha256
|
|
6
|
+
* - config_sha256 (ai-toolkit-config.yaml)
|
|
7
|
+
* - override_file + override_sha256 + upstream_issue (#1087)
|
|
8
|
+
* - base_model pin with cleared Apache-2.0 SPDX licence
|
|
9
|
+
* - window_id
|
|
10
|
+
* - steps, checkpoint_sha256s, sample_grid_sha256s
|
|
11
|
+
* - eval_pack_sha (attached once the eval pack runs)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
AI_TOOLKIT_UPSTREAM_ISSUE,
|
|
18
|
+
assertNotHeldTrainingData,
|
|
19
|
+
getAiToolkitOverrideMetadata,
|
|
20
|
+
} from './adapters/ai-toolkit.js';
|
|
21
|
+
import { inputError as rawInputError } from './errors.js';
|
|
22
|
+
|
|
23
|
+
function inputError(code, msg) {
|
|
24
|
+
return rawInputError(code, `${code}: ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const CLEARED_TRAIN_BASE_LICENCES = new Set([
|
|
28
|
+
'Apache-2.0',
|
|
29
|
+
'MIT',
|
|
30
|
+
'BSD-2-Clause',
|
|
31
|
+
'BSD-3-Clause',
|
|
32
|
+
'CC0-1.0',
|
|
33
|
+
'CC-BY-4.0',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function normalizeSha256(val) {
|
|
37
|
+
if (typeof val !== 'string') return null;
|
|
38
|
+
const trimmed = val.trim().toLowerCase();
|
|
39
|
+
const hex = trimmed.startsWith('sha256:') ? trimmed.slice(7) : trimmed;
|
|
40
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) return null;
|
|
41
|
+
return `sha256:${hex}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build and validate a `train-run.json` record (§7, G13).
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} opts
|
|
48
|
+
* @returns {Object}
|
|
49
|
+
*/
|
|
50
|
+
export function buildTrainRunRecord(opts = {}) {
|
|
51
|
+
const {
|
|
52
|
+
run_id,
|
|
53
|
+
window_id,
|
|
54
|
+
dataset_package_id,
|
|
55
|
+
dataset_manifest_sha256,
|
|
56
|
+
config_sha256,
|
|
57
|
+
override_file,
|
|
58
|
+
override_sha256,
|
|
59
|
+
upstream_issue = AI_TOOLKIT_UPSTREAM_ISSUE,
|
|
60
|
+
base_model,
|
|
61
|
+
steps = [],
|
|
62
|
+
checkpoint_sha256s = {},
|
|
63
|
+
sample_grid_sha256s = {},
|
|
64
|
+
eval_pack_sha = null,
|
|
65
|
+
created_at = new Date().toISOString(),
|
|
66
|
+
} = opts;
|
|
67
|
+
|
|
68
|
+
if (!run_id || typeof run_id !== 'string') {
|
|
69
|
+
throw inputError('TRAIN_RUN_INVALID', 'train-run.json requires a non-empty "run_id".');
|
|
70
|
+
}
|
|
71
|
+
if (!window_id || typeof window_id !== 'string') {
|
|
72
|
+
throw inputError('TRAIN_RUN_INVALID', 'train-run.json requires a non-empty "window_id".');
|
|
73
|
+
}
|
|
74
|
+
if (!dataset_package_id || typeof dataset_package_id !== 'string') {
|
|
75
|
+
throw inputError('TRAIN_RUN_INVALID', 'train-run.json requires a non-empty "dataset_package_id".');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assertNotHeldTrainingData({
|
|
79
|
+
profile: { profile_id: dataset_package_id },
|
|
80
|
+
manifest: { source_export_id: run_id },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const normDatasetSha = normalizeSha256(dataset_manifest_sha256);
|
|
84
|
+
const normConfigSha = normalizeSha256(config_sha256);
|
|
85
|
+
const defaultOverride = getAiToolkitOverrideMetadata();
|
|
86
|
+
const normOverrideSha = normalizeSha256(override_sha256 || defaultOverride.override_sha256);
|
|
87
|
+
|
|
88
|
+
if (!normDatasetSha || !normConfigSha || !normOverrideSha) {
|
|
89
|
+
throw inputError(
|
|
90
|
+
'TRAIN_RUN_SHA_INVALID',
|
|
91
|
+
'train-run.json requires valid 64-hex dataset_manifest_sha256, config_sha256, and override_sha256.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!base_model || typeof base_model !== 'object' || !base_model.name) {
|
|
96
|
+
throw inputError('TRAIN_RUN_BASE_MODEL_INVALID', 'train-run.json requires a base_model object with name.');
|
|
97
|
+
}
|
|
98
|
+
const normBaseSha = normalizeSha256(base_model.sha256);
|
|
99
|
+
if (!normBaseSha) {
|
|
100
|
+
throw inputError('TRAIN_RUN_SHA_INVALID', 'base_model.sha256 must be a valid 64-hex sha256 digest.');
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
!base_model.licence_id ||
|
|
104
|
+
!CLEARED_TRAIN_BASE_LICENCES.has(base_model.licence_id) ||
|
|
105
|
+
!base_model.licence_url ||
|
|
106
|
+
!base_model.date_read
|
|
107
|
+
) {
|
|
108
|
+
throw inputError(
|
|
109
|
+
'TRAIN_RUN_LICENCE_INVALID',
|
|
110
|
+
`base_model "${base_model.name}" must carry a cleared commercial SPDX licence_id (${[...CLEARED_TRAIN_BASE_LICENCES].join(', ')}), licence_url, and date_read (got "${base_model.licence_id}").`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
115
|
+
throw inputError('TRAIN_RUN_STEPS_EMPTY', 'train-run.json requires at least one checkpoint step.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const normalizedCheckpoints = {};
|
|
119
|
+
const normalizedSamples = {};
|
|
120
|
+
for (const step of steps) {
|
|
121
|
+
const key = String(step);
|
|
122
|
+
const ckptSha = normalizeSha256(checkpoint_sha256s[key] ?? checkpoint_sha256s[step]);
|
|
123
|
+
if (!ckptSha) {
|
|
124
|
+
throw inputError(
|
|
125
|
+
'TRAIN_RUN_SHA_INVALID',
|
|
126
|
+
`Missing or invalid checkpoint_sha256 for step ${key}.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
normalizedCheckpoints[key] = ckptSha;
|
|
130
|
+
|
|
131
|
+
const gridRaw = sample_grid_sha256s[key] ?? sample_grid_sha256s[step];
|
|
132
|
+
if (gridRaw !== undefined && gridRaw !== null) {
|
|
133
|
+
const gridSha = normalizeSha256(gridRaw);
|
|
134
|
+
if (!gridSha) {
|
|
135
|
+
throw inputError(
|
|
136
|
+
'TRAIN_RUN_SHA_INVALID',
|
|
137
|
+
`Invalid sample_grid_sha256 for step ${key}.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
normalizedSamples[key] = gridSha;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const normEvalSha = eval_pack_sha ? normalizeSha256(eval_pack_sha) : null;
|
|
145
|
+
if (eval_pack_sha && !normEvalSha) {
|
|
146
|
+
throw inputError('TRAIN_RUN_SHA_INVALID', 'eval_pack_sha must be null or a valid 64-hex sha256 digest.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
run_id,
|
|
151
|
+
window_id,
|
|
152
|
+
lane: 'train',
|
|
153
|
+
dataset_package_id,
|
|
154
|
+
dataset_manifest_sha256: normDatasetSha,
|
|
155
|
+
config_sha256: normConfigSha,
|
|
156
|
+
override_file: override_file || defaultOverride.override_file,
|
|
157
|
+
override_sha256: normOverrideSha,
|
|
158
|
+
upstream_issue,
|
|
159
|
+
base_model: {
|
|
160
|
+
name: base_model.name,
|
|
161
|
+
repo: base_model.repo || base_model.name,
|
|
162
|
+
revision: base_model.revision || 'main',
|
|
163
|
+
sha256: normBaseSha,
|
|
164
|
+
licence_id: base_model.licence_id,
|
|
165
|
+
licence_url: base_model.licence_url,
|
|
166
|
+
date_read: base_model.date_read,
|
|
167
|
+
},
|
|
168
|
+
steps: steps.map(Number),
|
|
169
|
+
checkpoint_sha256s: normalizedCheckpoints,
|
|
170
|
+
sample_grid_sha256s: normalizedSamples,
|
|
171
|
+
eval_pack_sha: normEvalSha,
|
|
172
|
+
created_at,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Write `train-run.json` to disk.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} trainRunPath
|
|
180
|
+
* @param {Object} record
|
|
181
|
+
* @returns {Object}
|
|
182
|
+
*/
|
|
183
|
+
export function writeTrainRunJson(trainRunPath, record) {
|
|
184
|
+
const validated = buildTrainRunRecord(record);
|
|
185
|
+
mkdirSync(dirname(trainRunPath), { recursive: true });
|
|
186
|
+
writeFileSync(trainRunPath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
|
|
187
|
+
return validated;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read and validate `train-run.json` from disk.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} trainRunPath
|
|
194
|
+
* @returns {Object}
|
|
195
|
+
*/
|
|
196
|
+
export function readTrainRunJson(trainRunPath) {
|
|
197
|
+
const parsed = JSON.parse(readFileSync(trainRunPath, 'utf-8'));
|
|
198
|
+
return buildTrainRunRecord(parsed);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Attach `eval_pack_sha` to an existing `train-run.json` once the eval pack runs (G13).
|
|
203
|
+
*
|
|
204
|
+
* @param {string} trainRunPath
|
|
205
|
+
* @param {string} evalPackSha
|
|
206
|
+
* @returns {Object}
|
|
207
|
+
*/
|
|
208
|
+
export function attachEvalPackSha(trainRunPath, evalPackSha) {
|
|
209
|
+
const current = readTrainRunJson(trainRunPath);
|
|
210
|
+
return writeTrainRunJson(trainRunPath, {
|
|
211
|
+
...current,
|
|
212
|
+
eval_pack_sha: evalPackSha,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
* so the training run is fully reproducible.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFile, writeFile,
|
|
9
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
13
|
import { loadExport } from './export.js';
|
|
14
14
|
import { loadTrainingProfile } from './training-profiles.js';
|
|
15
|
-
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
15
|
+
import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdFile } from './snapshot.js';
|
|
16
16
|
import { listEvalPacks } from './eval-pack.js';
|
|
17
|
-
import { inputError } from './errors.js';
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
19
|
* Generate a training manifest ID: tm-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -34,6 +33,24 @@ async function hashFile(filePath) {
|
|
|
34
33
|
return createHash('sha256').update(data).digest('hex');
|
|
35
34
|
}
|
|
36
35
|
|
|
36
|
+
/**
|
|
37
|
+
* SDL-H9: parse a BSD-style checksums.txt (`SHA256 (path) = hash` per
|
|
38
|
+
* line, as written by export.js and training-packages.js) into
|
|
39
|
+
* {relPath, hash} entries. Tolerates stray/blank lines rather than
|
|
40
|
+
* throwing, since this file is meant to be human-readable too.
|
|
41
|
+
*/
|
|
42
|
+
function parseChecksumsFile(text) {
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const line of text.split('\n')) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (!trimmed) continue;
|
|
47
|
+
const match = /^SHA256 \((.+)\) = ([0-9a-fA-F]{64})$/.exec(trimmed);
|
|
48
|
+
if (!match) continue;
|
|
49
|
+
entries.push({ relPath: match[1], hash: match[2].toLowerCase() });
|
|
50
|
+
}
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
/**
|
|
38
55
|
* Create a training manifest from an export package and training profile.
|
|
39
56
|
*
|
|
@@ -51,6 +68,17 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
51
68
|
const exportManifestPath = join(projectRoot, 'exports', exportId, 'manifest.json');
|
|
52
69
|
const exportManifestHash = await hashFile(exportManifestPath);
|
|
53
70
|
|
|
71
|
+
// SDL-H9: export_manifest_hash above covers ONLY manifest.json — a file
|
|
72
|
+
// that does not include image bytes. checksums.txt (written by
|
|
73
|
+
// export.js after the manifest, D-015) covers every file in the export,
|
|
74
|
+
// including images, so bind to it too when present. `include_checksums`
|
|
75
|
+
// is a profile-controlled export option — an export built without it
|
|
76
|
+
// simply has nothing to bind here, which is not itself an error.
|
|
77
|
+
const exportChecksumsPath = join(projectRoot, 'exports', exportId, 'checksums.txt');
|
|
78
|
+
const exportChecksumsHash = existsSync(exportChecksumsPath)
|
|
79
|
+
? await hashFile(exportChecksumsPath)
|
|
80
|
+
: null;
|
|
81
|
+
|
|
54
82
|
// Determine adapter target
|
|
55
83
|
const adapterTarget = options.adapterTarget || profile.adapter_targets[0];
|
|
56
84
|
if (!profile.adapter_targets.includes(adapterTarget)) {
|
|
@@ -63,11 +91,14 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
63
91
|
const evalPacks = await listEvalPacks(projectRoot);
|
|
64
92
|
const linkedEvalPackIds = evalPacks.map(p => p.id);
|
|
65
93
|
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
// Deferred so the id can be plugged in per-attempt by claimIdFile below
|
|
95
|
+
// (a training manifest is a flat file, not a directory — see claimIdFile
|
|
96
|
+
// in snapshot.js for why the atomic primitive differs from the other
|
|
97
|
+
// artifacts in this domain).
|
|
98
|
+
const buildContent = (id) => JSON.stringify({
|
|
68
99
|
// DB-001 / DB-006: stamp manifest schema version
|
|
69
100
|
schema_version: SCHEMA_VERSION,
|
|
70
|
-
training_manifest_id:
|
|
101
|
+
training_manifest_id: id,
|
|
71
102
|
created_at: new Date().toISOString(),
|
|
72
103
|
created_by: 'sdlab-training-manifest-v1',
|
|
73
104
|
project_id: exportManifest.project,
|
|
@@ -77,6 +108,9 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
77
108
|
source_split_id: exportManifest.split_id,
|
|
78
109
|
config_fingerprint: computeConfigFingerprint(projectRoot),
|
|
79
110
|
export_manifest_hash: exportManifestHash,
|
|
111
|
+
// SDL-H9: the binding hash that actually covers image bytes. null
|
|
112
|
+
// when the source export was built with include_checksums: false.
|
|
113
|
+
export_checksums_hash: exportChecksumsHash,
|
|
80
114
|
dataset_counts: exportManifest.counts,
|
|
81
115
|
base_model: options.baseModel || profile.base_model_recommendations?.[0] || null,
|
|
82
116
|
adapter_target: adapterTarget,
|
|
@@ -87,21 +121,21 @@ export async function createTrainingManifest(projectRoot, exportId, profileId, o
|
|
|
87
121
|
target_family: profile.target_family,
|
|
88
122
|
},
|
|
89
123
|
linked_eval_pack_ids: linkedEvalPackIds,
|
|
90
|
-
};
|
|
124
|
+
}, null, 2) + '\n';
|
|
91
125
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
'
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
// SDL-M10: atomically claim the manifest file — closes the
|
|
127
|
+
// check-then-act race the old existsSync+writeFile pattern had (D-005's
|
|
128
|
+
// intent, now actually race-proof).
|
|
129
|
+
const { id: manifestId } = await claimIdFile(
|
|
130
|
+
join(projectRoot, 'training', 'manifests'),
|
|
131
|
+
generateManifestId,
|
|
132
|
+
buildContent,
|
|
133
|
+
{
|
|
134
|
+
code: 'TRAINING_MANIFEST_ID_COLLISION',
|
|
135
|
+
message: 'Could not claim a unique training-manifest ID — every candidate collided with an existing manifest file.',
|
|
136
|
+
hint: 'Retry; if this persists, another sdlab process may be creating training manifests in a tight loop.',
|
|
137
|
+
},
|
|
138
|
+
);
|
|
105
139
|
|
|
106
140
|
return { manifestId, exportId, profileId };
|
|
107
141
|
}
|
|
@@ -121,13 +155,72 @@ export async function validateTrainingManifest(projectRoot, manifestId) {
|
|
|
121
155
|
if (!existsSync(exportPath)) {
|
|
122
156
|
issues.push(`source export "${manifest.source_export_id}" not found`);
|
|
123
157
|
} else {
|
|
124
|
-
// Check export hash
|
|
158
|
+
// Check export hash — NOTE: manifest.json alone does not include image
|
|
159
|
+
// bytes (SDL-H9); see the checksums.txt re-verification below for the
|
|
160
|
+
// check that actually catches a replaced image.
|
|
125
161
|
const currentHash = await hashFile(exportPath);
|
|
126
162
|
if (currentHash !== manifest.export_manifest_hash) {
|
|
127
163
|
issues.push(`export manifest hash mismatch — export may have been modified`);
|
|
128
164
|
}
|
|
129
165
|
}
|
|
130
166
|
|
|
167
|
+
// SDL-H9: export_manifest_hash (above) never covered image bytes —
|
|
168
|
+
// checksums.txt does. Re-verify it two ways:
|
|
169
|
+
// 1. binding check — checksums.txt's own hash still matches what was
|
|
170
|
+
// captured at training-manifest creation time (only meaningful for
|
|
171
|
+
// manifests created after SDL-H9, when export_checksums_hash was
|
|
172
|
+
// first captured).
|
|
173
|
+
// 2. full re-verification — every individual file checksums.txt
|
|
174
|
+
// records is re-hashed against its CURRENT on-disk content. This is
|
|
175
|
+
// the check that actually catches "an image was silently replaced
|
|
176
|
+
// after export": checksums.txt's own bytes don't change just
|
|
177
|
+
// because a file it lists was overwritten, so step 1 alone cannot
|
|
178
|
+
// see it. Runs whenever checksums.txt exists, independent of
|
|
179
|
+
// whether this manifest predates export_checksums_hash, so it also
|
|
180
|
+
// retroactively protects manifests created before this fix.
|
|
181
|
+
const exportChecksumsPath = join(projectRoot, 'exports', manifest.source_export_id, 'checksums.txt');
|
|
182
|
+
if (manifest.export_checksums_hash && !existsSync(exportChecksumsPath)) {
|
|
183
|
+
issues.push(`export checksums.txt not found at ${exportChecksumsPath} — export may have been modified (checksums.txt removed)`);
|
|
184
|
+
} else if (existsSync(exportChecksumsPath)) {
|
|
185
|
+
if (manifest.export_checksums_hash) {
|
|
186
|
+
const currentChecksumsHash = await hashFile(exportChecksumsPath);
|
|
187
|
+
if (currentChecksumsHash !== manifest.export_checksums_hash) {
|
|
188
|
+
issues.push(`export checksums.txt hash mismatch — the checksum manifest itself has changed since this training manifest was created`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const exportDir = join(projectRoot, 'exports', manifest.source_export_id);
|
|
193
|
+
const checksumsText = await readFile(exportChecksumsPath, 'utf-8');
|
|
194
|
+
const entries = parseChecksumsFile(checksumsText);
|
|
195
|
+
const missing = [];
|
|
196
|
+
const mismatched = [];
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const filePath = join(exportDir, entry.relPath);
|
|
199
|
+
if (!existsSync(filePath)) {
|
|
200
|
+
missing.push(entry.relPath);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const currentFileHash = await hashFile(filePath);
|
|
204
|
+
if (currentFileHash !== entry.hash) {
|
|
205
|
+
mismatched.push(entry.relPath);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (missing.length > 0) {
|
|
209
|
+
const sample = missing.slice(0, 5).join(', ');
|
|
210
|
+
issues.push(
|
|
211
|
+
`export file(s) referenced by checksums.txt are missing: ${sample}` +
|
|
212
|
+
(missing.length > 5 ? `, ... (${missing.length} total)` : ''),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (mismatched.length > 0) {
|
|
216
|
+
const sample = mismatched.slice(0, 5).join(', ');
|
|
217
|
+
issues.push(
|
|
218
|
+
`export file(s) modified since export — content hash no longer matches checksums.txt: ${sample}` +
|
|
219
|
+
(mismatched.length > 5 ? `, ... (${mismatched.length} total)` : ''),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
131
224
|
// Check config fingerprint
|
|
132
225
|
const currentFingerprint = computeConfigFingerprint(projectRoot);
|
|
133
226
|
if (currentFingerprint !== manifest.config_fingerprint) {
|
package/lib/training-packages.js
CHANGED
|
@@ -6,19 +6,21 @@
|
|
|
6
6
|
* but never mutate inclusion or split truth.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFile, writeFile,
|
|
9
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises';
|
|
10
10
|
import { join, relative } from 'node:path';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
|
+
import { platform } from 'node:os';
|
|
12
13
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
14
|
import { loadTrainingManifest } from './training-manifests.js';
|
|
14
15
|
import { loadTrainingProfile } from './training-profiles.js';
|
|
15
16
|
import { loadSplitPartition } from './split.js';
|
|
16
17
|
import { loadRecord } from './records.js';
|
|
17
18
|
import { loadProjectConfig, detectLane, detectGroup } from './config.js';
|
|
18
|
-
import { inputError } from './errors.js';
|
|
19
|
-
import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
|
|
19
|
+
import { inputError, SdlabError } from './errors.js';
|
|
20
|
+
import { SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
|
|
20
21
|
import { loadAdapter } from './training-adapters.js';
|
|
21
22
|
import { recordToRow, filterRowsForProfile } from './rows.js';
|
|
23
|
+
import { assertSafeAssetPath, assertSafeId } from './export.js';
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Generate a training package ID: tp-YYYYMMDD-HHMMSS-XXXX
|
|
@@ -51,16 +53,6 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
51
53
|
const adapter = await loadAdapter(adapterTarget);
|
|
52
54
|
|
|
53
55
|
const recordsDir = join(projectRoot, 'records');
|
|
54
|
-
const packageId = generatePackageId();
|
|
55
|
-
const packageDir = join(projectRoot, 'training', 'packages', packageId);
|
|
56
|
-
// D-005: refuse to silently overwrite on ID collision.
|
|
57
|
-
if (existsSync(packageDir)) {
|
|
58
|
-
throw inputError(
|
|
59
|
-
'TRAINING_PACKAGE_ID_COLLISION',
|
|
60
|
-
`Training package directory already exists: ${packageDir}`,
|
|
61
|
-
'Retry to generate a fresh package ID.',
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
56
|
|
|
65
57
|
// Convert split partitions → canonical rows (D6 adapter input shape).
|
|
66
58
|
// Adapters only understand rows — records-flow callers go through this
|
|
@@ -68,23 +60,90 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
68
60
|
// contract, two producers.
|
|
69
61
|
const rows = [];
|
|
70
62
|
const partitionCounts = { train: 0, val: 0, test: 0 };
|
|
63
|
+
// F3: same contract as export.js's identical guard — a record the split
|
|
64
|
+
// promised (via source_split_id's train/val/test.jsonl) whose file was
|
|
65
|
+
// later deleted from records/ must never silently shrink the training
|
|
66
|
+
// package below what the split counted. loadRecord() returns null on
|
|
67
|
+
// ENOENT; previously that was a silent `continue` with no counter, no
|
|
68
|
+
// warning, no id recorded. Accumulated across all three partitions so
|
|
69
|
+
// one thrown error names every missing id instead of discovering them
|
|
70
|
+
// one rebuild at a time.
|
|
71
|
+
const missingRecords = [];
|
|
72
|
+
// H3: recordToRow() can throw CAPTION_SUBJECT_MISSING (lib/captions.js's
|
|
73
|
+
// buildSubjectNaturalLanguageCaption) per-record. That throw is correct —
|
|
74
|
+
// the granularity of handling it here was not: a bare loop with no
|
|
75
|
+
// try/catch meant the FIRST offending record aborted the whole build,
|
|
76
|
+
// before claimIdDir (below) ever reserved a package id, so a 400-record
|
|
77
|
+
// build died having written nothing, named exactly one offender, and had
|
|
78
|
+
// to be rerun in full for each subsequent one. Mirrors the aggregation
|
|
79
|
+
// pattern lib/canon-build/build.js already uses for contextLimitFailures:
|
|
80
|
+
// collect across the WHOLE loop, throw one summary naming every offender.
|
|
81
|
+
const captionFailures = [];
|
|
71
82
|
|
|
72
83
|
for (const partition of ['train', 'val', 'test']) {
|
|
73
84
|
const entries = await loadSplitPartition(projectRoot, manifest.source_split_id, partition);
|
|
74
85
|
|
|
75
86
|
for (const entry of entries) {
|
|
76
87
|
const record = await loadRecord(recordsDir, entry.record_id);
|
|
77
|
-
if (!record)
|
|
88
|
+
if (!record) {
|
|
89
|
+
missingRecords.push({ record_id: entry.record_id, stage: 'training-package', partition });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
78
92
|
|
|
79
93
|
const prompt = record.provenance?.prompt || '';
|
|
80
94
|
const lane = detectLane(record.id, prompt, config.lanes);
|
|
81
95
|
const group = detectGroup(record.id, prompt, config.terminology);
|
|
82
96
|
|
|
83
|
-
|
|
97
|
+
let row;
|
|
98
|
+
try {
|
|
99
|
+
row = recordToRow(record, lane, group, profile, partition);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Only batch STRUCTURED/expected failures (SdlabError) — an
|
|
102
|
+
// unexpected bug should still crash loud with its real stack
|
|
103
|
+
// instead of being swallowed into a friendly summary.
|
|
104
|
+
if (!(err instanceof SdlabError)) throw err;
|
|
105
|
+
captionFailures.push({ record_id: entry.record_id, partition, message: err.message });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
84
108
|
if (row) rows.push(row);
|
|
85
109
|
}
|
|
86
110
|
}
|
|
87
111
|
|
|
112
|
+
// F3: hard-fail — training packages sit on the same Law 4 ("exports are
|
|
113
|
+
// reproducible") contract export.js does, one layer further downstream;
|
|
114
|
+
// see export.js's EXPORT_MISSING_RECORDS guard for the full rationale on
|
|
115
|
+
// why this escalates to a thrown error rather than the warn()-and-ledger
|
|
116
|
+
// pattern used in split.js / implementation-packs.js. Checked before the
|
|
117
|
+
// Windows symlink guard, row-safety validation, and claimIdDir below, so
|
|
118
|
+
// a doomed build never reserves a package id or touches the filesystem.
|
|
119
|
+
if (missingRecords.length > 0) {
|
|
120
|
+
const sample = missingRecords.slice(0, 10).map(m => `${m.record_id} (${m.partition})`);
|
|
121
|
+
throw inputError(
|
|
122
|
+
'TRAINING_PACKAGE_MISSING_RECORDS',
|
|
123
|
+
`${missingRecords.length} record(s) referenced by split "${manifest.source_split_id}" could not be loaded from ${recordsDir} — this training package would silently contain fewer rows than the split promised.` +
|
|
124
|
+
(missingRecords.length > 10
|
|
125
|
+
? ` First 10: ${sample.join(', ')}, ...`
|
|
126
|
+
: ` Missing: ${sample.join(', ')}.`),
|
|
127
|
+
'Record file(s) were likely deleted after the split was created. Restore the missing file(s), or create a fresh snapshot/split reflecting current on-disk records before building a training package.',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// H3: aggregated caption-failure report — see the captionFailures
|
|
132
|
+
// declaration above. Checked alongside missingRecords, before claimIdDir,
|
|
133
|
+
// so this also never reserves a package id or touches the filesystem.
|
|
134
|
+
if (captionFailures.length > 0) {
|
|
135
|
+
const sample = captionFailures.slice(0, 10).map(f => `${f.record_id} (${f.partition}): ${f.message}`);
|
|
136
|
+
throw inputError(
|
|
137
|
+
'TRAINING_PACKAGE_CAPTION_FAILURES',
|
|
138
|
+
`${captionFailures.length} record(s) failed to caption while building this training package.\n` +
|
|
139
|
+
sample.join('\n') +
|
|
140
|
+
(captionFailures.length > 10 ? `\n...and ${captionFailures.length - 10} more.` : ''),
|
|
141
|
+
'Fix the offending record(s) — commonly: populate canon.subject on records using caption_strategy ' +
|
|
142
|
+
'"subject-natural-language" — then retry. Every failure is listed above so this can be fixed in one ' +
|
|
143
|
+
'pass instead of one rerun per offender.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
88
147
|
// Apply profile filters (eligible_lanes + entity_id_scope — D8).
|
|
89
148
|
// filterRowsForProfile returns rows where lane ∈ eligible_lanes AND
|
|
90
149
|
// entity_id === entity_id_scope (when set). Zero filters = identity.
|
|
@@ -95,6 +154,54 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
95
154
|
const totalRecords = filteredRows.length;
|
|
96
155
|
let totalImages = 0;
|
|
97
156
|
|
|
157
|
+
// SDL-C5: mirror export.js's D-008 Windows guard. Symlink mode on
|
|
158
|
+
// Windows without admin/Developer Mode fails on every single row
|
|
159
|
+
// (EPERM) — unguarded, that meant imageCount stayed 0, every
|
|
160
|
+
// metadata/<partition>.jsonl came out empty, and the command still
|
|
161
|
+
// exited 0 printing "Images: 0". Refuse upfront instead of limping
|
|
162
|
+
// through an all-rows-fail package that LOOKS like success.
|
|
163
|
+
if (!options.copy && platform() === 'win32' && filteredRows.length > 0) {
|
|
164
|
+
throw inputError(
|
|
165
|
+
'SYMLINK_UNSUPPORTED_WINDOWS',
|
|
166
|
+
'Symlink mode is not supported on Windows training-package builds (target semantics differ and break on archive/transfer).',
|
|
167
|
+
'Re-run with --copy to produce a portable, self-contained training package.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SDL-H5 / SDL-H6: validate every row BEFORE any adapter touches the
|
|
172
|
+
// filesystem. export.js already guards asset_path against traversal
|
|
173
|
+
// (D-007) and now guards the id that becomes the destination filename
|
|
174
|
+
// (SDL-H5); this ports the exact same checks (same error codes) to the
|
|
175
|
+
// training-package path, which previously only checked existsSync — a
|
|
176
|
+
// traversal path satisfies existsSync just as well as a legitimate one.
|
|
177
|
+
// Centralized here once, per the finding's own suggestion, rather than
|
|
178
|
+
// duplicated per-adapter — though each adapter ALSO carries the same
|
|
179
|
+
// guard directly (see lib/adapters/*.js) as defense-in-depth for a
|
|
180
|
+
// caller that invokes adapter.buildPackage without going through this
|
|
181
|
+
// function (e.g. the adapters' own unit tests, or a future canon-build
|
|
182
|
+
// caller — see lib/rows.js's documented second row producer).
|
|
183
|
+
for (const row of filteredRows) {
|
|
184
|
+
assertSafeId(row.entity_id, `row.entity_id for training package (asset_path "${row.asset_path}")`);
|
|
185
|
+
assertSafeAssetPath(row.asset_path, projectRoot, row.entity_id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// SDL-M10: atomically claim the training-package id/dir — closes the
|
|
189
|
+
// check-then-act race the old existsSync+recursive-mkdir pattern had
|
|
190
|
+
// (D-005's intent, now actually race-proof; see claimIdDir in
|
|
191
|
+
// snapshot.js). Deferred to here (rather than claimed at the top of the
|
|
192
|
+
// function) since nothing above this point actually needs packageId —
|
|
193
|
+
// no point reserving one before the Windows guard / row validation have
|
|
194
|
+
// had a chance to reject the build outright.
|
|
195
|
+
const { id: packageId, dir: packageDir } = await claimIdDir(
|
|
196
|
+
join(projectRoot, 'training', 'packages'),
|
|
197
|
+
generatePackageId,
|
|
198
|
+
{
|
|
199
|
+
code: 'TRAINING_PACKAGE_ID_COLLISION',
|
|
200
|
+
message: 'Could not claim a unique training-package ID — every candidate collided with an existing package directory.',
|
|
201
|
+
hint: 'Retry; if this persists, another sdlab process may be creating training packages in a tight loop.',
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
98
205
|
// Let the adapter build the package layout from the canonical rows.
|
|
99
206
|
const adapterResult = await adapter.buildPackage({
|
|
100
207
|
packageDir,
|
|
@@ -107,6 +214,22 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
107
214
|
});
|
|
108
215
|
|
|
109
216
|
totalImages = adapterResult.imageCount || 0;
|
|
217
|
+
// SDL-C5: DB-002-style accounting, ported from export.js — a caught
|
|
218
|
+
// per-row copy/symlink failure must be visible, not silently dropped
|
|
219
|
+
// alongside its metadata row and caption sidecar via a bare
|
|
220
|
+
// `catch { continue; }`. The adapter itself already emitted a warn()
|
|
221
|
+
// (it has the precise per-row context); this builds the PERSISTED
|
|
222
|
+
// record on the package manifest so the failure survives after the
|
|
223
|
+
// terminal scrolls, without re-printing the same stderr line twice.
|
|
224
|
+
const imagesFailed = adapterResult.imagesFailed || [];
|
|
225
|
+
const warnings = [];
|
|
226
|
+
if (imagesFailed.length > 0) {
|
|
227
|
+
const sample = imagesFailed.slice(0, 10).map(f => `${f.record_id} (${f.reason})`);
|
|
228
|
+
const msg =
|
|
229
|
+
`training-package: ${imagesFailed.length} of ${filteredRows.length} rows failed to place` +
|
|
230
|
+
(imagesFailed.length > 10 ? ` — first 10: ${sample.join(', ')}, ...` : ` — ${sample.join(', ')}`);
|
|
231
|
+
warnings.push(msg);
|
|
232
|
+
}
|
|
110
233
|
|
|
111
234
|
// D-015: Write manifest + README FIRST so they are covered by checksums.
|
|
112
235
|
const packageManifest = {
|
|
@@ -122,14 +245,23 @@ export async function buildTrainingPackage(projectRoot, manifestId, options = {}
|
|
|
122
245
|
source_snapshot_id: manifest.source_snapshot_id,
|
|
123
246
|
source_split_id: manifest.source_split_id,
|
|
124
247
|
config_fingerprint: manifest.config_fingerprint,
|
|
248
|
+
warnings,
|
|
125
249
|
counts: {
|
|
126
250
|
total_records: totalRecords,
|
|
127
251
|
train: partitionCounts.train,
|
|
128
252
|
val: partitionCounts.val,
|
|
129
253
|
test: partitionCounts.test,
|
|
130
254
|
images: totalImages,
|
|
255
|
+
// SDL-C5: expected/actual/failed breakdown (export.js DB-002 shape)
|
|
256
|
+
// so a partially- or totally-failed placement is visible in the
|
|
257
|
+
// manifest, not just inferable from a suspiciously-low `images`.
|
|
258
|
+
images_expected: filteredRows.length,
|
|
259
|
+
images_actual: totalImages,
|
|
260
|
+
images_failed: imagesFailed.length,
|
|
131
261
|
checksums: 0, // updated after checksum pass below
|
|
132
262
|
},
|
|
263
|
+
// SDL-C5: record-level failure detail for operator triage.
|
|
264
|
+
images_failed: imagesFailed,
|
|
133
265
|
};
|
|
134
266
|
await writeFile(join(packageDir, 'manifest.json'), JSON.stringify(packageManifest, null, 2) + '\n');
|
|
135
267
|
|
package/lib/verifier.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { warn } from './log.js';
|
|
16
|
+
import { assertNoSelfVerification } from './canon-review-gate.js';
|
|
17
|
+
|
|
18
|
+
export { assertNoSelfVerification };
|
|
16
19
|
|
|
17
20
|
/** The rule-based critique engine's judge id. Bump vN when the rule set changes. */
|
|
18
21
|
export const RULE_BASED_JUDGE = 'rule-based:sdlab-critique-v1';
|