@barivia/barsom-mcp 0.13.0 → 0.15.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
@@ -1,6 +1,6 @@
1
1
  # @barivia/barsom-mcp
2
2
 
3
- MCP proxy for the Barivia Analytics Engine — connects any stdio MCP client (Cursor, Claude Desktop, etc.) to the barSOM cloud API. The npm package is **`@barivia/barsom-mcp`**; many configs label the server **`analytics-engine`** (the MCP server name in the client JSON). **`guide_barsom_workflow`** is the canonical bootstrap: it loads plan-scoped workflow text from the Barivia API when online (tool map, async rules, training modes, SOP). If the API is unreachable, it returns a short offline stub. Core tools are `datasets`, `jobs` (`train_map`, `train_siom_map`, `train_floop_siom` where entitled), `results`, and `inference`. **Optional MCP App tools** (`training_prep`, `training_monitor`, `results_explorer`) add embedded or localhost viz; they are **not required** to complete upload → train → poll → results.
3
+ MCP proxy for the Barivia Analytics Engine — connects any stdio MCP client (Cursor, Claude Desktop, etc.) to the barSOM cloud API. The npm package is **`@barivia/barsom-mcp`**; many configs label the server **`analytics-engine`** (the MCP server name in the client JSON). **`guide_barsom_workflow`** is the canonical bootstrap: it loads plan-scoped workflow text from the Barivia API when online (tool map, async rules, training modes, SOP). If the API is unreachable, it returns a short offline stub. Core tools are `datasets`, `train` (`map`, `siom_map`, `impute`, `floop_siom` where entitled, `baseline_study`), `jobs` (status/list/compare/cancel/delete), `results`, and `inference`. **Optional MCP App tools** (`training_prep`, `training_monitor`, `results_explorer`) add embedded or localhost viz; they are **not required** to complete upload → train → poll → results.
4
4
 
5
5
  **Pre-training help (pick one):** **`prepare_training`** prompt = narrative checklist (tier-scoped from the API when online); **`training_guidance`** tool = structured presets and parameter hints; **`training_prep`** tool = interactive UI plus **`submit_prepared_training`**.
6
6
 
@@ -86,7 +86,7 @@ Call at the **start of mapping work** (or when the user asks what the MCP can do
86
86
 
87
87
  | Action | Use when |
88
88
  |--------|----------|
89
- | `upload` | Adding a new CSV — returns dataset_id |
89
+ | `upload` | Adding a new CSV — returns dataset_id. Accepts `.csv`/`.tsv` **and pre-gzipped `.csv.gz`/`.tsv.gz`** (compress big tables locally to transfer ~3× less). Large files stream directly to object storage; multi-GB uploads are supported. |
90
90
  | `preview` | Before training — inspect columns, stats, cyclic/datetime hints |
91
91
  | `analyze` | Pre-training recommendations (correlation, columns to consider dropping, etc.) |
92
92
  | `list` | Finding dataset IDs |
@@ -95,21 +95,29 @@ Call at the **start of mapping work** (or when the user asks what the MCP can do
95
95
  | `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. |
96
96
  | `delete` | Removing a dataset |
97
97
 
98
- ### `jobs(action)`
98
+ ### `train(action)`
99
+
100
+ All training is submitted via the `train` tool; every action is async and returns a `job_id` (poll with `jobs(action=status, job_id=...)`).
101
+
102
+ | Action | Use when |
103
+ |--------|----------|
104
+ | `map` | New map training job — full control: model type, grid, epochs, cyclic/temporal features, transforms. Complete-case data only (no NaNs in training columns). |
105
+ | `impute` | Sparse training data: accelerated missSOM / SIOM missSOM — trains a map and imputes missing cells in one job. Defaults: `model=auto`, `cv_folds=5`. Returns map artifacts + `imputed.csv`, `imputation_mask.csv`, optional `quality.csv` / `imputation_uncertainty.csv`. `quality.csv` reports two labeled numbers per column: `kfold_holdout_pool` (honest held-out) and `resubstitution_optimistic` (in-sample, optimistic — not validation). Plain numeric columns only. Valid parent for `inference(impute_column)`. |
106
+ | `siom_map` | Self-interacting map — same grid-map workflow plus SIOM controls such as `gamma`, `siom_decay`, and penalty selection. |
107
+ | `floop_siom` | FLooP-SIOM — growing node-budget manifold; default `topology=free` (CHL); optional `topology=chain` for strict 1D linked list. Requires Premium/Enterprise (`all_algorithms`). |
108
+ | `baseline_study` | Auto-configured baseline SOM for a dataset (heuristic grid + MAD normalization). |
109
+
110
+ ### `jobs(action)` — lifecycle
99
111
 
100
112
  | Action | Use when |
101
113
  |--------|----------|
102
- | `train_map` | Submitting a new map training job — full control: model type, grid, epochs, cyclic/temporal features, transforms. Returns `job_id`; poll with `jobs(action=status, job_id=...)`. Complete-case data only (no NaNs in training columns). |
103
- | `train_impute` | Sparse training data: accelerated missSOM / SIOM missSOM — trains a map and imputes missing cells in one job. Defaults: `model=auto`, `cv_folds=5`. Returns map artifacts + `imputed.csv`, `imputation_mask.csv`, optional `quality.csv` / `imputation_uncertainty.csv`. `quality.csv` reports two labeled numbers per column: `kfold_holdout_pool` (honest held-out) and `resubstitution_optimistic` (in-sample, optimistic — not validation). Plain numeric columns only. Valid parent for `inference(impute_column)`. |
104
- | `train_siom_map` | Submitting a self-interacting map training job — same grid-map workflow plus SIOM controls such as `gamma`, `siom_decay`, and penalty selection. |
105
- | `train_floop_siom` | Submitting a FLooP-SIOM job — growing node-budget manifold; default `topology=free` (CHL); optional `topology=chain` for strict 1D linked list. |
106
114
  | `status` | Polling after any async job — every 10–15s; map jobs expose `progress_phase` (ordering, convergence, cv, artifacts) |
107
115
  | `list` | Finding job IDs, checking pipeline state |
108
116
  | `compare` | Picking the best run from a set (QE, TE, silhouette table) |
109
117
  | `cancel` | Stopping a running job |
110
118
  | `delete` | Permanently removing a job + its S3 files |
111
- | `batch_predict` | Submit multiple predict jobs from one parent training job |
112
- | `run_baseline_study` | Auto-configured baseline SOM for a dataset |
119
+
120
+ `jobs(action=train_map | train_siom_map | train_impute | train_floop_siom | run_baseline_study | batch_predict)` still work as **deprecated** forwarders to `train(...)` / `inference(batch_predict)` for one release (each prints a deprecation note). Migrate to `train` / `inference`.
113
121
 
114
122
  ### `results(action)`
115
123
 
@@ -129,7 +137,8 @@ All actions use a frozen trained map — no retraining. Derived columns use **`d
129
137
  | Action | Output | Timing |
130
138
  |--------|--------|--------|
131
139
  | `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 |
132
- | `impute_column` | Fill a numeric **target_column** not used in training: **requires** `dataset_id` + `target_column`. Parent job: completed **train_map** or **train_impute**. 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`**. For holes across many training columns, prefer **jobs(train_impute)** first. | 5–120s |
140
+ | `batch_predict` | Fan out many predict jobs at once against one parent training `job_id`: pass `inputs` (array of `{ dataset_id? , rows? }`). Returns the list of submitted job_ids (does not auto-poll poll each with `jobs(action=status)`). | submits N jobs |
141
+ | `impute_column` | Fill a numeric **target_column** not used in training: **requires** `dataset_id` + `target_column`. Parent job: completed **train(action=map)** or **train(action=impute)**. 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`**. For holes across many training columns, prefer **train(action=impute)** first. | 5–120s |
133
142
  | `compare` | density-diff heatmap + top gained/lost nodes — drift, A/B, cohort | 30–120s |
134
143
  | `project_columns` | Project one or more dataset columns onto the trained map (component planes) | async |
135
144
  | `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 |
@@ -169,8 +178,9 @@ The right viewer depends on **(MCP App support)** **and** **(can the human reach
169
178
 
170
179
  ### Migration notes
171
180
 
181
+ - **Training moved to the `train` tool (0.14.0):** submit training via `train(action=map | siom_map | impute | floop_siom | baseline_study)`; `jobs` now covers lifecycle only (`status`/`list`/`compare`/`cancel`/`delete`). `batch_predict` moved to `inference(action=batch_predict)`. The old `jobs(action=train_map | train_siom_map | train_impute | train_floop_siom | run_baseline_study | batch_predict)` actions still work as **deprecated forwarders** for one release (each prints a deprecation note); they will be removed later. Update Cursor / Claude Desktop configs and prompts to the new tools.
172
182
  - **`explore_map` removed (0.13.0):** the deprecated alias of `results_explorer` is gone. Update any Cursor / Claude Desktop / MCP config or call site to use **`results_explorer`**.
173
- - **`train_floop_chain` removed (0.13.0):** the deprecated alias of `jobs(action=train_floop_siom)` is gone. Use **`jobs(action=train_floop_siom)`**.
183
+ - **`train_floop_chain` removed (0.13.0):** the deprecated alias of `jobs(action=train_floop_siom)` is gone. Use **`train(action=floop_siom)`**.
174
184
  - **Shorter `info` prompt:** Clients that relied on the old long `info` text should use **`guide_barsom_workflow`** or server **instructions** for the full story.
175
185
 
176
186
  ### `send_feedback`
@@ -187,7 +197,7 @@ When adding or refining tools, follow [MCP best practices](https://modelcontextp
187
197
 
188
198
  ## Data preparation
189
199
 
190
- To train on a subset of your data (e.g. first 2000 rows, or rows where region=Europe) without re-uploading: use **datasets(action=subset)** with `row_range` and/or `filter` to create a new dataset, then train with **jobs(action=train_map, dataset_id=...)**, **jobs(action=train_siom_map, dataset_id=...)**, or **jobs(action=train_floop_siom, dataset_id=...)** on the new dataset_id; or pass **row_range** in the training job params for a one-off slice.
200
+ To train on a subset of your data (e.g. first 2000 rows, or rows where region=Europe) without re-uploading: use **datasets(action=subset)** with `row_range` and/or `filter` to create a new dataset, then train with **train(action=map, dataset_id=...)**, **train(action=siom_map, dataset_id=...)**, or **train(action=floop_siom, dataset_id=...)** on the new dataset_id; or pass **row_range** in the training job params for a one-off slice.
191
201
 
192
202
  ## How It Works
193
203
 
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 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 T,TRAINING_MONITOR_URI as M}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as O}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_impute (sparse data: map + dense imputed.csv), train_siom_map, train_floop_siom (entitled), status, list, compare, cancel, delete, batch_predict, run_baseline_study |\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; `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. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\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,M,M,{mimeType:s},async()=>{const e=await c("training-monitor");return{contents:[{uri:M,mimeType:s,text:e??"<html><body>Training Monitor view not built yet.</body></html>"}]}}),v(A),h(A),I(A),T(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 O(e)}}]}));const S=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(S)})().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{registerTrainTool as g}from"./tools/train.js";import{registerJobsTool as _,JOBS_DESCRIPTION_BASE as b}from"./tools/jobs.js";import{registerResultsTool as y}from"./tools/results.js";import{registerExploreMapTool as h,RESULTS_EXPLORER_URI as w}from"./tools/explore_map.js";import{registerAccountTool as j}from"./tools/account.js";import{registerInferenceTool as v}from"./tools/inference.js";import{registerGuideBarsomTool as P}from"./tools/guide_barsom.js";import{registerTrainingGuidanceTool as x}from"./tools/training_guidance.js";import{registerFeedbackTool as I}from"./tools/feedback.js";import{registerTrainingPrepTools as k,TRAINING_PREP_URI as T}from"./tools/training_prep.js";import{registerTrainingMonitorTool as M,TRAINING_MONITOR_URI as O}from"./tools/training_monitor.js";import{resolvePrepareTrainingPromptText as A}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 S=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 `train(action=map)`, `train(action=siom_map)`, or `train(action=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| Train | `train` | map, siom_map, impute (sparse data: map + dense imputed.csv), floop_siom (entitled), baseline_study — all async, returns job_id |\n| Jobs | `jobs` | status, list, compare, cancel, delete (lifecycle). jobs(train_*/run_baseline_study/batch_predict) still work as deprecated forwarders → use train / inference |\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"), batch_predict (fan out many predict jobs), 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; `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. Per-request HTTP timeout defaults to 60s (`datasets(analyze)` is async + auto-polled, so it is not bound by it); on slow networks raise `BARIVIA_FETCH_TIMEOUT_MS`. Timeouts are NOT auto-retried (avoids duplicating slow/expensive work).\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(S,w,w,{mimeType:s},async()=>{const e=await c("results-explorer");return{contents:[{uri:w,mimeType:s,text:e??"<html><body>Results Explorer view not built yet. Run: npm run build:views</body></html>"}]}}),n(S,T,T,{mimeType:s},async()=>{const e=await c("training-prep");return{contents:[{uri:T,mimeType:s,text:e??"<html><body>Training Preparation view not built yet.</body></html>"}]}}),n(S,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>"}]}}),P(S),h(S),k(S),M(S),f(S),g(S),_(S,b),y(S),j(S),v(S),x(S),I(S),S.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), `train` (map/siom_map/impute/floop_siom/baseline_study; pass `label` for readable compare rows), `jobs` (status/list/compare/cancel/delete), `results` (get/download/export/recolor/transition_flow; figures="none" for metrics-only), `inference` (predict; batch_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")}}]})),S.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 A(e)}}]}));const C=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=S.server;e.oninitialized=()=>{const t=e.getClientCapabilities(),o=r(t);d(!!o?.mimeTypes?.includes(s))},await S.connect(C)})().catch(console.error);
@@ -1,5 +1,5 @@
1
1
  import { apiCall } from "./shared.js";
2
- const FALLBACK_PREFIX = "Before upload: for jobs(action=train_map) ensure training columns have no NaNs; for jobs(action=train_impute) missing cells in training columns are OK (plain numeric only). Please run datasets(action=preview, dataset_id=\"{id}\") to inspect columns, then datasets(action=analyze, dataset_id=\"{id}\") to see which columns and temporal periods are most informative. Then choose the training path: jobs(action=train_map, dataset_id=\"{id}\", ...) for complete-case data, jobs(action=train_impute, dataset_id=\"{id}\", ...) for sparse matrices, jobs(action=train_siom_map, dataset_id=\"{id}\", ...) for a fixed-grid SIOM, or jobs(action=train_floop_siom, dataset_id=\"{id}\", ...) for FLooP-SIOM (default topology=free / CHL; optional topology=chain).";
2
+ const FALLBACK_PREFIX = "Before upload: for train(action=map) ensure training columns have no NaNs; for train(action=impute) missing cells in training columns are OK (plain numeric only). Please run datasets(action=preview, dataset_id=\"{id}\") to inspect columns, then datasets(action=analyze, dataset_id=\"{id}\") to see which columns and temporal periods are most informative. Then choose the training path: train(action=map, dataset_id=\"{id}\", ...) for complete-case data, train(action=impute, dataset_id=\"{id}\", ...) for sparse matrices, train(action=siom_map, dataset_id=\"{id}\", ...) for a fixed-grid SIOM, or train(action=floop_siom, dataset_id=\"{id}\", ...) for FLooP-SIOM (default topology=free / CHL; optional topology=chain).";
3
3
  /** Used by the `prepare_training` MCP prompt; prefers tier-scoped text from the API when online. */
4
4
  export async function resolvePrepareTrainingPromptText(datasetId) {
5
5
  let promptText = FALLBACK_PREFIX.replaceAll("{id}", datasetId);
package/dist/shared.js CHANGED
@@ -2,10 +2,12 @@
2
2
  * Shared configuration, helpers, types, and state for the MCP proxy.
3
3
  */
4
4
  import fs from "node:fs/promises";
5
- import { createReadStream } from "node:fs";
5
+ import { createReadStream, createWriteStream } from "node:fs";
6
6
  import { createGzip } from "node:zlib";
7
- import { createHash } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
8
  import { Readable } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+ import os from "node:os";
9
11
  import path from "node:path";
10
12
  import { fileURLToPath } from "node:url";
11
13
  import { logInfo } from "./logger.js";
@@ -24,7 +26,7 @@ export const RETRYABLE_STATUS = new Set([502, 503, 504]);
24
26
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
25
27
  * wrapper version each action requires. Keep in sync with package.json on bump.
26
28
  */
27
- export const CLIENT_VERSION = "0.13.0";
29
+ export const CLIENT_VERSION = "0.15.0";
28
30
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
29
31
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
30
32
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -80,31 +82,64 @@ export async function streamFileSha256(srcPath) {
80
82
  s.on("error", reject);
81
83
  });
82
84
  }
85
+ /** Turn a raw S3/R2 presigned-PUT error into a clean, actionable message. */
86
+ function presignedPutError(status, bodyText) {
87
+ const snippet = bodyText.slice(0, 200);
88
+ if (status === 411 || /MissingContentLength/i.test(bodyText)) {
89
+ // Should no longer happen now that we always send Content-Length, but keep a
90
+ // clear message instead of leaking raw S3 XML if a CDN strips the header.
91
+ return new Error("Upload rejected by storage: the Content-Length header was missing. " +
92
+ "This is a client bug — please update @barivia/barsom-mcp to the latest version.");
93
+ }
94
+ if (status === 413 || /EntityTooLarge|entity is too large/i.test(bodyText)) {
95
+ return new Error("Upload rejected by storage: file exceeds the maximum upload size.");
96
+ }
97
+ if (status === 403) {
98
+ return new Error("Upload rejected by storage: the presigned URL expired or was invalid; retry datasets(action=upload).");
99
+ }
100
+ return new Error(`Presigned upload failed: HTTP ${status} ${snippet}`);
101
+ }
83
102
  /**
84
- * Stream a local file through gzip directly to a presigned PUT URL (e.g. R2),
85
- * never materializing the file in memory. Bypasses the API/Cloudflare body cap.
103
+ * Stream a local file directly to a presigned PUT URL (e.g. R2), gzip-compressing
104
+ * on the way unless the source is already gzipped. Never materializes the payload
105
+ * in process memory: when compression is needed we gzip to a temp file first, then
106
+ * PUT that file with an explicit Content-Length.
107
+ *
108
+ * R2/S3 presigned PUT requires Content-Length and rejects chunked transfer-encoding
109
+ * with HTTP 411 (MissingContentLength). Node's fetch sends a streaming body as
110
+ * chunked unless an explicit Content-Length is set, and gzip output length is not
111
+ * known until compression finishes — hence the gzip-to-temp-file step.
86
112
  */
87
- export async function putPresignedStream(url, srcPath, contentType, timeoutMs = PRESIGNED_PUT_TIMEOUT_MS) {
88
- const gz = createReadStream(srcPath).pipe(createGzip());
89
- const webStream = Readable.toWeb(gz);
113
+ export async function putPresignedStream(url, srcPath, contentType, timeoutMs = PRESIGNED_PUT_TIMEOUT_MS, alreadyGzipped = false) {
114
+ let putPath = srcPath;
115
+ let tempPath;
116
+ if (!alreadyGzipped) {
117
+ tempPath = path.join(os.tmpdir(), `barsom-upload-${randomUUID()}.csv.gz`);
118
+ await pipeline(createReadStream(srcPath), createGzip(), createWriteStream(tempPath));
119
+ putPath = tempPath;
120
+ }
90
121
  const controller = new AbortController();
91
122
  const timer = setTimeout(() => controller.abort(), timeoutMs);
92
123
  try {
124
+ const contentLength = (await fs.stat(putPath)).size;
125
+ const webStream = Readable.toWeb(createReadStream(putPath));
93
126
  const resp = await fetch(url, {
94
127
  method: "PUT",
95
128
  body: webStream,
96
- headers: { "Content-Type": contentType },
129
+ headers: { "Content-Type": contentType, "Content-Length": String(contentLength) },
97
130
  // Required by Node's fetch to send a streaming request body.
98
131
  duplex: "half",
99
132
  signal: controller.signal,
100
133
  });
101
134
  if (!resp.ok) {
102
135
  const t = await resp.text().catch(() => "");
103
- throw new Error(`Presigned upload failed: HTTP ${resp.status} ${t.slice(0, 200)}`);
136
+ throw presignedPutError(resp.status, t);
104
137
  }
105
138
  }
106
139
  finally {
107
140
  clearTimeout(timer);
141
+ if (tempPath)
142
+ await fs.rm(tempPath, { force: true }).catch(() => { });
108
143
  }
109
144
  }
110
145
  export async function fetchWithTimeout(url, init, timeoutMs = FETCH_TIMEOUT_MS) {
@@ -16,7 +16,7 @@ export function registerAccountTool(server) {
16
16
  action=status: Returns plan tier, compute class (CPU/GPU), usage limits, live queue state, training time estimates, and credit balance.
17
17
  Use BEFORE large jobs to check GPU availability and estimate wait time.
18
18
  action=request_compute: Provisions an ephemeral cloud burst EC2 instance. Data is in shared R2 — no re-upload needed. Wait ~3 min after requesting before submitting jobs.
19
- NOT FOR: Training itself — use jobs(action=train_map). This tool only manages the account and compute lease.`, {
19
+ NOT FOR: Training itself — use train(action=map). This tool only manages the account and compute lease.`, {
20
20
  action: z
21
21
  .enum(["status", "request_compute", "compute_status", "release_compute", "history", "add_funds"])
22
22
  .describe("status: plan/license/queue info; request_compute: provision burst; compute_status: check active lease; release_compute: stop lease; history: recent compute usage; add_funds: instructions"),
@@ -45,7 +45,7 @@ function formatAnalyzeResult(data, dataset_id) {
45
45
  ``,
46
46
  ...(periodicityCaveat ? [`Note: ${periodicityCaveat}`, ``] : []),
47
47
  `Column recommendations:`,
48
- ` Train (use in jobs(action=train_map)): ${trainList.length ? trainList.join(", ") : "—"}`,
48
+ ` Train (use in train(action=map)): ${trainList.length ? trainList.join(", ") : "—"}`,
49
49
  ` Consider dropping (highly correlated with another): ${considerDropping.length ? considerDropping.join(", ") : "—"}`,
50
50
  ` Project later (after training, use inference(action=project_columns, job_id=<training_job_id>, dataset_id=<dataset_id>, columns=[...]) onto map): ${projectLater.length ? projectLater.join(", ") : "—"}`,
51
51
  ` Low variance (near-constant): ${lowVariance.length ? lowVariance.join(", ") : "—"}`,
@@ -88,7 +88,7 @@ function formatAnalyzeResult(data, dataset_id) {
88
88
  nextSteps.push(`Columns show periodic behavior at lags ${lags.join(", ")} (assuming row order is meaningful). Consider cyclic_features or temporal_features with matching periods.`);
89
89
  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.`);
90
90
  }
91
- nextSteps.push(`Then call jobs(action=train_map, dataset_id=${dataset_id}, columns=[...], ...).`);
91
+ nextSteps.push(`Then call train(action=map, dataset_id=${dataset_id}, columns=[...], ...).`);
92
92
  lines.push(`Next steps:`, ...nextSteps.map((s) => ` ${s}`));
93
93
  return lines.join("\n");
94
94
  }
@@ -98,7 +98,7 @@ export function registerDatasetsTool(server) {
98
98
  | Action | Use when |
99
99
  |--------|----------|
100
100
  | upload | You have a CSV file to add — do this first |
101
- | preview | Before jobs(action=train_map) — always preview an unfamiliar dataset to spot cyclics, nulls, column types |
101
+ | preview | Before train(action=map) — always preview an unfamiliar dataset to spot cyclics, nulls, column types |
102
102
  | analyze | Pre-training column analysis: correlation matrix, periodicity ranking, column recommendations (train / drop / project later). Run after preview, before train_map. |
103
103
  | list | Finding dataset IDs for train_map, preview, or subset — see all available datasets |
104
104
  | subset | Creating a filtered/sliced view without re-uploading the full CSV |
@@ -106,12 +106,12 @@ export function registerDatasetsTool(server) {
106
106
  | 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. |
107
107
  | delete | Cleaning up after experiments or freeing the dataset slot |
108
108
 
109
- 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).
109
+ 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 train(action=map).
110
110
 
111
- Step 0 (before upload): (1) CSV with header; one row per observation. (2) Columns you will use for training must be numeric (or datetime if you will use temporal extraction). (3) Complete-case path (jobs action=train_map): no NaNs in training columns. Sparse path (jobs action=train_impute): missing cells in training columns are OK — the job trains missSOM and returns dense imputed.csv; plain numeric columns only. (4) Categoricals: encode (e.g. one-hot, label) or exclude — do not use raw categorical columns as training features; preview shows which columns are non-numeric. (5) Optional: if you plan to use transition_flow, sort rows chronologically before upload.
111
+ Step 0 (before upload): (1) CSV with header; one row per observation. (2) Columns you will use for training must be numeric (or datetime if you will use temporal extraction). (3) Complete-case path (train action=map): no NaNs in training columns. Sparse path (train action=impute): missing cells in training columns are OK — the job trains missSOM and returns dense imputed.csv; plain numeric columns only. (4) Categoricals: encode (e.g. one-hot, label) or exclude — do not use raw categorical columns as training features; preview shows which columns are non-numeric. (5) Optional: if you plan to use transition_flow, sort rows chronologically before upload.
112
112
  Step 1 (after upload): always datasets(action=preview) to verify column types and spot cyclics/datetime, then choose train_map (complete-case) or train_impute (sparse) before submit.
113
113
 
114
- action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before jobs(action=train_map) on an unfamiliar dataset.
114
+ action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before train(action=map) on an unfamiliar dataset.
115
115
  action=analyze: Pre-training analysis on numeric columns — Pearson correlation, autocorrelation periodicity scores, and column recommendations (train, consider_dropping, project_later, low_variance). Use to choose which columns to include in training and which to project onto the map after training.
116
116
  action=list: List all datasets belonging to the organisation with id, name, rows, cols, created_at (use created_at or id to distinguish datasets with the same name).
117
117
  action=subset: Create a new dataset from a subset of an existing one. Requires name and at least one of row_range, filters, or sample_n.
@@ -132,7 +132,7 @@ action=delete: Remove a dataset and all S3 data permanently.
132
132
 
133
133
  BEST FOR: Tabular numeric data. CSV with header required.
134
134
  NOT FOR: Real-time data streams or binary files — upload a snapshot CSV instead.
135
- ESCALATION: If upload fails with column errors, open the file locally and verify the header row. If preview shows nulls in training columns: use jobs(action=train_impute) for sparse data, or clean/subset for jobs(action=train_map).`, {
135
+ ESCALATION: If upload fails with column errors, open the file locally and verify the header row. If preview shows nulls in training columns: use train(action=impute) for sparse data, or clean/subset for train(action=map).`, {
136
136
  action: z
137
137
  .enum(["upload", "preview", "analyze", "list", "subset", "delete", "add_expression", "reduce_spectral"])
138
138
  .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 (or, with project_onto_job, project the expression onto a trained map as a component plane); 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"),
@@ -224,9 +224,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
224
224
  if (file_path) {
225
225
  await apiCall("GET", "/v1/system/info");
226
226
  const resolved = await resolveFilePathForUpload(file_path, server);
227
- const ext = path.extname(resolved).toLowerCase();
228
- if (ext !== ".csv" && ext !== ".tsv") {
229
- throw new Error("Only .csv and .tsv files can be uploaded as datasets.");
227
+ const lower = resolved.toLowerCase();
228
+ // Accept pre-gzipped CSV/TSV (.csv.gz / .tsv.gz) so large tables transfer
229
+ // ~3x smaller. Already-gzipped files are stored as-is (the platform stores
230
+ // every dataset gzipped anyway); plain files are gzipped on the way up.
231
+ const isGzipInput = lower.endsWith(".gz");
232
+ const baseExt = path.extname(isGzipInput ? lower.slice(0, -3) : lower);
233
+ if (baseExt !== ".csv" && baseExt !== ".tsv") {
234
+ throw new Error("Only .csv, .tsv, .csv.gz, or .tsv.gz files can be uploaded as datasets.");
230
235
  }
231
236
  const HARD_MAX_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB (R2 single-PUT limit)
232
237
  let stat;
@@ -246,11 +251,14 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
246
251
  const idem = await streamFileSha256(resolved);
247
252
  const init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
248
253
  const datasetId = (init.dataset_id ?? init.id);
249
- if (init.idempotent_replay) {
254
+ // Only short-circuit when the prior upload actually completed. A stale
255
+ // `pending_upload` row (a previous PUT that never landed) comes back with
256
+ // a fresh upload_url and no idempotent_replay flag, so we re-PUT below.
257
+ if (init.idempotent_replay && !init.upload_url) {
250
258
  return textResult({ id: datasetId, status: init.status, idempotent_replay: true,
251
259
  suggested_next_step: `datasets(action=preview, dataset_id=${datasetId})` });
252
260
  }
253
- await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS);
261
+ await putPresignedStream(init.upload_url, resolved, init.content_type ?? "application/octet-stream", PRESIGNED_PUT_TIMEOUT_MS, isGzipInput);
254
262
  const fin = (await apiCall("POST", `/v1/datasets/${datasetId}/finalize`, {}));
255
263
  const jobId = (fin.id ?? fin.job_id);
256
264
  const poll = await pollUntilComplete(jobId, POLL_STAGE_MAX_MS);
@@ -267,6 +275,21 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
267
275
  : `Still staging; poll jobs(action=status, job_id="${jobId}") then datasets(action=preview, dataset_id=${datasetId}).`,
268
276
  });
269
277
  }
278
+ // Small pre-gzipped files: send the bytes as-is (the API decompresses
279
+ // Content-Encoding: gzip), never decoding them as UTF-8 text.
280
+ if (isGzipInput) {
281
+ const gzBytes = await fs.readFile(resolved);
282
+ const data = (await apiCall("POST", "/v1/datasets", gzBytes, {
283
+ "X-Dataset-Name": name,
284
+ "Content-Type": "text/csv",
285
+ "Content-Encoding": "gzip",
286
+ "Idempotency-Key": createHash("sha256").update(`${name}\n`).update(gzBytes).digest("hex"),
287
+ }, UPLOAD_DATASET_TIMEOUT_MS));
288
+ const gid = data.id ?? data.dataset_id;
289
+ if (gid != null)
290
+ data.suggested_next_step = `Suggested next step: datasets(action=preview, dataset_id=${gid}) to inspect columns before training.`;
291
+ return textResult(data);
292
+ }
270
293
  body = await fs.readFile(resolved, "utf-8");
271
294
  }
272
295
  else if (csv_data && csv_data.length > 0) {
@@ -367,8 +390,8 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
367
390
  const temporalCols = temporalSugg.flatMap((ts) => ts.columns ?? []);
368
391
  const allCols = [...new Set([...cyclicCols, ...temporalCols])];
369
392
  lines.push(``, allCols.length > 0
370
- ? `Consider using cyclic_features or temporal_features for these columns when calling jobs(action=train_map): ${allCols.join(", ")}.`
371
- : `Consider using cyclic_features or temporal_features for the columns above when calling jobs(action=train_map).`);
393
+ ? `Consider using cyclic_features or temporal_features for these columns when calling train(action=map): ${allCols.join(", ")}.`
394
+ : `Consider using cyclic_features or temporal_features for the columns above when calling train(action=map).`);
372
395
  }
373
396
  if (samples.length > 0) {
374
397
  lines.push(``, `Sample Rows (first ${samples.length}):`);
@@ -378,7 +401,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
378
401
  lines.push(`| ${cols.map((c) => String(row[c] ?? "")).join(" | ")} |`);
379
402
  }
380
403
  }
381
- lines.push(``, `Suggested next step: jobs(action=train_map, dataset_id=${dataset_id}, ...) or use the prepare_training prompt; to add derived columns use datasets(action=add_expression, dataset_id=${dataset_id}, name=..., expression=...).`);
404
+ lines.push(``, `Suggested next step: train(action=map, dataset_id=${dataset_id}, ...) or use the prepare_training prompt; to add derived columns use datasets(action=add_expression, dataset_id=${dataset_id}, name=..., expression=...).`);
382
405
  return { content: [{ type: "text", text: lines.join("\n") }] };
383
406
  }
384
407
  if (action === "analyze") {
@@ -473,7 +496,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
473
496
  `Expression: ${expression} | Rows: ${summary.n_rows ?? "?"}`,
474
497
  summary.nan_count ? `NaN values: ${summary.nan_count}` : "",
475
498
  `Min: ${summary.min ?? "?"} | Max: ${summary.max ?? "?"} | Mean: ${summary.mean ?? "?"}`,
476
- `Column now available. Use datasets(action=preview) to verify, or include in jobs(action=train_map) via the 'columns' parameter.`,
499
+ `Column now available. Use datasets(action=preview) to verify, or include in train(action=map) via the 'columns' parameter.`,
477
500
  ]
478
501
  .filter(Boolean)
479
502
  .join("\n"),
@@ -531,7 +554,7 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
531
554
  lines.push(`Selected source-column indices (1-based): [${idxs.join(", ")}]`);
532
555
  }
533
556
  }
534
- 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.`);
557
+ lines.push(`Use datasets(action=preview, dataset_id=${dataset_id}) to inspect the new columns, then train(action=map, dataset_id=${dataset_id}, columns=[..., "${outCols[0] ?? ""}", …]) to train on the reduced features.`);
535
558
  return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }] };
536
559
  }
537
560
  if (poll.status === "failed") {
@@ -30,18 +30,40 @@ export function formatPredictPreviewLines(summary) {
30
30
  }
31
31
  return lines;
32
32
  }
33
+ /**
34
+ * Fan out N predict jobs against one trained map. Shared by inference(batch_predict)
35
+ * and the deprecated jobs(batch_predict) wrapper. Posts to the same route and returns
36
+ * the list of submitted job_ids (does not auto-poll — poll each via jobs(status)).
37
+ */
38
+ export async function runBatchPredict(args) {
39
+ const { job_id, inputs } = args;
40
+ if (!job_id)
41
+ throw new Error("inference(batch_predict) requires job_id (parent training job)");
42
+ if (!inputs || !Array.isArray(inputs) || inputs.length === 0) {
43
+ throw new Error("inference(batch_predict) requires inputs (non-empty array of { dataset_id? or rows? })");
44
+ }
45
+ const data = (await apiCall("POST", `/v1/results/${job_id}/batch_predict`, { inputs }));
46
+ const ids = (data.ids ?? []);
47
+ return {
48
+ content: [{
49
+ type: "text",
50
+ text: `Batch predict: ${ids.length} job(s) submitted. Parent: ${job_id}. Poll each with jobs(action=status, job_id="<id>"). IDs: ${ids.join(", ")}`,
51
+ }],
52
+ };
53
+ }
33
54
  export function registerInferenceTool(server) {
34
55
  registerAuditedTool(server, "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.
35
56
 
36
57
  | Action | Use when | Timing |
37
58
  |--------|----------|--------|
38
59
  | predict | Scoring rows against the trained map (new data OR the training set itself) | 5–120s |
60
+ | batch_predict | Fan out many predict jobs at once against one trained map (several datasets/row-sets in one call) | submits N jobs |
39
61
  | 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 |
40
62
  | compare | Comparing hit distributions of a second dataset against training (drift, A/B); needs one trained job_id + dataset B. NOT jobs(compare), which tabulates metrics across multiple training runs. | 30–120s |
41
63
  | project_columns | Project one or more dataset columns onto the map (component planes); dataset can be training set or partial-feature set | 10–90s |
42
64
  | report | Get a report manifest (artifact keys + URLs) to build your own report in Quarto/Notebook/script | Immediate (sync) |
43
65
 
44
- 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.
66
+ Sync/async: predict, impute_column, and compare are async jobs that the proxy auto-polls and usually returns on completion; if one returns a job_id instead (e.g. timeout), poll jobs(action=status, job_id=...) then results(action=download, job_id=...). batch_predict does NOT auto-poll — it returns a list of submitted job_ids; poll each with jobs(action=status).
45
67
  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).
46
68
  report is the only synchronous inference action — returns manifest immediately; no job to poll.
47
69
  NOT FOR: Retraining or changing the map — all actions treat the trained map as frozen.
@@ -65,9 +87,16 @@ action=compare: density-diff of a cohort dataset against the training set on ONE
65
87
  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>).
66
88
  action=report: Returns a report manifest for the given job_id (job must be completed). Includes figure_manifest (logical names → filenames), download_urls for all artifacts, cluster_summary when available, and summary metrics. Stakeholder report PDF (if generated) is available via results(action=download, job_id=<training_job_id>), filename e.g. report.pdf.`, {
67
89
  action: z
68
- .enum(["predict", "impute_column", "compare", "project_columns", "report"])
69
- .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."),
90
+ .enum(["predict", "batch_predict", "impute_column", "compare", "project_columns", "report"])
91
+ .describe("predict: score rows; batch_predict: fan out many predict jobs at once; 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."),
70
92
  job_id: z.string().describe("Job ID of a completed map training job"),
93
+ inputs: z
94
+ .array(z.object({
95
+ dataset_id: z.string().optional(),
96
+ rows: z.array(z.record(z.string(), z.number())).optional(),
97
+ }))
98
+ .optional()
99
+ .describe("action=batch_predict: array of inputs; each item has dataset_id and/or rows (same shape as a single predict)."),
71
100
  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."),
72
101
  columns: z.array(z.string()).optional().describe("action=project_columns: column names to project onto the map (must exist in the dataset)."),
73
102
  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."),
@@ -84,9 +113,11 @@ action=report: Returns a report manifest for the given job_id (job must be compl
84
113
  .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."),
85
114
  weighting: z.enum(["distance", "uniform"]).optional()
86
115
  .describe("action=impute_column: distance (default) weights pooled values by map-neighbourhood proximity (closer BMU nodes count more); uniform is a flat pool."),
87
- }, 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 }) => {
116
+ }, 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, inputs }) => {
88
117
  const dpiMap = { standard: 1, retina: 2, print: 4 };
89
118
  const numericDpi = dpiMap[output_dpi ?? "retina"] ?? 2;
119
+ if (action === "batch_predict")
120
+ return runBatchPredict({ job_id, inputs });
90
121
  if (action === "predict") {
91
122
  const outputStyle = output ?? "compact";
92
123
  if (outputStyle === "annotated" && rows) {