diff --git a/app/src/components/intelligence/GraphCorePanel.test.tsx b/app/src/components/intelligence/GraphCorePanel.test.tsx new file mode 100644 index 0000000000..25a46fce54 --- /dev/null +++ b/app/src/components/intelligence/GraphCorePanel.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeGraphCore } from '../../lib/memory/graphCore'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphCorePanel from './GraphCorePanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// Triangle A-B-C (2-core) plus pendant D off A -> degeneracy 2, shells {2:3,1:1}. +const cored = computeGraphCore([rel('A', 'B'), rel('B', 'C'), rel('C', 'A'), rel('A', 'D')]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('graph-core-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 shell decomposition, and the ranked table', () => { + render(); + expect(screen.getByText('Entities')).toBeInTheDocument(); + expect(screen.getByText('Connections')).toBeInTheDocument(); + expect(screen.getByText('Degeneracy')).toBeInTheDocument(); + expect(screen.getByText('Shell decomposition')).toBeInTheDocument(); + expect(screen.getByText('Deepest-core entities')).toBeInTheDocument(); + // shell labels for the two coreness levels present. + expect(screen.getByText('2-core')).toBeInTheDocument(); + expect(screen.getByText('1-core')).toBeInTheDocument(); + // densest shell holds the triangle (3 entities at the 2-core). + expect(screen.getByText(/2-core · 3 entities/)).toBeInTheDocument(); + }); + + it('badges the deepest-core members and not the periphery', () => { + render(); + // three triangle members carry the core badge; the pendant D does not. + expect(screen.getAllByText('core')).toHaveLength(3); + }); +}); diff --git a/app/src/components/intelligence/GraphCorePanel.tsx b/app/src/components/intelligence/GraphCorePanel.tsx new file mode 100644 index 0000000000..04419faa78 --- /dev/null +++ b/app/src/components/intelligence/GraphCorePanel.tsx @@ -0,0 +1,245 @@ +/** + * Graph Core — presentational view. Pure: renders the core summary tiles + * (entities / connections / degeneracy), the shell decomposition, and a ranked + * table of the deepest-core entities. No data fetching, no clock, no randomness. + */ +import { useMemo } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { type CoreResult, kCoreSize } from '../../lib/memory/graphCore'; + +const MAX_ROWS = 25; +const MAX_SHELLS = 10; + +interface GraphCorePanelProps { + result: CoreResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const GraphCorePanel = ({ result, loading, error, onRetry }: GraphCorePanelProps) => { + const { t } = useT(); + + const degeneracyCoreSize = useMemo( + () => (result ? kCoreSize(result, result.degeneracy) : 0), + [result] + ); + const maxShellCount = useMemo( + () => (result ? result.shells.reduce((max, s) => (s.count > max ? s.count : max), 0) : 0), + [result] + ); + + const intro = ( +
+

{t('graphCore.title')}

+

{t('graphCore.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + const rows = result.nodes.slice(0, MAX_ROWS); + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('graphCore.metricEntities'), value: result.nodeCount }, + { label: t('graphCore.metricConnections'), value: result.edgeCount }, + { label: t('graphCore.metricDegeneracy'), value: result.degeneracy }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('graphCore.degeneracyCaption') + .replace('{degeneracy}', String(result.degeneracy)) + .replace('{coreSize}', String(degeneracyCoreSize))} +

+ + {/* Shell decomposition */} +
+

+ {t('graphCore.shellsHeading')} +

+
    + {result.shells.slice(0, MAX_SHELLS).map(shell => ( +
  • + + {t('graphCore.shellLabel').replace('{k}', String(shell.k))} + +
    +
    +
    + + {shell.count} + +
  • + ))} + {result.shells.length > MAX_SHELLS && ( +
  • + {t('graphCore.shellsMore').replace( + '{count}', + String(result.shells.length - MAX_SHELLS) + )} +
  • + )} +
+
+ + {/* Ranked deepest-core entities */} +
+

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

+ + + + + + + + + + + {rows.map((node, i) => ( + + + + + + + ))} + +
+ {t('graphCore.colRank')} + + {t('graphCore.colEntity')} + + {t('graphCore.colCore')} + + {t('graphCore.colLinks')} +
{i + 1} + {node.id} + {result.degeneracy > 0 && node.coreness === result.degeneracy && ( + + {t('graphCore.coreBadge')} + + )} + +
+
+
+
+ + {node.coreness} + +
+
+ {node.degree} +
+
+
+ ); +}; + +export default GraphCorePanel; diff --git a/app/src/components/intelligence/GraphCoreTab.test.tsx b/app/src/components/intelligence/GraphCoreTab.test.tsx new file mode 100644 index 0000000000..b4b781380c --- /dev/null +++ b/app/src/components/intelligence/GraphCoreTab.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphCore } from '../../lib/memory/graphCore'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import GraphCoreTab from './GraphCoreTab'; + +const mockLoadCore = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/graphCoreApi', () => ({ + loadCore: (...args: unknown[]) => mockLoadCore(...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 = computeGraphCore([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + +describe('', () => { + beforeEach(() => { + mockLoadCore.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadCore.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads core (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadCore).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(screen.getByText('Deepest-core 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(mockLoadCore).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadCore.mockReset(); + mockLoadCore.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/GraphCoreTab.tsx b/app/src/components/intelligence/GraphCoreTab.tsx new file mode 100644 index 0000000000..a83bd87eea --- /dev/null +++ b/app/src/components/intelligence/GraphCoreTab.tsx @@ -0,0 +1,83 @@ +/** + * Graph Core 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 { CoreResult } from '../../lib/memory/graphCore'; +import { loadCore, loadNamespaces } from '../../services/api/graphCoreApi'; +import GraphCorePanel from './GraphCorePanel'; + +const GraphCoreTab = () => { + 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 loadCore(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 core 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 GraphCoreTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index be57d0c4c7..77d8713e11 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4359,6 +4359,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'متوسط التجميع {avg} · التعدّي {transitivity}', 'graphCohesion.title': 'تماسك الرسم البياني', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'النواة', + 'graphCore.title': 'نواة الرسم البياني', + 'graphCore.intro': + 'يفصل تحليل k-core النواة الحاملة للمعرفة — الكيانات التي يعززها عدد كبير من الحقائق المترابطة — عن هوامش الأوراق والجسور. حتى المحور عالي الدرجة المليء بحقائق منفردة يبقى coreness 1؛ العمق لا الدرجة هو ما يميز النواة.', + 'graphCore.loading': 'جارٍ حساب بنية النواة…', + 'graphCore.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'graphCore.retry': 'إعادة المحاولة', + 'graphCore.empty': 'لا يوجد رسم بياني للمعرفة بعد.', + 'graphCore.emptyHint': 'عندما يسجّل المساعد حقائق مترابطة عنك، ستظهر النواة كثيفة الروابط هنا.', + 'graphCore.namespaceLabel': 'مساحة الاسم', + 'graphCore.namespaceAll': 'جميع مساحات الاسم', + 'graphCore.metricEntities': 'الكيانات', + 'graphCore.metricConnections': 'الاتصالات', + 'graphCore.metricDegeneracy': 'الانحلالية', + 'graphCore.degeneracyCaption': 'أكثف قشرة: {degeneracy}-core · {coreSize} كيانات', + 'graphCore.shellsHeading': 'تحليل القشرة', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… و{count} أصداف أخرى', + 'graphCore.rankedHeading': 'كيانات النواة الأعمق', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'الكيان', + 'graphCore.colCore': 'النواة', + 'graphCore.colLinks': 'الروابط', + 'graphCore.coreBadge': 'نواة', + 'graphCore.coreTitle': + 'في أكثف {degeneracy}-core — كل عضو هنا يحافظ على {degeneracy} روابط على الأقل مع أعضاء النواة الآخرين.', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 9ec2d1481b..7266676cd3 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4434,6 +4434,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'গড় ক্লাস্টারিং {avg} · সংক্রমণতা {transitivity}', 'graphCohesion.title': 'গ্রাফ সংসক্তি', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'মূল', + 'graphCore.title': 'গ্রাফ কোর', + 'graphCore.intro': + 'k-core বিশ্লেষণ আপনার জ্ঞানের ভারবাহী মূল অংশকে আলাদা করে — এমন সত্তাগুলি যেগুলি পরস্পর সংযুক্ত অনেক তথ্য দ্বারা পারস্পরিকভাবে শক্তিশালী হয় — পাতা ও সেতুর প্রান্ত থেকে। একক তথ্যসমৃদ্ধ উচ্চ-ডিগ্রি হাবেরও coreness ১ থাকে; গভীরতা, ডিগ্রি নয়, মূলকে চিহ্নিত করে।', + 'graphCore.loading': 'কোর কাঠামো গণনা করা হচ্ছে…', + 'graphCore.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'graphCore.retry': 'পুনরায় চেষ্টা করুন', + 'graphCore.empty': 'এখনো কোনো জ্ঞান গ্রাফ নেই।', + 'graphCore.emptyHint': + 'সহকারী আপনার সম্পর্কে সংযুক্ত তথ্য রেকর্ড করার সাথে সাথে ঘন-সংযুক্ত কোরটি এখানে প্রকাশ পাবে।', + 'graphCore.namespaceLabel': 'নেমস্পেস', + 'graphCore.namespaceAll': 'সমস্ত নেমস্পেস', + 'graphCore.metricEntities': 'সত্তা', + 'graphCore.metricConnections': 'সংযোগ', + 'graphCore.metricDegeneracy': 'ডিজেনারেসি', + 'graphCore.degeneracyCaption': 'সর্বাধিক ঘন শেল: {degeneracy}-core · {coreSize} সত্তা', + 'graphCore.shellsHeading': 'শেল বিশ্লেষণ', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… এবং আরো {count}টি শেল', + 'graphCore.rankedHeading': 'গভীরতম কোর সত্তা', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'সত্তা', + 'graphCore.colCore': 'কোর', + 'graphCore.colLinks': 'লিঙ্ক', + 'graphCore.coreBadge': 'কোর', + 'graphCore.coreTitle': + 'সবচেয়ে ঘন {degeneracy}-core-এ — এখানকার প্রতিটি সদস্য অন্য কোর সদস্যদের সাথে কমপক্ষে {degeneracy}টি লিঙ্ক বজায় রাখে।', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b3ae7764b3..f8c1e6f3f7 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4551,6 +4551,33 @@ const messages: TranslationMap = { 'Durchschnittliches Clustering {avg} · Transitivität {transitivity}', 'graphCohesion.title': 'Graph-Kohäsion', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Kern', + 'graphCore.title': 'Graph-Kern', + 'graphCore.intro': + 'Die k-core-Zerlegung trennt den tragenden Kern Ihres Wissens — Entitäten, die durch viele vernetzte Fakten gegenseitig gestärkt werden — von der Peripherie aus Blättern und Brücken. Selbst ein hochgradiger Hub mit einmaligen Fakten hat coreness 1; Tiefe, nicht Grad, kennzeichnet den Kern.', + 'graphCore.loading': 'Kernstruktur wird berechnet…', + 'graphCore.errorPrefix': 'Graph konnte nicht geladen werden:', + 'graphCore.retry': 'Wiederholen', + 'graphCore.empty': 'Noch kein Wissensgraph.', + 'graphCore.emptyHint': + 'Sobald der Assistent verknüpfte Fakten über Sie erfasst, erscheint der dicht vernetzte Kern hier.', + 'graphCore.namespaceLabel': 'Namensraum', + 'graphCore.namespaceAll': 'Alle Namensräume', + 'graphCore.metricEntities': 'Entitäten', + 'graphCore.metricConnections': 'Verbindungen', + 'graphCore.metricDegeneracy': 'Degeneriertheit', + 'graphCore.degeneracyCaption': 'Dichteste Schale: {degeneracy}-core · {coreSize} Entitäten', + 'graphCore.shellsHeading': 'Schalenzerlegung', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… und {count} weitere Schalen', + 'graphCore.rankedHeading': 'Entitäten mit tiefstem Kern', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entität', + 'graphCore.colCore': 'Kern', + 'graphCore.colLinks': 'Verknüpfungen', + 'graphCore.coreBadge': 'Kern', + 'graphCore.coreTitle': + 'Im dichtesten {degeneracy}-core — jedes Mitglied hier hält mindestens {degeneracy} Verbindungen zu anderen Kernmitgliedern.', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2a61c636fb..aa0769f5a1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -303,6 +303,7 @@ const en: TranslationMap = { 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.core': 'Core', 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.cohesion': 'Cohesion', @@ -488,6 +489,33 @@ const en: TranslationMap = { 'graphCohesion.brokerTitle': "Structural hole: this entity's neighbours aren't connected to each other — it's the sole link between them.", + 'graphCore.title': 'Graph Core', + 'graphCore.intro': + 'k-core decomposition separates the load-bearing core of your knowledge — entities mutually reinforced by many interlinked facts — from the periphery of leaves and bridges. A high-degree hub of one-off facts still has coreness 1; depth, not degree, marks the core.', + 'graphCore.loading': 'Computing core structure…', + 'graphCore.errorPrefix': 'Could not load the graph:', + 'graphCore.retry': 'Retry', + 'graphCore.empty': 'No knowledge graph yet.', + 'graphCore.emptyHint': + 'As the assistant records connected facts about you, the densely-linked core will surface here.', + 'graphCore.namespaceLabel': 'Namespace', + 'graphCore.namespaceAll': 'All namespaces', + 'graphCore.metricEntities': 'Entities', + 'graphCore.metricConnections': 'Connections', + 'graphCore.metricDegeneracy': 'Degeneracy', + 'graphCore.degeneracyCaption': 'Densest shell: {degeneracy}-core · {coreSize} entities', + 'graphCore.shellsHeading': 'Shell decomposition', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… and {count} more shells', + 'graphCore.rankedHeading': 'Deepest-core entities', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entity', + 'graphCore.colCore': 'Core', + 'graphCore.colLinks': 'Links', + 'graphCore.coreBadge': 'core', + 'graphCore.coreTitle': + 'In the densest {degeneracy}-core — every member here keeps at least {degeneracy} links to other core members.', + // 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..c1f6f46f14 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4520,6 +4520,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Agrupamiento promedio {avg} · transitividad {transitivity}', 'graphCohesion.title': 'Cohesión del grafo', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Núcleo', + 'graphCore.title': 'Núcleo del grafo', + 'graphCore.intro': + 'La descomposición k-core separa el núcleo estructural de tu conocimiento — entidades reforzadas mutuamente por muchos hechos interconectados — de la periferia de hojas y puentes. Incluso un hub de alto grado con hechos únicos tiene coreness 1; la profundidad, no el grado, marca el núcleo.', + 'graphCore.loading': 'Calculando la estructura del núcleo…', + 'graphCore.errorPrefix': 'No se pudo cargar el grafo:', + 'graphCore.retry': 'Reintentar', + 'graphCore.empty': 'Aún no hay grafo de conocimiento.', + 'graphCore.emptyHint': + 'A medida que el asistente registre hechos conectados sobre ti, el núcleo densamente enlazado aparecerá aquí.', + 'graphCore.namespaceLabel': 'Espacio de nombres', + 'graphCore.namespaceAll': 'Todos los espacios de nombres', + 'graphCore.metricEntities': 'Entidades', + 'graphCore.metricConnections': 'Conexiones', + 'graphCore.metricDegeneracy': 'Degeneración', + 'graphCore.degeneracyCaption': 'Capa más densa: {degeneracy}-core · {coreSize} entidades', + 'graphCore.shellsHeading': 'Descomposición en capas', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… y {count} capas más', + 'graphCore.rankedHeading': 'Entidades del núcleo más profundo', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entidad', + 'graphCore.colCore': 'Núcleo', + 'graphCore.colLinks': 'Vínculos', + 'graphCore.coreBadge': 'núcleo', + 'graphCore.coreTitle': + 'En el {degeneracy}-core más denso — cada miembro aquí mantiene al menos {degeneracy} vínculos con otros miembros del núcleo.', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d76e393c00..4050ad4d16 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4536,6 +4536,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Regroupement moyen {avg} · transitivité {transitivity}', 'graphCohesion.title': 'Cohésion du graphe', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Noyau', + 'graphCore.title': 'Noyau du graphe', + 'graphCore.intro': + 'La décomposition k-core sépare le noyau porteur de votre connaissance — des entités mutuellement renforcées par de nombreux faits interconnectés — de la périphérie des feuilles et des ponts. Même un hub de haut degré avec des faits ponctuels a une coreness de 1 ; la profondeur, et non le degré, marque le noyau.', + 'graphCore.loading': 'Calcul de la structure du noyau…', + 'graphCore.errorPrefix': 'Impossible de charger le graphe :', + 'graphCore.retry': 'Réessayer', + 'graphCore.empty': "Aucun graphe de connaissance pour l'instant.", + 'graphCore.emptyHint': + "Au fur et à mesure que l'assistant enregistre des faits connectés vous concernant, le noyau densément lié apparaîtra ici.", + 'graphCore.namespaceLabel': 'Espace de noms', + 'graphCore.namespaceAll': 'Tous les espaces de noms', + 'graphCore.metricEntities': 'Entités', + 'graphCore.metricConnections': 'Connexions', + 'graphCore.metricDegeneracy': 'Dégénérescence', + 'graphCore.degeneracyCaption': 'Couche la plus dense : {degeneracy}-core · {coreSize} entités', + 'graphCore.shellsHeading': 'Décomposition en couches', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… et {count} coques de plus', + 'graphCore.rankedHeading': 'Entités du noyau le plus profond', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entité', + 'graphCore.colCore': 'Noyau', + 'graphCore.colLinks': 'Liens', + 'graphCore.coreBadge': 'noyau', + 'graphCore.coreTitle': + "Dans le {degeneracy}-core le plus dense — chaque membre ici maintient au moins {degeneracy} liens vers d'autres membres du noyau.", }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 5c149a403b..955f1c778f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4442,6 +4442,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'औसत क्लस्टरिंग {avg} · सकर्मकता {transitivity}', 'graphCohesion.title': 'ग्राफ संसक्ति', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'केंद्र', + 'graphCore.title': 'ग्राफ केंद्र', + 'graphCore.intro': + 'k-core विघटन आपके ज्ञान के भार-वाहक केंद्र को अलग करता है — ऐसी संस्थाएँ जो कई परस्पर जुड़े तथ्यों द्वारा परस्पर सुदृढ़ होती हैं — पत्तियों और पुलों की परिधि से। एकल तथ्यों का उच्च-डिग्री हब भी coreness 1 रखता है; गहराई, डिग्री नहीं, केंद्र को परिभाषित करती है।', + 'graphCore.loading': 'केंद्र संरचना की गणना हो रही है…', + 'graphCore.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'graphCore.retry': 'पुनः प्रयास', + 'graphCore.empty': 'अभी तक कोई ज्ञान ग्राफ नहीं।', + 'graphCore.emptyHint': + 'जैसे-जैसे सहायक आपके बारे में जुड़े हुए तथ्य दर्ज करेगा, घनिष्ठ-जुड़ा केंद्र यहाँ उभर कर आएगा।', + 'graphCore.namespaceLabel': 'नामस्थान', + 'graphCore.namespaceAll': 'सभी नामस्थान', + 'graphCore.metricEntities': 'संस्थाएँ', + 'graphCore.metricConnections': 'कनेक्शन', + 'graphCore.metricDegeneracy': 'अपभ्रंशता', + 'graphCore.degeneracyCaption': 'सबसे घना आवरण: {degeneracy}-core · {coreSize} संस्थाएँ', + 'graphCore.shellsHeading': 'आवरण विघटन', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… और {count} और शेल', + 'graphCore.rankedHeading': 'सबसे गहरे केंद्र की संस्थाएँ', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'संस्था', + 'graphCore.colCore': 'केंद्र', + 'graphCore.colLinks': 'लिंक', + 'graphCore.coreBadge': 'केंद्र', + 'graphCore.coreTitle': + 'सबसे घने {degeneracy}-core में — यहाँ का प्रत्येक सदस्य अन्य केंद्र सदस्यों के साथ कम से कम {degeneracy} लिंक बनाए रखता है।', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c8542b0c47..f1cfe0cede 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4451,6 +4451,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Pengelompokan rata-rata {avg} · transitivitas {transitivity}', 'graphCohesion.title': 'Kohesi Graf', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Inti', + 'graphCore.title': 'Inti Graf', + 'graphCore.intro': + 'Dekomposisi k-core memisahkan inti penopang pengetahuan Anda — entitas yang saling diperkuat oleh banyak fakta yang saling terhubung — dari tepi berupa daun dan jembatan. Hub berderajat tinggi dari fakta-fakta sekali pakai pun tetap memiliki coreness 1; kedalaman, bukan derajat, yang menandai inti.', + 'graphCore.loading': 'Menghitung struktur inti…', + 'graphCore.errorPrefix': 'Gagal memuat graf:', + 'graphCore.retry': 'Coba lagi', + 'graphCore.empty': 'Belum ada graf pengetahuan.', + 'graphCore.emptyHint': + 'Saat asisten mencatat fakta-fakta terhubung tentang Anda, inti yang padat-terhubung akan muncul di sini.', + 'graphCore.namespaceLabel': 'Namespace', + 'graphCore.namespaceAll': 'Semua namespace', + 'graphCore.metricEntities': 'Entitas', + 'graphCore.metricConnections': 'Koneksi', + 'graphCore.metricDegeneracy': 'Degenerasi', + 'graphCore.degeneracyCaption': 'Cangkang paling padat: {degeneracy}-core · {coreSize} entitas', + 'graphCore.shellsHeading': 'Dekomposisi cangkang', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… dan {count} shell lainnya', + 'graphCore.rankedHeading': 'Entitas inti terdalam', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entitas', + 'graphCore.colCore': 'Inti', + 'graphCore.colLinks': 'Tautan', + 'graphCore.coreBadge': 'inti', + 'graphCore.coreTitle': + 'Dalam {degeneracy}-core paling padat — setiap anggota di sini mempertahankan setidaknya {degeneracy} tautan ke anggota inti lainnya.', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index abe89fd4c3..35c462e019 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4512,6 +4512,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Raggruppamento medio {avg} · transitività {transitivity}', 'graphCohesion.title': 'Coesione del grafo', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Nucleo', + 'graphCore.title': 'Nucleo del grafo', + 'graphCore.intro': + 'La decomposizione k-core separa il nucleo portante della tua conoscenza — entità mutuamente rinforzate da molti fatti interconnessi — dalla periferia di foglie e ponti. Anche un hub di alto grado con fatti isolati ha coreness 1; la profondità, non il grado, contraddistingue il nucleo.', + 'graphCore.loading': 'Calcolo della struttura del nucleo…', + 'graphCore.errorPrefix': 'Impossibile caricare il grafo:', + 'graphCore.retry': 'Riprova', + 'graphCore.empty': 'Nessun grafo della conoscenza ancora.', + 'graphCore.emptyHint': + "Man mano che l'assistente registra fatti connessi su di te, il nucleo densamente collegato apparirà qui.", + 'graphCore.namespaceLabel': 'Spazio dei nomi', + 'graphCore.namespaceAll': 'Tutti gli spazi dei nomi', + 'graphCore.metricEntities': 'Entità', + 'graphCore.metricConnections': 'Connessioni', + 'graphCore.metricDegeneracy': 'Degenerazione', + 'graphCore.degeneracyCaption': 'Guscio più denso: {degeneracy}-core · {coreSize} entità', + 'graphCore.shellsHeading': 'Decomposizione in gusci', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… e altri {count} gusci', + 'graphCore.rankedHeading': 'Entità del nucleo più profondo', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entità', + 'graphCore.colCore': 'Nucleo', + 'graphCore.colLinks': 'Collegamento', + 'graphCore.coreBadge': 'nucleo', + 'graphCore.coreTitle': + 'Nel {degeneracy}-core più denso — ogni membro qui mantiene almeno {degeneracy} collegamenti verso altri membri del nucleo.', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 855b0f4c45..fa38d7ca64 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.core': '핵심', + 'graphCore.title': '그래프 핵심', + 'graphCore.intro': + 'k-core 분해는 지식의 핵심 구조 — 수많은 연결된 사실에 의해 상호 강화되는 개체들 — 를 잎과 다리의 주변부로부터 분리합니다. 일회성 사실로 이루어진 고차수 허브도 coreness가 1에 불과합니다; 핵심을 나타내는 것은 차수가 아닌 깊이입니다.', + 'graphCore.loading': '핵심 구조 계산 중…', + 'graphCore.errorPrefix': '그래프를 불러올 수 없습니다:', + 'graphCore.retry': '다시 시도', + 'graphCore.empty': '아직 지식 그래프가 없습니다.', + 'graphCore.emptyHint': '어시스턴트가 연결된 사실을 기록하면 밀집된 핵심이 여기에 나타납니다.', + 'graphCore.namespaceLabel': '네임스페이스', + 'graphCore.namespaceAll': '모든 네임스페이스', + 'graphCore.metricEntities': '개체', + 'graphCore.metricConnections': '연결', + 'graphCore.metricDegeneracy': '퇴화도', + 'graphCore.degeneracyCaption': '가장 밀집된 셸: {degeneracy}-core · {coreSize}개 개체', + 'graphCore.shellsHeading': '셸 분해', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… 그리고 {count}개의 셸 더', + 'graphCore.rankedHeading': '가장 깊은 핵심 개체', + 'graphCore.colRank': '#', + 'graphCore.colEntity': '개체', + 'graphCore.colCore': '핵심', + 'graphCore.colLinks': '링크', + 'graphCore.coreBadge': '핵심', + 'graphCore.coreTitle': + '가장 밀집된 {degeneracy}-core — 여기의 모든 구성원은 다른 핵심 구성원과 최소 {degeneracy}개의 링크를 유지합니다.', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 27c9d94351..d00991e6e8 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4509,6 +4509,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Średnia klasteryzacja {avg} · tranzytywność {transitivity}', 'graphCohesion.title': 'Spójność grafu', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Rdzeń', + 'graphCore.title': 'Rdzeń grafu', + 'graphCore.intro': + 'Dekompozycja k-core oddziela nośny rdzeń wiedzy — encje wzajemnie wzmacniane przez wiele powiązanych faktów — od peryferii złożonej z liści i mostów. Nawet wysoce połączony węzeł z jednostkowymi faktami ma coreness równy 1; to głębokość, a nie stopień, wyznacza rdzeń.', + 'graphCore.loading': 'Obliczanie struktury rdzenia…', + 'graphCore.errorPrefix': 'Nie można załadować grafu:', + 'graphCore.retry': 'Spróbuj ponownie', + 'graphCore.empty': 'Brak grafu wiedzy.', + 'graphCore.emptyHint': + 'Gdy asystent zarejestruje połączone fakty na Twój temat, gęsto powiązany rdzeń pojawi się tutaj.', + 'graphCore.namespaceLabel': 'Przestrzeń nazw', + 'graphCore.namespaceAll': 'Wszystkie przestrzenie nazw', + 'graphCore.metricEntities': 'Encje', + 'graphCore.metricConnections': 'Połączenia', + 'graphCore.metricDegeneracy': 'Degeneracja', + 'graphCore.degeneracyCaption': 'Najgęstsza powłoka: {degeneracy}-core · {coreSize} encji', + 'graphCore.shellsHeading': 'Dekompozycja powłokowa', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… i jeszcze {count} powłok', + 'graphCore.rankedHeading': 'Encje najgłębszego rdzenia', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Encja', + 'graphCore.colCore': 'Rdzeń', + 'graphCore.colLinks': 'Łącza', + 'graphCore.coreBadge': 'rdzeń', + 'graphCore.coreTitle': + 'W najgęstszym {degeneracy}-core — każdy członek utrzymuje co najmniej {degeneracy} połączeń z innymi członkami rdzenia.', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index e0d058d990..acba1d38ba 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4508,6 +4508,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Agrupamento médio {avg} · transitividade {transitivity}', 'graphCohesion.title': 'Coesão do grafo', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Núcleo', + 'graphCore.title': 'Núcleo do grafo', + 'graphCore.intro': + 'A decomposição k-core separa o núcleo estrutural do seu conhecimento — entidades mutuamente reforçadas por muitos fatos interligados — da periferia de folhas e pontes. Mesmo um hub de alto grau com fatos avulsos tem coreness 1; a profundidade, não o grau, marca o núcleo.', + 'graphCore.loading': 'Calculando a estrutura do núcleo…', + 'graphCore.errorPrefix': 'Não foi possível carregar o grafo:', + 'graphCore.retry': 'Tentar novamente', + 'graphCore.empty': 'Nenhum grafo de conhecimento ainda.', + 'graphCore.emptyHint': + 'À medida que o assistente registar factos conectados sobre si, o núcleo densamente ligado surgirá aqui.', + 'graphCore.namespaceLabel': 'Espaço de nomes', + 'graphCore.namespaceAll': 'Todos os espaços de nomes', + 'graphCore.metricEntities': 'Entidades', + 'graphCore.metricConnections': 'Conexões', + 'graphCore.metricDegeneracy': 'Degeneração', + 'graphCore.degeneracyCaption': 'Camada mais densa: {degeneracy}-core · {coreSize} entidades', + 'graphCore.shellsHeading': 'Decomposição em camadas', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… e mais {count} camadas', + 'graphCore.rankedHeading': 'Entidades do núcleo mais profundo', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Entidade', + 'graphCore.colCore': 'Núcleo', + 'graphCore.colLinks': 'Ligações', + 'graphCore.coreBadge': 'núcleo', + 'graphCore.coreTitle': + 'No {degeneracy}-core mais denso — cada membro aqui mantém pelo menos {degeneracy} ligações para outros membros do núcleo.', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 4082908b76..f3cea40e07 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4478,6 +4478,33 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Средняя кластеризация {avg} · транзитивность {transitivity}', 'graphCohesion.title': 'Связность графа', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': 'Ядро', + 'graphCore.title': 'Ядро графа', + 'graphCore.intro': + 'Разложение k-core выделяет несущее ядро ваших знаний — сущности, взаимно подкреплённые многими взаимосвязанными фактами — из периферии листьев и мостов. Даже высококонтактный хаб из разрозненных фактов имеет coreness 1; ядро определяется глубиной, а не степенью.', + 'graphCore.loading': 'Вычисление структуры ядра…', + 'graphCore.errorPrefix': 'Не удалось загрузить граф:', + 'graphCore.retry': 'Повторить', + 'graphCore.empty': 'Граф знаний пока отсутствует.', + 'graphCore.emptyHint': + 'По мере того как ассистент фиксирует связанные факты о вас, плотно связанное ядро появится здесь.', + 'graphCore.namespaceLabel': 'Пространство имён', + 'graphCore.namespaceAll': 'Все пространства имён', + 'graphCore.metricEntities': 'Сущности', + 'graphCore.metricConnections': 'Связи', + 'graphCore.metricDegeneracy': 'Вырожденность', + 'graphCore.degeneracyCaption': 'Плотнейший слой: {degeneracy}-core · {coreSize} сущностей', + 'graphCore.shellsHeading': 'Послойное разложение', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… и ещё {count} оболочек', + 'graphCore.rankedHeading': 'Сущности глубочайшего ядра', + 'graphCore.colRank': '#', + 'graphCore.colEntity': 'Сущность', + 'graphCore.colCore': 'Ядро', + 'graphCore.colLinks': 'Связи', + 'graphCore.coreBadge': 'ядро', + 'graphCore.coreTitle': + 'В плотнейшем {degeneracy}-core — каждый участник поддерживает не менее {degeneracy} связей с другими участниками ядра.', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f1f2a1cfa6..2925226ac0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4224,6 +4224,32 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': '平均聚类系数 {avg} · 传递性 {transitivity}', 'graphCohesion.title': '图的凝聚度', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.core': '核心', + 'graphCore.title': '图核心', + 'graphCore.intro': + 'k-core 分解将知识的承重核心——被许多相互关联的事实共同强化的实体——从叶子和桥梁的外围中分离出来。即使是充满一次性事实的高度数枢纽,其 coreness 也仅为 1;标志核心的是深度,而非度数。', + 'graphCore.loading': '正在计算核心结构…', + 'graphCore.errorPrefix': '无法加载图:', + 'graphCore.retry': '重试', + 'graphCore.empty': '暂无知识图谱。', + 'graphCore.emptyHint': '随着助手记录关于您的关联事实,密集连接的核心将在此显示。', + 'graphCore.namespaceLabel': '命名空间', + 'graphCore.namespaceAll': '所有命名空间', + 'graphCore.metricEntities': '实体', + 'graphCore.metricConnections': '连接', + 'graphCore.metricDegeneracy': '简并度', + 'graphCore.degeneracyCaption': '最密集的壳层:{degeneracy}-core · {coreSize} 个实体', + 'graphCore.shellsHeading': '壳层分解', + 'graphCore.shellLabel': '{k}-core', + 'graphCore.shellsMore': '… 以及另外 {count} 个层', + 'graphCore.rankedHeading': '最深核心的实体', + 'graphCore.colRank': '#', + 'graphCore.colEntity': '实体', + 'graphCore.colCore': '核心', + 'graphCore.colLinks': '链接', + 'graphCore.coreBadge': '核心', + 'graphCore.coreTitle': + '在最密集的 {degeneracy}-core 中——这里的每个成员与其他核心成员保持至少 {degeneracy} 条链接。', }; export default messages; diff --git a/app/src/lib/memory/graphCore.test.ts b/app/src/lib/memory/graphCore.test.ts new file mode 100644 index 0000000000..3e22f6cc1b --- /dev/null +++ b/app/src/lib/memory/graphCore.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeGraphCore, kCoreSize } from './graphCore'; + +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 coreness(result: ReturnType, id: string): number { + const node = result.nodes.find(n => n.id === id); + if (!node) throw new Error(`node ${id} not found`); + return node.coreness; +} + +describe('computeGraphCore — basic shapes', () => { + it('returns an empty result for no relations', () => { + const r = computeGraphCore([]); + expect(r.nodeCount).toBe(0); + expect(r.edgeCount).toBe(0); + expect(r.degeneracy).toBe(0); + expect(r.shells).toEqual([]); + expect(r.nodes).toEqual([]); + }); + + it('a single triangle is a uniform 2-core', () => { + const r = computeGraphCore([rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.nodeCount).toBe(3); + expect(r.edgeCount).toBe(3); + expect(r.degeneracy).toBe(2); + for (const id of ['A', 'B', 'C']) expect(coreness(r, id)).toBe(2); + expect(r.shells).toEqual([{ k: 2, count: 3 }]); + }); + + it('a path is a 1-core (no node survives to the 2-core)', () => { + const r = computeGraphCore([rel('A', 'B'), rel('B', 'C')]); + expect(r.degeneracy).toBe(1); + for (const id of ['A', 'B', 'C']) expect(coreness(r, id)).toBe(1); + expect(r.shells).toEqual([{ k: 1, count: 3 }]); + }); + + it('a star (tree) is a 1-core: the hub has high degree but coreness 1', () => { + const r = computeGraphCore([rel('X', 'A'), rel('X', 'B'), rel('X', 'C')]); + expect(r.degeneracy).toBe(1); + expect(r.nodes.find(n => n.id === 'X')?.degree).toBe(3); + expect(coreness(r, 'X')).toBe(1); // high degree, shallow core + expect(r.shells).toEqual([{ k: 1, count: 4 }]); + }); + + it('K4 (complete graph on four) is a uniform 3-core', () => { + const r = computeGraphCore([ + rel('A', 'B'), + rel('A', 'C'), + rel('A', 'D'), + rel('B', 'C'), + rel('B', 'D'), + rel('C', 'D'), + ]); + expect(r.nodeCount).toBe(4); + expect(r.edgeCount).toBe(6); + expect(r.degeneracy).toBe(3); + for (const id of ['A', 'B', 'C', 'D']) expect(coreness(r, id)).toBe(3); + }); +}); + +describe('computeGraphCore — core vs periphery separation', () => { + // Triangle A-B-C (a 2-core) with a pendant D hanging off A. + const r = computeGraphCore([rel('A', 'B'), rel('B', 'C'), rel('C', 'A'), rel('A', 'D')]); + + it('peels the pendant to coreness 1 while the triangle stays at 2', () => { + expect(coreness(r, 'A')).toBe(2); + expect(coreness(r, 'B')).toBe(2); + expect(coreness(r, 'C')).toBe(2); + expect(coreness(r, 'D')).toBe(1); + expect(r.degeneracy).toBe(2); + }); + + it('reports the shell decomposition (k DESC)', () => { + expect(r.shells).toEqual([ + { k: 2, count: 3 }, + { k: 1, count: 1 }, + ]); + }); + + it('A keeps the high degree but shares the core with the triangle', () => { + // A is adjacent to B, C and D -> degree 3, yet coreness 2 (pendant excluded). + expect(r.nodes.find(n => n.id === 'A')?.degree).toBe(3); + }); + + it('sorts nodes coreness DESC, then degree DESC, then id ASC', () => { + // Core nodes first; among the coreness-2 trio, A (degree 3) leads B, C. + expect(r.nodes[0]).toMatchObject({ id: 'A', coreness: 2, degree: 3 }); + expect(r.nodes[r.nodes.length - 1].id).toBe('D'); // the periphery sinks last + }); +}); + +describe('kCoreSize', () => { + const r = computeGraphCore([rel('A', 'B'), rel('B', 'C'), rel('C', 'A'), rel('A', 'D')]); + + it('counts nodes with coreness >= k (nested cores)', () => { + expect(kCoreSize(r, 1)).toBe(4); // everyone is in the 1-core + expect(kCoreSize(r, 2)).toBe(3); // only the triangle is in the 2-core + expect(kCoreSize(r, 3)).toBe(0); // no 3-core exists here + }); +}); + +describe('computeGraphCore — normalization & determinism', () => { + it('drops the self-loop EDGE but keeps the endpoint as a node', () => { + const r = computeGraphCore([rel('A', 'A'), rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]); + expect(r.nodeCount).toBe(3); + expect(r.edgeCount).toBe(3); + expect(r.degeneracy).toBe(2); // self-loop edge ignored -> plain triangle + }); + + it('preserves an entity whose only relation is a self-loop (no empty result)', () => { + // A user with the single fact "Alice→Alice" must still see Alice in the + // graph (degree 0, coreness 0), not vanish into the empty state. + const r = computeGraphCore([rel('Alice', 'Alice')]); + expect(r.nodeCount).toBe(1); + expect(r.edgeCount).toBe(0); + expect(r.degeneracy).toBe(0); + expect(r.nodes).toEqual([{ id: 'Alice', degree: 0, coreness: 0 }]); + expect(r.shells).toEqual([{ k: 0, count: 1 }]); + }); + + it('collapses parallel edges and ignores direction', () => { + const r = computeGraphCore([ + rel('A', 'B', 'knows'), + rel('B', 'A', 'likes'), + rel('A', 'B', 'trusts'), + rel('B', 'C'), + rel('C', 'A'), + ]); + expect(r.edgeCount).toBe(3); + expect(r.degeneracy).toBe(2); + }); + + it('drops malformed relations (non-string subject/object)', () => { + const malformed = { ...rel('A', 'B'), subject: 42 as unknown as string }; + const r = computeGraphCore([rel('A', 'B'), rel('B', 'C'), rel('C', 'A'), malformed]); + expect(r.nodeCount).toBe(3); + expect(r.degeneracy).toBe(2); + }); + + it('treats "Alice" and "alice" as distinct nodes (no case-folding)', () => { + const r = computeGraphCore([rel('Alice', 'Bob'), rel('alice', 'Bob')]); + expect(r.nodeCount).toBe(3); + }); + + it('is order-independent: shuffled input yields identical output', () => { + const edges = [ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'A'), + rel('A', 'D'), + rel('D', 'B'), + rel('D', 'C'), + rel('E', 'A'), + ]; + const forward = computeGraphCore(edges); + const reversed = computeGraphCore([...edges].reverse()); + const rotated = computeGraphCore([...edges.slice(3), ...edges.slice(0, 3)]); + expect(reversed).toEqual(forward); + expect(rotated).toEqual(forward); + }); +}); diff --git a/app/src/lib/memory/graphCore.ts b/app/src/lib/memory/graphCore.ts new file mode 100644 index 0000000000..583279ab9e --- /dev/null +++ b/app/src/lib/memory/graphCore.ts @@ -0,0 +1,214 @@ +/** + * Graph Core — pure k-core decomposition engine. + * + * The memory knowledge graph is a set of (subject)-[predicate]->(object) + * triples. Where the Centrality lens asks "which entities are important" + * (PageRank), this lens asks a complementary question: "how DEEP in the + * densely-connected core does each entity sit". + * + * The k-core of a graph is the maximal subgraph in which every vertex has at + * least k neighbours INSIDE that subgraph. A vertex's CORENESS is the largest k + * for which it survives in the k-core — found by repeatedly peeling away the + * lowest-degree vertex. The DEGENERACY of the whole graph is the maximum + * coreness: the depth of its densest shell. + * + * Why it matters: coreness separates the load-bearing CORE of the user's + * knowledge (entities mutually reinforced by many interlinked facts, high + * coreness) from the PERIPHERY (leaves and bridges, coreness 1). Neither a + * degree count nor PageRank captures this — a hub with many one-off pendants has + * high degree but coreness 1, while a modest entity embedded in a dense cluster + * has low degree but high coreness. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. Coreness is a graph INVARIANT (independent of peel order), and the + * peeling is seeded from a canonically id-sorted vertex list, 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 — a self-loop is not a neighbour and cannot deepen a + * core. The endpoint of a self-loop is still REGISTERED as a node, so a + * user whose only recorded fact is "A→A" still appears with coreness 0 + * rather than vanishing into an empty result. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface CoreNode { + id: string; + degree: number; // undirected degree in the full graph + coreness: number; // largest k such that this node is in the k-core +} + +export interface CoreShell { + k: number; // coreness level + count: number; // number of nodes whose coreness is EXACTLY k +} + +export interface CoreResult { + nodes: CoreNode[]; // sorted coreness DESC, then degree DESC, then id ASC + nodeCount: number; + edgeCount: number; // distinct undirected edges (self-loops excluded) + degeneracy: number; // max coreness over all nodes (0 if empty) + shells: CoreShell[]; // shell decomposition, sorted k DESC +} + +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 (neighbour-less) + * NODE so a user whose only recorded fact is "A→A" still appears in the graph + * with degree 0 and coreness 0, rather than vanishing into an empty result. */ +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. + neighbours(subject); + continue; + } + neighbours(subject).add(object); + neighbours(object).add(subject); + } + return adjacency; +} + +/** + * Core numbers via the Batagelj–Zaversnik O(V+E) bucket algorithm. `neighbours` + * is an index-based adjacency list; the returned array holds each vertex's + * coreness at the matching index. The vertex set must already be in a canonical + * order (we feed it id-sorted) so intermediate bucketing is reproducible. + */ +function corenessByIndex(neighbours: number[][]): number[] { + const n = neighbours.length; + const degree = new Array(n); + let maxDegree = 0; + for (let i = 0; i < n; i += 1) { + degree[i] = neighbours[i].length; + if (degree[i] > maxDegree) maxDegree = degree[i]; + } + + // Bin-sort vertices by current degree into vert[], tracking each vertex's + // position in pos[]. bin[d] marks where the degree-d block starts. + const bin = new Array(maxDegree + 1).fill(0); + for (let i = 0; i < n; i += 1) bin[degree[i]] += 1; + let start = 0; + for (let d = 0; d <= maxDegree; d += 1) { + const count = bin[d]; + bin[d] = start; + start += count; + } + const pos = new Array(n); + const vert = new Array(n); + for (let i = 0; i < n; i += 1) { + pos[i] = bin[degree[i]]; + vert[pos[i]] = i; + bin[degree[i]] += 1; + } + // Shift bin starts back: bin[d] now again points at the degree-d block start. + for (let d = maxDegree; d >= 1; d -= 1) bin[d] = bin[d - 1]; + bin[0] = 0; + + // Peel: process vertices in increasing current degree. When a vertex is + // removed, each still-present higher-degree neighbour drops one degree and + // slides one slot left into the next-lower bucket. + for (let i = 0; i < n; i += 1) { + const v = vert[i]; + for (const u of neighbours[v]) { + if (degree[u] > degree[v]) { + const du = degree[u]; + const pu = pos[u]; + const pw = bin[du]; + const w = vert[pw]; + if (u !== w) { + pos[u] = pw; + vert[pu] = w; + pos[w] = pu; + vert[pw] = u; + } + bin[du] += 1; + degree[u] -= 1; + } + } + } + + return degree; // degree[i] is now the core number of vertex i +} + +/** Compute the k-core decomposition of the memory graph. PURE. */ +export function computeGraphCore(relations: GraphRelation[]): CoreResult { + const adjacency = buildAdjacency(relations); + + // Canonical, id-sorted vertex ordering -> reproducible bucketing. + 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 coreness = corenessByIndex(neighbours.map(list => [...list])); + + const nodes: CoreNode[] = ids.map((id, i) => ({ + id, + degree: neighbours[i].length, + coreness: coreness[i], + })); + + nodes.sort((a, b) => { + if (b.coreness !== a.coreness) return b.coreness - a.coreness; + if (b.degree !== a.degree) return b.degree - a.degree; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + + let degeneracy = 0; + const shellCounts = new Map(); + for (const node of nodes) { + if (node.coreness > degeneracy) degeneracy = node.coreness; + shellCounts.set(node.coreness, (shellCounts.get(node.coreness) ?? 0) + 1); + } + const shells: CoreShell[] = [...shellCounts.entries()] + .map(([k, count]) => ({ k, count })) + .sort((a, b) => b.k - a.k); + + return { nodes, nodeCount: ids.length, edgeCount: edgeDegreeSum / 2, degeneracy, shells }; +} + +/** + * Size of the k-core: the number of nodes whose coreness is >= k (the nested + * subgraph in which every member has at least k neighbours among the members). + * Pure; derived entirely from the result. + */ +export function kCoreSize(result: CoreResult, k: number): number { + let size = 0; + for (const node of result.nodes) { + if (node.coreness >= k) size += 1; + } + return size; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 9235646219..88c1a0f972 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 GraphCoreTab from '../components/intelligence/GraphCoreTab'; 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' + | 'core' | '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: 'core', label: t('memory.tab.core') }, { 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 === 'core' && } + {activeTab === 'associations' && } {activeTab === 'freshness' && } diff --git a/app/src/services/api/graphCoreApi.test.ts b/app/src/services/api/graphCoreApi.test.ts new file mode 100644 index 0000000000..9ff9261e30 --- /dev/null +++ b/app/src/services/api/graphCoreApi.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeGraphCore } from '../../lib/memory/graphCore'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { graphCoreApi, loadCore, loadNamespaces } from './graphCoreApi'; + +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('graphCoreApi.loadCore', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine result for those triples', async () => { + const triples = [rel('A', 'B'), rel('B', 'C'), rel('C', 'A')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadCore('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeGraphCore(triples)); + expect(out.degeneracy).toBe(2); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadCore(); + 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(loadCore()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('graphCoreApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('graphCoreApi object', () => { + it('exposes the public surface', () => { + expect(typeof graphCoreApi.loadCore).toBe('function'); + expect(typeof graphCoreApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/graphCoreApi.ts b/app/src/services/api/graphCoreApi.ts new file mode 100644 index 0000000000..bc33b7dfe4 --- /dev/null +++ b/app/src/services/api/graphCoreApi.ts @@ -0,0 +1,29 @@ +/** + * RPC facade for Graph Core (k-core decomposition). + * + * 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 { computeGraphCore, type CoreResult } from '../../lib/memory/graphCore'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('graph-core:api'); + +/** Fetch the graph relations for a namespace (or all) and decompose into cores. */ +export async function loadCore(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + log('loadCore namespace=%s relations=%d', namespace ?? '(all)', relations.length); + return computeGraphCore(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const graphCoreApi = { loadCore, loadNamespaces };