Skip to content

Topic tree

Section titled “Why B-Tree invariants instead of flat search”

Semantic similarity can answer “which existing topic is closest to this item?” — it cannot safely answer “should this be a new topic?”, “should two topics merge?”, or “is retrieval getting noisy?”. A growing topic can hold many similar artifacts and still be bad for retrieval (a Database topic containing migrations, queries, and transactions).

The tree borrows B-Tree invariants, not its data structure (embeddings have no total order):

Topic node = routing page (centroid + radius + counts)
Leaf topic = bucket owning members (≤ maxLeafItems)
Internal topic = router over child topics (≤ maxChildren)
__root__ ← fixed entry point
┌────┴────┐
Internal Internal ← routers: closest centroid
┌────┴───┐ ┌┴────┐ wins at each level
Leaf Leaf Leaf Leaf ← buckets: hold the memories
[memories] [memories] [memories] (≤ maxLeafItems each)

Topics track local shape — centroid, radius, member count, child count, depth, model — and the golden rule: compare a topic against itself, its children, or its siblings; never against one global threshold.

Every centroid/radius computation uses BIRCH CF-triplets — sufficient statistics over a vector set:

CF = { count n, linear sum LS, sum of squares SS }
centroid = LS / n
RMS radius = sqrt(max(SS/n − |centroid|², 0))
weighted radius = SS − |LS|² / n (sum of squared deviations)

The point: these are exact from the triplet alone — no vector rescan. Adding or removing one vector updates the triplet in O(dim); split, merge, rebalance, and concept clustering all derive centroids/radii from triplets. This is the canonical definition — the same primitive appears in Concepts & consolidation.

Algorithm — greedy descent:

route_insert(artifact):
vector = embed(artifact)
node = __root__
while node has children:
children = sorted(node.children by topic_key)
node = child with closest centroid (ties → smaller topic_key)
insert artifact into node
refresh centroid/radius/counts upward (CF-triplet updates)
if node.member_count > maxLeafItems: enqueue split(node)

Deterministic by construction: same input data, model version, and sort order ⇒ same placement. Ties always break to the lexicographically smaller topic_key — never to insertion order or memory ids.

New topics are created only by splits — never by one-off similarity guesses. A leaf over maxLeafItems runs:

Algorithm — deterministic split (farthest-pair + Lloyd refinement):

leaf over budget (129 members) after deterministic split
───────────────────────────── ─────────────────────────
t_x t_x
│ farthest-pair ┌───┴───┐
▼ + Lloyd refine ▼ ▼
129 members ──────────────────────▶ leaf-a leaf-b
(64) (65)
accept ONLY IF:
left_count ≥ minLeafItems
right_count ≥ minLeafItems
weighted_child_radius < parent_radius (children are cleaner)
child keys = hashes of the CANONICAL vectors (never memory ids)

The acceptance rule is the anti-thrash guard: only split if the children are cleaner than the parent. A rejected split marks the topic split_deferred — it stays fully routable while a durable job retries. A leaf that keeps rejecting hits the hard-limit backstop (splitDeferredHardLimit = 4× the budget) and is force-split.

Determinism at the root: child keys are hashes of the canonical vectors, never memory ids — so the same embeddings produce the same tree across runs, machines, and databases (regression-tested).

Internal nodes split the same way when child_count > maxChildren, with child topics as the units.

Underflow (member_count < minLeafItems) self-heals without thrash:

underflow (10 < minLeafItems 32)
├─▶ borrow ── nearest larger sibling donates
│ min(needed, donor) at a time — donor-min rule
│ (skip manual_lock members)
└─▶ merge ── consume the nearest sibling ONLY when
the result ≤ maxLeafItems
(a merge must never immediately re-overflow)
otherwise stay underfilled

manual_lock memberships are never auto-moved — a locked member stays put unless a reviewer transfers it.

Even valid siblings can become uneven:

Auth
OAuth 34 items
Sessions 126 items
Tokens 118 items

Rule: rebalance when largest_sibling_count > 2 × smallest_sibling_count. Move borderline members (closer to the small sibling’s centroid than the large one’s, manual_lock = false, radii preserved or improved) from the large side to the small side. Cooldown-gated (rebalanceCooldownMs) to avoid churn.

__root__ the tree's fixed entry point (never removed)
__unassigned_active__ members without embeddings / ambiguous routes —
batched and promoted only through the same
deterministic split logic