File size: 8,140 Bytes
5e0b58b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
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
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
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
"""Conservative topology self-organization for Brain-5D.

The engine is optional and disabled by default. It uses the public manipulator
instead of mutating core dictionaries directly. STDP/reward learning stays in
``src.learning``; this engine only changes topology and performs slow structural
adaptation.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from src.core.spatial_index import (
    Coord5D,
    iter_neighbour_coords,
    pack_coords,
    unpack_coords,
)
from src.manipulation.manipulator import Brain5DManipulator


@dataclass(frozen=True, slots=True)
class SelfOrganizationParameters:
    enabled: bool = False
    interval_ticks: int = 100
    pruning_enabled: bool = False
    pruning_weight_threshold: float = 0.005
    pruning_min_age_ticks: int = 1000
    sprouting_enabled: bool = False
    sprouting_max_out_degree: int = 12
    sprouting_radius: float = 2.0
    sprouting_weight: float = 0.05
    sprouting_delay: int = 1
    neurogenesis_enabled: bool = False
    neurogenesis_spike_delta_threshold: int = 50
    neurogenesis_radius: float = 1.0
    neurogenesis_max_per_cycle: int = 1
    max_neurons: int = 0  # 0 = unlimited

    @classmethod
    def from_config(cls, config: dict[str, Any]) -> SelfOrganizationParameters:
        c = config.get("self_organization", {})
        params = cls(
            **{k: c[k] for k in cls.__dataclass_fields__ if k in c}
        )  # pylint: disable=no-member
        # Also support top-level max_neurons for backward compatibility
        if params.max_neurons == 0 and "max_neurons" in config:
            object.__setattr__(params, "max_neurons", int(config["max_neurons"]))
        return params

    def validate(self) -> None:
        if self.interval_ticks < 1:
            raise ValueError("self_organization.interval_ticks must be >= 1")
        if self.sprouting_max_out_degree < 0:
            raise ValueError("sprouting_max_out_degree must be >= 0")
        if self.sprouting_delay < 1:
            raise ValueError("sprouting_delay must be >= 1")
        if self.neurogenesis_max_per_cycle < 0:
            raise ValueError("neurogenesis_max_per_cycle must be >= 0")


@dataclass(frozen=True, slots=True)
class SelfOrganizationStats:
    cycles: int
    pruned_synapses: int
    created_synapses: int
    created_neurons: int


class SelfOrganizationEngine:
    """Slow structural adaptation layer attached through a post-step hook."""

    def __init__(
        self, network: Any, manipulator: Brain5DManipulator, config: dict[str, Any]
    ):
        self.network = network
        self.manipulator = manipulator
        self.params = SelfOrganizationParameters.from_config(config)
        self.params.validate()
        self._attached = False
        self._cycles = 0
        self._pruned = 0
        self._created_synapses = 0
        self._created_neurons = 0
        self._last_spike_counter: dict[int, int] = {}

    @property
    def stats(self) -> SelfOrganizationStats:
        return SelfOrganizationStats(
            self._cycles, self._pruned, self._created_synapses, self._created_neurons
        )

    def attach(self) -> None:
        if not self._attached:
            self.network.add_post_step_hook(self.update)
            self._attached = True

    def detach(self) -> None:
        if self._attached:
            self.network.remove_post_step_hook(self.update)
            self._attached = False

    def update(self, step_result: Any) -> None:
        if not self.params.enabled:
            return
        tick = int(step_result.tick)
        if (tick + 1) % self.params.interval_ticks != 0:
            return
        self.run_cycle(tick)

    def run_cycle(self, tick: int | None = None) -> SelfOrganizationStats:
        tick = int(self.network.current_tick if tick is None else tick)
        if self.params.pruning_enabled:
            self._run_pruning(tick)
        if self.params.sprouting_enabled:
            self._run_sprouting(tick)
        if self.params.neurogenesis_enabled:
            self._run_neurogenesis(tick)
        self._cycles += 1
        return self.stats

    def _run_pruning(self, tick: int) -> None:
        to_remove: list[tuple[int, int]] = []
        for source_id, synapses in tuple(self.network.synapses.items()):
            for syn in tuple(synapses):
                meta = self.manipulator.synapse_metadata.get((source_id, syn.target_id))
                created_tick = 0 if meta is None else int(meta.created_tick)
                age = tick - created_tick
                if (
                    age >= self.params.pruning_min_age_ticks
                    and abs(float(syn.weight)) < self.params.pruning_weight_threshold
                ):
                    to_remove.append((source_id, syn.target_id))
        for source_id, target_id in to_remove:
            self.manipulator.delete_synapse(source_id, target_id)
            self._pruned += 1

    def _run_sprouting(self, _tick: int) -> None:
        for source_id in tuple(self.network.neurons):
            outgoing = self.network.synapses.get(source_id, ())
            if len(outgoing) >= self.params.sprouting_max_out_degree:
                continue
            connected = {s.target_id for s in outgoing}
            coord = unpack_coords(source_id)
            target_id = None
            for ncoord in iter_neighbour_coords(
                coord, self.network.dimensions, self.params.sprouting_radius
            ):
                candidate = pack_coords(*ncoord)
                if (
                    candidate == source_id
                    or candidate not in self.network.neurons
                    or candidate in connected
                ):
                    continue
                target_id = candidate
                break
            if target_id is None:
                continue
            self.manipulator.create_synapse(
                source_id,
                target_id,
                self.params.sprouting_weight,
                min(self.params.sprouting_delay, int(self.network.max_delay)),
            )
            self._created_synapses += 1

    def _run_neurogenesis(self, _tick: int) -> None:
        if (
            self.params.max_neurons
            and len(self.network.neurons) >= self.params.max_neurons
        ):
            return
        created = 0
        ranked = sorted(
            self.network.neurons.items(),
            key=lambda item: int(item[1].spike_counter)
            - self._last_spike_counter.get(item[0], 0),
            reverse=True,
        )
        for parent_id, neuron in ranked:
            delta = int(neuron.spike_counter) - self._last_spike_counter.get(
                parent_id, 0
            )
            self._last_spike_counter[parent_id] = int(neuron.spike_counter)
            if delta < self.params.neurogenesis_spike_delta_threshold:
                continue
            free_coord = self._find_free_coord(parent_id)
            if free_coord is None:
                continue
            child_id = self.manipulator.create_neuron(free_coord)
            self.manipulator.set_neuron(
                child_id,
                a=neuron.a,
                b=neuron.b,
                c=neuron.c,
                d=neuron.d,
                v=neuron.c,
                u=neuron.b * neuron.c,
                energy=min(1.0, max(0.0, float(neuron.energy))),
            )
            self.manipulator.create_synapse(
                parent_id, child_id, self.params.sprouting_weight, 1
            )
            self._created_neurons += 1
            self._created_synapses += 1
            created += 1
            if created >= self.params.neurogenesis_max_per_cycle:
                break

    def _find_free_coord(self, neuron_id: int) -> Coord5D | None:
        coord = unpack_coords(neuron_id)
        for candidate in iter_neighbour_coords(
            coord, self.network.dimensions, self.params.neurogenesis_radius
        ):
            nid = pack_coords(*candidate)
            if nid not in self.network.neurons:
                return candidate
        return None