formlab-mcp 0.6.49 → 0.6.50

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.
@@ -0,0 +1,323 @@
1
+ // ============================================================
2
+ // NESTED FORMULATIONS — plumbing
3
+ // ============================================================
4
+ // A composition row can reference either an ingredient (c.ingredientId)
5
+ // OR another formulation (c.formulationId). Exactly one of the two is
6
+ // set. A row pointing at a formulation is a "sub-formula" row.
7
+ //
8
+ // The helpers here are the foundation for milestones 2-6 (modal,
9
+ // recursive cost/fractions, rollups, version pointers).
10
+ // ============================================================
11
+
12
+ const MAX_NESTING_DEPTH = 5;
13
+
14
+ // True if the composition row points at another formulation.
15
+ function isSubFormulaRow(c) {
16
+ return !!(c && c.formulationId);
17
+ }
18
+
19
+ // Resolve a row to its referenced formulation, or null if missing/deleted.
20
+ function _resolveSubFormula(c) {
21
+ if (!c || !c.formulationId) return null;
22
+ return db.formulations.find(f => f.id === c.formulationId) || null;
23
+ }
24
+
25
+ // Compute the max nesting depth under `formulation`. A formulation with
26
+ // no sub-formula rows has depth 0. Returns Infinity if a cycle is hit
27
+ // (defensive — cycles should be blocked at add time, but never trust
28
+ // stored data blindly).
29
+ function _subTreeDepth(formulation, _seen) {
30
+ if (!formulation) return 0;
31
+ _seen = _seen || new Set();
32
+ if (_seen.has(formulation.id)) return Infinity;
33
+ _seen.add(formulation.id);
34
+ let maxChild = 0;
35
+ for (const c of (formulation.composition || [])) {
36
+ if (!isSubFormulaRow(c)) continue;
37
+ const child = _resolveSubFormula(c);
38
+ if (!child) continue;
39
+ const d = _subTreeDepth(child, _seen) + 1;
40
+ if (d > maxChild) maxChild = d;
41
+ }
42
+ _seen.delete(formulation.id);
43
+ return maxChild;
44
+ }
45
+
46
+ // True if including `candidateId` as a sub-formula inside `parentId`
47
+ // would (directly or indirectly) reference `parentId`. parentId may be
48
+ // null for new formulations being authored (no cycle possible yet).
49
+ function hasCycle(parentId, candidateId) {
50
+ if (!parentId || !candidateId) return false;
51
+ if (parentId === candidateId) return true;
52
+ const candidate = db.formulations.find(f => f.id === candidateId);
53
+ if (!candidate) return false;
54
+ // Walk descendants of candidate; if we find parentId anywhere, that's a cycle.
55
+ const stack = [candidate];
56
+ const seen = new Set([candidate.id]);
57
+ while (stack.length) {
58
+ const cur = stack.pop();
59
+ for (const c of (cur.composition || [])) {
60
+ if (!isSubFormulaRow(c)) continue;
61
+ if (c.formulationId === parentId) return true;
62
+ if (seen.has(c.formulationId)) continue;
63
+ seen.add(c.formulationId);
64
+ const child = _resolveSubFormula(c);
65
+ if (child) stack.push(child);
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+
71
+ // Combined gate the modal will call before letting the user add a row.
72
+ // Returns { ok: true } or { ok: false, reason: '...' }.
73
+ function canIncludeSubFormula(parentId, candidateId) {
74
+ if (!candidateId) return { ok: false, reason: 'No formulation selected' };
75
+ const candidate = db.formulations.find(f => f.id === candidateId);
76
+ if (!candidate) return { ok: false, reason: 'Formulation not found' };
77
+ if (parentId && parentId === candidateId) {
78
+ return { ok: false, reason: 'Cannot include a formulation in itself' };
79
+ }
80
+ if (hasCycle(parentId, candidateId)) {
81
+ return { ok: false, reason: 'Would create a cycle' };
82
+ }
83
+ // Parent's existing depth + 1 (new child) + candidate's own sub-tree
84
+ // must fit within MAX_NESTING_DEPTH.
85
+ const parent = parentId ? db.formulations.find(f => f.id === parentId) : null;
86
+ const parentDepth = parent ? _subTreeDepth(parent) : 0;
87
+ const candidateDepth = _subTreeDepth(candidate);
88
+ const totalDepth = Math.max(parentDepth, 1 + candidateDepth);
89
+ if (totalDepth > MAX_NESTING_DEPTH) {
90
+ return { ok: false, reason: `Exceeds depth limit (${MAX_NESTING_DEPTH} levels)` };
91
+ }
92
+ return { ok: true };
93
+ }
94
+
95
+ // Recursively expand a formulation's composition into a flat list of
96
+ // pure-ingredient rows. Each output row is:
97
+ // {
98
+ // ingredientId, amount, unit, // same shape as a normal row
99
+ // _weight, // fractional weight of this row
100
+ // // relative to the ROOT formulation
101
+ // // (1.0 = the whole root). Useful
102
+ // // for cost/fraction roll-up math.
103
+ // _path: [formulationId, ...] // sub-formula chain that led here
104
+ // }
105
+ //
106
+ // Strategy: at each level we compute each row's wt-fraction within its
107
+ // own parent (using normalized wt%/g/etc). For sub-formula rows we
108
+ // recurse and multiply child fractions by the parent's fraction. For
109
+ // ingredient rows we emit a flat row carrying _weight = parentWeight ×
110
+ // rowFraction.
111
+ //
112
+ // Caveats:
113
+ // - This is a fraction-only flatten. Absolute units inside a
114
+ // sub-formula are normalized to wt-fractions of that sub-formula
115
+ // before being scaled by the parent's contribution. Computing cost
116
+ // and percentages from this output is straightforward; reconstructing
117
+ // absolute masses requires a separate basis assumption (the caller's
118
+ // job).
119
+ // - Rows with unknown/unparseable amounts contribute 0.
120
+ // - Volume units (mL etc.) are converted to mass via the ingredient's
121
+ // density if known, otherwise the row contributes 0 and is logged in
122
+ // `unresolved` for the caller to surface.
123
+ function _flattenComposition(formulation, opts) {
124
+ opts = opts || {};
125
+ const out = [];
126
+ const unresolved = [];
127
+
128
+ function walk(f, weight, path, depth) {
129
+ if (!f || weight <= 0) return;
130
+ if (depth > MAX_NESTING_DEPTH) {
131
+ unresolved.push({ reason: 'depth-limit', formulationId: f.id, path: path.slice() });
132
+ return;
133
+ }
134
+ const rawRows = Array.isArray(f.composition) ? f.composition : [];
135
+ const rows = rawRows.filter(c => c && (c.ingredientId || c.formulationId));
136
+ if (!rows.length) return;
137
+
138
+ // Compute each row's wt-fraction WITHIN this formulation. We reuse the
139
+ // same logic shape as computeCompositionFractions but inline here so
140
+ // this helper stays self-contained (computeCompositionFractions will
141
+ // grow to call back into _flattenComposition in milestone 3).
142
+ const fracs = _rowWtFractionsWithin(f, unresolved);
143
+
144
+ for (let i = 0; i < rows.length; i++) {
145
+ const c = rows[i];
146
+ const fr = fracs[i] || 0;
147
+ if (fr <= 0) continue;
148
+ if (isSubFormulaRow(c)) {
149
+ if (path.indexOf(c.formulationId) >= 0) {
150
+ unresolved.push({ reason: 'cycle', formulationId: c.formulationId, path: path.slice() });
151
+ continue;
152
+ }
153
+ const child = _resolveSubFormula(c);
154
+ if (!child) {
155
+ unresolved.push({ reason: 'deleted-subformula', formulationId: c.formulationId, path: path.slice() });
156
+ continue;
157
+ }
158
+ walk(child, weight * fr, path.concat([c.formulationId]), depth + 1);
159
+ } else {
160
+ // Carry the leaf's role so the flattened-view UI can render
161
+ // a rolled-up role column. Other consumers (cost / inventory /
162
+ // properties) ignore the field — additive, no breakage.
163
+ out.push({
164
+ ingredientId: c.ingredientId,
165
+ amount: c.amount,
166
+ unit: c.unit,
167
+ role: c.role || '',
168
+ _weight: weight * fr,
169
+ _path: path.slice(),
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ walk(formulation, 1, [formulation ? formulation.id : null].filter(Boolean), 0);
176
+ return { rows: out, unresolved };
177
+ }
178
+
179
+ // Rolled-up density (kg/L) of a formulation, computed by flattening to
180
+ // leaf ingredients and taking the harmonic-mean density weighted by
181
+ // each leaf's wt-fraction. Returns null if any leaf is missing density.
182
+ //
183
+ // Derivation: for a unit mass (1 kg) of the formulation, leaf i
184
+ // contributes weight_i kg and occupies weight_i / density_i L. So
185
+ // total volume per kg = Σ (weight_i / density_i), and the formulation
186
+ // density is 1 / that sum.
187
+ // Density the PLANNER should use for this formula (kg/L === g/mL), with its
188
+ // provenance. A declared finished density (measured on a batch, or typed from
189
+ // a spec) wins over the ideal-mix estimate, because real mixes contract or
190
+ // expand — 50 mL ethanol + 50 mL water is 96 mL — and the estimate below
191
+ // assumes they don't (2026-09-13, "volume-based batch target").
192
+ // → { kgPerL, source: 'finished' | 'ideal', temp, batchId, how } | null
193
+ // how: 'measured' | 'manual' | 'spec' (finished only)
194
+ function formulaPlanningDensity(formulation) {
195
+ if (!formulation) return null;
196
+ const fd = formulation.finishedDensity;
197
+ const v = fd ? parseFloat(fd.value) : NaN;
198
+ if (isFinite(v) && v > 0) {
199
+ return { kgPerL: v, source: 'finished', temp: (fd.temp != null && fd.temp !== '') ? fd.temp : null,
200
+ batchId: fd.batchId || null, how: fd.source || 'manual' };
201
+ }
202
+ const est = _idealMixDensity(formulation);
203
+ return (est != null) ? { kgPerL: est, source: 'ideal', temp: null, batchId: null, how: null } : null;
204
+ }
205
+
206
+ // Effective density for every caller that needs "the formula's density"
207
+ // (batch cost, actual-volume estimate, sub-formula rows, find-by-results…):
208
+ // the declared finished density when there is one, else the ideal-mix
209
+ // estimate. Synthetic objects ({ id, composition }) have no finishedDensity
210
+ // and get the estimate as before.
211
+ function _rolledUpDensity(formulation) {
212
+ const pd = formulaPlanningDensity(formulation);
213
+ return pd ? pd.kgPerL : null;
214
+ }
215
+
216
+ // Ideal-mix (sum-of-parts) estimate: harmonic mean of leaf densities weighted
217
+ // by wt-fraction. Ignores contraction on mixing, entrained air and temperature.
218
+ function _idealMixDensity(formulation) {
219
+ if (!formulation) return null;
220
+ const flat = _flattenComposition(formulation);
221
+ if (!flat.rows.length) return null;
222
+ let invSum = 0;
223
+ for (const r of flat.rows) {
224
+ if (r._weight <= 0) continue;
225
+ const ing = db.ingredients.find(i => i.id === r.ingredientId);
226
+ if (!ing) return null;
227
+ const d = (typeof _densityToKgPerL === 'function')
228
+ ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
229
+ if (!d) return null;
230
+ invSum += r._weight / d;
231
+ }
232
+ return invSum > 0 ? 1 / invSum : null;
233
+ }
234
+
235
+ // Helper: per-row wt-fractions within a single formulation level (no
236
+ // recursion). Looks at the row units, normalizes mass/volume rows to
237
+ // kg via density where possible, and returns a parallel array of
238
+ // fractions summing to ≤ 1. Pure wt%/% rows are normalized to sum to 1
239
+ // if they sum to anything > 0.
240
+ function _rowWtFractionsWithin(f, unresolved) {
241
+ // Defensive: tampered imports could land a truthy non-array here
242
+ // (string, object, number). `(f.composition || [])` only catches
243
+ // falsy — it would still try .filter() on the wrong shape and
244
+ // crash. Caught 2026-05-31 by import/export round-trip stress test.
245
+ const raw = Array.isArray(f.composition) ? f.composition : [];
246
+ const comp = raw.filter(c => c && (c.ingredientId || c.formulationId));
247
+ if (!comp.length) return [];
248
+
249
+ const kinds = comp.map(c => {
250
+ const u = String(c.unit || 'wt%').trim();
251
+ const lower = u.toLowerCase();
252
+ if (lower.startsWith('wt') || u === '%') return 'wt';
253
+ if (lower.startsWith('vol')) return 'vol';
254
+ if (typeof MASS_TO_KG !== 'undefined' && u in MASS_TO_KG) return 'mass';
255
+ if (typeof VOL_TO_L !== 'undefined' && u in VOL_TO_L) return 'volabs';
256
+ return 'unknown';
257
+ });
258
+
259
+ // A fractional row (wt%/vol%) and an absolute one (g, mL…) cannot be pooled:
260
+ // the conversion below turns absolute rows into kilograms while a fractional
261
+ // row contributes its RAW number, so "50 wt%" silently becomes 50 kg — making
262
+ // the same row read 99.98% beside 10 g and 0.5% beside 10 ton. There is no
263
+ // correct answer without a batch basis, so refuse to invent one: flag it and
264
+ // return zeros, the same shape this function already uses for a volume row
265
+ // with no density. The editor blocks new saves; this covers data that predates
266
+ // the block or arrived by import. wt% WITH vol% is NOT this case — density
267
+ // reconciles those, and they resolve through the branches below.
268
+ const _hasFrac = kinds.some(k => k === 'wt' || k === 'vol');
269
+ const _hasAbs = kinds.some(k => k === 'mass' || k === 'volabs');
270
+ if (_hasFrac && _hasAbs) {
271
+ unresolved && unresolved.push({ reason: 'mixed-basis', formulationId: f ? f.id : null });
272
+ return comp.map(_ => 0);
273
+ }
274
+
275
+ const allWt = kinds.every(k => k === 'wt');
276
+ if (allWt) {
277
+ const nums = comp.map(c => Math.max(0, parseFloat(c.amount) || 0));
278
+ const sum = nums.reduce((a, b) => a + b, 0);
279
+ if (sum <= 0) return nums.map(_ => 0);
280
+ return nums.map(n => n / sum);
281
+ }
282
+
283
+ // For mass + vol absolute mixes: convert each to kg if possible (vol
284
+ // needs the ingredient density; sub-formula rows assume neutral mass
285
+ // for now — milestone 3 will refine with rollup density).
286
+ const kgPerRow = comp.map((c, i) => {
287
+ const u = String(c.unit || '').trim();
288
+ const amt = Math.max(0, parseFloat(c.amount) || 0);
289
+ if (!amt) return 0;
290
+ if (kinds[i] === 'mass') return amt * MASS_TO_KG[u];
291
+ if (kinds[i] === 'volabs') {
292
+ if (c.ingredientId) {
293
+ const ing = db.ingredients.find(x => x.id === c.ingredientId);
294
+ const d = ing ? _densityToKgPerL(ing.density, ing.densityUnit) : null;
295
+ if (d) return amt * VOL_TO_L[u] * d;
296
+ unresolved && unresolved.push({ reason: 'no-density', ingredientId: c.ingredientId });
297
+ return 0;
298
+ }
299
+ // Sub-formula volume row — compute the child's rolled-up density
300
+ // (mirrors the pattern js/cost.js already uses for sub-formula
301
+ // cost rollup). _rolledUpDensity recursively flattens the child;
302
+ // cycle/depth detection on _flattenComposition keeps the
303
+ // recursion bounded. When the child has at least one leaf with
304
+ // no density, _rolledUpDensity returns null and we surface a
305
+ // matching unresolved entry so the consumption modal can warn.
306
+ const child = _resolveSubFormula(c);
307
+ if (!child) {
308
+ unresolved && unresolved.push({ reason: 'deleted-subformula', formulationId: c.formulationId });
309
+ return 0;
310
+ }
311
+ const d = _rolledUpDensity(child);
312
+ if (d) return amt * VOL_TO_L[u] * d;
313
+ unresolved && unresolved.push({ reason: 'no-subformula-density', formulationId: c.formulationId });
314
+ return 0;
315
+ }
316
+ if (kinds[i] === 'wt') return amt; // treat as relative weight
317
+ if (kinds[i] === 'vol') return amt; // treat as relative volume (best-effort)
318
+ return 0;
319
+ });
320
+ const sum = kgPerRow.reduce((a, b) => a + b, 0);
321
+ if (sum <= 0) return kgPerRow.map(_ => 0);
322
+ return kgPerRow.map(n => n / sum);
323
+ }