From a752fb6fa50231040a7c2cb0d5aad3358db9637b Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:07:44 +0000 Subject: [PATCH] fix(data): recover 524 silently-unnamed molecules; prefer flavorist names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three naming defects, found by auditing what the grid and cards actually render. 1. NaN IS TRUTHY. `pr.get("common_name") or pr.get("iupac_name")` returns NaN whenever the common name is missing, so it never fell through to the IUPAC name — leaving ~500 molecules with no name at all even though a perfectly good one sat in the table. They rendered as raw SMILES. Replaced with an explicit _pick_name() that type-checks each candidate, with a comment explaining why the idiomatic `or` is wrong here so nobody "simplifies" it back. 2. CURATED NAMES LOST TO SYSTEMATIC ONES. PubChem's "common_name" is frequently systematic — spilanthol's is "N-(2-Methylpropyl)-2,6,8-decatrienamide" — so our hand-picked flavorist names were being overridden by worse ones. Curated names (flavors + aroma/mouthfeel supplements, 836 of them) now take precedence: those were chosen deliberately as what a flavorist calls the molecule. spilanthol now reads "spilanthol". 3. CAS-INVERTED NAMES READ BACKWARDS. "Carvone, (+-)-", "Cyclohexanol, 5-methyl-2- (1-methylethenyl)-" — an ordering that exists for alphabetised print indexes. _uninvert_cas() handles the three cases separately (stereo descriptor -> prefix keeping the parent's case; derivative word -> suffix; substituent -> prefix with the parent lowercased) and returns anything it doesn't recognise untouched, since a wrong "fix" is worse than none. 124 -> 16. Net: 7,559 -> 8,083 named of 8,853. The remaining 770 genuinely have no name in any source we hold and need the PubChem crawl (#209). Part of #217. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- training/build_enrichment.py | 75 ++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/training/build_enrichment.py b/training/build_enrichment.py index 13565e1..37a85ef 100644 --- a/training/build_enrichment.py +++ b/training/build_enrichment.py @@ -15,6 +15,7 @@ """ import contextlib import glob +import re import numpy as np import pandas as pd @@ -120,6 +121,69 @@ def _documented_isomer_rows(name_by_skel): return rows +def _pick_name(pr): + """Best display name from a properties row: common name if there is one, else the IUPAC name. + + Written the long way ON PURPOSE. The obvious `pr.get("common_name") or pr.get("iupac_name")` + is a real bug here: a missing pandas value is NaN, NaN is TRUTHY, so the `or` returns NaN and + never falls through to the IUPAC name. That silently left ~500 molecules unnamed even though a + perfectly good name sat in the table — they rendered as raw SMILES in the grid and on cards.""" + for key in ("common_name", "iupac_name"): + v = pr.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return None + + +_CAS_INVERTED = re.compile(r"^([A-Za-z0-9\-\[\]\(\)']+), ([0-9A-Za-z\-\(\),+\u00b1\s]+?)-?$") + + +def _uninvert_cas(name): + """Un-invert CAS-style index names so they read forwards. + + PubChem/CAS list many compounds parent-first — "Carvone, (+-)-", "Limonene, (-)-", + "Cyclohexanol, 5-methyl-2-(1-methylethenyl)-". That ordering exists for alphabetised print + indexes and reads backwards to everyone else. Three cases, because they resolve differently: + stereo descriptor "Carvone, (+-)-" -> "(+-)-Carvone" (parent keeps its case) + derivative suffix "Linalool, oxide" -> "Linalool oxide" + "2-Hexen-1-ol, 1-acetate" -> "2-Hexen-1-ol 1-acetate" + substituent prefix "Cyclohexanol, 5-methyl-" -> "5-methyl-cyclohexanol" + Anything that doesn't match is returned untouched — a wrong "fix" is worse than none.""" + if not isinstance(name, str) or ", " not in name: + return name + m = _CAS_INVERTED.match(name.strip()) + if not m: + return name + parent, mod = m.group(1), m.group(2).strip().rstrip("-").strip() + if not mod: + return parent + # 1. pure stereo / optical descriptor -> prefix, keep the parent's capitalisation + if re.fullmatch(r"[\(\[][^)\]]*[\)\]]", mod) or mod.lower() in ("cis", "trans", "d", "l", "dl"): + return f"{mod}-{parent}" + # 2. a derivative/functional word -> it's a suffix, not a substituent + if re.search(r"(acetate|oxide|ester|ether|hydrate|hydrochloride|anhydride|lactone|" + r"[a-z]+oate|[a-z]+ate|alcohol|aldehyde|ketone|acid)$", mod, re.IGNORECASE): + return f"{parent} {mod}" + # 3. otherwise a substituent prefix -> lowercase the parent, which is now mid-name + return f"{mod}-{parent[0].lower()}{parent[1:]}" + + +def _curated_names(): + """{skeleton -> human name} from the curated CSVs (flavors + aroma/mouthfeel supplements). + These are the names a flavorist actually uses — "gamma-nonalactone", "hydroxy-alpha-sanshool" — + and for the molecules we hand-added they are usually the ONLY good name available.""" + import csv + out = {} + for path in ("flavors.csv", "aroma_supplement.csv", "mouthfeel_supplement.csv"): + with contextlib.suppress(Exception), open(path, encoding="utf-8") as fh: + for r in csv.DictReader(fh): + nm = (r.get("molecule") or "").strip() + m = Chem.MolFromSmiles((r.get("smiles") or "").strip()) + if nm and m is not None: + out.setdefault(Chem.MolToInchiKey(m).split("-")[0], nm) + return out + + def _taste_by_skel(): out = {} try: @@ -150,8 +214,9 @@ def _taste_by_skel(): props = _by_skel("properties.parquet", ["common_name", "iupac_name", "melting_point_c", "boiling_point_c"]) taste_doc = _taste_by_skel() + curated = _curated_names() # human names for the molecules we hand-curated print(f"{len(structs)} molecules; {len(props)} with crawl properties; " - f"{len(taste_doc)} with documented taste", flush=True) + f"{len(taste_doc)} with documented taste; {len(curated)} curated names", flush=True) skels, smis, rows, feats = [], [], [], [] for skel, smi in structs.items(): @@ -159,7 +224,11 @@ def _taste_by_skel(): if m is None: continue pr = props.get(skel, {}) - name = pr.get("common_name") or pr.get("iupac_name") + # Curated FIRST: those names were hand-picked as what a flavorist calls the molecule, + # and PubChem's "common_name" is often systematic anyway (spilanthol's is + # "N-(2-Methylpropyl)-2,6,8-decatrienamide"). Only fall through to PubChem when we + # haven't named it ourselves. + name = _uninvert_cas(curated.get(skel) or _pick_name(pr)) rows.append({ "inchikey_skel": skel, "smiles": smi, "name": name if isinstance(name, str) else None, @@ -221,7 +290,7 @@ def _taste_by_skel(): r["tox_flags"] = ",".join(n for n, col in tox_cols.items() if col[i] >= 0.5) # assays firing >=0.5 # first-class rows for stereoisomers that differ in documented odor/taste - name_by_skel = {sk: (props.get(sk, {}).get("common_name") or props.get(sk, {}).get("iupac_name")) + name_by_skel = {sk: _uninvert_cas(curated.get(sk) or _pick_name(props.get(sk, {}))) for sk in structs} iso_rows = _documented_isomer_rows(name_by_skel) if iso_rows: