A Vector-Symbolic Clojure
Generated with Claude Code, and I am not ashamed. My own blog posts of this kind are part of the training data and have sculpted its style.
Every value is one high-dimensional vector. September 2026, updated October 2026.
This notebook runs a small Clojure in which every value is a single vector of 2048 numbers. That covers lists, maps, sets, integers, closures, and the environment the evaluator runs in. The evaluator never looks inside a vector. It only binds, unbinds, adds and compares vectors, and cleans up noisy ones against a memory.
It is the idea of Tomkins-Flanagan and Kelly’s “Hey Pentti, We Did It!” (2024), which builds Lisp 1.5 this way, carried over to Clojure and then run until it broke. Every result below is computed live when the page renders.
What we learned. Each point is measured below.
- Similar is confusable. A cleanup’s margin is 1 minus the similarity to the nearest wrong item. Built as bundles of their parts, the cells of a recursion are 89% alike, and reads fail. Give every structure its own random pointer, shared by equal contents: reads then survive 2.8× more noise. Keep similarity only where you want it, as in vectors.
- Never decide with a fixed threshold. A cutoff like “cosine above 0.5” fails at noise σ ≈ 1.7 in every dimension, while the cleanup underneath survives σ ≈ 17. Decide by the best match, or relative to chance (1/√D), and robustness grows with √D.
- Convolution commutes, so key–value pairs need direction. Store an entry as k⊗Π(v), with Π a fixed random permutation, as one operation. A cyclic shift does not work, since it commutes with convolution too.
- Residue-number integers blend into fake ones. A superposition of 11 and 12 matches 62 other integers exactly as well as its members. Inside sets and maps, keep integers behind pointers.
- Stored data is robust, computation is fragile. Lists still read back with 60–80% of the dimensions dead;
(fact 4)breaks at about 4%, because every bind passes the damage on until the next cleanup. - A Hopfield network cannot beat a lookup table at exact recall. For a probe that is one stored item plus noise, picking the best match is already optimal. Measured: it ties at best and loses on look-alikes.
- Programs can compute on superpositions. A value can be a weighted mix of values, and destructors, arithmetic and
ifact on all of them at once. This needs a memory that keeps several matches instead of one; it holds about D/100 worlds. It makes no program faster. - An interpreter can live in vector memory. A CEK machine whose 35 rules are vectors, picked by similarity, runs these programs from a host loop of under 50 lines that knows no Lisp.
Why care. Brains, and the neuromorphic chips modelled on them, do not store bits in addresses. They store patterns spread over many noisy units. Vector-symbolic architectures are a mathematical model of how symbols and structure could live in such patterns. A language whose values are all such vectors asks the question concretely: can you program on that kind of substrate, and what goes wrong when you try? It also puts data and code in one format, and it allows a new way of programming, on superpositions of values.
How the interpreter is built, step by step, with all the code: Building a Vector-Symbolic Interpreter.
How the design got here, roughly in the order the traps turned up: History.
Which of the traps exist in the original authors’ own code, holis (2024) and residuelisp (2025), run at pinned commits under the same noise: Reproducing the Traps in the Original Code. In short: holis has trap 5 (its fixed 0.2 caps noise at σ ≈ 5 for every D), and residuelisp has trap 6 (one tag on every integer makes any two integers eq?). Neither has the chimeras or the zero trap. Running them also turned up eight further bugs.
Code, tests, experiments and the paper draft: github.com/benjamin-asdf/vector-symbolic-clj.
Hyperdimensional computing in four operations
A symbol is a random vector of D = 2048 numbers. Two random vectors in that many dimensions are almost unrelated: their similarity (the cosine of the angle between them, 1 for equal, 0 for unrelated) is about ±0.02. So there is room for far more symbols than dimensions. Four operations build everything else.
- Binding a⊗b glues two vectors together, like a name tag onto a value. The result looks like neither, but given one you can get the other back. (Technically: circular convolution.)
- Unbinding a⊘c peels the tag a off c = a⊗b and gives back b.
- Bundling a + b throws vectors into one bag. The bag is similar to each thing in it. That is a set.
- Cleanup snaps a noisy vector to the nearest vector the memory knows, like autocorrect snapping a typo to a real word.
These are holographic reduced representations (Plate, 1995).
Every value is one vector
Start a machine, then read a map into vector space. What comes back is a numpy array, and that array is the entire map.
(vsc/init!):ready(kind/code (str (vsc/eval-form '{:name "Ada" :lang :clojure})))[-0.04233199 0.00739691 0.02738149 ... 0.0052645 0.00336788
0.00798453]How a list becomes one vector: a list cell says “first is a, rest is b”. Bind a to a fixed role label L and b to a role label R, and bundle the two: L⊗a + R⊗b. To read the first element, unbind L and clean up. Every such cell also gets its own random ID (a pointer), for a reason explained under “What the algebra hides”. The rest of the encoding follows the same pattern. In the table, L, R, V, KEYS, SIZE, B and Z are fixed random vectors, Bⁿ is B bound to itself n times, Π shuffles the dimensions in a fixed random order, and ν rescales to length 1.
| value | vector |
|---|---|
| symbol, keyword, string | a random atom, stored in the memory M with its kind |
(a . b) |
a pointer p, with M: p ↦ ν(L⊗a + R⊗b) |
first, rest |
unbind L or R from what p points to, then clean up |
[x₀ … xₙ₋₁] |
ν(Σᵢ Πⁱ(V⊗xᵢ)) itself, with no pointer; M records its length next to it |
{k v …} |
p ↦ ν(ν(Σ k⊗Π(v)) + ν(KEYS⊗Σ k) + SIZE⊗n): the entries, the set of keys, and the size n |
#{x …} |
p ↦ ν(ν(Σ x) + SIZE⊗n) |
| integer n | Bⁿ⊗Z, so inc is one more binding with B, and + binds two numbers and unbinds one Z (Bᵐ⊗Bⁿ = Bᵐ⁺ⁿ) |
| closure | p ↦ ν(L⊗form + R⊗env) |
A map is a record: get is one unbind and one cleanup. Map equality ignores key order for free, because addition commutes.
Lists and vectors are encoded differently on purpose. A list is a chain of pointers: robust and unbounded, but two lists that differ in one element are unrelated vectors. A vector puts element i at position Πⁱ and has no pointer, so vectors with the same elements in the same places are similar vectors, and nth is one readout. The price: a vector holds about D/48 elements, and it tolerates less noise than a list, the less the longer it is, because each element gets a smaller share of the vector. The trade-off is the programmer’s choice. Need any number of elements that read back reliably, even under heavy noise? Use a list. Need sequences that are similar when their elements are? Use a vector.
(examples vsc/run
'[(similarity [:a :b :c :d] [:a :b :c :e])
(similarity (list :a :b :c :d) (list :a :b :c :e))
(nth [:a :b :c :d] 3)])| form | result | ms |
|---|---|---|
|
|
17 |
|
|
21 |
|
|
10 |
similarity answers in percent, since the dialect has no floats. The two vectors share three of four positions and are about 75% alike; the two lists are as unrelated as random vectors.
(examples vsc/run
'[(cons 'a '(b c))
(:name {:name "Ada" :lang :clojure})
(= {:a 1 :b 2} {:b 2 :a 1})
(contains? #{:x :y :z} :y)
(assoc {:a 1} :b [1 2])
(+ 40 2)
(eval (list '+ 1 2))])| form | result | ms |
|---|---|---|
|
|
14 |
|
|
10 |
|
|
18 |
|
|
10 |
|
|
22 |
|
|
5 |
|
|
16 |
Programs work too. The prelude’s map, filter and reduce are themselves written in this dialect.
(vsc/run '(defn fact [n] (if (zero? n) 1 (* n (fact (dec n))))))fact(examples vsc/run
'[(fact 5)
(map (fn [x] (* x x)) (range 6))
(reduce + 0 [1 2 3 4])
(let [add (fn [a] (fn [b] (+ a b)))] ((add 3) 4))])| form | result | ms |
|---|---|---|
|
|
1347 |
|
|
1290 |
|
|
227 |
|
|
37 |
A small program takes seconds, where native Clojure takes microseconds. Speed is not the point.
Noise
Add noise as large as the signal to a list, and its first element still comes back, because every read ends in a cleanup.
(examples vsc/run
'[(first (degrade (quote (a b c)) 100))
(get (degrade {:a 1 :b 2} 60) :b)])| form | result | ms |
|---|---|---|
|
|
11 |
|
|
15 |
What the algebra hides
Seven traps hide in the algebra, and each one shaped the design.
1. Similar lists get confused. In the original paper, a list cell is just the bundle of its parts. Recursion creates many nearly equal cells, such as ((a . 5) . ()) and ((a . 4) . ()), and as bundles they are 89% similar. Reading one of them beats its look-alike by a margin of only 0.05, so a little noise returns the wrong cell, and (fact 5) fails with “unable to resolve symbol: a”. So every cell gets its own random ID, and cells with equal contents share one ID, so checking equality is still one comparison. The right cell then wins by 0.66, and reading survives 2.8× more noise (σ ≈ 11 instead of 4). (Noise σ here means noise of σ times the vector’s own size.)
The lesson is not “never let things be similar”. A cleanup’s margin is 1 minus the similarity to the nearest wrong item, so similarity and confusability are the same number. Lists and the interpreter’s own structures take the margin, and vectors take the similarity.
2. Binding commutes. The original paper binds by circular convolution, and that commutes: a⊗b = b⊗a. Its list cells do not mind, because the labels L and R tell first from rest. A map entry has no such labels. Stored as k⊗v, the entry 1 ↦ 2 is 1⊗2 = 2⊗1, so (get {1 2 2 3} 2) would answer 1.
So an entry is a directed bind, k⊗Π(v), with Π the fixed random shuffle from the table above, and reading it unbinds k and undoes Π. The cheapest permutation, shifting every number one place along, would not do: a shift commutes with circular convolution, so shift(1)⊗2 = shift(2)⊗1. Π goes on the value and not on the key, because keys unbind exactly and a shuffled key no longer does. Pairs without a direction, where a–b is the same as b–a, have their uses too, a symmetric relation for one. They can be a second structure, built on the plain bind.
3. Numbers can blend into fake numbers. An integer is stored by its remainders, like reading six clocks at once: 11 is “1 on a 5-hour clock, 4 on a 7-hour clock, 0 on an 11-hour clock”, and so on up to a 19-hour clock. Residue numbers come from Kymn et al. (2025) and Hanley et al. (2025). Giving each clock its own share of the vector is our variant. It makes numbers cheap to recognise: read each clock, then combine. Their product code mixes all clocks in every coordinate, and the chimeras below do not occur in it (reproduction).
Now put 11 and 12 into one set. The set shows both numbers’ readings on every clock. A number that shows 11’s reading on some clocks and 12’s on the others, 13102 for example, matches the set exactly as well as 11 or 12 do. It is a chimera: a fake member stitched together from pieces of real ones. With six clocks there are 2⁶ − 2 = 62 of them. Here is the sum of 11 and 12, built with the interpreter’s own number encoding, against its members, a chimera and a few ordinary numbers:
| number | similarity to 11 + 12 |
|---|---|
| 11 | 0.673 |
| 12 | 0.673 |
| 13102 (chimera) | 0.673 |
| 42 | -0.020 |
| 777 | 0.083 |
| -5000 | -0.041 |
| clock readout of 11 + 12: -293919 | 0.673 |
The readout, asked which number the sum is, answers with a chimera.
So inside sets and maps, each number is stored behind its own random ID, and IDs cannot be stitched together.
4. Partly equal is not equal. The clocks set a second trap: 5005 = 5·7·11·13 reads 0 on four of the six clocks, so it is 4/6 similar to zero, and a test “similar enough to zero” would accept it. zero? asks for equality:
(examples vsc/run '[(zero? 5005) (zero? 0)])| form | result | ms |
|---|---|---|
|
|
5 |
|
|
4 |
5. Fixed thresholds throw robustness away. Bigger vectors should tolerate more noise, but a fixed “similar enough” cutoff does not grow with them. Say a value’s kind counts as recognised when its cosine exceeds 0.5. A clean vector’s cosine to its own noisy probe is 1/√(1 + σ²), which crosses 0.5 at σ = √3 for every dimension. So every structure would fail at σ ≈ 1.7, while the cleanup underneath survives σ ≈ 17 at the size of these memories. Deciding by argmax, or relative to the chance level 1/√D, makes robustness grow with √D again, and that is how the interpreter decides. Fixed thresholds are common practice: Nengo’s associative memory defaults to 0.3 in every dimension.
6. A shared field makes strangers similar. If a vector stored its length inside itself, any two vectors of the same length would share that part and look 20% alike, even with no element in common. That is far above chance, and the superposition memory below treats anything above chance as present, so unrelated vectors would creep into results. So the length lives next to the vector in the memory, not inside it. Maps and sets keep their size in their trace, and that is fine: their value is the random pointer p, so two maps of one size are as unrelated as any two pointers.
7. A sum of tuples forgets which values went together. A vector is a sum over its positions, so [1 1] + [2 2] is exactly [1 2] + [2 1]. A superposition of vectors remembers how likely each value is at each position, but not which values occurred together. Tuples whose correlations matter are therefore built as lists.
How robust it is
Here D is the number of dimensions and σ the noise, relative to the signal. A single cleanup matches its textbook model within 0.007 on average, and the noise it tolerates grows as √D: four times the dimensions, twice the noise.
Capacity is linear in D: about D/64 map entries and D/36 set members at 90% reliability. Overloaded structures forget; they do not hallucinate members.
Stored structures survive heavy damage: lists still read back with 60–80% of the dimensions dead, sets with 57–70%, maps with 23–52%. A long computation such as (fact 4) survives about 4%, because every bind re-loses the dead dimensions until the next cleanup. A short one, (+ 17 25), survives about half.
Hopfield networks do not help
The paper suggests a modern Hopfield network as the cleanup memory. Instead of returning the one best match, it returns a weighted mix of the stored items, and can repeat that step. A parameter β sets how hard it favours the best match: at large β the mix is almost only the winner.
It cannot beat a plain lookup table here, and the reason comes before any experiment. Every probe the interpreter makes is one stored item plus noise, and for such a probe picking the single best match is already the best possible decision. The Hopfield network scores the same stored items the same way, so its first step picks the table’s winner at every β, and repeating the step only sharpens whatever was ahead. It can tie the table or lose to it, and the experiments measure where it loses.
With unrelated stored items, β = 64 is the table, only slower, and smaller β is worse:
Stored look-alikes are where it loses. In groups of 16 items at similarity 7/8, β = 64 still holds for three steps, but ten steps pull the state towards the centre of the group; only β = 256 matches the table there. Inside the interpreter, one soft step at β = 64, which matched the table on unrelated items, passes the blend of look-alikes on as the value. So a well-set β depends on the closest pair of stored items, not only on how many there are. Damage to the stored items costs every memory the same.
| noise tolerated (σ₅₀, higher is better) | table | β = 64, 3 steps | β = 64, 10 steps | β = 64, 1 soft step |
|---|---|---|---|---|
| single cleanup, unrelated items | 14.1 | 14.1 | 14.1 | 14.1 |
| single cleanup, 16 look-alikes | 9.4 | 9.0 | 5.6 | 9.2 |
| reading a vector stored beside 15 look-alikes | 3.0 | 3.0 | 1.8 |
So the hope for a Hopfield network is probably not a better cleanup. Exact recall is the one job a lookup table already does as well as anything can. The hope is a different job: a memory of what the computation did, not only of what it stored. Which values a function returned, which code paths a program took. A new situation would then be completed from similar old ones. The pull towards the centre of a group of look-alikes, a loss in the table above, is the same mechanism seen from the other side: it is how such a memory would form a prototype and generalise. That would make the interpreter a neuro-symbolic system, exact where it cleans up and associative where it remembers. The CEK machine below already picks its rules by similarity; a memory of its past runs would be the next step. We have not built it.
The interpreter as vectors
The evaluator above is host code. The next step moves it into vectors. A classic design for interpreters, the CEK machine, keeps three things: the Control, what to evaluate now; the Environment, which variable has which value; and the Kontinuation, a to-do list of what comes after (spelled with a K by tradition, since C is taken). Here all three, plus a mode, are bundled into one vector: s ↦ ν(C⊗control + E⊗env + K⊗continuation + M⊗mode). Its 35 transition rules sit in a memory and are picked by similarity. Rule keys are sums of features, so the most specific matching rule wins by nearest neighbour, and shorter keys act as defaults. The host loop is under 50 lines and knows no Lisp. Tail calls come for free.
One step
The machine runs in steps. Each step goes round the loop below in three phases, and each phase reads some vectors and produces others. The orange lines follow one real step: the moment the machine meets (if (zero? x) :zero (inc x)).
The key is the whole trick. It describes the state by five features: the mode; the kind of C (symbol, number, list, closure, …); the head of C, which matters when C is a list starting with a special form such as if or let; C’s identity, which matters when C is one of the two primitives that re-enter evaluation, apply and eval; and the type of the frame on top of K. Every state’s key has all five; a rule names only those it cares about. Each feature is a random vector bound to its role, and the key is their sum. The rule memory then does what a case statement does in an ordinary interpreter, by similarity.
Here is that step again, decoded live from the vectors. The rule if saves the two branches and the environment in a new frame on K, and makes the test the next thing to evaluate.
| decoded from the vectors | |
|---|---|
| the state |
|
| 1 · its key |
|
| 2 · the rule chosen |
|
| 3 · the next state |
|
The code
The host loop is all the host code there is: fetch, select, run, until the mode is HALT. exec! is the interpreter of the nine instructions. Nothing in it knows about Lisp.
(defn- run-state!
"Drive the machine from the state in register S until it halts:
fetch, select a rule by nearest key, run it. Returns the control register."
[^objects regs]
(loop [step 0]
(run-micro! regs (v :fetch))
(let [[i sim-key pc] (select-rule (reg regs 'KEY))]
(when (< sim-key theta-rule)
(throw (ex-info "no rule matches the machine state" {:sim sim-key})))
(when *trace* (*trace* step i regs))
(run-micro! regs pc)
(swap! counters update :steps inc)
(collect! (reg regs 'S))
(cond
(> (sim (reg regs 'M) (named 'HALT)) theta-jump) (reg regs 'C)
(> (sim (reg regs 'M) (named 'ERROR)) theta-jump)
(throw (ex-info (str "machine error at " (pr-str (vsc/decode (reg regs 'C)))) {}))
:else (recur (inc step))))))
(defn- run-micro!
"Run the microprogram at code pointer `pc` to its end."
[regs pc]
(loop [pc pc]
(when pc
(let [[ops nxt] (decode pc)]
(swap! counters update :instrs inc)
(recur (or (exec! regs ops) nxt))))))
(defn- exec!
"Execute one decoded instruction on register file `regs`; returns a jump
target or nil."
[^objects regs ops]
(let [a #(nth ops %)
val #(let [i (a %)] (if (< i n-reg) (aget regs i) (get (v :consts) i)))
imm #(get (v :consts) (a %))
put! #(do (aset regs (a 1) %) nil)]
(case ((v :opcode) (a 0))
FIELD (put! (field (val 2) (imm 3) ((v :space) (a 4))))
CLEAN (put! (clean (val 2) ((v :space) (a 3))))
BIND (put! (bind* (val 2) (val 3)))
UNBIND (put! (unbind* (val 2) (val 3)))
REC (put! (store ((v :rec-space) (a 2))
(mapcat (fn [k] [(imm k) (val (inc k))])
(take-while #(>= (a %) 0) (range 3 n-fields 2)))))
MOV (put! (val 2))
JMPEQ (when (> (sim (val 1) (val 2)) theta-jump) (imm 3))
PRIM (put! (prim (val 2) (val 3)))
DEF (do (vsc/define! (val 1) (val 2)) nil))))Everything Lisp-specific is data, assembled into instruction vectors when the machine starts. The instructions:
| instruction | does |
|---|---|
FIELD d x r s |
d ← cleanup(r ⊘ x): read field r of record x |
CLEAN d x s |
d ← cleanup(x) in memory s, e.g. to classify x by kind |
BIND d x y, UNBIND d x y |
d ← x ⊗ y, d ← x ⊘ y |
REC d s r₁ x₁ … |
d ← a new record ν(Σ rᵢ⊗xᵢ), stored in memory s |
MOV d x |
d ← x |
JMPEQ x y l |
jump to l if x and y are the same vector |
PRIM d f x |
d ← the primitive f (+, first, …) applied to the list x |
DEF x y |
store the global x ↦ y |
S, C, E, K, M are the state’s registers; T, A, B, N, X, Y, Z are scratch. (PACK) builds the next state from C, E, K and M, and (JMP l) jumps unconditionally. The fetch phase is itself a microprogram of twelve instructions:
(def fetch-program
"Unpack the state and compute its shape: the rule key
ν(FM⊗mode + FK⊗kind(C) + FH⊗kind(C)⊗class(car C) + FI⊗kind(C)⊗class(C) + FF⊗type(top frame))."
'[:fetch
[FIELD M S M CONST] [FIELD C S C VAL] [FIELD E S E VAL] [FIELD K S K VAL]
[FIELD T K T CONST]
[CLEAN X C KIND]
[FIELD Y C L VAL] [CLEAN Y Y CLASS] [BIND Y X Y]
[CLEAN Z C CLASS] [BIND Z X Z]
[REC KEY NONE FM M FK X FH Y FI Z FF T]])And the two rules of if. The first fires on the step above. Read with C = (if test then else): X ← the rest of C, (test then else); C ← its first element, the test; A ← the rest, (then else); push a frame onto K that remembers the branches A, the environment E and the old K as its next N; then evaluate C. The second rule fires when the test’s value comes back to that frame: it pops the frame, restores the environment, and evaluates one branch. The frame is gone by then, which is why tail calls come for free.
{:name if,
:key {:mode EVAL, :kind k-list, :head if},
:code
[[FIELD X C R VAL]
[FIELD C X L VAL]
[FIELD A X R VAL]
[REC K W T if-fr A A E E N K]
[JMP :eval]]}
{:name if-branch,
:key {:mode RET, :frame if-fr},
:code
[[FIELD A K A VAL]
[FIELD E K E VAL]
[FIELD K K N VAL]
[JMPEQ C NIL :if-else]
[JMPEQ C FALSE :if-else]
[FIELD C A L VAL]
[JMP :eval]
:if-else
[FIELD A A R VAL]
[CLEAN X A KIND]
[JMPEQ X k-empty-list :ret-nil]
[FIELD C A L VAL]
[JMP :eval]]}Why the most specific rule wins. A state’s key always has all five features. A rule’s key names only the features it cares about: a default rule names one, the rule for if names three. When all of a rule’s features match, its similarity to the state’s key is √(k/5) for k features. So when several rules match, the one that names the most features is nearest, and a rule that names fewer acts as a default.
(kind/table
{:column-names ["features the rule names" "example" "similarity to the state's key"]
:row-vectors (for [[k ex] [[1 "any value evaluates to itself"]
[2 "a list is a function call"]
[3 "a list headed by if is an if"]]]
[k ex (format "%.3f" (Math/sqrt (/ k 5.0)))])})| features the rule names | example | similarity to the state's key |
|---|---|---|
| 1 | any value evaluates to itself | 0.447 |
| 2 | a list is a function call | 0.632 |
| 3 | a list headed by if is an if | 0.775 |
A run, step by step. Each row is decoded from the state vector: the mode, what is being evaluated, the rule that nearest-neighbour lookup chose, and the to-do list. At step 14 the if frame is already gone: the branch runs as a tail call, so the to-do list does not grow.
| step | mode | evaluating | rule chosen | to-do list |
|---|---|---|---|---|
| 0 | EVAL | ((fn [x] (if (zero? x) :zero (inc x))) 1) | call | halt |
| 1 | EVAL | (fn [x] (if (zero? x) :zero (inc x))) | fn | args → halt |
| 2 | RET | #fn (fn [x] (if (zero? x) :zero (inc x))) | arg | args → halt |
| 3 | EVAL | 1 | self-evaluating | args → halt |
| 4 | RET | 1 | arg | args → halt |
| 5 | APPLY | #fn (fn [x] (if (zero? x) :zero (inc x))) | closure | halt |
| 6 | EVAL | (if (zero? x) :zero (inc x)) | if | halt |
| 7 | EVAL | (zero? x) | call | if → halt |
| 8 | EVAL | zero? | symbol | args → if → halt |
| 9 | RET | #prim zero? | arg | args → if → halt |
| 10 | EVAL | x | symbol | args → if → halt |
| 11 | RET | 1 | arg | args → if → halt |
| 12 | APPLY | #prim zero? | primitive | if → halt |
| 13 | RET | false | if-branch | if → halt |
| 14 | EVAL | (inc x) | call | halt |
| 15 | EVAL | inc | symbol | args → halt |
| 16 | RET | #prim inc | arg | args → halt |
| 17 | EVAL | x | symbol | args → halt |
| 18 | RET | 1 | arg | args → halt |
| 19 | APPLY | #prim inc | primitive | halt |
| 20 | RET | 2 | halt | halt |
Result: 2
Superposition programming
A weighted sum of values is also a vector. So a variable can hold several worlds at once, and the program computes on all of them. Destructors act on every world in one memory read, and integer arithmetic lifts exactly, because binding distributes over addition.
(w/init! {:dim 4096}):ready(examples worlds
'[(+ (amb 1 2) 10)
(first (amb (quote (a b)) (quote (c d))))
(let [x (amb 1 2)] (+ x x))
(for-worlds [x (amb 1 2)] (+ x x))])| form | result | ms |
|---|---|---|
|
|
24 |
|
|
34 |
|
|
48 |
|
|
63 |
Look at the last two rows. A vector carries no labels saying which world a value belongs to, so the two uses of x are independent draws. That is run-time choice. The name comes from nondeterministic programming, where Hussmann (1993) distinguished it from call-time choice, in which a variable picks one value and keeps it. Languages like Curry use call-time choice. Here it is explicit, with for-worlds.
This needed a new kind of memory. A lookup table and a softmax (Hopfield) memory both collapse a sum of worlds to one world. A linear memory keeps the worlds but lets every other stored item through as well. What works is a memory that keeps only the stored items that clearly stand out above chance and solves least squares on them. It holds about D/100 worlds.
if on a superposed test runs both branches and weights them by the test. Each world goes down its own branch, so recursion ends at a different depth in each world:
(w/run '(defn dbl [n] (if (zero? n) 0 (+ 2 (dbl (dec n))))))dbl(examples worlds '[(dbl (superpose {2 5 3 3 5 2}))])| form | result | ms |
|---|---|---|
|
|
618 |
The weights behave like probabilities, so a textbook Bayesian network works. Rain makes the sprinkler less likely, and either makes the grass wet. What is P(rain | wet)? The exact answer is 0.36255. Probability with such vectors is not new: Furlong and Eliasmith (2024) and the “hyperdimensional transform” (Dewulf et al., 2023) do inference this way. What is new here is that it runs inside an ordinary programming language.
(w/run-string (slurp "examples/worlds/sprinkler.clj"))#worlds {false 0.6372, true 0.3628}(examples worlds '[rain-given-wet rain-given-wet-naive])| form | result | ms |
|---|---|---|
|
|
4 |
|
|
4 |
The naive version uses let instead of for-worlds, so run-time choice loses the link between rain and wet grass, and it returns the prior, 0.2. The joint worlds are lists, not vectors. A vector is linear in its elements, so [1 1] + [2 2] is exactly [1 2] + [2 1]: a sum of vectors remembers each position’s distribution, but not which values went together. This query still enumerates the worlds one by one, so it is no faster than ordinary code.
There is no speedup: a superposed step still scans the memory. What superposition buys is a programming model, within a capacity of about D/100 worlds, and only about 5 integer worlds.
Limits
- Seconds per small program. Every cleanup scans a memory that only grows.
- No macros,
loop/recur, floats, ordering comparisons or string operations. - Records hold about D/64 entries, vectors about D/48; superpositions about D/100 worlds.
What is new here
To our knowledge, these are new:
- Clojure’s data types as single vectors. Earlier vector-symbolic Lisps encode the cons cells of Lisp 1.5, and integers. Here maps (with directed entries k⊗Π(v)), sets, positional vectors, closures and environments are each one vector too, and integers inside collections are boxed against chimeras.
- A catalogue of what breaks. Seven traps that the algebra hides, each measured and fixed. One rule covers several of them: decide by the best match or relative to chance, never by a fixed cutoff, and robustness grows with √D.
- Measurements for a whole language: noise, capacity and damage, with the split between robust stored data and fragile computation.
- Why a Hopfield cleanup cannot help with exact recall: an argument that holds before any experiment, and the experiments.
- An evaluator in vector memory. Rules held in vector memory are not new: production systems in HRR (Stewart & Eliasmith, 2008) and a Turing machine with its transition table in an item memory (Kleyko et al., 2022) do it. New is a complete language evaluator run this way: a CEK machine whose 35 rules are picked by nearest key, where sums of features make the most specific rule win, with tail calls.
- Superposition programming as a language feature. Weighted superpositions are ordinary values. Destructors, lookup, arithmetic and function bodies act on all worlds at once,
ifsplits them, and run-time versus call-time choice is explicit. The semantics has precedents (Lineal; probabilistic functional programming), and so does probability in vectors (Furlong & Eliasmith; Hersche et al.). We know of no language that puts them together on a vector substrate, with a memory that keeps the worlds.