Source code for vibesketch.naming

"""Names for drawn species, through vibe-qc's naming engine when it is there.

``vibeqc_naming`` is an optional standalone distribution. Drawn bonds and
RDKit-assigned CIP descriptors are passed directly to its graph API.

The engine's names are checked before they are shown (vibe-qc #539 lists
high-confidence wrong names). A name is mapped back to a structure and
compared with the drawing by the connectivity block of the InChIKey, in two
ways, because each of the engine's back-mappings has faults of its own:

1. the engine's 3D structure for the name (``structure_from_name``), with
   bonds perceived by RDKit (``rdDetermineBonds.DetermineBonds``, RDKit's
   implementation of xyz2mol: Kim and Kim, *Bull. Korean Chem. Soc.* 2015,
   36, 1769);
2. the engine's SMILES for the name (``smiles_from_name``), which mangles
   carbonyl groups (vibe-qc #539).

The outcome:

* **verified**: either mapping gives the drawn constitution;
* **rejected**: the 3D structure gives a different, valid molecule, so the
  name describes something else and is not shown;
* **unverified**: no mapping could check it (only a SMILES mismatch, or none
  at all), so it is shown as a suggestion with that caveat;
* **not-applicable**: an ion or a radical, which the engine cannot name yet
  (it takes no charge or spin);
* **unavailable** or **failed**: no engine, or no 3D structure to give it.

Stereo-defined drawings require a full stereochemical identity match to
verify a name; a constitution-only match remains an unverified suggestion.
"""

from __future__ import annotations

from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Literal

__all__ = [
    "NameResult",
    "NamingUnavailable",
    "install_hint",
    "name_for",
    "naming_available",
    "smiles_for_name",
]

Status = Literal["verified", "unverified", "rejected", "not-applicable", "unavailable", "failed"]


[docs] class NamingUnavailable(RuntimeError): """``vibeqc_naming`` cannot be imported.""" def __init__(self) -> None: super().__init__(install_hint())
[docs] def install_hint() -> str: from vibesketch.install_hints import not_in_app, self_contained if self_contained(): return not_in_app("Naming", "vibe-qc's naming engine") return ( "naming needs the standalone vibeqc-naming package. Install with " "`python -m pip install --find-links " "https://vibe-qc.com/updates/vibeqc-naming/0.1.1/index.html " "'vibeqc-naming>=0.1.1,<0.2'`; no vibe-qc core is needed. " "A source checkout remains available through --naming-path." )
[docs] @dataclass(frozen=True) class NameResult: status: Status value: str | None = None source: str | None = None confidence: str | None = None note: str | None = None
[docs] @property def shown(self) -> bool: """Whether the name may be shown to the user at all.""" return self.status in ("verified", "unverified") and self.value is not None
def _engine() -> Any: try: import vibeqc_naming except ImportError as error: raise NamingUnavailable() from error return vibeqc_naming
[docs] def naming_available() -> bool: try: _engine() except NamingUnavailable: return False return True
def _connectivity(inchikey: str | None) -> str | None: return inchikey.split("-")[0] if inchikey else None def _drawn_graph(engine: Any, smiles: str) -> Any: """Transmit explicit H/bonds and CIP labels; never infer bonds from geometry.""" from rdkit import Chem from rdkit.Chem import rdCIPLabeler from vibesketch.chem.species import mol_from_smiles mol = Chem.AddHs(mol_from_smiles(smiles)) # RDKit's CIP implementation supplies absolute descriptors; graph indices # remain stable through Kekulization (IUPAC Blue Book 2013, P-9). rdCIPLabeler.AssignCIPLabels(mol) atom_stereo = { atom.GetIdx(): atom.GetProp("_CIPCode") for atom in mol.GetAtoms() if atom.HasProp("_CIPCode") } bond_stereo = { (bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()): bond.GetProp("_CIPCode") for bond in mol.GetBonds() if bond.HasProp("_CIPCode") } Chem.Kekulize(mol, clearAromaticFlags=True) return engine.MolecularGraph.from_bonds( [atom.GetAtomicNum() for atom in mol.GetAtoms()], [ (b.GetBeginAtomIdx(), b.GetEndAtomIdx(), int(b.GetBondTypeAsDouble())) for b in mol.GetBonds() ], atom_stereo=atom_stereo, bond_stereo=bond_stereo, )
[docs] @lru_cache(maxsize=512) def name_for(smiles: str, charge: int = 0, multiplicity: int = 1) -> NameResult: """The checked name of the species with this SMILES, charge and multiplicity.""" from vibesketch.chem.species import identity, mol_from_smiles if charge != 0 or multiplicity != 1: return NameResult( "not-applicable", note="the naming engine cannot name ions or radicals yet (vibe-qc #539)", ) try: engine = _engine() except NamingUnavailable as error: return NameResult("unavailable", note=str(error)) drawn = identity(mol_from_smiles(smiles)) try: result = engine.name_from_graph_detailed(_drawn_graph(engine, smiles)) except Exception as error: # the engine is another project's code; report, do not crash return NameResult("failed", note=f"the naming engine failed: {error}") name = str(result.name).strip() source = f"vibeqc_naming:{getattr(result.source, 'value', result.source)}" confidence = str(getattr(result.confidence, "value", result.confidence)) if not name: return NameResult("failed", note="the naming engine returned no name") target = _connectivity(drawn.inchikey) from_3d = _key_from_structure(engine, name) from_smiles = _key_from_smiles(engine, name) if drawn.has_stereo: if drawn.inchikey is not None and drawn.inchikey in (from_3d, from_smiles): return NameResult("verified", name, source, confidence) if target is not None and target in (_connectivity(from_3d), _connectivity(from_smiles)): return NameResult( "unverified", name, source, confidence, "the name matches connectivity but its stereodescriptors are not verified", ) elif target is not None and target in (_connectivity(from_3d), _connectivity(from_smiles)): return NameResult("verified", name, source, confidence) if from_3d is not None: return NameResult( "rejected", name, source, confidence, f"the engine's structure for {name!r} is a different molecule", ) return NameResult( "unverified", name, source, confidence, "the engine cannot map this name back to a structure that checks it", )
def _key_from_structure(engine: Any, name: str) -> str | None: """InChIKey of the engine's 3D structure for ``name``, bonds perceived by RDKit.""" from rdkit import Chem, rdBase from rdkit.Chem import inchi as inchi_module from rdkit.Chem import rdDetermineBonds from vibesketch.chem._rdkit import untyped inchi = untyped(inchi_module) try: atoms = engine.structure_from_name(name) except Exception: return None if not atoms: return None table = Chem.GetPeriodicTable() lines = [str(len(atoms)), name] lines += [f"{table.GetElementSymbol(int(z))} {x} {y} {w}" for z, x, y, w in atoms] blocker = rdBase.BlockLogs() try: mol = Chem.MolFromXYZBlock("\n".join(lines)) rdDetermineBonds.DetermineBonds(mol, charge=0) key = str(inchi.MolToInchiKey(Chem.RemoveHs(mol))) or None except Exception: key = None del blocker return key def _key_from_smiles(engine: Any, name: str) -> str | None: """InChIKey of the engine's SMILES for ``name``, if RDKit can read it.""" from vibesketch.chem.species import identity, mol_from_smiles try: smiles = engine.smiles_from_name(name) return identity(mol_from_smiles(str(smiles))).inchikey if smiles else None except Exception: return None
[docs] def smiles_for_name(name: str) -> str | None: """The SMILES the engine knows for ``name``, or ``None``; raises :class:`NamingUnavailable`.""" engine = _engine() try: smiles = engine.smiles_from_name(name.strip()) except Exception: return None return str(smiles) if smiles else None