feat(intelligence): add Knowledge Graph Centrality (#2931)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Aashir Athar
2026-05-29 19:46:58 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent e9882f63c7
commit ec382ad732
23 changed files with 1478 additions and 1 deletions
@@ -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('<GraphCentralityPanel />', () => {
it('renders the loading skeleton', () => {
render(<GraphCentralityPanel result={null} loading />);
expect(screen.getByTestId('graph-centrality-loading')).toBeInTheDocument();
});
it('renders the empty state when there are no nodes', () => {
render(<GraphCentralityPanel result={computeGraphCentrality([])} />);
expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument();
});
it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<GraphCentralityPanel 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 and the ranked hub table for a populated graph', () => {
render(<GraphCentralityPanel result={fixtureE} />);
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(<GraphCentralityPanel result={bridgeResult} />);
expect(screen.getByText('connector')).toBeInTheDocument();
});
});
@@ -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<number, number>();
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 = (
<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('graphCentrality.title')}</p>
<p>{t('graphCentrality.intro')}</p>
</div>
);
if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('graphCentrality.loading')}
data-testid="graph-centrality-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('graphCentrality.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('graphCentrality.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('graphCentrality.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('graphCentrality.emptyHint')}
</p>
</div>
</div>
);
}
const maxRank = result.nodes[0].pageRank || 1;
const rows = result.nodes.slice(0, MAX_ROWS);
return (
<div className="space-y-4">
{intro}
{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-3">
{[
{ label: t('graphCentrality.metricEntities'), value: result.nodeCount },
{ label: t('graphCentrality.metricConnections'), value: result.edgeCount },
{ label: t('graphCentrality.metricClusters'), value: result.componentCount },
].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">
{t('graphCentrality.clustersCaption')
.replace('{components}', String(result.componentCount))
.replace('{largest}', String(largestCluster))}
{!result.converged && (
<span
title={t('graphCentrality.approximateTitle')}
className="ml-2 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{t('graphCentrality.approximateBadge')}
</span>
)}
</p>
{/* Ranked hub table */}
<section aria-labelledby="graph-centrality-heading" className="space-y-1">
<h3
id="graph-centrality-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('graphCentrality.rankedHeading')}
</h3>
<table 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('graphCentrality.colRank')}
</th>
<th scope="col" className="py-1 pr-2 font-medium">
{t('graphCentrality.colEntity')}
</th>
<th scope="col" className="w-1/3 py-1 pr-2 font-medium">
{t('graphCentrality.colInfluence')}
</th>
<th scope="col" className="w-12 py-1 text-right font-medium">
{t('graphCentrality.colLinks')}
</th>
</tr>
</thead>
<tbody>
{rows.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}
{bridgeIds.has(node.id) && (
<span
title={t('graphCentrality.bridgeTitle')}
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('graphCentrality.bridgeBadge')}
</span>
)}
</td>
<td className="py-1 pr-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded bg-stone-100 dark:bg-neutral-800 overflow-hidden">
<div
className="h-full bg-primary-400/70"
style={{ width: `${(node.pageRank / maxRank) * 100}%` }}
/>
</div>
<span className="w-12 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{node.pageRank.toFixed(3)}
</span>
</div>
</td>
<td
className="py-1 text-right text-stone-500 dark:text-neutral-400"
title={t('graphCentrality.degreeTitle')
.replace('{in}', String(node.inDegree))
.replace('{out}', String(node.outDegree))}
aria-label={t('graphCentrality.degreeTitle')
.replace('{in}', String(node.inDegree))
.replace('{out}', String(node.outDegree))}>
{node.totalDegree}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
);
};
export default GraphCentralityPanel;
@@ -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('<GraphCentralityTab />', () => {
beforeEach(() => {
mockLoadCentrality.mockReset();
mockLoadNamespaces.mockReset();
mockLoadCentrality.mockResolvedValue(result);
mockLoadNamespaces.mockResolvedValue([]);
});
it('loads centrality (all namespaces) on mount and renders the result', async () => {
render(<GraphCentralityTab />);
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(<GraphCentralityTab />);
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(<GraphCentralityTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
@@ -0,0 +1,83 @@
/**
* Knowledge Graph Centrality tab (container). Owns load-on-mount and the
* namespace selector; delegates all rendering to the pure <GraphCentralityPanel>.
* 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<CentralityResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [namespaces, setNamespaces] = useState<string[]>([]);
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 (
<div className="space-y-4">
{namespaces.length > 0 && (
<label className="flex items-center gap-2 text-xs text-stone-600 dark:text-neutral-300">
{t('graphCentrality.namespaceLabel')}
<select
value={namespace}
onChange={e => handleNamespace(e.target.value)}
className="rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1 text-sm text-stone-800 dark:text-neutral-100">
<option value="">{t('graphCentrality.namespaceAll')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
</label>
)}
<GraphCentralityPanel
result={result}
loading={loading}
error={error}
onRetry={() => void load(namespace)}
/>
</div>
);
};
export default GraphCentralityTab;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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':
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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;
+26
View File
@@ -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',
+257
View File
@@ -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<string, number> {
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([]);
});
});
+303
View File
@@ -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 * Σ_{uv} 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<string, Map<string, number>>();
const nodeSet = new Set<string>();
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<string, number>();
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<string, number>();
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: number; weight: number }>> = 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<string, number> {
const sorted = [...nodes].sort((a, b) => value(b) - value(a));
const ranks = new Map<string, number>();
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;
}
+6 -1
View File
@@ -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' && <IntelligenceCallsTab onToast={addToast} />}
{activeTab === 'dreams' && <IntelligenceDreamsTab />}
{activeTab === 'centrality' && <GraphCentralityTab />}
</div>
</div>
</div>
@@ -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');
});
});
@@ -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<CentralityResult> {
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<string[]> {
return memoryListNamespaces();
}
export const graphCentralityApi = { loadCentrality, loadNamespaces };