formlab-mcp 0.6.37 → 0.6.39

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
@@ -51,7 +51,7 @@ Ask Claude (or another MCP client) things like:
51
51
  | `compare_batches` | Reproducibility of 2+ runs (ideally of one formula): each run's **yield %**, actual produced mass, cost/kg; per-ingredient **drift** across the runs vs the formula's proposed wt-%; and the biggest **outlier** run. `basis`: `wt_percent` (default) or `amount` |
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
- | `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 |
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. A ranked-scale (ordinal) or text parameter returns its step / pass-fail series instead: per-condition points, firstFail, latest, scaleShift |
55
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.37",
3
+ "version": "0.6.39",
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.37",
5
+ "version": "0.6.39",
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.37",
16
+ "version": "0.6.39",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -164,8 +164,8 @@ const list_test_results = {
164
164
  const found = (t.measurements || []).some(m => {
165
165
  if (args.parameter && !norm(m.parameter).includes(pn)) return false;
166
166
  if (hasRange) {
167
- const v = parseFloat(m.value);
168
- if (!Number.isFinite(v)) return false;
167
+ const v = _mcpRepNumber(m); // a curve's final value, not its point count (13a)
168
+ if (v == null) return false;
169
169
  if (args.value_min != null && v < args.value_min) return false;
170
170
  if (args.value_max != null && v > args.value_max) return false;
171
171
  }
@@ -352,7 +352,9 @@ const get_doe_matrix = {
352
352
  const cellParamValue = (r, col) => {
353
353
  const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
354
354
  if (!ms.length) return null;
355
- const nums = ms.map(m => parseFloat(m.value)).filter(v => isFinite(v));
355
+ // The app's DOE Matrix numbers (13a): a curve's final value, a
356
+ // distribution's D50, an ordinal's 1-based rank; text → its first reading.
357
+ const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
356
358
  if (!nums.length) return ms[0].value ?? null;
357
359
  const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
358
360
  return +mean.toFixed(4);
@@ -405,21 +407,96 @@ const get_doe_matrix = {
405
407
  // so a parameter measured 3× on one occasion counts once in failure rates and
406
408
  // coverage, exactly as Test Analytics does.
407
409
  const FAIL_MIN_N = 5; // app: _TA_FAIL_MIN_N — rates on fewer readings are "low n"
408
- function _mcpRepNumber(m) {
409
- const type = m.type || 'scalar';
410
- if (type === 'time_series' && Array.isArray(m.points) && m.points.length) {
410
+ // One number per measurement — the app's flMeasNum (js/utils.js, 13a), kept
411
+ // in step. Never parseFloat(m.value): a saved time series / distribution has
412
+ // "12 points" / "8 bins" there, and an ordinal label "6B" parses to 6.
413
+ // scalar → value; time_series → opts.ts ('final' default | 'initial' | 'peak');
414
+ // distribution → opts.dist ('D50' default, a named bin only — no stand-in);
415
+ // ordinal → null, or with opts.ordinal === 'rank' its 1-based scale position;
416
+ // categorical → null.
417
+ function _mcpMeasType(m) {
418
+ const T = ['scalar', 'ordinal', 'categorical', 'time_series', 'distribution'];
419
+ if (m && m.type && T.includes(m.type)) return m.type;
420
+ return m && m.valueType === 'Categorical' ? 'categorical' : 'scalar';
421
+ }
422
+ function _mcpRepNumber(m, opts) {
423
+ const o = opts || {};
424
+ if (!m) return null;
425
+ const type = _mcpMeasType(m);
426
+ if (type === 'time_series') {
427
+ if (!Array.isArray(m.points) || !m.points.length) return null;
411
428
  const agg = _mcpTsAggregate(m.points);
412
- const last = agg[agg.length - 1];
413
- return last ? last.mean : null;
429
+ if (!agg.length) return null;
430
+ const means = agg.map(g => g.mean);
431
+ const red = o.ts || 'final';
432
+ const v = red === 'initial' ? means[0] : red === 'peak' ? Math.max(...means) : means[means.length - 1];
433
+ return isFinite(v) ? v : null;
414
434
  }
415
- if (type === 'distribution' && Array.isArray(m.bins) && m.bins.length) {
416
- const d50 = m.bins.find(b => String(b.label || '').toUpperCase().replace(/\s/g, '') === 'D50');
417
- const v = d50 ? parseFloat(d50.value) : NaN;
435
+ if (type === 'distribution') {
436
+ if (!Array.isArray(m.bins) || !m.bins.length) return null;
437
+ const want = String(o.dist || 'D50').toUpperCase();
438
+ const b = m.bins.find(x => String((x && x.label) || '').toUpperCase().replace(/\s/g, '') === want);
439
+ const v = b ? parseFloat(b.value) : NaN;
418
440
  return isFinite(v) ? v : null;
419
441
  }
442
+ if (type === 'ordinal') {
443
+ if (o.ordinal !== 'rank' || !Array.isArray(m.scale)) return null;
444
+ const i = m.scale.map(x => String(x).trim().toUpperCase()).indexOf(String(m.value == null ? '' : m.value).trim().toUpperCase());
445
+ return i >= 0 ? i + 1 : null;
446
+ }
447
+ if (type === 'categorical') return null;
420
448
  const v = parseFloat(m.value);
421
449
  return isFinite(v) ? v : null;
422
450
  }
451
+ // A ranked-scale (ordinal) or text (categorical) parameter's trend, as the
452
+ // app's Stability view draws it (FormLab 13b): one point per test date × run
453
+ // condition — an ordinal date is the MEDIAN rank of its readings (rank =
454
+ // 1-based position on the reading's own scale), a text date its value (or
455
+ // "mixed: a / b") — failing when any reading fails; one series per condition.
456
+ function _mcpGradedStability(entity, entityType, args, graded) {
457
+ const nOrd = graded.filter(r => r.type === 'ordinal').length;
458
+ const kind = nOrd >= graded.length - nOrd ? 'ordinal' : 'categorical';
459
+ const rows = graded.filter(r => r.type === kind);
460
+ const scale = kind === 'ordinal' ? ((rows.find(r => r.scale) || {}).scale || null) : null;
461
+ const split = args.by_condition !== false;
462
+ const byKey = new Map();
463
+ rows.forEach(r => { const k = r.day + '\u0000' + (split ? r.cond : ''); if (!byKey.has(k)) byKey.set(k, []); byKey.get(k).push(r); });
464
+ const pts = [...byKey.values()].map(g => {
465
+ let label, rank = null;
466
+ if (kind === 'ordinal') {
467
+ const rs = g.map(r => r.rank).filter(v => v != null).sort((a, b) => a - b);
468
+ rank = rs.length ? rs[Math.floor((rs.length - 1) / 2)] : null;
469
+ label = rank != null && scale ? scale[rank - 1] : g[0].label;
470
+ } else {
471
+ const vals = [...new Set(g.map(r => r.label))];
472
+ label = vals.length === 1 ? vals[0] : 'mixed: ' + vals.join(' / ');
473
+ }
474
+ return { date: g[0].day, condition: split ? (g[0].cond || null) : null, label, ...(kind === 'ordinal' ? { rank } : {}),
475
+ readings: g.length, values: g.map(r => r.label), reports: [...new Set(g.map(r => r.report))],
476
+ pf: g.some(r => r.pf === 'fail') ? 'fail' : g.some(r => r.pf === 'pass') ? 'pass' : null };
477
+ }).sort((a, b) => a.date.localeCompare(b.date) || String(a.condition).localeCompare(String(b.condition)));
478
+ const sp = (rows.find(r => r.spec) || {}).spec || null;
479
+ const floorLabel = sp ? (sp.minValue != null ? sp.minValue : (sp.minRank != null && scale ? scale[sp.minRank] : null)) : null;
480
+ const spec = sp ? { ...(floorLabel != null ? { atLeast: floorLabel, atLeastRank: scale ? scale.indexOf(floorLabel) + 1 : null } : {}),
481
+ ...(sp.requiredValue != null ? { required: sp.requiredValue } : {}) } : null;
482
+ const byCond = new Map();
483
+ pts.forEach(p => { const k = p.condition || ''; if (!byCond.has(k)) byCond.set(k, []); byCond.get(k).push(p); });
484
+ const series = [...byCond.keys()].sort().map(k => {
485
+ const ps = byCond.get(k), first = ps[0], last = ps[ps.length - 1];
486
+ const firstFail = ps.find(p => p.pf === 'fail');
487
+ const out = { condition: k || null, points: ps, latest: { date: last.date, label: last.label, pf: last.pf },
488
+ firstFail: firstFail ? { date: firstFail.date, label: firstFail.label } : null, inSpecNow: last.pf == null ? null : last.pf === 'pass' };
489
+ if (kind === 'ordinal' && first.rank != null && last.rank != null) out.scaleShift = { from: first.label, to: last.label, steps: last.rank - first.rank };
490
+ return out;
491
+ });
492
+ return {
493
+ entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
494
+ parameter: args.parameter, kind, ...(scale ? { scale } : {}), spec, byCondition: split, pointCount: pts.length, series,
495
+ note: kind === 'ordinal'
496
+ ? 'A ranked scale (ordinal) has no number, so there is no regression, control chart or shelf-life projection: each point is one test date per condition, its value the MEDIAN rank of that date\'s readings (rank = position on `scale`, 1-based; higher = later on the scale). pf "fail" when any reading that date failed. scaleShift.steps is the rank change first → latest (negative = moved down the scale). Narrate firstFail / latest; do not average ranks into a number.'
497
+ : 'A text (categorical) result has no number: each point is one test date per condition with its value (or "mixed: a / b" when readings differ) and pass/fail against `spec.required`; pf "fail" when any reading failed. Narrate firstFail / latest.',
498
+ };
499
+ }
423
500
  function _mcpCondSig(m) {
424
501
  const c = m && m.conditions;
425
502
  if (!c || typeof c !== 'object') return '';
@@ -1127,7 +1204,7 @@ function _shelfLifeAssess(sl, targetMonths, now) {
1127
1204
  const get_stability = {
1128
1205
  definition: {
1129
1206
  name: 'get_stability',
1130
- description: 'Grounded stability / shelf-life analysis for one entity × parameter — the deterministic numbers behind the Stability & Trends view. Pass a formulation (pools all its samples), a sample (that sample over time), or a batch (pools its samples) by id or UID, plus a parameter name. Returns the time series, drift (per-month slope + R²), an individuals control chart (mean ± 3σ + out-of-control points), spec status, and an ICH-flavored projected shelf life (linear extrapolation to the spec crossing: point estimate + the conservative 95%-confidence-bound crossing). When measurements carry run conditions (e.g. Storage 25 °C vs 40 °C) it splits into one series per condition — a true accelerated-vs-ambient comparison. A point is one TEST DATE: every reading taken that day under that condition is averaged (points = test dates, readings = the raw count), so replicates never count as separate time points in the drift, control chart or shelf-life fit — the same points as the app. Use these figures directly; do not re-derive the regression.',
1207
+ description: 'Grounded stability / shelf-life analysis for one entity × parameter — the deterministic numbers behind the Stability & Trends view. Pass a formulation (pools all its samples), a sample (that sample over time), or a batch (pools its samples) by id or UID, plus a parameter name. Returns the time series, drift (per-month slope + R²), an individuals control chart (mean ± 3σ + out-of-control points), spec status, and an ICH-flavored projected shelf life (linear extrapolation to the spec crossing: point estimate + the conservative 95%-confidence-bound crossing). When measurements carry run conditions (e.g. Storage 25 °C vs 40 °C) it splits into one series per condition — a true accelerated-vs-ambient comparison. A point is one TEST DATE: every reading taken that day under that condition is averaged (points = test dates, readings = the raw count), so replicates never count as separate time points in the drift, control chart or shelf-life fit — the same points as the app. A ranked-scale (ordinal, e.g. pencil hardness) or text (e.g. appearance) parameter returns kind "ordinal" / "categorical" instead: per-condition points with the label (median rank for an ordinal), pass/fail, firstFail, latest and scaleShift — the app\'s step / pass-fail-strip trend, with no regression. Use these figures directly; do not re-derive the regression.',
1131
1208
  inputSchema: {
1132
1209
  type: 'object',
1133
1210
  properties: {
@@ -1153,6 +1230,8 @@ const get_stability = {
1153
1230
  const paramLc = (args.parameter || '').trim().toLowerCase();
1154
1231
  if (!paramLc) return { error: 'parameter is required.' };
1155
1232
  const points = [];
1233
+ const skipped = { ordinal: 0, categorical: 0 };
1234
+ const graded = []; // ordinal / text readings, for the step / strip trend (13b)
1156
1235
  let specMin = null, specMax = null, unit = '';
1157
1236
  // Each reading keeps the spec it was judged by (reading → panel row →
1158
1237
  // method). Mirrors the app: one band only when they all agree; 2+ distinct
@@ -1165,11 +1244,22 @@ const get_stability = {
1165
1244
  if (!Number.isFinite(date.getTime())) return;
1166
1245
  (t.measurements || []).forEach(m => {
1167
1246
  if ((m.parameter || '').trim().toLowerCase() !== paramLc) return;
1168
- let value = parseFloat(m.value);
1169
- if (!Number.isFinite(value) && Array.isArray(m.points) && m.points.length) {
1170
- const last = m.points[m.points.length - 1]; value = parseFloat(last && (last.value ?? last.y)); // time-series → final
1247
+ // As the app's Stability chart (13a): a curve → its final value (not
1248
+ // "12 points" → 12), and ordinal / text readings left out and counted.
1249
+ const _ty = _mcpMeasType(m);
1250
+ if (_ty === 'ordinal' || _ty === 'categorical') {
1251
+ skipped[_ty]++;
1252
+ const label = String(m.value == null ? '' : m.value).trim();
1253
+ if (label) {
1254
+ const rs0 = _resolveSpec(m, t.templateId);
1255
+ graded.push({ day: dStr, type: _ty, label, rank: _mcpRepNumber(m, { ordinal: 'rank' }), scale: Array.isArray(m.scale) ? m.scale : null,
1256
+ spec: rs0.spec || null, pf: rs0.spec ? _evalPassFail(m, t.templateId) : null, report: t.uid || t.id,
1257
+ cond: (m.conditions && Object.keys(m.conditions).length) ? Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(' · ') : '' });
1258
+ }
1259
+ return;
1171
1260
  }
1172
- if (!Number.isFinite(value)) return;
1261
+ const value = _mcpRepNumber(m);
1262
+ if (value == null) return;
1173
1263
  if (m.unit && !unit) unit = m.unit;
1174
1264
  const rs = _resolveSpec(m, t.templateId), ac = rs.spec;
1175
1265
  const sp = (ac && (Number.isFinite(ac.min) || Number.isFinite(ac.max))) ? { min: Number.isFinite(ac.min) ? ac.min : null, max: Number.isFinite(ac.max) ? ac.max : null } : null;
@@ -1208,6 +1298,7 @@ const get_stability = {
1208
1298
  points.push({ date: new Date(g[0].day), value, cond: g[0].cond, spec: (g.find(r => r.spec) || {}).spec || null, readings: g.length });
1209
1299
  });
1210
1300
  points.sort((a, b) => (a.date - b.date) || a.cond.localeCompare(b.cond));
1301
+ if (!points.length && graded.length) return _mcpGradedStability(entity, entityType, args, graded);
1211
1302
  if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
1212
1303
 
1213
1304
  const spec = { min: specMin, max: specMax };
@@ -1275,14 +1366,14 @@ const get_stability = {
1275
1366
 
1276
1367
  return {
1277
1368
  entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
1278
- parameter: args.parameter, unit, spec, ...(specVaries ? { specVaries, specNote: 'Readings were judged against different specs (different panels): no single band, no shelf-life projection; each reading keeps its own pass/fail.' } : {}), pointCount: points.length, byCondition: split, series,
1369
+ parameter: args.parameter, unit, spec, ...((skipped.ordinal || skipped.categorical) ? { skipped, skippedNote: 'Ordinal (ranked scale) and text readings have no number to trend and are left out, as in the app.' } : {}), ...(specVaries ? { specVaries, specNote: 'Readings were judged against different specs (different panels): no single band, no shelf-life projection; each reading keeps its own pass/fail.' } : {}), pointCount: points.length, byCondition: split, series,
1279
1370
  targetShelfLife: target ? { months: target.months, source: target.source } : null,
1280
1371
  note: 'shelfLife.fromT0Months / ci95FromT0Months are the projected shelf life counted from the first test (T0): point estimate and the conservative (ICH Q1E) 95%-bound crossing. remainingMonths / ci95Months are the same, counted from the last test. verdict compares against targetShelfLife: "short" = the trend crosses spec before the target, "marginal" = the trend clears it but the 95% bound does not, "meets" = both clear it; null = no target set (then concern is by time left: medium <3 mo or overdue, else info). A projection never reaches \"high\": high is reserved for a MEASURED out-of-spec value (spec.latestInSpec=false). short/overdue → medium, marginal/meets → info. overdue=true means projectedCrossDate is already past today (time left runs from the last test), so the sample is probably out of spec now — recommend a re-test. reportable=false means the app shows no projection (R² < 0.3, crossing already past, or beyond 5 years). Numbers are computed here — narrate them, do not recompute.',
1281
1372
  };
1282
1373
  },
1283
1374
  };
1284
1375
 
1285
- export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
1376
+ export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
1286
1377
  export const tools = {
1287
1378
  list_test_results,
1288
1379
  get_test_result,
@@ -27,6 +27,7 @@
27
27
  // ============================================================
28
28
 
29
29
  import { getStore, resolveById, flattenComposition } from '../data.js';
30
+ import { _mcpRepNumber } from './analytics.js';
30
31
 
31
32
  // ---- composition helper -----------------------------------------------------
32
33
  // Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
@@ -344,12 +345,20 @@ function _roleCandidates(forms, roleMaps) {
344
345
  });
345
346
  return out.sort((a, b) => b.score - a.score);
346
347
  }
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).
348
+ // A formula's value for a measured parameter, as the app's
349
+ // _asmFormulaValueForCol (13a): readings of the primary run condition (the one
350
+ // with the most readings), each as the DOE Matrix reads it (a curve's final
351
+ // value, a distribution's D50, an ordinal's rank), then their mean.
349
352
  function _paramValue(f, param, db) {
350
353
  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); } }); });
354
+ const ms = [];
355
+ (db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) ms.push(m); }); });
356
+ const sig = (m) => { const c = m && m.conditions; return c && typeof c === 'object' ? Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => k + '=' + c[k]).join('; ') : ''; };
357
+ const byCond = new Map();
358
+ ms.forEach(m => { if (_mcpRepNumber(m) == null) return; const k = sig(m); byCond.set(k, (byCond.get(k) || 0) + 1); });
359
+ const primary = [...byCond.entries()].sort((a, b) => b[1] - a[1])[0];
360
+ const pool = primary ? ms.filter(m => sig(m) === primary[0]) : ms;
361
+ const vals = pool.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
353
362
  return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
354
363
  }
355
364
  function _spearman(xs, ys) {