rlx-fpga 0.2.13

FPGA backend for RLX — per-graph datapath synthesis. IR → Verilog → bitstream.
Documentation
// RLX — versatile ML compiler + runtime.
// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

//! Synthesis scripts + optional board constraints for FPGA export.
//!
//! RTL (`top.sv`) is always target-agnostic. These sidecars tell Yosys /
//! nextpnr how to map soft ports onto a concrete part when requested.

use crate::codegen::Artifact;
use crate::export_config::{FpgaExportConfig, HwTarget};
use crate::model::Model;

/// Emit `synth.sh`, `EXPORT.md`, and optional constraint files.
pub fn collect_synth_artifacts(model: &Model, cfg: &FpgaExportConfig) -> Vec<Artifact> {
    let mut out = Vec::new();
    if cfg.emit_synth_scripts {
        out.push(Artifact {
            rel_path: "synth.sh".into(),
            content: emit_synth_sh(&cfg.hw_target),
        });
        out.push(Artifact {
            rel_path: "EXPORT.md".into(),
            content: emit_export_md(model, cfg),
        });
    }
    if cfg.emit_constraints && cfg.hw_target.emits_constraints() {
        if let Some((path, body)) = emit_constraints(&cfg.hw_target) {
            out.push(Artifact {
                rel_path: path,
                content: body,
            });
        }
    }
    out
}

fn emit_synth_sh(hw: &HwTarget) -> String {
    let mut s = String::from(
        "#!/usr/bin/env bash\nset -euo pipefail\n# Generated by rlx-fpga — do not hand-edit.\ncd \"$(dirname \"$0\")\"\n\n",
    );
    match hw {
        HwTarget::Generic => {
            s.push_str(
                r#"# Target-agnostic: Yosys generic synth to a JSON netlist.
# Soft ports only — wrap `top` with a board shell for pin mapping.
yosys -p 'read_verilog -sv top.sv layers/*.sv primitives/*.sv; hierarchy -top top; synth -top top; write_json out.json'
echo "wrote out.json (generic). Bind pins in a board wrapper before P&R."
"#,
            );
        }
        HwTarget::Ecp5 { device, package } => {
            s.push_str(&format!(
                r#"yosys -p 'read_verilog -sv top.sv layers/*.sv primitives/*.sv; synth_ecp5 -top top -json out.json'
nextpnr-ecp5 --json out.json --textcfg out.config --{device} --package {package}
ecppack out.config out.bit
echo "wrote out.bit"
"#
            ));
        }
        HwTarget::Ice40 { device, package } => {
            s.push_str(&format!(
                r#"yosys -p 'read_verilog -sv top.sv layers/*.sv primitives/*.sv; synth_ice40 -top top -json out.json'
nextpnr-ice40 --json out.json --pcf constraints.pcf --{device} --package {package} --asc out.asc
icepack out.asc out.bin
echo "wrote out.bin"
"#
            ));
        }
        HwTarget::Xilinx7 { part } => {
            s.push_str(&format!(
                r#"# Experimental open XC7 flow — requires nextpnr-xilinx / prjxray tooling.
yosys -p 'read_verilog -sv top.sv layers/*.sv primitives/*.sv; synth_xilinx -flatten -top top; write_json out.json'
echo "wrote out.json for part {part}. Run nextpnr-xilinx / vendor tools next."
"#
            ));
        }
    }
    s
}

fn emit_export_md(model: &Model, cfg: &FpgaExportConfig) -> String {
    format!(
        r#"# RLX FPGA export — `{name}`

| Field | Value |
|-------|-------|
| Model | `{name}` |
| Input length | {input_len} |
| Layers | {n_layers} |
| Tune | `{tune}` |
| Hardware target | {hw} |
| RTL | **target-agnostic** SystemVerilog (`top.sv` soft ports) |

## Ports (`top`)

| Port | Dir | Role |
|------|-----|------|
{ports}

## Soft I/O

| Field | Value |
|-------|-------|
| Input iface | `{in_iface:?}` |
| Output iface | `{out_iface:?}` |
| Bind input | `{bind_in}` |
| Bind outputs | `{bind_out}` |

## Build

```sh
bash synth.sh
```

Generic target produces `out.json` only. Board targets (`ecp5`, `ice40`, …)
run place-and-route when the open toolchain is on `PATH`.

## Parity

Bit-exact reference: `rlx_fpga::reference` (Rust) ↔ emitted Verilog.
Do not diff against f32 Cortex-M requant paths.
"#,
        name = model.name,
        input_len = model.input_len,
        n_layers = model.layers.len(),
        tune = cfg.tune,
        hw = cfg.hw_target,
        ports = crate::codegen::io_ports::port_table_md(&cfg.io, model),
        in_iface = cfg.io.input,
        out_iface = cfg.io.output,
        bind_in = cfg.io.bind.input.as_deref().unwrap_or("(first Op::Input)"),
        bind_out = if cfg.io.bind.outputs.is_empty() {
            "(graph.outputs[0])".into()
        } else {
            cfg.io.bind.outputs.join(", ")
        },
    )
}

fn emit_constraints(hw: &HwTarget) -> Option<(String, String)> {
    match hw {
        HwTarget::Generic => None,
        HwTarget::Ecp5 { .. } => Some((
            "constraints.lpf".into(),
            r#"# Stub Lattice ECP5 constraints — map soft ports to board pins.
# BLOCK RESETPATHS;
# BLOCK ASYNCPATHS;
# LOCATE COMP "clk" SITE "P6";
# IOBUF PORT "clk" IO_TYPE=LVCMOS33;
# LOCATE COMP "rst" SITE "...";
# LOCATE COMP "start" SITE "...";
# LOCATE COMP "done" SITE "...";
"#
            .into(),
        )),
        HwTarget::Ice40 { .. } => Some((
            "constraints.pcf".into(),
            r#"# Stub iCE40 PCF — map soft ports to board pins.
# set_io clk 35
# set_io rst 37
# set_io start 34
# set_io done 32
"#
            .into(),
        )),
        HwTarget::Xilinx7 { .. } => Some((
            "constraints.xdc".into(),
            r#"# Stub Xilinx XDC — map soft ports to board pins.
# set_property PACKAGE_PIN <PIN> [get_ports clk]
# set_property IOSTANDARD LVCMOS33 [get_ports clk]
"#
            .into(),
        )),
    }
}