@@ -239,6 +239,11 @@ def _load_rf(path):
239239# molecule is in our labeled set, we report the verified fact instead of a guess.
240240_KNOWN = {} # inchikey -> {taste: 1}
241241_MASTER = Path ("taste_master.parquet" )
242+ # The neighbor / substitute reference set: the FULL molecule universe (every structure we know,
243+ # ~8.8k) so structural neighbors and profile substitutes can surface ANY molecule — e.g. ethyl
244+ # vanillin as the top vanillin substitute — not just the taste-labelled subset. Falls back to
245+ # taste_master when the enrichment table hasn't been built yet.
246+ _UNIVERSE = Path ("master_enrichment.parquet" )
242247if _MASTER .exists ():
243248 import pandas as pd
244249 _m = pd .read_parquet (_MASTER )
@@ -1061,57 +1066,99 @@ def _taste_profile(out):
10611066# tool (swap an expensive or supply-constrained ingredient for a close analogue,
10621067# with its known tastes shown). This is the clean Track-A core; the product
10631068# (Track B, #22) mirrors it as a pgvector ANN query over the same fingerprints.
1064- _SUB_INDEX = None # lazily built: (fps, smiles, known_tastes, predicted_aromas)
1069+ # lazily built: (fps, smiles, known_tastes, predicted_aromas, profiles, profile_dims)
1070+ # profiles: an (N x D) float32 matrix of predicted head SCORES — taste heads then aroma heads —
1071+ # the "flavor profile" vector used for profile-based substitutes (vs the fingerprint fps used for
1072+ # structural neighbors). profile_dims labels the columns.
1073+ _SUB_INDEX = None
10651074_SUB_LOCK = _threading .Lock () # guards the one-time index build against concurrent callers
10661075
10671076
1077+ def _profile_heads ():
1078+ """The ordered head list backing a flavor-profile vector: taste heads then aroma heads.
1079+ Same order is used at index-build and query time so the vectors line up."""
1080+ return sorted (_CLASSIFIERS ), list (_AROMA_MODELS )
1081+
1082+
10681083def _build_sub_index ():
10691084 global _SUB_INDEX
1085+ import numpy as np
1086+ # Fast path: load the precomputed profile index (build_profile_index.py). The 170-head
1087+ # inference over ~8.8k molecules is slow (~3 min); the cache makes startup instant. We only
1088+ # rebuild the cheap Morgan fingerprints from SMILES on load.
1089+ cache = Path ("profile_index.npz" )
1090+ if cache .exists ():
1091+ z = np .load (cache , allow_pickle = True )
1092+ smis = [str (s ) for s in z ["smiles" ]]
1093+ tastes = [[t for t in str (s ).split ("," ) if t .strip ()] for s in z ["taste_documented" ]]
1094+ aromas = [[a for a in str (s ).split ("," ) if a .strip ()] for s in z ["aromas" ]]
1095+ fps = [_MORGAN .GetFingerprint (Chem .MolFromSmiles (s )) for s in smis ]
1096+ _SUB_INDEX = (fps , smis , tastes , aromas , z ["profiles" ], list (z ["dims" ]))
1097+ return
10701098 fps , smis , tastes , feats = [], [], [], []
1071- if _MASTER .exists ():
1072- import numpy as np
1099+ profiles , profile_dims = None , []
1100+ src = _UNIVERSE if _UNIVERSE .exists () else _MASTER
1101+ if src .exists ():
10731102 import pandas as pd
1074- m = pd .read_parquet (_MASTER )
1103+ m = pd .read_parquet (src )
10751104 basic = [t for t in ("sweet" , "bitter" , "umami" , "sour" , "salty" ) if t in m .columns ]
1105+ has_documented = "taste_documented" in m .columns # enrichment: comma-separated string
1106+ seen_skel = set ()
10761107 for _ , r in m .iterrows ():
10771108 mol = Chem .MolFromSmiles (str (r ["smiles" ]))
10781109 if mol is None :
10791110 continue
1111+ skel = Chem .MolToInchiKey (mol ).split ("-" )[0 ]
1112+ if skel in seen_skel : # dedupe by connectivity so the universe doesn't repeat a molecule
1113+ continue
1114+ seen_skel .add (skel )
10801115 fps .append (_MORGAN .GetFingerprint (mol ))
10811116 smis .append (Chem .MolToSmiles (mol ))
1082- tastes .append ([t for t in basic if r .get (t ) == 1 ])
1117+ if has_documented :
1118+ tastes .append ([t for t in str (r .get ("taste_documented" ) or "" ).split ("," ) if t .strip ()])
1119+ else :
1120+ tastes .append ([t for t in basic if r .get (t ) == 1 ])
10831121 feats .append (_feat (mol )[0 ])
1084- # predicted aroma descriptors per molecule (batched over the 16 heads) — so the palette
1085- # match can score aroma as well as taste, once, at first use
1122+ # One-time batched inference over the whole labeled set. We keep BOTH:
1123+ # - the thresholded confident-aroma NAME list per molecule (for display / palette match)
1124+ # - the full head-SCORE matrix (taste heads + aroma heads) for profile-based substitutes.
1125+ # Kept at n_jobs=1: a single big predict_proba is already vectorized C; joblib fan-out here
1126+ # thrashed under concurrency. Reusing this index is what makes the endpoints fast.
10861127 aromas = [[] for _ in smis ]
1087- if _AROMA_MODELS and feats :
1128+ if feats :
10881129 X = np .vstack (feats )
1089- # One-time batch over the whole labeled set (~8k rows x 24 heads). Kept at n_jobs=1:
1090- # a single big predict_proba is already vectorized C, and joblib fan-out here just
1091- # thrashed under concurrency. Reusing these aromas is what makes the endpoint fast.
1092- for name , clf in _AROMA_MODELS . items ():
1093- col = clf . predict_proba ( X )[:, 1 ]
1130+ taste_heads , aroma_heads = _profile_heads ()
1131+ cols = [ _CLASSIFIERS [ t ]. predict_proba ( X )[:, 1 ] for t in taste_heads ]
1132+ for name in aroma_heads :
1133+ col = _AROMA_MODELS [ name ]. predict_proba ( X )[:, 1 ]
1134+ cols . append ( col )
10941135 for i in range (len (smis )):
10951136 if col [i ] >= 0.5 :
10961137 aromas [i ].append (name )
1138+ profile_dims = [f"taste:{ t } " for t in taste_heads ] + [f"aroma:{ a } " for a in aroma_heads ]
1139+ profiles = np .column_stack (cols ).astype ("float32" ) if cols else None
10971140 else :
10981141 aromas = []
1099- _SUB_INDEX = (fps , smis , tastes , aromas )
1142+ _SUB_INDEX = (fps , smis , tastes , aromas , profiles , profile_dims )
11001143
11011144
1102- def substitute (smiles : str , k : int = 8 , min_similarity : float = 0.0 ) -> dict :
1103- """Nearest-neighbor substitution: the k labeled molecules most structurally
1104- similar to the query (Tanimoto over Morgan fingerprints), each with its known
1105- tastes. The reformulation / cost-down tool — swap an ingredient for a close
1106- analogue. Returns {'neighbors': [...]} ranked by similarity (self excluded)."""
1107- mol = Chem .MolFromSmiles (smiles )
1108- if mol is None :
1109- return {"error" : f"unparseable SMILES: { smiles } " }
1145+ def _ensure_sub_index ():
11101146 if _SUB_INDEX is None : # double-checked lock: a request during the startup build waits for
11111147 with _SUB_LOCK : # that one build instead of kicking off a second (which would thrash cores)
11121148 if _SUB_INDEX is None :
11131149 _build_sub_index ()
1114- fps , smis , tastes , _aromas = _SUB_INDEX
1150+
1151+
1152+ def structural_neighbors (smiles : str , k : int = 8 , min_similarity : float = 0.0 ) -> dict :
1153+ """STRUCTURAL neighbors: the k labeled molecules most structurally similar to the query
1154+ (Tanimoto over Morgan fingerprints), each with its known tastes. Structural look-alikes —
1155+ contrast with profile-based `substitutes` (taste/aroma-alikes). Returns {'neighbors': [...]}
1156+ ranked by similarity (self excluded)."""
1157+ mol = Chem .MolFromSmiles (smiles )
1158+ if mol is None :
1159+ return {"error" : f"unparseable SMILES: { smiles } " }
1160+ _ensure_sub_index ()
1161+ fps , smis , tastes , _aromas , _profiles , _dims = _SUB_INDEX
11151162 if not fps :
11161163 return {"neighbors" : [], "note" : "no reference set loaded (taste_master.parquet absent)" }
11171164 q = _MORGAN .GetFingerprint (mol )
@@ -1138,7 +1185,46 @@ def substitute(smiles: str, k: int = 8, min_similarity: float = 0.0) -> dict:
11381185 if len (neighbors ) >= k :
11391186 break
11401187 return {"query" : self_smi , "neighbors" : neighbors ,
1141- "basis" : "Tanimoto / Morgan r2 2048-bit over labeled molecules" }
1188+ "basis" : "structural — Tanimoto / Morgan r2 2048-bit over labeled molecules" }
1189+
1190+
1191+ # back-compat alias: the endpoint / callers historically called this `substitute`
1192+ substitute = structural_neighbors
1193+
1194+
1195+ def substitutes (smiles : str , k : int = 8 ) -> dict :
1196+ """PROFILE-based substitutes: the k molecules whose predicted FLAVOR profile (taste + aroma
1197+ head scores) is closest to the query's — the drop-in reformulation list. A molecule that
1198+ *tastes and smells* like the target is a likely substitute regardless of its structure, so
1199+ this ranks by cosine similarity over the head-score vectors (not fingerprint distance).
1200+ Returns {'substitutes': [...]} ranked by profile match (self excluded)."""
1201+ import numpy as np
1202+ mol = Chem .MolFromSmiles (smiles )
1203+ if mol is None :
1204+ return {"error" : f"unparseable SMILES: { smiles } " }
1205+ _ensure_sub_index ()
1206+ _fps , smis , tastes , aromas , profiles , _dims = _SUB_INDEX
1207+ if profiles is None or not len (smis ):
1208+ return {"substitutes" : [], "note" : "no reference set / models loaded" }
1209+ x = _feat (mol )
1210+ taste_heads , aroma_heads = _profile_heads ()
1211+ qv = np .array ([_CLASSIFIERS [t ].predict_proba (x )[0 , 1 ] for t in taste_heads ]
1212+ + [_AROMA_MODELS [a ].predict_proba (x )[0 , 1 ] for a in aroma_heads ], dtype = "float32" )
1213+ qn = qv / (float (np .linalg .norm (qv )) + 1e-9 )
1214+ pn = profiles / (np .linalg .norm (profiles , axis = 1 , keepdims = True ) + 1e-9 )
1215+ sims = pn @ qn
1216+ self_skel = Chem .MolToInchiKey (mol ).split ("-" )[0 ]
1217+ subs = []
1218+ for i in np .argsort (- sims ):
1219+ ni = Chem .MolFromSmiles (smis [i ])
1220+ if ni is None or Chem .MolToInchiKey (ni ).split ("-" )[0 ] == self_skel :
1221+ continue
1222+ subs .append ({"smiles" : smis [i ], "profile_match" : round (float (sims [i ]), 3 ),
1223+ "known_tastes" : tastes [i ], "aromas" : aromas [i ] if i < len (aromas ) else []})
1224+ if len (subs ) >= k :
1225+ break
1226+ return {"query" : Chem .MolToSmiles (mol ), "substitutes" : subs ,
1227+ "basis" : "profile — cosine over predicted taste + aroma head scores" }
11421228
11431229
11441230def palette_match (tastes , aromas = None , k = 5 ):
@@ -1150,7 +1236,7 @@ def palette_match(tastes, aromas=None, k=5):
11501236 predicted descriptors."""
11511237 if _SUB_INDEX is None :
11521238 _build_sub_index ()
1153- _ , smis , tlist , alist = _SUB_INDEX
1239+ _ , smis , tlist , alist , _profiles , _dims = _SUB_INDEX
11541240 t_target , a_target = set (tastes or []), set (aromas or [])
11551241 if not (t_target or a_target ) or not smis :
11561242 return {"target" : {"tastes" : sorted (t_target ), "aromas" : sorted (a_target )}, "matches" : []}
0 commit comments