Skip to content

Commit f765539

Browse files
fix(ui): namespace mouthfeel terms so the sensation isn't drowned by the like-named note
Verifying the chips on the live box exposed a real semantic bug: picking `pungent` under Mouthfeel returned Acetic Acid, Acetone, Ammonia and CO2 — sharp-SMELLING molecules — with only 1 TRPV1 agent in 40 results. Both the aroma head and the sensation head wrote the same bare `pungent` tag, and the far larger aroma set drowned the sensation one. `cooling` only looked correct by luck (menthol genuinely smells and feels cool). Mouthfeel tags are now namespaced (`mouthfeel:pungent`), matching how the profile index already keys `aroma:cooling` vs `mouthfeel:cooling`. Chips send the namespaced term; the UI renders the plain label. Verified after restart: mouthfeel:pungent -> allyl isothiocyanate, diallyl disulfide, capsaicin note:pungent -> acetic acid, acetaldehyde, acetone, ammonia Also splits _MOUTHFEEL_CARRIERS out of _NOTE_CARRIERS: both CSVs key on bare names, so merging them would have relabelled aroma_supplement's `pungent` Piper amides as sensation agents. All five chips now return real agents (tingling finds 11, of which 2 are food-listed — the sanshools aren't on the FDA/EU registers, which is correct). Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent bd720d2 commit f765539

2 files changed

Lines changed: 48 additions & 22 deletions

File tree

training/app.py

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,14 +1101,15 @@ def _resolve_one(it):
11011101

11021102
def _load_note_carriers():
11031103
"""descriptor/note -> [(smiles, name)] of KNOWN character-impact molecules, from the curated
1104-
flavors.csv + aroma_supplement.csv + mouthfeel_supplement.csv (same schema). The recipe designer
1105-
prefers these (e.g. gamma-nonalactone for coconut, maltol for caramel) over a generic palette
1106-
match, which can surface poor carriers. The mouthfeel agents matter especially here: the design
1107-
pool is built from the HSDB *odor* corpus, which barely contains them (0 of 11 tingling agents,
1108-
1 of 11 astringent), so without folding them in those chips would match nothing."""
1104+
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.
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."""
11091110
import csv
11101111
m = {}
1111-
for path in ("flavors.csv", "aroma_supplement.csv", "mouthfeel_supplement.csv"):
1112+
for path in ("flavors.csv", "aroma_supplement.csv"):
11121113
with contextlib.suppress(Exception), open(path, encoding="utf-8") as fh: # missing file / bad rows; just skip
11131114
for r in csv.DictReader(fh):
11141115
note = (r.get("flavor") or "").strip().lower()
@@ -1119,7 +1120,25 @@ def _load_note_carriers():
11191120
return m
11201121

11211122

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+
11221140
_NOTE_CARRIERS = _load_note_carriers()
1141+
_MOUTHFEEL_CARRIERS = _load_mouthfeel_carriers()
11231142

11241143

11251144
class DesignRecipeQuery(BaseModel):
@@ -1473,9 +1492,13 @@ def _precompute_design():
14731492
if clf is not None:
14741493
for i in np.where(clf.predict_proba(X)[:, 1] >= 0.5)[0]:
14751494
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.
14761499
for name, clf in P._MOUTHFEEL_MODELS.items(): # mouthfeel / chemesthesis
14771500
for i in np.where(clf.predict_proba(X)[:, 1] >= 0.5)[0]:
1478-
tagsets[i].add(name)
1501+
tagsets[i].add(f"mouthfeel:{name}")
14791502
cnt, pool = Counter(), []
14801503
for (smi, nm, skel, _, _), tags in zip(rows, tagsets):
14811504
if not tags:
@@ -1485,26 +1508,27 @@ def _precompute_design():
14851508
# fold the curated character-impact molecules (supplement + flavors) into the index so
14861509
# their descriptors (coconut, nutty, vanilla, cinnamon...) are searchable + offerable even
14871510
# where the industrial-skewed odor corpus is thin on them
1488-
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:
14891515
for csmi, cnm in carriers:
14901516
cm = Chem.MolFromSmiles(csmi)
14911517
if cm is None:
14921518
continue
1493-
pool.append({"smiles": csmi, "name": cnm or "", "tags": {note},
1519+
pool.append({"smiles": csmi, "name": cnm or "", "tags": {tag},
14941520
"gras": Chem.MolToInchiKey(cm).split("-")[0] in P._GRAS})
1495-
cnt.update([note])
1521+
cnt.update([tag])
14961522
_DESIGN[:] = pool
14971523
# Offer EVERY trained aroma head as a selectable note (even aroma-only ones with few
1498-
# food-safe carriers), plus any design note that has >=5 carriers — but keep the
1499-
# mouthfeel-ONLY sensations out of the notes list (they get their own group below).
1500-
# cooling/pungent are exempt from that subtraction: they're genuine aroma heads too.
1501-
mouth_only = set(P._MOUTHFEEL_MODELS) - 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.
15021526
_DESIGN_DESCS[:] = sorted(
1503-
({d for d, n in cnt.items() if n >= 5} | set(P._AROMA_MODELS)) - mouth_only)
1504-
# Mouthfeel is offered as its own pick-list — a different modality, not an odour note.
1505-
# cooling/pungent intentionally appear in BOTH lists (they're a trained aroma head *and* a
1506-
# trained sensation head), so a molecule that smells cool and one that feels cool both match.
1507-
_DESIGN_MOUTHFEEL[:] = sorted(P._MOUTHFEEL_MODELS)
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)]
15081532

15091533

15101534
def _fpvec(mol):

training/workbench.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2317,7 +2317,8 @@ <h4>Software &amp; type</h4>
23172317
if(mouth.length){
23182318
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">`+
23192319
`<div class="studio-group studio-group-notes">`+
2320-
mouth.map(m=>`<button type="button" class="schip schip-mouth" data-term="${esc(m)}" title="${esc(MOUTHFEEL_HELP[m]||m)}">${m}</button>`).join('')+
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('')+
23212322
`</div></div></div>`;
23222323
}
23232324
$('studioChips').innerHTML = html;
@@ -2589,8 +2590,9 @@ <h4>Software &amp; type</h4>
25892590
// mouthfeel targets — the trigeminal sensations, aimed at like notes but a distinct modality
25902591
const mbox=$('formTargetMouth');
25912592
(d.mouthfeel||[]).forEach(m=>{
2592-
const b=document.createElement('span'); b.className='chip chip-mouth'; b.textContent=m; b.dataset.term=m;
2593-
b.title=MOUTHFEEL_HELP[m]||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;
25942596
b.onclick=()=>{ if(_formTarget.has(m)){ _formTarget.delete(m); b.classList.remove('on'); } else { _formTarget.add(m); b.classList.add('on'); } };
25952597
mbox.appendChild(b);
25962598
});

0 commit comments

Comments
 (0)