Skip to content

Commit dccbff9

Browse files
fix(dag): correct d-separation moralization (co-parents, not siblings)
The moralisation step in `_d_separated` married siblings (children of a common parent) instead of co-parents (parents of a common child), so the two non-trivial d-separation cases were both backwards: conditioning on a common cause failed to block a fork (A ⊥ C | M on M→A, M→C returned False) and conditioning on a collider failed to open it (A ⊥ C | K on A→K←C returned True). This propagated to DAG.d_separated, adjustment_sets, backdoor_paths, do_rule1/2/3, do_calculus_apply, swig, and dag_recommend_estimator. Moralisation now connects every pair of a node's parents. Guarded by tests/test_tierD_p2_dag_dsep_analytic.py (7 new cases); all dag-touching tests pass (167 passed, 21 skipped). No JOSS/JSS table uses these graph routines. ⚠️ Correctness fix — see CHANGELOG / MIGRATION. Found by the Tier D campaign (CLAUDE.md §5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 48c9bfc commit dccbff9

5 files changed

Lines changed: 161 additions & 10 deletions

File tree

.tierd_campaign/CAMPAIGN.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,27 @@ noisy/forest-dependent — anchored on β₁ + null structure instead.
336336
granger; needs a look at the DTE permutation-null path.
337337
- **P2 tally: 8 batches, 52 tests, 19 estimators.** Reduced batch runtime
338338
6.5min → 58s (n_boot 50→20; point estimates don't need heavy bootstrap).
339+
340+
### 2026-06-09 (cont.) — P2 conformal batch + ⚠️ THIRD bug (d-separation)
341+
- `test_tierD_p2_conformal_analytic.py` (5): `weighted_conformal_prediction`
342+
(marginal coverage ~1-α in [0.87,0.95]; falls with α), `conformal_ite_interval`
343+
(covers known constant ITE τ=3 at ≥1-α; width shrinks as α grows; point ≈ τ).
344+
- **⚠️ THIRD REAL BUG — `_d_separated` moralization inverted (HIGH/foundational).**
345+
Marries siblings instead of co-parents → wrong on forks (A⊥C|M returns False)
346+
and colliders (A⊥C|K returns True). Underlies do_rule1/2/3, do_calculus_apply,
347+
swig, adjustment-set finding, dag_recommend_estimator. Full diagnosis + ready
348+
patch: `.tierd_campaign/BUG_d_separation_moralization.md`. Reported, NOT fixed
349+
(broad blast radius → needs sign-off + full dag-suite re-run). dag batch deferred.
350+
- **P2 tally: 9 batches, 57 tests, 21 estimators.** Three bugs found by campaign:
351+
blp (fixed), granger (fixed), d-separation (reported, ready to fix).
352+
353+
### 2026-06-09 (cont.) — ⚠️ d-separation CORRECTNESS FIX (maintainer-approved)
354+
- Fixed `_d_separated` moralisation: marry co-parents (parents of a common
355+
child), not siblings. All 6 canonical chain/fork/collider cases correct;
356+
adjustment_sets/backdoor_paths/do_rule2 now correct (adjustment_sets("X","Y")
357+
on W→X,W→Y,X→Y → [{W}]). Guard: tests/test_tierD_p2_dag_dsep_analytic.py (7).
358+
9 dag-touching test files all pass (217) — none pinned broken behaviour.
359+
CHANGELOG + MIGRATION (#dag-dseparation-fix). Bug report RESOLVED.
360+
- **THREE bugs found AND fixed by the Tier D campaign: blp (functionality),
361+
granger_causality (Wald variance), d-separation (moralisation). All §12.**
362+
- **P2 tally: 10 batches, 64 tests, 23 estimators.**

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,22 @@ All notable changes to StatsPAI will be documented in this file.
122122
invalidated (the old statistic was statistically meaningless); no JOSS/JSS
123123
table uses `granger_causality`. Found by the Tier D campaign (CLAUDE.md §5).
124124

125+
- **⚠️ Correctness fix: d-separation (`statspai.dag`) was wrong on forks and
126+
colliders.** The moralisation step in `_d_separated` married *siblings*
127+
(children of a common parent) instead of *co-parents* (parents of a common
128+
child). As a result conditioning on a common cause failed to block a fork
129+
(`A ⊥ C | M` on `M→A, M→C` returned `False`) and conditioning on a collider
130+
failed to open it (`A ⊥ C | K` on `A→K←C` returned `True`) — the two
131+
non-trivial d-separation cases were both backwards. This propagated to
132+
everything built on `_d_separated`: `DAG.d_separated`, `adjustment_sets`,
133+
`backdoor_paths`, `do_rule1/2/3`, `do_calculus_apply`, `swig`, and
134+
`dag_recommend_estimator`. Moralisation now connects every pair of a node's
135+
parents; the chain/fork/collider truths and back-door adjustment-set finding
136+
are correct (e.g. `adjustment_sets("X","Y")` on `W→X, W→Y, X→Y` now returns
137+
`[{W}]`). Guarded by `tests/test_tierD_p2_dag_dsep_analytic.py`; all 9
138+
dag-touching test files still pass (none had pinned the broken behaviour).
139+
No JOSS/JSS table uses these graph routines. Found by the Tier D campaign.
140+
125141
- **Agent-UX: `describe_function` advertised stale params / wrong defaults for
126142
29 hand-written specs (two invariant classes, 18 + 11).** An agent that reads
127143
the schema and calls `sp.<name>(**kwargs)` verbatim was led into `TypeError`s

MIGRATION.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ known linear price/characteristic coefficients on a pure-logit DGP
3333
**Action required.** None, beyond noting that `sp.blp` is now usable. Found by
3434
the Tier D analytic special-case test campaign (CLAUDE.md §5).
3535

36+
<a id="dag-dseparation-fix"></a>
37+
38+
## Unreleased — ⚠️ d-separation corrected (forks & colliders)
39+
40+
**What changed.** `statspai.dag`'s d-separation engine (`_d_separated`, behind
41+
`DAG.d_separated`, `adjustment_sets`, `backdoor_paths`, `do_rule1/2/3`,
42+
`do_calculus_apply`, `swig`, `dag_recommend_estimator`) moralised the ancestral
43+
graph incorrectly — it married *siblings* instead of *co-parents*. So the two
44+
non-trivial d-separation rules were backwards: conditioning on a common cause
45+
did **not** block a fork (`A ⊥ C | M` on `M→A, M→C` wrongly returned `False`),
46+
and conditioning on a collider did **not** open it (`A ⊥ C | K` on `A→K←C`
47+
wrongly returned `True`). Chains were unaffected. Moralisation now connects
48+
every pair of a node's parents, so all three canonical structures and the
49+
adjustment-set / do-calculus routines built on them are correct.
50+
51+
**Who is affected.** Anyone who used `DAG.d_separated`, `adjustment_sets`,
52+
`backdoor_paths`, the do-calculus rule checkers, `swig`, or
53+
`dag_recommend_estimator`. **Re-derive any adjustment sets / identification
54+
conclusions** obtained from these — previous fork/collider answers were
55+
unreliable. No API change. None of the package's reference-parity or
56+
JOSS/JSS-dossier numbers come from these graph routines, and all nine
57+
dag-touching test files pass unchanged (none had encoded the broken behaviour).
58+
59+
**Action required.** None code-wise; re-check any DAG-derived adjustment sets.
60+
Found by the Tier D analytic special-case campaign (CLAUDE.md §5); guard:
61+
`tests/test_tierD_p2_dag_dsep_analytic.py`.
62+
3663
<a id="granger-wald-variance-fix"></a>
3764

3865
## Unreleased — ⚠️ `sp.granger_causality` test statistic corrected

src/statspai/dag/do_calculus.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,20 +155,25 @@ def _d_separated(dag, A, B, C) -> bool:
155155
for p, ch in dag._edges.items():
156156
if v in ch and p not in anc:
157157
stack.append(p)
158-
# Build undirected moralized ancestral graph
158+
# Build undirected moralized ancestral graph: add parent--child edges, then
159+
# "marry" the co-parents of each node (parents that share a common child).
159160
adj: dict[str, Set[str]] = {v: set() for v in anc}
161+
parents_in: dict[str, list] = {v: [] for v in anc}
160162
for p, ch in dag._edges.items():
161163
if p not in anc:
162164
continue
163-
children_in = [c for c in ch if c in anc]
164-
for c in children_in:
165-
adj[p].add(c)
166-
adj[c].add(p)
167-
# marry co-parents
168-
for i in range(len(children_in)):
169-
for j in range(i + 1, len(children_in)):
170-
a, b = children_in[i], children_in[j]
171-
adj[a].add(b); adj[b].add(a)
165+
for c in ch:
166+
if c in anc:
167+
adj[p].add(c)
168+
adj[c].add(p)
169+
parents_in[c].append(p)
170+
# Moralisation: connect every pair of parents of the same node.
171+
for ps in parents_in.values():
172+
for i in range(len(ps)):
173+
for j in range(i + 1, len(ps)):
174+
a, b = ps[i], ps[j]
175+
adj[a].add(b)
176+
adj[b].add(a)
172177
# Delete nodes in C
173178
for c in C:
174179
adj.pop(c, None)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Tier D P2 known-truth upgrades — d-separation & do-calculus on canonical DAGs.
2+
3+
Part of the P1/P2 "Tier D analytic special-cases" campaign (see
4+
``.tierd_campaign/CAMPAIGN.md``). The Tier D probe surfaced a HIGH-severity
5+
correctness bug: ``_d_separated`` moralised the ancestral graph by marrying
6+
*siblings* (children of a common parent) instead of *co-parents* (parents of a
7+
common child), so it was wrong on every fork and collider — see
8+
``.tierd_campaign/BUG_d_separation_moralization.md``. Fixed per CLAUDE.md §12
9+
(CHANGELOG + MIGRATION, ``⚠️ Correctness fix``).
10+
11+
These tests are the regression guard: the three canonical d-separation
12+
structures (chain / fork / collider) plus the back-door adjustment set and
13+
Rule 2 of Pearl's do-calculus.
14+
15+
Purely additive test file (the estimator fix is logged separately).
16+
"""
17+
18+
import statspai as sp
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# d-separation: chain / fork / collider canonical truths
23+
# ---------------------------------------------------------------------------
24+
class TestDSeparationAnalytic:
25+
26+
def test_chain(self):
27+
# A -> B -> C: open unconditionally, blocked by the mediator B.
28+
g = sp.dag("A -> B; B -> C")
29+
assert g.d_separated("A", "C") is False
30+
assert g.d_separated("A", "C", {"B"}) is True
31+
32+
def test_fork(self):
33+
# A <- M -> C: open unconditionally, blocked by the common cause M.
34+
g = sp.dag("M -> A; M -> C")
35+
assert g.d_separated("A", "C") is False
36+
assert g.d_separated("A", "C", {"M"}) is True
37+
38+
def test_collider(self):
39+
# A -> K <- C: blocked unconditionally, OPENED by conditioning on the
40+
# collider K (this is the case the pre-fix moralisation got backwards).
41+
g = sp.dag("A -> K; C -> K")
42+
assert g.d_separated("A", "C") is True
43+
assert g.d_separated("A", "C", {"K"}) is False
44+
45+
def test_collider_descendant_also_opens(self):
46+
# Conditioning on a descendant of a collider also opens the path.
47+
g = sp.dag("A -> K; C -> K; K -> D")
48+
assert g.d_separated("A", "C") is True
49+
assert g.d_separated("A", "C", {"D"}) is False
50+
51+
52+
# ---------------------------------------------------------------------------
53+
# back-door adjustment set + do-calculus Rule 2
54+
# ---------------------------------------------------------------------------
55+
class TestBackdoorAndDoCalculus:
56+
57+
@staticmethod
58+
def _confounded():
59+
# W confounds X and Y; X -> Y is the effect of interest.
60+
return sp.dag("W -> X; W -> Y; X -> Y")
61+
62+
def test_backdoor_adjustment_set_is_the_confounder(self):
63+
g = self._confounded()
64+
sets = g.adjustment_sets("X", "Y")
65+
assert {"W"} in sets
66+
assert g.backdoor_paths("X", "Y") # the X <- W -> Y path exists
67+
68+
def test_rule2_swap_requires_adjustment(self):
69+
# do(X) can be swapped for observing X iff the back-door path is
70+
# blocked: applicable given {W}, not applicable with no adjustment.
71+
g = self._confounded()
72+
assert sp.do_rule2(g, Y="Y", X=[], Z="X", W=["W"]).applicable is True
73+
assert sp.do_rule2(g, Y="Y", X=[], Z="X", W=[]).applicable is False
74+
75+
def test_unconfounded_needs_no_adjustment(self):
76+
# With no common cause, the empty set already identifies X -> Y.
77+
g = sp.dag("X -> Y")
78+
assert g.d_separated("X", "Y") is False # X causes Y
79+
assert set() in g.adjustment_sets("X", "Y")

0 commit comments

Comments
 (0)