From 00ddb7c9917f813a91d56da0121581224d4a2c58 Mon Sep 17 00:00:00 2001 From: "Austin L." <86896075+rvnminers-A-and-N@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:18:46 +0000 Subject: [PATCH] test: add regression suite for the predict.py rule/computed packs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .github/workflows/ci.yml | 2 ++ requirements.txt | 9 ++++++ tests/conftest.py | 5 ++++ tests/test_physchem.py | 32 +++++++++++++++++++++ tests/test_predict.py | 24 ++++++++++++++++ tests/test_rules.py | 62 ++++++++++++++++++++++++++++++++++++++++ tests/test_substitute.py | 39 +++++++++++++++++++++++++ 7 files changed, 173 insertions(+) create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_physchem.py create mode 100644 tests/test_predict.py create mode 100644 tests/test_rules.py create mode 100644 tests/test_substitute.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ea4d48..c03dc28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7a64e1d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..359948b --- /dev/null +++ b/tests/conftest.py @@ -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")) diff --git a/tests/test_physchem.py b/tests/test_physchem.py new file mode 100644 index 0000000..2b2a0de --- /dev/null +++ b/tests/test_physchem.py @@ -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"] == [] diff --git a/tests/test_predict.py b/tests/test_predict.py new file mode 100644 index 0000000..02c6360 --- /dev/null +++ b/tests/test_predict.py @@ -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"] diff --git a/tests/test_rules.py b/tests/test_rules.py new file mode 100644 index 0000000..292c61a --- /dev/null +++ b/tests/test_rules.py @@ -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) diff --git a/tests/test_substitute.py b/tests/test_substitute.py new file mode 100644 index 0000000..94a476a --- /dev/null +++ b/tests/test_substitute.py @@ -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)