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
2 changes: 1 addition & 1 deletion docs/ACCURACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ it believes the answer is yes.

| modality | heads | what they answer |
|---|---|---|
| Taste | 6 | sweet, bitter, umami, sour, salty, tasteless |
| Taste | 6 | the five basics — sweet, bitter, umami, sour, salty — plus `tasteless` |
| Aroma | 167 | vanilla, citrus, smoky, pine, jasmine… |
| Mouthfeel | 5 | cooling, warming, pungent, tingling, astringent |
| Safety | 12 | Tox21 assay screens — caution flags, never a clearance |
Expand Down
21 changes: 20 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,31 @@ costs nothing on Track A — the demo keeps working while the product is built.
| App / API | **ASP.NET Core (C#)** | Enterprise default for a service like this: strong tooling, broad hiring pool, first-class ONNX Runtime support. |
| ML serving | **ONNX Runtime in-process in .NET** | Taste models (sklearn → `skl2onnx`) run inside the .NET app, no Python at runtime. |
| Aroma serving | **Python FastAPI sidecar** *(only if needed)* | The GNN may not export to ONNX cleanly; if not, a thin localhost sidecar does aroma inference only. Best case it exports and there's zero Python at runtime. |
| Frontend | **React** | Deepest hiring pool and the lightest fit for a simple single-screen workbench. |
| Frontend | **React 19 + Vite + TypeScript** | Deepest hiring pool and the lightest fit for a simple single-screen workbench. |
| UI kit | **Tailwind + shadcn/ui** *(not MUI)* | shadcn is copy-in, not import: the components live in our source tree where they can be read and owned. MUI would flatten Flavormancer's existing visual identity into Material and we would spend the port fighting it. The trade is real — MUI wins if you need an enterprise data-grid and date pickers tomorrow; this app is cards, chips, charts and a modal, which is shadcn's sweet spot. |
| Database | **PostgreSQL + pgvector** | Mature and battle-tested; pgvector backs the substitution-search index with first-class vector search. |
| Deploy | **Linux + Docker Compose** on the client-owned box | Single-box, small user count → Compose, not Kubernetes. Containers make the OS matrix irrelevant. |

---

## Deliberate exclusions

What we chose **not** to use matters as much as the stack, and both of these come up often enough
to be worth writing down.

**Node.js — build tooling and MCP only, never a third backend.** Two API stacks (the shipping
Python/FastAPI service and the planned .NET one) is breadth; a third is sprawl, and it reads as
indecision rather than range. Node earns its place in exactly two spots: the React toolchain
(Vite, TypeScript, the test runner), and — optionally — a **TypeScript MCP server** alongside the
Python one, which is ~200 lines and demonstrates the official TS SDK against the same contract.

**Laravel — deliberately absent.** It is a genuinely good fit for CRUD-and-content products and is
used heavily elsewhere in this portfolio. It is the wrong tool here: Flavormancer is on-prem
scientific computing, and adding a comfortable framework that proves nothing new would muddy that
story. Choosing against your most familiar stack when it does not fit is the point.

---

## The core principle: Python trains, .NET ships

Training language is an *internal build detail*, not part of the product. Nobody
Expand Down
36 changes: 36 additions & 0 deletions docs/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,42 @@ the difference between **prediction** and **lookup**, and it's the whole point.

---

## 0.5. Reading the numbers — a two-sentence glossary

Every head reports the same handful of numbers. They are used throughout this document, and three
of them are routinely misread, so here is what each one actually means. The long version, with
worked examples, is in [`ACCURACY.md`](ACCURACY.md).

**Head** — one yes/no expert for one property. *Does this smell like vanilla?* is a head; so is
*does this taste bitter?* There are 190 of them, and each returns a number from 0 to 1 for how
strongly it believes the answer is yes.

**AUROC** — a **ranking** score. Hand a head one true vanilla molecule and one non-vanilla: how
often does it score the vanilla one higher? **0.5 is a coin flip, 1.0 is never wrong.** It says
nothing about how often the head is right when it actually fires — see precision.

**Threshold** — the score at or above which a head counts as *firing*. It is **not** a flat 0.5;
each head has its own, fitted on data it never trained on, and they range 0.16–0.85. A head with
13 examples hedges, so a real `pine` match can land at 0.42 — a shared cut-off would have silently
withheld it.

**Precision** — **when it says yes, how often is it right?** This is trustworthiness, and it is the
number AUROC cannot see. A head with 11 positives among 2,403 molecules can score AUROC 0.979 and
have precision 0.10 — right one time in ten. Both are true of `ginger`.

**Recall** — **of all the real ones, how many did it catch?** This is thoroughness. It trades
against precision: raise a head's threshold and you get fewer false alarms but more misses.

**Out-of-fold** — scored by a model that never saw that molecule. Every accuracy number here is
out-of-fold; none of them are a model grading its own homework. See 5-fold cross-validation below.

**Confident vs indicative** — a head may only be called *confident* if it clears **50% precision**;
it has to be right more often than not. The 73 aroma heads that cannot are shipped as
**indicative** — they keep their score, their chips and every molecule they find, but they are
never dressed up as a confident call.

---

## 1. How a molecule becomes numbers

Everything starts by turning a structure into something math can chew on:
Expand Down
50 changes: 42 additions & 8 deletions training/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,19 +1218,29 @@ def predict_mouthfeel(mol):
}


def predict_tox(mol, threshold=0.5):
def predict_tox(mol, threshold=None):
"""Caution-only in-vitro tox-assay activity (Tox21 models). INDICATIVE flags for
review — NEVER a toxicity/safety determination. Honest/empty if heads untrained."""
review — NEVER a toxicity/safety determination. Honest/empty if heads untrained.

Each assay fires at its OWN calibrated threshold, and calibration matters more here than
anywhere else in the app: assay actives are rare, so a flat 0.5 made several heads
over-flag. Every one of the twelve calibrated UPWARD (NR-AR to 0.69, NR-ER to 0.63) — the
opposite direction from the thin aroma heads. A caution flag that cries wolf is worse than
no flag, because it teaches people to ignore the ones that matter.
"""
if not _TOX_MODELS:
return {"available": False,
"note": "tox heads not trained — run train_tox.py (Tox21, public domain)"}
x = _fp(mol)
assays = []
for name, clf in sorted(_TOX_MODELS.items()):
p = round(float(clf.predict_proba(x)[0, 1]), 3)
thr = _head_threshold(_TOX_META, name, threshold)
assays.append({"assay": name, "meaning": _TOX_MEANING.get(name, name), "probability": p,
"threshold": thr, "flagged": p >= thr,
"precision": _TOX_META.get(name, {}).get("cv_precision"),
"auroc": _TOX_META.get(name, {}).get("auroc")})
flags = [a["assay"] for a in assays if a["probability"] >= threshold]
flags = [a["assay"] for a in assays if a["flagged"]]
return {"available": True, "assays": assays, "flags": flags,
"note": "INDICATIVE in-vitro tox-assay activity (Tox21 RandomForest heads) — "
"caution-only, NOT a toxicity/safety determination; confirm with a toxicologist."}
Expand Down Expand Up @@ -1310,13 +1320,13 @@ def _aroma(a):
"desc": AROMA_DESC.get(h), **_cal(_MOUTHFEEL_META, h)}
for h in mouthfeel_heads]
return {
# taste heads keep a flat 0.5: hundreds of positives each, so they were never shy
"taste": [{"head": t, "auroc": _taste_auroc(t), "threshold": 0.5,
"confident_capable": True} for t in taste_heads],
"taste": [{"head": t, "auroc": _taste_auroc(t), **_cal(_TASTE_META, t)}
for t in taste_heads],
"aroma": [_aroma(a) for a in aroma_heads],
"mouthfeel": mouthfeel,
"safety": [{"head": t, "auroc": _TOX_META.get(t, {}).get("auroc"),
"meaning": _TOX_MEANING.get(t, t)} for t in sorted(_TOX_MODELS)],
"meaning": _TOX_MEANING.get(t, t), **_cal(_TOX_META, t)}
for t in sorted(_TOX_MODELS)],
}


Expand Down Expand Up @@ -1473,6 +1483,26 @@ def _predicted_tastes_at(profiles, i):
return [t for s, t in scored[:3] if s >= 0.2]


def _predicted_mouthfeel_at(profiles, i):
"""The MOUTHFEEL read for reference-set row i, straight off the profile matrix — no extra
inference, the columns are already there. Each sensation must clear its OWN calibrated
threshold (and be confident-capable), so a card never shows a sensation the modal would
call indicative. Returns the firing sensations, strongest first."""
if profiles is None:
return []
taste_heads, aroma_heads, mouth_heads = _profile_heads()
base = len(taste_heads) + len(aroma_heads) # mouthfeel columns follow taste then aroma
row, out = profiles[i], []
for j, name in enumerate(mouth_heads):
col = base + j
if col >= len(row):
break
score = float(row[col])
if score >= _head_threshold(_MOUTHFEEL_META, name) and _head_capable(_MOUTHFEEL_META, name):
out.append((score, name))
return [n for _, n in sorted(out, reverse=True)]


def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) -> dict:
"""STRUCTURAL neighbors: the k labeled molecules most structurally similar to the query
(Tanimoto over Morgan fingerprints), each with its known tastes. Structural look-alikes —
Expand Down Expand Up @@ -1504,6 +1534,7 @@ def structural_neighbors(smiles: str, k: int = 8, min_similarity: float = 0.0) -
neighbors.append({"smiles": smis[i], "similarity": round(float(sims[i]), 3),
"known_tastes": tastes[i],
"predicted_tastes": _predicted_tastes_at(profiles, i),
"mouthfeel": _predicted_mouthfeel_at(profiles, i),
# confident aromas precomputed once in the index — reused so the
# endpoint never re-runs the 24 aroma heads per neighbor (8x ~1.3s saved)
"aromas": _aromas[i] if i < len(_aromas) else []})
Expand Down Expand Up @@ -1545,6 +1576,7 @@ def substitutes(smiles: str, k: int = 8, min_match: float = 0.0) -> dict:
continue
subs.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3),
"known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i),
"mouthfeel": _predicted_mouthfeel_at(profiles, i),
"aromas": aromas[i] if i < len(aromas) else []})
if len(subs) >= k:
break
Expand Down Expand Up @@ -1584,6 +1616,7 @@ def mixture_to_molecule(smiles_list: list, weights: list | None = None, k: int =
continue
out.append({"smiles": smis[i], "profile_match": round(float(sims[i]), 3),
"known_tastes": tastes[i], "predicted_tastes": _predicted_tastes_at(profiles, i),
"mouthfeel": _predicted_mouthfeel_at(profiles, i),
"aromas": aromas[i] if i < len(aromas) else []})
if len(out) >= k:
break
Expand Down Expand Up @@ -1733,7 +1766,8 @@ def predict(smiles: str, include_aroma: bool = False) -> dict:
# If two+ taste heads both fire high, surface that as a complex-taste note —
# the model-side echo of ChemTastesDB's 'multitaste' class.
strong = [t for t in ("sweet", "bitter", "umami")
if isinstance(out.get(t), float) and out[t] >= 0.5]
if isinstance(out.get(t), float)
and out[t] >= _head_threshold(_TASTE_META, t)]
out["multitaste"] = len(strong) >= 2
out["taste_profile"] = _taste_profile(out)
out["physchem"] = physchem(mol)
Expand Down
19 changes: 16 additions & 3 deletions training/train_taste.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
from rdkit.Chem import DataStructs, rdFingerprintGenerator
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
from sklearn.metrics import r2_score, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.model_selection import cross_val_predict, train_test_split
from train_aroma import (
_calibrate, # same out-of-fold threshold + precision floor as the aroma heads
)

BASIC = ["sweet", "bitter", "umami", "sour", "salty", "tasteless"]
# Salty now ALSO trains as an INDICATIVE head (CV-AUROC ~0.96 once the PubChem documented-
Expand Down Expand Up @@ -102,9 +105,19 @@ def train_classifiers(master):
clf = RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42)
clf.fit(Xtr, ytr)
auc = roc_auc_score(yte, clf.predict_proba(Xte)[:, 1])
print(f" {taste:7s} AUROC={auc:.3f} (pos={pos}, neg={neg})")
# Per-head decision threshold + measured precision, same instrument as the aroma heads
# (train_aroma._calibrate). These heads have hundreds of positives each and were never
# expected to be shy — but "we measured and 0.5 was right" is a result, not a reason to
# skip measuring. See docs/ACCURACY.md.
oof = cross_val_predict(RandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42),
Xv, yv, cv=5, method="predict_proba")[:, 1]
thr, prec, rec, f1, capable = _calibrate(yv, oof)
tag = "" if capable else " INDICATIVE (never reaches 50% precision)"
print(f" {taste:7s} AUROC={auc:.3f} thr={thr:.2f} prec={prec:.2f}{tag} (pos={pos}, neg={neg})")
joblib.dump(clf, OUT / f"{taste}_rf.joblib")
manifest[taste] = {"auroc": round(float(auc), 3), "n_pos": pos, "n_neg": neg}
manifest[taste] = {"auroc": round(float(auc), 3), "n_pos": pos, "n_neg": neg,
"threshold": thr, "cv_precision": prec, "cv_recall": rec,
"cv_f1": f1, "confident_capable": capable}
return manifest


Expand Down
19 changes: 16 additions & 3 deletions training/train_tox.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
from rdkit import Chem
from rdkit.Chem import DataStructs, rdFingerprintGenerator
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import cross_val_predict, cross_val_score
from train_aroma import (
_calibrate, # same out-of-fold threshold + precision floor as the aroma heads
)

FP_BITS, FP_RADIUS = 2048, 2
_MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_BITS)
Expand Down Expand Up @@ -62,9 +65,19 @@ def fp(smiles):
continue
clf_args = {"n_estimators": 200, "n_jobs": -1, "random_state": 42, "class_weight": "balanced"}
auc = cross_val_score(RandomForestClassifier(**clf_args), Xd, yd, cv=5, scoring="roc_auc").mean()
# Calibration matters MORE here than anywhere else in the app. Assay actives are rare, so a
# high AUROC can sit on top of terrible precision — and a safety flag that is wrong most of
# the time is worse than no flag, because it teaches people to ignore the ones that matter.
oof = cross_val_predict(RandomForestClassifier(**clf_args), Xd, yd, cv=5,
method="predict_proba")[:, 1]
thr, prec, rec, f1, capable = _calibrate(yd, oof)
joblib.dump(RandomForestClassifier(**clf_args).fit(Xd, yd), OUT / f"{t}_rf.joblib")
manifest[t] = {"auroc": round(float(auc), 3), "n_pos": int(yd.sum()), "n": int(mask.sum())}
manifest[t] = {"auroc": round(float(auc), 3), "n_pos": int(yd.sum()), "n": int(mask.sum()),
"threshold": thr, "cv_precision": prec, "cv_recall": rec, "cv_f1": f1,
"confident_capable": capable}
kept += 1
print(f" {t:14s} n={int(mask.sum()):5d} pos={int(yd.sum()):4d} CV-AUROC={auc:.3f}")
tag = "" if capable else " INDICATIVE (never reaches 50% precision)"
print(f" {t:14s} n={int(mask.sum()):5d} pos={int(yd.sum()):4d} CV-AUROC={auc:.3f} "
f"thr={thr:.2f} prec={prec:.2f}{tag}")
(OUT / "manifest.json").write_text(json.dumps({"assays": manifest}, indent=2))
print(f"\nkept {kept}/{len(TASKS)} tox heads -> tox_models/ (caution-only, Tox21 public domain)")
Loading
Loading