Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
run: pip install ruff pytest
- name: Ruff lint
run: ruff check .
- name: Install runtime deps
run: pip install -r requirements.txt
- name: Pytest
run: |
if git ls-files '*test_*.py' '*_test.py' 'tests/**/*.py' | grep -q .; then
Expand Down
9 changes: 9 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Core runtime for the prediction pipeline (training/predict.py) + the test suite.
# Demo/export extras (skl2onnx, onnxruntime, pubchempy, umap-learn, fastapi,
# openpyxl) are listed in training/SETUP.md and installed as needed.
rdkit
numpy
pandas
scikit-learn
joblib
pyarrow
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Make the training/ modules importable from the test suite."""
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "training"))
32 changes: 32 additions & 0 deletions tests/test_physchem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Physicochemical / stability / chemesthesis packs β€” exact-from-structure, model-independent."""
from rdkit import Chem

import predict


def test_physchem_benzene_mw():
out = predict.physchem(Chem.MolFromSmiles("c1ccccc1"))
assert abs(out["computed"]["mol_weight"] - 78.11) < 0.5
assert "logP" in out["computed"]
assert "water_solubility_logS" in out["estimate"]


def test_stability_flags_vanillin_motifs():
out = predict.stability(Chem.MolFromSmiles("O=Cc1ccc(O)c(OC)c1")) # vanillin
assert "aldehyde" in out["oxidation_watch"]
assert "phenol/catechol" in out["oxidation_watch"]


def test_chemesthesis_astringent_polyphenol():
out = predict.chemesthesis(Chem.MolFromSmiles("Oc1cccc(O)c1O")) # pyrogallol, 3 phenols
assert any("astringent" in c for c in out["classes"])


def test_chemesthesis_pungent_isothiocyanate():
out = predict.chemesthesis(Chem.MolFromSmiles("C=CCN=C=S")) # allyl isothiocyanate
assert any("pungent" in c for c in out["classes"])


def test_chemesthesis_silent_on_plain_molecule():
out = predict.chemesthesis(Chem.MolFromSmiles("CCO")) # ethanol
assert out["classes"] == []
24 changes: 24 additions & 0 deletions tests/test_predict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""End-to-end predict() β€” must run and stay well-formed even with NO trained models
(as in CI). The trained taste heads sharpen the output; their absence must never crash it.
"""
import predict


def test_predict_wellformed_without_models():
out = predict.predict("OC(=O)CC(O)(CC(=O)O)C(=O)O") # citric acid
for key in ("smiles", "physchem", "stability", "chemesthesis",
"analytical", "labeling", "safety", "taste_profile"):
assert key in out
assert out["safety"]["review_required"] is True
assert out["sour"] is True # the rule fires regardless of trained heads


def test_predict_rejects_bad_smiles():
out = predict.predict("not-a-molecule")
assert "error" in out


def test_predict_aroma_is_honest_placeholder():
out = predict.predict_aroma("CCO")
assert out["available"] is False
assert "AROMA.md" in out["note"]
62 changes: 62 additions & 0 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Deterministic rule / lookup logic β€” no trained models or data files required.

These cover the rule layer the .NET product (M2) will port: sour/salty calls,
allergen labeling, and the documented dangerous-mixture screen.
"""
from rdkit import Chem

import predict


def _mol(smiles):
return Chem.MolFromSmiles(smiles)


def test_sour_fires_on_acid():
out = predict._sour(_mol("CC(=O)O")) # acetic acid
assert out["sour"] is True
assert out["sour_reason"]


def test_sour_silent_on_nonacid():
out = predict._sour(_mol("c1ccccc1")) # benzene
assert out["sour"] is False
assert out["sour_reason"] == []


def test_salty_inorganic_salt():
out = predict._salty(_mol("[Na+].[Cl-]")) # NaCl
assert out["salty"] is True
assert "inorganic" in out["salty_reason"]


def test_salty_defers_to_organic_anion():
# monosodium glutamate β€” cation present, but the organic anion owns the taste
out = predict._salty(_mol("C(CC(=O)[O-])C(C(=O)O)N.[Na+]"))
assert out["salty"] is False
assert "organic anion" in out["salty_reason"]


def test_salty_silent_on_single_fragment():
out = predict._salty(_mol("OC1OC(CO)C(O)C(O)C1O")) # glucose
assert out["salty"] is False


def test_labeling_shape_and_negative():
out = predict.labeling(_mol("O=Cc1ccc(O)c(OC)c1")) # vanillin β€” not declarable
assert out["eu_declarable_allergen"] is False
assert out["allergen_name"] is None


def test_check_mixture_empty_is_wellformed():
out = predict.check_mixture([])
assert out["active_hazards"] == []
assert out["conditional_hazards"] == []
assert "disclaimer" in out


def test_check_mixture_flags_nitrosamine():
# sodium nitrite + a secondary amine -> documented nitrosamine hazard
out = predict.check_mixture(["[Na+].[O-]N=O", "CNC"])
hazards = out["active_hazards"] + out["conditional_hazards"]
assert any("nitrosamine" in h["possible_product"].lower() for h in hazards)
39 changes: 39 additions & 0 deletions tests/test_substitute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Substitution search β€” graceful without a reference set, correct ranking with one.

The ranking logic is tested against a hand-built in-memory index so it needs no data
files; the .NET product (M6) mirrors this as a pgvector ANN query.
"""
from rdkit import Chem

import predict


def _canon(s):
return Chem.MolToSmiles(Chem.MolFromSmiles(s))


def test_substitute_rejects_bad_smiles():
assert "error" in predict.substitute("nope")


def test_substitute_graceful_without_data(monkeypatch):
monkeypatch.setattr(predict, "_SUB_INDEX", ([], [], []))
out = predict.substitute("CCO")
assert out["neighbors"] == []
assert "note" in out


def test_substitute_ranks_by_similarity(monkeypatch):
mols = ["CCO", "CCCO", "c1ccccc1"] # ethanol, propanol, benzene
fps = [predict._MORGAN.GetFingerprint(Chem.MolFromSmiles(s)) for s in mols]
canon = [_canon(s) for s in mols]
monkeypatch.setattr(predict, "_SUB_INDEX", (fps, canon, [[], [], []]))
out = predict.substitute("CCO", k=2)
neighbors = out["neighbors"]
# the query itself is excluded
assert all(n["smiles"] != _canon("CCO") for n in neighbors)
# propanol (closer to ethanol) ranks above benzene
assert neighbors[0]["smiles"] == _canon("CCCO")
# similarities come back sorted descending
sims = [n["similarity"] for n in neighbors]
assert sims == sorted(sims, reverse=True)
Loading