Skip to main content

gwseq_io/hic/
header.rs

1//! HiC header and footer.
2//!
3//! v8 and v9 differ in the width of nearly every
4//! count and value here, and the version read out of the magic decides all of
5//! them.
6
7use indexmap::IndexMap;
8
9use crate::error::{Error, Result};
10use crate::genomic::ChrMap;
11use crate::source::ByteSource;
12
13use super::Unit;
14
15/// How much to reserve for a count the file declares, before anything has been
16/// read with it.
17///
18/// A reservation is not a read, so none of the checks the reads carry apply to
19/// it, and a corrupt count asks the allocator for its full size before the first
20/// of them runs. Capped rather than rejected: the lists this guards are small,
21/// and a genuine one grows past the cap in a single reallocation.
22const MAX_RESERVE: usize = 4096;
23
24fn clamp_reserve(count: i64) -> usize {
25    count.clamp(0, MAX_RESERVE as i64) as usize
26}
27
28#[derive(Debug, Clone)]
29pub struct HiCHeader {
30    pub version: i64,
31    pub footer_position: u64,
32    pub genome_id: String,
33    /// Where the normalization vector index sits, and how long it is. Only v9
34    /// carries them; -1 stands for a file that does not.
35    pub nvi_position: i64,
36    pub nvi_length: i64,
37    pub attributes: IndexMap<String, String>,
38    pub chr_map: ChrMap,
39    pub bp_resolutions: Vec<i64>,
40    pub frag_resolutions: Vec<i64>,
41    /// Restriction-site positions of each chromosome, keyed by its id and in
42    /// file order. Empty unless the file carries fragment resolutions.
43    pub sites: IndexMap<String, Vec<i64>>,
44}
45
46impl HiCHeader {
47    /// The resolutions the file was built at, for one unit.
48    pub fn resolutions(&self, unit: Unit) -> &[i64] {
49        match unit {
50            Unit::Bp => &self.bp_resolutions,
51            Unit::Frag => &self.frag_resolutions,
52        }
53    }
54}
55
56/// One entry of the master index: where a chromosome pair's matrix lives.
57#[derive(Debug, Clone, Copy)]
58pub struct HiCIndexItem {
59    pub position: u64,
60    pub size: i64,
61}
62
63#[derive(Debug, Clone)]
64pub struct ExpectedValueVector {
65    pub normalization: String,
66    pub unit: String,
67    pub bin_size: i64,
68    pub values: Vec<f32>,
69    pub chr_scale_factors: IndexMap<i64, f32>,
70}
71
72#[derive(Debug, Clone)]
73pub struct NormalizationVector {
74    pub normalization: String,
75    pub chr_index: i64,
76    pub unit: String,
77    pub bin_size: i64,
78    pub position: u64,
79    pub byte_count: i64,
80}
81
82#[derive(Debug, Clone, Default)]
83pub struct HiCFooter {
84    /// The `nBytesV5` field the footer opens with: how much of the file the
85    /// matrix section takes. Read but not acted on, and handed out for
86    /// callers that want it.
87    pub byte_count_v5: i64,
88    pub master_index: IndexMap<String, HiCIndexItem>,
89    pub expected_value_vectors: IndexMap<String, ExpectedValueVector>,
90    pub normalization_vectors: IndexMap<String, NormalizationVector>,
91    /// Sorted, so the reported list is stable.
92    pub normalizations: Vec<String>,
93    pub units: Vec<String>,
94}
95
96/// The key a vector is stored under. Built the same way on both sides, so a
97/// lookup and an insertion cannot spell it differently.
98pub fn vector_key(normalization: &str, bin_size: i64, unit: &str, chr: Option<i64>) -> String {
99    let base = format!("normalization={normalization}|bin_size={bin_size}|unit={unit}");
100    match chr {
101        Some(index) => format!("chr_index={index}|{base}"),
102        None => base,
103    }
104}
105
106/// A sequential reader over a source, for the header and footer — both are
107/// runs of variable-length fields with no offsets to seek by.
108struct Cursor<'a> {
109    source: &'a dyn ByteSource,
110    offset: u64,
111    buffer: bytes::Bytes,
112    consumed: usize,
113}
114
115impl<'a> Cursor<'a> {
116    fn new(source: &'a dyn ByteSource, offset: u64) -> Self {
117        Self {
118            source,
119            offset,
120            buffer: bytes::Bytes::new(),
121            consumed: 0,
122        }
123    }
124
125    /// Make at least `wanted` unread bytes available.
126    ///
127    /// Re-anchoring and reading are both done to `self`, and an early return
128    /// between them would leave the cursor describing a buffer it no longer
129    /// holds — which is exactly the bug this had: an error returned after
130    /// `offset` had advanced but before `buffer` was replaced sent the cursor
131    /// back to the start of the file, and the header parsed itself again from
132    /// its own magic. So the buffer is trimmed *first*, and every path after
133    /// that leaves the cursor consistent whether the read succeeds or not.
134    fn fill(&mut self, wanted: usize) -> Result<()> {
135        if self.buffer.len() - self.consumed >= wanted {
136            return Ok(());
137        }
138        const CHUNK: usize = 1 << 16;
139        self.buffer = self.buffer.slice(self.consumed..);
140        self.offset += self.consumed as u64;
141        self.consumed = 0;
142
143        while self.buffer.len() < wanted {
144            let at = self.offset + self.buffer.len() as u64;
145            let more = self
146                .source
147                .read_at(at, CHUNK.max(wanted - self.buffer.len()))?;
148            if more.is_empty() {
149                return Err(Error::corrupt(
150                    self.source.path(),
151                    at,
152                    "hic header ended early",
153                ));
154            }
155            let mut joined = bytes::BytesMut::with_capacity(self.buffer.len() + more.len());
156            joined.extend_from_slice(&self.buffer);
157            joined.extend_from_slice(&more);
158            self.buffer = joined.freeze();
159        }
160        Ok(())
161    }
162
163    fn take(&mut self, n: usize) -> Result<bytes::Bytes> {
164        self.fill(n)?;
165        let out = self.buffer.slice(self.consumed..self.consumed + n);
166        self.consumed += n;
167        Ok(out)
168    }
169
170    fn i32(&mut self) -> Result<i32> {
171        let b = self.take(4)?;
172        Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
173    }
174    fn i64(&mut self) -> Result<i64> {
175        let b = self.take(8)?;
176        Ok(i64::from_le_bytes([
177            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
178        ]))
179    }
180    fn f32(&mut self) -> Result<f32> {
181        let b = self.take(4)?;
182        Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
183    }
184    fn f64(&mut self) -> Result<f64> {
185        let b = self.take(8)?;
186        Ok(f64::from_le_bytes([
187            b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
188        ]))
189    }
190
191    /// A NUL-terminated string, which is how the format writes every name.
192    fn cstr(&mut self) -> Result<String> {
193        let mut wanted = 64usize;
194        loop {
195            // A short read is not a failure here — the terminator may already be
196            // in what is buffered — and `fill` leaves the cursor consistent
197            // either way, so its error is deliberately dropped.
198            let _ = self.fill(wanted);
199            let rest = &self.buffer[self.consumed..];
200            if let Some(at) = memchr::memchr(0, rest) {
201                let out = String::from_utf8_lossy(&rest[..at]).into_owned();
202                self.consumed += at + 1;
203                return Ok(out);
204            }
205            if rest.len() < wanted {
206                return Err(Error::corrupt(
207                    self.source.path(),
208                    self.offset,
209                    "hic header string is not NUL-terminated",
210                ));
211            }
212            wanted *= 2;
213        }
214    }
215}
216
217pub fn read_header(source: &dyn ByteSource) -> Result<HiCHeader> {
218    let path = source.path();
219    let mut c = Cursor::new(source, 0);
220
221    let magic = c.take(4)?;
222    if &magic[..3] != b"HIC" {
223        return Err(Error::format(
224            path,
225            format!(
226                "not a hic file (magic: '{}')",
227                String::from_utf8_lossy(&magic[..3])
228            ),
229        ));
230    }
231    let version = c.i32()? as i64;
232    // Bounded above as well as below: a layout past 9 does not exist yet, and
233    // reading one as a 9 fails somewhere deep in the footer instead of here.
234    if !(6..=9).contains(&version) {
235        return Err(Error::format(
236            path,
237            format!("hic version {version} unsupported (6 to 9)"),
238        ));
239    }
240    let footer_position = c.i64()? as u64;
241    let genome_id = c.cstr()?;
242    // The normalization vector index. Only v9 carries one, and -1 is what a
243    // file without it reports rather than a position of zero.
244    let (nvi_position, nvi_length) = if version > 8 {
245        (c.i64()?, c.i64()?)
246    } else {
247        (-1, -1)
248    };
249
250    let attribute_count = c.i32()?;
251    let mut attributes = IndexMap::with_capacity(clamp_reserve(attribute_count as i64));
252    for _ in 0..attribute_count.max(0) {
253        let key = c.cstr()?;
254        attributes.insert(key, c.cstr()?);
255    }
256
257    let chr_count = c.i32()?;
258    let mut chrs = Vec::with_capacity(clamp_reserve(chr_count as i64));
259    for index in 0..chr_count.max(0) {
260        let id = c.cstr()?;
261        let size = if version > 8 {
262            c.i64()?
263        } else {
264            c.i32()? as i64
265        };
266        chrs.push((id, size, index as usize));
267    }
268
269    let bp_count = c.i32()?;
270    let mut bp_resolutions = Vec::with_capacity(clamp_reserve(bp_count as i64));
271    for _ in 0..bp_count.max(0) {
272        bp_resolutions.push(c.i32()? as i64);
273    }
274    let frag_count = c.i32()?;
275    let mut frag_resolutions = Vec::with_capacity(clamp_reserve(frag_count as i64));
276    for _ in 0..frag_count.max(0) {
277        frag_resolutions.push(c.i32()? as i64);
278    }
279    // One list of site positions per chromosome, in the order the chromosomes
280    // were listed above, and the whole section **absent** from a file carrying
281    // no fragment resolutions.
282    //
283    // Absent means absent: reading a count regardless takes the body's first
284    // four bytes for one, which passes unnoticed only while the body opens on
285    // the matrix of chromosome 0 and those bytes are its index, zero. Laid out
286    // any other way, the header fails to read or comes back with a site that is
287    // not one.
288    //
289    // Keyed off the chromosome list rather than the map, since two chromosomes
290    // sharing a name would collapse into one entry of a map keyed by it and
291    // leave the remaining lists read against the wrong chromosomes.
292    let mut sites = IndexMap::new();
293    if !frag_resolutions.is_empty() {
294        for (id, _, _) in &chrs {
295            let count = c.i32()?;
296            let mut chr_sites = Vec::with_capacity(clamp_reserve(count as i64));
297            for _ in 0..count.max(0) {
298                chr_sites.push(c.i32()? as i64);
299            }
300            sites.insert(id.clone(), chr_sites);
301        }
302    }
303
304    Ok(HiCHeader {
305        version,
306        footer_position,
307        genome_id,
308        nvi_position,
309        nvi_length,
310        attributes,
311        chr_map: ChrMap::from_indexed_entries(chrs),
312        bp_resolutions,
313        frag_resolutions,
314        sites,
315    })
316}
317
318pub fn read_footer(source: &dyn ByteSource, header: &HiCHeader) -> Result<HiCFooter> {
319    let version = header.version;
320    let mut c = Cursor::new(source, header.footer_position);
321
322    // Read before the footer exists rather than assigned into a default one:
323    // it is the first field on the wire, and a `Default` that is immediately
324    // overwritten reads as if it had a meaningful zero.
325    let byte_count_v5 = if version > 8 {
326        c.i64()?
327    } else {
328        c.i32()? as i64
329    };
330    let mut footer = HiCFooter {
331        byte_count_v5,
332        ..Default::default()
333    };
334
335    let master_count = c.i32()?;
336    for _ in 0..master_count.max(0) {
337        let key = c.cstr()?;
338        let position = c.i64()? as u64;
339        let size = c.i32()? as i64;
340        footer
341            .master_index
342            .insert(key, HiCIndexItem { position, size });
343    }
344
345    let mut normalizations: Vec<String> = Vec::new();
346    let mut units: Vec<String> = Vec::new();
347    let note = |set: &mut Vec<String>, value: &str| {
348        if !set.iter().any(|v| v == value) {
349            set.push(value.to_string());
350        }
351    };
352
353    // Two runs: the unnormalized expected values, then the normalized ones.
354    for is_normalized in [false, true] {
355        let count = c.i32()?;
356        for _ in 0..count.max(0) {
357            let normalization = if is_normalized {
358                c.cstr()?.to_ascii_lowercase()
359            } else {
360                "none".to_string()
361            };
362            note(&mut normalizations, &normalization);
363            let unit = c.cstr()?.to_ascii_lowercase();
364            note(&mut units, &unit);
365            let bin_size = c.i32()? as i64;
366
367            let values = if version > 8 {
368                let n = c.i64()?;
369                let mut values = Vec::with_capacity(clamp_reserve(n));
370                for _ in 0..n.max(0) {
371                    values.push(c.f32()?);
372                }
373                values
374            } else {
375                let n = c.i32()? as i64;
376                let mut values = Vec::with_capacity(clamp_reserve(n));
377                for _ in 0..n.max(0) {
378                    values.push(c.f64()? as f32);
379                }
380                values
381            };
382
383            let factor_count = c.i32()?;
384            let mut chr_scale_factors = IndexMap::with_capacity(clamp_reserve(factor_count as i64));
385            for _ in 0..factor_count.max(0) {
386                let chr_index = c.i32()? as i64;
387                let factor = if version > 8 {
388                    c.f32()?
389                } else {
390                    c.f64()? as f32
391                };
392                chr_scale_factors.insert(chr_index, factor);
393            }
394
395            let key = vector_key(&normalization, bin_size, &unit, None);
396            footer.expected_value_vectors.insert(
397                key,
398                ExpectedValueVector {
399                    normalization,
400                    unit,
401                    bin_size,
402                    values,
403                    chr_scale_factors,
404                },
405            );
406        }
407    }
408
409    let vector_count = c.i32()?;
410    for _ in 0..vector_count.max(0) {
411        let normalization = c.cstr()?.to_ascii_lowercase();
412        note(&mut normalizations, &normalization);
413        let chr_index = c.i32()? as i64;
414        let unit = c.cstr()?.to_ascii_lowercase();
415        note(&mut units, &unit);
416        let bin_size = c.i32()? as i64;
417        let position = c.i64()? as u64;
418        let byte_count = if version > 8 {
419            c.i64()?
420        } else {
421            c.i32()? as i64
422        };
423        let key = vector_key(&normalization, bin_size, &unit, Some(chr_index));
424        footer.normalization_vectors.insert(
425            key,
426            NormalizationVector {
427                normalization,
428                chr_index,
429                unit,
430                bin_size,
431                position,
432                byte_count,
433            },
434        );
435    }
436
437    normalizations.sort();
438    units.sort();
439    footer.normalizations = normalizations;
440    footer.units = units;
441    Ok(footer)
442}
443
444/// Expected values for one chromosome at one resolution, scaled.
445///
446/// An expected value of zero cannot divide, and the contact it would have valued
447/// comes back NaN — see `process_record` in `hic/block.rs`, which is private
448/// and so not linked from here.
449pub fn compute_expected_values(
450    footer: &HiCFooter,
451    chr_index: i64,
452    unit: &str,
453    bin_size: i64,
454    normalization: &str,
455) -> Result<Vec<f32>> {
456    let key = vector_key(normalization, bin_size, unit, None);
457    let vector = footer
458        .expected_value_vectors
459        .get(&key)
460        .ok_or_else(|| Error::invalid(format!("expected value vector {key} not found")))?;
461    let factor = vector.chr_scale_factors.get(&chr_index).ok_or_else(|| {
462        Error::invalid(format!(
463            "expected value vector {} not found",
464            vector_key(normalization, bin_size, unit, Some(chr_index))
465        ))
466    })?;
467    let scale = 1.0f32 / factor;
468    Ok(vector.values.iter().map(|v| v * scale).collect())
469}
470
471pub fn read_normalization_vector(
472    source: &dyn ByteSource,
473    footer: &HiCFooter,
474    version: i64,
475    chr_index: i64,
476    unit: &str,
477    bin_size: i64,
478    normalization: &str,
479) -> Result<Vec<f32>> {
480    let key = vector_key(normalization, bin_size, unit, Some(chr_index));
481    let vector = footer
482        .normalization_vectors
483        .get(&key)
484        .ok_or_else(|| Error::invalid(format!("normalization vector {key} not found")))?;
485    let buffer = source.read_at(vector.position, vector.byte_count.max(0) as usize)?;
486
487    // The count is read out of the file and then drives a read of that many
488    // values, so it is checked against what the footer said the vector occupies.
489    let (header_size, value_size) = if version > 8 {
490        (8usize, 4usize)
491    } else {
492        (4, 8)
493    };
494    if buffer.len() < header_size {
495        return Err(Error::corrupt(
496            source.path(),
497            vector.position,
498            format!("normalization vector {key} is truncated"),
499        ));
500    }
501    let count = if version > 8 {
502        i64::from_le_bytes(buffer[..8].try_into().expect("checked length"))
503    } else {
504        i32::from_le_bytes(buffer[..4].try_into().expect("checked length")) as i64
505    };
506    // Widened and checked rather than multiplied in `usize`: `count` comes
507    // straight out of the file, and `count * value_size` overflows for a value
508    // a file can perfectly well name — which is a panic in a debug build and, in
509    // a release one, a wrapped number small enough to pass the very check it is
510    // part of.
511    let declared = (count.max(0) as u64)
512        .checked_mul(value_size as u64)
513        .and_then(|bytes| bytes.checked_add(header_size as u64));
514    if count < 0 || declared.is_none_or(|needed| needed > buffer.len() as u64) {
515        return Err(Error::corrupt(
516            source.path(),
517            vector.position,
518            format!(
519                "normalization vector {key} declares {count} values, which do not fit its {} bytes",
520                buffer.len()
521            ),
522        ));
523    }
524    let body = &buffer[header_size..];
525    Ok(if version > 8 {
526        body.chunks_exact(4)
527            .take(count as usize)
528            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
529            .collect()
530    } else {
531        body.chunks_exact(8)
532            .take(count as usize)
533            .map(|c| f64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]) as f32)
534            .collect()
535    })
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::source::testing::MemorySource;
542
543    /// A v8 header with two chromosomes and two resolutions.
544    fn header_bytes(version: i32, frag: bool) -> Vec<u8> {
545        let mut b = b"HIC\0".to_vec();
546        b.extend_from_slice(&version.to_le_bytes());
547        b.extend_from_slice(&4096i64.to_le_bytes()); // footer position
548        b.extend_from_slice(b"mm10\0");
549        if version > 8 {
550            b.extend_from_slice(&0i64.to_le_bytes());
551            b.extend_from_slice(&0i64.to_le_bytes());
552        }
553        b.extend_from_slice(&1i32.to_le_bytes()); // one attribute
554        b.extend_from_slice(b"software\0made-up\0");
555        b.extend_from_slice(&2i32.to_le_bytes()); // two chromosomes
556        for (name, size) in [("chr1", 1000i64), ("chr2", 2000)] {
557            b.extend_from_slice(name.as_bytes());
558            b.push(0);
559            if version > 8 {
560                b.extend_from_slice(&size.to_le_bytes());
561            } else {
562                b.extend_from_slice(&(size as i32).to_le_bytes());
563            }
564        }
565        b.extend_from_slice(&2i32.to_le_bytes()); // bp resolutions
566        b.extend_from_slice(&5000i32.to_le_bytes());
567        b.extend_from_slice(&10000i32.to_le_bytes());
568        if frag {
569            b.extend_from_slice(&1i32.to_le_bytes());
570            b.extend_from_slice(&1i32.to_le_bytes());
571            // And then the site lists the section only carries when it does:
572            // two positions for chr1, none for chr2.
573            b.extend_from_slice(&2i32.to_le_bytes());
574            b.extend_from_slice(&100i32.to_le_bytes());
575            b.extend_from_slice(&250i32.to_le_bytes());
576            b.extend_from_slice(&0i32.to_le_bytes());
577        } else {
578            b.extend_from_slice(&0i32.to_le_bytes());
579        }
580        b
581    }
582
583    #[test]
584    fn reads_a_v8_header() {
585        let source = MemorySource::new(header_bytes(8, false));
586        let h = read_header(&source).unwrap();
587        assert_eq!(h.version, 8);
588        assert_eq!(h.genome_id, "mm10");
589        assert_eq!(h.footer_position, 4096);
590        assert_eq!(
591            h.attributes.get("software").map(String::as_str),
592            Some("made-up")
593        );
594        assert_eq!(h.chr_map.names(), ["chr1", "chr2"]);
595        assert_eq!(h.chr_map.resolve("chr2").unwrap().size, 2000);
596        assert_eq!(h.bp_resolutions, [5000, 10000]);
597        assert!(h.frag_resolutions.is_empty());
598    }
599
600    #[test]
601    fn a_v9_header_widens_its_chromosome_sizes() {
602        let source = MemorySource::new(header_bytes(9, false));
603        let h = read_header(&source).unwrap();
604        assert_eq!(h.version, 9);
605        assert_eq!(h.chr_map.resolve("chr2").unwrap().size, 2000);
606    }
607
608    #[test]
609    fn resolutions_are_reported_per_unit() {
610        let source = MemorySource::new(header_bytes(8, true));
611        let h = read_header(&source).unwrap();
612        assert_eq!(h.resolutions(Unit::Bp), [5000, 10000]);
613        assert_eq!(h.resolutions(Unit::Frag), [1]);
614        // The site lists come with them, keyed in chromosome order.
615        assert_eq!(h.sites.keys().collect::<Vec<_>>(), ["chr1", "chr2"]);
616        assert_eq!(h.sites["chr1"], [100, 250]);
617        assert!(h.sites["chr2"].is_empty());
618    }
619
620    #[test]
621    fn a_file_without_fragment_resolutions_reads_no_site_section_at_all() {
622        // Reading a count regardless would take the body's first four bytes for
623        // one; here there is no body, so it would fail outright.
624        let source = MemorySource::new(header_bytes(8, false));
625        assert!(read_header(&source).unwrap().sites.is_empty());
626    }
627
628    #[test]
629    fn only_a_v9_header_carries_a_normalization_vector_index() {
630        assert_eq!(
631            read_header(&MemorySource::new(header_bytes(8, false)))
632                .unwrap()
633                .nvi_position,
634            -1
635        );
636        assert_eq!(
637            read_header(&MemorySource::new(header_bytes(9, false)))
638                .unwrap()
639                .nvi_position,
640            0
641        );
642    }
643
644    #[test]
645    fn a_bad_magic_and_an_unsupported_version_are_refused() {
646        let source = MemorySource::new(b"NOPE\x08\0\0\0".to_vec());
647        let err = read_header(&source).unwrap_err().to_string();
648        assert!(err.contains("not a hic file"), "{err}");
649
650        for version in [5i32, 10, 99] {
651            let source = MemorySource::new(header_bytes(version, false));
652            let err = read_header(&source).unwrap_err().to_string();
653            assert!(
654                err.contains("unsupported (6 to 9)"),
655                "version {version}: {err}"
656            );
657        }
658    }
659
660    #[test]
661    fn a_truncated_header_is_corrupt_not_a_panic() {
662        let mut bytes = header_bytes(8, false);
663        bytes.truncate(20);
664        assert!(matches!(
665            read_header(&MemorySource::new(bytes)),
666            Err(Error::Corrupt { .. })
667        ));
668    }
669
670    #[test]
671    fn the_vector_key_is_built_the_same_way_both_ways() {
672        assert_eq!(
673            vector_key("kr", 5000, "bp", None),
674            "normalization=kr|bin_size=5000|unit=bp"
675        );
676        assert_eq!(
677            vector_key("kr", 5000, "bp", Some(3)),
678            "chr_index=3|normalization=kr|bin_size=5000|unit=bp"
679        );
680    }
681
682    #[test]
683    fn expected_values_are_scaled_by_the_chromosomes_own_factor() {
684        let mut footer = HiCFooter::default();
685        let mut factors = IndexMap::new();
686        factors.insert(0i64, 2.0f32);
687        footer.expected_value_vectors.insert(
688            vector_key("none", 5000, "bp", None),
689            ExpectedValueVector {
690                normalization: "none".into(),
691                unit: "bp".into(),
692                bin_size: 5000,
693                values: vec![10.0, 20.0],
694                chr_scale_factors: factors,
695            },
696        );
697        // 1/2 is the scale, so the values halve.
698        let got = compute_expected_values(&footer, 0, "bp", 5000, "none").unwrap();
699        assert_eq!(got, [5.0, 10.0]);
700        // A chromosome the vector has no factor for names the key it looked for.
701        let err = compute_expected_values(&footer, 7, "bp", 5000, "none")
702            .unwrap_err()
703            .to_string();
704        assert!(err.contains("chr_index=7"), "{err}");
705    }
706}