@@ -1423,13 +1423,15 @@ def norm(col):
14231423 cols = {c : norm (c ).tolist () for c in ("x" , "y" , "x3" , "y3" , "z3" ) if c in df .columns }
14241424 smis , labs = df ["smiles" ].tolist (), df ["label" ].tolist ()
14251425 aromas = df ["aroma_label" ].tolist () if "aroma_label" in df .columns else [None ] * len (smis )
1426+ mouth = (df ["mouthfeel_label" ].tolist () if "mouthfeel_label" in df .columns
1427+ else [None ] * len (smis )) # dominant trigeminal sensation, for color-by-mouthfeel
14261428 # raw physicochemical values (for the interpretable MW×logP×TPSA axes view — real units)
14271429 mw = df ["mw" ].tolist () if "mw" in df .columns else [None ] * len (smis )
14281430 logp = df ["logp" ].tolist () if "logp" in df .columns else [None ] * len (smis )
14291431 tpsa = df ["tpsa" ].tolist () if "tpsa" in df .columns else [None ] * len (smis )
14301432 pts = []
14311433 for i in range (len (smis )):
1432- p = {"label" : labs [i ], "aroma" : aromas [i ], "smiles" : smis [i ],
1434+ p = {"label" : labs [i ], "aroma" : aromas [i ], "mouth" : mouth [ i ], " smiles" : smis [i ],
14331435 "name" : _table_name (smis [i ]) or "" ,
14341436 "mw" : None if mw [i ] != mw [i ] else mw [i ], # NaN -> None
14351437 "logp" : None if logp [i ] != logp [i ] else logp [i ],
@@ -1458,6 +1460,57 @@ def api_map():
14581460_DESIGN = [] # [{smiles, name, tags:set, gras:bool}]
14591461_DESIGN_DESCS = [] # descriptors with enough molecules to offer as options
14601462_DESIGN_MOUTHFEEL = [] # trained mouthfeel/chemesthesis sensations, offered as their own pick-list
1463+ _DESIGN_TASTE = [] # trained taste heads, offered as their own pick-list (namespaced taste:*)
1464+ _TERM_ALIASES = {} # term -> (equivalent terms in the other modality), both directions
1465+
1466+ # Curated flavor<->note synonyms the string normalizer can't reach: genuinely the same material
1467+ # under two names, not a loose association. Deliberately conservative — "lemon"/"citrus" is NOT
1468+ # here, because citrus is broader than lemon and equating them would silently widen a search.
1469+ _TERM_SYNONYMS = {
1470+ "blackcurrant" : "cassis" , # cassis IS blackcurrant
1471+ "tangerine" : "mandarin" , # tangerine IS a mandarin
1472+ "orange blossom" : "neroli" , # neroli IS orange-blossom
1473+ "cilantro" : "coriander" , # cilantro IS coriander leaf
1474+ "chocolate" : "cocoa" ,
1475+ "licorice" : "anise" ,
1476+ "cheese" : "cheesy" ,
1477+ "smoke" : "smoky" ,
1478+ "peppermint" : "minty" ,
1479+ "spearmint" : "minty" ,
1480+ }
1481+
1482+
1483+ def _build_term_aliases ():
1484+ """flavor <-> note aliases, both directions. Auto-derives spelling/adjective pairs (bread ->
1485+ bready, black pepper -> blackpepper) by normalizing away spacing and common suffixes, then
1486+ folds in the curated synonym table. Auto-derivation means new vocabulary keeps linking up
1487+ without anyone maintaining a list."""
1488+ import re
1489+
1490+ def norm (t ):
1491+ t = re .sub (r"[^a-z]" , "" , t .lower ())
1492+ for suf in ("y" , "ic" , "ish" ):
1493+ if t .endswith (suf ) and len (t ) > len (suf ) + 3 :
1494+ t = t [: - len (suf )]
1495+ return t
1496+
1497+ by_norm = {}
1498+ for n in _DESIGN_DESCS :
1499+ by_norm .setdefault (norm (n ), set ()).add (n )
1500+ pairs = set ()
1501+ for f in _FLAVORS :
1502+ for n in by_norm .get (norm (f ), ()):
1503+ if n != f :
1504+ pairs .add ((f , n ))
1505+ for f , n in _TERM_SYNONYMS .items ():
1506+ if f in _FLAVORS and n in _DESIGN_DESCS :
1507+ pairs .add ((f , n ))
1508+ out = {}
1509+ for f , n in pairs : # link both ways
1510+ out .setdefault (f , set ()).add (n )
1511+ out .setdefault (n , set ()).add (f )
1512+ _TERM_ALIASES .clear ()
1513+ _TERM_ALIASES .update ({k : tuple (sorted (v )) for k , v in out .items ()})
14611514
14621515
14631516def _precompute_design ():
@@ -1487,11 +1540,16 @@ def _precompute_design():
14871540 for name , clf in P ._AROMA_MODELS .items (): # model-confident aroma
14881541 for i in np .where (clf .predict_proba (X )[:, 1 ] >= 0.5 )[0 ]:
14891542 tagsets [i ].add (name )
1490- for t in ("sweet" , "bitter" , "umami" ): # taste
1543+ # Taste tags are NAMESPACED ("taste:sweet") for the same reason mouthfeel is: `sweet` is
1544+ # both a taste head and an aroma head, and a bare tag conflated "tastes sweet" with
1545+ # "smells sweet". `bitter` had additionally leaked into the aroma-notes picker.
1546+ # EVERY trained taste head, not just sweet/bitter/umami — sour, salty and tasteless are
1547+ # equally real targets (a neutral, tasteless carrier is a genuine formulation ask).
1548+ for t in sorted (P ._CLASSIFIERS ): # taste
14911549 clf = P ._CLASSIFIERS .get (t )
14921550 if clf is not None :
14931551 for i in np .where (clf .predict_proba (X )[:, 1 ] >= 0.5 )[0 ]:
1494- tagsets [i ].add (t )
1552+ tagsets [i ].add (f"taste: { t } " )
14951553 # Mouthfeel tags are NAMESPACED ("mouthfeel:pungent"), matching how the profile index keys
14961554 # its dims. Without this, picking `pungent` under Mouthfeel returned sharp-SMELLING
14971555 # molecules (acetic acid, ammonia, CO2) from the far larger aroma:pungent set instead of
@@ -1524,11 +1582,16 @@ def _precompute_design():
15241582 # food-safe carriers), plus any design note with >=5 carriers. Namespaced mouthfeel tags
15251583 # are excluded — they're a separate modality with their own pick-list below.
15261584 _DESIGN_DESCS [:] = sorted (
1527- {d for d , n in cnt .items () if n >= 5 and not d . startswith ( "mouthfeel:" )}
1528- | set ( P . _AROMA_MODELS ))
1529- # Mouthfeel terms stay namespaced ("mouthfeel:cooling") so picking `cooling` here matches
1530- # the SENSATION, not the like-named odour note. The UI shows the bare label .
1585+ {d for d , n in cnt .items () if n >= 5 and ":" not in d } | set ( P . _AROMA_MODELS ))
1586+ # Mouthfeel and taste terms stay namespaced ("mouthfeel:cooling", "taste:sweet") so picking
1587+ # `cooling`/`sweet` there matches the SENSATION / the TASTE, not the like-named odour note.
1588+ # The UI shows the bare label. Only tastes with carriers in the pool are offered .
15311589 _DESIGN_MOUTHFEEL [:] = [f"mouthfeel:{ m } " for m in sorted (P ._MOUTHFEEL_MODELS )]
1590+ # EVERY trained taste head is offered, exactly like the aroma heads above — a head with few
1591+ # confident carriers in this pool (sour, salty) must still be selectable, or the picker
1592+ # silently hides a dimension the model can actually read.
1593+ _DESIGN_TASTE [:] = [f"taste:{ t } " for t in sorted (P ._CLASSIFIERS )]
1594+ _build_term_aliases () # needs _FLAVORS + the finished _DESIGN_DESCS
15321595
15331596
15341597def _fpvec (mol ):
@@ -1705,9 +1768,10 @@ def _gras_subs(smi, k=3):
17051768
17061769@app .get ("/api/studio_terms" )
17071770def api_studio_terms ():
1708- """The unified pick-list: curated flavors (grouped by category), matchable aroma-note
1709- descriptors, and mouthfeel sensations — three modalities the studios can target."""
1710- return {"flavors" : _FLAVOR_CATS , "notes" : _DESIGN_DESCS , "mouthfeel" : _DESIGN_MOUTHFEEL }
1771+ """The unified pick-list, one entry per modality the studios can target: curated flavors
1772+ (grouped by category), aroma-note descriptors, mouthfeel sensations, and basic tastes."""
1773+ return {"flavors" : _FLAVOR_CATS , "notes" : _DESIGN_DESCS ,
1774+ "mouthfeel" : _DESIGN_MOUTHFEEL , "taste" : _DESIGN_TASTE }
17111775
17121776
17131777@app .get ("/api/nl" )
@@ -1843,8 +1907,20 @@ def _svg_cell(smi):
18431907 return _svg (smi , 104 , 62 )
18441908
18451909
1910+ @lru_cache (maxsize = 8192 )
1911+ def _tox_flags (smiles ):
1912+ """Tox21 assays this molecule is predicted active in (>=0.5), as a tuple. Caution-only: assay
1913+ activity is INDICATIVE and warrants review, it is never a toxicity determination (see TOX.md).
1914+ Cached because the studio calls it once per result row."""
1915+ mol = Chem .MolFromSmiles (smiles or "" )
1916+ if mol is None :
1917+ return ()
1918+ scr = P .predict_tox (mol )
1919+ return tuple (a ["assay" ] for a in scr .get ("assays" , []) if (a .get ("probability" ) or 0 ) >= 0.5 )
1920+
1921+
18461922@app .get ("/api/studio" )
1847- def api_studio (terms : str = "" , gras : int = 0 , offset : int = 0 , limit : int = 20 ):
1923+ def api_studio (terms : str = "" , gras : int = 0 , no_tox : int = 0 , offset : int = 0 , limit : int = 20 ):
18481924 """Unified search: given any mix of flavors and notes, rank the molecules that carry them.
18491925 Each molecule scores by how many distinct picked terms it matches (a flavor's character
18501926 molecule matches that flavor; a molecule with a note matches that note)."""
@@ -1853,6 +1929,10 @@ def api_studio(terms: str = "", gras: int = 0, offset: int = 0, limit: int = 20)
18531929 return {"items" : [], "requested" : want , "total_matches" : 0 , "offset" : offset , "limit" : limit }
18541930 flavor_terms = [t for t in want if t in _FLAVORS ]
18551931 note_terms = [t for t in want if t not in _FLAVORS ]
1932+ # cross-list dual-purpose terms: a flavor and its like-named note are the same concept spelled
1933+ # differently ("black pepper"/"blackpepper", "butter"/"buttery", "cilantro"/"coriander"), so
1934+ # picking either should surface the other's molecules too.
1935+ note_terms += [a for t in want for a in _TERM_ALIASES .get (t , ()) if a not in note_terms ]
18561936 cand = {} # skeleton -> {smiles, name, gras, matched:set}
18571937
18581938 def add (smi , name , is_gras , term ):
@@ -1878,6 +1958,11 @@ def add(smi, name, is_gras, term):
18781958 add (m ["smiles" ], m ["name" ], m ["gras" ], nt )
18791959 cand [Chem .MolToInchiKey (Chem .MolFromSmiles (m ["smiles" ])).split ("-" )[0 ]]["tags" ] = m ["tags" ]
18801960 scored = [r for r in cand .values () if not (gras and not r ["gras" ])]
1961+ # The 12 Tox21 heads aren't design TARGETS — nobody formulates *for* assay activity — but they
1962+ # shouldn't be invisible either, so they surface per result and can filter the list. Still
1963+ # caution-only: an assay flag means "review this", never "this is toxic".
1964+ if no_tox :
1965+ scored = [r for r in scored if not _tox_flags (r ["smiles" ])]
18811966 scored .sort (key = lambda r : (- len (r ["matched" ]), not r ["gras" ], r ["name" ] == "" ))
18821967 items = []
18831968 for r in scored [offset :offset + limit ]:
@@ -1886,6 +1971,7 @@ def add(smi, name, is_gras, term):
18861971 "matched" : matched , "n_matched" : len (matched ),
18871972 "svg" : _svg (r ["smiles" ], 108 , 78 ),
18881973 "other" : sorted (t for t in r ["tags" ] if t not in r ["matched" ])[:5 ],
1974+ "tox_flags" : _tox_flags (r ["smiles" ]),
18891975 "subs" : _gras_subs (r ["smiles" ])})
18901976 return {"items" : items , "requested" : want , "flavor_terms" : flavor_terms , "note_terms" : note_terms ,
18911977 "total_matches" : len (scored ), "offset" : offset , "limit" : limit }
0 commit comments