Skip to main content

sim_lib_music_serial/
array.rs

1//! Serial arrays with independent horizontal row-order and vertical aggregate evidence.
2
3use 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/// One horizontally validated row in a serial array.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct SerialArrayRow {
15    /// Stable row label retained in reports.
16    pub label: String,
17    /// Horizontal order validated independently of vertical requirements.
18    pub order: Series<PitchClassAlphabet>,
19}
20
21/// One named set of column indices checked together as one vertical aggregate.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct ColumnPartition {
24    /// Stable partition label retained in reports.
25    pub id: String,
26    /// Column indices included in caller order.
27    pub columns: Vec<usize>,
28}
29
30/// One named vertical aggregate requirement over array column partitions.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct VerticalAggregateRequirement {
33    /// Stable requirement label retained in the report.
34    pub id: String,
35    /// Column partitions checked under the shared rule.
36    pub partitions: Vec<ColumnPartition>,
37    /// Aggregate rule enforced over every partition's flattened vertical values.
38    pub rule: AggregateRule,
39}
40
41/// Coverage evidence for one set of column partitions.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct PartitionCoverageReport {
44    /// Column indices named by more than one partition.
45    pub duplicate_columns: Vec<usize>,
46    /// Column indices not named by any partition.
47    pub omitted_columns: Vec<usize>,
48    /// Whether the partitions cover every column exactly once.
49    pub complete: bool,
50}
51
52/// Aggregate evidence for one named array partition.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct AggregatePartitionReport {
55    /// Partition label retained from the requirement.
56    pub id: String,
57    /// Column indices checked by this partition.
58    pub columns: Vec<usize>,
59    /// Flattened vertical values in row-major order.
60    pub values: Vec<PitchClass>,
61    /// Pitch classes that repeated in this aggregate.
62    pub duplicates: Vec<PitchClass>,
63    /// Pitch classes omitted from this aggregate.
64    pub omissions: Vec<PitchClass>,
65    /// Whether the retained rule accepted this partition.
66    pub satisfied: bool,
67    /// Exact rule failure when the partition did not satisfy the rule.
68    pub error: Option<SeriesError>,
69}
70
71/// Aggregate evidence for one named vertical requirement.
72#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct AggregateArrayReport {
74    /// Requirement label retained from the array.
75    pub id: String,
76    /// Partition coverage evidence over the array's columns.
77    pub coverage: PartitionCoverageReport,
78    /// Per-partition aggregate evidence.
79    pub partitions: Vec<AggregatePartitionReport>,
80    /// Whether coverage and every partition both satisfied the requirement.
81    pub satisfied: bool,
82}
83
84/// One serial array with reusable vertical aggregate analyses.
85#[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    /// Constructs an array after checking non-empty, equal-length horizontal rows
94    /// and structurally valid vertical partitions.
95    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    /// Returns the number of retained rows.
156    pub fn row_count(&self) -> usize {
157        self.rows.len()
158    }
159
160    /// Returns the number of retained columns.
161    pub fn column_count(&self) -> usize {
162        self.column_count
163    }
164
165    /// Returns the horizontally validated rows.
166    pub fn rows(&self) -> &[SerialArrayRow] {
167        &self.rows
168    }
169
170    /// Returns the named vertical requirements.
171    pub fn vertical_requirements(&self) -> &[VerticalAggregateRequirement] {
172        &self.vertical_requirements
173    }
174
175    /// Returns one left-rotated copy of the array, preserving vertical requirements.
176    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    /// Materializes aggregate evidence for every retained vertical requirement.
198    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    /// Reports all-partition evidence for contiguous blocks of `block_width` columns.
206    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/// Failure while constructing or deriving one serial array analysis.
285#[derive(Clone, Debug, PartialEq, Eq, Error)]
286pub enum SerialArrayError {
287    /// The array declared no horizontal rows.
288    #[error("serial array must contain at least one row")]
289    EmptyArray,
290    /// One row label was empty.
291    #[error("serial array rows must carry a non-empty label")]
292    EmptyRowLabel,
293    /// One row carried no horizontal values.
294    #[error("serial array row {row_label:?} must contain at least one value")]
295    EmptyRowOrder {
296        /// Label of the empty row.
297        row_label: String,
298    },
299    /// Rows did not agree on one shared column count.
300    #[error("serial array row {row_label:?} has length {found}; expected {expected}")]
301    RowLengthMismatch {
302        /// Label of the row with the wrong length.
303        row_label: String,
304        /// Shared array width established by the first row.
305        expected: usize,
306        /// Row width found on the offending row.
307        found: usize,
308    },
309    /// One vertical requirement label was empty.
310    #[error("serial array vertical requirements must carry a non-empty id")]
311    EmptyRequirementId,
312    /// One partition label was empty.
313    #[error("serial array requirement {requirement_id:?} contains an empty partition id")]
314    EmptyPartitionId {
315        /// Requirement that owned the malformed partition.
316        requirement_id: String,
317    },
318    /// One partition named no columns.
319    #[error(
320        "serial array requirement {requirement_id:?} partition {partition_id:?} must name at least one column"
321    )]
322    EmptyPartition {
323        /// Requirement that owned the malformed partition.
324        requirement_id: String,
325        /// Partition that named no columns.
326        partition_id: String,
327    },
328    /// One partition named a column outside the array width.
329    #[error(
330        "serial array requirement {requirement_id:?} partition {partition_id:?} names column {column} outside 0..{column_count}"
331    )]
332    ColumnOutOfRange {
333        /// Requirement that owned the malformed partition.
334        requirement_id: String,
335        /// Partition that named the bad column.
336        partition_id: String,
337        /// Rejected column index.
338        column: usize,
339        /// Shared array width.
340        column_count: usize,
341    },
342    /// An all-partition report named a zero-width block.
343    #[error("all-partition block width must be at least 1")]
344    ZeroBlockWidth,
345    /// An all-partition block width did not divide the array width.
346    #[error("all-partition block width {block_width} does not divide column count {column_count}")]
347    BlockWidthMismatch {
348        /// Requested contiguous block width.
349        block_width: usize,
350        /// Shared array width.
351        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}