use super::checker::TripleDecline;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ConfluenceRelation {
CrossAxisSequenceMatch,
Spdi,
}
impl ConfluenceRelation {
pub fn as_str(self) -> &'static str {
match self {
ConfluenceRelation::CrossAxisSequenceMatch => "cross_axis",
ConfluenceRelation::Spdi => "spdi",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"cross_axis" => Some(ConfluenceRelation::CrossAxisSequenceMatch),
"spdi" => Some(ConfluenceRelation::Spdi),
_ => None,
}
}
}
impl std::fmt::Display for ConfluenceRelation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConfluenceGroup {
pub inputs: Vec<String>,
pub outputs: Vec<String>,
}
impl ConfluenceGroup {
pub(crate) fn from_class(members: impl IntoIterator<Item = (String, String)>) -> Option<Self> {
let mut inputs = Vec::new();
let mut outputs = BTreeSet::new();
for (input, output) in members {
inputs.push(input);
outputs.insert(output);
}
if outputs.len() > 1 {
Some(ConfluenceGroup {
inputs,
outputs: outputs.into_iter().collect(),
})
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ConfluenceSkipKind {
Unplaceable,
NormalizationDeclined,
}
impl ConfluenceSkipKind {
pub fn as_str(self) -> &'static str {
match self {
ConfluenceSkipKind::Unplaceable => "unplaceable",
ConfluenceSkipKind::NormalizationDeclined => "normalization_declined",
}
}
}
impl std::fmt::Display for ConfluenceSkipKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConfluenceSkip {
pub input: String,
pub kind: ConfluenceSkipKind,
pub decline: Option<TripleDecline>,
pub reason: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ConfluenceReport {
relation: ConfluenceRelation,
violations: Vec<ConfluenceGroup>,
classes_checked: usize,
skipped: Vec<ConfluenceSkip>,
undecided_pairs: usize,
}
impl ConfluenceReport {
pub(crate) fn new(
relation: ConfluenceRelation,
violations: Vec<ConfluenceGroup>,
classes_checked: usize,
skipped: Vec<ConfluenceSkip>,
undecided_pairs: usize,
) -> Self {
Self {
relation,
violations,
classes_checked,
skipped,
undecided_pairs,
}
}
pub fn relation(&self) -> ConfluenceRelation {
self.relation
}
pub fn violations(&self) -> &[ConfluenceGroup] {
&self.violations
}
pub fn classes_checked(&self) -> usize {
self.classes_checked
}
pub fn skipped(&self) -> &[ConfluenceSkip] {
&self.skipped
}
pub fn undecided_pairs(&self) -> usize {
self.undecided_pairs
}
pub fn is_confluent(&self) -> bool {
self.violations.is_empty()
}
pub fn is_complete(&self) -> bool {
self.skipped.is_empty() && self.undecided_pairs == 0
}
pub fn into_parts(self) -> (Vec<ConfluenceGroup>, Vec<ConfluenceSkip>) {
(self.violations, self.skipped)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn members(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(i, o)| (i.to_string(), o.to_string()))
.collect()
}
#[test]
fn a_class_with_one_distinct_output_is_confluent() {
assert_eq!(
ConfluenceGroup::from_class(members(&[("g.2A>G", "g.2A>G")])),
None,
"a singleton is confluent"
);
assert_eq!(
ConfluenceGroup::from_class(members(&[
("g.2A>G", "g.2A>G"),
("g.2delinsG", "g.2A>G"),
("g.2_2delinsG", "g.2A>G"),
])),
None,
"three spellings, one output"
);
}
#[test]
fn a_class_with_two_distinct_outputs_is_a_violation() {
let group = ConfluenceGroup::from_class(members(&[
("input-b", "g.2_7delinsGGCTA"),
("input-a", "g.[2A>G;7C>A]"),
("input-c", "g.2_7delinsGGCTA"),
]))
.expect("two distinct outputs is non-confluent");
assert_eq!(
group.inputs,
vec!["input-b", "input-a", "input-c"],
"inputs are recorded in the order given"
);
assert_eq!(
group.outputs,
vec!["g.2_7delinsGGCTA", "g.[2A>G;7C>A]"],
"outputs are the distinct set, sorted"
);
}
#[test]
fn two_members_two_outputs_is_the_minimal_witness() {
let group =
ConfluenceGroup::from_class(members(&[("a", "x"), ("b", "y")])).expect("two outputs");
assert_eq!(group.outputs, vec!["x", "y"]);
}
#[test]
fn an_empty_class_is_not_a_violation() {
assert_eq!(ConfluenceGroup::from_class(std::iter::empty()), None);
}
#[test]
fn a_report_exposes_its_parts() {
let violation = ConfluenceGroup {
inputs: vec!["a".to_string(), "b".to_string()],
outputs: vec!["x".to_string(), "y".to_string()],
};
let skip = ConfluenceSkip {
input: "c".to_string(),
kind: ConfluenceSkipKind::Unplaceable,
reason: "no SPDI key".to_string(),
decline: None,
};
let report = ConfluenceReport::new(
ConfluenceRelation::Spdi,
vec![violation.clone()],
3,
vec![skip.clone()],
7,
);
assert_eq!(report.relation(), ConfluenceRelation::Spdi);
assert_eq!(report.classes_checked(), 3);
assert_eq!(report.violations(), std::slice::from_ref(&violation));
assert_eq!(report.skipped(), std::slice::from_ref(&skip));
assert_eq!(report.undecided_pairs(), 7);
assert!(!report.is_confluent(), "one violation means not confluent");
assert!(!report.is_complete(), "a skip and undecided pairs are gaps");
let (violations, skipped) = report.into_parts();
assert_eq!(violations, [violation]);
assert_eq!(skipped, [skip]);
}
#[test]
fn completeness_is_about_coverage_not_about_the_verdict() {
let clean = ConfluenceReport::new(
ConfluenceRelation::CrossAxisSequenceMatch,
Vec::new(),
2,
Vec::new(),
0,
);
assert!(clean.is_complete() && clean.is_confluent());
let found = ConfluenceReport::new(
ConfluenceRelation::CrossAxisSequenceMatch,
vec![ConfluenceGroup {
inputs: vec!["a".to_string(), "b".to_string()],
outputs: vec!["x".to_string(), "y".to_string()],
}],
2,
Vec::new(),
0,
);
assert!(
found.is_complete() && !found.is_confluent(),
"a complete run can still report violations"
);
let blind = ConfluenceReport::new(
ConfluenceRelation::CrossAxisSequenceMatch,
Vec::new(),
2,
Vec::new(),
1,
);
assert!(
!blind.is_complete() && blind.is_confluent(),
"one undecidable comparison and no skip is still an incomplete run"
);
}
#[test]
fn every_relation_round_trips_through_its_name() {
for relation in [
ConfluenceRelation::CrossAxisSequenceMatch,
ConfluenceRelation::Spdi,
] {
let name = relation.as_str();
assert!(!name.is_empty(), "{relation:?} must have a name");
assert_eq!(
ConfluenceRelation::from_name(name),
Some(relation),
"{name:?} must parse back to {relation:?}"
);
assert_eq!(relation.to_string(), name, "Display matches as_str");
}
assert_eq!(ConfluenceRelation::from_name("cross-axis"), None);
assert_eq!(ConfluenceRelation::from_name(""), None);
}
#[test]
fn the_two_skip_kinds_are_distinguishable() {
let unplaceable = ConfluenceSkipKind::Unplaceable;
let declined = ConfluenceSkipKind::NormalizationDeclined;
assert_ne!(unplaceable.as_str(), declined.as_str());
assert_eq!(unplaceable.to_string(), unplaceable.as_str());
assert_eq!(declined.to_string(), declined.as_str());
}
}