Skip to content

Commit 9c900e5

Browse files
test: add regression suite for the predict.py rule/computed packs (#49)
Covers the deterministic, model-independent logic so the 🟡 "built but unvalidated" packs in BUILD-STATUS become regression-guarded — and CI actually exercises them: - test_rules: sour/salty calls (incl. MSG defers to the organic anion), allergen labeling, the documented dangerous-mixture screen (nitrite + amine -> nitrosamine). - test_physchem: physchem MW/logP/solubility, stability motif flags (vanillin's phenol + aldehyde), chemesthesis (astringent polyphenol, pungent isothiocyanate). - test_predict: predict() stays well-formed and never crashes with NO trained models (the CI case); bad-SMILES handling; predict_aroma honest placeholder. - test_substitute: graceful with no reference set; correct Tanimoto ranking against a hand-built in-memory index (no data files needed). Infra: requirements.txt (core runtime deps) and CI installs it before pytest, so the suite runs in CI without trained models or datasets. 19 tests, all green. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent f3ff09f commit 9c900e5

7 files changed

Lines changed: 173 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ jobs:
2121
run: pip install ruff pytest
2222
- name: Ruff lint
2323
run: ruff check .
24+
- name: Install runtime deps
25+
run: pip install -r requirements.txt
2426
- name: Pytest
2527
run: |
2628
if git ls-files '*test_*.py' '*_test.py' 'tests/**/*.py' | grep -q .; then

requirements.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Core runtime for the prediction pipeline (training/predict.py) + the test suite.
2+
# Demo/export extras (skl2onnx, onnxruntime, pubchempy, umap-learn, fastapi,
3+
# openpyxl) are listed in training/SETUP.md and installed as needed.
4+
rdkit
5+
numpy
6+
pandas
7+
scikit-learn
8+
joblib
9+
pyarrow

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Make the training/ modules importable from the test suite."""
2+
import os
3+
import sys
4+
5+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "training"))

tests/test_physchem.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Physicochemical / stability / chemesthesis packs — exact-from-structure, model-independent."""
2+
from rdkit import Chem
3+
4+
import predict
5+
6+
7+
def test_physchem_benzene_mw():
8+
out = predict.physchem(Chem.MolFromSmiles("c1ccccc1"))
9+
assert abs(out["computed"]["mol_weight"] - 78.11) < 0.5
10+
assert "logP" in out["computed"]
11+
assert "water_solubility_logS" in out["estimate"]
12+
13+
14+
def test_stability_flags_vanillin_motifs():
15+
out = predict.stability(Chem.MolFromSmiles("O=Cc1ccc(O)c(OC)c1")) # vanillin
16+
assert "aldehyde" in out["oxidation_watch"]
17+
assert "phenol/catechol" in out["oxidation_watch"]
18+
19+
20+
def test_chemesthesis_astringent_polyphenol():
21+
out = predict.chemesthesis(Chem.MolFromSmiles("Oc1cccc(O)c1O")) # pyrogallol, 3 phenols
22+
assert any("astringent" in c for c in out["classes"])
23+
24+
25+
def test_chemesthesis_pungent_isothiocyanate():
26+
out = predict.chemesthesis(Chem.MolFromSmiles("C=CCN=C=S")) # allyl isothiocyanate
27+
assert any("pungent" in c for c in out["classes"])
28+
29+
30+
def test_chemesthesis_silent_on_plain_molecule():
31+
out = predict.chemesthesis(Chem.MolFromSmiles("CCO")) # ethanol
32+
assert out["classes"] == []

tests/test_predict.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""End-to-end predict() — must run and stay well-formed even with NO trained models
2+
(as in CI). The trained taste heads sharpen the output; their absence must never crash it.
3+
"""
4+
import predict
5+
6+
7+
def test_predict_wellformed_without_models():
8+
out = predict.predict("OC(=O)CC(O)(CC(=O)O)C(=O)O") # citric acid
9+
for key in ("smiles", "physchem", "stability", "chemesthesis",
10+
"analytical", "labeling", "safety", "taste_profile"):
11+
assert key in out
12+
assert out["safety"]["review_required"] is True
13+
assert out["sour"] is True # the rule fires regardless of trained heads
14+
15+
16+
def test_predict_rejects_bad_smiles():
17+
out = predict.predict("not-a-molecule")
18+
assert "error" in out
19+
20+
21+
def test_predict_aroma_is_honest_placeholder():
22+
out = predict.predict_aroma("CCO")
23+
assert out["available"] is False
24+
assert "AROMA.md" in out["note"]

tests/test_rules.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Deterministic rule / lookup logic — no trained models or data files required.
2+
3+
These cover the rule layer the .NET product (M2) will port: sour/salty calls,
4+
allergen labeling, and the documented dangerous-mixture screen.
5+
"""
6+
from rdkit import Chem
7+
8+
import predict
9+
10+
11+
def _mol(smiles):
12+
return Chem.MolFromSmiles(smiles)
13+
14+
15+
def test_sour_fires_on_acid():
16+
out = predict._sour(_mol("CC(=O)O")) # acetic acid
17+
assert out["sour"] is True
18+
assert out["sour_reason"]
19+
20+
21+
def test_sour_silent_on_nonacid():
22+
out = predict._sour(_mol("c1ccccc1")) # benzene
23+
assert out["sour"] is False
24+
assert out["sour_reason"] == []
25+
26+
27+
def test_salty_inorganic_salt():
28+
out = predict._salty(_mol("[Na+].[Cl-]")) # NaCl
29+
assert out["salty"] is True
30+
assert "inorganic" in out["salty_reason"]
31+
32+
33+
def test_salty_defers_to_organic_anion():
34+
# monosodium glutamate — cation present, but the organic anion owns the taste
35+
out = predict._salty(_mol("C(CC(=O)[O-])C(C(=O)O)N.[Na+]"))
36+
assert out["salty"] is False
37+
assert "organic anion" in out["salty_reason"]
38+
39+
40+
def test_salty_silent_on_single_fragment():
41+
out = predict._salty(_mol("OC1OC(CO)C(O)C(O)C1O")) # glucose
42+
assert out["salty"] is False
43+
44+
45+
def test_labeling_shape_and_negative():
46+
out = predict.labeling(_mol("O=Cc1ccc(O)c(OC)c1")) # vanillin — not declarable
47+
assert out["eu_declarable_allergen"] is False
48+
assert out["allergen_name"] is None
49+
50+
51+
def test_check_mixture_empty_is_wellformed():
52+
out = predict.check_mixture([])
53+
assert out["active_hazards"] == []
54+
assert out["conditional_hazards"] == []
55+
assert "disclaimer" in out
56+
57+
58+
def test_check_mixture_flags_nitrosamine():
59+
# sodium nitrite + a secondary amine -> documented nitrosamine hazard
60+
out = predict.check_mixture(["[Na+].[O-]N=O", "CNC"])
61+
hazards = out["active_hazards"] + out["conditional_hazards"]
62+
assert any("nitrosamine" in h["possible_product"].lower() for h in hazards)

tests/test_substitute.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Substitution search — graceful without a reference set, correct ranking with one.
2+
3+
The ranking logic is tested against a hand-built in-memory index so it needs no data
4+
files; the .NET product (M6) mirrors this as a pgvector ANN query.
5+
"""
6+
from rdkit import Chem
7+
8+
import predict
9+
10+
11+
def _canon(s):
12+
return Chem.MolToSmiles(Chem.MolFromSmiles(s))
13+
14+
15+
def test_substitute_rejects_bad_smiles():
16+
assert "error" in predict.substitute("nope")
17+
18+
19+
def test_substitute_graceful_without_data(monkeypatch):
20+
monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], []))
21+
out = predict.substitute("CCO")
22+
assert out["neighbors"] == []
23+
assert "note" in out
24+
25+
26+
def test_substitute_ranks_by_similarity(monkeypatch):
27+
mols = ["CCO", "CCCO", "c1ccccc1"] # ethanol, propanol, benzene
28+
fps = [predict._MORGAN.GetFingerprint(Chem.MolFromSmiles(s)) for s in mols]
29+
canon = [_canon(s) for s in mols]
30+
monkeypatch.setattr(predict, "_SUB_INDEX", (fps, canon, [[], [], []]))
31+
out = predict.substitute("CCO", k=2)
32+
neighbors = out["neighbors"]
33+
# the query itself is excluded
34+
assert all(n["smiles"] != _canon("CCO") for n in neighbors)
35+
# propanol (closer to ethanol) ranks above benzene
36+
assert neighbors[0]["smiles"] == _canon("CCCO")
37+
# similarities come back sorted descending
38+
sims = [n["similarity"] for n in neighbors]
39+
assert sims == sorted(sims, reverse=True)

0 commit comments

Comments
 (0)