Skip to content

Commit ecd0914

Browse files
feat: no head looks empty on the map; cap the shareable card at what fires
MAP (#233): the map colours each molecule by a single WINNING label, so a head could read "(0)" in the legend while being trained on plenty of molecules — they were simply displayed under a rarer co-occurring label. Highlighting matched that winner-take-all label, so those heads lit up nothing and looked broken; it was the first thing that made the head roster look untrustworthy. New GET /api/map_members returns TRUE membership from the profile index — every molecule whose head fires >= 0.5, not just where it wins. Verified on the heads that read zero: clarysage 25 members, violetleaf 21, blackpepper 14, juniper 13, freesia 12, rosemary 12. Shipping 8,834 x 175 scores to the browser was not viable; the endpoint keeps the payload small and is cached. Zero-count legend entries are now clickable (with a tooltip explaining the distinction) and highlighting routes through one mapHit() predicate applied consistently across the 2D, properties and 3D views. Terms may be bare or dimension-qualified. CARD (#236): the shareable PNG rendered ALL 164 aroma cells — 28 grid rows — and omitted mouthfeel and safety entirely. A snapshot cannot scroll, so it now shows the 6 tastes ALWAYS (a fixed row, comparable across cards) and only what FIRES for the rest: aroma capped at 18, mouthfeel when a sensation fires, Tox21 only when flagged (warning red, caution-only wording). Labels state the subset honestly — "AROMA MODEL · 12 of 164 heads firing" — so nobody mistakes it for the whole model, and a molecule with nothing firing falls back to its strongest 3 rather than printing a blank card. Layout is adaptive: verified vanillin 692px (aroma only), menthol 766px (+cooling), capsaicin 840px (+pungent/warming, +NR-Aromatase/SR-ARE). Closes #233. Closes #236. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent af4df98 commit ecd0914

2 files changed

Lines changed: 117 additions & 15 deletions

File tree

training/app.py

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -583,9 +583,25 @@ def font(path, size):
583583
"tasteless": out.get("tasteless")}
584584
taste_cells = sorted(((t, float(v) if isinstance(v, (int, float)) else 0.0)
585585
for t, v in taste_src.items()), key=lambda kv: -kv[1])
586+
# The card is a shareable SNAPSHOT — a PNG can't scroll, and there are 187 heads. So: the 6
587+
# tastes ALWAYS render (a complete, fixed row you can compare across cards), while aroma,
588+
# mouthfeel and safety show only what actually FIRES, capped. The labels say "N of M" so a
589+
# reader knows they're seeing the firing subset, not the whole model.
590+
AROMA_CAP = 18 # 3 rows of 6 — keeps the card readable
586591
pa = P.predict_aroma(smi)
587-
aroma_cells = sorted(((d["odor"], d["score"]) for d in pa.get("descriptors", [])),
588-
key=lambda kv: -kv[1])
592+
_all_aroma = sorted(((d["odor"], d["score"]) for d in pa.get("descriptors", [])),
593+
key=lambda kv: -kv[1])
594+
_fired = [c for c in _all_aroma if c[1] >= 0.5]
595+
aroma_cells = (_fired or _all_aroma[:3])[:AROMA_CAP] # nothing firing -> top 3, never a blank card
596+
aroma_total, aroma_fired = len(_all_aroma), len(_fired)
597+
598+
_mol = Chem.MolFromSmiles(smi)
599+
mouth_cells = [(d["sensation"], d["score"])
600+
for d in (P.predict_mouthfeel(_mol).get("descriptors", []) if _mol else [])
601+
if d["score"] >= 0.5]
602+
tox_cells = [(a["assay"], a["probability"])
603+
for a in ((out.get("safety") or {}).get("tox_screen") or {}).get("assays", [])
604+
if (a.get("probability") or 0) >= 0.5]
589605

590606
pill_items = ([(fl, cream) for fl in tags.get("flavors", [])[:3]]
591607
+ [(t, _TASTE_RGB.get(t, teal)) for t in tags.get("tastes", [])]
@@ -611,6 +627,15 @@ def font(path, size):
611627
aroma_grid_y = aroma_label_y + 22
612628
aroma_rows = (len(aroma_cells) + COLS - 1) // COLS
613629
content_bottom = aroma_grid_y + aroma_rows * ROW_H
630+
# mouthfeel + safety only take space when something actually fires
631+
mouth_label_y = content_bottom + 12 if mouth_cells else None
632+
mouth_row_y = (mouth_label_y + 22) if mouth_cells else None
633+
if mouth_cells:
634+
content_bottom = mouth_row_y + ((len(mouth_cells) + COLS - 1) // COLS) * ROW_H
635+
tox_label_y = content_bottom + 12 if tox_cells else None
636+
tox_row_y = (tox_label_y + 22) if tox_cells else None
637+
if tox_cells:
638+
content_bottom = tox_row_y + ((len(tox_cells) + COLS - 1) // COLS) * ROW_H
614639
H = max(560, content_bottom + 56)
615640

616641
img = Image.new("RGB", (W, H), (15, 19, 25))
@@ -678,11 +703,29 @@ def cell(col, cy, label, v, color):
678703
for i, (t, v) in enumerate(taste_cells):
679704
cell(i, taste_row_y, t, v, _TASTE_RGB.get(t, teal))
680705

681-
# AROMA MODEL — all 24 heads in a 6-wide grid
682-
dr.text((GX0, aroma_label_y), f"AROMA MODEL · all {len(aroma_cells)} heads", font=f_lab, fill=muted)
706+
# AROMA MODEL — only the heads that fire, capped; label states the subset honestly
707+
_shown = len(aroma_cells)
708+
_alab = (f"AROMA MODEL · {_shown} of {aroma_total} heads firing"
709+
+ (f" (top {_shown} shown)" if aroma_fired > _shown else "")
710+
if aroma_fired else f"AROMA MODEL · none of {aroma_total} heads firing · strongest {_shown}")
711+
dr.text((GX0, aroma_label_y), _alab, font=f_lab, fill=muted)
683712
for i, (a, v) in enumerate(aroma_cells):
684713
cell(i % COLS, aroma_grid_y + (i // COLS) * ROW_H, a, v, teal)
685714

715+
# MOUTHFEEL — trigeminal sensations, only what fires
716+
if mouth_cells:
717+
dr.text((GX0, mouth_label_y), f"MOUTHFEEL · {len(mouth_cells)} of 5 sensations firing",
718+
font=f_lab, fill=muted)
719+
for i, (m, v) in enumerate(mouth_cells):
720+
cell(i % COLS, mouth_row_y + (i // COLS) * ROW_H, m, v, cream)
721+
722+
# SAFETY — Tox21 assays, only when flagged; caution-only, never a determination
723+
if tox_cells:
724+
dr.text((GX0, tox_label_y), f"SAFETY · {len(tox_cells)} Tox21 assay(s) flagged — caution-only, "
725+
"indicative in-vitro activity, NOT a toxicity determination", font=f_lab, fill=muted)
726+
for i, (a, v) in enumerate(tox_cells):
727+
cell(i % COLS, tox_row_y + (i // COLS) * ROW_H, a, v, (192, 85, 58))
728+
686729
# footer
687730
fy = H - 54
688731
dr.line([40, fy, W - 40, fy], fill=(42, 50, 60), width=1)
@@ -1907,6 +1950,40 @@ def _svg_cell(smi):
19071950
return _svg(smi, 104, 62)
19081951

19091952

1953+
@app.get("/api/map_members")
1954+
@lru_cache(maxsize=512)
1955+
def api_map_members(term: str = "", threshold: float = 0.5):
1956+
"""Every molecule whose head `term` fires >= threshold — not just the ones where it happens to
1957+
be the DOMINANT label.
1958+
1959+
The map colours each molecule by a single winning label, so a head can read "0" in the legend
1960+
while still being trained on plenty of molecules — they're simply displayed under a rarer
1961+
co-occurring label. Highlighting from the legend used to match that winner-take-all label, so
1962+
those heads lit up nothing and looked broken. This returns true membership from the profile
1963+
index (which carries every head score for every molecule), so no head can ever look empty.
1964+
1965+
`term` may be bare ("clarysage") or dimension-qualified ("aroma:clarysage", "mouthfeel:cooling")
1966+
— bare names resolve to aroma first, matching how the map legend labels them.
1967+
"""
1968+
t = (term or "").strip().lower()
1969+
if not t:
1970+
return {"term": term, "smiles": [], "n": 0}
1971+
P._ensure_sub_index()
1972+
smis, profiles, dims = P._SUB_INDEX[1], P._SUB_INDEX[4], P._SUB_INDEX[5]
1973+
if profiles is None or not smis:
1974+
return {"term": term, "smiles": [], "n": 0, "note": "profile index not built"}
1975+
dims = [str(d) for d in dims]
1976+
col = None
1977+
for cand in ([t] if ":" in t else [f"aroma:{t}", f"taste:{t}", f"mouthfeel:{t}"]):
1978+
if cand in dims:
1979+
col = dims.index(cand)
1980+
break
1981+
if col is None:
1982+
return {"term": term, "smiles": [], "n": 0, "note": "no such head"}
1983+
hits = [smis[i] for i in range(len(smis)) if float(profiles[i][col]) >= threshold]
1984+
return {"term": term, "dim": dims[col], "threshold": threshold, "n": len(hits), "smiles": hits}
1985+
1986+
19101987
@lru_cache(maxsize=8192)
19111988
def _tox_flags(smiles):
19121989
"""Tox21 assays this molecule is predicted active in (>=0.5), as a tuple. Caution-only: assay

training/workbench.html

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,9 @@
219219
.map-legend span{display:inline-flex;align-items:center;gap:5px;cursor:pointer;padding:2px 8px;border-radius:20px;border:1px solid transparent;transition:all .12s}
220220
.map-legend span:hover{border-color:var(--line)}
221221
.map-legend span.on{border-color:var(--brand-2);background:rgba(43,196,196,.10);color:var(--ink)}
222-
.map-legend span.leg-zero{opacity:.42;cursor:default}
222+
/* a (0) head is dimmed but still CLICKABLE — it has molecules, it just never wins the colour */
223+
.map-legend span.leg-zero{opacity:.55;cursor:pointer}
224+
.map-legend span.leg-zero:hover{opacity:.9}
223225
.map-legend span.leg-zero:hover{border-color:transparent}
224226
.map-legend i{width:10px;height:10px;border-radius:50%;display:inline-block}
225227
.map-controls{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:10px 0 8px}
@@ -2086,6 +2088,15 @@ <h4>Software &amp; type</h4>
20862088
mapLegendRender(); drawMap();
20872089
}
20882090
let _mapHi = new Set(); // classes highlighted from the legend (empty = show all evenly)
2091+
const _mapMembers = new Map(); // class -> Set(smiles) of TRUE members (head >= 0.5), from /api/map_members
2092+
// A point is spotlit if its dominant label is picked OR the head actually fires on it. Without the
2093+
// second test, heads that never win the winner-take-all colour highlight nothing and look broken.
2094+
function mapHit(p){
2095+
if(!_mapHi.size) return true;
2096+
if(_mapHi.has(mapLabel(p))) return true;
2097+
for(const l of _mapHi){ const m=_mapMembers.get(l); if(m && m.has(p.smiles)) return true; }
2098+
return false;
2099+
}
20892100
function mapLegendRender(){
20902101
const counts={}; _mapPts.forEach(p=>counts[mapLabel(p)]=(counts[mapLabel(p)]||0)+1);
20912102
// COMPLETE key: EVERY canonical class for the mode (all 6 taste heads / all 164 aroma heads /
@@ -2097,10 +2108,24 @@ <h4>Software &amp; type</h4>
20972108
const labs=present.concat(empty); if(counts.other) labs.push('other');
20982109
$('mapLegend').innerHTML=labs.map(l=>{
20992110
const n=counts[l]||0, zero=n===0;
2100-
return `<span class="leg${_mapHi.has(l)?' on':''}${zero?' leg-zero':''}" data-l="${l.replace(/"/g,'&quot;')}"><i style="background:${legendColor(l)}"></i>${l} (${n})</span>`;
2111+
// A "(0)" head isn't empty — it just never WINS the winner-take-all colour. Clicking it
2112+
// fetches true membership (every molecule the head predicts >=0.5), so no head looks broken.
2113+
const t = zero ? `${l}: no molecule shows this as its dominant note — click to highlight every molecule the head predicts`
2114+
: `${n} molecule${n===1?'':'s'} show ${l} as their dominant note — click to spotlight`;
2115+
return `<span class="leg${_mapHi.has(l)?' on':''}${zero?' leg-zero':''}" data-l="${l.replace(/"/g,'&quot;')}" title="${t.replace(/"/g,'&quot;')}"><i style="background:${legendColor(l)}"></i>${l} (${n})</span>`;
21012116
}).join('');
2102-
$('mapLegend').querySelectorAll('.leg:not(.leg-zero)').forEach(s=> s.addEventListener('click', ()=>{
2103-
const l=s.dataset.l; if(_mapHi.has(l)) _mapHi.delete(l); else _mapHi.add(l); mapLegendRender(); drawMap();
2117+
$('mapLegend').querySelectorAll('.leg').forEach(s=> s.addEventListener('click', async ()=>{
2118+
const l=s.dataset.l;
2119+
if(_mapHi.has(l)){ _mapHi.delete(l); _mapMembers.delete(l); }
2120+
else {
2121+
_mapHi.add(l);
2122+
// pull true membership so a zero-count (or merely under-represented) head still lights up
2123+
if(l!=='other' && !_mapMembers.has(l)){
2124+
try{ const d=await getJSON('/api/map_members?term='+encodeURIComponent(l));
2125+
_mapMembers.set(l, new Set(d.smiles||[])); }catch(_){ _mapMembers.set(l, new Set()); }
2126+
}
2127+
}
2128+
mapLegendRender(); drawMap();
21042129
}));
21052130
}
21062131

@@ -2132,7 +2157,7 @@ <h4>Software &amp; type</h4>
21322157
ctx.save(); ctx.globalCompositeOperation='lighter';
21332158
for(const p of _mapPts){
21342159
const l=mapLabel(p); if(!l||l==='other') continue;
2135-
if(hl && !_mapHi.has(l)) continue;
2160+
if(hl && !mapHit(p)) continue;
21362161
const s=screenOf(p); if(!s||s.x<-9000||!isFinite(s.x)||!isFinite(s.y)) continue;
21372162
ctx.fillStyle=mapColor(p); ctx.globalAlpha=A;
21382163
ctx.beginPath(); ctx.arc(s.x,s.y,R,0,7); ctx.fill();
@@ -2172,10 +2197,10 @@ <h4>Software &amp; type</h4>
21722197
drawDensity(ctx, p=>({x:px(p),y:py(p)}));
21732198
const hl=_mapHi.size;
21742199
ctx.globalCompositeOperation='lighter'; // glow only on shown/highlighted points (subtle by default)
2175-
for(const p of _mapPts){ if(hl && !_mapHi.has(mapLabel(p))) continue; const x=px(p),y=py(p); ctx.fillStyle=mapColor(p);
2200+
for(const p of _mapPts){ if(hl && !mapHit(p)) continue; const x=px(p),y=py(p); ctx.fillStyle=mapColor(p);
21762201
ctx.beginPath(); ctx.arc(x,y,hl?5.2:3.2,0,7); ctx.globalAlpha=hl?.16:.045; ctx.fill(); }
21772202
ctx.globalCompositeOperation='source-over';
2178-
for(const p of _mapPts){ const on=!hl||_mapHi.has(mapLabel(p)),x=px(p),y=py(p);
2203+
for(const p of _mapPts){ const on=!hl||mapHit(p),x=px(p),y=py(p);
21792204
ctx.fillStyle=on?mapColor(p):'#39424C'; ctx.beginPath(); ctx.arc(x,y,on?2.4:1.4,0,7); ctx.globalAlpha=on?(hl?.96:.82):.16; ctx.fill(); }
21802205
ctx.globalAlpha=1;
21812206
ctx.globalAlpha=1; _mapProj={px,py};
@@ -2221,10 +2246,10 @@ <h4>Software &amp; type</h4>
22212246
// points
22222247
const hl=_mapHi.size;
22232248
ctx.globalCompositeOperation='lighter';
2224-
for(const p of _mapPts){ if(p.mw==null||p.logp==null||(hl&&!_mapHi.has(mapLabel(p)))) continue; const x=bx(p.mw),y=by(p.logp); ctx.fillStyle=mapColor(p);
2249+
for(const p of _mapPts){ if(p.mw==null||p.logp==null||(hl&&!mapHit(p))) continue; const x=bx(p.mw),y=by(p.logp); ctx.fillStyle=mapColor(p);
22252250
ctx.beginPath(); ctx.arc(x,y,hl?5:3,0,7); ctx.globalAlpha=hl?.15:.04; ctx.fill(); }
22262251
ctx.globalCompositeOperation='source-over';
2227-
for(const p of _mapPts){ if(p.mw==null||p.logp==null) continue; const on=!hl||_mapHi.has(mapLabel(p)),x=bx(p.mw),y=by(p.logp);
2252+
for(const p of _mapPts){ if(p.mw==null||p.logp==null) continue; const on=!hl||mapHit(p),x=bx(p.mw),y=by(p.logp);
22282253
ctx.fillStyle=on?mapColor(p):'#39424C'; ctx.beginPath(); ctx.arc(x,y,on?2.4:1.4,0,7); ctx.globalAlpha=on?(hl?.95:.8):.15; ctx.fill(); }
22292254
ctx.globalAlpha=1;
22302255
ctx.globalAlpha=1;
@@ -2283,10 +2308,10 @@ <h4>Software &amp; type</h4>
22832308
drawDensity(ctx, p=>{ const s=proj(p); return {x:s.sx,y:s.sy}; });
22842309
const hl=_mapHi.size;
22852310
ctx.globalCompositeOperation='lighter'; // glow with depth; subtle by default, strong when highlighting
2286-
for(const {p,pr} of all){ if(hl && !_mapHi.has(mapLabel(p))) continue; const t=pr.d+0.5, rr=1.7+2.1*t, a=Math.max(.22,Math.min(1,.35+.6*t)); ctx.fillStyle=mapColor(p);
2311+
for(const {p,pr} of all){ if(hl && !mapHit(p)) continue; const t=pr.d+0.5, rr=1.7+2.1*t, a=Math.max(.22,Math.min(1,.35+.6*t)); ctx.fillStyle=mapColor(p);
22872312
ctx.beginPath(); ctx.arc(pr.sx,pr.sy,rr*(hl?2.1:1.5),0,7); ctx.globalAlpha=a*(hl?.2:.07); ctx.fill(); }
22882313
ctx.globalCompositeOperation='source-over';
2289-
for(const {p,pr} of all){ const on=!hl||_mapHi.has(mapLabel(p)); const t=pr.d+0.5, rr=1.7+2.1*t, a=Math.max(.22,Math.min(1,.35+.6*t)); ctx.fillStyle=on?mapColor(p):'#39424C';
2314+
for(const {p,pr} of all){ const on=!hl||mapHit(p); const t=pr.d+0.5, rr=1.7+2.1*t, a=Math.max(.22,Math.min(1,.35+.6*t)); ctx.fillStyle=on?mapColor(p):'#39424C';
22902315
ctx.beginPath(); ctx.arc(pr.sx,pr.sy,on?rr:rr*.7,0,7); ctx.globalAlpha=on?a:a*.28; ctx.fill(); }
22912316
ctx.globalAlpha=1; _mapProj={px:p=>proj(p).sx, py:p=>proj(p).sy};
22922317
}

0 commit comments

Comments
 (0)