salmon_model/dumps.rs
1//! Aux-output dump helpers shared by reads-mode and alignment-mode quant.
2//!
3//! These files are diagnostics: they let a user (or a parity test against C++
4//! salmon) inspect what the bias models actually learned from a run, rather than
5//! only seeing the corrected abundances that came out the far end.
6//!
7//! salmon writes several `aux_info` files in raw little-endian binary, gzipped:
8//! `fld.gz` (fragment-length sample histogram), the legacy simple-count seq-bias
9//! model (`observed_bias`/`observed_bias_3p`/`expected_bias`), and — under bias
10//! correction — the per-model observed/expected tables. The Rust port computes
11//! the `SBModel`/GC/positional models (dumped here as a documented Rust format:
12//! gzip of raw LE arrays; positional files carry a small header) but not salmon's
13//! legacy simple-count model, which is written as a documented stub for
14//! file-presence parity. `libParams/flenDist.txt` is the text PMF.
15//!
16//! The stubs exist because downstream tooling checks for these filenames; an
17//! absent file is an error to handle, a present placeholder is not.
18
19use std::io::Write;
20use std::path::Path;
21
22/// Flattened observed/expected bias-model tables captured for the dump files.
23/// Each group is empty when its correction was not enabled. Seq tables are the
24/// [`SBModel`](crate::seqbias::SBModel) transition tables; GC the `cond×gc`
25/// matrices; pos the per-length-class bin masses.
26///
27/// Everything is pre-flattened into plain `Vec<f64>` so this struct can be
28/// carried across the codebase without dragging the model types along with it.
29#[derive(Debug, Clone, Default)]
30pub struct BiasDump {
31 pub obs5_seq: Vec<f64>,
32 pub obs3_seq: Vec<f64>,
33 pub exp5_seq: Vec<f64>,
34 pub exp3_seq: Vec<f64>,
35 pub obs_gc: Vec<f64>,
36 pub exp_gc: Vec<f64>,
37 /// Positional models are per length class, hence the extra nesting.
38 pub obs5_pos: Vec<Vec<f64>>,
39 pub obs3_pos: Vec<Vec<f64>>,
40 pub exp5_pos: Vec<Vec<f64>>,
41 pub exp3_pos: Vec<Vec<f64>>,
42}
43
44/// Write the observed/expected bias-model tables to a human-readable text file
45/// (`--dumpBiasModels`). One line per row: `<name> [<lc>] v0 v1 ...`. Empty
46/// groups (corrections not enabled) are skipped. Intended for debugging and
47/// C++↔Rust parity comparison, not as a stable machine format.
48pub fn dump_bias_models_to_file(path: &Path, d: &BiasDump) -> std::io::Result<()> {
49 let mut f = std::io::BufWriter::new(std::fs::File::create(path)?);
50 // Two small local writers, one per table shape, so the twelve call sites
51 // below stay one line each.
52 let flat = |f: &mut std::io::BufWriter<std::fs::File>, name: &str, v: &[f64]| {
53 if v.is_empty() {
54 return Ok(());
55 }
56 write!(f, "{name}")?;
57 for x in v {
58 write!(f, " {x:.6}")?;
59 }
60 writeln!(f)
61 };
62 let per_lc = |f: &mut std::io::BufWriter<std::fs::File>, name: &str, v: &[Vec<f64>]| {
63 // The length-class index is written as a second field so rows stay
64 // identifiable when the file is grepped.
65 for (lc, row) in v.iter().enumerate() {
66 write!(f, "{name} {lc}")?;
67 for x in row {
68 write!(f, " {x:.6}")?;
69 }
70 writeln!(f)?;
71 }
72 // The closure's error type is otherwise ambiguous to the compiler.
73 Ok::<(), std::io::Error>(())
74 };
75 flat(&mut f, "obs5_seq", &d.obs5_seq)?;
76 flat(&mut f, "obs3_seq", &d.obs3_seq)?;
77 flat(&mut f, "exp5_seq", &d.exp5_seq)?;
78 flat(&mut f, "exp3_seq", &d.exp3_seq)?;
79 flat(&mut f, "obs_gc", &d.obs_gc)?;
80 flat(&mut f, "exp_gc", &d.exp_gc)?;
81 per_lc(&mut f, "obs5_pos", &d.obs5_pos)?;
82 per_lc(&mut f, "obs3_pos", &d.obs3_pos)?;
83 per_lc(&mut f, "exp5_pos", &d.exp5_pos)?;
84 per_lc(&mut f, "exp3_pos", &d.exp3_pos)?;
85 f.flush()
86}
87
88/// gzip a raw byte buffer to `path` (level 6, matching salmon's aux dumps).
89pub fn gz_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
90 let f = std::fs::File::create(path)?;
91 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::new(6));
92 enc.write_all(bytes)?;
93 // `finish` writes the gzip trailer; dropping the encoder without it would
94 // leave a truncated file.
95 enc.finish()?;
96 Ok(())
97}
98
99/// gzip of a raw little-endian `f64` array.
100///
101/// "Little-endian" is stated explicitly rather than using the host's byte order,
102/// so a dump written on one machine reads correctly on another.
103pub fn write_f64_gz(path: &Path, vals: &[f64]) -> std::io::Result<()> {
104 let mut b = Vec::with_capacity(vals.len() * 8);
105 for v in vals {
106 b.extend_from_slice(&v.to_le_bytes());
107 }
108 gz_write(path, &b)
109}
110
111/// gzip of a raw little-endian `i32` array.
112pub fn write_i32_gz(path: &Path, vals: &[i32]) -> std::io::Result<()> {
113 let mut b = Vec::with_capacity(vals.len() * 4);
114 for v in vals {
115 b.extend_from_slice(&v.to_le_bytes());
116 }
117 gz_write(path, &b)
118}
119
120/// gzip of a per-length-class positional model: header `[u32 num_models][u32
121/// bins_per_model]` then the models' bin masses as `f64` LE, row-major.
122///
123/// The header is needed because, unlike the flat dumps, this file holds a matrix
124/// whose shape a reader cannot infer from the byte count alone.
125pub fn write_pos_gz(path: &Path, models: &[Vec<f64>]) -> std::io::Result<()> {
126 // Every model has the same bin count; take it from the first (0 if empty).
127 let bins = models.first().map(|m| m.len()).unwrap_or(0) as u32;
128 let mut b = Vec::new();
129 b.extend_from_slice(&(models.len() as u32).to_le_bytes());
130 b.extend_from_slice(&bins.to_le_bytes());
131 for m in models {
132 for v in m {
133 b.extend_from_slice(&v.to_le_bytes());
134 }
135 }
136 gz_write(path, &b)
137}
138
139/// `aux_info/fld.gz`: per-length sample histogram. salmon draws 10,000 samples
140/// from the log-PMF and writes the per-length `i32` counts; we write the
141/// deterministic expected histogram `round(10000 * pmf[len])` (same type/layout).
142///
143/// Writing the *expectation* rather than an actual random draw gives a file with
144/// the same meaning and layout but no run-to-run variation — sampling here would
145/// add nondeterminism to a purely diagnostic output.
146pub fn write_fld_dump(path: &Path, pmf: &[f64]) -> std::io::Result<()> {
147 const N_SAMPLES: f64 = 10000.0;
148 let hist: Vec<i32> = pmf
149 .iter()
150 .map(|&p| (p * N_SAMPLES).round() as i32)
151 .collect();
152 write_i32_gz(path, &hist)
153}
154
155/// `libParams/flenDist.txt`: the normalized fragment-length PMF as a single line
156/// of tab-separated scientific-notation values (salmon's format).
157pub fn write_flen_dist(path: &Path, pmf: &[f64]) -> std::io::Result<()> {
158 if let Some(parent) = path.parent() {
159 std::fs::create_dir_all(parent)?;
160 }
161 let mut s = String::with_capacity(pmf.len() * 14);
162 for (i, p) in pmf.iter().enumerate() {
163 // Separator before every value except the first, so the line has no
164 // trailing tab.
165 if i > 0 {
166 s.push('\t');
167 }
168 // `{:e}` is scientific notation, which keeps precision for the very small
169 // probabilities in the tails.
170 s.push_str(&format!("{p:e}"));
171 }
172 s.push('\n');
173 std::fs::write(path, s)
174}
175
176/// Write the `aux_info` bias dumps into `aux_dir`: documented stubs for salmon's
177/// legacy simple-count model (not implemented in the port), plus the computed
178/// seq/GC/pos observed+expected tables present in `dump`.
179///
180/// Each group is written only when it was populated, so the presence of a file
181/// tells you the corresponding correction was enabled.
182pub fn write_aux_bias_dumps(aux_dir: &Path, dump: &BiasDump) -> std::io::Result<()> {
183 // Legacy simple-count seq-bias model (the port uses SBModel instead): stubs.
184 // Single-element arrays with neutral values (0 observed, 1.0 expected), so a
185 // reader that computes obs/exp gets a no-op correction rather than garbage.
186 write_i32_gz(&aux_dir.join("observed_bias.gz"), &[0])?;
187 write_i32_gz(&aux_dir.join("observed_bias_3p.gz"), &[0])?;
188 write_f64_gz(&aux_dir.join("expected_bias.gz"), &[1.0])?;
189
190 // The 5'/3' and observed/expected members of a group are always populated
191 // together, so one emptiness check gates all four files.
192 if !dump.obs5_seq.is_empty() {
193 write_f64_gz(&aux_dir.join("obs5_seq.gz"), &dump.obs5_seq)?;
194 write_f64_gz(&aux_dir.join("obs3_seq.gz"), &dump.obs3_seq)?;
195 write_f64_gz(&aux_dir.join("exp5_seq.gz"), &dump.exp5_seq)?;
196 write_f64_gz(&aux_dir.join("exp3_seq.gz"), &dump.exp3_seq)?;
197 }
198 if !dump.obs_gc.is_empty() {
199 write_f64_gz(&aux_dir.join("obs_gc.gz"), &dump.obs_gc)?;
200 write_f64_gz(&aux_dir.join("exp_gc.gz"), &dump.exp_gc)?;
201 }
202 if !dump.obs5_pos.is_empty() {
203 write_pos_gz(&aux_dir.join("obs5_pos.gz"), &dump.obs5_pos)?;
204 write_pos_gz(&aux_dir.join("obs3_pos.gz"), &dump.obs3_pos)?;
205 write_pos_gz(&aux_dir.join("exp5_pos.gz"), &dump.exp5_pos)?;
206 write_pos_gz(&aux_dir.join("exp3_pos.gz"), &dump.exp3_pos)?;
207 }
208 Ok(())
209}