Skip to main content

whitaker_common/test_support/
decomposition_adjacency.rs

1//! Observable adjacency-construction seams for decomposition advice tests.
2
3use crate::decomposition_advice::community::{SimilarityEdge, build_adjacency};
4use thiserror::Error;
5
6/// Declarative edge input for test scenarios.
7///
8/// # Examples
9///
10/// ```ignore
11/// use whitaker_common::test_support::decomposition::EdgeInput;
12///
13/// let edge = EdgeInput { left: 0, right: 1, weight: 10 };
14/// assert_eq!(edge.left, 0);
15/// ```
16#[derive(Clone, Copy, Debug)]
17pub struct EdgeInput {
18    /// Left endpoint node index in canonical order.
19    ///
20    /// Callers must provide `left < right`.
21    pub left: usize,
22    /// Right endpoint node index in canonical order.
23    ///
24    /// Callers must provide `left < right`.
25    pub right: usize,
26    /// Positive edge weight carried into the adjacency list.
27    pub weight: u64,
28}
29
30/// Structured validation errors for declarative adjacency test input.
31#[derive(Clone, Debug, Eq, Error, PartialEq)]
32pub enum AdjacencyError {
33    /// The edge endpoints are not in canonical order.
34    #[error("edge {index}: left ({left}) must be less than right ({right})")]
35    NonCanonicalEdge {
36        /// Edge index within the declarative input slice.
37        index: usize,
38        /// Left endpoint supplied by the caller.
39        left: usize,
40        /// Right endpoint supplied by the caller.
41        right: usize,
42    },
43    /// The right endpoint falls outside the declared node range.
44    #[error("edge {index}: right ({right}) is out of range for node_count {node_count}")]
45    EndpointOutOfRange {
46        /// Edge index within the declarative input slice.
47        index: usize,
48        /// Out-of-range right endpoint.
49        right: usize,
50        /// Declared node count for the report.
51        node_count: usize,
52    },
53    /// The edge weight violates the positive-weight production contract.
54    #[error("edge {index}: weight must be positive")]
55    ZeroWeight {
56        /// Edge index within the declarative input slice.
57        index: usize,
58    },
59}
60
61/// Observable adjacency-construction results for a set of edges.
62///
63/// # Examples
64///
65/// ```ignore
66/// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
67///
68/// let report = adjacency_report(3, &[
69///     EdgeInput { left: 0, right: 1, weight: 5 },
70/// ]);
71/// assert!(report.is_ok());
72/// let report = report.unwrap();
73/// assert_eq!(report.node_count(), 3);
74/// assert!(report.is_symmetric());
75/// ```
76#[derive(Clone, Debug, Eq, PartialEq)]
77pub struct AdjacencyReport {
78    node_count: usize,
79    neighbours: Vec<Vec<(usize, u64)>>,
80}
81
82impl AdjacencyReport {
83    /// Returns the number of nodes in the adjacency graph.
84    ///
85    /// ```rust
86    /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
87    ///
88    /// let report = adjacency_report(4, &[]).expect("valid input");
89    /// assert_eq!(report.node_count(), 4);
90    /// ```
91    #[must_use]
92    pub fn node_count(&self) -> usize {
93        self.node_count
94    }
95
96    /// Returns the neighbour list for `node`, sorted by neighbour index.
97    ///
98    /// Returns `None` if `node` is out of range.
99    ///
100    /// ```rust
101    /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
102    ///
103    /// let report = adjacency_report(3, &[
104    ///     EdgeInput { left: 0, right: 2, weight: 7 },
105    /// ]).expect("valid input");
106    /// assert_eq!(report.neighbours_of(0), Some(&[(2, 7)][..]));
107    /// assert_eq!(report.neighbours_of(10), None);
108    /// ```
109    #[must_use]
110    pub fn neighbours_of(&self, node: usize) -> Option<&[(usize, u64)]> {
111        self.neighbours.get(node).map(Vec::as_slice)
112    }
113
114    /// Returns `true` if all neighbour indices are within bounds.
115    ///
116    /// ```rust
117    /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
118    ///
119    /// let report = adjacency_report(3, &[
120    ///     EdgeInput { left: 0, right: 1, weight: 5 },
121    /// ]).expect("valid input");
122    /// assert!(report.all_indices_in_bounds());
123    /// ```
124    #[must_use]
125    pub fn all_indices_in_bounds(&self) -> bool {
126        self.neighbours.iter().all(|bucket| {
127            bucket
128                .iter()
129                .all(|&(neighbour, _)| neighbour < self.node_count)
130        })
131    }
132
133    /// Returns `true` if the adjacency lists are symmetric: for every entry
134    /// `(node -> neighbour, weight)`, the mirrored entry exists.
135    ///
136    /// ```rust
137    /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
138    ///
139    /// let report = adjacency_report(3, &[
140    ///     EdgeInput { left: 0, right: 2, weight: 7 },
141    /// ]).expect("valid input");
142    /// assert!(report.is_symmetric());
143    /// ```
144    #[must_use]
145    pub fn is_symmetric(&self) -> bool {
146        self.neighbours.iter().enumerate().all(|(node, bucket)| {
147            bucket
148                .iter()
149                .all(|&(neighbour, weight)| has_mirror(&self.neighbours, neighbour, node, weight))
150        })
151    }
152
153    /// Returns `true` if each per-node neighbour list is sorted by neighbour
154    /// index.
155    ///
156    /// ```rust
157    /// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
158    ///
159    /// let report = adjacency_report(4, &[
160    ///     EdgeInput { left: 0, right: 1, weight: 5 },
161    ///     EdgeInput { left: 0, right: 3, weight: 3 },
162    /// ]).expect("valid input");
163    /// assert!(report.is_sorted());
164    /// ```
165    #[must_use]
166    pub fn is_sorted(&self) -> bool {
167        self.neighbours
168            .iter()
169            .all(|bucket| bucket.windows(2).all(|pair| pair[0].0 <= pair[1].0))
170    }
171}
172
173#[expect(
174    clippy::unnecessary_map_or,
175    reason = "Keep the explicit non-panicking fallback requested in review."
176)]
177fn has_mirror(
178    neighbours: &[Vec<(usize, u64)>],
179    neighbour: usize,
180    node: usize,
181    weight: u64,
182) -> bool {
183    debug_assert!(
184        neighbour < neighbours.len(),
185        "has_mirror: neighbour index out of bounds - callers (e.g. adjacency_report) must guarantee valid indices"
186    );
187    neighbours.get(neighbour).map_or(false, |list| {
188        list.iter()
189            .any(|&(mirror, mirror_weight)| mirror == node && mirror_weight == weight)
190    })
191}
192
193/// Builds an [`AdjacencyReport`] from declarative edge input.
194///
195/// Returns `Err` if any edge endpoint is out of range for the given
196/// `node_count` or if `left >= right` (violating the production
197/// `build_similarity_edges` contract).
198///
199/// # Examples
200///
201/// ```rust
202/// use whitaker_common::test_support::decomposition::{EdgeInput, adjacency_report};
203///
204/// let report = adjacency_report(3, &[
205///     EdgeInput { left: 0, right: 2, weight: 7 },
206/// ]).expect("valid input");
207/// assert_eq!(report.node_count(), 3);
208/// ```
209///
210/// # Errors
211///
212/// Returns a typed validation error when an edge violates the production
213/// input contract.
214pub fn adjacency_report(
215    node_count: usize,
216    edges: &[EdgeInput],
217) -> Result<AdjacencyReport, AdjacencyError> {
218    let similarity_edges = validate_edges(node_count, edges)?;
219    let neighbours = build_adjacency(node_count, &similarity_edges);
220
221    Ok(AdjacencyReport {
222        node_count,
223        neighbours,
224    })
225}
226
227fn validate_edges(
228    node_count: usize,
229    edges: &[EdgeInput],
230) -> Result<Vec<SimilarityEdge>, AdjacencyError> {
231    let mut result = Vec::with_capacity(edges.len());
232
233    for (index, edge) in edges.iter().enumerate() {
234        if edge.left >= edge.right {
235            return Err(AdjacencyError::NonCanonicalEdge {
236                index,
237                left: edge.left,
238                right: edge.right,
239            });
240        }
241        if edge.right >= node_count {
242            return Err(AdjacencyError::EndpointOutOfRange {
243                index,
244                right: edge.right,
245                node_count,
246            });
247        }
248        if edge.weight == 0 {
249            return Err(AdjacencyError::ZeroWeight { index });
250        }
251        result.push(SimilarityEdge::new(edge.left, edge.right, edge.weight));
252    }
253
254    Ok(result)
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{AdjacencyError, EdgeInput, adjacency_report};
260
261    #[test]
262    fn adjacency_report_rejects_non_canonical_edges_with_structured_error() {
263        let result = adjacency_report(
264            3,
265            &[EdgeInput {
266                left: 1,
267                right: 1,
268                weight: 7,
269            }],
270        );
271
272        assert_eq!(
273            result,
274            Err(AdjacencyError::NonCanonicalEdge {
275                index: 0,
276                left: 1,
277                right: 1,
278            })
279        );
280    }
281
282    #[test]
283    fn adjacency_report_rejects_out_of_range_endpoints_with_structured_error() {
284        let result = adjacency_report(
285            3,
286            &[EdgeInput {
287                left: 1,
288                right: 3,
289                weight: 7,
290            }],
291        );
292
293        assert_eq!(
294            result,
295            Err(AdjacencyError::EndpointOutOfRange {
296                index: 0,
297                right: 3,
298                node_count: 3,
299            })
300        );
301    }
302
303    #[test]
304    fn adjacency_report_rejects_zero_weight_with_structured_error() {
305        let result = adjacency_report(
306            3,
307            &[EdgeInput {
308                left: 0,
309                right: 2,
310                weight: 0,
311            }],
312        );
313
314        assert_eq!(result, Err(AdjacencyError::ZeroWeight { index: 0 }));
315    }
316}