# except Exception: pass hid a dead causal engine

For 158 days, the part of NeoRx that made it a causal system called a function that doesn't exist. `nx.d_separated` was removed from NetworkX in 3.5, released May 2025, and NeoRx has pinned 3.6.1 since its first lockfile in March 2026, so every call raised `AttributeError`. Four lines under the call sat `except Exception: pass`. The score came out anyway, and it looked fine.

NeoRx is my drug-target research platform, source-available under a non-commercial licence, and this series is me auditing it in public after finding that the causal engine it's named for had never worked. Another part of the series covers the confidence intervals that resampled nothing. This one's about the test those intervals sat on top of, and it carries two lessons that aren't the same lesson. For engineers: what a swallowed exception costs. For researchers: how a component can be completely dead and still hand you numbers you'd defend in a seminar.

What's below:

- the timeline, from the line that introduced the call to the commit that deleted it
- what the method section claimed and what the interpreter did
- a six-line reproduction you can run right now, with no NeoRx involved
- why the tests, the linter and the deprecation machinery all stayed quiet
- how a dead component produces a plausible ranking, and the check that catches it

## The function was gone before the code that called it was written

On 30 March 2026 I committed the causal identifier, with two `nx.d_separated` calls in it. That same day's `uv.lock` pinned NetworkX 3.6.1, and so does every other lockfile in the repo's history, including the `v0.1.0-paper` tag from 31 March that the two manuscripts describe.

NetworkX renamed the function to `is_d_separator` in 3.3, released 6 April 2024, and kept a deprecated `d_separated` wrapper for two releases. Version 3.5, released 29 May 2025, expired it. The 3.5 release notes put it in one line: "Expire d_separated and minimum_d_separator functions."

So there was never a window where my code worked. I'd written it against an API that had been gone for ten months, into an environment that had never once contained it.

I found this on 3 September 2026, writing the design spec for a correctness sub-project whose whole job was to ask whether NeoRx's numbers were right rather than merely traceable. Both call sites came out the next day, at 15:02 and 17:05. On 5 September I cut 0.3.0, yanked 0.2.0, and wrote correction notices for both manuscripts: they withdraw every benchmark figure pending re-measurement, plus the methodological claim that gives the work its name.

## What the method section said, and what ran

The paper says NeoRx applies the backdoor criterion. Pearl's version of it (Causality, Definition 3.3.1) asks two things of an adjustment set Z: no member of Z is a descendant of the treatment, and Z blocks every path between treatment and outcome that contains an arrow into the treatment. The point of the criterion is that if such a Z exists, the effect is identifiable from observational data, and you know what to adjust for.

Here's what the code did with the candidate confounders, verbatim, from the tag the manuscripts describe:

```python
    # Verify d-separation when possible
    valid_adjustment: list[str] = []
    for node in candidates:
        z = frozenset({node})
        try:
            if nx.d_separated(G, {treatment}, {outcome}, z):
                # This node alone blocks a confounding path
                valid_adjustment.append(node)
            else:
                # Still include — it blocks *some* paths
                valid_adjustment.append(node)
        except Exception:
            # d_separated may fail on cyclic graphs; include anyway
            valid_adjustment.append(node)
```

Read the three branches. Pass, fail, raise: the node gets appended. Line 662 of that file is a test whose result can't reach anything, so it was decorative even before you know the function was missing. The `except` is the loudest of the four faults my correction notice lists, but it isn't the only one, and that matters later.

The second call site, at line 720 in `_estimate_causal_effect`, is where the AttributeError got swallowed in silence:

```python
    # 2. d-Separation quality
    dsep_factor = 1.0
    if adjustment_set:
        try:
            z = frozenset(adjustment_set)
            # In the mutilated graph (remove arrows into treatment),
            # check if adjustment blocks confounding
            if nx.d_separated(G, {treatment}, {outcome}, z):
                dsep_factor = 1.2  # bonus for clean identification
        except Exception:
            pass
    else:
        dsep_factor = 0.8  # No confounders blocked
```

On paper `dsep_factor` takes three values. In practice it took two: 1.0 when an adjustment set existed, 0.8 when it didn't. The 1.2 branch was unreachable on NetworkX 3.6.1, and on the graph NeoRx builds the adjustment set came out empty for every gene, so the number that reached the score was 0.8 nearly everywhere.

Identifiability itself, the thing `identifier.py` is named for, was decided at line 274, one line above the adjustment set, by this:

```python
is_identifiable = len(causal_pathway) > 0
```

`causal_pathway` is the shortest path from gene to disease, and when there's no directed path the helper falls back to the undirected graph. So "identifiable" meant "these two nodes are connected somehow", which on a graph stitched together from association edges is almost everyone. The adjustment set computed one line above was never read again.

![Two flowcharts side by side. The left one, what the method described, goes from finding candidate confounders to testing each with d-separation to keeping the ones that block to identifiable if the adjustment set is valid. The right one, what actually ran, goes from finding candidate confounders, none of which exist on the real graph, to nx.d_separated raising AttributeError, to if, else and except all keeping the node, to identifiable if any path exists and the set is never read.](https://cdn.hashnode.com/uploads/gql/6475f517d43ab4d34b06d4df/78ba3652-9344-4384-aa6b-6ff375e2a067.png)

## Six lines you can run yourself

You don't need NeoRx, a knowledge graph or a licence to see the shape of this. Save these 14 lines as `repro.py`:

```python
import networkx as nx

# Z confounds X and Y, and there is no X -> Y edge,
# so {Z} does d-separate X from Y. The honest answer is True.
G = nx.DiGraph([("Z", "X"), ("Z", "Y")])

bonus = 1.0
try:
    if nx.d_separated(G, {"X"}, {"Y"}, {"Z"}):
        bonus = 1.2
except Exception:
    pass

print(f"networkx {nx.__version__}: bonus = {bonus}")
```

Then run it twice, once on NetworkX 3.4.2 which still had the function, once on 3.6.1 which doesn't:

```bash
uv run --no-project --quiet --with networkx==3.4.2 python repro.py
uv run --no-project --quiet --with networkx==3.6.1 python repro.py
```

![Terminal output of the same script under two NetworkX versions. Under 3.4.2 it prints a DeprecationWarning saying d_separated is deprecated and will be removed in NetworkX v3.5, then bonus = 1.2. Under 3.6.1 it prints bonus = 1.0 with no warning at all.](https://cdn.hashnode.com/uploads/gql/6475f517d43ab4d34b06d4df/31830663-75ed-49de-be20-1917bc3b27d8.png)

*Run on 15 September 2026. Same file, same graph, no error either way.*

Same script, same graph, a weight that differs by 20%, and nothing in the 3.6.1 run that a human or a log aggregator would ever notice. That's the whole bug, in 14 lines. NeoRx's version was the same shape, buried in a 1,291-line module.

## Every safety net had a reason to stay quiet

This is the part I keep coming back to. None of the layers that should have caught this were missing. All four were there, and all four agreed the code was fine.

**The test asserted the wrong kind of thing.** The only test on the adjustment set was this:

```python
    def test_returns_list(self, hiv_networkx):
        disease_node = _find_disease_node(hiv_networkx, "HIV")
        adj_set = _compute_adjustment_set(hiv_networkx, "gene:CCR5", disease_node)
        assert isinstance(adj_set, list)
```

A function whose body is `return []` passes that assertion. So does one that appends every candidate node it sees, which is roughly what `_compute_adjustment_set` did for 158 days.

**The deprecation warning never had a chance.** NetworkX did the responsible thing here: 3.3 and 3.4 both warned before 3.5 removed the name. By March 2026 the wrapper was gone, so there was no warning left to print. And even inside that 14-month window it wouldn't have reached me, because Python's default filter is `default::DeprecationWarning:__main__` followed by `ignore::DeprecationWarning`. A library calling a deprecated library warns nobody. I checked that one too: `nx.d_separated` called from an imported module on 3.4.2 prints nothing at all and hands back a result.

**The linter wasn't asked.** NeoRx's ruff config selects seven rule groups, E, F, W, I, UP, B and SIM. That's a reasonable set, and it doesn't include either of the two rules that catch exactly this. Turn both on and my 14-line reproduction fails the lint:

```console
$ uvx ruff check --select BLE001,S110 repro.py
repro.py:11:1: S110 `try`-`except`-`pass` detected, consider logging the exception
repro.py:11:8: BLE001 Do not catch blind exception: `Exception`
Found 2 errors.
```

**The comment was a confession.** "d_separated may fail on cyclic graphs; include anyway." In March I knew about one specific failure mode, cycles, and I wrote a handler wide enough to swallow every other one, including the AttributeError that was firing on every call. A broad `except` is a claim about what you expect. Python reads it as a claim about what you accept.

## For researchers: a dead component still gives you numbers

Here's the part that should bother anyone who ships analysis code rather than services. It's the reason I'd rather write this post than quietly fix the repo.

When a web handler dies you get a 500, a stack trace and, if you're unlucky, a customer. When a scoring component dies and something absorbs the failure, you get a score. The run finishes, the table renders, the figure looks like a figure, and nothing anywhere is red.

Worse, this failure mode hides in the output. Identifiability was 15% of NeoRx's composite confidence, scored as a 0 or a 1, and it came out 1 for any candidate connected to the disease at all, which on a graph stitched from association edges is almost everyone. A component that returns the same value for every row is a constant, and a constant can't reorder anything. Every rank-based sanity check I could have run would have looked right, because the ordering really was being driven by the five components that still worked.

Constants do one thing, though. They shift scores against fixed thresholds. NeoRx promoted a target to CAUSAL at a composite of 0.6 or above, so a free 0.15 handed to almost every candidate moved things across that line, and it did it without ever producing an order that looked wrong.

So here's a check that isn't specific to NeoRx at all. Score a batch of 8 candidates, then count how many distinct values each component took across them:

```python
import random

random.seed(7)


def strict_check(candidate):
    # Stands in for any dependency call that no longer exists.
    # In NeoRx it was nx.d_separated after NetworkX 3.5.
    raise AttributeError("module has no attribute 'd_separated'")


def score(candidate):
    evidence = candidate["evidence"]
    has_path = candidate["path_len"] > 0

    passed_strict = False
    try:
        passed_strict = strict_check(candidate)
    except Exception:
        pass

    return {
        "name": candidate["name"],
        "evidence": evidence,
        "has_path": has_path,
        "passed_strict": passed_strict,
        "score": round(0.6 * evidence + 0.25 * has_path + 0.15 * passed_strict, 3),
    }


candidates = [
    {"name": n, "evidence": round(random.uniform(0.2, 0.95), 2),
     "path_len": random.choice([1, 2, 3])}
    for n in "ABCDEFGH"
]
rows = sorted((score(c) for c in candidates), key=lambda r: -r["score"])

for r in rows:
    print(f'{r["name"]}  score={r["score"]:.3f}')

# The check that would have caught it: does every component actually vary?
print()
for key in ("evidence", "has_path", "passed_strict"):
    seen = {r[key] for r in rows}
    flag = "  <- one value for every row. Is this alive?" if len(seen) == 1 else ""
    print(f"{key:14} {len(seen)} distinct{flag}")
```

![Terminal output of the demo script. Eight candidates ranked by score from 0.568 down to 0.394, then a component report: evidence took 8 distinct values, has_path took 1 distinct value, passed_strict took 1 distinct value, the last two flagged with one value for every row, is this alive?](https://cdn.hashnode.com/uploads/gql/6475f517d43ab4d34b06d4df/f9ce6c33-f62d-4f21-952e-8c166c3408da.png)

*Standard library only, runs with `python3 dead_component.py`.*

The ranking at the top is perfectly plausible. Eight candidates, scores from 0.568 down to 0.394, a clear winner. The three lines at the bottom are what tell you that two of the three inputs are wallpaper, and that one of them is wallpaper because an AttributeError is being eaten on every row. The same rule catches the dead component and the accidentally constant one, which matters, because in NeoRx both were true at once.

## The fix, and the nine minutes it nearly went wrong

The repair wasn't repairing the call. By September there was nothing left to repair it to.

Three other faults meant that even a working `d_separated` wouldn't have produced the backdoor criterion. The design asked for d-separation in the graph with arrows *into* the treatment removed, but that's the interventional graph, and it leaves the direct causal path intact, so gene and disease can never separate. The graph itself held no upstream variables at all: every edge pointed away from genes, a gene's only candidate parents came from undirected protein interaction data, and on the star topology the builder produces the adjustment set is empty for every gene. Then there's the three-identical-branches problem from earlier. My correction notice lists four findings and says any one of them would be sufficient on its own. That's the honest version. The `except` didn't hide a working engine, it hid the fact that there was nothing behind it.

So in 0.3.0 identification got rewritten on a typed causal subgraph. It calls `nx.is_d_separator` on a graph with the treatment's *outgoing* edges removed, and it returns one of seven explicit verdicts, each with the adjustment set that justifies it. That's part 4 of this series, and it needs the space.

Two small things from the 4 September cleanup that I'd tell anyone doing the same work.

First, deleting a swallowed exception changes behaviour, so you have to decide that on purpose. At 17:05 I replaced the block with `dsep_factor = 1.2 if adjustment_set else 0.8`, which is what the code looked like it had wanted all along. At 17:14 I put it back to 1.0, because 1.0 is what the broken version had actually been feeding the score, and quietly changing a live weight while fixing a bug is how one defect turns into two. That commit's message is "restore dsep_factor to 1.0, matching pre-fix behaviour".

Second, the CI gate I wrote to stop these names coming back shipped at 17:00 with the same class of bug inside it. Its first version skipped every line sitting in a `try` block, on the theory that such code is defensive. The fabricated statistics I was gating against lived in `try` blocks. Five minutes later I replaced all of that with a plain literal search, and `d_separated` is on its list now, next to the two fake bootstrap helpers and the dead p-value, so CI fails if any of the four comes back.

## The general lesson

For engineers: `except Exception: pass` isn't error handling. It's a decision that everything below it runs on made-up state. If you can't name the exception, you don't know what you're recovering from, so catch the specific class, let the rest crash, and if something truly has to continue, log it loudly enough that a `grep` finds it six months later. NeoRx's reinforcement learning stage had the polite version of this: a broad `except` that logged "collecting partial results" and carried on with an empty list on every run. That's how a pipeline ships without ever producing a single candidate. It's part 7.

For researchers: code that fails loudly is a luxury, and analysis pipelines mostly don't hand it to you. A dead scoring component doesn't crash. It contributes a constant, and constants are invisible in exactly the sanity check you're most likely to run. So test liveness, not just plausibility. Does each component take more than one value across a real batch? Can you flip it by changing an input you know should flip it? If not, that component isn't measuring anything, however good the ranking looks.

## Check yours today

Two commands. On any analysis repo:

```bash
uvx ruff check --select BLE001,S110 path/to/src
grep -rn -A1 "except Exception" path/to/src | grep -B1 "pass"
```

Then, for every component feeding a score, count its distinct values over one real batch. Anything stuck on a single value is either dead or a constant you forgot you'd hardcoded, and you want to know which one before it reaches a table with your name on it.

What I still don't know is how much of the old ranking the five working components were carrying on their own. Nobody can answer that from here: the benchmark script persisted no output, its results directory was never in version control, and there's nothing left to re-analyse. The whole measurement gets redone from scratch on version-pinned inputs, with the outputs written down this time. That part stings more than the bug did.

Part 4 is the rebuild: what a typed causal subgraph is, why identification returns seven verdicts now instead of one boolean, and how many candidates survive once the criterion is applied for real.

