From ce8158031ee6bfff56ccc54d884305922ce5f1fe Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Sat, 30 May 2026 02:35:28 +0500 Subject: [PATCH] feat(intelligence): add Graph Bridges (Tarjan articulations & cut edges) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new read-only "Bridges" tab for the Intelligence view: Tarjan's lowlink algorithm identifies the EXACT topological single-points-of-failure in the memory knowledge graph: - Articulation points (cut vertices): entities whose removal would disconnect the graph. - Bridges (cut edges): relations whose removal would disconnect the graph. An entity may have modest degree yet be the sole link holding two large clusters together — losing it shatters more knowledge than a higher-degree non-articulation. The centrality lens approximates this with PageRank-gap heuristics; this lens computes it exactly. Engine (pure, deterministic — no React/RPC/clock/RNG): - iterative DFS (no JS call-stack risk on long paths), - correct handling of disconnected graphs, self-loop-only entities, parallel edges, malformed rows, and non-string subject/object guards, - bridges emitted in canonical (lex-smaller-id-first) form. Vertices are canonically id-sorted and neighbour lists are deterministically sorted, so output is byte-identical regardless of input order. Adds ZERO new core surface: composes the already-shipped memoryGraphQuery / memoryListNamespaces JSON-RPC wrappers. Container/presentational split with a monotonic request-token race guard for load-on-mount; i18n across all 13 locales with a four-way singular/plural switch on the summary caption. Co-Authored-By: Claude Opus 4.7 --- .../intelligence/GraphBridgesPanel.test.tsx | 92 +++++++ .../intelligence/GraphBridgesPanel.tsx | 224 ++++++++++++++++ .../intelligence/GraphBridgesTab.test.tsx | 61 +++++ .../intelligence/GraphBridgesTab.tsx | 83 ++++++ app/src/lib/i18n/ar.ts | 29 +++ app/src/lib/i18n/bn.ts | 30 +++ app/src/lib/i18n/de.ts | 30 +++ app/src/lib/i18n/en.ts | 33 ++- app/src/lib/i18n/es.ts | 30 +++ app/src/lib/i18n/fr.ts | 30 +++ app/src/lib/i18n/hi.ts | 30 +++ app/src/lib/i18n/id.ts | 31 +++ app/src/lib/i18n/it.ts | 30 +++ app/src/lib/i18n/ko.ts | 29 +++ app/src/lib/i18n/pl.ts | 30 +++ app/src/lib/i18n/pt.ts | 30 +++ app/src/lib/i18n/ru.ts | 30 +++ app/src/lib/i18n/zh-CN.ts | 27 ++ app/src/lib/memory/graphBridges.test.ts | 189 ++++++++++++++ app/src/lib/memory/graphBridges.ts | 242 ++++++++++++++++++ app/src/services/api/graphBridgesApi.test.ts | 74 ++++++ app/src/services/api/graphBridgesApi.ts | 29 +++ 22 files changed, 1412 insertions(+), 1 deletion(-) create mode 100644 app/src/components/intelligence/GraphBridgesPanel.test.tsx create mode 100644 app/src/components/intelligence/GraphBridgesPanel.tsx create mode 100644 app/src/components/intelligence/GraphBridgesTab.test.tsx create mode 100644 app/src/components/intelligence/GraphBridgesTab.tsx create mode 100644 app/src/lib/memory/graphBridges.test.ts create mode 100644 app/src/lib/memory/graphBridges.ts create mode 100644 app/src/services/api/graphBridgesApi.test.ts create mode 100644 app/src/services/api/graphBridgesApi.ts diff --git a/app/src/components/intelligence/GraphBridgesPanel.test.tsx b/app/src/components/intelligence/GraphBridgesPanel.test.tsx new file mode 100644 index 0000000000..da52dd900a --- /dev/null +++ b/app/src/components/intelligence/GraphBridgesPanel.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeGraphBridges } from '../../lib/memory/graphBridges'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphBridgesPanel from './GraphBridgesPanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// Two triangles joined by a single edge: C and D are articulations; (C,D) is +// the only bridge. articulationCount=2, bridges=[{a:'C',b:'D'}]. +const joined = computeGraphBridges([ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'A'), + rel('D', 'E'), + rel('E', 'F'), + rel('F', 'D'), + rel('C', 'D'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('graph-bridges-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, articulation badges, and the bridge list', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Connections')).toBeInTheDocument(); + expect(screen.getByText('Articulations')).toBeInTheDocument(); + expect(screen.getByText('Articulation entities')).toBeInTheDocument(); + expect(screen.getByText('Bridge relations')).toBeInTheDocument(); + // both articulation badges present. + expect(screen.getAllByText('cut vertex')).toHaveLength(2); + // single bridge row C — D: each id appears in BOTH the articulation table + // row and the bridge list, so use getAllByText with the matching length. + expect(screen.getAllByText('C')).toHaveLength(2); + expect(screen.getAllByText('D')).toHaveLength(2); + // exactly one bridge and one connected component -> the singular-bridge, + // singular-component caption is rendered (no "1 bridges" ungrammar). + expect(screen.getByText('1 bridge · single component')).toBeInTheDocument(); + }); + + it('uses the plural caption when there are 2+ bridges and 2+ components', () => { + // Path A-B-C (2 bridges, 1 component) plus disconnected Y-Z (1 bridge, 2nd + // component). Total: 3 bridges across 2 components -> fully plural caption. + const multi = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('Y', 'Z')]); + render(); + expect(screen.getByText('3 bridges · 2 components')).toBeInTheDocument(); + }); + + it('shows the no-fragiles / no-bridges notes for a fully-cyclic graph', () => { + const triangle = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + render(); + expect( + screen.getByText( + 'No structural single-points-of-failure — every link sits in at least one cycle.' + ) + ).toBeInTheDocument(); + expect( + screen.getByText('No bridges — every relation sits in at least one cycle.') + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/GraphBridgesPanel.tsx b/app/src/components/intelligence/GraphBridgesPanel.tsx new file mode 100644 index 0000000000..2dc3910e9e --- /dev/null +++ b/app/src/components/intelligence/GraphBridgesPanel.tsx @@ -0,0 +1,224 @@ +/** + * Graph Bridges — presentational view. Pure: renders the cut summary tiles + * (entities / connections / articulations), the articulation entity list, and + * the bridge relations list. No data fetching, no clock, no randomness. + */ +import { useMemo } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { BridgeResult } from '../../lib/memory/graphBridges'; + +const MAX_ROWS = 25; + +interface GraphBridgesPanelProps { + result: BridgeResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const GraphBridgesPanel = ({ result, loading, error, onRetry }: GraphBridgesPanelProps) => { + const { t } = useT(); + + const articulationRows = useMemo( + () => (result ? result.nodes.filter(n => n.isArticulation).slice(0, MAX_ROWS) : []), + [result] + ); + const bridgeRows = useMemo(() => (result ? result.bridges.slice(0, MAX_ROWS) : []), [result]); + + const intro = ( +
+

{t('graphBridges.title')}

+

{t('graphBridges.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + // Four-way singular/plural switch over (bridges, components) — keeps the + // English caption grammatical for the common single-bridge / single-component + // cases instead of rendering "1 bridges" / "1 components". + const bridgesCount = String(result.bridges.length); + const componentsCount = String(result.componentCount); + const oneBridge = result.bridges.length === 1; + const oneComponent = result.componentCount === 1; + const summaryCaption = oneBridge + ? oneComponent + ? t('graphBridges.summaryCaptionOneBridgeOneComponent') + : t('graphBridges.summaryCaptionOneBridge').replace('{components}', componentsCount) + : oneComponent + ? t('graphBridges.summaryCaptionOne').replace('{bridges}', bridgesCount) + : t('graphBridges.summaryCaption') + .replace('{bridges}', bridgesCount) + .replace('{components}', componentsCount); + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('graphBridges.metricEntities'), value: result.nodeCount }, + { label: t('graphBridges.metricConnections'), value: result.edgeCount }, + { label: t('graphBridges.metricArticulations'), value: result.articulationCount }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

{summaryCaption}

+ + {/* Articulation entities */} +
+

+ {t('graphBridges.articulationsHeading')} +

+ {articulationRows.length === 0 ? ( +

+ {t('graphBridges.noFragiles')} +

+ ) : ( + + + + + + + + + + {articulationRows.map((node, i) => ( + + + + + + ))} + +
+ {t('graphBridges.colRank')} + + {t('graphBridges.colEntity')} + + {t('graphBridges.colLinks')} +
{i + 1} + {node.id} + + {t('graphBridges.articulationBadge')} + + + {node.degree} +
+ )} +
+ + {/* Bridge relations */} +
+

+ {t('graphBridges.bridgesHeading')} +

+ {bridgeRows.length === 0 ? ( +

+ {t('graphBridges.noBridges')} +

+ ) : ( +
    + {bridgeRows.map(edge => ( +
  • + {edge.a} + + {edge.b} +
  • + ))} +
+ )} +
+
+ ); +}; + +export default GraphBridgesPanel; diff --git a/app/src/components/intelligence/GraphBridgesTab.test.tsx b/app/src/components/intelligence/GraphBridgesTab.test.tsx new file mode 100644 index 0000000000..70c0481034 --- /dev/null +++ b/app/src/components/intelligence/GraphBridgesTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphBridges } from '../../lib/memory/graphBridges'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphBridgesTab from './GraphBridgesTab'; + +const mockLoadBridges = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/graphBridgesApi', () => ({ + loadBridges: (...args: unknown[]) => mockLoadBridges(...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 = computeGraphBridges([rel('A', 'B'), rel('B', 'C')]); + +describe('', () => { + beforeEach(() => { + mockLoadBridges.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadBridges.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads bridges (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadBridges).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(screen.getByText('Articulation 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(mockLoadBridges).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadBridges.mockReset(); + mockLoadBridges.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/GraphBridgesTab.tsx b/app/src/components/intelligence/GraphBridgesTab.tsx new file mode 100644 index 0000000000..f67bf02d63 --- /dev/null +++ b/app/src/components/intelligence/GraphBridgesTab.tsx @@ -0,0 +1,83 @@ +/** + * Graph Bridges 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 { BridgeResult } from '../../lib/memory/graphBridges'; +import { loadBridges, loadNamespaces } from '../../services/api/graphBridgesApi'; +import GraphBridgesPanel from './GraphBridgesPanel'; + +const GraphBridgesTab = () => { + 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 loadBridges(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 bridges 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 GraphBridgesTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d5e578503b..74ab569a2c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4350,6 +4350,35 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', 'pages.settings.account.security': 'الأمان', 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', + 'graphBridges.articulationBadge': 'رأس قاطع', + 'graphBridges.articulationTitle': + 'إزالة هذا الكيان ستفصل الرسم البياني: العناقيد التي تمر عبره تفقد الرابط بينها.', + 'graphBridges.articulationsHeading': 'كيانات نقاط القطع', + 'graphBridges.bridgesHeading': 'علاقات الجسور', + 'graphBridges.colEntity': 'الكيان', + 'graphBridges.colLinks': 'روابط', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'لا يوجد رسم معرفة بعد.', + 'graphBridges.emptyHint': + 'كلما سجّل المساعد حقائق مترابطة عنك، ستظهر هنا نقاط الفشل البنيوية الفردية.', + 'graphBridges.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'graphBridges.intro': + 'نقاط القطع هي كيانات تؤدي إزالتها إلى فصل رسم الذاكرة. الجسور هي العلاقات الوحيدة التي تربط النصفين معًا. افقد جسرًا واحدًا فيصمت قطاع كامل من السياق — ثغرة بنيوية لا تكشفها لا الدرجة ولا خوارزمية PageRank.', + 'graphBridges.loading': 'يجري حساب القطوع البنيوية…', + 'graphBridges.metricArticulations': 'نقاط القطع', + 'graphBridges.metricConnections': 'الاتصالات', + 'graphBridges.metricEntities': 'الكيانات', + 'graphBridges.namespaceAll': 'كل مساحات الأسماء', + 'graphBridges.namespaceLabel': 'مساحة الأسماء', + 'graphBridges.noBridges': 'لا توجد جسور — كل علاقة ضمن دورة واحدة على الأقل.', + 'graphBridges.noFragiles': 'لا توجد نقاط فشل بنيوية فردية — كل رابط ضمن دورة واحدة على الأقل.', + 'graphBridges.retry': 'إعادة المحاولة', + 'graphBridges.summaryCaption': '{bridges} جسور · {components} مكوّنات', + 'graphBridges.summaryCaptionOne': '{bridges} جسور · مكوّن واحد', + 'graphBridges.summaryCaptionOneBridge': 'جسر واحد · {components} مكوّنات', + 'graphBridges.summaryCaptionOneBridgeOneComponent': 'جسر واحد · مكوّن واحد', + 'graphBridges.title': 'جسور الرسم البياني', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index fb196b753d..2127e8dcc4 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4427,6 +4427,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', 'pages.settings.account.security': 'নিরাপত্তা', 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', + 'graphBridges.articulationBadge': 'কাট শীর্ষ', + 'graphBridges.articulationTitle': + 'এই সত্তা সরিয়ে দিলে গ্রাফ বিচ্ছিন্ন হয়ে যাবে: এর মধ্য দিয়ে যাওয়া ক্লাস্টারগুলো পরস্পরের লিঙ্ক হারায়।', + 'graphBridges.articulationsHeading': 'আর্টিকুলেশন সত্তা', + 'graphBridges.bridgesHeading': 'ব্রিজ সম্পর্ক', + 'graphBridges.colEntity': 'সত্তা', + 'graphBridges.colLinks': 'লিঙ্ক', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'graphBridges.emptyHint': + 'সহকারী যখন আপনার সম্পর্কে সংযুক্ত তথ্য রেকর্ড করে, কাঠামোগত একক-ব্যর্থতা-বিন্দুগুলো এখানে উঠে আসবে।', + 'graphBridges.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'graphBridges.intro': + 'আর্টিকুলেশন বিন্দু হলো এমন সত্তা যাদের অপসারণ মেমরি গ্রাফকে বিচ্ছিন্ন করে দেবে। ব্রিজ হলো একমাত্র সম্পর্ক যা দুই অর্ধকে একত্রে ধরে রাখে। একটি হারিয়ে ফেললে প্রসঙ্গের একটা পুরো অংশ স্তব্ধ হয়ে যায় — একটি কাঠামোগত দুর্বলতা যা ডিগ্রি বা PageRank কোনটিই উন্মোচন করে না।', + 'graphBridges.loading': 'কাঠামোগত কাট গণনা করা হচ্ছে…', + 'graphBridges.metricArticulations': 'আর্টিকুলেশন', + 'graphBridges.metricConnections': 'সংযোগ', + 'graphBridges.metricEntities': 'সত্তা', + 'graphBridges.namespaceAll': 'সমস্ত নেমস্পেস', + 'graphBridges.namespaceLabel': 'নেমস্পেস', + 'graphBridges.noBridges': 'কোনো ব্রিজ নেই — প্রতিটি সম্পর্ক অন্তত একটি চক্রে আছে।', + 'graphBridges.noFragiles': + 'কোনো কাঠামোগত একক-ব্যর্থতা-বিন্দু নেই — প্রতিটি লিঙ্ক অন্তত একটি চক্রে আছে।', + 'graphBridges.retry': 'পুনরায় চেষ্টা', + 'graphBridges.summaryCaption': '{bridges}টি ব্রিজ · {components}টি উপাদান', + 'graphBridges.summaryCaptionOne': '{bridges}টি ব্রিজ · একক উপাদান', + 'graphBridges.summaryCaptionOneBridge': '১টি ব্রিজ · {components}টি উপাদান', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '১টি ব্রিজ · একক উপাদান', + 'graphBridges.title': 'গ্রাফ ব্রিজ', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4b30e1572b..7ca0177a16 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4543,6 +4543,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', 'pages.settings.account.security': 'Sicherheit', 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', + 'graphBridges.articulationBadge': 'Schnittknoten', + 'graphBridges.articulationTitle': + 'Das Entfernen dieser Entität würde den Graphen trennen: Cluster, die durch sie verlaufen, verlieren ihre Verbindung zueinander.', + 'graphBridges.articulationsHeading': 'Artikulationsentitäten', + 'graphBridges.bridgesHeading': 'Brücken-Relationen', + 'graphBridges.colEntity': 'Entität', + 'graphBridges.colLinks': 'Verknüpfungen', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Noch kein Wissensgraph.', + 'graphBridges.emptyHint': + 'Während der Assistent verbundene Fakten über Sie erfasst, erscheinen hier die strukturellen Single Points of Failure.', + 'graphBridges.errorPrefix': 'Graph konnte nicht geladen werden:', + 'graphBridges.intro': + 'Artikulationspunkte sind Entitäten, deren Entfernung den Speichergraphen trennen würde. Brücken sind die einzigen Relationen, die zwei Hälften zusammenhalten. Verliert man eine, verstummt ein ganzer Kontextbereich — eine strukturelle Schwachstelle, die weder Grad noch PageRank aufdecken.', + 'graphBridges.loading': 'Berechne strukturelle Schnitte…', + 'graphBridges.metricArticulations': 'Artikulationen', + 'graphBridges.metricConnections': 'Verbindungen', + 'graphBridges.metricEntities': 'Entitäten', + 'graphBridges.namespaceAll': 'Alle Namensräume', + 'graphBridges.namespaceLabel': 'Namensraum', + 'graphBridges.noBridges': 'Keine Brücken — jede Relation liegt in mindestens einem Zyklus.', + 'graphBridges.noFragiles': + 'Keine strukturellen Single Points of Failure — jede Verbindung liegt in mindestens einem Zyklus.', + 'graphBridges.retry': 'Wiederholen', + 'graphBridges.summaryCaption': '{bridges} Brücken · {components} Komponenten', + 'graphBridges.summaryCaptionOne': '{bridges} Brücken · eine Komponente', + 'graphBridges.summaryCaptionOneBridge': '1 Brücke · {components} Komponenten', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 Brücke · eine Komponente', + 'graphBridges.title': 'Graph-Brücken', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ba050c75d5..2b08028581 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -307,6 +307,7 @@ const en: TranslationMap = { 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.bridges': 'Bridges', 'memory.tab.settings': 'Settings', 'memory.tab.council': 'Council', 'modelCouncil.title': 'Model Council', @@ -489,6 +490,36 @@ const en: TranslationMap = { 'graphCohesion.brokerTitle': "Structural hole: this entity's neighbours aren't connected to each other — it's the sole link between them.", + 'graphBridges.title': 'Graph Bridges', + 'graphBridges.intro': + 'Articulation points are entities whose removal would disconnect the memory graph. Bridges are the lone relations holding two halves together. Lose one and a swath of context goes silent — a structural vulnerability neither degree nor PageRank surfaces.', + 'graphBridges.loading': 'Computing structural cuts…', + 'graphBridges.errorPrefix': 'Could not load the graph:', + 'graphBridges.retry': 'Retry', + 'graphBridges.empty': 'No knowledge graph yet.', + 'graphBridges.emptyHint': + 'As the assistant records connected facts about you, the structural single-points-of-failure will surface here.', + 'graphBridges.namespaceLabel': 'Namespace', + 'graphBridges.namespaceAll': 'All namespaces', + 'graphBridges.metricEntities': 'Entities', + 'graphBridges.metricConnections': 'Connections', + 'graphBridges.metricArticulations': 'Articulations', + 'graphBridges.summaryCaption': '{bridges} bridges · {components} components', + 'graphBridges.summaryCaptionOne': '{bridges} bridges · single component', + 'graphBridges.summaryCaptionOneBridge': '1 bridge · {components} components', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 bridge · single component', + 'graphBridges.articulationsHeading': 'Articulation entities', + 'graphBridges.noFragiles': + 'No structural single-points-of-failure — every link sits in at least one cycle.', + 'graphBridges.colRank': '#', + 'graphBridges.colEntity': 'Entity', + 'graphBridges.colLinks': 'Links', + 'graphBridges.articulationBadge': 'cut vertex', + 'graphBridges.articulationTitle': + 'Removing this entity would disconnect the graph: clusters that pass through it lose their link to each other.', + 'graphBridges.bridgesHeading': 'Bridge relations', + 'graphBridges.noBridges': 'No bridges — every relation sits in at least one cycle.', + // Memory Tree status panel (#1856 Part 1) 'memoryTree.status.title': 'Memory Tree', 'memoryTree.status.autoSyncLabel': 'Auto-sync', @@ -2500,7 +2531,7 @@ const en: TranslationMap = { 'app.openhumanLink.notifications.send': 'Send test notification', 'app.openhumanLink.notifications.sendFailed': "Couldn't send: {error}", 'app.openhumanLink.notifications.sent': - "Test notification sent. If you didn't receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.", + 'Test notification sent. If you didn’t receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.', 'app.openhumanLink.skipForNow': 'Skip for now', 'app.openhumanLink.telegramUnavailable': 'Telegram unavailable', 'app.openhumanLink.title.accounts': 'Connect your apps', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6feea2220d..75430fdb54 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4509,6 +4509,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rechazar almacenamiento local', 'pages.settings.account.security': 'Seguridad', 'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero', + 'graphBridges.articulationBadge': 'vértice de corte', + 'graphBridges.articulationTitle': + 'Eliminar esta entidad desconectaría el grafo: los grupos que pasan por ella pierden su enlace entre sí.', + 'graphBridges.articulationsHeading': 'Entidades de articulación', + 'graphBridges.bridgesHeading': 'Relaciones puente', + 'graphBridges.colEntity': 'Entidad', + 'graphBridges.colLinks': 'Enlaces', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Aún no hay grafo de conocimiento.', + 'graphBridges.emptyHint': + 'A medida que el asistente registra hechos conectados sobre usted, los puntos únicos de fallo estructural aparecerán aquí.', + 'graphBridges.errorPrefix': 'No se pudo cargar el grafo:', + 'graphBridges.intro': + 'Los puntos de articulación son entidades cuya eliminación desconectaría el grafo de memoria. Los puentes son las únicas relaciones que mantienen unidas dos mitades. Pierda uno y toda una franja de contexto enmudece: una vulnerabilidad estructural que ni el grado ni PageRank revelan.', + 'graphBridges.loading': 'Calculando cortes estructurales…', + 'graphBridges.metricArticulations': 'Articulaciones', + 'graphBridges.metricConnections': 'Conexiones', + 'graphBridges.metricEntities': 'Entidades', + 'graphBridges.namespaceAll': 'Todos los espacios de nombres', + 'graphBridges.namespaceLabel': 'Espacio de nombres', + 'graphBridges.noBridges': 'Sin puentes: cada relación se encuentra en al menos un ciclo.', + 'graphBridges.noFragiles': + 'Sin puntos únicos de fallo estructural: cada enlace se encuentra en al menos un ciclo.', + 'graphBridges.retry': 'Reintentar', + 'graphBridges.summaryCaption': '{bridges} puentes · {components} componentes', + 'graphBridges.summaryCaptionOne': '{bridges} puentes · componente único', + 'graphBridges.summaryCaptionOneBridge': '1 puente · {components} componentes', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 puente · componente único', + 'graphBridges.title': 'Puentes del grafo', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e3b4395ada..aa44e5d76a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4524,6 +4524,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Refuser le stockage local', 'pages.settings.account.security': 'Sécurité', 'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau', + 'graphBridges.articulationBadge': 'sommet coupant', + 'graphBridges.articulationTitle': + 'Supprimer cette entité déconnecterait le graphe : les groupes qui passent par elle perdent leur lien entre eux.', + 'graphBridges.articulationsHeading': "Entités d'articulation", + 'graphBridges.bridgesHeading': 'Relations de pont', + 'graphBridges.colEntity': 'Entité', + 'graphBridges.colLinks': 'Liens', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Pas encore de graphe de connaissances.', + 'graphBridges.emptyHint': + "À mesure que l'assistant enregistre des faits connectés à votre sujet, les points de défaillance structurels uniques apparaîtront ici.", + 'graphBridges.errorPrefix': 'Impossible de charger le graphe :', + 'graphBridges.intro': + "Les points d'articulation sont des entités dont la suppression déconnecterait le graphe mémoire. Les ponts sont les seules relations qui maintiennent ensemble deux moitiés. En perdre un et toute une portion de contexte devient silencieuse — une vulnérabilité structurelle que ni le degré ni le PageRank ne révèlent.", + 'graphBridges.loading': 'Calcul des coupes structurelles…', + 'graphBridges.metricArticulations': 'Articulations', + 'graphBridges.metricConnections': 'Connexions', + 'graphBridges.metricEntities': 'Entités', + 'graphBridges.namespaceAll': 'Tous les espaces de noms', + 'graphBridges.namespaceLabel': 'Espace de noms', + 'graphBridges.noBridges': 'Aucun pont — chaque relation se trouve dans au moins un cycle.', + 'graphBridges.noFragiles': + 'Aucun point de défaillance structurel unique — chaque lien se trouve dans au moins un cycle.', + 'graphBridges.retry': 'Réessayer', + 'graphBridges.summaryCaption': '{bridges} ponts · {components} composantes', + 'graphBridges.summaryCaptionOne': '{bridges} ponts · composante unique', + 'graphBridges.summaryCaptionOneBridge': '1 pont · {components} composantes', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 pont · composante unique', + 'graphBridges.title': 'Ponts du graphe', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b63c812398..aabd380f9e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4434,6 +4434,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', 'pages.settings.account.security': 'सुरक्षा', 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', + 'graphBridges.articulationBadge': 'कट शीर्ष', + 'graphBridges.articulationTitle': + 'इस इकाई को हटाने से ग्राफ टूट जाएगा: इससे गुज़रने वाले क्लस्टर आपस का लिंक खो देते हैं।', + 'graphBridges.articulationsHeading': 'आर्टिक्युलेशन इकाइयाँ', + 'graphBridges.bridgesHeading': 'ब्रिज संबंध', + 'graphBridges.colEntity': 'इकाई', + 'graphBridges.colLinks': 'लिंक', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'अभी तक कोई नॉलेज ग्राफ नहीं।', + 'graphBridges.emptyHint': + 'जैसे-जैसे सहायक आपके बारे में जुड़े हुए तथ्य दर्ज करता है, संरचनात्मक एकल-विफलता-बिंदु यहाँ उभरेंगे।', + 'graphBridges.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'graphBridges.intro': + 'आर्टिक्युलेशन बिंदु वे इकाइयाँ हैं जिन्हें हटाने पर मेमोरी ग्राफ टूट जाएगा। ब्रिज वे एकमात्र संबंध हैं जो दो हिस्सों को आपस में थामे रखते हैं। एक खो दें और संदर्भ का पूरा हिस्सा खामोश हो जाता है — एक संरचनात्मक कमजोरी जिसे न तो डिग्री और न ही PageRank उजागर करता है।', + 'graphBridges.loading': 'संरचनात्मक कट गणना हो रही है…', + 'graphBridges.metricArticulations': 'आर्टिक्युलेशन', + 'graphBridges.metricConnections': 'कनेक्शन', + 'graphBridges.metricEntities': 'इकाइयाँ', + 'graphBridges.namespaceAll': 'सभी नेमस्पेस', + 'graphBridges.namespaceLabel': 'नेमस्पेस', + 'graphBridges.noBridges': 'कोई ब्रिज नहीं — हर संबंध कम-से-कम एक चक्र में है।', + 'graphBridges.noFragiles': + 'कोई संरचनात्मक एकल-विफलता-बिंदु नहीं — हर लिंक कम-से-कम एक चक्र में है।', + 'graphBridges.retry': 'पुनः प्रयास', + 'graphBridges.summaryCaption': '{bridges} ब्रिज · {components} घटक', + 'graphBridges.summaryCaptionOne': '{bridges} ब्रिज · एकल घटक', + 'graphBridges.summaryCaptionOneBridge': '1 ब्रिज · {components} घटक', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 ब्रिज · एकल घटक', + 'graphBridges.title': 'ग्राफ ब्रिज', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index b83ee91377..1b3f9da8b6 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4443,6 +4443,37 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', 'pages.settings.account.security': 'Keamanan', 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', + 'graphBridges.articulationBadge': 'simpul potong', + 'graphBridges.articulationTitle': + 'Menghapus entitas ini akan memutus graf: klaster yang melewatinya kehilangan tautan satu sama lain.', + 'graphBridges.articulationsHeading': 'Entitas artikulasi', + 'graphBridges.bridgesHeading': 'Relasi jembatan', + 'graphBridges.colEntity': 'Entitas', + 'graphBridges.colLinks': 'Tautan', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Belum ada graf pengetahuan.', + 'graphBridges.emptyHint': + 'Saat asisten mencatat fakta-fakta terhubung tentang Anda, titik-titik kegagalan tunggal struktural akan muncul di sini.', + 'graphBridges.errorPrefix': 'Tidak dapat memuat graf:', + 'graphBridges.intro': + 'Titik artikulasi adalah entitas yang penghapusannya akan memutus graf memori. Jembatan adalah relasi tunggal yang menahan dua bagian tetap terhubung. Hilangkan satu, dan sebagian besar konteks menjadi senyap — kerentanan struktural yang tidak diungkap oleh derajat maupun PageRank.', + 'graphBridges.loading': 'Menghitung pemotongan struktural…', + 'graphBridges.metricArticulations': 'Artikulasi', + 'graphBridges.metricConnections': 'Koneksi', + 'graphBridges.metricEntities': 'Entitas', + 'graphBridges.namespaceAll': 'Semua ruang nama', + 'graphBridges.namespaceLabel': 'Ruang nama', + 'graphBridges.noBridges': + 'Tidak ada jembatan — setiap relasi berada dalam setidaknya satu siklus.', + 'graphBridges.noFragiles': + 'Tidak ada titik kegagalan tunggal struktural — setiap tautan berada dalam setidaknya satu siklus.', + 'graphBridges.retry': 'Coba lagi', + 'graphBridges.summaryCaption': '{bridges} jembatan · {components} komponen', + 'graphBridges.summaryCaptionOne': '{bridges} jembatan · komponen tunggal', + 'graphBridges.summaryCaptionOneBridge': '1 jembatan · {components} komponen', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 jembatan · komponen tunggal', + 'graphBridges.title': 'Jembatan Graf', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2426c1472c..d2e014dc74 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4501,6 +4501,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale', 'pages.settings.account.security': 'Sicurezza', 'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi', + 'graphBridges.articulationBadge': 'vertice di taglio', + 'graphBridges.articulationTitle': + 'Rimuovere questa entità disconnetterebbe il grafo: i gruppi che vi passano perdono il loro collegamento reciproco.', + 'graphBridges.articulationsHeading': 'Entità di articolazione', + 'graphBridges.bridgesHeading': 'Relazioni ponte', + 'graphBridges.colEntity': 'Entità', + 'graphBridges.colLinks': 'Collegamenti', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Ancora nessun grafo della conoscenza.', + 'graphBridges.emptyHint': + "Man mano che l'assistente registra fatti connessi su di te, qui appariranno i punti singoli di guasto strutturale.", + 'graphBridges.errorPrefix': 'Impossibile caricare il grafo:', + 'graphBridges.intro': + "I punti di articolazione sono entità la cui rimozione disconnetterebbe il grafo della memoria. I ponti sono le uniche relazioni che tengono insieme due metà. Perderne uno e un'intera fascia di contesto tace — una vulnerabilità strutturale che né il grado né il PageRank rivelano.", + 'graphBridges.loading': 'Calcolo dei tagli strutturali…', + 'graphBridges.metricArticulations': 'Articolazioni', + 'graphBridges.metricConnections': 'Connessioni', + 'graphBridges.metricEntities': 'Entità', + 'graphBridges.namespaceAll': 'Tutti gli spazi dei nomi', + 'graphBridges.namespaceLabel': 'Spazio dei nomi', + 'graphBridges.noBridges': 'Nessun ponte — ogni relazione si trova in almeno un ciclo.', + 'graphBridges.noFragiles': + 'Nessun punto singolo di guasto strutturale — ogni collegamento si trova in almeno un ciclo.', + 'graphBridges.retry': 'Riprova', + 'graphBridges.summaryCaption': '{bridges} ponti · {components} componenti', + 'graphBridges.summaryCaptionOne': '{bridges} ponti · componente singolo', + 'graphBridges.summaryCaptionOneBridge': '1 ponte · {components} componenti', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 ponte · componente singolo', + 'graphBridges.title': 'Ponti del grafo', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ec68b50ab4..2eb4ca41a3 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4393,6 +4393,35 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '로컬 저장소 거부', 'pages.settings.account.security': '보안', 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', + 'graphBridges.articulationBadge': '절단 정점', + 'graphBridges.articulationTitle': + '이 엔티티를 제거하면 그래프가 분리됩니다: 이를 거치는 클러스터들은 서로 간 링크를 잃습니다.', + 'graphBridges.articulationsHeading': '절단 엔티티', + 'graphBridges.bridgesHeading': '다리 관계', + 'graphBridges.colEntity': '엔티티', + 'graphBridges.colLinks': '링크', + 'graphBridges.colRank': '#', + 'graphBridges.empty': '아직 지식 그래프가 없습니다.', + 'graphBridges.emptyHint': + '어시스턴트가 당신에 관한 연결된 사실들을 기록함에 따라, 구조적 단일 장애점들이 여기에 드러납니다.', + 'graphBridges.errorPrefix': '그래프를 불러올 수 없습니다:', + 'graphBridges.intro': + '절단점은 제거하면 메모리 그래프가 분리되는 엔티티입니다. 다리는 두 부분을 유일하게 연결하는 관계입니다. 하나만 잃어도 한 묶음의 맥락이 침묵에 빠집니다 — 차수나 PageRank로는 드러나지 않는 구조적 취약점입니다.', + 'graphBridges.loading': '구조적 절단 계산 중…', + 'graphBridges.metricArticulations': '절단점', + 'graphBridges.metricConnections': '연결', + 'graphBridges.metricEntities': '엔티티', + 'graphBridges.namespaceAll': '모든 네임스페이스', + 'graphBridges.namespaceLabel': '네임스페이스', + 'graphBridges.noBridges': '다리 없음 — 모든 관계가 최소 하나의 사이클에 속합니다.', + 'graphBridges.noFragiles': '구조적 단일 장애점 없음 — 모든 링크가 최소 하나의 사이클에 속합니다.', + 'graphBridges.retry': '다시 시도', + 'graphBridges.summaryCaption': '다리 {bridges}개 · 컴포넌트 {components}개', + 'graphBridges.summaryCaptionOne': '다리 {bridges}개 · 단일 컴포넌트', + 'graphBridges.summaryCaptionOneBridge': '교량 1개 · 컴포넌트 {components}개', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '교량 1개 · 단일 컴포넌트', + 'graphBridges.title': '그래프 다리', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b80e01d0d4..5097d6508d 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4501,6 +4501,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania', 'pages.settings.account.security': 'Bezpieczeństwo', 'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy', + 'graphBridges.articulationBadge': 'wierzchołek tnący', + 'graphBridges.articulationTitle': + 'Usunięcie tej encji rozłączyłoby graf: klastry przechodzące przez nią tracą wzajemne połączenie.', + 'graphBridges.articulationsHeading': 'Encje artykulacji', + 'graphBridges.bridgesHeading': 'Relacje mostowe', + 'graphBridges.colEntity': 'Encja', + 'graphBridges.colLinks': 'Łącza', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Jeszcze brak grafu wiedzy.', + 'graphBridges.emptyHint': + 'W miarę jak asystent zapisuje powiązane fakty o Tobie, strukturalne pojedyncze punkty awarii pojawią się tutaj.', + 'graphBridges.errorPrefix': 'Nie udało się załadować grafu:', + 'graphBridges.intro': + 'Punkty artykulacji to encje, których usunięcie rozłączyłoby graf pamięci. Mosty są jedynymi relacjami, które trzymają razem dwie połowy. Strać jedną, a cały pas kontekstu milknie — strukturalna podatność, której ani stopień, ani PageRank nie ujawniają.', + 'graphBridges.loading': 'Obliczanie cięć strukturalnych…', + 'graphBridges.metricArticulations': 'Artykulacje', + 'graphBridges.metricConnections': 'Połączenia', + 'graphBridges.metricEntities': 'Encje', + 'graphBridges.namespaceAll': 'Wszystkie przestrzenie nazw', + 'graphBridges.namespaceLabel': 'Przestrzeń nazw', + 'graphBridges.noBridges': 'Brak mostów — każda relacja należy do co najmniej jednego cyklu.', + 'graphBridges.noFragiles': + 'Brak strukturalnych pojedynczych punktów awarii — każde łącze należy do co najmniej jednego cyklu.', + 'graphBridges.retry': 'Spróbuj ponownie', + 'graphBridges.summaryCaption': '{bridges} mostów · {components} składowych', + 'graphBridges.summaryCaptionOne': '{bridges} mostów · jedna składowa', + 'graphBridges.summaryCaptionOneBridge': '1 most · {components} składowych', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 most · jedna składowa', + 'graphBridges.title': 'Mosty grafu', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 75c601bd2f..0d98a44e1b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4498,6 +4498,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Recusar armazenamento local', 'pages.settings.account.security': 'Segurança', 'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro', + 'graphBridges.articulationBadge': 'vértice de corte', + 'graphBridges.articulationTitle': + 'Remover esta entidade desconectaria o grafo: grupos que passam por ela perdem sua ligação entre si.', + 'graphBridges.articulationsHeading': 'Entidades de articulação', + 'graphBridges.bridgesHeading': 'Relações ponte', + 'graphBridges.colEntity': 'Entidade', + 'graphBridges.colLinks': 'Ligações', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Ainda sem grafo de conhecimento.', + 'graphBridges.emptyHint': + 'À medida que o assistente registra fatos conectados sobre você, os pontos únicos de falha estrutural aparecerão aqui.', + 'graphBridges.errorPrefix': 'Não foi possível carregar o grafo:', + 'graphBridges.intro': + 'Pontos de articulação são entidades cuja remoção desconectaria o grafo de memória. Pontes são as únicas relações que mantêm duas metades unidas. Perca uma e uma faixa de contexto silencia — uma vulnerabilidade estrutural que nem grau nem PageRank revelam.', + 'graphBridges.loading': 'Calculando cortes estruturais…', + 'graphBridges.metricArticulations': 'Articulações', + 'graphBridges.metricConnections': 'Conexões', + 'graphBridges.metricEntities': 'Entidades', + 'graphBridges.namespaceAll': 'Todos os espaços de nomes', + 'graphBridges.namespaceLabel': 'Espaço de nomes', + 'graphBridges.noBridges': 'Sem pontes — cada relação está em pelo menos um ciclo.', + 'graphBridges.noFragiles': + 'Sem pontos únicos de falha estrutural — cada ligação está em pelo menos um ciclo.', + 'graphBridges.retry': 'Tentar novamente', + 'graphBridges.summaryCaption': '{bridges} pontes · {components} componentes', + 'graphBridges.summaryCaptionOne': '{bridges} pontes · componente único', + 'graphBridges.summaryCaptionOneBridge': '1 ponte · {components} componentes', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 ponte · componente único', + 'graphBridges.title': 'Pontes do grafo', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 592a691e0c..3b88b5016c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4469,6 +4469,36 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', 'pages.settings.account.security': 'Безопасность', 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', + 'graphBridges.articulationBadge': 'точка сочленения', + 'graphBridges.articulationTitle': + 'Удаление этой сущности разорвало бы граф: кластеры, проходящие через неё, теряют связь друг с другом.', + 'graphBridges.articulationsHeading': 'Точки сочленения', + 'graphBridges.bridgesHeading': 'Связи-мосты', + 'graphBridges.colEntity': 'Сущность', + 'graphBridges.colLinks': 'Связки', + 'graphBridges.colRank': '#', + 'graphBridges.empty': 'Пока нет графа знаний.', + 'graphBridges.emptyHint': + 'По мере того как ассистент фиксирует связанные факты о вас, здесь появятся структурные единые точки отказа.', + 'graphBridges.errorPrefix': 'Не удалось загрузить граф:', + 'graphBridges.intro': + 'Точки сочленения — это сущности, удаление которых разорвало бы граф памяти. Мосты — это единственные связи, удерживающие две половины вместе. Потеряйте одну, и целая полоса контекста замолчит — структурная уязвимость, которую не вскрывают ни степень, ни PageRank.', + 'graphBridges.loading': 'Вычисление структурных разрезов…', + 'graphBridges.metricArticulations': 'Сочленения', + 'graphBridges.metricConnections': 'Связи', + 'graphBridges.metricEntities': 'Сущности', + 'graphBridges.namespaceAll': 'Все пространства имён', + 'graphBridges.namespaceLabel': 'Пространство имён', + 'graphBridges.noBridges': 'Мостов нет — каждая связь входит хотя бы в один цикл.', + 'graphBridges.noFragiles': + 'Структурных единых точек отказа нет — каждая связь входит хотя бы в один цикл.', + 'graphBridges.retry': 'Повторить', + 'graphBridges.summaryCaption': '{bridges} мостов · {components} компонент', + 'graphBridges.summaryCaptionOne': '{bridges} мостов · одна компонента', + 'graphBridges.summaryCaptionOneBridge': '1 мост · {components} компонент', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 мост · одна компонента', + 'graphBridges.title': 'Мосты графа', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6169e9a647..f2aaa20bbe 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4214,6 +4214,33 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '拒绝本地存储', 'pages.settings.account.security': '安全', 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', + 'graphBridges.articulationBadge': '割点', + 'graphBridges.articulationTitle': '移除该实体会使图断开:经过它的聚类彼此间会失去链接。', + 'graphBridges.articulationsHeading': '关节实体', + 'graphBridges.bridgesHeading': '桥关系', + 'graphBridges.colEntity': '实体', + 'graphBridges.colLinks': '链接', + 'graphBridges.colRank': '#', + 'graphBridges.empty': '暂无知识图。', + 'graphBridges.emptyHint': '随着助手记录有关你的相互关联的事实,结构性的单点故障将在此呈现。', + 'graphBridges.errorPrefix': '无法加载图:', + 'graphBridges.intro': + '关节点是那些一旦移除就会让记忆图断开的实体。桥是把两半连在一起的唯一关系。丢掉一座桥,大片上下文就会归于沉默——这是度数和 PageRank 都揭示不了的结构性脆弱。', + 'graphBridges.loading': '正在计算结构切口…', + 'graphBridges.metricArticulations': '关节点', + 'graphBridges.metricConnections': '连接', + 'graphBridges.metricEntities': '实体', + 'graphBridges.namespaceAll': '所有命名空间', + 'graphBridges.namespaceLabel': '命名空间', + 'graphBridges.noBridges': '没有桥——每个关系都处于至少一个循环中。', + 'graphBridges.noFragiles': '没有结构性单点故障——每条链接都处于至少一个循环中。', + 'graphBridges.retry': '重试', + 'graphBridges.summaryCaption': '{bridges} 座桥 · {components} 个组件', + 'graphBridges.summaryCaptionOne': '{bridges} 座桥 · 单一组件', + 'graphBridges.summaryCaptionOneBridge': '1 座桥 · {components} 个组件', + 'graphBridges.summaryCaptionOneBridgeOneComponent': '1 座桥 · 单一组件', + 'graphBridges.title': '图的桥', + 'memory.tab.bridges': 'Bridges', }; export default messages; diff --git a/app/src/lib/memory/graphBridges.test.ts b/app/src/lib/memory/graphBridges.test.ts new file mode 100644 index 0000000000..bfbce1d6e4 --- /dev/null +++ b/app/src/lib/memory/graphBridges.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeGraphBridges } from './graphBridges'; + +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 isArt(result: ReturnType, id: string): boolean { + const node = result.nodes.find(n => n.id === id); + if (!node) throw new Error(`node ${id} not found`); + return node.isArticulation; +} + +describe('computeGraphBridges — basic shapes', () => { + it('returns an empty result for no relations', () => { + const r = computeGraphBridges([]); + expect(r.nodeCount).toBe(0); + expect(r.edgeCount).toBe(0); + expect(r.componentCount).toBe(0); + expect(r.articulationCount).toBe(0); + expect(r.bridges).toEqual([]); + expect(r.nodes).toEqual([]); + }); + + it('a triangle has zero articulations and zero bridges (every edge in a cycle)', () => { + const r = computeGraphBridges([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.articulationCount).toBe(0); + expect(r.bridges).toEqual([]); + for (const id of ['A', 'B', 'C']) expect(isArt(r, id)).toBe(false); + }); + + it('a path A-B-C: B is the sole articulation; both edges are bridges', () => { + const r = computeGraphBridges([rel('A', 'B'), rel('B', 'C')]); + expect(isArt(r, 'A')).toBe(false); // degree 1, removing it leaves {B,C} + expect(isArt(r, 'B')).toBe(true); // removing B disconnects A from C + expect(isArt(r, 'C')).toBe(false); + expect(r.bridges).toEqual([ + { a: 'A', b: 'B' }, + { a: 'B', b: 'C' }, + ]); + }); + + it('a star: the hub is the sole articulation; every spoke is a bridge', () => { + const r = computeGraphBridges([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + expect(isArt(r, 'X')).toBe(true); + for (const leaf of ['A', 'B', 'C']) expect(isArt(r, leaf)).toBe(false); + expect(r.bridges).toEqual([ + { a: 'A', b: 'X' }, + { a: 'B', b: 'X' }, + { a: 'C', b: 'X' }, + ]); + }); + + it('K4 (complete graph on four) has zero articulations and zero bridges', () => { + const r = computeGraphBridges([ + rel('A', 'B'), + rel('A', 'C'), + rel('A', 'D'), + rel('B', 'C'), + rel('B', 'D'), + rel('C', 'D'), + ]); + expect(r.articulationCount).toBe(0); + expect(r.bridges).toEqual([]); + }); +}); + +describe('computeGraphBridges — non-trivial structures', () => { + it('a "bowtie" (two triangles sharing one node) has that node as the sole articulation, no bridges', () => { + // Triangle 1: A-B-C-A. Triangle 2: C-D-E-C. C is shared. + const r = computeGraphBridges([ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'A'), + rel('C', 'D'), + rel('D', 'E'), + rel('E', 'C'), + ]); + expect(isArt(r, 'C')).toBe(true); + expect(r.articulationCount).toBe(1); + expect(r.bridges).toEqual([]); // every edge sits in a triangle + }); + + it('two triangles joined by a single edge: that edge is a bridge; both endpoints are articulations', () => { + // Triangle 1: A-B-C-A. Triangle 2: D-E-F-D. Bridge: C-D. + const r = computeGraphBridges([ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'A'), + rel('D', 'E'), + rel('E', 'F'), + rel('F', 'D'), + rel('C', 'D'), + ]); + expect(isArt(r, 'C')).toBe(true); + expect(isArt(r, 'D')).toBe(true); + expect(r.articulationCount).toBe(2); + expect(r.bridges).toEqual([{ a: 'C', b: 'D' }]); + }); + + it('puts articulation points first in the ranked node list', () => { + const r = computeGraphBridges([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + // X is the only articulation; it must lead. + expect(r.nodes[0].id).toBe('X'); + expect(r.nodes[0].isArticulation).toBe(true); + }); +}); + +describe('computeGraphBridges — normalization & determinism', () => { + it('drops the self-loop EDGE but keeps the endpoint as a node', () => { + const r = computeGraphBridges([rel('A', 'A'), rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.nodeCount).toBe(3); + expect(r.edgeCount).toBe(3); + expect(r.articulationCount).toBe(0); // plain triangle + }); + + it('preserves an entity whose only relation is a self-loop', () => { + const r = computeGraphBridges([rel('Alice', 'Alice')]); + expect(r.nodeCount).toBe(1); + expect(r.edgeCount).toBe(0); + expect(r.articulationCount).toBe(0); // singleton component, no cut + expect(r.componentCount).toBe(1); + }); + + it('collapses parallel edges and ignores direction', () => { + const r = computeGraphBridges([ + rel('A', 'B', 'knows'), + rel('B', 'A', 'likes'), + rel('A', 'B', 'trusts'), + rel('B', 'C'), + ]); + expect(r.edgeCount).toBe(2); + // a path of two collapsed edges -> B is articulation; both edges bridges. + expect(isArt(r, 'B')).toBe(true); + expect(r.bridges).toEqual([ + { a: 'A', b: 'B' }, + { a: 'B', b: 'C' }, + ]); + }); + + it('drops malformed relations (non-string subject or object)', () => { + const malformedObject = { ...rel('A', 'B'), object: null as unknown as string }; + const malformedSubject = { ...rel('A', 'B'), subject: undefined as unknown as string }; + const r = computeGraphBridges([ + rel('A', 'B'), + rel('B', 'C'), + malformedObject, + malformedSubject, + ]); + expect(r.nodeCount).toBe(3); + expect(isArt(r, 'B')).toBe(true); + }); + + it('treats "Alice" and "alice" as distinct nodes (no case-folding)', () => { + const r = computeGraphBridges([rel('Alice', 'Bob'), rel('alice', 'Bob')]); + expect(r.nodeCount).toBe(3); + expect(isArt(r, 'Bob')).toBe(true); // Bob is the sole link between Alice and alice + }); + + it('is order-independent: shuffled input yields identical output', () => { + const edges = [ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'A'), + rel('D', 'E'), + rel('E', 'F'), + rel('F', 'D'), + rel('C', 'D'), + ]; + const forward = computeGraphBridges(edges); + const reversed = computeGraphBridges([...edges].reverse()); + const rotated = computeGraphBridges([...edges.slice(3), ...edges.slice(0, 3)]); + expect(reversed).toEqual(forward); + expect(rotated).toEqual(forward); + }); +}); diff --git a/app/src/lib/memory/graphBridges.ts b/app/src/lib/memory/graphBridges.ts new file mode 100644 index 0000000000..2c560fb189 --- /dev/null +++ b/app/src/lib/memory/graphBridges.ts @@ -0,0 +1,242 @@ +/** + * Graph Bridges — pure articulation-points / cut-edges engine (Tarjan's + * lowlink). + * + * The memory knowledge graph is a set of (subject)-[predicate]->(object) + * triples. This lens identifies the EXACT topological single-points-of-failure: + * + * - ARTICULATION POINTS (cut vertices): entities whose removal would + * disconnect the graph — strip them, and clusters that previously reached + * each other through them fall apart. + * - BRIDGES (cut edges): relations whose removal would disconnect the graph — + * the ONLY link between two otherwise-separable halves of the memory. + * + * Why it matters: the centrality lens surfaces entities that are IMPORTANT; + * this lens surfaces entities that are LOAD-BEARING. An entity may + * have modest degree yet be the sole link holding two large clusters together — + * losing it shatters more knowledge than a higher-degree non-articulation. The + * earlier centrality lens approximates this via PageRank-gap heuristics; this + * lens computes it exactly via Tarjan's algorithm. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. Articulation points and bridges are graph INVARIANTS (independent + * of DFS traversal order) 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 + * (same unordered pair) collapse to one, and self-loop EDGES (subject === + * object) are dropped — the endpoint is still registered as a node, so a + * loop-only entity appears with degree 0 (and is trivially a singleton + * component, not an articulation). + * - Bridges are emitted in CANONICAL form (lexicographically smaller id + * first) so a bridge is the same pair regardless of which way it was + * traversed. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface BridgeNode { + id: string; + degree: number; // undirected degree in the full graph + componentId: number; // smallest member index = stable component label + componentSize: number; + isArticulation: boolean; +} + +export interface BridgeEdge { + a: string; // lexicographically smaller id + b: string; // lexicographically larger id +} + +export interface BridgeResult { + nodes: BridgeNode[]; // sorted articulation DESC, then degree DESC, then id ASC + nodeCount: number; + edgeCount: number; // distinct undirected edges (self-loops excluded) + componentCount: number; + articulationCount: number; + bridges: BridgeEdge[]; // sorted by (a ASC, then b ASC) +} + +function isRelation(relation: GraphRelation): boolean { + return typeof relation.subject === 'string' && typeof relation.object === 'string'; +} + +/** Undirected simple-graph adjacency: self-loop edges and parallel edges are + * removed, but a self-loop's endpoint is still registered as a node. */ +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) { + neighbours(subject); // keep the node, drop the self-edge + continue; + } + neighbours(subject).add(object); + neighbours(object).add(subject); + } + return adjacency; +} + +/** Label connected components; componentLabel[i] is the smallest index in i's + * component (iterating ascending makes the first-seen index the smallest). */ +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; +} + +/** + * Tarjan's articulation-points & bridges. For each DFS tree edge (u, v): + * - `low[v] > disc[u]` ⇒ edge (u, v) is a BRIDGE. + * - `low[v] >= disc[u]` AND u is non-root ⇒ u is an ARTICULATION. + * - DFS root u is an articulation iff it has TWO OR MORE tree children. + * Iterative DFS so we never hit the JS call-stack limit on a long-path graph. + */ +function tarjanBridges(neighbours: number[][]): { + articulations: Set; + bridges: Array<[number, number]>; +} { + const n = neighbours.length; + const disc = new Array(n).fill(-1); + const low = new Array(n).fill(0); + const parent = new Array(n).fill(-1); + const articulations = new Set(); + const bridges: Array<[number, number]> = []; + let timer = 0; + + // For each unvisited vertex (handles disconnected graphs), run an iterative + // DFS using a stack of (vertex, neighbour-index) frames so we can resume + // after each child returns. + for (let start = 0; start < n; start += 1) { + if (disc[start] !== -1) continue; + disc[start] = timer; + low[start] = timer; + timer += 1; + let rootChildren = 0; + const stack: Array<{ u: number; i: number }> = [{ u: start, i: 0 }]; + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const { u } = frame; + if (frame.i < neighbours[u].length) { + const v = neighbours[u][frame.i]; + frame.i += 1; + if (disc[v] === -1) { + // Tree edge u -> v: descend. + parent[v] = u; + disc[v] = timer; + low[v] = timer; + timer += 1; + if (u === start) rootChildren += 1; + stack.push({ u: v, i: 0 }); + } else if (v !== parent[u]) { + // Back edge to an ancestor (or sibling — same effect on low). + if (disc[v] < low[u]) low[u] = disc[v]; + } + // else: edge to the immediate parent — skip (simple graph). + } else { + // Finished u: relax its parent's low and check the cut conditions. + const p = parent[u]; + if (p !== -1) { + if (low[u] < low[p]) low[p] = low[u]; + if (low[u] > disc[p]) { + bridges.push(u < p ? [u, p] : [p, u]); + } + if (p !== start && low[u] >= disc[p]) { + articulations.add(p); + } + } + stack.pop(); + } + } + if (rootChildren > 1) articulations.add(start); + } + + return { articulations, bridges }; +} + +/** Compute articulation points & bridges over the memory graph. PURE. */ +export function computeGraphBridges(relations: GraphRelation[]): BridgeResult { + const adjacency = buildAdjacency(relations); + + // Canonical, id-sorted vertex ordering -> reproducible DFS roots. + 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); + } + } + // Sort each neighbour list so DFS visits children deterministically. + list.sort((x, y) => x - y); + edgeDegreeSum += list.length; + return list; + }); + + const label = labelComponents(neighbours); + const { articulations, bridges } = tarjanBridges(neighbours); + + const sizeByComponent = new Map(); + for (const c of label) sizeByComponent.set(c, (sizeByComponent.get(c) ?? 0) + 1); + + const nodes: BridgeNode[] = ids.map((id, i) => ({ + id, + degree: neighbours[i].length, + componentId: label[i], + componentSize: sizeByComponent.get(label[i]) ?? 1, + isArticulation: articulations.has(i), + })); + + nodes.sort((a, b) => { + if (a.isArticulation !== b.isArticulation) return a.isArticulation ? -1 : 1; + if (b.degree !== a.degree) return b.degree - a.degree; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + + const bridgeEdges: BridgeEdge[] = bridges + .map(([x, y]) => ({ a: ids[x], b: ids[y] })) + .sort((p, q) => (p.a !== q.a ? (p.a < q.a ? -1 : 1) : p.b < q.b ? -1 : p.b > q.b ? 1 : 0)); + + return { + nodes, + nodeCount: ids.length, + edgeCount: edgeDegreeSum / 2, + componentCount: sizeByComponent.size, + articulationCount: articulations.size, + bridges: bridgeEdges, + }; +} diff --git a/app/src/services/api/graphBridgesApi.test.ts b/app/src/services/api/graphBridgesApi.test.ts new file mode 100644 index 0000000000..4ce92c98c5 --- /dev/null +++ b/app/src/services/api/graphBridgesApi.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphBridges } from '../../lib/memory/graphBridges'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { graphBridgesApi, loadBridges, loadNamespaces } from './graphBridgesApi'; + +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('graphBridgesApi.loadBridges', () => { + 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')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadBridges('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeGraphBridges(triples)); + expect(out.articulationCount).toBe(1); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadBridges(); + 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(loadBridges()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('graphBridgesApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('graphBridgesApi object', () => { + it('exposes the public surface', () => { + expect(typeof graphBridgesApi.loadBridges).toBe('function'); + expect(typeof graphBridgesApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/graphBridgesApi.ts b/app/src/services/api/graphBridgesApi.ts new file mode 100644 index 0000000000..e8db88b143 --- /dev/null +++ b/app/src/services/api/graphBridgesApi.ts @@ -0,0 +1,29 @@ +/** + * RPC facade for Graph Bridges (articulation points & cut edges). + * + * 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 BridgeResult, computeGraphBridges } from '../../lib/memory/graphBridges'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('graph-bridges:api'); + +/** Fetch the graph relations for a namespace (or all) and compute the cuts. */ +export async function loadBridges(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadBridges namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeGraphBridges(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const graphBridgesApi = { loadBridges, loadNamespaces };