Skip to content

Commit b5e5f9e

Browse files
Merge pull request #183 from echelonts/feat/formulation-studio
Formulation Studio — read a whole recipe before you pour
2 parents 4d487a0 + ae14ae5 commit b5e5f9e

3 files changed

Lines changed: 417 additions & 5 deletions

File tree

training/app.py

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"""
2020

2121
import threading
22+
from concurrent.futures import ThreadPoolExecutor
2223
from functools import lru_cache
2324
from pathlib import Path
2425

@@ -32,11 +33,44 @@
3233
app = FastAPI(title="Flavor Workbench (demo)")
3334

3435

36+
def _load_name2smiles():
37+
"""Local name -> SMILES index (instant, offline) so library/demo ingredients resolve without a
38+
PubChem round-trip. Built from master_enrichment.parquet (~8k named molecules) + the suggest
39+
CSV. Only genuinely-unknown names fall through to live PubChem in _resolve()."""
40+
idx = {}
41+
try:
42+
import pandas as pd
43+
df = pd.read_parquet("master_enrichment.parquet")
44+
for nm, smi in zip(df["name"], df["smiles"]):
45+
if isinstance(nm, str) and isinstance(smi, str) and nm.strip() and smi.strip():
46+
idx.setdefault(nm.strip().lower(), smi)
47+
except Exception: # noqa: BLE001 — table absent / no pandas; live lookup still covers it
48+
pass
49+
try:
50+
import csv
51+
with open("flavor_volatiles.csv", encoding="utf-8") as fh:
52+
for r in csv.DictReader(fh):
53+
if r.get("name") and r.get("smiles"):
54+
idx.setdefault(r["name"].strip().lower(), r["smiles"])
55+
except Exception: # noqa: BLE001 — no suggest file; fine
56+
pass
57+
return idx
58+
59+
60+
_NAME2SMILES = _load_name2smiles()
61+
62+
63+
@lru_cache(maxsize=8192)
3564
def _resolve(text: str):
36-
"""Accept a SMILES or a compound name; return canonical SMILES or None."""
65+
"""Accept a SMILES or a compound name; return canonical SMILES or None. Memoized. Tries a
66+
local name index first (instant, offline) so library/demo molecules never touch the network;
67+
only unknown names hit PubChem live (~1-2 s), which is why caching + the index matter."""
3768
text = (text or "").strip()
3869
if Chem.MolFromSmiles(text):
3970
return text
71+
hit = _NAME2SMILES.get(text.lower())
72+
if hit and Chem.MolFromSmiles(hit):
73+
return hit
4074
try:
4175
import pubchempy as pcp
4276
hits = pcp.get_compounds(text, "name")
@@ -120,6 +154,17 @@ def _names(smi):
120154
return None, None
121155

122156

157+
def _name_local(smi):
158+
"""Common name from the precomputed table ONLY (instant, no network) — for hot loops like
159+
the Formulation Studio's candidate ranking, where a live PubChem call per candidate would
160+
stall the request. Returns None for molecules not in the table (they're simply skipped)."""
161+
mol = Chem.MolFromSmiles(smi) if smi else None
162+
if mol is None:
163+
return None
164+
hit = _NAME_TABLE.get(Chem.MolToInchiKey(mol).split("-")[0])
165+
return hit[0] if hit else None
166+
167+
123168
class Query(BaseModel):
124169
smiles: str
125170
k: int = 8
@@ -500,6 +545,171 @@ def api_mixture(m: MixtureQuery):
500545
return out
501546

502547

548+
# ── Formulation Studio ──────────────────────────────────────────────────────
549+
# A recipe (ingredients + optional ppm) -> blended note-profile, dosing-balance /
550+
# overpowering-component flag, hazard screen, and (with a target) a gap analysis.
551+
# The whole point: read a formulation "before you pour" and save bench runs.
552+
_VOL_W = {"high": 3.0, "moderate": 2.0, "low": 1.0}
553+
_PROFILE_FLOOR = 0.35 # ignore each molecule's faint (<0.35 prob) heads so noise can't stack
554+
555+
556+
class FormulationQuery(BaseModel):
557+
ingredients: list[dict] = [] # [{name|smiles, ppm?}]
558+
processes: list[str] = [] # high_heat / refining / fermentation
559+
target: list[str] = [] # desired aroma notes for the gap analysis
560+
561+
562+
@app.post("/api/formulation")
563+
def api_formulation(f: FormulationQuery):
564+
"""Formulation Studio engine — reads a full recipe before it is poured.
565+
566+
Returns the blended note-profile (which aromas the mix reads as, and which
567+
ingredient drives each), the dosing balance / overpowering-component flag, a
568+
documented-hazard screen, and — when a target profile is supplied — a gap
569+
analysis with concrete add/cut moves.
570+
571+
HONEST SCOPE (surfaced in `data_gates`): the profile is DIRECTIONAL. Each
572+
molecule's predicted notes are weighted by OAV where odor thresholds are
573+
loaded, else by mass x volatility. It is NOT a calibrated finished-blend
574+
intensity map — suppression/synergy and true intensity need the customer's
575+
odor-threshold / panel data (a learned mixture model)."""
576+
# Resolve every ingredient concurrently — a name is a live PubChem lookup (~1-2 s each), so
577+
# a serial loop makes a big formula crawl. HTTP + name lookups release the GIL; memoized.
578+
def _resolve_one(it):
579+
raw = (it.get("smiles") or it.get("name") or "").strip()
580+
if not raw:
581+
return None
582+
smi = _resolve(raw)
583+
m = Chem.MolFromSmiles(smi) if smi else None
584+
if m is None:
585+
return {"raw": raw, "unresolved": True}
586+
smi = Chem.MolToSmiles(m) # canonical, so it keys against analyze_balance's rows
587+
ppm = it.get("ppm")
588+
try:
589+
ppm = float(ppm) if ppm not in (None, "") else None
590+
except (TypeError, ValueError):
591+
ppm = None
592+
# local-table name (instant) or the user's own input — avoids a SECOND live PubChem
593+
# round-trip per ingredient (_resolve already paid one); "decanal" reads fine as-is.
594+
return {"raw": raw, "smiles": smi, "ppm": ppm, "name": _name_local(smi) or raw}
595+
596+
resolved, unresolved = [], []
597+
ings = [it for it in f.ingredients if (it.get("smiles") or it.get("name") or "").strip()]
598+
if ings:
599+
with ThreadPoolExecutor(max_workers=min(8, len(ings))) as ex:
600+
for out in ex.map(_resolve_one, ings):
601+
if out is None:
602+
continue
603+
(unresolved.append(out["raw"]) if out.get("unresolved") else resolved.append(out))
604+
if not resolved:
605+
return {"error": "no resolvable ingredients", "unresolved": unresolved, "profile": []}
606+
607+
# dosing balance — OAV ranking where thresholds are loaded, else volatility tier
608+
bal = P.analyze_balance([{"smiles": r["smiles"], "ppm": r["ppm"], "name": r["name"]}
609+
for r in resolved])
610+
per = {row["smiles"]: row for row in bal.get("per_ingredient", []) if row.get("smiles")}
611+
612+
# per-ingredient odor-impact weight (cheap, serial)
613+
for r in resolved:
614+
row = per.get(r["smiles"], {})
615+
oav = row.get("OAV")
616+
if oav:
617+
w = float(oav) # quantitative: odor activity value
618+
else:
619+
vt = (row.get("volatility") or "moderate").split()[0]
620+
w = (r["ppm"] or 1.0) * _VOL_W.get(vt, 2.0) # directional: mass x volatility tier
621+
r["weight"] = round(w, 3)
622+
623+
# aroma prediction is the per-molecule cost (24 RF heads). It's CPU-bound and does NOT
624+
# release the GIL cleanly, so threading it hurts (contention) — keep it serial. Speed comes
625+
# from memoization (repeats/re-analyses are instant) and the startup pre-warm of demo mols.
626+
aromas = [P.predict_aroma(r["smiles"]) for r in resolved]
627+
628+
# weighted aggregate note-profile: sum (weight x per-molecule note score) across ingredients
629+
profile, contrib = {}, {}
630+
for r, pa in zip(resolved, aromas):
631+
r["aromas"] = [d["odor"] for d in pa.get("top", [])][:5]
632+
for d in pa.get("descriptors", []):
633+
if d["score"] < _PROFILE_FLOOR:
634+
continue
635+
c = r["weight"] * d["score"]
636+
profile[d["odor"]] = profile.get(d["odor"], 0.0) + c
637+
contrib.setdefault(d["odor"], []).append((r["name"], c))
638+
total = sum(profile.values()) or 1.0
639+
prof = sorted(
640+
({"note": n, "pct": round(100 * v / total, 1),
641+
"drivers": [nm for nm, _ in sorted(contrib[n], key=lambda t: -t[1])[:2]]}
642+
for n, v in profile.items()),
643+
key=lambda d: -d["pct"])
644+
645+
# overpowering-component flag — the "too heavy in one item" read. Works in BOTH bases
646+
# because it uses the blend weights we just computed, not only the quantitative OAV branch.
647+
overpowering = None
648+
wsum = sum(r["weight"] for r in resolved) or 1.0
649+
if len(resolved) > 1:
650+
top = max(resolved, key=lambda r: r["weight"])
651+
share = top["weight"] / wsum
652+
if share > 0.55:
653+
overpowering = {"name": top["name"], "share": round(100 * share),
654+
"drives": [p["note"] for p in prof if top["name"] in p.get("drivers", [])][:3]}
655+
656+
# target gap analysis — what the brief asks for vs what the blend reads as
657+
gap = None
658+
if [t for t in f.target if t.strip()]:
659+
tset = [t.strip().lower() for t in f.target if t.strip()]
660+
pmap = {p["note"]: p for p in prof}
661+
under, over, on_target = [], [], []
662+
for t in tset:
663+
hit = pmap.get(t)
664+
pct = hit["pct"] if hit else 0.0
665+
if pct < 8: # target note missing / too faint
666+
sug = P.palette_match([], [t], k=8)
667+
gras_adds, other_adds = [], [] # prefer food-safe (GRAS) carriers
668+
for mt in sug.get("matches", []):
669+
nm = _name_local(mt["smiles"]) # local-only (no network) — named carriers, fast
670+
if not nm or nm in gras_adds or nm in other_adds:
671+
continue
672+
cmol = Chem.MolFromSmiles(mt["smiles"]) # cheap GRAS lookup — no full predict() pipeline
673+
is_gras = cmol is not None and P._gras_status(cmol).startswith("in GRAS")
674+
(gras_adds if is_gras else other_adds).append(nm)
675+
if len(gras_adds) >= 2:
676+
break
677+
under.append({"note": t, "pct": pct, "add": (gras_adds + other_adds)[:2]})
678+
else:
679+
on_target.append({"note": t, "pct": pct})
680+
for p in prof: # loud notes nobody asked for
681+
if p["note"] not in tset and p["pct"] >= 15:
682+
over.append({"note": p["note"], "pct": p["pct"], "cut": p["drivers"][:1]})
683+
gap = {"under": under, "over": over[:4], "on_target": on_target}
684+
685+
haz = P.check_mixture([r["smiles"] for r in resolved], f.processes)
686+
quant = (bal.get("basis") or "").startswith("quantitative")
687+
return {
688+
"ingredients": [{"name": r["name"], "smiles": r["smiles"], "ppm": r["ppm"],
689+
"weight": r["weight"], "aromas": r["aromas"],
690+
"svg": _svg(r["smiles"], 110, 80)} for r in resolved],
691+
"unresolved": unresolved,
692+
"profile": prof,
693+
"weighting": bal.get("basis"),
694+
"overpowering": overpowering,
695+
"balance_warnings": bal.get("balance_warnings", []),
696+
"impact_ranking": bal.get("impact_ranking", []),
697+
"gap": gap,
698+
"active_hazards": haz.get("active_hazards", []),
699+
"conditional_hazards": haz.get("conditional_hazards", []),
700+
"data_gates": {
701+
"intensity": ("Directional note profile — contributions weighted by "
702+
+ ("OAV (odor thresholds are loaded)." if quant else
703+
"mass x volatility. Load odor thresholds for quantitative OAV / calibrated intensity — comes with your data.")),
704+
"synergy": ("Notes are assumed to add independently. Real blends show suppression / "
705+
"synergy (1+1 != 2); a learned mixture model needs formulation->panel "
706+
"data (your data) or a licensed set."),
707+
},
708+
"scope_note": bal.get("scope_note"),
709+
"disclaimer": bal.get("disclaimer"),
710+
}
711+
712+
503713
def _load_suggest():
504714
import csv
505715
try:
@@ -522,6 +732,29 @@ def _precompute_iupac():
522732
threading.Thread(target=_precompute_iupac, daemon=True).start()
523733

524734

735+
# Pre-warm the Formulation Studio's demo molecules (starter formulas) at startup, in the
736+
# background, so the first click on an example is instant. predict_aroma is CPU-bound (~1.3 s
737+
# cold per molecule) but memoized — warming these fills the cache before anyone reaches them.
738+
_FORMULATION_WARM = [
739+
"vanillin", "ethyl vanillin", "ethyl maltol", "limonene", "citral", "linalool",
740+
"ethyl butyrate", "menthol", "eucalyptol", "methyl salicylate", "benzaldehyde",
741+
]
742+
743+
744+
def _prewarm_formulation():
745+
for n in _FORMULATION_WARM:
746+
try:
747+
smi = _resolve(n)
748+
m = Chem.MolFromSmiles(smi) if smi else None
749+
if m is not None:
750+
P.predict_aroma(Chem.MolToSmiles(m))
751+
except Exception: # noqa: BLE001 — best-effort warmup; a miss just means a cold first hit
752+
pass
753+
754+
755+
threading.Thread(target=_prewarm_formulation, daemon=True).start()
756+
757+
525758
@app.get("/api/suggest")
526759
def api_suggest(qs: str = ""):
527760
"""Rich typeahead over the curated flavor-volatile list — name + SMILES + structure + IUPAC."""

training/predict.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
peptides or non-ionic salty compounds (little data, weak structure-activity).
4444
"""
4545

46+
from functools import lru_cache
4647
from pathlib import Path
4748

4849
import joblib
@@ -174,6 +175,22 @@ def _ik1(*smiles):
174175
}.items():
175176
_EU_ALLERGEN_IKS.update({k: _nm for k in _ik1(_smi)})
176177

178+
def _load_rf(path):
179+
"""Load a joblib RF head and pin n_jobs=1. These forests were trained with n_jobs=-1, which
180+
makes a SINGLE-sample predict_proba spawn a joblib thread pool on every call — ~150 ms of
181+
pure overhead that dwarfs the actual work and thrashes all cores under any concurrency.
182+
Single-threaded C tree traversal is far faster for our one-row inference, and it releases
183+
the GIL, so the app can parallelize a level up (e.g. per-ingredient in the Formulation
184+
Studio) instead of fighting joblib. Measured: ~3.8 s -> ~1.2 s per 24-head aroma read."""
185+
mdl = joblib.load(path)
186+
if hasattr(mdl, "n_jobs"):
187+
try:
188+
mdl.n_jobs = 1
189+
except Exception: # noqa: BLE001 — some wrapped estimators reject the set; harmless
190+
pass
191+
return mdl
192+
193+
177194
# load whatever classifier heads exist (sweet/bitter/umami...) + intensity
178195
_CLASSIFIERS = {}
179196
_INTENSITY = None
@@ -182,9 +199,9 @@ def _ik1(*smiles):
182199
for p in TASTE.glob("*_rf.joblib"):
183200
name = p.stem.replace("_rf", "")
184201
if name == "sweet_intensity":
185-
_INTENSITY = joblib.load(p)
202+
_INTENSITY = _load_rf(p)
186203
else:
187-
_CLASSIFIERS[name] = joblib.load(p)
204+
_CLASSIFIERS[name] = _load_rf(p)
188205
_tm = TASTE / "manifest.json"
189206
if _tm.exists():
190207
import json as _json
@@ -196,7 +213,7 @@ def _ik1(*smiles):
196213
_TOX_DIR = Path("tox_models")
197214
if _TOX_DIR.exists():
198215
for p in _TOX_DIR.glob("*_rf.joblib"):
199-
_TOX_MODELS[p.stem.replace("_rf", "")] = joblib.load(p)
216+
_TOX_MODELS[p.stem.replace("_rf", "")] = _load_rf(p)
200217

201218
# Odor-descriptor heads (public-domain HSDB corpus, presence/absence). Loaded if trained.
202219
# Real, commercial-clean aroma predictions — NOT intensity (see docs/AROMA.md).
@@ -205,7 +222,7 @@ def _ik1(*smiles):
205222
_AROMA_DIR = Path("aroma_models")
206223
if _AROMA_DIR.exists():
207224
for p in _AROMA_DIR.glob("*_clf.joblib"):
208-
_AROMA_MODELS[p.stem.replace("_clf", "")] = joblib.load(p)
225+
_AROMA_MODELS[p.stem.replace("_clf", "")] = _load_rf(p)
209226
_mf = _AROMA_DIR / "manifest.json"
210227
if _mf.exists():
211228
import json as _json
@@ -706,6 +723,7 @@ def analyze_balance(ingredients):
706723
}
707724

708725

726+
@lru_cache(maxsize=8192)
709727
def predict_aroma(smiles, top_k=8, threshold=0.5):
710728
"""Predicted odor descriptors from RandomForest heads trained on the PUBLIC-DOMAIN HSDB
711729
odor corpus (see docs/AROMA.md). Returns the descriptors the model scores above threshold,

0 commit comments

Comments
 (0)