@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.
@@ -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 needs a bundled audio backbone and lands in a later slice.
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 — so the
18
- * embedding can always be recomputed at predict time from the same weights.)
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
- const v = new Array(TEXT_DIM).fill(0);
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 imagePathOf(features, slug) {
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('Image rows must reference an image asset.');
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(imagePathOf(r.features, slug)));
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 path runs the backbone; text/tabular resolve synchronously. */
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 — v1 supports ${TRAINABLE_MODALITIES.join(' and ')}.`,
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)