Skip to content

Commit af4df98

Browse files
test(safety): pin that safety calls are identical across stereoisomers
Food-use status, the structural-alert screen and the Tox21 heads all key on CONNECTIVITY, not stereochemistry — food-use listings key on the InChIKey skeleton (first block), and the Morgan fingerprint the tox heads read is generated without chirality. So a listing or a tox flag on carvone necessarily covers both (R)- and (S)-carvone. That is correct — a regulator lists the substance, not one enantiomer — but nothing pinned it, and chirality-aware reads (#218) would touch exactly this machinery. Without a test, a future change could silently split safety by stereochemistry and under-report a hazard. Five assertions over three classic enantiomer pairs (carvone, limonene, menthol): the pairs are genuinely distinct molecules that share a skeleton, and their food-use status, structural alerts, per-assay tox probabilities and assembled safety block all match. Verified the test actually bites — stubbing the lookup to key on the full stereo-aware InChIKey makes it fail. The tox assertion no-ops when the heads are absent, so it stays green in CI (no model artifacts) and exercises fully on the box. Deliberate asymmetry, documented in the module docstring: SENSORY reads may legitimately differ between enantiomers ((R)-carvone is spearmint, (S)- is caraway) — that difference is documented, not predicted. Safety must not differ. Closes #238. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 5318831 commit af4df98

1 file changed

Lines changed: 76 additions & 0 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")

0 commit comments

Comments
 (0)