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}