Skip to content

Commit 3998c23

Browse files
committed
feat: replace curve_power with deadzone
1 parent 2d380a0 commit 3998c23

5 files changed

Lines changed: 95 additions & 49 deletions

File tree

src/bias.rs

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,8 @@ pub struct BiasCreateConfig {
225225
pub target_negative_retention: Option<f32>,
226226
pub positive_fscale: Option<u64>,
227227
pub negative_fscale: Option<u64>,
228-
pub curve_power: f32,
228+
pub deadzone_pos: f32,
229+
pub deadzone_neg: f32,
229230
}
230231

231232
#[derive(Debug, Clone, Copy)]
@@ -251,7 +252,8 @@ pub struct HashBiasTable {
251252
pub negative_fscale: u64,
252253
pub k_pos: f32,
253254
pub k_neg: f32,
254-
pub curve_power: f32,
255+
pub deadzone_pos: f32,
256+
pub deadzone_neg: f32,
255257
}
256258

257259
#[derive(Debug, Clone, Copy)]
@@ -365,7 +367,8 @@ impl HashBiasTable {
365367
config.target_negative_retention,
366368
config.positive_fscale.unwrap_or(0),
367369
config.negative_fscale.unwrap_or(0),
368-
config.curve_power,
370+
config.deadzone_pos,
371+
config.deadzone_neg,
369372
)?;
370373

371374
if let Some(ref pb) = progress {
@@ -384,16 +387,20 @@ impl HashBiasTable {
384387
target_negative_retention: Option<f32>,
385388
positive_fscale: u64,
386389
negative_fscale: u64,
387-
curve_power: f32,
390+
deadzone_pos: f32,
391+
deadzone_neg: f32,
388392
) -> Result<Self> {
389393
validate_cms_compatibility(positive, negative)?;
390394

391395
if !alpha.is_finite() || alpha <= 0.0 {
392396
anyhow::bail!("alpha must be finite and > 0, got {}", alpha);
393397
}
394398

395-
if !curve_power.is_finite() || curve_power <= 0.0 {
396-
anyhow::bail!("curve_power must be finite and > 0, got {}", curve_power);
399+
if !deadzone_pos.is_finite() || !(0.0..1.0).contains(&deadzone_pos) {
400+
anyhow::bail!("deadzone_pos must be finite and in [0.0, 1.0), got {}", deadzone_pos);
401+
}
402+
if !deadzone_neg.is_finite() || !(0.0..1.0).contains(&deadzone_neg) {
403+
anyhow::bail!("deadzone_neg must be finite and in [0.0, 1.0), got {}", deadzone_neg);
397404
}
398405

399406
if let Some(target) = target_positive_retention {
@@ -475,7 +482,8 @@ impl HashBiasTable {
475482
negative_fscale,
476483
k_pos: 0.0,
477484
k_neg: 0.0,
478-
curve_power,
485+
deadzone_pos,
486+
deadzone_neg,
479487
};
480488

481489
// Auto-derive k_pos and k_neg when soft filter is active
@@ -680,14 +688,20 @@ impl HashBiasTable {
680688

681689
/// Compute the effective fscale using piecewise sigmoid centered at 0.
682690
/// w >= 0 uses k_pos, w < 0 uses k_neg. At w=0: s=0.5 → geometric mean (midpoint anchor).
683-
/// When curve_power > 1.0, a power transform flattens the sigmoid's middle region,
684-
/// so only strongly biased k-mers get preferential/penalized treatment.
691+
/// deadzone_pos/deadzone_neg define the fraction of each side's weight range that stays
692+
/// flat (no effect) before the sigmoid transition kicks in.
685693
#[inline]
686694
fn effective_fscale(&self, w: i8) -> f64 {
687695
let k = if w >= 0 { self.k_pos } else { self.k_neg };
696+
let deadzone = if w >= 0 { self.deadzone_pos } else { self.deadzone_neg } as f64;
688697
let w_norm = w as f64 / 127.0;
689-
let w_curved = w_norm.signum() * w_norm.abs().powf(self.curve_power as f64);
690-
let x = k as f64 * w_curved * 127.0;
698+
let abs_w = w_norm.abs();
699+
let remapped = if abs_w <= deadzone {
700+
0.0
701+
} else {
702+
(abs_w - deadzone) / (1.0 - deadzone)
703+
};
704+
let x = k as f64 * w_norm.signum() * remapped * 127.0;
691705
let s = 1.0 / (1.0 + (-x).exp());
692706
let log_pos = (self.positive_fscale as f64).ln();
693707
let log_neg = (self.negative_fscale as f64).ln();
@@ -748,7 +762,8 @@ impl HashBiasTable {
748762
file.write_all(&self.negative_fscale.to_le_bytes())?;
749763
file.write_all(&self.k_pos.to_le_bytes())?;
750764
file.write_all(&self.k_neg.to_le_bytes())?;
751-
file.write_all(&self.curve_power.to_le_bytes())?;
765+
file.write_all(&self.deadzone_pos.to_le_bytes())?;
766+
file.write_all(&self.deadzone_neg.to_le_bytes())?;
752767

753768
for &seed in &self.seeds {
754769
file.write_all(&seed.to_le_bytes())?;
@@ -826,7 +841,10 @@ impl HashBiasTable {
826841
let k_neg = f32::from_le_bytes(buf4);
827842

828843
file.read_exact(&mut buf4)?;
829-
let curve_power = f32::from_le_bytes(buf4);
844+
let deadzone_pos = f32::from_le_bytes(buf4);
845+
846+
file.read_exact(&mut buf4)?;
847+
let deadzone_neg = f32::from_le_bytes(buf4);
830848

831849
let mut seeds = Vec::with_capacity(depth);
832850
for _ in 0..depth {
@@ -867,12 +885,13 @@ impl HashBiasTable {
867885
negative_fscale,
868886
k_pos,
869887
k_neg,
870-
curve_power,
888+
deadzone_pos,
889+
deadzone_neg,
871890
})
872891
}
873892

874893
pub fn to_bytes(&self) -> Vec<u8> {
875-
let header_size = 4 + 4 + 1 + 8 + 4 + 1 + 4 + 1 + 4 + 4 + 8 + 8 + 4 + 4 + 4; // 63 bytes
894+
let header_size = 4 + 4 + 1 + 8 + 4 + 1 + 4 + 1 + 4 + 4 + 8 + 8 + 4 + 4 + 4 + 4; // 67 bytes
876895
let seeds_size = self.config.depth * 8;
877896
let weights_size = self.config.width * self.config.depth;
878897
let total_size = header_size + seeds_size + weights_size;
@@ -892,7 +911,8 @@ impl HashBiasTable {
892911
out.extend_from_slice(&self.negative_fscale.to_le_bytes());
893912
out.extend_from_slice(&self.k_pos.to_le_bytes());
894913
out.extend_from_slice(&self.k_neg.to_le_bytes());
895-
out.extend_from_slice(&self.curve_power.to_le_bytes());
914+
out.extend_from_slice(&self.deadzone_pos.to_le_bytes());
915+
out.extend_from_slice(&self.deadzone_neg.to_le_bytes());
896916

897917
for &seed in &self.seeds {
898918
out.extend_from_slice(&seed.to_le_bytes());
@@ -938,9 +958,10 @@ impl HashBiasTable {
938958
let negative_fscale = u64::from_le_bytes(data[43..51].try_into().unwrap());
939959
let k_pos = f32::from_le_bytes(data[51..55].try_into().unwrap());
940960
let k_neg = f32::from_le_bytes(data[55..59].try_into().unwrap());
941-
let curve_power = f32::from_le_bytes(data[59..63].try_into().unwrap());
961+
let deadzone_pos = f32::from_le_bytes(data[59..63].try_into().unwrap());
962+
let deadzone_neg = f32::from_le_bytes(data[63..67].try_into().unwrap());
942963

943-
let seeds_start = 63;
964+
let seeds_start = 67;
944965
let seeds_end = seeds_start + depth * 8;
945966
let weights_start = seeds_end;
946967
let weights_end = weights_start + width * depth;
@@ -992,7 +1013,8 @@ impl HashBiasTable {
9921013
negative_fscale,
9931014
k_pos,
9941015
k_neg,
995-
curve_power,
1016+
deadzone_pos,
1017+
deadzone_neg,
9961018
})
9971019
}
9981020

@@ -1087,7 +1109,8 @@ impl HashBiasTable {
10871109
if self.is_soft_filter() {
10881110
eprintln!(" k_pos: {:.4}", self.k_pos);
10891111
eprintln!(" k_neg: {:.4}", self.k_neg);
1090-
eprintln!(" curve_power: {:.2}", self.curve_power);
1112+
eprintln!(" deadzone_pos: {:.2}", self.deadzone_pos);
1113+
eprintln!(" deadzone_neg: {:.2}", self.deadzone_neg);
10911114

10921115
eprintln!(" reference points:");
10931116
for p in self.soft_filter_reference_points() {
@@ -1388,7 +1411,8 @@ impl PartialEq for HashBiasTable {
13881411
&& self.negative_fscale == other.negative_fscale
13891412
&& self.k_pos == other.k_pos
13901413
&& self.k_neg == other.k_neg
1391-
&& self.curve_power == other.curve_power
1414+
&& self.deadzone_pos == other.deadzone_pos
1415+
&& self.deadzone_neg == other.deadzone_neg
13921416
&& self.seeds == other.seeds
13931417
&& self.weights == other.weights
13941418
}
@@ -1499,7 +1523,7 @@ mod tests {
14991523
)
15001524
.unwrap();
15011525

1502-
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 1.0).unwrap();
1526+
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 0.0, 0.0).unwrap();
15031527
assert!(table.threshold >= -127);
15041528
}
15051529

@@ -1530,7 +1554,7 @@ mod tests {
15301554
)
15311555
.unwrap();
15321556

1533-
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 1.0).unwrap();
1557+
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 0.0, 0.0).unwrap();
15341558

15351559
let output = NamedTempFile::new().unwrap();
15361560
table.save(output.path()).unwrap();
@@ -1568,7 +1592,7 @@ mod tests {
15681592
)
15691593
.unwrap();
15701594

1571-
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 1.0).unwrap();
1595+
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 0.0, 0.0).unwrap();
15721596

15731597
let bytes = table.to_bytes();
15741598
let loaded = HashBiasTable::from_bytes(&bytes).unwrap();
@@ -1603,7 +1627,7 @@ mod tests {
16031627
)
16041628
.unwrap();
16051629

1606-
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 1.0).unwrap();
1630+
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 0.0, 0.0).unwrap();
16071631

16081632
let mut passed = 0;
16091633
let mut failed = 0;
@@ -1651,7 +1675,7 @@ mod tests {
16511675
)
16521676
.unwrap();
16531677

1654-
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 1.0).unwrap();
1678+
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 0.0, 0.0).unwrap();
16551679
assert!(table.threshold >= -127);
16561680
assert!(table.fold_enrichment() >= 1.0);
16571681
}
@@ -1673,7 +1697,8 @@ mod tests {
16731697
target_negative_retention: None,
16741698
positive_fscale: None,
16751699
negative_fscale: None,
1676-
curve_power: 1.0,
1700+
deadzone_pos: 0.0,
1701+
deadzone_neg: 0.0,
16771702
};
16781703

16791704
let table = HashBiasTable::create(&[pos.path()], &[neg.path()], &config, None).unwrap();
@@ -1716,7 +1741,7 @@ mod tests {
17161741
.unwrap();
17171742

17181743
// Hard cutoff mode (positive_fscale=0, negative_fscale=0)
1719-
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 1.0).unwrap();
1744+
let table = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 0, 0.0, 0.0).unwrap();
17201745
assert!(!table.is_soft_filter());
17211746

17221747
// Verify passes_filter matches weight >= threshold for many hashes
@@ -1761,7 +1786,7 @@ mod tests {
17611786
.unwrap();
17621787

17631788
let table =
1764-
HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 1, 1000, 1.0).unwrap();
1789+
HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 1, 1000, 0.0, 0.0).unwrap();
17651790
assert!(table.is_soft_filter());
17661791

17671792
// Same hash always gives the same result
@@ -1800,7 +1825,7 @@ mod tests {
18001825
.unwrap();
18011826

18021827
let table =
1803-
HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 10, 5000, 1.0).unwrap();
1828+
HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 10, 5000, 0.0, 0.0).unwrap();
18041829
assert!(table.is_soft_filter());
18051830

18061831
let bytes = table.to_bytes();
@@ -1836,7 +1861,7 @@ mod tests {
18361861
.unwrap();
18371862

18381863
let table =
1839-
HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 10_000, 1.0).unwrap();
1864+
HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 10_000, 0.0, 0.0).unwrap();
18401865
assert!(table.is_soft_filter());
18411866

18421867
let expected = (100.0_f64 * 10_000.0_f64).sqrt();
@@ -1872,23 +1897,23 @@ mod tests {
18721897
.unwrap();
18731898

18741899
// positive_fscale < global fscale → error
1875-
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 50, 1000, 1.0);
1900+
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 50, 1000, 0.0, 0.0);
18761901
assert!(result.is_err());
18771902

18781903
// positive_fscale without negative_fscale → error
1879-
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 0, 1.0);
1904+
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 0, 0.0, 0.0);
18801905
assert!(result.is_err());
18811906

18821907
// negative_fscale without positive_fscale → error
1883-
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 1000, 1.0);
1908+
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 0, 1000, 0.0, 0.0);
18841909
assert!(result.is_err());
18851910

18861911
// negative_fscale <= positive_fscale → error
1887-
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 100, 1.0);
1912+
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 100, 0.0, 0.0);
18881913
assert!(result.is_err());
18891914

18901915
// Valid soft filter
1891-
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 5000, 1.0);
1916+
let result = HashBiasTable::build(&pos_raw, &neg_raw, 1.0, None, None, 100, 5000, 0.0, 0.0);
18921917
assert!(result.is_ok());
18931918
}
18941919
}

src/cli/args.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,16 @@ pub enum BiasCommands {
154154
/// Use "drop" to discard hashes below the threshold.
155155
#[arg(long)]
156156
negative_fscale: Option<String>,
157-
/// Power exponent for sigmoid curve shaping (>1 flattens the middle,
158-
/// so only strongly biased k-mers get preferential treatment).
159-
#[arg(long, default_value = "1.0")]
160-
curve_power: f32,
157+
/// Deadzone fraction for positively biased k-mers (0.0–1.0).
158+
/// Fraction of positive weight range that stays flat before sigmoid kicks in.
159+
/// E.g. 0.95 means only the top 5% of positively biased k-mers get boosted.
160+
#[arg(long, default_value = "0.0")]
161+
deadzone_pos: f32,
162+
/// Deadzone fraction for negatively biased k-mers (0.0–1.0).
163+
/// Fraction of negative weight range that stays flat before sigmoid kicks in.
164+
/// E.g. 0.0 means transition starts immediately (cliff behavior with high k_neg).
165+
#[arg(long, default_value = "0.0")]
166+
deadzone_neg: f32,
161167
/// Number of threads to use for bias sketching
162168
#[arg(long)]
163169
threads: Option<usize>,

src/cli/handlers.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,8 @@ pub fn handle_bias_create_command(
357357
negative_retention: Option<f32>,
358358
positive_fscale: Option<u64>,
359359
negative_fscale: Option<String>,
360-
curve_power: f32,
360+
deadzone_pos: f32,
361+
deadzone_neg: f32,
361362
threads: Option<usize>,
362363
force: bool,
363364
silent: bool,
@@ -388,8 +389,17 @@ pub fn handle_bias_create_command(
388389
return Err(anyhow::anyhow!("--alpha must be finite and > 0"));
389390
}
390391

391-
if !curve_power.is_finite() || curve_power <= 0.0 {
392-
return Err(anyhow::anyhow!("--curve-power must be finite and > 0"));
392+
if !deadzone_pos.is_finite() || !(0.0..1.0).contains(&deadzone_pos) {
393+
return Err(anyhow::anyhow!(
394+
"--deadzone-pos must be finite and in [0.0, 1.0), got {}",
395+
deadzone_pos
396+
));
397+
}
398+
if !deadzone_neg.is_finite() || !(0.0..1.0).contains(&deadzone_neg) {
399+
return Err(anyhow::anyhow!(
400+
"--deadzone-neg must be finite and in [0.0, 1.0), got {}",
401+
deadzone_neg
402+
));
393403
}
394404

395405
if let Some(v) = positive_retention
@@ -461,7 +471,8 @@ pub fn handle_bias_create_command(
461471
target_negative_retention: negative_retention,
462472
positive_fscale,
463473
negative_fscale,
464-
curve_power,
474+
deadzone_pos,
475+
deadzone_neg,
465476
};
466477

467478
let pos_paths: Vec<&std::path::Path> = positive.iter().map(|p| p.as_path()).collect();
@@ -532,7 +543,8 @@ pub fn handle_bias_create_command(
532543
if table.is_soft_filter() {
533544
eprintln!(" k_pos: {:.4}", table.k_pos);
534545
eprintln!(" k_neg: {:.4}", table.k_neg);
535-
eprintln!(" curve_power: {:.2}", table.curve_power);
546+
eprintln!(" deadzone_pos: {:.2}", table.deadzone_pos);
547+
eprintln!(" deadzone_neg: {:.2}", table.deadzone_neg);
536548
eprintln!(" reference points:");
537549
for p in table.soft_filter_reference_points() {
538550
eprintln!(
@@ -630,7 +642,8 @@ pub fn handle_bias_stats_command(
630642
"negative_fscale_drop": table.negative_fscale == u64::MAX,
631643
"k_pos": table.k_pos,
632644
"k_neg": table.k_neg,
633-
"curve_power": table.curve_power,
645+
"deadzone_pos": table.deadzone_pos,
646+
"deadzone_neg": table.deadzone_neg,
634647
},
635648
"weight_stats": {
636649
"min": min,

0 commit comments

Comments
 (0)