Skip to content

Commit ebd0c6c

Browse files
Add a plan with some analysis on matching implementation
1 parent 70d45f0 commit ebd0c6c

1 file changed

Lines changed: 125 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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

Comments
 (0)