BitBrain: generic clean-room ADE + SBC library with MNIST acceptance

Implement the BitBrain (Address Decoder Element + Sparse Binary Coincidence)
classifier as a generic, deterministic Nim library under common_libs/bitbrain/,
written from the published algorithm (Front. Neuroinform. 17:1125844), not from
the GPL-3.0 reference C.

- ade.nim: signed thresholded random projection (scale 64 / centre 127 defaults
  reproduce the reference), multi-width ADs, optional deterministic homeostatic
  threshold adaptation. Hebbian longevity and Metropolis-Hastings sampling are
  described but not implemented.
- sbc.nim: packed class-bit coincidence memory; idempotent learn, counting
  inference.
- bitbrain.nim: container over several ADs and SBCs, online learn/infer, argmax
  readout, memory accounting.
- tests: 32 unit checks (idempotence, planted rule + monotone online curve,
  shuffled-label chance control, unseen input, homeostasis, memory).
- tests/test_bitbrain_mnist.nim: loads the reference pretrained ADs/thresholds
  and MNIST from /tmp, reproduces the reference exactly - 97.210% corrected and
  96.540% bug-compatible - confirming the port.

No gun/wiring integration yet; inputs and outputs to be agreed separately.
This commit is contained in:
2026-09-24 20:52:45 +02:00
parent cc11ede824
commit 77e6dace01
6 changed files with 1040 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
# BitBrain (ADE + SBC) — generic Nim library
A clean-room Nim implementation of the classifier in
> *BitBrain and Sparse Binary Coincidence (SBC) memories*,
> Frontiers in Neuroinformatics 17:1125844, 2023.
**This is a library, not a gun.** There is no I/O design, no battle wiring and no
environment knobs here yet — input and output formats are to be agreed separately.
## Clean-room note
The implementation was written **from the published algorithm description only**.
No code was copied from the reference C program (`full_mnist_2048.c`) shipped with
the paper; that file is GPL-3.0-or-later, © 2022 The University of Manchester, and
copying it would impose that licence on this repository. The published defaults
(`scale = 64`, `centre = 127`) make the scoring rule numerically identical to the
reference, which the MNIST acceptance test confirms to the digit.
## Mechanism
1. **ADE (Address Decoder Element)** — a sparse, signed, thresholded random
projection. Each ADE has `width` synapses `(inputIndex, polarity)` and scores an
input as
```
raw = Σ_j polarity_j * (input[inputIndex_j] - center)
score = scale * raw
```
firing iff `score >= threshold`. Defaults `scale = 64`, `center = 127`
reproduce the reference exactly. Multi-width ADs (the paper's best setup uses
widths `{6, 8, 10, 12}`) detect features at different scales.
2. **Homeostatic threshold learning** (optional, unsupervised) — accumulate each
ADE's firing count over inputs, then nudge its threshold toward a target firing
rate (~1% in the paper). `accumulateFiring` + `adaptThresholds` implement the
paper's deterministic controller. The paper's additional Hebbian *longevity*
step (retire the weakest synapse, resample a new input index) and its
Metropolis–Hastings input-position sampling are **described in `ade.nim` but
not implemented** — `initRandomAddressDecoder` uses the paper's uniform random
initialisation.
3. **SBC memory** (supervised) — two ADs on the axes; a pair of simultaneously
firing ADEs `(i, j)` is a coincidence indexing one cell holding a class
bitmask. `learn` **sets** the class bit and is idempotent: setting it again is a
no-op. No clearing, no learning rate, no decay, no epochs. `infer` accesses the
same locations but **counts** set bits per class.
4. **BitBrain container** — several ADs (possibly different widths) plus several
SBCs built from pairs of them. Inference aggregates class counts across SBCs;
argmax wins (ties to the lowest class index).
## API
```nim
import bitbrain/bitbrain # re-exports ade + sbc
# --- construction ---
var bb = buildRandomBitBrain(
widths = @[6, 8, 10, 12], # one AD per width
nAde = 512, # ADEs per AD
inputWidth = 256, # input vector length
nClasses = 8,
seed = 1234'i64) # deterministic
# or build ADs yourself and assemble:
# var ad = initAddressDecoder(nAde = 2048, width = 6)
# ad.codes = ... # signed 1-based input codes (pretrained)
# ad.thresholds = ...
# var bb = initBitBrain(@[ad, ...], crossPairs(4) & withinPairs(4), nClasses = 10)
# --- online by construction (order-free, repeatable, interleaved) ---
bb.learn(input, class) # sets class bits, idempotent
let (label, counts) = bb.infer(input) # argmax + per-class counts
bb.resetLearning() # wipe SBCs (ADs unchanged)
# --- optional unsupervised homeostasis ---
for input in trainingStream:
ad.accumulateFiring(input)
ad.adaptThresholds(interval = 2000, targetRate = 0.01, step = 1)
# --- accounting ---
bb.memoryBytes # ADs + SBC tensors
bb.sbcMemoryBytes # SBC tensors only (the dominant term)
```
Generic over the input element type (`openArray[SomeInteger]`) and over the input
width, ADE count and class count — nothing is hardcoded to 784/10. Deterministic
given the seed. Dependencies: `std/` only.
Reference SBC wiring: `crossPairs(4)` gives the 6 cross-AD SBCs used by the
reference C. `withinPairs(n)` adds the paper's 4 within-AD ("half-size") SBCs;
this implementation stores them full-size (half-size packing is a separate memory
optimisation).
## Measured results
All figures measured on this machine with `-d:release`, single-threaded, on the
reference fixtures (pretrained ADs + MNIST). Nothing is committed: fixtures live
under `/tmp/bitbrain/BitBrain_C_code` (override with `$BITBRAIN_FIXTURES`).
### MNIST acceptance — exact reproduction of the reference
4 ADs × 2048 ADEs, widths `{6, 8, 10, 12}`, 6 cross-AD SBCs, 10 classes, one
online pass over 60,000 training samples, evaluated on all 10,000 test images:
| Reader | This library | Reference C |
|---|---:|---:|
| Corrected (clean-room, all row ADEs counted) | **97.210%** | 97.210% |
| Bug-compatible (`uint8_t bit_test`: only `i % 32 < 8`) | **96.540%** | 96.540% |
The bug-compatible mode reproduces the shipped reference's truncation bug exactly,
which proves the ADE scoring, the coincidence indexing and the idempotent SBC rule
are all correct. The default corrected reader is the one to use.
### Per-sample cost (reference MNIST configuration)
| Operation | Measured |
|---|---:|
| `learn` (4 ADs + 6 SBCs, dense active lists) | **0.384 ms/sample** |
| `infer` (corrected reader) | **0.630 ms/sample** |
| `infer` (bug-compatible reader) | 0.454 ms/sample |
All well inside this project's live budget of ~13.16 ms/tick. The reference C
measured ~0.2–0.56 ms/sample for the same geometry.
### Memory (measured via `sbcMemoryBytes` / `memoryBytes`)
The SBC tensor dominates: `nAde × nAde × nClasses` bits per SBC, rounded up to
`uint32` slots.
| Configuration | SBC tensors | ADs | Total |
|---|---:|---:|---:|
| Reference: 6 SBCs × 2048² × 10 | 31,457,280 B (30.0 MiB) | 360,448 B | 31,817,728 B (30.3 MiB) |
| Gun-sized: 6 SBCs × 512² × 8 | 1,572,864 B (1.5 MiB) | 90,112 B | 1,662,976 B (1.59 MiB) |
| 10 SBCs × 2048² × 10 (paper variant) | 52,428,800 B (50.0 MiB) | 360,448 B | 52,789,248 B (50.3 MiB) |
The reference configuration is L2/L3-hostile; a battle-sized configuration should
size `nAde` to the feature count, not copy 2048².
## Tests
```bash
# Unit / sanity suite (no fixtures required, ~3 s)
nim c -r --nimcache:/tmp/nc_j90 -d:release \
--path:common_libs common_libs/tests/test_bitbrain.nim
# MNIST acceptance harness (needs fixtures in /tmp; skips cleanly if absent)
nim c -r --nimcache:/tmp/nc_j90 -d:release --path:common_libs \
-o:/tmp/acceptance_bitbrain_mnist common_libs/tests/test_bitbrain_mnist.nim
```
The unit suite covers: hand-computed ADE scoring and the `>=` threshold, idempotent
SBC learning (learn one sample 1000× → bit-identical memory), a planted rule
learned near-perfectly with a monotone online accuracy curve, a shuffled-label
control that degrades to chance, unseen-input behaviour, homeostatic
threshold adaptation, and memory accounting.
+203
View File
@@ -0,0 +1,203 @@
## Address Decoder Element (ADE) layer — clean-room implementation.
##
## This module implements the ADE thresholded random projection as described in
##
## "BitBrain and Sparse Binary Coincidence (SBC) memories",
## Frontiers in Neuroinformatics 17:1125844, 2023.
##
## It was written **from the published algorithm description only**. No code was
## copied from the reference C program (`full_mnist_2048.c`) that ships alongside
## the paper, which is GPL-3.0-or-later, (c) 2022 The University of Manchester.
## Keeping this a clean-room implementation avoids imposing that licence on this
## repository. The published defaults (`scale = 64`, `center = 127`) make this
## module numerically identical to the reference's scoring rule.
##
## Mechanism
## ---------
## A *synapse* is `(inputIndex, polarity)`. An ADE with `width` synapses scores an
## input vector as
##
## raw = Σ_j polarity_j * (input[inputIndex_j] - center)
## score = scale * raw
##
## and **fires** iff `score >= threshold`. The reference C uses `scale = 64`
## and `center = 127` on raw `uint8` MNIST pixels, i.e. exactly the default
## parameters here. Multi-width ADs (the paper's best setup uses widths in
## {6, 8, 10, 12}) detect features at different scales.
##
## Storage
## -------
## Synapses are stored as a flat `seq[int32]` of signed 1-based codes: the sign
## is the polarity, `abs(code) - 1` is the 0-based input index. This is byte-for-
## byte the layout of the reference `AD*_2048` weight files, so a pretrained AD
## can be loaded with a single `memcpy`-equivalent read.
##
## Unsupervised phase (threshold homeostasis)
## ------------------------------------------
## The paper learns each ADE's homeostatic threshold so that it fires at a target
## rate (~1%). `accumulateFiring` + `adaptThresholds` below implement the
## paper's simple deterministic controller: over an interval of `interval` inputs
## the firing counts are compared against `targetRate * interval`, and each ADE's
## threshold is nudged up or down by `step`.
##
## The paper's *additional* unsupervised mechanisms are deliberately NOT
## implemented here (the task only requires them to be described):
##
## * **Hebbian "longevity" learning.** Each synapse carries a longevity
## counter. When an ADE fires, the smallest contributor to the threshold
## crossing has its longevity decremented and the largest contributor has it
## incremented. After an interval, any synapse whose longevity drops below a
## critical value is *retired* and replaced: a new input index is drawn by the
## same sampling mechanism used at initialisation, with longevity reset to
## the default. This lets each ADE "home in" on a feature. It is purely
## unsupervised.
## * **Metropolis-Hastings input-position sampling.** Synapse input positions
## are drawn from a distribution proportional to `sqrt` of the global input
## activity histogram (the `sqrt` both stabilises the Poisson uncertainty and
## flattens the distribution so that synapses can land slightly outside the
## training support). `initRandomAddressDecoder` instead samples uniformly at
## random, which is the paper's stated initialisation.
## * **Spatial clustering.** For image/volumetric data the sampled positions
## may be constrained to a local region (rejection sampling until the
## constraint holds) so each ADE sees a coherent receptive field.
import std/random
const
DefaultScale* = 64
## Score multiplier. 64 reproduces the reference C's `yang = ±64`.
DefaultCenter* = 127
## Input centre. 127 reproduces the reference C's `(pixel - 127)`.
type
AddressDecoder* = object
## An AD: `nAde` Address Decoder Elements, all of `width` synapses.
nAde*: int ## the paper's `w`
width*: int ## the paper's `n` (synapses per ADE)
codes*: seq[int32] ## nAde*width; sign = polarity, |code|-1 = input index
thresholds*: seq[int32] ## per-ADE firing thresholds
scale*: int ## score multiplier (default 64)
center*: int ## input centre (default 127)
fireCounts*: seq[int32] ## firing accumulator for threshold homeostasis
proc initAddressDecoder*(nAde, width: int, scale = DefaultScale,
center = DefaultCenter): AddressDecoder =
## Allocate an AD with zeroed synapses. Callers either fill `codes` directly
## (e.g. from a pretrained weight file) or use `initRandomAddressDecoder`.
doAssert nAde > 0, "nAde must be positive"
doAssert width > 0, "width must be positive"
result.nAde = nAde
result.width = width
result.scale = scale
result.center = center
result.codes = newSeq[int32](nAde * width)
result.thresholds = newSeq[int32](nAde)
result.fireCounts = newSeq[int32](nAde)
proc synCode(inputIdx: int, polarity: int8): int32 {.inline.} =
## Encode `(index, polarity)` as the reference's signed 1-based code.
doAssert inputIdx >= 0
doAssert polarity == 1'i8 or polarity == -1'i8
result = int32(polarity) * int32(inputIdx + 1)
proc initRandomAddressDecoder*(nAde, width, inputWidth: int, rng: var Rand,
scale = DefaultScale, center = DefaultCenter,
threshold: int32 = 0'i32): AddressDecoder =
## Randomly initialise an AD. For each ADE `width` **distinct** input indices
## are drawn uniformly from `0 ..< inputWidth` (multapses are disallowed, as in
## the paper) with a random ± polarity. Every ADE starts at `threshold`.
doAssert inputWidth >= width,
"cannot draw " & $width & " distinct indices from " & $inputWidth
result = initAddressDecoder(nAde, width, scale, center)
var chosen = newSeq[int32](width)
for i in 0 ..< nAde:
result.thresholds[i] = threshold
var k = 0
while k < width:
let idx = int32(rng.rand(inputWidth - 1))
var dup = false
for p in 0 ..< k:
if chosen[p] == idx:
dup = true
break
if not dup:
chosen[k] = idx
inc k
for j in 0 ..< width:
let pol = if rng.rand(1) == 0: 1'i8 else: -1'i8
result.codes[i * width + j] = synCode(int(chosen[j]), pol)
proc score*[T: SomeInteger](ad: AddressDecoder, input: openArray[T],
ade: int): int {.inline.} =
## Signed, centred, scaled score of ADE `ade` on `input`.
## `score = scale * Σ_j polarity_j * (input[idx_j] - center)`.
doAssert ade >= 0 and ade < ad.nAde
var raw = 0
let base = ade * ad.width
for j in 0 ..< ad.width:
let c = ad.codes[base + j]
let idx =
if c > 0'i32: int(c) - 1
else: int(-c) - 1
let pol = if c > 0'i32: 1 else: -1
raw += pol * (int(input[idx]) - ad.center)
result = ad.scale * raw
proc fires*[T: SomeInteger](ad: AddressDecoder, input: openArray[T],
ade: int): bool {.inline.} =
## An ADE fires iff its score reaches its threshold (`score >= threshold`).
score(ad, input, ade) >= int(ad.thresholds[ade])
proc activeList*[T: SomeInteger](ad: AddressDecoder, input: openArray[T],
active: var seq[int32]) =
## Fill `active` with the indices of the ADEs that fire on `input`.
## This is the sparse representation consumed by the SBC memory.
active.setLen(0)
for i in 0 ..< ad.nAde:
if fires(ad, input, i):
active.add int32(i)
proc fireCount*[T: SomeInteger](ad: AddressDecoder, input: openArray[T]): int =
## Number of ADEs in `ad` that fire on `input`.
for i in 0 ..< ad.nAde:
if fires(ad, input, i):
inc result
proc accumulateFiring*[T: SomeInteger](ad: var AddressDecoder,
input: openArray[T]) =
## Add this input's firing events to the homeostasis accumulator.
for i in 0 ..< ad.nAde:
if fires(ad, input, i):
inc ad.fireCounts[i]
proc resetFiringCounts*(ad: var AddressDecoder) =
## Zero the homeostasis accumulator (does not touch thresholds).
for i in 0 ..< ad.nAde:
ad.fireCounts[i] = 0
proc adaptThresholds*(ad: var AddressDecoder, interval: int, targetRate: float,
step: int = 1) =
## One step of the paper's homeostatic threshold controller.
##
## `fireCounts` is expected to hold the firing counts accumulated over the last
## `interval` inputs (see `accumulateFiring`). Each ADE whose observed rate is
## above/below `targetRate` has its threshold raised/lowered by `step`. The
## counters are then reset, ready for the next interval.
##
## This is a deterministic integer controller: given the same inputs and the
## same starting thresholds, it always produces the same thresholds.
doAssert interval > 0, "interval must be positive"
let expected = targetRate * float(interval)
for i in 0 ..< ad.nAde:
let excess = float(ad.fireCounts[i]) - expected
if excess > 0.5:
ad.thresholds[i] += int32(step)
elif excess < -0.5:
ad.thresholds[i] -= int32(step)
ad.fireCounts[i] = 0'i32
proc memoryBytes*(ad: AddressDecoder): int =
## Bytes held by this AD (synapse codes, thresholds, firing counters).
ad.codes.len * sizeof(int32) +
ad.thresholds.len * sizeof(int32) +
ad.fireCounts.len * sizeof(int32)
+136
View File
@@ -0,0 +1,136 @@
## BitBrain container — ADs + SBC memories + a counting readout.
##
## Clean-room implementation of the classification pipeline described in
##
## "BitBrain and Sparse Binary Coincidence (SBC) memories",
## Frontiers in Neuroinformatics 17:1125844, 2023.
##
## See `ade.nim` for the clean-room note (the reference C is GPL-3.0 and was not
## copied).
##
## The container holds several Address Decoders (possibly of different widths)
## and several SBC memories, each built from a pair of ADs. `learn` populates the
## SBCs from a labelled sample; `infer` drives every AD, reads every SBC with the
## same coincidence rule and sums the per-class set-bit counts. The class with the
## highest total wins. Everything is per-sample and order-free: the memory is
## monotone, so `learn` and `infer` may be interleaved arbitrarily.
##
## Reference setup used by the paper and the MNIST acceptance harness: 4 ADs of
## 2048 ADEs with widths {6, 8, 10, 12} and 6 cross-AD SBCs. The paper's
## 10-SBC variant adds 4 within-AD ("half-size") SBCs; `withinPairs` builds those
## (this implementation stores them full-size — the half-size packing is a
## separate memory optimisation).
import std/random
import ade, sbc
export ade, sbc
type
SbcSpec* = object
## Which pair of ADs feeds one SBC memory.
row*: int
col*: int
BitBrain* = object
ades*: seq[AddressDecoder]
sbcs*: seq[Sbc]
specs*: seq[SbcSpec]
nClasses*: int
proc crossPairs*(nAdes: int): seq[SbcSpec] =
## All unordered pairs of distinct ADs. For 4 ADs this is the paper's 6 SBCs.
for a in 0 ..< nAdes:
for b in a + 1 ..< nAdes:
result.add SbcSpec(row: a, col: b)
proc withinPairs*(nAdes: int): seq[SbcSpec] =
## The square/within-AD pairs (the paper's "half-size" SBCs).
for a in 0 ..< nAdes:
result.add SbcSpec(row: a, col: a)
proc initBitBrain*(ades: seq[AddressDecoder], specs: seq[SbcSpec],
nClasses: int): BitBrain =
## Build the container. Every AD must have the same number of ADEs because an
## SBC's axes are both `w` long (the paper's setup).
doAssert ades.len > 0, "need at least one AD"
doAssert nClasses > 0, "need at least one class"
let w = ades[0].nAde
for ad in ades:
doAssert ad.nAde == w, "all ADs must have the same number of ADEs"
result.ades = ades
result.nClasses = nClasses
result.specs = specs
result.sbcs = newSeq[Sbc](specs.len)
for s in 0 ..< specs.len:
doAssert specs[s].row >= 0 and specs[s].row < ades.len
doAssert specs[s].col >= 0 and specs[s].col < ades.len
result.sbcs[s] = initSbc(w, nClasses)
proc buildRandomBitBrain*(widths: seq[int], nAde, inputWidth, nClasses: int,
seed: int64,
specs: seq[SbcSpec] = @[]): BitBrain =
## Initialise a whole BitBrain with random ADs. If `specs` is empty the
## paper's 6 cross-AD SBCs are used. Fully deterministic given `seed`.
var rng = initRand(seed)
var ades: seq[AddressDecoder]
for w in widths:
ades.add initRandomAddressDecoder(nAde, w, inputWidth, rng)
let pairs = if specs.len == 0: crossPairs(widths.len) else: specs
result = initBitBrain(ades, pairs, nClasses)
proc nAdes*(bb: BitBrain): int {.inline.} =
bb.ades.len
proc resetLearning*(bb: var BitBrain) =
## Wipe every SBC. The ADs (and their thresholds) are left untouched.
for s in 0 ..< bb.sbcs.len:
bb.sbcs[s].clear()
proc fireInto*[T: SomeInteger](bb: BitBrain, input: openArray[T],
lists: var seq[seq[int32]]) =
## Compute the sparse firing pattern of every AD for `input`. `lists` is resized
## to `nAdes` and each entry is filled with that AD's firing ADE indices.
if lists.len != bb.ades.len:
lists.setLen(bb.ades.len)
for a in 0 ..< bb.ades.len:
bb.ades[a].activeList(input, lists[a])
proc learn*[T: SomeInteger](bb: var BitBrain, input: openArray[T], class: int) =
## One online supervised step: set the `class` bit of every observed
## coincidence. Idempotent — repeating the same sample is a no-op.
var lists: seq[seq[int32]]
bb.fireInto(input, lists)
for s in 0 ..< bb.sbcs.len:
let spec = bb.specs[s]
discard bb.sbcs[s].learn(lists[spec.row], lists[spec.col], class)
proc infer*[T: SomeInteger](bb: BitBrain, input: openArray[T]):
tuple[label: int, counts: seq[int]] =
## Drive every AD, count set class bits in every SBC, and return the argmax
## class plus the aggregated per-class counts. Ties go to the lowest class
## index (matching the reference's `>` scan that keeps the first maximum).
var lists: seq[seq[int32]]
bb.fireInto(input, lists)
result.counts = newSeq[int](bb.nClasses)
for s in 0 ..< bb.sbcs.len:
let spec = bb.specs[s]
bb.sbcs[s].infer(lists[spec.row], lists[spec.col], result.counts)
result.label = 0
var best = result.counts[0]
for k in 1 ..< bb.nClasses:
if result.counts[k] > best:
best = result.counts[k]
result.label = k
proc memoryBytes*(bb: BitBrain): int =
## Total bytes: AD synapse codes + thresholds + counters + SBC bit tensors.
for ad in bb.ades:
result += ad.memoryBytes
for s in bb.sbcs:
result += s.memoryBytes
proc sbcMemoryBytes*(bb: BitBrain): int =
## Just the SBC bit tensors — the dominant term for realistic configurations.
for s in bb.sbcs:
result += s.memoryBytes
+114
View File
@@ -0,0 +1,114 @@
## Sparse Binary Coincidence (SBC) memory — clean-room implementation.
##
## Implements the supervised half of the BitBrain algorithm as described in
##
## "BitBrain and Sparse Binary Coincidence (SBC) memories",
## Frontiers in Neuroinformatics 17:1125844, 2023.
##
## Written from the published algorithm only; see `ade.nim` for the clean-room
## note. The reference C is GPL-3.0 (c) University of Manchester and was not
## copied.
##
## Mechanism
## ---------
## Two Address Decoders (ADs) sit on the two axes of a 2-D memory. A pair of
## *simultaneously firing* ADEs `(i, j)` is a **coincidence** and addresses one
## memory cell. That cell holds a class bitmask with one bit per class
## (`nClasses` bits, one-hot encoding in the paper's default).
##
## Learning is **idempotent**: `learn` *sets* the bit for the observed class;
## setting it again is a no-op. There is no clearing, no learning rate, no decay
## and no epoch — one pass through the data is a complete supervised training run,
## and a second pass changes nothing.
##
## Inference uses the *same* address decoding: for each observed coincidence every
## class bit is read and the set bits are **counted** per class. Counts are summed
## across SBCs by the `bitbrain` container and the argmax wins.
##
## Bit layout
## ----------
## The bit for `(i, j, class)` lives at `((i * nAde) + j) * nClasses + class`, so
## all `nClasses` bits of one coincidence are contiguous. This differs from the
## reference C's layout but is a bijection onto the same set of triples; the
## learned rule is identical.
import std/bitops
type
Sbc* = object
## A 2-D coincidence memory with a class-bit depth.
nAde*: int ## number of ADEs on each axis (the paper's `w`)
nClasses*: int ## number of classes (the paper's `D`)
bits*: seq[uint32] ## packed bit tensor, nAde*nAde*nClasses bits
proc initSbc*(nAde, nClasses: int): Sbc =
## Allocate a zeroed SBC (nothing is known yet).
doAssert nAde > 0, "nAde must be positive"
doAssert nClasses > 0, "nClasses must be positive"
result.nAde = nAde
result.nClasses = nClasses
let nbits = nAde * nAde * nClasses
result.bits = newSeq[uint32]((nbits + 31) div 32)
proc clear*(sbc: var Sbc) =
## Forget everything. This is how a monotone memory is wiped (e.g. when a bot
## switches enemy and must not carry state across battles).
for i in 0 ..< sbc.bits.len:
sbc.bits[i] = 0'u32
proc bitIndex(sbc: Sbc, i, j, class: int): int {.inline.} =
((i * sbc.nAde) + j) * sbc.nClasses + class
proc bitAt*(sbc: Sbc, i, j, class: int): bool {.inline.} =
## Read one memory bit. Exposed mainly so harnesses can inspect the exact rule.
doAssert i >= 0 and i < sbc.nAde
doAssert j >= 0 and j < sbc.nAde
doAssert class >= 0 and class < sbc.nClasses
let bit = bitIndex(sbc, i, j, class)
(sbc.bits[bit shr 5] and (1'u32 shl (bit and 31))) != 0'u32
proc learn*(sbc: var Sbc, rowActive, colActive: openArray[int32],
class: int): int =
## Set the `class` bit for every coincidence between a firing row ADE and a
## firing column ADE. Returns the number of bits that were newly set (0 if the
## sample added no information, e.g. it was already learned).
doAssert class >= 0 and class < sbc.nClasses, "class out of range"
let D = sbc.nClasses
for r in rowActive:
let i = int(r)
for c in colActive:
let j = int(c)
let bit = ((i * sbc.nAde) + j) * D + class
let w = bit shr 5
let m = 1'u32 shl (bit and 31)
if (sbc.bits[w] and m) == 0'u32:
sbc.bits[w] = sbc.bits[w] or m
inc result
proc infer*(sbc: Sbc, rowActive, colActive: openArray[int32],
counts: var seq[int]) =
## Count, per class, how many observed coincidences have their class bit set.
## `counts` is *accumulated into* (not reset), so a container can sum several
## SBCs. It must be at least `nClasses` long.
doAssert counts.len >= sbc.nClasses, "counts buffer too small"
let D = sbc.nClasses
for r in rowActive:
let i = int(r)
for c in colActive:
let j = int(c)
let base = ((i * sbc.nAde) + j) * D
for k in 0 ..< D:
let bit = base + k
if (sbc.bits[bit shr 5] and (1'u32 shl (bit and 31))) != 0'u32:
inc counts[k]
proc memoryBytes*(sbc: Sbc): int =
## Bytes held by the packed bit tensor.
sbc.bits.len * sizeof(uint32)
proc occupancy*(sbc: Sbc): float =
## Fraction of the bit tensor that is set (diagnostic).
var setBits = 0
for w in sbc.bits:
setBits += countSetBits(w)
result = float(setBits) / float(sbc.nAde * sbc.nAde * sbc.nClasses)