Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>` 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
Expand Down
10 changes: 9 additions & 1 deletion bindings/python-bindings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
12 changes: 12 additions & 0 deletions bindings/python-bindings/tests/smoke.py
Original file line number Diff line number Diff line change
@@ -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)]})

Expand All @@ -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")
1 change: 1 addition & 0 deletions cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions cli/tests/cli_local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
);
}
31 changes: 27 additions & 4 deletions core/src/simulate/jitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,36 @@ 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<f64>,
}

impl JitterSimulator {
pub fn new(config: JitterConfig) -> Self {
Self {
config,
feature_std: 0.0,
feature_std: None,
}
}
}

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<DataFrame, CoreError> {
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
Expand Down Expand Up @@ -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}"
);
}
}
2 changes: 1 addition & 1 deletion model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ let model = OnnxModel::from_file(
"model.onnx".as_ref(),
"input", // ONNX input name
feature_order, // Vec<String>, 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
)?;
Expand Down
12 changes: 6 additions & 6 deletions model/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)));
}
}
Expand All @@ -152,8 +152,8 @@ fn resolve_multi_spec(
) -> Result<OutputSpec, CoreError> {
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 {
Expand Down Expand Up @@ -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(),
);
}
Expand Down
Loading