Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ running entirely on hardware you own.
<p align="center">
<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">
</p>
<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>
<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 167 aroma + 6 taste classes labelled. <b>190 trained heads</b> in all: 6 taste + 167 aroma + 5 mouthfeel + 12 safety.</sub></p>

> **8,847 unique molecules** across the open datasets · **taste + aroma + mouthfeel** prediction from
> structure · a **flavor library** (start from a flavor → its character-impact molecule) and
Expand Down Expand Up @@ -59,7 +59,7 @@ is tagged by how it was derived**, so nothing reads as more certain than its sou
**tasteless** (RandomForests on fingerprint + physicochemical features), plus a
sweetness-**intensity** regressor. Sour and salty *also* keep a transparent chemistry
rule (acid group / alkali-salt) as a deterministic cross-check alongside the model.
- **Aroma** — **164 odor-descriptor heads** (citrus, floral, minty, almond, fatty,
- **Aroma** — **167 odor-descriptor heads** (citrus, floral, minty, almond, fatty,
petroleum, earthy, medicinal, sulfurous, camphor, fruity, fishy, garlic, ethereal,
ammoniacal, pungent, pine, rose, rancid, alcoholic, woody, green, grassy, putrid)
trained on **public-domain** HSDB odor text + curated character-impact facts, surfaced
Expand Down Expand Up @@ -162,7 +162,7 @@ Flavormancer ships as two editions of one method:
| Edition | Commercial | Academic / open-source *(coming soon)* |
| License | Apache-2.0 | open-source, **research / NonCommercial** |
| Data | commercial-clean open data only | adds research odor datasets with **NonCommercial** terms |
| Aroma | **164 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) |
| Aroma | **167 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) |
| Use | free to use, sell, run on-prem | research, teaching, advancing the method |

The split is deliberate. The richest aroma data is licensed for research only, so
Expand Down
8 changes: 4 additions & 4 deletions docs/AROMA-AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Update — supplement applied, then made food-only.** `build_aroma_supplement.py` adds a curated
> **public-domain** character-impact set (`aroma_supplement.csv`, resolved via PubChem) for the sparse
> descriptors below. Result: **42 → 164 heads** (food-safe core + non-food aroma-only tier; see the
> descriptors below. Result: **42 → 167 heads** (food-safe core + non-food aroma-only tier; see the
> multi-industry note below), after open-government sourcing (see below) took the
> curated supplement to **~1,994 labelled molecules**. New heads include `coconut`, `nutty`, `caramel`,
> `winey`, `onion`, `honey`, `herbal`, `vanilla`, `buttery`, `balsamic`, `smoky`, `cinnamon`, `spicy`,
Expand All @@ -28,10 +28,10 @@
> 2-phenylethanol) instead of the E/Z terpene twins (nerol/geraniol) that dedupe to one row under the
> connectivity-skeleton key. That was the **food-safe** ceiling at ~86.
>
> **Then we walked the road past it — the non-food aroma-only descriptor space — to 164 heads.** The
> **Then we walked the road past it — the non-food aroma-only descriptor space — to 167 heads.** The
> corpus is now deliberately **multi-industry**, not food-only: odor is physical, so to read
> structure→smell well the corpus should include the fragrance space, and a per-molecule **safety flag**
> (open-gov food registers) — not corpus membership — gates edibility per mode. The same 164 heads serve
> (open-gov food registers) — not corpus membership — gates edibility per mode. The same 167 heads serve
> every "Mancer" mode: **Flavormancer** (food chemistry), **Beveragemancer** (beverage), **Aromamancer**
> (fragrance), **Vapemancer** (inhalation), **Oilmancer** (essential oils). Molecule names are resolved
> to structures through PubChem and folded in flagged non-food. New heads cleared across several
Expand Down Expand Up @@ -60,7 +60,7 @@
> its white-floral neighbours (`jasmine`/`gardenia`/`ylang`/`champaca`), nearly doubling their example
> counts and de-inflating their small-n scores.
>
> `sweet`-odor rides the bar (0.72, documented-only until a GNN). Net: **164 aroma heads / 170 total
> `sweet`-odor rides the bar (0.72, documented-only until a GNN). Net: **167 aroma heads / 190 total
> (with the 6 taste heads).** The count is inherently ±2–3 at the margin — ~15 heads sit right at the
> 10-positive / 0.70-AUROC boundary, so any data change reshuffles which marginal heads ship.
>
Expand Down
2 changes: 1 addition & 1 deletion docs/AROMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ enumerable via the annotations API. Pipeline:
physicochemical descriptor block** (`chemfeatures.py`: MW, logP, TPSA, H-bond donors/
acceptors, rotatable bonds, ring counts, fraction sp3, heteroatoms — the global properties
bare bits miss). After broadening the vocabulary, folding the curated `flavors.csv` molecules
in (825 → **1994** molecules, incl. a curated public-domain supplement), and adding those features, **164 heads clear CV-AUROC ≥ 0.70**
in (825 → **2403** molecules, incl. a curated public-domain supplement), and adding those features, **167 heads clear CV-AUROC ≥ 0.70**
(each score is that descriptor's own held-out AUROC): medicinal 0.97, ammoniacal 0.96,
petroleum 0.96, citrus 0.94, camphor 0.93, almond 0.92, fatty 0.89, minty 0.88, ethereal 0.87,
fruity 0.87, fishy 0.86, sulfurous 0.85, garlic 0.84, floral 0.83, pungent 0.81, earthy 0.73.
Expand Down
4 changes: 2 additions & 2 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ shape of the tool. The organizing discipline throughout: **every output is tagge
by how it's derived, and nothing claims more certainty than its source supports.**

> **Edition note.** This catalogues the **commercial** edition (Apache-2.0,
> commercial-clean data). **Aroma ships here** as 164 presence/absence odor-descriptor
> commercial-clean data). **Aroma ships here** as 167 presence/absence odor-descriptor
> heads trained on public-domain HSDB text (plus 5 mouthfeel/chemesthesis heads and 12
> Tox21 safety heads) — what's still gated is scored **intensity** (*how strong* a note
> is), which needs research/customer panel data and lives in the **academic edition**
Expand All @@ -33,7 +33,7 @@ by how it's derived, and nothing claims more certainty than its source supports.
- Multitaste — fires when 2+ taste heads are high (**trained**-derived).
- Known-taste ground truth — verified labels override predictions (**lookup**).

**Aroma** — **164 odor-descriptor heads ship** (**trained**)
**Aroma** — **167 odor-descriptor heads ship** (**trained**)
- Presence/absence per descriptor (citrus, floral, minty, almond, fatty, petroleum, earthy,
medicinal, sulfurous, camphor, fruity, fishy, garlic, ethereal, ammoniacal, pungent) —
RandomForests on fingerprint + physicochemical features, over public-domain HSDB odor text +
Expand Down
4 changes: 2 additions & 2 deletions docs/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ features (trained on SweetenersDB).

---

## 3. Aroma — 164 descriptor heads from public odor text
## 3. Aroma — 167 descriptor heads from public odor text

Odor is the hard, licensed part of this field — most rich odor datasets are NonCommercial. Our
clean route:
Expand All @@ -75,7 +75,7 @@ clean route:
`minty`), producing a presence/absence label per descriptor. We also fold in the curated
character-impact facts from `flavors.csv`.
3. **`train_aroma.py`** trains one RandomForest per descriptor (on fingerprint + descriptors) and
**keeps only the heads that clear CV-AUROC ≥ 0.70** — an honest bar. **164 heads** survive
**keeps only the heads that clear CV-AUROC ≥ 0.70** — an honest bar. **167 heads** survive
(citrus, floral, minty, almond, fatty, petroleum, earthy, medicinal, sulfurous, camphor,
fruity, fishy, garlic, ethereal, ammoniacal, pungent, pine, rose, rancid, alcoholic,
woody, green, grassy, putrid), 0.71–0.98. The lower-population heads (≈10–20 documented
Expand Down
2 changes: 1 addition & 1 deletion mcp-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ def _flavor_map(label: str, limit: int, full: bool) -> dict:
def read_flavor(molecule: str) -> dict:
"""Predict the flavor of a single molecule (name or SMILES).

Returns the six taste-head probabilities, confident aromas plus all 164 aroma scores, the
Returns the six taste-head probabilities, confident aromas plus all 167 aroma scores, the
trained mouthfeel/chemesthesis scores (cooling/pungent/warming/astringent/tingling), GRAS
status, structural alerts, caution-only Tox21 flags, and the applicability-domain flag.
Prediction only — tox flags are indicative in-vitro activity, never a determination.
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def models_present(base_url):
"""
import json
import time
deadline = time.monotonic() + 180 # poll for up to 3 min — covers a cold 160-head load under load
deadline = time.monotonic() + 180 # poll for up to 3 min — covers a cold 190-head load under load
while time.monotonic() < deadline:
with contextlib.suppress(Exception): # server busy/starting -> wait and retry
req = urllib.request.Request(base_url + "/api/predict", method="POST",
Expand Down
8 changes: 4 additions & 4 deletions training/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ def api_substitutes(q: Query):
@app.post("/api/precomputed")
def api_precomputed(q: Query):
"""Fast check: is this molecule's profile already in the index (instant read) or does it need a
fresh 170-head compute? Lets the UI show a 'conjuring a fresh reading' note for novel molecules."""
fresh 178-head compute? Lets the UI show a 'conjuring a fresh reading' note for novel molecules."""
smi = _resolve(q.smiles)
return {"precomputed": bool(smi and P.is_precomputed(smi))}

Expand Down Expand Up @@ -583,7 +583,7 @@ def font(path, size):
"tasteless": out.get("tasteless")}
taste_cells = sorted(((t, float(v) if isinstance(v, (int, float)) else 0.0)
for t, v in taste_src.items()), key=lambda kv: -kv[1])
# The card is a shareable SNAPSHOT — a PNG can't scroll, and there are 187 heads. So: the 6
# The card is a shareable SNAPSHOT — a PNG can't scroll, and there are 190 heads. So: the 6
# tastes ALWAYS render (a complete, fixed row you can compare across cards), while aroma,
# mouthfeel and safety show only what actually FIRES, capped. The labels say "N of M" so a
# reader knows they're seeing the firing subset, not the whole model.
Expand Down Expand Up @@ -1315,15 +1315,15 @@ def _prewarm_formulation():
with contextlib.suppress(Exception): # best-effort
P.substitute("CCO")
# Warm the PROFILE path too: builds the normalized reference matrix (_profiles_unit, the
# 8k×170 renormalization) once here instead of on the first /api/substitutes.
# 8k×178 renormalization) once here instead of on the first /api/substitutes.
P.substitutes("CCO")
for n in _FORMULATION_WARM:
with contextlib.suppress(Exception): # best-effort warmup; a miss just means a cold first hit
smi = _resolve(n)
m = Chem.MolFromSmiles(smi) if smi else None
if m is not None:
canon = Chem.MolToSmiles(m)
P.predict_aroma(canon) # fills the shared 164-head aroma cache
P.predict_aroma(canon) # fills the shared 167-head aroma cache
P.substitutes(canon) # taste heads + profile cosine, so the demo chips are instant


Expand Down
18 changes: 9 additions & 9 deletions training/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def _load_rf(path):

def _infer_pool():
"""A process-wide thread pool for parallel head inference on novel molecules. Sized to ~3/4 of
the box (env FLAVORMANCER_INFER_WORKERS overrides) so a fresh 170-head read rips across cores
the box (env FLAVORMANCER_INFER_WORKERS overrides) so a fresh 190-head read rips across cores
(~40 s -> a couple of seconds). Shared, so many concurrent novel reads share one bounded pool
instead of each spawning its own — in-corpus reads never touch it (they hit the index)."""
global _INFER_POOL
Expand Down Expand Up @@ -1066,12 +1066,12 @@ def analyze_balance(ingredients):

@lru_cache(maxsize=8192)
def _aroma_scores_canon(canon):
"""Run all 164 descriptor forests for a CANONICAL SMILES and return {head: score}."""
"""Run all 167 descriptor forests for a CANONICAL SMILES and return {head: score}."""
m = Chem.MolFromSmiles(canon)
if m is None or not _AROMA_MODELS:
return None
fp = _feat(m)
# Fan the 164 forests across cores — each predict_proba releases the GIL, so this turns the
# Fan the 167 forests across cores — each predict_proba releases the GIL, so this turns the
# ~40 s serial read (the only remaining cost, for genuinely novel/out-of-corpus molecules) into
# a couple of seconds. In-corpus molecules never reach here (they read the precomputed index row).
def _score(it):
Expand All @@ -1082,13 +1082,13 @@ def _score(it):


def _aroma_scores(smiles):
"""The expensive part of the aroma read: all 164 descriptor forests → {head: score}. Keyed on
"""The expensive part of the aroma read: all 167 descriptor forests → {head: score}. Keyed on
the CANONICAL SMILES (not threshold/top_k, not the raw string) so every caller shares one
computation per molecule regardless of how they spelled it — predict_aroma, _query_profile
(substitutes) and the /api/aroma endpoint all collapse to the same cache entry instead of each
re-running 164 forests (that double/mismatched inference was the ~6 s /api/substitutes).
re-running 167 forests (that double/mismatched inference was the ~6 s /api/substitutes).

In-corpus molecules skip the forests entirely: their 164 scores are read straight off the
In-corpus molecules skip the forests entirely: their 167 scores are read straight off the
precomputed profile index (built at startup) — the same numbers, ~40 s cheaper on a cold hit."""
m = Chem.MolFromSmiles(smiles)
if m is None or not _AROMA_MODELS:
Expand Down Expand Up @@ -1265,7 +1265,7 @@ def _aroma(a):
def _build_sub_index():
global _SUB_INDEX
import numpy as np
# Fast path: load the precomputed profile index (build_profile_index.py). The 170-head
# Fast path: load the precomputed profile index (build_profile_index.py). The 178-dim
# inference over ~8.8k molecules is slow (~3 min); the cache makes startup instant. We only
# rebuild the cheap Morgan fingerprints from SMILES on load.
cache = Path("profile_index.npz")
Expand Down Expand Up @@ -1340,7 +1340,7 @@ def _ensure_sub_index():
def _index_row(mol):
"""Row of `mol` in the profile index (matched by connectivity skeleton), or None if the
molecule isn't in the reference corpus. In-corpus molecules can reuse their PRECOMPUTED
170-head profile (built once at index build / startup) instead of re-running 164 forests at
178-dim profile (built once at index build / startup) instead of re-running 167 forests at
query time — that inference is ~40 s cold on a novel molecule and was the real /api/substitutes
and include_aroma cost. The precomputed row is the SAME model output, just paid up front."""
_ensure_sub_index()
Expand All @@ -1362,7 +1362,7 @@ def _index_row(mol):

def is_precomputed(smiles):
"""True if this molecule's full taste+aroma profile is already in the index (an instant read),
False if it's out-of-corpus and the 170 heads have to run fresh (the slower path). Used by the
False if it's out-of-corpus and the 178 profile heads have to run fresh (the slower path). Used by the
UI to decide whether to show the 'conjuring a fresh reading' note while a read brews."""
mol = Chem.MolFromSmiles(smiles or "")
return mol is not None and _index_row(mol) is not None
Expand Down
2 changes: 1 addition & 1 deletion training/train_aroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
descriptor that has enough positives, reports HONEST 5-fold CV AUROC, and keeps only the
descriptors that clear a minimum AUROC. Saves the kept heads to aroma_models/ + a manifest.

PARALLELISM: each head trains in its OWN process with n_jobs=1, so the ~164 heads train
PARALLELISM: each head trains in its OWN process with n_jobs=1, so the ~167 heads train
concurrently across the box's cores instead of one-at-a-time (the same process-per-unit pattern
that made build_profile_index fast). Feature matrix is memmapped so workers share it. A head
either clears the bar and dumps its model, or is dropped — workers write disjoint files, no locks.
Expand Down
Loading
Loading