1919"""
2020
2121import threading
22+ from concurrent .futures import ThreadPoolExecutor
2223from functools import lru_cache
2324from pathlib import Path
2425
3233app = 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 )
3564def _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+
123168class 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+
503713def _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" )
526759def api_suggest (qs : str = "" ):
527760 """Rich typeahead over the curated flavor-volatile list — name + SMILES + structure + IUPAC."""
0 commit comments