mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(intelligence): add Entity Associations (#2942)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
24f2b502be
commit
52a72fb311
@@ -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;
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user