Skip to content

Commit f6137b5

Browse files
AdrianM0claude
andcommitted
Keep every substituent on a spiro side ring, and prime them all
`extract_spiro_side_prefixes` matched one leading locant group and treated everything after it as a single substituent name, so only the first side-ring prefix was primed. Worse, its stereo-descriptor strip recognised R/S but not E/Z, so `(5E)-1-butyl-5-butylidene-1,3-diaza…` matched nothing at all and the function returned no prefixes: an entire butyl and butylidene disappeared from the name rather than merely losing their primes. Prime every top-level locant instead. Two things must not be primed. Locants inside a nested substituent number that substituent's own skeleton -- `(2,6-difluoro-3-methylphenyl)` -- so priming is tracked by parenthesis depth. Digits within a name, as in `but-2-en-1-yl`, are recognised by the fragment that follows them. The side ring's stereo descriptor stays dropped. Re-emitting it inline put it in the middle of the assembled name, which reads back as a different molecule; that attempt broke 41 names and is reverted. It belongs at the front of the whole name, which this string-level renderer cannot reach, so a missing descriptor is preferred to a misplaced one -- the one molecule this leaves incomplete is now wrong only in its E/Z, where before it was missing two whole substituents. A stereo descriptor opening the right-hand side now takes a hyphen, so `1-methyl(5'E)-…` no longer runs together. 58 corpus names fixed, 0 broken. pubchem mismatches 7 -> 5; zinc22 stays at 0. 1301 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1cf8d42 commit f6137b5

3 files changed

Lines changed: 76 additions & 6 deletions

File tree

src/openclatura/assembly_spiro.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -287,13 +287,52 @@ def extract_spiro_side_prefixes(side_name: str) -> tuple[list[str], str, tuple[t
287287
prefix_text = normalized[: -len(parent)].rstrip("-")
288288
elif normalized != parent and normalized.endswith(parent):
289289
prefix_text = normalized[: -len(parent)].rstrip("-")
290-
prefix_text = re.sub(r"^\((?:\d+[A-Za-z']*[RS](?:,\d+[A-Za-z']*[RS])*)\)-?", "", prefix_text)
291-
match = re.match(r"^([0-9,]+)-(.+)$", prefix_text)
292-
if not match:
290+
# A stereo descriptor here can be E/Z as readily as R/S. Matching only the
291+
# latter left `(5E)-` in front of the locants, the locant pattern below then
292+
# failed, and every prefix was dropped -- an entire butyl and butylidene
293+
# vanished from the name rather than merely losing their primes.
294+
# The side ring's stereo descriptor is dropped here rather than carried.
295+
# Re-emitting it inline placed it in the middle of the assembled name --
296+
# `...-7-(pyrimidin-4-yl)-(3'S)-1'-methylspiro[...` -- which reads back as a
297+
# different molecule. It belongs at the front of the whole name, which this
298+
# string-level renderer cannot reach, so a missing descriptor is preferred
299+
# to a misplaced one.
300+
prefix_text = re.sub(r"^\((?:\d+[A-Za-z']*(?:[RS]|[EZ])(?:,\d+[A-Za-z']*(?:[RS]|[EZ]))*)\)-?", "", prefix_text)
301+
if not prefix_text:
293302
return [], _spiro_side_parent_name(parent), tuple(side_suffixes)
294-
locants, substituent = match.groups()
295-
primed_locants = ",".join(f"{loc}'" for loc in locants.split(","))
296-
return [f"{primed_locants}-{substituent}"], _spiro_side_parent_name(parent), tuple(side_suffixes)
303+
return [_prime_side_prefix_locants(prefix_text)], _spiro_side_parent_name(parent), tuple(side_suffixes)
304+
305+
306+
# Fragments that follow a digit *inside* a substituent name -- `but-2-en-1-yl`
307+
# -- rather than after a locant that introduces one. Everything else beginning
308+
# a hyphen-separated segment after a number is a fresh prefix at that locant.
309+
_WITHIN_NAME_AFTER_LOCANT = ("en", "yn", "yl", "ylidene", "ylidyne", "ol", "one", "al", "amine", "oic", "carbo")
310+
311+
312+
def _prime_side_prefix_locants(prefix_text: str) -> str:
313+
"""Prime every locant that introduces a side-ring prefix, and only those.
314+
315+
The side ring is the primed component, so each of its substituent locants
316+
carries a prime. Priming only the first left the rest reading as the other
317+
ring's positions.
318+
"""
319+
320+
segments = prefix_text.split("-")
321+
depth = 0
322+
for index, segment in enumerate(segments):
323+
# Only locants at the top level position a prefix on the side ring.
324+
# Inside a nested substituent -- `(2,6-difluoro-3-methylphenyl)` -- they
325+
# number that substituent's own skeleton and must be left alone.
326+
opening, closing = segment.count("("), segment.count(")")
327+
was_nested = depth > 0
328+
depth += opening - closing
329+
if was_nested or depth > 0 or not re.fullmatch(r"[0-9,]+", segment):
330+
continue
331+
following = segments[index + 1] if index + 1 < len(segments) else ""
332+
if following.startswith(_WITHIN_NAME_AFTER_LOCANT):
333+
continue
334+
segments[index] = ",".join(f"{locant}'" for locant in segment.split(","))
335+
return "-".join(segments)
297336

298337

299338
def _extract_side_suffixes(side_name: str) -> tuple[str, list[tuple[str, str]]]:

src/openclatura/assembly_utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ def parse_locant(locant):
1414
return (2, 0.0, text)
1515

1616

17+
_STEREO_DESCRIPTOR_START = re.compile(r"\(\d+[A-Za-z']*(?:[RS]|[EZ])(?:,\d+[A-Za-z']*(?:[RS]|[EZ]))*\)")
18+
19+
1720
def needs_hyphen(left: str, right: str) -> bool:
1821
if not left or not right:
1922
return False
@@ -23,6 +26,9 @@ def needs_hyphen(left: str, right: str) -> bool:
2326
or right.startswith("N-")
2427
or right.startswith("N',")
2528
or right.startswith("N'-")
29+
# A stereo descriptor opening the right-hand side is a separate
30+
# italicised element and takes a hyphen: `1-methyl-(5'E)-1'-butyl…`.
31+
or _STEREO_DESCRIPTOR_START.match(right)
2632
):
2733
return True
2834
if left[-1].isdigit():

src/openclatura/tests/test_analysis.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,31 @@ def test_a_named_spiro_component_primes_its_replacement_prefixes():
19081908
)
19091909

19101910

1911+
def test_every_side_ring_substituent_survives_and_is_primed():
1912+
# The locant pattern matched only the first prefix, so the rest were left
1913+
# unprimed -- and a leading E/Z descriptor made it match nothing at all,
1914+
# dropping an entire butyl and butylidene from the name.
1915+
assert name_smiles("CCC/C=C1/N(CCCC)C(=O)NC12C(=O)N(C)c1ccccc12") == (
1916+
"1-methyl-1'-butyl-5'-butylidene-1',3'-diazaspiro[indoline-3,4'-cyclopentane]-2'-one-2-one"
1917+
)
1918+
assert name_smiles("C=C1N(C)C(=O)NC12C(=O)N(C)c1ccccc12") == (
1919+
"1-methyl-1'-methyl-5'-methylidene-1',3'-diazaspiro[indoline-3,4'-cyclopentane]-2'-one-2-one"
1920+
)
1921+
1922+
1923+
def test_a_nested_substituents_own_locants_are_not_primed():
1924+
"""Only top-level locants position a prefix on the side ring; the ones
1925+
inside a nested substituent number that substituent's own skeleton."""
1926+
1927+
from openclatura.assembly_spiro import extract_spiro_side_prefixes
1928+
1929+
prefixes, parent, _suffixes = extract_spiro_side_prefixes(
1930+
"1-((2,6-difluoro-3-methylphenyl)methyl)piperidine"
1931+
)
1932+
assert prefixes == ["1'-((2,6-difluoro-3-methylphenyl)methyl)"]
1933+
assert parent == "piperidine"
1934+
1935+
19111936
def test_a_counted_spiro_keeps_its_replacement_prefixes_unprimed():
19121937
# spiro[4.4] numbers the whole system once, so nothing is primed -- and a
19131938
# name holding both spiro forms must prime only the component-named one.

0 commit comments

Comments
 (0)