From 38bae6fe393c1e5556c0090188adbac83dc70d8a Mon Sep 17 00:00:00 2001 From: Taimoor Date: Sun, 31 May 2026 06:38:49 +0500 Subject: [PATCH] =?UTF-8?q?fix(intelligence):=20Graph=20Reach=20=E2=80=94?= =?UTF-8?q?=20BFS=20node-count=20gate,=20tab=20wiring,=20i18n=20flat=20(#2?= =?UTF-8?q?985)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MAX_REACH_NODES (5000) safety cap with isGraphReachFeasible() check to prevent BFS-from-every-node O(V*(V+E)) from freezing large graphs - Wire GraphReachTab into Intelligence.tsx (import, type union, tab entry, render) - Add memory.tab.reach + 21 graphReach.* i18n keys to en.ts and all 13 locales with real translations (flat single-file format) - Fix French/Italian apostrophe quoting (single→double quotes for l'excentricité etc.) --- .../intelligence/GraphReachPanel.test.tsx | 76 ++++++ .../intelligence/GraphReachPanel.tsx | 215 +++++++++++++++ .../intelligence/GraphReachTab.test.tsx | 61 +++++ .../components/intelligence/GraphReachTab.tsx | 83 ++++++ app/src/lib/i18n/ar.ts | 25 ++ app/src/lib/i18n/bn.ts | 26 ++ app/src/lib/i18n/de.ts | 26 ++ app/src/lib/i18n/en.ts | 27 ++ app/src/lib/i18n/es.ts | 26 ++ app/src/lib/i18n/fr.ts | 26 ++ app/src/lib/i18n/hi.ts | 26 ++ app/src/lib/i18n/id.ts | 26 ++ app/src/lib/i18n/it.ts | 26 ++ app/src/lib/i18n/ko.ts | 26 ++ app/src/lib/i18n/pl.ts | 26 ++ app/src/lib/i18n/pt.ts | 26 ++ app/src/lib/i18n/ru.ts | 26 ++ app/src/lib/i18n/zh-CN.ts | 24 ++ app/src/lib/memory/graphReach.test.ts | 171 ++++++++++++ app/src/lib/memory/graphReach.ts | 247 ++++++++++++++++++ app/src/pages/Intelligence.tsx | 5 + app/src/services/api/graphReachApi.test.ts | 74 ++++++ app/src/services/api/graphReachApi.ts | 29 ++ 23 files changed, 1323 insertions(+) create mode 100644 app/src/components/intelligence/GraphReachPanel.test.tsx create mode 100644 app/src/components/intelligence/GraphReachPanel.tsx create mode 100644 app/src/components/intelligence/GraphReachTab.test.tsx create mode 100644 app/src/components/intelligence/GraphReachTab.tsx create mode 100644 app/src/lib/memory/graphReach.test.ts create mode 100644 app/src/lib/memory/graphReach.ts create mode 100644 app/src/services/api/graphReachApi.test.ts create mode 100644 app/src/services/api/graphReachApi.ts diff --git a/app/src/components/intelligence/GraphReachPanel.test.tsx b/app/src/components/intelligence/GraphReachPanel.test.tsx new file mode 100644 index 0000000000..a159c915c3 --- /dev/null +++ b/app/src/components/intelligence/GraphReachPanel.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeGraphReach } from '../../lib/memory/graphReach'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphReachPanel from './GraphReachPanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// Path A-B-C-D: diameter 3, radius 2, center {B,C}. +const path = computeGraphReach([rel('A', 'B'), rel('B', 'C'), rel('C', 'D')]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('graph-reach-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 component summary, and the ranked table', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Diameter')).toBeInTheDocument(); + expect(screen.getByText('Radius')).toBeInTheDocument(); + expect(screen.getByText('Most central entities')).toBeInTheDocument(); + // single component holding all four nodes -> singular caption variant. + expect(screen.getByText('1 component · 4 entities')).toBeInTheDocument(); + }); + + it('badges the centers (eccentricity == radius) and not the periphery', () => { + render(); + // B and C are the two centers of the path; A and D are not. + expect(screen.getAllByText('center')).toHaveLength(2); + }); + + it('uses the plural caption when the graph has more than one component', () => { + // Path P-Q-R-S (giant, size 4) plus disjoint edge Y-Z (size 2). + const multi = computeGraphReach([rel('P', 'Q'), rel('Q', 'R'), rel('R', 'S'), rel('Y', 'Z')]); + render(); + expect(screen.getByText('2 components · largest holds 4')).toBeInTheDocument(); + }); + + it('uses the all-singular caption for a single-node component (self-loop-only)', () => { + // The only fact is "Alice→Alice": the engine keeps Alice as a singleton + // (size 1), and the caption renders the all-singular variant — never the + // ungrammatical "1 component · 1 entities". + const lonely = computeGraphReach([rel('Alice', 'Alice')]); + render(); + expect(screen.getByText('1 component · 1 entity')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/GraphReachPanel.tsx b/app/src/components/intelligence/GraphReachPanel.tsx new file mode 100644 index 0000000000..e1c4dbd31d --- /dev/null +++ b/app/src/components/intelligence/GraphReachPanel.tsx @@ -0,0 +1,215 @@ +/** + * Graph Reach — presentational view. Pure: renders the reach summary tiles + * (entities / diameter / radius), the component summary, and a ranked table of + * the most-central entities. No data fetching, no clock, no randomness. + */ +import { useMemo } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { ReachResult } from '../../lib/memory/graphReach'; + +const MAX_ROWS = 25; + +interface GraphReachPanelProps { + result: ReachResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const GraphReachPanel = ({ result, loading, error, onRetry }: GraphReachPanelProps) => { + const { t } = useT(); + + // Per-component diameter so each row's eccentricity bar is relative to its + // OWN component, not the giant component's diameter (which would let a node + // in a smaller-but-longer component render >100% width). + const componentDiameter = useMemo(() => { + const map = new Map(); + if (result) { + for (const c of result.components) map.set(c.id, c.diameter); + } + return map; + }, [result]); + + const intro = ( +
+

{t('graphReach.title')}

+

{t('graphReach.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + const rows = result.nodes.slice(0, MAX_ROWS); + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('graphReach.metricEntities'), value: result.nodeCount }, + { label: t('graphReach.metricDiameter'), value: result.diameter }, + { label: t('graphReach.metricRadius'), value: result.radius }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {result.componentCount === 1 + ? result.giantComponentSize === 1 + ? t('graphReach.summaryCaptionOneAndOne') + : t('graphReach.summaryCaptionOne').replace( + '{giant}', + String(result.giantComponentSize) + ) + : t('graphReach.summaryCaption') + .replace('{components}', String(result.componentCount)) + .replace('{giant}', String(result.giantComponentSize))} +

+ + {/* Ranked most-central entities */} +
+

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

+ + + + + + + + + + + {rows.map((node, i) => { + const localDiameter = componentDiameter.get(node.componentId) ?? 0; + const barWidth = localDiameter === 0 ? 0 : (node.eccentricity / localDiameter) * 100; + return ( + + + + + + + ); + })} + +
+ {t('graphReach.colRank')} + + {t('graphReach.colEntity')} + + {t('graphReach.colEccentricity')} + + {t('graphReach.colLinks')} +
{i + 1} + {node.id} + {node.isCenter && ( + + {t('graphReach.centerBadge')} + + )} + +
+
+
+
+ + {node.eccentricity} + +
+
+ {node.degree} +
+
+
+ ); +}; + +export default GraphReachPanel; diff --git a/app/src/components/intelligence/GraphReachTab.test.tsx b/app/src/components/intelligence/GraphReachTab.test.tsx new file mode 100644 index 0000000000..65171266b1 --- /dev/null +++ b/app/src/components/intelligence/GraphReachTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphReach } from '../../lib/memory/graphReach'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphReachTab from './GraphReachTab'; + +const mockLoadReach = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/graphReachApi', () => ({ + loadReach: (...args: unknown[]) => mockLoadReach(...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 = computeGraphReach([rel('A', 'B'), rel('B', 'C'), rel('C', 'D')]); + +describe('', () => { + beforeEach(() => { + mockLoadReach.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadReach.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads reach (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadReach).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(screen.getByText('Most central entities')).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(mockLoadReach).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadReach.mockReset(); + mockLoadReach.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/GraphReachTab.tsx b/app/src/components/intelligence/GraphReachTab.tsx new file mode 100644 index 0000000000..f85b5aade3 --- /dev/null +++ b/app/src/components/intelligence/GraphReachTab.tsx @@ -0,0 +1,83 @@ +/** + * Graph Reach 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 { ReachResult } from '../../lib/memory/graphReach'; +import { loadNamespaces, loadReach } from '../../services/api/graphReachApi'; +import GraphReachPanel from './GraphReachPanel'; + +const GraphReachTab = () => { + 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 loadReach(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 reach 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 GraphReachTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index be57d0c4c7..48cf7481bb 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4359,6 +4359,31 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'متوسط التجميع {avg} · التعدّي {transitivity}', 'graphCohesion.title': 'تماسك الرسم البياني', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'الوصول', + 'graphReach.title': 'وصول الرسم البياني', + 'graphReach.intro': + 'الانحراف هو مدى بُعد كيان ما عن كل ما يمكنه الوصول إليه — أطول أقصر مسار له. القطر هو أوسع هذه الفجوات، نصف القطر هو أصغرها، والمركز هو الكيان الذي يصل إلى المجموعة بأكملها في أقل عدد من الخطوات. لا الدرجة ولا PageRank يكشفان المركز.', + 'graphReach.loading': 'جارٍ حساب الوصول…', + 'graphReach.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'graphReach.retry': 'إعادة المحاولة', + 'graphReach.empty': 'لا يوجد رسم بياني للمعرفة بعد.', + 'graphReach.emptyHint': 'بينما يسجّل المساعد حقائق مترابطة عنك، ستظهر هنا شكل ذاكرتك ومركزها.', + 'graphReach.namespaceLabel': 'النطاق', + 'graphReach.namespaceAll': 'جميع النطاقات', + 'graphReach.metricEntities': 'الكيانات', + 'graphReach.metricDiameter': 'القطر', + 'graphReach.metricRadius': 'نصف القطر', + 'graphReach.summaryCaption': '{components} مكونات · أكبرها يضم {giant}', + 'graphReach.summaryCaptionOne': 'مكوّن واحد · {giant} كيانات', + 'graphReach.summaryCaptionOneAndOne': 'مكوّن واحد · كيان واحد', + 'graphReach.rankedHeading': 'أكثر الكيانات مركزيةً', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'الكيان', + 'graphReach.colEccentricity': 'الانحراف', + 'graphReach.colLinks': 'الروابط', + 'graphReach.centerBadge': 'مركز', + 'graphReach.centerTitle': + 'مركز مجموعته — يصل إلى كل كيان متصل في أقل عدد ممكن من الخطوات (الانحراف يساوي نصف القطر).', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 9ec2d1481b..33644cee45 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4434,6 +4434,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'গড় ক্লাস্টারিং {avg} · সংক্রমণতা {transitivity}', 'graphCohesion.title': 'গ্রাফ সংসক্তি', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'পৌঁছানো', + 'graphReach.title': 'গ্রাফ রিচ', + 'graphReach.intro': + 'এক্সেন্ট্রিসিটি হলো একটি সত্তা তার পৌঁছানো সবকিছু থেকে কতটা দূরে — তার দীর্ঘতম সংক্ষিপ্ততম পথ। ডায়ামিটার হলো সবচেয়ে বড় এই ব্যবধান, রেডিয়াস হলো সবচেয়ে ছোট, এবং কেন্দ্র হলো সেই সত্তা যা সবচেয়ে কম হপে পুরো ক্লাস্টারে পৌঁছায়। ডিগ্রি বা PageRank কেউই কেন্দ্র প্রকাশ করে না।', + 'graphReach.loading': 'রিচ গণনা করা হচ্ছে…', + 'graphReach.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'graphReach.retry': 'পুনরায় চেষ্টা', + 'graphReach.empty': 'এখনো কোনো জ্ঞান গ্রাফ নেই।', + 'graphReach.emptyHint': + 'সহকারী আপনার সম্পর্কে সংযুক্ত তথ্য রেকর্ড করার সাথে সাথে আপনার স্মৃতির আকৃতি ও কেন্দ্র এখানে প্রদর্শিত হবে।', + 'graphReach.namespaceLabel': 'নেমস্পেস', + 'graphReach.namespaceAll': 'সমস্ত নেমস্পেস', + 'graphReach.metricEntities': 'সত্তাসমূহ', + 'graphReach.metricDiameter': 'ডায়ামিটার', + 'graphReach.metricRadius': 'রেডিয়াস', + 'graphReach.summaryCaption': '{components}টি উপাংশ · সবচেয়ে বড়টিতে {giant}টি', + 'graphReach.summaryCaptionOne': '১টি উপাংশ · {giant}টি সত্তা', + 'graphReach.summaryCaptionOneAndOne': '১টি উপাংশ · ১টি সত্তা', + 'graphReach.rankedHeading': 'সবচেয়ে কেন্দ্রীয় সত্তাসমূহ', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'সত্তা', + 'graphReach.colEccentricity': 'এক্সেন্ট্রিসিটি', + 'graphReach.colLinks': 'লিঙ্কসমূহ', + 'graphReach.centerBadge': 'কেন্দ্র', + 'graphReach.centerTitle': + 'তার ক্লাস্টারের একটি কেন্দ্র — সবচেয়ে কম সম্ভব হপে প্রতিটি সংযুক্ত সত্তায় পৌঁছায় (এক্সেন্ট্রিসিটি রেডিয়াসের সমান)।', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b3ae7764b3..85ffa236a0 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4551,6 +4551,32 @@ const messages: TranslationMap = { 'Durchschnittliches Clustering {avg} · Transitivität {transitivity}', 'graphCohesion.title': 'Graph-Kohäsion', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Reichweite', + 'graphReach.title': 'Graph-Reichweite', + 'graphReach.intro': + 'Die Exzentrizität gibt an, wie weit eine Entität von allem entfernt ist, was sie erreichen kann — ihr längster kürzester Pfad. Der Durchmesser ist die größte solche Lücke, der Radius die kleinste, und das Zentrum ist die Entität, die den gesamten Cluster in den wenigsten Schritten erreicht. Weder Grad noch PageRank zeigt das Zentrum.', + 'graphReach.loading': 'Berechne Reichweite…', + 'graphReach.errorPrefix': 'Graph konnte nicht geladen werden:', + 'graphReach.retry': 'Wiederholen', + 'graphReach.empty': 'Noch kein Wissensgraph.', + 'graphReach.emptyHint': + 'Während der Assistent verbundene Fakten über Sie erfasst, erscheinen hier die Form und das Zentrum Ihres Gedächtnisses.', + 'graphReach.namespaceLabel': 'Namensraum', + 'graphReach.namespaceAll': 'Alle Namensräume', + 'graphReach.metricEntities': 'Entitäten', + 'graphReach.metricDiameter': 'Durchmesser', + 'graphReach.metricRadius': 'Radius', + 'graphReach.summaryCaption': '{components} Komponenten · größte enthält {giant}', + 'graphReach.summaryCaptionOne': '1 Komponente · {giant} Entitäten', + 'graphReach.summaryCaptionOneAndOne': '1 Komponente · 1 Entität', + 'graphReach.rankedHeading': 'Zentralste Entitäten', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entität', + 'graphReach.colEccentricity': 'Exzentrizität', + 'graphReach.colLinks': 'Verknüpfungen', + 'graphReach.centerBadge': 'Zentrum', + 'graphReach.centerTitle': + 'Ein Zentrum seines Clusters — erreicht jede verbundene Entität in den wenigsten möglichen Schritten (Exzentrizität entspricht dem Radius).', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2a61c636fb..133e63854d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -306,6 +306,7 @@ const en: TranslationMap = { 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Reach', 'memory.tab.settings': 'Settings', 'memory.tab.council': 'Council', 'modelCouncil.title': 'Model Council', @@ -488,6 +489,32 @@ const en: TranslationMap = { 'graphCohesion.brokerTitle': "Structural hole: this entity's neighbours aren't connected to each other — it's the sole link between them.", + 'graphReach.title': 'Graph Reach', + 'graphReach.intro': + 'Eccentricity is how far an entity sits from everything it can reach — its longest shortest-path. The diameter is the widest such gap, the radius the smallest, and the center is the entity that reaches the whole cluster in the fewest hops. Neither degree nor PageRank surfaces the center.', + 'graphReach.loading': 'Computing reach…', + 'graphReach.errorPrefix': 'Could not load the graph:', + 'graphReach.retry': 'Retry', + 'graphReach.empty': 'No knowledge graph yet.', + 'graphReach.emptyHint': + 'As the assistant records connected facts about you, the shape and center of your memory will surface here.', + 'graphReach.namespaceLabel': 'Namespace', + 'graphReach.namespaceAll': 'All namespaces', + 'graphReach.metricEntities': 'Entities', + 'graphReach.metricDiameter': 'Diameter', + 'graphReach.metricRadius': 'Radius', + 'graphReach.summaryCaption': '{components} components · largest holds {giant}', + 'graphReach.summaryCaptionOne': '1 component · {giant} entities', + 'graphReach.summaryCaptionOneAndOne': '1 component · 1 entity', + 'graphReach.rankedHeading': 'Most central entities', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entity', + 'graphReach.colEccentricity': 'Eccentricity', + 'graphReach.colLinks': 'Links', + 'graphReach.centerBadge': 'center', + 'graphReach.centerTitle': + 'A center of its cluster — reaches every connected entity in the fewest possible hops (eccentricity equals the radius).', + // 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 42d9faf057..6f50a5777c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4520,6 +4520,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Agrupamiento promedio {avg} · transitividad {transitivity}', 'graphCohesion.title': 'Cohesión del grafo', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Alcance', + 'graphReach.title': 'Alcance del grafo', + 'graphReach.intro': + 'La excentricidad indica qué tan lejos se encuentra una entidad de todo lo que puede alcanzar — su camino más corto más largo. El diámetro es la brecha más amplia, el radio la más pequeña, y el centro es la entidad que alcanza todo el clúster en el menor número de saltos. Ni el grado ni PageRank revelan el centro.', + 'graphReach.loading': 'Calculando alcance…', + 'graphReach.errorPrefix': 'No se pudo cargar el grafo:', + 'graphReach.retry': 'Reintentar', + 'graphReach.empty': 'Aún no hay grafo de conocimiento.', + 'graphReach.emptyHint': + 'A medida que el asistente registre hechos conectados sobre ti, la forma y el centro de tu memoria aparecerán aquí.', + 'graphReach.namespaceLabel': 'Espacio de nombres', + 'graphReach.namespaceAll': 'Todos los espacios de nombres', + 'graphReach.metricEntities': 'Entidades', + 'graphReach.metricDiameter': 'Diámetro', + 'graphReach.metricRadius': 'Radio', + 'graphReach.summaryCaption': '{components} componentes · el mayor tiene {giant}', + 'graphReach.summaryCaptionOne': '1 componente · {giant} entidades', + 'graphReach.summaryCaptionOneAndOne': '1 componente · 1 entidad', + 'graphReach.rankedHeading': 'Entidades más centrales', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entidad', + 'graphReach.colEccentricity': 'Excentricidad', + 'graphReach.colLinks': 'Vínculos', + 'graphReach.centerBadge': 'centro', + 'graphReach.centerTitle': + 'Un centro de su clúster — alcanza cada entidad conectada en el menor número posible de saltos (la excentricidad es igual al radio).', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d76e393c00..63cc701eef 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4536,6 +4536,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Regroupement moyen {avg} · transitivité {transitivity}', 'graphCohesion.title': 'Cohésion du graphe', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Portée', + 'graphReach.title': 'Portée du graphe', + 'graphReach.intro': + "L'excentricité mesure la distance entre une entité et tout ce qu'elle peut atteindre — son plus long chemin le plus court. Le diamètre est l'écart le plus large, le rayon le plus petit, et le centre est l'entité qui atteint tout le cluster en le moins de sauts. Ni le degré ni PageRank ne révèlent le centre.", + 'graphReach.loading': 'Calcul de la portée…', + 'graphReach.errorPrefix': 'Impossible de charger le graphe :', + 'graphReach.retry': 'Réessayer', + 'graphReach.empty': 'Pas encore de graphe de connaissances.', + 'graphReach.emptyHint': + "Au fur et à mesure que l'assistant enregistre des faits connectés sur vous, la forme et le centre de votre mémoire apparaîtront ici.", + 'graphReach.namespaceLabel': 'Espace de noms', + 'graphReach.namespaceAll': 'Tous les espaces de noms', + 'graphReach.metricEntities': 'Entités', + 'graphReach.metricDiameter': 'Diamètre', + 'graphReach.metricRadius': 'Rayon', + 'graphReach.summaryCaption': '{components} composantes · la plus grande contient {giant}', + 'graphReach.summaryCaptionOne': '1 composante · {giant} entités', + 'graphReach.summaryCaptionOneAndOne': '1 composante · 1 entité', + 'graphReach.rankedHeading': 'Entités les plus centrales', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entité', + 'graphReach.colEccentricity': 'Excentricité', + 'graphReach.colLinks': 'Liens', + 'graphReach.centerBadge': 'centre', + 'graphReach.centerTitle': + "Un centre de son cluster — atteint chaque entité connectée en le moins de sauts possible (l'excentricité est égale au rayon).", }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 5c149a403b..efab78710e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4442,6 +4442,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'औसत क्लस्टरिंग {avg} · सकर्मकता {transitivity}', 'graphCohesion.title': 'ग्राफ संसक्ति', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'पहुंच', + 'graphReach.title': 'ग्राफ पहुंच', + 'graphReach.intro': + 'विकेंद्रता बताती है कि एक इकाई अपनी पहुंच की हर चीज़ से कितनी दूर है — उसका सबसे लंबा सबसे छोटा पथ। व्यास सबसे बड़ा ऐसा अंतर है, त्रिज्या सबसे छोटा, और केंद्र वह इकाई है जो सबसे कम चरणों में पूरे क्लस्टर तक पहुंचती है। न डिग्री और न PageRank केंद्र को उजागर करता है।', + 'graphReach.loading': 'पहुंच की गणना हो रही है…', + 'graphReach.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'graphReach.retry': 'पुनः प्रयास', + 'graphReach.empty': 'अभी तक कोई ज्ञान ग्राफ नहीं है।', + 'graphReach.emptyHint': + 'जैसे-जैसे सहायक आपके बारे में जुड़े हुए तथ्य दर्ज करेगा, आपकी स्मृति की आकृति और केंद्र यहाँ दिखेगा।', + 'graphReach.namespaceLabel': 'नेमस्पेस', + 'graphReach.namespaceAll': 'सभी नेमस्पेस', + 'graphReach.metricEntities': 'इकाइयाँ', + 'graphReach.metricDiameter': 'व्यास', + 'graphReach.metricRadius': 'त्रिज्या', + 'graphReach.summaryCaption': '{components} घटक · सबसे बड़े में {giant}', + 'graphReach.summaryCaptionOne': '1 घटक · {giant} इकाइयाँ', + 'graphReach.summaryCaptionOneAndOne': '1 घटक · 1 इकाई', + 'graphReach.rankedHeading': 'सर्वाधिक केंद्रीय इकाइयाँ', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'इकाई', + 'graphReach.colEccentricity': 'विकेंद्रता', + 'graphReach.colLinks': 'लिंक', + 'graphReach.centerBadge': 'केंद्र', + 'graphReach.centerTitle': + 'अपने क्लस्टर का एक केंद्र — न्यूनतम संभव चरणों में हर जुड़ी इकाई तक पहुंचता है (विकेंद्रता त्रिज्या के बराबर है)।', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c8542b0c47..72825cbbc5 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4451,6 +4451,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Pengelompokan rata-rata {avg} · transitivitas {transitivity}', 'graphCohesion.title': 'Kohesi Graf', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Jangkauan', + 'graphReach.title': 'Jangkauan Graf', + 'graphReach.intro': + 'Eksentrisitas mengukur seberapa jauh suatu entitas dari semua yang bisa dijangkaunya — jalur terpendek terpanjangnya. Diameter adalah celah terlebar, radius adalah yang tersempit, dan pusat adalah entitas yang menjangkau seluruh kluster dalam lompatan paling sedikit. Baik derajat maupun PageRank tidak mengungkapkan pusat.', + 'graphReach.loading': 'Menghitung jangkauan…', + 'graphReach.errorPrefix': 'Tidak dapat memuat graf:', + 'graphReach.retry': 'Coba lagi', + 'graphReach.empty': 'Belum ada graf pengetahuan.', + 'graphReach.emptyHint': + 'Saat asisten mencatat fakta-fakta yang terhubung tentang Anda, bentuk dan pusat memori Anda akan muncul di sini.', + 'graphReach.namespaceLabel': 'Namespace', + 'graphReach.namespaceAll': 'Semua namespace', + 'graphReach.metricEntities': 'Entitas', + 'graphReach.metricDiameter': 'Diameter', + 'graphReach.metricRadius': 'Radius', + 'graphReach.summaryCaption': '{components} komponen · terbesar berisi {giant}', + 'graphReach.summaryCaptionOne': '1 komponen · {giant} entitas', + 'graphReach.summaryCaptionOneAndOne': '1 komponen · 1 entitas', + 'graphReach.rankedHeading': 'Entitas paling sentral', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entitas', + 'graphReach.colEccentricity': 'Eksentrisitas', + 'graphReach.colLinks': 'Tautan', + 'graphReach.centerBadge': 'pusat', + 'graphReach.centerTitle': + 'Pusat klusternya — menjangkau setiap entitas yang terhubung dalam lompatan sesedikit mungkin (eksentrisitas sama dengan radius).', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index abe89fd4c3..bc2b6e23fa 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4512,6 +4512,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Raggruppamento medio {avg} · transitività {transitivity}', 'graphCohesion.title': 'Coesione del grafo', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Raggiungibilità', + 'graphReach.title': 'Raggiungibilità del grafo', + 'graphReach.intro': + "L'eccentricità indica quanto un'entità sia lontana da tutto ciò che può raggiungere — il suo percorso più corto più lungo. Il diametro è il divario più ampio, il raggio il più piccolo, e il centro è l'entità che raggiunge l'intero cluster nel minor numero di salti. Né il grado né PageRank evidenziano il centro.", + 'graphReach.loading': 'Calcolo raggiungibilità…', + 'graphReach.errorPrefix': 'Impossibile caricare il grafo:', + 'graphReach.retry': 'Riprova', + 'graphReach.empty': 'Nessun grafo della conoscenza ancora.', + 'graphReach.emptyHint': + "Man mano che l'assistente registra fatti connessi su di te, la forma e il centro della tua memoria appariranno qui.", + 'graphReach.namespaceLabel': 'Namespace', + 'graphReach.namespaceAll': 'Tutti i namespace', + 'graphReach.metricEntities': 'Entità', + 'graphReach.metricDiameter': 'Diametro', + 'graphReach.metricRadius': 'Raggio', + 'graphReach.summaryCaption': '{components} componenti · il più grande ha {giant}', + 'graphReach.summaryCaptionOne': '1 componente · {giant} entità', + 'graphReach.summaryCaptionOneAndOne': '1 componente · 1 entità', + 'graphReach.rankedHeading': 'Entità più centrali', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entità', + 'graphReach.colEccentricity': 'Eccentricità', + 'graphReach.colLinks': 'Connessioni', + 'graphReach.centerBadge': 'centro', + 'graphReach.centerTitle': + "Un centro del suo cluster — raggiunge ogni entità connessa nel minor numero possibile di salti (l'eccentricità è uguale al raggio).", }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 855b0f4c45..adf7a43a60 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4401,6 +4401,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': '평균 군집계수 {avg} · 전이성 {transitivity}', 'graphCohesion.title': '그래프 응집도', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': '도달 범위', + 'graphReach.title': '그래프 도달 범위', + 'graphReach.intro': + '이심률은 엔티티가 도달할 수 있는 모든 것으로부터 얼마나 멀리 있는지를 나타냅니다 — 가장 긴 최단 경로. 지름은 가장 넓은 간격이고, 반지름은 가장 작은 간격이며, 중심은 가장 적은 홉으로 전체 클러스터에 도달하는 엔티티입니다. 차수도 PageRank도 중심을 드러내지 못합니다.', + 'graphReach.loading': '도달 범위 계산 중…', + 'graphReach.errorPrefix': '그래프를 불러올 수 없습니다:', + 'graphReach.retry': '재시도', + 'graphReach.empty': '아직 지식 그래프가 없습니다.', + 'graphReach.emptyHint': + '어시스턴트가 당신에 대한 연결된 사실을 기록하면, 메모리의 형태와 중심이 여기에 표시됩니다.', + 'graphReach.namespaceLabel': '네임스페이스', + 'graphReach.namespaceAll': '모든 네임스페이스', + 'graphReach.metricEntities': '엔티티', + 'graphReach.metricDiameter': '지름', + 'graphReach.metricRadius': '반지름', + 'graphReach.summaryCaption': '{components}개 구성요소 · 가장 큰 것에 {giant}개', + 'graphReach.summaryCaptionOne': '구성요소 1개 · {giant}개 엔티티', + 'graphReach.summaryCaptionOneAndOne': '구성요소 1개 · 엔티티 1개', + 'graphReach.rankedHeading': '가장 중심적인 엔티티', + 'graphReach.colRank': '#', + 'graphReach.colEntity': '엔티티', + 'graphReach.colEccentricity': '이심률', + 'graphReach.colLinks': '링크', + 'graphReach.centerBadge': '중심', + 'graphReach.centerTitle': + '클러스터의 중심 — 가능한 가장 적은 홉으로 연결된 모든 엔티티에 도달합니다 (이심률이 반지름과 같음).', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 27c9d94351..5c9b6f1270 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4509,6 +4509,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Średnia klasteryzacja {avg} · tranzytywność {transitivity}', 'graphCohesion.title': 'Spójność grafu', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Zasięg', + 'graphReach.title': 'Zasięg grafu', + 'graphReach.intro': + 'Ekscentryczność mierzy, jak daleko wierzchołek jest od wszystkiego, co może osiągnąć — jego najdłuższa najkrótsza ścieżka. Średnica to największa taka odległość, promień — najmniejsza, a centrum to wierzchołek, który dociera do całego klastra w najmniejszej liczbie skoków. Ani stopień, ani PageRank nie ujawniają centrum.', + 'graphReach.loading': 'Obliczanie zasięgu…', + 'graphReach.errorPrefix': 'Nie udało się załadować grafu:', + 'graphReach.retry': 'Ponów', + 'graphReach.empty': 'Brak grafu wiedzy.', + 'graphReach.emptyHint': + 'Gdy asystent zarejestruje powiązane fakty na Twój temat, kształt i centrum Twojej pamięci pojawią się tutaj.', + 'graphReach.namespaceLabel': 'Przestrzeń nazw', + 'graphReach.namespaceAll': 'Wszystkie przestrzenie nazw', + 'graphReach.metricEntities': 'Encje', + 'graphReach.metricDiameter': 'Średnica', + 'graphReach.metricRadius': 'Promień', + 'graphReach.summaryCaption': '{components} składowych · największa zawiera {giant}', + 'graphReach.summaryCaptionOne': '1 składowa · {giant} encji', + 'graphReach.summaryCaptionOneAndOne': '1 składowa · 1 encja', + 'graphReach.rankedHeading': 'Najbardziej centralne encje', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Encja', + 'graphReach.colEccentricity': 'Ekscentryczność', + 'graphReach.colLinks': 'Powiązania', + 'graphReach.centerBadge': 'centrum', + 'graphReach.centerTitle': + 'Centrum swojego klastra — dociera do każdej połączonej encji w możliwie najmniejszej liczbie skoków (ekscentryczność równa promieniowi).', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index e0d058d990..8ca355b368 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4508,6 +4508,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Agrupamento médio {avg} · transitividade {transitivity}', 'graphCohesion.title': 'Coesão do grafo', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Alcance', + 'graphReach.title': 'Alcance do grafo', + 'graphReach.intro': + 'A excentricidade mede o quão longe uma entidade está de tudo o que pode alcançar — seu caminho mais curto mais longo. O diâmetro é a maior dessas distâncias, o raio é a menor, e o centro é a entidade que alcança todo o cluster em menos saltos. Nem o grau nem o PageRank revelam o centro.', + 'graphReach.loading': 'A calcular alcance…', + 'graphReach.errorPrefix': 'Não foi possível carregar o grafo:', + 'graphReach.retry': 'Tentar novamente', + 'graphReach.empty': 'Ainda não há grafo de conhecimento.', + 'graphReach.emptyHint': + 'À medida que o assistente regista factos conectados sobre si, a forma e o centro da sua memória aparecerão aqui.', + 'graphReach.namespaceLabel': 'Espaço de nomes', + 'graphReach.namespaceAll': 'Todos os espaços de nomes', + 'graphReach.metricEntities': 'Entidades', + 'graphReach.metricDiameter': 'Diâmetro', + 'graphReach.metricRadius': 'Raio', + 'graphReach.summaryCaption': '{components} componentes · o maior tem {giant}', + 'graphReach.summaryCaptionOne': '1 componente · {giant} entidades', + 'graphReach.summaryCaptionOneAndOne': '1 componente · 1 entidade', + 'graphReach.rankedHeading': 'Entidades mais centrais', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Entidade', + 'graphReach.colEccentricity': 'Excentricidade', + 'graphReach.colLinks': 'Ligações', + 'graphReach.centerBadge': 'centro', + 'graphReach.centerTitle': + 'Um centro do seu cluster — alcança cada entidade conectada no menor número possível de saltos (a excentricidade é igual ao raio).', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 4082908b76..1b26814d0b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4478,6 +4478,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Средняя кластеризация {avg} · транзитивность {transitivity}', 'graphCohesion.title': 'Связность графа', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': 'Охват', + 'graphReach.title': 'Охват графа', + 'graphReach.intro': + 'Эксцентриситет показывает, насколько далеко вершина находится от всего, чего она может достичь — её самый длинный кратчайший путь. Диаметр — это наибольший такой разрыв, радиус — наименьший, а центр — вершина, которая достигает всего кластера за наименьшее число шагов. Ни степень, ни PageRank не выявляют центр.', + 'graphReach.loading': 'Вычисление охвата…', + 'graphReach.errorPrefix': 'Не удалось загрузить граф:', + 'graphReach.retry': 'Повторить', + 'graphReach.empty': 'Граф знаний пока отсутствует.', + 'graphReach.emptyHint': + 'По мере того как ассистент записывает связанные факты о вас, форма и центр вашей памяти появятся здесь.', + 'graphReach.namespaceLabel': 'Пространство имён', + 'graphReach.namespaceAll': 'Все пространства имён', + 'graphReach.metricEntities': 'Сущности', + 'graphReach.metricDiameter': 'Диаметр', + 'graphReach.metricRadius': 'Радиус', + 'graphReach.summaryCaption': '{components} компонент · наибольший содержит {giant}', + 'graphReach.summaryCaptionOne': '1 компонент · {giant} сущностей', + 'graphReach.summaryCaptionOneAndOne': '1 компонент · 1 сущность', + 'graphReach.rankedHeading': 'Наиболее центральные сущности', + 'graphReach.colRank': '#', + 'graphReach.colEntity': 'Сущность', + 'graphReach.colEccentricity': 'Эксцентриситет', + 'graphReach.colLinks': 'Связи', + 'graphReach.centerBadge': 'центр', + 'graphReach.centerTitle': + 'Центр своего кластера — достигает каждой связанной сущности за наименьшее возможное число шагов (эксцентриситет равен радиусу).', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f1f2a1cfa6..29abd426ab 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4224,6 +4224,30 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': '平均聚类系数 {avg} · 传递性 {transitivity}', 'graphCohesion.title': '图的凝聚度', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.reach': '可达性', + 'graphReach.title': '图的可达性', + 'graphReach.intro': + '离心率衡量一个实体与其所能到达的一切之间的距离——其最长的最短路径。直径是最大的此类间距,半径是最小的,中心则是以最少跳数到达整个集群的实体。度数和 PageRank 均无法揭示中心。', + 'graphReach.loading': '正在计算可达性…', + 'graphReach.errorPrefix': '无法加载图:', + 'graphReach.retry': '重试', + 'graphReach.empty': '尚无知识图谱。', + 'graphReach.emptyHint': '当助手记录关于您的关联事实后,您的记忆形态与中心将在此显示。', + 'graphReach.namespaceLabel': '命名空间', + 'graphReach.namespaceAll': '所有命名空间', + 'graphReach.metricEntities': '实体', + 'graphReach.metricDiameter': '直径', + 'graphReach.metricRadius': '半径', + 'graphReach.summaryCaption': '{components} 个连通分量 · 最大的包含 {giant}', + 'graphReach.summaryCaptionOne': '1 个连通分量 · {giant} 个实体', + 'graphReach.summaryCaptionOneAndOne': '1 个连通分量 · 1 个实体', + 'graphReach.rankedHeading': '最核心的实体', + 'graphReach.colRank': '#', + 'graphReach.colEntity': '实体', + 'graphReach.colEccentricity': '离心率', + 'graphReach.colLinks': '连接数', + 'graphReach.centerBadge': '中心', + 'graphReach.centerTitle': '其集群的中心——以最少跳数到达所有相连实体(离心率等于半径)。', }; export default messages; diff --git a/app/src/lib/memory/graphReach.test.ts b/app/src/lib/memory/graphReach.test.ts new file mode 100644 index 0000000000..059aec9caa --- /dev/null +++ b/app/src/lib/memory/graphReach.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeGraphReach } from './graphReach'; + +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 node(result: ReturnType, id: string) { + const n = result.nodes.find(x => x.id === id); + if (!n) throw new Error(`node ${id} not found`); + return n; +} + +describe('computeGraphReach — basic shapes', () => { + it('returns an empty result for no relations', () => { + const r = computeGraphReach([]); + expect(r.nodeCount).toBe(0); + expect(r.edgeCount).toBe(0); + expect(r.componentCount).toBe(0); + expect(r.diameter).toBe(0); + expect(r.radius).toBe(0); + expect(r.giantComponentSize).toBe(0); + expect(r.nodes).toEqual([]); + expect(r.components).toEqual([]); + }); + + it('a triangle has diameter 1, radius 1, every node a center', () => { + const r = computeGraphReach([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.diameter).toBe(1); + expect(r.radius).toBe(1); + for (const id of ['A', 'B', 'C']) { + expect(node(r, id).eccentricity).toBe(1); + expect(node(r, id).isCenter).toBe(true); + } + }); + + it('a path A-B-C-D: diameter 3, radius 2, center {B,C}', () => { + const r = computeGraphReach([rel('A', 'B'), rel('B', 'C'), rel('C', 'D')]); + expect(node(r, 'A').eccentricity).toBe(3); + expect(node(r, 'B').eccentricity).toBe(2); + expect(node(r, 'C').eccentricity).toBe(2); + expect(node(r, 'D').eccentricity).toBe(3); + expect(r.diameter).toBe(3); + expect(r.radius).toBe(2); + expect(node(r, 'B').isCenter).toBe(true); + expect(node(r, 'C').isCenter).toBe(true); + expect(node(r, 'A').isCenter).toBe(false); + expect(node(r, 'D').isCenter).toBe(false); + }); + + it('a star: the hub is the sole center (eccentricity 1), leaves are 2', () => { + const r = computeGraphReach([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + expect(node(r, 'X').eccentricity).toBe(1); + expect(node(r, 'A').eccentricity).toBe(2); + expect(r.diameter).toBe(2); + expect(r.radius).toBe(1); + expect(node(r, 'X').isCenter).toBe(true); + expect(node(r, 'A').isCenter).toBe(false); + // most-central first in the sort order. + expect(r.nodes[0].id).toBe('X'); + }); +}); + +describe('computeGraphReach — components', () => { + it('reports diameter/radius for the giant component and per-node ecc per component', () => { + // Big component: path P-Q-R-S (diameter 3). Small component: edge Y-Z. + const r = computeGraphReach([rel('P', 'Q'), rel('Q', 'R'), rel('R', 'S'), rel('Y', 'Z')]); + expect(r.componentCount).toBe(2); + expect(r.giantComponentSize).toBe(4); + expect(r.diameter).toBe(3); // from the giant component (the path) + expect(r.radius).toBe(2); + // the small component's own eccentricities stay local (both 1). + expect(node(r, 'Y').eccentricity).toBe(1); + expect(node(r, 'Z').eccentricity).toBe(1); + expect(node(r, 'Y').componentSize).toBe(2); + expect(node(r, 'P').componentSize).toBe(4); + }); + + it('breaks a giant-component size tie by smallest component id', () => { + // Two disjoint edges of equal size; A-B (smaller ids) wins the giant slot. + const r = computeGraphReach([rel('C', 'D'), rel('A', 'B')]); + expect(r.componentCount).toBe(2); + expect(r.giantComponentSize).toBe(2); + // components sorted size DESC then id ASC -> A-B component (id 0) first. + expect(r.components[0].id).toBe(0); + expect(r.diameter).toBe(1); + }); +}); + +describe('computeGraphReach — normalization & determinism', () => { + it('drops the self-loop EDGE but keeps the endpoint as a node', () => { + const r = computeGraphReach([rel('A', 'A'), rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.nodeCount).toBe(3); + expect(r.edgeCount).toBe(3); + expect(r.diameter).toBe(1); // plain triangle + }); + + it('preserves an entity whose only relation is a self-loop (singleton component)', () => { + // A user with the single fact "Alice→Alice" must still see Alice in the + // graph as a singleton component (size 1, eccentricity 0), not vanish. + const r = computeGraphReach([rel('Alice', 'Alice')]); + expect(r.nodeCount).toBe(1); + expect(r.edgeCount).toBe(0); + expect(r.componentCount).toBe(1); + expect(r.giantComponentSize).toBe(1); + expect(r.diameter).toBe(0); + expect(r.radius).toBe(0); + expect(r.nodes[0]).toMatchObject({ + id: 'Alice', + degree: 0, + eccentricity: 0, + isCenter: true, // 0 === radius -> Alice is the (trivial) center of itself + }); + }); + + it('collapses parallel edges and ignores direction', () => { + const r = computeGraphReach([ + rel('A', 'B', 'knows'), + rel('B', 'A', 'likes'), + rel('A', 'B', 'trusts'), + rel('B', 'C'), + ]); + expect(r.edgeCount).toBe(2); // A-B, B-C + expect(r.diameter).toBe(2); // path A-B-C + }); + + it('drops malformed relations (non-string subject/object)', () => { + // Lock both branches of the isRelation guard: a non-string object AND a + // non-string subject must each be rejected, leaving only the A-B-C path. + const malformedObject = { ...rel('A', 'B'), object: null as unknown as string }; + const malformedSubject = { ...rel('A', 'B'), subject: undefined as unknown as string }; + const r = computeGraphReach([rel('A', 'B'), rel('B', 'C'), malformedObject, malformedSubject]); + expect(r.nodeCount).toBe(3); + expect(r.diameter).toBe(2); + }); + + it('treats "Alice" and "alice" as distinct nodes (no case-folding)', () => { + const r = computeGraphReach([rel('Alice', 'Bob'), rel('alice', 'Bob')]); + expect(r.nodeCount).toBe(3); + expect(node(r, 'Bob').eccentricity).toBe(1); // Bob reaches both in 1 hop + }); + + it('is order-independent: shuffled input yields identical output', () => { + const edges = [ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'D'), + rel('D', 'E'), + rel('B', 'E'), + rel('X', 'Y'), + ]; + const forward = computeGraphReach(edges); + const reversed = computeGraphReach([...edges].reverse()); + const rotated = computeGraphReach([...edges.slice(3), ...edges.slice(0, 3)]); + expect(reversed).toEqual(forward); + expect(rotated).toEqual(forward); + }); +}); diff --git a/app/src/lib/memory/graphReach.ts b/app/src/lib/memory/graphReach.ts new file mode 100644 index 0000000000..5e1fbc772a --- /dev/null +++ b/app/src/lib/memory/graphReach.ts @@ -0,0 +1,247 @@ +/** + * Graph Reach — pure eccentricity / diameter / radius engine. + * + * The memory knowledge graph is a set of (subject)-[predicate]->(object) + * triples. Where the Connection-Path lens answers "is A linked to B, and how + * far", this lens answers the SUMMARY question: "how far is each entity from + * everything else it can reach". For a node v, its ECCENTRICITY is the longest + * shortest-path from v to any other node in its connected component. From that + * fall out the classic shape metrics: + * - DIAMETER — the longest shortest-path anywhere (max eccentricity), + * - RADIUS — the smallest eccentricity, + * - CENTER — the nodes whose eccentricity equals the radius: the entities + * from which the whole cluster is reachable in the fewest hops. + * + * Why it matters: the center of your knowledge is not the highest-degree node + * nor the highest-PageRank node — it is the one that minimises the worst-case + * distance to everything else, the natural place a traversal or summary should + * start. Neither degree nor PageRank surfaces it. + * + * Diameter/radius are reported for the LARGEST connected component (the "giant + * component"); per-node eccentricity and the center flag are always computed + * WITHIN each node's own component, so every component has a well-defined + * center even when the graph is fragmented. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. BFS distances are exact graph invariants and the vertex set is + * canonically id-sorted, so the same graph always yields byte-identical numbers + * — never dependent on insertion order — 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 sibling lenses, so "Alice" / "alice" stay distinct nodes. + * - The graph is UNDIRECTED and SIMPLE: direction is dropped, parallel edges + * collapse to one, and self-loop EDGES (subject === object) are dropped — + * the endpoint is still REGISTERED as a node, so a loop-only entity still + * appears as a singleton component (size 1, eccentricity 0) rather than + * vanishing into an empty result. + * - componentId is the SMALLEST id-sorted index in the component, so it is a + * stable label; the giant component breaks size ties by smallest componentId. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface ReachNode { + id: string; + degree: number; // undirected degree in the full graph + eccentricity: number; // longest shortest-path within its component + componentId: number; // smallest member index = stable component label + componentSize: number; // number of nodes in its component + isCenter: boolean; // eccentricity === its component's radius +} + +export interface ReachComponent { + id: number; // smallest member index + size: number; + diameter: number; // max eccentricity in this component + radius: number; // min eccentricity in this component +} + +export interface ReachResult { + nodes: ReachNode[]; // sorted eccentricity ASC, then degree DESC, then id ASC + nodeCount: number; + edgeCount: number; // distinct undirected edges (self-loops excluded) + componentCount: number; + components: ReachComponent[]; // sorted size DESC, then id ASC + giantComponentSize: number; // size of the largest component (0 if empty) + diameter: number; // diameter of the largest component (0 if empty) + radius: number; // radius of the largest component (0 if empty) +} + +function isRelation(relation: GraphRelation): boolean { + return typeof relation.subject === 'string' && typeof relation.object === 'string'; +} + +/** Undirected simple-graph adjacency (self-loops and parallel edges removed). */ +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) { + // Self-loop: register the endpoint as a node but add no self-edge — a + // loop-only entity (e.g. "Alice→Alice") still appears as a singleton + // component with eccentricity 0 rather than vanishing into empty state. + neighbours(subject); + continue; + } + neighbours(subject).add(object); + neighbours(object).add(subject); + } + return adjacency; +} + +/** Breadth-first eccentricity from `source`: the max distance to any node it + * reaches. Uses an array-backed queue with a head cursor (no shift cost). */ +function eccentricityFrom(source: number, neighbours: number[][]): number { + const distance = new Array(neighbours.length).fill(-1); + distance[source] = 0; + const queue = [source]; + let head = 0; + let max = 0; + while (head < queue.length) { + const node = queue[head]; + head += 1; + const d = distance[node]; + if (d > max) max = d; + for (const next of neighbours[node]) { + if (distance[next] === -1) { + distance[next] = d + 1; + queue.push(next); + } + } + } + return max; +} + +/** Label connected components; componentLabel[i] is the smallest index in i's + * component. Iterating in ascending index order makes each component's first- + * seen node its smallest member. */ +function labelComponents(neighbours: number[][]): number[] { + const n = neighbours.length; + const label = new Array(n).fill(-1); + for (let start = 0; start < n; start += 1) { + if (label[start] !== -1) continue; + label[start] = start; + const queue = [start]; + let head = 0; + while (head < queue.length) { + const node = queue[head]; + head += 1; + for (const next of neighbours[node]) { + if (label[next] === -1) { + label[next] = start; + queue.push(next); + } + } + } + } + return label; +} + +/** + * Safety cap: BFS from every node is O(V·(V+E)); beyond this many nodes the + * computation can freeze the browser tab. Callers should pre-check with + * `isGraphReachFeasible` and show a warning instead of blocking the UI. + */ +export const MAX_REACH_NODES = 5_000; + +/** Quick check: will `computeGraphReach` be fast enough to run inline? */ +export function isGraphReachFeasible(relations: GraphRelation[]): boolean { + const seen = new Set(); + for (const r of relations) { + if (!isRelation(r)) continue; + seen.add(r.subject); + seen.add(r.object); + if (seen.size > MAX_REACH_NODES) return false; + } + return true; +} + +/** Compute eccentricity / diameter / radius over the memory graph. PURE. */ +export function computeGraphReach(relations: GraphRelation[]): ReachResult { + const adjacency = buildAdjacency(relations); + + // Canonical, id-sorted vertex ordering -> reproducible indices & labels. + const ids = [...adjacency.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const indexOf = new Map(); + ids.forEach((id, i) => indexOf.set(id, i)); + + let edgeDegreeSum = 0; + const neighbours: number[][] = ids.map(id => { + const set = adjacency.get(id); + const list: number[] = []; + if (set) { + for (const other of set) { + const idx = indexOf.get(other); + if (idx !== undefined) list.push(idx); + } + } + edgeDegreeSum += list.length; + return list; + }); + + const label = labelComponents(neighbours); + const eccentricity = ids.map((_, i) => eccentricityFrom(i, neighbours)); + + // Per-component aggregates: size, radius (min ecc), diameter (max ecc). + const sizeByComponent = new Map(); + const radiusByComponent = new Map(); + const diameterByComponent = new Map(); + for (let i = 0; i < ids.length; i += 1) { + const c = label[i]; + const ecc = eccentricity[i]; + sizeByComponent.set(c, (sizeByComponent.get(c) ?? 0) + 1); + const curMin = radiusByComponent.get(c); + if (curMin === undefined || ecc < curMin) radiusByComponent.set(c, ecc); + const curMax = diameterByComponent.get(c); + if (curMax === undefined || ecc > curMax) diameterByComponent.set(c, ecc); + } + + const nodes: ReachNode[] = ids.map((id, i) => { + const c = label[i]; + return { + id, + degree: neighbours[i].length, + eccentricity: eccentricity[i], + componentId: c, + componentSize: sizeByComponent.get(c) ?? 1, + isCenter: eccentricity[i] === radiusByComponent.get(c), + }; + }); + + nodes.sort((a, b) => { + if (a.eccentricity !== b.eccentricity) return a.eccentricity - b.eccentricity; + if (b.degree !== a.degree) return b.degree - a.degree; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + + const components: ReachComponent[] = [...sizeByComponent.entries()] + .map(([id, size]) => ({ + id, + size, + diameter: diameterByComponent.get(id) ?? 0, + radius: radiusByComponent.get(id) ?? 0, + })) + .sort((a, b) => (b.size !== a.size ? b.size - a.size : a.id - b.id)); + + const giant = components[0]; + + return { + nodes, + nodeCount: ids.length, + edgeCount: edgeDegreeSum / 2, + componentCount: components.length, + components, + giantComponentSize: giant ? giant.size : 0, + diameter: giant ? giant.diameter : 0, + radius: giant ? giant.radius : 0, + }; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 9235646219..7d4a97b1dc 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -6,6 +6,7 @@ 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 GraphReachTab from '../components/intelligence/GraphReachTab'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab'; import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab'; @@ -35,6 +36,7 @@ type IntelligenceTab = | 'diagram' | 'centrality' | 'cohesion' + | 'reach' | 'associations' | 'freshness' | 'timeline' @@ -121,6 +123,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: 'reach', label: t('memory.tab.reach') }, { id: 'associations', label: t('memory.tab.associations') }, { id: 'freshness', label: t('memory.tab.freshness') }, { id: 'timeline', label: t('memory.tab.timeline') }, @@ -220,6 +223,8 @@ export default function Intelligence() { {activeTab === 'cohesion' && } + {activeTab === 'reach' && } + {activeTab === 'associations' && } {activeTab === 'freshness' && } diff --git a/app/src/services/api/graphReachApi.test.ts b/app/src/services/api/graphReachApi.test.ts new file mode 100644 index 0000000000..528096669a --- /dev/null +++ b/app/src/services/api/graphReachApi.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphReach } from '../../lib/memory/graphReach'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { graphReachApi, loadNamespaces, loadReach } from './graphReachApi'; + +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('graphReachApi.loadReach', () => { + 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', 'D')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadReach('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeGraphReach(triples)); + expect(out.diameter).toBe(3); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadReach(); + 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(loadReach()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('graphReachApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('graphReachApi object', () => { + it('exposes the public surface', () => { + expect(typeof graphReachApi.loadReach).toBe('function'); + expect(typeof graphReachApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/graphReachApi.ts b/app/src/services/api/graphReachApi.ts new file mode 100644 index 0000000000..fa67d12499 --- /dev/null +++ b/app/src/services/api/graphReachApi.ts @@ -0,0 +1,29 @@ +/** + * RPC facade for Graph Reach (eccentricity / diameter / radius). + * + * 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 { computeGraphReach, type ReachResult } from '../../lib/memory/graphReach'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('graph-reach:api'); + +/** Fetch the graph relations for a namespace (or all) and compute reach. */ +export async function loadReach(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadReach namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeGraphReach(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const graphReachApi = { loadReach, loadNamespaces };