@barivia/barsom-mcp 0.15.4 → 0.17.1

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.
@@ -10,6 +10,16 @@ export async function fetchTrainingPresets() {
10
10
  const configData = (await apiCall("GET", "/v1/training/config"));
11
11
  return configData?.presets || {};
12
12
  }
13
+ /**
14
+ * Fetch presets + the plan-scoped allowed_job_types in one /v1/training/config call.
15
+ * Used by training_prep to filter the train-action selector to entitled modes.
16
+ */
17
+ export async function fetchTrainingConfig() {
18
+ const configData = (await apiCall("GET", "/v1/training/config"));
19
+ const presets = configData?.presets || {};
20
+ const allowedJobTypes = configData?.guidance_scope?.allowed_job_types || [];
21
+ return { presets, allowedJobTypes };
22
+ }
13
23
  /**
14
24
  * Zod raw shape for all training parameters (core SOM + impute + SIOM + FLooP).
15
25
  * Spread into the `train` tool input schema and the deprecated `jobs` wrapper
@@ -48,7 +58,8 @@ export const TRAINING_INPUT_SCHEMA = {
48
58
  separator: z.string().optional(),
49
59
  })).optional(),
50
60
  feature_weights: z.record(z.number()).optional(),
51
- transforms: z.record(z.enum(["log", "log1p", "log10", "sqrt", "square", "abs", "invert", "rank", "none"])).optional(),
61
+ transforms: z.record(z.enum(["log", "log1p", "log10", "sqrt", "square", "abs", "invert", "rank", "none"])).optional()
62
+ .describe("Per-column transform before normalization. rank is train-only (no batch predict/project_columns). Not for train(impute)."),
52
63
  auto_log_transforms: z.boolean().optional().default(false),
53
64
  time_delay_embeddings: z.array(z.object({
54
65
  feature: z.string(),
@@ -59,7 +70,9 @@ export const TRAINING_INPUT_SCHEMA = {
59
70
  categories: z.array(z.string()),
60
71
  weight: z.number().positive()
61
72
  })).optional(),
62
- normalize: z.union([z.enum(["all", "auto", "mad", "sigmoidal"]), z.array(z.string())]).optional().default("auto"),
73
+ normalize: z.union([z.enum(["all", "auto", "mad", "sigmoidal", "sepd"]), z.array(z.string())]).optional().default("auto"),
74
+ normalization_methods: z.record(z.enum(["zscore", "mad", "sigmoidal", "sepd", "none"])).optional()
75
+ .describe("Per-feature normalization override. Use MAD for heavy tails; SEPD for skewed financial/policy columns (see training_guidance). Default auto=zscore on non-cyclics. train(impute) rejects sepd."),
63
76
  sigma_f: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
64
77
  learning_rate: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.union([z.number(), z.object({
65
78
  ordering: z.tuple([z.number(), z.number()]),
@@ -80,12 +93,18 @@ export const TRAINING_INPUT_SCHEMA = {
80
93
  .describe("With viz_mode=summary_plus_top: upload top-N component maps by variance (default 8)"),
81
94
  emit_cell_uncertainty: z.boolean().optional()
82
95
  .describe("impute: write imputation_uncertainty.csv (pool_std per imputed cell)"),
83
- gamma: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
84
- gamma_f: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
85
- siom_decay: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
86
- siom_penalty: z.enum(["linear", "log", "exp"]).optional(),
87
- penalty_alpha: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
88
- reset_per_epoch: z.boolean().optional(),
96
+ gamma: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional())
97
+ .describe("siom_map / impute (SIOM or auto on som_siom+): self-interaction strength γ (default 0.5 on worker)"),
98
+ gamma_f: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional())
99
+ .describe("siom_map / impute SIOM: final γ for exponential decay schedule (0 = constant γ)"),
100
+ siom_decay: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional())
101
+ .describe("siom_map / impute SIOM: occupation EMA decay λ (default 0.01; 0 = cumulative counting)"),
102
+ siom_penalty: z.enum(["linear", "log", "exp"]).optional()
103
+ .describe("siom_map / impute SIOM: occupation penalty shape"),
104
+ penalty_alpha: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional())
105
+ .describe("siom_map / impute SIOM: scaling α inside log/exp penalty"),
106
+ reset_per_epoch: z.boolean().optional()
107
+ .describe("siom_map / impute SIOM: reset occupation μ at each epoch"),
89
108
  siom_feature_geometry: z.enum(["l2", "mixed", "auto"]).optional().describe("siom_map: l2 (default) = legacy L2 on cos/sin columns; mixed = cyclic torus distance when cyclic pairs exist; auto = mixed only when cyclic encodings are present"),
90
109
  siom_qe_backend: z.enum(["cpu", "cuda", "auto"]).optional().describe("siom_map: backend for metric-aligned siom_qe when using mixed geometry; auto uses GPU when available"),
91
110
  siom_qe_batch_size: z.number().int().min(1).max(1_000_000).optional().describe("siom_map: batch size for GPU SIOM QE (default 256 on worker)"),
@@ -109,14 +128,112 @@ export const TRAINING_INPUT_SCHEMA = {
109
128
  elastic_anchor: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
110
129
  anchor_percentile: z.preprocess((v) => (v !== undefined && v !== null && typeof v === "string") ? parseFloat(v) : v, z.number().optional()),
111
130
  };
131
+ /**
132
+ * Build the FLooP-SIOM job params + summary from raw args. Shared single source for
133
+ * the `train` tool and `training_prep` so the growing-manifold knobs never drift.
134
+ * (FLooP has no fixed grid/epochs; it uses topology/max_nodes/effort + growth knobs.)
135
+ */
136
+ export function buildFloopParams(args) {
137
+ const { columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, normalize, row_range, output_format, output_dpi, colormap, topology, max_nodes, effort, n_initial, n_passes, growth_interval, neighborhood_size, edge_max_age, max_degree, gamma, siom_decay, siom_penalty, penalty_alpha, error_threshold, error_decay, ring_close_threshold, sigma_0, sigma_f, eta_0, eta_f, elastic_lambda, elastic_mu, elastic_anchor, anchor_percentile, } = args;
138
+ const params = {
139
+ _job_type: "train_floop_siom",
140
+ topology: topology ?? "free",
141
+ effort: effort ?? "standard",
142
+ normalize: normalize ?? "auto",
143
+ output_format: output_format ?? "png",
144
+ };
145
+ if (max_nodes !== undefined)
146
+ params.max_nodes = max_nodes;
147
+ if (columns?.length)
148
+ params.columns = columns;
149
+ if (cyclic_features?.length)
150
+ params.cyclic_features = cyclic_features;
151
+ if (temporal_features?.length)
152
+ params.temporal_features = temporal_features;
153
+ if (feature_weights && Object.keys(feature_weights).length > 0)
154
+ params.feature_weights = feature_weights;
155
+ if (transforms && Object.keys(transforms).length > 0)
156
+ params.transforms = transforms;
157
+ if (auto_log_transforms)
158
+ params.auto_log_transforms = auto_log_transforms;
159
+ if (row_range && row_range.length >= 2 && row_range[0] <= row_range[1])
160
+ params.row_range = row_range;
161
+ const dpiMap = { standard: 1, retina: 2, print: 4 };
162
+ if (output_dpi && output_dpi !== "retina")
163
+ params.output_dpi = dpiMap[output_dpi] ?? 2;
164
+ if (colormap)
165
+ params.colormap = colormap;
166
+ if (n_initial !== undefined)
167
+ params.n_initial = n_initial;
168
+ if (n_passes !== undefined)
169
+ params.n_passes = n_passes;
170
+ if (growth_interval !== undefined)
171
+ params.growth_interval = growth_interval;
172
+ if (neighborhood_size !== undefined)
173
+ params.neighborhood_size = neighborhood_size;
174
+ if (edge_max_age !== undefined)
175
+ params.edge_max_age = edge_max_age;
176
+ if (max_degree !== undefined)
177
+ params.max_degree = max_degree;
178
+ if (gamma !== undefined)
179
+ params.gamma = gamma;
180
+ if (siom_decay !== undefined)
181
+ params.siom_decay = siom_decay;
182
+ if (siom_penalty !== undefined)
183
+ params.siom_penalty = siom_penalty;
184
+ if (penalty_alpha !== undefined)
185
+ params.penalty_alpha = penalty_alpha;
186
+ if (error_threshold !== undefined)
187
+ params.error_threshold = error_threshold;
188
+ if (error_decay !== undefined)
189
+ params.error_decay = error_decay;
190
+ if (ring_close_threshold !== undefined)
191
+ params.ring_close_threshold = ring_close_threshold;
192
+ if (sigma_0 !== undefined)
193
+ params.sigma_0 = sigma_0;
194
+ if (sigma_f !== undefined)
195
+ params.sigma_f = sigma_f;
196
+ if (eta_0 !== undefined)
197
+ params.eta_0 = eta_0;
198
+ if (eta_f !== undefined)
199
+ params.eta_f = eta_f;
200
+ if (elastic_lambda !== undefined)
201
+ params.elastic_lambda = elastic_lambda;
202
+ if (elastic_mu !== undefined)
203
+ params.elastic_mu = elastic_mu;
204
+ if (elastic_anchor !== undefined)
205
+ params.elastic_anchor = elastic_anchor;
206
+ if (anchor_percentile !== undefined)
207
+ params.anchor_percentile = anchor_percentile;
208
+ const maxNodeSummary = max_nodes === undefined ? "max_nodes=auto(~2*sqrt(n_samples))" : `max_nodes=${max_nodes}`;
209
+ const paramSummary = [
210
+ "variant=floop",
211
+ `topology=${topology ?? "free"}`,
212
+ maxNodeSummary,
213
+ `effort=${effort ?? "standard"}`,
214
+ n_initial !== undefined ? `n_initial=${n_initial}` : "",
215
+ growth_interval !== undefined ? `growth_interval=${growth_interval}` : "",
216
+ n_passes !== undefined ? `n_passes=${n_passes}` : "",
217
+ gamma !== undefined ? `gamma=${gamma}` : "",
218
+ siom_decay !== undefined ? `siom_decay=${siom_decay}` : "",
219
+ siom_penalty !== undefined ? `siom_penalty=${siom_penalty}` : "",
220
+ elastic_lambda !== undefined ? `elastic_lambda=${elastic_lambda}` : "",
221
+ elastic_mu !== undefined ? `elastic_mu=${elastic_mu}` : "",
222
+ elastic_anchor !== undefined ? `elastic_anchor=${elastic_anchor}` : "",
223
+ ].filter(Boolean).join(", ");
224
+ return { params, paramSummary };
225
+ }
112
226
  export function buildTrainMapParams(args, presets) {
113
- const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, } = args;
227
+ const { preset, grid_x, grid_y, epochs, model, periodic, columns, cyclic_features, temporal_features, feature_weights, transforms, auto_log_transforms, time_delay_embeddings, categorical_features, normalize, normalization_methods, sigma_f, learning_rate, batch_size, quality_metrics, backend, output_format, output_dpi, colormap, row_range, siom_feature_geometry, siom_qe_backend, siom_qe_batch_size, } = args;
114
228
  const p = preset ? presets[preset] : undefined;
115
229
  const params = {
116
230
  model: model ?? "SOM",
117
231
  periodic: periodic ?? true,
118
232
  normalize: normalize ?? "auto",
119
233
  };
234
+ if (normalization_methods && Object.keys(normalization_methods).length > 0) {
235
+ params.normalization_methods = normalization_methods;
236
+ }
120
237
  if (grid_x !== undefined && grid_y !== undefined)
121
238
  params.grid = [grid_x, grid_y];
122
239
  else if (p)
@@ -1,7 +1,7 @@
1
1
  import { registerAuditedTool } from "../audit.js";
2
2
  import { fetchTrainingGuidanceFromApi } from "../shared.js";
3
3
  export function registerTrainingGuidanceTool(server) {
4
- registerAuditedTool(server, "training_guidance", "Structured parameter guidance for training (presets, grid/epochs/batch, model, periodic, SIOM/FLooP fields where your plan allows, categorical baselines). Served from the API and filtered to allowed_job_types. Prep ladder: prepare_training prompt = narrative checklist; this tool = JSON/hints; training_prep = interactive UI + submit_prepared_training. For full orientation and SOP, call guide_barsom_workflow first. Optional UIs: training_prep, results_explorer—not required; jobs + results suffice.", {}, async () => {
4
+ registerAuditedTool(server, "training_guidance", "Structured parameter guidance for training (presets, grid/epochs/batch, model, periodic, SIOM/FLooP fields where your plan allows, categorical baselines, normalization MAD vs SEPD guide). Served from the API and filtered to allowed_job_types. Prep ladder: prepare_training prompt = narrative checklist; this tool = JSON/hints + normalization decision ladder; training_prep = interactive UI + submit_prepared_training. For full orientation and SOP, call guide_barsom_workflow first. Optional UIs: training_prep, results_explorer—not required; jobs + results suffice.", {}, async () => {
5
5
  const text = await fetchTrainingGuidanceFromApi();
6
6
  return { content: [{ type: "text", text }] };
7
7
  });
@@ -4,6 +4,15 @@ import { runMcpToolAudit } from "../audit.js";
4
4
  import { apiCall, getClientSupportsMcpApps, getVizPort, structuredTextResult, } from "../shared.js";
5
5
  export const TRAINING_MONITOR_URI = "ui://barsom/training-monitor";
6
6
  export const TRAINING_MONITOR_REFRESH_MS = 5000;
7
+ function hasCurveArrays(data) {
8
+ const keys = [
9
+ "ordering_errors",
10
+ "convergence_errors",
11
+ "ordering_topographic_errors",
12
+ "convergence_topographic_errors",
13
+ ];
14
+ return keys.some((k) => Array.isArray(data[k]) && data[k].length > 0);
15
+ }
7
16
  function buildStructuredContent(job_id, data) {
8
17
  const id = String(data.id ?? job_id);
9
18
  return {
@@ -15,19 +24,55 @@ function buildStructuredContent(job_id, data) {
15
24
  refresh_interval_ms: TRAINING_MONITOR_REFRESH_MS,
16
25
  };
17
26
  }
27
+ async function enrichWithTrainingLog(job_id, data) {
28
+ const status = String(data.status ?? "");
29
+ if (status !== "completed" && status !== "failed")
30
+ return data;
31
+ if (hasCurveArrays(data))
32
+ return data;
33
+ try {
34
+ const log = (await apiCall("GET", `/v1/results/${job_id}/training-log`));
35
+ return {
36
+ ...data,
37
+ ordering_errors: log.ordering_errors ?? data.ordering_errors,
38
+ convergence_errors: log.convergence_errors ?? data.convergence_errors,
39
+ ordering_topographic_errors: log.ordering_topographic_errors ?? data.ordering_topographic_errors,
40
+ convergence_topographic_errors: log.convergence_topographic_errors ?? data.convergence_topographic_errors,
41
+ quantization_error: log.quantization_error ?? data.quantization_error,
42
+ topographic_error: log.topographic_error ?? data.topographic_error,
43
+ grid: log.grid ?? data.grid,
44
+ model: log.model ?? data.model,
45
+ epochs: log.epochs ?? data.epochs,
46
+ n_samples: log.n_samples ?? data.n_samples,
47
+ n_features: log.n_features ?? data.n_features,
48
+ };
49
+ }
50
+ catch {
51
+ return data;
52
+ }
53
+ }
18
54
  export function registerTrainingMonitorTool(server) {
19
55
  registerAppTool(server, "training_monitor", {
20
56
  title: "Training Monitor",
21
- description: "Optional embedded or standalone view of training progress for a job_id. When embedded in a client that supports MCP Apps, the panel auto-refreshes every 5s until the job completes. A standalone browser URL is also available. Same fields are available via jobs(action=status)—not required to complete training or read results.",
57
+ description: "Optional embedded or standalone view of training progress for a job_id. When embedded in a client that supports MCP Apps, the panel auto-refreshes every 5s until the job completes: live QE/TE learning curves plus a per-epoch hit-distribution heatmap (sampled BMU counts across the map, showing where observations are landing as training proceeds). A standalone browser URL is also available. The same scalar fields are available via jobs(action=status)—not required to complete training or read results.",
22
58
  inputSchema: {
23
59
  job_id: z.string().describe("Training job ID to monitor"),
60
+ fetch_training_log: z
61
+ .boolean()
62
+ .optional()
63
+ .describe("Internal: merge completed-job training-log arrays when live progress is empty"),
24
64
  },
25
65
  _meta: { ui: { resourceUri: TRAINING_MONITOR_URI } },
26
66
  }, async (args) => runMcpToolAudit("training_monitor", "default", args, async () => {
27
- const job_id = String(args.job_id);
28
- const data = (await apiCall("GET", `/v1/jobs/${job_id}`));
67
+ const { job_id, fetch_training_log } = args;
68
+ let data = (await apiCall("GET", `/v1/jobs/${job_id}`));
69
+ const jobStatus = String(data.status ?? "");
70
+ const terminal = jobStatus === "completed" || jobStatus === "failed";
71
+ if (fetch_training_log || (terminal && !hasCurveArrays(data))) {
72
+ data = await enrichWithTrainingLog(job_id, data);
73
+ }
29
74
  const structuredContent = buildStructuredContent(job_id, data);
30
- const status = String(data.status ?? "unknown");
75
+ const status = jobStatus;
31
76
  const progress = (data.progress ?? 0) * 100;
32
77
  const etaSec = data.training_eta_sec != null ? Number(data.training_eta_sec) : null;
33
78
  const elapsedSec = data.training_elapsed_sec != null ? Number(data.training_elapsed_sec) : null;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
3
3
  import { registerAuditedTool, runMcpToolAudit } from "../audit.js";
4
4
  import { apiCall, fetchTrainingGuidanceFromApi, getClientSupportsMcpApps, getVizPort, pollUntilComplete, POLL_DERIVE_MAX_MS, structuredTextResult, } from "../shared.js";
5
- import { buildTrainMapParams, fetchTrainingPresets, } from "./training_core.js";
5
+ import { buildTrainMapParams, buildFloopParams, fetchTrainingConfig, } from "./training_core.js";
6
6
  import { attachTrainingReviewPayload, consumeTrainingReview, getTrainingReview, storeTrainingReview, } from "./training_review_store.js";
7
7
  export const TRAINING_PREP_URI = "ui://barsom/training-prep";
8
8
  /**
@@ -162,20 +162,131 @@ function buildGuidanceLines(guidanceText) {
162
162
  }
163
163
  return [...new Set(lines)];
164
164
  }
165
- async function buildTrainingPrepPayload(datasetId, input) {
166
- const [preview, analyze, presets, guidanceText, preparePrompt] = await Promise.all([
165
+ function buildSuggestedNormalizationMethods(analyze) {
166
+ const hints = analyze.normalization_hints;
167
+ if (!hints || typeof hints !== "object")
168
+ return undefined;
169
+ const out = {};
170
+ for (const [col, hint] of Object.entries(hints)) {
171
+ if (hint === "mad" || hint === "sepd") {
172
+ out[col] = hint;
173
+ }
174
+ }
175
+ return Object.keys(out).length > 0 ? out : undefined;
176
+ }
177
+ function buildSuggestedTransforms(analyze) {
178
+ const hints = analyze.normalization_hints;
179
+ if (!hints || typeof hints !== "object")
180
+ return undefined;
181
+ const out = {};
182
+ for (const [col, hint] of Object.entries(hints)) {
183
+ if (hint === "log_then_zscore") {
184
+ out[col] = "log";
185
+ }
186
+ }
187
+ return Object.keys(out).length > 0 ? out : undefined;
188
+ }
189
+ /** Prep action -> (entitlement job type, review store action, display label). */
190
+ const PREP_ACTION_META = {
191
+ map: { jobType: "train_som", storeAction: "train_map", label: "Map (standard SOM)" },
192
+ siom_map: { jobType: "train_siom", storeAction: "train_siom", label: "SIOM map" },
193
+ impute: { jobType: "train_impute", storeAction: "train_impute", label: "Impute (sparse → map + imputed.csv)" },
194
+ floop_siom: { jobType: "train_floop_siom", storeAction: "train_floop_siom", label: "FLooP-SIOM (growing manifold)" },
195
+ };
196
+ function buildTrainActionOptions(allowedJobTypes) {
197
+ const allowed = new Set(allowedJobTypes);
198
+ // map is always available (train_som is the baseline entitlement); gate siom/impute.
199
+ return Object.keys(PREP_ACTION_META)
200
+ .filter((action) => action === "map" || allowed.size === 0 || allowed.has(PREP_ACTION_META[action].jobType))
201
+ .map((action) => ({ value: action, label: PREP_ACTION_META[action].label }));
202
+ }
203
+ /**
204
+ * Apply the action-specific job-type + knobs onto the params built by
205
+ * buildTrainMapParams (mirrors the train tool's runTrain for map/siom/impute).
206
+ */
207
+ function applyPrepActionParams(action, params, input) {
208
+ if (action === "siom_map") {
209
+ params._job_type = "train_siom";
210
+ }
211
+ else if (action === "impute") {
212
+ params._job_type = "train_impute";
213
+ params.model = input.model ?? "auto";
214
+ params.cv_folds = 5;
215
+ // impute does not accept these (mirror train.ts) and rejects sepd (warned separately).
216
+ delete params.categorical_features;
217
+ delete params.auto_log_transforms;
218
+ delete params.time_delay_embeddings;
219
+ }
220
+ }
221
+ /** Effective per-column normalization method, mirroring the worker/auto policy. */
222
+ function resolveColumnNormalization(column, normalize, methods, isCyclic) {
223
+ if (isCyclic)
224
+ return "cyclic_encoding";
225
+ if (methods && methods[column])
226
+ return methods[column];
227
+ if (typeof normalize === "string") {
228
+ if (normalize === "mad" || normalize === "sigmoidal" || normalize === "sepd")
229
+ return normalize;
230
+ return "zscore"; // auto / all -> z-score on non-cyclic numerics
231
+ }
232
+ return "zscore";
233
+ }
234
+ function normalizationDetail(method, stat, staging) {
235
+ const fmt = (v) => (v === null || v === undefined ? "?" : Number(v).toFixed(3));
236
+ const nulls = staging?.null_count ?? stat?.null_count;
237
+ const nullBit = nulls !== undefined && nulls !== null ? ` [nulls=${nulls}]` : "";
238
+ switch (method) {
239
+ case "zscore":
240
+ return `z-score · mean=${fmt(stat?.mean)}, std=${fmt(stat?.std)}${nullBit}`;
241
+ case "mad":
242
+ return `MAD (robust to heavy tails) · min=${fmt(stat?.min)}, max=${fmt(stat?.max)}${nullBit}`;
243
+ case "sigmoidal":
244
+ return `sigmoidal (logistic squashing of mean=${fmt(stat?.mean)}, std=${fmt(stat?.std)})${nullBit}`;
245
+ case "sepd":
246
+ return `SEPD (skewed exponential power; for skewed financial/policy columns)${nullBit}`;
247
+ case "none":
248
+ return `none (raw values)${nullBit}`;
249
+ case "cyclic_encoding":
250
+ return `cyclic (encoded as cos/sin, not separately normalized)`;
251
+ default:
252
+ return `${method}${nullBit}`;
253
+ }
254
+ }
255
+ function buildNormalizationPlan(selectedColumns, params, preview, analyze) {
256
+ const statByColumn = new Map((preview.column_stats ?? []).map((s) => [String(s.column ?? ""), s]));
257
+ const stagingStats = analyze.column_staging_stats ?? {};
258
+ const methods = params.normalization_methods;
259
+ const cyclicNames = new Set((params.cyclic_features ?? []).map((c) => c.feature));
260
+ return selectedColumns.map((column) => {
261
+ const method = resolveColumnNormalization(column, params.normalize, methods, cyclicNames.has(column));
262
+ return {
263
+ column,
264
+ method,
265
+ detail: normalizationDetail(method, statByColumn.get(column), stagingStats[column]),
266
+ };
267
+ });
268
+ }
269
+ async function buildTrainingPrepPayload(datasetId, input, trainAction) {
270
+ const [preview, analyze, config, guidanceText, preparePrompt] = await Promise.all([
167
271
  apiCall("GET", `/v1/datasets/${datasetId}/preview?n_rows=5`),
168
272
  fetchAnalyzeData(datasetId),
169
- fetchTrainingPresets().catch(() => ({})),
273
+ fetchTrainingConfig().catch(() => ({ presets: {}, allowedJobTypes: [] })),
170
274
  fetchTrainingGuidanceFromApi().catch(() => "No training guidance available."),
171
275
  fetchPreparePrompt(datasetId),
172
276
  ]);
277
+ const presets = config.presets;
173
278
  const selectedColumns = input.columns?.length
174
279
  ? input.columns
175
280
  : getRecommendedTrainColumns(preview, analyze);
176
281
  const resolvedInput = { ...input, columns: selectedColumns };
177
- const { params } = buildTrainMapParams(resolvedInput, presets);
178
- const review = storeTrainingReview(datasetId, params, "train_map");
282
+ const isFloop = trainAction === "floop_siom";
283
+ const params = isFloop
284
+ ? buildFloopParams(resolvedInput).params
285
+ : buildTrainMapParams(resolvedInput, presets).params;
286
+ if (!isFloop)
287
+ applyPrepActionParams(trainAction, params, resolvedInput);
288
+ const normalizationPlan = buildNormalizationPlan(selectedColumns, params, preview, analyze);
289
+ const review = storeTrainingReview(datasetId, params, PREP_ACTION_META[trainAction].storeAction);
179
290
  const recommendations = analyze.recommendations ?? {};
180
291
  const projectLater = recommendations.project_later ?? [];
181
292
  const transforms = Object.entries(params.transforms ?? {})
@@ -194,7 +305,25 @@ async function buildTrainingPrepPayload(datasetId, input) {
194
305
  const epochs = getEpochFields(params);
195
306
  const grid = params.grid ?? [];
196
307
  const warnings = buildWarnings(preview, selectedColumns, params, analyze);
308
+ if (trainAction === "impute") {
309
+ const sepdCols = normalizationPlan.filter((n) => n.method === "sepd").map((n) => n.column);
310
+ if (params.normalize === "sepd" || sepdCols.length > 0) {
311
+ warnings.push(`SEPD normalization is not allowed for impute (train(impute) rejects sepd)${sepdCols.length ? `: ${sepdCols.join(", ")}` : ""}. Switch these columns to mad/zscore before submit.`);
312
+ }
313
+ }
314
+ const suggestedNormalizationMethods = buildSuggestedNormalizationMethods(analyze);
315
+ const suggestedTransforms = buildSuggestedTransforms(analyze);
197
316
  const recommendationLines = buildDataRecommendations(analyze);
317
+ if (suggestedNormalizationMethods) {
318
+ recommendationLines.push("Suggested normalization_methods (confirm before submit): " +
319
+ JSON.stringify(suggestedNormalizationMethods) +
320
+ " — see training_guidance for MAD vs SEPD ladder. train(impute) rejects sepd.");
321
+ }
322
+ if (suggestedTransforms) {
323
+ recommendationLines.push("Suggested transforms before normalization (confirm before submit): " +
324
+ JSON.stringify(suggestedTransforms) +
325
+ " — rank is train-only (no inference); not for train(impute).");
326
+ }
198
327
  const guidanceLines = buildGuidanceLines(guidanceText);
199
328
  const datasetName = String(preview.name ?? datasetId);
200
329
  const sections = [
@@ -208,6 +337,15 @@ async function buildTrainingPrepPayload(datasetId, input) {
208
337
  ? [...projectLater, "(These columns are good candidates for post-training projection onto the map.)"]
209
338
  : ["None"],
210
339
  },
340
+ {
341
+ label: "Preprocessing / Normalization (per column)",
342
+ items: normalizationPlan.length > 0
343
+ ? [
344
+ ...normalizationPlan.map((n) => `${n.column}: ${n.method} — ${n.detail}`),
345
+ "Tweak via normalization_methods (per column) or a global normalize; changing these reruns preprocessing (new recipe) on the next submit.",
346
+ ]
347
+ : ["No training columns resolved yet."],
348
+ },
211
349
  {
212
350
  label: "Transformations",
213
351
  items: transforms.length > 0 ? transforms : ["None"],
@@ -218,14 +356,25 @@ async function buildTrainingPrepPayload(datasetId, input) {
218
356
  },
219
357
  {
220
358
  label: "Hyperparameters",
221
- items: [
222
- `Model: ${String(params.model ?? "SOM")}`,
223
- `Grid: ${grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "auto"}`,
224
- `Epochs: ${epochs.ordering || "auto"}${epochs.convergence ? ` + ${epochs.convergence}` : ""}`,
225
- `Backend: ${String(params.backend ?? "auto")}`,
226
- `Normalize: ${Array.isArray(params.normalize) ? params.normalize.join(", ") : String(params.normalize ?? "auto")}`,
227
- `Output: ${String(params.output_format ?? "png")}`,
228
- ],
359
+ items: isFloop
360
+ ? [
361
+ `Variant: FLooP-SIOM (growing manifold no fixed grid/epochs)`,
362
+ `Topology: ${String(params.topology ?? "free")}`,
363
+ `Max nodes: ${params.max_nodes !== undefined ? String(params.max_nodes) : "auto (~2·√n_samples)"}`,
364
+ `Effort: ${String(params.effort ?? "standard")}`,
365
+ params.gamma !== undefined ? `Gamma: ${String(params.gamma)}` : "",
366
+ params.siom_decay !== undefined ? `SIOM decay: ${String(params.siom_decay)}` : "",
367
+ `Normalize: ${Array.isArray(params.normalize) ? params.normalize.join(", ") : String(params.normalize ?? "auto")}`,
368
+ `Output: ${String(params.output_format ?? "png")}`,
369
+ ].filter(Boolean)
370
+ : [
371
+ `Model: ${String(params.model ?? "SOM")}`,
372
+ `Grid: ${grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "auto"}`,
373
+ `Epochs: ${epochs.ordering || "auto"}${epochs.convergence ? ` + ${epochs.convergence}` : ""}`,
374
+ `Backend: ${String(params.backend ?? "auto")}`,
375
+ `Normalize: ${Array.isArray(params.normalize) ? params.normalize.join(", ") : String(params.normalize ?? "auto")}`,
376
+ `Output: ${String(params.output_format ?? "png")}`,
377
+ ],
229
378
  },
230
379
  ];
231
380
  const port = getVizPort();
@@ -237,8 +386,11 @@ async function buildTrainingPrepPayload(datasetId, input) {
237
386
  reviewId: review.reviewId,
238
387
  reviewToken: review.reviewToken,
239
388
  reviewExpiresAt: new Date(review.expiresAt).toISOString(),
389
+ trainAction,
240
390
  selectedColumns,
241
391
  suggestedProjectionColumns: projectLater,
392
+ normalizationPlan,
393
+ suggestedNormalizationMethods,
242
394
  warnings,
243
395
  recommendations: recommendationLines,
244
396
  guidance: guidanceLines,
@@ -253,6 +405,12 @@ async function buildTrainingPrepPayload(datasetId, input) {
253
405
  gridY: grid.length >= 2 ? String(grid[1]) : "",
254
406
  epochsOrdering: epochs.ordering,
255
407
  epochsConvergence: epochs.convergence,
408
+ trainAction,
409
+ columns: selectedColumns.join(", "),
410
+ normalizationMethodsJson: params.normalization_methods
411
+ ? JSON.stringify(params.normalization_methods)
412
+ : "",
413
+ transformsJson: params.transforms ? JSON.stringify(params.transforms) : "",
256
414
  },
257
415
  options: {
258
416
  presetOptions: Object.keys(presets).map((value) => ({ value, label: value.replace("_", " ") })),
@@ -273,6 +431,7 @@ async function buildTrainingPrepPayload(datasetId, input) {
273
431
  { value: "SOM-SOFT", label: "SOM-SOFT" },
274
432
  { value: "RSOM-SOFT", label: "RSOM-SOFT" },
275
433
  ],
434
+ trainActionOptions: buildTrainActionOptions(config.allowedJobTypes),
276
435
  },
277
436
  },
278
437
  draftArgs: {
@@ -284,9 +443,12 @@ async function buildTrainingPrepPayload(datasetId, input) {
284
443
  },
285
444
  summaryLines: [
286
445
  `Dataset: ${datasetName}`,
446
+ `Action: ${PREP_ACTION_META[trainAction].label}`,
287
447
  `Variables: ${selectedColumns.length > 0 ? selectedColumns.join(", ") : "Not selected"}`,
288
- `Grid: ${grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "auto"}`,
289
- `Model: ${String(params.model ?? "SOM")} | Backend: ${String(params.backend ?? "auto")} | Output: ${String(params.output_format ?? "png")}`,
448
+ isFloop
449
+ ? `Topology: ${String(params.topology ?? "free")} | Max nodes: ${params.max_nodes !== undefined ? String(params.max_nodes) : "auto"} | Effort: ${String(params.effort ?? "standard")}`
450
+ : `Grid: ${grid.length >= 2 ? `${grid[0]}×${grid[1]}` : "auto"} | Model: ${String(params.model ?? "SOM")} | Backend: ${String(params.backend ?? "auto")} | Output: ${String(params.output_format ?? "png")}`,
451
+ `Normalization: ${normalizationPlan.length > 0 ? normalizationPlan.map((n) => `${n.column}→${n.method}`).join(", ") : "auto"}`,
290
452
  ],
291
453
  standaloneUrl,
292
454
  };
@@ -294,14 +456,25 @@ async function buildTrainingPrepPayload(datasetId, input) {
294
456
  return payload;
295
457
  }
296
458
  function buildTrainingPrepContent(payload) {
459
+ const normalizationBlock = payload.normalizationPlan.length > 0
460
+ ? [
461
+ "",
462
+ "Preprocessing / normalization plan (per column — review before submit):",
463
+ ...payload.normalizationPlan.map((n) => ` • ${n.column}: ${n.method} — ${n.detail}`),
464
+ "To change: re-call training_prep with normalization_methods (per column) and/or normalize/transforms; that reruns preprocessing on submit. Show this to the user and confirm before submit_prepared_training.",
465
+ ]
466
+ : [];
297
467
  const content = [
298
468
  {
299
469
  type: "text",
300
470
  text: [
301
471
  `Training prep ready for ${payload.datasetName} (${payload.datasetId}).`,
302
- `Review ID: ${payload.reviewId}`,
472
+ `Review ID: ${payload.reviewId} | Review token: ${payload.reviewToken ?? "(none)"}`,
303
473
  ...payload.summaryLines,
304
474
  payload.warnings.length > 0 ? `Warnings: ${payload.warnings.join(" | ")}` : "",
475
+ ...normalizationBlock,
476
+ "",
477
+ `When the user has confirmed, submit with submit_prepared_training(review_token="${payload.reviewToken ?? ""}", explicit_confirm=true).`,
305
478
  ].filter(Boolean).join("\n"),
306
479
  },
307
480
  ];
@@ -316,6 +489,8 @@ function buildTrainingPrepContent(payload) {
316
489
  export function registerTrainingPrepTools(server) {
317
490
  const trainPrepSchema = {
318
491
  dataset_id: z.string().describe("Dataset ID to prepare for training"),
492
+ train_action: z.enum(["map", "siom_map", "impute", "floop_siom"]).optional().default("map")
493
+ .describe("Which training mode to prepare: map (standard SOM), siom_map (self-interacting), impute (sparse → map + imputed.csv), or floop_siom (growing node-budget manifold). Filtered to your plan's allowed_job_types."),
319
494
  preset: z.enum(["quick", "standard", "refined", "high_res"]).optional(),
320
495
  grid_x: z.number().int().optional(),
321
496
  grid_y: z.number().int().optional(),
@@ -337,7 +512,8 @@ export function registerTrainingPrepTools(server) {
337
512
  feature_weights: z.record(z.number()).optional(),
338
513
  transforms: z.record(z.enum(["log", "log1p", "log10", "sqrt", "square", "abs", "invert", "rank", "none"])).optional(),
339
514
  auto_log_transforms: z.boolean().optional().default(false),
340
- normalize: z.union([z.enum(["all", "auto", "mad", "sigmoidal"]), z.array(z.string())]).optional().default("auto"),
515
+ normalize: z.union([z.enum(["all", "auto", "mad", "sigmoidal", "sepd"]), z.array(z.string())]).optional().default("auto"),
516
+ normalization_methods: z.record(z.enum(["zscore", "mad", "sigmoidal", "sepd", "none"])).optional(),
341
517
  batch_size: z.number().int().optional(),
342
518
  backend: z.enum(["auto", "cpu", "gpu", "gpu_graphs"]).optional().default("auto"),
343
519
  output_format: z.enum(["png", "pdf", "svg"]).optional().default("png"),
@@ -345,14 +521,28 @@ export function registerTrainingPrepTools(server) {
345
521
  siom_feature_geometry: z.enum(["l2", "mixed", "auto"]).optional(),
346
522
  siom_qe_backend: z.enum(["cpu", "cuda", "auto"]).optional(),
347
523
  siom_qe_batch_size: z.number().int().min(1).max(1_000_000).optional(),
524
+ // FLooP-SIOM (train_action=floop_siom) — growing manifold; no fixed grid/epochs.
525
+ topology: z.enum(["free", "chain"]).optional(),
526
+ max_nodes: z.number().int().min(2).optional(),
527
+ effort: z.enum(["quick", "standard", "thorough"]).optional(),
528
+ n_initial: z.number().int().min(2).optional(),
529
+ n_passes: z.number().int().min(1).optional(),
530
+ growth_interval: z.number().int().min(1).optional(),
531
+ gamma: z.number().optional(),
532
+ siom_decay: z.number().optional(),
533
+ siom_penalty: z.enum(["linear", "log", "exp"]).optional(),
534
+ penalty_alpha: z.number().optional(),
535
+ elastic_lambda: z.number().optional(),
536
+ elastic_mu: z.number().optional(),
537
+ elastic_anchor: z.number().optional(),
348
538
  };
349
539
  registerAppTool(server, "training_prep", {
350
540
  title: "Training Preparation",
351
- description: "Interactive training prep UI + guarded submit (submit_prepared_training). Prep ladder: prepare_training prompt = narrative checklist; training_guidance tool = JSON/presets; this tool = visual review. Opens inline review of variables, transforms, encodings, and hyperparameters.",
541
+ description: "Interactive training prep UI + guarded submit (submit_prepared_training). Shows the RESOLVED decisions before a job is committed: per-column normalization (z-score / MAD / SEPD / sigmoidal) with its stat, transforms, encodings, grid/epochs/model. Tweak normalization_methods, transforms, columns, or train_action and re-call to iterate preprocessing before submitting. Prep ladder: prepare_training prompt = narrative checklist; training_guidance tool = JSON/presets; this tool = visual review + guarded submit.",
352
542
  inputSchema: trainPrepSchema,
353
543
  _meta: { ui: { resourceUri: TRAINING_PREP_URI } },
354
- }, async ({ dataset_id, ...args }) => runMcpToolAudit("training_prep", "default", { dataset_id, ...args }, async () => {
355
- const payload = await buildTrainingPrepPayload(dataset_id, args);
544
+ }, async ({ dataset_id, train_action, ...args }) => runMcpToolAudit("training_prep", "default", { dataset_id, train_action, ...args }, async () => {
545
+ const payload = await buildTrainingPrepPayload(dataset_id, args, (train_action ?? "map"));
356
546
  return structuredTextResult(payload, undefined, buildTrainingPrepContent(payload));
357
547
  }));
358
548
  registerAuditedTool(server, "submit_prepared_training", "Submit a previously reviewed training-prep configuration. Requires an unexpired review token and explicit confirmation.", {