Skip to content

Commit cbd7bfb

Browse files
Merge pull request #204 from echelonts/feat/aroma-37-heads-and-formula
37 aroma heads + molecular-formula identifier + all heads pickable
2 parents 950c58e + 12ab0e1 commit cbd7bfb

11 files changed

Lines changed: 73 additions & 24 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@ running entirely on hardware you own.
1717
<p align="center">
1818
<img src="docs/assets/flavor-map.png" alt="Flavor-space map in 3D on MW × logP × TPSA axes, colored by taste and aroma" width="900">
1919
</p>
20-
<p align="center"><sub>The interactive flavor-space map in 3D on real <b>MW × logP × TPSA</b> axes, colored by <b>taste &amp; aroma</b> — every one of the 34 aroma + 6 taste classes labelled.</sub></p>
20+
<p align="center"><sub>The interactive flavor-space map in 3D on real <b>MW × logP × TPSA</b> axes, colored by <b>taste &amp; aroma</b> — every one of the 37 aroma + 6 taste classes labelled.</sub></p>
2121

2222
> **8,393 unique molecules** across the open datasets · **taste + aroma** prediction from
2323
> structure · a **flavor library** (start from a flavor → its character-impact molecule) and
2424
> **flavor designer** (pick your notes → best food-safe molecules + drop-in swaps) · an
2525
> interactive 2D/3D **flavor-space map** · **2D & 3D** structure views. All on
2626
> commercial-clean public data.
2727
>
28-
> Per set (unique molecules): taste training **3,845** · aroma training **1027** · odor
28+
> Per set (unique molecules): taste training **3,845** · aroma training **1034** · odor
2929
> corpus **2,255** · documented taste **676** · GRAS reference **2,781** · sweetness
3030
> intensity **316** · curated character-impact flavors **95 flavors / 77 molecules** · public-domain aroma supplement **102 associations**.
3131
> Every one of the 8,393 is enriched with names + measured properties from public-domain PubChem.
@@ -59,7 +59,7 @@ is tagged by how it was derived**, so nothing reads as more certain than its sou
5959
**tasteless** (RandomForests on fingerprint + physicochemical features), plus a
6060
sweetness-**intensity** regressor. Sour and salty *also* keep a transparent chemistry
6161
rule (acid group / alkali-salt) as a deterministic cross-check alongside the model.
62-
- **Aroma****34 odor-descriptor heads** (citrus, floral, minty, almond, fatty,
62+
- **Aroma****37 odor-descriptor heads** (citrus, floral, minty, almond, fatty,
6363
petroleum, earthy, medicinal, sulfurous, camphor, fruity, fishy, garlic, ethereal,
6464
ammoniacal, pungent, pine, rose, rancid, alcoholic, woody, green, grassy, putrid)
6565
trained on **public-domain** HSDB odor text + curated character-impact facts, surfaced
@@ -154,7 +154,7 @@ Flavormancer ships as two editions of one method:
154154
| Edition | Commercial | Academic / open-source *(coming soon)* |
155155
| License | Apache-2.0 | open-source, **research / NonCommercial** |
156156
| Data | commercial-clean open data only | adds research odor datasets with **NonCommercial** terms |
157-
| Aroma | **34 presence/absence descriptor heads ship** (public-domain HSDB); scored **intensity** is trained on your data or a licensed set (PMP 2001) | full open model incl. **intensity** (research odor data) |
157+
| Aroma | **37 presence/absence descriptor heads ship** (public-domain HSDB); scored **intensity** is trained on your data or a licensed set (PMP 2001) | full open model incl. **intensity** (research odor data) |
158158
| Use | free to use, sell, run on-prem | research, teaching, advancing the method |
159159

160160
The split is deliberate. The richest aroma data is licensed for research only, so

docs/AROMA-AUDIT.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@
22

33
> **Update — supplement applied.** `build_aroma_supplement.py` added a curated **public-domain**
44
> character-impact set (`aroma_supplement.csv`, resolved via PubChem) for the sparse descriptors
5-
> below. Result: **24 → 34 heads****10 new**: `coconut` (0.89), `nutty` (0.94), `caramel` (0.91),
5+
> below. Result: **24 → 37 heads****13 new**: `coconut` (0.89), `nutty` (0.94), `caramel` (0.91),
66
> `winey` (0.90), `onion` (0.87), `honey` (0.80), `herbal` (0.80), `vanilla` (0.93), `buttery` (0.87),
7-
> `balsamic` (0.93) — and `grassy` kept above the bar
8-
> (0.80) with its classic green-leaf volatiles. `coconut` is now *predictable* (γ-nonalactone →
9-
> coconut 1.0), which closes its earlier data-gate. `spicy` (0.667) and `sweet`-odor (0.646) stayed
10-
> unlearnable even with more data — a real representation limit (revisit with a GNN). The numbers
7+
> `balsamic` (0.93), `smoky` (0.99), `cinnamon` (1.00), `spicy` (0.71) — and `grassy` kept above the
8+
> bar (0.80) with its classic green-leaf volatiles. `coconut` is now *predictable* (γ-nonalactone →
9+
> coconut 1.0), which closes its earlier data-gate. `spicy` finally crossed (0.71, borderline — it
10+
> shares character molecules with cinnamon/clove). **Only `sweet`-odor (0.637) remains unlearnable**
11+
> even with 185 examples — a genuine representation limit (revisit with a GNN).
12+
>
13+
> **Small-n caveat (honest):** the *highest*-AUROC new heads are small, structurally-homogeneous
14+
> classes — e.g. `cinnamon` reaches CV-AUROC **1.00** only because all 10 positives are one
15+
> cinnamaldehyde-family scaffold the fingerprint separates trivially. That is memorising a scaffold,
16+
> not a superb general model: these heads are **narrow and high-variance** (like `grassy`/`coffee`)
17+
> and will sharpen — or get honestly re-scored — with more diverse examples. Treat AUROC on n≈10
18+
> heads as indicative, not gospel. The numbers
1119
> below describe the **pre-supplement** baseline + the standing plan.
1220
1321

docs/AROMA.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ enumerable via the annotations API. Pipeline:
1818
physicochemical descriptor block** (`chemfeatures.py`: MW, logP, TPSA, H-bond donors/
1919
acceptors, rotatable bonds, ring counts, fraction sp3, heteroatoms — the global properties
2020
bare bits miss). After broadening the vocabulary, folding the curated `flavors.csv` molecules
21-
in (825 → **1027** molecules, incl. a curated public-domain supplement), and adding those features, **34 heads clear CV-AUROC ≥ 0.70**
21+
in (825 → **1034** molecules, incl. a curated public-domain supplement), and adding those features, **37 heads clear CV-AUROC ≥ 0.70**
2222
(each score is that descriptor's own held-out AUROC): medicinal 0.97, ammoniacal 0.96,
2323
petroleum 0.96, citrus 0.94, camphor 0.93, almond 0.92, fatty 0.89, minty 0.88, ethereal 0.87,
2424
fruity 0.87, fishy 0.86, sulfurous 0.85, garlic 0.84, floral 0.83, pungent 0.81, earthy 0.73.

docs/CAPABILITIES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ by how it's derived, and nothing claims more certainty than its source supports.
3232
- Multitaste — fires when 2+ taste heads are high (**trained**-derived).
3333
- Known-taste ground truth — verified labels override predictions (**lookup**).
3434

35-
**Aroma****34 odor-descriptor heads ship** (**trained**)
35+
**Aroma****37 odor-descriptor heads ship** (**trained**)
3636
- Presence/absence per descriptor (citrus, floral, minty, almond, fatty, petroleum, earthy,
3737
medicinal, sulfurous, camphor, fruity, fishy, garlic, ethereal, ammoniacal, pungent) —
3838
RandomForests on fingerprint + physicochemical features, over public-domain HSDB odor text +

docs/HOW-IT-WORKS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ features (trained on SweetenersDB).
6262

6363
---
6464

65-
## 3. Aroma — 34 descriptor heads from public odor text
65+
## 3. Aroma — 37 descriptor heads from public odor text
6666

6767
Odor is the hard, licensed part of this field — most rich odor datasets are NonCommercial. Our
6868
clean route:
@@ -75,7 +75,7 @@ clean route:
7575
`minty`), producing a presence/absence label per descriptor. We also fold in the curated
7676
character-impact facts from `flavors.csv`.
7777
3. **`train_aroma.py`** trains one RandomForest per descriptor (on fingerprint + descriptors) and
78-
**keeps only the heads that clear CV-AUROC ≥ 0.70** — an honest bar. **34 heads** survive
78+
**keeps only the heads that clear CV-AUROC ≥ 0.70** — an honest bar. **37 heads** survive
7979
(citrus, floral, minty, almond, fatty, petroleum, earthy, medicinal, sulfurous, camphor,
8080
fruity, fishy, garlic, ethereal, ammoniacal, pungent, pine, rose, rancid, alcoholic,
8181
woody, green, grassy, putrid), 0.71–0.98. The lower-population heads (≈10–20 documented

mcp-server/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ def _read_flavor(molecule: str) -> dict:
6969
taste["salty"] = d["salty_predicted"]
7070
descriptors = (aroma.get("predicted") or {}).get("descriptors", [])
7171
return {
72-
"name": names.get("common"), "iupac": names.get("iupac"), "smiles": d.get("smiles"),
72+
"name": names.get("common"), "iupac": names.get("iupac"),
73+
"formula": names.get("formula"), "smiles": d.get("smiles"),
7374
"taste_probabilities": {k: round(v, 3) for k, v in taste.items()},
7475
"sour_rule": d.get("sour"), "salty_rule": d.get("salty"),
7576
"confident_aromas": [x["odor"] for x in descriptors if x.get("confident")],

skills/flavormancer/scripts/flavormancer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ def cmd_read(a):
7575
if isinstance(d.get(k), (int, float)):
7676
taste[lbl] = round(d[k], 3)
7777
desc = (aroma.get("predicted") or {}).get("descriptors", [])
78-
return {"name": names.get("common"), "iupac": names.get("iupac"), "smiles": d.get("smiles"),
78+
return {"name": names.get("common"), "iupac": names.get("iupac"),
79+
"formula": names.get("formula"), "smiles": d.get("smiles"),
7980
"taste_probabilities": taste, "sour_rule": d.get("sour"), "salty_rule": d.get("salty"),
8081
"confident_aromas": [x["odor"] for x in desc if x.get("confident")],
8182
"gras_status": (d.get("safety") or {}).get("gras_status"),

training/app.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,15 @@ def api_names(q: Query):
323323
# resolves to Title "3-Phenylprop-2-Enal"), which then looks like the IUPAC name repeated.
324324
if smi and raw and Chem.MolFromSmiles(raw) is None:
325325
common = raw[:1].upper() + raw[1:]
326-
return {"common": common, "iupac": iupac, "smiles": smi}
326+
return {"common": common, "iupac": iupac, "smiles": smi, "formula": _formula(smi)}
327+
328+
329+
def _formula(smi):
330+
"""Hill-system molecular formula (e.g. C9H16O2) — a compact 4th identifier alongside
331+
common / IUPAC / SMILES. None for an unparseable SMILES."""
332+
from rdkit.Chem import rdMolDescriptors
333+
m = Chem.MolFromSmiles(smi) if smi else None
334+
return rdMolDescriptors.CalcMolFormula(m) if m is not None else None
327335

328336

329337
@app.post("/api/structure")
@@ -504,7 +512,8 @@ def font(path, size):
504512
if iupac and iupac.lower() != name.lower():
505513
dr.text((x, 206), ("IUPAC " + iupac)[:64], font=f_body, fill=muted)
506514
dr.text((x, 232), smi[:58], font=f_mono, fill=muted)
507-
dr.text((x, 280), "READS AS", font=f_lab, fill=muted)
515+
dr.text((x, 254), "formula " + (_formula(smi) or ""), font=f_mono, fill=muted)
516+
dr.text((x, 282), "READS AS", font=f_lab, fill=muted)
508517
px, py = x, 306
509518
for text, c in pill_items:
510519
w = dr.textlength(text, font=f_pill)
@@ -1160,6 +1169,17 @@ def _precompute_design():
11601169
continue
11611170
pool.append({"smiles": smi, "name": nm, "tags": tags, "gras": skel in P._GRAS})
11621171
cnt.update(tags)
1172+
# fold the curated character-impact molecules (supplement + flavors) into the index so
1173+
# their descriptors (coconut, nutty, vanilla, cinnamon...) are searchable + offerable even
1174+
# where the industrial-skewed odor corpus is thin on them
1175+
for note, carriers in _NOTE_CARRIERS.items():
1176+
for csmi, cnm in carriers:
1177+
cm = Chem.MolFromSmiles(csmi)
1178+
if cm is None:
1179+
continue
1180+
pool.append({"smiles": csmi, "name": cnm or "", "tags": {note},
1181+
"gras": Chem.MolToInchiKey(cm).split("-")[0] in P._GRAS})
1182+
cnt.update([note])
11631183
_DESIGN[:] = pool
11641184
_DESIGN_DESCS[:] = sorted(d for d, n in cnt.items() if n >= 5)
11651185
except Exception: # noqa: BLE001 — no corpus/models; designer just stays empty
@@ -1389,6 +1409,7 @@ def api_nl(q: str = ""):
13891409
{"key": "name", "label": "Common name", "why": "Everyday name (PubChem Title, public domain) where one exists."},
13901410
{"key": "iupac", "label": "IUPAC name", "why": "Systematic IUPAC name (public-domain PubChem) — the unambiguous identity."},
13911411
{"key": "smiles", "label": "SMILES", "why": "The machine-readable structure string the models actually read."},
1412+
{"key": "formula", "label": "Formula", "why": "Hill-system molecular formula (e.g. C9H16O2) — a compact 4th identifier."},
13921413
{"key": "taste", "label": "Taste", "why": "Documented taste where known, else the model's call — what it tastes like."},
13931414
{"key": "aroma_top", "label": "Aroma", "why": "The strongest predicted odor descriptor — the note it most reads as."},
13941415
{"key": "mw", "label": "MW", "why": "Molecular weight (Da) — size. Heavier molecules are generally less volatile, so aroma fades."},
@@ -1420,6 +1441,7 @@ def _s(v): # NaN (a truthy float) -> "" ; keep real strings
14201441
iupac = (_NAME_TABLE.get(skel) or (None, None))[1] if isinstance(skel, str) else None
14211442
rows.append({
14221443
"smiles": r["smiles"], "name": _s(r.get("name")), "iupac": iupac or "",
1444+
"formula": _formula(r["smiles"]) or "",
14231445
"taste": taste, "aroma_top": _s(r.get("aroma_top")),
14241446
"mw": _num(r.get("mw")), "logp": _num(r.get("logp")), "tpsa": _num(r.get("tpsa")),
14251447
"hbd": _num(r.get("hbd")), "hba": _num(r.get("hba")), "rot_bonds": _num(r.get("rot_bonds")),

training/aroma_supplement.csv

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ cinnamon,cinnamic acid,O=C(O)C=Cc1ccccc1,aroma-supplement
4141
cinnamon,methyl cinnamate,COC(=O)C=Cc1ccccc1,aroma-supplement
4242
cinnamon,ethyl cinnamate,CCOC(=O)C=Cc1ccccc1,aroma-supplement
4343
cinnamon,cinnamyl acetate,CC(=O)OCC=Cc1ccccc1,aroma-supplement
44+
cinnamon,alpha-methylcinnamaldehyde,CC(C=O)=Cc1ccccc1,aroma-supplement
45+
cinnamon,cinnamyl formate,O=COCC=Cc1ccccc1,aroma-supplement
46+
cinnamon,hydrocinnamaldehyde,O=CCCc1ccccc1,aroma-supplement
47+
cinnamon,cinnamyl butyrate,CCCC(=O)OCC=Cc1ccccc1,aroma-supplement
4448
spicy,eugenol,C=CCc1ccc(O)c(OC)c1,aroma-supplement
4549
spicy,cinnamaldehyde,O=CC=Cc1ccccc1,aroma-supplement
4650
spicy,zingerone,COc1cc(CCC(C)=O)ccc1O,aroma-supplement
@@ -50,6 +54,9 @@ banana,isoamyl acetate,CC(=O)OCCC(C)C,aroma-supplement
5054
banana,amyl acetate,CCCCCOC(C)=O,aroma-supplement
5155
banana,isoamyl butyrate,CCCC(=O)OCCC(C)C,aroma-supplement
5256
banana,isoamyl isovalerate,CC(C)CCOC(=O)CC(C)C,aroma-supplement
57+
banana,isoamyl propionate,CCC(=O)OCCC(C)C,aroma-supplement
58+
banana,isobutyl acetate,CC(=O)OCC(C)C,aroma-supplement
59+
banana,isoamyl formate,CC(C)CCOC=O,aroma-supplement
5360
nutty,"2,3-dimethylpyrazine",Cc1nccnc1C,aroma-supplement
5461
nutty,"2,5-dimethylpyrazine",Cc1cnc(C)cn1,aroma-supplement
5562
nutty,2-ethylpyrazine,CCc1cnccn1,aroma-supplement
@@ -71,6 +78,10 @@ smoky,"2,6-dimethoxyphenol",COc1cccc(OC)c1O,aroma-supplement
7178
smoky,4-methylguaiacol,COc1cc(C)ccc1O,aroma-supplement
7279
smoky,4-ethylguaiacol,CCc1ccc(O)c(OC)c1,aroma-supplement
7380
smoky,4-vinylguaiacol,C=Cc1ccc(O)c(OC)c1,aroma-supplement
81+
smoky,phenol,Oc1ccccc1,aroma-supplement
82+
smoky,p-cresol,Cc1ccc(O)cc1,aroma-supplement
83+
smoky,o-cresol,Cc1ccccc1O,aroma-supplement
84+
smoky,"2,6-dimethylphenol",Cc1cccc(C)c1O,aroma-supplement
7485
anise,anethole,CC=Cc1ccc(OC)cc1,aroma-supplement
7586
anise,estragole,C=CCc1ccc(OC)cc1,aroma-supplement
7687
anise,anisaldehyde,COc1ccc(C=O)cc1,aroma-supplement

training/build_aroma_supplement.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,17 @@
3636
"acetovanillone", "isovanillin", "veratraldehyde", "syringaldehyde"],
3737
"clove": ["eugenol", "isoeugenol", "eugenyl acetate", "methyleugenol"],
3838
"cinnamon": ["cinnamaldehyde", "cinnamyl alcohol", "cinnamic acid", "methyl cinnamate",
39-
"ethyl cinnamate", "cinnamyl acetate"],
39+
"ethyl cinnamate", "cinnamyl acetate", "alpha-methylcinnamaldehyde", "cinnamyl formate", "hydrocinnamaldehyde", "cinnamyl butyrate"],
4040
"spicy": ["eugenol", "cinnamaldehyde", "zingerone", "piperonal", "carvacrol"],
41-
"banana": ["isoamyl acetate", "amyl acetate", "isoamyl butyrate", "isoamyl isovalerate"],
41+
"banana": ["isoamyl acetate", "amyl acetate", "isoamyl butyrate", "isoamyl isovalerate", "isoamyl propionate", "isobutyl acetate", "isoamyl formate", "amyl butyrate"],
4242
"nutty": ["2,3-dimethylpyrazine", "2,5-dimethylpyrazine", "2-ethylpyrazine",
4343
"2-acetylpyrazine", "5-methylfurfural", "2-ethyl-3-methylpyrazine",
4444
"2-acetylthiazole", "2,3-diethylpyrazine"],
4545
"cocoa": ["2,3,5,6-tetramethylpyrazine", "2,3,5-trimethylpyrazine", "3-methylbutanal",
4646
"2-methylbutanal", "isovaleraldehyde"],
4747
"coffee": ["furfuryl mercaptan", "guaiacol", "2-acetylpyrazine", "5-methylfurfural"],
4848
"smoky": ["guaiacol", "2,6-dimethoxyphenol", "4-methylguaiacol", "4-ethylguaiacol",
49-
"4-vinylguaiacol", "creosol"],
49+
"4-vinylguaiacol", "creosol", "phenol", "p-cresol", "o-cresol", "2,6-dimethylphenol"],
5050
"anise": ["anethole", "estragole", "anisaldehyde", "p-anisaldehyde"],
5151
"balsamic": ["benzyl benzoate", "benzyl cinnamate", "benzoic acid", "benzyl salicylate", "cinnamyl cinnamate"],
5252
"herbal": ["thymol", "carvacrol", "eucalyptol", "1,8-cineole", "menthone", "isomenthone"],

0 commit comments

Comments
 (0)