mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(intelligence): add Knowledge Graph Centrality (#2931)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e9882f63c7
commit
ec382ad732
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user