Skip to main content

ocas_py/
dual.rs

1//! Python bindings for hyper-dual numbers (forward automatic differentiation).
2//!
3//! Wraps [`ocas_domain::dual`] restricted to [`Rational`](ocas_domain::Rational)
4//! coefficients. Exposes [`DualShape`] (a first-order derivative layout) and
5//! [`HyperDual`] (a value plus its partial derivatives). Arithmetic operators
6//! propagate derivatives exactly via rational arithmetic.
7//!
8//! Only polynomial/rational arithmetic is supported (`+`, `-`, `*`, `/`,
9//! unary `-`). Transcendental functions (sin/exp/log) are out of scope; use
10//! repeated multiplication for integer powers.
11//!
12//! ```python
13//! from ocas import DualShape, HyperDual
14//!
15//! shape = DualShape.first_order(2)   # track ∂/∂x₀ and ∂/∂x₁
16//! # f(x, y) = x * y at point (3, 5).
17//! x = HyperDual.variable(shape, 0, 3)
18//! y = HyperDual.variable(shape, 1, 5)
19//! f = x * y
20//! print(f.value())         # 15
21//! print(f.deriv(0))        # 5  (∂f/∂x = y)
22//! print(f.deriv(1))        # 3  (∂f/∂y = x)
23//! ```
24
25use std::sync::Arc;
26
27use ocas_domain::Rational;
28use ocas_domain::dual::{DualShape, HyperDual, new_first_order};
29use pyo3::exceptions::{PyTypeError, PyValueError};
30use pyo3::prelude::*;
31
32/// Parse a Python `int` or `(num, denom)` tuple into a [`Rational`].
33fn py_to_rational(obj: &Bound<'_, PyAny>) -> PyResult<Rational> {
34    if let Ok(n) = obj.extract::<i64>() {
35        Ok(Rational::new(n, 1))
36    } else if let Ok((num, den)) = obj.extract::<(i64, i64)>() {
37        if den == 0 {
38            Err(PyValueError::new_err("rational denominator cannot be zero"))
39        } else {
40            Ok(Rational::new(num, den))
41        }
42    } else {
43        Err(PyTypeError::new_err("expected int or (num, denom) tuple"))
44    }
45}
46
47/// Format a [`Rational`] as a Python-friendly string `"num/den"` (or `"num"`
48/// when the denominator is 1).
49fn rational_to_string(r: &Rational) -> String {
50    let n = r.numer().to_i64().unwrap_or(0);
51    let d = r.denom().to_i64().unwrap_or(0);
52    if d == 1 {
53        n.to_string()
54    } else {
55        format!("{n}/{d}")
56    }
57}
58
59/// A first-order dual-number shape: declares how many differentiation
60/// variables are tracked. Cheap to share; build once via
61/// [`first_order`][PyDualShape::first_order] and reuse.
62#[pyclass(name = "DualShape")]
63pub struct PyDualShape {
64    shape: Arc<DualShape>,
65}
66
67#[pymethods]
68impl PyDualShape {
69    /// Build a first-order shape tracking one derivative per variable for
70    /// `n_vars` variables.
71    #[staticmethod]
72    fn first_order(n_vars: usize) -> PyResult<Self> {
73        if n_vars == 0 {
74            return Err(PyValueError::new_err("n_vars must be >= 1"));
75        }
76        Ok(PyDualShape {
77            shape: new_first_order::<Rational>(n_vars),
78        })
79    }
80
81    /// Number of differentiation variables.
82    #[getter]
83    fn n_vars(&self) -> usize {
84        self.shape.n_vars()
85    }
86
87    /// Total number of components (value + derivative slots).
88    #[getter]
89    fn n_components(&self) -> usize {
90        self.shape.n_components()
91    }
92
93    fn __repr__(&self) -> String {
94        format!(
95            "DualShape(n_vars={}, n_components={})",
96            self.n_vars(),
97            self.n_components()
98        )
99    }
100}
101
102/// A hyper-dual number over the rationals: a value plus its partial
103/// derivatives with respect to the variables of a [`DualShape`].
104///
105/// Construct via [`variable`][PyHyperDual::variable] or
106/// [`constant`][PyHyperDual::constant]; combine with arithmetic operators.
107#[pyclass(name = "HyperDual")]
108pub struct PyHyperDual {
109    inner: HyperDual<Rational>,
110    shape: Arc<DualShape>,
111}
112
113impl PyHyperDual {
114    fn new(inner: HyperDual<Rational>) -> Self {
115        let shape = inner.shape().clone();
116        PyHyperDual { inner, shape }
117    }
118}
119
120#[pymethods]
121impl PyHyperDual {
122    /// Create an independent variable `x_i = value` (derivative 1 w.r.t. `i`).
123    #[staticmethod]
124    fn variable(shape: &PyDualShape, i: usize, value: &Bound<'_, PyAny>) -> PyResult<Self> {
125        if i >= shape.shape.n_vars() {
126            return Err(PyValueError::new_err(format!(
127                "variable index {i} out of range (n_vars = {})",
128                shape.shape.n_vars()
129            )));
130        }
131        let v = py_to_rational(value)?;
132        Ok(PyHyperDual::new(HyperDual::variable(&shape.shape, i, v)))
133    }
134
135    /// Create a constant `value` (all derivatives zero).
136    #[staticmethod]
137    fn constant(shape: &PyDualShape, value: &Bound<'_, PyAny>) -> PyResult<Self> {
138        let v = py_to_rational(value)?;
139        Ok(PyHyperDual::new(HyperDual::constant(&shape.shape, v)))
140    }
141
142    /// The scalar value component, as a string (`"n"` or `"n/d"`).
143    fn value(&self) -> String {
144        rational_to_string(self.inner.value())
145    }
146
147    /// The derivative w.r.t. variable `i` as a string, or `None` if the
148    /// shape has no first-order component for `i`.
149    fn deriv(&self, i: usize) -> Option<String> {
150        self.inner.deriv(i).map(rational_to_string)
151    }
152
153    /// The number of differentiation variables.
154    #[getter]
155    fn n_vars(&self) -> usize {
156        self.shape.n_vars()
157    }
158
159    fn __repr__(&self) -> String {
160        format!(
161            "HyperDual(value={}, n_vars={})",
162            self.value(),
163            self.n_vars()
164        )
165    }
166
167    fn __add__(&self, other: &PyHyperDual) -> PyResult<PyHyperDual> {
168        if !Arc::ptr_eq(&self.shape, &other.shape) {
169            return Err(PyValueError::new_err(
170                "cannot add HyperDuals with different shapes",
171            ));
172        }
173        Ok(PyHyperDual::new(self.inner.clone() + other.inner.clone()))
174    }
175
176    fn __sub__(&self, other: &PyHyperDual) -> PyResult<PyHyperDual> {
177        if !Arc::ptr_eq(&self.shape, &other.shape) {
178            return Err(PyValueError::new_err(
179                "cannot subtract HyperDuals with different shapes",
180            ));
181        }
182        Ok(PyHyperDual::new(self.inner.clone() - other.inner.clone()))
183    }
184
185    fn __mul__(&self, other: &PyHyperDual) -> PyResult<PyHyperDual> {
186        if !Arc::ptr_eq(&self.shape, &other.shape) {
187            return Err(PyValueError::new_err(
188                "cannot multiply HyperDuals with different shapes",
189            ));
190        }
191        Ok(PyHyperDual::new(self.inner.clone() * other.inner.clone()))
192    }
193
194    fn __truediv__(&self, other: &PyHyperDual) -> PyResult<PyHyperDual> {
195        if !Arc::ptr_eq(&self.shape, &other.shape) {
196            return Err(PyValueError::new_err(
197                "cannot divide HyperDuals with different shapes",
198            ));
199        }
200        // Div panics when the divisor's value component is zero; guard first.
201        if other.inner.value() == &Rational::new(0, 1) {
202            return Err(PyValueError::new_err(
203                "division by zero (value component is zero)",
204            ));
205        }
206        Ok(PyHyperDual::new(self.inner.clone() / other.inner.clone()))
207    }
208
209    fn __neg__(&self) -> PyHyperDual {
210        PyHyperDual::new(-self.inner.clone())
211    }
212}