4  The Primary Unit: The Dyad

Status: Draft

v0.4

4.1 Introduction

In causal graphs, the dyad (two nodes linked by one directed edge) is the minimal unit of structure. Larger graphs (chains, forks, colliders, networks) are assemblies of dyads, not new kinds of atom (Peters et al. 2017). This chapter fixes that unit before Chapter 3 builds patterns and Chapter 4 attaches executable mechanisms.

4.2 The Dyad: Two Nodes, One Edge

A dyad \(X \rightarrow Y\) is the smallest structure that encodes a direct dependence of \(Y\) on \(X\). In an SCM it appears as

\[ Y \coloneqq f(X, U), \]

where \(f\) is the local mechanism and \(U\) is exogenous noise for that equation. Under \(do(X = x)\) the input is fixed and the same mechanism produces \(Y \coloneqq f(x, U)\); the edge remains as a dependence of \(Y\) on the (now intervened) value of \(X\).

project_root = let
    current = pwd()
    while !isfile(joinpath(current, "Project.toml")) && !isfile(joinpath(current, "_quarto.yml"))
        parent = dirname(current)
        parent == current && break
        current = parent
    end
    current
end
include(joinpath(project_root, "scripts", "ensure_packages.jl"))
@auto_using DAGMakie CairoMakie Graphs StableRNGs

g_dyad = SimpleDiGraph(2)
add_edge!(g_dyad, 1, 2)  # X → Y
true

The dyad \(X \rightarrow Y\): one directed edge, one local mechanism.

4.3 Why dyads are fundamental

The graph \(G = (V, E)\) is exactly a set of dyads: each \(e \in E\) joins two vertices. Roles of nodes are read from incident edges:

  • Source: no incoming edges (often exogenous or background)
  • Sink: no outgoing edges (often an outcome or terminal measurement)
  • Intermediate: both in- and out-edges
"""
Return a node's role from its incident edges.
"""
function node_role(g::AbstractGraph, node::Integer)
    has_in = !isempty(inneighbors(g, node))
    has_out = !isempty(outneighbors(g, node))
    if !has_in && has_out
        return "Source"
    elseif has_in && !has_out
        return "Sink"
    elseif has_in && has_out
        return "Intermediate"
    else
        return "Isolated"
    end
end

g_roles = SimpleDiGraph(4)
add_edge!(g_roles, 1, 2)
add_edge!(g_roles, 2, 3)
add_edge!(g_roles, 3, 4)

println("Node roles (from incident edges):")
for i in 1:nv(g_roles)
    println("  Node ", i, ": ", node_role(g_roles, i))
end
Node roles (from incident edges):
  Node 1: Source
  Node 2: Intermediate
  Node 3: Intermediate
  Node 4: Sink

A short chain: node 1 is a source, 2–3 are intermediate, 4 is a sink.

4.4 Building from dyads

Three classical patterns are just overlapping dyads (Chapter 3):

  • Chain: \(X \rightarrow Y\) and \(Y \rightarrow Z\)
  • Fork: \(X \leftarrow Y \rightarrow Z\)
  • Collider: \(X \rightarrow Y \leftarrow Z\)

With \(n\) nodes there are \(n(n-1)\) possible directed dyads; realistic models keep only a sparse subset. That sparsity is both a modelling assumption and what makes many graph algorithms practical.

rng = StableRNG(2)
n = 10
total_possible = n * (n - 1)
g_sparse = SimpleDiGraph(n)
for i in 1:n
    for t in rand(rng, 1:n, rand(rng, 1:2))
        if t != i
            add_edge!(g_sparse, i, t)
        end
    end
end
actual_edges = ne(g_sparse)
println("System with ", n, " nodes:")
println("  Possible directed dyads: ", total_possible)
println("  Edges in a sparse draw: ", actual_edges)
println("  Fraction absent: ", round(100 * (1 - actual_edges / total_possible); digits = 1), "%")
System with 10 nodes:
  Possible directed dyads: 90
  Edges in a sparse draw: 13
  Fraction absent: 85.6%

4.5 Summary

The dyad is the atom of causal structure: two nodes, one directed edge, one local mechanism \(Y \coloneqq f(X, U)\). Node roles follow incident edges; chains, forks, and colliders are assemblies of dyads; sparsity of possible edges is the usual case. Interventions fix the source of a dyad without inventing a new kind of relation.

4.6 Further Reading