use std::path::Path;
use crate::error_handling::ErrorConfig;
use crate::hgvs::edit::{InsertedSequence, NaEdit};
use crate::hgvs::interval::UncertainBoundary;
use crate::hgvs::location::CdsPos;
use crate::hgvs::variant::Accession;
use crate::{HgvsVariant, NormalizeConfig, ShuffleDirection};
pub const CASES_PATH: &str = "tests/fixtures/inversion-sweep/cases.tsv";
pub const WINDOWS_PATH: &str = "tests/fixtures/inversion-sweep/reference-windows.json";
pub const SPEC_DIR: &str = "assets/hgvs-nomenclature";
pub const TRANSCRIPT: &str = "NM_004006.2";
pub const START_FIRST: u64 = 101;
pub const START_LIMIT: u64 = 3000;
pub const START_STEP: u64 = 7;
pub const LENGTHS: [u64; 5] = [4, 6, 8, 12, 16];
pub const ADJUDICATED_INPUTS: [&str; 3] = [
"NM_004006.2:c.5657_5660inv",
"NM_004006.2:c.4145_4160inv",
"NM_004006.2:c.5657_5660delinsTCAG",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SweepInput {
pub start: u64,
pub length: u64,
}
impl SweepInput {
pub fn end(&self) -> u64 {
self.start + self.length - 1
}
pub fn description(&self) -> String {
format!("{TRANSCRIPT}:c.{}_{}inv", self.start, self.end())
}
}
pub fn sweep_inputs() -> Vec<SweepInput> {
let mut inputs = Vec::new();
let mut start = START_FIRST;
while start < START_LIMIT {
for length in LENGTHS {
inputs.push(SweepInput { start, length });
}
start += START_STEP;
}
inputs
}
pub fn sweep_normalize_config() -> (ErrorConfig, NormalizeConfig) {
let error_config = ErrorConfig::lenient();
let normalize_config =
NormalizeConfig::for_entry_point(ShuffleDirection::ThreePrime, error_config.clone());
(error_config, normalize_config)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SweepOutcome {
Unchanged,
ShiftedInversion,
PalindromicNoOp,
Repartitioned(usize),
MemberChangesLength(String),
Retyped(String),
Unexpected(String),
}
impl SweepOutcome {
pub fn is_conformant(&self) -> bool {
matches!(
self,
SweepOutcome::Unchanged
| SweepOutcome::ShiftedInversion
| SweepOutcome::PalindromicNoOp
| SweepOutcome::Repartitioned(_)
)
}
pub fn label(&self) -> &'static str {
match self {
SweepOutcome::Unchanged => "unchanged",
SweepOutcome::ShiftedInversion => "shifted-still-inv",
SweepOutcome::PalindromicNoOp => "palindromic-no-op",
SweepOutcome::Repartitioned(_) => "repartitioned",
SweepOutcome::MemberChangesLength(_) => "MEMBER-CHANGES-LENGTH",
SweepOutcome::Retyped(_) => "RETYPED",
SweepOutcome::Unexpected(_) => "UNEXPECTED",
}
}
}
fn names_the_sweep_transcript(accession: &Accession) -> bool {
accession.clone().without_genomic_context().full() == TRANSCRIPT
}
pub fn classify(input: &str, normalized: &HgvsVariant, rendered: &str) -> SweepOutcome {
if let HgvsVariant::Allele(allele) = normalized {
for member in &allele.variants {
if let Some(outcome) = member_violation(member, rendered) {
return outcome;
}
}
return SweepOutcome::Repartitioned(allele.variants.len());
}
let HgvsVariant::Cds(cds) = normalized else {
return SweepOutcome::Unexpected(format!(
"output is not a c. description on {TRANSCRIPT}: {rendered}"
));
};
if !names_the_sweep_transcript(&cds.accession) {
return SweepOutcome::Unexpected(format!(
"output is a c. description on {} rather than on {TRANSCRIPT}: {rendered}",
cds.accession.full()
));
}
let Some(edit) = cds.loc_edit.edit.inner() else {
return SweepOutcome::Unexpected(format!("output carries no edit: {rendered}"));
};
match edit {
NaEdit::Inversion { .. } => {
if rendered == input {
SweepOutcome::Unchanged
} else {
SweepOutcome::ShiftedInversion
}
}
NaEdit::Identity { .. } => SweepOutcome::PalindromicNoOp,
other => SweepOutcome::Retyped(edit_kind(other).to_string()),
}
}
fn member_violation(member: &HgvsVariant, rendered: &str) -> Option<SweepOutcome> {
let HgvsVariant::Cds(cds) = member else {
return Some(SweepOutcome::Unexpected(format!(
"a member is not a c. description on {TRANSCRIPT}: {rendered}"
)));
};
if !names_the_sweep_transcript(&cds.accession) {
return Some(SweepOutcome::Unexpected(format!(
"a member is on {} rather than on {TRANSCRIPT}: {rendered}",
cds.accession.full()
)));
}
let edit = cds.loc_edit.edit.inner()?;
match edit {
NaEdit::Substitution { .. } | NaEdit::SubstitutionNoRef { .. } => None,
NaEdit::Inversion { .. } | NaEdit::Identity { .. } => None,
NaEdit::Delins { sequence, .. } => {
let span = plain_span(cds)?;
let inserted = literal_insertion(sequence)?;
(inserted.len() as u64 != span).then(|| {
SweepOutcome::MemberChangesLength(format!(
"a delins member replaces {span} base(s) with {}: {rendered}",
inserted.len()
))
})
}
other => Some(SweepOutcome::MemberChangesLength(format!(
"a member is a {}, which cannot preserve the span's length: {rendered}",
edit_kind(other)
))),
}
}
fn plain_position(boundary: &UncertainBoundary<CdsPos>) -> Option<i64> {
let UncertainBoundary::Single(mu) = boundary else {
return None;
};
let pos = mu.inner()?;
if pos.offset.is_some() || pos.utr3 || pos.special.is_some() || pos.base < 1 {
return None;
}
Some(pos.base)
}
fn plain_span(cds: &crate::hgvs::variant::CdsVariant) -> Option<u64> {
let start = plain_position(&cds.loc_edit.location.start)?;
let end = plain_position(&cds.loc_edit.location.end)?;
u64::try_from(end - start + 1).ok()
}
fn literal_insertion(sequence: &InsertedSequence) -> Option<String> {
match sequence {
InsertedSequence::Literal(bases) => Some(bases.to_string()),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberEdit {
pub start: u64,
pub end: u64,
pub replacement: MemberReplacement,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberReplacement {
Literal(String),
ReverseComplement,
Unchanged,
}
pub fn member_edits(normalized: &HgvsVariant) -> Result<Vec<MemberEdit>, String> {
let members: Vec<&HgvsVariant> = match normalized {
HgvsVariant::Allele(allele) => allele.variants.iter().collect(),
single => vec![single],
};
let mut edits = Vec::with_capacity(members.len());
for member in members {
let HgvsVariant::Cds(cds) = member else {
return Err(format!("member is not a c. description: {member}"));
};
let start = plain_position(&cds.loc_edit.location.start)
.ok_or_else(|| format!("member start is not a plain coding position: {member}"))?;
let end = plain_position(&cds.loc_edit.location.end)
.ok_or_else(|| format!("member end is not a plain coding position: {member}"))?;
let edit = cds
.loc_edit
.edit
.inner()
.ok_or_else(|| format!("member carries no edit: {member}"))?;
let replacement = match edit {
NaEdit::Substitution { alternative, .. } => {
MemberReplacement::Literal(alternative.to_string())
}
NaEdit::SubstitutionNoRef { alternative } => {
MemberReplacement::Literal(alternative.to_string())
}
NaEdit::Inversion { .. } => MemberReplacement::ReverseComplement,
NaEdit::Identity { .. } => MemberReplacement::Unchanged,
NaEdit::Delins { sequence, .. } => MemberReplacement::Literal(
literal_insertion(sequence)
.ok_or_else(|| format!("member's insertion is not literal: {member}"))?,
),
other => {
return Err(format!(
"member is a {}, which the coding-axis oracle does not apply: {member}",
edit_kind(other)
))
}
};
edits.push(MemberEdit {
start: u64::try_from(start)
.map_err(|_| format!("member start is negative: {member}"))?,
end: u64::try_from(end).map_err(|_| format!("member end is negative: {member}"))?,
replacement,
});
}
Ok(edits)
}
pub fn apply_member_edits(
bases: &str,
cds_start: u64,
edits: &[MemberEdit],
) -> Result<String, String> {
let mut ordered: Vec<&MemberEdit> = edits.iter().collect();
ordered.sort_by_key(|edit| std::cmp::Reverse(edit.start));
let mut result = bases.as_bytes().to_vec();
let mut claimed_from = u64::MAX;
for edit in ordered {
if edit.end < edit.start {
return Err(format!("member spans c.{}_{}", edit.start, edit.end));
}
if edit.end >= claimed_from {
return Err(format!(
"member c.{}_{} overlaps the one 3' of it, so the output denotes no single \
sequence",
edit.start, edit.end
));
}
claimed_from = edit.start;
let lo = usize::try_from(cds_start + edit.start - 2)
.map_err(|_| format!("c.{} is out of range", edit.start))?;
let hi = usize::try_from(cds_start + edit.end - 1)
.map_err(|_| format!("c.{} is out of range", edit.end))?;
if hi > result.len() || lo >= hi {
return Err(format!(
"c.{}_{} maps to [{lo}, {hi}), outside the {} stored bases",
edit.start,
edit.end,
result.len()
));
}
let block = String::from_utf8(result[lo..hi].to_vec())
.map_err(|e| format!("stored bases are not ASCII: {e}"))?;
let replacement = match &edit.replacement {
MemberReplacement::Literal(bases) => bases.to_ascii_uppercase(),
MemberReplacement::ReverseComplement => reverse_complement(&block),
MemberReplacement::Unchanged => block,
};
result.splice(lo..hi, replacement.bytes());
}
String::from_utf8(result).map_err(|e| format!("edited bases are not ASCII: {e}"))
}
fn edit_kind(edit: &NaEdit) -> &'static str {
match edit {
NaEdit::Substitution { .. } | NaEdit::SubstitutionNoRef { .. } => "substitution",
NaEdit::Deletion { .. } | NaEdit::NPaddedDeletion { .. } => "deletion",
NaEdit::Insertion { .. } | NaEdit::BreakpointInsertion { .. } => "insertion",
NaEdit::Delins { .. } => "delins",
NaEdit::Duplication { .. } => "duplication",
NaEdit::DupIns { .. } => "dupins",
NaEdit::Repeat { .. } | NaEdit::MultiRepeat { .. } => "repeat",
NaEdit::Conversion { .. } => "conversion",
NaEdit::CopyNumber { .. } => "copy-number",
_ => "other",
}
}
pub fn reverse_complement(bases: &str) -> String {
bases
.bytes()
.rev()
.map(|b| match b.to_ascii_uppercase() {
b'A' => 'T',
b'C' => 'G',
b'G' => 'C',
b'T' => 'A',
b'U' => 'A',
b'R' => 'Y',
b'Y' => 'R',
b'K' => 'M',
b'M' => 'K',
b'B' => 'V',
b'V' => 'B',
b'D' => 'H',
b'H' => 'D',
other => other as char,
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinnedCase {
pub input: String,
pub output: String,
}
#[derive(Debug, Clone, Default)]
pub struct PinnedCases {
pub rows: Vec<PinnedCase>,
}
impl PinnedCases {
pub fn from_tsv(content: &str) -> Result<Self, String> {
let mut rows = Vec::new();
for (number, line) in content.lines().enumerate() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((input, output)) = line.split_once('\t') else {
return Err(format!(
"{CASES_PATH}:{}: expected `input<TAB>output`, got {line:?}",
number + 1
));
};
if input.is_empty() || output.is_empty() {
return Err(format!(
"{CASES_PATH}:{}: empty column in {line:?}",
number + 1
));
}
rows.push(PinnedCase {
input: input.to_string(),
output: output.to_string(),
});
}
Ok(Self { rows })
}
pub fn from_tsv_path(path: &Path) -> Result<Self, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
Self::from_tsv(&content)
}
pub fn to_tsv(&self, header: &str) -> String {
let mut out = String::new();
for line in header.lines() {
out.push_str("# ");
out.push_str(line);
out.push('\n');
}
for row in &self.rows {
out.push_str(&row.input);
out.push('\t');
out.push_str(&row.output);
out.push('\n');
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_sweep_enumerates_every_start_length_pair_exactly_once() {
let inputs = sweep_inputs();
assert_eq!(inputs.len(), 2075);
let mut seen: Vec<(u64, u64)> = inputs.iter().map(|i| (i.start, i.length)).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), inputs.len(), "the sweep repeats a (start, len)");
assert_eq!(
inputs[0],
SweepInput {
start: 101,
length: 4
}
);
assert_eq!(
*inputs.last().unwrap(),
SweepInput {
start: 2999,
length: 16
}
);
assert!(
inputs.iter().all(|input| input.start < START_LIMIT),
"a start escaped the sweep's exclusive upper bound"
);
}
#[test]
fn a_description_spans_exactly_its_length() {
let input = SweepInput {
start: 101,
length: 8,
};
assert_eq!(input.end(), 108);
assert_eq!(input.description(), "NM_004006.2:c.101_108inv");
}
#[test]
fn every_sweep_length_is_a_legal_inversion_width() {
assert!(LENGTHS.iter().all(|&len| len > 1));
}
#[test]
fn a_palindromic_span_is_its_own_reverse_complement() {
assert_eq!(reverse_complement("CTGA"), "TCAG");
assert_eq!(reverse_complement("GGCC"), "GGCC");
assert_eq!(reverse_complement("acgt"), "ACGT");
}
#[test]
fn the_reverse_complement_covers_the_iupac_alphabet() {
assert_eq!(reverse_complement("R"), "Y");
assert_eq!(reverse_complement("Y"), "R");
assert_eq!(reverse_complement("RY"), "RY");
assert_eq!(reverse_complement("KMBVDH"), "DHBVKM");
assert_eq!(reverse_complement("N"), "N");
assert_eq!(reverse_complement("S"), "S");
assert_eq!(reverse_complement("W"), "W");
}
#[test]
fn an_output_on_another_accession_is_unexpected_rather_than_unchanged() {
let input = "NM_004006.2:c.101_104inv";
let ours = crate::parse_hgvs(input).expect("parse");
assert_eq!(classify(input, &ours, input), SweepOutcome::Unchanged);
let elsewhere = "NM_000088.3:c.101_104inv";
let other = crate::parse_hgvs(elsewhere).expect("parse");
assert!(matches!(
classify(input, &other, elsewhere),
SweepOutcome::Unexpected(_)
));
let other_version = "NM_004006.3:c.101_104inv";
let versioned = crate::parse_hgvs(other_version).expect("parse");
assert!(matches!(
classify(input, &versioned, other_version),
SweepOutcome::Unexpected(_)
));
}
#[test]
fn a_repartition_and_the_inversion_it_replaced_denote_one_sequence() {
let bases = "ATGGCCTTAG";
let inversion = [MemberEdit {
start: 2,
end: 5,
replacement: MemberReplacement::ReverseComplement,
}];
let split: Vec<MemberEdit> = [(2, "G"), (3, "C"), (4, "C"), (5, "A")]
.into_iter()
.map(|(at, base)| MemberEdit {
start: at,
end: at,
replacement: MemberReplacement::Literal(base.to_string()),
})
.collect();
let inverted = apply_member_edits(bases, 1, &inversion).unwrap();
assert_eq!(inverted, "AGCCACTTAG");
assert_eq!(apply_member_edits(bases, 1, &split).unwrap(), inverted);
}
#[test]
fn the_oracle_reads_c_positions_through_the_cds_start() {
let bases = "ATGGCCTTAG";
let edits = [MemberEdit {
start: 1,
end: 2,
replacement: MemberReplacement::ReverseComplement,
}];
assert_eq!(apply_member_edits(bases, 3, &edits).unwrap(), "ATCCCCTTAG");
}
#[test]
fn the_oracle_refuses_overlapping_members_rather_than_ordering_them() {
let edits = [
MemberEdit {
start: 2,
end: 4,
replacement: MemberReplacement::Literal("AAA".to_string()),
},
MemberEdit {
start: 4,
end: 5,
replacement: MemberReplacement::Literal("CC".to_string()),
},
];
let error = apply_member_edits("ATGGCCTTAG", 1, &edits).unwrap_err();
assert!(error.contains("overlaps"), "{error}");
}
#[test]
fn member_edits_are_read_from_a_repartitioned_output() {
let parsed = crate::parse_hgvs("NM_004006.2:c.[101A>T;104A>T]").expect("parse");
let edits = member_edits(&parsed).expect("readable on the coding axis");
assert_eq!(
edits,
vec![
MemberEdit {
start: 101,
end: 101,
replacement: MemberReplacement::Literal("T".to_string())
},
MemberEdit {
start: 104,
end: 104,
replacement: MemberReplacement::Literal("T".to_string())
},
]
);
}
#[test]
fn member_edits_refuses_an_intronic_endpoint_instead_of_guessing() {
let parsed = crate::parse_hgvs("NM_004006.2:c.1322_1332-1inv").expect("parse");
let error = member_edits(&parsed).unwrap_err();
assert!(error.contains("not a plain coding position"), "{error}");
}
#[test]
fn a_repartition_is_conformant_and_a_length_changing_member_is_not() {
let input = "NM_004006.2:c.101_104inv";
for split in [
"NM_004006.2:c.[101A>T;104A>T]",
"NM_004006.2:c.[101A>T;102_103inv;104A>T]",
] {
let parsed = crate::parse_hgvs(split).expect("parse");
assert_eq!(
classify(input, &parsed, split),
SweepOutcome::Repartitioned(if split.contains("inv") { 3 } else { 2 })
);
}
let deletion = "NM_004006.2:c.[101A>T;104del]";
let parsed = crate::parse_hgvs(deletion).expect("parse");
assert!(matches!(
classify(input, &parsed, deletion),
SweepOutcome::MemberChangesLength(_)
));
let unbalanced = "NM_004006.2:c.[101_103delinsTT;104A>T]";
let parsed = crate::parse_hgvs(unbalanced).expect("parse");
assert!(matches!(
classify(input, &parsed, unbalanced),
SweepOutcome::MemberChangesLength(_)
));
}
#[test]
fn a_genomic_wrapper_is_not_a_re_anchor() {
let input = "NM_004006.2:c.101_104inv";
let wrapped = "NC_000023.11(NM_004006.2):c.101_104inv";
let parsed = crate::parse_hgvs(wrapped).expect("parse");
assert_eq!(
classify(input, &parsed, wrapped),
SweepOutcome::ShiftedInversion,
"the wrapper is the reference `checklist.md:20` requires, not a different \
transcript — the inner accession is unchanged, so this is a conformant \
re-rendering rather than `Unexpected`"
);
let split = "NC_000023.11(NM_004006.2):c.[101A>T;104A>T]";
let parsed = crate::parse_hgvs(split).expect("parse");
assert!(
classify(input, &parsed, split).is_conformant(),
"a wrapped member is on the sweep transcript too, and `member_violation` is a \
second comparison that has to agree with the first"
);
let elsewhere = "NC_000023.11(NM_000088.3):c.101_104inv";
let parsed = crate::parse_hgvs(elsewhere).expect("parse");
assert!(
matches!(
classify(input, &parsed, elsewhere),
SweepOutcome::Unexpected(_)
),
"stripping the wrapper must not strip the check — the inner accession still decides"
);
}
#[test]
fn a_member_on_another_accession_is_unexpected() {
let mixed = "NM_004006.2:c.[101A>T;104A>T]";
let parsed = crate::parse_hgvs(mixed).expect("parse");
assert!(classify(mixed, &parsed, mixed).is_conformant());
let elsewhere = "NM_000088.3:c.[101A>T;104A>T]";
let parsed = crate::parse_hgvs(elsewhere).expect("parse");
assert!(matches!(
classify(mixed, &parsed, elsewhere),
SweepOutcome::Unexpected(_)
));
}
#[test]
fn the_shared_normalize_config_is_lenient_and_three_prime() {
let (errors, normalize) = sweep_normalize_config();
assert_eq!(errors.mode, ErrorConfig::lenient().mode);
assert_eq!(normalize.shuffle_direction, ShuffleDirection::ThreePrime);
}
#[test]
fn the_tsv_round_trips_and_rejects_a_malformed_row() {
let cases = PinnedCases {
rows: vec![PinnedCase {
input: "NM_004006.2:c.101_104inv".to_string(),
output: "NM_004006.2:c.101_104inv".to_string(),
}],
};
let rendered = cases.to_tsv("generated\ndo not hand-edit");
assert!(rendered.starts_with("# generated\n# do not hand-edit\n"));
assert_eq!(PinnedCases::from_tsv(&rendered).unwrap().rows, cases.rows);
assert!(PinnedCases::from_tsv("no-tab-here").is_err());
assert!(PinnedCases::from_tsv("input\t").is_err());
}
#[test]
fn only_the_four_conformant_outcomes_are_conformant() {
assert!(SweepOutcome::Unchanged.is_conformant());
assert!(SweepOutcome::ShiftedInversion.is_conformant());
assert!(SweepOutcome::PalindromicNoOp.is_conformant());
assert!(SweepOutcome::Repartitioned(2).is_conformant());
assert!(!SweepOutcome::MemberChangesLength("x".to_string()).is_conformant());
assert!(!SweepOutcome::Retyped("substitution".to_string()).is_conformant());
assert!(!SweepOutcome::Unexpected("x".to_string()).is_conformant());
}
}