Skip to content

Commit 1cf8d42

Browse files
AdrianM0claude
andcommitted
Prime a named spiro component's replacement prefixes
In spiro[indoline-3,4'-cyclopentane] the side ring is the primed component, so its skeletal-replacement locants are primed with it. Both priming helpers were guarded on the literal `spiro[cyclopropane-`, so every other side ring kept unprimed locants and the heteroatoms read back on the wrong ring: a spiro indoline carrying two ring nitrogens came out as `1,3-diaza` rather than `1',3'-diaza`. The regex also only matched a bare `oxa`/`aza`/`thia`, so multiplied forms were missed entirely. The guard cannot simply be dropped. `6,8-diazaspiro[4.4]nonane` numbers the whole spiro system once and its prefixes must stay unprimed; only a descriptor that names its components primes the second one, and the prime inside the bracket tells the two apart. Nor can that test be made against the whole name. A name can hold both forms at once, and checking globally primed the `6-aza` of an `azaspiro[3.3]heptane` sitting beside a `spiro[indoline-3,1'- cyclohexane]` -- caught as the one regression in the first measurement. The inline test is now made against the bracket each prefix actually precedes. 30 corpus names fixed, 0 broken. Mismatch counts are unchanged at 7 and 0, because every one of these was already abstaining rather than being refuted -- the audit could not model the spiro side ring to begin with. 1299 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent dbe1432 commit 1cf8d42

2 files changed

Lines changed: 65 additions & 5 deletions

File tree

src/openclatura/assembly_spiro.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,18 +155,59 @@ def _reprime_side_prefixes(prefixes: tuple[str, ...], prime: str) -> list[str]:
155155

156156

157157
def _prime_replacement_prefixes_for_primed_component(core_name: str, prefixes: list[str]) -> list[str]:
158-
if "spiro[cyclopropane-" not in core_name:
158+
"""Prime replacement prefixes that describe the side ring.
159+
160+
The side component is always the primed one, and these prefixes are always
161+
the side ring's, so the priming does not depend on which ring it happens to
162+
be. Guarding on ``spiro[cyclopropane-`` meant every other side ring kept
163+
unprimed locants: spiro[indoline-3,4'-cyclopentane] carrying two ring
164+
nitrogens came out as ``1,3-diaza`` and read back with the nitrogens on the
165+
indoline instead.
166+
"""
167+
168+
if not _spiro_names_its_components(core_name):
159169
return prefixes
160170
return [
161-
re.sub(r"^([0-9,]+)-(oxa|aza|thia)$", lambda match: f"{match.group(1)}'-{match.group(2)}", prefix)
171+
re.sub(
172+
r"^([0-9,]+)-((?:di|tri|tetra|penta)?(?:oxa|aza|thia|selena|tellura|phospha|sila|bora|germa|stanna))$",
173+
lambda match: f"{','.join(f'{locant}' + chr(39) for locant in match.group(1).split(','))}-{match.group(2)}",
174+
prefix,
175+
)
162176
for prefix in prefixes
163177
]
164178

165179

166180
def _prime_inline_replacement_prefixes_for_primed_component(core_name: str) -> str:
167-
if "spiro[cyclopropane-" not in core_name:
168-
return core_name
169-
return re.sub(r"(^|-)([0-9,]+)-(oxa|aza|thia)spiro\[", r"\1\2'-\3spiro[", core_name)
181+
"""Prime a replacement prefix that ended up in front of the spiro core."""
182+
183+
# A name can hold both spiro forms at once, so the test has to be made
184+
# against the bracket this prefix actually sits in front of rather than
185+
# against the whole name: 6-azaspiro[3.3]heptane keeps unprimed locants
186+
# even when a spiro[indoline-3,1'-cyclohexane] appears elsewhere in it.
187+
def prime(match: re.Match) -> str:
188+
if "'" not in match.group(4):
189+
return match.group(0)
190+
locants = ",".join(f"{locant}'" for locant in match.group(2).split(","))
191+
return f"{match.group(1)}{locants}-{match.group(3)}spiro[{match.group(4)}"
192+
193+
return re.sub(
194+
r"(^|-)([0-9,]+)-((?:di|tri|tetra|penta)?(?:oxa|aza|thia|selena|tellura|phospha|sila|bora|germa|stanna))"
195+
r"spiro\[([^\]]*)",
196+
prime,
197+
core_name,
198+
)
199+
200+
201+
def _spiro_names_its_components(core_name: str) -> bool:
202+
"""Whether the spiro descriptor names its rings rather than counting them.
203+
204+
``spiro[indoline-3,4'-cyclopentane]`` numbers each component separately and
205+
primes the second, so a side-ring replacement prefix is primed with it.
206+
``6,8-diazaspiro[4.4]nonane`` numbers the whole system once and its
207+
prefixes must stay unprimed. The prime inside the bracket tells them
208+
apart."""
209+
210+
return bool(re.search(r"spiro\[[^\]]*'", core_name))
170211

171212

172213
def _prime_side_suffixes(suffixes: tuple[tuple[str, str], ...], prime: str) -> list[tuple[str, str]]:

src/openclatura/tests/test_analysis.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1899,6 +1899,25 @@ def test_two_nitrogen_chain_uses_its_retained_name():
18991899
assert name_smiles("CN=NC") == "1,2-dimethyldiazene"
19001900

19011901

1902+
def test_a_named_spiro_component_primes_its_replacement_prefixes():
1903+
# The side ring is the primed component, so its heteroatom locants are
1904+
# primed too. Unprimed, they read back on the other ring entirely.
1905+
assert name_smiles("N1CC2(C3=CC=CC=C13)NCNC2") == "1',3'-diazaspiro[indoline-3,4'-cyclopentane]"
1906+
assert name_smiles("O=C1NC2(CN1)c1ccccc1NC2=O") == (
1907+
"1',3'-diazaspiro[indoline-3,4'-cyclopentane]-2'-one-2-one"
1908+
)
1909+
1910+
1911+
def test_a_counted_spiro_keeps_its_replacement_prefixes_unprimed():
1912+
# spiro[4.4] numbers the whole system once, so nothing is primed -- and a
1913+
# name holding both spiro forms must prime only the component-named one.
1914+
assert name_smiles("C1CC2(CC1)NCNC2") == "6,8-diazaspiro[4.4]nonane"
1915+
assert name_smiles("C1CC2(CC1)OCCO2") == "6,9-dioxaspiro[4.4]nonane"
1916+
both = name_smiles("Cc1noc(C2CC3(C2)CN(C2CCC4(CC2)C(=O)Nc2ccccc24)C3)n1")
1917+
assert "6-azaspiro[3.3]heptan" in both
1918+
assert "spiro[indoline-3,1'-cyclohexane]" in both
1919+
1920+
19021921
def test_homonuclear_chain_names_are_reconstructed_by_the_audit():
19031922
"""A shortcut returns before the usual audit point, so it must hand over a
19041923
plan or its name can never be checked. Corrupting the plan must refute."""

0 commit comments

Comments
 (0)