@barivia/barsom-mcp 0.1.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/README.md +127 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2111 -0
- package/dist/index.js.map +1 -0
- package/dist/views/src/views/data-preview/index.html +137 -0
- package/dist/views/src/views/som-explorer/index.html +288 -0
- package/dist/views/src/views/training-monitor/index.html +146 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @barivia/barsom-mcp
|
|
4
|
+
*
|
|
5
|
+
* Thin MCP proxy: implements stdio MCP locally and forwards every tool call
|
|
6
|
+
* to the Barivia cloud REST API. Users configure BARIVIA_API_KEY and
|
|
7
|
+
* BARIVIA_API_URL as environment variables.
|
|
8
|
+
*
|
|
9
|
+
* Usage (in MCP client config, e.g. Cursor / Claude Desktop):
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* "mcpServers": {
|
|
13
|
+
* "analytics-engine": {
|
|
14
|
+
* "command": "npx",
|
|
15
|
+
* "args": ["-y", "@barivia/barsom-mcp"],
|
|
16
|
+
* "env": {
|
|
17
|
+
* "BARIVIA_API_KEY": "bv_live_xxxx",
|
|
18
|
+
* "BARIVIA_API_URL": "https://api.barivia.se"
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
+
import { z } from "zod";
|
|
27
|
+
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
|
28
|
+
import fs from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Config
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const API_URL = process.env.BARIVIA_API_URL ??
|
|
34
|
+
process.env.BARSOM_API_URL ??
|
|
35
|
+
"https://api.barivia.se";
|
|
36
|
+
const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY ?? "";
|
|
37
|
+
if (!API_KEY) {
|
|
38
|
+
console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "30000", 10);
|
|
45
|
+
const MAX_RETRIES = 2;
|
|
46
|
+
const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
47
|
+
function isTransientError(err, status) {
|
|
48
|
+
if (status !== undefined && RETRYABLE_STATUS.has(status))
|
|
49
|
+
return true;
|
|
50
|
+
if (err instanceof DOMException && err.name === "AbortError")
|
|
51
|
+
return true;
|
|
52
|
+
if (err instanceof TypeError)
|
|
53
|
+
return true; // network-level fetch failure
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function apiCall(method, path, body, extraHeaders) {
|
|
67
|
+
const url = `${API_URL}${path}`;
|
|
68
|
+
const contentType = extraHeaders?.["Content-Type"] ?? "application/json";
|
|
69
|
+
const requestId = Math.random().toString(36).slice(2, 10);
|
|
70
|
+
const headers = {
|
|
71
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
72
|
+
"Content-Type": contentType,
|
|
73
|
+
"X-Request-ID": requestId,
|
|
74
|
+
...extraHeaders,
|
|
75
|
+
};
|
|
76
|
+
let serializedBody;
|
|
77
|
+
if (body !== undefined) {
|
|
78
|
+
serializedBody =
|
|
79
|
+
contentType === "application/json" ? JSON.stringify(body) : String(body);
|
|
80
|
+
}
|
|
81
|
+
let lastError;
|
|
82
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
const resp = await fetchWithTimeout(url, {
|
|
85
|
+
method,
|
|
86
|
+
headers,
|
|
87
|
+
body: serializedBody,
|
|
88
|
+
});
|
|
89
|
+
const text = await resp.text();
|
|
90
|
+
if (!resp.ok) {
|
|
91
|
+
if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
|
|
92
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const errBody = (() => { try {
|
|
96
|
+
return JSON.parse(text);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
} })();
|
|
101
|
+
const detail = errBody?.error ?? text;
|
|
102
|
+
const hint = resp.status === 400 ? " Check parameter types and required fields."
|
|
103
|
+
: resp.status === 404 ? " The resource may not exist or may have been deleted."
|
|
104
|
+
: resp.status === 409 ? " The job may not be in the expected state."
|
|
105
|
+
: resp.status === 429 ? " Rate limit exceeded — wait a moment and retry."
|
|
106
|
+
: "";
|
|
107
|
+
throw new Error(`${detail}${hint}`);
|
|
108
|
+
}
|
|
109
|
+
return JSON.parse(text);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
lastError = err;
|
|
113
|
+
if (attempt < MAX_RETRIES && isTransientError(err)) {
|
|
114
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastError;
|
|
121
|
+
}
|
|
122
|
+
/** Fetch raw bytes from the API (for image downloads). */
|
|
123
|
+
async function apiRawCall(path) {
|
|
124
|
+
const url = `${API_URL}${path}`;
|
|
125
|
+
let lastError;
|
|
126
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
127
|
+
try {
|
|
128
|
+
const resp = await fetchWithTimeout(url, {
|
|
129
|
+
method: "GET",
|
|
130
|
+
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
131
|
+
});
|
|
132
|
+
if (!resp.ok) {
|
|
133
|
+
if (attempt < MAX_RETRIES && isTransientError(null, resp.status)) {
|
|
134
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw new Error(`API GET ${path} returned ${resp.status}`);
|
|
138
|
+
}
|
|
139
|
+
const arrayBuf = await resp.arrayBuffer();
|
|
140
|
+
return {
|
|
141
|
+
data: Buffer.from(arrayBuf),
|
|
142
|
+
contentType: resp.headers.get("content-type") ?? "application/octet-stream",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
lastError = err;
|
|
147
|
+
if (attempt < MAX_RETRIES && isTransientError(err)) {
|
|
148
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
throw lastError;
|
|
155
|
+
}
|
|
156
|
+
function textResult(data) {
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async function pollUntilComplete(jobId, maxWaitMs = 30_000, intervalMs = 1000) {
|
|
162
|
+
const start = Date.now();
|
|
163
|
+
while (Date.now() - start < maxWaitMs) {
|
|
164
|
+
const data = (await apiCall("GET", `/v1/jobs/${jobId}`));
|
|
165
|
+
const status = data.status;
|
|
166
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
167
|
+
return {
|
|
168
|
+
status,
|
|
169
|
+
result_ref: data.result_ref,
|
|
170
|
+
error: data.error,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
174
|
+
}
|
|
175
|
+
return { status: "timeout" };
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// MCP Server
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
const server = new McpServer({
|
|
181
|
+
name: "analytics-engine",
|
|
182
|
+
version: "0.4.0",
|
|
183
|
+
});
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// MCP Apps: Register UI resources for interactive dashboards
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
const BASE_DIR = import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname);
|
|
188
|
+
async function loadViewHtml(viewName) {
|
|
189
|
+
const candidates = [
|
|
190
|
+
path.join(BASE_DIR, "views", "src", "views", viewName, "index.html"),
|
|
191
|
+
path.join(BASE_DIR, "views", viewName, "index.html"),
|
|
192
|
+
path.join(BASE_DIR, "..", "dist", "views", "src", "views", viewName, "index.html"),
|
|
193
|
+
];
|
|
194
|
+
for (const p of candidates) {
|
|
195
|
+
try {
|
|
196
|
+
return await fs.readFile(p, "utf-8");
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const SOM_EXPLORER_URI = "ui://barsom/som-explorer";
|
|
205
|
+
const DATA_PREVIEW_URI = "ui://barsom/data-preview";
|
|
206
|
+
const TRAINING_MONITOR_URI = "ui://barsom/training-monitor";
|
|
207
|
+
registerAppResource(server, SOM_EXPLORER_URI, SOM_EXPLORER_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
208
|
+
const html = await loadViewHtml("som-explorer");
|
|
209
|
+
return {
|
|
210
|
+
contents: [
|
|
211
|
+
{
|
|
212
|
+
uri: SOM_EXPLORER_URI,
|
|
213
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
214
|
+
text: html ?? "<html><body>SOM Explorer view not built yet. Run: npm run build:views</body></html>",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
registerAppResource(server, DATA_PREVIEW_URI, DATA_PREVIEW_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
220
|
+
const html = await loadViewHtml("data-preview");
|
|
221
|
+
return {
|
|
222
|
+
contents: [{
|
|
223
|
+
uri: DATA_PREVIEW_URI,
|
|
224
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
225
|
+
text: html ?? "<html><body>Data Preview view not built yet.</body></html>",
|
|
226
|
+
}],
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
registerAppResource(server, TRAINING_MONITOR_URI, TRAINING_MONITOR_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
|
|
230
|
+
const html = await loadViewHtml("training-monitor");
|
|
231
|
+
return {
|
|
232
|
+
contents: [{
|
|
233
|
+
uri: TRAINING_MONITOR_URI,
|
|
234
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
235
|
+
text: html ?? "<html><body>Training Monitor view not built yet.</body></html>",
|
|
236
|
+
}],
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
// ---- explore_som (MCP App) ----
|
|
240
|
+
registerAppTool(server, "explore_som", {
|
|
241
|
+
title: "Explore SOM",
|
|
242
|
+
description: "Interactive SOM explorer dashboard. Opens an inline visualization " +
|
|
243
|
+
"where you can toggle features, click nodes, and export figures. " +
|
|
244
|
+
"Use this after get_results for a richer, interactive exploration experience. " +
|
|
245
|
+
"Falls back to text+image on hosts that don't support MCP Apps.",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
job_id: z.string().describe("Job ID of a completed SOM training job"),
|
|
248
|
+
},
|
|
249
|
+
_meta: { ui: { resourceUri: SOM_EXPLORER_URI } },
|
|
250
|
+
}, async ({ job_id }) => {
|
|
251
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}`));
|
|
252
|
+
const summary = (data.summary ?? {});
|
|
253
|
+
const content = [];
|
|
254
|
+
content.push({
|
|
255
|
+
type: "text",
|
|
256
|
+
text: JSON.stringify({
|
|
257
|
+
job_id,
|
|
258
|
+
summary,
|
|
259
|
+
download_urls: data.download_urls,
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
const imgExt = summary.output_format ?? "png";
|
|
263
|
+
await tryAttachImage(content, job_id, `combined.${imgExt}`);
|
|
264
|
+
return { content };
|
|
265
|
+
});
|
|
266
|
+
// ---- upload_dataset ----
|
|
267
|
+
server.tool("upload_dataset", `Upload a CSV dataset for SOM analysis. Returns dataset metadata including ID.
|
|
268
|
+
|
|
269
|
+
BEST FOR: Tabular data with numeric columns (sensor readings, financial data, process
|
|
270
|
+
measurements, survey results). CSV with header row required.
|
|
271
|
+
NOT FOR: Images, text documents, or pre-trained embeddings.
|
|
272
|
+
|
|
273
|
+
TIMING: Upload is near-instant for datasets under 100MB.
|
|
274
|
+
|
|
275
|
+
AFTER uploading, ask the user these questions to guide the analysis:
|
|
276
|
+
1. "What are you trying to discover in this data?" (clustering, anomalies, temporal patterns)
|
|
277
|
+
2. "Are any columns cyclic/periodic?" (hour=24, weekday=7, wind direction=360)
|
|
278
|
+
3. "Are any columns irrelevant or should be excluded?"
|
|
279
|
+
4. "Should any features be weighted more heavily?"
|
|
280
|
+
5. "Do any columns have very skewed distributions?" (suggest transforms)
|
|
281
|
+
|
|
282
|
+
COMMON MISTAKES:
|
|
283
|
+
- Uploading without previewing first — always use preview_dataset before train_som
|
|
284
|
+
- Including ID columns or row indices — these add noise without meaning
|
|
285
|
+
- Forgetting to check for datetime columns that could provide temporal features
|
|
286
|
+
|
|
287
|
+
Show the column names from the response so the user can identify features.
|
|
288
|
+
TIP: Use the prepare_training prompt for a structured preprocessing checklist.`, {
|
|
289
|
+
name: z.string().describe("Human-readable dataset name"),
|
|
290
|
+
csv_data: z.string().describe("CSV data with header row"),
|
|
291
|
+
}, async ({ name, csv_data }) => {
|
|
292
|
+
const data = await apiCall("POST", "/v1/datasets", csv_data, {
|
|
293
|
+
"X-Dataset-Name": name,
|
|
294
|
+
"Content-Type": "text/csv",
|
|
295
|
+
});
|
|
296
|
+
return textResult(data);
|
|
297
|
+
});
|
|
298
|
+
// ---- train_som ----
|
|
299
|
+
server.tool("train_som", `Train a Self-Organizing Map on the dataset. Returns a job_id for polling.
|
|
300
|
+
|
|
301
|
+
BEST FOR: Exploratory analysis of multivariate numeric data — clustering, regime
|
|
302
|
+
detection, process monitoring, anomaly visualization, dimensionality reduction.
|
|
303
|
+
NOT FOR: Time-series forecasting, classification, or text/image data.
|
|
304
|
+
|
|
305
|
+
TIMING (8700 samples, CPU):
|
|
306
|
+
- 10x10 grid, 10 epochs: ~30s
|
|
307
|
+
- 20x20 grid, 30 epochs: ~3–5 min
|
|
308
|
+
- 40x40 grid, 60 epochs: ~15–30 min
|
|
309
|
+
GPU cuts these by 3–5x. Check system_info for available resources.
|
|
310
|
+
|
|
311
|
+
BEFORE calling, ask the user:
|
|
312
|
+
1. Which columns to include? (use 'columns' to restrict — start with 5–10 most relevant)
|
|
313
|
+
2. Any cyclic features? (hour=24, weekday=7, angle=360 → cyclic_features)
|
|
314
|
+
3. Any skewed columns? (suggest transforms: log, sqrt, rank)
|
|
315
|
+
4. Feature weights? (weight > 1 emphasizes, 0 disables)
|
|
316
|
+
5. Quick exploration or refined map?
|
|
317
|
+
Quick: 10x10, epochs=[10,0] | Standard: 20x20, epochs=[20,10] | Refined: 30x30, epochs=[40,20]
|
|
318
|
+
|
|
319
|
+
TRAINING PHASES:
|
|
320
|
+
- Ordering: large neighborhoods → global structure. sigma_f controls end-radius (default 1.0).
|
|
321
|
+
- Convergence: small neighborhoods → fine-tuning. Skip with epochs=[N, 0].
|
|
322
|
+
- sigma_f 0.5–0.7 → sharper clusters. sigma_f 1.0–2.0 → smoother transitions.
|
|
323
|
+
|
|
324
|
+
TRANSFORMS: Per-column preprocessing before normalization.
|
|
325
|
+
transforms: {revenue: "log", volume: "log1p", pressure: "sqrt"}
|
|
326
|
+
Suggest when preview_dataset shows large value ranges or right-skewed distributions.
|
|
327
|
+
|
|
328
|
+
TEMPORAL FEATURES: NEVER auto-apply. Always ask which components to extract.
|
|
329
|
+
temporal_features: [{columns: ['Date'], format: 'dd.mm.yyyy', extract: ['day_of_year'], cyclic: true}]
|
|
330
|
+
|
|
331
|
+
MODEL TYPES: SOM (default), SOM-SOFT (uncertainty), RSOM (time-series), RSOM-SOFT (both).
|
|
332
|
+
|
|
333
|
+
COMMON MISTAKES:
|
|
334
|
+
- Too many features without column selection dilutes the map. Start with 5–10.
|
|
335
|
+
- Forgetting cyclic encoding for periodic variables (hours, angles) causes topology artifacts.
|
|
336
|
+
- Grid too small for the data → high QE. Grid too large → sparse nodes. Use sqrt(5*sqrt(N)).
|
|
337
|
+
- Not log-transforming skewed columns → a few outliers dominate the normalization.
|
|
338
|
+
- Using default batch_size for quality-sensitive work: set batch_size=32–64 for sharper maps.
|
|
339
|
+
- Skipping convergence phase: ordering alone gives rough structure; convergence refines it.
|
|
340
|
+
- Not checking get_training_log: if QE is still dropping, add more epochs.
|
|
341
|
+
|
|
342
|
+
QUALITY TARGETS: QE < 1.5 good, TE < 0.05 good, explained variance > 0.8 good.
|
|
343
|
+
If QE > 2 → more epochs or larger grid. If TE > 0.15 → larger grid or periodic=true.
|
|
344
|
+
|
|
345
|
+
OUTPUT: format (png/pdf/svg), dpi (standard/retina/print), colormap (viridis/plasma/inferno).
|
|
346
|
+
|
|
347
|
+
After training, use get_results → analyze(clusters) → component_planes → feature_correlation.
|
|
348
|
+
See docs/SOM_PROCESS_AND_BEST_PRACTICES.md for detailed processual knowledge.`, {
|
|
349
|
+
dataset_id: z.string().describe("Dataset ID from upload_dataset"),
|
|
350
|
+
preset: z
|
|
351
|
+
.enum(["quick", "standard", "refined", "high_res"])
|
|
352
|
+
.optional()
|
|
353
|
+
.describe("Training preset — sets sensible defaults for grid, epochs, and batch_size. " +
|
|
354
|
+
"Explicit params override preset values. " +
|
|
355
|
+
"quick: 15×15, [10,0], batch=64. " +
|
|
356
|
+
"standard: 25×25, [20,10], batch=64, best with GPU. " +
|
|
357
|
+
"refined: 40×40, [40,20], batch=32, best with GPU. " +
|
|
358
|
+
"high_res: 60×60, [50,30], batch=32, best with GPU."),
|
|
359
|
+
grid_x: z
|
|
360
|
+
.number()
|
|
361
|
+
.int()
|
|
362
|
+
.optional()
|
|
363
|
+
.describe("Grid width (omit for auto from data size)"),
|
|
364
|
+
grid_y: z
|
|
365
|
+
.number()
|
|
366
|
+
.int()
|
|
367
|
+
.optional()
|
|
368
|
+
.describe("Grid height (omit for auto from data size)"),
|
|
369
|
+
epochs: z
|
|
370
|
+
.union([z.number().int(), z.array(z.number().int()).length(2)])
|
|
371
|
+
.optional()
|
|
372
|
+
.describe("Epochs: integer or [ordering, convergence]. Set convergence=0 to skip phase 2 (e.g. [15, 0])."),
|
|
373
|
+
model: z
|
|
374
|
+
.enum(["SOM", "RSOM", "SOM-SOFT", "RSOM-SOFT"])
|
|
375
|
+
.optional()
|
|
376
|
+
.default("SOM")
|
|
377
|
+
.describe("SOM model type. SOM=standard, SOM-SOFT=GTM-style soft responsibilities, RSOM=recurrent (time-series), RSOM-SOFT=recurrent+soft."),
|
|
378
|
+
periodic: z
|
|
379
|
+
.boolean()
|
|
380
|
+
.optional()
|
|
381
|
+
.default(true)
|
|
382
|
+
.describe("Use periodic (toroidal) boundaries"),
|
|
383
|
+
columns: z
|
|
384
|
+
.array(z.string())
|
|
385
|
+
.optional()
|
|
386
|
+
.describe("Subset of CSV column names to train on. Omit to use all columns. Useful to exclude irrelevant features."),
|
|
387
|
+
cyclic_features: z
|
|
388
|
+
.array(z.object({
|
|
389
|
+
feature: z.string().describe("Column name (e.g., 'weekday')"),
|
|
390
|
+
period: z
|
|
391
|
+
.number()
|
|
392
|
+
.describe("Period (e.g., 7 for weekday, 24 for hour, 360 for angle)"),
|
|
393
|
+
}))
|
|
394
|
+
.optional()
|
|
395
|
+
.describe("Features to encode as cyclic (cos, sin) pairs"),
|
|
396
|
+
temporal_features: z
|
|
397
|
+
.array(z.object({
|
|
398
|
+
columns: z
|
|
399
|
+
.array(z.string())
|
|
400
|
+
.describe("Column name(s) containing datetime strings, combined in order (e.g. ['Date', 'Time'])"),
|
|
401
|
+
format: z
|
|
402
|
+
.string()
|
|
403
|
+
.describe("Julia Dates format string from the whitelist (e.g. 'dd.mm.yyyy HH:MM'). Must match the combined column values."),
|
|
404
|
+
extract: z
|
|
405
|
+
.array(z.enum([
|
|
406
|
+
"hour_of_day",
|
|
407
|
+
"day_of_year",
|
|
408
|
+
"month",
|
|
409
|
+
"day_of_week",
|
|
410
|
+
"minute_of_hour",
|
|
411
|
+
]))
|
|
412
|
+
.describe("Which temporal components to extract"),
|
|
413
|
+
cyclic: z
|
|
414
|
+
.boolean()
|
|
415
|
+
.default(true)
|
|
416
|
+
.describe("Encode extracted components as cyclic sin/cos pairs (default true)"),
|
|
417
|
+
separator: z
|
|
418
|
+
.string()
|
|
419
|
+
.optional()
|
|
420
|
+
.describe("Separator when combining multiple columns (default ' '). Use 'T' for ISO 8601."),
|
|
421
|
+
}))
|
|
422
|
+
.optional()
|
|
423
|
+
.describe("Temporal feature extraction from datetime columns. Parses dates/times and extracts components. NEVER add this without user approval."),
|
|
424
|
+
feature_weights: z
|
|
425
|
+
.record(z.number())
|
|
426
|
+
.optional()
|
|
427
|
+
.describe("Per-feature importance weights as {column_name: weight}. Applied after normalization (column *= sqrt(weight)). weight=0 disables, >1 emphasizes, <1 de-emphasizes. Cyclic shorthand: {'day_of_year': 2.0} auto-expands to both _cos and _sin."),
|
|
428
|
+
transforms: z
|
|
429
|
+
.record(z.enum([
|
|
430
|
+
"log",
|
|
431
|
+
"log1p",
|
|
432
|
+
"log10",
|
|
433
|
+
"sqrt",
|
|
434
|
+
"square",
|
|
435
|
+
"abs",
|
|
436
|
+
"invert",
|
|
437
|
+
"rank",
|
|
438
|
+
"none",
|
|
439
|
+
]))
|
|
440
|
+
.optional()
|
|
441
|
+
.describe("Per-column preprocessing applied BEFORE normalization. Example: {revenue: 'log', pressure: 'sqrt'}. " +
|
|
442
|
+
"'log' = natural log (fails on <=0), 'log1p' = log(1+x) (safe for zeros), " +
|
|
443
|
+
"'sqrt' = square root, 'rank' = replace with rank order, 'invert' = 1/x. " +
|
|
444
|
+
"Suggest log/log1p for right-skewed distributions (prices, volumes, counts)."),
|
|
445
|
+
normalize: z
|
|
446
|
+
.union([z.enum(["all", "auto"]), z.array(z.string())])
|
|
447
|
+
.optional()
|
|
448
|
+
.default("auto")
|
|
449
|
+
.describe("Normalization mode. 'auto' skips already-cyclic features."),
|
|
450
|
+
sigma_f: z
|
|
451
|
+
.number()
|
|
452
|
+
.optional()
|
|
453
|
+
.describe("Final neighborhood radius at end of ordering phase (default 1.0). Lower values (0.5–0.7) produce sharper cluster boundaries."),
|
|
454
|
+
learning_rate: z
|
|
455
|
+
.union([
|
|
456
|
+
z.number(),
|
|
457
|
+
z.object({
|
|
458
|
+
ordering: z.tuple([z.number(), z.number()]),
|
|
459
|
+
convergence: z.tuple([z.number(), z.number()]),
|
|
460
|
+
}),
|
|
461
|
+
])
|
|
462
|
+
.optional()
|
|
463
|
+
.describe("Learning rate control. Number = sets ordering final rate (e.g. 0.05). Object = full control: {ordering: [eta_0, eta_f], convergence: [eta_0, eta_f]}. Default: ordering 0.1→0.01, convergence 0.01→0.001."),
|
|
464
|
+
batch_size: z
|
|
465
|
+
.number()
|
|
466
|
+
.int()
|
|
467
|
+
.optional()
|
|
468
|
+
.describe("Training batch size (default: auto ≈ n_samples/10, max 256). Smaller batches (e.g. 32–64) often sharpen features and can improve map quality (QE, explained variance) at the cost of more steps per epoch. Larger batches = faster epochs but coarser updates; try 64–256 for large datasets (>10k samples)."),
|
|
469
|
+
backend: z
|
|
470
|
+
.enum(["auto", "cpu", "cuda", "cuda_graphs"])
|
|
471
|
+
.optional()
|
|
472
|
+
.default("auto")
|
|
473
|
+
.describe("Compute backend. 'auto' uses CUDA if GPU is available (recommended). 'cpu' forces CPU. 'cuda_graphs' uses CUDA graph capture for maximum GPU throughput."),
|
|
474
|
+
output_format: z
|
|
475
|
+
.enum(["png", "pdf", "svg"])
|
|
476
|
+
.optional()
|
|
477
|
+
.default("png")
|
|
478
|
+
.describe("Image output format. PNG for quick viewing (default), PDF for publication-quality vector graphics, SVG for web embedding."),
|
|
479
|
+
output_dpi: z
|
|
480
|
+
.enum(["standard", "retina", "print"])
|
|
481
|
+
.optional()
|
|
482
|
+
.default("retina")
|
|
483
|
+
.describe("Resolution for PNG output: standard (1x), retina (2x, default), print (4x). Ignored for PDF/SVG."),
|
|
484
|
+
colormap: z
|
|
485
|
+
.string()
|
|
486
|
+
.optional()
|
|
487
|
+
.describe("Override default colormap for component planes (e.g. viridis, plasma, inferno, coolwarm). U-matrix always uses grays, cyclic features use twilight."),
|
|
488
|
+
}, 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, }) => {
|
|
489
|
+
const PRESETS = {
|
|
490
|
+
quick: { grid: [15, 15], epochs: [10, 0], batch_size: 64 },
|
|
491
|
+
standard: { grid: [25, 25], epochs: [20, 10], batch_size: 64, backend: "cuda" },
|
|
492
|
+
refined: { grid: [40, 40], epochs: [40, 20], batch_size: 32, backend: "cuda" },
|
|
493
|
+
high_res: { grid: [60, 60], epochs: [50, 30], batch_size: 32, backend: "cuda" },
|
|
494
|
+
};
|
|
495
|
+
const p = preset ? PRESETS[preset] : undefined;
|
|
496
|
+
const params = {
|
|
497
|
+
model,
|
|
498
|
+
periodic,
|
|
499
|
+
normalize,
|
|
500
|
+
};
|
|
501
|
+
if (grid_x !== undefined && grid_y !== undefined) {
|
|
502
|
+
params.grid = [grid_x, grid_y];
|
|
503
|
+
}
|
|
504
|
+
else if (p) {
|
|
505
|
+
params.grid = p.grid;
|
|
506
|
+
}
|
|
507
|
+
if (epochs !== undefined) {
|
|
508
|
+
params.epochs = epochs;
|
|
509
|
+
}
|
|
510
|
+
else if (p) {
|
|
511
|
+
params.epochs = p.epochs;
|
|
512
|
+
}
|
|
513
|
+
if (cyclic_features && cyclic_features.length > 0) {
|
|
514
|
+
params.cyclic_features = cyclic_features;
|
|
515
|
+
}
|
|
516
|
+
if (columns && columns.length > 0) {
|
|
517
|
+
params.columns = columns;
|
|
518
|
+
}
|
|
519
|
+
if (transforms && Object.keys(transforms).length > 0) {
|
|
520
|
+
params.transforms = transforms;
|
|
521
|
+
}
|
|
522
|
+
if (temporal_features && temporal_features.length > 0) {
|
|
523
|
+
params.temporal_features = temporal_features;
|
|
524
|
+
}
|
|
525
|
+
if (feature_weights && Object.keys(feature_weights).length > 0) {
|
|
526
|
+
params.feature_weights = feature_weights;
|
|
527
|
+
}
|
|
528
|
+
if (sigma_f !== undefined) {
|
|
529
|
+
params.sigma_f = sigma_f;
|
|
530
|
+
}
|
|
531
|
+
if (learning_rate !== undefined) {
|
|
532
|
+
params.learning_rate = learning_rate;
|
|
533
|
+
}
|
|
534
|
+
if (batch_size !== undefined) {
|
|
535
|
+
params.batch_size = batch_size;
|
|
536
|
+
}
|
|
537
|
+
else if (p) {
|
|
538
|
+
params.batch_size = p.batch_size;
|
|
539
|
+
}
|
|
540
|
+
if (backend !== undefined && backend !== "auto") {
|
|
541
|
+
params.backend = backend;
|
|
542
|
+
}
|
|
543
|
+
else if (p?.backend) {
|
|
544
|
+
params.backend = p.backend;
|
|
545
|
+
}
|
|
546
|
+
if (output_format && output_format !== "png") {
|
|
547
|
+
params.output_format = output_format;
|
|
548
|
+
}
|
|
549
|
+
const dpiMap = { standard: 1, retina: 2, print: 4 };
|
|
550
|
+
if (output_dpi && output_dpi !== "retina") {
|
|
551
|
+
params.output_dpi = dpiMap[output_dpi] ?? 2;
|
|
552
|
+
}
|
|
553
|
+
if (colormap) {
|
|
554
|
+
params.colormap = colormap;
|
|
555
|
+
}
|
|
556
|
+
const data = await apiCall("POST", "/v1/jobs", { dataset_id, params });
|
|
557
|
+
return textResult(data);
|
|
558
|
+
});
|
|
559
|
+
// ---- get_job_status ----
|
|
560
|
+
server.tool("get_job_status", `Check status and progress of a training or analysis job.
|
|
561
|
+
|
|
562
|
+
TIMING: Poll every 3–5s for small jobs, every 10–15s for large grids.
|
|
563
|
+
Typical completion times (CPU, 8700 samples):
|
|
564
|
+
10x10, 10 epochs: ~30s | 20x20, 30 epochs: ~3–5 min | 40x40, 60 epochs: ~15–30 min
|
|
565
|
+
|
|
566
|
+
When status is 'completed', call get_results to retrieve the map and metrics.
|
|
567
|
+
When status is 'failed', show the error to the user and suggest parameter adjustments.`, {
|
|
568
|
+
job_id: z.string().describe("Job ID from train_som"),
|
|
569
|
+
}, async ({ job_id }) => {
|
|
570
|
+
const data = (await apiCall("GET", `/v1/jobs/${job_id}`));
|
|
571
|
+
const status = data.status;
|
|
572
|
+
const progress = (data.progress ?? 0) * 100;
|
|
573
|
+
let text = `Job ${job_id}: ${status} (${progress.toFixed(1)}%)`;
|
|
574
|
+
if (status === "completed") {
|
|
575
|
+
text += ` | Results ready. Use get_results(job_id="${job_id}") to retrieve.`;
|
|
576
|
+
}
|
|
577
|
+
else if (status === "failed") {
|
|
578
|
+
text += ` | Error: ${data.error ?? "unknown"}`;
|
|
579
|
+
}
|
|
580
|
+
return { content: [{ type: "text", text }] };
|
|
581
|
+
});
|
|
582
|
+
// ---- get_results ----
|
|
583
|
+
server.tool("get_results", `Retrieve results of a completed SOM training, projection, or derived variable job.
|
|
584
|
+
|
|
585
|
+
BEST FOR: Getting the first look at a trained SOM — combined visualization + quality metrics.
|
|
586
|
+
TIMING: Near-instant (reads pre-computed results from S3).
|
|
587
|
+
|
|
588
|
+
Returns: text summary with metrics, inline combined visualization, and resource download links.
|
|
589
|
+
|
|
590
|
+
OPTIONS:
|
|
591
|
+
- include_individual=true: shows each component plane, U-matrix, and hit histogram
|
|
592
|
+
as separate inline images. Best for side-by-side feature comparison.
|
|
593
|
+
|
|
594
|
+
AFTER showing results, guide the user:
|
|
595
|
+
1. "The U-matrix shows [N] distinct regions. Does this match expected groupings?"
|
|
596
|
+
2. "QE=X, TE=Y — [assessment]. Would you like to retrain with different params?"
|
|
597
|
+
3. "Which features show interesting patterns in the component planes?"
|
|
598
|
+
4. If QE > 2: suggest more epochs or larger grid
|
|
599
|
+
5. If TE > 0.15: suggest larger grid
|
|
600
|
+
6. If explained variance < 0.7: suggest transforms, feature selection, or more training
|
|
601
|
+
|
|
602
|
+
WORKFLOW: get_results → analyze(clusters) → component_planes → feature_correlation.
|
|
603
|
+
Use get_training_log() for the learning curve (QE vs epoch — healthy=steady decline then plateau).
|
|
604
|
+
Use quality_report() for extended metrics (trustworthiness, neighborhood preservation).
|
|
605
|
+
|
|
606
|
+
METRIC INTERPRETATION:
|
|
607
|
+
- QE < 1.5: good fit. QE > 2: consider more epochs, larger grid, or batch_size=32.
|
|
608
|
+
- TE < 0.05: good topology. TE > 0.15: grid too small.
|
|
609
|
+
- Explained variance > 0.8: good. < 0.7: try transforms, fewer features, or more training.`, {
|
|
610
|
+
job_id: z.string().describe("Job ID of a completed job"),
|
|
611
|
+
include_individual: z
|
|
612
|
+
.boolean()
|
|
613
|
+
.optional()
|
|
614
|
+
.default(false)
|
|
615
|
+
.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."),
|
|
616
|
+
}, async ({ job_id, include_individual }) => {
|
|
617
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}`));
|
|
618
|
+
const summary = (data.summary ?? {});
|
|
619
|
+
const downloadUrls = (data.download_urls ?? {});
|
|
620
|
+
const content = [];
|
|
621
|
+
const jobType = summary.job_type ?? "train_som";
|
|
622
|
+
// ── Dispatch by job type ──────────────────────────────────────────────────
|
|
623
|
+
const fmtExt = summary.output_format ?? "png";
|
|
624
|
+
if (jobType === "transition_flow") {
|
|
625
|
+
const lag = summary.lag ?? 1;
|
|
626
|
+
const flowImg = `transition_flow_lag${lag}.${fmtExt}`;
|
|
627
|
+
const stats = summary.flow_stats ?? {};
|
|
628
|
+
content.push({
|
|
629
|
+
type: "text",
|
|
630
|
+
text: [
|
|
631
|
+
`Transition Flow Results (job: ${job_id})`,
|
|
632
|
+
`Parent SOM: ${summary.parent_job_id ?? "N/A"} | Lag: ${lag} | Samples: ${summary.n_samples ?? 0}`,
|
|
633
|
+
``,
|
|
634
|
+
`Flow Statistics:`,
|
|
635
|
+
` Mean flow magnitude: ${stats.mean_magnitude !== undefined ? Number(stats.mean_magnitude).toFixed(4) : "N/A"}`,
|
|
636
|
+
` Max flow magnitude: ${stats.max_magnitude !== undefined ? Number(stats.max_magnitude).toFixed(4) : "N/A"}`,
|
|
637
|
+
` Nodes with flow: ${stats.n_nodes_with_flow ?? "N/A"}`,
|
|
638
|
+
``,
|
|
639
|
+
`Arrows show net directional drift between consecutive BMU assignments.`,
|
|
640
|
+
`Long/bright arrows = frequent state transitions. Short arrows = stable states.`,
|
|
641
|
+
`Background = U-matrix (cluster boundaries). Arrows in dark regions = intra-cluster.`,
|
|
642
|
+
``,
|
|
643
|
+
`Use transition_flow(lag=N) with larger N to reveal longer-term temporal structure.`,
|
|
644
|
+
].join("\n"),
|
|
645
|
+
});
|
|
646
|
+
await tryAttachImage(content, job_id, flowImg);
|
|
647
|
+
}
|
|
648
|
+
else if (jobType === "project_variable") {
|
|
649
|
+
const varName = summary.variable_name ?? "variable";
|
|
650
|
+
const agg = summary.aggregation ?? "mean";
|
|
651
|
+
const stats = summary.variable_stats ?? {};
|
|
652
|
+
const projImg = `projected_${varName}.${fmtExt}`;
|
|
653
|
+
content.push({
|
|
654
|
+
type: "text",
|
|
655
|
+
text: [
|
|
656
|
+
`Projected Variable: ${varName} (${agg}) — job: ${job_id}`,
|
|
657
|
+
`Parent SOM: ${summary.parent_job_id ?? "N/A"} | Samples: ${summary.n_samples ?? 0}`,
|
|
658
|
+
``,
|
|
659
|
+
`Variable Statistics (per-node ${agg}):`,
|
|
660
|
+
` Min: ${stats.min !== undefined ? Number(stats.min).toFixed(3) : "N/A"}`,
|
|
661
|
+
` Max: ${stats.max !== undefined ? Number(stats.max).toFixed(3) : "N/A"}`,
|
|
662
|
+
` Mean: ${stats.mean !== undefined ? Number(stats.mean).toFixed(3) : "N/A"}`,
|
|
663
|
+
` Nodes with data: ${stats.n_nodes_with_data ?? "N/A"} / ${(Number(stats.n_nodes_with_data ?? 0) + Number(stats.n_nodes_empty ?? 0))}`,
|
|
664
|
+
``,
|
|
665
|
+
`Non-random spatial patterns indicate the variable correlates with the SOM's`,
|
|
666
|
+
`learned feature space, even if it wasn't used in training.`,
|
|
667
|
+
].join("\n"),
|
|
668
|
+
});
|
|
669
|
+
await tryAttachImage(content, job_id, projImg);
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
// ── Default: train_som results ──────────────────────────────────────────
|
|
673
|
+
const grid = summary.grid ?? [0, 0];
|
|
674
|
+
const features = summary.features ?? [];
|
|
675
|
+
const epochs = summary.epochs;
|
|
676
|
+
const epochStr = Array.isArray(epochs)
|
|
677
|
+
? epochs[1] === 0
|
|
678
|
+
? `${epochs[0]} ordering only`
|
|
679
|
+
: `${epochs[0]} ordering + ${epochs[1]} convergence`
|
|
680
|
+
: String(epochs ?? "N/A");
|
|
681
|
+
const fmt = (v) => v !== null && v !== undefined ? Number(v).toFixed(4) : "N/A";
|
|
682
|
+
const duration = summary.training_duration_seconds;
|
|
683
|
+
const ordErrors = summary.ordering_errors;
|
|
684
|
+
const textSummary = [
|
|
685
|
+
`SOM Training Results (job: ${job_id})`,
|
|
686
|
+
`Grid: ${grid[0]}×${grid[1]} | Features: ${summary.n_features ?? 0} | Samples: ${summary.n_samples ?? 0}`,
|
|
687
|
+
`Model: ${summary.model ?? "SOM"} | Epochs: ${epochStr}`,
|
|
688
|
+
`Periodic: ${summary.periodic ?? true} | Normalize: ${summary.normalize ?? "auto"}`,
|
|
689
|
+
summary.sigma_f !== undefined ? `Sigma_f: ${summary.sigma_f}` : "",
|
|
690
|
+
duration !== undefined ? `Training duration: ${duration}s` : "",
|
|
691
|
+
``,
|
|
692
|
+
`Quality Metrics:`,
|
|
693
|
+
` Quantization Error: ${fmt(summary.quantization_error)} (lower is better)`,
|
|
694
|
+
` Topographic Error: ${fmt(summary.topographic_error)} (lower is better, <0.1 is good)`,
|
|
695
|
+
` Explained Variance: ${fmt(summary.explained_variance)} (higher is better, >0.7 is good)`,
|
|
696
|
+
` Silhouette Score: ${fmt(summary.silhouette)} (higher is better, [-1, 1])`,
|
|
697
|
+
` Davies-Bouldin: ${fmt(summary.davies_bouldin)} (lower is better)`,
|
|
698
|
+
` Calinski-Harabasz: ${fmt(summary.calinski_harabasz)} (higher is better)`,
|
|
699
|
+
ordErrors && ordErrors.length > 0
|
|
700
|
+
? ` Final ordering QE: ${ordErrors.at(-1)?.toFixed(4)} (use get_training_log for full curve)`
|
|
701
|
+
: "",
|
|
702
|
+
``,
|
|
703
|
+
`Features: ${features.join(", ")}`,
|
|
704
|
+
summary.selected_columns
|
|
705
|
+
? `Selected columns: ${summary.selected_columns.join(", ")}`
|
|
706
|
+
: "",
|
|
707
|
+
summary.transforms
|
|
708
|
+
? `Transforms: ${Object.entries(summary.transforms).map(([k, v]) => `${k}=${v}`).join(", ")}`
|
|
709
|
+
: "",
|
|
710
|
+
``,
|
|
711
|
+
`Use analyze() for deeper insights, quality_report() for extended metrics, get_training_log() for learning curves.`,
|
|
712
|
+
]
|
|
713
|
+
.filter((l) => l !== "")
|
|
714
|
+
.join("\n");
|
|
715
|
+
content.push({ type: "text", text: textSummary });
|
|
716
|
+
const imgExt = summary.output_format ?? "png";
|
|
717
|
+
await tryAttachImage(content, job_id, `combined.${imgExt}`);
|
|
718
|
+
if (include_individual) {
|
|
719
|
+
const feats = summary.features ?? [];
|
|
720
|
+
const imageNames = [
|
|
721
|
+
`umatrix.${imgExt}`,
|
|
722
|
+
`hit_histogram.${imgExt}`,
|
|
723
|
+
...feats.map((f, i) => `component_${i + 1}_${f.replace(/[^a-zA-Z0-9_]/g, "_")}.${imgExt}`),
|
|
724
|
+
];
|
|
725
|
+
const results = await Promise.allSettled(imageNames.map((name) => apiRawCall(`/v1/results/${job_id}/image/${name}`).then((r) => ({ name, ...r }))));
|
|
726
|
+
for (const r of results) {
|
|
727
|
+
if (r.status === "fulfilled") {
|
|
728
|
+
content.push({
|
|
729
|
+
type: "image",
|
|
730
|
+
data: r.value.data.toString("base64"),
|
|
731
|
+
mimeType: mimeForFilename(r.value.name),
|
|
732
|
+
annotations: { audience: ["user"], priority: 0.8 },
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Resource links for all files
|
|
739
|
+
const files = summary.files ?? [];
|
|
740
|
+
for (const fname of files) {
|
|
741
|
+
const url = downloadUrls[fname] ??
|
|
742
|
+
`${API_URL}/v1/results/${job_id}/image/${fname}`;
|
|
743
|
+
const mimeType = fname.endsWith(".json")
|
|
744
|
+
? "application/json"
|
|
745
|
+
: fname.endsWith(".pdf")
|
|
746
|
+
? "application/pdf"
|
|
747
|
+
: fname.endsWith(".svg")
|
|
748
|
+
? "image/svg+xml"
|
|
749
|
+
: "image/png";
|
|
750
|
+
content.push({
|
|
751
|
+
type: "resource_link",
|
|
752
|
+
uri: url,
|
|
753
|
+
name: fname,
|
|
754
|
+
mimeType,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return { content };
|
|
758
|
+
});
|
|
759
|
+
// ---- analyze ----
|
|
760
|
+
server.tool("analyze", `Run a specific analysis on SOM results. Use after get_results to drill into aspects.
|
|
761
|
+
|
|
762
|
+
Available analysis types and when to use them:
|
|
763
|
+
|
|
764
|
+
u_matrix — Cluster boundary map. Ask: "Do boundary regions match expected
|
|
765
|
+
groupings? How many clusters do you see?"
|
|
766
|
+
component_planes — Per-feature distributions. Ask: "Which features show similar
|
|
767
|
+
spatial patterns? Those are correlated in your data."
|
|
768
|
+
bmu_hits — Data density per node. Ask: "Are there dense hot-spots?
|
|
769
|
+
Sparse regions may be interpolated or rare states."
|
|
770
|
+
clusters — Quality assessment with recommendations. Always run this after
|
|
771
|
+
get_results to decide whether to retrain.
|
|
772
|
+
feature_importance — Rank features by SOM contribution (component plane variance).
|
|
773
|
+
Ask: "Does this ranking match your domain knowledge?"
|
|
774
|
+
feature_correlation — Compare all component planes side-by-side for correlation.
|
|
775
|
+
Ask: "Which pairs of features move together? Are any redundant?"
|
|
776
|
+
transition_flow — Temporal BMU transition patterns (best for time-series data).
|
|
777
|
+
Ask: "Do you see cyclic or directional patterns in the transitions?"
|
|
778
|
+
local_density — Identify cluster cores vs boundaries. Ask: "Where are the
|
|
779
|
+
high-density regions? Do they correspond to known operating modes?"
|
|
780
|
+
feature_gradient — Spatial rate of change per feature. Ask: "Where does this
|
|
781
|
+
feature change most rapidly? Does it align with cluster boundaries?"
|
|
782
|
+
|
|
783
|
+
WORKFLOW RECOMMENDATION:
|
|
784
|
+
1. Start with clusters → check quality metrics and recommendations
|
|
785
|
+
2. Then u_matrix → identify cluster boundaries (dark=cores, bright=boundaries)
|
|
786
|
+
3. Then component_planes or feature_importance → understand feature roles
|
|
787
|
+
(similar spatial patterns in component planes = correlated features)
|
|
788
|
+
4. Then feature_correlation → find redundant or linked features
|
|
789
|
+
5. For time-series: add transition_flow and local_density
|
|
790
|
+
|
|
791
|
+
INTERPRETATION TIPS:
|
|
792
|
+
- U-matrix: count dark "basins" to estimate cluster count; bright bands are boundaries.
|
|
793
|
+
- Component planes: compare side-by-side; similar patterns mean correlated features.
|
|
794
|
+
- BMU hits: empty nodes suggest oversized grid or data gaps.
|
|
795
|
+
- If quality is low, retrain with: larger grid, more epochs, smaller batch_size, or transforms.`, {
|
|
796
|
+
job_id: z.string().describe("Job ID of a completed job"),
|
|
797
|
+
analysis_type: z
|
|
798
|
+
.enum([
|
|
799
|
+
"u_matrix",
|
|
800
|
+
"component_planes",
|
|
801
|
+
"bmu_hits",
|
|
802
|
+
"clusters",
|
|
803
|
+
"feature_importance",
|
|
804
|
+
"feature_correlation",
|
|
805
|
+
"transition_flow",
|
|
806
|
+
"local_density",
|
|
807
|
+
"feature_gradient",
|
|
808
|
+
])
|
|
809
|
+
.describe("Type of analysis to run"),
|
|
810
|
+
params: z
|
|
811
|
+
.record(z.unknown())
|
|
812
|
+
.optional()
|
|
813
|
+
.describe("Analysis-specific parameters. For component_planes/feature_gradient: {features: [col,...]} to restrict to specific columns."),
|
|
814
|
+
}, async ({ job_id, analysis_type, params: extraParams }) => {
|
|
815
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}`));
|
|
816
|
+
const summary = (data.summary ?? {});
|
|
817
|
+
const features = summary.features ?? [];
|
|
818
|
+
const grid = summary.grid ?? [0, 0];
|
|
819
|
+
const ext = summary.output_format ?? "png";
|
|
820
|
+
const content = [];
|
|
821
|
+
if (analysis_type === "u_matrix") {
|
|
822
|
+
content.push({
|
|
823
|
+
type: "text",
|
|
824
|
+
text: [
|
|
825
|
+
`U-Matrix Analysis (job: ${job_id})`,
|
|
826
|
+
`Grid: ${grid[0]}×${grid[1]}`,
|
|
827
|
+
``,
|
|
828
|
+
`The U-matrix shows average distances between neighboring nodes.`,
|
|
829
|
+
` High values (bright/white) = cluster boundaries`,
|
|
830
|
+
` Low values (dark) = cluster cores`,
|
|
831
|
+
``,
|
|
832
|
+
`What to look for:`,
|
|
833
|
+
` - Dark islands separated by bright ridges = distinct clusters`,
|
|
834
|
+
` - Gradual transitions = continuous variation, no hard boundaries`,
|
|
835
|
+
` - Uniform brightness = poorly organized map (try more epochs)`,
|
|
836
|
+
].join("\n"),
|
|
837
|
+
});
|
|
838
|
+
await tryAttachImage(content, job_id, `umatrix.${ext}`);
|
|
839
|
+
}
|
|
840
|
+
else if (analysis_type === "component_planes") {
|
|
841
|
+
const requested = extraParams?.features ?? features;
|
|
842
|
+
content.push({
|
|
843
|
+
type: "text",
|
|
844
|
+
text: [
|
|
845
|
+
`Component Planes (job: ${job_id})`,
|
|
846
|
+
`Features: ${requested.join(", ")}`,
|
|
847
|
+
``,
|
|
848
|
+
`Each panel shows one feature's distribution across the SOM.`,
|
|
849
|
+
` Similar color patterns = correlated features`,
|
|
850
|
+
` Inverse patterns = negatively correlated features`,
|
|
851
|
+
` Unique patterns = independent structure drivers`,
|
|
852
|
+
].join("\n"),
|
|
853
|
+
});
|
|
854
|
+
for (let i = 0; i < features.length; i++) {
|
|
855
|
+
if (!requested.includes(features[i]))
|
|
856
|
+
continue;
|
|
857
|
+
const safeName = features[i].replace(/[^a-zA-Z0-9_]/g, "_");
|
|
858
|
+
await tryAttachImage(content, job_id, `component_${i + 1}_${safeName}.${ext}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
else if (analysis_type === "bmu_hits") {
|
|
862
|
+
content.push({
|
|
863
|
+
type: "text",
|
|
864
|
+
text: [
|
|
865
|
+
`BMU Hit Histogram (job: ${job_id})`,
|
|
866
|
+
`Grid: ${grid[0]}×${grid[1]} | Samples: ${summary.n_samples ?? 0}`,
|
|
867
|
+
``,
|
|
868
|
+
`Shows data density per SOM node.`,
|
|
869
|
+
` Large values (yellow/bright) = dense data regions (common operating states)`,
|
|
870
|
+
` Zero/low (dark purple) = sparse or interpolated areas`,
|
|
871
|
+
``,
|
|
872
|
+
`Cross-reference with U-matrix: dense nodes inside dark U-matrix regions`,
|
|
873
|
+
`indicate well-populated cluster cores.`,
|
|
874
|
+
].join("\n"),
|
|
875
|
+
});
|
|
876
|
+
await tryAttachImage(content, job_id, `hit_histogram.${ext}`);
|
|
877
|
+
}
|
|
878
|
+
else if (analysis_type === "clusters") {
|
|
879
|
+
const qe = summary.quantization_error;
|
|
880
|
+
const te = summary.topographic_error;
|
|
881
|
+
const ev = summary.explained_variance;
|
|
882
|
+
const sil = summary.silhouette;
|
|
883
|
+
const qeLabel = qe === undefined ? "N/A" :
|
|
884
|
+
qe < 0.5 ? "excellent" :
|
|
885
|
+
qe < 1.0 ? "good" :
|
|
886
|
+
qe < 2.0 ? "fair" : "poor";
|
|
887
|
+
const teLabel = te === undefined ? "N/A" :
|
|
888
|
+
te < 0.05 ? "excellent" :
|
|
889
|
+
te < 0.10 ? "good" :
|
|
890
|
+
te < 0.20 ? "fair" : "poor";
|
|
891
|
+
const fmt = (v) => v !== undefined ? v.toFixed(4) : "N/A";
|
|
892
|
+
const recommendations = [];
|
|
893
|
+
if (te !== undefined && te > 0.15) {
|
|
894
|
+
recommendations.push(`Topographic error ${(te * 100).toFixed(1)}% is high — try a larger grid or more epochs.`);
|
|
895
|
+
}
|
|
896
|
+
if (qe !== undefined && qe > 2.0) {
|
|
897
|
+
recommendations.push(`Quantization error ${qe.toFixed(3)} is high — try more epochs, a larger grid, or check for outliers.`);
|
|
898
|
+
}
|
|
899
|
+
if (ev !== undefined && ev < 0.7) {
|
|
900
|
+
recommendations.push(`Explained variance ${(ev * 100).toFixed(1)}% is low — try more epochs, a larger grid, or feature weighting.`);
|
|
901
|
+
}
|
|
902
|
+
if (sil !== undefined && sil < 0.1) {
|
|
903
|
+
recommendations.push(`Low silhouette score — clusters overlap. Try sigma_f=0.5 or more training.`);
|
|
904
|
+
}
|
|
905
|
+
if (recommendations.length === 0) {
|
|
906
|
+
recommendations.push(`Metrics look healthy. Proceed with component plane and feature analysis.`);
|
|
907
|
+
}
|
|
908
|
+
const gridStr = grid[0] > 0 ? `${grid[0]}×${grid[1]}` : "N/A";
|
|
909
|
+
content.push({
|
|
910
|
+
type: "text",
|
|
911
|
+
text: [
|
|
912
|
+
`Cluster Quality Assessment (job: ${job_id})`,
|
|
913
|
+
`Grid: ${gridStr} | Features: ${features.length} | Samples: ${summary.n_samples ?? "N/A"}`,
|
|
914
|
+
``,
|
|
915
|
+
`Quantization Error: ${fmt(qe)} (${qeLabel})`,
|
|
916
|
+
`Topographic Error: ${fmt(te)} (${teLabel})`,
|
|
917
|
+
`Explained Variance: ${fmt(ev)}`,
|
|
918
|
+
`Silhouette Score: ${fmt(sil)}`,
|
|
919
|
+
`Davies-Bouldin: ${fmt(summary.davies_bouldin)}`,
|
|
920
|
+
`Calinski-Harabasz: ${fmt(summary.calinski_harabasz)}`,
|
|
921
|
+
``,
|
|
922
|
+
`Recommendations:`,
|
|
923
|
+
...recommendations.map((r) => ` - ${r}`),
|
|
924
|
+
].join("\n"),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
else if (analysis_type === "feature_importance") {
|
|
928
|
+
content.push({
|
|
929
|
+
type: "text",
|
|
930
|
+
text: [
|
|
931
|
+
`Feature Importance Analysis (job: ${job_id})`,
|
|
932
|
+
`Grid: ${grid[0]}×${grid[1]} | Features: ${features.length}`,
|
|
933
|
+
``,
|
|
934
|
+
`Feature importance is determined by the variance of each component plane.`,
|
|
935
|
+
`Higher variance = feature contributes more to the SOM structure.`,
|
|
936
|
+
``,
|
|
937
|
+
`Features analyzed: ${features.join(", ")}`,
|
|
938
|
+
``,
|
|
939
|
+
`Compare the component planes visually: features with the most varied`,
|
|
940
|
+
`color gradients are the primary drivers of the cluster structure.`,
|
|
941
|
+
`Features with near-uniform color contribute little to differentiation.`,
|
|
942
|
+
].join("\n"),
|
|
943
|
+
});
|
|
944
|
+
await tryAttachImage(content, job_id, `combined.${ext}`);
|
|
945
|
+
}
|
|
946
|
+
else if (analysis_type === "feature_correlation") {
|
|
947
|
+
content.push({
|
|
948
|
+
type: "text",
|
|
949
|
+
text: [
|
|
950
|
+
`Feature Correlation Analysis (job: ${job_id})`,
|
|
951
|
+
`Features: ${features.join(", ")}`,
|
|
952
|
+
``,
|
|
953
|
+
`Compare component planes side-by-side to identify correlated features.`,
|
|
954
|
+
` Similar spatial patterns = positively correlated`,
|
|
955
|
+
` Inverse/mirrored patterns = negatively correlated`,
|
|
956
|
+
` Unrelated patterns = independent features`,
|
|
957
|
+
``,
|
|
958
|
+
`Correlated features may be redundant — consider disabling one via feature_weights: {col: 0}.`,
|
|
959
|
+
].join("\n"),
|
|
960
|
+
});
|
|
961
|
+
for (let i = 0; i < features.length; i++) {
|
|
962
|
+
const safeName = features[i].replace(/[^a-zA-Z0-9_]/g, "_");
|
|
963
|
+
await tryAttachImage(content, job_id, `component_${i + 1}_${safeName}.${ext}`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else if (analysis_type === "transition_flow") {
|
|
967
|
+
content.push({
|
|
968
|
+
type: "text",
|
|
969
|
+
text: [
|
|
970
|
+
`Transition Flow Analysis (job: ${job_id})`,
|
|
971
|
+
`Grid: ${grid[0]}×${grid[1]} | Samples: ${summary.n_samples ?? 0}`,
|
|
972
|
+
``,
|
|
973
|
+
`Transition flow shows how data points move between SOM nodes in sequence.`,
|
|
974
|
+
`This reveals temporal patterns and state machine behavior.`,
|
|
975
|
+
``,
|
|
976
|
+
`What to look for:`,
|
|
977
|
+
` - Dense arrow clusters = frequent state transitions (common paths)`,
|
|
978
|
+
` - Circular/cyclic flows = periodic behavior (daily/seasonal cycles)`,
|
|
979
|
+
` - Long-range transitions = regime changes or anomalies`,
|
|
980
|
+
``,
|
|
981
|
+
`Note: Full transition flow arrows require server-side support (planned).`,
|
|
982
|
+
`Currently showing U-matrix for cluster boundary context.`,
|
|
983
|
+
].join("\n"),
|
|
984
|
+
});
|
|
985
|
+
await tryAttachImage(content, job_id, `umatrix.${ext}`);
|
|
986
|
+
}
|
|
987
|
+
else if (analysis_type === "local_density") {
|
|
988
|
+
content.push({
|
|
989
|
+
type: "text",
|
|
990
|
+
text: [
|
|
991
|
+
`Local Density & Cluster Analysis (job: ${job_id})`,
|
|
992
|
+
`Grid: ${grid[0]}×${grid[1]} | Samples: ${summary.n_samples ?? 0}`,
|
|
993
|
+
``,
|
|
994
|
+
`Local density = inverse of U-matrix values.`,
|
|
995
|
+
` High density (low U-matrix) = cluster cores (similar neighbors)`,
|
|
996
|
+
` Low density (high U-matrix) = cluster boundaries (dissimilar neighbors)`,
|
|
997
|
+
``,
|
|
998
|
+
`Cross-reference hit histogram with U-matrix:`,
|
|
999
|
+
` Dense hits + low U-matrix = populated cluster core (dominant operating mode)`,
|
|
1000
|
+
` Dense hits + high U-matrix = transition zone with many samples (worth investigating)`,
|
|
1001
|
+
` Sparse hits anywhere = rare state or interpolated region`,
|
|
1002
|
+
].join("\n"),
|
|
1003
|
+
});
|
|
1004
|
+
await tryAttachImage(content, job_id, `umatrix.${ext}`);
|
|
1005
|
+
await tryAttachImage(content, job_id, `hit_histogram.${ext}`);
|
|
1006
|
+
}
|
|
1007
|
+
else if (analysis_type === "feature_gradient") {
|
|
1008
|
+
const targetFeature = extraParams?.feature;
|
|
1009
|
+
content.push({
|
|
1010
|
+
type: "text",
|
|
1011
|
+
text: [
|
|
1012
|
+
`Feature Gradient Analysis (job: ${job_id})`,
|
|
1013
|
+
`Target: ${targetFeature ?? "all features"}`,
|
|
1014
|
+
`Grid: ${grid[0]}×${grid[1]}`,
|
|
1015
|
+
``,
|
|
1016
|
+
`Feature gradients show where each feature changes most rapidly on the SOM.`,
|
|
1017
|
+
` High gradient = feature transitions rapidly (boundary region for this feature)`,
|
|
1018
|
+
` Low gradient = feature is stable across this region`,
|
|
1019
|
+
``,
|
|
1020
|
+
`Compare with U-matrix: if feature gradients align with U-matrix boundaries,`,
|
|
1021
|
+
`this feature is a key driver of the cluster separation.`,
|
|
1022
|
+
].join("\n"),
|
|
1023
|
+
});
|
|
1024
|
+
if (targetFeature) {
|
|
1025
|
+
const idx = features.indexOf(targetFeature);
|
|
1026
|
+
if (idx >= 0) {
|
|
1027
|
+
const safeName = targetFeature.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1028
|
+
await tryAttachImage(content, job_id, `component_${idx + 1}_${safeName}.${ext}`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
await tryAttachImage(content, job_id, `combined.${ext}`);
|
|
1033
|
+
}
|
|
1034
|
+
await tryAttachImage(content, job_id, `umatrix.${ext}`);
|
|
1035
|
+
}
|
|
1036
|
+
return { content };
|
|
1037
|
+
});
|
|
1038
|
+
// ---- compare_runs ----
|
|
1039
|
+
server.tool("compare_runs", `Compare metrics across multiple completed SOM training jobs.
|
|
1040
|
+
Returns a table of QE, TE, silhouette, and other metrics for each job.
|
|
1041
|
+
|
|
1042
|
+
Use to evaluate hyperparameter choices: grid size, epochs, sigma_f, model type, feature selection.
|
|
1043
|
+
|
|
1044
|
+
After comparing, ask the user:
|
|
1045
|
+
"Which job produced the best metrics for your goal?"
|
|
1046
|
+
- For visualization clarity: prioritize low topographic error (<0.1)
|
|
1047
|
+
- For tight clusters: prioritize low QE and high silhouette
|
|
1048
|
+
- For dimensionality reduction: prioritize high explained variance (>0.8)`, {
|
|
1049
|
+
job_ids: z
|
|
1050
|
+
.array(z.string())
|
|
1051
|
+
.min(2)
|
|
1052
|
+
.describe("Array of job IDs to compare (minimum 2)"),
|
|
1053
|
+
}, async ({ job_ids }) => {
|
|
1054
|
+
const ids = job_ids.join(",");
|
|
1055
|
+
const data = (await apiCall("GET", `/v1/jobs/compare?ids=${ids}`));
|
|
1056
|
+
const comparisons = (data.comparisons ?? []);
|
|
1057
|
+
const lines = [
|
|
1058
|
+
"| Job ID | Grid | Epochs | Model | QE | TE | Expl.Var | Silhouette |",
|
|
1059
|
+
"|--------|------|--------|-------|----|----|----------|------------|",
|
|
1060
|
+
];
|
|
1061
|
+
for (const c of comparisons) {
|
|
1062
|
+
if (c.error) {
|
|
1063
|
+
lines.push(`| ${c.job_id.slice(0, 8)}... | — | — | — | ${c.error} | — | — | — |`);
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const g = c.grid;
|
|
1067
|
+
const gridStr = g ? `${g[0]}×${g[1]}` : "—";
|
|
1068
|
+
const ep = c.epochs;
|
|
1069
|
+
const epStr = ep
|
|
1070
|
+
? ep[1] === 0
|
|
1071
|
+
? `${ep[0]}+0`
|
|
1072
|
+
: `${ep[0]}+${ep[1]}`
|
|
1073
|
+
: "—";
|
|
1074
|
+
const model = c.model ?? "—";
|
|
1075
|
+
const fmt = (v) => v !== null && v !== undefined ? Number(v).toFixed(4) : "—";
|
|
1076
|
+
lines.push(`| ${c.job_id.slice(0, 8)}... | ${gridStr} | ${epStr} | ${model} | ${fmt(c.quantization_error)} | ${fmt(c.topographic_error)} | ${fmt(c.explained_variance)} | ${fmt(c.silhouette)} |`);
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
// ---- cancel_job ----
|
|
1083
|
+
server.tool("cancel_job", `Cancel a pending or running job.
|
|
1084
|
+
|
|
1085
|
+
TIMING: Cancellation is not instant — the worker checks between training phases.
|
|
1086
|
+
Expect up to 30s delay for the job to actually stop.
|
|
1087
|
+
|
|
1088
|
+
Use when a training run is too slow, wrong parameters were submitted, or you
|
|
1089
|
+
want to free the worker for a different job. Partial results are discarded.
|
|
1090
|
+
After cancelling, submit a new job with corrected parameters.`, {
|
|
1091
|
+
job_id: z.string().describe("Job ID to cancel"),
|
|
1092
|
+
}, async ({ job_id }) => {
|
|
1093
|
+
const data = await apiCall("POST", `/v1/jobs/${job_id}/cancel`);
|
|
1094
|
+
return textResult(data);
|
|
1095
|
+
});
|
|
1096
|
+
// ---- delete_job ----
|
|
1097
|
+
server.tool("delete_job", `Delete a job and all its S3 result files.
|
|
1098
|
+
|
|
1099
|
+
Use when:
|
|
1100
|
+
- Cleaning up old or failed jobs to free storage
|
|
1101
|
+
- Removing test runs before going to production
|
|
1102
|
+
- The job is cancelled and you no longer need the record
|
|
1103
|
+
|
|
1104
|
+
WARNING: This permanently deletes all result files (images, weights, node stats).
|
|
1105
|
+
The job ID will no longer be usable with get_results or any other tools.`, {
|
|
1106
|
+
job_id: z.string().describe("Job ID to delete"),
|
|
1107
|
+
}, async ({ job_id }) => {
|
|
1108
|
+
const data = await apiCall("DELETE", `/v1/jobs/${job_id}`);
|
|
1109
|
+
return textResult(data);
|
|
1110
|
+
});
|
|
1111
|
+
// ---- preview_dataset ----
|
|
1112
|
+
server.tool("preview_dataset", `Preview a dataset before training — shows columns, statistics, sample rows, and detections.
|
|
1113
|
+
|
|
1114
|
+
BEST FOR: Understanding data structure before training. ALWAYS call this before train_som
|
|
1115
|
+
on an unfamiliar dataset.
|
|
1116
|
+
NOT FOR: Large data exploration (returns only sample rows). Use derive_variable for computations.
|
|
1117
|
+
|
|
1118
|
+
TIMING: Near-instant (reads only header + sample rows from S3).
|
|
1119
|
+
|
|
1120
|
+
This tool detects:
|
|
1121
|
+
1. Column types (numeric vs string) and basic stats (min/max/mean/std)
|
|
1122
|
+
2. Cyclic feature candidates (columns named hour, weekday, angle, direction, etc.)
|
|
1123
|
+
3. Datetime columns with format auto-detection
|
|
1124
|
+
4. Skewed distributions (large max/min ratios suggest log transforms)
|
|
1125
|
+
|
|
1126
|
+
AFTER previewing, ask the user:
|
|
1127
|
+
- "Which columns are relevant?" → columns parameter in train_som
|
|
1128
|
+
- "I see cyclic candidates: [list]. Encode cyclically?" → cyclic_features
|
|
1129
|
+
- "Column X ranges 0.01–50,000. Log-transform?" → transforms: {X: "log"}
|
|
1130
|
+
- "Datetime columns found. Extract temporal features?" → temporal_features (NEVER auto-apply)
|
|
1131
|
+
- "Are any features more important than others?" → feature_weights
|
|
1132
|
+
|
|
1133
|
+
COMMON MISTAKES:
|
|
1134
|
+
- Skipping preview and training on all columns (including IDs, timestamps, irrelevant features)
|
|
1135
|
+
- Not checking for datetime columns that could provide valuable cyclic features
|
|
1136
|
+
- Ignoring skewed distributions that will dominate normalization
|
|
1137
|
+
|
|
1138
|
+
TIP: Use the prepare_training prompt for a structured walkthrough of all decisions.`, {
|
|
1139
|
+
dataset_id: z.string().describe("Dataset ID to preview"),
|
|
1140
|
+
n_rows: z
|
|
1141
|
+
.number()
|
|
1142
|
+
.int()
|
|
1143
|
+
.optional()
|
|
1144
|
+
.default(5)
|
|
1145
|
+
.describe("Number of sample rows to return (default 5)"),
|
|
1146
|
+
}, async ({ dataset_id, n_rows }) => {
|
|
1147
|
+
const data = (await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=${n_rows ?? 5}`));
|
|
1148
|
+
const cols = data.columns ?? [];
|
|
1149
|
+
const stats = data.column_stats ?? [];
|
|
1150
|
+
const hints = data.cyclic_hints ?? [];
|
|
1151
|
+
const samples = data.sample_rows ?? [];
|
|
1152
|
+
const dtCols = data.datetime_columns ?? [];
|
|
1153
|
+
const temporalSugg = data.temporal_suggestions ?? [];
|
|
1154
|
+
const fmt = (v) => v === null || v === undefined ? "—" : Number(v).toFixed(3);
|
|
1155
|
+
const lines = [
|
|
1156
|
+
`Dataset: ${data.name} (${data.dataset_id})`,
|
|
1157
|
+
`${data.total_rows} rows × ${data.total_cols} columns`,
|
|
1158
|
+
``,
|
|
1159
|
+
`Column Statistics:`,
|
|
1160
|
+
`| Column | Min | Max | Mean | Std | Nulls | Numeric |`,
|
|
1161
|
+
`|--------|-----|-----|------|-----|-------|---------|`,
|
|
1162
|
+
];
|
|
1163
|
+
for (const s of stats) {
|
|
1164
|
+
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"} |`);
|
|
1165
|
+
}
|
|
1166
|
+
if (hints.length > 0) {
|
|
1167
|
+
lines.push(``, `Detected Cyclic Feature Hints:`);
|
|
1168
|
+
for (const h of hints) {
|
|
1169
|
+
lines.push(` • ${h.column} — period=${h.period} (${h.reason})`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (dtCols.length > 0) {
|
|
1173
|
+
lines.push(``, `Detected Datetime Columns:`);
|
|
1174
|
+
for (const dc of dtCols) {
|
|
1175
|
+
const formats = dc.detected_formats ?? [];
|
|
1176
|
+
const fmtStrs = formats
|
|
1177
|
+
.map((f) => `${f.format} — ${f.description} (${(f.match_rate * 100).toFixed(0)}% match)`)
|
|
1178
|
+
.join("; ");
|
|
1179
|
+
lines.push(` • ${dc.column}: sample="${dc.sample}" → ${fmtStrs}`);
|
|
1180
|
+
if (formats.length > 1) {
|
|
1181
|
+
lines.push(` ⚠ AMBIGUOUS: multiple formats match. Ask user to clarify.`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (temporalSugg.length > 0) {
|
|
1186
|
+
lines.push(``, `Temporal Feature Suggestions (require user approval):`);
|
|
1187
|
+
for (const ts of temporalSugg) {
|
|
1188
|
+
lines.push(` • Columns: ${ts.columns.join(" + ")} → format: "${ts.format}"`);
|
|
1189
|
+
lines.push(` Available components: ${ts.available_components.join(", ")}`);
|
|
1190
|
+
lines.push(` ${ts.note}`);
|
|
1191
|
+
}
|
|
1192
|
+
lines.push(``, `To use temporal features in train_som, add:`, ` temporal_features: [{columns: [...], format: "...", extract: [...], cyclic: true}]`);
|
|
1193
|
+
}
|
|
1194
|
+
if (samples.length > 0) {
|
|
1195
|
+
lines.push(``, `Sample Rows (first ${samples.length}):`);
|
|
1196
|
+
lines.push(`| ${cols.join(" | ")} |`);
|
|
1197
|
+
lines.push(`| ${cols.map(() => "---").join(" | ")} |`);
|
|
1198
|
+
for (const row of samples) {
|
|
1199
|
+
lines.push(`| ${cols.map((c) => String(row[c] ?? "")).join(" | ")} |`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
return {
|
|
1203
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1204
|
+
};
|
|
1205
|
+
});
|
|
1206
|
+
// ---- delete_dataset ----
|
|
1207
|
+
server.tool("delete_dataset", "Delete a dataset and its stored data. Frees a dataset slot for new uploads.", {
|
|
1208
|
+
dataset_id: z.string().describe("Dataset ID to delete"),
|
|
1209
|
+
}, async ({ dataset_id }) => {
|
|
1210
|
+
const data = await apiCall("DELETE", `/v1/datasets/${dataset_id}`);
|
|
1211
|
+
return textResult(data);
|
|
1212
|
+
});
|
|
1213
|
+
// ---- list_datasets ----
|
|
1214
|
+
server.tool("list_datasets", `List all datasets uploaded by the current organization.
|
|
1215
|
+
|
|
1216
|
+
Use this to check what data is available before calling train_som,
|
|
1217
|
+
or to find dataset IDs for datasets that were uploaded previously.`, {}, async () => {
|
|
1218
|
+
const data = await apiCall("GET", "/v1/datasets");
|
|
1219
|
+
return textResult(data);
|
|
1220
|
+
});
|
|
1221
|
+
// ---- list_jobs ----
|
|
1222
|
+
server.tool("list_jobs", `List all SOM training jobs, optionally filtered by dataset.
|
|
1223
|
+
|
|
1224
|
+
Shows status, params, and metrics for each job. Use this to:
|
|
1225
|
+
- Find job IDs for compare_runs
|
|
1226
|
+
- Check which jobs are completed vs pending
|
|
1227
|
+
- Review what hyperparameters were used in previous runs`, {
|
|
1228
|
+
dataset_id: z
|
|
1229
|
+
.string()
|
|
1230
|
+
.optional()
|
|
1231
|
+
.describe("Filter by dataset ID (omit to list all jobs)"),
|
|
1232
|
+
}, async ({ dataset_id }) => {
|
|
1233
|
+
const path = dataset_id
|
|
1234
|
+
? `/v1/jobs?dataset_id=${dataset_id}`
|
|
1235
|
+
: "/v1/jobs";
|
|
1236
|
+
const data = await apiCall("GET", path);
|
|
1237
|
+
return textResult(data);
|
|
1238
|
+
});
|
|
1239
|
+
// ---- get_training_log ----
|
|
1240
|
+
server.tool("get_training_log", `Retrieve the learning curve and training diagnostics for a completed job.
|
|
1241
|
+
|
|
1242
|
+
Returns per-epoch quantization error arrays, ASCII sparklines, AND an inline
|
|
1243
|
+
learning curve plot (generated during training) showing QE vs epoch for both
|
|
1244
|
+
ordering and convergence phases.
|
|
1245
|
+
|
|
1246
|
+
Use this to diagnose training quality:
|
|
1247
|
+
|
|
1248
|
+
- **Healthy**: errors drop steadily, then plateau (converged)
|
|
1249
|
+
- **Still learning**: errors still dropping at end → try more epochs
|
|
1250
|
+
- **Diverged**: errors increase → learning rate too high, try lower values
|
|
1251
|
+
- **Flat from start**: poor initialization or tiny grid
|
|
1252
|
+
|
|
1253
|
+
After showing the log, ask the user:
|
|
1254
|
+
- "The training shows [observation]. Would you like to adjust epochs or learning rate?"
|
|
1255
|
+
- If errors plateaued early: "Convergence was reached quickly. Consider a larger grid for more detail."
|
|
1256
|
+
- If errors were still falling: "Training was cut short. Add more epochs for a better map."
|
|
1257
|
+
|
|
1258
|
+
Also shows training duration, which helps estimate time for future runs.
|
|
1259
|
+
|
|
1260
|
+
BATCH SIZE EFFECT: Smaller batch sizes (32–64) produce more update steps per epoch,
|
|
1261
|
+
often yielding lower final QE and smoother convergence curves. If the learning curve
|
|
1262
|
+
plateaus early, try more epochs. If it's noisy, try a larger batch size for stability.`, {
|
|
1263
|
+
job_id: z.string().describe("Job ID of a completed training job"),
|
|
1264
|
+
}, async ({ job_id }) => {
|
|
1265
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}/training-log`));
|
|
1266
|
+
const ordErrors = data.ordering_errors ?? [];
|
|
1267
|
+
const convErrors = data.convergence_errors ?? [];
|
|
1268
|
+
const duration = data.training_duration_seconds;
|
|
1269
|
+
const epochs = data.epochs;
|
|
1270
|
+
const sparkline = (arr) => {
|
|
1271
|
+
if (arr.length === 0)
|
|
1272
|
+
return "(no data)";
|
|
1273
|
+
const blocks = "▁▂▃▄▅▆▇█";
|
|
1274
|
+
const min = Math.min(...arr);
|
|
1275
|
+
const max = Math.max(...arr);
|
|
1276
|
+
const range = max - min || 1;
|
|
1277
|
+
return arr
|
|
1278
|
+
.map((v) => blocks[Math.min(7, Math.floor(((v - min) / range) * 7))])
|
|
1279
|
+
.join("");
|
|
1280
|
+
};
|
|
1281
|
+
const lines = [
|
|
1282
|
+
`Training Log — Job ${job_id}`,
|
|
1283
|
+
`Grid: ${JSON.stringify(data.grid)} | Model: ${data.model ?? "SOM"}`,
|
|
1284
|
+
`Epochs: ${epochs ? `[${epochs[0]} ordering, ${epochs[1]} convergence]` : "N/A"}`,
|
|
1285
|
+
`Duration: ${duration !== null && duration !== undefined ? `${duration}s` : "N/A"}`,
|
|
1286
|
+
`Features: ${data.n_features ?? "?"} | Samples: ${data.n_samples ?? "?"}`,
|
|
1287
|
+
``,
|
|
1288
|
+
`Ordering Phase (${ordErrors.length} epochs):`,
|
|
1289
|
+
` Start QE: ${ordErrors[0]?.toFixed(4) ?? "—"} → End QE: ${ordErrors.at(-1)?.toFixed(4) ?? "—"}`,
|
|
1290
|
+
` Curve: ${sparkline(ordErrors)}`,
|
|
1291
|
+
];
|
|
1292
|
+
if (convErrors.length > 0) {
|
|
1293
|
+
lines.push(``, `Convergence Phase (${convErrors.length} epochs):`, ` Start QE: ${convErrors[0]?.toFixed(4) ?? "—"} → End QE: ${convErrors.at(-1)?.toFixed(4) ?? "—"}`, ` Curve: ${sparkline(convErrors)}`);
|
|
1294
|
+
}
|
|
1295
|
+
else if ((epochs?.[1] ?? 0) === 0) {
|
|
1296
|
+
lines.push(``, `Convergence phase: skipped (epochs[1]=0)`);
|
|
1297
|
+
}
|
|
1298
|
+
const finalQe = data.quantization_error;
|
|
1299
|
+
const finalEv = data.explained_variance;
|
|
1300
|
+
if (finalQe !== null && finalQe !== undefined) {
|
|
1301
|
+
lines.push(``, `Final QE: ${finalQe.toFixed(4)} | Explained Variance: ${(finalEv ?? 0).toFixed(4)}`);
|
|
1302
|
+
}
|
|
1303
|
+
const content = [
|
|
1304
|
+
{ type: "text", text: lines.join("\n") },
|
|
1305
|
+
];
|
|
1306
|
+
// Inline the pre-generated learning curve plot (worker saves it during training).
|
|
1307
|
+
// Try png first (default), then pdf/svg in case the job used a different format.
|
|
1308
|
+
let attached = false;
|
|
1309
|
+
for (const lcExt of ["png", "pdf", "svg"]) {
|
|
1310
|
+
try {
|
|
1311
|
+
const { data: lcBuf } = await apiRawCall(`/v1/results/${job_id}/image/learning_curve.${lcExt}`);
|
|
1312
|
+
content.push({
|
|
1313
|
+
type: "image",
|
|
1314
|
+
data: lcBuf.toString("base64"),
|
|
1315
|
+
mimeType: mimeForFilename(`learning_curve.${lcExt}`),
|
|
1316
|
+
annotations: { audience: ["user"], priority: 0.8 },
|
|
1317
|
+
});
|
|
1318
|
+
attached = true;
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
catch {
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (!attached) {
|
|
1326
|
+
content.push({ type: "text", text: "(learning curve plot not available)" });
|
|
1327
|
+
}
|
|
1328
|
+
return { content };
|
|
1329
|
+
});
|
|
1330
|
+
// ---- get_weights ----
|
|
1331
|
+
server.tool("get_weights", `Export the raw SOM weight matrix for a completed job.
|
|
1332
|
+
|
|
1333
|
+
Returns a structured weight matrix with:
|
|
1334
|
+
- Per-node coordinates (for spatial mapping)
|
|
1335
|
+
- Normalized and denormalized weight values per feature
|
|
1336
|
+
- Normalization statistics (mean/std used during training)
|
|
1337
|
+
|
|
1338
|
+
Use this to:
|
|
1339
|
+
- Export the trained model for external analysis
|
|
1340
|
+
- Visualize the weight space in custom tools
|
|
1341
|
+
- Compare weight structures between training runs
|
|
1342
|
+
- Build custom projections or classifications
|
|
1343
|
+
|
|
1344
|
+
The weight matrix can be large for big grids. Consider filtering to specific
|
|
1345
|
+
features if you only need a subset.`, {
|
|
1346
|
+
job_id: z.string().describe("Job ID of a completed training job"),
|
|
1347
|
+
}, async ({ job_id }) => {
|
|
1348
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}/weights`));
|
|
1349
|
+
const features = data.features ?? [];
|
|
1350
|
+
const nNodes = data.n_nodes ?? 0;
|
|
1351
|
+
const grid = data.grid ?? [0, 0];
|
|
1352
|
+
const lines = [
|
|
1353
|
+
`SOM Weights — Job ${job_id}`,
|
|
1354
|
+
`Grid: ${grid[0]}×${grid[1]} | Nodes: ${nNodes} | Features: ${features.length}`,
|
|
1355
|
+
`Features: ${features.join(", ")}`,
|
|
1356
|
+
``,
|
|
1357
|
+
`Normalization Stats:`,
|
|
1358
|
+
];
|
|
1359
|
+
const normStats = data.normalization_stats ?? {};
|
|
1360
|
+
for (const [feat, s] of Object.entries(normStats)) {
|
|
1361
|
+
lines.push(` ${feat}: mean=${s.mean?.toFixed(4)}, std=${s.std?.toFixed(4)}`);
|
|
1362
|
+
}
|
|
1363
|
+
lines.push(``, `Full weight matrix available in the response JSON.`, `Use the denormalized_weights array for original-scale values.`);
|
|
1364
|
+
return {
|
|
1365
|
+
content: [
|
|
1366
|
+
{ type: "text", text: lines.join("\n") },
|
|
1367
|
+
{ type: "text", text: JSON.stringify(data, null, 2) },
|
|
1368
|
+
],
|
|
1369
|
+
};
|
|
1370
|
+
});
|
|
1371
|
+
// ---- get_node_data ----
|
|
1372
|
+
server.tool("get_node_data", `Get per-node statistics for a completed SOM job.
|
|
1373
|
+
|
|
1374
|
+
Returns for each SOM node:
|
|
1375
|
+
- Hit count (how many data points map to this node)
|
|
1376
|
+
- Feature mean and std for all samples that map to this node
|
|
1377
|
+
|
|
1378
|
+
This answers "what data lives in this cluster?" — enabling characterization
|
|
1379
|
+
of distinct operating modes, regimes, or behavioral groups.
|
|
1380
|
+
|
|
1381
|
+
Use this to:
|
|
1382
|
+
- Profile each cluster by its feature distributions
|
|
1383
|
+
- Find dominant nodes (high hit count) vs rare nodes
|
|
1384
|
+
- Compare feature distributions between nodes
|
|
1385
|
+
- Identify the most "representative" state for each cluster
|
|
1386
|
+
|
|
1387
|
+
After showing node data, ask the user:
|
|
1388
|
+
- "Do these cluster profiles match your domain knowledge?"
|
|
1389
|
+
- "Which nodes represent the most common operating states?"
|
|
1390
|
+
- "Are there any nodes with extreme feature values worth investigating?"`, {
|
|
1391
|
+
job_id: z.string().describe("Job ID of a completed training job"),
|
|
1392
|
+
}, async ({ job_id }) => {
|
|
1393
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}/nodes`));
|
|
1394
|
+
const topNodes = [...data]
|
|
1395
|
+
.sort((a, b) => (b.hit_count ?? 0) - (a.hit_count ?? 0))
|
|
1396
|
+
.slice(0, 10);
|
|
1397
|
+
const emptyNodes = data.filter((n) => n.hit_count === 0).length;
|
|
1398
|
+
const totalHits = data.reduce((sum, n) => sum + (n.hit_count ?? 0), 0);
|
|
1399
|
+
const lines = [
|
|
1400
|
+
`Node Statistics — Job ${job_id}`,
|
|
1401
|
+
`Total nodes: ${data.length} | Active: ${data.length - emptyNodes} | Empty: ${emptyNodes}`,
|
|
1402
|
+
`Total hits: ${totalHits}`,
|
|
1403
|
+
``,
|
|
1404
|
+
`Top 10 Most Populated Nodes:`,
|
|
1405
|
+
`| Node | Coords | Hits | Hit% |`,
|
|
1406
|
+
`|------|--------|------|------|`,
|
|
1407
|
+
];
|
|
1408
|
+
for (const n of topNodes) {
|
|
1409
|
+
if (n.hit_count === 0)
|
|
1410
|
+
break;
|
|
1411
|
+
const coords = n.coords;
|
|
1412
|
+
const pct = ((n.hit_count / totalHits) * 100).toFixed(1);
|
|
1413
|
+
lines.push(`| ${n.node_index} | (${coords?.[0]?.toFixed(1)}, ${coords?.[1]?.toFixed(1)}) | ${n.hit_count} | ${pct}% |`);
|
|
1414
|
+
}
|
|
1415
|
+
return {
|
|
1416
|
+
content: [
|
|
1417
|
+
{ type: "text", text: lines.join("\n") },
|
|
1418
|
+
{
|
|
1419
|
+
type: "text",
|
|
1420
|
+
text: `\nFull node statistics JSON:\n${JSON.stringify(data, null, 2)}`,
|
|
1421
|
+
},
|
|
1422
|
+
],
|
|
1423
|
+
};
|
|
1424
|
+
});
|
|
1425
|
+
// ---- project_variable ----
|
|
1426
|
+
server.tool("project_variable", `Project a pre-computed variable onto a trained SOM without retraining.
|
|
1427
|
+
|
|
1428
|
+
BEST FOR: Mapping external metrics (revenue, labels, anomaly scores) onto the
|
|
1429
|
+
trained SOM structure. Use derive_variable instead if you need to compute
|
|
1430
|
+
the variable from existing dataset columns via a formula.
|
|
1431
|
+
NOT FOR: Re-training or adding features to the map.
|
|
1432
|
+
|
|
1433
|
+
TIMING: ~5–15s (loads cached SOM, computes per-node aggregation, renders plot).
|
|
1434
|
+
|
|
1435
|
+
The values array must have exactly one value per training sample (same CSV row order).
|
|
1436
|
+
Aggregation controls how multiple samples per node are combined (mean/median/sum/max/count).
|
|
1437
|
+
|
|
1438
|
+
BEFORE calling, ask:
|
|
1439
|
+
- "What variable? Is it from the original data or externally computed?"
|
|
1440
|
+
- "How to aggregate per node: mean (typical), sum (totals), max (peaks)?"
|
|
1441
|
+
|
|
1442
|
+
COMMON MISTAKES:
|
|
1443
|
+
- Wrong number of values (must match n_samples from training)
|
|
1444
|
+
- Using mean aggregation for count data (use sum instead)
|
|
1445
|
+
- Not trying derive_variable first when the variable can be computed from columns
|
|
1446
|
+
|
|
1447
|
+
TIP: If the variable is a formula over existing columns (e.g., revenue/cost),
|
|
1448
|
+
use derive_variable with project_onto_job instead — it handles the computation.`, {
|
|
1449
|
+
job_id: z.string().describe("ID of the completed SOM training job"),
|
|
1450
|
+
variable_name: z.string().describe("Name for this variable (used in visualization labels)"),
|
|
1451
|
+
values: z
|
|
1452
|
+
.array(z.number())
|
|
1453
|
+
.describe("Array of values to project — one per training sample, in original CSV row order"),
|
|
1454
|
+
aggregation: z
|
|
1455
|
+
.enum(["mean", "median", "sum", "min", "max", "std", "count"])
|
|
1456
|
+
.optional()
|
|
1457
|
+
.default("mean")
|
|
1458
|
+
.describe("How to aggregate values for nodes with multiple samples"),
|
|
1459
|
+
output_format: z
|
|
1460
|
+
.enum(["png", "pdf", "svg"])
|
|
1461
|
+
.optional()
|
|
1462
|
+
.default("png")
|
|
1463
|
+
.describe("Image output format for the projection plot."),
|
|
1464
|
+
output_dpi: z
|
|
1465
|
+
.enum(["standard", "retina", "print"])
|
|
1466
|
+
.optional()
|
|
1467
|
+
.default("retina")
|
|
1468
|
+
.describe("Resolution: standard (1x), retina (2x), print (4x)."),
|
|
1469
|
+
colormap: z
|
|
1470
|
+
.string()
|
|
1471
|
+
.optional()
|
|
1472
|
+
.describe("Override colormap for the projection plot (default: plasma)."),
|
|
1473
|
+
}, async ({ job_id, variable_name, values, aggregation, output_format, output_dpi, colormap }) => {
|
|
1474
|
+
const dpiMap = { standard: 1, retina: 2, print: 4 };
|
|
1475
|
+
const body = {
|
|
1476
|
+
variable_name,
|
|
1477
|
+
values,
|
|
1478
|
+
aggregation: aggregation ?? "mean",
|
|
1479
|
+
};
|
|
1480
|
+
if (output_format && output_format !== "png")
|
|
1481
|
+
body.output_format = output_format;
|
|
1482
|
+
if (output_dpi && output_dpi !== "retina")
|
|
1483
|
+
body.output_dpi = dpiMap[output_dpi] ?? 2;
|
|
1484
|
+
if (colormap)
|
|
1485
|
+
body.colormap = colormap;
|
|
1486
|
+
const data = (await apiCall("POST", `/v1/results/${job_id}/project`, body));
|
|
1487
|
+
const projJobId = data.id;
|
|
1488
|
+
const poll = await pollUntilComplete(projJobId);
|
|
1489
|
+
if (poll.status === "completed") {
|
|
1490
|
+
const results = (await apiCall("GET", `/v1/results/${projJobId}`));
|
|
1491
|
+
const summary = (results.summary ?? {});
|
|
1492
|
+
const stats = (summary.variable_stats ?? {});
|
|
1493
|
+
const content = [];
|
|
1494
|
+
content.push({
|
|
1495
|
+
type: "text",
|
|
1496
|
+
text: [
|
|
1497
|
+
`Projected Variable: ${variable_name} (${aggregation ?? "mean"}) — job: ${projJobId}`,
|
|
1498
|
+
`Parent SOM: ${job_id} | Samples: ${summary.n_samples ?? 0}`,
|
|
1499
|
+
``,
|
|
1500
|
+
`Variable Statistics (per-node ${aggregation ?? "mean"}):`,
|
|
1501
|
+
` Min: ${stats.min !== undefined ? Number(stats.min).toFixed(3) : "N/A"}`,
|
|
1502
|
+
` Max: ${stats.max !== undefined ? Number(stats.max).toFixed(3) : "N/A"}`,
|
|
1503
|
+
` Mean: ${stats.mean !== undefined ? Number(stats.mean).toFixed(3) : "N/A"}`,
|
|
1504
|
+
` Nodes with data: ${stats.n_nodes_with_data ?? "N/A"}`,
|
|
1505
|
+
].join("\n"),
|
|
1506
|
+
});
|
|
1507
|
+
const safeName = variable_name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1508
|
+
const imgExt = summary.output_format ?? output_format ?? "png";
|
|
1509
|
+
await tryAttachImage(content, projJobId, `projected_${safeName}.${imgExt}`);
|
|
1510
|
+
return { content };
|
|
1511
|
+
}
|
|
1512
|
+
if (poll.status === "failed") {
|
|
1513
|
+
return {
|
|
1514
|
+
content: [{
|
|
1515
|
+
type: "text",
|
|
1516
|
+
text: `Projection job ${projJobId} failed: ${poll.error ?? "unknown error"}`,
|
|
1517
|
+
}],
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
return {
|
|
1521
|
+
content: [{
|
|
1522
|
+
type: "text",
|
|
1523
|
+
text: [
|
|
1524
|
+
`Variable projection job submitted but did not complete within 30s.`,
|
|
1525
|
+
`Projection job ID: ${projJobId}`,
|
|
1526
|
+
``,
|
|
1527
|
+
`Poll with: get_job_status(job_id="${projJobId}")`,
|
|
1528
|
+
`Retrieve with: get_results(job_id="${projJobId}")`,
|
|
1529
|
+
].join("\n"),
|
|
1530
|
+
}],
|
|
1531
|
+
};
|
|
1532
|
+
});
|
|
1533
|
+
// ---- transition_flow ----
|
|
1534
|
+
server.tool("transition_flow", `Compute temporal transition flow for a trained SOM.
|
|
1535
|
+
|
|
1536
|
+
Shows how data points move between SOM nodes over time — revealing directional
|
|
1537
|
+
patterns, cycles, and state machine behavior in sequential data.
|
|
1538
|
+
|
|
1539
|
+
Best used for:
|
|
1540
|
+
- **Time-series data**: how does the system evolve over time?
|
|
1541
|
+
- **Cyclic processes**: daily/weekly patterns, recurring operating modes
|
|
1542
|
+
- **Process monitoring**: identify common transition paths between states
|
|
1543
|
+
- **Anomaly detection**: transitions that deviate from normal flow patterns
|
|
1544
|
+
|
|
1545
|
+
The lag parameter controls how many steps ahead to look for transitions.
|
|
1546
|
+
lag=1 shows immediate next-step transitions.
|
|
1547
|
+
lag=N shows transitions N steps apart (useful for periodic analysis).
|
|
1548
|
+
|
|
1549
|
+
BEFORE calling, ask the user:
|
|
1550
|
+
- "Is your data ordered in time? What is the time resolution of each row?"
|
|
1551
|
+
- "What lag should we use? lag=1 for immediate transitions, larger for longer-range patterns"
|
|
1552
|
+
- "Are there any breaks in the time series (missing intervals, separate sessions)?"
|
|
1553
|
+
|
|
1554
|
+
Output options: output_format (png/pdf/svg), output_dpi (standard/retina/print).
|
|
1555
|
+
|
|
1556
|
+
After showing results, ask:
|
|
1557
|
+
- "Do the flow arrows show expected directional patterns in your process?"
|
|
1558
|
+
- "Are there circular/cyclic regions? Do those match known periodic behavior?"
|
|
1559
|
+
- "Which nodes act as hubs (many transitions through them)?"`, {
|
|
1560
|
+
job_id: z.string().describe("ID of the completed SOM training job"),
|
|
1561
|
+
lag: z
|
|
1562
|
+
.number()
|
|
1563
|
+
.int()
|
|
1564
|
+
.optional()
|
|
1565
|
+
.default(1)
|
|
1566
|
+
.describe("Step lag for transition pairs (default 1 = consecutive rows)"),
|
|
1567
|
+
output_format: z
|
|
1568
|
+
.enum(["png", "pdf", "svg"])
|
|
1569
|
+
.optional()
|
|
1570
|
+
.default("png")
|
|
1571
|
+
.describe("Image output format for the flow plot."),
|
|
1572
|
+
output_dpi: z
|
|
1573
|
+
.enum(["standard", "retina", "print"])
|
|
1574
|
+
.optional()
|
|
1575
|
+
.default("retina")
|
|
1576
|
+
.describe("Resolution: standard (1x), retina (2x), print (4x)."),
|
|
1577
|
+
}, async ({ job_id, lag, output_format, output_dpi }) => {
|
|
1578
|
+
const dpiMap = { standard: 1, retina: 2, print: 4 };
|
|
1579
|
+
const body = { lag: lag ?? 1 };
|
|
1580
|
+
if (output_format && output_format !== "png")
|
|
1581
|
+
body.output_format = output_format;
|
|
1582
|
+
if (output_dpi && output_dpi !== "retina")
|
|
1583
|
+
body.output_dpi = dpiMap[output_dpi] ?? 2;
|
|
1584
|
+
const data = (await apiCall("POST", `/v1/results/${job_id}/transition-flow`, body));
|
|
1585
|
+
const flowJobId = data.id;
|
|
1586
|
+
const poll = await pollUntilComplete(flowJobId);
|
|
1587
|
+
if (poll.status === "completed") {
|
|
1588
|
+
const results = (await apiCall("GET", `/v1/results/${flowJobId}`));
|
|
1589
|
+
const summary = (results.summary ?? {});
|
|
1590
|
+
const stats = (summary.flow_stats ?? {});
|
|
1591
|
+
const content = [];
|
|
1592
|
+
content.push({
|
|
1593
|
+
type: "text",
|
|
1594
|
+
text: [
|
|
1595
|
+
`Transition Flow Results (job: ${flowJobId})`,
|
|
1596
|
+
`Parent SOM: ${job_id} | Lag: ${lag ?? 1} | Samples: ${summary.n_samples ?? 0}`,
|
|
1597
|
+
``,
|
|
1598
|
+
`Flow Statistics:`,
|
|
1599
|
+
` Active flow nodes: ${stats.active_flow_nodes ?? "N/A"}`,
|
|
1600
|
+
` Total transitions: ${stats.total_transitions ?? "N/A"}`,
|
|
1601
|
+
` Mean magnitude: ${stats.mean_magnitude !== undefined ? Number(stats.mean_magnitude).toFixed(4) : "N/A"}`,
|
|
1602
|
+
].join("\n"),
|
|
1603
|
+
});
|
|
1604
|
+
const imgExt = output_format ?? "png";
|
|
1605
|
+
await tryAttachImage(content, flowJobId, `transition_flow_lag${lag ?? 1}.${imgExt}`);
|
|
1606
|
+
return { content };
|
|
1607
|
+
}
|
|
1608
|
+
if (poll.status === "failed") {
|
|
1609
|
+
return {
|
|
1610
|
+
content: [{
|
|
1611
|
+
type: "text",
|
|
1612
|
+
text: `Transition flow job ${flowJobId} failed: ${poll.error ?? "unknown error"}`,
|
|
1613
|
+
}],
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
content: [{
|
|
1618
|
+
type: "text",
|
|
1619
|
+
text: [
|
|
1620
|
+
`Transition flow job submitted but did not complete within 30s.`,
|
|
1621
|
+
`Flow job ID: ${flowJobId}`,
|
|
1622
|
+
`Parent job: ${job_id} | Lag: ${lag ?? 1}`,
|
|
1623
|
+
``,
|
|
1624
|
+
`Poll with: get_job_status(job_id="${flowJobId}")`,
|
|
1625
|
+
`Retrieve with: get_results(job_id="${flowJobId}")`,
|
|
1626
|
+
].join("\n"),
|
|
1627
|
+
}],
|
|
1628
|
+
};
|
|
1629
|
+
});
|
|
1630
|
+
// ---- derive_variable ----
|
|
1631
|
+
server.tool("derive_variable", `Create a derived variable from existing dataset columns using mathematical expressions.
|
|
1632
|
+
|
|
1633
|
+
BEST FOR: Computing ratios, differences, log transforms, rolling statistics,
|
|
1634
|
+
or any combination of existing columns — either to enrich a dataset before
|
|
1635
|
+
training or to project a computed variable onto an existing SOM.
|
|
1636
|
+
|
|
1637
|
+
TWO MODES:
|
|
1638
|
+
1. Add to dataset (default): computes the new column and appends it to the dataset CSV.
|
|
1639
|
+
The column is then available for future train_som calls via the 'columns' parameter.
|
|
1640
|
+
2. Project onto SOM: computes the column from the training dataset and projects it
|
|
1641
|
+
onto a trained SOM, returning the visualization. Use this to explore how
|
|
1642
|
+
derived quantities distribute across the learned map structure.
|
|
1643
|
+
|
|
1644
|
+
COMMON FORMULAS:
|
|
1645
|
+
- Ratio: "revenue / cost"
|
|
1646
|
+
- Difference: "US10Y - US3M"
|
|
1647
|
+
- Log return: "log(close) - log(open)"
|
|
1648
|
+
- Z-score: "(volume - rolling_mean(volume, 20)) / rolling_std(volume, 20)"
|
|
1649
|
+
- Magnitude: "sqrt(x^2 + y^2)"
|
|
1650
|
+
- Unit convert: "temperature - 273.15"
|
|
1651
|
+
- First diff: "diff(consumption)"
|
|
1652
|
+
|
|
1653
|
+
SUPPORTED FUNCTIONS:
|
|
1654
|
+
- Arithmetic: +, -, *, /, ^
|
|
1655
|
+
- Math: log, log1p, log10, exp, sqrt, abs, sign, clamp, min, max
|
|
1656
|
+
- Trig: sin, cos, tan, asin, acos, atan
|
|
1657
|
+
- Rolling: rolling_mean(col, window), rolling_std(col, window), rolling_min, rolling_max
|
|
1658
|
+
- Temporal: diff(col), diff(col, n)
|
|
1659
|
+
- Constants: pi, numeric literals
|
|
1660
|
+
|
|
1661
|
+
WORKFLOW: Ask the user what domain-specific variables they care about.
|
|
1662
|
+
Suggest derived variables based on the column names. For example, if
|
|
1663
|
+
the dataset has "revenue" and "cost", suggest "revenue - cost" as profit
|
|
1664
|
+
and "revenue / cost" as cost efficiency.
|
|
1665
|
+
|
|
1666
|
+
COMMON MISTAKES:
|
|
1667
|
+
- Division by zero: if denominator column has zeros, use options.missing="skip"
|
|
1668
|
+
- Rolling functions produce NaN for the first (window-1) rows
|
|
1669
|
+
- diff() produces NaN for the first row
|
|
1670
|
+
- Column names with special characters are converted to underscores in expressions`, {
|
|
1671
|
+
dataset_id: z.string().describe("Dataset ID (source of column data)"),
|
|
1672
|
+
name: z.string().describe("Name for the derived variable (used in column header and visualization)"),
|
|
1673
|
+
expression: z
|
|
1674
|
+
.string()
|
|
1675
|
+
.describe("Mathematical expression referencing column names. " +
|
|
1676
|
+
"Examples: 'revenue / cost', 'log(price)', 'diff(temperature)', " +
|
|
1677
|
+
"'sqrt(x^2 + y^2)', 'rolling_mean(volume, 20)'"),
|
|
1678
|
+
project_onto_job: z
|
|
1679
|
+
.string()
|
|
1680
|
+
.optional()
|
|
1681
|
+
.describe("If provided, project the derived variable onto this SOM job instead of adding to dataset. " +
|
|
1682
|
+
"The job must be a completed train_som job."),
|
|
1683
|
+
aggregation: z
|
|
1684
|
+
.enum(["mean", "median", "sum", "min", "max", "std", "count"])
|
|
1685
|
+
.optional()
|
|
1686
|
+
.default("mean")
|
|
1687
|
+
.describe("How to aggregate values per SOM node (only used when project_onto_job is set)"),
|
|
1688
|
+
options: z
|
|
1689
|
+
.object({
|
|
1690
|
+
missing: z
|
|
1691
|
+
.enum(["skip", "zero", "interpolate"])
|
|
1692
|
+
.optional()
|
|
1693
|
+
.default("skip")
|
|
1694
|
+
.describe("How to handle NaN/missing values in the result"),
|
|
1695
|
+
window: z
|
|
1696
|
+
.number()
|
|
1697
|
+
.int()
|
|
1698
|
+
.optional()
|
|
1699
|
+
.describe("Default window size for rolling functions (default 20)"),
|
|
1700
|
+
description: z
|
|
1701
|
+
.string()
|
|
1702
|
+
.optional()
|
|
1703
|
+
.describe("Human-readable description of what this variable represents"),
|
|
1704
|
+
})
|
|
1705
|
+
.optional()
|
|
1706
|
+
.describe("Configuration for expression evaluation"),
|
|
1707
|
+
output_format: z
|
|
1708
|
+
.enum(["png", "pdf", "svg"])
|
|
1709
|
+
.optional()
|
|
1710
|
+
.default("png")
|
|
1711
|
+
.describe("Image format for projection visualization (only when project_onto_job is set)"),
|
|
1712
|
+
output_dpi: z
|
|
1713
|
+
.enum(["standard", "retina", "print"])
|
|
1714
|
+
.optional()
|
|
1715
|
+
.default("retina")
|
|
1716
|
+
.describe("Resolution for projection visualization"),
|
|
1717
|
+
colormap: z
|
|
1718
|
+
.string()
|
|
1719
|
+
.optional()
|
|
1720
|
+
.describe("Colormap for projection visualization (default: plasma)"),
|
|
1721
|
+
}, async ({ dataset_id, name, expression, project_onto_job, aggregation, options, output_format, output_dpi, colormap, }) => {
|
|
1722
|
+
const dpiMap = { standard: 1, retina: 2, print: 4 };
|
|
1723
|
+
if (project_onto_job) {
|
|
1724
|
+
// Mode: project onto SOM
|
|
1725
|
+
const body = {
|
|
1726
|
+
name,
|
|
1727
|
+
expression,
|
|
1728
|
+
aggregation: aggregation ?? "mean",
|
|
1729
|
+
};
|
|
1730
|
+
if (options)
|
|
1731
|
+
body.options = options;
|
|
1732
|
+
if (output_format && output_format !== "png")
|
|
1733
|
+
body.output_format = output_format;
|
|
1734
|
+
if (output_dpi && output_dpi !== "retina")
|
|
1735
|
+
body.output_dpi = dpiMap[output_dpi] ?? 2;
|
|
1736
|
+
if (colormap)
|
|
1737
|
+
body.colormap = colormap;
|
|
1738
|
+
const data = (await apiCall("POST", `/v1/results/${project_onto_job}/derive`, body));
|
|
1739
|
+
const deriveJobId = data.id;
|
|
1740
|
+
const poll = await pollUntilComplete(deriveJobId);
|
|
1741
|
+
if (poll.status === "completed") {
|
|
1742
|
+
const results = (await apiCall("GET", `/v1/results/${deriveJobId}`));
|
|
1743
|
+
const summary = (results.summary ?? {});
|
|
1744
|
+
const stats = (summary.variable_stats ?? {});
|
|
1745
|
+
const content = [];
|
|
1746
|
+
content.push({
|
|
1747
|
+
type: "text",
|
|
1748
|
+
text: [
|
|
1749
|
+
`Derived Variable Projected: ${name} — job: ${deriveJobId}`,
|
|
1750
|
+
`Expression: ${expression}`,
|
|
1751
|
+
`Parent SOM: ${project_onto_job} | Aggregation: ${aggregation ?? "mean"}`,
|
|
1752
|
+
``,
|
|
1753
|
+
`Statistics (per-node ${aggregation ?? "mean"}):`,
|
|
1754
|
+
` Min: ${stats.min !== undefined ? Number(stats.min).toFixed(3) : "N/A"}`,
|
|
1755
|
+
` Max: ${stats.max !== undefined ? Number(stats.max).toFixed(3) : "N/A"}`,
|
|
1756
|
+
` Mean: ${stats.mean !== undefined ? Number(stats.mean).toFixed(3) : "N/A"}`,
|
|
1757
|
+
` Nodes with data: ${stats.n_nodes_with_data ?? "N/A"}`,
|
|
1758
|
+
summary.nan_count ? ` NaN values: ${summary.nan_count}` : "",
|
|
1759
|
+
]
|
|
1760
|
+
.filter((l) => l !== "")
|
|
1761
|
+
.join("\n"),
|
|
1762
|
+
});
|
|
1763
|
+
const safeName = name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1764
|
+
const imgExt = summary.output_format ?? output_format ?? "png";
|
|
1765
|
+
await tryAttachImage(content, deriveJobId, `projected_${safeName}.${imgExt}`);
|
|
1766
|
+
return { content };
|
|
1767
|
+
}
|
|
1768
|
+
if (poll.status === "failed") {
|
|
1769
|
+
return {
|
|
1770
|
+
content: [{
|
|
1771
|
+
type: "text",
|
|
1772
|
+
text: `Derive+project job ${deriveJobId} failed: ${poll.error ?? "unknown error"}`,
|
|
1773
|
+
}],
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
content: [{
|
|
1778
|
+
type: "text",
|
|
1779
|
+
text: `Derive job submitted. Poll: get_job_status("${deriveJobId}")`,
|
|
1780
|
+
}],
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
else {
|
|
1784
|
+
// Mode: add to dataset
|
|
1785
|
+
const body = { name, expression };
|
|
1786
|
+
if (options)
|
|
1787
|
+
body.options = options;
|
|
1788
|
+
const data = (await apiCall("POST", `/v1/datasets/${dataset_id}/derive`, body));
|
|
1789
|
+
const deriveJobId = data.id;
|
|
1790
|
+
const poll = await pollUntilComplete(deriveJobId);
|
|
1791
|
+
if (poll.status === "completed") {
|
|
1792
|
+
const results = (await apiCall("GET", `/v1/results/${deriveJobId}`));
|
|
1793
|
+
const summary = (results.summary ?? {});
|
|
1794
|
+
return {
|
|
1795
|
+
content: [{
|
|
1796
|
+
type: "text",
|
|
1797
|
+
text: [
|
|
1798
|
+
`Derived column "${name}" added to dataset ${dataset_id}`,
|
|
1799
|
+
`Expression: ${expression}`,
|
|
1800
|
+
`Rows: ${summary.n_rows ?? "?"}`,
|
|
1801
|
+
summary.nan_count ? `NaN values: ${summary.nan_count}` : "",
|
|
1802
|
+
`Min: ${summary.min ?? "?"} | Max: ${summary.max ?? "?"} | Mean: ${summary.mean ?? "?"}`,
|
|
1803
|
+
``,
|
|
1804
|
+
`The column is now available in the dataset. Include it in train_som`,
|
|
1805
|
+
`via the 'columns' parameter, or use preview_dataset to verify.`,
|
|
1806
|
+
]
|
|
1807
|
+
.filter((l) => l !== "")
|
|
1808
|
+
.join("\n"),
|
|
1809
|
+
}],
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
if (poll.status === "failed") {
|
|
1813
|
+
return {
|
|
1814
|
+
content: [{
|
|
1815
|
+
type: "text",
|
|
1816
|
+
text: `Derive variable job ${deriveJobId} failed: ${poll.error ?? "unknown error"}`,
|
|
1817
|
+
}],
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
return {
|
|
1821
|
+
content: [{
|
|
1822
|
+
type: "text",
|
|
1823
|
+
text: `Derive job submitted. Poll: get_job_status("${deriveJobId}")`,
|
|
1824
|
+
}],
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
// ---- quality_report ----
|
|
1829
|
+
server.tool("quality_report", `Generate a comprehensive quality report for a trained SOM.
|
|
1830
|
+
|
|
1831
|
+
Returns all available metrics organized by category:
|
|
1832
|
+
- **Standard metrics**: Quantization Error (QE), Topographic Error (TE), Distortion
|
|
1833
|
+
- **Cluster metrics**: Silhouette, Davies-Bouldin, Calinski-Harabasz
|
|
1834
|
+
- **Topology metrics**: Neighborhood Preservation, Trustworthiness, Topographic Product
|
|
1835
|
+
- **Training info**: duration, epochs, learning parameters
|
|
1836
|
+
|
|
1837
|
+
Metric interpretation guide:
|
|
1838
|
+
- QE < 0.5: excellent | 0.5–1.0: good | 1.0–2.0: fair | >2.0: needs improvement
|
|
1839
|
+
- TE < 0.05: excellent | 0.05–0.10: good | 0.10–0.20: fair | >0.20: poor topology
|
|
1840
|
+
- Trustworthiness: closer to 1.0 = better (local neighborhoods preserved)
|
|
1841
|
+
- Neighborhood Preservation: closer to 1.0 = better (global structure preserved)
|
|
1842
|
+
- Topographic Product: near 0 = well-sized grid | <0 = grid too small | >0 = grid too large
|
|
1843
|
+
|
|
1844
|
+
After showing the report, ask the user:
|
|
1845
|
+
- "Which metrics are most important for your use case?"
|
|
1846
|
+
- "Do any metrics suggest the map needs retraining?"`, {
|
|
1847
|
+
job_id: z.string().describe("Job ID of a completed training job"),
|
|
1848
|
+
}, async ({ job_id }) => {
|
|
1849
|
+
const data = (await apiCall("GET", `/v1/results/${job_id}/quality-report`));
|
|
1850
|
+
const std = data.standard_metrics ?? {};
|
|
1851
|
+
const clust = data.cluster_metrics ?? {};
|
|
1852
|
+
const topo = data.topology_metrics ?? {};
|
|
1853
|
+
const train = data.training ?? {};
|
|
1854
|
+
const grid = data.grid ?? [0, 0];
|
|
1855
|
+
const fmt = (v) => v !== null && v !== undefined ? v.toFixed(4) : "—";
|
|
1856
|
+
const fmtPct = (v) => v !== null && v !== undefined ? `${(v * 100).toFixed(1)}%` : "—";
|
|
1857
|
+
const recommendations = [];
|
|
1858
|
+
const qe = std.quantization_error;
|
|
1859
|
+
const te = std.topographic_error;
|
|
1860
|
+
const ev = std.explained_variance;
|
|
1861
|
+
const sil = clust.silhouette;
|
|
1862
|
+
const trust = topo.trustworthiness;
|
|
1863
|
+
const nbp = topo.neighborhood_preservation;
|
|
1864
|
+
if (qe !== null && qe !== undefined && qe > 2.0)
|
|
1865
|
+
recommendations.push("QE is high → try more epochs or a larger grid");
|
|
1866
|
+
if (te !== null && te !== undefined && te > 0.15)
|
|
1867
|
+
recommendations.push("TE is high → topology is not well-preserved, try larger grid");
|
|
1868
|
+
if (ev !== null && ev !== undefined && ev < 0.7)
|
|
1869
|
+
recommendations.push("Explained variance < 70% → consider more training or feature selection");
|
|
1870
|
+
if (sil !== null && sil !== undefined && sil < 0.1)
|
|
1871
|
+
recommendations.push("Low silhouette → clusters overlap, try sigma_f=0.5 or more epochs");
|
|
1872
|
+
if (trust !== null && trust !== undefined && trust < 0.85)
|
|
1873
|
+
recommendations.push("Trustworthiness < 85% → local neighborhood structure is distorted");
|
|
1874
|
+
if (recommendations.length === 0)
|
|
1875
|
+
recommendations.push("All metrics look healthy — good map quality!");
|
|
1876
|
+
const epochs = train.epochs;
|
|
1877
|
+
const epochStr = epochs
|
|
1878
|
+
? epochs[1] === 0 ? `${epochs[0]} ordering only` : `${epochs[0]}+${epochs[1]}`
|
|
1879
|
+
: "—";
|
|
1880
|
+
const lines = [
|
|
1881
|
+
`Quality Report — Job ${job_id}`,
|
|
1882
|
+
`Grid: ${grid[0]}×${grid[1]} | Model: ${data.model ?? "SOM"} | Samples: ${data.n_samples ?? "?"}`,
|
|
1883
|
+
`Epochs: ${epochStr} | Duration: ${train.duration_seconds ? `${train.duration_seconds}s` : "—"}`,
|
|
1884
|
+
``,
|
|
1885
|
+
`Standard Metrics:`,
|
|
1886
|
+
` Quantization Error: ${fmt(std.quantization_error)} (lower is better)`,
|
|
1887
|
+
` Topographic Error: ${fmt(std.topographic_error)} (lower is better)`,
|
|
1888
|
+
` Distortion: ${fmt(std.distortion)}`,
|
|
1889
|
+
` Kaski-Lagus Error: ${fmt(std.kaski_lagus_error)} (lower is better)`,
|
|
1890
|
+
` Explained Variance: ${fmtPct(std.explained_variance)}`,
|
|
1891
|
+
``,
|
|
1892
|
+
`Cluster Quality Metrics:`,
|
|
1893
|
+
` Silhouette Score: ${fmt(clust.silhouette)} (higher is better, -1 to +1)`,
|
|
1894
|
+
` Davies-Bouldin: ${fmt(clust.davies_bouldin)} (lower is better)`,
|
|
1895
|
+
` Calinski-Harabasz: ${fmt(clust.calinski_harabasz)} (higher is better)`,
|
|
1896
|
+
``,
|
|
1897
|
+
`Topology Metrics:`,
|
|
1898
|
+
` Neighborhood Preservation: ${fmtPct(topo.neighborhood_preservation)} (higher is better)`,
|
|
1899
|
+
` Trustworthiness: ${fmtPct(topo.trustworthiness)} (higher is better)`,
|
|
1900
|
+
` Topographic Product: ${fmt(topo.topographic_product)} (near 0 is ideal)`,
|
|
1901
|
+
``,
|
|
1902
|
+
`Recommendations:`,
|
|
1903
|
+
...recommendations.map((r) => ` • ${r}`),
|
|
1904
|
+
];
|
|
1905
|
+
return {
|
|
1906
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1907
|
+
};
|
|
1908
|
+
});
|
|
1909
|
+
// ---- system_info ----
|
|
1910
|
+
server.tool("system_info", `Get runtime environment and worker topology information.
|
|
1911
|
+
|
|
1912
|
+
Returns compute resources, configured worker topology (num_workers,
|
|
1913
|
+
threads_per_worker, max_concurrent_jobs), job queue state, and training
|
|
1914
|
+
time estimates.
|
|
1915
|
+
|
|
1916
|
+
Use this BEFORE submitting large jobs to:
|
|
1917
|
+
- See how many workers are running and how many threads each has
|
|
1918
|
+
- Check queue depth to decide whether to wait or proceed
|
|
1919
|
+
- Estimate wall-clock time based on the current topology
|
|
1920
|
+
- Decide whether to call configure_resources first
|
|
1921
|
+
|
|
1922
|
+
Worker topology example:
|
|
1923
|
+
2 workers × 8 threads = 16 total threads
|
|
1924
|
+
max_concurrent_jobs=2 → both workers train simultaneously
|
|
1925
|
+
Wall-clock time for 30×30 60-epoch SOM: ~350s instead of ~700s
|
|
1926
|
+
|
|
1927
|
+
Use configure_resources to change the topology.`, {}, async () => {
|
|
1928
|
+
const data = (await apiCall("GET", "/v1/system/info"));
|
|
1929
|
+
const estimates = data.training_time_estimates_seconds ?? {};
|
|
1930
|
+
const notes = data.notes ?? [];
|
|
1931
|
+
const topo = data.worker_topology ?? {};
|
|
1932
|
+
const lines = [
|
|
1933
|
+
`System Information`,
|
|
1934
|
+
``,
|
|
1935
|
+
`Worker Topology (configured):`,
|
|
1936
|
+
` Workers: ${topo.num_workers ?? "?"}`,
|
|
1937
|
+
` Threads per Worker: ${topo.threads_per_worker ?? "?"}`,
|
|
1938
|
+
` Max Concurrent Jobs: ${topo.max_concurrent_jobs ?? "?"}`,
|
|
1939
|
+
` Total Thread Budget: ${topo.total_thread_budget ?? "?"}`,
|
|
1940
|
+
` To apply changes: ${topo.restart_command ?? "see configure_resources"}`,
|
|
1941
|
+
``,
|
|
1942
|
+
`Host Resources (API server):`,
|
|
1943
|
+
` CPU Threads: ${data.cpu_threads}`,
|
|
1944
|
+
` Total Memory: ${data.total_memory_gb} GB`,
|
|
1945
|
+
` Free Memory: ${data.free_memory_gb} GB`,
|
|
1946
|
+
` GPU Available: ${data.gpu_available ? "Yes" : "No (CPU only)"}`,
|
|
1947
|
+
``,
|
|
1948
|
+
`Job Queue:`,
|
|
1949
|
+
` Running Jobs: ${data.running_jobs}`,
|
|
1950
|
+
` Pending Jobs: ${data.pending_jobs}`,
|
|
1951
|
+
` Queue Depth: ${data.queue_depth}`,
|
|
1952
|
+
``,
|
|
1953
|
+
`Estimated Training Times (seconds, per-worker):`,
|
|
1954
|
+
...Object.entries(estimates)
|
|
1955
|
+
.filter(([k]) => k !== "formula")
|
|
1956
|
+
.map(([k, v]) => ` ${k}: ~${v}s`),
|
|
1957
|
+
` ${estimates.formula ?? ""}`,
|
|
1958
|
+
``,
|
|
1959
|
+
`Notes:`,
|
|
1960
|
+
...notes.map((n) => ` • ${n}`),
|
|
1961
|
+
topo.note ? ` • ${topo.note}` : "",
|
|
1962
|
+
].filter((l) => l !== undefined);
|
|
1963
|
+
return {
|
|
1964
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1965
|
+
};
|
|
1966
|
+
});
|
|
1967
|
+
// ---- configure_resources ----
|
|
1968
|
+
server.tool("configure_resources", `Configure worker topology: thread count, worker replicas, and job concurrency.
|
|
1969
|
+
|
|
1970
|
+
Parameters:
|
|
1971
|
+
- threads_per_worker (int, 1–64) — Julia threads each worker uses for training.
|
|
1972
|
+
More threads = faster per-job training.
|
|
1973
|
+
Typical: 4, 8, 16. Match to physical cores.
|
|
1974
|
+
- num_workers (int, 1–16) — Number of worker replicas running in parallel.
|
|
1975
|
+
More workers = more simultaneous training jobs.
|
|
1976
|
+
Each worker is a separate process with its own threads.
|
|
1977
|
+
- max_concurrent_jobs (int, 1–num_workers) — DB-level concurrency cap.
|
|
1978
|
+
Set equal to num_workers to fully utilize all replicas.
|
|
1979
|
+
|
|
1980
|
+
Effect:
|
|
1981
|
+
- max_concurrent_jobs takes effect IMMEDIATELY (no restart needed)
|
|
1982
|
+
- threads_per_worker and num_workers require a worker restart
|
|
1983
|
+
→ the response includes the exact restart_command to run
|
|
1984
|
+
|
|
1985
|
+
Examples:
|
|
1986
|
+
Single beefy run: threads_per_worker=16, num_workers=1, max_concurrent_jobs=1
|
|
1987
|
+
Parallel sweeps: threads_per_worker=8, num_workers=2, max_concurrent_jobs=2
|
|
1988
|
+
CI / light load: threads_per_worker=4, num_workers=1, max_concurrent_jobs=1
|
|
1989
|
+
|
|
1990
|
+
Rule of thumb: threads_per_worker × num_workers ≤ physical CPU core count.
|
|
1991
|
+
Call system_info first to see how many CPU threads are available.`, {
|
|
1992
|
+
threads_per_worker: z
|
|
1993
|
+
.number()
|
|
1994
|
+
.int()
|
|
1995
|
+
.min(1)
|
|
1996
|
+
.max(64)
|
|
1997
|
+
.optional()
|
|
1998
|
+
.describe("Julia threads per worker process (1–64). Restart required."),
|
|
1999
|
+
num_workers: z
|
|
2000
|
+
.number()
|
|
2001
|
+
.int()
|
|
2002
|
+
.min(1)
|
|
2003
|
+
.max(16)
|
|
2004
|
+
.optional()
|
|
2005
|
+
.describe("Number of parallel worker replicas (1–16). Restart required."),
|
|
2006
|
+
max_concurrent_jobs: z
|
|
2007
|
+
.number()
|
|
2008
|
+
.int()
|
|
2009
|
+
.min(1)
|
|
2010
|
+
.max(16)
|
|
2011
|
+
.optional()
|
|
2012
|
+
.describe("Maximum simultaneous training jobs (1–num_workers). Takes effect immediately."),
|
|
2013
|
+
}, async ({ threads_per_worker, num_workers, max_concurrent_jobs }) => {
|
|
2014
|
+
const body = {};
|
|
2015
|
+
if (threads_per_worker !== undefined)
|
|
2016
|
+
body.threads_per_worker = threads_per_worker;
|
|
2017
|
+
if (num_workers !== undefined)
|
|
2018
|
+
body.num_workers = num_workers;
|
|
2019
|
+
if (max_concurrent_jobs !== undefined)
|
|
2020
|
+
body.max_concurrent_jobs = max_concurrent_jobs;
|
|
2021
|
+
if (Object.keys(body).length === 0) {
|
|
2022
|
+
return {
|
|
2023
|
+
content: [
|
|
2024
|
+
{
|
|
2025
|
+
type: "text",
|
|
2026
|
+
text: "No parameters provided. Specify at least one of: threads_per_worker, num_workers, max_concurrent_jobs.",
|
|
2027
|
+
},
|
|
2028
|
+
],
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
const result = (await apiCall("POST", "/v1/system/resources", body));
|
|
2032
|
+
const applied = result.applied ?? {};
|
|
2033
|
+
const lines = [
|
|
2034
|
+
`Resource Configuration Applied`,
|
|
2035
|
+
``,
|
|
2036
|
+
`Current Settings:`,
|
|
2037
|
+
` Threads per Worker: ${applied.threads_per_worker}`,
|
|
2038
|
+
` Worker Replicas: ${applied.num_workers}`,
|
|
2039
|
+
` Max Concurrent Jobs: ${applied.max_concurrent_jobs}`,
|
|
2040
|
+
` Total Thread Budget: ${applied.total_thread_budget}`,
|
|
2041
|
+
``,
|
|
2042
|
+
`max_concurrent_jobs: ${result.max_concurrent_jobs_effective}`,
|
|
2043
|
+
];
|
|
2044
|
+
if (result.restart_required) {
|
|
2045
|
+
lines.push(``, `⚠ Worker restart required for thread/replica changes.`, ` Run: ${result.restart_command}`, ``, ` ${result.restart_note}`);
|
|
2046
|
+
}
|
|
2047
|
+
else {
|
|
2048
|
+
lines.push(``, `✓ All changes applied immediately — no restart needed.`);
|
|
2049
|
+
}
|
|
2050
|
+
return {
|
|
2051
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
2052
|
+
};
|
|
2053
|
+
});
|
|
2054
|
+
// ---------------------------------------------------------------------------
|
|
2055
|
+
// Prompts
|
|
2056
|
+
// ---------------------------------------------------------------------------
|
|
2057
|
+
server.prompt("prepare_training", "Guided pre-training checklist. Use after uploading a dataset and before calling " +
|
|
2058
|
+
"train_som. Walks through column selection, transforms, cyclic features, " +
|
|
2059
|
+
"temporal features, weighting, derived variables, and grid sizing.", { dataset_id: z.string().describe("Dataset ID to prepare for training") }, ({ dataset_id }) => ({
|
|
2060
|
+
messages: [
|
|
2061
|
+
{
|
|
2062
|
+
role: "user",
|
|
2063
|
+
content: {
|
|
2064
|
+
type: "text",
|
|
2065
|
+
text: `Guide me through preparing dataset ${dataset_id} for SOM training. ` +
|
|
2066
|
+
`For each step, show the relevant data and ask me to decide:\n` +
|
|
2067
|
+
`1. COLUMN SELECTION: Which columns to include/exclude?\n` +
|
|
2068
|
+
`2. TRANSFORMS: Any columns need log, sqrt, or rank transforms? (check for skewed distributions)\n` +
|
|
2069
|
+
`3. CYCLIC FEATURES: Any periodic columns (hour, weekday, angle, direction)?\n` +
|
|
2070
|
+
`4. TEMPORAL FEATURES: Any datetime columns to extract components from?\n` +
|
|
2071
|
+
`5. FEATURE WEIGHTS: Should any features be emphasized or de-emphasized?\n` +
|
|
2072
|
+
`6. DERIVED VARIABLES: Any new columns to compute from existing ones? (e.g., ratios, differences)\n` +
|
|
2073
|
+
`7. GRID & MODEL: What grid size and model type?\n\n` +
|
|
2074
|
+
`Start by calling preview_dataset to show me the columns and statistics.`,
|
|
2075
|
+
},
|
|
2076
|
+
},
|
|
2077
|
+
],
|
|
2078
|
+
}));
|
|
2079
|
+
// ---------------------------------------------------------------------------
|
|
2080
|
+
// Image helper
|
|
2081
|
+
// ---------------------------------------------------------------------------
|
|
2082
|
+
function mimeForFilename(fname) {
|
|
2083
|
+
if (fname.endsWith(".pdf"))
|
|
2084
|
+
return "application/pdf";
|
|
2085
|
+
if (fname.endsWith(".svg"))
|
|
2086
|
+
return "image/svg+xml";
|
|
2087
|
+
return "image/png";
|
|
2088
|
+
}
|
|
2089
|
+
async function tryAttachImage(content, jobId, filename) {
|
|
2090
|
+
try {
|
|
2091
|
+
const { data: imgBuf } = await apiRawCall(`/v1/results/${jobId}/image/${filename}`);
|
|
2092
|
+
content.push({
|
|
2093
|
+
type: "image",
|
|
2094
|
+
data: imgBuf.toString("base64"),
|
|
2095
|
+
mimeType: mimeForFilename(filename),
|
|
2096
|
+
annotations: { audience: ["user"], priority: 0.8 },
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
catch {
|
|
2100
|
+
content.push({
|
|
2101
|
+
type: "text",
|
|
2102
|
+
text: `(${filename} not available for inline display)`,
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
// ---------------------------------------------------------------------------
|
|
2107
|
+
// Connect via stdio
|
|
2108
|
+
// ---------------------------------------------------------------------------
|
|
2109
|
+
const transport = new StdioServerTransport();
|
|
2110
|
+
await server.connect(transport);
|
|
2111
|
+
//# sourceMappingURL=index.js.map
|