@myvillage/cli 2.3.0 → 2.4.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.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "MyVillageOS CLI for community developers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) : options.text;
106
+ row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality) : 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];
@@ -111,15 +111,27 @@ export async function datasetAppendCommand(options) {
111
111
  throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
112
112
  }
113
113
 
114
+ if (options.replace && !options.file) {
115
+ throw new Error('--replace only works with --file (it swaps in the whole working set).');
116
+ }
117
+ const featureNames = options.featureNames !== undefined ? parseNameList(options.featureNames) : undefined;
118
+
114
119
  const spinner = options.json ? null : villageSpinner('Appending rows...').start();
115
- const result = appendRows(slug, rows, { source: 'cli' });
116
- if (spinner) spinner.stop();
120
+ let result;
121
+ try {
122
+ result = appendRows(slug, rows, { source: options.source || 'cli' }, {
123
+ replace: Boolean(options.replace),
124
+ featureNames,
125
+ });
126
+ } finally {
127
+ if (spinner) spinner.stop();
128
+ }
117
129
 
118
130
  if (options.json) {
119
131
  console.log(JSON.stringify(result, null, 2));
120
132
  return;
121
133
  }
122
- success(`Appended ${result.appended} row(s) to ${slug}`);
134
+ success(`${result.replaced ? 'Replaced the working set with' : 'Appended'} ${result.appended} row(s) ${result.replaced ? 'in' : 'to'} ${slug}`);
123
135
  info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
124
136
  } catch (e) {
125
137
  error(e.message);
@@ -133,8 +145,13 @@ export async function datasetShowCommand(slug, options) {
133
145
  try {
134
146
  const manifest = readManifest(slug);
135
147
  if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
136
- const limit = options.limit ? parseInt(options.limit, 10) : 5;
137
- const sample = readRows(slug, { limit });
148
+ // --limit 0 returns every row (used by Studio's export).
149
+ const limit = options.limit !== undefined ? parseInt(options.limit, 10) : 5;
150
+ const version = options.frozen !== undefined ? parseInt(options.frozen, 10) : undefined;
151
+ if (version !== undefined && !manifest.versions.some((v) => v.version === version)) {
152
+ throw new Error(`Dataset "${slug}" has no frozen v${options.frozen}.`);
153
+ }
154
+ const sample = readRows(slug, { limit, version });
138
155
  if (options.json) {
139
156
  console.log(JSON.stringify({ manifest, sample }, null, 2));
140
157
  return;
@@ -206,7 +223,9 @@ export async function datasetRowUpdateCommand(options) {
206
223
  if (!slug) throw new Error('--dataset <slug> is required.');
207
224
  if (!options.id) throw new Error('--id <rowId> is required.');
208
225
  const updates = {};
209
- if (options.features !== undefined) updates.features = parseFeatures(options.features);
226
+ if (options.features !== undefined) {
227
+ updates.features = parseFeatures(options.features, readManifest(slug)?.modality);
228
+ }
210
229
  else if (options.text !== undefined) updates.features = options.text;
211
230
  if (options.label !== undefined) updates.label = options.label;
212
231
  if (options.target !== undefined) updates.target = Number(options.target);
@@ -244,8 +263,22 @@ function readRowsFromFile(path) {
244
263
  return rows;
245
264
  }
246
265
 
247
- function parseFeatures(raw) {
248
- // tabular features may be passed as a comma/JSON numeric vector
266
+ /** A JSON array of names, or a comma-separated list. */
267
+ function parseNameList(raw) {
268
+ const trimmed = String(raw).trim();
269
+ if (trimmed.startsWith('[')) {
270
+ const parsed = JSON.parse(trimmed);
271
+ if (!Array.isArray(parsed)) throw new Error('--feature-names must be a list.');
272
+ return parsed.map(String);
273
+ }
274
+ return trimmed.split(',').map((s) => s.trim());
275
+ }
276
+
277
+ function parseFeatures(raw, modality) {
278
+ // Tabular features are a numeric vector, passed as JSON ("[12, 45]") or a
279
+ // comma list ("12, 45" or a single "12"). Anything that isn't a number stays
280
+ // NaN so row validation rejects it rather than storing it as text.
281
+ if (modality !== 'tabular') return raw;
249
282
  const trimmed = raw.trim();
250
283
  if (trimmed.startsWith('[')) {
251
284
  try {
@@ -254,10 +287,7 @@ function parseFeatures(raw) {
254
287
  /* fall through */
255
288
  }
256
289
  }
257
- if (/^-?\d.*,/.test(trimmed)) {
258
- return trimmed.split(',').map((n) => Number(n.trim()));
259
- }
260
- return raw;
290
+ return trimmed.split(',').map((n) => (n.trim() === '' ? NaN : Number(n.trim())));
261
291
  }
262
292
 
263
293
  function truncate(s, n) {
package/src/index.js CHANGED
@@ -1010,13 +1010,17 @@ export function run() {
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
+ .option('--replace', 'With --file: replace the working set instead of adding to it')
1014
+ .option('--source <source>', 'Provenance source stamped on each row (e.g. import:csv)')
1015
+ .option('--feature-names <list>', 'Tabular column names (JSON array or comma-separated)')
1013
1016
  .option('--json', 'Output raw JSON')
1014
1017
  .action(datasetAppendCommand);
1015
1018
 
1016
1019
  datasetCmd
1017
1020
  .command('show <slug>')
1018
1021
  .description('Show a dataset manifest and a sample of rows')
1019
- .option('--limit <n>', 'Number of sample rows to show', '5')
1022
+ .option('--limit <n>', 'Number of sample rows to show (0 = all)', '5')
1023
+ .option('--frozen <n>', 'Read frozen version n instead of the working set')
1020
1024
  .option('--json', 'Output raw JSON')
1021
1025
  .action(datasetShowCommand);
1022
1026
 
@@ -202,23 +202,50 @@ export function createDataset({ name, modality, taskType = 'classification', lab
202
202
  * Append rows to a dataset's working set, stamping provenance, validating
203
203
  * against the dataset's modality/task, and updating manifest stats.
204
204
  *
205
+ * Every row is validated before anything is written, so a bad row leaves the
206
+ * dataset untouched. With `replace`, the working set is overwritten instead of
207
+ * 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.
211
+ *
205
212
  * @param {string} slug
206
213
  * @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
207
214
  * @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
208
- * @returns {{datasetId:string, appended:number, stats:object}}
215
+ * @param {{replace?:boolean, featureNames?:string[]}} opts
216
+ * @returns {{datasetId:string, appended:number, replaced:boolean, stats:object}}
209
217
  */
210
- export function appendRows(slug, rows, provenance = {}) {
218
+ export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames } = {}) {
211
219
  const manifest = readManifest(slug);
212
220
  if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
213
221
  if (!Array.isArray(rows) || rows.length === 0) {
214
222
  throw new Error('No rows to append.');
215
223
  }
216
- const source = provenance.source || 'cli';
217
- const lines = [];
218
- let appended = 0;
224
+ if (featureNames !== undefined) {
225
+ applyFeatureNames(manifest, featureNames, replace);
226
+ } else if (replace) {
227
+ delete manifest.featureNames;
228
+ }
219
229
 
220
- for (const raw of rows) {
221
- const row = normalizeRow(raw, manifest);
230
+ const existing = replace ? [] : readRows(slug);
231
+ // Without named columns, hold tabular rows to the width already on disk
232
+ // (the trainer needs every vector the same length).
233
+ let width = manifest.featureNames?.length;
234
+ if (width == null && manifest.modality === 'tabular') {
235
+ const first = existing.find((r) => Array.isArray(r.features));
236
+ if (first) width = first.features.length;
237
+ }
238
+
239
+ const source = provenance.source || 'cli';
240
+ const newRows = [];
241
+ rows.forEach((raw, i) => {
242
+ let row;
243
+ try {
244
+ row = normalizeRow(raw, manifest, width);
245
+ } catch (e) {
246
+ throw new Error(rows.length > 1 ? `Row ${i + 1}: ${e.message}` : e.message);
247
+ }
248
+ if (width == null && Array.isArray(row.features)) width = row.features.length;
222
249
  row.id = randomUUID();
223
250
  row.provenance = {
224
251
  source,
@@ -227,24 +254,47 @@ export function appendRows(slug, rows, provenance = {}) {
227
254
  ...(provenance.villageId ? { villageId: provenance.villageId } : {}),
228
255
  ts: nowISO(),
229
256
  };
230
- lines.push(JSON.stringify(row));
257
+ newRows.push(row);
258
+ });
231
259
 
232
- // stats
233
- manifest.stats.rowCount += 1;
234
- manifest.stats.bySplit[row.split] += 1;
235
- if (row.label != null) {
236
- manifest.stats.byLabel[row.label] = (manifest.stats.byLabel[row.label] || 0) + 1;
237
- if (!manifest.labels.includes(row.label)) manifest.labels.push(row.label);
238
- }
239
- appended += 1;
260
+ if (replace) manifest.labels = [];
261
+ for (const row of newRows) {
262
+ if (row.label != null && !manifest.labels.includes(row.label)) manifest.labels.push(row.label);
263
+ }
264
+ if (replace) {
265
+ writeWorkingSet(slug, manifest, newRows);
266
+ } else {
267
+ appendFileSync(getCurrentDataFile(slug), newRows.map((r) => JSON.stringify(r)).join('\n') + '\n');
268
+ manifest.stats = recomputeStats([...existing, ...newRows]);
269
+ writeManifest(slug, manifest);
240
270
  }
271
+ return { datasetId: slug, appended: newRows.length, replaced: replace, stats: manifest.stats };
272
+ }
241
273
 
242
- appendFileSync(getCurrentDataFile(slug), lines.join('\n') + '\n');
243
- writeManifest(slug, manifest);
244
- return { datasetId: slug, appended, stats: manifest.stats };
274
+ /** 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 });
277
+ }
278
+
279
+ /** Set a tabular dataset's column names, refusing a mismatch on append. */
280
+ function applyFeatureNames(manifest, featureNames, replace) {
281
+ if (manifest.modality !== 'tabular') {
282
+ throw new Error('Feature names only apply to tabular datasets.');
283
+ }
284
+ const names = featureNames.map((n) => String(n).trim());
285
+ if (names.length === 0 || names.some((n) => !n)) {
286
+ throw new Error('Feature names must be non-empty.');
287
+ }
288
+ const current = manifest.featureNames;
289
+ if (!replace && current?.length && current.join('\u0000') !== names.join('\u0000')) {
290
+ throw new Error(
291
+ `These columns don't match the dataset. It expects: ${current.join(', ')}.`,
292
+ );
293
+ }
294
+ manifest.featureNames = names;
245
295
  }
246
296
 
247
- function normalizeRow(raw, manifest) {
297
+ function normalizeRow(raw, manifest, width) {
248
298
  if (raw == null || typeof raw !== 'object') {
249
299
  throw new Error('Each row must be an object.');
250
300
  }
@@ -266,6 +316,18 @@ function normalizeRow(raw, manifest) {
266
316
  );
267
317
  }
268
318
  }
319
+ if (manifest.modality === 'tabular') {
320
+ const f = raw.features;
321
+ if (!Array.isArray(f) || f.length === 0 || f.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
322
+ throw new Error('Tabular rows need features that are all numbers (e.g. [12, 45, 8]).');
323
+ }
324
+ if (width != null && f.length !== width) {
325
+ throw new Error(`Expected ${width} number(s) in each row, got ${f.length}.`);
326
+ }
327
+ }
328
+ if (manifest.modality === 'text' && (typeof raw.features !== 'string' || !raw.features.trim())) {
329
+ throw new Error('Text rows need some text.');
330
+ }
269
331
  const row = { features: raw.features, split };
270
332
  if (manifest.taskType === 'classification') {
271
333
  if (raw.label == null || String(raw.label).length === 0) {
@@ -273,7 +335,7 @@ function normalizeRow(raw, manifest) {
273
335
  }
274
336
  row.label = String(raw.label);
275
337
  } else {
276
- if (raw.target == null || Number.isNaN(Number(raw.target))) {
338
+ if (raw.target == null || raw.target === '' || !Number.isFinite(Number(raw.target))) {
277
339
  throw new Error('Regression rows require a numeric "target".');
278
340
  }
279
341
  row.target = Number(raw.target);
@@ -376,6 +438,7 @@ export function updateRow(slug, rowId, updates = {}) {
376
438
  if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
377
439
  throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
378
440
  }
441
+ const sibling = rows.find((r, i) => i !== idx && Array.isArray(r.features));
379
442
  const merged = normalizeRow(
380
443
  {
381
444
  features: updates.features !== undefined ? updates.features : existing.features,
@@ -384,6 +447,7 @@ export function updateRow(slug, rowId, updates = {}) {
384
447
  target: updates.target !== undefined ? updates.target : existing.target,
385
448
  },
386
449
  manifest,
450
+ manifest.featureNames?.length ?? sibling?.features.length,
387
451
  );
388
452
  merged.id = existing.id;
389
453
  merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };