| """ |
| EXACT, EXHAUSTIVE verification of Lemma 1, Theorem 1, Theorem 2 and Theorem 4 |
| over the COMPLETE space of 4-trait evolutionary selection models: |
| |
| all 543 labelled DAGs on X_1..X_4 x all 2^4 = 16 choices of pa(S) |
| = 8,688 static models G, each unrolled to T = 1,2,3, |
| x all 55 disjoint triples (A,B,C) with A,B non-empty. |
| |
| Nothing here is sampled or simulated: every number is an exact combinatorial |
| count at machine precision. A random-model sweep at d = 5..10 extends the same |
| checks beyond the exhaustive range. |
| |
| Run: python3 exhaustive.py |
| """ |
| import itertools, json, os, time |
| import numpy as np |
| from evosel import (DG, SEL, dsep, cpdag, cpdag_key, clique_augmented, |
| multidomain_augmented, evolutionary_graph, evo_counts, |
| random_static_dag) |
|
|
| OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'outputs') |
| os.makedirs(OUT, exist_ok=True) |
|
|
|
|
| |
| def all_static_models(d): |
| """Every labelled DAG on d traits x every subset of X as pa(S).""" |
| pairs = list(itertools.combinations(range(d), 2)) |
| dags = [] |
| for code in itertools.product((0, 1, 2), repeat=len(pairs)): |
| g = DG(range(d)) |
| for (a, b), c in zip(pairs, code): |
| if c == 1: |
| g.add(a, b) |
| elif c == 2: |
| g.add(b, a) |
| if g.is_acyclic(): |
| dags.append([(u, v) for (u, v) in g.edges()]) |
| models = [] |
| for es in dags: |
| for r in range(d + 1): |
| for ps in itertools.combinations(range(d), r): |
| G = DG(list(range(d)) + [SEL], es) |
| for p in ps: |
| G.add(p, SEL) |
| models.append(G) |
| return dags, models |
|
|
|
|
| def triples(d): |
| """All (A,B,C) disjoint subsets of X with A,B non-empty, de-duplicated |
| under the A<->B symmetry.""" |
| out = [] |
| for code in itertools.product((0, 1, 2, 3), repeat=d): |
| A = [i for i in range(d) if code[i] == 0] |
| B = [i for i in range(d) if code[i] == 1] |
| C = [i for i in range(d) if code[i] == 2] |
| if A and B and A[0] < B[0]: |
| out.append((A, B, C)) |
| return out |
|
|
|
|
| def naive_dag(G, d): |
| """The selection-blind baseline: G with S simply deleted.""" |
| g = DG(range(d)) |
| for j in range(d): |
| for i in G.pa[j]: |
| if i != SEL: |
| g.add(i, j) |
| return g |
|
|
|
|
| |
| def run_dsep_claims(d=4, Ts=(1, 2, 3)): |
| """Lemma 1 (implication + converse failure) and Theorem 1 (biconditional), |
| plus the two destructive controls.""" |
| _, models = all_static_models(d) |
| trs = triples(d) |
| R = {'d': d, 'n_models': len(models), 'n_triples_per_model': len(trs), |
| 'Ts': list(Ts)} |
| lem_tested = lem_viol = lem_conv_fail = 0 |
| thm_tested = thm_viol = 0 |
| naive_tested = naive_mism = 0 |
| noinherit_tested = noinherit_conv_fail = 0 |
| degenerate_ok = degenerate_n = 0 |
| per_T = {} |
| for T in Ts: |
| t_l = t_v = t_c = t_t = t_tv = t_nm = 0 |
| for G in models: |
| gT = evolutionary_graph(G, d, T) |
| gp = clique_augmented(G, d) |
| gn = naive_dag(G, d) |
| Sall = [('S', t) for t in range(T)] |
| for (A, B, C) in trs: |
| AT = [('X', i, T) for i in A] |
| BT = [('X', i, T) for i in B] |
| CT = [('X', i, T) for i in C] + Sall |
| ev = dsep(gT, AT, BT, CT) |
| st = dsep(G, A, B, C + [SEL]) |
| pl = dsep(gp, A, B, C) |
| nv = dsep(gn, A, B, C) |
| t_l += 1 |
| t_v += (ev and not st) |
| t_c += (st and not ev) |
| t_t += 1 |
| t_tv += (ev != pl) |
| t_nm += (ev != nv) |
| per_T[T] = {'lemma1_tested': t_l, 'lemma1_violations': t_v, |
| 'lemma1_converse_failures': t_c, |
| 'theorem1_tested': t_t, 'theorem1_violations': t_tv, |
| 'control_naive_Sdeleted_mismatches': t_nm} |
| lem_tested += t_l; lem_viol += t_v; lem_conv_fail += t_c |
| thm_tested += t_t; thm_viol += t_tv |
| naive_tested += t_t; naive_mism += t_nm |
| R['per_T'] = per_T |
| R['lemma1'] = {'tested': lem_tested, 'violations': lem_viol, |
| 'converse_failure_witnesses': lem_conv_fail} |
| R['theorem1'] = {'tested': thm_tested, 'violations': thm_viol, |
| 'agreement': 1.0 - thm_viol / max(thm_tested, 1)} |
| R['control_naive'] = {'tested': naive_tested, 'mismatches': naive_mism, |
| 'agreement': 1.0 - naive_mism / max(naive_tested, 1)} |
|
|
| |
| diff_T = 0 |
| for G in models: |
| sigs = [] |
| for T in (1, 2, 3, 4): |
| gT = evolutionary_graph(G, d, T) |
| Sall = [('S', t) for t in range(T)] |
| sigs.append(tuple(dsep(gT, [('X', i, T) for i in A], |
| [('X', i, T) for i in B], |
| [('X', i, T) for i in C] + Sall) |
| for (A, B, C) in trs)) |
| diff_T += (len(set(sigs)) != 1) |
| R['theorem1_implication1_T_invariance'] = { |
| 'models': len(models), 'models_whose_dsep_set_changes_with_T': diff_T, |
| 'Ts_compared': [1, 2, 3, 4]} |
|
|
| |
| for G in models: |
| if not G.pa[SEL]: |
| degenerate_n += 1 |
| degenerate_ok += (sorted(clique_augmented(G, d).edges()) |
| == sorted(naive_dag(G, d).edges())) |
| R['theorem1_implication2_degenerate'] = { |
| 'models_with_no_selection_parents': degenerate_n, |
| 'models_where_Gplus_equals_G_minus_S': degenerate_ok} |
|
|
| |
| |
| |
| T = 3 |
| for G in models: |
| gT = evolutionary_graph(G, d, T) |
| g2 = DG(gT.nodes) |
| for (u, v) in gT.edges(): |
| if not (u[0] == 'e' and v[0] == 'e'): |
| g2.add(u, v) |
| gn = naive_dag(G, d) |
| Sall = [('S', t) for t in range(T)] |
| for (A, B, C) in trs: |
| ev = dsep(g2, [('X', i, T) for i in A], [('X', i, T) for i in B], |
| [('X', i, T) for i in C] + Sall) |
| noinherit_tested += 1 |
| noinherit_conv_fail += (dsep(G, A, B, C + [SEL]) and not ev) |
| R['control_no_inheritance'] = { |
| 'tested': noinherit_tested, |
| 'lemma1_converse_failures_without_inheritance': noinherit_conv_fail} |
| return R |
|
|
|
|
| |
| def run_theorem2(d=4): |
| """Theorem 2: adjacency soundness+completeness, orientation soundness, and |
| orientation COMPLETENESS (checked by exhaustive search for the alternative |
| DAG G' the theorem asserts must exist).""" |
| _, models = all_static_models(d) |
| keys, info = [], [] |
| for G in models: |
| gp = clique_augmented(G, d) |
| dd, uu = cpdag(gp) |
| keys.append(cpdag_key(dd, uu, d)) |
| rel = {} |
| for i, j in itertools.combinations(range(d), 2): |
| rel[(i, j)] = 1 if G.has(i, j) else (2 if G.has(j, i) else 0) |
| info.append((G, dd, uu, rel)) |
| |
| groups = {} |
| for k, (G, dd, uu, rel) in zip(keys, info): |
| groups.setdefault(k, []).append(rel) |
|
|
| n_pairs = adj_bad = 0 |
| n_or = or_bad = 0 |
| n_un = un_incomplete = 0 |
| n_or_into_anS = 0 |
| for k, (G, dd, uu, rel) in zip(keys, info): |
| anS = G.ancestors([SEL]) - {SEL} |
| adj = set() |
| for (u, v) in dd: |
| adj.add(frozenset((u, v))) |
| adj |= set(uu) |
| for i, j in itertools.combinations(range(d), 2): |
| n_pairs += 1 |
| truth = (G.has(i, j) or G.has(j, i) or ({i, j} <= anS)) |
| adj_bad += (truth != (frozenset((i, j)) in adj)) |
| for (u, v) in dd: |
| n_or += 1 |
| or_bad += not (G.has(u, v) and v not in anS) |
| n_or_into_anS += (v in anS) |
| alts = groups[k] |
| for e in uu: |
| i, j = sorted(tuple(e)) |
| n_un += 1 |
| if not any(r[(i, j)] != rel[(i, j)] for r in alts): |
| un_incomplete += 1 |
| return {'d': d, 'n_models': len(models), |
| 'adjacency_pairs_tested': n_pairs, 'adjacency_violations': adj_bad, |
| 'oriented_edges_tested': n_or, 'orientation_soundness_violations': or_bad, |
| 'oriented_edges_whose_head_is_in_an(S)': n_or_into_anS, |
| 'unoriented_edges_tested': n_un, |
| 'orientation_completeness_failures': un_incomplete, |
| 'n_distinct_cpdags': len(groups)} |
|
|
|
|
| def run_theorem2_control(d=4, n=3000, seed=3): |
| """Destructive control for Theorem 2: corrupt one oriented edge of the |
| CPDAG (reverse it) and confirm the soundness checker fires.""" |
| rng = np.random.default_rng(seed) |
| _, models = all_static_models(d) |
| idx = rng.choice(len(models), size=n, replace=False) |
| fired = tested = 0 |
| for k in idx: |
| G = models[k] |
| gp = clique_augmented(G, d) |
| dd, uu = cpdag(gp) |
| anS = G.ancestors([SEL]) - {SEL} |
| if not dd: |
| continue |
| u, v = list(dd)[int(rng.integers(len(dd)))] |
| tested += 1 |
| fired += not (G.has(v, u) and u not in anS) |
| return {'corrupted_models_tested': tested, 'checker_fired': fired} |
|
|
|
|
| |
| def run_theorem4(d=4, max_models=None, seed=5): |
| """Theorem 4: P_X keeps every orientation of C (monotonicity), stays sound, |
| and strictly improves on some models. Control: drop the an_G(S) expansion |
| that Theorem 3 prescribes and confirm soundness breaks.""" |
| _, models = all_static_models(d) |
| rng = np.random.default_rng(seed) |
| if max_models and max_models < len(models): |
| models = [models[i] for i in rng.choice(len(models), max_models, replace=False)] |
| Isets = [list(s) for r in range(d + 2) |
| for s in itertools.combinations(list(range(d)) + [SEL], r)] |
| n = mono_bad = sound_bad = strict = tot = 0 |
| extra_edges = 0 |
| ctrl_n = ctrl_bad = 0 |
| adj_bad = 0 |
| for G in models: |
| gp = clique_augmented(G, d) |
| C, Cu = cpdag(gp) |
| anS = G.ancestors([SEL]) - {SEL} |
| for I in Isets: |
| if not I: |
| continue |
| gpi = multidomain_augmented(G, d, I) |
| forced = [('zeta', v) for v in gpi.ch['zeta']] |
| P, Pu = cpdag(gpi, forced=forced) |
| PX = set((u, v) for (u, v) in P if u != 'zeta' and v != 'zeta') |
| tot += 1 |
| n += len(PX) |
| mono_bad += not (C <= PX) |
| bad = sum(1 for (u, v) in PX if not (G.has(u, v) and v not in anS)) |
| sound_bad += bad |
| strict += (len(PX) > len(C)) |
| extra_edges += len(PX - C) |
| |
| adjP = set(frozenset((u, v)) for (u, v) in PX) | \ |
| set(e for e in Pu if 'zeta' not in e) |
| for i, j in itertools.combinations(range(d), 2): |
| truth = (G.has(i, j) or G.has(j, i) or ({i, j} <= anS)) |
| adj_bad += (truth != (frozenset((i, j)) in adjP)) |
| |
| g2 = DG(list(range(d)) + ['zeta']) |
| for (u, v) in gp.edges(): |
| g2.add(u, v) |
| for x in [v for v in I if v != SEL]: |
| g2.add('zeta', x) |
| f2 = [('zeta', v) for v in g2.ch['zeta']] |
| P2, _ = cpdag(g2, forced=f2) |
| P2X = set((u, v) for (u, v) in P2 if u != 'zeta' and v != 'zeta') |
| ctrl_n += 1 |
| ctrl_bad += sum(1 for (u, v) in P2X |
| if not (G.has(u, v) and v not in anS)) |
| return {'d': d, 'n_models': len(models), 'n_I_sets': len(Isets) - 1, |
| 'model_x_Iset_configurations': tot, |
| 'monotonicity_violations': mono_bad, |
| 'orientation_soundness_violations': sound_bad, |
| 'adjacency_violations': adj_bad, |
| 'total_oriented_edges_multi_domain': n, |
| 'configurations_with_strictly_more_orientations': strict, |
| 'frac_strict_improvement': strict / max(tot, 1), |
| 'additional_oriented_edges_vs_single_domain': extra_edges, |
| 'control_no_anS_expansion': { |
| 'configurations': ctrl_n, |
| 'orientation_soundness_violations': ctrl_bad}} |
|
|
|
|
| |
| def run_random_sweep(ds=(5, 6, 7, 8, 9, 10), n_models=40, n_triples=200, |
| Ts=(1, 2, 3, 5), seed=11): |
| """Extend the exact checks beyond the exhaustive range with random models.""" |
| rng = np.random.default_rng(seed) |
| tot = viol_l = conv = viol_t = naive_mis = 0 |
| or_tested = or_bad = adj_tested = adj_bad = 0 |
| for d in ds: |
| for _ in range(n_models): |
| G = random_static_dag(d, rng, avg_deg=2.0) |
| gp = clique_augmented(G, d) |
| gn = naive_dag(G, d) |
| anS = G.ancestors([SEL]) - {SEL} |
| dd, uu = cpdag(gp) |
| for i, j in itertools.combinations(range(d), 2): |
| adj_tested += 1 |
| truth = (G.has(i, j) or G.has(j, i) or ({i, j} <= anS)) |
| has = frozenset((i, j)) in (set(frozenset(e) for e in dd) | uu) |
| adj_bad += (truth != has) |
| for (u, v) in dd: |
| or_tested += 1 |
| or_bad += not (G.has(u, v) and v not in anS) |
| for T in Ts: |
| gT = evolutionary_graph(G, d, T) |
| Sall = [('S', t) for t in range(T)] |
| for _ in range(n_triples): |
| code = rng.integers(0, 4, size=d) |
| A = [i for i in range(d) if code[i] == 0] |
| B = [i for i in range(d) if code[i] == 1] |
| C = [i for i in range(d) if code[i] == 2] |
| if not A or not B: |
| continue |
| ev = dsep(gT, [('X', i, T) for i in A], |
| [('X', i, T) for i in B], |
| [('X', i, T) for i in C] + Sall) |
| tot += 1 |
| viol_l += (ev and not dsep(G, A, B, C + [SEL])) |
| conv += (dsep(G, A, B, C + [SEL]) and not ev) |
| viol_t += (ev != dsep(gp, A, B, C)) |
| naive_mis += (ev != dsep(gn, A, B, C)) |
| return {'ds': list(ds), 'models_per_d': n_models, 'Ts': list(Ts), |
| 'dsep_relations_tested': tot, |
| 'lemma1_violations': viol_l, 'lemma1_converse_failures': conv, |
| 'theorem1_violations': viol_t, |
| 'control_naive_Sdeleted_mismatches': naive_mis, |
| 'theorem2_adjacency_pairs': adj_tested, |
| 'theorem2_adjacency_violations': adj_bad, |
| 'theorem2_oriented_edges': or_tested, |
| 'theorem2_orientation_violations': or_bad} |
|
|
|
|
| |
| def run_definition1(seed=17): |
| """Definition 1: acyclicity + the closed-form node/edge counts + all four |
| edge families, over an exhaustive d=4 sweep and a random d=6..20 sweep.""" |
| rng = np.random.default_rng(seed) |
| _, models = all_static_models(4) |
| n = acyc = vok = eok = fam_ok = 0 |
| for G in models: |
| for T in (1, 2, 3): |
| g = evolutionary_graph(G, 4, T) |
| V, E = evo_counts(G, 4, T) |
| n += 1 |
| acyc += g.is_acyclic() |
| vok += (len(g.nodes) == V) |
| eok += (g.n_edges() == E) |
| fams = set() |
| for (u, v) in g.edges(): |
| if u[0] == 'X' and v[0] == 'X': |
| fams.add('trait->trait') |
| elif u[0] == 'X' and v[0] == 'S': |
| fams.add('trait->S') |
| elif u[0] == 'e' and v[0] == 'X': |
| fams.add('eps->trait') |
| elif u[0] == 'e' and v[0] == 'e': |
| fams.add('eps->eps') |
| need = {'eps->trait', 'eps->eps'} |
| if any(u != SEL for j in range(4) for u in G.pa[j]): |
| need.add('trait->trait') |
| if G.pa[SEL]: |
| need.add('trait->S') |
| fam_ok += (fams == need) |
| big = [] |
| for d in (6, 8, 10, 15, 20): |
| for T in (1, 2, 3, 5): |
| for r in range(3): |
| G = random_static_dag(d, rng, avg_deg=2.0) |
| g = evolutionary_graph(G, d, T) |
| V, E = evo_counts(G, d, T) |
| big.append((g.is_acyclic(), len(g.nodes) == V, g.n_edges() == E, |
| len(g.nodes), g.n_edges(), d, T)) |
| return {'exhaustive_d4': {'constructions': n, 'acyclic': acyc, |
| 'node_count_formula_matches': vok, |
| 'edge_count_formula_matches': eok, |
| 'edge_families_exactly_as_defined': fam_ok}, |
| 'random_large': {'constructions': len(big), |
| 'acyclic': sum(b[0] for b in big), |
| 'node_count_matches': sum(b[1] for b in big), |
| 'edge_count_matches': sum(b[2] for b in big), |
| 'max_nodes': max(b[3] for b in big), |
| 'max_edges': max(b[4] for b in big)}, |
| 'node_formula': '|V| = 2d(T+1) + T', |
| 'edge_formula': '|E| = |E_G^X|(T+1) + |pa_G(S)|T + d(T+1) + dT'} |
|
|
|
|
| if __name__ == '__main__': |
| R = {} |
| t0 = time.time() |
| R['definition1'] = run_definition1(); print('def1', time.time() - t0) |
| R['theorem2'] = run_theorem2(); print('thm2', time.time() - t0) |
| R['theorem2_control'] = run_theorem2_control(); print('thm2c', time.time() - t0) |
| R['theorem4'] = run_theorem4(); print('thm4', time.time() - t0) |
| R['random_sweep'] = run_random_sweep(); print('sweep', time.time() - t0) |
| R['dsep_claims'] = run_dsep_claims(); print('dsep', time.time() - t0) |
| R['runtime_sec'] = time.time() - t0 |
| json.dump(R, open(os.path.join(OUT, 'exhaustive.json'), 'w'), indent=1) |
| print(json.dumps({k: v for k, v in R.items() if k != 'dsep_claims'}, indent=1)[:4000]) |
|
|