@barivia/barsom-mcp 0.15.0 → 0.15.3

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
@@ -86,10 +86,11 @@ 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. 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. |
89
+ | `upload` | Adding a new CSV — returns dataset_id and **`stage_job_id`** when staging is enqueued. 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 |
93
+ | `get` | One dataset by id — status, staging fields, `stage_job_id`, ingest errors |
93
94
  | `subset` | Creating a filtered/sliced copy (row_range, filter conditions) |
94
95
  | `add_expression` | Add a derived column from an expression (formula → new column on the dataset) |
95
96
  | `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. |
@@ -214,7 +215,7 @@ MCP Client (Cursor/Claude) ←stdio→ @barivia/barsom-mcp ←HTTPS→ api.bariv
214
215
  | `401` / invalid key | `BARIVIA_API_KEY` in MCP config; check your email or manage your key in the [account dashboard](https://barivia.se/dashboard). Error text includes a **request id** for support. |
215
216
  | Request timed out | Raise `BARIVIA_FETCH_TIMEOUT_MS` (e.g. `120000`). Large uploads already use an extended timeout; `datasets(analyze)` is async (auto-polled). Timeouts are not auto-retried, so re-running a timed-out upload is safe — an idempotency key reconciles it to the original dataset instead of duplicating. |
216
217
  | `Path must be within the workspace` / upload can’t find file | Set `BARIVIA_WORKSPACE_ROOT` to your project directory, or use an absolute path / `file:///...` URI. |
217
- | Job stuck “running” | Poll `jobs(action=status)` every 10–15s; large grids or FLooP-SIOM can take several minutes—not an MCP error. |
218
+ | Job stuck “running” | Poll `jobs(action=status)` every 10–15s; large grids or FLooP-SIOM can take several minutes—not an MCP error. Staged datasets enqueue **`prepare_training_matrix`** (or impute prepare) first — train submit returns `prepare_job_id`; MCP auto-polls prepare when present. CFD mesh submit may return `prepare_job_id` for **`cfd_prepare`** (barmesh auto-polls). |
218
219
  | `429` | Rate limit—wait and retry. |
219
220
  | Malformed MCP / client errors | Ensure nothing writes to **stdout** except MCP JSON-RPC (the proxy logs API traffic to **stderr** only). |
220
221
 
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{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);
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, **get** (status/staging), subset, add_expression, reduce_spectral, 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);
@@ -0,0 +1,18 @@
1
+ import { pollUntilComplete } from "./shared.js";
2
+ /**
3
+ * When the API enqueues prepare_training_matrix for cross-dataset scoring,
4
+ * poll it to completion before polling the main inference job.
5
+ */
6
+ export async function pollPrepareIfPresent(data, label, timeoutMs = 600_000) {
7
+ const prepareJobId = data.prepare_job_id;
8
+ if (!prepareJobId)
9
+ return null;
10
+ const poll = await pollUntilComplete(prepareJobId, timeoutMs);
11
+ if (poll.status === "failed") {
12
+ throw new Error(`${label}: prepare_training_matrix job ${prepareJobId} failed: ${poll.error ?? "unknown error"}`);
13
+ }
14
+ if (poll.status !== "completed") {
15
+ throw new Error(`${label}: prepare_training_matrix job ${prepareJobId} did not complete (status=${poll.status})`);
16
+ }
17
+ return prepareJobId;
18
+ }
@@ -0,0 +1,75 @@
1
+ /** Format jobs(status) text from GET /v1/jobs/:id payload. Exported for tests. */
2
+ export function formatJobStatusText(job_id, data) {
3
+ const status = String(data.status ?? "unknown");
4
+ const progress = (data.progress ?? 0) * 100;
5
+ const label = data.label != null && data.label !== "" ? String(data.label) : null;
6
+ const jobDesc = label ? `Job ${label} (id: ${job_id})` : `Job ${job_id}`;
7
+ const parts = [`${jobDesc}: ${status} (${progress.toFixed(1)}%)`];
8
+ const attempt = data.attempt != null ? Number(data.attempt) : null;
9
+ if (attempt != null && attempt > 1) {
10
+ parts.push(`attempt ${attempt} (job was requeued after worker timeout — not stuck from scratch)`);
11
+ }
12
+ const phase = data.progress_phase != null && String(data.progress_phase) !== ""
13
+ ? String(data.progress_phase)
14
+ : null;
15
+ if (status === "running") {
16
+ if (phase) {
17
+ parts.push(`phase: ${phase}`);
18
+ }
19
+ else {
20
+ parts.push(`phase: preprocessing (not set yet — often download/parse before first progress tick)`);
21
+ }
22
+ }
23
+ const startedAt = data.started_at != null ? String(data.started_at) : null;
24
+ if (startedAt) {
25
+ parts.push(`started_at: ${startedAt}`);
26
+ const startedMs = Date.parse(startedAt);
27
+ if (!Number.isNaN(startedMs)) {
28
+ const elapsedSec = Math.max(0, Math.round((Date.now() - startedMs) / 1000));
29
+ parts.push(`elapsed: ${elapsedSec}s`);
30
+ }
31
+ }
32
+ const datasetRows = data.dataset_rows != null ? Number(data.dataset_rows) : null;
33
+ const datasetName = data.dataset_name != null ? String(data.dataset_name) : null;
34
+ if (datasetRows != null && datasetRows > 0) {
35
+ const nameBit = datasetName ? ` (${datasetName})` : "";
36
+ parts.push(`dataset: ${datasetRows.toLocaleString()} rows${nameBit}`);
37
+ }
38
+ if (status === "running" &&
39
+ progress < 5 &&
40
+ datasetRows != null &&
41
+ datasetRows >= 1_000_000) {
42
+ parts.push("Advisory: still in early preprocessing (download/parse/normalize). Progress may stay near 0% for a long time on large tables; epoch counter starts after ingest.");
43
+ }
44
+ else if (status === "running" && progress < 5 && !phase) {
45
+ parts.push("Advisory: still in early preprocessing — epoch counter starts after ingest completes.");
46
+ }
47
+ if (status === "completed") {
48
+ const finalizeId = data.finalize_job_id != null && String(data.finalize_job_id) !== ""
49
+ ? String(data.finalize_job_id)
50
+ : null;
51
+ if (finalizeId) {
52
+ parts.push(`Training kernel done; finalize_training still running (finalize_job_id=${finalizeId}). Poll jobs(action=status) again to auto-wait, or poll that job directly before results(get).`);
53
+ }
54
+ else {
55
+ parts.push(`Results ready. Use results(action=get, job_id="${job_id}") to retrieve.`);
56
+ }
57
+ }
58
+ else if (status === "failed") {
59
+ const failureStage = data.failure_stage != null && String(data.failure_stage) !== ""
60
+ ? ` [${data.failure_stage}]`
61
+ : "";
62
+ parts.push(`Error${failureStage}: ${data.error ?? "unknown"}`);
63
+ }
64
+ else if (status === "cancelled") {
65
+ parts.push(`Cancelled. Poll again to confirm; dataset and partial worker state are unchanged.`);
66
+ }
67
+ return parts.join(" | ");
68
+ }
69
+ export function formatJobCancelText(job_id, data) {
70
+ const status = String(data.status ?? "cancelled");
71
+ const ts = new Date().toISOString();
72
+ return (`Cancel accepted at ${ts} for job ${job_id}: status=${status}. ` +
73
+ `Poll jobs(action=status, job_id="${job_id}") to confirm; training stops between phases (may take up to ~30s).`);
74
+ }
75
+ export const GZIP_UPLOAD_HINT = "Try gzip: save as .csv.gz (often 2–3× smaller). The upload limit applies to the compressed file size on presigned/large uploads.";
package/dist/shared.js CHANGED
@@ -26,7 +26,7 @@ export const RETRYABLE_STATUS = new Set([502, 503, 504]);
26
26
  * X-Barsom-Client-Version so the server can annotate tool guidance with the
27
27
  * wrapper version each action requires. Keep in sync with package.json on bump.
28
28
  */
29
- export const CLIENT_VERSION = "0.15.0";
29
+ export const CLIENT_VERSION = "0.15.3";
30
30
  /** User-facing links; keep aligned with barivia.se / api.barivia.se. */
31
31
  export const PUBLIC_SITE_ORIGIN = "https://barivia.se";
32
32
  /** Self-serve account dashboard (manage plan, billing, and API keys). */
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { registerAuditedTool } from "../audit.js";
7
7
  import { apiCall, getWorkspaceRootAsync, resolveFilePathForUpload, textResult, pollUntilComplete, POLL_DERIVE_MAX_MS, UPLOAD_DATASET_TIMEOUT_MS, LARGE_UPLOAD_BYTES, PRESIGNED_PUT_TIMEOUT_MS, POLL_STAGE_MAX_MS, streamFileSha256, putPresignedStream, } from "../shared.js";
8
+ import { GZIP_UPLOAD_HINT } from "../job_status_format.js";
8
9
  /**
9
10
  * Format a dataset analyze result (from either the async job summary or the
10
11
  * legacy sync GET response) into the human-readable text block.
@@ -95,18 +96,21 @@ function formatAnalyzeResult(data, dataset_id) {
95
96
  export function registerDatasetsTool(server) {
96
97
  registerAuditedTool(server, "datasets", `Manage datasets: upload, preview, list, subset, add_expression, or delete.
97
98
 
99
+ Formats: plain CSV/TSV or gzip (.csv.gz / .tsv.gz). For files above ~100 MB, prefer .csv.gz (often 2–3× smaller); large uploads use presigned direct-to-storage PUT and accept gzip bodies.
100
+
98
101
  | Action | Use when |
99
102
  |--------|----------|
100
- | upload | You have a CSV file to add — do this first |
103
+ | upload | You have a CSV or .csv.gz file to add — do this first |
101
104
  | preview | Before train(action=map) — always preview an unfamiliar dataset to spot cyclics, nulls, column types |
102
105
  | analyze | Pre-training column analysis: correlation matrix, periodicity ranking, column recommendations (train / drop / project later). Run after preview, before train_map. |
103
106
  | list | Finding dataset IDs for train_map, preview, or subset — see all available datasets |
107
+ | get | Fetch one dataset by id — status, staging fields, ingest_error (use after upload or when staging is slow) |
104
108
  | subset | Creating a filtered/sliced view without re-uploading the full CSV |
105
109
  | add_expression | Add a computed column from an expression (dataset_id + name + expression). With project_onto_job=<training_job_id>, instead projects the computed expression onto that map as a component plane in one step (no column added). |
106
110
  | 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
111
  | delete | Cleaning up after experiments or freeing the dataset slot |
108
112
 
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).
113
+ action=upload: PREFER file_path — server reads from workspace root (token-efficient; no file content in context). Accepts .csv, .tsv, .csv.gz, .tsv.gz. Use csv_data only for small inline pastes (e.g. <10KB). Returns dataset ID. Then use datasets(action=preview) before train(action=map). If plain CSV exceeds the 5 GB upload cap, gzip it first (.csv.gz).
110
114
 
111
115
  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
116
  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.
@@ -114,6 +118,7 @@ Step 1 (after upload): always datasets(action=preview) to verify column types an
114
118
  action=preview: Show columns, stats, sample rows, cyclic/datetime detections. ALWAYS preview before train(action=map) on an unfamiliar dataset.
115
119
  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
120
  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).
121
+ action=get: Fetch one dataset by dataset_id — returns status, staged_prefix, staged_version, ingest_error, and stage_job_id when staging is pending.
117
122
  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.
118
123
  - row_range: [start, end] 1-based inclusive (e.g. [1, 2000] for first 2000 rows)
119
124
  - filters: array of conditions, ALL must match (AND logic). Each: { column, op, value }.
@@ -134,12 +139,12 @@ BEST FOR: Tabular numeric data. CSV with header required.
134
139
  NOT FOR: Real-time data streams or binary files — upload a snapshot CSV instead.
135
140
  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
141
  action: z
137
- .enum(["upload", "preview", "analyze", "list", "subset", "delete", "add_expression", "reduce_spectral"])
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"),
142
+ .enum(["upload", "preview", "analyze", "list", "get", "subset", "delete", "add_expression", "reduce_spectral"])
143
+ .describe("upload: add CSV or .csv.gz; preview: inspect columns/stats; analyze: pre-training correlation and periodicity; list: see all datasets; get: fetch one dataset metadata (status/staging); 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"),
139
144
  name: z.string().optional().describe("Dataset name (required for action=upload and subset)"),
140
- 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."),
145
+ file_path: z.string().optional().describe("Path to local CSV or .csv.gz (PREFERRED): absolute path, file:// URI, or relative to workspace root. Token-efficient; server reads file. Use .csv.gz for large scientific tables."),
141
146
  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."),
142
- dataset_id: z.string().optional().describe("Dataset ID (required for preview, subset, and delete)"),
147
+ dataset_id: z.string().optional().describe("Dataset ID (required for preview, get, subset, and delete)"),
143
148
  n_rows: z.number().int().optional().default(5).describe("Sample rows to return (preview only)"),
144
149
  row_range: z
145
150
  .tuple([z.number().int(), z.number().int()])
@@ -243,13 +248,24 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
243
248
  `Set BARIVIA_WORKSPACE_ROOT in your MCP config env if needed (current: ${await getWorkspaceRootAsync(server)}).`);
244
249
  }
245
250
  if (stat.size > HARD_MAX_BYTES) {
246
- throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.`);
251
+ const gzipHint = isGzipInput ? "" : ` ${GZIP_UPLOAD_HINT}`;
252
+ throw new Error(`File too large (${(stat.size / 1024 / 1024 / 1024).toFixed(2)} GB). Maximum upload size is 5 GB.${gzipHint}`);
247
253
  }
248
254
  // Large files: stream gzip directly to R2 (presigned PUT), then stage async.
249
255
  // Never reads the file into memory or sends it through the API/Cloudflare.
250
256
  if (stat.size >= LARGE_UPLOAD_BYTES) {
251
257
  const idem = await streamFileSha256(resolved);
252
- const init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
258
+ let init;
259
+ try {
260
+ init = (await apiCall("POST", "/v1/datasets/upload-url", { name, size_bytes: stat.size }, { "Idempotency-Key": idem }));
261
+ }
262
+ catch (e) {
263
+ const msg = e instanceof Error ? e.message : String(e);
264
+ if (msg.includes("dataset_too_large") && !isGzipInput) {
265
+ throw new Error(`${msg} ${GZIP_UPLOAD_HINT}`);
266
+ }
267
+ throw e;
268
+ }
253
269
  const datasetId = (init.dataset_id ?? init.id);
254
270
  // Only short-circuit when the prior upload actually completed. A stale
255
271
  // `pending_upload` row (a previous PUT that never landed) comes back with
@@ -584,9 +600,43 @@ ESCALATION: If upload fails with column errors, open the file locally and verify
584
600
  return textResult(data);
585
601
  }
586
602
  if (action === "list") {
587
- const data = await apiCall("GET", "/v1/datasets");
603
+ const data = (await apiCall("GET", "/v1/datasets"));
604
+ if (Array.isArray(data)) {
605
+ const lines = data.map((ds) => {
606
+ const id = String(ds.id ?? "");
607
+ const name = String(ds.name ?? "");
608
+ const rows = ds.rows != null ? Number(ds.rows) : "?";
609
+ const cols = ds.cols != null ? Number(ds.cols) : "?";
610
+ const st = ds.status != null ? String(ds.status) : "ready";
611
+ const statusBit = st !== "ready" ? ` | status=${st}` : "";
612
+ const err = ds.ingest_error != null && String(ds.ingest_error) !== ""
613
+ ? ` | ingest_error=${String(ds.ingest_error)}`
614
+ : "";
615
+ return `${name} (${id}) — ${rows}×${cols}${statusBit}${err}`;
616
+ });
617
+ return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No datasets." }] };
618
+ }
588
619
  return textResult(data);
589
620
  }
621
+ if (action === "get") {
622
+ if (!dataset_id)
623
+ throw new Error("datasets(get) requires dataset_id");
624
+ const ds = (await apiCall("GET", `/v1/datasets/${dataset_id}`));
625
+ const lines = [
626
+ `Dataset: ${ds.name ?? "?"} (${ds.id ?? dataset_id})`,
627
+ `Status: ${ds.status ?? "ready"}`,
628
+ `Rows × cols: ${ds.rows ?? "?"} × ${ds.cols ?? "?"}`,
629
+ ds.size_bytes != null ? `Size: ${Number(ds.size_bytes).toLocaleString()} bytes` : "",
630
+ ds.staged_prefix != null ? `Staged prefix: ${String(ds.staged_prefix)}` : "",
631
+ ds.staged_version != null ? `Staged version: ${String(ds.staged_version)}` : "",
632
+ ds.stage_job_id != null ? `Stage job: ${String(ds.stage_job_id)} (poll jobs(action=status))` : "",
633
+ ds.ingest_error != null && String(ds.ingest_error) !== ""
634
+ ? `Ingest error: ${String(ds.ingest_error)}`
635
+ : "",
636
+ ds.created_at != null ? `Created: ${String(ds.created_at)}` : "",
637
+ ].filter(Boolean);
638
+ return { content: [{ type: "text", text: lines.join("\n") }] };
639
+ }
590
640
  if (action === "delete") {
591
641
  if (!dataset_id)
592
642
  throw new Error("datasets(delete) requires dataset_id");
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, pollUntilComplete, tryAttachImage } from "../shared.js";
4
+ import { pollPrepareIfPresent } from "../inference_prepare.js";
4
5
  const PREDICT_PREVIEW_ROW_CAP = 10;
5
6
  /** One line per scored row when worker embedded `predictions_preview` (n_rows ≤ cap). Exported for tests. */
6
7
  export function formatPredictPreviewLines(summary) {
@@ -153,6 +154,7 @@ action=report: Returns a report manifest for the given job_id (job must be compl
153
154
  body.rows = rows;
154
155
  const data = (await apiCall("POST", `/v1/results/${job_id}/predict`, body));
155
156
  const predictJobId = data.id;
157
+ const prepareId = await pollPrepareIfPresent(data, "inference(predict)");
156
158
  // annotated runs over the full dataset; allow up to 120s like compact
157
159
  const poll = await pollUntilComplete(predictJobId, 120_000);
158
160
  if (poll.status === "completed") {
@@ -180,6 +182,7 @@ action=report: Returns a report manifest for the given job_id (job must be compl
180
182
  : `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}.`);
181
183
  return { content: [{ type: "text", text: [
182
184
  headerLine,
185
+ prepareId ? `(prepare_training_matrix ${prepareId} completed before scoring)` : "",
183
186
  `Regime: ${regime}${regime === "training" ? " (scored against the training set)" : ""} | Style: ${effectiveStyle}`,
184
187
  `Rows scored: ${summary.n_rows ?? "?"}`,
185
188
  metricsLine,
@@ -271,6 +274,7 @@ action=report: Returns a report manifest for the given job_id (job must be compl
271
274
  body.colormap = colormap;
272
275
  const data = (await apiCall("POST", `/v1/results/${job_id}/project_columns`, body));
273
276
  const projJobId = data.id;
277
+ const prepareId = await pollPrepareIfPresent(data, "inference(project_columns)");
274
278
  const poll = await pollUntilComplete(projJobId, 90_000);
275
279
  if (poll.status === "completed") {
276
280
  const results = (await apiCall("GET", `/v1/results/${projJobId}`));
@@ -280,6 +284,7 @@ action=report: Returns a report manifest for the given job_id (job must be compl
280
284
  const fmtStat = (v) => v !== null && v !== undefined ? Number(v).toFixed(3) : "—";
281
285
  const lines = [
282
286
  `Project columns complete — job: ${projJobId}`,
287
+ prepareId ? `(prepare_training_matrix ${prepareId} completed before projection)` : "",
283
288
  `Columns: ${summary.columns?.join(", ") ?? columns.join(", ")} | Samples: ${summary.n_samples ?? "?"}`,
284
289
  ...Object.entries(columnStats).map(([col, s]) => {
285
290
  const parts = [`${col}: nodes=${s.n_nodes_with_data ?? "?"}`];
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, textResult } from "../shared.js";
4
+ import { formatJobCancelText, formatJobStatusText } from "../job_status_format.js";
5
+ import { pollFinalizeIfPresent, refreshJobAfterFinalize } from "../train_finalize.js";
4
6
  import { runTrain } from "./train.js";
5
7
  import { runBatchPredict } from "./inference.js";
6
8
  import { TRAINING_INPUT_SCHEMA } from "./training_core.js";
@@ -195,23 +197,20 @@ export function registerJobsTool(server, description) {
195
197
  if (action === "status") {
196
198
  if (!job_id)
197
199
  throw new Error("jobs(status) requires job_id");
198
- const data = (await apiCall("GET", `/v1/jobs/${job_id}`));
199
- const status = data.status;
200
- const progress = (data.progress ?? 0) * 100;
201
- const label = data.label != null && data.label !== "" ? String(data.label) : null;
202
- const jobDesc = label ? `Job ${label} (id: ${job_id})` : `Job ${job_id}`;
203
- let text = `${jobDesc}: ${status} (${progress.toFixed(1)}%)`;
204
- const phase = data.progress_phase != null && data.progress_phase !== "" ? String(data.progress_phase) : null;
205
- if (phase && status === "running") {
206
- text += ` — phase: ${phase}`;
200
+ let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
201
+ const status = String(data.status ?? "");
202
+ if (status === "completed" && data.finalize_job_id) {
203
+ const { note } = await pollFinalizeIfPresent(job_id, data);
204
+ data = await refreshJobAfterFinalize(job_id);
205
+ const text = formatJobStatusText(job_id, data);
206
+ return {
207
+ content: [{
208
+ type: "text",
209
+ text: note ? `${text}\n${note}` : text,
210
+ }],
211
+ };
207
212
  }
208
- if (status === "completed") {
209
- text += ` | Results ready. Use results(action=get, job_id="${job_id}") to retrieve.`;
210
- }
211
- else if (status === "failed") {
212
- text += ` | Error: ${data.error ?? "unknown"}`;
213
- }
214
- return { content: [{ type: "text", text }] };
213
+ return { content: [{ type: "text", text: formatJobStatusText(job_id, data) }] };
215
214
  }
216
215
  if (action === "list") {
217
216
  const listPath = dataset_id ? `/v1/jobs?dataset_id=${dataset_id}` : "/v1/jobs";
@@ -238,8 +237,8 @@ export function registerJobsTool(server, description) {
238
237
  if (action === "cancel") {
239
238
  if (!job_id)
240
239
  throw new Error("jobs(cancel) requires job_id");
241
- const data = await apiCall("POST", `/v1/jobs/${job_id}/cancel`);
242
- return textResult(data);
240
+ const data = (await apiCall("POST", `/v1/jobs/${job_id}/cancel`));
241
+ return { content: [{ type: "text", text: formatJobCancelText(job_id, data) }] };
243
242
  }
244
243
  if (action === "delete") {
245
244
  if (!job_id)
@@ -30,9 +30,9 @@ action=export: Structured data exports. Use export_type= to choose what to expor
30
30
  - export_type=nodes: per-node hit count + feature stats. Profile clusters and operating modes.
31
31
 
32
32
  action=download: Save figures to disk. Use so user can open, share, or version files locally.
33
- - folder: e.g. "." or "./results". Interpreted relative to the client's current working directory (or workspace). Files are written into a per-job subfolder named job_type + label (or job_id) under this folder, so downloading several jobs (even with the same label across types) never overwrites shared filenames like summary.json.
33
+ - folder: e.g. "." or "./results". Relative to the client's working directory (or MCP workspace). Each job lands in a dedicated subfolder under folder: {job_type}_{label} when label was set at train time, otherwise {job_type}_{job_id_prefix} so parallel downloads never overwrite summary.json or figure names.
34
34
  - figures: "all" (default) or array of filenames.
35
- - include_json: also save summary.json.
35
+ - include_json: when true, also saves summary.json (and adds it to the download list if figures omitted).
36
36
 
37
37
  action=recolor: Change colormap or output format — no retraining. Returns a new job_id; auto-polls 60s.
38
38
  AFTER: use results(action=get, job_id=NEW_JOB_ID).
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { registerAuditedTool } from "../audit.js";
3
3
  import { apiCall, textResult } from "../shared.js";
4
+ import { buildTrainSubmitExtras } from "../train_submit_message.js";
4
5
  import { TRAINING_INPUT_SCHEMA, buildTrainMapParams, fetchTrainingPresets, } from "./training_core.js";
5
6
  export const TRAIN_DESCRIPTION = `Submit a self-organizing map training job. Returns a job_id; poll with jobs(action=status) and then results(action=get). All actions are async.
6
7
 
@@ -124,29 +125,41 @@ export async function runTrain(action, args) {
124
125
  : action === "impute" ? "variant=train_impute"
125
126
  : "variant=som";
126
127
  data.effective_params = `${variantPrefix}, ${paramSummary}`;
128
+ const hasTransforms = action !== "impute" &&
129
+ transforms != null &&
130
+ typeof transforms === "object" &&
131
+ Object.keys(transforms).length > 0;
132
+ const extras = buildTrainSubmitExtras({
133
+ newJobId,
134
+ totalRows,
135
+ hasTransforms,
136
+ prepareJobId: data.prepare_job_id ?? null,
137
+ variantPrefix,
138
+ paramSummary,
139
+ impute: action === "impute",
140
+ });
127
141
  try {
128
142
  const sys = (await apiCall("GET", "/v1/system/info"));
129
143
  const pending = Number(sys.status?.pending_jobs ?? sys.pending_jobs ?? 0);
130
144
  const totalEta = Number(sys.training_time_estimates_seconds?.total ??
131
145
  (sys.gpu_available ? 45 : 120));
132
146
  const waitMinutes = Math.round((pending * totalEta) / 60);
133
- let msg = `Job submitted (${variantPrefix}, ${paramSummary}). `;
147
+ let msg = extras.message;
134
148
  if (waitMinutes > 1)
135
- msg += `You are #${pending + 1} in queue. Estimated wait: ~${waitMinutes} min. `;
136
- msg += `Poll with jobs(action=status, job_id="${newJobId}"). When status is completed, use results(action=get, job_id="${newJobId}") to view the map${action === "impute" ? " and imputed.csv" : ""} and metrics.`;
137
- msg += ` Optional: training_monitor(job_id="${newJobId}") for charts—not required.`;
149
+ msg = `You are #${pending + 1} in queue. Estimated wait: ~${waitMinutes} min. ${msg}`;
138
150
  if (effectiveGrid && totalRows > 0 && effectiveGrid[0] * effectiveGrid[1] > totalRows * 0.75) {
139
151
  msg += ` Note: Grid may be large for ${totalRows} rows (consider grid=auto for fewer dead nodes).`;
140
152
  }
141
153
  data.message = msg;
154
+ data.suggested_next_step = extras.suggested_next_step;
142
155
  }
143
156
  catch {
144
- let msg = `Job submitted (${variantPrefix}, ${paramSummary}). Poll with jobs(action=status, job_id="${newJobId}"). When status is completed, use results(action=get, job_id="${newJobId}") to view the map${action === "impute" ? " and imputed.csv" : ""} and metrics.`;
145
- msg += ` Optional: training_monitor(job_id="${newJobId}") for charts—not required.`;
157
+ let msg = extras.message;
146
158
  if (effectiveGrid && totalRows > 0 && effectiveGrid[0] * effectiveGrid[1] > totalRows * 0.75) {
147
159
  msg += ` Note: Grid may be large for ${totalRows} rows (consider grid=auto for fewer dead nodes).`;
148
160
  }
149
161
  data.message = msg;
162
+ data.suggested_next_step = extras.suggested_next_step;
150
163
  }
151
164
  return textResult(data);
152
165
  }
@@ -229,6 +242,13 @@ export async function runTrain(action, args) {
229
242
  submitBody.label = label;
230
243
  const data = (await apiCall("POST", "/v1/jobs", submitBody));
231
244
  const newJobId = data.id;
245
+ let totalRows = 0;
246
+ try {
247
+ const preview = (await apiCall("GET", `/v1/datasets/${dataset_id}/preview?n_rows=1`));
248
+ totalRows = Number(preview?.total_rows ?? 0);
249
+ }
250
+ catch { /* ignore */ }
251
+ const hasTransforms = transforms != null && typeof transforms === "object" && Object.keys(transforms).length > 0;
232
252
  const maxNodeSummary = max_nodes === undefined ? "max_nodes=auto(~2*sqrt(n_samples))" : `max_nodes=${max_nodes}`;
233
253
  const paramSummary = [
234
254
  "variant=floop",
@@ -238,10 +258,17 @@ export async function runTrain(action, args) {
238
258
  gamma !== undefined ? `gamma=${gamma}` : "",
239
259
  ].filter(Boolean).join(", ");
240
260
  data.effective_params = paramSummary;
241
- data.message =
242
- `Job submitted (${paramSummary}). Poll with jobs(action=status, job_id="${newJobId}"). ` +
243
- `When status is completed, use results(action=get, job_id="${newJobId}") to view FLooP-SIOM figures and metrics. ` +
244
- `Optional: training_monitor(job_id="${newJobId}") for charts—not required.`;
261
+ const extras = buildTrainSubmitExtras({
262
+ newJobId,
263
+ totalRows,
264
+ hasTransforms,
265
+ prepareJobId: data.prepare_job_id ?? null,
266
+ variantPrefix: "variant=floop",
267
+ paramSummary,
268
+ resultsHint: "view FLooP-SIOM figures and metrics",
269
+ });
270
+ data.message = extras.message;
271
+ data.suggested_next_step = extras.suggested_next_step;
245
272
  return textResult(data);
246
273
  }
247
274
  throw new Error("Invalid train action");
@@ -0,0 +1,25 @@
1
+ import { apiCall, pollUntilComplete } from "./shared.js";
2
+ /**
3
+ * When train compute finishes, finalize_training may still be rendering figures on worker-io.
4
+ * Poll it before fetching results.
5
+ */
6
+ export async function pollFinalizeIfPresent(jobId, data, timeoutMs = 600_000) {
7
+ const finalizeJobId = data.finalize_job_id;
8
+ if (!finalizeJobId)
9
+ return { finalizeJobId: null, note: null };
10
+ const poll = await pollUntilComplete(finalizeJobId, timeoutMs);
11
+ if (poll.status === "failed") {
12
+ throw new Error(`Training job ${jobId}: finalize_training ${finalizeJobId} failed: ${poll.error ?? "unknown error"}`);
13
+ }
14
+ if (poll.status !== "completed") {
15
+ throw new Error(`Training job ${jobId}: finalize_training ${finalizeJobId} did not complete (status=${poll.status})`);
16
+ }
17
+ return {
18
+ finalizeJobId,
19
+ note: `Finalize job ${finalizeJobId} completed (figures and summary uploaded).`,
20
+ };
21
+ }
22
+ /** Re-fetch GET /v1/jobs/:id after finalize so callers see cleared finalize_job_id. */
23
+ export async function refreshJobAfterFinalize(jobId) {
24
+ return (await apiCall("GET", `/v1/jobs/${jobId}`));
25
+ }
@@ -0,0 +1,52 @@
1
+ import { getVizPort } from "./shared.js";
2
+ export function buildTrainSubmitExtras(opts) {
3
+ const { newJobId, totalRows, hasTransforms, prepareJobId, variantPrefix, paramSummary, impute, resultsHint } = opts;
4
+ const monitorUrl = getVizPort() > 0
5
+ ? `http://localhost:${getVizPort()}/viz/training-monitor?mode=standalone&job_id=${encodeURIComponent(newJobId)}`
6
+ : null;
7
+ let msg = `Job submitted (${variantPrefix}, ${paramSummary}). `;
8
+ if (prepareJobId) {
9
+ msg += `Preprocessing job prepare_training_matrix (${prepareJobId}) runs on worker-io first; train job ${newJobId} starts after it completes. Poll jobs(action=status, job_id="${prepareJobId}") until completed, then poll the train job. `;
10
+ }
11
+ msg += `Recommended: training_monitor(job_id="${newJobId}")`;
12
+ if (monitorUrl)
13
+ msg += ` or open ${monitorUrl}`;
14
+ msg += ` while polling jobs(action=status, job_id="${newJobId}") every 60–120s for large jobs. `;
15
+ msg += `When status is completed, check jobs(action=status) for finalize_job_id — if present, poll that finalize_training job before results(action=get). `;
16
+ msg += `Then use results(action=get, job_id="${newJobId}")`;
17
+ if (resultsHint) {
18
+ msg += ` to ${resultsHint}`;
19
+ }
20
+ else if (impute) {
21
+ msg += ` for the map and imputed.csv`;
22
+ }
23
+ else {
24
+ msg += ` to view the map and metrics`;
25
+ }
26
+ msg += `.`;
27
+ if (totalRows >= 1_000_000 || hasTransforms) {
28
+ msg += ` Large-table note: progress may stay low during staging preprocess before epochs start.`;
29
+ if (hasTransforms && totalRows >= 1_000_000) {
30
+ if (prepareJobId) {
31
+ msg += ` Transforms run in prepare_training_matrix on worker-io (full dataset, no CSV re-download on GPU).`;
32
+ }
33
+ else {
34
+ msg += ` Transforms require prepare_training_matrix on worker-io for staged datasets; the API enqueues it automatically when needed.`;
35
+ }
36
+ }
37
+ }
38
+ const pollSteps = prepareJobId
39
+ ? [
40
+ `1) jobs(action=status, job_id="${prepareJobId}") until prepare completes`,
41
+ `2) jobs(action=status, job_id="${newJobId}") every 60–120s until completed`,
42
+ `3) if finalize_job_id is set, poll it until completed`,
43
+ `4) results(action=download, job_id="${newJobId}", folder=".", include_json=true)`,
44
+ ]
45
+ : [
46
+ `1) training_monitor(job_id="${newJobId}")`,
47
+ `2) jobs(action=status, job_id="${newJobId}") every 60–120s until completed`,
48
+ `3) if finalize_job_id is set, poll it until completed`,
49
+ `4) results(action=download, job_id="${newJobId}", folder=".", include_json=true)`,
50
+ ];
51
+ return { message: msg, suggested_next_step: pollSteps.join(" → ") };
52
+ }