Skip to content

Synapses

Stochastic-computing synapses implement weighted connections between neurons using bitstream multiplication (AND gates).

Class Learning Use case
BitstreamSynapse None (static weight) Inference, fixed networks
StochasticSTDPSynapse Hebbian STDP Unsupervised learning
RewardModulatedSTDPSynapse Three-factor R-STDP Reinforcement learning
BitstreamDotProduct None Multi-input weighted sum
TripletSTDP Pfister-Gerstner 2006 Rate-dependent cortical plasticity
BCMSynapse Sliding threshold Metaplasticity, selectivity
ClopathSTDP Voltage-based Unifies rate + timing plasticity
TripartiteSynapse Astrocyte-modulated Neuron-glia-synapse coupling
GapJunction Electrical coupling Interneuron synchrony

Static Synapse

sc_neurocore.synapses.sc_synapse.BitstreamSynapse dataclass

Stochastic-computing synapse using bitstreams.

Each synapse has a weight w in [w_min, w_max]. SC multiplication via bitwise AND: P(out=1) ~ P(pre=1) * P(w=1).

Example

import numpy as np syn = BitstreamSynapse(w_min=0.0, w_max=1.0, w=0.5, length=1024, seed=42) pre = np.ones(1024, dtype=np.uint8) # all-ones input post = syn.apply(pre) abs(post.mean() - 0.5) < 0.1 # output ~50% ones True

Source code in src/sc_neurocore/synapses/sc_synapse.py
Python
 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
@dataclass
class BitstreamSynapse:
    """
    Stochastic-computing synapse using bitstreams.

    Each synapse has a weight w in [w_min, w_max].
    SC multiplication via bitwise AND: P(out=1) ~ P(pre=1) * P(w=1).

    Example
    -------
    >>> import numpy as np
    >>> syn = BitstreamSynapse(w_min=0.0, w_max=1.0, w=0.5, length=1024, seed=42)
    >>> pre = np.ones(1024, dtype=np.uint8)  # all-ones input
    >>> post = syn.apply(pre)
    >>> abs(post.mean() - 0.5) < 0.1  # output ~50% ones
    True
    """

    w_min: float
    w_max: float
    length: int = SYNAPSE_DEFAULT_LENGTH
    w: float = SYNAPSE_DEFAULT_WEIGHT
    seed: Optional[int] = None

    def __post_init__(self) -> None:
        if not math.isfinite(self.w_min):
            raise ValueError("w_min must be finite")
        if not math.isfinite(self.w_max):
            raise ValueError("w_max must be finite")
        if self.w_min >= self.w_max:
            raise ValueError("w_min must be < w_max.")
        if type(self.length) is not int or self.length <= 0:
            raise ValueError("length must be a positive integer")
        self._validate_weight(self.w)

        self._rng = RNG(self.seed)
        self._weight_encoder = BitstreamEncoder(
            x_min=self.w_min,
            x_max=self.w_max,
            length=self.length,
            seed=self.seed,
        )
        self.weight_bits = self.encode_weight(self.w)

    def encode_weight(self, w: float) -> np.ndarray[Any, Any]:
        """
        Encode scalar weight w into a unipolar bitstream.
        """
        return self._weight_encoder.encode(w)

    def update_weight(self, new_w: float) -> None:
        """
        Change synaptic weight and recompute its bitstream.
        """
        self._validate_weight(new_w)
        self.w = new_w
        self.weight_bits = self.encode_weight(new_w)

    def apply(self, pre_bits: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
        """
        Apply synapse to a pre-synaptic bitstream.

        Parameters
        ----------
        pre_bits : np.ndarray
            Bitstream of shape (length,) with values {0,1}.

        Returns
        -------
        np.ndarray
            Post-synaptic bitstream of shape (length,).
        """
        if not isinstance(pre_bits, np.ndarray):
            raise ValueError("pre_bits must be a numpy array")
        if pre_bits.ndim != 1:
            raise ValueError("pre_bits must be a one-dimensional bitstream")
        if pre_bits.shape[0] != self.weight_bits.shape[0]:
            raise ValueError(
                f"Bitstream length mismatch: pre={pre_bits.shape[0]}, "
                f"weight={self.weight_bits.shape[0]}"
            )
        if not np.all((pre_bits == 0) | (pre_bits == 1)):
            raise ValueError("pre_bits must contain only binary values 0 or 1")

        # Logical AND implements multiplication in SC domain
        result: np.ndarray[Any, Any] = (pre_bits & self.weight_bits).astype(np.uint8)
        return result

    def effective_weight_probability(self) -> float:
        """
        Decode the weight bitstream's probability P(weight_bit=1).
        This is the effective unipolar probability representation.
        """
        return bitstream_to_probability(self.weight_bits)

    def _validate_weight(self, w: float) -> None:
        if not math.isfinite(w):
            raise ValueError("w must be finite")
        if not self.w_min <= w <= self.w_max:
            raise ValueError("w must be within [w_min, w_max]")

encode_weight(w)

Encode scalar weight w into a unipolar bitstream.

Source code in src/sc_neurocore/synapses/sc_synapse.py
Python
67
68
69
70
71
def encode_weight(self, w: float) -> np.ndarray[Any, Any]:
    """
    Encode scalar weight w into a unipolar bitstream.
    """
    return self._weight_encoder.encode(w)

update_weight(new_w)

Change synaptic weight and recompute its bitstream.

Source code in src/sc_neurocore/synapses/sc_synapse.py
Python
73
74
75
76
77
78
79
def update_weight(self, new_w: float) -> None:
    """
    Change synaptic weight and recompute its bitstream.
    """
    self._validate_weight(new_w)
    self.w = new_w
    self.weight_bits = self.encode_weight(new_w)

apply(pre_bits)

Apply synapse to a pre-synaptic bitstream.

Parameters

pre_bits : np.ndarray Bitstream of shape (length,) with values {0,1}.

Returns

np.ndarray Post-synaptic bitstream of shape (length,).

Source code in src/sc_neurocore/synapses/sc_synapse.py
Python
 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
def apply(self, pre_bits: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
    """
    Apply synapse to a pre-synaptic bitstream.

    Parameters
    ----------
    pre_bits : np.ndarray
        Bitstream of shape (length,) with values {0,1}.

    Returns
    -------
    np.ndarray
        Post-synaptic bitstream of shape (length,).
    """
    if not isinstance(pre_bits, np.ndarray):
        raise ValueError("pre_bits must be a numpy array")
    if pre_bits.ndim != 1:
        raise ValueError("pre_bits must be a one-dimensional bitstream")
    if pre_bits.shape[0] != self.weight_bits.shape[0]:
        raise ValueError(
            f"Bitstream length mismatch: pre={pre_bits.shape[0]}, "
            f"weight={self.weight_bits.shape[0]}"
        )
    if not np.all((pre_bits == 0) | (pre_bits == 1)):
        raise ValueError("pre_bits must contain only binary values 0 or 1")

    # Logical AND implements multiplication in SC domain
    result: np.ndarray[Any, Any] = (pre_bits & self.weight_bits).astype(np.uint8)
    return result

effective_weight_probability()

Decode the weight bitstream's probability P(weight_bit=1). This is the effective unipolar probability representation.

Source code in src/sc_neurocore/synapses/sc_synapse.py
Python
111
112
113
114
115
116
def effective_weight_probability(self) -> float:
    """
    Decode the weight bitstream's probability P(weight_bit=1).
    This is the effective unipolar probability representation.
    """
    return bitstream_to_probability(self.weight_bits)

STDP Synapse

sc_neurocore.synapses.stochastic_stdp.StochasticSTDPSynapse dataclass

Bases: BitstreamSynapse

Stochastic synapse with spike-timing-dependent plasticity.

LTP on pre→post coincidence, LTD on pre-without-post. Asymmetry ratio from Bi & Poo, J. Neurosci. 18(24), 1998.

Example

syn = StochasticSTDPSynapse(w_min=0.0, w_max=1.0, w=0.5, length=64) for _ in range(100): ... syn.process_step(pre_bit=1, post_bit=1) # correlated activity → LTP syn.w >= 0.5 # weight increased or stayed True

Source code in src/sc_neurocore/synapses/stochastic_stdp.py
Python
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
@dataclass
class StochasticSTDPSynapse(BitstreamSynapse):
    """
    Stochastic synapse with spike-timing-dependent plasticity.

    LTP on pre→post coincidence, LTD on pre-without-post.
    Asymmetry ratio from Bi & Poo, J. Neurosci. 18(24), 1998.

    Example
    -------
    >>> syn = StochasticSTDPSynapse(w_min=0.0, w_max=1.0, w=0.5, length=64)
    >>> for _ in range(100):
    ...     syn.process_step(pre_bit=1, post_bit=1)  # correlated activity → LTP
    >>> syn.w >= 0.5  # weight increased or stayed
    True
    """

    learning_rate: float = STDP_LEARNING_RATE
    window_size: int = STDP_WINDOW_SIZE
    ltd_ratio: float = STDP_LTD_RATIO

    _pre_trace: np.ndarray[Any, Any] = field(init=False, repr=False)

    def __post_init__(self) -> None:
        super().__post_init__()
        if not math.isfinite(self.learning_rate) or not 0.0 <= self.learning_rate <= 1.0:
            raise ValueError("learning_rate must be finite and within [0, 1]")
        if type(self.window_size) is not int or self.window_size <= 0:
            raise ValueError("window_size must be a positive integer")
        if not math.isfinite(self.ltd_ratio) or self.ltd_ratio < 0.0:
            raise ValueError("ltd_ratio must be finite and non-negative")

        # Buffer to store recent pre-synaptic bits
        self._pre_trace = np.zeros(self.window_size, dtype=np.uint8)

    def process_step(self, pre_bit: int, post_bit: int) -> int:
        """Process one timestep: compute output, update trace, apply STDP."""
        self._validate_bit("pre_bit", pre_bit)
        self._validate_bit("post_bit", post_bit)

        weight_bit = 1 if self._rng.random() < self.effective_weight_probability() else 0
        output_bit = pre_bit & weight_bit

        self._pre_trace = np.roll(self._pre_trace, 1)
        self._pre_trace[0] = pre_bit

        # Trace-based STDP: post spike + recent pre activity → LTP.
        # Pre spike without post → LTD. Mutually exclusive per timestep.
        if post_bit == 1 and np.any(self._pre_trace[1:]):
            if self._rng.random() < self.learning_rate:
                self._potentiate()
        elif pre_bit == 1 and post_bit == 0:
            if self._rng.random() < self.learning_rate * self.ltd_ratio:
                self._depress()

        return output_bit

    def _potentiate(self) -> None:
        new_w = min(self.w_max, self.w + self.learning_rate * (self.w_max - self.w_min))
        self.update_weight(new_w)

    def _depress(self) -> None:
        new_w = max(self.w_min, self.w - self.learning_rate * (self.w_max - self.w_min))
        self.update_weight(new_w)

    @staticmethod
    def _validate_bit(name: str, value: int) -> None:
        if type(value) is not int or value not in (0, 1):
            raise ValueError(f"{name} must be an integer bit, 0 or 1")

process_step(pre_bit, post_bit)

Process one timestep: compute output, update trace, apply STDP.

Source code in src/sc_neurocore/synapses/stochastic_stdp.py
Python
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def process_step(self, pre_bit: int, post_bit: int) -> int:
    """Process one timestep: compute output, update trace, apply STDP."""
    self._validate_bit("pre_bit", pre_bit)
    self._validate_bit("post_bit", post_bit)

    weight_bit = 1 if self._rng.random() < self.effective_weight_probability() else 0
    output_bit = pre_bit & weight_bit

    self._pre_trace = np.roll(self._pre_trace, 1)
    self._pre_trace[0] = pre_bit

    # Trace-based STDP: post spike + recent pre activity → LTP.
    # Pre spike without post → LTD. Mutually exclusive per timestep.
    if post_bit == 1 and np.any(self._pre_trace[1:]):
        if self._rng.random() < self.learning_rate:
            self._potentiate()
    elif pre_bit == 1 and post_bit == 0:
        if self._rng.random() < self.learning_rate * self.ltd_ratio:
            self._depress()

    return output_bit

Reward-Modulated STDP

sc_neurocore.synapses.r_stdp.RewardModulatedSTDPSynapse dataclass

Bases: StochasticSTDPSynapse

Reward-modulated STDP synapse (Izhikevich, Cerebral Cortex 17(10), 2007).

Eligibility trace accumulates Hebbian coincidences; weight update fires only when a global reward signal arrives.

Example

syn = RewardModulatedSTDPSynapse(w_min=0.0, w_max=1.0, w=0.5, length=64) for _ in range(20): ... syn.process_step(pre_bit=1, post_bit=1) syn.apply_reward(reward=1.0) # positive reward → potentiate syn.w >= 0.5 True

Source code in src/sc_neurocore/synapses/r_stdp.py
Python
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
@dataclass
class RewardModulatedSTDPSynapse(StochasticSTDPSynapse):
    """
    Reward-modulated STDP synapse (Izhikevich, Cerebral Cortex 17(10), 2007).

    Eligibility trace accumulates Hebbian coincidences; weight update
    fires only when a global reward signal arrives.

    Example
    -------
    >>> syn = RewardModulatedSTDPSynapse(w_min=0.0, w_max=1.0, w=0.5, length=64)
    >>> for _ in range(20):
    ...     syn.process_step(pre_bit=1, post_bit=1)
    >>> syn.apply_reward(reward=1.0)  # positive reward → potentiate
    >>> syn.w >= 0.5
    True
    """

    eligibility_trace: float = 0.0
    trace_decay: float = RSTDP_TRACE_DECAY
    anti_hebbian_scale: float = RSTDP_ANTI_HEBBIAN_SCALE

    def __post_init__(self) -> None:
        super().__post_init__()
        if not math.isfinite(self.eligibility_trace):
            raise ValueError("eligibility_trace must be finite")
        if not math.isfinite(self.trace_decay) or not 0.0 <= self.trace_decay <= 1.0:
            raise ValueError("trace_decay must be finite and within [0, 1]")
        if not math.isfinite(self.anti_hebbian_scale) or self.anti_hebbian_scale < 0.0:
            raise ValueError("anti_hebbian_scale must be finite and non-negative")

    def process_step(self, pre_bit: int, post_bit: int) -> int:
        self._validate_bit("pre_bit", pre_bit)
        self._validate_bit("post_bit", post_bit)

        # 1. Compute Output (Same as standard)
        w_prob = self.effective_weight_probability()
        weight_bit = 1 if self._rng.random() < w_prob else 0
        output_bit = pre_bit & weight_bit

        # 2. Update Eligibility Trace instead of Weight
        # (Simplified Hebbian / STDP logic)

        # Hebbian Term: Pre * Post
        # If both fire, trace goes up (Potentiation eligibility)
        if pre_bit == 1 and post_bit == 1:
            self.eligibility_trace += 1.0

        # Anti-Hebbian Term: Pre * !Post (or vice versa depending on rule)
        # If Pre fires but Post doesn't, trace goes down (Depression eligibility)
        elif pre_bit == 1 and post_bit == 0:
            self.eligibility_trace -= self.anti_hebbian_scale

        # Decay trace
        self.eligibility_trace *= self.trace_decay

        return output_bit

    def apply_reward(self, reward: float) -> None:
        """
        Global reward signal triggers weight update.
        """
        if not math.isfinite(reward):
            raise ValueError("reward must be finite")

        # Delta W ~ Reward * Trace
        update = self.learning_rate * reward * self.eligibility_trace

        new_w = self.w + update
        # Clip
        new_w = max(self.w_min, min(self.w_max, new_w))

        self.update_weight(new_w)

        # Optionally reset trace? Usually trace decays naturally.
        # self.eligibility_trace = 0

    @staticmethod
    def _validate_bit(name: str, value: int) -> None:
        if type(value) is not int or value not in (0, 1):
            raise ValueError(f"{name} must be an integer bit, 0 or 1")

apply_reward(reward)

Global reward signal triggers weight update.

Source code in src/sc_neurocore/synapses/r_stdp.py
Python
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def apply_reward(self, reward: float) -> None:
    """
    Global reward signal triggers weight update.
    """
    if not math.isfinite(reward):
        raise ValueError("reward must be finite")

    # Delta W ~ Reward * Trace
    update = self.learning_rate * reward * self.eligibility_trace

    new_w = self.w + update
    # Clip
    new_w = max(self.w_min, min(self.w_max, new_w))

    self.update_weight(new_w)

Dot Product

sc_neurocore.synapses.dot_product.BitstreamDotProduct dataclass

Bitstream-level dot product via SC synapses.

For each input i, applies synapse_i (AND gate), then sums decoded probabilities: y ~ sum_i w_i * x_i.

Example

import numpy as np from sc_neurocore import BitstreamSynapse syns = [BitstreamSynapse(w_min=0.0, w_max=1.0, w=0.5, length=256) ... for _ in range(3)] dp = BitstreamDotProduct(synapses=syns) pre = np.ones((3, 256), dtype=np.uint8) post_matrix, y_scalar = dp.apply(pre) post_matrix.shape (3, 256)

Source code in src/sc_neurocore/synapses/dot_product.py
Python
 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
@dataclass
class BitstreamDotProduct:
    """
    Bitstream-level dot product via SC synapses.

    For each input i, applies synapse_i (AND gate), then sums decoded
    probabilities: y ~ sum_i w_i * x_i.

    Example
    -------
    >>> import numpy as np
    >>> from sc_neurocore import BitstreamSynapse
    >>> syns = [BitstreamSynapse(w_min=0.0, w_max=1.0, w=0.5, length=256)
    ...         for _ in range(3)]
    >>> dp = BitstreamDotProduct(synapses=syns)
    >>> pre = np.ones((3, 256), dtype=np.uint8)
    >>> post_matrix, y_scalar = dp.apply(pre)
    >>> post_matrix.shape
    (3, 256)
    """

    synapses: List[BitstreamSynapse]

    def __post_init__(self) -> None:
        if not isinstance(self.synapses, list) or len(self.synapses) == 0:
            raise ValueError("synapses must be a non-empty list")
        if not all(isinstance(synapse, BitstreamSynapse) for synapse in self.synapses):
            raise ValueError("synapses must contain only BitstreamSynapse instances")
        length = self.synapses[0].length
        if any(synapse.length != length for synapse in self.synapses):
            raise ValueError("synapses must share a common bitstream length")

    @property
    def n_inputs(self) -> int:
        return len(self.synapses)

    def apply(
        self,
        pre_matrix: np.ndarray[Any, Any],
        y_min: float = 0.0,
        y_max: float = 1.0,
    ) -> Tuple[np.ndarray[Any, Any], float]:
        """
        Apply all synapses to the pre-synaptic bitstreams and compute
        a scalar 'dot-product-like' value.

        Parameters
        ----------
        pre_matrix : np.ndarray
            Shape (n_inputs, length), entries {0,1}.
        y_min, y_max : float
            Range in which the final scalar output is interpreted
            (e.g., current range for the neuron).

        Returns
        -------
        post_matrix : np.ndarray
            Post-synaptic bitstreams of shape (n_inputs, length).
        y_scalar : float
            Scalar result representing sum_i P(post_i=1) mapped into [y_min, y_max].
        """
        if not math.isfinite(y_min) or not math.isfinite(y_max) or y_min >= y_max:
            raise ValueError("y_min and y_max must be finite with y_min < y_max")
        if not isinstance(pre_matrix, np.ndarray):
            raise ValueError("pre_matrix must be a numpy array")
        if pre_matrix.ndim != 2:
            raise ValueError("pre_matrix must be a two-dimensional bitstream matrix")
        if pre_matrix.shape[0] != self.n_inputs:
            raise ValueError(
                f"pre_matrix expected {self.n_inputs} input bitstreams, got {pre_matrix.shape[0]}"
            )
        expected_length = self.synapses[0].length
        if pre_matrix.shape[1] != expected_length:
            raise ValueError(
                f"pre_matrix expected bitstream length {expected_length}, got {pre_matrix.shape[1]}"
            )
        if not np.all((pre_matrix == 0) | (pre_matrix == 1)):
            raise ValueError("pre_matrix must contain only binary values 0 or 1")

        post_matrix = np.zeros_like(pre_matrix, dtype=np.uint8)
        probs = []

        for i, syn in enumerate(self.synapses):
            post_i = syn.apply(pre_matrix[i])
            post_matrix[i] = post_i
            probs.append(bitstream_to_probability(post_i))

        # Dot-product in probability space (weights already baked into probs)
        y_prob_sum = float(sum(probs))

        # Normalize by number of inputs if desired
        # Here we just keep the sum and clamp into [0, 1]
        y_prob_clamped = max(min(y_prob_sum, 1.0), 0.0)

        # Map that into [y_min, y_max]
        y_scalar = unipolar_prob_to_value(y_prob_clamped, y_min, y_max)

        return post_matrix, y_scalar

apply(pre_matrix, y_min=0.0, y_max=1.0)

Apply all synapses to the pre-synaptic bitstreams and compute a scalar 'dot-product-like' value.

Parameters

pre_matrix : np.ndarray Shape (n_inputs, length), entries {0,1}. y_min, y_max : float Range in which the final scalar output is interpreted (e.g., current range for the neuron).

Returns

post_matrix : np.ndarray Post-synaptic bitstreams of shape (n_inputs, length). y_scalar : float Scalar result representing sum_i P(post_i=1) mapped into [y_min, y_max].

Source code in src/sc_neurocore/synapses/dot_product.py
Python
 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
def apply(
    self,
    pre_matrix: np.ndarray[Any, Any],
    y_min: float = 0.0,
    y_max: float = 1.0,
) -> Tuple[np.ndarray[Any, Any], float]:
    """
    Apply all synapses to the pre-synaptic bitstreams and compute
    a scalar 'dot-product-like' value.

    Parameters
    ----------
    pre_matrix : np.ndarray
        Shape (n_inputs, length), entries {0,1}.
    y_min, y_max : float
        Range in which the final scalar output is interpreted
        (e.g., current range for the neuron).

    Returns
    -------
    post_matrix : np.ndarray
        Post-synaptic bitstreams of shape (n_inputs, length).
    y_scalar : float
        Scalar result representing sum_i P(post_i=1) mapped into [y_min, y_max].
    """
    if not math.isfinite(y_min) or not math.isfinite(y_max) or y_min >= y_max:
        raise ValueError("y_min and y_max must be finite with y_min < y_max")
    if not isinstance(pre_matrix, np.ndarray):
        raise ValueError("pre_matrix must be a numpy array")
    if pre_matrix.ndim != 2:
        raise ValueError("pre_matrix must be a two-dimensional bitstream matrix")
    if pre_matrix.shape[0] != self.n_inputs:
        raise ValueError(
            f"pre_matrix expected {self.n_inputs} input bitstreams, got {pre_matrix.shape[0]}"
        )
    expected_length = self.synapses[0].length
    if pre_matrix.shape[1] != expected_length:
        raise ValueError(
            f"pre_matrix expected bitstream length {expected_length}, got {pre_matrix.shape[1]}"
        )
    if not np.all((pre_matrix == 0) | (pre_matrix == 1)):
        raise ValueError("pre_matrix must contain only binary values 0 or 1")

    post_matrix = np.zeros_like(pre_matrix, dtype=np.uint8)
    probs = []

    for i, syn in enumerate(self.synapses):
        post_i = syn.apply(pre_matrix[i])
        post_matrix[i] = post_i
        probs.append(bitstream_to_probability(post_i))

    # Dot-product in probability space (weights already baked into probs)
    y_prob_sum = float(sum(probs))

    # Normalize by number of inputs if desired
    # Here we just keep the sum and clamp into [0, 1]
    y_prob_clamped = max(min(y_prob_sum, 1.0), 0.0)

    # Map that into [y_min, y_max]
    y_scalar = unipolar_prob_to_value(y_prob_clamped, y_min, y_max)

    return post_matrix, y_scalar

Triplet STDP (Pfister-Gerstner 2006)

sc_neurocore.synapses.triplet_stdp.TripletSTDP dataclass

Triplet STDP synapse (Pfister-Gerstner 2006).

Parameters

tau_plus : float Pre-synaptic trace decay (ms). Default: 16.8 (visual cortex fit). tau_minus : float Post-synaptic trace decay (ms). Default: 33.7. tau_x : float Slow pre-synaptic trace decay (ms). Default: 101. tau_y : float Slow post-synaptic trace decay (ms). Default: 125. a2_plus : float Pair LTP amplitude. Default: 7.5e-10. a3_plus : float Triplet LTP amplitude. Default: 9.3e-3. a2_minus : float Pair LTD amplitude. Default: 7.0e-3. a3_minus : float Triplet LTD amplitude. Default: 2.3e-4. w_min : float Minimum weight. Default: 0.0. w_max : float Maximum weight. Default: 1.0.

Source code in src/sc_neurocore/synapses/triplet_stdp.py
Python
 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
@dataclass
class TripletSTDP:
    """Triplet STDP synapse (Pfister-Gerstner 2006).

    Parameters
    ----------
    tau_plus : float
        Pre-synaptic trace decay (ms). Default: 16.8 (visual cortex fit).
    tau_minus : float
        Post-synaptic trace decay (ms). Default: 33.7.
    tau_x : float
        Slow pre-synaptic trace decay (ms). Default: 101.
    tau_y : float
        Slow post-synaptic trace decay (ms). Default: 125.
    a2_plus : float
        Pair LTP amplitude. Default: 7.5e-10.
    a3_plus : float
        Triplet LTP amplitude. Default: 9.3e-3.
    a2_minus : float
        Pair LTD amplitude. Default: 7.0e-3.
    a3_minus : float
        Triplet LTD amplitude. Default: 2.3e-4.
    w_min : float
        Minimum weight. Default: 0.0.
    w_max : float
        Maximum weight. Default: 1.0.
    """

    tau_plus: float = 16.8
    tau_minus: float = 33.7
    tau_x: float = 101.0
    tau_y: float = 125.0
    a2_plus: float = 7.5e-10
    a3_plus: float = 9.3e-3
    a2_minus: float = 7.0e-3
    a3_minus: float = 2.3e-4
    w_min: float = 0.0
    w_max: float = 1.0
    weight: float = 0.5

    def __post_init__(self) -> None:
        for name in ("tau_plus", "tau_minus", "tau_x", "tau_y"):
            value = getattr(self, name)
            if not math.isfinite(value) or value <= 0.0:
                raise ValueError(f"{name} must be finite and positive")

        for name in ("a2_plus", "a3_plus", "a2_minus", "a3_minus", "w_min", "w_max", "weight"):
            value = getattr(self, name)
            if not math.isfinite(value):
                raise ValueError(f"{name} must be finite")

        for name in ("a2_plus", "a3_plus", "a2_minus", "a3_minus"):
            if getattr(self, name) < 0.0:
                raise ValueError(f"{name} must be non-negative")

        if self.w_min > self.w_max:
            raise ValueError("w_min must be less than or equal to w_max")
        if not (self.w_min <= self.weight <= self.w_max):
            raise ValueError("weight must be within [w_min, w_max]")

        self.r1 = 0.0  # fast pre-synaptic trace
        self.r2 = 0.0  # slow pre-synaptic trace
        self.o1 = 0.0  # fast post-synaptic trace
        self.o2 = 0.0  # slow post-synaptic trace

    def step(self, pre_spike: bool, post_spike: bool, dt: float = 1.0) -> float:
        """Advance one timestep.

        Returns the current weight after update.
        """
        if type(pre_spike) is not bool or type(post_spike) is not bool:
            raise TypeError("pre_spike and post_spike must be bool")
        if not math.isfinite(dt) or dt <= 0.0:
            raise ValueError("dt must be finite and positive")

        # Decay traces
        self.r1 *= math.exp(-dt / self.tau_plus)
        self.r2 *= math.exp(-dt / self.tau_x)
        self.o1 *= math.exp(-dt / self.tau_minus)
        self.o2 *= math.exp(-dt / self.tau_y)

        # Weight updates on spikes
        if post_spike:
            # LTP: pair + triplet pre-post-post
            self.weight += self.r1 * (self.a2_plus + self.a3_plus * self.o2)
        if pre_spike:
            # LTD: pair + triplet pre-pre-post
            self.weight -= self.o1 * (self.a2_minus + self.a3_minus * self.r2)

        # Clamp
        self.weight = max(self.w_min, min(self.w_max, self.weight))

        # Update traces after weight change (order matters — Pfister 2006 Eq. 3-4)
        if pre_spike:
            self.r1 += 1.0
            self.r2 += 1.0
        if post_spike:
            self.o1 += 1.0
            self.o2 += 1.0

        return self.weight

    def reset(self) -> None:
        self.r1 = self.r2 = self.o1 = self.o2 = 0.0

step(pre_spike, post_spike, dt=1.0)

Advance one timestep.

Returns the current weight after update.

Source code in src/sc_neurocore/synapses/triplet_stdp.py
Python
 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
def step(self, pre_spike: bool, post_spike: bool, dt: float = 1.0) -> float:
    """Advance one timestep.

    Returns the current weight after update.
    """
    if type(pre_spike) is not bool or type(post_spike) is not bool:
        raise TypeError("pre_spike and post_spike must be bool")
    if not math.isfinite(dt) or dt <= 0.0:
        raise ValueError("dt must be finite and positive")

    # Decay traces
    self.r1 *= math.exp(-dt / self.tau_plus)
    self.r2 *= math.exp(-dt / self.tau_x)
    self.o1 *= math.exp(-dt / self.tau_minus)
    self.o2 *= math.exp(-dt / self.tau_y)

    # Weight updates on spikes
    if post_spike:
        # LTP: pair + triplet pre-post-post
        self.weight += self.r1 * (self.a2_plus + self.a3_plus * self.o2)
    if pre_spike:
        # LTD: pair + triplet pre-pre-post
        self.weight -= self.o1 * (self.a2_minus + self.a3_minus * self.r2)

    # Clamp
    self.weight = max(self.w_min, min(self.w_max, self.weight))

    # Update traces after weight change (order matters — Pfister 2006 Eq. 3-4)
    if pre_spike:
        self.r1 += 1.0
        self.r2 += 1.0
    if post_spike:
        self.o1 += 1.0
        self.o2 += 1.0

    return self.weight

BCM Metaplasticity (Bienenstock-Cooper-Munro 1982)

sc_neurocore.synapses.bcm.BCMSynapse dataclass

BCM synapse with sliding modification threshold.

Parameters

eta : float Learning rate. tau_theta : float Time constant for sliding threshold (ms). theta_init : float Initial threshold value. w_min, w_max : float Weight bounds.

Source code in src/sc_neurocore/synapses/bcm.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
 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
@dataclass
class BCMSynapse:
    """BCM synapse with sliding modification threshold.

    Parameters
    ----------
    eta : float
        Learning rate.
    tau_theta : float
        Time constant for sliding threshold (ms).
    theta_init : float
        Initial threshold value.
    w_min, w_max : float
        Weight bounds.
    """

    eta: float = 0.01
    tau_theta: float = 1000.0
    theta_init: float = 0.1
    w_min: float = 0.0
    w_max: float = 1.0
    weight: float = 0.5

    def __post_init__(self) -> None:
        if not math.isfinite(self.eta) or self.eta < 0.0:
            raise ValueError("eta must be finite and non-negative")
        if not math.isfinite(self.tau_theta) or self.tau_theta <= 0.0:
            raise ValueError("tau_theta must be finite and positive")
        if not math.isfinite(self.theta_init) or self.theta_init < 0.0:
            raise ValueError("theta_init must be finite and non-negative")
        for name in ("w_min", "w_max", "weight"):
            value = getattr(self, name)
            if not math.isfinite(value):
                raise ValueError(f"{name} must be finite")
        if self.w_min > self.w_max:
            raise ValueError("w_min must be less than or equal to w_max")
        if not (self.w_min <= self.weight <= self.w_max):
            raise ValueError("weight must be within [w_min, w_max]")

        self.theta_m = self.theta_init

    def step(self, pre_rate: float, post_rate: float, dt: float = 1.0) -> float:
        """Advance one timestep.

        Parameters
        ----------
        pre_rate : float
            Pre-synaptic firing rate (or spike indicator).
        post_rate : float
            Post-synaptic firing rate (or membrane proxy).
        dt : float
            Timestep in ms.

        Returns
        -------
        float
            Updated weight.
        """
        if not math.isfinite(pre_rate) or pre_rate < 0.0:
            raise ValueError("pre_rate must be finite and non-negative")
        if not math.isfinite(post_rate) or post_rate < 0.0:
            raise ValueError("post_rate must be finite and non-negative")
        if not math.isfinite(dt) or dt <= 0.0:
            raise ValueError("dt must be finite and positive")

        # BCM update: dw = eta * y * (y - theta_M) * x
        dw = self.eta * post_rate * (post_rate - self.theta_m) * pre_rate * dt
        self.weight += dw
        self.weight = max(self.w_min, min(self.w_max, self.weight))

        # Sliding threshold: d(theta)/dt = (y^2 - theta) / tau_theta
        self.theta_m += (post_rate**2 - self.theta_m) * dt / self.tau_theta

        return self.weight

    def reset(self) -> None:
        self.theta_m = self.theta_init

step(pre_rate, post_rate, dt=1.0)

Advance one timestep.

Parameters

pre_rate : float Pre-synaptic firing rate (or spike indicator). post_rate : float Post-synaptic firing rate (or membrane proxy). dt : float Timestep in ms.

Returns

float Updated weight.

Source code in src/sc_neurocore/synapses/bcm.py
Python
 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
def step(self, pre_rate: float, post_rate: float, dt: float = 1.0) -> float:
    """Advance one timestep.

    Parameters
    ----------
    pre_rate : float
        Pre-synaptic firing rate (or spike indicator).
    post_rate : float
        Post-synaptic firing rate (or membrane proxy).
    dt : float
        Timestep in ms.

    Returns
    -------
    float
        Updated weight.
    """
    if not math.isfinite(pre_rate) or pre_rate < 0.0:
        raise ValueError("pre_rate must be finite and non-negative")
    if not math.isfinite(post_rate) or post_rate < 0.0:
        raise ValueError("post_rate must be finite and non-negative")
    if not math.isfinite(dt) or dt <= 0.0:
        raise ValueError("dt must be finite and positive")

    # BCM update: dw = eta * y * (y - theta_M) * x
    dw = self.eta * post_rate * (post_rate - self.theta_m) * pre_rate * dt
    self.weight += dw
    self.weight = max(self.w_min, min(self.w_max, self.weight))

    # Sliding threshold: d(theta)/dt = (y^2 - theta) / tau_theta
    self.theta_m += (post_rate**2 - self.theta_m) * dt / self.tau_theta

    return self.weight

Voltage-Based STDP (Clopath et al. 2010)

ClopathSTDP implements the two voltage-gated pathways from Clopath et al.:

  • LTD is applied on presynaptic spikes when the slow postsynaptic voltage trace is above theta_minus.
  • LTP is applied when the current postsynaptic voltage is above theta_plus and the fast postsynaptic voltage trace is above theta_minus.

This makes the public contract trace-dependent rather than spike-pair only: regression tests should preserve both the LTD path under moderate depolarisation and the LTP path under strong depolarisation.

sc_neurocore.synapses.clopath_stdp.ClopathSTDP dataclass

Voltage-based STDP (Clopath et al. 2010).

Parameters

a_ltd : float LTD amplitude. Default: 14e-5 (Clopath 2010, Table 1). a_ltp : float LTP amplitude. Default: 8e-5. tau_x : float Pre-synaptic trace decay (ms). Default: 15. tau_minus : float Slow voltage trace decay (ms). Default: 10. tau_plus : float Fast voltage trace decay (ms). Default: 7. theta_minus : float LTD voltage threshold (mV). Default: -70.6 (rest). theta_plus : float LTP voltage threshold (mV). Default: -45.3 (depolarization). w_min, w_max : float Weight bounds.

Source code in src/sc_neurocore/synapses/clopath_stdp.py
Python
 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
@dataclass
class ClopathSTDP:
    """Voltage-based STDP (Clopath et al. 2010).

    Parameters
    ----------
    a_ltd : float
        LTD amplitude. Default: 14e-5 (Clopath 2010, Table 1).
    a_ltp : float
        LTP amplitude. Default: 8e-5.
    tau_x : float
        Pre-synaptic trace decay (ms). Default: 15.
    tau_minus : float
        Slow voltage trace decay (ms). Default: 10.
    tau_plus : float
        Fast voltage trace decay (ms). Default: 7.
    theta_minus : float
        LTD voltage threshold (mV). Default: -70.6 (rest).
    theta_plus : float
        LTP voltage threshold (mV). Default: -45.3 (depolarization).
    w_min, w_max : float
        Weight bounds.
    """

    a_ltd: float = 14e-5
    a_ltp: float = 8e-5
    tau_x: float = 15.0
    tau_minus: float = 10.0
    tau_plus: float = 7.0
    theta_minus: float = -70.6
    theta_plus: float = -45.3
    w_min: float = 0.0
    w_max: float = 1.0
    weight: float = 0.5

    def __post_init__(self) -> None:
        for name in ("a_ltd", "a_ltp"):
            value = getattr(self, name)
            if not math.isfinite(value) or value < 0.0:
                raise ValueError(f"{name} must be finite and non-negative")
        for name in ("tau_x", "tau_minus", "tau_plus"):
            value = getattr(self, name)
            if not math.isfinite(value) or value <= 0.0:
                raise ValueError(f"{name} must be finite and positive")
        for name in ("theta_minus", "theta_plus", "w_min", "w_max", "weight"):
            value = getattr(self, name)
            if not math.isfinite(value):
                raise ValueError(f"{name} must be finite")
        if self.theta_plus <= self.theta_minus:
            raise ValueError("theta_plus must be greater than theta_minus")
        if self.w_min > self.w_max:
            raise ValueError("w_min must be less than or equal to w_max")
        if not (self.w_min <= self.weight <= self.w_max):
            raise ValueError("weight must be within [w_min, w_max]")

        self.x_bar = 0.0  # low-pass filtered pre-synaptic trace
        self.u_bar_minus = 0.0  # slow voltage trace (LTD)
        self.u_bar_plus = 0.0  # fast voltage trace (LTP)

    def step(self, pre_spike: bool, u_post: float, dt: float = 1.0) -> float:
        """Advance one timestep.

        Parameters
        ----------
        pre_spike : bool
            Whether the pre-synaptic neuron spiked.
        u_post : float
            Post-synaptic membrane voltage (mV).
        dt : float
            Timestep in ms.

        Returns
        -------
        float
            Updated weight.
        """
        if type(pre_spike) is not bool:
            raise TypeError("pre_spike must be bool")
        if not math.isfinite(u_post):
            raise ValueError("u_post must be finite")
        if not math.isfinite(dt) or dt <= 0.0:
            raise ValueError("dt must be finite and positive")

        decay_x = math.exp(-dt / self.tau_x)
        decay_minus = math.exp(-dt / self.tau_minus)
        decay_plus = math.exp(-dt / self.tau_plus)

        # LTD: pre-synaptic spike × post depolarization (Clopath 2010, Eq. 2)
        if pre_spike:
            ltd = self.a_ltd * self.x_bar * max(0.0, self.u_bar_minus - self.theta_minus)
            self.weight -= ltd

        # LTP: evaluated every timestep, pre contribution via x_bar trace (Clopath 2010, Eq. 3)
        ltp_post = max(0.0, u_post - self.theta_plus)
        ltp_pre = max(0.0, self.u_bar_plus - self.theta_minus)
        if ltp_post > 0 and ltp_pre > 0:
            self.weight += self.a_ltp * self.x_bar * ltp_post * ltp_pre

        self.weight = max(self.w_min, min(self.w_max, self.weight))

        # Update traces: exact exponential filter (no double-decay)
        self.x_bar *= decay_x
        if pre_spike:
            self.x_bar += 1.0
        self.u_bar_minus = decay_minus * self.u_bar_minus + (1 - decay_minus) * u_post
        self.u_bar_plus = decay_plus * self.u_bar_plus + (1 - decay_plus) * u_post

        return self.weight

    def reset(self) -> None:
        self.x_bar = 0.0
        self.u_bar_minus = 0.0
        self.u_bar_plus = 0.0

step(pre_spike, u_post, dt=1.0)

Advance one timestep.

Parameters

pre_spike : bool Whether the pre-synaptic neuron spiked. u_post : float Post-synaptic membrane voltage (mV). dt : float Timestep in ms.

Returns

float Updated weight.

Source code in src/sc_neurocore/synapses/clopath_stdp.py
Python
 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
def step(self, pre_spike: bool, u_post: float, dt: float = 1.0) -> float:
    """Advance one timestep.

    Parameters
    ----------
    pre_spike : bool
        Whether the pre-synaptic neuron spiked.
    u_post : float
        Post-synaptic membrane voltage (mV).
    dt : float
        Timestep in ms.

    Returns
    -------
    float
        Updated weight.
    """
    if type(pre_spike) is not bool:
        raise TypeError("pre_spike must be bool")
    if not math.isfinite(u_post):
        raise ValueError("u_post must be finite")
    if not math.isfinite(dt) or dt <= 0.0:
        raise ValueError("dt must be finite and positive")

    decay_x = math.exp(-dt / self.tau_x)
    decay_minus = math.exp(-dt / self.tau_minus)
    decay_plus = math.exp(-dt / self.tau_plus)

    # LTD: pre-synaptic spike × post depolarization (Clopath 2010, Eq. 2)
    if pre_spike:
        ltd = self.a_ltd * self.x_bar * max(0.0, self.u_bar_minus - self.theta_minus)
        self.weight -= ltd

    # LTP: evaluated every timestep, pre contribution via x_bar trace (Clopath 2010, Eq. 3)
    ltp_post = max(0.0, u_post - self.theta_plus)
    ltp_pre = max(0.0, self.u_bar_plus - self.theta_minus)
    if ltp_post > 0 and ltp_pre > 0:
        self.weight += self.a_ltp * self.x_bar * ltp_post * ltp_pre

    self.weight = max(self.w_min, min(self.w_max, self.weight))

    # Update traces: exact exponential filter (no double-decay)
    self.x_bar *= decay_x
    if pre_spike:
        self.x_bar += 1.0
    self.u_bar_minus = decay_minus * self.u_bar_minus + (1 - decay_minus) * u_post
    self.u_bar_plus = decay_plus * self.u_bar_plus + (1 - decay_plus) * u_post

    return self.weight

Tripartite Synapse (Astrocyte Coupling)

TripartiteSynapse couples presynaptic activity to an internal astrocyte model before applying the effective synaptic weight:

  • presynaptic spikes accumulate a glutamate signal that drives astrocyte IP3 production;
  • astrocyte calcium above ca_threshold facilitates the synaptic weight through the configured facilitation gain;
  • calcium below threshold relaxes the weight back toward base_weight through depression_rate;
  • w_min and w_max bound the effective weight in both regimes.

The public contract is therefore stateful across synapse and astrocyte state. Tests should preserve IP3 accumulation from sustained presynaptic activity, calcium-gated facilitation, baseline recovery, and bounded effective weights.

sc_neurocore.synapses.tripartite.TripartiteSynapse dataclass

Synapse with bidirectional astrocyte coupling.

Parameters

base_weight : float Baseline synaptic weight. glut_per_spike : float IP3 production rate per pre-synaptic spike (µM/s). ca_threshold : float Astrocyte Ca²⁺ threshold for gliotransmitter release (µM). facilitation : float Multiplicative gain when astrocyte is active (> 1 for facilitation). depression_rate : float Weight depression rate when astrocyte Ca²⁺ is below threshold. w_min, w_max : float Weight bounds.

Source code in src/sc_neurocore/synapses/tripartite.py
Python
 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
@dataclass
class TripartiteSynapse:
    """Synapse with bidirectional astrocyte coupling.

    Parameters
    ----------
    base_weight : float
        Baseline synaptic weight.
    glut_per_spike : float
        IP3 production rate per pre-synaptic spike (µM/s).
    ca_threshold : float
        Astrocyte Ca²⁺ threshold for gliotransmitter release (µM).
    facilitation : float
        Multiplicative gain when astrocyte is active (> 1 for facilitation).
    depression_rate : float
        Weight depression rate when astrocyte Ca²⁺ is below threshold.
    w_min, w_max : float
        Weight bounds.
    """

    base_weight: float = 0.5
    glut_per_spike: float = 2.0
    ca_threshold: float = 0.3
    facilitation: float = 1.5
    depression_rate: float = 0.001
    w_min: float = 0.0
    w_max: float = 1.0

    def __post_init__(self) -> None:
        if not math.isfinite(self.w_min) or not math.isfinite(self.w_max):
            raise ValueError("w_min and w_max must be finite")
        if self.w_min > self.w_max:
            raise ValueError("w_min must be <= w_max")
        if not math.isfinite(self.base_weight) or not (
            self.w_min <= self.base_weight <= self.w_max
        ):
            raise ValueError("base_weight must be finite and within [w_min, w_max]")
        if not math.isfinite(self.glut_per_spike) or self.glut_per_spike < 0.0:
            raise ValueError("glut_per_spike must be finite and non-negative")
        if not math.isfinite(self.ca_threshold) or self.ca_threshold < 0.0:
            raise ValueError("ca_threshold must be finite and non-negative")
        if not math.isfinite(self.facilitation) or self.facilitation < 0.0:
            raise ValueError("facilitation must be finite and non-negative")
        if not math.isfinite(self.depression_rate) or self.depression_rate < 0.0:
            raise ValueError("depression_rate must be finite and non-negative")

        self.weight = self.base_weight
        self.astrocyte = AstrocyteModel()
        self._glut_current = 0.0  # accumulated glutamate signal

    def step(self, pre_spike: bool, post_spike: bool, dt: float = 0.01) -> float:
        """Advance one timestep.

        Parameters
        ----------
        pre_spike : bool
            Pre-synaptic spike.
        post_spike : bool
            Post-synaptic spike (unused in basic model, reserved for Hebbian extension).
        dt : float
            Timestep in seconds.

        Returns
        -------
        float
            Effective synaptic weight (base_weight * astrocyte modulation).
        """
        if type(pre_spike) is not bool:
            raise TypeError("pre_spike must be a bool")
        if type(post_spike) is not bool:
            raise TypeError("post_spike must be a bool")
        if not math.isfinite(dt) or dt <= 0.0:
            raise ValueError("dt must be finite and positive")

        # Pre-synaptic activity → glutamate → IP3
        if pre_spike:
            self._glut_current += self.glut_per_spike
        # Glutamate decays (tau_glut ~ 0.2s)
        self._glut_current *= math.exp(-dt / 0.2)

        # Step the astrocyte with glutamate-driven IP3 production
        self.astrocyte.dt = dt
        ca = self.astrocyte.step(self._glut_current)

        # Astrocyte modulation of synaptic weight
        if ca > self.ca_threshold:
            # Gliotransmitter release → synaptic facilitation
            self.weight += self.facilitation * (ca - self.ca_threshold) * dt
        else:
            # Slow depression toward baseline without astrocyte support
            self.weight += (self.base_weight - self.weight) * self.depression_rate * dt

        self.weight = max(self.w_min, min(self.w_max, self.weight))
        return self.weight

    @property
    def ca(self) -> float:
        """Current astrocyte Ca²⁺ concentration (µM)."""
        return self.astrocyte.ca

    @property
    def ip3(self) -> float:
        """Current astrocyte IP3 concentration (µM)."""
        return self.astrocyte.ip3

    def effective_weight(self) -> float:
        """Current effective synaptic weight."""
        return self.weight

    def reset(self) -> None:
        self.weight = self.base_weight
        self.astrocyte.reset()
        self._glut_current = 0.0

ca property

Current astrocyte Ca²⁺ concentration (µM).

ip3 property

Current astrocyte IP3 concentration (µM).

step(pre_spike, post_spike, dt=0.01)

Advance one timestep.

Parameters

pre_spike : bool Pre-synaptic spike. post_spike : bool Post-synaptic spike (unused in basic model, reserved for Hebbian extension). dt : float Timestep in seconds.

Returns

float Effective synaptic weight (base_weight * astrocyte modulation).

Source code in src/sc_neurocore/synapses/tripartite.py
Python
 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
def step(self, pre_spike: bool, post_spike: bool, dt: float = 0.01) -> float:
    """Advance one timestep.

    Parameters
    ----------
    pre_spike : bool
        Pre-synaptic spike.
    post_spike : bool
        Post-synaptic spike (unused in basic model, reserved for Hebbian extension).
    dt : float
        Timestep in seconds.

    Returns
    -------
    float
        Effective synaptic weight (base_weight * astrocyte modulation).
    """
    if type(pre_spike) is not bool:
        raise TypeError("pre_spike must be a bool")
    if type(post_spike) is not bool:
        raise TypeError("post_spike must be a bool")
    if not math.isfinite(dt) or dt <= 0.0:
        raise ValueError("dt must be finite and positive")

    # Pre-synaptic activity → glutamate → IP3
    if pre_spike:
        self._glut_current += self.glut_per_spike
    # Glutamate decays (tau_glut ~ 0.2s)
    self._glut_current *= math.exp(-dt / 0.2)

    # Step the astrocyte with glutamate-driven IP3 production
    self.astrocyte.dt = dt
    ca = self.astrocyte.step(self._glut_current)

    # Astrocyte modulation of synaptic weight
    if ca > self.ca_threshold:
        # Gliotransmitter release → synaptic facilitation
        self.weight += self.facilitation * (ca - self.ca_threshold) * dt
    else:
        # Slow depression toward baseline without astrocyte support
        self.weight += (self.base_weight - self.weight) * self.depression_rate * dt

    self.weight = max(self.w_min, min(self.w_max, self.weight))
    return self.weight

effective_weight()

Current effective synaptic weight.

Source code in src/sc_neurocore/synapses/tripartite.py
Python
141
142
143
def effective_weight(self) -> float:
    """Current effective synaptic weight."""
    return self.weight

Gap Junction (Electrical Synapse)

sc_neurocore.synapses.gap_junction.GapJunction dataclass

Bidirectional electrical synapse.

Parameters

conductance : float Gap junction conductance g_c (nS). Typical: 0.01-1.0 nS. Bennett & Zukin, Neuron 2004. rectification : float Rectification factor in [0, 1]. 0 = fully bidirectional (ohmic), 1 = fully rectifying (current flows in one direction only). Default 0 (standard gap junction).

Source code in src/sc_neurocore/synapses/gap_junction.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
 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
@dataclass
class GapJunction:
    """Bidirectional electrical synapse.

    Parameters
    ----------
    conductance : float
        Gap junction conductance g_c (nS). Typical: 0.01-1.0 nS.
        Bennett & Zukin, Neuron 2004.
    rectification : float
        Rectification factor in [0, 1]. 0 = fully bidirectional (ohmic),
        1 = fully rectifying (current flows in one direction only).
        Default 0 (standard gap junction).
    """

    conductance: float = 0.1
    rectification: float = 0.0

    def __post_init__(self) -> None:
        """Validate the conductance and rectification parameters."""
        if not math.isfinite(self.conductance) or self.conductance < 0.0:
            raise ValueError("conductance must be finite and non-negative")
        if not math.isfinite(self.rectification) or not 0.0 <= self.rectification <= 1.0:
            raise ValueError("rectification must be finite and within [0, 1]")

    def current(self, v_pre: float, v_post: float) -> float:
        """Compute gap junction current flowing INTO v_post.

        I_gap = g_c * (V_pre - V_post) * rectification_factor

        Positive current depolarizes post. The same junction produces
        equal and opposite current for the pre-synaptic neuron.
        """
        if not math.isfinite(v_pre) or not math.isfinite(v_post):
            raise ValueError("voltage inputs must be finite")

        dv = v_pre - v_post
        if self.rectification > 0:
            # Rectification: reduce current in one direction
            factor = 1.0 - self.rectification * (1.0 if dv < 0 else 0.0)
            return self.conductance * dv * factor
        return self.conductance * dv

    def current_matrix(
        self, voltages: np.ndarray[Any, Any], adjacency: np.ndarray[Any, Any]
    ) -> np.ndarray[Any, Any]:
        """Compute gap junction currents for a population.

        Parameters
        ----------
        voltages : np.ndarray, shape (N,)
            Membrane voltages of all neurons.
        adjacency : np.ndarray, shape (N, N)
            Binary or weighted adjacency matrix. A[i,j] = 1 means
            neurons i and j are connected by a gap junction.

        Returns
        -------
        np.ndarray, shape (N,)
            Net gap junction current for each neuron.
        """
        self._validate_current_matrix_inputs(voltages, adjacency)

        N = len(voltages)
        dv_matrix = voltages[np.newaxis, :] - voltages[:, np.newaxis]  # dv[i,j] = V[j] - V[i]
        currents = self.conductance * dv_matrix * adjacency
        net_current: np.ndarray[Any, Any] = currents.sum(axis=1)
        return net_current

    @staticmethod
    def _validate_current_matrix_inputs(
        voltages: np.ndarray[Any, Any], adjacency: np.ndarray[Any, Any]
    ) -> None:
        if not isinstance(voltages, np.ndarray) or voltages.ndim != 1:
            raise ValueError("current_matrix voltages must be a one-dimensional array")
        if not np.all(np.isfinite(voltages)):
            raise ValueError("current_matrix voltages must be finite")
        if not isinstance(adjacency, np.ndarray) or adjacency.ndim != 2:
            raise ValueError("current_matrix adjacency must be a two-dimensional array")
        if adjacency.shape != (voltages.shape[0], voltages.shape[0]):
            raise ValueError("current_matrix adjacency must be square with one row per voltage")
        if not np.all(np.isfinite(adjacency)):
            raise ValueError("current_matrix adjacency must be finite")
        if np.any(adjacency < 0.0):
            raise ValueError("current_matrix adjacency must be non-negative")
        if not np.allclose(adjacency, adjacency.T):
            raise ValueError("current_matrix adjacency must be symmetric for reciprocal coupling")

__post_init__()

Validate the conductance and rectification parameters.

Source code in src/sc_neurocore/synapses/gap_junction.py
Python
52
53
54
55
56
57
def __post_init__(self) -> None:
    """Validate the conductance and rectification parameters."""
    if not math.isfinite(self.conductance) or self.conductance < 0.0:
        raise ValueError("conductance must be finite and non-negative")
    if not math.isfinite(self.rectification) or not 0.0 <= self.rectification <= 1.0:
        raise ValueError("rectification must be finite and within [0, 1]")

current(v_pre, v_post)

Compute gap junction current flowing INTO v_post.

I_gap = g_c * (V_pre - V_post) * rectification_factor

Positive current depolarizes post. The same junction produces equal and opposite current for the pre-synaptic neuron.

Source code in src/sc_neurocore/synapses/gap_junction.py
Python
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def current(self, v_pre: float, v_post: float) -> float:
    """Compute gap junction current flowing INTO v_post.

    I_gap = g_c * (V_pre - V_post) * rectification_factor

    Positive current depolarizes post. The same junction produces
    equal and opposite current for the pre-synaptic neuron.
    """
    if not math.isfinite(v_pre) or not math.isfinite(v_post):
        raise ValueError("voltage inputs must be finite")

    dv = v_pre - v_post
    if self.rectification > 0:
        # Rectification: reduce current in one direction
        factor = 1.0 - self.rectification * (1.0 if dv < 0 else 0.0)
        return self.conductance * dv * factor
    return self.conductance * dv

current_matrix(voltages, adjacency)

Compute gap junction currents for a population.

Parameters

voltages : np.ndarray, shape (N,) Membrane voltages of all neurons. adjacency : np.ndarray, shape (N, N) Binary or weighted adjacency matrix. A[i,j] = 1 means neurons i and j are connected by a gap junction.

Returns

np.ndarray, shape (N,) Net gap junction current for each neuron.

Source code in src/sc_neurocore/synapses/gap_junction.py
Python
 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
def current_matrix(
    self, voltages: np.ndarray[Any, Any], adjacency: np.ndarray[Any, Any]
) -> np.ndarray[Any, Any]:
    """Compute gap junction currents for a population.

    Parameters
    ----------
    voltages : np.ndarray, shape (N,)
        Membrane voltages of all neurons.
    adjacency : np.ndarray, shape (N, N)
        Binary or weighted adjacency matrix. A[i,j] = 1 means
        neurons i and j are connected by a gap junction.

    Returns
    -------
    np.ndarray, shape (N,)
        Net gap junction current for each neuron.
    """
    self._validate_current_matrix_inputs(voltages, adjacency)

    N = len(voltages)
    dv_matrix = voltages[np.newaxis, :] - voltages[:, np.newaxis]  # dv[i,j] = V[j] - V[i]
    currents = self.conductance * dv_matrix * adjacency
    net_current: np.ndarray[Any, Any] = currents.sum(axis=1)
    return net_current