From aa1534691def109a08835a7dc054bb544eb25c60 Mon Sep 17 00:00:00 2001 From: SuhasSrinivasan <32346517+SuhasSrinivasan@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:20:35 -0700 Subject: [PATCH 1/3] Handle ambiguous bases in optimized mod scanning --- modkit-core/src/pileup/base_mods_adapter.rs | 43 ++++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/modkit-core/src/pileup/base_mods_adapter.rs b/modkit-core/src/pileup/base_mods_adapter.rs index 3dbf3d5..966ef57 100644 --- a/modkit-core/src/pileup/base_mods_adapter.rs +++ b/modkit-core/src/pileup/base_mods_adapter.rs @@ -77,7 +77,7 @@ impl<'a, const SIZE: usize> BaseModsAdapter<'a, SIZE> { b'C' | b'c' => agg[2] += 1u32, b'G' | b'g' => agg[1] += 1u32, b'T' | b't' => agg[0] += 1u32, - _ => unreachable!(), + _ => {} } agg }, @@ -465,11 +465,23 @@ fn parse_int(bs: &[u8]) -> (u32, usize) { fn base_complement(base: u8) -> u8 { match base { + b'=' => b'=', b'A' => b'T', b'C' => b'G', + b'M' => b'K', b'G' => b'C', + b'R' => b'Y', + b'S' => b'S', + b'V' => b'B', b'T' => b'A', - _ => panic!("not allowed base"), + b'W' => b'W', + b'Y' => b'R', + b'H' => b'D', + b'K' => b'M', + b'D' => b'H', + b'B' => b'V', + b'N' => b'N', + _ => base, } } @@ -622,6 +634,33 @@ mod base_mods_adapter_tests { assert!(mod_state.is_none()); } + #[test] + fn test_ambiguous_query_bases_preserve_mm_scanning() { + for (seq, reverse, expected) in [ + ("CARNCC", false, vec![(0, 235), (4, 225)]), + ("GNYGG", true, vec![(3, 225), (4, 235)]), + ] { + let record = + make_record("C+m?,0,0;", &[20, 30], seq, None, reverse); + let mut scanner = BaseModsAdapter::<1>::new(&record).unwrap(); + let states = std::iter::from_fn(|| { + scanner.next_modified_position([0f32; 4], &[]).unwrap() + }) + .map(|state| { + assert_eq!( + state.primary_base, + crate::mod_base_code::DnaBase::C + ); + assert!(!state.modified); + assert!(!state.inferred); + (state.mod_position, state.mod_qual) + }) + .collect::>(); + + assert_eq!(states, expected, "reverse={reverse}"); + } + } + #[test] fn test_forward() { let seq = "ATCATCATTCCTACCGCTATAGCCT"; From 85d62ea359ab6c26ecd03d1e06353e3ac004a90d Mon Sep 17 00:00:00 2001 From: SuhasSrinivasan <32346517+SuhasSrinivasan@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:20:53 -0700 Subject: [PATCH 2/3] Preserve pileup reads around ambiguous query bases --- modkit-core/src/pileup/pileup_processor.rs | 107 ++++++---------- modkit/tests/test_pileup.rs | 140 +++++++++++++++++++++ 2 files changed, 175 insertions(+), 72 deletions(-) diff --git a/modkit-core/src/pileup/pileup_processor.rs b/modkit-core/src/pileup/pileup_processor.rs index 262371a..afc5367 100644 --- a/modkit-core/src/pileup/pileup_processor.rs +++ b/modkit-core/src/pileup/pileup_processor.rs @@ -51,6 +51,17 @@ where ) -> anyhow::Result; } +#[inline] +fn get_query_base(record: &bam::Record, qpos: usize) -> Option { + DnaBase::try_from(record.seq()[qpos]).ok().map(|base| { + if record.is_reverse() { + base.complement() + } else { + base + } + }) +} + pub(super) struct DnaPileupWorker< T, M, @@ -319,17 +330,8 @@ impl< // at this aligned position, but there is no modification // call (Some(q), Some(mp)) if q < mp.mod_position => { - let base = { - let Ok(tmp) = DnaBase::try_from(record.seq()[q]) - else { - erred_records = erred_records.saturating_add(1); - continue 'records; - }; - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + let Some(base) = get_query_base(&record, q) else { + continue 'pileup; }; self.matrix .incr_diff_call(rpos, base, ref_base, reverse, hp); @@ -362,22 +364,16 @@ impl< break 'overran; } else { assert!(pos > q); - let base = { - let tmp = DnaBase::try_from( - record.seq()[q], - ) - .unwrap(); - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + mod_state = Some(ms); + let Some(base) = + get_query_base(&record, q) + else { + break 'overran; }; self.matrix.incr_diff_call( rpos, base, ref_base, reverse, hp, ); - mod_state = Some(ms); break 'overran; } } @@ -388,20 +384,14 @@ impl< continue 'records; } Ok(None) => { - let base = { - let tmp = - DnaBase::try_from(record.seq()[q]) - .unwrap(); - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + mod_state = None; + let Some(base) = get_query_base(&record, q) + else { + break 'overran; }; self.matrix.incr_diff_call( rpos, base, ref_base, reverse, hp, ); - mod_state = None; break 'overran; } } @@ -412,14 +402,8 @@ impl< self.matrix.incr_delete(rpos, reverse, hp); } (Some(q), None) => { - let base = { - let tmp = - DnaBase::try_from(record.seq()[q]).unwrap(); - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + let Some(base) = get_query_base(&record, q) else { + continue 'pileup; }; self.matrix .incr_diff_call(rpos, base, ref_base, reverse, hp); @@ -755,17 +739,8 @@ impl PileupWorker for GenericPileupWorker { continue 'pileup; } (Some(q), Some(mp)) if q < mp => { - let base = { - let Ok(tmp) = DnaBase::try_from(record.seq()[q]) - else { - erred_records = erred_records.saturating_add(1); - continue 'records; - }; - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + let Some(base) = get_query_base(&record, q) else { + continue 'pileup; }; if implicit_bases.contains(&base) { add_to_tally( @@ -860,15 +835,13 @@ impl PileupWorker for GenericPileupWorker { break 'overran; } else { assert!(pos > q); - let base = { - let tmp = - DnaBase::try_from(record.seq()[q]) - .unwrap(); - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + mod_pos = Some(pos); + canonical_base = Some(can_base); + pos_base_mod_call = Some(call); + mod_strand = Some(pos_mod_strand); + let Some(base) = get_query_base(&record, q) + else { + break 'overran; }; if implicit_bases.contains(&base) { add_to_tally( @@ -891,10 +864,6 @@ impl PileupWorker for GenericPileupWorker { motif_idxs, ); } - mod_pos = Some(pos); - canonical_base = Some(can_base); - pos_base_mod_call = Some(call); - mod_strand = Some(pos_mod_strand); break 'overran; } } @@ -910,14 +879,8 @@ impl PileupWorker for GenericPileupWorker { ); } (Some(q), None) => { - let base = { - let tmp = - DnaBase::try_from(record.seq()[q]).unwrap(); - if record.is_reverse() { - tmp.complement() - } else { - tmp - } + let Some(base) = get_query_base(&record, q) else { + continue 'pileup; }; if implicit_bases.contains(&base) { add_to_tally( diff --git a/modkit/tests/test_pileup.rs b/modkit/tests/test_pileup.rs index 68f2cac..e1741e1 100644 --- a/modkit/tests/test_pileup.rs +++ b/modkit/tests/test_pileup.rs @@ -13,6 +13,90 @@ use mod_kit::mod_base_code::{ModCodeRepr, METHYL_CYTOSINE}; mod common; +fn make_ambiguous_query_pileup_bam(bam_path: &PathBuf, overrun: bool) { + let mut header = bam::Header::new(); + let mut header_record = bam::header::HeaderRecord::new(b"HD"); + header_record.push_tag(b"VN", "1.6").push_tag(b"SO", "coordinate"); + header.push_record(&header_record); + let mut reference_record = bam::header::HeaderRecord::new(b"SQ"); + reference_record + .push_tag(b"SN", "oligo_1512_adapters") + .push_tag(b"LN", 156); + header.push_record(&reference_record); + + let (sequence, cigar, mm_tag, ml_tag) = if overrun { + ( + b"CNCTGTACTT".as_slice(), + bam::record::CigarString(vec![ + bam::record::Cigar::SoftClip(1), + bam::record::Cigar::Match(9), + ]), + "C+m?,0,0;", + vec![255, 255], + ) + } else { + ( + b"NCTGTACTTN".as_slice(), + bam::record::CigarString(vec![bam::record::Cigar::Match(10)]), + "C+m?,0;", + vec![255], + ) + }; + let mut record = bam::Record::new(); + record.set(b"ambiguous-query", Some(&cigar), sequence, &[30; 10]); + record.set_tid(0); + record.set_pos(0); + record.set_mapq(60); + record.push_aux(b"MM", bam::record::Aux::String(mm_tag)).unwrap(); + record + .push_aux(b"ML", bam::record::Aux::ArrayU8((&ml_tag[..]).into())) + .unwrap(); + + let mut writer = + bam::Writer::from_path(bam_path, &header, bam::Format::Bam).unwrap(); + writer.write(&record).unwrap(); + drop(writer); + bam::index::build(bam_path, None, bam::index::Type::Bai, 1).unwrap(); +} + +type AmbiguousQueryPileupRow = (u64, ModCodeRepr, u64, u64); + +fn run_ambiguous_query_pileup( + input_bam: &PathBuf, + output_bed: &PathBuf, + optimized: bool, +) -> Result, String> { + let mut args = vec![ + "pileup", + input_bam.to_str().unwrap(), + output_bed.to_str().unwrap(), + ]; + if optimized { + args.extend(["--modified-bases", "5mC"]); + } + args.extend([ + "--no-filtering", + "--ref", + "../tests/resources/CGI_ladder_3.6kb_ref.fa", + "--threads", + "1", + ]); + run_modkit(&args).map_err(|error| error.to_string())?; + + Ok(BufReader::new(File::open(output_bed).unwrap()) + .lines() + .map(|line| BedMethylLine::parse(&line.unwrap()).unwrap()) + .map(|row| { + ( + row.start(), + row.raw_mod_code, + row.count_methylated, + row.valid_coverage, + ) + }) + .collect()) +} + #[test] fn test_pileup_help() { let pileup_help_args = ["pileup", "--help"]; @@ -155,6 +239,62 @@ fn test_pileup_no_mod_calls() { assert_eq!(lines.len(), 0); } +#[test] +fn test_pileup_skips_ambiguous_query_and_keeps_downstream_modification() { + let temp_dir = tempfile::tempdir().unwrap(); + let input_bam = temp_dir.path().join("ambiguous-query.bam"); + let optimized_output = temp_dir.path().join("optimized.bed"); + let generic_output = temp_dir.path().join("generic.bed"); + make_ambiguous_query_pileup_bam(&input_bam, false); + let observed = vec![ + ( + "optimized", + run_ambiguous_query_pileup(&input_bam, &optimized_output, true), + ), + ( + "generic", + run_ambiguous_query_pileup(&input_bam, &generic_output, false), + ), + ]; + let expected_row = (1, METHYL_CYTOSINE, 1, 1); + + assert_eq!( + observed, + vec![ + ("optimized", Ok(vec![expected_row])), + ("generic", Ok(vec![expected_row])), + ] + ); +} + +#[test] +fn test_pileup_skips_ambiguous_query_after_unaligned_modification() { + let temp_dir = tempfile::tempdir().unwrap(); + let input_bam = temp_dir.path().join("ambiguous-query-overrun.bam"); + let optimized_output = temp_dir.path().join("optimized.bed"); + let generic_output = temp_dir.path().join("generic.bed"); + make_ambiguous_query_pileup_bam(&input_bam, true); + let observed = vec![ + ( + "optimized", + run_ambiguous_query_pileup(&input_bam, &optimized_output, true), + ), + ( + "generic", + run_ambiguous_query_pileup(&input_bam, &generic_output, false), + ), + ]; + let expected_row = (1, METHYL_CYTOSINE, 1, 1); + + assert_eq!( + observed, + vec![ + ("optimized", Ok(vec![expected_row])), + ("generic", Ok(vec![expected_row])), + ] + ); +} + #[test] fn test_pileup_old_tags() { let updated_file = From bb20328778b2a5e3f0f815b36af2ae09c1df7ae7 Mon Sep 17 00:00:00 2001 From: SuhasSrinivasan <32346517+SuhasSrinivasan@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:50:35 -0700 Subject: [PATCH 3/3] Reject reference-equal pileup records --- modkit-core/src/pileup/mod.rs | 2 + modkit-core/src/pileup/pileup_processor.rs | 55 +++++++++++++ modkit-core/src/pileup/subcommand.rs | 16 ++++ modkit/tests/test_pileup.rs | 92 +++++++++++++++++++--- 4 files changed, 152 insertions(+), 13 deletions(-) diff --git a/modkit-core/src/pileup/mod.rs b/modkit-core/src/pileup/mod.rs index e7e0d85..eb8692a 100644 --- a/modkit-core/src/pileup/mod.rs +++ b/modkit-core/src/pileup/mod.rs @@ -171,6 +171,7 @@ pub struct ModBasePileup2 { pub(crate) interval_width: usize, pub(crate) stride: usize, pub(crate) failed_records: usize, + pub(crate) reference_equal_records: usize, pub(crate) phased_feature_counts: [Vec; 2], } @@ -182,6 +183,7 @@ impl ModBasePileup2 { interval_width: 0, stride: 0, failed_records: 0, + reference_equal_records: 0, phased_feature_counts: [Vec::new(), Vec::new()], } } diff --git a/modkit-core/src/pileup/pileup_processor.rs b/modkit-core/src/pileup/pileup_processor.rs index afc5367..8f9f145 100644 --- a/modkit-core/src/pileup/pileup_processor.rs +++ b/modkit-core/src/pileup/pileup_processor.rs @@ -62,6 +62,37 @@ fn get_query_base(record: &bam::Record, qpos: usize) -> Option { }) } +#[inline] +fn record_uses_reference_equal_bases(record: &bam::Record) -> bool { + let seq = record.seq(); + // Each complete byte stores two bases. For odd-length sequences, only + // inspect the final high nibble because the low nibble is zero padding. + let complete_bytes = seq.len() / 2; + seq.encoded[..complete_bytes] + .iter() + .any(|packed| packed & 0x0f == 0 || packed >> 4 == 0) + || (seq.len() % 2 == 1 && seq.encoded[complete_bytes] >> 4 == 0) +} + +#[cfg(test)] +mod reference_equal_base_tests { + use super::*; + + #[test] + fn odd_length_padding_is_not_a_reference_equal_base() { + let mut canonical = bam::Record::new(); + canonical.set(b"canonical", None, b"ACGTA", &[30; 5]); + let seq = canonical.seq(); + assert_eq!(seq.len(), 5); + assert_eq!(seq.encoded.last().unwrap() & 0x0f, 0); + assert!(!record_uses_reference_equal_bases(&canonical)); + + let mut reference_equal = bam::Record::new(); + reference_equal.set(b"reference-equal", None, b"AC=TA", &[30; 5]); + assert!(record_uses_reference_equal_bases(&reference_equal)); + } +} + pub(super) struct DnaPileupWorker< T, M, @@ -157,6 +188,7 @@ impl< mut pileup_space: ModBasePileup2, ) -> anyhow::Result { let mut erred_records = 0usize; + let mut reference_equal_records = 0usize; let chrom_tid = item.chrom_tid; let start_pos = item.start_pos; let end_pos = item.end_pos; @@ -203,6 +235,16 @@ impl< }); 'records: for (record, hp) in records { + // BAM code zero (`=` in SAM) means "same as the reference". Its + // identity cannot be recovered from SEQ alone, so reject the + // record before adding any of its observations to the matrix. + if record_uses_reference_equal_bases(&record) { + erred_records = erred_records.saturating_add(1); + reference_equal_records = + reference_equal_records.saturating_add(1); + continue 'records; + } + if self.allow_non_primary && record_is_not_primary(&record) { if validate_mn_tag_on_record(&record).is_err() { erred_records = erred_records.saturating_add(1); @@ -445,6 +487,7 @@ impl< pileup_space.position_feature_counts = combined_counts; pileup_space.phased_feature_counts = [hp1, hp2]; pileup_space.failed_records = erred_records; + pileup_space.reference_equal_records = reference_equal_records; Ok(pileup_space) } else { pileup_space.chrom_name = chrom_name; @@ -455,6 +498,7 @@ impl< .filter(|x| x.is_valid()) .collect(); pileup_space.failed_records = erred_records; + pileup_space.reference_equal_records = reference_equal_records; Ok(pileup_space) } } @@ -539,6 +583,7 @@ impl PileupWorker for GenericPileupWorker { mut pileup_space: ModBasePileup2, ) -> anyhow::Result { let mut erred_records = 0usize; + let mut reference_equal_records = 0usize; let chrom_tid = chrom_coordinates.chrom_tid; let start_pos = chrom_coordinates.start_pos; let end_pos = chrom_coordinates.end_pos; @@ -614,6 +659,15 @@ impl PileupWorker for GenericPileupWorker { let mut pos_base_mod_call = Option::::None; let mut mod_strand = Option::::None; 'records: for record in records { + // Keep this preflight before modification parsing and tallying so + // a reference-equal query cannot contribute a partial record. + if record_uses_reference_equal_bases(&record) { + erred_records = erred_records.saturating_add(1); + reference_equal_records = + reference_equal_records.saturating_add(1); + continue 'records; + } + let reverse = record.is_reverse(); let record_strand = if record.is_reverse() { Strand::Negative @@ -934,6 +988,7 @@ impl PileupWorker for GenericPileupWorker { pileup_space.interval_width = width; pileup_space.position_feature_counts = position_feature_counts; pileup_space.failed_records = erred_records; + pileup_space.reference_equal_records = reference_equal_records; Ok(pileup_space) } } diff --git a/modkit-core/src/pileup/subcommand.rs b/modkit-core/src/pileup/subcommand.rs index 4503f99..1f3e51d 100644 --- a/modkit-core/src/pileup/subcommand.rs +++ b/modkit-core/src/pileup/subcommand.rs @@ -1623,11 +1623,16 @@ impl ModBamPileup { drop(records_tx); }); + let mut reference_equal_records = 0u64; for result in records_rx.into_iter() { match result { Ok(mod_base_pileup) => { tid_progress.inc(mod_base_pileup.interval_width as u64); erred_reads.inc(mod_base_pileup.failed_records as u64); + reference_equal_records = reference_equal_records + .saturating_add( + mod_base_pileup.reference_equal_records as u64, + ); let rows_written = writer.write(mod_base_pileup, &motif_labels)?; write_progress.inc(rows_written); @@ -1641,6 +1646,17 @@ impl ModBamPileup { let rows_processed = write_progress.position(); let n_failed_reads = erred_reads.position(); + if reference_equal_records > 0 { + master_progress.suspend(|| { + error!( + "~{reference_equal_records} records rejected because BAM \ + SEQ contains '='; '=' means reference-equality and must \ + be resolved against the reference to an explicit base \ + before pileup" + ); + }); + } + if n_failed_reads > 0 { master_progress.suspend(|| { error!("~{n_failed_reads} failed processing"); diff --git a/modkit/tests/test_pileup.rs b/modkit/tests/test_pileup.rs index e1741e1..f4d55ec 100644 --- a/modkit/tests/test_pileup.rs +++ b/modkit/tests/test_pileup.rs @@ -59,13 +59,47 @@ fn make_ambiguous_query_pileup_bam(bam_path: &PathBuf, overrun: bool) { bam::index::build(bam_path, None, bam::index::Type::Bai, 1).unwrap(); } -type AmbiguousQueryPileupRow = (u64, ModCodeRepr, u64, u64); +fn make_reference_equal_query_pileup_bam(bam_path: &PathBuf) { + let mut header = bam::Header::new(); + let mut header_record = bam::header::HeaderRecord::new(b"HD"); + header_record.push_tag(b"VN", "1.6").push_tag(b"SO", "coordinate"); + header.push_record(&header_record); + let mut reference_record = bam::header::HeaderRecord::new(b"SQ"); + reference_record + .push_tag(b"SN", "oligo_1512_adapters") + .push_tag(b"LN", 156); + header.push_record(&reference_record); + + let cigar = bam::record::CigarString(vec![bam::record::Cigar::Match(10)]); + let mut writer = + bam::Writer::from_path(bam_path, &header, bam::Format::Bam).unwrap(); + for (name, sequence) in [ + (b"reference-equal".as_slice(), b"C=TGTACTTC".as_slice()), + (b"valid-control".as_slice(), b"CCTGTACTTC".as_slice()), + ] { + let mut record = bam::Record::new(); + record.set(name, Some(&cigar), sequence, &[30; 10]); + record.set_tid(0); + record.set_pos(0); + record.set_mapq(60); + record.push_aux(b"MM", bam::record::Aux::String("C+m?,0;")).unwrap(); + record + .push_aux(b"ML", bam::record::Aux::ArrayU8((&[255][..]).into())) + .unwrap(); + writer.write(&record).unwrap(); + } + drop(writer); + bam::index::build(bam_path, None, bam::index::Type::Bai, 1).unwrap(); +} -fn run_ambiguous_query_pileup( +type QueryPileupRow = (u64, ModCodeRepr, u64, u64); + +fn run_query_pileup( input_bam: &PathBuf, output_bed: &PathBuf, optimized: bool, -) -> Result, String> { + log_path: Option<&PathBuf>, +) -> Result, String> { let mut args = vec![ "pileup", input_bam.to_str().unwrap(), @@ -81,6 +115,9 @@ fn run_ambiguous_query_pileup( "--threads", "1", ]); + if let Some(log_path) = log_path { + args.extend(["--log", log_path.to_str().unwrap()]); + } run_modkit(&args).map_err(|error| error.to_string())?; Ok(BufReader::new(File::open(output_bed).unwrap()) @@ -249,12 +286,9 @@ fn test_pileup_skips_ambiguous_query_and_keeps_downstream_modification() { let observed = vec![ ( "optimized", - run_ambiguous_query_pileup(&input_bam, &optimized_output, true), - ), - ( - "generic", - run_ambiguous_query_pileup(&input_bam, &generic_output, false), + run_query_pileup(&input_bam, &optimized_output, true, None), ), + ("generic", run_query_pileup(&input_bam, &generic_output, false, None)), ]; let expected_row = (1, METHYL_CYTOSINE, 1, 1); @@ -277,12 +311,9 @@ fn test_pileup_skips_ambiguous_query_after_unaligned_modification() { let observed = vec![ ( "optimized", - run_ambiguous_query_pileup(&input_bam, &optimized_output, true), - ), - ( - "generic", - run_ambiguous_query_pileup(&input_bam, &generic_output, false), + run_query_pileup(&input_bam, &optimized_output, true, None), ), + ("generic", run_query_pileup(&input_bam, &generic_output, false, None)), ]; let expected_row = (1, METHYL_CYTOSINE, 1, 1); @@ -295,6 +326,41 @@ fn test_pileup_skips_ambiguous_query_after_unaligned_modification() { ); } +#[test] +fn test_pileup_rejects_reference_equal_query_before_tallying() { + let temp_dir = tempfile::tempdir().unwrap(); + let input_bam = temp_dir.path().join("reference-equal-query.bam"); + make_reference_equal_query_pileup_bam(&input_bam); + let expected_row = (0, METHYL_CYTOSINE, 1, 1); + + for (label, optimized) in [("optimized", true), ("generic", false)] { + let output_bed = temp_dir.path().join(format!("{label}.bed")); + let log_path = temp_dir.path().join(format!("{label}.log")); + let rows = run_query_pileup( + &input_bam, + &output_bed, + optimized, + Some(&log_path), + ) + .unwrap(); + let log = std::fs::read_to_string(log_path).unwrap(); + + assert_eq!(rows, vec![expected_row], "{label}"); + assert!( + log.contains("~1 records rejected because BAM SEQ contains '='"), + "{label}: {log}" + ); + assert!( + log.contains( + "'=' means reference-equality and must be resolved against \ + the reference to an explicit base before pileup" + ), + "{label}: {log}" + ); + assert!(log.contains("~1 failed processing"), "{label}: {log}"); + } +} + #[test] fn test_pileup_old_tags() { let updated_file =