@myvillage/cli 2.4.0 → 2.6.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 +8 -5
- package/src/agent-runtime/builtin-tools.js +28 -12
- package/src/commands/dataset.js +21 -9
- package/src/commands/model.js +9 -4
- package/src/index.js +6 -3
- package/src/utils/audio-backbone.js +194 -0
- package/src/utils/dataset-workspace.js +46 -11
- package/src/utils/model-trainer.js +56 -34
- package/src/utils/table-columns.js +162 -0
- package/vendor/backbones/yamnet/SOURCE.md +22 -0
- package/vendor/backbones/yamnet/group1-shard1of4.bin +0 -0
- package/vendor/backbones/yamnet/group1-shard2of4.bin +0 -0
- package/vendor/backbones/yamnet/group1-shard3of4.bin +0 -0
- package/vendor/backbones/yamnet/group1-shard4of4.bin +0 -0
- package/vendor/backbones/yamnet/model.json +1 -0
|
@@ -7,47 +7,37 @@
|
|
|
7
7
|
* the Studio is just a front-door that streams the progress it emits.
|
|
8
8
|
*
|
|
9
9
|
* v1 modalities:
|
|
10
|
-
* - tabular: numeric feature vectors → standardized → dense head
|
|
10
|
+
* - tabular: numeric feature vectors → standardized → dense head; typed
|
|
11
|
+
* tables (numbers, categories, yes/no, notes) → see table-columns.js
|
|
11
12
|
* - text: hashing vectorizer (pure JS) → dense head
|
|
12
13
|
* - image: bundled MobileNetV3-Small backbone → 1024-d embedding → dense head
|
|
13
|
-
* audio
|
|
14
|
+
* - audio: WAV → 16 kHz → bundled YAMNet backbone → averaged 1024-d embedding → dense head
|
|
14
15
|
*
|
|
15
16
|
* Transfer-learning-style split: featurize once, then train a small head. The
|
|
16
17
|
* featurization params travel in the model record so predict() needs no data.
|
|
17
|
-
* (For image, the backbone itself is bundled — see image-backbone.js
|
|
18
|
-
* embedding can always be recomputed at predict
|
|
18
|
+
* (For image and audio, the backbone itself is bundled — see image-backbone.js
|
|
19
|
+
* and audio-backbone.js — so the embedding can always be recomputed at predict
|
|
20
|
+
* time from the same weights.)
|
|
19
21
|
*/
|
|
20
22
|
import * as tf from '@tensorflow/tfjs';
|
|
21
23
|
import { readRows, getAssetPath } from './dataset-workspace.js';
|
|
22
24
|
import { embedImage, imageFeaturization } from './image-backbone.js';
|
|
25
|
+
import { embedAudio, audioFeaturization } from './audio-backbone.js';
|
|
26
|
+
import {
|
|
27
|
+
columnTypesOf,
|
|
28
|
+
fitTableFeaturization,
|
|
29
|
+
featurizeTableRow,
|
|
30
|
+
normalizeTypedFeatures,
|
|
31
|
+
vectorizeText as vectorizeWords,
|
|
32
|
+
} from './table-columns.js';
|
|
23
33
|
|
|
24
34
|
const TEXT_DIM = 1024;
|
|
25
|
-
export const TRAINABLE_MODALITIES = ['tabular', 'text', 'image'];
|
|
35
|
+
export const TRAINABLE_MODALITIES = ['tabular', 'text', 'image', 'audio'];
|
|
26
36
|
|
|
27
37
|
// ── Featurization ──────────────────────────────────────
|
|
28
38
|
|
|
29
|
-
function tokenize(s) {
|
|
30
|
-
return String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// FNV-1a → bucket. Stable across runs/machines (no Math.random).
|
|
34
|
-
function hashToken(t) {
|
|
35
|
-
let h = 2166136261 >>> 0;
|
|
36
|
-
for (let i = 0; i < t.length; i++) {
|
|
37
|
-
h ^= t.charCodeAt(i);
|
|
38
|
-
h = Math.imul(h, 16777619) >>> 0;
|
|
39
|
-
}
|
|
40
|
-
return h >>> 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
function vectorizeText(s) {
|
|
44
|
-
|
|
45
|
-
for (const tok of tokenize(s)) v[hashToken(tok) % TEXT_DIM] += 1;
|
|
46
|
-
let norm = 0;
|
|
47
|
-
for (let i = 0; i < TEXT_DIM; i++) norm += v[i] * v[i];
|
|
48
|
-
norm = Math.sqrt(norm);
|
|
49
|
-
if (norm > 0) for (let i = 0; i < TEXT_DIM; i++) v[i] /= norm;
|
|
50
|
-
return v;
|
|
40
|
+
return vectorizeWords(s, TEXT_DIM);
|
|
51
41
|
}
|
|
52
42
|
|
|
53
43
|
function toNumericVector(features) {
|
|
@@ -58,25 +48,38 @@ function toNumericVector(features) {
|
|
|
58
48
|
throw new Error('Tabular features must be a numeric vector.');
|
|
59
49
|
}
|
|
60
50
|
|
|
61
|
-
/** Resolve an image row's features to an absolute file path on disk. */
|
|
62
|
-
function
|
|
51
|
+
/** Resolve an image or sound row's features to an absolute file path on disk. */
|
|
52
|
+
function assetPathOf(features, slug, what = 'Image') {
|
|
63
53
|
// Rows store an AssetRef {kind,path}; path is relative to the dataset assets/.
|
|
64
54
|
const rel = typeof features === 'string' ? features : features?.path;
|
|
65
|
-
if (!rel) throw new Error(
|
|
55
|
+
if (!rel) throw new Error(`${what} rows must reference a${what === 'Image' ? 'n image' : ' sound'} asset.`);
|
|
66
56
|
return getAssetPath(slug, rel);
|
|
67
57
|
}
|
|
68
58
|
|
|
69
59
|
/** Build the feature matrix + the featurization params to persist. Async to
|
|
70
60
|
* allow the image backbone to embed each example off disk. */
|
|
71
|
-
async function featurize(rows, modality, slug) {
|
|
61
|
+
async function featurize(rows, modality, slug, manifest) {
|
|
72
62
|
if (modality === 'image') {
|
|
73
63
|
const X = [];
|
|
74
|
-
for (const r of rows) X.push(await embedImage(
|
|
64
|
+
for (const r of rows) X.push(await embedImage(assetPathOf(r.features, slug)));
|
|
75
65
|
return { X, featurization: imageFeaturization() };
|
|
76
66
|
}
|
|
67
|
+
if (modality === 'audio') {
|
|
68
|
+
const X = [];
|
|
69
|
+
for (const r of rows) X.push(await embedAudio(assetPathOf(r.features, slug, 'Sound')));
|
|
70
|
+
return { X, featurization: audioFeaturization() };
|
|
71
|
+
}
|
|
77
72
|
if (modality === 'text') {
|
|
78
73
|
return { X: rows.map((r) => vectorizeText(r.features)), featurization: { modality, dim: TEXT_DIM } };
|
|
79
74
|
}
|
|
75
|
+
// typed table: each column featurized by its type (featurization.columns)
|
|
76
|
+
const types = columnTypesOf(manifest);
|
|
77
|
+
if (types) {
|
|
78
|
+
const values = rows.map((r) => normalizeTypedFeatures(r.features, manifest.featureNames, types));
|
|
79
|
+
const featurization = fitTableFeaturization(values, manifest.featureNames, types);
|
|
80
|
+
if (featurization.dim === 0) throw new Error('None of the columns give the model anything to learn from.');
|
|
81
|
+
return { X: values.map((v) => featurizeTableRow(v, featurization)), featurization };
|
|
82
|
+
}
|
|
80
83
|
// tabular: standardize each column (store mean/std for predict)
|
|
81
84
|
const raw = rows.map((r) => toNumericVector(r.features));
|
|
82
85
|
const dim = raw[0].length;
|
|
@@ -94,7 +97,7 @@ async function featurize(rows, modality, slug) {
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
/** Featurize a single input the same way (for predict). Async because the
|
|
97
|
-
* image
|
|
100
|
+
* image and sound paths run a backbone; text/tabular resolve synchronously. */
|
|
98
101
|
export async function featurizeOne(input, featurization) {
|
|
99
102
|
if (featurization.modality === 'image') {
|
|
100
103
|
// input is an absolute image file path (CLI --image / model_predict tool).
|
|
@@ -102,7 +105,26 @@ export async function featurizeOne(input, featurization) {
|
|
|
102
105
|
if (!path) throw new Error('Image prediction needs an image file path.');
|
|
103
106
|
return embedImage(path);
|
|
104
107
|
}
|
|
108
|
+
if (featurization.modality === 'audio') {
|
|
109
|
+
// input is an absolute WAV file path (CLI --audio / model_predict tool).
|
|
110
|
+
const path = typeof input === 'string' ? input : input?.path;
|
|
111
|
+
if (!path) throw new Error('Sound prediction needs a WAV file path.');
|
|
112
|
+
return embedAudio(path);
|
|
113
|
+
}
|
|
105
114
|
if (featurization.modality === 'text') return vectorizeText(input);
|
|
115
|
+
if (featurization.columns) {
|
|
116
|
+
const names = featurization.columns.map((c) => c.name);
|
|
117
|
+
const types = featurization.columns.map((c) => c.type);
|
|
118
|
+
let raw = input;
|
|
119
|
+
if (typeof raw === 'string') {
|
|
120
|
+
try {
|
|
121
|
+
raw = JSON.parse(raw); // CLI --input '{"Role":"student", ...}' or '[3, "student"]'
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error(`Send the values as JSON, for example {${names.map((n) => JSON.stringify(n) + ': …').join(', ')}}.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return featurizeTableRow(normalizeTypedFeatures(raw, names, types), featurization);
|
|
127
|
+
}
|
|
106
128
|
const v = toNumericVector(input);
|
|
107
129
|
return v.map((x, j) => (x - featurization.mean[j]) / (featurization.std[j] || 1));
|
|
108
130
|
}
|
|
@@ -137,14 +159,14 @@ export async function trainModel(slug, manifest, { epochs = 40, onProgress } = {
|
|
|
137
159
|
|
|
138
160
|
if (!TRAINABLE_MODALITIES.includes(manifest.modality)) {
|
|
139
161
|
throw new Error(
|
|
140
|
-
`Training for "${manifest.modality}" datasets is coming soon —
|
|
162
|
+
`Training for "${manifest.modality}" datasets is coming soon — this version supports ${TRAINABLE_MODALITIES.join(', ')}.`,
|
|
141
163
|
);
|
|
142
164
|
}
|
|
143
165
|
const rows = readRows(slug, {});
|
|
144
166
|
if (rows.length < 4) throw new Error('Need at least 4 examples to train (more is better).');
|
|
145
167
|
|
|
146
168
|
const task = manifest.taskType;
|
|
147
|
-
const { X, featurization } = await featurize(rows, manifest.modality, slug);
|
|
169
|
+
const { X, featurization } = await featurize(rows, manifest.modality, slug, manifest);
|
|
148
170
|
const inputDim = X[0].length;
|
|
149
171
|
|
|
150
172
|
// Build targets: one-hot rows (classification) or standardized scalars (regression)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed columns for "table" (tabular) datasets.
|
|
3
|
+
*
|
|
4
|
+
* A table dataset names its columns (manifest.featureNames) and, optionally,
|
|
5
|
+
* gives each a type (manifest.featureTypes, same order):
|
|
6
|
+
*
|
|
7
|
+
* number 3, 4.5 scaled to mean 0 / std 1 for training
|
|
8
|
+
* category "student" one slot per value seen in training (one-hot)
|
|
9
|
+
* boolean true / false 1 or 0
|
|
10
|
+
* text "I worked on…" hashed words, like a words dataset (short notes)
|
|
11
|
+
*
|
|
12
|
+
* A row's features are its values in column order: [3, "student", true, "…"].
|
|
13
|
+
* Datasets with no featureTypes are all numbers, exactly as before, so every
|
|
14
|
+
* existing dataset and model keeps working unchanged.
|
|
15
|
+
*
|
|
16
|
+
* The featurization here must match myvillage-studio packages/lab-runtime
|
|
17
|
+
* (myvillage-model.js), which runs these models outside the CLI; its parity
|
|
18
|
+
* test trains with this code and compares answers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const COLUMN_TYPES = ['number', 'category', 'boolean', 'text'];
|
|
22
|
+
|
|
23
|
+
/** Hash-bucket width for a writing column inside a table (a words dataset uses 1024). */
|
|
24
|
+
export const TABLE_TEXT_DIM = 256;
|
|
25
|
+
/** Most distinct values a category column one-hot encodes; the rest count as "other" (all zeros). */
|
|
26
|
+
export const MAX_CATEGORY_VALUES = 50;
|
|
27
|
+
|
|
28
|
+
const TRUE_WORDS = new Set(['true', 'yes', 'y', '1', 't']);
|
|
29
|
+
const FALSE_WORDS = new Set(['false', 'no', 'n', '0', 'f']);
|
|
30
|
+
|
|
31
|
+
/** The dataset's column types, or null for a plain all-numbers table. */
|
|
32
|
+
export function columnTypesOf(manifest) {
|
|
33
|
+
const t = manifest?.featureTypes;
|
|
34
|
+
return Array.isArray(t) && t.length && t.some((x) => x !== 'number') ? t : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Check a featureTypes list against its names. */
|
|
38
|
+
export function validateColumnTypes(types, names) {
|
|
39
|
+
if (!Array.isArray(types) || types.length === 0) throw new Error('Column types must be a list.');
|
|
40
|
+
const bad = types.find((t) => !COLUMN_TYPES.includes(t));
|
|
41
|
+
if (bad !== undefined) throw new Error(`Unknown column type "${bad}". Use: ${COLUMN_TYPES.join(', ')}.`);
|
|
42
|
+
if (names && names.length !== types.length) {
|
|
43
|
+
throw new Error(`There are ${names.length} column names but ${types.length} column types.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function coerce(value, type, name) {
|
|
48
|
+
const label = name ? `"${name}"` : 'A value';
|
|
49
|
+
if (type === 'number') {
|
|
50
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value.trim()) : NaN;
|
|
51
|
+
if (!Number.isFinite(n)) throw new Error(`${label} needs a number.`);
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
if (type === 'boolean') {
|
|
55
|
+
if (typeof value === 'boolean') return value;
|
|
56
|
+
const w = String(value ?? '').trim().toLowerCase();
|
|
57
|
+
if (TRUE_WORDS.has(w)) return true;
|
|
58
|
+
if (FALSE_WORDS.has(w)) return false;
|
|
59
|
+
throw new Error(`${label} needs yes or no (true/false).`);
|
|
60
|
+
}
|
|
61
|
+
// category / text: any scalar, kept as trimmed text; blank is allowed.
|
|
62
|
+
if (value !== null && value !== undefined && typeof value === 'object') {
|
|
63
|
+
throw new Error(`${label} needs a single value, not a list or object.`);
|
|
64
|
+
}
|
|
65
|
+
return value === null || value === undefined ? '' : String(value).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Turn a raw typed-table row (a list in column order, or an object keyed by
|
|
70
|
+
* column name) into the stored list of values. Throws a message a person can act on.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeTypedFeatures(raw, names, types) {
|
|
73
|
+
let values;
|
|
74
|
+
if (Array.isArray(raw)) {
|
|
75
|
+
values = raw;
|
|
76
|
+
} else if (raw && typeof raw === 'object') {
|
|
77
|
+
values = names.map((n) => {
|
|
78
|
+
if (!(n in raw)) throw new Error(`Missing "${n}".`);
|
|
79
|
+
return raw[n];
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
throw new Error(`Table rows need a value for each column: ${names.join(', ')}.`);
|
|
83
|
+
}
|
|
84
|
+
if (values.length !== types.length) {
|
|
85
|
+
throw new Error(`Expected ${types.length} values (${names.join(', ')}), got ${values.length}.`);
|
|
86
|
+
}
|
|
87
|
+
return values.map((v, i) => coerce(v, types[i], names[i]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Featurization ─────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function tokenize(s) {
|
|
93
|
+
return String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// FNV-1a, the same stable hash words datasets use.
|
|
97
|
+
export function hashToken(t) {
|
|
98
|
+
let h = 2166136261 >>> 0;
|
|
99
|
+
for (let i = 0; i < t.length; i++) {
|
|
100
|
+
h ^= t.charCodeAt(i);
|
|
101
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
102
|
+
}
|
|
103
|
+
return h >>> 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function vectorizeText(s, dim) {
|
|
107
|
+
const v = new Array(dim).fill(0);
|
|
108
|
+
for (const tok of tokenize(s)) v[hashToken(tok) % dim] += 1;
|
|
109
|
+
let norm = 0;
|
|
110
|
+
for (let i = 0; i < dim; i++) norm += v[i] * v[i];
|
|
111
|
+
norm = Math.sqrt(norm);
|
|
112
|
+
if (norm > 0) for (let i = 0; i < dim; i++) v[i] /= norm;
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Learn how to turn each column into numbers from the training rows (already
|
|
118
|
+
* normalized). Returns the featurization record saved with the model.
|
|
119
|
+
*/
|
|
120
|
+
export function fitTableFeaturization(rows, names, types) {
|
|
121
|
+
const columns = types.map((type, j) => {
|
|
122
|
+
const col = rows.map((r) => r[j]);
|
|
123
|
+
if (type === 'number') {
|
|
124
|
+
const mean = col.reduce((a, b) => a + b, 0) / col.length;
|
|
125
|
+
const std = Math.sqrt(col.reduce((a, b) => a + (b - mean) ** 2, 0) / col.length) || 1;
|
|
126
|
+
return { name: names[j], type, mean, std };
|
|
127
|
+
}
|
|
128
|
+
if (type === 'category') {
|
|
129
|
+
const counts = new Map();
|
|
130
|
+
for (const v of col) if (v !== '') counts.set(v, (counts.get(v) || 0) + 1);
|
|
131
|
+
// Most common first, ties alphabetically, so the order is stable.
|
|
132
|
+
const values = [...counts.entries()]
|
|
133
|
+
.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
134
|
+
.slice(0, MAX_CATEGORY_VALUES)
|
|
135
|
+
.map(([v]) => v);
|
|
136
|
+
return { name: names[j], type, values };
|
|
137
|
+
}
|
|
138
|
+
if (type === 'text') return { name: names[j], type, dim: TABLE_TEXT_DIM };
|
|
139
|
+
return { name: names[j], type };
|
|
140
|
+
});
|
|
141
|
+
const dim = columns.reduce((n, c) => n + columnWidth(c), 0);
|
|
142
|
+
return { modality: 'tabular', dim, columns };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function columnWidth(c) {
|
|
146
|
+
if (c.type === 'category') return c.values.length;
|
|
147
|
+
if (c.type === 'text') return c.dim;
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One normalized row → the model's input vector. */
|
|
152
|
+
export function featurizeTableRow(values, featurization) {
|
|
153
|
+
const out = [];
|
|
154
|
+
featurization.columns.forEach((c, j) => {
|
|
155
|
+
const v = values[j];
|
|
156
|
+
if (c.type === 'number') out.push((v - c.mean) / (c.std || 1));
|
|
157
|
+
else if (c.type === 'boolean') out.push(v ? 1 : 0);
|
|
158
|
+
else if (c.type === 'category') for (const value of c.values) out.push(v === value ? 1 : 0);
|
|
159
|
+
else for (const x of vectorizeText(v, c.dim)) out.push(x);
|
|
160
|
+
});
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# YAMNet (TF.js GraphModel) — vendored backbone
|
|
2
|
+
|
|
3
|
+
Bundled sound feature-extractor for the Studio "Lab" pillar audio models.
|
|
4
|
+
**Never downloaded at runtime** — shipped inside `@myvillage/cli` so the Studio
|
|
5
|
+
stays "the only thing you install" (see ed-platform docs/architecture/studio-models-pillar.md §0b, §13).
|
|
6
|
+
|
|
7
|
+
- **Model:** YAMNet (MobileNetV1-style audio event classifier trained on AudioSet)
|
|
8
|
+
- **Input:** `waveform` — 1-D float32 mono samples at **16 kHz**, in `[-1, 1]`, any length
|
|
9
|
+
- **Outputs:** `Identity` `[frames, 521]` class scores, `Identity_1` `[frames, 1024]`
|
|
10
|
+
embeddings (what we use, averaged over frames), `Identity_2` log-mel spectrogram.
|
|
11
|
+
One frame per 0.48 s of sound (0.96 s windows); a clip shorter than one window is padded.
|
|
12
|
+
- **Format:** TensorFlow.js `graph-model` (model.json + 4 weight shards, ~16 MB, float32)
|
|
13
|
+
- **Source:** Google YAMNet TF.js (`google/tfjs-model/yamnet/tfjs/1`), TF Hub / Kaggle Models
|
|
14
|
+
(`google/yamnet`), converted by TensorFlow.js Converter v2.1.0
|
|
15
|
+
- **Downloaded:** 2026-09-24 from `https://tfhub.dev/google/tfjs-model/yamnet/tfjs/1`
|
|
16
|
+
- **SHA-256:**
|
|
17
|
+
- `model.json` 552a4e59884a7fad25dbdbc1bbe75af2a416bca44de12523081d4b55606d59d3
|
|
18
|
+
- `group1-shard1of4.bin` 2ba509dbfc875e0483e5e3f6935cd9be9f986dd1219796c649422fd337dde4ca
|
|
19
|
+
- `group1-shard2of4.bin` b789c08119ef33a534475b964c25447586e18328039c06322d4b5337887d1fca
|
|
20
|
+
- `group1-shard3of4.bin` 464a4a19cfb207c81bbd6ba079d2f36402902c07faa8b6d305f438dc0ab74212
|
|
21
|
+
- `group1-shard4of4.bin` 311fcb65f9985e825a9f162b7ef82bdc81a745bff11f90bd7b1eaabbd1f55e6b
|
|
22
|
+
- **License:** Apache-2.0 (TensorFlow Hub / Google)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|