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
 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
@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:
        """Validate input/weight lengths and derive the input count."""
        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)
            bipolar_current: np.ndarray[Any, Any] = (
                self.y_min + ((clipped + 1.0) / 2.0) * (self.y_max - self.y_min)
            ).astype(np.float64, copy=False)
            return bipolar_current

        probs = self.post_matrix.mean(axis=0, dtype=np.float64)
        unipolar_current: np.ndarray[Any, Any] = (
            self.y_min + probs * (self.y_max - self.y_min)
        ).astype(np.float64, copy=False)
        return unipolar_current

    def reset(self) -> None:
        """Reset the realised current trace cursor to its first timestep."""
        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)

__post_init__()

Validate input/weight lengths and derive the input count.

Source code in src/sc_neurocore/sources/bitstream_current_source.py
Python
 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
def __post_init__(self) -> None:
    """Validate input/weight lengths and derive the input count."""
    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

reset()

Reset the realised current trace cursor to its first timestep.

Source code in src/sc_neurocore/sources/bitstream_current_source.py
Python
154
155
156
def reset(self) -> None:
    """Reset the realised current trace cursor to its first timestep."""
    self._t = 0

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
158
159
160
161
162
163
164
165
166
167
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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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
185
186
187
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

Simulated quantum-measurement entropy source for stochastic inputs.

This module maintains a small classical state-vector simulation, applies Hadamard-style mixing before measurement, and converts seeded measurement outcomes into deterministic pseudo-random samples. It does not claim access to physical quantum hardware or certified quantum random numbers.

QuantumEntropySource dataclass

Simulated quantum-measurement entropy source.

Injects simulated quantum indeterminacy into neural models by maintaining a qubit state |psi>, applying Hadamard superposition and phase rotations, and measuring (collapsing) the state to generate noise.

Source code in src/sc_neurocore/sources/quantum_entropy.py
Python
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 QuantumEntropySource:
    """Simulated quantum-measurement entropy source.

    Injects simulated quantum indeterminacy into neural models by maintaining
    a qubit state ``|psi>``, applying Hadamard superposition and phase
    rotations, and measuring (collapsing) the state to generate noise.
    """

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

    def __post_init__(self) -> None:
        """Initialise the RNG and reset the qubit register to ``|0>``."""
        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 one default normal sample from the simulated measurement source."""
        return self.sample_normal()

__post_init__()

Initialise the RNG and reset the qubit register to |0>.

Source code in src/sc_neurocore/sources/quantum_entropy.py
Python
34
35
36
37
38
39
def __post_init__(self) -> None:
    """Initialise the RNG and reset the qubit register to ``|0>``."""
    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

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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)

sample()

Return one default normal sample from the simulated measurement source.

Source code in src/sc_neurocore/sources/quantum_entropy.py
Python
85
86
87
def sample(self) -> float:
    """Return one default normal sample from the simulated measurement source."""
    return self.sample_normal()