Skip to main content

sc_neurocore_engine/ir/
bindings.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 — Compute-graph IR PyO3 bindings
8
9//! Python bindings for constructing, verifying, serialising, and emitting SC IR graphs.
10
11use crate::ir;
12use pyo3::exceptions::PyValueError;
13use pyo3::prelude::*;
14
15/// Register the compute-graph IR classes and functions on the Python extension module.
16pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
17    m.add_class::<PyScGraph>()?;
18    m.add_class::<PyScGraphBuilder>()?;
19    m.add_function(wrap_pyfunction!(ir_verify, m)?)?;
20    m.add_function(wrap_pyfunction!(ir_print, m)?)?;
21    m.add_function(wrap_pyfunction!(ir_parse, m)?)?;
22    m.add_function(wrap_pyfunction!(ir_emit_sv, m)?)?;
23    Ok(())
24}
25
26// IR bridge
27
28#[pyclass(name = "ScGraph", module = "sc_neurocore_engine.sc_neurocore_engine")]
29pub struct PyScGraph {
30    inner: ir::graph::ScGraph,
31}
32
33#[pymethods]
34impl PyScGraph {
35    /// Number of operations in the graph.
36    fn len(&self) -> usize {
37        self.inner.len()
38    }
39
40    fn __len__(&self) -> usize {
41        self.inner.len()
42    }
43
44    /// Whether the graph is empty.
45    fn is_empty(&self) -> bool {
46        self.inner.is_empty()
47    }
48
49    /// Graph name.
50    #[getter]
51    fn name(&self) -> &str {
52        &self.inner.name
53    }
54
55    /// Number of input ports.
56    fn num_inputs(&self) -> usize {
57        self.inner.inputs().len()
58    }
59
60    /// Number of output ports.
61    fn num_outputs(&self) -> usize {
62        self.inner.outputs().len()
63    }
64
65    fn __repr__(&self) -> String {
66        format!("ScGraph('{}', ops={})", self.inner.name, self.inner.len())
67    }
68}
69
70#[pyclass(
71    name = "ScGraphBuilder",
72    module = "sc_neurocore_engine.sc_neurocore_engine"
73)]
74pub struct PyScGraphBuilder {
75    inner: Option<ir::builder::ScGraphBuilder>,
76}
77
78impl PyScGraphBuilder {
79    fn builder_mut(&mut self) -> PyResult<&mut ir::builder::ScGraphBuilder> {
80        self.inner
81            .as_mut()
82            .ok_or_else(|| PyValueError::new_err("Builder already consumed by build()."))
83    }
84}
85
86#[pymethods]
87impl PyScGraphBuilder {
88    #[new]
89    fn new(name: String) -> Self {
90        Self {
91            inner: Some(ir::builder::ScGraphBuilder::new(name)),
92        }
93    }
94
95    /// Add a typed input port. Returns value ID.
96    fn input(&mut self, name: &str, ty: &str) -> PyResult<u32> {
97        let sc_type = parse_sc_type(ty)?;
98        Ok(self.builder_mut()?.input(name, sc_type).0)
99    }
100
101    /// Add an output port forwarding a value.
102    fn output(&mut self, name: &str, source_id: u32) -> PyResult<u32> {
103        Ok(self
104            .builder_mut()?
105            .output(name, ir::graph::ValueId(source_id))
106            .0)
107    }
108
109    /// Add a float constant.
110    fn constant_f64(&mut self, value: f64, ty: &str) -> PyResult<u32> {
111        let sc_type = parse_sc_type(ty)?;
112        Ok(self
113            .builder_mut()?
114            .constant(ir::graph::ScConst::F64(value), sc_type)
115            .0)
116    }
117
118    /// Add an integer constant.
119    fn constant_i64(&mut self, value: i64, ty: &str) -> PyResult<u32> {
120        let sc_type = parse_sc_type(ty)?;
121        Ok(self
122            .builder_mut()?
123            .constant(ir::graph::ScConst::I64(value), sc_type)
124            .0)
125    }
126
127    /// Add a float-vector constant.
128    fn constant_f64_vec(&mut self, values: Vec<f64>, ty: &str) -> PyResult<u32> {
129        let sc_type = parse_sc_type(ty)?;
130        Ok(self
131            .builder_mut()?
132            .constant(ir::graph::ScConst::F64Vec(values), sc_type)
133            .0)
134    }
135
136    /// Add a single Kuramoto integration step over an explicit coupling matrix.
137    ///
138    /// `phases_id` and `omega_id` are length-`N` vector constants; `coupling_id`
139    /// is the row-major `N×N` matrix `K_nm`. `dt` is the Euler step.
140    fn kuramoto_step(
141        &mut self,
142        phases_id: u32,
143        omega_id: u32,
144        coupling_id: u32,
145        dt: f64,
146    ) -> PyResult<u32> {
147        Ok(self
148            .builder_mut()?
149            .kuramoto_step(
150                ir::graph::ValueId(phases_id),
151                ir::graph::ValueId(omega_id),
152                ir::graph::ValueId(coupling_id),
153                dt,
154            )
155            .0)
156    }
157
158    /// Add a degree-normalised graph aggregation over an explicit adjacency matrix.
159    ///
160    /// `features_id` is the `n_nodes × n_features` (node-major) constant vector and
161    /// `adjacency_id` the row-major `n_nodes × n_nodes` matrix.
162    fn graph_forward(
163        &mut self,
164        features_id: u32,
165        adjacency_id: u32,
166        n_nodes: usize,
167        n_features: usize,
168    ) -> PyResult<u32> {
169        Ok(self
170            .builder_mut()?
171            .graph_forward(
172                ir::graph::ValueId(features_id),
173                ir::graph::ValueId(adjacency_id),
174                n_nodes,
175                n_features,
176            )
177            .0)
178    }
179
180    /// Add a single-head scaled-dot-product softmax attention op.
181    ///
182    /// `q_id` is `q_rows × dim_k`, `k_id` is `k_rows × dim_k` and `v_id` is
183    /// `k_rows × v_cols` (row-major constant vectors); shapes are inferred from
184    /// their lengths and `dim_k` at emit time.
185    fn softmax_attention(
186        &mut self,
187        q_id: u32,
188        k_id: u32,
189        v_id: u32,
190        dim_k: usize,
191    ) -> PyResult<u32> {
192        Ok(self
193            .builder_mut()?
194            .softmax_attention(
195                ir::graph::ValueId(q_id),
196                ir::graph::ValueId(k_id),
197                ir::graph::ValueId(v_id),
198                dim_k,
199            )
200            .0)
201    }
202
203    /// Add a Bernoulli encode operation.
204    fn encode(&mut self, prob_id: u32, length: usize, seed: u64) -> PyResult<u32> {
205        let seed = u16::try_from(seed)
206            .map_err(|_| PyValueError::new_err(format!("Seed out of range for u16: {seed}")))?;
207        Ok(self
208            .builder_mut()?
209            .encode(ir::graph::ValueId(prob_id), length, seed)
210            .0)
211    }
212
213    /// Add a bitwise AND (SC multiply).
214    fn bitwise_and(&mut self, lhs_id: u32, rhs_id: u32) -> PyResult<u32> {
215        Ok(self
216            .builder_mut()?
217            .bitwise_and(ir::graph::ValueId(lhs_id), ir::graph::ValueId(rhs_id))
218            .0)
219    }
220
221    /// Add a popcount operation.
222    fn popcount(&mut self, input_id: u32) -> PyResult<u32> {
223        Ok(self.builder_mut()?.popcount(ir::graph::ValueId(input_id)).0)
224    }
225
226    /// Add a LIF neuron step.
227    #[pyo3(signature = (
228        current_id,
229        leak_id,
230        gain_id,
231        noise_id,
232        data_width=16,
233        fraction=8,
234        v_rest=0,
235        v_reset=0,
236        v_threshold=256,
237        refractory_period=2
238    ))]
239    #[allow(clippy::too_many_arguments)]
240    fn lif_step(
241        &mut self,
242        current_id: u32,
243        leak_id: u32,
244        gain_id: u32,
245        noise_id: u32,
246        data_width: u32,
247        fraction: u32,
248        v_rest: i64,
249        v_reset: i64,
250        v_threshold: i64,
251        refractory_period: u32,
252    ) -> PyResult<u32> {
253        let params = ir::graph::LifParams {
254            data_width,
255            fraction,
256            v_rest,
257            v_reset,
258            v_threshold,
259            refractory_period,
260        };
261        Ok(self
262            .builder_mut()?
263            .lif_step(
264                ir::graph::ValueId(current_id),
265                ir::graph::ValueId(leak_id),
266                ir::graph::ValueId(gain_id),
267                ir::graph::ValueId(noise_id),
268                params,
269            )
270            .0)
271    }
272
273    /// Add a dense layer forward pass.
274    #[pyo3(signature = (
275        inputs_id,
276        weights_id,
277        leak_id,
278        gain_id,
279        n_inputs=3,
280        n_neurons=7,
281        data_width=16,
282        stream_length=1024,
283        seed_base=0xACE1u64,
284        y_min=0,
285        y_max=65535
286    ))]
287    #[allow(clippy::too_many_arguments)]
288    fn dense_forward(
289        &mut self,
290        inputs_id: u32,
291        weights_id: u32,
292        leak_id: u32,
293        gain_id: u32,
294        n_inputs: usize,
295        n_neurons: usize,
296        data_width: u32,
297        stream_length: usize,
298        seed_base: u64,
299        y_min: i64,
300        y_max: i64,
301    ) -> PyResult<u32> {
302        let input_seed_base = u16::try_from(seed_base).map_err(|_| {
303            PyValueError::new_err(format!("seed_base out of range for u16: {seed_base}"))
304        })?;
305        let params = ir::graph::DenseParams {
306            n_inputs,
307            n_neurons,
308            data_width,
309            stream_length,
310            input_seed_base,
311            weight_seed_base: input_seed_base.wrapping_add(1),
312            y_min,
313            y_max,
314        };
315        Ok(self
316            .builder_mut()?
317            .dense_forward(
318                ir::graph::ValueId(inputs_id),
319                ir::graph::ValueId(weights_id),
320                ir::graph::ValueId(leak_id),
321                ir::graph::ValueId(gain_id),
322                params,
323            )
324            .0)
325    }
326
327    /// Add a scale (multiply by constant factor) operation.
328    fn scale(&mut self, input_id: u32, factor: f64) -> PyResult<u32> {
329        Ok(self
330            .builder_mut()?
331            .scale(ir::graph::ValueId(input_id), factor)
332            .0)
333    }
334
335    /// Add an offset (add constant) operation.
336    fn offset(&mut self, input_id: u32, offset_val: f64) -> PyResult<u32> {
337        Ok(self
338            .builder_mut()?
339            .offset(ir::graph::ValueId(input_id), offset_val)
340            .0)
341    }
342
343    /// Add a divide-by-constant operation.
344    fn div_const(&mut self, input_id: u32, divisor: u64) -> PyResult<u32> {
345        Ok(self
346            .builder_mut()?
347            .div_const(ir::graph::ValueId(input_id), divisor)
348            .0)
349    }
350
351    /// Consume the builder and return a graph.
352    fn build(&mut self) -> PyResult<PyScGraph> {
353        let builder = self
354            .inner
355            .take()
356            .ok_or_else(|| PyValueError::new_err("Builder already consumed by build()."))?;
357        Ok(PyScGraph {
358            inner: builder.build(),
359        })
360    }
361}
362
363/// Verify an IR graph. Returns None on success, or a list of error strings.
364#[pyfunction]
365fn ir_verify(graph: PyRef<'_, PyScGraph>) -> Option<Vec<String>> {
366    match ir::verify::verify(&graph.inner) {
367        Ok(()) => None,
368        Err(errors) => Some(errors.iter().map(|e| e.to_string()).collect()),
369    }
370}
371
372/// Print an IR graph to its stable text format.
373#[pyfunction]
374fn ir_print(graph: PyRef<'_, PyScGraph>) -> String {
375    ir::printer::print(&graph.inner)
376}
377
378/// Parse an IR graph from text format.
379#[pyfunction]
380fn ir_parse(text: &str) -> PyResult<PyScGraph> {
381    ir::parser::parse(text)
382        .map(|graph| PyScGraph { inner: graph })
383        .map_err(|e| PyValueError::new_err(e.to_string()))
384}
385
386/// Emit SystemVerilog from an IR graph.
387#[pyfunction]
388fn ir_emit_sv(graph: PyRef<'_, PyScGraph>) -> PyResult<String> {
389    ir::emit_sv::emit(&graph.inner).map_err(PyValueError::new_err)
390}
391
392/// Parse a Python type string into ScType.
393///
394/// Accepted formats: "bool", "rate", "u32", "u64", "i16", "i32",
395/// "bitstream", "bitstream<1024>", "fixed<16,8>", "vec<bool,7>".
396fn parse_sc_type(s: &str) -> PyResult<ir::graph::ScType> {
397    let s = s.trim();
398    let lower = s.to_ascii_lowercase();
399    match lower.as_str() {
400        "bool" => Ok(ir::graph::ScType::Bool),
401        "rate" => Ok(ir::graph::ScType::Rate),
402        "u32" => Ok(ir::graph::ScType::UInt { width: 32 }),
403        "u64" => Ok(ir::graph::ScType::UInt { width: 64 }),
404        "i16" => Ok(ir::graph::ScType::SInt { width: 16 }),
405        "i32" => Ok(ir::graph::ScType::SInt { width: 32 }),
406        "bitstream" => Ok(ir::graph::ScType::Bitstream { length: 0 }),
407        _ => {
408            if let Some(width) = lower.strip_prefix('u') {
409                if let Ok(width) = width.parse::<u32>() {
410                    return Ok(ir::graph::ScType::UInt { width });
411                }
412            }
413            if let Some(width) = lower.strip_prefix('i') {
414                if let Ok(width) = width.parse::<u32>() {
415                    return Ok(ir::graph::ScType::SInt { width });
416                }
417            }
418            if let Some(inner) = lower
419                .strip_prefix("bitstream<")
420                .and_then(|r| r.strip_suffix('>'))
421            {
422                let length = inner.parse::<usize>().map_err(|_| {
423                    PyValueError::new_err(format!("Invalid bitstream length: '{inner}'"))
424                })?;
425                return Ok(ir::graph::ScType::Bitstream { length });
426            }
427            if let Some(inner) = lower
428                .strip_prefix("fixed<")
429                .and_then(|r| r.strip_suffix('>'))
430            {
431                let parts: Vec<&str> = inner.split(',').collect();
432                if parts.len() != 2 {
433                    return Err(PyValueError::new_err(format!(
434                        "fixed type needs 2 params: '{s}'"
435                    )));
436                }
437                let width = parts[0].trim().parse::<u32>().map_err(|_| {
438                    PyValueError::new_err(format!("Invalid fixed width: '{}'", parts[0]))
439                })?;
440                let frac = parts[1].trim().parse::<u32>().map_err(|_| {
441                    PyValueError::new_err(format!("Invalid fixed frac: '{}'", parts[1]))
442                })?;
443                return Ok(ir::graph::ScType::FixedPoint { width, frac });
444            }
445            if let Some(inner) = lower.strip_prefix("vec<").and_then(|r| r.strip_suffix('>')) {
446                if let Some(comma_pos) = inner.rfind(',') {
447                    let inner_ty_str = &inner[..comma_pos];
448                    let count_str = inner[comma_pos + 1..].trim();
449                    let inner_ty = parse_sc_type(inner_ty_str)?;
450                    let count = count_str.parse::<usize>().map_err(|_| {
451                        PyValueError::new_err(format!("Invalid vec count: '{count_str}'"))
452                    })?;
453                    return Ok(ir::graph::ScType::Vec {
454                        element: Box::new(inner_ty),
455                        count,
456                    });
457                }
458            }
459            Err(PyValueError::new_err(format!("Unknown IR type: '{s}'")))
460        }
461    }
462}