fix(memory-graph): deduplicate shared graph entities

This commit is contained in:
shamAnimates 2026-08-17 14:39:43 +05:30
parent 5d2b5855fe
commit 2d8a6350a2
6 changed files with 713 additions and 62 deletions

View file

@ -29,5 +29,13 @@ jobs:
- name: Run TypeScript type checking
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
- name: Run memory graph unit tests
working-directory: packages/memory-graph
run: bun run test
- name: Run web unit tests
working-directory: apps/web
run: bun test
- name: Run Biome CI (format & lint on changed files)
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched

View file

@ -0,0 +1,276 @@
import { describe, expect, it, mock } from "bun:test"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
import type {
ApiDocument,
ApiDocumentsResponse,
ApiMemoryEntry,
} from "./use-graph-api"
const ISOLATED_HOOK_TEST_ENV = "GRAPH_API_HOOK_INTEGRATION_TEST"
const testFilePath = fileURLToPath(import.meta.url)
if (process.env[ISOLATED_HOOK_TEST_ENV] === "1") {
await registerUseGraphApiIntegrationTests()
} else {
const graphApi = await import("./use-graph-api")
registerGraphApiPaginationTests(graphApi)
describe("useGraphApi document normalization wiring", () => {
it("passes in an isolated Bun process", () => {
const result = spawnSync(process.execPath, ["test", testFilePath], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, [ISOLATED_HOOK_TEST_ENV]: "1" },
})
if (result.status !== 0) {
throw new Error(
`Isolated useGraphApi tests failed:\n${result.stdout}\n${result.stderr}`,
)
}
expect(result.status).toBe(0)
})
})
}
function makeMemory(
id: string,
spaceContainerTag: string,
overrides: Partial<Omit<ApiMemoryEntry, "id" | "spaceContainerTag">> = {},
): ApiMemoryEntry {
return {
id,
memory: id,
spaceId: "space",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
spaceContainerTag,
...overrides,
}
}
function makeDocument(
id: string,
memoryEntries: ApiMemoryEntry[],
overrides: Partial<Omit<ApiDocument, "id" | "memoryEntries">> = {},
): ApiDocument {
return {
id,
title: id,
type: "text",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
memoryEntries,
}
}
function makePage(
currentPage: number,
totalPages: number,
documents: ApiDocument[],
): ApiDocumentsResponse {
return {
documents,
pagination: {
currentPage,
limit: 500,
totalItems: documents.length,
totalPages,
},
}
}
function makePages(): ApiDocumentsResponse[] {
return [
makePage(1, 3, [
makeDocument(
"doc-a",
[
makeMemory("shared-memory", "included", {
relation: "extends",
}),
makeMemory("filtered-memory", "excluded"),
],
{ title: "First canonical title", summary: "First canonical summary" },
),
]),
makePage(2, 3, [
makeDocument("doc-b", [
makeMemory("shared-memory", "included", { relation: "derives" }),
makeMemory("unique-memory", "included"),
]),
makeDocument(
"doc-a",
[
makeMemory("shared-memory", "included", {
relation: "updates",
}),
makeMemory("late-memory", "included", { relation: "derives" }),
],
{ title: "Later duplicate title", summary: "Later duplicate summary" },
),
]),
]
}
function registerGraphApiPaginationTests(
graphApi: Pick<
typeof import("./use-graph-api"),
"getLoadedGraphNodeCount" | "getNextGraphPageParam"
>,
) {
const pages = makePages()
describe("graph API pagination node accounting", () => {
it("counts unique document and memory IDs across every loaded page", () => {
expect(graphApi.getLoadedGraphNodeCount(pages)).toBe(6)
expect(graphApi.getLoadedGraphNodeCount(pages, ["included"])).toBe(5)
})
it("uses the filtered unique count to decide whether another page is needed", () => {
const lastPage = pages.at(-1)
if (!lastPage) throw new Error("Missing pagination fixture")
expect(
graphApi.getNextGraphPageParam(lastPage, pages, {
hasDocumentIds: false,
maxNodes: 6,
}),
).toBeUndefined()
expect(
graphApi.getNextGraphPageParam(lastPage, pages, {
hasDocumentIds: false,
maxNodes: 6,
containerTags: ["included"],
}),
).toBe(3)
expect(
graphApi.getNextGraphPageParam(lastPage, pages, {
hasDocumentIds: false,
maxNodes: 5,
containerTags: ["included"],
}),
).toBeUndefined()
})
it("never paginates the by-IDs request path", () => {
const lastPage = pages.at(-1)
if (!lastPage) throw new Error("Missing pagination fixture")
expect(
graphApi.getNextGraphPageParam(lastPage, pages, {
hasDocumentIds: true,
maxNodes: 100,
containerTags: ["included"],
}),
).toBeUndefined()
})
})
}
async function registerUseGraphApiIntegrationTests() {
const pages = makePages()
type QueryOptions = {
getNextPageParam: (
lastPage: ApiDocumentsResponse,
allPages: readonly ApiDocumentsResponse[],
) => number | undefined
}
let queryOptions: QueryOptions | null = null
mock.module("@tanstack/react-query", () => ({
useInfiniteQuery: (options: QueryOptions) => {
queryOptions = options
return {
data: { pages },
error: null,
isPending: false,
isFetchingNextPage: false,
hasNextPage: true,
fetchNextPage: mock(async () => undefined),
}
},
}))
mock.module("react", () => ({
useEffect: () => undefined,
useMemo: <T>(factory: () => T) => factory(),
}))
mock.module("@lib/api", () => ({
$fetch: () => {
throw new Error("The integration fixture must not make a request")
},
}))
const { getLoadedGraphNodeCount, useGraphApi } = await import(
"./use-graph-api"
)
describe("useGraphApi cross-page document normalization", () => {
it("renders each document once while retaining first metadata and later memories", () => {
const result = useGraphApi({
containerTags: ["included"],
maxNodes: 5,
})
const documentIds = result.documents.map((document) => document.id)
expect(documentIds).toEqual(["doc-a", "doc-b"])
expect(new Set(documentIds).size).toBe(result.documents.length)
const documentA = result.documents.find(
(document) => document.id === "doc-a",
)
const documentB = result.documents.find(
(document) => document.id === "doc-b",
)
expect(documentA).toBeDefined()
expect(documentB).toBeDefined()
expect(documentA?.title).toBe("First canonical title")
expect(documentA?.summary).toBe("First canonical summary")
expect(documentA?.memories.map((memory) => memory.id)).toEqual([
"shared-memory",
"late-memory",
])
expect(documentA?.memories[0]?.relation).toBe("extends")
expect(documentA?.memories[1]?.relation).toBe("derives")
expect(documentB?.memories[0]?.id).toBe("shared-memory")
expect(documentB?.memories[0]?.relation).toBe("derives")
const uniqueMemoryIds = new Set(
result.documents.flatMap((document) =>
document.memories.map((memory) => memory.id),
),
)
const renderedUniqueNodeCount =
new Set(documentIds).size + uniqueMemoryIds.size
expect(renderedUniqueNodeCount).toBe(5)
expect(getLoadedGraphNodeCount(pages, ["included"])).toBe(
renderedUniqueNodeCount,
)
if (!queryOptions) throw new Error("Query options were not captured")
const lastPage = pages.at(-1)
if (!lastPage) throw new Error("Missing pagination fixture")
expect(queryOptions.getNextPageParam(lastPage, pages)).toBeUndefined()
})
it("keeps production pagination open below the unique-node budget", () => {
queryOptions = null
const result = useGraphApi({
containerTags: ["included"],
maxNodes: 6,
})
expect(result.documents.map((document) => document.id)).toEqual([
"doc-a",
"doc-b",
])
expect(getLoadedGraphNodeCount(pages, ["included"])).toBe(5)
if (!queryOptions) throw new Error("Query options were not captured")
const lastPage = pages.at(-1)
if (!lastPage) throw new Error("Missing pagination fixture")
expect(queryOptions.getNextPageParam(lastPage, pages)).toBe(3)
})
})
}

View file

@ -18,7 +18,7 @@ interface UseGraphApiOptions {
maxNodes?: number
}
interface ApiMemoryEntry {
export interface ApiMemoryEntry {
id: string
memory: string
content?: string | null
@ -40,7 +40,7 @@ interface ApiMemoryEntry {
spaceContainerTag?: string | null
}
interface ApiDocument {
export interface ApiDocument {
id: string
title: string | null
summary?: string | null
@ -50,7 +50,7 @@ interface ApiDocument {
memoryEntries: ApiMemoryEntry[]
}
interface ApiDocumentsResponse {
export interface ApiDocumentsResponse {
documents: ApiDocument[]
pagination: {
currentPage: number
@ -60,13 +60,6 @@ interface ApiDocumentsResponse {
}
}
function getGraphNodeCount(documents: ApiDocument[]): number {
return documents.reduce(
(total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0),
0,
)
}
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
@ -115,6 +108,83 @@ function toGraphDocument(
}
}
function normalizeGraphPages(
pages: readonly ApiDocumentsResponse[],
containerTags?: string[],
): GraphApiDocument[] {
// Keep the first document and per-document memory occurrence canonical, but
// merge later unique memories. The same memory may still belong to many docs.
const documentsById = new Map<
string,
{ document: GraphApiDocument; memoryIds: Set<string> }
>()
for (const page of pages) {
for (const apiDocument of page.documents ?? []) {
const graphDocument = toGraphDocument(apiDocument, containerTags)
const existing = documentsById.get(graphDocument.id)
if (!existing) {
const memoryIds = new Set<string>()
const memories = graphDocument.memories.filter((memory) => {
if (memoryIds.has(memory.id)) return false
memoryIds.add(memory.id)
return true
})
documentsById.set(graphDocument.id, {
document: { ...graphDocument, memories },
memoryIds,
})
continue
}
for (const memory of graphDocument.memories) {
if (existing.memoryIds.has(memory.id)) continue
existing.memoryIds.add(memory.id)
existing.document.memories.push(memory)
}
}
}
return Array.from(documentsById.values(), ({ document }) => document)
}
function getGraphNodeCount(documents: readonly GraphApiDocument[]): number {
const memoryIds = new Set<string>()
for (const document of documents) {
for (const memory of document.memories) memoryIds.add(memory.id)
}
return documents.length + memoryIds.size
}
export function getLoadedGraphNodeCount(
pages: readonly ApiDocumentsResponse[],
containerTags?: string[],
): number {
return getGraphNodeCount(normalizeGraphPages(pages, containerTags))
}
export function getNextGraphPageParam(
lastPage: ApiDocumentsResponse,
allPages: readonly ApiDocumentsResponse[],
options: {
hasDocumentIds: boolean
maxNodes?: number
containerTags?: string[]
},
): number | undefined {
if (options.hasDocumentIds) return undefined
if (
options.maxNodes != null &&
getLoadedGraphNodeCount(allPages, options.containerTags) >= options.maxNodes
) {
return undefined
}
const { currentPage, totalPages } = lastPage.pagination
return currentPage < totalPages ? currentPage + 1 : undefined
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const { containerTags, documentIds, enabled = true, maxNodes } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
@ -164,30 +234,25 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
return response.data as unknown as ApiDocumentsResponse
},
getNextPageParam: (lastPage, allPages) => {
if (hasDocumentIds) return undefined
if (maxNodes != null) {
const loadedNodes = allPages.reduce(
(total, page) => total + getGraphNodeCount(page.documents ?? []),
0,
)
if (loadedNodes >= maxNodes) return undefined
}
const { currentPage, totalPages } = lastPage.pagination
return currentPage < totalPages ? currentPage + 1 : undefined
},
getNextPageParam: (lastPage, allPages) =>
getNextGraphPageParam(lastPage, allPages, {
hasDocumentIds,
maxNodes,
containerTags,
}),
staleTime: 5 * 60 * 1000,
enabled,
})
const loadedNodeCount = useMemo(() => {
if (!data?.pages) return 0
return data.pages.reduce(
(total, page) => total + getGraphNodeCount(page.documents ?? []),
0,
)
}, [data])
const documents = useMemo(() => {
if (!data?.pages) return []
return normalizeGraphPages(data.pages, containerTags)
}, [data, containerTags])
const loadedNodeCount = useMemo(
() => getGraphNodeCount(documents),
[documents],
)
useEffect(() => {
if (!enabled || hasDocumentIds) return
@ -204,13 +269,6 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
fetchNextPage,
])
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) =>
page.documents.map((doc) => toGraphDocument(doc, containerTags)),
)
}, [data, containerTags])
const totalCount = data?.pages[0]?.pagination.totalItems ?? 0
return {

View file

@ -0,0 +1,287 @@
import { cleanup, render, renderHook, waitFor } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"
import { MemoryGraph } from "../components/memory-graph"
import { DEFAULT_COLORS } from "../constants"
import { computeEdges, useGraphData } from "../hooks/use-graph-data"
import type {
GraphApiDocument,
GraphApiMemory,
DocumentNodeData,
GraphEdge,
GraphNode,
MemoryNodeData,
} from "../types"
const graphCanvasCapture = vi.hoisted(() => ({
nodes: [] as GraphNode[],
edges: [] as GraphEdge[],
}))
vi.mock("../components/graph-canvas", () => ({
GraphCanvas: ({
nodes,
edges,
}: {
nodes: GraphNode[]
edges: GraphEdge[]
}) => {
graphCanvasCapture.nodes = nodes
graphCanvasCapture.edges = edges
return null
},
}))
afterEach(() => {
cleanup()
graphCanvasCapture.nodes = []
graphCanvasCapture.edges = []
vi.restoreAllMocks()
})
function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
return {
id: "memory",
memory: "A remembered fact",
isStatic: false,
spaceId: "space",
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
rootMemoryId: null,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
memoryRelations: null,
...overrides,
}
}
function makeDocument(
id: string,
memories: GraphApiMemory[],
): GraphApiDocument {
return {
id,
title: id,
summary: null,
documentType: "text",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
memories,
}
}
function getMemoryNode(
nodes: GraphNode[],
id: string,
): GraphNode & { data: MemoryNodeData } {
const node = nodes.find((candidate) => candidate.id === id)
expect(node?.type).toBe("memory")
if (!node || !("documentId" in node.data)) {
throw new Error(`Missing memory ${id}`)
}
return node as GraphNode & { data: MemoryNodeData }
}
function getDocumentNode(
nodes: GraphNode[],
id: string,
): GraphNode & { data: DocumentNodeData } {
const node = nodes.find((candidate) => candidate.id === id)
expect(node?.type).toBe("document")
if (!node || !("memories" in node.data)) {
throw new Error(`Missing document ${id}`)
}
return node as GraphNode & { data: DocumentNodeData }
}
describe("shared memory identity", () => {
it("materializes the first source occurrence once while retaining every document edge", () => {
const documents = [
makeDocument("doc-a", [
makeMemory({ id: "shared-memory", memory: "first source content" }),
]),
makeDocument("doc-b", [
makeMemory({ id: "shared-memory", memory: "later source content" }),
]),
]
const { result, unmount } = renderHook(() =>
useGraphData(documents, null, 800, 600, DEFAULT_COLORS),
)
const nodeIds = result.current.nodes.map((node) => node.id)
const sharedNode = getMemoryNode(result.current.nodes, "shared-memory")
const derivesEdges = result.current.edges.filter(
(edge) => edge.edgeType === "derives",
)
expect(nodeIds).toHaveLength(3)
expect(new Set(nodeIds).size).toBe(nodeIds.length)
expect(sharedNode.data.content).toBe("first source content")
expect(sharedNode.data.documentId).toBe("doc-a")
expect(derivesEdges).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "dm-5:doc-a|13:shared-memory",
source: "doc-a",
target: "shared-memory",
}),
expect.objectContaining({
id: "dm-5:doc-b|13:shared-memory",
source: "doc-b",
target: "shared-memory",
}),
]),
)
expect(derivesEdges).toHaveLength(2)
unmount()
})
it("preserves cached position when another source occurrence is appended", () => {
const firstDocuments = [
makeDocument("doc-a", [makeMemory({ id: "shared-memory" })]),
]
const { result, rerender, unmount } = renderHook(
({ documents }: { documents: GraphApiDocument[] }) =>
useGraphData(documents, null, 800, 600, DEFAULT_COLORS),
{ initialProps: { documents: firstDocuments } },
)
const firstNode = getMemoryNode(result.current.nodes, "shared-memory")
rerender({
documents: [
...firstDocuments,
makeDocument("doc-b", [makeMemory({ id: "shared-memory" })]),
],
})
const rerenderedNode = getMemoryNode(result.current.nodes, "shared-memory")
expect({ x: rerenderedNode.x, y: rerenderedNode.y }).toEqual({
x: firstNode.x,
y: firstNode.y,
})
expect(rerenderedNode.data.documentId).toBe("doc-a")
unmount()
})
it("unions relation pairs but keeps direction, type, and first occurrence", () => {
const documents = [
makeDocument("doc-a", [
makeMemory({
id: "shared-memory",
memoryRelations: { "target-a": "updates" },
}),
makeMemory({ id: "target-a" }),
]),
makeDocument("doc-b", [
makeMemory({
id: "shared-memory",
memoryRelations: {
"target-a": "extends",
"target-b": "extends",
},
}),
makeMemory({ id: "target-b" }),
]),
]
const relationEdges = computeEdges(documents).filter((edge) =>
edge.id.startsWith("rel-"),
)
expect(relationEdges).toHaveLength(2)
expect(relationEdges).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "rel-8:target-a|13:shared-memory",
source: "target-a",
target: "shared-memory",
edgeType: "updates",
}),
expect.objectContaining({
id: "rel-8:target-b|13:shared-memory",
source: "target-b",
target: "shared-memory",
edgeType: "extends",
}),
]),
)
})
it("does not collide for ambiguous relation or derives tuples", () => {
const relationDocuments = [
makeDocument("relations", [
makeMemory({ id: "a-b" }),
makeMemory({ id: "c", memoryRelations: { "a-b": "updates" } }),
makeMemory({ id: "a" }),
makeMemory({ id: "b-c", memoryRelations: { a: "extends" } }),
]),
]
const relationEdges = computeEdges(relationDocuments).filter((edge) =>
edge.id.startsWith("rel-"),
)
expect(relationEdges.map((edge) => edge.id).sort()).toEqual([
"rel-1:a|3:b-c",
"rel-3:a-b|1:c",
])
expect(new Set(relationEdges.map((edge) => edge.id)).size).toBe(2)
const derivesEdges = computeEdges([
makeDocument("a-b", [makeMemory({ id: "c" })]),
makeDocument("a", [makeMemory({ id: "b-c" })]),
]).filter((edge) => edge.edgeType === "derives")
expect(derivesEdges.map((edge) => edge.id).sort()).toEqual([
"dm-1:a|3:b-c",
"dm-3:a-b|1:c",
])
expect(new Set(derivesEdges.map((edge) => edge.id)).size).toBe(2)
})
})
describe("MemoryGraph maxNodes", () => {
it("counts unique memories and retains a later duplicate after saturation", async () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
bottom: 600,
height: 600,
left: 0,
right: 800,
top: 0,
width: 800,
x: 0,
y: 0,
toJSON: () => ({}),
})
const documents = [
makeDocument("doc-a", [
makeMemory({ id: "shared-memory" }),
makeMemory({ id: "unique-a" }),
]),
makeDocument("doc-b", [
makeMemory({ id: "over-budget" }),
makeMemory({ id: "shared-memory" }),
]),
]
render(<MemoryGraph documents={documents} maxNodes={4} />)
await waitFor(() => {
expect(graphCanvasCapture.nodes).toHaveLength(4)
})
expect(graphCanvasCapture.nodes.map((node) => node.id).sort()).toEqual([
"doc-a",
"doc-b",
"shared-memory",
"unique-a",
])
const docB = getDocumentNode(graphCanvasCapture.nodes, "doc-b")
expect(docB.data.memories.map((memory) => memory.id)).toEqual([
"shared-memory",
])
})
})

View file

@ -73,37 +73,40 @@ export function MemoryGraph({
// Used as a dependency proxy to recalculate popover positions
const [viewportVersion, setViewportVersion] = useState(0)
// Limit documents so total node count (documents + their memories) stays under maxNodes
// Limit documents so total node count (documents + unique memories) stays
// under maxNodes. A memory can belong to more than one source document, so
// repeated occurrences must remain in each admitted document without
// consuming the node budget again.
const limitedDocuments = useMemo(() => {
if (!maxNodes || documents.length === 0) return documents
let totalNodes = 0
const seenMemoryIds = new Set<string>()
const limited: GraphApiDocument[] = []
for (let i = 0; i < documents.length; i++) {
const doc = documents[i]
if (!doc) continue
for (const doc of documents) {
if (totalNodes >= maxNodes) break
const remainingNodes = maxNodes - totalNodes
const memories = doc.memories ?? []
const docNodes = 1 + memories.length
if (docNodes <= remainingNodes) {
limited.push(doc)
totalNodes += docNodes
continue
}
if (remainingNodes > 1) {
limited.push({
...doc,
memories: memories.slice(0, remainingNodes - 1),
})
totalNodes = maxNodes
break
}
limited.push({ ...doc, memories: [] })
const includedMemories: GraphApiDocument["memories"] = []
totalNodes += 1
for (const memory of memories) {
if (seenMemoryIds.has(memory.id)) {
includedMemories.push(memory)
continue
}
if (totalNodes >= maxNodes) continue
seenMemoryIds.add(memory.id)
includedMemories.push(memory)
totalNodes += 1
}
limited.push(
includedMemories.length === memories.length
? doc
: { ...doc, memories: includedMemories },
)
}
return limited
}, [documents, maxNodes])

View file

@ -403,6 +403,14 @@ function getAppendSpatialKey(x: number, y: number): string {
* Pure function that computes graph edges from documents.
* Extracted from the hook for testability.
*/
function createEdgeId(
prefix: "dm" | "rel",
sourceId: string,
targetId: string,
): string {
return `${prefix}-${sourceId.length}:${sourceId}|${targetId.length}:${targetId}`
}
export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
if (!documents || documents.length === 0) return []
@ -414,10 +422,14 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
}
// 1. Derives edges: document -> memory (structural)
const derivesEdgeIds = new Set<string>()
for (const doc of documents) {
for (const mem of doc.memories) {
const edgeId = createEdgeId("dm", doc.id, mem.id)
if (derivesEdgeIds.has(edgeId)) continue
derivesEdgeIds.add(edgeId)
result.push({
id: `dm-${doc.id}-${mem.id}`,
id: edgeId,
source: doc.id,
target: mem.id,
visualProps: getEdgeVisualProps("derives"),
@ -429,12 +441,16 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
// 2. Memory-to-memory relation edges from backend data.
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
// falls back to parentMemoryId for legacy data.
const relationEdgeIds = new Set<string>()
for (const doc of documents) {
for (const mem of doc.memories) {
const relations = getMemoryRelationTargets(mem)
for (const [targetId, relationType] of Object.entries(relations)) {
if (!allNodeIds.has(targetId)) continue
const edgeId = createEdgeId("rel", targetId, mem.id)
if (relationEdgeIds.has(edgeId)) continue
relationEdgeIds.add(edgeId)
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
@ -442,7 +458,7 @@ export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
? relationType
: "updates"
result.push({
id: `rel-${targetId}-${mem.id}`,
id: edgeId,
source: targetId,
target: mem.id,
visualProps: getEdgeVisualProps(edgeType),
@ -495,6 +511,7 @@ export function useGraphData(
const clusterAssignments = computeClusterAssignments(documents)
const result: GraphNode[] = []
const seenMemoryIds = new Set<string>()
// Spiral layout: documents form a compact spiral core, memories orbit
// around their parent documents. The force simulation then gently
// pushes memories outward to create the constellation/starburst effect.
@ -574,6 +591,8 @@ export function useGraphData(
for (let i = 0; i < memCount; i++) {
const mem = doc.memories[i]
if (!mem) continue
if (seenMemoryIds.has(mem.id)) continue
seenMemoryIds.add(mem.id)
const previousMemNode = previousCache.get(mem.id)
const memData: MemoryNodeData = {
...mem,