- holo-narrative
- What it does
- Installation
- Usage
- Results
- 1. Chain retrieval β 25/25 exact
- 2. Bidirectional walk β 10/10 both directions
- 3. Multiple storylines in one store
- 4. Fuzzy anchor retrieval
- 5. Timeline queries
- 6. Gap behavior
- 7. Chain capacity ceiling
- 8. Multi-hop exactness
- 9. Interpolation between adjacent events
- 10. Narrative QA
- 11. Causal DAG with branching
- 12. Alternating storylines
- API reference
- Design notes
- Limitations
- Known issues
- Citation
- References
- License
- What it does
holo-narrative
Ordered memory for events, timelines, and narrative chains.
A single-file library for storing and walking ordered sequences using hyperdimensional vectors. Directed binding preserves order. Symmetric binding does not. Every operation is O(D). No training, no gradients, no external model.
What it does
The store holds two directed traces:
S_next = sum_i bind_dir(event_i, event_{i+1})
S_prev = sum_i bind_dir(event_{i+1}, event_i)
bind_dir is a frequency-domain roll of the second operand in the FFT
domain. It is non-commutative: bind_dir(a, b) != bind_dir(b, a).
Symmetric binding (bind) is commutative, so bind(a, b) == bind(b, a)
and the reverse edge competes with the forward edge. A chain built with
symmetric binding walks 0 -> 1 -> 2 -> 3 -> 2 -> 3 -> 2 -> 3 ... and
never reaches the end. Directed binding removes this failure.
Given one event, the store returns its successor or predecessor. Chained queries walk forward or backward. Multiple chains share one store without interfering.
Installation
pip install numpy # required
pip install matplotlib # optional, for plots
Usage
Python
from holo_narrative import NarrativeMemory
mem = NarrativeMemory(d=4096)
mem.add_chain(["wake", "coffee", "commute", "work", "lunch"])
chain = mem.walk_forward(0, max_steps=10)
print(mem.describe(chain))
# wake -> coffee -> commute -> work -> lunch
nxt = mem.query_next(0)
print(mem.label(nxt))
# coffee
mem.save_json("timeline.json")
mem.plot("timeline.png")
CLI
python holo_narrative.py
python holo_narrative.py --output results/
Runs twelve demonstrations and writes the output to a directory.
Results
All results from a standard run at D=4096 unless noted. The self-test
cos(unbind_dir(a, bind_dir(a, b)), b) returns 1.000000 (exact).
1. Chain retrieval β 25/25 exact
A 25-event daily routine walks end-to-end without error:
wake_up -> brush_teeth -> make_coffee -> drink_coffee -> shower ->
get_dressed -> pack_bag -> leave_home -> walk_to_station_1 ->
catch_train_1 -> arrive_office -> greet_colleagues -> check_email ->
morning_meeting -> work_on_project -> lunch_break -> afternoon_work ->
wrap_up -> leave_office -> walk_to_station_2 -> catch_train_2 ->
arrive_home -> dinner -> read_book -> go_to_bed
25 events reached out of 25.
2. Bidirectional walk β 10/10 both directions
Chain of 10 events:
forward: a -> b -> c -> d -> e -> f -> g -> h -> i -> j
backward: j -> i -> h -> g -> f -> e -> d -> c -> b -> a
Both directions reach full length.
3. Multiple storylines in one store
Two 8-event storylines share one store (14 edges total). Each walks its own chain without crossing into the other:
alice chain: alice_wakes -> alice_reads -> alice_cooks ->
alice_leaves -> alice_meeting -> alice_lunch ->
alice_returns -> alice_sleeps
bob chain: bob_wakes -> bob_jogs -> bob_showers -> bob_leaves ->
bob_workout -> bob_lunch -> bob_returns -> bob_sleeps
Querying the successor of alice's last event returns none (no cross-talk
into bob's chain).
4. Fuzzy anchor retrieval
Query with a noisy version of an event vector. The correct anchor is retrieved across a wide noise range:
| Noise level | Retrieved | Similarity |
|---|---|---|
| 0.1 | step_050 | 0.9975 |
| 0.3 | step_050 | 0.9768 |
| 0.5 | step_050 | 0.9284 |
| 0.8 | step_050 | 0.8040 |
Walk forward from the retrieved anchor continues cleanly.
5. Timeline queries
Successor and predecessor at interior positions:
| Index | Label | Prev | Next |
|---|---|---|---|
| 0 | e0 | none | e1 |
| 5 | e5 | e4 | e6 |
| 10 | e10 | e9 | e11 |
| 15 | e15 | e14 | e16 |
| 19 | e19 | e18 | none |
Boundary events correctly report none for the missing direction.
6. Gap behavior
Removing one edge (c -> d) breaks forward traversal at the gap
without breaking other edges:
| Query | Result |
|---|---|
next of b |
c |
next of c |
none |
next of d |
e |
next of e |
f |
prev of e |
d (backward intact) |
The gap is a clean stop, not a partial return.
7. Chain capacity ceiling
Maximum chain length walkable end-to-end at 90%+ accuracy:
| D | N=50 | N=100 | N=200 | N=500 |
|---|---|---|---|---|
| 4096 | 1.000 | 1.000 | 0.160 | 0.002 |
| 8192 | 1.000 | 1.000 | 0.145 | 0.006 |
The ceiling is between N=100 and N=200 for both dimensions. Doubling D does not substantially raise the chain ceiling, because each event participates in two edges and per-event interference is roughly doubled compared to flat item storage.
8. Multi-hop exactness
D=8192, chain of 100 events. For four starting positions, walk 20 hops and count exact retrievals:
| Start index | Exact hops | Attempts |
|---|---|---|
| 0 | 20 | 20 |
| 25 | 20 | 20 |
| 50 | 20 | 20 |
| 75 | 20 | 20 |
All 80 hops across four starts are exact.
9. Interpolation between adjacent events
Query blends two adjacent events: q = alpha * e[5] + (1 - alpha) * e[6].
Unbinding gives a mixture of the successors of the two sources:
| alpha | Retrieved | sim_e6 | sim_e7 |
|---|---|---|---|
| 0.00 | e7 | 0.0041 | 0.3360 |
| 0.25 | e7 | 0.0529 | 0.3218 |
| 0.50 | e7 | 0.2146 | 0.2046 |
| 0.75 | e7 | 0.3276 | 0.0435 |
| 1.00 | e4 | 0.3345 | 0.0141 |
The sim_e6 and sim_e7 columns show the mixture cleanly. At alpha=0
the successor is e7 (correct). At alpha=1 the successor should be e6,
and sim_e6 = 0.3345 confirms this β the readout picks e4 because
e6 is excluded from the argmax (see Known issues).
10. Narrative QA
One-hop, one-hop backward, and three-hop queries on a 12-event story:
Q: After 'alice_reads_letter', what happened?
A: alice_calls_bob
Q: Before 'they_discover_secret', what happened?
A: they_open_door
Q: What happened after 'they_discuss_clues' (3 steps)?
A: they_visit_house -> they_find_key -> they_open_door
11. Causal DAG with branching
A 9-node DAG with 10 directed edges. Nodes with multiple successors
return all branches via successors():
successors of 'power_outage':
elevators_stop sim=0.3098
traffic_lights_fail sim=0.3023
successors of 'delayed_meetings':
missed_connections sim=0.3224
walk_forward picks one branch per step (greedy). Use successors()
for DAG traversal with choice.
12. Alternating storylines
Two storylines with interleaved timestamps stay separate:
| step | alice | bob |
|---|---|---|
| 0 | a_wake | b_wake |
| 1 | a_breakfast | b_run |
| 2 | a_walk | b_shower |
| 3 | a_meeting | b_commute |
| 4 | a_lunch | b_lunch |
| 5 | a_work | b_gym |
| 6 | a_home | b_home |
| 7 | a_sleep | b_sleep |
Querying next of a_meeting returns a_lunch, not b_lunch. The two
chains are stored in the same trace and remain separate.
API reference
NarrativeMemory
NarrativeMemory(d=4096, seed=0)
Event management
add_event(label) -> idxβ add a single event.add_chain(labels) -> [idx]β add a chain of events and link consecutive ones.link(a, b)β add a directed edge between two existing events.link_many([idx])β link a list of indices as a chain.
Retrieval
query_next(idx) -> idx | Noneβ successor of an event.query_prev(idx) -> idx | Noneβ predecessor of an event.walk_forward(idx, max_steps=20) -> [idx]β walk forward until a gap or repeat.walk_backward(idx, max_steps=20) -> [idx]β walk backward.successors(idx, k=4, threshold=0.05) -> [(idx, sim)]β all successors above threshold, for DAGs.
Display
label(idx) -> strdescribe(chain) -> strstats() -> dict
Export
save_json(path)β events, edges, metadata.plot(path)β timeline diagram (requires matplotlib).
Design notes
Why directed binding
Symmetric binding bind(a, b) = ifft(fft(a) * fft(b)) is commutative.
For a chain stored as sum_i bind(e_i, e_{i+1}), the reverse edge
bind(e_{i+1}, e_i) is the same object. Unbinding with e_{i+1} returns
e_i, and the walk enters a 2-cycle.
Directed binding bind_dir(a, b) = ifft(fft(a) * roll(fft(b), 1))
breaks the symmetry. The reverse interference becomes noise rather than
a structured competing signal. The self-test at the top of the demo
verifies this: cos(unbind_dir(a, bind_dir(a, b)), b) = 1.000000.
Why two traces
Forward and backward walks need separate traces because directed binding
is not self-inverse in the way symmetric binding is. S_next handles
forward traversal; S_prev handles backward. The cost of both is the
same as storing one, and the two directions are independent (removing a
forward edge does not affect backward retrieval).
Why no learned codebook
Events are assigned random phase-only vectors. No training, no gradients, no embeddings. The substrate carries order through the binding operation, not through the vectors themselves. This makes the tool reproducible, inspectable, and dependency-free beyond NumPy.
Limitations
Chain capacity is D-limited. Around N=100 for D=4096, roughly the same for D=8192. Doubling D does not substantially raise it because each event participates in two edges. For longer sequences, split into multiple stores or use sparse anchoring.
Greedy walk on a DAG picks one branch. walk_forward returns the
highest-similarity successor at each step. For explicit branching use
successors() and traverse manually.
No deletion of individual events. Removing an event requires subtracting its two edges and re-linking the neighbors. A helper method for this is not yet implemented.
No cyclic chains. A chain where the last event links back to the
first would walk into an infinite loop under walk_forward. The
visited set prevents the loop, but the chain terminates early.
Known issues
EXP 9 (interpolation) excludes the correct answer at alpha=1.
The demo excludes indices 5 and 6 (the two source events) from the
argmax, on the assumption that they are the query's own components and
should not appear as answers. But unbind_dir returns the successor
of the query. At alpha=1 (pure e5) the correct answer is e6 β which is
one of the excluded indices. The sim_e6 = 0.3345 column shows the
correct answer is present in the raw similarity vector; the argmax
simply cannot select it.
To see the correct interpolation behavior, remove the source-event exclusion from the demo. The library itself does not exclude anything; the issue is only in the demonstration code.
EXP 7 shows D=8192 slightly worse than D=4096 at N=200.
Values 0.145 and 0.160 respectively. Both are deep in the degraded regime and the difference is within seed variation. It is not an inversion of the capacity law.
Citation
@misc{holo-narrative2026,
title = {holo-narrative: Ordered memory for events, timelines,
and narrative chains},
author = {zeechimp},
year = {2026},
note = {Single-file library for ordered retrieval from a
holographic substrate.}
}
References
- Plate, T. A. "Holographic Reduced Representations." IEEE Transactions on Neural Networks 6:3 (1995), 623β641.
- Kanerva, P. "Hyperdimensional Computing." Cognitive Computation 1:2 (2009), 139β159.
- Gayler, R. W. "Vector Symbolic Architectures Answer Jackendoff's Challenges." ICCS/ASCS (2003).
License
Apache 2.0