formlab-mcp 0.6.4 → 0.6.5

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
@@ -19,6 +19,7 @@ Ask Claude (or another MCP client) things like:
19
19
  - _"What parameters fail most often in Q2 testing?"_
20
20
  - _"Show me the DOE matrix at sample grain for all 'Anti-aging serum' family formulas."_
21
21
  - _"What's untested? Which of my approved formulas have no measurements yet?"_
22
+ - _"Map the design space of my WallPaint project — which ingredients vary, and what combination haven't I tried yet?"_
22
23
  - _"List all peptide ingredients with purity above 95%."_
23
24
  - _"Which botanicals do I source from Madagascar?"_
24
25
  - _"Find ingredients where the sequence contains KTTKS."_
@@ -43,7 +44,8 @@ Ask Claude (or another MCP client) things like:
43
44
  | `get_test_result` | Full report: every measurement's value, spec, and the **resolved instrument** (`instrumentSource`: row / method / run) |
44
45
  | `get_doe_matrix` | Pivot matrix (CSV by default) — rows × ingredients × parameters |
45
46
  | `find_failures` | Pareto-style: parameters that fail acceptance most often |
46
- | `get_coverage_matrix` | Which formulations × parameters have been measured |
47
+ | `get_coverage_matrix` | Which formulations × parameters have been measured (TEST coverage) |
48
+ | `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?"* |
47
49
  | `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` |
48
50
  | `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 |
49
51
  | `find_by_smarts` | SMARTS-pattern substructure search across every ingredient with a SMILES. 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). |
@@ -143,7 +145,7 @@ Or for local-dev (from the repo):
143
145
  }
144
146
  ```
145
147
 
146
- Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 32 tools become callable in any conversation.
148
+ Restart Claude Desktop. You should see a hammer icon indicating tools are available, and FormLab's 33 tools become callable in any conversation.
147
149
 
148
150
  ## Wire it up to Claude Code
149
151
 
package/SUBMISSION.md CHANGED
@@ -47,8 +47,10 @@ _"what parameters fail acceptance most often?"_, or _"show me the DOE matrix
47
47
  at sample grain for Anti-Aging Serum family"_ — all against a JSON export
48
48
  that stays on the user's laptop.
49
49
 
50
- **15 read-only tools** covering formulations, ingredients, batches, samples,
51
- test results, DOE matrices, similarity, failure analysis, coverage matrices.
50
+ **33 read-only tools** covering formulations, ingredients, batches, samples,
51
+ test results, DOE matrices + saved designs, similarity, failure analysis,
52
+ test-coverage AND composition design-space mapping (varying ingredients,
53
+ untested gaps, PCA), equipment, test methods, panels, projects and ELN notes.
52
54
 
53
55
  - Repo: https://github.com/juliu1980/FormLab/tree/main/mcp
54
56
  - npm: https://www.npmjs.com/package/formlab-mcp
package/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // FORMLAB_EXPORT=/path/to/export.json node index.js
8
8
  // formlab-mcp /path/to/export.json (when installed via npm)
9
9
  //
10
- // Exposes 32 tools (see ./tools/) over stdio. The MCP host (Claude
10
+ // Exposes 33 tools (see ./tools/) over stdio. The MCP host (Claude
11
11
  // Desktop, Claude Code, etc.) handles tool discovery, invocation
12
12
  // and response formatting. We just register handlers and stay out
13
13
  // of the way.
@@ -30,6 +30,7 @@ import * as formulations from './tools/formulations.js';
30
30
  import * as ingredients from './tools/ingredients.js';
31
31
  import * as lab from './tools/lab.js';
32
32
  import * as analytics from './tools/analytics.js';
33
+ import * as designspace from './tools/designspace.js';
33
34
  import * as library from './tools/library.js';
34
35
  import * as projects from './tools/projects.js';
35
36
  import * as notebook from './tools/notebook.js';
@@ -108,6 +109,7 @@ const TOOLS = [
108
109
  ...Object.values(ingredients.tools),
109
110
  ...Object.values(lab.tools),
110
111
  ...Object.values(analytics.tools),
112
+ ...Object.values(designspace.tools),
111
113
  ...Object.values(library.tools),
112
114
  ...Object.values(projects.tools),
113
115
  ...Object.values(notebook.tools),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab — 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.5.2",
5
+ "version": "0.6.5",
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.5.2",
16
+ "version": "0.6.5",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -508,12 +508,16 @@ const _DOE_TYPE_LABELS = {
508
508
  function _doeModelKey(d) {
509
509
  return d && d.designType === 'dopt' ? ((d.settings || {}).doptModel || null) : null;
510
510
  }
511
+ // A DOE factor is keyed by ingredientId, or by formulationId when it's a
512
+ // sub-formula dosed as a base. Both factors[] and runs[] use that id.
513
+ function _doeFactorId(f) { return f.ingredientId || f.formulationId; }
511
514
  // Factor names come off the RECORD first so a design still reads correctly
512
515
  // after an ingredient is renamed or deleted.
513
516
  function _doeNamer(d, db) {
514
- const byId = new Map((d.factors || []).map(f => [f.ingredientId, f.name]));
517
+ const byId = new Map((d.factors || []).map(f => [_doeFactorId(f), f.name]));
515
518
  return (id) => byId.get(id)
516
519
  || (((db.ingredients || []).find(i => i.id === id) || {}).name)
520
+ || (((db.formulations || []).find(x => x.id === id) || {}).name)
517
521
  || id || '?';
518
522
  }
519
523
  function _trimDoeDesign(d, db) {
@@ -528,7 +532,7 @@ function _trimDoeDesign(d, db) {
528
532
  project: proj ? proj.name : null,
529
533
  runCount: d.runCount || 0,
530
534
  factorCount: (d.factors || []).length,
531
- factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(f.ingredientId), low: f.low, high: f.high, unit: f.unit || '%' })),
535
+ factors: (d.factors || []).map(f => ({ ingredient: f.name || nameOf(_doeFactorId(f)), low: f.low, high: f.high, unit: f.unit || '%' })),
532
536
  constraints: (d.constraints || []).map(c => _doeConstraintText(c, nameOf)),
533
537
  mixtureMode: !!d.mixtureMode,
534
538
  // The model the design was built for. Lives in settings, so without this a
@@ -663,10 +667,10 @@ const get_doe_design = {
663
667
  }
664
668
  if (args.include_runs !== false) {
665
669
  const facs = d.factors || [];
666
- out.factorOrder = facs.map(f => f.name || nameOf(f.ingredientId));
670
+ out.factorOrder = facs.map(f => f.name || nameOf(_doeFactorId(f)));
667
671
  out.runs = (d.runs || []).map((r, i) => {
668
672
  const row = { run: i + 1 };
669
- facs.forEach(f => { row[f.name || nameOf(f.ingredientId)] = r[f.ingredientId]; });
673
+ facs.forEach(f => { row[f.name || nameOf(_doeFactorId(f))] = r[_doeFactorId(f)]; });
670
674
  const lf = formsById.get((d.formulationIds || [])[i]);
671
675
  row.formulation = lf ? (lf.name || lf.uid) : null;
672
676
  return row;
@@ -0,0 +1,448 @@
1
+ // ============================================================
2
+ // DESIGN-SPACE TOOL — the COMPOSITION-space counterpart to
3
+ // get_coverage_matrix (which reports TEST coverage).
4
+ //
5
+ // get_design_space — per project: which ingredients vary and over
6
+ // what observed ranges, a summary of the occupied
7
+ // region ("where you've been"), the biggest
8
+ // untested INTERIOR gap expressed as ingredient
9
+ // ranges (ready to seed a DOE), and — optionally —
10
+ // a standardised 2-component PCA of ALL varying
11
+ // ingredients (loadings + variance-explained + a
12
+ // full-dimensional gap).
13
+ //
14
+ // This is a faithful server-side port of the in-app Design Space Viewer
15
+ // (js/views/assembler-designspace.js, shipped v1.15.128–145). The numeric
16
+ // algorithms are copied verbatim so the MCP answer and the on-screen map
17
+ // always agree:
18
+ // - variation ranking ← _asmDoeGenFactorCandidates (doegen)
19
+ // - observed per-ingredient range ← _asmDoeGenObservedRange (doegen)
20
+ // - top-3/top-2 interior gap ← _asmDsComputeGap
21
+ // - full-dimensional PCA gap ← _asmDsComputeGapPCA
22
+ // - standardised 2-component PCA ← _asmDsPCA
23
+ // - convex hull + point-in-poly ← _asmDsHull / _asmDsPointInPoly
24
+ //
25
+ // Read-only: it computes over the same DB export every other tool reads;
26
+ // it never mutates anything.
27
+ // ============================================================
28
+
29
+ import { getStore, resolveById, flattenComposition } from '../data.js';
30
+
31
+ // ---- composition helper -----------------------------------------------------
32
+ // Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
33
+ // - a pure-% flat composition (no sub-formulas, every row wt%/%) is taken
34
+ // VERBATIM (preserves the common "omit the balance-to-100 row" case);
35
+ // - anything else (absolute units, mixed, or nested) is flattened to true
36
+ // wt-% via the shared flatten engine, so a leaf reached through a premix
37
+ // still counts. Returns Map<ingredientId, percent 0–100>.
38
+ function _ingredientPctMap(f) {
39
+ const out = new Map();
40
+ if (!f) return out;
41
+ const comp = (f.composition || []).filter(c => c && (c.ingredientId || c.formulationId));
42
+ const hasSub = comp.some(c => c.formulationId);
43
+ const allPct = !hasSub && comp.every(c => {
44
+ const u = String(c.unit || 'wt%').trim().toLowerCase();
45
+ return u.startsWith('wt') || u === '%';
46
+ });
47
+ if (allPct) {
48
+ for (const c of comp) {
49
+ if (!c.ingredientId) continue;
50
+ const amt = parseFloat(c.amount);
51
+ out.set(c.ingredientId, (out.get(c.ingredientId) || 0) + (isNaN(amt) ? 0 : amt));
52
+ }
53
+ return out;
54
+ }
55
+ const flat = flattenComposition(f); // rows carry weightFraction (0–1)
56
+ for (const r of flat.rows) {
57
+ if (!r.ingredientId) continue;
58
+ const pct = (r.weightFraction || 0) * 100;
59
+ out.set(r.ingredientId, (out.get(r.ingredientId) || 0) + pct);
60
+ }
61
+ // Strip float-precision noise (0.105 + 0.18 = 0.28500000000000003).
62
+ for (const [k, v] of out) out.set(k, parseFloat(v.toFixed(6)));
63
+ return out;
64
+ }
65
+
66
+ // _asmFormulaValueForCol(f, 'ing:'+id) equivalent: the ingredient's wt-% in f,
67
+ // or null when absent (callers coerce null → 0 for the geometry).
68
+ function _ingVal(mapsByForm, f, id) {
69
+ const m = mapsByForm.get(f.id);
70
+ const v = m ? m.get(id) : undefined;
71
+ return (v == null || !Number.isFinite(v)) ? null : v;
72
+ }
73
+
74
+ // ---- variation ranking (port of _asmDoeGenFactorCandidates, ingredients only) ---
75
+ // A factor is only worth mapping if the data shows it already varies. Score the
76
+ // rest by proportional swing (relative spread) weighted by how broadly they're
77
+ // used, so a pigment ranging 8–14% across many formulas outranks a preservative
78
+ // pinned at 0.2%. Ingredient-only (the design-space view calls with bases off).
79
+ function _factorCandidates(forms, mapsByForm, db) {
80
+ const byId = new Map();
81
+ forms.forEach(f => {
82
+ const map = mapsByForm.get(f.id);
83
+ if (!map) return;
84
+ map.forEach((v, id) => {
85
+ if (v == null || !Number.isFinite(v)) return;
86
+ if (!byId.has(id)) byId.set(id, []);
87
+ byId.get(id).push(v);
88
+ });
89
+ });
90
+ const ingName = new Map((db.ingredients || []).map(i => [i.id, i.name]));
91
+ const cand = [];
92
+ const scoreOf = (amounts) => {
93
+ const min = Math.min(...amounts), max = Math.max(...amounts);
94
+ const spread = max - min;
95
+ if (spread <= 0.01) return null; // effectively constant — not a factor
96
+ const mean = amounts.reduce((a, b) => a + b, 0) / amounts.length;
97
+ const rel = mean > 0 ? spread / mean : 0;
98
+ return { obs: { min, max, mean, n: amounts.length }, spread, rel, score: rel * Math.log2(1 + amounts.length) };
99
+ };
100
+ byId.forEach((amounts, id) => {
101
+ if (amounts.length < 2) return; // need ≥2 uses to see a range
102
+ const name = ingName.get(id);
103
+ if (!name) return; // ingredient since deleted
104
+ const s = scoreOf(amounts);
105
+ if (s) cand.push({ id, name, ...s });
106
+ });
107
+ cand.sort((a, b) => b.score - a.score);
108
+ return cand;
109
+ }
110
+
111
+ // Observed [min,max,mean,n] for one ingredient across the WHOLE library (global,
112
+ // exactly like _asmDoeGenObservedRange — the PCA gap clamps against it).
113
+ function _observedRange(ingredientId, db, mapsByForm) {
114
+ if (!ingredientId) return null;
115
+ const amounts = [];
116
+ (db.formulations || []).forEach(f => {
117
+ if (!f || f._trashed) return;
118
+ const m = mapsByForm.get(f.id) || _ingredientPctMap(f);
119
+ if (!mapsByForm.has(f.id)) mapsByForm.set(f.id, m);
120
+ const v = m.get(ingredientId);
121
+ if (v != null && Number.isFinite(v)) amounts.push(v);
122
+ });
123
+ if (!amounts.length) return null;
124
+ return {
125
+ min: Math.min(...amounts),
126
+ max: Math.max(...amounts),
127
+ mean: amounts.reduce((a, b) => a + b, 0) / amounts.length,
128
+ n: amounts.length,
129
+ };
130
+ }
131
+
132
+ // ---- geometry (verbatim ports) ---------------------------------------------
133
+ // Andrew's monotone-chain convex hull. Input/return: [{x,y}].
134
+ function _hull(pts) {
135
+ const P = pts.slice().sort((a, b) => a.x - b.x || a.y - b.y);
136
+ if (P.length < 3) return P;
137
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
138
+ const lower = [];
139
+ for (const p of P) {
140
+ while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
141
+ lower.push(p);
142
+ }
143
+ const upper = [];
144
+ for (let i = P.length - 1; i >= 0; i--) {
145
+ const p = P[i];
146
+ while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
147
+ upper.push(p);
148
+ }
149
+ lower.pop(); upper.pop();
150
+ return lower.concat(upper);
151
+ }
152
+
153
+ // Ray-casting point-in-polygon.
154
+ function _pointInPoly(x, y, poly) {
155
+ if (!poly || poly.length < 3) return false;
156
+ let inside = false;
157
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
158
+ const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
159
+ if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-12) + xi)) inside = !inside;
160
+ }
161
+ return inside;
162
+ }
163
+
164
+ // ---- top-K interior gap (port of _asmDsComputeGap) --------------------------
165
+ // The emptiest reachable spot in ABSOLUTE per-ingredient space + the DOE ranges
166
+ // that bracket it. Working per-axis on each ingredient's own observed range (NOT
167
+ // a normalised simplex) is essential: components can differ 10× in scale, and a
168
+ // normalised gap would seed physically silly levels. `comps` = top-2 or top-3
169
+ // candidates. Returns { mode, abs, factors, interior } or null.
170
+ function _computeGap(mode, comps, forms, mapsByForm) {
171
+ const K = comps.length;
172
+ const pts = [];
173
+ forms.forEach(f => {
174
+ const comp = comps.map(c => { const v = _ingVal(mapsByForm, f, c.id); return v == null ? 0 : v; });
175
+ if (comp.every(v => v === 0)) return;
176
+ pts.push(comp);
177
+ });
178
+ if (pts.length < 4) return null; // too few points to trust a "gap"
179
+ const mn = comps.map((_, k) => Math.min(...pts.map(p => p[k])));
180
+ const mx = comps.map((_, k) => Math.max(...pts.map(p => p[k])));
181
+ const span = comps.map((_, k) => Math.max(1e-6, mx[k] - mn[k]));
182
+ const norm = pts.map(p => p.map((v, k) => (v - mn[k]) / span[k])); // unit-cube coords
183
+ const M = 0.05;
184
+ const steps = 12, lo = -M, hiU = 1 + M;
185
+ const g = (i) => lo + (hiU - lo) * i / steps;
186
+ const grid = [];
187
+ if (K === 3) { for (let i = 0; i <= steps; i++) for (let j = 0; j <= steps; j++) for (let l = 0; l <= steps; l++) grid.push([g(i), g(j), g(l)]); }
188
+ else { for (let i = 0; i <= steps; i++) for (let j = 0; j <= steps; j++) grid.push([g(i), g(j)]); }
189
+ const dist = (u) => { let dmin = Infinity; for (const n of norm) { let d = 0; for (let k = 0; k < K; k++) { const dd = n[k] - u[k]; d += dd * dd; } if (d < dmin) dmin = d; } return dmin; };
190
+ // "Bracketed" = real formulas sit on BOTH sides on every axis — a genuine
191
+ // INTERIOR void, not a frontier extrapolation.
192
+ const bracketed = (u) => {
193
+ for (let k = 0; k < K; k++) {
194
+ let below = false, above = false;
195
+ for (const n of norm) { if (n[k] < u[k] - 1e-9) below = true; else if (n[k] > u[k] + 1e-9) above = true; if (below && above) break; }
196
+ if (!(below && above)) return false;
197
+ }
198
+ return true;
199
+ };
200
+ // For the 2-D case, ALSO require the gap inside the (shrunk) hull, so it can't
201
+ // sit outside a slanted/correlated cloud.
202
+ let insideHull = () => true;
203
+ if (K === 2) {
204
+ const hull = _hull(norm.map(p => ({ x: p[0], y: p[1] })));
205
+ if (hull.length >= 3) {
206
+ let hcx = 0, hcy = 0; hull.forEach(p => { hcx += p.x; hcy += p.y; }); hcx /= hull.length; hcy /= hull.length;
207
+ const sh = hull.map(p => ({ x: hcx + (p.x - hcx) * 0.85, y: hcy + (p.y - hcy) * 0.85 }));
208
+ insideHull = (u) => _pointInPoly(u[0], u[1], sh);
209
+ }
210
+ }
211
+ let best = null, bestD = -1, interior = false;
212
+ for (const u of grid) { if (!bracketed(u) || !insideHull(u)) continue; const d = dist(u); if (d > bestD) { bestD = d; best = u; interior = true; } }
213
+ if (!best) { for (const u of grid) { const d = dist(u); if (d > bestD) { bestD = d; best = u; } } }
214
+ if (!best) return null;
215
+ const abs = comps.map((_, k) => {
216
+ const t = mn[k] + best[k] * span[k];
217
+ return Math.min(mx[k] + M * span[k], Math.max(Math.max(0, mn[k] - M * span[k]), t));
218
+ });
219
+ const factors = comps.map((c, k) => {
220
+ const hw = Math.max(0.25, span[k] * 0.3); // a real design width, not a point
221
+ return { id: c.id, low: Math.max(0, +(abs[k] - hw).toFixed(3)), high: +(abs[k] + hw).toFixed(3), unit: '%' };
222
+ });
223
+ return { mode, abs, factors, interior };
224
+ }
225
+
226
+ // ---- standardised 2-component PCA (port of _asmDsPCA) -----------------------
227
+ // Each ingredient z-scored first so a 14% filler and a 0.9% dispersant get equal
228
+ // say. Power iteration + deflation (no libraries), fixed seed → deterministic.
229
+ function _pca(forms, ids, mapsByForm) {
230
+ const n = ids.length; if (n < 2) return null;
231
+ const rows = [];
232
+ forms.forEach(f => { const v = ids.map(id => { const x = _ingVal(mapsByForm, f, id); return x == null ? 0 : x; }); rows.push({ f, v }); });
233
+ const m = rows.length; if (m < 3) return null;
234
+ const mean = ids.map((_, j) => rows.reduce((s, r) => s + r.v[j], 0) / m);
235
+ const std = ids.map((_, j) => { const mu = mean[j]; const varj = rows.reduce((s, r) => s + (r.v[j] - mu) ** 2, 0) / Math.max(1, m - 1); return Math.sqrt(varj) || 1; });
236
+ const Z = rows.map(r => r.v.map((x, j) => (x - mean[j]) / std[j]));
237
+ const C = Array.from({ length: n }, () => new Array(n).fill(0));
238
+ for (let a = 0; a < n; a++) for (let b = a; b < n; b++) { let s = 0; for (let i = 0; i < m; i++) s += Z[i][a] * Z[i][b]; s /= Math.max(1, m - 1); C[a][b] = s; C[b][a] = s; }
239
+ const matVec = (Mx, x) => Mx.map(row => row.reduce((s, e, k) => s + e * x[k], 0));
240
+ const normalize = (x) => { const l = Math.sqrt(x.reduce((s, e) => s + e * e, 0)) || 1; return x.map(e => e / l); };
241
+ const power = (Mx) => { let v = normalize(ids.map((_, i) => Math.sin(i + 1.3))); let lam = 0; for (let it = 0; it < 90; it++) { const wv = matVec(Mx, v); const l = Math.sqrt(wv.reduce((s, e) => s + e * e, 0)) || 1; const vn = wv.map(e => e / l); let diff = 0; for (let k = 0; k < n; k++) diff += Math.abs(vn[k] - v[k]); v = vn; lam = l; if (diff < 1e-9) break; } return { vec: v, val: lam }; };
242
+ const K = Math.min(n, 6);
243
+ const comps = []; let Mx = C;
244
+ for (let c = 0; c < K; c++) { const pc = power(Mx); comps.push(pc); Mx = Mx.map((row, a) => row.map((e, b) => e - pc.val * pc.vec[a] * pc.vec[b])); }
245
+ const pc1 = comps[0], pc2 = comps[1] || comps[0];
246
+ const trace = C.reduce((s, row, a) => s + row[a], 0) || 1;
247
+ const varAll = comps.map(pc => Math.max(0, pc.val) / trace);
248
+ const project = (vabs) => { const z = vabs.map((x, j) => (x - mean[j]) / std[j]); return [z.reduce((s, e, k) => s + e * pc1.vec[k], 0), z.reduce((s, e, k) => s + e * pc2.vec[k], 0)]; };
249
+ const invProject = (x, y) => ids.map((_, k) => mean[k] + (x * pc1.vec[k] + y * pc2.vec[k]) * std[k]);
250
+ const scores = rows.map(r => project(r.v));
251
+ return { ids, rows, scores, project, invProject, load1: pc1.vec, load2: pc2.vec, varAll, var1: varAll[0] || 0, var2: varAll[1] || 0 };
252
+ }
253
+
254
+ // The ingredients each PC is "made of", biggest |weight| first, with sign.
255
+ function _topLoadings(pca, which, count, db) {
256
+ const vec = which === 1 ? pca.load1 : pca.load2;
257
+ const nm = new Map((db.ingredients || []).map(i => [i.id, i.name]));
258
+ return pca.ids.map((id, k) => ({ ingredient: nm.get(id) || id, id, weight: +vec[k].toFixed(4) }))
259
+ .sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight)).slice(0, count || 5);
260
+ }
261
+
262
+ // ---- full-dimensional PCA gap (port of _asmDsComputeGapPCA) -----------------
263
+ // The emptiest INTERIOR spot in the standardised PCA plane, mapped back to real
264
+ // ingredient levels and clamped to each ingredient's observed range so the
265
+ // seeded DOE stays realistic. Returns { score, factors, interior } or null.
266
+ function _computeGapPCA(pca, db, mapsByForm) {
267
+ const scores = pca.scores; if (scores.length < 4) return null;
268
+ const xs = scores.map(s => s[0]), ys = scores.map(s => s[1]);
269
+ const xMin = Math.min(...xs), xMax = Math.max(...xs), yMin = Math.min(...ys), yMax = Math.max(...ys);
270
+ const sx = (xMax - xMin) || 1, sy = (yMax - yMin) || 1, G = 22;
271
+ const dist = (x, y) => { let mn = Infinity; for (const s of scores) { const dx = (s[0] - x) / sx, dy = (s[1] - y) / sy; const dd = dx * dx + dy * dy; if (dd < mn) mn = dd; } return mn; };
272
+ const hull = _hull(scores.map(s => ({ x: s[0], y: s[1] })));
273
+ let hcx = 0, hcy = 0; hull.forEach(p => { hcx += p.x; hcy += p.y; }); hcx /= (hull.length || 1); hcy /= (hull.length || 1);
274
+ const shrunk = hull.map(p => ({ x: hcx + (p.x - hcx) * 0.82, y: hcy + (p.y - hcy) * 0.82 }));
275
+ const scan = (poly) => { let b = null, bd = -1; for (let i = 0; i <= G; i++) for (let j = 0; j <= G; j++) { const x = xMin + sx * i / G, y = yMin + sy * j / G; if (poly && !_pointInPoly(x, y, poly)) continue; const dd = dist(x, y); if (dd > bd) { bd = dd; b = [x, y]; } } return b; };
276
+ let best = scan(shrunk), interior = !!best;
277
+ if (!best) { best = scan(hull); interior = false; }
278
+ if (!best) { best = scan(null); interior = false; }
279
+ if (!best) return null;
280
+ const abs = pca.invProject(best[0], best[1]);
281
+ const factors = pca.ids.map((id, k) => { const obs = _observedRange(id, db, mapsByForm) || { min: 0, max: 100 }; const hw = Math.max(0.25, (obs.max - obs.min) * 0.3); const t = Math.min(obs.max + hw, Math.max(0, abs[k])); return { id, low: Math.max(0, +(t - hw).toFixed(3)), high: +(t + hw).toFixed(3), unit: '%' }; });
282
+ return { score: best, factors, interior };
283
+ }
284
+
285
+ // ---- eligibility: projects worth mapping (≥3 formulas) ----------------------
286
+ function _eligibleProjects(db) {
287
+ const counts = new Map();
288
+ (db.formulations || []).forEach(f => { if (f && !f._trashed && f.projectId) counts.set(f.projectId, (counts.get(f.projectId) || 0) + 1); });
289
+ const out = [];
290
+ (db.projects || []).forEach(p => { const n = counts.get(p.id) || 0; if (n >= 3) out.push({ id: p.id, name: p.name || p.id, n }); });
291
+ out.sort((a, b) => b.n - a.n);
292
+ return out;
293
+ }
294
+
295
+ const get_design_space = {
296
+ definition: {
297
+ 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.',
299
+ inputSchema: {
300
+ type: 'object',
301
+ 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).' },
303
+ 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
+ loadings_per_component: { type: 'number', description: 'How many top ingredient loadings to list per principal component. Default 5, max 20.' },
305
+ },
306
+ },
307
+ },
308
+ handler: async (args) => {
309
+ const { db } = getStore();
310
+ const norm = (s) => String(s || '').toLowerCase();
311
+
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}".` };
319
+ } 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);
323
+ }
324
+ const pid = proj.id;
325
+ const forms = (db.formulations || []).filter(f => f && !f._trashed && f.projectId === pid);
326
+
327
+ // Precompute ingredient maps once (reused by ranking, gap, PCA, observed range).
328
+ const mapsByForm = new Map(forms.map(f => [f.id, _ingredientPctMap(f)]));
329
+
330
+ const out = {
331
+ project: { id: pid, uid: proj.uid || null, name: proj.name || pid, formulaCount: forms.length },
332
+ notes: [],
333
+ };
334
+ 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.');
336
+ }
337
+
338
+ // Varying ingredients + observed ranges (ranked).
339
+ const cand = _factorCandidates(forms, mapsByForm, db);
340
+ out.varyingIngredientCount = cand.length;
341
+ out.varyingIngredients = cand.map(c => ({
342
+ ingredient: c.name,
343
+ id: c.id,
344
+ observedRange: { min: +c.obs.min.toFixed(4), max: +c.obs.max.toFixed(4), mean: +c.obs.mean.toFixed(4), unit: '%', usedInFormulas: c.obs.n },
345
+ relativeSpread: +c.rel.toFixed(4),
346
+ variationScore: +c.score.toFixed(4),
347
+ }));
348
+
349
+ if (cand.length < 2) {
350
+ out.notes.push(`Only ${cand.length} ingredient${cand.length === 1 ? '' : 's'} vary across this project — a space needs at least 2 that change from formula to formula. Nothing to map yet.`);
351
+ out.axes = null;
352
+ out.occupiedRegion = null;
353
+ out.gap = null;
354
+ return out;
355
+ }
356
+
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);
360
+ out.axes = {
361
+ 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),
366
+ };
367
+
368
+ // Occupied-region summary: the bounding envelope of the plotted axes + how
369
+ // many formulas actually sit in the space. The app draws the convex hull of
370
+ // 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++; });
373
+ out.occupiedRegion = {
374
+ formulaPoints: inSpace,
375
+ axisRanges: comps.map(c => ({ ingredient: c.name, id: c.id, min: +c.obs.min.toFixed(4), max: +c.obs.max.toFixed(4), unit: '%' })),
376
+ 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
+ };
378
+
379
+ // The top untested interior gap, as ready-to-seed DOE ranges.
380
+ const gap = _computeGap(mode, comps, forms, mapsByForm);
381
+ if (!gap) {
382
+ out.gap = null;
383
+ out.notes.push('Too few distinct formula points (need ≥4 with these axes) to locate a trustworthy gap.');
384
+ } else {
385
+ out.gap = {
386
+ interior: gap.interior,
387
+ basis: `${mode === 'ternary' ? 'top-3' : 'top-2'} varying ingredients`,
388
+ target: comps.map((c, k) => ({
389
+ ingredient: c.name,
390
+ id: c.id,
391
+ center: +gap.abs[k].toFixed(3),
392
+ low: gap.factors[k].low,
393
+ high: gap.factors[k].high,
394
+ unit: '%',
395
+ })),
396
+ note: gap.interior
397
+ ? 'Interior void: real formulas bracket this point on every axis — a combination you could have made but skipped. The low/high ranges are ready to seed a DOE.'
398
+ : 'Frontier point (fallback): the cluster was too thin to bracket a true interior void, so this is the farthest-from-any-formula point — an extrapolation; sanity-check before running.',
399
+ };
400
+ }
401
+
402
+ // Optional: PCA over ALL varying ingredients.
403
+ const wantPca = args.include_pca !== false;
404
+ if (wantPca && cand.length >= 3 && forms.length >= 3) {
405
+ const ids = cand.map(c => c.id);
406
+ const pca = _pca(forms, ids, mapsByForm);
407
+ if (pca) {
408
+ const nLoad = Math.min(20, Math.max(1, args.loadings_per_component || 5));
409
+ const varPct = Math.round((pca.var1 + pca.var2) * 100);
410
+ const pcaOut = {
411
+ ingredientCount: ids.length,
412
+ varianceExplained: { pc1: +pca.var1.toFixed(4), pc2: +pca.var2.toFixed(4), cumulative: +((pca.var1 + pca.var2)).toFixed(4) },
413
+ components: [
414
+ { component: 1, varianceExplained: +pca.var1.toFixed(4), loadings: _topLoadings(pca, 1, nLoad, db) },
415
+ { component: 2, varianceExplained: +pca.var2.toFixed(4), loadings: _topLoadings(pca, 2, nLoad, db) },
416
+ ],
417
+ scree: pca.varAll.map((v, i) => ({ component: i + 1, varianceExplained: +v.toFixed(4) })),
418
+ note: 'Axes are ingredient combinations, not single ingredients — read the loadings to interpret them. + weight means the ingredient increases toward that axis\'s positive end. Each ingredient is standardised (z-scored) first, so a trace dispersant and a bulk filler get equal say.',
419
+ };
420
+ if (varPct < 60) pcaOut.warning = `PC1+PC2 hold only ${varPct}% of the variation — a rough overview; structure may live in components not shown (see scree).`;
421
+ const gapPca = _computeGapPCA(pca, db, mapsByForm);
422
+ if (gapPca) {
423
+ pcaOut.gap = {
424
+ interior: gapPca.interior,
425
+ dimensionality: 'full (all varying ingredients)',
426
+ target: gapPca.factors.map(fac => {
427
+ const c = cand.find(x => x.id === fac.id);
428
+ return { ingredient: c ? c.name : fac.id, id: fac.id, low: fac.low, high: fac.high, unit: '%' };
429
+ }),
430
+ note: gapPca.interior
431
+ ? 'Full-dimensional interior void: emptiest spot inside the occupied PCA cloud, mapped back to real ingredient levels and clamped to each ingredient\'s observed range. Ready to seed a DOE across all varying ingredients.'
432
+ : 'Frontier fallback (not a bracketed interior void) — sanity-check before running.',
433
+ };
434
+ }
435
+ out.pca = pcaOut;
436
+ }
437
+ } else if (wantPca) {
438
+ out.notes.push(`PCA needs ≥3 varying ingredients and ≥3 formulas (have ${cand.length} varying, ${forms.length} formulas) — skipped.`);
439
+ }
440
+
441
+ if (!out.notes.length) delete out.notes;
442
+ return out;
443
+ },
444
+ };
445
+
446
+ export const tools = {
447
+ get_design_space,
448
+ };