Where We Left Off

In my last post, I introduced the entire process of finding functional groups, but these groups live on the molecule's backbone. So now we need to begin the hunt for the parent chain.

The Challenge: What Even IS a Parent Chain?

The parent chain is the backbone of IUPAC naming. It's the main structure that determines the base name (e.g., "pentane" or "hexanoic acid"). But choosing it is surprisingly complex.

Imagine you're a chemist looking at a molecule. You have to decide:

  • "Which chain is the 'main' one?"
  • "What about atoms that aren't carbon, like nitrogen or oxygen?"
  • "What if there's a ring? Does that count?"

IUPAC has a whole decision tree for this. And I mean a whole decision tree.

The IUPAC Decision Tree (Simplified)

After a lot of reading and re-reading of the Blue Book, here's what I came up with:

  1. Contains the principal functional group (highest priority)

  2. Maximum number of principal characteristic groups

  3. Parent based on senior element (N > P > Si > B > O > S > C)

  4. Rings are senior to chains (if same elements)

  5. Contains more atoms

  6. Contains more multiple bonds

  7. Lower locants for principal groups

  8. Lower locants for unsaturation

  9. Maximum number of substituents

  10. Lowest set of substituent locants

  11. Name cited earlier in alphabetical order

Yes, that's 11 rules. And I'm skipping a whole lot for feasibility.

The Journey: From Simple to Complex

Attempt 1: Just Find the Longest Chain

My first implementation was naive: just find the longest carbon chain and call it a day. This works for simple alkanes like CCCC (butane) or CCCCCC (hexane). But it fails spectacularly for anything with heteroatoms.

# This works:
"CCCCCC"  hexane (6 carbons)

# This doesn't:
"CSiCCNNN"  Wait, what's the parent here?

Enter fullscreen mode Exit fullscreen mode

The issue is that IUPAC prioritizes heteroatoms (non-carbon atoms) over chain length. Nitrogen is more "senior" than silicon, which is more senior than carbon. So a chain with 3 nitrogen atoms beats a chain with 5 silicon atoms, which beats a chain with 10 carbons.

Attempt 2: Follow the Most Senior Element

I decided to build a system that could find chains for any element, not just carbon. The idea was simple:

For each senior element (N, P, Si, B, O, S, C), find all chains of that element and then score them.

This worked for simple heteroatom chains. But it still couldn't handle:

  • Molecules with functional groups
  • Molecules where the parent chain contains multiple elements (e.g., C-N-C-O-C)
  • Chains with rings

Attempt 3: The Two-Pass System (Current Implementation)

The current approach splits the problem into two cases:

Case 1: Molecules with functional groups

  1. Find all functional groups (using the motif engine)

  2. Pick the highest priority group (the "principal" group)

  3. Find all chains that contain this group

  4. Score them by:

    - Number of principal groups in the chain
    
    - Most senior heteroatoms
    
    - Chain length
    
    - Number of multiple bonds
    

The highest-scoring chain is the parent chain

Case 2: Molecules with NO functional groups (parent hydrides)

  1. Find all chains of each senior element (N, P, Si, B, O, S, C)
  2. Score them by:

    - Most senior element present
    
    - Chain length
    
    - Number of multiple bonds
    

The highest-scoring chain is the parent hydride

The Code: How It Actually Works

The Candidate System

I introduced a _Candidate class to track each potential parent chain along with its element type:

python

class _Candidate(NamedTuple):
    chain: list[int]
    elem: str

Enter fullscreen mode Exit fullscreen mode

This allows me to keep track of which element a chain is made of, which is crucial for the seniority-based selection.

The key function is _chains_through(), which finds all possible chains of a given element that pass through a specific atom:

python

def _chains_through(self, idx: int, element: str, visited=None) -> list[list[int]]:
    curr = self._molecule[idx]
    visited = visited
    visited.add(idx)

    chains = self._follow_chain(idx, element, visited)

    # If the current atom isn't the element type, or there's only one chain, return
    if curr.element.symbol != element or len(chains) < 2:
        return chains

    # Group chains by their first step to avoid duplicates
    by_subtree = {}
    for chain in chains:
        first_step = chain[1]
        best = by_subtree.get(first_step)
        if best is None or self._score_chain(_Candidate(best, element)) > self._score_chain(
                _Candidate(best, element)):
            by_subtree[first_step] = chain

    final_chains = list(by_subtree.values())

    # If there are at least two strong subtrees, merge them through the current atom
    if len(final_chains) >= 2:
        top_two = sorted(final_chains, key=lambda c: self._score_chain(_Candidate(c, element)), reverse=True)[:2]
        merged = list(reversed(top_two[0][1:])) + [idx] + top_two[1][1:]
        final_chains.append(merged)

    return final_chains

Enter fullscreen mode Exit fullscreen mode

This is where the real magic happens. The function:

  • Finds all chains of a specific element starting from the current atom

  • Groups them by their first step (to avoid duplicates)

  • If there are at least two strong branches, merges them through the current atom

Then, the _follow_chain method uses DFS to find all chains of a given element:

python

def _follow_chain(self, curr_idx: int, element: str, visited=None) -> list[list[int]]:
    if visited is None:
        visited = set()

    curr = self._molecule[curr_idx]
    visited.add(curr_idx)
    branches = []

    for neighbour_idx, _ in curr.bonds.items():
        if neighbour_idx in visited:
            continue

        neighbour = self._molecule[neighbour_idx]
        if neighbour.element.symbol == element:
            visited.add(neighbour_idx)
            branches.extend(self._follow_chain(neighbour_idx, element, visited.copy()))

    if branches:
        return [[curr_idx] + chain for chain in branches]
    elif self._molecule[curr_idx].element.symbol == element:
        return [[curr_idx]]
    else:
        return []

Enter fullscreen mode Exit fullscreen mode

Now that we have potential chains, the scoring system prioritizes chains by:

  • Length (more atoms = better)

  • Multiple bonds (more double/triple bonds = better)

  • Double bonds (within multiple bonds, more double bonds = better)

python

def _score_chain(self, candidate: _Candidate) -> tuple[int, int, int]:
    chain = candidate.chain
    if not chain:
        return 0, 0, 0

    length = len(chain)
    double_bonds = 0
    triple_bonds = 0

    for i in range(len(chain) - 1):
        atom = self._molecule[chain[i]]
        bond_order = atom.bonds.get(chain[i + 1])

        if bond_order == 3:
            triple_bonds += 1
        elif bond_order == 2:
            double_bonds += 1

    multiple_bonds = double_bonds + triple_bonds
    return length, multiple_bonds, double_bonds

Enter fullscreen mode Exit fullscreen mode

Selecting the Best Parent Chain

The _select_best_parent method applies the IUPAC rules in order:

python

def _select_best_parent(self, candidates: list[_Candidate], princip_idxs: Optional[set[int]] = None) -> Optional[_Candidate]:
    if not candidates:
        return None

    # Rule 1: Maximize principal characteristic groups
    if princip_idxs:
        max_count = max(self._princip_count(candidate.chain, princip_idxs) for candidate in candidates)
        candidates = [
            candidate for candidate in candidates
            if self._princip_count(candidate.chain, princip_idxs) == max_count
        ]

    # Rule 2: Most senior element (N > P > Si > B > O > S > C)
    for element in self._SENIOR_ELEMENTS:
        element_candidates = [candidate for candidate in candidates if candidate.elem == element]
        if element_candidates:
            return self._score_chains(element_candidates)

    return None

Enter fullscreen mode Exit fullscreen mode

The "Aha!" Moment

The biggest insight came when I realized that the parent chain doesn't have to be pure carbon. IUPAC allows chains of N, P, Si, B, O, and S to be parent hydrides. This means a molecule like CSiSiSiSiSiCNNNNNNNNNCNNNN has a parent chain of 9 nitrogen atoms (nonazane), not the carbon-silicon chain.

This is why the scoring system prioritizes the most senior element present, not just the longest chain. Nitrogen (N) is more senior than silicon (Si), which is more senior than carbon (C). So a chain with 9 N atoms beats a chain with 5 Si atoms, which beats a chain with 10 C atoms.

This is hardcoded into the scoring system, and it's what allows me to correctly select, for example, a nitrogen chain over a silicon chain.

The Takeaway

Building a parent chain finder from scratch has been one of the most challenging parts of this project. It forced me to understand IUPAC rules deeply and translate them into code. The recursive DFS approach, combined with the candidate system, gives me a flexible way to handle the complex decision tree.

Is it perfect? Not yet. But it's getting there. It's important to have an MVP running rather than hunting for optimizations at every step, that we we can learn much more about the product. Plus, this implementation works wonderfully!


What's Next?

Now onto a part I left behind! I'll be implementing:

  • Ring Detection – finding rings for cyclic compounds
  • Substituent handling – identifying and naming branches
  • Numbering – assigning lowest possible locants
  • Name assembly – combining prefixes, suffixes, and locants

It's going to be a wild ride. See you in the next post!


*P.S. If you want to follow along, the code is on GitHub. Star it if you're feeling generous!