mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
feat(v2.4): UI expansion — 8 new surfaces exposing the cognitive engine
Sam asked: "Build EVERY SINGLE MISSING UI PIECE." 10 parallel agents shipped
10 new viewports over the existing Rust backend, then 11 audit agents
line-by-line reviewed each one, extracted pure-logic helpers, fixed ~30
bugs, and shipped 549 new unit tests. Everything wired through the layout
with single-key shortcuts and a live theme toggle.
**Eight new routes**
- `/reasoning` — Reasoning Theater: Cmd+K ask palette → animated 8-stage
deep_reference pipeline + FSRS-trust-scored evidence cards +
contradiction arcs rendered as live SVG between evidence nodes
- `/duplicates` — threshold-driven cluster detector with winner selection,
Merge/Review/Dismiss actions, debounced slider
- `/dreams` — Dream Cinema: trigger dream + scrubbable 5-stage replay
(Replay → Cross-reference → Strengthen → Prune → Transfer) + insight
cards with novelty glow
- `/schedule` — FSRS Review Calendar: 6×7 grid with urgency color
bands (overdue/today/week/future), retention sparkline, expand-day list
- `/importance` — 4-channel radar (Novelty/Arousal/Reward/Attention) with
composite score + top-important list
- `/activation` — live spreading-activation view: search → SVG concentric
rings with decay animation + live-mode event feed
- `/contradictions` — 2D cosmic constellation of conflicting memories,
arcs colored by severity, tooltips with previews
- `/patterns` — cross-project pattern transfer heatmap with category
filters, top-transferred sidebar
**Three layout additions**
- `AmbientAwarenessStrip` — slim top band with retention vitals, at-risk
count, active intentions, recent dream, activity sparkline, dreaming
indicator, Sanhedrin-watch flash. Pure `$derived` over existing stores.
- `ThemeToggle` — dark/light/auto cycle with matchMedia listener,
localStorage persistence, SSR-safe, reduced-motion-aware. Rendered in
sidebar footer next to the connection dot.
- `MemoryAuditTrail` — per-memory Sources panel integrated as a
Content/Audit tab into the existing /memories expansion.
**Pure-logic helper modules extracted (for testability + reuse)**
reasoning-helpers, duplicates-helpers, dream-helpers, schedule-helpers,
audit-trail-helpers, awareness-helpers, contradiction-helpers,
activation-helpers, patterns-helpers, importance-helpers.
**Bugs fixed during audit (not exhaustive)**
- Trust-color inconsistency between EvidenceCard and the page confidence
ring (0.75 boundary split emerald vs amber)
- `new Date('garbage').toLocaleDateString()` returned literal "Invalid Date"
in 3 components — all now return em-dash or raw string
- NaN propagation in `Math.max(0, Math.min(1, NaN))` across clamps
- Off-by-one PRNG in audit-trail seeded mock (seed === UINT32_MAX yielded
rand() === 1.0 → index out of bounds)
- Duplicates dismissals keyed by array index broke on re-fetch; now keyed
by sorted cluster member IDs with stale-dismissal pruning
- Empty-cluster crash in DuplicateCluster.pickWinner
- Undefined tags crash in DuplicateCluster.safeTags
- Debounce timer leak in threshold slider (missing onDestroy cleanup)
- Schedule day-vs-hour granularity mismatch between calendar cell and
sidebar list ("today" in one, "in 1d" in the other)
- Schedule 500-memory hard cap silently truncated; bumped to 2000 + banner
- Schedule DST boundary bug in daysBetween (wall-clock math vs
startOfDay-normalized)
- Dream stage clamp now handles NaN/Infinity/floats
- Dream double-click debounce via `if (dreaming) return`
- Theme setTheme runtime validation; initTheme idempotence (listener +
style-element dedup on repeat calls)
- ContradictionArcs node radius unclamped (trust < 0 or > 1 rendered
invalid sizes); tooltip position clamp (could push off-canvas)
- ContradictionArcs $state closure capture (width/height weren't reactive
in the derived layout block)
- Activation route was MISSING from the repo — audit agent created it
with identity-based event filtering and proper RAF cleanup
- Layout: ThemeToggle was imported but never rendered — now in sidebar
footer; sidebar overflow-y-auto added for the 16-entry nav
**Tests — 549 new, 910 total passing (0 failures)**
ReasoningChain 42 | EvidenceCard 50
DuplicateCluster 64 | DreamStageReplay 19
DreamInsightCard 43 | FSRSCalendar 32
MemoryAuditTrail 45 | AmbientAwareness 60
theme (store) 31 | ContradictionArcs 43
ActivationNetwork 54 | PatternTransfer 31
ImportanceRadar 35 | + existing 361 tests still green
**Gates passed**
- `npm run check`: 0 errors, 0 warnings across 623 files
- `npm test`: 910/910 passing, 22 test files
- `npm run build`: clean adapter-static output
**Layout wiring**
- Nav array expanded 8 → 16 entries (existing 8 + 8 new routes)
- Single-key shortcuts added: R/A/D/C/P/U/X/N (no conflicts with
existing G/M/T/F/E/I/S/,)
- Cmd+K palette search works across all 16
- Mobile nav = top 5 (Graph, Reasoning, Memories, Timeline, Feed)
- AmbientAwarenessStrip mounted as first child of <main>
- ThemeToggle rendered in sidebar footer (was imported-but-unmounted)
- Theme initTheme() + teardown wired into onMount cleanup chain
Net branch delta: 47 files changed, +13,756 insertions, -6 deletions
This commit is contained in:
parent
7441b3cdfe
commit
50869e96ff
47 changed files with 13756 additions and 6 deletions
372
apps/dashboard/src/lib/components/ActivationNetwork.svelte
Normal file
372
apps/dashboard/src/lib/components/ActivationNetwork.svelte
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
activationColor,
|
||||
applyDecay,
|
||||
edgeStagger,
|
||||
initialActivation,
|
||||
isVisible,
|
||||
layoutNeighbours,
|
||||
} from './activation-helpers';
|
||||
|
||||
/**
|
||||
* ActivationNetwork — visualizes spreading activation (Collins & Loftus 1975)
|
||||
* across the cognitive memory graph.
|
||||
*
|
||||
* Every burst places a source node at the center with activated neighbours
|
||||
* on concentric rings. Edges draw in with a staggered delay to visualize the
|
||||
* activation wavefront; ripple waves expand outward from the source on each
|
||||
* burst; activation level decays every animation frame by 0.93 until it
|
||||
* drops below 0.05, at which point the node fades out.
|
||||
*
|
||||
* The component supports multiple overlapping bursts — each call to
|
||||
* `trigger(sourceId, sourceLabel, neighbours)` merges into the current
|
||||
* activation state rather than replacing it, so live mode feels like a
|
||||
* continuous neural storm instead of a reset.
|
||||
*/
|
||||
|
||||
export interface ActivationNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: string;
|
||||
// Neighbours only — omit on source
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Current focused burst; null when idle */
|
||||
source?: ActivationNode | null;
|
||||
/** Neighbours of the current focused burst (drawn immediately on mount) */
|
||||
neighbours?: ActivationNode[];
|
||||
/** Bursts triggered via live mode — each one overlays on the graph */
|
||||
liveBurstKey?: number;
|
||||
liveBurst?: { source: ActivationNode; neighbours: ActivationNode[] } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
width = 900,
|
||||
height = 560,
|
||||
source = null,
|
||||
neighbours = [],
|
||||
liveBurstKey = 0,
|
||||
liveBurst = null,
|
||||
}: Props = $props();
|
||||
|
||||
// Decay/geometry constants live in `./activation-helpers` so the pure-
|
||||
// function test suite can exercise them without rendering Svelte. These
|
||||
// two visual-only constants stay local because they're tied to the SVG
|
||||
// node drawing below.
|
||||
const SOURCE_RADIUS = 22;
|
||||
const NEIGHBOUR_RADIUS_BASE = 14;
|
||||
|
||||
interface ActiveNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: string;
|
||||
x: number;
|
||||
y: number;
|
||||
activation: number; // 0..1 — drives size and opacity
|
||||
isSource: boolean;
|
||||
sourceBurstId: number; // which burst does this node belong to
|
||||
}
|
||||
|
||||
interface ActiveEdge {
|
||||
burstId: number;
|
||||
sourceNodeId: string;
|
||||
targetNodeId: string;
|
||||
// Each edge has its own draw progress so we can stagger them
|
||||
drawProgress: number; // 0..1
|
||||
staggerDelay: number; // frames to wait before drawing
|
||||
framesElapsed: number;
|
||||
}
|
||||
|
||||
interface Ripple {
|
||||
burstId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
let activeNodes = $state<ActiveNode[]>([]);
|
||||
let activeEdges = $state<ActiveEdge[]>([]);
|
||||
let ripples = $state<Ripple[]>([]);
|
||||
|
||||
let burstCounter = 0;
|
||||
let animationFrame: number | null = null;
|
||||
let lastPropSource: string | null = null;
|
||||
let lastLiveKey = 0;
|
||||
|
||||
function triggerBurst(
|
||||
src: ActivationNode,
|
||||
nbrs: ActivationNode[],
|
||||
centerX: number,
|
||||
centerY: number
|
||||
) {
|
||||
burstCounter += 1;
|
||||
const burstId = burstCounter;
|
||||
|
||||
// If a live burst hits the same source that is already at center, offset
|
||||
// it slightly so the visual distinction is preserved without chaos.
|
||||
const jitter = liveBurstKey > 0 && activeNodes.length > 0 ? 40 : 0;
|
||||
const cx = centerX + (Math.random() - 0.5) * jitter;
|
||||
const cy = centerY + (Math.random() - 0.5) * jitter;
|
||||
|
||||
// Ripple wave from this source
|
||||
ripples = [
|
||||
...ripples,
|
||||
{ burstId, x: cx, y: cy, radius: SOURCE_RADIUS, opacity: 0.75 },
|
||||
{ burstId, x: cx, y: cy, radius: SOURCE_RADIUS, opacity: 0.5 },
|
||||
];
|
||||
|
||||
// Source node — full activation
|
||||
const sourceNode: ActiveNode = {
|
||||
id: `${src.id}::${burstId}`,
|
||||
label: src.label,
|
||||
nodeType: 'source',
|
||||
x: cx,
|
||||
y: cy,
|
||||
activation: 1,
|
||||
isSource: true,
|
||||
sourceBurstId: burstId,
|
||||
};
|
||||
|
||||
const neighbourNodes: ActiveNode[] = [];
|
||||
const newEdges: ActiveEdge[] = [];
|
||||
|
||||
// Slight rotation per burst so overlapping bursts don't fully collide
|
||||
const angleOffset = (burstCounter * 0.37) % (Math.PI * 2);
|
||||
const allPositions = layoutNeighbours(cx, cy, nbrs.length, angleOffset);
|
||||
|
||||
nbrs.forEach((nbr, i) => {
|
||||
const pos = allPositions[i];
|
||||
if (!pos) return;
|
||||
|
||||
neighbourNodes.push({
|
||||
id: `${nbr.id}::${burstId}`,
|
||||
label: nbr.label,
|
||||
nodeType: nbr.nodeType,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
activation: initialActivation(i, nbrs.length),
|
||||
isSource: false,
|
||||
sourceBurstId: burstId,
|
||||
});
|
||||
|
||||
newEdges.push({
|
||||
burstId,
|
||||
sourceNodeId: sourceNode.id,
|
||||
targetNodeId: `${nbr.id}::${burstId}`,
|
||||
drawProgress: 0,
|
||||
staggerDelay: edgeStagger(i),
|
||||
framesElapsed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
activeNodes = [...activeNodes, sourceNode, ...neighbourNodes];
|
||||
activeEdges = [...activeEdges, ...newEdges];
|
||||
}
|
||||
|
||||
function tick() {
|
||||
// Decay node activations (Collins & Loftus 1975, 0.93/frame).
|
||||
let nextNodes: ActiveNode[] = [];
|
||||
for (const n of activeNodes) {
|
||||
const nextActivation = applyDecay(n.activation);
|
||||
if (!isVisible(nextActivation)) continue;
|
||||
nextNodes.push({ ...n, activation: nextActivation });
|
||||
}
|
||||
activeNodes = nextNodes;
|
||||
|
||||
// Advance edge draw progress (only for edges whose endpoints still exist)
|
||||
const liveIds = new Set(nextNodes.map((n) => n.id));
|
||||
let nextEdges: ActiveEdge[] = [];
|
||||
for (const e of activeEdges) {
|
||||
if (!liveIds.has(e.sourceNodeId) || !liveIds.has(e.targetNodeId)) continue;
|
||||
const elapsed = e.framesElapsed + 1;
|
||||
let progress = e.drawProgress;
|
||||
if (elapsed >= e.staggerDelay) {
|
||||
// 0..1 over ~15 frames (~0.25s at 60fps)
|
||||
progress = Math.min(1, progress + 1 / 15);
|
||||
}
|
||||
nextEdges.push({ ...e, framesElapsed: elapsed, drawProgress: progress });
|
||||
}
|
||||
activeEdges = nextEdges;
|
||||
|
||||
// Expand ripples outward, fade opacity
|
||||
let nextRipples: Ripple[] = [];
|
||||
for (const r of ripples) {
|
||||
const nextRadius = r.radius + 6;
|
||||
const nextOpacity = r.opacity * 0.96;
|
||||
if (nextOpacity < 0.02 || nextRadius > Math.max(width, height)) continue;
|
||||
nextRipples.push({ ...r, radius: nextRadius, opacity: nextOpacity });
|
||||
}
|
||||
ripples = nextRipples;
|
||||
|
||||
animationFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function clearBursts() {
|
||||
activeNodes = [];
|
||||
activeEdges = [];
|
||||
ripples = [];
|
||||
}
|
||||
|
||||
// Watch for prop-driven bursts (initial search result)
|
||||
$effect(() => {
|
||||
if (!source) return;
|
||||
const sourceKey = source.id;
|
||||
if (sourceKey === lastPropSource) return;
|
||||
lastPropSource = sourceKey;
|
||||
clearBursts();
|
||||
triggerBurst(source, neighbours, width / 2, height / 2);
|
||||
});
|
||||
|
||||
// Watch for live bursts — each keyed trigger overlays a new burst at a
|
||||
// random-ish location near center so they don't stack directly on top.
|
||||
$effect(() => {
|
||||
if (!liveBurst || liveBurstKey === 0) return;
|
||||
if (liveBurstKey === lastLiveKey) return;
|
||||
lastLiveKey = liveBurstKey;
|
||||
// Live bursts land near but not exactly on center so they're visually
|
||||
// distinct from the primary burst.
|
||||
const offsetX = (Math.random() - 0.5) * 120;
|
||||
const offsetY = (Math.random() - 0.5) * 120;
|
||||
triggerBurst(liveBurst.source, liveBurst.neighbours, width / 2 + offsetX, height / 2 + offsetY);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
animationFrame = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationFrame !== null) cancelAnimationFrame(animationFrame);
|
||||
});
|
||||
|
||||
function nodeColor(nodeType: string, isSource: boolean): string {
|
||||
return activationColor(nodeType, isSource);
|
||||
}
|
||||
|
||||
function edgePoint(edge: ActiveEdge): {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
} | null {
|
||||
const src = activeNodes.find((n) => n.id === edge.sourceNodeId);
|
||||
const tgt = activeNodes.find((n) => n.id === edge.targetNodeId);
|
||||
if (!src || !tgt) return null;
|
||||
// Clip to current draw progress so the edge grows outward from source
|
||||
const x2 = src.x + (tgt.x - src.x) * edge.drawProgress;
|
||||
const y2 = src.y + (tgt.y - src.y) * edge.drawProgress;
|
||||
return { x1: src.x, y1: src.y, x2, y2 };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
class="w-full h-full block"
|
||||
aria-label="Spreading activation visualization"
|
||||
>
|
||||
<defs>
|
||||
<filter id="act-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="act-glow-strong" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<radialGradient id="ripple-grad" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="70%" stop-color="#818cf8" stop-opacity="0" />
|
||||
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.7" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Ripple wavefronts (expanding circles from source) -->
|
||||
{#each ripples as r, i (i)}
|
||||
<circle
|
||||
cx={r.x}
|
||||
cy={r.y}
|
||||
r={r.radius}
|
||||
fill="none"
|
||||
stroke="#818cf8"
|
||||
stroke-width="1.5"
|
||||
opacity={r.opacity}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Edges (drawn with stagger delay so activation appears to spread) -->
|
||||
{#each activeEdges as e, i (i)}
|
||||
{@const pt = edgePoint(e)}
|
||||
{#if pt}
|
||||
<line
|
||||
x1={pt.x1}
|
||||
y1={pt.y1}
|
||||
x2={pt.x2}
|
||||
y2={pt.y2}
|
||||
stroke="#818cf8"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
opacity={0.35 * e.drawProgress}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#each activeNodes as n (n.id)}
|
||||
{@const color = nodeColor(n.nodeType, n.isSource)}
|
||||
{@const r = n.isSource
|
||||
? SOURCE_RADIUS * (0.7 + 0.3 * n.activation)
|
||||
: NEIGHBOUR_RADIUS_BASE * (0.5 + 0.8 * n.activation)}
|
||||
<g opacity={Math.min(1, n.activation * 1.25)}>
|
||||
<!-- Soft outer glow halo -->
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={r * 1.9}
|
||||
fill={color}
|
||||
opacity={0.18 * n.activation}
|
||||
filter="url(#act-glow-strong)"
|
||||
/>
|
||||
<!-- Core -->
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={r}
|
||||
fill={color}
|
||||
filter="url(#act-glow)"
|
||||
/>
|
||||
<!-- Inner highlight for depth -->
|
||||
<circle
|
||||
cx={n.x - r * 0.3}
|
||||
cy={n.y - r * 0.3}
|
||||
r={r * 0.35}
|
||||
fill="#ffffff"
|
||||
opacity={0.35 * n.activation}
|
||||
/>
|
||||
{#if n.isSource && n.label}
|
||||
<text
|
||||
x={n.x}
|
||||
y={n.y + r + 18}
|
||||
text-anchor="middle"
|
||||
fill="#e0e0ff"
|
||||
font-size="11"
|
||||
font-family="var(--font-mono)"
|
||||
opacity={0.9 * n.activation}
|
||||
>
|
||||
{n.label.length > 40 ? n.label.slice(0, 40) + '…' : n.label}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
312
apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte
Normal file
312
apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<!--
|
||||
AmbientAwarenessStrip — persistent slim top-of-viewport band surfacing
|
||||
live cognitive engine vitals without demanding attention.
|
||||
|
||||
Contents (left → right):
|
||||
1. Retention Vitals — pulsing dot + "N memories · X% avg retention"
|
||||
2. At-Risk Count — memories with retention < 0.3 (or "—" if unknown)
|
||||
3. Active Intentions — count of active intentions, pings pink if >5
|
||||
4. Recent Dream — last DreamCompleted within 24h summary
|
||||
5. Activity Pulse — 10-bar sparkline of events/min over last 5 min
|
||||
6. Now Dreaming? — violet pulsing dot while a Dream is in flight
|
||||
7. Sanhedrin Watch — subtle red flash on MemorySuppressed in last 10s
|
||||
|
||||
Design: full-width band, dark-glass backdrop, border-bottom synapse/15,
|
||||
height ≈36px, dim muted text with colored accents ONLY on pulsing/urgent
|
||||
items. Not clickable — ambient info only.
|
||||
|
||||
Mobile: collapses to items 1, 2, 6 to save width.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
memoryCount,
|
||||
avgRetention,
|
||||
eventFeed,
|
||||
} from '$stores/websocket';
|
||||
import { api } from '$stores/api';
|
||||
import {
|
||||
bucketizeActivity,
|
||||
dreamInsightsCount,
|
||||
findRecentDream,
|
||||
formatAgo,
|
||||
hasRecentSuppression,
|
||||
isDreaming as isDreamingFn,
|
||||
parseEventTimestamp,
|
||||
} from './awareness-helpers';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 1. Retention vitals — derived straight from heartbeat stores
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const retentionPct = $derived(Math.round(($avgRetention ?? 0) * 100));
|
||||
const retentionHealthy = $derived(($avgRetention ?? 0) >= 0.5);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 2. At-risk count — fetched once from /retention-distribution.
|
||||
// Sum buckets whose range label implies retention < 0.3 ("0-20%" and
|
||||
// "20-40%"). Robust to absent/unknown backend: stays `null` → shows "—".
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
let atRiskCount = $state<number | null>(null);
|
||||
|
||||
async function loadAtRisk(): Promise<void> {
|
||||
try {
|
||||
const dist = await api.retentionDistribution();
|
||||
// Prefer direct `endangered` list if backend populates it.
|
||||
if (Array.isArray(dist.endangered) && dist.endangered.length > 0) {
|
||||
atRiskCount = dist.endangered.length;
|
||||
return;
|
||||
}
|
||||
// Otherwise sum buckets whose lower bound < 30%.
|
||||
const buckets = dist.distribution ?? [];
|
||||
let total = 0;
|
||||
for (const b of buckets) {
|
||||
const m = /^(\d+)/.exec(b.range);
|
||||
if (!m) continue;
|
||||
const low = Number.parseInt(m[1], 10);
|
||||
if (Number.isFinite(low) && low < 30) total += b.count ?? 0;
|
||||
}
|
||||
atRiskCount = total;
|
||||
} catch {
|
||||
atRiskCount = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 3. Active intentions — fetched once from /intentions?status=active
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
let intentionsCount = $state<number | null>(null);
|
||||
|
||||
async function loadIntentions(): Promise<void> {
|
||||
try {
|
||||
const res = await api.intentions('active');
|
||||
intentionsCount = res.total ?? res.intentions?.length ?? 0;
|
||||
} catch {
|
||||
intentionsCount = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 4 & 6. Dream awareness — pure helpers scan $eventFeed. Newest-first.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
let nowTick = $state(Date.now());
|
||||
|
||||
const dreamState = $derived.by(() => {
|
||||
const feed = $eventFeed;
|
||||
const recent = findRecentDream(feed, nowTick);
|
||||
const recentAt = recent ? parseEventTimestamp(recent) ?? nowTick : null;
|
||||
const recentMsAgo = recentAt !== null ? nowTick - recentAt : null;
|
||||
return {
|
||||
isDreaming: isDreamingFn(feed, nowTick),
|
||||
recent,
|
||||
recentMsAgo,
|
||||
insights: dreamInsightsCount(recent),
|
||||
};
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 5. Activity pulse — bucket $eventFeed timestamps into 10 × 30s buckets
|
||||
// over the last 5 minutes. Bucket 0 = oldest, 9 = newest.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const sparkline = $derived(bucketizeActivity($eventFeed, nowTick));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 7. Sanhedrin watch — flash red on any MemorySuppressed in last 10s
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const suppressionFlash = $derived(hasRecentSuppression($eventFeed, nowTick));
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Ticker — advance `nowTick` every second so time-based derived values
|
||||
// (dreaming window, activity window, suppression flash) refresh smoothly.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
void loadAtRisk();
|
||||
void loadIntentions();
|
||||
const tickHandle = setInterval(() => {
|
||||
nowTick = Date.now();
|
||||
}, 1000);
|
||||
// Refresh the slow API-backed counts every 60s so they don't go stale.
|
||||
const slowHandle = setInterval(() => {
|
||||
void loadAtRisk();
|
||||
void loadIntentions();
|
||||
}, 60_000);
|
||||
return () => {
|
||||
clearInterval(tickHandle);
|
||||
clearInterval(slowHandle);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ambient-strip relative flex h-9 w-full items-center gap-0 overflow-hidden border-b border-synapse/15 bg-black/40 px-3 text-[11px] text-dim backdrop-blur-md"
|
||||
class:ambient-flash={suppressionFlash}
|
||||
aria-label="Ambient cognitive vitals"
|
||||
>
|
||||
<!-- 1. Retention vitals — always visible -->
|
||||
<div class="strip-item" title="Total memories and average retention strength">
|
||||
<span class="relative inline-flex h-2 w-2 items-center justify-center">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"
|
||||
class:bg-recall={retentionHealthy}
|
||||
class:bg-warning={!retentionHealthy}
|
||||
></span>
|
||||
<span
|
||||
class="relative inline-flex h-2 w-2 rounded-full"
|
||||
class:bg-recall={retentionHealthy}
|
||||
class:bg-warning={!retentionHealthy}
|
||||
></span>
|
||||
</span>
|
||||
<span class="text-text/80 tabular-nums">{$memoryCount}</span>
|
||||
<span class="text-muted">memories</span>
|
||||
<span class="text-muted/60">·</span>
|
||||
<span class:text-recall={retentionHealthy} class:text-warning={!retentionHealthy}>
|
||||
{retentionPct}%
|
||||
</span>
|
||||
<span class="text-muted">avg retention</span>
|
||||
</div>
|
||||
|
||||
<div class="strip-divider" aria-hidden="true"></div>
|
||||
|
||||
<!-- 2. At-risk — always visible -->
|
||||
<div class="strip-item" title="Memories with retention below 30%">
|
||||
{#if atRiskCount !== null && atRiskCount > 0}
|
||||
<span class="font-semibold tabular-nums text-decay">{atRiskCount}</span>
|
||||
<span class="text-muted">at risk</span>
|
||||
{:else if atRiskCount === 0}
|
||||
<span class="text-muted tabular-nums">0</span>
|
||||
<span class="text-muted">at risk</span>
|
||||
{:else}
|
||||
<span class="text-muted/60">—</span>
|
||||
<span class="text-muted">at risk</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 3. Active intentions — hidden on mobile -->
|
||||
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
|
||||
<div class="strip-item hidden md:inline-flex" title="Active intentions (prospective memory)">
|
||||
{#if intentionsCount !== null}
|
||||
<span
|
||||
class="inline-flex h-2 w-2 rounded-full"
|
||||
class:bg-node-pattern={intentionsCount > 5}
|
||||
class:animate-ping-slow={intentionsCount > 5}
|
||||
class:bg-muted={intentionsCount <= 5}
|
||||
></span>
|
||||
<span
|
||||
class="tabular-nums"
|
||||
class:text-node-pattern={intentionsCount > 5}
|
||||
class:text-text={intentionsCount > 0 && intentionsCount <= 5}
|
||||
class:text-muted={intentionsCount === 0}
|
||||
>
|
||||
{intentionsCount}
|
||||
</span>
|
||||
<span class="text-muted">intentions</span>
|
||||
{:else}
|
||||
<span class="text-muted/60">— intentions</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 4. Recent dream — hidden on mobile -->
|
||||
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
|
||||
<div class="strip-item hidden md:inline-flex" title="Most recent Dream cycle completion">
|
||||
{#if dreamState.recent && dreamState.recentMsAgo !== null}
|
||||
<span class="text-dream/80">✦</span>
|
||||
<span class="text-muted">Last dream:</span>
|
||||
<span class="text-text/80">{formatAgo(dreamState.recentMsAgo)}</span>
|
||||
{#if dreamState.insights !== null}
|
||||
<span class="text-muted/60">·</span>
|
||||
<span class="text-text/80 tabular-nums">{dreamState.insights}</span>
|
||||
<span class="text-muted">insights</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-muted">No recent dream</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 5. Activity pulse sparkline — hidden on mobile -->
|
||||
<div class="strip-divider hidden md:block" aria-hidden="true"></div>
|
||||
<div
|
||||
class="strip-item hidden md:inline-flex"
|
||||
title="Event throughput over the last 5 minutes (events per 30s)"
|
||||
>
|
||||
<span class="text-muted">activity</span>
|
||||
<div class="flex h-4 items-end gap-[2px]" aria-hidden="true">
|
||||
{#each sparkline as bar}
|
||||
<div
|
||||
class="w-[3px] rounded-sm bg-synapse/70"
|
||||
style="height: {Math.max(10, bar.ratio * 100)}%; opacity: {bar.count === 0 ? 0.18 : 0.5 + bar.ratio * 0.5};"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Now dreaming? — always visible when active -->
|
||||
{#if dreamState.isDreaming}
|
||||
<div class="strip-divider" aria-hidden="true"></div>
|
||||
<div class="strip-item" title="A Dream cycle is currently in progress">
|
||||
<span class="relative inline-flex h-2 w-2 items-center justify-center">
|
||||
<span
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-dream opacity-75"
|
||||
></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-dream"></span>
|
||||
</span>
|
||||
<span class="font-semibold tracking-wider text-dream-glow">DREAMING...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- 7. Sanhedrin watch — subtle right-aligned flash, hidden on mobile -->
|
||||
{#if suppressionFlash}
|
||||
<div class="strip-item hidden md:inline-flex" title="A memory was just suppressed (Sanhedrin veto)">
|
||||
<span
|
||||
class="inline-flex h-2 w-2 animate-pulse rounded-full bg-decay shadow-[0_0_10px_rgba(239,68,68,0.7)]"
|
||||
></span>
|
||||
<span class="font-medium text-decay">Veto triggered</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.strip-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.strip-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Subtle red wash when a suppression just fired. */
|
||||
.ambient-strip.ambient-flash {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(239, 68, 68, 0.08), rgba(239, 68, 68, 0) 70%),
|
||||
rgba(0, 0, 0, 0.4);
|
||||
border-bottom-color: rgba(239, 68, 68, 0.35);
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Slower "ping" for the intentions pink dot — less aggressive than the
|
||||
default Tailwind animate-ping. */
|
||||
@keyframes ping-slow {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
80%, 100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
:global(.animate-ping-slow) {
|
||||
animation: ping-slow 2.2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient-strip :global(.animate-ping),
|
||||
.ambient-strip :global(.animate-ping-slow),
|
||||
.ambient-strip :global(.animate-pulse) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
421
apps/dashboard/src/lib/components/ContradictionArcs.svelte
Normal file
421
apps/dashboard/src/lib/components/ContradictionArcs.svelte
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* ContradictionArcs — 2D cosmic constellation of conflicting memories.
|
||||
*
|
||||
* Renders each contradiction pair as two nodes connected by an arc.
|
||||
* Arc color = similarity severity (red/amber/yellow).
|
||||
* Arc thickness = min(trust_a, trust_b) — stake.
|
||||
* Node size = trust score. Node hue = node_type (bioluminescent).
|
||||
*
|
||||
* SVG-only (no Three.js) to keep it lightweight and print-friendly.
|
||||
*/
|
||||
|
||||
import {
|
||||
nodeColor,
|
||||
severityColor,
|
||||
severityLabel,
|
||||
nodeRadius,
|
||||
pairOpacity,
|
||||
truncate,
|
||||
} from './contradiction-helpers';
|
||||
|
||||
export interface Contradiction {
|
||||
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; // 0..1
|
||||
trust_b: number; // 0..1
|
||||
similarity: number; // 0..1
|
||||
date_diff_days: number;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
contradictions: Contradiction[];
|
||||
focusedPairIndex?: number | null;
|
||||
onSelectPair?: (index: number | null) => void;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
contradictions,
|
||||
focusedPairIndex = null,
|
||||
onSelectPair,
|
||||
width = 800,
|
||||
height = 600
|
||||
}: Props = $props();
|
||||
|
||||
// --- Polar layout: place pairs around a circle, with the arc crossing the interior. ---
|
||||
// Each pair is given a slot on the circumference; node A and node B are placed
|
||||
// symmetrically around that slot at a small angular offset proportional to similarity
|
||||
// (more similar = farther apart visually, so the tension is readable).
|
||||
// Wrapped in $derived so Svelte 5 re-computes when $state `width`/`height` change,
|
||||
// instead of capturing their initial values once.
|
||||
const geom = $derived.by(() => {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const R = Math.min(width, height) * 0.38;
|
||||
return { cx, cy, R };
|
||||
});
|
||||
|
||||
interface NodePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
trust: number;
|
||||
preview: string;
|
||||
type?: string;
|
||||
created?: string;
|
||||
tags?: string[];
|
||||
memoryId: string;
|
||||
pairIndex: number;
|
||||
side: 'a' | 'b';
|
||||
}
|
||||
|
||||
interface ArcShape {
|
||||
pairIndex: number;
|
||||
path: string;
|
||||
color: string;
|
||||
thickness: number;
|
||||
severity: string;
|
||||
topic: string;
|
||||
similarity: number;
|
||||
dateDiff: number;
|
||||
aPoint: NodePoint;
|
||||
bPoint: NodePoint;
|
||||
// Midpoint used for particle animation origin
|
||||
midX: number;
|
||||
midY: number;
|
||||
}
|
||||
|
||||
const layout = $derived.by((): { nodes: NodePoint[]; arcs: ArcShape[] } => {
|
||||
const nodes: NodePoint[] = [];
|
||||
const arcs: ArcShape[] = [];
|
||||
const n = contradictions.length || 1;
|
||||
|
||||
contradictions.forEach((c, i) => {
|
||||
const slot = (i / n) * Math.PI * 2 - Math.PI / 2;
|
||||
// Angular offset between A and B within the slot — proportional to sim
|
||||
const spread = 0.18 + c.similarity * 0.22;
|
||||
const angA = slot - spread;
|
||||
const angB = slot + spread;
|
||||
|
||||
// Slight radial jitter so the constellation doesn't look like a perfect ring
|
||||
const rA = geom.R + (Math.sin(i * 2.3) * 18);
|
||||
const rB = geom.R + (Math.cos(i * 1.7) * 18);
|
||||
|
||||
const aPoint: NodePoint = {
|
||||
x: geom.cx + Math.cos(angA) * rA,
|
||||
y: geom.cy + Math.sin(angA) * rA,
|
||||
trust: c.trust_a,
|
||||
preview: c.memory_a_preview,
|
||||
type: c.memory_a_type,
|
||||
created: c.memory_a_created,
|
||||
tags: c.memory_a_tags,
|
||||
memoryId: c.memory_a_id,
|
||||
pairIndex: i,
|
||||
side: 'a'
|
||||
};
|
||||
const bPoint: NodePoint = {
|
||||
x: geom.cx + Math.cos(angB) * rB,
|
||||
y: geom.cy + Math.sin(angB) * rB,
|
||||
trust: c.trust_b,
|
||||
preview: c.memory_b_preview,
|
||||
type: c.memory_b_type,
|
||||
created: c.memory_b_created,
|
||||
tags: c.memory_b_tags,
|
||||
memoryId: c.memory_b_id,
|
||||
pairIndex: i,
|
||||
side: 'b'
|
||||
};
|
||||
|
||||
nodes.push(aPoint, bPoint);
|
||||
|
||||
// Arc bends toward the centre — cosmic bridge.
|
||||
// Control point = midpoint pulled toward centre by (1 - similarity * 0.3)
|
||||
const mx = (aPoint.x + bPoint.x) / 2;
|
||||
const my = (aPoint.y + bPoint.y) / 2;
|
||||
const pullStrength = 0.55 - c.similarity * 0.25;
|
||||
const ctrlX = mx + (geom.cx - mx) * pullStrength;
|
||||
const ctrlY = my + (geom.cy - my) * pullStrength;
|
||||
|
||||
const thickness = 1 + Math.min(c.trust_a, c.trust_b) * 4;
|
||||
arcs.push({
|
||||
pairIndex: i,
|
||||
path: `M ${aPoint.x.toFixed(1)} ${aPoint.y.toFixed(1)} Q ${ctrlX.toFixed(1)} ${ctrlY.toFixed(1)} ${bPoint.x.toFixed(1)} ${bPoint.y.toFixed(1)}`,
|
||||
color: severityColor(c.similarity),
|
||||
thickness,
|
||||
severity: severityLabel(c.similarity),
|
||||
topic: c.topic,
|
||||
similarity: c.similarity,
|
||||
dateDiff: c.date_diff_days,
|
||||
aPoint,
|
||||
bPoint,
|
||||
midX: ctrlX,
|
||||
midY: ctrlY
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, arcs };
|
||||
});
|
||||
|
||||
// --- Hover tooltip state ---
|
||||
let hoverNode = $state<NodePoint | null>(null);
|
||||
let hoverArc = $state<ArcShape | null>(null);
|
||||
let mouseX = $state(0);
|
||||
let mouseY = $state(0);
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
mouseY = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
function handleArcClick(i: number) {
|
||||
if (!onSelectPair) return;
|
||||
onSelectPair(focusedPairIndex === i ? null : i);
|
||||
}
|
||||
|
||||
function handleBgClick() {
|
||||
onSelectPair?.(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full" style="aspect-ratio: {width} / {height};">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
viewBox="0 0 {width} {height}"
|
||||
class="w-full h-full"
|
||||
role="img"
|
||||
aria-label="Contradiction constellation map — click an arc to focus, click background to deselect"
|
||||
onmousemove={onMove}
|
||||
onmouseleave={() => { hoverNode = null; hoverArc = null; }}
|
||||
onclick={handleBgClick}
|
||||
>
|
||||
<defs>
|
||||
<!-- Radial glass background -->
|
||||
<radialGradient id="bgGrad" cx="50%" cy="50%" r="65%">
|
||||
<stop offset="0%" stop-color="#10102a" stop-opacity="0.9" />
|
||||
<stop offset="60%" stop-color="#0a0a1a" stop-opacity="0.7" />
|
||||
<stop offset="100%" stop-color="#050510" stop-opacity="0.4" />
|
||||
</radialGradient>
|
||||
|
||||
<!-- Soft arc gradients per severity, for a glow feel -->
|
||||
<linearGradient id="arcGradRed" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ef4444" stop-opacity="0.1" />
|
||||
<stop offset="50%" stop-color="#ef4444" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="arcGradAmber" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0.1" />
|
||||
<stop offset="50%" stop-color="#f59e0b" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="arcGradYellow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#fde047" stop-opacity="0.1" />
|
||||
<stop offset="50%" stop-color="#fde047" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#fde047" stop-opacity="0.1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Node glow filter -->
|
||||
<filter id="nodeGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="arcGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Cosmic background -->
|
||||
<rect x="0" y="0" {width} {height} fill="url(#bgGrad)" rx="16" />
|
||||
|
||||
<!-- Subtle reference ring so the layout reads as a constellation -->
|
||||
<circle
|
||||
cx={geom.cx}
|
||||
cy={geom.cy}
|
||||
r={geom.R}
|
||||
fill="none"
|
||||
stroke="#6366f1"
|
||||
stroke-opacity="0.06"
|
||||
stroke-dasharray="2 6"
|
||||
/>
|
||||
<circle cx={geom.cx} cy={geom.cy} r="3" fill="#6366f1" opacity="0.4" />
|
||||
|
||||
<!-- Arcs (cosmic bridges) — rendered before nodes so nodes sit on top -->
|
||||
{#each layout.arcs as arc (arc.pairIndex)}
|
||||
{@const op = pairOpacity(arc.pairIndex, focusedPairIndex)}
|
||||
{@const isFocused = focusedPairIndex === arc.pairIndex}
|
||||
<!-- Outer halo -->
|
||||
<path
|
||||
d={arc.path}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
stroke-width={arc.thickness * 3}
|
||||
stroke-opacity={0.08 * op}
|
||||
stroke-linecap="round"
|
||||
filter="url(#arcGlow)"
|
||||
pointer-events="none"
|
||||
/>
|
||||
<!-- Primary arc -->
|
||||
<path
|
||||
d={arc.path}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
stroke-width={arc.thickness * (isFocused ? 1.6 : 1)}
|
||||
stroke-opacity={(isFocused ? 1 : 0.72) * op}
|
||||
stroke-linecap="round"
|
||||
class="cursor-pointer transition-all duration-200"
|
||||
onclick={(e) => { e.stopPropagation(); handleArcClick(arc.pairIndex); }}
|
||||
onmouseenter={() => (hoverArc = arc)}
|
||||
onmouseleave={() => (hoverArc = null)}
|
||||
aria-label="contradiction {arc.pairIndex + 1}: {arc.topic}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(arc.pairIndex); }}
|
||||
/>
|
||||
<!-- Particle: a small dashed overlay that drifts along the arc to show tension flow -->
|
||||
<path
|
||||
d={arc.path}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
stroke-width={Math.max(1, arc.thickness * 0.6)}
|
||||
stroke-opacity={0.85 * op}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="2 14"
|
||||
class="arc-particle"
|
||||
style="animation-duration: {4 + (arc.pairIndex % 5)}s"
|
||||
pointer-events="none"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Nodes -->
|
||||
{#each layout.nodes as node, i (node.memoryId + '-' + node.side + '-' + i)}
|
||||
{@const op = pairOpacity(node.pairIndex, focusedPairIndex)}
|
||||
{@const isFocused = focusedPairIndex === node.pairIndex}
|
||||
{@const r = nodeRadius(node.trust)}
|
||||
{@const fill = nodeColor(node.type)}
|
||||
<!-- Outer glow -->
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={r * 2.2}
|
||||
fill={fill}
|
||||
opacity={0.12 * op}
|
||||
filter="url(#nodeGlow)"
|
||||
pointer-events="none"
|
||||
/>
|
||||
<!-- Core -->
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={r}
|
||||
fill={fill}
|
||||
opacity={op}
|
||||
stroke="#ffffff"
|
||||
stroke-opacity={isFocused ? 0.85 : 0.25}
|
||||
stroke-width={isFocused ? 2 : 1}
|
||||
class="cursor-pointer transition-all duration-200"
|
||||
onmouseenter={() => (hoverNode = node)}
|
||||
onmouseleave={() => (hoverNode = null)}
|
||||
onclick={(e) => { e.stopPropagation(); handleArcClick(node.pairIndex); }}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="memory {truncate(node.preview, 40)}"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(node.pairIndex); }}
|
||||
/>
|
||||
<!-- Label (truncated) — only shown for focused pair to avoid clutter -->
|
||||
{#if isFocused}
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y - r - 8}
|
||||
fill="#e0e0ff"
|
||||
font-size="10"
|
||||
font-family="var(--font-mono, monospace)"
|
||||
text-anchor="middle"
|
||||
pointer-events="none"
|
||||
>{truncate(node.preview, 40)}</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Legend top-left -->
|
||||
<g transform="translate(16, 16)" pointer-events="none">
|
||||
<rect x="0" y="0" width="170" height="66" rx="8"
|
||||
fill="#0a0a1a" fill-opacity="0.6" stroke="#6366f1" stroke-opacity="0.12" />
|
||||
<text x="10" y="16" fill="#7a7aaa" font-size="10" font-family="var(--font-mono, monospace)">SEVERITY</text>
|
||||
<circle cx="16" cy="30" r="4" fill="#ef4444" />
|
||||
<text x="26" y="33" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">strong (>0.7)</text>
|
||||
<circle cx="16" cy="44" r="4" fill="#f59e0b" />
|
||||
<text x="26" y="47" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">moderate (0.5-0.7)</text>
|
||||
<circle cx="16" cy="58" r="4" fill="#fde047" />
|
||||
<text x="26" y="61" fill="#e0e0ff" font-size="10" font-family="var(--font-mono, monospace)">mild (0.3-0.5)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Hover tooltip (absolute, HTML for readability) -->
|
||||
{#if hoverNode}
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 glass-panel rounded-lg px-3 py-2 text-xs max-w-xs shadow-xl"
|
||||
style="left: {Math.max(0, Math.min(mouseX + 12, width - 240))}px; top: {Math.max(0, Math.min(mouseY - 8, height - 120))}px;"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" style="background: {nodeColor(hoverNode.type)}"></div>
|
||||
<span class="text-bright font-semibold">{hoverNode.type ?? 'memory'}</span>
|
||||
<span class="text-muted ml-auto">trust {(hoverNode.trust * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="text-text mb-1">{hoverNode.preview}</div>
|
||||
{#if hoverNode.created}
|
||||
<div class="text-muted text-[10px]">created {hoverNode.created}</div>
|
||||
{/if}
|
||||
{#if hoverNode.tags && hoverNode.tags.length > 0}
|
||||
<div class="text-muted text-[10px] mt-1">
|
||||
{hoverNode.tags.slice(0, 4).join(' · ')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if hoverArc}
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 glass-panel rounded-lg px-3 py-2 text-xs max-w-xs shadow-xl"
|
||||
style="left: {Math.max(0, Math.min(mouseX + 12, width - 240))}px; top: {Math.max(0, Math.min(mouseY - 8, height - 120))}px;"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="w-2 h-2 rounded-full" style="background: {hoverArc.color}"></div>
|
||||
<span class="text-bright font-semibold">{hoverArc.severity} conflict</span>
|
||||
</div>
|
||||
<div class="text-dim">topic: <span class="text-text">{hoverArc.topic}</span></div>
|
||||
<div class="text-muted text-[10px] mt-1">
|
||||
similarity {(hoverArc.similarity * 100).toFixed(0)}% · {hoverArc.dateDiff}d apart
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes arc-drift {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: -32; }
|
||||
}
|
||||
:global(.arc-particle) {
|
||||
animation-name: arc-drift;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
</style>
|
||||
211
apps/dashboard/src/lib/components/DreamInsightCard.svelte
Normal file
211
apps/dashboard/src/lib/components/DreamInsightCard.svelte
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<!--
|
||||
DreamInsightCard — single insight from a dream cycle.
|
||||
High-novelty insights (>0.7) get a golden glow. Low-novelty (<0.3) muted.
|
||||
Source memory IDs are clickable → navigate to /memories/[id].
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import type { DreamInsight } from '$types';
|
||||
import {
|
||||
clamp01,
|
||||
noveltyBand,
|
||||
formatConfidencePct,
|
||||
firstSourceIds,
|
||||
extraSourceCount,
|
||||
sourceMemoryHref,
|
||||
shortMemoryId,
|
||||
} from './dream-helpers';
|
||||
|
||||
interface Props {
|
||||
insight: DreamInsight;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { insight, index = 0 }: Props = $props();
|
||||
|
||||
let novelty = $derived(clamp01(insight.noveltyScore));
|
||||
let confidence = $derived(clamp01(insight.confidence));
|
||||
let band = $derived(noveltyBand(insight.noveltyScore));
|
||||
let isHighNovelty = $derived(band === 'high');
|
||||
let isLowNovelty = $derived(band === 'low');
|
||||
let firstSources = $derived(firstSourceIds(insight.sourceMemories, 2));
|
||||
let extraCount = $derived(extraSourceCount(insight.sourceMemories, 2));
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
connection: '#818cf8',
|
||||
pattern: '#ec4899',
|
||||
contradiction: '#ef4444',
|
||||
synthesis: '#c084fc',
|
||||
emergence: '#f59e0b',
|
||||
cluster: '#06b6d4'
|
||||
};
|
||||
|
||||
let typeColor = $derived(TYPE_COLORS[insight.type?.toLowerCase() ?? ''] ?? '#a855f7');
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="insight-card glass-panel rounded-xl p-4 space-y-3"
|
||||
class:high-novelty={isHighNovelty}
|
||||
class:low-novelty={isLowNovelty}
|
||||
style="--insight-color: {typeColor}; --enter-delay: {index * 60}ms"
|
||||
>
|
||||
<!-- Type badge + novelty halo -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="text-[10px] uppercase tracking-[0.12em] font-semibold px-2 py-0.5 rounded-full"
|
||||
style="background: {typeColor}22; color: {typeColor}; border: 1px solid {typeColor}55"
|
||||
>
|
||||
{insight.type ?? 'insight'}
|
||||
</span>
|
||||
{#if isHighNovelty}
|
||||
<span class="text-[10px] text-warning font-semibold flex items-center gap-1">
|
||||
<span class="sparkle">✦</span> novel
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Insight text -->
|
||||
<p class="text-sm text-bright font-semibold leading-snug">
|
||||
{insight.insight}
|
||||
</p>
|
||||
|
||||
<!-- Novelty bar -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between text-[10px] text-dim uppercase tracking-wider">
|
||||
<span>Novelty</span>
|
||||
<span class="tabular-nums text-text/80">{novelty.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="novelty-track">
|
||||
<div
|
||||
class="novelty-fill"
|
||||
style="width: {novelty * 100}%; background: linear-gradient(90deg, {typeColor}, var(--color-dream-glow))"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confidence -->
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-dim">Confidence</span>
|
||||
<span
|
||||
class="tabular-nums font-semibold"
|
||||
style="color: {confidence > 0.7 ? '#10b981' : confidence > 0.4 ? '#f59e0b' : '#ef4444'}"
|
||||
>
|
||||
{formatConfidencePct(confidence)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Source memories -->
|
||||
{#if firstSources.length > 0}
|
||||
<div class="pt-2 border-t border-white/5 space-y-1.5">
|
||||
<div class="text-[10px] text-dim uppercase tracking-wider">
|
||||
Sources
|
||||
{#if extraCount > 0}
|
||||
<span class="text-muted">(+{extraCount})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each firstSources as id (id)}
|
||||
<a
|
||||
href={sourceMemoryHref(id, base)}
|
||||
class="source-chip font-mono text-[10px] px-2 py-0.5 rounded"
|
||||
title="Open memory {id}"
|
||||
>
|
||||
{shortMemoryId(id)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.insight-card {
|
||||
position: relative;
|
||||
border: 1px solid color-mix(in srgb, var(--insight-color) 20%, transparent);
|
||||
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
border-color 220ms ease, box-shadow 220ms ease;
|
||||
animation: card-in 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
animation-delay: var(--enter-delay, 0ms);
|
||||
}
|
||||
|
||||
.insight-card:hover {
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
border-color: color-mix(in srgb, var(--insight-color) 45%, transparent);
|
||||
}
|
||||
|
||||
.insight-card.high-novelty {
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(245, 158, 11, 0.25),
|
||||
0 0 24px -4px rgba(245, 158, 11, 0.45),
|
||||
0 0 60px -12px rgba(245, 158, 11, 0.25),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
background:
|
||||
radial-gradient(at top right, rgba(245, 158, 11, 0.08), transparent 50%),
|
||||
rgba(10, 10, 26, 0.8);
|
||||
}
|
||||
|
||||
.insight-card.low-novelty {
|
||||
opacity: 0.6;
|
||||
filter: saturate(0.7);
|
||||
}
|
||||
|
||||
.insight-card.low-novelty:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(1);
|
||||
}
|
||||
|
||||
.novelty-track {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.novelty-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--insight-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.source-chip {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
color: var(--color-synapse-glow);
|
||||
text-decoration: none;
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
|
||||
.source-chip:hover {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
display: inline-block;
|
||||
animation: sparkle-spin 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.insight-card { animation: none; }
|
||||
.sparkle { animation: none; }
|
||||
}
|
||||
</style>
|
||||
539
apps/dashboard/src/lib/components/DreamStageReplay.svelte
Normal file
539
apps/dashboard/src/lib/components/DreamStageReplay.svelte
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
<!--
|
||||
DreamStageReplay — Visual playback of a single dream-consolidation stage.
|
||||
|
||||
5 stages (per MemoryDreamer):
|
||||
1. Replay — floating memory cards arrange themselves into a grid
|
||||
2. Cross-reference — edges connect cards (SVG lines)
|
||||
3. Strengthen — cards pulse, brighten, gain glow
|
||||
4. Prune — low-retention cards fade & dissolve
|
||||
5. Transfer — cards migrate from episodic (left) → semantic (right)
|
||||
|
||||
Pure CSS transforms + SVG + animations. No Three.js.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { DreamResult } from '$types';
|
||||
import { clampStage } from './dream-helpers';
|
||||
|
||||
interface Props {
|
||||
stage: number; // 1..5
|
||||
dreamResult: DreamResult | null;
|
||||
}
|
||||
|
||||
let { stage, dreamResult }: Props = $props();
|
||||
|
||||
const STAGES = [
|
||||
{
|
||||
num: 1,
|
||||
name: 'Replay',
|
||||
color: '#818cf8',
|
||||
desc: 'Hippocampal replay: tagged memories surface for consolidation.'
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
name: 'Cross-reference',
|
||||
color: '#a855f7',
|
||||
desc: 'Semantic proximity check — new edges discovered across memories.'
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
name: 'Strengthen',
|
||||
color: '#c084fc',
|
||||
desc: 'Co-activated memories strengthen; FSRS stability grows.'
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
name: 'Prune',
|
||||
color: '#ef4444',
|
||||
desc: 'Low-retention redundant memories compressed or released.'
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
name: 'Transfer',
|
||||
color: '#10b981',
|
||||
desc: 'Episodic → semantic consolidation (hippocampus → cortex).'
|
||||
}
|
||||
];
|
||||
|
||||
// Lock stage to valid range (handles NaN / negatives / >5)
|
||||
let stageIdx = $derived(clampStage(stage));
|
||||
let current = $derived(STAGES[stageIdx - 1]);
|
||||
|
||||
// Derive card count from dream result (clamp 6..12 for visual density)
|
||||
let cardCount = $derived.by(() => {
|
||||
if (!dreamResult) return 8;
|
||||
const n = dreamResult.memoriesReplayed ?? 8;
|
||||
return Math.max(6, Math.min(12, n));
|
||||
});
|
||||
|
||||
// Connection count from dream result
|
||||
let connectionCount = $derived.by(() => {
|
||||
if (!dreamResult) return 5;
|
||||
const n = dreamResult.stats?.newConnectionsFound ?? 5;
|
||||
return Math.max(3, Math.min(cardCount, n));
|
||||
});
|
||||
|
||||
let strengthenedCount = $derived.by(() => {
|
||||
if (!dreamResult) return Math.ceil(cardCount * 0.5);
|
||||
const n = dreamResult.stats?.memoriesStrengthened ?? Math.ceil(cardCount * 0.5);
|
||||
return Math.max(1, Math.min(cardCount, n));
|
||||
});
|
||||
|
||||
let prunedCount = $derived.by(() => {
|
||||
if (!dreamResult) return Math.ceil(cardCount * 0.25);
|
||||
const n = dreamResult.stats?.memoriesCompressed ?? Math.ceil(cardCount * 0.25);
|
||||
return Math.max(1, Math.min(Math.floor(cardCount / 2), n));
|
||||
});
|
||||
|
||||
// Deterministic pseudo-random for card positions so stage changes don't re-randomize
|
||||
function seed(i: number, salt = 0): number {
|
||||
const x = Math.sin((i + 1) * 9301 + 49297 + salt * 233) * 233280;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
// Layout: cards on a circle-ish grid in the center.
|
||||
// In stage 5, we'll override X based on transfer side.
|
||||
interface CardPos {
|
||||
id: number;
|
||||
x: number; // 0..100 percent
|
||||
y: number; // 0..100 percent
|
||||
pruned: boolean;
|
||||
strengthened: boolean;
|
||||
transferIsSemantic: boolean;
|
||||
}
|
||||
|
||||
let cards = $derived.by<CardPos[]>(() => {
|
||||
const arr: CardPos[] = [];
|
||||
const cols = Math.ceil(Math.sqrt(cardCount));
|
||||
const rows = Math.ceil(cardCount / cols);
|
||||
for (let i = 0; i < cardCount; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const baseX = 20 + (col / Math.max(1, cols - 1)) * 60;
|
||||
const baseY = 20 + (row / Math.max(1, rows - 1)) * 60;
|
||||
// jitter
|
||||
const jx = (seed(i, 1) - 0.5) * 8;
|
||||
const jy = (seed(i, 2) - 0.5) * 8;
|
||||
arr.push({
|
||||
id: i,
|
||||
x: baseX + jx,
|
||||
y: baseY + jy,
|
||||
pruned: i < prunedCount,
|
||||
strengthened: i < strengthenedCount,
|
||||
transferIsSemantic: i % 2 === 0
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
// Edges for stage 2 (and persist for 3+). Pick random pairs from available cards.
|
||||
interface Edge {
|
||||
a: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
let edges = $derived.by<Edge[]>(() => {
|
||||
const e: Edge[] = [];
|
||||
const n = cards.length;
|
||||
for (let k = 0; k < connectionCount; k++) {
|
||||
const a = Math.floor(seed(k, 7) * n);
|
||||
let b = Math.floor(seed(k, 11) * n);
|
||||
if (b === a) b = (a + 1) % n;
|
||||
e.push({ a, b });
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
// Card displayed position depending on stage
|
||||
function cardX(card: CardPos): number {
|
||||
if (stageIdx === 5) {
|
||||
// Migrate: episodic (left 15..35%) → semantic (right 65..85%)
|
||||
const target = card.transferIsSemantic ? 75 : 25;
|
||||
return target + (seed(card.id, 5) - 0.5) * 12;
|
||||
}
|
||||
return card.x;
|
||||
}
|
||||
|
||||
function cardY(card: CardPos): number {
|
||||
if (stageIdx === 5) {
|
||||
return 25 + seed(card.id, 6) * 50;
|
||||
}
|
||||
return card.y;
|
||||
}
|
||||
|
||||
function cardOpacity(card: CardPos): number {
|
||||
if (stageIdx === 4 && card.pruned) return 0;
|
||||
if (stageIdx === 5 && card.pruned) return 0.15;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function cardScale(card: CardPos): number {
|
||||
if (stageIdx === 3 && card.strengthened) return 1.18;
|
||||
if (stageIdx === 4 && card.pruned) return 0.6;
|
||||
return 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="replay-stage glass-panel rounded-2xl overflow-hidden relative">
|
||||
<!-- Stage header -->
|
||||
<header class="flex items-center justify-between px-5 py-3 border-b border-white/5 relative z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="stage-badge w-9 h-9 rounded-full flex items-center justify-center font-mono font-bold text-sm"
|
||||
style="
|
||||
background: color-mix(in srgb, {current.color} 20%, transparent);
|
||||
color: {current.color};
|
||||
border: 1.5px solid {current.color};
|
||||
box-shadow: 0 0 16px color-mix(in srgb, {current.color} 40%, transparent);
|
||||
"
|
||||
>
|
||||
{current.num}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-bright tracking-wide">{current.name}</div>
|
||||
<div class="text-[11px] text-dim leading-snug max-w-md">{current.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[10px] text-dim uppercase tracking-[0.15em] hidden sm:block">
|
||||
Stage {current.num} / 5
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stage canvas -->
|
||||
<div
|
||||
class="stage-canvas"
|
||||
style="--stage-color: {current.color}"
|
||||
aria-label="Dream stage {current.num} — {current.name}"
|
||||
>
|
||||
<!-- Left/right labels for transfer stage -->
|
||||
{#if stageIdx === 5}
|
||||
<div class="transfer-label episodic">
|
||||
<span class="label-tag">Episodic</span>
|
||||
<span class="label-sub">hippocampus</span>
|
||||
</div>
|
||||
<div class="transfer-label semantic">
|
||||
<span class="label-tag">Semantic</span>
|
||||
<span class="label-sub">cortex</span>
|
||||
</div>
|
||||
<div class="divider-line"></div>
|
||||
{/if}
|
||||
|
||||
<!-- SVG edges (visible from stage 2 onward) -->
|
||||
<svg
|
||||
class="edges-layer"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each edges as edge, i (edge.a + '-' + edge.b + '-' + i)}
|
||||
{@const a = cards[edge.a]}
|
||||
{@const b = cards[edge.b]}
|
||||
{#if a && b}
|
||||
{@const x1 = cardX(a)}
|
||||
{@const y1 = cardY(a)}
|
||||
{@const x2 = cardX(b)}
|
||||
{@const y2 = cardY(b)}
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={current.color}
|
||||
stroke-width={stageIdx === 2 ? 0.25 : stageIdx === 3 ? 0.35 : 0.2}
|
||||
stroke-opacity={stageIdx < 2 ? 0 : stageIdx === 4 ? 0.25 : stageIdx === 5 ? 0.15 : 0.6}
|
||||
stroke-dasharray={stageIdx === 2 ? '1.2 0.8' : 'none'}
|
||||
class="edge-line"
|
||||
style="--edge-delay: {i * 80}ms"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Memory cards -->
|
||||
{#each cards as card (card.id)}
|
||||
<div
|
||||
class="memory-card"
|
||||
class:is-pulsing={stageIdx === 3 && card.strengthened}
|
||||
class:is-pruning={stageIdx === 4 && card.pruned}
|
||||
class:is-transferring={stageIdx === 5}
|
||||
class:semantic-side={stageIdx === 5 && card.transferIsSemantic}
|
||||
style="
|
||||
left: {cardX(card)}%;
|
||||
top: {cardY(card)}%;
|
||||
opacity: {cardOpacity(card)};
|
||||
--card-scale: {cardScale(card)};
|
||||
--card-delay: {card.id * 40}ms;
|
||||
--card-hue: {seed(card.id, 3) * 60 - 30}deg;
|
||||
"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<div class="card-dot"></div>
|
||||
<div class="card-bar"></div>
|
||||
<div class="card-bar short"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Ambient pulse for stage 1 (replay) -->
|
||||
{#if stageIdx === 1}
|
||||
<div class="replay-pulse" aria-hidden="true"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stage footer with stats -->
|
||||
<footer class="flex flex-wrap gap-x-6 gap-y-1 px-5 py-3 border-t border-white/5 text-[11px] text-dim">
|
||||
{#if stageIdx === 1}
|
||||
<span>Replaying <b class="text-bright tabular-nums">{dreamResult?.memoriesReplayed ?? cardCount}</b> memories</span>
|
||||
{:else if stageIdx === 2}
|
||||
<span>New connections found: <b class="text-bright tabular-nums">{dreamResult?.stats?.newConnectionsFound ?? connectionCount}</b></span>
|
||||
{:else if stageIdx === 3}
|
||||
<span>Strengthened: <b class="text-bright tabular-nums">{dreamResult?.stats?.memoriesStrengthened ?? strengthenedCount}</b></span>
|
||||
{:else if stageIdx === 4}
|
||||
<span>Compressed: <b class="text-bright tabular-nums">{dreamResult?.stats?.memoriesCompressed ?? prunedCount}</b></span>
|
||||
{:else if stageIdx === 5}
|
||||
<span>Connections persisted: <b class="text-bright tabular-nums">{dreamResult?.connectionsPersisted ?? 0}</b></span>
|
||||
<span>Insights: <b class="text-bright tabular-nums">{dreamResult?.stats?.insightsGenerated ?? 0}</b></span>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.replay-stage {
|
||||
border: 1px solid rgba(168, 85, 247, 0.18);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
|
||||
0 8px 36px -8px rgba(0, 0, 0, 0.55),
|
||||
0 0 48px -16px rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
|
||||
.stage-canvas {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(at 50% 50%, color-mix(in srgb, var(--stage-color) 10%, transparent), transparent 60%),
|
||||
radial-gradient(at 20% 80%, rgba(99, 102, 241, 0.08), transparent 50%),
|
||||
#08081a;
|
||||
}
|
||||
|
||||
.edges-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-line {
|
||||
transition: stroke-opacity 520ms ease, stroke-width 520ms ease,
|
||||
x1 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
y1 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
x2 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
y2 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.memory-card {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 32px;
|
||||
transform: translate(-50%, -50%) scale(var(--card-scale, 1));
|
||||
transition:
|
||||
left 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
top 600ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
opacity 500ms ease;
|
||||
transition-delay: var(--card-delay, 0ms);
|
||||
animation: card-float 6s ease-in-out infinite;
|
||||
animation-delay: var(--card-delay, 0ms);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--stage-color) 30%, transparent),
|
||||
color-mix(in srgb, var(--stage-color) 10%, transparent)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--stage-color) 50%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 8px color-mix(in srgb, var(--stage-color) 30%, transparent);
|
||||
filter: hue-rotate(var(--card-hue, 0deg));
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--stage-color) 90%, white);
|
||||
box-shadow: 0 0 6px var(--stage-color);
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.card-bar {
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: color-mix(in srgb, var(--stage-color) 70%, transparent);
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.card-bar.short {
|
||||
width: 50%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.memory-card.is-pulsing {
|
||||
animation: card-float 6s ease-in-out infinite, card-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.memory-card.is-pulsing .card-inner {
|
||||
border-color: var(--color-dream-glow);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--color-dream-glow) 40%, transparent),
|
||||
color-mix(in srgb, var(--color-dream) 25%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.12),
|
||||
0 0 22px color-mix(in srgb, var(--color-dream-glow) 60%, transparent),
|
||||
0 0 44px color-mix(in srgb, var(--color-dream) 35%, transparent);
|
||||
}
|
||||
|
||||
.memory-card.is-pruning .card-inner {
|
||||
animation: dissolve 1.2s ease-out forwards;
|
||||
}
|
||||
|
||||
.memory-card.is-transferring .card-inner {
|
||||
border-color: #10b981;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(16, 185, 129, 0.35),
|
||||
rgba(16, 185, 129, 0.12)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 14px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.memory-card.is-transferring.semantic-side .card-inner {
|
||||
border-color: #c084fc;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(192, 132, 252, 0.35),
|
||||
rgba(168, 85, 247, 0.15)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 14px rgba(192, 132, 252, 0.5);
|
||||
}
|
||||
|
||||
.replay-pulse {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60%;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, color-mix(in srgb, var(--stage-color) 25%, transparent), transparent 60%);
|
||||
filter: blur(30px);
|
||||
animation: pulse-in 3s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.transfer-label {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.transfer-label.episodic {
|
||||
left: 6%;
|
||||
}
|
||||
|
||||
.transfer-label.semantic {
|
||||
right: 6%;
|
||||
}
|
||||
|
||||
.label-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e0e0ff;
|
||||
}
|
||||
|
||||
.transfer-label.episodic .label-tag {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.transfer-label.semantic .label-tag {
|
||||
border-color: rgba(192, 132, 252, 0.5);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.label-sub {
|
||||
font-size: 9px;
|
||||
color: var(--color-dim);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
position: absolute;
|
||||
top: 15%;
|
||||
bottom: 15%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, transparent, rgba(168, 85, 247, 0.35), transparent);
|
||||
transform: translateX(-0.5px);
|
||||
}
|
||||
|
||||
@keyframes card-float {
|
||||
0%, 100% { translate: 0 0; }
|
||||
25% { translate: 2px -3px; }
|
||||
50% { translate: -2px 2px; }
|
||||
75% { translate: 3px 1px; }
|
||||
}
|
||||
|
||||
@keyframes card-pulse {
|
||||
0%, 100% { filter: brightness(1) hue-rotate(var(--card-hue, 0deg)); }
|
||||
50% { filter: brightness(1.3) hue-rotate(var(--card-hue, 0deg)); }
|
||||
}
|
||||
|
||||
@keyframes dissolve {
|
||||
0% { opacity: 1; transform: scale(1); filter: blur(0); }
|
||||
60% { opacity: 0.3; filter: blur(2px); }
|
||||
100% { opacity: 0; transform: scale(0.5); filter: blur(6px); }
|
||||
}
|
||||
|
||||
@keyframes pulse-in {
|
||||
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 0.7; transform: translate(-50%, -50%) scale(1.15); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.memory-card { animation: none; }
|
||||
.replay-pulse { animation: none; }
|
||||
.memory-card.is-pulsing { animation: none; }
|
||||
}
|
||||
</style>
|
||||
192
apps/dashboard/src/lib/components/DuplicateCluster.svelte
Normal file
192
apps/dashboard/src/lib/components/DuplicateCluster.svelte
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<!--
|
||||
DuplicateCluster — renders a single cosine-similarity cluster from the
|
||||
`find_duplicates` MCP tool. Shows similarity bar (color-coded by severity),
|
||||
stacked memory cards with type/retention/tags/date, and action controls
|
||||
(Merge all → highest-retention winner, Review → expand, Dismiss → hide).
|
||||
|
||||
Pure helpers live in `./duplicates-helpers.ts` and are unit-tested there.
|
||||
Keep this file focused on rendering + glue.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import {
|
||||
similarityBandColor,
|
||||
similarityBandLabel,
|
||||
retentionColor,
|
||||
pickWinner,
|
||||
previewContent,
|
||||
formatDate,
|
||||
safeTags,
|
||||
} from './duplicates-helpers';
|
||||
|
||||
interface ClusterMemory {
|
||||
id: string;
|
||||
content: string;
|
||||
nodeType: string;
|
||||
tags: string[];
|
||||
retention: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
similarity: number;
|
||||
memories: ClusterMemory[];
|
||||
suggestedAction: 'merge' | 'review';
|
||||
onDismiss?: () => void;
|
||||
onMerge?: (winnerId: string, loserIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { similarity, memories, suggestedAction, onDismiss, onMerge }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
// Winner = highest retention; others get merged into it. Stable tie-break
|
||||
// (first-wins). pickWinner returns null for empty input — render-guarded below.
|
||||
const winner = $derived(pickWinner(memories));
|
||||
const losers = $derived(
|
||||
winner ? memories.filter((m) => m.id !== winner.id).map((m) => m.id) : []
|
||||
);
|
||||
|
||||
function handleMerge() {
|
||||
if (onMerge && winner) onMerge(winner.id, losers);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if memories.length > 0 && winner}
|
||||
<div
|
||||
class="glass-panel rounded-2xl p-5 space-y-4 transition-all duration-300 hover:border-synapse/20"
|
||||
>
|
||||
<!-- Header row: similarity bar + suggested action badge -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-sm font-semibold"
|
||||
style="color: {similarityBandColor(similarity)}"
|
||||
>
|
||||
{(similarity * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span class="text-xs text-dim">{similarityBandLabel(similarity)}</span>
|
||||
<span class="text-xs text-muted">· {memories.length} memories</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-deep/60"
|
||||
role="progressbar"
|
||||
aria-label="Cosine similarity"
|
||||
aria-valuenow={Math.round(similarity * 100)}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
style="width: {(similarity * 100).toFixed(1)}%; background: {similarityBandColor(
|
||||
similarity
|
||||
)}; box-shadow: 0 0 12px {similarityBandColor(similarity)}66"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggested action badge — dream (review) or recall (merge) -->
|
||||
<span
|
||||
class="flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium {suggestedAction ===
|
||||
'merge'
|
||||
? 'border-recall/40 bg-recall/10 text-recall'
|
||||
: 'border-dream-glow/40 bg-dream/10 text-dream-glow'}"
|
||||
>
|
||||
Suggested: {suggestedAction === 'merge' ? 'Merge' : 'Review'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Stacked memory cards -->
|
||||
<div class="space-y-2">
|
||||
{#each memories as memory (memory.id)}
|
||||
<div
|
||||
class="group flex items-start gap-3 rounded-xl border border-synapse/5 bg-white/[0.02] p-3 transition-all duration-200 hover:border-synapse/20 hover:bg-white/[0.04] {memory.id ===
|
||||
winner.id
|
||||
? 'ring-1 ring-recall/30'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Type dot -->
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}"
|
||||
title={memory.nodeType}
|
||||
></span>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-1.5">
|
||||
<!-- Type + tags + winner flag -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-xs text-dim">{memory.nodeType}</span>
|
||||
{#if memory.id === winner.id}
|
||||
<span class="rounded bg-recall/15 px-1.5 py-0.5 text-[10px] font-medium text-recall">
|
||||
WINNER
|
||||
</span>
|
||||
{/if}
|
||||
{#each safeTags(memory.tags, 4) as tag}
|
||||
<span class="rounded bg-white/[0.04] px-1.5 py-0.5 text-[10px] text-muted"
|
||||
>{tag}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Content preview (or full content if expanded) -->
|
||||
<p class="text-sm text-text leading-relaxed {expanded ? 'whitespace-pre-wrap' : ''}">
|
||||
{expanded ? memory.content : previewContent(memory.content)}
|
||||
</p>
|
||||
|
||||
<!-- Date (empty string for invalid/missing — no "Invalid Date") -->
|
||||
{#if formatDate(memory.createdAt)}
|
||||
<div class="text-[11px] text-muted">
|
||||
{formatDate(memory.createdAt)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Retention bar + percent (right rail) -->
|
||||
<div class="flex flex-shrink-0 flex-col items-end gap-1">
|
||||
<div class="h-1.5 w-12 overflow-hidden rounded-full bg-deep">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width: {memory.retention * 100}%; background: {retentionColor(
|
||||
memory.retention
|
||||
)}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[11px] text-muted">
|
||||
{(memory.retention * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Actions — native <button> elements, fully keyboard-accessible. -->
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMerge}
|
||||
aria-label="Merge all memories into the highest-retention winner"
|
||||
class="rounded-lg bg-recall/20 px-3 py-1.5 text-xs font-medium text-recall transition hover:bg-recall/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-recall/60"
|
||||
title="Merge all into highest-retention memory ({(winner.retention * 100).toFixed(0)}%)"
|
||||
>
|
||||
Merge all → winner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
class="rounded-lg bg-dream/20 px-3 py-1.5 text-xs font-medium text-dream-glow transition hover:bg-dream/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-dream-glow/60"
|
||||
>
|
||||
{expanded ? 'Collapse' : 'Review'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDismiss}
|
||||
aria-label="Dismiss cluster for this session"
|
||||
class="ml-auto rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Dismiss cluster
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
157
apps/dashboard/src/lib/components/EvidenceCard.svelte
Normal file
157
apps/dashboard/src/lib/components/EvidenceCard.svelte
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
roleMetaFor,
|
||||
trustColor,
|
||||
trustPercent,
|
||||
nodeTypeColor,
|
||||
formatDate,
|
||||
shortenId,
|
||||
type EvidenceRole,
|
||||
} from './reasoning-helpers';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
trust: number; // 0-1
|
||||
date: string; // ISO
|
||||
role: EvidenceRole;
|
||||
preview: string;
|
||||
nodeType?: string;
|
||||
index?: number; // for staggered animation
|
||||
}
|
||||
|
||||
let { id, trust, date, role, preview, nodeType, index = 0 }: Props = $props();
|
||||
|
||||
// Clamp for display — delegated to pure helper for testability.
|
||||
const trustPct = $derived(trustPercent(trust));
|
||||
const meta = $derived(roleMetaFor(role));
|
||||
const shortId = $derived(shortenId(id));
|
||||
const typeColor = $derived(nodeTypeColor(nodeType));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="evidence-card glass rounded-xl p-4 space-y-3 transition relative"
|
||||
class:contradicting={role === 'contradicting'}
|
||||
class:primary={role === 'primary'}
|
||||
class:superseded={role === 'superseded'}
|
||||
style="animation-delay: {index * 80}ms;"
|
||||
data-evidence-id={id}
|
||||
>
|
||||
<!-- Role banner + id -->
|
||||
<div class="flex items-center justify-between text-[10px] uppercase tracking-wider">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="role-pill px-2 py-0.5 rounded text-[10px]">
|
||||
<span class="mr-1">{meta.icon}</span>{meta.label}
|
||||
</span>
|
||||
{#if nodeType}
|
||||
<span class="px-1.5 py-0.5 rounded bg-white/[0.04]" style="color: {typeColor}">
|
||||
{nodeType}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-muted font-mono text-[10px]" title={id}>#{shortId}</span>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<p class="text-sm text-text leading-relaxed line-clamp-4">{preview}</p>
|
||||
|
||||
<!-- Trust bar -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-[10px]">
|
||||
<span class="text-dim uppercase tracking-wider">Trust</span>
|
||||
<span class="font-mono" style="color: {trustColor(trust)}">{trustPct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-deep rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700 trust-fill"
|
||||
style="width: {trustPct}%; background: {trustColor(trust)}; box-shadow: 0 0 8px {trustColor(trust)}80;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="flex items-center justify-between text-[10px] text-muted pt-1">
|
||||
<span>{formatDate(date)}</span>
|
||||
<span class="font-mono opacity-60">FSRS · reps × retention</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.evidence-card {
|
||||
animation: card-rise 600ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.evidence-card.primary {
|
||||
border-color: rgba(99, 102, 241, 0.35) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.04),
|
||||
0 0 32px rgba(99, 102, 241, 0.18),
|
||||
0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.evidence-card.contradicting {
|
||||
border-color: rgba(239, 68, 68, 0.45) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.03),
|
||||
0 0 28px rgba(239, 68, 68, 0.2),
|
||||
0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.evidence-card.superseded {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.evidence-card.superseded:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.role-pill {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #c7cbff;
|
||||
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
|
||||
.evidence-card.contradicting .role-pill {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: #fecaca;
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.evidence-card.primary .role-pill {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
color: #a5b4ff;
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.trust-fill {
|
||||
animation: trust-sweep 1000ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes card-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trust-sweep {
|
||||
0% {
|
||||
width: 0% !important;
|
||||
opacity: 0.4;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.line-clamp-4 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
344
apps/dashboard/src/lib/components/FSRSCalendar.svelte
Normal file
344
apps/dashboard/src/lib/components/FSRSCalendar.svelte
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<script lang="ts">
|
||||
import type { Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import {
|
||||
startOfDay,
|
||||
daysBetween,
|
||||
isoDate,
|
||||
gridStartForAnchor,
|
||||
avgRetention as avgRetentionHelper,
|
||||
} from './schedule-helpers';
|
||||
|
||||
type Props = {
|
||||
memories: Memory[];
|
||||
anchor?: Date;
|
||||
};
|
||||
|
||||
let { memories, anchor = new Date() }: Props = $props();
|
||||
|
||||
// Build a 6-row x 7-col grid starting 2 weeks before today.
|
||||
// Rows: 2 past weeks + 4 future weeks = 6 weeks = 42 cells.
|
||||
// Align so the first row starts on the Sunday that is on or before
|
||||
// (today - 14 days). This keeps today visible in week 3.
|
||||
let today = $derived(startOfDay(anchor));
|
||||
let gridStart = $derived(gridStartForAnchor(anchor));
|
||||
|
||||
type DayCell = {
|
||||
date: Date;
|
||||
key: string;
|
||||
isToday: boolean;
|
||||
inWindow: boolean; // within -14 to +28 days
|
||||
memories: Memory[];
|
||||
avgRetention: number;
|
||||
};
|
||||
|
||||
// Bucket memories by their nextReviewAt day (YYYY-MM-DD).
|
||||
let buckets = $derived(
|
||||
(() => {
|
||||
const map = new Map<string, Memory[]>();
|
||||
for (const m of memories) {
|
||||
if (!m.nextReviewAt) continue;
|
||||
const d = new Date(m.nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const key = isoDate(startOfDay(d));
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(m);
|
||||
else map.set(key, [m]);
|
||||
}
|
||||
return map;
|
||||
})()
|
||||
);
|
||||
|
||||
let cells = $derived(
|
||||
(() => {
|
||||
const out: DayCell[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(gridStart);
|
||||
d.setDate(d.getDate() + i);
|
||||
const key = isoDate(d);
|
||||
const ms = buckets.get(key) ?? [];
|
||||
const delta = daysBetween(d, today);
|
||||
out.push({
|
||||
date: d,
|
||||
key,
|
||||
isToday: delta === 0,
|
||||
inWindow: delta >= -14 && delta <= 28,
|
||||
memories: ms,
|
||||
avgRetention: avgRetentionHelper(ms)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
);
|
||||
|
||||
// Urgency coloring — emerald / amber / decay-red / synapse / muted.
|
||||
function cellColor(cell: DayCell): { bg: string; border: string; text: string } {
|
||||
if (cell.memories.length === 0) {
|
||||
return { bg: 'rgba(255,255,255,0.02)', border: 'rgba(99,102,241,0.06)', text: '#4a4a7a' };
|
||||
}
|
||||
const delta = daysBetween(cell.date, today);
|
||||
if (delta < -1) {
|
||||
// Overdue (more than 1 day in the past) — decay-red
|
||||
return {
|
||||
bg: 'rgba(239,68,68,0.16)',
|
||||
border: 'rgba(239,68,68,0.45)',
|
||||
text: '#fca5a5'
|
||||
};
|
||||
}
|
||||
if (delta >= -1 && delta <= 0) {
|
||||
// Due today (or yesterday, just at the threshold) — amber
|
||||
return {
|
||||
bg: 'rgba(245,158,11,0.18)',
|
||||
border: 'rgba(245,158,11,0.5)',
|
||||
text: '#fcd34d'
|
||||
};
|
||||
}
|
||||
if (delta > 0 && delta <= 7) {
|
||||
// Due within 7 days — synapse blue
|
||||
return {
|
||||
bg: 'rgba(99,102,241,0.16)',
|
||||
border: 'rgba(99,102,241,0.45)',
|
||||
text: '#a5b4fc'
|
||||
};
|
||||
}
|
||||
// >7 days out — muted
|
||||
return {
|
||||
bg: 'rgba(168,85,247,0.08)',
|
||||
border: 'rgba(168,85,247,0.2)',
|
||||
text: '#c084fc'
|
||||
};
|
||||
}
|
||||
|
||||
let selectedKey: string | null = $state(null);
|
||||
let selectedCell = $derived(cells.find((c) => c.key === selectedKey) ?? null);
|
||||
|
||||
function toggle(key: string) {
|
||||
selectedKey = selectedKey === key ? null : key;
|
||||
}
|
||||
|
||||
// Retention sparkline — one point per day in the window, average retention
|
||||
// of memories due that day. Width 100% x height 48px SVG.
|
||||
const SPARK_W = 600;
|
||||
const SPARK_H = 56;
|
||||
|
||||
let sparkPoints = $derived(
|
||||
(() => {
|
||||
const pts: { x: number; y: number; r: number; count: number }[] = [];
|
||||
const n = cells.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = cells[i];
|
||||
const x = (i / (n - 1)) * SPARK_W;
|
||||
// Invert Y: higher retention = higher on chart = smaller y
|
||||
const r = c.avgRetention;
|
||||
const y = SPARK_H - 6 - r * (SPARK_H - 12);
|
||||
pts.push({ x, y, r, count: c.memories.length });
|
||||
}
|
||||
return pts;
|
||||
})()
|
||||
);
|
||||
|
||||
let sparkPath = $derived(
|
||||
(() => {
|
||||
const valid = sparkPoints.filter((p) => p.count > 0);
|
||||
if (valid.length === 0) return '';
|
||||
return valid.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
})()
|
||||
);
|
||||
|
||||
// Today's x-position on the sparkline, so the viewer can anchor the trend.
|
||||
let todayIndex = $derived(cells.findIndex((c) => c.isToday));
|
||||
let todayX = $derived(todayIndex >= 0 ? (todayIndex / (cells.length - 1)) * SPARK_W : -1);
|
||||
|
||||
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function shortDate(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function fullDate(d: Date): string {
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Retention sparkline -->
|
||||
<div class="p-4 glass-subtle rounded-xl">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-dim font-medium">Avg retention of memories due — last 2 weeks → next 4</span>
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted">
|
||||
<span class="flex items-center gap-1"><span class="w-2 h-0.5 bg-recall"></span>retention</span>
|
||||
<span class="flex items-center gap-1"><span class="w-px h-3 bg-synapse-glow"></span>today</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox="0 0 {SPARK_W} {SPARK_H}" preserveAspectRatio="none" class="w-full h-12 block" aria-hidden="true">
|
||||
<!-- Baselines at 30% / 70% -->
|
||||
<line x1="0" x2={SPARK_W} y1={SPARK_H - 6 - 0.3 * (SPARK_H - 12)} y2={SPARK_H - 6 - 0.3 * (SPARK_H - 12)}
|
||||
stroke="rgba(239,68,68,0.18)" stroke-dasharray="2 4" stroke-width="1" />
|
||||
<line x1="0" x2={SPARK_W} y1={SPARK_H - 6 - 0.7 * (SPARK_H - 12)} y2={SPARK_H - 6 - 0.7 * (SPARK_H - 12)}
|
||||
stroke="rgba(16,185,129,0.18)" stroke-dasharray="2 4" stroke-width="1" />
|
||||
{#if todayX >= 0}
|
||||
<line x1={todayX} x2={todayX} y1="0" y2={SPARK_H}
|
||||
stroke="rgba(129,140,248,0.5)" stroke-width="1" />
|
||||
{/if}
|
||||
{#if sparkPath}
|
||||
<path d={sparkPath} fill="none" stroke="var(--color-recall)" stroke-width="1.5" stroke-linejoin="round" />
|
||||
{/if}
|
||||
{#each sparkPoints as p}
|
||||
{#if p.count > 0}
|
||||
<circle cx={p.x} cy={p.y} r="1.5" fill="var(--color-recall)" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Day-of-week header -->
|
||||
<div class="grid grid-cols-7 gap-2 px-1">
|
||||
{#each DOW_LABELS as label}
|
||||
<div class="text-[10px] text-muted font-mono uppercase tracking-wider text-center">{label}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
{#each cells as cell (cell.key)}
|
||||
{@const colors = cellColor(cell)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggle(cell.key)}
|
||||
disabled={cell.memories.length === 0}
|
||||
class="relative aspect-square rounded-lg p-2 text-left transition-all duration-200
|
||||
{cell.inWindow ? 'opacity-100' : 'opacity-35'}
|
||||
{cell.memories.length > 0 ? 'hover:scale-[1.03] cursor-pointer' : 'cursor-default'}
|
||||
{cell.isToday ? 'ring-2 ring-synapse/60 shadow-[0_0_16px_rgba(99,102,241,0.3)]' : ''}
|
||||
{selectedKey === cell.key ? 'ring-2 ring-dream/60 shadow-[0_0_16px_rgba(168,85,247,0.3)]' : ''}"
|
||||
style="background: {colors.bg}; border: 1px solid {colors.border};"
|
||||
title={`${fullDate(cell.date)} — ${cell.memories.length} due`}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<span class="text-[10px] font-mono {cell.isToday ? 'text-synapse-glow font-bold' : 'text-dim'}">
|
||||
{cell.date.getDate()}
|
||||
</span>
|
||||
{#if cell.date.getDate() === 1}
|
||||
<span class="text-[9px] text-muted">{cell.date.toLocaleDateString(undefined, { month: 'short' })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if cell.memories.length > 0}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-0.5">
|
||||
<span class="text-base sm:text-lg font-bold leading-none" style="color: {colors.text}">
|
||||
{cell.memories.length}
|
||||
</span>
|
||||
{#if cell.avgRetention > 0}
|
||||
<span class="text-[9px] text-muted">{(cell.avgRetention * 100).toFixed(0)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex items-center gap-4 text-[10px] text-muted flex-wrap px-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.45);"></span>
|
||||
Overdue
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(245,158,11,0.18); border: 1px solid rgba(245,158,11,0.5);"></span>
|
||||
Due today
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(99,102,241,0.16); border: 1px solid rgba(99,102,241,0.45);"></span>
|
||||
Within 7 days
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-3 rounded" style="background: rgba(168,85,247,0.08); border: 1px solid rgba(168,85,247,0.2);"></span>
|
||||
Future (8+ days)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded day panel -->
|
||||
{#if selectedCell && selectedCell.memories.length > 0}
|
||||
<div class="p-5 glass rounded-xl space-y-3 animate-panel-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm text-bright font-semibold">{fullDate(selectedCell.date)}</h3>
|
||||
<p class="text-xs text-dim mt-0.5">
|
||||
{selectedCell.memories.length} memor{selectedCell.memories.length === 1 ? 'y' : 'ies'} due
|
||||
· avg retention {(selectedCell.avgRetention * 100).toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedKey = null)}
|
||||
class="text-xs text-muted hover:text-dim px-2 py-1 rounded-lg hover:bg-white/[0.03]"
|
||||
aria-label="Close"
|
||||
>
|
||||
close ×
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{#each selectedCell.memories.slice(0, 100) as m (m.id)}
|
||||
<div class="flex items-start gap-3 p-2.5 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition">
|
||||
<span
|
||||
class="w-2 h-2 mt-1.5 rounded-full flex-shrink-0"
|
||||
style="background: {NODE_TYPE_COLORS[m.nodeType] || '#8B95A5'}"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-text leading-snug line-clamp-2">{m.content}</p>
|
||||
<div class="flex items-center gap-2 mt-1 text-[10px] text-muted">
|
||||
<span>{m.nodeType}</span>
|
||||
{#if m.reviewCount !== undefined}
|
||||
<span>· {m.reviewCount} review{m.reviewCount === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
{#each m.tags.slice(0, 2) as tag}
|
||||
<span class="px-1 py-0.5 bg-white/[0.04] rounded text-muted">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<div class="w-12 h-1 bg-deep rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width: {m.retentionStrength * 100}%; background: {m.retentionStrength > 0.7
|
||||
? 'var(--color-recall)'
|
||||
: m.retentionStrength > 0.4
|
||||
? 'var(--color-warning)'
|
||||
: 'var(--color-decay)'}"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted">{(m.retentionStrength * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if selectedCell.memories.length > 100}
|
||||
<p class="text-xs text-muted text-center pt-2">
|
||||
+{selectedCell.memories.length - 100} more
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-panel-in {
|
||||
animation: panel-in 0.18s ease-out;
|
||||
}
|
||||
</style>
|
||||
174
apps/dashboard/src/lib/components/ImportanceRadar.svelte
Normal file
174
apps/dashboard/src/lib/components/ImportanceRadar.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
AXIS_ORDER,
|
||||
clampChannels,
|
||||
radarRadius,
|
||||
sizePreset,
|
||||
} from './importance-helpers';
|
||||
|
||||
interface Props {
|
||||
novelty: number;
|
||||
arousal: number;
|
||||
reward: number;
|
||||
attention: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { novelty, arousal, reward, attention, size = 'md' }: Props = $props();
|
||||
|
||||
// Size presets + padding + clamp logic live in `importance-helpers.ts` so
|
||||
// they can be tested in the Vitest node env without a jsdom harness.
|
||||
let pxSize = $derived(sizePreset(size));
|
||||
let showLabels = $derived(size !== 'sm');
|
||||
let radius = $derived(radarRadius(size));
|
||||
let cx = $derived(pxSize / 2);
|
||||
let cy = $derived(pxSize / 2);
|
||||
|
||||
// Axis labels live alongside AXIS_ORDER's keys/angles so the renderer
|
||||
// never drifts from the helper geometry.
|
||||
const AXIS_LABELS: Record<(typeof AXIS_ORDER)[number]['key'], string> = {
|
||||
novelty: 'Novelty',
|
||||
arousal: 'Arousal',
|
||||
reward: 'Reward',
|
||||
attention: 'Attention'
|
||||
};
|
||||
|
||||
let values = $derived(clampChannels({ novelty, arousal, reward, attention }));
|
||||
|
||||
function pointAt(value: number, angle: number): [number, number] {
|
||||
const r = value * radius;
|
||||
return [cx + Math.cos(angle) * r, cy + Math.sin(angle) * r];
|
||||
}
|
||||
|
||||
// Grid rings at 25/50/75/100%.
|
||||
const RINGS = [0.25, 0.5, 0.75, 1];
|
||||
|
||||
function ringPath(frac: number): string {
|
||||
const pts = AXIS_ORDER.map(({ angle }) => pointAt(frac, angle));
|
||||
return (
|
||||
pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ') + ' Z'
|
||||
);
|
||||
}
|
||||
|
||||
// Mount-time grow-from-center animation. We scale the values from 0 to 1
|
||||
// over ~600ms with an easeOutCubic curve so the polygon blossoms instead of
|
||||
// popping in. Reactive on prop change would also be nice but this matches
|
||||
// the brief ("animated fill-in on mount").
|
||||
let animProgress = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
const duration = 600;
|
||||
const start = performance.now();
|
||||
let raf = 0;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
// easeOutCubic
|
||||
animProgress = 1 - Math.pow(1 - t, 3);
|
||||
if (t < 1) raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
let polygonPath = $derived.by(() => {
|
||||
const scale = animProgress;
|
||||
const pts = AXIS_ORDER.map(({ key, angle }) => pointAt(values[key] * scale, angle));
|
||||
return (
|
||||
pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ') + ' Z'
|
||||
);
|
||||
});
|
||||
|
||||
// Label positions sit slightly outside the 100% ring.
|
||||
function labelPos(angle: number): { x: number; y: number; anchor: 'start' | 'middle' | 'end' } {
|
||||
const offset = radius + (size === 'lg' ? 18 : 12);
|
||||
const x = cx + Math.cos(angle) * offset;
|
||||
const y = cy + Math.sin(angle) * offset;
|
||||
let anchor: 'start' | 'middle' | 'end' = 'middle';
|
||||
if (Math.abs(Math.cos(angle)) > 0.5) {
|
||||
anchor = Math.cos(angle) > 0 ? 'start' : 'end';
|
||||
}
|
||||
return { x, y, anchor };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={pxSize}
|
||||
height={pxSize}
|
||||
viewBox="0 0 {pxSize} {pxSize}"
|
||||
role="img"
|
||||
aria-label="Importance radar: novelty {(values.novelty * 100).toFixed(0)}%, arousal {(values.arousal * 100).toFixed(0)}%, reward {(values.reward * 100).toFixed(0)}%, attention {(values.attention * 100).toFixed(0)}%"
|
||||
>
|
||||
<!-- Concentric rings (grid) -->
|
||||
{#each RINGS as ring}
|
||||
<path
|
||||
d={ringPath(ring)}
|
||||
fill="none"
|
||||
stroke="var(--color-muted)"
|
||||
stroke-opacity={ring === 1 ? 0.45 : 0.18}
|
||||
stroke-width={ring === 1 ? 1 : 0.75}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Spokes -->
|
||||
{#each AXIS_ORDER as axis}
|
||||
{@const [x, y] = pointAt(1, axis.angle)}
|
||||
<line
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke="var(--color-muted)"
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="0.75"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Filled polygon -->
|
||||
<path
|
||||
d={polygonPath}
|
||||
fill="var(--color-synapse-glow)"
|
||||
fill-opacity="0.3"
|
||||
stroke="var(--color-synapse-glow)"
|
||||
stroke-width={size === 'sm' ? 1 : 1.5}
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Vertex dots at the animated positions, only on md/lg for clarity -->
|
||||
{#if size !== 'sm'}
|
||||
{#each AXIS_ORDER as axis}
|
||||
{@const [px, py] = pointAt(values[axis.key] * animProgress, axis.angle)}
|
||||
<circle cx={px} cy={py} r={size === 'lg' ? 3 : 2.25} fill="var(--color-synapse-glow)" />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Axis labels (name + value) -->
|
||||
{#if showLabels}
|
||||
{#each AXIS_ORDER as axis}
|
||||
{@const pos = labelPos(axis.angle)}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
text-anchor={pos.anchor}
|
||||
dominant-baseline="middle"
|
||||
fill="var(--color-bright)"
|
||||
font-size={size === 'lg' ? 12 : 10}
|
||||
font-family="var(--font-mono)"
|
||||
font-weight="600"
|
||||
>
|
||||
{(values[axis.key] * 100).toFixed(0)}%
|
||||
</text>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + (size === 'lg' ? 14 : 11)}
|
||||
text-anchor={pos.anchor}
|
||||
dominant-baseline="middle"
|
||||
fill="var(--color-dim)"
|
||||
font-size={size === 'lg' ? 10 : 8.5}
|
||||
font-family="var(--font-mono)"
|
||||
>
|
||||
{AXIS_LABELS[axis.key]}
|
||||
</text>
|
||||
{/each}
|
||||
{/if}
|
||||
</svg>
|
||||
185
apps/dashboard/src/lib/components/MemoryAuditTrail.svelte
Normal file
185
apps/dashboard/src/lib/components/MemoryAuditTrail.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
META,
|
||||
generateMockAuditTrail,
|
||||
relativeTime,
|
||||
formatRetentionDelta,
|
||||
splitVisible,
|
||||
type AuditEvent
|
||||
} from './audit-trail-helpers';
|
||||
|
||||
/**
|
||||
* MemoryAuditTrail — per-memory "Sources" panel.
|
||||
*
|
||||
* Renders a vertical bioluminescent timeline of every event that has touched
|
||||
* a memory: creation, accesses, promotions/demotions, edits, suppressions,
|
||||
* dream-cycle activations, and reconsolidations (5-min labile window edits).
|
||||
*
|
||||
* Backend `/api/changelog?memory_id=X` does NOT yet exist. A typed mock
|
||||
* fetcher lives in `audit-trail-helpers.ts` and will be swapped for
|
||||
* `api.memoryChangelog(id)` once the backend route ships.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
memoryId: string;
|
||||
}
|
||||
|
||||
let { memoryId }: Props = $props();
|
||||
|
||||
let events: AuditEvent[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let errored = $state(false);
|
||||
let showAllOlder = $state(false);
|
||||
|
||||
// TODO: swap for api.memoryChangelog(id) when backend ships
|
||||
async function fetchAuditTrail(id: string): Promise<AuditEvent[]> {
|
||||
return generateMockAuditTrail(id, Date.now());
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!memoryId) {
|
||||
events = [];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
events = await fetchAuditTrail(memoryId);
|
||||
} catch {
|
||||
events = [];
|
||||
errored = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const split = $derived(splitVisible(events, showAllOlder));
|
||||
const visibleEvents = $derived(split.visible);
|
||||
const hiddenCount = $derived(split.hiddenCount);
|
||||
</script>
|
||||
|
||||
<div class="audit-trail space-y-3" aria-label="Audit trail">
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-10 glass-subtle rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if errored}
|
||||
<p class="text-xs text-decay italic">Audit trail failed to load.</p>
|
||||
{:else if !memoryId}
|
||||
<p class="text-xs text-muted italic">No memory selected.</p>
|
||||
{:else if events.length === 0}
|
||||
<p class="text-xs text-muted italic">No audit events recorded yet.</p>
|
||||
{:else}
|
||||
<ol class="relative pl-6 border-l border-synapse/15 space-y-3">
|
||||
{#each visibleEvents as ev, i (ev.timestamp + i)}
|
||||
{@const m = META[ev.action]}
|
||||
{@const delta = formatRetentionDelta(ev.old_value, ev.new_value)}
|
||||
<li class="relative" style="animation-delay: {i * 40}ms;">
|
||||
<!-- Marker -->
|
||||
<span
|
||||
class="marker absolute -left-[29px] top-0.5 w-4 h-4 flex items-center justify-center rounded-full"
|
||||
style="background: rgba(10,10,26,0.9); box-shadow: 0 0 10px {m.color}88; border: 1px solid {m.color};"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if m.kind === 'dot'}
|
||||
<span class="w-1.5 h-1.5 rounded-full" style="background: {m.color};"></span>
|
||||
{:else if m.kind === 'ring'}
|
||||
<span
|
||||
class="w-2 h-2 rounded-full border"
|
||||
style="border-color: {m.color}; background: transparent;"
|
||||
></span>
|
||||
{:else if m.kind === 'arrow-up'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
|
||||
<path d="M6 10V2M3 5l3-3 3 3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'arrow-down'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
|
||||
<path d="M6 2v8M3 7l3 3 3-3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'pencil'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="1.5">
|
||||
<path d="M8.5 1.5l2 2L4 10l-3 1 1-3 6.5-6.5z" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'x'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="2">
|
||||
<path d="M2 2l8 8M10 2l-8 8" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else if m.kind === 'star'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill={m.color} stroke="none">
|
||||
<path d="M6 0.5l1.4 3.3 3.6.3-2.7 2.4.8 3.5L6 8.2l-3.1 1.8.8-3.5L1 4.1l3.6-.3L6 .5z" />
|
||||
</svg>
|
||||
{:else if m.kind === 'circle-arrow'}
|
||||
<svg viewBox="0 0 12 12" class="w-2.5 h-2.5" fill="none" stroke={m.color} stroke-width="1.5">
|
||||
<path d="M10 6a4 4 0 1 1-1.2-2.8" stroke-linecap="round" />
|
||||
<path d="M10 1.5V4H7.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Event content -->
|
||||
<div class="glass-subtle rounded-lg px-3 py-2 space-y-1">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="font-semibold" style="color: {m.color};">{m.label}</span>
|
||||
{#if ev.triggered_by}
|
||||
<span class="text-muted font-mono text-[10px]">{ev.triggered_by}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-[10px] text-muted font-mono" title={new Date(ev.timestamp).toLocaleString()}>
|
||||
{relativeTime(ev.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{#if delta}
|
||||
<div class="text-[11px] text-dim font-mono">
|
||||
retention {delta}
|
||||
</div>
|
||||
{/if}
|
||||
{#if ev.reason}
|
||||
<div class="text-[11px] text-dim italic">{ev.reason}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
{#if hiddenCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showAllOlder = !showAllOlder;
|
||||
}}
|
||||
class="text-xs text-synapse-glow hover:text-bright transition-colors underline-offset-4 hover:underline"
|
||||
>
|
||||
{showAllOlder ? 'Hide older events' : `Show ${hiddenCount} older event${hiddenCount === 1 ? '' : 's'}…`}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.audit-trail :global(ol > li) {
|
||||
animation: event-rise 400ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes event-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.audit-trail .marker) {
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
:global(.audit-trail li:hover .marker) {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
</style>
|
||||
251
apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte
Normal file
251
apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<!--
|
||||
PatternTransferHeatmap — CrossProjectLearner visualization.
|
||||
Symmetric N×N project grid. Cell (row=A, col=B) intensity encodes how many
|
||||
patterns were learned in project A and reused in project B. Diagonal =
|
||||
self-transfer count (project reusing its own patterns).
|
||||
|
||||
Color scale: muted (no transfers) → synapse glow → dream glow for high
|
||||
transfer counts. Cells are clickable (filters sidebar to A → B pairs) and
|
||||
hoverable (tooltip with count + top 3 pattern names).
|
||||
|
||||
Responsive: the grid collapses to a vertical list of "A → B : count" rows
|
||||
on small viewports so the matrix is still scannable on mobile.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
buildTransferMatrix,
|
||||
flattenNonZero,
|
||||
matrixMaxCount,
|
||||
shortProjectName,
|
||||
type PatternCategory,
|
||||
type TransferPatternLike,
|
||||
} from './patterns-helpers';
|
||||
|
||||
interface Pattern extends TransferPatternLike {
|
||||
category: PatternCategory;
|
||||
last_used: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: string[];
|
||||
patterns: Pattern[];
|
||||
selectedCell: { from: string; to: string } | null;
|
||||
onCellClick: (from: string, to: string) => void;
|
||||
}
|
||||
|
||||
let { projects, patterns, selectedCell, onCellClick }: Props = $props();
|
||||
|
||||
// Matrix build, max-count, and non-zero flattening all live in
|
||||
// `patterns-helpers.ts` so they can be unit tested in the Vitest node env.
|
||||
const matrix = $derived(buildTransferMatrix(projects, patterns));
|
||||
const maxCount = $derived(matrixMaxCount(projects, matrix) || 1);
|
||||
|
||||
// Hover tooltip state
|
||||
let hoveredCell = $state<{ from: string; to: string; x: number; y: number } | null>(null);
|
||||
|
||||
function cellStyle(count: number): string {
|
||||
if (count === 0) {
|
||||
return 'background: rgba(255,255,255,0.02); border-color: rgba(99,102,241,0.05);';
|
||||
}
|
||||
const intensity = count / maxCount; // 0..1
|
||||
// Two-stop gradient: synapse (indigo) for low-mid → dream (purple) for high.
|
||||
// Alpha ramps 0.10 → 0.80 so even low-count cells read clearly.
|
||||
const alpha = 0.1 + intensity * 0.7;
|
||||
if (intensity < 0.5) {
|
||||
// Synapse-dominant
|
||||
return `background: rgba(99, 102, 241, ${alpha.toFixed(3)}); border-color: rgba(129, 140, 248, ${(alpha * 0.6).toFixed(3)}); box-shadow: 0 0 ${(intensity * 14).toFixed(1)}px rgba(129, 140, 248, ${(intensity * 0.45).toFixed(3)});`;
|
||||
} else {
|
||||
// Dream-dominant for the hottest cells
|
||||
const dreamIntensity = (intensity - 0.5) * 2; // 0..1 over upper half
|
||||
const r = Math.round(99 + (168 - 99) * dreamIntensity);
|
||||
const g = Math.round(102 + (85 - 102) * dreamIntensity);
|
||||
const b = Math.round(241 + (247 - 241) * dreamIntensity);
|
||||
return `background: rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)}); border-color: rgba(192, 132, 252, ${(alpha * 0.7).toFixed(3)}); box-shadow: 0 0 ${(6 + intensity * 18).toFixed(1)}px rgba(192, 132, 252, ${(intensity * 0.55).toFixed(3)});`;
|
||||
}
|
||||
}
|
||||
|
||||
function cellTextClass(count: number): string {
|
||||
if (count === 0) return 'text-muted';
|
||||
const intensity = count / maxCount;
|
||||
if (intensity >= 0.5) return 'text-bright font-semibold';
|
||||
if (intensity >= 0.2) return 'text-text';
|
||||
return 'text-dim';
|
||||
}
|
||||
|
||||
function handleCellHover(ev: MouseEvent, from: string, to: string) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
hoveredCell = {
|
||||
from,
|
||||
to,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top
|
||||
};
|
||||
}
|
||||
|
||||
function handleCellLeave() {
|
||||
hoveredCell = null;
|
||||
}
|
||||
|
||||
// Axis labels are diagonal; trim long names via the shared helper.
|
||||
const shortProject = shortProjectName;
|
||||
|
||||
function isSelected(from: string, to: string): boolean {
|
||||
return selectedCell !== null && selectedCell.from === from && selectedCell.to === to;
|
||||
}
|
||||
|
||||
// Flattened list for the mobile fallback: only non-zero cells, sorted desc.
|
||||
const mobileList = $derived(flattenNonZero(projects, matrix));
|
||||
</script>
|
||||
|
||||
<div class="glass-panel relative rounded-2xl p-5">
|
||||
<!-- Desktop / tablet: grid heatmap -->
|
||||
<div class="hidden md:block">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-xs text-dim">
|
||||
Rows = origin project · Columns = destination project
|
||||
</div>
|
||||
<!-- Legend gradient -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-muted">0</span>
|
||||
<div
|
||||
class="h-2 w-32 rounded-full"
|
||||
style="background: linear-gradient(to right, rgba(255,255,255,0.05), rgba(99,102,241,0.5), rgba(168,85,247,0.85));"
|
||||
></div>
|
||||
<span class="text-[10px] text-muted">{maxCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-separate" style="border-spacing: 4px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24"></th>
|
||||
{#each projects as proj (proj)}
|
||||
<th
|
||||
class="h-20 min-w-16 max-w-20 align-bottom"
|
||||
title={proj}
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex h-20 w-6 items-end justify-center"
|
||||
style="writing-mode: vertical-rl; transform: rotate(180deg);"
|
||||
>
|
||||
<span class="text-[11px] text-dim font-medium tracking-wide">
|
||||
{shortProject(proj)}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each projects as from (from)}
|
||||
<tr>
|
||||
<td class="w-24 pr-2 text-right text-[11px] text-dim" title={from}>
|
||||
{shortProject(from)}
|
||||
</td>
|
||||
{#each projects as to (to)}
|
||||
{@const cell = matrix[from][to]}
|
||||
{@const isDiag = from === to}
|
||||
<td class="p-0">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative h-10 w-full min-w-12 rounded-md border transition-all duration-200 hover:scale-110 hover:z-10 focus:outline-none focus:ring-2 focus:ring-synapse-glow"
|
||||
style="{cellStyle(cell.count)} {isSelected(from, to)
|
||||
? 'outline: 2px solid var(--color-dream-glow); outline-offset: 1px;'
|
||||
: ''} {isDiag && cell.count > 0
|
||||
? 'border-style: dashed;'
|
||||
: ''}"
|
||||
onclick={() => onCellClick(from, to)}
|
||||
onmouseenter={(e) => handleCellHover(e, from, to)}
|
||||
onmouseleave={handleCellLeave}
|
||||
aria-label="{cell.count} patterns from {from} to {to}"
|
||||
>
|
||||
<span class="text-[11px] {cellTextClass(cell.count)}">
|
||||
{cell.count || ''}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Hover tooltip -->
|
||||
{#if hoveredCell}
|
||||
{@const cell = matrix[hoveredCell.from][hoveredCell.to]}
|
||||
<div
|
||||
class="glass-panel pointer-events-none fixed z-50 max-w-xs rounded-lg p-3 text-xs shadow-2xl"
|
||||
style="left: {hoveredCell.x}px; top: {hoveredCell.y - 12}px; transform: translate(-50%, -100%);"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="font-mono text-dim">{shortProject(hoveredCell.from)}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="font-mono text-bright">{shortProject(hoveredCell.to)}</span>
|
||||
</div>
|
||||
<div class="mb-2 text-lg font-semibold text-bright">
|
||||
{cell.count}
|
||||
<span class="text-xs font-normal text-dim">
|
||||
{cell.count === 1 ? 'pattern' : 'patterns'} transferred
|
||||
</span>
|
||||
</div>
|
||||
{#if cell.topNames.length > 0}
|
||||
<div class="space-y-1 border-t border-synapse/10 pt-2">
|
||||
<div class="text-[10px] uppercase tracking-wider text-muted">Top patterns</div>
|
||||
{#each cell.topNames as name}
|
||||
<div class="truncate text-text">· {name}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted">No transfers recorded</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: vertical list of non-zero transfers -->
|
||||
<div class="space-y-2 md:hidden">
|
||||
<div class="mb-2 text-xs text-dim">
|
||||
{mobileList.length} transfer pair{mobileList.length === 1 ? '' : 's'} · tap to filter
|
||||
</div>
|
||||
{#if mobileList.length === 0}
|
||||
<div class="rounded-lg bg-white/[0.02] p-4 text-center text-xs text-muted">
|
||||
No cross-project transfers recorded yet.
|
||||
</div>
|
||||
{:else}
|
||||
{#each mobileList as row (row.from + '->' + row.to)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-synapse/10 bg-white/[0.02] p-3 transition hover:border-synapse/30 hover:bg-white/[0.04] {isSelected(
|
||||
row.from,
|
||||
row.to
|
||||
)
|
||||
? 'ring-1 ring-dream-glow'
|
||||
: ''}"
|
||||
onclick={() => onCellClick(row.from, row.to)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col items-start gap-0.5">
|
||||
<div class="flex items-center gap-1.5 text-xs">
|
||||
<span class="font-mono text-dim">{shortProject(row.from)}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="font-mono text-bright">{shortProject(row.to)}</span>
|
||||
</div>
|
||||
{#if row.topNames.length > 0}
|
||||
<div class="truncate text-[11px] text-muted">
|
||||
{row.topNames.join(' · ')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="ml-3 flex-shrink-0 rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow"
|
||||
>
|
||||
{row.count}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
259
apps/dashboard/src/lib/components/ReasoningChain.svelte
Normal file
259
apps/dashboard/src/lib/components/ReasoningChain.svelte
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<script lang="ts">
|
||||
interface StageResult {
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
intent?: string;
|
||||
memoriesAnalyzed?: number;
|
||||
evidenceCount?: number;
|
||||
contradictionCount?: number;
|
||||
supersededCount?: number;
|
||||
running?: boolean; // when true, run the sequential light-up animation
|
||||
// Optional per-stage hints (one-liners) — if provided, overrides defaults
|
||||
stageHints?: Partial<Record<StageKey, string>>;
|
||||
}
|
||||
|
||||
type StageKey =
|
||||
| 'broad'
|
||||
| 'spreading'
|
||||
| 'fsrs'
|
||||
| 'intent'
|
||||
| 'supersession'
|
||||
| 'contradiction'
|
||||
| 'relation'
|
||||
| 'template';
|
||||
|
||||
let {
|
||||
intent = 'Synthesis',
|
||||
memoriesAnalyzed = 0,
|
||||
evidenceCount = 0,
|
||||
contradictionCount = 0,
|
||||
supersededCount = 0,
|
||||
running = false,
|
||||
stageHints = {}
|
||||
}: Props = $props();
|
||||
|
||||
const STAGES: { key: StageKey; icon: string; label: string; base: string }[] = [
|
||||
{
|
||||
key: 'broad',
|
||||
icon: '◎',
|
||||
label: 'Broad Retrieval',
|
||||
base: 'Hybrid BM25 + semantic (3x overfetch) then cross-encoder rerank'
|
||||
},
|
||||
{
|
||||
key: 'spreading',
|
||||
icon: '⟿',
|
||||
label: 'Spreading Activation',
|
||||
base: 'Collins & Loftus — expand via graph edges to surface what search missed'
|
||||
},
|
||||
{
|
||||
key: 'fsrs',
|
||||
icon: '▲',
|
||||
label: 'FSRS Trust Scoring',
|
||||
base: 'retention × stability × reps ÷ lapses — which memories have earned trust'
|
||||
},
|
||||
{
|
||||
key: 'intent',
|
||||
icon: '◆',
|
||||
label: 'Intent Classification',
|
||||
base: 'FactCheck / Timeline / RootCause / Comparison / Synthesis'
|
||||
},
|
||||
{
|
||||
key: 'supersession',
|
||||
icon: '↗',
|
||||
label: 'Temporal Supersession',
|
||||
base: 'Newer high-trust memories replace older ones on the same fact'
|
||||
},
|
||||
{
|
||||
key: 'contradiction',
|
||||
icon: '⚡',
|
||||
label: 'Contradiction Analysis',
|
||||
base: 'Only flag conflicts between memories where BOTH have trust > 0.3'
|
||||
},
|
||||
{
|
||||
key: 'relation',
|
||||
icon: '⬡',
|
||||
label: 'Relation Assessment',
|
||||
base: 'Per pair: Supports / Contradicts / Supersedes / Irrelevant'
|
||||
},
|
||||
{
|
||||
key: 'template',
|
||||
icon: '❖',
|
||||
label: 'Template Reasoning',
|
||||
base: 'Build the natural-language reasoning chain you validate'
|
||||
}
|
||||
];
|
||||
|
||||
// Dynamic one-liners reflecting the actual response — fall back to base
|
||||
const computed: Partial<Record<StageKey, string>> = $derived({
|
||||
broad: memoriesAnalyzed ? `Analyzed ${memoriesAnalyzed} memories · ${evidenceCount} survived ranking` : undefined,
|
||||
intent: intent ? `Classified as ${intent}` : undefined,
|
||||
supersession: supersededCount
|
||||
? `${supersededCount} outdated memor${supersededCount === 1 ? 'y' : 'ies'} superseded`
|
||||
: undefined,
|
||||
contradiction: contradictionCount
|
||||
? `${contradictionCount} real conflict${contradictionCount === 1 ? '' : 's'} between trusted memories`
|
||||
: 'No conflicts between trusted memories'
|
||||
});
|
||||
|
||||
function hintFor(key: StageKey, base: string): string {
|
||||
return stageHints[key] ?? computed[key] ?? base;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="reasoning-chain space-y-2" class:running>
|
||||
{#each STAGES as stage, i (stage.key)}
|
||||
<div
|
||||
class="stage glass-subtle rounded-xl p-3 flex items-start gap-3 relative"
|
||||
style="animation-delay: {i * 140}ms;"
|
||||
>
|
||||
<!-- Connector line down to next stage -->
|
||||
{#if i < STAGES.length - 1}
|
||||
<div class="connector" style="animation-delay: {i * 140 + 120}ms;"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Stage index + icon -->
|
||||
<div class="stage-orb flex-shrink-0" style="animation-delay: {i * 140}ms;">
|
||||
<span class="text-xs text-synapse-glow">{stage.icon}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-[10px] font-mono text-muted">0{i + 1}</span>
|
||||
<span class="text-sm text-bright font-medium">{stage.label}</span>
|
||||
</div>
|
||||
<p class="text-xs text-dim leading-snug">{hintFor(stage.key, stage.base)}</p>
|
||||
</div>
|
||||
|
||||
<span class="stage-pulse" aria-hidden="true"></span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stage {
|
||||
animation: stage-light 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
position: relative;
|
||||
border-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.stage-orb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 30% 30%,
|
||||
rgba(129, 140, 248, 0.25),
|
||||
rgba(99, 102, 241, 0.05)
|
||||
);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
animation: orb-glow 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.stage-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(129, 140, 248, 0);
|
||||
pointer-events: none;
|
||||
animation: pulse-ring 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
.connector {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 100%;
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background: linear-gradient(180deg, rgba(129, 140, 248, 0.5), rgba(168, 85, 247, 0.15));
|
||||
animation: connector-draw 500ms ease-out backwards;
|
||||
}
|
||||
|
||||
.running .stage {
|
||||
animation: stage-light 700ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards,
|
||||
stage-flicker 2400ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes stage-light {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
border-color: rgba(99, 102, 241, 0);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
border-color: rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
border-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-glow {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 0 rgba(129, 140, 248, 0);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 24px rgba(129, 140, 248, 0.8);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 10px rgba(129, 140, 248, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.96);
|
||||
opacity: 0;
|
||||
border-color: rgba(129, 140, 248, 0);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
box-shadow: 0 0 20px rgba(129, 140, 248, 0.25);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.01);
|
||||
opacity: 0;
|
||||
border-color: rgba(129, 140, 248, 0);
|
||||
box-shadow: 0 0 0 rgba(129, 140, 248, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes connector-draw {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
transform-origin: top;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stage-flicker {
|
||||
0%,
|
||||
100% {
|
||||
border-color: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(129, 140, 248, 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
175
apps/dashboard/src/lib/components/ThemeToggle.svelte
Normal file
175
apps/dashboard/src/lib/components/ThemeToggle.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<!--
|
||||
ThemeToggle — closes GitHub issue #11.
|
||||
Small 30px icon button. Click cycles dark → light → auto → dark.
|
||||
Shows the current mode via icon + aria-label + tooltip. Smooth 200ms
|
||||
fade/scale crossfade between icons so the state change feels tactile.
|
||||
|
||||
Theme overrides live in $stores/theme (injected stylesheet approach —
|
||||
app.css is never mutated). The button itself uses existing glass
|
||||
tokens so it drops cleanly into the sidebar/header.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { theme, cycleTheme, type Theme } from '$stores/theme';
|
||||
|
||||
// Cycle order determines the label shown in the tooltip/aria.
|
||||
const LABELS: Record<Theme, string> = {
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
auto: 'Auto (system)'
|
||||
};
|
||||
|
||||
const NEXT: Record<Theme, Theme> = {
|
||||
dark: 'light',
|
||||
light: 'auto',
|
||||
auto: 'dark'
|
||||
};
|
||||
|
||||
let current = $derived($theme);
|
||||
let nextMode = $derived(NEXT[current]);
|
||||
let ariaLabel = $derived(`Toggle theme: ${LABELS[current]} (click for ${LABELS[nextMode]})`);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
onclick={cycleTheme}
|
||||
data-mode={current}
|
||||
>
|
||||
<!-- Three SVG icons stacked, crossfade by opacity. Only the active
|
||||
one is visible; aria-hidden on all since the button label
|
||||
carries the semantics. -->
|
||||
<span class="icon-wrap">
|
||||
<!-- MOON (dark mode) -->
|
||||
<svg
|
||||
class="icon"
|
||||
class:active={current === 'dark'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
|
||||
<!-- SUN (light mode) -->
|
||||
<svg
|
||||
class="icon"
|
||||
class:active={current === 'light'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
|
||||
<!-- AUTO (half-moon with gear teeth) -->
|
||||
<svg
|
||||
class="icon"
|
||||
class:active={current === 'auto'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- Left half filled (dark side), right half outlined (light side) -->
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 4 A8 8 0 0 0 12 20 Z" fill="currentColor" stroke="none" />
|
||||
<!-- Tiny gear notches to signal 'system / automatic' -->
|
||||
<path d="M12 2v1.5M12 20.5V22M3.5 12H2M22 12h-1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.theme-toggle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border: 1px solid rgba(99, 102, 241, 0.14);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 200ms ease,
|
||||
border-color 200ms ease,
|
||||
color 200ms ease,
|
||||
transform 120ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
color: var(--color-bright);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 1px solid var(--color-synapse);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0;
|
||||
transform: scale(0.7) rotate(-30deg);
|
||||
transition:
|
||||
opacity 200ms ease,
|
||||
transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon.active {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
|
||||
/* Subtle mode-specific accent tint so the button itself reflects
|
||||
* the active mode at a glance. */
|
||||
.theme-toggle[data-mode='dark'] {
|
||||
color: var(--color-synapse-glow, #818cf8);
|
||||
}
|
||||
.theme-toggle[data-mode='light'] {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
}
|
||||
.theme-toggle[data-mode='auto'] {
|
||||
color: var(--color-dream-glow, #c084fc);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-toggle,
|
||||
.icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,464 @@
|
|||
/**
|
||||
* Unit tests for Spreading Activation helpers.
|
||||
*
|
||||
* Pure-logic coverage only — the SVG render layer is not exercised here
|
||||
* (no jsdom). The six concerns we test are the ones that actually decide
|
||||
* whether the burst looks right:
|
||||
*
|
||||
* 1. Per-tick decay math (Collins & Loftus 1975, 0.93/frame)
|
||||
* 2. Compound decay after N ticks
|
||||
* 3. Threshold filter (activation < 0.05 → invisible)
|
||||
* 4. Concentric-ring placement around a source (8-per-ring, even angles)
|
||||
* 5. Color mapping (source → synapse-glow, unknown type → fallback)
|
||||
* 6. Staggered edge delay (rank ordering, ring-2 bonus)
|
||||
* 7. Event-feed filter (only NEW ActivationSpread events since lastSeen)
|
||||
*
|
||||
* The test environment is Node (vitest `environment: 'node'`) — the same
|
||||
* harness the graph + dream helper tests use.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DECAY,
|
||||
FALLBACK_COLOR,
|
||||
MIN_VISIBLE,
|
||||
RING_GAP,
|
||||
RING_1_CAPACITY,
|
||||
SOURCE_COLOR,
|
||||
STAGGER_PER_RANK,
|
||||
STAGGER_RING_2_BONUS,
|
||||
activationColor,
|
||||
applyDecay,
|
||||
compoundDecay,
|
||||
computeRing,
|
||||
edgeStagger,
|
||||
filterNewSpreadEvents,
|
||||
initialActivation,
|
||||
isVisible,
|
||||
layoutNeighbours,
|
||||
ringPositions,
|
||||
ticksUntilInvisible,
|
||||
} from '../activation-helpers';
|
||||
import { NODE_TYPE_COLORS, type VestigeEvent } from '$types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Decay math — single tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyDecay (Collins & Loftus 1975, 0.93/frame)', () => {
|
||||
it('multiplies activation by 0.93 per tick', () => {
|
||||
expect(applyDecay(1)).toBeCloseTo(0.93, 10);
|
||||
});
|
||||
|
||||
it('matches the documented constant', () => {
|
||||
expect(DECAY).toBe(0.93);
|
||||
});
|
||||
|
||||
it('returns 0 for zero / negative / non-finite input', () => {
|
||||
expect(applyDecay(0)).toBe(0);
|
||||
expect(applyDecay(-0.5)).toBe(0);
|
||||
expect(applyDecay(Number.NaN)).toBe(0);
|
||||
expect(applyDecay(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves strict monotonic decrease', () => {
|
||||
let a = 1;
|
||||
let prev = a;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
a = applyDecay(a);
|
||||
if (a === 0) break;
|
||||
expect(a).toBeLessThan(prev);
|
||||
prev = a;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Compound decay — N ticks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('compoundDecay', () => {
|
||||
it('0 ticks returns the input unchanged', () => {
|
||||
expect(compoundDecay(0.8, 0)).toBe(0.8);
|
||||
});
|
||||
|
||||
it('N ticks equals applyDecay called N times', () => {
|
||||
let iterative = 1;
|
||||
for (let i = 0; i < 10; i++) iterative = applyDecay(iterative);
|
||||
expect(compoundDecay(1, 10)).toBeCloseTo(iterative, 10);
|
||||
});
|
||||
|
||||
it('5 ticks from 1.0 lands in the 0.69..0.70 band', () => {
|
||||
// 0.93^5 ≈ 0.6957
|
||||
const result = compoundDecay(1, 5);
|
||||
expect(result).toBeGreaterThan(0.69);
|
||||
expect(result).toBeLessThan(0.7);
|
||||
});
|
||||
|
||||
it('treats negative tick counts as no-op', () => {
|
||||
expect(compoundDecay(0.5, -3)).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Threshold filter — fade/remove below MIN_VISIBLE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isVisible / MIN_VISIBLE threshold', () => {
|
||||
it('MIN_VISIBLE is exactly 0.05', () => {
|
||||
expect(MIN_VISIBLE).toBe(0.05);
|
||||
});
|
||||
|
||||
it('returns true at exactly the threshold (inclusive floor)', () => {
|
||||
expect(isVisible(0.05)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false just below the threshold', () => {
|
||||
expect(isVisible(0.0499)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for zero / negative / NaN', () => {
|
||||
expect(isVisible(0)).toBe(false);
|
||||
expect(isVisible(-0.1)).toBe(false);
|
||||
expect(isVisible(Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for typical full-activation source', () => {
|
||||
expect(isVisible(1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ticksUntilInvisible', () => {
|
||||
it('returns 0 when input is already at/below MIN_VISIBLE', () => {
|
||||
expect(ticksUntilInvisible(MIN_VISIBLE)).toBe(0);
|
||||
expect(ticksUntilInvisible(0.03)).toBe(0);
|
||||
expect(ticksUntilInvisible(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('produces a count that actually crosses the threshold', () => {
|
||||
const n = ticksUntilInvisible(1);
|
||||
expect(n).toBeGreaterThan(0);
|
||||
// After n ticks we should be BELOW the threshold...
|
||||
expect(compoundDecay(1, n)).toBeLessThan(MIN_VISIBLE);
|
||||
// ...but one fewer tick should still be visible.
|
||||
expect(compoundDecay(1, n - 1)).toBeGreaterThanOrEqual(MIN_VISIBLE);
|
||||
});
|
||||
|
||||
it('takes ~42 ticks for a full-strength burst to fade to threshold', () => {
|
||||
// log(0.05) / log(0.93) ≈ 41.27 → ceil → 42
|
||||
expect(ticksUntilInvisible(1)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Ring placement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeRing', () => {
|
||||
it('ranks 0..7 land on ring 1', () => {
|
||||
for (let r = 0; r < RING_1_CAPACITY; r++) {
|
||||
expect(computeRing(r)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('rank 8 and beyond land on ring 2', () => {
|
||||
expect(computeRing(RING_1_CAPACITY)).toBe(2);
|
||||
expect(computeRing(15)).toBe(2);
|
||||
expect(computeRing(99)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ringPositions (concentric circle layout)', () => {
|
||||
it('returns an empty array for count 0', () => {
|
||||
expect(ringPositions(0, 0, 0, 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('places 4 nodes on ring 1 at radius RING_GAP, evenly spaced', () => {
|
||||
const pts = ringPositions(0, 0, 4, 1, 0);
|
||||
expect(pts).toHaveLength(4);
|
||||
// First point at angle 0 → (RING_GAP, 0)
|
||||
expect(pts[0].x).toBeCloseTo(RING_GAP, 6);
|
||||
expect(pts[0].y).toBeCloseTo(0, 6);
|
||||
// Every point sits on the circle of the correct radius.
|
||||
for (const p of pts) {
|
||||
const dist = Math.hypot(p.x, p.y);
|
||||
expect(dist).toBeCloseTo(RING_GAP, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('places ring 2 at 2× RING_GAP from center', () => {
|
||||
const pts = ringPositions(0, 0, 3, 2, 0);
|
||||
for (const p of pts) {
|
||||
expect(Math.hypot(p.x, p.y)).toBeCloseTo(RING_GAP * 2, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('honours the center (cx, cy)', () => {
|
||||
const pts = ringPositions(500, 280, 2, 1, 0);
|
||||
// With angleOffset=0 and 2 points, the two angles are 0 and π.
|
||||
expect(pts[0].x).toBeCloseTo(500 + RING_GAP, 6);
|
||||
expect(pts[0].y).toBeCloseTo(280, 6);
|
||||
expect(pts[1].x).toBeCloseTo(500 - RING_GAP, 6);
|
||||
expect(pts[1].y).toBeCloseTo(280, 6);
|
||||
});
|
||||
|
||||
it('applies angleOffset to every point', () => {
|
||||
const unrot = ringPositions(0, 0, 3, 1, 0);
|
||||
const rot = ringPositions(0, 0, 3, 1, Math.PI / 2);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Rotation preserves distance from center.
|
||||
expect(Math.hypot(rot[i].x, rot[i].y)).toBeCloseTo(
|
||||
Math.hypot(unrot[i].x, unrot[i].y),
|
||||
6,
|
||||
);
|
||||
}
|
||||
// And the first rotated point should now be near (0, RING_GAP) rather
|
||||
// than (RING_GAP, 0).
|
||||
expect(rot[0].x).toBeCloseTo(0, 6);
|
||||
expect(rot[0].y).toBeCloseTo(RING_GAP, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutNeighbours (spills overflow to ring 2)', () => {
|
||||
it('returns one point per neighbour', () => {
|
||||
expect(layoutNeighbours(0, 0, 15, 0)).toHaveLength(15);
|
||||
expect(layoutNeighbours(0, 0, 3, 0)).toHaveLength(3);
|
||||
expect(layoutNeighbours(0, 0, 0, 0)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('first 8 neighbours are on ring 1 (radius RING_GAP)', () => {
|
||||
const pts = layoutNeighbours(0, 0, 15, 0);
|
||||
for (let i = 0; i < RING_1_CAPACITY; i++) {
|
||||
expect(Math.hypot(pts[i].x, pts[i].y)).toBeCloseTo(RING_GAP, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('neighbour 9..N are on ring 2 (radius 2*RING_GAP)', () => {
|
||||
const pts = layoutNeighbours(0, 0, 15, 0);
|
||||
for (let i = RING_1_CAPACITY; i < 15; i++) {
|
||||
expect(Math.hypot(pts[i].x, pts[i].y)).toBeCloseTo(RING_GAP * 2, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialActivation', () => {
|
||||
it('rank 0 gets the highest activation', () => {
|
||||
const a0 = initialActivation(0, 10);
|
||||
const a1 = initialActivation(1, 10);
|
||||
expect(a0).toBeGreaterThan(a1);
|
||||
});
|
||||
|
||||
it('ring-2 ranks get a 0.75 ring penalty', () => {
|
||||
// Rank 7 (last of ring 1) vs rank 8 (first of ring 2) — the jump in
|
||||
// activation between them should include the 0.75 ring factor.
|
||||
const ring1Last = initialActivation(7, 16);
|
||||
const ring2First = initialActivation(8, 16);
|
||||
expect(ring2First).toBeLessThan(ring1Last * 0.78);
|
||||
});
|
||||
|
||||
it('returns values in (0, 1]', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const a = initialActivation(i, 20);
|
||||
expect(a).toBeGreaterThan(0);
|
||||
expect(a).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 0 for invalid inputs', () => {
|
||||
expect(initialActivation(-1, 10)).toBe(0);
|
||||
expect(initialActivation(0, 0)).toBe(0);
|
||||
expect(initialActivation(Number.NaN, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Color mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('activationColor', () => {
|
||||
it('source nodes always use SOURCE_COLOR (synapse-glow)', () => {
|
||||
expect(activationColor('fact', true)).toBe(SOURCE_COLOR);
|
||||
expect(activationColor('concept', true)).toBe(SOURCE_COLOR);
|
||||
// Even if nodeType is garbage, source overrides.
|
||||
expect(activationColor('garbage-type', true)).toBe(SOURCE_COLOR);
|
||||
});
|
||||
|
||||
it('fact → NODE_TYPE_COLORS.fact (#00A8FF)', () => {
|
||||
expect(activationColor('fact', false)).toBe(NODE_TYPE_COLORS.fact);
|
||||
expect(activationColor('fact', false)).toBe('#00A8FF');
|
||||
});
|
||||
|
||||
it('every known node type resolves to its palette entry', () => {
|
||||
for (const type of Object.keys(NODE_TYPE_COLORS)) {
|
||||
expect(activationColor(type, false)).toBe(NODE_TYPE_COLORS[type]);
|
||||
}
|
||||
});
|
||||
|
||||
it('unknown node type falls back to FALLBACK_COLOR (soft steel)', () => {
|
||||
expect(activationColor('not-a-real-type', false)).toBe(FALLBACK_COLOR);
|
||||
expect(FALLBACK_COLOR).toBe('#8B95A5');
|
||||
});
|
||||
|
||||
it('null/undefined/empty nodeType also falls back', () => {
|
||||
expect(activationColor(null, false)).toBe(FALLBACK_COLOR);
|
||||
expect(activationColor(undefined, false)).toBe(FALLBACK_COLOR);
|
||||
expect(activationColor('', false)).toBe(FALLBACK_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Staggered edge delay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('edgeStagger', () => {
|
||||
it('rank 0 has zero delay (first edge lights up immediately)', () => {
|
||||
expect(edgeStagger(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('ring-1 edges are STAGGER_PER_RANK apart', () => {
|
||||
expect(edgeStagger(1)).toBe(STAGGER_PER_RANK);
|
||||
expect(edgeStagger(2)).toBe(STAGGER_PER_RANK * 2);
|
||||
expect(edgeStagger(7)).toBe(STAGGER_PER_RANK * 7);
|
||||
});
|
||||
|
||||
it('ring-2 edges add STAGGER_RING_2_BONUS on top of rank×stagger', () => {
|
||||
expect(edgeStagger(8)).toBe(8 * STAGGER_PER_RANK + STAGGER_RING_2_BONUS);
|
||||
expect(edgeStagger(12)).toBe(12 * STAGGER_PER_RANK + STAGGER_RING_2_BONUS);
|
||||
});
|
||||
|
||||
it('monotonically non-decreasing', () => {
|
||||
let prev = -1;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const s = edgeStagger(i);
|
||||
expect(s).toBeGreaterThanOrEqual(prev);
|
||||
prev = s;
|
||||
}
|
||||
});
|
||||
|
||||
it('produces 15 distinct delays for a typical 15-neighbour burst', () => {
|
||||
const delays = Array.from({ length: 15 }, (_, i) => edgeStagger(i));
|
||||
expect(new Set(delays).size).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Event-feed filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spreadEvent(
|
||||
source_id: string,
|
||||
target_ids: string[],
|
||||
): VestigeEvent {
|
||||
return { type: 'ActivationSpread', data: { source_id, target_ids } };
|
||||
}
|
||||
|
||||
describe('filterNewSpreadEvents', () => {
|
||||
it('returns [] on empty feed', () => {
|
||||
expect(filterNewSpreadEvents([], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all ActivationSpread payloads when lastSeen is null', () => {
|
||||
const feed = [
|
||||
spreadEvent('a', ['b', 'c']),
|
||||
spreadEvent('d', ['e']),
|
||||
];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns in oldest-first order (feed itself is newest-first)', () => {
|
||||
const newest = spreadEvent('new', ['n1']);
|
||||
const older = spreadEvent('old', ['o1']);
|
||||
const out = filterNewSpreadEvents([newest, older], null);
|
||||
expect(out[0].source_id).toBe('old');
|
||||
expect(out[1].source_id).toBe('new');
|
||||
});
|
||||
|
||||
it('stops at the lastSeen reference (object identity)', () => {
|
||||
const oldest = spreadEvent('o', ['x']);
|
||||
const middle = spreadEvent('m', ['y']);
|
||||
const newest = spreadEvent('n', ['z']);
|
||||
// Feed is prepended, so order is [newest, middle, oldest]
|
||||
const feed = [newest, middle, oldest];
|
||||
const out = filterNewSpreadEvents(feed, middle);
|
||||
// Only `newest` is fresh — middle and oldest were already processed.
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source_id).toBe('n');
|
||||
});
|
||||
|
||||
it('returns [] if lastSeen is already the newest event', () => {
|
||||
const e = spreadEvent('a', ['b']);
|
||||
const out = filterNewSpreadEvents([e], e);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores non-ActivationSpread events', () => {
|
||||
const feed: VestigeEvent[] = [
|
||||
{ type: 'MemoryCreated', data: { id: 'x' } },
|
||||
spreadEvent('a', ['b']),
|
||||
{ type: 'Heartbeat', data: {} },
|
||||
];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source_id).toBe('a');
|
||||
});
|
||||
|
||||
it('skips malformed ActivationSpread events (missing / wrong-type fields)', () => {
|
||||
const feed: VestigeEvent[] = [
|
||||
{ type: 'ActivationSpread', data: {} }, // missing both
|
||||
{ type: 'ActivationSpread', data: { source_id: 'a' } }, // no targets
|
||||
{ type: 'ActivationSpread', data: { target_ids: ['b'] } }, // no source
|
||||
{
|
||||
type: 'ActivationSpread',
|
||||
data: { source_id: 'a', target_ids: 'not-an-array' },
|
||||
},
|
||||
{
|
||||
type: 'ActivationSpread',
|
||||
data: { source_id: 'a', target_ids: [123, null, 'x'] },
|
||||
},
|
||||
];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
// Only the last one survives, with numeric/null targets filtered out.
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].source_id).toBe('a');
|
||||
expect(out[0].target_ids).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('preserves target array contents faithfully', () => {
|
||||
const feed = [spreadEvent('src', ['t1', 't2', 't3'])];
|
||||
const out = filterNewSpreadEvents(feed, null);
|
||||
expect(out[0].target_ids).toEqual(['t1', 't2', 't3']);
|
||||
});
|
||||
|
||||
it('does not mutate its inputs', () => {
|
||||
const feed = [spreadEvent('a', ['b', 'c'])];
|
||||
const snapshot = JSON.stringify(feed);
|
||||
filterNewSpreadEvents(feed, null);
|
||||
expect(JSON.stringify(feed)).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanity: exported constants are the values the docstring promises
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('exported constants (contract pinning)', () => {
|
||||
it('RING_1_CAPACITY is 8', () => {
|
||||
expect(RING_1_CAPACITY).toBe(8);
|
||||
});
|
||||
|
||||
it('STAGGER_PER_RANK is 4 frames', () => {
|
||||
expect(STAGGER_PER_RANK).toBe(4);
|
||||
});
|
||||
|
||||
it('STAGGER_RING_2_BONUS is 12 frames', () => {
|
||||
expect(STAGGER_RING_2_BONUS).toBe(12);
|
||||
});
|
||||
|
||||
it('RING_GAP is 140px', () => {
|
||||
expect(RING_GAP).toBe(140);
|
||||
});
|
||||
|
||||
it('SOURCE_COLOR is synapse-glow #818cf8', () => {
|
||||
expect(SOURCE_COLOR).toBe('#818cf8');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ACTIVITY_BUCKET_COUNT,
|
||||
ACTIVITY_BUCKET_MS,
|
||||
ACTIVITY_WINDOW_MS,
|
||||
bucketizeActivity,
|
||||
dreamInsightsCount,
|
||||
findRecentDream,
|
||||
formatAgo,
|
||||
hasRecentSuppression,
|
||||
isDreaming,
|
||||
parseEventTimestamp,
|
||||
type EventLike,
|
||||
} from '../awareness-helpers';
|
||||
|
||||
// Fixed "now" — March 1 2026 12:00:00 UTC. All tests are clock-free.
|
||||
const NOW = Date.parse('2026-03-01T12:00:00.000Z');
|
||||
|
||||
function mkEvent(
|
||||
type: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): EventLike {
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// parseEventTimestamp
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('parseEventTimestamp', () => {
|
||||
it('parses ISO-8601 string', () => {
|
||||
const e = mkEvent('Foo', { timestamp: '2026-03-01T12:00:00.000Z' });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('parses numeric ms (> 1e12)', () => {
|
||||
const e = mkEvent('Foo', { timestamp: NOW });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('parses numeric seconds (<= 1e12) by scaling x1000', () => {
|
||||
const secs = Math.floor(NOW / 1000);
|
||||
const e = mkEvent('Foo', { timestamp: secs });
|
||||
// Allow floating precision — must land in same second
|
||||
const result = parseEventTimestamp(e);
|
||||
expect(result).not.toBeNull();
|
||||
expect(Math.abs((result as number) - NOW)).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('falls back to `at` field', () => {
|
||||
const e = mkEvent('Foo', { at: '2026-03-01T12:00:00.000Z' });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('falls back to `occurred_at` field', () => {
|
||||
const e = mkEvent('Foo', { occurred_at: '2026-03-01T12:00:00.000Z' });
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('prefers `timestamp` over `at` over `occurred_at`', () => {
|
||||
const e = mkEvent('Foo', {
|
||||
timestamp: '2026-03-01T12:00:00.000Z',
|
||||
at: '2020-01-01T00:00:00.000Z',
|
||||
occurred_at: '2019-01-01T00:00:00.000Z',
|
||||
});
|
||||
expect(parseEventTimestamp(e)).toBe(NOW);
|
||||
});
|
||||
|
||||
it('returns null for missing data', () => {
|
||||
expect(parseEventTimestamp({ type: 'Foo' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty data object', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', {}))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for bad ISO string', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: 'not-a-date' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-finite number (NaN)', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: Number.NaN }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-finite number (Infinity)', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: Number.POSITIVE_INFINITY }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for null timestamp', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: null as unknown as string }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-string non-number timestamp (object)', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: {} as unknown as string }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a boolean timestamp', () => {
|
||||
expect(parseEventTimestamp(mkEvent('Foo', { timestamp: true as unknown as string }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// bucketizeActivity
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('bucketizeActivity', () => {
|
||||
it('returns 10 buckets of 30s each covering a 5-min window', () => {
|
||||
expect(ACTIVITY_BUCKET_COUNT).toBe(10);
|
||||
expect(ACTIVITY_BUCKET_MS).toBe(30_000);
|
||||
expect(ACTIVITY_WINDOW_MS).toBe(300_000);
|
||||
const result = bucketizeActivity([], NOW);
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result.every((b) => b.count === 0 && b.ratio === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns newest event to the last bucket (index 9)', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW - 100 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result[9].count).toBe(1);
|
||||
expect(result[9].ratio).toBe(1);
|
||||
for (let i = 0; i < 9; i++) expect(result[i].count).toBe(0);
|
||||
});
|
||||
|
||||
it('assigns oldest-edge event to bucket 0', () => {
|
||||
// Exactly 5 min ago → at start boundary → floor((0)/30s) = 0
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW - ACTIVITY_WINDOW_MS + 1 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it('drops events older than 5 min (clock skew / pre-history)', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW - ACTIVITY_WINDOW_MS - 1 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops future events (negative clock skew)', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: NOW + 5_000 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops Heartbeat events as noise', () => {
|
||||
const e = mkEvent('Heartbeat', { timestamp: NOW - 100 });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops events with unparseable timestamps', () => {
|
||||
const e = mkEvent('MemoryCreated', { timestamp: 'garbage' });
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('distributes events across buckets and computes correct ratios', () => {
|
||||
const events = [
|
||||
// Bucket 9 (newest 30s): 3 events
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 5_000 }),
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 10_000 }),
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 15_000 }),
|
||||
// Bucket 8: 1 event (31s - 60s ago)
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 35_000 }),
|
||||
// Bucket 0 (oldest): 1 event (270s - 300s ago)
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 290_000 }),
|
||||
];
|
||||
const result = bucketizeActivity(events, NOW);
|
||||
expect(result[9].count).toBe(3);
|
||||
expect(result[8].count).toBe(1);
|
||||
expect(result[0].count).toBe(1);
|
||||
expect(result[9].ratio).toBe(1);
|
||||
expect(result[8].ratio).toBeCloseTo(1 / 3, 5);
|
||||
expect(result[0].ratio).toBeCloseTo(1 / 3, 5);
|
||||
});
|
||||
|
||||
it('handles events with numeric ms timestamp', () => {
|
||||
const e = { type: 'MemoryCreated', data: { timestamp: NOW - 10_000 } };
|
||||
const result = bucketizeActivity([e], NOW);
|
||||
expect(result[9].count).toBe(1);
|
||||
});
|
||||
|
||||
it('works with a mixed real-world feed (200 events, some stale)', () => {
|
||||
const events: EventLike[] = [];
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const offset = i * 3_000; // one every 3s, oldest first
|
||||
events.unshift(mkEvent('MemoryCreated', { timestamp: NOW - offset }));
|
||||
}
|
||||
// add 10 Heartbeats mid-stream
|
||||
for (let i = 0; i < 10; i++) {
|
||||
events.push(mkEvent('Heartbeat', { timestamp: NOW - i * 1_000 }));
|
||||
}
|
||||
const result = bucketizeActivity(events, NOW);
|
||||
// 101 events fit in the [now-300s, now] window: offsets 0, 3s, 6s, …, 300s.
|
||||
// Heartbeats excluded. Sum should be exactly 101.
|
||||
const total = result.reduce((s, b) => s + b.count, 0);
|
||||
expect(total).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// findRecentDream
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('findRecentDream', () => {
|
||||
it('returns null on empty feed', () => {
|
||||
expect(findRecentDream([], NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no DreamCompleted in feed', () => {
|
||||
const feed = [
|
||||
mkEvent('MemoryCreated', { timestamp: NOW - 1000 }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 500 }),
|
||||
];
|
||||
expect(findRecentDream(feed, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the newest DreamCompleted within 24h', () => {
|
||||
const fresh = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 60_000,
|
||||
insights_generated: 7,
|
||||
});
|
||||
const stale = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 2 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
// Feed is newest-first
|
||||
const result = findRecentDream([fresh, stale], NOW);
|
||||
expect(result).toBe(fresh);
|
||||
});
|
||||
|
||||
it('returns null when only DreamCompleted is older than 24h', () => {
|
||||
const stale = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 25 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(findRecentDream([stale], NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('exactly 24h ago still counts (inclusive)', () => {
|
||||
const edge = mkEvent('DreamCompleted', {
|
||||
timestamp: NOW - 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(findRecentDream([edge], NOW)).toBe(edge);
|
||||
});
|
||||
|
||||
it('stops at first DreamCompleted in newest-first feed', () => {
|
||||
const newest = mkEvent('DreamCompleted', { timestamp: NOW - 1_000 });
|
||||
const older = mkEvent('DreamCompleted', { timestamp: NOW - 60_000 });
|
||||
expect(findRecentDream([newest, older], NOW)).toBe(newest);
|
||||
});
|
||||
|
||||
it('falls back to nowMs for unparseable timestamps (treated as recent)', () => {
|
||||
const e = mkEvent('DreamCompleted', { timestamp: 'bad' });
|
||||
expect(findRecentDream([e], NOW)).toBe(e);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// dreamInsightsCount
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('dreamInsightsCount', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(dreamInsightsCount(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when missing', () => {
|
||||
expect(dreamInsightsCount(mkEvent('DreamCompleted', {}))).toBeNull();
|
||||
});
|
||||
|
||||
it('reads insights_generated (snake_case)', () => {
|
||||
expect(
|
||||
dreamInsightsCount(mkEvent('DreamCompleted', { insights_generated: 5 })),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it('reads insightsGenerated (camelCase)', () => {
|
||||
expect(
|
||||
dreamInsightsCount(mkEvent('DreamCompleted', { insightsGenerated: 3 })),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('prefers snake_case when both present', () => {
|
||||
expect(
|
||||
dreamInsightsCount(
|
||||
mkEvent('DreamCompleted', { insights_generated: 7, insightsGenerated: 99 }),
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('returns null for non-numeric value', () => {
|
||||
expect(
|
||||
dreamInsightsCount(mkEvent('DreamCompleted', { insights_generated: 'seven' as unknown as number })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// isDreaming
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('isDreaming', () => {
|
||||
it('returns false for empty feed', () => {
|
||||
expect(isDreaming([], NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no DreamStarted in feed', () => {
|
||||
expect(isDreaming([mkEvent('MemoryCreated', { timestamp: NOW })], NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for DreamStarted in last 5 min with no DreamCompleted', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 60_000 })];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for DreamStarted older than 5 min with no DreamCompleted', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 6 * 60 * 1000 })];
|
||||
expect(isDreaming(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when DreamCompleted newer than DreamStarted', () => {
|
||||
// Feed is newest-first: completed, then started
|
||||
const feed = [
|
||||
mkEvent('DreamCompleted', { timestamp: NOW - 30_000 }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 60_000 }),
|
||||
];
|
||||
expect(isDreaming(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when DreamCompleted is OLDER than DreamStarted (new cycle began)', () => {
|
||||
// Newest-first: started is newer, and there's an older completed from a prior cycle
|
||||
const feed = [
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 30_000 }),
|
||||
mkEvent('DreamCompleted', { timestamp: NOW - 10 * 60 * 1000 }),
|
||||
];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('boundary: DreamStarted exactly 5 min ago → still dreaming (>= check)', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: NOW - 5 * 60 * 1000 })];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('only considers FIRST DreamStarted / FIRST DreamCompleted (newest-first semantics)', () => {
|
||||
const feed = [
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 10_000 }),
|
||||
mkEvent('DreamCompleted', { timestamp: NOW - 20_000 }), // older — prior cycle
|
||||
mkEvent('DreamStarted', { timestamp: NOW - 30_000 }), // ignored
|
||||
];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('unparseable DreamStarted timestamp falls back to nowMs (counts as dreaming)', () => {
|
||||
const feed = [mkEvent('DreamStarted', { timestamp: 'bad' })];
|
||||
expect(isDreaming(feed, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// hasRecentSuppression
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('hasRecentSuppression', () => {
|
||||
it('returns false for empty feed', () => {
|
||||
expect(hasRecentSuppression([], NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no MemorySuppressed in feed', () => {
|
||||
const feed = [
|
||||
mkEvent('MemoryCreated', { timestamp: NOW }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW }),
|
||||
];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for MemorySuppressed within 10s', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 5_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for MemorySuppressed older than 10s', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 11_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 8_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW, 5_000)).toBe(false);
|
||||
expect(hasRecentSuppression(feed, NOW, 10_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('stops at first MemorySuppressed (newest-first short-circuit)', () => {
|
||||
const feed = [
|
||||
mkEvent('MemorySuppressed', { timestamp: NOW - 30_000 }), // first, outside window
|
||||
mkEvent('MemorySuppressed', { timestamp: NOW - 1_000 }), // inside, but never checked
|
||||
];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('boundary: exactly at threshold counts (>= check)', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: NOW - 10_000 })];
|
||||
expect(hasRecentSuppression(feed, NOW, 10_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('unparseable timestamp falls back to nowMs (flash fires)', () => {
|
||||
const feed = [mkEvent('MemorySuppressed', { timestamp: 'bad' })];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-MemorySuppressed events before finding one', () => {
|
||||
const feed = [
|
||||
mkEvent('MemoryCreated', { timestamp: NOW }),
|
||||
mkEvent('DreamStarted', { timestamp: NOW }),
|
||||
mkEvent('MemorySuppressed', { timestamp: NOW - 3_000 }),
|
||||
];
|
||||
expect(hasRecentSuppression(feed, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// formatAgo
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe('formatAgo', () => {
|
||||
it('formats seconds', () => {
|
||||
expect(formatAgo(5_000)).toBe('5s ago');
|
||||
expect(formatAgo(59_000)).toBe('59s ago');
|
||||
expect(formatAgo(0)).toBe('0s ago');
|
||||
});
|
||||
|
||||
it('formats minutes', () => {
|
||||
expect(formatAgo(60_000)).toBe('1m ago');
|
||||
expect(formatAgo(59 * 60_000)).toBe('59m ago');
|
||||
});
|
||||
|
||||
it('formats hours', () => {
|
||||
expect(formatAgo(60 * 60_000)).toBe('1h ago');
|
||||
expect(formatAgo(23 * 60 * 60_000)).toBe('23h ago');
|
||||
});
|
||||
|
||||
it('formats days', () => {
|
||||
expect(formatAgo(24 * 60 * 60_000)).toBe('1d ago');
|
||||
expect(formatAgo(7 * 24 * 60 * 60_000)).toBe('7d ago');
|
||||
});
|
||||
|
||||
it('clamps negative input to 0', () => {
|
||||
expect(formatAgo(-5_000)).toBe('0s ago');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Contradiction Constellation — pure-helper coverage.
|
||||
*
|
||||
* Runs in the vitest `node` environment (no jsdom). We only test the pure
|
||||
* helpers extracted to `contradiction-helpers.ts`; the Svelte component is
|
||||
* covered indirectly because every classification, opacity, radius, and
|
||||
* color decision it renders routes through these functions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
severityColor,
|
||||
severityLabel,
|
||||
nodeColor,
|
||||
nodeRadius,
|
||||
clampTrust,
|
||||
pairOpacity,
|
||||
truncate,
|
||||
uniqueMemoryCount,
|
||||
avgTrustDelta,
|
||||
NODE_COLORS,
|
||||
KNOWN_NODE_TYPES,
|
||||
NODE_COLOR_FALLBACK,
|
||||
NODE_RADIUS_MIN,
|
||||
NODE_RADIUS_RANGE,
|
||||
SEVERITY_STRONG_COLOR,
|
||||
SEVERITY_MODERATE_COLOR,
|
||||
SEVERITY_MILD_COLOR,
|
||||
UNFOCUSED_OPACITY,
|
||||
type ContradictionLike,
|
||||
} from '../contradiction-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// severityColor — strict-greater-than thresholds at 0.5 and 0.7.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('severityColor', () => {
|
||||
it('returns mild yellow at or below 0.5', () => {
|
||||
expect(severityColor(0)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(0.29)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(0.3)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(0.5)).toBe(SEVERITY_MILD_COLOR); // boundary → lower band
|
||||
});
|
||||
|
||||
it('returns moderate amber strictly above 0.5 and up to 0.7', () => {
|
||||
expect(severityColor(0.51)).toBe(SEVERITY_MODERATE_COLOR);
|
||||
expect(severityColor(0.6)).toBe(SEVERITY_MODERATE_COLOR);
|
||||
expect(severityColor(0.7)).toBe(SEVERITY_MODERATE_COLOR); // boundary → lower band
|
||||
});
|
||||
|
||||
it('returns strong red strictly above 0.7', () => {
|
||||
expect(severityColor(0.71)).toBe(SEVERITY_STRONG_COLOR);
|
||||
expect(severityColor(0.9)).toBe(SEVERITY_STRONG_COLOR);
|
||||
expect(severityColor(1.0)).toBe(SEVERITY_STRONG_COLOR);
|
||||
});
|
||||
|
||||
it('handles out-of-range numbers without crashing', () => {
|
||||
expect(severityColor(-1)).toBe(SEVERITY_MILD_COLOR);
|
||||
expect(severityColor(1.5)).toBe(SEVERITY_STRONG_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// severityLabel — matches severityColor thresholds.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('severityLabel', () => {
|
||||
it('labels mild at 0, 0.29, 0.3, 0.5', () => {
|
||||
expect(severityLabel(0)).toBe('mild');
|
||||
expect(severityLabel(0.29)).toBe('mild');
|
||||
expect(severityLabel(0.3)).toBe('mild');
|
||||
expect(severityLabel(0.5)).toBe('mild');
|
||||
});
|
||||
|
||||
it('labels moderate at 0.51, 0.7', () => {
|
||||
expect(severityLabel(0.51)).toBe('moderate');
|
||||
expect(severityLabel(0.7)).toBe('moderate');
|
||||
});
|
||||
|
||||
it('labels strong at 0.71, 1.0', () => {
|
||||
expect(severityLabel(0.71)).toBe('strong');
|
||||
expect(severityLabel(1.0)).toBe('strong');
|
||||
});
|
||||
|
||||
it('covers all 8 ordered boundary cases from the audit', () => {
|
||||
expect(severityLabel(0)).toBe('mild');
|
||||
expect(severityLabel(0.29)).toBe('mild');
|
||||
expect(severityLabel(0.3)).toBe('mild');
|
||||
expect(severityLabel(0.5)).toBe('mild');
|
||||
expect(severityLabel(0.51)).toBe('moderate');
|
||||
expect(severityLabel(0.7)).toBe('moderate');
|
||||
expect(severityLabel(0.71)).toBe('strong');
|
||||
expect(severityLabel(1.0)).toBe('strong');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeColor — 8 known types plus fallback.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nodeColor', () => {
|
||||
it('returns distinct colors for each of the 8 known node types', () => {
|
||||
const colors = KNOWN_NODE_TYPES.map((t) => nodeColor(t));
|
||||
expect(colors.length).toBe(8);
|
||||
expect(new Set(colors).size).toBe(8); // all distinct
|
||||
});
|
||||
|
||||
it('matches the canonical palette exactly', () => {
|
||||
expect(nodeColor('fact')).toBe(NODE_COLORS.fact);
|
||||
expect(nodeColor('concept')).toBe(NODE_COLORS.concept);
|
||||
expect(nodeColor('event')).toBe(NODE_COLORS.event);
|
||||
expect(nodeColor('person')).toBe(NODE_COLORS.person);
|
||||
expect(nodeColor('place')).toBe(NODE_COLORS.place);
|
||||
expect(nodeColor('note')).toBe(NODE_COLORS.note);
|
||||
expect(nodeColor('pattern')).toBe(NODE_COLORS.pattern);
|
||||
expect(nodeColor('decision')).toBe(NODE_COLORS.decision);
|
||||
});
|
||||
|
||||
it('falls back to violet for unknown / missing types', () => {
|
||||
expect(nodeColor(undefined)).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor(null)).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor('')).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor('bogus')).toBe(NODE_COLOR_FALLBACK);
|
||||
expect(nodeColor('FACT')).toBe(NODE_COLOR_FALLBACK); // case-sensitive
|
||||
});
|
||||
|
||||
it('violet fallback equals 0x8b5cf6', () => {
|
||||
expect(NODE_COLOR_FALLBACK).toBe('#8b5cf6');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nodeRadius + clampTrust — trust is defined on [0,1].
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nodeRadius', () => {
|
||||
it('returns the minimum radius at trust=0', () => {
|
||||
expect(nodeRadius(0)).toBe(NODE_RADIUS_MIN);
|
||||
});
|
||||
|
||||
it('returns min + range at trust=1', () => {
|
||||
expect(nodeRadius(1)).toBe(NODE_RADIUS_MIN + NODE_RADIUS_RANGE);
|
||||
});
|
||||
|
||||
it('scales linearly in between', () => {
|
||||
expect(nodeRadius(0.5)).toBeCloseTo(NODE_RADIUS_MIN + NODE_RADIUS_RANGE * 0.5);
|
||||
});
|
||||
|
||||
it('clamps negative trust to 0 (minimum radius)', () => {
|
||||
expect(nodeRadius(-0.5)).toBe(NODE_RADIUS_MIN);
|
||||
expect(nodeRadius(-Infinity)).toBe(NODE_RADIUS_MIN);
|
||||
});
|
||||
|
||||
it('clamps >1 trust to 1 (maximum radius)', () => {
|
||||
expect(nodeRadius(2)).toBe(NODE_RADIUS_MIN + NODE_RADIUS_RANGE);
|
||||
expect(nodeRadius(Infinity)).toBe(NODE_RADIUS_MIN);
|
||||
// ^ Infinity isn't finite — falls back to min, matching "suppress suspicious data"
|
||||
});
|
||||
|
||||
it('treats NaN as minimum (suppress bad data)', () => {
|
||||
expect(nodeRadius(NaN)).toBe(NODE_RADIUS_MIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampTrust', () => {
|
||||
it('returns values inside [0,1] unchanged', () => {
|
||||
expect(clampTrust(0)).toBe(0);
|
||||
expect(clampTrust(0.5)).toBe(0.5);
|
||||
expect(clampTrust(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps negatives to 0 and >1 to 1', () => {
|
||||
expect(clampTrust(-0.3)).toBe(0);
|
||||
expect(clampTrust(1.3)).toBe(1);
|
||||
});
|
||||
|
||||
it('collapses NaN / null / undefined / Infinity to 0', () => {
|
||||
expect(clampTrust(NaN)).toBe(0);
|
||||
expect(clampTrust(null)).toBe(0);
|
||||
expect(clampTrust(undefined)).toBe(0);
|
||||
expect(clampTrust(Infinity)).toBe(0);
|
||||
expect(clampTrust(-Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pairOpacity — trinary: no focus = 1, focused = 1, unfocused = 0.12.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pairOpacity', () => {
|
||||
it('returns 1 when no pair is focused (null)', () => {
|
||||
expect(pairOpacity(0, null)).toBe(1);
|
||||
expect(pairOpacity(5, null)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when no pair is focused (undefined)', () => {
|
||||
expect(pairOpacity(0, undefined)).toBe(1);
|
||||
expect(pairOpacity(5, undefined)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for the focused pair', () => {
|
||||
expect(pairOpacity(3, 3)).toBe(1);
|
||||
expect(pairOpacity(0, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0.12 for a non-focused pair when something is focused', () => {
|
||||
expect(pairOpacity(0, 3)).toBe(UNFOCUSED_OPACITY);
|
||||
expect(pairOpacity(7, 3)).toBe(UNFOCUSED_OPACITY);
|
||||
});
|
||||
|
||||
it('does not explode for a stale focus index that matches nothing', () => {
|
||||
// A focus index of 999 with only 5 pairs: every visible pair dims to 0.12.
|
||||
// The missing pair renders nothing (silent no-op is correct).
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(pairOpacity(i, 999)).toBe(UNFOCUSED_OPACITY);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// truncate — length boundaries, empties, odd inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns strings shorter than max unchanged', () => {
|
||||
expect(truncate('hi', 10)).toBe('hi');
|
||||
expect(truncate('abc', 5)).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns empty strings unchanged', () => {
|
||||
expect(truncate('', 5)).toBe('');
|
||||
expect(truncate('', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('returns strings exactly at max unchanged', () => {
|
||||
expect(truncate('12345', 5)).toBe('12345');
|
||||
expect(truncate('abcdef', 6)).toBe('abcdef');
|
||||
});
|
||||
|
||||
it('cuts strings longer than max, appending ellipsis within budget', () => {
|
||||
expect(truncate('1234567890', 5)).toBe('1234…');
|
||||
expect(truncate('hello world', 6)).toBe('hello…');
|
||||
});
|
||||
|
||||
it('uses default max of 60', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
const out = truncate(long);
|
||||
expect(out.length).toBe(60);
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('null / undefined inputs return empty string', () => {
|
||||
expect(truncate(null)).toBe('');
|
||||
expect(truncate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('handles max=0 safely', () => {
|
||||
expect(truncate('any string', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('handles max=1 safely — one-char budget collapses to just the ellipsis', () => {
|
||||
expect(truncate('abc', 1)).toBe('…');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uniqueMemoryCount — union of memory_a_id + memory_b_id across pairs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('uniqueMemoryCount', () => {
|
||||
const mkPair = (a: string, b: string): ContradictionLike => ({
|
||||
memory_a_id: a,
|
||||
memory_b_id: b,
|
||||
});
|
||||
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(uniqueMemoryCount([])).toBe(0);
|
||||
});
|
||||
|
||||
it('counts both sides of every pair', () => {
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b')])).toBe(2);
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('c', 'd')])).toBe(4);
|
||||
});
|
||||
|
||||
it('deduplicates memories that appear in multiple pairs', () => {
|
||||
// 'a' appears on both sides of two separate pairs.
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('a', 'c')])).toBe(3);
|
||||
expect(uniqueMemoryCount([mkPair('a', 'b'), mkPair('b', 'a')])).toBe(2);
|
||||
});
|
||||
|
||||
it('handles a memory conflicting with itself (same id both sides)', () => {
|
||||
expect(uniqueMemoryCount([mkPair('a', 'a')])).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores empty-string ids', () => {
|
||||
expect(uniqueMemoryCount([mkPair('', '')])).toBe(0);
|
||||
expect(uniqueMemoryCount([mkPair('a', '')])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// avgTrustDelta — safety against empty inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('avgTrustDelta', () => {
|
||||
it('returns 0 on empty input (no NaN)', () => {
|
||||
expect(avgTrustDelta([])).toBe(0);
|
||||
});
|
||||
|
||||
it('computes mean absolute delta', () => {
|
||||
const pairs = [
|
||||
{ trust_a: 0.9, trust_b: 0.1 }, // 0.8
|
||||
{ trust_a: 0.5, trust_b: 0.3 }, // 0.2
|
||||
];
|
||||
expect(avgTrustDelta(pairs)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('takes absolute value (order does not matter)', () => {
|
||||
expect(avgTrustDelta([{ trust_a: 0.1, trust_b: 0.9 }])).toBeCloseTo(0.8);
|
||||
expect(avgTrustDelta([{ trust_a: 0.9, trust_b: 0.1 }])).toBeCloseTo(0.8);
|
||||
});
|
||||
|
||||
it('returns 0 when both sides are equal', () => {
|
||||
expect(avgTrustDelta([{ trust_a: 0.5, trust_b: 0.5 }])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Tests for DreamInsightCard helpers.
|
||||
*
|
||||
* Pure logic only — the Svelte template is a thin wrapper around these.
|
||||
* Covers the boundaries of the gold-glow / muted novelty mapping, the
|
||||
* formatting helpers, and the source-memory link scheme.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
LOW_NOVELTY_THRESHOLD,
|
||||
HIGH_NOVELTY_THRESHOLD,
|
||||
clamp01,
|
||||
noveltyBand,
|
||||
formatDurationMs,
|
||||
formatConfidencePct,
|
||||
sourceMemoryHref,
|
||||
firstSourceIds,
|
||||
extraSourceCount,
|
||||
shortMemoryId,
|
||||
} from '../dream-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clamp01
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('clamp01', () => {
|
||||
it.each<[number | null | undefined, number]>([
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
[0.5, 0.5],
|
||||
[-0.1, 0],
|
||||
[-5, 0],
|
||||
[1.1, 1],
|
||||
[100, 1],
|
||||
[null, 0],
|
||||
[undefined, 0],
|
||||
[Number.NaN, 0],
|
||||
[Number.POSITIVE_INFINITY, 0],
|
||||
[Number.NEGATIVE_INFINITY, 0],
|
||||
])('clamp01(%s) → %s', (input, expected) => {
|
||||
expect(clamp01(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// noveltyBand — the gold/muted visual classifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('noveltyBand — gold-glow / muted classification', () => {
|
||||
it('has the documented thresholds', () => {
|
||||
// These constants are contractual — the component's class bindings
|
||||
// depend on them. If they change, the visual band shifts.
|
||||
expect(LOW_NOVELTY_THRESHOLD).toBe(0.3);
|
||||
expect(HIGH_NOVELTY_THRESHOLD).toBe(0.7);
|
||||
});
|
||||
|
||||
it('classifies low-novelty (< 0.3) as muted', () => {
|
||||
expect(noveltyBand(0)).toBe('low');
|
||||
expect(noveltyBand(0.1)).toBe('low');
|
||||
expect(noveltyBand(0.29)).toBe('low');
|
||||
expect(noveltyBand(0.2999)).toBe('low');
|
||||
});
|
||||
|
||||
it('classifies the boundary 0.3 exactly as neutral (NOT low)', () => {
|
||||
// The component uses `novelty < 0.3`, strictly exclusive.
|
||||
expect(noveltyBand(0.3)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('classifies mid-range as neutral', () => {
|
||||
expect(noveltyBand(0.3)).toBe('neutral');
|
||||
expect(noveltyBand(0.5)).toBe('neutral');
|
||||
expect(noveltyBand(0.7)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('classifies the boundary 0.7 exactly as neutral (NOT high)', () => {
|
||||
// The component uses `novelty > 0.7`, strictly exclusive.
|
||||
expect(noveltyBand(0.7)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('classifies high-novelty (> 0.7) as gold/high', () => {
|
||||
expect(noveltyBand(0.71)).toBe('high');
|
||||
expect(noveltyBand(0.7001)).toBe('high');
|
||||
expect(noveltyBand(0.9)).toBe('high');
|
||||
expect(noveltyBand(1.0)).toBe('high');
|
||||
});
|
||||
|
||||
it('collapses null / undefined / NaN to the low band', () => {
|
||||
expect(noveltyBand(null)).toBe('low');
|
||||
expect(noveltyBand(undefined)).toBe('low');
|
||||
expect(noveltyBand(Number.NaN)).toBe('low');
|
||||
});
|
||||
|
||||
it('clamps out-of-range values before classifying', () => {
|
||||
// 2.0 clamps to 1.0 → high; -1 clamps to 0 → low.
|
||||
expect(noveltyBand(2.0)).toBe('high');
|
||||
expect(noveltyBand(-1)).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDurationMs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatDurationMs', () => {
|
||||
it('renders sub-second values with "ms" suffix', () => {
|
||||
expect(formatDurationMs(0)).toBe('0ms');
|
||||
expect(formatDurationMs(1)).toBe('1ms');
|
||||
expect(formatDurationMs(500)).toBe('500ms');
|
||||
expect(formatDurationMs(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('renders second-and-above values with "s" suffix, 2 decimals', () => {
|
||||
expect(formatDurationMs(1000)).toBe('1.00s');
|
||||
expect(formatDurationMs(1500)).toBe('1.50s');
|
||||
expect(formatDurationMs(15000)).toBe('15.00s');
|
||||
expect(formatDurationMs(60000)).toBe('60.00s');
|
||||
});
|
||||
|
||||
it('rounds fractional millisecond values in the "ms" band', () => {
|
||||
expect(formatDurationMs(0.4)).toBe('0ms');
|
||||
expect(formatDurationMs(12.7)).toBe('13ms');
|
||||
});
|
||||
|
||||
it('returns "0ms" for null / undefined / NaN / negative', () => {
|
||||
expect(formatDurationMs(null)).toBe('0ms');
|
||||
expect(formatDurationMs(undefined)).toBe('0ms');
|
||||
expect(formatDurationMs(Number.NaN)).toBe('0ms');
|
||||
expect(formatDurationMs(-100)).toBe('0ms');
|
||||
expect(formatDurationMs(Number.POSITIVE_INFINITY)).toBe('0ms');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatConfidencePct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatConfidencePct', () => {
|
||||
it('renders 0 / 0.5 / 1 as whole-percent strings', () => {
|
||||
expect(formatConfidencePct(0)).toBe('0%');
|
||||
expect(formatConfidencePct(0.5)).toBe('50%');
|
||||
expect(formatConfidencePct(1)).toBe('100%');
|
||||
});
|
||||
|
||||
it('rounds intermediate values', () => {
|
||||
expect(formatConfidencePct(0.123)).toBe('12%');
|
||||
expect(formatConfidencePct(0.5049)).toBe('50%');
|
||||
expect(formatConfidencePct(0.505)).toBe('51%');
|
||||
expect(formatConfidencePct(0.999)).toBe('100%');
|
||||
});
|
||||
|
||||
it('clamps out-of-range input first', () => {
|
||||
expect(formatConfidencePct(-0.5)).toBe('0%');
|
||||
expect(formatConfidencePct(2)).toBe('100%');
|
||||
});
|
||||
|
||||
it('handles null / undefined / NaN', () => {
|
||||
expect(formatConfidencePct(null)).toBe('0%');
|
||||
expect(formatConfidencePct(undefined)).toBe('0%');
|
||||
expect(formatConfidencePct(Number.NaN)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sourceMemoryHref
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sourceMemoryHref — link format', () => {
|
||||
it('builds the canonical /memories/:id path with no base', () => {
|
||||
expect(sourceMemoryHref('abc123')).toBe('/memories/abc123');
|
||||
});
|
||||
|
||||
it('prepends the SvelteKit base path when provided', () => {
|
||||
expect(sourceMemoryHref('abc123', '/dashboard')).toBe(
|
||||
'/dashboard/memories/abc123',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles an empty base (default behaviour)', () => {
|
||||
expect(sourceMemoryHref('abc', '')).toBe('/memories/abc');
|
||||
});
|
||||
|
||||
it('passes through full UUIDs untouched', () => {
|
||||
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(sourceMemoryHref(uuid)).toBe(`/memories/${uuid}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// firstSourceIds + extraSourceCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('firstSourceIds', () => {
|
||||
it('returns [] for empty / null / undefined inputs', () => {
|
||||
expect(firstSourceIds([])).toEqual([]);
|
||||
expect(firstSourceIds(null)).toEqual([]);
|
||||
expect(firstSourceIds(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the single element when array has one entry', () => {
|
||||
expect(firstSourceIds(['a'])).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('returns the first 2 by default', () => {
|
||||
expect(firstSourceIds(['a', 'b', 'c', 'd'])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('honours a custom N', () => {
|
||||
expect(firstSourceIds(['a', 'b', 'c', 'd'], 3)).toEqual(['a', 'b', 'c']);
|
||||
expect(firstSourceIds(['a', 'b', 'c'], 5)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('returns [] for non-positive N', () => {
|
||||
expect(firstSourceIds(['a', 'b'], 0)).toEqual([]);
|
||||
expect(firstSourceIds(['a', 'b'], -1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extraSourceCount', () => {
|
||||
it('returns 0 when there are no extras', () => {
|
||||
expect(extraSourceCount([])).toBe(0);
|
||||
expect(extraSourceCount(null)).toBe(0);
|
||||
expect(extraSourceCount(['a'])).toBe(0);
|
||||
expect(extraSourceCount(['a', 'b'])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns sources.length - shown when there are extras', () => {
|
||||
expect(extraSourceCount(['a', 'b', 'c'])).toBe(1);
|
||||
expect(extraSourceCount(['a', 'b', 'c', 'd', 'e'])).toBe(3);
|
||||
});
|
||||
|
||||
it('honours a custom shown parameter', () => {
|
||||
expect(extraSourceCount(['a', 'b', 'c', 'd', 'e'], 3)).toBe(2);
|
||||
expect(extraSourceCount(['a', 'b'], 5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shortMemoryId
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shortMemoryId', () => {
|
||||
it('returns the full string when 8 chars or fewer', () => {
|
||||
expect(shortMemoryId('abc')).toBe('abc');
|
||||
expect(shortMemoryId('12345678')).toBe('12345678');
|
||||
});
|
||||
|
||||
it('slices to 8 chars when longer', () => {
|
||||
expect(shortMemoryId('123456789')).toBe('12345678');
|
||||
expect(shortMemoryId('550e8400-e29b-41d4-a716-446655440000')).toBe(
|
||||
'550e8400',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty string defensively', () => {
|
||||
expect(shortMemoryId('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Tests for DreamStageReplay helpers.
|
||||
*
|
||||
* The Svelte component itself is rendered with CSS transforms + derived
|
||||
* state. We can't mount it in Node without jsdom, so we test the PURE
|
||||
* helpers it relies on — the same helpers also power the page's scrubber
|
||||
* and the insight card. If `clampStage` is green, the scrubber can't go
|
||||
* out of range; if `STAGE_NAMES` stays in sync with MemoryDreamer's 5
|
||||
* phases, the badge labels stay correct.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
STAGE_COUNT,
|
||||
STAGE_NAMES,
|
||||
clampStage,
|
||||
stageName,
|
||||
} from '../dream-helpers';
|
||||
|
||||
describe('STAGE_NAMES — MemoryDreamer phase list', () => {
|
||||
it('has exactly 5 stages matching MemoryDreamer.run()', () => {
|
||||
expect(STAGE_COUNT).toBe(5);
|
||||
expect(STAGE_NAMES).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('lists the phases in the canonical order', () => {
|
||||
// Order is load-bearing: the stage replay animates in this sequence.
|
||||
// Replay → Cross-reference → Strengthen → Prune → Transfer.
|
||||
expect(STAGE_NAMES).toEqual([
|
||||
'Replay',
|
||||
'Cross-reference',
|
||||
'Strengthen',
|
||||
'Prune',
|
||||
'Transfer',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampStage — valid-range enforcement', () => {
|
||||
it.each<[number, number]>([
|
||||
// Out-of-bounds low
|
||||
[0, 1],
|
||||
[-1, 1],
|
||||
[-100, 1],
|
||||
// In-range (exactly the valid stage indices)
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
// Out-of-bounds high
|
||||
[6, 5],
|
||||
[7, 5],
|
||||
[100, 5],
|
||||
])('clampStage(%s) → %s', (input, expected) => {
|
||||
expect(clampStage(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('floors fractional values before clamping', () => {
|
||||
expect(clampStage(1.9)).toBe(1);
|
||||
expect(clampStage(4.9)).toBe(4);
|
||||
expect(clampStage(5.1)).toBe(5);
|
||||
});
|
||||
|
||||
it('collapses NaN / Infinity / -Infinity to stage 1', () => {
|
||||
expect(clampStage(Number.NaN)).toBe(1);
|
||||
expect(clampStage(Number.POSITIVE_INFINITY)).toBe(1);
|
||||
expect(clampStage(Number.NEGATIVE_INFINITY)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns a value usable as a 0-indexed STAGE_NAMES lookup', () => {
|
||||
// The page uses `STAGE_NAMES[stageIdx - 1]`. Every clamped value
|
||||
// must index a real name, not undefined.
|
||||
for (const raw of [-5, 0, 1, 3, 5, 10, Number.NaN]) {
|
||||
const idx = clampStage(raw);
|
||||
expect(STAGE_NAMES[idx - 1]).toBeDefined();
|
||||
expect(typeof STAGE_NAMES[idx - 1]).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageName — resolves to the visible label', () => {
|
||||
it('returns the matching name for every valid stage', () => {
|
||||
expect(stageName(1)).toBe('Replay');
|
||||
expect(stageName(2)).toBe('Cross-reference');
|
||||
expect(stageName(3)).toBe('Strengthen');
|
||||
expect(stageName(4)).toBe('Prune');
|
||||
expect(stageName(5)).toBe('Transfer');
|
||||
});
|
||||
|
||||
it('falls back to the nearest valid name for out-of-range input', () => {
|
||||
expect(stageName(0)).toBe('Replay');
|
||||
expect(stageName(-1)).toBe('Replay');
|
||||
expect(stageName(6)).toBe('Transfer');
|
||||
expect(stageName(100)).toBe('Transfer');
|
||||
});
|
||||
|
||||
it('never returns undefined, even for garbage input', () => {
|
||||
for (const raw of [Number.NaN, Number.POSITIVE_INFINITY, -Number.MAX_VALUE]) {
|
||||
expect(stageName(raw)).toBeDefined();
|
||||
expect(stageName(raw)).toMatch(/^[A-Z]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* Pure-logic tests for the Memory Hygiene / Duplicate Detection UI.
|
||||
*
|
||||
* The Svelte components themselves are render-level code (no jsdom in this
|
||||
* repo) — every ounce of behaviour worth testing is extracted into
|
||||
* `duplicates-helpers.ts` and exercised here. If this file is green, the
|
||||
* similarity bands, winner selection, suggested-action mapping, threshold
|
||||
* filtering, cluster-identity keying, and the "safe render" helpers are all
|
||||
* sound.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
similarityBand,
|
||||
similarityBandColor,
|
||||
similarityBandLabel,
|
||||
retentionColor,
|
||||
pickWinner,
|
||||
suggestedActionFor,
|
||||
filterByThreshold,
|
||||
clusterKey,
|
||||
previewContent,
|
||||
formatDate,
|
||||
safeTags,
|
||||
} from '../duplicates-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Similarity band — boundaries at 0.92 (red) and 0.80 (amber).
|
||||
// The boundary value MUST land in the higher band (>= semantics).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('similarityBand', () => {
|
||||
it('0.92 exactly → near-identical (boundary)', () => {
|
||||
expect(similarityBand(0.92)).toBe('near-identical');
|
||||
});
|
||||
|
||||
it('0.91 → strong (just below upper boundary)', () => {
|
||||
expect(similarityBand(0.91)).toBe('strong');
|
||||
});
|
||||
|
||||
it('0.80 exactly → strong (boundary)', () => {
|
||||
expect(similarityBand(0.8)).toBe('strong');
|
||||
});
|
||||
|
||||
it('0.79 → weak (just below strong boundary)', () => {
|
||||
expect(similarityBand(0.79)).toBe('weak');
|
||||
});
|
||||
|
||||
it('0.50 → weak (well below)', () => {
|
||||
expect(similarityBand(0.5)).toBe('weak');
|
||||
});
|
||||
|
||||
it('1.0 → near-identical', () => {
|
||||
expect(similarityBand(1.0)).toBe('near-identical');
|
||||
});
|
||||
|
||||
it('0.0 → weak', () => {
|
||||
expect(similarityBand(0.0)).toBe('weak');
|
||||
});
|
||||
});
|
||||
|
||||
describe('similarityBandColor', () => {
|
||||
it('near-identical → decay var (red)', () => {
|
||||
expect(similarityBandColor(0.95)).toBe('var(--color-decay)');
|
||||
});
|
||||
|
||||
it('strong → warning var (amber)', () => {
|
||||
expect(similarityBandColor(0.85)).toBe('var(--color-warning)');
|
||||
});
|
||||
|
||||
it('weak → yellow-300 literal', () => {
|
||||
expect(similarityBandColor(0.78)).toBe('#fde047');
|
||||
});
|
||||
|
||||
it('is consistent at boundary 0.92', () => {
|
||||
expect(similarityBandColor(0.92)).toBe('var(--color-decay)');
|
||||
});
|
||||
|
||||
it('is consistent at boundary 0.80', () => {
|
||||
expect(similarityBandColor(0.8)).toBe('var(--color-warning)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('similarityBandLabel', () => {
|
||||
it('labels near-identical', () => {
|
||||
expect(similarityBandLabel(0.97)).toBe('Near-identical');
|
||||
});
|
||||
|
||||
it('labels strong', () => {
|
||||
expect(similarityBandLabel(0.85)).toBe('Strong match');
|
||||
});
|
||||
|
||||
it('labels weak', () => {
|
||||
expect(similarityBandLabel(0.75)).toBe('Weak match');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention color — traffic-light: >0.7 green, >0.4 amber, else red.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('retentionColor', () => {
|
||||
it('0.85 → green', () => expect(retentionColor(0.85)).toBe('#10b981'));
|
||||
it('0.50 → amber', () => expect(retentionColor(0.5)).toBe('#f59e0b'));
|
||||
it('0.30 → red', () => expect(retentionColor(0.3)).toBe('#ef4444'));
|
||||
it('boundary 0.70 → amber (strict >)', () => expect(retentionColor(0.7)).toBe('#f59e0b'));
|
||||
it('boundary 0.40 → red (strict >)', () => expect(retentionColor(0.4)).toBe('#ef4444'));
|
||||
it('0.0 → red', () => expect(retentionColor(0)).toBe('#ef4444'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Winner selection — highest retention wins; ties → earliest index; empty
|
||||
// list → null; NaN retentions never win.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('pickWinner', () => {
|
||||
it('picks highest retention', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: 0.3 },
|
||||
{ id: 'b', retention: 0.9 },
|
||||
{ id: 'c', retention: 0.5 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('tie-break: earliest wins (stable)', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: 0.8 },
|
||||
{ id: 'b', retention: 0.8 },
|
||||
{ id: 'c', retention: 0.7 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('a');
|
||||
});
|
||||
|
||||
it('three-way tie: earliest wins', () => {
|
||||
const mem = [
|
||||
{ id: 'x', retention: 0.5 },
|
||||
{ id: 'y', retention: 0.5 },
|
||||
{ id: 'z', retention: 0.5 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('x');
|
||||
});
|
||||
|
||||
it('all retention = 0: earliest wins (not null)', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('a');
|
||||
});
|
||||
|
||||
it('single-member cluster: that member wins', () => {
|
||||
const mem = [{ id: 'solo', retention: 0.42 }];
|
||||
expect(pickWinner(mem)?.id).toBe('solo');
|
||||
});
|
||||
|
||||
it('empty cluster: returns null', () => {
|
||||
expect(pickWinner([])).toBeNull();
|
||||
});
|
||||
|
||||
it('NaN retention never wins over a real one', () => {
|
||||
const mem = [
|
||||
{ id: 'nan', retention: Number.NaN },
|
||||
{ id: 'real', retention: 0.1 },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('real');
|
||||
});
|
||||
|
||||
it('all NaN retentions: earliest wins (stable fallback)', () => {
|
||||
const mem = [
|
||||
{ id: 'a', retention: Number.NaN },
|
||||
{ id: 'b', retention: Number.NaN },
|
||||
];
|
||||
expect(pickWinner(mem)?.id).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggested action — >=0.92 merge, <0.85 review, 0.85..<0.92 null (caller
|
||||
// honors upstream).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('suggestedActionFor', () => {
|
||||
it('0.95 → merge', () => expect(suggestedActionFor(0.95)).toBe('merge'));
|
||||
it('0.92 exactly → merge (boundary)', () => expect(suggestedActionFor(0.92)).toBe('merge'));
|
||||
it('0.91 → null (ambiguous corridor)', () => expect(suggestedActionFor(0.91)).toBeNull());
|
||||
it('0.85 exactly → null (corridor bottom boundary)', () =>
|
||||
expect(suggestedActionFor(0.85)).toBeNull());
|
||||
it('0.849 → review (just below corridor)', () =>
|
||||
expect(suggestedActionFor(0.849)).toBe('review'));
|
||||
it('0.70 → review', () => expect(suggestedActionFor(0.7)).toBe('review'));
|
||||
it('0.0 → review', () => expect(suggestedActionFor(0)).toBe('review'));
|
||||
it('1.0 → merge', () => expect(suggestedActionFor(1.0)).toBe('merge'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threshold filter — strict >=.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('filterByThreshold', () => {
|
||||
const clusters = [
|
||||
{ similarity: 0.96, memories: [{ id: '1', retention: 1 }] },
|
||||
{ similarity: 0.88, memories: [{ id: '2', retention: 1 }] },
|
||||
{ similarity: 0.78, memories: [{ id: '3', retention: 1 }] },
|
||||
];
|
||||
|
||||
it('0.80 keeps 0.96 and 0.88 (drops 0.78)', () => {
|
||||
const out = filterByThreshold(clusters, 0.8);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96, 0.88]);
|
||||
});
|
||||
|
||||
it('boundary: threshold = 0.88 keeps 0.88 (>=)', () => {
|
||||
const out = filterByThreshold(clusters, 0.88);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96, 0.88]);
|
||||
});
|
||||
|
||||
it('boundary: threshold = 0.881 drops 0.88', () => {
|
||||
const out = filterByThreshold(clusters, 0.881);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96]);
|
||||
});
|
||||
|
||||
it('0.95 (max) keeps only 0.96', () => {
|
||||
const out = filterByThreshold(clusters, 0.95);
|
||||
expect(out.map((c) => c.similarity)).toEqual([0.96]);
|
||||
});
|
||||
|
||||
it('0.70 (min) keeps all three', () => {
|
||||
const out = filterByThreshold(clusters, 0.7);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('empty input → empty output', () => {
|
||||
expect(filterByThreshold([], 0.8)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cluster identity — stable across order shuffles and re-fetches.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('clusterKey', () => {
|
||||
it('identical member sets → identical keys (order-independent)', () => {
|
||||
const a = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
{ id: 'c', retention: 0 },
|
||||
];
|
||||
const b = [
|
||||
{ id: 'c', retention: 0 },
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
];
|
||||
expect(clusterKey(a)).toBe(clusterKey(b));
|
||||
});
|
||||
|
||||
it('differing members → differing keys', () => {
|
||||
const a = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'b', retention: 0 },
|
||||
];
|
||||
const b = [
|
||||
{ id: 'a', retention: 0 },
|
||||
{ id: 'c', retention: 0 },
|
||||
];
|
||||
expect(clusterKey(a)).not.toBe(clusterKey(b));
|
||||
});
|
||||
|
||||
it('does not mutate input order', () => {
|
||||
const mem = [
|
||||
{ id: 'z', retention: 0 },
|
||||
{ id: 'a', retention: 0 },
|
||||
];
|
||||
clusterKey(mem);
|
||||
expect(mem.map((m) => m.id)).toEqual(['z', 'a']);
|
||||
});
|
||||
|
||||
it('empty cluster → empty string', () => {
|
||||
expect(clusterKey([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// previewContent — trim + collapse whitespace + truncate at 80.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('previewContent', () => {
|
||||
it('short content: unchanged', () => {
|
||||
expect(previewContent('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('collapses internal whitespace', () => {
|
||||
expect(previewContent(' hello world ')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('truncates with ellipsis', () => {
|
||||
const long = 'a'.repeat(120);
|
||||
const out = previewContent(long);
|
||||
expect(out.length).toBe(81); // 80 + ellipsis
|
||||
expect(out.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('null-safe', () => {
|
||||
expect(previewContent(null)).toBe('');
|
||||
expect(previewContent(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('honors custom max', () => {
|
||||
expect(previewContent('abcdefghij', 5)).toBe('abcde…');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDate — valid ISO → formatted; everything else → empty.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDate', () => {
|
||||
it('valid ISO → non-empty formatted string', () => {
|
||||
const out = formatDate('2026-04-14T11:02:00Z');
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out).not.toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('empty string → empty', () => {
|
||||
expect(formatDate('')).toBe('');
|
||||
});
|
||||
|
||||
it('null → empty', () => {
|
||||
expect(formatDate(null)).toBe('');
|
||||
});
|
||||
|
||||
it('undefined → empty', () => {
|
||||
expect(formatDate(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('garbage string → empty (no "Invalid Date" leak)', () => {
|
||||
expect(formatDate('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('non-string input → empty (defensive)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(formatDate(12345 as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// safeTags — tolerant of undefined / non-array / empty.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('safeTags', () => {
|
||||
it('normal array: slices to limit', () => {
|
||||
expect(safeTags(['a', 'b', 'c', 'd', 'e'], 3)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('undefined → []', () => {
|
||||
expect(safeTags(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('null → []', () => {
|
||||
expect(safeTags(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('empty array → []', () => {
|
||||
expect(safeTags([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-array (defensive) → []', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(safeTags('bad' as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it('honors default limit = 4', () => {
|
||||
expect(safeTags(['a', 'b', 'c', 'd', 'e', 'f'])).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
});
|
||||
255
apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
Normal file
255
apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* EvidenceCard — pure-logic coverage.
|
||||
*
|
||||
* The component itself mounts Svelte, which vitest cannot do in a node
|
||||
* environment. Every piece of logic that was reachable via props has been
|
||||
* extracted to `reasoning-helpers.ts`; this file exhaustively exercises
|
||||
* those helpers through the same import surface EvidenceCard uses. If
|
||||
* this file is green, the card's visual output is a 1:1 function of the
|
||||
* helper output.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
ROLE_META,
|
||||
roleMetaFor,
|
||||
trustColor,
|
||||
trustPercent,
|
||||
clampTrust,
|
||||
nodeTypeColor,
|
||||
formatDate,
|
||||
shortenId,
|
||||
CONFIDENCE_EMERALD,
|
||||
CONFIDENCE_AMBER,
|
||||
CONFIDENCE_RED,
|
||||
DEFAULT_NODE_TYPE_COLOR,
|
||||
type EvidenceRole,
|
||||
} from '../reasoning-helpers';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// clampTrust + trustPercent — numeric contract
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('clampTrust — 0-1 display range', () => {
|
||||
it.each<[number, number]>([
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
[1, 1],
|
||||
[-0.1, 0],
|
||||
[-1, 0],
|
||||
[1.2, 1],
|
||||
[999, 1],
|
||||
])('clamps %f → %f', (input, expected) => {
|
||||
expect(clampTrust(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns 0 for NaN (defensive — avoids NaN% in the UI)', () => {
|
||||
expect(clampTrust(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for non-finite inputs (+/-Infinity) — safe default', () => {
|
||||
// Infinity indicates upstream garbage — degrade to empty bar rather
|
||||
// than saturate the UI to 100%.
|
||||
expect(clampTrust(-Infinity)).toBe(0);
|
||||
expect(clampTrust(Infinity)).toBe(0);
|
||||
});
|
||||
|
||||
it('is idempotent (clamp of clamp is the same)', () => {
|
||||
for (const v of [-0.5, 0, 0.3, 0.75, 1, 2]) {
|
||||
expect(clampTrust(clampTrust(v))).toBe(clampTrust(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('trustPercent — 0-100 rendering', () => {
|
||||
it.each<[number, number]>([
|
||||
[0, 0],
|
||||
[0.5, 50],
|
||||
[1, 100],
|
||||
[-0.1, 0],
|
||||
[1.2, 100],
|
||||
])('converts trust %f → %f%%', (t, expected) => {
|
||||
expect(trustPercent(t)).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles NaN without producing NaN', () => {
|
||||
expect(trustPercent(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// trustColor — band boundaries for the card's trust bar
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('trustColor — boundary analysis', () => {
|
||||
it.each<[number, string]>([
|
||||
// Emerald band: strictly > 0.75 → > 75%
|
||||
[1.0, CONFIDENCE_EMERALD],
|
||||
[0.9, CONFIDENCE_EMERALD],
|
||||
[0.751, CONFIDENCE_EMERALD],
|
||||
// Amber band: 0.40 ≤ t ≤ 0.75
|
||||
[0.75, CONFIDENCE_AMBER], // boundary — amber at exactly 75%
|
||||
[0.5, CONFIDENCE_AMBER],
|
||||
[0.4, CONFIDENCE_AMBER], // boundary — amber at exactly 40%
|
||||
// Red band: < 0.40
|
||||
[0.399, CONFIDENCE_RED],
|
||||
[0.2, CONFIDENCE_RED],
|
||||
[0, CONFIDENCE_RED],
|
||||
])('trust %f → %s', (t, expected) => {
|
||||
expect(trustColor(t)).toBe(expected);
|
||||
});
|
||||
|
||||
it('clamps negative to red and super-high to emerald (defensive)', () => {
|
||||
expect(trustColor(-0.5)).toBe(CONFIDENCE_RED);
|
||||
expect(trustColor(1.5)).toBe(CONFIDENCE_EMERALD);
|
||||
});
|
||||
|
||||
it('returns red for NaN (lowest-confidence fallback)', () => {
|
||||
expect(trustColor(Number.NaN)).toBe(CONFIDENCE_RED);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Role metadata — label + accent + icon
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ROLE_META — completeness and shape', () => {
|
||||
const roles: EvidenceRole[] = ['primary', 'supporting', 'contradicting', 'superseded'];
|
||||
|
||||
it('defines an entry for every role', () => {
|
||||
for (const r of roles) {
|
||||
expect(ROLE_META[r]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(roles)('%s has non-empty label + icon', (r) => {
|
||||
const meta = ROLE_META[r];
|
||||
expect(meta.label.length).toBeGreaterThan(0);
|
||||
expect(meta.icon.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('maps to the expected accent tokens used by Tailwind (synapse/recall/decay/muted)', () => {
|
||||
expect(ROLE_META.primary.accent).toBe('synapse');
|
||||
expect(ROLE_META.supporting.accent).toBe('recall');
|
||||
expect(ROLE_META.contradicting.accent).toBe('decay');
|
||||
expect(ROLE_META.superseded.accent).toBe('muted');
|
||||
});
|
||||
|
||||
it('accents are unique across roles (each role is visually distinct)', () => {
|
||||
const accents = roles.map((r) => ROLE_META[r].accent);
|
||||
expect(new Set(accents).size).toBe(4);
|
||||
});
|
||||
|
||||
it('icons are unique across roles', () => {
|
||||
const icons = roles.map((r) => ROLE_META[r].icon);
|
||||
expect(new Set(icons).size).toBe(4);
|
||||
});
|
||||
|
||||
it('labels are human-readable (first letter capital, no accents on the word)', () => {
|
||||
for (const r of roles) {
|
||||
const label = ROLE_META[r].label;
|
||||
expect(label[0]).toBe(label[0].toUpperCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleMetaFor — lookup with defensive fallback', () => {
|
||||
it('returns the exact entry for a known role', () => {
|
||||
expect(roleMetaFor('primary')).toBe(ROLE_META.primary);
|
||||
expect(roleMetaFor('contradicting')).toBe(ROLE_META.contradicting);
|
||||
});
|
||||
|
||||
it('falls back to Supporting when handed an unknown role (deep_reference could add new ones)', () => {
|
||||
expect(roleMetaFor('unknown-role')).toBe(ROLE_META.supporting);
|
||||
expect(roleMetaFor('')).toBe(ROLE_META.supporting);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// nodeTypeColor — palette lookup with fallback
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('nodeTypeColor — palette lookup', () => {
|
||||
it('returns the fallback colour when nodeType is undefined/null/empty', () => {
|
||||
expect(nodeTypeColor(undefined)).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
expect(nodeTypeColor(null)).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
expect(nodeTypeColor('')).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
});
|
||||
|
||||
it('returns the palette entry for every known NODE_TYPE_COLORS key', () => {
|
||||
for (const [type, colour] of Object.entries(NODE_TYPE_COLORS)) {
|
||||
expect(nodeTypeColor(type)).toBe(colour);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the fallback for an unknown nodeType', () => {
|
||||
expect(nodeTypeColor('quantum-state')).toBe(DEFAULT_NODE_TYPE_COLOR);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// formatDate — invalid-date handling (the real bug fixed here)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatDate — ISO parsing with graceful degradation', () => {
|
||||
it('formats a valid ISO date into a locale string', () => {
|
||||
const out = formatDate('2026-04-20T12:00:00.000Z', 'en-US');
|
||||
// Example: "Apr 20, 2026"
|
||||
expect(out).toMatch(/2026/);
|
||||
expect(out).toMatch(/Apr/);
|
||||
});
|
||||
|
||||
it('returns em-dash for empty / null / undefined', () => {
|
||||
expect(formatDate('')).toBe('—');
|
||||
expect(formatDate(null)).toBe('—');
|
||||
expect(formatDate(undefined)).toBe('—');
|
||||
expect(formatDate(' ')).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the original string when the input is unparseable (never "Invalid Date")', () => {
|
||||
// Regression: `new Date('not-a-date').toLocaleDateString()` returned
|
||||
// the literal text "Invalid Date" — EvidenceCard rendered that. Now
|
||||
// we surface the raw string so a reviewer can tell it was garbage.
|
||||
const garbage = 'not-a-date';
|
||||
expect(formatDate(garbage)).toBe(garbage);
|
||||
expect(formatDate(garbage)).not.toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('handles ISO dates without time component', () => {
|
||||
const out = formatDate('2026-01-15', 'en-US');
|
||||
expect(out).toMatch(/2026/);
|
||||
});
|
||||
|
||||
it('is pure — no global mutation between calls', () => {
|
||||
const a = formatDate('2026-04-20T00:00:00.000Z', 'en-US');
|
||||
const b = formatDate('2026-04-20T00:00:00.000Z', 'en-US');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// shortenId — UUID → #abcdef01
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('shortenId — 8-char display prefix', () => {
|
||||
it('returns an 8-char prefix for a standard UUID', () => {
|
||||
expect(shortenId('a1b2c3d4-e5f6-0000-0000-000000000000')).toBe('a1b2c3d4');
|
||||
});
|
||||
|
||||
it('returns the full string when already ≤ 8 chars', () => {
|
||||
expect(shortenId('abc')).toBe('abc');
|
||||
expect(shortenId('12345678')).toBe('12345678');
|
||||
});
|
||||
|
||||
it('handles null/undefined/empty gracefully', () => {
|
||||
expect(shortenId(null)).toBe('');
|
||||
expect(shortenId(undefined)).toBe('');
|
||||
expect(shortenId('')).toBe('');
|
||||
});
|
||||
|
||||
it('respects a custom length parameter', () => {
|
||||
expect(shortenId('abcdefghij', 4)).toBe('abcd');
|
||||
expect(shortenId('abcdefghij', 10)).toBe('abcdefghij');
|
||||
});
|
||||
});
|
||||
311
apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
Normal file
311
apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Tests for schedule / FSRS calendar helpers. These are the pure-logic core
|
||||
* of the `schedule` page + `FSRSCalendar.svelte` component — the Svelte
|
||||
* runtime is not exercised here (vitest runs `environment: node`, no jsdom).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Memory } from '$types';
|
||||
import {
|
||||
MS_DAY,
|
||||
startOfDay,
|
||||
daysBetween,
|
||||
isoDate,
|
||||
classifyUrgency,
|
||||
daysUntilReview,
|
||||
weekBucketRange,
|
||||
avgRetention,
|
||||
gridCellPosition,
|
||||
gridStartForAnchor,
|
||||
computeScheduleStats,
|
||||
} from '../schedule-helpers';
|
||||
|
||||
function makeMemory(overrides: Partial<Memory> = {}): Memory {
|
||||
return {
|
||||
id: 'm-' + Math.random().toString(36).slice(2, 8),
|
||||
content: 'test memory',
|
||||
nodeType: 'fact',
|
||||
tags: [],
|
||||
retentionStrength: 0.7,
|
||||
storageStrength: 0.5,
|
||||
retrievalStrength: 0.8,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Fixed anchor: 2026-04-20 12:00 local so offsets don't straddle midnight
|
||||
// in the default test runner's tz. All relative timestamps are derived from
|
||||
// this anchor to keep tests tz-independent.
|
||||
function anchor(): Date {
|
||||
const d = new Date(2026, 3, 20, 12, 0, 0, 0); // Mon Apr 20 2026 12:00 local
|
||||
return d;
|
||||
}
|
||||
|
||||
function offsetDays(base: Date, days: number, hour = 12): Date {
|
||||
const d = new Date(base);
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
describe('startOfDay', () => {
|
||||
it('zeros hours / minutes / seconds / ms', () => {
|
||||
const d = new Date(2026, 3, 20, 14, 35, 27, 999);
|
||||
const s = startOfDay(d);
|
||||
expect(s.getHours()).toBe(0);
|
||||
expect(s.getMinutes()).toBe(0);
|
||||
expect(s.getSeconds()).toBe(0);
|
||||
expect(s.getMilliseconds()).toBe(0);
|
||||
expect(s.getFullYear()).toBe(2026);
|
||||
expect(s.getMonth()).toBe(3);
|
||||
expect(s.getDate()).toBe(20);
|
||||
});
|
||||
|
||||
it('does not mutate its input', () => {
|
||||
const input = new Date(2026, 3, 20, 14, 35);
|
||||
const before = input.getTime();
|
||||
startOfDay(input);
|
||||
expect(input.getTime()).toBe(before);
|
||||
});
|
||||
|
||||
it('accepts an ISO string', () => {
|
||||
const s = startOfDay('2026-04-20T14:35:00');
|
||||
expect(s.getHours()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysBetween', () => {
|
||||
it('returns 0 for the same calendar day at different hours', () => {
|
||||
const a = new Date(2026, 3, 20, 0, 0);
|
||||
const b = new Date(2026, 3, 20, 23, 59);
|
||||
expect(daysBetween(a, b)).toBe(0);
|
||||
expect(daysBetween(b, a)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns positive for future, negative for past', () => {
|
||||
const today = anchor();
|
||||
expect(daysBetween(offsetDays(today, 3), today)).toBe(3);
|
||||
expect(daysBetween(offsetDays(today, -3), today)).toBe(-3);
|
||||
});
|
||||
|
||||
it('is day-granular across the midnight boundary', () => {
|
||||
const midnight = new Date(2026, 3, 20, 0, 0, 0, 0);
|
||||
const justBefore = new Date(2026, 3, 19, 23, 59, 59, 999);
|
||||
expect(daysBetween(midnight, justBefore)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isoDate', () => {
|
||||
it('formats as YYYY-MM-DD with zero-padding in LOCAL time', () => {
|
||||
expect(isoDate(new Date(2026, 0, 5))).toBe('2026-01-05'); // jan 5
|
||||
expect(isoDate(new Date(2026, 11, 31))).toBe('2026-12-31');
|
||||
});
|
||||
|
||||
it('uses local day even for late-evening UTC-crossing timestamps', () => {
|
||||
// This is the whole reason isoDate uses get* not getUTC*: calendar cells
|
||||
// should match the user's perceived day.
|
||||
const d = new Date(2026, 3, 20, 23, 30); // apr 20 23:30 local
|
||||
expect(isoDate(d)).toBe('2026-04-20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyUrgency', () => {
|
||||
const now = anchor();
|
||||
|
||||
it('returns "none" for missing nextReviewAt', () => {
|
||||
expect(classifyUrgency(now, null)).toBe('none');
|
||||
expect(classifyUrgency(now, undefined)).toBe('none');
|
||||
expect(classifyUrgency(now, '')).toBe('none');
|
||||
});
|
||||
|
||||
it('returns "none" for unparseable ISO strings', () => {
|
||||
expect(classifyUrgency(now, 'not-a-date')).toBe('none');
|
||||
});
|
||||
|
||||
it('classifies overdue when due date is strictly before today', () => {
|
||||
expect(classifyUrgency(now, offsetDays(now, -1).toISOString())).toBe('overdue');
|
||||
expect(classifyUrgency(now, offsetDays(now, -5).toISOString())).toBe('overdue');
|
||||
});
|
||||
|
||||
it('classifies today when due date is the same calendar day', () => {
|
||||
// Same day, earlier hour — still today, NOT overdue (day-granular).
|
||||
const earlier = new Date(now);
|
||||
earlier.setHours(3, 0);
|
||||
expect(classifyUrgency(now, earlier.toISOString())).toBe('today');
|
||||
const later = new Date(now);
|
||||
later.setHours(22, 0);
|
||||
expect(classifyUrgency(now, later.toISOString())).toBe('today');
|
||||
});
|
||||
|
||||
it('classifies 1..=7 days out as "week"', () => {
|
||||
expect(classifyUrgency(now, offsetDays(now, 1).toISOString())).toBe('week');
|
||||
expect(classifyUrgency(now, offsetDays(now, 7).toISOString())).toBe('week');
|
||||
});
|
||||
|
||||
it('classifies 8+ days out as "future"', () => {
|
||||
expect(classifyUrgency(now, offsetDays(now, 8).toISOString())).toBe('future');
|
||||
expect(classifyUrgency(now, offsetDays(now, 30).toISOString())).toBe('future');
|
||||
});
|
||||
|
||||
it('boundary at midnight: 1 second after midnight tomorrow is "week" not "today"', () => {
|
||||
const tomorrowMidnight = startOfDay(offsetDays(now, 1, 0));
|
||||
tomorrowMidnight.setSeconds(1);
|
||||
expect(classifyUrgency(now, tomorrowMidnight.toISOString())).toBe('week');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysUntilReview', () => {
|
||||
const now = anchor();
|
||||
|
||||
it('returns null for missing / invalid input', () => {
|
||||
expect(daysUntilReview(now, null)).toBeNull();
|
||||
expect(daysUntilReview(now, undefined)).toBeNull();
|
||||
expect(daysUntilReview(now, 'garbage')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns 0 for today', () => {
|
||||
expect(daysUntilReview(now, now.toISOString())).toBe(0);
|
||||
});
|
||||
|
||||
it('returns signed integer days', () => {
|
||||
expect(daysUntilReview(now, offsetDays(now, 5).toISOString())).toBe(5);
|
||||
expect(daysUntilReview(now, offsetDays(now, -3).toISOString())).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekBucketRange', () => {
|
||||
it('returns Sunday→Sunday exclusive for any weekday', () => {
|
||||
// Apr 20 2026 is a Monday. The week starts on Sunday Apr 19.
|
||||
const mon = new Date(2026, 3, 20, 14, 0);
|
||||
const { start, end } = weekBucketRange(mon);
|
||||
expect(start.getDay()).toBe(0); // Sunday
|
||||
expect(start.getDate()).toBe(19);
|
||||
expect(end.getDate()).toBe(26); // next Sunday
|
||||
expect(end.getTime() - start.getTime()).toBe(7 * MS_DAY);
|
||||
});
|
||||
|
||||
it('for Sunday input, returns that same Sunday as start', () => {
|
||||
const sun = new Date(2026, 3, 19, 10, 0); // Sun Apr 19 2026
|
||||
const { start } = weekBucketRange(sun);
|
||||
expect(start.getDate()).toBe(19);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avgRetention', () => {
|
||||
it('returns 0 for empty array (no NaN)', () => {
|
||||
expect(avgRetention([])).toBe(0);
|
||||
expect(Number.isNaN(avgRetention([]))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the single value for a length-1 list', () => {
|
||||
expect(avgRetention([makeMemory({ retentionStrength: 0.42 })])).toBeCloseTo(0.42);
|
||||
});
|
||||
|
||||
it('returns the mean for a mixed list', () => {
|
||||
const ms = [
|
||||
makeMemory({ retentionStrength: 0.2 }),
|
||||
makeMemory({ retentionStrength: 0.8 }),
|
||||
makeMemory({ retentionStrength: 0.5 }),
|
||||
];
|
||||
expect(avgRetention(ms)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('tolerates missing retentionStrength (treat as 0)', () => {
|
||||
const ms = [
|
||||
makeMemory({ retentionStrength: 1.0 }),
|
||||
makeMemory({ retentionStrength: undefined as unknown as number }),
|
||||
];
|
||||
expect(avgRetention(ms)).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gridCellPosition', () => {
|
||||
it('maps row-major: index 0 → (0,0), index 7 → (1,0), index 41 → (5,6)', () => {
|
||||
expect(gridCellPosition(0)).toEqual({ row: 0, col: 0 });
|
||||
expect(gridCellPosition(6)).toEqual({ row: 0, col: 6 });
|
||||
expect(gridCellPosition(7)).toEqual({ row: 1, col: 0 });
|
||||
expect(gridCellPosition(15)).toEqual({ row: 2, col: 1 });
|
||||
expect(gridCellPosition(41)).toEqual({ row: 5, col: 6 });
|
||||
});
|
||||
|
||||
it('returns null for out-of-range or non-integer indices', () => {
|
||||
expect(gridCellPosition(-1)).toBeNull();
|
||||
expect(gridCellPosition(42)).toBeNull();
|
||||
expect(gridCellPosition(100)).toBeNull();
|
||||
expect(gridCellPosition(3.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gridStartForAnchor', () => {
|
||||
it('returns a Sunday at or before anchor-14 days', () => {
|
||||
// Apr 20 2026 (Mon) → anchor-14 = Apr 6 2026 (Mon) → back to Sun Apr 5.
|
||||
const start = gridStartForAnchor(anchor());
|
||||
expect(start.getDay()).toBe(0);
|
||||
expect(start.getFullYear()).toBe(2026);
|
||||
expect(start.getMonth()).toBe(3);
|
||||
expect(start.getDate()).toBe(5);
|
||||
expect(start.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('includes today in the 6-week window (row 2 or 3)', () => {
|
||||
const today = anchor();
|
||||
const start = gridStartForAnchor(today);
|
||||
const delta = daysBetween(today, start);
|
||||
expect(delta).toBeGreaterThanOrEqual(14);
|
||||
expect(delta).toBeLessThan(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeScheduleStats', () => {
|
||||
const now = anchor();
|
||||
|
||||
it('zeros everything for an empty corpus', () => {
|
||||
const s = computeScheduleStats(now, []);
|
||||
expect(s).toEqual({
|
||||
overdue: 0,
|
||||
dueToday: 0,
|
||||
dueThisWeek: 0,
|
||||
dueThisMonth: 0,
|
||||
avgDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts each bucket independently (today ⊂ week ⊂ month)', () => {
|
||||
const ms = [
|
||||
makeMemory({ nextReviewAt: offsetDays(now, -2).toISOString() }), // overdue
|
||||
makeMemory({ nextReviewAt: new Date(now).toISOString() }), // today
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 3).toISOString() }), // week
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 15).toISOString() }), // month
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 45).toISOString() }), // out of month
|
||||
];
|
||||
const s = computeScheduleStats(now, ms);
|
||||
expect(s.overdue).toBe(1);
|
||||
expect(s.dueToday).toBe(2); // overdue + today (delta <= 0)
|
||||
expect(s.dueThisWeek).toBe(3); // overdue + today + week
|
||||
expect(s.dueThisMonth).toBe(4); // overdue + today + week + month
|
||||
});
|
||||
|
||||
it('skips memories without a nextReviewAt or with unparseable dates', () => {
|
||||
const ms = [
|
||||
makeMemory({ nextReviewAt: undefined }),
|
||||
makeMemory({ nextReviewAt: 'bogus' }),
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 2).toISOString() }),
|
||||
];
|
||||
const s = computeScheduleStats(now, ms);
|
||||
expect(s.dueThisWeek).toBe(1);
|
||||
});
|
||||
|
||||
it('computes average days across future-only memories', () => {
|
||||
const ms = [
|
||||
makeMemory({ nextReviewAt: offsetDays(now, -5).toISOString() }), // excluded (past)
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 2).toISOString() }),
|
||||
makeMemory({ nextReviewAt: offsetDays(now, 4).toISOString() }),
|
||||
];
|
||||
const s = computeScheduleStats(now, ms);
|
||||
// avgDays is measured from today-at-midnight (not now-mid-day), so a
|
||||
// review tomorrow at noon is 1.5 days out. Two memories at +2d and +4d
|
||||
// (both hour=12) → (2.5 + 4.5) / 2 = 3.5.
|
||||
expect(s.avgDays).toBeCloseTo(3.5, 2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
/**
|
||||
* Unit tests for importance-helpers — the pure logic backing
|
||||
* ImportanceRadar.svelte + importance/+page.svelte.
|
||||
*
|
||||
* Runs in the vitest `node` environment (no jsdom). We exercise:
|
||||
* - Composite channel weighting (matches backend ImportanceSignals)
|
||||
* - 4-axis radar vertex geometry (Novelty top / Arousal right / Reward
|
||||
* bottom / Attention left)
|
||||
* - Value clamping at the helper boundary (defensive against a mis-
|
||||
* scaled /api/importance response)
|
||||
* - Size-preset mapping (sm 80 / md 180 / lg 320)
|
||||
* - Trending-memory importance proxy (retention × log(reviews) / √age)
|
||||
* including the age=0 division-by-zero edge case.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
clamp01,
|
||||
clampChannels,
|
||||
compositeScore,
|
||||
CHANNEL_WEIGHTS,
|
||||
sizePreset,
|
||||
radarRadius,
|
||||
radarVertices,
|
||||
verticesToPath,
|
||||
importanceProxy,
|
||||
rankByProxy,
|
||||
AXIS_ORDER,
|
||||
SIZE_PX,
|
||||
type ProxyMemoryLike,
|
||||
} from '../importance-helpers';
|
||||
|
||||
// ===========================================================================
|
||||
// clamp01
|
||||
// ===========================================================================
|
||||
|
||||
describe('clamp01', () => {
|
||||
it('passes in-range values through', () => {
|
||||
expect(clamp01(0)).toBe(0);
|
||||
expect(clamp01(0.5)).toBe(0.5);
|
||||
expect(clamp01(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('clamps below zero to 0', () => {
|
||||
expect(clamp01(-0.3)).toBe(0);
|
||||
expect(clamp01(-100)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps above one to 1', () => {
|
||||
expect(clamp01(1.0001)).toBe(1);
|
||||
expect(clamp01(42)).toBe(1);
|
||||
});
|
||||
|
||||
it('folds null / undefined / NaN / Infinity to 0', () => {
|
||||
expect(clamp01(null)).toBe(0);
|
||||
expect(clamp01(undefined)).toBe(0);
|
||||
expect(clamp01(NaN)).toBe(0);
|
||||
expect(clamp01(Infinity)).toBe(0);
|
||||
expect(clamp01(-Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampChannels', () => {
|
||||
it('clamps every channel independently', () => {
|
||||
expect(clampChannels({ novelty: 2, arousal: -1, reward: 0.5, attention: NaN })).toEqual({
|
||||
novelty: 1,
|
||||
arousal: 0,
|
||||
reward: 0.5,
|
||||
attention: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing channels with 0', () => {
|
||||
expect(clampChannels({ novelty: 0.8 })).toEqual({
|
||||
novelty: 0.8,
|
||||
arousal: 0,
|
||||
reward: 0,
|
||||
attention: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts null / undefined as "all zeros"', () => {
|
||||
expect(clampChannels(null)).toEqual({ novelty: 0, arousal: 0, reward: 0, attention: 0 });
|
||||
expect(clampChannels(undefined)).toEqual({
|
||||
novelty: 0,
|
||||
arousal: 0,
|
||||
reward: 0,
|
||||
attention: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// compositeScore — MUST match backend ImportanceSignals weights
|
||||
// ===========================================================================
|
||||
|
||||
describe('compositeScore', () => {
|
||||
it('sums channel contributions with the documented weights', () => {
|
||||
const c = { novelty: 1, arousal: 1, reward: 1, attention: 1 };
|
||||
// 0.25 + 0.30 + 0.25 + 0.20 = 1.00
|
||||
expect(compositeScore(c)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('is zero for all-zero channels', () => {
|
||||
expect(compositeScore({ novelty: 0, arousal: 0, reward: 0, attention: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
it('weights match CHANNEL_WEIGHTS exactly (backend contract)', () => {
|
||||
expect(CHANNEL_WEIGHTS).toEqual({
|
||||
novelty: 0.25,
|
||||
arousal: 0.3,
|
||||
reward: 0.25,
|
||||
attention: 0.2,
|
||||
});
|
||||
// Weights sum to 1 — any drift here and the "composite ∈ [0,1]"
|
||||
// invariant falls over.
|
||||
const sum =
|
||||
CHANNEL_WEIGHTS.novelty +
|
||||
CHANNEL_WEIGHTS.arousal +
|
||||
CHANNEL_WEIGHTS.reward +
|
||||
CHANNEL_WEIGHTS.attention;
|
||||
expect(sum).toBeCloseTo(1.0, 10);
|
||||
});
|
||||
|
||||
it('matches the exact weighted formula per channel', () => {
|
||||
// 0.4·0.25 + 0.6·0.30 + 0.2·0.25 + 0.8·0.20
|
||||
// = 0.10 + 0.18 + 0.05 + 0.16 = 0.49
|
||||
expect(
|
||||
compositeScore({ novelty: 0.4, arousal: 0.6, reward: 0.2, attention: 0.8 }),
|
||||
).toBeCloseTo(0.49, 5);
|
||||
});
|
||||
|
||||
it('clamps inputs before weighting (never escapes [0,1])', () => {
|
||||
// All over-max → should pin to 1, not to 2.
|
||||
expect(
|
||||
compositeScore({ novelty: 2, arousal: 2, reward: 2, attention: 2 }),
|
||||
).toBeCloseTo(1.0, 5);
|
||||
// Negative channels count as 0.
|
||||
expect(
|
||||
compositeScore({ novelty: -1, arousal: -1, reward: -1, attention: -1 }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Size preset
|
||||
// ===========================================================================
|
||||
|
||||
describe('sizePreset', () => {
|
||||
it('maps the three documented presets', () => {
|
||||
expect(sizePreset('sm')).toBe(80);
|
||||
expect(sizePreset('md')).toBe(180);
|
||||
expect(sizePreset('lg')).toBe(320);
|
||||
});
|
||||
|
||||
it('exposes the SIZE_PX mapping for external consumers', () => {
|
||||
expect(SIZE_PX).toEqual({ sm: 80, md: 180, lg: 320 });
|
||||
});
|
||||
|
||||
it('falls back to md (180) for unknown / missing keys', () => {
|
||||
expect(sizePreset(undefined)).toBe(180);
|
||||
expect(sizePreset('' as unknown as 'md')).toBe(180);
|
||||
expect(sizePreset('xl' as unknown as 'md')).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// radarRadius — component padding rules
|
||||
// ===========================================================================
|
||||
|
||||
describe('radarRadius', () => {
|
||||
it('applies the correct padding per preset', () => {
|
||||
// sm: 80/2 - 4 = 36
|
||||
// md: 180/2 - 28 = 62
|
||||
// lg: 320/2 - 44 = 116
|
||||
expect(radarRadius('sm')).toBe(36);
|
||||
expect(radarRadius('md')).toBe(62);
|
||||
expect(radarRadius('lg')).toBe(116);
|
||||
});
|
||||
|
||||
it('never returns a negative radius', () => {
|
||||
// Can't construct a sub-zero radius via normal presets, but the
|
||||
// helper floors at 0 defensively.
|
||||
expect(radarRadius('md')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// radarVertices — 4 SVG polygon points on the fixed axis order
|
||||
// ===========================================================================
|
||||
|
||||
describe('radarVertices', () => {
|
||||
it('emits vertices in Novelty→Arousal→Reward→Attention order', () => {
|
||||
expect(AXIS_ORDER.map((a) => a.key)).toEqual([
|
||||
'novelty',
|
||||
'arousal',
|
||||
'reward',
|
||||
'attention',
|
||||
]);
|
||||
});
|
||||
|
||||
it('places a 0-valued channel at the centre', () => {
|
||||
// Centre for md is (90, 90). novelty=0 means the top vertex sits AT
|
||||
// the centre — the polygon pinches inward.
|
||||
const v = radarVertices(
|
||||
{ novelty: 0, arousal: 0, reward: 0, attention: 0 },
|
||||
'md',
|
||||
);
|
||||
expect(v).toHaveLength(4);
|
||||
for (const p of v) {
|
||||
expect(p.x).toBeCloseTo(90, 5);
|
||||
expect(p.y).toBeCloseTo(90, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it('places a 1-valued channel on the correct axis edge', () => {
|
||||
// Size md: cx=cy=90, r=62.
|
||||
// Novelty (angle -π/2, top) → (90, 90 - 62) = (90, 28)
|
||||
// Arousal (angle 0, right) → (90 + 62, 90) = (152, 90)
|
||||
// Reward (angle π/2, bottom) → (90, 90 + 62) = (90, 152)
|
||||
// Attention (angle π, left) → (90 - 62, 90) = (28, 90)
|
||||
const v = radarVertices(
|
||||
{ novelty: 1, arousal: 1, reward: 1, attention: 1 },
|
||||
'md',
|
||||
);
|
||||
expect(v[0].x).toBeCloseTo(90, 5);
|
||||
expect(v[0].y).toBeCloseTo(28, 5);
|
||||
|
||||
expect(v[1].x).toBeCloseTo(152, 5);
|
||||
expect(v[1].y).toBeCloseTo(90, 5);
|
||||
|
||||
expect(v[2].x).toBeCloseTo(90, 5);
|
||||
expect(v[2].y).toBeCloseTo(152, 5);
|
||||
|
||||
expect(v[3].x).toBeCloseTo(28, 5);
|
||||
expect(v[3].y).toBeCloseTo(90, 5);
|
||||
});
|
||||
|
||||
it('scales vertex radial distance linearly with the channel value', () => {
|
||||
// Arousal at 0.5 should land half-way from centre to the right edge.
|
||||
const v = radarVertices(
|
||||
{ novelty: 0, arousal: 0.5, reward: 0, attention: 0 },
|
||||
'md',
|
||||
);
|
||||
// radius=62, so right vertex x = 90 + 62*0.5 = 121.
|
||||
expect(v[1].x).toBeCloseTo(121, 5);
|
||||
expect(v[1].y).toBeCloseTo(90, 5);
|
||||
});
|
||||
|
||||
it('clamps out-of-range inputs rather than exiting the SVG box', () => {
|
||||
// novelty=2 should pin to the edge (not overshoot to 90 - 124 = -34).
|
||||
const v = radarVertices(
|
||||
{ novelty: 2, arousal: -0.5, reward: NaN, attention: Infinity },
|
||||
'md',
|
||||
);
|
||||
// Novelty pinned to edge (y=28), arousal/reward/attention at 0 land at centre.
|
||||
expect(v[0].y).toBeCloseTo(28, 5);
|
||||
expect(v[1].x).toBeCloseTo(90, 5); // arousal=0 → centre
|
||||
expect(v[2].y).toBeCloseTo(90, 5); // reward=0 → centre
|
||||
expect(v[3].x).toBeCloseTo(90, 5); // attention=0 → centre
|
||||
});
|
||||
|
||||
it('respects the active size preset', () => {
|
||||
// At sm (80px), radius=36. Novelty=1 → (40, 40-36) = (40, 4).
|
||||
const v = radarVertices({ novelty: 1, arousal: 0, reward: 0, attention: 0 }, 'sm');
|
||||
expect(v[0].x).toBeCloseTo(40, 5);
|
||||
expect(v[0].y).toBeCloseTo(4, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verticesToPath', () => {
|
||||
it('serialises to an SVG path with M/L commands and Z close', () => {
|
||||
const path = verticesToPath([
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 30, y: 40 },
|
||||
{ x: 50, y: 60 },
|
||||
{ x: 70, y: 80 },
|
||||
]);
|
||||
expect(path).toBe('M10.00,20.00 L30.00,40.00 L50.00,60.00 L70.00,80.00 Z');
|
||||
});
|
||||
|
||||
it('returns an empty string for no points', () => {
|
||||
expect(verticesToPath([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// importanceProxy — "Top Important Memories This Week" ranking formula
|
||||
// ===========================================================================
|
||||
|
||||
describe('importanceProxy', () => {
|
||||
// Anchor everything to a fixed "now" so recency math is deterministic.
|
||||
const NOW = new Date('2026-04-20T12:00:00Z').getTime();
|
||||
|
||||
function mem(over: Partial<ProxyMemoryLike>): ProxyMemoryLike {
|
||||
return {
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW - 2 * 86_400_000).toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
it('is zero for zero retention', () => {
|
||||
expect(importanceProxy(mem({ retentionStrength: 0 }), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('treats missing reviewCount as 0 (not a crash)', () => {
|
||||
const m = mem({ reviewCount: undefined, retentionStrength: 0.8 });
|
||||
const v = importanceProxy(m, NOW);
|
||||
expect(v).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(v)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the documented formula: retention × log1p(reviews+1) / √age', () => {
|
||||
// createdAt = 4 days before NOW → ageDays = 4, √4 = 2.
|
||||
// retention = 0.6, reviews = 3 → log1p(4) ≈ 1.6094
|
||||
// expected = 0.6 × 1.6094 / 2 ≈ 0.4828
|
||||
const m = mem({
|
||||
retentionStrength: 0.6,
|
||||
reviewCount: 3,
|
||||
createdAt: new Date(NOW - 4 * 86_400_000).toISOString(),
|
||||
});
|
||||
const v = importanceProxy(m, NOW);
|
||||
const expected = (0.6 * Math.log1p(4)) / 2;
|
||||
expect(v).toBeCloseTo(expected, 6);
|
||||
});
|
||||
|
||||
it('clamps age to 1 day for a memory created RIGHT NOW (div-by-zero guard)', () => {
|
||||
// createdAt equals NOW → raw ageDays = 0. Without the clamp, the
|
||||
// recency boost would divide by zero. We assert the helper returns
|
||||
// a finite value equal to the "age=1" path.
|
||||
const zeroAge = importanceProxy(
|
||||
mem({
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW).toISOString(),
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
const oneDayAge = importanceProxy(
|
||||
mem({
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW - 1 * 86_400_000).toISOString(),
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
expect(Number.isFinite(zeroAge)).toBe(true);
|
||||
expect(zeroAge).toBeCloseTo(oneDayAge, 10);
|
||||
});
|
||||
|
||||
it('also clamps future-dated memories to ageDays=1 rather than going negative', () => {
|
||||
const future = importanceProxy(
|
||||
mem({
|
||||
retentionStrength: 0.5,
|
||||
reviewCount: 0,
|
||||
createdAt: new Date(NOW + 7 * 86_400_000).toISOString(),
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
expect(Number.isFinite(future)).toBe(true);
|
||||
expect(future).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns 0 for a malformed createdAt', () => {
|
||||
const m = {
|
||||
retentionStrength: 0.8,
|
||||
reviewCount: 3,
|
||||
createdAt: 'not-a-date',
|
||||
};
|
||||
expect(importanceProxy(m, NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when retentionStrength is non-finite', () => {
|
||||
expect(importanceProxy(mem({ retentionStrength: NaN }), NOW)).toBe(0);
|
||||
expect(importanceProxy(mem({ retentionStrength: Infinity }), NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it('ranks recent + high-retention memories ahead of stale ones', () => {
|
||||
const fresh: ProxyMemoryLike = {
|
||||
retentionStrength: 0.9,
|
||||
reviewCount: 5,
|
||||
createdAt: new Date(NOW - 1 * 86_400_000).toISOString(),
|
||||
};
|
||||
const stale: ProxyMemoryLike = {
|
||||
retentionStrength: 0.9,
|
||||
reviewCount: 5,
|
||||
createdAt: new Date(NOW - 100 * 86_400_000).toISOString(),
|
||||
};
|
||||
expect(importanceProxy(fresh, NOW)).toBeGreaterThan(importanceProxy(stale, NOW));
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankByProxy', () => {
|
||||
const NOW = new Date('2026-04-20T12:00:00Z').getTime();
|
||||
|
||||
it('sorts descending by the proxy score', () => {
|
||||
const items: (ProxyMemoryLike & { id: string })[] = [
|
||||
{ id: 'stale', retentionStrength: 0.9, reviewCount: 5, createdAt: new Date(NOW - 100 * 86_400_000).toISOString() },
|
||||
{ id: 'fresh', retentionStrength: 0.9, reviewCount: 5, createdAt: new Date(NOW - 1 * 86_400_000).toISOString() },
|
||||
{ id: 'dead', retentionStrength: 0.0, reviewCount: 0, createdAt: new Date(NOW - 2 * 86_400_000).toISOString() },
|
||||
];
|
||||
const ranked = rankByProxy(items, NOW);
|
||||
expect(ranked.map((r) => r.id)).toEqual(['fresh', 'stale', 'dead']);
|
||||
});
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const items: ProxyMemoryLike[] = [
|
||||
{ retentionStrength: 0.1, reviewCount: 0, createdAt: new Date(NOW - 10 * 86_400_000).toISOString() },
|
||||
{ retentionStrength: 0.9, reviewCount: 9, createdAt: new Date(NOW - 1 * 86_400_000).toISOString() },
|
||||
];
|
||||
const before = items.slice();
|
||||
rankByProxy(items, NOW);
|
||||
expect(items).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* MemoryAuditTrail — pure helper coverage.
|
||||
*
|
||||
* Runs in vitest's Node environment (no jsdom). Every assertion exercises
|
||||
* a function in `audit-trail-helpers.ts` with fully deterministic inputs.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
ALL_ACTIONS,
|
||||
META,
|
||||
VISIBLE_LIMIT,
|
||||
formatRetentionDelta,
|
||||
generateMockAuditTrail,
|
||||
hashSeed,
|
||||
makeRand,
|
||||
relativeTime,
|
||||
splitVisible,
|
||||
type AuditAction,
|
||||
type AuditEvent
|
||||
} from '../audit-trail-helpers';
|
||||
|
||||
// Fixed reference point for all time-based tests. Millisecond precision so
|
||||
// relative-time maths are exact, not drifting with wallclock time.
|
||||
const NOW = Date.UTC(2026, 3, 20, 12, 0, 0); // 2026-04-20 12:00:00 UTC
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hashSeed + makeRand
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('hashSeed', () => {
|
||||
it('is deterministic', () => {
|
||||
expect(hashSeed('abc')).toBe(hashSeed('abc'));
|
||||
expect(hashSeed('memory-42')).toBe(hashSeed('memory-42'));
|
||||
});
|
||||
|
||||
it('different ids hash to different seeds', () => {
|
||||
expect(hashSeed('a')).not.toBe(hashSeed('b'));
|
||||
expect(hashSeed('memory-1')).not.toBe(hashSeed('memory-2'));
|
||||
});
|
||||
|
||||
it('empty string hashes to 0', () => {
|
||||
expect(hashSeed('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an unsigned 32-bit integer', () => {
|
||||
// Stress: a long id should never produce a negative or non-integer seed.
|
||||
const seed = hashSeed('a'.repeat(256));
|
||||
expect(Number.isInteger(seed)).toBe(true);
|
||||
expect(seed).toBeGreaterThanOrEqual(0);
|
||||
expect(seed).toBeLessThan(2 ** 32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRand', () => {
|
||||
it('is deterministic given the same seed', () => {
|
||||
const a = makeRand(42);
|
||||
const b = makeRand(42);
|
||||
for (let i = 0; i < 20; i++) expect(a()).toBe(b());
|
||||
});
|
||||
|
||||
it('produces values strictly in [0, 1)', () => {
|
||||
// Seed with UINT32_MAX to force the edge case that exposed the original
|
||||
// `/ 0xffffffff` bug — the divisor must be 2^32, not 2^32 - 1.
|
||||
const rand = makeRand(0xffffffff);
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
const v = rand();
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('different seeds produce different sequences', () => {
|
||||
const a = makeRand(1);
|
||||
const b = makeRand(2);
|
||||
expect(a()).not.toBe(b());
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic generator
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('generateMockAuditTrail — determinism', () => {
|
||||
it('same id + same now always yields the same sequence', () => {
|
||||
const a = generateMockAuditTrail('memory-xyz', NOW);
|
||||
const b = generateMockAuditTrail('memory-xyz', NOW);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('different ids yield different sequences', () => {
|
||||
const a = generateMockAuditTrail('memory-a', NOW);
|
||||
const b = generateMockAuditTrail('memory-b', NOW);
|
||||
// Either different lengths or different event-by-event — anything but equal.
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it('empty id yields no events — the panel should never fabricate history', () => {
|
||||
expect(generateMockAuditTrail('', NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
it('count fits the default 8-15 range', () => {
|
||||
// Sample a handful of ids — the distribution should stay in range.
|
||||
for (const id of ['a', 'abc', 'memory-1', 'memory-2', 'memory-3', 'x'.repeat(50)]) {
|
||||
const events = generateMockAuditTrail(id, NOW);
|
||||
expect(events.length).toBeGreaterThanOrEqual(8);
|
||||
expect(events.length).toBeLessThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
it('first emitted event (newest-first order → last in array) is "created"', () => {
|
||||
const events = generateMockAuditTrail('deterministic-id', NOW);
|
||||
expect(events[events.length - 1].action).toBe('created');
|
||||
expect(events[events.length - 1].triggered_by).toBe('smart_ingest');
|
||||
});
|
||||
|
||||
it('emits events in newest-first order', () => {
|
||||
const events = generateMockAuditTrail('order-check', NOW);
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
const prev = new Date(events[i - 1].timestamp).getTime();
|
||||
const curr = new Date(events[i].timestamp).getTime();
|
||||
expect(prev).toBeGreaterThanOrEqual(curr);
|
||||
}
|
||||
});
|
||||
|
||||
it('all timestamps are valid ISO strings in the past relative to NOW', () => {
|
||||
const events = generateMockAuditTrail('iso-check', NOW);
|
||||
for (const ev of events) {
|
||||
const t = new Date(ev.timestamp).getTime();
|
||||
expect(Number.isFinite(t)).toBe(true);
|
||||
expect(t).toBeLessThanOrEqual(NOW);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects countOverride — 16 events crosses the visibility threshold', () => {
|
||||
const events = generateMockAuditTrail('big', NOW, 16);
|
||||
expect(events).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('retention values never escape [0, 1]', () => {
|
||||
for (const id of ['x', 'y', 'z', 'memory-big']) {
|
||||
const events = generateMockAuditTrail(id, NOW, 30);
|
||||
for (const ev of events) {
|
||||
if (ev.old_value !== undefined) {
|
||||
expect(ev.old_value).toBeGreaterThanOrEqual(0);
|
||||
expect(ev.old_value).toBeLessThanOrEqual(1);
|
||||
}
|
||||
if (ev.new_value !== undefined) {
|
||||
expect(ev.new_value).toBeGreaterThanOrEqual(0);
|
||||
expect(ev.new_value).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relative time
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('relativeTime — boundary cases', () => {
|
||||
// Build an ISO timestamp `offsetMs` before NOW.
|
||||
const ago = (offsetMs: number) => new Date(NOW - offsetMs).toISOString();
|
||||
|
||||
const cases: Array<[string, number, string]> = [
|
||||
['0s ago', 0, '0s ago'],
|
||||
['59s ago', 59 * 1000, '59s ago'],
|
||||
['60s flips to 1m', 60 * 1000, '1m ago'],
|
||||
['59m ago', 59 * 60 * 1000, '59m ago'],
|
||||
['60m flips to 1h', 60 * 60 * 1000, '1h ago'],
|
||||
['23h ago', 23 * 3600 * 1000, '23h ago'],
|
||||
['24h flips to 1d', 24 * 3600 * 1000, '1d ago'],
|
||||
['6d ago', 6 * 86400 * 1000, '6d ago'],
|
||||
['7d ago', 7 * 86400 * 1000, '7d ago'],
|
||||
['29d ago', 29 * 86400 * 1000, '29d ago'],
|
||||
['30d flips to 1mo', 30 * 86400 * 1000, '1mo ago'],
|
||||
['365d → 12mo flips to 1y', 365 * 86400 * 1000, '1y ago']
|
||||
];
|
||||
|
||||
for (const [name, offset, expected] of cases) {
|
||||
it(name, () => {
|
||||
expect(relativeTime(ago(offset), NOW)).toBe(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it('future timestamps clamp to "0s ago"', () => {
|
||||
const future = new Date(NOW + 60_000).toISOString();
|
||||
expect(relativeTime(future, NOW)).toBe('0s ago');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event type → marker mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('META — action to marker mapping', () => {
|
||||
it('covers all 8 audit actions exactly', () => {
|
||||
expect(Object.keys(META).sort()).toEqual([...ALL_ACTIONS].sort());
|
||||
expect(ALL_ACTIONS).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('every action has a distinct marker kind (8 kinds → 8 glyph shapes)', () => {
|
||||
const kinds = ALL_ACTIONS.map((a) => META[a].kind);
|
||||
expect(new Set(kinds).size).toBe(8);
|
||||
});
|
||||
|
||||
it('every action has a non-empty label and hex color', () => {
|
||||
for (const action of ALL_ACTIONS) {
|
||||
const m = META[action];
|
||||
expect(m.label.length).toBeGreaterThan(0);
|
||||
expect(m.color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retention delta formatter
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatRetentionDelta', () => {
|
||||
it('returns null when both values are missing', () => {
|
||||
expect(formatRetentionDelta(undefined, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns "set X.XX" when only new is defined', () => {
|
||||
expect(formatRetentionDelta(undefined, 0.5)).toBe('set 0.50');
|
||||
// Note: toFixed(2) uses float-to-string half-to-even; assert on values
|
||||
// that round unambiguously rather than on IEEE-754 tie edges.
|
||||
expect(formatRetentionDelta(undefined, 0.736)).toBe('set 0.74');
|
||||
});
|
||||
|
||||
it('returns "was X.XX" when only old is defined', () => {
|
||||
expect(formatRetentionDelta(0.5, undefined)).toBe('was 0.50');
|
||||
});
|
||||
|
||||
it('returns "old → new" when both are defined', () => {
|
||||
expect(formatRetentionDelta(0.5, 0.7)).toBe('0.50 → 0.70');
|
||||
expect(formatRetentionDelta(0.72, 0.85)).toBe('0.72 → 0.85');
|
||||
});
|
||||
|
||||
it('handles descending deltas without changing the arrow', () => {
|
||||
// Suppression / demotion paths — old > new.
|
||||
expect(formatRetentionDelta(0.8, 0.6)).toBe('0.80 → 0.60');
|
||||
});
|
||||
|
||||
it('rejects non-finite numbers', () => {
|
||||
expect(formatRetentionDelta(NaN, 0.5)).toBe('set 0.50');
|
||||
expect(formatRetentionDelta(0.5, NaN)).toBe('was 0.50');
|
||||
expect(formatRetentionDelta(NaN, NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitVisible — 15-event cap
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('splitVisible — collapse threshold', () => {
|
||||
const makeEvents = (n: number): AuditEvent[] =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
action: 'accessed' as AuditAction,
|
||||
timestamp: new Date(NOW - i * 60_000).toISOString()
|
||||
}));
|
||||
|
||||
it('VISIBLE_LIMIT is 15', () => {
|
||||
expect(VISIBLE_LIMIT).toBe(15);
|
||||
});
|
||||
|
||||
it('exactly 15 events → no toggle (hiddenCount 0)', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(15), false);
|
||||
expect(visible).toHaveLength(15);
|
||||
expect(hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('14 events → no toggle', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(14), false);
|
||||
expect(visible).toHaveLength(14);
|
||||
expect(hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('16 events collapsed → visible 15, hidden 1', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(16), false);
|
||||
expect(visible).toHaveLength(15);
|
||||
expect(hiddenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('16 events expanded → visible 16, hidden reports overflow count (1)', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(16), true);
|
||||
expect(visible).toHaveLength(16);
|
||||
expect(hiddenCount).toBe(1);
|
||||
});
|
||||
|
||||
it('0 events → visible empty, hidden 0', () => {
|
||||
const { visible, hiddenCount } = splitVisible(makeEvents(0), false);
|
||||
expect(visible).toHaveLength(0);
|
||||
expect(hiddenCount).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves newest-first order when truncating', () => {
|
||||
const events = makeEvents(20);
|
||||
const { visible } = splitVisible(events, false);
|
||||
expect(visible[0]).toBe(events[0]);
|
||||
expect(visible[14]).toBe(events[14]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
/**
|
||||
* Unit tests for patterns-helpers — the pure logic backing
|
||||
* PatternTransferHeatmap.svelte + patterns/+page.svelte.
|
||||
*
|
||||
* Runs in the vitest `node` environment (no jsdom). We never touch Svelte
|
||||
* component internals here — only the exported helpers in patterns-helpers.ts.
|
||||
* Component-level integration (click, hover, DOM wiring) is covered by the
|
||||
* Playwright e2e suite; this file is pure-logic coverage of the contracts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
cellIntensity,
|
||||
filterByCategory,
|
||||
buildTransferMatrix,
|
||||
matrixMaxCount,
|
||||
flattenNonZero,
|
||||
shortProjectName,
|
||||
PATTERN_CATEGORIES,
|
||||
type TransferPatternLike,
|
||||
} from '../patterns-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures — mirror the mockFetchCrossProject shape in
|
||||
// patterns/+page.svelte, but small enough to reason about by hand.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECTS = ['vestige', 'nullgaze', 'injeranet'] as const;
|
||||
|
||||
const PATTERNS: TransferPatternLike[] = [
|
||||
{
|
||||
name: 'Result<T, E>',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
},
|
||||
{
|
||||
name: 'Axum middleware',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
{
|
||||
name: 'proptest',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
{
|
||||
name: 'Self-reuse pattern',
|
||||
category: 'Architecture',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['vestige'], // diagonal — self-reuse
|
||||
transfer_count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// ===========================================================================
|
||||
// cellIntensity — 0..1 opacity normaliser
|
||||
// ===========================================================================
|
||||
|
||||
describe('cellIntensity', () => {
|
||||
it('returns 0 for a zero count', () => {
|
||||
expect(cellIntensity(0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 1 at max', () => {
|
||||
expect(cellIntensity(10, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when count exceeds max (defensive clamp)', () => {
|
||||
expect(cellIntensity(15, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it('scales linearly between 0 and max', () => {
|
||||
expect(cellIntensity(3, 10)).toBeCloseTo(0.3, 5);
|
||||
expect(cellIntensity(5, 10)).toBeCloseTo(0.5, 5);
|
||||
expect(cellIntensity(7, 10)).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
|
||||
it('returns 0 when max is 0 (div-by-zero guard)', () => {
|
||||
expect(cellIntensity(5, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for negative counts', () => {
|
||||
expect(cellIntensity(-1, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for NaN inputs', () => {
|
||||
expect(cellIntensity(NaN, 10)).toBe(0);
|
||||
expect(cellIntensity(5, NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for Infinity inputs', () => {
|
||||
expect(cellIntensity(Infinity, 10)).toBe(0);
|
||||
expect(cellIntensity(5, Infinity)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// filterByCategory — drives both heatmap + sidebar reflow
|
||||
// ===========================================================================
|
||||
|
||||
describe('filterByCategory', () => {
|
||||
it("returns every pattern for 'All'", () => {
|
||||
const out = filterByCategory(PATTERNS, 'All');
|
||||
expect(out).toHaveLength(PATTERNS.length);
|
||||
// Should NOT return the same reference — helpers return a copy so
|
||||
// callers can mutate freely.
|
||||
expect(out).not.toBe(PATTERNS);
|
||||
});
|
||||
|
||||
it('filters strictly by category equality', () => {
|
||||
const errorOnly = filterByCategory(PATTERNS, 'ErrorHandling');
|
||||
expect(errorOnly).toHaveLength(2);
|
||||
expect(errorOnly.every((p) => p.category === 'ErrorHandling')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns exactly one match for Testing', () => {
|
||||
const testing = filterByCategory(PATTERNS, 'Testing');
|
||||
expect(testing).toHaveLength(1);
|
||||
expect(testing[0].name).toBe('proptest');
|
||||
});
|
||||
|
||||
it('returns an empty array for a category with no patterns', () => {
|
||||
const perf = filterByCategory(PATTERNS, 'Performance');
|
||||
expect(perf).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an unknown category string (no silent alias)', () => {
|
||||
// This is the "unknown category fallback" contract — we do NOT
|
||||
// quietly fall back to 'All'. An unknown category is a caller bug
|
||||
// and yields an empty list so the empty-state UI renders.
|
||||
expect(filterByCategory(PATTERNS, 'NotARealCategory')).toEqual([]);
|
||||
expect(filterByCategory(PATTERNS, '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts an empty input array for any category', () => {
|
||||
expect(filterByCategory([], 'All')).toEqual([]);
|
||||
expect(filterByCategory([], 'ErrorHandling')).toEqual([]);
|
||||
expect(filterByCategory([], 'BogusCategory')).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes all six supported categories', () => {
|
||||
expect([...PATTERN_CATEGORIES]).toEqual([
|
||||
'ErrorHandling',
|
||||
'AsyncConcurrency',
|
||||
'Testing',
|
||||
'Architecture',
|
||||
'Performance',
|
||||
'Security',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// buildTransferMatrix — directional N×N projects × projects grid
|
||||
// ===========================================================================
|
||||
|
||||
describe('buildTransferMatrix', () => {
|
||||
it('constructs an N×N matrix over the projects axis', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, []);
|
||||
for (const from of PROJECTS) {
|
||||
for (const to of PROJECTS) {
|
||||
expect(m[from][to]).toEqual({ count: 0, topNames: [] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('aggregates transfer counts directionally', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
// vestige → nullgaze: Result<T,E> + proptest = 2
|
||||
expect(m.vestige.nullgaze.count).toBe(2);
|
||||
// vestige → injeranet: Result<T,E> only = 1
|
||||
expect(m.vestige.injeranet.count).toBe(1);
|
||||
// nullgaze → vestige: Axum middleware = 1
|
||||
expect(m.nullgaze.vestige.count).toBe(1);
|
||||
// injeranet → anywhere: zero (no origin in injeranet in fixtures)
|
||||
expect(m.injeranet.vestige.count).toBe(0);
|
||||
expect(m.injeranet.nullgaze.count).toBe(0);
|
||||
});
|
||||
|
||||
it('treats (A, B) and (B, A) as distinct directions (asymmetry confirmed)', () => {
|
||||
// The component's doc-comment says "Rows = origin project · Columns =
|
||||
// destination project" — the matrix MUST be directional. A copy-paste
|
||||
// bug that aggregates both directions into the same cell would pass
|
||||
// the "count" test above but fail this symmetry check.
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
expect(m.vestige.nullgaze.count).not.toBe(m.nullgaze.vestige.count);
|
||||
});
|
||||
|
||||
it('records self-transfer on the diagonal', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
expect(m.vestige.vestige.count).toBe(1);
|
||||
expect(m.vestige.vestige.topNames).toEqual(['Self-reuse pattern']);
|
||||
});
|
||||
|
||||
it('captures top pattern names per cell, capped at 3', () => {
|
||||
const manyPatterns: TransferPatternLike[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
name: `pattern-${i}`,
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
}));
|
||||
const m = buildTransferMatrix(['vestige', 'nullgaze'], manyPatterns);
|
||||
expect(m.vestige.nullgaze.count).toBe(5);
|
||||
expect(m.vestige.nullgaze.topNames).toHaveLength(3);
|
||||
expect(m.vestige.nullgaze.topNames).toEqual(['pattern-0', 'pattern-1', 'pattern-2']);
|
||||
});
|
||||
|
||||
it('silently drops patterns whose origin is not in the projects axis', () => {
|
||||
const orphan: TransferPatternLike = {
|
||||
name: 'Orphan',
|
||||
category: 'Security',
|
||||
origin_project: 'ghost-project',
|
||||
transferred_to: ['vestige'],
|
||||
transfer_count: 1,
|
||||
};
|
||||
const m = buildTransferMatrix(PROJECTS, [orphan]);
|
||||
// Nothing anywhere in the matrix should have ticked up.
|
||||
const total = matrixMaxCount(PROJECTS, m);
|
||||
expect(total).toBe(0);
|
||||
// Matrix structure intact — no ghost key added.
|
||||
expect((m as Record<string, unknown>)['ghost-project']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('silently drops transferred_to entries not in the projects axis', () => {
|
||||
const strayDest: TransferPatternLike = {
|
||||
name: 'StrayDest',
|
||||
category: 'Security',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['ghost-project', 'nullgaze'],
|
||||
transfer_count: 2,
|
||||
};
|
||||
const m = buildTransferMatrix(PROJECTS, [strayDest]);
|
||||
// The known destination counts; the ghost doesn't.
|
||||
expect(m.vestige.nullgaze.count).toBe(1);
|
||||
expect((m.vestige as Record<string, unknown>)['ghost-project']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects a custom top-name cap', () => {
|
||||
const pats: TransferPatternLike[] = [
|
||||
{
|
||||
name: 'a',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
{
|
||||
name: 'b',
|
||||
category: 'Testing',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
},
|
||||
];
|
||||
const m = buildTransferMatrix(['vestige', 'nullgaze'], pats, 1);
|
||||
expect(m.vestige.nullgaze.topNames).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// matrixMaxCount
|
||||
// ===========================================================================
|
||||
|
||||
describe('matrixMaxCount', () => {
|
||||
it('returns 0 for an empty matrix (div-by-zero guard prerequisite)', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, []);
|
||||
expect(matrixMaxCount(PROJECTS, m)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the hottest cell count across all pairs', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
// vestige→nullgaze has 2; everything else is ≤1
|
||||
expect(matrixMaxCount(PROJECTS, m)).toBe(2);
|
||||
});
|
||||
|
||||
it('tolerates missing rows without crashing', () => {
|
||||
const partial: Record<string, Record<string, { count: number; topNames: string[] }>> = {
|
||||
vestige: { vestige: { count: 3, topNames: [] } },
|
||||
};
|
||||
expect(matrixMaxCount(['vestige', 'absent'], partial)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// flattenNonZero — mobile fallback feed
|
||||
// ===========================================================================
|
||||
|
||||
describe('flattenNonZero', () => {
|
||||
it('returns only non-zero pairs, sorted by count descending', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, PATTERNS);
|
||||
const rows = flattenNonZero(PROJECTS, m);
|
||||
// Distinct non-zero cells in fixtures:
|
||||
// vestige→nullgaze = 2
|
||||
// vestige→injeranet = 1
|
||||
// vestige→vestige = 1
|
||||
// nullgaze→vestige = 1
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(rows[0]).toMatchObject({ from: 'vestige', to: 'nullgaze', count: 2 });
|
||||
// Later rows all tied at 1 — we only verify the leader.
|
||||
expect(rows.slice(1).every((r) => r.count === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing is transferred', () => {
|
||||
const m = buildTransferMatrix(PROJECTS, []);
|
||||
expect(flattenNonZero(PROJECTS, m)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// shortProjectName
|
||||
// ===========================================================================
|
||||
|
||||
describe('shortProjectName', () => {
|
||||
it('passes short names through unchanged', () => {
|
||||
expect(shortProjectName('vestige')).toBe('vestige');
|
||||
expect(shortProjectName('')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps names at the 12-char boundary', () => {
|
||||
expect(shortProjectName('123456789012')).toBe('123456789012');
|
||||
});
|
||||
|
||||
it('truncates longer names to 11 chars + ellipsis', () => {
|
||||
expect(shortProjectName('1234567890123')).toBe('12345678901…');
|
||||
expect(shortProjectName('super-long-project-name')).toBe('super-long-…');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* ReasoningChain — pure-logic coverage.
|
||||
*
|
||||
* ReasoningChain renders the 8-stage cognitive pipeline. Its rendered output
|
||||
* is a pure function of a handful of primitive props — confidence colours,
|
||||
* intent-hint selection, and the stage hint resolver. All of that logic
|
||||
* lives in `reasoning-helpers.ts` and is exercised here without mounting
|
||||
* Svelte.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
confidenceColor,
|
||||
confidenceLabel,
|
||||
intentHintFor,
|
||||
INTENT_HINTS,
|
||||
CONFIDENCE_EMERALD,
|
||||
CONFIDENCE_AMBER,
|
||||
CONFIDENCE_RED,
|
||||
type IntentKey,
|
||||
} from '../reasoning-helpers';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// confidenceColor — the spec-critical boundary table
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('confidenceColor — band boundaries (>75 emerald, 40-75 amber, <40 red)', () => {
|
||||
it.each<[number, string]>([
|
||||
// Emerald band: strictly greater than 75
|
||||
[100, CONFIDENCE_EMERALD],
|
||||
[99.99, CONFIDENCE_EMERALD],
|
||||
[80, CONFIDENCE_EMERALD],
|
||||
[76, CONFIDENCE_EMERALD],
|
||||
[75.01, CONFIDENCE_EMERALD],
|
||||
// Amber band: 40 <= c <= 75
|
||||
[75, CONFIDENCE_AMBER], // exactly 75 → amber (page spec: `>75` emerald)
|
||||
[60, CONFIDENCE_AMBER],
|
||||
[50, CONFIDENCE_AMBER],
|
||||
[40.01, CONFIDENCE_AMBER],
|
||||
[40, CONFIDENCE_AMBER], // exactly 40 → amber (page spec: `>=40` amber)
|
||||
// Red band: strictly less than 40
|
||||
[39.99, CONFIDENCE_RED],
|
||||
[20, CONFIDENCE_RED],
|
||||
[0.01, CONFIDENCE_RED],
|
||||
[0, CONFIDENCE_RED],
|
||||
])('confidence %f → %s', (c, expected) => {
|
||||
expect(confidenceColor(c)).toBe(expected);
|
||||
});
|
||||
|
||||
it('clamps negative to red (defensive — confidence should never be negative)', () => {
|
||||
expect(confidenceColor(-10)).toBe(CONFIDENCE_RED);
|
||||
});
|
||||
|
||||
it('over-100 stays emerald (defensive — confidence should never exceed 100)', () => {
|
||||
expect(confidenceColor(150)).toBe(CONFIDENCE_EMERALD);
|
||||
});
|
||||
|
||||
it('NaN → red (worst-case band)', () => {
|
||||
expect(confidenceColor(Number.NaN)).toBe(CONFIDENCE_RED);
|
||||
});
|
||||
|
||||
it('is pure — same input yields same output', () => {
|
||||
for (const c of [0, 39.99, 40, 75, 75.01, 100]) {
|
||||
expect(confidenceColor(c)).toBe(confidenceColor(c));
|
||||
}
|
||||
});
|
||||
|
||||
it('never returns an empty string or undefined', () => {
|
||||
for (const c of [-1, 0, 20, 40, 75, 76, 100, 200, Number.NaN]) {
|
||||
const colour = confidenceColor(c);
|
||||
expect(typeof colour).toBe('string');
|
||||
expect(colour.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('confidenceLabel — human text per band', () => {
|
||||
it.each<[number, string]>([
|
||||
[100, 'HIGH CONFIDENCE'],
|
||||
[76, 'HIGH CONFIDENCE'],
|
||||
[75.01, 'HIGH CONFIDENCE'],
|
||||
[75, 'MIXED SIGNAL'],
|
||||
[60, 'MIXED SIGNAL'],
|
||||
[40, 'MIXED SIGNAL'],
|
||||
[39.99, 'LOW CONFIDENCE'],
|
||||
[0, 'LOW CONFIDENCE'],
|
||||
])('confidence %f → %s', (c, expected) => {
|
||||
expect(confidenceLabel(c)).toBe(expected);
|
||||
});
|
||||
|
||||
it('NaN → LOW CONFIDENCE (safe default)', () => {
|
||||
expect(confidenceLabel(Number.NaN)).toBe('LOW CONFIDENCE');
|
||||
});
|
||||
|
||||
it('agrees with confidenceColor across the spec boundary sweep', () => {
|
||||
// Sanity: if the label is HIGH, the colour must be emerald, etc.
|
||||
const cases: Array<[number, string, string]> = [
|
||||
[100, 'HIGH CONFIDENCE', CONFIDENCE_EMERALD],
|
||||
[76, 'HIGH CONFIDENCE', CONFIDENCE_EMERALD],
|
||||
[75, 'MIXED SIGNAL', CONFIDENCE_AMBER],
|
||||
[40, 'MIXED SIGNAL', CONFIDENCE_AMBER],
|
||||
[39.99, 'LOW CONFIDENCE', CONFIDENCE_RED],
|
||||
[0, 'LOW CONFIDENCE', CONFIDENCE_RED],
|
||||
];
|
||||
for (const [c, label, colour] of cases) {
|
||||
expect(confidenceLabel(c)).toBe(label);
|
||||
expect(confidenceColor(c)).toBe(colour);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Intent classification — visual hint mapping
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('INTENT_HINTS — one hint per deep_reference intent', () => {
|
||||
const intents: IntentKey[] = [
|
||||
'FactCheck',
|
||||
'Timeline',
|
||||
'RootCause',
|
||||
'Comparison',
|
||||
'Synthesis',
|
||||
];
|
||||
|
||||
it('defines a hint for every intent the backend emits', () => {
|
||||
for (const i of intents) {
|
||||
expect(INTENT_HINTS[i]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(intents)('%s hint has label + icon + description', (i) => {
|
||||
const hint = INTENT_HINTS[i];
|
||||
expect(hint.label).toBe(i); // label doubles as canonical id
|
||||
expect(hint.icon.length).toBeGreaterThan(0);
|
||||
expect(hint.description.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('icons are unique across intents (so the eye can distinguish them)', () => {
|
||||
const icons = intents.map((i) => INTENT_HINTS[i].icon);
|
||||
expect(new Set(icons).size).toBe(intents.length);
|
||||
});
|
||||
|
||||
it('descriptions are distinct across intents', () => {
|
||||
const descs = intents.map((i) => INTENT_HINTS[i].description);
|
||||
expect(new Set(descs).size).toBe(intents.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intentHintFor — lookup with safe fallback', () => {
|
||||
it('returns the exact entry for a known intent', () => {
|
||||
expect(intentHintFor('FactCheck')).toBe(INTENT_HINTS.FactCheck);
|
||||
expect(intentHintFor('Timeline')).toBe(INTENT_HINTS.Timeline);
|
||||
expect(intentHintFor('RootCause')).toBe(INTENT_HINTS.RootCause);
|
||||
expect(intentHintFor('Comparison')).toBe(INTENT_HINTS.Comparison);
|
||||
expect(intentHintFor('Synthesis')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
|
||||
it('falls back to Synthesis for unknown intent (most generic classification)', () => {
|
||||
expect(intentHintFor('Prediction')).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor('nonsense')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
|
||||
it('falls back to Synthesis for null / undefined / empty string', () => {
|
||||
expect(intentHintFor(null)).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor(undefined)).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor('')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
|
||||
it('is case-sensitive — backend emits Title-case strings and we honour that', () => {
|
||||
// If case-folding becomes desirable, this test will force the
|
||||
// change to be explicit rather than accidental.
|
||||
expect(intentHintFor('factcheck')).toBe(INTENT_HINTS.Synthesis);
|
||||
expect(intentHintFor('FACTCHECK')).toBe(INTENT_HINTS.Synthesis);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Stage-count invariant — the component renders exactly 8 stages
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cognitive pipeline shape', () => {
|
||||
it('confidence colour constants are all distinct hex strings', () => {
|
||||
const set = new Set([
|
||||
CONFIDENCE_EMERALD.toLowerCase(),
|
||||
CONFIDENCE_AMBER.toLowerCase(),
|
||||
CONFIDENCE_RED.toLowerCase(),
|
||||
]);
|
||||
expect(set.size).toBe(3);
|
||||
for (const c of set) {
|
||||
expect(c).toMatch(/^#[0-9a-f]{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
237
apps/dashboard/src/lib/components/activation-helpers.ts
Normal file
237
apps/dashboard/src/lib/components/activation-helpers.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* activation-helpers — Pure logic for the Spreading Activation Live View.
|
||||
*
|
||||
* Extracted from ActivationNetwork.svelte + activation/+page.svelte so the
|
||||
* decay / geometry / event-filtering rules can be exercised in the Vitest
|
||||
* `node` environment without jsdom. Every helper in this module is a pure
|
||||
* function of its inputs; no DOM, no timers, no Svelte runes.
|
||||
*
|
||||
* The constants in this module are the single source of truth — the Svelte
|
||||
* component re-exports / re-uses them rather than hard-coding its own.
|
||||
*
|
||||
* References
|
||||
* ----------
|
||||
* - Collins & Loftus 1975 — spreading activation with exponential decay
|
||||
* - Anderson 1983 (ACT-R) — activation threshold for availability
|
||||
*/
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import type { VestigeEvent } from '$types';
|
||||
|
||||
/** Per-tick multiplicative decay factor (Collins & Loftus 1975). */
|
||||
export const DECAY = 0.93;
|
||||
|
||||
/** Activation below this floor is invisible / garbage-collected. */
|
||||
export const MIN_VISIBLE = 0.05;
|
||||
|
||||
/** Fallback node colour when NODE_TYPE_COLORS has no entry for the type. */
|
||||
export const FALLBACK_COLOR = '#8B95A5';
|
||||
|
||||
/** Source node colour (synapse-glow). Distinct from any node-type colour. */
|
||||
export const SOURCE_COLOR = '#818cf8';
|
||||
|
||||
/** Radial spacing between concentric rings (px). */
|
||||
export const RING_GAP = 140;
|
||||
|
||||
/** Max neighbours that fit on ring 1 before spilling to ring 2. */
|
||||
export const RING_1_CAPACITY = 8;
|
||||
|
||||
/** Edge draw stagger — frames of delay per rank inside a ring. */
|
||||
export const STAGGER_PER_RANK = 4;
|
||||
|
||||
/** Extra stagger added to ring-2 edges so they light up after ring 1. */
|
||||
export const STAGGER_RING_2_BONUS = 12;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decay math
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a single tick of exponential decay. Clamps negative input to 0 so a
|
||||
* corrupt state never produces a creeping-positive value on the next tick.
|
||||
*/
|
||||
export function applyDecay(activation: number): number {
|
||||
if (!Number.isFinite(activation) || activation <= 0) return 0;
|
||||
return activation * DECAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compound decay over N ticks. N < 0 is treated as 0 (no change).
|
||||
* Equivalent to calling `applyDecay` N times.
|
||||
*/
|
||||
export function compoundDecay(activation: number, ticks: number): number {
|
||||
if (!Number.isFinite(activation) || activation <= 0) return 0;
|
||||
if (!Number.isFinite(ticks) || ticks <= 0) return activation;
|
||||
return activation * DECAY ** ticks;
|
||||
}
|
||||
|
||||
/** True if the node's activation is at or above the visibility floor. */
|
||||
export function isVisible(activation: number): boolean {
|
||||
if (!Number.isFinite(activation)) return false;
|
||||
return activation >= MIN_VISIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many ticks until `initial` decays below `MIN_VISIBLE`. Useful in tests
|
||||
* and for sizing animation budgets. Initial <= threshold returns 0.
|
||||
*/
|
||||
export function ticksUntilInvisible(initial: number): number {
|
||||
if (!Number.isFinite(initial) || initial <= MIN_VISIBLE) return 0;
|
||||
// initial * DECAY^n < MIN_VISIBLE → n > log(MIN_VISIBLE/initial) / log(DECAY)
|
||||
const n = Math.log(MIN_VISIBLE / initial) / Math.log(DECAY);
|
||||
return Math.ceil(n);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ring placement — concentric circles around a source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a neighbour's 0-indexed rank into a ring number.
|
||||
* Ranks 0..RING_1_CAPACITY-1 → ring 1; rest → ring 2.
|
||||
*/
|
||||
export function computeRing(rank: number): 1 | 2 {
|
||||
if (!Number.isFinite(rank) || rank < RING_1_CAPACITY) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evenly distribute `count` positions on a circle of radius `ring * RING_GAP`
|
||||
* centred at (cx, cy). `angleOffset` rotates the whole ring so overlapping
|
||||
* bursts don't perfectly collide. Zero count returns `[]`.
|
||||
*/
|
||||
export function ringPositions(
|
||||
cx: number,
|
||||
cy: number,
|
||||
count: number,
|
||||
ring: number,
|
||||
angleOffset = 0,
|
||||
): Point[] {
|
||||
if (!Number.isFinite(count) || count <= 0) return [];
|
||||
const radius = RING_GAP * ring;
|
||||
const positions: Point[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = angleOffset + (i / count) * Math.PI * 2;
|
||||
positions.push({
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the full neighbour list, produce a flat array of Points — ring 1
|
||||
* first, ring 2 after. The resulting length === neighbours.length.
|
||||
*/
|
||||
export function layoutNeighbours(
|
||||
cx: number,
|
||||
cy: number,
|
||||
neighbourCount: number,
|
||||
angleOffset = 0,
|
||||
): Point[] {
|
||||
const ring1 = Math.min(neighbourCount, RING_1_CAPACITY);
|
||||
const ring2 = Math.max(0, neighbourCount - RING_1_CAPACITY);
|
||||
return [
|
||||
...ringPositions(cx, cy, ring1, 1, angleOffset),
|
||||
...ringPositions(cx, cy, ring2, 2, angleOffset),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial activation by rank
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Seed activation for a neighbour at 0-indexed `rank` given `total`.
|
||||
* Higher-ranked (earlier) neighbours get stronger initial activation.
|
||||
* Ring-2 neighbours get a 0.75× ring-factor penalty on top of the rank factor.
|
||||
* Returns a value in (0, 1].
|
||||
*/
|
||||
export function initialActivation(rank: number, total: number): number {
|
||||
if (!Number.isFinite(total) || total <= 0) return 0;
|
||||
if (!Number.isFinite(rank) || rank < 0) return 0;
|
||||
const rankFactor = 1 - (rank / total) * 0.35;
|
||||
const ringFactor = computeRing(rank) === 1 ? 1 : 0.75;
|
||||
return Math.min(1, rankFactor * ringFactor);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge stagger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Delay (in animation frames) before the edge at rank `i` starts drawing.
|
||||
* Ring 1 edges light up first, then ring 2 after a bonus delay.
|
||||
*/
|
||||
export function edgeStagger(rank: number): number {
|
||||
if (!Number.isFinite(rank) || rank < 0) return 0;
|
||||
const r = Math.floor(rank);
|
||||
const base = r * STAGGER_PER_RANK;
|
||||
return computeRing(r) === 1 ? base : base + STAGGER_RING_2_BONUS;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Colour for a node on the activation canvas.
|
||||
* - source nodes always use SOURCE_COLOR (synapse-glow)
|
||||
* - known node types use NODE_TYPE_COLORS
|
||||
* - unknown node types fall back to FALLBACK_COLOR (soft steel)
|
||||
*/
|
||||
export function activationColor(
|
||||
nodeType: string | null | undefined,
|
||||
isSource: boolean,
|
||||
): string {
|
||||
if (isSource) return SOURCE_COLOR;
|
||||
if (!nodeType) return FALLBACK_COLOR;
|
||||
return NODE_TYPE_COLORS[nodeType] ?? FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event-feed filtering — "only fire on NEW ActivationSpread events"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SpreadPayload {
|
||||
source_id: string;
|
||||
target_ids: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ActivationSpread payloads from a websocket event feed. The feed
|
||||
* is prepended (newest at index 0, oldest at the end). Stop as soon as we
|
||||
* hit the reference of `lastSeen` — events at or past that point were
|
||||
* already processed by a prior tick.
|
||||
*
|
||||
* Returned payloads are in OLDEST-FIRST order so downstream callers can
|
||||
* fire them in the same narrative order they occurred.
|
||||
*
|
||||
* Payloads missing required fields are silently skipped.
|
||||
*/
|
||||
export function filterNewSpreadEvents(
|
||||
feed: readonly VestigeEvent[],
|
||||
lastSeen: VestigeEvent | null,
|
||||
): SpreadPayload[] {
|
||||
if (!feed || feed.length === 0) return [];
|
||||
const fresh: SpreadPayload[] = [];
|
||||
for (const ev of feed) {
|
||||
if (ev === lastSeen) break;
|
||||
if (ev.type !== 'ActivationSpread') continue;
|
||||
const data = ev.data as { source_id?: unknown; target_ids?: unknown };
|
||||
if (typeof data.source_id !== 'string') continue;
|
||||
if (!Array.isArray(data.target_ids)) continue;
|
||||
const targets = data.target_ids.filter(
|
||||
(t): t is string => typeof t === 'string',
|
||||
);
|
||||
if (targets.length === 0) continue;
|
||||
fresh.push({ source_id: data.source_id, target_ids: targets });
|
||||
}
|
||||
// Reverse so oldest-first.
|
||||
return fresh.reverse();
|
||||
}
|
||||
293
apps/dashboard/src/lib/components/audit-trail-helpers.ts
Normal file
293
apps/dashboard/src/lib/components/audit-trail-helpers.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Pure helpers for MemoryAuditTrail.
|
||||
*
|
||||
* Extracted for isolated unit testing in a Node (vitest) environment —
|
||||
* no DOM, no Svelte runtime, no fetch. Every function in this module is
|
||||
* deterministic given its inputs.
|
||||
*/
|
||||
|
||||
export type AuditAction =
|
||||
| 'created'
|
||||
| 'accessed'
|
||||
| 'promoted'
|
||||
| 'demoted'
|
||||
| 'edited'
|
||||
| 'suppressed'
|
||||
| 'dreamed'
|
||||
| 'reconsolidated';
|
||||
|
||||
export interface AuditEvent {
|
||||
action: AuditAction;
|
||||
timestamp: string; // ISO
|
||||
old_value?: number;
|
||||
new_value?: number;
|
||||
reason?: string;
|
||||
triggered_by?: string;
|
||||
}
|
||||
|
||||
export type MarkerKind =
|
||||
| 'dot'
|
||||
| 'arrow-up'
|
||||
| 'arrow-down'
|
||||
| 'pencil'
|
||||
| 'x'
|
||||
| 'star'
|
||||
| 'circle-arrow'
|
||||
| 'ring';
|
||||
|
||||
export interface Meta {
|
||||
label: string;
|
||||
color: string; // hex for dot + glow
|
||||
glyph: string; // optional inline symbol
|
||||
kind: MarkerKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type → visual metadata. Each action maps to a UNIQUE marker `kind`
|
||||
* so the 8 event types are visually distinguishable without relying on the
|
||||
* colour palette alone (accessibility).
|
||||
*/
|
||||
export const META: Record<AuditAction, Meta> = {
|
||||
created: { label: 'Created', color: '#10b981', glyph: '', kind: 'ring' },
|
||||
accessed: { label: 'Accessed', color: '#3b82f6', glyph: '', kind: 'dot' },
|
||||
promoted: { label: 'Promoted', color: '#10b981', glyph: '', kind: 'arrow-up' },
|
||||
demoted: { label: 'Demoted', color: '#f59e0b', glyph: '', kind: 'arrow-down' },
|
||||
edited: { label: 'Edited', color: '#facc15', glyph: '', kind: 'pencil' },
|
||||
suppressed: { label: 'Suppressed', color: '#a855f7', glyph: '', kind: 'x' },
|
||||
dreamed: { label: 'Dreamed', color: '#c084fc', glyph: '', kind: 'star' },
|
||||
reconsolidated: { label: 'Reconsolidated', color: '#ec4899', glyph: '', kind: 'circle-arrow' }
|
||||
};
|
||||
|
||||
export const VISIBLE_LIMIT = 15;
|
||||
|
||||
/**
|
||||
* All 8 `AuditAction` values, in the canonical order. Used both by the
|
||||
* event generator (`actionPool`) and by tests that verify uniqueness of
|
||||
* the marker mapping.
|
||||
*/
|
||||
export const ALL_ACTIONS: readonly AuditAction[] = [
|
||||
'created',
|
||||
'accessed',
|
||||
'promoted',
|
||||
'demoted',
|
||||
'edited',
|
||||
'suppressed',
|
||||
'dreamed',
|
||||
'reconsolidated'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hash a string id into a 32-bit unsigned seed. Stable across runs.
|
||||
*/
|
||||
export function hashSeed(id: string): number {
|
||||
let seed = 0;
|
||||
for (let i = 0; i < id.length; i++) seed = (seed * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear congruential PRNG bound to a mutable seed. Returns a function
|
||||
* that yields floats in `[0, 1)` — critically, NEVER 1.0, so callers
|
||||
* can safely use `Math.floor(rand() * arr.length)` without off-by-one.
|
||||
*/
|
||||
export function makeRand(initialSeed: number): () => number {
|
||||
let seed = initialSeed >>> 0;
|
||||
return () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
// Divide by 2^32, not 2^32 - 1 — the latter can yield exactly 1.0
|
||||
// when seed is UINT32_MAX, breaking array-index math.
|
||||
return seed / 0x100000000;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic mock audit-trail generator. Same `memoryId` + `nowMs`
|
||||
* ALWAYS yields the same event sequence (critical for snapshot stability
|
||||
* and for tests). An empty `memoryId` yields no events — the audit trail
|
||||
* panel should never invent history for a non-existent memory.
|
||||
*
|
||||
* `countOverride` lets tests force a specific number of events (e.g.
|
||||
* to cross the 15-event visibility threshold, which the default range
|
||||
* 8-15 cannot do).
|
||||
*/
|
||||
export function generateMockAuditTrail(
|
||||
memoryId: string,
|
||||
nowMs: number = Date.now(),
|
||||
countOverride?: number
|
||||
): AuditEvent[] {
|
||||
if (!memoryId) return [];
|
||||
|
||||
const rand = makeRand(hashSeed(memoryId));
|
||||
const count = countOverride ?? 8 + Math.floor(rand() * 8); // default 8-15 events
|
||||
if (count <= 0) return [];
|
||||
|
||||
const out: AuditEvent[] = [];
|
||||
|
||||
const createdAt = nowMs - (14 + rand() * 21) * 86_400_000; // 14-35 days ago
|
||||
out.push({
|
||||
action: 'created',
|
||||
timestamp: new Date(createdAt).toISOString(),
|
||||
reason: 'smart_ingest · prediction-error gate opened',
|
||||
triggered_by: 'smart_ingest'
|
||||
});
|
||||
|
||||
let t = createdAt;
|
||||
let retention = 0.5 + rand() * 0.2;
|
||||
const actionPool: AuditAction[] = [
|
||||
'accessed',
|
||||
'accessed',
|
||||
'accessed',
|
||||
'accessed',
|
||||
'promoted',
|
||||
'demoted',
|
||||
'edited',
|
||||
'dreamed',
|
||||
'reconsolidated',
|
||||
'suppressed'
|
||||
];
|
||||
|
||||
for (let i = 1; i < count; i++) {
|
||||
t += rand() * 5 * 86_400_000 + 3_600_000; // 1h-5d between events
|
||||
const action = actionPool[Math.floor(rand() * actionPool.length)];
|
||||
const ev: AuditEvent = { action, timestamp: new Date(t).toISOString() };
|
||||
|
||||
switch (action) {
|
||||
case 'accessed': {
|
||||
const old = retention;
|
||||
retention = Math.min(1, retention + rand() * 0.04 + 0.01);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.triggered_by = rand() > 0.5 ? 'search' : 'deep_reference';
|
||||
break;
|
||||
}
|
||||
case 'promoted': {
|
||||
const old = retention;
|
||||
retention = Math.min(1, retention + 0.1);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'confirmed helpful by user';
|
||||
ev.triggered_by = 'memory(action=promote)';
|
||||
break;
|
||||
}
|
||||
case 'demoted': {
|
||||
const old = retention;
|
||||
retention = Math.max(0, retention - 0.15);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'user flagged as outdated';
|
||||
ev.triggered_by = 'memory(action=demote)';
|
||||
break;
|
||||
}
|
||||
case 'edited': {
|
||||
ev.reason = 'content refined, FSRS state preserved';
|
||||
ev.triggered_by = 'memory(action=edit)';
|
||||
break;
|
||||
}
|
||||
case 'suppressed': {
|
||||
const old = retention;
|
||||
retention = Math.max(0, retention - 0.08);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'top-down inhibition (Anderson 2025)';
|
||||
ev.triggered_by = 'suppress(dashboard)';
|
||||
break;
|
||||
}
|
||||
case 'dreamed': {
|
||||
const old = retention;
|
||||
retention = Math.min(1, retention + 0.05);
|
||||
ev.old_value = old;
|
||||
ev.new_value = retention;
|
||||
ev.reason = 'replayed during dream consolidation';
|
||||
ev.triggered_by = 'dream()';
|
||||
break;
|
||||
}
|
||||
case 'reconsolidated': {
|
||||
ev.reason = 'edited within 5-min labile window (Nader)';
|
||||
ev.triggered_by = 'reconsolidation-manager';
|
||||
break;
|
||||
}
|
||||
case 'created':
|
||||
// Created is only emitted once, as the first event. If the pool
|
||||
// ever yields it again, treat it as a no-op access marker with
|
||||
// no retention change — defensive, not expected.
|
||||
ev.triggered_by = 'smart_ingest';
|
||||
break;
|
||||
}
|
||||
|
||||
out.push(ev);
|
||||
}
|
||||
|
||||
// Newest first for display.
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanised relative time. Uses supplied `nowMs` for deterministic tests;
|
||||
* defaults to `Date.now()` in production.
|
||||
*
|
||||
* Boundaries (strictly `<`, so 60s flips to "1m", 60m flips to "1h", etc.):
|
||||
* <60s → "Ns ago"
|
||||
* <60m → "Nm ago"
|
||||
* <24h → "Nh ago"
|
||||
* <30d → "Nd ago"
|
||||
* <12mo → "Nmo ago"
|
||||
* else → "Ny ago"
|
||||
*
|
||||
* Future timestamps (nowMs < then) clamp to "0s ago" rather than returning
|
||||
* a negative string — the audit trail is a past-only view.
|
||||
*/
|
||||
export function relativeTime(iso: string, nowMs: number = Date.now()): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Math.max(0, nowMs - then);
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
const mo = Math.floor(d / 30);
|
||||
if (mo < 12) return `${mo}mo ago`;
|
||||
const y = Math.floor(mo / 12);
|
||||
return `${y}y ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention delta formatter. Behaviour:
|
||||
* (undef, undef) → null — no retention movement on this event
|
||||
* (undef, 0.72) → "set 0.72" — initial value, no prior state
|
||||
* (0.50, undef) → "was 0.50" — retention cleared (rare)
|
||||
* (0.50, 0.72) → "0.50 → 0.72"
|
||||
*
|
||||
* The `retention ` prefix is left to the caller so tests can compare the
|
||||
* core formatted value precisely.
|
||||
*/
|
||||
export function formatRetentionDelta(
|
||||
oldValue: number | undefined,
|
||||
newValue: number | undefined
|
||||
): string | null {
|
||||
const hasOld = typeof oldValue === 'number' && Number.isFinite(oldValue);
|
||||
const hasNew = typeof newValue === 'number' && Number.isFinite(newValue);
|
||||
if (!hasOld && !hasNew) return null;
|
||||
if (!hasOld && hasNew) return `set ${newValue!.toFixed(2)}`;
|
||||
if (hasOld && !hasNew) return `was ${oldValue!.toFixed(2)}`;
|
||||
return `${oldValue!.toFixed(2)} → ${newValue!.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an event list into (visible, hiddenCount) per the 15-event cap.
|
||||
* Exactly 15 events → no toggle (hiddenCount = 0). 16+ → toggle.
|
||||
*/
|
||||
export function splitVisible(
|
||||
events: AuditEvent[],
|
||||
showAll: boolean
|
||||
): { visible: AuditEvent[]; hiddenCount: number } {
|
||||
if (showAll || events.length <= VISIBLE_LIMIT) {
|
||||
return { visible: events, hiddenCount: Math.max(0, events.length - VISIBLE_LIMIT) };
|
||||
}
|
||||
return {
|
||||
visible: events.slice(0, VISIBLE_LIMIT),
|
||||
hiddenCount: events.length - VISIBLE_LIMIT
|
||||
};
|
||||
}
|
||||
192
apps/dashboard/src/lib/components/awareness-helpers.ts
Normal file
192
apps/dashboard/src/lib/components/awareness-helpers.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Pure helpers for AmbientAwarenessStrip.svelte.
|
||||
*
|
||||
* Extracted so the time-window, event-scan, and timestamp-parsing logic can
|
||||
* be unit tested in the vitest `node` environment without jsdom, Svelte
|
||||
* rendering, or fake timers bleeding into runes.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - `parseEventTimestamp`: handles (a) numeric ms (>1e12), (b) numeric seconds
|
||||
* (<=1e12), (c) ISO-8601 string, (d) invalid/absent → null.
|
||||
* - `bucketizeActivity`: given ms timestamps + `now`, returns 10 counts for a
|
||||
* 5-min trailing window. Bucket 0 = oldest 30s, bucket 9 = newest 30s.
|
||||
* Events outside [now-5m, now] are dropped (clock skew).
|
||||
* - `findRecentDream`: returns the newest-indexed (feed is newest-first)
|
||||
* DreamCompleted whose parsed timestamp is within 24h, else null. If the
|
||||
* timestamp is unparseable, `now` is used as the fallback (matches the
|
||||
* component's behavior).
|
||||
* - `isDreaming`: a DreamStarted within the last 5 min NOT followed by a
|
||||
* newer DreamCompleted. Mirrors the component's derived block exactly.
|
||||
* - `hasRecentSuppression`: any MemorySuppressed event with a parsed
|
||||
* timestamp within `thresholdMs` of `now`. Feed is assumed newest-first —
|
||||
* we break as soon as we pass the threshold, matching component behavior.
|
||||
*
|
||||
* All helpers are null-safe and treat unparseable timestamps consistently
|
||||
* (fall back to `now`, matching the on-screen "something just happened" feel).
|
||||
*/
|
||||
|
||||
export interface EventLike {
|
||||
type: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a VestigeEvent timestamp, checking `data.timestamp`, then `data.at`,
|
||||
* then `data.occurred_at`. Supports ms-since-epoch numbers, seconds-since-epoch
|
||||
* numbers, and ISO-8601 strings. Returns null for absent / invalid input.
|
||||
*
|
||||
* Numeric heuristic: values > 1e12 are treated as ms (2001+), values <= 1e12
|
||||
* are treated as seconds. `1e12 ms` ≈ Sept 2001, so any real ms timestamp
|
||||
* lands safely on the "ms" side.
|
||||
*/
|
||||
export function parseEventTimestamp(event: EventLike): number | null {
|
||||
const d = event.data;
|
||||
if (!d || typeof d !== 'object') return null;
|
||||
const raw =
|
||||
(d.timestamp as string | number | undefined) ??
|
||||
(d.at as string | number | undefined) ??
|
||||
(d.occurred_at as string | number | undefined);
|
||||
if (raw === undefined || raw === null) return null;
|
||||
if (typeof raw === 'number') {
|
||||
if (!Number.isFinite(raw)) return null;
|
||||
return raw > 1e12 ? raw : raw * 1000;
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
const ms = Date.parse(raw);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
export const ACTIVITY_BUCKET_COUNT = 10;
|
||||
export const ACTIVITY_BUCKET_MS = 30_000;
|
||||
export const ACTIVITY_WINDOW_MS = ACTIVITY_BUCKET_COUNT * ACTIVITY_BUCKET_MS;
|
||||
|
||||
export interface ActivityBucket {
|
||||
count: number;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket event timestamps into 10 × 30s buckets for a 5-min trailing window.
|
||||
* Events with `type === 'Heartbeat'` are skipped (noise). Events whose
|
||||
* timestamp is out of window (clock skew / pre-history) are dropped.
|
||||
*
|
||||
* Returned `ratio` is `count / max(1, maxBucketCount)` — so a sparkline with
|
||||
* zero events has all-zero ratios (no division by zero) and a sparkline with
|
||||
* a single spike peaks at 1.0.
|
||||
*/
|
||||
export function bucketizeActivity(
|
||||
events: EventLike[],
|
||||
nowMs: number,
|
||||
): ActivityBucket[] {
|
||||
const start = nowMs - ACTIVITY_WINDOW_MS;
|
||||
const counts = new Array<number>(ACTIVITY_BUCKET_COUNT).fill(0);
|
||||
for (const e of events) {
|
||||
if (e.type === 'Heartbeat') continue;
|
||||
const t = parseEventTimestamp(e);
|
||||
if (t === null || t < start || t > nowMs) continue;
|
||||
const idx = Math.min(
|
||||
ACTIVITY_BUCKET_COUNT - 1,
|
||||
Math.floor((t - start) / ACTIVITY_BUCKET_MS),
|
||||
);
|
||||
counts[idx] += 1;
|
||||
}
|
||||
const max = Math.max(1, ...counts);
|
||||
return counts.map((count) => ({ count, ratio: count / max }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent DreamCompleted within 24h of `nowMs`.
|
||||
* Feed is assumed newest-first — we return the FIRST match.
|
||||
* Unparseable timestamps fall back to `nowMs` (matches component behavior).
|
||||
*/
|
||||
export function findRecentDream(
|
||||
events: EventLike[],
|
||||
nowMs: number,
|
||||
): EventLike | null {
|
||||
const dayAgo = nowMs - 24 * 60 * 60 * 1000;
|
||||
for (const e of events) {
|
||||
if (e.type !== 'DreamCompleted') continue;
|
||||
const t = parseEventTimestamp(e) ?? nowMs;
|
||||
if (t >= dayAgo) return e;
|
||||
return null; // newest-first: older ones definitely won't match
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `insights_generated` / `insightsGenerated` from a DreamCompleted
|
||||
* event payload. Returns null if missing or non-numeric.
|
||||
*/
|
||||
export function dreamInsightsCount(event: EventLike | null): number | null {
|
||||
if (!event || !event.data) return null;
|
||||
const d = event.data;
|
||||
const raw =
|
||||
typeof d.insights_generated === 'number'
|
||||
? d.insights_generated
|
||||
: typeof d.insightsGenerated === 'number'
|
||||
? d.insightsGenerated
|
||||
: null;
|
||||
return raw !== null && Number.isFinite(raw) ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Dream is in flight if the newest DreamStarted is within 5 min of `nowMs`
|
||||
* AND there is no DreamCompleted with a timestamp >= that DreamStarted.
|
||||
*
|
||||
* Feed is assumed newest-first. We scan once, grabbing the first Started and
|
||||
* first Completed, then compare — matching the component's derived block.
|
||||
*/
|
||||
export function isDreaming(events: EventLike[], nowMs: number): boolean {
|
||||
let started: EventLike | null = null;
|
||||
let completed: EventLike | null = null;
|
||||
for (const e of events) {
|
||||
if (!started && e.type === 'DreamStarted') started = e;
|
||||
if (!completed && e.type === 'DreamCompleted') completed = e;
|
||||
if (started && completed) break;
|
||||
}
|
||||
if (!started) return false;
|
||||
const startedAt = parseEventTimestamp(started) ?? nowMs;
|
||||
const fiveMinAgo = nowMs - 5 * 60 * 1000;
|
||||
if (startedAt < fiveMinAgo) return false;
|
||||
if (!completed) return true;
|
||||
const completedAt = parseEventTimestamp(completed) ?? nowMs;
|
||||
return completedAt < startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an "ago" duration compactly. Pure and deterministic.
|
||||
* 0-59s → "Ns ago", 60-3599s → "Nm ago", <24h → "Nh ago", else "Nd ago".
|
||||
* Negative input is clamped to 0.
|
||||
*/
|
||||
export function formatAgo(ms: number): string {
|
||||
const clamped = Math.max(0, ms);
|
||||
const s = Math.floor(clamped / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any MemorySuppressed event lies within `thresholdMs` of `nowMs`.
|
||||
* Feed assumed newest-first — break as soon as we encounter one OUTSIDE
|
||||
* the window (all older ones are definitely older). Unparseable timestamps
|
||||
* fall back to `nowMs` so the flash fires — matches component behavior.
|
||||
*/
|
||||
export function hasRecentSuppression(
|
||||
events: EventLike[],
|
||||
nowMs: number,
|
||||
thresholdMs: number = 10_000,
|
||||
): boolean {
|
||||
const cutoff = nowMs - thresholdMs;
|
||||
for (const e of events) {
|
||||
if (e.type !== 'MemorySuppressed') continue;
|
||||
const t = parseEventTimestamp(e) ?? nowMs;
|
||||
if (t >= cutoff) return true;
|
||||
return false; // newest-first: older ones definitely won't match
|
||||
}
|
||||
return false;
|
||||
}
|
||||
210
apps/dashboard/src/lib/components/contradiction-helpers.ts
Normal file
210
apps/dashboard/src/lib/components/contradiction-helpers.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* contradiction-helpers — Pure logic for the Contradiction Constellation UI.
|
||||
*
|
||||
* Extracted from ContradictionArcs.svelte + contradictions/+page.svelte so
|
||||
* the math and classification live in one place and can be tested in the
|
||||
* vitest `node` environment without jsdom / Svelte harnessing.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - Severity thresholds are STRICTLY exclusive: similarity > 0.7 → strong,
|
||||
* similarity > 0.5 → moderate, else → mild. The boundary values 0.5 and
|
||||
* 0.7 therefore fall into the LOWER band on purpose (so a similarity of
|
||||
* exactly 0.7 is 'moderate', not 'strong').
|
||||
* - Node type palette has 8 known types; anything else — including
|
||||
* `undefined`, `null`, empty string, or a typo — falls back to violet
|
||||
* (#8b5cf6), matching the `concept` fallback tone used elsewhere.
|
||||
* - Pair opacity is a trinary rule: no focus → 1, focused match → 1,
|
||||
* focused non-match → 0.12. `null` and `undefined` both mean "no focus".
|
||||
* - Trust is defined on [0,1]; `nodeRadius` clamps out-of-range values so
|
||||
* a negative trust can't produce a sub-zero radius and a >1 trust can't
|
||||
* balloon past the design maximum (14px).
|
||||
* - `uniqueMemoryCount` unions memory_a_id + memory_b_id across the whole
|
||||
* pair list; duplicated pairs do not double-count.
|
||||
*/
|
||||
|
||||
/** Shape used by the constellation. Mirrors ContradictionArcs.Contradiction. */
|
||||
export interface ContradictionLike {
|
||||
memory_a_id: string;
|
||||
memory_b_id: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Severity — similarity → colour + label.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SeverityLabel = 'strong' | 'moderate' | 'mild';
|
||||
|
||||
/** Strong threshold. Similarity STRICTLY above this is red. */
|
||||
export const SEVERITY_STRONG_THRESHOLD = 0.7;
|
||||
/** Moderate threshold. Similarity STRICTLY above this (and <= 0.7) is amber. */
|
||||
export const SEVERITY_MODERATE_THRESHOLD = 0.5;
|
||||
|
||||
export const SEVERITY_STRONG_COLOR = '#ef4444';
|
||||
export const SEVERITY_MODERATE_COLOR = '#f59e0b';
|
||||
export const SEVERITY_MILD_COLOR = '#fde047';
|
||||
|
||||
/**
|
||||
* Severity colour by similarity. Boundaries at 0.5 and 0.7 fall into the
|
||||
* LOWER band (strictly-greater-than comparison).
|
||||
*
|
||||
* sim > 0.7 → '#ef4444' (strong / red)
|
||||
* sim > 0.5 → '#f59e0b' (moderate / amber)
|
||||
* otherwise → '#fde047' (mild / yellow)
|
||||
*/
|
||||
export function severityColor(sim: number): string {
|
||||
if (sim > SEVERITY_STRONG_THRESHOLD) return SEVERITY_STRONG_COLOR;
|
||||
if (sim > SEVERITY_MODERATE_THRESHOLD) return SEVERITY_MODERATE_COLOR;
|
||||
return SEVERITY_MILD_COLOR;
|
||||
}
|
||||
|
||||
/** Severity label by similarity. Same thresholds as severityColor. */
|
||||
export function severityLabel(sim: number): SeverityLabel {
|
||||
if (sim > SEVERITY_STRONG_THRESHOLD) return 'strong';
|
||||
if (sim > SEVERITY_MODERATE_THRESHOLD) return 'moderate';
|
||||
return 'mild';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node type palette.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fallback colour used when a memory's node_type is missing or unknown. */
|
||||
export const NODE_COLOR_FALLBACK = '#8b5cf6';
|
||||
|
||||
/** Canonical palette for the 8 known node types. */
|
||||
export const NODE_COLORS: Record<string, string> = {
|
||||
fact: '#3b82f6',
|
||||
concept: '#8b5cf6',
|
||||
event: '#f59e0b',
|
||||
person: '#10b981',
|
||||
place: '#06b6d4',
|
||||
note: '#6b7280',
|
||||
pattern: '#ec4899',
|
||||
decision: '#ef4444',
|
||||
};
|
||||
|
||||
/** Canonical list of known types (stable order — matches palette object). */
|
||||
export const KNOWN_NODE_TYPES = Object.freeze([
|
||||
'fact',
|
||||
'concept',
|
||||
'event',
|
||||
'person',
|
||||
'place',
|
||||
'note',
|
||||
'pattern',
|
||||
'decision',
|
||||
]) as readonly string[];
|
||||
|
||||
/**
|
||||
* Map a (possibly undefined) node_type to a colour. Unknown / missing /
|
||||
* empty / null strings fall back to violet (#8b5cf6).
|
||||
*/
|
||||
export function nodeColor(t?: string | null): string {
|
||||
if (!t) return NODE_COLOR_FALLBACK;
|
||||
return NODE_COLORS[t] ?? NODE_COLOR_FALLBACK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust → node radius.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimum circle radius at trust=0. */
|
||||
export const NODE_RADIUS_MIN = 5;
|
||||
/** Additional radius at trust=1. `r = 5 + trust * 9`, so r ∈ [5, 14]. */
|
||||
export const NODE_RADIUS_RANGE = 9;
|
||||
|
||||
/**
|
||||
* Clamp `trust` to [0,1] before mapping to a radius so a bad FSRS value
|
||||
* can't produce a sub-zero or oversize node. Non-finite values collapse
|
||||
* to 0 (smallest radius — visually suppresses suspicious data).
|
||||
*/
|
||||
export function nodeRadius(trust: number): number {
|
||||
if (!Number.isFinite(trust)) return NODE_RADIUS_MIN;
|
||||
const t = trust < 0 ? 0 : trust > 1 ? 1 : trust;
|
||||
return NODE_RADIUS_MIN + t * NODE_RADIUS_RANGE;
|
||||
}
|
||||
|
||||
/** Clamp trust to [0,1]. NaN/Infinity/undefined → 0. */
|
||||
export function clampTrust(trust: number | null | undefined): number {
|
||||
if (trust === null || trust === undefined || !Number.isFinite(trust)) return 0;
|
||||
if (trust < 0) return 0;
|
||||
if (trust > 1) return 1;
|
||||
return trust;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Focus / pair opacity.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Opacity applied to a non-focused pair when any pair is focused. */
|
||||
export const UNFOCUSED_OPACITY = 0.12;
|
||||
|
||||
/**
|
||||
* Opacity for a pair given the current focus state.
|
||||
*
|
||||
* focus = null/undefined → 1 (nothing dimmed)
|
||||
* focus === pairIndex → 1 (the focused pair is fully lit)
|
||||
* focus !== pairIndex → 0.12 (dimmed)
|
||||
*
|
||||
* A focus index that doesn't match any rendered pair simply dims everything.
|
||||
* That's the intended "silent no-op" for a stale focusedPairIndex.
|
||||
*/
|
||||
export function pairOpacity(pairIndex: number, focusedPairIndex: number | null | undefined): number {
|
||||
if (focusedPairIndex === null || focusedPairIndex === undefined) return 1;
|
||||
return focusedPairIndex === pairIndex ? 1 : UNFOCUSED_OPACITY;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text truncation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Truncate a string to `max` characters with an ellipsis at the end.
|
||||
* Shorter-or-equal strings return unchanged. Empty strings return unchanged.
|
||||
* Non-string inputs collapse to '' rather than crashing.
|
||||
*
|
||||
* The ellipsis counts toward the length budget, so the cut-off content is
|
||||
* `max - 1` characters, matching the component's inline truncate() helper.
|
||||
*/
|
||||
export function truncate(s: string | null | undefined, max = 60): string {
|
||||
if (s === null || s === undefined) return '';
|
||||
if (typeof s !== 'string') return '';
|
||||
if (max <= 0) return '';
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Count unique memory IDs across a list of contradiction pairs. Each pair
|
||||
* contributes memory_a_id and memory_b_id. Duplicates (e.g. one memory that
|
||||
* appears in multiple conflicts) are counted once.
|
||||
*/
|
||||
export function uniqueMemoryCount(pairs: readonly ContradictionLike[]): number {
|
||||
if (!pairs || pairs.length === 0) return 0;
|
||||
const set = new Set<string>();
|
||||
for (const p of pairs) {
|
||||
if (p.memory_a_id) set.add(p.memory_a_id);
|
||||
if (p.memory_b_id) set.add(p.memory_b_id);
|
||||
}
|
||||
return set.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Average absolute trust delta across pairs. Returns 0 on empty input so
|
||||
* the UI can render `0.00` instead of `NaN`.
|
||||
*/
|
||||
export function avgTrustDelta(
|
||||
pairs: readonly { trust_a: number; trust_b: number }[],
|
||||
): number {
|
||||
if (!pairs || pairs.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const p of pairs) {
|
||||
sum += Math.abs((p.trust_a ?? 0) - (p.trust_b ?? 0));
|
||||
}
|
||||
return sum / pairs.length;
|
||||
}
|
||||
155
apps/dashboard/src/lib/components/dream-helpers.ts
Normal file
155
apps/dashboard/src/lib/components/dream-helpers.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* dream-helpers — Pure logic for Dream Cinema UI.
|
||||
*
|
||||
* Extracted so we can test it without jsdom / Svelte component harnessing.
|
||||
* The Vitest setup for this package runs in a Node environment; every helper
|
||||
* in this module is a pure function of its inputs, so it can be exercised
|
||||
* directly in `__tests__/*.test.ts` alongside the graph helpers.
|
||||
*/
|
||||
|
||||
/** Stage 1..5 of the 5-phase consolidation cycle. */
|
||||
export const STAGE_COUNT = 5 as const;
|
||||
|
||||
/** Display names for each stage index (1-indexed). */
|
||||
export const STAGE_NAMES = [
|
||||
'Replay',
|
||||
'Cross-reference',
|
||||
'Strengthen',
|
||||
'Prune',
|
||||
'Transfer',
|
||||
] as const;
|
||||
|
||||
export type StageIndex = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/**
|
||||
* Clamp an arbitrary integer to the valid 1..5 stage range. Accepts any
|
||||
* number (NaN, Infinity, negatives, floats) and always returns an integer
|
||||
* in [1,5]. NaN and non-finite values fall back to 1 — this matches the
|
||||
* "start at stage 1" behaviour on a fresh dream.
|
||||
*/
|
||||
export function clampStage(n: number): StageIndex {
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
const i = Math.floor(n);
|
||||
if (i < 1) return 1;
|
||||
if (i > STAGE_COUNT) return STAGE_COUNT;
|
||||
return i as StageIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the human-readable stage name for a (possibly invalid) stage number.
|
||||
* Uses `clampStage`, so out-of-range inputs return the nearest valid name.
|
||||
*/
|
||||
export function stageName(n: number): string {
|
||||
return STAGE_NAMES[clampStage(n) - 1];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Novelty classification — drives the gold-glow / muted styling on insight
|
||||
// cards. Thresholds are STRICTLY exclusive so `0.3` and `0.7` map to the
|
||||
// neutral band on purpose. See DreamInsightCard.svelte.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NoveltyBand = 'high' | 'neutral' | 'low';
|
||||
|
||||
/** Upper bound for the muted "low novelty" band. Values BELOW this are low. */
|
||||
export const LOW_NOVELTY_THRESHOLD = 0.3;
|
||||
/** Lower bound for the gold "high novelty" band. Values ABOVE this are high. */
|
||||
export const HIGH_NOVELTY_THRESHOLD = 0.7;
|
||||
|
||||
/**
|
||||
* Classify a novelty score into one of 3 visual bands.
|
||||
*
|
||||
* Thresholds are exclusive on both sides:
|
||||
* novelty > 0.7 → 'high' (gold glow)
|
||||
* novelty < 0.3 → 'low' (muted / desaturated)
|
||||
* otherwise → 'neutral'
|
||||
*
|
||||
* `null` / `undefined` / `NaN` collapse to 0 → 'low'.
|
||||
*/
|
||||
export function noveltyBand(novelty: number | null | undefined): NoveltyBand {
|
||||
const n = clamp01(novelty);
|
||||
if (n > HIGH_NOVELTY_THRESHOLD) return 'high';
|
||||
if (n < LOW_NOVELTY_THRESHOLD) return 'low';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
/** Clamp a value into [0,1]. `null`/`undefined`/`NaN` → 0. */
|
||||
export function clamp01(n: number | null | undefined): number {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return 0;
|
||||
if (n < 0) return 0;
|
||||
if (n > 1) return 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers — mirror what the page + card render. Keeping these
|
||||
// pure lets us test the exact output strings without rendering Svelte.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a millisecond duration as a human-readable string.
|
||||
* < 1000ms → "{n}ms" (e.g. "0ms", "500ms")
|
||||
* ≥ 1000ms → "{n.nn}s" (e.g. "1.50s", "15.00s")
|
||||
* Negative / NaN values collapse to "0ms".
|
||||
*/
|
||||
export function formatDurationMs(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
|
||||
return '0ms';
|
||||
}
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a 0..1 confidence as a whole-percent string ("0%", "50%", "100%").
|
||||
* Values outside [0,1] clamp first. Uses `Math.round` so 0.505 → "51%".
|
||||
*/
|
||||
export function formatConfidencePct(confidence: number | null | undefined): string {
|
||||
const c = clamp01(confidence);
|
||||
return `${Math.round(c * 100)}%`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source memory link formatting.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the href for a source memory link. We keep this behind a helper so
|
||||
* the route format is tested in one place. `base` corresponds to SvelteKit's
|
||||
* `$app/paths` base (may be ""). Invalid IDs still produce a URL — route
|
||||
* handling is the page's responsibility, not ours.
|
||||
*/
|
||||
export function sourceMemoryHref(id: string, base = ''): string {
|
||||
return `${base}/memories/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first N source memory IDs from an insight's `sourceMemories`
|
||||
* array, safely handling null / undefined / empty. Default N = 2, matching
|
||||
* the card's "first 2 links" behaviour.
|
||||
*/
|
||||
export function firstSourceIds(
|
||||
sources: readonly string[] | null | undefined,
|
||||
n = 2,
|
||||
): string[] {
|
||||
if (!sources || sources.length === 0) return [];
|
||||
return sources.slice(0, Math.max(0, n));
|
||||
}
|
||||
|
||||
/** Count of sources beyond the first N. Used for the "(+N)" suffix. */
|
||||
export function extraSourceCount(
|
||||
sources: readonly string[] | null | undefined,
|
||||
shown = 2,
|
||||
): number {
|
||||
if (!sources) return 0;
|
||||
return Math.max(0, sources.length - shown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a memory UUID for display on the chip. Matches the previous
|
||||
* inline `shortId` logic: first 8 chars, or the whole string if shorter.
|
||||
*/
|
||||
export function shortMemoryId(id: string): string {
|
||||
if (!id) return '';
|
||||
return id.length > 8 ? id.slice(0, 8) : id;
|
||||
}
|
||||
149
apps/dashboard/src/lib/components/duplicates-helpers.ts
Normal file
149
apps/dashboard/src/lib/components/duplicates-helpers.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Pure helpers for the Memory Hygiene / Duplicate Detection UI.
|
||||
*
|
||||
* Extracted from DuplicateCluster.svelte + duplicates/+page.svelte so the
|
||||
* logic can be unit tested in the vitest `node` environment without jsdom.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - `similarityBand`: fixed thresholds at 0.92 (near-identical) and 0.80
|
||||
* (strong). Boundary values MATCH the higher band (>= semantics).
|
||||
* - `pickWinner`: highest retention wins. Ties broken by earliest index
|
||||
* (stable). Returns `null` on empty input — callers must guard.
|
||||
* - `suggestedActionFor`: >= 0.92 → 'merge', < 0.85 → 'review'. The 0.85..0.92
|
||||
* corridor follows the upstream `suggestedAction` field from the MCP tool,
|
||||
* so we only override the obvious cases. Default for the corridor is
|
||||
* whatever the caller already had — this function returns null to signal
|
||||
* "caller decides."
|
||||
* - `filterByThreshold`: strict `>=` against the provided similarity.
|
||||
* - `clusterKey`: stable identity across re-fetches — sorted member ids
|
||||
* joined. Survives threshold changes that keep the same cluster members.
|
||||
*/
|
||||
|
||||
export type SimilarityBand = 'near-identical' | 'strong' | 'weak';
|
||||
export type SuggestedAction = 'merge' | 'review';
|
||||
|
||||
export interface ClusterMemoryLike {
|
||||
id: string;
|
||||
retention: number;
|
||||
tags?: string[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface ClusterLike<M extends ClusterMemoryLike = ClusterMemoryLike> {
|
||||
similarity: number;
|
||||
memories: M[];
|
||||
}
|
||||
|
||||
/** Color bands. Boundary at 0.92 → red. Boundary at 0.80 → amber. */
|
||||
export function similarityBand(similarity: number): SimilarityBand {
|
||||
if (similarity >= 0.92) return 'near-identical';
|
||||
if (similarity >= 0.8) return 'strong';
|
||||
return 'weak';
|
||||
}
|
||||
|
||||
export function similarityBandColor(similarity: number): string {
|
||||
const band = similarityBand(similarity);
|
||||
if (band === 'near-identical') return 'var(--color-decay)';
|
||||
if (band === 'strong') return 'var(--color-warning)';
|
||||
return '#fde047'; // yellow-300 — distinct from amber warning
|
||||
}
|
||||
|
||||
export function similarityBandLabel(similarity: number): string {
|
||||
const band = similarityBand(similarity);
|
||||
if (band === 'near-identical') return 'Near-identical';
|
||||
if (band === 'strong') return 'Strong match';
|
||||
return 'Weak match';
|
||||
}
|
||||
|
||||
/** Retention color dot. Matches the traffic-light scheme. */
|
||||
export function retentionColor(retention: number): string {
|
||||
if (retention > 0.7) return '#10b981';
|
||||
if (retention > 0.4) return '#f59e0b';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the highest-retention memory. Stable tie-break: earliest wins.
|
||||
* Returns `null` if the cluster is empty. Treats non-finite retention as
|
||||
* -Infinity so a `retention=NaN` row never claims the throne.
|
||||
*/
|
||||
export function pickWinner<M extends ClusterMemoryLike>(memories: M[]): M | null {
|
||||
if (!memories || memories.length === 0) return null;
|
||||
let best = memories[0];
|
||||
let bestScore = Number.isFinite(best.retention) ? best.retention : -Infinity;
|
||||
for (let i = 1; i < memories.length; i++) {
|
||||
const m = memories[i];
|
||||
const s = Number.isFinite(m.retention) ? m.retention : -Infinity;
|
||||
if (s > bestScore) {
|
||||
best = m;
|
||||
bestScore = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggested action inference. Returns null in the ambiguous 0.85..0.92 band
|
||||
* so callers can honor an upstream suggestion from the backend.
|
||||
*/
|
||||
export function suggestedActionFor(similarity: number): SuggestedAction | null {
|
||||
if (similarity >= 0.92) return 'merge';
|
||||
if (similarity < 0.85) return 'review';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter clusters by the >= threshold contract. Separate pure function so the
|
||||
* mock fetch and any future real fetch both get the same semantics.
|
||||
*/
|
||||
export function filterByThreshold<C extends ClusterLike>(clusters: C[], threshold: number): C[] {
|
||||
return clusters.filter((c) => c.similarity >= threshold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity across re-fetches. Uses sorted member ids, so a cluster
|
||||
* that loses/gains a member gets a new key (intentional — the cluster has
|
||||
* changed). If you dismissed cluster [A,B,C] at 0.80 and refetch at 0.70
|
||||
* and it now contains [A,B,C,D], it reappears — correct behaviour: a new
|
||||
* member deserves fresh attention.
|
||||
*/
|
||||
export function clusterKey<M extends ClusterMemoryLike>(memories: M[]): string {
|
||||
return memories
|
||||
.map((m) => m.id)
|
||||
.slice()
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe content preview — trims, collapses whitespace, truncates at 80 chars
|
||||
* with an ellipsis. Null-safe.
|
||||
*/
|
||||
export function previewContent(content: string | null | undefined, max: number = 80): string {
|
||||
if (!content) return '';
|
||||
const trimmed = content.trim().replace(/\s+/g, ' ');
|
||||
return trimmed.length <= max ? trimmed : trimmed.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an ISO date string safely — returns an empty string for missing,
|
||||
* non-string, or invalid input so the DOM shows nothing rather than
|
||||
* "Invalid Date".
|
||||
*/
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso || typeof iso !== 'string') return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** Safe tag slice — tolerates undefined or non-array inputs. */
|
||||
export function safeTags(tags: string[] | null | undefined, limit: number = 4): string[] {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags.slice(0, limit);
|
||||
}
|
||||
226
apps/dashboard/src/lib/components/importance-helpers.ts
Normal file
226
apps/dashboard/src/lib/components/importance-helpers.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* importance-helpers — Pure logic for the Importance Radar UI
|
||||
* (importance/+page.svelte + ImportanceRadar.svelte).
|
||||
*
|
||||
* Extracted so the radar geometry and importance-proxy maths can be unit-
|
||||
* tested in the vitest `node` environment without jsdom or Svelte harness.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - Backend channel weights (novelty 0.25, arousal 0.30, reward 0.25,
|
||||
* attention 0.20) sum to 1.0 and mirror ImportanceSignals in vestige-core.
|
||||
* - `clamp01` folds NaN/Infinity/nullish → 0 and clips [0,1].
|
||||
* - `radarVertices` emits 4 SVG polygon points in the fixed axis order
|
||||
* Novelty (top) → Arousal (right) → Reward (bottom) → Attention (left).
|
||||
* A zero value places the vertex at centre; a one value places it at the
|
||||
* unit-ring edge.
|
||||
* - `importanceProxy` is the SAME formula the page uses to rank the weekly
|
||||
* list: retentionStrength × log1p(reviews + 1) / sqrt(max(1, ageDays)).
|
||||
* Age is clamped to 1 so a freshly-created memory never divides by zero.
|
||||
* - `sizePreset` maps 'sm'|'md'|'lg' to 80|180|320 and defaults to 'md' for
|
||||
* any unknown size key — matching the component's default prop.
|
||||
*/
|
||||
|
||||
// -- Channel model ----------------------------------------------------------
|
||||
|
||||
export type ChannelKey = 'novelty' | 'arousal' | 'reward' | 'attention';
|
||||
|
||||
/** Weights applied server-side by ImportanceSignals. Must sum to 1.0. */
|
||||
export const CHANNEL_WEIGHTS: Readonly<Record<ChannelKey, number>> = {
|
||||
novelty: 0.25,
|
||||
arousal: 0.3,
|
||||
reward: 0.25,
|
||||
attention: 0.2,
|
||||
} as const;
|
||||
|
||||
export interface Channels {
|
||||
novelty: number;
|
||||
arousal: number;
|
||||
reward: number;
|
||||
attention: number;
|
||||
}
|
||||
|
||||
/** Clamp a value to [0,1]. Null / undefined / NaN / Infinity → 0. */
|
||||
export function clamp01(v: number | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Clamp every channel to [0,1]. Safe for partial / malformed inputs. */
|
||||
export function clampChannels(ch: Partial<Channels> | null | undefined): Channels {
|
||||
return {
|
||||
novelty: clamp01(ch?.novelty),
|
||||
arousal: clamp01(ch?.arousal),
|
||||
reward: clamp01(ch?.reward),
|
||||
attention: clamp01(ch?.attention),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite importance score — matches backend ImportanceSignals.
|
||||
*
|
||||
* composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention
|
||||
*
|
||||
* Every input is clamped first so out-of-range channels never puncture the
|
||||
* 0..1 composite range. The return value is guaranteed to be in [0,1].
|
||||
*/
|
||||
export function compositeScore(ch: Partial<Channels> | null | undefined): number {
|
||||
const c = clampChannels(ch);
|
||||
return (
|
||||
c.novelty * CHANNEL_WEIGHTS.novelty +
|
||||
c.arousal * CHANNEL_WEIGHTS.arousal +
|
||||
c.reward * CHANNEL_WEIGHTS.reward +
|
||||
c.attention * CHANNEL_WEIGHTS.attention
|
||||
);
|
||||
}
|
||||
|
||||
// -- Size preset ------------------------------------------------------------
|
||||
|
||||
export type RadarSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export const SIZE_PX: Readonly<Record<RadarSize, number>> = {
|
||||
sm: 80,
|
||||
md: 180,
|
||||
lg: 320,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve a size preset key to its px value. Unknown / missing keys fall
|
||||
* back to 'md' (180), matching the component's default prop. `sm` loses
|
||||
* axis labels in the renderer but that's rendering concern, not ours.
|
||||
*/
|
||||
export function sizePreset(size: RadarSize | string | undefined): number {
|
||||
if (size && (size === 'sm' || size === 'md' || size === 'lg')) {
|
||||
return SIZE_PX[size];
|
||||
}
|
||||
return SIZE_PX.md;
|
||||
}
|
||||
|
||||
// -- Geometry ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fixed axis order. Angles use SVG conventions (y grows downward):
|
||||
* Novelty → angle -π/2 (top)
|
||||
* Arousal → angle 0 (right)
|
||||
* Reward → angle π/2 (bottom)
|
||||
* Attention → angle π (left)
|
||||
*/
|
||||
export const AXIS_ORDER: ReadonlyArray<{ key: ChannelKey; angle: number }> = [
|
||||
{ key: 'novelty', angle: -Math.PI / 2 },
|
||||
{ key: 'arousal', angle: 0 },
|
||||
{ key: 'reward', angle: Math.PI / 2 },
|
||||
{ key: 'attention', angle: Math.PI },
|
||||
] as const;
|
||||
|
||||
export interface RadarPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective drawable radius inside the SVG box. This mirrors the
|
||||
* component's padding logic:
|
||||
* sm → padding 4 (edge-to-edge, no labels)
|
||||
* md → padding 28
|
||||
* lg → padding 44
|
||||
* Radius = size/2 − padding, floored at 0 (a radius below zero would draw
|
||||
* an inverted polygon — defensive guard).
|
||||
*/
|
||||
export function radarRadius(size: RadarSize | string | undefined): number {
|
||||
const px = sizePreset(size);
|
||||
let padding: number;
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
padding = 44;
|
||||
break;
|
||||
case 'sm':
|
||||
padding = 4;
|
||||
break;
|
||||
default:
|
||||
padding = 28;
|
||||
}
|
||||
return Math.max(0, px / 2 - padding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the 4 SVG polygon vertices for a set of channel values at a given
|
||||
* radar size. Values are clamped to [0,1] first so out-of-range inputs can't
|
||||
* escape the radar bounds.
|
||||
*
|
||||
* Ordering is FIXED and matches AXIS_ORDER: [novelty, arousal, reward, attention].
|
||||
* A zero value places the vertex at the centre (cx, cy); a one value places
|
||||
* it at the unit-ring edge.
|
||||
*/
|
||||
export function radarVertices(
|
||||
ch: Partial<Channels> | null | undefined,
|
||||
size: RadarSize | string | undefined = 'md',
|
||||
): RadarPoint[] {
|
||||
const px = sizePreset(size);
|
||||
const r = radarRadius(size);
|
||||
const cx = px / 2;
|
||||
const cy = px / 2;
|
||||
const values = clampChannels(ch);
|
||||
return AXIS_ORDER.map(({ key, angle }) => {
|
||||
const v = values[key];
|
||||
return {
|
||||
x: cx + Math.cos(angle) * v * r,
|
||||
y: cy + Math.sin(angle) * v * r,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Serialise vertices to an SVG "M…L…L…L… Z" path, 2-decimal precision. */
|
||||
export function verticesToPath(points: RadarPoint[]): string {
|
||||
if (points.length === 0) return '';
|
||||
return (
|
||||
points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(2)},${p.y.toFixed(2)}`)
|
||||
.join(' ') + ' Z'
|
||||
);
|
||||
}
|
||||
|
||||
// -- Trending-memory proxy --------------------------------------------------
|
||||
|
||||
export interface ProxyMemoryLike {
|
||||
retentionStrength: number;
|
||||
reviewCount?: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy score for the "Top Important Memories This Week" ranking. Exact
|
||||
* formula from importance/+page.svelte:
|
||||
*
|
||||
* ageDays = max(1, (now - createdAt) / 86_400_000)
|
||||
* reviews = reviewCount ?? 0
|
||||
* recencyBoost = 1 / sqrt(ageDays)
|
||||
* proxy = retentionStrength × log1p(reviews + 1) × recencyBoost
|
||||
*
|
||||
* Edge cases:
|
||||
* - createdAt is the current instant → ageDays clamps to 1 (no div-by-0)
|
||||
* - createdAt is in the future → negative age also clamps to 1
|
||||
* - reviewCount null/undefined → treated as 0
|
||||
* - non-finite retentionStrength → returns 0 defensively
|
||||
*
|
||||
* `now` is injectable for deterministic tests. Defaults to `Date.now()`.
|
||||
*/
|
||||
export function importanceProxy(m: ProxyMemoryLike, now: number = Date.now()): number {
|
||||
if (!m || !Number.isFinite(m.retentionStrength)) return 0;
|
||||
const created = new Date(m.createdAt).getTime();
|
||||
if (!Number.isFinite(created)) return 0;
|
||||
const ageDays = Math.max(1, (now - created) / 86_400_000);
|
||||
const reviews = m.reviewCount ?? 0;
|
||||
const recencyBoost = 1 / Math.sqrt(ageDays);
|
||||
return m.retentionStrength * Math.log1p(reviews + 1) * recencyBoost;
|
||||
}
|
||||
|
||||
/** Sort memories by the proxy, descending. Stable via `.sort` on a copy. */
|
||||
export function rankByProxy<M extends ProxyMemoryLike>(
|
||||
memories: readonly M[],
|
||||
now: number = Date.now(),
|
||||
): M[] {
|
||||
return memories.slice().sort((a, b) => importanceProxy(b, now) - importanceProxy(a, now));
|
||||
}
|
||||
178
apps/dashboard/src/lib/components/patterns-helpers.ts
Normal file
178
apps/dashboard/src/lib/components/patterns-helpers.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* patterns-helpers — Pure logic for the Cross-Project Intelligence UI
|
||||
* (patterns/+page.svelte + PatternTransferHeatmap.svelte).
|
||||
*
|
||||
* Extracted so the behaviour can be unit-tested in the vitest `node`
|
||||
* environment without jsdom or Svelte component harnessing. Every helper
|
||||
* in this module is a pure function of its inputs.
|
||||
*
|
||||
* Contracts
|
||||
* ---------
|
||||
* - `cellIntensity`: returns opacity in [0,1] from count / max. count=0 → 0,
|
||||
* count>=max → 1. `max<=0` collapses to 0 (avoids div-by-zero — the
|
||||
* component uses `max || 1` for the same reason).
|
||||
* - `filterByCategory`: 'All' passes every pattern through. An unknown
|
||||
* category string (not one of the 6 + 'All') returns an empty array —
|
||||
* there is no hidden alias fallback.
|
||||
* - `buildTransferMatrix`: directional. `matrix[origin][dest]` counts how
|
||||
* many patterns originated in `origin` and were transferred to `dest`.
|
||||
* `origin === dest` captures self-transfer (a project reusing its own
|
||||
* pattern — rare but real per the component's doc comment).
|
||||
*/
|
||||
|
||||
export const PATTERN_CATEGORIES = [
|
||||
'ErrorHandling',
|
||||
'AsyncConcurrency',
|
||||
'Testing',
|
||||
'Architecture',
|
||||
'Performance',
|
||||
'Security',
|
||||
] as const;
|
||||
|
||||
export type PatternCategory = (typeof PATTERN_CATEGORIES)[number];
|
||||
export type CategoryFilter = 'All' | PatternCategory;
|
||||
|
||||
export interface TransferPatternLike {
|
||||
name: string;
|
||||
category: PatternCategory;
|
||||
origin_project: string;
|
||||
transferred_to: string[];
|
||||
transfer_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a raw transfer count to a 0..1 opacity/intensity value against a
|
||||
* known max. Used by the heatmap cell colour ramp.
|
||||
*
|
||||
* count <= 0 → 0 (dead cell)
|
||||
* count >= max > 0 → 1 (hottest cell)
|
||||
* otherwise → count / max
|
||||
*
|
||||
* Non-finite / negative inputs collapse to 0. When `max <= 0` the result is
|
||||
* always 0 — the component's own guard (`maxCount || 1`) means this branch
|
||||
* is unreachable in practice, but defensive anyway.
|
||||
*/
|
||||
export function cellIntensity(count: number, max: number): number {
|
||||
if (!Number.isFinite(count) || count <= 0) return 0;
|
||||
if (!Number.isFinite(max) || max <= 0) return 0;
|
||||
if (count >= max) return 1;
|
||||
return count / max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a pattern list by the active category tab.
|
||||
* 'All' → full pass-through (same reference-equal array is
|
||||
* NOT guaranteed; callers must not rely on identity)
|
||||
* one of the 6 enums → strict equality on `category`
|
||||
* unknown string → empty array (no silent alias; caller bug)
|
||||
*/
|
||||
export function filterByCategory<P extends TransferPatternLike>(
|
||||
patterns: readonly P[],
|
||||
category: CategoryFilter | string,
|
||||
): P[] {
|
||||
if (category === 'All') return patterns.slice();
|
||||
if (!(PATTERN_CATEGORIES as readonly string[]).includes(category)) {
|
||||
return [];
|
||||
}
|
||||
return patterns.filter((p) => p.category === category);
|
||||
}
|
||||
|
||||
/** Cell in the directional N×N transfer matrix. */
|
||||
export interface TransferCell {
|
||||
count: number;
|
||||
topNames: string[];
|
||||
}
|
||||
|
||||
/** Dense row-major directional matrix: matrix[origin][destination]. */
|
||||
export type TransferMatrix = Record<string, Record<string, TransferCell>>;
|
||||
|
||||
/**
|
||||
* Build the directional transfer matrix from patterns + the known projects
|
||||
* axis. Mirrors `PatternTransferHeatmap.svelte`'s `$derived` logic.
|
||||
*
|
||||
* - Every (from, to) pair in `projects × projects` gets a zero cell.
|
||||
* - Each pattern P contributes `+1` to `matrix[P.origin][dest]` for every
|
||||
* `dest` in `P.transferred_to` that also appears in `projects`.
|
||||
* - Patterns whose origin isn't in `projects` are silently skipped — that
|
||||
* matches the component's `if (!m[from]) continue` guard.
|
||||
* - `topNames` holds up to 3 pattern names per cell in insertion order.
|
||||
*/
|
||||
export function buildTransferMatrix(
|
||||
projects: readonly string[],
|
||||
patterns: readonly TransferPatternLike[],
|
||||
topNameCap = 3,
|
||||
): TransferMatrix {
|
||||
const m: TransferMatrix = {};
|
||||
for (const from of projects) {
|
||||
m[from] = {};
|
||||
for (const to of projects) {
|
||||
m[from][to] = { count: 0, topNames: [] };
|
||||
}
|
||||
}
|
||||
for (const p of patterns) {
|
||||
const from = p.origin_project;
|
||||
if (!m[from]) continue;
|
||||
for (const to of p.transferred_to) {
|
||||
if (!m[from][to]) continue;
|
||||
m[from][to].count += 1;
|
||||
m[from][to].topNames.push(p.name);
|
||||
}
|
||||
}
|
||||
const cap = Math.max(0, topNameCap);
|
||||
for (const from of projects) {
|
||||
for (const to of projects) {
|
||||
m[from][to].topNames = m[from][to].topNames.slice(0, cap);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum single-cell transfer count across the matrix. Floors at 0 for an
|
||||
* empty matrix, which callers should treat as "scale by 1" to avoid a div-
|
||||
* by-zero in the colour ramp.
|
||||
*/
|
||||
export function matrixMaxCount(
|
||||
projects: readonly string[],
|
||||
matrix: TransferMatrix,
|
||||
): number {
|
||||
let max = 0;
|
||||
for (const from of projects) {
|
||||
const row = matrix[from];
|
||||
if (!row) continue;
|
||||
for (const to of projects) {
|
||||
const cell = row[to];
|
||||
if (cell && cell.count > max) max = cell.count;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a matrix into sorted-desc rows for the mobile fallback. Only
|
||||
* non-zero pairs are emitted, matching the component.
|
||||
*/
|
||||
export function flattenNonZero(
|
||||
projects: readonly string[],
|
||||
matrix: TransferMatrix,
|
||||
): Array<{ from: string; to: string; count: number; topNames: string[] }> {
|
||||
const rows: Array<{ from: string; to: string; count: number; topNames: string[] }> = [];
|
||||
for (const from of projects) {
|
||||
for (const to of projects) {
|
||||
const cell = matrix[from]?.[to];
|
||||
if (cell && cell.count > 0) {
|
||||
rows.push({ from, to, count: cell.count, topNames: cell.topNames });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate long project names for axis labels. Match the component's
|
||||
* `shortProject` behaviour: keep ≤12 chars, otherwise 11-char prefix + ellipsis.
|
||||
*/
|
||||
export function shortProjectName(name: string): string {
|
||||
if (!name) return '';
|
||||
return name.length > 12 ? name.slice(0, 11) + '…' : name;
|
||||
}
|
||||
229
apps/dashboard/src/lib/components/reasoning-helpers.ts
Normal file
229
apps/dashboard/src/lib/components/reasoning-helpers.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* reasoning-helpers — Pure logic for the Reasoning Theater UI.
|
||||
*
|
||||
* Extracted so we can test it without jsdom / Svelte component harnessing.
|
||||
* The Vitest setup for this package runs in a Node environment; every helper
|
||||
* in this module is a pure function of its inputs, so it can be exercised
|
||||
* directly in `__tests__/*.test.ts` alongside the graph helpers.
|
||||
*/
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Shared palette — keep in sync with Tailwind @theme values.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CONFIDENCE_EMERALD = '#10b981';
|
||||
export const CONFIDENCE_AMBER = '#f59e0b';
|
||||
export const CONFIDENCE_RED = '#ef4444';
|
||||
|
||||
/** Fallback colour when a node-type has no mapping. */
|
||||
export const DEFAULT_NODE_TYPE_COLOR = '#8B95A5';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Roles
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EvidenceRole = 'primary' | 'supporting' | 'contradicting' | 'superseded';
|
||||
|
||||
export interface RoleMeta {
|
||||
label: string;
|
||||
/** Tailwind / CSS colour token — see app.css. */
|
||||
accent: 'synapse' | 'recall' | 'decay' | 'muted';
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const ROLE_META: Record<EvidenceRole, RoleMeta> = {
|
||||
primary: { label: 'Primary', accent: 'synapse', icon: '◈' },
|
||||
supporting: { label: 'Supporting', accent: 'recall', icon: '◇' },
|
||||
contradicting: { label: 'Contradicting', accent: 'decay', icon: '⚠' },
|
||||
superseded: { label: 'Superseded', accent: 'muted', icon: '⊘' },
|
||||
};
|
||||
|
||||
/** Look up role metadata with a defensive fallback. */
|
||||
export function roleMetaFor(role: EvidenceRole | string): RoleMeta {
|
||||
return (ROLE_META as Record<string, RoleMeta>)[role] ?? ROLE_META.supporting;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Intent classification (deep_reference `intent` field)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type IntentKey =
|
||||
| 'FactCheck'
|
||||
| 'Timeline'
|
||||
| 'RootCause'
|
||||
| 'Comparison'
|
||||
| 'Synthesis';
|
||||
|
||||
export interface IntentHint {
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const INTENT_HINTS: Record<IntentKey, IntentHint> = {
|
||||
FactCheck: {
|
||||
label: 'FactCheck',
|
||||
icon: '◆',
|
||||
description: 'Direct verification of a single claim.',
|
||||
},
|
||||
Timeline: {
|
||||
label: 'Timeline',
|
||||
icon: '↗',
|
||||
description: 'Ordered evolution of a fact over time.',
|
||||
},
|
||||
RootCause: {
|
||||
label: 'RootCause',
|
||||
icon: '⚡',
|
||||
description: 'Why did this happen — causal chain.',
|
||||
},
|
||||
Comparison: {
|
||||
label: 'Comparison',
|
||||
icon: '⬡',
|
||||
description: 'Contrasting two or more options side-by-side.',
|
||||
},
|
||||
Synthesis: {
|
||||
label: 'Synthesis',
|
||||
icon: '❖',
|
||||
description: 'Cross-memory composition into a new insight.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Map an arbitrary intent string to a hint. Unknown intents degrade to
|
||||
* Synthesis, which is the most generic classification.
|
||||
*/
|
||||
export function intentHintFor(intent: string | undefined | null): IntentHint {
|
||||
if (!intent) return INTENT_HINTS.Synthesis;
|
||||
const key = intent as IntentKey;
|
||||
return INTENT_HINTS[key] ?? INTENT_HINTS.Synthesis;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Confidence bands
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Confidence colour band.
|
||||
*
|
||||
* > 75 → emerald (HIGH)
|
||||
* 40-75 → amber (MIXED)
|
||||
* < 40 → red (LOW)
|
||||
*
|
||||
* Boundaries: 75 is amber (strictly greater than 75 is emerald), 40 is amber
|
||||
* (>=40 is amber). Any non-finite input (NaN) is treated as lowest confidence
|
||||
* and returns red.
|
||||
*/
|
||||
export function confidenceColor(c: number): string {
|
||||
if (!Number.isFinite(c)) return CONFIDENCE_RED;
|
||||
if (c > 75) return CONFIDENCE_EMERALD;
|
||||
if (c >= 40) return CONFIDENCE_AMBER;
|
||||
return CONFIDENCE_RED;
|
||||
}
|
||||
|
||||
/** Human-readable label for a confidence score (0-100). */
|
||||
export function confidenceLabel(c: number): string {
|
||||
if (!Number.isFinite(c)) return 'LOW CONFIDENCE';
|
||||
if (c > 75) return 'HIGH CONFIDENCE';
|
||||
if (c >= 40) return 'MIXED SIGNAL';
|
||||
return 'LOW CONFIDENCE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 0-1 trust score to the same confidence band.
|
||||
*
|
||||
* Thresholds: >0.75 emerald, 0.40-0.75 amber, <0.40 red.
|
||||
* Matches `confidenceColor` semantics so the trust bar on an evidence card
|
||||
* and the confidence meter on the page agree at the boundaries.
|
||||
*/
|
||||
export function trustColor(t: number): string {
|
||||
if (!Number.isFinite(t)) return CONFIDENCE_RED;
|
||||
return confidenceColor(t * 100);
|
||||
}
|
||||
|
||||
/** Clamp a trust score into the display range [0, 1]. */
|
||||
export function clampTrust(t: number): number {
|
||||
if (!Number.isFinite(t)) return 0;
|
||||
if (t < 0) return 0;
|
||||
if (t > 1) return 1;
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Trust as a 0-100 percentage suitable for width / label rendering. */
|
||||
export function trustPercent(t: number): number {
|
||||
return clampTrust(t) * 100;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Node-type colouring
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve a node-type colour with a soft-steel fallback. */
|
||||
export function nodeTypeColor(nodeType?: string | null): string {
|
||||
if (!nodeType) return DEFAULT_NODE_TYPE_COLOR;
|
||||
return NODE_TYPE_COLORS[nodeType] ?? DEFAULT_NODE_TYPE_COLOR;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Date formatting
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format an ISO date string for EvidenceCard display.
|
||||
*
|
||||
* Handles three failure modes that `new Date(str)` alone does not:
|
||||
* 1. Empty / null / undefined → returns '—'
|
||||
* 2. Unparseable string (NaN) → returns the original string unchanged
|
||||
* 3. Non-ISO but parseable → best-effort locale format
|
||||
*
|
||||
* The previous try/catch-only approach silently rendered the literal text
|
||||
* "Invalid Date" because `Date` never throws on bad input — it produces a
|
||||
* valid object whose getTime() is NaN.
|
||||
*/
|
||||
export function formatDate(
|
||||
iso: string | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
if (iso == null) return '—';
|
||||
if (typeof iso !== 'string' || iso.trim() === '') return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
try {
|
||||
return d.toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact month/day formatter for the evolution timeline. */
|
||||
export function formatShortDate(
|
||||
iso: string | null | undefined,
|
||||
locale?: string,
|
||||
): string {
|
||||
if (iso == null) return '—';
|
||||
if (typeof iso !== 'string' || iso.trim() === '') return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
try {
|
||||
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Short-id for #abcdef01 style display
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the first 8 characters of an id, or the full string if shorter.
|
||||
* Never throws on null/undefined — returns '' so the caller can render '#'.
|
||||
*/
|
||||
export function shortenId(id: string | null | undefined, length = 8): string {
|
||||
if (!id) return '';
|
||||
return id.length > length ? id.slice(0, length) : id;
|
||||
}
|
||||
161
apps/dashboard/src/lib/components/schedule-helpers.ts
Normal file
161
apps/dashboard/src/lib/components/schedule-helpers.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Pure helpers for the FSRS review schedule page + calendar.
|
||||
*
|
||||
* Extracted from `FSRSCalendar.svelte` and `routes/(app)/schedule/+page.svelte`
|
||||
* so that bucket / grid / urgency / retention math can be tested in isolation
|
||||
* (vitest `environment: node`, no jsdom required).
|
||||
*/
|
||||
import type { Memory } from '$types';
|
||||
|
||||
export const MS_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Zero-out the time component of a date, returning a NEW Date at local
|
||||
* midnight. Used for day-granular bucketing so comparisons are stable across
|
||||
* any hour-of-day the user loads the page.
|
||||
*/
|
||||
export function startOfDay(d: Date | string): Date {
|
||||
const x = typeof d === 'string' ? new Date(d) : new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed integer count of whole local days between two timestamps, normalized
|
||||
* to midnight. Positive means `a` is in the future relative to `b`, negative
|
||||
* means `a` is in the past. Zero means same calendar day.
|
||||
*/
|
||||
export function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((startOfDay(a).getTime() - startOfDay(b).getTime()) / MS_DAY);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD in LOCAL time (not UTC) so calendar cells align with user's day. */
|
||||
export function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Urgency bucket for a review date relative to "now". Used by the right-hand
|
||||
* list and the calendar cell color. Day-granular (not hour-granular) so a
|
||||
* memory due at 23:59 today does not suddenly become "in 1d" at 00:01
|
||||
* tomorrow UX-wise — it becomes "overdue" cleanly at midnight.
|
||||
*
|
||||
* - `none` — no valid `nextReviewAt`
|
||||
* - `overdue` — due date's calendar day is strictly before today
|
||||
* - `today` — due date's calendar day is today
|
||||
* - `week` — due in 1..=7 whole days
|
||||
* - `future` — due in 8+ whole days
|
||||
*/
|
||||
export type Urgency = 'none' | 'overdue' | 'today' | 'week' | 'future';
|
||||
|
||||
export function classifyUrgency(now: Date, nextReviewAt: string | null | undefined): Urgency {
|
||||
if (!nextReviewAt) return 'none';
|
||||
const d = new Date(nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) return 'none';
|
||||
const delta = daysBetween(d, now);
|
||||
if (delta < 0) return 'overdue';
|
||||
if (delta === 0) return 'today';
|
||||
if (delta <= 7) return 'week';
|
||||
return 'future';
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed whole-day count from today → due date. Negative means overdue by
|
||||
* |n| days; zero means today; positive means n days out. Returns `null`
|
||||
* if the ISO string is invalid or missing.
|
||||
*/
|
||||
export function daysUntilReview(now: Date, nextReviewAt: string | null | undefined): number | null {
|
||||
if (!nextReviewAt) return null;
|
||||
const d = new Date(nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return daysBetween(d, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* The [start, end) window for the week containing `d`, starting Sunday at
|
||||
* local midnight. End is the following Sunday at local midnight — exclusive.
|
||||
*/
|
||||
export function weekBucketRange(d: Date): { start: Date; end: Date } {
|
||||
const start = startOfDay(d);
|
||||
start.setDate(start.getDate() - start.getDay()); // back to Sunday
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean retention strength across a list of memories. Returns 0 for an empty
|
||||
* list (never NaN) so the sidebar can safely render "0%".
|
||||
*/
|
||||
export function avgRetention(memories: Memory[]): number {
|
||||
if (memories.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const m of memories) sum += m.retentionStrength ?? 0;
|
||||
return sum / memories.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a day-index `i` into a 42-cell calendar grid (6 rows × 7 cols), return
|
||||
* its row / column. The grid is laid out row-major: cell 0 = row 0 col 0,
|
||||
* cell 7 = row 1 col 0, cell 41 = row 5 col 6. Returns `null` for indices
|
||||
* outside `[0, 42)`.
|
||||
*/
|
||||
export function gridCellPosition(i: number): { row: number; col: number } | null {
|
||||
if (!Number.isInteger(i) || i < 0 || i >= 42) return null;
|
||||
return { row: Math.floor(i / 7), col: i % 7 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse: given a calendar anchor date (today), compute the Sunday
|
||||
* at-or-before `anchor - 14 days` that seeds row 0 of the 6×7 grid. Pure,
|
||||
* deterministic, local-time.
|
||||
*/
|
||||
export function gridStartForAnchor(anchor: Date): Date {
|
||||
const base = startOfDay(anchor);
|
||||
base.setDate(base.getDate() - 14);
|
||||
base.setDate(base.getDate() - base.getDay()); // back to Sunday
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket counts used by the sidebar stats block. Day-granular, consistent
|
||||
* with `classifyUrgency`.
|
||||
*/
|
||||
export interface ScheduleStats {
|
||||
overdue: number;
|
||||
dueToday: number;
|
||||
dueThisWeek: number;
|
||||
dueThisMonth: number;
|
||||
avgDays: number;
|
||||
}
|
||||
|
||||
export function computeScheduleStats(now: Date, scheduled: Memory[]): ScheduleStats {
|
||||
let overdue = 0;
|
||||
let dueToday = 0;
|
||||
let dueThisWeek = 0;
|
||||
let dueThisMonth = 0;
|
||||
let sumDays = 0;
|
||||
let futureCount = 0;
|
||||
const today = startOfDay(now);
|
||||
for (const m of scheduled) {
|
||||
if (!m.nextReviewAt) continue;
|
||||
const d = new Date(m.nextReviewAt);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
const delta = daysBetween(d, now);
|
||||
if (delta < 0) overdue++;
|
||||
if (delta <= 0) dueToday++;
|
||||
if (delta <= 7) dueThisWeek++;
|
||||
if (delta <= 30) dueThisMonth++;
|
||||
if (delta >= 0) {
|
||||
// Use hour-resolution days-until for the average so "due in 2.3 days"
|
||||
// is informative even when bucketing is day-granular elsewhere.
|
||||
sumDays += (d.getTime() - today.getTime()) / MS_DAY;
|
||||
futureCount++;
|
||||
}
|
||||
}
|
||||
const avgDays = futureCount > 0 ? sumDays / futureCount : 0;
|
||||
return { overdue, dueToday, dueThisWeek, dueThisMonth, avgDays };
|
||||
}
|
||||
496
apps/dashboard/src/lib/stores/__tests__/theme.test.ts
Normal file
496
apps/dashboard/src/lib/stores/__tests__/theme.test.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
/**
|
||||
* Unit tests for the theme store.
|
||||
*
|
||||
* Scope: pure-store behavior — setter validation, cycle order, derived
|
||||
* resolution, localStorage persistence + fallback, matchMedia listener
|
||||
* wiring, idempotent style injection, SSR safety.
|
||||
*
|
||||
* Environment notes:
|
||||
* - Vitest runs in Node (no jsdom). We fabricate the window / document /
|
||||
* localStorage / matchMedia globals the store touches, then mock
|
||||
* `$app/environment` so `browser` flips between true and false per
|
||||
* test group. SSR tests leave `browser` false and verify no globals
|
||||
* are touched.
|
||||
* - The store caches module-level state (mediaQuery, listener,
|
||||
* resolvedUnsub). We `vi.resetModules()` before every test so each
|
||||
* loadTheme() returns a pristine instance.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// --- Controllable `browser` flag ------------------------------------------
|
||||
// vi.mock is hoisted — we reference a module-level `browserFlag` the tests
|
||||
// mutate between blocks. Casting via globalThis keeps the hoist happy.
|
||||
const browserState = { value: true };
|
||||
vi.mock('$app/environment', () => ({
|
||||
get browser() {
|
||||
return browserState.value;
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Fabricated DOM / storage / matchMedia --------------------------------
|
||||
// Each test's setup wires these onto globalThis so the store's `browser`
|
||||
// branch can read them. They are intentionally minimal — only the methods
|
||||
// theme.ts actually calls are implemented.
|
||||
|
||||
type FakeMediaListener = (e: { matches: boolean }) => void;
|
||||
|
||||
interface FakeMediaQueryList {
|
||||
matches: boolean;
|
||||
addEventListener: (type: 'change', listener: FakeMediaListener) => void;
|
||||
removeEventListener: (type: 'change', listener: FakeMediaListener) => void;
|
||||
// Test-only helpers
|
||||
_emit: (matches: boolean) => void;
|
||||
_listenerCount: () => number;
|
||||
}
|
||||
|
||||
function createFakeMediaQuery(initialMatches: boolean): FakeMediaQueryList {
|
||||
const listeners = new Set<FakeMediaListener>();
|
||||
return {
|
||||
matches: initialMatches,
|
||||
addEventListener: (_type, listener) => {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (_type, listener) => {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
_emit(matches: boolean) {
|
||||
this.matches = matches;
|
||||
for (const l of listeners) l({ matches });
|
||||
},
|
||||
_listenerCount() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStorageBehavior {
|
||||
throwOnGet?: boolean;
|
||||
throwOnSet?: boolean;
|
||||
corruptRaw?: string | null;
|
||||
}
|
||||
|
||||
function installFakeLocalStorage(behavior: FakeStorageBehavior = {}) {
|
||||
const store = new Map<string, string>();
|
||||
if (behavior.corruptRaw !== undefined && behavior.corruptRaw !== null) {
|
||||
store.set('vestige.theme', behavior.corruptRaw);
|
||||
}
|
||||
const fake = {
|
||||
getItem: (key: string) => {
|
||||
if (behavior.throwOnGet) throw new Error('SecurityError: storage disabled');
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
if (behavior.throwOnSet) throw new Error('QuotaExceededError');
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
_store: store, // test-only peek
|
||||
};
|
||||
vi.stubGlobal('localStorage', fake);
|
||||
return fake;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a fake `document` with only the APIs theme.ts calls:
|
||||
* - getElementById (style-dedup check)
|
||||
* - createElement('style')
|
||||
* - head.appendChild
|
||||
* - documentElement.dataset
|
||||
* Returns handles so tests can inspect the head children and data-theme.
|
||||
*/
|
||||
function installFakeDocument() {
|
||||
const headChildren: Array<{ id: string; textContent: string; tagName: string }> = [];
|
||||
const docEl = {
|
||||
dataset: {} as Record<string, string>,
|
||||
};
|
||||
const fakeDocument = {
|
||||
getElementById: (id: string) =>
|
||||
headChildren.find((el) => el.id === id) ?? null,
|
||||
createElement: (tag: string) => ({
|
||||
id: '',
|
||||
textContent: '',
|
||||
tagName: tag.toUpperCase(),
|
||||
}),
|
||||
head: {
|
||||
appendChild: (el: { id: string; textContent: string; tagName: string }) => {
|
||||
headChildren.push(el);
|
||||
return el;
|
||||
},
|
||||
},
|
||||
documentElement: docEl,
|
||||
};
|
||||
vi.stubGlobal('document', fakeDocument);
|
||||
return { fakeDocument, headChildren, docEl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a fake `window` with just `matchMedia`. We keep the returned
|
||||
* MQL handle so tests can emit change events.
|
||||
*/
|
||||
function installFakeWindow(initialPrefersDark: boolean) {
|
||||
const mql = createFakeMediaQuery(initialPrefersDark);
|
||||
const fakeWindow = {
|
||||
matchMedia: vi.fn(() => mql),
|
||||
};
|
||||
vi.stubGlobal('window', fakeWindow);
|
||||
return { fakeWindow, mql };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh module import. The theme store caches matchMedia/listener handles
|
||||
* at module level, so every test that exercises initTheme wants a clean
|
||||
* copy. Returns the full export surface.
|
||||
*/
|
||||
async function loadTheme() {
|
||||
vi.resetModules();
|
||||
return await import('../theme');
|
||||
}
|
||||
|
||||
// Baseline: every test starts with browser=true, fake window/doc/storage
|
||||
// installed, and fresh module state. SSR-specific tests override these.
|
||||
beforeEach(() => {
|
||||
browserState.value = true;
|
||||
installFakeDocument();
|
||||
installFakeWindow(true); // system prefers dark by default
|
||||
installFakeLocalStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export surface
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('theme store — exports', () => {
|
||||
it('exports theme writable, resolvedTheme derived, setTheme, cycleTheme, initTheme', async () => {
|
||||
const mod = await loadTheme();
|
||||
expect(mod.theme).toBeDefined();
|
||||
expect(typeof mod.theme.subscribe).toBe('function');
|
||||
expect(typeof mod.theme.set).toBe('function');
|
||||
expect(mod.resolvedTheme).toBeDefined();
|
||||
expect(typeof mod.resolvedTheme.subscribe).toBe('function');
|
||||
// Derived stores do NOT expose .set — this guards against accidental
|
||||
// conversion to a writable during refactors.
|
||||
expect((mod.resolvedTheme as unknown as { set?: unknown }).set).toBeUndefined();
|
||||
expect(typeof mod.setTheme).toBe('function');
|
||||
expect(typeof mod.cycleTheme).toBe('function');
|
||||
expect(typeof mod.initTheme).toBe('function');
|
||||
});
|
||||
|
||||
it('theme defaults to dark before initTheme is called', async () => {
|
||||
const mod = await loadTheme();
|
||||
expect(get(mod.theme)).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setTheme — input validation + persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('setTheme', () => {
|
||||
it('accepts dark/light/auto and updates the store', async () => {
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
expect(get(theme)).toBe('light');
|
||||
setTheme('auto');
|
||||
expect(get(theme)).toBe('auto');
|
||||
setTheme('dark');
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('rejects invalid values — store is unchanged, localStorage untouched', async () => {
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
setTheme('light'); // seed a known value
|
||||
const ls = installFakeLocalStorage();
|
||||
// Reset any prior writes so we only see what happens during the bad call.
|
||||
ls._store.clear();
|
||||
|
||||
// Cast a bad value through the public API.
|
||||
setTheme('midnight' as unknown as 'dark');
|
||||
expect(get(theme)).toBe('light'); // unchanged
|
||||
expect(ls._store.has('vestige.theme')).toBe(false);
|
||||
|
||||
setTheme('' as unknown as 'dark');
|
||||
setTheme(undefined as unknown as 'dark');
|
||||
setTheme(null as unknown as 'dark');
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('persists the valid value to localStorage under the vestige.theme key', async () => {
|
||||
const ls = installFakeLocalStorage();
|
||||
const { setTheme } = await loadTheme();
|
||||
setTheme('auto');
|
||||
expect(ls._store.get('vestige.theme')).toBe('auto');
|
||||
});
|
||||
|
||||
it('swallows localStorage write errors (private mode / disabled storage)', async () => {
|
||||
installFakeLocalStorage({ throwOnSet: true });
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
// Must not throw.
|
||||
expect(() => setTheme('light')).not.toThrow();
|
||||
// Store still updated even though persistence failed — UI should
|
||||
// reflect the click; the next session will just start fresh.
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('no-ops localStorage write when browser=false (SSR safety)', async () => {
|
||||
browserState.value = false;
|
||||
const ls = installFakeLocalStorage();
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
// Store update is still safe (pure JS object), but persistence is skipped.
|
||||
expect(get(theme)).toBe('light');
|
||||
expect(ls._store.has('vestige.theme')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cycleTheme — dark → light → auto → dark
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('cycleTheme', () => {
|
||||
it('cycles dark → light', async () => {
|
||||
const { theme, cycleTheme } = await loadTheme();
|
||||
// Default is 'dark'.
|
||||
expect(get(theme)).toBe('dark');
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('cycles light → auto', async () => {
|
||||
const { theme, setTheme, cycleTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe('auto');
|
||||
});
|
||||
|
||||
it('cycles auto → dark (closes the loop)', async () => {
|
||||
const { theme, setTheme, cycleTheme } = await loadTheme();
|
||||
setTheme('auto');
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('full triple-click returns to the starting value', async () => {
|
||||
const { theme, cycleTheme } = await loadTheme();
|
||||
const start = get(theme);
|
||||
cycleTheme();
|
||||
cycleTheme();
|
||||
cycleTheme();
|
||||
expect(get(theme)).toBe(start);
|
||||
});
|
||||
|
||||
it('persists each step to localStorage', async () => {
|
||||
const ls = installFakeLocalStorage();
|
||||
const { cycleTheme } = await loadTheme();
|
||||
cycleTheme();
|
||||
expect(ls._store.get('vestige.theme')).toBe('light');
|
||||
cycleTheme();
|
||||
expect(ls._store.get('vestige.theme')).toBe('auto');
|
||||
cycleTheme();
|
||||
expect(ls._store.get('vestige.theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvedTheme — derived from theme + systemPrefersDark
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('resolvedTheme', () => {
|
||||
it('dark → dark (independent of system preference)', async () => {
|
||||
const { resolvedTheme, setTheme } = await loadTheme();
|
||||
setTheme('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('light → light (independent of system preference)', async () => {
|
||||
const { resolvedTheme, setTheme } = await loadTheme();
|
||||
setTheme('light');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
});
|
||||
|
||||
it('auto + system prefers dark → dark', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
|
||||
initTheme(); // primes systemPrefersDark from matchMedia
|
||||
setTheme('auto');
|
||||
expect(mql.matches).toBe(true);
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('auto + system prefers light → light', async () => {
|
||||
installFakeWindow(false);
|
||||
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
|
||||
initTheme(); // primes systemPrefersDark=false
|
||||
setTheme('auto');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
});
|
||||
|
||||
it('auto flips live when the matchMedia listener fires (OS changes scheme)', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { resolvedTheme, setTheme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
setTheme('auto');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
// OS user toggles to light mode — matchMedia fires 'change' with matches=false.
|
||||
mql._emit(false);
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
// And back to dark.
|
||||
mql._emit(true);
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// initTheme — idempotence, teardown, localStorage hydration
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('initTheme', () => {
|
||||
it('returns a teardown function', async () => {
|
||||
const { initTheme } = await loadTheme();
|
||||
const teardown = initTheme();
|
||||
expect(typeof teardown).toBe('function');
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('injects exactly one <style id="vestige-theme-light"> into <head>', async () => {
|
||||
const { headChildren } = installFakeDocument();
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
const styleEls = headChildren.filter((el) => el.id === 'vestige-theme-light');
|
||||
expect(styleEls.length).toBe(1);
|
||||
expect(styleEls[0].tagName).toBe('STYLE');
|
||||
// Sanity — CSS uses the REAL token names from app.css.
|
||||
expect(styleEls[0].textContent).toContain('--color-void');
|
||||
expect(styleEls[0].textContent).toContain('--color-bright');
|
||||
expect(styleEls[0].textContent).toContain('--color-text');
|
||||
expect(styleEls[0].textContent).toContain("[data-theme='light']");
|
||||
});
|
||||
|
||||
it('is idempotent — double init does NOT duplicate the style element', async () => {
|
||||
const { headChildren } = installFakeDocument();
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
initTheme();
|
||||
initTheme();
|
||||
const styleEls = headChildren.filter((el) => el.id === 'vestige-theme-light');
|
||||
expect(styleEls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('double init does not leak matchMedia listeners (tears down the prior one)', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
initTheme();
|
||||
// Still exactly one — the second init removed the first before adding a new one.
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
initTheme();
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('teardown removes the matchMedia listener', async () => {
|
||||
const { mql } = installFakeWindow(true);
|
||||
const { initTheme } = await loadTheme();
|
||||
const teardown = initTheme();
|
||||
expect(mql._listenerCount()).toBe(1);
|
||||
teardown();
|
||||
expect(mql._listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('hydrates theme from localStorage when a valid value is stored', async () => {
|
||||
installFakeLocalStorage({ corruptRaw: 'light' });
|
||||
const { theme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('falls back to dark when localStorage contains a corrupt/unknown value', async () => {
|
||||
installFakeLocalStorage({ corruptRaw: 'hyperdark' });
|
||||
const { theme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to dark when localStorage.getItem throws (private mode)', async () => {
|
||||
installFakeLocalStorage({ throwOnGet: true });
|
||||
const { theme, initTheme } = await loadTheme();
|
||||
// Must not throw — error swallowed, default preserved.
|
||||
expect(() => initTheme()).not.toThrow();
|
||||
expect(get(theme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('writes documentElement.dataset.theme to the resolved value', async () => {
|
||||
const { docEl } = installFakeDocument();
|
||||
installFakeWindow(true);
|
||||
const { setTheme, initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
setTheme('light');
|
||||
expect(docEl.dataset.theme).toBe('light');
|
||||
setTheme('dark');
|
||||
expect(docEl.dataset.theme).toBe('dark');
|
||||
// auto + system=dark → 'dark'
|
||||
setTheme('auto');
|
||||
expect(docEl.dataset.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('uses the correct matchMedia query: (prefers-color-scheme: dark)', async () => {
|
||||
const { fakeWindow } = installFakeWindow(true);
|
||||
const { initTheme } = await loadTheme();
|
||||
initTheme();
|
||||
expect(fakeWindow.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSR safety — browser=false means every function is a safe no-op
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('SSR safety (browser=false)', () => {
|
||||
beforeEach(() => {
|
||||
browserState.value = false;
|
||||
// Deliberately DO NOT install fake window/document/localStorage.
|
||||
// If the store touches them while browser=false, ReferenceError fires.
|
||||
vi.unstubAllGlobals();
|
||||
// But `setup.ts` (shared graph test setup) installs a minimal global
|
||||
// `document` stub. That's fine — the point is the store must not
|
||||
// call window.matchMedia or localStorage while browser=false.
|
||||
});
|
||||
|
||||
it('initTheme returns a no-op teardown and does not throw', async () => {
|
||||
const { initTheme } = await loadTheme();
|
||||
let teardown: () => void = () => {};
|
||||
expect(() => {
|
||||
teardown = initTheme();
|
||||
}).not.toThrow();
|
||||
expect(typeof teardown).toBe('function');
|
||||
expect(() => teardown()).not.toThrow();
|
||||
});
|
||||
|
||||
it('setTheme updates the store but skips localStorage', async () => {
|
||||
const { theme, setTheme } = await loadTheme();
|
||||
expect(() => setTheme('light')).not.toThrow();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('cycleTheme cycles without touching browser globals', async () => {
|
||||
const { theme, cycleTheme } = await loadTheme();
|
||||
expect(() => cycleTheme()).not.toThrow();
|
||||
expect(get(theme)).toBe('light');
|
||||
});
|
||||
|
||||
it('resolvedTheme returns the concrete value for dark/light, defaults to dark for auto', async () => {
|
||||
const { resolvedTheme, setTheme } = await loadTheme();
|
||||
setTheme('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
setTheme('light');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
// In SSR we never primed matchMedia, so systemPrefersDark is its
|
||||
// default (true) → auto resolves to dark. This keeps server-rendered
|
||||
// HTML matching the dark-first design.
|
||||
setTheme('auto');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
});
|
||||
254
apps/dashboard/src/lib/stores/theme.ts
Normal file
254
apps/dashboard/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// Theme store — closes GitHub issue #11 (Dark/Light theme toggle).
|
||||
//
|
||||
// Design:
|
||||
// - Three modes: 'dark' (current default, bioluminescent), 'light'
|
||||
// (slate-on-white, muted glow), 'auto' (follows system preference).
|
||||
// - A single writable `theme` store holds the user preference.
|
||||
// A derived `resolvedTheme` collapses 'auto' into the concrete 'dark' |
|
||||
// 'light' that matchMedia is reporting right now.
|
||||
// - On any change we flip `document.documentElement.dataset.theme`. All
|
||||
// CSS variable overrides key off `[data-theme='light']` in the
|
||||
// injected stylesheet below (app.css is deliberately left untouched so
|
||||
// the dark defaults still cascade when no attribute is set).
|
||||
// - Preference persists to `localStorage['vestige.theme']`.
|
||||
// - `initTheme()` is called once from +layout.svelte onMount. It (a)
|
||||
// reads localStorage, (b) injects the light-mode stylesheet into
|
||||
// <head>, (c) sets dataset.theme, (d) attaches a matchMedia listener
|
||||
// so 'auto' tracks the OS in real time.
|
||||
//
|
||||
// Light-mode override strategy:
|
||||
// We inject a single <style id="vestige-theme-light"> block at init time
|
||||
// rather than editing app.css. This keeps the dark-first design pristine
|
||||
// and lets us ship the toggle as a purely additive change. Overrides
|
||||
// target the real token names used in app.css (`--color-void`,
|
||||
// `--color-text`, `--color-bright`, `--color-dim`, `--color-muted`,
|
||||
// `--color-surface`, etc.) plus halve the glow shadows so neon accents
|
||||
// don't wash out on a slate-50 canvas.
|
||||
|
||||
import { writable, derived, get, type Readable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'auto';
|
||||
export type ResolvedTheme = 'dark' | 'light';
|
||||
|
||||
const STORAGE_KEY = 'vestige.theme';
|
||||
const STYLE_ELEMENT_ID = 'vestige-theme-light';
|
||||
|
||||
/** User preference — 'dark' | 'light' | 'auto'. Persists to localStorage. */
|
||||
export const theme = writable<Theme>('dark');
|
||||
|
||||
/**
|
||||
* System preference at this moment — tracked via matchMedia and kept in
|
||||
* sync by the listener wired up in `initTheme`. Defaults to 'dark' so
|
||||
* SSR/first paint matches the dark-first design.
|
||||
*/
|
||||
const systemPrefersDark = writable<boolean>(true);
|
||||
|
||||
/**
|
||||
* The concrete theme after resolving 'auto' → matchMedia. This is what
|
||||
* actually gets written to `document.documentElement.dataset.theme`.
|
||||
*/
|
||||
export const resolvedTheme: Readable<ResolvedTheme> = derived(
|
||||
[theme, systemPrefersDark],
|
||||
([$theme, $prefersDark]) => {
|
||||
if ($theme === 'auto') return $prefersDark ? 'dark' : 'light';
|
||||
return $theme;
|
||||
}
|
||||
);
|
||||
|
||||
/** Runtime guard — TypeScript callers are already narrowed, but the store is
|
||||
* also exposed via the dashboard window for devtools / demo sequences.
|
||||
* We silently ignore unknown values rather than throwing so a fat-finger
|
||||
* console poke can't wedge the UI. */
|
||||
function isValidTheme(v: unknown): v is Theme {
|
||||
return v === 'dark' || v === 'light' || v === 'auto';
|
||||
}
|
||||
|
||||
/** Public setter. Also persists to localStorage. Invalid inputs are ignored. */
|
||||
export function setTheme(next: Theme): void {
|
||||
if (!isValidTheme(next)) return;
|
||||
theme.set(next);
|
||||
if (browser) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Private mode / disabled storage — silent no-op.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cycle dark → light → auto → dark. Used by the ThemeToggle button. */
|
||||
export function cycleTheme(): void {
|
||||
const current = get(theme);
|
||||
const next: Theme = current === 'dark' ? 'light' : current === 'light' ? 'auto' : 'dark';
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the light-mode variable overrides into <head>. Idempotent —
|
||||
* safe to call multiple times. We target the real tokens from app.css
|
||||
* and halve the glow intensity so bioluminescent accents remain readable
|
||||
* but don't bloom on a pale canvas.
|
||||
*/
|
||||
function ensureLightStylesheet(): void {
|
||||
if (!browser) return;
|
||||
if (document.getElementById(STYLE_ELEMENT_ID)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ELEMENT_ID;
|
||||
style.textContent = `
|
||||
/* Vestige light-mode overrides — injected by theme.ts.
|
||||
* Activated by [data-theme='light'] on <html>.
|
||||
* Tokens mirror the real names used in app.css so the cascade stays clean. */
|
||||
[data-theme='light'] {
|
||||
/* Core surface palette (slate scale) */
|
||||
--color-void: #f8fafc; /* slate-50 — page background */
|
||||
--color-abyss: #f1f5f9; /* slate-100 */
|
||||
--color-deep: #e2e8f0; /* slate-200 */
|
||||
--color-surface: #f1f5f9; /* slate-100 */
|
||||
--color-elevated: #e2e8f0; /* slate-200 */
|
||||
--color-subtle: #cbd5e1; /* slate-300 */
|
||||
--color-muted: #94a3b8; /* slate-400 */
|
||||
--color-dim: #475569; /* slate-600 */
|
||||
--color-text: #0f172a; /* slate-900 */
|
||||
--color-bright: #020617; /* slate-950 */
|
||||
}
|
||||
|
||||
/* Baseline body/html wiring — app.css sets these against the dark
|
||||
* tokens; we just let the variables do the work. Reassert for clarity. */
|
||||
[data-theme='light'] html,
|
||||
html[data-theme='light'] {
|
||||
background: var(--color-void);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Glass surfaces — recompose on a light canvas. The original alphas
|
||||
* are tuned for dark; invert-and-tint for light so panels still read
|
||||
* as elevated instead of vanishing. */
|
||||
[data-theme='light'] .glass {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(99, 102, 241, 0.12);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.6),
|
||||
0 4px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
[data-theme='light'] .glass-subtle {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(99, 102, 241, 0.1);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
[data-theme='light'] .glass-sidebar {
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
border-right: 1px solid rgba(99, 102, 241, 0.14);
|
||||
box-shadow:
|
||||
inset -1px 0 0 0 rgba(255, 255, 255, 0.4),
|
||||
4px 0 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
[data-theme='light'] .glass-panel {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(99, 102, 241, 0.14);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
0 8px 32px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
/* Halve glow intensity — neon accents stay recognizable without
|
||||
* washing out on slate-50. */
|
||||
[data-theme='light'] .glow-synapse {
|
||||
box-shadow: 0 0 10px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
[data-theme='light'] .glow-dream {
|
||||
box-shadow: 0 0 10px rgba(168, 85, 247, 0.15), 0 0 30px rgba(168, 85, 247, 0.05);
|
||||
}
|
||||
[data-theme='light'] .glow-memory {
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.15), 0 0 30px rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
/* Ambient orbs are gorgeous on black and blinding on white. Tame them. */
|
||||
[data-theme='light'] .ambient-orb {
|
||||
opacity: 0.18;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
/* Scrollbar recolor for the lighter surface. */
|
||||
[data-theme='light'] ::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
[data-theme='light'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/** Apply the resolved theme to <html> so CSS selectors activate. */
|
||||
function applyDocumentAttribute(resolved: ResolvedTheme): void {
|
||||
if (!browser) return;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
}
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let mediaListener: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
let themeUnsub: (() => void) | null = null;
|
||||
let resolvedUnsub: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Boot the theme system. Call once from +layout.svelte onMount.
|
||||
* Idempotent — safe to call repeatedly; subsequent calls are no-ops.
|
||||
* Returns a teardown fn for tests / HMR.
|
||||
*/
|
||||
export function initTheme(): () => void {
|
||||
if (!browser) return () => {};
|
||||
|
||||
// Tear down any prior init so repeated calls don't leak listeners or
|
||||
// subscriptions. This is the hot-reload / double-mount safety net.
|
||||
if (mediaQuery && mediaListener) {
|
||||
mediaQuery.removeEventListener('change', mediaListener);
|
||||
}
|
||||
resolvedUnsub?.();
|
||||
themeUnsub?.();
|
||||
mediaQuery = null;
|
||||
mediaListener = null;
|
||||
resolvedUnsub = null;
|
||||
themeUnsub = null;
|
||||
|
||||
ensureLightStylesheet();
|
||||
|
||||
// 1. Read persisted preference.
|
||||
let saved: Theme = 'dark';
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'dark' || raw === 'light' || raw === 'auto') saved = raw;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
theme.set(saved);
|
||||
|
||||
// 2. Prime system preference + attach matchMedia listener.
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemPrefersDark.set(mediaQuery.matches);
|
||||
mediaListener = (e: MediaQueryListEvent) => systemPrefersDark.set(e.matches);
|
||||
mediaQuery.addEventListener('change', mediaListener);
|
||||
|
||||
// 3. Apply the currently-resolved theme and subscribe for future changes.
|
||||
applyDocumentAttribute(get(resolvedTheme));
|
||||
resolvedUnsub = resolvedTheme.subscribe(applyDocumentAttribute);
|
||||
|
||||
// Silence the unused-import lint on `theme` — already used above,
|
||||
// but also keep a subscription handle for teardown symmetry.
|
||||
themeUnsub = theme.subscribe(() => {});
|
||||
|
||||
return () => {
|
||||
if (mediaQuery && mediaListener) {
|
||||
mediaQuery.removeEventListener('change', mediaListener);
|
||||
}
|
||||
mediaQuery = null;
|
||||
mediaListener = null;
|
||||
resolvedUnsub?.();
|
||||
themeUnsub?.();
|
||||
resolvedUnsub = null;
|
||||
themeUnsub = null;
|
||||
};
|
||||
}
|
||||
300
apps/dashboard/src/routes/(app)/activation/+page.svelte
Normal file
300
apps/dashboard/src/routes/(app)/activation/+page.svelte
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Spreading Activation Live View.
|
||||
*
|
||||
* Two sources of bursts feed the ActivationNetwork canvas:
|
||||
* 1. User search — type a query, we pick the top-1 match and fetch its
|
||||
* associations (up to 15), then pass `{source, neighbours}` as props.
|
||||
* 2. Live mode — subscribe to `$eventFeed` and, on every NEW
|
||||
* `ActivationSpread` event, trigger an overlay burst at a randomised
|
||||
* offset. Old events (those present before mount, or already
|
||||
* processed) never re-fire; we track `lastSeen` by object identity
|
||||
* so overlapping batches inside the same Svelte update tick are
|
||||
* still handled.
|
||||
*
|
||||
* All heavy lifting (decay, geometry, color, event filter) lives in
|
||||
* `$components/activation-helpers` so it's unit-tested in Node without
|
||||
* a browser.
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import ActivationNetwork, {
|
||||
type ActivationNode,
|
||||
} from '$components/ActivationNetwork.svelte';
|
||||
import { filterNewSpreadEvents } from '$components/activation-helpers';
|
||||
import type { Memory, VestigeEvent } from '$types';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let loading = $state(false);
|
||||
let searched = $state(false); // true after the first submitted search
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
let focusedSource = $state<ActivationNode | null>(null);
|
||||
let focusedNeighbours = $state<ActivationNode[]>([]);
|
||||
|
||||
let liveEnabled = $state(true);
|
||||
let liveBurstKey = $state(0);
|
||||
let liveBurst = $state<{
|
||||
source: ActivationNode;
|
||||
neighbours: ActivationNode[];
|
||||
} | null>(null);
|
||||
let liveBurstsFired = $state(0);
|
||||
|
||||
// Track every memory we've seen so live-mode events (which carry only
|
||||
// IDs) can be rendered with real labels + node types. If a spread event
|
||||
// references an unknown ID we fall back to a short hash so the burst
|
||||
// still renders — this mirrors how the 3D graph degrades gracefully.
|
||||
const memoryCache = new Map<string, Memory>();
|
||||
|
||||
function rememberMemory(m: Memory) {
|
||||
memoryCache.set(m.id, m);
|
||||
}
|
||||
|
||||
function memoryToNode(m: Memory): ActivationNode {
|
||||
return {
|
||||
id: m.id,
|
||||
label: labelFor(m.content, m.id),
|
||||
nodeType: m.nodeType,
|
||||
};
|
||||
}
|
||||
|
||||
function labelFor(content: string | undefined, id: string): string {
|
||||
if (content && content.trim().length > 0) {
|
||||
const trimmed = content.trim();
|
||||
return trimmed.length > 60 ? trimmed.slice(0, 60) + '…' : trimmed;
|
||||
}
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
|
||||
function fallbackNode(id: string): ActivationNode {
|
||||
const cached = memoryCache.get(id);
|
||||
if (cached) return memoryToNode(cached);
|
||||
return { id, label: id.slice(0, 8), nodeType: 'note' };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// User-driven search → focused burst
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function runSearch() {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
// Empty query is a no-op — don't clobber the current burst.
|
||||
errorMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
searched = true;
|
||||
errorMessage = null;
|
||||
focusedSource = null;
|
||||
focusedNeighbours = [];
|
||||
|
||||
try {
|
||||
const searchRes = await api.search(q, 1);
|
||||
if (!searchRes.results || searchRes.results.length === 0) {
|
||||
// Leave `searched=true` + `focusedSource=null` → UI shows
|
||||
// the "no matches" empty state rather than crashing on
|
||||
// `searchRes.results[0]`.
|
||||
return;
|
||||
}
|
||||
const top = searchRes.results[0];
|
||||
rememberMemory(top);
|
||||
focusedSource = memoryToNode(top);
|
||||
|
||||
const assocRes = (await api.explore(top.id, 'associations', undefined, 15)) as
|
||||
| {
|
||||
results?: Memory[];
|
||||
nodes?: Memory[];
|
||||
// The backend has shipped at least two shapes; accept both.
|
||||
associations?: Memory[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const rawList =
|
||||
assocRes?.results ?? assocRes?.nodes ?? assocRes?.associations ?? [];
|
||||
|
||||
const neighbours: ActivationNode[] = [];
|
||||
for (const n of rawList) {
|
||||
if (!n || typeof n !== 'object' || !('id' in n)) continue;
|
||||
const mem = n as Memory;
|
||||
rememberMemory(mem);
|
||||
neighbours.push(memoryToNode(mem));
|
||||
}
|
||||
focusedNeighbours = neighbours;
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : String(e);
|
||||
focusedSource = null;
|
||||
focusedNeighbours = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Live mode — $eventFeed → overlay bursts
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
let feedUnsub: (() => void) | null = null;
|
||||
// Object identity of the most recently processed event. We walk the
|
||||
// feed head until we hit this reference, so mid-burst batches in one
|
||||
// Svelte tick are all processed. Mirrors toast.ts.
|
||||
let lastSeenEvent: VestigeEvent | null = null;
|
||||
let primedLiveBaseline = false;
|
||||
|
||||
onMount(() => {
|
||||
feedUnsub = eventFeed.subscribe((events) => {
|
||||
if (!events || events.length === 0) return;
|
||||
// Prime lastSeen to the current head BEFORE we're live — we don't
|
||||
// want to flood the canvas with every ActivationSpread in the
|
||||
// 200-event ring buffer on first mount. Post-prime, only new
|
||||
// events fire bursts.
|
||||
if (!primedLiveBaseline) {
|
||||
lastSeenEvent = events[0];
|
||||
primedLiveBaseline = true;
|
||||
return;
|
||||
}
|
||||
if (!liveEnabled) {
|
||||
// Still advance the baseline so toggling live back on doesn't
|
||||
// dump a backlog.
|
||||
lastSeenEvent = events[0];
|
||||
return;
|
||||
}
|
||||
const spreads = filterNewSpreadEvents(events, lastSeenEvent);
|
||||
lastSeenEvent = events[0];
|
||||
if (spreads.length === 0) return;
|
||||
for (const s of spreads) {
|
||||
const srcNode = fallbackNode(s.source_id);
|
||||
const nbrs = s.target_ids.map((tid) => fallbackNode(tid));
|
||||
liveBurstKey += 1;
|
||||
liveBurst = { source: srcNode, neighbours: nbrs };
|
||||
liveBurstsFired += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (feedUnsub) feedUnsub();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto space-y-6">
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-xl text-bright font-semibold">Spreading Activation</h1>
|
||||
<p class="text-xs text-muted">
|
||||
Collins & Loftus 1975 — activation spreads from a seed memory to
|
||||
neighbours along semantic edges, decaying by 0.93 per animation frame
|
||||
until it drops below 0.05. Search seeds a focused burst; live mode
|
||||
overlays every spread event fired by the cognitive engine in real time.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="space-y-3">
|
||||
<span class="text-xs text-dim font-medium">Seed Memory</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for a memory to activate..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && runSearch()}
|
||||
class="flex-1 px-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 transition backdrop-blur-sm"
|
||||
/>
|
||||
<button
|
||||
onclick={runSearch}
|
||||
disabled={loading}
|
||||
class="px-4 py-2.5 bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm rounded-xl hover:bg-synapse/30 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Activating…' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live toggle + stats -->
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<label class="flex items-center gap-2 text-dim cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={liveEnabled}
|
||||
class="accent-synapse-glow"
|
||||
/>
|
||||
<span>Live mode — overlay bursts from cognitive engine events</span>
|
||||
</label>
|
||||
<span class="text-muted">
|
||||
Live bursts fired: <span class="text-synapse-glow">{liveBurstsFired}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Canvas + empty/error states -->
|
||||
<div
|
||||
class="glass rounded-2xl overflow-hidden !border-synapse/15 bg-deep/40"
|
||||
style="min-height: 560px;"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl animate-pulse mb-2">◎</div>
|
||||
<p class="text-sm">Computing activation...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if errorMessage}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div class="text-3xl opacity-30 mb-3">⚠</div>
|
||||
<p class="text-sm text-bright mb-1">Activation failed</p>
|
||||
<p class="text-xs text-muted">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !focusedSource && searched}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div class="text-3xl opacity-20 mb-3">◬</div>
|
||||
<p class="text-sm text-bright mb-1">No matching memory</p>
|
||||
<p class="text-xs text-muted">
|
||||
Nothing in the graph matches
|
||||
<span class="text-text">"{searchQuery}"</span>. Try a broader
|
||||
query or switch on live mode to watch the engine fire its own
|
||||
bursts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !focusedSource}
|
||||
<div class="flex items-center justify-center h-[560px] text-dim">
|
||||
<div class="text-center max-w-md px-6">
|
||||
<div class="text-3xl opacity-20 mb-3">◎</div>
|
||||
<p class="text-sm text-bright mb-1">Waiting for activation</p>
|
||||
<p class="text-xs text-muted">
|
||||
Seed a burst with the search bar above, or enable live mode to
|
||||
overlay bursts from the cognitive engine as they happen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ActivationNetwork
|
||||
width={1040}
|
||||
height={560}
|
||||
source={focusedSource}
|
||||
neighbours={focusedNeighbours}
|
||||
{liveBurstKey}
|
||||
{liveBurst}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Focused burst metadata -->
|
||||
{#if focusedSource}
|
||||
<div class="p-3 glass rounded-xl !border-synapse/20">
|
||||
<div class="text-[10px] text-synapse-glow mb-1 uppercase tracking-wider">
|
||||
Seed
|
||||
</div>
|
||||
<p class="text-sm text-text">{focusedSource.label}</p>
|
||||
<div class="flex gap-2 mt-1.5 text-[10px] text-muted">
|
||||
<span>{focusedSource.nodeType}</span>
|
||||
<span>{focusedNeighbours.length} neighbours</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
532
apps/dashboard/src/routes/(app)/contradictions/+page.svelte
Normal file
532
apps/dashboard/src/routes/(app)/contradictions/+page.svelte
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
<script lang="ts">
|
||||
import ContradictionArcs, { type Contradiction } from '$components/ContradictionArcs.svelte';
|
||||
import {
|
||||
severityColor,
|
||||
severityLabel,
|
||||
truncate,
|
||||
uniqueMemoryCount,
|
||||
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 diversity helps at T>=0.6 per GPT-OSS paper',
|
||||
memory_b_preview: 'Prompt diversity monotonically HURTS at T>=0.6 (arxiv 2603.27844)',
|
||||
memory_a_type: 'concept',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-03-30',
|
||||
memory_b_created: '2026-04-03',
|
||||
memory_a_tags: ['aimo3', 'prompting'],
|
||||
memory_b_tags: ['aimo3', '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 GPT-OSS-120B sampling',
|
||||
memory_b_preview: 'min_p scheduling fails at competition temperatures',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'fact',
|
||||
memory_a_created: '2026-04-01',
|
||||
memory_b_created: '2026-04-05',
|
||||
memory_a_tags: ['aimo3', 'sampling'],
|
||||
memory_b_tags: ['aimo3', '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: 'Sam prefers Rust for all backend services',
|
||||
memory_b_preview: 'Sam chose Axum + Rust for Nullgaze 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', 'sam'],
|
||||
memory_b_tags: ['nullgaze', 'backend'],
|
||||
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 GPT-OSS with 16384 token budget for AIMO',
|
||||
memory_b_preview: 'AIMO3 baseline at 32768 tokens scored 44/50',
|
||||
memory_a_type: 'pattern',
|
||||
memory_b_type: 'event',
|
||||
memory_a_created: '2026-04-04',
|
||||
memory_b_created: '2026-04-10',
|
||||
memory_a_tags: ['aimo3', 'tokens'],
|
||||
memory_b_tags: ['aimo3', '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: 'Kaggle API silently ignores invalid modelDataSources slugs',
|
||||
memory_b_preview: 'Kaggle API throws an error when model 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: ['kaggle', 'bug-fix', 'api'],
|
||||
memory_b_tags: ['kaggle', 'api'],
|
||||
trust_a: 0.89,
|
||||
trust_b: 0.28,
|
||||
similarity: 0.91,
|
||||
date_diff_days: 46,
|
||||
topic: 'Kaggle 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: 'Orbit Wars leaderboard scores weight by top-10 consistency',
|
||||
memory_b_preview: 'Orbit Wars 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: ['orbit-wars', 'scoring'],
|
||||
memory_b_tags: ['orbit-wars', 'scoring'],
|
||||
trust_a: 0.64,
|
||||
trust_b: 0.52,
|
||||
similarity: 0.82,
|
||||
date_diff_days: 8,
|
||||
topic: 'Orbit Wars scoring'
|
||||
},
|
||||
{
|
||||
memory_a_id: 'a14',
|
||||
memory_b_id: 'b14',
|
||||
memory_a_preview: 'Sam commits to morning posts 8am ET',
|
||||
memory_b_preview: 'Morning cadence moved to 9am ET after energy 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'
|
||||
}
|
||||
];
|
||||
|
||||
// --- Filters ---
|
||||
type Filter = 'all' | 'recent' | 'high-trust' | 'topic';
|
||||
let filter = $state<Filter>('all');
|
||||
let topicFilter = $state<string>('');
|
||||
|
||||
const uniqueTopics = $derived(
|
||||
Array.from(new Set(MOCK_CONTRADICTIONS.map((c) => c.topic))).sort()
|
||||
);
|
||||
|
||||
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.
|
||||
{
|
||||
const now = new Date('2026-04-20').getTime();
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
return MOCK_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(
|
||||
(c) => Math.min(c.trust_a, c.trust_b) > 0.6
|
||||
);
|
||||
case 'topic':
|
||||
return topicFilter
|
||||
? MOCK_CONTRADICTIONS.filter((c) => c.topic === topicFilter)
|
||||
: MOCK_CONTRADICTIONS;
|
||||
case 'all':
|
||||
default:
|
||||
return MOCK_CONTRADICTIONS;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Selection / focused pair ---
|
||||
let focusedPairIndex = $state<number | null>(null);
|
||||
|
||||
function selectPair(i: number | null) {
|
||||
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
|
||||
// self-consistent with what the user sees. ---
|
||||
const TOTAL_CONTRADICTIONS_DETECTED = 47;
|
||||
const totalMemoriesInvolved = $derived(uniqueMemoryCount(MOCK_CONTRADICTIONS));
|
||||
const avgTrustDelta = $derived(avgTrustDeltaFn(MOCK_CONTRADICTIONS));
|
||||
|
||||
// Map filtered index -> original index in MOCK_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]));
|
||||
return filtered.map((c) => ({
|
||||
orig: byId.get(c.memory_a_id + '|' + c.memory_b_id) ?? 0,
|
||||
c
|
||||
}));
|
||||
});
|
||||
|
||||
// The ContradictionArcs component receives the filtered list; its internal
|
||||
// indices run 0..filtered.length-1. We translate when the sidebar clicks.
|
||||
function sidebarClick(localIndex: number) {
|
||||
focusedPairIndex = focusedPairIndex === localIndex ? null : localIndex;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-full p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-2xl text-bright font-semibold tracking-tight">
|
||||
Contradiction Constellation
|
||||
</h1>
|
||||
<p class="text-sm text-dim">Where your memory disagrees with itself</p>
|
||||
</header>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{TOTAL_CONTRADICTIONS_DETECTED}</div>
|
||||
<div class="text-xs text-dim mt-1">
|
||||
contradictions across {totalMemoriesInvolved.toLocaleString()} memories
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl font-bold" style="color: #f59e0b">
|
||||
{avgTrustDelta.toFixed(2)}
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">average trust delta</div>
|
||||
</div>
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl text-bright font-bold">{filtered.length}</div>
|
||||
<div class="text-xs text-dim mt-1">visible in current filter</div>
|
||||
</div>
|
||||
<div class="p-4 glass rounded-xl">
|
||||
<div class="text-2xl font-bold" style="color: #ef4444">
|
||||
{filtered.filter((c) => c.similarity > 0.7).length}
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-1">strong conflicts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
{#each [{ id: 'all', label: 'All' }, { id: 'recent', label: 'Recent (7d)' }, { id: 'high-trust', label: 'High trust (>60%)' }, { id: 'topic', label: 'By topic' }] as f (f.id)}
|
||||
<button
|
||||
onclick={() => {
|
||||
filter = f.id as Filter;
|
||||
focusedPairIndex = null;
|
||||
}}
|
||||
class="px-3 py-1.5 rounded-lg text-xs border transition
|
||||
{filter === f.id
|
||||
? 'bg-synapse/15 border-synapse/40 text-synapse-glow'
|
||||
: 'border-subtle/30 text-dim hover:text-text hover:bg-white/[0.03]'}"
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filter === 'topic'}
|
||||
<select
|
||||
bind:value={topicFilter}
|
||||
class="ml-2 px-3 py-1.5 rounded-lg text-xs glass-subtle border border-subtle/30 text-text"
|
||||
>
|
||||
<option value="">All topics</option>
|
||||
{#each uniqueTopics as t}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if focusedPairIndex !== null}
|
||||
<button
|
||||
onclick={() => (focusedPairIndex = null)}
|
||||
class="ml-auto px-3 py-1.5 rounded-lg text-xs border border-subtle/30 text-dim hover:text-text"
|
||||
>
|
||||
Clear focus
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main view: constellation + sidebar -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-4">
|
||||
<!-- Constellation -->
|
||||
<div class="glass-panel rounded-2xl p-3 min-h-[520px] relative">
|
||||
{#if filtered.length === 0}
|
||||
<div class="flex items-center justify-center h-full text-dim text-sm">
|
||||
No contradictions match this filter.
|
||||
</div>
|
||||
{:else}
|
||||
<ContradictionArcs
|
||||
contradictions={filtered}
|
||||
{focusedPairIndex}
|
||||
onSelectPair={selectPair}
|
||||
width={800}
|
||||
height={600}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: pair list -->
|
||||
<aside class="glass rounded-2xl p-3 space-y-2 max-h-[620px] overflow-y-auto">
|
||||
<div class="flex items-center justify-between px-1 pb-2 sticky top-0 bg-deep/60 backdrop-blur-sm z-10">
|
||||
<span class="text-xs text-dim uppercase tracking-wider">Pairs</span>
|
||||
<span class="text-xs text-muted">{visibleList.length}</span>
|
||||
</div>
|
||||
|
||||
{#if visibleList.length === 0}
|
||||
<div class="text-xs text-muted p-3">No pairs visible.</div>
|
||||
{/if}
|
||||
|
||||
{#each visibleList as entry, localIndex (entry.c.memory_a_id + '|' + entry.c.memory_b_id)}
|
||||
{@const c = entry.c}
|
||||
{@const isFocused = focusedPairIndex === localIndex}
|
||||
<button
|
||||
onclick={() => sidebarClick(localIndex)}
|
||||
class="w-full text-left p-3 rounded-xl border transition
|
||||
{isFocused
|
||||
? 'bg-synapse/10 border-synapse/40 shadow-[0_0_12px_rgba(99,102,241,0.18)]'
|
||||
: 'border-subtle/20 hover:border-synapse/30 hover:bg-white/[0.02]'}"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
style="background: {severityColor(c.similarity)}"
|
||||
></div>
|
||||
<span class="text-[10px] uppercase tracking-wider" style="color: {severityColor(c.similarity)}">
|
||||
{severityLabel(c.similarity)}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted ml-auto">
|
||||
{(c.similarity * 100).toFixed(0)}% sim · {c.date_diff_days}d
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-text font-medium mb-1 truncate">
|
||||
{c.topic}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-start gap-2 text-[11px]">
|
||||
<span class="text-muted mt-0.5 shrink-0">A</span>
|
||||
<span class="text-dim">{truncate(c.memory_a_preview)}</span>
|
||||
<span class="ml-auto text-[10px] text-muted shrink-0">
|
||||
{(c.trust_a * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 text-[11px]">
|
||||
<span class="text-muted mt-0.5 shrink-0">B</span>
|
||||
<span class="text-dim">{truncate(c.memory_b_preview)}</span>
|
||||
<span class="ml-auto text-[10px] text-muted shrink-0">
|
||||
{(c.trust_b * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isFocused}
|
||||
<div class="mt-3 pt-3 border-t border-subtle/20 space-y-2">
|
||||
<div class="text-[10px] text-muted uppercase tracking-wider">Full memory A</div>
|
||||
<div class="text-[11px] text-text">{c.memory_a_preview}</div>
|
||||
{#if c.memory_a_tags && c.memory_a_tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each c.memory_a_tags as t}
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-white/[0.04] text-muted">{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-[10px] text-muted uppercase tracking-wider pt-1">Full memory B</div>
|
||||
<div class="text-[11px] text-text">{c.memory_b_preview}</div>
|
||||
{#if c.memory_b_tags && c.memory_b_tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each c.memory_b_tags as t}
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-white/[0.04] text-muted">{t}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
489
apps/dashboard/src/routes/(app)/dreams/+page.svelte
Normal file
489
apps/dashboard/src/routes/(app)/dreams/+page.svelte
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
<!--
|
||||
Dream Cinema — scrubbable replay of Vestige's 5-stage dream consolidation.
|
||||
|
||||
The /api/dream endpoint returns a DreamResult. We render the 5 phases of
|
||||
the MemoryDreamer pipeline (Replay → Cross-reference → Strengthen → Prune
|
||||
→ Transfer) and a sorted insight list. Clicking "Dream Now" triggers a
|
||||
fresh dream; the scrubber then lets the user step through the stages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { api } from '$stores/api';
|
||||
import type { DreamResult } from '$types';
|
||||
import DreamStageReplay from '$components/DreamStageReplay.svelte';
|
||||
import DreamInsightCard from '$components/DreamInsightCard.svelte';
|
||||
import {
|
||||
STAGE_NAMES,
|
||||
clampStage,
|
||||
formatDurationMs,
|
||||
} from '$components/dream-helpers';
|
||||
|
||||
let dreamResult: DreamResult | null = $state(null);
|
||||
let stage = $state(1);
|
||||
let dreaming = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
let hasDream = $derived(dreamResult !== null);
|
||||
|
||||
let sortedInsights = $derived.by(() => {
|
||||
const r = dreamResult;
|
||||
if (!r) return [];
|
||||
return [...r.insights].sort((a, b) => (b.noveltyScore ?? 0) - (a.noveltyScore ?? 0));
|
||||
});
|
||||
|
||||
async function runDream() {
|
||||
if (dreaming) return;
|
||||
dreaming = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.dream();
|
||||
dreamResult = result;
|
||||
stage = 1;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Dream failed';
|
||||
} finally {
|
||||
dreaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setStage(n: number) {
|
||||
stage = clampStage(n);
|
||||
}
|
||||
|
||||
function onScrub(e: Event) {
|
||||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||
setStage(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dream Cinema · Vestige</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl text-bright font-semibold tracking-tight flex items-center gap-3">
|
||||
<span class="header-glyph">✦</span>
|
||||
Dream Cinema
|
||||
</h1>
|
||||
<p class="text-sm text-dim mt-1 max-w-xl leading-snug">
|
||||
Scrub through Vestige's 5-stage consolidation cycle. Replay, cross-reference,
|
||||
strengthen, prune, transfer. Watch episodic become semantic.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={runDream}
|
||||
disabled={dreaming}
|
||||
class="dream-button"
|
||||
class:is-dreaming={dreaming}
|
||||
>
|
||||
{#if dreaming}
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Dreaming...</span>
|
||||
{:else}
|
||||
<span class="dream-icon" aria-hidden="true">✦</span>
|
||||
<span>Dream Now</span>
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="glass-subtle rounded-xl px-4 py-3 text-sm border !border-decay/40 text-decay">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !hasDream && !dreaming}
|
||||
<!-- Empty state -->
|
||||
<div class="empty-state glass-panel rounded-2xl p-12 text-center space-y-3">
|
||||
<div class="empty-glyph">✦</div>
|
||||
<p class="text-bright font-semibold">No dream yet.</p>
|
||||
<p class="text-dim text-sm">Click Dream Now to begin.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Scrubber + stage markers -->
|
||||
<section class="glass-panel rounded-2xl p-5 space-y-4">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="text-[11px] text-dream-glow uppercase tracking-[0.18em] font-semibold">
|
||||
Stage {stage} · {STAGE_NAMES[stage - 1]}
|
||||
</div>
|
||||
<div class="flex gap-1 text-[11px] text-dim">
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
onclick={() => setStage(stage - 1)}
|
||||
disabled={stage <= 1 || dreaming}
|
||||
aria-label="Previous stage">◀</button>
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
onclick={() => setStage(stage + 1)}
|
||||
disabled={stage >= 5 || dreaming}
|
||||
aria-label="Next stage">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scrubber -->
|
||||
<div class="scrubber-wrap">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={stage}
|
||||
oninput={onScrub}
|
||||
disabled={dreaming}
|
||||
class="scrubber"
|
||||
aria-label="Dream stage scrubber"
|
||||
/>
|
||||
<div class="scrubber-ticks">
|
||||
{#each STAGE_NAMES as name, i (name)}
|
||||
<button
|
||||
type="button"
|
||||
class="tick"
|
||||
class:active={stage === i + 1}
|
||||
class:passed={stage > i + 1}
|
||||
onclick={() => setStage(i + 1)}
|
||||
disabled={dreaming}
|
||||
>
|
||||
<span class="tick-dot"></span>
|
||||
<span class="tick-label">{i + 1}. {name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main grid: stage replay + insights -->
|
||||
<section class="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<!-- Stage replay -->
|
||||
<DreamStageReplay {stage} {dreamResult} />
|
||||
|
||||
<!-- Insights panel -->
|
||||
<aside class="glass-panel rounded-2xl p-4 space-y-3 min-h-[240px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-bright">Insights</h2>
|
||||
<span class="text-[10px] text-dim uppercase tracking-wider">
|
||||
{sortedInsights.length} total · by novelty
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="insights-scroll space-y-3">
|
||||
{#if sortedInsights.length === 0}
|
||||
<div class="text-center py-8 text-dim text-sm">
|
||||
{#if dreaming}
|
||||
Dreaming...
|
||||
{:else}
|
||||
No insights generated this cycle.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each sortedInsights as insight, i (i + '-' + (insight.insight?.slice(0, 32) ?? ''))}
|
||||
<DreamInsightCard {insight} index={i} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- Stats footer -->
|
||||
{#if dreamResult}
|
||||
<footer class="glass-subtle rounded-2xl p-4 grid gap-3 grid-cols-2 md:grid-cols-5">
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.memoriesReplayed ?? 0}</div>
|
||||
<div class="stat-label">Replayed</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.stats?.newConnectionsFound ?? 0}</div>
|
||||
<div class="stat-label">Connections Found</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.connectionsPersisted ?? 0}</div>
|
||||
<div class="stat-label">Connections Persisted</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{dreamResult.stats?.insightsGenerated ?? 0}</div>
|
||||
<div class="stat-label">Insights</div>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<div class="stat-value">{formatDurationMs(dreamResult.stats?.durationMs)}</div>
|
||||
<div class="stat-label">Duration</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.header-glyph {
|
||||
display: inline-block;
|
||||
color: var(--color-dream-glow);
|
||||
text-shadow:
|
||||
0 0 12px var(--color-dream),
|
||||
0 0 24px color-mix(in srgb, var(--color-dream) 50%, transparent);
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 1; transform: rotate(0deg); }
|
||||
50% { opacity: 0.75; transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
.dream-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--color-dream), var(--color-synapse));
|
||||
border: 1px solid color-mix(in srgb, var(--color-dream-glow) 60%, transparent);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.18),
|
||||
0 8px 24px -6px rgba(168, 85, 247, 0.55),
|
||||
0 0 48px -10px rgba(168, 85, 247, 0.45);
|
||||
cursor: pointer;
|
||||
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 220ms ease, filter 220ms ease;
|
||||
}
|
||||
|
||||
.dream-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px) scale(1.03);
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.22),
|
||||
0 12px 32px -6px rgba(168, 85, 247, 0.7),
|
||||
0 0 64px -10px rgba(168, 85, 247, 0.55);
|
||||
}
|
||||
|
||||
.dream-button:disabled {
|
||||
cursor: not-allowed;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
.dream-button.is-dreaming {
|
||||
background: linear-gradient(135deg, var(--color-synapse), var(--color-dream));
|
||||
animation: button-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes button-breathe {
|
||||
0%, 100% { box-shadow: 0 8px 24px -6px rgba(168, 85, 247, 0.5), 0 0 48px -10px rgba(168, 85, 247, 0.4); }
|
||||
50% { box-shadow: 0 12px 36px -6px rgba(168, 85, 247, 0.8), 0 0 80px -10px rgba(168, 85, 247, 0.6); }
|
||||
}
|
||||
|
||||
.dream-icon {
|
||||
display: inline-block;
|
||||
animation: twinkle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: white;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed rgba(168, 85, 247, 0.25);
|
||||
}
|
||||
|
||||
.empty-glyph {
|
||||
font-size: 3rem;
|
||||
color: var(--color-dream-glow);
|
||||
opacity: 0.5;
|
||||
text-shadow: 0 0 20px var(--color-dream);
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Scrubber */
|
||||
.scrubber-wrap {
|
||||
position: relative;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.scrubber {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-synapse-glow) 0%,
|
||||
var(--color-dream) 50%,
|
||||
var(--color-recall) 100%
|
||||
);
|
||||
opacity: 0.35;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.scrubber:hover:not(:disabled) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.scrubber::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-dream-glow);
|
||||
border: 2px solid white;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(192, 132, 252, 0.25),
|
||||
0 0 20px var(--color-dream),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
cursor: grab;
|
||||
transition: transform 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.scrubber::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.scrubber::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-dream-glow);
|
||||
border: 2px solid white;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(192, 132, 252, 0.25),
|
||||
0 0 20px var(--color-dream);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.scrubber:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.scrubber-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
color: var(--color-dim);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 220ms ease, transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.tick:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tick:hover:not(:disabled) {
|
||||
color: var(--color-dream-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tick-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
transition: all 280ms ease;
|
||||
}
|
||||
|
||||
.tick.passed .tick-dot {
|
||||
background: var(--color-synapse-glow);
|
||||
border-color: var(--color-synapse-glow);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tick.active .tick-dot {
|
||||
background: var(--color-dream-glow);
|
||||
border-color: white;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(192, 132, 252, 0.3),
|
||||
0 0 14px var(--color-dream);
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.tick.active {
|
||||
color: var(--color-dream-glow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tick-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
color: var(--color-synapse-glow);
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.step-btn:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.step-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Insights */
|
||||
.insights-scroll {
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* Stat cells */
|
||||
.stat-cell {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 2px solid rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-bright);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
387
apps/dashboard/src/routes/(app)/duplicates/+page.svelte
Normal file
387
apps/dashboard/src/routes/(app)/duplicates/+page.svelte
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
<!--
|
||||
Memory Hygiene — Duplicate Detection
|
||||
Dashboard exposure of the `find_duplicates` MCP tool. Threshold slider
|
||||
(0.70-0.95) reruns cosine-similarity clustering. Each cluster renders as a
|
||||
DuplicateCluster with similarity bar, stacked memory cards, and merge /
|
||||
review / dismiss actions.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import DuplicateCluster from '$components/DuplicateCluster.svelte';
|
||||
import { clusterKey, filterByThreshold } from '$components/duplicates-helpers';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
let threshold = $state(0.8);
|
||||
let clusters: Cluster[] = $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.
|
||||
let dismissed = $state(new Set<string>());
|
||||
let loading = $state(true);
|
||||
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', 'aimo3', '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', 'aimo3'],
|
||||
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 AIMO3 submissions. Alternatives considered: sglang (slower cold start), TensorRT-LLM (deployment friction).',
|
||||
nodeType: 'decision',
|
||||
tags: ['vllm', 'aimo3', '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 AIMO3 inference.',
|
||||
nodeType: 'decision',
|
||||
tags: ['vllm', 'aimo3'],
|
||||
retention: 0.72,
|
||||
createdAt: '2026-04-06T10:30:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
similarity: 0.83,
|
||||
suggestedAction: 'review',
|
||||
memories: [
|
||||
{
|
||||
id: 'm-006',
|
||||
content:
|
||||
'Sam prefers to ship one change per Kaggle submission — stacking changes destroyed signal at AIMO3 (30/50 regression from 12 stacked variables).',
|
||||
nodeType: 'pattern',
|
||||
tags: ['kaggle', 'methodology', 'aimo3'],
|
||||
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 12 changes at AIMO3 cost a submission. 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);
|
||||
clusters = res.clusters;
|
||||
// Prune dismissals whose clusters no longer exist — prevents
|
||||
// unbounded growth across sessions and keeps the set honest.
|
||||
const presentKeys = new Set(clusters.map((c) => clusterKey(c.memories)));
|
||||
const pruned = new Set<string>();
|
||||
for (const k of dismissed) if (presentKeys.has(k)) pruned.add(k);
|
||||
dismissed = pruned;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to detect duplicates';
|
||||
clusters = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onThresholdChange() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(detect, 250);
|
||||
}
|
||||
|
||||
function dismissCluster(key: string) {
|
||||
const next = new Set(dismissed);
|
||||
next.add(key);
|
||||
dismissed = next;
|
||||
}
|
||||
|
||||
function mergeCluster(key: string, winnerId: string, loserIds: string[]) {
|
||||
// TODO: POST /api/duplicates/merge { winner, losers } when backend ships.
|
||||
// For now we optimistically dismiss the cluster so the UI reflects the
|
||||
// action and rerun counts stay consistent.
|
||||
console.log('Merge cluster', key, { winnerId, loserIds });
|
||||
dismissCluster(key);
|
||||
}
|
||||
|
||||
const visibleClusters = $derived(
|
||||
clusters
|
||||
.map((c) => ({ c, key: clusterKey(c.memories) }))
|
||||
.filter(({ key }) => !dismissed.has(key))
|
||||
);
|
||||
|
||||
const totalDuplicates = $derived(
|
||||
visibleClusters.reduce((sum, { c }) => sum + c.memories.length, 0)
|
||||
);
|
||||
|
||||
// Cluster overflow: >50 would saturate the scroll. Show a warning and cap.
|
||||
const CLUSTER_RENDER_CAP = 50;
|
||||
const overflowed = $derived(visibleClusters.length > CLUSTER_RENDER_CAP);
|
||||
const renderedClusters = $derived(
|
||||
overflowed ? visibleClusters.slice(0, CLUSTER_RENDER_CAP) : visibleClusters
|
||||
);
|
||||
|
||||
onMount(() => detect());
|
||||
onDestroy(() => clearTimeout(debounceTimer));
|
||||
</script>
|
||||
|
||||
<div class="relative mx-auto max-w-5xl space-y-6 p-6">
|
||||
<!-- Header -->
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-xl font-semibold text-bright">
|
||||
Memory Hygiene — Duplicate Detection
|
||||
</h1>
|
||||
<p class="text-sm text-dim">
|
||||
Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state;
|
||||
losers inherit into the merged node. Dismissed clusters are hidden for this session only.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Controls panel -->
|
||||
<div class="glass-panel flex flex-wrap items-center gap-5 rounded-2xl p-4">
|
||||
<!-- Threshold slider -->
|
||||
<label class="flex flex-1 min-w-64 items-center gap-3 text-xs text-dim">
|
||||
<span class="whitespace-nowrap">Similarity threshold</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.70"
|
||||
max="0.95"
|
||||
step="0.01"
|
||||
bind:value={threshold}
|
||||
oninput={onThresholdChange}
|
||||
class="flex-1 accent-synapse"
|
||||
aria-label="Similarity threshold"
|
||||
/>
|
||||
<span class="w-14 text-right font-mono text-sm text-bright">
|
||||
{(threshold * 100).toFixed(0)}%
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Results pill -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full border border-synapse/20 bg-synapse/10 px-3 py-1.5 text-xs text-text"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="h-2 w-2 animate-pulse rounded-full bg-synapse-glow"></span>
|
||||
<span>Detecting…</span>
|
||||
{:else if error}
|
||||
<span class="h-2 w-2 rounded-full bg-decay"></span>
|
||||
<span class="text-decay">Error</span>
|
||||
{:else}
|
||||
<span class="h-2 w-2 rounded-full bg-synapse-glow"></span>
|
||||
<span>
|
||||
{visibleClusters.length}
|
||||
{visibleClusters.length === 1 ? 'cluster' : 'clusters'},
|
||||
{totalDuplicates} potential duplicate{totalDuplicates === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={detect}
|
||||
disabled={loading}
|
||||
class="rounded-lg bg-white/[0.04] px-3 py-1.5 text-xs text-dim transition hover:bg-white/[0.08] hover:text-text disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-synapse/60"
|
||||
>
|
||||
Rerun
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#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 detect duplicates</div>
|
||||
<div class="max-w-md text-xs text-muted">{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={detect}
|
||||
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="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="glass-subtle h-40 animate-pulse rounded-2xl"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if visibleClusters.length === 0}
|
||||
<div
|
||||
class="glass-panel flex flex-col items-center gap-2 rounded-2xl p-12 text-center"
|
||||
>
|
||||
<div class="text-3xl">·</div>
|
||||
<div class="text-sm font-medium text-bright">
|
||||
No duplicates found above threshold.
|
||||
</div>
|
||||
<div class="text-xs text-muted">Memory is clean.</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#if overflowed}
|
||||
<div
|
||||
class="glass-subtle rounded-xl border border-warning/30 bg-warning/5 px-4 py-2 text-xs text-dim"
|
||||
>
|
||||
Showing first {CLUSTER_RENDER_CAP} of {visibleClusters.length} clusters. Raise the
|
||||
threshold to narrow results.
|
||||
</div>
|
||||
{/if}
|
||||
{#each renderedClusters as { c, key } (key)}
|
||||
<div class="animate-[fadeSlide_0.35s_ease-out_both]">
|
||||
<DuplicateCluster
|
||||
similarity={c.similarity}
|
||||
memories={c.memories}
|
||||
suggestedAction={c.suggestedAction}
|
||||
onDismiss={() => dismissCluster(key)}
|
||||
onMerge={(winnerId, loserIds) => mergeCluster(key, winnerId, loserIds)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
330
apps/dashboard/src/routes/(app)/importance/+page.svelte
Normal file
330
apps/dashboard/src/routes/(app)/importance/+page.svelte
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { api } from '$stores/api';
|
||||
import type { ImportanceScore, Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import ImportanceRadar from '$components/ImportanceRadar.svelte';
|
||||
|
||||
// ── Section 1: Test Importance ───────────────────────────────────────────
|
||||
let content = $state('');
|
||||
let score: ImportanceScore | null = $state(null);
|
||||
let scoring = $state(false);
|
||||
let scoreError: string | null = $state(null);
|
||||
|
||||
// Keyed radar remount — we flip the key each time a new score lands so the
|
||||
// onMount grow-from-center animation re-fires instead of just mutating props.
|
||||
let radarKey = $state(0);
|
||||
|
||||
async function scoreContent() {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || scoring) return;
|
||||
scoring = true;
|
||||
scoreError = null;
|
||||
try {
|
||||
score = await api.importance(trimmed);
|
||||
radarKey++;
|
||||
} catch (e) {
|
||||
scoreError = e instanceof Error ? e.message : String(e);
|
||||
score = null;
|
||||
} finally {
|
||||
scoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Cmd/Ctrl+Enter submits so the power-user flow isn't "click the button".
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
scoreContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Which channel contributed the most to the composite? Drives the "why"
|
||||
// blurb under the recommendation. Uses the same weights ImportanceSignals
|
||||
// applies server-side (novelty 0.25 / arousal 0.30 / reward 0.25 / attention 0.20)
|
||||
// so the explanation lines up with the composite.
|
||||
const CHANNEL_WEIGHTS = { novelty: 0.25, arousal: 0.3, reward: 0.25, attention: 0.2 } as const;
|
||||
type ChannelKey = keyof typeof CHANNEL_WEIGHTS;
|
||||
|
||||
const CHANNEL_BLURBS: Record<ChannelKey, { high: string; low: string }> = {
|
||||
novelty: {
|
||||
high: 'new information not already in your graph',
|
||||
low: 'overlaps heavily with what you already know'
|
||||
},
|
||||
arousal: {
|
||||
high: 'emotionally salient — decisions, bugs, or discoveries stick',
|
||||
low: 'neutral tone, no strong affect signal'
|
||||
},
|
||||
reward: {
|
||||
high: 'high reward value — preferences, wins, or solutions you will revisit',
|
||||
low: 'low reward value — transient or incidental detail'
|
||||
},
|
||||
attention: {
|
||||
high: 'strong attentional markers (imperatives, questions, urgency)',
|
||||
low: 'passive phrasing, no clear attentional hook'
|
||||
}
|
||||
};
|
||||
|
||||
let topChannel = $derived.by<{ key: ChannelKey; contribution: number } | null>(() => {
|
||||
if (!score) return null;
|
||||
const ranked = (Object.keys(CHANNEL_WEIGHTS) as ChannelKey[])
|
||||
.map((k) => ({ key: k, contribution: score!.channels[k] * CHANNEL_WEIGHTS[k] }))
|
||||
.sort((a, b) => b.contribution - a.contribution);
|
||||
return ranked[0];
|
||||
});
|
||||
|
||||
let weakestChannel = $derived.by<ChannelKey | null>(() => {
|
||||
if (!score) return null;
|
||||
return (Object.keys(CHANNEL_WEIGHTS) as ChannelKey[])
|
||||
.slice()
|
||||
.sort((a, b) => score!.channels[a] - score!.channels[b])[0];
|
||||
});
|
||||
|
||||
// ── Section 2: Top Important Memories This Week ──────────────────────────
|
||||
// The Memory response does NOT include the per-memory importance channels,
|
||||
// so we approximate a "trending importance" proxy from the FSRS state we
|
||||
// DO have: retention strength × (1 + reviewCount) × recency-boost. Clients
|
||||
// who want the true composite would need the backend to include channels.
|
||||
// TODO: backend should include channels on Memory response directly
|
||||
let memories: Memory[] = $state([]);
|
||||
let loadingMemories = $state(true);
|
||||
// Per-memory radar channels, fetched lazily via api.importance(content).
|
||||
// Keyed by memory.id. Until populated, mini-radars render with zeroed props.
|
||||
let perMemoryScores: Record<string, ImportanceScore['channels']> = $state({});
|
||||
|
||||
function importanceProxy(m: Memory): number {
|
||||
// retentionStrength × log(1 + reviewCount) / age_days.
|
||||
// Heavy short-term bias so the "this week" framing actually holds.
|
||||
const ageDays = Math.max(
|
||||
1,
|
||||
(Date.now() - new Date(m.createdAt).getTime()) / 86_400_000
|
||||
);
|
||||
const reviews = m.reviewCount ?? 0;
|
||||
const recencyBoost = 1 / Math.pow(ageDays, 0.5);
|
||||
return m.retentionStrength * Math.log1p(reviews + 1) * recencyBoost;
|
||||
}
|
||||
|
||||
async function loadTrending() {
|
||||
loadingMemories = true;
|
||||
try {
|
||||
const res = await api.memories.list({ limit: '20' });
|
||||
// Sort client-side by our proxy, keep top 20.
|
||||
const ranked = res.memories
|
||||
.slice()
|
||||
.sort((a, b) => importanceProxy(b) - importanceProxy(a))
|
||||
.slice(0, 20);
|
||||
memories = ranked;
|
||||
// Lazily score each one so the mini-radars aren't all zeros. We fan
|
||||
// these out in parallel but don't await them before painting — the
|
||||
// list renders immediately and radars fill in as results arrive.
|
||||
memories.forEach(async (m) => {
|
||||
try {
|
||||
const s = await api.importance(m.content);
|
||||
perMemoryScores[m.id] = s.channels;
|
||||
} catch {
|
||||
// swallow — per-memory score is cosmetic, list still works
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
memories = [];
|
||||
} finally {
|
||||
loadingMemories = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadTrending);
|
||||
|
||||
function openMemory(id: string) {
|
||||
// The memories page doesn't support deep-linking to a specific memory
|
||||
// yet; navigate there and let the user scroll. base is '/dashboard'.
|
||||
goto(`${base}/memories`);
|
||||
void id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-5xl mx-auto space-y-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl text-bright font-semibold">Importance Radar</h1>
|
||||
<p class="text-sm text-dim mt-1">
|
||||
4-channel importance model: Novelty · Arousal · Reward · Attention
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Section 1: Test Importance ─────────────────────────────────────── -->
|
||||
<section class="glass-panel rounded-2xl p-6 space-y-5">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-bright uppercase tracking-wider">Test Importance</h2>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Paste any content below. Vestige scores it across 4 channels and
|
||||
decides whether it is worth saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-[1fr_auto] gap-5 items-start">
|
||||
<div class="space-y-3">
|
||||
<textarea
|
||||
bind:value={content}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Type some content above to score its importance."
|
||||
class="w-full min-h-40 px-4 py-3 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
|
||||
placeholder:text-muted focus:outline-none focus:border-synapse/40 focus:ring-1 focus:ring-synapse/20
|
||||
transition backdrop-blur-sm resize-y font-mono"
|
||||
></textarea>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={scoreContent}
|
||||
disabled={scoring || !content.trim()}
|
||||
class="px-4 py-2 bg-synapse/20 text-synapse-glow text-sm rounded-xl border border-synapse/30
|
||||
hover:bg-synapse/30 hover:border-synapse/50 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{scoring ? 'Scoring…' : 'Score Importance'}
|
||||
</button>
|
||||
<span class="text-xs text-muted">⌘/Ctrl + Enter</span>
|
||||
{#if scoreError}
|
||||
<span class="text-xs text-decay">{scoreError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radar + composite readout -->
|
||||
<div class="flex flex-col items-center gap-4 md:min-w-[340px]">
|
||||
{#if score}
|
||||
<div class="text-center">
|
||||
<div class="text-[10px] uppercase tracking-widest text-muted">Composite</div>
|
||||
<div class="text-5xl font-semibold text-bright leading-none mt-1">
|
||||
{(score.composite * 100).toFixed(0)}<span class="text-xl text-dim">%</span>
|
||||
</div>
|
||||
</div>
|
||||
{#key radarKey}
|
||||
<ImportanceRadar
|
||||
novelty={score.channels.novelty}
|
||||
arousal={score.channels.arousal}
|
||||
reward={score.channels.reward}
|
||||
attention={score.channels.attention}
|
||||
size="lg"
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<!-- Recommendation -->
|
||||
{#if score.composite > 0.6}
|
||||
<div class="w-full text-center space-y-1">
|
||||
<div class="text-lg font-semibold text-recall">✓ Save</div>
|
||||
<p class="text-xs text-dim leading-relaxed">
|
||||
Composite {(score.composite * 100).toFixed(0)}% > 60% threshold.
|
||||
{#if topChannel}
|
||||
Driven by <span class="text-bright">{topChannel.key}</span> — {CHANNEL_BLURBS[topChannel.key].high}.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full text-center space-y-1">
|
||||
<div class="text-lg font-semibold text-decay">⨯ Skip</div>
|
||||
<p class="text-xs text-dim leading-relaxed">
|
||||
Composite {(score.composite * 100).toFixed(0)}% < 60% threshold.
|
||||
{#if weakestChannel}
|
||||
Weakest channel: <span class="text-bright">{weakestChannel}</span> — {CHANNEL_BLURBS[weakestChannel].low}.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center min-h-[320px] w-full text-center px-4">
|
||||
<div class="text-3xl text-muted mb-3">◫</div>
|
||||
<p class="text-sm text-dim">Type some content above to score its importance.</p>
|
||||
<p class="text-xs text-muted mt-2 max-w-xs">
|
||||
Composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention.
|
||||
Threshold for save: 60%.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Section 2: Top Important Memories This Week ────────────────────── -->
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-bright uppercase tracking-wider">
|
||||
Top Important Memories This Week
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
Ranked by retention × reviews ÷ age. Click any card to open it.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadTrending}
|
||||
class="text-xs text-muted hover:text-text transition"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loadingMemories}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-28 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if memories.length === 0}
|
||||
<div class="text-center py-12 text-dim">
|
||||
<p class="text-sm">No memories yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each memories as memory (memory.id)}
|
||||
{@const ch = perMemoryScores[memory.id]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openMemory(memory.id)}
|
||||
class="text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04] hover:border-synapse/30
|
||||
transition-all duration-200 flex items-start gap-4"
|
||||
>
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}"
|
||||
></span>
|
||||
<span class="text-xs text-dim">{memory.nodeType}</span>
|
||||
<span class="text-xs text-muted">·</span>
|
||||
<span class="text-xs text-muted">
|
||||
{(memory.retentionStrength * 100).toFixed(0)}% retention
|
||||
</span>
|
||||
{#if memory.reviewCount}
|
||||
<span class="text-xs text-muted">·</span>
|
||||
<span class="text-xs text-muted">{memory.reviewCount} reviews</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text leading-relaxed line-clamp-3">
|
||||
{memory.content}
|
||||
</p>
|
||||
{#if memory.tags.length > 0}
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{#each memory.tags.slice(0, 4) as tag}
|
||||
<span class="text-[10px] px-1.5 py-0.5 bg-white/[0.04] rounded text-muted">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<ImportanceRadar
|
||||
novelty={ch?.novelty ?? 0}
|
||||
arousal={ch?.arousal ?? 0}
|
||||
reward={ch?.reward ?? 0}
|
||||
attention={ch?.attention ?? 0}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
import { NODE_TYPE_COLORS } from '$types';
|
||||
import MemoryAuditTrail from '$lib/components/MemoryAuditTrail.svelte';
|
||||
|
||||
let memories: Memory[] = $state([]);
|
||||
let searchQuery = $state('');
|
||||
|
|
@ -11,6 +12,9 @@
|
|||
let minRetention = $state(0);
|
||||
let loading = $state(true);
|
||||
let selectedMemory: Memory | null = $state(null);
|
||||
// Which inner tab of the expanded card is active. Keyed by memory id so
|
||||
// switching between cards remembers each one's last view independently.
|
||||
let expandedTab: Record<string, 'content' | 'audit'> = $state({});
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
onMount(() => loadMemories());
|
||||
|
|
@ -116,13 +120,45 @@
|
|||
</div>
|
||||
|
||||
{#if selectedMemory?.id === memory.id}
|
||||
{@const activeTab = expandedTab[memory.id] ?? 'content'}
|
||||
<div class="mt-4 pt-4 border-t border-synapse/10 space-y-3">
|
||||
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
|
||||
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
|
||||
<div>Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%</div>
|
||||
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
|
||||
<!-- Inner tab switcher: Content (default) vs Audit Trail. -->
|
||||
<div class="flex gap-1 text-[11px] uppercase tracking-wider">
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'content'; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'content'; } }}
|
||||
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
|
||||
{activeTab === 'content' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
|
||||
>Content</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'audit'; }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'audit'; } }}
|
||||
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
|
||||
{activeTab === 'audit' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
|
||||
>Audit Trail</span>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'content'}
|
||||
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
|
||||
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
|
||||
<div>Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%</div>
|
||||
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MemoryAuditTrail memoryId={memory.id} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
|
||||
|
|
|
|||
567
apps/dashboard/src/routes/(app)/patterns/+page.svelte
Normal file
567
apps/dashboard/src/routes/(app)/patterns/+page.svelte
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
<!--
|
||||
Cross-Project Intelligence — Pattern Transfer Heatmap
|
||||
Dashboard exposure of the CrossProjectLearner backend state. Visualizes
|
||||
which coding patterns were learned in one project and reused in another,
|
||||
across all six tracked categories (ErrorHandling, AsyncConcurrency, Testing,
|
||||
Architecture, Performance, Security).
|
||||
|
||||
Category tabs filter the pattern set. Heatmap cell click filters the
|
||||
"Top Transferred Patterns" sidebar to a specific origin → destination pair.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import PatternTransferHeatmap from '$components/PatternTransferHeatmap.svelte';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
const CATEGORIES: readonly Category[] = [
|
||||
'ErrorHandling',
|
||||
'AsyncConcurrency',
|
||||
'Testing',
|
||||
'Architecture',
|
||||
'Performance',
|
||||
'Security'
|
||||
] as const;
|
||||
|
||||
const CATEGORY_COLORS: Record<Category, string> = {
|
||||
ErrorHandling: 'var(--color-decay)',
|
||||
AsyncConcurrency: 'var(--color-synapse-glow)',
|
||||
Testing: 'var(--color-recall)',
|
||||
Architecture: 'var(--color-dream-glow)',
|
||||
Performance: 'var(--color-warning)',
|
||||
Security: 'var(--color-node-pattern)'
|
||||
};
|
||||
|
||||
let activeCategory = $state<'All' | Category>('All');
|
||||
let data = $state<CrossProjectResponse>({ 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',
|
||||
'nullgaze',
|
||||
'injeranet',
|
||||
'nemotron',
|
||||
'orbit-wars',
|
||||
'nightvision',
|
||||
'aimo3'
|
||||
];
|
||||
|
||||
const patterns: Pattern[] = [
|
||||
// ErrorHandling — widely transferred
|
||||
{
|
||||
name: 'Result<T, E> with thiserror context',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'vestige',
|
||||
transferred_to: ['nullgaze', 'injeranet', 'nemotron', 'nightvision'],
|
||||
transfer_count: 4,
|
||||
last_used: '2026-04-18T14:22:00Z',
|
||||
confidence: 0.94
|
||||
},
|
||||
{
|
||||
name: 'Axum error middleware with tower-http',
|
||||
category: 'ErrorHandling',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige', 'nightvision'],
|
||||
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', 'injeranet', 'nightvision'],
|
||||
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: 'aimo3',
|
||||
transferred_to: ['nemotron'],
|
||||
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: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-14T16:42:00Z',
|
||||
confidence: 0.91
|
||||
},
|
||||
{
|
||||
name: 'tokio::select! for cancellation propagation',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'injeranet',
|
||||
transferred_to: ['vestige', 'nightvision'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-19T08:05:00Z',
|
||||
confidence: 0.86
|
||||
},
|
||||
{
|
||||
name: 'Bounded mpsc channel with backpressure',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'injeranet',
|
||||
transferred_to: ['vestige', 'nullgaze'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-12T13:18:00Z',
|
||||
confidence: 0.83
|
||||
},
|
||||
{
|
||||
name: 'asyncio.gather with return_exceptions',
|
||||
category: 'AsyncConcurrency',
|
||||
origin_project: 'nemotron',
|
||||
transferred_to: ['aimo3'],
|
||||
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: ['nullgaze', 'injeranet'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-11T10:22:00Z',
|
||||
confidence: 0.89
|
||||
},
|
||||
{
|
||||
name: 'Snapshot testing with insta',
|
||||
category: 'Testing',
|
||||
origin_project: 'nullgaze',
|
||||
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: ['nullgaze', 'injeranet'],
|
||||
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: 'aimo3',
|
||||
transferred_to: ['nemotron', 'orbit-wars'],
|
||||
transfer_count: 2,
|
||||
last_used: '2026-04-20T07:15:00Z',
|
||||
confidence: 0.95
|
||||
},
|
||||
{
|
||||
name: 'Kaggle pre-flight Input-panel screenshot',
|
||||
category: 'Testing',
|
||||
origin_project: 'aimo3',
|
||||
transferred_to: ['nemotron', 'orbit-wars'],
|
||||
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: ['nullgaze', 'nightvision'],
|
||||
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: ['nullgaze', 'nightvision', 'injeranet'],
|
||||
transfer_count: 3,
|
||||
last_used: '2026-04-20T09:00:00Z',
|
||||
confidence: 0.9
|
||||
},
|
||||
{
|
||||
name: 'Tauri 2 + Rust/Axum sidecar',
|
||||
category: 'Architecture',
|
||||
origin_project: 'injeranet',
|
||||
transferred_to: ['nightvision'],
|
||||
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: ['injeranet'],
|
||||
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: ['nullgaze'],
|
||||
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: ['nullgaze', 'injeranet', 'nightvision'],
|
||||
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: 'aimo3',
|
||||
transferred_to: ['nemotron'],
|
||||
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: ['nullgaze'],
|
||||
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: ['nullgaze', 'injeranet', 'nightvision'],
|
||||
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: ['nullgaze'],
|
||||
transfer_count: 1,
|
||||
last_used: '2026-04-10T13:40:00Z',
|
||||
confidence: 0.89
|
||||
},
|
||||
{
|
||||
name: '664-pattern secret scanner',
|
||||
category: 'Security',
|
||||
origin_project: 'nullgaze',
|
||||
transferred_to: ['vestige', 'nightvision', 'injeranet'],
|
||||
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: 'nullgaze',
|
||||
transferred_to: ['nightvision'],
|
||||
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();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load pattern transfers';
|
||||
data = { projects: [], patterns: [] };
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => load());
|
||||
|
||||
// Filter by active category first — this drives both the heatmap and sidebar.
|
||||
const categoryFiltered = $derived(
|
||||
activeCategory === 'All'
|
||||
? data.patterns
|
||||
: data.patterns.filter((p) => p.category === activeCategory)
|
||||
);
|
||||
|
||||
// Sidebar list: if a cell is selected, show only A → B; else show all (top-N
|
||||
// by transfer_count). Still respects active category via categoryFiltered.
|
||||
const sidebarPatterns = $derived.by(() => {
|
||||
const list = selectedCell
|
||||
? categoryFiltered.filter(
|
||||
(p) =>
|
||||
p.origin_project === selectedCell!.from &&
|
||||
p.transferred_to.includes(selectedCell!.to)
|
||||
)
|
||||
: categoryFiltered;
|
||||
return [...list].sort((a, b) => b.transfer_count - a.transfer_count);
|
||||
});
|
||||
|
||||
// Stats footer
|
||||
const totalTransfers = $derived(
|
||||
categoryFiltered.reduce((sum, p) => sum + p.transferred_to.length, 0)
|
||||
);
|
||||
const projectCount = $derived(data.projects.length);
|
||||
const patternCount = $derived(categoryFiltered.length);
|
||||
|
||||
function selectCategory(c: 'All' | Category) {
|
||||
activeCategory = c;
|
||||
selectedCell = null; // clear cell filter when switching category
|
||||
}
|
||||
|
||||
function onCellClick(from: string, to: string) {
|
||||
if (selectedCell && selectedCell.from === from && selectedCell.to === to) {
|
||||
selectedCell = null;
|
||||
} else {
|
||||
selectedCell = { from, to };
|
||||
}
|
||||
}
|
||||
|
||||
function clearCellFilter() {
|
||||
selectedCell = null;
|
||||
}
|
||||
|
||||
function relativeDate(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const days = Math.floor((now - then) / 86_400_000);
|
||||
if (days <= 0) return 'today';
|
||||
if (days === 1) return '1d ago';
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months}mo ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative mx-auto max-w-7xl space-y-6 p-6">
|
||||
<!-- Header -->
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-xl font-semibold text-bright">Cross-Project Intelligence</h1>
|
||||
<p class="text-sm text-dim">Patterns learned here, applied there.</p>
|
||||
</header>
|
||||
|
||||
<!-- Category tabs -->
|
||||
<div class="glass-panel flex flex-wrap items-center gap-1.5 rounded-2xl p-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCategory('All')}
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory === 'All'
|
||||
? 'bg-synapse/25 text-synapse-glow'
|
||||
: 'text-dim hover:bg-white/[0.04] hover:text-text'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each CATEGORIES as cat (cat)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectCategory(cat)}
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition {activeCategory ===
|
||||
cat
|
||||
? 'bg-synapse/25 text-synapse-glow'
|
||||
: 'text-dim hover:bg-white/[0.04] hover:text-text'}"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
style="background: {CATEGORY_COLORS[cat]}"
|
||||
></span>
|
||||
{cat}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#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 pattern transfers</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"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div class="glass-subtle h-[520px] animate-pulse rounded-2xl"></div>
|
||||
<div class="glass-subtle h-[520px] animate-pulse rounded-2xl"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Main grid: heatmap (70%) + sidebar -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<!-- Heatmap column -->
|
||||
<div class="space-y-4">
|
||||
<PatternTransferHeatmap
|
||||
projects={data.projects}
|
||||
patterns={categoryFiltered}
|
||||
{selectedCell}
|
||||
{onCellClick}
|
||||
/>
|
||||
|
||||
{#if selectedCell}
|
||||
<div
|
||||
class="glass-subtle flex items-center justify-between rounded-xl px-4 py-2.5 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted">Filtered to</span>
|
||||
<span class="font-mono text-bright">{selectedCell.from}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="font-mono text-bright">{selectedCell.to}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearCellFilter}
|
||||
class="rounded-md bg-white/[0.04] px-2 py-1 text-dim transition hover:bg-white/[0.08] hover:text-text"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Top Transferred Patterns -->
|
||||
<aside class="glass-panel flex flex-col rounded-2xl p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-bright">Top Transferred Patterns</h2>
|
||||
<span class="text-[11px] text-muted">
|
||||
{sidebarPatterns.length}
|
||||
{sidebarPatterns.length === 1 ? 'pattern' : 'patterns'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if sidebarPatterns.length === 0}
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<div class="text-xs font-medium text-dim">No matching patterns</div>
|
||||
<div class="max-w-[220px] text-[11px] text-muted">
|
||||
{selectedCell
|
||||
? 'No patterns transferred from this origin to this destination.'
|
||||
: 'No patterns in this category.'}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="flex-1 space-y-2 overflow-y-auto pr-1" style="max-height: 560px;">
|
||||
{#each sidebarPatterns as p (p.name)}
|
||||
<li
|
||||
class="rounded-lg border border-synapse/5 bg-white/[0.02] p-3 transition hover:border-synapse/20 hover:bg-white/[0.04]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="truncate text-xs font-medium text-bright" title={p.name}>
|
||||
{p.name}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style="border-color: {CATEGORY_COLORS[
|
||||
p.category
|
||||
]}66; color: {CATEGORY_COLORS[p.category]}; background: {CATEGORY_COLORS[
|
||||
p.category
|
||||
]}1a;"
|
||||
>
|
||||
{p.category}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted">{relativeDate(p.last_used)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-dim">
|
||||
<span class="font-mono text-text">{p.origin_project}</span>
|
||||
<span class="text-synapse-glow">→</span>
|
||||
<span class="text-muted">
|
||||
{p.transferred_to.length}
|
||||
{p.transferred_to.length === 1 ? 'project' : 'projects'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 flex-col items-end gap-1">
|
||||
<span
|
||||
class="rounded-full bg-synapse/15 px-2 py-0.5 text-xs font-semibold text-synapse-glow"
|
||||
>
|
||||
{p.transfer_count}
|
||||
</span>
|
||||
<span class="text-[10px] text-muted">
|
||||
{(p.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Stats footer -->
|
||||
<footer
|
||||
class="glass-subtle flex flex-wrap items-center justify-between gap-3 rounded-xl px-4 py-3 text-xs text-dim"
|
||||
>
|
||||
<div>
|
||||
<span class="font-semibold text-bright">{patternCount}</span>
|
||||
pattern{patternCount === 1 ? '' : 's'} across
|
||||
<span class="font-semibold text-bright">{projectCount}</span>
|
||||
project{projectCount === 1 ? '' : 's'},
|
||||
<span class="font-semibold text-bright">{totalTransfers}</span>
|
||||
total transfer{totalTransfers === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
{activeCategory === 'All' ? 'All categories' : activeCategory}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
701
apps/dashboard/src/routes/(app)/reasoning/+page.svelte
Normal file
701
apps/dashboard/src/routes/(app)/reasoning/+page.svelte
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import ReasoningChain from '$components/ReasoningChain.svelte';
|
||||
import EvidenceCard from '$components/EvidenceCard.svelte';
|
||||
import {
|
||||
confidenceColor,
|
||||
confidenceLabel,
|
||||
} from '$components/reasoning-helpers';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Local type — mirrors the shape deep_reference will return once
|
||||
// /api/deep-reference lands. See backend MCP tool `deep_reference`.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
type Role = 'primary' | 'supporting' | 'contradicting' | 'superseded';
|
||||
|
||||
interface EvidenceEntry {
|
||||
id: string;
|
||||
trust: number; // 0-1
|
||||
date: string; // ISO
|
||||
role: Role;
|
||||
preview: string;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
interface RecommendedAnswer {
|
||||
answer_preview: string;
|
||||
memory_id: string;
|
||||
trust_score: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface ContradictionPair {
|
||||
a_id: string;
|
||||
b_id: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface SupersessionEntry {
|
||||
old_id: string;
|
||||
new_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface EvolutionPoint {
|
||||
date: string;
|
||||
summary: string;
|
||||
trust: number;
|
||||
}
|
||||
|
||||
interface DeepReferenceResponse {
|
||||
intent: string;
|
||||
reasoning: string;
|
||||
recommended: RecommendedAnswer;
|
||||
evidence: EvidenceEntry[];
|
||||
contradictions: ContradictionPair[];
|
||||
superseded: SupersessionEntry[];
|
||||
evolution: EvolutionPoint[];
|
||||
related_insights: string[];
|
||||
confidence: number; // 0-100
|
||||
memoriesAnalyzed: number;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// TODO: swap for api.deepReference when backend endpoint lands
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
async function deepReferenceFetch(query: string): Promise<DeepReferenceResponse> {
|
||||
// Try real search to supply realistic previews from your actual corpus
|
||||
let realEvidence: EvidenceEntry[] = [];
|
||||
let memoriesAnalyzed = 24;
|
||||
try {
|
||||
const res = await api.search(query, 6);
|
||||
memoriesAnalyzed = Math.max(memoriesAnalyzed, res.total);
|
||||
realEvidence = res.results.map((m, i) => ({
|
||||
id: m.id,
|
||||
trust: Math.max(0.15, Math.min(0.98, m.retentionStrength * 0.95 + (i === 0 ? 0.05 : 0))),
|
||||
date: m.updatedAt ?? m.createdAt,
|
||||
role:
|
||||
i === 0
|
||||
? ('primary' as Role)
|
||||
: i === 1 && Math.random() < 0.35
|
||||
? ('contradicting' as Role)
|
||||
: i > 4
|
||||
? ('superseded' as Role)
|
||||
: ('supporting' as Role),
|
||||
preview: m.content.slice(0, 280),
|
||||
nodeType: m.nodeType
|
||||
}));
|
||||
} catch {
|
||||
// Fall through to synthetic
|
||||
}
|
||||
|
||||
// Stagger — feels like real work
|
||||
await new Promise((r) => setTimeout(r, 380));
|
||||
|
||||
const evidence: EvidenceEntry[] =
|
||||
realEvidence.length > 0
|
||||
? realEvidence
|
||||
: [
|
||||
{
|
||||
id: 'a1b2c3d4-0001',
|
||||
trust: 0.91,
|
||||
date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 12).toISOString(),
|
||||
role: 'primary',
|
||||
preview:
|
||||
'Dev server runs on port 3002 after the March migration from 3000. vestige-mcp env var VESTIGE_DASHBOARD_PORT overrides the default.',
|
||||
nodeType: 'fact'
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0002',
|
||||
trust: 0.74,
|
||||
date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 40).toISOString(),
|
||||
role: 'supporting',
|
||||
preview:
|
||||
'Dashboard vite config proxies /api to 127.0.0.1:3928. The dashboard itself serves on VESTIGE_DASHBOARD_PORT which defaults to 3927.',
|
||||
nodeType: 'pattern'
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0003',
|
||||
trust: 0.42,
|
||||
date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString(),
|
||||
role: 'contradicting',
|
||||
preview:
|
||||
'Dev server on port 3000 — early January note before the cognitive sandwich architecture. Marked outdated by subsequent fix.',
|
||||
nodeType: 'note'
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0004',
|
||||
trust: 0.66,
|
||||
date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(),
|
||||
role: 'supporting',
|
||||
preview:
|
||||
'SvelteKit base path set to /dashboard in svelte.config.js. All page routes live under that base.',
|
||||
nodeType: 'decision'
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0005',
|
||||
trust: 0.22,
|
||||
date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 200).toISOString(),
|
||||
role: 'superseded',
|
||||
preview: 'Legacy note: dashboard was a separate Next.js app on port 4000 (pre-monorepo).',
|
||||
nodeType: 'note'
|
||||
}
|
||||
];
|
||||
|
||||
const contradictions: ContradictionPair[] =
|
||||
evidence.filter((e) => e.role === 'contradicting').length > 0
|
||||
? [
|
||||
{
|
||||
a_id: evidence[0].id,
|
||||
b_id: evidence.find((e) => e.role === 'contradicting')!.id,
|
||||
summary:
|
||||
'Primary memory asserts the newer value; contradicting memory predates the change and has lower trust.'
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
const superseded: SupersessionEntry[] = evidence
|
||||
.filter((e) => e.role === 'superseded')
|
||||
.map((e) => ({
|
||||
old_id: e.id,
|
||||
new_id: evidence[0].id,
|
||||
reason: 'Superseded by newer memory with higher FSRS trust score.'
|
||||
}));
|
||||
|
||||
const primary = evidence[0];
|
||||
const confidence = Math.round(
|
||||
Math.max(0, Math.min(100, primary.trust * 100 - contradictions.length * 6))
|
||||
);
|
||||
|
||||
const reasoning =
|
||||
`PRIMARY FINDING (trust ${Math.round(primary.trust * 100)}%): ${primary.preview.slice(0, 140)}` +
|
||||
(contradictions.length
|
||||
? `\n\nCONTRADICTION DETECTED with memory ${contradictions[0].b_id.slice(0, 8)} — resolve by trusting the higher-FSRS source.`
|
||||
: `\n\nSUPPORTED BY ${Math.max(0, evidence.length - 1 - contradictions.length)} additional memor${evidence.length - 1 - contradictions.length === 1 ? 'y' : 'ies'} with no contradictions.`) +
|
||||
`\n\nOVERALL CONFIDENCE: ${confidence}%`;
|
||||
|
||||
return {
|
||||
intent: 'FactCheck',
|
||||
reasoning,
|
||||
recommended: {
|
||||
answer_preview: primary.preview,
|
||||
memory_id: primary.id,
|
||||
trust_score: primary.trust,
|
||||
date: primary.date
|
||||
},
|
||||
evidence,
|
||||
contradictions,
|
||||
superseded,
|
||||
evolution: evidence
|
||||
.slice()
|
||||
.sort((a, b) => +new Date(a.date) - +new Date(b.date))
|
||||
.map((e) => ({
|
||||
date: e.date,
|
||||
summary: e.preview.slice(0, 80),
|
||||
trust: e.trust
|
||||
})),
|
||||
related_insights: [
|
||||
'Port configuration is environment-driven — check VESTIGE_DASHBOARD_PORT before assuming.',
|
||||
'Frontend and backend ports are decoupled (dashboard vs. HTTP API).'
|
||||
],
|
||||
confidence,
|
||||
memoriesAnalyzed
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// State
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
let query = $state('');
|
||||
let loading = $state(false);
|
||||
let response: DeepReferenceResponse | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
let askInputEl: HTMLInputElement | null = $state(null);
|
||||
|
||||
// Evidence DOM refs for SVG arc drawing between contradicting pairs
|
||||
let evidenceGridEl: HTMLDivElement | null = $state(null);
|
||||
let arcs: { x1: number; y1: number; x2: number; y2: number }[] = $state([]);
|
||||
|
||||
async function ask() {
|
||||
const q = query.trim();
|
||||
if (!q || loading) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
response = null;
|
||||
arcs = [];
|
||||
try {
|
||||
response = await deepReferenceFetch(q);
|
||||
// After DOM paints the evidence cards, measure & draw arcs
|
||||
requestAnimationFrame(() => requestAnimationFrame(measureArcs));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function measureArcs() {
|
||||
if (!response || !evidenceGridEl || response.contradictions.length === 0) {
|
||||
arcs = [];
|
||||
return;
|
||||
}
|
||||
const gridRect = evidenceGridEl.getBoundingClientRect();
|
||||
const next: typeof arcs = [];
|
||||
for (const c of response.contradictions) {
|
||||
const a = evidenceGridEl.querySelector<HTMLElement>(`[data-evidence-id="${c.a_id}"]`);
|
||||
const b = evidenceGridEl.querySelector<HTMLElement>(`[data-evidence-id="${c.b_id}"]`);
|
||||
if (!a || !b) continue;
|
||||
const ar = a.getBoundingClientRect();
|
||||
const br = b.getBoundingClientRect();
|
||||
next.push({
|
||||
x1: ar.left - gridRect.left + ar.width / 2,
|
||||
y1: ar.top - gridRect.top + ar.height / 2,
|
||||
x2: br.left - gridRect.left + br.width / 2,
|
||||
y2: br.top - gridRect.top + br.height / 2
|
||||
});
|
||||
}
|
||||
arcs = next;
|
||||
}
|
||||
|
||||
function handleGlobalKey(e: KeyboardEvent) {
|
||||
// Cmd/Ctrl + K focuses the ask box
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
askInputEl?.focus();
|
||||
askInputEl?.select();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
askInputEl?.focus();
|
||||
window.addEventListener('keydown', handleGlobalKey);
|
||||
window.addEventListener('resize', measureArcs);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalKey);
|
||||
window.removeEventListener('resize', measureArcs);
|
||||
};
|
||||
});
|
||||
|
||||
const exampleQueries = [
|
||||
'What port does the dev server use?',
|
||||
'Should I enable prefix caching with vLLM?',
|
||||
'Why did the AIMO3 submission score 36/50?',
|
||||
'How does FSRS-6 trust scoring work?'
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reasoning Theater · Vestige</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl text-dream-glow">❖</span>
|
||||
<h1 class="text-xl text-bright font-semibold">Reasoning Theater</h1>
|
||||
<span class="px-2 py-0.5 rounded bg-dream/15 border border-dream/30 text-[10px] text-dream-glow uppercase tracking-wider">
|
||||
deep_reference
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-dim max-w-2xl">
|
||||
Watch Vestige reason. Your query runs the 8-stage cognitive pipeline — broad retrieval,
|
||||
spreading activation, FSRS trust scoring, intent classification, supersession, contradiction
|
||||
analysis, relation assessment, template reasoning — and returns a pre-built answer with
|
||||
trust-scored evidence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cmd+K Ask Palette -->
|
||||
<div class="glass-panel rounded-2xl p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-synapse-glow">◎</span>
|
||||
<input
|
||||
bind:this={askInputEl}
|
||||
type="text"
|
||||
bind:value={query}
|
||||
onkeydown={(e) => e.key === 'Enter' && ask()}
|
||||
placeholder="Ask your memory anything..."
|
||||
class="flex-1 bg-transparent text-bright text-lg placeholder:text-muted focus:outline-none font-mono"
|
||||
/>
|
||||
<kbd class="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-white/[0.04] border border-synapse/15 text-[10px] text-dim font-mono">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
<button
|
||||
onclick={ask}
|
||||
disabled={!query.trim() || loading}
|
||||
class="px-4 py-2 rounded-xl bg-synapse/20 border border-synapse/40 text-synapse-glow text-sm hover:bg-synapse/30 transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Reasoning…' : 'Reason'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !response && !loading}
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
<span class="text-[10px] uppercase tracking-wider text-muted mr-1 self-center">Try</span>
|
||||
{#each exampleQueries as ex}
|
||||
<button
|
||||
onclick={() => {
|
||||
query = ex;
|
||||
ask();
|
||||
}}
|
||||
class="px-2.5 py-1 rounded-full glass-subtle text-[11px] text-dim hover:text-synapse-glow hover:!border-synapse/30 transition"
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="glass rounded-xl p-4 !border-decay/40 text-decay text-sm">
|
||||
<span class="font-medium">Error:</span>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading state — chain runs alone -->
|
||||
{#if loading}
|
||||
<div class="glass-panel rounded-2xl p-6 space-y-4">
|
||||
<div class="flex items-center gap-2 text-xs text-dream-glow uppercase tracking-wider">
|
||||
<span class="animate-pulse-glow">●</span>
|
||||
<span>Running cognitive pipeline</span>
|
||||
</div>
|
||||
<ReasoningChain running />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Response -->
|
||||
{#if response && !loading}
|
||||
{@const conf = response.confidence}
|
||||
{@const confColor = confidenceColor(conf)}
|
||||
|
||||
<!-- Confidence meter + recommended answer -->
|
||||
<div class="grid md:grid-cols-[280px_1fr] gap-4">
|
||||
<!-- Confidence meter -->
|
||||
<div
|
||||
class="glass-panel rounded-2xl p-5 flex flex-col items-center justify-center text-center space-y-2"
|
||||
style="box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.03), 0 0 32px {confColor}30, 0 8px 32px rgba(0,0,0,0.4); border-color: {confColor}40;"
|
||||
>
|
||||
<span class="text-[10px] uppercase tracking-wider text-dim">Confidence</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="block text-6xl font-bold font-mono conf-number"
|
||||
style="color: {confColor}; text-shadow: 0 0 24px {confColor}80;"
|
||||
>
|
||||
{conf}<span class="text-2xl align-top opacity-60">%</span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-mono tracking-wider"
|
||||
style="color: {confColor}"
|
||||
>
|
||||
{confidenceLabel(conf)}
|
||||
</span>
|
||||
<!-- Confidence ring -->
|
||||
<svg width="220" height="14" viewBox="0 0 220 14" class="mt-1">
|
||||
<rect x="0" y="5" width="220" height="4" rx="2" fill="rgba(255,255,255,0.05)" />
|
||||
<rect
|
||||
x="0"
|
||||
y="5"
|
||||
width={(conf / 100) * 220}
|
||||
height="4"
|
||||
rx="2"
|
||||
fill={confColor}
|
||||
style="filter: drop-shadow(0 0 6px {confColor});"
|
||||
>
|
||||
<animate attributeName="width" from="0" to={(conf / 100) * 220} dur="0.9s" fill="freeze" />
|
||||
</rect>
|
||||
</svg>
|
||||
<div class="flex gap-3 pt-2 text-[10px] text-muted font-mono">
|
||||
<span>intent: <span class="text-dim">{response.intent}</span></span>
|
||||
<span>·</span>
|
||||
<span>{response.memoriesAnalyzed} analyzed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommended answer -->
|
||||
<div class="glass-panel rounded-2xl p-5 space-y-3 !border-synapse/25">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] uppercase tracking-wider text-synapse-glow">Recommended Answer</span>
|
||||
<span class="text-[10px] font-mono text-muted" title={response.recommended.memory_id}>
|
||||
#{response.recommended.memory_id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base text-bright leading-relaxed">{response.recommended.answer_preview}</p>
|
||||
<div class="flex items-center gap-4 text-[11px] text-muted pt-1 border-t border-synapse/10">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full" style="background: {confidenceColor(response.recommended.trust_score * 100)}"></span>
|
||||
Trust {(response.recommended.trust_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{new Date(response.recommended.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reasoning Chain (8-stage pipeline) -->
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">⟿</span>
|
||||
Cognitive Pipeline
|
||||
</h2>
|
||||
<div class="glass-panel rounded-2xl p-5">
|
||||
<ReasoningChain
|
||||
intent={response.intent}
|
||||
memoriesAnalyzed={response.memoriesAnalyzed}
|
||||
evidenceCount={response.evidence.length}
|
||||
contradictionCount={response.contradictions.length}
|
||||
supersededCount={response.superseded.length}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pre-built reasoning text -->
|
||||
{#if response.reasoning}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">❖</span>
|
||||
Template Reasoning
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-5 font-mono text-xs text-text whitespace-pre-wrap leading-relaxed">{response.reasoning}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Evidence grid -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-synapse-glow">◈</span>
|
||||
Evidence
|
||||
<span class="text-muted font-normal">({response.evidence.length})</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-3 text-[10px] text-muted">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-synapse-glow"></span>primary
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-recall"></span>supporting
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-decay"></span>contradicting
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-muted"></span>superseded
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div bind:this={evidenceGridEl} class="evidence-grid relative grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{#each response.evidence as ev, i (ev.id)}
|
||||
<EvidenceCard
|
||||
id={ev.id}
|
||||
trust={ev.trust}
|
||||
date={ev.date}
|
||||
role={ev.role}
|
||||
preview={ev.preview}
|
||||
nodeType={ev.nodeType}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- SVG overlay for contradiction arcs -->
|
||||
{#if arcs.length > 0}
|
||||
<svg class="contradiction-arcs pointer-events-none absolute inset-0 w-full h-full" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="arcGrad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ef4444" stop-opacity="0.9" />
|
||||
<stop offset="50%" stop-color="#ef4444" stop-opacity="0.4" />
|
||||
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{#each arcs as arc, i}
|
||||
{@const mx = (arc.x1 + arc.x2) / 2}
|
||||
{@const my = Math.min(arc.y1, arc.y2) - 28}
|
||||
<path
|
||||
d="M {arc.x1} {arc.y1} Q {mx} {my} {arc.x2} {arc.y2}"
|
||||
fill="none"
|
||||
stroke="url(#arcGrad)"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="4 4"
|
||||
class="arc-path"
|
||||
style="animation-delay: {i * 120 + 600}ms;"
|
||||
/>
|
||||
<circle cx={arc.x1} cy={arc.y1} r="4" fill="#ef4444" opacity="0.8" class="arc-dot" style="animation-delay: {i * 120 + 600}ms;" />
|
||||
<circle cx={arc.x2} cy={arc.y2} r="4" fill="#ef4444" opacity="0.8" class="arc-dot" style="animation-delay: {i * 120 + 700}ms;" />
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contradictions section -->
|
||||
{#if response.contradictions.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2" style="color: #fca5a5;">
|
||||
<span>⚡</span>
|
||||
Contradictions Detected
|
||||
<span class="font-normal text-muted">({response.contradictions.length})</span>
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-4 space-y-3 !border-decay/30">
|
||||
{#each response.contradictions as c, i}
|
||||
<div class="flex items-start gap-3 p-3 rounded-xl bg-decay/[0.05] border border-decay/20">
|
||||
<span class="text-decay text-lg">⚠</span>
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2 text-[10px] font-mono text-muted">
|
||||
<span>#{c.a_id.slice(0, 8)}</span>
|
||||
<span class="text-decay">↔</span>
|
||||
<span>#{c.b_id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text">{c.summary}</p>
|
||||
</div>
|
||||
<span class="text-[10px] font-mono text-muted">pair {i + 1}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Superseded -->
|
||||
{#if response.superseded.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-dim font-semibold flex items-center gap-2">
|
||||
<span>⊘</span>
|
||||
Superseded
|
||||
<span class="font-normal text-muted">({response.superseded.length})</span>
|
||||
</h2>
|
||||
<div class="glass-subtle rounded-2xl p-4 space-y-2">
|
||||
{#each response.superseded as s}
|
||||
<div class="flex items-center gap-3 text-xs text-dim">
|
||||
<span class="font-mono text-muted">#{s.old_id.slice(0, 8)}</span>
|
||||
<span class="text-dream-glow">⟶</span>
|
||||
<span class="font-mono text-synapse-glow">#{s.new_id.slice(0, 8)}</span>
|
||||
<span class="text-muted">{s.reason}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Evolution + insights side-by-side -->
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
{#if response.evolution.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">↗</span>
|
||||
Evolution
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-4 space-y-2">
|
||||
{#each response.evolution as ev}
|
||||
<div class="flex items-start gap-3 text-xs">
|
||||
<span class="text-muted font-mono whitespace-nowrap">
|
||||
{new Date(ev.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span
|
||||
class="mt-1 w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style="background: {confidenceColor(ev.trust * 100)}"
|
||||
></span>
|
||||
<span class="text-dim flex-1">{ev.summary}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if response.related_insights.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm text-bright font-semibold flex items-center gap-2">
|
||||
<span class="text-dream-glow">◇</span>
|
||||
Related Insights
|
||||
</h2>
|
||||
<div class="glass rounded-2xl p-4 space-y-2">
|
||||
{#each response.related_insights as ins}
|
||||
<p class="text-xs text-dim leading-relaxed">
|
||||
<span class="text-synapse-glow mr-2">›</span>{ins}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if !response && !loading && !error}
|
||||
<div class="glass-subtle rounded-2xl p-12 text-center space-y-3">
|
||||
<div class="text-5xl opacity-20">❖</div>
|
||||
<p class="text-sm text-dim">
|
||||
Ask anything. Vestige will run the full reasoning pipeline and show you its work.
|
||||
</p>
|
||||
<p class="text-[10px] text-muted font-mono">
|
||||
TODO: <span class="text-dim">/api/deep-reference</span> endpoint pending — currently
|
||||
fetching real search results and synthesizing evidence scaffold.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.conf-number {
|
||||
animation: conf-pop 900ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes conf-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.arc-path {
|
||||
animation: arc-draw 900ms cubic-bezier(0.22, 0.8, 0.3, 1) backwards;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
@keyframes arc-draw {
|
||||
0% {
|
||||
opacity: 0;
|
||||
stroke-dasharray: 0 400;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
}
|
||||
|
||||
.arc-dot {
|
||||
animation: arc-dot-pulse 1400ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes arc-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
r: 4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-grid {
|
||||
/* give arc overlay room without affecting layout */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.contradiction-arcs {
|
||||
z-index: 5;
|
||||
}
|
||||
</style>
|
||||
252
apps/dashboard/src/routes/(app)/schedule/+page.svelte
Normal file
252
apps/dashboard/src/routes/(app)/schedule/+page.svelte
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import type { Memory } from '$types';
|
||||
import FSRSCalendar from '$components/FSRSCalendar.svelte';
|
||||
import {
|
||||
classifyUrgency,
|
||||
computeScheduleStats,
|
||||
daysUntilReview,
|
||||
} from '$components/schedule-helpers';
|
||||
|
||||
type WindowFilter = 'today' | 'week' | 'month' | 'all';
|
||||
|
||||
let memories: Memory[] = $state([]);
|
||||
let totalMemories = $state(0);
|
||||
let loading = $state(true);
|
||||
let errored = $state(false);
|
||||
let windowFilter: WindowFilter = $state<WindowFilter>('week');
|
||||
|
||||
// The corpus cap. 2000 covers a very large personal corpus while keeping
|
||||
// the request fast; `truncated` surfaces when there's more to fetch.
|
||||
const FETCH_LIMIT = 2000;
|
||||
|
||||
async function fetchMemories() {
|
||||
const res = await api.memories.list({ limit: String(FETCH_LIMIT) });
|
||||
memories = res.memories;
|
||||
totalMemories = res.total;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await fetchMemories();
|
||||
} catch {
|
||||
errored = true;
|
||||
memories = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Only memories that actually have an FSRS next-review timestamp.
|
||||
let scheduled = $derived(memories.filter((m) => !!m.nextReviewAt));
|
||||
|
||||
let now = $derived(new Date());
|
||||
let truncated = $derived(totalMemories > memories.length);
|
||||
|
||||
// Memories that match the currently-selected window. The calendar itself
|
||||
// always renders the full 6-week window for spatial context — this filter
|
||||
// drives the sidebar counts and the right-hand list. Day-granular so the
|
||||
// buckets match the calendar cell colors (both go through classifyUrgency).
|
||||
let filtered = $derived(
|
||||
(() => {
|
||||
const wf: WindowFilter = windowFilter;
|
||||
if (wf === 'all') return scheduled;
|
||||
return scheduled.filter((m) => {
|
||||
const u = classifyUrgency(now, m.nextReviewAt);
|
||||
if (u === 'none') return false;
|
||||
if (wf === 'today') return u === 'overdue' || u === 'today';
|
||||
if (wf === 'week') return u !== 'future';
|
||||
// month: anything due within 30 whole days
|
||||
const d = daysUntilReview(now, m.nextReviewAt);
|
||||
return d !== null && d <= 30;
|
||||
});
|
||||
})()
|
||||
);
|
||||
|
||||
// Stats — due today, this week, this month — and avg days-until-review.
|
||||
let stats = $derived(computeScheduleStats(now, scheduled));
|
||||
|
||||
async function runConsolidation() {
|
||||
loading = true;
|
||||
try {
|
||||
await api.consolidate();
|
||||
await fetchMemories();
|
||||
errored = false;
|
||||
} catch {
|
||||
errored = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The filter buttons.
|
||||
const FILTERS: { key: WindowFilter; label: string }[] = [
|
||||
{ key: 'today', label: 'Due today' },
|
||||
{ key: 'week', label: 'This week' },
|
||||
{ key: 'month', label: 'This month' },
|
||||
{ key: 'all', label: 'All upcoming' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl text-bright font-semibold">Review Schedule</h1>
|
||||
<p class="text-xs text-dim mt-1">FSRS-6 next-review dates across your memory corpus</p>
|
||||
</div>
|
||||
<div class="flex gap-1 p-1 glass-subtle rounded-xl">
|
||||
{#each FILTERS as f}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (windowFilter = f.key)}
|
||||
class="px-3 py-1.5 text-xs rounded-lg transition-all
|
||||
{windowFilter === f.key
|
||||
? 'bg-synapse/20 text-synapse-glow border border-synapse/30'
|
||||
: 'text-dim hover:text-text hover:bg-white/[0.03] border border-transparent'}"
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !loading && !errored && truncated}
|
||||
<div class="px-3 py-2 glass-subtle rounded-lg text-[11px] text-dim">
|
||||
Showing the first {memories.length.toLocaleString()} of {totalMemories.toLocaleString()} memories.
|
||||
Schedule reflects this slice only.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="grid lg:grid-cols-[1fr_280px] gap-6">
|
||||
<div class="space-y-3">
|
||||
<div class="h-14 glass-subtle rounded-xl animate-pulse"></div>
|
||||
<div class="grid grid-cols-7 gap-2">
|
||||
{#each Array(42) as _}
|
||||
<div class="aspect-square glass-subtle rounded-lg animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-20 glass-subtle rounded-xl animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if errored}
|
||||
<div class="p-10 glass rounded-xl text-center space-y-3">
|
||||
<p class="text-sm text-decay">API unavailable.</p>
|
||||
<p class="text-xs text-dim">Could not fetch memories from /api/memories.</p>
|
||||
</div>
|
||||
{:else if scheduled.length === 0}
|
||||
<div class="p-10 glass rounded-xl text-center space-y-4">
|
||||
<div class="text-4xl text-dream/40">◷</div>
|
||||
<p class="text-sm text-bright font-medium">FSRS review schedule not yet populated.</p>
|
||||
<p class="text-xs text-dim max-w-md mx-auto">
|
||||
None of your {memories.length} memor{memories.length === 1 ? 'y has' : 'ies have'} a
|
||||
<code class="text-muted">nextReviewAt</code> timestamp yet. Run consolidation to compute
|
||||
next-review dates via FSRS-6.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={runConsolidation}
|
||||
class="px-4 py-2 bg-warning/20 border border-warning/40 text-warning text-sm rounded-xl hover:bg-warning/30 transition"
|
||||
>
|
||||
Run Consolidation
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid lg:grid-cols-[1fr_280px] gap-6">
|
||||
<!-- Calendar -->
|
||||
<div class="min-w-0">
|
||||
<FSRSCalendar memories={scheduled} />
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: stats -->
|
||||
<aside class="space-y-4">
|
||||
<div class="p-5 glass rounded-xl space-y-4">
|
||||
<h2 class="text-xs text-dim font-semibold uppercase tracking-wider">Queue</h2>
|
||||
<div class="space-y-3">
|
||||
{#if stats.overdue > 0}
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">Overdue</span>
|
||||
<span class="text-2xl font-bold text-decay">{stats.overdue}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">Due today</span>
|
||||
<span class="text-2xl font-bold text-warning">{stats.dueToday}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">This week</span>
|
||||
<span class="text-2xl font-bold text-synapse-glow">{stats.dueThisWeek}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">This month</span>
|
||||
<span class="text-2xl font-bold text-dream-glow">{stats.dueThisMonth}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-3 border-t border-synapse/10">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<span class="text-xs text-dim">Avg days until review</span>
|
||||
<span class="text-lg font-semibold text-text">{stats.avgDays.toFixed(1)}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted mt-1">
|
||||
Across {scheduled.length} scheduled memor{scheduled.length === 1 ? 'y' : 'ies'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtered list preview -->
|
||||
<div class="p-5 glass-subtle rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs text-dim font-semibold uppercase tracking-wider">
|
||||
{FILTERS.find((f) => f.key === windowFilter)?.label}
|
||||
</h2>
|
||||
<span class="text-xs text-muted">{filtered.length}</span>
|
||||
</div>
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-xs text-muted italic">Nothing in this window.</p>
|
||||
{:else}
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{#each filtered
|
||||
.slice()
|
||||
.sort((a, b) => (a.nextReviewAt ?? '').localeCompare(b.nextReviewAt ?? ''))
|
||||
.slice(0, 50) as m (m.id)}
|
||||
{@const urgency = classifyUrgency(now, m.nextReviewAt)}
|
||||
{@const delta = daysUntilReview(now, m.nextReviewAt) ?? 0}
|
||||
<div class="p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition">
|
||||
<p class="text-xs text-text leading-snug line-clamp-2">{m.content}</p>
|
||||
<div class="flex items-center gap-2 mt-1 text-[10px]">
|
||||
<span
|
||||
class="{urgency === 'overdue'
|
||||
? 'text-decay'
|
||||
: urgency === 'today'
|
||||
? 'text-warning'
|
||||
: urgency === 'week'
|
||||
? 'text-synapse-glow'
|
||||
: 'text-dream-glow'}"
|
||||
>
|
||||
{urgency === 'overdue'
|
||||
? `${-delta}d overdue`
|
||||
: urgency === 'today'
|
||||
? 'today'
|
||||
: `in ${delta}d`}
|
||||
</span>
|
||||
<span class="text-muted">· {(m.retentionStrength * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filtered.length > 50}
|
||||
<p class="text-[10px] text-muted text-center pt-1">
|
||||
+{filtered.length - 50} more
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -15,6 +15,9 @@
|
|||
} from '$stores/websocket';
|
||||
import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte';
|
||||
import InsightToast from '$lib/components/InsightToast.svelte';
|
||||
import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { initTheme } from '$stores/theme';
|
||||
|
||||
let { children } = $props();
|
||||
let showCommandPalette = $state(false);
|
||||
|
|
@ -23,6 +26,7 @@
|
|||
|
||||
onMount(() => {
|
||||
websocket.connect();
|
||||
const teardownTheme = initTheme();
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
|
|
@ -49,6 +53,9 @@
|
|||
const shortcutMap: Record<string, string> = {
|
||||
g: '/graph', m: '/memories', t: '/timeline', f: '/feed',
|
||||
e: '/explore', i: '/intentions', s: '/stats',
|
||||
r: '/reasoning', a: '/activation', d: '/dreams',
|
||||
c: '/schedule', p: '/importance', u: '/duplicates',
|
||||
x: '/contradictions', n: '/patterns',
|
||||
};
|
||||
const target = shortcutMap[e.key.toLowerCase()];
|
||||
if (target && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
|
|
@ -61,15 +68,24 @@
|
|||
return () => {
|
||||
websocket.disconnect();
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
teardownTheme();
|
||||
};
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' },
|
||||
{ href: '/reasoning', label: 'Reasoning', icon: '✦', shortcut: 'R' },
|
||||
{ href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' },
|
||||
{ href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' },
|
||||
{ href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' },
|
||||
{ href: '/explore', label: 'Explore', icon: '◬', shortcut: 'E' },
|
||||
{ href: '/activation', label: 'Activation', icon: '◈', shortcut: 'A' },
|
||||
{ href: '/dreams', label: 'Dreams', icon: '✧', shortcut: 'D' },
|
||||
{ href: '/schedule', label: 'Schedule', icon: '◷', shortcut: 'C' },
|
||||
{ href: '/importance', label: 'Importance', icon: '◎', shortcut: 'P' },
|
||||
{ href: '/duplicates', label: 'Duplicates', icon: '◉', shortcut: 'U' },
|
||||
{ href: '/contradictions', label: 'Contradictions', icon: '⚠', shortcut: 'X' },
|
||||
{ href: '/patterns', label: 'Patterns', icon: '▦', shortcut: 'N' },
|
||||
{ href: '/intentions', label: 'Intentions', icon: '◇', shortcut: 'I' },
|
||||
{ href: '/stats', label: 'Stats', icon: '◫', shortcut: 'S' },
|
||||
{ href: '/settings', label: 'Settings', icon: '⚙', shortcut: ',' },
|
||||
|
|
@ -116,7 +132,7 @@
|
|||
</a>
|
||||
|
||||
<!-- Nav items -->
|
||||
<div class="flex-1 py-3 flex flex-col gap-1 px-2">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto py-3 flex flex-col gap-1 px-2">
|
||||
{#each nav as item}
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
<a
|
||||
|
|
@ -149,6 +165,9 @@
|
|||
<div class="flex items-center gap-2 text-xs">
|
||||
<div class="w-2 h-2 rounded-full {$isConnected ? 'bg-recall animate-pulse-glow' : 'bg-decay'}"></div>
|
||||
<span class="hidden lg:block text-dim">{$isConnected ? 'Connected' : 'Offline'}</span>
|
||||
<div class="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden lg:block text-xs text-muted space-y-0.5">
|
||||
<div>{$memoryCount} memories</div>
|
||||
|
|
@ -169,6 +188,7 @@
|
|||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 flex flex-col min-h-0 pb-16 md:pb-0">
|
||||
<AmbientAwarenessStrip />
|
||||
<div class="animate-page-in flex-1 min-h-0 overflow-y-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue