Skip to content

Commit c623544

Browse files
test: API-contract integration layer, and fold in the measured properties (#198, #209)
INTEGRATION TESTS (#198). The pyramid had unit tests that never touch HTTP and Playwright e2e tests that assert on rendered pixels. Neither would notice a payload silently dropping a field, renaming a key, or starting to return 200 with an error body — and the workbench, the MCP server and the Claude skill all consume these payloads BY KEY, so a renamed field is a breaking change nothing in the suite could see. tests/test_api_contract.py closes that gap with FastAPI's TestClient: 11 tests over liveness, the head catalog, prediction, the search surfaces and degradation, with no browser, no port and no fifty-second model load. Suite goes 43 -> 54. It immediately earned its place by finding a real bug. FLAVORMANCER_NO_MODELS=1 skipped loading but never set MODELS_READY, so the warming-gate middleware returned 503 to every request FOREVER. A models-less install was not "degraded", it was dead — the exact opposite of what DATA-PIPELINE.md promises, and something no existing test could have caught because none of them speak HTTP. It now marks ready-with-no-models, so the structure-derived answers that need no heads at all (physchem, the sour/salty rules, applicability) are served. CI installs fastapi/httpx explicitly. Without them the file skips itself rather than failing, which would mean CI silently stopping checking every endpoint contract. MEASURED PROPERTIES (#209). The PUG-View crawl finished: 2,282 molecules gained a measured boiling or melting point that PubChem's property table never carried. Folding them in exposed a second NaN trap, and this one had silently discarded the ENTIRE crawl. The merge guarded on `tgt.get(k) is None`, but a missing value read from parquet is NaN, not None — so the guard never fired for any row that already existed in properties.parquet with an empty cell, which is all of them. First rebuild after the crawl moved coverage by exactly zero. Guarding with pd.isna() instead: boiling point 27% -> 34% (2,478 -> 3,075 molecules) melting point 27% -> 31% (2,437 -> 2,792) Verified end to end rather than assumed: of the crawl's 1,354 boiling points, exactly 6 failed to reach the table. The rest of the apparent shortfall is real and explainable — the crawler targeted molecules missing EITHER property, so many returned a boiling point that was already present. This is the same NaN-is-not-None family as the bug that once left ~500 molecules unnamed. Worth naming as a pattern: any merge against a pandas-derived dict needs pd.isna(), never `is None`. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent 1eacf5a commit c623544

4 files changed

Lines changed: 174 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ jobs:
2323
run: ruff check .
2424
- name: Install runtime deps
2525
run: pip install -r requirements.txt
26-
- name: Pytest (unit)
26+
# fastapi + httpx back the TestClient integration tests (tests/test_api_contract.py).
27+
# Without them that file skips itself rather than failing, but then CI silently stops
28+
# checking every endpoint contract — so install them explicitly.
29+
- name: Install test-only deps
30+
run: pip install fastapi httpx pydantic
31+
- name: Pytest (unit + integration)
2732
run: |
2833
if git ls-files '*test_*.py' '*_test.py' 'tests/**/*.py' | grep -qv '^tests/e2e/'; then
2934
pytest -q --ignore=tests/e2e

tests/test_api_contract.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Integration tests: every endpoint's CONTRACT, through FastAPI's TestClient.
2+
3+
The suite already had unit tests (rules, physchem, stereo-safety) and Playwright e2e tests that
4+
drive a real browser against a real server. This is the missing middle: it exercises the actual
5+
HTTP layer — routing, request parsing, response shape, status codes — without a browser, without a
6+
port, and without waiting fifty seconds for models to load.
7+
8+
That gap mattered. A response that silently drops a field, renames a key, or starts returning 200
9+
with an error body would sail past the unit tests (which never touch HTTP) and might well pass the
10+
e2e tests too (which assert on rendered pixels, not payloads). The workbench, the MCP server and
11+
the Claude skill all consume these payloads by key, so a renamed field is a breaking change that
12+
nothing else in the pyramid would catch.
13+
14+
Runs in CI with FLAVORMANCER_NO_MODELS=1: endpoints that need trained heads answer honestly with
15+
`available: false` rather than 500, and that degradation is itself worth asserting — it is what
16+
keeps a partially-built install usable instead of broken.
17+
"""
18+
import os
19+
20+
import pytest
21+
22+
# Import the app the same way uvicorn does. NO_MODELS keeps this fast and CI-friendly; the tests
23+
# below assert the *contract*, which must hold whether or not the heads are present.
24+
os.environ.setdefault("FLAVORMANCER_NO_MODELS", "1")
25+
26+
# importorskip is not enough here: `fastapi.testclient` imports cleanly and then starlette raises
27+
# a RuntimeError if httpx is missing, which pytest reports as a collection ERROR rather than a
28+
# skip. Catch both so an environment without the test extras skips this file instead of failing.
29+
try:
30+
from fastapi.testclient import TestClient
31+
except (ImportError, RuntimeError) as exc: # pragma: no cover — environment-dependent
32+
pytest.skip(f"FastAPI TestClient unavailable ({exc})", allow_module_level=True)
33+
34+
import app as flavor_app
35+
36+
VANILLIN = "COc1cc(C=O)ccc1O"
37+
38+
39+
@pytest.fixture(scope="module")
40+
def client():
41+
with TestClient(flavor_app.app) as c:
42+
yield c
43+
44+
45+
# --- liveness -------------------------------------------------------------------------------
46+
47+
def test_healthz_is_always_answerable(client):
48+
"""/healthz must answer even while models are loading — it is what the container's health
49+
check polls, and a 503 during a legitimate 50s warm-up would kill the container."""
50+
r = client.get("/healthz")
51+
assert r.status_code == 200, r.text
52+
53+
54+
def test_status_reports_load_progress(client):
55+
r = client.get("/api/status")
56+
assert r.status_code == 200
57+
body = r.json()
58+
for key in ("loaded", "total", "phase", "ready"):
59+
assert key in body, f"/api/status lost `{key}` — the warming page polls it"
60+
assert isinstance(body["ready"], bool)
61+
62+
63+
# --- head catalog ---------------------------------------------------------------------------
64+
65+
def test_heads_catalog_shape(client):
66+
"""The catalog is consumed by the modal, the studios, the MCP server and the skill. Its four
67+
categories and their per-head fields are a contract, not an implementation detail."""
68+
r = client.get("/api/heads")
69+
assert r.status_code == 200
70+
body = r.json()
71+
for group in ("taste", "aroma", "mouthfeel", "safety"):
72+
assert group in body, f"/api/heads lost the `{group}` group"
73+
assert isinstance(body[group], list)
74+
75+
76+
def test_every_head_publishes_its_calibration(client):
77+
"""Since #261 a head must say where its bar sits and how precise it is there. Dropping these
78+
would silently return the project to reporting AUROC alone — the exact failure ACCURACY.md
79+
exists to prevent."""
80+
body = client.get("/api/heads").json()
81+
for group in ("taste", "aroma", "mouthfeel", "safety"):
82+
for head in body[group]:
83+
assert "head" in head
84+
assert "confident_capable" in head, (
85+
f"{group}/{head.get('head')} has no confident_capable flag")
86+
thr = head.get("threshold")
87+
if thr is not None:
88+
assert 0.0 < float(thr) <= 1.0, f"{head['head']} threshold {thr} out of range"
89+
90+
91+
# --- prediction -----------------------------------------------------------------------------
92+
93+
def test_predict_returns_the_documented_blocks(client):
94+
r = client.post("/api/predict", json={"smiles": VANILLIN})
95+
assert r.status_code == 200, r.text
96+
body = r.json()
97+
assert body.get("smiles")
98+
# physchem and the applicability gate are computed from structure alone, so they are present
99+
# even with no trained heads at all — that is what makes a models-less install still useful.
100+
assert "physchem" in body
101+
assert "applicability" in body
102+
103+
104+
def test_predict_rejects_an_unparseable_smiles(client):
105+
"""Garbage in must produce a clear error, not a 500 and not a confident-looking empty read."""
106+
r = client.post("/api/predict", json={"smiles": "not-a-molecule"})
107+
assert r.status_code < 500, "an unparseable SMILES must not crash the service"
108+
body = r.json()
109+
assert "error" in body or body.get("applicability", {}).get("in_domain") is False, (
110+
"an unparseable SMILES should surface an error, not a silent empty result")
111+
112+
113+
def test_predict_requires_smiles(client):
114+
r = client.post("/api/predict", json={})
115+
assert r.status_code < 500
116+
117+
118+
# --- search surfaces ------------------------------------------------------------------------
119+
120+
def test_design_search_contract(client):
121+
"""The reverse search backs the studios. Its envelope (items / requested / total_matches)
122+
is what the UI paginates against."""
123+
r = client.get("/api/design", params={"descriptors": "vanilla", "limit": 3})
124+
assert r.status_code == 200
125+
body = r.json()
126+
for key in ("items", "requested", "total_matches", "offset", "limit"):
127+
assert key in body, f"/api/design lost `{key}`"
128+
assert isinstance(body["items"], list)
129+
assert len(body["items"]) <= 3, "limit must be honoured"
130+
131+
132+
def test_design_with_an_unknown_descriptor_is_empty_not_an_error(client):
133+
r = client.get("/api/design", params={"descriptors": "notarealnote"})
134+
assert r.status_code == 200
135+
assert r.json()["items"] == []
136+
137+
138+
def test_studio_terms_groups(client):
139+
r = client.get("/api/studio_terms")
140+
assert r.status_code == 200
141+
body = r.json()
142+
for group in ("flavors", "notes", "mouthfeel", "taste"):
143+
assert group in body, f"/api/studio_terms lost `{group}` — a chip family would vanish"
144+
145+
146+
# --- degradation ----------------------------------------------------------------------------
147+
148+
def test_model_backed_endpoints_degrade_honestly(client):
149+
"""With no heads loaded, endpoints that need them must say so rather than 500 or, worse,
150+
return an empty read that looks like a confident 'no aroma'."""
151+
body = client.post("/api/predict", json={"smiles": VANILLIN}).json()
152+
aroma = body.get("aroma")
153+
if isinstance(aroma, dict) and aroma.get("available") is False:
154+
assert aroma.get("note"), "an unavailable modality must explain why"

training/build_enrichment.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ def _taste_by_skel():
248248
for _skel, _row in _by_skel("iupac_backfill.parquet", ["common_name", "iupac_name"]).items():
249249
tgt = props.setdefault(_skel, {})
250250
for _k in ("common_name", "iupac_name"):
251+
# isinstance(..., str) is the right guard here — it treats NaN as absent correctly
251252
if not isinstance(tgt.get(_k), str) and isinstance(_row.get(_k), str):
252253
tgt[_k] = _row[_k]
253254
# MEASURED boiling/melting points from PUG-View (build_measured_properties.py). The property
@@ -257,7 +258,11 @@ def _taste_by_skel():
257258
["melting_point_c", "boiling_point_c"]).items():
258259
tgt = props.setdefault(_skel, {})
259260
for _k in ("melting_point_c", "boiling_point_c"):
260-
if tgt.get(_k) is None and _row.get(_k) is not None:
261+
# `is None` is WRONG here and silently discarded the entire crawl: a missing value
262+
# read from parquet is NaN, not None, so the guard never fired for any row that
263+
# already existed in properties.parquet with an empty cell — which is all of them.
264+
# Same NaN trap that once left ~500 molecules unnamed (see _pick_name).
265+
if pd.isna(tgt.get(_k)) and not pd.isna(_row.get(_k)):
261266
tgt[_k] = _row[_k]
262267
taste_doc = _taste_by_skel()
263268
curated = _curated_names() # human names for the molecules we hand-curated

training/predict.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,14 @@ def _load_all_models():
323323
# only need featurization (_feat / _MORGAN), like the parallel index builder, which loads each head
324324
# in its own worker process rather than in this parent.
325325
if os.environ.get("FLAVORMANCER_NO_MODELS") == "1":
326-
pass
326+
# Skip loading, but still mark READY. Leaving the event unset meant the app's warming gate
327+
# returned 503 to every request FOREVER — a models-less install was not "degraded", it was
328+
# dead, which is the opposite of what the docs promised. Structure-derived answers (physchem,
329+
# the sour/salty rules, applicability, substructure) need no heads at all and should be served.
330+
with _LOAD_LOCK:
331+
LOAD_PROGRESS["phase"] = "ready (no models)"
332+
LOAD_PROGRESS["ready"] = True
333+
MODELS_READY.set()
327334
elif os.environ.get("FLAVORMANCER_BLOCKING_LOAD") == "1":
328335
_load_all_models()
329336
else:

0 commit comments

Comments
 (0)