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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 122 additions & 19 deletions src/openclatura/assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,92 @@
("1-thiacyclohexan-", "thian-"),
)

_LEGACY_TERT_BUTYL = {"2-methylpropan-2-yl", "1,1-dimethylethyl"}
_LEGACY_SUBST_ALKYL_ACYL = {"ethylcarbonyl", "propylcarbonyl"}
_LEGACY_LEADING_HYPHEN = {
"1-azacyclobutane",
"1-azacyclopentane",
"1-azacyclohexane",
"1-oxacyclopentane",
"1-oxacyclohexane",
"1-thiacyclopentane",
"1-thiacyclohexane",
}


def _compile_legacy_replacements() -> tuple[tuple[str, re.Pattern, str], ...]:
"""Precompile the literal-replacement patterns once (they are name-independent).

Each entry keeps its literal so callers can skip the regex entirely when the literal
is absent -- every pattern requires it as a substring, so the sub would be a no-op.
"""

compiled: list[tuple[str, re.Pattern, str]] = []
for old, new in LEGACY_POSTPROCESS_LITERAL_REPLACEMENTS:
esc = re.escape(old)
if old in _LEGACY_TERT_BUTYL:
compiled.append((old, re.compile(rf"(?<![a-zA-Z0-9\-,]){esc}(?![a-zA-Z])"), new))
elif old in _LEGACY_SUBST_ALKYL_ACYL:
compiled.append((old, re.compile(rf"(?<![a-zA-Z)]){esc}(?![a-zA-Z])"), new))
else:
if old in _LEGACY_LEADING_HYPHEN:
compiled.append((old, re.compile(rf"-{esc}(?![a-zA-Z])"), new))
compiled.append((old, re.compile(rf"(?<![a-zA-Z]){esc}(?![a-zA-Z])"), new))
return tuple(compiled)


_LEGACY_COMPILED_REPLACEMENTS = _compile_legacy_replacements()


def _balanced_group_end(name: str, start: int) -> int | None:
"""If ``name[start]`` opens a parenthesis, return the index just past its match."""

if start >= len(name) or name[start] != "(":
return None
depth = 0
for i in range(start, len(name)):
if name[i] == "(":
depth += 1
elif name[i] == ")":
depth -= 1
if depth == 0:
return i + 1
return None


def _oxo_group_methyl_to_carbonyl(name: str) -> str:
"""Contract a one-carbon ``methyl`` parent bearing ``(oxo)`` and one parenthesized
group into ``(group)carbonyl``, for either substituent order.

Paren-aware replacement for two regexes that mis-matched the inner ``)methyl`` of a
group ending in a substituent methyl (e.g. heteroaryl-methyl), which corrupted names
like ``(oxo)(1-((thiophen-2-yl)methyl)cyclohexyl)methyl``.
"""

def _word_char(idx: int) -> bool:
return idx < len(name) and (name[idx].isalnum() or name[idx] == "_")

out: list[str] = []
i = 0
while i < len(name):
# (oxo)(GROUP)methyl
if name.startswith("(oxo)", i):
g_end = _balanced_group_end(name, i + 5)
if g_end is not None and name.startswith("methyl", g_end) and not _word_char(g_end + 6):
out.append(name[i + 5 : g_end] + "carbonyl")
i = g_end + 6
continue
# (GROUP)(oxo)methyl
if name[i] == "(":
g_end = _balanced_group_end(name, i)
if g_end is not None and name.startswith("(oxo)methyl", g_end) and not _word_char(g_end + 11):
out.append(name[i:g_end] + "carbonyl")
i = g_end + 11
continue
out.append(name[i])
i += 1
return "".join(out)


def _post_process_name(name: str) -> str:
name = apply_data_postprocessing(name)
Expand All @@ -170,8 +256,7 @@ def _post_process_name(name: str) -> str:

name = re.sub(r"-1-formate\b", "-formate", name)

name = re.sub(r"\((.*?)\)\((?<!thi)oxo\)methyl\b", r"(\1)carbonyl", name)
name = re.sub(r"\((?<!thi)oxo\)\((.*?)\)methyl\b", r"(\1)carbonyl", name)
name = _oxo_group_methyl_to_carbonyl(name)
name = name.replace("(oxo)methyl", "formyl")
name = re.sub(r"(?<!thi)oxomethyl\b", "formyl", name)
name = name.replace("thioxomethyl", "carbonothioyl")
Expand Down Expand Up @@ -202,21 +287,10 @@ def _post_process_name(name: str) -> str:
name = re.sub(r"\b1-([a-zA-Z0-9\-\[\]\(\)\,]+?)aminomethanenitrile\b", r"\1cyanamide", name)
name = name.replace("aminomethanenitrile", "cyanamide")

for old, new in LEGACY_POSTPROCESS_LITERAL_REPLACEMENTS:
if old in ["2-methylpropan-2-yl", "1,1-dimethylethyl"]:
name = re.sub(rf"(?<![a-zA-Z0-9\-,]){re.escape(old)}(?![a-zA-Z])", new, name)
else:
if old in [
"1-azacyclobutane",
"1-azacyclopentane",
"1-azacyclohexane",
"1-oxacyclopentane",
"1-oxacyclohexane",
"1-thiacyclopentane",
"1-thiacyclohexane",
]:
name = re.sub(rf"-{re.escape(old)}(?![a-zA-Z])", new, name)
name = re.sub(rf"(?<![a-zA-Z]){re.escape(old)}(?![a-zA-Z])", new, name)
# Substituted-alkyl acyl contractions (ethyl/propyl) renumber the chain, so their
# patterns exclude a leading group paren; see _compile_legacy_replacements.
for _literal, pattern, new in _LEGACY_COMPILED_REPLACEMENTS:
name = pattern.sub(new, name)

name = apply_data_postprocessing(name)

Expand Down Expand Up @@ -326,11 +400,40 @@ def _add_relative_stereo_prefix(parts: AssemblyParts, final_word: str) -> str:
return "".join(f"{prefix}-" for prefix in prefixes) + final_word


def _front_modifier_sort_key(name: str) -> str:
"""Alphanumeric ordering key: ignore the italic ``tert-``/``sec-`` prefixes."""

key = name
for prefix in ("tert-", "sec-"):
if key.startswith(prefix):
key = key[len(prefix) :]
break
return key.lstrip("([").lower()


def _add_front_modifiers(parts: AssemblyParts, final_word: str) -> str:
if not parts.front_modifiers:
return final_word
counts = {}
for mod in parts.front_modifiers:
mods = parts.front_modifiers
locants = parts.front_modifier_locants
have_locants = len(locants) == len(mods) and all(loc is not None for loc in locants)
# Unsymmetrical polyacid esters need locants so each alkyl pairs with its own
# carboxyl (e.g. 1-tert-butyl 2-methyl ...dicarboxylate); a single ester or a
# symmetrical one keeps the simpler unlocanted (multiplied) form.
if have_locants and len(set(mods)) > 1:
by_name: dict[str, list[str]] = {}
for mod, loc in zip(mods, locants):
by_name.setdefault(mod, []).append(loc)
entries = []
for name in sorted(by_name, key=_front_modifier_sort_key):
group_locants = sorted(by_name[name], key=lambda loc: (len(loc), loc))
locant_str = ",".join(group_locants)
count = len(group_locants)
rendered = format_multiplier(name, count, safe_enclose=True) if count > 1 else name
entries.append(f"{locant_str}-{rendered}")
return f"{' '.join(entries)} {final_word}"
counts: dict[str, int] = {}
for mod in mods:
counts[mod] = counts.get(mod, 0) + 1
front_words = [format_multiplier(m, c, safe_enclose=True) if c > 1 else m for m, c in sorted(counts.items())]
return f"{' '.join(front_words)} {final_word}"
Expand Down
1 change: 1 addition & 0 deletions src/openclatura/assembly_parts.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class AssemblyParts:
retained_name: str | None = None
retained_parent_metadata: RetainedParentMetadata | None = None
front_modifiers: list[str] = field(default_factory=list)
front_modifier_locants: list[str | None] = field(default_factory=list)
front_modifier_atom_ids: set[int] = field(default_factory=set)
front_modifier_charge_atom_ids: set[int] = field(default_factory=set)
principal_suffix_modifiers: list[SubstituentItem] = field(default_factory=list)
Expand Down
51 changes: 23 additions & 28 deletions src/openclatura/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,16 +403,15 @@ def _shortest_component_path(start: int, end: int, component: set[int], adj: dic
return [start]


def get_cyclic_atoms(mol: Molecule, exclude_atoms: set[int] = None) -> set[int]:
if exclude_atoms is None:
exclude_atoms = set()
valid_nodes = {a.idx for a in mol if a.idx not in exclude_atoms and (a.is_carbon or mol.degree(a.idx) >= 2)}
cyclic = set()
def _cyclic_atoms_within(mol: Molecule, valid_nodes: set[int]) -> set[int]:
"""Atoms lying on a cycle of the subgraph induced by ``valid_nodes``."""

cyclic = set()
for n in valid_nodes:
neighbors = [x for x in mol.get_neighbors(n) if x in valid_nodes]
in_cycle = False
for nxt in neighbors:
for nxt in mol.get_neighbors(n):
if nxt not in valid_nodes:
continue
visited = {n, nxt}
q = [nxt]
found = False
Expand All @@ -433,10 +432,23 @@ def get_cyclic_atoms(mol: Molecule, exclude_atoms: set[int] = None) -> set[int]:
break
if in_cycle:
cyclic.add(n)

return cyclic


def get_cyclic_atoms(mol: Molecule, exclude_atoms: set[int] = None) -> set[int]:
# The full-molecule ring set is computed once and cached; removing atoms can only
# break cycles, never create them, so it is always a superset of any excluded result.
if mol._cyclic_cache is None:
all_nodes = {a.idx for a in mol if a.is_carbon or mol.degree(a.idx) >= 2}
mol._cyclic_cache = _cyclic_atoms_within(mol, all_nodes)
if not exclude_atoms:
return set(mol._cyclic_cache)
candidates = mol._cyclic_cache - exclude_atoms
if not candidates:
return set()
return _cyclic_atoms_within(mol, candidates)


def find_all_carbon_paths(mol: Molecule, exclude_atoms: set[int] = None) -> list[list[int]]:
if exclude_atoms is None:
exclude_atoms = set()
Expand Down Expand Up @@ -480,7 +492,9 @@ def find_ring_systems(mol: Molecule, exclude_atoms: set[int] = None) -> list[Rin

if exclude_atoms is None:
exclude_atoms = set()
valid_nodes = {a.idx for a in mol if a.idx not in exclude_atoms and (a.is_carbon or mol.degree(a.idx) >= 2)}
# Every cycle lies entirely within the ring atoms, so searching only those prunes the
# exhaustive DFS out of all acyclic branches without losing any cycle.
valid_nodes = get_cyclic_atoms(mol, exclude_atoms)
cycles = []

def dfs_cycle(curr, start, path, visited):
Expand Down Expand Up @@ -861,18 +875,6 @@ def _audited_ring_numberings(
return _dedupe_ring_numberings(audited)


def _audited_ring_paths(
mol: Molecule,
kind: str,
descriptor_numbers: tuple[int, ...],
paths: tuple[tuple[int, ...], ...] | list[tuple[int, ...]],
edges: frozenset[tuple[int, int]],
) -> list[list[int]]:
return _dedupe_numbering_paths(
[list(numbering.path) for numbering in _audited_ring_numberings(mol, kind, descriptor_numbers, paths, edges)]
)


def _audited_von_baeyer_numberings(
mol: Molecule,
descriptor: str,
Expand Down Expand Up @@ -1139,13 +1141,6 @@ def _polyspiro_or_von_baeyer_candidate(
)


def _polyspiro_descriptor_or_von_baeyer(mol: Molecule, atoms: set[int], edges: set[tuple[int, int]]):
"""Legacy tuple wrapper for callers that have not migrated to candidates."""

candidate = _polyspiro_or_von_baeyer_candidate(mol, atoms, edges)
return candidate.descriptor, candidate.paths


def _has_multiple_spiro_centers(nodes: set[int], edges: set[tuple[int, int]]) -> bool:
degrees = {node: 0 for node in nodes}
for u, v in edges:
Expand Down
6 changes: 0 additions & 6 deletions src/openclatura/charge_specs.py

This file was deleted.

3 changes: 3 additions & 0 deletions src/openclatura/component_modifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def add_component_front_modifiers(
principal_key: str | None,
sub_exclude: set[int],
branch_namer: RecursiveSubgraphNamer,
get_loc=None,
) -> None:
"""Add ester/sulfonate front modifiers such as the alcohol component name."""

Expand All @@ -38,6 +39,8 @@ def add_component_front_modifiers(
if branch_name:
modifier_atoms = subgraph_component(mol, r_group_c, sub_exclude | {single_o})
parts.front_modifiers.append(strip_outer_parentheses(branch_name))
locant = str(get_loc(group.attachment_carbon)) if get_loc is not None else None
parts.front_modifier_locants.append(locant)
parts.front_modifier_atom_ids.update(modifier_atoms)
parts.front_modifier_charge_atom_ids.update(_charged_atoms(mol, modifier_atoms))

Expand Down
2 changes: 1 addition & 1 deletion src/openclatura/component_namer.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ def name_component_again(next_mol: Molecule, next_atoms: set[int], is_substituen
parts = parent_plan.parts
emit_bond_stereo(mol, parts, numbered_path, get_loc, state.base_exclude)
add_component_front_modifiers(
mol, parts, state.perceived_groups, state.principal_key, state.sub_exclude, name_subgraph
mol, parts, state.perceived_groups, state.principal_key, state.sub_exclude, name_subgraph, get_loc
)
add_component_n_substituents(
mol,
Expand Down
20 changes: 19 additions & 1 deletion src/openclatura/data/parser_grammar_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,31 @@
],
"allowed_substituent_names": [
"methyl",
"ethyl",
"propyl",
"butyl",
"pentyl",
"propan-2-yl",
"2-methylpropyl",
"butan-2-yl",
"tert-butyl",
"ethenyl",
"ethynyl",
"trifluoromethyl",
"fluoro",
"chloro",
"bromo",
"iodo",
"amino",
"methylamino",
"dimethylamino",
"ethylamino",
"hydroxy",
"methoxy"
"methoxy",
"ethoxy",
"propoxy",
"isopropoxy",
"acetyl"
]
},
"retained_fused_tokens": {
Expand Down
9 changes: 8 additions & 1 deletion src/openclatura/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from .graph_io import get_connected_components, read_rdkit_mol, read_smiles
from .molecule import DecisionTrace, Molecule, NameAnalysis, TracePhase
from .name_assembly import set_token_span_building
from .namer_config import SALT_METAL_NAMES
from .operations import infer_operations
from .opsin_verify import OpsinCheck, verify_with_opsin
Expand Down Expand Up @@ -207,9 +208,13 @@ def run(self, request: NamingRequest) -> NamingResult:
datasets.
"""

# Token spans feed only the trace/analysis output; skip building them on the
# pure-name path so the common API does not pay for diagnostics it discards.
need_analysis = request.include_trace or request.verify_opsin
previous_span_building = set_token_span_building(need_analysis)
try:
mol, smiles = self._prepare_input(request)
if request.include_trace or request.verify_opsin:
if need_analysis:
analysis = self._analyze(mol, smiles=smiles, token_debug=request.token_debug)
rules, hints = _extract_rules_hit(analysis.trace_segments)
result = NamingResult(
Expand All @@ -226,6 +231,8 @@ def run(self, request: NamingRequest) -> NamingResult:
result = NamingResult(name=self._name(mol), smiles=smiles)
except Exception as exc: # noqa: BLE001 - intentionally permissive boundary
return NamingResult(name="", smiles=request.smiles, error=f"{type(exc).__name__}: {exc}")
finally:
set_token_span_building(previous_span_building)

if request.verify_opsin:
check = verify_with_opsin(result.name, result.smiles)
Expand Down
16 changes: 0 additions & 16 deletions src/openclatura/fused_ion_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,22 +167,6 @@ def consume_fused_ion_operation(parts: AssemblyParts, candidate: FusedIonOperati
_replace_fused_ion_bindings(parts, candidate)


def render_fused_ion_template_name(parts: AssemblyParts) -> str | None:
"""Render graph-certified retained fused ion names.

This is a production renderer only for generic graph operations whose
represented atoms are already present in ``AssemblyParts``. It does not
inspect the assembled string. New ion classes should add a graph operation
renderer here, then opt into template rows through the registry.
"""

candidate = select_fused_ion_operation(parts)
if candidate is not None:
consume_fused_ion_operation(parts, candidate)
return candidate.rendered_name
return None


def fused_ion_operation_candidates(parts: AssemblyParts) -> tuple[FusedIonOperationCandidate, ...]:
"""Return graph-derived retained fused ion operations for assembly parts."""

Expand Down
Loading
Loading