Skip to main content

antecedent_data/
network.rs

1//! Unit-level tabular data with a fixed interference network.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::sync::Arc;
6
7use crate::{DataError, TableView, TabularData};
8
9/// Directed weighted edge from a source unit to an exposed target unit.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct NetworkEdge {
12    /// Source unit row.
13    pub from: u32,
14    /// Target unit row.
15    pub to: u32,
16    /// Non-negative exposure weight.
17    pub weight: f64,
18}
19
20/// Tabular unit data paired with a fixed, row-indexed network.
21///
22/// Incoming adjacency is stored CSR-style: one edge array sorted by
23/// `(to, from)` plus an offsets table, so construction is two allocations
24/// (the array-of-`Arc`s layout it replaces made `n + 1`, most of them empty)
25/// and `incoming(unit)` is a contiguous slice.
26#[derive(Clone, Debug)]
27pub struct NetworkData {
28    units: TabularData,
29    edges: Arc<[NetworkEdge]>,
30    /// All edges re-sorted by `(to, from)`; unit `u`'s incoming edges are
31    /// `incoming_edges[incoming_offsets[u]..incoming_offsets[u + 1]]`.
32    incoming_edges: Arc<[NetworkEdge]>,
33    incoming_offsets: Arc<[usize]>,
34}
35
36impl NetworkData {
37    /// Build a network, validating row indexes, weights, self-edges, and duplicates.
38    ///
39    /// # Errors
40    ///
41    /// [`DataError::InvalidArgument`] when an edge is invalid for the unit table.
42    pub fn try_new(
43        units: TabularData,
44        edges: impl Into<Arc<[NetworkEdge]>>,
45    ) -> Result<Self, DataError> {
46        let edges = edges.into();
47        let n = units.row_count();
48        let mut sorted = edges.to_vec();
49        sorted.sort_by_key(|edge| (edge.from, edge.to));
50        for (i, edge) in sorted.iter().enumerate() {
51            if edge.from as usize >= n || edge.to as usize >= n {
52                return Err(DataError::InvalidArgument {
53                    message: "network edge row index is outside the unit table".into(),
54                });
55            }
56            if edge.from == edge.to {
57                return Err(DataError::InvalidArgument {
58                    message: "network self-edges are not allowed".into(),
59                });
60            }
61            if !edge.weight.is_finite() || edge.weight < 0.0 {
62                return Err(DataError::InvalidArgument {
63                    message: "network weights must be finite and non-negative".into(),
64                });
65            }
66            if i > 0 && (sorted[i - 1].from, sorted[i - 1].to) == (edge.from, edge.to) {
67                return Err(DataError::InvalidArgument {
68                    message: "duplicate network edge".into(),
69                });
70            }
71        }
72        let edges: Arc<[NetworkEdge]> = sorted.into();
73        // (to, from) order keeps each unit's incoming edges in ascending-from
74        // order, matching the per-unit push order of the previous layout.
75        let mut by_to = edges.to_vec();
76        by_to.sort_by_key(|edge| (edge.to, edge.from));
77        let mut incoming_offsets = vec![0usize; n + 1];
78        for edge in &by_to {
79            incoming_offsets[edge.to as usize + 1] += 1;
80        }
81        for u in 0..n {
82            incoming_offsets[u + 1] += incoming_offsets[u];
83        }
84        Ok(Self {
85            units,
86            edges,
87            incoming_edges: by_to.into(),
88            incoming_offsets: incoming_offsets.into(),
89        })
90    }
91
92    /// Borrow unit-level columns.
93    #[must_use]
94    pub const fn units(&self) -> &TabularData {
95        &self.units
96    }
97
98    /// Borrow all directed network edges.
99    #[must_use]
100    pub fn edges(&self) -> &[NetworkEdge] {
101        &self.edges
102    }
103
104    /// Incoming neighbors whose assignments define exposure for `unit`.
105    ///
106    /// # Errors
107    ///
108    /// [`DataError::InvalidArgument`] when `unit` is outside the table.
109    pub fn incoming(&self, unit: usize) -> Result<&[NetworkEdge], DataError> {
110        if unit + 1 >= self.incoming_offsets.len() {
111            return Err(DataError::InvalidArgument {
112                message: "network unit index is out of range".into(),
113            });
114        }
115        Ok(&self.incoming_edges[self.incoming_offsets[unit]..self.incoming_offsets[unit + 1]])
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn empty_network_is_valid_and_has_no_incoming_edges() {
125        let values = [1.0, 2.0];
126        let table = TabularData::from_f64_columns([("y", values.as_slice())]).unwrap();
127        let network = NetworkData::try_new(table, []).unwrap();
128        assert!(network.incoming(0).unwrap().is_empty());
129    }
130}