Skip to content

Commit 9d79448

Browse files
feat(demo): mixture per-ingredient reads + single-molecule taste-palette match (#71)
Mixture mode now shows, in addition to the documented-hazard screen: - each INGREDIENT read individually (structure + name + tastes + GRAS + any structural-alert/tox flags); - SINGLE MOLECULES with a similar taste palette to the blend — predict.palette_match() unions the ingredients' tastes and finds labeled molecules whose known taste-label set best matches (Jaccard), each clickable to analyze. Framed honestly: a blend's real palette isn't the union of its parts (suppression/synergy needs their data) — this is a structural-label approximation. Verified: glucose + caffeine -> ingredients [Hexose sweet/bitter, Caffeine bitter]; palette {sweet,bitter} -> isoleucine / a flavanone / ... at 100% label match. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 054957b commit 9d79448

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

training/app.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,33 @@ class MixtureQuery(BaseModel):
125125

126126
@app.post("/api/mixture")
127127
def api_mixture(m: MixtureQuery):
128-
"""Documented dangerous-mixture screen over 2..n ingredients (predict.check_mixture)."""
128+
"""Per-ingredient reads + documented-hazard screen + a single-molecule palette match."""
129129
smis = [s for s in (_resolve(x) for x in m.ingredients) if s]
130-
return P.check_mixture(smis, m.processes)
130+
out = P.check_mixture(smis, m.processes)
131+
reads, palette = [], set()
132+
for s in smis:
133+
r = P.predict(s)
134+
tp = r.get("taste_profile", [])
135+
tastes = [t for t in ("sweet", "bitter", "umami") if isinstance(r.get(t), (int, float)) and r[t] >= 0.5]
136+
if r.get("sour"):
137+
tastes.append("sour")
138+
if r.get("salty") is True:
139+
tastes.append("salty")
140+
for t in (r.get("known_tastes") or []):
141+
if t not in tastes:
142+
tastes.append(t)
143+
palette.update(tastes)
144+
reads.append({"smiles": r["smiles"], "name": _names(s)[0], "svg": _svg(s, 110, 80),
145+
"top_taste": tp[0]["taste"] if tp else None, "tastes": tastes,
146+
"gras": r["safety"]["gras_status"], "alerts": r["safety"]["structural_alerts"],
147+
"tox_flags": r["safety"]["tox_screen"].get("flags", []) if r["applicability"]["in_domain"] else []})
148+
pal = P.palette_match(sorted(palette), k=5)
149+
for mt in pal.get("matches", []):
150+
mt["svg"] = _svg(mt["smiles"], 110, 80)
151+
mt["name"] = _names(mt["smiles"])[0]
152+
out["ingredients"] = reads
153+
out["palette"] = pal
154+
return out
131155

132156

133157
def _load_suggest():

training/predict.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,31 @@ def substitute(smiles: str, k: int = 8, min_similarity: float = 0.0) -> dict:
710710
"basis": "Tanimoto / Morgan r2 2048-bit over labeled molecules"}
711711

712712

713+
def palette_match(tastes, k=5):
714+
"""Single molecules whose KNOWN taste-label set best matches a target taste set
715+
(Jaccard over sweet/bitter/umami/sour/salty). NOT a blend-perception model — a
716+
label-set similarity over the labeled molecules, for the 'one molecule like this
717+
mixture' view. A blend's actual palette isn't the union of its parts (suppression /
718+
synergy); this is an honest structural-label approximation."""
719+
if _SUB_INDEX is None:
720+
_build_sub_index()
721+
_, smis, tlist = _SUB_INDEX
722+
target = set(tastes)
723+
if not target or not smis:
724+
return {"target": sorted(target), "matches": []}
725+
scored = []
726+
for smi, ts in zip(smis, tlist):
727+
s = set(ts)
728+
if not s:
729+
continue
730+
j = len(target & s) / len(target | s)
731+
if j > 0:
732+
scored.append((j, smi, sorted(s)))
733+
scored.sort(key=lambda e: -e[0])
734+
return {"target": sorted(target),
735+
"matches": [{"smiles": sm, "tastes": ts, "match": round(j, 2)} for j, sm, ts in scored[:k]]}
736+
737+
713738
def predict(smiles: str, include_aroma: bool = False) -> dict:
714739
mol = Chem.MolFromSmiles(smiles)
715740
if mol is None:

training/workbench.html

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@
9393
.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}
9494
.mix-btn:disabled{opacity:.5}
9595
#mixResults{margin-top:14px}
96+
.mix-ings{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}
97+
.mix-ing{display:flex;gap:10px;align-items:center;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
98+
.mix-ing:hover{background:var(--accent-soft)}
9699
.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}
97100
.hazard.cond{border-color:#E8D38A;background:#FFF6E6;color:#7A5A12}
98101
.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}
@@ -396,18 +399,42 @@ <h2>Aroma</h2>
396399
['results','behaviorCard','aromaCard','footnote','err'].forEach(id=>$(id).style.display='none');
397400
if(on && !$('mixRows').children.length){ mixAddRow(); mixAddRow(); }
398401
});
402+
$('mixResults').addEventListener('click', e=>{
403+
const card=e.target.closest('.mix-ing[data-smi]');
404+
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(); }
405+
});
399406
$('mixCheck').addEventListener('click', async ()=>{
400407
const ings=[...$('mixRows').querySelectorAll('input')].map(i=>i.value.trim()).filter(Boolean);
401408
const procs=[...document.querySelectorAll('.proc:checked')].map(c=>c.value);
402409
if(ings.length<2){ $('mixResults').innerHTML='<div class="empty">Add at least two ingredients.</div>'; return; }
403410
$('mixCheck').disabled=true; $('mixCheck').textContent='Checking…';
404411
try{
405412
const r=await post('/api/mixture',{ingredients:ings, processes:procs});
406-
const A=r.active_hazards||[], C=r.conditional_hazards||[];
407413
let html='';
408-
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>';
414+
const ing=r.ingredients||[];
415+
if(ing.length){
416+
html+='<div class="bsub">Ingredients (read individually)</div><div class="mix-ings">'+ing.map(it=>`
417+
<div class="mix-ing">
418+
<div class="nb-struct">${it.svg||''}</div>
419+
<div class="nb-info"><div class="nb-name">${it.name||it.smiles}</div>
420+
<div class="kt">${(it.tastes||[]).join(' · ')||'—'}${(it.gras||'').indexOf('in GRAS')===0?' · GRAS':''}</div>
421+
${((it.alerts||[]).length||(it.tox_flags||[]).length)?`<div class="kt" style="color:#8A3A22">⚠ ${[...(it.alerts||[]),...(it.tox_flags||[]).map(f=>'tox:'+f)].join(', ')}</div>`:''}
422+
</div></div>`).join('')+'</div>';
423+
}
424+
const A=r.active_hazards||[], C=r.conditional_hazards||[];
425+
html+='<div class="bsub">Documented mixture hazards</div>';
426+
if(!A.length && !C.length) html+='<div class="empty">None documented for these ingredients/processes. (Not a safety clearance.)</div>';
409427
A.forEach(h=>html+=`<div class="hazard">⚠ <b>${h.possible_product}</b> — from ${h.precursors.join(' + ')}. ${h.note}</div>`);
410428
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>`);
429+
const pal=r.palette||{}, M=pal.matches||[];
430+
if(M.length){
431+
html+=`<div class="bsub">Single molecules with a similar taste palette (${(pal.target||[]).join(' · ')||'—'})</div><div class="mix-ings">`+M.map(it=>`
432+
<div class="mix-ing" data-smi="${it.smiles}" style="cursor:pointer" title="Click to analyze">
433+
<div class="nb-struct">${it.svg||''}</div>
434+
<div class="nb-info"><div class="nb-name">${it.name||it.smiles}</div><div class="kt">${(it.tastes||[]).join(' · ')}</div></div>
435+
<div class="sim">${Math.round(it.match*100)}% match</div></div>`).join('')+'</div>';
436+
}
437+
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>';
411438
$('mixResults').innerHTML=html;
412439
}catch(e){ $('mixResults').innerHTML='<div class="err">'+e.message+'</div>'; }
413440
finally{ $('mixCheck').disabled=false; $('mixCheck').textContent='Check mixture'; }

0 commit comments

Comments
 (0)