diff --git a/app/src/components/intelligence/EntityAssociationsPanel.test.tsx b/app/src/components/intelligence/EntityAssociationsPanel.test.tsx
new file mode 100644
index 000000000..1d689ca5a
--- /dev/null
+++ b/app/src/components/intelligence/EntityAssociationsPanel.test.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { computeEntityAssociations } from '../../lib/memory/entityAssociations';
+import type { GraphRelation } from '../../utils/tauriCommands/memory';
+import EntityAssociationsPanel from './EntityAssociationsPanel';
+
+function rel(subject: string, object: string): GraphRelation {
+ return {
+ namespace: 'n',
+ subject,
+ predicate: 'p',
+ object,
+ attrs: {},
+ updatedAt: 0,
+ evidenceCount: 1,
+ orderIndex: null,
+ documentIds: [],
+ chunkIds: [],
+ };
+}
+
+// X,Y share neighbours A,B (inferred pair); the triangle A-B-? gives linked pairs.
+const report = computeEntityAssociations([
+ rel('X', 'A'),
+ rel('X', 'B'),
+ rel('Y', 'A'),
+ rel('Y', 'B'),
+]);
+
+describe('', () => {
+ it('renders the loading skeleton', () => {
+ render();
+ expect(screen.getByTestId('entity-associations-loading')).toBeInTheDocument();
+ });
+
+ it('renders the empty state when there are no pairs', () => {
+ render();
+ expect(screen.getByText('No associations yet.')).toBeInTheDocument();
+ });
+
+ it('renders an error with a working retry button', () => {
+ const onRetry = vi.fn();
+ render();
+ expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/);
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders metric tiles and ranked pairs with an inferred badge', () => {
+ render();
+ expect(screen.getByText('Entities')).toBeInTheDocument();
+ expect(screen.getByText('Associations')).toBeInTheDocument();
+ expect(screen.getByText('Strongest associations')).toBeInTheDocument();
+ // X~Y and A~B are perfect, non-linked associations -> "inferred" badges.
+ expect(screen.getAllByText('inferred').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('notes truncation when more pairs exist than are shown', () => {
+ const leaves = ['a', 'b', 'c', 'd', 'e'];
+ const big = computeEntityAssociations(
+ leaves.map(l => rel(l, 'H')),
+ { limit: 3 }
+ );
+ render();
+ expect(screen.getByText('Showing 3 of 10 — strongest first.')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/intelligence/EntityAssociationsPanel.tsx b/app/src/components/intelligence/EntityAssociationsPanel.tsx
new file mode 100644
index 000000000..52eaa75ec
--- /dev/null
+++ b/app/src/components/intelligence/EntityAssociationsPanel.tsx
@@ -0,0 +1,190 @@
+/**
+ * Entity Associations — presentational view. Pure: renders the association
+ * report (metric tiles + ranked pair list). No data fetching, no clock, no RNG.
+ */
+import { useT } from '../../lib/i18n/I18nContext';
+import type { AssociationReport } from '../../lib/memory/entityAssociations';
+
+interface EntityAssociationsPanelProps {
+ report: AssociationReport | null;
+ loading?: boolean;
+ error?: string | null;
+ onRetry?: () => void;
+}
+
+const pct = (fraction: number): number => Math.round(fraction * 100);
+
+const EntityAssociationsPanel = ({
+ report,
+ loading,
+ error,
+ onRetry,
+}: EntityAssociationsPanelProps) => {
+ const { t } = useT();
+
+ const intro = (
+
+
{t('entityAssociations.title')}
+
{t('entityAssociations.intro')}
+
+ );
+
+ if (loading) {
+ return (
+
+ {intro}
+
+
+ {[0, 1].map(i => (
+
+ ))}
+
+ {[0, 1, 2, 3].map(i => (
+
+ ))}
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {intro}
+
+
+ {t('entityAssociations.errorPrefix')} {error}
+
+ {onRetry && (
+
+ )}
+
+
+ );
+ }
+
+ if (!report || report.pairs.length === 0) {
+ return (
+
+ {intro}
+
+
+ {t('entityAssociations.empty')}
+
+
+ {t('entityAssociations.emptyHint')}
+
+
+
+ );
+ }
+
+ return (
+
+ {intro}
+
+ {/* Metric tiles */}
+
+ {[
+ { label: t('entityAssociations.metricEntities'), value: report.entityCount },
+ { label: t('entityAssociations.metricPairs'), value: report.pairCount },
+ ].map(tile => (
+
+
+ {tile.label}
+
+
+ {tile.value}
+
+
+ ))}
+
+
+ {/* Ranked pair list */}
+
+
+ {t('entityAssociations.rankedHeading')}
+
+
+ {report.pairs.map(pair => (
+ -
+
+
+ {pair.a} ~ {pair.b}
+
+
+ {pair.directlyLinked
+ ? t('entityAssociations.linkedBadge')
+ : t('entityAssociations.inferredBadge')}
+
+
+
+
+
+ {pct(pair.jaccard)}%
+
+
+ {t('entityAssociations.sharedLabel').replace(
+ '{shared}',
+ String(pair.sharedCount)
+ )}
+
+
+
+ ))}
+
+ {report.truncated && (
+
+ {t('entityAssociations.truncated')
+ .replace('{shown}', String(report.pairs.length))
+ .replace('{total}', String(report.pairCount))}
+
+ )}
+
+
+ );
+};
+
+export default EntityAssociationsPanel;
diff --git a/app/src/components/intelligence/EntityAssociationsTab.test.tsx b/app/src/components/intelligence/EntityAssociationsTab.test.tsx
new file mode 100644
index 000000000..9f4b0c8fd
--- /dev/null
+++ b/app/src/components/intelligence/EntityAssociationsTab.test.tsx
@@ -0,0 +1,66 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { computeEntityAssociations } from '../../lib/memory/entityAssociations';
+import type { GraphRelation } from '../../utils/tauriCommands/memory';
+import EntityAssociationsTab from './EntityAssociationsTab';
+
+const mockLoadAssociations = vi.fn();
+const mockLoadNamespaces = vi.fn();
+
+vi.mock('../../services/api/entityAssociationsApi', () => ({
+ loadAssociations: (...args: unknown[]) => mockLoadAssociations(...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 report = computeEntityAssociations([
+ rel('X', 'A'),
+ rel('X', 'B'),
+ rel('Y', 'A'),
+ rel('Y', 'B'),
+]);
+
+describe('', () => {
+ beforeEach(() => {
+ mockLoadAssociations.mockReset();
+ mockLoadNamespaces.mockReset();
+ mockLoadAssociations.mockResolvedValue(report);
+ mockLoadNamespaces.mockResolvedValue([]);
+ });
+
+ it('loads associations (all namespaces) on mount and renders the result', async () => {
+ render();
+ expect(mockLoadAssociations).toHaveBeenCalledWith(undefined);
+ await waitFor(() => expect(screen.getByText('Strongest associations')).toBeInTheDocument());
+ });
+
+ it('shows the namespace selector and re-queries on change', async () => {
+ mockLoadNamespaces.mockResolvedValueOnce(['work', 'personal']);
+ render();
+ await waitFor(() => screen.getByRole('combobox'));
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'work' } });
+ await waitFor(() => expect(mockLoadAssociations).toHaveBeenCalledWith('work'));
+ });
+
+ it('surfaces an error when the load fails', async () => {
+ mockLoadAssociations.mockReset();
+ mockLoadAssociations.mockRejectedValueOnce(new Error('graph unavailable'));
+ render();
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
+ });
+});
diff --git a/app/src/components/intelligence/EntityAssociationsTab.tsx b/app/src/components/intelligence/EntityAssociationsTab.tsx
new file mode 100644
index 000000000..e11a28766
--- /dev/null
+++ b/app/src/components/intelligence/EntityAssociationsTab.tsx
@@ -0,0 +1,83 @@
+/**
+ * Entity Associations tab (container). Owns load-on-mount and the namespace
+ * selector; delegates all rendering to the pure .
+ * Read-only — the result is recomputed from the live graph, never persisted.
+ */
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useT } from '../../lib/i18n/I18nContext';
+import type { AssociationReport } from '../../lib/memory/entityAssociations';
+import { loadAssociations, loadNamespaces } from '../../services/api/entityAssociationsApi';
+import EntityAssociationsPanel from './EntityAssociationsPanel';
+
+const EntityAssociationsTab = () => {
+ const { t } = useT();
+ const [report, setReport] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [namespaces, setNamespaces] = useState([]);
+ const [namespace, setNamespace] = useState('');
+ // Monotonic token: ignore a response if a newer load has since started, so
+ // an out-of-order resolution can never overwrite the latest result.
+ const latestRequestId = useRef(0);
+
+ const load = useCallback(async (ns: string) => {
+ const requestId = (latestRequestId.current += 1);
+ setLoading(true);
+ setError(null);
+ try {
+ const next = await loadAssociations(ns || undefined);
+ if (requestId !== latestRequestId.current) return;
+ setReport(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 associations view, so swallow that error specifically.
+ loadNamespaces()
+ .then(setNamespaces)
+ .catch(() => setNamespaces([]));
+ void load('');
+ }, [load]);
+
+ const handleNamespace = (next: string): void => {
+ setNamespace(next);
+ void load(next);
+ };
+
+ return (
+
+ {namespaces.length > 0 && (
+
+ )}
+
+ void load(namespace)}
+ />
+
+ );
+};
+
+export default EntityAssociationsTab;
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 9e61c2451..544c1083c 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -320,6 +320,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'التوصيل',
'graphCentrality.bridgeTitle': 'موصّل — أكثر تأثيرًا مما يوحي به عدد روابطه',
'graphCentrality.degreeTitle': 'Xqx0xxx في ×1x',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 48b797b30..2265d644c 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -324,6 +324,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'স্বয়ংক্রিয়',
'graphCentrality.bridgeTitle': 'Connector — এর লিংক গণনায় আরও প্রভাবশালী',
'graphCentrality.degreeTitle': 'xqxqx × xx11x এর জন্য',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 31339b39f..634a0eeec 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -335,6 +335,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Connector – einflussreicher als die Anzahl der Links vermuten lässt',
'graphCentrality.degreeTitle': '{in} rein · {out} raus',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 9274abfa6..4e2868c84 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -349,6 +349,29 @@ const en: TranslationMap = {
'graphCentrality.bridgeBadge': 'connector',
'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests',
'graphCentrality.degreeTitle': '{in} in · {out} out',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 9f7a0bb6a..3c93c4140 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -334,6 +334,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'conector',
'graphCentrality.bridgeTitle': 'Conector: más influyente de lo que sugiere su número de enlaces',
'graphCentrality.degreeTitle': '{in} entra · {out} sale',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index 64ea9c5d0..9ad4e1f60 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -332,6 +332,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'connecteur',
'graphCentrality.bridgeTitle': 'Connecteur — plus influent que ne le suggère son nombre de liens',
'graphCentrality.degreeTitle': '{in} en · {out} hors',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 8710bd99c..22db6ecdd 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -323,6 +323,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'कनेक्टर',
'graphCentrality.bridgeTitle': 'कनेक्टर - इसके लिंक गिनती से अधिक प्रभावशाली सुझाव देते हैं',
'graphCentrality.degreeTitle': '{in}',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 1dc5f20f1..f5978cac0 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -326,6 +326,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Konektor - lebih berpengaruh daripada jumlah link yang menunjukkan',
'graphCentrality.degreeTitle': '{in} keluar',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index c52b6830c..58db8a065 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -332,6 +332,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Connettore — più influente di quanto suggerisca il suo numero di collegamenti',
'graphCentrality.degreeTitle': '{in} in entrata · {out} in uscita',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index d386a70f1..6bf6eebed 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -323,6 +323,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': '커넥터',
'graphCentrality.bridgeTitle': '커넥터 - 링크 수보다 더 큰 영향력을 가진 엔터티',
'graphCentrality.degreeTitle': '들어옴 {in} · 나감 {out}',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index d38f691f6..439ebb762 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -328,6 +328,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'łącznik',
'graphCentrality.bridgeTitle': 'Łącznik — bardziej wpływowy, niż sugeruje liczba linków',
'graphCentrality.degreeTitle': '{in} wej. · {out} wyj.',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 74ba48454..0985f3dbb 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -333,6 +333,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': 'conector',
'graphCentrality.bridgeTitle': 'Conector — mais influente do que seu número de links sugere',
'graphCentrality.degreeTitle': '{in} entrando · {out} saindo',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index 7d904c99e..7905b9e21 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -326,6 +326,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeTitle':
'Коннектор — более влиятельный, чем предполагает количество ссылок.',
'graphCentrality.degreeTitle': '{in} вход · {out} выход',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index b2c26c8c4..24f375a6c 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -312,6 +312,29 @@ const messages: TranslationMap = {
'graphCentrality.bridgeBadge': '连接点',
'graphCentrality.bridgeTitle': '连接点,影响力高于其链接数量所暗示的程度',
'graphCentrality.degreeTitle': '{in} 入 · {out} 出',
+ 'memory.tab.associations': 'Associations',
+ 'entityAssociations.title': 'Entity Associations',
+ 'entityAssociations.intro':
+ 'Entities that share many of the same connections are associated — even when no single fact links them directly. Jaccard similarity surfaces these hidden pairings.',
+ 'entityAssociations.loading': 'Scoring associations…',
+ 'entityAssociations.errorPrefix': 'Could not load the graph:',
+ 'entityAssociations.retry': 'Retry',
+ 'entityAssociations.empty': 'No associations yet.',
+ 'entityAssociations.emptyHint':
+ 'When entities start sharing connections, the strongest pairings will surface here.',
+ 'entityAssociations.namespaceLabel': 'Namespace',
+ 'entityAssociations.namespaceAll': 'All namespaces',
+ 'entityAssociations.metricEntities': 'Entities',
+ 'entityAssociations.metricPairs': 'Associations',
+ 'entityAssociations.rankedHeading': 'Strongest associations',
+ 'entityAssociations.linkedBadge': 'linked',
+ 'entityAssociations.inferredBadge': 'inferred',
+ 'entityAssociations.linkedTitle': 'These entities are directly connected.',
+ 'entityAssociations.inferredTitle':
+ 'No direct link — associated only through shared connections.',
+ 'entityAssociations.sharedLabel': '{shared} shared',
+ 'entityAssociations.pairTitle': '{jaccard}% similar · {shared} of {union} connections shared',
+ 'entityAssociations.truncated': 'Showing {shown} of {total} — strongest first.',
'memory.tab.freshness': 'Freshness',
'memoryFreshness.title': 'Knowledge Freshness',
'memoryFreshness.intro':
diff --git a/app/src/lib/memory/entityAssociations.test.ts b/app/src/lib/memory/entityAssociations.test.ts
new file mode 100644
index 000000000..a1e0985a5
--- /dev/null
+++ b/app/src/lib/memory/entityAssociations.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from 'vitest';
+
+import type { GraphRelation } from '../../utils/tauriCommands/memory';
+import {
+ type AssociationReport,
+ computeEntityAssociations,
+ type EntityPair,
+} from './entityAssociations';
+
+function rel(subject: string, object: string): GraphRelation {
+ return {
+ namespace: 'n',
+ subject,
+ predicate: 'p',
+ object,
+ attrs: {},
+ updatedAt: 0,
+ evidenceCount: 1,
+ orderIndex: null,
+ documentIds: [],
+ chunkIds: [],
+ };
+}
+
+function pairOf(report: AssociationReport, a: string, b: string): EntityPair | undefined {
+ return report.pairs.find(p => p.a === a && p.b === b);
+}
+
+describe('computeEntityAssociations', () => {
+ it('returns an empty report for fewer than two entities', () => {
+ expect(computeEntityAssociations([])).toEqual({
+ pairs: [],
+ entityCount: 0,
+ pairCount: 0,
+ truncated: false,
+ });
+ // A single edge has two entities but no shared-neighbour pair.
+ const single = computeEntityAssociations([rel('A', 'B')]);
+ expect(single.entityCount).toBe(2);
+ expect(single.pairs).toEqual([]);
+ expect(single.pairCount).toBe(0);
+ });
+
+ it('scores perfect twins (identical neighbours) at jaccard 1, not directly linked', () => {
+ // X,Y both connect to A,B (and vice versa); X-Y and A-B are NOT linked.
+ const r = computeEntityAssociations([
+ rel('X', 'A'),
+ rel('X', 'B'),
+ rel('Y', 'A'),
+ rel('Y', 'B'),
+ ]);
+ expect(r.entityCount).toBe(4);
+ expect(r.pairCount).toBe(2);
+ const ab = pairOf(r, 'A', 'B')!;
+ const xy = pairOf(r, 'X', 'Y')!;
+ expect(ab.jaccard).toBe(1);
+ expect(ab.sharedCount).toBe(2);
+ expect(ab.directlyLinked).toBe(false);
+ expect(xy.jaccard).toBe(1);
+ expect(xy.directlyLinked).toBe(false);
+ });
+
+ it('computes jaccard 1/3 for a triangle and marks pairs directly linked', () => {
+ const r = computeEntityAssociations([rel('A', 'B'), rel('B', 'C'), rel('A', 'C')]);
+ expect(r.pairCount).toBe(3);
+ for (const p of r.pairs) {
+ expect(p.jaccard).toBeCloseTo(1 / 3, 12);
+ expect(p.sharedCount).toBe(1);
+ expect(p.unionCount).toBe(3);
+ expect(p.directlyLinked).toBe(true);
+ }
+ });
+
+ it('ignores self-loops when building neighbourhoods', () => {
+ // A's self-loop must not make A its own neighbour; A and C both connect to B.
+ const r = computeEntityAssociations([rel('A', 'A'), rel('A', 'B'), rel('C', 'B')]);
+ expect(r.entityCount).toBe(3); // A, B, C
+ const ac = pairOf(r, 'A', 'C')!;
+ expect(ac.jaccard).toBe(1); // both neighbour only B
+ expect(ac.sharedCount).toBe(1);
+ expect(ac.directlyLinked).toBe(false);
+ });
+
+ it('treats direction as symmetric (in/out edges build the same neighbourhood)', () => {
+ // A->B and C->B: A and C share neighbour B regardless of arrow direction.
+ const r = computeEntityAssociations([rel('A', 'B'), rel('C', 'B')]);
+ expect(pairOf(r, 'A', 'C')!.sharedCount).toBe(1);
+ });
+
+ it('respects minShared', () => {
+ // Triangle pairs all share exactly 1 neighbour -> excluded at minShared 2.
+ const r = computeEntityAssociations([rel('A', 'B'), rel('B', 'C'), rel('A', 'C')], {
+ minShared: 2,
+ });
+ expect(r.pairs).toEqual([]);
+ expect(r.pairCount).toBe(0);
+ });
+
+ it('caps output at the limit and flags truncation', () => {
+ // A central hub H linked to many leaves: every leaf-pair shares H.
+ const leaves = ['a', 'b', 'c', 'd', 'e'];
+ const r = computeEntityAssociations(
+ leaves.map(l => rel(l, 'H')),
+ { limit: 3 }
+ );
+ expect(r.pairs).toHaveLength(3);
+ expect(r.pairCount).toBe(10); // C(5,2) leaf pairs all share H
+ expect(r.truncated).toBe(true);
+ });
+
+ it('is invariant to relation order (deterministic ranking)', () => {
+ const triples = [rel('X', 'A'), rel('X', 'B'), rel('Y', 'A'), rel('Y', 'B'), rel('A', 'B')];
+ const forward = computeEntityAssociations(triples);
+ const reversed = computeEntityAssociations([...triples].reverse());
+ expect(reversed.pairs).toEqual(forward.pairs);
+ });
+
+ it('drops malformed relations with a non-string endpoint', () => {
+ const malformed = { ...rel('A', 'B'), object: null as unknown as string };
+ const r = computeEntityAssociations([rel('A', 'B'), malformed, rel('C', 'B')]);
+ expect(r.entityCount).toBe(3); // A, B, C — the null-object row is ignored
+ });
+
+ it('keeps entities containing spaces distinct (no key collision)', () => {
+ const r = computeEntityAssociations([rel('New York', 'USA'), rel('Los Angeles', 'USA')]);
+ // Both cities neighbour only "USA" -> jaccard 1; canonical key keeps them apart.
+ const pair = pairOf(r, 'Los Angeles', 'New York')!;
+ expect(pair.jaccard).toBe(1);
+ expect(pair.sharedCount).toBe(1);
+ });
+});
diff --git a/app/src/lib/memory/entityAssociations.ts b/app/src/lib/memory/entityAssociations.ts
new file mode 100644
index 000000000..7848b940b
--- /dev/null
+++ b/app/src/lib/memory/entityAssociations.ts
@@ -0,0 +1,161 @@
+/**
+ * Entity Associations — pure similarity-scoring engine.
+ *
+ * Two entities in the knowledge graph are "associated" when they share many of
+ * the same connections — e.g. two people who know all the same people, or two
+ * projects that touch the same systems. This is captured by the Jaccard
+ * similarity of their (undirected) neighbour sets:
+ *
+ * jaccard(a, b) = |N(a) ∩ N(b)| / |N(a) ∪ N(b)|
+ *
+ * The valuable case is a HIGH-similarity pair that is NOT directly linked: the
+ * graph already "knows" they belong together (shared context) even though no
+ * single triple connects them — a relationship a direct-edge view never shows.
+ *
+ * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no
+ * randomness. Output depends only on the (subject, object) structure, never on
+ * insertion order, so the same graph always yields identical rankings.
+ *
+ * Direction is ignored for neighbourhoods (an association is symmetric) and
+ * self-loops are dropped (a node is not its own neighbour). Entity identity is
+ * the raw string AS-IS (no trimming / case-folding), consistent with the other
+ * graph lenses. Pair keys are canonical JSON (smaller id first), so entities
+ * containing any character can never collide.
+ */
+import type { GraphRelation } from '../../utils/tauriCommands/memory';
+
+export interface EntityPair {
+ a: string; // lexicographically smaller id
+ b: string; // lexicographically larger id
+ jaccard: number; // 0..1 similarity of neighbour sets
+ sharedCount: number; // |N(a) ∩ N(b)|
+ unionCount: number; // |N(a) ∪ N(b)|
+ directlyLinked: boolean; // a and b are directly connected by a triple
+}
+
+export interface AssociationReport {
+ pairs: EntityPair[]; // top `limit` pairs, jaccard desc (then sharedCount, ids)
+ entityCount: number;
+ pairCount: number; // total candidate pairs (sharing >= minShared neighbours)
+ truncated: boolean; // pairCount > pairs.length
+}
+
+export interface AssociationOptions {
+ limit?: number; // max pairs to return (default 50)
+ minShared?: number; // min shared neighbours for a pair to qualify (default 1)
+}
+
+export const DEFAULT_LIMIT = 50;
+export const DEFAULT_MIN_SHARED = 1;
+
+const EMPTY_REPORT: AssociationReport = {
+ pairs: [],
+ entityCount: 0,
+ pairCount: 0,
+ truncated: false,
+};
+
+function compareIds(a: string, b: string): number {
+ return a < b ? -1 : a > b ? 1 : 0;
+}
+
+/** Canonical, collision-free key for an unordered pair (smaller id first). */
+function pairKey(x: string, y: string): string {
+ return x < y ? JSON.stringify([x, y]) : JSON.stringify([y, x]);
+}
+
+interface PairTally {
+ a: string; // smaller id
+ b: string; // larger id
+ shared: number;
+}
+
+/**
+ * Compute entity associations. Pure function of `relations`.
+ *
+ * Candidate pairs are generated from an inverted index (for each neighbour, the
+ * entities adjacent to it) so only pairs that actually share a neighbour are
+ * scored — far cheaper than all-pairs on a sparse graph.
+ */
+export function computeEntityAssociations(
+ relations: GraphRelation[],
+ options: AssociationOptions = {}
+): AssociationReport {
+ const limit = options.limit ?? DEFAULT_LIMIT;
+ const minShared = options.minShared ?? DEFAULT_MIN_SHARED;
+
+ // 1. Build undirected neighbour sets (drop self-loops + malformed rows) and
+ // record which entity pairs are directly linked.
+ const neighbours = new Map>();
+ const linked = new Set();
+ const ensure = (id: string): Set => {
+ let set = neighbours.get(id);
+ if (!set) {
+ set = new Set();
+ neighbours.set(id, set);
+ }
+ return set;
+ };
+ for (const relation of relations) {
+ const { subject, object } = relation;
+ if (typeof subject !== 'string' || typeof object !== 'string') continue;
+ if (subject === object) {
+ ensure(subject); // self-loop: node exists but is not its own neighbour
+ continue;
+ }
+ ensure(subject).add(object);
+ ensure(object).add(subject);
+ linked.add(pairKey(subject, object));
+ }
+
+ const entityCount = neighbours.size;
+ if (entityCount < 2) return { ...EMPTY_REPORT, entityCount };
+
+ // 2. Candidate pairs via inverted index: any two entities adjacent to the
+ // same neighbour share it. De-dupe and count shared neighbours.
+ const tallies = new Map();
+ for (const adjacent of neighbours.values()) {
+ if (adjacent.size < 2) continue;
+ const members = [...adjacent].sort(compareIds);
+ for (let i = 0; i < members.length; i += 1) {
+ for (let j = i + 1; j < members.length; j += 1) {
+ const a = members[i];
+ const b = members[j];
+ const key = pairKey(a, b);
+ const tally = tallies.get(key);
+ if (tally) tally.shared += 1;
+ else tallies.set(key, { a, b, shared: 1 });
+ }
+ }
+ }
+
+ // 3. Score each candidate pair (Jaccard) and keep those meeting minShared.
+ const pairs: EntityPair[] = [];
+ for (const { a, b, shared } of tallies.values()) {
+ if (shared < minShared) continue;
+ const sizeA = neighbours.get(a)?.size ?? 0;
+ const sizeB = neighbours.get(b)?.size ?? 0;
+ const unionCount = sizeA + sizeB - shared;
+ const jaccard = unionCount === 0 ? 0 : shared / unionCount;
+ pairs.push({
+ a,
+ b,
+ jaccard,
+ sharedCount: shared,
+ unionCount,
+ directlyLinked: linked.has(pairKey(a, b)),
+ });
+ }
+
+ // 4. Rank: jaccard desc, then sharedCount desc, then ids asc (deterministic).
+ pairs.sort(
+ (p, q) =>
+ q.jaccard - p.jaccard ||
+ q.sharedCount - p.sharedCount ||
+ compareIds(p.a, q.a) ||
+ compareIds(p.b, q.b)
+ );
+
+ const pairCount = pairs.length;
+ return { pairs: pairs.slice(0, limit), entityCount, pairCount, truncated: pairCount > limit };
+}
diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx
index 371b105d8..6c1f1c66b 100644
--- a/app/src/pages/Intelligence.tsx
+++ b/app/src/pages/Intelligence.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
+import EntityAssociationsTab from '../components/intelligence/EntityAssociationsTab';
import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
@@ -29,6 +30,7 @@ type IntelligenceTab =
| 'workflows'
| 'diagram'
| 'centrality'
+ | 'associations'
| 'freshness'
| 'timeline';
@@ -110,6 +112,7 @@ export default function Intelligence() {
},
{ id: 'diagram', label: t('memory.tab.diagram') },
{ id: 'centrality', label: t('memory.tab.centrality') },
+ { id: 'associations', label: t('memory.tab.associations') },
{ id: 'freshness', label: t('memory.tab.freshness') },
{ id: 'timeline', label: t('memory.tab.timeline') },
];
@@ -203,6 +206,8 @@ export default function Intelligence() {
{activeTab === 'centrality' && }
+ {activeTab === 'associations' && }
+
{activeTab === 'freshness' && }
{activeTab === 'timeline' && }
diff --git a/app/src/services/api/entityAssociationsApi.test.ts b/app/src/services/api/entityAssociationsApi.test.ts
new file mode 100644
index 000000000..579331417
--- /dev/null
+++ b/app/src/services/api/entityAssociationsApi.test.ts
@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { computeEntityAssociations } from '../../lib/memory/entityAssociations';
+import type { GraphRelation } from '../../utils/tauriCommands/memory';
+import { entityAssociationsApi, loadAssociations, loadNamespaces } from './entityAssociationsApi';
+
+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): GraphRelation {
+ return {
+ namespace: 'work',
+ subject,
+ predicate: 'p',
+ object,
+ attrs: {},
+ updatedAt: 0,
+ evidenceCount: 1,
+ orderIndex: null,
+ documentIds: [],
+ chunkIds: [],
+ };
+}
+
+describe('entityAssociationsApi.loadAssociations', () => {
+ beforeEach(() => {
+ mockGraphQuery.mockReset();
+ });
+
+ it('passes the namespace through and returns the engine report for those facts', async () => {
+ const facts = [rel('X', 'A'), rel('X', 'B'), rel('Y', 'A'), rel('Y', 'B')];
+ mockGraphQuery.mockResolvedValueOnce(facts);
+ const out = await loadAssociations('work');
+ expect(mockGraphQuery).toHaveBeenCalledWith('work');
+ expect(out).toEqual(computeEntityAssociations(facts));
+ });
+
+ it('queries all namespaces when none is given', async () => {
+ mockGraphQuery.mockResolvedValueOnce([]);
+ const out = await loadAssociations();
+ expect(mockGraphQuery).toHaveBeenCalledWith(undefined);
+ expect(out.pairs).toEqual([]);
+ });
+
+ it('propagates query errors', async () => {
+ mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable'));
+ await expect(loadAssociations()).rejects.toThrow('graph unavailable');
+ });
+});
+
+describe('entityAssociationsApi.loadNamespaces', () => {
+ beforeEach(() => {
+ mockListNamespaces.mockReset();
+ });
+
+ it('returns the namespace list from the RPC', async () => {
+ mockListNamespaces.mockResolvedValueOnce(['work', 'personal']);
+ expect(await loadNamespaces()).toEqual(['work', 'personal']);
+ });
+});
+
+describe('entityAssociationsApi object', () => {
+ it('exposes the public surface', () => {
+ expect(typeof entityAssociationsApi.loadAssociations).toBe('function');
+ expect(typeof entityAssociationsApi.loadNamespaces).toBe('function');
+ });
+});
diff --git a/app/src/services/api/entityAssociationsApi.ts b/app/src/services/api/entityAssociationsApi.ts
new file mode 100644
index 000000000..5d45b406b
--- /dev/null
+++ b/app/src/services/api/entityAssociationsApi.ts
@@ -0,0 +1,32 @@
+/**
+ * RPC facade for Entity Associations.
+ *
+ * Adds ZERO new core surface. Composes two already-shipped JSON-RPC wrappers:
+ * - memoryGraphQuery (openhuman.memory_graph_query) — the triples
+ * - memoryListNamespaces (openhuman.memory_list_namespaces) — the selector
+ * and delegates all scoring to the pure, deterministic engine. Read-only —
+ * the result is always reproducible from the current graph.
+ */
+import debug from 'debug';
+
+import {
+ type AssociationReport,
+ computeEntityAssociations,
+} from '../../lib/memory/entityAssociations';
+import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory';
+
+const log = debug('entity-associations:api');
+
+/** Fetch the facts for a namespace (or all) and score entity associations. */
+export async function loadAssociations(namespace?: string): Promise {
+ const relations = await memoryGraphQuery(namespace);
+ log('loadAssociations namespace=%s relations=%d', namespace ?? '(all)', relations.length);
+ return computeEntityAssociations(relations);
+}
+
+/** List the namespaces available for the namespace selector. */
+export async function loadNamespaces(): Promise {
+ return memoryListNamespaces();
+}
+
+export const entityAssociationsApi = { loadAssociations, loadNamespaces };