diff --git a/app/src/components/intelligence/GraphCentralityPanel.test.tsx b/app/src/components/intelligence/GraphCentralityPanel.test.tsx new file mode 100644 index 000000000..e65012448 --- /dev/null +++ b/app/src/components/intelligence/GraphCentralityPanel.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeGraphCentrality } from '../../lib/memory/graphCentrality'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphCentralityPanel from './GraphCentralityPanel'; + +function rel(subject: string, object: string, evidenceCount = 1): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const fixtureE = computeGraphCentrality([ + rel('A', 'B'), + rel('A', 'C'), + rel('B', 'C'), + rel('C', 'A'), + rel('D', 'C'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('graph-centrality-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no nodes', () => { + render(); + expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument(); + }); + + it('renders an error with a working retry button', () => { + const onRetry = vi.fn(); + render(); + expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders metric tiles and the ranked hub table for a populated graph', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Connections')).toBeInTheDocument(); + expect(screen.getByText('Clusters')).toBeInTheDocument(); + expect(screen.getByText('Top entities by influence')).toBeInTheDocument(); + // C is the top hub; its PageRank (0.394) renders to 3 decimals. + expect(screen.getByText('C')).toBeInTheDocument(); + expect(screen.getByText('0.394')).toBeInTheDocument(); + }); + + it('flags a connector/bridge entity with a badge', () => { + // V inherits hub H's outflow (top influence) but has degree 1, while the + // source hubs M2/M3 fill the middle degree tiers -> V is a connector. + const bridgeResult = computeGraphCentrality([ + rel('L1', 'H'), + rel('L2', 'H'), + rel('L3', 'H'), + rel('H', 'V'), + rel('M2', 'x'), + rel('M2', 'y'), + rel('M3', 'p'), + rel('M3', 'q'), + rel('M3', 'r'), + ]); + render(); + expect(screen.getByText('connector')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/GraphCentralityPanel.tsx b/app/src/components/intelligence/GraphCentralityPanel.tsx new file mode 100644 index 000000000..7aeb6f5d7 --- /dev/null +++ b/app/src/components/intelligence/GraphCentralityPanel.tsx @@ -0,0 +1,224 @@ +/** + * Knowledge Graph Centrality — presentational view. Pure: derives the bridge + * set and cluster sizes from the precomputed result and renders the metric + * tiles + ranked hub table. No data fetching, no clock, no randomness. + */ +import { useMemo } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { type CentralityResult, findBridges } from '../../lib/memory/graphCentrality'; + +const MAX_ROWS = 25; + +interface GraphCentralityPanelProps { + result: CentralityResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const GraphCentralityPanel = ({ result, loading, error, onRetry }: GraphCentralityPanelProps) => { + const { t } = useT(); + + const bridgeIds = useMemo( + () => new Set((result ? findBridges(result) : []).map(b => b.id)), + [result] + ); + + const largestCluster = useMemo(() => { + if (!result) return 0; + const sizes = new Map(); + for (const node of result.nodes) { + sizes.set(node.componentId, (sizes.get(node.componentId) ?? 0) + 1); + } + let max = 0; + for (const size of sizes.values()) { + if (size > max) max = size; + } + return max; + }, [result]); + + const intro = ( +
+

{t('graphCentrality.title')}

+

{t('graphCentrality.intro')}

+
+ ); + + if (loading) { + return ( +
+ {intro} +
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+ {[0, 1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ {intro} +
+

+ {t('graphCentrality.errorPrefix')} {error} +

+ {onRetry && ( + + )} +
+
+ ); + } + + if (!result || result.nodes.length === 0) { + return ( +
+ {intro} +
+

+ {t('graphCentrality.empty')} +

+

+ {t('graphCentrality.emptyHint')} +

+
+
+ ); + } + + const maxRank = result.nodes[0].pageRank || 1; + const rows = result.nodes.slice(0, MAX_ROWS); + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('graphCentrality.metricEntities'), value: result.nodeCount }, + { label: t('graphCentrality.metricConnections'), value: result.edgeCount }, + { label: t('graphCentrality.metricClusters'), value: result.componentCount }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('graphCentrality.clustersCaption') + .replace('{components}', String(result.componentCount)) + .replace('{largest}', String(largestCluster))} + {!result.converged && ( + + {t('graphCentrality.approximateBadge')} + + )} +

+ + {/* Ranked hub table */} +
+

+ {t('graphCentrality.rankedHeading')} +

+ + + + + + + + + + + {rows.map((node, i) => ( + + + + + + + ))} + +
+ {t('graphCentrality.colRank')} + + {t('graphCentrality.colEntity')} + + {t('graphCentrality.colInfluence')} + + {t('graphCentrality.colLinks')} +
{i + 1} + {node.id} + {bridgeIds.has(node.id) && ( + + {t('graphCentrality.bridgeBadge')} + + )} + +
+
+
+
+ + {node.pageRank.toFixed(3)} + +
+
+ {node.totalDegree} +
+
+
+ ); +}; + +export default GraphCentralityPanel; diff --git a/app/src/components/intelligence/GraphCentralityTab.test.tsx b/app/src/components/intelligence/GraphCentralityTab.test.tsx new file mode 100644 index 000000000..18a5b0598 --- /dev/null +++ b/app/src/components/intelligence/GraphCentralityTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphCentrality } from '../../lib/memory/graphCentrality'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphCentralityTab from './GraphCentralityTab'; + +const mockLoadCentrality = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/graphCentralityApi', () => ({ + loadCentrality: (...args: unknown[]) => mockLoadCentrality(...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 = computeGraphCentrality([rel('A', 'B'), rel('B', 'A')]); + +describe('', () => { + beforeEach(() => { + mockLoadCentrality.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadCentrality.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads centrality (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadCentrality).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(screen.getByText('Top entities by influence')).toBeInTheDocument()); + }); + + it('shows the namespace selector and re-queries on change', async () => { + mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']); + render(); + await waitFor(() => screen.getByRole('combobox')); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } }); + await waitFor(() => expect(mockLoadCentrality).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadCentrality.mockReset(); + mockLoadCentrality.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/GraphCentralityTab.tsx b/app/src/components/intelligence/GraphCentralityTab.tsx new file mode 100644 index 000000000..e70a78bd9 --- /dev/null +++ b/app/src/components/intelligence/GraphCentralityTab.tsx @@ -0,0 +1,83 @@ +/** + * Knowledge Graph Centrality tab (container). Owns load-on-mount and the + * namespace selector; delegates all rendering to the pure . + * Read-only — the result is recomputed from the live graph, never persisted. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { CentralityResult } from '../../lib/memory/graphCentrality'; +import { loadCentrality, loadNamespaces } from '../../services/api/graphCentralityApi'; +import GraphCentralityPanel from './GraphCentralityPanel'; + +const GraphCentralityTab = () => { + const { t } = useT(); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [namespaces, setNamespaces] = useState([]); + const [namespace, setNamespace] = useState(''); + // Monotonic token: ignore a response if a newer load has since started, so + // an out-of-order resolution can never overwrite the latest result. + const latestRequestId = useRef(0); + + const load = useCallback(async (ns: string) => { + const requestId = (latestRequestId.current += 1); + setLoading(true); + setError(null); + try { + const next = await loadCentrality(ns || undefined); + if (requestId !== latestRequestId.current) return; + setResult(next); + } catch (err) { + if (requestId !== latestRequestId.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (requestId === latestRequestId.current) setLoading(false); + } + }, []); + + useEffect(() => { + // Namespaces are optional UI sugar; a failure to list them must not block + // the centrality view, so swallow that error specifically. + loadNamespaces() + .then(setNamespaces) + .catch(() => setNamespaces([])); + void load(''); + }, [load]); + + const handleNamespace = (next: string): void => { + setNamespace(next); + void load(next); + }; + + return ( +
+ {namespaces.length > 0 && ( + + )} + + void load(namespace)} + /> +
+ ); +}; + +export default GraphCentralityTab; diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index a76e07784..b551516c1 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -1578,6 +1578,32 @@ const ar1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default ar1; diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index adeb7b23f..310dfece8 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -1587,6 +1587,32 @@ const bn1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default bn1; diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index 79338ea94..e9f029301 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -1605,6 +1605,32 @@ const de1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default de1; diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index 7e84a8bb2..129fc96db 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -492,8 +492,34 @@ const en1: TranslationMap = { 'memory.tab.dreams': 'Dreams', 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', + 'memory.tab.centrality': 'Centrality', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', 'memoryTree.status.title': 'Memory Tree', 'memoryTree.status.autoSyncLabel': 'Auto-sync', 'memoryTree.status.autoSyncDescription': diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index ec4292562..c66a76bbb 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -1602,6 +1602,32 @@ const es1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default es1; diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index 0dde5cee9..142645137 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -1607,6 +1607,32 @@ const fr1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default fr1; diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index 45d251eb9..be162979f 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -1586,6 +1586,32 @@ const hi1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default hi1; diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index 190863de5..1ce4240e2 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -1590,6 +1590,32 @@ const id1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default id1; diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 17c26d8fb..e1b4abe73 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -1595,6 +1595,32 @@ const it1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default it1; diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index d4b23bb06..7cdab0dea 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -1587,5 +1587,31 @@ const ko1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default ko1; diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index 96eac576c..c4ff70be4 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -1600,6 +1600,32 @@ const pt1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default pt1; diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index d5883bdf5..79e6d6e06 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -1590,6 +1590,32 @@ const ru1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default ru1; diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 6567060c3..aceeb7e35 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -1571,6 +1571,32 @@ const zhCN1: TranslationMap = { 'settings.search.accessBlockAll': 'Block all', 'settings.search.accessBlockAllHint': 'All web access is blocked — the assistant cannot open or read any website.', + 'memory.tab.centrality': 'Centrality', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', }; export default zhCN1; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 699c587a7..4505f8d5f 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -300,8 +300,34 @@ const en: TranslationMap = { 'memory.tab.dreams': 'Dreams', 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', + 'memory.tab.centrality': 'Centrality', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'graphCentrality.title': 'Knowledge Graph Centrality', + 'graphCentrality.intro': + 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', + 'graphCentrality.loading': 'Computing centrality…', + 'graphCentrality.errorPrefix': 'Could not load the graph:', + 'graphCentrality.retry': 'Retry', + 'graphCentrality.empty': 'No knowledge graph yet.', + 'graphCentrality.emptyHint': + 'As the assistant records facts about you, the most connected entities will surface here.', + 'graphCentrality.namespaceLabel': 'Namespace', + 'graphCentrality.namespaceAll': 'All namespaces', + 'graphCentrality.metricEntities': 'Entities', + 'graphCentrality.metricConnections': 'Connections', + 'graphCentrality.metricClusters': 'Clusters', + 'graphCentrality.clustersCaption': '{components} clusters · largest holds {largest}', + 'graphCentrality.approximateBadge': 'approximate', + 'graphCentrality.approximateTitle': 'Stopped at the iteration cap before fully converging', + 'graphCentrality.rankedHeading': 'Top entities by influence', + 'graphCentrality.colRank': '#', + 'graphCentrality.colEntity': 'Entity', + 'graphCentrality.colInfluence': 'Influence', + 'graphCentrality.colLinks': 'Links', + 'graphCentrality.bridgeBadge': 'connector', + 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', + 'graphCentrality.degreeTitle': '{in} in · {out} out', // Memory Tree status panel (#1856 Part 1) 'memoryTree.status.title': 'Memory Tree', diff --git a/app/src/lib/memory/graphCentrality.test.ts b/app/src/lib/memory/graphCentrality.test.ts new file mode 100644 index 000000000..8251e3305 --- /dev/null +++ b/app/src/lib/memory/graphCentrality.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { type CentralityResult, computeGraphCentrality, findBridges } from './graphCentrality'; + +function rel(subject: string, object: string, evidenceCount = 1, predicate = 'p'): GraphRelation { + return { + namespace: 'n', + subject, + predicate, + object, + attrs: {}, + updatedAt: 0, + evidenceCount, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +/** PageRank keyed by entity id. */ +function ranks(result: CentralityResult): Record { + return Object.fromEntries(result.nodes.map(n => [n.id, n.pageRank])); +} + +function sum(result: CentralityResult): number { + return result.nodes.reduce((s, n) => s + n.pageRank, 0); +} + +describe('computeGraphCentrality — known-value fixtures', () => { + it('FIXTURE A: 2-node mutual link is symmetric (0.5 / 0.5)', () => { + const r = computeGraphCentrality([rel('A', 'B'), rel('B', 'A')]); + const pr = ranks(r); + expect(pr.A).toBeCloseTo(0.5, 6); + expect(pr.B).toBeCloseTo(0.5, 6); + expect(r.componentCount).toBe(1); + expect(sum(r)).toBeCloseTo(1, 9); + }); + + it('FIXTURE B: 3-node directed cycle stays uniform (1/3 each)', () => { + const r = computeGraphCentrality([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + const pr = ranks(r); + expect(pr.A).toBeCloseTo(0.333333, 6); + expect(pr.B).toBeCloseTo(0.333333, 6); + expect(pr.C).toBeCloseTo(0.333333, 6); + expect(r.componentCount).toBe(1); + expect(sum(r)).toBeCloseTo(1, 9); + }); + + it('FIXTURE C: in-star with dangling sink (dangling-mass regression)', () => { + const r = computeGraphCentrality([rel('A', 'C'), rel('B', 'C')]); + const pr = ranks(r); + expect(pr.A).toBeCloseTo(0.212766, 6); + expect(pr.B).toBeCloseTo(0.212766, 6); + expect(pr.C).toBeCloseTo(0.574468, 6); + expect(sum(r)).toBeCloseTo(1, 9); // canary: drops below 1 if dangling leaks + }); + + it('FIXTURE D: evidenceCount weights the out-flow (3:1 split)', () => { + const r = computeGraphCentrality([rel('A', 'B', 3), rel('A', 'C', 1)]); + const pr = ranks(r); + expect(pr.A).toBeCloseTo(0.25974, 6); + expect(pr.B).toBeCloseTo(0.425325, 6); + expect(pr.C).toBeCloseTo(0.314935, 6); + expect(pr.B).toBeGreaterThan(pr.C); // heavier edge -> higher rank + expect(sum(r)).toBeCloseTo(1, 9); + }); + + it('FIXTURE E: 4-node mixed graph (integration + closed-form anchor)', () => { + const r = computeGraphCentrality([ + rel('A', 'B'), + rel('A', 'C'), + rel('B', 'C'), + rel('C', 'A'), + rel('D', 'C'), + ]); + const pr = ranks(r); + expect(pr.A).toBeCloseTo(0.372527, 6); + expect(pr.B).toBeCloseTo(0.195824, 6); + expect(pr.C).toBeCloseTo(0.394149, 6); + expect(pr.D).toBeCloseTo(0.0375, 6); // pure source: exactly (1-d)/N = 0.15/4 + expect(r.nodes[0].id).toBe('C'); // C is the top hub (3 inbound) + expect(r.componentCount).toBe(1); + expect(sum(r)).toBeCloseTo(1, 9); + }); + + it('FIXTURE F: single self-loop keeps all mass and counts degree both ways', () => { + const r = computeGraphCentrality([rel('A', 'A')]); + const a = r.nodes[0]; + expect(a.id).toBe('A'); + expect(a.pageRank).toBeCloseTo(1, 6); + expect(a.inDegree).toBe(1); + expect(a.outDegree).toBe(1); + expect(a.totalDegree).toBe(2); + expect(r.componentCount).toBe(1); + expect(r.converged).toBe(true); + }); + + it('FIXTURE G: two self-loops are two components (self-loops skipped in union-find)', () => { + const r = computeGraphCentrality([rel('A', 'A'), rel('B', 'B')]); + const pr = ranks(r); + expect(pr.A).toBeCloseTo(0.5, 6); + expect(pr.B).toBeCloseTo(0.5, 6); + expect(r.componentCount).toBe(2); + expect(sum(r)).toBeCloseTo(1, 9); + }); + + it('EMPTY: no relations -> empty, safe, converged result', () => { + const r = computeGraphCentrality([]); + expect(r.nodes).toEqual([]); + expect(r.nodeCount).toBe(0); + expect(r.edgeCount).toBe(0); + expect(r.componentCount).toBe(0); + expect(r.iterations).toBe(0); + expect(r.converged).toBe(true); + }); +}); + +describe('computeGraphCentrality — determinism', () => { + it('is invariant to relation order (no Map-iteration-order dependence)', () => { + const triples = [rel('A', 'B'), rel('A', 'C'), rel('B', 'C'), rel('C', 'A'), rel('D', 'C')]; + const forward = computeGraphCentrality(triples); + const reversed = computeGraphCentrality([...triples].reverse()); + expect(ranks(reversed)).toEqual(ranks(forward)); + expect(reversed.nodes.map(n => n.id)).toEqual(forward.nodes.map(n => n.id)); + }); + + it('is identical across tolerances 1e-9 / 1e-12 / 1e-15', () => { + const triples = [rel('A', 'C'), rel('B', 'C')]; + const a = ranks(computeGraphCentrality(triples, { tolerance: 1e-9 })); + const b = ranks(computeGraphCentrality(triples, { tolerance: 1e-12 })); + const c = ranks(computeGraphCentrality(triples, { tolerance: 1e-15 })); + for (const id of ['A', 'B', 'C']) { + expect(b[id]).toBeCloseTo(a[id], 6); + expect(c[id]).toBeCloseTo(b[id], 6); + } + }); +}); + +describe('computeGraphCentrality — degree, collapse, sanitization', () => { + it('reports unweighted and evidence-weighted in/out degree', () => { + const r = computeGraphCentrality([rel('A', 'B', 3), rel('A', 'C', 1)]); + const byId = Object.fromEntries(r.nodes.map(n => [n.id, n])); + expect(byId.A.outDegree).toBe(2); + expect(byId.A.weightedOutDegree).toBe(4); // 3 + 1 + expect(byId.A.inDegree).toBe(0); + expect(byId.B.inDegree).toBe(1); + expect(byId.B.weightedInDegree).toBe(3); + expect(byId.C.weightedInDegree).toBe(1); + }); + + it('collapses parallel edges between the same pair and sums their weights', () => { + // Same (A,B) pair under two predicates + a duplicate => one edge, weight 1+1+2. + const r = computeGraphCentrality([ + rel('A', 'B', 1, 'knows'), + rel('A', 'B', 1, 'likes'), + rel('A', 'B', 2, 'knows'), + ]); + expect(r.edgeCount).toBe(1); + const byId = Object.fromEntries(r.nodes.map(n => [n.id, n])); + expect(byId.A.outDegree).toBe(1); + expect(byId.A.weightedOutDegree).toBe(4); + }); + + it('clamps zero / negative / NaN evidenceCount to weight 1', () => { + const r = computeGraphCentrality([ + rel('A', 'B', 0), + rel('C', 'D', -5), + rel('E', 'F', Number.NaN), + ]); + const byId = Object.fromEntries(r.nodes.map(n => [n.id, n])); + expect(byId.A.weightedOutDegree).toBe(1); + expect(byId.C.weightedOutDegree).toBe(1); + expect(byId.E.weightedOutDegree).toBe(1); + expect(sum(r)).toBeCloseTo(1, 9); + }); + + it('drops a malformed relation with a non-string endpoint', () => { + const malformed = { ...rel('A', 'B'), object: null as unknown as string }; + const r = computeGraphCentrality([rel('A', 'B'), malformed, rel('B', 'C')]); + // 'A','B','C' are the only nodes; the null-object row is ignored. + expect(r.nodes.map(n => n.id).sort()).toEqual(['A', 'B', 'C']); + }); + + it('treats case/whitespace variants as DISTINCT entities (no normalization)', () => { + const r = computeGraphCentrality([rel('Alice', 'X'), rel('alice', 'X'), rel(' Alice ', 'X')]); + const ids = r.nodes.map(n => n.id).sort(); + expect(ids).toContain('Alice'); + expect(ids).toContain('alice'); + expect(ids).toContain(' Alice '); + }); + + it('handles entities containing the historic separator without collision', () => { + // "A" + "B C" vs "A B" + "C" must NOT merge into one edge. + const r = computeGraphCentrality([rel('A', 'B C'), rel('A B', 'C')]); + expect(r.edgeCount).toBe(2); + expect(r.nodes.map(n => n.id).sort()).toEqual(['A', 'A B', 'B C', 'C']); + }); +}); + +describe('computeGraphCentrality — convergence cap', () => { + it('returns converged:false when maxIterations is hit before tolerance', () => { + const triples = [rel('A', 'B'), rel('B', 'C'), rel('C', 'A'), rel('A', 'C')]; + const capped = computeGraphCentrality(triples, { maxIterations: 1 }); + expect(capped.converged).toBe(false); + expect(capped.iterations).toBe(1); + // Still returns a usable estimate that sums to ~1. + expect(sum(capped)).toBeCloseTo(1, 9); + }); +}); + +describe('findBridges', () => { + // V inherits hub H's outflow (highest PageRank) but has degree 1, while the + // pure-source hubs M2/M3 occupy the middle degree tiers — so V's PageRank + // dense-rank (1) sits well above its degree dense-rank, marking it a connector. + const bridgeFixture: GraphRelation[] = [ + rel('L1', 'H'), + rel('L2', 'H'), + rel('L3', 'H'), + rel('H', 'V'), + rel('M2', 'x'), + rel('M2', 'y'), + rel('M3', 'p'), + rel('M3', 'q'), + rel('M3', 'r'), + ]; + + it('flags a high-PageRank, low-degree connector and not the obvious hub', () => { + const bridges = findBridges(computeGraphCentrality(bridgeFixture)); + const ids = bridges.map(b => b.id); + expect(ids).toContain('V'); + expect(ids).not.toContain('H'); // H ranks high on BOTH degree and PageRank + const v = bridges.find(b => b.id === 'V')!; + expect(v.gap).toBeGreaterThanOrEqual(2); + }); + + it('uses tie-aware dense ranks — flags are independent of entity names', () => { + const base = findBridges(computeGraphCentrality(bridgeFixture)); + // Relabel every entity via a bijection; the structure is identical so the + // set of gaps must be identical (only the ids change). + const renamed = bridgeFixture.map(t => rel(`z_${t.subject}`, `z_${t.object}`, t.evidenceCount)); + const after = findBridges(computeGraphCentrality(renamed)); + expect(after.map(b => b.gap).sort()).toEqual(base.map(b => b.gap).sort()); + expect(after.map(b => b.id)).toEqual(base.map(b => `z_${b.id}`)); + }); + + it('gives tied nodes the same dense rank (no name-driven gap)', () => { + // Two mutually-linked nodes: identical pageRank AND identical degree, so + // both dense ranks tie and neither is a spurious bridge. + const bridges = findBridges(computeGraphCentrality([rel('A', 'B'), rel('B', 'A')])); + expect(bridges).toEqual([]); + }); + + it('returns [] for an empty graph', () => { + expect(findBridges(computeGraphCentrality([]))).toEqual([]); + }); +}); diff --git a/app/src/lib/memory/graphCentrality.ts b/app/src/lib/memory/graphCentrality.ts new file mode 100644 index 000000000..70e3ee338 --- /dev/null +++ b/app/src/lib/memory/graphCentrality.ts @@ -0,0 +1,303 @@ +/** + * Knowledge Graph Centrality — pure scoring engine. + * + * The memory knowledge graph is a set of (subject)-[predicate]->(object) + * triples. This engine treats every distinct subject/object string as a NODE + * and every triple as a directed EDGE, then computes the *structural* backbone + * of the user's accumulated knowledge: + * - PageRank (directed, damping 0.85) — which entities everything points at, + * - in/out/total degree (unweighted + evidence-weighted), + * - the number of weakly-connected components (islands of knowledge). + * + * No mainstream assistant surfaces the load-bearing HUBS and BRIDGE entities of + * your own memory; a frequency/evidence sort cannot reveal a low-frequency node + * that nonetheless connects otherwise-disjoint clusters — PageRank can. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. The result depends ONLY on the (subject, object, evidenceCount) + * structure of the relations — never on insertion order — so the same graph + * always yields byte-identical ranks and every branch is unit-testable. + * + * Load-bearing design choices (do not "fix" without reading the tests): + * - Entity identity is the raw string AS-IS: NO trimming, NO case-folding. + * Surfacing that "Alice" / "alice" / " Alice " are split variants is a + * feature of this lens (it deliberately diverges from the case-insensitive + * neighbour lookups elsewhere in the memory layer). + * - A self-loop (subject === object) is kept in the PageRank transition and + * counts toward in/out degree, but is SKIPPED in the component union-find + * (a self-loop adds no connectivity). + * - Parallel edges (same ordered pair under different predicates / duplicate + * triples) collapse into one edge whose weight is the SUM of per-triple + * evidence weights. Collapse uses a nested map keyed on the raw strings, so + * two pairs can never collide regardless of what characters an entity holds. + * - evidenceCount is sanitized to >= 1 per triple: a 0 / negative / NaN value + * still represents a real asserted edge, and a 0/negative weight would + * corrupt the stochastic transition matrix. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface CentralityNode { + id: string; + pageRank: number; + inDegree: number; // distinct collapsed in-edges (incl. a self-loop) + outDegree: number; // distinct collapsed out-edges (incl. a self-loop) + totalDegree: number; // inDegree + outDegree + weightedInDegree: number; // sum of inbound edge weights + weightedOutDegree: number; // sum of outbound edge weights + componentId: number; // smallest node index in this weakly-connected component +} + +export interface CentralityResult { + nodes: CentralityNode[]; // sorted by pageRank DESC, then id ASC (stable) + componentCount: number; + iterations: number; + converged: boolean; + nodeCount: number; + edgeCount: number; // distinct collapsed (subject, object) edges +} + +export interface CentralityOptions { + damping?: number; + tolerance?: number; + maxIterations?: number; +} + +export const DEFAULT_DAMPING = 0.85; +export const DEFAULT_TOLERANCE = 1e-12; +export const DEFAULT_MAX_ITERATIONS = 1000; + +const EMPTY_RESULT: CentralityResult = { + nodes: [], + componentCount: 0, + iterations: 0, + converged: true, + nodeCount: 0, + edgeCount: 0, +}; + +/** Per-triple evidence weight, clamped to >= 1 (a real edge is never weightless). */ +function edgeWeight(evidenceCount: number): number { + return Math.max(1, Number.isFinite(evidenceCount) ? evidenceCount : 1); +} + +/** Total-order string comparator so node indexing never depends on Map order. */ +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +interface CollapsedEdge { + source: number; // node index + target: number; // node index + weight: number; +} + +/** + * Compute centrality over the knowledge graph. Pure function of `relations`. + * + * PageRank uses Jacobi power iteration: r0(i) = 1/N, and per step + * r(v) = (1 - d)/N + d * Dmass / N + d * Σ_{u→v} r(u) * w(u,v) / Wout(u) + * where Dmass is the total rank sitting on dangling (zero-out-weight) nodes, + * redistributed uniformly so Σ r == 1 every iteration. Iterates until the L1 + * delta < tolerance or maxIterations is hit (converged: false in the latter). + */ +export function computeGraphCentrality( + relations: GraphRelation[], + options: CentralityOptions = {} +): CentralityResult { + const damping = options.damping ?? DEFAULT_DAMPING; + const tolerance = options.tolerance ?? DEFAULT_TOLERANCE; + const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS; + + // 1. Collapse triples into weighted directed edges, dropping malformed rows. + // A nested map (source -> target -> weight) avoids any string-key + // concatenation, so entities are never mis-merged or mis-split. + const weightsBySource = new Map>(); + const nodeSet = new Set(); + for (const relation of relations) { + const subject = relation.subject; + const object = relation.object; + if (typeof subject !== 'string' || typeof object !== 'string') continue; + nodeSet.add(subject); + nodeSet.add(object); + const weight = edgeWeight(relation.evidenceCount); + let targets = weightsBySource.get(subject); + if (!targets) { + targets = new Map(); + weightsBySource.set(subject, targets); + } + targets.set(object, (targets.get(object) ?? 0) + weight); + } + + const nodeCount = nodeSet.size; + if (nodeCount === 0) return EMPTY_RESULT; + + // 2. Fixed total order over node ids -> stable matrix indices. + const ids = [...nodeSet].sort(compareIds); + const indexOf = new Map(); + ids.forEach((id, i) => indexOf.set(id, i)); + + // 3. Build collapsed edges, sorted by (source, target) for a deterministic + // summation order. + const edges: CollapsedEdge[] = []; + for (const [source, targets] of weightsBySource) { + const sourceIndex = indexOf.get(source)!; + for (const [target, weight] of targets) { + edges.push({ source: sourceIndex, target: indexOf.get(target)!, weight }); + } + } + edges.sort((a, b) => a.source - b.source || a.target - b.target); + const edgeCount = edges.length; + + // 4. Degree + adjacency. inEdges[v] is appended in source-sorted order + // (because `edges` is pre-sorted), pinning floating-point accumulation. + const outWeight = new Float64Array(nodeCount); + const weightedIn = new Float64Array(nodeCount); + const inDegree = new Int32Array(nodeCount); + const outDegree = new Int32Array(nodeCount); + const inEdges: Array> = Array.from( + { length: nodeCount }, + () => [] + ); + for (const edge of edges) { + outWeight[edge.source] += edge.weight; + weightedIn[edge.target] += edge.weight; + outDegree[edge.source] += 1; + inDegree[edge.target] += 1; + inEdges[edge.target].push({ from: edge.source, weight: edge.weight }); + } + + // 5. Weakly-connected components via union-find (smaller index = root, so the + // component id is deterministic). Self-loops add no connectivity. + const parent = new Int32Array(nodeCount); + for (let i = 0; i < nodeCount; i += 1) parent[i] = i; + const find = (x: number): number => { + let root = x; + while (parent[root] !== root) { + parent[root] = parent[parent[root]]; + root = parent[root]; + } + return root; + }; + const union = (a: number, b: number): void => { + const ra = find(a); + const rb = find(b); + if (ra === rb) return; + if (ra < rb) parent[rb] = ra; + else parent[ra] = rb; + }; + for (const edge of edges) { + if (edge.source !== edge.target) union(edge.source, edge.target); + } + let componentCount = 0; + const componentId = new Int32Array(nodeCount); + for (let i = 0; i < nodeCount; i += 1) { + const root = find(i); + componentId[i] = root; + if (root === i) componentCount += 1; + } + + // 6. PageRank power iteration (Jacobi: accumulate into a fresh array). + let rank = new Float64Array(nodeCount).fill(1 / nodeCount); + const teleport = (1 - damping) / nodeCount; + let iterations = 0; + let converged = false; + while (iterations < maxIterations) { + iterations += 1; + let danglingMass = 0; + for (let i = 0; i < nodeCount; i += 1) { + if (outWeight[i] === 0) danglingMass += rank[i]; + } + const base = teleport + (damping * danglingMass) / nodeCount; + const next = new Float64Array(nodeCount); + for (let v = 0; v < nodeCount; v += 1) { + let inbound = 0; + for (const edge of inEdges[v]) { + inbound += (rank[edge.from] * edge.weight) / outWeight[edge.from]; + } + next[v] = base + damping * inbound; + } + let delta = 0; + for (let i = 0; i < nodeCount; i += 1) delta += Math.abs(next[i] - rank[i]); + rank = next; + if (delta < tolerance) { + converged = true; + break; + } + } + + // 7. Assemble + sort by pageRank DESC, then id ASC (stable tie-break). + const nodes: CentralityNode[] = ids.map((id, i) => ({ + id, + pageRank: rank[i], + inDegree: inDegree[i], + outDegree: outDegree[i], + totalDegree: inDegree[i] + outDegree[i], + weightedInDegree: weightedIn[i], + weightedOutDegree: outWeight[i], + componentId: componentId[i], + })); + nodes.sort((a, b) => b.pageRank - a.pageRank || compareIds(a.id, b.id)); + + return { nodes, componentCount, iterations, converged, nodeCount, edgeCount }; +} + +/** + * A "bridge"/connector entity: its PageRank rank is markedly higher than its + * degree rank — structurally important out of proportion to how many direct + * connections it has (the thing a raw frequency sort cannot surface). `gap` is + * (degreePosition - pageRankPosition); positive and large => a connector. + */ +export interface BridgeFlag { + id: string; + pageRankRank: number; // 1-based DENSE rank by PageRank value (ties share a rank) + degreeRank: number; // 1-based DENSE rank by total degree value (ties share a rank) + gap: number; // degreeRank - pageRankRank +} + +/** + * Dense 1-based ranks by a numeric metric (descending): nodes with an equal + * value share the same rank. Using the metric VALUES (not array positions) + * makes ranking independent of entity names — a pure rename can never change a + * node's rank, so it can never add/remove a bridge flag. + */ +function denseRanksDesc( + nodes: CentralityNode[], + value: (node: CentralityNode) => number +): Map { + const sorted = [...nodes].sort((a, b) => value(b) - value(a)); + const ranks = new Map(); + let rank = 0; + let previous = Number.POSITIVE_INFINITY; + for (const node of sorted) { + const v = value(node); + if (v < previous) { + rank += 1; + previous = v; + } + ranks.set(node.id, rank); + } + return ranks; +} + +/** + * Flag connector/bridge entities from a computed result. A node is a bridge + * when its PageRank rank is at least `minGap` tiers ahead of its degree rank + * (default 2) — surfaces hubs whose influence outruns their raw fan-out. Ranks + * are tie-aware dense ranks on the metric values, so the result never depends + * on entity names. + */ +export function findBridges(result: CentralityResult, minGap = 2): BridgeFlag[] { + const { nodes } = result; + if (nodes.length === 0) return []; + const pageRankRanks = denseRanksDesc(nodes, node => node.pageRank); + const degreeRanks = denseRanksDesc(nodes, node => node.totalDegree); + + const bridges: BridgeFlag[] = []; + for (const node of nodes) { + const pageRankRank = pageRankRanks.get(node.id)!; + const degreeRank = degreeRanks.get(node.id)!; + const gap = degreeRank - pageRankRank; + if (gap >= minGap) bridges.push({ id: node.id, pageRankRank, degreeRank, gap }); + } + return bridges; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 6f6cd9542..4b471ad47 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import DiagramViewerTab from '../components/intelligence/DiagramViewerTab'; import { GameplayReviewWorkspace } from '../components/intelligence/GameplayReviewWorkspace'; +import GraphCentralityTab from '../components/intelligence/GraphCentralityTab'; import IntelligenceCallsTab from '../components/intelligence/IntelligenceCallsTab'; import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; @@ -28,7 +29,8 @@ type IntelligenceTab = | 'calls' | 'dreams' | 'tasks' - | 'diagram'; + | 'diagram' + | 'centrality'; export default function Intelligence() { const { t } = useT(); @@ -104,6 +106,7 @@ export default function Intelligence() { { id: 'diagram', label: t('memory.tab.diagram') }, { id: 'calls', label: t('memory.tab.calls') }, { id: 'dreams', label: t('memory.tab.dreams') }, + { id: 'centrality', label: t('memory.tab.centrality') }, ]; return ( @@ -188,6 +191,8 @@ export default function Intelligence() { {activeTab === 'calls' && } {activeTab === 'dreams' && } + + {activeTab === 'centrality' && }
diff --git a/app/src/services/api/graphCentralityApi.test.ts b/app/src/services/api/graphCentralityApi.test.ts new file mode 100644 index 000000000..71335d41f --- /dev/null +++ b/app/src/services/api/graphCentralityApi.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphCentrality } from '../../lib/memory/graphCentrality'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { graphCentralityApi, loadCentrality, loadNamespaces } from './graphCentralityApi'; + +const mockGraphQuery = vi.fn(); +const mockListNamespaces = vi.fn(); + +vi.mock('../../utils/tauriCommands/memory', () => ({ + memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args), + memoryListNamespaces: (...args: unknown[]) => mockListNamespaces(...args), +})); + +function rel(subject: string, object: string, evidenceCount = 1): GraphRelation { + return { + namespace: 'work', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('graphCentralityApi.loadCentrality', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine result for those triples', async () => { + const triples = [rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadCentrality('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeGraphCentrality(triples)); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadCentrality(); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.nodes).toEqual([]); + expect(out.nodeCount).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadCentrality()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('graphCentralityApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('graphCentralityApi object', () => { + it('exposes the public surface', () => { + expect(typeof graphCentralityApi.loadCentrality).toBe('function'); + expect(typeof graphCentralityApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/graphCentralityApi.ts b/app/src/services/api/graphCentralityApi.ts new file mode 100644 index 000000000..cd30a1ebc --- /dev/null +++ b/app/src/services/api/graphCentralityApi.ts @@ -0,0 +1,29 @@ +/** + * RPC facade for Knowledge Graph Centrality. + * + * Adds ZERO new core surface. It composes two already-shipped JSON-RPC wrappers: + * - memoryGraphQuery (openhuman.memory_graph_query) — the triples + * - memoryListNamespaces (openhuman.memory_list_namespaces) — the selector + * and delegates all math to the pure, deterministic engine. Read-only: there is + * no persistence — the result is always reproducible from the current graph. + */ +import debug from 'debug'; + +import { type CentralityResult, computeGraphCentrality } from '../../lib/memory/graphCentrality'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('graph-centrality:api'); + +/** Fetch the graph relations for a namespace (or all) and score their centrality. */ +export async function loadCentrality(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadCentrality namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeGraphCentrality(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const graphCentralityApi = { loadCentrality, loadNamespaces };