vestige/apps/dashboard/src/lib/components/audit-trail-helpers.ts
Sam Valladares 50869e96ff feat(v2.4): UI expansion — 8 new surfaces exposing the cognitive engine
Sam asked: "Build EVERY SINGLE MISSING UI PIECE." 10 parallel agents shipped
10 new viewports over the existing Rust backend, then 11 audit agents
line-by-line reviewed each one, extracted pure-logic helpers, fixed ~30
bugs, and shipped 549 new unit tests. Everything wired through the layout
with single-key shortcuts and a live theme toggle.

**Eight new routes**
- `/reasoning`  — Reasoning Theater: Cmd+K ask palette → animated 8-stage
  deep_reference pipeline + FSRS-trust-scored evidence cards +
  contradiction arcs rendered as live SVG between evidence nodes
- `/duplicates` — threshold-driven cluster detector with winner selection,
  Merge/Review/Dismiss actions, debounced slider
- `/dreams`     — Dream Cinema: trigger dream + scrubbable 5-stage replay
  (Replay → Cross-reference → Strengthen → Prune → Transfer) + insight
  cards with novelty glow
- `/schedule`   — FSRS Review Calendar: 6×7 grid with urgency color
  bands (overdue/today/week/future), retention sparkline, expand-day list
- `/importance` — 4-channel radar (Novelty/Arousal/Reward/Attention) with
  composite score + top-important list
- `/activation` — live spreading-activation view: search → SVG concentric
  rings with decay animation + live-mode event feed
- `/contradictions` — 2D cosmic constellation of conflicting memories,
  arcs colored by severity, tooltips with previews
- `/patterns`   — cross-project pattern transfer heatmap with category
  filters, top-transferred sidebar

**Three layout additions**
- `AmbientAwarenessStrip` — slim top band with retention vitals, at-risk
  count, active intentions, recent dream, activity sparkline, dreaming
  indicator, Sanhedrin-watch flash. Pure `$derived` over existing stores.
- `ThemeToggle` — dark/light/auto cycle with matchMedia listener,
  localStorage persistence, SSR-safe, reduced-motion-aware. Rendered in
  sidebar footer next to the connection dot.
- `MemoryAuditTrail` — per-memory Sources panel integrated as a
  Content/Audit tab into the existing /memories expansion.

**Pure-logic helper modules extracted (for testability + reuse)**
  reasoning-helpers, duplicates-helpers, dream-helpers, schedule-helpers,
  audit-trail-helpers, awareness-helpers, contradiction-helpers,
  activation-helpers, patterns-helpers, importance-helpers.

**Bugs fixed during audit (not exhaustive)**
- Trust-color inconsistency between EvidenceCard and the page confidence
  ring (0.75 boundary split emerald vs amber)
- `new Date('garbage').toLocaleDateString()` returned literal "Invalid Date"
  in 3 components — all now return em-dash or raw string
- NaN propagation in `Math.max(0, Math.min(1, NaN))` across clamps
- Off-by-one PRNG in audit-trail seeded mock (seed === UINT32_MAX yielded
  rand() === 1.0 → index out of bounds)
- Duplicates dismissals keyed by array index broke on re-fetch; now keyed
  by sorted cluster member IDs with stale-dismissal pruning
- Empty-cluster crash in DuplicateCluster.pickWinner
- Undefined tags crash in DuplicateCluster.safeTags
- Debounce timer leak in threshold slider (missing onDestroy cleanup)
- Schedule day-vs-hour granularity mismatch between calendar cell and
  sidebar list ("today" in one, "in 1d" in the other)
- Schedule 500-memory hard cap silently truncated; bumped to 2000 + banner
- Schedule DST boundary bug in daysBetween (wall-clock math vs
  startOfDay-normalized)
- Dream stage clamp now handles NaN/Infinity/floats
- Dream double-click debounce via `if (dreaming) return`
- Theme setTheme runtime validation; initTheme idempotence (listener +
  style-element dedup on repeat calls)
- ContradictionArcs node radius unclamped (trust < 0 or > 1 rendered
  invalid sizes); tooltip position clamp (could push off-canvas)
- ContradictionArcs $state closure capture (width/height weren't reactive
  in the derived layout block)
- Activation route was MISSING from the repo — audit agent created it
  with identity-based event filtering and proper RAF cleanup
- Layout: ThemeToggle was imported but never rendered — now in sidebar
  footer; sidebar overflow-y-auto added for the 16-entry nav

**Tests — 549 new, 910 total passing (0 failures)**
  ReasoningChain     42 | EvidenceCard       50
  DuplicateCluster   64 | DreamStageReplay   19
  DreamInsightCard   43 | FSRSCalendar       32
  MemoryAuditTrail   45 | AmbientAwareness   60
  theme (store)      31 | ContradictionArcs  43
  ActivationNetwork  54 | PatternTransfer    31
  ImportanceRadar    35 | + existing 361 tests still green

**Gates passed**
- `npm run check`:  0 errors, 0 warnings across 623 files
- `npm test`:       910/910 passing, 22 test files
- `npm run build`:  clean adapter-static output

**Layout wiring**
- Nav array expanded 8 → 16 entries (existing 8 + 8 new routes)
- Single-key shortcuts added: R/A/D/C/P/U/X/N (no conflicts with
  existing G/M/T/F/E/I/S/,)
- Cmd+K palette search works across all 16
- Mobile nav = top 5 (Graph, Reasoning, Memories, Timeline, Feed)
- AmbientAwarenessStrip mounted as first child of <main>
- ThemeToggle rendered in sidebar footer (was imported-but-unmounted)
- Theme initTheme() + teardown wired into onMount cleanup chain

Net branch delta: 47 files changed, +13,756 insertions, -6 deletions
2026-04-21 02:25:07 -05:00

293 lines
8.6 KiB
TypeScript

/**
* Pure helpers for MemoryAuditTrail.
*
* Extracted for isolated unit testing in a Node (vitest) environment —
* no DOM, no Svelte runtime, no fetch. Every function in this module is
* deterministic given its inputs.
*/
export type AuditAction =
| 'created'
| 'accessed'
| 'promoted'
| 'demoted'
| 'edited'
| 'suppressed'
| 'dreamed'
| 'reconsolidated';
export interface AuditEvent {
action: AuditAction;
timestamp: string; // ISO
old_value?: number;
new_value?: number;
reason?: string;
triggered_by?: string;
}
export type MarkerKind =
| 'dot'
| 'arrow-up'
| 'arrow-down'
| 'pencil'
| 'x'
| 'star'
| 'circle-arrow'
| 'ring';
export interface Meta {
label: string;
color: string; // hex for dot + glow
glyph: string; // optional inline symbol
kind: MarkerKind;
}
/**
* Event type → visual metadata. Each action maps to a UNIQUE marker `kind`
* so the 8 event types are visually distinguishable without relying on the
* colour palette alone (accessibility).
*/
export const META: Record<AuditAction, Meta> = {
created: { label: 'Created', color: '#10b981', glyph: '', kind: 'ring' },
accessed: { label: 'Accessed', color: '#3b82f6', glyph: '', kind: 'dot' },
promoted: { label: 'Promoted', color: '#10b981', glyph: '', kind: 'arrow-up' },
demoted: { label: 'Demoted', color: '#f59e0b', glyph: '', kind: 'arrow-down' },
edited: { label: 'Edited', color: '#facc15', glyph: '', kind: 'pencil' },
suppressed: { label: 'Suppressed', color: '#a855f7', glyph: '', kind: 'x' },
dreamed: { label: 'Dreamed', color: '#c084fc', glyph: '', kind: 'star' },
reconsolidated: { label: 'Reconsolidated', color: '#ec4899', glyph: '', kind: 'circle-arrow' }
};
export const VISIBLE_LIMIT = 15;
/**
* All 8 `AuditAction` values, in the canonical order. Used both by the
* event generator (`actionPool`) and by tests that verify uniqueness of
* the marker mapping.
*/
export const ALL_ACTIONS: readonly AuditAction[] = [
'created',
'accessed',
'promoted',
'demoted',
'edited',
'suppressed',
'dreamed',
'reconsolidated'
] as const;
/**
* Hash a string id into a 32-bit unsigned seed. Stable across runs.
*/
export function hashSeed(id: string): number {
let seed = 0;
for (let i = 0; i < id.length; i++) seed = (seed * 31 + id.charCodeAt(i)) >>> 0;
return seed;
}
/**
* Linear congruential PRNG bound to a mutable seed. Returns a function
* that yields floats in `[0, 1)` — critically, NEVER 1.0, so callers
* can safely use `Math.floor(rand() * arr.length)` without off-by-one.
*/
export function makeRand(initialSeed: number): () => number {
let seed = initialSeed >>> 0;
return () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
// Divide by 2^32, not 2^32 - 1 — the latter can yield exactly 1.0
// when seed is UINT32_MAX, breaking array-index math.
return seed / 0x100000000;
};
}
/**
* Deterministic mock audit-trail generator. Same `memoryId` + `nowMs`
* ALWAYS yields the same event sequence (critical for snapshot stability
* and for tests). An empty `memoryId` yields no events — the audit trail
* panel should never invent history for a non-existent memory.
*
* `countOverride` lets tests force a specific number of events (e.g.
* to cross the 15-event visibility threshold, which the default range
* 8-15 cannot do).
*/
export function generateMockAuditTrail(
memoryId: string,
nowMs: number = Date.now(),
countOverride?: number
): AuditEvent[] {
if (!memoryId) return [];
const rand = makeRand(hashSeed(memoryId));
const count = countOverride ?? 8 + Math.floor(rand() * 8); // default 8-15 events
if (count <= 0) return [];
const out: AuditEvent[] = [];
const createdAt = nowMs - (14 + rand() * 21) * 86_400_000; // 14-35 days ago
out.push({
action: 'created',
timestamp: new Date(createdAt).toISOString(),
reason: 'smart_ingest · prediction-error gate opened',
triggered_by: 'smart_ingest'
});
let t = createdAt;
let retention = 0.5 + rand() * 0.2;
const actionPool: AuditAction[] = [
'accessed',
'accessed',
'accessed',
'accessed',
'promoted',
'demoted',
'edited',
'dreamed',
'reconsolidated',
'suppressed'
];
for (let i = 1; i < count; i++) {
t += rand() * 5 * 86_400_000 + 3_600_000; // 1h-5d between events
const action = actionPool[Math.floor(rand() * actionPool.length)];
const ev: AuditEvent = { action, timestamp: new Date(t).toISOString() };
switch (action) {
case 'accessed': {
const old = retention;
retention = Math.min(1, retention + rand() * 0.04 + 0.01);
ev.old_value = old;
ev.new_value = retention;
ev.triggered_by = rand() > 0.5 ? 'search' : 'deep_reference';
break;
}
case 'promoted': {
const old = retention;
retention = Math.min(1, retention + 0.1);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'confirmed helpful by user';
ev.triggered_by = 'memory(action=promote)';
break;
}
case 'demoted': {
const old = retention;
retention = Math.max(0, retention - 0.15);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'user flagged as outdated';
ev.triggered_by = 'memory(action=demote)';
break;
}
case 'edited': {
ev.reason = 'content refined, FSRS state preserved';
ev.triggered_by = 'memory(action=edit)';
break;
}
case 'suppressed': {
const old = retention;
retention = Math.max(0, retention - 0.08);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'top-down inhibition (Anderson 2025)';
ev.triggered_by = 'suppress(dashboard)';
break;
}
case 'dreamed': {
const old = retention;
retention = Math.min(1, retention + 0.05);
ev.old_value = old;
ev.new_value = retention;
ev.reason = 'replayed during dream consolidation';
ev.triggered_by = 'dream()';
break;
}
case 'reconsolidated': {
ev.reason = 'edited within 5-min labile window (Nader)';
ev.triggered_by = 'reconsolidation-manager';
break;
}
case 'created':
// Created is only emitted once, as the first event. If the pool
// ever yields it again, treat it as a no-op access marker with
// no retention change — defensive, not expected.
ev.triggered_by = 'smart_ingest';
break;
}
out.push(ev);
}
// Newest first for display.
return out.reverse();
}
/**
* Humanised relative time. Uses supplied `nowMs` for deterministic tests;
* defaults to `Date.now()` in production.
*
* Boundaries (strictly `<`, so 60s flips to "1m", 60m flips to "1h", etc.):
* <60s → "Ns ago"
* <60m → "Nm ago"
* <24h → "Nh ago"
* <30d → "Nd ago"
* <12mo → "Nmo ago"
* else → "Ny ago"
*
* Future timestamps (nowMs < then) clamp to "0s ago" rather than returning
* a negative string — the audit trail is a past-only view.
*/
export function relativeTime(iso: string, nowMs: number = Date.now()): string {
const then = new Date(iso).getTime();
const diff = Math.max(0, nowMs - then);
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo}mo ago`;
const y = Math.floor(mo / 12);
return `${y}y ago`;
}
/**
* Retention delta formatter. Behaviour:
* (undef, undef) → null — no retention movement on this event
* (undef, 0.72) → "set 0.72" — initial value, no prior state
* (0.50, undef) → "was 0.50" — retention cleared (rare)
* (0.50, 0.72) → "0.50 → 0.72"
*
* The `retention ` prefix is left to the caller so tests can compare the
* core formatted value precisely.
*/
export function formatRetentionDelta(
oldValue: number | undefined,
newValue: number | undefined
): string | null {
const hasOld = typeof oldValue === 'number' && Number.isFinite(oldValue);
const hasNew = typeof newValue === 'number' && Number.isFinite(newValue);
if (!hasOld && !hasNew) return null;
if (!hasOld && hasNew) return `set ${newValue!.toFixed(2)}`;
if (hasOld && !hasNew) return `was ${oldValue!.toFixed(2)}`;
return `${oldValue!.toFixed(2)}${newValue!.toFixed(2)}`;
}
/**
* Split an event list into (visible, hiddenCount) per the 15-event cap.
* Exactly 15 events → no toggle (hiddenCount = 0). 16+ → toggle.
*/
export function splitVisible(
events: AuditEvent[],
showAll: boolean
): { visible: AuditEvent[]; hiddenCount: number } {
if (showAll || events.length <= VISIBLE_LIMIT) {
return { visible: events, hiddenCount: Math.max(0, events.length - VISIBLE_LIMIT) };
}
return {
visible: events.slice(0, VISIBLE_LIMIT),
hiddenCount: events.length - VISIBLE_LIMIT
};
}