Skip to main content

sc_neurocore_engine/bindings/
bitstream.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Bitstream PyO3 binding
8
9//! Python bindings for packing, unpacking, counting, and encoding bitstreams.
10
11use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1};
12use pyo3::exceptions::PyValueError;
13use pyo3::prelude::*;
14use pyo3::IntoPyObject;
15
16use crate::{bitstream, encoder, neuron, simd};
17
18/// Register bitstream bindings without adding implementation code to the crate root.
19pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
20    module.add_class::<Lfsr16>()?;
21    module.add_class::<BitstreamEncoder>()?;
22    module.add_class::<PyBitstreamAverager>()?;
23    module.add_function(wrap_pyfunction!(pack_bitstream, module)?)?;
24    module.add_function(wrap_pyfunction!(unpack_bitstream, module)?)?;
25    module.add_function(wrap_pyfunction!(popcount, module)?)?;
26    module.add_function(wrap_pyfunction!(pack_bitstream_numpy, module)?)?;
27    module.add_function(wrap_pyfunction!(popcount_numpy, module)?)?;
28    module.add_function(wrap_pyfunction!(unpack_bitstream_numpy, module)?)?;
29    module.add_function(wrap_pyfunction!(batch_encode, module)?)?;
30    module.add_function(wrap_pyfunction!(batch_encode_numpy, module)?)?;
31    Ok(())
32}
33
34#[pyclass(
35    name = "BitstreamAverager",
36    module = "sc_neurocore_engine.sc_neurocore_engine"
37)]
38pub struct PyBitstreamAverager {
39    inner: neuron::BitstreamAverager,
40}
41
42#[pymethods]
43impl PyBitstreamAverager {
44    #[new]
45    #[pyo3(signature = (window=1024))]
46    fn new(window: usize) -> Self {
47        Self {
48            inner: neuron::BitstreamAverager::new(window),
49        }
50    }
51
52    fn push(&mut self, bit: u8) {
53        self.inner.push(bit);
54    }
55
56    fn estimate(&self) -> f64 {
57        self.inner.estimate()
58    }
59
60    fn reset(&mut self) {
61        self.inner.reset();
62    }
63
64    #[getter]
65    fn window(&self) -> usize {
66        self.inner.window()
67    }
68}
69
70#[pyclass(module = "sc_neurocore_engine.sc_neurocore_engine")]
71pub struct Lfsr16 {
72    inner: encoder::Lfsr16,
73    seed_init: u16,
74}
75
76#[pymethods]
77impl Lfsr16 {
78    #[new]
79    #[pyo3(signature = (seed=0xACE1))]
80    fn new(seed: u16) -> PyResult<Self> {
81        if seed == 0 {
82            return Err(PyValueError::new_err("LFSR seed must be non-zero."));
83        }
84        Ok(Self {
85            inner: encoder::Lfsr16::new(seed),
86            seed_init: seed,
87        })
88    }
89
90    fn step(&mut self) -> u16 {
91        self.inner.step()
92    }
93
94    #[getter]
95    fn reg(&self) -> u16 {
96        self.inner.reg
97    }
98
99    #[getter]
100    fn width(&self) -> u32 {
101        self.inner.width
102    }
103
104    #[pyo3(signature = (seed=None))]
105    fn reset(&mut self, seed: Option<u16>) -> PyResult<()> {
106        let next = seed.unwrap_or(self.seed_init);
107        if next == 0 {
108            return Err(PyValueError::new_err("LFSR seed must be non-zero."));
109        }
110        self.inner = encoder::Lfsr16::new(next);
111        self.seed_init = next;
112        Ok(())
113    }
114}
115
116#[pyclass(module = "sc_neurocore_engine.sc_neurocore_engine")]
117pub struct BitstreamEncoder {
118    inner: encoder::BitstreamEncoder,
119    seed_init: u16,
120}
121
122#[pymethods]
123impl BitstreamEncoder {
124    #[new]
125    #[pyo3(signature = (data_width=16, seed=0xACE1))]
126    fn new(data_width: u32, seed: u16) -> PyResult<Self> {
127        if seed == 0 {
128            return Err(PyValueError::new_err("LFSR seed must be non-zero."));
129        }
130        Ok(Self {
131            inner: encoder::BitstreamEncoder::new(data_width, seed),
132            seed_init: seed,
133        })
134    }
135
136    fn step(&mut self, x_value: u16) -> u8 {
137        self.inner.step(x_value)
138    }
139
140    #[getter]
141    fn data_width(&self) -> u32 {
142        self.inner.data_width
143    }
144
145    #[getter]
146    fn reg(&self) -> u16 {
147        self.inner.lfsr.reg
148    }
149
150    #[pyo3(signature = (seed=None))]
151    fn reset(&mut self, seed: Option<u16>) -> PyResult<()> {
152        let next = seed.unwrap_or(self.seed_init);
153        if next == 0 {
154            return Err(PyValueError::new_err("LFSR seed must be non-zero."));
155        }
156        self.inner.reset(Some(next));
157        self.seed_init = next;
158        Ok(())
159    }
160}
161
162/// Pack a one- or two-dimensional Python bit sequence into 64-bit words.
163#[pyfunction]
164fn pack_bitstream(py: Python<'_>, bits: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
165    if let Ok(rows) = bits.extract::<Vec<Vec<u8>>>() {
166        let packed_rows: Vec<Vec<u64>> = rows.iter().map(|row| bitstream::pack(row).data).collect();
167        return Ok(packed_rows
168            .into_pyobject(py)
169            .map_err(|e| PyValueError::new_err(e.to_string()))?
170            .into_any()
171            .unbind());
172    }
173
174    let flat = bits
175        .extract::<Vec<u8>>()
176        .map_err(|_| PyValueError::new_err("Expected a 1-D or 2-D array of uint8 bits."))?;
177    Ok(bitstream::pack(&flat)
178        .data
179        .into_pyobject(py)
180        .map_err(|e| PyValueError::new_err(e.to_string()))?
181        .into_any()
182        .unbind())
183}
184
185/// Unpack 64-bit words into their original one- or two-dimensional bit shape.
186#[pyfunction]
187#[pyo3(signature = (packed, original_length, original_shape=None))]
188fn unpack_bitstream(
189    py: Python<'_>,
190    packed: &Bound<'_, PyAny>,
191    original_length: usize,
192    original_shape: Option<(usize, usize)>,
193) -> PyResult<Py<PyAny>> {
194    if let Ok(rows) = packed.extract::<Vec<Vec<u64>>>() {
195        let batch = rows.len();
196        let per_batch_len = if let Some((expected_batch, length)) = original_shape {
197            if expected_batch != batch {
198                return Err(PyValueError::new_err(format!(
199                    "original_shape batch {} does not match packed batch {}.",
200                    expected_batch, batch
201                )));
202            }
203            length
204        } else {
205            original_length.checked_div(batch).unwrap_or(0)
206        };
207
208        let unpacked_rows: Vec<Vec<u8>> = rows
209            .into_iter()
210            .map(|row| {
211                bitstream::unpack(&bitstream::BitStreamTensor::from_words(row, per_batch_len))
212            })
213            .collect();
214        return Ok(unpacked_rows
215            .into_pyobject(py)
216            .map_err(|e| PyValueError::new_err(e.to_string()))?
217            .into_any()
218            .unbind());
219    }
220
221    let words = packed.extract::<Vec<u64>>().map_err(|_| {
222        PyValueError::new_err("Expected packed uint64 words as 1-D or 2-D sequence.")
223    })?;
224    let tensor = bitstream::BitStreamTensor::from_words(words, original_length);
225    Ok(bitstream::unpack(&tensor)
226        .into_pyobject(py)
227        .map_err(|e| PyValueError::new_err(e.to_string()))?
228        .into_any()
229        .unbind())
230}
231
232/// Count set bits in a one- or two-dimensional collection of packed words.
233#[pyfunction]
234fn popcount(packed: &Bound<'_, PyAny>) -> PyResult<u64> {
235    // Zero-copy fast path: a 1-D numpy uint64 array borrows its buffer straight into the
236    // SIMD dispatch instead of deep-copying every word into a Vec, as the `extract::<Vec…>`
237    // paths below do. External review (KR-4) flagged that path as a large-array footgun;
238    // this mirrors `popcount_numpy` so `popcount(np.ndarray)` no longer copies.
239    if let Ok(array) = packed.extract::<PyReadonlyArray1<'_, u64>>() {
240        return Ok(simd::popcount_dispatch(array.as_slice()?));
241    }
242
243    if let Ok(rows) = packed.extract::<Vec<Vec<u64>>>() {
244        return Ok(rows
245            .iter()
246            .map(|row| simd::popcount_dispatch(row))
247            .sum::<u64>());
248    }
249
250    let words = packed.extract::<Vec<u64>>().map_err(|_| {
251        PyValueError::new_err("Expected packed uint64 words as 1-D or 2-D sequence.")
252    })?;
253    Ok(simd::popcount_dispatch(&words))
254}
255
256/// Pack a 1-D numpy uint8 array into packed u64 words, returning a numpy array.
257/// Zero-copy input, single-allocation output.
258#[pyfunction]
259fn pack_bitstream_numpy<'py>(
260    py: Python<'py>,
261    bits: PyReadonlyArray1<'py, u8>,
262) -> PyResult<Bound<'py, PyArray1<u64>>> {
263    let slice = bits
264        .as_slice()
265        .map_err(|e| PyValueError::new_err(format!("Cannot read numpy array: {e}")))?;
266    let tensor = simd::pack_dispatch(slice);
267    Ok(tensor.data.into_pyarray(py))
268}
269
270/// Popcount on a numpy uint64 array — zero-copy input.
271#[pyfunction]
272fn popcount_numpy(packed: PyReadonlyArray1<'_, u64>) -> PyResult<u64> {
273    let words = packed
274        .as_slice()
275        .map_err(|e| PyValueError::new_err(format!("Cannot read numpy array: {e}")))?;
276    Ok(simd::popcount_dispatch(words))
277}
278
279/// Unpack a numpy uint64 array back to a numpy uint8 array.
280#[pyfunction]
281fn unpack_bitstream_numpy<'py>(
282    py: Python<'py>,
283    packed: PyReadonlyArray1<'py, u64>,
284    original_length: usize,
285) -> PyResult<Bound<'py, PyArray1<u8>>> {
286    let words = packed
287        .as_slice()
288        .map_err(|e| PyValueError::new_err(format!("Cannot read numpy array: {e}")))?;
289    let tensor = bitstream::BitStreamTensor::from_words(words.to_vec(), original_length);
290    let bits = bitstream::unpack(&tensor);
291    Ok(bits.into_pyarray(py))
292}
293
294/// Bernoulli-encode a numpy float64 array into packed bitstream words.
295///
296/// Returns nested packed words with shape (n_probs, ceil(length / 64)).
297#[pyfunction]
298#[pyo3(signature = (probs, length=1024, seed=0xACE1))]
299fn batch_encode<'py>(
300    _py: Python<'py>,
301    probs: PyReadonlyArray1<'py, f64>,
302    length: usize,
303    seed: u64,
304) -> PyResult<Vec<Vec<u64>>> {
305    let prob_slice = probs
306        .as_slice()
307        .map_err(|e| PyValueError::new_err(format!("Cannot read probs: {e}")))?;
308    let words = length.div_ceil(64);
309
310    use rand::SeedableRng;
311    let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(seed);
312
313    let packed: Vec<Vec<u64>> = prob_slice
314        .iter()
315        .map(|&p| {
316            let mut data = bitstream::bernoulli_packed(p, length, &mut rng);
317            data.resize(words, 0);
318            data
319        })
320        .collect();
321
322    Ok(packed)
323}
324
325/// Bernoulli-encode a numpy float64 array into a 2-D numpy uint64 array.
326///
327/// Returns shape `(n_probs, ceil(length / 64))`.
328#[pyfunction]
329#[pyo3(signature = (probs, length=1024, seed=0xACE1))]
330fn batch_encode_numpy<'py>(
331    py: Python<'py>,
332    probs: PyReadonlyArray1<'py, f64>,
333    length: usize,
334    seed: u64,
335) -> PyResult<Bound<'py, PyArray2<u64>>> {
336    use rayon::prelude::*;
337
338    let prob_slice = probs
339        .as_slice()
340        .map_err(|e| PyValueError::new_err(format!("Cannot read probs: {e}")))?;
341    let words = length.div_ceil(64);
342    let n_probs = prob_slice.len();
343
344    let rows: Vec<Vec<u64>> = prob_slice
345        .par_iter()
346        .enumerate()
347        .map(|(idx, &p)| {
348            use rand::SeedableRng;
349
350            let prob_seed = seed.wrapping_add(idx as u64);
351            let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(prob_seed);
352            let mut row = bitstream::bernoulli_packed_simd(p, length, &mut rng);
353            row.resize(words, 0);
354            row
355        })
356        .collect();
357
358    let mut flat = Vec::with_capacity(n_probs * words);
359    for row in &rows {
360        flat.extend_from_slice(row);
361    }
362
363    let arr = ndarray::Array2::from_shape_vec((n_probs, words), flat)
364        .map_err(|e| PyValueError::new_err(format!("Shape construction failed: {e}")))?;
365    Ok(arr.into_pyarray(py))
366}