Skip to content

Hardware-Aware SNN NAS

NSGA-II evolutionary search over SNN architectures under FPGA resource budgets.

Searches {neuron model, layer width, bitstream length, delay range} jointly — the first NAS that optimizes hardware parameters alongside topology.

The public surface exposes derived architecture helpers for layer count, compiler-facing layer dimensions, and dense connection counts. Search and equivalence result objects also provide compact textual summaries for logs, reports, and CI artifacts.

Search Space

sc_neurocore.nas.search_space

Define the architecture search space for hardware-aware SNN NAS.

Search dimensions
  • n_layers: number of hidden layers
  • widths: neurons per layer
  • neuron_type: per-layer neuron model
  • bitstream_length: per-layer SC precision (L)
  • delay_range: maximum synaptic delay per layer

Each architecture encodes one point in this joint space. FPGA constraints (LUT, BRAM budgets) prune infeasible points.

Architecture dataclass

One point in the NAS search space.

Source code in src/sc_neurocore/nas/search_space.py
Python
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@dataclass
class Architecture:
    """One point in the NAS search space."""

    n_inputs: int
    layer_widths: list[int]
    neuron_types: list[str]
    bitstream_lengths: list[int]
    delay_ranges: list[int]
    fitness_accuracy: float = 0.0
    fitness_luts: int = 0
    fitness_energy_nj: float = 0.0
    dominates_count: int = 0

    @property
    def n_layers(self) -> int:
        """Return the number of layers encoded by this architecture."""
        return len(self.layer_widths)

    @property
    def layer_sizes(self) -> list[tuple[int, int]]:
        """Return adjacent layer dimensions for hardware cost estimation."""
        sizes: list[tuple[int, int]] = []
        prev = self.n_inputs
        for w in self.layer_widths:
            sizes.append((prev, w))
            prev = w
        return sizes

    @property
    def total_params(self) -> int:
        """Return the dense connection count across all encoded layers."""
        return sum(n_in * n_out for n_in, n_out in self.layer_sizes)

n_layers property

Return the number of layers encoded by this architecture.

layer_sizes property

Return adjacent layer dimensions for hardware cost estimation.

total_params property

Return the dense connection count across all encoded layers.

SearchSpace dataclass

Configurable NAS search space.

Parameters

n_inputs : int Input dimension. n_outputs : int Output dimension (width of final layer). min_layers, max_layers : int Range of hidden layer count. width_choices : list of int Candidate widths per layer. neuron_choices : list of str Candidate neuron models. L_choices : list of int Candidate bitstream lengths. delay_choices : list of int Candidate max-delay values.

Source code in src/sc_neurocore/nas/search_space.py
Python
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@dataclass
class SearchSpace:
    """Configurable NAS search space.

    Parameters
    ----------
    n_inputs : int
        Input dimension.
    n_outputs : int
        Output dimension (width of final layer).
    min_layers, max_layers : int
        Range of hidden layer count.
    width_choices : list of int
        Candidate widths per layer.
    neuron_choices : list of str
        Candidate neuron models.
    L_choices : list of int
        Candidate bitstream lengths.
    delay_choices : list of int
        Candidate max-delay values.
    """

    n_inputs: int
    n_outputs: int
    min_layers: int = 1
    max_layers: int = 4
    width_choices: list[int] = field(default_factory=lambda: list(WIDTH_CHOICES))
    neuron_choices: list[str] = field(default_factory=lambda: list(NEURON_CHOICES))
    L_choices: list[int] = field(default_factory=lambda: list(L_CHOICES))
    delay_choices: list[int] = field(default_factory=lambda: list(DELAY_CHOICES))

    def random_architecture(self, rng: np.random.RandomState) -> Architecture:
        """Sample a random architecture from the space."""
        n_layers = rng.randint(self.min_layers, self.max_layers + 1)
        widths = [int(rng.choice(self.width_choices)) for _ in range(n_layers - 1)]
        widths.append(self.n_outputs)
        neurons = [str(rng.choice(self.neuron_choices)) for _ in range(n_layers)]
        lengths = [int(rng.choice(self.L_choices)) for _ in range(n_layers)]
        delays = [int(rng.choice(self.delay_choices)) for _ in range(n_layers)]
        return Architecture(
            n_inputs=self.n_inputs,
            layer_widths=widths,
            neuron_types=neurons,
            bitstream_lengths=lengths,
            delay_ranges=delays,
        )

    def mutate(self, arch: Architecture, rng: np.random.RandomState) -> Architecture:
        """Mutate one random gene in the architecture."""
        widths = list(arch.layer_widths)
        neurons = list(arch.neuron_types)
        lengths = list(arch.bitstream_lengths)
        delays = list(arch.delay_ranges)

        gene = rng.randint(0, 4)
        layer_idx = rng.randint(0, arch.n_layers)

        if gene == 0 and layer_idx < arch.n_layers - 1:
            widths[layer_idx] = int(rng.choice(self.width_choices))
        elif gene == 1:
            neurons[layer_idx] = str(rng.choice(self.neuron_choices))
        elif gene == 2:
            lengths[layer_idx] = int(rng.choice(self.L_choices))
        else:
            delays[layer_idx] = int(rng.choice(self.delay_choices))

        return Architecture(
            n_inputs=arch.n_inputs,
            layer_widths=widths,
            neuron_types=neurons,
            bitstream_lengths=lengths,
            delay_ranges=delays,
        )

    def crossover(
        self, a: Architecture, b: Architecture, rng: np.random.RandomState
    ) -> Architecture:
        """Uniform crossover between two architectures of equal layer count."""
        n = min(a.n_layers, b.n_layers)
        widths, neurons, lengths, delays = [], [], [], []
        for i in range(n):
            src = a if rng.random() < 0.5 else b
            widths.append(src.layer_widths[i])
            neurons.append(src.neuron_types[i])
            lengths.append(src.bitstream_lengths[i])
            delays.append(src.delay_ranges[i])
        return Architecture(
            n_inputs=a.n_inputs,
            layer_widths=widths,
            neuron_types=neurons,
            bitstream_lengths=lengths,
            delay_ranges=delays,
        )

    @property
    def space_size(self) -> int:
        """Approximate total architectures in the search space."""
        per_layer = (
            len(self.width_choices)
            * len(self.neuron_choices)
            * len(self.L_choices)
            * len(self.delay_choices)
        )
        total = 0
        for n in range(self.min_layers, self.max_layers + 1):
            total += per_layer**n
        return total

space_size property

Approximate total architectures in the search space.

random_architecture(rng)

Sample a random architecture from the space.

Source code in src/sc_neurocore/nas/search_space.py
Python
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def random_architecture(self, rng: np.random.RandomState) -> Architecture:
    """Sample a random architecture from the space."""
    n_layers = rng.randint(self.min_layers, self.max_layers + 1)
    widths = [int(rng.choice(self.width_choices)) for _ in range(n_layers - 1)]
    widths.append(self.n_outputs)
    neurons = [str(rng.choice(self.neuron_choices)) for _ in range(n_layers)]
    lengths = [int(rng.choice(self.L_choices)) for _ in range(n_layers)]
    delays = [int(rng.choice(self.delay_choices)) for _ in range(n_layers)]
    return Architecture(
        n_inputs=self.n_inputs,
        layer_widths=widths,
        neuron_types=neurons,
        bitstream_lengths=lengths,
        delay_ranges=delays,
    )

mutate(arch, rng)

Mutate one random gene in the architecture.

Source code in src/sc_neurocore/nas/search_space.py
Python
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def mutate(self, arch: Architecture, rng: np.random.RandomState) -> Architecture:
    """Mutate one random gene in the architecture."""
    widths = list(arch.layer_widths)
    neurons = list(arch.neuron_types)
    lengths = list(arch.bitstream_lengths)
    delays = list(arch.delay_ranges)

    gene = rng.randint(0, 4)
    layer_idx = rng.randint(0, arch.n_layers)

    if gene == 0 and layer_idx < arch.n_layers - 1:
        widths[layer_idx] = int(rng.choice(self.width_choices))
    elif gene == 1:
        neurons[layer_idx] = str(rng.choice(self.neuron_choices))
    elif gene == 2:
        lengths[layer_idx] = int(rng.choice(self.L_choices))
    else:
        delays[layer_idx] = int(rng.choice(self.delay_choices))

    return Architecture(
        n_inputs=arch.n_inputs,
        layer_widths=widths,
        neuron_types=neurons,
        bitstream_lengths=lengths,
        delay_ranges=delays,
    )

crossover(a, b, rng)

Uniform crossover between two architectures of equal layer count.

Source code in src/sc_neurocore/nas/search_space.py
Python
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def crossover(
    self, a: Architecture, b: Architecture, rng: np.random.RandomState
) -> Architecture:
    """Uniform crossover between two architectures of equal layer count."""
    n = min(a.n_layers, b.n_layers)
    widths, neurons, lengths, delays = [], [], [], []
    for i in range(n):
        src = a if rng.random() < 0.5 else b
        widths.append(src.layer_widths[i])
        neurons.append(src.neuron_types[i])
        lengths.append(src.bitstream_lengths[i])
        delays.append(src.delay_ranges[i])
    return Architecture(
        n_inputs=a.n_inputs,
        layer_widths=widths,
        neuron_types=neurons,
        bitstream_lengths=lengths,
        delay_ranges=delays,
    )

Search Engine

sc_neurocore.nas.search

NSGA-II evolutionary search over SNN architectures under FPGA constraints.

Searches {neuron model, layer width, bitstream length, delay range} jointly, evaluating each candidate for accuracy (simulated) and hardware cost (via the energy estimator). Returns a Pareto front of non-dominated architectures.

No equivalent exists: SpikeNAS searches only software architectures. This is the first NAS that searches hardware parameters (L, delays, LUTs) alongside network topology.

NASResult dataclass

Result of a NAS run.

Source code in src/sc_neurocore/nas/search.py
Python
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@dataclass
class NASResult:
    """Result of a NAS run."""

    pareto_front: list[Architecture]
    all_evaluated: list[Architecture]
    generations: int
    total_evaluations: int

    def best_accuracy(self) -> Architecture | None:
        """Architecture with highest accuracy on the Pareto front."""
        if not self.pareto_front:
            return None
        return max(self.pareto_front, key=lambda a: a.fitness_accuracy)

    def best_efficiency(self) -> Architecture | None:
        """Architecture with lowest energy on the Pareto front."""
        if not self.pareto_front:
            return None
        return min(self.pareto_front, key=lambda a: a.fitness_energy_nj)

    def summary(self) -> str:
        """Return a line-oriented summary of the Pareto front."""
        lines = [
            f"NAS Result: {self.generations} generations, {self.total_evaluations} evaluations",
            f"Pareto front: {len(self.pareto_front)} architectures",
        ]
        for i, a in enumerate(self.pareto_front):
            lines.append(
                f"  [{i}] {a.layer_widths} L={a.bitstream_lengths} "
                f"acc={a.fitness_accuracy:.3f} luts={a.fitness_luts} "
                f"E={a.fitness_energy_nj:.1f}nJ"
            )
        return "\n".join(lines)

best_accuracy()

Architecture with highest accuracy on the Pareto front.

Source code in src/sc_neurocore/nas/search.py
Python
43
44
45
46
47
def best_accuracy(self) -> Architecture | None:
    """Architecture with highest accuracy on the Pareto front."""
    if not self.pareto_front:
        return None
    return max(self.pareto_front, key=lambda a: a.fitness_accuracy)

best_efficiency()

Architecture with lowest energy on the Pareto front.

Source code in src/sc_neurocore/nas/search.py
Python
49
50
51
52
53
def best_efficiency(self) -> Architecture | None:
    """Architecture with lowest energy on the Pareto front."""
    if not self.pareto_front:
        return None
    return min(self.pareto_front, key=lambda a: a.fitness_energy_nj)

summary()

Return a line-oriented summary of the Pareto front.

Source code in src/sc_neurocore/nas/search.py
Python
55
56
57
58
59
60
61
62
63
64
65
66
67
def summary(self) -> str:
    """Return a line-oriented summary of the Pareto front."""
    lines = [
        f"NAS Result: {self.generations} generations, {self.total_evaluations} evaluations",
        f"Pareto front: {len(self.pareto_front)} architectures",
    ]
    for i, a in enumerate(self.pareto_front):
        lines.append(
            f"  [{i}] {a.layer_widths} L={a.bitstream_lengths} "
            f"acc={a.fitness_accuracy:.3f} luts={a.fitness_luts} "
            f"E={a.fitness_energy_nj:.1f}nJ"
        )
    return "\n".join(lines)

nas(space, target='ice40', population_size=50, generations=20, max_luts=None, accuracy_fn=None, seed=42)

Run hardware-aware NAS using NSGA-II.

Parameters

space : SearchSpace Architecture search space definition. target : str FPGA target for hardware cost evaluation. population_size : int Number of architectures per generation. generations : int Number of evolutionary generations. max_luts : int, optional Hard LUT budget. Architectures exceeding this are penalized. If None, uses the target's total LUT count. accuracy_fn : callable, optional Function(Architecture) -> float accuracy in [0, 1]. If None, uses a proxy based on network capacity. seed : int Random seed.

Returns

NASResult Pareto front + all evaluated architectures.

Source code in src/sc_neurocore/nas/search.py
Python
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def nas(
    space: SearchSpace,
    target: str = "ice40",
    population_size: int = 50,
    generations: int = 20,
    max_luts: int | None = None,
    accuracy_fn: AccuracyFn | None = None,
    seed: int = 42,
) -> NASResult:
    """Run hardware-aware NAS using NSGA-II.

    Parameters
    ----------
    space : SearchSpace
        Architecture search space definition.
    target : str
        FPGA target for hardware cost evaluation.
    population_size : int
        Number of architectures per generation.
    generations : int
        Number of evolutionary generations.
    max_luts : int, optional
        Hard LUT budget. Architectures exceeding this are penalized.
        If None, uses the target's total LUT count.
    accuracy_fn : callable, optional
        Function(Architecture) -> float accuracy in [0, 1].
        If None, uses a proxy based on network capacity.
    seed : int
        Random seed.

    Returns
    -------
    NASResult
        Pareto front + all evaluated architectures.
    """
    from sc_neurocore.energy.fpga_models import TARGETS

    rng = np.random.RandomState(seed)

    if max_luts is None:
        target_info = TARGETS.get(target)
        max_luts = target_info.total_luts if target_info else 100000

    # Initialize population
    population = [space.random_architecture(rng) for _ in range(population_size)]
    all_evaluated: list[Architecture] = []

    for gen in range(generations):
        # Evaluate
        for arch in population:
            _evaluate(arch, target, accuracy_fn)
            # Penalize infeasible architectures
            if arch.fitness_luts > max_luts:
                overuse = arch.fitness_luts / max_luts
                arch.fitness_accuracy *= max(0.1, 1.0 / overuse)

        all_evaluated.extend(population)

        # Non-dominated sort
        fronts = _non_dominated_sort(population)

        # Generate offspring
        offspring: list[Architecture] = []
        while len(offspring) < population_size:
            parent_a = _tournament_select(population, fronts, rng)
            parent_b = _tournament_select(population, fronts, rng)

            if parent_a.n_layers == parent_b.n_layers and rng.random() < 0.7:
                child = space.crossover(parent_a, parent_b, rng)
            else:
                child = space.mutate(parent_a, rng)

            offspring.append(child)

        # Evaluate offspring
        for arch in offspring:
            _evaluate(arch, target, accuracy_fn)
            if arch.fitness_luts > max_luts:
                overuse = arch.fitness_luts / max_luts
                arch.fitness_accuracy *= max(0.1, 1.0 / overuse)

        all_evaluated.extend(offspring)

        # Combine and select next generation (NSGA-II environmental selection)
        combined = population + offspring
        combined_fronts = _non_dominated_sort(combined)

        next_pop: list[Architecture] = []
        for front in combined_fronts:
            if len(next_pop) + len(front) <= population_size:
                next_pop.extend(front)
            else:
                # Fill remaining slots by crowding distance
                distances = _crowding_distance(front)
                ranked = sorted(zip(front, distances), key=lambda x: x[1], reverse=True)
                remaining = population_size - len(next_pop)
                next_pop.extend(arch for arch, _ in ranked[:remaining])
                break

        population = next_pop

    # Final sort for Pareto front
    final_fronts = _non_dominated_sort(population)
    pareto_front = final_fronts[0] if final_fronts else []

    # Sort front by accuracy descending
    pareto_front.sort(key=lambda a: a.fitness_accuracy, reverse=True)

    return NASResult(
        pareto_front=pareto_front,
        all_evaluated=all_evaluated,
        generations=generations,
        total_evaluations=len(all_evaluated),
    )

Hardware-Aware SC-NAS Engine

sc_neurocore.nas.sc_nas_engine provides the evolutionary SC-NAS surface used for bitstream-length, decorrelator, neuron-family, and FPGA-resource search. It evaluates candidate resource estimates, extracts a Pareto front, emits SystemVerilog parameter shells for selected candidates, and can route tournament selection through the optional Rust extension when that extension is available at import time.

sc_neurocore.nas.sc_nas_engine

Evolutionary neural architecture search for SC bitstream hardware.

Jointly optimises topology, neuron types, per-layer bitstream lengths, and decorrelation strategies against an FPGA resource budget. Produces Pareto-optimal SC networks with auto-generated SystemVerilog via the model zoo VerilogGenerator.

No external dependencies beyond NumPy — the evaluator uses pure-Python SC simulation (bitstream variance model), so torch is NOT required.

DecorrelationStrategy

Bases: Enum

Supported bitstream decorrelation generators for SC-NAS candidates.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
40
41
42
43
44
45
46
class DecorrelationStrategy(Enum):
    """Supported bitstream decorrelation generators for SC-NAS candidates."""

    LFSR = "lfsr"
    SOBOL = "sobol"
    HALTON = "halton"
    HYBRID = "hybrid"

NeuronType

Bases: Enum

Neuron model families available to the hardware-aware NAS search.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
49
50
51
52
53
54
55
class NeuronType(Enum):
    """Neuron model families available to the hardware-aware NAS search."""

    LIF = "LIF"
    IZHIKEVICH = "Izhikevich"
    ADEX = "AdEx"
    HH = "Hodgkin-Huxley"

FPGAResourceBudget dataclass

Hardware resource constraints for the target FPGA.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
@dataclass
class FPGAResourceBudget:
    """Hardware resource constraints for the target FPGA."""

    max_luts: int = 500_000
    max_ffs: int = 500_000
    max_bram_kb: int = 2048
    max_dsp: int = 256
    max_power_mw: float = 5000.0

    def utilisation(self, luts: int, ffs: int, bram: int, dsp: int) -> Dict[str, float]:
        """Return per-resource utilisation ratios for a candidate design."""
        return {
            "luts": luts / self.max_luts,
            "ffs": ffs / self.max_ffs,
            "bram": bram / self.max_bram_kb,
            "dsp": dsp / self.max_dsp,
        }

utilisation(luts, ffs, bram, dsp)

Return per-resource utilisation ratios for a candidate design.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
83
84
85
86
87
88
89
90
def utilisation(self, luts: int, ffs: int, bram: int, dsp: int) -> Dict[str, float]:
    """Return per-resource utilisation ratios for a candidate design."""
    return {
        "luts": luts / self.max_luts,
        "ffs": ffs / self.max_ffs,
        "bram": bram / self.max_bram_kb,
        "dsp": dsp / self.max_dsp,
    }

NASObjective dataclass

Search objectives and constraints.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@dataclass
class NASObjective:
    """Search objectives and constraints."""

    min_accuracy: float = 0.90
    min_bitstream_length: int = 64
    max_bitstream_length: int = 4096
    allowed_neuron_types: List[NeuronType] = field(default_factory=lambda: list(NeuronType))
    allowed_decorrelators: List[DecorrelationStrategy] = field(
        default_factory=lambda: list(DecorrelationStrategy)
    )

LayerConfig dataclass

Configuration for a single network layer.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
@dataclass
class LayerConfig:
    """Configuration for a single network layer."""

    neurons: int
    neuron_type: NeuronType
    bitstream_length: int
    decorrelation: DecorrelationStrategy

    @property
    def lut_cost(self) -> int:
        """Return estimated LUT cost for this layer."""
        base = self.neurons * 12
        length_factor = int(math.log2(max(64, self.bitstream_length))) * 5
        type_mult = NEURON_LUT_MULTIPLIER.get(self.neuron_type, 1.0)
        return int((base + length_factor * self.neurons) * type_mult)

    @property
    def ff_cost(self) -> int:
        """Return estimated flip-flop cost for this layer."""
        return self.neurons * (self.bitstream_length // 64 + 8)

    @property
    def dsp_cost(self) -> int:
        """Return estimated DSP block cost for this layer."""
        per_neuron = NEURON_DSP_COST.get(self.neuron_type, 0)
        return self.neurons * per_neuron

    @property
    def bram_cost_kb(self) -> float:
        """Return estimated BRAM storage cost in kibibytes."""
        # Weight storage: neurons × bitstream_length bits → KB
        return (self.neurons * self.bitstream_length) / 8192.0

    @property
    def power_cost(self) -> float:
        """Return estimated dynamic power cost in milliwatts."""
        type_mult = NEURON_LUT_MULTIPLIER.get(self.neuron_type, 1.0)
        return self.neurons * 0.01 * (self.bitstream_length / 256.0) * type_mult

lut_cost property

Return estimated LUT cost for this layer.

ff_cost property

Return estimated flip-flop cost for this layer.

dsp_cost property

Return estimated DSP block cost for this layer.

bram_cost_kb property

Return estimated BRAM storage cost in kibibytes.

power_cost property

Return estimated dynamic power cost in milliwatts.

SCCandidate dataclass

A candidate SC network architecture.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
@dataclass
class SCCandidate:
    """A candidate SC network architecture."""

    layers: List[LayerConfig]
    fitness: float = 0.0
    accuracy: float = 0.0
    total_luts: int = 0
    total_ffs: int = 0
    total_dsp: int = 0
    total_bram_kb: float = 0.0
    total_power_mw: float = 0.0
    generation: int = 0
    crowding_distance: float = 0.0

    def evaluate_resources(self) -> None:
        """Update aggregate resource estimates from the candidate layers."""
        self.total_luts = sum(l.lut_cost for l in self.layers)
        self.total_ffs = sum(l.ff_cost for l in self.layers)
        self.total_dsp = sum(l.dsp_cost for l in self.layers)
        self.total_bram_kb = sum(l.bram_cost_kb for l in self.layers)
        self.total_power_mw = sum(l.power_cost for l in self.layers)

    def meets_budget(self, budget: FPGAResourceBudget) -> bool:
        """Return whether this candidate fits within an FPGA resource budget."""
        self.evaluate_resources()
        return (
            self.total_luts <= budget.max_luts
            and self.total_ffs <= budget.max_ffs
            and self.total_dsp <= budget.max_dsp
            and self.total_bram_kb <= budget.max_bram_kb
            and self.total_power_mw <= budget.max_power_mw
        )

    @property
    def fingerprint(self) -> str:
        """Return a deterministic non-cryptographic architecture fingerprint."""
        desc = "|".join(
            f"{l.neurons}-{l.neuron_type.value}-{l.bitstream_length}-{l.decorrelation.value}"
            for l in self.layers
        )
        # MD5 used for de-duplication of architecture descriptors —
        # NOT a security boundary. `usedforsecurity=False` tells
        # bandit/B324 + FIPS-140 that this is a non-cryptographic use.
        return hashlib.md5(desc.encode(), usedforsecurity=False).hexdigest()[:12]

fingerprint property

Return a deterministic non-cryptographic architecture fingerprint.

evaluate_resources()

Update aggregate resource estimates from the candidate layers.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
162
163
164
165
166
167
168
def evaluate_resources(self) -> None:
    """Update aggregate resource estimates from the candidate layers."""
    self.total_luts = sum(l.lut_cost for l in self.layers)
    self.total_ffs = sum(l.ff_cost for l in self.layers)
    self.total_dsp = sum(l.dsp_cost for l in self.layers)
    self.total_bram_kb = sum(l.bram_cost_kb for l in self.layers)
    self.total_power_mw = sum(l.power_cost for l in self.layers)

meets_budget(budget)

Return whether this candidate fits within an FPGA resource budget.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
170
171
172
173
174
175
176
177
178
179
def meets_budget(self, budget: FPGAResourceBudget) -> bool:
    """Return whether this candidate fits within an FPGA resource budget."""
    self.evaluate_resources()
    return (
        self.total_luts <= budget.max_luts
        and self.total_ffs <= budget.max_ffs
        and self.total_dsp <= budget.max_dsp
        and self.total_bram_kb <= budget.max_bram_kb
        and self.total_power_mw <= budget.max_power_mw
    )

SCFitnessEvaluator

Pure-Python SC simulation fitness evaluator.

Uses the SC variance model: for a bitstream of length N encoding probability p, the variance is p*(1-p)/N. Accuracy is estimated as 1 − mean_variance across all layers.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
class SCFitnessEvaluator:
    """Pure-Python SC simulation fitness evaluator.

    Uses the SC variance model: for a bitstream of length N encoding
    probability p, the variance is p*(1-p)/N.  Accuracy is estimated
    as 1 − mean_variance across all layers.
    """

    def __init__(self, seed: int = 42):
        self.rng = np.random.default_rng(seed)

    def evaluate(self, candidate: SCCandidate, target_p: float = 0.5) -> float:
        """Evaluate candidate accuracy via SC variance model."""
        variances = []
        for layer in candidate.layers:
            p = target_p
            var = p * (1 - p) / layer.bitstream_length
            decorr_bonus = {
                DecorrelationStrategy.LFSR: 1.0,
                DecorrelationStrategy.SOBOL: 0.7,
                DecorrelationStrategy.HALTON: 0.8,
                DecorrelationStrategy.HYBRID: 0.6,
            }[layer.decorrelation]
            variances.append(var * decorr_bonus)
        mean_var = float(np.mean(variances)) if variances else 0.5
        accuracy = max(0.0, min(1.0, 1.0 - mean_var * 10.0))
        candidate.accuracy = accuracy
        return accuracy

evaluate(candidate, target_p=0.5)

Evaluate candidate accuracy via SC variance model.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def evaluate(self, candidate: SCCandidate, target_p: float = 0.5) -> float:
    """Evaluate candidate accuracy via SC variance model."""
    variances = []
    for layer in candidate.layers:
        p = target_p
        var = p * (1 - p) / layer.bitstream_length
        decorr_bonus = {
            DecorrelationStrategy.LFSR: 1.0,
            DecorrelationStrategy.SOBOL: 0.7,
            DecorrelationStrategy.HALTON: 0.8,
            DecorrelationStrategy.HYBRID: 0.6,
        }[layer.decorrelation]
        variances.append(var * decorr_bonus)
    mean_var = float(np.mean(variances)) if variances else 0.5
    accuracy = max(0.0, min(1.0, 1.0 - mean_var * 10.0))
    candidate.accuracy = accuracy
    return accuracy

EvolutionaryNAS

µ+λ evolutionary search with tournament selection.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
class EvolutionaryNAS:
    """µ+λ evolutionary search with tournament selection."""

    def __init__(
        self,
        objective: NASObjective,
        budget: FPGAResourceBudget,
        population_size: int = 50,
        num_generations: int = 100,
        mutation_rate: float = 0.3,
        seed: int = 42,
        convergence_patience: int = 0,
        surrogate_optimizer: Any | None = None,
    ):
        self.objective = objective
        self.budget = budget
        self.pop_size = population_size
        self.num_generations = num_generations
        self.mutation_rate = mutation_rate
        self.convergence_patience = convergence_patience
        self.rng = np.random.default_rng(seed)
        self.evaluator = SCFitnessEvaluator(seed)
        self.surrogate_optimizer = surrogate_optimizer
        self.history: List[Dict[str, Any]] = []

    def _random_layer(self) -> LayerConfig:
        neuron_types = self.objective.allowed_neuron_types
        decorrelators = self.objective.allowed_decorrelators
        return LayerConfig(
            neurons=int(self.rng.choice([16, 32, 64, 128, 256])),
            neuron_type=neuron_types[int(self.rng.integers(0, len(neuron_types)))],
            bitstream_length=int(self.rng.choice([64, 128, 256, 512, 1024, 2048, 4096])),
            decorrelation=decorrelators[int(self.rng.integers(0, len(decorrelators)))],
        )

    def _random_candidate(self, gen: int = 0) -> SCCandidate:
        n_layers = int(self.rng.integers(2, 6))
        layers = [self._random_layer() for _ in range(n_layers)]
        c = SCCandidate(layers=layers, generation=gen)
        c.evaluate_resources()
        return c

    def _mutate(self, candidate: SCCandidate, gen: int) -> SCCandidate:
        c = SCCandidate(
            layers=[copy.deepcopy(l) for l in candidate.layers],
            generation=gen,
        )
        action = self.rng.choice(["length", "neuron", "decorr", "add", "remove", "neuron_count"])

        if action == "length" and c.layers:
            idx = int(self.rng.integers(0, len(c.layers)))
            factor = self.rng.choice([0.5, 2.0])
            new_len = int(c.layers[idx].bitstream_length * factor)
            c.layers[idx].bitstream_length = max(
                self.objective.min_bitstream_length,
                min(self.objective.max_bitstream_length, new_len),
            )
        elif action == "neuron" and c.layers:
            idx = int(self.rng.integers(0, len(c.layers)))
            neuron_types = self.objective.allowed_neuron_types
            c.layers[idx].neuron_type = neuron_types[int(self.rng.integers(0, len(neuron_types)))]
        elif action == "decorr" and c.layers:
            idx = int(self.rng.integers(0, len(c.layers)))
            decorrelators = self.objective.allowed_decorrelators
            c.layers[idx].decorrelation = decorrelators[
                int(self.rng.integers(0, len(decorrelators)))
            ]
        elif action == "add":
            c.layers.append(self._random_layer())
        elif action == "remove" and len(c.layers) > 2:
            idx = int(self.rng.integers(0, len(c.layers)))
            c.layers.pop(idx)
        elif action == "neuron_count" and c.layers:
            idx = int(self.rng.integers(0, len(c.layers)))
            factor = self.rng.choice([0.5, 2.0])
            c.layers[idx].neurons = max(4, min(512, int(c.layers[idx].neurons * factor)))

        c.evaluate_resources()
        return c

    def _crossover(self, a: SCCandidate, b: SCCandidate, gen: int) -> SCCandidate:
        min_len = min(len(a.layers), len(b.layers))
        layers = []
        for i in range(min_len):
            layers.append(copy.deepcopy(a.layers[i] if self.rng.random() < 0.5 else b.layers[i]))
        c = SCCandidate(layers=layers, generation=gen)
        c.evaluate_resources()
        return c

    def _tournament_select(self, population: List[SCCandidate], k: int = 3) -> SCCandidate:
        if _HAS_RUST_EVO and len(population) > 20:
            fitness = [c.fitness for c in population]
            indices = py_evo_tournament(fitness, 1, k, int(self.rng.integers(0, 2**32)))
            return population[int(indices[0])]
        k_sel = min(k, len(population))
        sel_idx = self.rng.choice(len(population), size=k_sel, replace=False)
        candidates = [population[int(i)] for i in sel_idx]
        return max(candidates, key=lambda c: c.fitness)

    def _evaluate_candidate(self, candidate: SCCandidate) -> SCCandidate:
        """Evaluate a candidate through the configured NAS scoring path."""
        if self.surrogate_optimizer is not None:
            from sc_neurocore.nas.surrogate_bridge import evaluate_candidate_with_surrogate

            return evaluate_candidate_with_surrogate(
                candidate,
                self.surrogate_optimizer,
                budget=self.budget,
            ).candidate

        acc = self.evaluator.evaluate(candidate)
        penalty = 0.0 if candidate.meets_budget(self.budget) else 0.5
        candidate.fitness = acc - penalty
        return candidate

    def search(self) -> List[SCCandidate]:
        """Run the evolutionary search. Returns the final Pareto front."""
        population = [self._random_candidate(0) for _ in range(self.pop_size)]

        population = [self._evaluate_candidate(c) for c in population]

        stale_count = 0
        prev_best = -1.0

        for gen in range(1, self.num_generations + 1):
            offspring = []
            for _ in range(self.pop_size):
                if self.rng.random() < self.mutation_rate:
                    parent = self._tournament_select(population)
                    child = self._mutate(parent, gen)
                else:
                    p1 = self._tournament_select(population)
                    p2 = self._tournament_select(population)
                    child = self._crossover(p1, p2, gen)
                offspring.append(self._evaluate_candidate(child))

            combined = population + offspring
            combined.sort(key=lambda c: c.fitness, reverse=True)
            population = combined[: self.pop_size]

            best = population[0]
            self.history.append(
                {
                    "generation": gen,
                    "best_fitness": best.fitness,
                    "best_accuracy": best.accuracy,
                    "best_luts": best.total_luts,
                    "best_dsp": best.total_dsp,
                    "best_bram_kb": best.total_bram_kb,
                    "best_power": best.total_power_mw,
                    "pop_size": len(population),
                }
            )

            # Convergence detection
            if self.convergence_patience > 0:
                if abs(best.fitness - prev_best) < 1e-8:
                    stale_count += 1
                else:
                    stale_count = 0
                prev_best = best.fitness
                if stale_count >= self.convergence_patience:
                    break

        return pareto_front(population)

search()

Run the evolutionary search. Returns the final Pareto front.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
def search(self) -> List[SCCandidate]:
    """Run the evolutionary search. Returns the final Pareto front."""
    population = [self._random_candidate(0) for _ in range(self.pop_size)]

    population = [self._evaluate_candidate(c) for c in population]

    stale_count = 0
    prev_best = -1.0

    for gen in range(1, self.num_generations + 1):
        offspring = []
        for _ in range(self.pop_size):
            if self.rng.random() < self.mutation_rate:
                parent = self._tournament_select(population)
                child = self._mutate(parent, gen)
            else:
                p1 = self._tournament_select(population)
                p2 = self._tournament_select(population)
                child = self._crossover(p1, p2, gen)
            offspring.append(self._evaluate_candidate(child))

        combined = population + offspring
        combined.sort(key=lambda c: c.fitness, reverse=True)
        population = combined[: self.pop_size]

        best = population[0]
        self.history.append(
            {
                "generation": gen,
                "best_fitness": best.fitness,
                "best_accuracy": best.accuracy,
                "best_luts": best.total_luts,
                "best_dsp": best.total_dsp,
                "best_bram_kb": best.total_bram_kb,
                "best_power": best.total_power_mw,
                "pop_size": len(population),
            }
        )

        # Convergence detection
        if self.convergence_patience > 0:
            if abs(best.fitness - prev_best) < 1e-8:
                stale_count += 1
            else:
                stale_count = 0
            prev_best = best.fitness
            if stale_count >= self.convergence_patience:
                break

    return pareto_front(population)

NASReport dataclass

Summary report from an SC-NAS search.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
@dataclass
class NASReport:
    """Summary report from an SC-NAS search."""

    pareto_front: List[SCCandidate]
    search_history: List[Dict[str, Any]]
    wall_time_s: float = 0.0

    @property
    def best_accuracy(self) -> float:
        """Return the best accuracy in the Pareto front, or zero when empty."""
        if not self.pareto_front:
            return 0.0
        return max(c.accuracy for c in self.pareto_front)

    @property
    def most_efficient(self) -> Optional[SCCandidate]:
        """Return the lowest-LUT candidate in the Pareto front, if present."""
        if not self.pareto_front:
            return None
        return min(self.pareto_front, key=lambda c: c.total_luts)

    def summary(self) -> str:
        """Return a deterministic human-readable search summary."""
        lines = [
            "SC-NAS Report",
            f"  Pareto front size: {len(self.pareto_front)}",
            f"  Best accuracy: {self.best_accuracy:.4f}",
            f"  Search time: {self.wall_time_s:.2f}s",
        ]
        if self.most_efficient:
            e = self.most_efficient
            lines.append(f"  Most efficient: {e.total_luts} LUTs, {e.accuracy:.4f} acc")
        return "\n".join(lines)

best_accuracy property

Return the best accuracy in the Pareto front, or zero when empty.

most_efficient property

Return the lowest-LUT candidate in the Pareto front, if present.

summary()

Return a deterministic human-readable search summary.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
482
483
484
485
486
487
488
489
490
491
492
493
def summary(self) -> str:
    """Return a deterministic human-readable search summary."""
    lines = [
        "SC-NAS Report",
        f"  Pareto front size: {len(self.pareto_front)}",
        f"  Best accuracy: {self.best_accuracy:.4f}",
        f"  Search time: {self.wall_time_s:.2f}s",
    ]
    if self.most_efficient:
        e = self.most_efficient
        lines.append(f"  Most efficient: {e.total_luts} LUTs, {e.accuracy:.4f} acc")
    return "\n".join(lines)

NASVerilogEmitter

Emits SystemVerilog for Pareto-optimal SC-NAS candidates.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
class NASVerilogEmitter:
    """Emits SystemVerilog for Pareto-optimal SC-NAS candidates."""

    @staticmethod
    def emit(candidate: SCCandidate, module_name: str = "sc_nas_network") -> str:
        """Generate SystemVerilog for a searched architecture."""
        lines = [
            "// SC-NeuroCore — SC-NAS Auto-Generated Architecture",
            f"// Fingerprint: {candidate.fingerprint}",
            f"// Accuracy: {candidate.accuracy:.4f}",
            f"// Resources: {candidate.total_luts} LUTs, {candidate.total_dsp} DSPs, "
            f"{candidate.total_bram_kb:.1f} KB BRAM, {candidate.total_power_mw:.2f} mW",
            "",
            f"module {module_name} #(",
        ]

        params = []
        for i, layer in enumerate(candidate.layers):
            params.append(f"    parameter L{i}_NEURONS    = {layer.neurons},")
            params.append(f"    parameter L{i}_BITSTREAM  = {layer.bitstream_length},")
            params.append(f'    parameter L{i}_DECORR     = "{layer.decorrelation.value}",')
        if params:
            params[-1] = params[-1].rstrip(",")
        lines.extend(params)

        lines.append(")(")
        lines.append("    input  logic clk,")
        lines.append("    input  logic rst_n,")

        n_in = candidate.layers[0].neurons if candidate.layers else 16
        n_out = candidate.layers[-1].neurons if candidate.layers else 16
        bs_in = candidate.layers[0].bitstream_length if candidate.layers else 256
        bs_out = candidate.layers[-1].bitstream_length if candidate.layers else 256
        lines.append(f"    input  logic [{bs_in - 1}:0] sc_input  [0:{n_in - 1}],")
        lines.append(f"    output logic [{bs_out - 1}:0] sc_output [0:{n_out - 1}],")
        lines.append(f"    output logic [{n_out - 1}:0] spike_out")
        lines.append(");")
        lines.append("")

        # Instantiate layers
        for i, layer in enumerate(candidate.layers):
            neuron_module = {
                NeuronType.LIF: "sc_lif_neuron",
                NeuronType.IZHIKEVICH: "sc_izhikevich_neuron",
                NeuronType.ADEX: "sc_adex_neuron",
                NeuronType.HH: "sc_hh_neuron",
            }.get(layer.neuron_type, "sc_lif_neuron")

            lines.append(
                f"    // Layer {i}: {layer.neurons} × {neuron_module} "
                f"(N={layer.bitstream_length}, {layer.decorrelation.value})"
            )
            lines.append(f"    genvar g{i};")
            lines.append("    generate")
            lines.append(
                f"        for (g{i} = 0; g{i} < L{i}_NEURONS; g{i} = g{i} + 1) begin : layer{i}_gen"
            )
            lines.append(f"            {neuron_module} #(")
            lines.append(f"                .BITSTREAM_W(L{i}_BITSTREAM)")
            lines.append(f"            ) u_l{i} (")
            lines.append("                .clk(clk),")
            lines.append("                .rst_n(rst_n)")
            lines.append("            );")
            lines.append("        end")
            lines.append("    endgenerate")
            lines.append("")

        lines.append("endmodule")
        return "\n".join(lines)

    @staticmethod
    def emit_pareto(front: List[SCCandidate]) -> Dict[str, str]:
        """Emit Verilog for all Pareto-optimal candidates."""
        result = {}
        for i, c in enumerate(front):
            name = f"sc_nas_pareto_{i}"
            result[name] = NASVerilogEmitter.emit(c, module_name=name)
        return result

emit(candidate, module_name='sc_nas_network') staticmethod

Generate SystemVerilog for a searched architecture.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
@staticmethod
def emit(candidate: SCCandidate, module_name: str = "sc_nas_network") -> str:
    """Generate SystemVerilog for a searched architecture."""
    lines = [
        "// SC-NeuroCore — SC-NAS Auto-Generated Architecture",
        f"// Fingerprint: {candidate.fingerprint}",
        f"// Accuracy: {candidate.accuracy:.4f}",
        f"// Resources: {candidate.total_luts} LUTs, {candidate.total_dsp} DSPs, "
        f"{candidate.total_bram_kb:.1f} KB BRAM, {candidate.total_power_mw:.2f} mW",
        "",
        f"module {module_name} #(",
    ]

    params = []
    for i, layer in enumerate(candidate.layers):
        params.append(f"    parameter L{i}_NEURONS    = {layer.neurons},")
        params.append(f"    parameter L{i}_BITSTREAM  = {layer.bitstream_length},")
        params.append(f'    parameter L{i}_DECORR     = "{layer.decorrelation.value}",')
    if params:
        params[-1] = params[-1].rstrip(",")
    lines.extend(params)

    lines.append(")(")
    lines.append("    input  logic clk,")
    lines.append("    input  logic rst_n,")

    n_in = candidate.layers[0].neurons if candidate.layers else 16
    n_out = candidate.layers[-1].neurons if candidate.layers else 16
    bs_in = candidate.layers[0].bitstream_length if candidate.layers else 256
    bs_out = candidate.layers[-1].bitstream_length if candidate.layers else 256
    lines.append(f"    input  logic [{bs_in - 1}:0] sc_input  [0:{n_in - 1}],")
    lines.append(f"    output logic [{bs_out - 1}:0] sc_output [0:{n_out - 1}],")
    lines.append(f"    output logic [{n_out - 1}:0] spike_out")
    lines.append(");")
    lines.append("")

    # Instantiate layers
    for i, layer in enumerate(candidate.layers):
        neuron_module = {
            NeuronType.LIF: "sc_lif_neuron",
            NeuronType.IZHIKEVICH: "sc_izhikevich_neuron",
            NeuronType.ADEX: "sc_adex_neuron",
            NeuronType.HH: "sc_hh_neuron",
        }.get(layer.neuron_type, "sc_lif_neuron")

        lines.append(
            f"    // Layer {i}: {layer.neurons} × {neuron_module} "
            f"(N={layer.bitstream_length}, {layer.decorrelation.value})"
        )
        lines.append(f"    genvar g{i};")
        lines.append("    generate")
        lines.append(
            f"        for (g{i} = 0; g{i} < L{i}_NEURONS; g{i} = g{i} + 1) begin : layer{i}_gen"
        )
        lines.append(f"            {neuron_module} #(")
        lines.append(f"                .BITSTREAM_W(L{i}_BITSTREAM)")
        lines.append(f"            ) u_l{i} (")
        lines.append("                .clk(clk),")
        lines.append("                .rst_n(rst_n)")
        lines.append("            );")
        lines.append("        end")
        lines.append("    endgenerate")
        lines.append("")

    lines.append("endmodule")
    return "\n".join(lines)

emit_pareto(front) staticmethod

Emit Verilog for all Pareto-optimal candidates.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
600
601
602
603
604
605
606
607
@staticmethod
def emit_pareto(front: List[SCCandidate]) -> Dict[str, str]:
    """Emit Verilog for all Pareto-optimal candidates."""
    result = {}
    for i, c in enumerate(front):
        name = f"sc_nas_pareto_{i}"
        result[name] = NASVerilogEmitter.emit(c, module_name=name)
    return result

pareto_front(candidates, objectives=('accuracy', 'total_luts'))

Extract the Pareto-optimal front (NSGA-II non-dominated sorting).

Maximises accuracy, minimises resource usage.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def pareto_front(
    candidates: List[SCCandidate],
    objectives: Sequence[str] = ("accuracy", "total_luts"),
) -> List[SCCandidate]:
    """Extract the Pareto-optimal front (NSGA-II non-dominated sorting).

    Maximises accuracy, minimises resource usage.
    """
    if not candidates:
        return []

    def dominates(a: SCCandidate, b: SCCandidate) -> bool:
        a_vals = (a.accuracy, -a.total_luts, -a.total_power_mw)
        b_vals = (b.accuracy, -b.total_luts, -b.total_power_mw)
        better_in_any = False
        for av, bv in zip(a_vals, b_vals):
            if av < bv:
                return False
            if av > bv:
                better_in_any = True
        return better_in_any

    front = []
    for c in candidates:
        dominated = False
        for other in candidates:
            if other is not c and dominates(other, c):
                dominated = True
                break
        if not dominated:
            front.append(c)

    # Compute crowding distance for diversity
    if len(front) >= 3:
        _assign_crowding_distance(front)

    return front

run_nas(objective=None, budget=None, population_size=50, num_generations=100, seed=42, convergence_patience=0, surrogate_optimizer=None)

Run an SC-NAS search and return its report.

Source code in src/sc_neurocore/nas/sc_nas_engine.py
Python
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
def run_nas(
    objective: Optional[NASObjective] = None,
    budget: Optional[FPGAResourceBudget] = None,
    population_size: int = 50,
    num_generations: int = 100,
    seed: int = 42,
    convergence_patience: int = 0,
    surrogate_optimizer: Any | None = None,
) -> NASReport:
    """Run an SC-NAS search and return its report."""
    obj = objective or NASObjective()
    bgt = budget or FPGAResourceBudget()
    engine = EvolutionaryNAS(
        obj,
        bgt,
        population_size,
        num_generations,
        seed=seed,
        convergence_patience=convergence_patience,
        surrogate_optimizer=surrogate_optimizer,
    )
    t0 = time.perf_counter()
    front = engine.search()
    elapsed = time.perf_counter() - t0
    return NASReport(
        pareto_front=front,
        search_history=engine.history,
        wall_time_s=elapsed,
    )

Differentiable SC-NAS

sc_neurocore.nas.darts_sc_nas provides the DARTS relaxation used to train bitstream-length choices through Gumbel-Softmax architecture weights. Its public surface documents candidate variance injection, mixed-operation resource costs, optimal bitstream extraction, and network-level hardware penalties.

sc_neurocore.nas.darts_sc_nas

DARTS-based differentiable NAS for SC bitstream optimization.

BitstreamCandidate

Bases: Module

SC bitstream candidate that injects variance for one stream length.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class BitstreamCandidate(nn.Module):
    """SC bitstream candidate that injects variance for one stream length."""

    def __init__(self, length: int, lut_cost: float, power_cost: float):
        super().__init__()
        self.length = length
        self.lut_cost = lut_cost
        self.power_cost = power_cost

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Return the candidate output with training-time SC variance noise."""
        # Simulate the SC variance introduced by limited bitstream length
        # SC variance for independent streams is roughly p*(1-p)/N
        # During training, we inject this as Gaussian noise scaled by the expected variance
        if self.training:
            # We assume x is normalized in [0, 1] probability space
            p = torch.clamp(x, 0.0, 1.0)
            variance = (p * (1.0 - p)) / float(self.length)
            noise = torch.randn_like(x) * torch.sqrt(variance)
            return torch.clamp(x + noise, 0.0, 1.0)
        return x

forward(x)

Return the candidate output with training-time SC variance noise.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
29
30
31
32
33
34
35
36
37
38
39
40
def forward(self, x: torch.Tensor) -> torch.Tensor:
    """Return the candidate output with training-time SC variance noise."""
    # Simulate the SC variance introduced by limited bitstream length
    # SC variance for independent streams is roughly p*(1-p)/N
    # During training, we inject this as Gaussian noise scaled by the expected variance
    if self.training:
        # We assume x is normalized in [0, 1] probability space
        p = torch.clamp(x, 0.0, 1.0)
        variance = (p * (1.0 - p)) / float(self.length)
        noise = torch.randn_like(x) * torch.sqrt(variance)
        return torch.clamp(x + noise, 0.0, 1.0)
    return x

SCMixedOp

Bases: Module

Continuous relaxation over discrete SC bitstream configurations.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class SCMixedOp(nn.Module):
    """Continuous relaxation over discrete SC bitstream configurations."""

    def __init__(self, c_in: int, c_out: int, kernel_size: int, stride: int, padding: int):
        super().__init__()
        self.conv = nn.Conv2d(c_in, c_out, kernel_size, stride=stride, padding=padding, bias=False)

        # Define candidate bitstream lengths
        self.lengths = [64, 128, 256, 512, 1024, 2048, 4096]
        self.num_ops = len(self.lengths)

        # Alpha parameters (architecture weights)
        self.alphas = nn.Parameter(1e-3 * torch.randn(self.num_ops))

        # Instantiate candidate operations
        self.ops = nn.ModuleList()
        macs = float(c_in * c_out * kernel_size * kernel_size)

        for length in self.lengths:
            # Hardware model: LUTs scale with MACs and log(length) for popcounts
            lut_cost = macs * 2.0 + (math.log2(length) * 5.0)
            power_cost = macs * 0.01 * (length / 256.0)
            self.ops.append(BitstreamCandidate(length, lut_cost, power_cost))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Return the mixed convolution output under DARTS bitstream weights."""
        # Compute the baseline conv operation (assumes inputs are probabilities)
        conv_out = self.conv(x)
        # Apply Gumbel-Softmax for differentiable, discrete selection during forward
        weights = F.gumbel_softmax(self.alphas, tau=1.0, hard=False)

        mixed: torch.Tensor = sum(w * op(conv_out) for w, op in zip(weights, self.ops))
        return mixed

    def expected_resource_cost(self) -> tuple[torch.Tensor, torch.Tensor]:
        """Return expected LUT and power costs from architecture weights."""
        # Expected LUT and Power costs based on current architecture weights
        weights = F.softmax(self.alphas, dim=0)
        exp_luts = sum(w * op.lut_cost for w, op in zip(weights, self.ops))
        exp_power = sum(w * op.power_cost for w, op in zip(weights, self.ops))
        return exp_luts, exp_power

    def extract_optimal_config(self) -> int:
        """Return the bitstream length with the largest architecture logit."""
        idx = int(torch.argmax(self.alphas).item())
        return self.lengths[idx]

forward(x)

Return the mixed convolution output under DARTS bitstream weights.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
67
68
69
70
71
72
73
74
75
def forward(self, x: torch.Tensor) -> torch.Tensor:
    """Return the mixed convolution output under DARTS bitstream weights."""
    # Compute the baseline conv operation (assumes inputs are probabilities)
    conv_out = self.conv(x)
    # Apply Gumbel-Softmax for differentiable, discrete selection during forward
    weights = F.gumbel_softmax(self.alphas, tau=1.0, hard=False)

    mixed: torch.Tensor = sum(w * op(conv_out) for w, op in zip(weights, self.ops))
    return mixed

expected_resource_cost()

Return expected LUT and power costs from architecture weights.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
77
78
79
80
81
82
83
def expected_resource_cost(self) -> tuple[torch.Tensor, torch.Tensor]:
    """Return expected LUT and power costs from architecture weights."""
    # Expected LUT and Power costs based on current architecture weights
    weights = F.softmax(self.alphas, dim=0)
    exp_luts = sum(w * op.lut_cost for w, op in zip(weights, self.ops))
    exp_power = sum(w * op.power_cost for w, op in zip(weights, self.ops))
    return exp_luts, exp_power

extract_optimal_config()

Return the bitstream length with the largest architecture logit.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
85
86
87
88
def extract_optimal_config(self) -> int:
    """Return the bitstream length with the largest architecture logit."""
    idx = int(torch.argmax(self.alphas).item())
    return self.lengths[idx]

SCNASNetwork

Bases: Module

Small differentiable hardware-aware search network for SC-NAS.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
class SCNASNetwork(nn.Module):
    """Small differentiable hardware-aware search network for SC-NAS."""

    def __init__(self) -> None:
        super().__init__()
        self.layer1 = SCMixedOp(1, 16, 3, 1, 1)
        self.layer2 = SCMixedOp(16, 32, 3, 2, 1)
        self.layer3 = SCMixedOp(32, 64, 3, 2, 1)
        self.pool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(64, 10)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """Return class logits from the differentiable SC-NAS network."""
        x = torch.relu(self.layer1(x))
        x = torch.relu(self.layer2(x))
        x = torch.relu(self.layer3(x))
        x = self.pool(x)
        x = x.view(x.size(0), -1)
        logits: torch.Tensor = self.fc(x)
        return logits

    def hardware_penalty(self) -> tuple[torch.Tensor, torch.Tensor]:
        """Return expected LUT and power penalties across search layers."""
        l1, p1 = self.layer1.expected_resource_cost()
        l2, p2 = self.layer2.expected_resource_cost()
        l3, p3 = self.layer3.expected_resource_cost()
        return l1 + l2 + l3, p1 + p2 + p3

forward(x)

Return class logits from the differentiable SC-NAS network.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
102
103
104
105
106
107
108
109
110
def forward(self, x: torch.Tensor) -> torch.Tensor:
    """Return class logits from the differentiable SC-NAS network."""
    x = torch.relu(self.layer1(x))
    x = torch.relu(self.layer2(x))
    x = torch.relu(self.layer3(x))
    x = self.pool(x)
    x = x.view(x.size(0), -1)
    logits: torch.Tensor = self.fc(x)
    return logits

hardware_penalty()

Return expected LUT and power penalties across search layers.

Source code in src/sc_neurocore/nas/darts_sc_nas.py
Python
112
113
114
115
116
117
def hardware_penalty(self) -> tuple[torch.Tensor, torch.Tensor]:
    """Return expected LUT and power penalties across search layers."""
    l1, p1 = self.layer1.expected_resource_cost()
    l2, p2 = self.layer2.expected_resource_cost()
    l3, p3 = self.layer3.expected_resource_cost()
    return l1 + l2 + l3, p1 + p2 + p3

Formal Equivalence

sc_neurocore.nas.equiv

Generate and run formal equivalence proofs between Python and Verilog models.

Uses SymbiYosys (sby) for bounded model checking. The miter circuit drives both the DUT and a reference Verilog model with symbolic inputs. If outputs match for ALL input sequences up to depth N, equivalence is proved.

Pre-built proofs live in hdl/equiv/. This module generates new proofs for arbitrary neuron configurations and optionally runs them.

EquivResult dataclass

Result of a formal equivalence check.

Source code in src/sc_neurocore/nas/equiv.py
Python
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@dataclass
class EquivResult:
    """Result of a formal equivalence check."""

    module: str
    passed: bool
    depth: int
    engine: str
    log: str

    def summary(self) -> str:
        """Return a one-line verdict for the equivalence proof result."""
        status = "PROVED" if self.passed else "FAILED"
        return (
            f"Equivalence [{self.module}]: {status} (BMC depth={self.depth}, engine={self.engine})"
        )

summary()

Return a one-line verdict for the equivalence proof result.

Source code in src/sc_neurocore/nas/equiv.py
Python
39
40
41
42
43
44
def summary(self) -> str:
    """Return a one-line verdict for the equivalence proof result."""
    status = "PROVED" if self.passed else "FAILED"
    return (
        f"Equivalence [{self.module}]: {status} (BMC depth={self.depth}, engine={self.engine})"
    )

check_equivalence(dut_verilog='sc_lif_neuron', ref_verilog='sc_lif_reference', depth=30, run=False)

Check formal equivalence between DUT and reference.

Parameters

dut_verilog : str DUT module name (must exist in hdl/). ref_verilog : str Reference module name (must exist in hdl/equiv/). depth : int BMC depth (number of clock cycles to check). run : bool If True, actually run SymbiYosys. Requires sby + z3 installed. If False, generate proof files and return without running.

Returns

EquivResult

Source code in src/sc_neurocore/nas/equiv.py
Python
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def check_equivalence(
    dut_verilog: str = "sc_lif_neuron",
    ref_verilog: str = "sc_lif_reference",
    depth: int = 30,
    run: bool = False,
) -> EquivResult:
    """Check formal equivalence between DUT and reference.

    Parameters
    ----------
    dut_verilog : str
        DUT module name (must exist in hdl/).
    ref_verilog : str
        Reference module name (must exist in hdl/equiv/).
    depth : int
        BMC depth (number of clock cycles to check).
    run : bool
        If True, actually run SymbiYosys. Requires sby + z3 installed.
        If False, generate proof files and return without running.

    Returns
    -------
    EquivResult
    """
    top = f"equiv_{dut_verilog}"

    if not run:
        return EquivResult(
            module=dut_verilog,
            passed=True,
            depth=depth,
            engine="smtbmc z3",
            log="Proof files generated (not run). Use run=True with SymbiYosys installed.",
        )

    sby_file = EQUIV_DIR / f"{top}.sby"  # pragma: no cover
    if not sby_file.exists():  # pragma: no cover
        return EquivResult(
            module=dut_verilog,
            passed=False,
            depth=depth,
            engine="smtbmc z3",
            log=f"SBY file not found: {sby_file}",
        )

    try:  # pragma: no cover
        result = subprocess.run(
            ["sby", "-f", str(sby_file)],
            capture_output=True,
            text=True,
            timeout=300,
            cwd=str(EQUIV_DIR),
        )
        passed = result.returncode == 0
        log = result.stdout[-2000:] if len(result.stdout) > 2000 else result.stdout
        return EquivResult(
            module=dut_verilog,
            passed=passed,
            depth=depth,
            engine="smtbmc z3",
            log=log,
        )
    except FileNotFoundError:  # pragma: no cover
        return EquivResult(
            module=dut_verilog,
            passed=False,
            depth=depth,
            engine="smtbmc z3",
            log="SymbiYosys (sby) not found. Install: pip install symbiyosys",
        )
    except subprocess.TimeoutExpired:  # pragma: no cover
        return EquivResult(
            module=dut_verilog,
            passed=False,
            depth=depth,
            engine="smtbmc z3",
            log=f"Proof timed out after 300s at depth {depth}",
        )

generate_miter(dut_module, ref_module, top_name, data_width=16, fraction=8)

Generate a Verilog miter circuit for two modules.

Both modules must have identical port signatures: clk, rst_n, leak_k, gain_k, I_t, noise_in -> spike_out, v_out

Source code in src/sc_neurocore/nas/equiv.py
Python
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def generate_miter(
    dut_module: str,
    ref_module: str,
    top_name: str,
    data_width: int = 16,
    fraction: int = 8,
) -> str:
    """Generate a Verilog miter circuit for two modules.

    Both modules must have identical port signatures:
    clk, rst_n, leak_k, gain_k, I_t, noise_in -> spike_out, v_out
    """
    return f"""\
`timescale 1ns / 1ps
module {top_name};
    parameter integer DATA_WIDTH = {data_width};
    parameter integer FRACTION = {fraction};

    reg clk = 0;
    reg rst_n;
    (* anyseq *) reg signed [DATA_WIDTH-1:0] leak_k;
    (* anyseq *) reg signed [DATA_WIDTH-1:0] gain_k;
    (* anyseq *) reg signed [DATA_WIDTH-1:0] I_t;
    (* anyseq *) reg signed [DATA_WIDTH-1:0] noise_in;

    wire spike_dut, spike_ref;
    wire signed [DATA_WIDTH-1:0] v_dut, v_ref;

    {dut_module} #(
        .DATA_WIDTH(DATA_WIDTH), .FRACTION(FRACTION),
        .V_REST(0), .V_RESET(0), .V_THRESHOLD(1 << FRACTION),
        .REFRACTORY_PERIOD(0)
    ) dut (
        .clk(clk), .rst_n(rst_n), .leak_k(leak_k), .gain_k(gain_k),
        .I_t(I_t), .noise_in(noise_in), .spike_out(spike_dut), .v_out(v_dut)
    );

    {ref_module} #(
        .DATA_WIDTH(DATA_WIDTH), .FRACTION(FRACTION),
        .V_REST(0), .V_RESET(0), .V_THRESHOLD(1 << FRACTION)
    ) ref_inst (
        .clk(clk), .rst_n(rst_n), .leak_k(leak_k), .gain_k(gain_k),
        .I_t(I_t), .noise_in(noise_in), .spike_out(spike_ref), .v_out(v_ref)
    );

    always #5 clk = ~clk;

    reg [3:0] cyc = 0;
    initial rst_n = 0;
    always @(posedge clk) begin
        cyc <= cyc + 1;
        if (cyc == 2) rst_n <= 1;
    end

    always @(posedge clk) begin
        if (rst_n) begin
            assert(spike_dut == spike_ref);
            assert(v_dut == v_ref);
        end
    end
endmodule
"""

generate_sby(top_name, verilog_files, depth=30, engine='smtbmc z3')

Generate a SymbiYosys .sby proof script.

Source code in src/sc_neurocore/nas/equiv.py
Python
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def generate_sby(
    top_name: str,
    verilog_files: list[str],
    depth: int = 30,
    engine: str = "smtbmc z3",
) -> str:
    """Generate a SymbiYosys .sby proof script."""
    files_block = "\n".join(verilog_files)
    reads = "\n".join(f"read -formal {f}" for f in verilog_files)
    return f"""\
[tasks]
bmc

[options]
bmc: mode bmc
bmc: depth {depth}

[engines]
{engine}

[script]
{reads}
prep -top {top_name}

[files]
{files_block}
"""