diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31c40b9..308b5ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,6 +224,9 @@ jobs: with: ref: ${{ inputs.bootstrap_commit }} + - name: Install pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -247,6 +250,14 @@ jobs: exit 1 fi + - name: Install source dependencies for real-model assertions + run: pnpm install --frozen-lockfile + + - name: Run real local-model assertions + env: + CODEGRAPH_TEST_LOCAL_MODEL: "1" + run: pnpm --filter @codegraph/plugin-nlp exec vitest run src/__tests__/embeddings.test.ts + - name: Run cold local-provider installed-tarball smoke run: node scripts/release/smoke-package.mjs --result tmp/release/package-result.json --mode local diff --git a/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts b/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts new file mode 100644 index 0000000..73ade4b --- /dev/null +++ b/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts @@ -0,0 +1,101 @@ +import cytoscape from 'cytoscape' +import { describe, expect, it } from 'vitest' +import { applyCanvasExpansion } from './graph-canvas' +import { + planGraphExpansion, + type GraphNodeData, + type GraphWindow, + type NeighborWindow, +} from '@/lib/graph-window' + +const sourceNode: GraphNodeData = { + id: 'source', + label: 'source', + type: 'Function', + properties: {}, +} + +const existingNode: GraphNodeData = { + id: 'existing', + label: 'existing', + type: 'Function', + properties: {}, +} + +const baseWindow: GraphWindow = { + nodes: [sourceNode, existingNode], + edges: [], + totalNodes: 2, + totalEdges: 0, + windowOrder: 'degree-desc,id-asc', + truncation: { incoming: false, outgoing: false }, +} + +const incoming: NeighborWindow = { + nodes: [ + sourceNode, + { + id: 'neighbor-a', + label: 'neighbor a', + type: 'Function', + properties: {}, + }, + { + id: 'neighbor-b', + label: 'neighbor b', + type: 'Function', + properties: {}, + }, + ], + edges: [ + { id: 'edge-a', source: 'source', target: 'neighbor-a', label: 'CALLS' }, + { id: 'edge-b', source: 'source', target: 'neighbor-b', label: 'CALLS' }, + ], + incomingTruncated: false, + outgoingTruncated: false, +} + +describe('incremental graph expansion', () => { + it('seeds only new nodes around the source without requesting fit or a global layout', () => { + const sourcePosition = { x: 240, y: 180 } + + const plan = planGraphExpansion(baseWindow, incoming, 'source', sourcePosition) + + expect(plan.preserveViewport).toBe(true) + expect(plan.fit).toBe(false) + expect(plan.runLayout).toBe(false) + expect(plan.newNodes.map(({ node }) => node.id)).toEqual(['neighbor-a', 'neighbor-b']) + expect(plan.newNodes.every(({ position }) => ( + Math.hypot(position.x - sourcePosition.x, position.y - sourcePosition.y) >= 96 + ))).toBe(true) + expect(new Set(plan.newNodes.map(({ position }) => `${position.x},${position.y}`)).size).toBe(2) + }) + + it('keeps existing positions and the viewport unchanged while adding an expansion', () => { + const cy = cytoscape({ + headless: true, + elements: [ + { data: { id: 'source' }, position: { x: 100, y: 200 } }, + { data: { id: 'existing' }, position: { x: 450, y: 325 }, locked: true }, + ], + }) + cy.pan({ x: 17, y: -23 }) + cy.zoom(1.6) + const sourcePosition = cy.getElementById('source').position() + const existingPosition = cy.getElementById('existing').position() + const viewport = { pan: cy.pan(), zoom: cy.zoom() } + const plan = planGraphExpansion(baseWindow, incoming, 'source', sourcePosition) + + applyCanvasExpansion(cy, plan) + + expect(cy.getElementById('source').position()).toEqual(sourcePosition) + expect(cy.getElementById('existing').position()).toEqual(existingPosition) + expect(cy.getElementById('source').locked()).toBe(false) + expect(cy.getElementById('existing').locked()).toBe(true) + expect(cy.pan()).toEqual(viewport.pan) + expect(cy.zoom()).toBe(viewport.zoom) + expect(cy.getElementById('neighbor-a').position()).toEqual(plan.newNodes[0]?.position) + expect(cy.getElementById('neighbor-b').position()).toEqual(plan.newNodes[1]?.position) + cy.destroy() + }) +}) diff --git a/packages/dashboard/src/components/dashboard/graph-canvas.tsx b/packages/dashboard/src/components/dashboard/graph-canvas.tsx index af71ebf..65bffa9 100644 --- a/packages/dashboard/src/components/dashboard/graph-canvas.tsx +++ b/packages/dashboard/src/components/dashboard/graph-canvas.tsx @@ -5,10 +5,11 @@ import { cytoscapeStylesheet, LAYOUT_OPTIONS, type LayoutName } from '@/lib/cyto import { fetchGraphWindow, fetchNeighbors, - mergeGraphWindow, + planGraphExpansion, resetGraphWindow, restoreGraphWindow, type GraphEdgeData, + type GraphExpansionPlan, type GraphNodeData, type GraphViewMode, type GraphWindow, @@ -33,6 +34,11 @@ interface CanvasEdgeElement { data: GraphWireEdge } +export interface CanvasViewport { + pan: cytoscape.Position + zoom: number +} + export interface CanvasSelectionPlan { nodeId: string nodeToAdd: CanvasNodeElement | null @@ -53,6 +59,31 @@ function graphNodeToCanvasElement(node: GraphNode): CanvasNodeElement { } } +export function applyCanvasExpansion( + cy: cytoscape.Core, + plan: GraphExpansionPlan, + viewport: CanvasViewport = { pan: { ...cy.pan() }, zoom: cy.zoom() }, +): void { + const existingNodes = cy.nodes() + const previouslyUnlockedNodes = existingNodes.filter((element) => !element.locked()) + existingNodes.lock() + try { + cy.batch(() => { + cy.add([ + ...plan.newNodes.map(({ node, position }) => ({ + ...graphNodeToCanvasElement(node), + position, + })), + ...plan.newEdges.map((edge) => ({ data: edge })), + ]) + }) + } finally { + previouslyUnlockedNodes.unlock() + cy.pan(viewport.pan) + cy.zoom(viewport.zoom) + } +} + export function planCanvasSelection( loadedNodes: readonly GraphNode[], requestedNode: GraphNode, @@ -130,6 +161,10 @@ export function GraphCanvas({ const expandNode = useCallback(async (node: GraphNode): Promise => { if (expansionAbortRef.current !== null) return + const initialCy = cyRef.current + const viewport = initialCy && !initialCy.destroyed() + ? { pan: { ...initialCy.pan() }, zoom: initialCy.zoom() } + : null const controller = new AbortController() expansionAbortRef.current = controller setExpandingNodeId(node.id) @@ -140,34 +175,20 @@ export function GraphCanvas({ const cy = cyRef.current if (!current || !cy || cy.destroyed()) return - const merged = mergeGraphWindow(current, incoming) - const existingNodeIds = new Set(current.nodes.map((entry) => entry.id)) - const existingEdgeIds = new Set(current.edges.map((edge) => edge.id)) - const nodesToAdd = incoming.nodes - .filter((entry) => !existingNodeIds.has(entry.id)) - .map(graphNodeToCanvasElement) - const mergedNodeIds = new Set(merged.nodes.map((entry) => entry.id)) - const edgesToAdd = incoming.edges - .filter((edge) => !existingEdgeIds.has(edge.id)) - .filter((edge) => mergedNodeIds.has(edge.source) && mergedNodeIds.has(edge.target)) - .map((edge) => ({ data: edge })) - - if (nodesToAdd.length > 0 || edgesToAdd.length > 0) { - cy.add([...nodesToAdd, ...edgesToAdd]) - cy.layout(LAYOUT_OPTIONS[layout]).run() - } + const target = cy.getElementById(node.id) + if (target.length === 0) return + const plan = planGraphExpansion(current, incoming, node.id, target.position()) + const merged = plan.window + + applyCanvasExpansion(cy, plan, viewport ?? undefined) graphWindowRef.current = merged appliedExpansionIdsRef.current = [...appliedExpansionIdsRef.current, node.id] setGraphWindow(merged) setCanvasNodes(merged.nodes) setNodeCount(merged.nodes.length) setEdgeCount(merged.edges.length) - const target = cy.getElementById(node.id) - if (target.length > 0) { - if (incoming.incomingTruncated || incoming.outgoingTruncated) { - target.addClass('truncated') - } - cy.animate({ fit: { eles: target.neighborhood().add(target), padding: 60 }, duration: 400 }) + if (incoming.incomingTruncated || incoming.outgoingTruncated) { + target.addClass('truncated') } onExpanded?.(node) } catch (error) { @@ -180,7 +201,7 @@ export function GraphCanvas({ setExpandingNodeId(null) } } - }, [apiUrl, layout, onExpanded, windowLimit]) + }, [apiUrl, onExpanded, windowLimit]) expandNodeRef.current = expandNode // Initialize Cytoscape and load data diff --git a/packages/dashboard/src/components/dashboard/workspace-state.test.tsx b/packages/dashboard/src/components/dashboard/workspace-state.test.tsx index 75ce45d..3c02e9b 100644 --- a/packages/dashboard/src/components/dashboard/workspace-state.test.tsx +++ b/packages/dashboard/src/components/dashboard/workspace-state.test.tsx @@ -5,6 +5,10 @@ import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import App from '../../App' +vi.mock('./graph-canvas', () => ({ + GraphCanvas: () => null, +})); + ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } ).IS_REACT_ACT_ENVIRONMENT = true @@ -141,5 +145,5 @@ describe('dashboard workspace persistence', () => { expect(fetcher.mock.calls.filter(([input]) => new URL(String(input), 'http://localhost:3000').pathname === '/api/query/cypher')).toHaveLength(1) await act(async () => root.unmount()) - }) + }, 20_000) }) diff --git a/packages/dashboard/src/lib/graph-window.ts b/packages/dashboard/src/lib/graph-window.ts index d78da37..86f5bbb 100644 --- a/packages/dashboard/src/lib/graph-window.ts +++ b/packages/dashboard/src/lib/graph-window.ts @@ -37,6 +37,25 @@ export interface NeighborWindow { outgoingTruncated: boolean } +export interface GraphPosition { + x: number + y: number +} + +export interface SeededGraphNode { + node: GraphNodeData + position: GraphPosition +} + +export interface GraphExpansionPlan { + window: GraphWindow + newNodes: SeededGraphNode[] + newEdges: GraphEdgeData[] + preserveViewport: true + runLayout: false + fit: false +} + export interface GraphViewState { mode: GraphViewMode limit: GraphWindowLimit @@ -352,6 +371,54 @@ export function mergeGraphWindow(base: GraphWindow, incoming: NeighborWindow): G } } +function stableFraction(value: string): number { + let hash = 2_166_136_261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16_777_619) + } + return (hash >>> 0) / 4_294_967_295 +} + +export function planGraphExpansion( + base: GraphWindow, + incoming: NeighborWindow, + sourceNodeId: string, + sourcePosition: GraphPosition, +): GraphExpansionPlan { + const window = mergeGraphWindow(base, incoming) + const existingNodeIds = new Set(base.nodes.map((node) => node.id)) + const existingEdgeIds = new Set(base.edges.map((edge) => edge.id)) + const mergedNodeIds = new Set(window.nodes.map((node) => node.id)) + const newNodes = incoming.nodes.filter((node) => !existingNodeIds.has(node.id)) + const count = newNodes.length + const angleStep = count > 0 ? (Math.PI * 2) / count : 0 + const radius = Math.max(96, (count * 72) / (Math.PI * 2)) + const seededNodes = newNodes.map((node, index): SeededGraphNode => { + const jitter = (stableFraction(`${sourceNodeId}:${node.id}`) - 0.5) + * Math.min(angleStep * 0.2, 0.18) + const angle = (-Math.PI / 2) + (index * angleStep) + jitter + return { + node, + position: { + x: sourcePosition.x + (Math.cos(angle) * radius), + y: sourcePosition.y + (Math.sin(angle) * radius), + }, + } + }) + + return { + window, + newNodes: seededNodes, + newEdges: incoming.edges + .filter((edge) => !existingEdgeIds.has(edge.id)) + .filter((edge) => mergedNodeIds.has(edge.source) && mergedNodeIds.has(edge.target)), + preserveViewport: true, + runLayout: false, + fit: false, + } +} + export function resetGraphWindow(base: GraphWindow): GraphWindow { return base } diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index 91afb5f..02e5f26 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + test: { + testTimeout: 20_000, + }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), diff --git a/packages/plugin-nlp/src/__tests__/embeddings.test.ts b/packages/plugin-nlp/src/__tests__/embeddings.test.ts index fe9dd91..c8cbcf8 100644 --- a/packages/plugin-nlp/src/__tests__/embeddings.test.ts +++ b/packages/plugin-nlp/src/__tests__/embeddings.test.ts @@ -2,7 +2,7 @@ * Embedding Generation — Unit Tests * * Tests both tiers: - * - Local: Real nomic-embed-text-v1.5 model (~10ms/embedding, no API key needed) + * - Local: Real nomic-embed-text-v1.5 model when CODEGRAPH_TEST_LOCAL_MODEL=1 * - Cloud: Mocked (requires OPENROUTER_API_KEY in production) * - Config resolution: provider selection, dimensions, availability */ @@ -125,7 +125,15 @@ describe('Transformers.js response compatibility', () => { // Local embedding generation (real model, no mocking) // ============================================================================ -describe('Local embedding generation', () => { +const runLocalModelTests = process.env['CODEGRAPH_TEST_LOCAL_MODEL'] === '1'; + +if (!runLocalModelTests) { + console.info( + 'SKIP Local embedding generation: set CODEGRAPH_TEST_LOCAL_MODEL=1 to run tests that download and execute the real Hugging Face model.', + ); +} + +describe.skipIf(!runLocalModelTests)('Local embedding generation', () => { afterAll(() => { _resetLocalModel(); }); diff --git a/packages/plugin-nlp/src/__tests__/speaker-entities.test.ts b/packages/plugin-nlp/src/__tests__/speaker-entities.test.ts index f9b9a55..2423ef3 100644 --- a/packages/plugin-nlp/src/__tests__/speaker-entities.test.ts +++ b/packages/plugin-nlp/src/__tests__/speaker-entities.test.ts @@ -99,6 +99,7 @@ describe('extractConversation creates Person entities for speakers', () => { await extractConversation(chunkResult, ops, { extractor: { languageModel: mockModel }, + embeddings: false, }); const entityCalls = vi.mocked(ops.createEntity).mock.calls; @@ -115,6 +116,7 @@ describe('extractConversation creates Person entities for speakers', () => { await extractConversation(chunkResult, ops, { extractor: { languageModel: mockModel }, + embeddings: false, }); const relCalls = vi.mocked(ops.createRelationship).mock.calls; @@ -132,6 +134,7 @@ describe('extractConversation creates Person entities for speakers', () => { await extractConversation(chunkResult, ops, { extractor: { languageModel: mockModel }, + embeddings: false, }); const relCalls = vi.mocked(ops.createRelationship).mock.calls; @@ -154,6 +157,7 @@ describe('extractConversation creates Person entities for speakers', () => { await extractConversation(chunkResult, ops, { extractor: { languageModel: mockModel }, + embeddings: false, }); const relCalls = vi.mocked(ops.createRelationship).mock.calls; @@ -175,6 +179,7 @@ describe('extractConversation creates Person entities for speakers', () => { await extractConversation(chunkResult, ops, { extractor: { languageModel: mockModel }, + embeddings: false, }); const entityCalls = vi.mocked(ops.createEntity).mock.calls; diff --git a/packages/plugin-nlp/vitest.config.ts b/packages/plugin-nlp/vitest.config.ts index df5c0d7..9090080 100644 --- a/packages/plugin-nlp/vitest.config.ts +++ b/packages/plugin-nlp/vitest.config.ts @@ -15,5 +15,8 @@ export default defineConfig({ 'src/__tests__/episodic-extraction.test.ts', 'src/__tests__/ingest-conversation.test.ts', ], + env: { + CODEGRAPH_EMBEDDING_PROVIDER: 'none', + }, }, });