feat(sf): wire memory_relations into ranking — graph-boost pass
memory_relations was storage-only since56ee89a94/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:
parent
1da4d5fdf6
commit
55b14c3f78
3 changed files with 139 additions and 2 deletions
|
|
@ -301,6 +301,57 @@ export function loadEmbeddingMap(): Map<string, Float32Array> {
|
||||||
return 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<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 ────────────────────────────────────
|
// ─── Auto-engagement / backfill driver ────────────────────────────────────
|
||||||
|
|
||||||
/** Find active memories (not superseded) that don't yet have an embedding row.
|
/** Find active memories (not superseded) that don't yet have an embedding row.
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export async function getRelevantMemoriesRanked(
|
||||||
if (!queryVec || embeddingMap.size === 0) {
|
if (!queryVec || embeddingMap.size === 0) {
|
||||||
return pool.slice(0, limit);
|
return pool.slice(0, limit);
|
||||||
}
|
}
|
||||||
const ranked = rankMemoriesByEmbedding(
|
let ranked = rankMemoriesByEmbedding(
|
||||||
pool.map((m) => ({
|
pool.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
staticScore: m.confidence * (1 + m.hit_count * 0.1),
|
staticScore: m.confidence * (1 + m.hit_count * 0.1),
|
||||||
|
|
@ -183,8 +183,37 @@ export async function getRelevantMemoriesRanked(
|
||||||
queryVec,
|
queryVec,
|
||||||
embeddingMap,
|
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]));
|
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[] = [];
|
const topK: Memory[] = [];
|
||||||
for (const r of ranked) {
|
for (const r of ranked) {
|
||||||
const mem = byId.get(r.id);
|
const mem = byId.get(r.id);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, test, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyRelationBoost,
|
||||||
loadEmbeddingMap,
|
loadEmbeddingMap,
|
||||||
rankMemoriesByEmbedding,
|
rankMemoriesByEmbedding,
|
||||||
saveEmbedding,
|
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", () => {
|
describe("loadEmbeddingMap", () => {
|
||||||
test("returns vectors keyed by memoryId for active memories", () => {
|
test("returns vectors keyed by memoryId for active memories", () => {
|
||||||
const a = createMemory({ category: "architecture", content: "alpha" });
|
const a = createMemory({ category: "architecture", content: "alpha" });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue