feat(intelligence): add Entity Associations (#2942)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Aashir Athar
2026-05-30 09:14:20 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 24f2b502be
commit 52a72fb311
23 changed files with 1130 additions and 0 deletions
@@ -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('<EntityAssociationsPanel />', () => {
it('renders the loading skeleton', () => {
render(<EntityAssociationsPanel report={null} loading />);
expect(screen.getByTestId('entity-associations-loading')).toBeInTheDocument();
});
it('renders the empty state when there are no pairs', () => {
render(<EntityAssociationsPanel report={computeEntityAssociations([rel('A', 'B')])} />);
expect(screen.getByText('No associations yet.')).toBeInTheDocument();
});
it('renders an error with a working retry button', () => {
const onRetry = vi.fn();
render(<EntityAssociationsPanel report={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 ranked pairs with an inferred badge', () => {
render(<EntityAssociationsPanel report={report} />);
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(<EntityAssociationsPanel report={big} />);
expect(screen.getByText('Showing 3 of 10 — strongest first.')).toBeInTheDocument();
});
});
@@ -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 = (
<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('entityAssociations.title')}</p>
<p>{t('entityAssociations.intro')}</p>
</div>
);
if (loading) {
return (
<div className="space-y-4">
{intro}
<div
className="space-y-3"
role="status"
aria-label={t('entityAssociations.loading')}
data-testid="entity-associations-loading">
<div className="grid gap-2 sm:grid-cols-2">
{[0, 1].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-10"
/>
))}
</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('entityAssociations.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('entityAssociations.retry')}
</button>
)}
</div>
</div>
);
}
if (!report || report.pairs.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('entityAssociations.empty')}
</h3>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('entityAssociations.emptyHint')}
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{intro}
{/* Metric tiles */}
<div className="grid gap-2 sm:grid-cols-2">
{[
{ label: t('entityAssociations.metricEntities'), value: report.entityCount },
{ label: t('entityAssociations.metricPairs'), value: report.pairCount },
].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>
{/* Ranked pair list */}
<section aria-labelledby="entity-associations-heading" className="space-y-1">
<h3
id="entity-associations-heading"
className="text-xs font-semibold uppercase tracking-wider text-stone-500 dark:text-neutral-400">
{t('entityAssociations.rankedHeading')}
</h3>
<ul className="space-y-1.5">
{report.pairs.map(pair => (
<li
key={JSON.stringify([pair.a, pair.b])}
className="rounded-lg border border-stone-200 dark:border-neutral-800 px-3 py-2"
title={t('entityAssociations.pairTitle')
.replace('{jaccard}', String(pct(pair.jaccard)))
.replace('{shared}', String(pair.sharedCount))
.replace('{union}', String(pair.unionCount))}>
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 text-sm text-stone-800 dark:text-neutral-100 break-words">
{pair.a} <span className="text-stone-400 dark:text-neutral-500">~</span> {pair.b}
</p>
<span
title={
pair.directlyLinked
? t('entityAssociations.linkedTitle')
: t('entityAssociations.inferredTitle')
}
className={`shrink-0 inline-flex items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
pair.directlyLinked
? 'bg-stone-100 dark:bg-neutral-800 text-stone-500 dark:text-neutral-400'
: 'bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300'
}`}>
{pair.directlyLinked
? t('entityAssociations.linkedBadge')
: t('entityAssociations.inferredBadge')}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums">
<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: `${pct(pair.jaccard)}%` }}
/>
</div>
<span className="w-10 shrink-0 text-right text-stone-500 dark:text-neutral-400">
{pct(pair.jaccard)}%
</span>
<span className="w-20 shrink-0 text-right text-stone-400 dark:text-neutral-500">
{t('entityAssociations.sharedLabel').replace(
'{shared}',
String(pair.sharedCount)
)}
</span>
</div>
</li>
))}
</ul>
{report.truncated && (
<p className="text-center text-xs text-stone-400 dark:text-neutral-500">
{t('entityAssociations.truncated')
.replace('{shown}', String(report.pairs.length))
.replace('{total}', String(report.pairCount))}
</p>
)}
</section>
</div>
);
};
export default EntityAssociationsPanel;
@@ -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('<EntityAssociationsTab />', () => {
beforeEach(() => {
mockLoadAssociations.mockReset();
mockLoadNamespaces.mockReset();
mockLoadAssociations.mockResolvedValue(report);
mockLoadNamespaces.mockResolvedValue([]);
});
it('loads associations (all namespaces) on mount and renders the result', async () => {
render(<EntityAssociationsTab />);
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(<EntityAssociationsTab />);
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(<EntityAssociationsTab />);
await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/));
});
});
@@ -0,0 +1,83 @@
/**
* Entity Associations tab (container). Owns load-on-mount and the namespace
* selector; delegates all rendering to the pure <EntityAssociationsPanel>.
* 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<AssociationReport | 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 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 (
<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('entityAssociations.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('entityAssociations.namespaceAll')}</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
</label>
)}
<EntityAssociationsPanel
report={report}
loading={loading}
error={error}
onRetry={() => void load(namespace)}
/>
</div>
);
};
export default EntityAssociationsTab;
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
+23
View File
@@ -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':
@@ -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);
});
});
+161
View File
@@ -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<string, Set<string>>();
const linked = new Set<string>();
const ensure = (id: string): Set<string> => {
let set = neighbours.get(id);
if (!set) {
set = new Set<string>();
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<string, PairTally>();
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 };
}
+5
View File
@@ -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' && <GraphCentralityTab />}
{activeTab === 'associations' && <EntityAssociationsTab />}
{activeTab === 'freshness' && <MemoryFreshnessTab />}
{activeTab === 'timeline' && <MemoryTimelineTab />}
@@ -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');
});
});
@@ -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<AssociationReport> {
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<string[]> {
return memoryListNamespaces();
}
export const entityAssociationsApi = { loadAssociations, loadNamespaces };