@myvillage/cli 2.9.0 → 2.12.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/package.json +1 -1
- package/src/agent-runtime/builtin-tools-registry.js +35 -0
- package/src/agent-runtime/builtin-tools.js +55 -2
- package/src/agent-runtime/context.js +8 -7
- package/src/agent-runtime/loop.js +2 -1
- package/src/agent-runtime/runner.js +11 -0
- package/src/commands/dataset.js +18 -0
- package/src/index.js +9 -0
- package/src/utils/agent-models.js +88 -0
- package/src/utils/dataset-workspace.js +62 -0
package/package.json
CHANGED
|
@@ -30,6 +30,13 @@ export const DATASET_READ_DESCRIPTION =
|
|
|
30
30
|
'as absolute file paths (which model_predict accepts). Read-only — use ' +
|
|
31
31
|
'dataset_append to add rows.';
|
|
32
32
|
|
|
33
|
+
export const DATASET_SPLIT_DESCRIPTION =
|
|
34
|
+
'Shuffle a LOCAL dataset\'s working set into training rows and validation ' +
|
|
35
|
+
'(held-back checking) rows in one step. `valPercent` (default 20) is the share ' +
|
|
36
|
+
'held back; classification datasets split each label evenly so every group ' +
|
|
37
|
+
'is checked. Replaces any split already set by hand. Use it before training, ' +
|
|
38
|
+
'or after adding many rows. Data stays local.';
|
|
39
|
+
|
|
33
40
|
export const MODEL_PREDICT_DESCRIPTION =
|
|
34
41
|
'Run a prediction with a LOCAL trained model on this machine. `input` is ' +
|
|
35
42
|
'text (words models), the values of one table row as a list in column ' +
|
|
@@ -52,6 +59,12 @@ export const BUILTIN_TOOL_DEFS = [
|
|
|
52
59
|
tier: 'villager',
|
|
53
60
|
builtin: true,
|
|
54
61
|
},
|
|
62
|
+
{
|
|
63
|
+
name: 'dataset_split',
|
|
64
|
+
description: DATASET_SPLIT_DESCRIPTION,
|
|
65
|
+
tier: 'villager',
|
|
66
|
+
builtin: true,
|
|
67
|
+
},
|
|
55
68
|
{
|
|
56
69
|
name: 'model_predict',
|
|
57
70
|
description: MODEL_PREDICT_DESCRIPTION,
|
|
@@ -59,3 +72,25 @@ export const BUILTIN_TOOL_DEFS = [
|
|
|
59
72
|
builtin: true,
|
|
60
73
|
},
|
|
61
74
|
];
|
|
75
|
+
|
|
76
|
+
/** One line telling an agent what a local model is for and what it takes. */
|
|
77
|
+
export function describeLocalModel(m) {
|
|
78
|
+
const f = m.featurization ?? {};
|
|
79
|
+
const modality = f.modality ?? m.modality;
|
|
80
|
+
let takes;
|
|
81
|
+
if (modality === 'image') takes = 'a local image file path (.jpg/.png)';
|
|
82
|
+
else if (modality === 'audio') takes = 'a local .wav file path';
|
|
83
|
+
else if (modality === 'text') takes = 'some writing';
|
|
84
|
+
else if (Array.isArray(f.columns)) {
|
|
85
|
+
takes = 'a table row with ' + f.columns.map((c) => `${JSON.stringify(c.name)} (${c.type})`).join(', ');
|
|
86
|
+
} else if (Array.isArray(m.featureNames) && m.featureNames.length) {
|
|
87
|
+
takes = 'a table row of numbers: ' + m.featureNames.map((n) => JSON.stringify(n)).join(', ');
|
|
88
|
+
} else {
|
|
89
|
+
takes = `a list of ${m.inputDim ?? 'some'} numbers`;
|
|
90
|
+
}
|
|
91
|
+
const gives =
|
|
92
|
+
m.taskType === 'classification' && Array.isArray(m.labels) && m.labels.length
|
|
93
|
+
? `sorts it into: ${m.labels.join(', ')}`
|
|
94
|
+
: 'gives back a number';
|
|
95
|
+
return `- "${m.slug}" (${m.name}): takes ${takes}; ${gives}.`;
|
|
96
|
+
}
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
* what's been collected (to answer questions about it, avoid duplicate
|
|
15
15
|
* appends, or feed rows to model_predict). Also local-only.
|
|
16
16
|
*
|
|
17
|
+
* `dataset_split` wraps splitRows() — the same engine as
|
|
18
|
+
* `myvillage dataset split` — to deal rows into train / val in one write.
|
|
19
|
+
*
|
|
17
20
|
* Tool contracts mirror @myvillage-studio/dataset-schema DATASET_APPEND_TOOL
|
|
18
21
|
* and DATASET_READ_TOOL.
|
|
19
22
|
*/
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
datasetExists,
|
|
25
28
|
readManifest,
|
|
26
29
|
readRows,
|
|
30
|
+
splitRows,
|
|
27
31
|
ingestAsset,
|
|
28
32
|
getAssetPath,
|
|
29
33
|
listDatasets,
|
|
@@ -31,8 +35,12 @@ import {
|
|
|
31
35
|
import {
|
|
32
36
|
DATASET_APPEND_DESCRIPTION,
|
|
33
37
|
DATASET_READ_DESCRIPTION,
|
|
38
|
+
DATASET_SPLIT_DESCRIPTION,
|
|
34
39
|
MODEL_PREDICT_DESCRIPTION,
|
|
40
|
+
describeLocalModel,
|
|
35
41
|
} from './builtin-tools-registry.js';
|
|
42
|
+
import { readAgentModels } from '../utils/agent-models.js';
|
|
43
|
+
import { readModelManifest as readModelManifestSync } from '../utils/model-workspace.js';
|
|
36
44
|
import { readToolsYaml } from '../utils/local-agent.js';
|
|
37
45
|
|
|
38
46
|
export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
|
|
@@ -91,11 +99,21 @@ function applyBuiltinAllowlist(builtins, agentName) {
|
|
|
91
99
|
if (!Array.isArray(allowed)) return builtins;
|
|
92
100
|
const filtered = {};
|
|
93
101
|
for (const [name, t] of Object.entries(builtins)) {
|
|
94
|
-
|
|
102
|
+
// model_predict is granted by the agent's `models:` list, not this allowlist.
|
|
103
|
+
if (name === 'model_predict' || allowed.includes(name)) filtered[name] = t;
|
|
95
104
|
}
|
|
96
105
|
return filtered;
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
/** model_predict's description, naming the models this agent was given. */
|
|
109
|
+
function modelPredictDescription(slugs, readModelManifest) {
|
|
110
|
+
const lines = slugs.map((slug) => {
|
|
111
|
+
const m = readModelManifest(slug);
|
|
112
|
+
return m ? describeLocalModel(m) : `- "${slug}": not on this computer yet.`;
|
|
113
|
+
});
|
|
114
|
+
return `${MODEL_PREDICT_DESCRIPTION} Use one whenever its job matches what you are doing. The models you can use (the quoted slug is the modelId):\n${lines.join('\n')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
/**
|
|
100
118
|
* Build the native built-in tools for an agent.
|
|
101
119
|
* @param {string} agentName
|
|
@@ -103,6 +121,8 @@ function applyBuiltinAllowlist(builtins, agentName) {
|
|
|
103
121
|
* @returns {Record<string, object>} AI SDK tools keyed by tool name
|
|
104
122
|
*/
|
|
105
123
|
export function getBuiltinTools(agentName, config) {
|
|
124
|
+
// Models are granted per agent, like tools: only the ones in its tools.yaml.
|
|
125
|
+
const grantedModels = readAgentModels(agentName);
|
|
106
126
|
const all = {
|
|
107
127
|
dataset_append: tool({
|
|
108
128
|
description: DATASET_APPEND_DESCRIPTION,
|
|
@@ -246,8 +266,33 @@ export function getBuiltinTools(agentName, config) {
|
|
|
246
266
|
},
|
|
247
267
|
}),
|
|
248
268
|
|
|
269
|
+
dataset_split: tool({
|
|
270
|
+
description: DATASET_SPLIT_DESCRIPTION,
|
|
271
|
+
inputSchema: z.object({
|
|
272
|
+
datasetId: z.string().describe('Slug of the local dataset to split'),
|
|
273
|
+
valPercent: z
|
|
274
|
+
.number()
|
|
275
|
+
.min(0)
|
|
276
|
+
.max(90)
|
|
277
|
+
.optional()
|
|
278
|
+
.describe('Percent of rows held back for validation (default 20)'),
|
|
279
|
+
seed: z.number().int().optional().describe('Seed for a repeatable shuffle'),
|
|
280
|
+
}),
|
|
281
|
+
execute: async ({ datasetId, valPercent = 20, seed }) => {
|
|
282
|
+
if (!datasetExists(datasetId)) {
|
|
283
|
+
return { error: `Dataset "${datasetId}" not found locally.` };
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const r = splitRows(datasetId, { valPercent, seed });
|
|
287
|
+
return { ok: true, datasetId: r.datasetId, valPercent: r.valPercent, moved: r.moved, stats: r.stats };
|
|
288
|
+
} catch (e) {
|
|
289
|
+
return { error: e.message };
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
|
|
249
294
|
model_predict: tool({
|
|
250
|
-
description:
|
|
295
|
+
description: modelPredictDescription(grantedModels, readModelManifestSync),
|
|
251
296
|
inputSchema: z.object({
|
|
252
297
|
modelId: z.string().describe('Slug of the local trained model'),
|
|
253
298
|
input: z
|
|
@@ -263,6 +308,13 @@ export function getBuiltinTools(agentName, config) {
|
|
|
263
308
|
),
|
|
264
309
|
}),
|
|
265
310
|
execute: async ({ modelId, input }) => {
|
|
311
|
+
if (!grantedModels.includes(modelId)) {
|
|
312
|
+
return {
|
|
313
|
+
error:
|
|
314
|
+
`This agent hasn't been given the model "${modelId}". ` +
|
|
315
|
+
`It can use: ${grantedModels.join(', ')}.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
266
318
|
try {
|
|
267
319
|
// Lazy-load the model/tfjs code so agent startup stays light — tfjs
|
|
268
320
|
// only loads the first time an agent actually predicts.
|
|
@@ -286,5 +338,6 @@ export function getBuiltinTools(agentName, config) {
|
|
|
286
338
|
},
|
|
287
339
|
}),
|
|
288
340
|
};
|
|
341
|
+
if (grantedModels.length === 0) delete all.model_predict;
|
|
289
342
|
return applyBuiltinAllowlist(all, agentName);
|
|
290
343
|
}
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { listDatasets } from '../utils/dataset-workspace.js';
|
|
15
15
|
import { listModels } from '../utils/model-workspace.js';
|
|
16
|
+
import { describeLocalModel } from './builtin-tools-registry.js';
|
|
17
|
+
import { readAgentModels } from '../utils/agent-models.js';
|
|
16
18
|
|
|
17
|
-
export async function gatherContext(agentConfig, lastCheckIn, recentActions = []) {
|
|
19
|
+
export async function gatherContext(agentConfig, lastCheckIn, recentActions = [], agentName = agentConfig?.name) {
|
|
18
20
|
const parts = [];
|
|
19
21
|
|
|
20
22
|
parts.push(`Current time: ${new Date().toISOString()}`);
|
|
@@ -50,13 +52,12 @@ export async function gatherContext(agentConfig, lastCheckIn, recentActions = []
|
|
|
50
52
|
/* no local datasets / store unavailable — skip */
|
|
51
53
|
}
|
|
52
54
|
try {
|
|
53
|
-
|
|
55
|
+
// Only the models this agent was given (its tools.yaml `models:` list).
|
|
56
|
+
const granted = new Set(agentName ? readAgentModels(agentName) : []);
|
|
57
|
+
const models = listModels().filter((m) => granted.has(m.slug));
|
|
54
58
|
if (models.length > 0) {
|
|
55
|
-
parts.push('\
|
|
56
|
-
for (const m of models.slice(0, 25))
|
|
57
|
-
const labels = m.labels?.length ? ` → ${m.labels.join('/')}` : '';
|
|
58
|
-
parts.push(`- ${m.slug} — ${m.modality}/${m.taskType}${labels}`);
|
|
59
|
-
}
|
|
59
|
+
parts.push('\nModels you can run with model_predict (the quoted slug is the modelId). Use one whenever its job matches what you are doing:');
|
|
60
|
+
for (const m of models.slice(0, 25)) parts.push(describeLocalModel(m));
|
|
60
61
|
}
|
|
61
62
|
} catch {
|
|
62
63
|
/* no local models / store unavailable — skip */
|
|
@@ -286,7 +286,7 @@ export async function agentLoop(agentName, { signal }) {
|
|
|
286
286
|
// no longer auto-fetched here — the agent pulls them on demand via its
|
|
287
287
|
// tools — so these activity counters stay at 0 unless a future signal
|
|
288
288
|
// repopulates them.
|
|
289
|
-
const contextResult = await gatherContext(config, lastCheckIn, recentActions);
|
|
289
|
+
const contextResult = await gatherContext(config, lastCheckIn, recentActions, agentName);
|
|
290
290
|
let context = contextResult.text;
|
|
291
291
|
const eventsFound = 0;
|
|
292
292
|
|
|
@@ -665,6 +665,7 @@ const ACTION_TOOLS = new Set([
|
|
|
665
665
|
'moment_create',
|
|
666
666
|
'pulse_create',
|
|
667
667
|
'dataset_append',
|
|
668
|
+
'dataset_split',
|
|
668
669
|
'agent_join_community',
|
|
669
670
|
'agent_leave_community',
|
|
670
671
|
'wallet_send',
|
|
@@ -27,6 +27,7 @@ import { refreshAccessToken } from '../utils/api.js';
|
|
|
27
27
|
import { listLocalAgents, readAgentConfig, getAgentDir, writeAgentConfig } from '../utils/local-agent.js';
|
|
28
28
|
import { startDaemon, stopDaemon, isDaemonRunning, getDaemonInfo } from './daemon.js';
|
|
29
29
|
import { shipLogsOnce, forgetLogCursor } from './log-shipper.js';
|
|
30
|
+
import { writeSyncedModel } from '../utils/agent-models.js';
|
|
30
31
|
|
|
31
32
|
const RUNNER_STATE_FILE = path.join(os.homedir(), '.myvillage', 'runner.json');
|
|
32
33
|
const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
@@ -176,6 +177,16 @@ async function syncAgentConfig(ctx, assignment) {
|
|
|
176
177
|
fs.writeFileSync(path.join(agentDir, 'tools.yaml'), c.toolsYaml);
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
// The trained models this agent was given travel with its config.
|
|
181
|
+
if (c.models && typeof c.models === 'object') {
|
|
182
|
+
for (const [slug, entry] of Object.entries(c.models)) {
|
|
183
|
+
const result = writeSyncedModel(slug, entry);
|
|
184
|
+
if (result === 'written') log(`synced model ${slug} for ${name}`);
|
|
185
|
+
else if (result === 'kept-local') log(`kept this machine's own model ${slug} for ${name}`);
|
|
186
|
+
else if (result === 'invalid') log(`skipped model ${slug} for ${name}: its files didn't check out`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
179
190
|
// Compose agent.config.yaml from server-known identity + published schedule
|
|
180
191
|
// and brain, preserving anything already set locally that the control plane
|
|
181
192
|
// does not own.
|
package/src/commands/dataset.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
freezeVersion,
|
|
22
22
|
deleteRow,
|
|
23
23
|
updateRow,
|
|
24
|
+
splitRows,
|
|
24
25
|
} from '../utils/dataset-workspace.js';
|
|
25
26
|
|
|
26
27
|
// ── create ─────────────────────────────────────────────
|
|
@@ -250,6 +251,23 @@ export async function datasetRowUpdateCommand(options) {
|
|
|
250
251
|
}
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// ── split ──────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export async function datasetSplitCommand(slug, options) {
|
|
257
|
+
try {
|
|
258
|
+
const result = splitRows(slug, { valPercent: options.val, seed: options.seed });
|
|
259
|
+
if (options.json) {
|
|
260
|
+
console.log(JSON.stringify(result, null, 2));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
success(`Split ${slug}: ${100 - result.valPercent}% train / ${result.valPercent}% val`);
|
|
264
|
+
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val}) · ${result.moved} rows moved`);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
error(e.message);
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
253
271
|
// ── helpers ────────────────────────────────────────────
|
|
254
272
|
|
|
255
273
|
function readRowsFromFile(path) {
|
package/src/index.js
CHANGED
|
@@ -143,6 +143,7 @@ import {
|
|
|
143
143
|
datasetFreezeCommand,
|
|
144
144
|
datasetRowDeleteCommand,
|
|
145
145
|
datasetRowUpdateCommand,
|
|
146
|
+
datasetSplitCommand,
|
|
146
147
|
} from './commands/dataset.js';
|
|
147
148
|
import {
|
|
148
149
|
modelTrainCommand,
|
|
@@ -1054,6 +1055,14 @@ export function run() {
|
|
|
1054
1055
|
.option('--json', 'Output raw JSON')
|
|
1055
1056
|
.action(datasetRowUpdateCommand);
|
|
1056
1057
|
|
|
1058
|
+
datasetCmd
|
|
1059
|
+
.command('split <slug>')
|
|
1060
|
+
.description('Shuffle the working set into train and val (each label split evenly)')
|
|
1061
|
+
.option('--val <percent>', 'Percent of rows held back to check the model (0-90)', '20')
|
|
1062
|
+
.option('--seed <n>', 'Seed for a repeatable shuffle')
|
|
1063
|
+
.option('--json', 'Output raw JSON')
|
|
1064
|
+
.action(datasetSplitCommand);
|
|
1065
|
+
|
|
1057
1066
|
// ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
|
|
1058
1067
|
const modelCmd = program
|
|
1059
1068
|
.command('model')
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trained models an agent may use, and the files that carry them between
|
|
3
|
+
* machines.
|
|
4
|
+
*
|
|
5
|
+
* An agent's models are granted like its tools: a `models:` list of local
|
|
6
|
+
* model slugs in its tools.yaml. No list means no models. Because tools.yaml
|
|
7
|
+
* is published verbatim to the control plane, the list follows the agent to
|
|
8
|
+
* any machine; the model files themselves travel in the published config's
|
|
9
|
+
* `models` field (see bundleAgentModels / writeSyncedModel).
|
|
10
|
+
*
|
|
11
|
+
* A model a machine trained itself is never overwritten by a synced copy. A
|
|
12
|
+
* synced copy is marked with `.synced-hash`, so the runner only replaces what
|
|
13
|
+
* it put there.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'crypto';
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
import { readToolsYaml } from './local-agent.js';
|
|
19
|
+
import { getModelDir, modelExists } from './model-workspace.js';
|
|
20
|
+
|
|
21
|
+
const MODEL_FILES = ['manifest.json', 'model.json', 'weights.b64'];
|
|
22
|
+
const SYNCED_MARKER = '.synced-hash';
|
|
23
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
24
|
+
|
|
25
|
+
/** Slugs listed under `models:` in an agent's tools.yaml (deduped, in order). */
|
|
26
|
+
export function readAgentModels(agentName) {
|
|
27
|
+
const list = readToolsYaml(agentName)?.models;
|
|
28
|
+
if (!Array.isArray(list)) return [];
|
|
29
|
+
return [...new Set(list.filter((s) => typeof s === 'string' && SLUG_RE.test(s)))];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hashFiles(files) {
|
|
33
|
+
const h = createHash('sha256');
|
|
34
|
+
for (const f of MODEL_FILES) h.update(f).update('\0').update(files[f]).update('\0');
|
|
35
|
+
return h.digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The files for each of an agent's models that exist on this machine, keyed by
|
|
40
|
+
* slug: { hash, files: { 'manifest.json', 'model.json', 'weights.b64' } }.
|
|
41
|
+
*/
|
|
42
|
+
export function bundleAgentModels(agentName) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const slug of readAgentModels(agentName)) {
|
|
45
|
+
if (!modelExists(slug)) continue;
|
|
46
|
+
const dir = getModelDir(slug);
|
|
47
|
+
try {
|
|
48
|
+
const files = Object.fromEntries(MODEL_FILES.map((f) => [f, readFileSync(join(dir, f), 'utf-8')]));
|
|
49
|
+
out[slug] = { hash: hashFiles(files), files };
|
|
50
|
+
} catch {
|
|
51
|
+
/* half-written or partly deleted model: skip it */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write a model that arrived with an agent's config. Returns what happened:
|
|
59
|
+
* 'written', 'current' (already this version), 'kept-local' (this machine
|
|
60
|
+
* trained its own model with that name), or 'invalid'.
|
|
61
|
+
*/
|
|
62
|
+
export function writeSyncedModel(slug, entry) {
|
|
63
|
+
if (!SLUG_RE.test(slug) || !entry || typeof entry.files !== 'object') return 'invalid';
|
|
64
|
+
const files = entry.files;
|
|
65
|
+
if (!MODEL_FILES.every((f) => typeof files[f] === 'string' && files[f].length > 0)) return 'invalid';
|
|
66
|
+
const hash = hashFiles(files);
|
|
67
|
+
if (entry.hash && entry.hash !== hash) return 'invalid';
|
|
68
|
+
try {
|
|
69
|
+
if (JSON.parse(files['manifest.json']).slug !== slug) return 'invalid';
|
|
70
|
+
} catch {
|
|
71
|
+
return 'invalid';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const dir = getModelDir(slug);
|
|
75
|
+
const markerPath = join(dir, SYNCED_MARKER);
|
|
76
|
+
if (modelExists(slug)) {
|
|
77
|
+
if (!existsSync(markerPath)) return 'kept-local';
|
|
78
|
+
if (readFileSync(markerPath, 'utf-8').trim() === hash) return 'current';
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
// Weights first and the manifest last: a model counts as present once its
|
|
82
|
+
// manifest exists, so a half-finished write never looks like a whole model.
|
|
83
|
+
writeFileSync(join(dir, 'weights.b64'), files['weights.b64']);
|
|
84
|
+
writeFileSync(join(dir, 'model.json'), files['model.json']);
|
|
85
|
+
writeFileSync(join(dir, 'manifest.json'), files['manifest.json']);
|
|
86
|
+
writeFileSync(markerPath, hash);
|
|
87
|
+
return 'written';
|
|
88
|
+
}
|
|
@@ -494,6 +494,68 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
494
494
|
return { datasetId: slug, updated: merged.id, stats: manifest.stats };
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
// ── Split the working set into train / val ─────────────
|
|
498
|
+
|
|
499
|
+
/** Small seeded random (mulberry32), so a --seed split is repeatable. */
|
|
500
|
+
function seededRandom(seed) {
|
|
501
|
+
let a = seed >>> 0;
|
|
502
|
+
return () => {
|
|
503
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
504
|
+
let t = a;
|
|
505
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
506
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
507
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function shuffle(items, rand) {
|
|
512
|
+
for (let i = items.length - 1; i > 0; i--) {
|
|
513
|
+
const j = Math.floor(rand() * (i + 1));
|
|
514
|
+
[items[i], items[j]] = [items[j], items[i]];
|
|
515
|
+
}
|
|
516
|
+
return items;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Re-deal every working-set row into train or val in one write. Rows are
|
|
521
|
+
* shuffled, and for classification each label is split on its own, so every
|
|
522
|
+
* group gets its fair share of val rows (a label with 2+ rows always keeps at
|
|
523
|
+
* least one of each). Records the ratio in manifest.splitConfig.
|
|
524
|
+
* @param {string} slug
|
|
525
|
+
* @param {{valPercent?:number, seed?:number}} opts valPercent 0–90, default 20
|
|
526
|
+
* @returns {{datasetId:string, valPercent:number, moved:number, stats:object}}
|
|
527
|
+
*/
|
|
528
|
+
export function splitRows(slug, { valPercent = 20, seed } = {}) {
|
|
529
|
+
const manifest = readManifest(slug);
|
|
530
|
+
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
531
|
+
const pct = Number(valPercent);
|
|
532
|
+
if (!Number.isFinite(pct) || pct < 0 || pct > 90) {
|
|
533
|
+
throw new Error('The share held back for checking must be between 0 and 90 percent.');
|
|
534
|
+
}
|
|
535
|
+
const rows = readRows(slug);
|
|
536
|
+
if (rows.length === 0) throw new Error('This dataset has no rows to split yet.');
|
|
537
|
+
const rand = seed != null && seed !== '' ? seededRandom(Number(seed)) : Math.random;
|
|
538
|
+
|
|
539
|
+
const groups = new Map();
|
|
540
|
+
for (const row of rows) {
|
|
541
|
+
const key = manifest.taskType === 'classification' ? (row.label ?? '') : '';
|
|
542
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
543
|
+
groups.get(key).push(row);
|
|
544
|
+
}
|
|
545
|
+
const before = new Map(rows.map((r) => [r.id, r.split]));
|
|
546
|
+
for (const group of groups.values()) {
|
|
547
|
+
let nVal = Math.round((group.length * pct) / 100);
|
|
548
|
+
if (pct > 0 && group.length >= 2) nVal = Math.min(Math.max(nVal, 1), group.length - 1);
|
|
549
|
+
shuffle([...group], rand).forEach((row, i) => {
|
|
550
|
+
row.split = i < nVal ? 'val' : 'train';
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const moved = rows.filter((r) => before.get(r.id) !== r.split).length;
|
|
554
|
+
manifest.splitConfig = { ...(manifest.splitConfig || {}), trainRatio: 1 - pct / 100 };
|
|
555
|
+
writeWorkingSet(slug, manifest, rows);
|
|
556
|
+
return { datasetId: slug, valPercent: pct, moved, stats: manifest.stats };
|
|
557
|
+
}
|
|
558
|
+
|
|
497
559
|
// ── Freeze a version ───────────────────────────────────
|
|
498
560
|
|
|
499
561
|
/**
|