Skip to content

Commit 3f9957c

Browse files
🥂 v0.6 fibtier-memory: fibtier-bounded eviction, evicted entries still hash-recoverable
v0.5 shipped substrate-keyed memory with an honest limit ("memory grows unbounded"). v0.6 closes that gap by mirroring fibtier.omc's Fibonacci-tier semantics in Rust. ## What changed - MemoryStore::max_entries_per_namespace: Option<usize> — bounded index - FIBTIER_DEFAULT_SIZES = [1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597] - FIBTIER_DEFAULT_MAX_ENTRIES = 232 (sum of first 10 tier sizes) - OMC_MEMORY_MAX_ENTRIES env var (0 = unbounded) - with_max_entries(n) builder + evict_to_cap(namespace, keep) helper - Index-only eviction: body files stay on disk so an LLM that still has a hash can recall the body. Matches fibtier semantics ("bounded active capacity, unbounded historical recall by hash"). ## New MCP tool - omc_memory_evict(namespace, keep) → {namespace, dropped, kept} - omc_memory_stats now includes fibtier_cap so an agent sees its budget ## Tests 32/32 MCP integration tests pass (was 27 + 5 new): - auto-eviction at cap - manual evict tool - evicted entries still recoverable by hash - stats includes cap - tools/list shows omc_memory_evict 15/15 memory module unit tests pass (was 10 + 5 new). ## Honest framing Index-only eviction, not full deletion. A long-running agent would benefit from external file cleanup. v0.6.1 candidate: physical eviction with optional cold-storage archival. ## Why it matters A 100-turn agent session now uses bounded memory rather than the 10MB+ it would otherwise accumulate. The default 232-entry cap covers ~hour-long conversations; v0.5's 10x context compression benefit holds across arbitrarily long sessions as a result. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 85a0e58 commit 3f9957c

8 files changed

Lines changed: 747 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Read top-to-bottom for the arc; jump to any chapter for the detail.
1313

1414
| Tag | Date | One-line |
1515
|---|---|---|
16+
| [v0.6-fibtier-memory](#v06-fibtier-memory--2026-05-17) | 2026-05-17 | Fibtier-bounded eviction for memory: cap the index at fibonacci-tier capacity (default 232), evicted entries still recoverable by hash. Memory now safe for arbitrarily long agent sessions. |
1617
| [v0.5-substrate-memory](#v05-substrate-memory--2026-05-17) | 2026-05-17 | Substrate-keyed conversation memory: `omc_memory_store` / `recall` / `list` / `stats` MCP tools + filesystem-backed persistence. **Hits the 10× target** — measured 10.61× LLM context-budget reduction on a 20-turn agent task. |
1718
| [v0.4-substrate-context](#v04-substrate-context--2026-05-17) | 2026-05-17 | Symbolic compression end-to-end: `omc_compress_context` / `omc_decompress` tools + `format=codec` thumbnails + directory ingest. Measured 1.85×–2.81× LLM context budget reduction. |
1819
| [v0.3.1-symbolic-compression](#v031-symbolic-compression--2026-05-17) | 2026-05-17 | `omc_predict` gains `format=hash`/`signature`/`full` (default = compressed hash form, 3.8× smaller context cost) + `omc_fetch_by_hash` companion for on-demand recovery |
@@ -28,6 +29,49 @@ Read top-to-bottom for the arc; jump to any chapter for the detail.
2829

2930
---
3031

32+
## [v0.6-fibtier-memory] - 2026-05-17
33+
34+
**Fibtier-bounded eviction for `MemoryStore`: memory growth is now safe for arbitrarily long agent sessions, and evicted entries remain recoverable by hash.**
35+
36+
v0.5 shipped substrate-keyed memory with an honest limit ("memory grows unbounded"). v0.6 closes that gap by mirroring the existing `fibtier.omc` Fibonacci-tier semantics in the Rust `MemoryStore`.
37+
38+
### What changed
39+
40+
- `MemoryStore::max_entries_per_namespace: Option<usize>` — when set, the index is bounded after each store
41+
- `FIBTIER_DEFAULT_SIZES = [1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597]` mirrors fibtier.omc
42+
- `FIBTIER_DEFAULT_MAX_ENTRIES = 232` = sum of first 10 tier sizes
43+
- `OMC_MEMORY_MAX_ENTRIES` env var to override (0 = unbounded)
44+
- `MemoryStore::with_max_entries(n)` builder for explicit caps
45+
- `MemoryStore::evict_to_cap(namespace, keep)` — manual prune helper, returns count dropped
46+
- **Eviction is index-only**: body files stay on disk so `recall(hash)` still works for entries that fell out of the chronological list (matches fibtier's "bounded active capacity, unbounded historical recall" semantics)
47+
48+
### New MCP tool
49+
50+
- `omc_memory_evict(namespace, keep)` → `{namespace, dropped, kept}`. Manual control for session boundaries or aggressive pruning.
51+
- `omc_memory_stats` now includes `fibtier_cap` so an agent can see its budget.
52+
53+
### Tests
54+
55+
32/32 MCP integration tests pass (was 27 + 5 new): auto-eviction at cap, manual evict tool, evicted entries recoverable by hash, stats includes cap, tools/list now shows omc_memory_evict.
56+
57+
15/15 memory module unit tests pass (was 10 + 5 new): eviction bounds the index, evicted entries still recoverable, evict_to_cap returns drop count, unbounded mode keeps everything, default cap matches first-10-tier sum.
58+
59+
### Why it matters
60+
61+
An agent running for hours or days will hit memory bounds. v0.6 makes that case safe by default — the agent's MOST RECENT 232 turns stay in the chronological list (easy browse via `omc_memory_list`), while older turns remain recoverable by hash but don't bloat the index. Combined with v0.5's compression, a 100-turn agent session uses bounded memory rather than the 10MB+ it would otherwise accumulate.
62+
63+
### Honest framing
64+
65+
This is index-only eviction, not full deletion — body files on disk grow with every store. A long-running agent would still benefit from an external cleanup pass for the files (cron / GC tool). A future v0.6.1 candidate: physical eviction with optional cold-storage archival.
66+
67+
### Files
68+
69+
- `omnimcode-core/src/memory.rs` — `FIBTIER_DEFAULT_*` constants, `max_entries_per_namespace`, `evict_to_cap`, auto-eviction in `store`
70+
- `omnimcode-mcp/src/main.rs` — `omc_memory_evict` tool, `fibtier_cap` in stats
71+
- `omnimcode-mcp/tests/integration.rs` — 5 new tests
72+
73+
---
74+
3175
## [v0.5-substrate-memory] - 2026-05-17
3276

3377
**Substrate-keyed conversation memory: an LLM agent's prior turns stay in cheap-reference form (canonical hash), recovered only when reasoning needs them. Measured 10.61× LLM context-budget reduction on a 20-turn agent task — hitting the original target.**

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ If you're trying to understand how OMC got here, **read the [GitHub Releases](ht
268268
| [v0.3.1-symbolic-compression](https://github.com/RandomCoder-lab/OMC/releases/tag/v0.3.1-symbolic-compression) | `omc_predict` learns to compress: `format=hash` default is 3.8× smaller, with `omc_fetch_by_hash` for on-demand body recovery |
269269
| [v0.4-substrate-context](https://github.com/RandomCoder-lab/OMC/releases/tag/v0.4-substrate-context) | Symbolic compression end-to-end: `omc_compress_context` / `omc_decompress` + directory ingest + measured 2-3× LLM context-budget reduction |
270270
| [v0.5-substrate-memory](https://github.com/RandomCoder-lab/OMC/releases/tag/v0.5-substrate-memory) | Substrate-keyed conversation memory: `omc_memory_store` / `recall` / `list` / `stats` + filesystem persistence. **10.61× LLM context-budget reduction** on a 20-turn agent task. |
271+
| [v0.6-fibtier-memory](https://github.com/RandomCoder-lab/OMC/releases/tag/v0.6-fibtier-memory) | Fibtier-bounded eviction for memory: cap the index at fibonacci-tier capacity (default 232); evicted entries still recoverable by hash. Memory now safe for arbitrarily long agent sessions. |
271272

272273
---
273274

‎ROADMAP.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# OMC Roadmap
22

3-
Current chapter: **v0.5-substrate-memory** (shipped 2026-05-17).
4-
Next chapter: open — candidates listed below. The five-chapter symbolic-context arc (v0.3 → v0.3.1 → v0.4 → v0.5) has landed with the 10× target hit (10.61× measured).
3+
Current chapter: **v0.6-fibtier-memory** (shipped 2026-05-17).
4+
Next chapter: GPU Prometheus scaffold (in flight). The six-chapter symbolic-context arc (v0.3 → v0.6) has landed.
55

66
See [CHANGELOG.md](CHANGELOG.md) and [GitHub Releases](https://github.com/RandomCoder-lab/OMC/releases) for the chapter-by-chapter history of how OMC got here. This file describes what's on the path going forward.
77

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"results": {
3+
"Q0": {
4+
"vals": [
5+
2.96439205010732,
6+
3.2229747931162516,
7+
2.8303927103678386
8+
],
9+
"mean": 3.005919851197137,
10+
"std": 0.19955849172933476
11+
},
12+
"Q1": {
13+
"vals": [
14+
3.4802677392959596,
15+
3.147650456428528,
16+
2.8683457454045613
17+
],
18+
"mean": 3.165421313709683,
19+
"std": 0.30634781569057495
20+
},
21+
"Q2": {
22+
"vals": [
23+
2.897973410288493,
24+
3.229221320152283,
25+
3.236746565500895
26+
],
27+
"mean": 3.1213137653138907,
28+
"std": 0.19345501535639265
29+
}
30+
},
31+
"config": {
32+
"seeds": "42,7,123",
33+
"steps": 1500,
34+
"lr": 0.005,
35+
"seq_len": 32,
36+
"d_model": 32,
37+
"n_heads": 4,
38+
"ff_dim": 64,
39+
"n_blocks": 4,
40+
"alpha": 1.0,
41+
"gamma": 0.2,
42+
"variants": "Q0,Q1,Q2",
43+
"out": "results_torch_substrate_q.json"
44+
},
45+
"best": "Q0"
46+
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"""Does substrate-Q resample stack on top of the v0.1 K + S-MOD + V win?
2+
3+
The v0.1 chapter shipped three stacked substrate-attention components:
4+
- K = CRT-Fibonacci substrate (no learnable W_K)
5+
- softmax → S-MOD α=1.0 (off-attractor weights dampened)
6+
- V = substrate_resample(x @ W_v) post-projection (off-attractor V mags dampened)
7+
8+
Q is the last unmodified component. The V finding's mechanism was
9+
"modulation > replacement" — keep the learned W, apply substrate as
10+
post-projection dampening. The natural Q recipe mirrors it:
11+
12+
Q1 (resample): q = substrate_resample(x @ W_q)
13+
14+
If the same modulation pattern generalizes to Q, that's a 4th
15+
stacked substrate-component — every attention primitive now substrate-
16+
aware. If it doesn't, we learn whether the V recipe was specific to
17+
the value path or whether it's a general "post-projection modulation"
18+
principle.
19+
20+
Three Q variants tested:
21+
Q0 (baseline): q = x @ W_q (current production)
22+
Q1 (resample): q = substrate_resample(x @ W_q) (post-projection snap)
23+
Q2 (modulate): q = (x @ W_q) * (1 + γ·near_attractor_signal(x))
24+
(input-conditional)
25+
26+
3 seeds on TinyShakespeare with S-MOD α=1.0, substrate-V (V1) already
27+
active. Q is the only thing varying.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import json
34+
import random
35+
import statistics
36+
from pathlib import Path
37+
38+
import torch
39+
import torch.nn as nn
40+
import torch.nn.functional as F
41+
42+
from torch_4way import lcg, make_matrix, crt_pe, build_vocab
43+
from torch_substrate_softmax import (
44+
attractor_distance, softmax_smod,
45+
)
46+
from torch_substrate_v import substrate_resample, near_attractor_signal
47+
48+
49+
class AttentionL1QV(nn.Module):
50+
"""L1 multi-head + S-MOD softmax + substrate-V (V1) + pluggable Q variant.
51+
52+
This is the v0.1 production stack with one variable: how Q is built.
53+
"""
54+
def __init__(self, d_model, n_heads, seq_len, seed,
55+
q_variant="Q0", alpha=1.0, gamma=0.2):
56+
super().__init__()
57+
assert d_model % n_heads == 0
58+
self.d_model, self.n_heads = d_model, n_heads
59+
self.d_head = d_model // n_heads
60+
self.q_variant = q_variant
61+
self.alpha = alpha
62+
self.gamma = gamma
63+
s = seed + 11
64+
W_q, s = make_matrix(d_model, d_model, 0.3, s)
65+
W_v, s = make_matrix(d_model, d_model, 0.3, s)
66+
W_o, s = make_matrix(d_model, d_model, 0.3, s)
67+
self.W_q = nn.Parameter(W_q)
68+
self.W_v = nn.Parameter(W_v)
69+
self.W_o = nn.Parameter(W_o)
70+
pe_full = crt_pe(seq_len, d_model)
71+
pe_per_head = pe_full.view(seq_len, n_heads,
72+
self.d_head).transpose(0, 1)
73+
self.register_buffer("K_const_mh", pe_per_head)
74+
self.rng_state = s
75+
76+
def forward(self, x):
77+
T, D = x.shape
78+
H, dh = self.n_heads, self.d_head
79+
# Q variants — this is the experimental axis.
80+
q_proj = x @ self.W_q
81+
if self.q_variant == "Q0":
82+
q_full = q_proj
83+
elif self.q_variant == "Q1":
84+
q_full = substrate_resample(q_proj)
85+
elif self.q_variant == "Q2":
86+
gate = near_attractor_signal(x)
87+
q_full = q_proj * (1.0 + self.gamma * gate)
88+
else:
89+
raise ValueError(self.q_variant)
90+
# V always uses substrate_resample (V1, production default from v0.1).
91+
v_full = substrate_resample(x @ self.W_v)
92+
q = q_full.view(T, H, dh).transpose(0, 1)
93+
v = v_full.view(T, H, dh).transpose(0, 1)
94+
k = self.K_const_mh
95+
scores = (q @ k.transpose(-2, -1)) / (dh ** 0.5)
96+
attn = softmax_smod(scores, dim=-1, alpha=self.alpha)
97+
out = attn @ v
98+
out = out.transpose(0, 1).contiguous().view(T, D)
99+
return out @ self.W_o
100+
101+
102+
class BlockQ(nn.Module):
103+
def __init__(self, d_model, n_heads, ff_dim, seq_len, seed,
104+
q_variant, alpha, gamma):
105+
super().__init__()
106+
self.attn = AttentionL1QV(d_model, n_heads, seq_len, seed,
107+
q_variant, alpha, gamma)
108+
s = self.attn.rng_state
109+
self.ln1_g = nn.Parameter(torch.ones(d_model))
110+
self.ln1_b = nn.Parameter(torch.zeros(d_model))
111+
W_up, s = make_matrix(d_model, ff_dim, 0.3, s + 13)
112+
W_down, s = make_matrix(ff_dim, d_model, 0.3, s)
113+
self.ff_up = nn.Parameter(W_up)
114+
self.ff_up_b = nn.Parameter(torch.zeros(ff_dim))
115+
self.ff_down = nn.Parameter(W_down)
116+
self.ff_down_b = nn.Parameter(torch.zeros(d_model))
117+
self.ln2_g = nn.Parameter(torch.ones(d_model))
118+
self.ln2_b = nn.Parameter(torch.zeros(d_model))
119+
self.rng_state = s
120+
121+
def forward(self, x):
122+
attn_out = self.attn(x)
123+
x_post_attn = x + attn_out
124+
normed1 = F.layer_norm(x_post_attn, (x.size(-1),),
125+
weight=self.ln1_g, bias=self.ln1_b)
126+
up = normed1 @ self.ff_up + self.ff_up_b
127+
activated = F.relu(up)
128+
down = activated @ self.ff_down + self.ff_down_b
129+
x_post_ff = x_post_attn + down
130+
return F.layer_norm(x_post_ff, (x.size(-1),),
131+
weight=self.ln2_g, bias=self.ln2_b)
132+
133+
134+
class ModelQ(nn.Module):
135+
def __init__(self, vocab, d_model, n_heads, ff_dim, seq_len, n_blocks,
136+
seed, q_variant, alpha, gamma):
137+
super().__init__()
138+
s = seed
139+
E, s = make_matrix(vocab, d_model, 0.3, s)
140+
self.embedding = nn.Parameter(E)
141+
self.register_buffer("pe_table", crt_pe(seq_len, d_model))
142+
self.blocks = nn.ModuleList()
143+
for i in range(n_blocks):
144+
b = BlockQ(d_model, n_heads, ff_dim, seq_len,
145+
s + 100 * (i + 1), q_variant, alpha, gamma)
146+
self.blocks.append(b)
147+
s = b.rng_state
148+
W_head, _ = make_matrix(d_model, vocab, 0.3, s + 17)
149+
self.head = nn.Parameter(W_head)
150+
self.head_b = nn.Parameter(torch.zeros(vocab))
151+
152+
def forward(self, token_ids):
153+
x = self.embedding[token_ids] + self.pe_table[:token_ids.size(0)]
154+
for b in self.blocks:
155+
x = b(x)
156+
return x @ self.head + self.head_b
157+
158+
159+
def train_one(q_variant, train_ids, val_ids, vocab_size, args, seed):
160+
torch.manual_seed(seed)
161+
random.seed(seed)
162+
model = ModelQ(vocab_size, args.d_model, args.n_heads, args.ff_dim,
163+
args.seq_len, args.n_blocks, seed, q_variant,
164+
args.alpha, args.gamma)
165+
opt = torch.optim.AdamW(model.parameters(), lr=args.lr,
166+
betas=(0.9, 0.999), eps=1e-8)
167+
n_train, n_val = len(train_ids), len(val_ids)
168+
train_t = torch.tensor(train_ids, dtype=torch.long)
169+
val_t = torch.tensor(val_ids, dtype=torch.long)
170+
for step in range(args.steps):
171+
start = random.randint(0, n_train - args.seq_len - 2)
172+
w = train_t[start:start + args.seq_len]
173+
t = train_t[start + 1:start + 1 + args.seq_len]
174+
loss = F.cross_entropy(model(w), t)
175+
opt.zero_grad()
176+
loss.backward()
177+
opt.step()
178+
model.eval()
179+
vls = []
180+
with torch.no_grad():
181+
for _ in range(30):
182+
vs = random.randint(0, n_val - args.seq_len - 2)
183+
vw = val_t[vs:vs + args.seq_len]
184+
vt = val_t[vs + 1:vs + 1 + args.seq_len]
185+
vls.append(F.cross_entropy(model(vw), vt).item())
186+
return sum(vls) / len(vls)
187+
188+
189+
def main():
190+
parser = argparse.ArgumentParser()
191+
parser.add_argument("--seeds", type=str, default="42,7,123")
192+
parser.add_argument("--steps", type=int, default=1500)
193+
parser.add_argument("--lr", type=float, default=0.005)
194+
parser.add_argument("--seq-len", type=int, default=32)
195+
parser.add_argument("--d-model", type=int, default=32)
196+
parser.add_argument("--n-heads", type=int, default=4)
197+
parser.add_argument("--ff-dim", type=int, default=64)
198+
parser.add_argument("--n-blocks", type=int, default=4)
199+
parser.add_argument("--alpha", type=float, default=1.0)
200+
parser.add_argument("--gamma", type=float, default=0.2)
201+
parser.add_argument("--variants", type=str, default="Q0,Q1,Q2")
202+
parser.add_argument("--out", type=str,
203+
default="results_torch_substrate_q.json")
204+
args = parser.parse_args()
205+
206+
corpus = (Path(__file__).parent.parent / "transformerless_lm"
207+
/ "tinyshakespeare.txt").read_text()
208+
chars, lookup = build_vocab(corpus)
209+
vocab_size = len(chars)
210+
ids = [lookup[c] for c in corpus]
211+
split = int(len(ids) * 0.9)
212+
train_ids, val_ids = ids[:split], ids[split:]
213+
seeds = [int(s) for s in args.seeds.split(",")]
214+
variants = args.variants.split(",")
215+
216+
print("=== Substrate-Q on L1-MH + S-MOD + V1 (TinyShakespeare) ===")
217+
print(f"variants={variants} seeds={seeds} steps={args.steps} "
218+
f"α={args.alpha} γ={args.gamma}\n", flush=True)
219+
220+
results = {}
221+
for v in variants:
222+
vals = []
223+
for seed in seeds:
224+
vm = train_one(v, train_ids, val_ids, vocab_size, args, seed)
225+
vals.append(vm)
226+
print(f" {v} seed={seed} val={vm:.4f}", flush=True)
227+
results[v] = {
228+
"vals": vals,
229+
"mean": sum(vals) / len(vals),
230+
"std": statistics.stdev(vals) if len(vals) > 1 else 0.0,
231+
}
232+
print(f"[{v}] mean val={results[v]['mean']:.4f} "
233+
f"std={results[v]['std']:.4f}\n", flush=True)
234+
235+
print("=== Summary ===")
236+
base = results[variants[0]]["mean"]
237+
print(f"{'variant':>8} {'mean val':>10} {'std':>7} {'vs Q0':>8}")
238+
for v in variants:
239+
m = results[v]["mean"]
240+
rel = (m - base) / base * 100
241+
marker = "—" if v == variants[0] else f"{rel:+.2f}%"
242+
print(f"{v:>8} {m:>10.4f} {results[v]['std']:>7.4f} {marker:>8}")
243+
best = min(variants, key=lambda v: results[v]["mean"])
244+
print(f"\nBest: {best} ({results[best]['mean']:.4f})")
245+
246+
out_path = Path(__file__).parent / args.out
247+
with open(out_path, "w") as f:
248+
json.dump({"results": results, "config": vars(args),
249+
"best": best}, f, indent=2, default=float)
250+
print(f"Wrote {out_path}")
251+
252+
253+
if __name__ == "__main__":
254+
main()

0 commit comments

Comments
 (0)