Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
69 changes: 45 additions & 24 deletions packages/dashboard/src/components/dashboard/graph-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +34,11 @@ interface CanvasEdgeElement {
data: GraphWireEdge
}

export interface CanvasViewport {
pan: cytoscape.Position
zoom: number
}

export interface CanvasSelectionPlan {
nodeId: string
nodeToAdd: CanvasNodeElement | null
Expand All @@ -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,
Expand Down Expand Up @@ -130,6 +161,10 @@ export function GraphCanvas({

const expandNode = useCallback(async (node: GraphNode): Promise<void> => {
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)
Expand All @@ -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) {
Expand All @@ -180,7 +201,7 @@ export function GraphCanvas({
setExpandingNodeId(null)
}
}
}, [apiUrl, layout, onExpanded, windowLimit])
}, [apiUrl, onExpanded, windowLimit])
expandNodeRef.current = expandNode

// Initialize Cytoscape and load data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
})
67 changes: 67 additions & 0 deletions packages/dashboard/src/lib/graph-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions packages/dashboard/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
12 changes: 10 additions & 2 deletions packages/plugin-nlp/src/__tests__/embeddings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
});
Expand Down
Loading
Loading