-
Notifications
You must be signed in to change notification settings - Fork 3.3k
feat(intelligence): add Graph Bridges (Tarjan articulations & cut edges) #2988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
app/src/components/intelligence/GraphBridgesPanel.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { fireEvent, render, screen } from '@testing-library/react'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { computeGraphBridges } from '../../lib/memory/graphBridges'; | ||
| import type { GraphRelation } from '../../utils/tauriCommands/memory'; | ||
| import GraphBridgesPanel from './GraphBridgesPanel'; | ||
|
|
||
| function rel(subject: string, object: string): GraphRelation { | ||
| return { | ||
| namespace: 'n', | ||
| subject, | ||
| predicate: 'p', | ||
| object, | ||
| attrs: {}, | ||
| updatedAt: 0, | ||
| evidenceCount: 1, | ||
| orderIndex: null, | ||
| documentIds: [], | ||
| chunkIds: [], | ||
| }; | ||
| } | ||
|
|
||
| // Two triangles joined by a single edge: C and D are articulations; (C,D) is | ||
| // the only bridge. articulationCount=2, bridges=[{a:'C',b:'D'}]. | ||
| const joined = computeGraphBridges([ | ||
| rel('A', 'B'), | ||
| rel('B', 'C'), | ||
| rel('C', 'A'), | ||
| rel('D', 'E'), | ||
| rel('E', 'F'), | ||
| rel('F', 'D'), | ||
| rel('C', 'D'), | ||
| ]); | ||
|
|
||
| describe('<GraphBridgesPanel />', () => { | ||
| it('renders the loading skeleton', () => { | ||
| render(<GraphBridgesPanel result={null} loading />); | ||
| expect(screen.getByTestId('graph-bridges-loading')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders the empty state when there are no nodes', () => { | ||
| render(<GraphBridgesPanel result={computeGraphBridges([])} />); | ||
| expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders an error with a working retry button', () => { | ||
| const onRetry = vi.fn(); | ||
| render(<GraphBridgesPanel result={null} error="graph unavailable" onRetry={onRetry} />); | ||
| expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/); | ||
| fireEvent.click(screen.getByRole('button', { name: 'Retry' })); | ||
| expect(onRetry).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('renders metric tiles, articulation badges, and the bridge list', () => { | ||
| render(<GraphBridgesPanel result={joined} />); | ||
| expect(screen.getByText('Entities')).toBeInTheDocument(); | ||
| expect(screen.getByText('Connections')).toBeInTheDocument(); | ||
| expect(screen.getByText('Articulations')).toBeInTheDocument(); | ||
| expect(screen.getByText('Articulation entities')).toBeInTheDocument(); | ||
| expect(screen.getByText('Bridge relations')).toBeInTheDocument(); | ||
| // both articulation badges present. | ||
| expect(screen.getAllByText('cut vertex')).toHaveLength(2); | ||
| // single bridge row C — D: each id appears in BOTH the articulation table | ||
| // row and the bridge list, so use getAllByText with the matching length. | ||
| expect(screen.getAllByText('C')).toHaveLength(2); | ||
| expect(screen.getAllByText('D')).toHaveLength(2); | ||
| // exactly one bridge and one connected component -> the singular-bridge, | ||
| // singular-component caption is rendered (no "1 bridges" ungrammar). | ||
| expect(screen.getByText('1 bridge · single component')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('uses the plural caption when there are 2+ bridges and 2+ components', () => { | ||
| // Path A-B-C (2 bridges, 1 component) plus disconnected Y-Z (1 bridge, 2nd | ||
| // component). Total: 3 bridges across 2 components -> fully plural caption. | ||
| const multi = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('Y', 'Z')]); | ||
| render(<GraphBridgesPanel result={multi} />); | ||
| expect(screen.getByText('3 bridges · 2 components')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('shows the no-fragiles / no-bridges notes for a fully-cyclic graph', () => { | ||
| const triangle = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); | ||
| render(<GraphBridgesPanel result={triangle} />); | ||
| expect( | ||
| screen.getByText( | ||
| 'No structural single-points-of-failure — every link sits in at least one cycle.' | ||
| ) | ||
| ).toBeInTheDocument(); | ||
| expect( | ||
| screen.getByText('No bridges — every relation sits in at least one cycle.') | ||
| ).toBeInTheDocument(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| /** | ||
| * Graph Bridges — presentational view. Pure: renders the cut summary tiles | ||
| * (entities / connections / articulations), the articulation entity list, and | ||
| * the bridge relations list. No data fetching, no clock, no randomness. | ||
| */ | ||
| import { useMemo } from 'react'; | ||
|
|
||
| import { useT } from '../../lib/i18n/I18nContext'; | ||
| import type { BridgeResult } from '../../lib/memory/graphBridges'; | ||
|
|
||
| const MAX_ROWS = 25; | ||
|
|
||
| interface GraphBridgesPanelProps { | ||
| result: BridgeResult | null; | ||
| loading?: boolean; | ||
| error?: string | null; | ||
| onRetry?: () => void; | ||
| } | ||
|
|
||
| const GraphBridgesPanel = ({ result, loading, error, onRetry }: GraphBridgesPanelProps) => { | ||
| const { t } = useT(); | ||
|
|
||
| const articulationRows = useMemo( | ||
| () => (result ? result.nodes.filter(n => n.isArticulation).slice(0, MAX_ROWS) : []), | ||
| [result] | ||
| ); | ||
| const bridgeRows = useMemo(() => (result ? result.bridges.slice(0, MAX_ROWS) : []), [result]); | ||
|
|
||
| const intro = ( | ||
| <div | ||
| role="note" | ||
| className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200"> | ||
| <p className="font-medium mb-1">{t('graphBridges.title')}</p> | ||
| <p>{t('graphBridges.intro')}</p> | ||
| </div> | ||
| ); | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <div className="space-y-4"> | ||
| {intro} | ||
| <div | ||
| className="space-y-3" | ||
| role="status" | ||
| aria-label={t('graphBridges.loading')} | ||
| data-testid="graph-bridges-loading"> | ||
| <div className="grid gap-2 sm:grid-cols-3"> | ||
| {[0, 1, 2].map(i => ( | ||
| <div | ||
| key={i} | ||
| className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-16" | ||
| /> | ||
| ))} | ||
| </div> | ||
| {[0, 1, 2, 3].map(i => ( | ||
| <div | ||
| key={i} | ||
| className="animate-pulse rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 h-8" | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className="space-y-4"> | ||
| {intro} | ||
| <div className="rounded-lg border border-coral-200 dark:border-coral-500/30 p-4 text-center"> | ||
| <p role="alert" className="text-xs text-coral-700 dark:text-coral-300"> | ||
| {t('graphBridges.errorPrefix')} {error} | ||
| </p> | ||
| {onRetry && ( | ||
| <button | ||
| type="button" | ||
| onClick={onRetry} | ||
| className="mt-2 rounded-lg bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-600"> | ||
| {t('graphBridges.retry')} | ||
| </button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (!result || result.nodes.length === 0) { | ||
| return ( | ||
| <div className="space-y-4"> | ||
| {intro} | ||
| <div className="py-8 text-center"> | ||
| <h3 className="text-sm font-semibold text-stone-700 dark:text-neutral-200"> | ||
| {t('graphBridges.empty')} | ||
| </h3> | ||
| <p className="mt-1 text-xs text-stone-500 dark:text-neutral-400"> | ||
| {t('graphBridges.emptyHint')} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // Four-way singular/plural switch over (bridges, components) — keeps the | ||
| // English caption grammatical for the common single-bridge / single-component | ||
| // cases instead of rendering "1 bridges" / "1 components". | ||
| const bridgesCount = String(result.bridges.length); | ||
| const componentsCount = String(result.componentCount); | ||
| const oneBridge = result.bridges.length === 1; | ||
| const oneComponent = result.componentCount === 1; | ||
| const summaryCaption = oneBridge | ||
| ? oneComponent | ||
| ? t('graphBridges.summaryCaptionOneBridgeOneComponent') | ||
| : t('graphBridges.summaryCaptionOneBridge').replace('{components}', componentsCount) | ||
| : oneComponent | ||
| ? t('graphBridges.summaryCaptionOne').replace('{bridges}', bridgesCount) | ||
| : t('graphBridges.summaryCaption') | ||
| .replace('{bridges}', bridgesCount) | ||
| .replace('{components}', componentsCount); | ||
|
|
||
| return ( | ||
| <div className="space-y-4"> | ||
| {intro} | ||
|
|
||
| {/* Metric tiles */} | ||
| <div className="grid gap-2 sm:grid-cols-3"> | ||
| {[ | ||
| { label: t('graphBridges.metricEntities'), value: result.nodeCount }, | ||
| { label: t('graphBridges.metricConnections'), value: result.edgeCount }, | ||
| { label: t('graphBridges.metricArticulations'), value: result.articulationCount }, | ||
| ].map(tile => ( | ||
| <div | ||
| key={tile.label} | ||
| className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3"> | ||
| <div className="text-[10px] uppercase tracking-wider text-stone-400 dark:text-neutral-500"> | ||
| {tile.label} | ||
| </div> | ||
| <div className="text-lg font-semibold tabular-nums text-stone-900 dark:text-neutral-100"> | ||
| {tile.value} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| <p className="text-[11px] text-stone-500 dark:text-neutral-400">{summaryCaption}</p> | ||
|
|
||
| {/* Articulation entities */} | ||
| <section aria-labelledby="graph-bridges-articulations-heading" className="space-y-1"> | ||
| <h3 | ||
| id="graph-bridges-articulations-heading" | ||
| className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400"> | ||
| {t('graphBridges.articulationsHeading')} | ||
| </h3> | ||
| {articulationRows.length === 0 ? ( | ||
| <p className="py-2 text-[11px] text-stone-500 dark:text-neutral-400"> | ||
| {t('graphBridges.noFragiles')} | ||
| </p> | ||
| ) : ( | ||
| <table | ||
| aria-labelledby="graph-bridges-articulations-heading" | ||
| className="w-full text-left text-[11px] tabular-nums"> | ||
| <thead className="text-stone-400 dark:text-neutral-500"> | ||
| <tr> | ||
| <th scope="col" className="w-8 py-1 pr-2 font-medium"> | ||
| {t('graphBridges.colRank')} | ||
| </th> | ||
| <th scope="col" className="py-1 pr-2 font-medium"> | ||
| {t('graphBridges.colEntity')} | ||
| </th> | ||
| <th scope="col" className="w-12 py-1 text-right font-medium"> | ||
| {t('graphBridges.colLinks')} | ||
| </th> | ||
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {articulationRows.map((node, i) => ( | ||
| <tr key={node.id} className="border-t border-stone-100 dark:border-neutral-800/60"> | ||
| <td className="py-1 pr-2 text-stone-400 dark:text-neutral-500">{i + 1}</td> | ||
| <td className="py-1 pr-2 text-stone-800 dark:text-neutral-100 break-words"> | ||
| {node.id} | ||
| <span | ||
| title={t('graphBridges.articulationTitle')} | ||
| className="ml-1.5 inline-flex items-center rounded px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300"> | ||
| {t('graphBridges.articulationBadge')} | ||
| </span> | ||
| </td> | ||
| <td className="py-1 text-right text-stone-500 dark:text-neutral-400"> | ||
| {node.degree} | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| )} | ||
| </section> | ||
|
|
||
| {/* Bridge relations */} | ||
| <section aria-labelledby="graph-bridges-edges-heading" className="space-y-1"> | ||
| <h3 | ||
| id="graph-bridges-edges-heading" | ||
| className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400"> | ||
| {t('graphBridges.bridgesHeading')} | ||
| </h3> | ||
| {bridgeRows.length === 0 ? ( | ||
| <p className="py-2 text-[11px] text-stone-500 dark:text-neutral-400"> | ||
| {t('graphBridges.noBridges')} | ||
| </p> | ||
| ) : ( | ||
| <ul className="space-y-1"> | ||
| {bridgeRows.map(edge => ( | ||
| <li | ||
| key={JSON.stringify([edge.a, edge.b])} | ||
| className="border-t border-stone-100 dark:border-neutral-800/60 pt-1 text-[11px] text-stone-700 dark:text-neutral-200 break-words"> | ||
| <span className="font-medium">{edge.a}</span> | ||
| <span className="mx-1.5 text-stone-400 dark:text-neutral-500">—</span> | ||
| <span className="font-medium">{edge.b}</span> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
| </section> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default GraphBridgesPanel; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { fireEvent, render, screen, waitFor } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { computeGraphBridges } from '../../lib/memory/graphBridges'; | ||
| import type { GraphRelation } from '../../utils/tauriCommands/memory'; | ||
| import GraphBridgesTab from './GraphBridgesTab'; | ||
|
|
||
| const mockLoadBridges = vi.fn(); | ||
| const mockLoadNamespaces = vi.fn(); | ||
|
|
||
| vi.mock('../../services/api/graphBridgesApi', () => ({ | ||
| loadBridges: (...args: unknown[]) => mockLoadBridges(...args), | ||
| loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args), | ||
| })); | ||
|
|
||
| function rel(subject: string, object: string): GraphRelation { | ||
| return { | ||
| namespace: 'n', | ||
| subject, | ||
| predicate: 'p', | ||
| object, | ||
| attrs: {}, | ||
| updatedAt: 0, | ||
| evidenceCount: 1, | ||
| orderIndex: null, | ||
| documentIds: [], | ||
| chunkIds: [], | ||
| }; | ||
| } | ||
|
|
||
| const result = computeGraphBridges([rel('A', 'B'), rel('B', 'C')]); | ||
|
|
||
| describe('<GraphBridgesTab />', () => { | ||
| beforeEach(() => { | ||
| mockLoadBridges.mockReset(); | ||
| mockLoadNamespaces.mockReset(); | ||
| mockLoadBridges.mockResolvedValue(result); | ||
| mockLoadNamespaces.mockResolvedValue([]); | ||
| }); | ||
|
|
||
| it('loads bridges (all namespaces) on mount and renders the result', async () => { | ||
| render(<GraphBridgesTab />); | ||
| expect(mockLoadBridges).toHaveBeenCalledWith(undefined); | ||
| await waitFor(() => expect(screen.getByText('Articulation entities')).toBeInTheDocument()); | ||
| }); | ||
|
|
||
| it('shows the namespace selector and re-queries on change', async () => { | ||
| mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']); | ||
| render(<GraphBridgesTab />); | ||
| await waitFor(() => screen.getByRole('combobox')); | ||
| fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } }); | ||
| await waitFor(() => expect(mockLoadBridges).toHaveBeenCalledWith('work')); | ||
| }); | ||
|
|
||
| it('surfaces an error when the load fails', async () => { | ||
| mockLoadBridges.mockReset(); | ||
| mockLoadBridges.mockRejectedValueOnce(new Error('graph unavailable')); | ||
| render(<GraphBridgesTab />); | ||
| await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silent truncation: tables cap at 25 rows while tiles/caption show full counts.
articulationRows/bridgeRowscap atMAX_ROWS(25), but the metric tiles and caption render the fullarticulationCount/bridges.length. When either set exceeds 25, the lists are truncated with no indicator, so a user could read the tile as "120 articulations" yet only see 25 rows and assume that's the complete set. Consider surfacing a "+N more" / "showing top 25" hint viauseT()when the underlying count exceeds what is rendered.🤖 Prompt for AI Agents