From 8edd5fb907e9b11ffd6a9908f83aafe14200949d Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sun, 7 Jun 2026 11:25:31 -0400
Subject: [PATCH 01/16] Add structure resolver and PubChem client retry
Introduce a smart structure resolver and harden PubChem client: add classify_query, fetch_structure and student_friendly_resolve to route SMILES/InChI locally (RDKit) and CID/InChIKey/name to PubChem. Add inchi_to_xyz and search_cid_by_inchikey helpers, URL-encode queries, and implement client-side throttling plus exponential back-off retries for 503 responses. Move PubChem network parameters into config constants and expose new symbols from the package; update the UI to call the new resolver. Add tests covering query classification, URL encoding, throttle/backoff behavior, routing, and provenance metadata.
---
quantui/__init__.py | 10 ++
quantui/app.py | 8 +-
quantui/config.py | 12 ++
quantui/pubchem.py | 311 +++++++++++++++++++++++++++++++++-
tests/test_struct_resolver.py | 239 ++++++++++++++++++++++++++
5 files changed, 571 insertions(+), 9 deletions(-)
create mode 100644 tests/test_struct_resolver.py
diff --git a/quantui/__init__.py b/quantui/__init__.py
index 3a28d33..38aaf95 100644
--- a/quantui/__init__.py
+++ b/quantui/__init__.py
@@ -127,13 +127,18 @@
MoleculeNotFoundError,
PubChemError,
check_pubchem_availability,
+ classify_query,
display_2d_structure,
fetch_molecule,
+ fetch_structure,
generate_2d_structure_svg,
get_common_molecules,
get_smiles_examples,
+ inchi_to_xyz,
+ search_cid_by_inchikey,
smiles_to_xyz,
student_friendly_fetch,
+ student_friendly_resolve,
student_friendly_smiles_to_xyz,
validate_smiles,
)
@@ -238,7 +243,12 @@
"optimize_geometry",
# PubChem (optional)
"fetch_molecule",
+ "fetch_structure",
+ "classify_query",
"student_friendly_fetch",
+ "student_friendly_resolve",
+ "inchi_to_xyz",
+ "search_cid_by_inchikey",
"get_common_molecules",
"check_pubchem_availability",
"PubChemError",
diff --git a/quantui/app.py b/quantui/app.py
index ed1db96..08995f9 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -473,13 +473,13 @@
RDKIT_AVAILABLE as _PUBCHEM_RDKIT_AVAILABLE,
)
from quantui.pubchem import (
- student_friendly_fetch as _student_friendly_fetch,
+ student_friendly_resolve as _student_friendly_resolve,
)
PUBCHEM_AVAILABLE = _PUBCHEM_RDKIT_AVAILABLE
except ImportError:
PUBCHEM_AVAILABLE = False
- _student_friendly_fetch = None # type: ignore[assignment]
+ _student_friendly_resolve = None # type: ignore[assignment]
try:
from quantui.session_calc import SessionResult, run_in_session # noqa: F401
@@ -2558,7 +2558,7 @@ def _on_search_pubchem(self, btn) -> None:
if not query:
self.pubchem_msg.value = "Enter a molecule name or SMILES."
return
- if _student_friendly_fetch is None:
+ if _student_friendly_resolve is None:
self.pubchem_msg.value = "PubChem module not available."
return
self.pubchem_msg.value = f'Searching for "{query}"...'
@@ -2568,7 +2568,7 @@ def _on_search_pubchem(self, btn) -> None:
def _do():
try:
- xyz_str, _msg = _student_friendly_fetch(query)
+ xyz_str, _msg = _student_friendly_resolve(query)
if xyz_str is None:
raise ValueError(_msg)
atoms, coords = parse_xyz_input(xyz_str)
diff --git a/quantui/config.py b/quantui/config.py
index 784cfa6..e0ca247 100644
--- a/quantui/config.py
+++ b/quantui/config.py
@@ -229,6 +229,18 @@
DESCRIPTION_WIDTH = "150px"
+# ── External structure resolvers (M-STRUCT) ──────────────────────────────────
+# Network + throttle settings shared by the PubChem client (and, later, the
+# NCI CACTUS resolver). All timeouts/limits live here per constraint #5.
+PUBCHEM_TIMEOUT_S: float = 15.0 # per-request HTTP timeout
+PUBCHEM_AVAILABILITY_TIMEOUT_S: float = 5.0 # quick reachability probe
+PUBCHEM_MAX_RETRIES: int = 3 # bounded retries on 503 / throttling
+PUBCHEM_BACKOFF_BASE_S: float = 0.5 # exponential back-off base (×2**attempt)
+PUBCHEM_BACKOFF_MAX_S: float = 8.0 # cap on a single back-off sleep
+# Proactive client-side rate limit. PUG-REST allows ~5 req/s; stay conservative
+# so a classroom of simultaneous users doesn't trip the server-side throttle.
+PUBCHEM_MIN_REQUEST_INTERVAL_S: float = 0.25
+
# Molecule presets — 20+ curated educational molecules
MOLECULE_LIBRARY: Dict[str, Dict[str, Any]] = {
# ========== SIMPLE DIATOMIC MOLECULES ==========
diff --git a/quantui/pubchem.py b/quantui/pubchem.py
index 38fd640..6e111d2 100644
--- a/quantui/pubchem.py
+++ b/quantui/pubchem.py
@@ -6,11 +6,17 @@
"""
import logging
+import re
+import threading
+import time
from functools import lru_cache
from typing import Any, Dict, Optional, Tuple
+from urllib.parse import quote
import requests
+from . import config
+
try:
from rdkit import Chem
from rdkit.Chem import AllChem, Descriptors
@@ -24,7 +30,63 @@
# PubChem API endpoints
PUBCHEM_BASE_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
-PUBCHEM_TIMEOUT = 10 # seconds
+# Back-compat alias; canonical value lives in config (constraint #5).
+PUBCHEM_TIMEOUT = config.PUBCHEM_TIMEOUT_S
+
+# ── HTTP client: client-side throttle + bounded 503 back-off (STRUCT.2) ──────
+# A single process-wide limiter keeps us under PUG-REST's ~5 req/s ceiling even
+# when several search threads fire at once.
+_request_lock = threading.Lock()
+_last_request_time = 0.0
+
+
+def _throttle() -> None:
+ """Block just long enough to honor the client-side minimum request gap."""
+ global _last_request_time
+ with _request_lock:
+ wait = config.PUBCHEM_MIN_REQUEST_INTERVAL_S - (
+ time.monotonic() - _last_request_time
+ )
+ if wait > 0:
+ time.sleep(wait)
+ _last_request_time = time.monotonic()
+
+
+def _http_get(
+ url: str,
+ *,
+ params: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+) -> requests.Response:
+ """GET with client-side throttle + exponential back-off on 503 throttling.
+
+ Retries only on HTTP 503 (PUG-REST's throttle signal). All other status
+ codes are returned to the caller unchanged; network exceptions
+ (``Timeout`` / ``ConnectionError`` / ...) propagate so callers can map them
+ to :class:`PubChemAPIError` exactly as before.
+ """
+ timeout = timeout if timeout is not None else config.PUBCHEM_TIMEOUT_S
+ response = None
+ for attempt in range(config.PUBCHEM_MAX_RETRIES):
+ _throttle()
+ response = requests.get(url, params=params, timeout=timeout)
+ if response.status_code != 503:
+ return response
+ # Throttled — back off (capped) and retry, unless this was the last try.
+ if attempt < config.PUBCHEM_MAX_RETRIES - 1:
+ backoff = min(
+ config.PUBCHEM_BACKOFF_BASE_S * (2**attempt),
+ config.PUBCHEM_BACKOFF_MAX_S,
+ )
+ logger.warning(
+ "PubChem throttled (503); retrying in %.1fs (attempt %d/%d)",
+ backoff,
+ attempt + 1,
+ config.PUBCHEM_MAX_RETRIES,
+ )
+ time.sleep(backoff)
+ # Exhausted retries — hand the last 503 back; caller raises via raise_for_status.
+ return response # type: ignore[return-value]
class PubChemError(Exception):
@@ -59,11 +121,11 @@ def search_molecule_by_name(name: str) -> int:
PubChemAPIError: If API request fails
MoleculeNotFoundError: If molecule not found
"""
- url = f"{PUBCHEM_BASE_URL}/compound/name/{name}/cids/JSON"
+ url = f"{PUBCHEM_BASE_URL}/compound/name/{quote(name, safe='')}/cids/JSON"
try:
logger.debug(f"Searching PubChem for: {name}")
- response = requests.get(url, timeout=PUBCHEM_TIMEOUT)
+ response = _http_get(url)
if response.status_code == 404:
raise MoleculeNotFoundError(f"Molecule '{name}' not found in PubChem")
@@ -84,6 +146,27 @@ def search_molecule_by_name(name: str) -> int:
raise PubChemAPIError(f"Failed to connect to PubChem: {e}")
+def search_cid_by_inchikey(inchikey: str) -> int:
+ """Resolve a standard InChIKey to a PubChem CID.
+
+ InChIKeys are hashes and cannot be inverted to a structure locally, so this
+ is the one identifier type that always requires the network.
+ """
+ url = f"{PUBCHEM_BASE_URL}/compound/inchikey/{quote(inchikey, safe='')}/cids/JSON"
+ try:
+ response = _http_get(url)
+ if response.status_code == 404:
+ raise MoleculeNotFoundError(f"InChIKey '{inchikey}' not found in PubChem")
+ response.raise_for_status()
+ cids = response.json().get("IdentifierList", {}).get("CID", [])
+ if not cids:
+ raise MoleculeNotFoundError(f"No CID found for InChIKey '{inchikey}'")
+ return int(cids[0])
+ except requests.RequestException as e:
+ logger.error(f"PubChem InChIKey request failed: {e}")
+ raise PubChemAPIError(f"Failed to connect to PubChem: {e}")
+
+
@lru_cache(maxsize=50)
def get_molecule_sdf(cid: int, conformer_3d: bool = True) -> str:
"""
@@ -109,7 +192,7 @@ def get_molecule_sdf(cid: int, conformer_3d: bool = True) -> str:
try:
logger.debug(f"Fetching {record_type.upper()} SDF for CID {cid}")
- response = requests.get(url, params=params, timeout=PUBCHEM_TIMEOUT)
+ response = _http_get(url, params=params)
if response.status_code == 404:
if conformer_3d:
@@ -158,7 +241,8 @@ def sdf_to_xyz(sdf_content: str) -> Tuple[str, Dict[str, Any]]:
mol = Chem.AddHs(mol)
# Generate 3D coordinates if needed
- if mol.GetNumConformers() == 0:
+ coords_embedded = mol.GetNumConformers() == 0
+ if coords_embedded:
AllChem.EmbedMolecule(mol, randomSeed=42)
AllChem.UFFOptimizeMolecule(mol)
@@ -184,6 +268,7 @@ def sdf_to_xyz(sdf_content: str) -> Tuple[str, Dict[str, Any]]:
"charge": Chem.GetFormalCharge(mol),
"num_atoms": mol.GetNumAtoms(),
"num_heavy_atoms": mol.GetNumHeavyAtoms(),
+ "coords_embedded": coords_embedded,
}
logger.debug(f"Converted SDF to XYZ: {metadata['formula']}")
@@ -424,6 +509,60 @@ def smiles_to_xyz(smiles: str, optimize_3d: bool = True) -> Tuple[str, Dict[str,
raise ValueError(f"Failed to convert SMILES to XYZ: {e}")
+def inchi_to_xyz(inchi: str, optimize_3d: bool = True) -> Tuple[str, Dict[str, Any]]:
+ """Convert an InChI string to XYZ coordinates via RDKit (no network).
+
+ Mirrors :func:`smiles_to_xyz`: parse → add H → embed (ETKDG, seed 42) →
+ UFF-optimize. Returns ``(xyz_string, metadata)``; metadata carries the
+ canonical SMILES so the caller can label provenance consistently.
+ """
+ if not RDKIT_AVAILABLE:
+ raise ImportError("RDKit is required for InChI conversion")
+
+ try:
+ mol = Chem.MolFromInchi(inchi)
+ if mol is None:
+ raise ValueError(f"Invalid InChI string: {inchi}")
+
+ mol = Chem.AddHs(mol)
+ if optimize_3d:
+ result = AllChem.EmbedMolecule(mol, randomSeed=42)
+ if result != 0:
+ AllChem.EmbedMolecule(mol, randomSeed=42, useRandomCoords=True)
+ try:
+ AllChem.UFFOptimizeMolecule(mol)
+ except Exception:
+ logger.warning("UFF optimization failed, using unoptimized coordinates")
+
+ if mol.GetNumConformers() == 0:
+ raise ValueError("Failed to generate 3D coordinates")
+
+ conf = mol.GetConformer()
+ formula = Chem.rdMolDescriptors.CalcMolFormula(mol)
+ xyz_lines = [str(mol.GetNumAtoms()), f"Generated from InChI ({formula})"]
+ for atom in mol.GetAtoms():
+ pos = conf.GetAtomPosition(atom.GetIdx())
+ xyz_lines.append(
+ f"{atom.GetSymbol():3s} {pos.x:12.6f} {pos.y:12.6f} {pos.z:12.6f}"
+ )
+
+ metadata = {
+ "formula": formula,
+ "molecular_weight": Descriptors.MolWt(mol),
+ "charge": Chem.GetFormalCharge(mol),
+ "num_atoms": mol.GetNumAtoms(),
+ "num_heavy_atoms": mol.GetNumHeavyAtoms(),
+ "inchi": inchi,
+ "canonical_smiles": Chem.MolToSmiles(mol),
+ }
+ logger.info(f"Converted InChI to XYZ: {formula}")
+ return "\n".join(xyz_lines), metadata
+
+ except Exception as e:
+ logger.error(f"InChI to XYZ conversion failed: {e}")
+ raise ValueError(f"Failed to convert InChI to XYZ: {e}")
+
+
def student_friendly_smiles_to_xyz(smiles: str) -> Tuple[Optional[str], str]:
"""
Convert SMILES to XYZ with student-friendly error messages.
@@ -688,3 +827,165 @@ def validate_smiles(smiles: str) -> Tuple[bool, str]:
except Exception as e:
return False, f"Validation error: {str(e)}"
+
+
+# ============================================================================
+# Smart input routing (STRUCT.1)
+# ============================================================================
+
+# Standard InChIKey: 14 block chars - 10 block chars - 1 flag char.
+_INCHIKEY_RE = re.compile(r"^[A-Z]{14}-[A-Z]{10}-[A-Z]$")
+# A bare molecular formula, e.g. "H2O", "C6H6", "CO2", "Fe".
+_FORMULA_RE = re.compile(r"^(?:[A-Z][a-z]?\d*)+$")
+# Characters that only appear in SMILES, never in a common/IUPAC molecule name.
+_SMILES_STRUCTURAL = set("=#()[]/\\@.%+")
+
+
+def _looks_like_smiles(query: str) -> bool:
+ """High-precision SMILES check: only ``True`` when RDKit parses it *and* it
+ carries SMILES-only signals (structural punctuation or ring digits).
+
+ Deliberately conservative — bare-letter tokens like ``CCO`` (which are valid
+ SMILES *and* plausible names/formulas) are left for the provider chain
+ (STRUCT.4) to disambiguate, so we never misroute a plain name to a local
+ parse.
+ """
+ if not RDKIT_AVAILABLE or " " in query:
+ return False
+ has_structural = any(c in _SMILES_STRUCTURAL for c in query)
+ has_digit = any(c.isdigit() for c in query)
+ if not (has_structural or has_digit):
+ return False
+ return Chem.MolFromSmiles(query) is not None
+
+
+def classify_query(query: str) -> str:
+ """Classify a user structure query so it can be routed to the right resolver.
+
+ Returns one of ``"cid"``, ``"inchikey"``, ``"inchi"``, ``"smiles"``,
+ ``"formula"``, or ``"name"``. ``smiles`` / ``inchi`` resolve locally via
+ RDKit (no network); the rest go to PubChem. ``formula`` is currently routed
+ like ``name`` (PubChem's async fastformula search is deferred to STRUCT.4).
+ """
+ q = query.strip()
+ if not q:
+ raise ValueError("Empty query")
+ if q.startswith("InChI="):
+ return "inchi"
+ if _INCHIKEY_RE.match(q):
+ return "inchikey"
+ if re.fullmatch(r"(?:CID:?\s*)?\d+", q, flags=re.IGNORECASE):
+ return "cid"
+ if _looks_like_smiles(q):
+ return "smiles"
+ if _FORMULA_RE.match(q):
+ return "formula"
+ return "name"
+
+
+def _coerce_cid(query: str) -> int:
+ """Extract the integer CID from ``123`` / ``CID123`` / ``cid: 123`` forms."""
+ return int(re.sub(r"[^\d]", "", query))
+
+
+def fetch_structure(
+ query: str, conformer_3d: bool = True
+) -> Tuple[str, Dict[str, Any]]:
+ """Resolve any supported query type to ``(xyz_string, metadata)``.
+
+ Routes by :func:`classify_query`: SMILES/InChI resolve locally via RDKit
+ (no network); CID/InChIKey/name/formula go to PubChem. ``metadata`` always
+ carries a ``source`` key (``"rdkit-smiles"`` / ``"rdkit-inchi"`` /
+ ``"pubchem"``) and a ``conformer_origin`` key describing where the 3D
+ coordinates came from, so the UI can be honest about provenance.
+
+ Raises :class:`PubChemError` / :class:`ValueError` on failure (the
+ student-friendly wrapper :func:`student_friendly_resolve` maps these to
+ messages).
+ """
+ qtype = classify_query(query)
+ q = query.strip()
+ logger.info(f"Resolving structure query '{q}' classified as '{qtype}'")
+
+ if qtype == "smiles":
+ xyz, metadata = smiles_to_xyz(q, optimize_3d=conformer_3d)
+ metadata["source"] = "rdkit-smiles"
+ metadata["conformer_origin"] = "rdkit-embedded"
+ return xyz, metadata
+
+ if qtype == "inchi":
+ xyz, metadata = inchi_to_xyz(q, optimize_3d=conformer_3d)
+ metadata["source"] = "rdkit-inchi"
+ metadata["conformer_origin"] = "rdkit-embedded"
+ return xyz, metadata
+
+ # Network branch: resolve to a CID, then fetch + convert the SDF.
+ if qtype == "cid":
+ cid = _coerce_cid(q)
+ elif qtype == "inchikey":
+ cid = search_cid_by_inchikey(q)
+ else: # "name" or "formula"
+ cid = search_molecule_by_name(q)
+
+ sdf_content = get_molecule_sdf(cid, conformer_3d=conformer_3d)
+ xyz, metadata = sdf_to_xyz(sdf_content)
+ metadata["source"] = "pubchem"
+ metadata["pubchem_cid"] = cid
+ metadata["query_type"] = qtype
+ metadata["conformer_origin"] = (
+ "rdkit-embedded" if metadata.get("coords_embedded") else "pubchem"
+ )
+ return xyz, metadata
+
+
+def student_friendly_resolve(query: str) -> Tuple[Optional[str], str]:
+ """Smart, type-aware structure fetch with student-friendly messages.
+
+ Drop-in replacement for :func:`student_friendly_fetch` that also handles
+ SMILES / InChI / CID / InChIKey input (the plain-name path is unchanged).
+ Returns ``(xyz_string_or_None, message)``.
+ """
+ try:
+ xyz_string, metadata = fetch_structure(query, conformer_3d=True)
+ except (MoleculeNotFoundError, ValueError) as exc:
+ return None, (
+ f"❌ Could not resolve '{query}'.\n"
+ f" {exc}\n"
+ f" Try a different name, a SMILES (e.g. CCO), or check spelling.\n"
+ f" Search manually at: https://pubchem.ncbi.nlm.nih.gov/"
+ )
+ except PubChemAPIError:
+ return None, (
+ "❌ Connection to PubChem failed.\n"
+ " • Check your internet connection\n"
+ " • Try again in a moment\n"
+ " • Use a preset molecule if the problem persists"
+ )
+ except ImportError:
+ return None, (
+ "❌ RDKit is required for SMILES / InChI input.\n"
+ " Install with: conda install -c conda-forge rdkit"
+ )
+ except Exception as exc: # pragma: no cover - unexpected
+ logger.error(
+ f"Unexpected error in student_friendly_resolve: {exc}", exc_info=True
+ )
+ return None, f"❌ Error resolving '{query}': {exc}"
+
+ source = {
+ "rdkit-smiles": "generated locally from SMILES",
+ "rdkit-inchi": "generated locally from InChI",
+ "pubchem": "PubChem",
+ }.get(metadata.get("source", ""), metadata.get("source", "?"))
+ origin = metadata.get("conformer_origin", "")
+ origin_note = (
+ " (2D structure embedded by RDKit)" if origin == "rdkit-embedded" else ""
+ )
+ message = (
+ f"✓ Resolved '{query}' via {source}.\n"
+ f" Formula: {metadata.get('formula', '?')}\n"
+ f" Atoms: {metadata.get('num_atoms', '?')} "
+ f"({metadata.get('num_heavy_atoms', '?')} heavy)\n"
+ f" Molecular weight: {metadata.get('molecular_weight', 0):.2f} g/mol{origin_note}"
+ )
+ return xyz_string, message
diff --git a/tests/test_struct_resolver.py b/tests/test_struct_resolver.py
new file mode 100644
index 0000000..89338eb
--- /dev/null
+++ b/tests/test_struct_resolver.py
@@ -0,0 +1,239 @@
+"""Tests for M-STRUCT STRUCT.1 (input-type routing) + STRUCT.2 (hardened client).
+
+All tests are platform-independent: network is mocked, RDKit-only paths are
+gated. No PySCF dependency.
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+import requests
+
+from quantui import config
+from quantui.pubchem import (
+ RDKIT_AVAILABLE,
+ MoleculeNotFoundError,
+ PubChemAPIError,
+ classify_query,
+ fetch_structure,
+ get_molecule_sdf,
+ search_cid_by_inchikey,
+ search_molecule_by_name,
+ student_friendly_resolve,
+)
+
+rdkit_only = pytest.mark.skipif(not RDKIT_AVAILABLE, reason="rdkit not installed")
+
+
+# ============================================================================
+# STRUCT.1 — classify_query routing
+# ============================================================================
+
+
+class TestClassifyQuery:
+ @pytest.mark.parametrize(
+ "query,expected",
+ [
+ ("962", "cid"),
+ ("CID962", "cid"),
+ ("cid: 2244", "cid"),
+ ("InChI=1S/H2O/h1H2", "inchi"),
+ ("XLYOFNOQVPJJNP-UHFFFAOYSA-N", "inchikey"), # water InChIKey
+ ("CC(=O)O", "smiles"), # acetic acid — structural chars
+ ("c1ccccc1", "smiles"), # benzene — aromatic + ring digit
+ ("[OH3+]", "smiles"), # hydronium — brackets + charge
+ ("H2O", "formula"), # digit but not valid SMILES
+ ("C6H6", "formula"),
+ ("Fe", "formula"),
+ ("water", "name"),
+ ("carbon dioxide", "name"), # space → never SMILES
+ ("acetylsalicylic acid", "name"),
+ ],
+ )
+ def test_classification(self, query, expected):
+ # SMILES classification needs RDKit to validate; skip those when absent.
+ if expected == "smiles" and not RDKIT_AVAILABLE:
+ pytest.skip("rdkit not installed")
+ assert classify_query(query) == expected
+
+ def test_empty_query_raises(self):
+ with pytest.raises(ValueError):
+ classify_query(" ")
+
+ @rdkit_only
+ def test_bare_letter_smiles_routes_to_name_not_smiles(self):
+ # "CCO" is valid SMILES *and* a plausible token; we deliberately leave
+ # it to the (future) provider chain rather than misroute a name.
+ assert classify_query("CCO") in ("formula", "name")
+
+
+# ============================================================================
+# STRUCT.2 — URL encoding
+# ============================================================================
+
+
+class TestUrlEncoding:
+ @patch("quantui.pubchem.requests.get")
+ def test_name_with_space_is_encoded(self, mock_get):
+ mock_get.return_value = Mock(
+ status_code=200, json=Mock(return_value={"IdentifierList": {"CID": [280]}})
+ )
+ search_molecule_by_name("carbon dioxide")
+ url = mock_get.call_args[0][0]
+ assert "carbon%20dioxide" in url
+ assert " " not in url
+
+ @patch("quantui.pubchem.requests.get")
+ def test_inchikey_endpoint_encoded(self, mock_get):
+ mock_get.return_value = Mock(
+ status_code=200, json=Mock(return_value={"IdentifierList": {"CID": [962]}})
+ )
+ cid = search_cid_by_inchikey("XLYOFNOQVPJJNP-UHFFFAOYSA-N")
+ assert cid == 962
+ assert "compound/inchikey/" in mock_get.call_args[0][0]
+
+
+# ============================================================================
+# STRUCT.2 — throttle / 503 back-off
+# ============================================================================
+
+
+class TestThrottleBackoff:
+ @patch("quantui.pubchem.time.sleep") # don't actually sleep
+ @patch("quantui.pubchem.requests.get")
+ def test_503_then_success_retries(self, mock_get, mock_sleep):
+ resp_503 = Mock(status_code=503)
+ resp_ok = Mock(
+ status_code=200, json=Mock(return_value={"IdentifierList": {"CID": [962]}})
+ )
+ mock_get.side_effect = [resp_503, resp_ok]
+
+ cid = search_molecule_by_name("water")
+ assert cid == 962
+ assert mock_get.call_count == 2 # one retry after the 503
+ assert mock_sleep.called # backed off between attempts
+
+ @patch("quantui.pubchem.time.sleep")
+ @patch("quantui.pubchem.requests.get")
+ def test_persistent_503_exhausts_retries_then_errors(self, mock_get, mock_sleep):
+ resp_503 = Mock(status_code=503)
+ resp_503.raise_for_status.side_effect = requests.HTTPError("503")
+ mock_get.return_value = resp_503
+
+ with pytest.raises(PubChemAPIError):
+ search_molecule_by_name("water")
+ # Tried the full retry budget, then gave up.
+ assert mock_get.call_count == config.PUBCHEM_MAX_RETRIES
+ # Backed off at least once between attempts (sleep is also used by the
+ # rate-limiter, so assert "happened" rather than an exact count).
+ assert mock_sleep.called
+
+ @patch("quantui.pubchem.requests.get")
+ def test_non_503_not_retried(self, mock_get):
+ # A 500 must surface immediately, not trigger the 503 retry loop.
+ resp = Mock(status_code=500)
+ resp.raise_for_status.side_effect = requests.HTTPError("500")
+ mock_get.return_value = resp
+ with pytest.raises(PubChemAPIError):
+ search_molecule_by_name("water")
+ assert mock_get.call_count == 1
+
+
+# ============================================================================
+# STRUCT.1 — fetch_structure routing (local vs network)
+# ============================================================================
+
+
+class TestFetchStructureRouting:
+ @rdkit_only
+ @patch("quantui.pubchem.requests.get")
+ def test_smiles_resolves_locally_without_network(self, mock_get):
+ mock_get.side_effect = AssertionError("network must not be touched for SMILES")
+ xyz, meta = fetch_structure("CC(=O)O") # acetic acid
+ assert meta["source"] == "rdkit-smiles"
+ assert meta["conformer_origin"] == "rdkit-embedded"
+ assert meta["formula"] == "C2H4O2"
+ assert xyz.strip().splitlines()[0].strip() == str(meta["num_atoms"])
+ mock_get.assert_not_called()
+
+ @rdkit_only
+ @patch("quantui.pubchem.requests.get")
+ def test_inchi_resolves_locally_without_network(self, mock_get):
+ mock_get.side_effect = AssertionError("network must not be touched for InChI")
+ xyz, meta = fetch_structure("InChI=1S/H2O/h1H2") # water
+ assert meta["source"] == "rdkit-inchi"
+ assert meta["num_heavy_atoms"] == 1
+ mock_get.assert_not_called()
+
+ @rdkit_only
+ @patch("quantui.pubchem.search_cid_by_inchikey")
+ @patch("quantui.pubchem.get_molecule_sdf")
+ def test_inchikey_routes_through_network(
+ self, mock_sdf, mock_inchikey, sample_sdf_water
+ ):
+ mock_inchikey.return_value = 962
+ mock_sdf.return_value = sample_sdf_water
+ xyz, meta = fetch_structure("XLYOFNOQVPJJNP-UHFFFAOYSA-N")
+ mock_inchikey.assert_called_once()
+ assert meta["source"] == "pubchem"
+ assert meta["pubchem_cid"] == 962
+ assert meta["conformer_origin"] in ("pubchem", "rdkit-embedded")
+
+ @rdkit_only
+ @patch("quantui.pubchem.get_molecule_sdf")
+ @patch("quantui.pubchem.search_molecule_by_name")
+ def test_name_routes_through_pubchem(self, mock_search, mock_sdf, sample_sdf_water):
+ mock_search.return_value = 962
+ mock_sdf.return_value = sample_sdf_water
+ _, meta = fetch_structure("water")
+ mock_search.assert_called_once_with("water")
+ assert meta["source"] == "pubchem"
+ assert meta["query_type"] == "name"
+
+
+# ============================================================================
+# STRUCT.1/.2 — student-friendly wrapper
+# ============================================================================
+
+
+class TestStudentFriendlyResolve:
+ @rdkit_only
+ def test_smiles_success_message(self):
+ # Use an unambiguous SMILES (structural chars) so it resolves locally
+ # with no network. Bare-letter tokens like "CCO" classify as
+ # formula/name by design and are left to the provider chain.
+ xyz, msg = student_friendly_resolve("CC(=O)O")
+ assert xyz is not None
+ assert "generated locally from SMILES" in msg
+ assert "C2H4O2" in msg
+
+ @patch("quantui.pubchem.fetch_structure")
+ def test_not_found_message(self, mock_fetch):
+ mock_fetch.side_effect = MoleculeNotFoundError("nope")
+ xyz, msg = student_friendly_resolve("zzxqq")
+ assert xyz is None
+ assert "Could not resolve" in msg
+
+ @patch("quantui.pubchem.fetch_structure")
+ def test_api_error_message(self, mock_fetch):
+ mock_fetch.side_effect = PubChemAPIError("down")
+ xyz, msg = student_friendly_resolve("water")
+ assert xyz is None
+ assert "Connection to PubChem failed" in msg
+
+
+# ============================================================================
+# STRUCT.2 — sdf_to_xyz provenance flag (regression guard)
+# ============================================================================
+
+
+class TestCoordsEmbeddedFlag:
+ @rdkit_only
+ def test_sdf_with_3d_coords_not_flagged_embedded(self, sample_sdf_water):
+ from quantui.pubchem import sdf_to_xyz
+
+ get_molecule_sdf.cache_clear()
+ _, meta = sdf_to_xyz(sample_sdf_water)
+ assert "coords_embedded" in meta
+ # The fixture SDF already carries 3D coords, so RDKit should not embed.
+ assert meta["coords_embedded"] is False
From c01c5cb991146170f887b50285bbd4aa8adaea0b Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sun, 7 Jun 2026 13:17:58 -0400
Subject: [PATCH 02/16] Add CACTUS resolver and provider chain
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce an NCI CACTUS resolver (quantui.cactus) to fetch SDF/3D structures as a chained fallback after PubChem, with robust error mapping to existing PubChem exception types. Add a unified provider chain (quantui.structure_providers) that normalizes results into a ResolvedStructure dataclass and implements resolution order: local RDKit → bundled library exact → PubChem → CACTUS → bundled-library fuzzy offline fallback. Expose resolve_structure, ResolvedStructure and fetch_from_cactus via package __init__, wire student_friendly_resolve usage in app, and add CACTUS_TIMEOUT_S config. Add comprehensive tests for CACTUS and the provider chain (tests/test_struct_providers.py).
---
quantui/__init__.py | 5 +
quantui/app.py | 2 +-
quantui/cactus.py | 85 ++++++++++++
quantui/config.py | 4 +
quantui/structure_providers.py | 236 +++++++++++++++++++++++++++++++++
tests/test_struct_providers.py | 178 +++++++++++++++++++++++++
6 files changed, 509 insertions(+), 1 deletion(-)
create mode 100644 quantui/cactus.py
create mode 100644 quantui/structure_providers.py
create mode 100644 tests/test_struct_providers.py
diff --git a/quantui/__init__.py b/quantui/__init__.py
index 38aaf95..f91871e 100644
--- a/quantui/__init__.py
+++ b/quantui/__init__.py
@@ -123,6 +123,7 @@
# PubChem integration (optional — requires internet)
try:
+ from .cactus import fetch_from_cactus
from .pubchem import (
MoleculeNotFoundError,
PubChemError,
@@ -142,6 +143,7 @@
student_friendly_smiles_to_xyz,
validate_smiles,
)
+ from .structure_providers import ResolvedStructure, resolve_structure
PUBCHEM_AVAILABLE = True
except ImportError:
@@ -247,6 +249,9 @@
"classify_query",
"student_friendly_fetch",
"student_friendly_resolve",
+ "resolve_structure",
+ "ResolvedStructure",
+ "fetch_from_cactus",
"inchi_to_xyz",
"search_cid_by_inchikey",
"get_common_molecules",
diff --git a/quantui/app.py b/quantui/app.py
index 08995f9..4a15f09 100644
--- a/quantui/app.py
+++ b/quantui/app.py
@@ -472,7 +472,7 @@
from quantui.pubchem import (
RDKIT_AVAILABLE as _PUBCHEM_RDKIT_AVAILABLE,
)
- from quantui.pubchem import (
+ from quantui.structure_providers import (
student_friendly_resolve as _student_friendly_resolve,
)
diff --git a/quantui/cactus.py b/quantui/cactus.py
new file mode 100644
index 0000000..dd96caf
--- /dev/null
+++ b/quantui/cactus.py
@@ -0,0 +1,85 @@
+"""NCI CACTUS Chemical Identifier Resolver (M-STRUCT STRUCT.3).
+
+A chained fallback after PubChem. CACTUS resolves a wide range of identifiers
+(common name, IUPAC name, CAS number, InChI, SMILES, formula) to a 3D SDF with
+no API key. It often answers queries PubChem misses — CAS numbers in
+particular.
+
+Best-effort by design: every failure mode (miss, network error, malformed
+response) raises one of the shared PubChem exception types so the provider
+chain in :mod:`quantui.structure_providers` can treat all resolvers uniformly.
+"""
+
+import logging
+from typing import Any, Dict, Tuple
+from urllib.parse import quote
+
+import requests
+
+from . import config
+from .pubchem import MoleculeNotFoundError, PubChemAPIError, sdf_to_xyz
+
+logger = logging.getLogger(__name__)
+
+# CACTUS resolver base. The ``/file?format=sdf&get3d=true`` form returns a 3D
+# SDF; the bare ``/sdf`` form returns whatever (often 2D) CACTUS has.
+CACTUS_BASE_URL = "https://cactus.nci.nih.gov/chemical/structure"
+
+
+def _looks_like_sdf(text: str) -> bool:
+ """CACTUS returns an HTML error page (HTTP 200) for unknown identifiers.
+
+ A real SDF molfile always carries the ``V2000``/``V3000`` counts-line tag
+ and the ``M END`` terminator, so key off those rather than the status code.
+ """
+ return "M END" in text and ("V2000" in text or "V3000" in text)
+
+
+def resolve_to_sdf(identifier: str, conformer_3d: bool = True) -> str:
+ """Resolve an identifier to SDF text via CACTUS.
+
+ Tries the 3D endpoint first, then falls back to the plain ``/sdf`` form
+ (whose coordinates RDKit will embed downstream if they are 2D).
+
+ Raises:
+ MoleculeNotFoundError: CACTUS has no structure for the identifier.
+ PubChemAPIError: network/transport failure reaching CACTUS.
+ """
+ enc = quote(identifier, safe="")
+ urls = []
+ if conformer_3d:
+ urls.append(f"{CACTUS_BASE_URL}/{enc}/file?format=sdf&get3d=true")
+ urls.append(f"{CACTUS_BASE_URL}/{enc}/sdf")
+
+ last_status = None
+ try:
+ for url in urls:
+ logger.debug(f"CACTUS resolving: {url}")
+ response = requests.get(url, timeout=config.CACTUS_TIMEOUT_S)
+ last_status = response.status_code
+ if response.status_code == 200 and _looks_like_sdf(response.text):
+ return str(response.text)
+ except requests.RequestException as e:
+ logger.error(f"CACTUS request failed: {e}")
+ raise PubChemAPIError(f"Failed to connect to CACTUS: {e}")
+
+ raise MoleculeNotFoundError(
+ f"CACTUS could not resolve '{identifier}' (last status: {last_status})"
+ )
+
+
+def fetch_from_cactus(
+ identifier: str, conformer_3d: bool = True
+) -> Tuple[str, Dict[str, Any]]:
+ """Resolve an identifier to ``(xyz_string, metadata)`` via CACTUS.
+
+ Metadata carries ``source="cactus"`` and a ``conformer_origin`` describing
+ whether RDKit had to embed the coordinates.
+ """
+ sdf_content = resolve_to_sdf(identifier, conformer_3d=conformer_3d)
+ xyz, metadata = sdf_to_xyz(sdf_content)
+ metadata["source"] = "cactus"
+ metadata["conformer_origin"] = (
+ "rdkit-embedded" if metadata.get("coords_embedded") else "cactus"
+ )
+ return xyz, metadata
diff --git a/quantui/config.py b/quantui/config.py
index e0ca247..f891431 100644
--- a/quantui/config.py
+++ b/quantui/config.py
@@ -241,6 +241,10 @@
# so a classroom of simultaneous users doesn't trip the server-side throttle.
PUBCHEM_MIN_REQUEST_INTERVAL_S: float = 0.25
+# NCI CACTUS Chemical Identifier Resolver — chained fallback after PubChem
+# (resolves name / CAS / InChI / SMILES → 3D SDF; no API key).
+CACTUS_TIMEOUT_S: float = 15.0
+
# Molecule presets — 20+ curated educational molecules
MOLECULE_LIBRARY: Dict[str, Dict[str, Any]] = {
# ========== SIMPLE DIATOMIC MOLECULES ==========
diff --git a/quantui/structure_providers.py b/quantui/structure_providers.py
new file mode 100644
index 0000000..9079d97
--- /dev/null
+++ b/quantui/structure_providers.py
@@ -0,0 +1,236 @@
+"""Unified structure-resolver chain with offline fallback (M-STRUCT STRUCT.4).
+
+Resolution order (first hit wins):
+
+1. **Local RDKit** for SMILES / InChI input — offline, no network.
+2. **Bundled library** exact hit (formula key) — offline, instant.
+3. **PubChem** (hardened client, STRUCT.2).
+4. **NCI CACTUS** resolver (STRUCT.3).
+5. **Bundled-library fuzzy fallback** (name/description substring) — the
+ last-resort offline answer so the search box is never a dead end, even with
+ no network.
+
+Every resolver returns a normalized :class:`ResolvedStructure` so callers
+(and the future disambiguation UI, STRUCT.5) treat all sources uniformly. The
+``source`` field records which resolver answered, so the UI can be honest about
+provenance.
+
+The bundled-library steps currently search ``config.MOLECULE_LIBRARY`` (the 20
+presets). When STRUCT.6/.7/.8 move the library into an indexed package-data
+store, only :func:`_library_exact` / :func:`_library_fuzzy` change — the chain
+is unaffected.
+"""
+
+import logging
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional, Tuple
+
+from . import cactus, config
+from .pubchem import (
+ MoleculeNotFoundError,
+ PubChemAPIError,
+ classify_query,
+ fetch_structure,
+)
+
+logger = logging.getLogger(__name__)
+
+# Elements that count as "heavy" exclusions when tallying heavy atoms.
+_HYDROGEN = "H"
+
+
+@dataclass
+class ResolvedStructure:
+ """A structure resolved by the provider chain, normalized across sources."""
+
+ xyz: str
+ source: str # "rdkit-smiles" | "rdkit-inchi" | "library" | "pubchem" | "cactus" | "library-offline-fallback"
+ formula: str = "?"
+ num_atoms: int = 0
+ num_heavy_atoms: int = 0
+ charge: int = 0
+ multiplicity: int = 1
+ molecular_weight: Optional[float] = None
+ conformer_origin: str = ""
+ identifiers: Dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def is_offline(self) -> bool:
+ return self.source in (
+ "library",
+ "library-offline-fallback",
+ ) or self.source.startswith("rdkit-")
+
+
+def _from_metadata(xyz: str, meta: Dict[str, Any], *, source: str) -> ResolvedStructure:
+ """Build a ResolvedStructure from a ``(xyz, metadata)`` resolver result."""
+ identifiers = {
+ k: meta[k]
+ for k in ("pubchem_cid", "smiles", "canonical_smiles", "inchi", "query_type")
+ if k in meta
+ }
+ return ResolvedStructure(
+ xyz=xyz,
+ source=source,
+ formula=meta.get("formula", "?"),
+ num_atoms=int(meta.get("num_atoms", 0) or 0),
+ num_heavy_atoms=int(meta.get("num_heavy_atoms", 0) or 0),
+ charge=int(meta.get("charge", 0) or 0),
+ molecular_weight=meta.get("molecular_weight"),
+ conformer_origin=meta.get("conformer_origin", meta.get("source", "")),
+ identifiers=identifiers,
+ )
+
+
+def _entry_to_xyz(name: str, entry: Dict[str, Any]) -> str:
+ """Format a ``MOLECULE_LIBRARY`` entry as an XYZ string."""
+ atoms = entry["atoms"]
+ coords = entry["coordinates"]
+ desc = entry.get("description", "")
+ lines = [str(len(atoms)), f"{name}: {desc}".strip()]
+ for sym, (x, y, z) in zip(atoms, coords):
+ lines.append(f"{sym:3s} {float(x):12.6f} {float(y):12.6f} {float(z):12.6f}")
+ return "\n".join(lines)
+
+
+def _entry_to_resolved(
+ name: str, entry: Dict[str, Any], *, source: str
+) -> ResolvedStructure:
+ atoms = entry["atoms"]
+ return ResolvedStructure(
+ xyz=_entry_to_xyz(name, entry),
+ source=source,
+ formula=name,
+ num_atoms=len(atoms),
+ num_heavy_atoms=sum(1 for a in atoms if a != _HYDROGEN),
+ charge=int(entry.get("charge", 0)),
+ multiplicity=int(entry.get("multiplicity", 1)),
+ conformer_origin="library",
+ identifiers={"library_key": name},
+ )
+
+
+def _library_exact(query: str) -> Optional[ResolvedStructure]:
+ """Exact (case-insensitive) match on a bundled-library formula key."""
+ q = query.strip().lower()
+ for key, entry in config.MOLECULE_LIBRARY.items():
+ if q == key.lower():
+ logger.info(f"Resolved '{query}' from bundled library (exact key '{key}')")
+ return _entry_to_resolved(key, entry, source="library")
+ return None
+
+
+def _library_fuzzy(query: str) -> Optional[ResolvedStructure]:
+ """Loose offline fallback: substring match over keys + descriptions.
+
+ Used only when the network resolvers are unreachable or all miss, so a
+ looser match is acceptable (and clearly labelled as a fallback to the user).
+ """
+ q = query.strip().lower()
+ if not q:
+ return None
+ for key, entry in config.MOLECULE_LIBRARY.items():
+ haystack = f"{key} {entry.get('description', '')}".lower()
+ if q == key.lower() or q in haystack:
+ logger.info(f"Offline fallback matched '{query}' to library entry '{key}'")
+ return _entry_to_resolved(key, entry, source="library-offline-fallback")
+ return None
+
+
+def resolve_structure(
+ query: str,
+ *,
+ conformer_3d: bool = True,
+ allow_network: bool = True,
+) -> ResolvedStructure:
+ """Resolve ``query`` through the provider chain. Raises on total failure.
+
+ Raises:
+ MoleculeNotFoundError: nothing in the chain could resolve the query.
+ ValueError: empty query, or RDKit failed to parse a SMILES/InChI.
+ """
+ qtype = classify_query(query)
+ logger.info(f"Resolving '{query}' (type={qtype}, network={allow_network})")
+
+ # 1. Local RDKit for SMILES / InChI — no network, no library needed.
+ if qtype in ("smiles", "inchi"):
+ xyz, meta = fetch_structure(query, conformer_3d=conformer_3d)
+ return _from_metadata(xyz, meta, source=meta["source"])
+
+ # 2. Exact bundled-library hit (offline, instant) — e.g. "H2O", "C6H6".
+ hit = _library_exact(query)
+ if hit is not None:
+ return hit
+
+ # 3/4. Network resolvers: PubChem, then CACTUS. A genuine miss
+ # (MoleculeNotFoundError) or a transport error (PubChemAPIError) both fall
+ # through to the next resolver, then to the offline fallback.
+ errors: List[Tuple[str, Exception]] = []
+ if allow_network:
+ try:
+ xyz, meta = fetch_structure(query, conformer_3d=conformer_3d)
+ return _from_metadata(xyz, meta, source="pubchem")
+ except (MoleculeNotFoundError, PubChemAPIError) as exc:
+ logger.info(f"PubChem did not resolve '{query}': {exc}")
+ errors.append(("pubchem", exc))
+
+ try:
+ xyz, meta = cactus.fetch_from_cactus(query, conformer_3d=conformer_3d)
+ return _from_metadata(xyz, meta, source="cactus")
+ except (MoleculeNotFoundError, PubChemAPIError) as exc:
+ logger.info(f"CACTUS did not resolve '{query}': {exc}")
+ errors.append(("cactus", exc))
+
+ # 5. Offline fuzzy fallback against the bundled library.
+ hit = _library_fuzzy(query)
+ if hit is not None:
+ return hit
+
+ tried = ", ".join(name for name, _ in errors) or "offline only"
+ raise MoleculeNotFoundError(
+ f"Could not resolve '{query}' (tried: {tried}, bundled library)"
+ )
+
+
+def student_friendly_resolve(query: str) -> Tuple[Optional[str], str]:
+ """Chain-backed, student-friendly resolve. Drop-in for the UI handler.
+
+ Returns ``(xyz_string_or_None, message)``.
+ """
+ try:
+ result = resolve_structure(query, conformer_3d=True)
+ except (MoleculeNotFoundError, ValueError) as exc:
+ return None, (
+ f"❌ Could not resolve '{query}'.\n"
+ f" {exc}\n"
+ f" Try a different name, a SMILES (e.g. CC(=O)O), a CAS number, "
+ f"or check spelling.\n"
+ f" Search manually at: https://pubchem.ncbi.nlm.nih.gov/"
+ )
+ except Exception as exc: # pragma: no cover - unexpected
+ logger.error(f"Unexpected error resolving '{query}': {exc}", exc_info=True)
+ return None, f"❌ Error resolving '{query}': {exc}"
+
+ source_label = {
+ "rdkit-smiles": "generated locally from SMILES",
+ "rdkit-inchi": "generated locally from InChI",
+ "library": "the bundled library (offline)",
+ "library-offline-fallback": "the bundled library (offline fallback — "
+ "network resolvers were unavailable)",
+ "pubchem": "PubChem",
+ "cactus": "NCI CACTUS",
+ }.get(result.source, result.source)
+
+ embedded = (
+ " (2D structure embedded by RDKit)"
+ if result.conformer_origin == "rdkit-embedded"
+ else ""
+ )
+ mw = f"{result.molecular_weight:.2f} g/mol" if result.molecular_weight else "—"
+ message = (
+ f"✓ Resolved '{query}' via {source_label}.\n"
+ f" Formula: {result.formula}\n"
+ f" Atoms: {result.num_atoms} ({result.num_heavy_atoms} heavy)\n"
+ f" Molecular weight: {mw}{embedded}"
+ )
+ return result.xyz, message
diff --git a/tests/test_struct_providers.py b/tests/test_struct_providers.py
new file mode 100644
index 0000000..dc3b44c
--- /dev/null
+++ b/tests/test_struct_providers.py
@@ -0,0 +1,178 @@
+"""Tests for M-STRUCT STRUCT.3 (NCI CACTUS) + STRUCT.4 (provider chain).
+
+Platform-independent: network mocked, RDKit-only paths gated, no PySCF.
+"""
+
+from unittest.mock import Mock, patch
+
+import pytest
+import requests
+
+from quantui import cactus
+from quantui.pubchem import RDKIT_AVAILABLE, MoleculeNotFoundError, PubChemAPIError
+from quantui.structure_providers import (
+ ResolvedStructure,
+ resolve_structure,
+ student_friendly_resolve,
+)
+
+rdkit_only = pytest.mark.skipif(not RDKIT_AVAILABLE, reason="rdkit not installed")
+
+_WATER_META = {
+ "formula": "H2O",
+ "num_atoms": 3,
+ "num_heavy_atoms": 1,
+ "charge": 0,
+ "molecular_weight": 18.02,
+ "conformer_origin": "pubchem",
+ "source": "pubchem",
+}
+_WATER_XYZ = "3\nwater\nO 0 0 0\nH 1 0 0\nH 0 1 0"
+
+
+# ============================================================================
+# STRUCT.3 — CACTUS resolver
+# ============================================================================
+
+
+class TestCactus:
+ def test_looks_like_sdf(self, sample_sdf_water):
+ assert cactus._looks_like_sdf(sample_sdf_water) is True
+ assert cactus._looks_like_sdf("Page not found") is False
+
+ @patch("quantui.cactus.requests.get")
+ def test_resolve_to_sdf_success(self, mock_get, sample_sdf_water):
+ mock_get.return_value = Mock(status_code=200, text=sample_sdf_water)
+ sdf = cactus.resolve_to_sdf("aspirin")
+ assert "M END" in sdf
+ assert "compound" not in mock_get.call_args[0][0] # uses CACTUS, not pubchem
+
+ @patch("quantui.cactus.requests.get")
+ def test_resolve_to_sdf_tries_3d_then_2d(self, mock_get, sample_sdf_water):
+ # 3D endpoint returns an HTML error page; 2D endpoint returns real SDF.
+ mock_get.side_effect = [
+ Mock(status_code=200, text="error"),
+ Mock(status_code=200, text=sample_sdf_water),
+ ]
+ sdf = cactus.resolve_to_sdf("weirdmol", conformer_3d=True)
+ assert "M END" in sdf
+ assert mock_get.call_count == 2
+
+ @patch("quantui.cactus.requests.get")
+ def test_resolve_to_sdf_miss_raises_not_found(self, mock_get):
+ mock_get.return_value = Mock(status_code=404, text="not found")
+ with pytest.raises(MoleculeNotFoundError):
+ cactus.resolve_to_sdf("zzznotreal")
+
+ @patch("quantui.cactus.requests.get")
+ def test_resolve_to_sdf_network_error_raises_api_error(self, mock_get):
+ mock_get.side_effect = requests.ConnectionError("down")
+ with pytest.raises(PubChemAPIError):
+ cactus.resolve_to_sdf("aspirin")
+
+ @rdkit_only
+ @patch("quantui.cactus.requests.get")
+ def test_fetch_from_cactus_returns_xyz(self, mock_get, sample_sdf_water):
+ mock_get.return_value = Mock(status_code=200, text=sample_sdf_water)
+ xyz, meta = cactus.fetch_from_cactus("water")
+ assert meta["source"] == "cactus"
+ assert meta["num_atoms"] == 3
+
+
+# ============================================================================
+# STRUCT.4 — provider chain ordering
+# ============================================================================
+
+
+class TestProviderChain:
+ @rdkit_only
+ @patch("quantui.pubchem.requests.get")
+ def test_smiles_resolves_locally_no_network(self, mock_get):
+ mock_get.side_effect = AssertionError("network must not be touched")
+ result = resolve_structure("CC(=O)O")
+ assert isinstance(result, ResolvedStructure)
+ assert result.source == "rdkit-smiles"
+ assert result.is_offline is True
+ mock_get.assert_not_called()
+
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_exact_library_hit_short_circuits_network(self, mock_fetch):
+ mock_fetch.side_effect = AssertionError("library hit must not hit network")
+ result = resolve_structure("H2O") # exact MOLECULE_LIBRARY key
+ assert result.source == "library"
+ assert result.formula == "H2O"
+ assert result.num_atoms == 3
+ mock_fetch.assert_not_called()
+
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_name_resolves_via_pubchem(self, mock_fetch):
+ mock_fetch.return_value = (_WATER_XYZ, dict(_WATER_META))
+ result = resolve_structure("dihydrogen monoxide")
+ assert result.source == "pubchem"
+ mock_fetch.assert_called_once()
+
+ @patch("quantui.structure_providers.cactus.fetch_from_cactus")
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_pubchem_miss_falls_through_to_cactus(self, mock_fetch, mock_cactus):
+ mock_fetch.side_effect = MoleculeNotFoundError("pubchem miss")
+ mock_cactus.return_value = (_WATER_XYZ, {**_WATER_META, "source": "cactus"})
+ result = resolve_structure("64-19-7") # a CAS number
+ assert result.source == "cactus"
+ mock_fetch.assert_called_once()
+ mock_cactus.assert_called_once()
+
+ @patch("quantui.structure_providers.cactus.fetch_from_cactus")
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_network_down_uses_offline_fuzzy_fallback(self, mock_fetch, mock_cactus):
+ mock_fetch.side_effect = PubChemAPIError("network down")
+ mock_cactus.side_effect = PubChemAPIError("network down")
+ # "water" isn't an exact key but appears in the H2O entry description.
+ result = resolve_structure("water")
+ assert result.source == "library-offline-fallback"
+ assert result.formula == "H2O"
+
+ @patch("quantui.structure_providers.cactus.fetch_from_cactus")
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_total_miss_raises(self, mock_fetch, mock_cactus):
+ mock_fetch.side_effect = MoleculeNotFoundError("miss")
+ mock_cactus.side_effect = MoleculeNotFoundError("miss")
+ with pytest.raises(MoleculeNotFoundError):
+ resolve_structure("zzqxnotarealmolecule")
+
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_allow_network_false_skips_resolvers(self, mock_fetch):
+ mock_fetch.side_effect = AssertionError("network disabled")
+ result = resolve_structure("water", allow_network=False)
+ assert result.source == "library-offline-fallback"
+ mock_fetch.assert_not_called()
+
+
+# ============================================================================
+# STRUCT.4 — friendly wrapper messaging
+# ============================================================================
+
+
+class TestFriendlyResolve:
+ def test_library_message_mentions_offline(self):
+ xyz, msg = student_friendly_resolve("H2O")
+ assert xyz is not None
+ assert "bundled library (offline)" in msg
+ assert "H2O" in msg
+
+ @patch("quantui.structure_providers.cactus.fetch_from_cactus")
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_offline_fallback_message(self, mock_fetch, mock_cactus):
+ mock_fetch.side_effect = PubChemAPIError("down")
+ mock_cactus.side_effect = PubChemAPIError("down")
+ xyz, msg = student_friendly_resolve("water")
+ assert xyz is not None
+ assert "offline fallback" in msg
+
+ @patch("quantui.structure_providers.cactus.fetch_from_cactus")
+ @patch("quantui.structure_providers.fetch_structure")
+ def test_not_found_message(self, mock_fetch, mock_cactus):
+ mock_fetch.side_effect = MoleculeNotFoundError("miss")
+ mock_cactus.side_effect = MoleculeNotFoundError("miss")
+ xyz, msg = student_friendly_resolve("zzqxnotreal")
+ assert xyz is None
+ assert "Could not resolve" in msg
From d635822460f79065f5726dfa58cba3bc89fbf875 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sun, 7 Jun 2026 16:23:45 -0400
Subject: [PATCH 03/16] Add bundled molecule library and loader
Move the hard-coded MOLECULE_LIBRARY into a packaged, indexed library and provide a lazy loader/back-compat shim.
- Add quantui/data/manifests/presets.json and quantui/data/library/library.sqlite as packaged data.
- Implement quantui/molecule_library.py: compact coord encoder/decoder, deterministic SQLite store builder, read-only query API (get/search/categories/count), JSON-manifest fallback, and get_preset_dict() that returns the legacy MOLECULE_LIBRARY shape while excluding bulk categories.
- Replace the inline MOLECULE_LIBRARY in quantui/config.py with a PEP 562 __getattr__ shim that lazily loads presets via get_preset_dict() to preserve existing consumers.
- Add scripts/build_library.py to rebuild the SQLite store from manifests (with a 10 MB budget check) and tests/test_molecule_library.py covering codec, store, queries, back-compat, and size governance.
- Include package-data in pyproject.toml so the manifest and sqlite store are bundled in distributions.
This keeps imports fast, preserves backwards compatibility, supports offline use, and provides a compact, indexed store that can scale to larger manifest sets.
---
pyproject.toml | 6 +
quantui/config.py | 256 +--------
quantui/data/library/library.sqlite | Bin 0 -> 28672 bytes
quantui/data/manifests/presets.json | 816 ++++++++++++++++++++++++++++
quantui/molecule_library.py | 408 ++++++++++++++
scripts/build_library.py | 32 ++
tests/test_molecule_library.py | 135 +++++
7 files changed, 1415 insertions(+), 238 deletions(-)
create mode 100644 quantui/data/library/library.sqlite
create mode 100644 quantui/data/manifests/presets.json
create mode 100644 quantui/molecule_library.py
create mode 100644 scripts/build_library.py
create mode 100644 tests/test_molecule_library.py
diff --git a/pyproject.toml b/pyproject.toml
index 1042865..52ce47b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,12 @@ quantui = "quantui.cli:main"
[tool.setuptools]
packages = ["quantui"]
+[tool.setuptools.package-data]
+# Bundled molecule library (M-STRUCT): the indexed SQLite store + the
+# human-readable JSON manifests it is built from. Both ship in the wheel so
+# offline structure lookup works on a fresh install.
+quantui = ["data/library/*.sqlite", "data/manifests/*.json"]
+
[project.optional-dependencies]
# PySCF requires Linux/macOS/WSL — not available on Windows natively.
# Use the Apptainer container (apptainer/quantui.def) for Windows.
diff --git a/quantui/config.py b/quantui/config.py
index f891431..eb77082 100644
--- a/quantui/config.py
+++ b/quantui/config.py
@@ -245,244 +245,24 @@
# (resolves name / CAS / InChI / SMILES → 3D SDF; no API key).
CACTUS_TIMEOUT_S: float = 15.0
-# Molecule presets — 20+ curated educational molecules
-MOLECULE_LIBRARY: Dict[str, Dict[str, Any]] = {
- # ========== SIMPLE DIATOMIC MOLECULES ==========
- "H2": {
- "atoms": ["H", "H"],
- "coordinates": [[0.0, 0.0, 0.0], [0.0, 0.0, 0.74]],
- "charge": 0,
- "multiplicity": 1,
- "description": "Hydrogen molecule (simplest molecule)",
- },
- "O2": {
- "atoms": ["O", "O"],
- "coordinates": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.21]],
- "charge": 0,
- "multiplicity": 3, # Triplet ground state
- "description": "Oxygen molecule (triplet ground state)",
- },
- "N2": {
- "atoms": ["N", "N"],
- "coordinates": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.10]],
- "charge": 0,
- "multiplicity": 1,
- "description": "Nitrogen molecule (triple bond)",
- },
- "CO": {
- "atoms": ["C", "O"],
- "coordinates": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.13]],
- "charge": 0,
- "multiplicity": 1,
- "description": "Carbon monoxide",
- },
- "HF": {
- "atoms": ["H", "F"],
- "coordinates": [[0.0, 0.0, 0.0], [0.0, 0.0, 0.92]],
- "charge": 0,
- "multiplicity": 1,
- "description": "Hydrogen fluoride (polar bond)",
- },
- "HCl": {
- "atoms": ["H", "Cl"],
- "coordinates": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.27]],
- "charge": 0,
- "multiplicity": 1,
- "description": "Hydrogen chloride",
- },
- # ========== SIMPLE TRIATOMIC MOLECULES ==========
- "H2O": {
- "atoms": ["O", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [0.0, 0.757, 0.587],
- [0.0, -0.757, 0.587],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Water molecule (bent geometry)",
- },
- "CO2": {
- "atoms": ["C", "O", "O"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [0.0, 0.0, 1.16],
- [0.0, 0.0, -1.16],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Carbon dioxide (linear)",
- },
- "O3": {
- "atoms": ["O", "O", "O"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [1.07, 0.65, 0.0],
- [-1.07, 0.65, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Ozone (bent, resonance structure)",
- },
- "H2O2": {
- "atoms": ["H", "O", "O", "H"],
- "coordinates": [
- [0.74, -0.54, 0.48],
- [0.0, 0.0, 0.0],
- [0.0, 0.0, 1.45],
- [-0.74, -0.54, 1.93],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Hydrogen peroxide (non-planar)",
- },
- # ========== SIMPLE ORGANIC MOLECULES ==========
- "CH4": {
- "atoms": ["C", "H", "H", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [0.63, 0.63, 0.63],
- [-0.63, -0.63, 0.63],
- [-0.63, 0.63, -0.63],
- [0.63, -0.63, -0.63],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Methane (tetrahedral)",
- },
- "NH3": {
- "atoms": ["N", "H", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [0.0, 0.94, 0.33],
- [0.81, -0.47, 0.33],
- [-0.81, -0.47, 0.33],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Ammonia (pyramidal)",
- },
- "C2H6": {
- "atoms": ["C", "C", "H", "H", "H", "H", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [1.54, 0.0, 0.0],
- [-0.51, 0.89, 0.0],
- [-0.51, -0.44, 0.89],
- [-0.51, -0.44, -0.89],
- [2.05, 0.89, 0.0],
- [2.05, -0.44, 0.89],
- [2.05, -0.44, -0.89],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Ethane (single bond)",
- },
- "C2H4": {
- "atoms": ["C", "C", "H", "H", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [1.34, 0.0, 0.0],
- [-0.51, 0.93, 0.0],
- [-0.51, -0.93, 0.0],
- [1.85, 0.93, 0.0],
- [1.85, -0.93, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Ethylene (double bond, planar)",
- },
- "C2H2": {
- "atoms": ["C", "C", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [1.20, 0.0, 0.0],
- [-1.06, 0.0, 0.0],
- [2.26, 0.0, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Acetylene (triple bond, linear)",
- },
- "CH3OH": {
- "atoms": ["C", "O", "H", "H", "H", "H"],
- "coordinates": [
- [0.66, -0.02, 0.0],
- [-0.75, 0.09, 0.0],
- [1.03, -0.54, 0.89],
- [1.03, -0.54, -0.89],
- [1.05, 0.99, 0.0],
- [-1.07, -0.83, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Methanol (alcohol)",
- },
- "CH2O": {
- "atoms": ["C", "O", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [0.0, 0.0, 1.21],
- [0.94, 0.0, -0.59],
- [-0.94, 0.0, -0.59],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Formaldehyde (carbonyl group)",
- },
- "CH3CHO": {
- "atoms": ["C", "C", "O", "H", "H", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [1.51, 0.0, 0.0],
- [2.17, 1.03, 0.0],
- [-0.36, -0.52, 0.89],
- [-0.36, -0.52, -0.89],
- [-0.39, 1.02, 0.0],
- [1.89, -1.02, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Acetaldehyde (aldehyde group)",
- },
- "CH3COOH": {
- "atoms": ["C", "C", "O", "O", "H", "H", "H", "H"],
- "coordinates": [
- [0.0, 0.0, 0.0],
- [1.51, 0.0, 0.0],
- [2.09, 1.09, 0.0],
- [2.16, -1.18, 0.0],
- [-0.36, -0.52, 0.89],
- [-0.36, -0.52, -0.89],
- [-0.39, 1.02, 0.0],
- [3.11, -1.09, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Acetic acid (carboxylic acid)",
- },
- # ========== AROMATIC MOLECULES ==========
- "C6H6": {
- "atoms": ["C", "C", "C", "C", "C", "C", "H", "H", "H", "H", "H", "H"],
- "coordinates": [
- [0.0, 1.40, 0.0],
- [1.21, 0.70, 0.0],
- [1.21, -0.70, 0.0],
- [0.0, -1.40, 0.0],
- [-1.21, -0.70, 0.0],
- [-1.21, 0.70, 0.0],
- [0.0, 2.48, 0.0],
- [2.15, 1.24, 0.0],
- [2.15, -1.24, 0.0],
- [0.0, -2.48, 0.0],
- [-2.15, -1.24, 0.0],
- [-2.15, 1.24, 0.0],
- ],
- "charge": 0,
- "multiplicity": 1,
- "description": "Benzene (aromatic, hexagonal)",
- },
-}
+# Molecule presets — bundled library (STRUCT.6).
+# The former inline literal now lives in the indexed package-data store
+# (quantui/data/library/library.sqlite, seeded from
+# quantui/data/manifests/presets.json). ``config.MOLECULE_LIBRARY`` is a lazy
+# back-compat shim resolved via module ``__getattr__`` (PEP 562): on first
+# access it loads the curated/preset entries from the store in the original
+# {formula: {atoms, coordinates, charge, multiplicity, description}} shape, so
+# every existing consumer keeps working unchanged.
+MOLECULE_LIBRARY: Dict[str, Dict[str, Any]] # populated lazily by __getattr__
+
+
+def __getattr__(name: str) -> Any:
+ if name == "MOLECULE_LIBRARY":
+ from quantui.molecule_library import get_preset_dict
+
+ return get_preset_dict()
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
# Valid atomic symbols (periodic table subset commonly used)
VALID_ATOMS = [
diff --git a/quantui/data/library/library.sqlite b/quantui/data/library/library.sqlite
new file mode 100644
index 0000000000000000000000000000000000000000..33207a68ea8c997d58b8a1697a35d94732bdd259
GIT binary patch
literal 28672
zcmeI4Yit}>6@c&T%)Y#j>sMqMV|5UM*uibdx&*1@2hL`lG1+>G>naYdYK?a%-f3oM
z%no
z@y7h95K^i$yK~Rn$DDKSch8;Od!6hfpD!CtUA|yDb*(8+vwkKB?6b1W7)!vJgflqe
za3KX5kV4n>VV4PZ-#2dO_>Y5g-CYfCvx)B0vO)
zz#RyviNwgq#)XT9Rn^yx>iV)_RcgjXyb`F>)_b=Fkr#H`*VeXdtL^t?zjak_R2*ZiY1o$B
zrKDopPBqYds;r(0!SqeuA4`lB#|7qwOyi3ttm&7vR?~L#%fX;89}liXm}V)QYB@NE
zlTR`JEq*5dkNgYybNO8E#oQy=x3ibCW0@P7Lf`eine@x)$5OARrlBDnM1Tko0U|&I
zh`|4fz~-6G6q4
zT+`RJRol|c$s#zl4x9Bwc0>Ac%#)`(C>eYDHp(Y8cZYdfo&WmKe;=lM)UhknRf
z1-@fWR5WMBUT?#9FSo$;9Eld$pYe|kOl8MXr@*4h)>2j1qi6Qs>uiV$LwR{uVHNhikd~|el-?J~HM~d2=M!XV}`(9~gRj;+H2sOA`b?nyK9lY8|C0tI2
zd9#v~b{VS=a2V2kXUavT0Iqmv#EqEbxiO1>g$ay%bgZ^1N8M173=FNpu0hwYcNDe=
z#;$i-{d{C(Bs6#2gkHNt;wMpy3D4p~db6fkwuzmt*fkrY=Su9Yei%lx&SpMznWM|yd-Tr4Ps;EdYzdx;^?Hr?=T!q;3=msf17IwhNirE5+X
z?w0^EI`DfKT9mK5-&ntrj~JXY!Po=A88zs4vec~k_NsOZ)X*v4Z~XX?RWK4!oReE
zo!H)nZ&!gU%qx2cJeKmyQ+_$f%e%{2J~T9RH0?hzrpxonwD*p<5J0cjZOhQ)iM6()
z)r~4VC+5*A960~4iJub`c1%3Bhw_bGltw0E;M0#6fwTLL7>IXA67;dDHyy2}R~`RR
z!|(mrd9n}3^q$~}GFOIK0%7Ex5G1(bGb+I}QQB3cX?tf~8-h>9i`%mZMMZ8@Nx}g6F8pUfV&rltONyfzO;Qss-=e
zW21-+#7xJoZ*I{tl
zq31aA@5Ii+urQYCfJ~tN@j(CmQ%ceF-qm0TJoVuSRBEQ}zzCoQJc=f!?@9}CgdA#`
zn^I=+b(oq9X3JFr;c1(i<4(}YK<6x~WY37FC0AzgT2um>kN!_5*{muf1W^;rZHVh6Y2Z
z9^CC%AK)40ON@V=U*T8z5?|+auJKL&J^m;DOMaXGn*Wf$!QbR>@o)2g@}K%NNe}@d
zKm>>Y5g-CYfCvx)B0vO)01+SpKp-U^5pef@I3tb;{=WE7pE&AXE9#(I4|_KQX>kP0
zRHff7f@MATFCNJI7bkK_aY%r@y(~)Dg3Dk9ZjbdP!~sE_PP^r5A%zmkldde939MFT
z<0y)%gkruRy7v@fad9B66lncV;YtLE01+SpM1Tko0U|&IhyW2F0z}~b6L6RAwElnp
z0O?L5Km>>Y5g-CYfCvx)B0vO)01+SpcL9OBYW<&oh2>x2|Kfk)zvHj+pYb1XgP-Fi
zKFvSEdA^hXTmB~eKEOBm*Z7z20`%k}5g-CYfCvx)B0vO)01+SpM1TlBz80S
zxq?J@Cf=Qqx-(*TCYBM$(}4o--~V4_e3#$iZ}Y$Nzw$rA|MtJhU+2H!FY{l(ZorS=
z7XZG`ukr8j=lInRcnDGVhyW2F0z`la5CI}U1c(3;AOb{y2>kyENFo!4F&V;S5R(B+
m`Z3{{ bytes:
+ """Pack ``(atoms, coords)`` into the compact per-atom binary record."""
+ buf = bytearray()
+ for sym, (x, y, z) in zip(atoms, coords):
+ s = sym.encode("ascii")
+ if not 1 <= len(s) <= 2:
+ raise ValueError(f"Unsupported element symbol: {sym!r}")
+ c0 = s[0]
+ c1 = s[1] if len(s) > 1 else 0
+ buf += struct.pack(
+ _ATOM_RECORD,
+ c0,
+ c1,
+ round(float(x) * _COORD_SCALE),
+ round(float(y) * _COORD_SCALE),
+ round(float(z) * _COORD_SCALE),
+ )
+ return bytes(buf)
+
+
+def decode_coords(blob: bytes) -> Tuple[List[str], List[List[float]]]:
+ """Unpack a coordinate blob back into ``(atoms, coords)``."""
+ atoms: List[str] = []
+ coords: List[List[float]] = []
+ for offset in range(0, len(blob), _ATOM_RECORD_SIZE):
+ c0, c1, xi, yi, zi = struct.unpack(
+ _ATOM_RECORD, blob[offset : offset + _ATOM_RECORD_SIZE]
+ )
+ sym = chr(c0) + (chr(c1) if c1 else "")
+ atoms.append(sym)
+ coords.append([xi / _COORD_SCALE, yi / _COORD_SCALE, zi / _COORD_SCALE])
+ return atoms, coords
+
+
+# ── Paths ────────────────────────────────────────────────────────────────────
+def _data_dir() -> Path:
+ return Path(str(files("quantui"))) / "data"
+
+
+def db_path() -> Path:
+ return _data_dir() / "library" / "library.sqlite"
+
+
+def manifest_paths() -> List[Path]:
+ """All manifest JSON files that seed the store (STRUCT.7/.8 add more)."""
+ manifest_dir = _data_dir() / "manifests"
+ if not manifest_dir.is_dir():
+ return []
+ return sorted(manifest_dir.glob("*.json"))
+
+
+# ── Manifest loading (always-available fallback source) ──────────────────────
+@lru_cache(maxsize=1)
+def _manifest_entries() -> Tuple[Dict[str, Any], ...]:
+ """Load every manifest entry. Cached. The JSON is the source of record."""
+ entries: List[Dict[str, Any]] = []
+ for path in manifest_paths():
+ try:
+ entries.extend(json.loads(path.read_text(encoding="utf-8")))
+ except Exception as exc: # pragma: no cover - corrupt manifest
+ logger.error(f"Failed to read library manifest {path}: {exc}")
+ return tuple(entries)
+
+
+def _normalize_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
+ """Fill derived fields (id/formula/n_atoms/...) for a manifest entry."""
+ atoms = raw["atoms"]
+ formula = raw.get("formula") or raw.get("id") or raw["name"]
+ return {
+ "id": raw.get("id", formula),
+ "name": raw.get("name", formula),
+ "formula": formula,
+ "category": raw.get("category", "preset"),
+ "n_atoms": len(atoms),
+ "n_heavy": sum(1 for a in atoms if a != "H"),
+ "charge": int(raw.get("charge", 0)),
+ "multiplicity": int(raw.get("multiplicity", 1)),
+ "source": raw.get("source", "preset"),
+ "smiles": raw.get("smiles"),
+ "inchikey": raw.get("inchikey"),
+ "synonyms": raw.get("synonyms", ""),
+ "description": raw.get("description", ""),
+ "atoms": atoms,
+ "coordinates": raw["coordinates"],
+ }
+
+
+# ── Store build (run by scripts/build_library.py or on-demand) ───────────────
+_SCHEMA = """
+CREATE TABLE molecule (
+ id TEXT PRIMARY KEY,
+ name TEXT,
+ formula TEXT NOT NULL,
+ category TEXT NOT NULL,
+ n_heavy INTEGER NOT NULL,
+ n_atoms INTEGER NOT NULL,
+ charge INTEGER NOT NULL DEFAULT 0,
+ multiplicity INTEGER NOT NULL DEFAULT 1,
+ source TEXT NOT NULL,
+ smiles TEXT,
+ inchikey TEXT,
+ synonyms TEXT,
+ description TEXT,
+ coords BLOB NOT NULL
+);
+CREATE INDEX idx_name ON molecule(name);
+CREATE INDEX idx_formula ON molecule(formula);
+CREATE INDEX idx_category ON molecule(category);
+CREATE INDEX idx_inchikey ON molecule(inchikey);
+"""
+
+
+def build_store(entries: List[Dict[str, Any]], target: Path) -> Path:
+ """(Re)build the SQLite store from a list of (raw) manifest entries.
+
+ Deterministic: entries are inserted in the order given, so a rebuild from
+ the same manifest yields a stable file. Overwrites ``target``.
+ """
+ target.parent.mkdir(parents=True, exist_ok=True)
+ if target.exists():
+ target.unlink()
+ con = sqlite3.connect(target)
+ try:
+ con.executescript(_SCHEMA)
+ rows = []
+ for raw in entries:
+ e = _normalize_entry(raw)
+ rows.append(
+ (
+ e["id"],
+ e["name"],
+ e["formula"],
+ e["category"],
+ e["n_heavy"],
+ e["n_atoms"],
+ e["charge"],
+ e["multiplicity"],
+ e["source"],
+ e["smiles"],
+ e["inchikey"],
+ e["synonyms"],
+ e["description"],
+ encode_coords(e["atoms"], e["coordinates"]),
+ )
+ )
+ con.executemany(
+ "INSERT INTO molecule (id, name, formula, category, n_heavy, n_atoms, "
+ "charge, multiplicity, source, smiles, inchikey, synonyms, description, "
+ "coords) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ rows,
+ )
+ con.commit()
+ finally:
+ con.close()
+ logger.info(f"Built molecule library store: {target} ({len(entries)} entries)")
+ return target
+
+
+def build_from_manifests(target: Optional[Path] = None) -> Path:
+ """Build the store from all committed manifests. Returns the store path."""
+ target = target or db_path()
+ return build_store([dict(e) for e in _manifest_entries()], target)
+
+
+def _ensure_store() -> Optional[Path]:
+ """Best-effort: ensure the SQLite store exists. Returns its path or None.
+
+ Never raises — if the package dir is read-only (installed wheel) and the
+ store is somehow absent, callers fall back to the JSON manifest.
+ """
+ path = db_path()
+ if path.exists():
+ return path
+ try:
+ if _manifest_entries():
+ return build_from_manifests(path)
+ except Exception as exc: # pragma: no cover - read-only install w/o store
+ logger.warning(f"Could not build library store on demand: {exc}")
+ return None
+
+
+# ── Query API ────────────────────────────────────────────────────────────────
+def _connect_ro() -> Optional[sqlite3.Connection]:
+ """Open a fresh read-only connection (thread-safe per-call), or None."""
+ path = _ensure_store()
+ if path is None or not path.exists():
+ return None
+ con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
+ con.row_factory = sqlite3.Row
+ return con
+
+
+def _row_to_entry(row: sqlite3.Row) -> Dict[str, Any]:
+ atoms, coords = decode_coords(row["coords"])
+ return {
+ "id": row["id"],
+ "name": row["name"],
+ "formula": row["formula"],
+ "category": row["category"],
+ "n_heavy": row["n_heavy"],
+ "n_atoms": row["n_atoms"],
+ "charge": row["charge"],
+ "multiplicity": row["multiplicity"],
+ "source": row["source"],
+ "smiles": row["smiles"],
+ "inchikey": row["inchikey"],
+ "synonyms": row["synonyms"],
+ "description": row["description"],
+ "atoms": atoms,
+ "coordinates": coords,
+ }
+
+
+@lru_cache(maxsize=1)
+def get_preset_dict() -> Dict[str, Dict[str, Any]]:
+ """Return the curated/preset entries in the legacy ``MOLECULE_LIBRARY`` shape.
+
+ Keyed by entry id (the formula key, e.g. ``"H2O"``); each value carries the
+ legacy ``atoms`` / ``coordinates`` / ``charge`` / ``multiplicity`` /
+ ``description`` fields. Bulk categories are excluded (they are reached via
+ :func:`search`, never the browse dropdown). Preserves manifest order.
+
+ Prefers the SQLite store; falls back to the JSON manifest if the store is
+ unavailable, so this never hard-fails.
+ """
+ out: Dict[str, Dict[str, Any]] = {}
+ con = _connect_ro()
+ if con is not None:
+ try:
+ placeholders = ",".join("?" * len(_BULK_CATEGORIES))
+ rows = con.execute(
+ f"SELECT * FROM molecule WHERE category NOT IN ({placeholders}) "
+ "ORDER BY rowid",
+ tuple(_BULK_CATEGORIES),
+ ).fetchall()
+ for row in rows:
+ e = _row_to_entry(row)
+ out[e["id"]] = _legacy_view(e)
+ return out
+ except Exception as exc: # pragma: no cover - corrupt store
+ logger.warning(f"Library store unreadable, using manifest: {exc}")
+ finally:
+ con.close()
+ # JSON fallback.
+ for raw in _manifest_entries():
+ e = _normalize_entry(raw)
+ if e["category"] in _BULK_CATEGORIES:
+ continue
+ out[e["id"]] = _legacy_view(e)
+ return out
+
+
+def _legacy_view(entry: Dict[str, Any]) -> Dict[str, Any]:
+ """Project a full entry down to the legacy MOLECULE_LIBRARY value shape."""
+ return {
+ "atoms": entry["atoms"],
+ "coordinates": entry["coordinates"],
+ "charge": entry["charge"],
+ "multiplicity": entry["multiplicity"],
+ "description": entry["description"],
+ }
+
+
+def get(entry_id: str) -> Optional[Dict[str, Any]]:
+ """Fetch one full entry (incl. decoded coordinates) by id, or None."""
+ con = _connect_ro()
+ if con is not None:
+ try:
+ row = con.execute(
+ "SELECT * FROM molecule WHERE id = ?", (entry_id,)
+ ).fetchone()
+ return _row_to_entry(row) if row else None
+ finally:
+ con.close()
+ for raw in _manifest_entries():
+ e = _normalize_entry(raw)
+ if e["id"] == entry_id:
+ return e
+ return None
+
+
+def search(
+ query: str = "", *, category: Optional[str] = None, limit: int = 50
+) -> List[Dict[str, Any]]:
+ """Search the library by name / formula / synonyms (substring, case-insensitive).
+
+ Returns lightweight rows (no coordinates) for listing; call :func:`get` to
+ materialize the chosen entry. Empty ``query`` lists entries (optionally
+ filtered by ``category``).
+ """
+ q = f"%{query.strip().lower()}%"
+ con = _connect_ro()
+ if con is not None:
+ try:
+ sql = (
+ "SELECT id, name, formula, category, n_heavy, n_atoms, charge, "
+ "multiplicity, source FROM molecule WHERE "
+ "(lower(name) LIKE ? OR lower(formula) LIKE ? OR lower(synonyms) LIKE ?)"
+ )
+ params: List[Any] = [q, q, q]
+ if category:
+ sql += " AND category = ?"
+ params.append(category)
+ sql += " ORDER BY n_atoms, id LIMIT ?"
+ params.append(limit)
+ return [dict(r) for r in con.execute(sql, params).fetchall()]
+ finally:
+ con.close()
+ # JSON fallback (linear scan).
+ needle = query.strip().lower()
+ results = []
+ for raw in _manifest_entries():
+ e = _normalize_entry(raw)
+ hay = f"{e['name']} {e['formula']} {e['synonyms']}".lower()
+ if needle in hay and (category is None or e["category"] == category):
+ results.append(
+ {
+ k: e[k]
+ for k in (
+ "id",
+ "name",
+ "formula",
+ "category",
+ "n_heavy",
+ "n_atoms",
+ "charge",
+ "multiplicity",
+ "source",
+ )
+ }
+ )
+ results.sort(key=lambda r: (r["n_atoms"], r["id"]))
+ return results[:limit]
+
+
+def categories() -> List[str]:
+ """Distinct category labels present in the library."""
+ con = _connect_ro()
+ if con is not None:
+ try:
+ return [
+ r[0]
+ for r in con.execute(
+ "SELECT DISTINCT category FROM molecule ORDER BY category"
+ ).fetchall()
+ ]
+ finally:
+ con.close()
+ return sorted({_normalize_entry(e)["category"] for e in _manifest_entries()})
+
+
+def count() -> int:
+ """Total number of entries in the library."""
+ con = _connect_ro()
+ if con is not None:
+ try:
+ return int(con.execute("SELECT COUNT(*) FROM molecule").fetchone()[0])
+ finally:
+ con.close()
+ return len(_manifest_entries())
diff --git a/scripts/build_library.py b/scripts/build_library.py
new file mode 100644
index 0000000..0c8ae23
--- /dev/null
+++ b/scripts/build_library.py
@@ -0,0 +1,32 @@
+"""Rebuild the bundled molecule-library SQLite store from the committed manifests.
+
+Usage:
+ python scripts/build_library.py
+
+Reads every ``quantui/data/manifests/*.json`` and writes
+``quantui/data/library/library.sqlite`` (deterministic, overwrites). Prints the
+final entry count + on-disk size and asserts the 10 MB budget (STRUCT.10).
+
+When STRUCT.7 (curated set) and STRUCT.8 (bulk QM9 subset) land, they add more
+manifests; this tool is unchanged.
+"""
+
+from quantui import molecule_library as ml
+
+_BUDGET_BYTES = 10 * 1024 * 1024 # 10 MB (DEC-015)
+
+
+def main() -> None:
+ path = ml.build_from_manifests()
+ size = path.stat().st_size
+ n = ml.count()
+ print(f"Built {path} — {n} entries, {size / 1024:.1f} KiB")
+ print(f"Categories: {', '.join(ml.categories())}")
+ if size > _BUDGET_BYTES:
+ raise SystemExit(
+ f"Library store {size / 1024 / 1024:.2f} MB exceeds the 10 MB budget"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_molecule_library.py b/tests/test_molecule_library.py
new file mode 100644
index 0000000..7b651f4
--- /dev/null
+++ b/tests/test_molecule_library.py
@@ -0,0 +1,135 @@
+"""Tests for the bundled molecule-library store + loader (M-STRUCT STRUCT.6).
+
+Platform-independent (stdlib sqlite3 + struct + json); no RDKit, no PySCF.
+"""
+
+import math
+
+import pytest
+
+from quantui import config
+from quantui import molecule_library as ml
+
+_BUDGET_BYTES = 10 * 1024 * 1024 # DEC-015
+
+
+# ============================================================================
+# Coordinate codec
+# ============================================================================
+
+
+class TestCoordCodec:
+ def test_round_trip_exact_for_3_decimal_coords(self):
+ atoms = ["O", "H", "H", "Cl"]
+ coords = [
+ [0.0, 0.0, 0.0],
+ [0.0, 0.757, 0.587],
+ [0.0, -0.757, 0.587],
+ [1.27, 0.0, -2.48],
+ ]
+ blob = ml.encode_coords(atoms, coords)
+ out_atoms, out_coords = ml.decode_coords(blob)
+ assert out_atoms == atoms
+ for a, b in zip(coords, out_coords):
+ for ca, cb in zip(a, b):
+ assert math.isclose(ca, cb, abs_tol=1e-9)
+
+ def test_record_size_is_8_bytes_per_atom(self):
+ blob = ml.encode_coords(["C", "H"], [[0, 0, 0], [1, 1, 1]])
+ assert len(blob) == 2 * 8
+
+ def test_two_char_symbol_round_trips(self):
+ atoms, coords = ml.decode_coords(
+ ml.encode_coords(["Cl", "Na"], [[0, 0, 0], [2, 0, 0]])
+ )
+ assert atoms == ["Cl", "Na"]
+
+ def test_rejects_long_symbol(self):
+ with pytest.raises(ValueError):
+ ml.encode_coords(["Uuo"], [[0, 0, 0]])
+
+
+# ============================================================================
+# Store build + query API
+# ============================================================================
+
+
+class TestStoreBuildAndQuery:
+ def test_build_from_manifest_round_trips(self, tmp_path):
+ target = tmp_path / "lib.sqlite"
+ ml.build_from_manifests(target)
+ assert target.exists()
+
+ def test_committed_store_has_20_presets(self):
+ assert ml.count() == 20
+
+ def test_categories_present(self):
+ cats = ml.categories()
+ assert {"diatomic", "triatomic", "small-organic", "aromatic"} <= set(cats)
+
+ def test_get_returns_full_entry_with_coords(self):
+ entry = ml.get("H2O")
+ assert entry is not None
+ assert entry["atoms"] == ["O", "H", "H"]
+ assert entry["n_heavy"] == 1
+ assert entry["coordinates"][1] == [0.0, 0.757, 0.587]
+
+ def test_get_unknown_returns_none(self):
+ assert ml.get("NOTAREALID") is None
+
+ def test_search_by_formula(self):
+ hits = ml.search("C6H6")
+ assert any(h["id"] == "C6H6" for h in hits)
+
+ def test_search_by_category_filter(self):
+ hits = ml.search("", category="aromatic")
+ assert [h["id"] for h in hits] == ["C6H6"]
+
+ def test_search_returns_lightweight_rows(self):
+ hits = ml.search("H2O")
+ assert hits and "coordinates" not in hits[0]
+
+
+# ============================================================================
+# Legacy back-compat shim
+# ============================================================================
+
+
+class TestPresetDictBackCompat:
+ def test_get_preset_dict_shape(self):
+ d = ml.get_preset_dict()
+ assert len(d) == 20
+ h2o = d["H2O"]
+ assert set(h2o) == {
+ "atoms",
+ "coordinates",
+ "charge",
+ "multiplicity",
+ "description",
+ }
+
+ def test_config_shim_resolves(self):
+ lib = config.MOLECULE_LIBRARY
+ assert len(lib) == 20
+ assert lib["O2"]["multiplicity"] == 3 # triplet preserved
+ assert lib["H2O"]["coordinates"][2] == [0.0, -0.757, 0.587]
+
+ def test_config_shim_unknown_attr_raises(self):
+ with pytest.raises(AttributeError):
+ _ = config.THIS_DOES_NOT_EXIST
+
+ def test_preset_dict_excludes_bulk_categories(self):
+ # No bulk entries yet, but the contract must hold for STRUCT.8.
+ for entry_id in ml.get_preset_dict():
+ full = ml.get(entry_id)
+ assert full["category"] not in ml._BULK_CATEGORIES
+
+
+# ============================================================================
+# Size governance (STRUCT.10 precursor)
+# ============================================================================
+
+
+class TestSizeBudget:
+ def test_committed_store_within_10mb(self):
+ assert ml.db_path().stat().st_size <= _BUDGET_BYTES
From 6ec51f2ec36d2f2f7a5bbf1bceff50e60b472bd9 Mon Sep 17 00:00:00 2001
From: NCCU-Schultz-Lab
Date: Sun, 7 Jun 2026 18:46:18 -0400
Subject: [PATCH 04/16] Add bundled curated molecule library
Introduce a bundled curated molecule library and integrate it into the app. Changes include:
- Add a large curated manifest (quantui/data/manifests/curated.json) plus curated seed and build script (scripts/curated_seed.json, scripts/build_curated_library.py) and tests (tests/test_curated_library.py).
- Update the packaged library database (quantui/data/library/library.sqlite) and adjust molecule library code/tests (quantui/molecule_library.py, tests/test_molecule_library.py) to use the curated content.
- Add configuration limits to keep bundled content classroom-friendly: LIBRARY_SIZE_BUDGET_BYTES = 10 * 1024 * 1024, LIBRARY_HEAVY_ATOM_CEILING_CURATED = 30, LIBRARY_HEAVY_ATOM_CEILING_BULK = 9.
- Small UI copy change in build_molecule_section to reference the bundled library of curated molecules.
These changes add curated molecule data, enforce size/atom ceilings for usability, and provide tooling/tests to build and validate the bundled library.
---
quantui/app_builders.py | 4 +-
quantui/config.py | 6 +
quantui/data/library/library.sqlite | Bin 28672 -> 81920 bytes
quantui/data/manifests/curated.json | 15482 ++++++++++++++++++++++++++
quantui/molecule_library.py | 11 +-
scripts/build_curated_library.py | 124 +
scripts/curated_seed.json | 166 +
tests/test_curated_library.py | 121 +
tests/test_molecule_library.py | 33 +-
9 files changed, 15940 insertions(+), 7 deletions(-)
create mode 100644 quantui/data/manifests/curated.json
create mode 100644 scripts/build_curated_library.py
create mode 100644 scripts/curated_seed.json
create mode 100644 tests/test_curated_library.py
diff --git a/quantui/app_builders.py b/quantui/app_builders.py
index 8182147..7ed3868 100644
--- a/quantui/app_builders.py
+++ b/quantui/app_builders.py
@@ -1026,7 +1026,9 @@ def build_molecule_section(
hint = ''
tab_preset = widgets.VBox(
[
- widgets.HTML(hint + "Choose from 20+ curated educational molecules.
"),
+ widgets.HTML(
+ hint + "Choose from the bundled library of curated molecules.
"
+ ),
app.preset_dd,
]
)
diff --git a/quantui/config.py b/quantui/config.py
index eb77082..5033199 100644
--- a/quantui/config.py
+++ b/quantui/config.py
@@ -245,6 +245,12 @@
# (resolves name / CAS / InChI / SMILES → 3D SDF; no API key).
CACTUS_TIMEOUT_S: float = 15.0
+# Bundled-library size budget + heavy-atom ceilings (M-STRUCT STRUCT.7/.8/.10).
+# These are QC *starting* geometries, so keep them runnable in a classroom.
+LIBRARY_SIZE_BUDGET_BYTES: int = 10 * 1024 * 1024 # 10 MB (DEC-015)
+LIBRARY_HEAVY_ATOM_CEILING_CURATED: int = 30 # named drugs run a bit larger
+LIBRARY_HEAVY_ATOM_CEILING_BULK: int = 9 # QM9 caps here anyway
+
# Molecule presets — bundled library (STRUCT.6).
# The former inline literal now lives in the indexed package-data store
# (quantui/data/library/library.sqlite, seeded from
diff --git a/quantui/data/library/library.sqlite b/quantui/data/library/library.sqlite
index 33207a68ea8c997d58b8a1697a35d94732bdd259..d70e657dd59f53f85f469e692b844662832cc0a5 100644
GIT binary patch
literal 81920
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