diff --git a/docs/ACCURACY.md b/docs/ACCURACY.md index 9551f76..a46b85d 100644 --- a/docs/ACCURACY.md +++ b/docs/ACCURACY.md @@ -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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 345153b..74e87b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index d4505f7..7045dab 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -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: diff --git a/training/predict.py b/training/predict.py index 05b96fa..48bba8b 100644 --- a/training/predict.py +++ b/training/predict.py @@ -1218,9 +1218,16 @@ 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)"} @@ -1228,9 +1235,12 @@ def predict_tox(mol, threshold=0.5): 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."} @@ -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)], } @@ -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 — @@ -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 []}) @@ -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 @@ -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 @@ -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) diff --git a/training/train_taste.py b/training/train_taste.py index e611e34..dc04b77 100644 --- a/training/train_taste.py +++ b/training/train_taste.py @@ -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- @@ -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 diff --git a/training/train_tox.py b/training/train_tox.py index 452d9ba..b4dbb2e 100644 --- a/training/train_tox.py +++ b/training/train_tox.py @@ -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) @@ -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)") diff --git a/training/workbench.html b/training/workbench.html index 7af6d10..6bf23a3 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -522,13 +522,34 @@ .fam-label{font-family:var(--mono);font-size:10.5px;letter-spacing:.07em;text-transform:uppercase; color:var(--muted);margin:0 0 5px;display:flex;align-items:baseline;gap:7px} .fam-n{font-size:10px;opacity:.6} - .schip-note{border-style:dashed} - .schip-taste{border-style:solid;border-width:1.5px} + /* ONE chip language, four members. Every family shares the same pill, size and border weight; + identity comes from a leading dot in the family's own hue plus a tinted border. Previously + each family had been given a different border STYLE (dashed / dotted / thicker) at a different + time, which read as four accidents rather than one system — the taste and mouthfeel chips in + particular stuck out against the flavor and note chips they sit beside. */ + .atag.mf{border-style:solid;position:relative;padding-left:14px} + .atag.mf::before{content:'';position:absolute;left:6px;top:50%;width:4px;height:4px;margin-top:-2px;border-radius:50%;background:currentColor} + .how-card.gloss .gl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:5px 10px;align-items:baseline} + .how-card.gloss dt{font-family:var(--mono);font-size:10.5px;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);white-space:nowrap} + .how-card.gloss dd{margin:0;font-size:11.5px;line-height:1.55;color:var(--muted)} + .how-card.gloss dd b{color:var(--ink);font-weight:600} + @media(max-width:560px){.how-card.gloss .gl{grid-template-columns:1fr;gap:2px}.how-card.gloss dt{margin-top:7px}} + .ss-why{font-size:11.5px;line-height:1.62;color:var(--muted);margin:2px 0 11px;max-width:74ch} + .ss-why b{color:var(--ink);font-weight:600} + .ss-why code{font-family:var(--mono);font-size:10.5px;background:var(--panel);border:1px solid var(--line);border-radius:4px;padding:0 4px} + .schip{display:inline-flex;align-items:center;gap:7px} + .schip::before{content:'';width:6px;height:6px;border-radius:50%;flex:0 0 auto; + background:var(--chip,var(--muted));box-shadow:0 0 0 2px rgb(from var(--chip,var(--muted)) r g b / .16)} + .schip-flavor{--chip:var(--brand-1)} + .schip-note{--chip:var(--aroma)} + .schip-taste{--chip:var(--brand-accent)} + .schip-mouth{--chip:var(--accent)} + .schip-flavor,.schip-note,.schip-taste,.schip-mouth{border-color:rgb(from var(--chip) r g b / .38)} + .schip.on::before{background:#08121A;box-shadow:none} .dtox{margin-top:4px;font-family:var(--mono);font-size:10.5px;color:#C0553A;cursor:help} /* mouthfeel chips: a distinct dotted edge + amber cast so the sensation modality reads apart from odour notes at a glance (cooling/pungent legitimately appear in both rows) */ - .schip-mouth{border-style:dotted;border-color:var(--accent);color:var(--accent)} - .schip-mouth:hover{border-color:var(--brand-2)} + .schip-mouth:hover,.schip-taste:hover,.schip-note:hover,.schip-flavor:hover{border-color:var(--chip)} .schip.on{background:var(--brand-grad);color:#08121A;border-color:transparent;font-weight:650;box-shadow:0 2px 12px rgba(43,196,196,.32);transform:translateY(-1px)} .schip{transition:all .13s ease} .design-grid .dcell{animation:fadeUp .3s ease both} @@ -622,10 +643,17 @@ .hg-note{font-size:11px;color:var(--muted);margin:6px 0 0;line-height:1.5} #aromaPreview::-webkit-scrollbar{width:8px}#aromaPreview::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px} /* the two swap lists fill their box (bounded + internal scroll) so no wasted whitespace */ - .swap-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:stretch} - .swap-grid .card{display:flex;flex-direction:column;min-height:360px;max-height:70vh} - .swap-grid .card .nb-list{flex:1 1 auto;min-height:0;max-height:none} - @media(max-width:820px){ .modal-top{grid-template-columns:1fr} .swap-grid{grid-template-columns:1fr} .swap-grid .card{max-height:none} } + /* minmax(0,1fr), NOT 1fr: a bare `1fr` is minmax(auto,1fr), and `auto` refuses to shrink below + the content's min-content width — which is how these two cards ended up 468px wide inside a + 358px modal on a phone. .modal-top already had this; .swap-grid did not. */ + .swap-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px;align-items:stretch} + .swap-grid .card{display:flex;flex-direction:column;min-width:0;min-height:360px;max-height:70vh} + .swap-grid .card .nb-list{flex:1 1 auto;min-width:0;min-height:0;max-height:none} + @media(max-width:820px){ + .modal-top{grid-template-columns:minmax(0,1fr)} + .swap-grid{grid-template-columns:minmax(0,1fr)} /* stack into one column */ + .swap-grid .card{max-height:none;min-height:0} + } /* mobile */ @media(max-width:640px){ header{padding:22px 14px 18px} @@ -803,8 +831,17 @@

Flavormancer

3 · Aroma — 167 odor heads

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.

4 · Honest by design

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.

5 · How good is a head, really?

AUROC alone will lie to you. It measures ranking and is blind to how rare a note is — a head with 11 examples among 2,403 molecules can score 0.979 and still be right just 1 time in 10 when it fires. So every head also publishes its precision: when it says yes, how often it's actually right, measured only on molecules it never trained on. Each head gets its own firing threshold (0.16 – 0.85, not a flat 0.5), fitted so a confident call is right more than half the time. 23 heads have never been wrong on unseen molecules; the 73 that can't clear 50% are shown as indicative — hatched bars, kept in full, never dressed up as confident.

-

6 · Formulation, not just molecules

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.

-

7 · On-prem & commercial-clean

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).

+

6 · Reading the numbers

+
+
AUROC
A ranking score: given one true example and one false one, how often is the true one scored higher? 0.5 = coin flip, 1.0 = never wrong. Says nothing about how often a head is right when it fires.
+
Threshold
The score at which a head counts as firing. Not a flat 0.5 — each head has its own, fitted on data it never trained on, ranging 0.16 – 0.85.
+
Precision
When it says yes, how often is it right? Trustworthiness — and the number AUROC can't see.
+
Recall
Of all the real ones, how many did it catch? Thoroughness. Trades against precision.
+
Out‑of‑fold
Scored by a model that never saw that molecule. Every number here is out-of-fold — no model grades its own homework.
+
Indicative
A head that can't reach 50% precision at any threshold. Kept in full, shown with a hatched bar, never called confident.
+
+

7 · Formulation, not just molecules

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.

+

8 · On-prem & commercial-clean

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).

Deeper dives in the repo: docs/HOW-IT-WORKS.md (the full method) · docs/DATA-SOURCES.md (every source + license) · docs/AROMA.md (the odor heads).

@@ -1633,6 +1670,7 @@

Software & type

${x.smiles}
${(()=>{const doc=(x.known_tastes||[]).map(t=>`${t}`);const pred=(x.predicted_tastes||[]).filter(t=>!(x.known_tastes||[]).includes(t)).map(t=>`${t} •`);return [...doc,...pred].join('')||'no taste read';})()}
${(x.aroma&&x.aroma.length)?`
${x.aroma.map(a=>`${a.odor}${a.source==='predicted'?' •':''}`).join('')}
`:''} + ${(x.mouthfeel&&x.mouthfeel.length)?`
${x.mouthfeel.map(m=>`${m}`).join('')}
`:''}
${Math.round((x[scoreKey]||0)*100)}% match
`).join(''); @@ -1722,7 +1760,10 @@

Software & type

'Build the reference set to enable structural neighbors.', $('nbCount')); renderBehavior(p); - $('results').style.display='block'; $('footnote').style.display='block'; + // '' not 'block': #results is a flex column whose 14px gap separates the cards, and an inline + // display:block silently killed that gap (block boxes ignore `gap`) — the Heads card sat flush + // against the Substitutes card below it. Clearing the inline style lets the stylesheet win. + $('results').style.display=''; $('footnote').style.display=''; // animate bar fills requestAnimationFrame(()=>document.querySelectorAll('.fill') .forEach(f=>f.style.width=f.dataset.w+'%')); @@ -1903,7 +1944,8 @@

Software & type

const rows=assays.slice().sort((a,b)=>(b.probability||0)-(a.probability||0)); box.innerHTML = rows.map(a=>{ const col = a.probability>=0.5 ? '#C0553A' : 'var(--muted)'; // amber-red when the assay fires - return headBar(a.assay, a.probability, a.auroc, col, a.meaning); + return headBar(a.assay, a.probability, a.auroc, col, a.meaning, + {threshold:a.threshold, precision:a.precision}); }).join(''); grp.style.display='block'; } @@ -2416,7 +2458,7 @@

Software & type

let html = `
`; - html += `
Flavors a flavor you know → the molecule that makes it
`; + html += `
Flavors a flavor you know → the molecule that makes it

A flavor is the whole percept — banana, cola, root beer. It is what a person names, and it is almost never one molecule: it is a taste, several aroma notes and often a mouthfeel arriving together. Start here when you know the target and need the parts.

`; html += cats.map(c=>`
${svgIcon(c.category)}${c.category}`+ c.flavors.map(f=>``).join('')+`
`).join(''); html += `
`; @@ -2430,7 +2472,7 @@

Software & type

if(have.length) groups.push([fam, have]); } if(left.size) groups.push(['Other', [...left].sort()]); - html += `
Notes an aroma quality → molecules that carry it
`+ + html += `
Notes an aroma quality → molecules that carry it

A note is what your nose reads — vanilla, smoky, green, citrus. Aroma carries most of what people call “flavor”: block your nose and a pear and a potato are hard to tell apart. This is the largest set here (167 trained heads) and the main lever for making something smell like what you intend.

`+ groups.map(([fam, ms])=> `
${fam} ${ms.length}
`+ `
`+ @@ -2440,18 +2482,21 @@

Software & type

} // Taste — the basic tastes, namespaced so they don't collide with the like-named aroma notes if(tastes.length){ - html += `
Taste what the tongue reads — distinct from a sweet smell
`+ + html += `
Taste what the tongue reads — distinct from a sweet smell

Taste is the five basics the tongue detects — sweet, bitter, umami, sour, salty. We train six heads: those five plus tasteless, which predicts the documented absence of taste and is how you find a clean carrier. It is a genuinely different channel from smell, which is why taste:sweet and the aroma note sweet are separate heads: vanillin smells sweet without being a sweetener. Use taste to set the backbone, notes to give it character.

`+ `
`+ tastes.map(t=>{const lbl=t.replace(/^taste:/,''); - return ``;}).join('')+ + // set --chip, don't override border/text colour: the per-taste hue now flows through the + // shared chip system (dot + tinted border) instead of recolouring the whole chip, which + // is what made these stand apart from the flavour and note chips beside them + return ``;}).join('')+ `
`; } // Mouthfeel — trained trigeminal sensations (a different modality from taste and aroma) if(mouth.length){ - html += `
Mouthfeel how it feels in the mouth — chemesthesis, not smell
`+ + html += `
Mouthfeel how it feels in the mouth — chemesthesis, not smell

Mouthfeel is touch, not taste or smell — the trigeminal nerve reporting cooling, warming, pungency, tingle and astringency. Menthol is cold with no temperature change; capsaicin burns with no heat. It is the dimension most often left out of a formulation and the one people notice immediately when it is wrong.

`+ `
`+ mouth.map(m=>{const lbl=m.replace(/^mouthfeel:/,''); // term stays namespaced, label reads plain - return ``;}).join('')+ + return ``;}).join('')+ `
`; } $('studioChips').innerHTML = html;