1use std::collections::BTreeMap;
4
5use sim_lib_pitch_core::PitchClass;
6use sim_lib_pitch_serial::PitchClassAlphabet;
7use sim_lib_serial_core::{AggregateRule, Series, SeriesError};
8use thiserror::Error;
9
10use crate::rotate_sequence_left;
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct SerialArrayRow {
15 pub label: String,
17 pub order: Series<PitchClassAlphabet>,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ColumnPartition {
24 pub id: String,
26 pub columns: Vec<usize>,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct VerticalAggregateRequirement {
33 pub id: String,
35 pub partitions: Vec<ColumnPartition>,
37 pub rule: AggregateRule,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct PartitionCoverageReport {
44 pub duplicate_columns: Vec<usize>,
46 pub omitted_columns: Vec<usize>,
48 pub complete: bool,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct AggregatePartitionReport {
55 pub id: String,
57 pub columns: Vec<usize>,
59 pub values: Vec<PitchClass>,
61 pub duplicates: Vec<PitchClass>,
63 pub omissions: Vec<PitchClass>,
65 pub satisfied: bool,
67 pub error: Option<SeriesError>,
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct AggregateArrayReport {
74 pub id: String,
76 pub coverage: PartitionCoverageReport,
78 pub partitions: Vec<AggregatePartitionReport>,
80 pub satisfied: bool,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct SerialArray {
87 rows: Vec<SerialArrayRow>,
88 vertical_requirements: Vec<VerticalAggregateRequirement>,
89 column_count: usize,
90}
91
92impl SerialArray {
93 pub fn try_new(
96 rows: Vec<SerialArrayRow>,
97 vertical_requirements: Vec<VerticalAggregateRequirement>,
98 ) -> Result<Self, SerialArrayError> {
99 let Some(first_row) = rows.first() else {
100 return Err(SerialArrayError::EmptyArray);
101 };
102 let column_count = first_row.order.order().len();
103 if column_count == 0 {
104 return Err(SerialArrayError::EmptyRowOrder {
105 row_label: first_row.label.clone(),
106 });
107 }
108 for row in &rows {
109 if row.label.trim().is_empty() {
110 return Err(SerialArrayError::EmptyRowLabel);
111 }
112 if row.order.order().len() != column_count {
113 return Err(SerialArrayError::RowLengthMismatch {
114 row_label: row.label.clone(),
115 expected: column_count,
116 found: row.order.order().len(),
117 });
118 }
119 }
120 for requirement in &vertical_requirements {
121 if requirement.id.trim().is_empty() {
122 return Err(SerialArrayError::EmptyRequirementId);
123 }
124 for partition in &requirement.partitions {
125 if partition.id.trim().is_empty() {
126 return Err(SerialArrayError::EmptyPartitionId {
127 requirement_id: requirement.id.clone(),
128 });
129 }
130 if partition.columns.is_empty() {
131 return Err(SerialArrayError::EmptyPartition {
132 requirement_id: requirement.id.clone(),
133 partition_id: partition.id.clone(),
134 });
135 }
136 for &column in &partition.columns {
137 if column >= column_count {
138 return Err(SerialArrayError::ColumnOutOfRange {
139 requirement_id: requirement.id.clone(),
140 partition_id: partition.id.clone(),
141 column,
142 column_count,
143 });
144 }
145 }
146 }
147 }
148 Ok(Self {
149 rows,
150 vertical_requirements,
151 column_count,
152 })
153 }
154
155 pub fn row_count(&self) -> usize {
157 self.rows.len()
158 }
159
160 pub fn column_count(&self) -> usize {
162 self.column_count
163 }
164
165 pub fn rows(&self) -> &[SerialArrayRow] {
167 &self.rows
168 }
169
170 pub fn vertical_requirements(&self) -> &[VerticalAggregateRequirement] {
172 &self.vertical_requirements
173 }
174
175 pub fn rotate_columns(&self, steps: usize) -> Self {
177 let rows = self
178 .rows
179 .iter()
180 .map(|row| SerialArrayRow {
181 label: row.label.clone(),
182 order: Series::try_new(
183 row.order.alphabet().clone(),
184 row.order.rule().clone(),
185 rotate_sequence_left(row.order.order(), steps),
186 )
187 .expect("rotating a validated row preserves its aggregate contract"),
188 })
189 .collect();
190 Self {
191 rows,
192 vertical_requirements: self.vertical_requirements.clone(),
193 column_count: self.column_count,
194 }
195 }
196
197 pub fn aggregate_reports(&self) -> Vec<AggregateArrayReport> {
199 self.vertical_requirements
200 .iter()
201 .map(|requirement| self.aggregate_report(requirement))
202 .collect()
203 }
204
205 pub fn all_partition_report(
207 &self,
208 block_width: usize,
209 ) -> Result<AggregateArrayReport, SerialArrayError> {
210 if block_width == 0 {
211 return Err(SerialArrayError::ZeroBlockWidth);
212 }
213 if !self.column_count.is_multiple_of(block_width) {
214 return Err(SerialArrayError::BlockWidthMismatch {
215 block_width,
216 column_count: self.column_count,
217 });
218 }
219 let partitions = (0..self.column_count / block_width)
220 .map(|index| ColumnPartition {
221 id: format!("partition/{}", index + 1),
222 columns: ((index * block_width)..((index + 1) * block_width)).collect(),
223 })
224 .collect::<Vec<_>>();
225 Ok(self.aggregate_report(&VerticalAggregateRequirement {
226 id: format!("all-partition/{block_width}"),
227 partitions,
228 rule: AggregateRule::exhaustive_exactly_once(),
229 }))
230 }
231
232 fn aggregate_report(&self, requirement: &VerticalAggregateRequirement) -> AggregateArrayReport {
233 let coverage = coverage_report(self.column_count, &requirement.partitions);
234 let alphabet = PitchClassAlphabet::try_new().expect("canonical pitch-class alphabet");
235 let partitions = requirement
236 .partitions
237 .iter()
238 .map(|partition| {
239 let values = self.flatten_partition(&partition.columns);
240 let duplicates = duplicate_pitch_classes(&values);
241 let omissions = omitted_pitch_classes(&values);
242 match Series::try_new(alphabet.clone(), requirement.rule.clone(), values.clone()) {
243 Ok(_) => AggregatePartitionReport {
244 id: partition.id.clone(),
245 columns: partition.columns.clone(),
246 values,
247 duplicates,
248 omissions,
249 satisfied: true,
250 error: None,
251 },
252 Err(error) => AggregatePartitionReport {
253 id: partition.id.clone(),
254 columns: partition.columns.clone(),
255 values,
256 duplicates,
257 omissions,
258 satisfied: false,
259 error: Some(error),
260 },
261 }
262 })
263 .collect::<Vec<_>>();
264 let satisfied = coverage.complete && partitions.iter().all(|partition| partition.satisfied);
265 AggregateArrayReport {
266 id: requirement.id.clone(),
267 coverage,
268 partitions,
269 satisfied,
270 }
271 }
272
273 fn flatten_partition(&self, columns: &[usize]) -> Vec<PitchClass> {
274 let mut values = Vec::with_capacity(self.rows.len() * columns.len());
275 for row in &self.rows {
276 for &column in columns {
277 values.push(row.order.order()[column]);
278 }
279 }
280 values
281 }
282}
283
284#[derive(Clone, Debug, PartialEq, Eq, Error)]
286pub enum SerialArrayError {
287 #[error("serial array must contain at least one row")]
289 EmptyArray,
290 #[error("serial array rows must carry a non-empty label")]
292 EmptyRowLabel,
293 #[error("serial array row {row_label:?} must contain at least one value")]
295 EmptyRowOrder {
296 row_label: String,
298 },
299 #[error("serial array row {row_label:?} has length {found}; expected {expected}")]
301 RowLengthMismatch {
302 row_label: String,
304 expected: usize,
306 found: usize,
308 },
309 #[error("serial array vertical requirements must carry a non-empty id")]
311 EmptyRequirementId,
312 #[error("serial array requirement {requirement_id:?} contains an empty partition id")]
314 EmptyPartitionId {
315 requirement_id: String,
317 },
318 #[error(
320 "serial array requirement {requirement_id:?} partition {partition_id:?} must name at least one column"
321 )]
322 EmptyPartition {
323 requirement_id: String,
325 partition_id: String,
327 },
328 #[error(
330 "serial array requirement {requirement_id:?} partition {partition_id:?} names column {column} outside 0..{column_count}"
331 )]
332 ColumnOutOfRange {
333 requirement_id: String,
335 partition_id: String,
337 column: usize,
339 column_count: usize,
341 },
342 #[error("all-partition block width must be at least 1")]
344 ZeroBlockWidth,
345 #[error("all-partition block width {block_width} does not divide column count {column_count}")]
347 BlockWidthMismatch {
348 block_width: usize,
350 column_count: usize,
352 },
353}
354
355fn coverage_report(column_count: usize, partitions: &[ColumnPartition]) -> PartitionCoverageReport {
356 let mut counts = vec![0usize; column_count];
357 for partition in partitions {
358 for &column in &partition.columns {
359 counts[column] += 1;
360 }
361 }
362 let duplicate_columns = counts
363 .iter()
364 .enumerate()
365 .filter_map(|(column, count)| (*count > 1).then_some(column))
366 .collect::<Vec<_>>();
367 let omitted_columns = counts
368 .iter()
369 .enumerate()
370 .filter_map(|(column, count)| (*count == 0).then_some(column))
371 .collect::<Vec<_>>();
372 PartitionCoverageReport {
373 complete: duplicate_columns.is_empty() && omitted_columns.is_empty(),
374 duplicate_columns,
375 omitted_columns,
376 }
377}
378
379fn duplicate_pitch_classes(values: &[PitchClass]) -> Vec<PitchClass> {
380 let mut counts = BTreeMap::new();
381 for &value in values {
382 *counts.entry(value).or_insert(0usize) += 1;
383 }
384 counts
385 .into_iter()
386 .filter_map(|(pitch_class, count)| (count > 1).then_some(pitch_class))
387 .collect()
388}
389
390fn omitted_pitch_classes(values: &[PitchClass]) -> Vec<PitchClass> {
391 PitchClassAlphabet::try_new()
392 .expect("canonical pitch-class alphabet")
393 .classes()
394 .iter()
395 .copied()
396 .filter(|pitch_class| !values.contains(pitch_class))
397 .collect()
398}