Skip to content

Commit c878769

Browse files
feat: collapse a mixture into a single equivalent molecule (#231)
The inverse of a recipe: average a blend's component taste+aroma PROFILE vectors, dose-weighted by each ingredient's ppm, into one target profile, then find the single molecules whose own profile is closest — one molecule that tastes and smells like the whole blend. Reuses the profile index from #213. Validated: a 3:1:1 vanilla-dominant blend (vanillin weighted 3x by dose, plus 1 part each cinnamaldehyde + citral) collapses to ethyl vanillin (0.86 profile match), then anisaldehyde / coniferaldehyde (spicy+vanilla) — chemically sensible: the heavy vanillin dose pulls the blend profile toward vanilla, so the closest single molecule is the stronger vanilla. Wired across surfaces: predict.mixture_to_molecule, POST /api/mixture_to_molecule, the Formulation Studio ('equivalent single molecule' section under the analysis, click-to-open), MCP tool mixture_to_molecule, skill 'collapse' command. 24 unit tests pass (2 new). Closes #228. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 803f09f commit c878769

6 files changed

Lines changed: 138 additions & 0 deletions

File tree

mcp-server/server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ def _screen_mixture(ingredients: list, processes: list) -> dict:
145145
return _strip(r.json())
146146

147147

148+
def _mixture_to_molecule(ingredients: list, weights: list, k: int) -> dict:
149+
r = _post("/api/mixture_to_molecule",
150+
{"ingredients": ingredients, "weights": weights or [], "k": k})
151+
if r.status_code != 200:
152+
return {"error": f"mixture->molecule failed (HTTP {r.status_code})"}
153+
return _strip(r.json())
154+
155+
148156
def _predict_reactions(ingredients: list, processes: list) -> dict:
149157
r = _post("/api/mixture", {"ingredients": ingredients, "processes": processes or []})
150158
if r.status_code != 200:
@@ -371,6 +379,17 @@ def predict_reactions(ingredients: list[str], processes: list[str] | None = None
371379
return _predict_reactions(ingredients, processes)
372380

373381

382+
@mcp.tool()
383+
def mixture_to_molecule(ingredients: list[str], weights: list[float] | None = None,
384+
k: int = 6) -> dict:
385+
"""Collapse a blend into a single equivalent molecule: average the components' predicted
386+
taste+aroma PROFILE (dose-weighted if weights given) and return the k single molecules whose
387+
own profile is closest — one molecule that tastes and smells like the whole blend. The inverse
388+
of a recipe. Each with its profile_match, tastes, and aromas.
389+
"""
390+
return _mixture_to_molecule(ingredients, weights or [], k)
391+
392+
374393
@mcp.tool()
375394
def find_molecules_by_notes(notes: list[str], food_safe: bool = True) -> dict:
376395
"""Find molecules that carry a set of taste/aroma NOTES (e.g. ["citrus","fresh","sweet"]),

skills/flavormancer/scripts/flavormancer.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ def cmd_reactions(a):
108108
return {k: d.get(k) for k in ("reactions", "active_hazards", "conditional_hazards")}
109109

110110

111+
def cmd_collapse(a):
112+
# collapse a blend into single equivalent molecules (dose-weighted taste+aroma profile match)
113+
w = [float(x) for x in a.weights] if a.weights else []
114+
return _req("/api/mixture_to_molecule", "POST",
115+
{"ingredients": a.ingredients, "weights": w, "k": a.k})
116+
117+
111118
def cmd_notes(a):
112119
return _req("/api/studio", params={"terms": ",".join(a.notes),
113120
"gras": 0 if a.any_source else 1, "limit": a.limit})
@@ -213,6 +220,12 @@ def mol(name):
213220
s.add_argument("--process", action="append")
214221
s.set_defaults(fn=fn)
215222

223+
s = sub.add_parser("collapse") # blend -> single equivalent molecule (taste+aroma profile)
224+
s.add_argument("ingredients", nargs="+")
225+
s.add_argument("--weights", nargs="*", help="optional per-ingredient dose weights")
226+
s.add_argument("-k", type=int, default=6)
227+
s.set_defaults(fn=cmd_collapse)
228+
216229
s = sub.add_parser("notes")
217230
s.add_argument("notes", nargs="+")
218231
s.add_argument("--any-source", action="store_true", help="don't restrict to GRAS/food-safe")

tests/test_substitute.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,16 @@ def test_substitute_ranks_by_similarity(monkeypatch):
3636
# similarities come back sorted descending
3737
sims = [n["similarity"] for n in neighbors]
3838
assert sims == sorted(sims, reverse=True)
39+
40+
41+
def test_mixture_to_molecule_graceful_without_profiles(monkeypatch):
42+
# no profile matrix -> a clean error, never a crash / real (slow) index build
43+
monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], [], [], None, []))
44+
out = predict.mixture_to_molecule(["CCO", "CCCO"])
45+
assert "error" in out
46+
47+
48+
def test_mixture_to_molecule_rejects_all_bad(monkeypatch):
49+
monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], [], [], None, []))
50+
out = predict.mixture_to_molecule(["nope", "xyz"])
51+
assert "error" in out

training/app.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,30 @@ class MixtureQuery(BaseModel):
722722
processes: list[str] = []
723723

724724

725+
class BlendQuery(BaseModel):
726+
ingredients: list[str]
727+
weights: list[float] = []
728+
k: int = 6
729+
730+
731+
@app.post("/api/mixture_to_molecule")
732+
def api_mixture_to_molecule(b: BlendQuery):
733+
"""Collapse a blend to single equivalent molecules: the dose-weighted mean taste+aroma
734+
profile of the components, then the molecules whose own profile is closest."""
735+
smis = [s for s in (_resolve(x) for x in b.ingredients) if s]
736+
if not smis:
737+
return {"equivalents": []}
738+
res = P.mixture_to_molecule(smis, weights=b.weights or None, k=b.k)
739+
for n in res.get("equivalents", []): # enrich like neighbors/substitutes
740+
n["svg"] = _svg(n["smiles"], 132, 96)
741+
nm = _names(n["smiles"])
742+
n["name"], n["iupac"] = nm[0], nm[1]
743+
n["aroma"] = _aroma_tags_cheap(n["smiles"], n.pop("aromas", []))
744+
_m = Chem.MolFromSmiles(n["smiles"])
745+
n["gras"] = bool(_m is not None and Chem.MolToInchiKey(_m).split("-")[0] in P._GRAS)
746+
return res
747+
748+
725749
@app.post("/api/mixture")
726750
def api_mixture(m: MixtureQuery):
727751
"""Per-ingredient reads + documented-hazard screen + a single-molecule palette match."""

training/predict.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,49 @@ def substitutes(smiles: str, k: int = 8) -> dict:
12401240
"basis": "profile — cosine over predicted taste + aroma head scores"}
12411241

12421242

1243+
def mixture_to_molecule(smiles_list: list, weights: list | None = None, k: int = 6) -> dict:
1244+
"""Collapse a blend into a single equivalent molecule: average the components' taste+aroma
1245+
profile vectors (dose-weighted if weights given) into one target profile, then return the k
1246+
molecules whose own profile is closest — 'one molecule that tastes and smells like the whole
1247+
blend'. The inverse of a recipe: instead of many ingredients, find the single closest match."""
1248+
import numpy as np
1249+
_ensure_sub_index()
1250+
_fps, smis, tastes, aromas, profiles, _dims = _SUB_INDEX
1251+
if profiles is None or not len(smis):
1252+
return {"error": "no profile index / reference set"}
1253+
taste_heads, aroma_heads = _profile_heads()
1254+
comps = []
1255+
for smi in smiles_list or []:
1256+
m = Chem.MolFromSmiles(smi)
1257+
if m is None:
1258+
continue
1259+
x = _feat(m)
1260+
v = np.array([_CLASSIFIERS[t].predict_proba(x)[0, 1] for t in taste_heads]
1261+
+ [_AROMA_MODELS[a].predict_proba(x)[0, 1] for a in aroma_heads], dtype="float32")
1262+
comps.append((Chem.MolToSmiles(m), v))
1263+
if not comps:
1264+
return {"error": "no parseable components"}
1265+
w = np.array((weights or [1.0] * len(comps))[:len(comps)], dtype="float32")
1266+
w = w / (float(w.sum()) + 1e-9)
1267+
target = np.average(np.vstack([v for _, v in comps]), axis=0, weights=w).astype("float32")
1268+
in_skel = {Chem.MolToInchiKey(Chem.MolFromSmiles(s)).split("-")[0] for s, _ in comps}
1269+
qn = target / (float(np.linalg.norm(target)) + 1e-9)
1270+
pn = profiles / (np.linalg.norm(profiles, axis=1, keepdims=True) + 1e-9)
1271+
sims = pn @ qn
1272+
out = []
1273+
for i in np.argsort(-sims):
1274+
ni = Chem.MolFromSmiles(smis[i])
1275+
if ni is None or Chem.MolToInchiKey(ni).split("-")[0] in in_skel:
1276+
continue
1277+
out.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3),
1278+
"known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i),
1279+
"aromas": aromas[i] if i < len(aromas) else []})
1280+
if len(out) >= k:
1281+
break
1282+
return {"components": [s for s, _ in comps], "equivalents": out,
1283+
"basis": "cosine of each candidate to the dose-weighted mean blend profile"}
1284+
1285+
12431286
def palette_match(tastes, aromas=None, k=5):
12441287
"""Single molecules that best resemble a target flavor PALETTE — taste labels AND aroma
12451288
descriptors — scored by the mean of taste-Jaccard and aroma-Jaccard over the labeled set

training/workbench.html

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2419,9 +2419,35 @@ <h4>Software &amp; type</h4>
24192419
const r=await post('/api/formulation',{ingredients:rows, processes:procs, target:[..._formTarget]});
24202420
$('formResults').innerHTML=renderFormulation(r);
24212421
const fill=$('formResults').querySelectorAll('.fprof-fill'); requestAnimationFrame(()=>fill.forEach(f=>f.style.width=f.dataset.w));
2422+
// collapse the blend -> the single molecule(s) whose taste+aroma profile matches the whole
2423+
const ing=(r.ingredients||[]).filter(i=>i.smiles);
2424+
if(ing.length>=2){ try{
2425+
const eq=await post('/api/mixture_to_molecule',{ingredients:ing.map(i=>i.smiles), weights:ing.map(i=>+i.ppm||1), k:5});
2426+
$('formResults').insertAdjacentHTML('beforeend', renderEquivalents(eq));
2427+
}catch(_){} }
24222428
}catch(e){ $('formResults').innerHTML='<div class="err">'+e.message+'</div>'; }
24232429
finally{ $('formGo').disabled=false; $('formGo').textContent='Analyze formulation'; }
24242430
});
2431+
function renderEquivalents(eq){
2432+
const list=(eq&&eq.equivalents)||[];
2433+
if(!list.length) return '';
2434+
return '<div class="bsub" style="margin-top:18px">Single-molecule equivalent <span style="font-weight:400;color:var(--muted)">— one molecule whose <b>taste + aroma profile</b> matches the whole blend (a possible drop-in for the mix). Flavor = taste + aroma.</span></div>'+
2435+
'<div id="equivList">'+list.map(x=>`
2436+
<div class="neighbor" data-smi="${formEsc(x.smiles)}" title="Click to analyze this molecule">
2437+
<div class="nb-structwrap"><div class="nb-struct">${x.svg||''}</div></div>
2438+
<div class="nb-info">
2439+
<div class="nb-name">${formEsc(x.name||'—')}${x.gras?'<span class="nb-gras" title="Listed in a food-use reference — a listing flag, not a clearance">Food-listed</span>':''}</div>
2440+
${x.iupac?`<div class="nb-iupac">${formEsc(x.iupac)}</div>`:''}
2441+
<div class="s">${formEsc(x.smiles)}</div>
2442+
${(x.aroma&&x.aroma.length)?`<div class="nb-aroma">${x.aroma.map(a=>`<span class="atag" style="color:${aromaColor(a.odor)};border-color:${aromaColor(a.odor)}44">${a.odor}${a.source==='predicted'?' •':''}</span>`).join('')}</div>`:''}
2443+
</div>
2444+
<div class="sim">${Math.round((x.profile_match||0)*100)}% match</div>
2445+
</div>`).join('')+'</div>';
2446+
}
2447+
$('formResults').addEventListener('click', e=>{
2448+
const nb=e.target.closest('#equivList .neighbor');
2449+
if(nb && nb.dataset.smi){ q.value=nb.dataset.smi; window.scrollTo({top:0,behavior:'smooth'}); run(); }
2450+
});
24252451
function renderFormulation(r){
24262452
if(r.error) return '<div class="err">'+formEsc(r.error)+(r.unresolved&&r.unresolved.length?' (couldn\'t resolve: '+r.unresolved.map(formEsc).join(', ')+')':'')+'</div>';
24272453
let h=''; const ing=r.ingredients||[];

0 commit comments

Comments
 (0)