hdcstorage / hdc /adder.py
AEUPH's picture
HDCSTORAGE: hyperdimensional computer, webOS and agentic MCP layer (stdlib-only Python)
ad474c9 verified
Raw History Blame Contribute Delete
14.8 kB
"""A hyperdimensional full adder.
The integer ALU used to decode a register to a Python ``int``, add, and
re-encode. This module removes that step for ``ADD`` and ``SUB``: the sum is
computed by a ripple-carry circuit whose primitive is *binding plus cleanup*,
which is the only thing a hypervector machine has.
The circuit
-----------
A number is a superposition of bit-position atoms (``int_to_hv``). Reading a
bit is a similarity probe; that is unavoidable, and it is what a bit line is
in hardware:
bit_i(n) == sim(hv, bind(NUM, perm(BITBASE, i))) >= CODEC_THRESHOLD
A full-adder row is the classic 8-entry truth table, and each row is stored as
a record in HDC storage:
row = bundle([
bind(role("key"), key_of(a, b, c)),
bind(role("sum"), atom("s", a ^ b ^ c)),
bind(role("carry"), atom("k", majority(a, b, c))),
])
Evaluating one stage is a *content-addressable lookup*: form the query key,
and ask storage which row it resembles. Each stage reports the similarity it
resolved at, so a marginal carry chain is visible rather than assumed.
Why the key needs one atom pair **per slot**
-------------------------------------------
XOR is associative and commutative, so a key built as
``bind(bind(atom_a, atom_b), atom_c)`` only ever sees
``atom_a ^ atom_b ^ atom_c``. With a single shared pair for zero and one that
is *parity*: ``1^1^1`` and ``1^0^0`` both collapse to the same vector, and the
8-row table degenerates to 2 distinguishable entries. Tagging each input with
a role before the XOR does not help, for the same reason — the role constants
cancel into a fixed mask.
Giving each slot its own atom pair (``gate/atom/a/0``, ``gate/atom/a/1``, ...)
makes the eight keys XOR combinations of *independently chosen* constants, so
they are pairwise near-orthogonal and cleanup resolves them exactly. That is
the same distinction as using distinct address lines rather than one shared
bit line.
Subtraction is addition of the complement with carry-in 1 — two's complement,
so there is no sign special case anywhere: ``a - b == a + ~b + 1`` and the
32-bit wrap is whatever falls out of dropping the final carry.
Cost: 32 stages x (8 similarity probes + 4 atom probes) at d=10 000 is roughly
0.9 ms per operation, versus ~0.05 ms for the old decode-and-add. That is the
price of keeping the arithmetic in the vector domain, and it is stated in the
README rather than hidden.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
from .hypervec import (
CODEC_THRESHOLD,
DEFAULT_DIM,
MAX_INT_BITS,
bind,
bundle,
hvc,
hv_bits_of_int,
hv_from_bits,
hv_to_bits,
hv_to_int,
role,
sim,
)
from .memory import HDCStorage
PREFIX = "adder/row/"
#: Gate slots: two inputs, the carry in, then the sum and carry out.
GATE_SLOTS = ("a", "b", "c", "s", "k")
class AdderError(Exception):
"""The adder could not resolve a truth-table row."""
def wrap(value: int, width: int = MAX_INT_BITS) -> int:
"""Wrap an integer into a ``width``-bit two's complement range."""
span = 1 << width
return (value + (span >> 1)) % span - (span >> 1)
@dataclass(frozen=True)
class Stage:
"""One bit slice of the carry chain, as executed."""
index: int
a: int
b: int
carry_in: int
total: int
carry_out: int
confidence: float
def render(self) -> str:
return (
"bit %2d a=%d b=%d cin=%d -> sum=%d cout=%d (lookup sim %.3f)"
% (self.index, self.a, self.b, self.carry_in, self.total, self.carry_out,
self.confidence)
)
@dataclass(frozen=True)
class Row:
"""One truth-table row, and the record that stores it."""
index: int
a: int
b: int
c: int
total: int
carry: int
key: int
cell: str
def render(self) -> str:
return "a=%d b=%d cin=%d | sum=%d carry=%d | %s" % (
self.a, self.b, self.c, self.total, self.carry, self.cell
)
class HyperAdder:
"""Ripple-carry adder whose gates are HDC bindings and cleanups."""
def __init__(
self,
d: int = DEFAULT_DIM,
storage: HDCStorage | None = None,
width: int = MAX_INT_BITS,
threshold: float = CODEC_THRESHOLD,
radix: int = 1,
):
self.d = d
self.width = min(width, MAX_INT_BITS)
self.threshold = threshold
#: Bits per stage. 1 is the 8-row full adder (32 stages); 8 is a
#: 131 072-row byte gate (4 stages), materialised lazily.
self.radix = max(1, min(radix, 8))
#: One atom pair per slot — see the module docstring on why sharing
#: a single pair would collapse the key to parity.
self.atoms = {
(slot, bit): hvc("gate/atom/%s/%d" % (slot, bit), d)
for slot in GATE_SLOTS
for bit in (0, 1)
}
self.storage = storage if storage is not None else HDCStorage(
d=d, capacity=64, label="adder"
)
self.rows: list[Row] = []
self.last_stages: list = []
self.stages_executed = 0
self.gate = None
if self.radix == 1:
self._build_table()
else:
from .slicegate import SliceGate
self.gate = SliceGate(radix=self.radix, d=d, threshold=threshold)
# ------------------------------------------------------------ the gate
def atom(self, slot: str, bit: int) -> int:
"""The vector that represents ``bit`` in a given gate slot."""
return self.atoms[(slot, 1 if bit else 0)]
def key_of(self, a: int, b: int, c: int) -> int:
"""Exact gate address: XOR of three role-specific atoms."""
return bind(
bind(self.atom("a", a), self.atom("b", b), self.d),
self.atom("c", c),
self.d,
)
def _build_table(self) -> None:
"""Materialise the 8 full-adder rows as cells in HDC storage."""
for a in (0, 1):
for b in (0, 1):
for c in (0, 1):
total = a ^ b ^ c
carry = 1 if (a + b + c) >= 2 else 0
key = self.key_of(a, b, c)
record = bundle(
[
bind(role("key", self.d), key, self.d),
bind(role("sum", self.d), self.atom("s", total), self.d),
bind(role("carry", self.d), self.atom("k", carry), self.d),
],
self.d,
)
cell = PREFIX + "%d%d%d" % (a, b, c)
self.storage.write(cell, record, symbol=True)
self.rows.append(
Row(a * 4 + b * 2 + c, a, b, c, total, carry, key, cell)
)
def _pick(self, probe: int, slot: str) -> tuple[int, float]:
"""Cleanup an output field to 0 or 1; returns (bit, margin)."""
s0 = sim(probe, self.atom(slot, 0), self.d)
s1 = sim(probe, self.atom(slot, 1), self.d)
if s1 >= s0:
return 1, s1 - s0
return 0, s0 - s1
def full_adder(self, a: int, b: int, c: int) -> tuple[int, int, float]:
"""One stage: bind the inputs, clean up over the 8 stored rows."""
query = self.key_of(a, b, c)
# The stored row holds bind(role("key"), key), so the probe carries the
# same role; a bare key against the bundle would sit at the noise floor.
probe = bind(role("key", self.d), query, self.d)
hits = self.storage.recall(probe, k=1, prefix=PREFIX)
if not hits or hits[0][2] < 0.20:
raise AdderError(
"no truth-table row resolved (best similarity %.3f)"
% (hits[0][2] if hits else -1.0)
)
record, confidence = hits[0][1], hits[0][2]
total, _ = self._pick(bind(record, role("sum", self.d), self.d), "s")
carry, _ = self._pick(bind(record, role("carry", self.d), self.d), "k")
return total, carry, confidence
# --------------------------------------------------------- the ripple
def add_bits(
self, a_bits: Sequence[int], b_bits: Sequence[int], carry_in: int = 0
) -> tuple[list[int], list]:
"""Carry across the operand, one gate stage at a time."""
if self.gate is not None:
return self._add_sliced(a_bits, b_bits, carry_in)
carry = 1 if carry_in else 0
out: list[int] = []
stages: list[Stage] = []
for i in range(self.width):
# A short slice is zero-extended, exactly as a partial bit vector
# is on a real adder input.
a = 1 if i < len(a_bits) and a_bits[i] else 0
b = 1 if i < len(b_bits) and b_bits[i] else 0
total, carry_next, confidence = self.full_adder(a, b, carry)
stages.append(Stage(i, a, b, carry, total, carry_next, confidence))
out.append(total)
carry = carry_next
self.stages_executed += len(stages)
self.last_stages = stages
return out, stages
def _add_sliced(
self, a_bits: Sequence[int], b_bits: Sequence[int], carry_in: int
) -> tuple[list[int], list]:
"""Width-k stages: each gate call consumes ``radix`` bits plus carry."""
from .slicegate import SliceStep
k = self.radix
out = [0] * self.width
steps: list[SliceStep] = []
carry = 1 if carry_in else 0
for start in range(0, self.width, k):
a_val = 0
b_val = 0
for i in range(k):
pos = start + i
if pos < self.width:
a_val |= (1 if pos < len(a_bits) and a_bits[pos] else 0) << i
b_val |= (1 if pos < len(b_bits) and b_bits[pos] else 0) << i
total, carry_next, confidence = self.gate.evaluate(a_val, b_val, carry)
for i in range(k):
pos = start + i
if pos < self.width:
out[pos] = (total >> i) & 1
steps.append(
SliceStep(start, a_val, b_val, carry, total, carry_next, confidence, k)
)
carry = carry_next
self.stages_executed += len(steps)
self.last_stages = steps
return out, steps
# -------------------------------------------------------- public API
def add(self, a: int, b: int) -> int:
"""Integer operands in, hypervector out."""
bits, _ = self.add_bits(hv_bits_of_int(a), hv_bits_of_int(b), 0)
return hv_from_bits(bits, self.d)
def sub(self, a: int, b: int) -> int:
"""``a - b`` as ``a + ~b + 1``, with no sign case analysis."""
inverted = [0 if bit else 1 for bit in hv_bits_of_int(b)]
bits, _ = self.add_bits(hv_bits_of_int(a), inverted, 1)
return hv_from_bits(bits, self.d)
def add_hv(self, a_hv: int, b_hv: int) -> int:
"""Hypervector in, hypervector out — no integer is materialised."""
bits, _ = self.add_bits(
hv_to_bits(a_hv, self.d, self.threshold),
hv_to_bits(b_hv, self.d, self.threshold),
0,
)
return hv_from_bits(bits, self.d)
def sub_hv(self, a_hv: int, b_hv: int) -> int:
"""As :meth:`add_hv`, via the complement and carry-in 1."""
b_bits = hv_to_bits(b_hv, self.d, self.threshold)
bits, _ = self.add_bits(
hv_to_bits(a_hv, self.d, self.threshold),
[0 if bit else 1 for bit in b_bits],
1,
)
return hv_from_bits(bits, self.d)
def decode(self, hv: int) -> int:
"""Decode to an integer — for display and for verifying the circuit."""
return hv_to_int(hv, self.d, self.threshold)
# ------------------------------------------------------------- views
def table_text(self) -> str:
rows = ["full-adder truth table, stored as records in %r" % self.storage.label]
for row in self.rows:
rows.append(" " + row.render())
return "\n".join(rows)
def trace_text(self, limit: int | None = None) -> str:
stages = self.last_stages if limit is None else self.last_stages[:limit]
if not stages:
return "<no stages executed yet>"
return "\n".join(stage.render() for stage in stages)
def separation(self) -> dict:
"""Measured similarity between the 8 row keys (signal vs crosstalk)."""
if self.gate is not None:
summary = self.gate.summary()
summary.update({
"rows": summary["table_rows"],
"worst_cross_similarity": None,
"table_cells": summary["cells"],
"width": self.width,
"d": self.d,
})
return summary
worst = 2.0
for i, row_a in enumerate(self.rows):
for j, row_b in enumerate(self.rows):
if i != j:
worst = min(worst, sim(row_a.key, row_b.key, self.d))
return {
"rows": len(self.rows),
"worst_cross_similarity": worst,
"table_cells": len(self.storage),
"width": self.width,
"d": self.d,
}
def verify(self, trials: int = 200, seed: int = 1234) -> dict:
"""Check the circuit against Python arithmetic, both directions."""
import random
rng = random.Random(seed)
adds = subs = hv_layer = 0
for _ in range(trials):
a = rng.randrange(-(1 << 31), 1 << 31)
b = rng.randrange(-(1 << 31), 1 << 31)
if self.decode(self.add(a, b)) == wrap(a + b, self.width):
adds += 1
if self.decode(self.sub(a, b)) == wrap(a - b, self.width):
subs += 1
if self.decode(self.add_hv(self.add(0, a), self.add(0, b))) == wrap(
a + b, self.width
):
hv_layer += 1
return {
"trials": trials,
"add_correct": adds,
"sub_correct": subs,
"hv_to_hv_correct": hv_layer,
"stages": self.width,
}
def selfcheck(trials: int = 100) -> dict:
"""Small self-verification used by the demo and the CLI."""
adder = HyperAdder(d=2000)
report = adder.separation()
report.update(adder.verify(trials))
report["decode_roundtrip"] = all(
adder.decode(adder.add(0, n)) == n for n in (0, 1, -1, 7, -7, 65535, -12345)
)
return report
if __name__ == "__main__": # pragma: no cover - manual inspection
for key, value in selfcheck().items():
print("%-22s %s" % (key, value))