+ );
+};
+
+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 };