From 55b14c3f781892a6e9422a30ccbc254190ec7598 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 3 May 2026 00:09:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(sf):=20wire=20memory=5Frelations=20into=20?= =?UTF-8?q?ranking=20=E2=80=94=20graph-boost=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory_relations was storage-only since 56ee89a94 / 23c5de38b. Now getRelevantMemoriesRanked walks edges of cosine top-N memories and applies a one-pass score-boost to neighbors: combined += parent_score × edge_confidence × damping where damping=0.4 by default. Both endpoints of an edge get the boost symmetrically (memory A pulling B is equally evidence that B is relevant to A's context). Pure helper `applyRelationBoost(ranked, edges, options)` lives in memory-embeddings.ts so memory-store.ts doesn't take a direct dependency on memory-relations.ts; the call site composes the two modules. When memory_relations is empty (the case until a writer adds edges — a future agent or hook), applyRelationBoost returns the input unchanged → no behavior change today. Intra-pool only: cross-pool edges (where one endpoint is outside the 50–200 cosine pool) are skipped to avoid pulling in low-static memories on a hot edge alone. Pool expansion via relations would be a separate, more invasive feature. 4 new tests cover empty edges, empty ranked, cross-pool edge skip, and the canonical "low-but-related promoted above lone" case. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../extensions/sf/memory-embeddings.ts | 51 +++++++++++++++++ src/resources/extensions/sf/memory-store.ts | 33 ++++++++++- .../sf/tests/memory-query-ranking.test.ts | 57 +++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/sf/memory-embeddings.ts b/src/resources/extensions/sf/memory-embeddings.ts index ee6dc12c3..f180a7aeb 100644 --- a/src/resources/extensions/sf/memory-embeddings.ts +++ b/src/resources/extensions/sf/memory-embeddings.ts @@ -301,6 +301,57 @@ export function loadEmbeddingMap(): Map { return map; } +/** Edge for the relation-boost pass — supplied by memory-relations.ts at + * the call site (kept abstract here so memory-embeddings doesn't dep on + * memory-relations directly). */ +export interface BoostEdge { + from: string; + to: string; + confidence: number; +} + +/** Apply a one-pass graph-boost to an embedding-ranked list. For each + * edge (A, B) where both endpoints are in the pool, B's combined score + * gains `score(A) × edge.confidence × damping`. Same for the reverse + * direction (relations are symmetric for ranking purposes — if A pulls + * B, B should also pull A by the same amount). The boost is additive + * and capped at one iteration so an N-memory pool never explodes + * beyond O(N + |edges|) work. + * + * When the pool has no edges (the empty-graph case while no writers + * exist), returns the input unchanged. */ +export function applyRelationBoost( + ranked: Array<{ id: string; combinedScore: number; cosine: number }>, + edges: BoostEdge[], + options?: { damping?: number }, +): Array<{ id: string; combinedScore: number; cosine: number }> { + const damping = options?.damping ?? 0.4; + if (edges.length === 0 || ranked.length === 0) return ranked; + const scoreById = new Map(ranked.map((r) => [r.id, r.combinedScore])); + const boosts = new Map(); + const bumpScore = (id: string, contribution: number): void => { + boosts.set(id, (boosts.get(id) ?? 0) + contribution); + }; + for (const edge of edges) { + const fromScore = scoreById.get(edge.from); + const toScore = scoreById.get(edge.to); + // Only boost edges where BOTH endpoints are in the pool — keeps the + // pass intra-pool and prevents a hot edge from pulling in unrelated + // low-static memories that would otherwise never reach the cosine + // pool. Cross-pool edges may matter eventually but require a + // separate "expand pool by relation" step that's out of scope here. + if (fromScore == null || toScore == null) continue; + bumpScore(edge.to, fromScore * edge.confidence * damping); + bumpScore(edge.from, toScore * edge.confidence * damping); + } + return ranked + .map((r) => ({ + ...r, + combinedScore: r.combinedScore + (boosts.get(r.id) ?? 0), + })) + .sort((a, b) => b.combinedScore - a.combinedScore); +} + // ─── Auto-engagement / backfill driver ──────────────────────────────────── /** Find active memories (not superseded) that don't yet have an embedding row. diff --git a/src/resources/extensions/sf/memory-store.ts b/src/resources/extensions/sf/memory-store.ts index 05a8e818b..1e7cfe057 100644 --- a/src/resources/extensions/sf/memory-store.ts +++ b/src/resources/extensions/sf/memory-store.ts @@ -175,7 +175,7 @@ export async function getRelevantMemoriesRanked( if (!queryVec || embeddingMap.size === 0) { return pool.slice(0, limit); } - const ranked = rankMemoriesByEmbedding( + let ranked = rankMemoriesByEmbedding( pool.map((m) => ({ id: m.id, staticScore: m.confidence * (1 + m.hit_count * 0.1), @@ -183,8 +183,37 @@ export async function getRelevantMemoriesRanked( queryVec, embeddingMap, ); + // One-pass relation boost: when memory_relations holds edges between + // pooled memories, propagate score across edges. No-op when the + // table is empty (the case until a writer adds edges) — listRelations + // returns nothing per memory, applyRelationBoost short-circuits. + try { + const { applyRelationBoost } = await import("./memory-embeddings.js"); + const { listRelationsFor } = await import("./memory-relations.js"); + const edges: import("./memory-embeddings.js").BoostEdge[] = []; + const seen = new Set(); + // Walk relations only for the cosine top-N (N = 2× limit) — we + // don't need edges for tail-of-pool memories that won't reach + // the result anyway. + const probeIds = ranked.slice(0, Math.min(ranked.length, limit * 2)); + for (const r of probeIds) { + for (const rel of listRelationsFor(r.id)) { + const key = `${rel.from}->${rel.to}:${rel.rel}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + from: rel.from, + to: rel.to, + confidence: rel.confidence, + }); + } + } + ranked = applyRelationBoost(ranked, edges); + } catch { + // Relation boost is additive; failure preserves cosine ranking. + } const byId = new Map(pool.map((m) => [m.id, m])); - // Top-K from cosine rank — feed this into the optional rerank pass. + // Top-K from cosine+relation rank — feed this into the optional rerank pass. const topK: Memory[] = []; for (const r of ranked) { const mem = byId.get(r.id); diff --git a/src/resources/extensions/sf/tests/memory-query-ranking.test.ts b/src/resources/extensions/sf/tests/memory-query-ranking.test.ts index 71808fa5b..48692fc03 100644 --- a/src/resources/extensions/sf/tests/memory-query-ranking.test.ts +++ b/src/resources/extensions/sf/tests/memory-query-ranking.test.ts @@ -16,6 +16,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, test, vi } from "vitest"; import { + applyRelationBoost, loadEmbeddingMap, rankMemoriesByEmbedding, saveEmbedding, @@ -121,6 +122,62 @@ describe("rankMemoriesByEmbedding (pure)", () => { }); }); +describe("applyRelationBoost (pure)", () => { + test("returns input unchanged when there are no edges", () => { + const ranked = [ + { id: "a", combinedScore: 1.0, cosine: 0.8 }, + { id: "b", combinedScore: 0.5, cosine: 0.4 }, + ]; + const out = applyRelationBoost(ranked, []); + assert.deepEqual(out, ranked); + }); + + test("returns input unchanged when ranked is empty", () => { + const out = applyRelationBoost( + [], + [{ from: "a", to: "b", confidence: 1 }], + ); + assert.deepEqual(out, []); + }); + + test("ignores edges where either endpoint is outside the pool", () => { + const ranked = [ + { id: "a", combinedScore: 1.0, cosine: 1 }, + { id: "b", combinedScore: 0.5, cosine: 0.5 }, + ]; + const out = applyRelationBoost( + ranked, + [{ from: "a", to: "outside", confidence: 1.0 }], + ); + // "outside" never reaches the pool; "a" should not be boosted. + assert.deepEqual( + out.map((r) => r.id), + ["a", "b"], + ); + assert.equal(out[0].combinedScore, 1.0); + assert.equal(out[1].combinedScore, 0.5); + }); + + test("intra-pool edge boosts both endpoints symmetrically", () => { + const ranked = [ + { id: "high", combinedScore: 1.0, cosine: 1 }, + { id: "low-but-related", combinedScore: 0.2, cosine: 0.2 }, + { id: "lone", combinedScore: 0.5, cosine: 0.5 }, + ]; + const out = applyRelationBoost( + ranked, + [{ from: "high", to: "low-but-related", confidence: 1.0 }], + { damping: 0.4 }, + ); + // low-but-related: 0.2 + (1.0 * 1.0 * 0.4) = 0.6 → bumps above lone (0.5) + // high: 1.0 + (0.2 * 1.0 * 0.4) = 1.08 → still on top + // lone: unchanged at 0.5 + assert.equal(out[0].id, "high"); + assert.equal(out[1].id, "low-but-related"); + assert.equal(out[2].id, "lone"); + }); +}); + describe("loadEmbeddingMap", () => { test("returns vectors keyed by memoryId for active memories", () => { const a = createMemory({ category: "architecture", content: "alpha" });