necksense 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
necksense/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """NeckSense: Drosophila Antennal Lobe + Mushroom Body edge sensory intelligence.
2
+
3
+ Product #2 under the NeckLink fruit-fly brain suite:
4
+ - :class:`AntennalNormalizer` (dual-timescale drift adaptation + Olsen-Wilson LN gain)
5
+ - :class:`KenyonHasher` & :class:`KenyonIndex` (zero-multiply sparse FlyHash + Popcount search)
6
+ - :class:`MushroomNoveltyDetector` (1-shot MBON-alpha'3 anti-Hebbian anomaly detector)
7
+ - :class:`MushroomValenceClassifier` (1-shot / few-shot DAN->KC->MBON classifier)
8
+ """
9
+
10
+ from .antennal import AntennalNormalizer, Q16_ONE, float_to_q16, q16_to_float
11
+ from .kenyon import (
12
+ KenyonHasher,
13
+ KenyonIndex,
14
+ KenyonMatch,
15
+ build_projection_indices,
16
+ popcount_overlap,
17
+ popcount_u64,
18
+ )
19
+ from .mushroom import MushroomNoveltyDetector, MushroomValenceClassifier
20
+
21
+ __version__ = "1.1.0"
22
+
23
+ __all__ = [
24
+ "__version__",
25
+ "Q16_ONE",
26
+ "float_to_q16",
27
+ "q16_to_float",
28
+ "AntennalNormalizer",
29
+ "KenyonHasher",
30
+ "KenyonIndex",
31
+ "KenyonMatch",
32
+ "build_projection_indices",
33
+ "popcount_overlap",
34
+ "popcount_u64",
35
+ "MushroomNoveltyDetector",
36
+ "MushroomValenceClassifier",
37
+ ]
necksense/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Module entry point for `python -m necksense`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
necksense/antennal.py ADDED
@@ -0,0 +1,336 @@
1
+ """Antennal Lobe sensory conditioning and divisive lateral inhibition.
2
+
3
+ Implements dual-timescale olfactory receptor neuron (ORN) baseline adaptation
4
+ followed by Olsen-Wilson GABAergic local interneuron (LN) divisive gain
5
+ normalization:
6
+
7
+ r_i = max(0, u_i)^n / (sigma^n + (gamma * mean_pool)^n + max(0, u_i)^n)
8
+
9
+ References:
10
+ - Olsen, S. R. & Wilson, R. I. (2008). Lateral presynaptic inhibition
11
+ mediates gain control in an olfactory circuit. Nature, 452, 956-960.
12
+ - Nagel, K. I. & Wilson, R. I. (2011). Biophysical mechanisms underlying
13
+ olfactory receptor neuron dynamics. Nature Neuroscience, 14, 208-216.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ Q16_ONE: int = 1 << 16
21
+ _INT32_MIN: int = -(1 << 31)
22
+ _INT32_MAX: int = (1 << 31) - 1
23
+ _UINT64_SHIFT_GUARD: int = 1 << 47
24
+
25
+
26
+ def float_to_q16(values: np.ndarray | float) -> np.ndarray:
27
+ """Convert floating-point values into clamped Q16.16 signed int32 integers."""
28
+ arr = np.asarray(values, dtype=np.float64)
29
+ scaled = np.rint(arr * float(Q16_ONE))
30
+ clamped = np.clip(scaled, _INT32_MIN, _INT32_MAX)
31
+ return clamped.astype(np.int32)
32
+
33
+
34
+ def q16_to_float(values: np.ndarray) -> np.ndarray:
35
+ """Convert Q16.16 integer values back into float64."""
36
+ return np.asarray(values, dtype=np.float64) / float(Q16_ONE)
37
+
38
+
39
+ class AntennalNormalizer:
40
+ """Dual-timescale receptor drift removal + Olsen-Wilson LN normalization.
41
+
42
+ Subtracts slow tonic baseline drift (thermal, humidity, or sensor aging)
43
+ while preserving fast phasic plume/event transients, then applies lateral
44
+ GABAergic divisive normalization so combinatorial channel patterns remain
45
+ invariant across 100x+ concentration swings without amplifying zero-input
46
+ sensor noise.
47
+
48
+ Parameters
49
+ ----------
50
+ n_channels : int
51
+ Number of sensory input channels (`D >= 1`).
52
+ sigma : float, default=0.01
53
+ Semisaturation constant (`sigma > 0`) that suppresses low-level
54
+ electronic noise when no plume/event is present.
55
+ gamma : float, default=1.0
56
+ Lateral `LN` divisive inhibition strength multiplying `mean_pool`.
57
+ hill_n : float, default=2.0
58
+ Hill exponent `n` (`n > 0`). In `c_parity=True` mode, `hill_n` must be
59
+ `1` or `2`.
60
+ tonic_alpha : float, default=0.01
61
+ Slow tonic baseline drift adaptation rate in `[0, 1]`.
62
+ tonic_down_alpha : float | None, default=None
63
+ Baseline recovery rate when signal drops below tonic estimate. Defaults
64
+ to `min(1.0, tonic_alpha * 5.0)` when `tonic_alpha > 0`, else `0.0`.
65
+ tonic_slew_limit : float | None, default=0.02
66
+ Maximum upward baseline increment per step so high-concentration plumes
67
+ do not pull up the baseline and blind subsequent weak plumes. Set `None`
68
+ to disable slew clamping.
69
+ phasic_gain : float, default=0.5
70
+ Gain applied to fast phasic transients (`x - fast_state`).
71
+ fast_alpha : float, default=0.35
72
+ Adaptation rate of the fast receptor state in `[0, 1]`.
73
+ c_parity : bool, default=False
74
+ If `True`, executes Q16.16 fixed-point integer arithmetic matching
75
+ `csrc/necksense.c` (`ns_antennal_step` / `ns_antennal_normalize`).
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ n_channels: int,
81
+ *,
82
+ sigma: float = 0.01,
83
+ gamma: float = 1.0,
84
+ hill_n: float = 2.0,
85
+ tonic_alpha: float = 0.01,
86
+ tonic_down_alpha: float | None = None,
87
+ tonic_slew_limit: float | None = 0.02,
88
+ phasic_gain: float = 0.5,
89
+ fast_alpha: float = 0.35,
90
+ c_parity: bool = False,
91
+ ) -> None:
92
+ if n_channels < 1:
93
+ raise ValueError("n_channels must be >= 1")
94
+ if sigma <= 0.0:
95
+ raise ValueError("sigma must be > 0")
96
+ if gamma < 0.0:
97
+ raise ValueError("gamma must be >= 0")
98
+ if hill_n <= 0.0:
99
+ raise ValueError("hill_n must be > 0")
100
+ if not (0.0 <= tonic_alpha <= 1.0):
101
+ raise ValueError("tonic_alpha must be in [0, 1]")
102
+ if phasic_gain < 0.0:
103
+ raise ValueError("phasic_gain must be >= 0")
104
+ if not (0.0 <= fast_alpha <= 1.0):
105
+ raise ValueError("fast_alpha must be in [0, 1]")
106
+ if c_parity and int(hill_n) not in (1, 2):
107
+ raise ValueError("c_parity=True supports integer hill_n in {1, 2}")
108
+
109
+ self.n_channels = int(n_channels)
110
+ self.sigma = float(sigma)
111
+ self.gamma = float(gamma)
112
+ self.hill_n = float(int(hill_n) if c_parity else hill_n)
113
+ self.tonic_alpha = float(tonic_alpha)
114
+ if tonic_down_alpha is None:
115
+ self.tonic_down_alpha = min(1.0, self.tonic_alpha * 5.0) if self.tonic_alpha > 0.0 else 0.0
116
+ else:
117
+ if not (0.0 <= tonic_down_alpha <= 1.0):
118
+ raise ValueError("tonic_down_alpha must be in [0, 1]")
119
+ self.tonic_down_alpha = float(tonic_down_alpha)
120
+ if tonic_slew_limit is not None and tonic_slew_limit < 0.0:
121
+ raise ValueError("tonic_slew_limit must be >= 0 or None")
122
+ self.tonic_slew_limit = None if tonic_slew_limit is None else float(tonic_slew_limit)
123
+ self.phasic_gain = float(phasic_gain)
124
+ self.fast_alpha = float(fast_alpha)
125
+ self.c_parity = bool(c_parity)
126
+
127
+ # Q16.16 fixed-point parameters for exact C99 parity
128
+ self._sigma_q16 = max(1, int(round(self.sigma * Q16_ONE)))
129
+ self._gamma_q16 = int(round(self.gamma * Q16_ONE))
130
+ self._tonic_alpha_q16 = int(round(self.tonic_alpha * Q16_ONE))
131
+ self._tonic_down_q16 = int(round(self.tonic_down_alpha * Q16_ONE))
132
+ self._tonic_slew_q16 = (
133
+ 0 if self.tonic_slew_limit is None else max(1, int(round(self.tonic_slew_limit * Q16_ONE)))
134
+ )
135
+ self._phasic_gain_q16 = int(round(self.phasic_gain * Q16_ONE))
136
+ self._fast_alpha_q16 = int(round(self.fast_alpha * Q16_ONE))
137
+
138
+ self.tonic_baseline = np.zeros(self.n_channels, dtype=np.float64)
139
+ self.fast_state = np.zeros(self.n_channels, dtype=np.float64)
140
+ self._tonic_q16 = np.zeros(self.n_channels, dtype=np.int32)
141
+ self._fast_q16 = np.zeros(self.n_channels, dtype=np.int32)
142
+
143
+ def reset(self, baseline: np.ndarray | float | None = None) -> None:
144
+ """Reset tonic and fast receptor states to zero or an explicit baseline."""
145
+ if baseline is None:
146
+ self.tonic_baseline.fill(0.0)
147
+ self.fast_state.fill(0.0)
148
+ self._tonic_q16.fill(0)
149
+ self._fast_q16.fill(0)
150
+ return
151
+
152
+ b = np.broadcast_to(np.asarray(baseline, dtype=np.float64), (self.n_channels,)).copy()
153
+ self.tonic_baseline[:] = b
154
+ self.fast_state[:] = b
155
+ b_q16 = float_to_q16(b)
156
+ self._tonic_q16[:] = b_q16
157
+ self._fast_q16[:] = b_q16
158
+
159
+ def calibrate(self, ambient_samples: np.ndarray) -> np.ndarray:
160
+ """Lock tonic and fast receptor baselines to clean-air / ambient data."""
161
+ arr = np.asarray(ambient_samples, dtype=np.float64)
162
+ if arr.ndim == 1:
163
+ if arr.shape[0] != self.n_channels:
164
+ raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[0]}")
165
+ mean_vec = arr.copy()
166
+ elif arr.ndim == 2:
167
+ if arr.shape[1] != self.n_channels:
168
+ raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[1]}")
169
+ mean_vec = arr.mean(axis=0)
170
+ else:
171
+ raise ValueError("ambient_samples must be 1D (D,) or 2D (T, D)")
172
+ self.reset(mean_vec)
173
+ return mean_vec
174
+
175
+ def _ln_normalize_q16_vec(self, pos_q16: np.ndarray) -> np.ndarray:
176
+ """Exact Q16.16 integer Olsen-Wilson LN normalization for a 1D vector."""
177
+ d = self.n_channels
178
+ p_u64 = pos_q16.astype(np.uint64)
179
+ mean_pool = int(np.sum(p_u64, dtype=np.uint64)) // d
180
+ lat_q16 = (mean_pool * self._gamma_q16) >> 16
181
+ out = np.zeros(d, dtype=np.int32)
182
+ hill_n_int = int(self.hill_n)
183
+
184
+ if hill_n_int == 1:
185
+ base_den = self._sigma_q16 + lat_q16
186
+ for i in range(d):
187
+ num = int(p_u64[i])
188
+ den = base_den + num
189
+ out[i] = 0 if den == 0 else int((num << 16) // den)
190
+ else:
191
+ sig2 = self._sigma_q16 * self._sigma_q16
192
+ lat2 = lat_q16 * lat_q16
193
+ base_den = sig2 + lat2
194
+ for i in range(d):
195
+ p_val = int(p_u64[i])
196
+ num = p_val * p_val
197
+ den = base_den + num
198
+ if den == 0:
199
+ out[i] = 0
200
+ else:
201
+ n_s = num
202
+ d_s = den
203
+ while n_s > _UINT64_SHIFT_GUARD:
204
+ n_s >>= 2
205
+ d_s >>= 2
206
+ out[i] = Q16_ONE if d_s == 0 else int((n_s << 16) // d_s)
207
+ return out
208
+
209
+ def normalize_q16(self, x_q16: np.ndarray) -> np.ndarray:
210
+ """Stateless Q16.16 Olsen-Wilson LN normalization returning int32 Q16.16."""
211
+ arr = np.asarray(x_q16, dtype=np.int32)
212
+ if arr.ndim == 1:
213
+ if arr.shape[0] != self.n_channels:
214
+ raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[0]}")
215
+ pos = np.maximum(arr, 0)
216
+ return self._ln_normalize_q16_vec(pos)
217
+ if arr.ndim == 2:
218
+ if arr.shape[1] != self.n_channels:
219
+ raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[1]}")
220
+ out = np.empty_like(arr, dtype=np.int32)
221
+ for t in range(arr.shape[0]):
222
+ out[t] = self._ln_normalize_q16_vec(np.maximum(arr[t], 0))
223
+ return out
224
+ raise ValueError("x_q16 must be 1D (D,) or 2D (N, D)")
225
+
226
+ def normalize(self, x: np.ndarray) -> np.ndarray:
227
+ """Stateless Olsen-Wilson LN divisive gain normalization (no state update).
228
+
229
+ Accepts a 1D vector `(D,)` or 2D batch `(N, D)` and returns normalized
230
+ responses in `[0, 1)`.
231
+ """
232
+ arr = np.asarray(x, dtype=np.float64)
233
+ if arr.ndim not in (1, 2):
234
+ raise ValueError("x must be 1D (D,) or 2D (N, D)")
235
+ if arr.shape[-1] != self.n_channels:
236
+ raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[-1]}")
237
+
238
+ if self.c_parity:
239
+ q_in = float_to_q16(arr)
240
+ q_out = self.normalize_q16(q_in)
241
+ return q16_to_float(q_out)
242
+
243
+ pos = np.maximum(arr, 0.0)
244
+ mean_pool = np.mean(pos, axis=-1, keepdims=True)
245
+ if self.hill_n == 2.0:
246
+ num = pos * pos
247
+ lat = self.gamma * mean_pool
248
+ den = (self.sigma * self.sigma) + (lat * lat) + num
249
+ elif self.hill_n == 1.0:
250
+ num = pos
251
+ den = self.sigma + (self.gamma * mean_pool) + num
252
+ else:
253
+ num = np.power(pos, self.hill_n)
254
+ den = (self.sigma ** self.hill_n) + np.power(self.gamma * mean_pool, self.hill_n) + num
255
+ return num / den
256
+
257
+ def step_q16(self, x_q16: np.ndarray) -> np.ndarray:
258
+ """Single-step stateful Q16.16 receptor adaptation + LN normalization."""
259
+ arr = np.asarray(x_q16, dtype=np.int32)
260
+ if arr.ndim != 1 or arr.shape[0] != self.n_channels:
261
+ raise ValueError(f"Expected 1D vector of length {self.n_channels}")
262
+
263
+ pos = np.zeros(self.n_channels, dtype=np.int32)
264
+ for i in range(self.n_channels):
265
+ xv = int(arr[i])
266
+ bv = int(self._tonic_q16[i])
267
+ fv = int(self._fast_q16[i])
268
+
269
+ diff_tonic = xv - bv
270
+ diff_fast = xv - fv
271
+ phasic_term = (diff_fast * self._phasic_gain_q16) >> 16
272
+ drive = diff_tonic + phasic_term
273
+ if drive > 0:
274
+ pos[i] = _INT32_MAX if drive > _INT32_MAX else int(drive)
275
+
276
+ fv_next = fv + ((diff_fast * self._fast_alpha_q16) >> 16)
277
+ self._fast_q16[i] = int(np.clip(fv_next, _INT32_MIN, _INT32_MAX))
278
+
279
+ if diff_tonic >= 0:
280
+ step_b = (diff_tonic * self._tonic_alpha_q16) >> 16
281
+ if self._tonic_slew_q16 > 0 and step_b > self._tonic_slew_q16:
282
+ step_b = self._tonic_slew_q16
283
+ bv_next = bv + step_b
284
+ else:
285
+ step_b = (diff_tonic * self._tonic_down_q16) >> 16
286
+ bv_next = bv + step_b
287
+ self._tonic_q16[i] = int(np.clip(bv_next, _INT32_MIN, _INT32_MAX))
288
+
289
+ self.tonic_baseline[:] = q16_to_float(self._tonic_q16)
290
+ self.fast_state[:] = q16_to_float(self._fast_q16)
291
+ return self._ln_normalize_q16_vec(pos)
292
+
293
+ def step(self, x: np.ndarray) -> np.ndarray:
294
+ """Process one sensory sample `(D,)` online with drift adaptation + LN gain."""
295
+ arr = np.asarray(x, dtype=np.float64)
296
+ if arr.ndim != 1 or arr.shape[0] != self.n_channels:
297
+ raise ValueError(f"Expected 1D vector of length {self.n_channels}, got {arr.shape}")
298
+
299
+ if self.c_parity:
300
+ q_out = self.step_q16(float_to_q16(arr))
301
+ return q16_to_float(q_out)
302
+
303
+ diff_tonic = arr - self.tonic_baseline
304
+ diff_fast = arr - self.fast_state
305
+ drive = diff_tonic + self.phasic_gain * diff_fast
306
+
307
+ # Update fast phasic tracker
308
+ self.fast_state += self.fast_alpha * diff_fast
309
+
310
+ # Update slow tonic baseline with asymmetric recovery and optional upward slew limit
311
+ up_step = self.tonic_alpha * diff_tonic
312
+ if self.tonic_slew_limit is not None:
313
+ up_step = np.minimum(up_step, self.tonic_slew_limit)
314
+ down_step = self.tonic_down_alpha * diff_tonic
315
+ self.tonic_baseline += np.where(diff_tonic >= 0.0, up_step, down_step)
316
+
317
+ return self.normalize(drive)
318
+
319
+ def transform(self, X: np.ndarray, *, reset_state: bool = False) -> np.ndarray:
320
+ """Process a 1D sample `(D,)` or sequential 2D stream `(T, D)`."""
321
+ if reset_state:
322
+ self.reset()
323
+ arr = np.asarray(X, dtype=np.float64)
324
+ if arr.ndim == 1:
325
+ return self.step(arr)
326
+ if arr.ndim == 2:
327
+ if arr.shape[1] != self.n_channels:
328
+ raise ValueError(f"Expected {self.n_channels} channels, got {arr.shape[1]}")
329
+ out = np.empty_like(arr, dtype=np.float64)
330
+ for t in range(arr.shape[0]):
331
+ out[t] = self.step(arr[t])
332
+ return out
333
+ raise ValueError("X must be 1D (D,) or 2D (T, D)")
334
+
335
+ def __call__(self, x: np.ndarray) -> np.ndarray:
336
+ return self.transform(x)