fix(gaps): G1 — re-enable Python bindings (PyO3 0.25, Python 3.14)

- Upgrade workspace pyo3 0.24 → 0.25 and numpy 0.24 → 0.25 for Python 3.14 support
- rtx-sklearn-py: replace pinned pyo3 0.20 / pyo3-asyncio 0.20 / numpy 0.20 with workspace versions;
  remove broken pyo3-asyncio async feature; update pyo3-build-config to 0.24
- rtx-bindings: uncomment pyo3/numpy/ndarray optional deps; enable python feature in Cargo.toml
- Migrate rtx-bindings python/ to PyO3 0.25 Bound API:
  &PyAny → Bound<'py, PyAny>, downcast/extract on Bound types, remove rtx_runtime import,
  remove InferenceError arm (variant not in enum), fix py_shape_to_shape signature
- Migrate rtx-sklearn-py src/ to PyO3 0.25 Bound API:
  #[pymodule] fn now takes &Bound<'_, PyModule>, &PyDict → &Bound<'py, PyDict>,
  from_array returns Bound (unbind instead of to_owned), PyTuple::new now fallible,
  use numpy::ndarray (0.16) over workspace ndarray (0.15) to resolve trait mismatches

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-26 13:55:09 +00:00
co-authored by Claude Sonnet 4.6
parent 228137555f
commit 448c0a0be5
14 changed files with 310 additions and 355 deletions
@@ -2,7 +2,7 @@
Simplified sklearn-compatible classifier wrappers that compile with current API
*/
use ndarray::{Array1, Array2};
use numpy::ndarray::{Array1, Array2};
use numpy::{PyArray1, PyArray2, PyReadonlyArrayDyn};
use pyo3::prelude::*;
use pyo3::types::PyDict;
@@ -196,7 +196,7 @@ impl DecisionTreeClassifier {
let predictions: Array1<i64> =
Array1::from_iter(predictions_data.iter().map(|&x| x.round() as i64));
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, predictions)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, predictions)?.unbind()))
}
/// Predict class probabilities
@@ -251,7 +251,7 @@ impl DecisionTreeClassifier {
))
})?;
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, probabilities)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, probabilities)?.unbind()))
}
/// Score the model on test data
@@ -259,8 +259,11 @@ impl DecisionTreeClassifier {
let predictions = self.predict(x)?;
Python::with_gil(|py| {
let pred_array = predictions.as_ref(py).readonly();
let pred_view = ArrayConverter::to_dyn_view(&pred_array);
use numpy::PyArrayMethods;
let pred_bound = predictions.bind(py);
let pred_array = pred_bound.readonly();
// Convert Ix1 readonly array view to dynamic view directly
let pred_view = pred_array.as_array();
let y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
if pred_view.len() != y_view.len() {
@@ -281,26 +284,21 @@ impl DecisionTreeClassifier {
/// Get model parameters
fn get_params(&self) -> PyResult<HashMap<String, PyObject>> {
use pyo3::IntoPyObjectExt;
Python::with_gil(|py| {
let mut params = HashMap::new();
params.insert("criterion".to_string(), self.criterion.to_object(py));
params.insert("max_depth".to_string(), self.max_depth.to_object(py));
params.insert(
"min_samples_split".to_string(),
self.min_samples_split.to_object(py),
);
params.insert(
"min_samples_leaf".to_string(),
self.min_samples_leaf.to_object(py),
);
params.insert("random_state".to_string(), self.random_state.to_object(py));
params.insert("device".to_string(), self.device.to_string().to_object(py));
params.insert("criterion".to_string(), self.criterion.clone().into_py_any(py)?);
params.insert("max_depth".to_string(), self.max_depth.into_py_any(py)?);
params.insert("min_samples_split".to_string(), self.min_samples_split.into_py_any(py)?);
params.insert("min_samples_leaf".to_string(), self.min_samples_leaf.into_py_any(py)?);
params.insert("random_state".to_string(), self.random_state.into_py_any(py)?);
params.insert("device".to_string(), self.device.to_string().into_py_any(py)?);
Ok(params)
})
}
/// Set model parameters
fn set_params(&mut self, params: &PyDict) -> PyResult<()> {
fn set_params<'py>(&mut self, params: &Bound<'py, PyDict>) -> PyResult<()> {
let valid_params = vec![
"criterion",
"max_depth",
@@ -343,7 +341,7 @@ impl DecisionTreeClassifier {
match &self.classes {
Some(classes) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, classes.clone())?.to_owned(),
ArrayConverter::from_array1(py, classes.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -375,7 +373,7 @@ impl DecisionTreeClassifier {
Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, importances)?.to_owned(),
ArrayConverter::from_array1(py, importances)?.unbind(),
))
})
}
@@ -2,7 +2,7 @@
Simplified sklearn-compatible clustering wrappers
*/
use ndarray::{Array1, Array2, Axis};
use numpy::ndarray::{Array1, Array2, Axis};
use numpy::{PyArray1, PyArray2, PyReadonlyArrayDyn};
use pyo3::prelude::*;
use rand::{Rng, SeedableRng};
@@ -190,7 +190,7 @@ impl KMeans {
labels[sample_idx] = best_cluster as i64;
}
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, labels)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, labels)?.unbind()))
}
fn fit_predict(&mut self, x: PyReadonlyArrayDyn<f64>) -> PyResult<Py<PyArray1<i64>>> {
@@ -198,7 +198,7 @@ impl KMeans {
match &self.labels_ {
Some(labels) => Python::with_gil(|py| {
Ok(ArrayConverter::from_array1(py, labels.clone())?.to_owned())
Ok(ArrayConverter::from_array1(py, labels.clone())?.unbind())
}),
None => Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
"fit_predict failed: no labels available",
@@ -227,16 +227,17 @@ impl KMeans {
}
}
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, distances)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, distances)?.unbind()))
}
fn get_params(&self) -> PyResult<HashMap<String, PyObject>> {
use pyo3::IntoPyObjectExt;
Python::with_gil(|py| {
let mut params = HashMap::new();
params.insert("n_clusters".to_string(), self.n_clusters.to_object(py));
params.insert("random_state".to_string(), self.random_state.to_object(py));
params.insert("max_iter".to_string(), self.max_iter.to_object(py));
params.insert("device".to_string(), self.device.to_string().to_object(py));
params.insert("n_clusters".to_string(), self.n_clusters.into_py_any(py)?);
params.insert("random_state".to_string(), self.random_state.into_py_any(py)?);
params.insert("max_iter".to_string(), self.max_iter.into_py_any(py)?);
params.insert("device".to_string(), self.device.to_string().into_py_any(py)?);
Ok(params)
})
}
@@ -246,7 +247,7 @@ impl KMeans {
match &self.cluster_centers_ {
Some(centers) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array2(py, centers.clone())?.to_owned(),
ArrayConverter::from_array2(py, centers.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -258,7 +259,7 @@ impl KMeans {
match &self.labels_ {
Some(labels) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, labels.clone())?.to_owned(),
ArrayConverter::from_array1(py, labels.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -299,12 +300,12 @@ impl DBSCAN {
fn fit_predict(&mut self, x: PyReadonlyArrayDyn<f64>) -> PyResult<Py<PyArray1<i64>>> {
let x_view = ArrayConverter::to_array_view2(&x).map_err(|e| PyErr::from(e))?;
// Simplified: assign random labels for demonstration
// Simplified: assign zero labels for demonstration
let labels = Array1::zeros(x_view.shape()[0]);
self.labels_ = Some(labels.clone());
self.is_fitted = true;
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, labels)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, labels)?.unbind()))
}
#[getter]
@@ -312,7 +313,7 @@ impl DBSCAN {
match &self.labels_ {
Some(labels) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, labels.clone())?.to_owned(),
ArrayConverter::from_array1(py, labels.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -2,7 +2,7 @@
Simplified sklearn-compatible model selection wrappers
*/
use ndarray::{Array1, Array2, Axis};
use numpy::ndarray::{Array1, Array2, Axis};
use numpy::{PyArray1, PyReadonlyArrayDyn};
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyTuple};
@@ -27,7 +27,7 @@ pub struct GridSearchCV {
impl GridSearchCV {
#[new]
#[pyo3(signature = (estimator, param_grid, cv=5))]
fn new(estimator: PyObject, param_grid: &PyDict, cv: Option<usize>) -> PyResult<Self> {
fn new<'py>(estimator: PyObject, param_grid: &Bound<'py, PyDict>, cv: Option<usize>) -> PyResult<Self> {
// Convert param_grid from PyDict to HashMap (simplified)
let param_grid_map = HashMap::new(); // Placeholder
@@ -56,7 +56,7 @@ impl GridSearchCV {
Python::with_gil(|py| {
let x_view = ArrayConverter::to_array_view2(&x).map_err(|e| PyErr::from(e))?;
let dummy_preds = Array1::<f64>::zeros(x_view.shape()[0]);
Ok(ArrayConverter::from_array1(py, dummy_preds)?.to_object(py))
Ok(ArrayConverter::from_array1(py, dummy_preds)?.into_any().unbind())
})
}
@@ -66,8 +66,15 @@ impl GridSearchCV {
}
#[getter]
fn best_params_(&self) -> Option<HashMap<String, PyObject>> {
self.best_params_.clone()
fn best_params_(&self) -> PyResult<Option<PyObject>> {
match &self.best_params_ {
Some(_params) => Python::with_gil(|py| {
let dict = PyDict::new(py);
// Return empty dict for the placeholder
Ok(Some(dict.into_any().unbind()))
}),
None => Ok(None),
}
}
}
@@ -88,7 +95,7 @@ pub fn cross_val_score(
// Simplified - return dummy cross-validation scores
let scores = Array1::from_vec(vec![0.9, 0.92, 0.88, 0.91, 0.89][..cv_folds].to_vec());
Ok(ArrayConverter::from_array1(py, scores)?.to_owned())
Ok(ArrayConverter::from_array1(py, scores)?.unbind())
}
/// Simplified sklearn-compatible train_test_split function
@@ -154,21 +161,16 @@ pub fn train_test_split(
let y_train_1d = Array1::from_vec(y_train.iter().copied().collect());
let y_test_1d = Array1::from_vec(y_test.iter().copied().collect());
let x_train_py = ArrayConverter::from_array2(py, x_train_2d)?;
let x_test_py = ArrayConverter::from_array2(py, x_test_2d)?;
let y_train_py = ArrayConverter::from_array1(py, y_train_1d)?;
let y_test_py = ArrayConverter::from_array1(py, y_test_1d)?;
let x_train_py = ArrayConverter::from_array2(py, x_train_2d)?.into_any().unbind();
let x_test_py = ArrayConverter::from_array2(py, x_test_2d)?.into_any().unbind();
let y_train_py = ArrayConverter::from_array1(py, y_train_1d)?.into_any().unbind();
let y_test_py = ArrayConverter::from_array1(py, y_test_1d)?.into_any().unbind();
// Return tuple
let result = PyTuple::new(
py,
&[
x_train_py.to_object(py),
x_test_py.to_object(py),
y_train_py.to_object(py),
y_test_py.to_object(py),
],
);
&[x_train_py, x_test_py, y_train_py, y_test_py],
)?;
Ok(result.to_object(py))
Ok(result.into_any().unbind())
}
@@ -2,7 +2,7 @@
Simplified sklearn-compatible preprocessing wrappers
*/
use ndarray::{Array1, Array2, Axis};
use numpy::ndarray::{Array1, Array2, Axis};
use numpy::{PyArray1, PyArray2, PyReadonlyArrayDyn};
use pyo3::prelude::*;
@@ -94,7 +94,7 @@ impl StandardScaler {
))
},
)?;
Ok(ArrayConverter::from_array2(py, transformed_2d)?.to_owned())
Ok(ArrayConverter::from_array2(py, transformed_2d)?.unbind())
})
}
@@ -144,7 +144,7 @@ impl StandardScaler {
))
},
)?;
Ok(ArrayConverter::from_array2(py, transformed_2d)?.to_owned())
Ok(ArrayConverter::from_array2(py, transformed_2d)?.unbind())
})
}
@@ -176,7 +176,7 @@ impl StandardScaler {
e
))
})?;
Ok(ArrayConverter::from_array2(py, inv_transformed_2d)?.to_owned())
Ok(ArrayConverter::from_array2(py, inv_transformed_2d)?.unbind())
})
}
@@ -185,7 +185,7 @@ impl StandardScaler {
match &self.mean_ {
Some(mean) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, mean.clone())?.to_owned(),
ArrayConverter::from_array1(py, mean.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -197,7 +197,7 @@ impl StandardScaler {
match &self.scale_ {
Some(scale) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, scale.clone())?.to_owned(),
ArrayConverter::from_array1(py, scale.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -282,7 +282,7 @@ impl MinMaxScaler {
}
}
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, transformed)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, transformed)?.unbind()))
}
fn fit_transform(&mut self, x: PyReadonlyArrayDyn<f64>) -> PyResult<Py<PyArray2<f64>>> {
@@ -320,7 +320,7 @@ impl MinMaxScaler {
}
}
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, transformed)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array2(py, transformed)?.unbind()))
}
#[getter]
@@ -328,7 +328,7 @@ impl MinMaxScaler {
match &self.data_min_ {
Some(data_min) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, data_min.clone())?.to_owned(),
ArrayConverter::from_array1(py, data_min.clone())?.unbind(),
))
}),
None => Ok(None),
@@ -355,20 +355,20 @@ impl OneHotEncoder {
})
}
fn fit(&mut self, x: &PyAny) -> PyResult<()> {
fn fit<'py>(&mut self, _x: &Bound<'py, PyAny>) -> PyResult<()> {
self.is_fitted = true;
Ok(())
}
fn transform(&self, x: &PyAny) -> PyResult<Py<PyArray2<f64>>> {
fn transform<'py>(&self, _x: &Bound<'py, PyAny>) -> PyResult<Py<PyArray2<f64>>> {
// Placeholder - return identity transform
Python::with_gil(|py| {
let dummy = Array2::eye(2);
Ok(ArrayConverter::from_array2(py, dummy)?.to_owned())
Ok(ArrayConverter::from_array2(py, dummy)?.unbind())
})
}
fn fit_transform(&mut self, x: &PyAny) -> PyResult<Py<PyArray2<f64>>> {
fn fit_transform<'py>(&mut self, x: &Bound<'py, PyAny>) -> PyResult<Py<PyArray2<f64>>> {
self.fit(x)?;
self.transform(x)
}
@@ -392,20 +392,20 @@ impl LabelEncoder {
})
}
fn fit(&mut self, y: &PyAny) -> PyResult<()> {
fn fit<'py>(&mut self, _y: &Bound<'py, PyAny>) -> PyResult<()> {
self.is_fitted = true;
Ok(())
}
fn transform(&self, y: &PyAny) -> PyResult<Py<PyArray1<i64>>> {
fn transform<'py>(&self, _y: &Bound<'py, PyAny>) -> PyResult<Py<PyArray1<i64>>> {
// Placeholder - return dummy labels
Python::with_gil(|py| {
let dummy = Array1::zeros(10);
Ok(ArrayConverter::from_array1(py, dummy)?.to_owned())
Ok(ArrayConverter::from_array1(py, dummy)?.unbind())
})
}
fn fit_transform(&mut self, y: &PyAny) -> PyResult<Py<PyArray1<i64>>> {
fn fit_transform<'py>(&mut self, y: &Bound<'py, PyAny>) -> PyResult<Py<PyArray1<i64>>> {
self.fit(y)?;
self.transform(y)
}
@@ -2,8 +2,8 @@
Simplified sklearn-compatible regressor wrappers
*/
use ndarray::Array1;
use numpy::{PyArray1, PyReadonlyArrayDyn};
use numpy::ndarray::Array1;
use numpy::{PyArray1, PyReadonlyArrayDyn, PyArrayMethods};
use pyo3::prelude::*;
use std::collections::HashMap;
@@ -44,7 +44,7 @@ impl LinearRegression {
ArrayConverter::validate_fit_input(&x, Some(&y)).map_err(|e| PyErr::from(e))?;
let x_view = ArrayConverter::to_array_view2(&x).map_err(|e| PyErr::from(e))?;
let y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
let _y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
self.n_features_in = Some(x_view.shape()[1]);
@@ -81,15 +81,18 @@ impl LinearRegression {
predictions[i] = sum + intercept;
}
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, predictions)?.to_owned()))
Python::with_gil(|py| Ok(ArrayConverter::from_array1(py, predictions)?.unbind()))
}
fn score(&self, x: PyReadonlyArrayDyn<f64>, y: PyReadonlyArrayDyn<f64>) -> PyResult<f64> {
let predictions = self.predict(x)?;
Python::with_gil(|py| {
let pred_array = predictions.as_ref(py).readonly();
let pred_view = ArrayConverter::to_dyn_view(&pred_array);
use numpy::PyArrayMethods;
let pred_bound = predictions.bind(py);
let pred_array = pred_bound.readonly();
// Convert Ix1 readonly array view to ndarray view directly
let pred_view = pred_array.as_array();
let y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
let y_mean = y_view.iter().sum::<f64>() / y_view.len() as f64;
@@ -97,7 +100,7 @@ impl LinearRegression {
let ss_res: f64 = pred_view
.iter()
.zip(y_view.iter())
.map(|(pred, actual)| (actual - pred).powi(2))
.map(|(pred, actual)| (actual - pred) as f64 * (actual - pred) as f64)
.sum();
let ss_tot: f64 = y_view.iter().map(|actual| (actual - y_mean).powi(2)).sum();
@@ -107,13 +110,14 @@ impl LinearRegression {
}
fn get_params(&self) -> PyResult<HashMap<String, PyObject>> {
use pyo3::IntoPyObjectExt;
Python::with_gil(|py| {
let mut params = HashMap::new();
params.insert(
"fit_intercept".to_string(),
self.fit_intercept.to_object(py),
self.fit_intercept.into_py_any(py)?,
);
params.insert("device".to_string(), self.device.to_string().to_object(py));
params.insert("device".to_string(), self.device.to_string().into_py_any(py)?);
Ok(params)
})
}
@@ -123,7 +127,7 @@ impl LinearRegression {
match &self.coef_ {
Some(coef) => Python::with_gil(|py| {
Ok(Some(
ArrayConverter::from_array1(py, coef.clone())?.to_owned(),
ArrayConverter::from_array1(py, coef.clone())?.unbind(),
))
}),
None => Ok(None),