@myvillage/cli 1.68.0 → 1.70.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myvillage/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.70.0",
|
|
4
4
|
"description": "MyVillageOS CLI for community developers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"@ai-sdk/anthropic": "^3.0.78",
|
|
31
31
|
"@ai-sdk/mcp": "^1.0.42",
|
|
32
32
|
"@ai-sdk/openai": "^3.0.33",
|
|
33
|
+
"@tensorflow/tfjs": "^4.22.0",
|
|
33
34
|
"ai": "^6.0.0",
|
|
34
35
|
"axios": "^1.6.2",
|
|
35
36
|
"chalk": "^5.3.0",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of native built-in agent tools — plain data only (no `ai`/`zod`/
|
|
3
|
+
* tfjs imports), so the Studio can read tool metadata cheaply to show built-ins
|
|
4
|
+
* in its tool palette / allowlist alongside MCP tools.
|
|
5
|
+
*
|
|
6
|
+
* Single source of truth for built-in tool name + description + tier:
|
|
7
|
+
* - the agent loop's builtin-tools.js pulls descriptions from here
|
|
8
|
+
* - the Studio (via cli-bridge) imports this to list selectable built-ins
|
|
9
|
+
*
|
|
10
|
+
* `builtin: true` marks them so the UI can distinguish them from MCP tools.
|
|
11
|
+
* Permission `tier` reuses the notification send-tier model.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DATASET_APPEND_DESCRIPTION =
|
|
15
|
+
'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
|
|
16
|
+
'The dataset must already exist (created via the Studio Lab or ' +
|
|
17
|
+
'`myvillage dataset create`). For classification provide `label`; for ' +
|
|
18
|
+
'regression provide a numeric `target`. Data stays local; provenance ' +
|
|
19
|
+
'is recorded automatically.';
|
|
20
|
+
|
|
21
|
+
/** All native built-in tools available to every local agent. */
|
|
22
|
+
export const BUILTIN_TOOL_DEFS = [
|
|
23
|
+
{
|
|
24
|
+
name: 'dataset_append',
|
|
25
|
+
description: DATASET_APPEND_DESCRIPTION,
|
|
26
|
+
tier: 'villager',
|
|
27
|
+
builtin: true,
|
|
28
|
+
},
|
|
29
|
+
// model_predict lands here when L3 (use a trained model as a tool) ships.
|
|
30
|
+
];
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
import { tool } from 'ai';
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { appendRows, datasetExists } from '../utils/dataset-workspace.js';
|
|
18
|
+
import { DATASET_APPEND_DESCRIPTION } from './builtin-tools-registry.js';
|
|
19
|
+
|
|
20
|
+
export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Build the native built-in tools for an agent.
|
|
@@ -25,12 +28,7 @@ import { appendRows, datasetExists } from '../utils/dataset-workspace.js';
|
|
|
25
28
|
export function getBuiltinTools(agentName, config) {
|
|
26
29
|
return {
|
|
27
30
|
dataset_append: tool({
|
|
28
|
-
description:
|
|
29
|
-
'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
|
|
30
|
-
'The dataset must already exist (created via the Studio Lab or ' +
|
|
31
|
-
'`myvillage dataset create`). For classification provide `label`; for ' +
|
|
32
|
-
'regression provide a numeric `target`. Data stays local; provenance ' +
|
|
33
|
-
'is recorded automatically.',
|
|
31
|
+
description: DATASET_APPEND_DESCRIPTION,
|
|
34
32
|
inputSchema: z.object({
|
|
35
33
|
datasetId: z.string().describe('Slug of the local dataset to append to'),
|
|
36
34
|
rows: z
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `myvillage model *` — train and use local models for the Studio Lab.
|
|
3
|
+
*
|
|
4
|
+
* Training runs in-process on TF.js (CPU). This is the single training engine;
|
|
5
|
+
* the Studio drives these same commands and streams the progress they emit
|
|
6
|
+
* (with --json, one progress line per epoch, then a final result object).
|
|
7
|
+
*/
|
|
8
|
+
import { brand, success, error, info, header, villageSpinner } from '../utils/brand.js';
|
|
9
|
+
import { readManifest as readDatasetManifest, datasetExists } from '../utils/dataset-workspace.js';
|
|
10
|
+
import { trainModel, predictWithModel, TRAINABLE_MODALITIES } from '../utils/model-trainer.js';
|
|
11
|
+
import { saveModel, listModels, readModelManifest, modelExists, loadModel } from '../utils/model-workspace.js';
|
|
12
|
+
|
|
13
|
+
// ── train ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export async function modelTrainCommand(options) {
|
|
16
|
+
try {
|
|
17
|
+
const datasetSlug = options.dataset;
|
|
18
|
+
if (!datasetSlug) throw new Error('--dataset <slug> is required.');
|
|
19
|
+
if (!datasetExists(datasetSlug)) throw new Error(`Dataset "${datasetSlug}" not found.`);
|
|
20
|
+
const ds = readDatasetManifest(datasetSlug);
|
|
21
|
+
if (!TRAINABLE_MODALITIES.includes(ds.modality)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Training "${ds.modality}" datasets is coming soon — v1 supports ${TRAINABLE_MODALITIES.join(' and ')}.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const name = options.name || `${ds.name} model`;
|
|
27
|
+
const epochs = options.epochs ? parseInt(options.epochs, 10) : 40;
|
|
28
|
+
|
|
29
|
+
// Progress: stream JSON lines when --json (Studio parses these); else spinner.
|
|
30
|
+
const spinner = options.json ? null : villageSpinner('Training…').start();
|
|
31
|
+
const onProgress = (p) => {
|
|
32
|
+
if (options.json) {
|
|
33
|
+
process.stdout.write(JSON.stringify({ type: 'progress', ...p }) + '\n');
|
|
34
|
+
} else {
|
|
35
|
+
const metric =
|
|
36
|
+
p.valAccuracy != null ? `val acc ${(p.valAccuracy * 100).toFixed(1)}%`
|
|
37
|
+
: p.valMae != null ? `val MAE ${p.valMae}`
|
|
38
|
+
: `loss ${p.loss}`;
|
|
39
|
+
spinner.text = `Training… epoch ${p.epoch}/${p.totalEpochs} · ${metric}`;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const { model, record } = await trainModel(datasetSlug, ds, { epochs, onProgress });
|
|
44
|
+
const manifest = await saveModel(name, {
|
|
45
|
+
model,
|
|
46
|
+
record,
|
|
47
|
+
datasetSlug,
|
|
48
|
+
datasetVersion: ds.currentVersion || 0,
|
|
49
|
+
});
|
|
50
|
+
if (spinner) spinner.stop();
|
|
51
|
+
|
|
52
|
+
if (options.json) {
|
|
53
|
+
process.stdout.write(JSON.stringify({ type: 'result', model: manifest }) + '\n');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
success(`Trained "${manifest.name}" (${manifest.slug}) from ${record.examplesUsed} examples`);
|
|
57
|
+
const m = record.metrics;
|
|
58
|
+
if (m.accuracy != null) info(` accuracy: ${(m.accuracy * 100).toFixed(1)}% over ${m.classes} classes`);
|
|
59
|
+
if (m.mae != null) info(` MAE: ${m.mae} · R²: ${m.r2}`);
|
|
60
|
+
info(` test it: myvillage model predict ${manifest.slug} --input "..."`);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (options.json) process.stdout.write(JSON.stringify({ type: 'error', error: e.message }) + '\n');
|
|
63
|
+
else error(e.message);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── list ───────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export async function modelListCommand(options) {
|
|
71
|
+
try {
|
|
72
|
+
const models = listModels();
|
|
73
|
+
if (options.json) {
|
|
74
|
+
console.log(JSON.stringify(models, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (models.length === 0) {
|
|
78
|
+
info('No models yet. Train one with: myvillage model train --dataset <slug>');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
header('Local models');
|
|
82
|
+
for (const m of models) {
|
|
83
|
+
const score =
|
|
84
|
+
m.metrics?.accuracy != null ? `${(m.metrics.accuracy * 100).toFixed(0)}% acc`
|
|
85
|
+
: m.metrics?.mae != null ? `MAE ${m.metrics.mae}`
|
|
86
|
+
: '—';
|
|
87
|
+
console.log(` ${brand.gold(m.slug.padEnd(28))} ${String(m.taskType).padEnd(15)} ${String(score).padStart(10)} ← ${m.datasetSlug}`);
|
|
88
|
+
}
|
|
89
|
+
console.log('');
|
|
90
|
+
} catch (e) {
|
|
91
|
+
error(e.message);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── show ───────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export async function modelShowCommand(slug, options) {
|
|
99
|
+
try {
|
|
100
|
+
const m = readModelManifest(slug);
|
|
101
|
+
if (!m) throw new Error(`Model "${slug}" not found.`);
|
|
102
|
+
if (options.json) {
|
|
103
|
+
console.log(JSON.stringify(m, null, 2));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
header(`${m.name} (${m.slug})`);
|
|
107
|
+
info(` task: ${m.taskType} · modality: ${m.modality} · trained on: ${m.datasetSlug} (v${m.datasetVersion})`);
|
|
108
|
+
info(` examples: ${m.examplesUsed} · format: ${m.format}`);
|
|
109
|
+
if (m.metrics?.accuracy != null) info(` accuracy: ${(m.metrics.accuracy * 100).toFixed(1)}% over ${m.metrics.classes} classes`);
|
|
110
|
+
if (m.metrics?.mae != null) info(` MAE: ${m.metrics.mae} · R²: ${m.metrics.r2}`);
|
|
111
|
+
if (m.labels?.length) info(` labels: ${m.labels.join(', ')}`);
|
|
112
|
+
console.log('');
|
|
113
|
+
} catch (e) {
|
|
114
|
+
error(e.message);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── predict ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
export async function modelPredictCommand(slug, options) {
|
|
122
|
+
try {
|
|
123
|
+
if (!modelExists(slug)) throw new Error(`Model "${slug}" not found.`);
|
|
124
|
+
if (options.input === undefined) throw new Error('--input <value> is required.');
|
|
125
|
+
const record = readModelManifest(slug);
|
|
126
|
+
const model = await loadModel(slug);
|
|
127
|
+
const result = predictWithModel(model, record, options.input);
|
|
128
|
+
if (options.json) {
|
|
129
|
+
console.log(JSON.stringify(result, null, 2));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (result.label != null) {
|
|
133
|
+
success(`Prediction: ${result.label}`);
|
|
134
|
+
const ranked = Object.entries(result.scores).sort((a, b) => b[1] - a[1]);
|
|
135
|
+
for (const [label, score] of ranked) info(` ${label}: ${(score * 100).toFixed(1)}%`);
|
|
136
|
+
} else {
|
|
137
|
+
success(`Prediction: ${result.score}`);
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
error(e.message);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/index.js
CHANGED
|
@@ -125,6 +125,12 @@ import {
|
|
|
125
125
|
datasetShowCommand,
|
|
126
126
|
datasetFreezeCommand,
|
|
127
127
|
} from './commands/dataset.js';
|
|
128
|
+
import {
|
|
129
|
+
modelTrainCommand,
|
|
130
|
+
modelListCommand,
|
|
131
|
+
modelShowCommand,
|
|
132
|
+
modelPredictCommand,
|
|
133
|
+
} from './commands/model.js';
|
|
128
134
|
|
|
129
135
|
const require = createRequire(import.meta.url);
|
|
130
136
|
const pkg = require('../package.json');
|
|
@@ -893,5 +899,38 @@ export function run() {
|
|
|
893
899
|
.option('--json', 'Output raw JSON')
|
|
894
900
|
.action(datasetFreezeCommand);
|
|
895
901
|
|
|
902
|
+
// ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
|
|
903
|
+
const modelCmd = program
|
|
904
|
+
.command('model')
|
|
905
|
+
.description('Train and use local models for the Studio Lab (runs on your machine)');
|
|
906
|
+
|
|
907
|
+
modelCmd
|
|
908
|
+
.command('train')
|
|
909
|
+
.description('Train a model from a local dataset (tabular or text in v1)')
|
|
910
|
+
.requiredOption('--dataset <slug>', 'Dataset slug to train on')
|
|
911
|
+
.option('--name <name>', 'Name for the resulting model')
|
|
912
|
+
.option('--epochs <n>', 'Training epochs (default 40)')
|
|
913
|
+
.option('--json', 'Stream progress as JSON lines, then a final result object')
|
|
914
|
+
.action(modelTrainCommand);
|
|
915
|
+
|
|
916
|
+
modelCmd
|
|
917
|
+
.command('list')
|
|
918
|
+
.description('List local trained models')
|
|
919
|
+
.option('--json', 'Output raw JSON')
|
|
920
|
+
.action(modelListCommand);
|
|
921
|
+
|
|
922
|
+
modelCmd
|
|
923
|
+
.command('show <slug>')
|
|
924
|
+
.description('Show a model: task, metrics, training dataset')
|
|
925
|
+
.option('--json', 'Output raw JSON')
|
|
926
|
+
.action(modelShowCommand);
|
|
927
|
+
|
|
928
|
+
modelCmd
|
|
929
|
+
.command('predict <slug>')
|
|
930
|
+
.description('Run a prediction with a trained model')
|
|
931
|
+
.requiredOption('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
|
|
932
|
+
.option('--json', 'Output raw JSON')
|
|
933
|
+
.action(modelPredictCommand);
|
|
934
|
+
|
|
896
935
|
program.parse();
|
|
897
936
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local model trainer for the Studio "Lab" pillar.
|
|
3
|
+
*
|
|
4
|
+
* Runs IN NODE on TensorFlow.js (CPU backend) — no GPU, no native binary, no
|
|
5
|
+
* downloads — so it works headless from `myvillage model train` AND when the
|
|
6
|
+
* Studio shells out to that same command. This is the single training engine;
|
|
7
|
+
* the Studio is just a front-door that streams the progress it emits.
|
|
8
|
+
*
|
|
9
|
+
* v1 modalities (zero downloaded backbones):
|
|
10
|
+
* - tabular: numeric feature vectors → standardized → dense head
|
|
11
|
+
* - text: hashing vectorizer (pure JS) → dense head
|
|
12
|
+
* image/audio need a bundled vision/audio backbone and land in a later slice.
|
|
13
|
+
*
|
|
14
|
+
* Transfer-learning-style split: featurize once, then train a small head. The
|
|
15
|
+
* featurization params travel in the model record so predict() needs no data.
|
|
16
|
+
*/
|
|
17
|
+
import * as tf from '@tensorflow/tfjs';
|
|
18
|
+
import { readRows } from './dataset-workspace.js';
|
|
19
|
+
|
|
20
|
+
const TEXT_DIM = 1024;
|
|
21
|
+
export const TRAINABLE_MODALITIES = ['tabular', 'text'];
|
|
22
|
+
|
|
23
|
+
// ── Featurization ──────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function tokenize(s) {
|
|
26
|
+
return String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// FNV-1a → bucket. Stable across runs/machines (no Math.random).
|
|
30
|
+
function hashToken(t) {
|
|
31
|
+
let h = 2166136261 >>> 0;
|
|
32
|
+
for (let i = 0; i < t.length; i++) {
|
|
33
|
+
h ^= t.charCodeAt(i);
|
|
34
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return h >>> 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function vectorizeText(s) {
|
|
40
|
+
const v = new Array(TEXT_DIM).fill(0);
|
|
41
|
+
for (const tok of tokenize(s)) v[hashToken(tok) % TEXT_DIM] += 1;
|
|
42
|
+
let norm = 0;
|
|
43
|
+
for (let i = 0; i < TEXT_DIM; i++) norm += v[i] * v[i];
|
|
44
|
+
norm = Math.sqrt(norm);
|
|
45
|
+
if (norm > 0) for (let i = 0; i < TEXT_DIM; i++) v[i] /= norm;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toNumericVector(features) {
|
|
50
|
+
if (Array.isArray(features)) return features.map(Number);
|
|
51
|
+
if (typeof features === 'string') {
|
|
52
|
+
return features.split(',').map((n) => Number(n.trim()));
|
|
53
|
+
}
|
|
54
|
+
throw new Error('Tabular features must be a numeric vector.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build the feature matrix + the featurization params to persist. */
|
|
58
|
+
function featurize(rows, modality) {
|
|
59
|
+
if (modality === 'text') {
|
|
60
|
+
return { X: rows.map((r) => vectorizeText(r.features)), featurization: { modality, dim: TEXT_DIM } };
|
|
61
|
+
}
|
|
62
|
+
// tabular: standardize each column (store mean/std for predict)
|
|
63
|
+
const raw = rows.map((r) => toNumericVector(r.features));
|
|
64
|
+
const dim = raw[0].length;
|
|
65
|
+
if (raw.some((v) => v.length !== dim)) {
|
|
66
|
+
throw new Error('All tabular rows must have the same number of features.');
|
|
67
|
+
}
|
|
68
|
+
const mean = new Array(dim).fill(0);
|
|
69
|
+
const std = new Array(dim).fill(0);
|
|
70
|
+
for (const v of raw) for (let j = 0; j < dim; j++) mean[j] += v[j];
|
|
71
|
+
for (let j = 0; j < dim; j++) mean[j] /= raw.length;
|
|
72
|
+
for (const v of raw) for (let j = 0; j < dim; j++) std[j] += (v[j] - mean[j]) ** 2;
|
|
73
|
+
for (let j = 0; j < dim; j++) std[j] = Math.sqrt(std[j] / raw.length) || 1;
|
|
74
|
+
const X = raw.map((v) => v.map((x, j) => (x - mean[j]) / std[j]));
|
|
75
|
+
return { X, featurization: { modality, dim, mean, std } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Featurize a single input the same way (for predict). */
|
|
79
|
+
export function featurizeOne(input, featurization) {
|
|
80
|
+
if (featurization.modality === 'text') return vectorizeText(input);
|
|
81
|
+
const v = toNumericVector(input);
|
|
82
|
+
return v.map((x, j) => (x - featurization.mean[j]) / (featurization.std[j] || 1));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Model ──────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function buildModel(inputDim, task, numClasses) {
|
|
88
|
+
const model = tf.sequential();
|
|
89
|
+
model.add(tf.layers.dense({ inputShape: [inputDim], units: 32, activation: 'relu' }));
|
|
90
|
+
model.add(tf.layers.dropout({ rate: 0.2 }));
|
|
91
|
+
if (task === 'classification') {
|
|
92
|
+
model.add(tf.layers.dense({ units: numClasses, activation: 'softmax' }));
|
|
93
|
+
// one-hot targets + categoricalCrossentropy (avoids int32/sparse dtype traps)
|
|
94
|
+
model.compile({ optimizer: tf.train.adam(0.01), loss: 'categoricalCrossentropy', metrics: ['accuracy'] });
|
|
95
|
+
} else {
|
|
96
|
+
model.add(tf.layers.dense({ units: 1 }));
|
|
97
|
+
model.compile({ optimizer: tf.train.adam(0.01), loss: 'meanSquaredError', metrics: ['mae'] });
|
|
98
|
+
}
|
|
99
|
+
return model;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Train ──────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Train a model from a local dataset's working set.
|
|
106
|
+
* @returns {{ model: tf.LayersModel, record: object }} record holds everything
|
|
107
|
+
* predict() needs (featurization, labels/target stats, metrics).
|
|
108
|
+
*/
|
|
109
|
+
export async function trainModel(slug, manifest, { epochs = 40, onProgress } = {}) {
|
|
110
|
+
await tf.setBackend('cpu');
|
|
111
|
+
await tf.ready();
|
|
112
|
+
|
|
113
|
+
if (!TRAINABLE_MODALITIES.includes(manifest.modality)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Training for "${manifest.modality}" datasets is coming soon — v1 supports ${TRAINABLE_MODALITIES.join(' and ')}.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const rows = readRows(slug, {});
|
|
119
|
+
if (rows.length < 4) throw new Error('Need at least 4 examples to train (more is better).');
|
|
120
|
+
|
|
121
|
+
const task = manifest.taskType;
|
|
122
|
+
const { X, featurization } = featurize(rows, manifest.modality);
|
|
123
|
+
const inputDim = X[0].length;
|
|
124
|
+
|
|
125
|
+
// Build targets: one-hot rows (classification) or standardized scalars (regression)
|
|
126
|
+
let labels = [], targetMean = null, targetStd = null, numClasses = 0;
|
|
127
|
+
let Y;
|
|
128
|
+
if (task === 'classification') {
|
|
129
|
+
labels = [...new Set(rows.map((r) => String(r.label)))].sort();
|
|
130
|
+
if (labels.length < 2) throw new Error('Classification needs at least 2 distinct labels.');
|
|
131
|
+
const idx = new Map(labels.map((l, i) => [l, i]));
|
|
132
|
+
numClasses = labels.length;
|
|
133
|
+
Y = rows.map((r) => {
|
|
134
|
+
const o = new Array(numClasses).fill(0);
|
|
135
|
+
o[idx.get(String(r.label))] = 1;
|
|
136
|
+
return o;
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
const t = rows.map((r) => Number(r.target));
|
|
140
|
+
targetMean = t.reduce((a, b) => a + b, 0) / t.length;
|
|
141
|
+
targetStd = Math.sqrt(t.reduce((a, b) => a + (b - targetMean) ** 2, 0) / t.length) || 1;
|
|
142
|
+
Y = t.map((v) => (v - targetMean) / targetStd);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// train/val split in plain JS (explicit val rows, else hold out the last ~20%)
|
|
146
|
+
const explicitVal = rows.map((r, i) => (r.split === 'val' ? i : -1)).filter((i) => i >= 0);
|
|
147
|
+
let valSet;
|
|
148
|
+
if (explicitVal.length >= 2 && explicitVal.length < rows.length) {
|
|
149
|
+
valSet = new Set(explicitVal);
|
|
150
|
+
} else {
|
|
151
|
+
const n = Math.max(1, Math.floor(rows.length * 0.2));
|
|
152
|
+
valSet = new Set(rows.map((_, i) => i).slice(rows.length - n));
|
|
153
|
+
}
|
|
154
|
+
const trI = [], vaI = [];
|
|
155
|
+
rows.forEach((_, i) => (valSet.has(i) ? vaI : trI).push(i));
|
|
156
|
+
if (trI.length === 0) { trI.push(...vaI); vaI.length = 0; }
|
|
157
|
+
|
|
158
|
+
const pick = (arr, idxs) => idxs.map((i) => arr[i]);
|
|
159
|
+
const mkY = (vals) => (task === 'classification' ? tf.tensor2d(vals) : tf.tensor1d(vals));
|
|
160
|
+
const trainXs = tf.tensor2d(pick(X, trI));
|
|
161
|
+
const trainYs = mkY(pick(Y, trI));
|
|
162
|
+
const valXs = vaI.length ? tf.tensor2d(pick(X, vaI)) : null;
|
|
163
|
+
const valYs = vaI.length ? mkY(pick(Y, vaI)) : null;
|
|
164
|
+
|
|
165
|
+
const model = buildModel(inputDim, task, numClasses);
|
|
166
|
+
const fitConfig = {
|
|
167
|
+
epochs,
|
|
168
|
+
shuffle: true,
|
|
169
|
+
callbacks: {
|
|
170
|
+
onEpochEnd: (epoch, logs) => {
|
|
171
|
+
onProgress?.({
|
|
172
|
+
epoch: epoch + 1,
|
|
173
|
+
totalEpochs: epochs,
|
|
174
|
+
loss: round(logs.loss),
|
|
175
|
+
valLoss: round(logs.val_loss),
|
|
176
|
+
accuracy: round(logs.acc ?? logs.accuracy),
|
|
177
|
+
valAccuracy: round(logs.val_acc ?? logs.val_accuracy),
|
|
178
|
+
mae: round(logs.mae),
|
|
179
|
+
valMae: round(logs.val_mae),
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
if (valXs) fitConfig.validationData = [valXs, valYs];
|
|
185
|
+
await model.fit(trainXs, trainYs, fitConfig);
|
|
186
|
+
|
|
187
|
+
// Metrics over the eval set (val if we have one, else the training set).
|
|
188
|
+
const evalXs = valXs || trainXs;
|
|
189
|
+
const evalYs = valYs || trainYs;
|
|
190
|
+
const metrics = computeMetrics(model, evalXs, evalYs, task, labels, targetMean, targetStd);
|
|
191
|
+
trainXs.dispose(); trainYs.dispose();
|
|
192
|
+
if (valXs) { valXs.dispose(); valYs.dispose(); }
|
|
193
|
+
|
|
194
|
+
const record = {
|
|
195
|
+
modality: manifest.modality,
|
|
196
|
+
taskType: task,
|
|
197
|
+
inputDim,
|
|
198
|
+
featurization,
|
|
199
|
+
labels: labels ?? [],
|
|
200
|
+
targetMean: targetMean ?? null,
|
|
201
|
+
targetStd: targetStd ?? null,
|
|
202
|
+
metrics,
|
|
203
|
+
hyperparams: { epochs, optimizer: 'adam', learningRate: 0.01 },
|
|
204
|
+
examplesUsed: rows.length,
|
|
205
|
+
};
|
|
206
|
+
return { model, record };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function computeMetrics(model, xs, ys, task, labels, targetMean, targetStd) {
|
|
210
|
+
return tf.tidy(() => {
|
|
211
|
+
const pred = model.predict(xs);
|
|
212
|
+
if (task === 'classification') {
|
|
213
|
+
const yhat = pred.argMax(1);
|
|
214
|
+
const ytrue = ys.argMax(1); // ys is one-hot
|
|
215
|
+
const acc = yhat.equal(ytrue).mean().arraySync();
|
|
216
|
+
return { accuracy: round(acc), classes: labels.length };
|
|
217
|
+
}
|
|
218
|
+
// regression: de-standardize, compute MAE + R²
|
|
219
|
+
const yhat = pred.flatten().mul(targetStd).add(targetMean);
|
|
220
|
+
const ytrue = ys.mul(targetStd).add(targetMean);
|
|
221
|
+
const mae = yhat.sub(ytrue).abs().mean().arraySync();
|
|
222
|
+
const ssRes = yhat.sub(ytrue).square().sum().arraySync();
|
|
223
|
+
const yMean = ytrue.mean().arraySync();
|
|
224
|
+
const ssTot = ytrue.sub(yMean).square().sum().arraySync();
|
|
225
|
+
return { mae: round(mae), r2: round(ssTot > 0 ? 1 - ssRes / ssTot : 0) };
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Run a prediction with an already-loaded model + its record. */
|
|
230
|
+
export function predictWithModel(model, record, input) {
|
|
231
|
+
return tf.tidy(() => {
|
|
232
|
+
const feats = featurizeOne(input, record.featurization);
|
|
233
|
+
const out = model.predict(tf.tensor2d([feats]));
|
|
234
|
+
if (record.taskType === 'classification') {
|
|
235
|
+
const scores = Array.from(out.dataSync());
|
|
236
|
+
let best = 0;
|
|
237
|
+
for (let i = 1; i < scores.length; i++) if (scores[i] > scores[best]) best = i;
|
|
238
|
+
const byLabel = {};
|
|
239
|
+
record.labels.forEach((l, i) => (byLabel[l] = round(scores[i])));
|
|
240
|
+
return { label: record.labels[best], scores: byLabel };
|
|
241
|
+
}
|
|
242
|
+
const z = out.dataSync()[0];
|
|
243
|
+
return { score: round(z * record.targetStd + record.targetMean) };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function round(n) {
|
|
248
|
+
return n == null || Number.isNaN(n) ? undefined : Math.round(n * 1e4) / 1e4;
|
|
249
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local trained-model store for the Studio "Lab" pillar.
|
|
3
|
+
*
|
|
4
|
+
* Models live under ~/.myvillage/models/<slug>/ — local-only, like datasets.
|
|
5
|
+
* manifest.json # TrainedModelRecord (modality, task, metrics, featurization…)
|
|
6
|
+
* model.json # tfjs topology + weight specs
|
|
7
|
+
* weights.b64 # base64 weight data
|
|
8
|
+
*
|
|
9
|
+
* Save/load go through TF.js's in-memory IO handlers (no tfjs-node needed),
|
|
10
|
+
* so this stays pure-JS and matches the trainer's CPU runtime.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { randomUUID } from 'crypto';
|
|
15
|
+
import * as tf from '@tensorflow/tfjs';
|
|
16
|
+
import { getConfigDir } from './config.js';
|
|
17
|
+
import { slugify } from './dataset-workspace.js';
|
|
18
|
+
|
|
19
|
+
const MODELS_DIR_NAME = 'models';
|
|
20
|
+
export const MODEL_SCHEMA_VERSION = '1.0.0';
|
|
21
|
+
|
|
22
|
+
export function getModelsRootDir() {
|
|
23
|
+
const dir = join(getConfigDir(), MODELS_DIR_NAME);
|
|
24
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getModelDir(slug) {
|
|
29
|
+
return join(getModelsRootDir(), slug);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function manifestPath(slug) {
|
|
33
|
+
return join(getModelDir(slug), 'manifest.json');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function modelExists(slug) {
|
|
37
|
+
return existsSync(manifestPath(slug));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readModelManifest(slug) {
|
|
41
|
+
const p = manifestPath(slug);
|
|
42
|
+
if (!existsSync(p)) return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function listModels() {
|
|
51
|
+
const root = getModelsRootDir();
|
|
52
|
+
return readdirSync(root, { withFileTypes: true })
|
|
53
|
+
.filter((e) => e.isDirectory())
|
|
54
|
+
.map((e) => readModelManifest(e.name))
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function deleteModel(slug) {
|
|
59
|
+
rmSync(getModelDir(slug), { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Persist a trained model + its record.
|
|
64
|
+
* @param {string} name human-readable model name (slug derived from it)
|
|
65
|
+
* @param {object} opts { model: tf.LayersModel, record, datasetSlug, datasetVersion }
|
|
66
|
+
* @returns {object} the saved manifest
|
|
67
|
+
*/
|
|
68
|
+
export async function saveModel(name, { model, record, datasetSlug, datasetVersion }) {
|
|
69
|
+
const slug = slugify(name);
|
|
70
|
+
if (!slug) throw new Error('Could not derive a slug from the model name.');
|
|
71
|
+
const dir = getModelDir(slug);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
let modelTopology, weightSpecs, weightB64;
|
|
75
|
+
await model.save(
|
|
76
|
+
tf.io.withSaveHandler(async (artifacts) => {
|
|
77
|
+
modelTopology = artifacts.modelTopology;
|
|
78
|
+
weightSpecs = artifacts.weightSpecs;
|
|
79
|
+
weightB64 = Buffer.from(artifacts.weightData).toString('base64');
|
|
80
|
+
return { modelArtifactsInfo: { dateSaved: new Date(), modelTopologyType: 'JSON' } };
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
writeFileSync(join(dir, 'model.json'), JSON.stringify({ modelTopology, weightSpecs }));
|
|
84
|
+
writeFileSync(join(dir, 'weights.b64'), weightB64);
|
|
85
|
+
|
|
86
|
+
const ts = new Date().toISOString();
|
|
87
|
+
const manifest = {
|
|
88
|
+
schemaVersion: MODEL_SCHEMA_VERSION,
|
|
89
|
+
id: randomUUID(),
|
|
90
|
+
slug,
|
|
91
|
+
name,
|
|
92
|
+
datasetSlug,
|
|
93
|
+
datasetVersion: datasetVersion ?? 0,
|
|
94
|
+
format: 'tfjs',
|
|
95
|
+
artifacts: ['model.json', 'weights.b64'],
|
|
96
|
+
...record,
|
|
97
|
+
status: 'ready',
|
|
98
|
+
createdAt: ts,
|
|
99
|
+
updatedAt: ts,
|
|
100
|
+
};
|
|
101
|
+
writeFileSync(manifestPath(slug), JSON.stringify(manifest, null, 2));
|
|
102
|
+
return manifest;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Load a saved tf model back into memory (for predict). */
|
|
106
|
+
export async function loadModel(slug) {
|
|
107
|
+
const dir = getModelDir(slug);
|
|
108
|
+
if (!existsSync(join(dir, 'model.json'))) throw new Error(`Model "${slug}" not found.`);
|
|
109
|
+
const { modelTopology, weightSpecs } = JSON.parse(readFileSync(join(dir, 'model.json'), 'utf-8'));
|
|
110
|
+
const buf = Buffer.from(readFileSync(join(dir, 'weights.b64'), 'utf-8'), 'base64');
|
|
111
|
+
const weightData = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
112
|
+
return tf.loadLayersModel(tf.io.fromMemory({ modelTopology, weightSpecs, weightData }));
|
|
113
|
+
}
|