diff --git a/mcp-server/server.py b/mcp-server/server.py index c06de25..0e49fe5 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -145,6 +145,14 @@ def _screen_mixture(ingredients: list, processes: list) -> dict: return _strip(r.json()) +def _mixture_to_molecule(ingredients: list, weights: list, k: int) -> dict: + r = _post("/api/mixture_to_molecule", + {"ingredients": ingredients, "weights": weights or [], "k": k}) + if r.status_code != 200: + return {"error": f"mixture->molecule failed (HTTP {r.status_code})"} + return _strip(r.json()) + + def _predict_reactions(ingredients: list, processes: list) -> dict: r = _post("/api/mixture", {"ingredients": ingredients, "processes": processes or []}) if r.status_code != 200: @@ -371,6 +379,17 @@ def predict_reactions(ingredients: list[str], processes: list[str] | None = None return _predict_reactions(ingredients, processes) +@mcp.tool() +def mixture_to_molecule(ingredients: list[str], weights: list[float] | None = None, + k: int = 6) -> dict: + """Collapse a blend into a single equivalent molecule: average the components' predicted + taste+aroma PROFILE (dose-weighted if weights given) and return the k single molecules whose + own profile is closest — one molecule that tastes and smells like the whole blend. The inverse + of a recipe. Each with its profile_match, tastes, and aromas. + """ + return _mixture_to_molecule(ingredients, weights or [], k) + + @mcp.tool() def find_molecules_by_notes(notes: list[str], food_safe: bool = True) -> dict: """Find molecules that carry a set of taste/aroma NOTES (e.g. ["citrus","fresh","sweet"]), diff --git a/skills/flavormancer/scripts/flavormancer.py b/skills/flavormancer/scripts/flavormancer.py index 272d490..cf3f5a0 100755 --- a/skills/flavormancer/scripts/flavormancer.py +++ b/skills/flavormancer/scripts/flavormancer.py @@ -108,6 +108,13 @@ def cmd_reactions(a): return {k: d.get(k) for k in ("reactions", "active_hazards", "conditional_hazards")} +def cmd_collapse(a): + # collapse a blend into single equivalent molecules (dose-weighted taste+aroma profile match) + w = [float(x) for x in a.weights] if a.weights else [] + return _req("/api/mixture_to_molecule", "POST", + {"ingredients": a.ingredients, "weights": w, "k": a.k}) + + def cmd_notes(a): return _req("/api/studio", params={"terms": ",".join(a.notes), "gras": 0 if a.any_source else 1, "limit": a.limit}) @@ -213,6 +220,12 @@ def mol(name): s.add_argument("--process", action="append") s.set_defaults(fn=fn) + s = sub.add_parser("collapse") # blend -> single equivalent molecule (taste+aroma profile) + s.add_argument("ingredients", nargs="+") + s.add_argument("--weights", nargs="*", help="optional per-ingredient dose weights") + s.add_argument("-k", type=int, default=6) + s.set_defaults(fn=cmd_collapse) + s = sub.add_parser("notes") s.add_argument("notes", nargs="+") s.add_argument("--any-source", action="store_true", help="don't restrict to GRAS/food-safe") diff --git a/tests/test_substitute.py b/tests/test_substitute.py index 82bccad..6616d9a 100644 --- a/tests/test_substitute.py +++ b/tests/test_substitute.py @@ -36,3 +36,16 @@ def test_substitute_ranks_by_similarity(monkeypatch): # similarities come back sorted descending sims = [n["similarity"] for n in neighbors] assert sims == sorted(sims, reverse=True) + + +def test_mixture_to_molecule_graceful_without_profiles(monkeypatch): + # no profile matrix -> a clean error, never a crash / real (slow) index build + monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], [], [], None, [])) + out = predict.mixture_to_molecule(["CCO", "CCCO"]) + assert "error" in out + + +def test_mixture_to_molecule_rejects_all_bad(monkeypatch): + monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], [], [], None, [])) + out = predict.mixture_to_molecule(["nope", "xyz"]) + assert "error" in out diff --git a/training/app.py b/training/app.py index 5d43ae1..bf4da38 100644 --- a/training/app.py +++ b/training/app.py @@ -722,6 +722,30 @@ class MixtureQuery(BaseModel): processes: list[str] = [] +class BlendQuery(BaseModel): + ingredients: list[str] + weights: list[float] = [] + k: int = 6 + + +@app.post("/api/mixture_to_molecule") +def api_mixture_to_molecule(b: BlendQuery): + """Collapse a blend to single equivalent molecules: the dose-weighted mean taste+aroma + profile of the components, then the molecules whose own profile is closest.""" + smis = [s for s in (_resolve(x) for x in b.ingredients) if s] + if not smis: + return {"equivalents": []} + res = P.mixture_to_molecule(smis, weights=b.weights or None, k=b.k) + for n in res.get("equivalents", []): # enrich like neighbors/substitutes + n["svg"] = _svg(n["smiles"], 132, 96) + nm = _names(n["smiles"]) + n["name"], n["iupac"] = nm[0], nm[1] + n["aroma"] = _aroma_tags_cheap(n["smiles"], n.pop("aromas", [])) + _m = Chem.MolFromSmiles(n["smiles"]) + n["gras"] = bool(_m is not None and Chem.MolToInchiKey(_m).split("-")[0] in P._GRAS) + return res + + @app.post("/api/mixture") def api_mixture(m: MixtureQuery): """Per-ingredient reads + documented-hazard screen + a single-molecule palette match.""" diff --git a/training/predict.py b/training/predict.py index 8702f9d..a04c971 100644 --- a/training/predict.py +++ b/training/predict.py @@ -1240,6 +1240,49 @@ def substitutes(smiles: str, k: int = 8) -> dict: "basis": "profile — cosine over predicted taste + aroma head scores"} +def mixture_to_molecule(smiles_list: list, weights: list | None = None, k: int = 6) -> dict: + """Collapse a blend into a single equivalent molecule: average the components' taste+aroma + profile vectors (dose-weighted if weights given) into one target profile, then return the k + molecules whose own profile is closest — 'one molecule that tastes and smells like the whole + blend'. The inverse of a recipe: instead of many ingredients, find the single closest match.""" + import numpy as np + _ensure_sub_index() + _fps, smis, tastes, aromas, profiles, _dims = _SUB_INDEX + if profiles is None or not len(smis): + return {"error": "no profile index / reference set"} + taste_heads, aroma_heads = _profile_heads() + comps = [] + for smi in smiles_list or []: + m = Chem.MolFromSmiles(smi) + if m is None: + continue + x = _feat(m) + v = np.array([_CLASSIFIERS[t].predict_proba(x)[0, 1] for t in taste_heads] + + [_AROMA_MODELS[a].predict_proba(x)[0, 1] for a in aroma_heads], dtype="float32") + comps.append((Chem.MolToSmiles(m), v)) + if not comps: + return {"error": "no parseable components"} + w = np.array((weights or [1.0] * len(comps))[:len(comps)], dtype="float32") + w = w / (float(w.sum()) + 1e-9) + target = np.average(np.vstack([v for _, v in comps]), axis=0, weights=w).astype("float32") + in_skel = {Chem.MolToInchiKey(Chem.MolFromSmiles(s)).split("-")[0] for s, _ in comps} + qn = target / (float(np.linalg.norm(target)) + 1e-9) + pn = profiles / (np.linalg.norm(profiles, axis=1, keepdims=True) + 1e-9) + sims = pn @ qn + out = [] + for i in np.argsort(-sims): + ni = Chem.MolFromSmiles(smis[i]) + if ni is None or Chem.MolToInchiKey(ni).split("-")[0] in in_skel: + continue + out.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3), + "known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i), + "aromas": aromas[i] if i < len(aromas) else []}) + if len(out) >= k: + break + return {"components": [s for s, _ in comps], "equivalents": out, + "basis": "cosine of each candidate to the dose-weighted mean blend profile"} + + def palette_match(tastes, aromas=None, k=5): """Single molecules that best resemble a target flavor PALETTE — taste labels AND aroma descriptors — scored by the mean of taste-Jaccard and aroma-Jaccard over the labeled set diff --git a/training/workbench.html b/training/workbench.html index 862a0fa..8b51b82 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -2419,9 +2419,35 @@

Software & type

const r=await post('/api/formulation',{ingredients:rows, processes:procs, target:[..._formTarget]}); $('formResults').innerHTML=renderFormulation(r); const fill=$('formResults').querySelectorAll('.fprof-fill'); requestAnimationFrame(()=>fill.forEach(f=>f.style.width=f.dataset.w)); + // collapse the blend -> the single molecule(s) whose taste+aroma profile matches the whole + const ing=(r.ingredients||[]).filter(i=>i.smiles); + if(ing.length>=2){ try{ + const eq=await post('/api/mixture_to_molecule',{ingredients:ing.map(i=>i.smiles), weights:ing.map(i=>+i.ppm||1), k:5}); + $('formResults').insertAdjacentHTML('beforeend', renderEquivalents(eq)); + }catch(_){} } }catch(e){ $('formResults').innerHTML='
'+e.message+'
'; } finally{ $('formGo').disabled=false; $('formGo').textContent='Analyze formulation'; } }); +function renderEquivalents(eq){ + const list=(eq&&eq.equivalents)||[]; + if(!list.length) return ''; + return '
Single-molecule equivalent — one molecule whose taste + aroma profile matches the whole blend (a possible drop-in for the mix). Flavor = taste + aroma.
'+ + '
'+list.map(x=>` +
+
${x.svg||''}
+
+
${formEsc(x.name||'—')}${x.gras?'Food-listed':''}
+ ${x.iupac?`
${formEsc(x.iupac)}
`:''} +
${formEsc(x.smiles)}
+ ${(x.aroma&&x.aroma.length)?`
${x.aroma.map(a=>`${a.odor}${a.source==='predicted'?' •':''}`).join('')}
`:''} +
+
${Math.round((x.profile_match||0)*100)}% match
+
`).join('')+'
'; +} +$('formResults').addEventListener('click', e=>{ + const nb=e.target.closest('#equivList .neighbor'); + if(nb && nb.dataset.smi){ q.value=nb.dataset.smi; window.scrollTo({top:0,behavior:'smooth'}); run(); } +}); function renderFormulation(r){ if(r.error) return '
'+formEsc(r.error)+(r.unresolved&&r.unresolved.length?' (couldn\'t resolve: '+r.unresolved.map(formEsc).join(', ')+')':'')+'
'; let h=''; const ing=r.ingredients||[];