diff --git a/app/src/components/intelligence/TriadClosurePanel.test.tsx b/app/src/components/intelligence/TriadClosurePanel.test.tsx new file mode 100644 index 0000000000..170de2cdd5 --- /dev/null +++ b/app/src/components/intelligence/TriadClosurePanel.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeTriadClosure } from '../../lib/memory/triadClosure'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import TriadClosurePanel from './TriadClosurePanel'; + +function rel(subject: string, object: string): GraphRelation { + return { + namespace: 'n', + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// Two intermediaries for (A, C) -> support=2, score = 2 / log(3). +const populated = computeTriadClosure([rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C')]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('triad-closure-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 summary caption, and the suggested-edge worklist with intermediaries', () => { + render(); + expect(screen.getByText('Suggested edges')).toBeInTheDocument(); + expect(screen.getByText('Candidate pairs')).toBeInTheDocument(); + expect(screen.getByText('Minimum support')).toBeInTheDocument(); + expect(screen.getByText('Suggested edges to consider')).toBeInTheDocument(); + // Subject A and object C appear as the suggested edge. + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('C')).toBeInTheDocument(); + // Intermediary chips B and D render alphabetically. + expect(screen.getByText('B')).toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + // Score 2 / log(3) ≈ 1.820 rounds to 3dp as "1.820". + expect(screen.getByText('1.820')).toBeInTheDocument(); + }); + + it('shows the all-filtered caption when every candidate is below minSupport', () => { + // Single intermediary -> support=1 < default minSupport=2 -> all filtered. + const filtered = computeTriadClosure([rel('A', 'B'), rel('B', 'C')]); + render(); + expect(screen.getByText(/1 candidate pairs filtered out by support floor/)).toBeInTheDocument(); + }); + + it('shows the no-candidates caption when the graph has no open wedges', () => { + // Single edge -> no wedge possible. + const flat = computeTriadClosure([rel('A', 'B')]); + render(); + expect( + screen.getByText('No open triads — the graph has no wedges to close.') + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/TriadClosurePanel.tsx b/app/src/components/intelligence/TriadClosurePanel.tsx new file mode 100644 index 0000000000..7c7489ca8a --- /dev/null +++ b/app/src/components/intelligence/TriadClosurePanel.tsx @@ -0,0 +1,205 @@ +/** + * Triad Closure — presentational view. Pure: renders the summary tiles + * (candidates / minSupport / nodes), an empty-state when minSupport filters + * everything out, and a ranked worklist of suggested edges with their + * Adamic-Adar score + intermediaries. No data fetching, no clock, no + * randomness. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { TriadClosureResult } from '../../lib/memory/triadClosure'; + +const MAX_ROWS = 25; +const MAX_INTERMEDIARIES_SHOWN = 5; + +interface TriadClosurePanelProps { + result: TriadClosureResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const TriadClosurePanel = ({ result, loading, error, onRetry }: TriadClosurePanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('triadClosure.title')}

+

{t('triadClosure.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + const rows = result.hints.slice(0, MAX_ROWS); + const maxScore = rows.reduce((m, h) => (h.score > m ? h.score : m), 0); + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('triadClosure.metricHints'), value: result.hints.length }, + { label: t('triadClosure.metricCandidates'), value: result.candidatePairCount }, + { label: t('triadClosure.metricSupport'), value: `≥${result.minSupport}` }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('triadClosure.summaryCaption') + .replace('{nodes}', String(result.nodeCount)) + .replace('{edges}', String(result.edgeCount))} + {result.truncated && ( + + {t('triadClosure.truncatedBadge')} + + )} +

+ + {/* Hints worklist */} + {rows.length === 0 ? ( +

+ {result.candidatePairCount === 0 + ? t('triadClosure.noCandidates') + : t('triadClosure.allFiltered').replace('{count}', String(result.candidatePairCount))} +

+ ) : ( +
+

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

+
    + {rows.map((row, i) => { + const widthPct = + maxScore === 0 ? 0 : Math.max(0, Math.min(100, (row.score / maxScore) * 100)); + const shownIntermediaries = row.intermediaries.slice(0, MAX_INTERMEDIARIES_SHOWN); + const extra = row.intermediaries.length - shownIntermediaries.length; + return ( +
  • +
    + + {i + 1} + + {row.subject} + + {t('triadClosure.suggestEdgeTo')} + {row.object} + + {row.score.toFixed(3)} + +
    +
    +
    +
    +
    + + {t('triadClosure.viaPrefix')} + + {shownIntermediaries.map(b => ( + + {b} + + ))} + {extra > 0 && ( + + {t('triadClosure.extraIntermediaries').replace('{n}', String(extra))} + + )} +
    +
  • + ); + })} +
+
+ )} +
+ ); +}; + +export default TriadClosurePanel; diff --git a/app/src/components/intelligence/TriadClosureTab.test.tsx b/app/src/components/intelligence/TriadClosureTab.test.tsx new file mode 100644 index 0000000000..fa2d2e1c5f --- /dev/null +++ b/app/src/components/intelligence/TriadClosureTab.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeTriadClosure } from '../../lib/memory/triadClosure'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import TriadClosureTab from './TriadClosureTab'; + +const mockLoadClosure = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/triadClosureApi', () => ({ + loadTriadClosure: (...args: unknown[]) => mockLoadClosure(...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 = computeTriadClosure([rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C')]); + +describe('', () => { + beforeEach(() => { + mockLoadClosure.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadClosure.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads hints (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadClosure).toHaveBeenCalledWith(undefined); + await waitFor(() => + expect(screen.getByText('Suggested edges to consider')).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(mockLoadClosure).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadClosure.mockReset(); + mockLoadClosure.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/TriadClosureTab.tsx b/app/src/components/intelligence/TriadClosureTab.tsx new file mode 100644 index 0000000000..1097777661 --- /dev/null +++ b/app/src/components/intelligence/TriadClosureTab.tsx @@ -0,0 +1,83 @@ +/** + * Triad Closure 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 { TriadClosureResult } from '../../lib/memory/triadClosure'; +import { loadNamespaces, loadTriadClosure } from '../../services/api/triadClosureApi'; +import TriadClosurePanel from './TriadClosurePanel'; + +const TriadClosureTab = () => { + 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 loadTriadClosure(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 worklist, 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 TriadClosureTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d5e578503b..a5b619d16f 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4350,6 +4350,31 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', 'pages.settings.account.security': 'الأمان', 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', + 'triadClosure.title': 'تلميحات إكمال الرسم البياني', + 'triadClosure.intro': + 'كل العدسات الأخرى تقيس علاقات موجودة فعلًا. أمّا هذه فتكشف ما هو مفقود: لكل زوج مرتّب (A، C) دون حافة مباشرة لكن مع عدة وسطاء مشتركين A→B→C، تقترح إنشاء A→C. تُرتَّب التلميحات حسب درجة Adamic–Adar — الوسطاء ذوو الدرجة المنخفضة يحملون وزنًا أكبر، لأن B التي لا تعرف سوى A وC دليل أقوى بكثير من محور ضخم يعرف الجميع.', + 'triadClosure.loading': 'يجري حساب تلميحات الإغلاق…', + 'triadClosure.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'triadClosure.retry': 'إعادة المحاولة', + 'triadClosure.empty': 'لا يوجد رسم معرفة بعد.', + 'triadClosure.emptyHint': 'كلما سجّل المساعد علاقات عنك، ستظهر هنا الحواف المقترحة لإغلاقها.', + 'triadClosure.namespaceLabel': 'مساحة الأسماء', + 'triadClosure.namespaceAll': 'كل مساحات الأسماء', + 'triadClosure.metricHints': 'حواف مقترحة', + 'triadClosure.metricCandidates': 'أزواج مرشّحة', + 'triadClosure.metricSupport': 'الحد الأدنى للدعم', + 'triadClosure.summaryCaption': '{nodes} كيانات · {edges} حواف موجّهة', + 'triadClosure.truncatedBadge': 'مقتطع', + 'triadClosure.truncatedTitle': + 'بلغت عقدة مصدر كثيفة الروابط حدّ الإسفين لكل مصدر — قد تكون بعض التلميحات مفقودة لذلك المصدر.', + 'triadClosure.noCandidates': 'لا توجد ثلاثيات مفتوحة — ليس في الرسم أيّ إسفين لإغلاقه.', + 'triadClosure.allFiltered': + 'تمت تصفية {count} زوجًا مرشّحًا بسبب حد الدعم — كل إسفين إغلاق كان بوسيط واحد فقط. خفّض minSupport لرؤيتها.', + 'triadClosure.rankedHeading': 'حواف مقترحة للنظر فيها', + 'triadClosure.suggestEdgeTo': 'اقترح حافة إلى', + 'triadClosure.viaPrefix': 'عبر', + 'triadClosure.extraIntermediaries': '+{n} المزيد', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index fb196b753d..0f9a35eace 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4427,6 +4427,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', 'pages.settings.account.security': 'নিরাপত্তা', 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', + 'triadClosure.title': 'গ্রাফ পরিপূরণ ইঙ্গিত', + 'triadClosure.intro': + 'অন্য প্রতিটি লেন্স ইতিমধ্যেই বিদ্যমান সম্পর্ক মাপে। এটি উন্মোচন করে যা অনুপস্থিত: প্রতিটি ক্রমিক জোড়া (A, C) যাদের সরাসরি প্রান্ত নেই কিন্তু একাধিক সাধারণ মধ্যবর্তী A→B→C আছে, তাদের জন্য A→C তৈরির প্রস্তাব দেয়। ইঙ্গিতগুলো Adamic–Adar স্কোর দ্বারা র‍্যাঙ্ক করা হয় — কম-ডিগ্রির মধ্যবর্তী বেশি ওজন বহন করে, কারণ যে B শুধু A আর C কে জানে, তা সবাইকে চেনা মেগা-হাবের চেয়ে অনেক জোরালো প্রমাণ।', + 'triadClosure.loading': 'সমাপ্তি ইঙ্গিত গণনা করা হচ্ছে…', + 'triadClosure.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'triadClosure.retry': 'পুনরায় চেষ্টা', + 'triadClosure.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'triadClosure.emptyHint': + 'সহকারী যখন আপনার সম্পর্কে সম্পর্কগুলো রেকর্ড করে, সুপারিশকৃত সমাপ্তি-প্রান্তগুলো এখানে উঠে আসবে।', + 'triadClosure.namespaceLabel': 'নেমস্পেস', + 'triadClosure.namespaceAll': 'সমস্ত নেমস্পেস', + 'triadClosure.metricHints': 'সুপারিশকৃত প্রান্ত', + 'triadClosure.metricCandidates': 'প্রার্থী জোড়া', + 'triadClosure.metricSupport': 'ন্যূনতম সমর্থন', + 'triadClosure.summaryCaption': '{nodes}টি সত্তা · {edges}টি দিকনির্দেশিত প্রান্ত', + 'triadClosure.truncatedBadge': 'ছাঁটাই করা', + 'triadClosure.truncatedTitle': + 'অনেক সংযোগওয়ালা একটি উৎস নোড প্রতি-উৎস ওয়েজ সীমা ছুঁয়ে ফেলেছে — সেই উৎসের জন্য কিছু ইঙ্গিত অনুপস্থিত হতে পারে।', + 'triadClosure.noCandidates': 'কোনো খোলা ত্রিকা নেই — গ্রাফে বন্ধ করার মতো কোনো ওয়েজ নেই।', + 'triadClosure.allFiltered': + 'সমর্থন সীমার কারণে {count}টি প্রার্থী জোড়া ছেঁকে বাদ পড়েছে — প্রতিটি সমাপ্তি-ওয়েজ একক-মধ্যবর্তী ছিল। সেগুলো দেখতে minSupport কমান।', + 'triadClosure.rankedHeading': 'বিবেচনার জন্য সুপারিশকৃত প্রান্ত', + 'triadClosure.suggestEdgeTo': 'প্রান্তের সুপারিশ — গন্তব্য:', + 'triadClosure.viaPrefix': 'মাধ্যমে', + 'triadClosure.extraIntermediaries': '+{n} আরও', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4b30e1572b..81578cf74c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4543,6 +4543,33 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', 'pages.settings.account.security': 'Sicherheit', 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', + 'triadClosure.title': 'Graph-Vervollständigungshinweise', + 'triadClosure.intro': + 'Jede andere Linse misst Relationen, die BEREITS existieren. Diese deckt auf, was FEHLT: für jedes geordnete Paar (A, C) ohne direkte Kante, aber mit mehreren gemeinsamen Zwischenknoten A→B→C wird das Erzeugen von A→C vorgeschlagen. Hinweise werden nach dem Adamic–Adar-Score gerankt — Zwischenknoten mit niedrigem Grad zählen mehr, denn ein B, das nur A und C kennt, ist viel stärkere Evidenz als ein Mega-Hub, der jeden kennt.', + 'triadClosure.loading': 'Berechne Schließungshinweise…', + 'triadClosure.errorPrefix': 'Graph konnte nicht geladen werden:', + 'triadClosure.retry': 'Wiederholen', + 'triadClosure.empty': 'Noch kein Wissensgraph.', + 'triadClosure.emptyHint': + 'Während der Assistent Relationen über Sie erfasst, erscheinen hier vorgeschlagene schließende Kanten.', + 'triadClosure.namespaceLabel': 'Namensraum', + 'triadClosure.namespaceAll': 'Alle Namensräume', + 'triadClosure.metricHints': 'Vorgeschlagene Kanten', + 'triadClosure.metricCandidates': 'Kandidatenpaare', + 'triadClosure.metricSupport': 'Mindest-Support', + 'triadClosure.summaryCaption': '{nodes} Entitäten · {edges} gerichtete Kanten', + 'triadClosure.truncatedBadge': 'gekürzt', + 'triadClosure.truncatedTitle': + 'Ein hub-lastiger Quellknoten hat das Wedge-Limit pro Quelle erreicht — einige Hinweise könnten für diese Quelle fehlen.', + 'triadClosure.noCandidates': + 'Keine offenen Triaden — der Graph hat keine zu schließenden Wedges.', + 'triadClosure.allFiltered': + '{count} Kandidatenpaare durch Support-Schwelle gefiltert — jedes schließende Wedge hatte nur einen Zwischenknoten. Senken Sie minSupport, um sie zu sehen.', + 'triadClosure.rankedHeading': 'Vorgeschlagene Kanten zur Berücksichtigung', + 'triadClosure.suggestEdgeTo': 'Kante vorschlagen zu', + 'triadClosure.viaPrefix': 'über', + 'triadClosure.extraIntermediaries': '+{n} weitere', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ba050c75d5..0dfc8e1273 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -307,6 +307,7 @@ const en: TranslationMap = { 'memory.tab.namespaces': 'Namespaces', 'memory.tab.timeline': 'Timeline', 'memory.tab.cohesion': 'Cohesion', + 'memory.tab.completion': 'Completion', 'memory.tab.settings': 'Settings', 'memory.tab.council': 'Council', 'modelCouncil.title': 'Model Council', @@ -488,6 +489,31 @@ const en: TranslationMap = { 'graphCohesion.brokerBadge': 'broker', 'graphCohesion.brokerTitle': "Structural hole: this entity's neighbours aren't connected to each other — it's the sole link between them.", + 'triadClosure.title': 'Graph Completion Hints', + 'triadClosure.intro': + "Every other lens measures relations that ALREADY exist. This one surfaces what's MISSING: for every ordered pair (A, C) with no direct edge but several shared intermediaries A→B→C, propose creating A→C. Hints are ranked by the Adamic–Adar score — low-degree intermediaries weigh more, because a B that only knows A and C is much stronger evidence than a mega-hub that knows everyone.", + 'triadClosure.loading': 'Computing closure hints…', + 'triadClosure.errorPrefix': 'Could not load the graph:', + 'triadClosure.retry': 'Retry', + 'triadClosure.empty': 'No knowledge graph yet.', + 'triadClosure.emptyHint': + 'As the assistant records relations about you, suggested closing edges will surface here.', + 'triadClosure.namespaceLabel': 'Namespace', + 'triadClosure.namespaceAll': 'All namespaces', + 'triadClosure.metricHints': 'Suggested edges', + 'triadClosure.metricCandidates': 'Candidate pairs', + 'triadClosure.metricSupport': 'Minimum support', + 'triadClosure.summaryCaption': '{nodes} entities · {edges} directed edges', + 'triadClosure.truncatedBadge': 'truncated', + 'triadClosure.truncatedTitle': + 'A hub-heavy source node hit the per-source wedge cap — some hints may be missing for that source.', + 'triadClosure.noCandidates': 'No open triads — the graph has no wedges to close.', + 'triadClosure.allFiltered': + '{count} candidate pairs filtered out by support floor — every closing wedge was single-intermediary. Lower minSupport to see them.', + 'triadClosure.rankedHeading': 'Suggested edges to consider', + 'triadClosure.suggestEdgeTo': 'suggest edge to', + 'triadClosure.viaPrefix': 'via', + 'triadClosure.extraIntermediaries': '+{n} more', // Memory Tree status panel (#1856 Part 1) 'memoryTree.status.title': 'Memory Tree', @@ -2500,7 +2526,7 @@ const en: TranslationMap = { 'app.openhumanLink.notifications.send': 'Send test notification', 'app.openhumanLink.notifications.sendFailed': "Couldn't send: {error}", 'app.openhumanLink.notifications.sent': - "Test notification sent. If you didn't receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.", + 'Test notification sent. If you didn’t receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.', 'app.openhumanLink.skipForNow': 'Skip for now', 'app.openhumanLink.telegramUnavailable': 'Telegram unavailable', 'app.openhumanLink.title.accounts': 'Connect your apps', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 6feea2220d..8a55b239fb 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4509,6 +4509,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rechazar almacenamiento local', 'pages.settings.account.security': 'Seguridad', 'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero', + 'triadClosure.title': 'Sugerencias de compleción del grafo', + 'triadClosure.intro': + 'Todas las demás lentes miden relaciones que YA existen. Esta revela lo que FALTA: para cada par ordenado (A, C) sin arista directa pero con varios intermediarios compartidos A→B→C, propone crear A→C. Las sugerencias se ordenan por la puntuación Adamic–Adar: los intermediarios de grado bajo pesan más, porque un B que solo conoce a A y a C es una evidencia mucho más fuerte que un mega-hub que conoce a todos.', + 'triadClosure.loading': 'Calculando sugerencias de cierre…', + 'triadClosure.errorPrefix': 'No se pudo cargar el grafo:', + 'triadClosure.retry': 'Reintentar', + 'triadClosure.empty': 'Aún no hay grafo de conocimiento.', + 'triadClosure.emptyHint': + 'A medida que el asistente registra relaciones sobre usted, aquí aparecerán las aristas de cierre sugeridas.', + 'triadClosure.namespaceLabel': 'Espacio de nombres', + 'triadClosure.namespaceAll': 'Todos los espacios de nombres', + 'triadClosure.metricHints': 'Aristas sugeridas', + 'triadClosure.metricCandidates': 'Pares candidatos', + 'triadClosure.metricSupport': 'Soporte mínimo', + 'triadClosure.summaryCaption': '{nodes} entidades · {edges} aristas dirigidas', + 'triadClosure.truncatedBadge': 'truncado', + 'triadClosure.truncatedTitle': + 'Un nodo fuente con muchos enlaces alcanzó el límite de cuñas por fuente; pueden faltar algunas sugerencias para esa fuente.', + 'triadClosure.noCandidates': 'Sin tríadas abiertas: el grafo no tiene cuñas por cerrar.', + 'triadClosure.allFiltered': + '{count} pares candidatos filtrados por el umbral de soporte — cada cuña de cierre tenía un solo intermediario. Reduzca minSupport para verlas.', + 'triadClosure.rankedHeading': 'Aristas sugeridas a considerar', + 'triadClosure.suggestEdgeTo': 'sugerir arista a', + 'triadClosure.viaPrefix': 'vía', + 'triadClosure.extraIntermediaries': '+{n} más', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e3b4395ada..863e67de7a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4524,6 +4524,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Refuser le stockage local', 'pages.settings.account.security': 'Sécurité', 'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau', + 'triadClosure.title': 'Suggestions de complétion du graphe', + 'triadClosure.intro': + "Toutes les autres lentilles mesurent les relations qui EXISTENT DÉJÀ. Celle-ci révèle ce qui MANQUE : pour chaque paire ordonnée (A, C) sans arête directe mais avec plusieurs intermédiaires partagés A→B→C, on propose de créer A→C. Les suggestions sont classées par score Adamic–Adar — les intermédiaires de faible degré pèsent plus, car un B qui ne connaît que A et C constitue une preuve bien plus forte qu'un méga-hub qui connaît tout le monde.", + 'triadClosure.loading': 'Calcul des suggestions de fermeture…', + 'triadClosure.errorPrefix': 'Impossible de charger le graphe :', + 'triadClosure.retry': 'Réessayer', + 'triadClosure.empty': 'Pas encore de graphe de connaissances.', + 'triadClosure.emptyHint': + "À mesure que l'assistant enregistre des relations à votre sujet, les arêtes de fermeture suggérées apparaîtront ici.", + 'triadClosure.namespaceLabel': 'Espace de noms', + 'triadClosure.namespaceAll': 'Tous les espaces de noms', + 'triadClosure.metricHints': 'Arêtes suggérées', + 'triadClosure.metricCandidates': 'Paires candidates', + 'triadClosure.metricSupport': 'Support minimum', + 'triadClosure.summaryCaption': '{nodes} entités · {edges} arêtes dirigées', + 'triadClosure.truncatedBadge': 'tronqué', + 'triadClosure.truncatedTitle': + 'Un nœud source à forte concentration a atteint le plafond de coins par source — certaines suggestions peuvent manquer pour cette source.', + 'triadClosure.noCandidates': "Aucune triade ouverte — le graphe n'a aucun coin à fermer.", + 'triadClosure.allFiltered': + "{count} paires candidates filtrées par le seuil de support — chaque coin de fermeture n'avait qu'un seul intermédiaire. Abaissez minSupport pour les voir.", + 'triadClosure.rankedHeading': 'Arêtes suggérées à considérer', + 'triadClosure.suggestEdgeTo': 'suggérer une arête vers', + 'triadClosure.viaPrefix': 'via', + 'triadClosure.extraIntermediaries': '+{n} de plus', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b63c812398..0e38077cf2 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4434,6 +4434,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', 'pages.settings.account.security': 'सुरक्षा', 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', + 'triadClosure.title': 'ग्राफ पूर्णता संकेत', + 'triadClosure.intro': + 'हर दूसरी लेंस उन संबंधों को मापती है जो पहले से मौजूद हैं। यह वह उजागर करती है जो गायब है: हर क्रमित जोड़ी (A, C) के लिए जिसमें कोई सीधा किनारा नहीं है लेकिन कई साझा मध्यवर्ती A→B→C हैं, A→C बनाने का सुझाव देती है। संकेत Adamic–Adar स्कोर से रैंक होते हैं — कम-डिग्री वाले मध्यवर्ती अधिक वज़न रखते हैं, क्योंकि जो B केवल A और C को जानता है वह सब को जानने वाले मेगा-हब से कहीं अधिक मज़बूत प्रमाण है।', + 'triadClosure.loading': 'समापन संकेत गणना हो रही है…', + 'triadClosure.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'triadClosure.retry': 'पुनः प्रयास', + 'triadClosure.empty': 'अभी तक कोई नॉलेज ग्राफ नहीं।', + 'triadClosure.emptyHint': + 'जैसे-जैसे सहायक आपके बारे में संबंध दर्ज करता है, सुझाए गए समापन-किनारे यहाँ उभरेंगे।', + 'triadClosure.namespaceLabel': 'नेमस्पेस', + 'triadClosure.namespaceAll': 'सभी नेमस्पेस', + 'triadClosure.metricHints': 'सुझाए गए किनारे', + 'triadClosure.metricCandidates': 'उम्मीदवार जोड़ियाँ', + 'triadClosure.metricSupport': 'न्यूनतम समर्थन', + 'triadClosure.summaryCaption': '{nodes} इकाइयाँ · {edges} दिशायुक्त किनारे', + 'triadClosure.truncatedBadge': 'काट-छाँट किया गया', + 'triadClosure.truncatedTitle': + 'अधिक-कनेक्शन वाले स्रोत नोड ने प्रति-स्रोत वेज सीमा को छू लिया — उस स्रोत के लिए कुछ संकेत गायब हो सकते हैं।', + 'triadClosure.noCandidates': 'कोई खुली त्रिकाएँ नहीं — ग्राफ में बंद करने योग्य कोई वेज नहीं।', + 'triadClosure.allFiltered': + 'समर्थन सीमा के कारण {count} उम्मीदवार जोड़ियाँ छन गईं — हर समापन-वेज एकल-मध्यवर्ती था। उन्हें देखने के लिए minSupport कम करें।', + 'triadClosure.rankedHeading': 'विचार करने योग्य सुझाए गए किनारे', + 'triadClosure.suggestEdgeTo': 'किनारा सुझाएँ — गंतव्य:', + 'triadClosure.viaPrefix': 'के माध्यम से', + 'triadClosure.extraIntermediaries': '+{n} और', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index b83ee91377..f9f4b0e515 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4443,6 +4443,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', 'pages.settings.account.security': 'Keamanan', 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', + 'triadClosure.title': 'Petunjuk Penyempurnaan Graf', + 'triadClosure.intro': + 'Setiap lensa lain mengukur relasi yang SUDAH ada. Yang satu ini mengungkap apa yang HILANG: untuk setiap pasangan terurut (A, C) tanpa tepi langsung tetapi dengan beberapa perantara bersama A→B→C, mengusulkan pembuatan A→C. Petunjuk diperingkat berdasarkan skor Adamic–Adar — perantara berderajat rendah berbobot lebih besar, karena B yang hanya mengenal A dan C adalah bukti yang jauh lebih kuat daripada mega-hub yang mengenal semua orang.', + 'triadClosure.loading': 'Menghitung petunjuk penutupan…', + 'triadClosure.errorPrefix': 'Tidak dapat memuat graf:', + 'triadClosure.retry': 'Coba lagi', + 'triadClosure.empty': 'Belum ada graf pengetahuan.', + 'triadClosure.emptyHint': + 'Saat asisten mencatat relasi tentang Anda, tepi penutup yang disarankan akan muncul di sini.', + 'triadClosure.namespaceLabel': 'Ruang nama', + 'triadClosure.namespaceAll': 'Semua ruang nama', + 'triadClosure.metricHints': 'Tepi yang disarankan', + 'triadClosure.metricCandidates': 'Pasangan kandidat', + 'triadClosure.metricSupport': 'Dukungan minimum', + 'triadClosure.summaryCaption': '{nodes} entitas · {edges} tepi terarah', + 'triadClosure.truncatedBadge': 'dipotong', + 'triadClosure.truncatedTitle': + 'Simpul sumber yang padat hub mencapai batas wedge per sumber — beberapa petunjuk mungkin hilang untuk sumber tersebut.', + 'triadClosure.noCandidates': 'Tidak ada triad terbuka — graf tidak memiliki wedge untuk ditutup.', + 'triadClosure.allFiltered': + '{count} pasangan kandidat tersaring oleh ambang dukungan — setiap wedge penutup hanya memiliki satu perantara. Turunkan minSupport untuk melihatnya.', + 'triadClosure.rankedHeading': 'Tepi yang disarankan untuk dipertimbangkan', + 'triadClosure.suggestEdgeTo': 'sarankan tepi ke', + 'triadClosure.viaPrefix': 'via', + 'triadClosure.extraIntermediaries': '+{n} lagi', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2426c1472c..2291af8535 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4501,6 +4501,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale', 'pages.settings.account.security': 'Sicurezza', 'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi', + 'triadClosure.title': 'Suggerimenti di completamento del grafo', + 'triadClosure.intro': + "Tutte le altre lenti misurano relazioni che GIÀ esistono. Questa rivela ciò che MANCA: per ogni coppia ordinata (A, C) senza arco diretto ma con diversi intermediari comuni A→B→C, propone di creare A→C. I suggerimenti sono ordinati per punteggio Adamic–Adar — gli intermediari a basso grado pesano di più, perché un B che conosce solo A e C è un'evidenza molto più forte di un mega-hub che conosce tutti.", + 'triadClosure.loading': 'Calcolo dei suggerimenti di chiusura…', + 'triadClosure.errorPrefix': 'Impossibile caricare il grafo:', + 'triadClosure.retry': 'Riprova', + 'triadClosure.empty': 'Ancora nessun grafo della conoscenza.', + 'triadClosure.emptyHint': + "Man mano che l'assistente registra relazioni su di te, qui appariranno gli archi di chiusura suggeriti.", + 'triadClosure.namespaceLabel': 'Spazio dei nomi', + 'triadClosure.namespaceAll': 'Tutti gli spazi dei nomi', + 'triadClosure.metricHints': 'Archi suggeriti', + 'triadClosure.metricCandidates': 'Coppie candidate', + 'triadClosure.metricSupport': 'Supporto minimo', + 'triadClosure.summaryCaption': '{nodes} entità · {edges} archi diretti', + 'triadClosure.truncatedBadge': 'troncato', + 'triadClosure.truncatedTitle': + 'Un nodo sorgente ad alta densità ha raggiunto il limite di cunei per sorgente — alcune indicazioni potrebbero mancare per quella sorgente.', + 'triadClosure.noCandidates': 'Nessuna triade aperta — il grafo non ha cunei da chiudere.', + 'triadClosure.allFiltered': + '{count} coppie candidate filtrate dalla soglia di supporto — ogni cuneo di chiusura aveva un solo intermediario. Abbassa minSupport per vederle.', + 'triadClosure.rankedHeading': 'Archi suggeriti da considerare', + 'triadClosure.suggestEdgeTo': 'suggerisci arco a', + 'triadClosure.viaPrefix': 'tramite', + 'triadClosure.extraIntermediaries': '+{n} altri', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ec68b50ab4..b9946816b7 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4393,6 +4393,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '로컬 저장소 거부', 'pages.settings.account.security': '보안', 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', + 'triadClosure.title': '그래프 완성 힌트', + 'triadClosure.intro': + '다른 모든 렌즈는 이미 존재하는 관계를 측정합니다. 이 렌즈는 누락된 것을 드러냅니다: 직접 간선은 없지만 여러 공유 중간자 A→B→C가 있는 모든 순서쌍 (A, C)에 대해 A→C 생성을 제안합니다. 힌트는 Adamic–Adar 점수로 순위가 매겨집니다 — 낮은 차수의 중간자가 더 큰 가중치를 가집니다. A와 C만 아는 B가 모두를 아는 메가 허브보다 훨씬 강한 증거이기 때문입니다.', + 'triadClosure.loading': '폐쇄 힌트 계산 중…', + 'triadClosure.errorPrefix': '그래프를 불러올 수 없습니다:', + 'triadClosure.retry': '다시 시도', + 'triadClosure.empty': '아직 지식 그래프가 없습니다.', + 'triadClosure.emptyHint': + '어시스턴트가 당신에 관한 관계들을 기록함에 따라, 추천 폐쇄 간선이 여기에 드러납니다.', + 'triadClosure.namespaceLabel': '네임스페이스', + 'triadClosure.namespaceAll': '모든 네임스페이스', + 'triadClosure.metricHints': '추천 간선', + 'triadClosure.metricCandidates': '후보 쌍', + 'triadClosure.metricSupport': '최소 지지도', + 'triadClosure.summaryCaption': '엔티티 {nodes}개 · 방향 간선 {edges}개', + 'triadClosure.truncatedBadge': '잘림', + 'triadClosure.truncatedTitle': + '허브가 많은 소스 노드가 소스당 웨지 한도에 도달했습니다 — 해당 소스에 대한 일부 힌트가 누락될 수 있습니다.', + 'triadClosure.noCandidates': '열린 트라이어드 없음 — 그래프에 닫을 웨지가 없습니다.', + 'triadClosure.allFiltered': + '지지도 임계값에 의해 후보 쌍 {count}개가 걸러졌습니다 — 각 폐쇄 웨지는 중간자가 하나뿐이었습니다. 보려면 minSupport를 낮추세요.', + 'triadClosure.rankedHeading': '고려할 추천 간선', + 'triadClosure.suggestEdgeTo': '간선 추천 — 대상:', + 'triadClosure.viaPrefix': '경유', + 'triadClosure.extraIntermediaries': '+{n}개 더', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b80e01d0d4..dc368e7a29 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4501,6 +4501,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania', 'pages.settings.account.security': 'Bezpieczeństwo', 'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy', + 'triadClosure.title': 'Podpowiedzi uzupełnienia grafu', + 'triadClosure.intro': + 'Każda inna soczewka mierzy relacje, które JUŻ istnieją. Ta ujawnia to, czego BRAKUJE: dla każdej uporządkowanej pary (A, C) bez bezpośredniej krawędzi, ale z kilkoma wspólnymi pośrednikami A→B→C, proponuje utworzenie A→C. Podpowiedzi są rankingowane według wyniku Adamic–Adar — pośrednicy o niskim stopniu ważą więcej, bo B, które zna tylko A i C, to znacznie silniejszy dowód niż mega-węzeł znający wszystkich.', + 'triadClosure.loading': 'Obliczanie podpowiedzi zamknięcia…', + 'triadClosure.errorPrefix': 'Nie udało się załadować grafu:', + 'triadClosure.retry': 'Spróbuj ponownie', + 'triadClosure.empty': 'Jeszcze brak grafu wiedzy.', + 'triadClosure.emptyHint': + 'W miarę jak asystent zapisuje relacje o Tobie, sugerowane krawędzie zamykające pojawią się tutaj.', + 'triadClosure.namespaceLabel': 'Przestrzeń nazw', + 'triadClosure.namespaceAll': 'Wszystkie przestrzenie nazw', + 'triadClosure.metricHints': 'Sugerowane krawędzie', + 'triadClosure.metricCandidates': 'Pary kandydujące', + 'triadClosure.metricSupport': 'Minimalne wsparcie', + 'triadClosure.summaryCaption': '{nodes} encji · {edges} skierowanych krawędzi', + 'triadClosure.truncatedBadge': 'skrócone', + 'triadClosure.truncatedTitle': + 'Węzeł źródłowy o dużej liczbie połączeń osiągnął limit klinów na źródło — dla tego źródła mogą brakować niektóre podpowiedzi.', + 'triadClosure.noCandidates': 'Brak otwartych triad — graf nie ma klinów do zamknięcia.', + 'triadClosure.allFiltered': + '{count} par kandydujących odfiltrowanych przez próg wsparcia — każdy zamykający klin miał tylko jednego pośrednika. Obniż minSupport, aby je zobaczyć.', + 'triadClosure.rankedHeading': 'Sugerowane krawędzie do rozważenia', + 'triadClosure.suggestEdgeTo': 'zaproponuj krawędź do', + 'triadClosure.viaPrefix': 'przez', + 'triadClosure.extraIntermediaries': '+{n} więcej', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 75c601bd2f..b6fec6735e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4498,6 +4498,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Recusar armazenamento local', 'pages.settings.account.security': 'Segurança', 'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro', + 'triadClosure.title': 'Sugestões de completude do grafo', + 'triadClosure.intro': + 'Toda outra lente mede relações que JÁ existem. Esta revela o que ESTÁ FALTANDO: para cada par ordenado (A, C) sem aresta direta mas com vários intermediários compartilhados A→B→C, propõe criar A→C. As sugestões são ordenadas pelo escore Adamic–Adar — intermediários de baixo grau pesam mais, porque um B que conhece apenas A e C é uma evidência muito mais forte do que um mega-hub que conhece todo mundo.', + 'triadClosure.loading': 'Calculando sugestões de fechamento…', + 'triadClosure.errorPrefix': 'Não foi possível carregar o grafo:', + 'triadClosure.retry': 'Tentar novamente', + 'triadClosure.empty': 'Ainda sem grafo de conhecimento.', + 'triadClosure.emptyHint': + 'À medida que o assistente registra relações sobre você, as arestas de fechamento sugeridas aparecerão aqui.', + 'triadClosure.namespaceLabel': 'Espaço de nomes', + 'triadClosure.namespaceAll': 'Todos os espaços de nomes', + 'triadClosure.metricHints': 'Arestas sugeridas', + 'triadClosure.metricCandidates': 'Pares candidatos', + 'triadClosure.metricSupport': 'Suporte mínimo', + 'triadClosure.summaryCaption': '{nodes} entidades · {edges} arestas direcionadas', + 'triadClosure.truncatedBadge': 'truncado', + 'triadClosure.truncatedTitle': + 'Um nó de origem com muitos enlaces atingiu o limite de cunhas por origem — algumas sugestões podem estar faltando para essa origem.', + 'triadClosure.noCandidates': 'Sem tríades abertas — o grafo não tem cunhas para fechar.', + 'triadClosure.allFiltered': + '{count} pares candidatos filtrados pelo limite de suporte — cada cunha de fechamento tinha um único intermediário. Reduza minSupport para vê-los.', + 'triadClosure.rankedHeading': 'Arestas sugeridas a considerar', + 'triadClosure.suggestEdgeTo': 'sugerir aresta para', + 'triadClosure.viaPrefix': 'via', + 'triadClosure.extraIntermediaries': '+{n} mais', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 592a691e0c..9f9bcedbf3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4469,6 +4469,32 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', 'pages.settings.account.security': 'Безопасность', 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', + 'triadClosure.title': 'Подсказки достройки графа', + 'triadClosure.intro': + 'Все остальные линзы измеряют связи, которые УЖЕ существуют. Эта вскрывает то, чего НЕДОСТАЁТ: для каждой упорядоченной пары (A, C) без прямого ребра, но с несколькими общими посредниками A→B→C, предлагается создать A→C. Подсказки ранжируются по оценке Адамика–Адара — посредники с малой степенью весят больше, потому что B, знающее лишь A и C, — гораздо более сильное свидетельство, чем мега-узел, знающий всех.', + 'triadClosure.loading': 'Вычисление подсказок замыкания…', + 'triadClosure.errorPrefix': 'Не удалось загрузить граф:', + 'triadClosure.retry': 'Повторить', + 'triadClosure.empty': 'Пока нет графа знаний.', + 'triadClosure.emptyHint': + 'По мере того как ассистент фиксирует связи о вас, здесь появятся предлагаемые замыкающие рёбра.', + 'triadClosure.namespaceLabel': 'Пространство имён', + 'triadClosure.namespaceAll': 'Все пространства имён', + 'triadClosure.metricHints': 'Предлагаемые рёбра', + 'triadClosure.metricCandidates': 'Пары-кандидаты', + 'triadClosure.metricSupport': 'Минимальная поддержка', + 'triadClosure.summaryCaption': '{nodes} сущностей · {edges} направленных рёбер', + 'triadClosure.truncatedBadge': 'усечено', + 'triadClosure.truncatedTitle': + 'Узел-источник с большим числом связей достиг предела клиньев на источник — некоторые подсказки для этого источника могут отсутствовать.', + 'triadClosure.noCandidates': 'Открытых триад нет — у графа нет клиньев для замыкания.', + 'triadClosure.allFiltered': + '{count} пар-кандидатов отфильтровано по порогу поддержки — у каждого замыкающего клина был лишь один посредник. Снизьте minSupport, чтобы их увидеть.', + 'triadClosure.rankedHeading': 'Предлагаемые рёбра для рассмотрения', + 'triadClosure.suggestEdgeTo': 'предложить ребро к', + 'triadClosure.viaPrefix': 'через', + 'triadClosure.extraIntermediaries': '+ещё {n}', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6169e9a647..f91022656c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4214,6 +4214,30 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '拒绝本地存储', 'pages.settings.account.security': '安全', 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', + 'triadClosure.title': '图补全提示', + 'triadClosure.intro': + '其他每个透镜都在度量已经存在的关系,而这个透镜揭示的是缺失的关系:对于每一对有序的 (A, C),它们之间没有直接边,但有若干共享中间者 A→B→C,提议创建 A→C。提示按 Adamic–Adar 分数排序——低度数的中间者权重更大,因为只认识 A 和 C 的 B 远比认识所有人的超级枢纽更有力。', + 'triadClosure.loading': '正在计算闭合提示…', + 'triadClosure.errorPrefix': '无法加载图:', + 'triadClosure.retry': '重试', + 'triadClosure.empty': '暂无知识图。', + 'triadClosure.emptyHint': '随着助手记录有关你的关系,建议的闭合边将在此呈现。', + 'triadClosure.namespaceLabel': '命名空间', + 'triadClosure.namespaceAll': '所有命名空间', + 'triadClosure.metricHints': '建议边', + 'triadClosure.metricCandidates': '候选对', + 'triadClosure.metricSupport': '最小支持度', + 'triadClosure.summaryCaption': '{nodes} 个实体 · {edges} 条有向边', + 'triadClosure.truncatedBadge': '已截断', + 'triadClosure.truncatedTitle': '一个连接密集的源节点达到了每源楔形上限——对该源可能缺少某些提示。', + 'triadClosure.noCandidates': '没有开三元组——图中没有可闭合的楔形。', + 'triadClosure.allFiltered': + '{count} 个候选对因支持度下限被过滤掉——每个闭合楔形都只有单一中间者。降低 minSupport 即可查看。', + 'triadClosure.rankedHeading': '可考虑的建议边', + 'triadClosure.suggestEdgeTo': '建议边到', + 'triadClosure.viaPrefix': '经由', + 'triadClosure.extraIntermediaries': '+{n} 个更多', + 'memory.tab.completion': 'Completion', }; export default messages; diff --git a/app/src/lib/memory/triadClosure.test.ts b/app/src/lib/memory/triadClosure.test.ts new file mode 100644 index 0000000000..15dcc844b9 --- /dev/null +++ b/app/src/lib/memory/triadClosure.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeTriadClosure } from './triadClosure'; + +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 hint(result: ReturnType, s: string, o: string) { + const h = result.hints.find(x => x.subject === s && x.object === o); + if (!h) throw new Error(`hint (${s} -> ${o}) not found`); + return h; +} + +describe('computeTriadClosure — basic shapes', () => { + it('F1 — empty input yields the EMPTY_RESULT shape', () => { + const r = computeTriadClosure([]); + expect(r.hints).toEqual([]); + expect(r.nodeCount).toBe(0); + expect(r.edgeCount).toBe(0); + expect(r.candidatePairCount).toBe(0); + expect(r.minSupport).toBe(2); + expect(r.truncated).toBe(false); + }); + + it('F2 — a single wedge (support=1) is filtered by default minSupport=2', () => { + // A->B->C, no A->C edge. Single intermediary B -> support=1 < default 2. + const r = computeTriadClosure([rel('A', 'B'), rel('B', 'C')]); + expect(r.candidatePairCount).toBe(1); // pre-filter: 1 candidate (A, C) + expect(r.hints).toEqual([]); // post-filter: empty + expect(r.nodeCount).toBe(3); // {A, B, C} + expect(r.edgeCount).toBe(2); // A->B and B->C + }); + + it('F3 — minSupport=1 exposes the single-intermediary candidate', () => { + const r = computeTriadClosure([rel('A', 'B'), rel('B', 'C')], { minSupport: 1 }); + expect(r.hints).toHaveLength(1); + expect(hint(r, 'A', 'C').support).toBe(1); + expect(hint(r, 'A', 'C').intermediaries).toEqual(['B']); + // deg(B) = |{A, C}| = 2 in undirected graph; score = 1 / log(1 + 2). + expect(hint(r, 'A', 'C').score).toBeCloseTo(1 / Math.log(3), 12); + }); +}); + +describe('computeTriadClosure — Adamic-Adar weighting', () => { + it('two intermediaries: score sums per Adamic-Adar', () => { + // A->B, B->C, A->D, D->C. Two intermediaries B and D for (A, C). + // deg(B) = |{A, C}| = 2; deg(D) = |{A, C}| = 2. + // score = 1/log(3) + 1/log(3) = 2 / log(3). + const r = computeTriadClosure([rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C')]); + expect(r.hints).toHaveLength(1); + const h = hint(r, 'A', 'C'); + expect(h.support).toBe(2); + expect(h.intermediaries).toEqual(['B', 'D']); + expect(h.score).toBeCloseTo(2 / Math.log(3), 12); + }); + + it('a high-degree intermediary contributes less than a low-degree one', () => { + // Triad 1: A->B->C with B's only connections to A and C (deg=2). + // Triad 2: X->H->Y where H is a hub also connected to many others. + const r = computeTriadClosure( + [ + // Pair (A, C) — intermediary B has degree 2 (only A and C). + rel('A', 'B'), + rel('B', 'C'), + // Pair (X, Y) — intermediary H is a hub with degree 6. + rel('X', 'H'), + rel('H', 'Y'), + rel('H', 'one'), + rel('H', 'two'), + rel('H', 'three'), + rel('H', 'four'), + ], + { minSupport: 1 } + ); + const ac = hint(r, 'A', 'C'); + const xy = hint(r, 'X', 'Y'); + expect(ac.score).toBeGreaterThan(xy.score); + // (A, C) leads the ranking — Adamic-Adar favours low-degree witnesses. + expect(r.hints[0]).toMatchObject({ subject: 'A', object: 'C' }); + }); +}); + +describe('computeTriadClosure — direct-edge suppression', () => { + it('an existing A->C edge under ANY predicate suppresses the hint', () => { + // Wedge A->B->C exists, AND a direct A->C edge exists under a different + // predicate. The hint must NOT appear (predicate-agnostic semantics). + const r = computeTriadClosure( + [ + rel('A', 'B', 'knows'), + rel('B', 'C', 'knows'), + rel('A', 'C', 'trusts'), // direct edge under a different predicate + ], + { minSupport: 1 } + ); + expect(r.hints).toEqual([]); + expect(r.candidatePairCount).toBe(0); + }); + + it('C->A direct edge (reverse direction) does NOT suppress the hint', () => { + // Suggest A->C even when C->A already exists — direction matters; the + // suggestion is about adding the forward edge. + const r = computeTriadClosure( + [ + rel('A', 'B'), + rel('B', 'C'), + rel('C', 'A'), // reverse-direction edge + ], + { minSupport: 1 } + ); + expect(hint(r, 'A', 'C').support).toBe(1); + }); +}); + +describe('computeTriadClosure — normalisation & determinism', () => { + it('F4 — drops self-loops entirely (they cannot close a triad)', () => { + // A self-loop B->B cannot be part of an A->B->C wedge. + const r = computeTriadClosure([rel('A', 'B'), rel('B', 'B'), rel('B', 'C'), rel('B', 'C')], { + minSupport: 1, + }); + expect(r.hints).toHaveLength(1); + // Parallel B->C collapsed to one directed edge. + expect(r.edgeCount).toBe(2); + }); + + it('drops malformed relations (non-string subject/object)', () => { + const malformed = { ...rel('A', 'B'), object: null as unknown as string }; + const r = computeTriadClosure([rel('A', 'B'), rel('B', 'C'), malformed], { minSupport: 1 }); + expect(r.hints).toHaveLength(1); + }); + + it('treats "Alice" and "alice" as distinct nodes (no case-folding)', () => { + const r = computeTriadClosure([rel('Alice', 'b'), rel('b', 'c'), rel('alice', 'b')], { + minSupport: 1, + }); + expect(r.nodeCount).toBe(4); // Alice, alice, b, c + expect(hint(r, 'Alice', 'c').support).toBe(1); + expect(hint(r, 'alice', 'c').support).toBe(1); + }); + + it('is order-independent: shuffled input yields BYTE-identical hints', () => { + const edges = [ + rel('A', 'B'), + rel('B', 'C'), + rel('A', 'D'), + rel('D', 'C'), + rel('X', 'Y'), + rel('Y', 'Z'), + ]; + const forward = computeTriadClosure(edges); + const reversed = computeTriadClosure([...edges].reverse()); + const rotated = computeTriadClosure([...edges.slice(3), ...edges.slice(0, 3)]); + expect(reversed).toEqual(forward); + expect(rotated).toEqual(forward); + if (forward.hints.length > 0) { + // bit equality on the float score, not toBeCloseTo + expect(reversed.hints[0].score).toBe(forward.hints[0].score); + } + }); + + it('sorts hints score DESC, then support DESC, then subject ASC, then object ASC', () => { + // Pair (A, C): single intermediary B with deg 2 -> score = 1/log(3) ≈ 0.910. + // Pair (P, R): two intermediaries Q1, Q2 each with deg 2 -> score = 2/log(3) ≈ 1.820. + // Pair (X, Z): single intermediary Y with deg 4 (Y also points at W,V) -> smaller. + const r = computeTriadClosure( + [ + rel('A', 'B'), + rel('B', 'C'), + rel('P', 'Q1'), + rel('Q1', 'R'), + rel('P', 'Q2'), + rel('Q2', 'R'), + rel('X', 'Y'), + rel('Y', 'Z'), + rel('Y', 'W'), + rel('Y', 'V'), + ], + { minSupport: 1 } + ); + // Sort: P->R (2/log3 ≈ 1.820) leads, then A->C (1/log3 ≈ 0.910), then + // the three X-> candidates all tie at 1/log(5) ≈ 0.621 and break by + // object ASC -> V, W, Z. + expect(r.hints.map(h => `${h.subject}->${h.object}`)).toEqual([ + 'P->R', + 'A->C', + 'X->V', + 'X->W', + 'X->Z', + ]); + }); + + it('limit option caps the returned hints', () => { + const r = computeTriadClosure( + [rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C'), rel('P', 'Q'), rel('Q', 'R')], + { minSupport: 1, limit: 1 } + ); + expect(r.hints).toHaveLength(1); + // candidatePairCount still reports the pre-filter count (2 here). + expect(r.candidatePairCount).toBe(2); + }); +}); diff --git a/app/src/lib/memory/triadClosure.ts b/app/src/lib/memory/triadClosure.ts new file mode 100644 index 0000000000..b0330ca5ec --- /dev/null +++ b/app/src/lib/memory/triadClosure.ts @@ -0,0 +1,231 @@ +/** + * Triad Closure — pure graph-completion engine (Adamic–Adar over open wedges). + * + * Every one of the 21 sibling intelligence lenses measures something about + * relations that ALREADY EXIST. This is the first to surface what's MISSING: + * for every ordered entity pair (A, C) that share at least `minSupport` + * intermediaries (A→B→C structure) but have NO direct A→C edge under any + * predicate, propose creating A→C as a candidate "edge to consider". + * + * Hints are ranked by the Adamic–Adar score + * + * score(A, C) = Σ_B 1 / log(1 + deg(B)) + * + * over the intermediary set, where deg(B) is B's undirected degree. The + * 1 + deg(B) shift (vs the textbook Adamic–Adar's bare deg(B)) keeps the + * logarithm finite and positive even when an intermediary has degree exactly + * 1 — every intermediary B in a triad through (A, C) is at least connected to + * BOTH A and C, so deg(B) ≥ 2 in practice and the shift is a defensive + * boundary fill that never bites real data. Scores from this engine are NOT + * directly comparable to textbook Adamic–Adar literature because of that + * shift; they're internally consistent and rank-equivalent. + * + * Why "intermediaries with low degree weigh more": a B that knows only A and + * C is much stronger structural evidence that A and C belong together than a + * mega-hub B who knows everyone — Adamic–Adar formalises that intuition by + * dampening high-degree intermediaries via the log. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. The per-pair float sum walks intermediaries in their canonical + * sorted order (string ASC), so the score is byte-identical regardless of + * relation insertion order. Pair keys are `JSON.stringify([subject, object])` + * — separator collisions impossible, and the codebase reviewer's + * control-char scan stays at zero. + * + * Load-bearing design choices (do not "fix" without reading the tests): + * - Predicate-AGNOSTIC: a direct A→C edge under ANY predicate suppresses + * a hint for (A, C). This is the cleanest "no link exists" semantics + * for a graph-completion suggestion — surfacing (A, C) when an A→C edge + * already exists under a different predicate would be misleading. + * - Self-loops (subject === object) are dropped entirely: they cannot + * participate in a closing triad. + * - Multigraph edges (same (s, p, o) repeated or different predicates on + * the same ordered pair) collapse to a single directed edge for the + * purpose of intermediary lookup. + * - Default `minSupport = 2` — a single-intermediary triad is too weak a + * signal; this matches the literature convention and keeps the worklist + * actionable. + * - Default `limit = 500` — caps the returned list. A pathological + * hub-and-spoke graph could otherwise emit a multi-MB payload. + * - Per-A wedge ceiling `MAX_WEDGES_PER_A = 200_000` — caps the work done + * per source node so a degree-1000 hub (~1M potential wedges) cannot + * spike CPU on a small frontend graph; the work-cap is reported in the + * result so the UI can show "results truncated". + * - Output sort: score DESC, support DESC, subject ASC, object ASC — + * a total order, byte-identical across input permutations. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface TriadHint { + subject: string; + object: string; + score: number; // Adamic–Adar Σ 1/log(1 + deg(B)) over intermediaries + support: number; // |intermediaries| (always >= minSupport in output) + intermediaries: string[]; // sorted ASC; full list, the UI can truncate +} + +export interface TriadClosureResult { + hints: TriadHint[]; // sorted score DESC, support DESC, subject ASC, object ASC + nodeCount: number; + edgeCount: number; // distinct collapsed directed ordered pairs (self-loops excluded) + candidatePairCount: number; // count BEFORE the minSupport filter (lets UI explain an empty worklist) + minSupport: number; // echoed for reproducibility / debugging + truncated: boolean; // true when per-A wedge ceiling was hit on at least one source +} + +export interface TriadClosureOptions { + minSupport?: number; // default 2 + limit?: number; // default 500 (pass 0 for unlimited; negative is clamped to 0) +} + +const DEFAULT_MIN_SUPPORT = 2; +const DEFAULT_LIMIT = 500; +const MAX_WEDGES_PER_A = 200_000; + +function isRelation(relation: GraphRelation): boolean { + return typeof relation.subject === 'string' && typeof relation.object === 'string'; +} + +function pairKey(a: string, c: string): string { + return JSON.stringify([a, c]); +} + +function compareStrings(a: string, b: string): number { + if (a === b) return 0; + return a < b ? -1 : 1; +} + +/** Compute Adamic-Adar triad-closure hints over the memory graph. PURE. */ +export function computeTriadClosure( + relations: GraphRelation[], + options?: TriadClosureOptions +): TriadClosureResult { + const minSupport = Math.max(1, Math.floor(options?.minSupport ?? DEFAULT_MIN_SUPPORT)); + // Contract: 0 = unlimited; negative = clamped to 0 (empty result). + const floored = Math.floor(options?.limit ?? DEFAULT_LIMIT); + const limit = floored < 0 ? 0 : floored === 0 ? Number.POSITIVE_INFINITY : floored; + + // Pass 1 — build directed adjacency (parallel edges collapsed via Set; + // self-loops dropped — they cannot participate in a closing triad). + const outNeighbours = new Map>(); + const undirected = new Map>(); + const ensureSet = (map: Map>, key: string): Set => { + let set = map.get(key); + if (set === undefined) { + set = new Set(); + map.set(key, set); + } + return set; + }; + let edgeCount = 0; + for (const relation of relations) { + if (!isRelation(relation)) continue; + const { subject, object } = relation; + if (subject === object) continue; + const out = ensureSet(outNeighbours, subject); + if (!out.has(object)) { + out.add(object); + edgeCount += 1; + } + // Also register the object as a node (so it appears in nodeCount and gets + // a deg() entry) even if it never appears as a subject. + ensureSet(outNeighbours, object); + ensureSet(undirected, subject).add(object); + ensureSet(undirected, object).add(subject); + } + + // Pass 2 — undirected degree per node (used by Adamic-Adar weighting). + const degree = new Map(); + for (const [node, set] of undirected) degree.set(node, set.size); + for (const node of outNeighbours.keys()) { + if (!degree.has(node)) degree.set(node, 0); + } + + // Canonical id-sorted node list -> reproducible iteration order for the + // wedge enumeration (and for the per-pair intermediary list). + const sortedNodes = [...outNeighbours.keys()].sort(compareStrings); + + // Pass 3 — wedge enumeration. For each A, walk its sorted out-neighbours + // B; for each B, walk its sorted out-neighbours C; record A->B->C wedges + // whose A->C direct edge does NOT exist. + interface Accum { + subject: string; + object: string; + intermediaries: string[]; + } + const accums = new Map(); + let truncated = false; + + for (const a of sortedNodes) { + const aOut = outNeighbours.get(a); + if (aOut === undefined || aOut.size === 0) continue; + const bList = [...aOut].sort(compareStrings); + let wedgesForA = 0; + let cappedThisA = false; + for (const b of bList) { + if (cappedThisA) break; + if (b === a) continue; + const bOut = outNeighbours.get(b); + if (bOut === undefined || bOut.size === 0) continue; + const cList = [...bOut].sort(compareStrings); + for (const c of cList) { + if (c === a || c === b) continue; + if (aOut.has(c)) continue; // direct A->C edge already exists + const key = pairKey(a, c); + let accum = accums.get(key); + if (accum === undefined) { + accum = { subject: a, object: c, intermediaries: [] }; + accums.set(key, accum); + } + accum.intermediaries.push(b); + wedgesForA += 1; + if (wedgesForA >= MAX_WEDGES_PER_A) { + truncated = true; + cappedThisA = true; + break; + } + } + } + } + + // Pass 4 — dedupe-and-sort intermediary lists, score, filter, sort output. + const allHints: TriadHint[] = []; + for (const accum of accums.values()) { + // The intermediary list may contain a B more than once if A has parallel + // routes to B; dedupe via Set then sort ASC for a canonical float walk. + const intermediaries = [...new Set(accum.intermediaries)].sort(compareStrings); + if (intermediaries.length < minSupport) continue; + let score = 0; + for (const b of intermediaries) { + const d = degree.get(b) ?? 0; + score += 1 / Math.log(1 + d); + } + allHints.push({ + subject: accum.subject, + object: accum.object, + score, + support: intermediaries.length, + intermediaries, + }); + } + + allHints.sort((x, y) => { + if (y.score !== x.score) return y.score - x.score; + if (y.support !== x.support) return y.support - x.support; + const s = compareStrings(x.subject, y.subject); + if (s !== 0) return s; + return compareStrings(x.object, y.object); + }); + + const candidatePairCount = accums.size; + const hints = limit === Number.POSITIVE_INFINITY ? allHints : allHints.slice(0, limit); + + return { + hints, + nodeCount: outNeighbours.size, + edgeCount, + candidatePairCount, + minSupport, + truncated, + }; +} diff --git a/app/src/services/api/triadClosureApi.test.ts b/app/src/services/api/triadClosureApi.test.ts new file mode 100644 index 0000000000..29167b7636 --- /dev/null +++ b/app/src/services/api/triadClosureApi.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeTriadClosure } from '../../lib/memory/triadClosure'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { loadNamespaces, loadTriadClosure, triadClosureApi } from './triadClosureApi'; + +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('triadClosureApi.loadTriadClosure', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine result', async () => { + const triples = [rel('A', 'B'), rel('B', 'C'), rel('A', 'D'), rel('D', 'C')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadTriadClosure('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeTriadClosure(triples)); + expect(out.candidatePairCount).toBe(1); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadTriadClosure(); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.hints).toEqual([]); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadTriadClosure()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('triadClosureApi.loadNamespaces', () => { + beforeEach(() => mockListNamespaces.mockReset()); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('triadClosureApi object', () => { + it('exposes the public surface', () => { + expect(typeof triadClosureApi.loadTriadClosure).toBe('function'); + expect(typeof triadClosureApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/triadClosureApi.ts b/app/src/services/api/triadClosureApi.ts new file mode 100644 index 0000000000..2844dfa8e5 --- /dev/null +++ b/app/src/services/api/triadClosureApi.ts @@ -0,0 +1,35 @@ +/** + * RPC facade for Triad Closure (Adamic-Adar graph-completion hints). + * + * Adds ZERO new core surface. Composes the already-shipped + * - 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. + */ +import debug from 'debug'; + +import { computeTriadClosure, type TriadClosureResult } from '../../lib/memory/triadClosure'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('triad-closure:api'); + +/** Fetch graph relations for a namespace (or all) and compute closure hints. */ +export async function loadTriadClosure(namespace?: string): Promise { + const relations = await memoryGraphQuery(namespace); + // Do not log the raw namespace value — it can carry user identifiers (PII). + // Emit only whether one was provided, with a grep-friendly prefix. + log( + '[rpc] loadTriadClosure method=%s namespaceProvided=%s relations=%d', + 'loadTriadClosure', + namespace != null, + relations.length + ); + return computeTriadClosure(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const triadClosureApi = { loadTriadClosure, loadNamespaces };