orthonym.rules.polycyclic#

Note

Internal API. Names and behaviour may change between releases.

Von Baeyer polycyclic descriptor generation (IUPAC 2013,.

Implements the von Baeyer algorithm (through) for generating correct polycyclic descriptors for any ring count (bicyclo through decacyclo).

This module replaces the broken descriptor generation in tricyclo.py and polycyclic_bridged.py, which incorrectly used SSSR for main ring finding.

Key algorithm: 1. Ring count via cycle rank formula (edges - vertices + 1) 2. Main ring via longest-path between bridgehead pairs (NOT SSSR) 3. Main bridge between main bridgeheads through non-main-ring atoms 4. Secondary bridges: independent before dependent, descending by length 5. numbering: main ring (longer path first) -> main bridge -> secondary bridges 6. Verification: sum(bridge_lengths) + 2 == total_ring_atoms

Reference: IUPAC 2013 Blue Book, through.

orthonym.rules.polycyclic.fusion_nomenclature_applies(mol, ring_atoms)#

True when the ring system ring_atoms holds an ortho-fused cluster with at least two rings of five or more members, i.e. when a fusion (or bridged fusion) name exists and a von Baeyer name of the system is therefore NOT its PIN.

“Five-membered ring requirement” (the Blue Book,:23710): “Fusion

nomenclature gives preferred IUPAC names only to compounds having at least two rings of at least five or more members. […] When fusion names are not allowed, unsaturated von Baeyer ring system names are preferred IUPAC names.” (:19532) ranks (c) fused and (d) bridged fused ring systems above (e) nonfused bridged (von Baeyer) systems, and:23883 “the bridged fused ring name is preferred to the von Baeyer name”. BB rows: ‘decahydronaphthalene (PIN)’ over ‘bicyclo[4.4.0]decane’ (:24233); ‘hexadecahydro-1H-8,12-methanobenzo[13]annulene (PIN)’ over ‘tricyclo[12.3.1.0^5,10]octadecane’ (:23875-23879); ‘hexahydro-1H- 4,7-methanoindene (PIN)’ (:49311, the tricyclo[5.2.1.0^2,6] skeleton); ‘tetrahydro-4,8-ethanopyrano[4,3-c]pyran-…-tetrone (PIN) {not 4,9-dioxatricyclo [4.4.2.0^2,7]dodecane-…}’ (:32535). Boundary rows where the von Baeyer name IS the PIN (this returns False for each): ‘bicyclo[4.1.0]hepta-1,3,5-triene’, ‘bicyclo[4.2.0]octa-1,3,5,7-tetraene’ (:23718,:23725; one ring of five or more), ‘tetracyclo[3.2.0.0^2,7.0^4,6]heptane’ (:10824: its two five-membered rings meet a four-membered ring each by one bond but share three atoms with each other, so they are bridged, not fused), ‘cubane (PIN) pentacyclo[4.2.0.0^2,5.0^3,8.0^4,7] octane’ (:9889), ‘tetracyclo[2.2.0.0^2,6.0^3,5]hexane (PIN)’ (:9897), ‘3,6,8-trioxatricyclo[3.2.1.0^2,4]octane (PIN)’ (:48763).

A cluster is grown along rings that share exactly one bond (ortho-fusion); every ring in it must meet every other ring in at most one atom or exactly one bond, as in a fused ring system. Smallest rings come from RDKit’s symmetrized SSSR. A miss only leaves a label as it was; it never changes a name.

A cluster that holds every ring atom while the system has more rings than the cluster closes those rings by bonds only. (:14025): “An atom or group of atoms is named as a bridge”, so such a cluster gives no bridged fused name (a decalin with a cyclobutane closed across it, tricyclo[4.4.0.0^5,10]decane); the search goes on for a cluster that leaves a bridge atom or is the whole system.

orthonym.rules.polycyclic.retained_von_baeyer_parent(mol, ring_atoms)#

‘adamantane’ / ‘cubane’ when the all-carbon ring system ring_atoms is that retained parent hydride, else None. A ring heteroatom returns None.

orthonym.rules.polycyclic.von_baeyer_ring_count(mol, cage_atoms)#

The number of rings counts over cage_atoms, or None.

(the Blue Book) DEFINES the quantity: “A ‘polycyclic

system’ contains a number of rings equal to the minimum number of scissions required to convert the system into an acyclic skeleton.” Restated at (:9645): “The number of rings is equal to the number of bond cuts necessary to transform the polycyclic system into an acyclic skeleton.” That is the graph’s circuit rank, E - V + C over the induced cage subgraph (C = connected components), and it is exactly the number cyclo_ring_count_word spells – the two are the count and its word, so they live together here.

Why this is NOT GetRingInfo.NumRings (a phase T3b)#

RDKit’s ring info is the symmetrized SSSR, which deliberately keeps extra symmetry-equivalent smallest rings, so its cardinality OVER-COUNTS the

number on exactly the symmetric cages von Baeyer nomenclature is

for:

  • adamantane – Blue Book tricyclo[3.3.1.1^3,7]decane (PIN, :9840), 3 rings – symmetrized ring count 4;

  • cubane – pentacyclo[4.2.0.0^2,5.0^3,8.0^4,7]octane (PIN, :9889), 5 rings – symmetrized ring count 6;

  • a 12-atom bridged cage whose reference name is heptacyclo[...]dodecane, 7 rings – symmetrized ring count 11.

MAX_CAGE_RINGS was compared against the symmetrized count, so a cap meant to bound “8 rings” refused heptacyclo cages.

Two other places in the tree already compute this quantity correctly but privately – VonBaeyerAnalyzer._get_ring_count (as E - V + 1, so it under-counts a DISCONNECTED atom set) and rules/bicyclo.py:163, whose comment had already diagnosed the hazard in prose (“cycle_rank… is always reliable regardless of SSSR issues”). Neither was the bug, so neither is rerouted here: _get_ring_count has 7 PIN-path call sites and swapping its disconnected-set behaviour is a separate, separately-gated change. New callers should use THIS function.

Returns None when cage_atoms is empty, so callers fail closed rather than treat “no cage” as a ring count.

orthonym.rules.polycyclic.cyclo_ring_count_word(ring_count)#

The von Baeyer ring-count term for ring_count rings, or None.

(the Blue Book): “The number of rings is indicated by the

nondetachable prefix ‘bicyclo’ (not dicyclo), ‘tricyclo’, ‘tetracyclo’, etc.” – restated at (:9645). Both sentences end in “etc.”: the series is OPEN-ENDED and the Blue Book prints no table of these words (the highest one attested anywhere in the text is hexacyclo, :9731). The term is therefore COMPUTED – simple multiplying prefix (Table 1.4, + cyclo – with the single irregularity that 2 is bi, not di. No vowel elision applies: cyclo starts with a consonant.

CYCLO_PREFIXES is consulted first purely as a fast, human-auditable path; it agrees with the composition at every entry it holds (asserted by test_table_agrees_with_composition), so it is a cache of the rule and not a second, competing definition of it.

Returns None when no word can be formed, so callers FAIL CLOSED rather than emit the non-word the previous f-string fallback produced ("21cyclo" for 21 rings). That fallback was NOT latent: the PIN path (name_polycyclic_complete) caps nothing above ring_count < 2, and a 21-ring cage really did emit 21cyclo[...]tetratetracontane. The MAX_CAGE_RINGS = 8 ceiling guards only the opt-in general-engine path in vonbaeyer_universal, which is a different caller.

class orthonym.rules.polycyclic.BridgeInfo(atoms, length, start_bh, end_bh, is_secondary=False, is_dependent=False, locant_low=None, locant_high=None)#

Bases: object

Information about a single bridge in a polycyclic system.

atoms: List[int]#
length: int#
start_bh: int#
end_bh: int#
is_secondary: bool = False#
is_dependent: bool = False#
locant_low: int | None = None#
locant_high: int | None = None#
class orthonym.rules.polycyclic.PolycyclicDescriptor(ring_count, bridge_info_list, numbering, total_atoms, descriptor_string, bridge_lengths=<factory>, legality=None)#

Bases: object

Complete von Baeyer descriptor for a polycyclic system.

ring_count: int#
bridge_info_list: List[BridgeInfo]#
numbering: Dict[int, int]#
total_atoms: int#
descriptor_string: str#
bridge_lengths: List[int]#
legality: bool | None = None#

Verdict of VonBaeyerAnalyzer._legality_verified – True only when descriptor_string, read by the Blue Book’s own numbering rules, rebuilds exactly this cage under numbering. None means NOT ADJUDICATED (a raw _analyze_impl candidate); analyze sets it on every return path. Anything that spells a name from this object must require ``legality is True`` – a descriptor that fails is not a worse name, it is a name for a different molecule.

orthonym.rules.polycyclic.find_longest_path(mol, start, end, allowed_atoms)#

Find the longest simple path from start to end through allowed atoms.

Uses DFS with backtracking. For polycyclic ring systems (<50 atoms), exhaustive search is feasible and fast. The search is bounded by _MAX_DFS_EXPANSIONS so a pathological dense cage cannot hang; on abort the best path found so far is returned.

Parameters:
  • mol – RDKit Mol object

  • start (int) – Starting atom index

  • end (int) – Target atom index

  • allowed_atoms (Set[int]) – Set of atom indices that the path may traverse

Returns:

List of atom indices from start to end (inclusive), or empty list if no path exists.

Return type:

List[int]

class orthonym.rules.polycyclic.VonBaeyerAnalyzer#

Bases: object

Implements IUPAC von Baeyer nomenclature, through).

Pipeline: 1. Ring count: cycle_rank = edges - vertices + 1 2. Main ring: largest ring through a bridgehead pair 3. Main bridge: longest path between main bridgeheads not through main ring 4. Secondary bridges: remaining connections, independent before dependent 5. Numbering: main ring -> main bridge -> secondary bridges 6. Descriptor: prefix[bridge_lengths]

Verification: sum(bridge_lengths) + 2 = total_skeletal_atoms

analyze(mol, ring_atoms, spiro_atom=None)#

Main entry point: analyze a polycyclic system and produce its descriptor.

spiro_atom (default None) is the atom index of the spiro junction when this cage is a COMPONENT of a spiro ring system; passing it makes the numbering selection give that atom the lowest locant, ABOVE the heteroatom criteria. Every whole-molecule caller leaves it None and the analysis is byte-identical. Only the substituted/heteroatom branch honours it – a spiro component is always “substituted” (its spiro atom bonds into the other component), so it never takes the pure-cage renumber branch; if it somehow did, the criterion is a safe no-op there.

Determinism : for an UNSUBSTITUTED cage the cascade is run on a copy renumbered into a total RDKit canonical-rank order (identical for every SMILES spelling), making the descriptor spelling-independent; the numbering is then mapped back to the caller’s atom indices. If the canonical run yields a malformed descriptor the cascade falls back to the original atom order. SUBSTITUTED / heteroatom cages are NOT renumbered (that shifts substituent locants and breaks round-trips); they still tie-break on canonical ranks inside _find_main_ring.

The fallback carries no quality guarantee. This docstring used to claim the fallback “is never worse than the un-renumbered path” – it IS the un-renumbered path, so the sentence was true only vacuously, and it read as a validation that did not exist: the fallback result was returned with no check at all, which is how descriptors whose brackets do not account for every skeletal atom reached the namer .

What IS guaranteed: every return path is adjudicated by _legality_verified and the verdict published as PolycyclicDescriptor.legality. analyze still returns its best analysis when that verdict is False – the numbering remains useful to callers that only inspect the cage – so every caller that SPELLS A NAME must require ``legality is True`` and fail closed otherwise.

Parameters:
  • mol – RDKit Mol object

  • ring_atoms (Set[int]) – Set of atom indices in the ring system

Returns:

PolycyclicDescriptor with all VB information, legality set, or None when the cascade could not produce an analysis at all.

Return type:

PolycyclicDescriptor

orthonym.rules.polycyclic.vonbaeyer_cage_has_aromaticity(mol, cage_atoms)#

G0 fail-closed safety (DD7 S1 — “fail closed, never hallucinate”).

Von Baeyer and bicyclo nomenclature describe SATURATED bridged ring skeletons; unsaturation is expressible only as -ene/-yne with locants, and aromaticity CANNOT be represented at all. Naming a cage that contains aromatic ring atoms therefore silently DROPS the aromaticity and emits a structurally WRONG (de-aromatised) cage — e.g. benzonorbornadiene C1C2C=CC1c1ccccc12 -> tricyclo[4.4.0.1(2,5)]undec-3-ene (the benzo ring desaturated). The correct PIN is a bridged-fused name, e.g. 1,4-dihydro-1,4-methanonaphthalene), a Phase-G1 build; until then the caller must fail closed (raise the limit -> unknown organic compound / OrthonymLimitError) rather than emit the wrong saturated cage.

cage_atoms MUST be the EXACT atom set the namer numbers — the von-Baeyer descriptor’s numbering keys for name_polycyclic_complete, or get_complete_bicyclo_data['ring_atoms'] for the bicyclo path — NOT the molecule’s largest connected ring component. Keying off the actual cage means a PENDANT aromatic ring joined by a single (non-ring) bond — e.g. a naphthyl on norbornane, even when the naphthyl is LARGER than the cage — is never part of the cage and so can never trip the guard (it is correctly named as a substituent). (.)

orthonym.rules.polycyclic.generate_polycyclic_name(mol)#

Generate the base IUPAC name for a polycyclic bridged system.

Returns “prefix[descriptor]parentname” (e.g., “tricyclo[3.3.1.1(3,7)]decane”). Only base name – no substituents, unsaturation, or stereo.

This function is an internal helper that will be called by name_polycyclic_complete in Plan 16-03.

Parameters:

mol – RDKit Mol object

Returns:

Base name string, or None if not a polycyclic system

Return type:

str | None

orthonym.rules.polycyclic.is_polycyclic_system(mol)#

Detect whether a molecule has a bridged polycyclic ring system with ring_count >= 3 (tricyclo+).

Returns True for tricyclo and higher bridged systems. Returns False for: - bicyclo (ring_count == 2) - purely fused aromatic systems (naphthalene, perylene, coronene) - spiro systems - monocyclic rings

This function is used by the composer for routing.

Parameters:

mol – RDKit Mol object

Returns:

True if molecule has a tricyclo+ bridged polycyclic system

Return type:

bool

orthonym.rules.polycyclic.get_heteroatom_replacement_prefix(mol, numbering, ring_atoms)#

Generate the ‘a’ replacement-nomenclature prefix for ring heteroatoms.

Thin wrapper over rules/ring_replacement.build_replacement_prefix, which owns the construction (element table, Table-2.8 citation order, λ tokens, multiplying prefixes, the no-trailing-hyphen rule of. The string returned here is byte-identical to what this function built inline before

a phase — the three PIN callers (name_polycyclic_complete,

bicyclo.py) see no change.

Parameters:
  • mol – RDKit Mol object

  • numbering (Dict[int, int]) – Dict mapping atom_idx -> VB locant (1-indexed)

  • ring_atoms (Set[int]) – Set of atom indices in the ring system

Returns:

Formatted prefix string (e.g. “7-oxa”); "" when there is no heteroatom to express; or None when some skeletal atom CANNOT be expressed, in which case the caller must refuse.

Return type:

str | None

The None case is the fix for what this docstring used to concede: the old -> str signature could not report an atom it failed to express, so an off-table skeletal element was dropped from the name while the ring stem kept counting it. Its three PIN von Baeyer callers (name_polycyclic_with_heteroatoms, name_polycyclic_complete, bicyclo.get_complete_bicyclo_data) rested on an unverified assumption that such an element never reaches them; they now get the signal instead of the proof obligation. "" and None are deliberately distinct – if not prefix would conflate “carbocycle” with “refuse”, so callers test is None.

orthonym.rules.polycyclic.detect_polycyclic_lactone(mol, ring_system_atoms)#

Detect if a polycyclic system contains a lactone (cyclic ester).

A polycyclic lactone has: - An ester group [-C(=O)-O-] where both the carbonyl carbon AND

the ester oxygen are part of the ring system

  • The carbonyl oxygen (=O) is exocyclic (double-bonded to carbonyl C)

Parameters:
  • mol – RDKit Mol object

  • ring_system_atoms (Set[int]) – Set of atom indices in the polycyclic ring system

Returns:

Dict with lactone info if found – carbonyl_c: atom index of carbonyl carbon

ring_oxygen: atom index of ring (ester) oxygen carbonyl_oxygen: atom index of exocyclic carbonyl oxygen

None if no polycyclic lactone found

Return type:

Dict | None

orthonym.rules.polycyclic.name_polycyclic_with_heteroatoms(mol)#

Generate IUPAC name for a polycyclic system containing ring heteroatoms.

Uses “a” replacement nomenclature for ring heteroatoms (oxa, aza, thia) and pseudoketone naming for polycyclic lactones (oxa- prefix + -one suffix).

Format: “{hetero_prefix}bicyclo[descriptor]{parent_name}” or with -one suffix Example: “7-oxabicyclo[2.2.1]heptane” or “3-oxabicyclo[3.2.1]octan-2-one”

Parameters:

mol – RDKit Mol object

Returns:

Complete IUPAC name with heteroatom prefixes, or None if not applicable

Return type:

str | None

orthonym.rules.polycyclic.get_polycyclic_substituents(mol, ring_atoms, numbering, exclude_atoms=None)#

Detect substituents attached to a polycyclic ring system.

For each ring atom, checks neighbors not in ring_atoms. Traces each substituent branch and determines its name using get_alkyl_name.

Parameters:
  • mol – RDKit Mol object

  • ring_atoms (Set[int]) – Set of atom indices in the polycyclic ring system

  • numbering (Dict[int, int]) – Dict mapping atom_idx -> VB locant (1-indexed)

  • exclude_atoms (Set[int] | None) – Optional set of non-ring atom indices to skip (e.g., atoms that are part of functional groups)

Returns:

List of substituent info dicts, each containing –

  • ‘locant’: VB locant where substituent attaches

  • ’name’: substituent name (e.g., ‘methyl’, ‘ethyl’)

  • ’atom_indices’: list of atom indices in the substituent

Return type:

List[Dict]

Note

Skips exocyclic double bonds (=O, =S) as those are handled as suffixes.

orthonym.rules.polycyclic.get_polycyclic_unsaturation(mol, ring_atoms, numbering)#

Detect double and triple bonds within a polycyclic ring system.

Scans all bonds where BOTH atoms are in ring_atoms and identifies multiple bonds. Returns the lower VB locant for each bond.

Parameters:
  • mol – RDKit Mol object

  • ring_atoms (Set[int]) – Set of atom indices in the polycyclic ring system

  • numbering (Dict[int, int]) – Dict mapping atom_idx -> VB locant (1-indexed)

Returns:

Dict with –

  • ‘double_bonds’: list of locants for double bonds

  • ’triple_bonds’: list of locants for triple bonds

Return type:

Dict

orthonym.rules.polycyclic.get_polycyclic_stereo(mol, numbering)#

Collect and format stereodescriptors for a polycyclic system.

Uses the VB numbering to generate IUPAC locants for R/S stereocenters.

Parameters:
  • mol – RDKit Mol object (stereochemistry should already be assigned)

  • numbering (Dict[int, int]) – Dict mapping atom_idx -> VB locant (1-indexed)

Returns:

Formatted stereodescriptor string like “(1R,4S)-” or empty string if no stereodescriptors found.

Return type:

str

orthonym.rules.polycyclic.name_polycyclic_complete(mol, features=None)#

Generate the complete IUPAC name for a polycyclic bridged system.

This is the FINAL public API for polycyclic naming. It supersedes generate_polycyclic_name which only generates base names.

Produces complete names including: - Stereodescriptors: “(1R,4S)-” - Substituent prefixes: “3-methyl-” - Functional group prefixes: “5-hydroxy-” (non-principal groups) - Heteroatom replacement: “7-oxa-” (when Plan 16-02 implemented) - Descriptor: “bicyclo[2.2.1]” - Parent name with unsaturation: “hept-2-ene” - Functional group suffix: “-2-one”, “-1-ol”, “-carboxylic acid”

Example output: “(1R,4S)-5-hydroxy-3-methyl-7-oxabicyclo[2.2.1]heptan-2-one”

Parameters:
  • mol – RDKit Mol object

  • features – Optional MolecularFeatures object for functional group detection. If None, FG detection is performed directly via SMARTS.

Returns:

Tuple of (name, ring_atoms, atom_to_locant, substituents_included) where substituents_included is True (polycyclic handler discovers substituents via get_polycyclic_substituents + _detect_ring_functional_groups), or None if not a polycyclic system.