mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(memory-graph): deduplicate shared graph entities
This commit is contained in:
parent
5d2b5855fe
commit
2d8a6350a2
6 changed files with 713 additions and 62 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
276
apps/web/components/memory-graph/hooks/use-graph-api.test.ts
Normal file
276
apps/web/components/memory-graph/hooks/use-graph-api.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue