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
28 changes: 26 additions & 2 deletions training/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,33 @@ class MixtureQuery(BaseModel):

@app.post("/api/mixture")
def api_mixture(m: MixtureQuery):
"""Documented dangerous-mixture screen over 2..n ingredients (predict.check_mixture)."""
"""Per-ingredient reads + documented-hazard screen + a single-molecule palette match."""
smis = [s for s in (_resolve(x) for x in m.ingredients) if s]
return P.check_mixture(smis, m.processes)
out = P.check_mixture(smis, m.processes)
reads, palette = [], set()
for s in smis:
r = P.predict(s)
tp = r.get("taste_profile", [])
tastes = [t for t in ("sweet", "bitter", "umami") if isinstance(r.get(t), (int, float)) and r[t] >= 0.5]
if r.get("sour"):
tastes.append("sour")
if r.get("salty") is True:
tastes.append("salty")
for t in (r.get("known_tastes") or []):
if t not in tastes:
tastes.append(t)
palette.update(tastes)
reads.append({"smiles": r["smiles"], "name": _names(s)[0], "svg": _svg(s, 110, 80),
"top_taste": tp[0]["taste"] if tp else None, "tastes": tastes,
"gras": r["safety"]["gras_status"], "alerts": r["safety"]["structural_alerts"],
"tox_flags": r["safety"]["tox_screen"].get("flags", []) if r["applicability"]["in_domain"] else []})
pal = P.palette_match(sorted(palette), k=5)
for mt in pal.get("matches", []):
mt["svg"] = _svg(mt["smiles"], 110, 80)
mt["name"] = _names(mt["smiles"])[0]
out["ingredients"] = reads
out["palette"] = pal
return out


def _load_suggest():
Expand Down
25 changes: 25 additions & 0 deletions training/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,31 @@ def substitute(smiles: str, k: int = 8, min_similarity: float = 0.0) -> dict:
"basis": "Tanimoto / Morgan r2 2048-bit over labeled molecules"}


def palette_match(tastes, k=5):
"""Single molecules whose KNOWN taste-label set best matches a target taste set
(Jaccard over sweet/bitter/umami/sour/salty). NOT a blend-perception model — a
label-set similarity over the labeled molecules, for the 'one molecule like this
mixture' view. A blend's actual palette isn't the union of its parts (suppression /
synergy); this is an honest structural-label approximation."""
if _SUB_INDEX is None:
_build_sub_index()
_, smis, tlist = _SUB_INDEX
target = set(tastes)
if not target or not smis:
return {"target": sorted(target), "matches": []}
scored = []
for smi, ts in zip(smis, tlist):
s = set(ts)
if not s:
continue
j = len(target & s) / len(target | s)
if j > 0:
scored.append((j, smi, sorted(s)))
scored.sort(key=lambda e: -e[0])
return {"target": sorted(target),
"matches": [{"smiles": sm, "tastes": ts, "match": round(j, 2)} for j, sm, ts in scored[:k]]}


def predict(smiles: str, include_aroma: bool = False) -> dict:
mol = Chem.MolFromSmiles(smiles)
if mol is None:
Expand Down
31 changes: 29 additions & 2 deletions training/workbench.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@
.mix-btn{font-family:var(--ui);font-weight:600;font-size:14px;padding:9px 18px;border:none;border-radius:8px;background:var(--accent);color:#fff;cursor:pointer}
.mix-btn:disabled{opacity:.5}
#mixResults{margin-top:14px}
.mix-ings{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}
.mix-ing{display:flex;gap:10px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
.mix-ing:hover{background:var(--accent-soft)}
.hazard{border:1px solid #E7C4B8;background:#FBEDE9;color:#8A3A22;border-radius:8px;padding:10px 13px;margin-bottom:8px;font-size:13px;line-height:1.5}
.hazard.cond{border-color:#E8D38A;background:#FFF6E6;color:#7A5A12}
.suggest-dd{position:absolute;z-index:60;background:var(--panel);border:1px solid var(--line);border-radius:9px;box-shadow:0 8px 24px #00000022;max-height:340px;overflow:auto}
Expand Down Expand Up @@ -396,18 +399,42 @@ <h2>Aroma</h2>
['results','behaviorCard','aromaCard','footnote','err'].forEach(id=>$(id).style.display='none');
if(on && !$('mixRows').children.length){ mixAddRow(); mixAddRow(); }
});
$('mixResults').addEventListener('click', e=>{
const card=e.target.closest('.mix-ing[data-smi]');
if(card && card.dataset.smi){ $('mixToggle').checked=false; $('mixToggle').dispatchEvent(new Event('change')); q.value=card.dataset.smi; window.scrollTo({top:0,behavior:'smooth'}); run(); }
});
$('mixCheck').addEventListener('click', async ()=>{
const ings=[...$('mixRows').querySelectorAll('input')].map(i=>i.value.trim()).filter(Boolean);
const procs=[...document.querySelectorAll('.proc:checked')].map(c=>c.value);
if(ings.length<2){ $('mixResults').innerHTML='<div class="empty">Add at least two ingredients.</div>'; return; }
$('mixCheck').disabled=true; $('mixCheck').textContent='Checking…';
try{
const r=await post('/api/mixture',{ingredients:ings, processes:procs});
const A=r.active_hazards||[], C=r.conditional_hazards||[];
let html='';
if(!A.length && !C.length) html='<div class="empty">No documented hazards for these ingredients/processes. (Absence of a flag is not a safety clearance.)</div>';
const ing=r.ingredients||[];
if(ing.length){
html+='<div class="bsub">Ingredients (read individually)</div><div class="mix-ings">'+ing.map(it=>`
<div class="mix-ing">
<div class="nb-struct">${it.svg||''}</div>
<div class="nb-info"><div class="nb-name">${it.name||it.smiles}</div>
<div class="kt">${(it.tastes||[]).join(' · ')||'—'}${(it.gras||'').indexOf('in GRAS')===0?' · GRAS':''}</div>
${((it.alerts||[]).length||(it.tox_flags||[]).length)?`<div class="kt" style="color:#8A3A22">⚠ ${[...(it.alerts||[]),...(it.tox_flags||[]).map(f=>'tox:'+f)].join(', ')}</div>`:''}
</div></div>`).join('')+'</div>';
}
const A=r.active_hazards||[], C=r.conditional_hazards||[];
html+='<div class="bsub">Documented mixture hazards</div>';
if(!A.length && !C.length) html+='<div class="empty">None documented for these ingredients/processes. (Not a safety clearance.)</div>';
A.forEach(h=>html+=`<div class="hazard">⚠ <b>${h.possible_product}</b> — from ${h.precursors.join(' + ')}. ${h.note}</div>`);
C.forEach(h=>html+=`<div class="hazard cond">○ <b>${h.possible_product}</b> — from ${h.precursors.join(' + ')}; <b>would form with</b>: ${(h.requires_process||[]).join(', ')}. ${h.note}</div>`);
const pal=r.palette||{}, M=pal.matches||[];
if(M.length){
html+=`<div class="bsub">Single molecules with a similar taste palette (${(pal.target||[]).join(' · ')||'—'})</div><div class="mix-ings">`+M.map(it=>`
<div class="mix-ing" data-smi="${it.smiles}" style="cursor:pointer" title="Click to analyze">
<div class="nb-struct">${it.svg||''}</div>
<div class="nb-info"><div class="nb-name">${it.name||it.smiles}</div><div class="kt">${(it.tastes||[]).join(' · ')}</div></div>
<div class="sim">${Math.round(it.match*100)}% match</div></div>`).join('')+'</div>';
}
html+='<div class="empty" style="font-size:12px">Each ingredient is read individually and screened for documented hazards. How the blend actually <i>tastes</i> together (suppression / synergy) needs your formulation data — not predictable from public data.</div>';
$('mixResults').innerHTML=html;
}catch(e){ $('mixResults').innerHTML='<div class="err">'+e.message+'</div>'; }
finally{ $('mixCheck').disabled=false; $('mixCheck').textContent='Check mixture'; }
Expand Down
Loading