From 31bde4ee4fa31639eab35c7be2ea6a3b5aa86cba Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Sat, 30 May 2026 06:12:57 +0500 Subject: [PATCH] feat(intelligence): add Evidence-Weighted Trust Lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new read-only "Trust" tab. The 20 shipped lenses all read the (subject, predicate, object) triple structure or its document provenance. None of them makes `evidenceCount` — the field that measures how many times an assertion has been independently corroborated — the PRIMARY signal. This lens does. Three orthogonal-to-everything-shipped outputs: - TRUST QUOTIENT per entity = mean evidence per relation; separates a prolific entity with many single-witness facts from a quiet entity whose few relations are heavily corroborated. - EVIDENCE GINI = inequality of evidence concentration across entities (closed-form on sorted entity weights — one integer-weighted sum, one division; no float-associativity hazard). - UNDER-CORROBORATED WORKLIST = relations whose evidence falls below max(1, floor(median_positive_evidence / 4)) — a curator's review queue. Plus Predicate Reliability Index per predicate and a `degraded:true` banner when every relation has evidenceCount = 1 (the field was never populated meaningfully). Picked by the loop-21 judge-panel design workflow (4 fresh angles × 3 judges + synthesis): weighted score 8.633/10, beating Chunk Corroboration Atlas, Inferred Edges, and Attention Quadrants. Engine (pure, deterministic — no React/RPC/clock/RNG): - evidenceCount coerced via Number(...) then sanitised (non-finite/NaN/ non-positive -> 0; positive floored to integer), - multigraph collapse: same (namespace, s, p, o) tuple appearing multiple times SUMS evidence; one distinct relation, - entity identity = (namespace, name) — orthogonal to Entity Duplicates, - median for the under-corroboration threshold uses INDEX-PICK (lower middle on even sets), never the arithmetic mean — preventing float drift, - empty-graph and all-zero-evidence guards: globalGini = 0 when entityCount < 2 OR sum-of-entity-weights === 0, - all the synthesis-stage refinements applied (Number coercion, dedup before exclusivity, degraded flag, fixed-decimal output rounding, threshold exposed in summary). Adds ZERO new core surface: composes the already-shipped memoryGraphQuery / memoryListNamespaces wrappers. Container/ presentational split with a monotonic request-token race guard for load-on-mount; i18n across all 13 locales; aria-hidden on the decorative → glyph paired with an sr-only "points to". Co-Authored-By: Claude Opus 4.7 --- .../intelligence/EvidenceTrustPanel.test.tsx | 100 ++++++ .../intelligence/EvidenceTrustPanel.tsx | 278 ++++++++++++++++ .../intelligence/EvidenceTrustTab.test.tsx | 66 ++++ .../intelligence/EvidenceTrustTab.tsx | 84 +++++ app/src/lib/i18n/ar.ts | 28 ++ app/src/lib/i18n/bn.ts | 31 ++ app/src/lib/i18n/de.ts | 32 ++ app/src/lib/i18n/en.ts | 35 +- app/src/lib/i18n/es.ts | 32 ++ app/src/lib/i18n/fr.ts | 32 ++ app/src/lib/i18n/hi.ts | 31 ++ app/src/lib/i18n/id.ts | 31 ++ app/src/lib/i18n/it.ts | 32 ++ app/src/lib/i18n/ko.ts | 31 ++ app/src/lib/i18n/pl.ts | 32 ++ app/src/lib/i18n/pt.ts | 32 ++ app/src/lib/i18n/ru.ts | 32 ++ app/src/lib/i18n/zh-CN.ts | 28 ++ app/src/lib/memory/evidenceTrust.test.ts | 271 ++++++++++++++++ app/src/lib/memory/evidenceTrust.ts | 306 ++++++++++++++++++ app/src/services/api/evidenceTrustApi.test.ts | 78 +++++ app/src/services/api/evidenceTrustApi.ts | 35 ++ 22 files changed, 1656 insertions(+), 1 deletion(-) create mode 100644 app/src/components/intelligence/EvidenceTrustPanel.test.tsx create mode 100644 app/src/components/intelligence/EvidenceTrustPanel.tsx create mode 100644 app/src/components/intelligence/EvidenceTrustTab.test.tsx create mode 100644 app/src/components/intelligence/EvidenceTrustTab.tsx create mode 100644 app/src/lib/memory/evidenceTrust.test.ts create mode 100644 app/src/lib/memory/evidenceTrust.ts create mode 100644 app/src/services/api/evidenceTrustApi.test.ts create mode 100644 app/src/services/api/evidenceTrustApi.ts diff --git a/app/src/components/intelligence/EvidenceTrustPanel.test.tsx b/app/src/components/intelligence/EvidenceTrustPanel.test.tsx new file mode 100644 index 0000000000..d5037d1357 --- /dev/null +++ b/app/src/components/intelligence/EvidenceTrustPanel.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeEvidenceTrust } from '../../lib/memory/evidenceTrust'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import EvidenceTrustPanel from './EvidenceTrustPanel'; + +function rel( + subject: string, + predicate: string, + object: string, + evidenceCount: number +): GraphRelation { + return { + namespace: 'n', + subject, + predicate, + object, + attrs: {}, + updatedAt: 0, + evidenceCount, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +// Mix: prolific (4 thin facts) vs quiet (1 heavily-corroborated fact) + +// an under-corroborated edge so the worklist is non-empty. +const mixed = computeEvidenceTrust([ + rel('prolific', 'knows', 'a', 1), + rel('prolific', 'knows', 'b', 1), + rel('prolific', 'knows', 'c', 1), + rel('prolific', 'knows', 'd', 1), + rel('quiet', 'recommends', 'rare', 20), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('evidence-trust-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no relations', () => { + 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('shows the degraded banner when every relation has evidence === 1', () => { + // All evidence===1 -> degraded path. + const degraded = computeEvidenceTrust([rel('A', 'p', 'B', 1), rel('B', 'p', 'C', 1)]); + render(); + expect( + screen.getByText('Evidence signal sparse — populate evidenceCount to unlock this lens.') + ).toBeInTheDocument(); + }); + + it('renders metric tiles, per-entity trust ranking, and predicate reliability', () => { + render(); + expect(screen.getByText('Evidence Gini')).toBeInTheDocument(); + expect(screen.getByText('Entities weighted')).toBeInTheDocument(); + expect(screen.getByText('Total evidence')).toBeInTheDocument(); + expect(screen.getByText('Trust Quotient ranking')).toBeInTheDocument(); + expect(screen.getByText('Predicate Reliability Index')).toBeInTheDocument(); + // quiet has TQ 20, much higher than prolific's TQ 1. + expect(screen.getByText('quiet')).toBeInTheDocument(); + expect(screen.getByText('prolific')).toBeInTheDocument(); + // For this fixture (positives [1,1,1,1,20], median=1, threshold=1), no + // relation falls below threshold -> the "no worklist" caption renders. + expect( + screen.getByText( + 'No under-corroborated relations — every assertion meets the evidence threshold.' + ) + ).toBeInTheDocument(); + }); + + it('renders an under-corroborated worklist when threshold catches relations', () => { + // Positive evidences [1, 1, 8, 8, 8, 8] -> median index 2 -> 8; + // threshold = max(1, floor(8/4)) = 2. Two evidence=1 entries flagged. + const worklist = computeEvidenceTrust([ + rel('S', 'p', 'X', 1), + rel('S', 'p', 'Y', 1), + rel('M', 'p', 'A', 8), + rel('M', 'p', 'B', 8), + rel('M', 'p', 'C', 8), + rel('M', 'p', 'D', 8), + ]); + render(); + expect(screen.getByText('Under-corroborated worklist')).toBeInTheDocument(); + expect(screen.getAllByText('ev 1')).toHaveLength(2); + }); +}); diff --git a/app/src/components/intelligence/EvidenceTrustPanel.tsx b/app/src/components/intelligence/EvidenceTrustPanel.tsx new file mode 100644 index 0000000000..7a88a63151 --- /dev/null +++ b/app/src/components/intelligence/EvidenceTrustPanel.tsx @@ -0,0 +1,278 @@ +/** + * Evidence-Weighted Trust — presentational view. Pure: renders the summary + * tiles (Trust Gini / weighted entities / total evidence), per-entity Trust + * Quotient ranking, predicate reliability ranking, and the under-corroborated + * worklist. No data fetching, no clock, no randomness. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { EvidenceTrustResult } from '../../lib/memory/evidenceTrust'; + +const MAX_ENTITY_ROWS = 25; +const MAX_PREDICATE_ROWS = 15; +const MAX_WORKLIST_ROWS = 25; + +interface EvidenceTrustPanelProps { + result: EvidenceTrustResult | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const EvidenceTrustPanel = ({ result, loading, error, onRetry }: EvidenceTrustPanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('evidenceTrust.title')}

+

{t('evidenceTrust.intro')}

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

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

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

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

+

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

+
+
+ ); + } + + if (result.degraded) { + return ( +
+ {intro} +
+

+ {t('evidenceTrust.degradedTitle')} +

+

+ {t('evidenceTrust.degradedHint')} +

+
+
+ ); + } + + const giniPct = Math.max(0, Math.min(100, Math.round(result.globalGini * 100))); + const entityRows = result.entities.slice(0, MAX_ENTITY_ROWS); + const predicateRows = result.predicates.slice(0, MAX_PREDICATE_ROWS); + const worklistRows = result.underCorroborated.slice(0, MAX_WORKLIST_ROWS); + const maxTQ = entityRows.reduce((m, e) => (e.trustQuotient > m ? e.trustQuotient : m), 0); + + return ( +
+ {intro} + + {/* Metric tiles */} +
+ {[ + { label: t('evidenceTrust.metricGini'), value: `${giniPct}%` }, + { label: t('evidenceTrust.metricEntities'), value: result.entityCount }, + { label: t('evidenceTrust.metricEvidence'), value: result.totalEvidence }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+

+ {t('evidenceTrust.summaryCaption') + .replace('{relations}', String(result.totalRelations)) + .replace('{threshold}', String(result.threshold))} +

+ + {/* Per-entity Trust Quotient */} +
+

+ {t('evidenceTrust.entitiesHeading')} +

+ + + + + + + + + + + {entityRows.map((row, i) => { + const widthPct = + maxTQ === 0 ? 0 : Math.max(0, Math.min(100, (row.trustQuotient / maxTQ) * 100)); + return ( + + + + + + + ); + })} + +
+ {t('evidenceTrust.colRank')} + + {t('evidenceTrust.colEntity')} + + {t('evidenceTrust.colTrust')} + + {t('evidenceTrust.colDegree')} +
{i + 1} + {row.entity} + {row.namespace !== null && row.namespace.length > 0 && ( + + ({row.namespace}) + + )} + +
+
+
+
+ + {row.trustQuotient.toFixed(1)} + +
+
+ {row.degree} +
+
+ + {/* Predicate reliability index */} +
+

+ {t('evidenceTrust.predicatesHeading')} +

+
    + {predicateRows.map(row => ( +
  • + {row.predicate} + + {row.reliability.toFixed(2)} + + ({row.relationCount}) + + +
  • + ))} +
+
+ + {/* Under-corroborated worklist */} + {worklistRows.length === 0 ? ( +

+ {t('evidenceTrust.noWorklist')} +

+ ) : ( +
+

+ {t('evidenceTrust.worklistHeading')} +

+
    + {worklistRows.map(row => ( +
  • +
    + {row.subject} + · + {row.predicate} + + {t('evidenceTrust.pointsTo')} + {row.object} + + {t('evidenceTrust.evidenceLabel').replace('{n}', String(row.evidence))} + +
    +
  • + ))} +
+
+ )} +
+ ); +}; + +export default EvidenceTrustPanel; diff --git a/app/src/components/intelligence/EvidenceTrustTab.test.tsx b/app/src/components/intelligence/EvidenceTrustTab.test.tsx new file mode 100644 index 0000000000..167854b72e --- /dev/null +++ b/app/src/components/intelligence/EvidenceTrustTab.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeEvidenceTrust } from '../../lib/memory/evidenceTrust'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import EvidenceTrustTab from './EvidenceTrustTab'; + +const mockLoadTrust = vi.fn(); +const mockLoadNamespaces = vi.fn(); + +vi.mock('../../services/api/evidenceTrustApi', () => ({ + loadEvidenceTrust: (...args: unknown[]) => mockLoadTrust(...args), + loadNamespaces: (...args: unknown[]) => mockLoadNamespaces(...args), +})); + +function rel( + subject: string, + predicate: string, + object: string, + evidenceCount: number +): GraphRelation { + return { + namespace: 'n', + subject, + predicate, + object, + attrs: {}, + updatedAt: 0, + evidenceCount, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const result = computeEvidenceTrust([rel('A', 'p', 'B', 5), rel('B', 'p', 'C', 2)]); + +describe('', () => { + beforeEach(() => { + mockLoadTrust.mockReset(); + mockLoadNamespaces.mockReset(); + mockLoadTrust.mockResolvedValue(result); + mockLoadNamespaces.mockResolvedValue([]); + }); + + it('loads trust (all namespaces) on mount and renders the result', async () => { + render(); + expect(mockLoadTrust).toHaveBeenCalledWith(undefined); + await waitFor(() => expect(screen.getByText('Trust Quotient ranking')).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(mockLoadTrust).toHaveBeenCalledWith('work')); + }); + + it('surfaces an error when the load fails', async () => { + mockLoadTrust.mockReset(); + mockLoadTrust.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/EvidenceTrustTab.tsx b/app/src/components/intelligence/EvidenceTrustTab.tsx new file mode 100644 index 0000000000..fc17fe00f2 --- /dev/null +++ b/app/src/components/intelligence/EvidenceTrustTab.tsx @@ -0,0 +1,84 @@ +/** + * Evidence-Weighted Trust 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 { EvidenceTrustResult } from '../../lib/memory/evidenceTrust'; +import { loadEvidenceTrust, loadNamespaces } from '../../services/api/evidenceTrustApi'; +import EvidenceTrustPanel from './EvidenceTrustPanel'; + +const EvidenceTrustTab = () => { + 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 loadEvidenceTrust(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 trust view, so swallow that error specifically. + loadNamespaces() + .then(setNamespaces) + .catch(() => setNamespaces([])); + void load(''); + }, [load]); + + const handleNamespace = (next: string): void => { + setNamespace(next); + void load(next); + }; + + return ( +
+ {namespaces.length > 0 && ( + + )} + + void load(namespace)} + /> +
+ ); +}; + +export default EvidenceTrustTab; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d5e578503b..4da3da13fd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4350,6 +4350,34 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', 'pages.settings.account.security': 'الأمان', 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', + 'evidenceTrust.colDegree': 'علاقات', + 'evidenceTrust.colEntity': 'الكيان', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'حاصل الثقة', + 'evidenceTrust.degradedHint': + 'كل علاقة حالياً تحمل evidenceCount = 1، لذا ينهار حاصل الثقة إلى ثابت. تحتاج العدسة إلى قيم تأكيد متنوعة لتمييز الغزير-الرفيع عن الهادئ-الصلب.', + 'evidenceTrust.degradedTitle': 'إشارة الدليل شحيحة — املأ evidenceCount لفتح هذه العدسة.', + 'evidenceTrust.empty': 'لا يوجد رسم معرفي بعد.', + 'evidenceTrust.emptyHint': 'كلما سجّل المساعد علاقات مع evidenceCount عنك، سيظهر هنا ملف الثقة.', + 'evidenceTrust.entitiesHeading': 'ترتيب حاصل الثقة', + 'evidenceTrust.errorPrefix': 'تعذّر تحميل الرسم البياني:', + 'evidenceTrust.evidenceLabel': 'دل. {n}', + 'evidenceTrust.intro': + 'حاصل الثقة لكل كيان = متوسط الدليل لكل علاقة يشارك فيها. Gini الدليل يقيس مدى عدم تساوي تركز الدليل بين الكيانات. قائمة عمل ناقص التأكيد تشير إلى العلاقات التي ينخفض دليلها تحت عتبة مستمدة من الرسم — قائمة مراجعة القيّم.', + 'evidenceTrust.loading': 'حساب ملف الثقة…', + 'evidenceTrust.metricEntities': 'كيانات مرجحة', + 'evidenceTrust.metricEvidence': 'إجمالي الأدلة', + 'evidenceTrust.metricGini': 'Gini للدليل', + 'evidenceTrust.namespaceAll': 'كل مساحات الأسماء', + 'evidenceTrust.namespaceLabel': 'مساحة الأسماء', + 'evidenceTrust.noWorklist': 'لا توجد علاقات ناقصة التأكيد — كل تأكيد يفي بعتبة الدليل.', + 'evidenceTrust.pointsTo': 'يشير إلى', + 'evidenceTrust.predicatesHeading': 'مؤشر موثوقية المسندات', + 'evidenceTrust.retry': 'إعادة المحاولة', + 'evidenceTrust.summaryCaption': '{relations} علاقة متمايزة · عتبة ناقص-التأكيد {threshold}', + 'evidenceTrust.title': 'الثقة الموزونة بالدليل', + 'evidenceTrust.worklistHeading': 'قائمة عمل ناقصة التأكيد', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index fb196b753d..24250ebdf4 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4427,6 +4427,37 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', 'pages.settings.account.security': 'নিরাপত্তা', 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', + 'evidenceTrust.colDegree': 'সম্প.', + 'evidenceTrust.colEntity': 'সত্তা', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'বিশ্বাস ভাগফল', + 'evidenceTrust.degradedHint': + 'প্রতিটি সম্পর্ক বর্তমানে evidenceCount = 1 বহন করে, তাই বিশ্বাস ভাগফল একটি ধ্রুবকে ধ্বসে পড়ে। প্রচুর-পাতলাকে শান্ত-দৃঢ় থেকে আলাদা করতে লেন্সের বৈচিত্র্যপূর্ণ সমর্থন মান প্রয়োজন।', + 'evidenceTrust.degradedTitle': 'প্রমাণ সংকেত বিরল — এই লেন্স আনলক করতে evidenceCount পূরণ করুন।', + 'evidenceTrust.empty': 'এখনও কোনো জ্ঞান গ্রাফ নেই।', + 'evidenceTrust.emptyHint': + 'সহকারী যখন আপনার সম্পর্কে evidenceCount সহ সম্পর্ক রেকর্ড করে, বিশ্বাস প্রোফাইল এখানে উঠে আসবে।', + 'evidenceTrust.entitiesHeading': 'বিশ্বাস ভাগফল র‍্যাঙ্কিং', + 'evidenceTrust.errorPrefix': 'গ্রাফ লোড করা যায়নি:', + 'evidenceTrust.evidenceLabel': 'প্রমাণ {n}', + 'evidenceTrust.intro': + 'প্রতি-সত্তা বিশ্বাস ভাগফল = এটি যে সম্পর্কে জড়িত সেই সম্পর্ক প্রতি গড় প্রমাণ। প্রমাণ Gini পরিমাপ করে সত্তা জুড়ে প্রমাণ কতটা অসমানভাবে কেন্দ্রীভূত। অপর্যাপ্ত-সমর্থিত কর্মতালিকা সেই সম্পর্কগুলি চিহ্নিত করে যেগুলির প্রমাণ গ্রাফ-উৎসারিত সীমার নিচে পড়ে — কিউরেটরের পর্যালোচনা সারি।', + 'evidenceTrust.loading': 'বিশ্বাস প্রোফাইল গণনা করা হচ্ছে…', + 'evidenceTrust.metricEntities': 'ওজনযুক্ত সত্তা', + 'evidenceTrust.metricEvidence': 'মোট প্রমাণ', + 'evidenceTrust.metricGini': 'প্রমাণ Gini', + 'evidenceTrust.namespaceAll': 'সমস্ত নেমস্পেস', + 'evidenceTrust.namespaceLabel': 'নেমস্পেস', + 'evidenceTrust.noWorklist': + 'কোনো অপর্যাপ্ত-সমর্থিত সম্পর্ক নেই — প্রতিটি দাবি প্রমাণ সীমা পূরণ করে।', + 'evidenceTrust.pointsTo': 'নির্দেশ করে', + 'evidenceTrust.predicatesHeading': 'বিধেয় নির্ভরযোগ্যতা সূচক', + 'evidenceTrust.retry': 'পুনরায় চেষ্টা', + 'evidenceTrust.summaryCaption': + '{relations} টি স্বতন্ত্র সম্পর্ক · অপর্যাপ্ত-সমর্থন সীমা {threshold}', + 'evidenceTrust.title': 'প্রমাণ-ওজনযুক্ত বিশ্বাস', + 'evidenceTrust.worklistHeading': 'অপর্যাপ্ত-সমর্থিত কর্মতালিকা', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4b30e1572b..720767807b 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4543,6 +4543,38 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', 'pages.settings.account.security': 'Sicherheit', 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Entität', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Vertrauensquotient', + 'evidenceTrust.degradedHint': + 'Jede Relation trägt derzeit evidenceCount = 1, sodass der Vertrauensquotient zu einer Konstante kollabiert. Die Linse braucht variierte Bestätigungswerte, um produktiv-dünn von leise-solide zu trennen.', + 'evidenceTrust.degradedTitle': + 'Evidenzsignal dünn — füllen Sie evidenceCount, um diese Linse freizuschalten.', + 'evidenceTrust.empty': 'Noch kein Wissensgraph.', + 'evidenceTrust.emptyHint': + 'Während der Assistent Relationen mit evidenceCount über Sie erfasst, erscheint hier das Vertrauensprofil.', + 'evidenceTrust.entitiesHeading': 'Vertrauensquotient-Rangliste', + 'evidenceTrust.errorPrefix': 'Graph konnte nicht geladen werden:', + 'evidenceTrust.evidenceLabel': 'Ev. {n}', + 'evidenceTrust.intro': + 'Vertrauensquotient pro Entität = mittlere Evidenz pro Relation, an der sie beteiligt ist. Evidenz-Gini misst, wie ungleich Evidenz über Entitäten konzentriert ist. Die unzureichend bestätigte Arbeitsliste markiert Relationen, deren Evidenz unter einer graphabgeleiteten Schwelle liegt — die Prüfschlange des Kurators.', + 'evidenceTrust.loading': 'Berechne Vertrauensprofil…', + 'evidenceTrust.metricEntities': 'Gewichtete Entitäten', + 'evidenceTrust.metricEvidence': 'Gesamtevidenz', + 'evidenceTrust.metricGini': 'Evidenz-Gini', + 'evidenceTrust.namespaceAll': 'Alle Namensräume', + 'evidenceTrust.namespaceLabel': 'Namensraum', + 'evidenceTrust.noWorklist': + 'Keine unzureichend bestätigten Relationen — jede Aussage erfüllt die Evidenzschwelle.', + 'evidenceTrust.pointsTo': 'zeigt auf', + 'evidenceTrust.predicatesHeading': 'Prädikat-Zuverlässigkeitsindex', + 'evidenceTrust.retry': 'Wiederholen', + 'evidenceTrust.summaryCaption': + '{relations} verschiedene Relationen · Unterbestätigungsschwelle {threshold}', + 'evidenceTrust.title': 'Evidenzgewichtetes Vertrauen', + 'evidenceTrust.worklistHeading': 'Unzureichend bestätigte Arbeitsliste', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ba050c75d5..168622f4d6 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.trust': 'Trust', 'memory.tab.settings': 'Settings', 'memory.tab.council': 'Council', 'modelCouncil.title': 'Model Council', @@ -489,6 +490,38 @@ const en: TranslationMap = { 'graphCohesion.brokerTitle': "Structural hole: this entity's neighbours aren't connected to each other — it's the sole link between them.", + 'evidenceTrust.title': 'Evidence-Weighted Trust', + 'evidenceTrust.intro': + "Trust Quotient per entity = mean evidence per relation it's involved in. Evidence Gini measures how unevenly evidence is concentrated across entities. The under-corroborated worklist flags relations whose evidence falls below a graph-derived threshold — the curator's review queue.", + 'evidenceTrust.loading': 'Computing trust profile…', + 'evidenceTrust.errorPrefix': 'Could not load the graph:', + 'evidenceTrust.retry': 'Retry', + 'evidenceTrust.empty': 'No knowledge graph yet.', + 'evidenceTrust.emptyHint': + 'As the assistant records relations with evidenceCount about you, the trust profile will surface here.', + 'evidenceTrust.degradedTitle': + 'Evidence signal sparse — populate evidenceCount to unlock this lens.', + 'evidenceTrust.degradedHint': + 'Every relation currently carries evidenceCount = 1, so Trust Quotient collapses to a constant. The lens needs varied corroboration values to separate prolific-thin from quiet-solid.', + 'evidenceTrust.namespaceLabel': 'Namespace', + 'evidenceTrust.namespaceAll': 'All namespaces', + 'evidenceTrust.metricGini': 'Evidence Gini', + 'evidenceTrust.metricEntities': 'Entities weighted', + 'evidenceTrust.metricEvidence': 'Total evidence', + 'evidenceTrust.summaryCaption': + '{relations} distinct relations · under-corroboration threshold {threshold}', + 'evidenceTrust.entitiesHeading': 'Trust Quotient ranking', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colEntity': 'Entity', + 'evidenceTrust.colTrust': 'Trust Quotient', + 'evidenceTrust.colDegree': 'Rels', + 'evidenceTrust.predicatesHeading': 'Predicate Reliability Index', + 'evidenceTrust.worklistHeading': 'Under-corroborated worklist', + 'evidenceTrust.noWorklist': + 'No under-corroborated relations — every assertion meets the evidence threshold.', + 'evidenceTrust.pointsTo': 'points to', + 'evidenceTrust.evidenceLabel': 'ev {n}', + // Memory Tree status panel (#1856 Part 1) 'memoryTree.status.title': 'Memory Tree', 'memoryTree.status.autoSyncLabel': 'Auto-sync', @@ -2500,7 +2533,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..738aabda41 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4509,6 +4509,38 @@ 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', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Entidad', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Cociente de confianza', + 'evidenceTrust.degradedHint': + 'Cada relación actualmente lleva evidenceCount = 1, por lo que el Cociente de confianza colapsa a una constante. La lente necesita valores variados de corroboración para separar prolífico-delgado de silencioso-sólido.', + 'evidenceTrust.degradedTitle': + 'Señal de evidencia escasa — rellene evidenceCount para desbloquear esta lente.', + 'evidenceTrust.empty': 'Aún no hay grafo de conocimiento.', + 'evidenceTrust.emptyHint': + 'A medida que el asistente registra relaciones con evidenceCount sobre usted, el perfil de confianza aparecerá aquí.', + 'evidenceTrust.entitiesHeading': 'Clasificación por Cociente de confianza', + 'evidenceTrust.errorPrefix': 'No se pudo cargar el grafo:', + 'evidenceTrust.evidenceLabel': 'ev. {n}', + 'evidenceTrust.intro': + 'Cociente de confianza por entidad = evidencia media por relación en la que participa. El Gini de evidencia mide cuán desigualmente se concentra la evidencia entre las entidades. La lista de sub-corroboradas señala relaciones cuya evidencia cae por debajo de un umbral derivado del grafo — la cola de revisión del curador.', + 'evidenceTrust.loading': 'Calculando perfil de confianza…', + 'evidenceTrust.metricEntities': 'Entidades ponderadas', + 'evidenceTrust.metricEvidence': 'Evidencia total', + 'evidenceTrust.metricGini': 'Gini de evidencia', + 'evidenceTrust.namespaceAll': 'Todos los espacios de nombres', + 'evidenceTrust.namespaceLabel': 'Espacio de nombres', + 'evidenceTrust.noWorklist': + 'No hay relaciones sub-corroboradas — cada afirmación alcanza el umbral de evidencia.', + 'evidenceTrust.pointsTo': 'apunta a', + 'evidenceTrust.predicatesHeading': 'Índice de fiabilidad de predicados', + 'evidenceTrust.retry': 'Reintentar', + 'evidenceTrust.summaryCaption': + '{relations} relaciones distintas · umbral de sub-corroboración {threshold}', + 'evidenceTrust.title': 'Confianza ponderada por evidencia', + 'evidenceTrust.worklistHeading': 'Lista de sub-corroboradas', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index e3b4395ada..a478c3811f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4524,6 +4524,38 @@ 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', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Entité', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Quotient de confiance', + 'evidenceTrust.degradedHint': + 'Chaque relation porte actuellement evidenceCount = 1, donc le Quotient de confiance se réduit à une constante. La lentille a besoin de valeurs de corroboration variées pour distinguer prolifique-mince de discret-solide.', + 'evidenceTrust.degradedTitle': + 'Signal de preuve clairsemé — renseignez evidenceCount pour débloquer cette lentille.', + 'evidenceTrust.empty': 'Pas encore de graphe de connaissances.', + 'evidenceTrust.emptyHint': + "À mesure que l'assistant enregistre des relations avec evidenceCount à votre sujet, le profil de confiance apparaîtra ici.", + 'evidenceTrust.entitiesHeading': 'Classement par Quotient de confiance', + 'evidenceTrust.errorPrefix': 'Impossible de charger le graphe :', + 'evidenceTrust.evidenceLabel': 'pr. {n}', + 'evidenceTrust.intro': + "Quotient de confiance par entité = preuve moyenne par relation à laquelle elle participe. Le Gini de preuve mesure à quel point les preuves sont concentrées inégalement entre les entités. La liste des sous-corroborées signale les relations dont les preuves passent sous un seuil dérivé du graphe — la file d'examen du conservateur.", + 'evidenceTrust.loading': 'Calcul du profil de confiance…', + 'evidenceTrust.metricEntities': 'Entités pondérées', + 'evidenceTrust.metricEvidence': 'Preuves totales', + 'evidenceTrust.metricGini': 'Gini de preuve', + 'evidenceTrust.namespaceAll': 'Tous les espaces de noms', + 'evidenceTrust.namespaceLabel': 'Espace de noms', + 'evidenceTrust.noWorklist': + 'Aucune relation insuffisamment corroborée — chaque affirmation atteint le seuil de preuve.', + 'evidenceTrust.pointsTo': 'pointe vers', + 'evidenceTrust.predicatesHeading': 'Indice de fiabilité des prédicats', + 'evidenceTrust.retry': 'Réessayer', + 'evidenceTrust.summaryCaption': + '{relations} relations distinctes · seuil de sous-corroboration {threshold}', + 'evidenceTrust.title': 'Confiance pondérée par les preuves', + 'evidenceTrust.worklistHeading': 'Liste des sous-corroborées', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b63c812398..a66274d1f6 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4434,6 +4434,37 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', 'pages.settings.account.security': 'सुरक्षा', 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', + 'evidenceTrust.colDegree': 'सं.', + 'evidenceTrust.colEntity': 'इकाई', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'विश्वास भागफल', + 'evidenceTrust.degradedHint': + 'हर संबंध वर्तमान में evidenceCount = 1 रखता है, इसलिए विश्वास भागफल एक स्थिरांक में सिमट जाता है। प्रचुर-पतले को शांत-ठोस से अलग करने के लिए लेंस को विविध समर्थन मानों की आवश्यकता है।', + 'evidenceTrust.degradedTitle': + 'साक्ष्य संकेत विरल — इस लेंस को अनलॉक करने के लिए evidenceCount भरें।', + 'evidenceTrust.empty': 'अभी कोई नॉलेज ग्राफ नहीं।', + 'evidenceTrust.emptyHint': + 'जैसे-जैसे सहायक आपके बारे में evidenceCount के साथ संबंध दर्ज करता है, विश्वास प्रोफ़ाइल यहाँ उभरेगा।', + 'evidenceTrust.entitiesHeading': 'विश्वास भागफल रैंकिंग', + 'evidenceTrust.errorPrefix': 'ग्राफ लोड नहीं हो सका:', + 'evidenceTrust.evidenceLabel': 'साक्ष्य {n}', + 'evidenceTrust.intro': + 'प्रति-इकाई विश्वास भागफल = उस संबंध प्रति औसत साक्ष्य जिसमें वह शामिल है। साक्ष्य Gini मापता है कि इकाइयों में साक्ष्य कितना असमान रूप से केंद्रित है। अपर्याप्त-समर्थित कार्य-सूची उन संबंधों को चिह्नित करती है जिनके साक्ष्य ग्राफ-व्युत्पन्न सीमा से नीचे गिरते हैं — क्यूरेटर की समीक्षा कतार।', + 'evidenceTrust.loading': 'विश्वास प्रोफ़ाइल गणना हो रही है…', + 'evidenceTrust.metricEntities': 'भारित इकाइयाँ', + 'evidenceTrust.metricEvidence': 'कुल साक्ष्य', + 'evidenceTrust.metricGini': 'साक्ष्य Gini', + 'evidenceTrust.namespaceAll': 'सभी नेमस्पेस', + 'evidenceTrust.namespaceLabel': 'नेमस्पेस', + 'evidenceTrust.noWorklist': + 'कोई अपर्याप्त-समर्थित संबंध नहीं — हर दावा साक्ष्य सीमा को पूरा करता है।', + 'evidenceTrust.pointsTo': 'की ओर इशारा करता है', + 'evidenceTrust.predicatesHeading': 'विधेय विश्वसनीयता सूचकांक', + 'evidenceTrust.retry': 'पुनः प्रयास', + 'evidenceTrust.summaryCaption': '{relations} विशिष्ट संबंध · अपर्याप्त-समर्थन सीमा {threshold}', + 'evidenceTrust.title': 'साक्ष्य-भारित विश्वास', + 'evidenceTrust.worklistHeading': 'अपर्याप्त-समर्थित कार्य-सूची', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index b83ee91377..bd05a4fdaa 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4443,6 +4443,37 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', 'pages.settings.account.security': 'Keamanan', 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Entitas', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Kuosien Kepercayaan', + 'evidenceTrust.degradedHint': + 'Setiap relasi saat ini membawa evidenceCount = 1, jadi Kuosien Kepercayaan menciut menjadi konstanta. Lensa membutuhkan nilai konfirmasi yang bervariasi untuk memisahkan prolifik-tipis dari sunyi-solid.', + 'evidenceTrust.degradedTitle': 'Sinyal bukti jarang — isi evidenceCount untuk membuka lensa ini.', + 'evidenceTrust.empty': 'Belum ada graf pengetahuan.', + 'evidenceTrust.emptyHint': + 'Saat asisten mencatat relasi dengan evidenceCount tentang Anda, profil kepercayaan akan muncul di sini.', + 'evidenceTrust.entitiesHeading': 'Peringkat Kuosien Kepercayaan', + 'evidenceTrust.errorPrefix': 'Tidak dapat memuat graf:', + 'evidenceTrust.evidenceLabel': 'bukti {n}', + 'evidenceTrust.intro': + 'Kuosien Kepercayaan per entitas = bukti rata-rata per relasi yang melibatkannya. Gini Bukti mengukur seberapa tidak merata bukti terkonsentrasi di antara entitas. Daftar kerja kurang-dikonfirmasi menandai relasi yang buktinya jatuh di bawah ambang yang diturunkan dari graf — antrean tinjauan kurator.', + 'evidenceTrust.loading': 'Menghitung profil kepercayaan…', + 'evidenceTrust.metricEntities': 'Entitas berbobot', + 'evidenceTrust.metricEvidence': 'Total bukti', + 'evidenceTrust.metricGini': 'Gini bukti', + 'evidenceTrust.namespaceAll': 'Semua ruang nama', + 'evidenceTrust.namespaceLabel': 'Ruang nama', + 'evidenceTrust.noWorklist': + 'Tidak ada relasi yang kurang dikonfirmasi — setiap pernyataan memenuhi ambang bukti.', + 'evidenceTrust.pointsTo': 'menunjuk ke', + 'evidenceTrust.predicatesHeading': 'Indeks Keandalan Predikat', + 'evidenceTrust.retry': 'Coba lagi', + 'evidenceTrust.summaryCaption': + '{relations} relasi berbeda · ambang kurang-konfirmasi {threshold}', + 'evidenceTrust.title': 'Kepercayaan tertimbang bukti', + 'evidenceTrust.worklistHeading': 'Daftar kerja kurang-dikonfirmasi', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 2426c1472c..8a01eddbbd 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4501,6 +4501,38 @@ 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', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Entità', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Quoziente di fiducia', + 'evidenceTrust.degradedHint': + 'Ogni relazione attualmente porta evidenceCount = 1, quindi il Quoziente di fiducia collassa a una costante. La lente ha bisogno di valori di corroborazione variati per distinguere prolifico-sottile da silenzioso-solido.', + 'evidenceTrust.degradedTitle': + 'Segnale di evidenza scarso — popola evidenceCount per sbloccare questa lente.', + 'evidenceTrust.empty': 'Ancora nessun grafo della conoscenza.', + 'evidenceTrust.emptyHint': + "Man mano che l'assistente registra relazioni con evidenceCount su di te, qui apparirà il profilo di fiducia.", + 'evidenceTrust.entitiesHeading': 'Classifica per Quoziente di fiducia', + 'evidenceTrust.errorPrefix': 'Impossibile caricare il grafo:', + 'evidenceTrust.evidenceLabel': 'ev. {n}', + 'evidenceTrust.intro': + "Quoziente di fiducia per entità = evidenza media per relazione a cui partecipa. Il Gini di evidenza misura quanto sia distribuita in modo non uniforme l'evidenza tra le entità. La lista delle sotto-corroborate segnala relazioni la cui evidenza scende sotto una soglia derivata dal grafo — la coda di revisione del curatore.", + 'evidenceTrust.loading': 'Calcolo profilo di fiducia…', + 'evidenceTrust.metricEntities': 'Entità ponderate', + 'evidenceTrust.metricEvidence': 'Evidenza totale', + 'evidenceTrust.metricGini': 'Gini di evidenza', + 'evidenceTrust.namespaceAll': 'Tutti gli spazi dei nomi', + 'evidenceTrust.namespaceLabel': 'Spazio dei nomi', + 'evidenceTrust.noWorklist': + 'Nessuna relazione sotto-corroborata — ogni asserzione raggiunge la soglia di evidenza.', + 'evidenceTrust.pointsTo': 'punta a', + 'evidenceTrust.predicatesHeading': 'Indice di affidabilità dei predicati', + 'evidenceTrust.retry': 'Riprova', + 'evidenceTrust.summaryCaption': + '{relations} relazioni distinte · soglia di sotto-corroborazione {threshold}', + 'evidenceTrust.title': "Fiducia pesata sull'evidenza", + 'evidenceTrust.worklistHeading': 'Lista delle sotto-corroborate', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ec68b50ab4..57c57dd150 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4393,6 +4393,37 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '로컬 저장소 거부', 'pages.settings.account.security': '보안', 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', + 'evidenceTrust.colDegree': '관계', + 'evidenceTrust.colEntity': '엔티티', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': '신뢰 지수', + 'evidenceTrust.degradedHint': + '모든 관계가 현재 evidenceCount = 1을 가지므로, 신뢰 지수가 상수로 무너집니다. 다작-얇은 것과 조용-견고한 것을 구분하려면 렌즈에 다양한 입증 값이 필요합니다.', + 'evidenceTrust.degradedTitle': + '증거 신호 희소 — 이 렌즈를 잠금 해제하려면 evidenceCount를 채우세요.', + 'evidenceTrust.empty': '아직 지식 그래프가 없습니다.', + 'evidenceTrust.emptyHint': + '어시스턴트가 사용자에 대해 evidenceCount를 갖는 관계를 기록함에 따라, 신뢰 프로필이 여기에 나타납니다.', + 'evidenceTrust.entitiesHeading': '신뢰 지수 순위', + 'evidenceTrust.errorPrefix': '그래프를 불러올 수 없습니다:', + 'evidenceTrust.evidenceLabel': '증거 {n}', + 'evidenceTrust.intro': + '엔티티당 신뢰 지수 = 관여한 관계당 평균 증거. 증거 Gini는 증거가 엔티티 전체에 얼마나 불균등하게 집중되어 있는지 측정합니다. 입증 부족 작업 목록은 증거가 그래프에서 도출된 임계값 아래로 떨어지는 관계를 표시합니다 — 큐레이터의 검토 대기열.', + 'evidenceTrust.loading': '신뢰 프로필 계산 중…', + 'evidenceTrust.metricEntities': '가중 엔티티', + 'evidenceTrust.metricEvidence': '총 증거', + 'evidenceTrust.metricGini': '증거 Gini', + 'evidenceTrust.namespaceAll': '모든 네임스페이스', + 'evidenceTrust.namespaceLabel': '네임스페이스', + 'evidenceTrust.noWorklist': + '충분히 입증되지 않은 관계 없음 — 모든 주장이 증거 임계값을 충족합니다.', + 'evidenceTrust.pointsTo': '가리킴', + 'evidenceTrust.predicatesHeading': '술어 신뢰성 지수', + 'evidenceTrust.retry': '다시 시도', + 'evidenceTrust.summaryCaption': '{relations}개 고유 관계 · 입증 부족 임계값 {threshold}', + 'evidenceTrust.title': '증거 가중 신뢰', + 'evidenceTrust.worklistHeading': '입증 부족 작업 목록', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b80e01d0d4..d60911be09 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4501,6 +4501,38 @@ 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', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Encja', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Współczynnik zaufania', + 'evidenceTrust.degradedHint': + 'Każda relacja obecnie niesie evidenceCount = 1, więc Współczynnik zaufania zwija się do stałej. Soczewka potrzebuje zróżnicowanych wartości potwierdzenia, by oddzielić płodne-cienkie od cichych-solidnych.', + 'evidenceTrust.degradedTitle': + 'Sygnał dowodu rzadki — wypełnij evidenceCount, aby odblokować tę soczewkę.', + 'evidenceTrust.empty': 'Jeszcze brak grafu wiedzy.', + 'evidenceTrust.emptyHint': + 'Gdy asystent zapisuje relacje z evidenceCount o tobie, profil zaufania pojawi się tutaj.', + 'evidenceTrust.entitiesHeading': 'Ranking Współczynnika zaufania', + 'evidenceTrust.errorPrefix': 'Nie udało się załadować grafu:', + 'evidenceTrust.evidenceLabel': 'dow. {n}', + 'evidenceTrust.intro': + 'Współczynnik zaufania na encję = średni dowód na relację, w której uczestniczy. Gini dowodów mierzy, jak nierównomiernie dowód jest skoncentrowany między encjami. Lista słabo potwierdzonych oznacza relacje, których dowód spada poniżej progu wyprowadzonego z grafu — kolejka rewizji kuratora.', + 'evidenceTrust.loading': 'Obliczanie profilu zaufania…', + 'evidenceTrust.metricEntities': 'Encje ważone', + 'evidenceTrust.metricEvidence': 'Łączny dowód', + 'evidenceTrust.metricGini': 'Gini dowodów', + 'evidenceTrust.namespaceAll': 'Wszystkie przestrzenie nazw', + 'evidenceTrust.namespaceLabel': 'Przestrzeń nazw', + 'evidenceTrust.noWorklist': + 'Brak słabo potwierdzonych relacji — każde stwierdzenie osiąga próg dowodu.', + 'evidenceTrust.pointsTo': 'wskazuje na', + 'evidenceTrust.predicatesHeading': 'Wskaźnik niezawodności predykatów', + 'evidenceTrust.retry': 'Spróbuj ponownie', + 'evidenceTrust.summaryCaption': + '{relations} różnych relacji · próg słabego potwierdzenia {threshold}', + 'evidenceTrust.title': 'Zaufanie ważone dowodem', + 'evidenceTrust.worklistHeading': 'Lista słabo potwierdzonych', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 75c601bd2f..afac1bdb4a 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4498,6 +4498,38 @@ 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', + 'evidenceTrust.colDegree': 'Rel.', + 'evidenceTrust.colEntity': 'Entidade', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Quociente de confiança', + 'evidenceTrust.degradedHint': + 'Cada relação atualmente carrega evidenceCount = 1, então o Quociente de Confiança colapsa para uma constante. A lente precisa de valores de corroboração variados para separar prolífico-fino de silencioso-sólido.', + 'evidenceTrust.degradedTitle': + 'Sinal de evidência esparso — preencha evidenceCount para desbloquear esta lente.', + 'evidenceTrust.empty': 'Ainda sem grafo de conhecimento.', + 'evidenceTrust.emptyHint': + 'À medida que o assistente registra relações com evidenceCount sobre você, o perfil de confiança aparecerá aqui.', + 'evidenceTrust.entitiesHeading': 'Classificação por Quociente de confiança', + 'evidenceTrust.errorPrefix': 'Não foi possível carregar o grafo:', + 'evidenceTrust.evidenceLabel': 'ev. {n}', + 'evidenceTrust.intro': + 'Quociente de confiança por entidade = evidência média por relação em que ela participa. O Gini de evidência mede quão desigualmente a evidência se concentra entre as entidades. A lista de sub-corroboradas sinaliza relações cuja evidência cai abaixo de um limiar derivado do grafo — a fila de revisão do curador.', + 'evidenceTrust.loading': 'Calculando perfil de confiança…', + 'evidenceTrust.metricEntities': 'Entidades ponderadas', + 'evidenceTrust.metricEvidence': 'Evidência total', + 'evidenceTrust.metricGini': 'Gini de evidência', + 'evidenceTrust.namespaceAll': 'Todos os espaços de nomes', + 'evidenceTrust.namespaceLabel': 'Espaço de nomes', + 'evidenceTrust.noWorklist': + 'Sem relações sub-corroboradas — cada afirmação atinge o limiar de evidência.', + 'evidenceTrust.pointsTo': 'aponta para', + 'evidenceTrust.predicatesHeading': 'Índice de confiabilidade de predicados', + 'evidenceTrust.retry': 'Tentar novamente', + 'evidenceTrust.summaryCaption': + '{relations} relações distintas · limiar de sub-corroboração {threshold}', + 'evidenceTrust.title': 'Confiança ponderada por evidência', + 'evidenceTrust.worklistHeading': 'Lista de sub-corroboradas', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 592a691e0c..1dd73b2a55 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4469,6 +4469,38 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', 'pages.settings.account.security': 'Безопасность', 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', + 'evidenceTrust.colDegree': 'Отн.', + 'evidenceTrust.colEntity': 'Сущность', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': 'Коэффициент доверия', + 'evidenceTrust.degradedHint': + 'Каждое отношение в настоящее время несёт evidenceCount = 1, поэтому Коэффициент доверия сворачивается в константу. Линзе нужны разнообразные значения подтверждения, чтобы отделить плодовито-тонкое от тихо-прочного.', + 'evidenceTrust.degradedTitle': + 'Сигнал свидетельств разрежён — заполните evidenceCount, чтобы разблокировать эту линзу.', + 'evidenceTrust.empty': 'Пока нет графа знаний.', + 'evidenceTrust.emptyHint': + 'По мере того как ассистент фиксирует отношения с evidenceCount о вас, здесь появится профиль доверия.', + 'evidenceTrust.entitiesHeading': 'Рейтинг по Коэффициенту доверия', + 'evidenceTrust.errorPrefix': 'Не удалось загрузить граф:', + 'evidenceTrust.evidenceLabel': 'св. {n}', + 'evidenceTrust.intro': + 'Коэффициент доверия на сущность = среднее свидетельство на отношение, в котором она участвует. Джини свидетельств измеряет, насколько неравномерно свидетельства концентрируются между сущностями. Список недостаточно подтверждённых отмечает отношения, чьи свидетельства падают ниже порога, выведенного из графа — очередь проверки куратора.', + 'evidenceTrust.loading': 'Вычисление профиля доверия…', + 'evidenceTrust.metricEntities': 'Сущности с весом', + 'evidenceTrust.metricEvidence': 'Всего свидетельств', + 'evidenceTrust.metricGini': 'Джини свидетельств', + 'evidenceTrust.namespaceAll': 'Все пространства имён', + 'evidenceTrust.namespaceLabel': 'Пространство имён', + 'evidenceTrust.noWorklist': + 'Нет недостаточно подтверждённых отношений — каждое утверждение достигает порога свидетельств.', + 'evidenceTrust.pointsTo': 'указывает на', + 'evidenceTrust.predicatesHeading': 'Индекс надёжности предикатов', + 'evidenceTrust.retry': 'Повторить', + 'evidenceTrust.summaryCaption': + '{relations} различных отношений · порог недоподтверждения {threshold}', + 'evidenceTrust.title': 'Доверие, взвешенное по свидетельствам', + 'evidenceTrust.worklistHeading': 'Список недостаточно подтверждённых', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6169e9a647..bcab26e8ba 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4214,6 +4214,34 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '拒绝本地存储', 'pages.settings.account.security': '安全', 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', + 'evidenceTrust.colDegree': '关系', + 'evidenceTrust.colEntity': '实体', + 'evidenceTrust.colRank': '#', + 'evidenceTrust.colTrust': '信任商', + 'evidenceTrust.degradedHint': + '目前每条关系都带 evidenceCount = 1,因此信任商坍缩为常数。本透镜需要多样化的印证值,才能将「多产-单薄」与「沉静-稳固」区分开来。', + 'evidenceTrust.degradedTitle': '证据信号稀疏——填充 evidenceCount 以解锁此透镜。', + 'evidenceTrust.empty': '暂无知识图。', + 'evidenceTrust.emptyHint': '随着助手记录带 evidenceCount 的关于你的关系,信任画像将在此显现。', + 'evidenceTrust.entitiesHeading': '信任商排名', + 'evidenceTrust.errorPrefix': '无法加载图:', + 'evidenceTrust.evidenceLabel': '证 {n}', + 'evidenceTrust.intro': + '实体信任商 = 它所参与的每条关系的平均证据。证据基尼衡量证据在实体间的集中不均匀程度。印证不足工作清单标记证据低于由图导出的阈值的关系——策展人的审查队列。', + 'evidenceTrust.loading': '正在计算信任画像…', + 'evidenceTrust.metricEntities': '加权实体', + 'evidenceTrust.metricEvidence': '总证据', + 'evidenceTrust.metricGini': '证据基尼', + 'evidenceTrust.namespaceAll': '所有命名空间', + 'evidenceTrust.namespaceLabel': '命名空间', + 'evidenceTrust.noWorklist': '没有印证不足的关系——每条断言都达到证据阈值。', + 'evidenceTrust.pointsTo': '指向', + 'evidenceTrust.predicatesHeading': '谓词可靠性指数', + 'evidenceTrust.retry': '重试', + 'evidenceTrust.summaryCaption': '{relations} 条不同关系 · 印证不足阈值 {threshold}', + 'evidenceTrust.title': '证据加权信任', + 'evidenceTrust.worklistHeading': '印证不足工作清单', + 'memory.tab.trust': 'Trust', }; export default messages; diff --git a/app/src/lib/memory/evidenceTrust.test.ts b/app/src/lib/memory/evidenceTrust.test.ts new file mode 100644 index 0000000000..e4cb25ee4e --- /dev/null +++ b/app/src/lib/memory/evidenceTrust.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeEvidenceTrust } from './evidenceTrust'; + +function rel( + subject: string, + predicate: string, + object: string, + evidenceCount: number | string | null | undefined = 1, + namespace: string | null = 'work' +): GraphRelation { + return { + namespace, + subject, + predicate, + object, + attrs: {}, + updatedAt: 0, + evidenceCount: evidenceCount as number, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +function ent( + result: ReturnType, + name: string, + namespace: string | null = 'work' +) { + const e = result.entities.find(x => x.entity === name && x.namespace === namespace); + if (!e) throw new Error(`entity (${namespace ?? ''}, ${name}) not found`); + return e; +} + +describe('computeEvidenceTrust — empty / single-entity guards', () => { + it('F1 — empty input yields a fully zero result', () => { + const r = computeEvidenceTrust([]); + expect(r.entities).toEqual([]); + expect(r.predicates).toEqual([]); + expect(r.underCorroborated).toEqual([]); + expect(r.totalRelations).toBe(0); + expect(r.totalEvidence).toBe(0); + expect(r.entityCount).toBe(0); + expect(r.globalGini).toBe(0); + expect(r.threshold).toBe(1); + expect(r.degraded).toBe(false); + }); + + it('F2 — a single sourced relation with evidence 5: TQ=5, Gini=0 (n<2), no under-corroborated', () => { + const r = computeEvidenceTrust([rel('A', 'knows', 'B', 5)]); + expect(r.totalRelations).toBe(1); + expect(r.totalEvidence).toBe(5); + expect(r.entityCount).toBe(2); // A and B + expect(ent(r, 'A').trustQuotient).toBe(5); + expect(ent(r, 'A').weightOfEvidence).toBe(5); + expect(ent(r, 'A').degree).toBe(1); + expect(ent(r, 'B').trustQuotient).toBe(5); + // Σw = 5+5 = 10; sorted [5,5]; closed-form: (2·1-2-1)·5 + (2·2-2-1)·5 = -5 + 5 = 0 -> Gini=0 + expect(r.globalGini).toBe(0); + // median positive evidence = 5; threshold = max(1, floor(5/4)) = 1; 5 >= 1 -> empty. + expect(r.threshold).toBe(1); + expect(r.underCorroborated).toEqual([]); + expect(r.degraded).toBe(false); + }); +}); + +describe('computeEvidenceTrust — trust quotient separates prolific-thin from quiet-solid', () => { + // prolific: 4 relations each evidence 1 -> W=4, D=4, TQ=1. + // quiet: 1 relation evidence 10 -> W=10, D=1, TQ=10. + // (each rel touches subject AND object, so degree counts both touches) + const r = computeEvidenceTrust([ + rel('prolific', 'knows', 'a', 1), + rel('prolific', 'knows', 'b', 1), + rel('prolific', 'knows', 'c', 1), + rel('prolific', 'knows', 'd', 1), + rel('quiet', 'recommends', 'rare', 10), + ]); + + it('quiet entity outranks prolific entity by trust quotient', () => { + expect(ent(r, 'prolific').weightOfEvidence).toBe(4); + expect(ent(r, 'prolific').degree).toBe(4); + expect(ent(r, 'prolific').trustQuotient).toBe(1); + + expect(ent(r, 'quiet').weightOfEvidence).toBe(10); + expect(ent(r, 'quiet').degree).toBe(1); + expect(ent(r, 'quiet').trustQuotient).toBe(10); + // quiet sits at the top of the sorted list (TQ DESC). + expect(r.entities[0].entity).toBe('quiet'); + }); + + it('predicate reliability: recommends > knows on PRI', () => { + const rec = r.predicates.find(p => p.predicate === 'recommends')!; + const kno = r.predicates.find(p => p.predicate === 'knows')!; + expect(rec.reliability).toBe(10); + expect(kno.reliability).toBe(1); + expect(r.predicates[0].predicate).toBe('recommends'); + }); +}); + +describe('computeEvidenceTrust — Gini coefficient', () => { + it('zero inequality: every entity has the same weight -> Gini = 0', () => { + // Two disjoint edges of equal evidence -> 4 entities each with W=3. + const r = computeEvidenceTrust([rel('A', 'p', 'B', 3), rel('C', 'p', 'D', 3)]); + const weights = r.entities.map(e => e.weightOfEvidence); + expect(weights).toEqual([3, 3, 3, 3]); + expect(r.globalGini).toBe(0); + }); + + it('maximum-style inequality: one heavy edge, one zero edge -> Gini = 0.5', () => { + // Edges: A-B with evidence 100 (W(A)=W(B)=100), C-D with evidence 0 + // (W(C)=W(D)=0). Sorted weights [0,0,100,100]; closed-form + // Σ = (-3)·0 + (-1)·0 + 1·100 + 3·100 = 400. + // G = 400 / (4 · 200) = 0.5. + const r = computeEvidenceTrust([rel('A', 'p', 'B', 100), rel('C', 'p', 'D', 0)]); + expect(r.globalGini).toBe(0.5); + }); + + it('the all-zero-evidence guard: every relation evidence=0 -> Gini = 0 (no divide-by-zero)', () => { + const r = computeEvidenceTrust([rel('A', 'p', 'B', 0), rel('C', 'p', 'D', 0)]); + expect(r.totalEvidence).toBe(0); + expect(r.globalGini).toBe(0); + }); +}); + +describe('computeEvidenceTrust — under-corroboration threshold & worklist', () => { + it('threshold = max(1, floor(median_positive_evidence / 4)); index-picks lower-middle on even sets', () => { + // Positive evidence list: [1, 2, 4, 8, 12]. Median (index 2) = 4. + // threshold = max(1, floor(4 / 4)) = 1. Nothing under-corroborated. + const r = computeEvidenceTrust([ + rel('A', 'p', 'B', 1), + rel('B', 'p', 'C', 2), + rel('C', 'p', 'D', 4), + rel('D', 'p', 'E', 8), + rel('E', 'p', 'F', 12), + ]); + expect(r.threshold).toBe(1); + expect(r.underCorroborated).toEqual([]); + }); + + it('flags relations below threshold (index-pick median, NOT mean of two middles)', () => { + // Positive evidence list sorted: [1, 1, 8, 8, 8, 8] -> n=6, lower-middle + // index = (6-1)>>1 = 2 -> 8. threshold = max(1, floor(8/4)) = 2. + // The two evidence=1 relations are flagged; the evidence=8 relations are not. + const r = computeEvidenceTrust([ + rel('S', 'p', 'X', 1), + rel('S', 'p', 'Y', 1), + rel('M', 'p', 'A', 8), + rel('M', 'p', 'B', 8), + rel('M', 'p', 'C', 8), + rel('M', 'p', 'D', 8), + ]); + expect(r.threshold).toBe(2); + expect(r.underCorroborated.map(u => `${u.subject}-${u.object}:${u.evidence}`)).toEqual([ + 'S-X:1', + 'S-Y:1', + ]); + }); + + it('includes zero-evidence relations in the worklist (threshold >= 1 always)', () => { + const r = computeEvidenceTrust([rel('A', 'p', 'B', 0), rel('C', 'p', 'D', 5)]); + expect(r.threshold).toBe(1); // max(1, floor(5/4)) = 1 + expect(r.underCorroborated).toHaveLength(1); + expect(r.underCorroborated[0]).toMatchObject({ subject: 'A', object: 'B', evidence: 0 }); + }); +}); + +describe('computeEvidenceTrust — degraded mode', () => { + it('flags degraded:true when every distinct relation has sanitised evidence === 1', () => { + const r = computeEvidenceTrust([ + rel('A', 'p', 'B', 1), + rel('B', 'p', 'C', 1), + rel('C', 'p', 'D', 1), + ]); + expect(r.degraded).toBe(true); + }); + + it('not degraded when some relation has evidence !== 1', () => { + const r = computeEvidenceTrust([rel('A', 'p', 'B', 1), rel('B', 'p', 'C', 2)]); + expect(r.degraded).toBe(false); + }); + + it('not degraded when input is empty', () => { + expect(computeEvidenceTrust([]).degraded).toBe(false); + }); +}); + +describe('computeEvidenceTrust — sanitisation', () => { + it('coerces string-encoded numerics and scientific notation', () => { + const r = computeEvidenceTrust([ + rel('A', 'p', 'B', '3' as unknown as number), // string "3" + rel('B', 'p', 'C', '1e2' as unknown as number), // string "1e2" = 100 + ]); + expect(ent(r, 'A').weightOfEvidence).toBe(3); + expect(ent(r, 'B').weightOfEvidence).toBe(3 + 100); // touched by both + expect(ent(r, 'C').weightOfEvidence).toBe(100); + }); + + it('treats NaN / Infinity / negative / null as 0 evidence (relation still counted)', () => { + // NB: passing `undefined` would trigger the rel() default-parameter (=1); + // use `null` to actually exercise the missing-evidence path. + const r = computeEvidenceTrust([ + rel('A', 'p', 'B', Number.NaN), + rel('C', 'p', 'D', Number.POSITIVE_INFINITY), + rel('E', 'p', 'F', -5), + rel('G', 'p', 'H', null), + ]); + expect(r.totalRelations).toBe(4); + expect(r.totalEvidence).toBe(0); + expect(ent(r, 'A').weightOfEvidence).toBe(0); + // All-zero evidence -> Gini=0 (n>=2 but Σw===0 path). + expect(r.globalGini).toBe(0); + }); + + it('floors fractional evidence', () => { + const r = computeEvidenceTrust([rel('A', 'p', 'B', 3.9 as unknown as number)]); + expect(ent(r, 'A').weightOfEvidence).toBe(3); + }); +}); + +describe('computeEvidenceTrust — multigraph & namespace', () => { + it('SUMS evidence across multiple GraphRelation entries for the same (ns, s, p, o) tuple', () => { + const r = computeEvidenceTrust([ + rel('A', 'knows', 'B', 2), // first attestation, ev 2 + rel('A', 'knows', 'B', 3), // second attestation of the SAME triple, ev 3 + ]); + // After multigraph collapse, ONE distinct relation with evidence 5. + expect(r.totalRelations).toBe(1); + expect(r.totalEvidence).toBe(5); + expect(ent(r, 'A').weightOfEvidence).toBe(5); + expect(ent(r, 'A').degree).toBe(1); + expect(ent(r, 'A').trustQuotient).toBe(5); + }); + + it('treats the same name in different namespaces as distinct entities', () => { + const r = computeEvidenceTrust([ + rel('Alice', 'knows', 'Bob', 10, 'work'), + rel('Alice', 'knows', 'Bob', 1, 'personal'), + ]); + expect(r.entityCount).toBe(4); // Alice@work, Bob@work, Alice@personal, Bob@personal + expect(ent(r, 'Alice', 'work').weightOfEvidence).toBe(10); + expect(ent(r, 'Alice', 'personal').weightOfEvidence).toBe(1); + }); +}); + +describe('computeEvidenceTrust — normalisation & determinism', () => { + it('drops relations with a non-string subject, predicate, or object', () => { + const badSubject = { ...rel('A', 'p', 'B'), subject: null as unknown as string }; + const r = computeEvidenceTrust([rel('A', 'p', 'B', 5), badSubject]); + expect(r.totalRelations).toBe(1); + expect(r.totalEvidence).toBe(5); + }); + + it('is order-independent: shuffled input yields BYTE-identical output', () => { + const edges = [ + rel('A', 'knows', 'B', 3), + rel('A', 'trusts', 'C', 1), + rel('B', 'knows', 'D', 4), + rel('C', 'mentors', 'E', 2), + rel('A', 'knows', 'B', 2), // multi-attestation (merges with first) + rel('orphan', 'p', 'island', 1, 'personal'), + ]; + const forward = computeEvidenceTrust(edges); + const reversed = computeEvidenceTrust([...edges].reverse()); + const rotated = computeEvidenceTrust([...edges.slice(3), ...edges.slice(0, 3)]); + expect(reversed).toEqual(forward); + expect(rotated).toEqual(forward); + expect(reversed.globalGini).toBe(forward.globalGini); // bit equality + }); +}); diff --git a/app/src/lib/memory/evidenceTrust.ts b/app/src/lib/memory/evidenceTrust.ts new file mode 100644 index 0000000000..ccbe7aa296 --- /dev/null +++ b/app/src/lib/memory/evidenceTrust.ts @@ -0,0 +1,306 @@ +/** + * Evidence-Weighted Trust Lens — pure trust-and-corroboration engine. + * + * The 20 shipped intelligence lenses all read the (subject, predicate, object) + * triple structure or its document provenance. None of them makes + * `evidenceCount` — the field that measures how many times an assertion has + * been independently corroborated — the PRIMARY signal. This lens does. + * + * It surfaces three orthogonal-to-everything-shipped numbers: + * + * - TRUST QUOTIENT per entity (TQ) — the mean evidence per relation an + * entity is involved in. A prolific entity with many single-witness + * facts has low TQ; a quiet entity whose few relations are heavily + * corroborated has high TQ. Degree alone (centrality, k-core) cannot + * express this distinction. + * - EVIDENCE GINI (G) — the inequality of evidence concentration across + * entities. 0 means every entity carries the same weight of evidence; + * near 1 means one entity dominates. Healthy memories sit in the + * middle; runaway concentration suggests a single source is being + * over-trusted. + * - UNDER-CORROBORATED WORKLIST — relations whose evidence falls below + * a graph-derived threshold (max(1, floor(median_positive_evidence / 4))), + * handed to the curator as a "review these next" queue. + * + * Two reliability ranks fall out as supporting outputs: + * + * - Predicate Reliability Index (PRI) per predicate — mean evidence per + * relation of that predicate kind. Surfaces predicates that are + * systematically thin (a `birthdate` you read once vs a `knows` you + * re-heard a dozen times). + * - `degraded: true` when every relation has evidence === 1 (the field + * was never populated meaningfully), so the UI can show an empty-state + * instead of misleading the user with constant TQ everywhere. + * + * Everything here is PURE and DETERMINISTIC. The only float divisions are + * single-op (TQ, PRI, Gini final ratio) on integer numerators, so there is + * no float-add-associativity hazard. All Map iteration is replaced by + * canonical sorted-key walks (entities by JSON.stringify([namespace, name]) + * ASC; predicates by predicate ASC; relations by canonical relKey ASC). + * + * Load-bearing design choices (do not "fix" without reading the tests): + * - evidenceCount is COERCED via `Number(...)` (handles string-encoded + * JSON numerics like "3" / "1e2") then SANITISED: any non-finite, NaN, + * or non-positive value collapses to 0. Sanitised values are floored + * to integers so all subsequent sums are exact integer arithmetic. + * - Relations with sanitised evidence 0 are KEPT (they still represent + * an assertion; we flag them in the under-corroborated bucket). They + * contribute 0 to W(x) and Σw. + * - Multigraph evidence policy: when the same (namespace, subject, + * predicate, object) tuple appears multiple times in the input + * (different documentIds, etc.), evidence SUMS across all instances. + * The unique tuple counts as ONE distinct relation for D(x), PRI's + * denominator, and the under-corroborated worklist. + * - Entity identity is (namespace, name) — a name appearing in two + * namespaces produces two distinct rows. We deliberately avoid the + * case-folding / trimming reserved for the Entity Duplicates lens. + * - Gini uses the closed-form `G = Σ_i (2i - n - 1) w_i / (n · Σw)` with + * 1-indexed i over sorted-ascending weights — one integer-weighted sum + * and one division, so the result is order-immune. + * - Median of positive evidence picks the LOWER MIDDLE index for + * even-sized lists (NOT the arithmetic mean — that would introduce + * float drift). Ties on evidence are broken by relKey ASC so the + * median is byte-identical across input permutations. + * - Empty-graph and all-zero-evidence guards: globalGini = 0 when + * entityCount < 2 OR Σw === 0 (the OR is critical — `&&` would crash + * on an all-NaN graph). + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface EntityTrust { + entity: string; + namespace: string | null; + weightOfEvidence: number; // W(x) + degree: number; // D(x) — distinct relations touching x + trustQuotient: number; // W(x) / D(x); 0 when D(x) === 0 +} + +export interface PredicateReliability { + predicate: string; + weightSum: number; // total sanitised evidence on relations of this predicate + relationCount: number; // distinct relations of this predicate + reliability: number; // weightSum / relationCount; 0 when relationCount === 0 +} + +export interface UnderCorroboratedRelation { + namespace: string | null; + subject: string; + predicate: string; + object: string; + evidence: number; // sanitised value (Math.floor of positive finite number, else 0) +} + +export interface EvidenceTrustResult { + entities: EntityTrust[]; // sorted trustQuotient DESC, weightOfEvidence DESC, namespace ASC, entity ASC + predicates: PredicateReliability[]; // sorted reliability DESC, predicate ASC + underCorroborated: UnderCorroboratedRelation[]; // sorted evidence ASC, then relKey ASC + totalRelations: number; // distinct (namespace, s, p, o) tuples after multigraph collapse + totalEvidence: number; // Σ sanitised evidence across all distinct relations + entityCount: number; // distinct (namespace, name) entities + globalGini: number; // 0..1; 0 when entityCount < 2 OR totalEvidence === 0 + threshold: number; // max(1, floor(median_positive_evidence / 4)); 1 when no positives + degraded: boolean; // true when every distinct relation has sanitised evidence === 1 +} + +function isRelation(relation: GraphRelation): boolean { + return ( + typeof relation.subject === 'string' && + typeof relation.predicate === 'string' && + typeof relation.object === 'string' + ); +} + +/** Coerce + sanitise an evidenceCount value. Anything non-finite, NaN, or + * non-positive collapses to 0; otherwise floored to an integer. Handles the + * common JSON quirks: string-encoded numerics, scientific notation, undefined. */ +function sanitiseEvidence(raw: unknown): number { + const numeric = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(numeric) || numeric <= 0) return 0; + return Math.floor(numeric); +} + +function entityKey(namespace: string | null, name: string): string { + return JSON.stringify([namespace, name]); +} + +function relKey( + namespace: string | null, + subject: string, + predicate: string, + object: string +): string { + return JSON.stringify([namespace, subject, predicate, object]); +} + +function compareStrings(a: string, b: string): number { + if (a === b) return 0; + return a < b ? -1 : 1; +} + +interface DistinctRelation { + namespace: string | null; + subject: string; + predicate: string; + object: string; + evidence: number; + relKey: string; +} + +/** Compute the Evidence-Weighted Trust Lens result. PURE. */ +export function computeEvidenceTrust(relations: GraphRelation[]): EvidenceTrustResult { + // Pass 1: multigraph collapse. Same (namespace, s, p, o) tuple appearing + // multiple times SUMS evidence; it counts as ONE distinct relation. + const distinctRels = new Map(); + for (const relation of relations) { + if (!isRelation(relation)) continue; + const namespace = typeof relation.namespace === 'string' ? relation.namespace : null; + const { subject, predicate, object } = relation; + const key = relKey(namespace, subject, predicate, object); + const evidence = sanitiseEvidence(relation.evidenceCount); + const existing = distinctRels.get(key); + if (existing === undefined) { + distinctRels.set(key, { namespace, subject, predicate, object, evidence, relKey: key }); + } else { + existing.evidence += evidence; + } + } + + // Canonical iteration order over distinct relations. + const sortedRels = [...distinctRels.values()].sort((a, b) => compareStrings(a.relKey, b.relKey)); + + // Pass 2: aggregate per-entity (W, D) and per-predicate (weightSum, count). + const weightByEntity = new Map(); + const degreeByEntity = new Map(); + const weightByPredicate = new Map(); + const countByPredicate = new Map(); + let totalEvidence = 0; + + for (const rel of sortedRels) { + // Every relation credits BOTH endpoints with the relation's evidence and + // one degree count (undirected-degree trust-attribution convention — both + // sides of a corroborated fact get the same trust credit). A self-loop + // (subject === object) collapses both updates onto the same Map entry, + // naturally doubling the touch — exactly the right behaviour, no special + // case needed. + const subjectKey = entityKey(rel.namespace, rel.subject); + const objectKey = entityKey(rel.namespace, rel.object); + + weightByEntity.set(subjectKey, (weightByEntity.get(subjectKey) ?? 0) + rel.evidence); + degreeByEntity.set(subjectKey, (degreeByEntity.get(subjectKey) ?? 0) + 1); + weightByEntity.set(objectKey, (weightByEntity.get(objectKey) ?? 0) + rel.evidence); + degreeByEntity.set(objectKey, (degreeByEntity.get(objectKey) ?? 0) + 1); + + weightByPredicate.set( + rel.predicate, + (weightByPredicate.get(rel.predicate) ?? 0) + rel.evidence + ); + countByPredicate.set(rel.predicate, (countByPredicate.get(rel.predicate) ?? 0) + 1); + + totalEvidence += rel.evidence; + } + + // Build per-entity rows in canonical entity-key sorted order. + const sortedEntityKeys = [...weightByEntity.keys()].sort(compareStrings); + const entities: EntityTrust[] = sortedEntityKeys.map(key => { + const [namespace, entity] = JSON.parse(key) as [string | null, string]; + const w = weightByEntity.get(key) ?? 0; + const d = degreeByEntity.get(key) ?? 0; + return { + entity, + namespace, + weightOfEvidence: w, + degree: d, + trustQuotient: d === 0 ? 0 : w / d, + }; + }); + entities.sort((a, b) => { + if (b.trustQuotient !== a.trustQuotient) return b.trustQuotient - a.trustQuotient; + if (b.weightOfEvidence !== a.weightOfEvidence) return b.weightOfEvidence - a.weightOfEvidence; + const ns = compareStrings(a.namespace ?? '', b.namespace ?? ''); + if (ns !== 0) return ns; + return compareStrings(a.entity, b.entity); + }); + + // Per-predicate reliability rows in canonical predicate-ASC order. + const sortedPredicates = [...weightByPredicate.keys()].sort(compareStrings); + const predicates: PredicateReliability[] = sortedPredicates.map(predicate => { + const weightSum = weightByPredicate.get(predicate) ?? 0; + const relationCount = countByPredicate.get(predicate) ?? 0; + return { + predicate, + weightSum, + relationCount, + reliability: relationCount === 0 ? 0 : weightSum / relationCount, + }; + }); + predicates.sort((a, b) => { + if (b.reliability !== a.reliability) return b.reliability - a.reliability; + return compareStrings(a.predicate, b.predicate); + }); + + // Global Gini coefficient via the closed form on sorted entity weights: + // G = Σ_i (2i - n - 1) · w_i / (n · Σw) (1-indexed i over w_i ASC) + // Σw here is the sum of ENTITY weights (each relation's evidence is credited + // to both subject and object, so Σw = 2·totalEvidence in the simple-graph + // case but the entity-weight sum is what the Gini formula actually needs). + const weightsAsc = entities.map(e => e.weightOfEvidence).sort((a, b) => a - b); + let weightsSum = 0; + for (const w of weightsAsc) weightsSum += w; + let globalGini = 0; + if (weightsAsc.length >= 2 && weightsSum > 0) { + let weighted = 0; + for (let i = 0; i < weightsAsc.length; i += 1) { + weighted += (2 * (i + 1) - weightsAsc.length - 1) * weightsAsc[i]; + } + globalGini = weighted / (weightsAsc.length * weightsSum); + } + + // Median-positive-evidence threshold for the under-corroborated worklist. + // Sort by (evidence ASC, relKey ASC) and pick the LOWER MIDDLE index for + // even-sized lists — index `(n - 1) >> 1` — so the median is byte-identical + // across input permutations and never averages two values (which would + // introduce float drift). + const positives = sortedRels + .filter(r => r.evidence > 0) + .sort((a, b) => { + if (a.evidence !== b.evidence) return a.evidence - b.evidence; + return compareStrings(a.relKey, b.relKey); + }); + const threshold = + positives.length === 0 + ? 1 + : Math.max(1, Math.floor(positives[(positives.length - 1) >> 1].evidence / 4)); + + // Under-corroborated relations: sanitised evidence < threshold. + const underCorroborated: UnderCorroboratedRelation[] = sortedRels + .filter(r => r.evidence < threshold) + .map(r => ({ + namespace: r.namespace, + subject: r.subject, + predicate: r.predicate, + object: r.object, + evidence: r.evidence, + })) + .sort((a, b) => { + if (a.evidence !== b.evidence) return a.evidence - b.evidence; + return compareStrings( + relKey(a.namespace, a.subject, a.predicate, a.object), + relKey(b.namespace, b.subject, b.predicate, b.object) + ); + }); + + // Degraded mode: every distinct relation has sanitised evidence === 1. + const degraded = sortedRels.length > 0 && sortedRels.every(r => r.evidence === 1); + + return { + entities, + predicates, + underCorroborated, + totalRelations: sortedRels.length, + totalEvidence, + entityCount: entities.length, + globalGini, + threshold, + degraded, + }; +} diff --git a/app/src/services/api/evidenceTrustApi.test.ts b/app/src/services/api/evidenceTrustApi.test.ts new file mode 100644 index 0000000000..22f8510b1a --- /dev/null +++ b/app/src/services/api/evidenceTrustApi.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeEvidenceTrust } from '../../lib/memory/evidenceTrust'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { evidenceTrustApi, loadEvidenceTrust, loadNamespaces } from './evidenceTrustApi'; + +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, + predicate: string, + object: string, + evidenceCount: number +): GraphRelation { + return { + namespace: 'work', + subject, + predicate, + object, + attrs: {}, + updatedAt: 0, + evidenceCount, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('evidenceTrustApi.loadEvidenceTrust', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('passes the namespace through and returns the engine result', async () => { + const triples = [rel('A', 'knows', 'B', 5), rel('C', 'knows', 'D', 1)]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadEvidenceTrust('work'); + expect(mockGraphQuery).toHaveBeenCalledWith('work'); + expect(out).toEqual(computeEvidenceTrust(triples)); + expect(out.totalEvidence).toBe(6); + }); + + it('queries all namespaces when none is given', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadEvidenceTrust(); + expect(mockGraphQuery).toHaveBeenCalledWith(undefined); + expect(out.entities).toEqual([]); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadEvidenceTrust()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('evidenceTrustApi.loadNamespaces', () => { + beforeEach(() => { + mockListNamespaces.mockReset(); + }); + + it('returns the namespace list from the RPC', async () => { + mockListNamespaces.mockResolvedValueOnce(['work', 'personal']); + expect(await loadNamespaces()).toEqual(['work', 'personal']); + }); +}); + +describe('evidenceTrustApi object', () => { + it('exposes the public surface', () => { + expect(typeof evidenceTrustApi.loadEvidenceTrust).toBe('function'); + expect(typeof evidenceTrustApi.loadNamespaces).toBe('function'); + }); +}); diff --git a/app/src/services/api/evidenceTrustApi.ts b/app/src/services/api/evidenceTrustApi.ts new file mode 100644 index 0000000000..e39a2295a1 --- /dev/null +++ b/app/src/services/api/evidenceTrustApi.ts @@ -0,0 +1,35 @@ +/** + * RPC facade for the Evidence-Weighted Trust Lens. + * + * Adds ZERO new core surface. It composes two already-shipped JSON-RPC wrappers: + * - memoryGraphQuery (openhuman.memory_graph_query) — the triples + * - memoryListNamespaces (openhuman.memory_list_namespaces) — the selector + * and delegates all math to the pure, deterministic engine. Read-only. + */ +import debug from 'debug'; + +import { computeEvidenceTrust, type EvidenceTrustResult } from '../../lib/memory/evidenceTrust'; +import { memoryGraphQuery, memoryListNamespaces } from '../../utils/tauriCommands/memory'; + +const log = debug('evidence-trust:api'); + +/** Fetch graph relations for a namespace (or all) and compute trust metrics. */ +export async function loadEvidenceTrust(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] loadEvidenceTrust method=%s namespaceProvided=%s relations=%d', + 'loadEvidenceTrust', + namespace != null, + relations.length + ); + return computeEvidenceTrust(relations); +} + +/** List the namespaces available for the namespace selector. */ +export async function loadNamespaces(): Promise { + return memoryListNamespaces(); +} + +export const evidenceTrustApi = { loadEvidenceTrust, loadNamespaces };