@barivia/barsom-mcp 0.2.4 → 0.2.6

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/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * BARIVIA_API_URL as environment variables.
8
8
  *
9
9
  * Usage (in MCP client config, e.g. Cursor / Claude Desktop):
10
- *
10
+
11
11
  * {
12
12
  * "mcpServers": {
13
13
  * "analytics-engine": {
@@ -263,64 +263,142 @@ registerAppTool(server, "explore_som", {
263
263
  await tryAttachImage(content, job_id, `combined.${imgExt}`);
264
264
  return { content };
265
265
  });
266
- // ---- upload_dataset ----
267
- server.tool("upload_dataset", `Upload a CSV dataset for SOM analysis. Returns dataset metadata including ID.
268
-
269
- PREFER file_path over csv_data: when the user points to a local file, use file_path.
270
- The MCP reads the file directly no need to pass large CSV strings through the LLM.
271
-
272
- BEST FOR: Tabular data with numeric columns (sensor readings, financial data, process
273
- measurements, survey results). CSV with header row required.
274
- NOT FOR: Images, text documents, or pre-trained embeddings.
275
-
276
- TIMING: Upload is near-instant for datasets under 100MB.
277
-
278
- AFTER uploading, ask the user these questions to guide the analysis:
279
- 1. "What are you trying to discover in this data?" (clustering, anomalies, temporal patterns)
280
- 2. "Are any columns cyclic/periodic?" (hour=24, weekday=7, wind direction=360)
281
- 3. "Are any columns irrelevant or should be excluded?"
282
- 4. "Should any features be weighted more heavily?"
283
- 5. "Do any columns have very skewed distributions?" (suggest transforms)
284
-
285
- COMMON MISTAKES:
286
- - Uploading without previewing first — always use preview_dataset before train_som
287
- - Including ID columns or row indices — these add noise without meaning
288
- - Forgetting to check for datetime columns that could provide temporal features
289
-
290
- Show the column names from the response so the user can identify features.
291
- TIP: Use the prepare_training prompt for a structured preprocessing checklist.`, {
292
- name: z.string().describe("Human-readable dataset name"),
293
- file_path: z
294
- .string()
266
+ // ---- datasets ----
267
+ server.tool("datasets", `Manage datasets: upload, preview, subset, or delete.
268
+
269
+ action=upload: Upload a CSV for SOM analysis. Prefer file_path over csv_data so the MCP reads the file directly. Returns dataset ID and metadata. Then use datasets(action=preview) before train_som.
270
+ action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before train_som on an unfamiliar dataset.
271
+ action=subset: Create a new dataset from a subset of an existing one (by row range and/or column filter). Use to train on a slice (e.g. first 2000 rows, or region=Europe) without re-uploading. Requires name and at least one of row_range or filter. row_range: [start, end] 1-based inclusive. filter: { column, op, value } with op in eq, in, gt, lt, gte, lte.
272
+ action=delete: Remove a dataset and free the slot.
273
+
274
+ BEST FOR: Tabular numeric data. CSV with header required. Use list(type=datasets) to see existing datasets. To train on a subset, use datasets(action=subset) then train_som on the new dataset_id, or pass row_range in train_som params.`, {
275
+ action: z
276
+ .enum(["upload", "preview", "subset", "delete"])
277
+ .describe("upload: add a CSV; preview: inspect before training; subset: create subset dataset; delete: remove dataset"),
278
+ name: z.string().optional().describe("Dataset name (required for action=upload and subset)"),
279
+ file_path: z.string().optional().describe("Path to local CSV (for upload; prefer over csv_data)"),
280
+ csv_data: z.string().optional().describe("Inline CSV string (for upload; use for small data)"),
281
+ dataset_id: z.string().optional().describe("Dataset ID (required for preview, subset, and delete)"),
282
+ n_rows: z.number().int().optional().default(5).describe("Sample rows to return (preview only)"),
283
+ row_range: z
284
+ .tuple([z.number().int(), z.number().int()])
295
285
  .optional()
296
- .describe("Path to a local CSV file. Use this when the user has a file on disk — the MCP reads it directly. Absolute or relative to the MCP process CWD (often the project root)."),
297
- csv_data: z
298
- .string()
286
+ .describe("For subset: [start, end] 1-based inclusive row range (e.g. [1, 2000])"),
287
+ filter: z
288
+ .object({
289
+ column: z.string(),
290
+ op: z.enum(["eq", "in", "gt", "lt", "gte", "lte"]),
291
+ value: z.union([z.string(), z.number(), z.array(z.union([z.string(), z.number()]))]),
292
+ })
299
293
  .optional()
300
- .describe("CSV data with header row. Use for small inline data (<10KB). Prefer file_path for larger files."),
301
- }, async ({ name, file_path, csv_data }) => {
302
- let body;
303
- if (file_path) {
304
- const resolved = path.resolve(file_path);
305
- try {
306
- body = await fs.readFile(resolved, "utf-8");
294
+ .describe("For subset: filter rows by column value (e.g. { column: 'region', op: 'eq', value: 'Europe' })"),
295
+ }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filter }) => {
296
+ if (action === "upload") {
297
+ if (!name)
298
+ throw new Error("datasets(upload) requires name");
299
+ let body;
300
+ if (file_path) {
301
+ const resolved = path.resolve(file_path);
302
+ try {
303
+ body = await fs.readFile(resolved, "utf-8");
304
+ }
305
+ catch (err) {
306
+ const msg = err instanceof Error ? err.message : String(err);
307
+ throw new Error(`Cannot read file "${resolved}": ${msg}`);
308
+ }
307
309
  }
308
- catch (err) {
309
- const msg = err instanceof Error ? err.message : String(err);
310
- throw new Error(`Cannot read file "${resolved}": ${msg}`);
310
+ else if (csv_data && csv_data.length > 0) {
311
+ body = csv_data;
311
312
  }
313
+ else {
314
+ throw new Error("datasets(upload) requires file_path or csv_data");
315
+ }
316
+ const data = await apiCall("POST", "/v1/datasets", body, {
317
+ "X-Dataset-Name": name,
318
+ "Content-Type": "text/csv",
319
+ });
320
+ return textResult(data);
321
+ }
322
+ if (action === "preview") {
323
+ if (!dataset_id)
324
+ throw new Error("datasets(preview) requires dataset_id");
325
+ const data = (await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=${n_rows ?? 5}`));
326
+ const cols = data.columns ?? [];
327
+ const stats = data.column_stats ?? [];
328
+ const hints = data.cyclic_hints ?? [];
329
+ const samples = data.sample_rows ?? [];
330
+ const dtCols = data.datetime_columns ?? [];
331
+ const temporalSugg = data.temporal_suggestions ?? [];
332
+ const fmt = (v) => v === null || v === undefined ? "—" : Number(v).toFixed(3);
333
+ const lines = [
334
+ `Dataset: ${data.name} (${data.dataset_id})`,
335
+ `${data.total_rows} rows × ${data.total_cols} columns`,
336
+ ``,
337
+ `Column Statistics:`,
338
+ `| Column | Min | Max | Mean | Std | Nulls | Numeric |`,
339
+ `|--------|-----|-----|------|-----|-------|---------|`,
340
+ ];
341
+ for (const s of stats) {
342
+ lines.push(`| ${s.column} | ${fmt(s.min)} | ${fmt(s.max)} | ${fmt(s.mean)} | ${fmt(s.std)} | ${s.null_count ?? 0} | ${s.is_numeric !== false ? "yes" : "no"} |`);
343
+ }
344
+ if (hints.length > 0) {
345
+ lines.push(``, `Detected Cyclic Feature Hints:`);
346
+ for (const h of hints) {
347
+ lines.push(` • ${h.column} — period=${h.period} (${h.reason})`);
348
+ }
349
+ }
350
+ if (dtCols.length > 0) {
351
+ lines.push(``, `Detected Datetime Columns:`);
352
+ for (const dc of dtCols) {
353
+ const formats = dc.detected_formats ?? [];
354
+ const fmtStrs = formats
355
+ .map((f) => `${f.format} — ${f.description} (${(f.match_rate * 100).toFixed(0)}% match)`)
356
+ .join("; ");
357
+ lines.push(` • ${dc.column}: sample="${dc.sample}" → ${fmtStrs}`);
358
+ }
359
+ }
360
+ if (temporalSugg.length > 0) {
361
+ lines.push(``, `Temporal Feature Suggestions (require user approval):`);
362
+ for (const ts of temporalSugg) {
363
+ lines.push(` • Columns: ${ts.columns.join(" + ")} → format: "${ts.format}"`);
364
+ lines.push(` Available components: ${ts.available_components.join(", ")}`);
365
+ }
366
+ }
367
+ if (samples.length > 0) {
368
+ lines.push(``, `Sample Rows (first ${samples.length}):`);
369
+ lines.push(`| ${cols.join(" | ")} |`);
370
+ lines.push(`| ${cols.map(() => "---").join(" | ")} |`);
371
+ for (const row of samples) {
372
+ lines.push(`| ${cols.map((c) => String(row[c] ?? "")).join(" | ")} |`);
373
+ }
374
+ }
375
+ return { content: [{ type: "text", text: lines.join("\n") }] };
376
+ }
377
+ if (action === "subset") {
378
+ if (!dataset_id)
379
+ throw new Error("datasets(subset) requires dataset_id");
380
+ if (!name)
381
+ throw new Error("datasets(subset) requires name");
382
+ if (row_range === undefined && filter === undefined) {
383
+ throw new Error("datasets(subset) requires at least one of row_range or filter");
384
+ }
385
+ const body = { name };
386
+ if (row_range !== undefined)
387
+ body.row_range = row_range;
388
+ if (filter !== undefined)
389
+ body.filter = filter;
390
+ const data = await apiCall("POST", `/v1/datasets/${dataset_id}/subset`, JSON.stringify(body), {
391
+ "Content-Type": "application/json",
392
+ });
393
+ return textResult(data);
312
394
  }
313
- else if (csv_data && csv_data.length > 0) {
314
- body = csv_data;
315
- }
316
- else {
317
- throw new Error("Provide either file_path or csv_data");
395
+ if (action === "delete") {
396
+ if (!dataset_id)
397
+ throw new Error("datasets(delete) requires dataset_id");
398
+ const data = await apiCall("DELETE", `/v1/datasets/${dataset_id}`);
399
+ return textResult(data);
318
400
  }
319
- const data = await apiCall("POST", "/v1/datasets", body, {
320
- "X-Dataset-Name": name,
321
- "Content-Type": "text/csv",
322
- });
323
- return textResult(data);
401
+ throw new Error("Invalid action");
324
402
  });
325
403
  // ---- train_som ----
326
404
  server.tool("train_som", `Train a Self-Organizing Map on the dataset. Returns a job_id for polling.
@@ -343,11 +421,11 @@ BEFORE calling, ask the user:
343
421
  5. Quick exploration or refined map?
344
422
 
345
423
  PRESET TABLE:
346
- | preset | grid | epochs | batch_size |
347
- | quick | 15x15 | [10, 0] | 64 |
348
- | standard | 25x25 | [20, 10] | 64 |
349
- | refined | 40x40 | [40, 20] | 32 |
350
- | high_res | 60x60 | [50, 30] | 32 |
424
+ | preset | grid | epochs | batch_size |
425
+ | quick | 15x15 | [15, 5] | 48 |
426
+ | standard | 25x25 | [30, 15] | 48 |
427
+ | refined | 40x40 | [50, 25] | 32 |
428
+ | high_res | 60x60 | [60, 40] | 32 |
351
429
 
352
430
  TRAINING PHASES:
353
431
  - Ordering: large neighborhoods → global structure. sigma_f controls end-radius (default 1.0).
@@ -356,7 +434,7 @@ TRAINING PHASES:
356
434
 
357
435
  TRANSFORMS: Per-column preprocessing before normalization.
358
436
  transforms: {revenue: "log", volume: "log1p", pressure: "sqrt"}
359
- Suggest when preview_dataset shows large value ranges or right-skewed distributions.
437
+ Suggest when datasets(action=preview) shows large value ranges or right-skewed distributions.
360
438
 
361
439
  TEMPORAL FEATURES: NEVER auto-apply. Always ask which components to extract.
362
440
  temporal_features: [{columns: ['Date'], format: 'dd.mm.yyyy', extract: ['day_of_year'], cyclic: true}]
@@ -370,25 +448,25 @@ COMMON MISTAKES:
370
448
  - Not log-transforming skewed columns → a few outliers dominate the normalization.
371
449
  - Using default batch_size for quality-sensitive work: set batch_size=32–64 for sharper maps.
372
450
  - Skipping convergence phase: ordering alone gives rough structure; convergence refines it.
373
- - Not checking get_training_log: if QE is still dropping, add more epochs.
451
+ - Not checking get_job_export(export="training_log"): if QE is still dropping, add more epochs.
374
452
 
375
453
  QUALITY TARGETS: QE < 1.5 good, TE < 0.05 good, explained variance > 0.8 good.
376
454
  If QE > 2 → more epochs or larger grid. If TE > 0.15 → larger grid or periodic=true.
377
455
 
378
- OUTPUT: format (png/pdf/svg), dpi (standard/retina/print), colormap (viridis/plasma/inferno).
456
+ OUTPUT: format (png/pdf/svg), dpi (standard/retina/print), colormap (e.g. viridis, plasma, inferno, magma, cividis, turbo, coolwarm, RdBu, Spectral).
379
457
 
380
458
  After training, use get_results → analyze(clusters) → component_planes → feature_correlation.
381
459
  See docs/SOM_PROCESS_AND_BEST_PRACTICES.md for detailed processual knowledge.`, {
382
- dataset_id: z.string().describe("Dataset ID from upload_dataset"),
460
+ dataset_id: z.string().describe("Dataset ID from datasets(action=upload) or list(type=datasets)"),
383
461
  preset: z
384
462
  .enum(["quick", "standard", "refined", "high_res"])
385
463
  .optional()
386
464
  .describe("Training preset — sets sensible defaults for grid, epochs, and batch_size. " +
387
465
  "Explicit params override preset values. " +
388
- "quick: 15×15, [10,0], batch=64. " +
389
- "standard: 25×25, [20,10], batch=64, best with GPU. " +
390
- "refined: 40×40, [40,20], batch=32, best with GPU. " +
391
- "high_res: 60×60, [50,30], batch=32, best with GPU."),
466
+ "quick: 15×15, [15,5], batch=48. " +
467
+ "standard: 25×25, [30,15], batch=48, best with GPU. " +
468
+ "refined: 40×40, [50,25], batch=32, best with GPU. " +
469
+ "high_res: 60×60, [60,40], batch=32, best with GPU."),
392
470
  grid_x: z
393
471
  .number()
394
472
  .int()
@@ -529,13 +607,17 @@ See docs/SOM_PROCESS_AND_BEST_PRACTICES.md for detailed processual knowledge.`,
529
607
  colormap: z
530
608
  .string()
531
609
  .optional()
532
- .describe("Override default colormap for component planes (e.g. viridis, plasma, inferno, coolwarm). U-matrix always uses grays, cyclic features use twilight."),
533
- }, async ({ dataset_id, preset, grid_x, grid_y, epochs, model, periodic, columns, transforms, cyclic_features, temporal_features, feature_weights, normalize, sigma_f, learning_rate, batch_size, backend, output_format, output_dpi, colormap, }) => {
610
+ .describe("Override default colormap for component planes and hit histogram. Examples: viridis, plasma, inferno, magma, cividis, turbo, thermal, hot, coolwarm, balance, RdBu, Spectral. U-matrix always uses grays, cyclic features use twilight."),
611
+ row_range: z
612
+ .tuple([z.number().int().min(1), z.number().int().min(1)])
613
+ .optional()
614
+ .describe("Train on a subset of rows only: [start, end] 1-based inclusive. Alternative to creating a subset dataset with datasets(action=subset)."),
615
+ }, async ({ dataset_id, preset, grid_x, grid_y, epochs, model, periodic, columns, transforms, cyclic_features, temporal_features, feature_weights, normalize, sigma_f, learning_rate, batch_size, backend, output_format, output_dpi, colormap, row_range, }) => {
534
616
  const PRESETS = {
535
- quick: { grid: [15, 15], epochs: [10, 0], batch_size: 64 },
536
- standard: { grid: [25, 25], epochs: [20, 10], batch_size: 64, backend: "cuda" },
537
- refined: { grid: [40, 40], epochs: [40, 20], batch_size: 32, backend: "cuda" },
538
- high_res: { grid: [60, 60], epochs: [50, 30], batch_size: 32, backend: "cuda" },
617
+ quick: { grid: [15, 15], epochs: [15, 5], batch_size: 48 },
618
+ standard: { grid: [25, 25], epochs: [30, 15], batch_size: 48, backend: "cuda" },
619
+ refined: { grid: [40, 40], epochs: [50, 25], batch_size: 32, backend: "cuda" },
620
+ high_res: { grid: [60, 60], epochs: [60, 40], batch_size: 32, backend: "cuda" },
539
621
  };
540
622
  const p = preset ? PRESETS[preset] : undefined;
541
623
  const params = {
@@ -598,6 +680,9 @@ See docs/SOM_PROCESS_AND_BEST_PRACTICES.md for detailed processual knowledge.`,
598
680
  if (colormap) {
599
681
  params.colormap = colormap;
600
682
  }
683
+ if (row_range && row_range.length >= 2 && row_range[0] <= row_range[1]) {
684
+ params.row_range = row_range;
685
+ }
601
686
  const data = await apiCall("POST", "/v1/jobs", { dataset_id, params });
602
687
  return textResult(data);
603
688
  });
@@ -624,6 +709,59 @@ When status is 'failed', show the error to the user and suggest parameter adjust
624
709
  }
625
710
  return { content: [{ type: "text", text }] };
626
711
  });
712
+ /** Resolve get_results figures param to list of image filenames to fetch. */
713
+ function getResultsImagesToFetch(jobType, summary, figures, includeIndividual) {
714
+ const ext = summary.output_format ?? "png";
715
+ if (jobType === "transition_flow") {
716
+ const lag = summary.lag ?? 1;
717
+ return [`transition_flow_lag${lag}.${ext}`];
718
+ }
719
+ if (jobType === "project_variable") {
720
+ const varName = summary.variable_name ?? "variable";
721
+ const safe = String(varName).replace(/[^a-zA-Z0-9_]/g, "_");
722
+ return [`projected_${safe}.${ext}`];
723
+ }
724
+ if (jobType === "derive_variable") {
725
+ const varName = summary.variable_name ?? "variable";
726
+ const safe = String(varName).replace(/[^a-zA-Z0-9_]/g, "_");
727
+ return [`projected_${safe}.${ext}`];
728
+ }
729
+ // train_som
730
+ const features = summary.features ?? [];
731
+ const combinedName = `combined.${ext}`;
732
+ const umatrixName = `umatrix.${ext}`;
733
+ const hitHistName = `hit_histogram.${ext}`;
734
+ const componentNames = features.map((f, i) => `component_${i + 1}_${f.replace(/[^a-zA-Z0-9_]/g, "_")}.${ext}`);
735
+ const allList = [combinedName, umatrixName, hitHistName, ...componentNames];
736
+ if (figures === undefined || figures === "default") {
737
+ return includeIndividual ? allList : [combinedName];
738
+ }
739
+ if (figures === "combined_only")
740
+ return [combinedName];
741
+ if (figures === "all")
742
+ return allList;
743
+ if (Array.isArray(figures)) {
744
+ const nameToFile = {
745
+ combined: combinedName,
746
+ umatrix: umatrixName,
747
+ hit_histogram: hitHistName,
748
+ };
749
+ features.forEach((_, i) => {
750
+ nameToFile[`component_${i + 1}`] = componentNames[i];
751
+ });
752
+ return figures
753
+ .map((key) => {
754
+ const k = key.trim().toLowerCase();
755
+ if (nameToFile[k])
756
+ return nameToFile[k];
757
+ if (key.includes("."))
758
+ return key;
759
+ return null;
760
+ })
761
+ .filter((f) => f != null);
762
+ }
763
+ return [combinedName];
764
+ }
627
765
  // ---- get_results ----
628
766
  server.tool("get_results", `Retrieve results of a completed SOM training, projection, or derived variable job.
629
767
 
@@ -632,11 +770,15 @@ TIMING: Near-instant (reads pre-computed results from S3).
632
770
 
633
771
  Returns: text summary with metrics and inline images (combined view and all plots shown directly in chat).
634
772
 
635
- DOWNLOAD LINKS: Links to API-domain or presigned URLs may not work when clicked (MCP holds the API key, not the browser). Images are inlined. For weights, use get_weights. For node stats, use get_node_data. If the user wants to save a file, offer to fetch and return the content via the appropriate tool.
773
+ DOWNLOAD LINKS: Links to API-domain or presigned URLs may not work when clicked (MCP holds the API key, not the browser). Images are inlined. For weights use get_job_export(export="weights"); for node stats use get_job_export(export="nodes"). If the user wants to save a file, offer to fetch via the appropriate tool.
636
774
 
637
775
  OPTIONS:
638
- - include_individual=true: shows each component plane, U-matrix, and hit histogram
639
- as separate inline images. Best for side-by-side feature comparison.
776
+ - figures: request specific plots only. Omit for default (combined only; or all if include_individual=true).
777
+ - "combined_only": only the combined view.
778
+ - "all": combined + umatrix + hit_histogram + all component planes.
779
+ - Array of logical names: e.g. figures: ["umatrix"] for just the U-matrix, or figures: ["combined","hit_histogram"] or ["combined","umatrix","component_1","component_2"]. Logical names: combined, umatrix, hit_histogram, component_1, component_2, ... (component_N = Nth feature).
780
+ - include_individual=true: when figures is omitted, shows each component plane, U-matrix, and hit histogram
781
+ as separate inline images. Ignored when figures is set.
640
782
 
641
783
  AFTER showing results, guide the user:
642
784
  1. "The U-matrix shows [N] distinct regions. Does this match expected groupings?"
@@ -647,20 +789,28 @@ AFTER showing results, guide the user:
647
789
  6. If explained variance < 0.7: suggest transforms, feature selection, or more training
648
790
 
649
791
  WORKFLOW: get_results → analyze(clusters) → component_planes → feature_correlation.
650
- Use get_training_log() for the learning curve (QE vs epoch healthy=steady decline then plateau).
651
- Use quality_report() for extended metrics (trustworthiness, neighborhood preservation).
792
+ Request specific figures with get_results(job_id, figures=[...]) (e.g. figures: ["umatrix"] or figures: ["combined","hit_histogram"]) or run analyze(job_id, analysis_type) for a single view.
793
+ Use get_job_export(export="training_log") for the learning curve (QE vs epoch — healthy=steady decline then plateau).
794
+ Use analyze(job_id, "quality_report") for extended metrics (trustworthiness, neighborhood preservation).
652
795
 
653
796
  METRIC INTERPRETATION:
654
797
  - QE < 1.5: good fit. QE > 2: consider more epochs, larger grid, or batch_size=32.
655
798
  - TE < 0.05: good topology. TE > 0.15: grid too small.
656
799
  - Explained variance > 0.8: good. < 0.7: try transforms, fewer features, or more training.`, {
657
800
  job_id: z.string().describe("Job ID of a completed job"),
801
+ figures: z
802
+ .union([
803
+ z.enum(["default", "combined_only", "all"]),
804
+ z.array(z.string()),
805
+ ])
806
+ .optional()
807
+ .describe("Which figures to return. Omit or 'default' for combined only (or all if include_individual=true). 'combined_only': just combined view. 'all': combined + umatrix + hit_histogram + all component planes. Or array of logical names: combined, umatrix, hit_histogram, component_1, component_2, ..."),
658
808
  include_individual: z
659
809
  .boolean()
660
810
  .optional()
661
811
  .default(false)
662
- .describe("If true, inline each individual plot (component planes, u-matrix, hit histogram) separately instead of just the combined view. Useful for side-by-side feature comparison or publication-quality individual figures."),
663
- }, async ({ job_id, include_individual }) => {
812
+ .describe("If true and figures is omitted, inline each individual plot (component planes, u-matrix, hit histogram). Ignored when figures is set."),
813
+ }, async ({ job_id, figures, include_individual }) => {
664
814
  const data = (await apiCall("GET", `/v1/results/${job_id}`));
665
815
  const summary = (data.summary ?? {});
666
816
  const downloadUrls = (data.download_urls ?? {});
@@ -691,8 +841,10 @@ METRIC INTERPRETATION:
691
841
  `Use transition_flow(lag=N) with larger N to reveal longer-term temporal structure.`,
692
842
  ].join("\n"),
693
843
  });
694
- await tryAttachImage(content, job_id, flowImg);
695
- inlinedImages.add(flowImg);
844
+ for (const name of getResultsImagesToFetch(jobType, summary, figures, include_individual)) {
845
+ await tryAttachImage(content, job_id, name);
846
+ inlinedImages.add(name);
847
+ }
696
848
  }
697
849
  else if (jobType === "project_variable") {
698
850
  const varName = summary.variable_name ?? "variable";
@@ -715,8 +867,10 @@ METRIC INTERPRETATION:
715
867
  `learned feature space, even if it wasn't used in training.`,
716
868
  ].join("\n"),
717
869
  });
718
- await tryAttachImage(content, job_id, projImg);
719
- inlinedImages.add(projImg);
870
+ for (const name of getResultsImagesToFetch(jobType, summary, figures, include_individual)) {
871
+ await tryAttachImage(content, job_id, name);
872
+ inlinedImages.add(name);
873
+ }
720
874
  }
721
875
  else {
722
876
  // ── Default: train_som results ──────────────────────────────────────────
@@ -747,7 +901,7 @@ METRIC INTERPRETATION:
747
901
  ` Davies-Bouldin: ${fmt(summary.davies_bouldin)} (lower is better)`,
748
902
  ` Calinski-Harabasz: ${fmt(summary.calinski_harabasz)} (higher is better)`,
749
903
  ordErrors && ordErrors.length > 0
750
- ? ` Final ordering QE: ${ordErrors.at(-1)?.toFixed(4)} (use get_training_log for full curve)`
904
+ ? ` Final ordering QE: ${ordErrors.at(-1)?.toFixed(4)} (use get_job_export(export="training_log") for full curve)`
751
905
  : "",
752
906
  ``,
753
907
  `Features: ${features.join(", ")}`,
@@ -758,34 +912,16 @@ METRIC INTERPRETATION:
758
912
  ? `Transforms: ${Object.entries(summary.transforms).map(([k, v]) => `${k}=${v}`).join(", ")}`
759
913
  : "",
760
914
  ``,
761
- `Use analyze() for deeper insights, quality_report() for extended metrics, get_training_log() for learning curves.`,
915
+ `Use analyze() for deeper insights and quality_report; get_job_export(export="training_log") for learning curves.`,
762
916
  ]
763
917
  .filter((l) => l !== "")
764
918
  .join("\n");
765
919
  content.push({ type: "text", text: textSummary });
766
920
  const imgExt = summary.output_format ?? "png";
767
- const combinedName = `combined.${imgExt}`;
768
- await tryAttachImage(content, job_id, combinedName);
769
- inlinedImages.add(combinedName);
770
- if (include_individual) {
771
- const feats = summary.features ?? [];
772
- const imageNames = [
773
- `umatrix.${imgExt}`,
774
- `hit_histogram.${imgExt}`,
775
- ...feats.map((f, i) => `component_${i + 1}_${f.replace(/[^a-zA-Z0-9_]/g, "_")}.${imgExt}`),
776
- ];
777
- const results = await Promise.allSettled(imageNames.map((name) => apiRawCall(`/v1/results/${job_id}/image/${name}`).then((r) => ({ name, ...r }))));
778
- for (const r of results) {
779
- if (r.status === "fulfilled") {
780
- content.push({
781
- type: "image",
782
- data: r.value.data.toString("base64"),
783
- mimeType: mimeForFilename(r.value.name),
784
- annotations: { audience: ["user"], priority: 0.8 },
785
- });
786
- inlinedImages.add(r.value.name);
787
- }
788
- }
921
+ const imagesToFetch = getResultsImagesToFetch(jobType, summary, figures, include_individual);
922
+ for (const name of imagesToFetch) {
923
+ await tryAttachImage(content, job_id, name);
924
+ inlinedImages.add(name);
789
925
  }
790
926
  }
791
927
  // Inline remaining image files; for JSON provide tool hints (no clickable URLs — auth required)
@@ -797,21 +933,114 @@ METRIC INTERPRETATION:
797
933
  }
798
934
  else if (fname.endsWith(".json")) {
799
935
  const hint = fname === "weights.json"
800
- ? `Use get_weights for full weight matrix including node_coords.`
936
+ ? `Use get_job_export(export="weights") for full weight matrix including node_coords.`
801
937
  : fname === "node_stats.json"
802
- ? `Use get_node_data for per-node statistics.`
938
+ ? `Use get_job_export(export="nodes") for per-node statistics.`
803
939
  : fname === "summary.json"
804
940
  ? null
805
- : `Use get_weights or get_node_data for structured data.`;
941
+ : `Use get_job_export for structured data (weights or nodes).`;
806
942
  if (hint) {
807
943
  content.push({ type: "text", text: `${fname}: ${hint}` });
808
944
  }
809
945
  }
810
946
  }
947
+ // List available artifacts so the LLM can offer to fetch specific views
948
+ if (files.length > 0) {
949
+ const features = summary.features ?? [];
950
+ const logicalNames = jobType === "train_som"
951
+ ? `Logical names: combined, umatrix, hit_histogram, ${features.map((_, i) => `component_${i + 1}`).join(", ")}. `
952
+ : "";
953
+ content.push({
954
+ type: "text",
955
+ text: `Available to fetch individually: ${files.join(", ")}. ${logicalNames}Use get_results(job_id, figures=[...]) to request specific plots, get_results(job_id, include_individual=true) or figures="all" to inline all plots, or analyze(job_id, analysis_type) for a specific view (u_matrix, component_planes, bmu_hits, clusters, quality_report, etc.).`,
956
+ });
957
+ }
958
+ return { content };
959
+ });
960
+ // ---- recolor_som ----
961
+ server.tool("recolor_som", `Re-render a completed SOM result with a different colormap — no retraining.
962
+
963
+ Use when the user wants to see the same combined (or other) plot with another color scheme (e.g. plasma, inferno, coolwarm). Submits a short render job; when complete, use get_results(new_job_id) or get_result_image to retrieve the recolored figure(s).
964
+
965
+ Colormaps: e.g. viridis, plasma, inferno, magma, cividis, turbo, thermal, hot, coolwarm, balance, RdBu, Spectral. U-matrix and cyclic panels keep fixed colormaps (grays, twilight).`, {
966
+ job_id: z.string().describe("Job ID of a completed SOM training job (parent)"),
967
+ colormap: z.string().describe("Colormap name (e.g. viridis, plasma, inferno, magma, coolwarm)"),
968
+ figures: z
969
+ .array(z.string())
970
+ .optional()
971
+ .default(["combined"])
972
+ .describe("Which figures to re-render: combined (default), umatrix, hit_histogram, component_1, component_2, ..."),
973
+ output_format: z.enum(["png", "pdf", "svg"]).optional().default("png"),
974
+ output_dpi: z.number().int().min(1).max(4).optional().default(2),
975
+ }, async ({ job_id, colormap, figures, output_format, output_dpi }) => {
976
+ const body = { colormap, figures, output_format, output_dpi };
977
+ const data = (await apiCall("POST", `/v1/results/${job_id}/render`, JSON.stringify(body), {
978
+ "Content-Type": "application/json",
979
+ }));
980
+ const newJobId = data.id;
981
+ const content = [
982
+ {
983
+ type: "text",
984
+ text: [
985
+ `Re-render job submitted with colormap "${colormap}".`,
986
+ `New job_id: ${newJobId}. Poll get_job_status(job_id="${newJobId}") until status is 'completed', then use get_results(job_id="${newJobId}") or get_result_image to retrieve the recolored plot(s). No retraining was performed.`,
987
+ ].join("\n"),
988
+ },
989
+ ];
811
990
  return { content };
812
991
  });
992
+ // ---- download_results ----
993
+ server.tool("download_results", `Save result figures (and optionally JSON) to a folder on disk. Use so the user can open, share, or version files locally without writing their own download script.
994
+
995
+ folder: path to the directory (e.g. "." for current/workspace, "./results", or absolute path). In Cursor, the process cwd is typically the project root.
996
+ figures: "all" (default) = all image files from the job; "images" = same; or an array of filenames e.g. ["combined.png", "umatrix.png"].
997
+ include_json: if true, also save summary.json (and other JSON artifacts) into the same folder.`, {
998
+ job_id: z.string().describe("Job ID of a completed job"),
999
+ folder: z.string().describe("Directory path to save files (e.g. '.' or './results'). Relative paths are relative to process cwd (usually project root)."),
1000
+ figures: z
1001
+ .union([z.enum(["all", "images"]), z.array(z.string())])
1002
+ .optional()
1003
+ .default("all")
1004
+ .describe("Which files to download: 'all' (default) or 'images' for all image files, or array of filenames e.g. ['combined.png', 'umatrix.png']"),
1005
+ include_json: z.boolean().optional().default(false).describe("If true, also download summary.json and other JSON files"),
1006
+ }, async ({ job_id, folder, figures, include_json }) => {
1007
+ const data = (await apiCall("GET", `/v1/results/${job_id}`));
1008
+ const summary = (data.summary ?? {});
1009
+ const files = summary.files ?? [];
1010
+ const isImage = (f) => f.endsWith(".png") || f.endsWith(".svg") || f.endsWith(".pdf");
1011
+ const isJson = (f) => f.endsWith(".json");
1012
+ let toDownload;
1013
+ if (figures === "all" || figures === "images") {
1014
+ toDownload = include_json ? files : files.filter(isImage);
1015
+ }
1016
+ else {
1017
+ toDownload = figures;
1018
+ if (include_json && !toDownload.includes("summary.json")) {
1019
+ toDownload = [...toDownload, "summary.json"];
1020
+ }
1021
+ }
1022
+ const resolvedDir = path.resolve(folder);
1023
+ await fs.mkdir(resolvedDir, { recursive: true });
1024
+ const saved = [];
1025
+ for (const filename of toDownload) {
1026
+ try {
1027
+ const { data: buf } = await apiRawCall(`/v1/results/${job_id}/image/${filename}`);
1028
+ const outPath = path.join(resolvedDir, filename);
1029
+ await fs.writeFile(outPath, buf);
1030
+ saved.push(filename);
1031
+ }
1032
+ catch {
1033
+ // Skip missing or failed files
1034
+ }
1035
+ }
1036
+ const text = saved.length > 0
1037
+ ? `Saved ${saved.length} file(s) to ${resolvedDir}: ${saved.join(", ")}`
1038
+ : `No files saved (job may have no matching files or download failed). Check job_id and that the job is completed.`;
1039
+ return { content: [{ type: "text", text }] };
1040
+ });
813
1041
  // ---- analyze ----
814
1042
  server.tool("analyze", `Run a specific analysis on SOM results. Use after get_results to drill into aspects.
1043
+ Request specific plots: get_results(job_id, figures=[...]) for chosen figures (e.g. figures: ["umatrix"]) or analyze(job_id, analysis_type) for a single analysis view.
815
1044
 
816
1045
  Available analysis types and when to use them:
817
1046
 
@@ -833,6 +1062,8 @@ Available analysis types and when to use them:
833
1062
  high-density regions? Do they correspond to known operating modes?"
834
1063
  feature_gradient — Spatial rate of change per feature. Ask: "Where does this
835
1064
  feature change most rapidly? Does it align with cluster boundaries?"
1065
+ quality_report — Comprehensive quality report: QE, TE, silhouette, trustworthiness,
1066
+ neighborhood preservation, topographic product, and recommendations.
836
1067
 
837
1068
  WORKFLOW RECOMMENDATION:
838
1069
  1. Start with clusters → check quality metrics and recommendations
@@ -859,6 +1090,7 @@ INTERPRETATION TIPS:
859
1090
  "transition_flow",
860
1091
  "local_density",
861
1092
  "feature_gradient",
1093
+ "quality_report",
862
1094
  ])
863
1095
  .describe("Type of analysis to run"),
864
1096
  params: z
@@ -1087,6 +1319,64 @@ INTERPRETATION TIPS:
1087
1319
  }
1088
1320
  await tryAttachImage(content, job_id, `umatrix.${ext}`);
1089
1321
  }
1322
+ else if (analysis_type === "quality_report") {
1323
+ const qrData = (await apiCall("GET", `/v1/results/${job_id}/quality-report`));
1324
+ const std = qrData.standard_metrics ?? {};
1325
+ const clust = qrData.cluster_metrics ?? {};
1326
+ const topo = qrData.topology_metrics ?? {};
1327
+ const train = qrData.training ?? {};
1328
+ const qrGrid = qrData.grid ?? [0, 0];
1329
+ const fmt = (v) => v !== null && v !== undefined ? v.toFixed(4) : "—";
1330
+ const fmtPct = (v) => v !== null && v !== undefined ? `${(v * 100).toFixed(1)}%` : "—";
1331
+ const recommendations = [];
1332
+ const qe = std.quantization_error;
1333
+ const te = std.topographic_error;
1334
+ const ev = std.explained_variance;
1335
+ const sil = clust.silhouette;
1336
+ const trust = topo.trustworthiness;
1337
+ if (qe !== null && qe !== undefined && qe > 2.0)
1338
+ recommendations.push("QE is high → try more epochs or a larger grid");
1339
+ if (te !== null && te !== undefined && te > 0.15)
1340
+ recommendations.push("TE is high → topology is not well-preserved, try larger grid");
1341
+ if (ev !== null && ev !== undefined && ev < 0.7)
1342
+ recommendations.push("Explained variance < 70% → consider more training or feature selection");
1343
+ if (sil !== null && sil !== undefined && sil < 0.1)
1344
+ recommendations.push("Low silhouette → clusters overlap, try sigma_f=0.5 or more epochs");
1345
+ if (trust !== null && trust !== undefined && trust < 0.85)
1346
+ recommendations.push("Trustworthiness < 85% → local neighborhood structure is distorted");
1347
+ if (recommendations.length === 0)
1348
+ recommendations.push("All metrics look healthy — good map quality!");
1349
+ const epochs = train.epochs;
1350
+ const epochStr = epochs
1351
+ ? epochs[1] === 0 ? `${epochs[0]} ordering only` : `${epochs[0]}+${epochs[1]}`
1352
+ : "—";
1353
+ const qrLines = [
1354
+ `Quality Report — Job ${job_id}`,
1355
+ `Grid: ${qrGrid[0]}×${qrGrid[1]} | Model: ${qrData.model ?? "SOM"} | Samples: ${qrData.n_samples ?? "?"}`,
1356
+ `Epochs: ${epochStr} | Duration: ${train.duration_seconds ? `${train.duration_seconds}s` : "—"}`,
1357
+ ``,
1358
+ `Standard Metrics:`,
1359
+ ` Quantization Error: ${fmt(std.quantization_error)} (lower is better)`,
1360
+ ` Topographic Error: ${fmt(std.topographic_error)} (lower is better)`,
1361
+ ` Distortion: ${fmt(std.distortion)}`,
1362
+ ` Kaski-Lagus Error: ${fmt(std.kaski_lagus_error)} (lower is better)`,
1363
+ ` Explained Variance: ${fmtPct(std.explained_variance)}`,
1364
+ ``,
1365
+ `Cluster Quality Metrics:`,
1366
+ ` Silhouette Score: ${fmt(clust.silhouette)} (higher is better, -1 to +1)`,
1367
+ ` Davies-Bouldin: ${fmt(clust.davies_bouldin)} (lower is better)`,
1368
+ ` Calinski-Harabasz: ${fmt(clust.calinski_harabasz)} (higher is better)`,
1369
+ ``,
1370
+ `Topology Metrics:`,
1371
+ ` Neighborhood Preservation: ${fmtPct(topo.neighborhood_preservation)} (higher is better)`,
1372
+ ` Trustworthiness: ${fmtPct(topo.trustworthiness)} (higher is better)`,
1373
+ ` Topographic Product: ${fmt(topo.topographic_product)} (near 0 is ideal)`,
1374
+ ``,
1375
+ `Recommendations:`,
1376
+ ...recommendations.map((r) => ` • ${r}`),
1377
+ ];
1378
+ content.push({ type: "text", text: qrLines.join("\n") });
1379
+ }
1090
1380
  return { content };
1091
1381
  });
1092
1382
  // ---- compare_runs ----
@@ -1133,324 +1423,147 @@ After comparing, ask the user:
1133
1423
  content: [{ type: "text", text: lines.join("\n") }],
1134
1424
  };
1135
1425
  });
1136
- // ---- cancel_job ----
1137
- server.tool("cancel_job", `Cancel a pending or running job.
1138
-
1139
- TIMING: Cancellation is not instant — the worker checks between training phases.
1140
- Expect up to 30s delay for the job to actually stop.
1141
-
1142
- Use when a training run is too slow, wrong parameters were submitted, or you
1143
- want to free the worker for a different job. Partial results are discarded.
1144
- After cancelling, submit a new job with corrected parameters.`, {
1145
- job_id: z.string().describe("Job ID to cancel"),
1146
- }, async ({ job_id }) => {
1147
- const data = await apiCall("POST", `/v1/jobs/${job_id}/cancel`);
1148
- return textResult(data);
1149
- });
1150
- // ---- delete_job ----
1151
- server.tool("delete_job", `Delete a job and all its S3 result files.
1152
-
1153
- Use when:
1154
- - Cleaning up old or failed jobs to free storage
1155
- - Removing test runs before going to production
1156
- - The job is cancelled and you no longer need the record
1157
-
1158
- WARNING: This permanently deletes all result files (images, weights, node stats).
1159
- The job ID will no longer be usable with get_results or any other tools.`, {
1160
- job_id: z.string().describe("Job ID to delete"),
1161
- }, async ({ job_id }) => {
1162
- const data = await apiCall("DELETE", `/v1/jobs/${job_id}`);
1163
- return textResult(data);
1164
- });
1165
- // ---- preview_dataset ----
1166
- server.tool("preview_dataset", `Preview a dataset before training — shows columns, statistics, sample rows, and detections.
1167
-
1168
- BEST FOR: Understanding data structure before training. ALWAYS call this before train_som
1169
- on an unfamiliar dataset.
1170
- NOT FOR: Large data exploration (returns only sample rows). Use derive_variable for computations.
1171
-
1172
- TIMING: Near-instant (reads only header + sample rows from S3).
1173
-
1174
- This tool detects:
1175
- 1. Column types (numeric vs string) and basic stats (min/max/mean/std)
1176
- 2. Cyclic feature candidates (columns named hour, weekday, angle, direction, etc.)
1177
- 3. Datetime columns with format auto-detection
1178
- 4. Skewed distributions (large max/min ratios suggest log transforms)
1179
-
1180
- AFTER previewing, ask the user:
1181
- - "Which columns are relevant?" → columns parameter in train_som
1182
- - "I see cyclic candidates: [list]. Encode cyclically?" → cyclic_features
1183
- - "Column X ranges 0.01–50,000. Log-transform?" → transforms: {X: "log"}
1184
- - "Datetime columns found. Extract temporal features?" → temporal_features (NEVER auto-apply)
1185
- - "Are any features more important than others?" → feature_weights
1186
-
1187
- COMMON MISTAKES:
1188
- - Skipping preview and training on all columns (including IDs, timestamps, irrelevant features)
1189
- - Not checking for datetime columns that could provide valuable cyclic features
1190
- - Ignoring skewed distributions that will dominate normalization
1191
-
1192
- TIP: Use the prepare_training prompt for a structured walkthrough of all decisions.`, {
1193
- dataset_id: z.string().describe("Dataset ID to preview"),
1194
- n_rows: z
1195
- .number()
1196
- .int()
1197
- .optional()
1198
- .default(5)
1199
- .describe("Number of sample rows to return (default 5)"),
1200
- }, async ({ dataset_id, n_rows }) => {
1201
- const data = (await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=${n_rows ?? 5}`));
1202
- const cols = data.columns ?? [];
1203
- const stats = data.column_stats ?? [];
1204
- const hints = data.cyclic_hints ?? [];
1205
- const samples = data.sample_rows ?? [];
1206
- const dtCols = data.datetime_columns ?? [];
1207
- const temporalSugg = data.temporal_suggestions ?? [];
1208
- const fmt = (v) => v === null || v === undefined ? "—" : Number(v).toFixed(3);
1209
- const lines = [
1210
- `Dataset: ${data.name} (${data.dataset_id})`,
1211
- `${data.total_rows} rows × ${data.total_cols} columns`,
1212
- ``,
1213
- `Column Statistics:`,
1214
- `| Column | Min | Max | Mean | Std | Nulls | Numeric |`,
1215
- `|--------|-----|-----|------|-----|-------|---------|`,
1216
- ];
1217
- for (const s of stats) {
1218
- lines.push(`| ${s.column} | ${fmt(s.min)} | ${fmt(s.max)} | ${fmt(s.mean)} | ${fmt(s.std)} | ${s.null_count ?? 0} | ${s.is_numeric !== false ? "yes" : "no"} |`);
1426
+ // ---- manage_job ----
1427
+ server.tool("manage_job", `Cancel or delete a job.
1428
+
1429
+ action=cancel: Cancel a pending or running job. Not instant — worker checks between phases (expect up to 30s). Use when run is too slow, wrong params, or to free the worker. Partial results discarded.
1430
+ action=delete: Permanently delete a job and all S3 result files. Use to free storage, remove test runs, or clean up after cancel. WARNING: Job ID will no longer work with get_results or other tools.`, {
1431
+ job_id: z.string().describe("Job ID to cancel or delete"),
1432
+ action: z
1433
+ .enum(["cancel", "delete"])
1434
+ .describe("cancel: stop the job; delete: remove job and all result files"),
1435
+ }, async ({ job_id, action }) => {
1436
+ if (action === "cancel") {
1437
+ const data = await apiCall("POST", `/v1/jobs/${job_id}/cancel`);
1438
+ return textResult(data);
1219
1439
  }
1220
- if (hints.length > 0) {
1221
- lines.push(``, `Detected Cyclic Feature Hints:`);
1222
- for (const h of hints) {
1223
- lines.push(` • ${h.column} — period=${h.period} (${h.reason})`);
1224
- }
1225
- }
1226
- if (dtCols.length > 0) {
1227
- lines.push(``, `Detected Datetime Columns:`);
1228
- for (const dc of dtCols) {
1229
- const formats = dc.detected_formats ?? [];
1230
- const fmtStrs = formats
1231
- .map((f) => `${f.format} — ${f.description} (${(f.match_rate * 100).toFixed(0)}% match)`)
1232
- .join("; ");
1233
- lines.push(` • ${dc.column}: sample="${dc.sample}" → ${fmtStrs}`);
1234
- if (formats.length > 1) {
1235
- lines.push(` ⚠ AMBIGUOUS: multiple formats match. Ask user to clarify.`);
1236
- }
1237
- }
1238
- }
1239
- if (temporalSugg.length > 0) {
1240
- lines.push(``, `Temporal Feature Suggestions (require user approval):`);
1241
- for (const ts of temporalSugg) {
1242
- lines.push(` • Columns: ${ts.columns.join(" + ")} → format: "${ts.format}"`);
1243
- lines.push(` Available components: ${ts.available_components.join(", ")}`);
1244
- lines.push(` ${ts.note}`);
1245
- }
1246
- lines.push(``, `To use temporal features in train_som, add:`, ` temporal_features: [{columns: [...], format: "...", extract: [...], cyclic: true}]`);
1247
- }
1248
- if (samples.length > 0) {
1249
- lines.push(``, `Sample Rows (first ${samples.length}):`);
1250
- lines.push(`| ${cols.join(" | ")} |`);
1251
- lines.push(`| ${cols.map(() => "---").join(" | ")} |`);
1252
- for (const row of samples) {
1253
- lines.push(`| ${cols.map((c) => String(row[c] ?? "")).join(" | ")} |`);
1254
- }
1255
- }
1256
- return {
1257
- content: [{ type: "text", text: lines.join("\n") }],
1258
- };
1259
- });
1260
- // ---- delete_dataset ----
1261
- server.tool("delete_dataset", "Delete a dataset and its stored data. Frees a dataset slot for new uploads.", {
1262
- dataset_id: z.string().describe("Dataset ID to delete"),
1263
- }, async ({ dataset_id }) => {
1264
- const data = await apiCall("DELETE", `/v1/datasets/${dataset_id}`);
1265
- return textResult(data);
1266
- });
1267
- // ---- list_datasets ----
1268
- server.tool("list_datasets", `List all datasets uploaded by the current organization.
1269
-
1270
- Use this to check what data is available before calling train_som,
1271
- or to find dataset IDs for datasets that were uploaded previously.`, {}, async () => {
1272
- const data = await apiCall("GET", "/v1/datasets");
1440
+ const data = await apiCall("DELETE", `/v1/jobs/${job_id}`);
1273
1441
  return textResult(data);
1274
1442
  });
1275
- // ---- list_jobs ----
1276
- server.tool("list_jobs", `List all SOM training jobs, optionally filtered by dataset.
1277
-
1278
- Shows status, params, and metrics for each job. Use this to:
1279
- - Find job IDs for compare_runs
1280
- - Check which jobs are completed vs pending
1281
- - Review what hyperparameters were used in previous runs`, {
1443
+ // ---- list ----
1444
+ server.tool("list", `List datasets or jobs.
1445
+
1446
+ type=datasets: List all datasets uploaded by the organization. Use to check what data is available before train_som or to find dataset IDs.
1447
+ type=jobs: List SOM training jobs (optionally filtered by dataset_id). Use to find job IDs for compare_runs, check completed vs pending, or review hyperparameters.`, {
1448
+ type: z
1449
+ .enum(["datasets", "jobs"])
1450
+ .describe("What to list: datasets or jobs"),
1282
1451
  dataset_id: z
1283
1452
  .string()
1284
1453
  .optional()
1285
- .describe("Filter by dataset ID (omit to list all jobs)"),
1286
- }, async ({ dataset_id }) => {
1454
+ .describe("Filter jobs by dataset ID (only used when type=jobs)"),
1455
+ }, async ({ type, dataset_id }) => {
1456
+ if (type === "datasets") {
1457
+ const data = await apiCall("GET", "/v1/datasets");
1458
+ return textResult(data);
1459
+ }
1287
1460
  const path = dataset_id
1288
1461
  ? `/v1/jobs?dataset_id=${dataset_id}`
1289
1462
  : "/v1/jobs";
1290
1463
  const data = await apiCall("GET", path);
1291
1464
  return textResult(data);
1292
1465
  });
1293
- // ---- get_training_log ----
1294
- server.tool("get_training_log", `Retrieve the learning curve and training diagnostics for a completed job.
1295
-
1296
- Returns per-epoch quantization error arrays, ASCII sparklines, AND an inline
1297
- learning curve plot (generated during training) showing QE vs epoch for both
1298
- ordering and convergence phases.
1299
-
1300
- Use this to diagnose training quality:
1301
-
1302
- - **Healthy**: errors drop steadily, then plateau (converged)
1303
- - **Still learning**: errors still dropping at end → try more epochs
1304
- - **Diverged**: errors increase → learning rate too high, try lower values
1305
- - **Flat from start**: poor initialization or tiny grid
1466
+ // ---- get_job_export ----
1467
+ server.tool("get_job_export", `Export structured data from a completed SOM training job.
1306
1468
 
1307
- After showing the log, ask the user:
1308
- - "The training shows [observation]. Would you like to adjust epochs or learning rate?"
1309
- - If errors plateaued early: "Convergence was reached quickly. Consider a larger grid for more detail."
1310
- - If errors were still falling: "Training was cut short. Add more epochs for a better map."
1311
-
1312
- Also shows training duration, which helps estimate time for future runs.
1313
-
1314
- BATCH SIZE EFFECT: Smaller batch sizes (32–64) produce more update steps per epoch,
1315
- often yielding lower final QE and smoother convergence curves. If the learning curve
1316
- plateaus early, try more epochs. If it's noisy, try a larger batch size for stability.`, {
1469
+ export=training_log: Learning curve and diagnostics (per-epoch QE, sparklines, inline plot). Use to diagnose convergence, plateau, or divergence.
1470
+ export=weights: Raw weight matrix with node_coords, normalized/denormalized values, normalization stats. Use for external analysis or custom visualizations. Can be large (e.g. 600KB+ for 30×30×12).
1471
+ export=nodes: Per-node statistics (hit count, feature mean/std). Use to profile clusters and characterize operating modes.`, {
1317
1472
  job_id: z.string().describe("Job ID of a completed training job"),
1318
- }, async ({ job_id }) => {
1319
- const data = (await apiCall("GET", `/v1/results/${job_id}/training-log`));
1320
- const ordErrors = data.ordering_errors ?? [];
1321
- const convErrors = data.convergence_errors ?? [];
1322
- const duration = data.training_duration_seconds;
1323
- const epochs = data.epochs;
1324
- const sparkline = (arr) => {
1325
- if (arr.length === 0)
1326
- return "(no data)";
1327
- const blocks = "▁▂▃▄▅▆▇█";
1328
- const min = Math.min(...arr);
1329
- const max = Math.max(...arr);
1330
- const range = max - min || 1;
1331
- return arr
1332
- .map((v) => blocks[Math.min(7, Math.floor(((v - min) / range) * 7))])
1333
- .join("");
1334
- };
1335
- const lines = [
1336
- `Training Log Job ${job_id}`,
1337
- `Grid: ${JSON.stringify(data.grid)} | Model: ${data.model ?? "SOM"}`,
1338
- `Epochs: ${epochs ? `[${epochs[0]} ordering, ${epochs[1]} convergence]` : "N/A"}`,
1339
- `Duration: ${duration !== null && duration !== undefined ? `${duration}s` : "N/A"}`,
1340
- `Features: ${data.n_features ?? "?"} | Samples: ${data.n_samples ?? "?"}`,
1341
- ``,
1342
- `Ordering Phase (${ordErrors.length} epochs):`,
1343
- ` Start QE: ${ordErrors[0]?.toFixed(4) ?? "—"} → End QE: ${ordErrors.at(-1)?.toFixed(4) ?? ""}`,
1344
- ` Curve: ${sparkline(ordErrors)}`,
1345
- ];
1346
- if (convErrors.length > 0) {
1347
- lines.push(``, `Convergence Phase (${convErrors.length} epochs):`, ` Start QE: ${convErrors[0]?.toFixed(4) ?? "—"} → End QE: ${convErrors.at(-1)?.toFixed(4) ?? "—"}`, ` Curve: ${sparkline(convErrors)}`);
1348
- }
1349
- else if ((epochs?.[1] ?? 0) === 0) {
1350
- lines.push(``, `Convergence phase: skipped (epochs[1]=0)`);
1351
- }
1352
- const finalQe = data.quantization_error;
1353
- const finalEv = data.explained_variance;
1354
- if (finalQe !== null && finalQe !== undefined) {
1355
- lines.push(``, `Final QE: ${finalQe.toFixed(4)} | Explained Variance: ${(finalEv ?? 0).toFixed(4)}`);
1356
- }
1357
- const content = [
1358
- { type: "text", text: lines.join("\n") },
1359
- ];
1360
- // Inline the pre-generated learning curve plot (worker saves it during training).
1361
- // Try png first (default), then pdf/svg in case the job used a different format.
1362
- let attached = false;
1363
- for (const lcExt of ["png", "pdf", "svg"]) {
1364
- try {
1365
- const { data: lcBuf } = await apiRawCall(`/v1/results/${job_id}/image/learning_curve.${lcExt}`);
1366
- content.push({
1367
- type: "image",
1368
- data: lcBuf.toString("base64"),
1369
- mimeType: mimeForFilename(`learning_curve.${lcExt}`),
1370
- annotations: { audience: ["user"], priority: 0.8 },
1371
- });
1372
- attached = true;
1373
- break;
1473
+ export: z
1474
+ .enum(["training_log", "weights", "nodes"])
1475
+ .describe("What to export: training_log, weights, or nodes"),
1476
+ }, async ({ job_id, export: exportType }) => {
1477
+ if (exportType === "training_log") {
1478
+ const data = (await apiCall("GET", `/v1/results/${job_id}/training-log`));
1479
+ const ordErrors = data.ordering_errors ?? [];
1480
+ const convErrors = data.convergence_errors ?? [];
1481
+ const duration = data.training_duration_seconds;
1482
+ const epochs = data.epochs;
1483
+ const sparkline = (arr) => {
1484
+ if (arr.length === 0)
1485
+ return "(no data)";
1486
+ const blocks = "▁▂▃▄▅▆▇█";
1487
+ const min = Math.min(...arr);
1488
+ const max = Math.max(...arr);
1489
+ const range = max - min || 1;
1490
+ return arr
1491
+ .map((v) => blocks[Math.min(7, Math.floor(((v - min) / range) * 7))])
1492
+ .join("");
1493
+ };
1494
+ const lines = [
1495
+ `Training Log Job ${job_id}`,
1496
+ `Grid: ${JSON.stringify(data.grid)} | Model: ${data.model ?? "SOM"}`,
1497
+ `Epochs: ${epochs ? `[${epochs[0]} ordering, ${epochs[1]} convergence]` : "N/A"}`,
1498
+ `Duration: ${duration !== null && duration !== undefined ? `${duration}s` : "N/A"}`,
1499
+ `Features: ${data.n_features ?? "?"} | Samples: ${data.n_samples ?? "?"}`,
1500
+ ``,
1501
+ `Ordering Phase (${ordErrors.length} epochs):`,
1502
+ ` Start QE: ${ordErrors[0]?.toFixed(4) ?? "—"} → End QE: ${ordErrors.at(-1)?.toFixed(4) ?? "—"}`,
1503
+ ` Curve: ${sparkline(ordErrors)}`,
1504
+ ];
1505
+ if (convErrors.length > 0) {
1506
+ lines.push(``, `Convergence Phase (${convErrors.length} epochs):`, ` Start QE: ${convErrors[0]?.toFixed(4) ?? "—"} → End QE: ${convErrors.at(-1)?.toFixed(4) ?? "—"}`, ` Curve: ${sparkline(convErrors)}`);
1374
1507
  }
1375
- catch {
1376
- continue;
1508
+ else if ((epochs?.[1] ?? 0) === 0) {
1509
+ lines.push(``, `Convergence phase: skipped (epochs[1]=0)`);
1377
1510
  }
1511
+ const finalQe = data.quantization_error;
1512
+ const finalEv = data.explained_variance;
1513
+ if (finalQe !== null && finalQe !== undefined) {
1514
+ lines.push(``, `Final QE: ${finalQe.toFixed(4)} | Explained Variance: ${(finalEv ?? 0).toFixed(4)}`);
1515
+ }
1516
+ const content = [
1517
+ { type: "text", text: lines.join("\n") },
1518
+ ];
1519
+ let attached = false;
1520
+ for (const lcExt of ["png", "pdf", "svg"]) {
1521
+ try {
1522
+ const { data: lcBuf } = await apiRawCall(`/v1/results/${job_id}/image/learning_curve.${lcExt}`);
1523
+ content.push({
1524
+ type: "image",
1525
+ data: lcBuf.toString("base64"),
1526
+ mimeType: mimeForFilename(`learning_curve.${lcExt}`),
1527
+ annotations: { audience: ["user"], priority: 0.8 },
1528
+ });
1529
+ attached = true;
1530
+ break;
1531
+ }
1532
+ catch {
1533
+ continue;
1534
+ }
1535
+ }
1536
+ if (!attached) {
1537
+ content.push({ type: "text", text: "(learning curve plot not available)" });
1538
+ }
1539
+ return { content };
1378
1540
  }
1379
- if (!attached) {
1380
- content.push({ type: "text", text: "(learning curve plot not available)" });
1381
- }
1382
- return { content };
1383
- });
1384
- // ---- get_weights ----
1385
- server.tool("get_weights", `Export the raw SOM weight matrix for a completed job.
1386
-
1387
- BEST FOR: Exporting the trained model for external analysis, custom visualizations, or comparing weight structures.
1388
-
1389
- Returns a structured weight matrix with:
1390
- - node_coords: [x,y] per node (SOM topology coordinates for spatial mapping)
1391
- - Normalized and denormalized weight values per feature
1392
- - Normalization statistics (mean/std used during training)
1393
-
1394
- Response includes node_coords (SOM topology coordinates) for spatial mapping.
1395
-
1396
- Use this to:
1397
- - Export the trained model for external analysis
1398
- - Visualize the weight space in custom tools
1399
- - Compare weight structures between training runs
1400
- - Build custom projections or classifications
1401
-
1402
- Output can be large for big grids (e.g. 600KB+ for 30×30×12). Consider filtering
1403
- to specific features if you only need a subset.`, {
1404
- job_id: z.string().describe("Job ID of a completed training job"),
1405
- }, async ({ job_id }) => {
1406
- const data = (await apiCall("GET", `/v1/results/${job_id}/weights`));
1407
- const features = data.features ?? [];
1408
- const nNodes = data.n_nodes ?? 0;
1409
- const grid = data.grid ?? [0, 0];
1410
- const lines = [
1411
- `SOM Weights — Job ${job_id}`,
1412
- `Grid: ${grid[0]}×${grid[1]} | Nodes: ${nNodes} | Features: ${features.length}`,
1413
- `node_coords: [x,y] per node for topology`,
1414
- `Features: ${features.join(", ")}`,
1415
- ``,
1416
- `Normalization Stats:`,
1417
- ];
1418
- const normStats = data.normalization_stats ?? {};
1419
- for (const [feat, s] of Object.entries(normStats)) {
1420
- lines.push(` ${feat}: mean=${s.mean?.toFixed(4)}, std=${s.std?.toFixed(4)}`);
1541
+ if (exportType === "weights") {
1542
+ const data = (await apiCall("GET", `/v1/results/${job_id}/weights`));
1543
+ const features = data.features ?? [];
1544
+ const nNodes = data.n_nodes ?? 0;
1545
+ const grid = data.grid ?? [0, 0];
1546
+ const lines = [
1547
+ `SOM Weights Job ${job_id}`,
1548
+ `Grid: ${grid[0]}×${grid[1]} | Nodes: ${nNodes} | Features: ${features.length}`,
1549
+ `node_coords: [x,y] per node for topology`,
1550
+ `Features: ${features.join(", ")}`,
1551
+ ``,
1552
+ `Normalization Stats:`,
1553
+ ];
1554
+ const normStats = data.normalization_stats ?? {};
1555
+ for (const [feat, s] of Object.entries(normStats)) {
1556
+ lines.push(` ${feat}: mean=${s.mean?.toFixed(4)}, std=${s.std?.toFixed(4)}`);
1557
+ }
1558
+ lines.push(``, `Full weight matrix available in the response JSON (includes node_coords).`, `Use the denormalized_weights array for original-scale values.`);
1559
+ return {
1560
+ content: [
1561
+ { type: "text", text: lines.join("\n") },
1562
+ { type: "text", text: JSON.stringify(data, null, 2) },
1563
+ ],
1564
+ };
1421
1565
  }
1422
- lines.push(``, `Full weight matrix available in the response JSON (includes node_coords).`, `Use the denormalized_weights array for original-scale values.`);
1423
- return {
1424
- content: [
1425
- { type: "text", text: lines.join("\n") },
1426
- { type: "text", text: JSON.stringify(data, null, 2) },
1427
- ],
1428
- };
1429
- });
1430
- // ---- get_node_data ----
1431
- server.tool("get_node_data", `Get per-node statistics for a completed SOM job.
1432
-
1433
- BEST FOR: Profiling clusters, finding dominant vs rare nodes, characterizing operating modes.
1434
-
1435
- Returns for each SOM node:
1436
- - Hit count (how many data points map to this node)
1437
- - Feature mean and std for all samples that map to this node
1438
-
1439
- This answers "what data lives in this cluster?" — enabling characterization
1440
- of distinct operating modes, regimes, or behavioral groups.
1441
-
1442
- Use this to:
1443
- - Profile each cluster by its feature distributions
1444
- - Find dominant nodes (high hit count) vs rare nodes
1445
- - Compare feature distributions between nodes
1446
- - Identify the most "representative" state for each cluster
1447
-
1448
- After showing node data, ask the user:
1449
- - "Do these cluster profiles match your domain knowledge?"
1450
- - "Which nodes represent the most common operating states?"
1451
- - "Are there any nodes with extreme feature values worth investigating?"`, {
1452
- job_id: z.string().describe("Job ID of a completed training job"),
1453
- }, async ({ job_id }) => {
1566
+ // exportType === "nodes"
1454
1567
  const data = (await apiCall("GET", `/v1/results/${job_id}/nodes`));
1455
1568
  const topNodes = [...data]
1456
1569
  .sort((a, b) => (b.hit_count ?? 0) - (a.hit_count ?? 0))
@@ -1531,7 +1644,7 @@ HINT: If values length mismatch, suggest derive_variable for formula-based varia
1531
1644
  colormap: z
1532
1645
  .string()
1533
1646
  .optional()
1534
- .describe("Override colormap for the projection plot (default: plasma)."),
1647
+ .describe("Override colormap for the projection plot (default: plasma). Examples: viridis, plasma, inferno, magma, cividis, turbo, coolwarm, RdBu, Spectral."),
1535
1648
  }, async ({ job_id, variable_name, values, aggregation, output_format, output_dpi, colormap }) => {
1536
1649
  const dpiMap = { standard: 1, retina: 2, print: 4 };
1537
1650
  const body = {
@@ -1781,7 +1894,7 @@ COMMON MISTAKES:
1781
1894
  colormap: z
1782
1895
  .string()
1783
1896
  .optional()
1784
- .describe("Colormap for projection visualization (default: plasma)"),
1897
+ .describe("Colormap for projection visualization (default: plasma). Examples: viridis, plasma, inferno, magma, cividis, turbo, coolwarm, RdBu, Spectral."),
1785
1898
  }, async ({ dataset_id, name, expression, project_onto_job, aggregation, options, output_format, output_dpi, colormap, }) => {
1786
1899
  const dpiMap = { standard: 1, retina: 2, print: 4 };
1787
1900
  if (project_onto_job) {
@@ -1866,7 +1979,7 @@ COMMON MISTAKES:
1866
1979
  `Min: ${summary.min ?? "?"} | Max: ${summary.max ?? "?"} | Mean: ${summary.mean ?? "?"}`,
1867
1980
  ``,
1868
1981
  `The column is now available in the dataset. Include it in train_som`,
1869
- `via the 'columns' parameter, or use preview_dataset to verify.`,
1982
+ `via the 'columns' parameter, or use datasets(action=preview) to verify.`,
1870
1983
  ]
1871
1984
  .filter((l) => l !== "")
1872
1985
  .join("\n"),
@@ -1889,87 +2002,6 @@ COMMON MISTAKES:
1889
2002
  };
1890
2003
  }
1891
2004
  });
1892
- // ---- quality_report ----
1893
- server.tool("quality_report", `Generate a comprehensive quality report for a trained SOM.
1894
-
1895
- Returns all available metrics organized by category:
1896
- - **Standard metrics**: Quantization Error (QE), Topographic Error (TE), Distortion
1897
- - **Cluster metrics**: Silhouette, Davies-Bouldin, Calinski-Harabasz
1898
- - **Topology metrics**: Neighborhood Preservation, Trustworthiness, Topographic Product
1899
- - **Training info**: duration, epochs, learning parameters
1900
-
1901
- Metric interpretation guide:
1902
- - QE < 0.5: excellent | 0.5–1.0: good | 1.0–2.0: fair | >2.0: needs improvement
1903
- - TE < 0.05: excellent | 0.05–0.10: good | 0.10–0.20: fair | >0.20: poor topology
1904
- - Trustworthiness: closer to 1.0 = better (local neighborhoods preserved)
1905
- - Neighborhood Preservation: closer to 1.0 = better (global structure preserved)
1906
- - Topographic Product: near 0 = well-sized grid | <0 = grid too small | >0 = grid too large
1907
-
1908
- After showing the report, ask the user:
1909
- - "Which metrics are most important for your use case?"
1910
- - "Do any metrics suggest the map needs retraining?"`, {
1911
- job_id: z.string().describe("Job ID of a completed training job"),
1912
- }, async ({ job_id }) => {
1913
- const data = (await apiCall("GET", `/v1/results/${job_id}/quality-report`));
1914
- const std = data.standard_metrics ?? {};
1915
- const clust = data.cluster_metrics ?? {};
1916
- const topo = data.topology_metrics ?? {};
1917
- const train = data.training ?? {};
1918
- const grid = data.grid ?? [0, 0];
1919
- const fmt = (v) => v !== null && v !== undefined ? v.toFixed(4) : "—";
1920
- const fmtPct = (v) => v !== null && v !== undefined ? `${(v * 100).toFixed(1)}%` : "—";
1921
- const recommendations = [];
1922
- const qe = std.quantization_error;
1923
- const te = std.topographic_error;
1924
- const ev = std.explained_variance;
1925
- const sil = clust.silhouette;
1926
- const trust = topo.trustworthiness;
1927
- const nbp = topo.neighborhood_preservation;
1928
- if (qe !== null && qe !== undefined && qe > 2.0)
1929
- recommendations.push("QE is high → try more epochs or a larger grid");
1930
- if (te !== null && te !== undefined && te > 0.15)
1931
- recommendations.push("TE is high → topology is not well-preserved, try larger grid");
1932
- if (ev !== null && ev !== undefined && ev < 0.7)
1933
- recommendations.push("Explained variance < 70% → consider more training or feature selection");
1934
- if (sil !== null && sil !== undefined && sil < 0.1)
1935
- recommendations.push("Low silhouette → clusters overlap, try sigma_f=0.5 or more epochs");
1936
- if (trust !== null && trust !== undefined && trust < 0.85)
1937
- recommendations.push("Trustworthiness < 85% → local neighborhood structure is distorted");
1938
- if (recommendations.length === 0)
1939
- recommendations.push("All metrics look healthy — good map quality!");
1940
- const epochs = train.epochs;
1941
- const epochStr = epochs
1942
- ? epochs[1] === 0 ? `${epochs[0]} ordering only` : `${epochs[0]}+${epochs[1]}`
1943
- : "—";
1944
- const lines = [
1945
- `Quality Report — Job ${job_id}`,
1946
- `Grid: ${grid[0]}×${grid[1]} | Model: ${data.model ?? "SOM"} | Samples: ${data.n_samples ?? "?"}`,
1947
- `Epochs: ${epochStr} | Duration: ${train.duration_seconds ? `${train.duration_seconds}s` : "—"}`,
1948
- ``,
1949
- `Standard Metrics:`,
1950
- ` Quantization Error: ${fmt(std.quantization_error)} (lower is better)`,
1951
- ` Topographic Error: ${fmt(std.topographic_error)} (lower is better)`,
1952
- ` Distortion: ${fmt(std.distortion)}`,
1953
- ` Kaski-Lagus Error: ${fmt(std.kaski_lagus_error)} (lower is better)`,
1954
- ` Explained Variance: ${fmtPct(std.explained_variance)}`,
1955
- ``,
1956
- `Cluster Quality Metrics:`,
1957
- ` Silhouette Score: ${fmt(clust.silhouette)} (higher is better, -1 to +1)`,
1958
- ` Davies-Bouldin: ${fmt(clust.davies_bouldin)} (lower is better)`,
1959
- ` Calinski-Harabasz: ${fmt(clust.calinski_harabasz)} (higher is better)`,
1960
- ``,
1961
- `Topology Metrics:`,
1962
- ` Neighborhood Preservation: ${fmtPct(topo.neighborhood_preservation)} (higher is better)`,
1963
- ` Trustworthiness: ${fmtPct(topo.trustworthiness)} (higher is better)`,
1964
- ` Topographic Product: ${fmt(topo.topographic_product)} (near 0 is ideal)`,
1965
- ``,
1966
- `Recommendations:`,
1967
- ...recommendations.map((r) => ` • ${r}`),
1968
- ];
1969
- return {
1970
- content: [{ type: "text", text: lines.join("\n") }],
1971
- };
1972
- });
1973
2005
  // ---- system_info ----
1974
2006
  server.tool("system_info", `Get plan capabilities, backend info, live status, and training time estimates.
1975
2007
 
@@ -2052,7 +2084,7 @@ server.prompt("prepare_training", "Guided pre-training checklist. Use after uplo
2052
2084
  `5. FEATURE WEIGHTS: Should any features be emphasized or de-emphasized?\n` +
2053
2085
  `6. DERIVED VARIABLES: Any new columns to compute from existing ones? (e.g., ratios, differences)\n` +
2054
2086
  `7. GRID & MODEL: What grid size and model type?\n\n` +
2055
- `Start by calling preview_dataset to show me the columns and statistics.`,
2087
+ `Start by calling datasets(action=preview, dataset_id=...) to show me the columns and statistics.`,
2056
2088
  },
2057
2089
  },
2058
2090
  ],