orthonym.rules.spiro#

Note

Internal API. Names and behaviour may change between releases.

Spiro compound naming module.

Handles naming of spiro systems where rings share exactly one atom each (the spiro center). Generates IUPAC spiro[a.b] descriptors for monospiro and dispiro[a.b.c.d] for polyspiro compounds.

IUPAC / rules for spiro naming: - Monospiro descriptor: spiro[a.b] where a <= b - a = smaller_ring_size - 1, b = larger_ring_size - 1 - The -1 accounts for the shared spiro center - Numbering starts at atom adjacent to spiro center in smaller ring - Goes around smaller ring, through spiro center, then around larger ring

IUPAC: dispiro/trispiro naming for multiple spiro centers - dispiro[a.b.c.d] where a,b,c,d are segment sizes between spiro atoms - Numbering starts in terminal ring, proceeds through spiro atoms

IUPAC: heterocyclic spiro compounds use skeletal replacement ‘a’ prefixes (oxa, aza, thia, etc.) with locants from spiro numbering

Examples

spiro[4.5]decane - cyclopentane fused to cyclohexane (5-1=4, 6-1=5) spiro[5.5]undecane - two cyclohexanes sharing one carbon (6-1=5, 6-1=5) dispiro[2.1.2.1]octane - three rings sharing two spiro centers

orthonym.rules.spiro.is_spiro_system(mol)#

Check if molecule is a pure spiro system.

A pure spiro system has N spiro atoms connecting N+1 rings, with no additional fused or bridged ring junctions. Molecules that have spiro atoms but also have additional polycyclic complexity (fused, bridged, etc.) are NOT classified as spiro systems.

Parameters:

mol – RDKit Mol object

Returns:

True if molecule is a pure spiro system

Return type:

bool

orthonym.rules.spiro.get_spiro_ring_sizes(mol, spiro_center)#

Get the sizes of the two rings sharing a spiro center.

Parameters:
  • mol – RDKit Mol object

  • spiro_center (int) – Atom index of the spiro center

Returns:

Tuple of (smaller_ring_size, larger_ring_size), sorted

Raises:

ValueError – If spiro_center is not in exactly 2 rings

Return type:

Tuple[int, int]

orthonym.rules.spiro.generate_spiro_descriptor(mol)#

Generate the spiro descriptor for a spiro compound.

For monospiro (1 spiro center): spiro[a.b] where a <= b. For polyspiro (2+ spiro centers): dispiro[a.b.c.d], trispiro[…], etc.

Parameters:

mol – RDKit Mol object

Returns:

Spiro descriptor string like “spiro[4.5]” or “dispiro[2.1.2.1]”, or None if not spiro

Return type:

str | None

orthonym.rules.spiro.get_spiro_numbering(mol, spiro_center, suffix_ring_atoms=None, prefix_ring_atoms=None)#

Generate IUPAC numbering for a monospiro system.

IUPAC /: numbering starts at an atom adjacent to the spiro centre in the SMALLER ring, proceeds around that ring, through the spiro centre, then around the larger ring.

Among the directional choices (which spiro-neighbour starts each ring, and — when the two rings are the same size — which ring is numbered first), the chosen numbering gives the LOWEST locants to the heteroatoms considered together, then to the most senior heteroatom /, then — a phase SUBST-01, mirroring get_bicyclo_numbering — to the suffix_ring_atoms (the free valence of a spiro SUBSTITUENT,. A spelling-independent canonical-rank tiebreak makes the result fully deterministic for symmetric systems (e.g. spiro[5.5] acetals). This both fixes the latent SMILES-order dependence in heteroatom locants (a tetra- valent 1-oxaspiro[4.5]decane was flipping to 4-oxaspiro[4.5]decane) and yields the correct lowest-locant PIN. Without the suffix tier, a symmetric carbocyclic spiro substituent flipped between equivalent locants (spiro[5.5]undecan-3-yl vs -9-yl) by SMILES order.

Returns a dict mapping atom index to IUPAC locant (1-indexed), or {} when the spiro centre is not in exactly two rings.

orthonym.rules.spiro.get_spiro_substituents(mol, spiro_center, atom_to_locant)#

Find substituents on a spiro system.

Parameters:
  • mol – RDKit Mol object

  • spiro_center (int) – Atom index of the spiro center

  • atom_to_locant (Dict[int, int]) – Mapping from atom index to IUPAC locant

Returns:

Dictionary mapping locant to substituent name

Return type:

Dict[int, str]

orthonym.rules.spiro.name_spiro_system(mol)#

Generate the complete IUPAC name for a spiro compound.

Handles monospiro, dispiro, trispiro hydrocarbons and heterospiro compounds with skeletal replacement ‘a’ prefixes.

Parameters:

mol – RDKit Mol object

Returns:

Tuple of (name, ring_atoms, atom_to_locant, substituents_included) where substituents_included is False (spiro handler does not discover substituents via universal pipeline), or None if not a valid spiro system.

Examples

>>> mol = Chem.MolFromSmiles('C1CCC2(CC1)CCCC2')
>>> result = name_spiro_system(mol)
>>> result[0]
'spiro[4.5]decane'
orthonym.rules.spiro.name_charged_spiro_system(mol, cation_idx)#

method (1): name a spiro system carrying a single cationic ring HETEROATOM (a quaternary onium at a spiro junction, e.g. a quaternary ring N+) as the neutral skeletal-replacement (‘a’) spiro PARENT plus the parent-hydride ‘-ium’ suffix cited at the cation’s spiro locant.

A spiro-junction onium – a ring N+ whose four bonds are ALL ring bonds – cannot be named by the neutralize -> re-enter route the rest of the charged router uses: removing the charge leaves an over-valent neutral heteroatom (a 4-bonded neutral N) that RDKit SanitizeMol rejects, so no neutral parent name is ever produced and the molecule abstains. name_spiro_system however already names the aza-/thia-/phospha-… spiro PARENT directly off the CHARGED mol – the cation reads as an ordinary skeletal heteroatom for the replacement prefix and the spiro numbering – so the cation name is that parent with -<locant>-ium appended at the same locant.

Method (1) (neutral ‘a’ parent + ‘-ium’/’-ylium’ suffix) gives the PREFERRED IUPAC name; it is preferred to the ‘azonia’ cationic skeletal-replacement alternative, “Method (1) gives preferred IUPAC names”, the Blue Book; 1-methyl-1-azabicyclo[2.2.1]heptan-1-ium (PIN) vs the azonia form, the Blue Book). So this builder emits ...azaspiro...-ium, never ...azonia- spiro....

The cation reference-set spellings 7-azoniadispiro[5.0.5.3]pentadecane etc. are the non-PIN method-(2) alternative; the PIN this returns is 6-azadispiro[5.0.5.3]pentadecan-6-ium (the heteroatom takes the lowest spiro locant,. Both spellings OPSIN-round-trip to the same structure, so the caller’s RT gate accepts the emission and 0-wrong holds.

Returns the candidate ‘-ium’ name, or ‘’ when the shape is out of scope (not a spiro system; the cation is not a ring heteroatom of charge +1; or the spiro parent cannot be named). This builder does NO OPSIN validation – the CALLER MUST OPSIN-round-trip the returned name against mol and abstain on any mismatch (0-wrong absolute).

Examples (each OPSIN-round-trips to the input on the caller’s RT gate):
>>> from rdkit import Chem
>>> m = Chem.MolFromSmiles('C1CCCC[N+]12CCCCC2')
>>> cat = next(a.GetIdx for a in m.GetAtoms
... if a.GetFormalCharge == 1)
>>> name_charged_spiro_system(m, cat)
'6-azaspiro[5.5]undecan-6-ium'
orthonym.rules.spiro.get_rings_from_spiro_center(mol, spiro_center)#

Get the two rings sharing a spiro center.

Parameters:
  • mol – RDKit Mol object

  • spiro_center (int) – Atom index of the spiro center

Returns:

Tuple of two ring tuples – (smaller_ring, larger_ring)

Raises:

ValueError – If spiro_center is not in exactly 2 rings

Return type:

Tuple[Tuple[int, …], Tuple[int, …]]

orthonym.rules.spiro.is_mixed_spiro_fused(mol, allow_vonbaeyer=False, restrict_atoms=None)#

Detect a mixed spiro / fused ring system AMENABLE TO AUTONOM NAMING.

A mixed spiro/fused system has:
  1. exactly ONE spiro atom (scope; multi-spiro mixed →)

  2. at least one fused-ring junction (rings share an edge)

  3. the spiro centre cleanly separates the ring graph into a FUSED component (≥2 rings sharing edges) on one side AND a SINGLE algorithmic side ring on the other — i.e., _classify_rings_around_spiro_center succeeds.

  4. is NOT a recognized natural-product backbone (RESEARCH Pitfall 3 false-positive guard — steroids and alkaloids may carry RDKit ring perception artifacts that look spiro-like).

Mutually exclusive with is_spiro_system per a phase-02: is_spiro_system returns True only when n_rings == n_spiro + 1.

Topology constraint (c) is critical for canary stability: hexacyclic natural-product variants (e.g., aconitane derivatives with one spiro centre between two multi-ring fused components) must NOT route to Branch 5b — they belong to the polycyclic-bridged Von Baeyer branch. Logged as follow-up #7 for both-sides-fused topology.

a phase-02 / / (b).

Parameters:

mol – RDKit Mol object. Returns False if mol is None.

Returns:

True iff (a) AND (b) AND (c) AND (d) hold.

Return type:

bool

orthonym.rules.spiro.get_spiro_iupac_locants(mol)#

Cascade-step-6 supplier for pure spiro systems (a phase-02).

Wraps the existing _get_polyspiro_numbering (multi-spiro) and get_spiro_numbering (monospiro) helpers, returning the same atom -> locant map shape that a phase’s _build_ring_pos consumes via the _has_iupac_locants cascade-step-6 gate.

Coverage invariant per Pitfall 7: returns None on partial coverage so the cascade-step-6 gate falls through to the sorted-int proxy.

Parameters:

mol – RDKit Mol object.

Returns:

Dict mapping atom_idx -> int locant, covering ALL ring atoms, OR None if mol is not a pure spiro system OR coverage is partial.

Return type:

Dict[int, int | Tuple[int, str]] | None

orthonym.rules.spiro.name_mixed_spiro_fused(mol, allow_vonbaeyer_component=False, force_vonbaeyer_component=False, restrict_atoms=None)#

Build the AUTONOM separable-parts name for a mixed spiro/fused system.

allow_vonbaeyer_component (Task C, best-effort FLOOR only): additionally name a SATURATED von-Baeyer fused/bridged component (bicyclo/tricyclo) via analyze_cage_universal, so a spiro-of-bicyclic degrades to the separable spiro[bicyclo[...]-x,y'-<comp2>] covering name instead of abstaining. Default False keeps the PIN path byte-identical.

Algorithm (a phase-02 + AUTONOM):
  1. Identify the spiro centre (must be exactly 1 in scope).

  2. Partition rings at the centre into FUSED component and SIDE ring.

  3. Name the fused component via existing fused-ring pipeline.

  4. Name the side ring algorithmically (cycloalkane / heterocycle).

  5. Recombine: spiro[<fused-name>-<f_loc>,<s_loc>’-<side-name>]. The prime sits on the side-ring’s spiro-attachment locant only, per Q-05 OPSIN preview (NESTED_FORM_PARSEABLE).

  6. (follow-up) Re-calculate unsaturation when one part becomes fully saturated by the spiro junction.

Return shape MUST match name_spiro_system per composer.py:3098-3105:

(name, ring_atoms, atom_to_locant, substituents_included=False)

Out-of-scope for (return None, log to AUTONOM-followups):
  • Multi-spiro mixed cases (n_spiro > 1 + fused junctions).

  • Cases where _classify_rings_around_spiro_center cannot cleanly separate the topology (both sides fused, exotic 4-way junctions).

  • Cases where the fused component name builder declines (no retained name AND _name_saturated_fused_carbocyclic returns None).

Source: 151-internal notes; AUTONOM-1990-insights.md;

IUPAC; Q-05 OPSIN preview (internal notes-B.md).

orthonym.rules.spiro.find_masked_spiro_atoms(mol, restrict_atoms=None)#

Detect MASKED spiro atoms — a spiro atom that is ALSO a von-Baeyer bridgehead, so it lies in >=3 SSSR rings and get_spiro_atoms (which needs EXACTLY 2 SSSR-ring membership) misses it entirely.

Root-cause detection rule (general, M4 L1a): within a connected ring system, the TRUE masked spiro atom is a ring atom in >=3 SSSR rings whose removal splits its OWN ring-atom-induced subgraph into EXACTLY 2 connected ring-components — a genuine spiro cut-vertex. An atom in >=3 SSSR rings whose removal leaves its ring system in 1 component is a bicyclo/von-Baeyer BRIDGEHEAD (the other bridges keep it connected), NOT spiro, and is excluded.

The split is evaluated per-ring-system (the atom’s own connected component of ring atoms), so a molecule with several disjoint ring systems or a pendant ring never miscounts. restrict_atoms scopes the whole search to one ring system. Returns the set of masked spiro atom indices (usually 0 or 1).

orthonym.rules.spiro.is_spirobi(mol)#

: monospiro ring system with two IDENTICAL (polycyclic) components joined at one spiro atom (e.g. 1,1’-spirobi[indene]).

These have n_rings > n_spiro + 1 (each component is itself polycyclic), so is_spiro_system rejects them and they are NOT mixed-spiro-fused (which requires one side to be a single ring). Fail-closed: exactly one spiro atom, both fused components polycyclic and graph-isomorphic, and the whole system unsubstituted (substituted spirobi numbering is a follow-on).

orthonym.rules.spiro.name_spirobi(mol)#

Build the spirobi name for two identical polycyclic components at one spiro atom (1,1'-spirobi[indene]). Return shape matches name_mixed_spiro_fused (name, ring_atoms, atom_to_locant, substituents_included=False). Fail-closed (see _name_spirobi_core).

orthonym.rules.spiro.is_spiroter(mol)#

P-24.8.3: three identical polycyclic components + one λ spiro atom.

orthonym.rules.spiro.name_spiroter(mol)#

Build the spiroter name. Fail-closed.

orthonym.rules.spiro.is_spiro_named_components(mol)#

P-24.8.4.2: three distinct ring components + one λ spiro atom in 3 rings.

orthonym.rules.spiro.name_spiro_named_components(mol)#

Build the three-component monospiro name. Fail-closed.

orthonym.rules.spiro.is_dispiroter(mol)#

: three identical polycyclic components sharing two spiro atoms.

orthonym.rules.spiro.name_dispiroter(mol)#

Build the dispiroter name. Fail-closed (see _name_dispiroter_core).

orthonym.rules.spiro.is_branched_polyspiro(mol)#

: BRANCHED polyspiro — a central fused-ring component that carries THREE OR MORE spiro junctions (a branching node). Detection only: the full branched component-name build (heteromonocycle central components, 3-junction walk) is a documented follow-on, so name_branched_polyspiro fails closed. Recognising the class here routes it to a fail-closed tag instead of leaking a structure-dropping partial name from the ortho-fused path (accuracy-first: never a wrong name).

orthonym.rules.spiro.name_branched_polyspiro(mol)#

branched polyspiro with different terminal components around a monocyclic-heterocycle central branch node. Fail-closed (return None) when the topology / components are outside the built class.

orthonym.rules.spiro.is_lambda_multiring_spiro(mol)#

P-24.8.1.3 detection: a single spiro atom shared by THREE OR MORE rings that carries a NONSTANDARD (λ) bonding number (e.g. a λ6 S in three monocyclic rings). get_spiro_atoms misses it (it requires exactly 2 rings), so without this the system falls through to a partial monocyclic name. The component-name / von-Baeyer-λ build for this rare form is a documented follow-on (Task 8 deferred), so this is detection-only and routes to a FAIL-CLOSED tag — never a wrong (structure-dropping) name.

orthonym.rules.spiro.name_lambda_multiring_spiro(mol)#

λ spiro atom in >=3 monocyclic rings — FAIL CLOSED (return None). The λ von-Baeyer-descriptor build is a documented follow-on; refusing here prevents a structure-dropping partial monocyclic name.

orthonym.rules.spiro.is_unbranched_polyspiro_different(mol)#

: unbranched polyspiro, different components, >=1 polycyclic.

orthonym.rules.spiro.name_unbranched_polyspiro_different(mol)#

Build the unbranched-polyspiro-different name. Fail-closed.

orthonym.rules.spiro.find_monospiro_separation_atom(mol)#

The unique ring atom whose removal splits the ring-atom graph into exactly two components (a monospiro junction), ROBUST to a spiro atom that sits in >2 SSSR rings (a von-Baeyer cage-bridge spiro, where get_spiro_atoms returns nothing). A spiro atom has exactly four ring bonds (two into each component) and is a cut vertex of the ring-atom graph; a VB bridgehead is NOT a cut vertex (the other bridges keep the cage connected) and an ortho-fusion junction atom has three ring bonds, not four. Returns (spiro_atom, [component_a_atoms, component_b_atoms]) or None when there is not exactly one such clean two-way split (polyspiro / fused / bridged).

orthonym.rules.spiro.is_spiro_vonbaeyer(mol)#

: monospiro system with >=1 von Baeyer (bridged) ring component (e.g. spiro[bicyclo[2.2.1]heptane-2,1'-cyclohexane], 2,2'-spirobi[bicyclo[2.2.1]heptane]). Fail-closed (see _name_spiro_vonbaeyer_core).

orthonym.rules.spiro.name_spiro_vonbaeyer(mol)#

Build the component-name spiro PIN for a monospiro system with at least one von Baeyer cage component. Return shape matches name_spirobi (name, ring_atoms, atom_to_locant, substituents_included=False).

orthonym.rules.spiro.get_mixed_spiro_fused_iupac_locants(mol)#

Cascade-step-6 supplier for mixed spiro/fused systems (a phase-02).

Wraps name_mixed_spiro_fused and returns the combined atom-to-locant map (covering ALL ring atoms) or None if naming declined.

Coverage invariant per Pitfall 7: returns None on partial coverage so the cascade-step-6 gate falls through to the sorted-int proxy.