formlab-mcp 0.6.34 → 0.6.36

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
@@ -52,7 +52,7 @@ Ask Claude (or another MCP client) things like:
52
52
  | `get_batch_pivot` | Production analytics — aggregate batches by `group_by` (formula / status / month / prepared_by / project) × `metric` (count, avg_yield_pct, total_produced_kg, avg_cost_per_batch, total_samples, total_tests), with share + total for additive metrics. Optional `batch_uids` scope + `status` filter |
53
53
  | `get_project_pivot` | Portfolio analytics — aggregate projects by `group_by` (status / phase / priority / business_unit / site / customer / lead, or `cf:<custom field>`) × `metric` (count, total_formulas / batches / samples, total_cost, avg_formulas / batches / cost_per_batch), rolling child activity + cost up by any project attribute. Optional `project_uids` scope |
54
54
  | `get_stability` | Grounded stability / shelf-life analysis for a formulation, sample or batch × parameter (auto-detected): time-series, drift (per-month slope + R²), an I-chart (mean ±3σ + out-of-control points), spec status, and an ICH-Q1E-flavored projected shelf life (point + 95%-CI crossing), all on **one point per test date per run condition** (replicates averaged, never separate time points), split by storage condition |
55
- | `get_design_space` | COMPOSITION design space for one project: which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
55
+ | `get_design_space` | COMPOSITION design space for one project, several (`projects`) or any formula list (`formulas`, across projects — like the app's Scope → DOE Matrix): which ingredients vary and over what observed wt-% ranges (ranked), an occupied-region summary ("where you've been"), the biggest untested **interior gap** as ready-to-seed DOE ranges (a combination you could have made but skipped), and an optional standardised **2-component PCA** of all varying ingredients — loadings, variance-explained, scree, and a full-dimensional gap. Optional `axes` (choose the 2–3 axes), `by_role` (map ingredients summed by primary Function, e.g. Pigment / Solvent / Film Former — no gap, roles aren't DOE factors) and `property` (highest / lowest formula plus the ingredients whose level tracks it, Spearman ρ). Reports formulas that aren't on the map and the spread on it. Matches the in-app Design Space Viewer exactly. Answers *"where haven't I explored?"* / *"what should I formulate next?"* |
56
56
  | `list_doe_designs` | Saved DOE designs — the *recipe* behind each batch of runs: design type, factors + ranges, constraints, run count, D-efficiency. Filters: `project`, `design_type`, `constrained_only`, `name_contains` |
57
57
  | `get_doe_design` | One design in full: constraints in plain language, the model it was optimised for, generation settings (run budget, replicates, centre points, seed) and the complete run matrix with the formulation each run became |
58
58
  | `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES; peptides that only store a sequence are searched with the structure derived from it, as the app draws them (`smilesDerived: true`). Requires `@rdkit/rdkit` (optional dependency — install with `npm install @rdkit/rdkit` in `mcp/` if you get a "not installed" error). Examples: `c1ccccc1` (any aromatic 6-ring), `[OX2H1]` (any hydroxyl), `C(=O)O` (carboxylic acid), `[F,Cl,Br,I]` (any halogen). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.34",
3
+ "version": "0.6.36",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
4
4
  "description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
5
- "version": "0.6.34",
5
+ "version": "0.6.36",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
7
  "repository": {
8
8
  "url": "https://github.com/juliu1980/FormLab",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "formlab-mcp",
16
- "version": "0.6.34",
16
+ "version": "0.6.36",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -226,7 +226,7 @@ const get_test_result = {
226
226
  const get_doe_matrix = {
227
227
  definition: {
228
228
  name: 'get_doe_matrix',
229
- description: 'Build a Design-of-Experiments style pivot matrix: rows = formulations (or samples / batches / tests), columns = composition wt-% per ingredient + aggregated test-parameter values. Same shape as the FormLab DOE Matrix view. A parameter measured under two or more RUN CONDITIONS (e.g. storage 25 °C vs 40 °C) is one column per condition — "pH · 40 °C" — and the bare parameter column is the base arm (readings with no condition, else the condition with the most readings); they are different responses, never averaged together. Returns CSV when format=csv (default) for compact LLM consumption, or structured JSON when format=json.',
229
+ description: 'Build a Design-of-Experiments style pivot matrix: rows = formulations (or samples / batches / tests), columns = composition wt-% per ingredient + aggregated test-parameter values. Same shape as the FormLab DOE Matrix view. A parameter measured under two or more RUN CONDITIONS (e.g. storage 25 °C vs 40 °C) is one column per condition — "pH · 40 °C" — and the bare parameter column is the base arm (readings with no condition, else the condition with the most readings, on a tie the one declared as the default on the Test Method / Test Panel); they are different responses, never averaged together. Returns CSV when format=csv (default) for compact LLM consumption, or structured JSON when format=json.',
230
230
  inputSchema: {
231
231
  type: 'object',
232
232
  properties: {
@@ -453,7 +453,34 @@ function _mcpCondTags(conds) {
453
453
  const lead = tags.map(t => t.split(/\s+[\/·]\s+/)[0]);
454
454
  return (new Set(lead).size === lead.length) ? lead : tags;
455
455
  }
456
- // items: [{ parameter, m }] → Map(parameter → Map(condSig → column name)),
456
+ // Is this reading taken under the DEFAULT run condition its Test Method or
457
+ // Test Panel declares? Port of the app's flCondDefaultScore (js/utils.js):
458
+ // the tiebreak for the primary arm when two arms have equally long records,
459
+ // instead of the condition's name ("25 °C" sorting before "40 °C").
460
+ function _mcpCondDefaultScore(m, t, db) {
461
+ const c = m && m.conditions;
462
+ if (!c || typeof c !== 'object') return 0;
463
+ const decls = [];
464
+ const ps = db.parameters || [];
465
+ const nm = String(m.parameter || '').trim().toLowerCase();
466
+ const method = (m.parameterId && ps.find(p => p && p.id === m.parameterId)) || (nm ? ps.find(p => p && String(p.name || '').trim().toLowerCase() === nm) : null);
467
+ if (method && Array.isArray(method.conditions)) decls.push(...method.conditions);
468
+ const tmpl = t && t.templateId ? (db.templates || []).find(x => x && x.id === t.templateId) : null;
469
+ if (tmpl && Array.isArray(tmpl.conditions)) decls.push(...tmpl.conditions);
470
+ const lc = {};
471
+ Object.keys(c).forEach(k => { lc[k.toLowerCase()] = c[k]; });
472
+ let matched = 0;
473
+ for (const d of decls) {
474
+ if (!d || !d.key || d.default == null || d.default === '') continue;
475
+ const v = lc[String(d.key).toLowerCase()];
476
+ if (v == null || v === '') continue;
477
+ if (String(v).trim() !== String(d.default).trim()) return 0;
478
+ matched++;
479
+ }
480
+ return matched ? 1 : 0;
481
+ }
482
+
483
+ // items: [{ parameter, m, test? }] → Map(parameter → Map(condSig → column name)),
457
484
  // only for parameters with two or more arms.
458
485
  function _mcpArmNames(items) {
459
486
  const arms = new Map();
@@ -461,14 +488,26 @@ function _mcpArmNames(items) {
461
488
  const sig = _mcpCondSig(it.m);
462
489
  if (!arms.has(it.parameter)) arms.set(it.parameter, new Map());
463
490
  const a = arms.get(it.parameter);
464
- const cur = a.get(sig) || { n: 0, m: it.m };
491
+ const cur = a.get(sig) || { n: 0, m: it.m, t: it.test || null };
465
492
  cur.n++; a.set(sig, cur);
466
493
  }
467
494
  const out = new Map();
495
+ let testOf = null; // measurement → its report, built only if a tie needs a panel default
496
+ const score = (arm) => {
497
+ const { db } = getStore();
498
+ let t = arm.t;
499
+ if (!t) {
500
+ if (!testOf) { testOf = new Map(); (db.testResults || []).forEach(r => (r.measurements || []).forEach(x => testOf.set(x, r))); }
501
+ t = testOf.get(arm.m) || null;
502
+ }
503
+ return _mcpCondDefaultScore(arm.m, t, db);
504
+ };
468
505
  arms.forEach((a, param) => {
469
506
  if (a.size < 2) return;
470
507
  const keys = [...a.keys()];
471
- const base = a.has('') ? '' : keys.slice().sort((x, y) => (a.get(y).n - a.get(x).n) || x.localeCompare(y))[0];
508
+ // No condition wins outright; among conditioned arms, most readings, then
509
+ // the declared default condition, then by name — as in the app.
510
+ const base = a.has('') ? '' : keys.slice().sort((x, y) => (a.get(y).n - a.get(x).n) || (score(a.get(y)) - score(a.get(x))) || x.localeCompare(y))[0];
472
511
  const ordered = [base, ...keys.filter(k => k !== base).sort((x, y) => x.localeCompare(y))];
473
512
  const tags = _mcpCondTags(ordered.map(k => k ? _mcpCondLabel(a.get(k).m) : ''));
474
513
  out.set(param, new Map(ordered.map((k, i) => [k, i === 0 ? param : `${param} · ${tags[i]}`])));
@@ -1243,7 +1282,7 @@ const get_stability = {
1243
1282
  },
1244
1283
  };
1245
1284
 
1246
- export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames };
1285
+ export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
1247
1286
  export const tools = {
1248
1287
  list_test_results,
1249
1288
  get_test_result,
@@ -292,14 +292,87 @@ function _eligibleProjects(db) {
292
292
  return out;
293
293
  }
294
294
 
295
+ // ---- scope, roles, property insights (ports of the app's build 26p–26s) -----
296
+
297
+ // Resolve a list of formula ids / UIDs / names (exact name first, then substring).
298
+ function _resolveFormulas(list, db) {
299
+ const norm = (s) => String(s || '').toLowerCase();
300
+ const out = [], missing = [];
301
+ (list || []).forEach(q => {
302
+ const f = resolveById('formulations', q)
303
+ || (db.formulations || []).find(x => x && norm(x.name) === norm(q))
304
+ || (db.formulations || []).find(x => x && norm(x.name).includes(norm(q)));
305
+ if (f && !f._trashed) { if (!out.includes(f)) out.push(f); } else missing.push(q);
306
+ });
307
+ return { forms: out, missing };
308
+ }
309
+ function _resolveProject(q, db) {
310
+ const norm = (s) => String(s || '').toLowerCase();
311
+ return resolveById('projects', q)
312
+ || (db.projects || []).find(p => norm(p.name) === norm(q))
313
+ || (db.projects || []).find(p => norm(p.name).includes(norm(q)));
314
+ }
315
+ // Primary Function role per ingredient ("Pigment|Opacifier" → "Pigment").
316
+ function _roleMap(db) {
317
+ const m = new Map();
318
+ (db.ingredients || []).forEach(i => { const r = i && i.function ? String(i.function).split('|')[0].trim() : ''; if (r) m.set(i.id, r); });
319
+ return m;
320
+ }
321
+ // Per-formula role totals (wt% summed by role), as a map shaped like the
322
+ // ingredient maps so the same geometry helpers work on it (keys 'role:<name>').
323
+ function _roleMaps(forms, mapsByForm, db) {
324
+ const rm = _roleMap(db), out = new Map();
325
+ forms.forEach(f => {
326
+ const m = mapsByForm.get(f.id) || new Map(), t = new Map();
327
+ m.forEach((v, id) => { const r = rm.get(id); if (r && Number.isFinite(v)) t.set('role:' + r, (t.get('role:' + r) || 0) + v); });
328
+ out.set(f.id, t);
329
+ });
330
+ return out;
331
+ }
332
+ // Roles that vary, ranked by ABSOLUTE spread (the app's _asmDsRoleCands) — the
333
+ // big building blocks first, not a small role that swings a lot relatively.
334
+ function _roleCandidates(forms, roleMaps) {
335
+ const by = new Map();
336
+ forms.forEach(f => (roleMaps.get(f.id) || new Map()).forEach((v, id) => { if (!by.has(id)) by.set(id, []); by.get(id).push(v); }));
337
+ const out = [];
338
+ by.forEach((a, id) => {
339
+ if (a.length < 2) return;
340
+ const min = Math.min(...a), max = Math.max(...a), spread = max - min;
341
+ if (spread <= 0.01) return;
342
+ const mean = a.reduce((x, y) => x + y, 0) / a.length;
343
+ out.push({ id, name: id.slice(5), role: true, obs: { min, max, mean, n: a.length }, spread, rel: mean > 0 ? spread / mean : 0, score: spread });
344
+ });
345
+ return out.sort((a, b) => b.score - a.score);
346
+ }
347
+ // A formula's value for a measured parameter: the mean of every numeric
348
+ // reading of it across the formula's samples (the app's _asmFormulaValueForCol).
349
+ function _paramValue(f, param, db) {
350
+ const sids = new Set((db.samples || []).filter(s => s.formulationId === f.id).map(s => s.id));
351
+ const vals = [];
352
+ (db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) { const v = parseFloat(m.value); if (!isNaN(v)) vals.push(v); } }); });
353
+ return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
354
+ }
355
+ function _spearman(xs, ys) {
356
+ const rank = (a) => { const idx = a.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]); const r = new Array(a.length); let i = 0; while (i < idx.length) { let j = i; while (j + 1 < idx.length && idx[j + 1][0] === idx[i][0]) j++; const avg = (i + j) / 2 + 1; for (let k = i; k <= j; k++) r[idx[k][1]] = avg; i = j + 1; } return r; };
357
+ const rx = rank(xs), ry = rank(ys), n = xs.length;
358
+ const mx = rx.reduce((a, b) => a + b, 0) / n, my = ry.reduce((a, b) => a + b, 0) / n;
359
+ let sxx = 0, syy = 0, sxy = 0; for (let i = 0; i < n; i++) { const dx = rx[i] - mx, dy = ry[i] - my; sxx += dx * dx; syy += dy * dy; sxy += dx * dy; }
360
+ return (sxx && syy) ? sxy / Math.sqrt(sxx * syy) : null;
361
+ }
362
+
295
363
  const get_design_space = {
296
364
  definition: {
297
365
  name: 'get_design_space',
298
- description: 'Map a project\'s COMPOSITION design space (the descriptive mirror of the DOE designer, and the composition-space counterpart to get_coverage_matrix\'s TEST coverage). For one project it returns: the ingredients that actually VARY across its formulas + their observed wt-% ranges (ranked by proportional swing); an occupied-region summary ("where you\'ve been"); the biggest untested INTERIOR gap — a combination you could have made but skipped, kept inside the occupied cloud — expressed as ready-to-seed DOE ingredient ranges (low/high %); and, when the project has ≥3 varying ingredients, a standardised 2-component PCA of ALL of them: per-axis ingredient loadings, variance-explained, a scree list, and a full-dimensional gap. Numbers match FormLab\'s in-app Design Space Viewer exactly. Use it to answer "where haven\'t I explored?" / "what should I formulate next?" and to hand a chemist an experiment target. Read-only.',
366
+ description: 'Map a project\'s COMPOSITION design space (the descriptive mirror of the DOE designer, and the composition-space counterpart to get_coverage_matrix\'s TEST coverage). For one project it returns: the ingredients that actually VARY across its formulas + their observed wt-% ranges (ranked by proportional swing); an occupied-region summary ("where you\'ve been"); the biggest untested INTERIOR gap — a combination you could have made but skipped, kept inside the occupied cloud — expressed as ready-to-seed DOE ingredient ranges (low/high %); and, when the project has ≥3 varying ingredients, a standardised 2-component PCA of ALL of them: per-axis ingredient loadings, variance-explained, a scree list, and a full-dimensional gap. Numbers match FormLab\'s in-app Design Space Viewer exactly. Scope can be one project, several (projects), or any formula list (formulas); axes can be chosen (axes) or mapped by ingredient role (by_role); property adds best/worst formula and the ingredient that tracks it. Use it to answer "where haven\'t I explored?" / "what should I formulate next?" and to hand a chemist an experiment target. Read-only.',
299
367
  inputSchema: {
300
368
  type: 'object',
301
369
  properties: {
302
- project: { type: 'string', description: 'Project id, UID, or name (case-insensitive; substring allowed). Omit to auto-pick the most-populated eligible project (≥3 formulas).' },
370
+ project: { type: 'string', description: 'Project id, UID, or name (case-insensitive; substring allowed). Omit (and omit projects/formulas) to auto-pick the most-populated eligible project (≥3 formulas).' },
371
+ projects: { type: 'array', items: { type: 'string' }, description: 'Map several projects together (ids, UIDs or names) — the app\'s cross-project scope. Ingredients most of the formulas share are ranked first.' },
372
+ formulas: { type: 'array', items: { type: 'string' }, description: 'Map exactly these formulas (ids, UIDs or names), from any projects — like the app\'s "Scope → DOE Matrix". Overrides project/projects.' },
373
+ axes: { type: 'array', items: { type: 'string' }, description: 'Choose the 2 or 3 axes yourself (ingredient names/ids, or role names with by_role) instead of the most-varying ones. Each must vary in the scope.' },
374
+ by_role: { type: 'boolean', description: 'Map ROLES instead of single ingredients: each formula\'s ingredients summed by their primary Function (e.g. Pigment / Solvent / Film Former), ranked by absolute spread. No gap is reported (roles are not DOE factors).' },
375
+ property: { type: 'string', description: 'A measured parameter name (e.g. "Gloss @60°"). Adds insights over the formulas on the map: highest and lowest formula, and the varying ingredient whose level tracks it most (Spearman ρ; a correlation, not a cause).' },
303
376
  include_pca: { type: 'boolean', description: 'Include the "map everything" PCA of all varying ingredients (loadings + variance + full-dimensional gap). Default true. Needs ≥3 varying ingredients and ≥3 formulas.' },
304
377
  loadings_per_component: { type: 'number', description: 'How many top ingredient loadings to list per principal component. Default 5, max 20.' },
305
378
  },
@@ -309,34 +382,54 @@ const get_design_space = {
309
382
  const { db } = getStore();
310
383
  const norm = (s) => String(s || '').toLowerCase();
311
384
 
312
- // Resolve the project (explicit pick, else the most-populated eligible one).
313
- let proj = null;
314
- if (args.project) {
315
- proj = resolveById('projects', args.project)
316
- || (db.projects || []).find(p => norm(p.name) === norm(args.project))
317
- || (db.projects || []).find(p => norm(p.name).includes(norm(args.project)));
318
- if (!proj) return { error: `No project matched "${args.project}".` };
385
+ // Resolve the scope: explicit formulas > several projects > one project >
386
+ // the most-populated eligible project.
387
+ let proj = null, forms, scope;
388
+ const notes = [];
389
+ if (Array.isArray(args.formulas) && args.formulas.length) {
390
+ const r = _resolveFormulas(args.formulas, db);
391
+ if (r.missing.length) notes.push(`No formula matched: ${r.missing.join(', ')}.`);
392
+ forms = r.forms;
393
+ scope = { kind: 'formulas' };
394
+ } else if (Array.isArray(args.projects) && args.projects.length) {
395
+ const ps = [];
396
+ for (const q of args.projects) { const p = _resolveProject(q, db); if (!p) return { error: `No project matched "${q}".` }; if (!ps.includes(p)) ps.push(p); }
397
+ const ids = new Set(ps.map(p => p.id));
398
+ forms = (db.formulations || []).filter(f => f && !f._trashed && ids.has(f.projectId));
399
+ scope = { kind: 'projects' };
400
+ if (ps.length === 1) proj = ps[0];
319
401
  } else {
320
- const elig = _eligibleProjects(db);
321
- if (!elig.length) return { error: 'No project has ≥3 formulas to map. Assign at least 3 formulas to a project first.' };
322
- proj = (db.projects || []).find(p => p.id === elig[0].id);
402
+ if (args.project) {
403
+ proj = _resolveProject(args.project, db);
404
+ if (!proj) return { error: `No project matched "${args.project}".` };
405
+ } else {
406
+ const elig = _eligibleProjects(db);
407
+ if (!elig.length) return { error: 'No project has ≥3 formulas to map. Assign at least 3 formulas to a project first.' };
408
+ proj = (db.projects || []).find(p => p.id === elig[0].id);
409
+ }
410
+ forms = (db.formulations || []).filter(f => f && !f._trashed && f.projectId === proj.id);
411
+ scope = { kind: 'project' };
323
412
  }
324
- const pid = proj.id;
325
- const forms = (db.formulations || []).filter(f => f && !f._trashed && f.projectId === pid);
413
+ const projIds = [...new Set(forms.map(f => f.projectId || ''))];
414
+ scope.formulaCount = forms.length;
415
+ scope.projects = projIds.map(id => (((db.projects || []).find(p => p.id === id) || {}).name) || (id ? id : '(no project)'));
326
416
 
327
417
  // Precompute ingredient maps once (reused by ranking, gap, PCA, observed range).
328
418
  const mapsByForm = new Map(forms.map(f => [f.id, _ingredientPctMap(f)]));
329
419
 
330
- const out = {
331
- project: { id: pid, uid: proj.uid || null, name: proj.name || pid, formulaCount: forms.length },
332
- notes: [],
333
- };
420
+ const out = { scope, notes };
421
+ if (proj) out.project = { id: proj.id, uid: proj.uid || null, name: proj.name || proj.id, formulaCount: forms.length };
334
422
  if (forms.length < 3) {
335
- out.notes.push('This project has fewer than 3 formulas — a design space is only coherent with at least 3 points. Results below are provisional.');
423
+ out.notes.push('The scope has fewer than 3 formulas — a design space is only coherent with at least 3 points. Results below are provisional.');
336
424
  }
337
425
 
338
- // Varying ingredients + observed ranges (ranked).
339
- const cand = _factorCandidates(forms, mapsByForm, db);
426
+ // Varying ingredients + observed ranges (ranked). A scope spanning projects
427
+ // ranks ingredients at least half its formulas SHARE first (the app's rule).
428
+ let cand = _factorCandidates(forms, mapsByForm, db);
429
+ if (projIds.length > 1) {
430
+ const half = forms.length / 2;
431
+ cand = cand.filter(c => c.obs.n >= half).concat(cand.filter(c => c.obs.n < half));
432
+ }
340
433
  out.varyingIngredientCount = cand.length;
341
434
  out.varyingIngredients = cand.map(c => ({
342
435
  ingredient: c.name,
@@ -354,31 +447,73 @@ const get_design_space = {
354
447
  return out;
355
448
  }
356
449
 
357
- // Axes: top-3 ternary (relative proportions) or top-2 scatter (absolute %).
358
- const mode = cand.length >= 3 ? 'ternary' : 'scatter';
359
- const comps = cand.slice(0, mode === 'ternary' ? 3 : 2);
450
+ // Axes: by role (pseudo-components) or ingredients; auto (most varying) or
451
+ // chosen. Values for the geometry come from `vmaps` (ingredient or role maps).
452
+ const byRole = !!args.by_role;
453
+ let vmaps = mapsByForm, axCand = cand;
454
+ if (byRole) {
455
+ vmaps = _roleMaps(forms, mapsByForm, db);
456
+ axCand = _roleCandidates(forms, vmaps);
457
+ out.roles = axCand.map(c => ({ role: c.name, observedRange: { min: +c.obs.min.toFixed(4), max: +c.obs.max.toFixed(4), unit: '%' }, usedInFormulas: c.obs.n }));
458
+ if (axCand.length < 2) { out.notes.push('Fewer than 2 ingredient roles vary (set ingredient Functions to use by_role).'); out.axes = null; return out; }
459
+ }
460
+ let comps = null, chosen = false;
461
+ if (Array.isArray(args.axes) && args.axes.length) {
462
+ const find = (q) => axCand.find(c => norm(c.id) === norm(q) || norm(c.name) === norm(q)) || axCand.find(c => norm(c.name).includes(norm(q)));
463
+ const picked = [];
464
+ args.axes.forEach(q => { const c = find(q); if (c && !picked.includes(c)) picked.push(c); });
465
+ if ((picked.length === 3 || picked.length === 2) && picked.length === args.axes.length) { comps = picked; chosen = true; }
466
+ else out.notes.push(`Could not use axes [${args.axes.join(', ')}] — each must be a distinct ${byRole ? 'role' : 'ingredient'} that varies in this scope (2 or 3). Using the most-varying instead.`);
467
+ }
468
+ if (!comps) comps = axCand.slice(0, axCand.length >= 3 ? 3 : 2);
469
+ const mode = comps.length === 3 ? 'ternary' : 'scatter';
470
+ const kind = byRole ? 'roles (ingredients summed by primary Function)' : 'ingredients';
360
471
  out.axes = {
361
472
  mode,
362
- basis: mode === 'ternary'
363
- ? 'Top 3 most-varying ingredients, as relative proportions (a ternary).'
364
- : 'Top 2 most-varying ingredients, as absolute wt-%.',
365
- ingredients: comps.map(c => c.name),
473
+ basis: `${chosen ? 'Chosen' : (mode === 'ternary' ? 'Top 3 most-varying' : 'Top 2 most-varying')} ${kind}, ${mode === 'ternary' ? 'as relative proportions (a ternary: each axis is its share of the three shown, not wt% of the recipe).' : 'as absolute wt-%.'}`,
474
+ [byRole ? 'roles' : 'ingredients']: comps.map(c => c.name),
366
475
  };
367
476
 
368
477
  // Occupied-region summary: the bounding envelope of the plotted axes + how
369
478
  // many formulas actually sit in the space. The app draws the convex hull of
370
479
  // these points; the per-axis min/max here is that hull's bounding box.
371
- let inSpace = 0;
372
- forms.forEach(f => { if (comps.some(c => _ingVal(mapsByForm, f, c.id) != null)) inSpace++; });
480
+ const onMap = forms.filter(f => comps.some(c => (_ingVal(vmaps, f, c.id) || 0) > 0));
481
+ const inSpace = onMap.length;
482
+ if (inSpace < forms.length) out.notes.push(`${forms.length - inSpace} of ${forms.length} formulas contain none of the axes and are not on the map.`);
483
+ const share = onMap.map(f => { const v = comps.map(c => _ingVal(vmaps, f, c.id) || 0); const t = v.reduce((a, b) => a + b, 0) || 1; return mode === 'ternary' ? v.map(x => 100 * x / t) : v; });
373
484
  out.occupiedRegion = {
374
485
  formulaPoints: inSpace,
486
+ spreadOnMap: comps.map((c, i) => { const vs = share.map(r => r[i]); return { axis: c.name, min: +Math.min(...vs).toFixed(2), max: +Math.max(...vs).toFixed(2), unit: mode === 'ternary' ? '% of the three' : '%' }; }),
375
487
  axisRanges: comps.map(c => ({ ingredient: c.name, id: c.id, min: +c.obs.min.toFixed(4), max: +c.obs.max.toFixed(4), unit: '%' })),
376
488
  note: 'Bounding envelope of the observed compositions on the mapped axes. In the app this is the shaded convex hull ("where you\'ve been"); the untested gap below was found INSIDE that occupied cloud, not on its frontier.',
377
489
  };
378
490
 
379
- // The top untested interior gap, as ready-to-seed DOE ranges.
380
- const gap = _computeGap(mode, comps, forms, mapsByForm);
381
- if (!gap) {
491
+ // Property insights over the formulas on the map.
492
+ if (args.property) {
493
+ const vals = onMap.map(f => ({ f, v: _paramValue(f, args.property, db) })).filter(x => x.v != null);
494
+ if (vals.length < 2) out.notes.push(`"${args.property}" is measured on ${vals.length} of the formulas on the map — need at least 2 for insights (check the exact parameter name).`);
495
+ else {
496
+ const hi = vals.reduce((a, b) => b.v > a.v ? b : a), lo = vals.reduce((a, b) => b.v < a.v ? b : a);
497
+ const pi = { property: args.property, basis: 'mean of every reading per formula', formulasMeasured: vals.length,
498
+ highest: { formula: hi.f.name, uid: hi.f.uid || null, value: +hi.v.toFixed(4) },
499
+ lowest: { formula: lo.f.name, uid: lo.f.uid || null, value: +lo.v.toFixed(4) } };
500
+ if (vals.length >= 4) {
501
+ const ranked = [];
502
+ cand.forEach(c => { const xs = vals.map(x => _ingVal(mapsByForm, x.f, c.id) || 0); if (new Set(xs).size < 2) return; const r = _spearman(xs, vals.map(x => x.v)); if (r != null) ranked.push({ ingredient: c.name, rho: +r.toFixed(3) }); });
503
+ ranked.sort((a, b) => Math.abs(b.rho) - Math.abs(a.rho));
504
+ pi.tracksMost = ranked.slice(0, 5);
505
+ pi.note = 'Spearman rank correlation across these formulas — a lead for what to vary, not proof of cause' + (vals.length < 8 ? '; few formulas, so treat it cautiously.' : '.');
506
+ }
507
+ out.propertyInsights = pi;
508
+ }
509
+ }
510
+
511
+ // The top untested interior gap, as ready-to-seed DOE ranges (not for roles:
512
+ // they aren't DOE factors).
513
+ const gap = byRole ? null : _computeGap(mode, comps, forms, mapsByForm);
514
+ if (byRole) {
515
+ out.gap = null;
516
+ } else if (!gap) {
382
517
  out.gap = null;
383
518
  out.notes.push('Too few distinct formula points (need ≥4 with these axes) to locate a trustworthy gap.');
384
519
  } else {