diff --git a/app/src/components/intelligence/GraphCohesionPanel.test.tsx b/app/src/components/intelligence/GraphCohesionPanel.test.tsx new file mode 100644 index 000000000..44f609157 --- /dev/null +++ b/app/src/components/intelligence/GraphCohesionPanel.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeGraphCohesion } from '../../lib/memory/graphCohesion'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphCohesionPanel from './GraphCohesionPanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// Diamond: A-B, A-C, B-C, B-D, C-D. avg clustering 5/6 (0.83), transitivity 0.75. +const diamond = computeGraphCohesion([ + rel('A', 'B'), + rel('A', 'C'), + rel('B', 'C'), + rel('B', 'D'), + rel('C', 'D'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('graph-cohesion-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no nodes', () => { + render(); + expect(screen.getByText('No knowledge graph 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, the network averages, and the brokerage ranking', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Connections')).toBeInTheDocument(); + expect(screen.getByText('Triangles')).toBeInTheDocument(); + expect(screen.getByText('Brokers — loosest neighbourhoods')).toBeInTheDocument(); + // average clustering 5/6 -> 0.83, transitivity 0.75. + expect(screen.getByText(/transitivity 0\.75/)).toBeInTheDocument(); + // the spine nodes B and C cluster at 0.67 (rendered to 2 decimals). + expect(screen.getAllByText('0.67')).toHaveLength(2); + }); + + it('badges a structural hole (clustering 0) as a broker', () => { + // Star: X links A, B, C which never connect -> X is a pure broker. + const star = computeGraphCohesion([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + render(); + expect(screen.getByText('broker')).toBeInTheDocument(); + expect(screen.getByText('X')).toBeInTheDocument(); + }); + + it('shows the no-brokers note when every entity has fewer than two links', () => { + const single = computeGraphCohesion([rel('A', 'B')]); + render(); + expect(screen.getByText('No entities with two or more connections yet.')).toBeInTheDocument(); + // tiles still render (the graph is non-empty), but no ranking table. + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.queryByText('Brokers — loosest neighbourhoods')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/GraphCohesionPanel.tsx b/app/src/components/intelligence/GraphCohesionPanel.tsx new file mode 100644 index 000000000..f0dbaef37 --- /dev/null +++ b/app/src/components/intelligence/GraphCohesionPanel.tsx @@ -0,0 +1,198 @@ +/** + * Graph Cohesion — presentational view. Pure: renders the cohesion summary + * tiles (entities / connections / triangles), the network averages, and a + * brokerage ranking of the loosest-neighbourhood entities. No data fetching, + * no clock, no randomness. + */ +import { useMemo } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { type CohesionResult, findBrokers } from '../../lib/memory/graphCohesion'; + +const MAX_ROWS = 25; + +interface GraphCohesionPanelProps { + result: CohesionResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const GraphCohesionPanel = ({ result, loading, error, onRetry }: GraphCohesionPanelProps) => { + const { t } = useT(); + + const brokers = useMemo(() => (result ? findBrokers(result, MAX_ROWS) : []), [result]); + + const intro = ( +
+

{t('graphCohesion.title')}

+

{t('graphCohesion.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('graphCohesion.metricEntities'), value: result.nodeCount }, + { label: t('graphCohesion.metricConnections'), value: result.edgeCount }, + { label: t('graphCohesion.metricTriangles'), value: result.triangleCount }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('graphCohesion.summaryCaption') + .replace('{avg}', result.averageClustering.toFixed(2)) + .replace('{transitivity}', result.transitivity.toFixed(2))} +

+ + {/* Brokerage ranking: loosest neighbourhoods first (structural holes). */} + {brokers.length === 0 ? ( +

+ {t('graphCohesion.noBrokers')} +

+ ) : ( +
+

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

+ + + + + + + + + + + {brokers.map((node, i) => ( + + + + + + + ))} + +
+ {t('graphCohesion.colRank')} + + {t('graphCohesion.colEntity')} + + {t('graphCohesion.colCohesion')} + + {t('graphCohesion.colLinks')} +
{i + 1} + {node.id} + {node.localClustering === 0 && ( + + {t('graphCohesion.brokerBadge')} + + )} + +
+
+
+
+ + {node.localClustering.toFixed(2)} + +
+
+ {node.degree} +
+
+ )} +
+ ); +}; + +export default GraphCohesionPanel; diff --git a/app/src/components/intelligence/GraphCohesionTab.test.tsx b/app/src/components/intelligence/GraphCohesionTab.test.tsx new file mode 100644 index 000000000..d7caa1589 --- /dev/null +++ b/app/src/components/intelligence/GraphCohesionTab.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphCohesion } from '../../lib/memory/graphCohesion'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphCohesionTab from './GraphCohesionTab'; + +const mockLoadCohesion = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/graphCohesionApi', () => ({ + loadCohesion: (...args: unknown[]) => mockLoadCohesion(...args), + loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args), +})); + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const result = computeGraphCohesion([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + +describe('', () => { + beforeEach(() => { + mockLoadCohesion.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadCohesion.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads cohesion (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadCohesion).toHaveBeenCalledWith(undefined); + await waitFor(() => + expect(screen.getByText('Brokers — loosest neighbourhoods')).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(mockLoadCohesion).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadCohesion.mockReset(); + mockLoadCohesion.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/GraphCohesionTab.tsx b/app/src/components/intelligence/GraphCohesionTab.tsx new file mode 100644 index 000000000..f834c1525 --- /dev/null +++ b/app/src/components/intelligence/GraphCohesionTab.tsx @@ -0,0 +1,83 @@ +/** + * Graph Cohesion 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 { CohesionResult } from '../../lib/memory/graphCohesion'; +import { loadCohesion, loadNamespaces } from '../../services/api/graphCohesionApi'; +import GraphCohesionPanel from './GraphCohesionPanel'; + +const GraphCohesionTab = () => { + const { t } = useT(); + const [result, setResult] = 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 loadCohesion(ns || undefined); + if (requestId !== latestRequestId.current) return; + setResult(next); + } catch (err) { + if (requestId !== latestRequestId.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (requestId === latestRequestId.current) setLoading(false); + } + }, []); + + useEffect(() => { + // Namespaces are optional UI sugar; a failure to list them must not block + // the cohesion 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 GraphCohesionTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index f44629bed..28d9df88b 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4309,6 +4309,30 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'مهلة الإدخال (مللي ثانية)', 'autocomplete.maxChars': 'أقصى عدد لأحرف السياق', 'autocomplete.overlayTtlMs': 'مهلة الطبقة (مللي ثانية)', + 'graphCohesion.brokerBadge': 'وسيط', + 'graphCohesion.brokerTitle': + 'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم — وهو الرابط الوحيد بينهم.', + 'graphCohesion.colCohesion': 'التماسك', + 'graphCohesion.colEntity': 'الكيان', + 'graphCohesion.colLinks': 'روابط', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'لا يوجد رسم معرفة بعد.', + 'graphCohesion.emptyHint': 'كلما سجّل المساعد حقائق مترابطة عنك، ستظهر هنا بنية تجميعها.', + 'graphCohesion.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'graphCohesion.intro': + 'مدى تماسك الجوار حول كل كيان. الوسطاء — كيانات لا يرتبط جيرانها ببعضهم — هم النقاط الوحيدة التي تربط بين عناقيد كانت ستظل منفصلة، وهو ما لا يمكن لفرز التكرار أو PageRank الكشف عنه.', + 'graphCohesion.loading': 'يجري حساب التماسك…', + 'graphCohesion.metricConnections': 'الاتصالات', + 'graphCohesion.metricEntities': 'الكيانات', + 'graphCohesion.metricTriangles': 'مثلثات', + 'graphCohesion.namespaceAll': 'كل مساحات الأسماء', + 'graphCohesion.namespaceLabel': 'مساحة الأسماء', + 'graphCohesion.noBrokers': 'لا توجد بعد كيانات بصلتين أو أكثر.', + 'graphCohesion.rankedHeading': 'الوسطاء — أكثر الجوارات تخلخلًا', + 'graphCohesion.retry': 'إعادة المحاولة', + 'graphCohesion.summaryCaption': 'متوسط التجميع {avg} · التعدّي {transitivity}', + 'graphCohesion.title': 'تماسك الرسم البياني', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 2e85b97c5..804e7646e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4383,6 +4383,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'ডিবাউন্স (মিলিসেকেন্ড)', 'autocomplete.maxChars': 'সর্বোচ্চ প্রসঙ্গ অক্ষর', 'autocomplete.overlayTtlMs': 'ওভারলে টাইমআউট (মিলিসেকেন্ড)', + 'graphCohesion.brokerBadge': 'ব্রোকার', + 'graphCohesion.brokerTitle': + 'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয় — এটিই তাদের মধ্যে একমাত্র সংযোগ।', + 'graphCohesion.colCohesion': 'সংসক্তি', + 'graphCohesion.colEntity': 'সত্তা', + 'graphCohesion.colLinks': 'লিঙ্ক', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'graphCohesion.emptyHint': + 'সহকারী যখন আপনার সম্পর্কে সংযুক্ত তথ্য রেকর্ড করে, তাদের ক্লাস্টারিং কাঠামো এখানে উঠে আসবে।', + 'graphCohesion.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'graphCohesion.intro': + 'প্রতিটি সত্তার চারপাশে প্রতিবেশ কতটা দৃঢ়ভাবে বোনা। ব্রোকার — যেসব সত্তার প্রতিবেশী একে অপরের সাথে যুক্ত নয় — তারাই একমাত্র বিন্দু যা পরস্পর-বিচ্ছিন্ন ক্লাস্টারকে একত্রে ধরে রাখে, যা ফ্রিকোয়েন্সি বা PageRank সাজানি প্রকাশ করতে পারে না।', + 'graphCohesion.loading': 'সংসক্তি গণনা করা হচ্ছে…', + 'graphCohesion.metricConnections': 'সংযোগ', + 'graphCohesion.metricEntities': 'সত্তা', + 'graphCohesion.metricTriangles': 'ত্রিভুজ', + 'graphCohesion.namespaceAll': 'সমস্ত নেমস্পেস', + 'graphCohesion.namespaceLabel': 'নেমস্পেস', + 'graphCohesion.noBrokers': 'এখনও দুই বা তার বেশি সংযোগওয়ালা কোনো সত্তা নেই।', + 'graphCohesion.rankedHeading': 'ব্রোকার — সবচেয়ে শিথিল প্রতিবেশ', + 'graphCohesion.retry': 'পুনরায় চেষ্টা', + 'graphCohesion.summaryCaption': 'গড় ক্লাস্টারিং {avg} · সংক্রমণতা {transitivity}', + 'graphCohesion.title': 'গ্রাফ সংসক্তি', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 2989539a5..a1528c17f 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4499,6 +4499,32 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Entprellung (ms)', 'autocomplete.maxChars': 'Maximale Kontextzeichen', 'autocomplete.overlayTtlMs': 'Overlay-Timeout (ms)', + 'graphCohesion.brokerBadge': 'Broker', + 'graphCohesion.brokerTitle': + 'Strukturelles Loch: Die Nachbarn dieser Entität sind nicht miteinander verbunden — sie ist die einzige Verknüpfung zwischen ihnen.', + 'graphCohesion.colCohesion': 'Kohäsion', + 'graphCohesion.colEntity': 'Entität', + 'graphCohesion.colLinks': 'Verknüpfungen', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Noch kein Wissensgraph.', + 'graphCohesion.emptyHint': + 'Während der Assistent verbundene Fakten über Sie erfasst, erscheint hier deren Clustering-Struktur.', + 'graphCohesion.errorPrefix': 'Graph konnte nicht geladen werden:', + 'graphCohesion.intro': + 'Wie eng verwoben die Nachbarschaft jeder Entität ist. Broker — Entitäten, deren Nachbarn untereinander nicht verbunden sind — sind die Einzelpunkte, die sonst getrennte Cluster zusammenhalten, was eine Häufigkeits- oder PageRank-Sortierung nicht aufdecken kann.', + 'graphCohesion.loading': 'Berechne Kohäsion…', + 'graphCohesion.metricConnections': 'Verbindungen', + 'graphCohesion.metricEntities': 'Entitäten', + 'graphCohesion.metricTriangles': 'Dreiecke', + 'graphCohesion.namespaceAll': 'Alle Namensräume', + 'graphCohesion.namespaceLabel': 'Namensraum', + 'graphCohesion.noBrokers': 'Noch keine Entitäten mit zwei oder mehr Verbindungen.', + 'graphCohesion.rankedHeading': 'Broker — lockerste Nachbarschaften', + 'graphCohesion.retry': 'Wiederholen', + 'graphCohesion.summaryCaption': + 'Durchschnittliches Clustering {avg} · Transitivität {transitivity}', + 'graphCohesion.title': 'Graph-Kohäsion', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d57ee9619..14c905173 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -305,6 +305,7 @@ const en: TranslationMap = { 'memory.tab.centrality': 'Centrality', 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', + 'memory.tab.cohesion': 'Cohesion', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', 'namespaceOverview.title': 'Namespace Overview', @@ -438,6 +439,31 @@ const en: TranslationMap = { 'connectionPath.missingTarget': '"{entity}" is not in the graph.', 'connectionPath.noPath': 'No connection found between "{source}" and "{target}".', + 'graphCohesion.title': 'Graph Cohesion', + 'graphCohesion.intro': + "How tightly knit the neighbourhood is around each entity. Brokers — entities whose neighbours aren't linked to each other — are the single points holding otherwise-separate clusters together, which a frequency or PageRank sort cannot reveal.", + 'graphCohesion.loading': 'Computing cohesion…', + 'graphCohesion.errorPrefix': 'Could not load the graph:', + 'graphCohesion.retry': 'Retry', + 'graphCohesion.empty': 'No knowledge graph yet.', + 'graphCohesion.emptyHint': + 'As the assistant records connected facts about you, their clustering structure will surface here.', + 'graphCohesion.namespaceLabel': 'Namespace', + 'graphCohesion.namespaceAll': 'All namespaces', + 'graphCohesion.metricEntities': 'Entities', + 'graphCohesion.metricConnections': 'Connections', + 'graphCohesion.metricTriangles': 'Triangles', + 'graphCohesion.summaryCaption': 'Average clustering {avg} · transitivity {transitivity}', + 'graphCohesion.noBrokers': 'No entities with two or more connections yet.', + 'graphCohesion.rankedHeading': 'Brokers — loosest neighbourhoods', + 'graphCohesion.colRank': '#', + 'graphCohesion.colEntity': 'Entity', + 'graphCohesion.colCohesion': 'Cohesion', + 'graphCohesion.colLinks': 'Links', + 'graphCohesion.brokerBadge': 'broker', + 'graphCohesion.brokerTitle': + "Structural hole: this entity's neighbours aren't connected to each other — it's the sole link between them.", + // Memory Tree status panel (#1856 Part 1) 'memoryTree.status.title': 'Memory Tree', 'memoryTree.status.autoSyncLabel': 'Auto-sync', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 823af2043..4f74f1278 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4469,6 +4469,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Retardo (ms)', 'autocomplete.maxChars': 'Máximo de caracteres de contexto', 'autocomplete.overlayTtlMs': 'Tiempo de espera de superposición (ms)', + 'graphCohesion.brokerBadge': 'intermediario', + 'graphCohesion.brokerTitle': + 'Hueco estructural: los vecinos de esta entidad no están conectados entre sí — ella es el único enlace entre ellos.', + 'graphCohesion.colCohesion': 'Cohesión', + 'graphCohesion.colEntity': 'Entidad', + 'graphCohesion.colLinks': 'Enlaces', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Aún no hay grafo de conocimiento.', + 'graphCohesion.emptyHint': + 'A medida que el asistente registra hechos conectados sobre usted, su estructura de agrupamiento aparecerá aquí.', + 'graphCohesion.errorPrefix': 'No se pudo cargar el grafo:', + 'graphCohesion.intro': + 'Cuán estrechamente tejido está el vecindario de cada entidad. Los intermediarios — entidades cuyos vecinos no están conectados entre sí — son los puntos únicos que mantienen unidos grupos que de otro modo estarían separados, algo que un orden por frecuencia o PageRank no puede revelar.', + 'graphCohesion.loading': 'Calculando cohesión…', + 'graphCohesion.metricConnections': 'Conexiones', + 'graphCohesion.metricEntities': 'Entidades', + 'graphCohesion.metricTriangles': 'Triángulos', + 'graphCohesion.namespaceAll': 'Todos los espacios de nombres', + 'graphCohesion.namespaceLabel': 'Espacio de nombres', + 'graphCohesion.noBrokers': 'Aún no hay entidades con dos o más conexiones.', + 'graphCohesion.rankedHeading': 'Intermediarios: vecindarios más sueltos', + 'graphCohesion.retry': 'Reintentar', + 'graphCohesion.summaryCaption': 'Agrupamiento promedio {avg} · transitividad {transitivity}', + 'graphCohesion.title': 'Cohesión del grafo', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3fcdcb2e8..fe1704053 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4485,6 +4485,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Anti-rebond (ms)', 'autocomplete.maxChars': 'Caractères de contexte maximum', 'autocomplete.overlayTtlMs': "Délai d'affichage (ms)", + 'graphCohesion.brokerBadge': 'courtier', + 'graphCohesion.brokerTitle': + 'Trou structurel : les voisins de cette entité ne sont pas connectés entre eux — elle est le seul lien entre eux.', + 'graphCohesion.colCohesion': 'Cohésion', + 'graphCohesion.colEntity': 'Entité', + 'graphCohesion.colLinks': 'Liens', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Pas encore de graphe de connaissances.', + 'graphCohesion.emptyHint': + "À mesure que l'assistant enregistre des faits connectés à votre sujet, leur structure de regroupement apparaîtra ici.", + 'graphCohesion.errorPrefix': 'Impossible de charger le graphe :', + 'graphCohesion.intro': + "À quel point le voisinage de chaque entité est étroitement tissé. Les courtiers — entités dont les voisins ne sont pas liés entre eux — sont les points uniques qui maintiennent ensemble des groupes autrement séparés, ce qu'un tri par fréquence ou PageRank ne peut révéler.", + 'graphCohesion.loading': 'Calcul de la cohésion…', + 'graphCohesion.metricConnections': 'Connexions', + 'graphCohesion.metricEntities': 'Entités', + 'graphCohesion.metricTriangles': 'Triangles', + 'graphCohesion.namespaceAll': 'Tous les espaces de noms', + 'graphCohesion.namespaceLabel': 'Espace de noms', + 'graphCohesion.noBrokers': "Aucune entité avec deux connexions ou plus pour l'instant.", + 'graphCohesion.rankedHeading': 'Courtiers — voisinages les plus lâches', + 'graphCohesion.retry': 'Réessayer', + 'graphCohesion.summaryCaption': 'Regroupement moyen {avg} · transitivité {transitivity}', + 'graphCohesion.title': 'Cohésion du graphe', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 4a6e13965..e1ad6294e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4391,6 +4391,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'डिबाउंस (ms)', 'autocomplete.maxChars': 'अधिकतम संदर्भ वर्ण', 'autocomplete.overlayTtlMs': 'ओवरले समय-समाप्ति (ms)', + 'graphCohesion.brokerBadge': 'ब्रोकर', + 'graphCohesion.brokerTitle': + 'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े — यह उनके बीच एकमात्र कड़ी है।', + 'graphCohesion.colCohesion': 'संसक्ति', + 'graphCohesion.colEntity': 'इकाई', + 'graphCohesion.colLinks': 'लिंक', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'अभी तक कोई नॉलेज ग्राफ नहीं।', + 'graphCohesion.emptyHint': + 'जैसे-जैसे सहायक आपके बारे में जुड़े हुए तथ्य दर्ज करता है, उनकी क्लस्टरिंग संरचना यहाँ उभरेगी।', + 'graphCohesion.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'graphCohesion.intro': + 'हर इकाई के चारों ओर पड़ोस कितना घनिष्ठ रूप से बुना हुआ है। ब्रोकर — वे इकाइयाँ जिनके पड़ोसी आपस में नहीं जुड़े — एकमात्र बिंदु हैं जो वरना अलग क्लस्टरों को साथ थामे रखते हैं, जिसे आवृत्ति या PageRank-आधारित क्रम नहीं दिखा सकता।', + 'graphCohesion.loading': 'संसक्ति गणना हो रही है…', + 'graphCohesion.metricConnections': 'कनेक्शन', + 'graphCohesion.metricEntities': 'इकाइयाँ', + 'graphCohesion.metricTriangles': 'त्रिकोण', + 'graphCohesion.namespaceAll': 'सभी नेमस्पेस', + 'graphCohesion.namespaceLabel': 'नेमस्पेस', + 'graphCohesion.noBrokers': 'अभी तक दो या अधिक कनेक्शन वाली कोई इकाई नहीं।', + 'graphCohesion.rankedHeading': 'ब्रोकर — सबसे ढीले पड़ोस', + 'graphCohesion.retry': 'पुनः प्रयास', + 'graphCohesion.summaryCaption': 'औसत क्लस्टरिंग {avg} · सकर्मकता {transitivity}', + 'graphCohesion.title': 'ग्राफ संसक्ति', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 25e9306db..946d14bf2 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4400,6 +4400,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Debounce (md)', 'autocomplete.maxChars': 'Karakter konteks maks', 'autocomplete.overlayTtlMs': 'Batas waktu overlay (md)', + 'graphCohesion.brokerBadge': 'broker', + 'graphCohesion.brokerTitle': + 'Lubang struktural: tetangga entitas ini tidak saling terhubung — entitas inilah satu-satunya tautan di antara mereka.', + 'graphCohesion.colCohesion': 'Kohesi', + 'graphCohesion.colEntity': 'Entitas', + 'graphCohesion.colLinks': 'Tautan', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Belum ada graf pengetahuan.', + 'graphCohesion.emptyHint': + 'Saat asisten mencatat fakta-fakta terhubung tentang Anda, struktur pengelompokannya akan muncul di sini.', + 'graphCohesion.errorPrefix': 'Tidak dapat memuat graf:', + 'graphCohesion.intro': + 'Seberapa rapat lingkungan di sekitar setiap entitas terjalin. Broker — entitas yang tetangganya tidak saling terhubung — adalah titik-titik tunggal yang menyatukan klaster yang sebenarnya terpisah, hal yang tidak dapat diungkap oleh pengurutan frekuensi atau PageRank.', + 'graphCohesion.loading': 'Menghitung kohesi…', + 'graphCohesion.metricConnections': 'Koneksi', + 'graphCohesion.metricEntities': 'Entitas', + 'graphCohesion.metricTriangles': 'Segitiga', + 'graphCohesion.namespaceAll': 'Semua ruang nama', + 'graphCohesion.namespaceLabel': 'Ruang nama', + 'graphCohesion.noBrokers': 'Belum ada entitas dengan dua atau lebih koneksi.', + 'graphCohesion.rankedHeading': 'Broker — lingkungan paling renggang', + 'graphCohesion.retry': 'Coba lagi', + 'graphCohesion.summaryCaption': 'Pengelompokan rata-rata {avg} · transitivitas {transitivity}', + 'graphCohesion.title': 'Kohesi Graf', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c6ca7f0b5..a4802da2f 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4461,6 +4461,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Debounce (ms)', 'autocomplete.maxChars': 'Caratteri massimi di contesto', 'autocomplete.overlayTtlMs': 'Timeout overlay (ms)', + 'graphCohesion.brokerBadge': 'broker', + 'graphCohesion.brokerTitle': + "Buco strutturale: i vicini di questa entità non sono collegati tra loro — essa è l'unico collegamento tra loro.", + 'graphCohesion.colCohesion': 'Coesione', + 'graphCohesion.colEntity': 'Entità', + 'graphCohesion.colLinks': 'Collegamenti', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Ancora nessun grafo della conoscenza.', + 'graphCohesion.emptyHint': + "Man mano che l'assistente registra fatti connessi su di te, qui apparirà la loro struttura di raggruppamento.", + 'graphCohesion.errorPrefix': 'Impossibile caricare il grafo:', + 'graphCohesion.intro': + 'Quanto è fittamente intrecciato il vicinato attorno a ciascuna entità. I broker — entità i cui vicini non sono collegati tra loro — sono i punti singoli che tengono uniti gruppi altrimenti separati, cosa che un ordinamento per frequenza o PageRank non può rivelare.', + 'graphCohesion.loading': 'Calcolo della coesione…', + 'graphCohesion.metricConnections': 'Connessioni', + 'graphCohesion.metricEntities': 'Entità', + 'graphCohesion.metricTriangles': 'Triangoli', + 'graphCohesion.namespaceAll': 'Tutti gli spazi dei nomi', + 'graphCohesion.namespaceLabel': 'Spazio dei nomi', + 'graphCohesion.noBrokers': 'Ancora nessuna entità con due o più connessioni.', + 'graphCohesion.rankedHeading': 'Broker — vicinati più radi', + 'graphCohesion.retry': 'Riprova', + 'graphCohesion.summaryCaption': 'Raggruppamento medio {avg} · transitività {transitivity}', + 'graphCohesion.title': 'Coesione del grafo', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 5a8c392e5..4a225fb71 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4350,6 +4350,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': '디바운스 (ms)', 'autocomplete.maxChars': '최대 컨텍스트 문자 수', 'autocomplete.overlayTtlMs': '오버레이 시간 초과 (ms)', + 'graphCohesion.brokerBadge': '브로커', + 'graphCohesion.brokerTitle': + '구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다 — 이들 사이의 유일한 연결고리입니다.', + 'graphCohesion.colCohesion': '응집도', + 'graphCohesion.colEntity': '엔티티', + 'graphCohesion.colLinks': '링크', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': '아직 지식 그래프가 없습니다.', + 'graphCohesion.emptyHint': + '어시스턴트가 당신에 관한 연결된 사실들을 기록함에 따라, 그 군집화 구조가 여기에 드러납니다.', + 'graphCohesion.errorPrefix': '그래프를 불러올 수 없습니다:', + 'graphCohesion.intro': + '각 엔티티 주변 이웃이 얼마나 촘촘히 엮여 있는지. 브로커 — 이웃들이 서로 연결되지 않은 엔티티 — 는 그렇지 않으면 분리되었을 클러스터를 묶어주는 단일 지점이며, 빈도나 PageRank 정렬로는 드러낼 수 없는 것입니다.', + 'graphCohesion.loading': '응집도 계산 중…', + 'graphCohesion.metricConnections': '연결', + 'graphCohesion.metricEntities': '엔티티', + 'graphCohesion.metricTriangles': '삼각형', + 'graphCohesion.namespaceAll': '모든 네임스페이스', + 'graphCohesion.namespaceLabel': '네임스페이스', + 'graphCohesion.noBrokers': '아직 연결이 둘 이상인 엔티티가 없습니다.', + 'graphCohesion.rankedHeading': '브로커 — 가장 느슨한 이웃', + 'graphCohesion.retry': '다시 시도', + 'graphCohesion.summaryCaption': '평균 군집계수 {avg} · 전이성 {transitivity}', + 'graphCohesion.title': '그래프 응집도', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 97f2e423f..7232f2219 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4458,6 +4458,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Opóźnienie (ms)', 'autocomplete.maxChars': 'Maksymalna liczba znaków kontekstu', 'autocomplete.overlayTtlMs': 'Limit czasu nakładki (ms)', + 'graphCohesion.brokerBadge': 'broker', + 'graphCohesion.brokerTitle': + 'Dziura strukturalna: sąsiedzi tej encji nie są ze sobą połączeni — jest ona jedynym łącznikiem między nimi.', + 'graphCohesion.colCohesion': 'Spójność', + 'graphCohesion.colEntity': 'Encja', + 'graphCohesion.colLinks': 'Łącza', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Jeszcze brak grafu wiedzy.', + 'graphCohesion.emptyHint': + 'W miarę jak asystent zapisuje powiązane fakty o Tobie, ich struktura klasteryzacji pojawi się tutaj.', + 'graphCohesion.errorPrefix': 'Nie udało się załadować grafu:', + 'graphCohesion.intro': + 'Jak ściśle spleciona jest okolica wokół każdej encji. Brokerzy — encje, których sąsiedzi nie są ze sobą połączeni — to pojedyncze punkty trzymające razem klastry, które inaczej byłyby oddzielne, czego sortowanie po częstotliwości ani PageRank nie ujawni.', + 'graphCohesion.loading': 'Obliczanie spójności…', + 'graphCohesion.metricConnections': 'Połączenia', + 'graphCohesion.metricEntities': 'Encje', + 'graphCohesion.metricTriangles': 'Trójkąty', + 'graphCohesion.namespaceAll': 'Wszystkie przestrzenie nazw', + 'graphCohesion.namespaceLabel': 'Przestrzeń nazw', + 'graphCohesion.noBrokers': 'Brak jeszcze encji z dwoma lub więcej połączeniami.', + 'graphCohesion.rankedHeading': 'Brokerzy — najluźniejsze sąsiedztwa', + 'graphCohesion.retry': 'Spróbuj ponownie', + 'graphCohesion.summaryCaption': 'Średnia klasteryzacja {avg} · tranzytywność {transitivity}', + 'graphCohesion.title': 'Spójność grafu', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 73ba3d657..9c46e58c2 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4457,6 +4457,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Atraso (ms)', 'autocomplete.maxChars': 'Máximo de caracteres de contexto', 'autocomplete.overlayTtlMs': 'Tempo limite da sobreposição (ms)', + 'graphCohesion.brokerBadge': 'intermediador', + 'graphCohesion.brokerTitle': + 'Buraco estrutural: os vizinhos desta entidade não estão conectados entre si — ela é a única ligação entre eles.', + 'graphCohesion.colCohesion': 'Coesão', + 'graphCohesion.colEntity': 'Entidade', + 'graphCohesion.colLinks': 'Ligações', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Ainda sem grafo de conhecimento.', + 'graphCohesion.emptyHint': + 'À medida que o assistente registra fatos conectados sobre você, a estrutura de agrupamento aparecerá aqui.', + 'graphCohesion.errorPrefix': 'Não foi possível carregar o grafo:', + 'graphCohesion.intro': + 'Quão fortemente entrelaçada é a vizinhança ao redor de cada entidade. Intermediadores — entidades cujos vizinhos não estão ligados entre si — são os pontos únicos que mantêm grupos separados unidos, algo que uma ordenação por frequência ou PageRank não pode revelar.', + 'graphCohesion.loading': 'Calculando coesão…', + 'graphCohesion.metricConnections': 'Conexões', + 'graphCohesion.metricEntities': 'Entidades', + 'graphCohesion.metricTriangles': 'Triângulos', + 'graphCohesion.namespaceAll': 'Todos os espaços de nomes', + 'graphCohesion.namespaceLabel': 'Espaço de nomes', + 'graphCohesion.noBrokers': 'Ainda não há entidades com duas ou mais conexões.', + 'graphCohesion.rankedHeading': 'Intermediadores — vizinhanças mais soltas', + 'graphCohesion.retry': 'Tentar novamente', + 'graphCohesion.summaryCaption': 'Agrupamento médio {avg} · transitividade {transitivity}', + 'graphCohesion.title': 'Coesão do grafo', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8b1a0cb47..dbdb891c2 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4427,6 +4427,31 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': 'Задержка (мс)', 'autocomplete.maxChars': 'Макс. символов контекста', 'autocomplete.overlayTtlMs': 'Тайм-аут наложения (мс)', + 'graphCohesion.brokerBadge': 'брокер', + 'graphCohesion.brokerTitle': + 'Структурная дыра: соседи этой сущности не связаны друг с другом — она единственная связь между ними.', + 'graphCohesion.colCohesion': 'Связность', + 'graphCohesion.colEntity': 'Сущность', + 'graphCohesion.colLinks': 'Связки', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': 'Пока нет графа знаний.', + 'graphCohesion.emptyHint': + 'По мере того как ассистент фиксирует связанные факты о вас, здесь появится их кластерная структура.', + 'graphCohesion.errorPrefix': 'Не удалось загрузить граф:', + 'graphCohesion.intro': + 'Насколько плотно сплетено окружение каждой сущности. Брокеры — сущности, чьи соседи не связаны друг с другом, — это единичные точки, удерживающие вместе иначе разделённые кластеры, чего сортировка по частоте или PageRank не вскроет.', + 'graphCohesion.loading': 'Вычисление связности…', + 'graphCohesion.metricConnections': 'Связи', + 'graphCohesion.metricEntities': 'Сущности', + 'graphCohesion.metricTriangles': 'Треугольники', + 'graphCohesion.namespaceAll': 'Все пространства имён', + 'graphCohesion.namespaceLabel': 'Пространство имён', + 'graphCohesion.noBrokers': 'Пока нет сущностей с двумя или более связями.', + 'graphCohesion.rankedHeading': 'Брокеры — самые разреженные окрестности', + 'graphCohesion.retry': 'Повторить', + 'graphCohesion.summaryCaption': 'Средняя кластеризация {avg} · транзитивность {transitivity}', + 'graphCohesion.title': 'Связность графа', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 40f5f354f..081809c82 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4175,6 +4175,29 @@ const messages: TranslationMap = { 'autocomplete.debounceMs': '防抖 (毫秒)', 'autocomplete.maxChars': '最大上下文字符数', 'autocomplete.overlayTtlMs': '覆盖层超时 (ms)', + 'graphCohesion.brokerBadge': '经纪者', + 'graphCohesion.brokerTitle': '结构洞:该实体的邻居彼此之间没有连接——它是它们之间唯一的链接。', + 'graphCohesion.colCohesion': '凝聚度', + 'graphCohesion.colEntity': '实体', + 'graphCohesion.colLinks': '链接', + 'graphCohesion.colRank': '#', + 'graphCohesion.empty': '暂无知识图。', + 'graphCohesion.emptyHint': '随着助手记录有关你的相互关联的事实,它们的聚类结构将在此呈现。', + 'graphCohesion.errorPrefix': '无法加载图:', + 'graphCohesion.intro': + '每个实体周围的邻域织得有多紧。经纪者——其邻居彼此之间没有连接的实体——是把本应分离的聚类绑在一起的单点,这是频率或 PageRank 排序无法揭示的。', + 'graphCohesion.loading': '正在计算凝聚度…', + 'graphCohesion.metricConnections': '连接', + 'graphCohesion.metricEntities': '实体', + 'graphCohesion.metricTriangles': '三角形', + 'graphCohesion.namespaceAll': '所有命名空间', + 'graphCohesion.namespaceLabel': '命名空间', + 'graphCohesion.noBrokers': '还没有连接数达到两条或以上的实体。', + 'graphCohesion.rankedHeading': '经纪者——最松散的邻域', + 'graphCohesion.retry': '重试', + 'graphCohesion.summaryCaption': '平均聚类系数 {avg} · 传递性 {transitivity}', + 'graphCohesion.title': '图的凝聚度', + 'memory.tab.cohesion': 'Cohesion', }; export default messages; diff --git a/app/src/lib/memory/graphCohesion.test.ts b/app/src/lib/memory/graphCohesion.test.ts new file mode 100644 index 000000000..e6fc324b1 --- /dev/null +++ b/app/src/lib/memory/graphCohesion.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeGraphCohesion, findBrokers } from './graphCohesion'; + +function rel(subject: string, object: string, predicate = 'knows'): GraphRelation { + return { + namespace: 'work', + subject, + predicate, + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +function nodeById(result: ReturnType, id: string) { + const node = result.nodes.find(n => n.id === id); + if (!node) throw new Error(`node ${id} not found`); + return node; +} + +describe('computeGraphCohesion — basic shapes', () => { + it('returns an empty result for no relations', () => { + const r = computeGraphCohesion([]); + expect(r.nodeCount).toBe(0); + expect(r.edgeCount).toBe(0); + expect(r.triangleCount).toBe(0); + expect(r.averageClustering).toBe(0); + expect(r.transitivity).toBe(0); + expect(r.nodes).toEqual([]); + }); + + it('a single triangle has clustering 1 everywhere', () => { + const r = computeGraphCohesion([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.nodeCount).toBe(3); + expect(r.edgeCount).toBe(3); + expect(r.triangleCount).toBe(1); + for (const id of ['A', 'B', 'C']) { + const n = nodeById(r, id); + expect(n.degree).toBe(2); + expect(n.triangles).toBe(1); + expect(n.localClustering).toBe(1); + } + expect(r.averageClustering).toBe(1); + expect(r.transitivity).toBe(1); + }); + + it('a path A-B-C has zero clustering (no closed triple)', () => { + const r = computeGraphCohesion([rel('A', 'B'), rel('B', 'C')]); + expect(r.edgeCount).toBe(2); + expect(r.triangleCount).toBe(0); + expect(nodeById(r, 'B').degree).toBe(2); + expect(nodeById(r, 'B').localClustering).toBe(0); + // only B is "clusterable" (degree >= 2) and it is 0 + expect(r.averageClustering).toBe(0); + expect(r.transitivity).toBe(0); + }); + + it('a 4-cycle has zero clustering (neighbours never adjacent)', () => { + const r = computeGraphCohesion([rel('A', 'B'), rel('B', 'C'), rel('C', 'D'), rel('D', 'A')]); + expect(r.edgeCount).toBe(4); + expect(r.triangleCount).toBe(0); + expect(r.averageClustering).toBe(0); + expect(r.transitivity).toBe(0); + for (const id of ['A', 'B', 'C', 'D']) expect(nodeById(r, id).localClustering).toBe(0); + }); + + it('a star: the hub has degree 3 but clustering 0 (a broker)', () => { + const r = computeGraphCohesion([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + expect(r.triangleCount).toBe(0); + const hub = nodeById(r, 'X'); + expect(hub.degree).toBe(3); + expect(hub.localClustering).toBe(0); + expect(r.transitivity).toBe(0); // 3·0 / 3 connected triples + // X is the broker: degree >= 2, lowest clustering. + expect(findBrokers(r)[0].id).toBe('X'); + }); +}); + +describe('computeGraphCohesion — diamond (two triangles sharing an edge)', () => { + // Edges: A-B, A-C, B-C, B-D, C-D. Triangles: A-B-C and B-C-D. + const r = computeGraphCohesion([ + rel('A', 'B'), + rel('A', 'C'), + rel('B', 'C'), + rel('B', 'D'), + rel('C', 'D'), + ]); + + it('counts the two triangles and five edges', () => { + expect(r.nodeCount).toBe(4); + expect(r.edgeCount).toBe(5); + expect(r.triangleCount).toBe(2); + }); + + it('degree-2 corners (A, D) are fully clustered', () => { + expect(nodeById(r, 'A').localClustering).toBe(1); + expect(nodeById(r, 'D').localClustering).toBe(1); + expect(nodeById(r, 'A').triangles).toBe(1); + }); + + it('degree-3 spine (B, C) clusters at 2/3', () => { + expect(nodeById(r, 'B').degree).toBe(3); + expect(nodeById(r, 'B').triangles).toBe(2); + expect(nodeById(r, 'B').localClustering).toBeCloseTo(2 / 3, 12); + expect(nodeById(r, 'C').localClustering).toBeCloseTo(2 / 3, 12); + }); + + it('average clustering = mean over the four clusterable nodes', () => { + // (1 + 1 + 2/3 + 2/3) / 4 = 5/6 + expect(r.averageClustering).toBeCloseTo(5 / 6, 12); + }); + + it('transitivity = 3·triangles / connected-triples = 6/8', () => { + // connected triples = C(2,2)+C(3,2)+C(3,2)+C(2,2) = 1+3+3+1 = 8 + expect(r.transitivity).toBeCloseTo(0.75, 12); + }); + + it('brokers are the lowest-clustering degree>=2 nodes first', () => { + const brokers = findBrokers(r); + // B and C (2/3) are less clustered than A and D (1), so they lead. + expect(brokers.map(b => b.id)).toEqual(['B', 'C', 'A', 'D']); + }); +}); + +describe('computeGraphCohesion — normalization & determinism', () => { + it('drops self-loops entirely', () => { + const r = computeGraphCohesion([rel('A', 'A'), rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + // self-loop A-A ignored; remaining is the A-B-C triangle. + expect(r.nodeCount).toBe(3); + expect(r.edgeCount).toBe(3); + expect(r.triangleCount).toBe(1); + expect(nodeById(r, 'A').degree).toBe(2); + }); + + it('collapses parallel edges and ignores direction', () => { + const r = computeGraphCohesion([ + rel('A', 'B', 'knows'), + rel('B', 'A', 'likes'), // reverse direction, same undirected edge + rel('A', 'B', 'trusts'), // duplicate + rel('B', 'C'), + rel('C', 'A'), + ]); + expect(r.edgeCount).toBe(3); // A-B, B-C, C-A — parallels collapsed + expect(r.triangleCount).toBe(1); + }); + + it('drops malformed relations (non-string subject/object)', () => { + const malformed = { ...rel('A', 'B'), object: null as unknown as string }; + const r = computeGraphCohesion([rel('A', 'B'), rel('B', 'C'), rel('C', 'A'), malformed]); + expect(r.triangleCount).toBe(1); + expect(r.nodeCount).toBe(3); + }); + + it('treats "Alice" and "alice" as distinct nodes (no case-folding)', () => { + const r = computeGraphCohesion([rel('Alice', 'Bob'), rel('alice', 'Bob')]); + expect(r.nodeCount).toBe(3); // Alice, alice, Bob + expect(nodeById(r, 'Bob').degree).toBe(2); + }); + + it('is order-independent: shuffled input yields identical output', () => { + const edges = [rel('A', 'B'), rel('A', 'C'), rel('B', 'C'), rel('B', 'D'), rel('C', 'D')]; + const forward = computeGraphCohesion(edges); + const reversed = computeGraphCohesion([...edges].reverse()); + expect(reversed).toEqual(forward); + }); + + it('averageClustering is BYTE-identical across input permutations (no float-order drift)', () => { + // A graph whose clustering multiset contains many 2/3-type values that are + // NOT exactly representable in IEEE-754, so a sum taken in input order would + // drift at the ULP level. Summing in canonical (sorted) order must keep the + // result bit-identical for every permutation. + const edges = [ + rel('A', 'B'), + rel('A', 'D'), + rel('A', 'E'), + rel('A', 'F'), + rel('A', 'G'), + rel('B', 'C'), + rel('B', 'D'), + rel('B', 'E'), + rel('B', 'F'), + rel('B', 'G'), + rel('C', 'E'), + rel('C', 'G'), + rel('D', 'E'), + rel('D', 'F'), + rel('E', 'F'), + rel('E', 'G'), + ]; + const forward = computeGraphCohesion(edges).averageClustering; + const reversed = computeGraphCohesion([...edges].reverse()).averageClustering; + const rotated = computeGraphCohesion([ + ...edges.slice(7), + ...edges.slice(0, 7), + ]).averageClustering; + expect(reversed).toBe(forward); // strict bit equality, not toBeCloseTo + expect(rotated).toBe(forward); + }); + + it('sorts nodes by clustering DESC, then degree DESC, then id ASC', () => { + const r = computeGraphCohesion([ + rel('A', 'B'), + rel('A', 'C'), + rel('B', 'C'), // triangle A-B-C (clustering 1 for A; B,C also 1 here) + rel('B', 'D'), + rel('C', 'D'), // diamond + ]); + // top entries are the clustering-1 nodes; A before D by id at equal degree. + expect(r.nodes[0].localClustering).toBe(1); + const ones = r.nodes.filter(n => n.localClustering === 1).map(n => n.id); + expect(ones).toEqual(['A', 'D']); + }); +}); + +describe('findBrokers', () => { + it('excludes degree<2 nodes and respects the limit', () => { + const r = computeGraphCohesion([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + // leaves A,B,C have degree 1 -> excluded; only X qualifies. + expect(findBrokers(r).map(b => b.id)).toEqual(['X']); + expect(findBrokers(r, 0)).toEqual([]); + }); +}); diff --git a/app/src/lib/memory/graphCohesion.ts b/app/src/lib/memory/graphCohesion.ts new file mode 100644 index 000000000..082a56bf5 --- /dev/null +++ b/app/src/lib/memory/graphCohesion.ts @@ -0,0 +1,176 @@ +/** + * Graph Cohesion — pure clustering-coefficient & triangle engine. + * + * The memory knowledge graph is a set of (subject)-[predicate]->(object) + * triples. This lens forgets edge DIRECTION and asks a different question from + * the centrality lens: not "which entities are important" but "how tightly knit + * is the neighbourhood AROUND each entity". Two structural signals fall out: + * + * - localClustering(v) — of all the PAIRS of v's neighbours, what fraction are + * themselves directly connected. 1.0 means v sits inside a fully-wired + * clique; 0.0 means v's neighbours never talk to each other (v is the only + * thing linking them — a structural hole / broker). + * - transitivity — the same idea at the WHOLE-GRAPH level: 3·triangles divided + * by the number of connected triples. It answers "globally, how often is a + * friend-of-a-friend also a direct friend". + * + * A frequency or PageRank sort can never reveal a BROKER: a node can have modest + * degree yet be the sole bridge between two otherwise-disjoint neighbour sets + * (clustering ≈ 0). Surfacing those is the point of this lens — they are the + * single points of failure / the brokerage opportunities in the user's memory. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. The result depends ONLY on the undirected (subject, object) + * structure of the relations — never on insertion order — so the same graph + * always yields byte-identical numbers and every branch is unit-testable. + * + * Load-bearing design choices (do not "fix" without reading the tests): + * - Entity identity is the raw string AS-IS: NO trimming, NO case-folding — + * matching the centrality lens, so "Alice" / "alice" stay distinct nodes. + * - The graph is treated as UNDIRECTED and SIMPLE: edge direction is dropped, + * parallel edges (same unordered pair under different predicates / duplicate + * triples) collapse to ONE edge, and self-loops (subject === object) are + * dropped entirely — a self-loop is not a neighbour and forms no triangle. + * - localClustering of a node with degree < 2 is 0 (the coefficient is + * mathematically undefined there; 0 is the conventional fill). + * - averageClustering averages localClustering over the nodes where it is + * DEFINED (degree >= 2). This is deliberately NOT the Watts–Strogatz "over + * all nodes" variant; averaging over degree-<2 nodes would dilute the signal + * with structural zeros. transitivity is reported alongside as the + * denominator-honest global counterpart. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface CohesionNode { + id: string; + degree: number; // distinct undirected neighbours (self excluded) + triangles: number; // edges among this node's neighbours = closed triples through it + localClustering: number; // 0..1; 0 when degree < 2 +} + +export interface CohesionResult { + nodes: CohesionNode[]; // sorted localClustering DESC, then degree DESC, then id ASC + nodeCount: number; + edgeCount: number; // distinct undirected edges (self-loops excluded) + triangleCount: number; // distinct triangles in the whole graph + averageClustering: number; // mean localClustering over nodes with degree >= 2 (0 if none) + transitivity: number; // 3·triangles / connected-triples (0 if no connected triples) +} + +function isRelation(relation: GraphRelation): boolean { + return typeof relation.subject === 'string' && typeof relation.object === 'string'; +} + +/** + * Build the undirected simple-graph adjacency: a map from each node id to the + * SET of its distinct neighbour ids. Self-loops and parallel edges are removed + * by construction (Set membership). + */ +function buildAdjacency(relations: GraphRelation[]): Map> { + const adjacency = new Map>(); + const neighbours = (id: string): Set => { + let set = adjacency.get(id); + if (set === undefined) { + set = new Set(); + adjacency.set(id, set); + } + return set; + }; + for (const relation of relations) { + if (!isRelation(relation)) continue; + const { subject, object } = relation; + if (subject === object) continue; // self-loop: no neighbour, no triangle + neighbours(subject).add(object); + neighbours(object).add(subject); + } + return adjacency; +} + +/** + * Count the edges among a node's neighbours — the number of closed triples + * through it. Each unordered neighbour pair {a, b} contributes 1 iff a—b is an + * edge. Iterating the smaller-vs-larger index keeps every pair counted once. + */ +function countTrianglesThrough( + neighbourList: string[], + adjacency: Map> +): number { + let count = 0; + for (let i = 0; i < neighbourList.length; i += 1) { + const a = adjacency.get(neighbourList[i]); + if (a === undefined) continue; + for (let j = i + 1; j < neighbourList.length; j += 1) { + if (a.has(neighbourList[j])) count += 1; + } + } + return count; +} + +/** Compute clustering coefficients, triangle count, and transitivity. PURE. */ +export function computeGraphCohesion(relations: GraphRelation[]): CohesionResult { + const adjacency = buildAdjacency(relations); + + const nodes: CohesionNode[] = []; + let edgeDegreeSum = 0; // sum of degrees = 2 · edgeCount + let closedTripleSum = 0; // sum of triangles-through-node = 3 · triangleCount + let connectedTriples = 0; // sum of C(degree, 2) over all nodes + + for (const [id, neighbourSet] of adjacency) { + const degree = neighbourSet.size; + edgeDegreeSum += degree; + const triangles = degree < 2 ? 0 : countTrianglesThrough([...neighbourSet], adjacency); + closedTripleSum += triangles; + connectedTriples += (degree * (degree - 1)) / 2; + const localClustering = degree < 2 ? 0 : (2 * triangles) / (degree * (degree - 1)); + nodes.push({ id, degree, triangles, localClustering }); + } + + nodes.sort((a, b) => { + if (b.localClustering !== a.localClustering) return b.localClustering - a.localClustering; + if (b.degree !== a.degree) return b.degree - a.degree; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + + // averageClustering is summed AFTER the sort, walking the now-canonically + // ordered `nodes`. Floating-point addition is NOT associative and the + // summands (e.g. 2/3) are not exactly representable, so summing in Map / + // input order would make the bit value depend on insertion order and break + // the byte-identical determinism contract above. The integer-valued + // accumulators (degrees, triangle counts, C(deg,2) — always exact) are + // immune, so only this one needs the canonical-order treatment. + let clusteringSum = 0; + let clusterableCount = 0; + for (const node of nodes) { + if (node.degree >= 2) { + clusteringSum += node.localClustering; + clusterableCount += 1; + } + } + + return { + nodes, + nodeCount: adjacency.size, + edgeCount: edgeDegreeSum / 2, + triangleCount: closedTripleSum / 3, + averageClustering: clusterableCount === 0 ? 0 : clusteringSum / clusterableCount, + transitivity: connectedTriples === 0 ? 0 : closedTripleSum / connectedTriples, + }; +} + +/** + * Broker candidates: nodes with degree >= 2 whose neighbours are the LEAST + * interconnected (lowest localClustering). A low-clustering high-degree node is + * a structural hole — it is the sole link between groups that would otherwise be + * disconnected. Sorted clustering ASC, then degree DESC (a bigger gap brokered + * matters more), then id ASC. Pure; derived entirely from the result. + */ +export function findBrokers(result: CohesionResult, limit = 25): CohesionNode[] { + return result.nodes + .filter(node => node.degree >= 2) + .sort((a, b) => { + if (a.localClustering !== b.localClustering) return a.localClustering - b.localClustering; + if (b.degree !== a.degree) return b.degree - a.degree; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }) + .slice(0, limit); +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 4800a2ad2..7d2410a3c 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -5,6 +5,7 @@ import ConnectionPathTab from '../components/intelligence/ConnectionPathTab'; import DiagramViewerTab from '../components/intelligence/DiagramViewerTab'; import EntityAssociationsTab from '../components/intelligence/EntityAssociationsTab'; import GraphCentralityTab from '../components/intelligence/GraphCentralityTab'; +import GraphCohesionTab from '../components/intelligence/GraphCohesionTab'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab'; import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab'; @@ -32,6 +33,7 @@ type IntelligenceTab = | 'workflows' | 'diagram' | 'centrality' + | 'cohesion' | 'associations' | 'freshness' | 'timeline' @@ -116,6 +118,7 @@ export default function Intelligence() { }, { id: 'diagram', label: t('memory.tab.diagram') }, { id: 'centrality', label: t('memory.tab.centrality') }, + { id: 'cohesion', label: t('memory.tab.cohesion') }, { id: 'associations', label: t('memory.tab.associations') }, { id: 'freshness', label: t('memory.tab.freshness') }, { id: 'timeline', label: t('memory.tab.timeline') }, @@ -212,6 +215,8 @@ export default function Intelligence() { {activeTab === 'centrality' && } + {activeTab === 'cohesion' && } + {activeTab === 'associations' && } {activeTab === 'freshness' && } diff --git a/app/src/services/api/graphCohesionApi.test.ts b/app/src/services/api/graphCohesionApi.test.ts new file mode 100644 index 000000000..0356702fe --- /dev/null +++ b/app/src/services/api/graphCohesionApi.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphCohesion } from '../../lib/memory/graphCohesion'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { graphCohesionApi, loadCohesion, loadNamespaces } from './graphCohesionApi'; + +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('graphCohesionApi.loadCohesion', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine result for those triples', async () => { + const triples = [rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadCohesion('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeGraphCohesion(triples)); + expect(out.triangleCount).toBe(1); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadCohesion(); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.nodes).toEqual([]); + expect(out.nodeCount).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadCohesion()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('graphCohesionApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('graphCohesionApi object', () => { + it('exposes the public surface', () => { + expect(typeof graphCohesionApi.loadCohesion).toBe('function'); + expect(typeof graphCohesionApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/graphCohesionApi.ts b/app/src/services/api/graphCohesionApi.ts new file mode 100644 index 000000000..925789357 --- /dev/null +++ b/app/src/services/api/graphCohesionApi.ts @@ -0,0 +1,29 @@ +/** + * RPC facade for Graph Cohesion. + * + * Adds ZERO new core surface. It composes two already-shipped JSON-RPC wrappers: + * - memoryGraphQuery (openhuman.memory_graph_query) — the triples + * - memoryListNamespaces (openhuman.memory_list_namespaces) — the selector + * and delegates all math to the pure, deterministic engine. Read-only: there is + * no persistence — the result is always reproducible from the current graph. + */ +import debug from 'debug'; + +import { type CohesionResult, computeGraphCohesion } from '../../lib/memory/graphCohesion'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('graph-cohesion:api'); + +/** Fetch the graph relations for a namespace (or all) and compute cohesion. */ +export async function loadCohesion(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadCohesion namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeGraphCohesion(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const graphCohesionApi = { loadCohesion, loadNamespaces };