From 479f28a6845e645e309fe45fcec0b2a8f0d077b7 Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:59:49 +0000 Subject: [PATCH 1/3] feat: calibrate every head, unify the chip language, fix the modal layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #265, #266, #267, #268. CALIBRATION EVERYWHERE (#265). Taste and Tox21 still ran on a flat 0.5 with only AUROC shown — the exact reporting gap #261 closed for aroma and mouthfeel. Both now use the same out-of-fold calibration with a 50% precision floor, and /api/heads publishes threshold, precision, recall and confident_capable for all four modalities. The taste heads were mildly shy, as expected with hundreds of positives each (sweet 0.33, umami 0.33, bitter 0.51), and all six clear the floor at 0.50-0.87. The Tox21 result is the one that matters: every one of the twelve calibrated UPWARD — NR-AR to 0.69, NR-AR-LBD 0.65, NR-ER 0.63 — which means a flat 0.5 had them OVER-flagging. That is the opposite direction from the thin aroma heads and the more dangerous one, because a caution flag that cries wolf teaches people to ignore the flags that matter. CHIP LANGUAGE (#266). Flavor, note, taste and mouthfeel chips had each been given a different border STYLE at a different time — dashed, dotted, thicker, plus per-taste text recolouring — so they read as four accidents rather than one system. They now share a pill, a size and a border weight, and carry identity in a leading dot in the family's own hue. Each studio section also gained a plain-language paragraph on what that dimension IS and why it matters: a flavor is the whole percept, a note is what the nose reads, taste is the basics the tongue reads, mouthfeel is touch reported by the trigeminal nerve. Menthol is cold without a temperature change; that distinction is load-bearing and nothing in the UI had ever said it. ALL MODALITIES ON CARDS (#267). Substitute and neighbor cards showed taste and aroma but not mouthfeel. Read straight off the profile matrix, whose mouthfeel columns were already there, so it costs no extra inference — and each sensation must clear its own calibrated threshold, so a card never shows something the modal would call indicative. MODAL LAYOUT (#268). Two real bugs, both measured rather than guessed: - Mobile: the swap grid used `1fr`, which is minmax(auto,1fr), and `auto` will not shrink below min-content — so the substitutes and neighbors cards rendered 468px wide inside a 358px modal. minmax(0,1fr) fixes it; .modal-top already had this and .swap-grid did not. - Desktop: #results is a flex column whose 14px gap separates the cards, but the render path set an inline display:block on it, and block boxes ignore `gap`. The Heads card had been sitting flush against the Substitutes card below it. Clearing the inline style lets the stylesheet win. Verified with a Playwright probe at 390px and 1440px: no card overflows its container at either size, and no two cards are within 8px of each other on desktop. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- training/predict.py | 50 ++++++++++++++++++++++++++------ training/train_taste.py | 19 +++++++++++-- training/train_tox.py | 19 +++++++++++-- training/workbench.html | 63 ++++++++++++++++++++++++++++++----------- 4 files changed, 121 insertions(+), 30 deletions(-) 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..c94a537 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -522,13 +522,29 @@ .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} + .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 +638,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} @@ -1633,6 +1656,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 +1746,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 +1930,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 +2444,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 +2458,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 +2468,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 only the five basics the tongue detects — sweet, bitter, umami, sour, salty (plus tasteless). 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; From 631ae43f448839cb438acc77afbda4bfbbd3194d Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:10:13 +0000 Subject: [PATCH 2/3] docs: define AUROC, threshold and precision where people actually land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HOW-IT-WORKS.md used CV-AUROC eleven times and never said what it is beyond a parenthetical, and never mentioned threshold, precision or recall at all — the real explanation lived only in ACCURACY.md, one click further than most readers go. A six-term glossary now sits at section 0.5, before any number is used: head, AUROC (0.5 is a coin flip, 1.0 is never wrong), threshold (not a flat 0.5 — 0.16 to 0.85, fitted per head), precision (when it says yes, how often is it right), recall, out-of-fold, and confident vs indicative. Two sentences each. The same glossary, compressed to a definition list, is now a card in the site's own How-it-works grid, so it reaches someone who will never open the docs folder. Also tightens the taste wording, which was loose in both places: there are FIVE basic tastes and SIX taste heads. `tasteless` is a head that predicts the documented absence of taste — genuinely useful, since it is how you find a clean carrier — but it is not a sixth basic taste, and calling it one is the kind of small inaccuracy a flavorist would notice immediately. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- docs/ACCURACY.md | 2 +- docs/HOW-IT-WORKS.md | 36 ++++++++++++++++++++++++++++++++++++ training/workbench.html | 20 +++++++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) 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/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/workbench.html b/training/workbench.html index c94a537..6bf23a3 100644 --- a/training/workbench.html +++ b/training/workbench.html @@ -529,6 +529,11 @@ 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} @@ -826,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).

@@ -2468,7 +2482,7 @@

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

Taste is only the five basics the tongue detects — sweet, bitter, umami, sour, salty (plus tasteless). 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.

`+ + 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:/,''); // set --chip, don't override border/text colour: the per-taste hue now flows through the From df81e31649c8b03c7abb20e99c4ed94029f3bf43 Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:11:41 +0000 Subject: [PATCH 3/3] docs(arch): pin the frontend UI kit and record what we deliberately excluded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three stack questions kept resurfacing, so they are now answered in the one place someone would look, with the reasoning rather than just the verdict. Frontend is pinned to React 19 + Vite + TypeScript with Tailwind and shadcn/ui, explicitly NOT MUI. shadcn is copy-in rather than import, so the components land in our own source tree where they can be read and owned — which for a portfolio is the difference between showing you used a component library and showing you can build one. Flavormancer also already has a visual identity (the purple/teal gradient, the gilded scroll rods, the flask loader) that Material would flatten, and the port would be spent overriding it. The trade is stated honestly: MUI wins when you need an enterprise data-grid and date pickers tomorrow. This app is cards, chips, charts and a modal. Node.js is scoped to build tooling and an optional TypeScript MCP server beside the Python one — never a third backend. Two API stacks is breadth; three is sprawl, and it reads as indecision. Laravel is deliberately absent and the reasoning is written down so nobody re-opens it: it is a good fit for CRUD-and-content products and is used heavily elsewhere in this portfolio, but Flavormancer is on-prem scientific computing. Adding a familiar framework that proves nothing new would blur that. Choosing against your most comfortable stack when it does not fit is the point. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com> --- docs/ARCHITECTURE.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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