Skip to content

Commit 43bf224

Browse files
feat: no head looks empty on the map; cap the shareable card; pin safety across stereoisomers (#249)
#238 — five assertions pin that food-use status, structural alerts, per-assay tox probabilities and the assembled safety block are identical across enantiomer pairs (carvone/limonene/menthol), since all three key on connectivity rather than stereochemistry. Verified the test fails if the lookup is made stereo-aware. Sensory reads may legitimately differ between enantiomers; safety must not. #233 — GET /api/map_members returns true membership (head >= 0.5) from the profile index, so heads that never win the map's winner-take-all colour no longer highlight nothing. clarysage 25 members, violetleaf 21, blackpepper 14. Zero-count legend entries are clickable; one mapHit() predicate now serves the 2D, properties and 3D views. #236 — the shareable PNG rendered all 164 aroma cells and omitted mouthfeel/safety. Now the 6 tastes always render and everything else shows only what fires (aroma capped at 18, Tox21 only when flagged, caution-only wording), with labels stating the subset honestly. Layout adapts: vanillin 692px, menthol 766px, capsaicin 840px. Closes #238. Closes #233. Closes #236.
1 parent 5318831 commit 43bf224

3 files changed

Lines changed: 193 additions & 15 deletions

File tree

tests/test_stereo_safety.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Safety calls must be IDENTICAL across stereoisomers.
2+
3+
Food-use status, the structural-alert screen and the Tox21 heads all key on the molecule's
4+
CONNECTIVITY, not its stereochemistry — food-use listings key on the InChIKey skeleton (first
5+
block), and the Morgan fingerprint the tox heads read is generated without chirality. So a food-use
6+
listing or a tox flag on carvone necessarily covers BOTH (R)- and (S)-carvone.
7+
8+
That is the correct and intended behaviour — a regulator lists the substance, not one enantiomer —
9+
but it rests on implementation details that nothing else pins down. Chirality-aware reads (#218)
10+
would touch exactly this machinery, so these tests exist to make any future change that silently
11+
splits safety by stereochemistry fail loudly instead of quietly under-reporting a hazard.
12+
13+
Note the deliberate asymmetry: *sensory* reads MAY legitimately differ between enantiomers
14+
((R)-carvone is spearmint, (S)-carvone is caraway) — that difference is documented, not predicted.
15+
Safety must not.
16+
"""
17+
import predict
18+
from rdkit import Chem
19+
20+
# (name, R-enantiomer SMILES, S-enantiomer SMILES) — classic flavor enantiomer pairs
21+
PAIRS = [
22+
("carvone", "CC(=C)[C@@H]1CC=C(C)C(=O)C1", "CC(=C)[C@H]1CC=C(C)C(=O)C1"),
23+
("limonene", "CC(=C)[C@@H]1CCC(C)=CC1", "CC(=C)[C@H]1CCC(C)=CC1"),
24+
("menthol", "C[C@@H]1CC[C@H](C(C)C)[C@@H](O)C1", "C[C@H]1CC[C@@H](C(C)C)[C@H](O)C1"),
25+
]
26+
27+
28+
def _both(pair):
29+
_, a, b = pair
30+
return Chem.MolFromSmiles(a), Chem.MolFromSmiles(b)
31+
32+
33+
def test_enantiomers_share_an_inchikey_skeleton():
34+
"""The whole guarantee rests on this: stereoisomers differ only past the first InChIKey block."""
35+
for pair in PAIRS:
36+
ma, mb = _both(pair)
37+
assert ma is not None and mb is not None, f"{pair[0]}: unparseable test SMILES"
38+
# genuinely different molecules...
39+
assert Chem.MolToSmiles(ma) != Chem.MolToSmiles(mb), f"{pair[0]}: SMILES are not distinct"
40+
# ...that nonetheless share a connectivity skeleton
41+
ska = Chem.MolToInchiKey(ma).split("-")[0]
42+
skb = Chem.MolToInchiKey(mb).split("-")[0]
43+
assert ska == skb, f"{pair[0]}: skeletons differ ({ska} vs {skb})"
44+
45+
46+
def test_food_use_status_is_identical_across_stereoisomers():
47+
for pair in PAIRS:
48+
ma, mb = _both(pair)
49+
assert predict._gras_status(ma) == predict._gras_status(mb), (
50+
f"{pair[0]}: food-use status differs between enantiomers — a listing must cover both")
51+
52+
53+
def test_structural_alerts_are_identical_across_stereoisomers():
54+
for pair in PAIRS:
55+
ma, mb = _both(pair)
56+
assert predict._tox_alerts(ma) == predict._tox_alerts(mb), (
57+
f"{pair[0]}: structural alerts differ between enantiomers")
58+
59+
60+
def test_tox_screen_is_identical_across_stereoisomers():
61+
"""Skipped when the Tox21 heads aren't present (CI has no model artifacts)."""
62+
if not predict._TOX_MODELS:
63+
return
64+
for pair in PAIRS:
65+
ma, mb = _both(pair)
66+
pa = {a["assay"]: a["probability"] for a in predict.predict_tox(ma)["assays"]}
67+
pb = {a["assay"]: a["probability"] for a in predict.predict_tox(mb)["assays"]}
68+
assert pa == pb, f"{pair[0]}: tox-assay probabilities differ between enantiomers"
69+
70+
71+
def test_safety_block_is_identical_across_stereoisomers():
72+
"""The assembled safety payload, not just its parts — catches a future field that splits."""
73+
for pair in PAIRS:
74+
ma, mb = _both(pair)
75+
assert predict._safety(ma) == predict._safety(mb), (
76+
f"{pair[0]}: assembled safety block differs between enantiomers")

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)