Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions mcp-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]),
Expand Down
13 changes: 13 additions & 0 deletions skills/flavormancer/scripts/flavormancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions tests/test_substitute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions training/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
43 changes: 43 additions & 0 deletions training/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions training/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -2419,9 +2419,35 @@ <h4>Software &amp; type</h4>
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='<div class="err">'+e.message+'</div>'; }
finally{ $('formGo').disabled=false; $('formGo').textContent='Analyze formulation'; }
});
function renderEquivalents(eq){
const list=(eq&&eq.equivalents)||[];
if(!list.length) return '';
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>'+
'<div id="equivList">'+list.map(x=>`
<div class="neighbor" data-smi="${formEsc(x.smiles)}" title="Click to analyze this molecule">
<div class="nb-structwrap"><div class="nb-struct">${x.svg||''}</div></div>
<div class="nb-info">
<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>
${x.iupac?`<div class="nb-iupac">${formEsc(x.iupac)}</div>`:''}
<div class="s">${formEsc(x.smiles)}</div>
${(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>`:''}
</div>
<div class="sim">${Math.round((x.profile_match||0)*100)}% match</div>
</div>`).join('')+'</div>';
}
$('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 '<div class="err">'+formEsc(r.error)+(r.unresolved&&r.unresolved.length?' (couldn\'t resolve: '+r.unresolved.map(formEsc).join(', ')+')':'')+'</div>';
let h=''; const ing=r.ingredients||[];
Expand Down
Loading