1use crate::{
9 DEFAULT_CUTOFF_READS, DEFAULT_CUTOFF_REFS, DEFAULT_GMTIG_BUCKETS, DEFAULT_K,
10 DEFAULT_LMTIG_BUCKETS, DEFAULT_MINIMIZER_LEN, DEFAULT_VERTEX_PARTITIONS, GraphInput, MAX_K,
11 MAX_MINIMIZER_LEN, default_threads, default_work_dir,
12};
13
14const GIB: usize = 1024 * 1024 * 1024;
15const PARTITION_FIXED_MEMORY: usize = 2 * GIB;
19const PARTITION_MEMORY_PER_WORKER: usize = 32 * 1024 * 1024;
20const LOCAL_FIXED_MEMORY: usize = 4 * GIB;
21const LOCAL_MEMORY_PER_WORKER: usize = 160 * 1024 * 1024;
22const POST_LOCAL_FIXED_MEMORY: usize = 4 * GIB;
23const POST_LOCAL_MEMORY_PER_WORKER: usize = 64 * 1024 * 1024;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct BuildParams {
32 pub input: GraphInput,
34 pub seqs: Vec<String>,
36 pub lists: Vec<String>,
38 pub dirs: Vec<String>,
40 pub k: u16,
42 pub minimizer_len: u16,
44 pub cutoff: Option<u32>,
46 pub color: bool,
48 pub compress_buckets: bool,
55 pub skip_unreadable: bool,
61 pub output_prefix: String,
63 pub work_dir: String,
65 pub vertex_partitions: usize,
67 pub lmtig_buckets: usize,
69 pub gmtig_buckets: usize,
71 pub threads: usize,
73 pub max_memory_gb: Option<usize>,
77}
78
79impl BuildParams {
80 pub fn new(input: GraphInput, output_prefix: String) -> Self {
82 Self {
83 input,
84 seqs: Vec::new(),
85 lists: Vec::new(),
86 dirs: Vec::new(),
87 k: DEFAULT_K,
88 minimizer_len: DEFAULT_MINIMIZER_LEN,
89 cutoff: None,
90 color: false,
91 compress_buckets: true,
92 skip_unreadable: false,
93 output_prefix,
94 work_dir: default_work_dir(),
95 vertex_partitions: DEFAULT_VERTEX_PARTITIONS,
96 lmtig_buckets: DEFAULT_LMTIG_BUCKETS,
97 gmtig_buckets: DEFAULT_GMTIG_BUCKETS,
98 threads: default_threads(),
99 max_memory_gb: None,
100 }
101 }
102
103 #[inline]
105 pub fn cutoff(&self) -> u32 {
106 self.cutoff.unwrap_or_else(|| self.input.default_cutoff())
107 }
108
109 pub fn max_memory_bytes(&self) -> Option<usize> {
111 self.max_memory_gb.and_then(|gb| gb.checked_mul(GIB))
112 }
113
114 fn memory_bounded_workers(&self, fixed: usize, per_worker: usize) -> usize {
115 let requested = self.threads.max(1);
116 let Some(budget) = self.max_memory_bytes() else {
117 return requested;
118 };
119 let bounded = requested
120 .min(budget.saturating_sub(fixed) / per_worker)
121 .max(1);
122 if bounded == requested {
123 return requested;
124 }
125 1usize << bounded.ilog2()
128 }
129
130 pub fn partition_workers(&self, input_files: usize) -> usize {
132 self.memory_bounded_workers(PARTITION_FIXED_MEMORY, PARTITION_MEMORY_PER_WORKER)
133 .min(input_files.max(1))
134 }
135
136 pub fn local_workers(&self) -> usize {
138 self.memory_bounded_workers(LOCAL_FIXED_MEMORY, LOCAL_MEMORY_PER_WORKER)
139 }
140
141 pub fn post_local_workers(&self) -> usize {
143 self.memory_bounded_workers(POST_LOCAL_FIXED_MEMORY, POST_LOCAL_MEMORY_PER_WORKER)
144 }
145
146 pub fn validate(&self) -> Result<(), ParamError> {
148 if self.seqs.is_empty() && self.lists.is_empty() && self.dirs.is_empty() {
149 return Err(ParamError::NoInput);
150 }
151
152 if self.output_prefix.is_empty() {
153 return Err(ParamError::MissingOutput);
154 }
155
156 if self.k <= 1 || self.k > MAX_K || self.k % 2 == 0 {
157 return Err(ParamError::InvalidK(self.k));
158 }
159
160 if self.minimizer_len == 0
161 || self.minimizer_len >= self.k
162 || self.minimizer_len > MAX_MINIMIZER_LEN
163 {
164 return Err(ParamError::InvalidMinimizerLen {
165 k: self.k,
166 l: self.minimizer_len,
167 });
168 }
169
170 if self.cutoff() == 0 {
171 return Err(ParamError::InvalidCutoff);
172 }
173
174 if !self.vertex_partitions.is_power_of_two()
175 || !self.lmtig_buckets.is_power_of_two()
176 || !self.gmtig_buckets.is_power_of_two()
177 {
178 return Err(ParamError::BucketCountsMustBePowersOfTwo);
179 }
180 if self.threads == 0 {
181 return Err(ParamError::InvalidThreadCount);
182 }
183 if self.max_memory_gb == Some(0)
184 || self.max_memory_gb.is_some_and(|gb| gb > usize::MAX / GIB)
185 {
186 return Err(ParamError::InvalidMemoryLimit);
187 }
188
189 Ok(())
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum ParamError {
195 NoInput,
196 MissingOutput,
197 InvalidK(u16),
198 InvalidMinimizerLen { k: u16, l: u16 },
199 InvalidCutoff,
200 BucketCountsMustBePowersOfTwo,
201 InvalidThreadCount,
202 InvalidMemoryLimit,
203}
204
205impl std::fmt::Display for ParamError {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 Self::NoInput => write!(f, "no sequence input provided"),
209 Self::MissingOutput => write!(f, "missing output prefix"),
210 Self::InvalidK(k) => write!(f, "k-mer length {k} is invalid; expected odd 3..={MAX_K}"),
211 Self::InvalidMinimizerLen { k, l } => {
212 write!(f, "minimizer length {l} is invalid for k={k}")
213 }
214 Self::InvalidCutoff => write!(f, "cutoff frequency must be at least 1"),
215 Self::BucketCountsMustBePowersOfTwo => write!(f, "bucket counts must be powers of two"),
216 Self::InvalidThreadCount => write!(f, "thread count must be at least 1"),
217 Self::InvalidMemoryLimit => write!(f, "maximum memory must be at least 1 GiB"),
218 }
219 }
220}
221
222impl std::error::Error for ParamError {}
223
224pub const _DEFAULT_CUTOFFS_FOR_DOCS: (u32, u32) = (DEFAULT_CUTOFF_REFS, DEFAULT_CUTOFF_READS);
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn validates_current_cpp_defaults() {
232 let mut p = BuildParams::new(GraphInput::References, "out".to_string());
233 p.seqs.push("data/refs1.fa".to_string());
234 assert_eq!(p.cutoff(), 1);
235 assert!(p.validate().is_ok());
236 }
237
238 #[test]
239 fn rejects_even_k_and_zero_cutoff() {
240 let mut p = BuildParams::new(GraphInput::Reads, "out".to_string());
241 p.seqs.push("data/reads.fq".to_string());
242 p.k = 32;
243 assert_eq!(p.validate(), Err(ParamError::InvalidK(32)));
244 p.k = 31;
245 p.cutoff = Some(0);
246 assert_eq!(p.validate(), Err(ParamError::InvalidCutoff));
247 }
248
249 #[test]
250 fn explicit_memory_budget_bounds_phase_concurrency() {
251 let mut p = BuildParams::new(GraphInput::References, "out".to_string());
252 p.threads = 256;
253 p.max_memory_gb = Some(16);
254
255 assert_eq!(p.partition_workers(1_000), 256);
256 assert_eq!(p.local_workers(), 64);
257 assert_eq!(p.post_local_workers(), 128);
258 assert_eq!(p.partition_workers(17), 17);
259 }
260
261 #[test]
262 fn unrestricted_concurrency_follows_the_user_request() {
263 let mut p = BuildParams::new(GraphInput::References, "out".to_string());
264 p.threads = 96;
265
266 assert_eq!(p.partition_workers(1_000), 96);
267 assert_eq!(p.local_workers(), 96);
268 assert_eq!(p.post_local_workers(), 96);
269 }
270
271 #[test]
272 fn ample_memory_budget_preserves_non_power_of_two_request() {
273 let mut p = BuildParams::new(GraphInput::References, "out".to_string());
274 p.threads = 96;
275 p.max_memory_gb = Some(24);
276
277 assert_eq!(p.partition_workers(1_000), 96);
278 assert_eq!(p.local_workers(), 96);
279 assert_eq!(p.post_local_workers(), 96);
280 }
281
282 #[test]
283 fn binding_memory_budget_only_reduces_constrained_phases() {
284 let mut p = BuildParams::new(GraphInput::References, "out".to_string());
285 p.threads = 96;
286 p.max_memory_gb = Some(12);
287
288 assert_eq!(p.partition_workers(1_000), 96);
289 assert_eq!(p.local_workers(), 32);
290 assert_eq!(p.post_local_workers(), 96);
291 }
292
293 #[test]
294 fn rejects_zero_memory_budget() {
295 let mut p = BuildParams::new(GraphInput::References, "out".to_string());
296 p.seqs.push("data/refs1.fa".to_string());
297 p.max_memory_gb = Some(0);
298 assert_eq!(p.validate(), Err(ParamError::InvalidMemoryLimit));
299 }
300}