diff --git a/CLAUDE.md b/CLAUDE.md index 4ee5762..7388599 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,30 @@ +# πŸš€ MANDATE #0 β€” MAXIMUM AMBITION. NO HEDGING. (read this first, every time) + +This is the standing default for ALL work on Vestige, before anything else below. + +- **Assume maximum ambition.** Build the most exhaustive, jaw-dropping, correct + version β€” not the safe "good enough" one. The ceiling is "nobody else has + this," not "this works." Default to the winning move, not the cautious one. +- **Scour before you settle.** When unsure or stuck, research the real frontier + (web, source, the actual running app) before acting. Never accept the first + adequate answer when a deeper one exists. Verify, don't assume. +- **No hedging.** Lead with the best path and a clear recommendation. Forbidden: + "this is probably too risky," "temper expectations," "good enough," "maybe try + the easier one." Risks get their own honest section β€” never used to shrink the + target. +- **Show proof.** Verify changes in the real running app and share the evidence + (screenshots, test output, gate results) β€” don't claim done without it. +- **Protect what's flawless, detonate what isn't.** Treat finished, loved work as + load-bearing (don't break it); push everything else past where any other dev + would stop. + +Origin: Sam, Jun 22 2026 β€” the overnight session that turned the dashboard + +Memory Cinema from "alive" into a category-of-one particle journey. The depth +only happened because the bar was set to maximum. Make that the default, not the +exception. + +--- + # Vestige Agent Guidance This file is intentionally safe for the public repository. It gives coding diff --git a/apps/dashboard/src/app.css b/apps/dashboard/src/app.css index 418467a..841c2a8 100644 --- a/apps/dashboard/src/app.css +++ b/apps/dashboard/src/app.css @@ -43,6 +43,43 @@ html { font-family: var(--font-mono); } +/* ═══════════════════════════════════════════ + OKLCH / DISPLAY-P3 ACCENT PALETTE (PROGRESSIVE ENHANCEMENT) + ═══════════════════════════════════════════ + The @theme block above keeps the original sRGB hex values, which + Tailwind reads at build time and which serve as the fallback for + sRGB monitors and browsers without OKLCH support. + + When the browser understands oklch(), we redefine the SAME vivid + accents + node-type colors using their OKLCH equivalents. These are + faithful conversions of the hex values (same hue/chroma identity); + on a wide-gamut display-p3 monitor they render more saturated while + reading as the same color. The void/abyss/surface neutrals are left + untouched β€” only the vivid accents benefit from the wider gamut. */ +@supports (color: oklch(0 0 0)) { + :root { + /* Accent colors */ + --color-synapse: oklch(0.585 0.222 277); + --color-synapse-glow: oklch(0.685 0.169 277); + --color-dream: oklch(0.627 0.265 304); + --color-dream-glow: oklch(0.714 0.203 305); + --color-memory: oklch(0.623 0.214 259); + --color-recall: oklch(0.696 0.17 162); + --color-decay: oklch(0.637 0.237 25); + --color-warning: oklch(0.769 0.188 70); + + /* Node type colors */ + --color-node-fact: oklch(0.623 0.214 259); + --color-node-concept: oklch(0.606 0.25 292); + --color-node-event: oklch(0.769 0.188 70); + --color-node-person: oklch(0.696 0.17 162); + --color-node-place: oklch(0.715 0.143 215); + --color-node-note: oklch(0.551 0.027 264); + --color-node-pattern: oklch(0.656 0.241 354); + --color-node-decision: oklch(0.637 0.237 25); + } +} + body { margin: 0; min-height: 100vh; @@ -234,3 +271,304 @@ body { .retention-low { color: var(--color-warning); } .retention-good { color: var(--color-recall); } .retention-strong { color: var(--color-synapse); } + +/* ═══════════════════════════════════════════ + VIEW TRANSITIONS (CROSSFADE) + ═══════════════════════════════════════════ + Native View Transitions API crossfade between routes. Pairs with the + onNavigate hook in +layout.svelte that calls document.startViewTransition. + Reduced-motion users get an instant cut (the @media guard disables the + animation entirely, so the default browser cross-fade does not run). */ +@media not (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 180ms; + animation-timing-function: ease; + } + ::view-transition-old(root) { + animation-name: vt-fade-out; + } + ::view-transition-new(root) { + animation-name: vt-fade-in; + } + + @keyframes vt-fade-out { + from { opacity: 1; } + to { opacity: 0; } + } + @keyframes vt-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + THE "ALIVE" LAYER (bleeding-edge, progressively enhanced) + ─────────────────────────────────────────────────────────────────────── + Shared primitives that make every page breathe, respond, and feel seen. + Everything here degrades gracefully and is disabled under reduced-motion. + ═══════════════════════════════════════════════════════════════════════ */ + +/* ── Registered animatable custom props (@property) ────────────────────── + Registering a property as / lets the browser TWEEN it, + which plain CSS vars can't do. Powers the rotating conic borders below. */ +@property --angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} +@property --shine { + syntax: ''; + initial-value: 0%; + inherits: false; +} + +/* ── Scroll-reveal (paired with use:reveal) ───────────────────────────── + Elements start translated + transparent; .reveal-in lands them. The + action toggles the class via IntersectionObserver, with per-item --reveal-delay + for staggered list entrances. */ +.reveal { + opacity: 0; + transform: translateY(var(--reveal-y, 16px)); + transition: + opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.55s cubic-bezier(0.22, 1, 0.36, 1); + transition-delay: var(--reveal-delay, 0ms); + will-change: opacity, transform; +} +.reveal-in { + opacity: 1; + transform: none; +} +@media (prefers-reduced-motion: reduce) { + .reveal { + opacity: 1; + transform: none; + transition: none; + } +} + +/* ── @starting-style page/section entry ───────────────────────────────── + Native entry animation with zero JS: the element animates FROM the + starting-style the first time it's rendered. Used on route content. */ +@media not (prefers-reduced-motion: reduce) { + .enter { + transition: + opacity 0.4s ease, + transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); + } + @starting-style { + .enter { + opacity: 0; + transform: translateY(10px); + } + } +} + +/* ── Conic-gradient animated border (premium "live" frame) ────────────── + A slowly rotating iridescent ring around a panel. The @property --angle + makes the conic gradient's start angle tweenable. Use class .live-border. */ +.live-border { + position: relative; + isolation: isolate; +} +.live-border::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: conic-gradient( + from var(--angle), + transparent 0%, + var(--color-synapse) 18%, + var(--color-dream) 33%, + transparent 50%, + transparent 100% + ); + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0.6; + z-index: -1; +} +@media not (prefers-reduced-motion: reduce) { + .live-border::before { + animation: border-rotate 6s linear infinite; + } + @keyframes border-rotate { + to { + --angle: 360deg; + } + } +} + +/* ── Cursor spotlight surface (paired with use:spotlight) ──────────────── + A soft radial glow follows the pointer across a panel. --spot-x/y/o are + set by the action; default opacity 0 so it's invisible until hovered. */ +.spotlight-surface { + position: relative; + overflow: hidden; +} +.spotlight-surface::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + 340px circle at var(--spot-x, 50%) var(--spot-y, 50%), + rgba(129, 140, 248, 0.12), + transparent 60% + ); + opacity: var(--spot-o, 0); + transition: opacity 0.3s ease; + z-index: 0; +} + +/* ── Tilt glare (paired with use:tilt glare) ────────────────────────────── */ +.tilt-glare { + position: relative; + overflow: hidden; +} +.tilt-glare::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + circle at var(--glare-x, 50%) var(--glare-y, 50%), + rgba(255, 255, 255, 0.14), + transparent 45% + ); + opacity: var(--glare-o, 0); + transition: opacity 0.3s ease; +} + +/* ── Breathing live indicator (calmer than a hard blink) ──────────────── + A dot that gently inhales/exhales scale + glow β€” reads as "alive, + listening" rather than "error blinking". */ +.breathe { + animation: breathe 3.2s ease-in-out infinite; +} +@keyframes breathe { + 0%, 100% { + transform: scale(1); + filter: drop-shadow(0 0 2px currentColor); + opacity: 0.85; + } + 50% { + transform: scale(1.18); + filter: drop-shadow(0 0 7px currentColor); + opacity: 1; + } +} +@media (prefers-reduced-motion: reduce) { + .breathe { animation: none; } +} + +/* A live "radar ping" ring that expands and fades β€” wrap a dot in .ping-host. */ +.ping-host { + position: relative; +} +.ping-host::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background: currentColor; + opacity: 0.6; + z-index: -1; +} +@media not (prefers-reduced-motion: reduce) { + .ping-host::before { + animation: ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite; + } + @keyframes ping { + 0% { transform: scale(1); opacity: 0.5; } + 80%, 100% { transform: scale(2.6); opacity: 0; } + } +} + +/* ── Shimmer skeleton (loading that feels intentional, not frozen) ─────── + A diagonal light sweep over skeleton blocks. Use .shimmer on the + placeholder; it replaces a plain pulse with a directional sheen. */ +.shimmer { + position: relative; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); +} +.shimmer::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + rgba(129, 140, 248, 0.12), + transparent + ); +} +@media not (prefers-reduced-motion: reduce) { + .shimmer::after { + animation: shimmer 1.6s ease-in-out infinite; + } + @keyframes shimmer { + 100% { transform: translateX(100%); } + } +} + +/* ── Gradient text that slowly drifts (alive headings) ──────────────────── */ +.text-aurora { + background: linear-gradient( + 100deg, + var(--color-synapse-glow), + var(--color-dream-glow), + var(--color-recall), + var(--color-synapse-glow) + ); + background-size: 250% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +@media not (prefers-reduced-motion: reduce) { + .text-aurora { + animation: aurora-drift 8s ease-in-out infinite; + } + @keyframes aurora-drift { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + } +} + +/* ── Hover lift utility β€” cards rise + glow when pointed at ───────────────*/ +.lift { + transition: + transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.28s ease, + border-color 0.28s ease; +} +.lift:hover { + transform: translateY(-3px); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(99, 102, 241, 0.18); +} +@media (prefers-reduced-motion: reduce) { + .lift:hover { transform: none; } +} + +/* ── Tabular numerals helper (count-ups don't jitter) ──────────────────── */ +.tabular-nums { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; +} + +/* ── Focus ring consistency (accessible + on-brand) ─────────────────────── */ +:where(button, a, input, select, [role='button'], [tabindex]):focus-visible { + outline: 2px solid var(--color-synapse-glow); + outline-offset: 2px; + border-radius: 0.4rem; +} diff --git a/apps/dashboard/src/lib/actions/interactions.ts b/apps/dashboard/src/lib/actions/interactions.ts new file mode 100644 index 0000000..c4e43b3 --- /dev/null +++ b/apps/dashboard/src/lib/actions/interactions.ts @@ -0,0 +1,136 @@ +// ═══════════════════════════════════════════════════════════════════════ +// Micro-interaction actions β€” the "alive" layer for pointer feel. +// ─────────────────────────────────────────────────────────────────────── +// All actions no-op under prefers-reduced-motion and clean up their own +// listeners on destroy. Pure pointer events, no dependency. +// ═══════════════════════════════════════════════════════════════════════ + +const prefersReducedMotion = () => + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + +// ── magnetic: element drifts toward the cursor, snaps back on leave ────── +export interface MagneticOptions { + /** How far the element is allowed to drift, in px. */ + strength?: number; +} +export function magnetic(node: HTMLElement, options: MagneticOptions = {}) { + if (prefersReducedMotion()) return {}; + const strength = options.strength ?? 8; + let raf = 0; + + function onMove(e: PointerEvent) { + const r = node.getBoundingClientRect(); + const mx = e.clientX - (r.left + r.width / 2); + const my = e.clientY - (r.top + r.height / 2); + const dx = Math.max(-1, Math.min(1, mx / (r.width / 2))) * strength; + const dy = Math.max(-1, Math.min(1, my / (r.height / 2))) * strength; + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + node.style.transform = `translate(${dx}px, ${dy}px)`; + }); + } + function onLeave() { + cancelAnimationFrame(raf); + node.style.transform = ''; + } + + node.style.transition = 'transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)'; + node.addEventListener('pointermove', onMove); + node.addEventListener('pointerleave', onLeave); + return { + destroy() { + node.removeEventListener('pointermove', onMove); + node.removeEventListener('pointerleave', onLeave); + cancelAnimationFrame(raf); + }, + }; +} + +// ── tilt: 3D parallax tilt toward the cursor (cards, hero panels) ──────── +export interface TiltOptions { + /** Max tilt in degrees. */ + max?: number; + /** Lift the card toward the viewer on hover, in px. */ + lift?: number; + /** Add a moving sheen highlight that follows the cursor. */ + glare?: boolean; +} +export function tilt(node: HTMLElement, options: TiltOptions = {}) { + if (prefersReducedMotion()) return {}; + const max = options.max ?? 6; + const lift = options.lift ?? 0; + const glare = options.glare ?? false; + let raf = 0; + + node.style.transformStyle = 'preserve-3d'; + node.style.transition = 'transform 0.3s cubic-bezier(0.23, 1, 0.32, 1)'; + + if (glare) { + node.style.setProperty('--glare-x', '50%'); + node.style.setProperty('--glare-y', '50%'); + node.style.setProperty('--glare-o', '0'); + } + + function onMove(e: PointerEvent) { + const r = node.getBoundingClientRect(); + const px = (e.clientX - r.left) / r.width; + const py = (e.clientY - r.top) / r.height; + const rx = (0.5 - py) * max * 2; + const ry = (px - 0.5) * max * 2; + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + node.style.transform = `perspective(900px) rotateX(${rx}deg) rotateY(${ry}deg)${lift ? ` translateZ(${lift}px)` : ''}`; + if (glare) { + node.style.setProperty('--glare-x', `${px * 100}%`); + node.style.setProperty('--glare-y', `${py * 100}%`); + node.style.setProperty('--glare-o', '1'); + } + }); + } + function onLeave() { + cancelAnimationFrame(raf); + node.style.transform = ''; + if (glare) node.style.setProperty('--glare-o', '0'); + } + + node.addEventListener('pointermove', onMove); + node.addEventListener('pointerleave', onLeave); + return { + destroy() { + node.removeEventListener('pointermove', onMove); + node.removeEventListener('pointerleave', onLeave); + cancelAnimationFrame(raf); + }, + }; +} + +// ── spotlight: a soft radial glow that tracks the cursor over a surface ── +// Sets --spot-x / --spot-y custom props the element's CSS can read to +// position a radial-gradient highlight. Makes large panels feel responsive +// to the pointer even when nothing is "hovered". +export function spotlight(node: HTMLElement) { + if (prefersReducedMotion()) return {}; + let raf = 0; + function onMove(e: PointerEvent) { + const r = node.getBoundingClientRect(); + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + node.style.setProperty('--spot-x', `${e.clientX - r.left}px`); + node.style.setProperty('--spot-y', `${e.clientY - r.top}px`); + node.style.setProperty('--spot-o', '1'); + }); + } + function onLeave() { + node.style.setProperty('--spot-o', '0'); + } + node.addEventListener('pointermove', onMove); + node.addEventListener('pointerleave', onLeave); + return { + destroy() { + node.removeEventListener('pointermove', onMove); + node.removeEventListener('pointerleave', onLeave); + cancelAnimationFrame(raf); + }, + }; +} diff --git a/apps/dashboard/src/lib/actions/reveal.ts b/apps/dashboard/src/lib/actions/reveal.ts new file mode 100644 index 0000000..43efd48 --- /dev/null +++ b/apps/dashboard/src/lib/actions/reveal.ts @@ -0,0 +1,68 @@ +// ═══════════════════════════════════════════════════════════════════════ +// reveal β€” scroll-into-view entrance animation as a Svelte action. +// ─────────────────────────────────────────────────────────────────────── +// Usage:
// default rise+fade +//
+// +// Adds an IntersectionObserver that flips the element to its "revealed" +// state the first time it scrolls into view. Pairs with the .reveal / +// .reveal-in CSS in app.css. Honors prefers-reduced-motion by revealing +// instantly (no transform), so motion-sensitive users still see content. +// +// Why an action and not CSS scroll-timeline alone: scroll-driven +// animation-timeline is great for continuous scrubbing, but for a simple +// "appear once when seen" we want a one-shot that also works as a staggered +// list entrance with per-item delay β€” an observer is the robust path with +// universal mid-2026 support. +// ═══════════════════════════════════════════════════════════════════════ + +export interface RevealOptions { + /** Pixels to translate up from on entry. */ + y?: number; + /** ms delay before the transition starts (use for stagger). */ + delay?: number; + /** Only reveal once, then stop observing (default true). */ + once?: boolean; + /** 0-1 visibility threshold to trigger. */ + threshold?: number; +} + +const prefersReducedMotion = () => + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + +export function reveal(node: HTMLElement, options: RevealOptions = {}) { + const { y = 16, delay = 0, once = true, threshold = 0.12 } = options; + + // Motion-off: show immediately, do nothing else. + if (prefersReducedMotion()) { + node.classList.add('reveal-in'); + return {}; + } + + node.classList.add('reveal'); + node.style.setProperty('--reveal-y', `${y}px`); + if (delay) node.style.setProperty('--reveal-delay', `${delay}ms`); + + const io = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + node.classList.add('reveal-in'); + if (once) io.unobserve(node); + } else if (!once) { + node.classList.remove('reveal-in'); + } + } + }, + { threshold, rootMargin: '0px 0px -8% 0px' } + ); + + io.observe(node); + + return { + destroy() { + io.disconnect(); + }, + }; +} diff --git a/apps/dashboard/src/lib/components/AnimatedNumber.svelte b/apps/dashboard/src/lib/components/AnimatedNumber.svelte new file mode 100644 index 0000000..0aab878 --- /dev/null +++ b/apps/dashboard/src/lib/components/AnimatedNumber.svelte @@ -0,0 +1,91 @@ + + +{prefix}{formatted}{suffix} diff --git a/apps/dashboard/src/lib/components/Dropdown.svelte b/apps/dashboard/src/lib/components/Dropdown.svelte new file mode 100644 index 0000000..9c12e69 --- /dev/null +++ b/apps/dashboard/src/lib/components/Dropdown.svelte @@ -0,0 +1,338 @@ + + + + +
+ {#if label} + {label} + {/if} + + + {#if open} +
+ {#each options as opt, i (opt.value)} + + {/each} +
+ {/if} +
+ + diff --git a/apps/dashboard/src/lib/components/Graph3D.svelte b/apps/dashboard/src/lib/components/Graph3D.svelte index 8c0f769..d09dcc9 100644 --- a/apps/dashboard/src/lib/components/Graph3D.svelte +++ b/apps/dashboard/src/lib/components/Graph3D.svelte @@ -50,6 +50,20 @@ let ctx: SceneContext; let animationId: number; + // Accessibility: honour the OS "reduce motion" setting. The dominant + // continuous motion in the graph is the camera auto-rotate; disabling it + // removes the vestibular-trigger while keeping the graph fully usable + // (manual orbit, hover, selection, live events all still work). Tracked + // reactively so a mid-session OS toggle is respected. + let prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + let reducedMotionMq: MediaQueryList | null = null; + function onReducedMotionChange(e: MediaQueryListEvent) { + prefersReducedMotion = e.matches; + if (ctx?.controls) ctx.controls.autoRotate = !prefersReducedMotion; + } + // Modules let nodeManager: NodeManager; let edgeManager: EdgeManager; @@ -74,6 +88,15 @@ onMount(() => { ctx = createScene(container); + // Respect reduced-motion: the scene defaults to auto-rotate on; turn it + // off up front for users who asked for less motion, and listen for live + // OS-setting changes. + if (prefersReducedMotion) ctx.controls.autoRotate = false; + if (typeof window !== 'undefined' && window.matchMedia) { + reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)'); + reducedMotionMq.addEventListener?.('change', onReducedMotionChange); + } + // Nebula background const nebula = createNebulaBackground(ctx.scene); nebulaMaterial = nebula.material; @@ -113,6 +136,7 @@ onDestroy(() => { cancelAnimationFrame(animationId); window.removeEventListener('resize', onResize); + reducedMotionMq?.removeEventListener?.('change', onReducedMotionChange); container?.removeEventListener('pointermove', onPointerMove); container?.removeEventListener('click', onClick); effects?.dispose(); diff --git a/apps/dashboard/src/lib/components/Icon.svelte b/apps/dashboard/src/lib/components/Icon.svelte new file mode 100644 index 0000000..ed248a0 --- /dev/null +++ b/apps/dashboard/src/lib/components/Icon.svelte @@ -0,0 +1,143 @@ + + + + + + + diff --git a/apps/dashboard/src/lib/components/MemoryCinema.svelte b/apps/dashboard/src/lib/components/MemoryCinema.svelte new file mode 100644 index 0000000..b78c827 --- /dev/null +++ b/apps/dashboard/src/lib/components/MemoryCinema.svelte @@ -0,0 +1,831 @@ + + + + +{#if open} + + +{/if} + + diff --git a/apps/dashboard/src/lib/components/PageHeader.svelte b/apps/dashboard/src/lib/components/PageHeader.svelte new file mode 100644 index 0000000..00ad638 --- /dev/null +++ b/apps/dashboard/src/lib/components/PageHeader.svelte @@ -0,0 +1,69 @@ + + +
+
+
+ +
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
+ {#if children} +
+ {@render children()} +
+ {/if} +
+ + diff --git a/apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts b/apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts new file mode 100644 index 0000000..d8fa1aa --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/__tests__/auteur.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { planShotsDeterministic, resolveShots, SHOT_DEFAULTS, type DirectorPlan } from '../auteur'; +import { planCinemaPath } from '../pathfinder'; +import { computeSignals } from '../topology'; +import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers'; + +describe('auteur β€” carry-forward shot resolution', () => { + beforeEach(() => resetNodeCounter()); + + function smallPath() { + const a = makeNode({ id: 'a' }); + const b = makeNode({ id: 'b' }); + const c = makeNode({ id: 'c' }); + const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })]; + return { path: planCinemaPath([a, b, c], edges, 'a'), nodes: [a, b, c], edges }; + } + + it('fills EVERY axis from a one-field shot, defaulting to today constants', () => { + const { path } = smallPath(); + const plan: DirectorPlan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + shots: [{ nodeId: 'a', move: 'orbit', why: 'test' }], + }; + const resolved = resolveShots(plan, path); + expect(resolved).toHaveLength(path.beats.length); + // The specified field is honored… + expect(resolved[0].move).toBe('orbit'); + // …and every other axis is a real default, never undefined. + expect(resolved[0].standoff).toBe(SHOT_DEFAULTS.standoff); + expect(resolved[0].flightSeconds).toBe(SHOT_DEFAULTS.flightSeconds); + expect(resolved[0].angle).toBe('eye'); + for (const s of resolved) { + for (const k of Object.keys(SHOT_DEFAULTS) as (keyof typeof SHOT_DEFAULTS)[]) { + expect(s[k]).toBeDefined(); + } + expect(typeof s.why).toBe('string'); + expect(s.why.length).toBeGreaterThan(0); + } + }); + + it('carries non-cut axes forward to subsequent beats', () => { + const { path } = smallPath(); + const plan: DirectorPlan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + // Only the FIRST beat sets standoff; later beats should inherit it. + shots: [{ nodeId: path.beats[0].nodeId, standoff: 41, why: 'set' }], + }; + const resolved = resolveShots(plan, path); + expect(resolved[0].standoff).toBe(41); + expect(resolved[resolved.length - 1].standoff).toBe(41); // carried forward + }); + + it('cut never carries forward β€” defaults to fly each beat', () => { + const { path } = smallPath(); + const plan: DirectorPlan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + shots: [{ nodeId: path.beats[0].nodeId, cut: 'hard_cut', why: 'cut' }], + }; + const resolved = resolveShots(plan, path); + expect(resolved[0].cut).toBe('hard_cut'); + if (resolved.length > 1) expect(resolved[1].cut).toBe('fly'); + }); + + it('back-fills garbage / out-of-range LLM fields from defaults', () => { + const { path } = smallPath(); + const plan = { + source: 'backend-llm', + logline: 'x', + arc: 'flat', + shots: [ + { + nodeId: path.beats[0].nodeId, + move: 'teleport', // invalid enum + standoff: 9999, // out of range + dwellSeconds: -5, // out of range + why: '', + }, + ], + } as unknown as DirectorPlan; + const resolved = resolveShots(plan, path); + expect(resolved[0].move).toBe(SHOT_DEFAULTS.move); // invalid β†’ default + expect(resolved[0].standoff).toBeLessThanOrEqual(90); // clamped + expect(resolved[0].dwellSeconds).toBeGreaterThanOrEqual(0.6); // clamped + expect(resolved[0].why.length).toBeGreaterThan(0); // empty why β†’ fallback + }); + + it('a null plan still yields one default shot per beat', () => { + const { path } = smallPath(); + const resolved = resolveShots(null, path); + expect(resolved).toHaveLength(path.beats.length); + expect(resolved[0].move).toBe(SHOT_DEFAULTS.move); + }); +}); + +describe('auteur β€” deterministic director', () => { + beforeEach(() => resetNodeCounter()); + + it('produces a valid plan: one grounded shot per beat, every why non-empty, every nodeId real', () => { + const nodes = Array.from({ length: 8 }, (_, i) => makeNode({ id: `n${i}` })); + const edges = [ + makeEdge('n0', 'n1', { weight: 0.9 }), + makeEdge('n1', 'n2', { weight: 0.2, type: 'contradiction' }), + makeEdge('n0', 'n3', { weight: 0.5 }), + makeEdge('n3', 'n4', { weight: 0.7 }), + ]; + const path = planCinemaPath(nodes, edges, 'n0'); + const signals = computeSignals(nodes, edges); + const plan = planShotsDeterministic(path, signals); + const realIds = new Set(nodes.map((n) => n.id)); + expect(plan.shots).toHaveLength(path.beats.length); + for (const s of plan.shots) { + expect(realIds.has(s.nodeId)).toBe(true); + expect(s.why && s.why.length).toBeGreaterThan(0); + } + expect(plan.source).toBe('deterministic'); + expect(plan.logline.length).toBeGreaterThan(0); + }); + + it('directs a contradiction beat as a Dutch hard-cut crimson collision', () => { + const a = makeNode({ id: 'a' }); + const normal = makeNode({ id: 'normal' }); + const conflict = makeNode({ id: 'conflict' }); + const edges = [ + makeEdge('a', 'normal', { weight: 0.95 }), + makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }), + ]; + const path = planCinemaPath([a, normal, conflict], edges, 'a'); + const signals = computeSignals([a, normal, conflict], edges); + const plan = planShotsDeterministic(path, signals); + const contradictionShot = plan.shots.find((_, i) => path.beats[i].kind === 'contradiction'); + expect(contradictionShot).toBeDefined(); + expect(contradictionShot!.stormMode).toBe('contradiction'); + expect(contradictionShot!.cut).toBe('hard_cut'); + expect(contradictionShot!.dutch).toBeGreaterThan(0); + expect(contradictionShot!.scoreCue).toBe('minor_drop'); + }); + + it('ends on a crane pull-back with a major resolve', () => { + const nodes = Array.from({ length: 5 }, (_, i) => makeNode({ id: `m${i}` })); + const edges = nodes.slice(1).map((n, i) => makeEdge(`m${i}`, n.id, { weight: 0.6 })); + const path = planCinemaPath(nodes, edges, 'm0'); + const signals = computeSignals(nodes, edges); + const plan = planShotsDeterministic(path, signals); + const last = plan.shots[plan.shots.length - 1]; + expect(last.move).toBe('crane'); + expect(last.scoreCue).toBe('major_resolve'); + }); + + it('is deterministic β€” same inputs yield the same plan', () => { + const nodes = Array.from({ length: 6 }, (_, i) => makeNode({ id: `d${i}` })); + const edges = [makeEdge('d0', 'd1', { weight: 0.8 }), makeEdge('d1', 'd2', { weight: 0.5 })]; + const path = planCinemaPath(nodes, edges, 'd0'); + const sig = computeSignals(nodes, edges); + const p1 = planShotsDeterministic(path, sig); + const p2 = planShotsDeterministic(path, sig); + expect(p1.shots.map((s) => s.move)).toEqual(p2.shots.map((s) => s.move)); + expect(p1.logline).toBe(p2.logline); + }); +}); + +describe('topology β€” graph signals', () => { + beforeEach(() => resetNodeCounter()); + + it('computes betweenness, clusters, and peak keystone on a real shape', () => { + // Two clusters bridged by 'hub' β†’ hub has the highest betweenness. + const hub = makeNode({ id: 'hub' }); + const l1 = makeNode({ id: 'l1' }); + const l2 = makeNode({ id: 'l2' }); + const r1 = makeNode({ id: 'r1' }); + const r2 = makeNode({ id: 'r2' }); + const edges = [ + makeEdge('l1', 'l2'), + makeEdge('l2', 'hub'), + makeEdge('hub', 'r1'), + makeEdge('r1', 'r2'), + ]; + const sig = computeSignals([hub, l1, l2, r1, r2], edges); + expect(sig.peakBetweennessId).toBe('hub'); + expect(sig.nodes.get('hub')!.betweenness).toBeGreaterThan(sig.nodes.get('l1')!.betweenness); + expect(sig.clusterCount).toBe(1); // all connected through hub + // All signals are finite and in range. + for (const s of sig.nodes.values()) { + expect(s.betweenness).toBeGreaterThanOrEqual(0); + expect(s.betweenness).toBeLessThanOrEqual(1); + expect(Number.isFinite(s.recencyRank)).toBe(true); + } + }); + + it('flags contradiction edges and computes surprise in range', () => { + const a = makeNode({ id: 'a' }); + const b = makeNode({ id: 'b' }); + const edges = [makeEdge('a', 'b', { weight: 0.1, type: 'contradiction' })]; + const sig = computeSignals([a, b], edges); + expect(sig.edges[0].isContradiction).toBe(true); + expect(sig.edges[0].surprise).toBeGreaterThanOrEqual(0); + expect(sig.edges[0].surprise).toBeLessThanOrEqual(1); + }); +}); diff --git a/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts b/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts new file mode 100644 index 0000000..40aeef6 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/__tests__/pathfinder.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { planCinemaPath } from '../pathfinder'; +import { makeNode, makeEdge, resetNodeCounter } from '../../__tests__/helpers'; + +describe('planCinemaPath', () => { + beforeEach(() => resetNodeCounter()); + + it('returns an empty path for no nodes', () => { + const path = planCinemaPath([], [], 'missing'); + expect(path.beats).toEqual([]); + expect(path.flowEdges).toEqual([]); + }); + + it('starts at the requested center when it exists', () => { + const a = makeNode({ id: 'a' }); + const b = makeNode({ id: 'b' }); + const path = planCinemaPath([a, b], [makeEdge('a', 'b')], 'a'); + expect(path.beats[0].nodeId).toBe('a'); + expect(path.beats[0].kind).toBe('origin'); + expect(path.beats[0].viaEdge).toBeNull(); + }); + + it('falls back to the most-connected node when center is missing', () => { + const hub = makeNode({ id: 'hub' }); + const x = makeNode({ id: 'x' }); + const y = makeNode({ id: 'y' }); + const path = planCinemaPath( + [x, hub, y], + [makeEdge('hub', 'x'), makeEdge('hub', 'y')], + 'does-not-exist' + ); + expect(path.beats[0].nodeId).toBe('hub'); + }); + + it('visits the strongest-weighted connection first', () => { + const a = makeNode({ id: 'a' }); + const weak = makeNode({ id: 'weak' }); + const strong = makeNode({ id: 'strong' }); + const path = planCinemaPath( + [a, weak, strong], + [makeEdge('a', 'weak', { weight: 0.1 }), makeEdge('a', 'strong', { weight: 0.9 })], + 'a' + ); + expect(path.beats[1].nodeId).toBe('strong'); + expect(path.beats[1].kind).toBe('connection'); + }); + + it('detours through a contradiction edge when reachable', () => { + const a = makeNode({ id: 'a' }); + const normal = makeNode({ id: 'normal' }); + const conflict = makeNode({ id: 'conflict' }); + const path = planCinemaPath( + [a, normal, conflict], + [ + makeEdge('a', 'normal', { weight: 0.95, type: 'semantic' }), + makeEdge('a', 'conflict', { weight: 0.2, type: 'contradiction' }), + ], + 'a' + ); + const kinds = path.beats.map((b) => b.kind); + expect(kinds).toContain('contradiction'); + // The contradiction beat carries max intensity. + const c = path.beats.find((b) => b.kind === 'contradiction'); + expect(c?.intensity).toBe(1); + }); + + it('never exceeds maxBeats and never repeats a node', () => { + const nodes = Array.from({ length: 20 }, (_, i) => makeNode({ id: `n${i}` })); + const edges = nodes.slice(1).map((n) => makeEdge('n0', n.id, { weight: Math.random() })); + const path = planCinemaPath(nodes, edges, 'n0', 5); + expect(path.beats.length).toBeLessThanOrEqual(5); + const ids = path.beats.map((b) => b.nodeId); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('is deterministic β€” same inputs yield the same path', () => { + const nodes = [makeNode({ id: 'a' }), makeNode({ id: 'b' }), makeNode({ id: 'c' })]; + const edges = [makeEdge('a', 'b', { weight: 0.8 }), makeEdge('b', 'c', { weight: 0.6 })]; + const p1 = planCinemaPath(nodes, edges, 'a'); + const p2 = planCinemaPath(nodes, edges, 'a'); + expect(p1.beats.map((b) => b.nodeId)).toEqual(p2.beats.map((b) => b.nodeId)); + }); + + it('records flowEdges for each traversed connection', () => { + const a = makeNode({ id: 'a' }); + const b = makeNode({ id: 'b' }); + const path = planCinemaPath([a, b], [makeEdge('a', 'b', { weight: 0.7 })], 'a'); + expect(path.flowEdges.length).toBeGreaterThanOrEqual(1); + expect(path.flowEdges[0].source === 'a' || path.flowEdges[0].target === 'a').toBe(true); + }); +}); diff --git a/apps/dashboard/src/lib/graph/cinema/auteur.ts b/apps/dashboard/src/lib/graph/cinema/auteur.ts new file mode 100644 index 0000000..e2334bf --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/auteur.ts @@ -0,0 +1,223 @@ +// The Auteur β€” the director's brain + the typed shot-plan contract. +// +// The LLM (Tier 1) or the deterministic rule table (Tier 2) produces a +// DirectorPlan: a sequence of cinematographic Shots, one per CinemaBeat, each +// grounded in a real node and justified by a real graph metric. The camera +// runtime (director.ts) executes it. Carry-forward semantics mean a sparse or +// half-hallucinated plan ALWAYS resolves to a coherent film β€” the same +// robustness pattern as narrator.resolveNarration. + +import type { CinemaPath, CinemaBeat } from './pathfinder'; +import type { GraphSignals } from './topology'; + +// ── Camera grammar (string unions keep LLM output validatable) ─────────────── +export type Move = 'push_in' | 'pull_back' | 'orbit' | 'crane' | 'whip_pan' | 'rack_focus' | 'hold'; +export type Angle = 'eye' | 'low' | 'high'; // low = look up (power); high = look down (decay) +export type Cut = 'fly' | 'hard_cut' | 'match_cut'; +export type StormMode = 'anchor' | 'connection' | 'contradiction' | 'surprise'; +export type CaptionTone = 'curious' | 'tense' | 'resolved' | 'awe' | 'neutral'; +export type ScoreCue = 'motif' | 'minor_drop' | 'major_resolve' | 'silence'; +export type Act = 'I' | 'II' | 'III'; +export type EmotionalArc = 'man_in_hole' | 'rags_to_riches' | 'icarus' | 'cinderella' | 'oedipus' | 'flat'; +export type DirectorSource = 'backend-llm' | 'on-device' | 'deterministic'; + +/** A directed shot. Only axes that CHANGE need be set β€” the rest carry forward + * from the previous resolved shot (ultimate default = today's camera constants). */ +export interface Shot { + nodeId: string; // MUST cite a real node (alignment key + grounding constraint) + move?: Move; + angle?: Angle; + dutch?: number; // camera roll, radians, 0..~0.5 + standoff?: number; // world units + flightSeconds?: number; + dwellSeconds?: number; + halflife?: number; // spring smoothing; 0 = jump-cut + cut?: Cut; + stormMode?: StormMode; + intensity?: number; // 0..1 β†’ scales the ignition spike + tension?: number; // 0..1 master scalar + act?: Act; + tone?: CaptionTone; + scoreCue?: ScoreCue; + why: string; // REQUIRED: cites the real metric driving this shot + viaEdgeKey?: string; // `${source}->${target}` for two-node framing +} + +export interface DirectorPlan { + source: DirectorSource; + logline: string; + arc: EmotionalArc; + shots: Shot[]; +} + +/** Every axis filled after carry-forward β€” what the director reads each beat. */ +export type ResolvedShot = Required> & { viaEdgeKey?: string }; + +// Ultimate defaults β€” today's hardcoded camera constants, so a plan-less or +// fully-sparse run is byte-identical to the pre-Auteur camera. +export const SHOT_DEFAULTS: Omit = { + move: 'hold', + angle: 'eye', + dutch: 0, + standoff: 26, + flightSeconds: 2.4, + dwellSeconds: 3.2, + halflife: 0.35, + cut: 'fly', + stormMode: 'connection', + intensity: 0.7, + tension: 0.3, + act: 'I', + tone: 'neutral', + scoreCue: 'motif', +}; + +const MOVES: ReadonlySet = new Set(['push_in', 'pull_back', 'orbit', 'crane', 'whip_pan', 'rack_focus', 'hold']); +const ANGLES: ReadonlySet = new Set(['eye', 'low', 'high']); +const CUTS: ReadonlySet = new Set(['fly', 'hard_cut', 'match_cut']); +const STORM_MODES: ReadonlySet = new Set(['anchor', 'connection', 'contradiction', 'surprise']); +const TONES: ReadonlySet = new Set(['curious', 'tense', 'resolved', 'awe', 'neutral']); +const SCORE_CUES: ReadonlySet = new Set(['motif', 'minor_drop', 'major_resolve', 'silence']); +const ACTS: ReadonlySet = new Set(['I', 'II', 'III']); + +function num(v: unknown, lo: number, hi: number, fallback: number): number { + const n = typeof v === 'number' && Number.isFinite(v) ? v : NaN; + if (Number.isNaN(n)) return fallback; + return Math.max(lo, Math.min(hi, n)); +} +function pick(v: unknown, set: ReadonlySet, fallback: T): T { + return typeof v === 'string' && set.has(v as T) ? (v as T) : fallback; +} + +/** + * Resolve a DirectorPlan into one fully-specified ResolvedShot per beat. + * Aligns by nodeId; every unspecified/garbage axis is back-filled by carry-forward + * (previous shot β†’ SHOT_DEFAULTS). A shot can NEVER be blank or invalid. + */ +export function resolveShots(plan: DirectorPlan | null, path: CinemaPath): ResolvedShot[] { + const byNode = new Map(); + for (const s of plan?.shots ?? []) { + if (s && typeof s.nodeId === 'string') byNode.set(s.nodeId, s); + } + const resolved: ResolvedShot[] = []; + let prev: ResolvedShot | null = null; + for (const beat of path.beats) { + const raw = byNode.get(beat.nodeId); + const base = prev ?? { ...SHOT_DEFAULTS, nodeId: beat.nodeId, why: '' }; + const shot: ResolvedShot = { + nodeId: beat.nodeId, + move: pick(raw?.move, MOVES, base.move), + angle: pick(raw?.angle, ANGLES, base.angle), + dutch: num(raw?.dutch, 0, 0.6, base.dutch), + standoff: num(raw?.standoff, 8, 90, base.standoff), + flightSeconds: num(raw?.flightSeconds, 0.4, 6, base.flightSeconds), + dwellSeconds: num(raw?.dwellSeconds, 0.6, 8, base.dwellSeconds), + halflife: num(raw?.halflife, 0, 1.5, base.halflife), + cut: pick(raw?.cut, CUTS, 'fly'), // cut never carries forward β€” default per beat + stormMode: pick(raw?.stormMode, STORM_MODES, base.stormMode), + intensity: num(raw?.intensity, 0, 1, base.intensity), + tension: num(raw?.tension, 0, 1, base.tension), + act: pick(raw?.act, ACTS, base.act), + tone: pick(raw?.tone, TONES, base.tone), + scoreCue: pick(raw?.scoreCue, SCORE_CUES, 'motif'), + why: typeof raw?.why === 'string' && raw.why.trim() ? raw.why : base.why || 'establishing shot', + viaEdgeKey: typeof raw?.viaEdgeKey === 'string' ? raw.viaEdgeKey : undefined, + }; + resolved.push(shot); + prev = shot; + } + return resolved; +} + +// ── The deterministic auteur (Tier 2) ──────────────────────────────────────── +// The graph-metric β†’ shot-grammar rule table. This SAME table is handed to the +// LLM as its system prompt (see directorSystemPrompt), so Tier-1 output is +// directly comparable to and back-fillable against this baseline. + +function actFor(progress: number): Act { + return progress < 0.34 ? 'I' : progress < 0.72 ? 'II' : 'III'; +} + +/** + * Produce a cinematic DirectorPlan from pure graph signals β€” no LLM. This alone + * ships the hero film: every shot is grounded and justified by a real metric. + */ +export function planShotsDeterministic(path: CinemaPath, signals: GraphSignals): DirectorPlan { + const n = path.beats.length; + const shots: Shot[] = path.beats.map((beat, i) => { + const progress = n > 1 ? i / (n - 1) : 0; + const act = actFor(progress); + const sig = signals.nodes.get(beat.nodeId); + const isPeak = beat.nodeId === signals.peakBetweennessId; + const isFinale = i === n - 1; + const isOrigin = i === 0; + + // Default shot for a plain connection beat. + let shot: Shot = { + nodeId: beat.nodeId, + move: 'push_in', + angle: 'eye', + cut: 'fly', + stormMode: 'connection', + tone: 'curious', + scoreCue: 'motif', + act, + intensity: 0.6, + tension: 0.3, + why: 'a connected memory', + }; + + if (isOrigin) { + shot = { ...shot, move: 'push_in', tone: 'curious', tension: 0.25, stormMode: 'anchor', why: 'opening on the focal memory' }; + } + // High-betweenness keystone β†’ reverent low-angle slow orbit. + if (isPeak || (sig && sig.betweenness > 0.6)) { + shot = { ...shot, move: 'orbit', angle: 'low', stormMode: 'anchor', intensity: 0.75, tension: 0.45, tone: 'awe', why: 'low-angle orbit β€” the most load-bearing memory in the graph' }; + } + // Contradiction β†’ Dutch push-in, hard cut, crimson chaos, minor drop. + if (beat.kind === 'contradiction') { + shot = { ...shot, move: 'push_in', angle: 'eye', dutch: 0.28, cut: 'hard_cut', stormMode: 'contradiction', intensity: 1, tension: 0.95, tone: 'tense', scoreCue: 'minor_drop', viaEdgeKey: beat.viaEdge ? `${beat.viaEdge.source}->${beat.viaEdge.target}` : undefined, why: 'two memories in tension β€” a Dutch two-shot collision' }; + } + // Surprise edge β†’ gold/violet convergence, rising awe. + if (beat.kind === 'surprise') { + shot = { ...shot, move: 'orbit', stormMode: 'surprise', intensity: 0.85, tension: 0.6, tone: 'awe', scoreCue: 'motif', why: 'a surprising, distant-but-plausible connection' }; + } + // Fading memory β†’ drifting high angle. + if (sig && (sig.retention < 0.35 || sig.suppression > 0.5)) { + shot = { ...shot, angle: 'high', move: 'pull_back', tone: 'neutral', intensity: 0.4, why: 'a fading memory β€” high-angle drift' }; + } + // Recent β†’ the "now" beat. + if (beat.kind === 'recent') { + shot = { ...shot, move: 'push_in', tone: 'resolved', tension: 0.4, why: 'where the memory is now' }; + } + // Finale β†’ crane pull-back, major resolve. + if (isFinale) { + shot = { ...shot, move: 'crane', cut: 'fly', stormMode: 'anchor', tone: 'awe', tension: 0.5, scoreCue: 'major_resolve', why: 'crane pull-back over the whole cluster β€” resolution' }; + } + return shot; + }); + + const arc: EmotionalArc = path.beats.some((b) => b.kind === 'contradiction') ? 'man_in_hole' : 'rags_to_riches'; + const originLabel = path.beats[0]?.node.label ?? 'a memory'; + const logline = `A short film about ${originLabel} β€” ${n} shots through the graph${arc === 'man_in_hole' ? ', through a contradiction and out the other side' : ''}.`; + + return { source: 'deterministic', logline, arc, shots }; +} + +/** The rule table as an LLM system prompt β€” keeps Tier-1 output comparable to + * the Tier-2 baseline (and thus back-fillable by resolveShots). */ +export function directorSystemPrompt(): string { + return [ + 'You are a film director shooting a short documentary about an AI\'s own memory graph.', + 'Output a DirectorPlan: a logline, an emotional arc, and one shot per beat.', + 'Each shot MUST cite a real nodeId and a real "why" referencing a graph metric.', + 'Grammar β†’ meaning:', + '- high betweenness (load-bearing memory) β†’ low-angle slow orbit, reverent', + '- contradiction edge β†’ Dutch angle + push_in + hard_cut + crimson storm + minor_drop score', + '- surprising distant link β†’ gold/violet orbitβ†’stream convergence + awe', + '- merge/supersede β†’ match_cut at identical standoff+angle (same idea)', + '- low retention / high suppression β†’ high-angle drift (fading)', + '- finale β†’ crane pull_back + major_resolve', + 'Build a real emotional arc across acts Iβ†’IIβ†’III. Only specify axes that change.', + ].join('\n'); +} diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts new file mode 100644 index 0000000..0891169 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -0,0 +1,285 @@ +// Memory Cinema β€” the camera director. +// +// Drives a smooth, cinematic camera flight through a planned CinemaPath. Pure +// choreography: it mutates a THREE.PerspectiveCamera + an OrbitControls-like +// target each frame and emits beat-arrival callbacks the narrator + sandbox +// hook into. It knows nothing about which renderer (WebGL/WebGPU) is on screen, +// so it works identically for the legacy graph and the WebGPU sandbox. +// +// Respects prefers-reduced-motion: when reduced, it JUMP-CUTS between beats +// (instant position, dwell, advance) instead of flying β€” captions still fire. + +import * as THREE from 'three'; +import type { CinemaPath, CinemaBeat } from './pathfinder'; +import type { ResolvedShot } from './auteur'; + +export interface DirectorCallbacks { + /** Fired once when the camera arrives at (or cuts to) a beat. The resolved + * shot for the beat is passed so consumers can drive storm/score/captions. */ + onBeat?: (beat: CinemaBeat, index: number, shot: ResolvedShot | null) => void; + /** Fired when the whole tour finishes. */ + onComplete?: () => void; + /** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */ + onProgress?: (t: number) => void; +} + +export interface DirectorOptions { + /** Seconds of camera flight between consecutive beats. */ + flightSeconds?: number; + /** Seconds the camera dwells on each beat before advancing. */ + dwellSeconds?: number; + /** Stand-off distance from the focused node, in world units. */ + standoff?: number; + /** Instant cuts instead of flights (prefers-reduced-motion). */ + reducedMotion?: boolean; + /** Optional per-beat director's plan (one ResolvedShot per beat, aligned by + * index). When ABSENT the camera behaves byte-identically to the pre-Auteur + * director β€” every value falls back to the constants above. When present, + * each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */ + shots?: ResolvedShot[]; + /** When true, the camera frames the WORLD ORIGIN every shot (the WebGPU storm + * is pinned there) instead of flying out to scattered node positions β€” so the + * subject is ALWAYS centered and can never fly off-screen. Camera variety + * comes purely from angle/standoff/orbit. Used by the WebGPU sandbox path. */ + centerOnOrigin?: boolean; +} + +type Phase = 'idle' | 'flying' | 'dwelling' | 'done'; + +const _tmpDir = new THREE.Vector3(); +const _tmpUp = new THREE.Vector3(0, 1, 0); +const _origin = new THREE.Vector3(0, 0, 0); + +export class CinemaDirector { + private camera: THREE.PerspectiveCamera; + private target: THREE.Vector3; + private positions: Map; + private path: CinemaPath; + private cb: DirectorCallbacks; + private opts: Required; + + private phase: Phase = 'idle'; + private beatIndex = 0; + private phaseElapsed = 0; + + // Flight interpolation endpoints. + private fromPos = new THREE.Vector3(); + private toPos = new THREE.Vector3(); + private fromTarget = new THREE.Vector3(); + private toTarget = new THREE.Vector3(); + + constructor( + camera: THREE.PerspectiveCamera, + target: THREE.Vector3, + positions: Map, + path: CinemaPath, + cb: DirectorCallbacks = {}, + opts: DirectorOptions = {} + ) { + this.camera = camera; + this.target = target; + this.positions = positions; + this.path = path; + this.cb = cb; + this.opts = { + flightSeconds: opts.flightSeconds ?? 2.4, + dwellSeconds: opts.dwellSeconds ?? 3.2, + standoff: opts.standoff ?? 26, + reducedMotion: opts.reducedMotion ?? false, + shots: opts.shots ?? [], + centerOnOrigin: opts.centerOnOrigin ?? false, + }; + } + + /** The resolved shot directing a beat, or null when no plan was supplied + * (β†’ the camera uses the constant defaults = pre-Auteur behavior). */ + private shotAt(index: number): ResolvedShot | null { + return this.opts.shots[index] ?? null; + } + + /** Per-beat flight duration: the shot's value, else the global default. A + * hard/match cut has zero flight (handled in beginFlightTo). */ + private flightSecondsAt(index: number): number { + return this.shotAt(index)?.flightSeconds ?? this.opts.flightSeconds; + } + private dwellSecondsAt(index: number): number { + return this.shotAt(index)?.dwellSeconds ?? this.opts.dwellSeconds; + } + + get totalBeats(): number { + return this.path.beats.length; + } + + get isRunning(): boolean { + return this.phase !== 'idle' && this.phase !== 'done'; + } + + /** Begin the tour from the first beat. */ + start(): void { + if (this.path.beats.length === 0) { + this.phase = 'done'; + this.cb.onComplete?.(); + return; + } + this.beatIndex = 0; + this.beginFlightTo(0); + } + + stop(): void { + this.phase = 'done'; + } + + /** The focal point a beat frames: the world ORIGIN in centered mode (storm is + * pinned there), else the node's laid-out position. */ + private focalPoint(beat: CinemaBeat): THREE.Vector3 | null { + if (this.opts.centerOnOrigin) return _origin; + return this.positions.get(beat.nodeId) ?? null; + } + + /** Compute the camera stand-off position for a beat's focal point, directed by + * its shot (move / angle / standoff). With no shot, reproduces the original + * framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */ + private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 { + const nodePos = this.focalPoint(beat); + if (!nodePos) { + // Node has no resolved position yet β€” keep current framing. + return out.copy(this.camera.position); + } + const shot = this.shotAt(index); + + _tmpDir.copy(this.camera.position).sub(nodePos); + if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1); + _tmpDir.normalize(); + + // Vertical bias = the camera angle. Default +0.35 (slightly above, the + // original filmic tilt). low = look UP at the node (power), high = look + // DOWN (decay/fading). + let upBias = 0.35; + if (shot) { + if (shot.angle === 'low') upBias = -0.45; + else if (shot.angle === 'high') upBias = 0.7; + } + _tmpDir.addScaledVector(_tmpUp, upBias).normalize(); + + // Stand-off = how close: push_in tightens, pull_back/crane widen. + let standoff = shot?.standoff ?? this.opts.standoff; + if (shot) { + if (shot.move === 'push_in') standoff *= 0.7; + else if (shot.move === 'pull_back') standoff *= 1.5; + else if (shot.move === 'crane') standoff *= 1.8; + } + // In centered (WebGPU storm) mode the subject is pinned to the origin and + // the sandbox clamps the camera to a far band. Keep the directed standoff + // INSIDE that band so the camera never fights the clamp (which read as an + // off-center jump) β€” variety here comes from angle + orbit, not distance. + if (this.opts.centerOnOrigin) standoff = Math.max(31, Math.min(43, standoff)); + return out.copy(nodePos).addScaledVector(_tmpDir, standoff); + } + + private beginFlightTo(index: number): void { + const beat = this.path.beats[index]; + const nodePos = this.focalPoint(beat); + const shot = this.shotAt(index); + + this.fromPos.copy(this.camera.position); + this.fromTarget.copy(this.target); + this.framePosition(beat, index, this.toPos); + this.toTarget.copy(nodePos ?? this.target); + this.phaseElapsed = 0; + + // A directed hard/match cut snaps instantly (like reduced-motion), so the + // editorial "cut" reads as an edit, not a fly. reduced-motion forces this + // for every beat regardless of shot. + const snap = this.opts.reducedMotion || shot?.cut === 'hard_cut' || shot?.cut === 'match_cut'; + if (snap) { + this.camera.position.copy(this.toPos); + this.target.copy(this.toTarget); + this.phase = 'dwelling'; + this.cb.onBeat?.(beat, index, shot); + } else { + this.phase = 'flying'; + } + } + + /** Advance the choreography. Call once per animation frame with delta seconds. */ + update(deltaSeconds: number): void { + if (this.phase === 'idle' || this.phase === 'done') return; + // Clamp dt so a tab-switch stall doesn't teleport the camera. + const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); + this.phaseElapsed += dt; + + const flightSecs = this.flightSecondsAt(this.beatIndex); + const dwellSecs = this.dwellSecondsAt(this.beatIndex); + + if (this.phase === 'flying') { + const t = Math.min(1, this.phaseElapsed / flightSecs); + const e = easeInOutCubic(t); + this.camera.position.lerpVectors(this.fromPos, this.toPos, e); + this.target.lerpVectors(this.fromTarget, this.toTarget, e); + this.applyDutch(this.beatIndex, e); + if (t >= 1) { + this.phase = 'dwelling'; + this.phaseElapsed = 0; + this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex, this.shotAt(this.beatIndex)); + } + } else if (this.phase === 'dwelling') { + if (!this.opts.reducedMotion) { + const nodePos = this.focalPoint(this.path.beats[this.beatIndex]); + if (nodePos) { + this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive + // An orbit shot slowly revolves the camera around the node + // during the dwell β€” the signature "reverent" move for keystones. + if (this.shotAt(this.beatIndex)?.move === 'orbit') { + this.orbitAround(nodePos, dt * 0.35); + } + } + } + if (this.phaseElapsed >= dwellSecs) { + const nextIndex = this.beatIndex + 1; + if (nextIndex >= this.path.beats.length) { + this.phase = 'done'; + this.cb.onProgress?.(1); + this.cb.onComplete?.(); + return; + } + this.beatIndex = nextIndex; + this.beginFlightTo(nextIndex); + } + } + + // Overall progress across the whole tour (beat + intra-beat fraction). + // Guard against an empty path (per = 0) so progress can never be NaN. + const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0; + const intra = + this.phase === 'flying' + ? Math.min(1, this.phaseElapsed / flightSecs) * 0.5 + : 0.5 + Math.min(1, this.phaseElapsed / dwellSecs) * 0.5; + this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per)); + } + + /** Revolve the camera around a node by `angle` radians (orbit shots). */ + private orbitAround(center: THREE.Vector3, angle: number): void { + _tmpDir.copy(this.camera.position).sub(center); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = _tmpDir.x * cos - _tmpDir.z * sin; + const z = _tmpDir.x * sin + _tmpDir.z * cos; + _tmpDir.x = x; + _tmpDir.z = z; + this.camera.position.copy(center).add(_tmpDir); + } + + /** Roll the camera (Dutch angle) toward the shot's target roll over the + * flight, easing back to upright for non-Dutch shots. */ + private applyDutch(index: number, t: number): void { + const targetRoll = this.shotAt(index)?.dutch ?? 0; + const roll = targetRoll * t; + // camera.up = rotate world-up around the camera's forward axis by `roll`. + _tmpDir.set(0, 0, -1).applyQuaternion(this.camera.quaternion); // forward + this.camera.up.set(0, 1, 0).applyAxisAngle(_tmpDir, roll); + } +} + +function easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; +} diff --git a/apps/dashboard/src/lib/graph/cinema/narrator.ts b/apps/dashboard/src/lib/graph/cinema/narrator.ts new file mode 100644 index 0000000..7e3d429 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/narrator.ts @@ -0,0 +1,148 @@ +// Memory Cinema β€” narration tiers 1 & 2. +// +// Tier 1 (premium): a backend LLM endpoint (/api/narrative) authors rich prose +// from the planned path. Used only when the backend advertises it. +// Tier 2 (smart local default): deterministic, structured captions generated +// purely from the real node/edge data β€” no network, no LLM, instant. This is +// what the static HN demo and any backend-without-LLM setup uses. +// +// Tier 3 (the BFS camera engine in director.ts) always runs underneath; the +// narrator only decides what TEXT accompanies each beat. If everything here +// fails, captions fall back to Tier 2, which cannot fail. + +import type { CinemaBeat, CinemaPath } from './pathfinder'; + +export interface BeatNarration { + nodeId: string; + /** The caption shown + optionally spoken for this beat. */ + text: string; + /** Short label for the beat kind, shown as a chip. */ + chip: string; +} + +export type NarrationSource = 'backend-llm' | 'local-captions'; + +export interface CinemaNarration { + source: NarrationSource; + beats: BeatNarration[]; +} + +// `satisfies` makes the compiler error if a new CinemaBeat['kind'] is added +// without a chip here β€” closes the silent "undefined chip β†’ blank UI" gap. +const KIND_CHIP = { + origin: 'Origin', + connection: 'Connection', + contradiction: 'Tension', + recent: 'Now', + bridge: 'Jump', + surprise: 'Surprise', +} satisfies Record; + +function snippet(content: string, max = 90): string { + const s = (content ?? '').replace(/\s+/g, ' ').trim(); + if (s.length <= max) return s; + return s.slice(0, max - 1).trimEnd() + '…'; +} + +function typeLabel(nodeType: string): string { + const t = (nodeType ?? 'memory').toLowerCase(); + return t.charAt(0).toUpperCase() + t.slice(1); +} + +/** + * Tier 2 β€” deterministic structured captions from real data only. + * Never throws; always returns a caption per beat. + */ +export function localCaptions(path: CinemaPath): CinemaNarration { + const beats: BeatNarration[] = path.beats.map((beat, i) => { + const n = beat.node; + const what = snippet(n.label || `(${typeLabel(n.type)} memory)`); + let text: string; + switch (beat.kind) { + case 'origin': + text = `We begin at a ${typeLabel(n.type).toLowerCase()} the graph is centered on β€” "${what}".`; + break; + case 'contradiction': { + const via = beat.viaEdge?.type ? beat.viaEdge.type.replace(/_/g, ' ') : 'a conflict'; + text = `This is held in tension with the last memory through ${via}: "${what}".`; + break; + } + case 'recent': + text = `And where the mind is now β€” a recent memory: "${what}".`; + break; + case 'bridge': + text = `Crossing to a separate cluster β€” "${what}".`; + break; + default: { + const w = beat.viaEdge?.weight ?? 0; + const strength = w > 0.66 ? 'strongly' : w > 0.33 ? 'closely' : 'loosely'; + text = `${strength} connected from there: a ${typeLabel(n.type).toLowerCase()} β€” "${what}".`; + } + } + // Tags add texture when present. + if (n.tags && n.tags.length > 0 && i > 0) { + text += ` [${n.tags.slice(0, 3).join(', ')}]`; + } + return { nodeId: beat.nodeId, text, chip: KIND_CHIP[beat.kind] }; + }); + return { source: 'local-captions', beats }; +} + +/** + * Resolve the best available narration for a path. + * + * @param fetchBackend optional async fn that returns backend-LLM narration + * beats (Tier 1). If it's absent, rejects, times out, or returns a mismatched + * shape, we silently fall back to Tier 2 local captions. The caller passes + * this only when the backend has advertised /api/narrative support. + */ +export async function resolveNarration( + path: CinemaPath, + fetchBackend?: () => Promise +): Promise { + const fallback = localCaptions(path); + if (!fetchBackend) return fallback; + + let timer: ReturnType | undefined; + try { + const backend = await Promise.race([ + fetchBackend(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), 6000); + }), + ]); + + // Keep only well-formed backend beats (guards against null/empty/garbage + // entries that would otherwise produce blank captions mid-tour). + const valid = Array.isArray(backend) + ? backend.filter( + (b): b is BeatNarration => + !!b && typeof b.nodeId === 'string' && typeof b.text === 'string' && b.text.trim().length > 0 + ) + : []; + if (valid.length === 0) return fallback; + + // Align backend beats to the real path by nodeId; fill any gap from the + // bounds-safe local caption so every beat always has text (never blank). + const byNode = new Map(valid.map((b) => [b.nodeId, b])); + const beats: BeatNarration[] = path.beats.map((beat, i) => { + const hit = byNode.get(beat.nodeId); + if (hit) { + const chip = typeof hit.chip === 'string' && hit.chip.trim() ? hit.chip : KIND_CHIP[beat.kind]; + return { nodeId: beat.nodeId, text: hit.text, chip }; + } + return ( + fallback.beats[i] ?? { + nodeId: beat.nodeId, + text: beat.node.label || '(unlabeled memory)', + chip: KIND_CHIP[beat.kind], + } + ); + }); + return { source: 'backend-llm', beats }; + } catch { + return fallback; + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/dashboard/src/lib/graph/cinema/pathfinder.ts b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts new file mode 100644 index 0000000..4e8a457 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/pathfinder.ts @@ -0,0 +1,171 @@ +// Memory Cinema β€” Tier 3: the bulletproof pathfinder. +// +// Plans a cinematic tour through the REAL memory graph using nothing but the +// nodes + edges the backend already returns. This is the deterministic engine +// that ALWAYS drives the camera, regardless of which narration tier (backend +// LLM / local captions / none) is active. No WebGPU, no network, no LLM β€” if +// everything else fails, this still produces a coherent, watchable flythrough. +// +// The path is intentionally a STORY, not a raw BFS dump: +// 1. start at the center (the memory the graph is focused on) +// 2. visit its strongest-weighted connections (what it's most tied to) +// 3. detour to a contradiction edge if one exists (tension = interesting) +// 4. end on a recently-created node (where the mind is now) +// Falling back to plain weighted BFS when those signals are absent. + +import type { GraphNode, GraphEdge } from '$types'; + +export interface CinemaBeat { + /** Node this beat centers the camera on. */ + nodeId: string; + /** The node payload, for the narrator + visuals. */ + node: GraphNode; + /** Edge traversed to arrive here (null for the opening beat). */ + viaEdge: GraphEdge | null; + /** Why this beat exists β€” drives the deterministic caption + visual emphasis. */ + kind: 'origin' | 'connection' | 'contradiction' | 'recent' | 'bridge' | 'surprise'; + /** 0..1 emphasis used by the sandbox to spike emissive/bloom on arrival. */ + intensity: number; +} + +export interface CinemaPath { + beats: CinemaBeat[]; + /** The node the requested centerId resolved to (which the tour actually + * starts from). May differ from the requested centerId when it was missing, + * in which case `pivoted` is true β€” callers can surface this if they care. */ + centerId: string; + /** True when the requested centerId did not exist and we picked a start node. */ + pivoted: boolean; + /** Edges that should visibly "flow" during the tour, in beat order. */ + flowEdges: GraphEdge[]; +} + +export interface Adjacency { + [nodeId: string]: { edge: GraphEdge; otherId: string }[]; +} + +export function buildAdjacency(edges: GraphEdge[]): Adjacency { + const adj: Adjacency = {}; + for (const edge of edges) { + (adj[edge.source] ??= []).push({ edge, otherId: edge.target }); + (adj[edge.target] ??= []).push({ edge, otherId: edge.source }); + } + // Strongest connections first so the tour visits the most meaningful ties. + for (const id of Object.keys(adj)) { + adj[id].sort((a, b) => (b.edge.weight ?? 0) - (a.edge.weight ?? 0)); + } + return adj; +} + +export function isContradictionEdge(edge: GraphEdge): boolean { + const t = (edge.type ?? '').toLowerCase(); + return t.includes('contradict') || t.includes('conflict') || t.includes('supersede'); +} + +export function recencyOf(node: GraphNode): number { + // Larger = more recent. Tolerates missing/invalid timestamps. + const t = Date.parse(node.updatedAt || node.createdAt || ''); + return Number.isFinite(t) ? t : 0; +} + +/** + * Plan a cinematic path over the real graph. + * + * @param maxBeats hard cap on tour length (keeps the flythrough watchable). + * Deterministic: same inputs always yield the same path (no randomness), so the + * recorded launch GIF is reproducible. + */ +export function planCinemaPath( + nodes: GraphNode[], + edges: GraphEdge[], + centerId: string, + maxBeats = 7 +): CinemaPath { + const byId = new Map(nodes.map((n) => [n.id, n])); + const empty: CinemaPath = { beats: [], centerId, pivoted: false, flowEdges: [] }; + if (nodes.length === 0) return empty; + + // Resolve a real starting node: prefer centerId, else the explicit center + // flag, else the most-connected node, else the first node. Track whether we + // had to pivot off the requested centerId so callers can surface it. + const adj = buildAdjacency(edges); + const requestedExists = byId.has(centerId); + let startId = requestedExists ? centerId : ''; + if (!startId) startId = nodes.find((n) => n.isCenter)?.id ?? ''; + if (!startId) { + startId = nodes + .map((n) => ({ id: n.id, deg: adj[n.id]?.length ?? 0 })) + .sort((a, b) => b.deg - a.deg)[0].id; + } + const start = byId.get(startId); + if (!start) return empty; + const pivoted = !requestedExists; + + const visited = new Set([startId]); + const beats: CinemaBeat[] = [ + { nodeId: startId, node: start, viaEdge: null, kind: 'origin', intensity: 1 }, + ]; + const flowEdges: GraphEdge[] = []; + + // Greedy weighted walk: from the current frontier, step to the strongest + // unvisited neighbour, with a one-time detour to a contradiction if reachable. + let current = startId; + let contradictionUsed = false; + + while (beats.length < maxBeats) { + const neighbours = adj[current] ?? []; + + // Prefer an unused contradiction edge once β€” tension makes a better story. + let next: { edge: GraphEdge; otherId: string } | undefined; + if (!contradictionUsed) { + next = neighbours.find((n) => !visited.has(n.otherId) && isContradictionEdge(n.edge)); + if (next) contradictionUsed = true; + } + // Otherwise the strongest unvisited tie. + if (!next) next = neighbours.find((n) => !visited.has(n.otherId)); + + // Dead end: hop to the most recent unvisited node anywhere (a "bridge" + // cut) so the tour can keep going instead of stalling. + if (!next) { + const remaining = nodes + .filter((n) => !visited.has(n.id)) + .sort((a, b) => recencyOf(b) - recencyOf(a)); + if (remaining.length === 0) break; + const node = remaining[0]; + visited.add(node.id); + beats.push({ nodeId: node.id, node, viaEdge: null, kind: 'bridge', intensity: 0.6 }); + current = node.id; + continue; + } + + const node = byId.get(next.otherId); + if (!node) { + visited.add(next.otherId); + continue; + } + visited.add(node.id); + flowEdges.push(next.edge); + beats.push({ + nodeId: node.id, + node, + viaEdge: next.edge, + kind: isContradictionEdge(next.edge) ? 'contradiction' : 'connection', + intensity: isContradictionEdge(next.edge) ? 1 : Math.min(1, 0.55 + (next.edge.weight ?? 0) * 0.45), + }); + current = node.id; + } + + // Closing beat: end on the single most-recent node not already the finale, + // so the tour lands on "where the memory is now". Only if it adds variety. + if (beats.length < maxBeats) { + const last = beats[beats.length - 1].nodeId; + const recent = nodes + .filter((n) => n.id !== last) + .sort((a, b) => recencyOf(b) - recencyOf(a))[0]; + if (recent && !beats.some((b) => b.nodeId === recent.id)) { + beats.push({ nodeId: recent.id, node: recent, viaEdge: null, kind: 'recent', intensity: 0.8 }); + } + } + + return { beats, centerId: startId, pivoted, flowEdges }; +} diff --git a/apps/dashboard/src/lib/graph/cinema/sandbox.ts b/apps/dashboard/src/lib/graph/cinema/sandbox.ts new file mode 100644 index 0000000..fbc7fe1 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/sandbox.ts @@ -0,0 +1,283 @@ +// Memory Cinema β€” the isolated WebGPU sandbox. +// +// Boots a SEPARATE WebGPU canvas + scene on Cinema launch. The legacy WebGL +// graph (nebula, grain, every current user's experience) is never touched β€” +// zero regression by construction. Inside the sandbox: the SemanticComputeStorm +// + selective MRT emissive bloom, driven by the CinemaDirector's beats. +// +// Everything here is dynamically imported (three/webgpu, three/tsl, storm.ts) +// so the heavy WebGPU bundle stays out of the main app. If WebGPU is +// unavailable, isSupported() returns false and the caller falls back to the +// camera-only flythrough on the existing canvas (captions still play). + +import * as THREE from 'three'; +import type { SemanticRole, SemanticComputeStorm } from './storm'; + +// The storm lives at the world origin, permanently. The camera always looks here +// and is clamped to a safe distance band so the subject can never leave frame. +const ORIGIN = new THREE.Vector3(0, 0, 0); +// Keep the camera in a narrow, fairly FAR band so the contained storm always +// sits comfortably small and centered in frame (a closer camera makes the cloud +// fill β€” and spill past β€” the edges once the bloom halo is added). +const MIN_CAM_DIST = 30; +const MAX_CAM_DIST = 44; + +export function isWebGPUSupported(): boolean { + return typeof navigator !== 'undefined' && 'gpu' in navigator; +} + +interface SandboxDeps { + WebGPURenderer: new (params: object) => { + init: () => Promise; + setSize: (w: number, h: number) => void; + setPixelRatio: (r: number) => void; + renderAsync: (scene: THREE.Scene, camera: THREE.Camera) => Promise; + computeAsync: (node: unknown) => Promise; + domElement: HTMLCanvasElement; + dispose?: () => void; + }; + PostProcessing: new (renderer: unknown) => { renderAsync: () => Promise; outputNode: unknown }; + StormCtor: typeof SemanticComputeStorm; + tsl: typeof import('three/tsl'); + bloomMod: { bloom: (node: unknown, strength?: number, radius?: number, threshold?: number) => unknown }; +} + +export class CinemaSandbox { + private container: HTMLElement; + private deps!: SandboxDeps; + private renderer!: SandboxDeps['WebGPURenderer']['prototype']; + // Scene/camera are created in boot() from the three/webgpu module so every + // object handed to the WebGPU renderer comes from the SAME Three.js instance + // (avoids the "multiple instances of Three.js" incompatibility β€” the base + // three import is used only for the shared Vector3 math type the director + // mutates, which is identical across instances). + private scene!: THREE.Scene; + private camera!: THREE.PerspectiveCamera; + private storm!: SemanticComputeStorm; + private post: { renderAsync: () => Promise } | null = null; + private booted = false; + + /** Camera target the director drives; mirrored into camera.lookAt each frame. */ + readonly target = new THREE.Vector3(0, 0, 0); + + // FLYTHROUGH β€” when >0, relaxes the camera-distance clamp floor so the camera + // can plunge inside the shell, and the storm stretches sprites along the + // apparent motion vector. Camera velocity is derived per-frame from the + // position delta (one Vector3, no compute). 0 = no streak (reduced-motion). + private flythrough = 0; + private prevCamPos = new THREE.Vector3(); + private camVel = new THREE.Vector3(); + + constructor(container: HTMLElement) { + this.container = container; + } + + get cameraRef(): THREE.PerspectiveCamera { + return this.camera; + } + + /** + * Boot the WebGPU pipeline. Throws if WebGPU is unsupported or init fails β€” + * the caller treats a throw as "fall back to camera-only mode". + */ + async boot(): Promise { + if (this.booted) return; + if (!isWebGPUSupported()) throw new Error('WebGPU not supported'); + + // Dynamic imports keep three/webgpu out of the main bundle. + const webgpu = (await import('three/webgpu')) as unknown as { + WebGPURenderer: SandboxDeps['WebGPURenderer']; + PostProcessing: SandboxDeps['PostProcessing']; + Scene: new () => THREE.Scene; + PerspectiveCamera: new (fov: number, aspect: number, near: number, far: number) => THREE.PerspectiveCamera; + Color: new (hex: number) => THREE.Color; + }; + const tsl = (await import('three/tsl')) as typeof import('three/tsl'); + // bloom() lives in the TSL display helpers; import the node module. + const bloomMod = (await import( + 'three/examples/jsm/tsl/display/BloomNode.js' + )) as unknown as SandboxDeps['bloomMod']; + const { SemanticComputeStorm } = await import('./storm'); + + this.deps = { + WebGPURenderer: webgpu.WebGPURenderer, + PostProcessing: webgpu.PostProcessing, + StormCtor: SemanticComputeStorm, + tsl, + bloomMod, + }; + + // Fail loud if the dynamic import didn't yield the expected constructors, + // instead of a cryptic "undefined is not a constructor" later. + if (!webgpu.WebGPURenderer || !webgpu.Scene || !webgpu.PerspectiveCamera || !webgpu.Color) { + throw new Error('[cinema] three/webgpu is missing expected exports'); + } + + // Build scene + camera from the SAME (webgpu) module instance the + // renderer + storm use, so all objects are instance-compatible. + const w = Math.max(1, this.container.clientWidth); + const h = Math.max(1, this.container.clientHeight); + this.scene = new webgpu.Scene(); + this.scene.background = new webgpu.Color(0x02020a); + this.camera = new webgpu.PerspectiveCamera(60, w / h, 0.1, 2000); + this.camera.position.set(0, 18, 60); + + const renderer = new this.deps.WebGPURenderer({ antialias: true, alpha: false }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(w, h); + // CRITICAL FOOTGUN: WebGPU init is async. Must await before first render + // or the canvas silently draws nothing. + await renderer.init(); + this.container.appendChild(renderer.domElement); + this.renderer = renderer; + + // The compute storm (150k GPU particles). + this.storm = new this.deps.StormCtor(renderer, this.scene, {}); + + // Selective MRT bloom: scene pass emits an emissive MRT; bloom only the + // emissive channel so the storm blazes against the void without washing + // the whole frame to grey. Falls back to a plain pass if MRT setup + // throws on a given driver. + try { + const { pass, mrt, output, emissive } = this.deps.tsl as unknown as { + pass: (s: THREE.Scene, c: THREE.Camera) => { + setMRT: (m: unknown) => void; + getTextureNode: (name: string) => unknown; + }; + mrt: (cfg: Record) => unknown; + output: unknown; + emissive: unknown; + }; + const scenePass = pass(this.scene, this.camera); + if (typeof scenePass?.setMRT !== 'function' || typeof scenePass?.getTextureNode !== 'function') { + throw new Error('three/tsl pass() API mismatch β€” setMRT/getTextureNode missing'); + } + scenePass.setMRT(mrt({ output, emissive })); + const outputTex = scenePass.getTextureNode('output'); + const emissiveTex = scenePass.getTextureNode('emissive'); + // Gentler bloom (strength 0.6, threshold 0.35) so it accents the bright + // cores instead of washing the whole colored cloud to white. + const bloomed = this.deps.bloomMod.bloom(emissiveTex, 0.6, 0.65, 0.35); + const post = new this.deps.PostProcessing(renderer); + (post as unknown as { outputNode: unknown }).outputNode = ( + outputTex as { add: (n: unknown) => unknown } + ).add(bloomed); + this.post = post as unknown as { renderAsync: () => Promise }; + } catch (e) { + // MRT/bloom unavailable on this driver β€” render straight, no crash. + console.warn('[cinema] selective bloom unavailable, rendering without MRT:', e); + this.post = null; + } + + this.booted = true; + } + + /** Retarget the storm's MODE/ignition for a beat. The storm is permanently + * centered at the WORLD ORIGIN (see render) so it is always dead-center in + * frame β€” worldPos here only conveys which node, not where the storm sits. + * `act` lets the storm hold Act I dimmer (it opens too hot otherwise). */ + transitionTo( + role: SemanticRole, + _worldPos: THREE.Vector3, + act: 'I' | 'II' | 'III' = 'II', + beatIndex = 99 + ): void { + if (!this.booted) return; + this.storm.transitionTo(role, ORIGIN, act, beatIndex); + } + + /** Fire one endless-dream beat β€” a random crazier figure + color blast. Called + * on a timer after the scripted tour ends so the storm never sits idle. */ + dreamBeat(): void { + if (!this.booted) return; + this.storm.dreamBeat(); + } + + /** Flythrough strength 0..1. Relaxes the clamp floor so the camera can dive + * inside the shell, and drives the storm's velocity-stretch streak. Set to 0 + * for reduced-motion (no streak, normal clamp). */ + setFlythrough(s: number): void { + this.flythrough = THREE.MathUtils.clamp(s, 0, 1); + if (this.booted) this.storm.setStreak(s); + } + + /** Pass-through: set the storm streak strength directly. */ + setStreak(s: number): void { + if (this.booted) this.storm.setStreak(s); + } + + /** Pass-through: push view-space camera velocity to the storm. */ + setCameraVel(v: THREE.Vector3): void { + if (this.booted) this.storm.setCameraVel(v); + } + + /** Render one frame. The storm is pinned to the origin and the camera always + * looks at the origin, so the storm CANNOT leave the frame. The director + * varies only the camera's orbital position/angle (set via cameraRef), and we + * clamp that to a safe distance band here as a final guarantee. */ + async render(deltaSeconds: number): Promise { + if (!this.booted) return; + + // Camera velocity (world units / sec) from the position delta this frame β€” + // one Vector3 subtract, no compute. Captured BEFORE the clamp so it tracks + // the director's intended move (the clamp only rescues runaway distances). + this.camVel.copy(this.camera.position).sub(this.prevCamPos).divideScalar(Math.max(deltaSeconds, 1e-3)); + this.prevCamPos.copy(this.camera.position); + + // Hard guarantee: clamp the camera into a distance band from origin so a + // runaway director move can never push the subject out of view, then look + // dead at the origin where the storm lives. Flythrough relaxes the floor + // toward 6 so the camera can plunge inside the shell; the MAX clamp stands. + const minDist = THREE.MathUtils.lerp(MIN_CAM_DIST, 6, this.flythrough); + const distToOrigin = this.camera.position.length(); + if (distToOrigin < minDist || distToOrigin > MAX_CAM_DIST || !Number.isFinite(distToOrigin)) { + const d = Math.min(MAX_CAM_DIST, Math.max(minDist, distToOrigin || MAX_CAM_DIST)); + if (distToOrigin > 1e-3) this.camera.position.setLength(d); + else this.camera.position.set(0, 12, d); + } + this.camera.lookAt(ORIGIN); + + // Push view-space apparent particle velocity to the storm (negated world + // camera velocity transformed into view space β†’ the direction sprites + // appear to streak). matrixWorldInverse is the previous frame's (the + // renderer refreshes it during renderAsync below) β€” a one-frame lag that is + // imperceptible for a streak direction. + const camVelView = this.camVel + .clone() + .applyMatrix3(new THREE.Matrix3().setFromMatrix4(this.camera.matrixWorldInverse)) + .negate(); + this.storm.setCameraVel(camVelView); + + // Size the containment sphere to the camera's VERTICAL FOV at the origin + // (the limiting dimension on a landscape frame). 0.82 lets the storm fill + // most of the frame; the storm's internal shell sits well inside this and + // the hard boundary snap keeps the bloom halo from spilling past the edge. + const dist = this.camera.position.length(); + const vfov = (this.camera.fov * Math.PI) / 180; + const fitRadius = Math.tan(vfov / 2) * dist * 0.82; + this.storm.setContainRadius(fitRadius); + + await this.storm.update(deltaSeconds); + if (this.post) await this.post.renderAsync(); + else await this.renderer.renderAsync(this.scene, this.camera); + } + + resize(): void { + if (!this.booted) return; + const w = Math.max(1, this.container.clientWidth); + const h = Math.max(1, this.container.clientHeight); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(w, h); + } + + dispose(): void { + if (!this.booted) return; + this.storm?.dispose(); + this.renderer?.dispose?.(); + if (this.renderer?.domElement?.parentNode) { + this.renderer.domElement.parentNode.removeChild(this.renderer.domElement); + } + this.booted = false; + } +} diff --git a/apps/dashboard/src/lib/graph/cinema/storm.ts b/apps/dashboard/src/lib/graph/cinema/storm.ts new file mode 100644 index 0000000..55caa26 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/storm.ts @@ -0,0 +1,1006 @@ +// Memory Cinema β€” the Semantic Compute Storm (WebGPU / TSL GPGPU). +// +// 150k particles whose physics run ENTIRELY on the GPU via Three Shading +// Language compute nodes. The storm shifts behaviour with the narrative beat: +// - origin/anchor β†’ stable orbital swarm around the focused node +// - connection β†’ fluid streaming toward the target with wave motion +// - contradiction β†’ explosive RΓΆssler strange-attractor chaos (crimson) +// Emissive colour is routed so only the storm blazes through the selective +// MRT bloom pass against a clean void. +// +// IMPORTANT β€” verified against the INSTALLED three@0.172 three/tsl build: +// * use select() (NOT cond β€” does not exist in this build) +// * use TSL sin()/cos() (NOT Math.sin inside Fn) +// * SpriteNodeMaterial (NOT SpritePointsMaterial) +// * renderer.computeAsync() for the dispatch +// The whole module is dynamically imported only when Cinema launches, so the +// heavy three/webgpu + three/tsl bundles never load for normal dashboard use. +// +// This file is intentionally framework-agnostic and uses `any` for the WebGPU +// renderer type: three/webgpu's WebGPURenderer is a runtime-only dynamic import +// (kept out of the main bundle), so a compile-time type isn't available here. + +import * as THREE from 'three'; +// StorageBufferAttribute + SpriteNodeMaterial live in the three/webgpu entry, +// not the base three module. This file is dynamically imported only at Cinema +// launch, so pulling from three/webgpu here does NOT add WebGPU to the main +// bundle. +import { StorageBufferAttribute, SpriteNodeMaterial } from 'three/webgpu'; +import { + Fn, + storage, + instanceIndex, + vec3, + uniform, + select, + float, + sin, + cos, + length, + clamp, + min, + mix, + fract, + abs, + floor, + smoothstep, + oneMinus, + cross, + sqrt, + pow, + mx_noise_vec3, + vec2, + atan, + positionView, +} from 'three/tsl'; +// note: .max()/.div()/.sub()/.cos()/.sin()/.log()/.lessThanEqual() etc. are +// fluent methods on TSL nodes β€” no import needed. + +export type SemanticRole = 'anchor' | 'connection' | 'contradiction'; + +const ROLE_MODE: Record = { + anchor: 0, + connection: 1, + contradiction: 2, +}; + +export interface StormOptions { + count?: number; + /** World-space radius of the initial particle cloud. */ + spawnRadius?: number; +} + +/** + * GPU compute particle storm. Construct with a WebGPURenderer + Scene, call + * update(dt) each frame, and transitionTo(role, worldPos) on each narrative + * beat. dispose() releases all GPU resources. + */ +/** The TSL compute node Fn(...)().compute(count) produces. three@0.172 does not + * export a public type for it; it is opaque and only handed to computeAsync(). */ +type ComputeDispatch = ReturnType>['compute']>; + +export class SemanticComputeStorm { + readonly count: number; + private scene: THREE.Scene; + // WebGPURenderer β€” runtime-only type (dynamic import); see file header. + private renderer: { computeAsync: (node: ComputeDispatch) => Promise }; + + private bufferPos: StorageBufferAttribute | null; + private bufferVel: StorageBufferAttribute | null; + private bufferPhase: StorageBufferAttribute | null; + + // Definite-assigned in buildCompute() (called from the constructor). + private computeNode!: ComputeDispatch; + private mesh: THREE.InstancedMesh | null = null; + private material: THREE.Material | null = null; + + // Serialize GPU compute dispatches: never queue a new compute pass before the + // previous one resolves, or the WebGPU dispatch queue backs up and stalls. + private computeInFlight: Promise | null = null; + + // Uniforms driven from the camera/beat loop. uIgnition starts non-zero so + // the storm is visible on the very first frame (before any beat fires). + private uTarget = uniform(new THREE.Vector3(0, 0, 0)); + private uTime = uniform(0); + private uIgnition = uniform(0.2); + private uMode = uniform(0); + // World-space radius the storm is contained within. Particles past this get + // a spring force back so the storm NEVER flies off-screen. Sized to the + // camera framing by the sandbox via setContainRadius(). + private uContainRadius = uniform(48); + // Global hue rotation (advances over time) + how strongly the beat's mode + // tint overrides the rainbow (0 = full rainbow, 1 = full mode color). + private uHueShift = uniform(0); + private uModeTintAmt = uniform(0.25); + // Detonation cycle: spikes to 1 on each beat (explosion), decays to 0 + // (crystallize/reform). Drives the explodeβ†’pixelateβ†’reform look. + private uBurst = uniform(0); + // ACT DIMMER β€” a master brightness scalar set per beat from the narrative + // act. Act I opens too hot (the cloud is still in its dense initial spawn and + // the first ignition flash stacks on top), so we hold Act I dimmer and let + // Acts II/III blaze at full. 1.0 = full brightness. Starts very low so the + // pre-first-beat / beat-0 boot frames fade in soft instead of flashing white. + private uActDim = uniform(0.12); + // WORLD STATE MACHINE β€” each narrative beat (1..7) is a UNIQUE visual world: + // 0 nebula mist Β· 1 orbital anchor Β· 2 strange attractor Β· 3 detonation void + // 4 crystal lattice Β· 5 fluid galaxy Β· 6 phyllotaxis bloom + // Beats map 1:1 to worlds (beatIndex % 7). The compute kernel builds all 7 + // home targets + forces and select()s the live one β€” particles are never + // swapped, only the forces acting on them, which IS the journey. + private uWorld = uniform(0); + private uPrevWorld = uniform(0); + // Crossfade prevβ†’current world over ~1s after each beat (eased in update()). + // 1 = fully previous world, 0 = fully current. + private uBlend = uniform(0); + private readonly worldCount = 7; + // COLOR BLAST β€” a LONG-LIVED chroma envelope, decoupled from the fast physics + // burst so the detonation color OUTLIVES the shockwave (owner: "color too + // brief"). uBlast is the 0..1 magnitude (slow ~2.8s decay); uBlastTime counts + // seconds since the last detonation and drives the outward spectral wave. + private uBlast = uniform(0); + private uBlastTime = uniform(0); + // ENDLESS DREAM MODE β€” after the scripted 7-beat tour, the storm keeps + // generating crazier figures forever instead of sitting idle. uMorphSeed + // randomizes each procedural figure (worlds 7..11); uChaos ramps 0β†’1 over the + // dream so every figure is wilder than the last. + private uMorphSeed = uniform(0); + private uChaos = uniform(0); + // JARRING CLASH PAIR β€” which opposing inner/outer duotone is live (0..4). Set + // per beat so every figure is a fresh ice-vs-fire / acid-vs-blood collision. + private uClash = uniform(0); + // NEAR-PLANE FADE β€” particles dissolve as they pass very close to the camera + // (flythrough) so they never additive-pop. Distance band in world units. + private uFadeNear = uniform(2.0); + private uFadeBand = uniform(7.0); + // VOLUMETRIC FOG β€” distant particles dim toward the void with view depth (exp + // falloff) for atmospheric depth. Combined with near-fade in one depth read. + private uFogDensity = uniform(0.012); + // DEPTH OF FIELD β€” off-focus particles dim (read as bokeh defocus under the + // bloom). Folded into the single depthFade depth read (no sprite-scale, which + // is finicky + collides with the streak). Focus tracks the dive. + private uFocus = uniform(28.0); + private uFocusRange = uniform(20.0); // wider in-focus band β†’ most of figure crisp + private uDofDim = uniform(0.3); // subtle off-focus fade β†’ depth without darkening + // INFINITE DROSTE ZOOM β€” the spine. The cloud endlessly dives inward: the + // nested inner figure grows by Ξ» each period to become the new outer shell, + // while a fresh inner spawns inside, looping FOREVER with no seam. Ξ» = 1/0.52 + // (the inner scale) makes innerβ†’outer EXACT so the snap is invisible. Pure + // fract(uTime/T) β€” no camera dolly (can't clip / fight the camera clamp). + private uZoomPeriod = uniform(9.0); // T: one promotion every 9s + private uLambda = uniform(1.923); // 1 / 0.52 self-similar ratio + private uZoomOn = uniform(0); // 0 = off (beats 0/1, reduced-motion), 1 = diving + // VELOCITY-STRETCH FLYTHROUGH STREAK β€” when the camera plunges through the + // shell, sprites elongate along the screen-space apparent motion vector (a + // motion-streak look). Pure scaleNode/rotationNode (a SEPARATE output graph + // from color/emissive) + camera-velocity uniforms β†’ zero per-frame compute, + // no positionView read in color/emissive. Strength is gated to 0 from JS for + // reduced-motion, so this is a no-op until the director drives uStreak. + private uCamVelView = uniform(new THREE.Vector3(0, 0, 0)); // view-space apparent particle velocity + private uStreak = uniform(0); // 0..1 flythrough strength + private uMaxStretch = uniform(7.0); + // JS-side dream state (not uniforms): which figure is live + how many fired. + private dreamCount = 0; + + constructor( + renderer: { computeAsync: (node: ComputeDispatch) => Promise }, + scene: THREE.Scene, + opts: StormOptions = {} + ) { + this.renderer = renderer; + this.scene = scene; + this.count = opts.count ?? 150_000; + // Spawn particles ALREADY SPREAD across a wide spherical SHELL (not a tiny + // dense ball at the origin). The old Β±8 cube packed all 150k into a tiny + // volume, so the very first frame (Beat 0, before the cloud expands to its + // rim-falloff homes) was a solid white blob β€” additive overlap dominates at + // high density regardless of per-particle dimming. Booting on a broad shell + // means the storm reads as a calm colored cloud from frame one. + const spawn = opts.spawnRadius ?? 34; + + const positions = new Float32Array(this.count * 3); + const velocities = new Float32Array(this.count * 3); + const phases = new Float32Array(this.count); + for (let i = 0; i < this.count; i++) { + // Uniform direction on a sphere, radius biased to the outer shell so the + // boot cloud is hollow-cored like the rim look (never a dense center). + const u1 = Math.random(); + const u2 = Math.random(); + const theta = u1 * Math.PI * 2; + const z = u2 * 2 - 1; + const r = Math.sqrt(Math.max(0, 1 - z * z)); + const rad = spawn * (0.55 + Math.random() * 0.45); // shell 0.55..1.0 + positions[i * 3] = Math.cos(theta) * r * rad; + positions[i * 3 + 1] = z * rad; + positions[i * 3 + 2] = Math.sin(theta) * r * rad; + phases[i] = Math.random() * Math.PI * 2; + } + const bufferPos = new StorageBufferAttribute(positions, 3); + const bufferVel = new StorageBufferAttribute(velocities, 3); + const bufferPhase = new StorageBufferAttribute(phases, 1); + this.bufferPos = bufferPos; + this.bufferVel = bufferVel; + this.bufferPhase = bufferPhase; + + this.buildCompute(bufferPos, bufferVel, bufferPhase); + this.buildRender(bufferPos, bufferPhase); + } + + private buildCompute( + bufferPos: StorageBufferAttribute, + bufferVel: StorageBufferAttribute, + bufferPhase: StorageBufferAttribute + ): void { + const posStore = storage(bufferPos, 'vec3', this.count); + const velStore = storage(bufferVel, 'vec3', this.count); + const phaseStore = storage(bufferPhase, 'float', this.count); + + this.computeNode = Fn(() => { + const pos = posStore.element(instanceIndex); + const vel = velStore.element(instanceIndex); + const phase = phaseStore.element(instanceIndex); + + // ── DETERMINISTIC PER-PARTICLE BASIS (phase β†’ stable spherical coords) ── + const a1 = phase.mul(12.9898).sin().mul(43758.5453); + const a2 = phase.mul(78.233).sin().mul(12543.531); + const a3 = phase.mul(39.346).sin().mul(24634.633); + const u = fract(a1); // 0..1 + const v = fract(a2); // 0..1 + const w2 = fract(a3); // 0..1 + const theta = u.mul(6.28318); // azimuth 0..2Ο€ + const phi = v.mul(3.14159); // polar 0..Ο€ + const R = this.uContainRadius; + // Outer-shell bias (0.62..1.0) keeps the core hollow β†’ reads as color, + // not a white-blooming dense center. (The dialed-in anti-white-out.) + const shellT = fract(phase.mul(3.7)); + const homeFrac = float(0.62).add(shellT.mul(shellT).mul(0.38)); + const fi = float(instanceIndex); // particle index as float (phyllotaxis) + + // ── CURL NOISE (divergence-free flow β†’ worlds 0 nebula, 5 fluid) ── + // Never clumps, never stops; the signature "living smoke" motion. + const curl = Fn(([p]: [ReturnType]) => { + const e = float(0.6); + const dx = mx_noise_vec3(p.add(vec3(e, 0, 0))).sub(mx_noise_vec3(p.sub(vec3(e, 0, 0)))); + const dy = mx_noise_vec3(p.add(vec3(0, e, 0))).sub(mx_noise_vec3(p.sub(vec3(0, e, 0)))); + const dz = mx_noise_vec3(p.add(vec3(0, 0, e))).sub(mx_noise_vec3(p.sub(vec3(0, 0, e)))); + return vec3(dy.z.sub(dz.y), dz.x.sub(dx.z), dx.y.sub(dy.x)).normalize(); + }); + + // ── 7 WORLD HOME TARGETS (all centered on origin β†’ centroid can't drift) ── + const sphereShell = vec3(sin(phi).mul(cos(theta)), cos(phi), sin(phi).mul(sin(theta))); + const wNebula = sphereShell.mul(R.mul(homeFrac)); // world 0 (and 3 base) + const wAnchor = sphereShell.mul(R.mul(float(0.5).add(shellT.mul(0.3)))); // world 1 + // world 2 attractor: home is "ahead" along the Thomas flow from current pos. + const bT = float(0.19); + const thomas = vec3( + sin(pos.y).sub(pos.x.mul(bT)), + sin(pos.z).sub(pos.y.mul(bT)), + sin(pos.x).sub(pos.z.mul(bT)) + ); + const wAttractor = pos.add(thomas.mul(R.mul(0.12))); + const wVoid = wNebula; // world 3 = sphere; the burst dominates this beat + const wCrystal = vec3( // world 4 cube lattice + u.sub(0.5).mul(2).mul(R.mul(0.8)), + v.sub(0.5).mul(2).mul(R.mul(0.8)), + w2.sub(0.5).mul(2).mul(R.mul(0.8)) + ); + const armAng = u.mul(6.28318).mul(3).add(w2.mul(0.6)); // world 5 galaxy spiral + const gr = R.mul(0.2).add(R.mul(0.8).mul(w2)); + const wGalaxy = vec3( + gr.mul(cos(armAng)), + R.mul(0.06).mul(sin(phase.mul(20))), + gr.mul(sin(armAng)) + ); + const golden = float(2.39996323); // world 6 phyllotaxis (Vogel sunflower) + const pAng = fi.mul(golden); + const pRad = sqrt(fi).mul(R.mul(0.0042)); // ~R at 150k particles + const wPhyllo = vec3(pAng.cos().mul(pRad), R.mul(0.04).mul(sin(phase.mul(9))), pAng.sin().mul(pRad)); + + // ══════════════════════════════════════════════════════════════════ + // ENDLESS DREAM FIGURES (worlds 7..11) β€” the generative mode that + // kicks in after the scripted 7-beat tour. These are PROCEDURAL and + // RANDOMIZED: uMorphSeed (set per auto-beat) + uChaos (ramps up over + // time β†’ each figure crazier than the last) modulate the parameters, + // so the same world index never looks the same twice. + // ══════════════════════════════════════════════════════════════════ + const seed = this.uMorphSeed; + const chaos = this.uChaos; + // seeded per-figure scalars (deterministic hash of the seed) + const s1 = fract(seed.mul(0.731).add(0.13)); + const s2 = fract(seed.mul(1.323).add(0.51)); + const s3 = fract(seed.mul(2.117).add(0.27)); + + // ── (u,v) MANIFOLD GRID ── THE spaghettiβ†’skin fix. The hash-scatter + // basis (u,v,w2) is white noise β†’ reads as gas/strings. A deterministic + // tensor grid over instanceIndex makes neighbors share rows/cols, so the + // procedural forms below render as a continuous SCULPTED SKIN, not lines. + // 387Β² = 149769 β‰ˆ 150k. Pure arithmetic on fi β€” no buffers, no indexing. + const GW = float(387); + const ug = fract(fi.div(GW)); // grid u 0..1 (across a row) + const vg = floor(fi.div(GW)).div(GW); // grid v 0..1 (down columns) + + // ── COMPLEX-MATH HELPERS (sinh/cosh/tanh are NOT in three@0.172 β€” expand + // via the confirmed .exp()). Used by the Calabi–Yau + Boy's surface forms. ── + type FNode = ReturnType; + type VNode = ReturnType; + const sinhT = (x: FNode) => x.exp().sub(x.mul(-1).exp()).mul(0.5); + const coshT = (x: FNode) => x.exp().add(x.mul(-1).exp()).mul(0.5); + const cMul = (a: VNode, b: VNode) => + vec2(a.x.mul(b.x).sub(a.y.mul(b.y)), a.x.mul(b.y).add(a.y.mul(b.x))); + const cExp = (a: VNode) => { + const e = a.x.exp(); + return vec2(e.mul(cos(a.y)), e.mul(sin(a.y))); + }; + const cLog = (a: VNode) => + vec2(a.x.mul(a.x).add(a.y.mul(a.y)).max(1e-12).log().mul(0.5), atan(a.y, a.x)); + const cPow = (a: VNode, p: FNode) => cExp(cMul(vec2(p, float(0)), cLog(a))); + const cCosh = (z: VNode) => vec2(coshT(z.x).mul(cos(z.y)), sinhT(z.x).mul(sin(z.y))); + const cSinh = (z: VNode) => vec2(sinhT(z.x).mul(cos(z.y)), coshT(z.x).mul(sin(z.y))); + const cInv = (a: VNode) => { + const dd = a.x.mul(a.x).add(a.y.mul(a.y)).max(1e-6); + return vec2(a.x.div(dd), a.y.mul(-1).div(dd)); + }; + + // world 7 Β· SUPERSHAPE (3D superformula β€” petals/stars/blobs, never same) + const m1 = float(2).add(floor(s1.mul(14))); // symmetry 2..15 + const sfAng = theta; + const sfR1 = pow(abs(cos(m1.mul(sfAng).div(4))), float(2).add(s2.mul(8))) + .add(pow(abs(sin(m1.mul(sfAng).div(4))), float(2).add(s3.mul(8)))) + .add(0.0001) + .pow(float(-0.5)); + const sfR2 = pow(abs(cos(m1.mul(phi).div(4))), float(3)) + .add(pow(abs(sin(m1.mul(phi).div(4))), float(3))) + .add(0.0001) + .pow(float(-0.5)); + const sfRad = R.mul(0.85).mul(clamp(sfR1.mul(sfR2).mul(0.5), 0.1, 1.4)); + const wSuper = vec3( + sin(phi).mul(cos(theta)).mul(sfRad), + cos(phi).mul(sfRad), + sin(phi).mul(sin(theta)).mul(sfRad) + ); + + // ══════ IMPOSSIBLE-GEOMETRY FORM PACK (worlds 8..11) ══════ + // Brand-new signature skins nobody ships as a living particle figure. + + // world 8 Β· CALABI–YAU quintic cross-section (6D string-theory manifold, + // Hanson 4Dβ†’3D projection). 25 interlocking petals; Ξ± rotates it THROUGH + // the 4th dimension so petals pass through each other. The trophy. + const nCY = float(5); + const patch = floor(fract(seed.mul(0.013).add(fi.mul(0.00667))).mul(25)); + const k1 = floor(patch.div(5)); // 0..4 + const k2 = patch.sub(k1.mul(5)); // 0..4 + const cyx = ug.mul(2).sub(1); // x ∈ [-1,1] + const cyy = vg.mul(1.5708); // y ∈ [0, Ο€/2] + const zc = vec2(cyx, cyy); + const e1 = cExp(vec2(float(0), k1.mul(6.28318).div(nCY))); + const e2 = cExp(vec2(float(0), k2.mul(6.28318).div(nCY))); + const z1 = cMul(e1, cPow(cCosh(zc), float(0.4))); // 2/n = 0.4 + const z2 = cMul(e2, cPow(cSinh(zc), float(0.4))); + const alpha = this.uTime.mul(0.25).add(seed).add(chaos.mul(1.5)); + const wKnot = vec3( + z1.x, + z2.x, + cos(alpha).mul(z1.y).add(sin(alpha).mul(z2.y)) + ).mul(R.mul(0.55)); // Β±1.6 β†’ ~0.88R, centroid (0,0,0) + + // world 9 Β· BOY'S SURFACE (Bryant–Kusner minimal immersion of RPΒ²) β€” a + // CLOSED non-orientable surface with one triple point, no spikes. Pure + // rational complex arithmetic over the unit disk β†’ a perfect 2-manifold. + const br = sqrt(ug); // sqrt β†’ uniform area on the disk + const bth = vg.mul(6.28318); + const zb = vec2(br.mul(cos(bth)), br.mul(sin(bth))); + const zb2 = cMul(zb, zb); + const zb3 = cMul(zb2, zb); + const zi2 = cInv(zb2); + const zi3 = cInv(zb3); + const denom = vec2(zb3.x.sub(zi3.x).add(2.2360679), zb3.y.sub(zi3.y)); // +√5 + const aZ = cInv(denom); + const V0 = cMul(vec2(float(0), float(1)), vec2(zb2.x.sub(zi2.x), zb2.y.sub(zi2.y))); + const V1 = vec2(zb2.x.add(zi2.x), zb2.y.add(zi2.y)); + const V2 = cMul(vec2(float(0), float(0.6667)), vec2(zb3.x.add(zi3.x), zb3.y.add(zi3.y))); + const Mx = cMul(aZ, V0).x; + const My = cMul(aZ, V1).x; + const Mz = cMul(aZ, V2).x.add(0.5); + const m2 = Mx.mul(Mx).add(My.mul(My)).add(Mz.mul(Mz)).max(1e-4); // sphere inversion + const wLissa = vec3(Mx.div(m2), My.div(m2), Mz.div(m2).sub(0.86)) // sub centroid z + .mul(R.mul(0.5)); + + // world 10 Β· AIZAWA attractor SHELL (capped spiral torus mapped over u,v; + // the Aizawa vector field added in the motion modifiers makes it breathe). + const az = vg.mul(2).sub(1); // -1..1 vertical + const ar = sqrt(float(1).sub(az.mul(az)).max(0)).mul(0.9).add(0.25); // radial profile + const aang = ug.mul(6.28318).add(az.mul(6).mul(chaos.add(0.4))); // spiral twist + const wHelix = vec3( + ar.mul(cos(aang)), + az.mul(1.4), // centered by construction + ar.mul(sin(aang)) + ).mul(R.mul(0.5)); + + // world 11 Β· GYROID↔SCHWARZ-D Bonnet morph (triply-periodic minimal + // surface β€” alien coral/bone lattice). The Bonnet angle ΞΈ continuously + // BENDS the gyroid into Schwarz-D. A woven solid skin, never seen living. + const period = float(2.2).add(chaos.mul(2.0)); + const gx = ug.mul(6.28318).mul(period); + const gy = vg.mul(6.28318).mul(period); + const gz = this.uTime.mul(0.3).add(seed.mul(6.28318)); + const gyroid = sin(gx).mul(cos(gy)).add(sin(gy).mul(cos(gz))).add(sin(gz).mul(cos(gx))); + const schwD = cos(gx).mul(cos(gy)).mul(cos(gz)).sub(sin(gx).mul(sin(gy)).mul(sin(gz))); + const bonnet = this.uTime.mul(0.15); + const fTPMS = cos(bonnet).mul(gyroid).add(sin(bonnet).mul(schwD)); + const tpmsBase = vec3( + sin(vg.mul(3.14159)).mul(cos(ug.mul(6.28318))), + cos(vg.mul(3.14159)), + sin(vg.mul(3.14159)).mul(sin(ug.mul(6.28318))) + ); + const wFoam = tpmsBase.mul(R.mul(0.5).add(fTPMS.mul(R.mul(0.12)))); // skin Β± displacement + + // select() chain β€” no dynamic indexing in this TSL build. + const homeFor = (idx: ReturnType) => + select(idx.equal(0), wNebula, + select(idx.equal(1), wAnchor, + select(idx.equal(2), wAttractor, + select(idx.equal(3), wVoid, + select(idx.equal(4), wCrystal, + select(idx.equal(5), wGalaxy, + select(idx.equal(6), wPhyllo, + select(idx.equal(7), wSuper, + select(idx.equal(8), wKnot, + select(idx.equal(9), wLissa, + select(idx.equal(10), wHelix, wFoam))))))))))); + const homeCur = homeFor(float(this.uWorld)); + const homePrev = homeFor(float(this.uPrevWorld)); + // uBlend eases prevβ†’cur (smoothstep) so the world morph is silky. + const blendE = smoothstep(float(0), float(1), oneMinus(this.uBlend)); + const outerHome = mix(homePrev, homeCur, blendE); + + // ══════════════════════════════════════════════════════════════════ + // 3D-WITHIN-3D β€” a NESTED INNER FIGURE at the core. + // ~33% of particles (a deterministic slice of the index) form a + // SECOND, smaller shape inside the outer shell β€” a different world, + // counter-rotating, at ~38% scale. This fills the formerly-blank-bright + // center with intentional structure (a figure inside a figure) and adds + // depth nobody ships with particles. The inner world is offset from the + // outer so the two layers never collapse into the same shape. + // ══════════════════════════════════════════════════════════════════ + const isInner = fract(fi.mul(0.001).add(0.5)).greaterThan(0.66); // ~34% inner + // Inner world = outer + 5, wrapped into 0..11 (a clearly different shape). + // Done with select() (no .mod()) so it's valid in this TSL build. + const innerSum = float(this.uWorld).add(5); + const innerWorldIdx = select(innerSum.greaterThan(11), innerSum.sub(12), innerSum); + const innerRaw = homeFor(innerWorldIdx); + // Counter-rotate the inner figure about Y so it spins against the shell, + // and scale it down to sit inside. cos/sin build a Y-rotation matrix. + const ia = this.uTime.mul(0.4); + const ic = cos(ia); + const is = sin(ia); + const innerRot = vec3( + innerRaw.x.mul(ic).sub(innerRaw.z.mul(is)), + innerRaw.y, + innerRaw.x.mul(is).add(innerRaw.z.mul(ic)) + ); + const innerHome = innerRot.mul(0.52); // nested core at ~52% scale (spread β†’ less white) + + // ── INFINITE ZOOM DIVE ── two layers ride offset phases of fract(uTime/T): + // the outer grows pow(Ξ», phase) toward the camera then snaps back (invisible + // because inner@1/Ξ» == outer@1); the inner (half-period offset) grows from + // its own base, promoted by Ξ» so it becomes the next outer shell. uZoomOn + // gates it β†’ beats 0/1 + reduced-motion stay perfectly still. + const zPhaseO = this.uTime.div(this.uZoomPeriod).fract(); + const zPhaseI = this.uTime.div(this.uZoomPeriod).add(0.5).fract(); + const zoomO = mix(float(1.0), this.uLambda.pow(zPhaseO), this.uZoomOn); + const zoomI = mix(float(1.0), this.uLambda.pow(zPhaseI), this.uZoomOn); + const outerDive = outerHome.mul(zoomO); + const innerDive = innerHome.mul(zoomI.mul(this.uLambda)); // inner promoted by Ξ» each cycle + // Each particle is permanently outer OR inner (no flicker): pick its home. + const home = mix(outerDive, innerDive, isInner.select(float(1), float(0))); + + // ── DETONATION: per-particle staggered radial blast so it blooms as a + // shockwave, not all-at-once. uBurst spikes on each beat, decays fast. + const outDir = pos.normalize(); + const stagger = oneMinus(fract(phase.mul(7.3)).mul(0.4)); + vel.addAssign(outDir.mul(this.uBurst.mul(0.95).mul(stagger))); + + // ── REFORM SPRING toward the (blended) world home ── + vel.addAssign(home.sub(pos).mul(0.045)); + + // ── PER-WORLD MOTION MODIFIERS (added to the spring) ── + // worlds 0 & 5: curl turbulence (living mist / liquid arms) + const curlV = curl(pos.mul(0.045).add(vec3(0, this.uTime.mul(0.2), 0))); + const curlAmt = select(this.uWorld.equal(0), float(0.05), + select(this.uWorld.equal(5), float(0.06), float(0.0))); + vel.addAssign(curlV.mul(curlAmt)); + // world 1: orbital spin around Y (cross product β†’ orbit, not collapse) + vel.addAssign(cross(vec3(0, 1, 0), pos).mul(0.0009).mul(select(this.uWorld.equal(1), float(1), float(0)))); + // world 2: integrate the Thomas attractor (chaos lattice) + vel.addAssign(thomas.mul(0.012).mul(select(this.uWorld.equal(2), float(1), float(0)))); + // world 10: integrate the AIZAWA vector field so the shell breathes/spirals + // along the real attractor flow (not a static torus). + const azx = pos.x.div(R.mul(0.5)); + const azy = pos.y.div(R.mul(0.5)); + const azz = pos.z.div(R.mul(0.5)); + const aizawa = vec3( + azz.sub(0.7).mul(azx).sub(azy.mul(3.5)), + azx.mul(3.5).add(azz.sub(0.7).mul(azy)), + float(0.6).add(azz.mul(0.95)).sub(azz.mul(azz).mul(azz).div(3)).sub(azx.mul(azx).add(azy.mul(azy))) + ); + vel.addAssign(aizawa.mul(0.008).mul(select(this.uWorld.equal(10), float(1), float(0)))); + // world 5: tangential swirl for liquid galaxy arms + vel.addAssign(cross(vec3(0, 1, 0), pos).mul(0.0016).mul(select(this.uWorld.equal(5), float(1), float(0)))); + + // Subtle living shimmer (mean-zero, no net drift). + const shimmer = home.normalize().mul(sin(this.uTime.mul(1.3).add(phase.mul(6.1))).mul(0.015)); + vel.addAssign(shimmer); + + // Hard velocity clamp β€” nothing can ever fly off or blow up. + const speed = length(vel); + const maxSpeed = float(1.3); + vel.assign(vel.mul(min(maxSpeed, speed).div(speed.max(0.0001)))); + + pos.addAssign(vel); + vel.mulAssign(0.9); // strong damping β†’ crisp crystallization, no overshoot + + // ── PIXELATION: voxel snap as particles crystallize (low burst). World 4 + // (crystal lattice) pushes it hardest for the holographic shard look. + const crystalBoost = select(this.uWorld.equal(4), float(1.6), float(1.0)); + const cell = mix(float(0.55), float(6.0), clamp(this.uBurst, 0, 1)); + const quantized = floor(pos.div(cell)).add(0.5).mul(cell); + const pixelAmt = clamp(oneMinus(this.uBurst.mul(1.4)), 0, 0.9).mul(crystalBoost).min(0.9); + pos.assign(mix(pos, quantized, pixelAmt)); + + // Final hard safety net: clamp anything past the contain radius back + // onto the boundary shell β€” guarantees nothing is ever off-screen. + const finalDist = length(pos); + const hardR = this.uContainRadius; + const snapped = pos.normalize().mul(hardR); + pos.assign(mix(pos, snapped, finalDist.greaterThan(hardR).select(float(1), float(0)))); + })().compute(this.count); + } + + private buildRender(bufferPos: StorageBufferAttribute, bufferPhase: StorageBufferAttribute): void { + // SpriteNodeMaterial: emissive routed to bloom; additive against the void. + const mat = new SpriteNodeMaterial({ + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) as SpriteNodeMaterial & { + positionNode: unknown; + colorNode: unknown; + emissiveNode: unknown; + }; + + // CANONICAL SPRITE CENTER: positionNode is the sprite's CENTER only. + // SpriteNodeMaterial.setupPositionView already builds the billboard quad + // from positionGeometry.xy (scaled by scaleNode, rotated by rotationNode), + // so the previous `.add(positionLocal)` double-counted the quad (harmless at + // the 0.1 size, sub-pixel). Using the bare center is required for the + // velocity-stretch streak (scaleNode/rotationNode now drive the quad shape). + const phaseStore = storage(bufferPhase, 'float', this.count); + const instancePos = storage(bufferPos, 'vec3', this.count).element(instanceIndex); + mat.positionNode = instancePos; + + // ── VELOCITY-STRETCH STREAK (scaleNode + rotationNode) ── a SEPARATE output + // graph from color/emissive: it reads only camera-velocity uniforms + + // phaseStore, NEVER positionView (a 2nd positionView read in a material + // output would stack-overflow three@0.172's node builder). Zero per-frame + // compute β€” the camera velocity is one uniform pushed from the sandbox. + const matStretch = mat as typeof mat & { scaleNode: unknown; rotationNode: unknown }; + { + // Screen-plane apparent particle velocity (view space x/y). speedβ‰₯Ξ΅ so + // length()/atan() stay finite when the camera is still. + const velView = vec3(this.uCamVelView); + const vScreen = vec2(velView.x, velView.y); + const speed = length(vScreen).max(1e-4); + // Per-particle jitter (0.75..1.25) so streaks don't all stretch identically. + const jit = fract(phaseStore.element(instanceIndex).mul(13.17)).mul(0.5).add(0.75); + // Orient the quad's long axis along the motion vector. atan(y, x) is the + // 2-arg form (full -Ο€..Ο€ range), NOT atan2. + matStretch.rotationNode = atan(vScreen.y, vScreen.x); + // X-stretch: 1 (no streak) β†’ uMaxStretch, scaled by speed Γ— strength Γ— jitter. + const stretch = clamp(speed.mul(this.uStreak).mul(jit).mul(0.6).add(1.0), float(1.0), this.uMaxStretch); + // bloat = DOF circle-of-confusion base (DOF brightness is in depthFade; + // the sprite-scale bloat is the neutral 1.0 here). Streak elongates X only; + // bloat stays on both axes so the DOF circle is preserved. + const bloat = float(1.0); + matStretch.scaleNode = vec2(bloat.mul(stretch), bloat); + } + + // ── SHARED RAINBOW COLOR ── + // One Fn produces the pure iridescent color for a particle; we feed it to + // BOTH colorNode (the lit/additive surface color) AND emissiveNode (the + // channel the selective MRT bloom reads). The original code only set + // colorNode, so the bloom had NO color to bloom β€” it washed the frame to + // white. Routing the SAME rainbow to emissive makes the bloom glow in full + // spectral color, which is the whole point. + + // ── IQ COSINE PALETTE ── one scalar t β†’ smooth, vivid, loopable color. + // color(t) = a + bΒ·cos(2π·(cΒ·t + d)). The workhorse for per-world palettes + // and the spectral dispersion wave. + const palette = Fn( + ([t, a, b, c, d]: [ + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ]) => a.add(b.mul(cos(c.mul(t).add(d).mul(6.28318)))) + ); + + // ── BLACKBODY Kβ†’RGB ── real plasma-cooling color (Tanner-Helland approx). + // Drives the detonation: blue-white core (hot) cooling to red embers as the + // blast decays. if/else collapsed to select() for this TSL build. + const blackbody = Fn(([kelvin]: [ReturnType]) => { + const k = kelvin.div(100.0); + const rHot = pow(k.sub(60.0).max(0.0001), float(-0.1332047592)).mul(329.698727446); + const r = k.lessThanEqual(66.0).select(float(255.0), rHot); + const gCool = k.max(0.0001).log().mul(99.4708025861).sub(161.1195681661); + const gHot = pow(k.sub(60.0).max(0.0001), float(-0.0755148492)).mul(288.1221695283); + const g = k.lessThanEqual(66.0).select(gCool, gHot); + const bMid = k.sub(10.0).max(0.0001).log().mul(138.5177312231).sub(305.0447927307); + const b = k.greaterThanEqual(66.0).select( + float(255.0), + k.lessThanEqual(19.0).select(float(0.0), bMid) + ); + return clamp(vec3(r, g, b).div(255.0), 0, 1); + }); + + const rainbowColor = Fn(() => { + const pos = instancePos; + const ph = phaseStore.element(instanceIndex); + const radius = length(pos.sub(vec3(this.uTarget))); + + // Recompute the inner/outer layer split (same formula as the compute kernel). + const fiC = float(instanceIndex); + const isInnerC = fract(fiC.mul(0.001).add(0.5)).greaterThan(0.66); + + // A flowing texture coordinate per particle β€” drives gradients WITHIN each + // layer's duotone so it shimmers, but stays inside that layer's color world. + const spatialBand = pos.x.mul(0.03).add(pos.y.mul(0.021)).add(pos.z.mul(0.027)); + const flow = fract( + ph.mul(0.41).add(radius.mul(0.06)).add(spatialBand).add(this.uTime.mul(0.10)).add(this.uHueShift) + ); + + // ══════════════════════════════════════════════════════════════════ + // JARRING DUOTONE CLASH β€” the share hook. + // The outer shell and the inner nested figure are painted from OPPOSING + // color universes (ice vs fire, acid vs blood, gold vs violet…). Not a + // hue shift in one rainbow β€” two palettes that FIGHT. uClash (set per + // beat) picks which clashing pair is live, so it's a fresh jarring combo + // every beat. Each layer is a 2-color gradient (coldβ†’cold, hotβ†’hot) so + // the layer reads as ONE color world, and the two worlds collide at the + // boundary. THIS is what makes someone stop scrolling and share. + // ══════════════════════════════════════════════════════════════════ + // Five hand-picked clash pairs: [outerA, outerB, innerA, innerB]. + const cl = this.uClash; // 0..4, set per beat + // outer gradient endpoints + const outA = select(cl.equal(0), vec3(0.0, 0.85, 1.0), // ICE: electric cyan + select(cl.equal(1), vec3(0.55, 1.0, 0.0), // ACID lime + select(cl.equal(2), vec3(1.0, 0.82, 0.0), // GOLD + select(cl.equal(3), vec3(0.0, 1.0, 0.6), // MINT/emerald + vec3(0.1, 0.5, 1.0))))); // ELECTRIC blue + const outB = select(cl.equal(0), vec3(0.3, 0.2, 1.0), // ICEβ†’deep indigo + select(cl.equal(1), vec3(0.0, 0.7, 0.5), // ACIDβ†’teal + select(cl.equal(2), vec3(1.0, 0.4, 0.0), // GOLDβ†’amber + select(cl.equal(3), vec3(0.0, 0.6, 1.0), // MINTβ†’cyan + vec3(0.5, 0.0, 1.0))))); // ELECTRICβ†’violet + // inner gradient endpoints β€” the OPPOSING world + const inA = select(cl.equal(0), vec3(1.0, 0.25, 0.0), // FIRE: molten orange + select(cl.equal(1), vec3(1.0, 0.0, 0.55), // BLOOD: hot pink + select(cl.equal(2), vec3(0.6, 0.0, 1.0), // VIOLET + select(cl.equal(3), vec3(1.0, 0.1, 0.3), // CRIMSON + vec3(1.0, 0.7, 0.0))))); // GOLD + const inB = select(cl.equal(0), vec3(1.0, 0.0, 0.3), // FIREβ†’crimson + select(cl.equal(1), vec3(1.0, 0.45, 0.0), // BLOODβ†’orange + select(cl.equal(2), vec3(1.0, 0.0, 0.7), // VIOLETβ†’magenta + select(cl.equal(3), vec3(1.0, 0.5, 0.0), // CRIMSONβ†’amber + vec3(1.0, 0.2, 0.4))))); // GOLDβ†’rose + + // Each layer = a 2-stop gradient driven by `flow` (stays in its world). + const grad = smoothstep(float(0.0), float(1.0), flow); + const outerColor = mix(outA, outB, grad); + const innerColor = mix(inA, inB, grad); + // Hard pick by layer β†’ the clash is absolute at the boundary. + const rainbow = mix(outerColor, innerColor, isInnerC.select(float(1), float(0))); + + // Beat mode tint kept very light so it never muddies the clash. + const modeTint = select( + this.uMode.equal(2), + vec3(1.0, 0.08, 0.32), + select(this.uMode.equal(3), vec3(1.0, 0.78, 0.1), vec3(0.1, 0.9, 1.0)) + ); + return mix(rainbow, modeTint, this.uModeTintAmt.mul(0.4)); + }); + + // ── RIM GLOW ── THE look: bright glowing EDGES, dim center. + // The dense middle of each form (particles near the center axis, all + // stacking toward the camera) is what blooms to white. So we DIM the core + // and BLAZE the rim: brightness rises with a particle's radial distance + // from the form's center. Near center β†’ ~0.12 (deep, calm), at the outer + // shell β†’ ~1.0 (full blaze). The result is the glowing-shell / hollow-eye + // torus look β€” luminous silhouette, serene dark center. + const rimFactor = Fn(() => { + const pos = instancePos; + // Normalized radial position 0 (center) .. 1 (contain radius). + const rNorm = clamp(length(pos).div(this.uContainRadius.max(0.0001)), 0, 1); + // Smooth ramp: dark core, bright rim β€” the outer-shell glow that keeps the + // center from blooming white (preserved white-out protection). + const edge = rNorm.mul(rNorm); + // FACING-RATIO FRESNEL β€” the "make it solid" amplifier. Approximate each + // particle's surface normal as its outward radial direction; view β‰ˆ +Z. + // pow(1βˆ’|nΒ·v|, 4) blazes the turning-away SILHOUETTE and quiets the front, + // which flips "glowing fog" into a lit, sculpted SKIN. + const nrm = pos.normalize(); + const fres = pow(oneMinus(abs(nrm.z)), float(4.0)); + const outerRim = float(0.12).add(edge.mul(0.6)).add(fres.mul(0.5)); + // The NESTED inner figure lives at small radius where `edge` is ~0 β†’ it + // would be invisible. Give inner particles their OWN brightness: a higher + // floor + the same Fresnel silhouette so the inner figure reads as its own + // glowing sculpted object floating inside the shell. + const fiC = float(instanceIndex); + const isInnerC = fract(fiC.mul(0.001).add(0.5)).greaterThan(0.66); + // Inner glow kept VERY low so the nested figure's CLASH COLOR survives as + // color, not white. Dense small-radius overlap blows to white fast and + // kills the contrast β€” so the inner sits dim and saturated, carried by its + // Fresnel silhouette. This is what makes the jarring inner/outer clash read. + const innerRim = float(0.07).add(fres.mul(0.3)); + const baseRim = isInnerC.select(innerRim, outerRim); + + // ── ZOOM SEAM CROSS-FADE ── each dive layer must be fully transparent + // exactly when it snaps (phase 0/1), so the infinite loop has NO visible + // pop. sin(phaseΒ·Ο€) = 0 at the snap, 1 mid-cycle. Normalize by the sum so + // total opacity stays β‰ˆ1. uZoomOn=0 forces weight to 1 (no fade when still). + const pO = this.uTime.div(this.uZoomPeriod).fract(); + const pI = this.uTime.div(this.uZoomPeriod).add(0.5).fract(); + const wO = sin(pO.mul(3.14159)); + const wI = sin(pI.mul(3.14159)); + const wSum = wO.add(wI).max(0.0001); + const seamO = mix(float(1.0), wO.div(wSum).mul(2.0).min(1.0), this.uZoomOn); + const seamI = mix(float(1.0), wI.div(wSum).mul(2.0).min(1.0), this.uZoomOn); + const seam = isInnerC.select(seamI, seamO); + return baseRim.mul(seam); + }); + + // ── DEPTH FADE (near dissolve Γ— volumetric fog) ── ONE Fn, ONE positionView + // read. Reading positionView from a SECOND Fn in the same material output + // triggers a cyclic type-resolution stack-overflow in three@0.172's node + // builder, so near-fade AND fog are combined here into a single depth read. + // near: smoothstep 0β†’1 across [near, near+band] (β‰ˆ1 at far camera β†’ no + // change until we fly inside; dissolves sprites at the camera). + // fog: exp falloff dims distant particles toward the void β†’ 3D depth. + const depthFade = Fn(() => { + const d = positionView.z.negate(); // +forward view distance, read ONCE + const near = smoothstep(this.uFadeNear, this.uFadeNear.add(this.uFadeBand), d); + // Fog floor raised to 0.45 so the far field still READS β€” all four depth + // systems multiply, so each must dim gently or they stack to black. + const fog = clamp(this.uFogDensity.mul(d).negate().exp(), 0.45, 1.0); + // DOF: dim particles off the focus plane (defocus β†’ bokeh under bloom). + // coc 0 at focus β†’ 1 fully out of focus; brightness 1 β†’ (1-uDofDim). + const coc = clamp(d.sub(this.uFocus).abs().div(this.uFocusRange), 0, 1); + const focusBright = oneMinus(coc.mul(this.uDofDim)); + return near.mul(fog).mul(focusBright); + }); + + // ── THE COLOR BLAST ── the signature detonation chroma. Keyed on the LONG + // uBlast envelope (~2.8s) so the color OUTLIVES the physics burst (owner's + // "color too brief" fix). Two layers: a blackbody plasma core that cools as + // the blast ages, and an outward-traveling SPECTRAL DISPERSION WAVE β€” rainbow + // shockwave rings expanding through the radius over uBlastTime, like a prism + // shattering. The unexpected color blast nobody else ships. + const blastColor = Fn(() => { + const pos = instancePos; + const b = clamp(this.uBlast, 0, 1); + const bt = this.uBlastTime; + const rNorm = clamp(length(pos).div(this.uContainRadius.max(0.0001)), 0, 1); + // Blackbody embers: a WARM core (capped ~5200K so it's hot-orange, NOT + // blinding blue-white β€” the white-out the owner saw was a 13000K plasma + // flash). Gentle gain so it tints, never dominates. + const kelvin = mix(float(1600.0), float(5200.0), b); + const gain = clamp(b.mul(1.1).add(0.4), 0, 1.3); + const fire = blackbody(kelvin).mul(gain); + // THE STAR OF THE BLAST β€” an outward SPECTRAL DISPERSION shockwave: + // concentric rainbow rings travel out through the radius over time (red + // lags, blue leads β€” real prism order). This is the color, not the fire. + const specT = fract(rNorm.mul(1.6).sub(bt.mul(1.5))); + const spectrum = palette(specT, vec3(0.55), vec3(0.55), vec3(3.0), vec3(0.0, 0.33, 0.67)); + // Spectrum DOMINATES (0.78); a touch of warm fire underneath for energy. + // The owner wants a COLOR blast, so the rainbow wins over the plasma. + return mix(fire, spectrum, float(0.78)); + }); + + // colorNode: world color Γ— rim Γ— act dim, then the blast overrides toward + // detonation chroma at the peak and lingers (the long uBlast tail) before + // melting back into the next world's palette. + mat.colorNode = Fn(() => { + const glow = clamp(this.uIgnition.mul(0.05).add(0.72), 0, 1.25); // lifted: 4 depth systems multiply-dim + const world = rainbowColor().mul(glow).mul(rimFactor()).mul(this.uActDim); + // Blast is CAPPED at 0.6 so the inner/outer CLASH duotone always shows + // through even during a detonation β€” the clash is the star, the blast is + // an accent (was fully overriding, which washed the contrast to rainbow). + const blastMix = smoothstep(float(0.0), float(0.85), clamp(this.uBlast, 0, 1)).mul(0.6); + return mix(world, blastColor().mul(rimFactor()).mul(this.uActDim), blastMix).mul(depthFade()); + })(); + + // emissiveNode: what the selective bloom reads β€” THE glow channel. Rim-gated + // so ONLY the outer shell blooms (calm dark center, no white blob). The blast + // gain is held below the color path (Γ—0.85) so the bloom never clips white. + mat.emissiveNode = Fn(() => { + const emGain = clamp(this.uIgnition.mul(0.04).add(0.85), 0, 1.35); // lifted to feed the bloom through the depth dimming + const world = rainbowColor().mul(emGain).mul(rimFactor()).mul(this.uActDim); + // Same cap as colorNode so the bloom feeds on the CLASH colors, not a + // rainbow override. + const blastMix = smoothstep(float(0.0), float(0.85), clamp(this.uBlast, 0, 1)).mul(0.6); + // Γ—nearFade so the bloom ALSO dissolves near the camera (no near-plane flash). + return mix(world, blastColor().mul(0.85).mul(rimFactor()).mul(this.uActDim), blastMix).mul(depthFade()); + })(); + + // One instanced sprite per particle. Small quads (0.1) keep individual + // particles as crisp colored points of light rather than overlapping into + // white mush across the now-larger volume. + const geometry = new THREE.PlaneGeometry(0.1, 0.1); + const mesh = new THREE.InstancedMesh(geometry, mat as unknown as THREE.Material, this.count); + mesh.frustumCulled = false; + this.material = mat; + this.mesh = mesh; + this.scene.add(this.mesh); + } + + /** Advance the GPU physics one frame. Compute dispatches are serialized so + * a slow GPU never lets passes pile up and stall the queue. */ + async update(deltaSeconds: number): Promise { + const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); + this.uTime.value += dt; + // Slowly rotate the whole rainbow so the cloud is always shimmering. + this.uHueShift.value = (this.uHueShift.value + dt * 0.06) % 1; + // World crossfade: ease uBlend 1β†’0 over ~1s after each beat so the cloud + // melts from the previous world's home/forces into the new one's. + this.uBlend.value = Math.max(0, this.uBlend.value - dt * 1.0); + // Ignition decays toward 0 between beats (spikes back up on transitionTo()). + this.uIgnition.value = Math.max(0, this.uIgnition.value - dt * 2.0); + // Burst decays fast so the explosion crystallizes back within ~1.2s. + this.uBurst.value = Math.max(0, this.uBurst.value - dt * 0.85); + // COLOR BLAST: slow decay (~2.8s) so the detonation chroma LASTS, and the + // wave clock counts up so the spectral shockwave travels outward over time. + this.uBlast.value = Math.max(0, this.uBlast.value - dt * 0.35); + this.uBlastTime.value += dt; + // RACK FOCUS β€” when diving, the focus plane tracks the descent (pulls inward + // over each zoom cycle so the new inner figure crisps up as it grows); when + // still, a gentle breathing pull keeps the DOF alive. + const zp = (this.uTime.value / this.uZoomPeriod.value) % 1; + const focusTarget = this.uZoomOn.value > 0.5 + ? 40 - zp * 16 // 40β†’24 over each dive cycle (keeps the grown figure in focus) + : 26 + Math.sin(this.uTime.value * 0.18) * 9; // 17..35 breathing + this.uFocus.value += (focusTarget - this.uFocus.value) * Math.min(1, dt * 3); + + // Wait for any in-flight compute to finish before queuing the next. + if (this.computeInFlight) await this.computeInFlight; + this.computeInFlight = this.renderer.computeAsync(this.computeNode).finally(() => { + this.computeInFlight = null; + }); + await this.computeInFlight; + } + + /** Fired on each narrative beat: retarget the storm + spike ignition. + * `act` blazes Acts II/III at full; `beatIndex` (0-based) holds the very first + * beats EXTRA dim β€” beats 0 and 1 fire while the cloud is still bunched from + * the initial reform and would otherwise wash to white. They ramp up to full + * over the opening, so the storm fades IN beautifully instead of flashing. */ + transitionTo( + role: SemanticRole, + worldPos: THREE.Vector3, + act: 'I' | 'II' | 'III' = 'II', + beatIndex = 99 + ): void { + this.uTarget.value.copy(worldPos); + const mode = ROLE_MODE[role] ?? 1; + this.uMode.value = mode; + + // WORLD ADVANCE: beats map 1:1 to the 7 worlds. Record the outgoing world + // and reset the crossfade so the cloud melts prevβ†’new over ~1s. + this.uPrevWorld.value = this.uWorld.value; + this.uWorld.value = beatIndex % this.worldCount; + this.uBlend.value = 1; // 1 = fully previous; update() eases it to 0 + // Cycle the jarring inner/outer clash pair so each beat collides a fresh + // pair of opposing color worlds (ice↔fire, acid↔blood, …). + this.uClash.value = beatIndex % 5; + // Engage the infinite dive from Act II onward (beats 0/1 stay calm + still). + this.uZoomOn.value = beatIndex >= 2 ? 1 : 0; + + // Per-beat warm-up dim: beats 0/1 stay calm (dialed-in safety), then hands + // off to the act-based brightness. Acts II/III blaze. + const warmup = beatIndex === 0 ? 0.12 : beatIndex === 1 ? 0.2 : null; + const actDim = act === 'I' ? 0.26 : 1.0; + this.uActDim.value = warmup ?? actDim; + // Ignition flash: nearly none on beats 0/1, gentle for the rest of Act I, + // strong (but no longer white-blowing) for Acts II/III β€” lowered from 8.0 to + // 4.5 so the inner/outer CLASH colors survive the detonation instead of + // washing to white. + this.uIgnition.value = beatIndex <= 1 ? 0.4 : act === 'I' ? 1.6 : 4.5; + // PHYSICS BURST (fast) β€” contradiction + the DETONATION world (3) hit hardest. + const isDetonation = this.uWorld.value === 3; + this.uBurst.value = mode === 2 || isDetonation ? 1.0 : 0.8; + // COLOR BLAST (LONG) β€” fire the chroma envelope + reset its outward-wave + // clock. Beats 0/1 keep it very low so the calm opener never flashes. + this.uBlast.value = beatIndex <= 1 ? 0.25 : 1.0; + this.uBlastTime.value = 0; + // Dramatic beats (contradiction=2, surprise=3) push their mode color over + // the rainbow so they read clearly; calm beats stay mostly iridescent. + this.uModeTintAmt.value = mode >= 2 ? 0.7 : 0.22; + } + + /** + * ENDLESS DREAM BEAT β€” fired on a timer AFTER the scripted tour ends, so the + * storm never sits idle. Jumps to a RANDOM procedural figure (worlds 7..11), + * reseeds it (so it's never the same shape twice), ramps uChaos up so each one + * is wilder than the last, and detonates a full color blast. This is the + * "random figure generator that makes even crazier beats." + */ + dreamBeat(): void { + this.dreamCount += 1; + // Pick a random wild figure (worlds 7..11 are the procedural generators). + const world = 7 + Math.floor(Math.random() * 5); + this.uPrevWorld.value = this.uWorld.value; + this.uWorld.value = world; + this.uBlend.value = 1; + // Fresh random seed β†’ the superformula/knot/lissajous/helix/foam params all + // change, so the same world index never looks the same twice. + this.uMorphSeed.value = Math.random() * 1000; + // Random opposing clash pair each dream figure β†’ never the same collision. + this.uClash.value = Math.floor(Math.random() * 5); + // Chaos ramps up and saturates β€” figures get progressively crazier, then + // hold at max wildness. Eases in over the first ~8 dream beats. + this.uChaos.value = Math.min(1, 0.25 + this.dreamCount * 0.1); + // Dream mode dives endlessly β€” the infinite zoom is always on here. + this.uZoomOn.value = 1; + // Detonation + long color blast every dream beat β€” but ignition kept + // MODERATE (not the tour's 8.0) so the random dense figures don't wash to + // white at the blast peak. The rim-gated spectral blast carries the color. + this.uActDim.value = 0.85; + this.uIgnition.value = 3.0; + this.uBurst.value = 1.0; + this.uBlast.value = 1.0; + this.uBlastTime.value = 0; + // Vary the mode tint randomly too so the palette keeps surprising. + const modes = [1, 2, 3]; + this.uMode.value = modes[Math.floor(Math.random() * modes.length)]; + this.uModeTintAmt.value = 0.3 + Math.random() * 0.5; + } + + /** Size the containment sphere (world units) so the storm always stays in + * frame. The sandbox derives this from the camera distance + fov. */ + setContainRadius(radius: number): void { + this.uContainRadius.value = Math.max(8, radius); + } + + /** Enable/disable the infinite Droste zoom dive. Off for beats 0/1 (calm) and + * reduced-motion; on for Act II+ and dream mode. */ + setZoom(on: boolean): void { + this.uZoomOn.value = on ? 1 : 0; + } + + /** View-space apparent particle velocity (the negated, view-transformed camera + * velocity). Drives the streak orientation + length. */ + setCameraVel(v: THREE.Vector3): void { + this.uCamVelView.value.copy(v); + } + + /** Flythrough streak strength 0..1. Set to 0 for reduced-motion (no streak). */ + setStreak(s: number): void { + this.uStreak.value = s; + } + + dispose(): void { + if (this.mesh) { + this.scene.remove(this.mesh); + this.mesh.geometry?.dispose(); + this.mesh.dispose?.(); + this.mesh = null; + } + this.material?.dispose(); + this.material = null; + // StorageBufferAttribute extends BufferAttribute, which has no dispose(): + // its GPU buffer is released by the renderer when the owning geometry is + // disposed (done above). Drop our references so the ~2.1MB of backing + // Float32Arrays can be garbage-collected. + this.bufferPos = null; + this.bufferVel = null; + this.bufferPhase = null; + } +} diff --git a/apps/dashboard/src/lib/graph/cinema/topology.ts b/apps/dashboard/src/lib/graph/cinema/topology.ts new file mode 100644 index 0000000..5518b39 --- /dev/null +++ b/apps/dashboard/src/lib/graph/cinema/topology.ts @@ -0,0 +1,225 @@ +// The Auteur β€” graph signal extraction. +// +// Pure, dependency-free statistics over the REAL /api/graph data, computed once +// per Cinema launch. These signals are what gives the AI director something +// meaningful to direct: which memory is most load-bearing (betweenness), where +// tension lives (contradictions), what's surprising (distant-but-plausible +// links), what's fading (low retention / suppression). No LLM, no WebGPU, no +// network β€” fully headless-testable. + +import type { GraphNode, GraphEdge } from '$types'; +import { buildAdjacency, recencyOf, isContradictionEdge } from './pathfinder'; + +export interface NodeSignal { + nodeId: string; + /** Raw connection count. */ + degree: number; + /** Brandes betweenness centrality, normalized 0..1 β€” how load-bearing this + * memory is as a bridge between clusters. The director favors high-betweenness + * nodes for hero shots. */ + betweenness: number; + /** Connected-component id (which cluster of memory this belongs to). */ + clusterId: number; + /** 0..1, 1 = most recent. */ + recencyRank: number; + /** FSRS retention 0..1. */ + retention: number; + /** Suppression pressure 0..1 (memory actively being forgotten). */ + suppression: number; +} + +export interface EdgeSignal { + source: string; + target: string; + isContradiction: boolean; + isMergeSupersede: boolean; + /** 0..1: high when endpoints share neighbors (plausible) yet the edge weight + * is low (distant) β€” a surprising, non-obvious connection. */ + surprise: number; + weight: number; +} + +export interface GraphSignals { + nodes: Map; + edges: EdgeSignal[]; + clusterCount: number; + /** Node id with the single highest betweenness β€” the graph's keystone. */ + peakBetweennessId: string; +} + +function isMergeSupersedeEdge(edge: GraphEdge): boolean { + const t = (edge.type ?? '').toLowerCase(); + return t.includes('merge') || t.includes('supersede') || t.includes('duplicate'); +} + +/** + * Brandes' algorithm for betweenness centrality on an unweighted, undirected + * graph. O(VΒ·E) β€” fine for /api/graph payloads. Returns raw (unnormalized) + * scores keyed by node id; the caller normalizes. + */ +function brandesBetweenness(nodeIds: string[], adj: Record): Map { + const cb = new Map(); + for (const v of nodeIds) cb.set(v, 0); + + for (const s of nodeIds) { + const stack: string[] = []; + const pred = new Map(); + const sigma = new Map(); + const dist = new Map(); + for (const v of nodeIds) { + pred.set(v, []); + sigma.set(v, 0); + dist.set(v, -1); + } + sigma.set(s, 1); + dist.set(s, 0); + + // BFS (unweighted shortest paths). + const queue: string[] = [s]; + let head = 0; + while (head < queue.length) { + const v = queue[head++]; + stack.push(v); + for (const { otherId: w } of adj[v] ?? []) { + if ((dist.get(w) ?? -1) < 0) { + dist.set(w, (dist.get(v) ?? 0) + 1); + queue.push(w); + } + if ((dist.get(w) ?? -1) === (dist.get(v) ?? 0) + 1) { + sigma.set(w, (sigma.get(w) ?? 0) + (sigma.get(v) ?? 0)); + pred.get(w)!.push(v); + } + } + } + + // Accumulation (back-propagate dependencies). + const delta = new Map(); + for (const v of nodeIds) delta.set(v, 0); + while (stack.length > 0) { + const w = stack.pop()!; + for (const v of pred.get(w) ?? []) { + const c = ((sigma.get(v) ?? 0) / (sigma.get(w) || 1)) * (1 + (delta.get(w) ?? 0)); + delta.set(v, (delta.get(v) ?? 0) + c); + } + if (w !== s) cb.set(w, (cb.get(w) ?? 0) + (delta.get(w) ?? 0)); + } + } + return cb; +} + +/** Union-find connected components β†’ a cluster id per node. */ +function components(nodeIds: string[], edges: GraphEdge[]): { clusterOf: Map; count: number } { + const parent = new Map(); + for (const id of nodeIds) parent.set(id, id); + const find = (x: string): string => { + let root = x; + while (parent.get(root) !== root) root = parent.get(root)!; + // Path compression. + let cur = x; + while (parent.get(cur) !== root) { + const next = parent.get(cur)!; + parent.set(cur, root); + cur = next; + } + return root; + }; + const union = (a: string, b: string) => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(ra, rb); + }; + for (const e of edges) { + if (parent.has(e.source) && parent.has(e.target)) union(e.source, e.target); + } + const rootToCluster = new Map(); + const clusterOf = new Map(); + let next = 0; + for (const id of nodeIds) { + const r = find(id); + if (!rootToCluster.has(r)) rootToCluster.set(r, next++); + clusterOf.set(id, rootToCluster.get(r)!); + } + return { clusterOf, count: next }; +} + +/** + * Compute all director signals from the real graph. Pure; safe to call once at + * launch. Caps betweenness work on very large graphs by limiting to the + * top-degree subset (the only nodes that can carry meaningful centrality). + */ +export function computeSignals(nodes: GraphNode[], edges: GraphEdge[]): GraphSignals { + const nodeIds = nodes.map((n) => n.id); + const adj = buildAdjacency(edges); + + // Recency ranking (0..1, 1 = newest). + const byRecency = [...nodes].sort((a, b) => recencyOf(a) - recencyOf(b)); + const recencyRank = new Map(); + byRecency.forEach((n, i) => recencyRank.set(n.id, nodes.length > 1 ? i / (nodes.length - 1) : 1)); + + // Betweenness β€” guard pathological sizes: above the cap, compute on the + // top-degree subset (others get 0; they can't be meaningful bridges anyway). + const BETWEENNESS_CAP = 600; + let betweennessNodes = nodeIds; + if (nodeIds.length > BETWEENNESS_CAP) { + betweennessNodes = [...nodeIds] + .sort((a, b) => (adj[b]?.length ?? 0) - (adj[a]?.length ?? 0)) + .slice(0, BETWEENNESS_CAP); + } + const rawBetween = brandesBetweenness(betweennessNodes, adj); + let maxBetween = 0; + for (const v of rawBetween.values()) maxBetween = Math.max(maxBetween, v); + + const { clusterOf, count: clusterCount } = components(nodeIds, edges); + + const maxSuppression = Math.max(1, ...nodes.map((n) => n.suppression_count ?? 0)); + + const nodeSignals = new Map(); + let peakBetweennessId = nodeIds[0] ?? ''; + let peakVal = -1; + for (const n of nodes) { + const bt = maxBetween > 0 ? (rawBetween.get(n.id) ?? 0) / maxBetween : 0; + if (bt > peakVal) { + peakVal = bt; + peakBetweennessId = n.id; + } + nodeSignals.set(n.id, { + nodeId: n.id, + degree: adj[n.id]?.length ?? 0, + betweenness: bt, + clusterId: clusterOf.get(n.id) ?? 0, + recencyRank: recencyRank.get(n.id) ?? 0, + retention: clamp01(n.retention ?? 0), + suppression: clamp01((n.suppression_count ?? 0) / maxSuppression), + }); + } + + // Edge signals incl. surprise (shared-neighbor overlap Γ— edge distance). + const neighborSets = new Map>(); + for (const id of nodeIds) neighborSets.set(id, new Set((adj[id] ?? []).map((a) => a.otherId))); + const edgeSignals: EdgeSignal[] = edges.map((e) => { + const a = neighborSets.get(e.source); + const b = neighborSets.get(e.target); + let shared = 0; + if (a && b) { + const [small, large] = a.size < b.size ? [a, b] : [b, a]; + for (const x of small) if (large.has(x)) shared++; + } + const union = (a?.size ?? 0) + (b?.size ?? 0) - shared || 1; + const overlap = shared / union; // Jaccard: structural plausibility. + const distance = 1 - clamp01(e.weight ?? 0); // low weight = semantically distant. + return { + source: e.source, + target: e.target, + isContradiction: isContradictionEdge(e), + isMergeSupersede: isMergeSupersedeEdge(e), + surprise: clamp01(overlap * distance * 2), // plausible AND distant = surprising. + weight: e.weight ?? 0, + }; + }); + + return { nodes: nodeSignals, edges: edgeSignals, clusterCount, peakBetweennessId }; +} + +function clamp01(x: number): number { + return Math.max(0, Math.min(1, Number.isFinite(x) ? x : 0)); +} diff --git a/apps/dashboard/src/lib/stores/websocket.ts b/apps/dashboard/src/lib/stores/websocket.ts index 8554581..fda02f9 100644 --- a/apps/dashboard/src/lib/stores/websocket.ts +++ b/apps/dashboard/src/lib/stores/websocket.ts @@ -6,11 +6,13 @@ const MAX_EVENTS = 200; function createWebSocketStore() { const { subscribe, set, update } = writable<{ connected: boolean; + reconnecting: boolean; events: VestigeEvent[]; lastHeartbeat: VestigeEvent | null; error: string | null; }>({ connected: false, + reconnecting: false, events: [], lastHeartbeat: null, error: null @@ -32,7 +34,7 @@ function createWebSocketStore() { ws.onopen = () => { reconnectAttempts = 0; - update(s => ({ ...s, connected: true, error: null })); + update(s => ({ ...s, connected: true, reconnecting: false, error: null })); }; ws.onmessage = (event) => { @@ -65,6 +67,7 @@ function createWebSocketStore() { function scheduleReconnect(url: string) { if (reconnectTimer) clearTimeout(reconnectTimer); + update(s => ({ ...s, reconnecting: true })); const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000); reconnectAttempts++; reconnectTimer = setTimeout(() => connect(url), delay); @@ -74,7 +77,7 @@ function createWebSocketStore() { if (reconnectTimer) clearTimeout(reconnectTimer); ws?.close(); ws = null; - set({ connected: false, events: [], lastHeartbeat: null, error: null }); + set({ connected: false, reconnecting: false, events: [], lastHeartbeat: null, error: null }); } function clearEvents() { @@ -108,6 +111,7 @@ export const websocket = createWebSocketStore(); // Derived stores for specific event types export const isConnected = derived(websocket, $ws => $ws.connected); +export const isReconnecting = derived(websocket, $ws => $ws.reconnecting); export const eventFeed = derived(websocket, $ws => $ws.events); export const heartbeat = derived(websocket, $ws => $ws.lastHeartbeat); export const memoryCount = derived(websocket, $ws => diff --git a/apps/dashboard/src/routes/(app)/activation/+page.svelte b/apps/dashboard/src/routes/(app)/activation/+page.svelte index 2433e43..ed4bcc0 100644 --- a/apps/dashboard/src/routes/(app)/activation/+page.svelte +++ b/apps/dashboard/src/routes/(app)/activation/+page.svelte @@ -24,6 +24,11 @@ } from '$components/ActivationNetwork.svelte'; import { filterNewSpreadEvents } from '$components/activation-helpers'; import type { Memory, VestigeEvent } from '$types'; + import PageHeader from '$lib/components/PageHeader.svelte'; + import Icon from '$lib/components/Icon.svelte'; + import AnimatedNumber from '$lib/components/AnimatedNumber.svelte'; + import { reveal } from '$lib/actions/reveal'; + import { spotlight, magnetic } from '$lib/actions/interactions'; let searchQuery = $state(''); let loading = $state(false); @@ -180,19 +185,30 @@ }); -
-
-

Spreading Activation

-

- Collins & Loftus 1975 β€” activation spreads from a seed memory to - neighbours along semantic edges, decaying by 0.93 per animation frame - until it drops below 0.05. Search seeds a focused burst; live mode - overlays every spread event fired by the cognitive engine in real time. -

-
+
+ +
+ + {liveEnabled ? 'Live' : 'Paused'} + Β· + + bursts +
+
-
+
Seed Memory
@@ -236,8 +254,8 @@ {#if loading}
-
β—Ž
-

Computing activation...

+
+

Computing activation…

{:else if errorMessage} @@ -250,8 +268,8 @@
{:else if !focusedSource && searched}
-
-
β—¬
+
+

No matching memory

Nothing in the graph matches @@ -263,8 +281,8 @@

{:else if !focusedSource}
-
-
β—Ž
+
+

Waiting for activation

Seed a burst with the search bar above, or enable live mode to diff --git a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte index 13ee04e..4415f0e 100644 --- a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte +++ b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte @@ -1,5 +1,10 @@

-

Explore Connections

+
{#each (['associations', 'chains', 'bridges'] as const) as m} @@ -144,29 +154,51 @@ {#if sourceMemory} {#if loading} -
-
β—Ž
-

Exploring {mode}...

+
+
+ + Exploring {mode}… +
+
+ {#each Array(4) as _, i} +
+
+
+
+
+
+
+ {/each} +
{:else if associations.length > 0}
-

{associations.length} Connections Found

+

+ + Connections Found +

- {#each associations as assoc, i} -
-
- {i + 1} -
-
-

{assoc.content}

-
- {#if assoc.nodeType}{assoc.nodeType}{/if} - {#if assoc.score}Score: {Number(assoc.score).toFixed(3)}{/if} - {#if assoc.similarity}Similarity: {Number(assoc.similarity).toFixed(3)}{/if} - {#if assoc.retention}{(Number(assoc.retention) * 100).toFixed(0)}% retention{/if} - {#if assoc.connectionType}{assoc.connectionType}{/if} + {#each associations as assoc, i (i)} +
+
+
+ {i + 1} +
+
+

{assoc.content}

+
+ {#if assoc.nodeType}{assoc.nodeType}{/if} + {#if assoc.score}Score: {Number(assoc.score).toFixed(3)}{/if} + {#if assoc.similarity}Similarity: {Number(assoc.similarity).toFixed(3)}{/if} + {#if assoc.retention}{(Number(assoc.retention) * 100).toFixed(0)}% retention{/if} + {#if assoc.connectionType}{assoc.connectionType}{/if} +
@@ -174,16 +206,26 @@
{:else} -
-
β—¬
-

No connections found for this query.

+
+ +

No connections surfaced yet

+

+ {#if mode === 'associations'} + This memory hasn't formed strong links here. Try a broader source query β€” the graph rewards more general seeds. + {:else} + No {mode} found between these two memories. Pick a different source or target and the path may light up. + {/if} +

{/if} {/if}
-

Importance Scorer

+

+ + Importance Scorer +

4-channel neuroscience scoring: novelty, arousal, reward, attention