necksense 1.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amandeep Singh Bains
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ include LICENSE
2
+ include README.md
3
+ include pyproject.toml
4
+ recursive-include necksense *.py py.typed
5
+ recursive-include csrc *.h *.c Makefile
6
+ prune assets
7
+ prune csrc/build
8
+ prune results
9
+ prune scripts
10
+ prune tests
@@ -0,0 +1,299 @@
1
+ Metadata-Version: 2.4
2
+ Name: necksense
3
+ Version: 1.1.0
4
+ Summary: Drosophila Antennal Lobe + Mushroom Body concentration-invariant sparse hashing, 1-shot novelty detection, and online associative classification in Python and zero-heap ANSI C99
5
+ Author: Amandeep Singh Bains
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/indian-aman/necklink/tree/main/packages/necksense
8
+ Project-URL: Repository, https://github.com/indian-aman/necklink/tree/main/packages/necksense
9
+ Project-URL: Releases, https://github.com/indian-aman/necksense/releases
10
+ Project-URL: Issues, https://github.com/indian-aman/necklink/issues
11
+ Project-URL: Documentation, https://github.com/indian-aman/necklink/tree/main/packages/necksense#readme
12
+ Keywords: necksense,necklink,drosophila,flyhash,mushroom-body,antennal-lobe,kenyon-cells,locality-sensitive-hashing,anomaly-detection,novelty-detection,olfaction,e-nose,embedded-ai,esp32,tinyml
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: C
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
25
+ Classifier: Topic :: System :: Hardware
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: numpy>=1.24
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0; extra == "dev"
32
+ Requires-Dist: matplotlib>=3.7; extra == "dev"
33
+ Requires-Dist: build>=1.0; extra == "dev"
34
+ Requires-Dist: twine>=5.0; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ <div align="center">
38
+
39
+ <a href="https://github.com/indian-aman/necklink"><img src="https://raw.githubusercontent.com/indian-aman/necklink/main/assets/logo/necklink-mark.svg" alt="NeckLink" width="52"></a>
40
+
41
+ # NeckSense (`necksense` v1.1.0)
42
+
43
+ **Concentration-invariant normalisation, sparse binary hashing, one-shot novelty detection and one-shot classification, modelled on the *Drosophila* antennal lobe and mushroom body, in Python (NumPy) and zero-heap, zero-float `Q16.16` ANSI C99 that is bit-identical to Python.**
44
+
45
+ [![Version](https://img.shields.io/badge/version-1.1.0-10B981?style=flat-square)](https://github.com/indian-aman/necklink/tree/main/packages/necksense)
46
+ [![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://github.com/indian-aman/necklink/tree/main/packages/necksense)
47
+ [![ANSI C99](https://img.shields.io/badge/C99-zero--heap%20%7C%20zero--float%20Q16.16-00599C?style=flat-square&logo=c&logoColor=white)](csrc/)
48
+ [![Tests](https://img.shields.io/badge/tests-17%20pytest%20%2B%205%20C99-10B981?style=flat-square)](tests/)
49
+ [![License: MIT](https://img.shields.io/badge/license-MIT-F59E0B?style=flat-square)](LICENSE)
50
+
51
+ [**Install**](#install) ·
52
+ [**Quick start**](#quick-start) ·
53
+ [**C99 core**](#zero-heap-zero-float-ansi-c99-core-csrc) ·
54
+ [**Benchmarks**](#benchmarks-5-seed-mean) ·
55
+ [**When to use it**](#when-to-use-necksense-and-when-not-to) ·
56
+ [**Changelog**](#changelog)
57
+
58
+ </div>
59
+
60
+ <p align="center">
61
+ <img src="https://raw.githubusercontent.com/indian-aman/necklink/main/packages/necksense/assets/necksense_benchmarks.png" alt="NeckSense benchmarks" width="100%">
62
+ </p>
63
+
64
+ ---
65
+
66
+ ## What it is
67
+
68
+ Multi-channel chemical, vibration and telemetry sensors face slow baseline drift, large amplitude swings and small microcontrollers. NeckSense turns the fly's four-stage olfactory circuit into a sensor-conditioning and memory library:
69
+
70
+ ```
71
+ raw sensor vector x(t)
72
+ │ [1] ORN dual-timescale adaptation: subtract slow (tonic) drift, keep fast transients
73
+ │ [2] Antennal-lobe divisive normalisation: r_i = p_i^n / (sigma^n + (gamma*mean)^n + p_i^n)
74
+ │ [3] Mushroom-body projection: each Kenyon cell sums d_proj=6 inputs (additions only),
75
+ │ then global winner-take-all keeps the top k cells -> packed uint64 bit tag
76
+ ├──► KenyonIndex popcount similarity search over binary tags
77
+ ├──► MushroomNoveltyDetector one-shot habituation -> novelty score in [0, 1]
78
+ └──► MushroomValenceClassifier one-shot / few-shot associative classifier
79
+ ```
80
+
81
+ - **Concentration invariance.** Channel *ratios* survive scaling: the minimum cosine similarity between 1× and 100× inputs is `> 0.99999` over 200 random 8-channel patterns. Near-zero inputs are squelched by `sigma` rather than amplified.
82
+ - **No weight matrix.** Each Kenyon cell's 6 input indices come from a deterministic PRNG, so projection needs **0 bytes of weights and no multiplications**, only additions.
83
+ - **One-shot memory.** Sparse codes let a new class or normal mode be stored from one example with little interference: 2% active cells for novelty, 5% for classification and search.
84
+ - **Bit-identical Python ↔ C.** With `c_parity=True`, Python reproduces the C core's `Q16.16` outputs, tags and scores exactly. The test suite checks this against golden vectors and against the live C binary.
85
+
86
+ ---
87
+
88
+ ## Install
89
+
90
+ ```bash
91
+ pip install "git+https://github.com/indian-aman/necklink.git#subdirectory=packages/necksense"
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Quick start
97
+
98
+ ```python
99
+ import numpy as np
100
+ from necksense import (
101
+ AntennalNormalizer,
102
+ KenyonIndex,
103
+ MushroomNoveltyDetector,
104
+ MushroomValenceClassifier,
105
+ )
106
+
107
+ ethanol_1x = np.array([0.90, 0.35, 0.10, 0.02, 0.50, 0.05, 0.01, 0.20])
108
+ acetone_1x = np.array([0.45, 0.15, 0.08, 0.80, 0.75, 0.10, 0.30, 0.05])
109
+
110
+ # 1. Antennal lobe: divisive normalisation keeps channel ratios across concentration
111
+ ant = AntennalNormalizer(n_channels=8, sigma=0.008, gamma=1.0, hill_n=2.0, tonic_alpha=0.03)
112
+
113
+ # 2. One-shot associative learning: recognise a 20x plume from a single 1x example
114
+ clf = MushroomValenceClassifier(n_inputs=8, antennal=ant, n_kc=1024, d_proj=6, wta_frac=0.05)
115
+ clf.learn(ethanol_1x, "ethanol", reward=1.0)
116
+ clf.learn(acetone_1x, "acetone", reward=1.0)
117
+ assert clf.predict(20.0 * acetone_1x) == "acetone"
118
+
119
+ # 3. Streaming novelty: familiar patterns habituate after a few exposures
120
+ nov = MushroomNoveltyDetector(n_inputs=8, antennal=AntennalNormalizer(8, sigma=0.008), lr=0.55)
121
+ scores = [nov.update_and_score(ethanol_1x) for _ in range(3)] # 1.00 -> 0.45 -> 0.20
122
+ s_anomaly = nov.update_and_score(acetone_1x) # unseen pattern: 0.82
123
+ print([round(s, 2) for s in scores], round(s_anomaly, 2))
124
+
125
+ # 4. Popcount similarity search over 128-byte (1024-bit) Kenyon tags
126
+ idx = KenyonIndex(n_inputs=64, n_kc=1024, d_proj=6, wta_frac=0.05)
127
+ db = np.random.default_rng(0).standard_normal((5000, 64))
128
+ idx.add(db)
129
+ assert idx.query(db[42] + 0.05, top_k=5)[0].id == 42
130
+ ```
131
+
132
+ ### Command line
133
+
134
+ ```bash
135
+ necksense demo # four-stage circuit walkthrough
136
+ necksense benchmark-quick # short version of the three benchmarks
137
+ necksense hash-csv sensors.csv --id-col 0 --n-kc 1024 --antennal-normalize -o tags.csv
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Zero-heap, zero-float ANSI C99 core (`csrc/`)
143
+
144
+ [`csrc/necksense.h`](csrc/necksense.h) and [`csrc/necksense.c`](csrc/necksense.c) use only `int32_t`, `uint32_t` and `uint64_t` (`Q16.16`, `1.0 == 65536`), with no `malloc` and no `float`/`double`. They compile cleanly with `-O2 -std=c99 -Wall -Wextra -pedantic -Werror`.
145
+
146
+ ```bash
147
+ make -C csrc clean all test
148
+ ```
149
+
150
+ ```c
151
+ #include "necksense.h"
152
+
153
+ static ns_antennal_t ant;
154
+ static ns_kenyon_t ken;
155
+ static ns_novelty_t nov;
156
+
157
+ void sensor_init(void) {
158
+ ns_antennal_init(&ant, 8u, 512, NS_Q16_ONE, 2u, 655, 1311, 32768, 22938);
159
+ ns_kenyon_init(&ken, 8u, 1024u, 6u, 20u, 42u); /* 20 of 1024 active (2%) for novelty */
160
+ ns_novelty_init(&nov, 1024u, 36045 /* lr = 0.55 */, 0);
161
+ }
162
+
163
+ int32_t sensor_tick_q16(const int32_t raw_q16[8]) {
164
+ int32_t norm_q16[8];
165
+ uint64_t kc_tag[16];
166
+ ns_antennal_step(&ant, raw_q16, norm_q16);
167
+ ns_kenyon_hash(&ken, norm_q16, kc_tag);
168
+ return ns_novelty_update_and_score(&nov, kc_tag); /* 0 .. 65536 */
169
+ }
170
+ ```
171
+
172
+ Static RAM per struct, as printed by the self-test:
173
+
174
+ | Struct | Default build (`NS_MAX_KC=2048`, `NS_MAX_CLASSES=16`) | `-DNS_MAX_KC=1024 -DNS_MAX_CLASSES=6` |
175
+ | :--- | :---: | :---: |
176
+ | `ns_antennal_t` | `548 B` | `548 B` |
177
+ | `ns_kenyon_t` (no weight table) | `24 B` | `24 B` |
178
+ | `ns_novelty_t` | `8,216 B` | `4,120 B` |
179
+ | `ns_valence_t` (classifier) | `131,172 B` | `24,628 B` |
180
+ | **Total** | **~140 KB** | **~29.3 KB** |
181
+
182
+ Full pipeline (normalise + hash + novelty, `D=16`, `M=1024`): **~25–30 us per sample** on a desktop x86-64 core at `-O2`. It has not been measured on MCU hardware.
183
+
184
+ ---
185
+
186
+ ## Benchmarks (5-seed mean)
187
+
188
+ ```bash
189
+ python scripts/benchmark_necksense.py # ~40 s; writes results/benchmark_summary.json and the chart
190
+ ```
191
+
192
+ ### 1. Nearest-neighbour retrieval (`N = 5,000`, `D = 64`, Recall@10)
193
+
194
+ **A. Equal number of active bits `k`.** This is the protocol of Dasgupta, Stevens & Navlakha (*Science* 2017). FlyHash uses `M = 20k` Kenyon cells; SimHash uses `k` Gaussian hyperplanes.
195
+
196
+ | `k` | FlyHash | SimHash | FlyHash float multiplications | SimHash float multiplications |
197
+ | :--- | :---: | :---: | :---: | :---: |
198
+ | 16 | **`17.85% ± 0.58%`** | `9.96% ± 0.21%` | 0 | 1,024 |
199
+ | 32 | **`23.21% ± 0.30%`** | `13.69% ± 0.43%` | 0 | 2,048 |
200
+ | 64 | **`29.93% ± 0.44%`** | `16.68% ± 0.48%` | 0 | 4,096 |
201
+ | 128 | **`36.19% ± 0.35%`** | `22.53% ± 0.18%` | 0 | 8,192 |
202
+
203
+ **B. Equal stored bits per item.** Both are stored as dense bit vectors. **SimHash wins clearly**, because a 50%-dense bit carries more information than a 5%-sparse one:
204
+
205
+ | Tag size | FlyHash | **SimHash** | FlyHash weight RAM | SimHash weight RAM |
206
+ | :--- | :---: | :---: | :---: | :---: |
207
+ | 64 B (512 bits) | `21.67%` | **`40.64%`** | 0 KB | 128 KB |
208
+ | 128 B (1024 bits) | `27.50%` | **`50.51%`** | 0 KB | 256 KB |
209
+ | 256 B (2048 bits) | `34.02%` | **`59.80%`** | 0 KB | 512 KB |
210
+
211
+ FlyHash's advantage is that it needs no weight matrix and no multiplications, which matters on an MCU. If you have the RAM and an FPU, SimHash, PQ or HNSW give better recall per byte.
212
+
213
+ ### 2. Gas classification: 8 sensors, 6 gases, 3 calibration shots per gas
214
+
215
+ 300 test plumes per seed at `0.1×–25×` concentration, with sinusoidal + linear baseline drift in the drift regimes. **"Baseline-corrected L2 k-NN"** is the standard e-nose pipeline: subtract a moving average of the clean-air readings between plumes (the same information NeckSense's drift tracker gets), L2-normalise, then 3-NN.
216
+
217
+ | Regime | NeckSense (3-shot) | NeckSense (1-shot) | **Baseline-corrected L2 k-NN** | L2 k-NN (no drift correction) | Z-score centroid |
218
+ | :--- | :---: | :---: | :---: | :---: | :---: |
219
+ | Clean 1× | `100%` | `100%` | `100%` | `100%` | `100%` |
220
+ | `0.1×–25×`, no drift | `100%` | `100%` | `100%` | `100%` | `89.9%` |
221
+ | Weak `0.1×–0.5×` + drift | `99.3% ± 0.7%` | `99.5% ± 0.6%` | **`100%`** | `33.4%` | `30.1%` |
222
+ | Full `0.1×–25×` + drift | `99.5% ± 0.5%` | `99.5% ± 0.5%` | **`100%`** | `76.5%` | `72.7%` |
223
+
224
+ What this shows:
225
+ - Drift correction plus ratio normalisation is what solves this task. Any pipeline with those two steps, NeckSense or the classical one, reaches ≥ 99%.
226
+ - NeckSense's version runs in integer C with one-shot learning.
227
+ - Without drift correction, k-NN collapses: dividing `0.2·gas + drift` by its norm points mostly along the drift.
228
+
229
+ ### 3. Streaming one-shot anomaly detection (`D = 16`, 20 recurring normal modes at `0.5×–5×`, 5% novel chords)
230
+
231
+ | Detector | AUROC | State | Python time / sample |
232
+ | :--- | :---: | :---: | :---: |
233
+ | **NeckSense novelty** (2% sparse, 1024 cells) | **`0.9942 ± 0.0018`** | 4 KB (`Q16` weights) | ~100–130 us |
234
+ | L2 + nearest-neighbour memory, 512 slots | **`0.9993 ± 0.0002`** | 32 KB (`float32`) | ~11 us |
235
+ | L2 + nearest-neighbour memory, 64 slots | `0.9403 ± 0.0081` | 4 KB (`float32`) | ~10 us |
236
+ | L2 + online Mahalanobis | `0.9581 ± 0.0098` | 1 KB + `O(D²)` math | ~20 us |
237
+ | Online Mahalanobis (raw) | `0.8453 ± 0.0209` | | ~18 us |
238
+ | Rolling z-score (raw) | `0.6349 ± 0.0400` | | ~12 us |
239
+ | Half-space, 32 projections (raw) | `0.5902 ± 0.0346` | | ~36 us |
240
+
241
+ What this shows:
242
+ - **At equal memory** (4 KB), NeckSense beats a nearest-neighbour memory (0.994 vs 0.940).
243
+ - **Given 8× the memory** and floating point, plain nearest-neighbour is better (0.999).
244
+ - Normalisation matters most: every raw-input detector does poorly.
245
+ - The Python implementation is slower than the baselines; use the C core where speed matters.
246
+
247
+ ---
248
+
249
+ ## When to use NeckSense, and when not to
250
+
251
+ **Good fit:**
252
+ - **One-shot novelty or one-shot classification on a microcontroller.** Integer-only, no weight matrix, a few KB of state, and bit-identical Python for development.
253
+ - **Sensors with amplitude swings and slow drift.** `AntennalNormalizer` gives drift tracking and ratio normalisation in one integer-friendly block.
254
+ - **Sparse binary codes** that can be ANDed, popcounted and updated online.
255
+
256
+ **Use something else when:**
257
+ - **You have a server with RAM and an FPU.** SimHash, PQ or HNSW (`faiss`, `hnswlib`) beat FlyHash on recall per byte, and a plain normalised nearest-neighbour memory beats the novelty detector if you can afford the memory.
258
+ - **You already run baseline correction plus normalisation** feeding k-NN. On our gas benchmark it is as accurate (100% vs 99.5%).
259
+ - **You have thousands of labelled examples.** Gradient-boosted trees or a trained network will outperform one-shot associative rules.
260
+ - **Faults are single-channel amplitude spikes.** A per-channel z-score is simpler and faster.
261
+
262
+ ---
263
+
264
+ ## Repository layout
265
+
266
+ | Path | Contents |
267
+ | :--- | :--- |
268
+ | [`necksense/antennal.py`](necksense/antennal.py) | `AntennalNormalizer`: drift tracking, divisive normalisation, `Q16.16` parity |
269
+ | [`necksense/kenyon.py`](necksense/kenyon.py) | `KenyonHasher` (FlyHash) and `KenyonIndex` (popcount search, `.npz` save/load) |
270
+ | [`necksense/mushroom.py`](necksense/mushroom.py) | `MushroomNoveltyDetector` and `MushroomValenceClassifier` |
271
+ | [`necksense/cli.py`](necksense/cli.py) | `necksense demo`, `benchmark-quick`, `hash-csv` |
272
+ | [`csrc/`](csrc/) | C99 core, `Makefile`, 5 self-tests and the `--json-parity` emitter |
273
+ | [`scripts/benchmark_necksense.py`](scripts/benchmark_necksense.py) | Reproducible benchmark and chart generator |
274
+ | [`tests/`](tests/) | 17 tests, including every README example, a static zero-heap/zero-float audit of the C sources and Python ↔ C bit parity |
275
+
276
+ ---
277
+
278
+ ## Changelog
279
+
280
+ **1.1.0**
281
+ - *Improvement:* `MushroomNoveltyDetector` now defaults to 2% sparse codes (`wta_frac=0.02`). Streaming anomaly AUROC rose from `0.9658` to `0.9942` on the benchmark seeds and from `0.961` to `0.997` on held-out seeds. For the C core, pass `k = 20` (of 1024) to `ns_kenyon_init` for novelty.
282
+ - *Benchmark:* added baseline-corrected k-NN, nearest-neighbour memories at equal and 8× memory, and L2 + Mahalanobis. 1.0.0 compared only against baselines without drift correction or normalisation, which overstated the gap: "99.5% where k-NN drops to 33%", and "0.966 vs 0.845 AUROC".
283
+ - *Docs:* the install instructions no longer claim a PyPI package. The C memory table now includes the classifier struct (131 KB by default). Every Python example runs as written.
284
+
285
+ ---
286
+
287
+ ## Scientific foundations
288
+
289
+ 1. Olsen, S. R. & Wilson, R. I. (2008). Lateral presynaptic inhibition mediates gain control in an olfactory circuit. *Nature*, 452, 956–960.
290
+ 2. Nagel, K. I. & Wilson, R. I. (2011). Biophysical mechanisms underlying olfactory receptor neuron dynamics. *Nature Neuroscience*, 14, 208–216.
291
+ 3. Dasgupta, S., Stevens, C. F. & Navlakha, S. (2017). A neural algorithm for a fundamental computing problem. *Science*, 358(6364), 793–796.
292
+ 4. Hattori, D. et al. (2017). Representations of novelty and familiarity in a mushroom body compartment. *Cell*, 169(5), 956–969.
293
+ 5. Aso, Y. et al. (2014). Mushroom body output neurons encode valence and guide memory-based action selection in *Drosophila*. *eLife*, 3, e04580.
294
+
295
+ ---
296
+
297
+ ## License
298
+
299
+ Released under the [MIT License](LICENSE). Copyright (c) 2026 Amandeep Singh Bains.
@@ -0,0 +1,263 @@
1
+ <div align="center">
2
+
3
+ <a href="https://github.com/indian-aman/necklink"><img src="https://raw.githubusercontent.com/indian-aman/necklink/main/assets/logo/necklink-mark.svg" alt="NeckLink" width="52"></a>
4
+
5
+ # NeckSense (`necksense` v1.1.0)
6
+
7
+ **Concentration-invariant normalisation, sparse binary hashing, one-shot novelty detection and one-shot classification, modelled on the *Drosophila* antennal lobe and mushroom body, in Python (NumPy) and zero-heap, zero-float `Q16.16` ANSI C99 that is bit-identical to Python.**
8
+
9
+ [![Version](https://img.shields.io/badge/version-1.1.0-10B981?style=flat-square)](https://github.com/indian-aman/necklink/tree/main/packages/necksense)
10
+ [![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://github.com/indian-aman/necklink/tree/main/packages/necksense)
11
+ [![ANSI C99](https://img.shields.io/badge/C99-zero--heap%20%7C%20zero--float%20Q16.16-00599C?style=flat-square&logo=c&logoColor=white)](csrc/)
12
+ [![Tests](https://img.shields.io/badge/tests-17%20pytest%20%2B%205%20C99-10B981?style=flat-square)](tests/)
13
+ [![License: MIT](https://img.shields.io/badge/license-MIT-F59E0B?style=flat-square)](LICENSE)
14
+
15
+ [**Install**](#install) ·
16
+ [**Quick start**](#quick-start) ·
17
+ [**C99 core**](#zero-heap-zero-float-ansi-c99-core-csrc) ·
18
+ [**Benchmarks**](#benchmarks-5-seed-mean) ·
19
+ [**When to use it**](#when-to-use-necksense-and-when-not-to) ·
20
+ [**Changelog**](#changelog)
21
+
22
+ </div>
23
+
24
+ <p align="center">
25
+ <img src="https://raw.githubusercontent.com/indian-aman/necklink/main/packages/necksense/assets/necksense_benchmarks.png" alt="NeckSense benchmarks" width="100%">
26
+ </p>
27
+
28
+ ---
29
+
30
+ ## What it is
31
+
32
+ Multi-channel chemical, vibration and telemetry sensors face slow baseline drift, large amplitude swings and small microcontrollers. NeckSense turns the fly's four-stage olfactory circuit into a sensor-conditioning and memory library:
33
+
34
+ ```
35
+ raw sensor vector x(t)
36
+ │ [1] ORN dual-timescale adaptation: subtract slow (tonic) drift, keep fast transients
37
+ │ [2] Antennal-lobe divisive normalisation: r_i = p_i^n / (sigma^n + (gamma*mean)^n + p_i^n)
38
+ │ [3] Mushroom-body projection: each Kenyon cell sums d_proj=6 inputs (additions only),
39
+ │ then global winner-take-all keeps the top k cells -> packed uint64 bit tag
40
+ ├──► KenyonIndex popcount similarity search over binary tags
41
+ ├──► MushroomNoveltyDetector one-shot habituation -> novelty score in [0, 1]
42
+ └──► MushroomValenceClassifier one-shot / few-shot associative classifier
43
+ ```
44
+
45
+ - **Concentration invariance.** Channel *ratios* survive scaling: the minimum cosine similarity between 1× and 100× inputs is `> 0.99999` over 200 random 8-channel patterns. Near-zero inputs are squelched by `sigma` rather than amplified.
46
+ - **No weight matrix.** Each Kenyon cell's 6 input indices come from a deterministic PRNG, so projection needs **0 bytes of weights and no multiplications**, only additions.
47
+ - **One-shot memory.** Sparse codes let a new class or normal mode be stored from one example with little interference: 2% active cells for novelty, 5% for classification and search.
48
+ - **Bit-identical Python ↔ C.** With `c_parity=True`, Python reproduces the C core's `Q16.16` outputs, tags and scores exactly. The test suite checks this against golden vectors and against the live C binary.
49
+
50
+ ---
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install "git+https://github.com/indian-aman/necklink.git#subdirectory=packages/necksense"
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Quick start
61
+
62
+ ```python
63
+ import numpy as np
64
+ from necksense import (
65
+ AntennalNormalizer,
66
+ KenyonIndex,
67
+ MushroomNoveltyDetector,
68
+ MushroomValenceClassifier,
69
+ )
70
+
71
+ ethanol_1x = np.array([0.90, 0.35, 0.10, 0.02, 0.50, 0.05, 0.01, 0.20])
72
+ acetone_1x = np.array([0.45, 0.15, 0.08, 0.80, 0.75, 0.10, 0.30, 0.05])
73
+
74
+ # 1. Antennal lobe: divisive normalisation keeps channel ratios across concentration
75
+ ant = AntennalNormalizer(n_channels=8, sigma=0.008, gamma=1.0, hill_n=2.0, tonic_alpha=0.03)
76
+
77
+ # 2. One-shot associative learning: recognise a 20x plume from a single 1x example
78
+ clf = MushroomValenceClassifier(n_inputs=8, antennal=ant, n_kc=1024, d_proj=6, wta_frac=0.05)
79
+ clf.learn(ethanol_1x, "ethanol", reward=1.0)
80
+ clf.learn(acetone_1x, "acetone", reward=1.0)
81
+ assert clf.predict(20.0 * acetone_1x) == "acetone"
82
+
83
+ # 3. Streaming novelty: familiar patterns habituate after a few exposures
84
+ nov = MushroomNoveltyDetector(n_inputs=8, antennal=AntennalNormalizer(8, sigma=0.008), lr=0.55)
85
+ scores = [nov.update_and_score(ethanol_1x) for _ in range(3)] # 1.00 -> 0.45 -> 0.20
86
+ s_anomaly = nov.update_and_score(acetone_1x) # unseen pattern: 0.82
87
+ print([round(s, 2) for s in scores], round(s_anomaly, 2))
88
+
89
+ # 4. Popcount similarity search over 128-byte (1024-bit) Kenyon tags
90
+ idx = KenyonIndex(n_inputs=64, n_kc=1024, d_proj=6, wta_frac=0.05)
91
+ db = np.random.default_rng(0).standard_normal((5000, 64))
92
+ idx.add(db)
93
+ assert idx.query(db[42] + 0.05, top_k=5)[0].id == 42
94
+ ```
95
+
96
+ ### Command line
97
+
98
+ ```bash
99
+ necksense demo # four-stage circuit walkthrough
100
+ necksense benchmark-quick # short version of the three benchmarks
101
+ necksense hash-csv sensors.csv --id-col 0 --n-kc 1024 --antennal-normalize -o tags.csv
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Zero-heap, zero-float ANSI C99 core (`csrc/`)
107
+
108
+ [`csrc/necksense.h`](csrc/necksense.h) and [`csrc/necksense.c`](csrc/necksense.c) use only `int32_t`, `uint32_t` and `uint64_t` (`Q16.16`, `1.0 == 65536`), with no `malloc` and no `float`/`double`. They compile cleanly with `-O2 -std=c99 -Wall -Wextra -pedantic -Werror`.
109
+
110
+ ```bash
111
+ make -C csrc clean all test
112
+ ```
113
+
114
+ ```c
115
+ #include "necksense.h"
116
+
117
+ static ns_antennal_t ant;
118
+ static ns_kenyon_t ken;
119
+ static ns_novelty_t nov;
120
+
121
+ void sensor_init(void) {
122
+ ns_antennal_init(&ant, 8u, 512, NS_Q16_ONE, 2u, 655, 1311, 32768, 22938);
123
+ ns_kenyon_init(&ken, 8u, 1024u, 6u, 20u, 42u); /* 20 of 1024 active (2%) for novelty */
124
+ ns_novelty_init(&nov, 1024u, 36045 /* lr = 0.55 */, 0);
125
+ }
126
+
127
+ int32_t sensor_tick_q16(const int32_t raw_q16[8]) {
128
+ int32_t norm_q16[8];
129
+ uint64_t kc_tag[16];
130
+ ns_antennal_step(&ant, raw_q16, norm_q16);
131
+ ns_kenyon_hash(&ken, norm_q16, kc_tag);
132
+ return ns_novelty_update_and_score(&nov, kc_tag); /* 0 .. 65536 */
133
+ }
134
+ ```
135
+
136
+ Static RAM per struct, as printed by the self-test:
137
+
138
+ | Struct | Default build (`NS_MAX_KC=2048`, `NS_MAX_CLASSES=16`) | `-DNS_MAX_KC=1024 -DNS_MAX_CLASSES=6` |
139
+ | :--- | :---: | :---: |
140
+ | `ns_antennal_t` | `548 B` | `548 B` |
141
+ | `ns_kenyon_t` (no weight table) | `24 B` | `24 B` |
142
+ | `ns_novelty_t` | `8,216 B` | `4,120 B` |
143
+ | `ns_valence_t` (classifier) | `131,172 B` | `24,628 B` |
144
+ | **Total** | **~140 KB** | **~29.3 KB** |
145
+
146
+ Full pipeline (normalise + hash + novelty, `D=16`, `M=1024`): **~25–30 us per sample** on a desktop x86-64 core at `-O2`. It has not been measured on MCU hardware.
147
+
148
+ ---
149
+
150
+ ## Benchmarks (5-seed mean)
151
+
152
+ ```bash
153
+ python scripts/benchmark_necksense.py # ~40 s; writes results/benchmark_summary.json and the chart
154
+ ```
155
+
156
+ ### 1. Nearest-neighbour retrieval (`N = 5,000`, `D = 64`, Recall@10)
157
+
158
+ **A. Equal number of active bits `k`.** This is the protocol of Dasgupta, Stevens & Navlakha (*Science* 2017). FlyHash uses `M = 20k` Kenyon cells; SimHash uses `k` Gaussian hyperplanes.
159
+
160
+ | `k` | FlyHash | SimHash | FlyHash float multiplications | SimHash float multiplications |
161
+ | :--- | :---: | :---: | :---: | :---: |
162
+ | 16 | **`17.85% ± 0.58%`** | `9.96% ± 0.21%` | 0 | 1,024 |
163
+ | 32 | **`23.21% ± 0.30%`** | `13.69% ± 0.43%` | 0 | 2,048 |
164
+ | 64 | **`29.93% ± 0.44%`** | `16.68% ± 0.48%` | 0 | 4,096 |
165
+ | 128 | **`36.19% ± 0.35%`** | `22.53% ± 0.18%` | 0 | 8,192 |
166
+
167
+ **B. Equal stored bits per item.** Both are stored as dense bit vectors. **SimHash wins clearly**, because a 50%-dense bit carries more information than a 5%-sparse one:
168
+
169
+ | Tag size | FlyHash | **SimHash** | FlyHash weight RAM | SimHash weight RAM |
170
+ | :--- | :---: | :---: | :---: | :---: |
171
+ | 64 B (512 bits) | `21.67%` | **`40.64%`** | 0 KB | 128 KB |
172
+ | 128 B (1024 bits) | `27.50%` | **`50.51%`** | 0 KB | 256 KB |
173
+ | 256 B (2048 bits) | `34.02%` | **`59.80%`** | 0 KB | 512 KB |
174
+
175
+ FlyHash's advantage is that it needs no weight matrix and no multiplications, which matters on an MCU. If you have the RAM and an FPU, SimHash, PQ or HNSW give better recall per byte.
176
+
177
+ ### 2. Gas classification: 8 sensors, 6 gases, 3 calibration shots per gas
178
+
179
+ 300 test plumes per seed at `0.1×–25×` concentration, with sinusoidal + linear baseline drift in the drift regimes. **"Baseline-corrected L2 k-NN"** is the standard e-nose pipeline: subtract a moving average of the clean-air readings between plumes (the same information NeckSense's drift tracker gets), L2-normalise, then 3-NN.
180
+
181
+ | Regime | NeckSense (3-shot) | NeckSense (1-shot) | **Baseline-corrected L2 k-NN** | L2 k-NN (no drift correction) | Z-score centroid |
182
+ | :--- | :---: | :---: | :---: | :---: | :---: |
183
+ | Clean 1× | `100%` | `100%` | `100%` | `100%` | `100%` |
184
+ | `0.1×–25×`, no drift | `100%` | `100%` | `100%` | `100%` | `89.9%` |
185
+ | Weak `0.1×–0.5×` + drift | `99.3% ± 0.7%` | `99.5% ± 0.6%` | **`100%`** | `33.4%` | `30.1%` |
186
+ | Full `0.1×–25×` + drift | `99.5% ± 0.5%` | `99.5% ± 0.5%` | **`100%`** | `76.5%` | `72.7%` |
187
+
188
+ What this shows:
189
+ - Drift correction plus ratio normalisation is what solves this task. Any pipeline with those two steps, NeckSense or the classical one, reaches ≥ 99%.
190
+ - NeckSense's version runs in integer C with one-shot learning.
191
+ - Without drift correction, k-NN collapses: dividing `0.2·gas + drift` by its norm points mostly along the drift.
192
+
193
+ ### 3. Streaming one-shot anomaly detection (`D = 16`, 20 recurring normal modes at `0.5×–5×`, 5% novel chords)
194
+
195
+ | Detector | AUROC | State | Python time / sample |
196
+ | :--- | :---: | :---: | :---: |
197
+ | **NeckSense novelty** (2% sparse, 1024 cells) | **`0.9942 ± 0.0018`** | 4 KB (`Q16` weights) | ~100–130 us |
198
+ | L2 + nearest-neighbour memory, 512 slots | **`0.9993 ± 0.0002`** | 32 KB (`float32`) | ~11 us |
199
+ | L2 + nearest-neighbour memory, 64 slots | `0.9403 ± 0.0081` | 4 KB (`float32`) | ~10 us |
200
+ | L2 + online Mahalanobis | `0.9581 ± 0.0098` | 1 KB + `O(D²)` math | ~20 us |
201
+ | Online Mahalanobis (raw) | `0.8453 ± 0.0209` | | ~18 us |
202
+ | Rolling z-score (raw) | `0.6349 ± 0.0400` | | ~12 us |
203
+ | Half-space, 32 projections (raw) | `0.5902 ± 0.0346` | | ~36 us |
204
+
205
+ What this shows:
206
+ - **At equal memory** (4 KB), NeckSense beats a nearest-neighbour memory (0.994 vs 0.940).
207
+ - **Given 8× the memory** and floating point, plain nearest-neighbour is better (0.999).
208
+ - Normalisation matters most: every raw-input detector does poorly.
209
+ - The Python implementation is slower than the baselines; use the C core where speed matters.
210
+
211
+ ---
212
+
213
+ ## When to use NeckSense, and when not to
214
+
215
+ **Good fit:**
216
+ - **One-shot novelty or one-shot classification on a microcontroller.** Integer-only, no weight matrix, a few KB of state, and bit-identical Python for development.
217
+ - **Sensors with amplitude swings and slow drift.** `AntennalNormalizer` gives drift tracking and ratio normalisation in one integer-friendly block.
218
+ - **Sparse binary codes** that can be ANDed, popcounted and updated online.
219
+
220
+ **Use something else when:**
221
+ - **You have a server with RAM and an FPU.** SimHash, PQ or HNSW (`faiss`, `hnswlib`) beat FlyHash on recall per byte, and a plain normalised nearest-neighbour memory beats the novelty detector if you can afford the memory.
222
+ - **You already run baseline correction plus normalisation** feeding k-NN. On our gas benchmark it is as accurate (100% vs 99.5%).
223
+ - **You have thousands of labelled examples.** Gradient-boosted trees or a trained network will outperform one-shot associative rules.
224
+ - **Faults are single-channel amplitude spikes.** A per-channel z-score is simpler and faster.
225
+
226
+ ---
227
+
228
+ ## Repository layout
229
+
230
+ | Path | Contents |
231
+ | :--- | :--- |
232
+ | [`necksense/antennal.py`](necksense/antennal.py) | `AntennalNormalizer`: drift tracking, divisive normalisation, `Q16.16` parity |
233
+ | [`necksense/kenyon.py`](necksense/kenyon.py) | `KenyonHasher` (FlyHash) and `KenyonIndex` (popcount search, `.npz` save/load) |
234
+ | [`necksense/mushroom.py`](necksense/mushroom.py) | `MushroomNoveltyDetector` and `MushroomValenceClassifier` |
235
+ | [`necksense/cli.py`](necksense/cli.py) | `necksense demo`, `benchmark-quick`, `hash-csv` |
236
+ | [`csrc/`](csrc/) | C99 core, `Makefile`, 5 self-tests and the `--json-parity` emitter |
237
+ | [`scripts/benchmark_necksense.py`](scripts/benchmark_necksense.py) | Reproducible benchmark and chart generator |
238
+ | [`tests/`](tests/) | 17 tests, including every README example, a static zero-heap/zero-float audit of the C sources and Python ↔ C bit parity |
239
+
240
+ ---
241
+
242
+ ## Changelog
243
+
244
+ **1.1.0**
245
+ - *Improvement:* `MushroomNoveltyDetector` now defaults to 2% sparse codes (`wta_frac=0.02`). Streaming anomaly AUROC rose from `0.9658` to `0.9942` on the benchmark seeds and from `0.961` to `0.997` on held-out seeds. For the C core, pass `k = 20` (of 1024) to `ns_kenyon_init` for novelty.
246
+ - *Benchmark:* added baseline-corrected k-NN, nearest-neighbour memories at equal and 8× memory, and L2 + Mahalanobis. 1.0.0 compared only against baselines without drift correction or normalisation, which overstated the gap: "99.5% where k-NN drops to 33%", and "0.966 vs 0.845 AUROC".
247
+ - *Docs:* the install instructions no longer claim a PyPI package. The C memory table now includes the classifier struct (131 KB by default). Every Python example runs as written.
248
+
249
+ ---
250
+
251
+ ## Scientific foundations
252
+
253
+ 1. Olsen, S. R. & Wilson, R. I. (2008). Lateral presynaptic inhibition mediates gain control in an olfactory circuit. *Nature*, 452, 956–960.
254
+ 2. Nagel, K. I. & Wilson, R. I. (2011). Biophysical mechanisms underlying olfactory receptor neuron dynamics. *Nature Neuroscience*, 14, 208–216.
255
+ 3. Dasgupta, S., Stevens, C. F. & Navlakha, S. (2017). A neural algorithm for a fundamental computing problem. *Science*, 358(6364), 793–796.
256
+ 4. Hattori, D. et al. (2017). Representations of novelty and familiarity in a mushroom body compartment. *Cell*, 169(5), 956–969.
257
+ 5. Aso, Y. et al. (2014). Mushroom body output neurons encode valence and guide memory-based action selection in *Drosophila*. *eLife*, 3, e04580.
258
+
259
+ ---
260
+
261
+ ## License
262
+
263
+ Released under the [MIT License](LICENSE). Copyright (c) 2026 Amandeep Singh Bains.
@@ -0,0 +1,21 @@
1
+ CC ?= gcc
2
+ CFLAGS ?= -O2 -std=c99 -Wall -Wextra -pedantic -Werror -I.
3
+ BUILD ?= build
4
+
5
+ CORE_SRCS := necksense.c
6
+
7
+ .PHONY: all test clean
8
+
9
+ all: $(BUILD)/necksense_selftest
10
+
11
+ $(BUILD):
12
+ mkdir -p $(BUILD)
13
+
14
+ $(BUILD)/necksense_selftest: tools/necksense_selftest.c $(CORE_SRCS) necksense.h | $(BUILD)
15
+ $(CC) $(CFLAGS) -o $@ tools/necksense_selftest.c $(CORE_SRCS)
16
+
17
+ test: $(BUILD)/necksense_selftest
18
+ ./$(BUILD)/necksense_selftest
19
+
20
+ clean:
21
+ rm -rf $(BUILD)