diff --git a/apps/dashboard/src/lib/components/ActivationNetwork.svelte b/apps/dashboard/src/lib/components/ActivationNetwork.svelte new file mode 100644 index 0000000..50012e0 --- /dev/null +++ b/apps/dashboard/src/lib/components/ActivationNetwork.svelte @@ -0,0 +1,372 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + {#each ripples as r, i (i)} + + {/each} + + + {#each activeEdges as e, i (i)} + {@const pt = edgePoint(e)} + {#if pt} + + {/if} + {/each} + + + {#each activeNodes as n (n.id)} + {@const color = nodeColor(n.nodeType, n.isSource)} + {@const r = n.isSource + ? SOURCE_RADIUS * (0.7 + 0.3 * n.activation) + : NEIGHBOUR_RADIUS_BASE * (0.5 + 0.8 * n.activation)} + + + + + + + + {#if n.isSource && n.label} + + {n.label.length > 40 ? n.label.slice(0, 40) + '…' : n.label} + + {/if} + + {/each} + diff --git a/apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte b/apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte new file mode 100644 index 0000000..4b9453b --- /dev/null +++ b/apps/dashboard/src/lib/components/AmbientAwarenessStrip.svelte @@ -0,0 +1,312 @@ + + + +
+ +
+ + + + + {$memoryCount} + memories + · + + {retentionPct}% + + avg retention +
+ + + + +
+ {#if atRiskCount !== null && atRiskCount > 0} + {atRiskCount} + at risk + {:else if atRiskCount === 0} + 0 + at risk + {:else} + + at risk + {/if} +
+ + + + + + + + + + + + + + + {#if dreamState.isDreaming} + +
+ + + + + DREAMING... +
+ {/if} + + +
+ + + {#if suppressionFlash} + + {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/ContradictionArcs.svelte b/apps/dashboard/src/lib/components/ContradictionArcs.svelte new file mode 100644 index 0000000..2969beb --- /dev/null +++ b/apps/dashboard/src/lib/components/ContradictionArcs.svelte @@ -0,0 +1,421 @@ + + +
+ + + { hoverNode = null; hoverArc = null; }} + onclick={handleBgClick} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {#each layout.arcs as arc (arc.pairIndex)} + {@const op = pairOpacity(arc.pairIndex, focusedPairIndex)} + {@const isFocused = focusedPairIndex === arc.pairIndex} + + + + { e.stopPropagation(); handleArcClick(arc.pairIndex); }} + onmouseenter={() => (hoverArc = arc)} + onmouseleave={() => (hoverArc = null)} + aria-label="contradiction {arc.pairIndex + 1}: {arc.topic}" + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(arc.pairIndex); }} + /> + + + {/each} + + + {#each layout.nodes as node, i (node.memoryId + '-' + node.side + '-' + i)} + {@const op = pairOpacity(node.pairIndex, focusedPairIndex)} + {@const isFocused = focusedPairIndex === node.pairIndex} + {@const r = nodeRadius(node.trust)} + {@const fill = nodeColor(node.type)} + + + + (hoverNode = node)} + onmouseleave={() => (hoverNode = null)} + onclick={(e) => { e.stopPropagation(); handleArcClick(node.pairIndex); }} + role="button" + tabindex="0" + aria-label="memory {truncate(node.preview, 40)}" + onkeydown={(e) => { if (e.key === 'Enter') handleArcClick(node.pairIndex); }} + /> + + {#if isFocused} + {truncate(node.preview, 40)} + {/if} + {/each} + + + + + SEVERITY + + strong (>0.7) + + moderate (0.5-0.7) + + mild (0.3-0.5) + + + + + {#if hoverNode} +
+
+
+ {hoverNode.type ?? 'memory'} + trust {(hoverNode.trust * 100).toFixed(0)}% +
+
{hoverNode.preview}
+ {#if hoverNode.created} +
created {hoverNode.created}
+ {/if} + {#if hoverNode.tags && hoverNode.tags.length > 0} +
+ {hoverNode.tags.slice(0, 4).join(' · ')} +
+ {/if} +
+ {:else if hoverArc} +
+
+
+ {hoverArc.severity} conflict +
+
topic: {hoverArc.topic}
+
+ similarity {(hoverArc.similarity * 100).toFixed(0)}% · {hoverArc.dateDiff}d apart +
+
+ {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/DreamInsightCard.svelte b/apps/dashboard/src/lib/components/DreamInsightCard.svelte new file mode 100644 index 0000000..6024015 --- /dev/null +++ b/apps/dashboard/src/lib/components/DreamInsightCard.svelte @@ -0,0 +1,211 @@ + + + +
+ +
+ + {insight.type ?? 'insight'} + + {#if isHighNovelty} + + novel + + {/if} +
+ + +

+ {insight.insight} +

+ + +
+
+ Novelty + {novelty.toFixed(2)} +
+
+
+
+
+ + +
+ Confidence + + {formatConfidencePct(confidence)} + +
+ + + {#if firstSources.length > 0} +
+
+ Sources + {#if extraCount > 0} + (+{extraCount}) + {/if} +
+
+ {#each firstSources as id (id)} + + {shortMemoryId(id)} + + {/each} +
+
+ {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/DreamStageReplay.svelte b/apps/dashboard/src/lib/components/DreamStageReplay.svelte new file mode 100644 index 0000000..b407191 --- /dev/null +++ b/apps/dashboard/src/lib/components/DreamStageReplay.svelte @@ -0,0 +1,539 @@ + + + +
+ +
+
+
+ {current.num} +
+
+
{current.name}
+
{current.desc}
+
+
+ +
+ + +
+ + {#if stageIdx === 5} +
+ Episodic + hippocampus +
+
+ Semantic + cortex +
+
+ {/if} + + + + + + {#each cards as card (card.id)} +
+
+
+
+
+
+
+ {/each} + + + {#if stageIdx === 1} + + {/if} +
+ + + +
+ + diff --git a/apps/dashboard/src/lib/components/DuplicateCluster.svelte b/apps/dashboard/src/lib/components/DuplicateCluster.svelte new file mode 100644 index 0000000..f414ec5 --- /dev/null +++ b/apps/dashboard/src/lib/components/DuplicateCluster.svelte @@ -0,0 +1,192 @@ + + + +{#if memories.length > 0 && winner} +
+ +
+
+
+ + {(similarity * 100).toFixed(1)}% + + {similarityBandLabel(similarity)} + · {memories.length} memories +
+
+
+
+
+ + + + Suggested: {suggestedAction === 'merge' ? 'Merge' : 'Review'} + +
+ + +
+ {#each memories as memory (memory.id)} +
+ + + +
+ +
+ {memory.nodeType} + {#if memory.id === winner.id} + + WINNER + + {/if} + {#each safeTags(memory.tags, 4) as tag} + {tag} + {/each} +
+ + +

+ {expanded ? memory.content : previewContent(memory.content)} +

+ + + {#if formatDate(memory.createdAt)} +
+ {formatDate(memory.createdAt)} +
+ {/if} +
+ + +
+
+
+
+ + {(memory.retention * 100).toFixed(0)}% + +
+
+ {/each} +
+ + +
+ + + +
+
+{/if} diff --git a/apps/dashboard/src/lib/components/EvidenceCard.svelte b/apps/dashboard/src/lib/components/EvidenceCard.svelte new file mode 100644 index 0000000..e845666 --- /dev/null +++ b/apps/dashboard/src/lib/components/EvidenceCard.svelte @@ -0,0 +1,157 @@ + + +
+ +
+
+ + {meta.icon}{meta.label} + + {#if nodeType} + + {nodeType} + + {/if} +
+ #{shortId} +
+ + +

{preview}

+ + +
+
+ Trust + {trustPct.toFixed(0)}% +
+
+
+
+
+ + +
+ {formatDate(date)} + FSRS · reps × retention +
+
+ + diff --git a/apps/dashboard/src/lib/components/FSRSCalendar.svelte b/apps/dashboard/src/lib/components/FSRSCalendar.svelte new file mode 100644 index 0000000..053dc5e --- /dev/null +++ b/apps/dashboard/src/lib/components/FSRSCalendar.svelte @@ -0,0 +1,344 @@ + + +
+ +
+
+ Avg retention of memories due — last 2 weeks → next 4 +
+ retention + today +
+
+ +
+ + +
+ {#each DOW_LABELS as label} +
{label}
+ {/each} +
+ + +
+ {#each cells as cell (cell.key)} + {@const colors = cellColor(cell)} + + {/each} +
+ + +
+ + + Overdue + + + + Due today + + + + Within 7 days + + + + Future (8+ days) + +
+ + + {#if selectedCell && selectedCell.memories.length > 0} +
+
+
+

{fullDate(selectedCell.date)}

+

+ {selectedCell.memories.length} memor{selectedCell.memories.length === 1 ? 'y' : 'ies'} due + · avg retention {(selectedCell.avgRetention * 100).toFixed(0)}% +

+
+ +
+
+ {#each selectedCell.memories.slice(0, 100) as m (m.id)} +
+ +
+

{m.content}

+
+ {m.nodeType} + {#if m.reviewCount !== undefined} + · {m.reviewCount} review{m.reviewCount === 1 ? '' : 's'} + {/if} + {#each m.tags.slice(0, 2) as tag} + {tag} + {/each} +
+
+
+
+
+
+ {(m.retentionStrength * 100).toFixed(0)}% +
+
+ {/each} + {#if selectedCell.memories.length > 100} +

+ +{selectedCell.memories.length - 100} more +

+ {/if} +
+
+ {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/ImportanceRadar.svelte b/apps/dashboard/src/lib/components/ImportanceRadar.svelte new file mode 100644 index 0000000..92cf1b3 --- /dev/null +++ b/apps/dashboard/src/lib/components/ImportanceRadar.svelte @@ -0,0 +1,174 @@ + + + + + {#each RINGS as ring} + + {/each} + + + {#each AXIS_ORDER as axis} + {@const [x, y] = pointAt(1, axis.angle)} + + {/each} + + + + + + {#if size !== 'sm'} + {#each AXIS_ORDER as axis} + {@const [px, py] = pointAt(values[axis.key] * animProgress, axis.angle)} + + {/each} + {/if} + + + {#if showLabels} + {#each AXIS_ORDER as axis} + {@const pos = labelPos(axis.angle)} + + {(values[axis.key] * 100).toFixed(0)}% + + + {AXIS_LABELS[axis.key]} + + {/each} + {/if} + diff --git a/apps/dashboard/src/lib/components/MemoryAuditTrail.svelte b/apps/dashboard/src/lib/components/MemoryAuditTrail.svelte new file mode 100644 index 0000000..4a4e8f3 --- /dev/null +++ b/apps/dashboard/src/lib/components/MemoryAuditTrail.svelte @@ -0,0 +1,185 @@ + + +
+ {#if loading} +
+ {#each Array(5) as _} +
+ {/each} +
+ {:else if errored} +

Audit trail failed to load.

+ {:else if !memoryId} +

No memory selected.

+ {:else if events.length === 0} +

No audit events recorded yet.

+ {:else} +
    + {#each visibleEvents as ev, i (ev.timestamp + i)} + {@const m = META[ev.action]} + {@const delta = formatRetentionDelta(ev.old_value, ev.new_value)} +
  1. + + + + +
    +
    +
    + {m.label} + {#if ev.triggered_by} + {ev.triggered_by} + {/if} +
    + + {relativeTime(ev.timestamp)} + +
    + {#if delta} +
    + retention {delta} +
    + {/if} + {#if ev.reason} +
    {ev.reason}
    + {/if} +
    +
  2. + {/each} +
+ + {#if hiddenCount > 0} + + {/if} + {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte b/apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte new file mode 100644 index 0000000..263661d --- /dev/null +++ b/apps/dashboard/src/lib/components/PatternTransferHeatmap.svelte @@ -0,0 +1,251 @@ + + + +
+ + + + +
+
+ {mobileList.length} transfer pair{mobileList.length === 1 ? '' : 's'} · tap to filter +
+ {#if mobileList.length === 0} +
+ No cross-project transfers recorded yet. +
+ {:else} + {#each mobileList as row (row.from + '->' + row.to)} + + {/each} + {/if} +
+
diff --git a/apps/dashboard/src/lib/components/ReasoningChain.svelte b/apps/dashboard/src/lib/components/ReasoningChain.svelte new file mode 100644 index 0000000..61ea92d --- /dev/null +++ b/apps/dashboard/src/lib/components/ReasoningChain.svelte @@ -0,0 +1,259 @@ + + +
+ {#each STAGES as stage, i (stage.key)} +
+ + {#if i < STAGES.length - 1} +
+ {/if} + + +
+ {stage.icon} +
+ +
+
+ 0{i + 1} + {stage.label} +
+

{hintFor(stage.key, stage.base)}

+
+ + +
+ {/each} +
+ + 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. +

+
+ + +
+ + + + +
+ {#if loading} + + Detecting… + {:else if error} + + Error + {:else} + + + {visibleClusters.length} + {visibleClusters.length === 1 ? 'cluster' : 'clusters'}, + {totalDuplicates} potential duplicate{totalDuplicates === 1 ? '' : 's'} + + {/if} +
+ + +
+ + + {#if error} +
+
Couldn't detect duplicates
+
{error}
+ +
+ {:else if loading} +
+ {#each Array(3) as _} +
+ {/each} +
+ {:else if visibleClusters.length === 0} +
+
·
+
+ No duplicates found above threshold. +
+
Memory is clean.
+
+ {:else} +
+ {#if overflowed} +
+ Showing first {CLUSTER_RENDER_CAP} of {visibleClusters.length} clusters. Raise the + threshold to narrow results. +
+ {/if} + {#each renderedClusters as { c, key } (key)} +
+ dismissCluster(key)} + onMerge={(winnerId, loserIds) => mergeCluster(key, winnerId, loserIds)} + /> +
+ {/each} +
+ {/if} +
+ + diff --git a/apps/dashboard/src/routes/(app)/importance/+page.svelte b/apps/dashboard/src/routes/(app)/importance/+page.svelte new file mode 100644 index 0000000..f157300 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/importance/+page.svelte @@ -0,0 +1,330 @@ + + +
+
+
+

Importance Radar

+

+ 4-channel importance model: Novelty · Arousal · Reward · Attention +

+
+
+ + +
+
+

Test Importance

+

+ Paste any content below. Vestige scores it across 4 channels and + decides whether it is worth saving. +

+
+ +
+
+ +
+ + ⌘/Ctrl + Enter + {#if scoreError} + {scoreError} + {/if} +
+
+ + +
+ {#if score} +
+
Composite
+
+ {(score.composite * 100).toFixed(0)}% +
+
+ {#key radarKey} + + {/key} + + + {#if score.composite > 0.6} +
+
✓ Save
+

+ Composite {(score.composite * 100).toFixed(0)}% > 60% threshold. + {#if topChannel} + Driven by {topChannel.key} — {CHANNEL_BLURBS[topChannel.key].high}. + {/if} +

+
+ {:else} +
+
⨯ Skip
+

+ Composite {(score.composite * 100).toFixed(0)}% < 60% threshold. + {#if weakestChannel} + Weakest channel: {weakestChannel} — {CHANNEL_BLURBS[weakestChannel].low}. + {/if} +

+
+ {/if} + {:else} +
+
+

Type some content above to score its importance.

+

+ Composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention. + Threshold for save: 60%. +

+
+ {/if} +
+
+
+ + +
+
+
+

+ Top Important Memories This Week +

+

+ Ranked by retention × reviews ÷ age. Click any card to open it. +

+
+ +
+ + {#if loadingMemories} +
+ {#each Array(6) as _} +
+ {/each} +
+ {:else if memories.length === 0} +
+

No memories yet.

+
+ {:else} +
+ {#each memories as memory (memory.id)} + {@const ch = perMemoryScores[memory.id]} + + {/each} +
+ {/if} +
+
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'}
-

{memory.content}

-
-
Storage: {(memory.storageStrength * 100).toFixed(1)}%
-
Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%
-
Created: {new Date(memory.createdAt).toLocaleDateString()}
+ +
+ { e.stopPropagation(); expandedTab[memory.id] = 'content'; }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'content'; } }} + class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition + {activeTab === 'content' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}" + >Content + { e.stopPropagation(); expandedTab[memory.id] = 'audit'; }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'audit'; } }} + class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition + {activeTab === 'audit' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}" + >Audit Trail
+ + {#if activeTab === 'content'} +

{memory.content}

+
+
Storage: {(memory.storageStrength * 100).toFixed(1)}%
+
Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%
+
Created: {new Date(memory.createdAt).toLocaleDateString()}
+
+ {:else} +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + +
+ {/if} +
{ e.stopPropagation(); api.memories.promote(memory.id); }} onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }} diff --git a/apps/dashboard/src/routes/(app)/patterns/+page.svelte b/apps/dashboard/src/routes/(app)/patterns/+page.svelte new file mode 100644 index 0000000..05fcbb0 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/patterns/+page.svelte @@ -0,0 +1,567 @@ + + + +
+ +
+

Cross-Project Intelligence

+

Patterns learned here, applied there.

+
+ + +
+ + {#each CATEGORIES as cat (cat)} + + {/each} +
+ + {#if error} +
+
Couldn't load pattern transfers
+
{error}
+ +
+ {:else if loading} +
+
+
+
+ {:else} + +
+ +
+ + + {#if selectedCell} +
+
+ Filtered to + {selectedCell.from} + + {selectedCell.to} +
+ +
+ {/if} +
+ + + +
+ + +
+
+ {patternCount} + pattern{patternCount === 1 ? '' : 's'} across + {projectCount} + project{projectCount === 1 ? '' : 's'}, + {totalTransfers} + total transfer{totalTransfers === 1 ? '' : 's'} +
+
+ {activeCategory === 'All' ? 'All categories' : activeCategory} +
+
+ {/if} +
diff --git a/apps/dashboard/src/routes/(app)/reasoning/+page.svelte b/apps/dashboard/src/routes/(app)/reasoning/+page.svelte new file mode 100644 index 0000000..6bc2039 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/reasoning/+page.svelte @@ -0,0 +1,701 @@ + + + + Reasoning Theater · Vestige + + +
+ +
+
+ +

Reasoning Theater

+ + deep_reference + +
+

+ Watch Vestige reason. Your query runs the 8-stage cognitive pipeline — broad retrieval, + spreading activation, FSRS trust scoring, intent classification, supersession, contradiction + analysis, relation assessment, template reasoning — and returns a pre-built answer with + trust-scored evidence. +

+
+ + +
+
+ + e.key === 'Enter' && ask()} + placeholder="Ask your memory anything..." + class="flex-1 bg-transparent text-bright text-lg placeholder:text-muted focus:outline-none font-mono" + /> + + +
+ + {#if !response && !loading} +
+ Try + {#each exampleQueries as ex} + + {/each} +
+ {/if} +
+ + + {#if error} +
+ Error: + {error} +
+ {/if} + + + {#if loading} +
+
+ + Running cognitive pipeline +
+ +
+ {/if} + + + {#if response && !loading} + {@const conf = response.confidence} + {@const confColor = confidenceColor(conf)} + + +
+ +
+ Confidence +
+ + {conf}% + +
+ + {confidenceLabel(conf)} + + + + + + + + +
+ intent: {response.intent} + · + {response.memoriesAnalyzed} analyzed +
+
+ + +
+
+ Recommended Answer + + #{response.recommended.memory_id.slice(0, 8)} + +
+

{response.recommended.answer_preview}

+
+ + + Trust {(response.recommended.trust_score * 100).toFixed(0)}% + + · + {new Date(response.recommended.date).toLocaleDateString()} +
+
+
+ + +
+

+ + Cognitive Pipeline +

+
+ +
+
+ + + {#if response.reasoning} +
+

+ + Template Reasoning +

+
{response.reasoning}
+
+ {/if} + + +
+
+

+ + Evidence + ({response.evidence.length}) +

+
+ + primary + + + supporting + + + contradicting + + + superseded + +
+
+ +
+ {#each response.evidence as ev, i (ev.id)} + + {/each} + + + {#if arcs.length > 0} + + {/if} +
+
+ + + {#if response.contradictions.length > 0} +
+

+ + Contradictions Detected + ({response.contradictions.length}) +

+
+ {#each response.contradictions as c, i} +
+ +
+
+ #{c.a_id.slice(0, 8)} + + #{c.b_id.slice(0, 8)} +
+

{c.summary}

+
+ pair {i + 1} +
+ {/each} +
+
+ {/if} + + + {#if response.superseded.length > 0} +
+

+ + Superseded + ({response.superseded.length}) +

+
+ {#each response.superseded as s} +
+ #{s.old_id.slice(0, 8)} + + #{s.new_id.slice(0, 8)} + {s.reason} +
+ {/each} +
+
+ {/if} + + +
+ {#if response.evolution.length > 0} +
+

+ + Evolution +

+
+ {#each response.evolution as ev} +
+ + {new Date(ev.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + + + {ev.summary} +
+ {/each} +
+
+ {/if} + + {#if response.related_insights.length > 0} +
+

+ + Related Insights +

+
+ {#each response.related_insights as ins} +

+ {ins} +

+ {/each} +
+
+ {/if} +
+ {/if} + + + {#if !response && !loading && !error} +
+
+

+ Ask anything. Vestige will run the full reasoning pipeline and show you its work. +

+

+ TODO: /api/deep-reference endpoint pending — currently + fetching real search results and synthesizing evidence scaffold. +

+
+ {/if} +
+ + diff --git a/apps/dashboard/src/routes/(app)/schedule/+page.svelte b/apps/dashboard/src/routes/(app)/schedule/+page.svelte new file mode 100644 index 0000000..781b284 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/schedule/+page.svelte @@ -0,0 +1,252 @@ + + +
+
+
+

Review Schedule

+

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. +

+ +
+ {:else} +
+ +
+ +
+ + + +
+ {/if} +
diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 52ea759..08b778b 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -15,6 +15,9 @@ } from '$stores/websocket'; import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte'; import InsightToast from '$lib/components/InsightToast.svelte'; + import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte'; + import ThemeToggle from '$lib/components/ThemeToggle.svelte'; + import { initTheme } from '$stores/theme'; let { children } = $props(); let showCommandPalette = $state(false); @@ -23,6 +26,7 @@ onMount(() => { websocket.connect(); + const teardownTheme = initTheme(); function onKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { @@ -49,6 +53,9 @@ const shortcutMap: Record = { g: '/graph', m: '/memories', t: '/timeline', f: '/feed', e: '/explore', i: '/intentions', s: '/stats', + r: '/reasoning', a: '/activation', d: '/dreams', + c: '/schedule', p: '/importance', u: '/duplicates', + x: '/contradictions', n: '/patterns', }; const target = shortcutMap[e.key.toLowerCase()]; if (target && !e.metaKey && !e.ctrlKey && !e.altKey) { @@ -61,15 +68,24 @@ return () => { websocket.disconnect(); window.removeEventListener('keydown', onKeyDown); + teardownTheme(); }; }); const nav = [ { href: '/graph', label: 'Graph', icon: '◎', shortcut: 'G' }, + { href: '/reasoning', label: 'Reasoning', icon: '✦', shortcut: 'R' }, { href: '/memories', label: 'Memories', icon: '◈', shortcut: 'M' }, { href: '/timeline', label: 'Timeline', icon: '◷', shortcut: 'T' }, { href: '/feed', label: 'Feed', icon: '◉', shortcut: 'F' }, { href: '/explore', label: 'Explore', icon: '◬', shortcut: 'E' }, + { href: '/activation', label: 'Activation', icon: '◈', shortcut: 'A' }, + { href: '/dreams', label: 'Dreams', icon: '✧', shortcut: 'D' }, + { href: '/schedule', label: 'Schedule', icon: '◷', shortcut: 'C' }, + { href: '/importance', label: 'Importance', icon: '◎', shortcut: 'P' }, + { href: '/duplicates', label: 'Duplicates', icon: '◉', shortcut: 'U' }, + { href: '/contradictions', label: 'Contradictions', icon: '⚠', shortcut: 'X' }, + { href: '/patterns', label: 'Patterns', icon: '▦', shortcut: 'N' }, { href: '/intentions', label: 'Intentions', icon: '◇', shortcut: 'I' }, { href: '/stats', label: 'Stats', icon: '◫', shortcut: 'S' }, { href: '/settings', label: 'Settings', icon: '⚙', shortcut: ',' }, @@ -116,7 +132,7 @@ -
+
{#each nav as item} {@const active = isActive(item.href, $page.url.pathname)}
+
+ +