@barivia/barsom-mcp 0.7.16 → 0.9.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 +17 -4
- package/dist/index.js +1 -1
- package/dist/shared.js +21 -4
- package/dist/tools/datasets.js +98 -7
- package/dist/tools/inference.js +130 -35
- package/dist/tools/jobs.js +121 -23
- package/dist/tools/results.js +84 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,8 @@ MCP clients typically run it with **`npx`** (downloads on first use):
|
|
|
29
29
|
|
|
30
30
|
**Also available:** hosted HTTP MCP at **`https://mcp.barivia.se/mcp`** (same API key; no npm) for clients that support remote MCP.
|
|
31
31
|
|
|
32
|
+
**Cursor / multi-account note:** Cursor's tool dispatcher resolves tools under a `serverIdentifier` (e.g. `user-barivia-bbb-maps`), which can differ from the key you wrote in `mcp.json` (e.g. `barivia-bbb-maps`). If a tool call fails with **"server does not exist"** or similar, list the resolved identifier with your client's tool inspector (`mcp.list-tools` or equivalent) and use that identifier — not the `mcp.json` key.
|
|
33
|
+
|
|
32
34
|
**Future:** A **private npm org** scoped package is an option later (paid npm teams/orgs; users authenticate with npm, usually simpler than GitHub Packages for pure npm consumers).
|
|
33
35
|
|
|
34
36
|
## Environment Variables
|
|
@@ -40,14 +42,14 @@ MCP clients typically run it with **`npx`** (downloads on first use):
|
|
|
40
42
|
| `BARIVIA_WORKSPACE_ROOT` | No | `process.cwd()` or `PWD` | Directory for relative `file_path` and `save_to_disk`. In Cursor MCP, `process.cwd()` is often the MCP install dir — add `BARIVIA_WORKSPACE_ROOT` to your MCP config `env` with your project path (e.g. `/home/user/myproject`). Absolute paths and `file://` URIs work without it. |
|
|
41
43
|
| `BARIVIA_FETCH_TIMEOUT_MS` | No | `30000` | Per-request HTTP timeout (ms) to the API. Increase (e.g. `120000`) on slow networks or when the API does long-running work. Large dataset uploads use a separate longer timeout internally. |
|
|
42
44
|
| `BARIVIA_VIZ_PORT` | No | OS-assigned | When the client has no MCP Apps support, the proxy may start a local viz server on `127.0.0.1`; set a fixed port if you need stable bookmark URLs. |
|
|
43
|
-
| `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | No |
|
|
45
|
+
| `BARIVIA_ENFORCE_WORKSPACE_SANDBOX` | No | `1` (enabled) | File uploads are constrained to the MCP workspace root by default. Set to `0` or `false` to allow absolute paths anywhere on the machine (high trust). **Changed in v0.x:** previously defaulted to off; now on for security. If uploads fail with "paths outside the workspace are disabled", either set `BARIVIA_WORKSPACE_ROOT` to cover your data directory, or opt out with `BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0`. |
|
|
44
46
|
|
|
45
47
|
Legacy `BARSOM_API_KEY` / `BARSOM_API_URL` / `BARSOM_WORKSPACE_ROOT` are also accepted as fallbacks.
|
|
46
48
|
|
|
47
49
|
### Trust model (who are we trusting?)
|
|
48
50
|
|
|
49
51
|
- **API key** — Anyone with your key can access your tenant on the Barivia API like any other HTTP client. The proxy does not add a separate authorization layer.
|
|
50
|
-
- **Local machine** — The process runs with **your** OS user.
|
|
52
|
+
- **Local machine** — The process runs with **your** OS user. File uploads are now sandboxed to the workspace root by default (`BARIVIA_ENFORCE_WORKSPACE_SANDBOX=1`). Set `BARIVIA_WORKSPACE_ROOT` to your project directory; use `BARIVIA_ENFORCE_WORKSPACE_SANDBOX=0` only if you need unrestricted absolute-path access.
|
|
51
53
|
- **Logs** — stderr may include API paths and tool activity; do not log secrets in MCP client configs.
|
|
52
54
|
|
|
53
55
|
**Supported stack:** Node **18+** (see `engines` in `package.json`). Built with `@modelcontextprotocol/sdk` **^1.x** — if a major MCP client upgrade breaks tools, check SDK release notes alongside this package version.
|
|
@@ -88,6 +90,7 @@ Call at the **start of mapping work** (or when the user asks what the MCP can do
|
|
|
88
90
|
| `list` | Finding dataset IDs |
|
|
89
91
|
| `subset` | Creating a filtered/sliced copy (row_range, filter conditions) |
|
|
90
92
|
| `add_expression` | Add a derived column from an expression (formula → new column on the dataset) |
|
|
93
|
+
| `reduce_spectral` | Pre-training reducer for long ordered numeric blocks (spectra, time series, sensor fingerprints, gene panels). Methods: **pca** (top-k principal components), **log_sample** (k columns at log-spaced indices — scattering, audio bands), **uniform_sample** (k columns at evenly-spaced indices — regularly-sampled time series), **stats** (6 fixed per-row summary columns). All produce one feature vector per row; appends derived columns to the dataset. |
|
|
91
94
|
| `delete` | Removing a dataset |
|
|
92
95
|
|
|
93
96
|
### `jobs(action)`
|
|
@@ -123,8 +126,8 @@ All actions use a frozen trained map — no retraining. Derived columns use **`d
|
|
|
123
126
|
|
|
124
127
|
| Action | Output | Timing |
|
|
125
128
|
|--------|--------|--------|
|
|
126
|
-
| `predict` | predictions.csv
|
|
127
|
-
| `
|
|
129
|
+
| `predict` | Score rows against the trained map. **Inputs:** `dataset_id` (defaults to the parent training dataset) **or** inline `rows` (≤500). **Output style** (`output` param): `"compact"` → `predictions.csv` (row_id, bmu_x/y, bmu_node_index, cluster_id [+ QE / qe_p95 / potential_anomaly when scoring **new** data]); `"annotated"` → `annotated.csv` (original CSV + BMU columns appended). **Regime auto-detected:** when the resolved dataset matches the training dataset, QE columns are intentionally omitted in compact output (training-set fit ≠ generalisation; the p95 anomaly flag would be circular). Prefer `dataset_id` for batches and SIOM/irregular maps. | 5–120s |
|
|
130
|
+
| `impute_column` | Fill a numeric **target_column** not used in training: **requires** `dataset_id` + `target_column`. Dataset must contain all training features plus the target. Pools observed target values from rows mapped to this row's BMU and topology neighbors (BMU + neighbors, often 7 nodes on hex interior; fewer on borders unless the map is periodic). `only_missing` (default true); `impute_aggregation`: mean or median. **Not** held-out validated — map-local estimate. Output **`imputed.csv`**. | 5–120s |
|
|
128
131
|
| `compare` | density-diff heatmap + top gained/lost nodes — drift, A/B, cohort | 30–120s |
|
|
129
132
|
| `project_columns` | Project one or more dataset columns onto the trained map (component planes) | async |
|
|
130
133
|
| `report` | Report **manifest** (figure names, download URLs, metrics, cluster summary) — sync; use with `results(download)` on the training `job_id` for `report.pdf` when present; build custom PDFs in Quarto/Jupyter | immediate |
|
|
@@ -153,6 +156,16 @@ All actions use a frozen trained map — no retraining. Derived columns use **`d
|
|
|
153
156
|
|
|
154
157
|
When the client does not advertise MCP Apps support, the proxy starts **`viz-server`** on **`127.0.0.1`** (localhost-only). Tool responses can include `http://127.0.0.1:PORT/viz/...` links. See **Local viz fallback** under Environment Variables.
|
|
155
158
|
|
|
159
|
+
#### Choosing where to view results
|
|
160
|
+
|
|
161
|
+
The right viewer depends on **(MCP App support)** **and** **(can the human reach the proxy's `127.0.0.1`)** — not on whether the agent is "text" or "GUI".
|
|
162
|
+
|
|
163
|
+
| Path | When to use | Caveats |
|
|
164
|
+
|------|-------------|---------|
|
|
165
|
+
| **Embedded MCP App** (`ui://barsom/results-explorer`) | Client supports MCP Apps (Cursor, Claude Desktop with App support). Preferred default. | None of the topology issues below — transported over the MCP channel. Works for local proxy, remote SSH proxy, or container. |
|
|
166
|
+
| **Localhost `viz-server` link** | MCP Apps unsupported AND the human user is on the same host as the proxy (typical local Cursor + local stdio proxy). | Ephemeral port (lost on proxy restart). **Unreachable** when the proxy runs on a remote SSH host while the user is elsewhere, in a container with no port forwarding, or behind a strict host firewall. Pure text agents on the same host CAN use this path — the user just clicks the link. |
|
|
167
|
+
| **`results(action=get)` text-only path** | Always works. Use when (a) the agent is headless / autonomous (no human to click anything) or (b) proxy and user are on different hosts and MCP Apps is unsupported. With **`figures="none"`** it's also the leanest path for parameter sweeps and LLM clients (no image payloads). | Returns metrics text and inline images via MCP content; never starts a server. |
|
|
168
|
+
|
|
156
169
|
### Migration notes
|
|
157
170
|
|
|
158
171
|
- **`explore_map` → `results_explorer`:** Update Cursor, Claude Desktop, or other MCP configs that still reference `explore_map`. The alias remains for backward compatibility.
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as n,RESOURCE_MIME_TYPE as s}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as
|
|
2
|
+
import{McpServer as e}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as t}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as o}from"zod";import{getUiCapability as r,registerAppResource as n,RESOURCE_MIME_TYPE as s}from"@modelcontextprotocol/ext-apps/server";import{startVizServer as a}from"./viz-server.js";import{API_KEY as i,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as m,setClientSupportsMcpApps as d,CLIENT_VERSION as u}from"./shared.js";import{registerDatasetsTool as f}from"./tools/datasets.js";import{registerJobsTool as g,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as b}from"./tools/results.js";import{registerExploreMapTool as h,RESULTS_EXPLORER_URI as y}from"./tools/explore_map.js";import{registerAccountTool as w}from"./tools/account.js";import{registerInferenceTool as j}from"./tools/inference.js";import{registerGuideBarsomTool as v}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as P}from"./tools/training_guidance.js";import{registerFeedbackTool as x}from"./tools/feedback.js";import{registerTrainingPrepTools as I,TRAINING_PREP_URI as k}from"./tools/training_prep.js";import{registerTrainingMonitorTool as M,TRAINING_MONITOR_URI as O}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as S}from"./prepare_training_prompt.js";i||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const A=new e({name:"analytics-engine",version:u,instructions:'# Barivia Mapping Analytics Engine\n\nSelf-organizing map (SOM) analytics: project high-dimensional data to a 2D grid for clusters, gradients, and anomalies.\n\n## Workflow (short)\n\nUpload (`datasets(upload)`) → `datasets(preview)` and `datasets(analyze)` before train → submit one of `jobs(train_map)`, `jobs(train_siom_map)`, or `jobs(train_floop_siom)` (only if plan allows FLooP) → poll `jobs(status)` every 10–15s until `completed` → `results(get)` for metrics and figures (there is no separate analyze tool). Then `jobs(compare)`, `results(download/recolor/transition_flow)`, or `inference` as needed.\n\n**Full detail:** Call `guide_barsom_workflow` for plan-scoped tool map, training modes, async rules, optional MCP App UIs, and step-by-step SOP (from the Barivia API when online).\n\n## Tool map (compact)\n\n| Area | Tool | Notes |\n|------|------|--------|\n| Data | `datasets` | upload, preview, analyze, list, subset, add_expression, reduce_spectral (pca/log_sample/uniform_sample/stats for long ordered numeric blocks), delete |\n| Jobs | `jobs` | train_map, train_siom_map, train_floop_siom (entitled), status, list, compare, cancel, delete, batch_predict, run_baseline_study; `train_floop_chain` = deprecated alias for train_floop_siom |\n| Results | `results` | get (figures="none" for metrics-only), export, download, recolor (async), transition_flow (async; time-ordered rows only) |\n| Inference | `inference` | predict (regime-aware; output="compact"|"annotated"), impute_column (neighbor-pool fill for a non-training column), compare, project_columns, report |\n| Account | `account` | status, burst/compute actions, history, add_funds |\n| Bootstrap | `guide_barsom_workflow` | orientation + SOP |\n| Parameters | `training_guidance` | presets and field hints (API-scoped) |\n| Prep | `prepare_training` prompt, `training_prep` + `submit_prepared_training` | checklist / interactive UI |\n| Explore | `results_explorer`, `training_monitor` | optional MCP Apps; `explore_map` = deprecated alias of `results_explorer`; `jobs(status)` and `results(get)` suffice without them |\n| Other | `send_feedback` | only after user agrees |\n\n## Async pattern\n\n- **Manual poll:** Training submits return `job_id` immediately — poll `jobs(status)` every 10–15s. **Running is not failed**; large grids or FLooP-SIOM can take many minutes. `max_nodes` (FLooP) is a total node budget, not grid side length.\n- **Often auto-polled:** `inference` actions, `results(recolor)`, `results(transition_flow)` may wait in-proxy; if you get a `job_id`, poll `jobs(status)` the same way.\n\nCredits: jobs consume compute credits; check `account(status)` before big runs. Slow networks: users can raise `BARIVIA_FETCH_TIMEOUT_MS`.\n\n## Constraints\n\n- Prep ladder: `prepare_training` prompt = narrative checklist; `training_guidance` = structured hints; `training_prep` = UI + guarded submit. Do not guess tiers or FLooP entitlement.\n- `inference(predict)`: prefer `dataset_id` for batch and for SIOM/irregular maps; single-row `rows` uses a fast path that can fail on some topologies — retry with `dataset_id`. FLooP-SIOM: if predict jobs fail while grid SIOM works, capture errors + `job_id`.\n- Column names are case-sensitive — match `datasets(preview)`.\n- Default training path is numeric/cyclic/temporal; use explicit `categorical_features` for baseline categoricals. `predict` must match the model contract.\n- After `recolor`, `transition_flow`, or `project_columns`, use the **new** `job_id` returned for follow-up `results` if applicable.'});n(A,y,y,{mimeType:s},async()=>{const e=await c("results-explorer");return{contents:[{uri:y,mimeType:s,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),n(A,k,k,{mimeType:s},async()=>{const e=await c("training-prep");return{contents:[{uri:k,mimeType:s,text:e??"<html><body>Training Preparation view not built yet.</body></html>"}]}}),n(A,O,O,{mimeType:s},async()=>{const e=await c("training-monitor");return{contents:[{uri:O,mimeType:s,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),v(A),h(A),I(A),M(A),f(A),g(A,_),b(A),w(A),j(A),P(A),x(A),A.prompt("info","Short orientation for the Barivia Mapping MCP. For full plan-scoped workflow, tool map, and SOP, the model should call guide_barsom_workflow. Use when the user asks what this MCP can do or how to get started.",{},()=>({messages:[{role:"user",content:{type:"text",text:["Give a concise, scannable answer (headers + bullets):","","**What it is:** MCP client to the Barivia mapping engine (2D SOM / SIOM / FLooP-SIOM when entitled) over HTTPS.","","**First step:** Call `guide_barsom_workflow` for plan-scoped bootstrap (full tool list, async rules, training modes, optional MCP Apps, SOP).","","**Core path:** `datasets(upload)` → `datasets(preview)` + `datasets(analyze)` → choose training action → poll `jobs(status)` every 10–15s until completed → `results(get)` (all main figures/metrics; no separate analyze tool).","",'**Key tools:** `datasets` (data; reduce_spectral for spectra/long blocks), `jobs` (train/poll/compare/…; train_map accepts an optional `label` for readable compare rows), `results` (get/download/export/recolor/transition_flow; figures="none" for metrics-only), `inference` (predict; impute_column for topology-neighbor pool fill; compare; project_columns; report), `account` (status/credits/queue).',"","**Prep help:** `prepare_training` prompt (checklist) · `training_guidance` (presets/JSON hints) · `training_prep` + `submit_prepared_training` (interactive UI).","","**Optional UI:** `results_explorer`, `training_monitor` — nice for browsing; not required if you use `results` + `jobs(status)`.","","**After training:** `jobs(compare)` across runs, `results(recolor)`, `inference(project_columns)` for variables not in training, `transition_flow` only if rows are time-ordered.","","**Rules:** Running ≠ failed. Column names must match `datasets(preview)` exactly. Do not call `_fetch_figure` from chat (host/UI only); use `results(get)` or `results_explorer`.","","Offer `send_feedback` only after asking the user."].join("\n")}}]})),A.prompt("prepare_training","Narrative pre-training checklist (prompt). Use after upload and before train. Content is tier-scoped from the API when online. Prep ladder: this prompt = story checklist; training_guidance tool = JSON presets/parameter hints; training_prep tool = interactive UI + submit_prepared_training.",{dataset_id:o.string().describe("Dataset ID to prepare for training")},async({dataset_id:e})=>({messages:[{role:"user",content:{type:"text",text:await S(e)}}]}));const T=new t;(async function(){try{const e=await a(l,p,c);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=A.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(s))},await A.connect(T)})().catch(console.error);
|
package/dist/shared.js
CHANGED
|
@@ -14,6 +14,12 @@ export const API_KEY = process.env.BARIVIA_API_KEY ?? process.env.BARSOM_API_KEY
|
|
|
14
14
|
export const FETCH_TIMEOUT_MS = parseInt(process.env.BARIVIA_FETCH_TIMEOUT_MS ?? "30000", 10);
|
|
15
15
|
export const MAX_RETRIES = 2;
|
|
16
16
|
export const RETRYABLE_STATUS = new Set([502, 503, 504]);
|
|
17
|
+
/**
|
|
18
|
+
* Single source of truth for the proxy version. Sent to the API as
|
|
19
|
+
* X-Barsom-Client-Version so the server can annotate tool guidance with the
|
|
20
|
+
* wrapper version each action requires. Keep in sync with package.json on bump.
|
|
21
|
+
*/
|
|
22
|
+
export const CLIENT_VERSION = "0.9.0";
|
|
17
23
|
/** User-facing links; keep aligned with barivia.se / api.barivia.se. */
|
|
18
24
|
export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
|
|
19
25
|
/** Poll window for datasets(add_expression) / derive jobs (server-side work can exceed 30s). */
|
|
@@ -105,8 +111,10 @@ export function sandboxPath(userPath, root) {
|
|
|
105
111
|
return resolved;
|
|
106
112
|
}
|
|
107
113
|
function enforceWorkspaceSandboxUpload() {
|
|
108
|
-
const v = process.env.BARIVIA_ENFORCE_WORKSPACE_SANDBOX ?? "";
|
|
109
|
-
|
|
114
|
+
const v = process.env.BARIVIA_ENFORCE_WORKSPACE_SANDBOX ?? "1";
|
|
115
|
+
if (v === "0" || v.toLowerCase() === "false")
|
|
116
|
+
return false;
|
|
117
|
+
return true;
|
|
110
118
|
}
|
|
111
119
|
/**
|
|
112
120
|
* Resolves file_path for dataset upload. Security: auth before read, generic errors, reject "..".
|
|
@@ -254,12 +262,18 @@ export async function apiCall(method, path, body, extraHeaders, requestTimeoutMs
|
|
|
254
262
|
Authorization: `Bearer ${API_KEY}`,
|
|
255
263
|
"Content-Type": contentType,
|
|
256
264
|
"X-Request-ID": requestId,
|
|
265
|
+
"X-Barsom-Client-Version": CLIENT_VERSION,
|
|
257
266
|
...extraHeaders,
|
|
258
267
|
};
|
|
259
268
|
let serializedBody;
|
|
260
269
|
if (body !== undefined) {
|
|
261
|
-
|
|
262
|
-
|
|
270
|
+
if (body instanceof Uint8Array) {
|
|
271
|
+
serializedBody = body; // pre-encoded bytes (e.g. gzipped CSV upload)
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
serializedBody =
|
|
275
|
+
contentType === "application/json" ? JSON.stringify(body) : String(body);
|
|
276
|
+
}
|
|
263
277
|
}
|
|
264
278
|
const effectiveTimeout = requestTimeoutMs ?? FETCH_TIMEOUT_MS;
|
|
265
279
|
const t0 = Date.now();
|
|
@@ -508,6 +522,9 @@ export async function tryAttachImage(content, jobId, filename) {
|
|
|
508
522
|
}
|
|
509
523
|
/** Resolve get_results figures param to list of image filenames to fetch. */
|
|
510
524
|
export function getResultsImagesToFetch(jobType, summary, figures, includeIndividual) {
|
|
525
|
+
// Metrics-only mode: skip every image fetch (sweeps and LLM clients).
|
|
526
|
+
if (figures === "none")
|
|
527
|
+
return [];
|
|
511
528
|
const ext = summary.output_format ?? "pdf";
|
|
512
529
|
if (jobType === "transition_flow") {
|
|
513
530
|
const lag = summary.lag ?? 1;
|
package/dist/tools/datasets.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import { gzipSync } from "node:zlib";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, POLL_DERIVE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, } from "../shared.js";
|
|
5
6
|
export function registerDatasetsTool(server) {
|
|
@@ -13,6 +14,7 @@ export function registerDatasetsTool(server) {
|
|
|
13
14
|
| list | Finding dataset IDs for train_map, preview, or subset — see all available datasets |
|
|
14
15
|
| subset | Creating a filtered/sliced view without re-uploading the full CSV |
|
|
15
16
|
| add_expression | Add a computed column from an expression (same as project(expression) without project_onto_job) — dataset_id + name + expression |
|
|
17
|
+
| reduce_spectral | Collapse a long ordered numeric block (e.g. 1000-point spectrum, time series, sensor fingerprint) into a small per-row feature set so the SOM can train on signal-dense features. Methods: pca, log_sample, uniform_sample, stats. |
|
|
16
18
|
| delete | Cleaning up after experiments or freeing the dataset slot |
|
|
17
19
|
|
|
18
20
|
action=upload: PREFER file_path — server reads from workspace root (token-efficient; no file content in context). Use csv_data only for small inline pastes (e.g. <10KB). Returns dataset ID. Then use datasets(action=preview) before jobs(action=train_map).
|
|
@@ -30,14 +32,20 @@ action=subset: Create a new dataset from a subset of an existing one. Requires n
|
|
|
30
32
|
Examples: { column: "region", op: "eq", value: "Europe" } | { column: "age", op: "between", value: [18, 65] }
|
|
31
33
|
- Combine row_range + filters to slice both rows and values.
|
|
32
34
|
- Single filter object is also accepted (auto-wrapped).
|
|
35
|
+
action=reduce_spectral: Run a pre-training reducer over an ordered block of numeric columns. All four methods produce one feature vector per row (rows in = rows out; only the column dimension is collapsed) and append derived columns to the dataset. Choose by data shape:
|
|
36
|
+
- pca: top-k principal components — general first try when many columns are correlated (spectroscopy, gene panels, sensor arrays). Returns explained_variance_ratio.
|
|
37
|
+
- log_sample: keep k columns at log-spaced indices — SAXS/WAXS & powder diffraction, log-frequency / octave-like audio, attenuation vs wavelength (UV–Vis–IR stacks), depth profiling, chromatography retention ladders — anywhere column order is exponential, logarithmic, or perceptually log-spaced.
|
|
38
|
+
- uniform_sample: keep k columns at evenly-spaced indices — regularly-sampled time series, frame-by-frame features, evenly-binned histograms.
|
|
39
|
+
- stats: 6 fixed per-row statistics (mean, std, min, max, skew, integral) — cheap baseline for any sequenced numeric block; k is ignored.
|
|
40
|
+
Required params: name (prefix for derived columns), method, columns_block (ordered source column names ≥ 2), k (≥ 1, < length(columns_block); ignored for stats).
|
|
33
41
|
action=delete: Remove a dataset and all S3 data permanently.
|
|
34
42
|
|
|
35
43
|
BEST FOR: Tabular numeric data. CSV with header required.
|
|
36
44
|
NOT FOR: Real-time data streams or binary files — upload a snapshot CSV instead.
|
|
37
45
|
ESCALATION: If upload fails with column errors, open the file locally and verify the header row. If preview shows unexpected nulls, the user must clean the CSV before training.`, {
|
|
38
46
|
action: z
|
|
39
|
-
.enum(["upload", "preview", "analyze", "list", "subset", "delete", "add_expression"])
|
|
40
|
-
.describe("upload: add CSV; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; subset: create filtered subset; delete: remove dataset; add_expression: add derived column from expression"),
|
|
47
|
+
.enum(["upload", "preview", "analyze", "list", "subset", "delete", "add_expression", "reduce_spectral"])
|
|
48
|
+
.describe("upload: add CSV; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; subset: create filtered subset; delete: remove dataset; add_expression: add derived column from expression; reduce_spectral: collapse a long ordered numeric block (e.g. spectrum, time series) into a small per-row feature set via PCA / log_sample / uniform_sample / stats"),
|
|
41
49
|
name: z.string().optional().describe("Dataset name (required for action=upload and subset)"),
|
|
42
50
|
file_path: z.string().optional().describe("Path to local CSV (PREFERRED): absolute path, file:// URI, or relative to workspace root. Token-efficient; server reads file."),
|
|
43
51
|
csv_data: z.string().optional().describe("Inline CSV string for small pastes only (<10KB). Avoid for large files — use file_path instead to avoid token explosion."),
|
|
@@ -81,7 +89,21 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
81
89
|
})
|
|
82
90
|
.optional()
|
|
83
91
|
.describe("action=add_expression: evaluation options (missing, window for rolling)"),
|
|
84
|
-
|
|
92
|
+
method: z
|
|
93
|
+
.enum(["pca", "log_sample", "uniform_sample", "stats"])
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("action=reduce_spectral: pca = top-k principal components (general first try); log_sample = k columns at log-spaced indices (scattering, audio bands, attenuation); uniform_sample = k columns at evenly-spaced indices (regularly-sampled time series); stats = 6 fixed per-row summary columns (mean, std, min, max, skew, integral; k ignored)"),
|
|
96
|
+
columns_block: z
|
|
97
|
+
.array(z.string())
|
|
98
|
+
.optional()
|
|
99
|
+
.describe("action=reduce_spectral: ordered list of source column names representing the block (≥ 2; ≥ k+1 for non-stats methods)"),
|
|
100
|
+
k: z
|
|
101
|
+
.number()
|
|
102
|
+
.int()
|
|
103
|
+
.min(1)
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("action=reduce_spectral: output dimensionality. Required for pca/log_sample/uniform_sample; ignored for stats."),
|
|
106
|
+
}, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options, method, columns_block, k }) => {
|
|
85
107
|
if (action === "upload") {
|
|
86
108
|
if (!name)
|
|
87
109
|
throw new Error("datasets(upload) requires name");
|
|
@@ -93,10 +115,17 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
93
115
|
if (ext !== ".csv" && ext !== ".tsv") {
|
|
94
116
|
throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
|
|
95
117
|
}
|
|
118
|
+
const MAX_UPLOAD_BYTES = 256 * 1024 * 1024; // 256 MB (gzip keeps the wire payload small)
|
|
96
119
|
try {
|
|
120
|
+
const stat = await fs.stat(resolved);
|
|
121
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
122
|
+
throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Maximum upload size is ${MAX_UPLOAD_BYTES / 1024 / 1024} MB.`);
|
|
123
|
+
}
|
|
97
124
|
body = await fs.readFile(resolved, "utf-8");
|
|
98
125
|
}
|
|
99
|
-
catch {
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err instanceof Error && err.message.includes("too large"))
|
|
128
|
+
throw err;
|
|
100
129
|
throw new Error(`File not accessible at resolved path. file_path is relative to workspace root. ` +
|
|
101
130
|
`Set BARIVIA_WORKSPACE_ROOT in your MCP config env if needed (current: ${await getWorkspaceRootAsync(server)}).`);
|
|
102
131
|
}
|
|
@@ -107,10 +136,19 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
107
136
|
else {
|
|
108
137
|
throw new Error("datasets(upload) requires file_path or csv_data. Prefer file_path for token efficiency.");
|
|
109
138
|
}
|
|
110
|
-
|
|
139
|
+
// gzip large CSVs to keep the wire payload (and the API's compressed-body
|
|
140
|
+
// cap) small; the API transparently decompresses. Small bodies stay plain.
|
|
141
|
+
const GZIP_THRESHOLD = 1024 * 1024; // 1 MB
|
|
142
|
+
const uploadHeaders = {
|
|
111
143
|
"X-Dataset-Name": name,
|
|
112
144
|
"Content-Type": "text/csv",
|
|
113
|
-
}
|
|
145
|
+
};
|
|
146
|
+
let uploadBody = body;
|
|
147
|
+
if (Buffer.byteLength(body, "utf-8") > GZIP_THRESHOLD) {
|
|
148
|
+
uploadBody = gzipSync(Buffer.from(body, "utf-8"));
|
|
149
|
+
uploadHeaders["Content-Encoding"] = "gzip";
|
|
150
|
+
}
|
|
151
|
+
const data = (await apiCall("POST", "/v1/datasets", uploadBody, uploadHeaders, UPLOAD_DATASET_TIMEOUT_MS));
|
|
114
152
|
const id = data.id ?? data.dataset_id;
|
|
115
153
|
if (id != null)
|
|
116
154
|
data.suggested_next_step = `Suggested next step: datasets(action=preview, dataset_id=${id}) to inspect columns before training.`;
|
|
@@ -265,7 +303,8 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
265
303
|
const periodicStrong = periodicity.filter((p) => p.dominant_score > 0.4);
|
|
266
304
|
if (periodicStrong.length > 0) {
|
|
267
305
|
const lags = [...new Set(periodicStrong.map((p) => p.dominant_lag))].sort((a, b) => a - b);
|
|
268
|
-
nextSteps.push(`
|
|
306
|
+
nextSteps.push(`Note: periodicity is computed on row order. If your CSV is not sorted by time, ignore lag-based suggestions; only use cyclic_features when the column is genuinely cyclic (e.g. hour, month, angle).`);
|
|
307
|
+
nextSteps.push(`Columns show periodic behavior at lags ${lags.join(", ")} (assuming row order is meaningful). Consider cyclic_features or temporal_features with matching periods.`);
|
|
269
308
|
nextSteps.push(`For datetime columns: run datasets(action=preview) for temporal_suggestions; match lags (e.g. 365→day_of_year, 12→month, 7→day_of_week, 24→hour_of_day) to choose which temporal components to extract.`);
|
|
270
309
|
}
|
|
271
310
|
nextSteps.push(`Then call jobs(action=train_map, dataset_id=${dataset_id}, columns=[...], ...).`);
|
|
@@ -311,6 +350,58 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
|
|
|
311
350
|
content: [{ type: "text", text: `datasets(add_expression) job ${deriveJobId} submitted. Poll with jobs(action=status, job_id="${deriveJobId}").` }],
|
|
312
351
|
};
|
|
313
352
|
}
|
|
353
|
+
if (action === "reduce_spectral") {
|
|
354
|
+
if (!dataset_id)
|
|
355
|
+
throw new Error("datasets(reduce_spectral) requires dataset_id");
|
|
356
|
+
if (!name)
|
|
357
|
+
throw new Error("datasets(reduce_spectral) requires name (prefix for derived columns)");
|
|
358
|
+
if (!method)
|
|
359
|
+
throw new Error("datasets(reduce_spectral) requires method: pca | log_sample | uniform_sample | stats");
|
|
360
|
+
if (!columns_block || columns_block.length < 2) {
|
|
361
|
+
throw new Error("datasets(reduce_spectral) requires columns_block: ordered array of ≥ 2 source column names");
|
|
362
|
+
}
|
|
363
|
+
if (method !== "stats" && (k === undefined || k < 1)) {
|
|
364
|
+
throw new Error(`datasets(reduce_spectral) requires k ≥ 1 for method '${method}'`);
|
|
365
|
+
}
|
|
366
|
+
if (method !== "stats" && k !== undefined && k >= columns_block.length) {
|
|
367
|
+
throw new Error(`datasets(reduce_spectral) requires k < length(columns_block); got k=${k}, columns_block=${columns_block.length}`);
|
|
368
|
+
}
|
|
369
|
+
const body = { name, method, columns: columns_block };
|
|
370
|
+
if (method !== "stats")
|
|
371
|
+
body.k = k;
|
|
372
|
+
const data = (await apiCall("POST", `/v1/datasets/${dataset_id}/reduce_spectral`, body));
|
|
373
|
+
const reduceJobId = data.id;
|
|
374
|
+
const poll = await pollUntilComplete(reduceJobId, 120_000);
|
|
375
|
+
if (poll.status === "completed") {
|
|
376
|
+
const results = (await apiCall("GET", `/v1/results/${reduceJobId}`));
|
|
377
|
+
const summary = (results.summary ?? {});
|
|
378
|
+
const outCols = summary.output_columns ?? [];
|
|
379
|
+
const sourceCols = summary.source_columns ?? [];
|
|
380
|
+
const lines = [
|
|
381
|
+
`Spectral reduction complete (${method}) — job: ${reduceJobId}`,
|
|
382
|
+
`Source: ${sourceCols.length} columns → Output: ${outCols.length} columns appended to dataset ${dataset_id}`,
|
|
383
|
+
`New columns: ${outCols.join(", ")}`,
|
|
384
|
+
];
|
|
385
|
+
if (method === "pca") {
|
|
386
|
+
const ev = summary.explained_variance_ratio;
|
|
387
|
+
const evTotal = summary.explained_variance_total;
|
|
388
|
+
if (ev)
|
|
389
|
+
lines.push(`Explained variance per component: [${ev.map((v) => v.toFixed(3)).join(", ")}] | total: ${evTotal !== undefined ? evTotal.toFixed(3) : "N/A"}`);
|
|
390
|
+
}
|
|
391
|
+
else if (method === "log_sample" || method === "uniform_sample") {
|
|
392
|
+
const idxs = summary.selected_indices;
|
|
393
|
+
if (idxs) {
|
|
394
|
+
lines.push(`Selected source-column indices (1-based): [${idxs.join(", ")}]`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
lines.push(`Use datasets(action=preview, dataset_id=${dataset_id}) to inspect the new columns, then jobs(action=train_map, dataset_id=${dataset_id}, columns=[..., "${outCols[0] ?? ""}", …]) to train on the reduced features.`);
|
|
398
|
+
return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }] };
|
|
399
|
+
}
|
|
400
|
+
if (poll.status === "failed") {
|
|
401
|
+
return { content: [{ type: "text", text: `datasets(reduce_spectral) job ${reduceJobId} failed: ${poll.error ?? "unknown error"}` }] };
|
|
402
|
+
}
|
|
403
|
+
return { content: [{ type: "text", text: `datasets(reduce_spectral) job ${reduceJobId} submitted. Poll with jobs(action=status, job_id="${reduceJobId}").` }] };
|
|
404
|
+
}
|
|
314
405
|
if (action === "subset") {
|
|
315
406
|
if (!dataset_id)
|
|
316
407
|
throw new Error("datasets(subset) requires dataset_id");
|
package/dist/tools/inference.js
CHANGED
|
@@ -1,46 +1,100 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { apiCall, pollUntilComplete, tryAttachImage } from "../shared.js";
|
|
3
|
+
const PREDICT_PREVIEW_ROW_CAP = 10;
|
|
4
|
+
/** One line per scored row when worker embedded `predictions_preview` (n_rows ≤ cap). Exported for tests. */
|
|
5
|
+
export function formatPredictPreviewLines(summary) {
|
|
6
|
+
const nRows = Number(summary.n_rows ?? 0);
|
|
7
|
+
if (nRows < 1 || nRows > PREDICT_PREVIEW_ROW_CAP)
|
|
8
|
+
return [];
|
|
9
|
+
const preview = summary.predictions_preview;
|
|
10
|
+
if (!Array.isArray(preview) || preview.length === 0)
|
|
11
|
+
return [];
|
|
12
|
+
const lines = ["Per-row summary (same columns as predictions.csv):"];
|
|
13
|
+
for (const raw of preview) {
|
|
14
|
+
const p = raw;
|
|
15
|
+
const rid = String(p.row_id ?? "?");
|
|
16
|
+
const bx = p.bmu_x !== undefined ? Number(p.bmu_x).toFixed(2) : "?";
|
|
17
|
+
const by = p.bmu_y !== undefined ? Number(p.bmu_y).toFixed(2) : "?";
|
|
18
|
+
const node = p.bmu_node_index ?? "?";
|
|
19
|
+
const cl = p.cluster_id ?? "?";
|
|
20
|
+
if (p.quantization_error !== undefined) {
|
|
21
|
+
const qe = Number(p.quantization_error).toFixed(4);
|
|
22
|
+
const anom = p.potential_anomaly === true ? "yes" : "no";
|
|
23
|
+
lines.push(` row ${rid}: BMU (${bx},${by}) node=${node} cluster=${cl} QE=${qe} anomaly=${anom}`);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// regime=training: QE columns are intentionally omitted (training-set fit, not generalisation)
|
|
27
|
+
lines.push(` row ${rid}: BMU (${bx},${by}) node=${node} cluster=${cl}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
3
32
|
export function registerInferenceTool(server) {
|
|
4
|
-
server.tool("inference", `Use a trained map as a persistent inference artifact — score
|
|
33
|
+
server.tool("inference", `Use a trained map as a persistent inference artifact — score data, annotate the source CSV, compare datasets, project columns, or generate a report manifest.
|
|
5
34
|
|
|
6
35
|
| Action | Use when | Timing |
|
|
7
36
|
|--------|----------|--------|
|
|
8
|
-
| predict | Scoring
|
|
9
|
-
|
|
|
37
|
+
| predict | Scoring rows against the trained map (new data OR the training set itself) | 5–120s |
|
|
38
|
+
| impute_column | Fill a numeric column (not used in training) by pooling observed values on the BMU plus topology neighbors (typically 6 on hex; periodic maps wrap) | 5–120s |
|
|
10
39
|
| compare | Comparing hit distributions of a second dataset against training (drift, A/B) | 30–120s |
|
|
11
40
|
| project_columns | Project one or more dataset columns onto the map (component planes); dataset can be training set or partial-feature set | 10–90s |
|
|
12
41
|
| report | Get a report manifest (artifact keys + URLs) to build your own report in Quarto/Notebook/script | Immediate (sync) |
|
|
13
42
|
|
|
14
|
-
Sync/async: predict,
|
|
15
|
-
Artifacts: When complete, use results(action=download, job_id=<returned_job_id>) to get: predict → predictions.csv;
|
|
43
|
+
Sync/async: predict, impute_column, and compare are async jobs. The proxy auto-polls and usually returns when the job completes. If it returns a job_id instead (e.g. timeout), poll jobs(action=status, job_id=...) then results(action=download, job_id=...) to retrieve the artifact.
|
|
44
|
+
Artifacts: When complete, use results(action=download, job_id=<returned_job_id>) to get: predict (output="compact") → predictions.csv; predict (output="annotated") → annotated.csv; impute_column → imputed.csv; compare → density-diff figure (e.g. density_diff.png).
|
|
16
45
|
report is the only synchronous inference action — returns manifest immediately; no job to poll.
|
|
17
46
|
NOT FOR: Retraining or changing the map — all actions treat the trained map as frozen.
|
|
18
47
|
ESCALATION: If any action returns "missing column", verify column names with datasets(action=preview). Column names are case-sensitive and must match the training feature set exactly.
|
|
19
48
|
|
|
20
|
-
action=predict:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
action=predict: Score rows against the trained map.
|
|
50
|
+
Inputs (one of):
|
|
51
|
+
- dataset_id (default = parent training job's dataset). Use a different dataset for new/unseen data; omit it (or pass the training dataset_id) to score the training set itself.
|
|
52
|
+
- rows (≤500 inline). Always treated as new data.
|
|
53
|
+
Output style (output param, default "compact"):
|
|
54
|
+
- "compact" → predictions.csv (row_id, bmu_x, bmu_y, bmu_node_index, cluster_id [, quantization_error, potential_anomaly]).
|
|
55
|
+
- "annotated" → annotated.csv (full source CSV with bmu_x, bmu_y, bmu_node_index, cluster_id appended). Requires a dataset (no inline rows).
|
|
56
|
+
Regime auto-detected:
|
|
57
|
+
- If the resolved dataset matches the parent training dataset, regime="training" and QE / qe_p95 / potential_anomaly fields are omitted from the compact output. QE on training data is fitting error, not a generalisation metric, and the p95 anomaly flag would be circular. Use a held-out dataset for quality assessment.
|
|
58
|
+
- Otherwise regime="new" and the full QE columns are returned.
|
|
59
|
+
Schema rules: dataset / batch rows must match the trained feature set including cyclic-expanded columns (e.g. key_cos, key_sin, not raw key). For a single inline row, the proxy uses the stateless model endpoint — raw categorical strings are allowed for baseline categorical_features models, and raw cyclic inputs are expanded from training config.
|
|
60
|
+
Routing: prefer dataset_id for many rows or whenever the map uses irregular SIOM / GeneralTopology layouts — the async worker path is the supported batch scorer. Single-row rows take a fast stateless path that may return invalid_inference_input on some topologies; if so, retry with dataset_id (a one-row dataset is fine). FLooP-SIOM: use dataset_id predict first.
|
|
61
|
+
When the scored set has at most ${PREDICT_PREVIEW_ROW_CAP} rows, completed responses include a short per-line preview in the tool text for chat agents.
|
|
62
|
+
|
|
63
|
+
action=impute_column: Map-local imputation as read-only post-processing (the trained map is frozen; not a held-out validity claim). Requires dataset_id + target_column. The dataset must contain all training features (same names and cyclic expansion as predict) plus the target column. target_column must NOT have been in jobs(train_map) columns — train without it, then impute. Pools finite target values from rows whose BMUs lie on this row's BMU and its topology neighbors (BMU + neighbors, often 7 nodes on hex interior; fewer on borders if the parent map is non-periodic; periodic hex wraps), aggregated neighbourhood-distance-weighted by default (weighting="distance" — closer nodes count more; weighting="uniform" for a flat pool). Excludes the current row from its own pool. only_missing (default true): keep observed values. impute_aggregation: mean or median of the pool. Optional cv_folds (2-20) writes quality.csv (held-out MAE/RMSE/R2); target_column_kind handles categorical (mode) / cumulative (warns). Output imputed.csv: row_id, target_original, target_imputed, impute_source (observed | imputed | insufficient_data), bmu_node_index, n_patch_nodes, n_pool_rows, pool_std, pool_p5, pool_p95.
|
|
64
|
+
|
|
65
|
+
action=compare: dataset_id must refer to a dataset with the same feature set as training (same column names and preprocessing, including cyclic expansion). A = training dataset; B = cohort to compare. Density-diff: positive = B gained vs A; negative = A had more. Returns density-diff heatmap (e.g. density_diff.png).
|
|
24
66
|
action=project_columns: Project one or more columns from a dataset onto the trained map. Pass dataset_id (the dataset containing the columns) and columns (array of column names). Uses cached BMUs when dataset is the training set; supports partial-feature mapping when dataset has only a subset of training features. Returns one component plane image per column. Get files via results(action=download, job_id=<returned_job_id>).
|
|
25
|
-
action=report: Returns a report manifest for the given job_id (job must be completed). Includes figure_manifest (logical names → filenames), download_urls for all artifacts, cluster_summary when available, and summary metrics. Stakeholder report PDF (if generated) is available via results(action=download, job_id=<training_job_id>), filename e.g. report.pdf
|
|
67
|
+
action=report: Returns a report manifest for the given job_id (job must be completed). Includes figure_manifest (logical names → filenames), download_urls for all artifacts, cluster_summary when available, and summary metrics. Stakeholder report PDF (if generated) is available via results(action=download, job_id=<training_job_id>), filename e.g. report.pdf.`, {
|
|
26
68
|
action: z
|
|
27
|
-
.enum(["predict", "
|
|
28
|
-
.describe("predict: score
|
|
69
|
+
.enum(["predict", "impute_column", "compare", "project_columns", "report"])
|
|
70
|
+
.describe("predict: score rows; impute_column: topology-neighbor pool imputation for a column not in training; compare: drift/cohort diff heatmap; project_columns: project dataset columns onto map; report: manifest of primitives for custom report."),
|
|
29
71
|
job_id: z.string().describe("Job ID of a completed map training job"),
|
|
30
|
-
dataset_id: z.string().optional().describe("action=predict/compare/project_columns: Dataset ID. predict=
|
|
72
|
+
dataset_id: z.string().optional().describe("action=predict/impute_column/compare/project_columns: Dataset ID. predict=data to score (defaults to the training dataset when omitted); impute_column=dataset with training features + target_column; compare=dataset B; project_columns=dataset with columns to project."),
|
|
31
73
|
columns: z.array(z.string()).optional().describe("action=project_columns: column names to project onto the map (must exist in the dataset)."),
|
|
32
74
|
rows: z.array(z.record(z.string(), z.union([z.number(), z.string()]))).optional().describe("action=predict: inline rows to score (max 500). For a single inline row, raw categorical strings are allowed for baseline categorical_features models. Batch rows should remain numeric and match the training schema."),
|
|
75
|
+
output: z.enum(["compact", "annotated"]).optional().default("compact").describe("action=predict: output style. compact = predictions.csv (default); annotated = annotated.csv (original rows plus bmu_x, bmu_y, bmu_node_index, cluster_id)."),
|
|
33
76
|
colormap: z.string().optional().describe("action=compare: colormap for diff heatmap (default: balance). action=report: n/a."),
|
|
34
77
|
output_format: z.enum(["png", "pdf", "svg"]).optional().default("png").describe("action=compare: output format for heatmap (default: png)"),
|
|
35
78
|
output_dpi: z.enum(["standard", "retina", "print"]).optional().default("retina").describe("Resolution: standard (1x), retina (2x, default), print (4x)"),
|
|
36
79
|
top_n: z.number().int().min(1).max(50).optional().default(10).describe("action=compare: number of top gained/lost nodes to report (default: 10)"),
|
|
37
|
-
|
|
80
|
+
target_column: z.string().optional().describe("action=impute_column: numeric column to impute (must not be a training feature)."),
|
|
81
|
+
only_missing: z.boolean().optional().default(true).describe("action=impute_column: if true, leave observed values unchanged."),
|
|
82
|
+
impute_aggregation: z.enum(["mean", "median"]).optional().default("mean").describe("action=impute_column: aggregation over pooled neighbor rows."),
|
|
83
|
+
cv_folds: z.number().int().min(2).max(20).optional().describe("action=impute_column: if set (2-20), run k-fold cross-validation on observed target cells and emit quality.csv with MAE / RMSE / R2 (held-out). Omit to skip."),
|
|
84
|
+
target_column_kind: z.enum(["instantaneous", "cumulative", "categorical"]).optional()
|
|
85
|
+
.describe("action=impute_column: instantaneous (default) = pool mean/median; categorical = pool mode; cumulative = monotonic-counter (pool aggregation is rough; warns). A monotonic-counter warning is emitted automatically regardless."),
|
|
86
|
+
weighting: z.enum(["distance", "uniform"]).optional()
|
|
87
|
+
.describe("action=impute_column: distance (default) weights pooled values by map-neighbourhood proximity (closer BMU nodes count more); uniform is a flat pool."),
|
|
88
|
+
}, async ({ action, job_id, dataset_id, columns, rows, output, colormap, output_format, output_dpi, top_n, target_column, only_missing, impute_aggregation, cv_folds, target_column_kind, weighting }) => {
|
|
38
89
|
const dpiMap = { standard: 1, retina: 2, print: 4 };
|
|
39
90
|
const numericDpi = dpiMap[output_dpi ?? "retina"] ?? 2;
|
|
40
91
|
if (action === "predict") {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
92
|
+
const outputStyle = output ?? "compact";
|
|
93
|
+
if (outputStyle === "annotated" && rows) {
|
|
94
|
+
throw new Error("inference(predict, output=\"annotated\") requires a dataset (not inline rows). Either omit rows and pass dataset_id, or use output=\"compact\".");
|
|
95
|
+
}
|
|
96
|
+
// Stateless single-row fast path: only valid for compact output and inline rows.
|
|
97
|
+
if (!dataset_id && rows && rows.length === 1 && outputStyle === "compact") {
|
|
44
98
|
const row = rows[0];
|
|
45
99
|
const data = (await apiCall("POST", `/v1/models/${job_id}/project`, { features: row }));
|
|
46
100
|
return {
|
|
@@ -62,49 +116,90 @@ action=report: Returns a report manifest for the given job_id (job must be compl
|
|
|
62
116
|
throw new Error("Batch inline predict rows must remain numeric. For baseline categorical inference, submit a single inline row so the proxy can use the stateless model endpoint.");
|
|
63
117
|
}
|
|
64
118
|
}
|
|
65
|
-
const body = {};
|
|
119
|
+
const body = { output_style: outputStyle };
|
|
66
120
|
if (dataset_id)
|
|
67
121
|
body.dataset_id = dataset_id;
|
|
68
122
|
if (rows)
|
|
69
123
|
body.rows = rows;
|
|
70
124
|
const data = (await apiCall("POST", `/v1/results/${job_id}/predict`, body));
|
|
71
125
|
const predictJobId = data.id;
|
|
126
|
+
// annotated runs over the full dataset; allow up to 120s like compact
|
|
72
127
|
const poll = await pollUntilComplete(predictJobId, 120_000);
|
|
73
128
|
if (poll.status === "completed") {
|
|
74
129
|
const results = (await apiCall("GET", `/v1/results/${predictJobId}`));
|
|
75
130
|
const summary = (results.summary ?? {});
|
|
76
131
|
const urls = (results.download_urls ?? {});
|
|
132
|
+
const previewLines = formatPredictPreviewLines(summary);
|
|
133
|
+
const regime = String(summary.regime ?? "new");
|
|
134
|
+
const effectiveStyle = String(summary.output_style ?? outputStyle);
|
|
135
|
+
const isAnnotated = effectiveStyle === "annotated";
|
|
136
|
+
const artifactName = isAnnotated ? "annotated.csv" : "predictions.csv";
|
|
137
|
+
const headerLine = isAnnotated
|
|
138
|
+
? `Annotated dataset ready — job: ${predictJobId}`
|
|
139
|
+
: `Predictions complete — job: ${predictJobId}`;
|
|
140
|
+
const trainingCaveat = regime === "training"
|
|
141
|
+
? `Training-set BMU lookup. QE on training data is not a generalisation metric; the QE / qe_p95 / potential_anomaly columns are intentionally omitted. Use a held-out dataset (different dataset_id) for quality assessment.`
|
|
142
|
+
: "";
|
|
143
|
+
const metricsLine = (regime !== "training" && !isAnnotated)
|
|
144
|
+
? `Mean QE: ${summary.mean_qe !== undefined ? Number(summary.mean_qe).toFixed(4) : "N/A"} | Max QE: ${summary.max_qe !== undefined ? Number(summary.max_qe).toFixed(4) : "N/A"} | qe_p95: ${summary.qe_p95 !== undefined ? Number(summary.qe_p95).toFixed(4) : "N/A"}`
|
|
145
|
+
: "";
|
|
146
|
+
const outputLine = isAnnotated
|
|
147
|
+
? `Output: annotated.csv (original CSV + bmu_x, bmu_y, bmu_node_index, cluster_id appended). Clusters: ${summary.n_clusters ?? Object.keys(summary.cluster_counts ?? {}).length}.`
|
|
148
|
+
: (regime === "training"
|
|
149
|
+
? `Output: predictions.csv (row_id, bmu_x, bmu_y, bmu_node_index, cluster_id). Clusters: ${Object.keys(summary.cluster_counts ?? {}).length}.`
|
|
150
|
+
: `Output: predictions.csv (row_id, bmu_x, bmu_y, bmu_node_index, cluster_id, quantization_error, potential_anomaly). Summary includes mean_qe, max_qe, qe_p95. Clusters: ${Object.keys(summary.cluster_counts ?? {}).length}.`);
|
|
77
151
|
return { content: [{ type: "text", text: [
|
|
78
|
-
|
|
152
|
+
headerLine,
|
|
153
|
+
`Regime: ${regime}${regime === "training" ? " (scored against the training set)" : ""} | Style: ${effectiveStyle}`,
|
|
79
154
|
`Rows scored: ${summary.n_rows ?? "?"}`,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
155
|
+
metricsLine,
|
|
156
|
+
outputLine,
|
|
157
|
+
urls[artifactName] ? `Download: ${urls[artifactName]}` : "",
|
|
158
|
+
trainingCaveat,
|
|
159
|
+
...previewLines,
|
|
84
160
|
].filter(Boolean).join("\n") }] };
|
|
85
161
|
}
|
|
86
162
|
if (poll.status === "failed")
|
|
87
163
|
return { content: [{ type: "text", text: `inference(predict) job ${predictJobId} failed: ${poll.error ?? "unknown error"}` }] };
|
|
88
164
|
return { content: [{ type: "text", text: `inference(predict) job ${predictJobId} submitted. Poll with jobs(action=status, job_id="${predictJobId}").` }] };
|
|
89
165
|
}
|
|
90
|
-
if (action === "
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
166
|
+
if (action === "impute_column") {
|
|
167
|
+
if (!dataset_id)
|
|
168
|
+
throw new Error("inference(impute_column) requires dataset_id");
|
|
169
|
+
if (!target_column?.trim())
|
|
170
|
+
throw new Error("inference(impute_column) requires target_column");
|
|
171
|
+
const body = {
|
|
172
|
+
dataset_id,
|
|
173
|
+
target_column: target_column.trim(),
|
|
174
|
+
only_missing: only_missing ?? true,
|
|
175
|
+
aggregation: impute_aggregation ?? "mean",
|
|
176
|
+
};
|
|
177
|
+
if (cv_folds !== undefined)
|
|
178
|
+
body.cv_folds = cv_folds;
|
|
179
|
+
if (target_column_kind !== undefined)
|
|
180
|
+
body.target_column_kind = target_column_kind;
|
|
181
|
+
if (weighting !== undefined)
|
|
182
|
+
body.weighting = weighting;
|
|
183
|
+
const data = (await apiCall("POST", `/v1/results/${job_id}/impute_column`, body));
|
|
184
|
+
const imputeJobId = data.id;
|
|
185
|
+
const poll = await pollUntilComplete(imputeJobId, 120_000);
|
|
94
186
|
if (poll.status === "completed") {
|
|
95
|
-
const results = (await apiCall("GET", `/v1/results/${
|
|
187
|
+
const results = (await apiCall("GET", `/v1/results/${imputeJobId}`));
|
|
96
188
|
const summary = (results.summary ?? {});
|
|
97
189
|
const urls = (results.download_urls ?? {});
|
|
98
190
|
return { content: [{ type: "text", text: [
|
|
99
|
-
`
|
|
100
|
-
`
|
|
101
|
-
`
|
|
102
|
-
|
|
103
|
-
|
|
191
|
+
`Impute column complete — job: ${imputeJobId}`,
|
|
192
|
+
`Target: ${summary.target_column ?? target_column} | aggregation: ${summary.aggregation ?? impute_aggregation} | only_missing: ${summary.only_missing ?? only_missing}`,
|
|
193
|
+
`Rows: ${summary.n_rows ?? "?"} | imputed rows (source=imputed): ${summary.n_imputed ?? "?"} | insufficient_data: ${summary.n_insufficient ?? "?"}`,
|
|
194
|
+
`Mean patch nodes: ${summary.mean_patch_nodes !== undefined ? Number(summary.mean_patch_nodes).toFixed(2) : "N/A"} (BMU + neighbors; hex interior often ~7).`,
|
|
195
|
+
urls["imputed.csv"] ? `Download imputed.csv: ${urls["imputed.csv"]}` : "Use results(action=get, download) for URLs.",
|
|
196
|
+
"",
|
|
197
|
+
"Map-local estimates only — not a substitute for held-out validation.",
|
|
198
|
+
].join("\n") }] };
|
|
104
199
|
}
|
|
105
200
|
if (poll.status === "failed")
|
|
106
|
-
return { content: [{ type: "text", text: `inference(
|
|
107
|
-
return { content: [{ type: "text", text: `inference(
|
|
201
|
+
return { content: [{ type: "text", text: `inference(impute_column) job ${imputeJobId} failed: ${poll.error ?? "unknown error"}` }] };
|
|
202
|
+
return { content: [{ type: "text", text: `inference(impute_column) job ${imputeJobId} submitted. Poll with jobs(action=status, job_id="${imputeJobId}").` }] };
|
|
108
203
|
}
|
|
109
204
|
if (action === "compare") {
|
|
110
205
|
if (!dataset_id)
|
package/dist/tools/jobs.js
CHANGED
|
@@ -5,7 +5,7 @@ export const JOBS_DESCRIPTION_BASE = `Manage and inspect jobs.
|
|
|
5
5
|
| Action | Use when |
|
|
6
6
|
|--------|----------|
|
|
7
7
|
| status | Polling after any async job submission — call every 10–15s |
|
|
8
|
-
| list | Finding job IDs, checking what is pending/completed, reviewing hyperparameters. Response includes job_type (train_map,
|
|
8
|
+
| list | Finding job IDs, checking what is pending/completed, reviewing hyperparameters. Response includes job_type (train_map, report, recolor, project, transition_flow, compare, predict, impute_column, annotated_dataset, reduce_spectral) to filter or display. |
|
|
9
9
|
| compare | Picking the best training run from a set of completed jobs |
|
|
10
10
|
| train_map | Submitting a new map training job — returns job_id for polling |
|
|
11
11
|
| train_siom_map | Submitting a self-interacting map training job — same map flow with SIOM coverage control |
|
|
@@ -35,18 +35,118 @@ action=train_map / train_siom_map: Submits a grid-map training job. Returns job_
|
|
|
35
35
|
Presets refined/high_res may use GPU. On CPU-only hosts pass backend=cpu. API expects strings "cpu" | "gpu" | "gpu_graphs" (no colon). Future backends (e.g. non-CUDA) may be added under the same contract.
|
|
36
36
|
normalize: "auto" (default) = scale only non-cyclic features; "all" = scale every feature. Use "auto" when using cyclic_features.
|
|
37
37
|
categorical_features: optional baseline categorical support using explicit weighted one-hot encoding. Provide the raw column name, allowed categories, and a weight. Advanced categorical embeddings are intentionally outside this default tool surface.
|
|
38
|
+
label (optional, ≤120 chars): user-supplied run name; appears in jobs(list) and jobs(compare) output. Strongly recommended for sweeps so compare rows stay readable.
|
|
38
39
|
train_siom_map only: siom_feature_geometry l2 (default) | mixed | auto — torus distance on cyclic (cos,sin) pairs when mixed or auto with cyclic encodings. siom_qe_backend cpu|cuda|auto and siom_qe_batch_size align siom_qe with training geometry when mixed.
|
|
39
40
|
action=train_floop_siom (preferred) or train_floop_chain (deprecated alias): Submits FLooP-SIOM — a growing node-budget manifold instead of a fixed hex grid. **Only if the API key’s plan includes all_algorithms** (Premium or Enterprise); otherwise the API returns a clear upgrade message. Default topology is free (CHL dynamic graph); topology=chain is optional for a strict 1D linked-list backbone.
|
|
40
41
|
Key params: topology (default free), max_nodes, effort, gamma, siom_decay.
|
|
41
42
|
max_nodes is a total node budget, not a grid width or area. If omitted, the backend uses a dataset-size heuristic (roughly 2*sqrt(n_samples), capped for stability).
|
|
42
43
|
effort controls passes only: quick≈15, standard≈30, thorough≈60. If coverage collapses, reduce max_nodes before increasing effort.
|
|
43
|
-
action=compare: Returns a metrics table (QE, TE, explained variance, silhouette) for 2+ jobs.
|
|
44
|
+
action=compare: Returns a metrics table (QE, TE, explained variance, silhouette) for 2+ jobs. The table only shows hyperparameter columns that actually differ across the compared runs (preset, backend, batch_size, normalize, periodic, n_features, etc.) so multi-run sweeps stay readable. Tip: pass label="sweep_a" / "sweep_b" on train_map to make rows self-explanatory.
|
|
44
45
|
action=cancel: Not instant — worker checks between phases. Expect up to 30s delay.
|
|
45
46
|
action=delete: WARNING — job ID will no longer work with results or any other tool.`;
|
|
46
47
|
export async function fetchTrainingPresets() {
|
|
47
48
|
const configData = (await apiCall("GET", "/v1/training/config"));
|
|
48
49
|
return configData?.presets || {};
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Render the jobs(compare) markdown table. Strategy: always show job_id (or label
|
|
53
|
+
* if present) + the four core metrics (QE, TE, Expl.Var, Silhouette); add any
|
|
54
|
+
* hyperparameter column iff at least two compared jobs differ on it. Keeps
|
|
55
|
+
* multi-run sweeps readable by hiding columns that are identical across runs.
|
|
56
|
+
*
|
|
57
|
+
* Error rows (job not completed / not found) are preserved as a single error cell.
|
|
58
|
+
*/
|
|
59
|
+
export function renderCompareTable(comparisons) {
|
|
60
|
+
if (comparisons.length === 0)
|
|
61
|
+
return "(no comparisons)";
|
|
62
|
+
const ok = comparisons.filter((c) => !c.error);
|
|
63
|
+
const fmt = (v) => (v !== null && v !== undefined ? Number(v).toFixed(4) : "—");
|
|
64
|
+
const fmtAny = (v) => {
|
|
65
|
+
if (v === null || v === undefined)
|
|
66
|
+
return "—";
|
|
67
|
+
if (Array.isArray(v))
|
|
68
|
+
return v.map((x) => String(x)).join(",");
|
|
69
|
+
if (typeof v === "boolean")
|
|
70
|
+
return v ? "true" : "false";
|
|
71
|
+
return String(v);
|
|
72
|
+
};
|
|
73
|
+
const renderGrid = (g) => {
|
|
74
|
+
if (Array.isArray(g) && g.length >= 2)
|
|
75
|
+
return `${g[0]}×${g[1]}`;
|
|
76
|
+
return "—";
|
|
77
|
+
};
|
|
78
|
+
const renderEpochs = (e) => {
|
|
79
|
+
if (Array.isArray(e) && e.length >= 2)
|
|
80
|
+
return `${e[0]}+${e[1]}`;
|
|
81
|
+
if (typeof e === "number")
|
|
82
|
+
return String(e);
|
|
83
|
+
return "—";
|
|
84
|
+
};
|
|
85
|
+
// Candidate hyperparameter columns considered for inclusion when they vary.
|
|
86
|
+
const candidates = [
|
|
87
|
+
{ key: "grid", label: "Grid", render: renderGrid },
|
|
88
|
+
{ key: "epochs", label: "Epochs", render: renderEpochs },
|
|
89
|
+
{ key: "model", label: "Model", render: fmtAny },
|
|
90
|
+
{ key: "periodic", label: "Periodic", render: fmtAny },
|
|
91
|
+
{ key: "backend", label: "Backend", render: fmtAny },
|
|
92
|
+
{ key: "batch_size", label: "Batch", render: fmtAny },
|
|
93
|
+
{ key: "normalize", label: "Normalize", render: fmtAny },
|
|
94
|
+
{ key: "quality_metrics", label: "QualMetrics", render: fmtAny },
|
|
95
|
+
{ key: "n_features", label: "Features", render: fmtAny },
|
|
96
|
+
{ key: "n_samples", label: "Samples", render: fmtAny },
|
|
97
|
+
];
|
|
98
|
+
// Include a candidate iff completed jobs disagree on it.
|
|
99
|
+
const varying = candidates.filter((col) => {
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const c of ok) {
|
|
102
|
+
seen.add(JSON.stringify(c[col.key] ?? null));
|
|
103
|
+
if (seen.size > 1)
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
});
|
|
108
|
+
const anyLabel = ok.some((c) => c.label != null && String(c.label) !== "");
|
|
109
|
+
const idHeader = anyLabel ? "Run" : "Job ID";
|
|
110
|
+
const idCell = (c) => {
|
|
111
|
+
const lbl = c.label != null && String(c.label) !== "" ? String(c.label) : "";
|
|
112
|
+
const idShort = `${c.job_id.slice(0, 8)}...`;
|
|
113
|
+
return lbl ? `${lbl} (${idShort})` : idShort;
|
|
114
|
+
};
|
|
115
|
+
// Always include job_id/label + four core metrics. Add varying columns in between.
|
|
116
|
+
const metrics = [
|
|
117
|
+
{ key: "quantization_error", label: "QE", render: fmt },
|
|
118
|
+
{ key: "topographic_error", label: "TE", render: fmt },
|
|
119
|
+
{ key: "explained_variance", label: "Expl.Var", render: fmt },
|
|
120
|
+
{ key: "silhouette", label: "Silhouette", render: fmt },
|
|
121
|
+
];
|
|
122
|
+
const headerCols = [idHeader, ...varying.map((c) => c.label), ...metrics.map((m) => m.label)];
|
|
123
|
+
const lines = [
|
|
124
|
+
`| ${headerCols.join(" | ")} |`,
|
|
125
|
+
`|${headerCols.map(() => "---").join("|")}|`,
|
|
126
|
+
];
|
|
127
|
+
for (const c of comparisons) {
|
|
128
|
+
if (c.error) {
|
|
129
|
+
const filler = Array(varying.length + metrics.length - 1).fill("—").join(" | ");
|
|
130
|
+
lines.push(`| ${idCell(c)} | ${c.error} | ${filler} |`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const cells = [
|
|
134
|
+
idCell(c),
|
|
135
|
+
...varying.map((col) => col.render(c[col.key])),
|
|
136
|
+
...metrics.map((m) => m.render(c[m.key])),
|
|
137
|
+
];
|
|
138
|
+
lines.push(`| ${cells.join(" | ")} |`);
|
|
139
|
+
}
|
|
140
|
+
if (varying.length === 0 && ok.length >= 2) {
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push("(All hyperparameters identical across compared jobs — only metrics shown. Pass label=\"…\" on train_map for readable run names.)");
|
|
143
|
+
}
|
|
144
|
+
else if (!anyLabel && ok.length >= 2) {
|
|
145
|
+
lines.push("");
|
|
146
|
+
lines.push("Tip: pass label=\"sweep_xyz\" on train_map / train_siom_map / train_floop_siom to make compare rows readable.");
|
|
147
|
+
}
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
50
150
|
export function buildTrainMapParams(args, presets) {
|
|
51
151
|
const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, } = args;
|
|
52
152
|
const p = preset ? presets[preset] : undefined;
|
|
@@ -145,9 +245,15 @@ export function registerJobsTool(server, description) {
|
|
|
145
245
|
.string()
|
|
146
246
|
.optional()
|
|
147
247
|
.describe("Required for action=train_map. For action=list, filter jobs by this dataset ID."),
|
|
248
|
+
label: z
|
|
249
|
+
.string()
|
|
250
|
+
.max(120)
|
|
251
|
+
.optional()
|
|
252
|
+
.describe("Optional run label (≤120 chars) for train_map / train_siom_map / train_floop_siom — appears in jobs(list) and the jobs(compare) table; sanitized server-side. Useful for sweeps (e.g. label=\"sweep_periodic_true\")."),
|
|
148
253
|
preset: z.enum(["quick", "standard", "refined", "high_res"]).optional(),
|
|
149
|
-
grid_x: z.number().int().optional()
|
|
150
|
-
|
|
254
|
+
grid_x: z.number().int().optional()
|
|
255
|
+
.describe("Grid width. Omit grid_x AND grid_y (and preset) to auto-size the map (~5·√√n per side); the result reports hit_stats.active_node_fraction and a grid_suggestion when too many nodes are dead."),
|
|
256
|
+
grid_y: z.number().int().optional().describe("Grid height. See grid_x for auto-sizing."),
|
|
151
257
|
epochs: z.preprocess((v) => {
|
|
152
258
|
if (v === undefined || v === null)
|
|
153
259
|
return v;
|
|
@@ -256,7 +362,7 @@ export function registerJobsTool(server, description) {
|
|
|
256
362
|
return { content: [{ type: "text", text: `Baseline study submitted. Job ID: ${jid}\nGrid size: ${side}x${side}\nNormalization: MAD\n\nPoll with jobs(action=status, job_id="${jid}") until complete, then retrieve with results(action=get, job_id="${jid}"). Optional: training_monitor(job_id="${jid}") for a visual panel—not required.` }] };
|
|
257
363
|
}
|
|
258
364
|
if (action === "train_map" || action === "train_siom_map") {
|
|
259
|
-
const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, gamma, gamma_f, siom_decay, siom_penalty, penalty_alpha, reset_per_epoch, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, } = args;
|
|
365
|
+
const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, gamma, gamma_f, siom_decay, siom_penalty, penalty_alpha, reset_per_epoch, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, label, } = args;
|
|
260
366
|
let PRESETS = {};
|
|
261
367
|
try {
|
|
262
368
|
PRESETS = await fetchTrainingPresets();
|
|
@@ -304,7 +410,10 @@ export function registerJobsTool(server, description) {
|
|
|
304
410
|
totalRows = Number(preview?.total_rows ?? 0);
|
|
305
411
|
}
|
|
306
412
|
catch { /* ignore */ }
|
|
307
|
-
const
|
|
413
|
+
const submitBody = { dataset_id, params };
|
|
414
|
+
if (label && label.trim() !== "")
|
|
415
|
+
submitBody.label = label;
|
|
416
|
+
const data = (await apiCall("POST", "/v1/jobs", submitBody));
|
|
308
417
|
const newJobId = data.id;
|
|
309
418
|
const variantPrefix = action === "train_siom_map" ? "variant=siom" : "variant=som";
|
|
310
419
|
data.effective_params = `${variantPrefix}, ${paramSummary}`;
|
|
@@ -335,7 +444,7 @@ export function registerJobsTool(server, description) {
|
|
|
335
444
|
return textResult(data);
|
|
336
445
|
}
|
|
337
446
|
if (action === "train_floop_siom" || action === "train_floop_chain") {
|
|
338
|
-
const { columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, normalize, row_range, output_format, output_dpi, colormap, topology, max_nodes, effort, n_initial, n_passes, growth_interval, neighborhood_size, edge_max_age, max_degree, gamma, siom_decay, siom_penalty, penalty_alpha, error_threshold, error_decay, ring_close_threshold, sigma_0, sigma_f, eta_0, eta_f, elastic_lambda, elastic_mu, elastic_anchor, anchor_percentile, } = args;
|
|
447
|
+
const { columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, normalize, row_range, output_format, output_dpi, colormap, topology, max_nodes, effort, n_initial, n_passes, growth_interval, neighborhood_size, edge_max_age, max_degree, gamma, siom_decay, siom_penalty, penalty_alpha, error_threshold, error_decay, ring_close_threshold, sigma_0, sigma_f, eta_0, eta_f, elastic_lambda, elastic_mu, elastic_anchor, anchor_percentile, label, } = args;
|
|
339
448
|
if (!dataset_id)
|
|
340
449
|
throw new Error("jobs(train_floop_siom) requires dataset_id");
|
|
341
450
|
const params = {
|
|
@@ -408,7 +517,10 @@ export function registerJobsTool(server, description) {
|
|
|
408
517
|
params.elastic_anchor = elastic_anchor;
|
|
409
518
|
if (anchor_percentile !== undefined)
|
|
410
519
|
params.anchor_percentile = anchor_percentile;
|
|
411
|
-
const
|
|
520
|
+
const submitBody = { dataset_id, params };
|
|
521
|
+
if (label && label.trim() !== "")
|
|
522
|
+
submitBody.label = label;
|
|
523
|
+
const data = (await apiCall("POST", "/v1/jobs", submitBody));
|
|
412
524
|
const newJobId = data.id;
|
|
413
525
|
const maxNodeSummary = max_nodes === undefined ? "max_nodes=auto(~2*sqrt(n_samples))" : `max_nodes=${max_nodes}`;
|
|
414
526
|
const paramSummary = [
|
|
@@ -465,21 +577,7 @@ export function registerJobsTool(server, description) {
|
|
|
465
577
|
const ids = job_ids.join(",");
|
|
466
578
|
const data = (await apiCall("GET", `/v1/jobs/compare?ids=${ids}`));
|
|
467
579
|
const comparisons = (data.comparisons ?? []);
|
|
468
|
-
|
|
469
|
-
"| Job ID | Grid | Epochs | Model | QE | TE | Expl.Var | Silhouette |",
|
|
470
|
-
"|--------|------|--------|-------|----|----|----------|------------|",
|
|
471
|
-
];
|
|
472
|
-
for (const c of comparisons) {
|
|
473
|
-
if (c.error) {
|
|
474
|
-
lines.push(`| ${c.job_id.slice(0, 8)}... | — | — | — | ${c.error} | — | — | — |`);
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
const g = c.grid;
|
|
478
|
-
const ep = c.epochs;
|
|
479
|
-
const fmt = (v) => v !== null && v !== undefined ? Number(v).toFixed(4) : "—";
|
|
480
|
-
lines.push(`| ${c.job_id.slice(0, 8)}... | ${g ? `${g[0]}×${g[1]}` : "—"} | ${ep ? `${ep[0]}+${ep[1]}` : "—"} | ${c.model ?? "—"} | ${fmt(c.quantization_error)} | ${fmt(c.topographic_error)} | ${fmt(c.explained_variance)} | ${fmt(c.silhouette)} |`);
|
|
481
|
-
}
|
|
482
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
580
|
+
return { content: [{ type: "text", text: renderCompareTable(comparisons) }] };
|
|
483
581
|
}
|
|
484
582
|
if (action === "cancel") {
|
|
485
583
|
if (!job_id)
|
package/dist/tools/results.js
CHANGED
|
@@ -17,7 +17,7 @@ ONLY call this after jobs(action=status) returns "completed".
|
|
|
17
17
|
ESCALATION: If job not found, verify job_id. If "job not complete", poll with jobs(action=status).
|
|
18
18
|
|
|
19
19
|
action=get: Returns text summary with quality metrics and inline images.
|
|
20
|
-
- figures: omit = combined only. "all" = all plots. Array = specific logical names (combined, umatrix, hit_histogram, learning_curve, correlation, component_1..N).
|
|
20
|
+
- figures: omit = combined only. "all" = all plots. "none" = metrics text only, no images (recommended for sweeps and LLM clients to keep tool payloads small). Array = specific logical names (combined, umatrix, hit_histogram, learning_curve, correlation, component_1..N).
|
|
21
21
|
- include_individual: if true (and figures omitted), inlines every component plane.
|
|
22
22
|
- After recolor or project, use the job_id returned by that operation to get the new artifacts, not the original training job_id.
|
|
23
23
|
- After showing results, guide the user: QE interpretation, whether to retrain, which features to explore.
|
|
@@ -29,7 +29,7 @@ action=export: Structured data exports. Use export_type= to choose what to expor
|
|
|
29
29
|
- export_type=nodes: per-node hit count + feature stats. Profile clusters and operating modes.
|
|
30
30
|
|
|
31
31
|
action=download: Save figures to disk. Use so user can open, share, or version files locally.
|
|
32
|
-
- folder: e.g. "." or "./results". Interpreted relative to the client's current working directory (or workspace).
|
|
32
|
+
- folder: e.g. "." or "./results". Interpreted relative to the client's current working directory (or workspace). Files are always written into a per-job subfolder (the job label, else the job_id) under this folder, so downloading several jobs into one folder never overwrites the shared filenames every job emits (e.g. summary.json).
|
|
33
33
|
- figures: "all" (default) or array of filenames.
|
|
34
34
|
- include_json: also save summary.json.
|
|
35
35
|
|
|
@@ -48,9 +48,9 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
48
48
|
.describe("get: inline results + metrics; recolor: new colormap/format (async); download: save to disk; export: structured data (training_log/weights/nodes); transition_flow: temporal dynamics (async)"),
|
|
49
49
|
job_id: z.string().describe("Job ID of a completed job"),
|
|
50
50
|
figures: z
|
|
51
|
-
.union([z.enum(["default", "combined_only", "all", "images"]), z.array(z.string())])
|
|
51
|
+
.union([z.enum(["default", "combined_only", "all", "images", "none"]), z.array(z.string())])
|
|
52
52
|
.optional()
|
|
53
|
-
.describe("action=get: omit=combined only; 'all'=all plots; array=specific (combined,umatrix,hit_histogram,learning_curve,correlation,component_1..N). action=download: 'all'=all image files; array=specific filenames."),
|
|
53
|
+
.describe("action=get: omit=combined only; 'all'=all plots; 'none'=metrics text only (no images, ideal for sweeps and LLM clients); array=specific (combined,umatrix,hit_histogram,learning_curve,correlation,component_1..N). action=download: 'all'=all image files; array=specific filenames."),
|
|
54
54
|
include_individual: z
|
|
55
55
|
.boolean()
|
|
56
56
|
.optional()
|
|
@@ -64,7 +64,7 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
64
64
|
folder: z
|
|
65
65
|
.string()
|
|
66
66
|
.optional()
|
|
67
|
-
.describe("action=download: directory path to save files (e.g. '.' or './results'). Relative to the client's current working directory (or workspace)."),
|
|
67
|
+
.describe("action=download: directory path to save files (e.g. '.' or './results'). Relative to the client's current working directory (or workspace). Files land in a per-job subfolder (job label or job_id) under this path."),
|
|
68
68
|
colormap: z
|
|
69
69
|
.string()
|
|
70
70
|
.optional()
|
|
@@ -202,23 +202,75 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
else if (jobType === "enrich_dataset") {
|
|
205
|
+
// Older exports: summary.job_type from stored results (read-only display).
|
|
205
206
|
const files = summary.files ?? [];
|
|
207
|
+
const csvArtifact = files.find((f) => typeof f === "string" && f.endsWith(".csv")) ?? "artifact.csv";
|
|
206
208
|
content.push({ type: "text", text: [
|
|
207
|
-
`
|
|
208
|
-
`Parent map job: ${summary.parent_job_id ?? "N/A"} |
|
|
209
|
+
`Annotated dataset (older export) — ${resultsHeader}`,
|
|
210
|
+
`Parent map job: ${summary.parent_job_id ?? "N/A"} | Rows: ${summary.n_rows ?? summary.n_samples ?? 0}`,
|
|
209
211
|
`Output: ${files.filter((f) => f !== "summary.json").join(", ")}`,
|
|
210
|
-
`Use results(action=download, job_id="${job_id}") to save
|
|
212
|
+
`Use results(action=download, job_id="${job_id}") to save ${csvArtifact}.`,
|
|
211
213
|
].join("\n") });
|
|
212
214
|
}
|
|
213
215
|
else if (jobType === "predict") {
|
|
214
216
|
const files = summary.files ?? [];
|
|
217
|
+
const regime = String(summary.regime ?? "new");
|
|
218
|
+
const outputStyle = String(summary.output_style ?? "compact");
|
|
219
|
+
const isAnnotated = outputStyle === "annotated";
|
|
220
|
+
const headerLabel = isAnnotated ? "Predict (annotated)" : "Predict";
|
|
221
|
+
const trainingCaveat = (regime === "training" && !isAnnotated)
|
|
222
|
+
? "QE columns intentionally omitted: regime=training (fitting error, not generalisation). Score a held-out dataset for quality assessment."
|
|
223
|
+
: "";
|
|
224
|
+
const metricsLine = (regime !== "training" && !isAnnotated)
|
|
225
|
+
? `Mean QE: ${summary.mean_qe !== undefined ? Number(summary.mean_qe).toFixed(4) : "N/A"} | Max QE: ${summary.max_qe !== undefined ? Number(summary.max_qe).toFixed(4) : "N/A"} | qe_p95: ${summary.qe_p95 !== undefined ? Number(summary.qe_p95).toFixed(4) : "N/A"}`
|
|
226
|
+
: "";
|
|
227
|
+
const downloadName = isAnnotated ? "annotated.csv" : "predictions.csv";
|
|
215
228
|
content.push({ type: "text", text: [
|
|
216
|
-
|
|
217
|
-
`Parent map job: ${summary.parent_job_id ?? "N/A"} |
|
|
229
|
+
`${headerLabel} — ${resultsHeader}`,
|
|
230
|
+
`Parent map job: ${summary.parent_job_id ?? "N/A"} | Regime: ${regime} | Style: ${outputStyle}`,
|
|
231
|
+
`Rows: ${summary.n_rows ?? summary.n_samples ?? 0}`,
|
|
232
|
+
metricsLine,
|
|
218
233
|
`Output: ${files.filter((f) => f !== "summary.json").join(", ")}`,
|
|
219
|
-
`Use results(action=download, job_id="${job_id}") to save
|
|
234
|
+
`Use results(action=download, job_id="${job_id}") to save ${downloadName}.`,
|
|
235
|
+
trainingCaveat,
|
|
236
|
+
].filter(Boolean).join("\n") });
|
|
237
|
+
}
|
|
238
|
+
else if (jobType === "impute_column") {
|
|
239
|
+
const files = summary.files ?? [];
|
|
240
|
+
content.push({ type: "text", text: [
|
|
241
|
+
`Impute column — ${resultsHeader}`,
|
|
242
|
+
`Parent map job: ${summary.parent_job_id ?? "N/A"} | Target: ${summary.target_column ?? "?"} | Rows: ${summary.n_rows ?? "?"}`,
|
|
243
|
+
`Aggregation: ${summary.aggregation ?? "?"} | only_missing: ${summary.only_missing ?? "?"} | imputed: ${summary.n_imputed ?? "?"} | insufficient: ${summary.n_insufficient ?? "?"}`,
|
|
244
|
+
`Mean patch nodes: ${summary.mean_patch_nodes !== undefined ? Number(summary.mean_patch_nodes).toFixed(2) : "N/A"}`,
|
|
245
|
+
`Output: ${files.filter((f) => f !== "summary.json").join(", ")}`,
|
|
246
|
+
`Use results(action=download, job_id="${job_id}") to save imputed.csv.`,
|
|
247
|
+
"Map-local pool estimates — not held-out validated predictions.",
|
|
220
248
|
].join("\n") });
|
|
221
249
|
}
|
|
250
|
+
else if (jobType === "reduce_spectral") {
|
|
251
|
+
const method = String(summary.method ?? "?");
|
|
252
|
+
const sourceCols = summary.source_columns ?? [];
|
|
253
|
+
const outCols = summary.output_columns ?? [];
|
|
254
|
+
const lines = [
|
|
255
|
+
`Reduce Spectral — ${resultsHeader}`,
|
|
256
|
+
`Method: ${method} | Source columns: ${sourceCols.length} | Output columns: ${outCols.length}`,
|
|
257
|
+
`Source: ${sourceCols.length <= 10 ? sourceCols.join(", ") : sourceCols.slice(0, 5).join(", ") + ", … " + sourceCols.slice(-3).join(", ")}`,
|
|
258
|
+
`Output: ${outCols.join(", ")}`,
|
|
259
|
+
];
|
|
260
|
+
if (method === "pca") {
|
|
261
|
+
const ev = summary.explained_variance_ratio;
|
|
262
|
+
const evTotal = summary.explained_variance_total;
|
|
263
|
+
if (ev)
|
|
264
|
+
lines.push(`Explained variance per component: [${ev.map((v) => v.toFixed(3)).join(", ")}] | total: ${evTotal !== undefined ? evTotal.toFixed(3) : "N/A"}`);
|
|
265
|
+
}
|
|
266
|
+
else if (method === "log_sample" || method === "uniform_sample") {
|
|
267
|
+
const idxs = summary.selected_indices;
|
|
268
|
+
if (idxs)
|
|
269
|
+
lines.push(`Selected indices: [${idxs.join(", ")}]`);
|
|
270
|
+
}
|
|
271
|
+
lines.push(`The new columns are appended to the source dataset and ready to use in jobs(action=train_map, columns=[..., "${outCols[0] ?? "feat_1"}", …]).`);
|
|
272
|
+
content.push({ type: "text", text: lines.join("\n") });
|
|
273
|
+
}
|
|
222
274
|
else if (jobType === "train_floop_siom") {
|
|
223
275
|
const siom = summary.siom ?? {};
|
|
224
276
|
const coverage = summary.coverage_assessment ?? {};
|
|
@@ -357,24 +409,29 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
357
409
|
const jobType2 = summary.job_type ?? "train_som";
|
|
358
410
|
const isImage = (f) => f.endsWith(".png") || f.endsWith(".svg") || f.endsWith(".pdf");
|
|
359
411
|
const hasExportableJson = files.some((f) => f.endsWith(".json") && f !== "summary.json");
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
412
|
+
// figures="none": metrics-only mode. Skip any further image attaches and the
|
|
413
|
+
// "Available figures…" hint; keep the structured-data export hint since it
|
|
414
|
+
// points at non-image artifacts.
|
|
415
|
+
if (figures !== "none") {
|
|
416
|
+
for (const fname of files) {
|
|
417
|
+
if (isImage(fname) && !inlinedImages.has(fname)) {
|
|
418
|
+
const cap = getCaptionForImage(fname);
|
|
419
|
+
if (cap)
|
|
420
|
+
content.push({ type: "text", text: cap });
|
|
421
|
+
await tryAttachImage(content, job_id, fname);
|
|
422
|
+
}
|
|
366
423
|
}
|
|
367
424
|
}
|
|
368
425
|
if (hasExportableJson && (jobType2 === "train_som" || jobType2 === "train_siom" || jobType2 === "render_variant")) {
|
|
369
426
|
content.push({ type: "text", text: `Structured data: results(action=export, export_type=weights|nodes|training_log)` });
|
|
370
427
|
}
|
|
371
428
|
const featuresForLog = summary.features ?? [];
|
|
372
|
-
const showAvailable = files.length > 0 && !(figures === "all" || figures === "images");
|
|
429
|
+
const showAvailable = files.length > 0 && !(figures === "all" || figures === "images" || figures === "none");
|
|
373
430
|
if (showAvailable) {
|
|
374
431
|
const logicalNames = jobType2 === "train_som" || jobType2 === "train_siom" || jobType2 === "render_variant"
|
|
375
432
|
? `Logical names: combined, umatrix, hit_histogram, correlation, ${featuresForLog.map((_, i) => `component_${i + 1}`).join(", ")}. `
|
|
376
433
|
: "";
|
|
377
|
-
content.push({ type: "text", text: `Available: ${files.join(", ")}. ${logicalNames}Use results(action=get, figures=[...]) for specific plots
|
|
434
|
+
content.push({ type: "text", text: `Available: ${files.join(", ")}. ${logicalNames}Use results(action=get, figures=[...]) for specific plots, figures=all for everything, or figures="none" for metrics-only.` });
|
|
378
435
|
}
|
|
379
436
|
return { content };
|
|
380
437
|
}
|
|
@@ -506,7 +563,7 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
506
563
|
const jobLabel = data.label != null && data.label !== "" ? String(data.label) : null;
|
|
507
564
|
const files = summary.files ?? [];
|
|
508
565
|
const jobType = summary.job_type ?? "train_som";
|
|
509
|
-
const needsAllFiles = ["enrich_dataset", "predict", "compare_datasets"].includes(jobType);
|
|
566
|
+
const needsAllFiles = ["enrich_dataset", "predict", "impute_column", "compare_datasets"].includes(jobType);
|
|
510
567
|
const isImage = (f) => f.endsWith(".png") || f.endsWith(".svg") || f.endsWith(".pdf");
|
|
511
568
|
let toDownload;
|
|
512
569
|
if (figures === "all" || figures === "images" || figures === undefined) {
|
|
@@ -521,9 +578,11 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
521
578
|
toDownload = files.filter(isImage);
|
|
522
579
|
}
|
|
523
580
|
let resolvedDir = sandboxPath(folder, await getWorkspaceRootAsync(server));
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
581
|
+
// Always namespace each job's files into its own subfolder so that
|
|
582
|
+
// downloading multiple jobs (or job types) into the same folder never
|
|
583
|
+
// overwrites the shared filenames every job emits (e.g. summary.json).
|
|
584
|
+
const jobSubfolder = (jobLabel ?? job_id).replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
585
|
+
resolvedDir = path.join(resolvedDir, jobSubfolder);
|
|
527
586
|
if (jobType === "render_variant" && summary.colormap) {
|
|
528
587
|
const colormapDir = String(summary.colormap).replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
529
588
|
resolvedDir = path.join(resolvedDir, colormapDir);
|
|
@@ -538,7 +597,8 @@ NOT FOR: Jobs that haven't completed. Use jobs(action=status) to check first.`,
|
|
|
538
597
|
}
|
|
539
598
|
catch { /* skip missing files */ }
|
|
540
599
|
}
|
|
541
|
-
|
|
600
|
+
const savedDir = path.join(folder, jobSubfolder);
|
|
601
|
+
return { content: [{ type: "text", text: saved.length > 0 ? `Saved ${saved.length} file(s) to ${savedDir}: ${saved.join(", ")}` : `No files saved. Check job_id and that the job is completed.` }] };
|
|
542
602
|
}
|
|
543
603
|
if (action === "recolor") {
|
|
544
604
|
if (!colormap)
|