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