Skip to content

Commit 321c4e8

Browse files
feat(training): mouthfeel modality — 5 chemesthesis heads surfaced across the stack (#243)
Mouthfeel is trigeminal, not olfactory, so it ships as its own modality end-to-end (build_mouthfeel_supplement -> build_mouthfeel_dataset -> train_mouthfeel -> mouthfeel_models/), mirroring the independent taste subsystem. Five heads clear the honest bar (>=10 public positives and CV-AUROC >= 0.70): cooling 0.94, pungent 0.98, warming 0.99, astringent 1.00, tingling 1.00. Bench sanity: capsaicin -> pungent/warming 1.0, menthol -> cooling 1.0. cooling and pungent exist as BOTH aroma and mouthfeel heads (as sweet is both taste and aroma) — the aroma head predicts the smell, the mouthfeel head the sensation; the profile index keys them by dimension so they cannot collide. The flavor-profile vector is now 175 dims (6 taste + 164 aroma + 5 mouthfeel); tox stays out of it deliberately. 187 trained heads total. Surfaced per the keep-in-sync rule: modal Heads card gained Mouthfeel + Safety groups (every head a ranked %-bar with CV-AUROC), MCP read_flavor and the CLI skill return mouthfeel_scores + tox_flags, new MCP list_heads tool over GET /api/heads. Tox21 heads gained a CV-AUROC manifest (0.72-0.90) and per-molecule storage in master_enrichment (8,847 molecules; 17 previously-missing curated molecules folded in). Training and index build parallelized process-per-unit (index ~3 min -> ~35 s), worker counts derived from os.cpu_count(). Models load on a background thread so uvicorn binds instantly behind a branded warming page instead of a ~90 s startup 502. Flavor-map axes no longer clip. Closes #232. Closes #235. Closes #237. Closes #239. Closes #242.
1 parent c878769 commit 321c4e8

19 files changed

Lines changed: 1287 additions & 221 deletions

README.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,21 @@ 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 164 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 164 aroma + 6 taste classes labelled. <b>187 trained heads</b> in all: 6 taste + 164 aroma + 5 mouthfeel + 12 safety.</sub></p>
2121

22-
> **8,830 unique molecules** across the open datasets · **taste + aroma** prediction from
22+
> **8,847 unique molecules** across the open datasets · **taste + aroma + mouthfeel** 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
>
2828
> Per set (unique molecules): taste training **3,845** · aroma training **2,394** · odor
29-
> corpus **2,255** · documented taste **676** · GRAS reference **2,781** · sweetness
30-
> intensity **316** · character-impact aroma supplement **602 associations** (open-gov-sourced).
31-
> Every one of the 8,830 is enriched with names + measured properties from public-domain PubChem.
29+
> corpus **2,255** · documented taste **676** · mouthfeel training **2,534** · Tox21 (safety)
30+
> **7,823** · GRAS reference **2,781** · sweetness intensity **316** · character-impact aroma
31+
> supplement **602 associations** (open-gov-sourced). Every one of the 8,847 is enriched with
32+
> names + measured properties from public-domain PubChem.
3233
>
33-
> *How the universe grows:* the flavor-space map and enrichment table show **8,830 unique
34+
> *How the universe grows:* the flavor-space map and enrichment table show **8,847 unique
3435
> structures** (deduped by connectivity skeleton), expanded by ingesting the **full EU/GB
3536
> flavourings Union List (~2,200 authorised, Open Government Licence v3)** so the browse-able
3637
> universe is food-forward. Meaningfully-distinct stereoisomers (e.g. R- vs S-limonene) that carry
@@ -70,12 +71,17 @@ is tagged by how it was derived**, so nothing reads as more certain than its sou
7071
(computed); water solubility (ESOL estimate); volatility tier and pKa ranges
7172
(qualitative); measured boiling point / vapor pressure when a property table is
7273
loaded (lookup — structure-based BP was evaluated and declined as too inaccurate).
73-
- **Stability & chemesthesis** — oxidation / hydrolysis / photo watch-flags;
74-
cooling / pungent / astringent class flags.
74+
- **Mouthfeel****five trained chemesthesis heads** (cooling / pungent / warming /
75+
astringent / tingling): the *trigeminal* sensation, trained on curated public-domain
76+
agents (menthol & WS-coolants, capsaicinoids, tannins, Sichuan-pepper sanshools), each
77+
with its held-out CV-AUROC. Distinct from the same-named aroma notes — the sensation, not
78+
the smell (menthol *feels* cool; WS-23 cools with almost no odour).
79+
- **Stability** — oxidation / hydrolysis / photo watch-flags.
7580
- **Safety (defensive, caution-only)** — a disclaimer + scope on every result,
76-
structural tox-alert screening, a preliminary TTC/Cramer concern tier, an optional
77-
GRAS cross-reference, and EU declarable-allergen labeling. It **flags for review;
78-
it never clears a compound for use.**
81+
**twelve Tox21 in-vitro assay heads** (nuclear-receptor + stress-response, each with its
82+
CV-AUROC) surfaced as *indicative* review flags, structural tox-alert screening, a
83+
preliminary TTC/Cramer concern tier, an optional GRAS cross-reference, and EU
84+
declarable-allergen labeling. It **flags for review; it never clears a compound for use.**
7985
- **Formulation** — a documented dangerous-mixture screen (benzene, nitrosamine,
8086
acrylamide, ethyl carbamate, furan, and more) and an OAV dosing-balance analysis
8187
that flags the component about to overpower a blend (quantitative when threshold

docs/CAPABILITIES.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ shape of the tool. The organizing discipline throughout: **every output is tagge
66
by how it's derived, and nothing claims more certainty than its source supports.**
77

88
> **Edition note.** This catalogues the **commercial** edition (Apache-2.0,
9-
> commercial-clean data). **Aroma ships here** as 24 presence/absence odor-descriptor
10-
> heads trained on public-domain HSDB text — what's still gated is scored **intensity**
11-
> (*how strong* a note is), which needs research/customer panel data and lives in the
12-
> **academic edition** (or trains on a customer's own data, on-prem).
9+
> commercial-clean data). **Aroma ships here** as 164 presence/absence odor-descriptor
10+
> heads trained on public-domain HSDB text (plus 5 mouthfeel/chemesthesis heads and 12
11+
> Tox21 safety heads) — what's still gated is scored **intensity** (*how strong* a note
12+
> is), which needs research/customer panel data and lives in the **academic edition**
13+
> (or trains on a customer's own data, on-prem).
1314
1415
**Confidence tiers used below**
1516
- **computed** — exact from structure (RDKit). Not a prediction; a calculation.
@@ -52,16 +53,20 @@ by how it's derived, and nothing claims more certainty than its source supports.
5253
**Stability**
5354
- Oxidation / hydrolysis / photodegradation watch-flags (**rule/qualitative**).
5455

55-
**Chemesthesis (trigeminal)**
56-
- Cooling / pungent / astringent class flags (**rule/lookup**, qualitative).
56+
**Mouthfeel / chemesthesis (trigeminal)****five trained sensation heads ship** (**trained**)
57+
- Cooling / pungent / warming / astringent / tingling — RandomForests on fingerprint +
58+
physicochemical features, over curated public-domain trigeminal agents (menthol & WS-coolants,
59+
capsaicinoids, tannins/polyphenols, Sichuan-pepper sanshools). CV-AUROC **0.94–1.00** (narrow,
60+
structurally-distinct classes). Its own modality (`mouthfeel_models/`), distinct from the
61+
same-named *aroma* odour heads — the sensation, not the smell. See [`MOUTHFEEL.md`](MOUTHFEEL.md).
5762

5863
**Safety (all defensive, caution-only — never a clearance)**
5964
- Disclaimer + scope on every result.
6065
- Structural tox-alert screen — nitro/N-nitroso/azo/epoxide (**rule**).
6166
- GRAS / approved-ingredient cross-reference — FDA SAF, public domain, `build_gras_reference.py` (**lookup**).
6267
- **In-vitro tox-assay flags** — 12 Tox21 assays (genotoxic-stress SR-p53/SR-ATAD5, AhR,
63-
mitochondrial, endocrine), `predict_tox()` (**trained**, Tox21 public domain). INDICATIVE
64-
activity for review, **never a determination**.
68+
mitochondrial, endocrine), `predict_tox()` (**trained**, Tox21 public domain; CV-AUROC
69+
**0.72–0.90**, surfaced per assay). INDICATIVE activity for review, **never a determination**.
6570
- Preliminary TTC concern tier (**qualitative** heuristic; Toxtree for the real call).
6671
- EU declarable fragrance-allergen labeling flag — `labeling()` (**lookup**, curated subset).
6772

docs/MOUTHFEEL.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Mouthfeel — a public-domain chemesthesis model
2+
3+
Flavor = taste **+** aroma **+** mouthfeel. Taste and aroma ship as trained heads
4+
([`AROMA.md`](AROMA.md)); this documents the third modality — **mouthfeel / chemesthesis**,
5+
the *trigeminal* (somatosensory) sensations in the mouth: cooling, pungency/heat, tingling,
6+
astringency. It is a **separate modality**, not a slice of aroma — a molecule can *smell* minty
7+
without *feeling* cool, and WS-23 cools the mouth with almost no odour at all.
8+
9+
## What ships (2026-07)
10+
11+
**Five trained heads**, one RandomForest per sensation on the Morgan fingerprint + physicochemical
12+
block (the same stack as taste/aroma), in their own `mouthfeel_models/` directory, loaded and tagged
13+
`mouthfeel` by `predict.py` and folded into the 175-dim flavor-profile vector used for substitutes.
14+
15+
| head | what it is | held-out CV-AUROC |
16+
|---|---|---|
17+
| **cooling** | TRPM8 coolants — menthol family + food-authorised WS-agents | 0.94 |
18+
| **pungent** | TRPV1 / mustard-oil heat & bite — capsaicinoids, isothiocyanates, allium sulfur | 0.98 |
19+
| **warming** | capsaicinoid & warm-spice heat — chili, pepper, ginger, cinnamon | 0.99 |
20+
| **astringent** | tannins & polyphenols — the puckering, mouth-drying sensation | 1.00 |
21+
| **tingling** | paresthesia alkylamides — Sichuan-pepper sanshools, jambu spilanthol, ZP-amides | 1.00 |
22+
23+
The high AUROCs reflect **narrow, structurally-distinct classes** (a capsaicinoid does not look like
24+
a tannin) trained against a broad random background — honest for what they are, but not a claim of
25+
fine-grained intensity resolution. Like aroma, this is **presence/absence**, not scored intensity.
26+
27+
## `cooling` / `pungent` exist in **both** aroma and mouthfeel
28+
29+
Deliberately — exactly as `sweet` is both a taste head and an aroma head. The *aroma* `cooling`
30+
head predicts a molecule that **smells** cool/minty (menthol, eucalyptol); the *mouthfeel* `cooling`
31+
head predicts the **TRPM8 sensation** (menthol, but also odourless WS-coolants). Same name, different
32+
modality. The profile index keys them by dimension (`aroma:cooling` vs `mouthfeel:cooling`) so they
33+
never collide, and the read surfaces each under its own group.
34+
35+
## Pipeline
36+
37+
- `build_mouthfeel_supplement.py` — the curated list: descriptor → well-known public-domain
38+
trigeminal-agent **names** (capsaicin, menthol, gallic acid, hydroxy-α-sanshool, …), resolved to
39+
authoritative canonical SMILES via PubChem (a SMILES hint is supported where a name isn't a
40+
PubChem synonym). Same provenance discipline as the aroma supplement: only measured, public-domain
41+
structure→sensation facts (Feist — descriptors aren't copyrightable), never copied from a
42+
restricted compilation.
43+
- `build_mouthfeel_dataset.py` — positives from the supplement + a broad random background from the
44+
molecule universe (a molecule with no documented chemesthesis is a negative) → `mouthfeel_train.parquet`.
45+
- `train_mouthfeel.py` — one head per sensation, honest bar: ≥10 positives **and** 5-fold
46+
CV-AUROC ≥ 0.70, else dropped. Kept heads + AUROC → `mouthfeel_models/` (+ `manifest.json`).
47+
48+
## Food safety is separate
49+
50+
A head predicts a **sensation**, not a food-use clearance. Its training molecules are food-present
51+
naturals (capsaicin in chili, tannins in tea/wine) plus food-authorised agents (nonivamide FL/GRAS,
52+
tannic acid 21 CFR) — but that a molecule is a good *warming* example does not assert it is a
53+
flavouring. The per-molecule food-safe flag and the standing IP/food review gate still govern any
54+
commercial use, exactly as for the aroma corpus.
55+
56+
## Near-misses (documented, not shipped)
57+
58+
`tingling` sits right at the bar — the pure sanshool/spilanthol/ZP-amide class is genuinely small
59+
(~11 public structures). It ships, but strictly on real paresthesia agents; the Piper long-pepper
60+
amides that *read pungent* were routed to the aroma `pungent` head instead, not used to pad tingling.

mcp-server/server.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ def _read_flavor(molecule: str) -> dict:
6868
if isinstance(d.get("salty_predicted"), (int, float)):
6969
taste["salty"] = d["salty_predicted"]
7070
descriptors = (aroma.get("predicted") or {}).get("descriptors", [])
71+
mouth = (d.get("mouthfeel") or {}).get("descriptors", []) # trained trigeminal heads
72+
tox = (safety.get("tox_screen") or {}).get("assays", []) # Tox21 caution-only assay heads
7173
return {
7274
"name": names.get("common"), "iupac": names.get("iupac"),
7375
"formula": names.get("formula"), "smiles": d.get("smiles"),
@@ -76,10 +78,14 @@ def _read_flavor(molecule: str) -> dict:
7678
"confident_aromas": [x["odor"] for x in descriptors if x.get("confident")],
7779
"all_aroma_scores": {x["odor"]: round(x["score"], 3) for x in descriptors},
7880
"aroma_descriptions": {x["odor"]: x["desc"] for x in descriptors if x.get("desc")},
81+
"mouthfeel_scores": {x["sensation"]: x["score"] for x in mouth},
82+
"confident_mouthfeel": [x["sensation"] for x in mouth if x.get("confident")],
7983
"gras_status": safety.get("gras_status"),
8084
"structural_alerts": safety.get("structural_alerts"),
85+
"tox_flags": [a["assay"] for a in tox if (a.get("probability") or 0) >= 0.5],
8186
"in_applicability_domain": (d.get("applicability") or {}).get("in_domain"),
82-
"disclaimer": "Flavor prediction only — NOT a safety, GRAS, or regulatory determination.",
87+
"disclaimer": "Flavor prediction only — NOT a safety, GRAS, or regulatory determination. "
88+
"Tox flags are indicative in-vitro assay activity, never a toxicity determination.",
8389
}
8490

8591

@@ -248,14 +254,25 @@ def _flavor_map(label: str, limit: int, full: bool) -> dict:
248254
# ── MCP tool surface ─────────────────────────────────────────────────────────
249255
@mcp.tool()
250256
def read_flavor(molecule: str) -> dict:
251-
"""Predict the taste + aroma of a single molecule (name or SMILES).
257+
"""Predict the flavor of a single molecule (name or SMILES).
252258
253-
Returns the six taste-head probabilities, confident aromas plus all 24 aroma scores,
254-
GRAS status, structural alerts, and applicability-domain flag. Prediction only.
259+
Returns the six taste-head probabilities, confident aromas plus all 164 aroma scores, the
260+
trained mouthfeel/chemesthesis scores (cooling/pungent/warming/astringent/tingling), GRAS
261+
status, structural alerts, caution-only Tox21 flags, and the applicability-domain flag.
262+
Prediction only — tox flags are indicative in-vitro activity, never a determination.
255263
"""
256264
return _read_flavor(molecule)
257265

258266

267+
@mcp.tool()
268+
def list_heads() -> dict:
269+
"""The full model-head catalog grouped by category — taste / aroma / mouthfeel / safety —
270+
each head with its held-out CV-AUROC. Categories are tags, not buckets (a head can appear in
271+
more than one). Useful to see the complete predictable dimension set."""
272+
with _client() as c:
273+
return c.get("/api/heads").json()
274+
275+
259276
@mcp.tool()
260277
def read_full(molecule: str) -> dict:
261278
"""The COMPLETE read of a molecule: everything read_flavor gives PLUS physicochemical
@@ -267,20 +284,22 @@ def read_full(molecule: str) -> dict:
267284

268285

269286
@mcp.tool()
270-
def find_substitutes(molecule: str, k: int = 8) -> dict:
271-
"""Find the k best SUBSTITUTES — molecules whose predicted taste+aroma PROFILE is closest to
272-
the given molecule (cosine over the head scores). These are the drop-in swaps: a molecule that
273-
tastes and smells like the target, regardless of structure (e.g. ethyl vanillin for vanillin).
274-
Each with its profile_match, known tastes, and aromas.
287+
def find_substitutes(molecule: str, k: int = 25) -> dict:
288+
"""Find SUBSTITUTES — molecules whose predicted taste+aroma PROFILE is closest to the given
289+
molecule (cosine over the head scores). These are the drop-in swaps: a molecule that tastes
290+
and smells like the target, regardless of structure (e.g. ethyl vanillin for vanillin). Returns
291+
every match above a profile-similarity floor, ranked, up to k. Each with its profile_match,
292+
known tastes, and aromas.
275293
"""
276294
return _find_substitutes(molecule, k)
277295

278296

279297
@mcp.tool()
280-
def find_structural_neighbors(molecule: str, k: int = 8) -> dict:
281-
"""Find the k STRUCTURAL neighbors — molecules most similar in structure (Tanimoto / Morgan
298+
def find_structural_neighbors(molecule: str, k: int = 25) -> dict:
299+
"""Find STRUCTURAL neighbors — molecules most similar in structure (Tanimoto / Morgan
282300
fingerprint) to the given molecule. Structural look-alikes (contrast find_substitutes, which
283-
matches by taste+aroma profile). Each with its similarity and known tastes.
301+
matches by taste+aroma profile). Returns every match above a similarity floor, ranked, up to k.
302+
Each with its similarity and known tastes.
284303
"""
285304
return _find_structural_neighbors(molecule, k)
286305

skills/flavormancer/scripts/flavormancer.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,17 @@ 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+
mouth = (d.get("mouthfeel") or {}).get("descriptors", [])
79+
tox = ((d.get("safety") or {}).get("tox_screen") or {}).get("assays", [])
7880
return {"name": names.get("common"), "iupac": names.get("iupac"),
7981
"formula": names.get("formula"), "smiles": d.get("smiles"),
8082
"taste_probabilities": taste, "sour_rule": d.get("sour"), "salty_rule": d.get("salty"),
8183
"confident_aromas": [x["odor"] for x in desc if x.get("confident")],
8284
"aroma_descriptions": {x["odor"]: x["desc"] for x in desc if x.get("desc")},
85+
"mouthfeel": {x["sensation"]: x["score"] for x in mouth},
86+
"confident_mouthfeel": [x["sensation"] for x in mouth if x.get("confident")],
8387
"gras_status": (d.get("safety") or {}).get("gras_status"),
88+
"tox_flags": [a["assay"] for a in tox if (a.get("probability") or 0) >= 0.5],
8489
"in_applicability_domain": (d.get("applicability") or {}).get("in_domain")}
8590

8691

@@ -202,10 +207,10 @@ def mol(name):
202207
mol("read-full").set_defaults(fn=cmd_read_full)
203208
mol("stereoisomers").set_defaults(fn=cmd_stereoisomers)
204209
s = mol("substitutes") # taste+aroma profile match (the drop-in swaps)
205-
s.add_argument("-k", type=int, default=8)
210+
s.add_argument("-k", type=int, default=25, help="max results (every match above a profile floor)")
206211
s.set_defaults(fn=cmd_substitutes)
207212
s = mol("structural-neighbors") # Tanimoto structural look-alikes
208-
s.add_argument("-k", type=int, default=8)
213+
s.add_argument("-k", type=int, default=25, help="max results (every match above a similarity floor)")
209214
s.set_defaults(fn=cmd_structural_neighbors)
210215

211216
s = sub.add_parser("formulate")

tests/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,8 @@
22
import os
33
import sys
44

5+
# The app loads model heads on a background thread in production (instant bind + warming page).
6+
# Tests need them present the moment `import predict` returns, so force the synchronous load path.
7+
os.environ.setdefault("FLAVORMANCER_BLOCKING_LOAD", "1")
8+
59
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "training"))

tests/test_substitute.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,23 @@ def test_mixture_to_molecule_rejects_all_bad(monkeypatch):
4949
monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], [], [], None, []))
5050
out = predict.mixture_to_molecule(["nope", "xyz"])
5151
assert "error" in out
52+
53+
54+
def test_substitutes_threshold_and_index_lookup(monkeypatch):
55+
"""substitutes() returns every match above min_match (self excluded), ranked; and an in-corpus
56+
query reads its profile straight off the index row (no forests) via _query_profile/_index_row."""
57+
import numpy as np
58+
mols = ["CCO", "CCCO", "c1ccccc1"] # ethanol, propanol, benzene
59+
canon = [_canon(s) for s in mols]
60+
fps = [predict._MORGAN.GetFingerprint(Chem.MolFromSmiles(s)) for s in mols]
61+
profiles = np.array([[1.0, 0.0, 0.0], [0.9, 0.1, 0.0], [0.0, 0.0, 1.0]], dtype="float32")
62+
dims = ["taste:sweet", "aroma:x", "aroma:y"]
63+
monkeypatch.setattr(predict, "_CLASSIFIERS", {"sweet": None}) # 1 taste col, matches the mock width
64+
monkeypatch.setattr(predict, "_SUB_INDEX", (fps, canon, [[], [], []], [[], [], []], profiles, dims))
65+
predict._PN_CACHE.clear(); predict._SKEL2ROW.clear()
66+
# ethanol is in the index -> query profile is its row (no model inference needed)
67+
assert predict._index_row(Chem.MolFromSmiles("CCO")) == 0
68+
out = predict.substitutes("CCO", k=10, min_match=0.5)
69+
subs = out["substitutes"]
70+
assert [n["smiles"] for n in subs] == [_canon("CCCO")] # propanol passes 0.5; benzene (orthogonal) filtered; self excluded
71+
assert subs[0]["profile_match"] >= 0.5

0 commit comments

Comments
 (0)