mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-12 22:42:10 +02:00
fix(dashboard): wire duplicates/contradictions/patterns to real APIs, remove mock data
The /duplicates, /contradictions, and /patterns routes shipped hardcoded mock data — and /duplicates rendered a "Live" badge over it. That is a launch credibility risk: a visitor who spots the canned data distrusts every real claim (including the CauseBench benchmark). Replaces the three mocks (mockFetchDuplicates, MOCK_CONTRADICTIONS, mockFetchCrossProject) with real dashboard HTTP endpoints backed by the existing core capabilities: - GET /api/duplicates -> dedup cluster detection - GET /api/contradictions -> trust-weighted contradiction analysis - GET /api/patterns/cross-project-> cross-project pattern transfer Adds the handlers (dashboard/handlers.rs), route registrations (dashboard/mod.rs), frontend api client methods + response types, and rewrites the three routes to fetch live. The "Live" badge is now truthful. Verified this session: - cargo build -p vestige-mcp: compiles - pnpm --filter @vestige/dashboard check: 905 files, 0 errors, 0 warnings - live backend on a 1073-memory DB returns real rows from all three endpoints Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d15cce7ab
commit
898bd336cc
7 changed files with 1024 additions and 761 deletions
|
|
@ -6,6 +6,10 @@ import type {
|
|||
HealthCheck,
|
||||
TimelineResponse,
|
||||
GraphResponse,
|
||||
DuplicatesResponse,
|
||||
ContradictionsResponse,
|
||||
CrossProjectPatternsResponse,
|
||||
MemoryAuditResponse,
|
||||
DreamResult,
|
||||
ImportanceScore,
|
||||
RetentionDistribution,
|
||||
|
|
@ -111,6 +115,28 @@ export const api = {
|
|||
|
||||
retentionDistribution: () => fetcher<RetentionDistribution>('/retention-distribution'),
|
||||
|
||||
// Memory hygiene & provenance: duplicate clusters, contradiction pairs,
|
||||
// cross-project pattern transfer, per-memory audit trail.
|
||||
duplicates: (threshold = 0.8, limit = 20) =>
|
||||
fetcher<DuplicatesResponse>(`/duplicates?threshold=${threshold}&limit=${limit}`),
|
||||
|
||||
contradictions: (params?: { topic?: string; min_trust?: number; limit?: number }) => {
|
||||
const qs = params
|
||||
? '?' +
|
||||
new URLSearchParams(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
).toString()
|
||||
: '';
|
||||
return fetcher<ContradictionsResponse>(`/contradictions${qs}`);
|
||||
},
|
||||
|
||||
crossProjectPatterns: () => fetcher<CrossProjectPatternsResponse>('/patterns/cross-project'),
|
||||
|
||||
memoryAudit: (id: string, limit = 100) =>
|
||||
fetcher<MemoryAuditResponse>(`/memories/${encodeURIComponent(id)}/audit?limit=${limit}`),
|
||||
|
||||
// Intentions
|
||||
intentions: (status = 'active') =>
|
||||
fetcher<{ intentions: IntentionItem[]; total: number; filter: string }>(`/intentions?status=${status}`),
|
||||
|
|
|
|||
|
|
@ -302,6 +302,107 @@ export interface IntentionItem {
|
|||
snoozed_until?: string | null;
|
||||
}
|
||||
|
||||
// Memory Hygiene — GET /api/duplicates (cosine-similarity clusters)
|
||||
export interface DuplicateClusterMemory {
|
||||
id: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
tags: string[];
|
||||
retention: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DuplicateClusterGroup {
|
||||
similarity: number;
|
||||
suggestedAction: 'merge' | 'review';
|
||||
memories: DuplicateClusterMemory[];
|
||||
}
|
||||
|
||||
export interface DuplicatesResponse {
|
||||
threshold: number;
|
||||
total: number;
|
||||
clusters: DuplicateClusterGroup[];
|
||||
}
|
||||
|
||||
// Contradiction pairs — GET /api/contradictions. Field names mirror the
|
||||
// Contradiction interface in $components/ContradictionArcs.svelte; a = older
|
||||
// memory, b = newer. Sorted by similarity desc.
|
||||
export interface ContradictionPair {
|
||||
memory_a_id: string;
|
||||
memory_b_id: string;
|
||||
memory_a_preview: string;
|
||||
memory_b_preview: string;
|
||||
memory_a_type?: string;
|
||||
memory_b_type?: string;
|
||||
memory_a_created?: string;
|
||||
memory_b_created?: string;
|
||||
memory_a_tags?: string[];
|
||||
memory_b_tags?: string[];
|
||||
trust_a: number;
|
||||
trust_b: number;
|
||||
similarity: number;
|
||||
date_diff_days: number;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface ContradictionsResponse {
|
||||
memoriesAnalyzed: number;
|
||||
total: number;
|
||||
contradictions: ContradictionPair[];
|
||||
}
|
||||
|
||||
// Cross-project pattern transfer — GET /api/patterns/cross-project.
|
||||
// Only the six tracked categories ever cross the wire (backend drops others).
|
||||
export type CrossProjectCategory =
|
||||
| 'ErrorHandling'
|
||||
| 'AsyncConcurrency'
|
||||
| 'Testing'
|
||||
| 'Architecture'
|
||||
| 'Performance'
|
||||
| 'Security';
|
||||
|
||||
export interface CrossProjectPattern {
|
||||
name: string;
|
||||
category: CrossProjectCategory;
|
||||
origin_project: string;
|
||||
transferred_to: string[];
|
||||
transfer_count: number;
|
||||
last_used: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface CrossProjectPatternsResponse {
|
||||
projects: string[];
|
||||
patterns: CrossProjectPattern[];
|
||||
}
|
||||
|
||||
// Per-memory audit trail — GET /api/memories/{id}/audit. Events arrive
|
||||
// newest-first; the action union matches AuditAction in
|
||||
// $components/audit-trail-helpers.ts.
|
||||
export type MemoryAuditAction =
|
||||
| 'created'
|
||||
| 'accessed'
|
||||
| 'promoted'
|
||||
| 'demoted'
|
||||
| 'edited'
|
||||
| 'suppressed'
|
||||
| 'dreamed'
|
||||
| 'reconsolidated';
|
||||
|
||||
export interface MemoryAuditEvent {
|
||||
action: MemoryAuditAction;
|
||||
timestamp: string; // RFC3339
|
||||
old_value?: number;
|
||||
new_value?: number;
|
||||
reason?: string;
|
||||
triggered_by?: string;
|
||||
}
|
||||
|
||||
export interface MemoryAuditResponse {
|
||||
memoryId: string;
|
||||
events: MemoryAuditEvent[];
|
||||
}
|
||||
|
||||
// Node type colors for visualization — bioluminescent palette
|
||||
export const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
fact: '#00A8FF', // electric blue
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ContradictionArcs, { type Contradiction } from '$components/ContradictionArcs.svelte';
|
||||
import PageHeader from '$components/PageHeader.svelte';
|
||||
import Dropdown, { type DropdownOption } from '$components/Dropdown.svelte';
|
||||
import Icon from '$components/Icon.svelte';
|
||||
import AnimatedNumber from '$components/AnimatedNumber.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { api } from '$stores/api';
|
||||
import {
|
||||
severityColor,
|
||||
severityLabel,
|
||||
|
|
@ -13,285 +15,33 @@
|
|||
avgTrustDelta as avgTrustDeltaFn,
|
||||
} from '$components/contradiction-helpers';
|
||||
|
||||
// TODO: swap for /api/contradictions when backend ships.
|
||||
// Expected shape matches the `Contradiction` interface in
|
||||
// $components/ContradictionArcs.svelte. Backend should derive pairs from the
|
||||
// contradiction-analysis step of deep_reference (only flag when BOTH memories
|
||||
// have >0.3 FSRS trust).
|
||||
const MOCK_CONTRADICTIONS: Contradiction[] = [
|
||||
{
|
||||
memory_a_id: 'a1',
|
||||
memory_b_id: 'b1',
|
||||
memory_a_preview: 'Dev server runs on port 3000 (default Vite config)',
|
||||
memory_b_preview: 'Dev server moved to port 3002 to avoid conflict',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'decision',
|
||||
memory_a_created: '2026-01-14',
|
||||
memory_b_created: '2026-03-22',
|
||||
memory_a_tags: ['dev', 'vite'],
|
||||
memory_b_tags: ['dev', 'vite', 'decision'],
|
||||
trust_a: 0.42,
|
||||
trust_b: 0.91,
|
||||
similarity: 0.88,
|
||||
date_diff_days: 67,
|
||||
topic: 'dev server port'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a2',
|
||||
memory_b_id: 'b2',
|
||||
memory_a_preview: 'Prompt variation helps at higher sampling temperatures',
|
||||
memory_b_preview: 'Prompt variation reduced accuracy in the latest benchmark run',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-03-30',
|
||||
memory_b_created: '2026-04-03',
|
||||
memory_a_tags: ['research', 'prompting'],
|
||||
memory_b_tags: ['research', 'prompting', 'evidence'],
|
||||
trust_a: 0.35,
|
||||
trust_b: 0.88,
|
||||
similarity: 0.92,
|
||||
date_diff_days: 4,
|
||||
topic: 'prompt diversity'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a3',
|
||||
memory_b_id: 'b3',
|
||||
memory_a_preview: 'Use min_p=0.05 for long-form sampling',
|
||||
memory_b_preview: 'min_p scheduling failed at high sampling temperatures',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-04-01',
|
||||
memory_b_created: '2026-04-05',
|
||||
memory_a_tags: ['sampling'],
|
||||
memory_b_tags: ['sampling'],
|
||||
trust_a: 0.58,
|
||||
trust_b: 0.74,
|
||||
similarity: 0.81,
|
||||
date_diff_days: 4,
|
||||
topic: 'min_p sampling'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a4',
|
||||
memory_b_id: 'b4',
|
||||
memory_a_preview: 'LoRA rank 16 is enough for domain adaptation',
|
||||
memory_b_preview: 'LoRA rank 32 consistently outperforms rank 16 on math',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-02-10',
|
||||
memory_b_created: '2026-04-12',
|
||||
memory_a_tags: ['lora', 'training'],
|
||||
memory_b_tags: ['lora', 'training', 'nemotron'],
|
||||
trust_a: 0.48,
|
||||
trust_b: 0.76,
|
||||
similarity: 0.74,
|
||||
date_diff_days: 61,
|
||||
topic: 'LoRA rank'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a5',
|
||||
memory_b_id: 'b5',
|
||||
memory_a_preview: 'Team prefers Rust for backend services',
|
||||
memory_b_preview: 'Project chose Axum + Rust for the dashboard backend',
|
||||
memory_a_type: 'note',
|
||||
memory_b_type: 'decision',
|
||||
memory_a_created: '2026-01-05',
|
||||
memory_b_created: '2026-02-18',
|
||||
memory_a_tags: ['preference', 'backend'],
|
||||
memory_b_tags: ['backend', 'decision'],
|
||||
trust_a: 0.81,
|
||||
trust_b: 0.88,
|
||||
similarity: 0.42,
|
||||
date_diff_days: 44,
|
||||
topic: 'backend language'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a6',
|
||||
memory_b_id: 'b6',
|
||||
memory_a_preview: 'Warm-start from checkpoint saves 8h of training',
|
||||
memory_b_preview: 'Warm-start code never loaded the PEFT adapter correctly',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-03-11',
|
||||
memory_b_created: '2026-04-16',
|
||||
memory_a_tags: ['training', 'warm-start'],
|
||||
memory_b_tags: ['training', 'warm-start', 'bug-fix'],
|
||||
trust_a: 0.55,
|
||||
trust_b: 0.93,
|
||||
similarity: 0.79,
|
||||
date_diff_days: 36,
|
||||
topic: 'warm-start correctness'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a7',
|
||||
memory_b_id: 'b7',
|
||||
memory_a_preview: 'Three.js force-directed graph runs fine at 5k nodes',
|
||||
memory_b_preview: 'WebGL graph stutters above 2k nodes on M1 MacBook Air',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2025-12-02',
|
||||
memory_b_created: '2026-03-29',
|
||||
memory_a_tags: ['vestige', 'graph', 'perf'],
|
||||
memory_b_tags: ['vestige', 'graph', 'perf'],
|
||||
trust_a: 0.39,
|
||||
trust_b: 0.72,
|
||||
similarity: 0.67,
|
||||
date_diff_days: 117,
|
||||
topic: 'graph performance'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a8',
|
||||
memory_b_id: 'b8',
|
||||
memory_a_preview: 'Submit benchmark runs with a 16384 token budget',
|
||||
memory_b_preview: 'Latest baseline improved when token budget increased to 32768',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'event',
|
||||
memory_a_created: '2026-04-04',
|
||||
memory_b_created: '2026-04-10',
|
||||
memory_a_tags: ['benchmark', 'tokens'],
|
||||
memory_b_tags: ['benchmark', 'baseline'],
|
||||
trust_a: 0.31,
|
||||
trust_b: 0.85,
|
||||
similarity: 0.73,
|
||||
date_diff_days: 6,
|
||||
topic: 'token budget'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a9',
|
||||
memory_b_id: 'b9',
|
||||
memory_a_preview: 'FSRS-6 parameters require ~1k reviews to train',
|
||||
memory_b_preview: 'FSRS-6 default parameters work fine out of the box',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'concept',
|
||||
memory_a_created: '2026-01-22',
|
||||
memory_b_created: '2026-02-28',
|
||||
memory_a_tags: ['fsrs', 'training'],
|
||||
memory_b_tags: ['fsrs'],
|
||||
trust_a: 0.62,
|
||||
trust_b: 0.54,
|
||||
similarity: 0.57,
|
||||
date_diff_days: 37,
|
||||
topic: 'FSRS parameter tuning'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a10',
|
||||
memory_b_id: 'b10',
|
||||
memory_a_preview: 'Tailwind 4 requires explicit CSS import only',
|
||||
memory_b_preview: 'Tailwind 4 config still supports tailwind.config.js',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-01-30',
|
||||
memory_b_created: '2026-02-14',
|
||||
memory_a_tags: ['tailwind', 'config'],
|
||||
memory_b_tags: ['tailwind', 'config'],
|
||||
trust_a: 0.47,
|
||||
trust_b: 0.33,
|
||||
similarity: 0.85,
|
||||
date_diff_days: 15,
|
||||
topic: 'Tailwind 4 config'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a11',
|
||||
memory_b_id: 'b11',
|
||||
memory_a_preview: 'Dataset API silently ignores invalid source slugs',
|
||||
memory_b_preview: 'Dataset API throws an error when source slug is invalid',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'concept',
|
||||
memory_a_created: '2026-04-07',
|
||||
memory_b_created: '2026-02-20',
|
||||
memory_a_tags: ['api', 'bug-fix'],
|
||||
memory_b_tags: ['api'],
|
||||
trust_a: 0.89,
|
||||
trust_b: 0.28,
|
||||
similarity: 0.91,
|
||||
date_diff_days: 46,
|
||||
topic: 'API validation'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a12',
|
||||
memory_b_id: 'b12',
|
||||
memory_a_preview: 'USearch HNSW is 20x faster than FAISS for embeddings',
|
||||
memory_b_preview: 'FAISS IVF is the fastest vector index at scale',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'concept',
|
||||
memory_a_created: '2026-02-01',
|
||||
memory_b_created: '2025-11-15',
|
||||
memory_a_tags: ['vectors', 'perf'],
|
||||
memory_b_tags: ['vectors', 'perf'],
|
||||
trust_a: 0.78,
|
||||
trust_b: 0.36,
|
||||
similarity: 0.69,
|
||||
date_diff_days: 78,
|
||||
topic: 'vector index perf'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a13',
|
||||
memory_b_id: 'b13',
|
||||
memory_a_preview: 'Leaderboard scores weight by top-10 consistency',
|
||||
memory_b_preview: 'Leaderboard uses single-best-episode scoring',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-04-18',
|
||||
memory_b_created: '2026-04-10',
|
||||
memory_a_tags: ['leaderboard', 'scoring'],
|
||||
memory_b_tags: ['leaderboard', 'scoring'],
|
||||
trust_a: 0.64,
|
||||
trust_b: 0.52,
|
||||
similarity: 0.82,
|
||||
date_diff_days: 8,
|
||||
topic: 'leaderboard scoring'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a14',
|
||||
memory_b_id: 'b14',
|
||||
memory_a_preview: 'Release notes were planned for 8am ET',
|
||||
memory_b_preview: 'Release notes moved to 9am ET after schedule review',
|
||||
memory_a_type: 'decision',
|
||||
memory_b_type: 'decision',
|
||||
memory_a_created: '2026-03-01',
|
||||
memory_b_created: '2026-04-15',
|
||||
memory_a_tags: ['cadence', 'content'],
|
||||
memory_b_tags: ['cadence', 'content'],
|
||||
trust_a: 0.50,
|
||||
trust_b: 0.81,
|
||||
similarity: 0.58,
|
||||
date_diff_days: 45,
|
||||
topic: 'posting cadence'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a15',
|
||||
memory_b_id: 'b15',
|
||||
memory_a_preview: 'Dream cycle consolidates ~50 memories per run',
|
||||
memory_b_preview: 'Dream cycle replays closer to 120 memories in practice',
|
||||
memory_a_type: 'fact',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-02-15',
|
||||
memory_b_created: '2026-04-08',
|
||||
memory_a_tags: ['vestige', 'dream'],
|
||||
memory_b_tags: ['vestige', 'dream'],
|
||||
trust_a: 0.44,
|
||||
trust_b: 0.79,
|
||||
similarity: 0.76,
|
||||
date_diff_days: 52,
|
||||
topic: 'dream cycle count'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a16',
|
||||
memory_b_id: 'b16',
|
||||
memory_a_preview: 'Never commit API keys to git; use .env files',
|
||||
memory_b_preview: 'Environment secrets should live in a 1Password vault',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'pattern',
|
||||
memory_a_created: '2025-10-11',
|
||||
memory_b_created: '2026-03-20',
|
||||
memory_a_tags: ['security', 'secrets'],
|
||||
memory_b_tags: ['security', 'secrets'],
|
||||
trust_a: 0.72,
|
||||
trust_b: 0.64,
|
||||
similarity: 0.48,
|
||||
date_diff_days: 160,
|
||||
topic: 'secret storage'
|
||||
// Live pairs from /api/contradictions — the contradiction-analysis
|
||||
// primitives behind deep_reference (only flagged when BOTH memories clear
|
||||
// the trust floor). Sorted by similarity desc by the backend.
|
||||
let contradictions = $state<Contradiction[]>([]);
|
||||
// System-wide count from the backend, vs. the derived stats below which
|
||||
// reflect only the pairs the page holds.
|
||||
let totalDetected = $state(0);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await api.contradictions();
|
||||
contradictions = res.contradictions;
|
||||
totalDetected = res.total;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contradictions';
|
||||
contradictions = [];
|
||||
totalDetected = 0;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
onMount(() => load());
|
||||
|
||||
// --- Filters ---
|
||||
type Filter = 'all' | 'recent' | 'high-trust' | 'topic';
|
||||
|
|
@ -299,7 +49,7 @@
|
|||
let topicFilter = $state<string>('');
|
||||
|
||||
const uniqueTopics = $derived(
|
||||
Array.from(new Set(MOCK_CONTRADICTIONS.map((c) => c.topic))).sort()
|
||||
Array.from(new Set(contradictions.map((c) => c.topic))).sort()
|
||||
);
|
||||
|
||||
// --- Clear, labelled dropdown options replace the bare filter buttons +
|
||||
|
|
@ -317,7 +67,7 @@
|
|||
...uniqueTopics.map((t) => ({
|
||||
value: t,
|
||||
label: t,
|
||||
badge: MOCK_CONTRADICTIONS.filter((c) => c.topic === t).length,
|
||||
badge: contradictions.filter((c) => c.topic === t).length,
|
||||
})),
|
||||
]);
|
||||
|
||||
|
|
@ -332,30 +82,28 @@
|
|||
const filtered = $derived.by<Contradiction[]>(() => {
|
||||
switch (filter) {
|
||||
case 'recent':
|
||||
// Within 7 days of "now" — use date_diff as a proxy by keeping pairs
|
||||
// where either memory was created within the last 7 days of our fixed
|
||||
// mock "today" (2026-04-20). Simple approach: keep pairs whose newest
|
||||
// created date is within 7 days of 2026-04-20.
|
||||
// Within 7 days of now — keep pairs whose newest created date is
|
||||
// within the last week.
|
||||
{
|
||||
const now = new Date('2026-04-20').getTime();
|
||||
const now = Date.now();
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
return MOCK_CONTRADICTIONS.filter((c) => {
|
||||
return contradictions.filter((c) => {
|
||||
const aT = c.memory_a_created ? new Date(c.memory_a_created).getTime() : 0;
|
||||
const bT = c.memory_b_created ? new Date(c.memory_b_created).getTime() : 0;
|
||||
return now - Math.max(aT, bT) <= week;
|
||||
});
|
||||
}
|
||||
case 'high-trust':
|
||||
return MOCK_CONTRADICTIONS.filter(
|
||||
return contradictions.filter(
|
||||
(c) => Math.min(c.trust_a, c.trust_b) > 0.6
|
||||
);
|
||||
case 'topic':
|
||||
return topicFilter
|
||||
? MOCK_CONTRADICTIONS.filter((c) => c.topic === topicFilter)
|
||||
: MOCK_CONTRADICTIONS;
|
||||
? contradictions.filter((c) => c.topic === topicFilter)
|
||||
: contradictions;
|
||||
case 'all':
|
||||
default:
|
||||
return MOCK_CONTRADICTIONS;
|
||||
return contradictions;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -366,18 +114,16 @@
|
|||
focusedPairIndex = i;
|
||||
}
|
||||
|
||||
// --- Stats. `TOTAL_CONTRADICTIONS_DETECTED` stays illustrative so the tile
|
||||
// reads like a system-wide count once the backend ships; everything else
|
||||
// is derived from the pairs the page actually holds so the numbers are
|
||||
// --- Stats. `totalDetected` is the backend's system-wide count; everything
|
||||
// else is derived from the pairs the page actually holds so the numbers are
|
||||
// self-consistent with what the user sees. ---
|
||||
const TOTAL_CONTRADICTIONS_DETECTED = 47;
|
||||
const totalMemoriesInvolved = $derived(uniqueMemoryCount(MOCK_CONTRADICTIONS));
|
||||
const avgTrustDelta = $derived(avgTrustDeltaFn(MOCK_CONTRADICTIONS));
|
||||
const totalMemoriesInvolved = $derived(uniqueMemoryCount(contradictions));
|
||||
const avgTrustDelta = $derived(avgTrustDeltaFn(contradictions));
|
||||
|
||||
// Map filtered index -> original index in MOCK_CONTRADICTIONS so the
|
||||
// Map filtered index -> original index in `contradictions` so the
|
||||
// constellation and sidebar stay in sync regardless of which filter is on.
|
||||
const visibleList = $derived.by<{ orig: number; c: Contradiction }[]>(() => {
|
||||
const byId = new Map(MOCK_CONTRADICTIONS.map((c, i) => [c.memory_a_id + '|' + c.memory_b_id, i]));
|
||||
const byId = new Map(contradictions.map((c, i) => [c.memory_a_id + '|' + c.memory_b_id, i]));
|
||||
return filtered.map((c) => ({
|
||||
orig: byId.get(c.memory_a_id + '|' + c.memory_b_id) ?? 0,
|
||||
c
|
||||
|
|
@ -404,11 +150,48 @@
|
|||
</span>
|
||||
</PageHeader>
|
||||
|
||||
{#if error}
|
||||
<div class="glass-panel flex flex-col items-center gap-3 rounded-2xl p-10 text-center">
|
||||
<div class="text-sm text-decay">Couldn't load contradictions</div>
|
||||
<div class="max-w-md text-xs text-muted">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={load}
|
||||
class="mt-2 rounded-lg bg-synapse/20 px-4 py-2 text-xs font-medium text-synapse-glow transition hover:bg-synapse/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{#each Array(4) as _}
|
||||
<div class="glass-subtle shimmer h-20 rounded-xl"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-4">
|
||||
<div class="glass-subtle shimmer min-h-[520px] rounded-2xl"></div>
|
||||
<div class="glass-subtle shimmer h-[520px] rounded-2xl"></div>
|
||||
</div>
|
||||
{:else if contradictions.length === 0}
|
||||
<div class="glass-panel enter flex flex-col items-center gap-3 rounded-2xl p-12 text-center">
|
||||
<div
|
||||
class="flex h-14 w-14 items-center justify-center rounded-2xl border border-recall/25 bg-recall/10 text-recall"
|
||||
>
|
||||
<Icon name="sparkle" size={26} draw />
|
||||
</div>
|
||||
<div class="text-sm font-medium text-bright">
|
||||
No contradictions found — your memory agrees with itself.
|
||||
</div>
|
||||
<div class="max-w-sm text-xs text-muted">
|
||||
Pairs appear here when two trusted memories about the same topic make opposing claims.
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div use:reveal={{ delay: 0, y: 12 }} class="p-4 glass rounded-xl lift">
|
||||
<div class="text-2xl text-bright font-bold tabular-nums">
|
||||
<AnimatedNumber value={TOTAL_CONTRADICTIONS_DETECTED} />
|
||||
<AnimatedNumber value={totalDetected} />
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">
|
||||
contradictions across {totalMemoriesInvolved.toLocaleString()} memories
|
||||
|
|
@ -572,4 +355,5 @@
|
|||
{/each}
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,34 +8,17 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import DuplicateCluster from '$components/DuplicateCluster.svelte';
|
||||
import { clusterKey, filterByThreshold } from '$components/duplicates-helpers';
|
||||
import { clusterKey } from '$components/duplicates-helpers';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import { spotlight } from '$lib/actions/interactions';
|
||||
|
||||
interface ClusterMemory {
|
||||
id: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
tags: string[];
|
||||
retention: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Cluster {
|
||||
similarity: number;
|
||||
memories: ClusterMemory[];
|
||||
suggestedAction: 'merge' | 'review';
|
||||
}
|
||||
|
||||
interface DuplicatesResponse {
|
||||
clusters: Cluster[];
|
||||
}
|
||||
import { api } from '$stores/api';
|
||||
import type { DuplicateClusterGroup } from '$types';
|
||||
|
||||
let threshold = $state(0.8);
|
||||
let clusters: Cluster[] = $state([]);
|
||||
let clusters: DuplicateClusterGroup[] = $state([]);
|
||||
// Dismissed clusters are tracked by stable identity (sorted member ids) so
|
||||
// dismissals survive a re-fetch. If the cluster membership changes, the key
|
||||
// changes and the cluster is treated as fresh.
|
||||
|
|
@ -44,165 +27,11 @@
|
|||
let error: string | null = $state(null);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Mock realistic response. Swap for real fetch when backend ships.
|
||||
// TODO(backend-swap): replace `mockFetchDuplicates` with:
|
||||
// const res = await fetch(`/api/duplicates?threshold=${t}`);
|
||||
// return (await res.json()) as DuplicatesResponse;
|
||||
// The pure `filterByThreshold` helper in duplicates-helpers.ts mirrors the
|
||||
// server-side >= semantics so the UI behaves identically before and after.
|
||||
async function mockFetchDuplicates(t: number): Promise<DuplicatesResponse> {
|
||||
// Simulate latency so the skeleton is visible.
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
|
||||
const all: Cluster[] = [
|
||||
{
|
||||
similarity: 0.96,
|
||||
suggestedAction: 'merge',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-001',
|
||||
content:
|
||||
'BUG FIX: Harmony parser dropped `final` channel tokens when tool call followed. Root cause: 5-layer fallback missed the final channel marker when channel switched mid-stream. Solution: added final-channel detector before tool-call pop. Files: src/parser/harmony.rs',
|
||||
nodeType: 'fact',
|
||||
tags: ['bug-fix', 'benchmark-suite', 'parser'],
|
||||
retention: 0.91,
|
||||
createdAt: '2026-04-12T14:22:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-002',
|
||||
content:
|
||||
'Fixed Harmony parser final-channel bug — 5-layer fallback was missing the final channel marker when a tool call followed. Added detector before tool pop.',
|
||||
nodeType: 'fact',
|
||||
tags: ['bug-fix', 'benchmark-suite'],
|
||||
retention: 0.64,
|
||||
createdAt: '2026-04-13T09:15:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-003',
|
||||
content:
|
||||
'Harmony parser: final channel dropped on tool-call. Patched the fallback stack.',
|
||||
nodeType: 'note',
|
||||
tags: ['parser'],
|
||||
retention: 0.38,
|
||||
createdAt: '2026-04-14T11:02:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.88,
|
||||
suggestedAction: 'merge',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-004',
|
||||
content:
|
||||
'DECISION: Use vLLM prefix caching at 0.35 gpu_memory_utilization for benchmark suite submissions. Alternatives considered: sglang (slower cold start), TensorRT-LLM (deployment friction).',
|
||||
nodeType: 'decision',
|
||||
tags: ['vllm', 'benchmark-suite', 'inference'],
|
||||
retention: 0.84,
|
||||
createdAt: '2026-04-05T18:44:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-005',
|
||||
content:
|
||||
'Chose vLLM with prefix caching (0.35 mem util) over sglang and TensorRT-LLM for benchmark suite inference.',
|
||||
nodeType: 'decision',
|
||||
tags: ['vllm', 'benchmark-suite'],
|
||||
retention: 0.72,
|
||||
createdAt: '2026-04-06T10:30:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.83,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-006',
|
||||
content:
|
||||
'Release process prefers one change per benchmark submission — stacking changes destroyed signal in a prior run.',
|
||||
nodeType: 'pattern',
|
||||
tags: ['methodology', 'benchmark-suite'],
|
||||
retention: 0.88,
|
||||
createdAt: '2026-04-04T22:10:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-007',
|
||||
content:
|
||||
'One-variable-at-a-time rule: never stack multiple changes per submission. Paper 2603.27844 proves +/-2 points is noise.',
|
||||
nodeType: 'pattern',
|
||||
tags: ['kaggle', 'methodology'],
|
||||
retention: 0.67,
|
||||
createdAt: '2026-04-08T16:20:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-008',
|
||||
content: 'Lesson: stacking many changes in one benchmark run hid the causal signal. Always isolate variables.',
|
||||
nodeType: 'note',
|
||||
tags: ['methodology'],
|
||||
retention: 0.42,
|
||||
createdAt: '2026-04-15T08:55:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.78,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-009',
|
||||
content:
|
||||
'Dimensional Illusion performance: 7-minute flow poi set, LED config Parthenos overcook preset, tempo 128 BPM.',
|
||||
nodeType: 'event',
|
||||
tags: ['dimensional-illusion', 'poi', 'performance'],
|
||||
retention: 0.76,
|
||||
createdAt: '2026-03-28T19:45:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-010',
|
||||
content: 'Dimensional Illusion set: 7 min, Parthenos LED overcook, 128 BPM.',
|
||||
nodeType: 'event',
|
||||
tags: ['dimensional-illusion', 'poi'],
|
||||
retention: 0.51,
|
||||
createdAt: '2026-04-02T12:12:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.76,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-011',
|
||||
content:
|
||||
'Vestige v2.0.7 shipped active forgetting via Anderson 2025 top-down inhibition + Davis Rac1 cascade. Suppress compounds, reversible 24h.',
|
||||
nodeType: 'fact',
|
||||
tags: ['vestige', 'release', 'active-forgetting'],
|
||||
retention: 0.93,
|
||||
createdAt: '2026-04-17T03:22:00Z',
|
||||
},
|
||||
{
|
||||
id: 'm-012',
|
||||
content:
|
||||
'Active Forgetting feature: compounds on each suppress, 24h reversible labile window, violet implosion animation in graph view.',
|
||||
nodeType: 'concept',
|
||||
tags: ['vestige', 'active-forgetting'],
|
||||
retention: 0.81,
|
||||
createdAt: '2026-04-18T09:07:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return { clusters: filterByThreshold(all, t) };
|
||||
}
|
||||
|
||||
async function detect() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// TODO: swap for real endpoint /api/duplicates when backend ships.
|
||||
// See comment on mockFetchDuplicates for the exact replacement.
|
||||
const res = await mockFetchDuplicates(threshold);
|
||||
const res = await api.duplicates(threshold);
|
||||
clusters = res.clusters;
|
||||
// Prune dismissals whose clusters no longer exist — prevents
|
||||
// unbounded growth across sessions and keeps the set honest.
|
||||
|
|
|
|||
|
|
@ -15,29 +15,11 @@
|
|||
import Icon from '$components/Icon.svelte';
|
||||
import AnimatedNumber from '$components/AnimatedNumber.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
|
||||
type Category =
|
||||
| 'ErrorHandling'
|
||||
| 'AsyncConcurrency'
|
||||
| 'Testing'
|
||||
| 'Architecture'
|
||||
| 'Performance'
|
||||
| 'Security';
|
||||
|
||||
interface Pattern {
|
||||
name: string;
|
||||
category: Category;
|
||||
origin_project: string;
|
||||
transferred_to: string[];
|
||||
transfer_count: number;
|
||||
last_used: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface CrossProjectResponse {
|
||||
projects: string[];
|
||||
patterns: Pattern[];
|
||||
}
|
||||
import { api } from '$stores/api';
|
||||
import type {
|
||||
CrossProjectCategory as Category,
|
||||
CrossProjectPatternsResponse
|
||||
} from '$types';
|
||||
|
||||
const CATEGORIES: readonly Category[] = [
|
||||
'ErrorHandling',
|
||||
|
|
@ -58,277 +40,16 @@
|
|||
};
|
||||
|
||||
let activeCategory = $state<'All' | Category>('All');
|
||||
let data = $state<CrossProjectResponse>({ projects: [], patterns: [] });
|
||||
let data = $state<CrossProjectPatternsResponse>({ projects: [], patterns: [] });
|
||||
let loading = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
let selectedCell = $state<{ from: string; to: string } | null>(null);
|
||||
|
||||
// TODO: swap for real fetch to /api/patterns/cross-project when backend ships.
|
||||
// The CrossProjectLearner already tracks these categories in Rust — exposing
|
||||
// it over HTTP is a straightforward map-to-DTO. Matching shape below so the
|
||||
// swap is a one-liner.
|
||||
async function mockFetchCrossProject(): Promise<CrossProjectResponse> {
|
||||
await new Promise((r) => setTimeout(r, 420));
|
||||
|
||||
const projects = [
|
||||
'vestige',
|
||||
'api-gateway',
|
||||
'desktop-app',
|
||||
'model-runner',
|
||||
'game-sim',
|
||||
'security-dashboard',
|
||||
'benchmark-suite'
|
||||
];
|
||||
|
||||
const patterns: Pattern[] = [
|
||||
// ErrorHandling — widely transferred
|
||||
{
|
||||
name: 'Result<T, E> with thiserror context',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'desktop-app', 'model-runner', 'security-dashboard'],
|
||||
transfer_count: 4,
|
||||
last_used: '2026-04-18T14:22:00Z',
|
||||
confidence: 0.94
|
||||
},
|
||||
{
|
||||
name: 'Axum error middleware with tower-http',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'api-gateway',
|
||||
transferred_to: ['vestige', 'security-dashboard'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-17T09:10:00Z',
|
||||
confidence: 0.88
|
||||
},
|
||||
{
|
||||
name: 'Graceful shutdown on SIGINT/SIGTERM',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['vestige', 'desktop-app', 'security-dashboard'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-15T22:01:00Z',
|
||||
confidence: 0.82
|
||||
},
|
||||
{
|
||||
name: 'Python try/except with contextual re-raise',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'benchmark-suite',
|
||||
transferred_to: ['model-runner'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-10T11:30:00Z',
|
||||
confidence: 0.7
|
||||
},
|
||||
|
||||
// AsyncConcurrency
|
||||
{
|
||||
name: 'Arc<Mutex<Connection>> reader/writer split',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'desktop-app'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-14T16:42:00Z',
|
||||
confidence: 0.91
|
||||
},
|
||||
{
|
||||
name: 'tokio::select! for cancellation propagation',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'desktop-app',
|
||||
transferred_to: ['vestige', 'security-dashboard'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T08:05:00Z',
|
||||
confidence: 0.86
|
||||
},
|
||||
{
|
||||
name: 'Bounded mpsc channel with backpressure',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'desktop-app',
|
||||
transferred_to: ['vestige', 'api-gateway'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-12T13:18:00Z',
|
||||
confidence: 0.83
|
||||
},
|
||||
{
|
||||
name: 'asyncio.gather with return_exceptions',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'model-runner',
|
||||
transferred_to: ['benchmark-suite'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-08T20:45:00Z',
|
||||
confidence: 0.72
|
||||
},
|
||||
|
||||
// Testing
|
||||
{
|
||||
name: 'Property-based tests with proptest',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'desktop-app'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-11T10:22:00Z',
|
||||
confidence: 0.89
|
||||
},
|
||||
{
|
||||
name: 'Snapshot testing with insta',
|
||||
category: 'Testing',
|
||||
origin_project: 'api-gateway',
|
||||
transferred_to: ['vestige'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-16T14:00:00Z',
|
||||
confidence: 0.81
|
||||
},
|
||||
{
|
||||
name: 'Vitest + Playwright dashboard harness',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'desktop-app'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T18:30:00Z',
|
||||
confidence: 0.87
|
||||
},
|
||||
{
|
||||
name: 'One-variable-at-a-time Kaggle submission',
|
||||
category: 'Testing',
|
||||
origin_project: 'benchmark-suite',
|
||||
transferred_to: ['model-runner', 'game-sim'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-20T07:15:00Z',
|
||||
confidence: 0.95
|
||||
},
|
||||
{
|
||||
name: 'Kaggle pre-flight Input-panel screenshot',
|
||||
category: 'Testing',
|
||||
origin_project: 'benchmark-suite',
|
||||
transferred_to: ['model-runner', 'game-sim'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-20T06:50:00Z',
|
||||
confidence: 0.98
|
||||
},
|
||||
|
||||
// Architecture
|
||||
{
|
||||
name: 'SvelteKit 2 + Svelte 5 runes dashboard',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'security-dashboard'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T12:10:00Z',
|
||||
confidence: 0.92
|
||||
},
|
||||
{
|
||||
name: 'glass-panel + cosmic-dark design system',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'security-dashboard', 'desktop-app'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-20T09:00:00Z',
|
||||
confidence: 0.9
|
||||
},
|
||||
{
|
||||
name: 'Tauri 2 + Rust/Axum sidecar',
|
||||
category: 'Architecture',
|
||||
origin_project: 'desktop-app',
|
||||
transferred_to: ['security-dashboard'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-13T19:44:00Z',
|
||||
confidence: 0.78
|
||||
},
|
||||
{
|
||||
name: 'MCP server with 23 stateful tools',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['desktop-app'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-17T11:05:00Z',
|
||||
confidence: 0.85
|
||||
},
|
||||
|
||||
// Performance
|
||||
{
|
||||
name: 'USearch HNSW index for vector search',
|
||||
category: 'Performance',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-09T15:20:00Z',
|
||||
confidence: 0.88
|
||||
},
|
||||
{
|
||||
name: 'SQLite WAL mode for concurrent reads',
|
||||
category: 'Performance',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'desktop-app', 'security-dashboard'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-18T21:33:00Z',
|
||||
confidence: 0.93
|
||||
},
|
||||
{
|
||||
name: 'vLLM prefix caching at 0.35 mem util',
|
||||
category: 'Performance',
|
||||
origin_project: 'benchmark-suite',
|
||||
transferred_to: ['model-runner'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-11T08:00:00Z',
|
||||
confidence: 0.84
|
||||
},
|
||||
{
|
||||
name: 'Cross-encoder rerank at k=30',
|
||||
category: 'Performance',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-14T17:55:00Z',
|
||||
confidence: 0.79
|
||||
},
|
||||
|
||||
// Security
|
||||
{
|
||||
name: 'Rotated auth token in env var',
|
||||
category: 'Security',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway', 'desktop-app', 'security-dashboard'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-16T20:12:00Z',
|
||||
confidence: 0.96
|
||||
},
|
||||
{
|
||||
name: 'Parameterized SQL via rusqlite params!',
|
||||
category: 'Security',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['api-gateway'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-10T13:40:00Z',
|
||||
confidence: 0.89
|
||||
},
|
||||
{
|
||||
name: '664-pattern secret scanner',
|
||||
category: 'Security',
|
||||
origin_project: 'api-gateway',
|
||||
transferred_to: ['vestige', 'security-dashboard', 'desktop-app'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-20T05:30:00Z',
|
||||
confidence: 0.97
|
||||
},
|
||||
{
|
||||
name: 'CSP header with nonce-based script allow',
|
||||
category: 'Security',
|
||||
origin_project: 'api-gateway',
|
||||
transferred_to: ['security-dashboard'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-05T16:08:00Z',
|
||||
confidence: 0.8
|
||||
}
|
||||
];
|
||||
|
||||
return { projects, patterns };
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// TODO: const res = await fetch('/api/patterns/cross-project');
|
||||
// data = await res.json();
|
||||
data = await mockFetchCrossProject();
|
||||
data = await api.crossProjectPatterns();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load pattern transfers';
|
||||
data = { projects: [], patterns: [] };
|
||||
|
|
|
|||
|
|
@ -2335,6 +2335,517 @@ pub fn read_review_mode(state: &AppState) -> vestige_core::ReviewMode {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEMORY HYGIENE + INTELLIGENCE SURFACES (live-wire endpoints)
|
||||
//
|
||||
// GET /api/duplicates — cosine duplicate clusters (dedup tool reshaped)
|
||||
// GET /api/contradictions — trust-weighted contradiction pairs
|
||||
// GET /api/patterns/cross-project — CrossProjectLearner hydrated on demand
|
||||
// GET /api/memories/{id}/audit — per-memory audit trail (state transitions)
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DuplicatesParams {
|
||||
pub threshold: Option<f64>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Duplicate clusters for the dashboard. Reuses the `dedup` tool's
|
||||
/// union-find clustering, then re-fetches each member node so the response
|
||||
/// carries full content and nodeType (the tool only keeps a 120-char preview).
|
||||
pub async fn list_duplicates(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DuplicatesParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let threshold = params.threshold.unwrap_or(0.80).clamp(0.5, 0.99);
|
||||
let limit = params.limit.unwrap_or(20).clamp(1, 50);
|
||||
|
||||
let args = serde_json::json!({
|
||||
"similarity_threshold": threshold,
|
||||
"limit": limit,
|
||||
});
|
||||
let raw = crate::tools::dedup::execute(&state.storage, Some(args))
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut clusters: Vec<Value> = Vec::new();
|
||||
if let Some(raw_clusters) = raw.get("clusters").and_then(|v| v.as_array()) {
|
||||
for cluster in raw_clusters {
|
||||
let members = cluster
|
||||
.get("members")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Representative pair similarity: max member similarity-to-anchor,
|
||||
// skipping the anchor itself (members[0], always exactly 1.0).
|
||||
let similarity = members
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter_map(|m| {
|
||||
m.get("similarityToAnchor")
|
||||
.and_then(|s| s.as_str())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
})
|
||||
.fold(0.0_f64, f64::max);
|
||||
|
||||
let memories: Vec<Value> = members
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let id = m.get("id").and_then(|v| v.as_str())?;
|
||||
let node = state.storage.get_node(id).ok().flatten()?;
|
||||
Some(serde_json::json!({
|
||||
"id": node.id,
|
||||
"content": node.content,
|
||||
"nodeType": node.node_type,
|
||||
"tags": node.tags,
|
||||
"retention": node.retention_strength,
|
||||
"createdAt": node.created_at.to_rfc3339(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if memories.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
clusters.push(serde_json::json!({
|
||||
"similarity": similarity,
|
||||
"suggestedAction": if similarity >= 0.9 { "merge" } else { "review" },
|
||||
"memories": memories,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"threshold": threshold,
|
||||
"total": clusters.len(),
|
||||
"clusters": clusters,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ContradictionsParams {
|
||||
pub topic: Option<String>,
|
||||
pub min_trust: Option<f64>,
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Contradiction pairs for the dashboard. Same pairing primitives as the
|
||||
/// `contradictions` MCP tool (topic overlap >= 0.4, negation/correction
|
||||
/// heuristics, trust gate), but built directly against the nodes so the
|
||||
/// response carries nodeType/createdAt and chronological a→b ordering
|
||||
/// (a = older, b = newer — b is the memory that supersedes a).
|
||||
pub async fn list_contradictions(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ContradictionsParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
use crate::tools::cross_reference::{appears_contradictory, compute_trust, topic_overlap};
|
||||
|
||||
let limit = params.limit.unwrap_or(50).clamp(2, 200);
|
||||
let min_trust = params.min_trust.unwrap_or(0.3).clamp(0.0, 1.0);
|
||||
let topic_param = params
|
||||
.topic
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from);
|
||||
|
||||
let memories = if let Some(topic) = topic_param.as_deref() {
|
||||
state
|
||||
.storage
|
||||
.hybrid_search(topic, limit, 0.3, 0.7)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.into_iter()
|
||||
.map(|result| result.node)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
state
|
||||
.storage
|
||||
.get_all_nodes(limit, 0)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
};
|
||||
|
||||
let mut pairs: Vec<(f32, Value)> = Vec::new();
|
||||
for i in 0..memories.len() {
|
||||
for j in (i + 1)..memories.len() {
|
||||
let x = &memories[i];
|
||||
let y = &memories[j];
|
||||
let overlap = topic_overlap(&x.content, &y.content);
|
||||
if overlap < 0.4 || !appears_contradictory(&x.content, &y.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x_trust = compute_trust(x.retention_strength, x.stability, x.reps, x.lapses);
|
||||
let y_trust = compute_trust(y.retention_strength, y.stability, y.reps, y.lapses);
|
||||
if x_trust.min(y_trust) < min_trust {
|
||||
continue;
|
||||
}
|
||||
|
||||
// a = older, b = newer (b supersedes a).
|
||||
let ((a, trust_a), (b, trust_b)) = if x.created_at <= y.created_at {
|
||||
((x, x_trust), (y, y_trust))
|
||||
} else {
|
||||
((y, y_trust), (x, x_trust))
|
||||
};
|
||||
let date_diff_days = (b.created_at - a.created_at).num_days().abs();
|
||||
let topic_label = topic_param
|
||||
.clone()
|
||||
.unwrap_or_else(|| shared_keywords(&a.content, &b.content));
|
||||
|
||||
pairs.push((
|
||||
overlap,
|
||||
serde_json::json!({
|
||||
"memory_a_id": a.id,
|
||||
"memory_b_id": b.id,
|
||||
"memory_a_preview": a.content.chars().take(200).collect::<String>(),
|
||||
"memory_b_preview": b.content.chars().take(200).collect::<String>(),
|
||||
"memory_a_type": a.node_type,
|
||||
"memory_b_type": b.node_type,
|
||||
"memory_a_created": a.created_at.to_rfc3339(),
|
||||
"memory_b_created": b.created_at.to_rfc3339(),
|
||||
"memory_a_tags": a.tags,
|
||||
"memory_b_tags": b.tags,
|
||||
"trust_a": (trust_a * 100.0).round() / 100.0,
|
||||
"trust_b": (trust_b * 100.0).round() / 100.0,
|
||||
"similarity": overlap,
|
||||
"date_diff_days": date_diff_days,
|
||||
"topic": topic_label,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let contradictions: Vec<Value> = pairs.into_iter().map(|(_, v)| v).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"memoriesAnalyzed": memories.len(),
|
||||
"total": contradictions.len(),
|
||||
"contradictions": contradictions,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Top shared substantive keywords between two contents — same tokenization
|
||||
/// as `topic_overlap` (lowercase, whitespace split, length > 3), trimmed of
|
||||
/// punctuation for display. Deterministic: longest-first, then alphabetical.
|
||||
fn shared_keywords(a: &str, b: &str) -> String {
|
||||
let a_lower = a.to_lowercase();
|
||||
let b_lower = b.to_lowercase();
|
||||
let tokenize = |s: &'_ str| -> HashSet<String> {
|
||||
s.split_whitespace()
|
||||
.map(|w| {
|
||||
w.trim_matches(|c: char| !c.is_alphanumeric())
|
||||
.to_string()
|
||||
})
|
||||
.filter(|w| w.len() > 3)
|
||||
.collect()
|
||||
};
|
||||
let a_words = tokenize(&a_lower);
|
||||
let b_words = tokenize(&b_lower);
|
||||
let mut shared: Vec<&String> = a_words.intersection(&b_words).collect();
|
||||
shared.sort_by(|x, y| y.len().cmp(&x.len()).then_with(|| x.cmp(y)));
|
||||
shared
|
||||
.into_iter()
|
||||
.take(3)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Cross-project pattern transfer. The `CrossProjectLearner` state is not
|
||||
/// persisted, so hydrate on demand: fetch the 500 most recent memories, infer
|
||||
/// project + category for each, run `learn_from_memories`, then map the
|
||||
/// discovered patterns to the dashboard DTO. Only the six frontend categories
|
||||
/// are emitted; sparse or empty results are expected on small stores.
|
||||
pub async fn get_cross_project_patterns(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
use std::collections::HashMap;
|
||||
use vestige_core::advanced::cross_project::{
|
||||
CrossProjectLearner, MemoryForLearning, PatternCategory,
|
||||
};
|
||||
|
||||
let nodes = state
|
||||
.storage
|
||||
.get_all_nodes(500, 0)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut projects: Vec<String> = Vec::new();
|
||||
// Earliest memory creation per project — used to order projects_seen_in
|
||||
// (the learner stores them in HashSet order, which is nondeterministic).
|
||||
let mut project_first_seen: HashMap<String, DateTime<Utc>> = HashMap::new();
|
||||
let mut memories: Vec<MemoryForLearning> = Vec::new();
|
||||
|
||||
for node in &nodes {
|
||||
// Memories with no real project attribution are skipped — patterns
|
||||
// need genuine cross-project evidence, not an "unknown" bucket.
|
||||
let Some(project) = infer_project(node) else {
|
||||
continue;
|
||||
};
|
||||
if !projects.contains(&project) {
|
||||
projects.push(project.clone());
|
||||
}
|
||||
project_first_seen
|
||||
.entry(project.clone())
|
||||
.and_modify(|t| {
|
||||
if node.created_at < *t {
|
||||
*t = node.created_at;
|
||||
}
|
||||
})
|
||||
.or_insert(node.created_at);
|
||||
memories.push(MemoryForLearning {
|
||||
id: node.id.clone(),
|
||||
content: node.content.clone(),
|
||||
project_name: project,
|
||||
category: infer_category(node),
|
||||
});
|
||||
}
|
||||
|
||||
let learner = CrossProjectLearner::new();
|
||||
learner.learn_from_memories(&memories);
|
||||
|
||||
let mut patterns: Vec<Value> = learner
|
||||
.get_all_patterns()
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let category = match p.pattern.category {
|
||||
PatternCategory::ErrorHandling => "ErrorHandling",
|
||||
PatternCategory::AsyncConcurrency => "AsyncConcurrency",
|
||||
PatternCategory::Testing => "Testing",
|
||||
PatternCategory::Architecture => "Architecture",
|
||||
PatternCategory::Performance => "Performance",
|
||||
PatternCategory::Security => "Security",
|
||||
// Frontend tracks exactly six categories; drop the rest.
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut seen_in = p.projects_seen_in.clone();
|
||||
seen_in.sort_by_key(|proj| {
|
||||
project_first_seen
|
||||
.get(proj)
|
||||
.copied()
|
||||
.unwrap_or_else(Utc::now)
|
||||
});
|
||||
let origin_project = seen_in.first().cloned()?;
|
||||
let transferred_to: Vec<String> = seen_in.into_iter().skip(1).collect();
|
||||
|
||||
Some(serde_json::json!({
|
||||
"name": p.pattern.name,
|
||||
"category": category,
|
||||
"origin_project": origin_project,
|
||||
"transferred_to": transferred_to,
|
||||
"transfer_count": transferred_to.len(),
|
||||
"last_used": p.last_seen.to_rfc3339(),
|
||||
"confidence": p.confidence,
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Deterministic order: confidence desc, then name.
|
||||
patterns.sort_by(|a, b| {
|
||||
let ca = a["confidence"].as_f64().unwrap_or(0.0);
|
||||
let cb = b["confidence"].as_f64().unwrap_or(0.0);
|
||||
cb.partial_cmp(&ca)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a["name"].as_str().cmp(&b["name"].as_str()))
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"projects": projects,
|
||||
"patterns": patterns,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Infer which project a memory belongs to. Priority order:
|
||||
/// 1. connector envelope's `source_project` (authoritative)
|
||||
/// 2. a `codebase:{name}` / `project:{name}` tag (codebase tool convention)
|
||||
/// 3. the `source` field when it is a bare project identifier
|
||||
///
|
||||
/// Returns None when no real attribution exists — such memories are skipped.
|
||||
fn infer_project(node: &vestige_core::KnowledgeNode) -> Option<String> {
|
||||
if let Some(project) = node
|
||||
.source_envelope
|
||||
.as_ref()
|
||||
.and_then(|e| e.source_project.as_deref())
|
||||
.filter(|p| !p.trim().is_empty())
|
||||
{
|
||||
return Some(project.trim().to_string());
|
||||
}
|
||||
|
||||
for tag in &node.tags {
|
||||
for prefix in ["codebase:", "project:"] {
|
||||
if let Some(name) = tag.strip_prefix(prefix).filter(|n| !n.trim().is_empty()) {
|
||||
return Some(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `codebase` tool sets source to the bare codebase name. Reject paths,
|
||||
// URLs, and generic provenance labels.
|
||||
if let Some(source) = node.source.as_deref().map(str::trim)
|
||||
&& !source.is_empty()
|
||||
&& !source.contains('/')
|
||||
&& !source.contains(':')
|
||||
&& !source.contains(char::is_whitespace)
|
||||
&& !matches!(
|
||||
source.to_lowercase().as_str(),
|
||||
"conversation" | "chat" | "manual" | "user" | "unknown" | "file"
|
||||
)
|
||||
{
|
||||
return Some(source.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Map a memory's tags + content onto a `PatternCategory` via the keyword
|
||||
/// conventions the dashboard tracks. First match wins; memories with no
|
||||
/// category return None and are skipped by the learner.
|
||||
fn infer_category(
|
||||
node: &vestige_core::KnowledgeNode,
|
||||
) -> Option<vestige_core::advanced::cross_project::PatternCategory> {
|
||||
use vestige_core::advanced::cross_project::PatternCategory;
|
||||
|
||||
let haystack = format!("{} {}", node.tags.join(" "), node.content).to_lowercase();
|
||||
let rules: &[(&[&str], PatternCategory)] = &[
|
||||
(
|
||||
&["error", "panic", "result", "unwrap"],
|
||||
PatternCategory::ErrorHandling,
|
||||
),
|
||||
(
|
||||
&["async", "tokio", "race", "concurrency", "deadlock"],
|
||||
PatternCategory::AsyncConcurrency,
|
||||
),
|
||||
(
|
||||
&["test", "vitest", "cargo test", "playwright"],
|
||||
PatternCategory::Testing,
|
||||
),
|
||||
(
|
||||
&["architecture", "module", "design"],
|
||||
PatternCategory::Architecture,
|
||||
),
|
||||
(
|
||||
&["perf", "latency", "optimize", "benchmark"],
|
||||
PatternCategory::Performance,
|
||||
),
|
||||
(
|
||||
&["security", "auth", "secret", "vulnerability"],
|
||||
PatternCategory::Security,
|
||||
),
|
||||
];
|
||||
|
||||
rules
|
||||
.iter()
|
||||
.find(|(keywords, _)| keywords.iter().any(|k| haystack.contains(k)))
|
||||
.map(|(_, category)| category.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditParams {
|
||||
pub limit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Per-memory audit trail: state transitions mapped to the 8 dashboard
|
||||
/// `AuditAction`s, plus a synthetic `created` event at node creation time.
|
||||
/// Events are returned newest-first (matches `splitVisible` in the frontend).
|
||||
pub async fn get_memory_audit(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<AuditParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let limit = params.limit.unwrap_or(100).clamp(1, 500);
|
||||
|
||||
let node = state
|
||||
.storage
|
||||
.get_node(&id)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let transitions = state
|
||||
.storage
|
||||
.get_state_transitions(&id, limit)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut events: Vec<(DateTime<Utc>, Value)> = Vec::new();
|
||||
for t in &transitions {
|
||||
let Some(action) = audit_action_for(&t.reason_type, &t.from_state, &t.to_state) else {
|
||||
continue;
|
||||
};
|
||||
let triggered_by = match t.reason_type.as_str() {
|
||||
"user_suppression" | "manual_override" => "user",
|
||||
_ => "system",
|
||||
};
|
||||
let reason = t
|
||||
.reason_data
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} → {} ({})", t.from_state, t.to_state, t.reason_type));
|
||||
events.push((
|
||||
t.timestamp,
|
||||
serde_json::json!({
|
||||
"action": action,
|
||||
"timestamp": t.timestamp.to_rfc3339(),
|
||||
"reason": reason,
|
||||
"triggered_by": triggered_by,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Synthetic anchor: every memory's trail begins with its creation.
|
||||
events.push((
|
||||
node.created_at,
|
||||
serde_json::json!({
|
||||
"action": "created",
|
||||
"timestamp": node.created_at.to_rfc3339(),
|
||||
"reason": "Memory ingested",
|
||||
"triggered_by": "system",
|
||||
}),
|
||||
));
|
||||
|
||||
// Newest-first, matching the frontend's expected ordering.
|
||||
events.sort_by_key(|(ts, _)| Reverse(*ts));
|
||||
let events: Vec<Value> = events.into_iter().map(|(_, v)| v).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"memoryId": node.id,
|
||||
"events": events,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Map a `state_transitions.reason_type` onto a dashboard `AuditAction`.
|
||||
///
|
||||
/// Real reason_type values (see migrations.rs V-schema comment): access,
|
||||
/// time_decay, cue_reactivation, competition_loss, interference_resolved,
|
||||
/// user_suppression, suppression_expired, manual_override, system_init.
|
||||
/// Unknown reasons fall back to the state-rank direction (up → promoted,
|
||||
/// down → demoted, flat → accessed). `system_init` is dropped because the
|
||||
/// synthetic `created` event already anchors the trail.
|
||||
fn audit_action_for(reason_type: &str, from_state: &str, to_state: &str) -> Option<&'static str> {
|
||||
match reason_type {
|
||||
"access" | "cue_reactivation" => Some("accessed"),
|
||||
"time_decay" | "competition_loss" => Some("demoted"),
|
||||
"user_suppression" => Some("suppressed"),
|
||||
"suppression_expired" | "interference_resolved" => Some("reconsolidated"),
|
||||
"manual_override" => Some("edited"),
|
||||
"system_init" => None,
|
||||
other if other.contains("dream") => Some("dreamed"),
|
||||
_ => {
|
||||
let rank = |s: &str| match s {
|
||||
"active" => 3,
|
||||
"dormant" => 2,
|
||||
"silent" => 1,
|
||||
"unavailable" => 0,
|
||||
_ => 2,
|
||||
};
|
||||
Some(match rank(to_state).cmp(&rank(from_state)) {
|
||||
std::cmp::Ordering::Greater => "promoted",
|
||||
std::cmp::Ordering::Less => "demoted",
|
||||
std::cmp::Ordering::Equal => "accessed",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -2497,4 +3008,284 @@ mod tests {
|
|||
let connected_err = default_center_id(&storage, GraphSort::Connected).unwrap_err();
|
||||
assert_eq!(connected_err, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Live-wire endpoints: /api/duplicates, /api/contradictions,
|
||||
// /api/patterns/cross-project, /api/memories/{id}/audit
|
||||
// ========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicates_returns_contract_shape_when_no_embeddings() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
ingest(&storage, "first memory about parsers");
|
||||
ingest(&storage, "second memory about parsers");
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let Json(body) = list_duplicates(
|
||||
State(state),
|
||||
Query(DuplicatesParams {
|
||||
threshold: None,
|
||||
limit: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Field names are load-bearing — the frontend interface is shipped.
|
||||
assert_eq!(body["threshold"], 0.8);
|
||||
assert_eq!(body["total"], 0);
|
||||
assert!(body["clusters"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicates_clamps_threshold_and_limit() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let Json(body) = list_duplicates(
|
||||
State(state),
|
||||
Query(DuplicatesParams {
|
||||
threshold: Some(1.5),
|
||||
limit: Some(9999),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(body["threshold"], 0.99);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn contradictions_reports_pair_with_contract_fields() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
let first = ingest(
|
||||
&storage,
|
||||
"For the release workflow we always run cargo test before publishing Vestige",
|
||||
);
|
||||
let second = ingest(
|
||||
&storage,
|
||||
"Correction: for the release workflow we never run cargo test before publishing Vestige",
|
||||
);
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let Json(body) = list_contradictions(
|
||||
State(state),
|
||||
Query(ContradictionsParams {
|
||||
topic: None,
|
||||
min_trust: Some(0.0),
|
||||
limit: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(body["memoriesAnalyzed"], 2);
|
||||
assert_eq!(body["total"], 1);
|
||||
let pair = &body["contradictions"][0];
|
||||
// Exact frontend field names (ContradictionArcs.svelte interface).
|
||||
for field in [
|
||||
"memory_a_id",
|
||||
"memory_b_id",
|
||||
"memory_a_preview",
|
||||
"memory_b_preview",
|
||||
"memory_a_type",
|
||||
"memory_b_type",
|
||||
"memory_a_created",
|
||||
"memory_b_created",
|
||||
"memory_a_tags",
|
||||
"memory_b_tags",
|
||||
"trust_a",
|
||||
"trust_b",
|
||||
"similarity",
|
||||
"date_diff_days",
|
||||
"topic",
|
||||
] {
|
||||
assert!(
|
||||
!pair[field].is_null(),
|
||||
"missing contract field: {}",
|
||||
field
|
||||
);
|
||||
}
|
||||
// a = older, b = newer (chronological).
|
||||
let ids: Vec<&str> = vec![
|
||||
pair["memory_a_id"].as_str().unwrap(),
|
||||
pair["memory_b_id"].as_str().unwrap(),
|
||||
];
|
||||
assert!(ids.contains(&first.as_str()));
|
||||
assert!(ids.contains(&second.as_str()));
|
||||
assert!(
|
||||
pair["memory_a_created"].as_str().unwrap()
|
||||
<= pair["memory_b_created"].as_str().unwrap(),
|
||||
"a must be the older memory"
|
||||
);
|
||||
assert!(pair["similarity"].as_f64().unwrap() >= 0.4);
|
||||
assert!(!pair["topic"].as_str().unwrap().is_empty());
|
||||
}
|
||||
|
||||
fn ingest_project_memory(storage: &Storage, content: &str, project: &str) {
|
||||
storage
|
||||
.ingest(IngestInput {
|
||||
content: content.to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
tags: vec![format!("codebase:{}", project)],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_patterns_finds_transfer_across_two_projects() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
// Same ErrorHandling keyword ("timeout", > 5 chars) in two projects
|
||||
// — exactly what the learner needs to mint a universal pattern.
|
||||
ingest_project_memory(
|
||||
&storage,
|
||||
"Fixed timeout error by wrapping the retry loop in a Result",
|
||||
"alpha",
|
||||
);
|
||||
ingest_project_memory(
|
||||
&storage,
|
||||
"The API client needs a timeout guard or the error propagates",
|
||||
"beta",
|
||||
);
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let Json(body) = get_cross_project_patterns(State(state)).await.unwrap();
|
||||
|
||||
let projects: Vec<&str> = body["projects"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p.as_str().unwrap())
|
||||
.collect();
|
||||
assert!(projects.contains(&"alpha"));
|
||||
assert!(projects.contains(&"beta"));
|
||||
|
||||
let patterns = body["patterns"].as_array().unwrap();
|
||||
assert!(!patterns.is_empty(), "expected at least one pattern");
|
||||
let pattern = &patterns[0];
|
||||
// Exact frontend field names (patterns/+page.svelte interface).
|
||||
assert!(pattern["name"].is_string());
|
||||
assert_eq!(pattern["category"], "ErrorHandling");
|
||||
assert!(pattern["origin_project"].is_string());
|
||||
assert!(pattern["transferred_to"].is_array());
|
||||
assert_eq!(
|
||||
pattern["transfer_count"].as_u64().unwrap() as usize,
|
||||
pattern["transferred_to"].as_array().unwrap().len()
|
||||
);
|
||||
assert!(pattern["last_used"].is_string());
|
||||
assert!(pattern["confidence"].is_number());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_project_patterns_empty_without_project_attribution() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
ingest(&storage, "memory with no project attribution at all");
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let Json(body) = get_cross_project_patterns(State(state)).await.unwrap();
|
||||
|
||||
assert!(body["projects"].as_array().unwrap().is_empty());
|
||||
assert!(body["patterns"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_audit_prepends_created_and_maps_time_decay_to_demoted() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
let id = ingest(&storage, "audit trail test memory");
|
||||
// Create the memory_states row, then force a recorded transition.
|
||||
storage.record_memory_access(&id).unwrap();
|
||||
storage
|
||||
.update_memory_state(&id, "dormant", "time_decay")
|
||||
.unwrap();
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let Json(body) = get_memory_audit(
|
||||
State(state),
|
||||
Path(id.clone()),
|
||||
Query(AuditParams { limit: None }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(body["memoryId"], id.as_str());
|
||||
let events = body["events"].as_array().unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
// Newest-first: the transition, then the synthetic created anchor.
|
||||
assert_eq!(events[0]["action"], "demoted");
|
||||
assert_eq!(events[1]["action"], "created");
|
||||
for event in events {
|
||||
assert!(event["action"].is_string());
|
||||
assert!(event["timestamp"].is_string());
|
||||
assert!(event["reason"].is_string());
|
||||
assert!(event["triggered_by"].is_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_audit_unknown_id_is_404() {
|
||||
let (_dir, storage) = seed_storage();
|
||||
let state = AppState::new(storage, None);
|
||||
|
||||
let err = get_memory_audit(
|
||||
State(state),
|
||||
Path("00000000-0000-0000-0000-000000000000".to_string()),
|
||||
Query(AuditParams { limit: None }),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_action_mapping_covers_real_reason_types() {
|
||||
assert_eq!(audit_action_for("access", "dormant", "active"), Some("accessed"));
|
||||
assert_eq!(
|
||||
audit_action_for("cue_reactivation", "silent", "active"),
|
||||
Some("accessed")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("time_decay", "active", "dormant"),
|
||||
Some("demoted")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("competition_loss", "active", "unavailable"),
|
||||
Some("demoted")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("user_suppression", "active", "unavailable"),
|
||||
Some("suppressed")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("suppression_expired", "unavailable", "dormant"),
|
||||
Some("reconsolidated")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("interference_resolved", "dormant", "dormant"),
|
||||
Some("reconsolidated")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("manual_override", "silent", "active"),
|
||||
Some("edited")
|
||||
);
|
||||
assert_eq!(audit_action_for("system_init", "active", "active"), None);
|
||||
// Unknown reasons fall back to state-rank direction.
|
||||
assert_eq!(
|
||||
audit_action_for("mystery", "silent", "active"),
|
||||
Some("promoted")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("mystery", "active", "silent"),
|
||||
Some("demoted")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("mystery", "active", "active"),
|
||||
Some("accessed")
|
||||
);
|
||||
assert_eq!(
|
||||
audit_action_for("rem_dream_replay", "active", "active"),
|
||||
Some("dreamed")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,17 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
.route("/api/receipts", get(handlers::list_receipts))
|
||||
.route("/api/receipts/{receipt_id}", get(handlers::get_receipt))
|
||||
// ============================================================
|
||||
// MEMORY HYGIENE + INTELLIGENCE (live-wire) — duplicates,
|
||||
// contradictions, cross-project patterns, per-memory audit
|
||||
// ============================================================
|
||||
.route("/api/duplicates", get(handlers::list_duplicates))
|
||||
.route("/api/contradictions", get(handlers::list_contradictions))
|
||||
.route(
|
||||
"/api/patterns/cross-project",
|
||||
get(handlers::get_cross_project_patterns),
|
||||
)
|
||||
.route("/api/memories/{id}/audit", get(handlers::get_memory_audit))
|
||||
// ============================================================
|
||||
// MEMORY PRs (v2.2) — risk-gated brain-change review queue
|
||||
// ============================================================
|
||||
.route("/api/memory-prs", get(handlers::list_memory_prs))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue