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
227/// Validates declarative [`EdgeInput`] entries against adjacency invariants and
228/// converts valid edges into [`SimilarityEdge`] values.
229///
230/// # Errors
231///
232/// Returns [`AdjacencyError`] when an edge is non-canonical, references a node
233/// index outside `node_count`, or carries zero weight.
234pub(crate) fn validate_edges(
235    node_count: usize,
236    edges: &[EdgeInput],
237) -> Result<Vec<SimilarityEdge>, AdjacencyError> {
238    let mut result = Vec::with_capacity(edges.len());
239
240    for (index, edge) in edges.iter().enumerate() {
241        if edge.left >= edge.right {
242            return Err(AdjacencyError::NonCanonicalEdge {
243                index,
244                left: edge.left,
245                right: edge.right,
246            });
247        }
248        if edge.right >= node_count {
249            return Err(AdjacencyError::EndpointOutOfRange {
250                index,
251                right: edge.right,
252                node_count,
253            });
254        }
255        if edge.weight == 0 {
256            return Err(AdjacencyError::ZeroWeight { index });
257        }
258        result.push(SimilarityEdge::new(edge.left, edge.right, edge.weight));
259    }
260
261    Ok(result)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::{AdjacencyError, EdgeInput, adjacency_report};
267
268    #[test]
269    fn adjacency_report_rejects_non_canonical_edges_with_structured_error() {
270        let result = adjacency_report(
271            3,
272            &[EdgeInput {
273                left: 1,
274                right: 1,
275                weight: 7,
276            }],
277        );
278
279        assert_eq!(
280            result,
281            Err(AdjacencyError::NonCanonicalEdge {
282                index: 0,
283                left: 1,
284                right: 1,
285            })
286        );
287    }
288
289    #[test]
290    fn adjacency_report_rejects_out_of_range_endpoints_with_structured_error() {
291        let result = adjacency_report(
292            3,
293            &[EdgeInput {
294                left: 1,
295                right: 3,
296                weight: 7,
297            }],
298        );
299
300        assert_eq!(
301            result,
302            Err(AdjacencyError::EndpointOutOfRange {
303                index: 0,
304                right: 3,
305                node_count: 3,
306            })
307        );
308    }
309
310    #[test]
311    fn adjacency_report_rejects_zero_weight_with_structured_error() {
312        let result = adjacency_report(
313            3,
314            &[EdgeInput {
315                left: 0,
316                right: 2,
317                weight: 0,
318            }],
319        );
320
321        assert_eq!(result, Err(AdjacencyError::ZeroWeight { index: 0 }));
322    }
323}