Skip to content

Sources

Input current sources that drive neurons via SC-encoded bitstreams.

Bitstream Current Source

Converts multiple scalar inputs through weighted SC synapses into a realised per-cycle current trace. step() consumes that trace one cycle at a time, and full_current_estimate() returns the mean of the same realised trace.

sc_neurocore.sources.bitstream_current_source.BitstreamCurrentSource dataclass

Multi-channel bitstream current source.

  • Takes scalar inputs x_i in [x_min, x_max]
  • Encodes each into a bitstream via BitstreamEncoder
  • Passes them through BitstreamSynapses
  • Decodes the realised post-synaptic bitstreams into a per-cycle current trace for neuron simulation.

Static inputs and weights are encoded once at construction time. The stochastic realisation is then fixed until a new source is constructed with different parameters or seeds.

Source code in src/sc_neurocore/sources/bitstream_current_source.py
Python
 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
@dataclass
class BitstreamCurrentSource:
    """
    Multi-channel bitstream current source.

    - Takes scalar inputs x_i in [x_min, x_max]
    - Encodes each into a bitstream via BitstreamEncoder
    - Passes them through BitstreamSynapses
    - Decodes the realised post-synaptic bitstreams into a per-cycle
      current trace for neuron simulation.

    Static inputs and weights are encoded once at construction time.
    The stochastic realisation is then fixed until a new source is
    constructed with different parameters or seeds.
    """

    x_inputs: Sequence[float]
    x_min: float
    x_max: float
    weight_values: Sequence[float]
    w_min: float
    w_max: float
    length: int = 1024
    y_min: float = 0.0  # output current min
    y_max: float = 0.1  # output current max
    seed: Optional[int] = None
    sc_mode: str = "unipolar"

    def __post_init__(self) -> None:
        self.n_inputs = len(self.x_inputs)
        if self.n_inputs < 1:
            raise ValueError("BitstreamCurrentSource requires at least one input.")
        if len(self.weight_values) != self.n_inputs:
            raise ValueError("x_inputs and weight_values must have same length.")
        if self.sc_mode not in {"unipolar", "bipolar"}:
            raise ValueError("sc_mode must be 'unipolar' or 'bipolar'.")

        # Encoders for input channels
        self._encoders: List[BitstreamEncoder] = []
        for i in range(self.n_inputs):
            self._encoders.append(
                BitstreamEncoder(
                    x_min=self.x_min,
                    x_max=self.x_max,
                    length=self.length,
                    seed=None if self.seed is None else self.seed + i,
                    mode="bipolar" if self.sc_mode == "bipolar" else "bernoulli",
                )
            )

        # Generate pre-synaptic bitstreams
        self.pre_matrix = np.zeros((self.n_inputs, self.length), dtype=np.uint8)
        for i, (enc, x) in enumerate(zip(self._encoders, self.x_inputs)):
            self.pre_matrix[i] = enc.encode(x)

        self.synapses: List[BitstreamSynapse] = []
        self.dot: BitstreamDotProduct | None = None
        if self.sc_mode == "bipolar":
            self.dot = None
            self.post_matrix, _ = self._apply_bipolar_xnor()
        else:
            # Build synapses
            for i, w in enumerate(self.weight_values):
                self.synapses.append(
                    BitstreamSynapse(
                        w_min=self.w_min,
                        w_max=self.w_max,
                        length=self.length,
                        w=w,
                        seed=None if self.seed is None else self.seed + 1000 + i,
                    )
                )

            # Dot-product engine
            self.dot = BitstreamDotProduct(self.synapses)

            # Post-synaptic streams; the scalar current is decoded below from
            # the same realised trace used by step().
            self.post_matrix, _ = self.dot.apply(
                self.pre_matrix, y_min=self.y_min, y_max=self.y_max
            )

        self._current_trace = self._decode_current_trace()
        self.current_scalar = float(self._current_trace.mean())

        # We'll treat each timestep as one index in the bitstreams
        self._t = 0

    def _apply_bipolar_xnor(self) -> tuple[np.ndarray[Any, Any], float]:
        weight_matrix = np.zeros((self.n_inputs, self.length), dtype=np.uint8)
        for i, w in enumerate(self.weight_values):
            weight_encoder = BitstreamEncoder(
                x_min=self.w_min,
                x_max=self.w_max,
                length=self.length,
                seed=None if self.seed is None else self.seed + 1000 + i,
                mode="bipolar",
            )
            weight_matrix[i] = weight_encoder.encode(w)

        post_matrix = (self.pre_matrix == weight_matrix).astype(np.uint8)
        products = 2.0 * post_matrix.mean(axis=1) - 1.0
        bipolar_mean = float(products.mean()) if products.size else 0.0
        current = self._map_bipolar_to_current(bipolar_mean)
        return post_matrix, current

    def _map_bipolar_to_current(self, value: float) -> float:
        clipped = max(min(value, 1.0), -1.0)
        return float(self.y_min + ((clipped + 1.0) / 2.0) * (self.y_max - self.y_min))

    def _decode_current_trace(self) -> np.ndarray[Any, Any]:
        if self.sc_mode == "bipolar":
            probs = self.post_matrix.mean(axis=0, dtype=np.float64)
            bipolar_values = (2.0 * probs) - 1.0
            clipped = np.clip(bipolar_values, -1.0, 1.0)
            return (self.y_min + ((clipped + 1.0) / 2.0) * (self.y_max - self.y_min)).astype(
                np.float64, copy=False
            )

        probs = self.post_matrix.mean(axis=0, dtype=np.float64)
        return (self.y_min + probs * (self.y_max - self.y_min)).astype(np.float64, copy=False)

    def reset(self) -> None:
        self._t = 0

    def current_trace(self) -> np.ndarray[Any, Any]:
        """
        Return the realised per-cycle decoded current trace.

        The returned trace is computed from the fixed post-synaptic
        bitstreams generated during construction. It is therefore the
        exact sequence consumed by repeated ``step()`` calls, not a
        separate probability-space approximation.
        """
        return self._current_trace.copy()

    def step(self) -> float:
        """
        Return the current I_t at the current time index and advance.

        ``step()`` returns the t-th element of ``current_trace()`` and
        clamps at the final element after the bitstream is exhausted.
        """
        idx = self._t
        if idx >= self.length:
            # Clamp at last timestep (or you can wrap)
            idx = self.length - 1

        I_t = self._current_trace[idx]
        self._t += 1
        return float(I_t)

    def full_current_estimate(self) -> float:
        """
        Return the mean current over the realised bitstream duration.
        """
        return float(self.current_scalar)

current_trace()

Return the realised per-cycle decoded current trace.

The returned trace is computed from the fixed post-synaptic bitstreams generated during construction. It is therefore the exact sequence consumed by repeated step() calls, not a separate probability-space approximation.

Source code in src/sc_neurocore/sources/bitstream_current_source.py
Python
145
146
147
148
149
150
151
152
153
154
def current_trace(self) -> np.ndarray[Any, Any]:
    """
    Return the realised per-cycle decoded current trace.

    The returned trace is computed from the fixed post-synaptic
    bitstreams generated during construction. It is therefore the
    exact sequence consumed by repeated ``step()`` calls, not a
    separate probability-space approximation.
    """
    return self._current_trace.copy()

step()

Return the current I_t at the current time index and advance.

step() returns the t-th element of current_trace() and clamps at the final element after the bitstream is exhausted.

Source code in src/sc_neurocore/sources/bitstream_current_source.py
Python
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def step(self) -> float:
    """
    Return the current I_t at the current time index and advance.

    ``step()`` returns the t-th element of ``current_trace()`` and
    clamps at the final element after the bitstream is exhausted.
    """
    idx = self._t
    if idx >= self.length:
        # Clamp at last timestep (or you can wrap)
        idx = self.length - 1

    I_t = self._current_trace[idx]
    self._t += 1
    return float(I_t)

full_current_estimate()

Return the mean current over the realised bitstream duration.

Source code in src/sc_neurocore/sources/bitstream_current_source.py
Python
172
173
174
175
176
def full_current_estimate(self) -> float:
    """
    Return the mean current over the realised bitstream duration.
    """
    return float(self.current_scalar)

Quantum Entropy Source

Optional integration with quantum random number generators (Qiskit, PennyLane) for true randomness in SC encoding.

sc_neurocore.sources.quantum_entropy

QuantumEntropySource dataclass

Generates entropy based on simulated Quantum Measurement Collapse. Used to inject 'True' (Simulated) Quantum Indeterminacy into Neural Models.

Physics: - Maintains a Qubit State |psi> - Applies Hadamard (Superposition) and Phase Rotations - Measures (Collapse) to generate noise

Source code in src/sc_neurocore/sources/quantum_entropy.py
Python
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
@dataclass
class QuantumEntropySource:
    """
    Generates entropy based on simulated Quantum Measurement Collapse.
    Used to inject 'True' (Simulated) Quantum Indeterminacy into Neural Models.

    Physics:
    - Maintains a Qubit State |psi>
    - Applies Hadamard (Superposition) and Phase Rotations
    - Measures (Collapse) to generate noise
    """

    n_qubits: int = 1
    seed: Optional[int] = None

    def __post_init__(self) -> None:
        self._rng = np.random.RandomState(self.seed)
        # Initialize |0> state
        self.state = np.zeros(2**self.n_qubits, dtype=np.complex128)
        self.state[0] = 1.0

    def _hadamard(self) -> None:
        """Apply Hadamard gate H = (1/√2)[[1,1],[1,-1]] to each qubit."""
        H = np.array([[1, 1], [1, -1]], dtype=np.complex128) / np.sqrt(2)
        result = self.state.copy()
        n = self.n_qubits
        dim = 2**n
        for q in range(n):
            new_result = np.zeros(dim, dtype=np.complex128)
            block = 2 ** (n - q)
            half = block // 2
            for start in range(0, dim, block):
                for i in range(half):
                    a = result[start + i]
                    b = result[start + half + i]
                    new_result[start + i] = H[0, 0] * a + H[0, 1] * b
                    new_result[start + half + i] = H[1, 0] * a + H[1, 1] * b
            result = new_result
        self.state = result

    def _measure(self) -> int:
        """Apply Hadamard, measure via Born rule, collapse state."""
        self._hadamard()
        probs = np.abs(self.state) ** 2
        idx = self._rng.choice(len(probs), p=probs)
        # Wavefunction collapse to measured basis state
        self.state = np.zeros_like(self.state)
        self.state[idx] = 1.0
        return int(idx)

    def sample_normal(self, mean: float = 0.0, std: float = 1.0) -> float:
        """
        Two independent measurements → Box-Muller → Gaussian sample.

        Discrete outcomes dithered with uniform jitter for continuous input.
        """
        N = len(self.state)

        u1 = (self._measure() + self._rng.uniform()) / N
        u1 = np.clip(u1, 1e-10, 1.0 - 1e-10)
        u2 = (self._measure() + self._rng.uniform()) / N

        z = np.sqrt(-2.0 * np.log(u1)) * np.cos(2.0 * np.pi * u2)
        return float(mean + z * std)

    def sample(self) -> float:
        return self.sample_normal()

sample_normal(mean=0.0, std=1.0)

Two independent measurements → Box-Muller → Gaussian sample.

Discrete outcomes dithered with uniform jitter for continuous input.

Source code in src/sc_neurocore/sources/quantum_entropy.py
Python
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def sample_normal(self, mean: float = 0.0, std: float = 1.0) -> float:
    """
    Two independent measurements → Box-Muller → Gaussian sample.

    Discrete outcomes dithered with uniform jitter for continuous input.
    """
    N = len(self.state)

    u1 = (self._measure() + self._rng.uniform()) / N
    u1 = np.clip(u1, 1e-10, 1.0 - 1e-10)
    u2 = (self._measure() + self._rng.uniform()) / N

    z = np.sqrt(-2.0 * np.log(u1)) * np.cos(2.0 * np.pi * u2)
    return float(mean + z * std)