Skip to content

Commit 5264cff

Browse files
AdrianM0claude
andcommitted
Gate retained fused parents on ambiguity, not on a substituent allow-list
A retained fused parent was abandoned whenever the molecule carried any substituent outside a curated list of 26 names. `pentyl` was on it and `hexyl` was not, so 2-pentyl-7H-purine named itself and its hexyl homologue fell back to 3-hexyl-2,4,7,9-tetraazabicyclo[4.3.0]nona-1,3,5,8-tetraene. The list gated the wrong thing: a substituent is named by its own recursive call, so what it is cannot affect whether the parent's locants are right. It was a rollout throttle standing in for a hazard nobody had named. The hazard is real but different. A lactam like 4-oxoquinoline-3-carboxamide saturates two ring positions beyond what quinoline supports, and which two cannot be recovered from the name -- OPSIN puts the hydrogen on C3 and reads a different molecule. Those positions have to be cited as 4-oxo-1,4-dihydroquinoline-3-carboxamide, and only parents wired into the indicated-hydrogen machinery can do it. So gate on that instead: count the parent's saturated positions, and keep the retained name only when they fit its indicated hydrogen or it can spell the rest out. Anything else keeps the von Baeyer name, which states its saturation positionally and stays unambiguous. Measured on 200k molecules, every name this changes was checked against OPSIN independently of the self-audit: 519 changed, 518 resolve back to their input, and the one that does not is a garbled spiro name whose previous form was equally unparseable -- the known spiro renderer gap, untouched here. Refutations stay at 27; without the ambiguity guard this same change introduced 29 wrong names. zinc22 is unchanged at 97.63%. pubchem drops 96.33% -> 96.21%: 119 names stop being confirmed, every one because the audit models neither added hydrogen (70) nor more than one indicated hydrogen (42), not because any name got worse. Teaching the audit those two constructs is the next step, and would also recover the ~200 lactams the new guard still holds back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8b68a0e commit 5264cff

2 files changed

Lines changed: 59 additions & 22 deletions

File tree

src/openclatura/retained_fused_production.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
from .assembly_parts import RetainedParentMetadata, SubstituentItem
1515
from .grammar_snapshot_data import retained_fused_derivative_gate
1616
from .molecule import Molecule
17+
from .namer_config import INDICATED_H_RETAINED_NAMES
1718
from .perception import PerceivedGroup
1819
from .retained_fused_templates import RetainedFusedTemplateMatch, match_retained_fused_templates
1920

2021
_DERIVATIVE_GATE = retained_fused_derivative_gate()
2122
PRODUCTION_RETAINED_FUSED_PARENTS = _DERIVATIVE_GATE.production_parent_names
2223
ALLOWED_PRINCIPAL_KEYS = _DERIVATIVE_GATE.allowed_principal_keys
2324
ALLOWED_GROUP_KEYS = _DERIVATIVE_GATE.allowed_group_keys
24-
ALLOWED_SUBSTITUENT_NAMES = _DERIVATIVE_GATE.allowed_substituent_names
2525

2626

2727
@dataclass(frozen=True)
@@ -85,6 +85,8 @@ def production_retained_fused_parent(
8585
if not maps:
8686
return None
8787
template = matches[0].template
88+
if not _added_hydrogen_is_citable(mol, parent_atoms, template):
89+
return None
8890
return ProductionRetainedFusedParent(
8991
name=parent_name,
9092
locant_maps=maps,
@@ -97,6 +99,32 @@ def production_retained_fused_parent(
9799
)
98100

99101

102+
def _added_hydrogen_is_citable(mol: Molecule, parent_atoms: set[int], template) -> bool:
103+
"""Whether every saturated position of the parent can be spelt out.
104+
105+
A lactam such as 4-oxoquinoline-3-carboxamide saturates two ring positions
106+
beyond what quinoline supports, and which two cannot be recovered from the
107+
name -- OPSIN puts the hydrogen on C3 and reads a different molecule. They
108+
have to be cited, ``4-oxo-1,4-dihydroquinoline-3-carboxamide``, and only
109+
parents wired into the indicated-hydrogen machinery can do that. A parent
110+
needing an uncitable added hydrogen falls back to von Baeyer, which states
111+
its saturation positionally and so stays unambiguous.
112+
"""
113+
114+
saturated = 0
115+
for atom in parent_atoms:
116+
ring_bonds = [
117+
bond
118+
for neighbor in mol.get_neighbors(atom)
119+
if neighbor in parent_atoms and (bond := mol.get_bond(atom, neighbor)) is not None
120+
]
121+
if ring_bonds and sum(bond.order for bond in ring_bonds) == len(ring_bonds):
122+
saturated += 1
123+
if saturated <= _indicated_hydrogen_count(template):
124+
return True
125+
return template.name in INDICATED_H_RETAINED_NAMES
126+
127+
100128
def _indicated_hydrogen_count(template) -> int:
101129
"""How many indicated hydrogens this mancude parent hydride supports.
102130
@@ -167,30 +195,15 @@ def _allowed_groups(parent_atoms: set[int], perceived_groups: list[PerceivedGrou
167195
return True
168196

169197

170-
def _base_substituent_name(name: str) -> str:
171-
"""Strip enclosing marks so the gate can list bare substituent names.
198+
def _allowed_substituents(substituent_mapping: dict[int, list[SubstituentItem]]) -> bool:
199+
"""Whether the substituents on a retained parent are ones we can render.
172200
173-
A complex substituent carries context-dependent enclosing marks in its
174-
rendered ``name`` (e.g. ``(propan-2-yl)`` on one parent, ``propan-2-yl``
175-
on another). Comparing the bare base name keeps the allow-list stable
176-
across attachment contexts.
201+
A substituent is named by its own recursive call, so what it *is* cannot
202+
affect whether the parent's locants are right; only a spiro junction, which
203+
the retained renderer cannot compose, disqualifies the parent here.
177204
"""
178205

179-
name = name.strip()
180-
closing = {"(": ")", "[": "]", "{": "}"}
181-
while len(name) >= 2 and name[0] in closing and name[-1] == closing[name[0]]:
182-
name = name[1:-1].strip()
183-
return name
184-
185-
186-
def _allowed_substituents(substituent_mapping: dict[int, list[SubstituentItem]]) -> bool:
187-
for items in substituent_mapping.values():
188-
for item in items:
189-
if item.spiro is not None:
190-
return False
191-
if _base_substituent_name(item.name) not in ALLOWED_SUBSTITUENT_NAMES:
192-
return False
193-
return True
206+
return all(item.spiro is None for items in substituent_mapping.values() for item in items)
194207

195208

196209
def _feature_locants_are_substitutable(atom_to_locant: dict[int, str], feature_atoms: set[int]) -> bool:

src/openclatura/tests/test_rules_tables.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,27 @@ def test_retained_fused_audit_templates_match_their_graph_templates():
116116
frozenset((labels[bond.GetBeginAtomIdx()], labels[bond.GetEndAtomIdx()])) for bond in frag.GetBonds()
117117
}
118118
assert actual_bonds == expected_bonds, f"{name}: bonds differ"
119+
120+
121+
def test_retained_parent_survives_an_arbitrary_substituent():
122+
"""A substituent is named by its own recursive call, so what it is cannot
123+
affect whether the parent's locants are right. A curated allow-list used to
124+
decide this, and anything off it dropped the retained parent entirely."""
125+
126+
from openclatura import name_smiles
127+
128+
for substituent in ("C" * 5, "C" * 6, "C" * 12, "CC(C)CC"):
129+
name = name_smiles(substituent + "c1ncc2[nH]cnc2n1")
130+
assert name.endswith("-7H-purine"), name
131+
assert name_smiles("c1ccc(-c2ncc3[nH]cnc3n2)cc1") == "2-phenyl-7H-purine"
132+
133+
134+
def test_uncitable_added_hydrogen_keeps_the_von_baeyer_parent():
135+
"""4-oxoquinoline-3-carboxamide reads as a different molecule -- the two
136+
saturated positions have to be cited, and quinoline cannot cite them yet."""
137+
138+
from openclatura import name_smiles
139+
140+
name = name_smiles("N#CC[C@H](O)CNC(=O)c1c[nH]c2ccccc2c1=O")
141+
assert "quinoline" not in name
142+
assert "bicyclo[4.4.0]" in name

0 commit comments

Comments
 (0)