diff --git a/README.md b/README.md index ae2237d..b39f95c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ running entirely on hardware you own.
The interactive flavor-space map in 3D on real MW × logP × TPSA axes, colored by taste & aroma — every one of the 164 aroma + 6 taste classes labelled. 187 trained heads in all: 6 taste + 164 aroma + 5 mouthfeel + 12 safety.
+The interactive flavor-space map in 3D on real MW × logP × TPSA axes, colored by taste & aroma — every one of the 167 aroma + 6 taste classes labelled. 190 trained heads in all: 6 taste + 167 aroma + 5 mouthfeel + 12 safety.
> **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 @@ -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 @@ -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 diff --git a/docs/AROMA-AUDIT.md b/docs/AROMA-AUDIT.md index 22ad354..7686eae 100644 --- a/docs/AROMA-AUDIT.md +++ b/docs/AROMA-AUDIT.md @@ -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`, @@ -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 @@ -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. > diff --git a/docs/AROMA.md b/docs/AROMA.md index 5485773..da235b6 100644 --- a/docs/AROMA.md +++ b/docs/AROMA.md @@ -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. diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index f096104..98b7b91 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -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** @@ -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 + diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 4f2a727..685f2f3 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -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: @@ -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 diff --git a/mcp-server/server.py b/mcp-server/server.py index 6fee069..a586be1 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -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. diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 23d82ff..d39b0be 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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", diff --git a/training/app.py b/training/app.py index e522eea..f4e6d48 100644 --- a/training/app.py +++ b/training/app.py @@ -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))} @@ -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. @@ -1315,7 +1315,7 @@ 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 @@ -1323,7 +1323,7 @@ def _prewarm_formulation(): 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 diff --git a/training/predict.py b/training/predict.py index 8eeb57d..ff2dd4b 100644 --- a/training/predict.py +++ b/training/predict.py @@ -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 @@ -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): @@ -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: @@ -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") @@ -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() @@ -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 diff --git a/training/train_aroma.py b/training/train_aroma.py index 4c85a7e..aa1586f 100644 --- a/training/train_aroma.py +++ b/training/train_aroma.py @@ -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. diff --git a/training/workbench.html b/training/workbench.html index 52fc276..a4402a5 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -795,7 +795,7 @@Every molecule becomes a 2,048-bit Morgan fingerprint (which substructures it contains) plus a block of physicochemical descriptors (logP, MW, TPSA, H-bonding…). That one shared vector feeds every model — the same structure-to-property representation QSAR has leaned on for decades.
Six random-forest classifiers (sweet, bitter, umami…) each return a probability, scored by an honest held-out CV-AUROC. Sour and salty are solution / ionic effects, not molecule-shape ones, so they're transparent rules — not faked models. A sweetness-intensity regressor estimates relative-to-sucrose potency.
One random forest per descriptor (citrus, floral, woody…), trained on public-domain odor text. A head ships only if it clears CV-AUROC ≥ 0.70 — 164 survive, each shown with its own score. It reads presence, not intensity (free text carries none) — an honest ceiling, stated in the UI.
One random forest per descriptor (citrus, floral, woody…), trained on public-domain odor text. A head ships only if it clears CV-AUROC ≥ 0.70 — 167 survive, each shown with its own score. It reads presence, not intensity (free text carries none) — an honest ceiling, stated in the UI.
Every value is tagged measured / predicted / estimate, so nothing reads as more precise than it is. Where a quantitative feature needs data we can't ship free-commercially (odor thresholds, panel intensities), the UI says so — and it lights up with your data.
The Formulation Studio reads a whole recipe before you pour — weighting each ingredient by odor impact, aggregating the blend's note-profile, flagging the overpowering component, and closing the gap to your target. That's single-molecule ML turned into a bench tool.
Nothing leaves the box — a read makes no cloud calls. The shipped models train only on public-domain or permissively-licensed data, so the commercial edition stays clean (provenance tracked in the repo).
Two flavor dimensions. Flip the color toggle between taste (sweet / bitter / umami / sour / salty / tasteless) and aroma (= odor — the same thing; - the 164 documented descriptor classes like citrus, floral, sulfurous). Same molecules, + the 167 documented descriptor classes like citrus, floral, sulfurous). Same molecules, same structural layout — you're overlaying a different sense. The both toggle merges them into one full-flavor view: each dot takes its taste color where a taste is known, otherwise its aroma color — so nearly every molecule is colored by its dominant known @@ -1035,7 +1035,7 @@
Predicted from structure by RandomForest heads on public-domain HSDB odor data — the same stack as taste, so aroma is predicted for any molecule. This is @@ -2102,7 +2102,7 @@