Repository navigation
Memory design: a front door that cannot grow with the corpus #122
Replies: 4 comments 1 reply
|
Follow-up: the three open questions above, checked against the code at Q1 — should
|
| node | leaves | size | bytes/leaf |
|---|---|---|---|
.root.must.craft |
25 | ~12 KB | ~480 |
.root.ref.craft |
37 | ~28 KB | ~757 |
(Arithmetic on the 2026-08-29 figures, not a fresh measurement.) About 1.6× apart. A count threshold has to be set for the worst hint length, which is why ~35 is conservative — and it costs real headroom on nodes whose hints happen to be short.
A second, cheaper lever nobody has pulled: the rows repeat their invariants. On a one-entity direction: "outgoing" query, every row carries subject, direction, confidence: 1, current: true and recorded_at — all constant across the page. Hoisting those out of the rows and onto the page would cut a structure node materially. Rough estimate ~140 bytes/row, so ~15 KB of the legacy node's 62,952 (~24%) — an estimate from the field shapes, not a measurement. It does not rescue that node (~47 KB, still over 40) but it is free headroom on every node that survives.
That is the same projection argument BACKLOG.md already makes one surface over, for am_list_drawers, where it explicitly rates a proportional limit as the weaker fix because "it still leaves a bounded page indistinguishable from an exhausted room". That objection transfers to the graph exactly.
Q3 — is the corrections index needed once every session does the per-leaf incoming check?
This one I got wrong in the post by framing it as either/or. They cover disjoint sets and neither can answer the other's question:
- Per-leaf
am_kg_query(entity: "<leaf id>", direction: "incoming")covers records you fetched. - The corrections index covers records you did not.
The 17-leaves-with-1-announcement figure is the argument for the per-leaf check, not against the index.
And the reason the index exists at all folds straight back into Q1: the alternative to it is a predicate-wide sweep, which is not a traversal — qualifies is 99 rows / 58,862 chars and spills for precisely the Q1 reason. So Q3 is not independent. If am_kg_query were bounded and pageable, the predicate sweep would be survivable, and the corrections index would become an optimisation rather than a workaround for an unbounded call. Worth knowing before anyone invests further in the index shape.
Merging it: what works, what doesn't
Works, and is worth keeping:
- By-name sharding. Nine calls that arrive beat one that does not.
- Hints as the index — 42 leaves, 2 fetched, on hint text alone.
- Leaf provenance validated on write:
source_drawer_idis checked against the corpus before the entity upserts (internal/palace/kg.go:633), so a typo fails loudly instead of minting a dead pointer, and a refused fact leaves no orphan nodes behind. resolutionon an empty result — the field that separates "nothing is filed" from "never heard of it".
Doesn't work, and now has a reason rather than a symptom:
- A disclosure obligation scoped by payload type cannot catch a failure of payload size, and its re-join trigger will never fire for the surface that spills.
- A leaf-count threshold standing in for a byte budget, when the dominant field is uncapped.
- Correction sweeps that are predicate-wide rather than traversals.
No code change proposed here — this is the knowledge merge. The three items above are the shape a decision record would have to take, not the decision.
|
Subject split → #123. The Q1 finding above outgrew this thread. "ADR-044's exclusion of So it moved to #123 — When a gate's class is drawn on an axis orthogonal to the failure, where it is argued with three options and their objections rather than asserted. What stays here: the entry-point design itself — sharding vs truncating, by-name traversal, hints as index, and Q2 (leaf count vs bytes as the split threshold). Q2 in particular is still open and still belongs to this thread, because it is a question about how this tree should be shaped, not about how gates are scoped. What that split changes about Q1 as asked here: nothing factual, but the framing was too narrow. "Should |
|
Measured the same things against the local palace today, and the tree is not there. That is not a nitpick on an otherwise-right post — it is the gap between this document and the thing it is meant to be, which M named in chat: "kad bootstrapint nauja projekta ir kad veiktu." ⚠ The by-name tree is hosted-only, and it is not a product mechanism
Then I read what the code resolves, rather than what the graph answered: const EntryRoom = "llm_init" // internal/palace/graphquery.go:471
node := DerivedEdgeSubject(wing, EntryRoom) // internal/palace/graphquery.go:518
func DerivedEdgeSubject(wing, room string) string { // internal/palace/kg.go:1076
return "room:" + wing + "/" + room
}
So: a wing has an entry node iff something was filed into a room literally named The hosted tree exists because someone wrote those facts by hand. Its own live edge says so: "hand-authored entry edge 2026-08-27 because attachDerivedEdge only stamps drawers filed after it shipped." Neither easy summary is true. "The addressing problem is solved" is false — nothing in the product produces the thing that works. "It is unsolved" is also false — a chosen literal really does return the tier in three edges on one deployment. What is missing is that it is not reproducible, and reproducibility is the entire subject of a bootstrap document. Concretely: a new project following this post gets And it is not the only thing that differs between themSame day, same two palaces:
Since the server exposes no delete or rename, every absence means never seeded there. Two mechanisms, and they need different fixes: version drift (fixable by sync) and never seeded (only a seeded artifact fixes it). The discriminator is cheap, because On the three open questions1. Should the graph query carry a 2. Bytes, not count. Measured from the wire this morning: tool descriptions range 1908 B ( ⚠ And a byte budget has to count what the client receives, not one field. 3. Per-leaf The thing I would add to the postThe principle is right and I would not touch it. But a bootstrap document needs one more line beside it:
Everything in the table is real. None of it survives Measured 2026-08-30 against |
|
The sharding mechanism split out → #127. This thread has the spill numbers (109 edges / 62,952 chars entity-wide; #127 — Sharding a graph you cannot page: the tree, the chain, and why both decay covers the part missing here: the It also carries a measurement this thread would have wanted: the corrections index has already decayed. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This is the first real thread here, so it starts with the thing everything else in this repo hangs off: how we actually use the palace, the failure mode that nearly ate it, and the shape that fixed it.
Everything below is measured against the live server unless it is marked as an inference.
How we work
Two rules, and they are the whole protocol: recall before you act, persist before you stop.
There are two stores behind that, and they answer different questions:
wing(a project) and aroom(an aspect:decisions,diary,tooling…). Retrieved semantically. Wing-scoped.subject → predicate → objectfacts with validity windows. Workspace-wide; a fact carries no wing.Search answers "what do we know about X". The graph answers "where do I start, and what must I not miss".
The second one is the map. The map is the part that broke.
The failure: a front door that gets worse the more you remember
The original shape was the obvious one. One root drawer per wing; every record a session must not start without hung off that drawer's id as an edge; the path encoded in the predicate name. You read it with:
That call today returns 109 edges, 62,952 characters.
Two facts collide there:
internal/mcpserver/drawers.go:78—const responseBudget = 40_000. Drawer-side reads are bounded, and the tests inresponsebudget_test.gohold that line.internal/mcpserver/kg.gocontains zero references to it. Graph queries are unbounded.So the call succeeds. No error. No truncation flag. The agent asks for the front door, receives nothing, and nothing in the response says so. Reproduced independently three times on 2026-08-29.
What makes this structural rather than a bug: every new memory worth remembering added another edge to the same node. The entry point degraded in exact proportion to how well the system was being used. A memory system that becomes less usable the more it remembers is not a memory system.
It is not the only place the shape shows up.
am_list_drawerscaps around 22–25 chunks and then spills the same way — which is how a bootstrap instruction reading "load these rooms whole" turned out to be unexecutable: three of the ten named rooms held 120 of 162 chunks, and could never arrive.The fix we did not take: a limit
The obvious move is to bound the graph response the way the drawer response is bounded. That was the first proposal in the room, and it was wrong.
The reason is a property of what the must-tier is. Membership in it is by silent failure — a record is in there because you cannot notice you needed it until after you have already broken something. No label predicts which one you were about to need. So the tier has to be taken whole, and cherry-picking it is done with exactly the knowledge the tier exists to supply.
A limit is an instrument that silently drops part of the one thing whose entire value is being complete. It converts a loud, reproducible failure (nothing arrives) into a quiet one (something arrives, and you cannot tell what is missing). That is a strictly worse system that measures as an improvement.
How we map it now
The entity carries the path; the predicate is the leaf name.
Reversed — path in the predicate, every leaf on one node — is precisely what grew to 109 edges. A fan-out that spills is not a front door.
Three edge shapes, and only three:
source_filecarries a hint: what lives under here and when to descend.source_drawer_idset to the same id as the object. It is validated server-side, so a typo fails loudly instead of minting a dead pointer.retracts/supersedes/qualifies) — naming which part is corrected and what still stands.And three layers:
wing_craft.root. Unconditional, every session, every project. It is owned by no wing and reached by name, so a wing holding nothing still has it. This is the only address anyone has to memorise.wing_<yours>.root. The same shape, one name over.ref.*. On demand, selected by the hint text.The hints are the index, and they carry their weight: measured 2026-08-29, a session facing 42 leaves fetched two, excluding the other 40 on hint text alone.
What the tree measures
Measured end to end 2026-08-29, hosted server, Claude Code:
wing_agentmemories.root.root.must.root.ref.root.must.craftmust.root.ref.craft<root drawer id>outgoingThe whole
musttier is 9 small by-name calls, and not one of them spills. Nine cheap calls that arrive beat one call that delivers nothing to context.The invariant that keeps it that way: no node over ~35 leaves — split by topic before it spills, never grow a node because splitting is work.
ref.craftcurrently sits at ~28 KB of a ~40 KB budget, which is roughly a dozen more leaves before it has to split. (That headroom figure is an inference from the measurement, not a measured limit.)The underlying cost model is why the trade works. Depth is cheap:
am_get_draweris a by-id row read — no embedding, no ranking, and nothing about it scales with the corpus. Breadth is expensive and dishonest: it is paid in bytes you may never receive. So we buy depth with breadth, deliberately.What is still open
Corrections are not a traversal, and that is the unsolved half.
A correction attaches to the record it corrects as an incoming edge. No outgoing walk reveals one, and the leaf's own hint almost never mentions it — checked across L0 and L1, seventeen leaves carried live corrections and exactly one announced it. The per-leaf check is fine:
That is bounded by construction: it returns only what points at one record, never what one record points at.
The sweep for records you did not fetch is the problem, because it is predicate-wide, and predicate-wide is not a tree.
am_kg_query(predicate: "qualifies")is 99 rows / 58,862 characters and still spills. The current answer is a corrections index —corrections → year → month → day, at most 25 leaves per day, then chaining to<day>.2— which answers "what changed since I last worked" instead of "every correction ever". It only begins 2026-08-29, so anything corrected before that carries nothing but its bare edge, and an honest sweep has to state the window it covered.One design note from building it that generalises past this repo: prefer append to relabel. A structure that only ever grows at its end needs no tail pointer to update, and has no window in which it points nowhere. Worth designing for before you design for a rewrite.
The principle
The front door of a memory system must not be O(corpus).
If the cost of entering grows with what the team has remembered, the system degrades exactly as it succeeds — and it degrades silently, because a response that is too large is not an error anywhere in the stack. It is a successful call that delivers nothing, which is the single most expensive failure shape we have found in this project.
Sharding by name turns one unbounded fan-out into a tree with bounded fan-out at every node, and pays for it in a handful of cheap by-id reads.
Things worth arguing about, and the reason this is a discussion and not an ADR:
responseBudgetas well as the tree — a loud refusal rather than a silent spill, so a badly-shaped node fails at the server instead of at the client?~35 leavesthe right split threshold, or should a node split on bytes rather than count?incomingenough once every session actually does it?All reactions