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}