spectrafit_core/lib.rs
1//! spectrafit-core — PyO3 cdylib bindings.
2//!
3//! Exposes Python-callable functions:
4//! - `fit(graph_json, data_json, options_json) -> str`
5//! - `fit_arrays(graph_json, x, y, sigma, dataset_sizes, n_dims, options_json) -> str`
6//! - `fit_arrays_numpy(graph_json, x, y, sigma, dataset_sizes, n_dims, options_json)
7//! -> (str, ndarray)` — like `fit_arrays`, but bypasses JSON serialisation of
8//! per-point arrays and returns the fitted curve as a NumPy array
9//! - `evaluate(graph_json, params_json, data_json) -> str`
10//! - `evaluate_components(graph_json, params_json, data_json) -> str`
11//! - `model_type_wire_strings() -> str` — returns the canonical wire-format
12//! string of every supported model type, in `ModelTypeStr` declaration order
13//!
14//! `fit` accepts JSON-encoded data (backwards-compatible, MCP-friendly).
15//! `fit_arrays` accepts raw numpy buffers and eliminates JSON serialisation
16//! of measurement data — the dominant bottleneck for large datasets.
17//! `fit_arrays_numpy` extends `fit_arrays`: returns `(compact_json, best_fit_ndarray)`
18//! so the large per-point arrays bypass JSON entirely, cutting 2–3 ms per call.
19//! Graph/options remain JSON for reproducibility and language-neutrality.
20//! `CoreError` maps to `PyValueError` so Python callers receive a descriptive
21//! `ValueError`.
22
23#![warn(missing_docs)]
24use std::collections::HashMap;
25
26use numpy::{PyArray1, PyReadonlyArray1};
27use pyo3::exceptions::PyValueError;
28use pyo3::prelude::*;
29use spectrafit_types::{
30 CoreError, FitGraphSpec, FitOptionsSpec, MeasurementInput, MeasurementSpec, ModelTypeStr,
31};
32
33// ---------------------------------------------------------------------------
34// Internal helper: CoreError → PyValueError
35// ---------------------------------------------------------------------------
36
37fn core_err(e: CoreError) -> PyErr {
38 PyValueError::new_err(e.to_string())
39}
40
41fn json_err(e: serde_json::Error) -> PyErr {
42 PyValueError::new_err(format!("JSON parse error: {e}"))
43}
44
45// ---------------------------------------------------------------------------
46// Internal helper: panic boundary
47//
48// A Rust panic crossing the PyO3 boundary surfaces in Python as
49// `pyo3_runtime.PanicException`, a `BaseException` subclass that slips past
50// `except Exception`. `guard` runs the entrypoint body inside `catch_unwind`
51// and maps any caught panic to a clean `PyValueError`, so a degenerate input
52// (e.g. a kernel arity precondition violated — see the `Model` trait `# Panics`
53// contract in spectrafit-models) always reaches the caller as a catchable
54// `Exception` rather than an uncatchable `PanicException`.
55//
56// The JSON entrypoints (`fit`, `evaluate`, `evaluate_components`) capture only
57// `&str`, so their closures are `UnwindSafe` and call `guard` directly. The
58// array entrypoints (`fit_arrays`, `fit_arrays_numpy`) capture PyO3 handles
59// (`PyReadonlyArray1`, `Python<'py>`) that are not `UnwindSafe`, so they wrap
60// the closure in `std::panic::AssertUnwindSafe`. That assertion is sound here
61// because the guarded closure only *reads* its inputs (the numpy buffers are
62// read-only views, never written) and builds fresh owned outputs; it mutates no
63// shared or long-lived state, so an unwind cannot leave a caller-visible value
64// half-modified. The captured handles are borrows the interpreter keeps alive
65// for the whole call, and PyO3 itself re-raises whatever this returns.
66// ---------------------------------------------------------------------------
67
68fn guard<T>(f: impl FnOnce() -> PyResult<T> + std::panic::UnwindSafe) -> PyResult<T> {
69 match std::panic::catch_unwind(f) {
70 Ok(result) => result,
71 Err(payload) => {
72 let msg = payload
73 .downcast_ref::<&str>()
74 .map(|s| (*s).to_string())
75 .or_else(|| payload.downcast_ref::<String>().cloned())
76 .unwrap_or_else(|| "unknown panic".to_string());
77 Err(PyValueError::new_err(format!(
78 "internal error (Rust panic): {msg}"
79 )))
80 }
81 }
82}
83
84// ---------------------------------------------------------------------------
85// Internal helper: flatten MeasurementSpec.x into a Vec<f64>
86//
87// Python sends x as "points × dims" where each inner list is one point's
88// coordinates: x[i] = [x_value_for_point_i]. We extract x[i][0] for each i.
89// ---------------------------------------------------------------------------
90
91/// Flatten a validated 1-D MeasurementSpec's x into a `Vec<f64>` (point's first
92/// coordinate). The caller (`collect_eval_x`) has already rejected empty rows, so
93/// `first()` is guaranteed present — no silent `unwrap_or(0.0)` fabrication.
94fn flatten_x(spec: &MeasurementSpec) -> Vec<f64> {
95 spec.x.iter().map(|row| row[0]).collect()
96}
97
98/// Collect the 1-D x grid for `evaluate`/`evaluate_components`. Both wrap the
99/// graph's 1-D, single-dataset evaluator, so reject multi-dataset / n-D inputs
100/// explicitly rather than silently using only the first dataset's first dimension
101/// (which produced wrong curves with no error for `Multi`/2-D input). Empty
102/// coordinate rows are rejected too — previously they were silently fabricated as
103/// `x = 0.0`, returning a wrong curve with no error.
104fn collect_eval_x(input: MeasurementInput) -> PyResult<Vec<f64>> {
105 let datasets = input.into_vec();
106 if datasets.len() > 1 {
107 return Err(PyValueError::new_err(format!(
108 "evaluate() supports a single dataset only (got {}); use fit() for multi-dataset graphs",
109 datasets.len()
110 )));
111 }
112 match datasets.first() {
113 Some(spec) if spec.x.iter().any(|row| row.len() > 1) => Err(PyValueError::new_err(
114 "evaluate() is 1-D only; got n-D coordinates — use fit_arrays with n_dims for n-D models",
115 )),
116 Some(spec) if spec.x.iter().any(Vec::is_empty) => Err(PyValueError::new_err(
117 "evaluate() received an x point with no coordinates (empty row); \
118 every point must carry at least one coordinate",
119 )),
120 Some(spec) => Ok(flatten_x(spec)),
121 None => Ok(Vec::new()),
122 }
123}
124
125// ---------------------------------------------------------------------------
126// Internal helper: transpose Python "points × dims" x format to Rust solver's
127// "dims × points" format.
128//
129// Python schema: x[i][d] — i-th data point, d-th dimension
130// Rust solver: x[d][i] — d-th dimension, i-th value along that dimension
131//
132// Example: [[0.0],[1.0],[2.0]] (3 pts × 1 dim) → [[0.0,1.0,2.0]] (1 dim × 3 pts)
133// ---------------------------------------------------------------------------
134
135fn transpose_x_for_solver(spec: MeasurementSpec) -> PyResult<MeasurementSpec> {
136 if spec.x.is_empty() {
137 return Ok(spec);
138 }
139
140 let n_points = spec.x.len();
141 let n_dims = spec.x[0].len();
142 if n_dims == 0 {
143 return Err(PyValueError::new_err(
144 "fit() received an x point with no coordinates (empty row); \
145 every point must carry at least one coordinate",
146 ));
147 }
148 // Reject ragged input rather than silently padding short rows with 0.0,
149 // which produced a wrong fit with no error.
150 if let Some(bad) = spec.x.iter().position(|row| row.len() != n_dims) {
151 return Err(PyValueError::new_err(format!(
152 "fit() received ragged x: point 0 has {n_dims} coordinate(s) but point \
153 {bad} has {}; all points must share the same dimensionality",
154 spec.x[bad].len()
155 )));
156 }
157
158 let mut transposed: Vec<Vec<f64>> = vec![Vec::with_capacity(n_points); n_dims];
159 for row in &spec.x {
160 for (d, col) in transposed.iter_mut().enumerate() {
161 col.push(row[d]);
162 }
163 }
164
165 Ok(MeasurementSpec {
166 x: transposed,
167 ..spec
168 })
169}
170
171// ---------------------------------------------------------------------------
172// #[pyfunction] fit
173// ---------------------------------------------------------------------------
174
175/// Run a full fit and return the result as JSON.
176///
177/// The solver is selected by `options_json`'s `solver` field (default `lm`);
178/// see `spectrafit_solver::fit` for the accepted solver strings.
179///
180/// Arguments (all JSON strings):
181/// - `graph_json` — serialised `FitGraphSpec`
182/// - `data_json` — serialised `MeasurementInput` (single or array)
183/// - `options_json` — serialised `FitOptionsSpec`
184///
185/// Returns a JSON string matching the `FitResult` Python schema.
186#[pyfunction]
187fn fit(graph_json: &str, data_json: &str, options_json: &str) -> PyResult<String> {
188 guard(|| {
189 // 1. Deserialise inputs
190 let graph: FitGraphSpec = serde_json::from_str(graph_json).map_err(json_err)?;
191 let input: MeasurementInput = serde_json::from_str(data_json).map_err(json_err)?;
192 let options: FitOptionsSpec = serde_json::from_str(options_json).map_err(json_err)?;
193
194 let datasets: Vec<MeasurementSpec> = input
195 .into_vec()
196 .into_iter()
197 .map(transpose_x_for_solver)
198 .collect::<PyResult<_>>()?;
199
200 // 2. Run solver
201 let result = spectrafit_solver::fit(&graph, datasets, &options).map_err(core_err)?;
202
203 // 3. Serialise result to JSON
204 serde_json::to_string(&result).map_err(json_err)
205 })
206}
207
208// ---------------------------------------------------------------------------
209// #[pyfunction] evaluate
210// ---------------------------------------------------------------------------
211
212/// Evaluate the model sum at every x-point and return a JSON array.
213///
214/// Arguments (all JSON strings):
215/// - `graph_json` — serialised `FitGraphSpec`
216/// - `params_json` — flat dict `{"node.param": value, ...}`
217/// - `data_json` — serialised `MeasurementInput`
218///
219/// Returns a JSON array `[f64, ...]`.
220#[pyfunction]
221fn evaluate(graph_json: &str, params_json: &str, data_json: &str) -> PyResult<String> {
222 guard(|| {
223 // 1. Deserialise
224 let graph: FitGraphSpec = serde_json::from_str(graph_json).map_err(json_err)?;
225 let params: HashMap<String, f64> = serde_json::from_str(params_json).map_err(json_err)?;
226 let input: MeasurementInput = serde_json::from_str(data_json).map_err(json_err)?;
227
228 // 2. Collect x values (single-dataset, 1-D only — rejects Multi/n-D explicitly)
229 let x: Vec<f64> = collect_eval_x(input)?;
230
231 // 3. Evaluate
232 let values = spectrafit_graph::evaluate(&graph, ¶ms, &x).map_err(core_err)?;
233
234 // 4. Serialise
235 serde_json::to_string(&values).map_err(json_err)
236 })
237}
238
239// ---------------------------------------------------------------------------
240// #[pyfunction] evaluate_components
241// ---------------------------------------------------------------------------
242
243/// Evaluate each node independently and return a JSON object.
244///
245/// Arguments (all JSON strings):
246/// - `graph_json` — serialised `FitGraphSpec`
247/// - `params_json` — flat dict `{"node.param": value, ...}`
248/// - `data_json` — serialised `MeasurementInput`
249///
250/// Returns a JSON object `{"node_id": [f64, ...], ...}`.
251#[pyfunction]
252fn evaluate_components(graph_json: &str, params_json: &str, data_json: &str) -> PyResult<String> {
253 guard(|| {
254 // 1. Deserialise
255 let graph: FitGraphSpec = serde_json::from_str(graph_json).map_err(json_err)?;
256 let params: HashMap<String, f64> = serde_json::from_str(params_json).map_err(json_err)?;
257 let input: MeasurementInput = serde_json::from_str(data_json).map_err(json_err)?;
258
259 // 2. Collect x values (single-dataset, 1-D only — rejects Multi/n-D explicitly)
260 let x: Vec<f64> = collect_eval_x(input)?;
261
262 // 3. Evaluate components
263 let components =
264 spectrafit_graph::evaluate_components(&graph, ¶ms, &x).map_err(core_err)?;
265
266 // 4. Serialise
267 serde_json::to_string(&components).map_err(json_err)
268 })
269}
270
271// ---------------------------------------------------------------------------
272// #[pyfunction] fit_arrays
273// ---------------------------------------------------------------------------
274
275/// Run a fit using raw numpy arrays for measurement data.
276///
277/// The solver is selected by `options_json`'s `solver` field (default `lm`);
278/// see `spectrafit_solver::fit` for the accepted solver strings.
279///
280/// This eliminates JSON serialisation of x/y/sigma, which is the dominant
281/// bottleneck for large datasets (scales as O(n)).
282///
283/// Arguments:
284/// - `graph_json` — serialised `FitGraphSpec` (JSON string)
285/// - `x` — flat 1-D numpy array of x-values in point-major layout
286/// (stride `n_dims`): point `i` of a dataset occupies the
287/// slice `x[i*n_dims .. (i+1)*n_dims]`, concatenated across
288/// datasets (shape `(n_total * n_dims,)`)
289/// - `y` — flat 1-D numpy array of observed values (shape `(n_total,)`)
290/// - `sigma` — optional 1-D numpy array of per-point σ (shape `(n_total,)`)
291/// - `dataset_sizes` — Python list of `int` giving the number of points per
292/// dataset; must sum to `len(y)`. Pass `[len(y)]` for a
293/// single dataset.
294/// - `n_dims` — number of x-dimensions per point (1 or 2). The flat `x`
295/// buffer is strided by this and reshaped to dims × points.
296/// - `options_json` — serialised `FitOptionsSpec` (JSON string)
297///
298/// Returns a JSON string matching the `FitResult` Python schema.
299///
300/// Every failure — including a Rust panic raised inside the solver — reaches
301/// Python as a catchable `ValueError` (see the `guard` panic boundary above).
302#[pyfunction]
303#[pyo3(signature = (graph_json, x, y, sigma, dataset_sizes, n_dims, options_json))]
304fn fit_arrays(
305 graph_json: &str,
306 x: PyReadonlyArray1<f64>,
307 y: PyReadonlyArray1<f64>,
308 sigma: Option<PyReadonlyArray1<f64>>,
309 dataset_sizes: Vec<usize>,
310 n_dims: usize,
311 options_json: &str,
312) -> PyResult<String> {
313 // Same panic boundary as the JSON `fit` path — see the `guard` block above
314 // for why `AssertUnwindSafe` is sound for this closure.
315 guard(std::panic::AssertUnwindSafe(|| {
316 // 1. Deserialise graph and options from JSON.
317 let graph: FitGraphSpec = serde_json::from_str(graph_json).map_err(json_err)?;
318 let options: FitOptionsSpec = serde_json::from_str(options_json).map_err(json_err)?;
319
320 // 2. Validate sizes and slice the flat buffers.
321 let x_slice = x
322 .as_slice()
323 .map_err(|e| PyValueError::new_err(format!("x array not contiguous: {e}")))?;
324 let y_slice = y
325 .as_slice()
326 .map_err(|e| PyValueError::new_err(format!("y array not contiguous: {e}")))?;
327 let sigma_vec: Option<Vec<f64>> = sigma
328 .map(|s| {
329 s.as_slice()
330 .map(|sl| sl.to_vec())
331 .map_err(|e| PyValueError::new_err(format!("sigma array not contiguous: {e}")))
332 })
333 .transpose()?;
334
335 let datasets = split_array_datasets(
336 x_slice,
337 y_slice,
338 sigma_vec.as_deref(),
339 &dataset_sizes,
340 n_dims,
341 )?;
342
343 // 3. Run solver and return JSON result.
344 let result = spectrafit_solver::fit(&graph, datasets, &options).map_err(core_err)?;
345 serde_json::to_string(&result).map_err(json_err)
346 }))
347}
348
349// ---------------------------------------------------------------------------
350// Internal helper: split flat point-major arrays into per-dataset specs.
351//
352// `x_slice` is point-major with stride `n_dims`: point `i` of the concatenated
353// stream occupies `x_slice[i*n_dims .. (i+1)*n_dims]`. Each per-dataset spec is
354// reshaped to the solver's dims × points layout (matching `transpose_x_for_solver`
355// on the JSON path). `n_dims` of 1 reproduces the original 1-D behaviour.
356// ---------------------------------------------------------------------------
357
358fn split_array_datasets(
359 x_slice: &[f64],
360 y_slice: &[f64],
361 sigma_slice: Option<&[f64]>,
362 dataset_sizes: &[usize],
363 n_dims: usize,
364) -> PyResult<Vec<MeasurementSpec>> {
365 if n_dims == 0 {
366 return Err(PyValueError::new_err("n_dims must be >= 1"));
367 }
368
369 let n_total: usize = dataset_sizes.iter().sum();
370 if n_total != y_slice.len() {
371 return Err(PyValueError::new_err(format!(
372 "dataset_sizes sum ({}) != len(y) ({})",
373 n_total,
374 y_slice.len()
375 )));
376 }
377 // Checked multiply: an adversarial dataset_sizes could overflow usize and make
378 // the length check pass spuriously, then panic on slice indexing below.
379 let x_expected = n_total.checked_mul(n_dims).ok_or_else(|| {
380 PyValueError::new_err("n_total * n_dims overflows usize (dataset_sizes too large)")
381 })?;
382 if x_expected != x_slice.len() {
383 return Err(PyValueError::new_err(format!(
384 "n_total * n_dims ({n_total} * {n_dims} = {x_expected}) != len(x) ({})",
385 x_slice.len()
386 )));
387 }
388
389 let mut datasets: Vec<MeasurementSpec> = Vec::with_capacity(dataset_sizes.len());
390 let mut point_offset = 0usize;
391 for &size in dataset_sizes {
392 let x_base = point_offset * n_dims;
393 // Reshape point-major (points × dims) into dims × points rows. The size
394 // checks above guarantee these ranges are in-bounds, but use `get(..)` so
395 // any future invariant drift surfaces as a clean ValueError, not a panic.
396 let mut x_dims: Vec<Vec<f64>> = vec![Vec::with_capacity(size); n_dims];
397 for i in 0..size {
398 let pt = x_slice
399 .get(x_base + i * n_dims..x_base + (i + 1) * n_dims)
400 .ok_or_else(|| PyValueError::new_err("x slice out of bounds while reshaping"))?;
401 for (d, col) in x_dims.iter_mut().enumerate() {
402 col.push(pt[d]);
403 }
404 }
405 let y_chunk = y_slice
406 .get(point_offset..point_offset + size)
407 .ok_or_else(|| PyValueError::new_err("y slice out of bounds while splitting datasets"))?
408 .to_vec();
409 let sigma_chunk = match sigma_slice {
410 Some(s) => Some(
411 s.get(point_offset..point_offset + size)
412 .ok_or_else(|| {
413 PyValueError::new_err("sigma slice out of bounds while splitting datasets")
414 })?
415 .to_vec(),
416 ),
417 None => None,
418 };
419 datasets.push(MeasurementSpec {
420 schema_version: None,
421 x: x_dims,
422 y: y_chunk,
423 sigma: sigma_chunk,
424 label: None,
425 });
426 point_offset += size;
427 }
428 Ok(datasets)
429}
430
431// ---------------------------------------------------------------------------
432// #[pyfunction] fit_arrays_numpy
433// ---------------------------------------------------------------------------
434
435/// Like `fit_arrays`, but bypasses JSON serialisation of per-point arrays.
436///
437/// Returns `(compact_result_json, best_fit_array)` where:
438/// - `compact_result_json` contains parameters + scalar fit metrics only
439/// (best_fit / residuals / init_fit / components are stripped out).
440/// - `best_fit_array` is a 1-D float64 NumPy array of fitted values.
441///
442/// This eliminates the dominant per-call overhead (~2 ms) when the caller
443/// only needs the fitted curve as a NumPy array, not as a JSON list.
444///
445/// This is the single deliberate exemption from the "every `#[pyfunction]`
446/// returns a JSON string" boundary rule: the ndarray is the whole point of the
447/// zero-copy path. Any other `#[pyfunction]` must keep returning a JSON
448/// string; this is the one intentional non-JSON return type.
449///
450/// Every failure — including a Rust panic raised inside the solver — reaches
451/// Python as a catchable `ValueError` (see the `guard` panic boundary above).
452// Allowed: PyO3 binding contract — 8 args mirror the Python boundary the
453// caller already constructs; squashing into a struct would force the Python
454// side to build an intermediate object on every call (defeats the
455// "eliminates ~2 ms per-call overhead" goal stated above).
456#[allow(clippy::too_many_arguments)]
457#[pyfunction]
458#[pyo3(signature = (graph_json, x, y, sigma, dataset_sizes, n_dims, options_json))]
459fn fit_arrays_numpy<'py>(
460 py: Python<'py>,
461 graph_json: &str,
462 x: PyReadonlyArray1<f64>,
463 y: PyReadonlyArray1<f64>,
464 sigma: Option<PyReadonlyArray1<f64>>,
465 dataset_sizes: Vec<usize>,
466 n_dims: usize,
467 options_json: &str,
468) -> PyResult<(String, Bound<'py, PyArray1<f64>>)> {
469 // Same panic boundary as the JSON `fit` path — see the `guard` block above
470 // for why `AssertUnwindSafe` is sound for this closure. `guard` is generic
471 // over the success type, so the `(String, Bound<'py, PyArray1<f64>>)` return
472 // needs no separate helper; only the `UnwindSafe` bound has to be asserted,
473 // because the closure captures the `Python<'py>` token and the read-only
474 // numpy views.
475 guard(std::panic::AssertUnwindSafe(|| {
476 let graph: FitGraphSpec = serde_json::from_str(graph_json).map_err(json_err)?;
477 let options: FitOptionsSpec = serde_json::from_str(options_json).map_err(json_err)?;
478
479 let x_slice = x
480 .as_slice()
481 .map_err(|e| PyValueError::new_err(format!("x array not contiguous: {e}")))?;
482 let y_slice = y
483 .as_slice()
484 .map_err(|e| PyValueError::new_err(format!("y array not contiguous: {e}")))?;
485 let sigma_vec: Option<Vec<f64>> = sigma
486 .map(|s| {
487 s.as_slice()
488 .map(|sl| sl.to_vec())
489 .map_err(|e| PyValueError::new_err(format!("sigma array not contiguous: {e}")))
490 })
491 .transpose()?;
492
493 let datasets = split_array_datasets(
494 x_slice,
495 y_slice,
496 sigma_vec.as_deref(),
497 &dataset_sizes,
498 n_dims,
499 )?;
500
501 let mut result = spectrafit_solver::fit(&graph, datasets, &options).map_err(core_err)?;
502
503 // Extract best_fit before stripping the large arrays from the result.
504 let best_fit = std::mem::take(&mut result.best_fit);
505 result.residuals.clear();
506 result.init_fit.clear();
507 result.components.clear();
508 result.dataset_slices = None;
509
510 let compact_json = serde_json::to_string(&result).map_err(json_err)?;
511 let best_fit_array = PyArray1::from_vec(py, best_fit);
512 Ok((compact_json, best_fit_array))
513 }))
514}
515
516// ---------------------------------------------------------------------------
517// #[pyfunction] model_type_wire_strings
518// ---------------------------------------------------------------------------
519
520/// Return the canonical wire-format string of every supported model type, in
521/// `ModelTypeStr` declaration order.
522///
523/// This exposes the single Rust source of truth — the
524/// `model_manifest!`-generated [`ModelTypeStr::ALL`] — to Python so the
525/// `ModelType` ↔ Rust parity test can pin the hand-written Python enum against
526/// the ACTUAL Rust variant set rather than a second hand-maintained list.
527/// Adding a model to the manifest auto-updates this enumeration, so the Python
528/// enum can drift from Rust only by failing that test (never silently).
529///
530/// Returns a JSON-encoded array of strings (not `Vec<String>`) — every
531/// `#[pyfunction]` in this module returns a JSON string so the PyO3 boundary
532/// has one uniform contract; callers `json.loads()` the result.
533#[pyfunction]
534fn model_type_wire_strings() -> PyResult<String> {
535 let wire: Vec<&str> = ModelTypeStr::ALL.iter().map(|m| m.as_str()).collect();
536 serde_json::to_string(&wire).map_err(json_err)
537}
538
539// ---------------------------------------------------------------------------
540// #[pymodule]
541// ---------------------------------------------------------------------------
542
543#[pymodule]
544fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
545 // Eagerly initialize the global rayon thread pool so the first `fit()` call
546 // does not pay the ~360 ms cold-start penalty. The Err branch fires only
547 // if the pool was already initialized (safe to ignore).
548 let _ = rayon::ThreadPoolBuilder::new().build_global();
549
550 m.add_function(wrap_pyfunction!(fit, m)?)?;
551 m.add_function(wrap_pyfunction!(fit_arrays, m)?)?;
552 m.add_function(wrap_pyfunction!(fit_arrays_numpy, m)?)?;
553 m.add_function(wrap_pyfunction!(evaluate, m)?)?;
554 m.add_function(wrap_pyfunction!(evaluate_components, m)?)?;
555 m.add_function(wrap_pyfunction!(model_type_wire_strings, m)?)?;
556 Ok(())
557}