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
+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)