Skip to main content

sc_neurocore_engine/ir/
emit_sv.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 — SystemVerilog emitter for SC IR graphs
8
9//! SystemVerilog emitter for SC IR graphs.
10//!
11//! Produces synthesizable RTL that instantiates modules from `hdl/`.
12//!
13//! Generated module interface:
14//! - Clock: `clk`
15//! - Reset: `rst_n` (active-low)
16//! - One port per `sc.input` / `sc.output` operation
17//! - Internal wiring for all intermediate values
18
19use crate::ir::graph::*;
20use crate::ir::sv_target::{ResourceReport, SvTarget};
21
22/// Emit a synthesizable SystemVerilog module from an SC graph.
23///
24/// The graph should pass `verify::verify()` before emission.
25pub fn emit(graph: &ScGraph) -> Result<String, String> {
26    emit_systemverilog_with_target(graph, SvTarget::Generic).map(|(systemverilog, _)| systemverilog)
27}
28
29/// Emit a synthesizable SystemVerilog module and a resource estimate for a target.
30pub fn emit_systemverilog_with_target(
31    graph: &ScGraph,
32    target: SvTarget,
33) -> Result<(String, ResourceReport), String> {
34    let mut sv = String::new();
35
36    // Header
37    sv.push_str(&format!(
38        "// Auto-generated by SC-NeuroCore IR Compiler v3.0\n\
39         // Source graph: {}\n\
40         // Do not edit — regenerate from IR source.\n\n",
41        graph.name
42    ));
43    sv.push_str(&target.header_comment());
44    sv.push_str("`timescale 1ns / 1ps\n\n");
45
46    // Module declaration
47    sv.push_str(&format!("module {} (\n", graph.name));
48    sv.push_str("    input wire clk,\n");
49    sv.push_str("    input wire rst_n");
50
51    // Collect inputs and outputs for port list
52    for op in &graph.ops {
53        match op {
54            ScOp::Input { name, ty, .. } => {
55                let port_width = type_to_width(ty);
56                if port_width == 1 {
57                    sv.push_str(&format!(",\n    input wire {}", name));
58                } else {
59                    sv.push_str(&format!(
60                        ",\n    input wire [{}:0] {}",
61                        port_width - 1,
62                        name
63                    ));
64                }
65            }
66            ScOp::Output { name, source, .. } => {
67                let width = find_value_width(graph, *source);
68                if width == 1 {
69                    sv.push_str(&format!(",\n    output wire {}", name));
70                } else {
71                    sv.push_str(&format!(",\n    output wire [{}:0] {}", width - 1, name));
72                }
73            }
74            _ => {}
75        }
76    }
77    sv.push_str("\n);\n\n");
78
79    // Wire declarations for intermediate values
80    for op in &graph.ops {
81        match op {
82            ScOp::Input { .. } | ScOp::Output { .. } => {}
83            ScOp::Constant { id, value, .. } => emit_constant(&mut sv, *id, value, &target),
84            ScOp::Encode { id, .. } => {
85                sv.push_str(&format!("    wire v{};\n", id.0));
86            }
87            ScOp::BitwiseAnd { id, .. } => {
88                sv.push_str(&format!("    wire v{};\n", id.0));
89            }
90            ScOp::Popcount { id, .. } => {
91                sv.push_str(&format!("    logic [63:0] v{};\n", id.0));
92            }
93            ScOp::LifStep { id, params, .. } => {
94                sv.push_str(&format!(
95                    "    wire v{}_spike;\n    wire signed [{}:0] v{}_v_out;\n",
96                    id.0,
97                    params.data_width - 1,
98                    id.0
99                ));
100            }
101            ScOp::DenseForward { id, params, .. } => {
102                sv.push_str(&format!(
103                    "    wire [{}:0] v{}_spikes;\n    wire v{}_running;\n    wire v{}_done;\n",
104                    params.n_neurons - 1,
105                    id.0,
106                    id.0,
107                    id.0
108                ));
109            }
110            ScOp::DclsLayer { id, params, .. } => {
111                sv.push_str(&format!(
112                    "    wire signed [{}:0] v{};\n\
113                     \x20   wire signed [31:0] v{}_accumulator_q16_16;\n\
114                     \x20   wire v{}_valid;\n\
115                     \x20   wire v{}_overflow;\n\
116                     \x20   wire v{}_invalid_sigma;\n",
117                    params.data_width - 1,
118                    id.0,
119                    id.0,
120                    id.0,
121                    id.0,
122                    id.0
123                ));
124            }
125            ScOp::BitwiseXor { id, .. } => {
126                sv.push_str(&format!("    wire v{};\n", id.0));
127            }
128            ScOp::Reduce { id, .. } => {
129                sv.push_str(&format!("    wire [63:0] v{};\n", id.0));
130            }
131            ScOp::GraphForward {
132                id,
133                n_nodes,
134                n_features,
135                ..
136            } => {
137                let width = n_nodes * n_features * GRAPH_DATA_WIDTH;
138                sv.push_str(&format!(
139                    "    wire signed [{}:0] v{};\n",
140                    width.saturating_sub(1),
141                    id.0
142                ));
143            }
144            ScOp::SoftmaxAttention { id, q, k, v, dim_k } => {
145                let width = softmax_attention_shape(graph, *q, *k, *v, *dim_k)
146                    .map(|(qr, _, vc)| qr * vc * ATTN_DATA_WIDTH)
147                    .unwrap_or(64);
148                sv.push_str(&format!("    wire signed [{}:0] v{};\n", width - 1, id.0));
149            }
150            ScOp::KuramotoStep { id, phases, .. } => {
151                let width = kuramoto_osc_count(graph, *phases)
152                    .map(|n| n * KURAMOTO_DATA_WIDTH)
153                    .unwrap_or(64);
154                sv.push_str(&format!("    wire signed [{}:0] v{};\n", width - 1, id.0));
155            }
156            ScOp::Scale { id, .. } | ScOp::Offset { id, .. } | ScOp::DivConst { id, .. } => {
157                sv.push_str(&format!("    wire [63:0] v{};\n", id.0));
158            }
159        }
160    }
161    sv.push('\n');
162
163    let mut inst_idx = 0_u32;
164
165    // Module instantiations
166    for op in &graph.ops {
167        match op {
168            ScOp::Encode { id, prob, seed, .. } => {
169                let prob_wire = value_to_wire(graph, *prob);
170                sv.push_str(&format!(
171                    "    sc_bitstream_encoder #(\n\
172                     \x20       .DATA_WIDTH(16),\n\
173                     \x20       .SEED_INIT(16'h{:04X})\n\
174                     \x20   ) u_enc_{} (\n\
175                     \x20       .clk(clk),\n\
176                     \x20       .rst_n(rst_n),\n\
177                     \x20       .x_value({}),\n\
178                     \x20       .t_index(32'd0),\n\
179                     \x20       .bit_out(v{})\n\
180                     \x20   );\n\n",
181                    seed, inst_idx, prob_wire, id.0
182                ));
183                inst_idx += 1;
184            }
185            ScOp::BitwiseAnd { id, lhs, rhs } => {
186                let lhs_wire = value_to_wire(graph, *lhs);
187                let rhs_wire = value_to_wire(graph, *rhs);
188                sv.push_str(&format!(
189                    "    sc_bitstream_synapse u_syn_{} (\n\
190                     \x20       .pre_bit({}),\n\
191                     \x20       .w_bit({}),\n\
192                     \x20       .post_bit(v{})\n\
193                     \x20   );\n\n",
194                    inst_idx, lhs_wire, rhs_wire, id.0
195                ));
196                inst_idx += 1;
197            }
198            ScOp::LifStep {
199                id,
200                current,
201                leak,
202                gain,
203                noise,
204                params,
205            } => {
206                let current_wire = value_to_wire(graph, *current);
207                let leak_wire = value_to_wire(graph, *leak);
208                let gain_wire = value_to_wire(graph, *gain);
209                let noise_wire = value_to_wire(graph, *noise);
210                emit_target_dsp_attribute(&mut sv, &target);
211                sv.push_str(&format!(
212                    "    sc_lif_neuron #(\n\
213                     \x20       .DATA_WIDTH({}),\n\
214                     \x20       .FRACTION({}),\n\
215                     \x20       .V_REST({}),\n\
216                     \x20       .V_RESET({}),\n\
217                     \x20       .V_THRESHOLD({}),\n\
218                     \x20       .REFRACTORY_PERIOD({})\n\
219                     \x20   ) u_lif_{} (\n\
220                     \x20       .clk(clk),\n\
221                     \x20       .rst_n(rst_n),\n\
222                     \x20       .leak_k({}),\n\
223                     \x20       .gain_k({}),\n\
224                     \x20       .I_t({}),\n\
225                     \x20       .noise_in({}),\n\
226                     \x20       .spike_out(v{}_spike),\n\
227                     \x20       .v_out(v{}_v_out)\n\
228                     \x20   );\n\n",
229                    params.data_width,
230                    params.fraction,
231                    params.v_rest,
232                    params.v_reset,
233                    params.v_threshold,
234                    params.refractory_period,
235                    inst_idx,
236                    leak_wire,
237                    gain_wire,
238                    current_wire,
239                    noise_wire,
240                    id.0,
241                    id.0
242                ));
243                inst_idx += 1;
244            }
245            ScOp::DenseForward {
246                id,
247                inputs,
248                weights,
249                leak,
250                gain,
251                params,
252            } => {
253                let inputs_wire = value_to_wire(graph, *inputs);
254                let weights_wire = value_to_wire(graph, *weights);
255                let leak_wire = value_to_wire(graph, *leak);
256                let gain_wire = value_to_wire(graph, *gain);
257                emit_dense_fold_plan_comment(&mut sv, &target, params);
258                emit_target_dsp_attribute(&mut sv, &target);
259                sv.push_str(&format!(
260                    "    sc_dense_layer_core #(\n\
261                     \x20       .N_INPUTS({}),\n\
262                     \x20       .N_NEURONS({}),\n\
263                     \x20       .DATA_WIDTH({})\n\
264                     \x20   ) u_dense_{} (\n\
265                     \x20       .clk(clk),\n\
266                     \x20       .rst_n(rst_n),\n\
267                     \x20       .start_pulse(1'b1),\n\
268                     \x20       .stream_len(32'd{}),\n\
269                     \x20       .x_input_fp({}),\n\
270                     \x20       .weight_fp({}),\n\
271                     \x20       .y_min_fp(16'd0),\n\
272                     \x20       .y_max_fp(16'd256),\n\
273                     \x20       .cfg_leak({}),\n\
274                     \x20       .cfg_gain({}),\n\
275                     \x20       .I_t(),\n\
276                     \x20       .spikes(v{}_spikes),\n\
277                     \x20       .step_valid(),\n\
278                     \x20       .run_done(v{}_done),\n\
279                     \x20       .running(v{}_running)\n\
280                     \x20   );\n\n",
281                    params.n_inputs,
282                    params.n_neurons,
283                    params.data_width,
284                    inst_idx,
285                    params.stream_length,
286                    inputs_wire,
287                    weights_wire,
288                    leak_wire,
289                    gain_wire,
290                    id.0,
291                    id.0,
292                    id.0
293                ));
294                inst_idx += 1;
295            }
296            ScOp::DclsLayer {
297                id,
298                spike,
299                weights,
300                centre,
301                sigma,
302                params,
303            } => {
304                if params.tap_offsets.len() != params.n_taps {
305                    return Err(format!(
306                        "DclsLayer (v{}) expected {} tap offsets, got {}",
307                        id.0,
308                        params.n_taps,
309                        params.tap_offsets.len()
310                    ));
311                }
312                let spike_wire = value_to_wire(graph, *spike);
313                let weights_wire = value_to_wire(graph, *weights);
314                let centre_wire = value_to_wire(graph, *centre);
315                let sigma_wire = value_to_wire(graph, *sigma);
316                let tap_offsets = emit_concat_u32(&params.tap_offsets, params.ptr_width)?;
317                emit_target_dsp_attribute(&mut sv, &target);
318                sv.push_str(&format!(
319                    "    sc_dcls_layer_core #(\n\
320                     \x20       .N_TAPS({}),\n\
321                     \x20       .DATA_WIDTH({}),\n\
322                     \x20       .FRACTION({}),\n\
323                     \x20       .DELAY_DEPTH({}),\n\
324                     \x20       .PTR_WIDTH({})\n\
325                     \x20   ) u_dcls_{} (\n\
326                     \x20       .clk(clk),\n\
327                     \x20       .rst_n(rst_n),\n\
328                     \x20       .in_valid(1'b1),\n\
329                     \x20       .spike_in({}),\n\
330                     \x20       .tap_offsets({}),\n\
331                     \x20       .tap_weights_q88({}),\n\
332                     \x20       .centre_q88({}),\n\
333                     \x20       .sigma_q88({}),\n\
334                     \x20       .out_valid(v{}_valid),\n\
335                     \x20       .weighted_sum_q88(v{}),\n\
336                     \x20       .accumulator_q16_16(v{}_accumulator_q16_16),\n\
337                     \x20       .overflow(v{}_overflow),\n\
338                     \x20       .invalid_sigma(v{}_invalid_sigma)\n\
339                     \x20   );\n\n",
340                    params.n_taps,
341                    params.data_width,
342                    params.fraction,
343                    params.delay_depth,
344                    params.ptr_width,
345                    inst_idx,
346                    spike_wire,
347                    tap_offsets,
348                    weights_wire,
349                    centre_wire,
350                    sigma_wire,
351                    id.0,
352                    id.0,
353                    id.0,
354                    id.0,
355                    id.0
356                ));
357                inst_idx += 1;
358            }
359            ScOp::BitwiseXor { id, lhs, rhs } => {
360                let lhs_wire = value_to_wire(graph, *lhs);
361                let rhs_wire = value_to_wire(graph, *rhs);
362                sv.push_str(&format!(
363                    "    assign v{} = {} ^ {};\n",
364                    id.0, lhs_wire, rhs_wire
365                ));
366            }
367            ScOp::Reduce { id, input, mode } => {
368                let in_wire = value_to_wire(graph, *input);
369                let label = match mode {
370                    ReduceMode::Sum => "reduce_sum",
371                    ReduceMode::Max => "reduce_max",
372                };
373                sv.push_str(&format!(
374                    "    // {label}: passthrough for single-element; multi-element requires adder/comparator tree\n\
375                     \x20   assign v{id} = {wire};\n",
376                    label = label,
377                    id = id.0,
378                    wire = in_wire,
379                ));
380            }
381            ScOp::GraphForward {
382                id,
383                features,
384                adjacency,
385                n_nodes,
386                n_features,
387            } => {
388                emit_graph_forward(
389                    &mut sv,
390                    graph,
391                    inst_idx,
392                    *id,
393                    *features,
394                    *adjacency,
395                    *n_nodes,
396                    *n_features,
397                )?;
398                inst_idx += 1;
399            }
400            ScOp::SoftmaxAttention { id, q, k, v, dim_k } => {
401                emit_softmax_attention(&mut sv, graph, inst_idx, *id, *q, *k, *v, *dim_k)?;
402                inst_idx += 1;
403            }
404            ScOp::KuramotoStep {
405                id,
406                phases,
407                omega,
408                coupling,
409                dt,
410            } => {
411                emit_kuramoto_step(
412                    &mut sv, graph, inst_idx, *id, *phases, *omega, *coupling, *dt,
413                )?;
414                inst_idx += 1;
415            }
416            ScOp::Output { name, source, .. } => {
417                let src_wire = value_to_wire(graph, *source);
418                sv.push_str(&format!("    assign {} = {};\n", name, src_wire));
419            }
420            ScOp::Scale { id, input, factor } => {
421                let in_wire = value_to_wire(graph, *input);
422                let scale_int = (*factor * 256.0) as i64; // Q8.8
423                sv.push_str(&format!(
424                    "    assign v{} = ({} * {}) >>> 8;\n",
425                    id.0, in_wire, scale_int
426                ));
427            }
428            ScOp::Offset { id, input, offset } => {
429                let in_wire = value_to_wire(graph, *input);
430                let offset_int = (*offset * 256.0) as i64;
431                sv.push_str(&format!(
432                    "    assign v{} = {} + {};\n",
433                    id.0, in_wire, offset_int
434                ));
435            }
436            ScOp::DivConst { id, input, divisor } => {
437                let in_wire = value_to_wire(graph, *input);
438                sv.push_str(&format!(
439                    "    assign v{} = {} / {};\n",
440                    id.0, in_wire, divisor
441                ));
442            }
443            ScOp::Popcount { id, input } => {
444                let in_wire = value_to_wire(graph, *input);
445                sv.push_str(&format!(
446                    "    // Combinatorial popcount for v{id}\n\
447                     \x20   always_comb begin\n\
448                     \x20       v{id} = 64'd0;\n\
449                     \x20       for (integer _pc_i = 0; _pc_i < 64; _pc_i = _pc_i + 1)\n\
450                     \x20           v{id} = v{id} + {{63'd0, {wire}[_pc_i]}};\n\
451                     \x20   end\n\n",
452                    id = id.0,
453                    wire = in_wire,
454                ));
455            }
456            _ => {}
457        }
458    }
459
460    sv.push_str("\nendmodule\n");
461    let report = target.estimate_graph(graph);
462    Ok((sv, report))
463}
464
465fn type_to_width(ty: &ScType) -> usize {
466    ty.bit_width()
467}
468
469fn find_value_width(graph: &ScGraph, id: ValueId) -> usize {
470    for op in &graph.ops {
471        if op.result_id() == id {
472            return match op {
473                ScOp::Input { ty, .. } => type_to_width(ty),
474                ScOp::Constant { ty, .. } => type_to_width(ty),
475                ScOp::Encode { .. } | ScOp::BitwiseAnd { .. } | ScOp::BitwiseXor { .. } => 1,
476                ScOp::Popcount { .. } | ScOp::Reduce { .. } => 64,
477                ScOp::LifStep { params, .. } => params.data_width as usize,
478                ScOp::DenseForward { params, .. } => params.n_neurons,
479                ScOp::DclsLayer { params, .. } => params.data_width as usize,
480                ScOp::GraphForward {
481                    n_nodes,
482                    n_features,
483                    ..
484                } => n_nodes * n_features * GRAPH_DATA_WIDTH,
485                ScOp::KuramotoStep { phases, .. } => kuramoto_osc_count(graph, *phases)
486                    .map(|n| n * KURAMOTO_DATA_WIDTH)
487                    .unwrap_or(64),
488                ScOp::SoftmaxAttention { q, k, v, dim_k, .. } => {
489                    softmax_attention_shape(graph, *q, *k, *v, *dim_k)
490                        .map(|(qr, _, vc)| qr * vc * ATTN_DATA_WIDTH)
491                        .unwrap_or(64)
492                }
493                ScOp::Scale { .. } | ScOp::Offset { .. } | ScOp::DivConst { .. } => 64,
494                ScOp::Output { source, .. } => find_value_width(graph, *source),
495            };
496        }
497    }
498    16
499}
500
501fn value_to_wire(graph: &ScGraph, id: ValueId) -> String {
502    for op in &graph.ops {
503        if op.result_id() == id {
504            return match op {
505                ScOp::Input { name, .. } => name.clone(),
506                ScOp::Constant { id, .. } => format!("c{}", id.0),
507                ScOp::LifStep { id, .. } => format!("v{}_spike", id.0),
508                ScOp::DenseForward { id, .. } => format!("v{}_spikes", id.0),
509                _ => format!("v{}", id.0),
510            };
511        }
512    }
513    format!("v{}", id.0)
514}
515
516fn emit_concat_u32(values: &[u32], width: u32) -> Result<String, String> {
517    if width == 0 {
518        return Err("packed unsigned concatenation width must be positive".to_string());
519    }
520    let max_value = if width >= 32 {
521        u32::MAX
522    } else {
523        (1_u32 << width) - 1
524    };
525    let mut fields = Vec::with_capacity(values.len());
526    for value in values.iter().rev() {
527        if *value > max_value {
528            return Err(format!(
529                "packed unsigned value {} exceeds {}-bit field",
530                value, width
531            ));
532        }
533        fields.push(format!("{}'d{}", width, value));
534    }
535    Ok(format!("{{{}}}", fields.join(", ")))
536}
537
538// Fixed-point contract of the `sc_kuramoto_step` phase core (see hdl/sc_kuramoto_step.v).
539// Q8.16 signed, 24-bit, 64-entry sine LUT — fixed by the baked hardware LUT.
540const KURAMOTO_DATA_WIDTH: usize = 24;
541const KURAMOTO_FRACTION: usize = 16;
542const KURAMOTO_LUT_SIZE: usize = 64;
543
544// Fixed-point contract of the `sc_graph_forward` aggregation core
545// (see hdl/sc_graph_forward.v). Signed Q8.16, 24-bit, matching the phase core.
546const GRAPH_DATA_WIDTH: usize = 24;
547const GRAPH_FRACTION: usize = 16;
548
549// Fixed-point contract of the `sc_softmax_attention` core
550// (see hdl/sc_softmax_attention.v). Signed Q8.16, 24-bit, 256-entry exp LUT over
551// the symmetric [-16, 16) grid at 0.125 spacing.
552const ATTN_DATA_WIDTH: usize = 24;
553const ATTN_FRACTION: usize = 16;
554
555/// Quantise a finite real into signed Q(`frac`) of `width` bits, or `None` when the
556/// value is non-finite or falls outside the representable two's-complement range.
557fn q_fixed(value: f64, frac: usize, width: usize) -> Option<i64> {
558    if !value.is_finite() {
559        return None;
560    }
561    let scaled = (value * (1i64 << frac) as f64).round() as i64;
562    let min = -(1i64 << (width - 1));
563    let max = (1i64 << (width - 1)) - 1;
564    (min..=max).contains(&scaled).then_some(scaled)
565}
566
567/// Q(FRACTION) representation of the `2*pi` phase modulus.
568fn kuramoto_phase_modulus() -> i64 {
569    (std::f64::consts::TAU * (1i64 << KURAMOTO_FRACTION) as f64).round() as i64
570}
571
572/// Q(FRACTION) representation of the `pi` half-phase modulus.
573fn kuramoto_half_phase_modulus() -> i64 {
574    (std::f64::consts::PI * (1i64 << KURAMOTO_FRACTION) as f64).round() as i64
575}
576
577/// Quantise a real value into the signed Q8.16 Kuramoto datapath, rejecting out-of-range constants.
578fn kuramoto_fixed(value: f64, name: &str, id: ValueId) -> Result<i64, String> {
579    q_fixed(value, KURAMOTO_FRACTION, KURAMOTO_DATA_WIDTH).ok_or_else(|| {
580        format!(
581            "KuramotoStep (v{}) {} value {} is not representable in signed Q8.16 (24-bit)",
582            id.0, name, value
583        )
584    })
585}
586
587/// Quantise a real value into the signed Q8.16 graph datapath, rejecting out-of-range constants.
588fn graph_fixed(value: f64, name: &str, id: ValueId) -> Result<i64, String> {
589    q_fixed(value, GRAPH_FRACTION, GRAPH_DATA_WIDTH).ok_or_else(|| {
590        format!(
591            "GraphForward (v{}) {} value {} is not representable in signed Q8.16 (24-bit)",
592            id.0, name, value
593        )
594    })
595}
596
597/// Quantise a real value into the signed Q8.16 attention datapath, rejecting out-of-range constants.
598fn attn_fixed(value: f64, name: &str, id: ValueId) -> Result<i64, String> {
599    q_fixed(value, ATTN_FRACTION, ATTN_DATA_WIDTH).ok_or_else(|| {
600        format!(
601            "SoftmaxAttention (v{}) {} value {} is not representable in signed Q8.16 (24-bit)",
602            id.0, name, value
603        )
604    })
605}
606
607/// Pack signed Q(FRACTION) words of `width` bits into a Verilog concatenation with
608/// element 0 at the LSB.
609fn pack_q_bus(values: &[i64], width: usize) -> String {
610    let mask = (1i64 << width) - 1;
611    let fields: Vec<String> = values
612        .iter()
613        .rev()
614        .map(|v| format!("{}'d{}", width, v & mask))
615        .collect();
616    format!("{{{}}}", fields.join(", "))
617}
618
619/// Resolve a constant vector operand to its `f64` values, if present.
620fn const_f64_vec(graph: &ScGraph, id: ValueId) -> Option<Vec<f64>> {
621    for op in &graph.ops {
622        if op.result_id() == id {
623            return match op {
624                ScOp::Constant {
625                    value: ScConst::F64Vec(v),
626                    ..
627                } => Some(v.clone()),
628                ScOp::Constant {
629                    value: ScConst::I64Vec(v),
630                    ..
631                } => Some(v.iter().map(|x| *x as f64).collect()),
632                _ => None,
633            };
634        }
635    }
636    None
637}
638
639/// Infer the oscillator count from the phase operand's vector length.
640fn kuramoto_osc_count(graph: &ScGraph, phases: ValueId) -> Option<usize> {
641    for op in &graph.ops {
642        if op.result_id() == phases {
643            return match op {
644                ScOp::Constant {
645                    value: ScConst::F64Vec(v),
646                    ..
647                } => Some(v.len()),
648                ScOp::Constant {
649                    value: ScConst::I64Vec(v),
650                    ..
651                } => Some(v.len()),
652                ScOp::Input {
653                    ty: ScType::Vec { count, .. },
654                    ..
655                }
656                | ScOp::Constant {
657                    ty: ScType::Vec { count, .. },
658                    ..
659                } => Some(*count),
660                _ => None,
661            };
662        }
663    }
664    None
665}
666
667/// Instantiate the fixed-point `sc_kuramoto_step` core for a single Kuramoto IR step.
668///
669/// The `phases`, `omega` and `coupling` operands must be constants: `phases` and
670/// `omega` are length-`N` vectors and `coupling` is the row-major `N×N` matrix
671/// `K_nm`. Values are baked into the signed Q8.16 datapath contract of the core;
672/// initial phases are wrapped into `[0, 2*pi)` before quantisation.
673#[allow(clippy::too_many_arguments)]
674fn emit_kuramoto_step(
675    sv: &mut String,
676    graph: &ScGraph,
677    inst_idx: u32,
678    id: ValueId,
679    phases: ValueId,
680    omega: ValueId,
681    coupling: ValueId,
682    dt: f64,
683) -> Result<(), String> {
684    let phase_vals = const_f64_vec(graph, phases)
685        .ok_or_else(|| format!("KuramotoStep (v{}) requires constant phase values", id.0))?;
686    let n = phase_vals.len();
687    if n == 0 {
688        return Err(format!(
689            "KuramotoStep (v{}) needs at least one oscillator",
690            id.0
691        ));
692    }
693    let omega_vals = const_f64_vec(graph, omega)
694        .ok_or_else(|| format!("KuramotoStep (v{}) requires constant omega values", id.0))?;
695    if omega_vals.len() != n {
696        return Err(format!(
697            "KuramotoStep (v{}) omega length {} does not match {} oscillators",
698            id.0,
699            omega_vals.len(),
700            n
701        ));
702    }
703    let coupling_vals = const_f64_vec(graph, coupling).ok_or_else(|| {
704        format!(
705            "KuramotoStep (v{}) requires a constant coupling matrix",
706            id.0
707        )
708    })?;
709    if coupling_vals.len() != n * n {
710        return Err(format!(
711            "KuramotoStep (v{}) coupling length {} is not {n}×{n}",
712            id.0,
713            coupling_vals.len()
714        ));
715    }
716
717    let two_pi = std::f64::consts::TAU;
718    let phase_fixed = phase_vals
719        .iter()
720        .map(|theta| kuramoto_fixed(theta.rem_euclid(two_pi), "phase", id))
721        .collect::<Result<Vec<_>, _>>()?;
722    let omega_fixed = omega_vals
723        .iter()
724        .map(|w| kuramoto_fixed(*w, "omega", id))
725        .collect::<Result<Vec<_>, _>>()?;
726    let coupling_fixed = coupling_vals
727        .iter()
728        .map(|k| kuramoto_fixed(*k, "coupling", id))
729        .collect::<Result<Vec<_>, _>>()?;
730    let dt_fixed = kuramoto_fixed(dt, "dt", id)?;
731
732    let dw = KURAMOTO_DATA_WIDTH;
733    sv.push_str(&format!(
734        "    sc_kuramoto_step #(\n\
735         \x20       .N_OSC({n}),\n\
736         \x20       .DATA_WIDTH({dw}),\n\
737         \x20       .FRACTION({frac}),\n\
738         \x20       .LUT_SIZE({lut}),\n\
739         \x20       .DT_FIXED({dw}'sd{dt_fixed}),\n\
740         \x20       .PHASE_MODULUS({dw}'sd{modulus}),\n\
741         \x20       .HALF_PHASE_MODULUS({dw}'sd{half})\n\
742         \x20   ) u_kuramoto_{inst_idx} (\n\
743         \x20       .phases_in({phases_bus}),\n\
744         \x20       .omega({omega_bus}),\n\
745         \x20       .coupling({coupling_bus}),\n\
746         \x20       .phases_out(v{result})\n\
747         \x20   );\n\n",
748        frac = KURAMOTO_FRACTION,
749        lut = KURAMOTO_LUT_SIZE,
750        modulus = kuramoto_phase_modulus(),
751        half = kuramoto_half_phase_modulus(),
752        phases_bus = pack_q_bus(&phase_fixed, KURAMOTO_DATA_WIDTH),
753        omega_bus = pack_q_bus(&omega_fixed, KURAMOTO_DATA_WIDTH),
754        coupling_bus = pack_q_bus(&coupling_fixed, KURAMOTO_DATA_WIDTH),
755        result = id.0,
756    ));
757    Ok(())
758}
759
760/// Instantiate the fixed-point `sc_graph_forward` core for a single graph aggregation.
761///
762/// The `features` (`n_nodes × n_features`, node-major) and `adjacency`
763/// (`n_nodes × n_nodes`, row-major) operands must be constants. Values are baked
764/// into the signed Q8.16 datapath and the core emits the degree-normalised
765/// neighbourhood aggregate `agg[i][f] = (Σ_j adj[i][j]·feat[j][f]) / degree[i]`.
766fn emit_graph_forward(
767    sv: &mut String,
768    graph: &ScGraph,
769    inst_idx: u32,
770    id: ValueId,
771    features: ValueId,
772    adjacency: ValueId,
773    n_nodes: usize,
774    n_features: usize,
775) -> Result<(), String> {
776    if n_nodes == 0 || n_features == 0 {
777        return Err(format!(
778            "GraphForward (v{}) needs at least one node and one feature",
779            id.0
780        ));
781    }
782    let feat_vals = const_f64_vec(graph, features)
783        .ok_or_else(|| format!("GraphForward (v{}) requires constant feature values", id.0))?;
784    if feat_vals.len() != n_nodes * n_features {
785        return Err(format!(
786            "GraphForward (v{}) feature length {} is not {n_nodes}×{n_features}",
787            id.0,
788            feat_vals.len()
789        ));
790    }
791    let adj_vals = const_f64_vec(graph, adjacency).ok_or_else(|| {
792        format!(
793            "GraphForward (v{}) requires a constant adjacency matrix",
794            id.0
795        )
796    })?;
797    if adj_vals.len() != n_nodes * n_nodes {
798        return Err(format!(
799            "GraphForward (v{}) adjacency length {} is not {n_nodes}×{n_nodes}",
800            id.0,
801            adj_vals.len()
802        ));
803    }
804
805    let feat_fixed = feat_vals
806        .iter()
807        .map(|x| graph_fixed(*x, "feature", id))
808        .collect::<Result<Vec<_>, _>>()?;
809    let adj_fixed = adj_vals
810        .iter()
811        .map(|x| graph_fixed(*x, "adjacency", id))
812        .collect::<Result<Vec<_>, _>>()?;
813
814    sv.push_str(&format!(
815        "    sc_graph_forward #(\n\
816         \x20       .N_NODES({n_nodes}),\n\
817         \x20       .N_FEATURES({n_features}),\n\
818         \x20       .DATA_WIDTH({dw}),\n\
819         \x20       .FRACTION({frac})\n\
820         \x20   ) u_graph_{inst_idx} (\n\
821         \x20       .features({feat_bus}),\n\
822         \x20       .adjacency({adj_bus}),\n\
823         \x20       .agg(v{result})\n\
824         \x20   );\n\n",
825        dw = GRAPH_DATA_WIDTH,
826        frac = GRAPH_FRACTION,
827        feat_bus = pack_q_bus(&feat_fixed, GRAPH_DATA_WIDTH),
828        adj_bus = pack_q_bus(&adj_fixed, GRAPH_DATA_WIDTH),
829        result = id.0,
830    ));
831    Ok(())
832}
833
834/// Infer the `(q_rows, k_rows, v_cols)` attention shape from the constant operand
835/// lengths and `dim_k`, or `None` when the operands are non-constant or ill-shaped.
836///
837/// `q` is `q_rows × dim_k`, `k` is `k_rows × dim_k` and `v` is `k_rows × v_cols`.
838fn softmax_attention_shape(
839    graph: &ScGraph,
840    q: ValueId,
841    k: ValueId,
842    v: ValueId,
843    dim_k: usize,
844) -> Option<(usize, usize, usize)> {
845    if dim_k == 0 {
846        return None;
847    }
848    let q_vals = const_f64_vec(graph, q)?;
849    let k_vals = const_f64_vec(graph, k)?;
850    let v_vals = const_f64_vec(graph, v)?;
851    if q_vals.len() % dim_k != 0 || k_vals.len() % dim_k != 0 {
852        return None;
853    }
854    let q_rows = q_vals.len() / dim_k;
855    let k_rows = k_vals.len() / dim_k;
856    if k_rows == 0 || v_vals.len() % k_rows != 0 {
857        return None;
858    }
859    let v_cols = v_vals.len() / k_rows;
860    if q_rows == 0 || v_cols == 0 {
861        return None;
862    }
863    Some((q_rows, k_rows, v_cols))
864}
865
866/// Instantiate the fixed-point `sc_softmax_attention` core for a single attention op.
867///
868/// The `q` (`q_rows × dim_k`), `k` (`k_rows × dim_k`) and `v` (`k_rows × v_cols`)
869/// operands must be constants; shapes are inferred from their lengths and `dim_k`.
870/// Values are baked into the signed Q8.16 datapath and the softmax scaling
871/// `1/sqrt(dim_k)` and exp-LUT geometry are baked as instance parameters.
872fn emit_softmax_attention(
873    sv: &mut String,
874    graph: &ScGraph,
875    inst_idx: u32,
876    id: ValueId,
877    q: ValueId,
878    k: ValueId,
879    v: ValueId,
880    dim_k: usize,
881) -> Result<(), String> {
882    if dim_k == 0 {
883        return Err(format!(
884            "SoftmaxAttention (v{}) needs a positive dim_k",
885            id.0
886        ));
887    }
888    let q_vals = const_f64_vec(graph, q).ok_or_else(|| {
889        format!(
890            "SoftmaxAttention (v{}) requires constant query values",
891            id.0
892        )
893    })?;
894    let k_vals = const_f64_vec(graph, k)
895        .ok_or_else(|| format!("SoftmaxAttention (v{}) requires constant key values", id.0))?;
896    let v_vals = const_f64_vec(graph, v).ok_or_else(|| {
897        format!(
898            "SoftmaxAttention (v{}) requires constant value values",
899            id.0
900        )
901    })?;
902    if q_vals.len() % dim_k != 0 {
903        return Err(format!(
904            "SoftmaxAttention (v{}) query length {} is not a multiple of dim_k {dim_k}",
905            id.0,
906            q_vals.len()
907        ));
908    }
909    if k_vals.len() % dim_k != 0 {
910        return Err(format!(
911            "SoftmaxAttention (v{}) key length {} is not a multiple of dim_k {dim_k}",
912            id.0,
913            k_vals.len()
914        ));
915    }
916    let q_rows = q_vals.len() / dim_k;
917    let k_rows = k_vals.len() / dim_k;
918    if k_rows == 0 {
919        return Err(format!(
920            "SoftmaxAttention (v{}) needs at least one key row",
921            id.0
922        ));
923    }
924    if v_vals.len() % k_rows != 0 {
925        return Err(format!(
926            "SoftmaxAttention (v{}) value length {} is not {k_rows} rows",
927            id.0,
928            v_vals.len()
929        ));
930    }
931    let v_cols = v_vals.len() / k_rows;
932    if q_rows == 0 || v_cols == 0 {
933        return Err(format!(
934            "SoftmaxAttention (v{}) needs at least one query row and value column",
935            id.0
936        ));
937    }
938
939    let inv_temp = 1.0 / (dim_k as f64).sqrt();
940    let q_fixed = q_vals
941        .iter()
942        .map(|x| attn_fixed(*x, "query", id))
943        .collect::<Result<Vec<_>, _>>()?;
944    let k_fixed = k_vals
945        .iter()
946        .map(|x| attn_fixed(*x, "key", id))
947        .collect::<Result<Vec<_>, _>>()?;
948    let v_fixed = v_vals
949        .iter()
950        .map(|x| attn_fixed(*x, "value", id))
951        .collect::<Result<Vec<_>, _>>()?;
952    let inv_temp_fixed = attn_fixed(inv_temp, "inv_temp", id)?;
953
954    // exp LUT geometry (mirrors hdl/sc_softmax_attention.v): 0.125 grid over [-16, 16).
955    let exp_shift = ATTN_FRACTION - 3;
956    let exp_min_abs = (16.0 * (1i64 << ATTN_FRACTION) as f64).round() as i64;
957
958    sv.push_str(&format!(
959        "    sc_softmax_attention #(\n\
960         \x20       .Q_ROWS({q_rows}),\n\
961         \x20       .K_ROWS({k_rows}),\n\
962         \x20       .DIM_K({dim_k}),\n\
963         \x20       .V_COLS({v_cols}),\n\
964         \x20       .DATA_WIDTH({dw}),\n\
965         \x20       .FRACTION({frac}),\n\
966         \x20       .INV_TEMP({inv_temp_lit}),\n\
967         \x20       .EXP_SHIFT({exp_shift}),\n\
968         \x20       .EXP_MIN_ABS({exp_min_abs})\n\
969         \x20   ) u_softmax_{inst_idx} (\n\
970         \x20       .q_in({q_bus}),\n\
971         \x20       .k_in({k_bus}),\n\
972         \x20       .v_in({v_bus}),\n\
973         \x20       .attn_out(v{result})\n\
974         \x20   );\n\n",
975        dw = ATTN_DATA_WIDTH,
976        frac = ATTN_FRACTION,
977        inv_temp_lit = signed_q_literal(inv_temp_fixed, ATTN_DATA_WIDTH),
978        q_bus = pack_q_bus(&q_fixed, ATTN_DATA_WIDTH),
979        k_bus = pack_q_bus(&k_fixed, ATTN_DATA_WIDTH),
980        v_bus = pack_q_bus(&v_fixed, ATTN_DATA_WIDTH),
981        result = id.0,
982    ));
983    Ok(())
984}
985
986fn emit_target_dsp_attribute(sv: &mut String, target: &SvTarget) {
987    if let Some(attribute) = target.dsp_attribute() {
988        sv.push_str("    ");
989        sv.push_str(attribute);
990        sv.push('\n');
991    }
992}
993
994fn emit_dense_fold_plan_comment(sv: &mut String, target: &SvTarget, params: &DenseParams) {
995    let Some(plan) = target.dense_fold_plan(params.n_inputs, params.n_neurons) else {
996        return;
997    };
998    if !plan.fold_required {
999        return;
1000    }
1001    sv.push_str(&format!(
1002        "    // Dense fold plan: unfurled_macs={}, dsp_budget={}, dsp_per_cycle={}, output_parallelism={}, input_parallelism={}, compute_cycles={}\n",
1003        plan.mac_count,
1004        plan.dsp_budget,
1005        plan.dsp_per_cycle,
1006        plan.output_parallelism,
1007        plan.input_parallelism,
1008        plan.compute_cycles
1009    ));
1010}
1011
1012fn emit_ram_style_attribute(sv: &mut String, target: &SvTarget, bits: u64) {
1013    if let Some(style) = target.ram_style_for_bits(bits) {
1014        sv.push_str(&format!("    (* ram_style = \"{}\" *)\n", style));
1015    }
1016}
1017
1018/// Format a signed fixed-point value as a Verilog literal (sign outside the sized base).
1019///
1020/// Verilog rejects `16'sd-51`; the negative sign must precede the sized literal as
1021/// `-16'sd51`.
1022fn signed_q_literal(value: i64, width: usize) -> String {
1023    if value < 0 {
1024        format!("-{}'sd{}", width, value.unsigned_abs())
1025    } else {
1026        format!("{}'sd{}", width, value)
1027    }
1028}
1029
1030fn emit_constant(sv: &mut String, id: ValueId, value: &ScConst, target: &SvTarget) {
1031    match value {
1032        ScConst::F64(v) => {
1033            let fp = (*v * 256.0) as i64; // Q8.8
1034            sv.push_str(&format!(
1035                "    localparam signed [15:0] c{} = {};\n",
1036                id.0,
1037                signed_q_literal(fp, 16)
1038            ));
1039        }
1040        ScConst::I64(v) => {
1041            sv.push_str(&format!(
1042                "    localparam signed [15:0] c{} = {};\n",
1043                id.0,
1044                signed_q_literal(*v, 16)
1045            ));
1046        }
1047        ScConst::U64(v) => {
1048            sv.push_str(&format!("    localparam [31:0] c{} = 32'd{};\n", id.0, v));
1049        }
1050        ScConst::F64Vec(vec) => {
1051            let width = vec.len().saturating_mul(16);
1052            if width == 0 {
1053                sv.push_str(&format!("    wire [0:0] c{};\n", id.0));
1054                return;
1055            }
1056            emit_ram_style_attribute(sv, target, width as u64);
1057            sv.push_str(&format!("    wire [{}:0] c{};\n", width - 1, id.0));
1058            for (i, v) in vec.iter().enumerate() {
1059                let fp = (*v * 256.0) as i64;
1060                sv.push_str(&format!(
1061                    "    assign c{}[{} +: 16] = {};\n",
1062                    id.0,
1063                    i * 16,
1064                    signed_q_literal(fp, 16)
1065                ));
1066            }
1067        }
1068        ScConst::I64Vec(vec) => {
1069            let width = vec.len().saturating_mul(16);
1070            if width == 0 {
1071                sv.push_str(&format!("    wire [0:0] c{};\n", id.0));
1072                return;
1073            }
1074            emit_ram_style_attribute(sv, target, width as u64);
1075            sv.push_str(&format!("    wire [{}:0] c{};\n", width - 1, id.0));
1076            for (i, v) in vec.iter().enumerate() {
1077                sv.push_str(&format!(
1078                    "    assign c{}[{} +: 16] = {};\n",
1079                    id.0,
1080                    i * 16,
1081                    signed_q_literal(*v, 16)
1082                ));
1083            }
1084        }
1085    }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use super::*;
1091    use crate::ir::builder::ScGraphBuilder;
1092    use crate::ir::sv_target::{SkuKind, SvTarget};
1093
1094    #[test]
1095    fn dcls_layer_emits_core_with_q88_contract_ports() {
1096        let mut builder = ScGraphBuilder::new("dcls_contract");
1097        let spike = builder.input("spike_in", ScType::Bool);
1098        let weights = builder.constant(
1099            ScConst::I64Vec(vec![256, 128, -64]),
1100            ScType::Vec {
1101                element: Box::new(ScType::FixedPoint { width: 16, frac: 8 }),
1102                count: 3,
1103            },
1104        );
1105        let centre = builder.constant(ScConst::I64(256), ScType::FixedPoint { width: 16, frac: 8 });
1106        let sigma = builder.constant(ScConst::I64(512), ScType::FixedPoint { width: 16, frac: 8 });
1107        let result = builder.dcls_layer(
1108            spike,
1109            weights,
1110            centre,
1111            sigma,
1112            DclsParams {
1113                n_taps: 3,
1114                data_width: 16,
1115                fraction: 8,
1116                delay_depth: 31,
1117                ptr_width: 5,
1118                tap_offsets: vec![0, 1, 2],
1119            },
1120        );
1121        builder.output("weighted_sum", result);
1122
1123        let sv = emit(&builder.build()).expect("DCLS layer should emit synthesizable RTL");
1124        assert!(sv.contains("sc_dcls_layer_core"));
1125        assert!(sv.contains(".tap_offsets({5'd2, 5'd1, 5'd0})"));
1126        assert!(sv.contains(".accumulator_q16_16(v4_accumulator_q16_16)"));
1127        assert!(sv.contains(".overflow(v4_overflow)"));
1128        assert!(sv.contains(".invalid_sigma(v4_invalid_sigma)"));
1129        assert!(sv.contains("assign weighted_sum = v4;"));
1130    }
1131
1132    #[test]
1133    fn ultrascale_plus_target_emits_dsp48e2_metadata_and_resource_report() {
1134        let mut builder = ScGraphBuilder::new("ultrascale_dense");
1135        let inputs = builder.input(
1136            "inputs",
1137            ScType::Vec {
1138                element: Box::new(ScType::FixedPoint { width: 16, frac: 8 }),
1139                count: 4,
1140            },
1141        );
1142        let weights = builder.constant(
1143            ScConst::I64Vec(vec![128; 12]),
1144            ScType::Vec {
1145                element: Box::new(ScType::FixedPoint { width: 16, frac: 8 }),
1146                count: 12,
1147            },
1148        );
1149        let leak = builder.constant(ScConst::I64(16), ScType::FixedPoint { width: 16, frac: 8 });
1150        let gain = builder.constant(ScConst::I64(1), ScType::FixedPoint { width: 16, frac: 8 });
1151        let result = builder.dense_forward(
1152            inputs,
1153            weights,
1154            leak,
1155            gain,
1156            DenseParams {
1157                n_inputs: 4,
1158                n_neurons: 3,
1159                ..DenseParams::default()
1160            },
1161        );
1162        builder.output("spikes", result);
1163
1164        let (sv, report) = emit_systemverilog_with_target(
1165            &builder.build(),
1166            SvTarget::zynq_ultrascale_plus(SkuKind::Zu3eg, 250),
1167        )
1168        .expect("UltraScale+ target emission should succeed");
1169
1170        assert!(sv.contains("Target: Zynq UltraScale+ MPSoC ZU3EG"));
1171        assert!(sv.contains("sc_target_dsp = \"DSP48E2\""));
1172        assert!(sv.contains("(* ram_style = \"distributed\" *)"));
1173        assert_eq!(report.device_part, "xczu3eg-sbva484-1-e");
1174        assert!(report.dsp_estimated >= 12);
1175        assert!(report.fits_dsp_budget);
1176    }
1177
1178    #[test]
1179    fn ultrascale_plus_over_budget_dense_emits_fold_plan_comment() {
1180        let mut builder = ScGraphBuilder::new("ultrascale_fold_dense");
1181        let inputs = builder.input(
1182            "inputs",
1183            ScType::Vec {
1184                element: Box::new(ScType::FixedPoint { width: 16, frac: 8 }),
1185                count: 64,
1186            },
1187        );
1188        let weights = builder.constant(
1189            ScConst::I64Vec(vec![128; 64 * 32]),
1190            ScType::Vec {
1191                element: Box::new(ScType::FixedPoint { width: 16, frac: 8 }),
1192                count: 64 * 32,
1193            },
1194        );
1195        let leak = builder.constant(ScConst::I64(16), ScType::FixedPoint { width: 16, frac: 8 });
1196        let gain = builder.constant(ScConst::I64(1), ScType::FixedPoint { width: 16, frac: 8 });
1197        let result = builder.dense_forward(
1198            inputs,
1199            weights,
1200            leak,
1201            gain,
1202            DenseParams {
1203                n_inputs: 64,
1204                n_neurons: 32,
1205                ..DenseParams::default()
1206            },
1207        );
1208        builder.output("spikes", result);
1209
1210        let (sv, report) = emit_systemverilog_with_target(
1211            &builder.build(),
1212            SvTarget::zynq_ultrascale_plus(SkuKind::Zu3eg, 250),
1213        )
1214        .expect("UltraScale+ target emission should produce fold-plan metadata");
1215
1216        assert!(sv.contains("Dense fold plan: unfurled_macs=2048"));
1217        assert!(sv.contains("dsp_per_cycle=320"));
1218        assert!(sv.contains("compute_cycles=7"));
1219        assert!(report.dense_fold_plan.is_some());
1220    }
1221}