File size: 15,955 Bytes
ad474c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
"""Binary hypervector algebra for HDCSTORAGE.

Everything in this machine is a *bit-packed hypervector*: a Python ``int``
interpreted as ``d`` bits, bit 0 being the least significant.  Representation
choices, and why:

* **No numpy.**  Dense HDC is normally an array of +/-1 floats or of packed
  uint64 words.  A Python ``int`` of 10 000 bits is one object whose bitwise
  ops run in C over whole machine words, so ``bind`` is a single ``^`` and
  ``popcount`` is ``int.bit_count()``.  Memory per vector is d/8 bytes plus
  object overhead, no dependency, and no per-element Python loop.
* **Binary spatter codes (BSC).**  bind = XOR (self-inverse, so unbind ==
  bind), bundle = per-bit majority, permute = rotate within the d-bit mask.

Algebraic facts this module relies on, used everywhere downstream:

    bind(a, b) ^ b == a                       (self-inverse)
    sim(bind(a, x), bind(b, x)) ~= sim(a, b)  (binding preserves similarity)
    sim(permute(a, k), permute(b, k)) == sim(a, b)
    sim(bundle(S), x) ~ sqrt(1/|S|)	for x in S  -> cleanup recovers members
    E[sim(a, b)] == 0 for independent random a, b (sd ~ 1/sqrt(d))

The last line is the reason d is large: it sets the noise floor that cleanup
memory and the instruction decoder have to beat.
"""

from __future__ import annotations

import hashlib
import math
from typing import Iterable, Sequence

__all__ = [
    "DEFAULT_DIM",
    "mask",
    "popcount",
    "hamming",
    "random_hv",
    "hvc",
    "bind",
    "unbind",
    "bundle",
    "permute",
    "permute_inv",
    "sim",
    "sim_matrix",
    "int_to_hv",
    "hv_to_int",
    "hv_bits_of_int",
    "hv_from_bits",
    "hv_to_bits",
    "bit_probe",
    "shift",
    "CODEC_THRESHOLD",
    "MAX_INT_BITS",
    "density",
    "bits",
    "bitplanes",
    "role",
    "ROLES",
    "clear_cache",
]

#: Default dimensionality.  10 000 bits is the classic Kanerva/VSA size: the
#: noise floor 1/sqrt(d) ~ 0.01 is far below the ~0.4 signal of a bundle
#: member, so 4-6 field records decode reliably while 1 250 bytes per vector
#: still fits a program image in a few hundred kilobytes.
DEFAULT_DIM = 10_000


# --------------------------------------------------------------------------
# primitive bit operations
# --------------------------------------------------------------------------
def mask(d: int = DEFAULT_DIM) -> int:
    """The all-ones mask covering ``d`` bits."""
    return (1 << d) - 1


def popcount(x: int) -> int:
    """Number of set bits."""
    return x.bit_count()


def hamming(a: int, b: int) -> int:
    """Hamming distance between two hypervectors."""
    return (a ^ b).bit_count()


def density(v: int, d: int = DEFAULT_DIM) -> float:
    """Fraction of set bits (should hover near 0.5 for random vectors)."""
    return popcount(v & mask(d)) / d


def bits(v: int, d: int = DEFAULT_DIM) -> str:
    """Bit string, most-significant bit first (the usual left-to-right view)."""
    return format(v & mask(d), "0%db" % d)


def bitplanes(v: int, d: int = DEFAULT_DIM, width: int = 64) -> list[str]:
    """Split a vector into fixed-width bit planes, WSB first."""
    s = bits(v, d)
    return [s[i : i + width] for i in range(0, len(s), width)]


# --------------------------------------------------------------------------
# deterministic symbol -> hypervector
# --------------------------------------------------------------------------
_CACHE: dict[tuple[str, int], int] = {}


def clear_cache() -> None:
    _CACHE.clear()


def hvc(name: str, d: int = DEFAULT_DIM) -> int:
    """Deterministic hypervector for a symbol name.

    The same name always maps to the same vector, in-process and across runs,
    on any machine.  That is what makes a stored program image portable: the
    symbolic layer (opcodes, register names, literals, tool names) is
    *regenerated* from names rather than serialised, and only the composed
    instruction vectors live in the storage file.

    Uses BLAKE2b expanded over a 4-byte block counter, then masked to d bits.
    """
    key = (name, d)
    cached = _CACHE.get(key)
    if cached is not None:
        return cached
    out = 0
    block = 0
    h = hashlib.blake2b(name.encode("utf-8"), digest_size=64)
    seed = h.digest()
    while out.bit_length() < d:
        chunk = hashlib.blake2b(
            seed + block.to_bytes(4, "little"), digest_size=64
        ).digest()
        out = (out << 512) | int.from_bytes(chunk, "big")
        block += 1
    out &= mask(d)
    _CACHE[key] = out
    return out


def random_hv(d: int = DEFAULT_DIM, rng=None) -> int:
    """Random balanced hypervector (about d/2 set bits)."""
    if rng is None:
        import random

        rng = random
    return rng.getrandbits(d) & mask(d)


# Roles: named slots used to build records.  BSC's canonical record is
#   record = bundle([bind(role_i, value_i) for i in fields])
# and a field is read back with bind(record, role_i) -> noisy value_i.
ROLES: dict[str, str] = {
    "op": "role/operation",
    "ra": "role/dest-register",
    "rb": "role/source-register-b",
    "rc": "role/source-register-c",
    "imm": "role/immediate",
    "tgt": "role/target-pc",
    "addr": "role/address",
    "val": "role/value",
    "name": "role/name",
    "cap": "role/capability",
    "doc": "role/documentation",
    "arity": "role/arity",
    "tool": "role/tool",
    "arg": "role/argument",
    "tag": "role/tag",
    "kind": "role/kind",
}


def role(name: str, d: int = DEFAULT_DIM) -> int:
    """Hypervector for a role slot (``"op"``, ``"ra"``, ...)."""
    return hvc(ROLES.get(name, name), d)


# --------------------------------------------------------------------------
# the algebra
# --------------------------------------------------------------------------
def bind(a: int, b: int, d: int | None = None) -> int:
    """Binding = XOR.  Involutive: ``bind(bind(a, b), b) == a``."""
    v = a ^ b
    return v & mask(d) if d is not None else v


def unbind(record: int, key: int, d: int | None = None) -> int:
    """Unbinding is binding (XOR is self-inverse); named for readability."""
    return bind(record, key, d)


def permute(v: int, k: int = 1, d: int = DEFAULT_DIM) -> int:
    """Cyclic bit rotation left by ``k`` (a rotation, so no bits are lost).

    Permutation is how this machine encodes *order*: position p of a sequence
    is ``permute(item, p)``, which keeps items near-orthogonal while making
    "same item, different position" a different address.
    """
    m = mask(d)
    v &= m
    k %= d
    if k == 0:
        return v
    return ((v << k) | (v >> (d - k))) & m


def permute_inv(v: int, k: int = 1, d: int = DEFAULT_DIM) -> int:
    """Inverse rotation (rotate right by ``k``)."""
    return permute(v, -k, d)


def _add_bitplane(plates: list[int], v: int) -> None:
    """Ripple-carry add of one bit-plane into a binary counter."""
    i = 0
    carry = v
    while carry:
        if i == len(plates):
            plates.append(carry)
            return
        nxt = plates[i] & carry
        plates[i] ^= carry
        carry = nxt
        i += 1


def _plane_ge(plates: Sequence[int], threshold: int, d: int) -> int:
    """Bit-sliced ``count >= threshold`` over the counter planes.

    Walks planes from most to least significant holding one predicate, ``eq``
    ("equal to the threshold so far"), and one accumulator, ``gt`` ("already
    greater, therefore certainly >= threshold").  Lanes that fall below the
    threshold at some position are dropped from ``eq`` and die there, which is
    exactly a ripple comparator built from AND/NOT — the same gate logic a
    hardware majority element uses, widened to d lanes at once.
    """
    m = mask(d)
    nbits = max(len(plates), threshold.bit_length()) or 1
    eq = m
    gt = 0
    for i in range(nbits - 1, -1, -1):
        p = plates[i] if i < len(plates) else 0
        if (threshold >> i) & 1:
            eq &= p  # count bit 0 where threshold bit is 1  ->  too small
        else:
            gt |= eq & p  # count bit 1 where threshold bit is 0  ->  greater
            eq &= ~p & m
        if eq == 0:
            break
    return (gt | eq) & m


def bundle(vecs: Iterable[int], d: int = DEFAULT_DIM) -> int:
    """Majority bundling: per-bit vote, ties resolve to 0.

    Implemented bit-sliced: every vector is added into a d-lane binary
    counter (one Python int per bit position of the count), then a single
    vectorised comparator thresholds it at ``floor(n/2)+1``.  Cost is
    O(n * log n) big-int operations rather than O(n * d) element visits,
    which is what keeps a 10 000-bit bundle at tens of microseconds.
    """
    items = list(vecs)
    if not items:
        return 0
    if len(items) == 1:
        return items[0] & mask(d)
    plates: list[int] = []
    for v in items:
        _add_bitplane(plates, v & mask(d))
    return _plane_ge(plates, len(items) // 2 + 1, d)


def sim(a: int, b: int, d: int = DEFAULT_DIM) -> float:
    """Cosine-style similarity in [-1, 1]: agreement of bits, rescaled.

    ``sim = (d - 2*hamming) / d``; +1 identical, 0 orthogonal, -1 complement.
    """
    if d <= 0:
        raise ValueError("d must be positive")
    return (d - 2 * (a ^ b).bit_count()) / d


def sim_matrix(vecs: Sequence[int], d: int = DEFAULT_DIM) -> list[list[float]]:
    """All-pairs similarity matrix."""
    return [[sim(a, b, d) for b in vecs] for a in vecs]


# --------------------------------------------------------------------------
# integers stored *as* hypervectors
# --------------------------------------------------------------------------
# A scalar is represented by superposition of bit-position atoms:
#   int_to_hv(n) = bundle([bind(NUM, permute(BITBASE, i)) for i where n has bit i])
# so an integer occupies a vector like every other value and can be recovered
# by cleanup (one similarity probe per bit position).  This is the pixel that
# makes "all state is hyperdimensional" true rather than decorative.
#
# The layout is **two's complement**, 32 bits: bit MAX_INT_BITS-1 is the sign,
# and a negative value is dense where a sign-magnitude encoding would be
# sparse.  That choice is what lets arithmetic happen as a ripple-carry
# circuit over the bit atoms (see hdc/adder.py): two's complement makes
# addition a pure function of the bits, with no sign case analysis anywhere.
_NUM = hvc("codec/integer")
_BITBASE = hvc("codec/bit-position")

#: Width of the integer payload, in bits, two's complement.
MAX_INT_BITS = 32

#: Similarity above which a bit atom counts as present in a bundle.
#:
#: Measured on this implementation (40 random atom subsets per size, both
#: dimensions): a member of a k-atom bundle recovers at 1/sqrt(k) — 0.35 at
#: k=4 down to 0.136 at k=32 — while a non-member stays below 0.044 at
#: d=10 000 and below 0.093 at d=2 000.  0.10 is the value that separates all
#: of those cases and round-trips 415/415 sampled 32-bit values at both
#: dimensions; d=2 000 is therefore the supported floor for the codec.
CODEC_THRESHOLD = 0.10

#: Smallest dimension at which the integer codec round-trips reliably.
#:
#: Measured over all 256 byte values plus 200 random bytes: 0 errors at
#: d=1500, 2000 and 3000; **60 errors at d=1000** and 401 at d=500, with a
#: worst delta of 512.  Below this floor the bit atoms crosstalk enough that a
#: decode silently returns a neighbouring value, which would corrupt every
#: threshold and shift downstream, so the codec refuses instead.
MIN_CODEC_DIM = 1500


def _check_dim(d: int) -> None:
    if d < MIN_CODEC_DIM:
        raise ValueError(
            "integer codec needs d >= %d (got %d): bit atoms crosstalk below that "
            "and values decode wrong" % (MIN_CODEC_DIM, d)
        )


def _bit_probe(i: int, d: int) -> int:
    return bind(_NUM, permute(_BITBASE, i, d), d)


def bit_probe(i: int, d: int = DEFAULT_DIM) -> int:
    """The atom for bit position ``i``, for probing a coded value directly.

    Exposed because other layers (the shader) need single-bit tests — sign,
    ordering — without decoding the whole integer.
    """
    return _bit_probe(i, d)


def int_to_hv(n: int, d: int = DEFAULT_DIM) -> int:
    """Encode a signed 32-bit integer as a hypervector (two's complement).

    Zero encodes to the all-zero vector (the empty bundle), so a cleared
    register is literally a vector with no atoms in it.

    This codec is *symbolic, not similarity-preserving*: two integers that
    share set bits share bit-position atoms, so ``sim(int_to_hv(5),
    int_to_hv(37))`` is high even though 5 and 37 have nothing to do with each
    other.  That is deliberate — decode needs the atoms to be shared and
    probeable, exactly like a bit field.  Do not read a distance between two
    encoded integers as a semantic relationship, and do not expect binding two
    of them to decorrelate the way binding two random vectors does.
    """
    _check_dim(d)
    lo, hi = -(1 << (MAX_INT_BITS - 1)), (1 << (MAX_INT_BITS - 1)) - 1
    if not lo <= n <= hi:
        raise ValueError("integer outside the %d-bit codec range: %r" % (MAX_INT_BITS, n))
    return hv_from_bits(hv_bits_of_int(n), d)


def hv_bits_of_int(n: int) -> list[int]:
    """The two's complement bit pattern of an integer, least significant first."""
    pattern = n & ((1 << MAX_INT_BITS) - 1)
    return [(pattern >> i) & 1 for i in range(MAX_INT_BITS)]


def hv_from_bits(bits: Sequence[int], d: int = DEFAULT_DIM) -> int:
    """Assemble a hypervector from bit values, purely by bundling atoms.

    No integer arithmetic is involved: each set position contributes its atom
    to a majority bundle.  Two's complement sign extension is the caller's
    concern, exactly as it is in hardware.
    """
    parts = [_bit_probe(i, d) for i, bit in enumerate(bits) if bit]
    return bundle(parts, d) if parts else 0


def hv_to_bits(v: int, d: int = DEFAULT_DIM, threshold: float = CODEC_THRESHOLD) -> list[int]:
    """Read the bit pattern back with one similarity probe per position."""
    return [1 if sim(v, _bit_probe(i, d), d) >= threshold else 0 for i in range(MAX_INT_BITS)]


def shift(v: int, k: int, d: int = DEFAULT_DIM) -> int:
    """Multiply or divide a coded integer by 2**k, without decoding it.

    This works only because the bit atoms are a *permutation orbit*:
    ``atom(i) = NUM ^ perm(BITBASE, i)``, so rotating the whole vector by k
    rotates every atom's index by k.  Rotation commutes with bundling (it is a
    bit permutation, and majority is per-bit), so

        rotate(hv, k) = bundle_i( perm(NUM,k) ^ atom(i+k) )

    and binding away the ``perm(NUM,k) ^ NUM`` offset leaves exactly the
    encoding of the shifted value.  Two XORs and two rotations, no similarity
    probe, no threshold, no decode.

    Positive ``k`` shifts left (multiply), negative shifts right (divide).
    Exact for non-negative values; for negative values the shift is logical,
    so the sign bit is not extended.
    """
    if k == 0:
        return v
    return bind(permute(v, k, d), permute(_NUM, k, d) ^ _NUM, d)


def hv_to_int(v: int, d: int = DEFAULT_DIM, threshold: float = CODEC_THRESHOLD) -> int:
    """Decode an integer hypervector by per-bit cleanup (two's complement)."""
    _check_dim(d)
    m = 0
    for i, bit in enumerate(hv_to_bits(v, d, threshold)):
        if bit:
            m |= 1 << i
    if m & (1 << (MAX_INT_BITS - 1)):
        m -= 1 << MAX_INT_BITS
    return m


# --------------------------------------------------------------------------
# diagnostics
# --------------------------------------------------------------------------
def capacity_bits(d: int = DEFAULT_DIM) -> float:
    """Information content of one vector, in bits (log2 of sign patterns)."""
    return float(d)


def orthogonal_capacity(d: int = DEFAULT_DIM, noise: float = 0.35) -> float:
    """Rough count of distinguishable near-orthogonal items (~ d / (2 log d))."""
    return d / (2.0 * math.log2(d))