@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 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 | unset | If `1` or `true`, `datasets(upload)` **file_path** and `file://` paths must resolve under the MCP workspace root (same rule as relative paths). Default allows **absolute** paths anywhere on the machine the client can read treat that as **high trust** (same API key can already exfiltrate org data via the API). |
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. It can read files you pass as `file_path` (and, by default, **any** absolute path). Prefer `BARIVIA_WORKSPACE_ROOT` + project-relative paths; use **`BARIVIA_ENFORCE_WORKSPACE_SANDBOX=1`** when you want uploads constrained to the workspace.
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: per-row bmu_x/y, cluster_id, quantization_error, potential_anomaly (QE > 95th pct); summary includes qe_p95 | 5–120s |
127
- | `enrich` | enriched.csv: training data + bmu_x/y/node_index/cluster_id | 5–60s |
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 i}from"./viz-server.js";import{API_KEY as a,apiCall as l,apiRawCall as p,loadViewHtml as c,setVizPort as m,setClientSupportsMcpApps as d}from"./shared.js";import{registerDatasetsTool as u}from"./tools/datasets.js";import{registerJobsTool as f,JOBS_DESCRIPTION_BASE as _}from"./tools/jobs.js";import{registerResultsTool as g}from"./tools/results.js";import{registerExploreMapTool as h,RESULTS_EXPLORER_URI as b}from"./tools/explore_map.js";import{registerAccountTool as y}from"./tools/account.js";import{registerInferenceTool as w}from"./tools/inference.js";import{registerGuideBarsomTool as j}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as v}from"./tools/training_guidance.js";import{registerFeedbackTool as x}from"./tools/feedback.js";import{registerTrainingPrepTools as P,TRAINING_PREP_URI as I}from"./tools/training_prep.js";import{registerTrainingMonitorTool as k,TRAINING_MONITOR_URI as A}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as T}from"./prepare_training_prompt.js";a||(console.error("Error: BARIVIA_API_KEY not set. Set it in your MCP client config."),process.exit(1));const C=new e({name:"analytics-engine",version:"0.7.16",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, 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, export, download, recolor (async), transition_flow (async; time-ordered rows only) |\n| Inference | `inference` | predict, enrich, 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- 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(C,b,b,{mimeType:s},async()=>{const e=await c("results-explorer");return{contents:[{uri:b,mimeType:s,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),n(C,I,I,{mimeType:s},async()=>{const e=await c("training-prep");return{contents:[{uri:I,mimeType:s,text:e??"<html><body>Training Preparation view not built yet.</body></html>"}]}}),n(C,A,A,{mimeType:s},async()=>{const e=await c("training-monitor");return{contents:[{uri:A,mimeType:s,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),j(C),h(C),P(C),k(C),u(C),f(C,_),g(C),y(C),w(C),v(C),x(C),C.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), `jobs` (train/poll/compare/…), `results` (get/download/export/recolor/transition_flow), `inference` (predict/enrich/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")}}]})),C.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 T(e)}}]}));const M=new t;(async function(){try{const e=await i(l,p,c);m(e)}catch(e){process.env.BARIVIA_VIZ_PORT&&console.error("Barivia viz server failed to start:",e)}const e=C.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(s))},await C.connect(M)})().catch(console.error);
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
- return v === "1" || v.toLowerCase() === "true";
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
- serializedBody =
262
- contentType === "application/json" ? JSON.stringify(body) : String(body);
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;
@@ -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
- }, async ({ action, name, file_path, csv_data, dataset_id, n_rows, row_range, filters, filter, expression, options }) => {
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
- const data = (await apiCall("POST", "/v1/datasets", body, {
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
- }, UPLOAD_DATASET_TIMEOUT_MS));
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(`Columns show periodic behavior at lags ${lags.join(", ")}. Consider cyclic_features or temporal_features with matching periods.`);
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");
@@ -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 new data, enrich the training CSV, compare datasets, or generate a PDF report.
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 new/unseen observations against the trained map | 5–120s |
9
- | enrich | Appending BMU coordinates + cluster_id to the original training CSV | 5–60s |
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, enrich, and compare are all 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.
15
- Artifacts: When complete, use results(action=download, job_id=<returned_job_id>) to get: predict → predictions.csv; enrichenriched.csv; compare → density-diff figure (e.g. density_diff.png).
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: Returns predictions.csv with row_id, bmu_x, bmu_y, bmu_node_index, cluster_id, quantization_error, potential_anomaly (true when QE > 95th percentile; summary includes qe_p95 for the threshold). Use potential_anomaly for downstream anomaly filtering.
21
- Dataset/batch rows must use the same schema as training (including cyclic-expanded columns, e.g. key_cos, key_sin not raw key). For a single inline row only, the proxy can use the stateless model endpoint instead: raw categorical strings are allowed for baseline categorical_features models, and raw cyclic inputs are expanded from training config. High QE = row is far from its nearest prototype = potential anomaly. Accepts dataset_id OR inline rows (≤500).
22
- action=enrich: Uses the job's training dataset; do not pass dataset_id. Returns enriched.csv original training CSV + bmu_x, bmu_y, bmu_node_index, cluster_id. When complete, get enriched.csv via results(action=download, job_id=<returned_enrich_job_id>). Filename: enriched.csv.
23
- action=compare: dataset_id must refer to a dataset with the same feature set as the training data (same column names and preprocessing, including cyclic expansion). A = training dataset (the map was trained on it). B = cohort to compare (the dataset_id you pass). Density-diff: positive = B gained vs A; negative = A had more. Using the same dataset as A and B yields a flat diff. Returns density-diff heatmap (e.g. density_diff.png).
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. Use results(action=get) and this manifest to fetch figures/data and render your own PDF (e.g. Quarto, Jupyter). See docs: BUILD_YOUR_OWN_REPORT.md.`, {
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", "enrich", "compare", "project_columns", "report"])
28
- .describe("predict: score new data; enrich: annotate training CSV with BMU coords; compare: drift/cohort diff heatmap; project_columns: project dataset columns onto map; report: manifest of primitives for custom report"),
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=input data to score; compare=dataset B; project_columns=dataset with columns to project."),
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
- }, async ({ action, job_id, dataset_id, columns, rows, colormap, output_format, output_dpi, top_n }) => {
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
- if (!dataset_id && !rows)
42
- throw new Error("inference(predict) requires dataset_id or rows");
43
- if (!dataset_id && rows && rows.length === 1) {
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
- `Predictions complete — job: ${predictJobId}`,
152
+ headerLine,
153
+ `Regime: ${regime}${regime === "training" ? " (scored against the training set)" : ""} | Style: ${effectiveStyle}`,
79
154
  `Rows scored: ${summary.n_rows ?? "?"}`,
80
- `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"}`,
81
- `Clusters: ${Object.keys(summary.cluster_counts ?? {}).length}`,
82
- `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.`,
83
- urls["predictions.csv"] ? `Download: ${urls["predictions.csv"]}` : "",
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 === "enrich") {
91
- const data = (await apiCall("POST", `/v1/results/${job_id}/enrich_dataset`, {}));
92
- const enrichJobId = data.id;
93
- const poll = await pollUntilComplete(enrichJobId, 60_000);
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/${enrichJobId}`));
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
- `Enriched dataset ready — job: ${enrichJobId}`,
100
- `Rows: ${summary.n_rows ?? "?"} | Clusters: ${summary.n_clusters ?? "?"}`,
101
- `Appended: bmu_x, bmu_y, bmu_node_index, cluster_id`,
102
- urls["enriched.csv"] ? `Download: ${urls["enriched.csv"]}` : "",
103
- ].filter(Boolean).join("\n") }] };
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(enrich) job ${enrichJobId} failed: ${poll.error ?? "unknown error"}` }] };
107
- return { content: [{ type: "text", text: `inference(enrich) job ${enrichJobId} submitted. Poll with jobs(action=status, job_id="${enrichJobId}").` }] };
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)
@@ -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, enrich, report, recolor, project, transition_flow, compare, predict) to filter or display. |
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
- grid_y: z.number().int().optional(),
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 data = (await apiCall("POST", "/v1/jobs", { dataset_id, params }));
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 data = (await apiCall("POST", "/v1/jobs", { dataset_id, params }));
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
- const lines = [
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)
@@ -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). If job has a label, a named subfolder may be created.
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
- `Enrich Dataset — ${resultsHeader}`,
208
- `Parent map job: ${summary.parent_job_id ?? "N/A"} | Samples: ${summary.n_samples ?? 0}`,
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 enriched.csv.`,
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
- `Predict — ${resultsHeader}`,
217
- `Parent map job: ${summary.parent_job_id ?? "N/A"} | Samples: ${summary.n_samples ?? 0}`,
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 predictions.csv.`,
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
- for (const fname of files) {
361
- if (isImage(fname) && !inlinedImages.has(fname)) {
362
- const cap = getCaptionForImage(fname);
363
- if (cap)
364
- content.push({ type: "text", text: cap });
365
- await tryAttachImage(content, job_id, fname);
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 or results(action=get, figures=all) for all 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
- if (jobLabel && (folder === "." || folder === "./results" || folder === "results")) {
525
- resolvedDir = path.join(resolvedDir, jobLabel);
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
- return { content: [{ type: "text", text: saved.length > 0 ? `Saved ${saved.length} file(s) to ${folder}: ${saved.join(", ")}` : `No files saved. Check job_id and that the job is completed.` }] };
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barivia/barsom-mcp",
3
- "version": "0.7.16",
3
+ "version": "0.9.0",
4
4
  "description": "barSOM MCP proxy — connect any MCP client to the barSOM cloud API for Self-Organizing Map analytics",
5
5
  "keywords": [
6
6
  "mcp",