|
| 1 | +# Divergence Analysis: Python vs C `AncestorMatcher` |
| 2 | + |
| 3 | +## 1. Traceback allelic_state traversal — SEGFAULT RISK |
| 4 | + |
| 5 | +**C (line 1812):** |
| 6 | +```c |
| 7 | +v = u; |
| 8 | +while (allelic_state[v] == TSK_NULL) { |
| 9 | + v = parent[v]; // parent was memset to 0xff (NULL_NODE = -1) |
| 10 | +} |
| 11 | +``` |
| 12 | + |
| 13 | +**Python (line 544-546):** |
| 14 | +```python |
| 15 | +v = u |
| 16 | +while self.allelic_state[v] == -1: |
| 17 | + v = self.parent[v] |
| 18 | +``` |
| 19 | + |
| 20 | +**Divergence:** The Python traceback rebuilds the full tree (parent, left_child, right_child, left_sib, right_sib) via `insert_edge`/`remove_edge` (lines 523-530). The C traceback only reconstructs `parent` (lines 1776, 1786, 1791) — it does a raw `parent[edge.child] = edge.parent` without using `insert_edge`. |
| 21 | + |
| 22 | +This means the C traceback's `parent` array is correct. BUT: if the allelic_state traversal reaches a node whose parent is `NULL_NODE` (-1), the C code indexes `parent[-1]` → **segfault**. The Python code would raise an IndexError. This can happen if the mutation is on the root node (node 0) but `u` starts at a non-root node that doesn't have a mutation on the path to root. |
| 23 | + |
| 24 | +**Specifically:** at line 1824, the upward walk for recombination stops at node 0 (`while u != 0`). But at line 1812, the allelic_state walk has no such guard — it walks `v = parent[v]` until it finds a mutation. If no mutation marks are found before reaching a node with `parent[v] == NULL_NODE`, it reads `allelic_state[-1]` which is out of bounds. |
| 25 | + |
| 26 | +## 2. Traceback tree reconstruction order — SEMANTIC DIVERGENCE |
| 27 | + |
| 28 | +**Python (lines 523-530):** |
| 29 | +```python |
| 30 | +# Reverse edges: REMOVE edges from left_index (insertion order), INSERT from right_index |
| 31 | +while k >= 0 and Il[k].left == pos: # left_index, traverse backwards |
| 32 | + self.remove_edge(Il[k]) # REMOVE |
| 33 | + k -= 1 |
| 34 | +while j >= 0 and Ir[j].right == pos: # right_index, traverse backwards |
| 35 | + self.insert_edge(Ir[j]) # INSERT |
| 36 | + j -= 1 |
| 37 | +``` |
| 38 | + |
| 39 | +**C (lines 1783-1791):** |
| 40 | +```c |
| 41 | +// Uses swapped variable names: 'in' = right_index_edges, 'out' = left_index_edges |
| 42 | +while (out_index >= 0 && out[out_index].left == pos) { // left_index |
| 43 | + parent[edge.child] = NULL_NODE; // REMOVE (set to -1) |
| 44 | + out_index--; |
| 45 | +} |
| 46 | +while (in_index >= 0 && in[in_index].right == pos) { // right_index |
| 47 | + parent[edge.child] = edge.parent; // INSERT |
| 48 | + in_index--; |
| 49 | +} |
| 50 | +``` |
| 51 | + |
| 52 | +These match — both remove from left_index and insert from right_index during reverse traversal. The naming is confusing (`in`/`out` are swapped vs the forward pass) but the logic is equivalent. |
| 53 | + |
| 54 | +## 3. Forward pass edge removal: decompression divergence |
| 55 | + |
| 56 | +**C (lines 2002-2023):** When removing an edge during the forward pass, if the child has `NULL_LIKELIHOOD` (compressed), the C code decompresses by traversing up through the L_cache to find the parent's likelihood. It then sets `L[edge.child] = L_child` and adds it to `likelihood_nodes`. |
| 57 | + |
| 58 | +**Concern:** The traversal at line 2004-2006 walks `u = parent[u]` looking for a node with either `L[u] != NULL_LIKELIHOOD` or `L_cache[u] != CACHE_UNSET`. If the edge has just been removed, `parent[edge.child]` is already set to `NULL_NODE`. But the code uses `edge.parent` at line 2003 to start the walk — not `parent[edge.child]`. This is correct because the edge was just removed. |
| 59 | + |
| 60 | +However: the walk at line 2006 uses `parent[u]` where `u` starts at `edge.parent`. If `edge.parent` is a root node, `parent[edge.parent] == NULL_NODE`. The walk would then read `L[NULL_NODE]` = `L[-1]` → **segfault**. |
| 61 | + |
| 62 | +**Python handles this differently:** it checks `self.likelihood[u]` inline and uses Python dict lookups. |
| 63 | + |
| 64 | +## 4. Forward pass initial tree loading bounds |
| 65 | + |
| 66 | +**C (line 1893):** |
| 67 | +```c |
| 68 | +while (in_index < M && out_index < M && in[in_index].left <= start_pos) { |
| 69 | +``` |
| 70 | +
|
| 71 | +**Python (line 303):** Similar loop but the condition differs slightly in how it handles the edge cases when `start_pos == 0`. |
| 72 | +
|
| 73 | +## 5. `num_nodes` in transition probability |
| 74 | +
|
| 75 | +**C (line 1484):** |
| 76 | +```c |
| 77 | +const double n = (double) self->matcher_indexes->num_nodes; |
| 78 | +``` |
| 79 | + |
| 80 | +**Python (test_lshmm.py line 371):** |
| 81 | +```python |
| 82 | +n = self.num_nodes |
| 83 | +``` |
| 84 | + |
| 85 | +Both use the total number of nodes in the tree sequence builder (not the current tree's node count). This is consistent. |
| 86 | + |
| 87 | +## 6. Traceback edge output — site index vs position |
| 88 | + |
| 89 | +**Python (lines 560-563):** Uses site INDEX for edge boundaries: |
| 90 | +```python |
| 91 | +output_edge.left = site_index # site index |
| 92 | +output_edge = Edge(right=site_index, parent=u) # site index |
| 93 | +``` |
| 94 | +Then converts to positions at lines 583-584. |
| 95 | + |
| 96 | +**C (lines 1831-1835):** Uses site POSITION directly: |
| 97 | +```c |
| 98 | +path_left[path_length] = sites_position[site]; // position |
| 99 | +path_right[path_length] = sites_position[site]; // position |
| 100 | +``` |
| 101 | + |
| 102 | +This is fine as long as `sites_position[site]` is valid, but skips the intermediate index representation. |
| 103 | + |
| 104 | +## 7. `parent` conversion — Python subtracts 1 |
| 105 | + |
| 106 | +**Python (line 588):** |
| 107 | +```python |
| 108 | +parent -= 1 |
| 109 | +``` |
| 110 | + |
| 111 | +The Python reference works with 1-indexed nodes internally (from the MatcherIndexes) and converts to 0-indexed at the end. The C code works with the same MatcherIndexes but may or may not need this offset. If the C code doesn't do this subtraction, all parent IDs are off by 1. |
| 112 | + |
| 113 | +## Summary: Most Likely Segfault Sources |
| 114 | + |
| 115 | +1. **Allelic state traversal in traceback (line 1812):** No guard against `parent[v] == NULL_NODE`. If the walk reaches the root without finding a mutation, it indexes `allelic_state[-1]`. |
| 116 | + |
| 117 | +2. **L_cache traversal during edge removal (line 2004-2006):** Walking `parent[u]` can reach `NULL_NODE` if starting from a root node, causing `L[-1]` access. |
| 118 | + |
| 119 | +3. **Recombination traversal in traceback (line 1824-1826):** The `assert(u != NULL_NODE)` at line 1826 would catch this in debug builds but is stripped in release. If `parent[u]` reaches `NULL_NODE` before reaching node 0, it reads `recombination_required[-1]`. |
| 120 | + |
| 121 | +## Files |
| 122 | + |
| 123 | +- `lib/ancestor_matcher.c` — C implementation (critical lines: 1508-1510, 1812-1814, 2004-2006, 1824-1826) |
| 124 | +- `tests/test_lshmm.py` — Python reference (lines 115-593) |
| 125 | +- `tests/algorithm.py` — Alternative Python reference (lines 719-1158) |
0 commit comments