From bcf60a6458c5bf473f7deb41ca6a996b076e7db9 Mon Sep 17 00:00:00 2001 From: Jeremy Wayland Date: Fri, 9 Oct 2026 13:48:42 +0000 Subject: [PATCH] Fix post-0.4.0 release issues - core: JitterSimulator::transform before fit is an error instead of applying zero noise (which scored as perfect jitter stability). - model: README example passes Some(1) for the Option positive class index; from_file errors name the parameter, not only the CLI flag. - cli: add --version. - pypasteur: expose __version__ (listed in __all__ so maturin's `import *` re-export picks it up). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++++++ bindings/python-bindings/src/lib.rs | 10 +++++++- bindings/python-bindings/tests/smoke.py | 12 ++++++++++ cli/src/args.rs | 1 + cli/tests/cli_local.rs | 14 +++++++++++ core/src/simulate/jitter.rs | 31 +++++++++++++++++++++---- model/README.md | 2 +- model/src/contract.rs | 12 +++++----- 8 files changed, 85 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7714a3..e42c326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +### Fixed + +- `JitterSimulator.transform` before `fit` now raises instead of returning the + data unchanged, which would have scored as perfect jitter stability. This + affects the Rust `JitterSimulator` and `pypasteur.JitterSimulator`. +- The `pasteur-model` README example passes `Some(1)` for the positive class + index, matching the 0.4.0 `Option` signature. +- Positive-class-index errors from `OnnxModel::from_file` name the parameter + as well as the CLI flag. + +### Added + +- `pasteur-cli --version`. +- `pypasteur.__version__`. + ## 0.4.0 ### Added diff --git a/bindings/python-bindings/src/lib.rs b/bindings/python-bindings/src/lib.rs index 8be1209..473fa38 100644 --- a/bindings/python-bindings/src/lib.rs +++ b/bindings/python-bindings/src/lib.rs @@ -14,5 +14,13 @@ pub fn register_module(m: &Bound<'_, PyModule>) -> PyResult<()> { #[pymodule] fn pypasteur(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { - register_module(m) + register_module(m)?; + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + // maturin's generated `__init__.py` re-exports with `import *`, which + // skips dunder names unless they are listed in `__all__`. + m.add( + "__all__", + ["BlackoutSimulator", "JitterSimulator", "__version__"], + )?; + Ok(()) } diff --git a/bindings/python-bindings/tests/smoke.py b/bindings/python-bindings/tests/smoke.py index 6257c54..45c472c 100644 --- a/bindings/python-bindings/tests/smoke.py +++ b/bindings/python-bindings/tests/smoke.py @@ -1,9 +1,13 @@ """Smoke test for an installed pypasteur wheel. Run: python tests/smoke.py""" +import importlib.metadata + import polars as pl import pypasteur +assert pypasteur.__version__ == importlib.metadata.version("pypasteur"), pypasteur.__version__ + N = 20 df = pl.DataFrame({"tsh": [float(i) for i in range(N)], "age": [40.0 + i for i in range(N)]}) @@ -22,4 +26,12 @@ assert not out["tsh"].equals(df["tsh"]), "jitter left tsh unchanged" assert out["age"].equals(df["age"]), "jitter touched a column it shouldn't" +unfitted = pypasteur.JitterSimulator("tsh", scale=1.0, random_state=42) +try: + unfitted.transform(df) +except ValueError as e: + assert "fit before transform" in str(e), e +else: + raise AssertionError("transform before fit should raise") + print("pypasteur smoke test OK") diff --git a/cli/src/args.rs b/cli/src/args.rs index daa619e..23e293e 100644 --- a/cli/src/args.rs +++ b/cli/src/args.rs @@ -6,6 +6,7 @@ use pasteur_core::TaskType; #[derive(Parser)] #[command( name = "pasteur-cli", + version, about = "Local Pasteur simulation, evaluation, and dataset-card generation (no Hub I/O)" )] pub struct Cli { diff --git a/cli/tests/cli_local.rs b/cli/tests/cli_local.rs index 6a2baef..2e0a57b 100644 --- a/cli/tests/cli_local.rs +++ b/cli/tests/cli_local.rs @@ -897,3 +897,17 @@ fn regression_targets_must_cover_the_cohort() { ); let _ = fs::remove_dir_all(&root); } + +#[test] +fn version_flag_prints_crate_version() { + let out = Command::new(pasteur_cli()) + .arg("--version") + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + stdout.trim(), + format!("pasteur-cli {}", env!("CARGO_PKG_VERSION")) + ); +} diff --git a/core/src/simulate/jitter.rs b/core/src/simulate/jitter.rs index 7acdb0d..b1b87f4 100644 --- a/core/src/simulate/jitter.rs +++ b/core/src/simulate/jitter.rs @@ -7,14 +7,16 @@ use super::Simulator; pub struct JitterSimulator { config: JitterConfig, - feature_std: f64, + /// Set by `fit`. `None` means unfitted, so `transform` refuses to run + /// instead of applying zero noise and reporting perfect stability. + feature_std: Option, } impl JitterSimulator { pub fn new(config: JitterConfig) -> Self { Self { config, - feature_std: 0.0, + feature_std: None, } } } @@ -22,13 +24,19 @@ impl JitterSimulator { impl Simulator for JitterSimulator { fn fit(&mut self, df: &DataFrame) -> Result<(), CoreError> { let series = df.column(&self.config.feature)?; - self.feature_std = series.as_materialized_series().std(1).unwrap_or(0.0); + self.feature_std = Some(series.as_materialized_series().std(1).unwrap_or(0.0)); Ok(()) } fn transform(&self, df: &DataFrame) -> Result { + let Some(feature_std) = self.feature_std else { + return Err(CoreError::InvalidConfig(format!( + "JitterSimulator for `{}` must be fit before transform", + self.config.feature + ))); + }; let n_rows = df.height(); - let scale = self.config.scale * self.feature_std; + let scale = self.config.scale * feature_std; // Deterministic per-row SHA256-seeded draw (see `stable_normal_sample` // in rng.rs) instead of a single shared RNG stream — matches jitter.py's @@ -87,4 +95,19 @@ mod tests { assert_ne!(a.get(i), orig.get(i)); } } + + #[test] + fn jitter_transform_before_fit_is_an_error() { + let df = df!["val" => [1.0, 2.0, 3.0]].unwrap(); + let sim = JitterSimulator::new(JitterConfig { + feature: "val".to_string(), + scale: 1.0, + random_state: Some(0), + }); + let err = sim.transform(&df).expect_err("unfitted jitter must fail"); + assert!( + err.to_string().contains("must be fit before transform"), + "{err}" + ); + } } diff --git a/model/README.md b/model/README.md index 7874336..a9f7695 100644 --- a/model/README.md +++ b/model/README.md @@ -9,7 +9,7 @@ let model = OnnxModel::from_file( "model.onnx".as_ref(), "input", // ONNX input name feature_order, // Vec, the column order fed to the model - 1, // positive class index + Some(1), // positive class index: binary only (None → 1); None for other tasks None, // null fill: None → metadata.json absent_sentinel → NaN None, // contract path: None → {model_dir}/metadata.json )?; diff --git a/model/src/contract.rs b/model/src/contract.rs index 1aaaaa2..dca10b7 100644 --- a/model/src/contract.rs +++ b/model/src/contract.rs @@ -128,8 +128,8 @@ fn resolve_binary_spec( if let Some(n) = classes.as_ref().map(Vec::len).or(width) { if positive_class_index >= n { return Err(CoreError::InvalidConfig(format!( - "--positive-class-index {positive_class_index} is out of range for a model \ - with {n} output columns" + "positive class index {positive_class_index} (--positive-class-index) is out \ + of range for a model with {n} output columns" ))); } } @@ -152,8 +152,8 @@ fn resolve_multi_spec( ) -> Result { if positive_class_index.is_some() { return Err(CoreError::InvalidConfig(format!( - "--positive-class-index selects one column of a binary model; this is a {task} \ - model and every column is scored" + "a positive class index (--positive-class-index) selects one column of a binary \ + model; this is a {task} model and every column is scored" ))); } let Some(classes) = classes.filter(|c| !c.is_empty()) else { @@ -217,8 +217,8 @@ fn resolve_regression_spec( let invalid = |msg: String| Err(CoreError::InvalidConfig(msg)); if positive_class_index.is_some() { return invalid( - "--positive-class-index selects one column of a binary model; a regression \ - model has one output, its predicted value" + "a positive class index (--positive-class-index) selects one column of a binary \ + model; a regression model has one output, its predicted value" .to_string(), ); }