From ce7977a690e89281d66f2c0bd54e74142dc369f6 Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:44:23 +0000 Subject: [PATCH] feat(demo): mixture per-ingredient reads + single-molecule taste-palette match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- training/app.py | 28 ++++++++++++++++++++++++++-- training/predict.py | 25 +++++++++++++++++++++++++ training/workbench.html | 31 +++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/training/app.py b/training/app.py index 0c9f4ef..c868588 100644 --- a/training/app.py +++ b/training/app.py @@ -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(): diff --git a/training/predict.py b/training/predict.py index 8fc6733..8c025f6 100644 --- a/training/predict.py +++ b/training/predict.py @@ -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: diff --git a/training/workbench.html b/training/workbench.html index ead416c..f271c5d 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -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} @@ -396,6 +399,10 @@