Repository: mwlon/pcodec (language: python)

Issue:
Refactor the `FooSpec`s to be mutually exclusive
> @bzinberg good question - I think for the initial release we'll just make `AutoDetect` monolithic, but we should probably leave open the later possibility of choosing a subset of modes. So really it would be something like
> 
> ```rust
> #[non_exhaustive]
> pub struct AutoDetectConfig {
>   // empty for now, could add power user features later
> }
> 
> pub enum ModeSpec {
>   ProvidedClassic,
>   ...,
>   AutoDetect(AutoDetectConfig),
> }
> ```
> 
> That said, I think structuring this as something like `vec![bid_a, bid_b].iter().max_by_key(|bid| bid.bits_saved)` makes sense in any case.
> 
> @Skielex Yep, the modes are exclusive. I see what you're getting at, but I think the complexity of making `ChunkConfig` generic would require a lot more code and outweight the benefits. For similar reasons, I've made e.g. `FloatMultSpec::Provided(f64)` instead of `...Provided(T)`. On incorrect use, users will get `InvalidArgument` errors, which are obviously not as good as compile-time errors, but these will still fail fast and be easy to test for.
> 
_Originally posted by @mwlon in https://github.com/mwlon/pcodec/issues/212#issuecomment-2183543586_


Discussion / hints:

Fix patch:
diff --git a/pco/src/chunk_config.rs b/pco/src/chunk_config.rs
index 31cd0ea0..c0fdfd95 100644
--- a/pco/src/chunk_config.rs
+++ b/pco/src/chunk_config.rs
@@ -2,67 +2,40 @@ use crate::constants::{Bitlen, DEFAULT_MAX_PAGE_N};
 use crate::errors::{PcoError, PcoResult};
 use crate::DEFAULT_COMPRESSION_LEVEL;
 
-/// Configures whether integer multiplier detection is enabled.
+/// Specifies how Pco should choose a [`mode`][crate::Mode] to compress this
+/// chunk of data.
 ///
-/// Examples where this helps:
-/// * nanosecond-precision timestamps that are mostly whole numbers of
-/// microseconds, with a few exceptions
-/// * integers `[7, 107, 207, 307, ... 100007]` shuffled
-///
-/// When this is helpful, compression and decompression speeds can be
-/// substantially reduced. This configuration may hurt
-/// compression speed slightly even when it isn't helpful.
-/// However, the compression ratio improvements tend to be large.
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
-pub enum IntMultSpec {
-  Disabled,
-  #[default]
-  Enabled,
-  /// If you know all your ints are roughly multiples of `base` (or have a
-  /// simple distribution modulo `base`), you can provide `base` here to
-  /// ensure it gets used and save compression time.
-  Provided(u64),
-}
-
-/// Configures whether float multiplier detection is enabled.
-///
-/// Examples where this helps:
-/// * approximate multiples of 0.01
-/// * approximate multiples of pi
-///
-/// Float mults can work even when there are NaNs and infinities.
-/// When this is helpful, compression and decompression speeds can be
-/// substantially reduced. In rare cases, this configuration
-/// may reduce compression speed somewhat even when it isn't helpful.
-/// However, the compression ratio improvements tend to be large.
+/// The `Try*` variants almost always use the provided mode, but fall back to an
+/// effectively uncompressed version of `Classic` if the provided mode is
+/// especially bad.
+/// It is recommended that you only use the `Try*` variants if you know for
+/// certain that your numbers benefit from that mode.
 #[derive(Clone, Copy, Debug, Default, PartialEq)]
-pub enum FloatMultSpec {
-  Disabled,
-  #[default]
-  Enabled,
-  /// If you know all your floats are roughly multiples of `base`, you can
-  /// provide `base` here to ensure it gets used and save compression time.
-  Provided(f64),
-  // TODO support a LossyEnabled mode that always drops the ULPs latent var
-}
-
-/// Configures whether quantized-float detection is enabled.
-///
-/// Examples where this helps:
-/// * float-valued data stored in a type that is unnecessarily wide (e.g. stored
-///   as `f64`s where only a `f32` worth of precision is used)
-#[derive(Clone, Copy, Debug, Default, PartialEq)]
-pub enum FloatQuantSpec {
-  Disabled,
-  /// Automatically decide whether to use FloatQuant, based on an estimate of how much it would
-  /// save compared to other available modes.
+pub enum ModeSpec {
+  /// Automatically detect a good mode.
+  ///
+  /// This works well most of the time, but costs some compression time and can
+  /// select a bad mode in adversarial cases.
   #[default]
-  Enabled,
-  /// If you know how many bits of precision are unused, you can supply that
-  /// number here.
-  Provided(Bitlen),
+  Auto,
+  /// Only use `Classic` mode.
+  Classic,
+  /// Try using `FloatMult` mode with a given `base`.
+  ///
+  /// Only applies to floating-point types.
+  TryFloatMult(f64),
+  /// Try using `FloatQuant` mode with `k` bits of quantization.
+  ///
+  /// Only applies to floating-point types.
+  TryFloatQuant(Bitlen),
+  /// Try using `IntMult` mode with a given `base`.
+  ///
+  /// Only applies to integer types.
+  TryIntMult(u64),
 }
 
+// TODO consider adding a "lossiness" spec that allows dropping secondary latent
+// vars.
 /// All configurations available for a compressor.
 ///
 /// Some, like `delta_encoding_order`, are explicitly stored in the
@@ -72,7 +45,7 @@ pub enum FloatQuantSpec {
 #[derive(Clone, Debug)]
 #[non_exhaustive]
 pub struct ChunkConfig {
-  /// `compression_level` ranges from 0 to 12 inclusive (default: 8).
+  /// Ranges from 0 to 12 inclusive (default: 8).
   ///
   /// At present,
   /// * Level 0 achieves only a small amount of compression.
@@ -82,8 +55,8 @@ pub struct ChunkConfig {
   /// The meaning of the compression levels is subject to change with
   /// new releases.
   pub compression_level: usize,
-  /// `delta_encoding_order` ranges from 0 to 7 inclusive (default:
-  /// `None`, automatically detecting on each chunk).
+  /// Ranges from 0 to 7 inclusive (default: `None`, automatically detecting on
+  /// each chunk).
   ///
   /// It is the number of times to apply delta encoding
   /// before compressing. For instance, say we have the numbers
@@ -103,31 +76,12 @@ pub struct ChunkConfig {
   /// chunks,
   /// [`auto_delta_encoding_order`][crate::auto_delta_encoding_order] can help.
   pub delta_encoding_order: Option<usize>,
-  /// Integer multiplier mode improves compression ratio in cases where many
-  /// numbers are congruent modulo an integer `base`
-  /// (default: `Enabled`).
-  ///
-  /// See [`IntMultSpec`][crate::IntMultSpec] for more detail.
-  pub int_mult_spec: IntMultSpec,
-  /// Float multiplier mode improves compression ratio in cases where the data
-  /// type is a float and all numbers are close to a multiple of a float
-  /// `base`
-  /// (default: `Enabled`).
-  ///
-  /// See [`FloatMultSpec`][crate::FloatMultSpec] for more detail.
-  pub float_mult_spec: FloatMultSpec,
-  /// Float quantization improves compression ratio in cases where a
-  /// float-valued dataset does not make use of the full precision available in
-  /// the data type, i.e., the several least-significant bits of the
-  /// significands are zero
-  /// (default: `Enabled`).
-  ///
-  /// See [`FloatQuantSpec`][crate::FloatQuantSpec] for more detail.
-  pub float_quant_spec: FloatQuantSpec,
-  /// `paging_spec` specifies how the chunk should be split into pages
-  /// (default: equal pages up to 2^18 numbers each).
+  /// Specifies how the mode should be determined.
   ///
-  /// See [`PagingSpec`][crate::PagingSpec] for more information.
+  /// See [`Mode`](crate::Mode) to understand what modes are.
+  pub mode_spec: ModeSpec,
+  /// Specifies how the chunk should be split into pages (default: equal pages
+  /// up to 2^18 numbers each).
   pub paging_spec: PagingSpec,
 }
 
@@ -136,9 +90,7 @@ impl Default for ChunkConfig {
     Self {
       compression_level: DEFAULT_COMPRESSION_LEVEL,
       delta_encoding_order: None,
-      int_mult_spec: IntMultSpec::default(),
-      float_mult_spec: FloatMultSpec::default(),
-      float_quant_spec: FloatQuantSpec::default(),
+      mode_spec: ModeSpec::default(),
       paging_spec: PagingSpec::EqualPagesUpTo(DEFAULT_MAX_PAGE_N),
     }
   }
@@ -157,21 +109,9 @@ impl ChunkConfig {
     self
   }
 
-  /// Sets [`int_mult_spec`][ChunkConfig::int_mult_spec].
-  pub fn with_int_mult_spec(mut self, int_mult_spec: IntMultSpec) -> Self {
-    self.int_mult_spec = int_mult_spec;
-    self
-  }
-
-  /// Sets [`float_mult_spec`][ChunkConfig::float_mult_spec].
-  pub fn with_float_mult_spec(mut self, float_mult_spec: FloatMultSpec) -> Self {
-    self.float_mult_spec = float_mult_spec;
-    self
-  }
-
-  /// Sets [`float_quant_spec`][ChunkConfig::float_quant_spec].
-  pub fn with_float_quant_spec(mut self, float_quant_spec: FloatQuantSpec) -> Self {
-    self.float_quant_spec = float_quant_spec;
+  /// Sets [`mode_spec`][ChunkConfig::mode_spec].
+  pub fn with_mode_spec(mut self, mode_spec: ModeSpec) -> Self {
+    self.mode_spec = mode_spec;
     self
   }
 
diff --git a/pco/src/data_types/floats.rs b/pco/src/data_types/floats.rs
index 798847fd..dd260ae3 100644
--- a/pco/src/data_types/floats.rs
+++ b/pco/src/data_types/floats.rs
@@ -2,6 +2,7 @@ use std::mem;
 
 use half::f16;
 
+use crate::chunk_config::ModeSpec;
 use crate::constants::Bitlen;
 use crate::data_types::{split_latents_classic, FloatLike, Latent, NumberLike};
 use crate::describers::LatentDescriber;
@@ -9,7 +10,7 @@ use crate::errors::{PcoError, PcoResult};
 use crate::float_mult_utils::FloatMultConfig;
 use crate::{
   describers, float_mult_utils, float_quant_utils, mode::Bid, sampling, ChunkConfig, ChunkMeta,
-  FloatMultSpec, FloatQuantSpec, Mode,
+  Mode,
 };
 
 use super::ModeAndLatents;
@@ -26,53 +27,31 @@ fn filter_sample<F: FloatLike>(num: &F) -> Option<F> {
   None
 }
 
-fn detect_mode_and_split_latents<F: FloatLike>(
-  nums: &[F],
-  chunk_config: &ChunkConfig,
-) -> ModeAndLatents<F::L> {
-  // up to 3 bids: classic, float mult, float quant modes
-  let mut bids: Vec<Bid<F>> = vec![];
-  bids.push(Bid {
-    mode: Mode::Classic,
-    bits_saved_per_num: 0.0,
-    split_fn: Box::new(|nums| split_latents_classic(nums)),
-  });
-  // computing sample is unnecessary when both are disabled, but we'll change
-  // things before the next release
-  let maybe_sample = sampling::choose_sample(nums, filter_sample);
-
-  if let Some(sample) = maybe_sample {
-    if matches!(
-      chunk_config.float_mult_spec,
-      FloatMultSpec::Enabled
-    ) {
-      bids.extend(float_mult_utils::compute_bid(&sample));
-    }
-    if matches!(
-      chunk_config.float_quant_spec,
-      FloatQuantSpec::Enabled
-    ) {
-      bids.extend(float_quant_utils::compute_bid(&sample));
-    }
-  }
-
-  let winning_bid = choose_winning_bid(bids);
-  let latents = (winning_bid.split_fn)(nums);
-  (winning_bid.mode, latents)
-}
-
 fn choose_mode_and_split_latents<F: FloatLike>(
   nums: &[F],
   chunk_config: &ChunkConfig,
 ) -> PcoResult<ModeAndLatents<F::L>> {
-  match (
-    chunk_config.float_mult_spec,
-    chunk_config.float_quant_spec,
-  ) {
-    (FloatMultSpec::Provided(_), FloatQuantSpec::Provided(_)) => Err(PcoError::invalid_argument(
-      "FloatMult and FloatQuant cannot be provided (thus required) simultaneously",
-    )),
-    (FloatMultSpec::Provided(base_f64), _) => {
+  match chunk_config.mode_spec {
+    ModeSpec::Auto => {
+      // up to 3 bids: classic, float mult, float quant modes
+      let mut bids: Vec<Bid<F>> = vec![];
+      bids.push(Bid {
+        mode: Mode::Classic,
+        bits_saved_per_num: 0.0,
+        split_fn: Box::new(|nums| split_latents_classic(nums)),
+      });
+
+      if let Some(sample) = sampling::choose_sample(nums, filter_sample) {
+        bids.extend(float_mult_utils::compute_bid(&sample));
+        bids.extend(float_quant_utils::compute_bid(&sample));
+      }
+
+      let winning_bid = choose_winning_bid(bids);
+      let latents = (winning_bid.split_fn)(nums);
+      Ok((winning_bid.mode, latents))
+    }
+    ModeSpec::Classic => Ok((Mode::Classic, split_latents_classic(nums))),
+    ModeSpec::TryFloatMult(base_f64) => {
       let base = F::from_f64(base_f64);
       let mode = Mode::float_mult(base);
       let float_mult_config = FloatMultConfig {
@@ -82,13 +61,12 @@ fn choose_mode_and_split_latents<F: FloatLike>(
       let latents = float_mult_utils::split_latents(nums, float_mult_config);
       Ok((mode, latents))
     }
-    (_, FloatQuantSpec::Provided(k)) => Ok((
+    ModeSpec::TryFloatQuant(k) => Ok((
       Mode::FloatQuant(k),
       float_quant_utils::split_latents(nums, k),
     )),
-    _ => Ok(detect_mode_and_split_latents(
-      nums,
-      chunk_config,
+    ModeSpec::TryIntMult(_) => Err(PcoError::invalid_argument(
+      "unable to use int mult mode on floats",
     )),
   }
 }
@@ -364,8 +342,8 @@ macro_rules! impl_float_number_like {
       fn choose_mode_and_split_latents(
         nums: &[Self],
         config: &ChunkConfig,
-      ) -> ModeAndLatents<Self::L> {
-        choose_mode_and_split_latents(nums, config).unwrap()
+      ) -> PcoResult<ModeAndLatents<Self::L>> {
+        choose_mode_and_split_latents(nums, config)
       }
 
       #[inline]
@@ -435,17 +413,16 @@ mod tests {
     );
   }
 
-  // TODO fix this; float mult overestimates its bits saved
-  // #[test]
-  // fn test_choose_quant_mode() {
-  //   let lowest_num_bits = 1.0_f64.to_bits();
-  //   let k = 20;
-  //   let nums = (0..1000)
-  //     .map(|i| f64::from_bits(lowest_num_bits + (i << k)))
-  //     .collect::<Vec<_>>();
-  //   let (mode, _) = choose_mode_and_split_latents(&nums, &ChunkConfig::default()).unwrap();
-  //   assert_eq!(mode, Mode::FloatQuant(k));
-  // }
+  #[test]
+  fn test_choose_quant_mode() {
+    let lowest_num_bits = 1.0_f64.to_bits();
+    let k = 20;
+    let nums = (0..1000)
+      .map(|i| f64::from_bits(lowest_num_bits + (i << k)))
+      .collect::<Vec<_>>();
+    let (mode, _) = choose_mode_and_split_latents(&nums, &ChunkConfig::default()).unwrap();
+    assert_eq!(mode, Mode::FloatQuant(k));
+  }
 
   #[test]
   fn test_float_ordering() {
diff --git a/pco/src/data_types/mod.rs b/pco/src/data_types/mod.rs
index d26b6a40..53858cab 100644
--- a/pco/src/data_types/mod.rs
+++ b/pco/src/data_types/mod.rs
@@ -9,6 +9,7 @@ pub use dynamic::CoreDataType;
 
 use crate::constants::Bitlen;
 use crate::describers::LatentDescriber;
+use crate::errors::PcoResult;
 use crate::{ChunkConfig, ChunkMeta, Mode};
 
 mod dynamic;
@@ -175,7 +176,10 @@ pub trait NumberLike: Copy + Debug + Display + Default + PartialEq + Send + Sync
   /// for decompression
   /// * latents: a list of latent variables, each of which contains a latent per
   /// num in `nums`
-  fn choose_mode_and_split_latents(nums: &[Self], config: &ChunkConfig) -> ModeAndLatents<Self::L>;
+  fn choose_mode_and_split_latents(
+    nums: &[Self],
+    config: &ChunkConfig,
+  ) -> PcoResult<ModeAndLatents<Self::L>>;
 
   fn from_latent_ordered(l: Self::L) -> Self;
   fn to_latent_ordered(self) -> Self::L;
diff --git a/pco/src/data_types/signeds.rs b/pco/src/data_types/signeds.rs
index 022059c9..7e6d6380 100644
--- a/pco/src/data_types/signeds.rs
+++ b/pco/src/data_types/signeds.rs
@@ -1,7 +1,8 @@
 use std::mem;
 
-use crate::data_types::{unsigneds, NumberLike};
+use crate::data_types::{unsigneds, ModeAndLatents, NumberLike};
 use crate::describers::LatentDescriber;
+use crate::errors::PcoResult;
 use crate::{describers, int_mult_utils, ChunkMeta};
 use crate::{ChunkConfig, Mode};
 
@@ -29,7 +30,7 @@ macro_rules! impl_signed {
       fn choose_mode_and_split_latents(
         nums: &[Self],
         config: &ChunkConfig,
-      ) -> (Mode<Self::L>, Vec<Vec<Self::L>>) {
+      ) -> PcoResult<ModeAndLatents<Self::L>> {
         unsigneds::choose_mode_and_split_latents(&nums, config)
       }
 
diff --git a/pco/src/data_types/unsigneds.rs b/pco/src/data_types/unsigneds.rs
index 1855b831..8353caf0 100644
--- a/pco/src/data_types/unsigneds.rs
+++ b/pco/src/data_types/unsigneds.rs
@@ -1,32 +1,37 @@
 use crate::constants::Bitlen;
 use crate::data_types::{split_latents_classic, Latent, NumberLike};
 use crate::describers::LatentDescriber;
+use crate::errors::{PcoError, PcoResult};
 use crate::Mode::Classic;
-use crate::{describers, int_mult_utils, ChunkConfig, ChunkMeta, IntMultSpec, Mode};
+use crate::{describers, int_mult_utils, ChunkConfig, ChunkMeta, Mode, ModeSpec};
+
+use super::ModeAndLatents;
 
 pub fn choose_mode_and_split_latents<T: NumberLike>(
   nums: &[T],
   config: &ChunkConfig,
-) -> (Mode<T::L>, Vec<Vec<T::L>>) {
-  use IntMultSpec::*;
-
-  match config.int_mult_spec {
-    Enabled => {
+) -> PcoResult<ModeAndLatents<T::L>> {
+  match config.mode_spec {
+    ModeSpec::Auto => {
       if let Some(base) = int_mult_utils::choose_base(nums) {
         let mode = Mode::IntMult(base);
         let latents = int_mult_utils::split_latents(nums, base);
-        (mode, latents)
+        Ok((mode, latents))
       } else {
-        (Classic, split_latents_classic(nums))
+        Ok((Classic, split_latents_classic(nums)))
       }
     }
-    Provided(base_u64) => {
+
+    ModeSpec::Classic => Ok((Mode::Classic, split_latents_classic(nums))),
+    ModeSpec::TryFloatMult(_) | ModeSpec::TryFloatQuant(_) => Err(PcoError::invalid_argument(
+      "unable to use float mode for ints",
+    )),
+    ModeSpec::TryIntMult(base_u64) => {
       let base = T::L::from_u64(base_u64);
       let mode = Mode::IntMult(base);
       let latents = int_mult_utils::split_latents(nums, base);
-      (mode, latents)
+      Ok((mode, latents))
     }
-    Disabled => (Classic, split_latents_classic(nums)),
   }
 }
 
@@ -95,7 +100,7 @@ macro_rules! impl_unsigned_number {
       fn choose_mode_and_split_latents(
         nums: &[Self],
         config: &ChunkConfig,
-      ) -> (Mode<Self::L>, Vec<Vec<Self::L>>) {
+      ) -> PcoResult<ModeAndLatents<Self::L>> {
         choose_mode_and_split_latents(nums, config)
       }
 
diff --git a/pco/src/lib.rs b/pco/src/lib.rs
index f0b7776e..753030e1 100644
--- a/pco/src/lib.rs
+++ b/pco/src/lib.rs
@@ -40,7 +40,7 @@
 #![deny(dead_code)]
 
 pub use bin::Bin;
-pub use chunk_config::{ChunkConfig, FloatMultSpec, FloatQuantSpec, IntMultSpec, PagingSpec};
+pub use chunk_config::{ChunkConfig, ModeSpec, PagingSpec};
 pub use chunk_meta::{ChunkLatentVarMeta, ChunkMeta};
 pub use constants::{DEFAULT_COMPRESSION_LEVEL, DEFAULT_MAX_PAGE_N, FULL_BATCH_N};
 pub use mode::Mode;
diff --git a/pco/src/mode.rs b/pco/src/mode.rs
index b76e421b..efc6d521 100644
--- a/pco/src/mode.rs
+++ b/pco/src/mode.rs
@@ -25,10 +25,15 @@ use crate::data_types::{FloatLike, Latent, NumberLike};
 // Note the differences between int mult and float mult,
 // which have equivalent formulas.
 
-/// A variation of how pco serializes and deserializes numbers.
+/// How Pco does the first step of processing for this chunk.
 ///
 /// Each mode splits the vector of numbers into one or two vectors of latents,
-/// with a different formula for how the split and join is done.
+/// with a different formula for how the split and (and eventual join during
+/// decompression) is done.
+/// Each of these vectors of latents is then passed through Pco's subsequent
+/// processing steps (possible delta encoding and binning) to produce the final
+/// compressed bytes.
+///
 /// We have delibrately written the formulas below in a slightly wrong way to
 /// convey the correct intuition without dealing with implementation
 /// complexities.
diff --git a/pco/src/standalone/guarantee.rs b/pco/src/standalone/guarantee.rs
index c7cf7ca3..e766c2de 100644
--- a/pco/src/standalone/guarantee.rs
+++ b/pco/src/standalone/guarantee.rs
@@ -45,7 +45,7 @@ mod tests {
   use crate::data_types::NumberLike;
   use crate::errors::PcoResult;
   use crate::standalone::{simple_compress, FileCompressor};
-  use crate::{ChunkConfig, FloatMultSpec, PagingSpec};
+  use crate::{ChunkConfig, ModeSpec, PagingSpec};
 
   use super::*;
 
@@ -78,7 +78,7 @@ mod tests {
       nums.push(rng.gen_range(-1.0_f32..1.0));
     }
     let config = ChunkConfig {
-      float_mult_spec: FloatMultSpec::Provided(0.1),
+      mode_spec: ModeSpec::TryFloatMult(0.1),
       delta_encoding_order: Some(5),
       paging_spec: PagingSpec::EqualPagesUpTo(10),
       ..Default::default()
diff --git a/pco/src/wrapped/chunk_compressor.rs b/pco/src/wrapped/chunk_compressor.rs
index d7e6b972..5489f6bd 100644
--- a/pco/src/wrapped/chunk_compressor.rs
+++ b/pco/src/wrapped/chunk_compressor.rs
@@ -574,7 +574,7 @@ pub(crate) fn new<T: NumberLike>(
   let n = nums.len();
   validate_chunk_size(n)?;
 
-  let (mode, latents) = T::choose_mode_and_split_latents(nums, config);
+  let (mode, latents) = T::choose_mode_and_split_latents(nums, config)?;
 
   let (candidate, bin_counts) = new_candidate_w_split(mode, latents, config)?;
   if should_fallback(n, &candidate, bin_counts) {
diff --git a/pco/src/wrapped/guarantee.rs b/pco/src/wrapped/guarantee.rs
index a0c329d8..909e22ae 100644
--- a/pco/src/wrapped/guarantee.rs
+++ b/pco/src/wrapped/guarantee.rs
@@ -38,7 +38,7 @@ mod tests {
   use crate::data_types::NumberLike;
   use crate::errors::PcoResult;
   use crate::wrapped::FileCompressor;
-  use crate::{ChunkConfig, FloatMultSpec, PagingSpec};
+  use crate::{ChunkConfig, ModeSpec, PagingSpec};
 
   use super::*;
 
@@ -87,7 +87,7 @@ mod tests {
       nums.push(rng.gen_range(-1.0..1.0));
     }
     let config = ChunkConfig {
-      float_mult_spec: FloatMultSpec::Provided(0.1),
+      mode_spec: ModeSpec::TryFloatMult(0.1),
       delta_encoding_order: Some(5),
       paging_spec: PagingSpec::EqualPagesUpTo(10),
       ..Default::default()
diff --git a/pco_cli/src/bench/codecs/pco.rs b/pco_cli/src/bench/codecs/pco.rs
index 469cd48b..7d13b203 100644
--- a/pco_cli/src/bench/codecs/pco.rs
+++ b/pco_cli/src/bench/codecs/pco.rs
@@ -1,33 +1,16 @@
-use pco::{ChunkConfig, FloatMultSpec, FloatQuantSpec, IntMultSpec};
+use pco::{ChunkConfig, ModeSpec};
 
 use crate::bench::codecs::CodecInternal;
 use crate::chunk_config_opt::ChunkConfigOpt;
 use crate::dtypes::PcoNumberLike;
 
-fn unparse_int_mult(spec: &IntMultSpec) -> String {
-  use IntMultSpec::*;
+fn unparse_mode_spec(spec: &ModeSpec) -> String {
   match spec {
-    Disabled => "Disabled".to_string(),
-    Enabled => "Enabled".to_string(),
-    Provided(base) => base.to_string(),
-  }
-}
-
-fn unparse_float_mult(spec: &FloatMultSpec) -> String {
-  use FloatMultSpec::*;
-  match spec {
-    Disabled => "Disabled".to_string(),
-    Enabled => "Enabled".to_string(),
-    Provided(base) => base.to_string(),
-  }
-}
-
-fn unparse_float_quant(spec: &FloatQuantSpec) -> String {
-  use FloatQuantSpec::*;
-  match spec {
-    Disabled => "Disabled".to_string(),
-    Enabled => "Enabled".to_string(),
-    Provided(k) => k.to_string(),
+    ModeSpec::Auto => "Auto".to_string(),
+    ModeSpec::Classic => "Classic".to_string(),
+    ModeSpec::TryFloatMult(base) => format!("FloatMult@{}", base),
+    ModeSpec::TryFloatQuant(k) => format!("FloatQuant@{}", k),
+    ModeSpec::TryIntMult(base) => format!("IntMult@{}", base),
   }
 }
 
@@ -46,15 +29,7 @@ impl CodecInternal for ChunkConfigOpt {
           .map(|order| order.to_string())
           .unwrap_or("Auto".to_string()),
       ),
-      ("int-mult", unparse_int_mult(&self.int_mult)),
-      (
-        "float-mult",
-        unparse_float_mult(&self.float_mult),
-      ),
-      (
-        "float-quant",
-        unparse_float_quant(&self.float_quant),
-      ),
+      ("mode", unparse_mode_spec(&self.mode)),
       ("chunk-n", self.chunk_n.to_string()),
     ]
   }
diff --git a/pco_cli/src/chunk_config_opt.rs b/pco_cli/src/chunk_config_opt.rs
index 2930cd5d..5947423d 100644
--- a/pco_cli/src/chunk_config_opt.rs
+++ b/pco_cli/src/chunk_config_opt.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use pco::{ChunkConfig, FloatMultSpec, FloatQuantSpec, IntMultSpec, PagingSpec};
+use pco::{ChunkConfig, ModeSpec, PagingSpec};
 
 use crate::parse;
 
@@ -16,18 +16,13 @@ pub struct ChunkConfigOpt {
   /// which tries to automatically detect the best delta encoding order.
   #[arg(long = "delta-order", default_value = "Auto", value_parser = parse::delta_encoding_order)]
   pub delta_encoding_order: std::option::Option<usize>,
-  /// Can be "Enabled", "Disabled", or a fixed integer to use as the base in
-  /// int mult mode.
-  #[arg(long, default_value = "Enabled", value_parser = parse::int_mult)]
-  pub int_mult: IntMultSpec,
-  /// Can be "Enabled", "Disabled", or a fixed float to use as the base in
-  /// float mult mode.
-  #[arg(long, default_value = "Enabled", value_parser = parse::float_mult)]
-  pub float_mult: FloatMultSpec,
-  /// Can be "Enabled", "Disabled", or a fixed integer to use as the parameter
-  /// `k` in float quant mode.
-  #[arg(long, default_value = "Enabled", value_parser = parse::float_quant)]
-  pub float_quant: FloatQuantSpec,
+  /// Can be "Auto", "Classic", "FloatMult@<base>", "FloatQuant@<k>", or
+  /// "IntMult@<base>".
+  ///
+  /// Specs other than Auto and Classic will try the given mode and fall back to
+  /// classic if the given mode is especially bad.
+  #[arg(long, default_value = "Auto", value_parser = parse::mode_spec)]
+  pub mode: ModeSpec,
   #[arg(long, default_value_t = pco::DEFAULT_MAX_PAGE_N)]
   pub chunk_n: usize,
 }
@@ -37,9 +32,7 @@ impl From<&ChunkConfigOpt> for ChunkConfig {
     ChunkConfig::default()
       .with_compression_level(opt.level)
       .with_delta_encoding_order(opt.delta_encoding_order)
-      .with_int_mult_spec(opt.int_mult)
-      .with_float_mult_spec(opt.float_mult)
-      .with_float_quant_spec(opt.float_quant)
+      .with_mode_spec(opt.mode)
       .with_paging_spec(PagingSpec::EqualPagesUpTo(opt.chunk_n))
   }
 }
diff --git a/pco_cli/src/parse.rs b/pco_cli/src/parse.rs
index e5a533cf..eb5c4342 100644
--- a/pco_cli/src/parse.rs
+++ b/pco_cli/src/parse.rs
@@ -3,7 +3,7 @@ use std::str::FromStr;
 use anyhow::anyhow;
 use arrow::datatypes::{DataType, TimeUnit};
 
-use pco::{FloatMultSpec, FloatQuantSpec, IntMultSpec};
+use pco::ModeSpec;
 
 pub fn delta_encoding_order(s: &str) -> anyhow::Result<Option<usize>> {
   match s.to_lowercase().as_str() {
@@ -15,46 +15,23 @@ pub fn delta_encoding_order(s: &str) -> anyhow::Result<Option<usize>> {
   }
 }
 
-pub fn int_mult(s: &str) -> anyhow::Result<IntMultSpec> {
+pub fn mode_spec(s: &str) -> anyhow::Result<ModeSpec> {
   let lowercase = s.to_lowercase();
   let spec = match lowercase.as_str() {
-    "enabled" => IntMultSpec::Enabled,
-    "disabled" => IntMultSpec::Disabled,
-    other => match other.parse::<u64>() {
-      Ok(mult) => IntMultSpec::Provided(mult),
-      _ => return Err(anyhow!("cannot parse int mult: {}", other)),
-    },
-  };
-  Ok(spec)
-}
-
-pub fn float_mult(s: &str) -> anyhow::Result<FloatMultSpec> {
-  let lowercase = s.to_lowercase();
-  let spec = match lowercase.as_str() {
-    "enabled" => FloatMultSpec::Enabled,
-    "disabled" => FloatMultSpec::Disabled,
-    other => match other.parse::<f64>() {
-      Ok(mult) => FloatMultSpec::Provided(mult),
-      _ => return Err(anyhow!("cannot parse float mult: {}", other)),
-    },
-  };
-  Ok(spec)
-}
-
-pub fn float_quant(s: &str) -> anyhow::Result<FloatQuantSpec> {
-  let lowercase = s.to_lowercase();
-  let spec = match lowercase.as_str() {
-    "enabled" => FloatQuantSpec::Enabled,
-    "disabled" => FloatQuantSpec::Disabled,
-    other => match other.parse::<u32>() {
-      Ok(k) => FloatQuantSpec::Provided(k),
-      _ => {
-        return Err(anyhow!(
-          "cannot parse float quant parameter: {}",
-          other
-        ))
+    "auto" => ModeSpec::Auto,
+    "classic" => ModeSpec::Classic,
+    other => {
+      let mut parts = other.split('@');
+      let name = parts.next().unwrap();
+      let err = || anyhow!("invalid mode spec: {}", s);
+      let value = parts.next().ok_or_else(err)?;
+      match name {
+        "floatmult" => ModeSpec::TryFloatMult(value.parse()?),
+        "floatquant" => ModeSpec::TryFloatQuant(value.parse()?),
+        "intmult" => ModeSpec::TryIntMult(value.parse()?),
+        _ => return Err(err()),
       }
-    },
+    }
   };
   Ok(spec)
 }
diff --git a/pco_python/src/lib.rs b/pco_python/src/lib.rs
index 552ab310..2306958b 100644
--- a/pco_python/src/lib.rs
+++ b/pco_python/src/lib.rs
@@ -1,7 +1,7 @@
 use half::f16;
 use numpy::PyArrayDyn;
 use pco::data_types::CoreDataType;
-use pco::{ChunkConfig, FloatMultSpec, FloatQuantSpec, IntMultSpec, PagingSpec, Progress};
+use pco::{ChunkConfig, ModeSpec, PagingSpec, Progress};
 use pyo3::exceptions::PyRuntimeError;
 use pyo3::prelude::{pymodule, FromPyObject, PyModule, PyResult, Python};
 use pyo3::{py_run, pyclass, pymethods, PyErr};
@@ -50,82 +50,44 @@ pub fn pco_err_to_py(pco: PcoError) -> PyErr {
   PyRuntimeError::new_err(format!("pco error: {}", pco))
 }
 
-#[pyclass(name = "IntMultSpec")]
+#[pyclass(name = "ModeSpec")]
 #[derive(Clone, Default)]
-pub struct PyIntMultSpec(IntMultSpec);
+pub struct PyModeSpec(ModeSpec);
 
-/// Specifies if pcodec should use an integer multiplier to compress data.
+/// Specifies how Pcodec should choose the mode.
 #[pymethods]
-impl PyIntMultSpec {
-  /// :returns: a IntMultSpec disabling integer multiplier detection.
+impl PyModeSpec {
+  /// :returns: a ModeSpec that automatically detects a good mode.
   #[staticmethod]
-  fn disabled() -> Self {
-    Self(IntMultSpec::Disabled)
+  fn auto() -> Self {
+    Self(ModeSpec::Auto)
   }
 
-  /// :returns: a IntMultSpec enabling integer multiplier detection. This
-  /// will automatically try to detect whether to use the mode and which `base`
-  /// to use.
+  /// :returns: a ModeSpec that always uses the simplest mode.
   #[staticmethod]
-  fn enabled() -> Self {
-    Self(IntMultSpec::Enabled)
+  fn classic() -> Self {
+    Self(ModeSpec::Classic)
   }
 
-  /// :returns: a IntMultSpec with a specific `base` to use for compression.
+  /// :returns: a ModeSpec that tries to use the IntMult mode with the given
+  /// base, if possible.
   #[staticmethod]
-  fn provided(base: u64) -> Self {
-    Self(IntMultSpec::Provided(base))
+  fn try_float_mult(base: f64) -> Self {
+    Self(ModeSpec::TryFloatMult(base))
   }
-}
-
-#[pyclass(name = "FloatMultSpec")]
-#[derive(Clone, Default)]
-pub struct PyFloatMultSpec(FloatMultSpec);
 
-/// Specifies if pcodec should use a floating point multiplier to compress
-/// data.
-#[pymethods]
-impl PyFloatMultSpec {
-  /// :returns: a FloatMultSpec disabling floating point multiplier detection.
-  #[staticmethod]
-  fn disabled() -> Self {
-    Self(FloatMultSpec::Disabled)
-  }
-
-  /// :returns: a FloatMultSpec enabling floating point multiplier detection.
-  /// This will automatically try to detect whether to use the mode and which
-  /// `base` to use.
-  #[staticmethod]
-  fn enabled() -> Self {
-    Self(FloatMultSpec::Enabled)
-  }
-
-  /// :returns: a FloatMultSpec with a specific `base` to use for compression.
-  #[staticmethod]
-  fn provided(base: f64) -> Self {
-    Self(FloatMultSpec::Provided(base))
-  }
-}
-
-#[pyclass(name = "FloatQuantSpec")]
-#[derive(Clone, Default)]
-pub struct PyFloatQuantSpec(FloatQuantSpec);
-
-/// Specifies if pcodec should use floating point quantization to compress
-/// data.
-#[pymethods]
-impl PyFloatQuantSpec {
-  /// :returns: a FloatQuantSpec disabling floating point quantization.
+  /// :returns: a ModeSpec that tries to use the IntMult mode with the given
+  /// base, if possible.
   #[staticmethod]
-  fn disabled() -> Self {
-    Self(FloatQuantSpec::Disabled)
+  fn try_float_quant(k: u32) -> Self {
+    Self(ModeSpec::TryFloatQuant(k))
   }
 
-  /// :returns: a FloatQuantSpec specifying that pcodec should use quantization
-  /// with a specific number of `bits``.
+  /// :returns: a ModeSpec that tries to use the IntMult mode with the given
+  /// base, if possible.
   #[staticmethod]
-  fn provided(bits: u32) -> Self {
-    Self(FloatQuantSpec::Provided(bits))
+  fn try_int_mult(base: u64) -> Self {
+    Self(ModeSpec::TryIntMult(base))
   }
 }
 
@@ -157,9 +119,7 @@ impl PyPagingSpec {
 pub struct PyChunkConfig {
   compression_level: usize,
   delta_encoding_order: Option<usize>,
-  int_mult_spec: PyIntMultSpec,
-  float_mult_spec: PyFloatMultSpec,
-  float_quant_spec: PyFloatQuantSpec,
+  mode_spec: PyModeSpec,
   paging_spec: PyPagingSpec,
 }
 
@@ -215,25 +175,19 @@ impl PyChunkConfig {
   #[pyo3(signature = (
     compression_level=pco::DEFAULT_COMPRESSION_LEVEL,
     delta_encoding_order=None,
-    int_mult_spec=PyIntMultSpec::default(),
-    float_mult_spec=PyFloatMultSpec::default(),
-    float_quant_spec=PyFloatQuantSpec::default(),
+    mode_spec=PyModeSpec::default(),
     paging_spec=PyPagingSpec::default(),
   ))]
   fn new(
     compression_level: usize,
     delta_encoding_order: Option<usize>,
-    int_mult_spec: PyIntMultSpec,
-    float_mult_spec: PyFloatMultSpec,
-    float_quant_spec: PyFloatQuantSpec,
+    mode_spec: PyModeSpec,
     paging_spec: PyPagingSpec,
   ) -> Self {
     Self {
       compression_level,
       delta_encoding_order,
-      int_mult_spec,
-      float_mult_spec,
-      float_quant_spec,
+      mode_spec,
       paging_spec,
     }
   }
@@ -246,9 +200,7 @@ impl TryFrom<&PyChunkConfig> for ChunkConfig {
     let res = ChunkConfig::default()
       .with_compression_level(py_config.compression_level)
       .with_delta_encoding_order(py_config.delta_encoding_order)
-      .with_int_mult_spec(py_config.int_mult_spec.0)
-      .with_float_mult_spec(py_config.float_mult_spec.0)
-      .with_float_quant_spec(py_config.float_quant_spec.0)
+      .with_mode_spec(py_config.mode_spec.0)
       .with_paging_spec(py_config.paging_spec.0.clone());
     Ok(res)
   }
@@ -275,9 +227,7 @@ pub enum DynTypedPyArrayDyn<'py> {
 fn pcodec(py: Python, m: &PyModule) -> PyResult<()> {
   m.add("__version__", env!("CARGO_PKG_VERSION"))?;
   m.add_class::<PyProgress>()?;
-  m.add_class::<PyIntMultSpec>()?;
-  m.add_class::<PyFloatMultSpec>()?;
-  m.add_class::<PyFloatQuantSpec>()?;
+  m.add_class::<PyModeSpec>()?;
   m.add_class::<PyPagingSpec>()?;
   m.add_class::<PyChunkConfig>()?;
   m.add(


Test patch:
diff --git a/pco/src/tests/compatibility.rs b/pco/src/tests/compatibility.rs
index 4a30a488..fd0bd76e 100644
--- a/pco/src/tests/compatibility.rs
+++ b/pco/src/tests/compatibility.rs
@@ -70,7 +70,7 @@ fn simple_write_if_version_matches<T: NumberLike>(
 mod tests {
   use crate::errors::PcoResult;
   use crate::tests::compatibility::{assert_compatible, simple_write_if_version_matches};
-  use crate::{ChunkConfig, FloatMultSpec, FloatQuantSpec};
+  use crate::{ChunkConfig, ModeSpec};
   use half::f16;
 
   #[test]
@@ -176,11 +176,9 @@ mod tests {
         }
       })
       .collect::<Vec<_>>();
-    let config = ChunkConfig::default()
-      .with_float_quant_spec(FloatQuantSpec::Provided(
-        f32::MANTISSA_DIGITS - f16::MANTISSA_DIGITS,
-      ))
-      .with_float_mult_spec(FloatMultSpec::Disabled);
+    let config = ChunkConfig::default().with_mode_spec(ModeSpec::TryFloatQuant(
+      f32::MANTISSA_DIGITS - f16::MANTISSA_DIGITS,
+    ));
     simple_write_if_version_matches::<f32>(version, name, &nums, &config)?;
     assert_compatible(version, name, &nums)?;
     Ok(())
diff --git a/pco/src/tests/recovery.rs b/pco/src/tests/recovery.rs
index 2fe33c82..b9e0538d 100644
--- a/pco/src/tests/recovery.rs
+++ b/pco/src/tests/recovery.rs
@@ -7,7 +7,7 @@ use crate::constants::Bitlen;
 use crate::data_types::NumberLike;
 use crate::errors::PcoResult;
 use crate::standalone::{simple_compress, simple_decompress, FileCompressor};
-use crate::{ChunkMeta, FloatMultSpec, FloatQuantSpec, Mode};
+use crate::{ChunkMeta, Mode, ModeSpec};
 
 fn compress_w_meta<T: NumberLike>(
   nums: &[T],
@@ -52,21 +52,11 @@ fn assert_recovers<T: NumberLike>(
   name: &str,
 ) -> PcoResult<()> {
   for delta_encoding_order in [0, 1, 7] {
-    for (fm, fq) in [
-      (
-        FloatMultSpec::Enabled,
-        FloatQuantSpec::Disabled,
-      ),
-      (
-        FloatMultSpec::Disabled,
-        FloatQuantSpec::Provided(14),
-      ),
-    ] {
+    for mode_spec in [ModeSpec::Classic, ModeSpec::Auto] {
       let config = ChunkConfig {
         compression_level,
         delta_encoding_order: Some(delta_encoding_order),
-        float_mult_spec: fm,
-        float_quant_spec: fq,
+        mode_spec,
         ..Default::default()
       };
       let compressed = simple_compress(nums, &config)?;
@@ -347,7 +337,7 @@ fn test_decimals() -> PcoResult<()> {
 fn test_f16_mult() -> PcoResult<()> {
   let nums = [100.1, 299.9, 200.0].repeat(100);
   let config = ChunkConfig {
-    float_mult_spec: FloatMultSpec::Provided(100.0),
+    mode_spec: ModeSpec::TryFloatMult(100.0),
     ..Default::default()
   };
   let (_, meta) = compress_w_meta(&nums, &config)?;
diff --git a/pco/src/tests/stability.rs b/pco/src/tests/stability.rs
index e955cf07..c801ecd6 100644
--- a/pco/src/tests/stability.rs
+++ b/pco/src/tests/stability.rs
@@ -3,12 +3,12 @@ use crate::chunk_meta::ChunkMeta;
 use crate::data_types::NumberLike;
 use crate::errors::{ErrorKind, PcoResult};
 use crate::standalone::{simple_decompress, FileCompressor};
-use crate::IntMultSpec;
+use crate::ModeSpec;
 
 fn assert_panic_safe<T: NumberLike>(nums: Vec<T>) -> PcoResult<ChunkMeta<T::L>> {
   let fc = FileCompressor::default();
   let config = ChunkConfig {
-    int_mult_spec: IntMultSpec::Disabled,
+    mode_spec: ModeSpec::Classic,
     delta_encoding_order: Some(0),
     ..Default::default()
   };
diff --git a/pco_python/test/test_standalone.py b/pco_python/test/test_standalone.py
index 647d1a8b..7c3bda9e 100644
--- a/pco_python/test/test_standalone.py
+++ b/pco_python/test/test_standalone.py
@@ -2,9 +2,7 @@
 import pytest
 from pcodec import (
     ChunkConfig,
-    FloatMultSpec,
-    FloatQuantSpec,
-    IntMultSpec,
+    ModeSpec,
     PagingSpec,
     standalone,
 )
@@ -19,27 +17,6 @@
 
 all_dtypes = ("f2", "f4", "f8", "i2", "i4", "i8", "u2", "u4", "u8")
 
-all_int_mult_specs = (
-    IntMultSpec.enabled(),
-    IntMultSpec.disabled(),
-    IntMultSpec.provided(2),
-    IntMultSpec.provided(10),
-)
-
-all_float_mult_specs = (
-    FloatMultSpec.enabled(),
-    FloatMultSpec.disabled(),
-    FloatMultSpec.provided(np.pi),
-    FloatMultSpec.provided(10 * np.pi),
-)
-
-all_float_quant_specs = (
-    FloatQuantSpec.disabled(),
-    FloatQuantSpec.provided(4),
-    FloatQuantSpec.provided(16),
-)
-
-
 @pytest.mark.parametrize("shape", all_shapes)
 @pytest.mark.parametrize("dtype", all_dtypes)
 def test_round_trip_decompress_into(shape, dtype):
@@ -130,9 +107,7 @@ def test_compression_options():
                 ChunkConfig(
                     compression_level=0,
                     delta_encoding_order=1,
-                    int_mult_spec=IntMultSpec.disabled(),
-                    float_mult_spec=FloatMultSpec.disabled(),
-                    float_quant_spec=FloatQuantSpec.disabled(),
+                    mode_spec=ModeSpec.classic(),
                     paging_spec=PagingSpec.equal_pages_up_to(77),
                 ),
             )
@@ -141,14 +116,14 @@ def test_compression_options():
     )
 
 
-@pytest.mark.parametrize("int_mult_spec", all_int_mult_specs)
-def test_compression_int_mult_spec_options(int_mult_spec):
+@pytest.mark.parametrize("mode_spec", [ModeSpec.auto(), ModeSpec.classic(), ModeSpec.try_int_mult(10)])
+def test_compression_int_mode_spec_options(mode_spec):
     data = (np.random.normal(size=100) * 1000).astype(np.int32)
 
     # check for errors
     compressed = standalone.simple_compress(
         data,
-        ChunkConfig(int_mult_spec=int_mult_spec),
+        ChunkConfig(mode_spec=mode_spec),
     )
 
     out = standalone.simple_decompress(compressed)
@@ -157,33 +132,14 @@ def test_compression_int_mult_spec_options(int_mult_spec):
     np.testing.assert_array_equal(data, out)
 
 
-@pytest.mark.parametrize("float_mult_spec", all_float_mult_specs)
-def test_compression_float_mult_spec_options(float_mult_spec):
+@pytest.mark.parametrize("mode_spec", [ModeSpec.auto(), ModeSpec.classic(), ModeSpec.try_float_mult(10.0), ModeSpec.try_float_quant(4)])
+def test_compression_float_mode_spec_options(mode_spec):
     data = (np.random.normal(size=100) * 1000).astype(np.int32) * np.pi
 
     # check for errors
     compressed = standalone.simple_compress(
         data,
-        ChunkConfig(float_mult_spec=float_mult_spec),
-    )
-
-    out = standalone.simple_decompress(compressed)
-
-    # check that the decompressed data is correct
-    np.testing.assert_array_equal(data, out)
-
-
-@pytest.mark.parametrize("float_quant_spec", all_float_quant_specs)
-def test_compression_float_quant_spec_options(float_quant_spec):
-    data = np.random.normal(size=100).astype(np.float32).astype(np.float64)
-
-    # check for errors
-    compressed = standalone.simple_compress(
-        data,
-        ChunkConfig(
-            float_mult_spec=FloatMultSpec.disabled(),
-            float_quant_spec=float_quant_spec,
-        ),
+        ChunkConfig(mode_spec=mode_spec),
     )
 
     out = standalone.simple_decompress(compressed)