@myvillage/cli 2.4.0 → 2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myvillage/cli",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,15 +14,18 @@
14
14
  export const DATASET_APPEND_DESCRIPTION =
15
15
  'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
16
16
  'The dataset must already exist (created via the Studio Lab or ' +
17
- '`myvillage dataset create`). `features` is text, a numeric vector ' +
18
- '(tabular), or a local image file path (image datasets — the file is ' +
17
+ '`myvillage dataset create`). `features` is text (words datasets); for a ' +
18
+ 'table dataset, its values in column order or an object keyed by column ' +
19
+ 'name (numbers, category text, true/false, short notes); or a local image ' +
20
+ 'file path (image datasets — the file is ' +
19
21
  'copied into the dataset). For classification provide `label`; for ' +
20
22
  'regression provide a numeric `target`. Data stays local; provenance ' +
21
23
  'is recorded automatically.';
22
24
 
23
25
  export const MODEL_PREDICT_DESCRIPTION =
24
26
  'Run a prediction with a LOCAL trained model on this machine. `input` is ' +
25
- 'text, a numeric vector (tabular), or a local image file path (image ' +
27
+ 'text (words models), the values of one table row as a list in column ' +
28
+ 'order or an object keyed by column name (table models), or a local image file path (image ' +
26
29
  'models). Returns the predicted class with per-class confidence scores ' +
27
30
  '(classification) or a numeric value (regression). The model must already ' +
28
31
  'exist (trained in the Studio Lab or via `myvillage model train`).';
@@ -65,10 +65,15 @@ export function getBuiltinTools(agentName, config) {
65
65
  .array(
66
66
  z.object({
67
67
  features: z
68
- .union([z.string(), z.array(z.number())])
68
+ .union([
69
+ z.string(),
70
+ z.array(z.union([z.number(), z.string(), z.boolean()])),
71
+ z.record(z.union([z.number(), z.string(), z.boolean()])),
72
+ ])
69
73
  .describe(
70
- 'Text content; a numeric vector for tabular data; or, for image ' +
71
- 'datasets, an absolute path to a local image file (.jpg/.png).',
74
+ 'Text content for a words dataset; for a table dataset, its values in column ' +
75
+ 'order or an object keyed by column name (numbers, category text, true/false, ' +
76
+ 'short notes); or, for image datasets, an absolute path to a local image file (.jpg/.png).',
72
77
  ),
73
78
  label: z.string().optional().describe('Class label (classification datasets)'),
74
79
  target: z.number().optional().describe('Numeric target (regression datasets)'),
@@ -122,10 +127,14 @@ export function getBuiltinTools(agentName, config) {
122
127
  inputSchema: z.object({
123
128
  modelId: z.string().describe('Slug of the local trained model'),
124
129
  input: z
125
- .union([z.string(), z.array(z.number())])
130
+ .union([
131
+ z.string(),
132
+ z.array(z.union([z.number(), z.string(), z.boolean()])),
133
+ z.record(z.union([z.number(), z.string(), z.boolean()])),
134
+ ])
126
135
  .describe(
127
- 'Text; a numeric vector for tabular models; or, for image models, ' +
128
- 'an absolute path to a local image file (.jpg/.png).',
136
+ 'Text for a words model; for a table model, its values in column order or an ' +
137
+ 'object keyed by column name; or, for image models, an absolute path to a local image file (.jpg/.png).',
129
138
  ),
130
139
  }),
131
140
  execute: async ({ modelId, input }) => {
@@ -103,7 +103,7 @@ export async function datasetAppendCommand(options) {
103
103
  rows = readRowsFromFile(options.file);
104
104
  } else if (options.text !== undefined || options.features !== undefined) {
105
105
  const row = { split: options.split || 'train' };
106
- row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality) : options.text;
106
+ row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality, manifest) : options.text;
107
107
  if (options.label !== undefined) row.label = options.label;
108
108
  if (options.target !== undefined) row.target = Number(options.target);
109
109
  rows = [row];
@@ -115,6 +115,7 @@ export async function datasetAppendCommand(options) {
115
115
  throw new Error('--replace only works with --file (it swaps in the whole working set).');
116
116
  }
117
117
  const featureNames = options.featureNames !== undefined ? parseNameList(options.featureNames) : undefined;
118
+ const featureTypes = options.featureTypes !== undefined ? parseNameList(options.featureTypes) : undefined;
118
119
 
119
120
  const spinner = options.json ? null : villageSpinner('Appending rows...').start();
120
121
  let result;
@@ -122,6 +123,7 @@ export async function datasetAppendCommand(options) {
122
123
  result = appendRows(slug, rows, { source: options.source || 'cli' }, {
123
124
  replace: Boolean(options.replace),
124
125
  featureNames,
126
+ featureTypes,
125
127
  });
126
128
  } finally {
127
129
  if (spinner) spinner.stop();
@@ -224,7 +226,8 @@ export async function datasetRowUpdateCommand(options) {
224
226
  if (!options.id) throw new Error('--id <rowId> is required.');
225
227
  const updates = {};
226
228
  if (options.features !== undefined) {
227
- updates.features = parseFeatures(options.features, readManifest(slug)?.modality);
229
+ const m = readManifest(slug);
230
+ updates.features = parseFeatures(options.features, m?.modality, m);
228
231
  }
229
232
  else if (options.text !== undefined) updates.features = options.text;
230
233
  if (options.label !== undefined) updates.label = options.label;
@@ -274,12 +277,20 @@ function parseNameList(raw) {
274
277
  return trimmed.split(',').map((s) => s.trim());
275
278
  }
276
279
 
277
- function parseFeatures(raw, modality) {
280
+ function parseFeatures(raw, modality, manifest) {
278
281
  // Tabular features are a numeric vector, passed as JSON ("[12, 45]") or a
279
282
  // comma list ("12, 45" or a single "12"). Anything that isn't a number stays
280
283
  // NaN so row validation rejects it rather than storing it as text.
281
284
  if (modality !== 'tabular') return raw;
282
285
  const trimmed = raw.trim();
286
+ // Typed tables take JSON: a list in column order or an object by column name.
287
+ if (manifest?.featureTypes?.some((t) => t !== 'number')) {
288
+ try {
289
+ return JSON.parse(trimmed);
290
+ } catch {
291
+ throw new Error('This table has typed columns; pass --features as JSON, e.g. \'{"Role": "student", "Hours": 3}\'.');
292
+ }
293
+ }
283
294
  if (trimmed.startsWith('[')) {
284
295
  try {
285
296
  return JSON.parse(trimmed);
package/src/index.js CHANGED
@@ -1006,13 +1006,14 @@ export function run() {
1006
1006
  .option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
1007
1007
  .option('--image <path>', 'Image file to add (image datasets); copied into the dataset')
1008
1008
  .option('--text <text>', 'Single-row text features')
1009
- .option('--features <value>', 'Single-row features (string, or numeric vector for tabular)')
1009
+ .option('--features <value>', 'Single-row features (string; numbers for a table, or JSON for a typed table)')
1010
1010
  .option('--label <label>', 'Class label (classification)')
1011
1011
  .option('--target <number>', 'Numeric target (regression)')
1012
1012
  .option('--split <split>', 'train | val', 'train')
1013
1013
  .option('--replace', 'With --file: replace the working set instead of adding to it')
1014
1014
  .option('--source <source>', 'Provenance source stamped on each row (e.g. import:csv)')
1015
1015
  .option('--feature-names <list>', 'Tabular column names (JSON array or comma-separated)')
1016
+ .option('--feature-types <list>', 'Tabular column types, same order: number | category | boolean | text')
1016
1017
  .option('--json', 'Output raw JSON')
1017
1018
  .action(datasetAppendCommand);
1018
1019
 
@@ -1045,7 +1046,7 @@ export function run() {
1045
1046
  .requiredOption('--dataset <slug>', 'Target dataset slug')
1046
1047
  .requiredOption('--id <rowId>', 'Row id to update')
1047
1048
  .option('--text <text>', 'New text features')
1048
- .option('--features <value>', 'New features (string, or numeric vector for tabular)')
1049
+ .option('--features <value>', 'New features (string; numbers for a table, or JSON for a typed table)')
1049
1050
  .option('--label <label>', 'New class label (classification)')
1050
1051
  .option('--target <number>', 'New numeric target (regression)')
1051
1052
  .option('--split <split>', 'train | val')
@@ -1081,7 +1082,7 @@ export function run() {
1081
1082
  modelCmd
1082
1083
  .command('predict <slug>')
1083
1084
  .description('Run a prediction with a trained model')
1084
- .option('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
1085
+ .option('--input <value>', 'Input: text; comma-separated numbers for a table; or JSON for a typed table')
1085
1086
  .option('--image <path>', 'Image file to classify (image models)')
1086
1087
  .option('--json', 'Output raw JSON')
1087
1088
  .action(modelPredictCommand);
@@ -30,6 +30,7 @@ import {
30
30
  import { join, extname, basename } from 'path';
31
31
  import { randomUUID } from 'crypto';
32
32
  import { getConfigDir } from './config.js';
33
+ import { columnTypesOf, normalizeTypedFeatures, validateColumnTypes } from './table-columns.js';
33
34
 
34
35
  const DATASETS_DIR_NAME = 'datasets';
35
36
  export const DATASET_SCHEMA_VERSION = '1.0.0';
@@ -205,26 +206,34 @@ export function createDataset({ name, modality, taskType = 'classification', lab
205
206
  * Every row is validated before anything is written, so a bad row leaves the
206
207
  * dataset untouched. With `replace`, the working set is overwritten instead of
207
208
  * extended (frozen versions are never touched) and the label list is rebuilt
208
- * from the new rows. `featureNames` names the columns of a tabular vector
209
- * (e.g. from a spreadsheet header); once set, every tabular row must have
210
- * exactly that many numbers.
209
+ * from the new rows. `featureNames` names the columns of a table (tabular)
210
+ * dataset and `featureTypes` optionally types them (number / category /
211
+ * boolean / text, see table-columns.js); without types every column is a
212
+ * number, as before. Rows may then give their values as a list in column
213
+ * order or as an object keyed by column name. Once set, every row must fit
214
+ * those columns.
211
215
  *
212
216
  * @param {string} slug
213
217
  * @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
214
218
  * @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
215
- * @param {{replace?:boolean, featureNames?:string[]}} opts
219
+ * @param {{replace?:boolean, featureNames?:string[], featureTypes?:string[]}} opts
216
220
  * @returns {{datasetId:string, appended:number, replaced:boolean, stats:object}}
217
221
  */
218
- export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames } = {}) {
222
+ export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames, featureTypes } = {}) {
219
223
  const manifest = readManifest(slug);
220
224
  if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
221
225
  if (!Array.isArray(rows) || rows.length === 0) {
222
226
  throw new Error('No rows to append.');
223
227
  }
228
+ if (featureTypes !== undefined && featureNames === undefined) {
229
+ throw new Error('Column types need column names too.');
230
+ }
224
231
  if (featureNames !== undefined) {
225
232
  applyFeatureNames(manifest, featureNames, replace);
233
+ applyFeatureTypes(manifest, featureTypes, replace);
226
234
  } else if (replace) {
227
235
  delete manifest.featureNames;
236
+ delete manifest.featureTypes;
228
237
  }
229
238
 
230
239
  const existing = replace ? [] : readRows(slug);
@@ -272,8 +281,8 @@ export function appendRows(slug, rows, provenance = {}, { replace = false, featu
272
281
  }
273
282
 
274
283
  /** Swap the whole working set for `rows` (e.g. a spreadsheet import). */
275
- export function replaceRows(slug, rows, provenance = {}, { featureNames } = {}) {
276
- return appendRows(slug, rows, provenance, { replace: true, featureNames });
284
+ export function replaceRows(slug, rows, provenance = {}, { featureNames, featureTypes } = {}) {
285
+ return appendRows(slug, rows, provenance, { replace: true, featureNames, featureTypes });
277
286
  }
278
287
 
279
288
  /** Set a tabular dataset's column names, refusing a mismatch on append. */
@@ -286,7 +295,7 @@ function applyFeatureNames(manifest, featureNames, replace) {
286
295
  throw new Error('Feature names must be non-empty.');
287
296
  }
288
297
  const current = manifest.featureNames;
289
- if (!replace && current?.length && current.join('\u0000') !== names.join('\u0000')) {
298
+ if (!replace && manifest.stats.rowCount > 0 && current?.length && current.join('\u0000') !== names.join('\u0000')) {
290
299
  throw new Error(
291
300
  `These columns don't match the dataset. It expects: ${current.join(', ')}.`,
292
301
  );
@@ -294,6 +303,24 @@ function applyFeatureNames(manifest, featureNames, replace) {
294
303
  manifest.featureNames = names;
295
304
  }
296
305
 
306
+ /** Set (replace) or check (append) a table's column types, alongside its names. */
307
+ function applyFeatureTypes(manifest, featureTypes, replace) {
308
+ const incoming = featureTypes === undefined ? undefined : featureTypes.map((t) => String(t).trim());
309
+ if (incoming) validateColumnTypes(incoming, manifest.featureNames);
310
+ // An empty dataset takes whatever columns the first rows bring.
311
+ if (replace || manifest.stats.rowCount === 0) {
312
+ if (incoming && incoming.some((t) => t !== 'number')) manifest.featureTypes = incoming;
313
+ else delete manifest.featureTypes;
314
+ return;
315
+ }
316
+ const current = columnTypesOf(manifest) ?? manifest.featureNames.map(() => 'number');
317
+ const want = incoming ?? manifest.featureNames.map(() => 'number');
318
+ if (current.join('\u0000') !== want.join('\u0000')) {
319
+ const describe = manifest.featureNames.map((n, k) => `${n} (${current[k]})`).join(', ');
320
+ throw new Error(`These columns don't match the dataset. It expects: ${describe}.`);
321
+ }
322
+ }
323
+
297
324
  function normalizeRow(raw, manifest, width) {
298
325
  if (raw == null || typeof raw !== 'object') {
299
326
  throw new Error('Each row must be an object.');
@@ -316,8 +343,16 @@ function normalizeRow(raw, manifest, width) {
316
343
  );
317
344
  }
318
345
  }
319
- if (manifest.modality === 'tabular') {
320
- const f = raw.features;
346
+ let features = raw.features;
347
+ const types = manifest.modality === 'tabular' ? columnTypesOf(manifest) : null;
348
+ if (types) {
349
+ features = normalizeTypedFeatures(raw.features, manifest.featureNames, types);
350
+ } else if (manifest.modality === 'tabular') {
351
+ // An object keyed by column name is fine for a named all-numbers table too.
352
+ if (features && typeof features === 'object' && !Array.isArray(features) && manifest.featureNames?.length) {
353
+ features = normalizeTypedFeatures(features, manifest.featureNames, manifest.featureNames.map(() => 'number'));
354
+ }
355
+ const f = features;
321
356
  if (!Array.isArray(f) || f.length === 0 || f.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
322
357
  throw new Error('Tabular rows need features that are all numbers (e.g. [12, 45, 8]).');
323
358
  }
@@ -328,7 +363,7 @@ function normalizeRow(raw, manifest, width) {
328
363
  if (manifest.modality === 'text' && (typeof raw.features !== 'string' || !raw.features.trim())) {
329
364
  throw new Error('Text rows need some text.');
330
365
  }
331
- const row = { features: raw.features, split };
366
+ const row = { features, split };
332
367
  if (manifest.taskType === 'classification') {
333
368
  if (raw.label == null || String(raw.label).length === 0) {
334
369
  throw new Error('Classification rows require a "label".');
@@ -7,7 +7,8 @@
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
14
  * audio needs a bundled audio backbone and lands in a later slice.
@@ -20,34 +21,21 @@
20
21
  import * as tf from '@tensorflow/tfjs';
21
22
  import { readRows, getAssetPath } from './dataset-workspace.js';
22
23
  import { embedImage, imageFeaturization } from './image-backbone.js';
24
+ import {
25
+ columnTypesOf,
26
+ fitTableFeaturization,
27
+ featurizeTableRow,
28
+ normalizeTypedFeatures,
29
+ vectorizeText as vectorizeWords,
30
+ } from './table-columns.js';
23
31
 
24
32
  const TEXT_DIM = 1024;
25
33
  export const TRAINABLE_MODALITIES = ['tabular', 'text', 'image'];
26
34
 
27
35
  // ── Featurization ──────────────────────────────────────
28
36
 
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
37
  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;
38
+ return vectorizeWords(s, TEXT_DIM);
51
39
  }
52
40
 
53
41
  function toNumericVector(features) {
@@ -68,7 +56,7 @@ function imagePathOf(features, slug) {
68
56
 
69
57
  /** Build the feature matrix + the featurization params to persist. Async to
70
58
  * allow the image backbone to embed each example off disk. */
71
- async function featurize(rows, modality, slug) {
59
+ async function featurize(rows, modality, slug, manifest) {
72
60
  if (modality === 'image') {
73
61
  const X = [];
74
62
  for (const r of rows) X.push(await embedImage(imagePathOf(r.features, slug)));
@@ -77,6 +65,14 @@ async function featurize(rows, modality, slug) {
77
65
  if (modality === 'text') {
78
66
  return { X: rows.map((r) => vectorizeText(r.features)), featurization: { modality, dim: TEXT_DIM } };
79
67
  }
68
+ // typed table: each column featurized by its type (featurization.columns)
69
+ const types = columnTypesOf(manifest);
70
+ if (types) {
71
+ const values = rows.map((r) => normalizeTypedFeatures(r.features, manifest.featureNames, types));
72
+ const featurization = fitTableFeaturization(values, manifest.featureNames, types);
73
+ if (featurization.dim === 0) throw new Error('None of the columns give the model anything to learn from.');
74
+ return { X: values.map((v) => featurizeTableRow(v, featurization)), featurization };
75
+ }
80
76
  // tabular: standardize each column (store mean/std for predict)
81
77
  const raw = rows.map((r) => toNumericVector(r.features));
82
78
  const dim = raw[0].length;
@@ -103,6 +99,19 @@ export async function featurizeOne(input, featurization) {
103
99
  return embedImage(path);
104
100
  }
105
101
  if (featurization.modality === 'text') return vectorizeText(input);
102
+ if (featurization.columns) {
103
+ const names = featurization.columns.map((c) => c.name);
104
+ const types = featurization.columns.map((c) => c.type);
105
+ let raw = input;
106
+ if (typeof raw === 'string') {
107
+ try {
108
+ raw = JSON.parse(raw); // CLI --input '{"Role":"student", ...}' or '[3, "student"]'
109
+ } catch {
110
+ throw new Error(`Send the values as JSON, for example {${names.map((n) => JSON.stringify(n) + ': …').join(', ')}}.`);
111
+ }
112
+ }
113
+ return featurizeTableRow(normalizeTypedFeatures(raw, names, types), featurization);
114
+ }
106
115
  const v = toNumericVector(input);
107
116
  return v.map((x, j) => (x - featurization.mean[j]) / (featurization.std[j] || 1));
108
117
  }
@@ -144,7 +153,7 @@ export async function trainModel(slug, manifest, { epochs = 40, onProgress } = {
144
153
  if (rows.length < 4) throw new Error('Need at least 4 examples to train (more is better).');
145
154
 
146
155
  const task = manifest.taskType;
147
- const { X, featurization } = await featurize(rows, manifest.modality, slug);
156
+ const { X, featurization } = await featurize(rows, manifest.modality, slug, manifest);
148
157
  const inputDim = X[0].length;
149
158
 
150
159
  // 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
+ }