vestige/apps/dashboard/src/lib/components/contradiction-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

210 lines
7.7 KiB
TypeScript

/**
* contradiction-helpers — Pure logic for the Contradiction Constellation UI.
*
* Extracted from ContradictionArcs.svelte + contradictions/+page.svelte so
* the math and classification live in one place and can be tested in the
* vitest `node` environment without jsdom / Svelte harnessing.
*
* Contracts
* ---------
* - Severity thresholds are STRICTLY exclusive: similarity > 0.7 → strong,
* similarity > 0.5 → moderate, else → mild. The boundary values 0.5 and
* 0.7 therefore fall into the LOWER band on purpose (so a similarity of
* exactly 0.7 is 'moderate', not 'strong').
* - Node type palette has 8 known types; anything else — including
* `undefined`, `null`, empty string, or a typo — falls back to violet
* (#8b5cf6), matching the `concept` fallback tone used elsewhere.
* - Pair opacity is a trinary rule: no focus → 1, focused match → 1,
* focused non-match → 0.12. `null` and `undefined` both mean "no focus".
* - Trust is defined on [0,1]; `nodeRadius` clamps out-of-range values so
* a negative trust can't produce a sub-zero radius and a >1 trust can't
* balloon past the design maximum (14px).
* - `uniqueMemoryCount` unions memory_a_id + memory_b_id across the whole
* pair list; duplicated pairs do not double-count.
*/
/** Shape used by the constellation. Mirrors ContradictionArcs.Contradiction. */
export interface ContradictionLike {
memory_a_id: string;
memory_b_id: string;
}
// ---------------------------------------------------------------------------
// Severity — similarity → colour + label.
// ---------------------------------------------------------------------------
export type SeverityLabel = 'strong' | 'moderate' | 'mild';
/** Strong threshold. Similarity STRICTLY above this is red. */
export const SEVERITY_STRONG_THRESHOLD = 0.7;
/** Moderate threshold. Similarity STRICTLY above this (and <= 0.7) is amber. */
export const SEVERITY_MODERATE_THRESHOLD = 0.5;
export const SEVERITY_STRONG_COLOR = '#ef4444';
export const SEVERITY_MODERATE_COLOR = '#f59e0b';
export const SEVERITY_MILD_COLOR = '#fde047';
/**
* Severity colour by similarity. Boundaries at 0.5 and 0.7 fall into the
* LOWER band (strictly-greater-than comparison).
*
* sim > 0.7 → '#ef4444' (strong / red)
* sim > 0.5 → '#f59e0b' (moderate / amber)
* otherwise → '#fde047' (mild / yellow)
*/
export function severityColor(sim: number): string {
if (sim > SEVERITY_STRONG_THRESHOLD) return SEVERITY_STRONG_COLOR;
if (sim > SEVERITY_MODERATE_THRESHOLD) return SEVERITY_MODERATE_COLOR;
return SEVERITY_MILD_COLOR;
}
/** Severity label by similarity. Same thresholds as severityColor. */
export function severityLabel(sim: number): SeverityLabel {
if (sim > SEVERITY_STRONG_THRESHOLD) return 'strong';
if (sim > SEVERITY_MODERATE_THRESHOLD) return 'moderate';
return 'mild';
}
// ---------------------------------------------------------------------------
// Node type palette.
// ---------------------------------------------------------------------------
/** Fallback colour used when a memory's node_type is missing or unknown. */
export const NODE_COLOR_FALLBACK = '#8b5cf6';
/** Canonical palette for the 8 known node types. */
export const NODE_COLORS: Record<string, string> = {
fact: '#3b82f6',
concept: '#8b5cf6',
event: '#f59e0b',
person: '#10b981',
place: '#06b6d4',
note: '#6b7280',
pattern: '#ec4899',
decision: '#ef4444',
};
/** Canonical list of known types (stable order — matches palette object). */
export const KNOWN_NODE_TYPES = Object.freeze([
'fact',
'concept',
'event',
'person',
'place',
'note',
'pattern',
'decision',
]) as readonly string[];
/**
* Map a (possibly undefined) node_type to a colour. Unknown / missing /
* empty / null strings fall back to violet (#8b5cf6).
*/
export function nodeColor(t?: string | null): string {
if (!t) return NODE_COLOR_FALLBACK;
return NODE_COLORS[t] ?? NODE_COLOR_FALLBACK;
}
// ---------------------------------------------------------------------------
// Trust → node radius.
// ---------------------------------------------------------------------------
/** Minimum circle radius at trust=0. */
export const NODE_RADIUS_MIN = 5;
/** Additional radius at trust=1. `r = 5 + trust * 9`, so r ∈ [5, 14]. */
export const NODE_RADIUS_RANGE = 9;
/**
* Clamp `trust` to [0,1] before mapping to a radius so a bad FSRS value
* can't produce a sub-zero or oversize node. Non-finite values collapse
* to 0 (smallest radius — visually suppresses suspicious data).
*/
export function nodeRadius(trust: number): number {
if (!Number.isFinite(trust)) return NODE_RADIUS_MIN;
const t = trust < 0 ? 0 : trust > 1 ? 1 : trust;
return NODE_RADIUS_MIN + t * NODE_RADIUS_RANGE;
}
/** Clamp trust to [0,1]. NaN/Infinity/undefined → 0. */
export function clampTrust(trust: number | null | undefined): number {
if (trust === null || trust === undefined || !Number.isFinite(trust)) return 0;
if (trust < 0) return 0;
if (trust > 1) return 1;
return trust;
}
// ---------------------------------------------------------------------------
// Focus / pair opacity.
// ---------------------------------------------------------------------------
/** Opacity applied to a non-focused pair when any pair is focused. */
export const UNFOCUSED_OPACITY = 0.12;
/**
* Opacity for a pair given the current focus state.
*
* focus = null/undefined → 1 (nothing dimmed)
* focus === pairIndex → 1 (the focused pair is fully lit)
* focus !== pairIndex → 0.12 (dimmed)
*
* A focus index that doesn't match any rendered pair simply dims everything.
* That's the intended "silent no-op" for a stale focusedPairIndex.
*/
export function pairOpacity(pairIndex: number, focusedPairIndex: number | null | undefined): number {
if (focusedPairIndex === null || focusedPairIndex === undefined) return 1;
return focusedPairIndex === pairIndex ? 1 : UNFOCUSED_OPACITY;
}
// ---------------------------------------------------------------------------
// Text truncation.
// ---------------------------------------------------------------------------
/**
* Truncate a string to `max` characters with an ellipsis at the end.
* Shorter-or-equal strings return unchanged. Empty strings return unchanged.
* Non-string inputs collapse to '' rather than crashing.
*
* The ellipsis counts toward the length budget, so the cut-off content is
* `max - 1` characters, matching the component's inline truncate() helper.
*/
export function truncate(s: string | null | undefined, max = 60): string {
if (s === null || s === undefined) return '';
if (typeof s !== 'string') return '';
if (max <= 0) return '';
if (s.length <= max) return s;
return s.slice(0, max - 1) + '…';
}
// ---------------------------------------------------------------------------
// Stats.
// ---------------------------------------------------------------------------
/**
* Count unique memory IDs across a list of contradiction pairs. Each pair
* contributes memory_a_id and memory_b_id. Duplicates (e.g. one memory that
* appears in multiple conflicts) are counted once.
*/
export function uniqueMemoryCount(pairs: readonly ContradictionLike[]): number {
if (!pairs || pairs.length === 0) return 0;
const set = new Set<string>();
for (const p of pairs) {
if (p.memory_a_id) set.add(p.memory_a_id);
if (p.memory_b_id) set.add(p.memory_b_id);
}
return set.size;
}
/**
* Average absolute trust delta across pairs. Returns 0 on empty input so
* the UI can render `0.00` instead of `NaN`.
*/
export function avgTrustDelta(
pairs: readonly { trust_a: number; trust_b: number }[],
): number {
if (!pairs || pairs.length === 0) return 0;
let sum = 0;
for (const p of pairs) {
sum += Math.abs((p.trust_a ?? 0) - (p.trust_b ?? 0));
}
return sum / pairs.length;
}