///|
/// Undirected or directed network represented by an adjacency matrix.
pub struct CausalNetwork {
adjacency : Array[Array[Int]]
nodes : Int
directed : Bool
}
///|
/// Exposure mapping for one unit.
pub struct ExposureSummary {
treated_neighbors : Int
control_neighbors : Int
total_neighbors : Int
treated_fraction : Double
exposure_level : Int
}
///|
/// Direct and spillover effect summary.
pub struct SpilloverEffect {
direct_effect : Double
spillover_effect : Double
treated_exposure_count : Int
control_exposure_count : Int
standard_error : Double
passes : Bool
}
///|
/// Network diagnostics.
pub struct NetworkAudit {
nodes : Int
edges : Int
isolated_nodes : Int
maximum_degree : Int
average_degree : Double
symmetric : Bool
passes : Bool
}
///|
/// Creates a network from a square integer adjacency matrix.
pub fn causal_network(
adjacency : Array[Array[Int]],
directed? : Bool = false,
) -> CausalNetwork {
let nodes = adjacency.length()
let normalized : Array[Array[Int]] = Array::new(capacity=nodes)
for row in adjacency {
let copied = Array::make(nodes, 0)
for i in 0.. Int {
let mut total = 0
for row in network.adjacency {
for value in row {
if value != 0 {
total += 1
}
}
}
if network.directed {
total
} else {
total / 2
}
}
///|
/// Returns node degrees.
pub fn network_degrees(network : CausalNetwork) -> Array[Int] {
let result = Array::make(network.nodes, 0)
for i in 0.. Array[Int] {
let result : Array[Int] = Array::new()
if node < 0 || node >= network.nodes {
return result
}
for i in 0.. ExposureSummary {
let neighbors = network_neighbors(network, node)
let mut treated = 0
for neighbor in neighbors {
if neighbor < treatment.length() && treatment[neighbor] {
treated += 1
}
}
let total = neighbors.length()
let fraction = if total == 0 {
0.0
} else {
treated.to_double() / total.to_double()
}
{
treated_neighbors: treated,
control_neighbors: total - treated,
total_neighbors: total,
treated_fraction: fraction,
exposure_level: if treated == 0 {
0
} else if treated == total {
2
} else {
1
},
}
}
///|
/// Computes exposure summaries for every node.
pub fn network_exposures(
network : CausalNetwork,
treatment : Array[Bool],
) -> Array[ExposureSummary] {
let result : Array[ExposureSummary] = Array::new(capacity=network.nodes)
for node in 0.. NetworkAudit {
let degrees = network_degrees(network)
let mut isolated = 0
let mut maximum = 0
let mut total = 0
let mut symmetric = true
for degree in degrees {
if degree == 0 {
isolated += 1
}
if degree > maximum {
maximum = degree
}
total += degree
}
if !network.directed {
for i in 0.. 0 && (network.directed || symmetric),
}
}
///|
/// Computes a direct effect among units with no treated neighbors.
pub fn network_direct_effect(
treatment : Array[Bool],
outcome : Array[Double],
exposures : Array[ExposureSummary],
) -> Double {
let treated = Array::new()
let control = Array::new()
let n = treatment.length().min(outcome.length()).min(exposures.length())
for i in 0.. SpilloverEffect {
let exposed = Array::new()
let unexposed = Array::new()
let direct_treated = Array::new()
let direct_control = Array::new()
let n = treatment.length().min(outcome.length()).min(exposures.length())
for i in 0.. 0 {
exposed.push(outcome[i])
} else {
unexposed.push(outcome[i])
direct_control.push(outcome[i])
}
}
let direct = mean_or(direct_treated, 0.0) - mean_or(direct_control, 0.0)
let spillover = mean_or(exposed, 0.0) - mean_or(unexposed, 0.0)
let combined = Array::new(capacity=exposed.length() + unexposed.length())
for value in exposed {
combined.push(value)
}
for value in unexposed {
combined.push(value)
}
let standard_error = if combined.length() < 2 {
0.0
} else {
std_dev(combined) / combined.length().to_double().sqrt()
}
{
direct_effect: direct,
spillover_effect: spillover,
treated_exposure_count: exposed.length(),
control_exposure_count: unexposed.length(),
standard_error,
passes: exposed.length() > 1 && unexposed.length() > 1,
}
}
///|
/// Returns integer exposure strata for network-aware adjustment.
pub fn network_exposure_strata(
network : CausalNetwork,
treatment : Array[Bool],
) -> Array[Int] {
let exposures = network_exposures(network, treatment)
let result = Array::new(capacity=exposures.length())
for exposure in exposures {
result.push(exposure.exposure_level)
}
result
}
///|
/// Assigns complete network clusters from a cluster label vector.
pub fn network_cluster_assignment(
cluster_ids : Array[Int],
treatment_fraction? : Double = 0.5,
seed? : UInt64 = 20260819,
) -> Array[Bool] {
let clusters : Array[Int] = Array::new()
for cluster in cluster_ids {
if !clusters.contains(cluster) {
clusters.push(cluster)
}
}
let assignment = blocked_assignment(
clusters.length(),
block_size=2,
treatment_fraction~,
seed~,
)
let result = Array::make(cluster_ids.length(), false)
for i in 0.. Array[Bool] {
let exposures = network_exposures(network, treatment)
let result = Array::new(capacity=exposures.length())
for exposure in exposures {
result.push(exposure.treated_fraction >= threshold)
}
result
}
///|
/// Returns connected components under an undirected view.
pub fn network_components(network : CausalNetwork) -> Array[Array[Int]] {
let visited = Array::make(network.nodes, false)
let result : Array[Array[Int]] = Array::new()
for start in 0.. Array[Double] {
let audit = audit_network(network)
[
audit.nodes.to_double(),
audit.edges.to_double(),
audit.isolated_nodes.to_double(),
audit.maximum_degree.to_double(),
audit.average_degree,
if audit.symmetric {
1.0
} else {
0.0
},
if audit.passes {
1.0
} else {
0.0
},
]
}