+
+
diff --git a/apps/dashboard/src/lib/components/ThemeToggle.svelte b/apps/dashboard/src/lib/components/ThemeToggle.svelte
new file mode 100644
index 0000000..25539a5
--- /dev/null
+++ b/apps/dashboard/src/lib/components/ThemeToggle.svelte
@@ -0,0 +1,175 @@
+
+
+
+
+
+
diff --git a/apps/dashboard/src/lib/components/__tests__/ActivationNetwork.test.ts b/apps/dashboard/src/lib/components/__tests__/ActivationNetwork.test.ts
new file mode 100644
index 0000000..a1641c1
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/ActivationNetwork.test.ts
@@ -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');
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/AmbientAwarenessStrip.test.ts b/apps/dashboard/src/lib/components/__tests__/AmbientAwarenessStrip.test.ts
new file mode 100644
index 0000000..e159e82
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/AmbientAwarenessStrip.test.ts
@@ -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 = {},
+): 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');
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/ContradictionArcs.test.ts b/apps/dashboard/src/lib/components/__tests__/ContradictionArcs.test.ts
new file mode 100644
index 0000000..5573d4c
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/ContradictionArcs.test.ts
@@ -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);
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/DreamInsightCard.test.ts b/apps/dashboard/src/lib/components/__tests__/DreamInsightCard.test.ts
new file mode 100644
index 0000000..7d02844
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/DreamInsightCard.test.ts
@@ -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('');
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/DreamStageReplay.test.ts b/apps/dashboard/src/lib/components/__tests__/DreamStageReplay.test.ts
new file mode 100644
index 0000000..8d18e72
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/DreamStageReplay.test.ts
@@ -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]/);
+ }
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/DuplicateCluster.test.ts b/apps/dashboard/src/lib/components/__tests__/DuplicateCluster.test.ts
new file mode 100644
index 0000000..fac7c77
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/DuplicateCluster.test.ts
@@ -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']);
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts b/apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
new file mode 100644
index 0000000..14a184d
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/EvidenceCard.test.ts
@@ -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');
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts b/apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
new file mode 100644
index 0000000..0b61494
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/FSRSCalendar.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/ImportanceRadar.test.ts b/apps/dashboard/src/lib/components/__tests__/ImportanceRadar.test.ts
new file mode 100644
index 0000000..bbda296
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/ImportanceRadar.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/MemoryAuditTrail.test.ts b/apps/dashboard/src/lib/components/__tests__/MemoryAuditTrail.test.ts
new file mode 100644
index 0000000..e105b83
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/MemoryAuditTrail.test.ts
@@ -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]);
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts b/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts
new file mode 100644
index 0000000..c7b9ccf
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts
@@ -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',
+ 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 + proptest = 2
+ expect(m.vestige.nullgaze.count).toBe(2);
+ // vestige → injeranet: Result 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)['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)['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> = {
+ 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-…');
+ });
+});
diff --git a/apps/dashboard/src/lib/components/__tests__/ReasoningChain.test.ts b/apps/dashboard/src/lib/components/__tests__/ReasoningChain.test.ts
new file mode 100644
index 0000000..f3c5307
--- /dev/null
+++ b/apps/dashboard/src/lib/components/__tests__/ReasoningChain.test.ts
@@ -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}$/);
+ }
+ });
+});
diff --git a/apps/dashboard/src/lib/components/activation-helpers.ts b/apps/dashboard/src/lib/components/activation-helpers.ts
new file mode 100644
index 0000000..e330910
--- /dev/null
+++ b/apps/dashboard/src/lib/components/activation-helpers.ts
@@ -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();
+}
diff --git a/apps/dashboard/src/lib/components/audit-trail-helpers.ts b/apps/dashboard/src/lib/components/audit-trail-helpers.ts
new file mode 100644
index 0000000..2dbca23
--- /dev/null
+++ b/apps/dashboard/src/lib/components/audit-trail-helpers.ts
@@ -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 = {
+ 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
+ };
+}
diff --git a/apps/dashboard/src/lib/components/awareness-helpers.ts b/apps/dashboard/src/lib/components/awareness-helpers.ts
new file mode 100644
index 0000000..d60a4a6
--- /dev/null
+++ b/apps/dashboard/src/lib/components/awareness-helpers.ts
@@ -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;
+}
+
+/**
+ * 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(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;
+}
diff --git a/apps/dashboard/src/lib/components/contradiction-helpers.ts b/apps/dashboard/src/lib/components/contradiction-helpers.ts
new file mode 100644
index 0000000..14ab90f
--- /dev/null
+++ b/apps/dashboard/src/lib/components/contradiction-helpers.ts
@@ -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 = {
+ 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();
+ 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;
+}
diff --git a/apps/dashboard/src/lib/components/dream-helpers.ts b/apps/dashboard/src/lib/components/dream-helpers.ts
new file mode 100644
index 0000000..b740af5
--- /dev/null
+++ b/apps/dashboard/src/lib/components/dream-helpers.ts
@@ -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;
+}
diff --git a/apps/dashboard/src/lib/components/duplicates-helpers.ts b/apps/dashboard/src/lib/components/duplicates-helpers.ts
new file mode 100644
index 0000000..3bbe0ed
--- /dev/null
+++ b/apps/dashboard/src/lib/components/duplicates-helpers.ts
@@ -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 {
+ 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(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(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(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);
+}
diff --git a/apps/dashboard/src/lib/components/importance-helpers.ts b/apps/dashboard/src/lib/components/importance-helpers.ts
new file mode 100644
index 0000000..8516fa5
--- /dev/null
+++ b/apps/dashboard/src/lib/components/importance-helpers.ts
@@ -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> = {
+ 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 | 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 | 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> = {
+ 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 | 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(
+ memories: readonly M[],
+ now: number = Date.now(),
+): M[] {
+ return memories.slice().sort((a, b) => importanceProxy(b, now) - importanceProxy(a, now));
+}
diff --git a/apps/dashboard/src/lib/components/patterns-helpers.ts b/apps/dashboard/src/lib/components/patterns-helpers.ts
new file mode 100644
index 0000000..ac498de
--- /dev/null
+++ b/apps/dashboard/src/lib/components/patterns-helpers.ts
@@ -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
(
+ 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>;
+
+/**
+ * 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;
+}
diff --git a/apps/dashboard/src/lib/components/reasoning-helpers.ts b/apps/dashboard/src/lib/components/reasoning-helpers.ts
new file mode 100644
index 0000000..83b90d1
--- /dev/null
+++ b/apps/dashboard/src/lib/components/reasoning-helpers.ts
@@ -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 = {
+ 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)[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 = {
+ 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;
+}
diff --git a/apps/dashboard/src/lib/components/schedule-helpers.ts b/apps/dashboard/src/lib/components/schedule-helpers.ts
new file mode 100644
index 0000000..93ca985
--- /dev/null
+++ b/apps/dashboard/src/lib/components/schedule-helpers.ts
@@ -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 };
+}
diff --git a/apps/dashboard/src/lib/stores/__tests__/theme.test.ts b/apps/dashboard/src/lib/stores/__tests__/theme.test.ts
new file mode 100644
index 0000000..46d8e33
--- /dev/null
+++ b/apps/dashboard/src/lib/stores/__tests__/theme.test.ts
@@ -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();
+ 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();
+ 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,
+ };
+ 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
diff --git a/apps/dashboard/src/routes/(app)/duplicates/+page.svelte b/apps/dashboard/src/routes/(app)/duplicates/+page.svelte
new file mode 100644
index 0000000..f5e527b
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/duplicates/+page.svelte
@@ -0,0 +1,387 @@
+
+
+
+
+
+
+
+ Memory Hygiene — Duplicate Detection
+
+
+ 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.
+
diff --git a/apps/dashboard/src/routes/(app)/memories/+page.svelte b/apps/dashboard/src/routes/(app)/memories/+page.svelte
index e1000cd..03e9808 100644
--- a/apps/dashboard/src/routes/(app)/memories/+page.svelte
+++ b/apps/dashboard/src/routes/(app)/memories/+page.svelte
@@ -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 = $state({});
let debounceTimer: ReturnType;
onMount(() => loadMemories());
@@ -116,13 +120,45 @@
{#if selectedMemory?.id === memory.id}
+ {@const activeTab = expandedTab[memory.id] ?? 'content'}
FSRS-6 next-review dates across your memory corpus
+
+
+ {#each FILTERS as f}
+
+ {/each}
+
+
+
+ {#if !loading && !errored && truncated}
+
+ Showing the first {memories.length.toLocaleString()} of {totalMemories.toLocaleString()} memories.
+ Schedule reflects this slice only.
+
+ {/if}
+
+ {#if loading}
+
+
+
+
+ {#each Array(42) as _}
+
+ {/each}
+
+
+
+ {#each Array(5) as _}
+
+ {/each}
+
+
+ {:else if errored}
+
+
API unavailable.
+
Could not fetch memories from /api/memories.
+
+ {:else if scheduled.length === 0}
+
+
◷
+
FSRS review schedule not yet populated.
+
+ None of your {memories.length} memor{memories.length === 1 ? 'y has' : 'ies have'} a
+ nextReviewAt timestamp yet. Run consolidation to compute
+ next-review dates via FSRS-6.
+