Skip to content

Commit 4364334

Browse files
feat(ui): mouthfeel as a target dimension in the studios (#245)
Mouthfeel shipped as a modality in #243 but wasn't selectable anywhere. _precompute_design now tags the design pool with the mouthfeel heads (batched, like aroma and taste), /api/studio_terms returns a third `mouthfeel` list, and both pickers gained a Mouthfeel group — the Studio chip rail and the Formulation Studio target profile — styled with a dotted amber edge. Two bugs caught by verifying against the live box rather than assuming: 1. tingling matched 0 molecules and astringent 1, because the design pool is built from the HSDB *odor* corpus which barely contains trigeminal agents (0 of 11 tingling, 1 of 11 astringent). Curated mouthfeel agents now fold into the pool via a dedicated _MOUTHFEEL_CARRIERS loader. 2. `pungent` under Mouthfeel returned acetic acid, acetone and ammonia — sharp-SMELLING molecules — because the aroma head and the sensation head shared a bare tag and aroma had far more corpus molecules. Mouthfeel tags are now namespaced (mouthfeel:pungent), matching how the profile index keys aroma:cooling vs mouthfeel:cooling. Verified after deploy: mouthfeel:pungent -> allyl isothiocyanate, diallyl disulfide, capsaicin; note:pungent -> acetic acid, acetaldehyde, acetone, ammonia. All five sensations return real agents. Part of #240, which stays open for the broader chip-picker usability pass.
1 parent 15e2116 commit 4364334

2 files changed

Lines changed: 87 additions & 10 deletions

File tree

training/app.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,7 +1102,11 @@ def _resolve_one(it):
11021102
def _load_note_carriers():
11031103
"""descriptor/note -> [(smiles, name)] of KNOWN character-impact molecules, from the curated
11041104
flavors.csv + aroma_supplement.csv. The recipe designer prefers these (e.g. gamma-nonalactone
1105-
for coconut, maltol for caramel) over a generic palette match, which can surface poor carriers."""
1105+
for coconut, maltol for caramel) over a generic palette match, which can surface poor carriers.
1106+
1107+
Mouthfeel agents load separately (_load_mouthfeel_carriers) and are NOT merged here: both files
1108+
key on bare names, so aroma_supplement's `pungent` Piper amides and mouthfeel_supplement's
1109+
TRPV1 agents would collide under one key and lose their modality."""
11061110
import csv
11071111
m = {}
11081112
for path in ("flavors.csv", "aroma_supplement.csv"):
@@ -1116,7 +1120,25 @@ def _load_note_carriers():
11161120
return m
11171121

11181122

1123+
def _load_mouthfeel_carriers():
1124+
"""sensation -> [(smiles, name)] of curated trigeminal agents (mouthfeel_supplement.csv, same
1125+
schema as flavors.csv). Kept separate from _NOTE_CARRIERS so the modality survives: the design
1126+
pool is built from the HSDB *odor* corpus, which barely contains these (0 of 11 tingling agents,
1127+
1 of 11 astringent), so without folding them in those chips match nothing."""
1128+
import csv
1129+
m = {}
1130+
with contextlib.suppress(Exception), open("mouthfeel_supplement.csv", encoding="utf-8") as fh:
1131+
for r in csv.DictReader(fh):
1132+
note = (r.get("flavor") or "").strip().lower()
1133+
smi = (r.get("smiles") or "").strip()
1134+
nm = (r.get("molecule") or "").strip()
1135+
if note and smi:
1136+
m.setdefault(note, []).append((smi, nm or note))
1137+
return m
1138+
1139+
11191140
_NOTE_CARRIERS = _load_note_carriers()
1141+
_MOUTHFEEL_CARRIERS = _load_mouthfeel_carriers()
11201142

11211143

11221144
class DesignRecipeQuery(BaseModel):
@@ -1435,6 +1457,7 @@ def api_map():
14351457
# --- Flavor designer: reverse search (desired descriptors -> best food-safe molecules) ---
14361458
_DESIGN = [] # [{smiles, name, tags:set, gras:bool}]
14371459
_DESIGN_DESCS = [] # descriptors with enough molecules to offer as options
1460+
_DESIGN_MOUTHFEEL = [] # trained mouthfeel/chemesthesis sensations, offered as their own pick-list
14381461

14391462

14401463
def _precompute_design():
@@ -1469,6 +1492,13 @@ def _precompute_design():
14691492
if clf is not None:
14701493
for i in np.where(clf.predict_proba(X)[:, 1] >= 0.5)[0]:
14711494
tagsets[i].add(t)
1495+
# Mouthfeel tags are NAMESPACED ("mouthfeel:pungent"), matching how the profile index keys
1496+
# its dims. Without this, picking `pungent` under Mouthfeel returned sharp-SMELLING
1497+
# molecules (acetic acid, ammonia, CO2) from the far larger aroma:pungent set instead of
1498+
# the TRPV1 burn agents — the two modalities share a name but not a meaning.
1499+
for name, clf in P._MOUTHFEEL_MODELS.items(): # mouthfeel / chemesthesis
1500+
for i in np.where(clf.predict_proba(X)[:, 1] >= 0.5)[0]:
1501+
tagsets[i].add(f"mouthfeel:{name}")
14721502
cnt, pool = Counter(), []
14731503
for (smi, nm, skel, _, _), tags in zip(rows, tagsets):
14741504
if not tags:
@@ -1478,18 +1508,27 @@ def _precompute_design():
14781508
# fold the curated character-impact molecules (supplement + flavors) into the index so
14791509
# their descriptors (coconut, nutty, vanilla, cinnamon...) are searchable + offerable even
14801510
# where the industrial-skewed odor corpus is thin on them
1481-
for note, carriers in _NOTE_CARRIERS.items():
1511+
# aroma/flavor carriers keep their bare note; mouthfeel carriers carry the namespaced tag
1512+
folds = [(n, c, n) for n, c in _NOTE_CARRIERS.items()]
1513+
folds += [(n, c, f"mouthfeel:{n}") for n, c in _MOUTHFEEL_CARRIERS.items()]
1514+
for _note, carriers, tag in folds:
14821515
for csmi, cnm in carriers:
14831516
cm = Chem.MolFromSmiles(csmi)
14841517
if cm is None:
14851518
continue
1486-
pool.append({"smiles": csmi, "name": cnm or "", "tags": {note},
1519+
pool.append({"smiles": csmi, "name": cnm or "", "tags": {tag},
14871520
"gras": Chem.MolToInchiKey(cm).split("-")[0] in P._GRAS})
1488-
cnt.update([note])
1521+
cnt.update([tag])
14891522
_DESIGN[:] = pool
14901523
# Offer EVERY trained aroma head as a selectable note (even aroma-only ones with few
1491-
# food-safe carriers), plus any design note that has >=5 carriers.
1492-
_DESIGN_DESCS[:] = sorted({d for d, n in cnt.items() if n >= 5} | set(P._AROMA_MODELS))
1524+
# food-safe carriers), plus any design note with >=5 carriers. Namespaced mouthfeel tags
1525+
# are excluded — they're a separate modality with their own pick-list below.
1526+
_DESIGN_DESCS[:] = sorted(
1527+
{d for d, n in cnt.items() if n >= 5 and not d.startswith("mouthfeel:")}
1528+
| set(P._AROMA_MODELS))
1529+
# Mouthfeel terms stay namespaced ("mouthfeel:cooling") so picking `cooling` here matches
1530+
# the SENSATION, not the like-named odour note. The UI shows the bare label.
1531+
_DESIGN_MOUTHFEEL[:] = [f"mouthfeel:{m}" for m in sorted(P._MOUTHFEEL_MODELS)]
14931532

14941533

14951534
def _fpvec(mol):
@@ -1666,8 +1705,9 @@ def _gras_subs(smi, k=3):
16661705

16671706
@app.get("/api/studio_terms")
16681707
def api_studio_terms():
1669-
"""The unified pick-list: curated flavors (grouped by category) + matchable note descriptors."""
1670-
return {"flavors": _FLAVOR_CATS, "notes": _DESIGN_DESCS}
1708+
"""The unified pick-list: curated flavors (grouped by category), matchable aroma-note
1709+
descriptors, and mouthfeel sensations — three modalities the studios can target."""
1710+
return {"flavors": _FLAVOR_CATS, "notes": _DESIGN_DESCS, "mouthfeel": _DESIGN_MOUTHFEEL}
16711711

16721712

16731713
@app.get("/api/nl")

training/workbench.html

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@
161161
border:1px solid var(--line);background:var(--surface)}
162162
.tox-code{font-size:9.5px;opacity:.62;letter-spacing:.02em}
163163
.chip[title]{cursor:help}
164+
/* mouthfeel target chips: dotted amber edge, so the sensation modality reads apart from the
165+
aroma-note chips beside it (cooling/pungent legitimately appear in both groups) */
166+
.chip-mouth{border-style:dotted;border-color:var(--accent);color:var(--accent)}
164167
.chip.on{color:#fff;border-color:transparent}
165168
/* scroll the qualifying set (every match above the similarity floor) instead of a fixed few;
166169
~5 rows tall, then scroll. A subtle fade at the bottom hints there's more below. */
@@ -501,6 +504,10 @@
501504
.schip{font:inherit;font-size:12px;padding:3px 11px;border:1px solid var(--line);border-radius:20px;background:var(--panel);color:var(--ink);cursor:pointer;text-transform:capitalize}
502505
.schip:hover{border-color:var(--brand-2)}
503506
.schip-note{border-style:dashed}
507+
/* mouthfeel chips: a distinct dotted edge + amber cast so the sensation modality reads apart
508+
from odour notes at a glance (cooling/pungent legitimately appear in both rows) */
509+
.schip-mouth{border-style:dotted;border-color:var(--accent);color:var(--accent)}
510+
.schip-mouth:hover{border-color:var(--brand-2)}
504511
.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)}
505512
.schip{transition:all .13s ease}
506513
.design-grid .dcell{animation:fadeUp .3s ease both}
@@ -829,6 +836,7 @@ <h1>Flavormancer</h1>
829836
<div class="form-target-head">Target profile <span class="mapsub" style="text-transform:none;font-weight:600">— optional; pick the <b>flavors</b> and <b>notes</b> you're aiming for. <b>Analyze</b> (above) scores your rows against it; <b>Design</b> (below) proposes a recipe for it.</span></div>
830837
<div class="form-tgt-group"><span class="form-tgt-label"><svg class="ic"><use href="#ic-flavors"/></svg> Flavors</span><div id="formTargetFlavors" class="form-chips"></div></div>
831838
<div class="form-tgt-group"><span class="form-tgt-label"><svg class="ic"><use href="#ic-notes"/></svg> Notes</span><div id="formTargetChips" class="form-chips"></div></div>
839+
<div class="form-tgt-group"><span class="form-tgt-label" title="Chemesthesis — how it feels in the mouth, not how it smells"><svg class="ic"><use href="#ic-notes"/></svg> Mouthfeel</span><div id="formTargetMouth" class="form-chips"></div></div>
832840
<button class="mix-btn" id="formDesign" title="Propose a starting recipe for your target flavors + notes" style="margin-top:4px">✨ Design a recipe for this target</button>
833841
</div>
834842
<div id="formResults"></div>
@@ -1236,6 +1244,16 @@ <h4>Software &amp; type</h4>
12361244
salty:'small mineral (alkali) salts — paired with a cation rule',
12371245
tasteless:'little or no basic taste expected from structure'
12381246
};
1247+
// per-sensation "what this feels like" — the mouthfeel parallel to AROMA_HELP/TASTE_HELP.
1248+
// Mouthfeel is trigeminal (chemesthesis), a different modality from smell — cooling and pungent
1249+
// exist as BOTH an aroma head and a mouthfeel head, so these blurbs stress the *sensation*.
1250+
const MOUTHFEEL_HELP = {
1251+
cooling:'cooling — TRPM8 coolants (menthol, WS-agents); a physiological cool, not just a cool smell',
1252+
pungent:'pungent — TRPV1 / mustard-oil heat &amp; bite (capsaicinoids, isothiocyanates, allium sulfur)',
1253+
warming:'warming — capsaicinoid &amp; warm-spice heat (chili, pepper, ginger, cinnamon)',
1254+
astringent:'astringent — tannins &amp; polyphenols; the puckering, mouth-drying sensation',
1255+
tingling:'tingling — paresthesia alkylamides (Sichuan-pepper sanshools, jambu spilanthol)',
1256+
};
12391257
// per-descriptor "what this note smells like" — the aroma parallel to TASTE_HELP
12401258
const AROMA_HELP = {
12411259
neroli:'orange-blossom / neroli — anthranilates &amp; indole over a terpene-alcohol base',
@@ -2270,7 +2288,7 @@ <h4>Software &amp; type</h4>
22702288
const STUDIO_EMPTY='<div class="empty">Pick one or more flavors or notes above, then “Find molecules”.</div>';
22712289
async function initStudio(){
22722290
try{
2273-
const d = await getJSON('/api/studio_terms'); const cats=d.flavors||[], notes=d.notes||[];
2291+
const d = await getJSON('/api/studio_terms'); const cats=d.flavors||[], notes=d.notes||[], mouth=d.mouthfeel||[];
22742292
if(!cats.length && !notes.length){ setTimeout(initStudio, 4000); return; }
22752293
const esc = s => s.replace(/"/g,'&quot;');
22762294
// category -> icon-sprite id (custom SVG glyphs, no emoji). Extra synonyms map to the
@@ -2295,6 +2313,14 @@ <h4>Software &amp; type</h4>
22952313
notes.map(n=>`<button type="button" class="schip schip-note" data-term="${esc(n)}">${n}</button>`).join('')+
22962314
`</div></div></div>`;
22972315
}
2316+
// Mouthfeel — trained trigeminal sensations (a different modality from taste and aroma)
2317+
if(mouth.length){
2318+
html += `<div class="studio-section"><div class="ss-head"><svg class="ic"><use href="#ic-notes"/></svg> Mouthfeel <span class="ss-sub">how it <b>feels</b> in the mouth — chemesthesis, not smell</span></div><div class="ss-body">`+
2319+
`<div class="studio-group studio-group-notes">`+
2320+
mouth.map(m=>{const lbl=m.replace(/^mouthfeel:/,''); // term stays namespaced, label reads plain
2321+
return `<button type="button" class="schip schip-mouth" data-term="${esc(m)}" title="${esc(MOUTHFEEL_HELP[lbl]||lbl)}">${lbl}</button>`;}).join('')+
2322+
`</div></div></div>`;
2323+
}
22982324
$('studioChips').innerHTML = html;
22992325
$('studioChips').querySelectorAll('.schip').forEach(b=> b.addEventListener('click', ()=>{
23002326
const t=b.dataset.term; if(_studioSel.has(t)){ _studioSel.delete(t); b.classList.remove('on'); } else { _studioSel.add(t); b.classList.add('on'); }
@@ -2561,6 +2587,15 @@ <h4>Software &amp; type</h4>
25612587
b.onclick=()=>{ if(_formFlavors.has(fl)){ _formFlavors.delete(fl); b.classList.remove('on'); } else { _formFlavors.add(fl); b.classList.add('on'); } };
25622588
box.appendChild(b);
25632589
}));
2590+
// mouthfeel targets — the trigeminal sensations, aimed at like notes but a distinct modality
2591+
const mbox=$('formTargetMouth');
2592+
(d.mouthfeel||[]).forEach(m=>{
2593+
const lbl=m.replace(/^mouthfeel:/,''); // term stays namespaced so it matches the sensation tag
2594+
const b=document.createElement('span'); b.className='chip chip-mouth'; b.textContent=lbl; b.dataset.term=m;
2595+
b.title=MOUTHFEEL_HELP[lbl]||lbl;
2596+
b.onclick=()=>{ if(_formTarget.has(m)){ _formTarget.delete(m); b.classList.remove('on'); } else { _formTarget.add(m); b.classList.add('on'); } };
2597+
mbox.appendChild(b);
2598+
});
25642599
}).catch(()=>{});
25652600
let _lastRecipe=[]; let _lastRecipeMeta={flavors:[],notes:[]};
25662601
$('formDesign').addEventListener('click', async ()=>{
@@ -2732,8 +2767,10 @@ <h4>Software &amp; type</h4>
27322767
$('formRows').innerHTML='';
27332768
b.dataset.ings.split('|').forEach(s=>{ const i=s.indexOf(':'); formAddRow(i<0?s:s.slice(0,i), i<0?'':s.slice(i+1)); });
27342769
_formTarget.clear(); $('formTargetChips').querySelectorAll('.chip.on').forEach(x=>x.classList.remove('on'));
2770+
$('formTargetMouth').querySelectorAll('.chip.on').forEach(x=>x.classList.remove('on')); // mouthfeel shares _formTarget
27352771
_formFlavors.clear(); $('formTargetFlavors').querySelectorAll('.chip.on').forEach(x=>x.classList.remove('on'));
2736-
(b.dataset.tgt||'').split(',').filter(Boolean).forEach(t=>{ _formTarget.add(t); const c=$('formTargetChips').querySelector(`.chip[data-term="${t}"]`); if(c) c.classList.add('on'); });
2772+
// a preset term may be an aroma note or a mouthfeel sensation — light whichever chip carries it
2773+
(b.dataset.tgt||'').split(',').filter(Boolean).forEach(t=>{ _formTarget.add(t); const c=$('formTargetChips').querySelector(`.chip[data-term="${t}"]`) || $('formTargetMouth').querySelector(`.chip[data-term="${t}"]`); if(c) c.classList.add('on'); });
27372774
$('formGo').click();
27382775
}));
27392776
</script>

0 commit comments

Comments
 (0)