diff --git a/app/src/components/intelligence/EntityAssociationsPanel.test.tsx b/app/src/components/intelligence/EntityAssociationsPanel.test.tsx new file mode 100644 index 000000000..1d689ca5a --- /dev/null +++ b/app/src/components/intelligence/EntityAssociationsPanel.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeEntityAssociations } from '../../lib/memory/entityAssociations'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import EntityAssociationsPanel from './EntityAssociationsPanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// X,Y share neighbours A,B (inferred pair); the triangle A-B-? gives linked pairs. +const report = computeEntityAssociations([ + rel('X', 'A'), + rel('X', 'B'), + rel('Y', 'A'), + rel('Y', 'B'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('entity-associations-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no pairs', () => { + render(); + expect(screen.getByText('No associations yet.')).toBeInTheDocument(); + }); + + it('renders an error with a working retry button', () => { + const onRetry = vi.fn(); + render(); + expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders metric tiles and ranked pairs with an inferred badge', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Associations')).toBeInTheDocument(); + expect(screen.getByText('Strongest associations')).toBeInTheDocument(); + // X~Y and A~B are perfect, non-linked associations -> "inferred" badges. + expect(screen.getAllByText('inferred').length).toBeGreaterThanOrEqual(1); + }); + + it('notes truncation when more pairs exist than are shown', () => { + const leaves = ['a', 'b', 'c', 'd', 'e']; + const big = computeEntityAssociations( + leaves.map(l => rel(l, 'H')), + { limit: 3 } + ); + render(); + expect(screen.getByText('Showing 3 of 10 — strongest first.')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/EntityAssociationsPanel.tsx b/app/src/components/intelligence/EntityAssociationsPanel.tsx new file mode 100644 index 000000000..52eaa75ec --- /dev/null +++ b/app/src/components/intelligence/EntityAssociationsPanel.tsx @@ -0,0 +1,190 @@ +/** + * Entity Associations — presentational view. Pure: renders the association + * report (metric tiles + ranked pair list). No data fetching, no clock, no RNG. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { AssociationReport } from '../../lib/memory/entityAssociations'; + +interface EntityAssociationsPanelProps { + report: AssociationReport | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const pct = (fraction: number): number => Math.round(fraction * 100); + +const EntityAssociationsPanel = ({ + report, + loading, + error, + onRetry, +}: EntityAssociationsPanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('entityAssociations.title')}

+

{t('entityAssociations.intro')}

+
+ ); + + if (loading) { + return ( +
+ {intro} +
+
+ {[0, 1].map(i => ( +
+ ))} +
+ {[0, 1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ {intro} +
+

+ {t('entityAssociations.errorPrefix')} {error} +

+ {onRetry && ( + + )} +
+
+ ); + } + + if (!report || report.pairs.length === 0) { + return ( +
+ {intro} +
+

+ {t('entityAssociations.empty')} +

+

+ {t('entityAssociations.emptyHint')} +

+
+
+ ); + } + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('entityAssociations.metricEntities'), value: report.entityCount }, + { label: t('entityAssociations.metricPairs'), value: report.pairCount }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+ + {/* Ranked pair list */} +
+

+ {t('entityAssociations.rankedHeading')} +

+
    + {report.pairs.map(pair => ( +
  • +
    +

    + {pair.a} ~ {pair.b} +

    + + {pair.directlyLinked + ? t('entityAssociations.linkedBadge') + : t('entityAssociations.inferredBadge')} + +
    +
    +
    +
    +
    + + {pct(pair.jaccard)}% + + + {t('entityAssociations.sharedLabel').replace( + '{shared}', + String(pair.sharedCount) + )} + +
    +
  • + ))} +
+ {report.truncated && ( +

+ {t('entityAssociations.truncated') + .replace('{shown}', String(report.pairs.length)) + .replace('{total}', String(report.pairCount))} +

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