feat(sf): wire memory_relations into ranking — graph-boost pass

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) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-03 00:09:33 +02:00
parent 1da4d5fdf6
commit 55b14c3f78
3 changed files with 139 additions and 2 deletions

View file

@ -301,6 +301,57 @@ export function loadEmbeddingMap(): Map<string, Float32Array> {
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<string, number>();
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.

View file

@ -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<string>();
// 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);

View file

@ -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" });