Skip to main content

jam_rs/
writer.rs

1use crate::bias::HashBiasTable;
2use crate::format::{
3    BUCKET_COUNT, BUCKET_TABLE_SIZE, BucketMeta, DATA_START, ENTRY_SIZE, Entry,
4    FLAG_HAS_BIAS_TABLE, HEADER_SIZE, Header, MAGIC, VERSION,
5};
6use crate::io::{extract_unique_hashes, read_entries, write_entries};
7use crate::sketch::{SketchConfig, SketchResult};
8use bytemuck;
9use memmap2::MmapMut;
10use rayon::prelude::*;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::Duration;
15use xorf::{BinaryFuse8, DmaSerializable};
16
17pub const FILTER_DESCRIPTOR_SIZE: usize = 20;
18
19pub fn serialize_filter(filter: &BinaryFuse8) -> Vec<u8> {
20    let fingerprints = filter.dma_fingerprints();
21    let descriptor_size = FILTER_DESCRIPTOR_SIZE as u32;
22    let fingerprints_size = fingerprints.len() as u32;
23
24    let total_size = 4 + 4 + FILTER_DESCRIPTOR_SIZE + fingerprints.len();
25    let mut out = vec![0u8; total_size];
26
27    out[0..4].copy_from_slice(&descriptor_size.to_le_bytes());
28    out[4..8].copy_from_slice(&fingerprints_size.to_le_bytes());
29    filter.dma_copy_descriptor_to(&mut out[8..8 + FILTER_DESCRIPTOR_SIZE]);
30    out[8 + FILTER_DESCRIPTOR_SIZE..].copy_from_slice(fingerprints);
31
32    out
33}
34
35fn serialize_sample_names(names: &[String]) -> Vec<u8> {
36    let mut buf = Vec::new();
37    for (idx, name) in names.iter().enumerate() {
38        let bytes = name.as_bytes();
39        let len = if bytes.len() > u16::MAX as usize {
40            eprintln!(
41                "Warning: Sample name at index {} is {} bytes, truncating to {} bytes",
42                idx,
43                bytes.len(),
44                u16::MAX
45            );
46            u16::MAX
47        } else {
48            bytes.len() as u16
49        };
50        buf.extend_from_slice(&len.to_le_bytes());
51        buf.extend_from_slice(&bytes[..len as usize]);
52    }
53    buf
54}
55
56fn serialize_sample_sizes(sizes: &[u64]) -> Vec<u8> {
57    bytemuck::cast_slice(sizes).to_vec()
58}
59
60#[derive(Clone)]
61pub struct CompactConfig {
62    pub num_threads: usize,
63}
64
65impl Default for CompactConfig {
66    fn default() -> Self {
67        Self { num_threads: 1 }
68    }
69}
70
71struct ProcessedBucket {
72    bucket_id: usize,
73    entry_count: u64,
74    unique_hash_count: u64,
75    filter_bytes: Vec<u8>,
76    sample_hash_counts: std::collections::HashMap<u32, u64>,
77}
78
79#[derive(Debug, Clone)]
80pub struct IndexStats {
81    pub total_entries: u64,
82    pub unique_hashes: u64,
83    pub sample_count: u32,
84    pub file_size: u64,
85    pub kmer_size: u8,
86    pub frac_max: u64,
87    pub bucket_entry_counts: [u64; BUCKET_COUNT],
88}
89
90#[derive(Clone)]
91pub struct BuildConfig {
92    pub kmer_size: u8,
93    pub fscale: u64,
94    pub num_threads: usize,
95    pub memory: usize,
96    pub temp_dir_base: Option<PathBuf>,
97    pub min_entropy: f64,
98    pub singleton: bool,
99    pub bias_table: Option<Arc<HashBiasTable>>,
100    pub show_progress: bool,
101}
102
103impl Default for BuildConfig {
104    fn default() -> Self {
105        Self {
106            kmer_size: 21,
107            fscale: 1000,
108            num_threads: 1,
109            memory: 4,
110            temp_dir_base: None,
111            min_entropy: 0.0,
112            singleton: false,
113            bias_table: None,
114            show_progress: false,
115        }
116    }
117}
118
119#[derive(Debug, thiserror::Error)]
120pub enum BuildError {
121    #[error("Sketch error: {0}")]
122    Sketch(#[from] crate::sketch::SketchError),
123
124    #[error("Compact error: {0}")]
125    Compact(#[from] CompactError),
126}
127
128pub fn build(
129    input_files: &[PathBuf],
130    output_path: &Path,
131    config: &BuildConfig,
132) -> Result<IndexStats, BuildError> {
133    let sketch_config = SketchConfig {
134        kmer_size: config.kmer_size,
135        fscale: config.fscale,
136        num_threads: config.num_threads,
137        memory: config.memory,
138        temp_dir_base: config.temp_dir_base.clone(),
139        min_entropy: config.min_entropy,
140        singleton: config.singleton,
141        bias_table: config.bias_table.clone(),
142        send_timeout: Duration::from_millis(1),
143        show_progress: config.show_progress,
144    };
145
146    let sketch_result = crate::sketch::run(input_files, &sketch_config)?;
147
148    let compact_config = CompactConfig {
149        num_threads: config.num_threads,
150    };
151
152    let stats = run(
153        output_path,
154        &sketch_result,
155        &compact_config,
156        config.kmer_size,
157        config.bias_table.as_deref(),
158    )?;
159
160    Ok(stats)
161}
162
163#[derive(Debug, thiserror::Error)]
164pub enum CompactError {
165    #[error("I/O error: {0}")]
166    Io(#[from] io::Error),
167
168    #[error("Filter construction failed for bucket {bucket}: {message}")]
169    FilterConstruction { bucket: usize, message: String },
170}
171
172pub fn run(
173    output_path: &Path,
174    sketch_result: &SketchResult,
175    _config: &CompactConfig,
176    kmer_size: u8,
177    bias_table: Option<&HashBiasTable>,
178) -> Result<IndexStats, CompactError> {
179    let temp_path = sketch_result.temp_dir.path();
180
181    let processed: Result<Vec<ProcessedBucket>, CompactError> = (0..BUCKET_COUNT)
182        .into_par_iter()
183        .map(|bucket_id| {
184            let bucket_path = temp_path.join(format!("bucket_{bucket_id:03}.bin"));
185
186            let mut entries = if bucket_path.exists() {
187                read_entries(&bucket_path)?
188            } else {
189                Vec::new()
190            };
191
192            entries.sort_unstable();
193            entries.dedup();
194
195            let mut sample_hash_counts: std::collections::HashMap<u32, u64> =
196                std::collections::HashMap::new();
197            for entry in &entries {
198                *sample_hash_counts.entry(entry.sample_id).or_insert(0) += 1;
199            }
200
201            let unique_hashes = extract_unique_hashes(&entries);
202            let unique_hash_count = unique_hashes.len() as u64;
203
204            let filter_bytes = if unique_hashes.is_empty() {
205                Vec::new()
206            } else {
207                let filter = BinaryFuse8::try_from(&unique_hashes[..]).map_err(|e| {
208                    CompactError::FilterConstruction {
209                        bucket: bucket_id,
210                        message: format!("{e:?}"),
211                    }
212                })?;
213                serialize_filter(&filter)
214            };
215
216            if !entries.is_empty() {
217                write_entries(&bucket_path, &entries)?;
218            }
219
220            Ok(ProcessedBucket {
221                bucket_id,
222                entry_count: entries.len() as u64,
223                unique_hash_count,
224                filter_bytes,
225                sample_hash_counts,
226            })
227        })
228        .collect();
229
230    let processed = processed?;
231
232    let mut aggregated_sample_sizes: Vec<u64> = vec![0u64; sketch_result.sample_count as usize];
233    for bucket in &processed {
234        for (&sample_id, &count) in &bucket.sample_hash_counts {
235            if (sample_id as usize) < aggregated_sample_sizes.len() {
236                aggregated_sample_sizes[sample_id as usize] += count;
237            }
238        }
239    }
240
241    use crate::format::align_to_page;
242
243    let bias_size: u64 = bias_table.map(|b| b.to_bytes().len() as u64).unwrap_or(0);
244    let sample_names_bytes = serialize_sample_names(&sketch_result.sample_names);
245    let sample_sizes_bytes = serialize_sample_sizes(&aggregated_sample_sizes);
246
247    let bucket_regions_start = align_to_page(DATA_START);
248    let mut current_offset = bucket_regions_start;
249    let mut bucket_offsets = Vec::with_capacity(BUCKET_COUNT);
250
251    for bucket in &processed {
252        bucket_offsets.push(current_offset);
253        let bucket_size = bucket.filter_bytes.len() + (bucket.entry_count as usize) * ENTRY_SIZE;
254        if bucket_size > 0 {
255            current_offset = align_to_page(current_offset + bucket_size);
256        }
257    }
258
259    let metadata_offset = current_offset;
260    let total_size =
261        metadata_offset + bias_size as usize + sample_names_bytes.len() + sample_sizes_bytes.len();
262
263    let file = std::fs::OpenOptions::new()
264        .read(true)
265        .write(true)
266        .create(true)
267        .truncate(true)
268        .open(output_path)?;
269    file.set_len(total_size as u64)?;
270
271    let mut mmap = unsafe { MmapMut::map_mut(&file)? };
272
273    let mut bucket_metas = vec![BucketMeta::default(); BUCKET_COUNT];
274    let mut total_unique_hashes = 0u64;
275    let mut entries_size = 0u64;
276    let mut filters_size = 0u64;
277
278    for bucket in &processed {
279        let bucket_offset = bucket_offsets[bucket.bucket_id];
280        let filter_size = bucket.filter_bytes.len();
281        let entries_bytes_len = (bucket.entry_count as usize) * ENTRY_SIZE;
282
283        if !bucket.filter_bytes.is_empty() {
284            mmap[bucket_offset..bucket_offset + filter_size].copy_from_slice(&bucket.filter_bytes);
285        }
286
287        let entry_offset = bucket_offset + filter_size;
288        if bucket.entry_count > 0 {
289            let bucket_path = temp_path.join(format!("bucket_{:03}.bin", bucket.bucket_id));
290            let entries = read_entries(&bucket_path)?;
291            let entry_bytes = bytemuck::cast_slice::<Entry, u8>(&entries);
292            mmap[entry_offset..entry_offset + entry_bytes.len()].copy_from_slice(entry_bytes);
293        }
294
295        bucket_metas[bucket.bucket_id] = BucketMeta {
296            filter_offset: bucket_offset as u64,
297            filter_size: filter_size as u64,
298            entry_offset: entry_offset as u64,
299            entry_count: bucket.entry_count,
300        };
301
302        total_unique_hashes += bucket.unique_hash_count;
303        entries_size += entries_bytes_len as u64;
304        filters_size += filter_size as u64;
305    }
306
307    let mut meta_offset = metadata_offset;
308
309    let (bias_table_offset, bias_table_size, flags) = if let Some(bias) = bias_table {
310        let bias_bytes = bias.to_bytes();
311        mmap[meta_offset..meta_offset + bias_bytes.len()].copy_from_slice(&bias_bytes);
312        let offset = meta_offset;
313        meta_offset += bias_bytes.len();
314        (offset as u64, bias_bytes.len() as u64, FLAG_HAS_BIAS_TABLE)
315    } else {
316        (0, 0, 0)
317    };
318
319    let sample_names_offset = meta_offset;
320    mmap[sample_names_offset..sample_names_offset + sample_names_bytes.len()]
321        .copy_from_slice(&sample_names_bytes);
322    meta_offset += sample_names_bytes.len();
323
324    let sample_sizes_offset = meta_offset;
325    mmap[sample_sizes_offset..sample_sizes_offset + sample_sizes_bytes.len()]
326        .copy_from_slice(&sample_sizes_bytes);
327
328    let table_bytes = bytemuck::cast_slice::<BucketMeta, u8>(&bucket_metas);
329    mmap[HEADER_SIZE..HEADER_SIZE + BUCKET_TABLE_SIZE].copy_from_slice(table_bytes);
330
331    let total_entries: u64 = processed.iter().map(|b| b.entry_count).sum();
332
333    let header = Header {
334        magic: MAGIC,
335        version: VERSION,
336        flags,
337        entry_count: total_entries,
338        unique_hash_count: total_unique_hashes,
339        sample_count: sketch_result.sample_count,
340        bucket_count: BUCKET_COUNT as u16,
341        bucket_bits: 8,
342        entry_size: ENTRY_SIZE as u8,
343        hash_threshold: sketch_result.frac_max,
344        kmer_size,
345        _param_reserved: [0; 7],
346        bucket_table_offset: HEADER_SIZE as u64,
347        entries_offset: bucket_regions_start as u64,
348        filters_offset: bucket_regions_start as u64,
349        bias_table_offset,
350        entries_size,
351        filters_size,
352        bias_table_size,
353        sample_names_offset: sample_names_offset as u64,
354        sample_names_size: sample_names_bytes.len() as u64,
355        sample_sizes_offset: sample_sizes_offset as u64,
356        sample_sizes_size: sample_sizes_bytes.len() as u64,
357        _padding: [0; 16],
358    };
359
360    let header_bytes = bytemuck::bytes_of(&header);
361    mmap[..HEADER_SIZE].copy_from_slice(header_bytes);
362
363    mmap.flush()?;
364    drop(mmap);
365
366    let mut bucket_entry_counts = [0u64; BUCKET_COUNT];
367    for bucket in &processed {
368        bucket_entry_counts[bucket.bucket_id] = bucket.entry_count;
369    }
370
371    Ok(IndexStats {
372        total_entries,
373        unique_hashes: total_unique_hashes,
374        sample_count: sketch_result.sample_count,
375        file_size: total_size as u64,
376        kmer_size,
377        frac_max: sketch_result.frac_max,
378        bucket_entry_counts,
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::format::bucket_id;
386    use crate::sketch::{SketchConfig, run as sketch_run};
387    use std::fs::File;
388    use std::io::Write;
389    use tempfile::NamedTempFile;
390
391    fn make_fasta(seqs: &[(&str, &str)]) -> NamedTempFile {
392        let mut f = NamedTempFile::with_suffix(".fa").unwrap();
393        for (name, seq) in seqs {
394            writeln!(f, ">{name}").unwrap();
395            writeln!(f, "{seq}").unwrap();
396        }
397        f
398    }
399
400    #[test]
401    fn test_compact_basic() {
402        let input = make_fasta(&[("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCG")]);
403        let sketch_config = SketchConfig {
404            kmer_size: 11,
405            fscale: 1,
406            num_threads: 2,
407            memory: 1,
408            ..Default::default()
409        };
410
411        let sketch_result = sketch_run(&[input.path().to_path_buf()], &sketch_config).unwrap();
412
413        let output_dir = tempfile::tempdir().unwrap();
414        let output_path = output_dir.path().join("test.jam");
415
416        let compact_config = CompactConfig::default();
417        let stats = run(&output_path, &sketch_result, &compact_config, 11, None).unwrap();
418
419        assert!(stats.total_entries > 0);
420        assert_eq!(stats.sample_count, 1);
421        assert_eq!(stats.kmer_size, 11);
422        assert!(output_path.exists());
423
424        let metadata = std::fs::metadata(&output_path).unwrap();
425        assert_eq!(metadata.len(), stats.file_size);
426    }
427
428    #[test]
429    fn test_compact_empty_buckets() {
430        let input = make_fasta(&[("seq1", "ATCGATCGATCGATCGATCG")]);
431        let sketch_config = SketchConfig {
432            kmer_size: 11,
433            fscale: 1_000_000, // Very restrictive - few hashes pass
434            num_threads: 1,
435            memory: 1,
436            ..Default::default()
437        };
438
439        let sketch_result = sketch_run(&[input.path().to_path_buf()], &sketch_config).unwrap();
440
441        let output_dir = tempfile::tempdir().unwrap();
442        let output_path = output_dir.path().join("test.jam");
443
444        let compact_config = CompactConfig::default();
445        let result = run(&output_path, &sketch_result, &compact_config, 11, None);
446
447        assert!(result.is_ok());
448    }
449
450    #[test]
451    fn test_compact_header_validation() {
452        let input = make_fasta(&[("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCG")]);
453        let sketch_config = SketchConfig {
454            kmer_size: 21,
455            fscale: 10,
456            num_threads: 1,
457            memory: 1,
458            ..Default::default()
459        };
460
461        let sketch_result = sketch_run(&[input.path().to_path_buf()], &sketch_config).unwrap();
462
463        let output_dir = tempfile::tempdir().unwrap();
464        let output_path = output_dir.path().join("test.jam");
465
466        let compact_config = CompactConfig::default();
467        run(&output_path, &sketch_result, &compact_config, 21, None).unwrap();
468
469        let file = File::open(&output_path).unwrap();
470        let mmap = unsafe { memmap2::Mmap::map(&file).unwrap() };
471
472        let header: &Header = bytemuck::from_bytes(&mmap[..HEADER_SIZE]);
473        assert!(header.validate().is_ok());
474        assert_eq!(header.kmer_size, 21);
475        assert_eq!(header.sample_count, 1);
476    }
477
478    #[test]
479    fn test_compact_entry_sorting() {
480        let input = make_fasta(&[
481            ("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCGATCG"),
482            ("seq2", "GCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTA"),
483        ]);
484        let sketch_config = SketchConfig {
485            kmer_size: 11,
486            fscale: 1, // Keep all hashes
487            num_threads: 2,
488            memory: 1,
489            ..Default::default()
490        };
491
492        let sketch_result = sketch_run(&[input.path().to_path_buf()], &sketch_config).unwrap();
493
494        let output_dir = tempfile::tempdir().unwrap();
495        let output_path = output_dir.path().join("test.jam");
496
497        let compact_config = CompactConfig::default();
498        run(&output_path, &sketch_result, &compact_config, 11, None).unwrap();
499
500        let file = File::open(&output_path).unwrap();
501        let mmap = unsafe { memmap2::Mmap::map(&file).unwrap() };
502
503        let bucket_table: &[BucketMeta] =
504            bytemuck::cast_slice(&mmap[HEADER_SIZE..HEADER_SIZE + BUCKET_TABLE_SIZE]);
505
506        for (i, meta) in bucket_table.iter().enumerate() {
507            if meta.entry_count == 0 {
508                continue;
509            }
510
511            let start = meta.entry_offset as usize;
512            let end = start + (meta.entry_count as usize) * ENTRY_SIZE;
513            let entries: &[Entry] = bytemuck::cast_slice(&mmap[start..end]);
514
515            for entry in entries {
516                assert_eq!(bucket_id(entry.hash), i, "Entry in wrong bucket");
517            }
518
519            for window in entries.windows(2) {
520                assert!(
521                    window[0] <= window[1],
522                    "Entries not sorted in bucket {i}: {:?} > {:?}",
523                    window[0],
524                    window[1]
525                );
526            }
527        }
528    }
529
530    #[test]
531    fn test_build_basic() {
532        let input = make_fasta(&[("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCG")]);
533        let output_dir = tempfile::tempdir().unwrap();
534        let output_path = output_dir.path().join("test.jam");
535
536        let config = BuildConfig {
537            kmer_size: 11,
538            fscale: 1,
539            num_threads: 2,
540            memory: 1,
541            ..Default::default()
542        };
543
544        let stats = build(&[input.path().to_path_buf()], &output_path, &config).unwrap();
545
546        assert!(stats.total_entries > 0);
547        assert_eq!(stats.sample_count, 1);
548        assert_eq!(stats.kmer_size, 11);
549        assert!(output_path.exists());
550    }
551
552    #[test]
553    fn test_build_singleton_mode() {
554        let input = make_fasta(&[
555            ("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCG"),
556            ("seq2", "GCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTA"),
557        ]);
558        let output_dir = tempfile::tempdir().unwrap();
559        let output_path = output_dir.path().join("test.jam");
560
561        let config = BuildConfig {
562            kmer_size: 11,
563            fscale: 1,
564            singleton: true,
565            num_threads: 2,
566            memory: 1,
567            ..Default::default()
568        };
569
570        let stats = build(&[input.path().to_path_buf()], &output_path, &config).unwrap();
571
572        assert_eq!(
573            stats.sample_count, 2,
574            "Singleton mode should create one sample per sequence"
575        );
576    }
577
578    #[test]
579    fn test_build_multiple_files() {
580        let input1 = make_fasta(&[("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCG")]);
581        let input2 = make_fasta(&[("seq2", "GCTAGCTAGCTAGCTAGCTAGCTAGCTAGCTA")]);
582        let output_dir = tempfile::tempdir().unwrap();
583        let output_path = output_dir.path().join("test.jam");
584
585        let config = BuildConfig {
586            kmer_size: 11,
587            fscale: 1,
588            num_threads: 2,
589            memory: 1,
590            ..Default::default()
591        };
592
593        let stats = build(
594            &[input1.path().to_path_buf(), input2.path().to_path_buf()],
595            &output_path,
596            &config,
597        )
598        .unwrap();
599
600        assert_eq!(stats.sample_count, 2);
601        assert!(stats.total_entries > 0);
602    }
603
604    #[test]
605    fn test_build_with_entropy_filter() {
606        let input = make_fasta(&[
607            ("low_complexity", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
608            ("high_complexity", "ATCGATCGATCGATCGATCGATCGATCGATCG"),
609        ]);
610        let output_dir = tempfile::tempdir().unwrap();
611        let output_path = output_dir.path().join("test.jam");
612
613        let config = BuildConfig {
614            kmer_size: 11,
615            fscale: 1,
616            min_entropy: 1.5,
617            num_threads: 1,
618            memory: 1,
619            ..Default::default()
620        };
621
622        let stats = build(&[input.path().to_path_buf()], &output_path, &config).unwrap();
623
624        assert!(stats.total_entries > 0);
625    }
626
627    #[test]
628    fn test_build_with_bias_table() {
629        use crate::bias::{CMSConfig, HashBiasTable, RawHashCounts};
630
631        let pos_fasta = make_fasta(&[("pos", "ATATATATATATATATATATATATATATATAT")]);
632        let neg_fasta = make_fasta(&[("neg", "GCGCGCGCGCGCGCGCGCGCGCGCGCGCGCGC")]);
633
634        let config = CMSConfig {
635            width: 1024,
636            depth: 3,
637            k: 11,
638            fscale: 1,
639        };
640
641        let rc = std::sync::atomic::AtomicU64::new(0);
642        let hc = std::sync::atomic::AtomicU64::new(0);
643        let pos_raw = RawHashCounts::build(&[pos_fasta.path()], config.clone(), &rc, &hc).unwrap();
644        let neg_raw = RawHashCounts::build(&[neg_fasta.path()], config, &rc, &hc).unwrap();
645        let bias_table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, Some(2.0)).unwrap();
646
647        let input = make_fasta(&[("seq1", "ATATATATATATATATATATATATATATATATATAT")]);
648        let output_dir = tempfile::tempdir().unwrap();
649        let output_path = output_dir.path().join("test.jam");
650
651        let config = BuildConfig {
652            kmer_size: 11,
653            fscale: 1,
654            num_threads: 1,
655            memory: 1,
656            bias_table: Some(std::sync::Arc::new(bias_table.clone())),
657            ..Default::default()
658        };
659
660        build(&[input.path().to_path_buf()], &output_path, &config).unwrap();
661
662        let reader = crate::reader::JamReader::open(&output_path).unwrap();
663        assert!(reader.has_bias_table());
664
665        let embedded_bias = reader.bias_table().unwrap();
666        assert_eq!(*embedded_bias, bias_table);
667    }
668
669    #[test]
670    fn test_build_without_bias_table() {
671        let input = make_fasta(&[("seq1", "ATCGATCGATCGATCGATCGATCGATCGATCG")]);
672        let output_dir = tempfile::tempdir().unwrap();
673        let output_path = output_dir.path().join("test.jam");
674
675        let config = BuildConfig {
676            kmer_size: 11,
677            fscale: 1,
678            num_threads: 1,
679            memory: 1,
680            bias_table: None,
681            ..Default::default()
682        };
683
684        build(&[input.path().to_path_buf()], &output_path, &config).unwrap();
685
686        let reader = crate::reader::JamReader::open(&output_path).unwrap();
687        assert!(!reader.has_bias_table());
688        assert!(reader.bias_table().is_none());
689    }
690}